Skip to content

[bug] Delegation is single-use — the Pilot ends the session its own driver requires, and the error blames the runner #271

Closed

Description

@serge-ivo

#257 shipped its history half — loadRepoTimeline, migration 0077, repo-scoped fetch in the
console. Good fix. The session-lifecycle half was raised in a comment after that issue closed,
so it is currently untracked. This is that half.

Delegation is single-use

Two facts, both still true at 5f28af8:

// lib/loop-drivers.ts:117 — the driver REQUIRES a live session
const session = await getActiveSessionForRepo(env, instanceId, userId, repo.id);
if (!session) return { ok: false, status: 409, error: `${repo.name} has no live coding session…` };

// workflows/coding-session.ts:447 — the Pilot ENDS the session when its run completes
await endSession(env, instanceId, userId, sessionId, status);

So every delegated job consumes the session it needed:

open a session by hand → ask for work → start_work → Pilot completes → Pilot ends session
                       → ask again → 409 → open another by hand

Verified three times today on FAS platform (964594b6), runner online throughout:

03:44:50 session opened (by hand)
03:45:23 start_work → Pilot ran git pull — worked
03:45:36 Pilot ended the session
~05:40 asked again → 409 no live coding session
~06:05 asked again after confirming → 409 again

Also why the Coder Lead could not delegate to any of four subordinates (#259).

The error message tells the user to fix the wrong thing

loop-drivers.ts:122:

${repo.name} has no live coding session — start one on its Coding tab (and run \pags up`), then try again.`

The (and run \pags up`)is unconditional. It fires when the runner is **already connected** — verified at the moment of the 409:runnerOnline: true, relayConnected: true, reachable: true,
runnerVersion 0.4.32, lastSeenAt` current.

So a perfectly-behaved agent relays it faithfully and misinforms the user. Both retests above ended
with the agent telling the user to start a runner that was running. That is not the agent's fault —
it is repeating platform text that conflates two independent conditions.

Suggested shape

The design question first: should a completed Pilot run end the session, or only mark the run
done?
Ending it is what makes delegation single-use, forces a fresh empty timeline per job, and
means a supervisor cannot supervise without a human opening a session first. Leaving the session
idle-but-live for the next goal removes all three at once.

If ending stays, the driver must be able to start a session itself when none is live and a
runner is connected — otherwise delegate_goal is only ever usable by someone sitting in the
console.

Either way, split the error by actual cause:

  • runner not connected → say so, name the machine, pags up
  • runner connected, no session → say that, and (once the above lands) just start one

Verification

  • Two consecutive delegated jobs succeed without a human opening a session between them.
  • With the runner online and no session, the message never mentions pags up.
  • With the runner offline, it does — and names the node.
  • The Coder Lead can delegate to an idle subordinate whose runner is connected.

Adjacent observation

The #256 run-history injection changed behaviour in a way worth watching: asked "Pull latest
changes" (an imperative), the agent answered about the 2-hour-old pull run and asked for
confirmation rather than acting. Grounded and honest — a clear improvement on denial — but it is
now possible for injected history to satisfy a request for new work. Worth a look if it recurs.

Activity

  1. serge-ivo commented on Aug 6, 2026

    @serge-ivo
    ContributorAuthor

    Fixed in 03f79b7 (fix(coder): the Lead can see connectivity, and delegation stops being single-use (#259, #271)).

    Verified at HEAD before building — both cited lines still said what the report says. loop-drivers.ts:117 required a live session and returned the 409 with the unconditional "(and run pags up)"; coding-session.ts:447 ended the session in its terminal finally step.

    The lifecycle decision, and why

    Both suggested shapes, because each alone leaves a hole:

    1. A run ends only the session it opened. This is the ownership/lease notion, expressed as sessionOpenedByRun on the workflow params and decided by one pure rule (shouldEndSessionAfterRun, lib/coding-session-lifecycle.ts).

    Answering your framing directly — least surprising to a human who opened a session by hand: ending someone else's session is the surprising half. Nothing else in the product closes a resource the user created, and the Pilot had no reason to be the first. The outcome is deliberately not an input to the rule: a failed run is the worst possible moment to take a session away, because that is exactly when the user wants to open the terminal and look at it.

    Default is false — "not mine". So an in-flight workflow and every existing caller (/sessions/:id/run, the Overseer's drive_claude) keep a session rather than lose one. Note this changes the console path too: hand a hand-opened session to the Pilot from the Coding tab and it is still there afterwards.

    2. Ownership alone still dead-ends on an agent nobody opened a session for — a supervisor could never supervise without a human sitting in the console first. So the driver opens one itself when the runner is connected, and by rule (1) that session is the run's, so it is cleaned up and the repo is left as it was found. startSessionOnRunner moved verbatim from routes/coding.ts into lib/coding-session-open.ts: it was private to a route, which is why the autonomous path had to refuse — the capability existed, it was just behind a URL.

    One consequence worth flagging: endSession is what normally frees the single-flight claim (#208). When the session survives, the Pilot now releases the claim explicitly — miss that and "delegation is single-use" becomes "delegation is once every 15 minutes" (STALE_DRIVER_MS).

    The error message

    Split by actual cause, and connectivity is now checked first, from the same resolver delegation routes through (shared with #259's classifier, so a Lead's relayed refusal and the status it reads cannot contradict each other):

    • runner unreachable → names the machine and prescribes pags up
    • machine alive, agent detached → pags up --force (plain pags up would just be rejected 4409 again)
    • runner connected → "The runner is connected on <node>, so this is not a pags up problem", plus the runner's own error when /coding/start failed (clone error, bad engine command)

    A test asserts the connected branch never emits anything that reads as an instruction to run pags up. That sentence is what cost you hours.

    Tests

    lib/coding-session-lifecycle.test.ts (7) for both rules. loop-drivers.test.ts gained: opens a session itself when none is live and the runner is connected (sessionOpenedByRun: true); does not claim ownership of a reused session (false); blames the runner only when it is actually unreachable. Its stub and delegate-instance.test.ts's now model a registered, relay-connected runner — without that they would have silently asserted against the offline path.

    Full suite green: 245 files / 3553 tests, pnpm typecheck and biome check clean on every touched file.

    Not verified

    I could not re-test this live — no runner session available from here. The verification list in the issue (two consecutive delegated jobs with no human in between; the message never mentioning pags up with the runner online; the Lead delegating to an idle subordinate) is covered by unit tests against the real decision functions, but the end-to-end run is still worth doing.

    Not done

    No migration. Ownership rides on a durable workflow param rather than a coding_sessions column — the only decision point is the Pilot's terminal step, and there was an active migration-number race with two other agents. If ownership ever needs to be visible (a console badge, a reaper that distinguishes abandoned agent sessions from human ones), a column is the right next step.

    Also untouched: the adjacent observation about #256 run-history injection satisfying a request for new work. Real, but a different bug — worth its own issue if it recurs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions