Skip to content

Add an offline repair for stranded automation quarantines - #257

Merged
jfrolich merged 3 commits into
mainfrom
repair-quarantined-automation-sessions
Sep 1, 2026
Merged

Add an offline repair for stranded automation quarantines#257
jfrolich merged 3 commits into
mainfrom
repair-quarantined-automation-sessions

Conversation

@tella-butler

Copy link
Copy Markdown
Contributor

Follow-up to #256. That PR stops new automation sessions from being stranded; this one recovers the ones already stranded. Split out because it touches no production code path — it is an offline operator job plus a pure evidence reducer.

Why an explicit release does not work

I originally reported that already-quarantined sessions could be recovered with an explicit {type: "quarantine", action: "release", sessionId}. That was wrong, and the tests here prove it:

  • The wedge detector fences these with commandKind: "run_state:running".
  • SessionKernelStore.quarantineRepairEvidence returns false whenever the run state is one of preparing/starting/running/ask_blocked/interrupted/reattaching and there is no recoverable gateway/delivery settlement. A run_state:* fence has neither, so it short-circuits to false.
  • So quarantinedSession().repairable is false — which is what GET /api/system/session-kernel/dead-letters reports — and releaseQuarantine returns without deleting the row.
  • actor-worker.ts fences every session mutation except quarantineSession and releaseQuarantine, so nothing online can settle the run state either.

That is a genuine deadlock, and it is correct behaviour: the fence exists precisely because an unsettled run state with no owner is unverified. the stranded state is genuinely unrecoverable online in the new test file pins both halves.

Approach: repair the cause, not the fence

Rather than adding a bypass to quarantineRepairEvidence, the job settles the run state offline. After that the unmodified releaseQuarantine passes on its own merits, with every other evidence check still running — ambiguous commands, claimed timers, pending effects all still fail closed. Generic run_state quarantine keeps its full strength for every session that cannot produce the proof below.

No production code path changes.

The durable proof required

Every clause is a fail-closed conjunct (settledAutomationQuarantineEvidence):

  1. Quarantine reason is exactly the ownership wedge string, and commandKind starts with run_state:.
  2. The automation ledger holds a terminal verdict (ok/error) for that exact session id. This is the durable receipt that runAutomation drained its engine stream and reached its completion tail — the precise state the missing settlement stranded. A ledger still reading running proves nothing and is refused.
  3. No run-journal record names the session under any alias (osSessionId, claudeSessionId, runKey). A journal record is a live owner or a recovery claim; either way the run may still execute.
  4. The settlement is a legal nextRunState edge. turn_end for ok, run_failed for error. This never invents a state the machine would refuse — it applies the settlement the completed run should have applied itself.

Ownership invariants

The job walks the quarantine catalog only. It never enumerates placements and never opens every actor database. It asserts opensession, opensession-executor and opensession-session-kernel all report explicit inactive before touching state, reusing assertServicesStopped from scripts/migrate-actor-transcripts.ts.

Usage

sudo systemctl stop opensession opensession-executor opensession-session-kernel
bun scripts/repair-automation-quarantines.ts --dry-run
bun scripts/repair-automation-quarantines.ts
sudo systemctl start opensession

Tests

18 across two files. Beyond the two deadlock-proof tests above:

  • accepts a completed run; settles a failed run as failed
  • refuses without a terminal ledger verdict (both running and absent)
  • refuses while the run journal still owns the session
  • refuses any quarantine that is not the ownership wedge (outbox fence, gateway fence)
  • only applies edges the run-state machine defines (idle + turn_end is refused as a double teardown)
  • settles and releases end to end, asserting currentRunId is cleared
  • dry run changes nothing; the job is idempotent; inspection does not mutate
  • mixed fixture: one repairable session among four unproven ones, asserting the other four keep both their fence and their run state
  • ledger indexing prefers a still-running duplicate, so the repair stays fail-closed

bun run check passes.

Started by Johnny Lin in this OS session

Automation sessions stranded by the missing run-state settlement cannot
be recovered online. The wedge detector fenced them with run_state:<state>,
and quarantineRepairEvidence refuses to release while the run state is
unsettled, so the fence reports repairable:false and an explicit release
is a no-op. The actor also fences every session mutation except
quarantineSession and releaseQuarantine, so nothing online can settle the
state either.

Repair the cause offline instead of weakening the fence: settle the run
state, after which the unmodified releaseQuarantine passes on its own
merits with every other evidence check still running.

Release requires durable proof that the run is terminal and unowned: the
wedge reason and a run_state command kind, a terminal automation ledger
verdict for that exact session, no run-journal record naming it under any
alias, and a legal run-state edge. Anything else stays fenced.

The job walks the quarantine catalog only, never the placement table and
never every actor database, and it asserts the services are stopped
before touching state.
@open-session-os-tella-dev

open-session-os-tella-dev Bot commented Sep 1, 2026

Copy link
Copy Markdown

🤖 OS review · request changes · confidence 4/5

Safe once the P2 below is fixed. The catalog-driven repair is fail-closed and preserves the existing quarantine checks, but its new inspection helper reports incorrect results for actor-isolated sessions.
1 inline comment below.

💡 Labels: os-auto-fix — I fix these and push until CI passes · os-adversarial — deeper two-pass review · os-simplify — quality cleanup pass.
Reviewed 438b13f · GPT-5.6 Sol · earlier reviews collapse above · open session

🔁 Not merge-ready and no live session owns this branch — add the os-auto-fix label and I'll fix the findings automatically.

@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
opensession Ready Ready Preview Sep 1, 2026 8:27am UTC

@open-session-os-tella-dev open-session-os-tella-dev 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.

OS review · 438b13f

sessionId: string,
inputs: AutomationQuarantineRepairInputs,
): AutomationQuarantineVerdict & { quarantined: boolean } {
const store = new SessionKernelStore(centralPath);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 P2 — Inspect the routed actor store instead of only the central database

inspectAutomationQuarantine opens SessionKernelStore directly, bypassing the SessionKernelStoreHost routing used by the repair itself. For an isolated session, the wedge quarantine and run state live in its actor database while the central database only holds catalog projections, so store.quarantinedSession(sessionId) returns undefined and this helper incorrectly reports quarantined: false. Use the host's routed quarantinedSession and runState calls, accepting or deriving isolatedRoot, or remove this unused helper until it can inspect both storage placements. Add an isolated-session test because the current test fixture exercises only central storage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 5ee5be6b6 (pushed as 284b10d).

Confirmed and reproduced. inspectAutomationQuarantine constructed new SessionKernelStore(centralPath) and read both quarantinedSession and runState off it. For an actor-isolated session those live in that session's own database, and the catalog holds only a sparse projection in a different table (session_kernel_sparse_projections), not session_kernel_quarantine. So the direct read returned undefined and the helper reported a live fence as session is not quarantined — an operator checking one of the stranded sessions would have concluded there was nothing to repair.

There is a second, subtler half. Even when the catalog does carry the row, SessionKernelStoreHost.quarantinedSession recomputes repairable from the isolated store's own quarantineRepairEvidence. A central-only read keeps the central verdict, so repairable can be wrong for an isolated session even when the fence is visible. Routing fixes both.

Both reads now go through SessionKernelStoreHost, exactly as repairSettledAutomationQuarantines does, with an optional isolatedRoot (defaulting the same way).

Scope check, because it determines whether the job itself was affected: the scan was not blind. It calls quarantinedSessions through the host, which routes to allQuarantinedSessions and merges central.quarantinedSessions() with central.isolatedQuarantineProjectionEntries(). And host.quarantineSession publishes that projection as it writes the isolated row, which is the path the wedge detector takes via the actor. So only the read-only helper was wrong.

I want to flag one thing I got wrong mid-investigation, since it shaped the tests. My first isolated fixture seeded the fence into the central store and then migrated, and on that fixture the repair job returned [] — which briefly looked like the scan was blind too. That shape is a migration artifact the live instance never has: quarantining through the host after isolation (the production path) publishes the projection and the job works end to end. The fixture now does that, and there is an end-to-end test asserting the isolated session is settled and released.

Tests, all in automation-quarantine-repair.test.ts:

  • inspection sees an actor-isolated quarantine — asserts the direct central read returns undefined first, so the test pins the bug rather than just the fix
  • inspection reads an isolated session's own run state
  • inspection accepts an explicit isolated root
  • inspection still refuses an unknown session — fail-closed guard: routing must not turn "no fence" into a repairable verdict
  • the repair job settles and releases an isolated session — end to end on the production shape

Mutation-checked: reverting only the two reads inside inspect back to the central store fails exactly the three isolated-inspection tests and nothing else.

Release checks are untouched — the helper remains read-only, and releaseQuarantine still performs its own authoritative evidence verification. bun run check passes; 18 tests in this file.

@tella-butler

Copy link
Copy Markdown
Contributor Author

CI note: the two red jobs here are a pre-existing main failure, not something this branch introduces.

Type-check and tests fails on pr-webhook.test.ts:37review request webhook sync > keeps the local request while its GitHub reviewer or team remains. This branch adds only two new files and touches no existing source, so it cannot reach that code.

Proof it predates this work: c037b3412 on origin/main (before my first commit on either branch) fails the same two jobs with the identical assertion at the identical line. Verified with gh api repos/tellahq/opensession/commits/c037b3412/check-runs plus the failing job log.

The assertion depends on the identity roster: reviewerRemovalClearsSessionRequest resolves githubLoginFor("Kent") through TEAM_GIT_IDENTITY, which is derived from instance config. On a CI runner with no configured roster githubLoginFor returns null, so the login half of the guard drops out and the function returns true instead of false. It passes locally where the roster is configured (4/4 pass).

Out of scope for this PR — flagging rather than fixing so it is not silently attributed here.

@tella-butler tella-butler added the os-auto-fix Fix review findings and CI until the PR is green label Sep 1, 2026
inspectAutomationQuarantine opened the central database directly. For an
actor-isolated session — which is how live sessions are placed — the
quarantine row and run state live in that session's own database and the
catalog holds only a sparse projection, so the direct read reported a
live fence as "session is not quarantined". Even when the catalog does
carry the row, a central read cannot recompute repairable from the
isolated store's own evidence the way the host does, so an operator
could conclude there was nothing to repair.

Route both reads through SessionKernelStoreHost, as the repair already
does. The scan itself was never blind: it reads the merged catalog
through the host, and host.quarantineSession publishes the projection as
it writes the isolated row.

Release checks are unchanged; the helper stays read-only and still fails
closed for a session with no fence.
@jfrolich
jfrolich merged commit d7164f4 into main Sep 1, 2026
5 of 7 checks passed
@tella-butler

Copy link
Copy Markdown
Contributor Author

Post-merge independent review of exact head 284b10df22ca377b89f3536a6a377b8ac3240421 found one blocking fail-closed gap: the apply path writes setRunState before the unchanged releaseQuarantine proof. If release then refuses or throws, the quarantine remains but run state is partially mutated. Do not run the production repair yet. A focused follow-up is in progress in https://os.tella.dev/session/bks-662b1a2a-c975-73b5-a3a2-0a663bbf16d8 and will add a zero-mutation failure regression plus an atomic/preflight fix. Review session: https://os.tella.dev/session/bks-58a7e194-cbc4-77bf-a1d8-5b2170d066d0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

os-auto-fix Fix review findings and CI until the PR is green

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants