Skip to content

Lanes

Lanes are parallel work streams that organize work by domain and prevent overload through WIP (Work-in-Progress) limits. At the kernel level, lanes also define scope boundaries — the scope intersection algorithm uses lane code_paths as one of four permission levels that determine what an agent can access.

Without lanes:

  • Everything competes for attention
  • Context switching kills productivity
  • Bottlenecks form unpredictably

With lanes:

  • Work is organized by domain
  • Each lane has focused attention
  • Optional WIP limits prevent overload where you want them

Lane design is an explicit lifecycle process:

unconfigured -> draft -> locked

lumenflow init bootstraps tooling and sets lane lifecycle to unconfigured. Before creating delivery WUs, run:

pnpm lane:setup
pnpm lane:validate
pnpm lane:lock

Check current state any time:

pnpm lane:status

Lanes use a Parent: Sublane naming convention. Create and edit them through the lifecycle commands so kernel scopes and delivery code paths remain synchronized:

pnpm lane:create \
  --name "Framework: Core Lifecycle" \
  --wip-limit 2 \
  --add-path "core/**" \
  --add-path "runtime/**"

pnpm lane:edit \
  --name "Framework: Core Lifecycle" \
  --add-path "plugins/**"

lane:edit writes both places a lane is described — the workspace lane scopes and the delivery code paths — under one validation and one commit. If either rejects the edit, neither is changed.

ParentPurpose
FrameworkCore libraries, CLI, memory, agents
OperationsInfrastructure, CI/CD, tooling
ContentDocumentation, guides
ExperienceUI components, pages

WIP (Work-in-Progress) limits control how many active WUs a lane can have simultaneously. Lane WIP is unlimited by default. A lane that sets neither wip_limit nor lock_policy takes no lane lock, runs no WIP check, and accepts any number of concurrent claims.

Why this is safe: overlap on ordinary source is advisory at claim and is resolved at wu:rebase or the wu:done auto-rebase, which re-gates the rebased tree. Host load is bounded by gate admission and the memory start guard, not by lanes.

The effective policy is resolved in one place:

  1. An explicit lock_policy wins.
  2. Otherwise an explicit wip_limit is enforced with lock_policy: all: a counting lock with wip_limit slots.
  3. Otherwise lock_policy: none (unlimited).

An explicit lock_policy: all or active with no wip_limit enforces a limit of 1.

To make a lane serial, or to cap it, set a limit. --wip-justification is an optional note recording why the lane sets an explicit limit:

pnpm lane:edit \
  --name "Framework: Core Lifecycle" \
  --wip-limit 1 \
  --wip-justification "shared generated files; keep this lane serial"

Existing configs that set wip_limit or lock_policy keep their enforcement. lumenflow init, lane:setup, lane:create and lane:suggest no longer write wip_limit: 1.

Parallel work across lanes: Each lane operates independently, so work in several lanes proceeds concurrently whatever their limits.

The lock_policy field controls how blocked WUs affect lane availability:

software_delivery:
  lanes:
    definitions:
      - name: 'Content: Documentation'
        wip_limit: 1
        lock_policy: active
        code_paths:
          - 'docs/**'
PolicyBehavior
allDefault when wip_limit is set. in_progress and blocked WUs count toward WIP and retain the lane lock.
activeOnly in_progress WUs count toward WIP. Blocking a WU releases its lane lock and WIP occupancy.
noneDefault when neither wip_limit nor lock_policy is set. Lane-lock acquisition and WIP occupancy checks are disabled.

Atomic lane locking, WIP occupancy, and code-path overlap queues are separate controls. A lane-lock refusal names the lock owner and current lock_policy; it is not evidence that the WIP preflight made the same decision.

  • --force affects only the WIP preflight. It cannot overwrite an existing atomic lane lock, so rerunning the same claim with --force does not resolve a lock refusal.
  • --wait-for-lane queues WUs only behind in-progress WUs whose code_paths overlap an unmergeable path (lockfiles, generated dist, ledgers, baselines; software_delivery.gates.unmergeable_paths). Ordinary-source overlap is advisory. It runs before lock acquisition and does not wait for an already-held lane lock.
  • Under the all policy, blocking the owner does not free the lane. Under active, a legitimate lifecycle block releases both the lock and WIP occupancy. Under none, no lock should be acquired.

Inspect the owner with pnpm wu:status --id <wu-id>. If the owner is genuinely abandoned, inspect the lock with pnpm wu:unlock-lane --lane "<lane>" --status, then follow the command it prints. The audited recovery surface requires a reason:

pnpm wu:unlock-lane --lane "<lane>" --reason "Confirmed abandoned owner <wu-id>"
  • none (default when no limit is set): Unlimited concurrent claims
  • all (default when wip_limit is set): High-conflict lanes where blocked work may resume soon
  • active: Low-conflict lanes like documentation where blocked work is unlikely to cause merge conflicts

Start with lock_policy: active on low-conflict lanes like Content: Documentation:

  1. Documentation WUs are low-conflict (different files, few dependencies)
  2. Blocked docs WUs rarely resume immediately
  3. Easy rollback: change active back to all in config

If lock_policy: active causes issues:

  1. Review the current definition with pnpm lane:list --json and update the governed lane policy through the workspace configuration process.
  2. Inspect pnpm wu:unlock-lane --lane "<lane>" --status.
  3. For a genuinely stale lock, run pnpm wu:unlock-lane --lane "<lane>" --reason "Lock policy rollback cleanup".

Lane locks are file-based counting locks that honour wip_limit. A lane with wip_limit: N owns N lock slots: <lane-kebab>.lock is slot 1 and <lane-kebab>.slot-<k>.lock are slots 2 to N, in the workspace lock directory. Each slot holds one WU and is created atomically, so N WUs can hold the lane at once and claim N+1 is refused with a diagnostic that names every holder. When N claims race for the last slot, exactly one wins. A wip_limit: 1 lane uses a single lock file, exactly as before. For lanes with lock_policy: none (including every lane that sets neither wip_limit nor lock_policy), lock checking is bypassed entirely. wu:unlock-lane --status and --list show every holder with its WU, session and timestamp; stale takeover, wu:unlock-lane and release all work per slot. The lock lifecycle has three key phases:

When wu:claim runs, it takes the first free slot by creating its lock file atomically using the wx (write-exclusive) flag. The lock file contains metadata including the WU ID, timestamp, agent session, and the PID of the claiming process.

Because wu:claim is a short-lived CLI process, the PID stored in the lock becomes invalid as soon as the claim completes. This is expected behavior — the lock persists on disk regardless of whether the original process is still running. The lock remains valid until explicitly released by wu:done or forcibly cleared.

Locks are released in three ways:

  1. Normal release: wu:done removes the lock after merging
  2. Stale zombie auto-clear: If a lock is both stale (older than 2 hours) AND the PID is no longer running, subsequent wu:claim calls auto-clear it. This handles genuinely abandoned locks from crashed processes.
  3. Manual unlock: inspect pnpm wu:unlock-lane --lane "<lane>" --status and the owner with pnpm wu:status --id <wu-id> before using the audited unlock. A recent dead claim PID is expected and is not sufficient to remove the lock without --force.

A “zombie lock” is one where the PID that created it is no longer running. However, a dead PID alone does NOT trigger auto-clearing. Since wu:claim exits immediately after creating the worktree, every lock will have a dead PID shortly after creation.

Auto-clearing requires BOTH conditions:

  • The lock PID is no longer running (zombie)
  • The lock is older than 2 hours (stale)

This prevents a second wu:claim from stealing a lane that was legitimately claimed by a recently exited wu:claim process.

Dead-Claim Exclusion from Lane WIP (Multi-Host)

Section titled “Dead-Claim Exclusion from Lane WIP (Multi-Host)”

A WU counted toward a lane’s WIP tally whose owner is gone would otherwise occupy the lane forever and silently block legitimate claims. LumenFlow runs on 1..N boxes and repos with git origin as the shared substrate — a claim’s worktree or local session file existing on THIS host proves nothing about a claim made on another host, or a cloud/branch-pr claim with no worktree at all. The lane-WIP check therefore never uses local-only evidence to decide a claim is dead, and never uses a lane branch or the tracked heartbeat ledger either — a worktree claim never pushes its lane branch or writes to origin/main’s ledger until wu:done, so both are permanently absent for every live claim.

Instead, wu:claim publishes a dedicated liveness marker — refs/lumenflow/claims/<wu-id> — on origin, and the governed dispatch heartbeat seam republishes it at the same 5-minute cadence the heartbeat ledger already uses. wu:done and wu:release delete it on cleanup. The delete is best-effort, so a failed one leaves the ref behind; wu:prune --execute removes the refs of done WUs in one push and reports the refs it considered, removed and kept (a dry-run lists them without removing).

A command run from the main checkout keeps that liveness fresh even when the claim’s record lives only on a split integration target (a push-only claim whose metadata target is not origin/main): the heartbeat routes to the claim’s own linked worktree, identified only by git worktree list (a registered worktree inside the main checkout whose own work unit record names the claim, with its claimed branch and pinned working branch equal to the registered branch and its worktree path equal to the registered path; a generation pinned in the main checkout must match). No match keeps the “no local claimed_integration_target binding” refusal; more than one refuses as ambiguous. The current-holder proof still decides every write, and a heartbeat left by an earlier holder session never stands in for a resumed holder’s liveness.

Only when a lane’s plain lane/status WIP count is already at or over its wip_limit does wu:claim fetch every occupant’s liveness ref (one network round trip for the whole lane, never one per WU) and check it against the configured threshold. A WU is excluded from the tally only when its liveness ref EXISTS on origin and is older than the threshold — a fresh ref, an absent ref (never published, or already deleted), or an unreachable origin all keep the claim counted as live. When a WU is excluded this way, wu:claim’s lane-occupancy output names it and the reason.

The staleness threshold defaults to 240 minutes and is configurable:

pnpm config:set --key software_delivery.lanes.dead_claim_liveness_threshold_minutes --value 90
High WIP (anti-pattern):
→ Multiple things started, none finished
→ Context switching overhead
→ Long cycle times
→ Merge conflicts

Low WIP (opt in with `wip_limit`):
→ Finish one thing
→ Start the next
→ Steady flow
→ Clean merges

Lane code_paths serve a dual purpose: they organize work AND enforce security boundaries through scope intersection.

When a tool call executes, the kernel computes:

effective_scope = workspace ∩ lane ∩ task ∩ tool

The lane’s code_paths act as the second narrowing layer. An agent working in Services: API (paths: services/api/**) cannot write to apps/web/** — the intersection is empty, and the kernel denies the call.

When claiming a WU, specify the full Parent: Sublane format:

pnpm wu:claim --id <wu-id> --lane "Experience: UI"

The WU spec should already have a lane assignment:

# <wu-id>.yaml
id: <wu-id>
title: Add email validation
lane: 'Experience: UI' # ← Full Parent: Sublane format

LumenFlow suggests lanes based on the authoritative lane definitions in workspace.yaml:

software_delivery:
  lanes:
    definitions:
      - name: 'Services: API'
        code_paths:
          - 'services/api/**'

If a WU’s code_paths match configured lane code_paths, the lane is suggested automatically by wu:infer-lane.

A WU should belong to one lane. If work spans multiple domains:

  1. Split the WU – One WU per lane
  2. Choose primary – Assign to the most affected lane

Instead of one WU: ❌ “Add user preferences (UI + API + DB)”

Split into: ✅ <wu-id>: “Add preferences API” (lane: Framework: Core) ✅ <wu-id>: “Add preferences UI” (lane: Experience: UI) ✅ <wu-id>: “Add preferences schema” (lane: Operations: Infrastructure)

The status file shows lane state:

# Status

## 🔧 In Progress

- [<wu-id> — Add preferences UI](wu/<wu-id>.yaml) (lane: Experience: UI)
- [<wu-id> — Add preferences API](wu/<wu-id>.yaml) (lane: Framework: Core)

## 🚀 Ready

- [<wu-id> — Add dark mode toggle](wu/<wu-id>.yaml) (lane: Experience: UI) ← blocked (lane occupied)
- [<wu-id> — Add export feature](wu/<wu-id>.yaml) (lane: Operations: Infrastructure) ← can claim

## ⛔ Blocked

(none)

Note: the “Add dark mode toggle” work unit cannot be claimed because the Experience: UI lane already has “Add preferences UI” in progress.