Skip to content

Recover ownership of a stuck agent session - #996

Merged
taras merged 2 commits into
mainfrom
agent/session-ownership-current-format-20261026
Oct 27, 2026
Merged

taras merged 2 commits into
mainfrom
agent/session-ownership-current-format-20261026

Conversation

@taras

@taras taras commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Recover a stuck agent session with an explicit force operation while retaining its native session identity and history. Ordinary invocations continue to respect an active owner. Forced recovery retains an explicit record of unproved cleanup and unknown outcome.

Internal ownership records use one strict current format. The architecture makes the prerelease policy explicit: change internal persistence in place, with no version compatibility branches, migrations or upgrade adapters, including manual paths.

The integration preserves the merged ACP startup and mount behavior. Fresh focused Deno validation passes 13 containers and 94 steps covering ownership, forced recovery, cleanup, CLI behavior and ACP regressions. Required CI, once-first-green Fable review, runner weight measurement and final metadata verification are handled by the delivery workflow.

Integrates the reviewed private ownership delta (8ecd5cf1..bb59899f) onto
main: one strict unversioned ownership record with a nullable forced-recovery
marker, `xmd session inspect/recover --force` administration held in host
assembly, joined cancellation of pending ownership I/O, and the architecture
rule that prerelease internal persistence has one current format with no
compatibility branches, migrations or upgrade paths, manual paths included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

PR #996: Recover ownership of a stuck agent session

14 files, +1992 / -316

Scope

🔴 PR has 2308 lines changed. Split into focused PRs.

🟡 2308 lines changed. PRs under 400 receive more thorough review.

🟡 Large PR with no linked issue.

Structural

🟡 5 console statements.

Oxlint structural signals:

  • no-empty-function ×4: packages/runtime/deno-agent-session-coordinator.ts, packages/cli/src/cli.ts
  • no-unnecessary-type-assertion ×4: packages/cli/src/session-ownership.ts, packages/runtime/tests/fixtures/agent-session-owner.ts
  • no-unused-vars ×2: packages/cli/src/cli.ts
  • no-unnecessary-boolean-literal-compare ×1: packages/runtime/deno-agent-session-coordinator.ts

Slop

Oxlint slop signals:

  • no-console ×6: packages/cli/src/session-ownership.ts, packages/cli/src/cli.ts

Static Analysis

Oxlint: 32 diagnostics across 5 files (13 rules)
Density: 0.016 violations/added-line

no-console (6): packages/cli/src/session-ownership.ts, packages/cli/src/cli.ts
no-array-sort (4): packages/runtime/agent-session-coordinator.ts
no-empty-function (4): packages/runtime/deno-agent-session-coordinator.ts, packages/cli/src/cli.ts
no-unnecessary-type-assertion (4): packages/cli/src/session-ownership.ts, packages/runtime/tests/fixtures/agent-session-owner.ts
unbound-method (3): packages/cli/src/cli.ts
no-shadow (2): packages/runtime/deno-agent-session-coordinator.ts
no-unused-vars (2): packages/cli/src/cli.ts
consistent-return (2): packages/cli/src/cli.ts
no-unmodified-loop-condition (1): packages/cli/src/cli.ts
no-useless-spread (1): packages/cli/src/cli.ts
no-unnecessary-boolean-literal-compare (1): packages/runtime/deno-agent-session-coordinator.ts
no-unsafe-type-assertion (1): packages/cli/src/cli.ts
no-floating-promises (1): packages/cli/src/cli.ts

Correctness

No extraneous code patterns detected.

@taras taras left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fable 5.1 first-green review of 9627e8e1 (base 53cdd360).

Verdict: no correctness blocker found. Two concrete duplicate-work simplifications and one export-layout nit are posted inline; none changes accepted behavior.

Correctness. The ownership path keeps its invariants: occupancy is reserved before any I/O, the lease handle and lock are released only after inner scopes settle, the idle write runs only on acknowledged quiescence, and force rereads the exact bytes under the same exclusive() boundary as ordinary acquisition. pending() registers its ensure() before the host call starts, so a cancelled open/lock/write/rename/sync still settles inside its owner and adopts the late result (FR5 covers all six phases, FR6 the first-failure attribution). The parser admits exactly the six current fields, ties release to state, and requires a self-consistent lastForcedRecovery when release is forced (HC13 negative controls). Administration reaches the CLI only through MachineSessionAssembly.ownershipAdministration; hostAcpDependencies still projects no such member (HC14). The thrown-vs-returned asymmetry for an invalid retained record (throw AgentSessionRecoveryRequired at coordinate) is inherited from the base and pinned by the existing CO regression, so it is not a finding here.

Performance. Conditional analysis, no benchmark run and none warranted: per acquisition the new code does one bounded read into a fixed 64 KiB buffer plus one SHA-256 over at most 64 KiB, and publication does exclusive-create, write loop, fsync, rename, directory fsync, the same syscall count as the base's writeTextFile/open/sync/rename/open/sync sequence. useMachineSessions() now builds one host instead of calling useSessionCoordinator() twice, a small win. The one measurable regression is the extra open/read/parse/hash on the recovery-required refusal path (inline comment). If anyone wants numbers, timing coordinate in the CO tests before and after that one-line change is the focused measurement; a broad benchmark would not discriminate.

Minimalism. The record/receipt types, the shared exclusive() and pending() helpers, and the single serializer are the right size for the accepted scope. The inline comments are the only reductions I would make.

Evidence consulted. Required green check and the lint, test-deno, test-node, test-bun, filesystem-contract, smoke, jsr and site jobs pass on this head (run 40426719308). The focused command deno task test packages/runtime/tests/agent-session-coordinator.test.ts packages/cli/tests/agent-session-coordinator.test.ts packages/cli/tests/session-ownership-cli.test.ts packages/cli/tests/cli-help.test.ts packages/cli/tests/repl-sidekick-acp.test.ts packages/cli/tests/repl-sidekick-dispatch.test.ts passed 13 containers / 94 steps on the staged tree 75cf8755, which equals this commit's tree. The xmd-review bot's oxlint signals (no-array-sort, no-shadow, …) come from rules outside the repository's enforced .oxlintrc.json; the enforced lint job passes.

Scope: one-time first-green review. This is not the delivery gate; required delivery verification stays separate.

}
}
if (retained?.state === "active") {
const observed = yield* read(digest);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Redundant second read on the recovery-required path (performance / minimalism).

Line 399 already calls read(digest) and that result carries recordRevision (see line 220), but only .record is kept, so line 414 opens, reads, parses and hashes the same file again under the lock to recover the revision it just discarded.

Consequence: one extra open/read/parse/SHA-256 on every refusal, and the --expect value in the message is computed from bytes read later than the bytes the admission decision used. Under the lock only a non-locking external writer could change them, so this is not a live bug, but the two reads are not provably the same record.

Recommendation (preserves behavior; CO4 and FR3 assert only the error name):

let retained: AgentSessionOwnershipInspection | undefined;
try {
  retained = yield* read(digest);
} catch (cause) { /* unchanged */ }
if (retained?.record.state === "active") {
  return Err(new AgentSessionRecoveryRequired(
    `… --expect ${retained.recordRevision} --force`,
    { keyDigest: digest, recordRevision: retained.recordRevision },
  ));
}
// then use retained?.record.lastForcedRecovery below

const receipt: AgentSessionForceRecoveryReceipt = {
keyDigest: request.keyDigest,
previousRecordRevision: request.expectedRevision,
recordRevision: revision(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Receipt revision re-serializes and re-hashes bytes publish already produced (minimalism).

publish() encodes serializeAgentSessionOwnership(record) at line 226 and writes exactly those bytes; lines 507-508 serialize and encode the same record a second time to hash it for the receipt. The two are equal only because serialization is deterministic, which is true today but is an unstated coupling.

Recommendation: have publish() return revision(bytes) (the hash of what it actually wrote) and use that here:

const recordRevision = yield* publish(request.keyDigest, record);
…
recordRevision,

One serialize/encode/hash instead of two, and the receipt's recordRevision is the hash of the written bytes by construction. FR2/FR4 compare later inspections, so behavior is unchanged.

Comment thread packages/runtime/mod.ts Outdated
hasDenoExecutableObserver,
} from "./deno-executable-observer.ts";

export { AgentSessionOwnershipError } from "./agent-session-coordinator.ts";

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

New exports appended as separate blocks from modules that already have export blocks (minimalism nit).

Lines 185-195 add a second export { … } from "./agent-session-coordinator.ts", a second export type { … } from "./agent-session-coordinator.ts", and a second export { … } from "./deno-agent-session-coordinator.ts", while the originals sit at lines 160-173. Merging AgentSessionOwnershipError, the seven new types and createDenoAgentSessionHost into those existing blocks keeps the module's public surface readable in one place per source file. No behavior change.

The admission decision already reads and hashes the retained record, so
coordinate() keeps that inspection instead of reading the file a second time
under the lock. The revision a refusal advertises for `--expect` now comes
from the same bytes the decision used.

publish() returns the revision of the bytes it wrote, so forced recovery
reports what it published rather than re-serializing and re-hashing the
record; FR2 pins the receipt to the revision a following inspection reads.

The runtime's ownership exports merge into the blocks that already name that
module.
@taras
taras marked this pull request as ready for review October 27, 2026 01:21
@taras
taras merged commit fc1c245 into main Oct 27, 2026
44 of 45 checks passed
@taras
taras deleted the agent/session-ownership-current-format-20261026 branch October 27, 2026 01:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant