Skip to content

Find and keep the session's real team after a resume or /clear, and tell the lead how to recover - #1659

Merged
michael-wojcik merged 27 commits into
mainfrom
fix/resume-team-split-realign
Oct 20, 2026
Merged

michael-wojcik merged 27 commits into
mainfrom
fix/resume-team-split-realign

Conversation

@michael-wojcik

@michael-wojcik michael-wojcik commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

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 /compact and /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

  • The resume census counts only teams that corroborate as this session's own, so one foreign live session no longer suppresses the session's own team. The census also runs at SessionStart, so a resume realigns before the first prompt.
  • A /clear gives the session a new hook id while the platform keeps its team. Each lead frame records a process key: the hook's parent claude pid plus that process's start time, taken only when CLAUDE_PID names 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

  • One ownership rule serves both the census and the realign command. A team belongs to another session when a live PACT session of this project, other than this one, leads or records it. "This one" means the same session id or the same non-empty process key. Liveness is read from each journal's ends.
  • An ended owner's team is still held when any live /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

  • A compaction keeps the recorded team instead of re-deriving it.
  • persist_context no longer rewrites the context file when its bytes would not change.
  • The prompt-time write-back runs ahead of the bootstrap-marker fast path.

Recording and telling the lead

  • Every move writes one 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.
  • A launch with work in flight surfaces a resumption claim, and that claim survives sessions that did nothing in between.
  • When the team cannot be decided, the lead gets a notice saying why. A lead-run command realigns it explicitly: cd "{plugin_root}/hooks" && python3 -m shared.realign_team --team <name>. It exits 3 on a refusal and 2 on a usage error.
  • State recovery, the orchestrator persona, bootstrap and the handoff-harvest skill describe the team-split recovery. When the secretary spawn is refused, the lead finds the platform's store by a line unique to this attempt that it wrote into the secretary task, searches the task stores recursively for it, and realigns to the one matching team. No match, or more than one, means stop.

Lead-only frames and journal routing

  • One predicate recognises the lead's session frame. The journal gate keys on the raw persisted team, so a non-lead frame never journals another store's task under the same id.

Classification

  • task_claim_gate, handoff_ordering_gate and bootstrap_prompt_gate are 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

  • Full suite (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.
  • Each behaviour has a test that was checked by breaking the code it covers and confirming the test fails.

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 /clear scenarios 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.

Scenario Result
Baseline, unpatched The bug reproduces: the record names the old team, and the secretary spawn is denied
Resume into a split, with a foreign session live The record names the new team at SessionStart, one team_realigned is written, the lead is told, and it re-creates the old team's two in-flight tasks citing their origin. A later /compact re-creates nothing
/compact after 16 idle minutes The context file and CLAUDE.md are byte-identical; no background-work notice
A new session beside a foreign live session It keeps its own team
A resumption claim across a session that did nothing The claim surfaces two sessions later, and Working Memory is not rebuilt
Two leads in one project, three /clears in one of them That session keeps its own team every time; the other session's team is never adopted
/clear after a realigned split The realigned team carries over, and the marker is written
A resume left idle for 16 minutes before the first prompt The realign had already landed at SessionStart, and the secretary joins the new team

Known limits

  • At SessionStart nothing distinguishes a lead-shaped in-process teammate's compaction from the lead's. If that compaction is the one that moves the record, the teammate gets the realign notice. The journal event is still written once.
  • A resume reuses the session dir, so a bootstrap marker from before the resume is still present while the new team has no secretary yet. This was observed in the probe, and it does not block anything.
  • Sessions without a process key fall back to the safe side: when another session's /clear lands 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.

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.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread pact-plugin/hooks/shared/pact_context.py
…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.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread pact-plugin/hooks/session_init.py Outdated
Comment thread pact-plugin/hooks/shared/pact_context.py
…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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread pact-plugin/hooks/shared/pact_context.py Outdated
…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.
@michael-wojcik
michael-wojcik merged commit c04f015 into main Oct 20, 2026
4 checks passed
@michael-wojcik
michael-wojcik deleted the fix/resume-team-split-realign branch October 20, 2026 02:42
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.

After a resume, PACT and the platform disagree on the session's team, and nothing in state recovery catches it

1 participant