Repository navigation
Let trusted hosts stop executions and join owned cleanup - #1008
Conversation
The generic half, on its own: an execution a host can stop, the durable root control it is written through, and the documentation for both. `HostExecution.stop()` answers for the stop and the join, never for the document. It reaches a document still running, unwinds it, and joins every teardown the execution owns before it answers — a stop that returned while an owned cleanup was still running would be reporting an ending that had not happened. What the journal keeps is the terminal representation the protocol already has: one cancelled `Close` for the root, a status and nothing else. No answer, outcome or successful payload is invented for work the document never finished, and asking again writes no second ending. A persistence failure raised while that ending is being written stays the run's own failure by identity: the stop answers with it, the owned teardown is still attempted and joined, and nothing compensating is appended in its place. A backing failure is still never a workflow terminal. `EP30` and `EP31` prove both at the seam itself — `executeInstalled` and nothing else, with no session, entry or command in them. The existing EP29 terminal-winner, fatal-versus-ordinary and durable root cases are unchanged beside them. This carries no command, session or presentation code. What consumes it is a separate delivery with this one as its ancestor.
The witness claimed partial output and had none. Its document was a single blocking component, and `execution.output` was never read — so everything about the stop was proved except the thing a reader of the comment would have taken for granted. The document prints a prefix now, before it reaches anything that blocks, and the row drains that channel from the moment the execution exists. It waits for both facts — the prefix seen and the component blocked — so the stop happens at a known point rather than a timed one. Afterwards the channel's close value is compared to exactly what was read: a stop that threw the partial text away, or closed the channel empty, passes every other assertion here and fails that one. Asking again adds neither a byte of output nor a second ending. The held cleanup gate, the non-success completion, the single status-only cancelled root Close and EP31's refused-append identity, cause, joined cleanup and absent compensating terminal are all unchanged. EP31 reads its error through the Result's own inferred type instead of asserting one. No production code is touched by this.
PR #1008: Let trusted hosts stop executions and join owned cleanup10 files, +2718 / -69 Scope🔴 PR has 2787 lines changed. Split into focused PRs. 🟡 2787 lines changed. PRs under 400 receive more thorough review. StructuralOxlint structural signals:
Slop
Static AnalysisOxlint: 64 diagnostics across 3 files (13 rules) no-shadow (13): packages/cli/src/sidekick-acp/posix.ts, packages/core/src/execute.ts CorrectnessFILE: packages/durable-streams/run.ts, PATTERN: dead-construct, CONCERN: Unused variable FILE: packages/core/src/execute.ts, PATTERN: dead-construct, CONCERN: Unused variable FILE: packages/cli/src/sidekick-acp/posix.ts, PATTERN: signal-cluster, CONCERN: Multiple Oxlint violations (no-shadow, no-unused-vars, no-redundant-type-constituents) in same file, QUESTION: Are these violations due to unreviewed generated code or incomplete refactoring? FILE: packages/core/src/execute.ts, PATTERN: signal-cluster, CONCERN: Multiple Oxlint violations (no-unused-vars, no-empty-function, no-unsafe-type-assertion) in same file, QUESTION: Are these violations due to unreviewed generated code or incomplete refactoring? |
The two failures CI found on this head, and nothing else. **An owner's exit removed a replacement it had never published.** `same()` identified a published attachment file by device and inode. An inode number is reused, and ext4 on the runner did reuse one: the file this owner wrote was unlinked, a replacement was created in its place and was handed the same number, and the exit took it away. `ENOENT` on the read that followed, in both the Deno and the Bun job. The correction is the one the dependent layout branch already carries, taken whole and unchanged: the moment a file came into existence is part of its identity. Measured here — a creation time is reported, an untouched file keeps it, and a file created after another was unlinked does not share it. Strictly stricter, so it can only ever leave a name alone and never remove one this owner did not publish. This host does not reuse inodes eagerly and so cannot reproduce the failure itself; the preserved run logs are that evidence. **A directory was read as a list.** `readdir` of `.acpx` was compared to a literal pair in one order, and a runner handed the two names back the other way round. It is sorted now, exactly as the assertion above it already was: what this row is about is which names are there, and a directory promises nothing about the order it enumerates them in. The membership is unchanged and nothing is skipped, slept on or excluded.
taras
left a comment
There was a problem hiding this comment.
Fable review — correctness, performance, minimalism
Verdict: no blocking defect found in the Stop capability. Three actionable findings are posted as line comments (one platform-conditional cleanup concern in the generic ACP ownership change, two minimalism/doc items). Reviewed: the complete published diff 267cc55f..b639fab6 (9 files, 3 commits), architecture.md + AGENTS.md + the Effection v4 guidance, and the supplied CI/receipt evidence.
Correctness (what I checked and how)
- Ordering of a stop.
stopDocumentasks the root first, then halts the runner task and joins it, then closes output with exactly the text that cleared the scanner, then settles once. The refused branch (requestInterruption() === false) halts nothing and waits on the completion, so an in-flightClose(ok)is never cancelled — EP29 covers it and my harness confirmed it on 90 samples (oneokclose, stopOk, completionOk). - Claim exclusivity in
durableRun.open → claimed → appending → settled/endedis only moved from inside the run; the ordinary return/catch paths refuse to acknowledge a terminal once claimed; the cancellation finalizer appends the one cancelledCloseand re-checks the sticky durability failure and replay alignment first.RootInterruptedis bypassed by object identity, not class, and the test proves a carried interruption still gets anerrclose elsewhere. - Reachability of the "workflow returned after the claim" path from the host.
HostExecution.stop()claims and callsrunner.halt()in one synchronous frame. Effection 4.1.0's reducer replaces a queued resume with the unwind (routine.resume(Ok())overwritesresumeWithwithunwinding = true), so from the host that path is unreachable; the completion for an accepted stop is alwaysHostExecutionError("this document execution was stopped")or the halt's own failure. The durable-streams test reaches it only by claiming without halting. - Failure identity.
stopInvocationranks durability, then Files, then the stop's own failure, then teardown, each by identity, and EP29/EP31 assertrefused === failed. Consistent withreconcile. - One edge worth knowing, not a defect: a Stop that lands while a replayed completed run is still restoring its output (before
durableRunwould have handed out a control) halts the restoration and the handle completes as stopped while the journal keeps itsokterminal. Nothing is written twice; the handle and the journal just disagree in that window. If that matters to the REPL owner,stopDocumentcould wait on the completion when the journal already holds a root close. - Focused tests at head, locally under Deno 2.9.7:
execution-protocol,durable-run,session-ownership-cli— ok, 6 passed (92 steps), 0 failed. First required green: run 40721115143.
Performance (measured, current head b639fab; base 267cc55 for the comparable workloads)
Boundary: application operation boundary (performance.now() around yield* execution.stop() inside one Effection run()), canonical executeInstalled + InMemoryStream, inline one-paragraph document suspended in a component with an ensure() cleanup gate. Warmup 5, 30 measured samples per workload per runtime, Apple M1 Max, Deno 2.9.7 / Node 26.5.1 / Bun 1.3.14. No UI, terminal or paint boundary is involved in this PR, so no FPS/frame/input figures exist here (that obligation stays with PR #1004).
| workload (ms, p50 / p95) | Deno | Node | Bun |
|---|---|---|---|
| W1 stop of a running document, cleanup released at once — ask→answer | 0.585 / 0.779 | 0.784 / 1.157 | 0.699 / 0.886 |
| W1h same with cleanup deliberately held; control cost after subtracting the measured hold | 0.593 / 0.688 | 0.683 / 0.838 | 0.609 / 0.817 |
| W2 eight concurrent asks on one running document — all answered | 0.789 / 0.897 | 0.866 / 1.010 | 0.816 / 0.973 |
| W2 repeated ask after settlement (each) | 0.002 / 0.004 | 0.002 / 0.005 | 0.002 / 0.008 |
W3 ask during an in-flight Close(ok) — journal release→answer |
0.301 / 0.350 | 0.288 / 0.365 | 0.304 / 2.338 |
W4 durableRun + control + halt (no document) |
0.122 / 0.148 | 0.135 / 0.160 | 0.081 / 0.096 |
| B1 scope-cancel of the same document (not a Stop equivalent: no close, output never closed), head / base | 0.365 / 0.409 | 0.446 / 0.539 | 0.393 / 0.456 |
| B2 ordinary run to completion, head / base (p50) | 3.48 / 3.71 | 4.06 / 5.06 | 4.80 / 5.00 |
Every sample held its invariants (one cancelled root close carrying only status, output closed with exactly the observed partial text, cleanup joined before the answer, all repeated asks Ok). The added cost of a Stop over a bare scope cancel is ~0.2–0.35 ms at p50, of which ~0.1 ms is the cancelled-close append itself (W4 vs the no-control halt). Ordinary completion shows no regression on any runtime; the only measurable delta is the isolated durable-only halt on Deno (0.025 vs 0.008 ms p50 — two extra ensure() registrations), invisible inside any document run.
CPU profiles (Node and Bun --cpu-prof; Deno via the V8 inspector since it has no --cpu-prof): hot self-time is Ajv schema codegen for component props and Effection scope construction, i.e. document setup, plus the tsx loader under Node (harness artefact). durable-streams/run.ts self-time is 3 ms of 3.1 s sampled on Node; inclusive time of stopDocument+haltRunner+stopInvocation is tens of microseconds per stop. Memory/allocation was not measured and nothing is claimed about it. The ownership change in posix.ts was probed rather than profiled (see the line comment).
Minimalism
The capability is small for what it guarantees. Two items would make it smaller or truer without changing behaviour (line comments on run.ts and execute.ts). The architecture and durable-integration paragraphs are accurate to the code.
Not covered here
Linux inode reuse was not reproduced locally (the preserved CI failure remains that evidence). No UI path was exercised. No merge or CI action was taken.
| */ | ||
| function same(a: Stats, b: Stats): boolean { | ||
| return a.dev === b.dev && a.ino === b.ino; | ||
| return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs; |
There was a problem hiding this comment.
Finding 1 — correctness (platform-conditional, PLAUSIBLE, not reproduced on macOS). The creation-time identity is captured before this owner's own metadata operations, so on a filesystem/runtime pair that substitutes ctime for birthtime the owner's exit leaves its own files behind.
Where: stageIdentity is read at line 416, then link(stage, path) at 419 and the stage unlink at 422; directoryIdentity is read before chmod(directory, 0o700) at 384. Each of link, unlink and chmod updates the inode's ctime. Node documents that birthtime "may instead hold either the ctime or 1970-01-01T00:00Z" where creation time is unavailable (libuv's non-statx fallback on Linux is the ctime case — e.g. a seccomp profile that denies statx). On such a host the exit comparison in same() is false for the descriptor, the stage file and the private directory, so removeOwned/rmdir leave them: every publish leaks a stale descriptor and a .sidekick-<uuid> file, and a later readDescriptor reads the stale descriptor. The direction is fail-safe (nothing foreign is ever removed), which is why I rate it a cleanup regression rather than a safety defect.
Evidence: measured on this host (APFS, Node 26 and Bun 1.3): across write → link → unlink → chmod on one inode, birthtimeMs stayed identical while ctimeMs changed at every step — i.e. the ordering only matters when the substitute is ctime, and CI's ext4 under statx reports a real btime, so green does not exercise it. The comment's claim that "both readings carry the same substitute" holds for the epoch-0 substitute only.
Recommendation: take the retained identity after the last metadata operation this owner performs — after link, re-read lstat(path), check dev/ino equal the pre-link reading before adopting it, and use that reading for both the stage removal and descriptorIdentity; read directoryIdentity after the chmod. That keeps the stricter predicate and makes it hold under either substitute.
| } | ||
|
|
||
| /** | ||
| * One run's own authority to end its root as cancelled. |
There was a problem hiding this comment.
Finding 2 — minimalism/doc (CONFIRMED). This JSDoc block ("One run's own authority to end its root as cancelled…", lines 85–92) is orphaned: a second JSDoc block follows it immediately and attaches to class RootInterrupted, so this one documents nothing, while export interface DurableRootControl (line 116) — the thing it describes — has no doc comment.
The RootInterrupted comment also says it is "Never seen outside this module's own frames", but the return path throws it to the caller by design (line 369) and the test "a request accepted while open ends the run, however the workflow returns" asserts the caller catches exactly that object.
Recommendation: move this block onto DurableRootControl, and reword the RootInterrupted sentence to say it leaves durableRun as the caller's error with no terminal written around it. No behaviour change.
| * within a kind. What comes back is the object the classifier found, never a | ||
| * rebuilt one, because the engine's fences match on identity. | ||
| */ | ||
| function fromEach<T>( |
There was a problem hiding this comment.
Finding 3 — minimalism (CONFIRMED, behaviour-preserving). fromEach is a third spelling of the kind-then-occurrence ranking that reconcile (line 4055) and reconcileExpansion (line 3970) already write as ?? chains over durabilityFailure / filesFatalFailure, all three returning the classifier's object by identity.
Recommendation: either write the two lookups in stopInvocation as the same ?? chains the neighbours use and drop fromEach, or keep the helper and use it in the two existing sites so there is one spelling of the rule. Result is identical either way (first non-undefined classification in argument order, identity preserved), and the EP29/EP31 identity assertions keep it honest.
An attachment captured dev, ino and birthtime when each object came into being, and then kept changing those objects: the directory was chmod'd and had a socket created inside it, the staged descriptor was written, linked and unlinked. Every one of those moves ctime, so where birthtimeMs stands in for a ctime the kernel does not expose, a reading taken at creation no longer describes the file by the time cleanup judges it, and an owned file is left behind. Re-read each identity once this owner has finished changing the object it names, and judge the directory before its socket child is taken out of it, since removing a child changes the directory the question is about. A second reading answers for the same inode or it is somebody else's file; sameNode says so rather than publishing theirs. The first reading stays in place until the second replaces it, so a cancellation in between still joins and adopts. Also: give DurableRootControl back the doc that had drifted off it, and say plainly that RootInterrupted does reach the caller on the return path. And spell the failure precedence one way — reconcile and reconcileExpansion now use the same fromEach the stop path already had.
Reading an identity again is only worth doing if the reading is checked, and these were checked in the wrong place. The stage comparison took its own fresh Stats for both sides, so it could not see a replacement at all. The published one stored the reading first and threw second — and the finalizer joins that acquisition, so the identity it had already stored was a foreign file's, and cleanup unlinked a file this owner never published. Cancellation made it worse, not better: the callback still runs, and nothing after it does. So the check moves inside the retained callback, which is where authority is actually installed, and it compares against an identity captured before the refresh that the refresh never writes over. A reading of another device and inode is refused there; the identity this owner already held stays, and the replacement stays somebody else's file. The directory question moves too. Closing the listener unlinks its socket — Node's documented behaviour for a path it bound — and taking a child out of a directory changes the directory, so asking afterwards asks about a directory this teardown has already modified. It is asked once, before any of that, and every listener and socket join still happens before the directory is removed. Four cases hold a refresh's read in flight: a stage name replaced under it, a published name replaced under it, a cancellation delivered while it is pending, and the directory judged while its socket is still in it. The first three remove the replacement without the fix; the fourth reads the directory after its socket is gone.
Asking which directory this is before anything else can change it was right. Raising the answer's failure from there was not: a read that fails is not a listener that closed, a peer that was let go or a name this owner knows it published, and all three were still this teardown's to finish. The attachment and its socket were left behind with the error on its way out. So the failure is kept rather than raised, and the stages that never depended on that question run. Ownership stays unconfirmed, which keeps `rmdir` off the table — a directory this owner could not identify is not one it may remove, and that is the direction to fail in. The error this teardown ends with is the one it was handed, by identity. When finishing also fails, both travel: the reading that failed and the removal that failed, neither standing in for the other. Two cases fail the directory's teardown read with EIO through the reader seam already there. Without this, the first leaves the descriptor and the socket on disk, and the second reports only the read. With it, the published name and the socket are gone, the accepted connection and the listener are joined, the unidentified directory is still there to be looked at, and the original error is observable either way. Also: the two message assertions added last time read the error by narrowing now, not by casting it.
A descriptor that will not unlink says nothing about the staged name beside it, and the one try/catch around the whole teardown treated it as if it did: the first stage to fail ended the rest. Cancel a publication at the staged refresh — both names exist then, one inode under two of them — make the parent unwritable, and only the published name was ever attempted. The staged one stayed on disk and its failure was never reported, so what came back said less than what happened. Each stage is attempted on its own now and keeps what it fails with. The order is the order they were met: the reading of the directory first when that is what failed, then each name as it was tried. One failure still leaves as itself, because a caller's fence may be looking for that exact object; more than one travel together. The early ownership decision, the joins, validate-before-adopt and the fail-safe unconfirmed directory are where they were. Nothing is removed that this owner did not publish, and nothing is removed because removing it would tidy up the leftovers. The new case holds the staged refresh, takes write permission off the parent and cancels there. Before this it reported two failures and left the staged file; now it reports three, the reading and both names, and both are still on disk to be seen.
| // inside the runner task, because the stop below is a *second* frame that has | ||
| // to read and finish the same things: the text that actually cleared the live | ||
| // scanner, the one settlement this completion ever takes, and the one close | ||
| // its consumers ever see. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // its consumers ever see. |
| // the run finishes writing, publishes its own result and output, and this | ||
| // waits for it. Halting now would cancel an append over a backend that | ||
| // may already hold it, and a cancelled ending written around it would be | ||
| // this run recording two. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // this run recording two. |
| // unwinds after everything the workflow owns. Inert unless this root | ||
| // accepted a claim while it was still open: an ordinary halt reaches here | ||
| // unclaimed and appends nothing, which is what every caller without a | ||
| // control has always seen. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // control has always seen. |
| control({ | ||
| requestInterruption(): boolean { | ||
| if (phaseNow() === "claimed") { | ||
| // The same ask, answered the same way. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // The same ask, answered the same way. |
Two of these cases replaced the attachment by unlinking it and writing a new file at the same name. On this host that is a different file and the cases pass. On the CI host it is not: the published name is the inode's last link by then, Linux frees the inode at the unlink and hands its number straight to the next file created, and the stranger arrives wearing the identity of the file it replaced. One case waited out its five minutes because nothing refused; the other watched the owner's cleanup delete the stranger it had adopted. So the stranger is written under its own name while ours still holds the one under test. Two files existing at the same moment cannot share an inode — no allocator can do that — and the cases now say so out loud before replacing anything. Then one rename moves it over, which never leaves the name empty. The refusal, the absence of publication, the preserved replacement with its own inode and bytes, the cancellation and the joined cleanup are all asserted exactly as before. The published case also stops suspending after a publication it does not expect, so a future regression says what is wrong instead of taking three hundred seconds to say nothing. This host cannot reproduce the CI failure — it never reuses an inode, measured under all three runtimes — so a green run here does not establish the Linux one. What it does establish is that the replacement no longer depends on which host it runs on.
A device and inode read off a name is a reading of history. Once the last link to a file goes, that inode number is handed to whoever creates the next one — so the name this publication had just written could come back wearing its number, pass the check, and become the authority that later unlinked somebody else's file. Timestamps cannot settle it: one runtime reports them to the second, and a host that has no creation time to report substitutes one that moves. Lifetime settles it. The publication now opens the file it creates and keeps that handle for as long as it is making claims about it. While the handle is open the original cannot be freed, so its number cannot be given away, and "is this name still that file" has an answer rather than an inference. The numbers come from the handle, as bigints, because rounding an inode number to a double would put a second ambiguity where this removes the first. A name's own reading answers that one question and never becomes the answer. The handle is taken even when the caller is cancelled between opening and owning, identified from itself rather than from a pathname when cancellation got in first, and let go of last — after every name decision, as one more stage whose failure joins the others in its turn rather than replacing them. Sockets, directories and unpinned names keep the predicate they had. The five cases describe a recycled number only where one could be recycled: the live original is checked, not assumed, and a closed one is what the old lifetime hands them.
| let directoryAcquisition: Promise<string> | undefined; | ||
| let directoryIdentity: Stats | undefined; | ||
| // The reading the directory was created with. Never written over by a | ||
| // refresh: it is what a refreshed reading has to still be. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // refresh: it is what a refreshed reading has to still be. |
| // taking a child out of a directory changes the directory. Asked | ||
| // after that, the question is about a directory this teardown has | ||
| // already modified; asked here, it is still a fair one. Every join | ||
| // below still happens before the directory is removed. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // below still happens before the directory is removed. |
| // published, and every one of those is still this cleanup's to | ||
| // finish. So the failure waits below, where it is what this teardown | ||
| // ends with — and `ours` stays false, because a directory this owner | ||
| // could not identify is not one it may remove. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // could not identify is not one it may remove. |
| // letting go of it is the final thing released rather than the first. | ||
| // A close that fails joins the others in its turn; nothing is | ||
| // removed on an unpinned reading afterwards, because there is no | ||
| // afterwards. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // afterwards. |
| // inside the runner task, because the stop below is a *second* frame that has | ||
| // to read and finish the same things: the text that actually cleared the live | ||
| // scanner, the one settlement this completion ever takes, and the one close | ||
| // its consumers ever see. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // its consumers ever see. |
| // retained history the definition did not align with is divergence rather | ||
| // than a stopped run: neither is papered over with a cancelled close, and | ||
| // neither is swallowed — whoever asked this root to stop has to learn | ||
| // which failure ended it. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // which failure ended it. |
| control({ | ||
| requestInterruption(): boolean { | ||
| if (phaseNow() === "claimed") { | ||
| // The same ask, answered the same way. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // The same ask, answered the same way. |
| } | ||
| // The phase moves first, so a cancellation asked for from here on joins | ||
| // this ending rather than cancelling the write and compensating over a | ||
| // backend that may already hold it. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // backend that may already hold it. |
| // settles it, and a terminal written around that would be this run | ||
| // recording two endings. By identity, and only this run's own: an | ||
| // interruption some other root minted is an ordinary failure here, and it | ||
| // gets the ordinary error close every other failure gets. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // gets the ordinary error close every other failure gets. |
| }; | ||
|
|
||
| // The same claim rule as the ordinary return: a Stop that holds this open | ||
| // root is its ending, and this failure does not acknowledge one around it. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // root is its ending, and this failure does not acknowledge one around it. |
The three posted line comments on PR #1008 are capability-owned; the layout work here is unchanged by them.
Why
A trusted host needs to stop one running document, retain its partial output and wait until its resources are released. This supplies the execution capability consumed by the separate REPL layout/Stops PR #1004.
What changes
Before: ending the host scope leaves the document unfinished; the host has no explicit operation that retains a cancelled root ending and joins that invocation.
After:
executeInstalled()returns a boundHostExecution.stop(). Stop joins owned cleanup and returns an Effection Result. Emitted text remains available, a normal terminal already being written keeps its outcome, and persistence or cleanup failures retain their established identity and precedence. Repeated Stop adds no output or second ending. Ordinary scope halt keeps its unfinished-history behavior.The prerequisite also includes a bounded generic ACP cleanup repair: published attachment identity checks creation time alongside device and inode, preserving replacements with a reused inode and a different creation time. The session-ownership test compares exact directory membership independently of enumeration order. These corrections support the existing CLI attachment lifecycle; they add no layout behavior.
How it works
The durable root uses the existing status-only cancelled Close and writer. Host authority stays bound to the owned execution. The ACP repair strengthens the existing identity predicate without changing acquisition or joined teardown ordering.
Review guide
Review
HostExecution.stopinpackages/core/src/execute.ts, the root control inpackages/durable-streams/run.ts, and EP29–EP31 alongside the durable-root regressions. The final CI correction is confined tosidekick-acp/posix.tsandsession-ownership-cli.test.ts.What must stay true
How to verify it
The unchanged host-Stop/durable tests passed at b60e58d: 5 passed, 89 steps, 0 failed. EP30 observes nonempty output before Stop and compares the channel's final value; EP31 checks refused-append identity and joined cleanup. Their blobs remain unchanged.
At b639fab:
deno task test packages/cli/tests/repl-sidekick-acp.test.ts packages/cli/tests/session-ownership-cli.test.ts: 5 passed, 26 steps, 0 failed.bun test packages/cli/tests/repl-sidekick-acp.test.ts packages/cli/tests/session-ownership-cli.test.ts: 26 pass, 0 fail.deno task test packages/cli/tests/repl-sidekick-lifecycle.test.ts packages/cli/tests/repl-sidekick-recovery.test.ts: 19 passed, 68 steps, 0 failed.Changed-path typecheck, lint and formatting receipts are clean. The standalone build passed at 0ab6863; that historical build does not prove this head's changed CLI build input. Current-head remote build/required CI and the first-green Fable delivery review remain pending.
Scope
Included: trusted-host Stop, canonical durable-root control, focused tests and generic documentation, plus the bounded generic ACP cleanup/test prerequisite.
The dependent #1004 retains REPL entry admission/recovery policy, session Stop dispatch, layout and UI test reorganization. Its unfinished work is not claimed complete here. Follow/shared queue presentation remains deferred. No dependency or journal schema is added.
Risks and limitations
Creation-time comparison strengthens the prior device/inode check; it is not a universal uniqueness guarantee on filesystems lacking precise creation timestamps. Local passing receipts do not claim to reproduce Linux inode reuse or establish remote success.
Scope confirmation
Current-head standalone build:
deno task buildpassed atb639fab612b69a608603d777b28621266d2b4eedbefore this normal push. Required remote checks and the independent first-green review remain delivery gates.