Skip to content

attach: follow a tab onto the background job it parked its work on - #7

Merged
simoncoombes merged 1 commit into
mainfrom
follow-parked-session
Sep 15, 2026
Merged

simoncoombes merged 1 commit into
mainfrom
follow-parked-session

Conversation

@simoncoombes

Copy link
Copy Markdown
Owner

A pane pinned at 08:51 was still drawing its tab's idle row at 14:44, while three agents ran under a session it had never heard of. The tab had parked its work on a background job, and the pane was pinned to the tab.

What the registry says

The interactive row keeps its sessionId, goes status:"idle" and grows parkedJobId; a second row with kind:"bg" and a matching jobId runs the work under a different session id.

{"pid":18643,"sessionId":"10e378ef-…","kind":"interactive","status":"idle","parkedJobId":"e9e4ae8e"}
{"pid":26106,"sessionId":"e9e4ae8e-…","kind":"bg","status":"busy","jobId":"e9e4ae8e"}

parseFile read six fields and none of them were these, so the pane had no way to know the hop had happened. Everything downstream then behaved correctly about the wrong session: the tab really was idle, the 27 suppressed agents really were its own from hours earlier, and the hook payloads carrying the live work went to the job's socket, which nobody was listening on.

The fix

registry.Follow walks parkedJobId to the row whose jobId matches it, by job id because that is the only field the two rows share. It stops on a row that parks nothing, on a missing row, on a repeat, and after four hops.

waitForPinned becomes followSession. It no longer returns after the first attach, because parking happens mid-session: resolving this once at startup would have fixed nothing about the pane in question, which was correct when it opened and wrong an hour later. It follows back when the work returns to the tab.

The world is re-keyed on the hop, or the fix does nothing visible: the events now arriving carry the job's session id and Machine.Accepts drops every one of them against the tab's. It resets rather than merges, and toasts, because a tree that empties itself with no explanation reads as a bug.

Decisions worth a look in review

  • The state file and run history stay filed under the tab's id. A pinned pane's --oneline reads current-<pin>.json and the idle screen's LAST RUN is that tab's work. The attacher carries a base (the tab) separately from what it is attached to.
  • The registry filter follows the attach, not the pin. Filtering to the pin after a hop would drop the job's permission waits, which is the one signal the registry exists to carry.
  • A spare bg process is skipped by the cwd guess and still reachable by a park. It is a warmed shell holding no work, but a tab that parks onto it has stated where its work went.

Surfaces

The header names both ids while following (AGENTS 3 · 10e3 → e9e4) and ? spells it out. doctor reports the hop and checks the job's socket rather than the tab's.

✔ attach target: 10e3 → e9e4 (--session, and that tab has parked its work on background job e9e4ae8e)
✔ socket: …/agentpane-e9e4ae8e-….sock reachable (an agentpane TUI is listening)

Tests

Follow (walk, missing row, stale duplicate, cycle), parseFile on the captured row shapes, AttachTarget skipping spares, resolveAttach on a parked pin, followSession hopping to the job and back, and the world re-key. Green under -race. The three codexsrc TestRealCodexSession* failures predate this branch and fail on main too.

Unverified

Whether spare is cleared when a spare process is handed a job. If it is not, a spare that gets work while still flagged stays out of the cwd guess; a park onto it still resolves. Documented with the captured row shapes in docs/DATA-SOURCES.md §2.1.

A pane pinned at 08:51 was still drawing its tab's idle row at 14:44, while
three agents ran under a session it had never heard of. The tab had parked
its work on a background job, and the pane was pinned to the tab.

The registry says so plainly. The interactive row keeps its sessionId, goes
status:"idle" and grows parkedJobId:"e9e4ae8e"; a second row with kind:"bg"
and jobId:"e9e4ae8e" runs the work under a different session id. parseFile
read six fields and none of them were these, so the pane had no way to know
the hop had happened. Everything downstream then behaved correctly about the
wrong session: the tab really was idle, the 27 suppressed agents really were
its own from hours earlier, and the hook payloads carrying the live work went
to the job's socket, which nobody was listening on.

registry.Follow walks parkedJobId to the row whose jobId matches it. The walk
is by job id because that is the only field the two rows share. It stops on a
row that parks nothing, on a job whose row is missing (the process died
between registry writes), on a repeat, and after four hops, so a registry
shape we do not understand cannot spin the poll loop.

waitForPinned becomes followSession, which is the same loop doing one more
job. It no longer returns after the first attach, because parking happens
mid-session: resolving this once at startup would have fixed nothing about
the pane in question, which was correct when it opened and wrong an hour
later. It follows back when the work returns to the tab. A base row going
missing is still not a reason to re-resolve anything.

The world has to be re-keyed on the hop or the fix does nothing visible. The
events now arriving carry the job's session id and Machine.Accepts drops
every one of them against the tab's. syncAttached resets rather than merges,
because the two sessions are different worlds and the agents of the one that
went away are not this one's, and it toasts, because a tree that empties
itself with no explanation reads as a bug.

Three decisions worth naming.

The state file and run history stay filed under the TAB's id. A pinned pane's
--oneline reads current-<pin>.json and the idle screen's LAST RUN is that
tab's work; following a parked job moves the world, not the name it is filed
under. So the attacher now carries a base (the tab) separately from what it
is attached to (wherever the work is).

The registry filter follows the attach, not the pin. Filtering to the pin
after a hop would drop the rows the pane is now watching for, including the
job's permission waits, which is the one signal the registry exists to carry.

A spare bg process is skipped by the cwd guess and still reachable by a park.
It is a warmed shell holding no work, idle by construction, and picking it
would attach the pane to the one session guaranteed to show nothing. But a
tab that parks onto it has STATED where its work went, and a stated hop beats
a guess about what the row looked like a moment ago.

The header names both ids while following (10e3 -> e9e4) and ? spells it out,
because "which session am I looking at" has two honest answers here and
hiding either one is how this went unnoticed for a whole session. doctor
reports the hop and checks the job's socket rather than the tab's.

Unverified, and stated in DATA-SOURCES §2.1 with the captured row shapes:
whether `spare` is cleared when a spare process is handed a job. If it is not,
a spare that gets work while still flagged stays out of the cwd guess. A park
onto it still resolves.
@simoncoombes
simoncoombes merged commit 19b807c into main Sep 15, 2026
3 checks passed
@simoncoombes
simoncoombes deleted the follow-parked-session branch September 23, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant