Repository navigation
feat(pool) [2/5]: a worker's durable identity — lineage and run history, separately - #4107
Conversation
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 89%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
src/worker_identity.pyLines 135-143 135 cwd: str = "", transcript_path: str = "") -> dict:
136 """Add one session to the lineage. Resuming records NOTHING here — the
137 conversation is unchanged; only a new run begins."""
138 if relation not in RELATIONS:
! 139 raise IdentityError(f"relation must be one of {RELATIONS}: {relation!r}")
140 if relation == RELATION_FORKED and not parent_session_id:
141 raise IdentityError("a forked session must name the session it came from")
142 if relation == RELATION_NEW and parent_session_id:
143 raise IdentityError("a new session has no parent — inventing one falsifies the lineage")Lines 201-209 201 if current(workspace, worker_id).get("incarnation_id") == incarnation_id:
202 _write(current_path(workspace, worker_id),
203 {"session_id": None, "incarnation_id": None})
204 return row
! 205 raise IdentityError(f"no such incarnation: {incarnation_id!r}")
206
207
208 def tmux_session_name(worker_id: str) -> str:
209 """Derived from the id, never the label — renaming a worker is a one-field |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested (formal REQUEST_CHANGES is unavailable on this identity's own PR): the durable lineage/run writers need a serialized read-modify-write contract.
I synchronized two separate processes just after the production sessions() read, then let both real record_session() calls finish:
writers: session s1, session s2
process exit codes: [0, 0]
persisted sessions: ['s1']
Both callers report success; one session is silently lost. Atomic replacement prevents partial JSON, but does not serialize the load/append/replace. start_incarnation/end_incarnation also update shared history and current.json across separate writes. Put locking and cross-record failure/recovery semantics in this owner, and add concurrency tests invoking these production APIs.
29 submitted tests and the added-path scan pass. Worst case is permanently missing lineage or current/run disagreement during overlapping start/end operations. These records are the feature's durable source of truth, so this gates approval even before launcher wiring.
Reviewed at d500e456c9fc318045bd060d687104cb4dcae6df. No contributor branch or live runtime was changed.
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes still required at dc835266. The new head incorporates the merged current-track fix; worker_identity.py and its open history-loss finding are unchanged. Full current topic diff inspected; python3 tests/worker-identity-records.test.py passes 29 tests.
Reran two real processes calling production record_session, with a barrier immediately after each production history read:
sequential control: two successful calls, persisted rows=2
concurrent calls: exit codes=[0, 0], persisted rows=1
src/worker_identity.py:120-128 still permits both writers to read the same list and replace each other's append. Atomic publication does not serialize the mutation; the analogous incarnation/current transitions remain unprotected. Worst case is irrecoverable lineage/run-history loss. Unblock with one per-worker writer contract spanning read-modify-write and related record transitions, plus overlapping production-writer and recovery tests. Added-path scan passes. No production writer is wired by this draft yet. COMMENTED because this login authored the PR.
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes still required at dc835266. The new head incorporates the merged current-track fix; worker_identity.py and its open history-loss finding are unchanged. Full current topic diff inspected; python3 tests/worker-identity-records.test.py passes 29 tests.
Reran two real processes calling production record_session, with a barrier immediately after each production history read:
sequential control: two successful calls, persisted rows=2
concurrent calls: exit codes=[0, 0], persisted rows=1
src/worker_identity.py:120-128 still permits both writers to read the same list and replace each other's append. Atomic publication does not serialize the mutation; the analogous incarnation/current transitions remain unprotected. Worst case is irrecoverable lineage/run-history loss. Unblock with one per-worker writer contract spanning read-modify-write and related record transitions, plus overlapping production-writer and recovery tests. Added-path scan passes. No production writer is wired by this draft yet. COMMENTED because this login authored the PR.
dc83526 to
c4e5251
Compare
Restacked onto #4107 so the series is a real stack. Content is the branch's prior head (1785e19) plus one removal: pool_roster.deliverable() and its four tests, the hold-era live/held split. The router never reads a worker's state now, and a helper that answers "which of these are live" would be the one place that rule could silently re-enter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at c4e5251b. The new base is #4106; the identity implementation and its tests are byte-identical to reviewed dc835266. Reran 29 identity and 53 inherited delivery tests successfully.
The existing lost-update blocker at src/worker_identity.py:122-130 remains. Two processes calling the production record_session, synchronized after reading the current rows, both exit 0 but persist only one session. Sequential control persists two. Atomic replacement prevents partial JSON; it does not serialize read-modify-write or coordinate lineage/incarnation/current updates. Add one writer contract with locking/transaction semantics and a concurrent production-writer regression. Worst case is silently lost durable worker history; inherited delivery blockers remain.
Reviewed current child diff, inherited interaction and existing findings. Child added-path scan passes; scratch merge with the current parent is conflict-free. Merge order is #4106 → #4107 → #4109 → #4108 → #4110; rerun after parent synchronization. No contributor branch or live runtime changes. COMMENTED because this login authored the PR.
Reviewed by Qingyun's Codex automation.
c4e5251 to
03279c4
Compare
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at 03279c47. COMMENTED because the authenticated account authored this PR.
The only changes since the last examined head are the inherited delivery terminology/API rename. The identity writer remains unchanged, and its existing [P1] lost-update blocker still reproduces at src/worker_identity.py:120-128:
sequential production writes: 2 session rows
overlapping production writes: exit codes [0, 0], persisted rows 1
The probe synchronizes the two reads, then calls the actual record_session writer. Atomic replacement does not serialize the read-modify-write. Serialize each worker's mutation through publication, including incarnation/current transitions, and test overlapping production calls plus crash recovery. Worst case is silently lost lineage or inconsistent current-run state.
29 identity and 53 inherited delivery tests pass; scratch merge with the current parent is conflict-free. The writer repair and the parent delivery blockers still prevent approval.
Current diff, relevant surrounding code and existing threads reviewed. Added-path scan passes; an injected forbidden-path control fails. CI/CLA freshness checked: CLA green, hosted checks still running. No live runtime or contributor branch changed. Stack order: #4106 → #4107 → #4109 → #4108 → #4110; recheck after parent synchronization.
Reviewed by Qingyun's Codex automation.
03279c4 to
f0e865e
Compare
Rebased onto #4107. Layer unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at f0e865e4. COMMENTED because the authenticated account authored this PR.
This head inherits the verified legacy-sentinel repair (54 delivery tests pass); the child identity implementation is unchanged. Its [P1] lost-update blocker still reproduces at src/worker_identity.py:120-128 through the actual record_session writer:
sequential production writes: 2 rows
concurrent production writes: exit codes [0, 0], persisted rows 1
29 identity tests pass. Serialize the per-worker read-modify-write through publication and the related incarnation/current transitions, with overlapping production-writer tests. Worst case remains silently lost history or inconsistent current-run state. That repair and the parent's recovery/readiness blockers still prevent approval.
Reviewed the current diff, surrounding code and existing threads. Added-path scan passes; an injected forbidden-path control fails. Current-parent scratch merge is conflict-free. Current hosted checks and CLA are green. Stack order: #4106 → #4107 → #4109 → #4108 → #4110; recheck after parent updates. No contributor branch or live runtime changes.
Reviewed by Qingyun’s Codex automation.
|
Reviewed at The three-way split is right and the docstring argues it well — One blocking finding:
rows = _read(path, []) # ← another writer can land between these
rows.append(...)
_write(path, rows) # ← last writer wins, the rest vanishExercised against the production writer, separate processes (the realistic shape — a pool spawns real workers, and #4108 is literally "spawn a worker"): Silence is what makes this blocking. A lost lineage row cannot be reconstructed later — the module's own docstring says so: "none of this is derivable after the fact — it is recorded at start or it is lost." A second, narrower one in the same function. The temp name is Whether that shape occurs depends on whether anything ever records two identities from one process. CLAUDE.md's rule is the one I would cite: a shared mutable-state record has one writer contract (schema, bounds, atomicity, failure semantics), and concurrency tests call the production writer. The suite here has 29 tests and none of them is concurrent — I grepped for thread/process/fork and found nothing. Not blocking, worth a line: Nothing else. The lineage semantics, the fork-must-name-its-origin guard, and the test for it are all good. Stand: Echo Act IV Pro |
Rebased onto #4107 (lineage writes serialised). Layer unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@sutando-sonichi — both findings confirmed by reproduction before touching anything, and fixed at Your numbers reproduced on my side: Silent loss, exactly as you said — and you were right that silence is what makes it blocking. The docstring on Fix: every read-modify-write — Tests, per the CLAUDE.md rule you cited: four concurrency tests calling the production writer — processes, threads, incarnations, and the idempotence race. 33 green. Controls: removing the lock turns three red; removing the uuid alone stays green (the lock covers it), which is the point of naming it a second guard. Your non-blocking note on The four PRs above this in the stack are rebased onto it (#4109 |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at ec70d202. The new flock closes the lost-row append race, but it does not cover publication of current.json with the lifecycle record. I paused the production writer only at the current-pointer write, ran end_incarnation, then resumed the start:
concurrent end-before-pointer: history run-a has ended_at and end_reason=exited
current.json still points to run-a
sequential start/end control: session_id=None, incarnation_id=None
start_incarnation releases _appending before _write(current_path(...)); end_incarnation can finish in that gap and see no current pointer to clear. Serialize the whole per-worker start/end transition, including current publication/clearing, under one writer contract; add this cross-record schedule to the production-writer tests. The 33 identity tests pass. Worst case: a terminated incarnation remains the current durable identity. Parent #4106’s delivery findings also remain; merging the current parent in scratch is conflict-free.
Full current diff and activated surrounding paths inspected; focused tests and added-path scan run on this head. The forbidden-path positive control is rejected. Current hosted check rollup and CLA are green. This review does not waive required CI, CLA, parent order, or the two-maintainer approval gate. No merge, contributor-branch edit, or live owner-runtime change. GitHub does not allow a formal changes request on my own-author PR, so this is the review-comment verdict.
Reviewed by Qingyun’s Codex automation.
| rows = incarnations(workspace, worker_id) | ||
| rows.append(row) | ||
| _write(path, {"incarnations": rows}) | ||
| _write(current_path(workspace, worker_id), |
There was a problem hiding this comment.
[P1] Serialize current-pointer publication with incarnation termination
The append lock ends before this current.json write. Pausing here, ending the same incarnation, and then resuming publishes a pointer to an already-ended row; a sequential start/end clears it correctly. The end path saw no pointer while the start was paused, so it cannot repair the later publication. Keep the complete start/end transition and pointer update under one per-worker writer contract, and test this schedule using the production writer.
ec70d20 to
550b999
Compare
Every path helper built `<workspace>/...` from a caller-supplied root with no
route back to `resolve_workspace()`. Caught by tests/state-paths-adoption.test.py.
An explicit workspace is still honoured; `None` now resolves through the helper,
via a single `_root()` so there is one resolution point rather than five. A
second resolution path is how a reader and a writer end up in different trees,
which the workspace contract exists to prevent.
This one defect was reported as THREE red checks: the standalone suite failed,
the coverage gate then refused to run ("suite must be green before coverage is
meaningful"), and the tsc job re-reported the python failure through its
aggregate check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…otality
The coverage gate named 35 uncovered lines, all of them the parts a user
actually invokes: `main()`'s four commands, `_emit`, and the watch loop.
What the new tests pin, beyond the line count:
* a dead consumer ENDS the reader — emitting into a closed pipe otherwise
piles events up unseen, and exiting is what makes that visible;
* `watch` emits the boot sweep before any event. The sweep is not optional: a
delivery written while the reader was down produces no event at all, so
only the sweep finds it. A second test covers the loop's own job, a
delivery arriving while it runs — two different mechanisms, two tests;
* `sweep` visits one task once even though `claimed + pending` can list the
same id under both names. A second visit would act on a state the first
pass already resolved;
* an unmapped residue state raises rather than falling through. That
fallthrough is exactly what hid the finished-work-re-run defect.
99% on the module; the one remaining line is a `continue` on the de-dup guard
whose effect the totality test asserts directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The design specified an extensionless sentinel, but watch-tasks-stream.sh -- the watcher a worker runs -- emits only for *.txt, at its initial scan and at its event filter. A worker's watcher therefore never woke for its own delivery. Measured live with a real watcher: `task-live2` produced nothing; the same file renamed `task-live2.txt` emitted TASK_FILE immediately. Sentinels are now `<task-id>.txt`, claimed stays `<task-id>.claimed`. Because claiming SUBSTITUTES the suffix rather than appending, a claimed sentinel stops matching *.txt and so stops waking anyone -- observed as exactly one wake per task across a full run. release() had the inverse defect: it handed work back under the extensionless name, invisible to both the watcher and this reader. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reader looked for tasks/<task-id>.json and parsed JSON. Every bridge writes tasks/<task-id>.txt in the header format, so on a real workspace read_payload returned None for every delivered task and residue never saw a payload. The sentinel design (docs/worker-pool-design.md, d693eb7) keeps that file immutable in tasks/ until finish; this module now points at it by its real name and hands back its text -- parsing headers is local_task_protocol's job. Control: pointing payload_path back at .json turns 12 tests red. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The bridge encodes a channel instance into the id (task-<instance>~<broker-id>); the id class had no ~, so a worker bound to a local-homeserver room saw its sentinel on disk while pending() and sweep() listed nothing. Found by worker 1 during the first live run. Control: removing ~ turns 1 test red. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The bridge drains results/<id>.txt on delivery, so after a drain the flag is the only durable evidence that a task completed. residue() read the flag only when a result was present, so a crash between finish step 2 (flag) and step 3 (archive) followed by a drain left .claimed + flag + payload and no result, which read as died-mid-work: sweep released it and finished work ran again. The flag is now terminal on its own. Found by worker 1's alignment review of the live run; control (old ordering) turns the new test red. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner decision 2026-09-10: "claim" was the rescue line's word for a core SELECTING work from an unassigned pool. Here a worker never selects — the sentinel's folder already decided the recipient — so the rename records that the assigned recipient took up work given to it. One rename, one meaning. .claimed -> .accepted, claim() -> accept(), claimed() -> accepted(), and the pending state is "pending" rather than "unclaimed". The mechanism is unchanged: one atomic os.rename, suffix substituted never appended, loser gets OSError. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The .accepted rename landed on a live pool with fifteen sentinels already written as .claimed. The new reader recognised neither name in find() nor in the sentinel regex, so accepted work read as never delivered — and the router's both-names guard, which relies on find(), let a second sentinel be created for work a worker was already holding. Reproduced against the live workspace. The legacy suffix is now recognised everywhere the accepted one is. Nothing writes it; it exists so a rename cannot strand work in flight. Control: removing `\.claimed` from the pattern turns the new test red. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e is exclusive Three findings from review, each with a control that reproduces it on the previous head: - `watch` emitted only the boot sweep's `ready`, then seeded `announced` from every pending name, so a delivery released from `.accepted` (or the legacy `.claimed`) at boot sat pending with nobody told. Both are emitted. - residue equated a result FILE with completion; an empty or whitespace-only placeholder suppressed recovery. The shared `delivery.readiness` predicate decides now. - `accept()` used a bare rename, which silently replaced an accepted name when a stray pending one appeared, so two accepts of one task both "succeeded". A per-folder lock (`.lock`, never parsed as a sentinel) arbitrates publish/accept/release; a second pending name is refused.
…ry, separately Rebased onto #4106 (legacy .claimed suffix recognised). Layer unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…currency _write was atomic for the READER -- os.replace guarantees no truncated record is observed -- but the update was read-modify-write with no lock, so of 24 concurrent record_session calls across processes only 15 rows survived, with zero non-zero exits. Silent loss of a row the module itself says cannot be reconstructed later. Found in review by Echo (sutando-sonichi) on this PR and reproduced here before fixing. Every read-modify-write -- record_session, start_incarnation, end_incarnation -- now runs under an exclusive flock on a sidecar, and the idempotence check in record_session shares that section, so two writers racing on one session id yield one row rather than two. The temp name also gains a uuid: per-pid alone collides across threads in one process (10 FileNotFoundError of 24). The lock now prevents that race, so the suffix is a second guard and the docstring says so. Four concurrency tests call the production writer, per CLAUDE.md. Control: removing the lock turns three of them red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
64f4f54 to
54b6f95
Compare
550b999 to
d5dc232
Compare
Rebased onto #4107 (lineage writes serialised). Layer unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@cla-assistant check |
There was a problem hiding this comment.
Changes requested at bb6e054e6b6fe49bc9c0eb53185d530fa4a8ea6d (self-author review). The late head update leaves the identity module, suite and workspace dependency byte-identical; 33 identity tests pass. The production start/end interleaving still leaves current.json pointing at an ended incarnation (src/worker_identity.py:183-184):
end before pointer publication: current=run-a; history run-a has ended_at/end_reason=exited
sequential start/end control: current incarnation_id=null
Keep pointer publication and termination inside the same per-worker writer contract. Worst case is routing or attaching to a run already recorded as ended. The open P1 remains actionable; no duplicate inline finding.
Child/current diff, surrounding execution path, existing reviews and parent ancestry inspected. Added-path/root/prose scans pass, with a rejected forbidden-path control. CI/CLA were refreshed; running checks are not approval evidence. No contributor branch or live service was modified.
Reviewed by Qingyun’s Codex automation.
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE at bb6e054e6.
The design decision I want to name is the symmetric lineage guard, because it is the half people skip:
if relation == RELATION_FORKED and not parent_session_id:
raise IdentityError("a forked session must name the session it came from")
if relation == RELATION_NEW and parent_session_id:
raise IdentityError("a new session has no parent — inventing one falsifies the lineage")The motivating failure is a fork with no parent — that is the one that loses information, and it is the one a guard normally stops at. Refusing new with a parent guards the opposite direction, where nothing is lost and something is invented. A fabricated parent is worse than a missing one, because every later reader inherits it as fact and no measurement afterwards can tell it from a real edge.
And both directions are tested, which is what makes it a guard rather than a comment:
:74 test_inventing_a_parent_for_a_new_session_is_refused
:82 assertRaises(IdentityError) -> record_session(..., relation="forked") # no parent
33 tests, including test_a_watcher_restart_is_not_a_run — the negative the body claims and the easiest one to leave unwritten, since nothing fails if a restart quietly counts as a run until someone reads the history a month later.
On "this cannot wait for the launcher"
I cannot reproduce your 473-done-flags measurement — that probe is host-shaped and found nothing here, so I am not reporting a number from it either way. But the claim it supports is independently visible on this host:
session transcripts under .claude-sutando/projects/: 510
One core, 510 sessions. That is the thing the record has to model, and it settles the "why not derive it later" question without needing your exact figures: a mapping that only exists while a run is live is not recoverable from artifacts that never carried it.
What I did not review
docs/src-map.md beyond confirming the entry; the interaction with #4106 (I read this layer's diff, which is what the restack made possible); and no live run — there is no caller in this layer by design. The --resume / --fork-session behaviour the lineage table rests on is runtime behaviour I took from the body rather than exercised.
yixuan-ag2
left a comment
There was a problem hiding this comment.
APPROVE at bb6e054e6 — second approval, and I verified @john-the-dev's central claim by mutation rather than agreeing with it.
His symmetric-guard claim holds under mutation
He says both directions of the lineage guard are tested. I removed each half separately and ran the suite:
drop `RELATION_FORKED and not parent_session_id` -> FAILED (failures=1)
drop `RELATION_NEW and parent_session_id` -> FAILED (failures=1) <- the half people skip
restore -> OK (33 tests)
Each half kills exactly one test and the restore is green, so both are genuinely covered, not merely accompanied by a test with a matching name. His reasoning about why the second direction matters — a fabricated parent is worse than a missing one, because every later reader inherits it as fact — is the part I would keep in the file.
What I add: the lock, which he listed as not reviewed
This module has a second critical section he did not cover, and I came to it with a specific prior — the sibling arbitration() lock in #4106 had zero coverage. I found that by removing both flock calls there and watching all 60 tests still pass.
Same probe here, opposite result:
remove `fcntl.flock(fh, fcntl.LOCK_EX)` from _appending() -> FAILED (failures=3)
killed: test_no_incarnation_row_is_lost
test_no_session_row_is_lost_across_processes
test_no_session_row_is_lost_across_threads
_spawn_processes starts 24 real subprocess.Popen writers against one file and asserts 25 rows survive, with a threaded twin. That is contention, not a sequential simulation, and it is the difference between a lock that is documented and a lock that is pinned.
This is worth saying out loud because it changes what the #4106 gap means. Two adjacent modules, same author, same series: this one tests its lock with real multi-process contention; #4106's is untested. So the gap there is an oversight, not a house style — and the technique to close it already exists in this PR. My suggestion on #4106 stands and just got cheaper: _spawn_processes is the pattern to copy.
Read but not counted
- @john-the-dev could not reproduce the 473-done-flags figure and reported 510 session transcripts instead. I did not attempt either measurement, so I am not corroborating a number — only noting that the design question ("why not derive lineage later?") does not depend on which figure is right.
--resume/--fork-sessionruntime behaviour: same position as his. The lineage table rests on it, neither of us exercised it, and I would not want two approvals to read as having verified it.- No live run, and none is possible in this layer by design — nothing imports
worker_identityyet. That is also why I am comfortable approving without a post-restart witness: there is no live path to witness.
One forward-looking note for whoever sequences the series: the moment a caller lands, worker_identity becomes live-path and REVIEW.md lesson 15 applies to that PR, not retroactively to this one.
Rebased onto #4107 (lineage writes serialised). Layer unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es (#4109) * feat(pool) [3/5]: bindings the owner writes, a roster the core compiles Rebased onto #4107 (lineage writes serialised). Layer unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(pool): a corrupt declaration is refused and the last roster survives `load_bindings` read an unreadable or mis-shaped bindings file as `{}`, so a corrupt owner declaration compiled into a valid EMPTY roster one version up and every bound task went to the core with no error anywhere. Absent stays a valid empty declaration; unreadable raises RosterError before the roster is written, so the last good one keeps routing. A binding naming more than one member is refused for now: members would share one payload and one result path, and the first to finish archives the others' work. Per-member identity is the next layer, not this one. --------- Co-authored-by: qingyun-wu <qingyun@ag2.ai> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What this is
The identity half of worker creation, specified by the owner in #4105. Part of the spawn-worker chain for the design in #4037.
Three questions, three answers. Collapsing any two loses what the others cannot recover:
Why lineage and run history are separate records
The runtime already distinguishes the two session cases —
--resume <id>keeps the session id,--fork-sessionmints a new one from the same history. So:S1after an exitS1S2fromS1S2, originS1S2S3S3, no parentS3One history cannot express both without lying about one of them.
Lineage stores the relation, never a flat ancestor list. A list cannot distinguish "s2 continued from s1" from "s3 is unrelated", and inventing a parent for an independent session is a fabrication every later reader inherits — so
relation: newwith a parent is refused outright.Why this cannot wait for the launcher
It is not derivable afterwards. Measured on this workspace: probing a core's own 473 done-flags, its six newest tasks resolved to three different sessions, because every restart began a new one.
A worker maps to many sessions over its life, so
runtime_session_idbelongs to the incarnation and must be written when the run starts. core-1's mapping is already unrecoverable — not expensive to reconstruct, gone. Size and mtime are candidates; mention-counting finds sessions that talk about a core, not the one that was it; transcripts self-report only their own session id.Also recorded, each earned
--folderdecides where a session's history is findable;end_reason— "no heartbeat since 16:40" means state unknown, not stopped;Evidence
Mutations, each breaking a distinct test:
scripts/review-checks.shPASS. New files only —src/watch-tasks-stream.shuntouched.Draft because
The launcher that calls this does not exist yet, so nothing writes these records in production. Landing the schema first is deliberate: every day without it, another worker's lineage becomes unreconstructable the way core-1's already is.
Refs #4105, #4037