Repository navigation
Find and keep the session's real team after a resume or /clear, and tell the lead how to recover - #1659
Merged
Conversation
After a resume, the census that finds the platform's new team counted every live candidate before checking which one belonged to this session. One unrelated PACT session that had written its task store in the last 15 minutes was enough to make it give up and keep the stale team. The census now corroborates each candidate once and requires exactly one corroborated team. Two corroborated teams still keep the recorded team rather than guess. Three existing census tests are corrected so they test what their names say. New tests cover one corroborated team beside a foreign live session, two corroborated teams, and a lone uncorroborated team.
persist_context rewrote the context file on every SessionStart, even when nothing in it had changed. It now compares the bytes it would write with the file on disk and skips the write when they match, so a re-derivation that reaches the same context keeps the file's inode and modification time. The write path writes that same serialised string, so the comparison and the write cannot drift. Comments that still described the census as counting before corroborating are corrected.
On every compaction, session_init worked the team out from the session id, which never changes after a resume, so it always arrived at the old team. The realigned team PACT had saved was never consulted, and each compaction, by the lead or by an in-process teammate, wrote the stale name back into the context file and the CLAUDE.md session block. A compact SessionStart now starts from the saved team, which the resolver keeps while that team's config exists. Other sources re-derive as before. session_init also initialises the session context before resolving the team. Without it the resolver could not read this session's journal, so its census of the platform's teams never ran at SessionStart. As a result, a startup or resume whose recorded team has no config, and whose journal shows team activity, now realigns to a single corroborated live team at SessionStart instead of at the first prompt. A fresh session has no context or journal and is unaffected.
The only hook that writes a corrected team name back returned early once the bootstrap marker was set, and the marker survives resume and compaction. So after bootstrap nothing ever repaired a stale team. The write-back now runs before that early return, on every lead prompt and Agent spawn, and is still a no-op when the team is already right. When it moves the session from one team to another, it appends a team_realigned event naming both teams and tells the lead which tasks are still in progress in the old team's store.
…ocess teammates Four hooks decided whether a frame came from the lead's session by comparing the hook's session id with the team config's leadSessionId. After a resume the platform gives the session a new team whose config records a new id, while hooks keep receiving the original one, so the comparison never matched again. Every in-process teammate was then treated as a separate-process teammate: the teammate registry recorded each of them under the lead's id, the claim gate auto-claimed tasks for whichever teammate registered last, and teammate handoffs stopped reaching the journal. A frame now counts as the lead's session when its id matches the session id recorded in the lead's context file, which only lead frames write, or the team config's leadSessionId. The registry skips a session id that already has a lead context file, and agent-name lookups no longer read the registry for such frames. The journal gate admits a non-lead frame only while the recorded team's config is readable. With the config gone the recorded team is the stale one, and a handoff journalled from it would carry the old team's task with the same id, so those frames are still skipped. Existing tests that seeded a lead context under a separate-process teammate's own id, which no real session produces, now seed it under the lead's id, and tests that asserted the old skip for a stale config now assert the corrected emit.
When the recorded team's config is gone and the census cannot settle on a single live team, PACT fell back to the stale name without saying so. SessionStart now adds a line naming the recorded team, saying its config is absent, and listing each live candidate with whether it matches this session. The dispatch gate's no-task deny carries the same line when the session team's config is absent, and a lead prompt gets a do-not-spawn notice when two live teams both match. A fresh session with no team activity gets none of these. The realign notice's task ids are now sanitised like the other prompt fields, and its tool name is backticked.
A plain resume in the middle of a workflow left no record that the session was resuming one, so the secretary rebuilt Working Memory from the store and later agents started with the feature's own conclusions. On startup or resume, with no pause or refresh claim, PACT now checks the previous session's recorded team for in-progress tasks and the journal for a phase that started without finishing since the last consolidation. When it finds either, it surfaces a prompt to treat the launch as a resume, and the existing resumption marker holds the Working Memory rebuild. A previous session that is still running is not treated as finished, and a compaction never raises this claim.
A paused claim was visible only one session back. A session that showed the claim and then exited without a single turn became the "previous session", and the claim was lost to every session after it. Each session now records the session before it on session_start. When the previous session holds nothing but launch events, the claim check looks one session further back, up to three sessions. Any other event, including the consumption of a claim, ends the walk, so a claim that was already handled is not raised again.
When the census cannot decide between two live teams, the lead had no supported way to apply the answer and had to call plugin internals by hand. `python3 -m shared.realign_team --team <name>`, run from the plugin's hooks directory, writes the named team into the session context and the CLAUDE.md session block and records a team_realigned event. It refuses, with one line and exit status 2, when this session has no lead context, when the name is unsafe, when the team has no config or no lead member, or when the team's lead runs from a different project directory. The command lives in its own module because the shared package already imports pact_context, and running that module directly would load a second copy of it.
State recovery now has a team-split procedure: on a realign or ambiguity notice, or after a compaction when a realign's in-flight tasks were never carried across, the lead uses the new team, re-creates the old team's in-flight tasks citing their old ids, re-links each teachback task to the work task it gates, and asks the secretary to harvest the old store. The claim steps describe reading past sessions that did nothing, and the harvest skill reads the earlier team's store named by a team_realigned event. Bootstrap's session-team step names the split as the exception to "an absent config is normal", and its explanation of the pause consumption write is cut to the instruction it exists to support.
Both hooks read the team config and the task store, so a mocked test cannot show they work. They join SEAM_DEPENDENT_HOOKS with their import closures and are mapped to non-mocked test files. handoff_ordering_gate skips silently when it cannot resolve a team, so it is also recorded as a candidate for the live-probe tier.
Three behaviours from the team-split incident had no direct test. A completion after a resume, a realign, an idle live store and a lead compaction must journal the live team's handoff when the same task id exists in both stores. Restoring the old compaction default journals the old team's handoff, which is the reported symptom. A same-session resume of a session that recorded no task activity must still reach its predecessor's resumption claim; a session with task activity stops the walk. When the secretary is a member of the live team only, the first lead prompt after the split must realign and write the bootstrap marker; two matching teams leave the record and the marker alone.
The resume census now runs at SessionStart, so a resume into a team
split moves the session's record inside session_init, before any
prompt. The prompt-time write-back was the only writer of team_realigned
and the realign notice, and by the first prompt it saw no move, so the
journal never recorded the split. The old-store handoff harvest, the
post-compaction recovery check and the lead's notice all key on that
event.
session_init now records the move it makes: one team_realigned
{from, to} and the realign notice in the SessionStart context, for a
lead frame on resume or compact. "from" is the record before this
launch persisted; "to" is read back from the context file, so a persist
that did not land records nothing. A move counts only when the new
team has a config, so a launch that falls back to a stale computed name
is not journalled as a move. The event and notice live in one shared
function that both writers call, and the write-back then sees no move,
so each move is recorded once.
/clear gives the session a new hook session id and a new session dir, while the platform keeps the session on its existing team, whose config still names the previous session as lead. No config carries the new id, and the resume census needs prior team activity in this session's journal, which a just-cleared session lacks. So the record named a team built from the new id that does not exist, the next prompt did not correct it, and the bootstrap marker was never written. The team resolver now follows the clear chain. When the record names no live team and this session started from a /clear, it reads the previous session's recorded team and adopts it only when that team's config is readable and either its leadSessionId is a session id on the chain, or a session on the chain journalled a team_realigned to it. Every session on the chain must have recorded the same team, the walk stops after three clears, and anything else keeps the computed default. SessionStart passes the predecessor in, since the new journal is still empty; the prompt-time write-back reads it from the session's own clear-sourced session_start, so both resolve the same team. The orchestrator persona's /clear sentence now states this mechanism.
…t of resolution Review of the team-split fix found three ways a session could adopt a team that is not its own, plus a set of smaller defects. They share the team resolver and the record path, so they land together. /clear binding. The carry-over took its predecessor from the project CLAUDE.md block, which names whichever lead wrote last, so a /clear in one of two concurrent sessions could adopt the other's team; and because a carried team recorded nothing, the chain expired after three clears. Each lead SessionStart now records a process key: the hook's parent claude process and its start time, recorded only when CLAUDE_PID agrees with that parent. A clear-born session adopts the team recorded by the newest other session dir with the same key, contained under pact-sessions, when that team's config exists. The key is stable across /clear, differs between concurrent sessions, and changes on --resume, so a later resume disarms the carry-over. The CLAUDE.md predecessor, the hop walk and the journalled- realign arm are gone. Census exclusion. Once a resumed session's own new store went quiet for fifteen minutes, a same-project sibling became the only candidate and was persisted and journalled. A team whose leadSessionId owns a PACT session dir is provably another session's, so the census never counts it; the re-provisioned own team's lead id is one no hook frame carries. The census anchors on the project dir only. Recording a move. team_realigned is written by one function, which records only when the old team had a config or a task store, reads the moved team back from disk, checks the append, takes a lock so a racing prompt and compaction write one event, and keeps the session's start time. The realign command goes through it, refuses a team another PACT session owns or one that differs from the identity-matched team, and uses a distinct exit code for a refusal. One description of an unresolved, ambiguous or candidate-less team now feeds the SessionStart line, the ambiguity notice and the dispatch refusal. The recovery instructions cover the candidate-less state and state one spawn rule. The claim walk only passes a session that has ended. Hooks that read the task store run as real child processes in a new seam test, and the /clear tests are rebuilt on the measured clear shape.
…oject leads or records it Verifying the /clear process key and the census exclusion found that the exclusion was both too strong and too weak. After a /clear that recorded no process key, the session's own team is led by its ended pre-clear session, which has a PACT dir, so the census and the realign command refused the session's own team and recovery had no way out. And a sibling that had done one /clear was readmitted, because the sibling's original lead session had ended while its cleared successor still ran on the same team. A team now belongs to another session only when a live session of this project, other than ours, either leads it or records it. A session is live until a session_end follows its last session_start. Ours means this session id, or a session that recorded the same non-empty process key; an empty key never matches. The census and the realign command share this rule, and the scan covers only this project's sessions, since a census candidate must already have this project as its lead cwd. The process key itself is now stable: ps runs with TZ=UTC so a time zone change inside one claude process keeps the key, and a lead SessionStart that cannot compute a key keeps the recorded one instead of blanking it. build_context_cache's docstring states the read that carries the key forward. When two processes record the same move, the notice goes only to the lead's prompt-time write-back, never to a SessionStart that lost the race. The realign lock is opened without following a symlink, and the claim gate reads the team config once by handing the lead id it already read to is_lead_session_frame. A test now pins that a clear-born session moved to another live team is not reverted at the next prompt.
… how pytest was launched Three test defects surfaced while verifying the /clear binding. The merge-guard token directory is computed when its module is imported, so the in-test home redirect never reached it, and every test that ran session_init in process globbed the real ~/.claude for merge tokens and unlinked stale ones. An autouse fixture now points that directory at the test's temp root, and a pin fails if it stops doing so. The /clear tests derived their process key from pytest's parent. A pytest launched detached has parent 1, so no key was computed and the cases failed at their own precondition, which read as an intermittent failure. The clear fixtures and the carry-over rows now run the hooks as children of the test process with CLAUDE_PID set to it, so the key is real and does not depend on the runner's ancestry. Several assertions checked that a task id was absent from the whole output, which also matched pytest's numbered temp directories. They now read the claim's own text.
…ssion liveness from the journal's ends A sibling session with no process key that ran /clear left its team led by an ended session and recorded by no one, so the resume census treated the team as unowned and could adopt it once the resuming session's own store went quiet; the sibling's successor was then locked out. An ended session now still owns its team, for the census, while a live session of this project other than ours was born by a /clear within a minute of that end, on either side, since SessionEnd is asynchronous. The birth is the first session_start, because each compaction appends another. The measured gap between a session's end and its /clear successor's start was 0 to 1 second across nine pairs; a wider window only adds exclusion. The realign command keeps the stricter rule. Liveness now reads only the ends of a session's journal, so a scan no longer parses whole journals, and this project's session records are read once per hook process. A lead SessionStart passes the same key to the resolver as it records.
…retary spawn is refused After a /clear that recorded no process key, the only signal is a refused spawn, which routes to recovery step 1. Step 1 assumed creating the secretary's task would let PACT resolve the platform's team, but a task creation journals nothing the census counts, so the spawn stayed refused and the step said to stop. Step 1 now finds the team that holds the secretary's task, the newest task store whose task with that id carries its subject, runs the realign command with it, and then spawns the secretary.
…each /clear successor to its nearest end The realign command checked ownership without the /clear-successor rule, so it accepted a keyless sibling's team that the census excluded, and a lead following recovery could move onto another session's team and lock its rightful owner out. Both now share one rule. The successor link was many-to-many: any live session born by a /clear within a minute of an ended owner's end linked to it, so a sibling's /clear could claim our own no-key predecessor and exclude our own team. Each ended owner now pairs only with the clear-born birth nearest its end, and each birth with at most one end. Our own clear birth takes part in the pairing, so our predecessor pairs with us first. The window is now ten seconds, against a measured gap of zero to one second. Journal lines are decoded with replacement before parsing, as the old reader did, so a corrupt byte no longer makes an ended session read as live, and liveness is read only for sessions whose birth qualifies.
…newest matching task Recovery step 1 picked the most recently modified task store holding a task with the secretary's id and subject. Every bootstrapped team holds that same task, so a busy concurrent session's store was often the one picked. The secretary's task now carries a line naming this session and the attempt's time, and step 1 searches for exactly that line, stopping if it finds none or more than one store.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9275c92. Configure here.
…ndow, unless our own is strictly nearer One-to-one nearest pairing let an unrelated ended session claim a keyless sibling's /clear successor, by a same-second tie or by ending a second nearer, which left the sibling's team unowned and adoptable again. The rule is now existence plus priority: an ended owner's team belongs to another session while any live clear-born session that is not ours was born within ten seconds of that owner's end, unless our own clear birth is strictly nearer to it than every such birth. Ties and nearer foreign births keep the team excluded, which is the safe direction. Births count as ours only when they are our own session dir. The clear births are now gathered once per census rather than once per candidate team.
Step 1's lookup expanded {config_dir}/tasks/*/*.json in the shell, which
fails with an over-long argument list on a machine with many task files
and errors on an empty glob under zsh; both read as no match and stall
recovery. It now runs grep recursively over the tasks directory, keeping
the trailing slash so a symlinked tasks root is still searched. The
system grep and the Bash tool's grep return the same matches.
…ar birth After two /clears in one process the first clear-born life has ended, yet it records the same process key as the live session. If such a life counted as ours, a nearby sibling leader's end could pair with it and read as our predecessor, leaving the sibling's team free to adopt. Only our own session dir counts as ours, and a foreign birth counts only while its session is live, so the ended earlier life counts as neither. A test now builds that world and requires the sibling's team to stay excluded, with a control that frees it once the sibling's successor is gone.
…dow, with no own-birth exception A keyless /clear could claim a sibling session's ended owner whenever its own birth sat one second nearer that owner's end than the sibling's successor did. With second-resolution timestamps that is as common as a tie, so two keyless leads clearing together could adopt each other's team. Any live /clear birth that is not ours, within the window of an owner's end, now holds that owner's team. Our own birth no longer counts either way. One predicate decides "ours" for both owners and births, so the lists cannot drift. In the keyless world this can exclude our own team; the census then names no candidate and the realign command refuses, which is fail-safe.
…tests green when pytest is orphaned The two process-key tests read the real parent pid, which is 1 once pytest's parent has exited, so they failed under an orphaned launch while the code was right. They now take this process as the parent. New tests pin branches a mutation pass found unguarded: a parent of 1 or a failed ps gives no key, a key recorded for another session is never carried, session dirs reached through a symlink or outside pact-sessions are never read, the journal and records memos see later writes, the realign dedupe compares the earlier team, and the realign command casefolds team names and names the earlier team when its event cannot be written. A resumed session takes "ours" from its new process key, so a crashed earlier life recorded under the old key is another session.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
After an interactive
--resume, the platform puts the session on a new team while hook stdin keeps the old session id. PACT kept recording the old team, so spawns were denied, the bootstrap marker was never written, the journal recorded handoffs from the wrong task store, and state recovery never noticed. This PR makes PACT find and record the team the platform is actually using, keeps that record stable across/compactand/clear, never lets one session take another session's team, and tells the lead how to recover the old team's in-flight work.Fixes #1654.
What changed
Finding the right team
/cleargives the session a new hook id while the platform keeps its team. Each lead frame records a process key: the hook's parentclaudepid plus that process's start time, taken only whenCLAUDE_PIDnames that parent. A clear-born session adopts the team of the newest earlier session in the project recorded with the same key. The carry-over applies only to a life whose latest start was a/clear, and only while the record names no live team.Never taking another session's team
/clear-born session that is not ours started within 10 seconds of that owner's end, because that session may be its successor. In sessions without a process key, this can exclude the session's own team. When it does, the census names nothing and the realign command refuses, which is the safe side.Keeping the record stable
persist_contextno longer rewrites the context file when its bytes would not change.Recording and telling the lead
team_realigned {from, to}journal event and a realign notice to the lead, whether the move happens at SessionStart or at a prompt. Nothing is recorded when the earlier team never existed.cd "{plugin_root}/hooks" && python3 -m shared.realign_team --team <name>. It exits 3 on a refusal and 2 on a usage error.Lead-only frames and journal routing
Classification
task_claim_gate,handoff_ordering_gateandbootstrap_prompt_gateare classified as seam-dependent, with subprocess rows for the resume split.Version 4.8.4 (PATCH).
Review
Four reviewers (design, tests, implementation, adversarial security) plus Bugbot found 33 issues at the first review. All are fixed in this PR. Six verify-and-fix rounds followed, each narrowing the ownership rule against constructions the security reviewer built; the final round found no way for a session to adopt another session's team. A separate mutation pass made 63 deliberate breaks, and the tests added after it catch every break that still applies to the final code.
Testing
cd pact-plugin && python3 -m pytest -q, no path argument) on the final head:21096 passed, 21 skipped, exit 0. The process-key tests also pass when pytest's parent has exited.Live interactive probe
The installed 4.8.3 plugin was patched with this branch and run in real interactive sessions, then restored. The restore was verified by checksum and by a diff against
main. The resume, two-session and/clearscenarios were run again on the final head, and all passed. The keyless ownership cases cannot be produced in a real session, so they were verified by running the real hooks as subprocesses against constructed sessions.team_realignedis written, the lead is told, and it re-creates the old team's two in-flight tasks citing their origin. A later/compactre-creates nothing/compactafter 16 idle minutes/clears in one of them/clearafter a realigned splitKnown limits
/clearlands within seconds of their predecessor's end, or a SessionEnd hook failed, they keep the recorded team and show a notice instead of claiming one.