Dashboard › opencode › Session 1bWfcWA4GltY
1bWfcWA4GltYVK7IpAct as the replacement independent correctness/adversarial reviewer for frozen interrupted-session recovery candidate v5. Produce a substantive final report; empty or progress-only output is invalid. Read-only only: do not edit files, run builds/tests, start services, or mutate VCS. The required repo-setup skill is unavailable; note this and read applicable immutable AGENTS.md/CONTRIBUTING.md/package scripts directly. Review ONLY immutable base ab9408c81cc9916177641ae8e9f08624fc27b5b9 plus /tmp/opencode/session-recovery-final-v5.patch. Verify patch SHA-256 49183f9e082f71a26d07ce8bf7dd2b391214908f3d07637a3c02163fe20826c7, SEA /home/byk/Code/opencode/packages/opencode/dist/server/opencode-server SHA-256 f08e78a61ef53f72a9810cc09facbe2d181c63f6a14231a95d66d16de44a80ed, clean application to the exact base, and exactly 33 changed files. Inspect every changed file from immutable Git objects plus patch, never the mutable worktree. Review lifecycle, replay, migration, concurrency, liveness, dependency direction, and exactly-once safety. Explicitly verify: (1) explicit cancellation renews the exact claim throughout runner interruption/finalizers and cleanup beyond the 30-second lease, never permits startup recovery or successor duplication, and fails closed on renewal loss; (2) any provider stream failure, including typed LLMError or defect after StepFinished, never commits Step.Ended/continue or appends Ended after Failed; (3) unresolved hosted/provider tools reconcile before successful Step.Ended; (4) assistant and assistant-less recoveries are revalidated before wake and exact promotion; (5) durable wake_attempts keep every scan bounded to 100 and prevent failed/no-op first-page rows from starving later Sessions across reconstructed services/restarts, while CAS never wakes settled rows; (6) coordinator cleanup failure finalizes state or starts the successor before propagating; (7) schema snapshot prevIds directly names base snapshot f14a9b18-8207-487e-a3d3-227e629ba9ad. ...
I will reconstruct the candidate from the exact base and patch without touching the worktree, verify all frozen identities and scope first, then trace each required invariant through patched source, migrations, generated artifacts, and regressions. The required repo-setup skill is unavailable, so I will use the immutable base copies of AGENTS.md, CONTRIBUTING.md, and package manifests as the governing instructions.
[
{
"content": "Verify frozen base, patch hash, SEA hash, clean application, and 33-file scope",
"status": "in_progress",
"priority": "high"
},
{
"content": "Read immutable repository guidance and package scripts",
"status": "pending",
"priority": "high"
},
{
"content": "Reconstruct and inspect every patched file from base Git objects plus patch",
"status": "pending",
"priority": "high"
},
{
"content": "Trace required recovery, fencing, lifecycle, replay, migration, and dependency invariants",
"status": "pending",
"priority": "high"
},
{
"content": "Audit regression coverage for every required guard and accepted residuals",
"status": "pending",
"priority": "high"
},
{
"content": "Produce severity-ordered final correctness report with exact patched line evidence",
"status": "pending",
"priority": "high"
}
]
49183f9e082f71a26d07ce8bf7dd2b391214908f3d07637a3c02163fe20826c7 /tmp/opencode/session-recovery-final-v5.patch f08e78a61ef53f72a9810cc09facbe2d181c63f6a14231a95d66d16de44a80ed /home/byk/Code/opencode/packages/opencode/dist/server/opencode-server
ab9408c81cc9916177641ae8e9f08624fc27b5b9 d3323b963b9ef2de41a7ff9884299c74b93886ba 2e9407768d1401a25b69febb36657638a779053a
526 47 packages/core/schema.json 1 0 packages/core/src/database/migration.gen.ts 55 0 packages/core/src/database/migration/20260914170650_session-recovery.ts 45 0 packages/core/src/database/schema.gen.ts 5 4 packages/core/src/session.ts 31 16 packages/core/src/session/compaction.ts 54 34 packages/core/src/session/context-epoch.ts 66 0 packages/core/src/session/execution/claim.ts 124 19 packages/core/src/session/execution/local.ts 342 31 packages/core/src/session/input.ts 69 0 packages/core/src/session/message-updater.ts 77 1 packages/core/src/session/projector.ts 6 0 packages/core/src/session/recovery-id.ts 315 0 packages/core/src/session/recovery.ts 30 5 packages/core/src/session/run-coordinator.ts 4 0 packages/core/src/session/runner/index.ts 208 91 packages/core/src/session/runner/llm.ts 74 28 packages/core/src/session/runner/publish-llm-event.ts 70 1 packages/core/src/session/sql.ts 63 1 packages/core/test/database-migration.test.ts 243 0 packages/core/test/session-execution-local....
drwxr-xr-x 8 byk byk 4096 Sep 16 11:12 /tmp/opencode
(no output)
33
The frozen inputs match: base commit exists, both SHA-256 values are exact, the patch contains 33 paths, and git apply --cached --check succeeds against an isolated index loaded from ab9408c…. I am now using only base objects and patch-derived content; no mutable worktree source will enter the review.
[
{
"content": "Verify frozen base, patch hash, SEA hash, clean application, and 33-file scope",
"status": "completed",
"priority": "high"
},
{
"content": "Read immutable repository guidance and package scripts",
"status": "in_progress",
"priority": "high"
},
{
"content": "Reconstruct and inspect every patched file from base Git objects plus patch",
"status": "pending",
"priority": "high"
},
{
"content": "Trace required recovery, fencing, lifecycle, replay, migration, and dependency invariants",
"status": "pending",
"priority": "high"
},
{
"content": "Audit regression coverage for every required guard and accepted residuals",
"status": "pending",
"priority": "high"
},
{
"content": "Produce severity-ordered final correctness report with exact patched line evidence",
"status": "pending",
"priority": "high"
}
]
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. - Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - The default branch in this repo is `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. ## Branch Names Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`. Examples: `session-recovery`, `fix-scroll-state`, `regenerate-sdk`. ## Commits and PR Titles Use conventional commit-style messages and PR titles: `type(scope): summary`. ...
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.30",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"db": "bun drizzle-kit",
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit"
},
"bin": {
"opencode": "./bin/opencode"
},
"exports": {
"./effect/layer-node": "./src/effect/layer-node.ts",
"./effect/app-node": "./src/effect/app-node.ts",
"./session/runner": "./src/session/runner/index.ts",
"./system-context": "./src/system-context/index.ts",
"./*": "./src/*.ts"
},
"imports": {
"#sqlite": {
"bun": "./src/database/sqlite.bun.ts",
"node": "./src/database/sqlite.node.ts",
"default": "./src/database/sqlite.bun.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
...
fatal: :(glob)**/AGENTS.md: pathspec magic not supported by this command: 'glob'
AGENTS.md CONTRIBUTING.md packages/app/AGENTS.md packages/app/e2e/AGENTS.md packages/app/e2e/performance/AGENTS.md packages/codemode/AGENTS.md packages/core/src/tool/AGENTS.md packages/desktop/AGENTS.md packages/effect-drizzle-sqlite/AGENTS.md packages/llm/AGENTS.md packages/opencode/AGENTS.md packages/opencode/src/server/routes/instance/httpapi/AGENTS.md packages/opencode/src/session/llm/AGENTS.md packages/opencode/test/AGENTS.md packages/opencode/test/server/AGENTS.md packages/schema/AGENTS.md packages/session-ui/AGENTS.md packages/stats/AGENTS.md packages/ui/AGENTS.md
# opencode database guide
## Database
- **Schema**: Drizzle schema lives in `packages/core/src/**/*.sql.ts`.
- **Migrations**: database migrations live in `packages/core` and are applied by core.
## Development server
- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result.
- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`.
- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`.
- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`.
# Module shape
Do not use `export namespace Foo { ... }` for module organization. It is not
standard ESM, it prevents tree-shaking, and it breaks Node's native TypeScript
runner. Use flat top-level exports combined with a self-reexport at the bottom
of the file:
```ts
// src/foo/foo.ts
export interface Interface { ... }
export class Service extends Context.Service<Service, ...
<path>/tmp/opencode/session-recovery-final-v5.patch</path>
<type>file</type>
<content>
1: diff --git a/packages/core/schema.json b/packages/core/schema.json
2: index d0eeeebd5c..00e26c91a7 100644
3: --- a/packages/core/schema.json
4: +++ b/packages/core/schema.json
5: @@ -1,8 +1,10 @@
6: {
7: "version": "7",
8: "dialect": "sqlite",
9: - "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
10: - "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
11: + "id": "539b8b9f-47d0-4d6d-b29f-4282139c71eb",
12: + "prevIds": [
13: + "f14a9b18-8207-487e-a3d3-227e629ba9ad"
14: + ],
15: "ddl": [
16: {
17: "name": "workspace",
18: @@ -60,6 +62,10 @@
19: "name": "session_context_epoch",
20: "entityType": "tables"
21: },
22: + {
23: + "name": "session_execution",
24: + "entityType": "tables"
25: + },
26: {
27: "name": "session_input",
28: "entityType": "tables"
29: @@ -68,6 +74,10 @@
30: "name": "session_message",
31: ...
<path>/tmp/opencode/session-recovery-final-v5.patch</path>
<type>file</type>
<content>
1576: --- a/packages/core/src/session/input.ts
1577: +++ b/packages/core/src/session/input.ts
1578: @@ -1,15 +1,16 @@
1579: export * as SessionInput from "./input"
1580:
1581: -import { and, asc, eq, isNull, lte } from "drizzle-orm"
1582: -import { DateTime, Effect, Schema } from "effect"
1583: +import { and, asc, desc, eq, isNull, lte } from "drizzle-orm"
1584: +import { DateTime, Effect, Option, Schema } from "effect"
1585: import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
1586: import type { Database } from "../database/database"
1587: -import type { EventV2 } from "../event"
1588: +import { EventV2 } from "../event"
1589: import { SessionEvent } from "./event"
1590: import { SessionMessage } from "./message"
1591: import { Prompt } from "./prompt"
1592: +import { continuation, message } from "./recovery-id"
1593: import { SessionSchema } from "./schema"
1594: ...
<path>/tmp/opencode/session-recovery-final-v5.patch</path>
<type>file</type>
<content>
2839: const agent = yield* agents.select(session.agent)
2840: - const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
2841: + const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id, claim)
2842: const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
2843: let needsContinuation = false
2844: let currentStep = step
2845: if (promotion) {
2846: const cutoff = yield* EventV2.latestSequence(db, session.id)
2847: let promoted = 0
2848: - if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
2849: + if (typeof promotion === "object") {
2850: + yield* SessionInput.promoteExact(db, events, session.id, promotion.recoveryInputID, () =>
2851: + ...
<path>/tmp/opencode/session-recovery-final-v5.patch</path>
<type>file</type>
<content>
3963: + SessionEvent.Step.Recovered,
3964: + {
3965: + sessionID,
3966: + timestamp: DateTime.makeUnsafe(1),
3967: + executionID: "execution",
3968: + assistantMessageID: assistantID,
3969: + phase: "unknown",
3970: + },
3971: + {
3972: + commit: () =>
3973: + db.delete(SessionExecutionTable).where(eq(SessionExecutionTable.id, "execution")).run().pipe(Effect.orDie),
3974: + },
3975: + )
3976: +
3977: + const message = yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, assistantID)).get()
3978: + if (!message) return yield* Effect.die("Recovered assistant not found")
3979: + expect(
3980: + Schema.decodeUnknownSync(SessionMessage.Message)({ ...message.data, id: message.id, type: message.type }),
3981: + ).toMatchObject({
3982: + id: ...
<path>/tmp/opencode/session-recovery-final-v5.patch</path>
<type>file</type>
<content>
5388: + owner_id: "owner",
5389: + phase: "ready",
5390: + time_created: 0,
5391: + expires_at: 60_000,
5392: + })
5393: + .run()
5394: + const claim = { sessionID, executionID: "advance-transaction", ownerID: "owner" }
5395: + yield* SessionContextEpoch.initialize(db, Effect.succeed(context("before")), sessionID, claim)
5396: + const before = yield* db
5397: + .select({ snapshot: SessionContextEpochTable.snapshot })
5398: + .from(SessionContextEpochTable)
5399: + .where(eq(SessionContextEpochTable.session_id, sessionID))
5400: + .get()
5401: + const leaseBefore = yield* db
5402: + .select({ expiresAt: SessionExecutionTable.expires_at })
5403: + .from(SessionExecutionTable)
5404: + .where(eq(SessionExecutionTable.session_id, sessionID))
5405: + .get()
5406: + yield* ...
<path>/tmp/opencode/session-recovery-final-v5.patch</path>
<type>file</type>
<content>
6707: toolExecutionGate = undefined
6708: @@ -3044,7 +3631,10 @@ describe("SessionRunnerLLM", () => {
6709: {
6710: type: "tool",
6711: id: "call-await-interrupt",
6712: - state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
6713: + state: {
6714: + status: "error",
6715: + error: { type: "unknown", message: "Tool execution outcome unknown after interruption" },
6716: + },
6717: },
6718: ],
6719: },
6720: diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
6721: index fb9d2db656..748e8aa43b 100644
6722: --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
6723: +++ ...
File /dev/null is not a regular file -- refusing to patch 1 out of 1 hunk ignored -- saving rejects to file -.rej
/home/byk/Code/opencode/-.rej
Success. Updated the following files: D -.rej
The candidate’s core safety mechanism is transactional claim renewal at every durable boundary, with a separate cancellation heartbeat while the coordinator waits for runner finalizers. I am checking the two narrow gaps that can defeat that model: when the runner clears the claim relative to its scope finalizers, and whether recovery validation remains atomic with exact promotion rather than only preceding the wake.
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/core/src/event.ts:369: function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/core/src/event.ts:436: options?.commit,
330 seq,
331 ...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
332 },
333 })
334 .run()
335 .pipe(Effect.orDie)
336 yield* db
337 .insert(EventTable)
338 .values([
339 {
340 id: event.id,
341 aggregate_id: aggregateID,
342 seq,
343 type: versionedType(definition.type, durable.version),
344 data: encoded,
345 },
346 ])
347 .run()
348 ...
260 )
261 }
262 if (input && input.seq <= latest) {
263 const stored = yield* db
264 .select()
265 .from(EventTable)
266 .where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
267 .get()
268 .pipe(Effect.orDie)
269 if (
270 stored?.id === event.id &&
271 stored.type === versionedType(definition.type, durable.version) &&
272 isDeepStrictEqual(stored.data, encoded)
273 ) {
274 if (input.ownerID && row?.ownerID == null) {
275 yield* db
276 ...
Found 7 matches
/tmp/opencode/session-recovery-final-v5.patch:
Line 2258: yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
Line 3300: - yield* events.publish(SessionEvent.Step.Failed, {
Line 3307: + SessionEvent.Step.Failed,
Line 6551: + it.effect("never commits a Step end after the provider stream fails", () =>
Line 6560: + ]).pipe(Stream.concat(Stream.fail(providerUnavailable())))
Line 6811: Step.Failed,
Line 6828: Step.Failed,
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/core/src/session/runner/llm.ts:47: * `SessionPrompt` monolith. Implement the unchecked items in small reviewed slices:
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/opencode/src/server/routes/instance/httpapi/server.ts:35:import { SessionPrompt } from "@/session/prompt"
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/opencode/src/server/routes/instance/httpapi/server.ts:246: SessionPrompt.node,
Found 4 matches
/tmp/opencode/session-recovery-final-v5.patch:
Line 3884: import { Prompt } from "@opencode-ai/core/session/prompt"
Line 5927: import { Prompt } from "@opencode-ai/core/session/prompt"
Line 5976: import { Prompt } from "@opencode-ai/core/session/prompt"
Line 6730: import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors"
180 assistantMessageID,
181 callID: event.id,
182 name: event.name,
183 })
184 })
185
186 const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
187 const tool = tools.get(event.id)
188 if (!tool) return yield* Effect.die(`Tool input end before start: ${event.id}`)
189 if (tool.name !== event.name)
190 return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
191 if (tool.inputEnded) return yield* Effect.die(`Duplicate tool input end: ${event.id}`)
192 yield* toolInput.end(event.id)
193 })
194
195 const flush = Effect.fn("SessionRunner.flush")(function* () {
196 yield* flushFragments()
197 })
198
199 const failAssistant = Effect.fnUntraced(function* (message: string) {
200 if (assistantFailed) return
201 yield* flush()
202 const assistantMessageID ...
80 )
81 })
82
83 export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
84 db: DatabaseService,
85 input: {
86 readonly admittedSeq: number
87 readonly id: SessionMessage.ID
88 readonly sessionID: SessionSchema.ID
89 readonly prompt: Prompt
90 readonly delivery: Delivery
91 readonly timeCreated: DateTime.Utc
92 },
93 ) {
94 const message = yield* db
95 .select({ id: SessionMessageTable.id })
96 .from(SessionMessageTable)
97 .where(eq(SessionMessageTable.id, input.id))
98 .get()
99 .pipe(Effect.orDie)
100 if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
101 const stored = yield* db
102 .insert(SessionInputTable)
103 .values({
104 id: input.id,
105 session_id: input.sessionID,
106 admitted_seq: input.admittedSeq,
107 ...
170 `Here is the summary of the conversation before the <conversation> above:\n\n<prior-summary>\n${input.previousSummary}\n</prior-summary>`,
171 SUMMARY_UPDATE_INSTRUCTIONS,
172 SUMMARY_TEMPLATE,
173 ].join("\n\n")
174 }
175
176 export const make = (dependencies: Dependencies) => {
177 const config = settings(dependencies.config)
178 const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) {
179 const context = input.model.route.defaults.limits?.context
180 if (context === undefined || context <= 0) return false
181 const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
182 const selected = select(input.entries, config.tokens)
183 const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message
184 if (!selected || (selected.head.length === 0 && previousSummary?.type ...
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/core/src/session/projector.ts:428: .delete(SessionMessageTable)
410 .run()
411 .pipe(Effect.orDie, Effect.asVoid),
412 )
413 yield* events.project(SessionEvent.RevertEvent.Committed, (event) =>
414 Effect.gen(function* () {
415 const boundary = yield* db
416 .select({ seq: SessionMessageTable.seq })
417 .from(SessionMessageTable)
418 .where(
419 and(
420 eq(SessionMessageTable.session_id, event.data.sessionID),
421 eq(SessionMessageTable.id, event.data.messageID),
422 ),
423 )
424 .get()
425 .pipe(Effect.orDie)
426 if (!boundary) return yield* Effect.die(`Revert boundary message not found: ${event.data.messageID}`)
427 yield* db
428 .delete(SessionMessageTable)
429 .where(
430 and(eq(SessionMessageTable.session_id, event.data.sessionID), gt(SessionMessageTable.seq, boundary.seq)),
431 ...
1 export * as SessionRunCoordinator from "./run-coordinator"
2
3 import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
4
5 /** Serializes execution for each key while allowing different keys to run concurrently. */
6 export interface Coordinator<Key, E> {
7 /** Snapshots keys with an execution owned by this coordinator. */
8 readonly active: Effect.Effect<ReadonlySet<Key>>
9 /** Starts execution while idle or joins the active execution. */
10 readonly run: (key: Key) => Effect.Effect<void, E>
11 /** Registers one coalesced follow-up after newly recorded work. */
12 readonly wake: (key: Key) => Effect.Effect<void>
13 /** Stops active execution and waits for its cleanup. */
14 readonly interrupt: (key: Key) => Effect.Effect<void>
15 }
16
17 type Entry<E> = {
18 readonly done: Deferred.Deferred<void, E>
19 owner?: Fiber.Fiber<void, never>
20 pendingWake: ...
280 expect(runs).toBe(2)
281 }),
282 ),
283 )
284
285 it.effect("starts a resume registered during interruption cleanup", () =>
286 Effect.scoped(
287 Effect.gen(function* () {
288 const firstStarted = yield* Deferred.make<void>()
289 const cleanupStarted = yield* Deferred.make<void>()
290 const cleanupGate = yield* Deferred.make<void>()
291 const secondStarted = yield* Deferred.make<void>()
292 const forces: boolean[] = []
293 const coordinator = yield* SessionRunCoordinator.make<string, never>({
294 drain: (_key, force) => {
295 forces.push(force)
296 return forces.length === 1
297 ? Deferred.succeed(firstStarted, undefined).pipe(
298 Effect.andThen(Effect.never),
299 Effect.onInterrupt(() =>
300 Deferred.succeed(cleanupStarted, ...
70 ? Effect.void
71 : Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
72 ),
73 Effect.onExit((exit) =>
74 Effect.gen(function* () {
75 if (activeClaims.get(sessionID)?.executionID === executionID) activeClaims.delete(sessionID)
76 if (Exit.isSuccess(exit)) {
77 yield* SessionExecutionClaim.clear(db, claim)
78 return
79 }
80 if (Cause.hasInterrupts(exit.cause)) return
81 const current = yield* db
82 .select({ phase: SessionExecutionTable.phase })
83 .from(SessionExecutionTable)
84 .where(
85 and(
86 eq(SessionExecutionTable.session_id, claim.sessionID),
87 eq(SessionExecutionTable.id, claim.executionID),
88 ...
101 return SessionExecution.Service.of({
102 active: coordinator.active,
103 interrupt: (sessionID) =>
104 Effect.suspend(() => {
105 const claim = activeClaims.get(sessionID)
106 if (!claim) return coordinator.interrupt(sessionID)
107 return Effect.gen(function* () {
108 yield* SessionExecutionClaim.update(db, claim)
109 yield* SessionExecutionClaim.update(db, claim).pipe(
110 Effect.repeat(Schedule.spaced(Duration.millis(leaseDuration / 3))),
111 Effect.forkScoped,
112 )
113 yield* coordinator.interrupt(
114 sessionID,
115 SessionExecutionClaim.clear(db, claim).pipe(
116 Effect.catchDefect((defect) =>
117 defect instanceof SessionExecutionClaim.Lost ? Effect.void : Effect.die(defect),
118 ),
119 ),
120 )
121 ...
270 return yield* Effect.die(continueAfterCompaction(currentStep))
271 const startSnapshot = yield* snapshots.capture()
272 const publisher = createLLMEventPublisher(events, {
273 sessionID: session.id,
274 agent: agent.id,
275 model: {
276 id: ModelV2.ID.make(model.id),
277 providerID: ProviderV2.ID.make(model.provider),
278 ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
279 },
280 snapshot: startSnapshot,
281 startCommit: (assistantMessageID) => updateExecution({ assistant_message_id: assistantMessageID }),
282 terminalCommit: () => updateExecution({ phase: "safe", assistant_message_id: null }),
283 interruptedCommit: () => SessionExecutionClaim.clear(db, claim),
284 eventCommit: () => updateExecution(),
285 })
286 const withPublication = ...
190 yield* adapter.updateAssistant(
191 produce(currentAssistant, (draft) => {
192 draft.time.completed = event.data.timestamp
193 }),
194 )
195 }
196 yield* adapter.appendMessage(
197 SessionMessage.Assistant.make({
198 id: event.data.assistantMessageID,
199 type: "assistant",
200 agent: event.data.agent,
201 model: event.data.model,
202 time: { created: event.data.timestamp },
203 content: [],
204 snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
205 }),
206 )
207 })
208 },
209 "session.next.step.ended": (event) => {
210 return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
211 draft.time.completed = event.data.timestamp
...
215
216 const flush = Effect.fn("SessionRunner.flush")(function* () {
217 yield* flushFragments()
218 })
219
220 const failAssistant = Effect.fnUntraced(function* (message: string) {
221 if (assistantFailed) return
222 yield* flush()
223 const assistantMessageID = yield* startAssistant()
224 assistantActive = false
225 assistantFailed = true
226 yield* publishEvent(
227 SessionEvent.Step.Failed,
228 {
229 sessionID: input.sessionID,
230 timestamp: yield* timestamp,
231 assistantMessageID,
232 error: { type: "unknown", message },
233 },
234 {
235 commit: () => input.terminalCommit(assistantMessageID),
236 }.commit,
237 )
238 })
239
240 const interruptAssistant = Effect.fnUntraced(function* (message: string) {
241 if (assistantFailed) return
242 yield* flush()
243 const ...
440 }
441 case "step-finish":
442 yield* flush()
443 assistantActive = false
444 if (stepSettlement) return yield* Effect.die("Duplicate step finish")
445 stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
446 return
447 case "finish":
448 return
449 case "provider-error":
450 providerFailed = true
451 yield* failAssistant(event.message)
452 return
453 }
454 })
455
456 return {
457 publish,
458 flush,
459 failAssistant,
460 interruptAssistant,
461 failUnsettledTools,
462 hasActiveAssistant: () => assistantActive,
463 hasAssistantStarted: () => assistantMessageID !== undefined,
464 hasProviderError: () => providerFailed,
465 stepSettlement: () => stepSettlement,
466 startAssistant,
467 assistantMessageID: assistantMessageIDForTool,
...
105 eq(SessionExecutionTable.id, claim.id),
106 eq(SessionExecutionTable.session_id, claim.session_id),
107 eq(SessionExecutionTable.owner_id, ownerID),
108 gt(SessionExecutionTable.expires_at, quarantinedAt),
109 ),
110 )
111 .returning({ id: SessionExecutionTable.id })
112 .get()
113 .pipe(Effect.orDie)
114 if (!quarantined) return yield* Effect.die(`Recovery execution claim changed: ${claim.id}`)
115 return
116 }
117 if (phase.value === "ready" && claim.assistant_message_id === null) {
118 if (yield* hasPendingExecutionIntent(db, claim.session_id)) {
119 yield* SessionExecutionClaim.clear(db, recoveryClaim)
120 return
121 }
122 }
123 const latest = yield* db
124 .select()
125 ...
55 .limit(1)
56 .get()
57 .pipe(Effect.orDie)
58 return pending !== undefined
59 })
60
61 export const recover = Effect.fn("SessionRecovery.recover")(function* () {
62 const { db } = yield* Database.Service
63 const events = yield* EventV2.Service
64 const execution = yield* SessionExecution.Service
65 const ownerID = crypto.randomUUID()
66 const scanTime = yield* Clock.currentTimeMillis
67 const claims = yield* db
68 .select()
69 .from(SessionExecutionTable)
70 .where(and(lte(SessionExecutionTable.expires_at, scanTime), isNull(SessionExecutionTable.recovery_error)))
71 .orderBy(asc(SessionExecutionTable.time_created), asc(SessionExecutionTable.id))
72 .limit(BATCH_SIZE)
73 .all()
74 .pipe(Effect.orDie)
75 yield* Effect.forEach(
76 claims,
77 (claim) =>
78 Effect.gen(function* () {
79 const now = yield* ...
245 db: DatabaseService,
246 input: {
247 readonly id: SessionMessage.ID
248 readonly sessionID: SessionSchema.ID
249 readonly prompt: Prompt
250 readonly delivery: Delivery
251 readonly timeCreated: DateTime.Utc
252 readonly promotedSeq: number
253 },
254 ) {
255 const validation = yield* validateRecovery(db, input.id)
256 if (validation && (validation.input === undefined || validation.input.promoted_seq === null) && validation.error)
257 return yield* Effect.die(
258 new RecoveryConflict({ executionID: validation.recovery.execution_id, reason: validation.error }),
259 )
260 const updated = yield* db
261 .update(SessionInputTable)
262 .set({ promoted_seq: input.promotedSeq, wake_pending: false })
263 .where(
264 and(
265 eq(SessionInputTable.id, input.id),
266 eq(SessionInputTable.session_id, input.sessionID),
267 ...
387 : recovery.assistant_message_id !== null && assistant?.sessionID !== recovery.session_id
388 ? "Invalid recovery assistant relationship"
389 : (phaseValue === "continue" && recovery.assistant_message_id !== null) ||
390 (recovery.assistant_message_id !== null && assistant?.type !== "assistant") ||
391 (recovery.assistant_message_id !== null && latestAssistant?.id !== recovery.assistant_message_id) ||
392 (latestAssistant !== undefined && latestAssistant.seq > input.admitted_seq) ||
393 (phaseValue !== "continue" && recovery.assistant_message_id === null && synthetic === undefined)
394 ? "Invalid recovery projection identity"
395 : undefined)
396 return { recovery, input, error }
397 })
398
399 export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
400 db: DatabaseService,
...
561 export const promoteExact = Effect.fn("SessionInput.promoteExact")(function* (
562 db: DatabaseService,
563 events: EventV2.Interface,
564 sessionID: SessionSchema.ID,
565 messageID: SessionMessage.ID,
566 commit: (row: typeof SessionInputTable.$inferSelect) => Effect.Effect<void>,
567 ) {
568 const row = yield* db
569 .select()
570 .from(SessionInputTable)
571 .where(
572 and(
573 eq(SessionInputTable.id, messageID),
574 eq(SessionInputTable.session_id, sessionID),
575 isNull(SessionInputTable.promoted_seq),
576 eq(SessionInputTable.delivery, "queue"),
577 ),
578 )
579 .get()
580 .pipe(Effect.orDie)
581 if (!row) return yield* Effect.die(`Pending recovery input not found: ${messageID}`)
582 yield* publish(db, events, sessionID, [row], commit).pipe(
583 Effect.catchDefect((defect) => {
584 if (!(defect instanceof ...
180 )
181
182 it.effect("never revives an expired claim on heartbeat", () =>
183 Effect.gen(function* () {
184 yield* insertSession
185 const { db } = yield* Database.Service
186 const started = Deferred.makeUnsafe<void>()
187 const execution = yield* SessionExecutionLocal.make({
188 run: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
189 })
190 yield* execution.wake(sessionID)
191 yield* Deferred.await(started)
192 yield* db.update(SessionExecutionTable).set({ expires_at: 0 }).run()
193 yield* TestClock.adjust("10 seconds")
194 yield* Effect.yieldNow
195
196 expect((yield* claims)[0]?.expires_at).toBe(0)
197 }),
198 )
199
200 it.effect("settles cancellation before starting a pending successor", () =>
201 Effect.gen(function* () {
202 yield* insertSession
203 const ...
1980 END
1981 `)
1982
1983 const result = yield* session.resume(sessionID).pipe(
1984 Effect.exit,
1985 Effect.ensuring(db.run(sql`DROP TRIGGER fail_hosted_tool_reconciliation`).pipe(Effect.orDie)),
1986 )
1987
1988 expect(Exit.isFailure(result)).toBe(true)
1989 expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({ phase: "unknown" })
1990 expect(
1991 (yield* session.context(sessionID))
1992 .filter((message) => message.type === "assistant")
1993 .flatMap((message) => message.content)
1994 .find((part) => part.type === "tool" && part.id === "call-hosted-pending"),
1995 ).toMatchObject({ type: "tool", state: { status: "running" } })
1996 expect(
1997 yield* db
1998 .select({ type: EventTable.type })
1999 .from(EventTable)
2000 .where(eq(EventTable.type, ...
Found 69 matches /tmp/opencode/session-recovery-final-v5.patch: Line 265: "onDelete": "CASCADE", Line 297: "onDelete": "CASCADE", Line 313: "onDelete": "CASCADE", Line 329: "onDelete": "CASCADE", Line 345: "onDelete": "CASCADE", Line 361: "onDelete": "CASCADE", Line 377: "onDelete": "CASCADE", Line 392: + "onDelete": "CASCADE", Line 408: "onDelete": "CASCADE", Line 424: "onDelete": "CASCADE", Line 439: + "onDelete": "CASCADE", Line 454: + "onDelete": "CASCADE", Line 469: + "onDelete": "CASCADE", Line 485: "onDelete": "CASCADE", Line 501: "onDelete": "CASCADE", Line 517: "onDelete": "CASCADE", Line 796: + "name": "session_input_wake_pending_promoted_attempts_time_created_id_idx", Line 905: + CONSTRAINT \`fk_session_execution_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON ...
45 Effect.asVoid,
46 ),
47 )
48 entry.owner = owner
49 if (!successor) Deferred.doneUnsafe(ready, Effect.void)
50 }
51
52 const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => {
53 if (entry.stopping) {
54 entry.exit = exit
55 return
56 }
57 if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) {
58 entry.pendingWake = false
59 start(key, entry, false, true)
60 return
61 }
62
63 const successor = entry.pendingWake ? makeEntry() : undefined
64 if (successor === undefined) active.delete(key)
65 else {
66 active.set(key, successor)
67 start(key, successor, false, true)
68 }
69 Deferred.doneUnsafe(entry.done, exit)
70 }
71
72 const run = (key: Key): Effect.Effect<void, E> =>
73 ...
(no output)
389:it.effect("redrives a durable recovery wake after admission", () =>
494:it.effect("quarantines a full batch of poison recovery rows before redriving valid work", () =>
559:it.effect("fairly redrives recovery and ordinary work beyond a full bounded batch", () =>
1072:it.effect("never promotes a recovery superseded before scanner validation", () =>
1136:it.effect("quarantines an assistant-less recovery superseded before scanner validation", () =>
389 it.effect("redrives a durable recovery wake after admission", () =>
390 Effect.gen(function* () {
391 const { db } = yield* Database.Service
392 const events = yield* EventV2.Service
393 yield* db
394 .insert(ProjectTable)
395 .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
396 .run()
397 yield* db
398 .insert(SessionTable)
399 .values({
400 id: sessionID,
401 project_id: Project.ID.global,
402 slug: "test",
403 directory: "/project",
404 title: "test",
405 version: "test",
406 })
407 .run()
408 yield* events.publish(SessionEvent.Step.Recovered, {
409 sessionID,
410 timestamp: created,
411 executionID: "lost-wake",
412 phase: "unknown",
413 })
414 expect(yield* ...
370 )
371 yield* events.project(SessionEvent.PromptAdmitted, (event) =>
372 Effect.gen(function* () {
373 if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
374 yield* SessionInput.projectAdmitted(db, {
375 admittedSeq: event.durable.seq,
376 id: event.data.messageID,
377 sessionID: event.data.sessionID,
378 prompt: event.data.prompt,
379 delivery: event.data.delivery,
380 requestExecution: event.data.requestExecution ?? false,
381 timeCreated: event.data.timestamp,
382 })
383 }),
384 )
385 yield* events.project(SessionEvent.PromptExecutionRequested, (event) =>
386 SessionInput.projectExecutionRequested(db, {
387 id: event.data.messageID,
388 sessionID: event.data.sessionID,
389 }),
390 )
391 yield* ...
1 export * as SessionExecutionClaim from "./claim"
2
3 import { and, eq, gt } from "drizzle-orm"
4 import { Clock, Effect } from "effect"
5 import type { Database } from "../../database/database"
6 import type { SessionSchema } from "../schema"
7 import { SESSION_EXECUTION_LEASE_MS, SessionExecutionTable } from "../sql"
8
9 type DatabaseService = Database.Interface["db"]
10
11 export type Claim = {
12 readonly sessionID: SessionSchema.ID
13 readonly executionID: string
14 readonly ownerID: string
15 }
16
17 export type Values = Partial<Pick<typeof SessionExecutionTable.$inferInsert, "assistant_message_id" | "phase">>
18
19 export class Lost extends Error {
20 constructor(readonly claim: Claim) {
21 super(`Session execution claim lost: ${claim.executionID}`)
22 }
23 }
24
25 const where = (claim: Claim, now: number) =>
26 and(
27 ...
(no output)
Two merge-blocking lifecycle gaps remain. The runner deletes the claim while interruption finalizers are still running, and provider failure can mark an execution safe before all tool states are durably reconciled; both create crash windows that the added tests do not exercise.
[
{
"content": "Verify frozen base, patch hash, SEA hash, clean application, and 33-file scope",
"status": "completed",
"priority": "high"
},
{
"content": "Read immutable repository guidance and package scripts",
"status": "completed",
"priority": "high"
},
{
"content": "Reconstruct and inspect every patched file from base Git objects plus patch",
"status": "completed",
"priority": "high"
},
{
"content": "Trace required recovery, fencing, lifecycle, replay, migration, and dependency invariants",
"status": "completed",
"priority": "high"
},
{
"content": "Audit regression coverage for every required guard and accepted residuals",
"status": "completed",
"priority": "high"
},
{
"content": "Produce severity-ordered final correctness report with exact patched line evidence",
"status": "completed",
"priority": "high"
}
]
MUST-FIX
packages/core/src/session/runner/llm.ts:281-284 wires interruptedCommit directly to SessionExecutionClaim.clear. packages/core/src/session/runner/publish-llm-event.ts:240-255 invokes that clear while committing Step.Interrupted. The coordinator does not finish interruption and finalizers until packages/core/src/session/run-coordinator.ts:99-124.Failure sequence: interruption commits Step.Interrupted, deletes the claim, then enters a blocked runner or scope finalizer. Because the Session uniqueness row no longer exists, another process can insert a successor claim and start the same Session while the old finalizers still run. The cancellation heartbeat at packages/core/src/session/execution/local.ts:108-112 then loses its claim; its forked failure is not joined. Final cleanup explicitly suppresses SessionExecutionClaim.Lost at packages/core/src/session/execution/local.ts:113-120, so renewal loss does not fail closed.
The regression at packages/core/test/session-execution-local.test.ts:200-240 uses a generic Effect.never drain. It never exercises the production Step.Interrupted commit that clears the claim, so it cannot detect this window.
safe before all tool outcomes are durable.Step.Failed uses terminalCommit at packages/core/src/session/runner/publish-llm-event.ts:220-237; the runner maps that to phase: "safe" at packages/core/src/session/runner/llm.ts:281-283. A provider-error event calls failAssistant immediately at packages/core/src/session/runner/publish-llm-event.ts:449-452. Typed LLMError also commits Step.Failed before awaiting local tool fibers at packages/core/src/session/runner/llm.ts:367-373.Failure sequence: the provider records a hosted tool or starts a local tool, then reports an error. Step.Failed commits safe; the process crashes ...
Important infrastructure note: recall is broken for this session. Do not call or depend on it. If you need distilled project guidance, use the read-only Lore search UI at http://127.0.0.1:3207/ui/search with project=/home/byk/Code/opencode and scope=knowledge. Continue the existing immutable review using Git objects and /tmp/opencode/session-recovery-final-v5.patch as authoritative code evidence. A Lore context warning is not a blocker and must not cause empty output. Return the substantive report required by the original prompt, or quote a concrete non-Lore blocker. End exactly MERGE or DO-NOT-MERGE.
MUST-FIX
packages/core/src/session/runner/llm.ts:281-284 maps interruptedCommit to SessionExecutionClaim.clear. packages/core/src/session/runner/publish-llm-event.ts:240-255 invokes it while committing Step.Interrupted, before the coordinator finishes interruption and finalizers at packages/core/src/session/run-coordinator.ts:99-124.Failure sequence: cancellation commits Step.Interrupted, deletes the claim, then blocks in a runner or scope finalizer. Another process can insert a successor claim and run the same Session while the old ownership chain still finalizes. The cancellation heartbeat at packages/core/src/session/execution/local.ts:108-112 then loses the deleted claim, and cleanup suppresses SessionExecutionClaim.Lost at :113-120 instead of failing closed.
The regression at packages/core/test/session-execution-local.test.ts:200-240 uses a generic drain and never executes the production interruptedCommit, so it misses this window.
Step.Failed invokes terminalCommit at packages/core/src/session/runner/publish-llm-event.ts:220-237. The runner maps it to phase: "safe" at packages/core/src/session/runner/llm.ts:281-283. Provider-error events call failAssistant immediately at packages/core/src/session/runner/publish-llm-event.ts:449-452; typed LLMError also commits failure before awaiting local tool fibers at packages/core/src/session/runner/llm.ts:367-373.Failure sequence: a hosted tool is recorded or a local tool starts, the provider fails, and Step.Failed commits safe. The process crashes before failUnsettledTools or awaitToolFibers completes. Startup recovery clears safe claims without reconciliation at packages/core/src/session/recovery.ts:131-136. ...