Dashboard › opencode › Session 0410hkwvS1AC
0410hkwvS1ACdy6D6Would it be possible to make a change to resume active sessions after a server restart? I think v2 has this
I’ll trace how the current server records and runs active sessions, then compare that path with V2’s durable admission and recovery model. I’ll identify whether we can safely resume after restart without replaying provider work or duplicating tool effects. The required repo-setup skill is unavailable in this session, so I’m proceeding from the repository instructions and current source.
[
{
"content": "Trace current session execution lifecycle",
"status": "in_progress",
"priority": "high"
},
{
"content": "Compare V2 restart recovery semantics",
"status": "pending",
"priority": "high"
},
{
"content": "Design or implement safe restart resume",
"status": "pending",
"priority": "high"
},
{
"content": "Validate affected packages and artifact",
"status": "pending",
"priority": "high"
}
]
<task id="ses_f61056c45ffecUMeJwzVhaMYIk" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f61053b64ffe4CseaCE7vytyjO" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The key safety question is whether “resume” means replaying an interrupted model call or only draining durable prompts that never started. V2 now appears to have process-local recovery claims, but I’m verifying the exact current implementation before porting anything; an unsafe replay could duplicate tool side effects.
I’m verifying V2’s exact restart guarantees before changing legacy session execution. The implementation must never replay an uncertain provider/tool step and duplicate side effects.
The Node standalone build exports the legacy server from /home/byk/Code/opencode/packages/opencode/src/node.ts:1-4; its build entry is /home/byk/Code/opencode/packages/opencode/script/build-node.ts:15-21.
Legacy V1 preserves the transcript, but not the execution. Safe automatic restart recovery does not exist. After restart, every session appears idle, no startup hook resumes work, and rerunning an uncertain provider Step or tool can duplicate calls and side effects.
Server.listen() builds the Node HTTP listener and the complete route/service graph
/home/byk/Code/opencode/packages/opencode/src/server/server.ts:73-97
/home/byk/Code/opencode/packages/opencode/src/server/server.ts:100-138
An instance-routed request lazily loads its directory and runs InstanceBootstrap.run
/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts:23-34
/home/byk/Code/opencode/packages/opencode/src/project/instance-store.ts:45-63
Bootstrap loads config, initializes plugins first, then initializes LSP, sharing, formatting, VCS, snapshots, and project services concurrently. It does not inspect or resume sessions
/home/byk/Code/opencode/packages/opencode/src/project/bootstrap.ts:32-46
POST /session/:sessionID/message calls SessionPrompt.prompt; prompt_async forks the same operation into the listener scope and returns immediately
/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:78-105
/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:295-329
SessionPrompt.prompt:
Legacy confirms the transcript survives but execution does not. Automatic replay is unsafe: a crash can occur after a provider request or tool side effect but before its result is persisted, so restarting the loop could repeat external changes. I’m waiting for the independent V2 trace to verify whether it solves this or only resumes prompts that never started.
Legacy can safely reconcile interrupted work, but cannot safely replay it. I’m verifying whether V2 truly resumes started work or only drains durable prompts that never began.
V2 does not resume active Session execution after a server restart.
It durably preserves:
steer and queue prompts,It does not durably preserve or reconstruct:
After restart, pending prompts remain dormant until a later wake. A new prompt or an exact prompt retry normally supplies that wake; Core also has an explicit resume, but the public HTTP API does not expose it.
SessionV2.prompt resolves and admits the prompt inside an uninterruptible region, validates exact retries, then issues only an advisory wake unless resume:false:
/home/byk/Code/opencode/packages/core/src/session.ts:360-385/home/byk/Code/opencode/packages/core/src/session/input.ts:41-80PromptAdmitted and the session_input projection commit in the durable event transaction:
/home/byk/Code/opencode/packages/core/src/session/projector.ts:362-373/home/byk/Code/opencode/packages/core/src/event.ts:237-353The durable inbox stores admitted_seq, nullable promoted_seq, and delivery mode:
/home/byk/Code/opencode/packages/core/src/session/sql.ts:140-165Tests establish:
/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts:143-163/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts:252-269V2 does not currently auto-resume after restart. It preserves unstarted prompts and partial progress, but its execution coordinator and wakeups are process-local; a later prompt retry or explicit internal resume wakes them.
A safe change is still possible: automatically continue only sessions that the server cleanly interrupted during a graceful restart. Hard-crash recovery must mark uncertain provider/tool work interrupted and never replay it automatically, because the prior side effect may already have happened.
<path>/home/byk/Code/opencode/packages/opencode/src/node-server.ts</path>
<type>file</type>
<content>
1: #!/usr/bin/env node
2:
3: import { Flag } from "@opencode-ai/core/flag/flag"
4: import { InstallationVersion } from "@opencode-ai/core/installation/version"
5: import { existsSync } from "node:fs"
6: import { Module } from "node:module"
7: import path from "node:path"
8: import { fileURLToPath } from "node:url"
9: import { parseArgs } from "node:util"
10: import { Server } from "./server/server"
11:
12: type ResolveContext = { parentURL?: string }
13: type NextResolve = (specifier: string, context: ResolveContext) => unknown
14: type ResolveHook = (specifier: string, context: ResolveContext, nextResolve: NextResolve) => unknown
15: const registerHooks = (
16: Module as typeof Module & { registerHooks(hooks: { resolve: ResolveHook }): void }
17: ).registerHooks
18:
19: const help = `opencode-server ${InstallationVersion}
20:
21: Usage: opencode-server [options]
22:
23: ...
[
{
"content": "Trace current session execution lifecycle",
"status": "completed",
"priority": "high"
},
{
"content": "Compare V2 restart recovery semantics",
"status": "completed",
"priority": "high"
},
{
"content": "Design safe restart recovery boundary",
"status": "completed",
"priority": "high"
},
{
"content": "Validate affected packages and artifact",
"status": "cancelled",
"priority": "high"
}
]
It is possible, but V2 does not currently auto-resume active sessions either. It only preserves pending inputs and partial history; execution ownership and wakeups disappear on restart.
A safe implementation should:
A smaller “resume everything after SIGTERM” patch would be unsafe. The server could die after a tool changed files or ran a command but before recording its result; replay could perform that action twice.
The practical first version should provide safe graceful-restart continuation:
Hard-crash continuation requires the larger durable attempt and tool-invocation design. I would implement graceful-restart continuation first rather than claim crash recovery we cannot guarantee.
what I do manually right now is to send a dumb "continue" prompt to all previously active sessions upon restart and it worked well so far. can we not do something akin to that
Yes. Your manual workflow is a valid recovery policy, but it is a fresh Step, not a resumed provider request.
We can automate it as follows:
unknown outcome.continue.SessionPrompt directly.This matches what you do manually. The model may choose to repeat an operation after seeing the interruption, but it does so as an informed new decision rather than through blind replay.
For graceful restarts we can record the active session IDs before shutdown. For crashes, startup can infer candidates from unfinished durable state. We must also ensure only one server claims each recovery, so two processes cannot both inject continue.
That is much smaller and safer than true execution resumption.
okay let's do this.
I’ll implement startup recovery as a fresh synthetic continue Step, never as replay. I’ll first locate the durable unfinished-state queries and the lowest-level admission path that avoids SessionPrompt, then add exclusive recovery claiming and isolated restart tests.
<skill_content name="effect">
# Skill: effect
# Effect
This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
## Source Of Truth
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
3. Also inspect existing repo code for local house style before introducing new patterns.
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
## Guidelines
- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
- Use `Effect.gen(function* () { ... })` for multi-step workflows.
- Use `Effect.fn("Name")` or ...
[
{
"content": "Define V2 synthetic recovery ownership and discovery",
"status": "in_progress",
"priority": "high"
},
{
"content": "Implement interruption reconciliation and synthetic continuation",
"status": "pending",
"priority": "high"
},
{
"content": "Add restart and duplicate-claim regressions",
"status": "pending",
"priority": "high"
},
{
"content": "Run package validation and isolated restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Run independent correctness and security reviews",
"status": "pending",
"priority": "high"
}
]
No files found
<task id="ses_f60e159e9ffeVskuIe3XCp5fFR" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<path>/home/byk/Code/opencode/CONTRIBUTING.md</path> <type>file</type> <content> 1: # Contributing to OpenCode 2: 3: We want to make it easy for you to contribute to OpenCode. Here are the most common type of changes that get merged: 4: 5: - Bug fixes 6: - Additional LSPs / Formatters 7: - Improvements to LLM performance 8: - Support for new providers 9: - Fixes for environment-specific quirks 10: - Missing standard behavior 11: - Documentation improvements 12: 13: However, any UI or core product feature must go through a design review with the core team before implementation. 14: 15: If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels: 16: 17: - [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted) 18: - [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22) 19: - ...
M .lore.md M AGENTS.md M packages/app/src/components/titlebar-tab-nav.css M packages/app/src/components/titlebar-tab-nav.tsx M packages/app/src/components/titlebar-tab-rail-state.ts M packages/app/src/components/titlebar-tab-rail.tsx M packages/app/src/components/titlebar-tab-strip.tsx ?? perf.data ?? perf.data.old
I’m tracing the durable claim and startup discovery boundaries before editing. The implementation will preserve current uncommitted UI work and will not touch the running service.
</task_result> </task>
The design agent returned no evidence, so I’m retrying that same session once with an explicit evidence-or-blocker contract. In parallel, I’ll inspect the V2 schemas and startup composition directly, without touching legacy SessionPrompt.
Oh you can and probably should commit that UI work too btw
I’ll commit the vertical-tab UI work as a focused conventional commit, including the required .lore.md update while leaving AGENTS.md and profiler captures untouched. I’ll inspect the full staged scope before committing, then verify the resulting status and commit.
## byk/cumulative...origin/dev [ahead 20] M .lore.md M AGENTS.md M packages/app/src/components/titlebar-tab-nav.css M packages/app/src/components/titlebar-tab-nav.tsx M packages/app/src/components/titlebar-tab-rail-state.ts M packages/app/src/components/titlebar-tab-rail.tsx M packages/app/src/components/titlebar-tab-strip.tsx ?? perf.data ?? perf.data.old
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_09f39880e001pMLJy3iqCSVPRw - ### Preference <!-- lore:01a08428-4b4d-7948-9828-85d7cdde6e02 --> * **AI cassette recording**: Always replay AI cassettes by default. Set \`RECORD=true\`, provide the listed provider credentials, and unset \`CI\` only when intentionally recording; CI forces replay. Keep cassettes as pretty-printed JSON because compact output makes multi-interaction changes hard to audit. -<!-- lore:01a086dc-bcb8-76b5-8389-a68df5c7bfdc --> -* **Always conduct frozen, read-only adversarial reviews with explicit evidence**: For non-trivial or correctness-critical changes, review the exact identified repository, change, commit, parent, diff, and artifacts without modifying files, mutating jj state, touching production data, or starting production services. ...
The UI diff is focused: it only adds width-aware dense rows to the existing vertical rail. .lore.md contains automatic knowledge maintenance and must accompany the commit; AGENTS.md and perf.data* will remain outside it.
M .lore.md M AGENTS.md M packages/app/src/components/titlebar-tab-nav.css M packages/app/src/components/titlebar-tab-nav.tsx M packages/app/src/components/titlebar-tab-rail-state.ts M packages/app/src/components/titlebar-tab-rail.tsx M packages/app/src/components/titlebar-tab-strip.tsx ?? perf.data ?? perf.data.old
[byk/cumulative ab9408c81c] feat(app): adapt vertical tab density 6 files changed, 75 insertions(+), 80 deletions(-)
M AGENTS.md ?? perf.data ?? perf.data.old ab9408c81c feat(app): adapt vertical tab density .lore.md | 132 +++++++++------------ packages/app/src/components/titlebar-tab-nav.css | 8 +- packages/app/src/components/titlebar-tab-nav.tsx | 4 +- .../app/src/components/titlebar-tab-rail-state.ts | 2 + packages/app/src/components/titlebar-tab-rail.tsx | 3 + packages/app/src/components/titlebar-tab-strip.tsx | 6 + 6 files changed, 75 insertions(+), 80 deletions(-)
Committed UI work as:
ab9408c81c feat(app): adapt vertical tab density
Remaining untouched:
AGENTS.mdperf.dataperf.data.oldThe restart-recovery investigation continues separately.
</task_result> </task>
The retry also returned empty, so it provides no design evidence. I’m replacing it with direct source inspection now; implementation remains blocked until the durable candidate query, claim transaction, and startup ownership are proven from current APIs.
Nudge your bg agent as it stopped due to an internal error
I’ll resume the same agent with a narrower evidence request and require it to report the internal blocker if it cannot inspect the source.
<task id="ses_f60e159e9ffeVskuIe3XCp5fFR" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The agent has resumed with a narrower source-evidence request. It must now return either exact file references or the internal error that blocks inspection.
continue. the server was restarted so you'll have to nudge your background agents to continue too.
I’m resuming the interrupted recovery-design agent with its exact evidence contract. After it returns, I’ll continue from the durable claim and startup-wiring findings rather than restart the investigation.
<task id="ses_f60e159e9ffeVskuIe3XCp5fFR" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The recovery-design agent is resumed on the same session and will continue from its prior context. I’ll proceed with implementation when its source-backed findings return.
</task_result> </task>
The resumed agent failed again without evidence, so I’m replacing it with direct source inspection rather than retrying it further. I’ll prove the candidate projection, transaction boundary, and startup layer before editing; if the current schema cannot support an exclusive recovery claim, I’ll add the smallest durable fact that can.
<path>/home/byk/Code/opencode/packages/core/package.json</path>
<type>file</type>
<content>
1: {
2: "$schema": "https://json.schemastore.org/package.json",
3: "version": "1.18.30",
4: "name": "@opencode-ai/core",
5: "type": "module",
6: "license": "MIT",
7: "private": true,
8: "scripts": {
9: "db": "bun drizzle-kit",
10: "migration": "bun run script/migration.ts",
11: "fix-node-pty": "bun run script/fix-node-pty.ts",
12: "test": "bun test --only-failures",
13: "typecheck": "tsgo --noEmit"
14: },
15: "bin": {
16: "opencode": "./bin/opencode"
17: },
18: "exports": {
19: "./effect/layer-node": "./src/effect/layer-node.ts",
20: "./effect/app-node": "./src/effect/app-node.ts",
21: "./session/runner": "./src/session/runner/index.ts",
22: "./system-context": "./src/system-context/index.ts",
23: "./*": "./src/*.ts"
24: },
25: "imports": {
26: "#sqlite": {
27: "bun": "./src/database/sqlite.bun.ts",
28: ...
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
1: import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
2: import * as DatabasePath from "../database/path"
3: import { ProjectTable } from "../project/sql"
4: import type { SessionMessage } from "./message"
5: import type { Prompt } from "./prompt"
6: import type { SessionInput } from "./input"
7: import type { Snapshot } from "../snapshot"
8: import { PermissionV1 } from "../v1/permission"
9: import { ProjectV2 } from "../project"
10: import type { SessionSchema } from "./schema"
11: import type { MessageID, PartID, SessionV1 } from "../v1/session"
12: import { WorkspaceV2 } from "../workspace"
13: import { Timestamps } from "../database/schema.sql"
14: import type { SystemContext } from "../system-context/index"
15: import { AgentV2 } from "../agent"
16: import type { Revert } from "@opencode-ai/schema/revert"
17:
18: type ...
<path>/home/byk/Code/opencode/packages/core/src/session/message.ts</path> <type>file</type> <content> 1: export * as SessionMessage from "./message" 2: export * from "@opencode-ai/schema/session-message" (End of file - total 2 lines) </content>
nudge the agent, it hit an error
I’ll resume the same recovery agent once more and require a concise partial result instead of broad exploration, so it can recover past the failing inspection path.
<task id="ses_f60e159e9ffeVskuIe3XCp5fFR" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The same agent is resumed with a strict eight-file scope and must return partial evidence or the exact read failure.
continue. the server was restarted so you'll have to nudge your background agents to continue too.
I’m resuming the recovery agent on its existing session and exact eight-file scope. If it returns empty again, I’ll retire it and complete the source trace directly without another retry.
<task id="ses_f60e159e9ffeVskuIe3XCp5fFR" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The recovery agent resumed on the same session. This is its final retry; an empty result will trigger direct inspection and implementation.
The closest durable candidate is the latest projected assistant message with no completion, optionally containing a pending or running tool:
WITH latest_assistant AS (
SELECT *,
row_number() OVER (
PARTITION BY session_id ORDER BY seq DESC
) AS rank
FROM session_message
WHERE type = 'assistant'
)
SELECT session_id, id AS assistant_message_id, seq
FROM latest_assistant
WHERE rank = 1
AND json_extract(data, '$.time.completed') IS NULL
Add a json_each(data, '$.content') predicate to identify tool entries whose state.status is pending or running.
Evidence:
SessionMessageTable stores session_id, type, durable seq, and encoded message data: /home/byk/Code/opencode/packages/core/src/session/sql.ts:119-137.(session_id, seq) index is unique: /home/byk/Code/opencode/packages/core/src/session/sql.ts:132-135.seq, and considers it active only when time.completed is absent: /home/byk/Code/opencode/packages/core/src/session/projector.ts:133-149.pending and running: /home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:119-138.This query is not enough for correct discovery. No listed table records provider preparation or dispatch before the first Step.Started projection. A crash after input promotion but before provider output can leave no incomplete assistant. Current durable facts therefore identify dangling projected work, but never all previously active Sessions.
There is no current Session-recovery claim mechanism.
SessionInput.admit checks by message ID and then publishes PromptAdmitted: ...The source evidence confirms no current durable recovery claim and that incomplete projections miss crashes before the first provider event. I’m expanding the design by one essential fact: record a durable per-Session execution boundary before a Step can start, clear it only at a safe terminal boundary, then atomically convert a stale boundary into one synthetic continue admission during startup.
[
{
"content": "Define V2 synthetic recovery ownership and discovery",
"status": "in_progress",
"priority": "high"
},
{
"content": "Implement interruption reconciliation and synthetic continuation",
"status": "pending",
"priority": "high"
},
{
"content": "Add restart and duplicate-claim regressions",
"status": "pending",
"priority": "high"
},
{
"content": "Run package validation and isolated restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Run independent correctness and security reviews",
"status": "pending",
"priority": "high"
}
]
I’m now tracing the exact Step lifecycle and event/projector transaction APIs needed for that boundary. Before editing, I’ll lock down four points: where execution becomes uncertain, how a Step becomes terminal, how a synthetic input gets a deterministic ID, and how migrations are generated.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
1: import {
2: LLM,
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Message,
7: SystemPart,
8: isContextOverflowFailure,
9: type ProviderErrorEvent,
10: } from "@opencode-ai/llm"
11: import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
12: import { AgentV2 } from "../../agent"
13: import { Config } from "../../config"
14: import { Database } from "../../database/database"
15: import { EventV2 } from "../../event"
16: import { Location } from "../../location"
17: import { ModelV2 } from "../../model"
18: import { PermissionV2 } from "../../permission"
19: import { ProviderV2 } from "../../provider"
20: import { QuestionV2 } from "../../question"
21: import { SystemContext } from "../../system-context/index"
22: import { SystemContextRegistry } from "../../system-context/registry"
23: import { SkillGuidance } from ...
<path>/home/byk/Code/opencode/packages/core/src/session/event.ts</path> <type>file</type> <content> 1: export * from "@opencode-ai/schema/session-event" 2: export * as SessionEvent from "@opencode-ai/schema/session-event" (End of file - total 2 lines) </content>
Found 9 matches
/home/byk/Code/opencode/packages/schema/src/session-event.ts:
Line 94: export const PromptAdmitted = Event.define({
Line 99: export type PromptAdmitted = typeof PromptAdmitted.Type
Line 148: export namespace Step {
Line 453: PromptAdmitted,
Line 458: Step.Started,
Line 459: Step.Ended,
Line 484: PromptAdmitted,
Line 489: Step.Started,
Line 490: Step.Ended,
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
70: messageID: SessionMessage.ID,
71: model: Model.Ref,
72: },
73: })
74: export type ModelSwitched = typeof ModelSwitched.Type
75:
76: export const Moved = Event.define({
77: type: "session.next.moved",
78: ...options,
79: schema: {
80: ...Base,
81: location: Location.Ref,
82: subdirectory: RelativePath.pipe(optional),
83: },
84: })
85: export type Moved = typeof Moved.Type
86:
87: export const Prompted = Event.define({
88: type: "session.next.prompted",
89: ...options,
90: schema: PromptFields,
91: })
92: export type Prompted = typeof Prompted.Type
93:
94: export const PromptAdmitted = Event.define({
95: type: "session.next.prompt.admitted",
96: ...options,
97: schema: PromptFields,
98: })
99: export type PromptAdmitted = typeof PromptAdmitted.Type
100:
101: export const ContextUpdated = Event.define({
102: type: ...
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
100: tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
101: tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
102: tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
103: tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
104: time_updated: sql`${SessionTable.time_updated}`,
105: })
106: .where(eq(SessionTable.id, sessionID))
107: .run()
108: .pipe(Effect.orDie)
109: }
110:
111: function run(db: DatabaseService, event: SessionEvent.Event) {
112: return Effect.gen(function* () {
113: const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
114: decodeMessage({ ...row.data, id: row.id, type: row.type })
115: const updateMessage = (message: ...
File not found: /home/byk/Code/opencode/packages/core/src/session/message/updater.ts
/home/byk/Code/opencode/packages/core/src/session/message-updater.ts
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
1: import { castDraft, produce, type WritableDraft } from "immer"
2: import { Effect } from "effect"
3: import { SessionEvent } from "./event"
4: import { SessionMessage } from "./message"
5:
6: export type MemoryState = {
7: messages: SessionMessage.Message[]
8: }
9:
10: export interface Adapter {
11: readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
12: readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
13: readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
14: readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
15: readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
16: readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void>
17: }
18:
19: ...
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
100: },
101: data: event.data,
102: }),
103: )
104: return {
105: events,
106: hasMore: rows.length > input.limit,
107: }
108: })
109:
110: export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
111: "EventV2.SubscriberOverflow",
112: { capacity: Schema.Int },
113: ) {}
114:
115: export const define = Event.define
116: export const versionedType = Event.versionedType
117:
118: export interface PublishOptions {
119: readonly id?: ID
120: readonly metadata?: Record<string, unknown>
121: readonly location?: Location.Ref
122: /** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
123: readonly commit?: (seq: number) => Effect.Effect<void>
124: }
125:
126: export interface Interface {
127: readonly publish: <D extends Definition>(
128: definition: ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
1: export * as SessionInput from "./input"
2:
3: import { and, asc, eq, isNull, lte } from "drizzle-orm"
4: import { DateTime, Effect, Schema } from "effect"
5: import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
6: import type { Database } from "../database/database"
7: import type { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionMessage } from "./message"
10: import { Prompt } from "./prompt"
11: import { SessionSchema } from "./schema"
12: import { SessionInputTable, SessionMessageTable } from "./sql"
13:
14: type DatabaseService = Database.Interface["db"]
15:
16: export { Admitted, Delivery }
17:
18: const decodePrompt = Schema.decodeUnknownSync(Prompt)
19: const encodePrompt = Schema.encodeSync(Prompt)
20:
21: const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
22: Admitted.make({
23: ...
Found 27 matches
/home/byk/Code/opencode/packages/core/src/session/projector.ts:
Line 68: revert: info.revert ? { ...info.revert, messageID: SessionMessage.ID.make(info.revert.messageID) } : null,
Line 124: eq(SessionMessageTable.id, SessionMessage.ID.make(id)),
Line 199: id: SessionMessage.ID.make(id),
/home/byk/Code/opencode/packages/core/src/session/input.ts:
Line 24: id: SessionMessage.ID.make(row.id),
Line 32: export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
Line 38: id: SessionMessage.ID,
Line 45: readonly id: SessionMessage.ID
Line 87: readonly id: SessionMessage.ID
Line 121: readonly id: SessionMessage.ID
Line 223: const id = SessionMessage.ID.make(row.id)
/home/byk/Code/opencode/packages/core/src/session/info.ts:
Line 43: revert: row.revert ? { ...row.revert, messageID: SessionMessage.ID.make(row.revert.messageID) } : ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
40: }
41:
42: type SettledOutput =
43: | { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
44: | { readonly error: { readonly type: "unknown"; readonly message: string } }
45:
46: const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
47: if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
48: const settled = value ?? ToolOutput.fromResultValue(result)
49: if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
50: return { structured: record(settled.structured), content: settled.content }
51: }
52:
53: /** Persist one provider turn without executing tools or starting a continuation turn. */
54: export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
55: const tools = new ...
<path>/home/byk/Code/opencode/.opencode</path> <type>directory</type> <entries> .gitignore agent/ bun.lock command/ env.d.ts glossary/ node_modules/ opencode.jsonc package-lock.json package.json plans/ plugins/ skills/ themes/ tool/ tui.json (16 entries) </entries>
The smallest sound boundary is simpler than a new execution ledger: persist Step.Started before llm.stream, then use the latest incomplete assistant as the recovery boundary. A single Step.Failed transaction can atomically mark that Step’s outcome unknown, insert a unique (session, assistant) recovery claim, and admit the synthetic continue; only the winning transaction wakes it.
<path>/home/byk/Code/opencode/packages/core/src/session/execution.ts</path>
<type>file</type>
<content>
1: export * as SessionExecution from "./execution"
2:
3: import { Context, Effect, Layer } from "effect"
4: import { LayerNode } from "../effect/layer-node"
5: import { Node } from "../effect/app-node"
6: import { SessionRunner } from "./runner/index"
7: import { SessionSchema } from "./schema"
8:
9: export interface Interface {
10: /** Snapshots active execution owned by this process. */
11: readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
12: /** Starts execution while idle or joins the active execution. */
13: readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
14: /** Registers newly recorded work. Repeated wakeups may coalesce. */
15: readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
16: /** Interrupt active work owned by this process. Idle interruption is a no-op. ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { Cause, Effect, Layer } from "effect"
2: import { LocationServiceMap } from "../../location-service-map"
3: import { makeGlobalNode } from "../../effect/app-node"
4: import { SessionRunCoordinator } from "../run-coordinator"
5: import { SessionRunner } from "../runner"
6: import { SessionSchema } from "../schema"
7: import { SessionStore } from "../store"
8: import { SessionExecution } from "../execution"
9:
10: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
11: const layer = Layer.effect(
12: SessionExecution.Service,
13: Effect.gen(function* () {
14: const store = yield* SessionStore.Service
15: const locations = yield* LocationServiceMap.Service
16: const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
17: drain: Effect.fnUntraced(function* ...
<path>/home/byk/Code/opencode/packages/server/src/routes.ts</path>
<type>file</type>
<content>
1: import { Database } from "@opencode-ai/core/database/database"
2: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
3: import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
4: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
5: import { EventV2 } from "@opencode-ai/core/event"
6: import { Credential } from "@opencode-ai/core/credential"
7: import { PermissionSaved } from "@opencode-ai/core/permission/saved"
8: import { PtyTicket } from "@opencode-ai/core/pty/ticket"
9: import { SessionV2 } from "@opencode-ai/core/session"
10: import { SessionExecution } from "@opencode-ai/core/session/execution"
11: import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
12: import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
13: import { ToolOutputStore } from ...
<path>/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/server.ts</path>
<type>file</type>
<content>
180: Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
181: )
182:
183: // `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
184: // processes that never serve it (CLI, scripts) don't pay at module load.
185: // `HttpServerResponse.jsonUnsafe` runs JSON.stringify eagerly, so caching
186: // the response also caches the serialized body — every /doc request reuses
187: // the same Uint8Array instead of re-stringifying the spec.
188: const docResponse = lazy(() => HttpServerResponse.jsonUnsafe(OpenApi.fromApi(PublicApi)))
189:
190: const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effect.succeed(docResponse()))).pipe(
191: Layer.provide(authOnlyRouterLayer),
192: )
193:
194: const uiRoute = HttpRouter.use((router) =>
195: Effect.gen(function* () {
196: const fs = yield* FSUtil.Service
197: ...
The atomic event must own the full recovery transition, not hide durable admission in a non-replayable commit hook. I’ll add one durable Session.Recovered fact whose projector, in the same transaction, claims the incomplete assistant, marks its Step and pending tools interrupted, and admits a deterministic continue input; replay reconstructs the same state.
<path>/home/byk/Code/opencode/packages/core/src/session/prompt.ts</path>
<type>file</type>
<content>
1: export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt"
(End of file - total 1 lines)
</content>
<path>/home/byk/Code/opencode/packages/schema/src/prompt.ts</path>
<type>file</type>
<content>
1: import { Schema } from "effect"
2: import { optional } from "./schema"
3: import { statics } from "./schema"
4:
5: export interface Source extends Schema.Schema.Type<typeof Source> {}
6: export const Source = Schema.Struct({
7: start: Schema.Finite,
8: end: Schema.Finite,
9: text: Schema.String,
10: }).annotate({ identifier: "Prompt.Source" })
11:
12: export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment> {}
13: export const FileAttachment = Schema.Struct({
14: uri: Schema.String,
15: mime: Schema.String,
16: name: Schema.String.pipe(optional),
17: description: Schema.String.pipe(optional),
18: source: Source.pipe(optional),
19: })
20: .annotate({ identifier: "Prompt.FileAttachment" })
21: .pipe(
22: statics((schema) => ({
23: create: (input: FileAttachment) =>
24: schema.make({
25: uri: input.uri,
26: ...
Found 7 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 280: yield* events.publish(SessionEvent.Synthetic, {
Line 396: yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" })
/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts:
Line 523: yield* events.publish(SessionEvent.Synthetic, {
/home/byk/Code/opencode/packages/core/test/shared-schema.test.ts:
Line 150: [coreSessionMessage.Synthetic, SessionMessage.Synthetic],
/home/byk/Code/opencode/packages/core/test/session-runner-message.test.ts:
Line 80: SessionMessage.Synthetic.make({
Line 84: text: "Synthetic context",
Line 121: [{ type: "text", text: "Synthetic context" }],
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path>
<type>file</type>
<content>
1: export * as SessionV2 from "./session"
2: export * from "./session/schema"
3:
4: import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
5: import { ListAnchor } from "@opencode-ai/schema/session"
6: import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
7: import { ProjectV2 } from "./project"
8: import { WorkspaceV2 } from "./workspace"
9: import { ModelV2 } from "./model"
10: import { Location } from "./location"
11: import { SessionMessage } from "./session/message"
12: import { Prompt } from "./session/prompt"
13: import { PromptInput } from "@opencode-ai/schema/prompt-input"
14: import { EventV2 } from "./event"
15: import { Database } from "./database/database"
16: import { SessionProjector } from "./session/projector"
17: import { SessionMessageTable, SessionTable } from "./session/sql"
18: import { SessionSchema } from ...
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path>
<type>file</type>
<content>
430: interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
431: Effect.uninterruptible(execution.interrupt(sessionID)),
432: ),
433: revert: {
434: stage: Effect.fn("V2Session.revert.stage")(function* (input) {
435: const session = yield* result.get(input.sessionID)
436: return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
437: Effect.provideService(Database.Service, database),
438: Effect.provideService(EventV2.Service, events),
439: Effect.provide(locations.get(session.location)),
440: )
441: }),
442: clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
443: const session = yield* result.get(sessionID)
444: yield* SessionRevert.clear(session).pipe(
445: ...
The remaining correctness issue is live overlap: an incomplete Step alone does not prove its owner died. I’m checking current process-instance and migration patterns before choosing whether recovery can safely rely on the server’s single-instance contract or needs a small lease; I will not add a claim that can interrupt another live server’s Step.
Found 97 matches
/home/byk/Code/opencode/packages/core/src/ripgrep/binary.ts:
Line 105: const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
/home/byk/Code/opencode/packages/core/src/event/sql.ts:
Line 7: owner_id: text(),
/home/byk/Code/opencode/packages/core/src/v1/config/provider.ts:
Line 17: release_date: Schema.optional(Schema.String),
/home/byk/Code/opencode/packages/core/src/v1/config/mcp.ts:
Line 18: description: "Enable or disable the MCP server on startup",
Line 48: description: "Enable or disable the MCP server on startup",
/home/byk/Code/opencode/packages/core/src/database/schema.gen.ts:
Line 76: \`owner_id\` text
/home/byk/Code/opencode/packages/core/src/models-dev.ts:
Line 71: release_date: Schema.String,
Line 204: const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
/home/byk/Code/opencode/packages/core/src/database/sqlite.node.ts:
Line 121: ...
A recovery event alone prevents duplicate injection but cannot distinguish a dead owner from an older live server during overlapping restart. I’m checking whether the server already enforces one database owner; if not, the implementation needs a short durable execution lease so startup recovery never races live provider or tool work.
Found 21 matches
/home/byk/Code/opencode/packages/opencode/src/tool/registry.ts:
Line 415: (entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean"
/home/byk/Code/opencode/packages/opencode/src/acp/content.ts:
Line 46: url: `data:${block.mimeType};base64,${block.data}`,
Line 106: : `data:${block.resource.mimeType};base64,${block.resource.blob}`,
/home/byk/Code/opencode/packages/opencode/src/tool/code-mode.ts:
Line 91: push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
Line 99: push({ type: "file", mime, url: dataUrl(mime, block.resource.blob), filename: lastSegment(block.resource.uri) })
/home/byk/Code/opencode/packages/opencode/src/plugin/tui/runtime.ts:
Line 36: import { Flock } from "@opencode-ai/core/util/flock"
Line 271: await Flock.withLock(`tui-theme:${dest}`, async () => ...
Found 14 matches
/home/byk/Code/opencode/packages/opencode/test/effect/runner.test.ts:
Line 90: "second ensureRunning ignores new work if already running",
/home/byk/Code/opencode/packages/opencode/test/plugin/xai.test.ts:
Line 239: test("does not share refresh single-flight across loader instances", async () => {
/home/byk/Code/opencode/packages/opencode/test/session/prompt.test.ts:
Line 2015: "shell rejects when another shell is already running",
/home/byk/Code/opencode/packages/opencode/test/cli/run/stream.transport.test.ts:
Line 2354: ).rejects.toThrow("prompt already running")
/home/byk/Code/opencode/packages/opencode/src/node-server.ts:
Line 89: process.env.OPENCODE_PID = String(process.pid)
/home/byk/Code/opencode/packages/opencode/src/index.ts:
Line 77: process.env.OPENCODE_PID = String(process.pid)
/home/byk/Code/opencode/packages/opencode/src/cli/cmd/run/stream.transport.ts:
Line 1198: yield* Effect.fail(new ...
I found the current server does not enforce single database ownership. I’m keeping the change scoped: recovery uses an atomic compare-and-set on the unfinished assistant projection to ensure only one startup scanner can inject continue; it will not add a speculative cluster lease. I’ll document that overlapping old and new servers remain unsupported, matching current execution ownership.
Cloning into '.opencode/references/effect-smol'...
The source checkout is now available. I’m validating the repository’s exact Effect startup-layer and SQLite compare-and-set patterns, then I’ll edit the Step start boundary, recovery event/projector, startup service, migration, and focused tests as one coherent change.
File not found: /home/byk/Code/opencode/packages/core/AGENTS.md
/home/byk/Code/opencode/packages/core/src/database/migration/20260410174513_workspace-name.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260622202450_simplify_session_input.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260612174303_project_dir_strategy.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260611192811_lush_chimera.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260611035744_credential.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260605042240_add_context_epoch_agent.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260605003541_add_session_context_snapshot.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260604172448_event_sourced_session_i...
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260622202450_simplify_session_input.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260622202450_simplify_session_input",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
9: yield* tx.run(`DELETE FROM \`session_input\`;`)
10: yield* tx.run(`DELETE FROM \`session_message\`;`)
11: yield* tx.run(`DELETE FROM \`event\`;`)
12: yield* tx.run(`DELETE FROM \`event_sequence\`;`)
13: yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`)
14: yield* tx.run(`DELETE FROM \`workspace\`;`)
15: })
16: },
17: } satisfies DatabaseMigration.Migration
(End of file - total 17 lines)
</content>
<path>/home/byk/Code/opencode/packages/core/src/database/migration.ts</path>
<type>file</type>
<content>
1: export * as DatabaseMigration from "./migration"
2:
3: import { sql } from "drizzle-orm"
4: import { Effect, Semaphore } from "effect"
5: import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
6: import { migrations } from "./migration.gen"
7: import schema from "./schema.gen"
8:
9: type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
10: type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
11: const lock = Semaphore.makeUnsafe(1)
12:
13: export type Migration = {
14: id: string
15: up: (tx: Transaction) => Effect.Effect<void, unknown>
16: }
17:
18: export function apply(db: Database) {
19: return lock.withPermit(
20: Effect.gen(function* () {
21: const tables = yield* db.all<{ name: string }>(
22: sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
23: )
24: ...
<path>/home/byk/Code/opencode/packages/core/script/migration.ts</path>
<type>file</type>
<content>
1: #!/usr/bin/env bun
2:
3: import { $ } from "bun"
4: import fs from "fs/promises"
5: import os from "os"
6: import path from "path"
7: import { pathToFileURL } from "url"
8: import { parseArgs } from "util"
9:
10: const root = path.resolve(import.meta.dirname, "../../..")
11: const snapshot = path.join(root, "packages/core/schema.json")
12: const tsDir = path.join(root, "packages/core/src/database/migration")
13: const registry = path.join(root, "packages/core/src/database/migration.gen.ts")
14: const schema = path.join(root, "packages/core/src/database/schema.gen.ts")
15: const args = parseArgs({
16: args: process.argv.slice(2),
17: options: {
18: check: { type: "boolean" },
19: name: { type: "string" },
20: },
21: })
22:
23: if (args.values.check) {
24: await check()
25: process.exit(0)
26: }
27:
28: await generate()
29:
30: async function generate() {
31: ...
<path>/home/byk/Code/opencode/packages/core/src/location-service-map.ts</path>
<type>file</type>
<content>
1: import { Context, Effect, Layer, LayerMap } from "effect"
2: import { LayerNode } from "./effect/layer-node"
3: import { Node } from "./effect/app-node"
4: import { Location } from "./location"
5: import type { LocationError, LocationServices } from "./location-services"
6:
7: export class Service extends Context.Service<
8: Service,
9: LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>
10: >()("@opencode/example/LocationServiceMap") {
11: static get(ref: Location.Ref) {
12: return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
13: }
14: }
15:
16: export const node = LayerNode.unbound(Service, Node.tags.values.global)
17:
18: export * as LocationServiceMap from "./location-service-map"
(End of file - total 18 lines)
</content>
<path>/home/byk/Code/opencode/packages/core/src/effect/app-node.ts</path>
<type>file</type>
<content>
1: import { LayerNode } from "./layer-node"
2:
3: export const tags = LayerNode.tags({
4: location: ["global"],
5: global: [],
6: })
7:
8: export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["global"]>
9: export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["location"]>
10:
11: export const makeGlobalNode = tags.make("global")
12: export const makeLocationNode = tags.make("location")
13:
14: export * as Node from "./app-node"
(End of file - total 14 lines)
</content>
Found 44 matches
/home/byk/Code/opencode/packages/core/src/ripgrep.ts:
Line 114: Effect.forkScoped,
/home/byk/Code/opencode/packages/core/src/models-dev.ts:
Line 257: yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
/home/byk/Code/opencode/packages/core/src/tool-output-store.ts:
Line 200: export const cleanupLayer = Layer.effectDiscard(
Line 203: yield* store.cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped)
/home/byk/Code/opencode/packages/core/src/question.ts:
Line 81: yield* Effect.addFinalizer(() =>
/home/byk/Code/opencode/packages/core/src/pty.ts:
Line 127: yield* Effect.addFinalizer(() =>
/home/byk/Code/opencode/packages/core/src/process.ts:
Line 223: const stderrFiber = yield* Effect.forkScoped(
/home/byk/Code/opencode/packages/core/src/plugin.ts:
Line 128: yield* Effect.addFinalizer((exit) ...
<path>/home/byk/Code/opencode/packages/core/src/project/copy.ts</path>
<type>file</type>
<content>
260: ),
261: }),
262: )
263: .pipe(Effect.orDie)
264: const changes = {
265: updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
266: removed: removed.filter((_, index) => result.removed[index]),
267: }
268: yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
269: return changes
270: })
271:
272: return Service.of({
273: register,
274: create,
275: remove,
276: refresh,
277: })
278: }),
279: )
280:
281: export const locationLayer = layer
282: export const node = makeLocationNode({
283: service: Service,
284: layer: layer,
285: deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node],
286: })
287:
288: export const refreshNode = makeLocationNode({
289: name: ...
Found 1 matches /home/byk/Code/opencode/packages/server/src/routes.ts: Line 30: ToolOutputStore.cleanupNode,
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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?: ...
The recovery transition is now fully specified. I’m checking model-history lowering and test fixtures before editing so the recovered Step exposes both the unknown Step failure and each interrupted tool result to the next Step, rather than only changing database status.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/to-llm-message.ts</path>
<type>file</type>
<content>
1: import {
2: Message,
3: ToolCallPart,
4: ToolOutput,
5: ToolResultPart,
6: type ContentPart,
7: type Model,
8: type ProviderMetadata,
9: } from "@opencode-ai/llm"
10: import { SessionMessage } from "../message"
11: import type { FileAttachment } from "../prompt"
12:
13: const media = (file: FileAttachment): ContentPart => ({
14: type: "media",
15: mediaType: file.mime,
16: data: file.uri,
17: filename: file.name,
18: metadata: file.description === undefined ? undefined : { description: file.description },
19: })
20:
21: const toolInput = (tool: SessionMessage.AssistantTool) => {
22: if (tool.state.status !== "pending") return tool.state.input
23: try {
24: return JSON.parse(tool.state.input) as unknown
25: } catch {
26: return tool.state.input
27: }
28: }
29:
30: const toolCall = (tool: SessionMessage.AssistantTool, ...
The pre-dispatch Step.Started shortcut would break overflow recovery because the runner deliberately retries overflow only before an assistant projection exists. I’m correcting the design: add a durable physical-attempt marker before llm.stream; normal Step settlement or an explicit pre-output abandonment clears it, while startup converts only a surviving marker into one unknown-outcome assistant failure plus one synthetic continue admission.
<path>/home/byk/Code/opencode/packages/schema/src/durable-event-manifest.ts</path>
<type>file</type>
<content>
1: export * as DurableEventManifest from "./durable-event-manifest"
2:
3: import { Event } from "./event"
4: import { SessionEvent } from "./session-event"
5: import { SessionV1 } from "./session-v1"
6:
7: export const SessionDurable = {
8: definitions: Event.durable(SessionEvent.DurableDefinitions),
9: schema: SessionEvent.Durable,
10: } as const
11:
12: export const Durable = Event.durable([
13: ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined),
14: ...SessionEvent.DurableDefinitions,
15: ])
(End of file - total 15 lines)
</content>
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
1: export * as SessionEvent from "./session-event"
2:
3: import { Schema } from "effect"
4: import { optional } from "./schema"
5: import { Event } from "./event"
6: import { ProviderMetadata, ToolContent } from "./llm"
7: import { Delivery } from "./session-delivery"
8: import { Model } from "./model"
9: import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema"
10: import { FileAttachment, Prompt } from "./prompt"
11: import { SessionID } from "./session-id"
12: import { Location } from "./location"
13: import { SessionMessage } from "./session-message"
14: import { Revert } from "./revert"
15:
16: export { FileAttachment }
17:
18: export const Source = Schema.Struct({
19: start: NonNegativeInt,
20: end: NonNegativeInt,
21: text: Schema.String,
22: }).annotate({
23: identifier: "session.next.event.source",
24: })
25: export interface Source extends ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: }
15:
16: const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
17:
18: const tokens = (usage: Usage | undefined) => {
19: const reasoning = safe(usage?.reasoningTokens)
20: const read = safe(usage?.cacheReadInputTokens)
21: const write = safe(usage?.cacheWriteInputTokens)
22: ...
<path>/home/byk/Code/opencode/packages/schema/src/event.ts</path>
<type>file</type>
<content>
1: export * as Event from "./event"
2:
3: import { Schema } from "effect"
4: import { optional } from "./schema"
5: import { ascending } from "./identifier"
6: import { Location } from "./location"
7: import { statics } from "./schema"
8:
9: export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
10: Schema.brand("Event.ID"),
11: statics((schema) => ({ create: () => schema.make("evt_" + ascending()) })),
12: )
13: export type ID = typeof ID.Type
14:
15: export type Definition<
16: Type extends string = string,
17: DataSchema extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>,
18: > = Schema.Top & {
19: readonly type: Type
20: readonly durable?: {
21: readonly version: number
22: readonly aggregate: string
23: }
24: readonly data: DataSchema
25: }
26:
27: export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
28: ...
Found 3 matches /home/byk/Code/opencode/packages/schema/src/session-event.ts: Line 150: type: "session.next.step.started", Line 163: type: "session.next.step.ended", Line 186: type: "session.next.step.failed",
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
130: callID: Schema.String,
131: command: Schema.String,
132: },
133: })
134: export type Started = typeof Started.Type
135:
136: export const Ended = Event.define({
137: type: "session.next.shell.ended",
138: ...options,
139: schema: {
140: ...Base,
141: callID: Schema.String,
142: output: Schema.String,
143: },
144: })
145: export type Ended = typeof Ended.Type
146: }
147:
148: export namespace Step {
149: export const Started = Event.define({
150: type: "session.next.step.started",
151: ...options,
152: schema: {
153: ...Base,
154: assistantMessageID: SessionMessage.ID,
155: agent: Schema.String,
156: model: Model.Ref,
157: snapshot: Schema.String.pipe(optional),
158: },
159: })
160: export type Started = typeof Started.Type
161:
162: export const Ended = ...
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
400: type: "session.next.compaction.started",
401: ...options,
402: schema: {
403: ...Base,
404: messageID: SessionMessage.ID,
405: reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
406: },
407: })
408: export type Started = typeof Started.Type
409:
410: export const Delta = Event.define({
411: type: "session.next.compaction.delta",
412: schema: {
413: ...Base,
414: messageID: SessionMessage.ID,
415: text: Schema.String,
416: },
417: })
418: export type Delta = typeof Delta.Type
419:
420: export const Ended = Event.define({
421: type: "session.next.compaction.ended",
422: ...options,
423: schema: {
424: ...Base,
425: messageID: SessionMessage.ID,
426: reason: Started.data.fields.reason,
427: text: Schema.String,
428: recent: ...
<path>/home/byk/Code/opencode</path> <type>directory</type> <entries> .dockerignore .editorconfig .git/ .gitattributes .github/ .gitignore .gitleaksignore .husky/ .lore.md .node-cache/ .opencode/ .oxlintrc.json .prettierignore .turbo/ .vscode/ .zed/ AGENTS.md artifacts/ bun.lock bunfig.toml CONTEXT.md CONTRIBUTING.md flake.lock flake.nix github/ infra/ install LICENSE nix/ node_modules/ package.json packages/ patches/ perf.data perf.data.old perf/ pnpm-lock.yaml pnpm-workspace.yaml README.ar.md README.bn.md README.br.md README.bs.md README.da.md README.de.md README.es.md README.fr.md README.gr.md README.it.md README.ja.md README.ko.md README.md README.no.md README.pl.md README.ru.md README.th.md README.tr.md README.uk.md README.vi.md README.zh.md README.zht.md screenshot-uk.png script/ sdks/ SECURITY.md specs/ sst-env.d.ts sst.config.ts STATS.md tsconfig.json turbo.json (70 entries) </entries>
The physical-attempt marker preserves existing logical Step behavior and gives startup an exact uncertain-work predicate. I’m tracing every llm.stream exit now so normal completion, known pre-dispatch failures, overflow retries, and interruption each settle or retain the marker deliberately before I patch the schema.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
1: import {
2: LLM,
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Message,
7: SystemPart,
8: isContextOverflowFailure,
9: type ProviderErrorEvent,
10: } from "@opencode-ai/llm"
11: import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
12: import { AgentV2 } from "../../agent"
13: import { Config } from "../../config"
14: import { Database } from "../../database/database"
15: import { EventV2 } from "../../event"
16: import { Location } from "../../location"
17: import { ModelV2 } from "../../model"
18: import { PermissionV2 } from "../../permission"
19: import { ProviderV2 } from "../../provider"
20: import { QuestionV2 } from "../../question"
21: import { SystemContext } from "../../system-context/index"
22: import { SystemContextRegistry } from "../../system-context/registry"
23: import { SkillGuidance } from ...
[
{
"content": "Define V2 synthetic recovery ownership and discovery",
"status": "in_progress",
"priority": "high"
},
{
"content": "Implement interruption reconciliation and synthetic continuation",
"status": "pending",
"priority": "high"
},
{
"content": "Add restart and duplicate-claim regressions",
"status": "pending",
"priority": "high"
},
{
"content": "Run package validation and isolated restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Run independent correctness and security reviews",
"status": "pending",
"priority": "high"
}
]
The design now covers the shutdown race without a cluster lease: each physical attempt gets durable Started, RecoveryRequested, Settled, and Recovered facts. Shutdown requests recovery before execution fibers close; only an interrupted attempt preserves that request, while completed or ordinary failed attempts clear it. Startup atomically turns each surviving attempt into an unknown outcome and one synthetic continue.
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
1: export * as SessionProjector from "./projector"
2:
3: import { and, desc, eq, gt, or, sql } from "drizzle-orm"
4: import { DateTime, Effect, Layer, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { EventV2 } from "../event"
7: import { makeGlobalNode } from "../effect/app-node"
8: import { SessionEvent } from "./event"
9: import { SessionV1 } from "../v1/session"
10: import { WorkspaceTable } from "../control-plane/workspace.sql"
11: import { SessionMessage } from "./message"
12: import { SessionMessageUpdater } from "./message-updater"
13: import { SessionInput } from "./input"
14: import { WorkspaceV2 } from "../workspace"
15: import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
16: import type { DeepMutable } from "../schema"
17:
18: type DatabaseService = Database.Interface["db"]
19:
20: ...
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
300: .pipe(Effect.orDie)
301: const previous = row && usage(row.data)
302: if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1)
303: yield* db
304: .delete(PartTable)
305: .where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
306: .run()
307: .pipe(Effect.orDie)
308: }),
309: )
310: yield* events.project(SessionV1.Event.PartUpdated, (event) =>
311: Effect.gen(function* () {
312: const id = event.data.part.id
313: const messageID = event.data.part.messageID
314: const sessionID = event.data.part.sessionID
315: const data = partData(event.data.part)
316: const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie)
317: yield* db
318: ...
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
369: function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
370: return Effect.gen(function* () {
371: if (!definition?.durable && commit)
372: return yield* Effect.die(
373: new InvalidDurableEventError({
374: type: event.type,
375: message: "Local commit hooks require a durable event",
376: }),
377: )
378: if (definition?.durable) {
379: const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
380: if (committed) {
381: event = {
382: ...event,
383: durable: {
384: aggregateID: committed.aggregateID,
385: seq: committed.seq,
386: version: ...
The marker must survive only ambiguous interruption, not ordinary provider failure. I’m implementing recovery as one replayable event and projector transaction: it claims one unfinished physical attempt, completes its assistant and unsettled tools with unknown errors, and inserts one deterministic continue inbox item before startup issues an advisory wake.
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
1: import { castDraft, produce, type WritableDraft } from "immer"
2: import { Effect } from "effect"
3: import { SessionEvent } from "./event"
4: import { SessionMessage } from "./message"
5:
6: export type MemoryState = {
7: messages: SessionMessage.Message[]
8: }
9:
10: export interface Adapter {
11: readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
12: readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
13: readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
14: readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
15: readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
16: readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void>
17: }
18:
19: ...
continue. the server was restarted so you'll have to nudge your background agents to continue too.
I’m resuming the failed recovery-design agent with a narrow read-only assignment: verify migration and startup-layer patterns only. In parallel, I’ll finish the attempt lifecycle and recovery event implementation without touching the running service or the existing unrelated worktree files.
<task id="ses_f60e159e9ffeVskuIe3XCp5fFR" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<skill_content name="effect">
# Skill: effect
# Effect
This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
## Source Of Truth
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
3. Also inspect existing repo code for local house style before introducing new patterns.
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
## Guidelines
- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
- Use `Effect.gen(function* () { ... })` for multi-step workflows.
- Use `Effect.fn("Name")` or ...
Found 9 matches
/home/byk/Code/opencode/packages/core/src/session/projector.ts:
Line 379: yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
Line 380: yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
Line 381: yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 64: * - [x] Stream exactly one `llm.stream(request)` provider turn.
Line 85: * Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here.
Line 239: const providerStream = llm.stream(request).pipe(
Line 333: events.publish(SessionEvent.Step.Ended, {
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line 78: yield* events.publish(SessionEvent.Step.Started, {
Line 205: yield* events.publish(SessionEvent.Step.Failed, {
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
1: import {
2: LLM,
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Message,
7: SystemPart,
8: isContextOverflowFailure,
9: type ProviderErrorEvent,
10: } from "@opencode-ai/llm"
11: import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
12: import { AgentV2 } from "../../agent"
13: import { Config } from "../../config"
14: import { Database } from "../../database/database"
15: import { EventV2 } from "../../event"
16: import { Location } from "../../location"
17: import { ModelV2 } from "../../model"
18: import { PermissionV2 } from "../../permission"
19: import { ProviderV2 } from "../../provider"
20: import { QuestionV2 } from "../../question"
21: import { SystemContext } from "../../system-context/index"
22: import { SystemContextRegistry } from "../../system-context/registry"
23: import { SkillGuidance } from ...
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
1: export * as SessionEvent from "./session-event"
2:
3: import { Schema } from "effect"
4: import { optional } from "./schema"
5: import { Event } from "./event"
6: import { ProviderMetadata, ToolContent } from "./llm"
7: import { Delivery } from "./session-delivery"
8: import { Model } from "./model"
9: import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema"
10: import { FileAttachment, Prompt } from "./prompt"
11: import { SessionID } from "./session-id"
12: import { Location } from "./location"
13: import { SessionMessage } from "./session-message"
14: import { Revert } from "./revert"
15:
16: export { FileAttachment }
17:
18: export const Source = Schema.Struct({
19: start: NonNegativeInt,
20: end: NonNegativeInt,
21: text: Schema.String,
22: }).annotate({
23: identifier: "session.next.event.source",
24: })
25: export interface Source extends ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { Cause, Effect, Layer } from "effect"
2: import { LocationServiceMap } from "../../location-service-map"
3: import { makeGlobalNode } from "../../effect/app-node"
4: import { SessionRunCoordinator } from "../run-coordinator"
5: import { SessionRunner } from "../runner"
6: import { SessionSchema } from "../schema"
7: import { SessionStore } from "../store"
8: import { SessionExecution } from "../execution"
9:
10: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
11: const layer = Layer.effect(
12: SessionExecution.Service,
13: Effect.gen(function* () {
14: const store = yield* SessionStore.Service
15: const locations = yield* LocationServiceMap.Service
16: const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
17: drain: Effect.fnUntraced(function* ...
Found 73 matches
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Stream.ts:
Line 9971: * Stream.ensuring(Effect.orDie(Console.log("cleanup")))
Line 9993: fromChannel(Channel.ensuring(self.channel, finalizer))
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Pool.ts:
Line 329: Effect.uninterruptibleMask(Effect.fnUntraced(function*(restore) {
Line 430: Effect.uninterruptibleMask((restore) =>
Line 435: Effect.ensuring(Effect.sync(() => self.state.waiters--)),
Line 461: Effect.onInterrupt(() => self.state.semaphore.release(1))
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/PartitionedSemaphore.ts:
Line 245: return Effect.uninterruptibleMask((restore) =>
Line 249: Effect.ensuring(
Line 291: return ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: }
15:
16: const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
17:
18: const tokens = (usage: Usage | undefined) => {
19: const reasoning = safe(usage?.reasoningTokens)
20: const read = safe(usage?.cacheReadInputTokens)
21: const write = safe(usage?.cacheWriteInputTokens)
22: ...
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
390: schema: {
391: ...Base,
392: attempt: Schema.Finite,
393: error: RetryError,
394: },
395: })
396: export type Retried = typeof Retried.Type
397:
398: export namespace Compaction {
399: export const Started = Event.define({
400: type: "session.next.compaction.started",
401: ...options,
402: schema: {
403: ...Base,
404: messageID: SessionMessage.ID,
405: reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
406: },
407: })
408: export type Started = typeof Started.Type
409:
410: export const Delta = Event.define({
411: type: "session.next.compaction.delta",
412: schema: {
413: ...Base,
414: messageID: SessionMessage.ID,
415: text: Schema.String,
416: },
417: })
418: export type Delta = typeof Delta.Type
419:
420: export const Ended = Event.define({
421: type: ...
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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?: ...
Found 61 matches
/home/byk/Code/opencode/packages/codemode/src/interpreter/runtime.ts:
Line 2204: yield* Fiber.interrupt(item.fiber)
/home/byk/Code/opencode/packages/sdk-next/test/embedded.test.ts:
Line 62: yield* opencode.sessions.interrupt({ sessionID })
Line 70: opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 240: SessionExecution.Service,
Line 246: return SessionExecution.Service.of({
Line 541: yield* Fiber.interrupt(fiber)
Line 1329: yield* session.interrupt(sessionID)
Line 1991: yield* session.interrupt(sessionID)
Line 2033: yield* session.interrupt(sessionID)
Line 2227: yield* (yield* SessionExecution.Service).wake(sessionID)
Line 2433: yield* (yield* SessionExecution.Service).wake(sessionID)
Line 2460: yield* (yield* SessionExecution.Service).wake(sessionID)
Line ...
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path>
<type>file</type>
<content>
150: prompt: PromptInput.Prompt
151: delivery?: SessionInput.Delivery
152: resume?: boolean
153: }) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
154: readonly shell: (input: {
155: id?: EventV2.ID
156: sessionID: SessionSchema.ID
157: command: string
158: resume?: boolean
159: }) => Effect.Effect<void, OperationUnavailableError>
160: readonly skill: (input: {
161: id?: EventV2.ID
162: sessionID: SessionSchema.ID
163: skill: string
164: resume?: boolean
165: }) => Effect.Effect<void, OperationUnavailableError>
166: readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
167: readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
168: readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
169: ...
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
80: and(
81: eq(EventTable.aggregate_id, input.aggregateID),
82: gt(EventTable.seq, after),
83: inArray(EventTable.type, Array.from(input.manifest.definitions.keys())),
84: ),
85: )
86: .orderBy(asc(EventTable.seq))
87: .limit(input.limit + 1)
88: .all()
89: .pipe(Effect.orDie)
90: const page = rows.slice(0, input.limit)
91: const decode = Schema.decodeUnknownSync(input.manifest.schema)
92: const events = page.map((event) =>
93: decode({
94: id: event.id,
95: type: input.manifest.definitions.get(event.type)?.type ?? event.type,
96: durable: {
97: aggregateID: event.aggregate_id,
98: seq: event.seq,
99: version: input.manifest.definitions.get(event.type)?.durable?.version,
100: },
101: data: event.data,
102: }),
103: )
104: return {
105: events,
106: hasMore: ...
<path>/home/byk/Code/opencode/packages/schema/src/event.ts</path>
<type>file</type>
<content>
1: export * as Event from "./event"
2:
3: import { Schema } from "effect"
4: import { optional } from "./schema"
5: import { ascending } from "./identifier"
6: import { Location } from "./location"
7: import { statics } from "./schema"
8:
9: export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
10: Schema.brand("Event.ID"),
11: statics((schema) => ({ create: () => schema.make("evt_" + ascending()) })),
12: )
13: export type ID = typeof ID.Type
14:
15: export type Definition<
16: Type extends string = string,
17: DataSchema extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>,
18: > = Schema.Top & {
19: readonly type: Type
20: readonly durable?: {
21: readonly version: number
22: readonly aggregate: string
23: }
24: readonly data: DataSchema
25: }
26:
27: export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
28: ...
Found 1 matches /home/byk/Code/opencode/packages/core/src/session/runner/to-llm-message.ts: Line 132: case "synthetic":
/home/byk/Code/opencode/packages/core/src/session/sql.ts:22-176sqliteTable(...), snake-case fields, typed columns, foreign keys, and indexes. Representative examples:
SessionMessageTable: lines 119-138SessionInputTable: lines 140-166SessionContextEpochTable: lines 168-176/home/byk/Code/opencode/packages/core/drizzle.config.ts:3-9schema: ["./src/**/*.sql.ts", "./src/**/sql.ts"]/home/byk/Code/opencode/packages/core/script/migration.ts:10-20packages/core/schema.jsonpackages/core/src/database/migration/<generated-name>.tspackages/core/src/database/schema.gen.tspackages/core/src/database/migration.gen.ts/home/byk/Code/opencode/packages/core/script/migration.ts:30-61schema.json, expects at most one incremental migration, writes the TypeScript migration, updates the snapshot, then regenerates the full schema and registry./home/byk/Code/opencode/packages/core/script/migration.ts:127-153DatabaseMigration.Migration, with id and up(tx)./home/byk/Code/opencode/packages/core/src/database/migration.gen.ts:1-44/home/byk/Code/opencode/packages/core/package.json:8-13**Exact generation command, run from ...
Found 35 matches /home/byk/Code/opencode/packages/core/src/pty.ts: Line 278: ? Math.max(0, input.cursor) Line 282: const offset = Math.max(0, from - start) /home/byk/Code/opencode/packages/core/src/cross-spawn-spawner.ts: Line 162: const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2) /home/byk/Code/opencode/packages/core/src/catalog.ts: Line 269: const maxCost = Math.max(...items.map((item) => item.cost), 0.01) Line 270: const maxAge = Math.max(...items.map((item) => item.age), 0.01) /home/byk/Code/opencode/packages/core/src/database/migration/20260510033149_session_usage.ts: Line 18: SELECT sum(coalesce(json_extract(message.data, '$.cost'), 0)) Line 21: AND json_extract(message.data, '$.role') = 'assistant' Line 24: SELECT sum(coalesce(json_extract(message.data, '$.tokens.input'), 0)) Line 27: AND json_extract(message.data, '$.role') = 'assistant' ...
The wiring trace confirmed the repository’s standard path: a Core makeGlobalNode, included in both application graphs, plus a generated Core migration. Before editing, I’m locking down the critical ordering: Step.Started must commit before llm.stream(request) is evaluated, and interruption must leave a durable candidate that normal success or failure settles.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
280: yield* events.publish(SessionEvent.Reasoning.Delta, {
281: sessionID: input.sessionID,
282: assistantMessageID: yield* currentAssistantMessageID(),
283: timestamp: yield* timestamp,
284: reasoningID: event.id,
285: delta: event.text,
286: })
287: return
288: case "reasoning-end":
289: yield* reasoning.end(event.id, event.providerMetadata)
290: return
291: case "tool-input-start":
292: yield* startToolInput(event)
293: return
294: case "tool-input-delta": {
295: const tool = tools.get(event.id)
296: if (!tool) return yield* Effect.die(`Tool input delta before start: ${event.id}`)
297: if (tool.name !== event.name)
298: return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ...
Found 71 matches
/home/byk/Code/opencode/packages/schema/test/event.test.ts:
Line 14: type: "test.versioned",
Line 19: type: "test.versioned",
/home/byk/Code/opencode/packages/schema/src/session-event.ts:
Line 448: export const DurableDefinitions = Event.inventory(
Line 514: export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
Line 516: .annotate({ identifier: "SessionDurableEvent" })
/home/byk/Code/opencode/packages/schema/src/event.ts:
Line 94: export function versionedType(type: string, version: number) {
Line 102: const key = versionedType(definition.type, definition.durable.version)
/home/byk/Code/opencode/packages/schema/src/event-manifest.ts:
Line 34: const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined)
Line 37: const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...
Found 1 matches /home/byk/Code/opencode/packages/core/src/integration.ts: Line 503: if (Exit.isFailure(exit)) return yield* exit
<path>/home/byk/Code/opencode/packages/core/src/integration.ts</path>
<type>file</type>
<content>
485: const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
486: const match = current.get(input.attemptID)
487: if (!match || match.status !== "pending" || match.completing) return [match, current]
488: if (match.authorization.mode === "code" && input.code === undefined) return [match, current]
489: return [match, new Map(current).set(input.attemptID, { ...match, completing: true })]
490: })
491: if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`)
492: if (attempt.status !== "pending") return
493: if (attempt.authorization.mode === "code" && input.code === undefined) {
494: return yield* new CodeRequiredError({ attemptID: input.attemptID })
495: }
496: if (attempt.completing) return yield* Effect.die(`OAuth ...
<path>/home/byk/Code/opencode/packages/schema/src/session-message.ts</path>
<type>file</type>
<content>
1: export * as SessionMessage from "./session-message"
2:
3: import { Schema } from "effect"
4: import { optional } from "./schema"
5: import { ProviderMetadata, ToolContent } from "./llm"
6: import { Model } from "./model"
7: import { FileAttachment, Prompt } from "./prompt"
8: import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema"
9: import { SessionID } from "./session-id"
10: import { ascending } from "./identifier"
11:
12: export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
13: Schema.brand("Session.Message.ID"),
14: statics((schema) => ({ create: () => schema.make("msg_" + ascending()) })),
15: )
16: export type ID = typeof ID.Type
17:
18: export interface UnknownError extends Schema.Schema.Type<typeof UnknownError> {}
19: export const UnknownError = Schema.Struct({
20: type: Schema.Literal("unknown"),
21: message: ...
File not found: /home/byk/Code/opencode/packages/core/src/database/schema.ts
Found 1 matches /home/byk/Code/opencode/packages/core/src/database/migration.ts: Line 7: import schema from "./schema.gen"
File not found: /home/byk/Code/opencode/packages/core/src/database/index.ts
No files found
/home/byk/Code/opencode/packages/core/src/database/migration.ts /home/byk/Code/opencode/packages/core/src/database/database.ts /home/byk/Code/opencode/packages/core/src/database/schema.gen.ts /home/byk/Code/opencode/packages/core/src/database/migration.gen.ts /home/byk/Code/opencode/packages/core/src/database/sqlite.ts /home/byk/Code/opencode/packages/core/src/database/sqlite.node.ts /home/byk/Code/opencode/packages/core/src/database/sqlite.bun.ts /home/byk/Code/opencode/packages/core/src/database/schema.sql.ts /home/byk/Code/opencode/packages/core/src/database/path.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260410174513_workspace-name.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260622202450_simplify_session_input.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260622142730_simplify_session_context_epoch.ts /home/byk/Code/o...
<path>/home/byk/Code/opencode/packages/core/src/database/database.ts</path>
<type>file</type>
<content>
1: export * as Database from "./database"
2:
3: import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
4: import { layer as sqliteLayer } from "#sqlite"
5: import { Context, Effect, Layer } from "effect"
6: import { Global } from "../global"
7: import { Flag } from "../flag/flag"
8: import { isAbsolute, join } from "path"
9: import { DatabaseMigration } from "./migration"
10: import { InstallationChannel } from "../installation/version"
11: import { makeGlobalNode } from "../effect/app-node"
12:
13: const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
14: type DatabaseShape = Effect.Success<typeof makeDatabase>
15:
16: export interface Interface {
17: db: DatabaseShape
18: }
19:
20: export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
21:
22: const layer = Layer.effect(
23: Service,
24: ...
Found 28 matches /home/byk/Code/opencode/packages/core/src/session/projector.ts: Line 1: export * as SessionProjector from "./projector" Line 242: yield* events.project(SessionEvent.Moved, (event) => Line 329: yield* events.project(SessionEvent.AgentSwitched, (event) => Line 337: yield* events.project(SessionEvent.ModelSwitched, (event) => Line 348: yield* events.project(SessionEvent.Prompted, (event) => Line 362: yield* events.project(SessionEvent.PromptAdmitted, (event) => Line 375: yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) Line 376: yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) Line 377: yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) Line 378: yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) Line 379: yield* events.project(SessionEvent.Step.Started, (event) => run(db, event)) Line 380: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
350: if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
351: return yield* Effect.failCause(settled.cause)
352: return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
353: }),
354: )
355: }, Effect.scoped)
356: type RunTurn = (
357: sessionID: SessionSchema.ID,
358: promotion: SessionInput.Delivery | undefined,
359: step: number,
360: ) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError>
361:
362: const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
363: return yield* runTurnAttempt(sessionID, promotion, step).pipe(
364: Effect.catchDefect(
365: Effect.fnUntraced(function* (defect) {
366: if (!(defect instanceof ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
160: super()
161: }
162: }
163:
164: const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step })
165: const continueAfterOverflowCompaction = (step: number) =>
166: new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
167:
168: const loadSystemContext = (agent: AgentV2.Selection) =>
169: Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
170: concurrency: "unbounded",
171: }).pipe(Effect.map(SystemContext.combine))
172:
173: const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
174: sessionID: SessionSchema.ID,
175: promotion: SessionInput.Delivery | undefined,
176: step: number,
177: recoverOverflow?: typeof compaction.compactAfterOverflow,
178: ) {
179: ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution.ts</path>
<type>file</type>
<content>
1: export * as SessionExecution from "./execution"
2:
3: import { Context, Effect, Layer } from "effect"
4: import { LayerNode } from "../effect/layer-node"
5: import { Node } from "../effect/app-node"
6: import { SessionRunner } from "./runner/index"
7: import { SessionSchema } from "./schema"
8:
9: export interface Interface {
10: /** Snapshots active execution owned by this process. */
11: readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
12: /** Starts execution while idle or joins the active execution. */
13: readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
14: /** Registers newly recorded work. Repeated wakeups may coalesce. */
15: readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
16: /** Interrupt active work owned by this process. Idle interruption is a no-op. ...
<path>/home/byk/Code/opencode/packages/core/src/effect/app-node.ts</path>
<type>file</type>
<content>
1: import { LayerNode } from "./layer-node"
2:
3: export const tags = LayerNode.tags({
4: location: ["global"],
5: global: [],
6: })
7:
8: export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["global"]>
9: export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["location"]>
10:
11: export const makeGlobalNode = tags.make("global")
12: export const makeLocationNode = tags.make("location")
13:
14: export * as Node from "./app-node"
(End of file - total 14 lines)
</content>
The decisive gap is in runTurnAttempt: the durable assistant record is created only after the provider emits its first event. I’m changing that boundary so every dispatched request has a durable unfinished Step, then adding one recovery event whose transaction both fences that exact unfinished Step and admits the synthetic continuation; no replay claim or legacy prompt path is involved.
<skill_content name="effect">
# Skill: effect
# Effect
This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
## Source Of Truth
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
3. Also inspect existing repo code for local house style before introducing new patterns.
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
## Guidelines
- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
- Use `Effect.gen(function* () { ... })` for multi-step workflows.
- Use `Effect.fn("Name")` or ...
/home/byk/Code/opencode/.opencode/references/effect-smol/vitest.shared.ts /home/byk/Code/opencode/.opencode/references/effect-smol/vitest.setup.ts /home/byk/Code/opencode/.opencode/references/effect-smol/vitest.config.ts /home/byk/Code/opencode/.opencode/references/effect-smol/tstyche.json /home/byk/Code/opencode/.opencode/references/effect-smol/tsconfig.packages.json /home/byk/Code/opencode/.opencode/references/effect-smol/tsconfig.json /home/byk/Code/opencode/.opencode/references/effect-smol/tsconfig.base.json /home/byk/Code/opencode/.opencode/references/effect-smol/stats.txt /home/byk/Code/opencode/.opencode/references/effect-smol/TODOS.md /home/byk/Code/opencode/.opencode/references/effect-smol/README.md /home/byk/Code/opencode/.opencode/references/effect-smol/MIGRATION.md /home/byk/Code/opencode/.opencode/references/effect-smol/LLMS.md /home/byk/Code/opencode/.opencode/references/effect-smol/LICENSE /home/byk/Code/opencode/.opencode/references/effect-smol/AGENTS.md /home/byk/Code/...
Found 24 matches
/home/byk/Code/opencode/packages/core/src/git.ts:
Line 105: changes: ChangeSet
Line 743: message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
Line 759: message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
Line 792: changes: ChangeSet
Line 799: stdin: Stream.make(new TextEncoder().encode(input.changes)),
Line 812: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
Line 835: message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes",
Line 851: message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
/home/byk/Code/opencode/packages/core/src/file-mutation.ts:
Line 70: * Serialize file changes by canonical target. ...
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
110: position: integer().notNull(),
111: ...Timestamps,
112: },
113: (table) => [
114: primaryKey({ columns: [table.session_id, table.position] }),
115: index("todo_session_idx").on(table.session_id),
116: ],
117: )
118:
119: export const SessionMessageTable = sqliteTable(
120: "session_message",
121: {
122: id: text().$type<SessionMessage.ID>().primaryKey(),
123: session_id: text()
124: .$type<SessionSchema.ID>()
125: .notNull()
126: .references(() => SessionTable.id, { onDelete: "cascade" }),
127: type: text().$type<SessionMessage.Type>().notNull(),
128: seq: integer().notNull(),
129: ...Timestamps,
130: data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
131: },
132: (table) => [
133: uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq),
134: ...
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
1: import { castDraft, produce, type WritableDraft } from "immer"
2: import { Effect } from "effect"
3: import { SessionEvent } from "./event"
4: import { SessionMessage } from "./message"
5:
6: export type MemoryState = {
7: messages: SessionMessage.Message[]
8: }
9:
10: export interface Adapter {
11: readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
12: readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
13: readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
14: readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
15: readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
16: readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void>
17: }
18:
19: ...
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
100: tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
101: tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
102: tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
103: tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
104: time_updated: sql`${SessionTable.time_updated}`,
105: })
106: .where(eq(SessionTable.id, sessionID))
107: .run()
108: .pipe(Effect.orDie)
109: }
110:
111: function run(db: DatabaseService, event: SessionEvent.Event) {
112: return Effect.gen(function* () {
113: const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
114: decodeMessage({ ...row.data, id: row.id, type: row.type })
115: const updateMessage = (message: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/to-llm-message.ts</path>
<type>file</type>
<content>
1: import {
2: Message,
3: ToolCallPart,
4: ToolOutput,
5: ToolResultPart,
6: type ContentPart,
7: type Model,
8: type ProviderMetadata,
9: } from "@opencode-ai/llm"
10: import { SessionMessage } from "../message"
11: import type { FileAttachment } from "../prompt"
12:
13: const media = (file: FileAttachment): ContentPart => ({
14: type: "media",
15: mediaType: file.mime,
16: data: file.uri,
17: filename: file.name,
18: metadata: file.description === undefined ? undefined : { description: file.description },
19: })
20:
21: const toolInput = (tool: SessionMessage.AssistantTool) => {
22: if (tool.state.status !== "pending") return tool.state.input
23: try {
24: return JSON.parse(tool.state.input) as unknown
25: } catch {
26: return tool.state.input
27: }
28: }
29:
30: const toolCall = (tool: SessionMessage.AssistantTool, ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
1: import {
2: LLM,
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Message,
7: SystemPart,
8: isContextOverflowFailure,
9: type ProviderErrorEvent,
10: } from "@opencode-ai/llm"
11: import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
12: import { AgentV2 } from "../../agent"
13: import { Config } from "../../config"
14: import { Database } from "../../database/database"
15: import { EventV2 } from "../../event"
16: import { Location } from "../../location"
17: import { ModelV2 } from "../../model"
18: import { PermissionV2 } from "../../permission"
19: import { ProviderV2 } from "../../provider"
20: import { QuestionV2 } from "../../question"
21: import { SystemContext } from "../../system-context/index"
22: import { SystemContextRegistry } from "../../system-context/registry"
23: import { SkillGuidance } from ...
Found 22 matches
/home/byk/Code/opencode/packages/core/src/fs-util.ts:
Line 59: return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
Line 159: if (yield* fs.exists(search)) result.push(search)
Line 174: if (yield* fs.exists(search)) result.push(search)
/home/byk/Code/opencode/packages/core/src/file-mutation.ts:
Line 101: const existed = yield* fs.exists(input.target.canonical)
/home/byk/Code/opencode/packages/core/src/credential/sql.ts:
Line 1: import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
/home/byk/Code/opencode/packages/core/src/skill/discovery.ts:
Line 86: if (yield* fs.exists(destination).pipe(Effect.orDie)) return true
Line 177: (yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) ||
Line 178: (yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie))
Line 183: const ...
<path>/home/byk/Code/opencode/packages/core/src/effect/layer-node.ts</path>
<type>file</type>
<content>
210:
211: export function hoist<A, E, T extends Tag, const Items extends Replacements = readonly []>(
212: root: Node<A, E, any>,
213: tag: T,
214: replacements?: ValidReplacements<Items>,
215: ): {
216: readonly node: Node<A, E>
217: readonly hoisted: Node<unknown, E>
218: } {
219: const hoisted = new Map<string, AnyNode>()
220: const replacementMap = replacementMapFrom(replacements)
221:
222: const node = walk<AnyNode>(
223: root,
224: (node, context) => {
225: if (node.kind === "group") {
226: return { ...node, dependencies: node.dependencies.map(context.visit) }
227: }
228: if (node.tag === tag) {
229: const existing = hoisted.get(node.name)
230: if (existing && existing !== node) {
231: throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
232: }
233: ...
<path>/home/byk/Code/opencode/packages/server/src/routes.ts</path>
<type>file</type>
<content>
1: import { Database } from "@opencode-ai/core/database/database"
2: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
3: import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
4: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
5: import { EventV2 } from "@opencode-ai/core/event"
6: import { Credential } from "@opencode-ai/core/credential"
7: import { PermissionSaved } from "@opencode-ai/core/permission/saved"
8: import { PtyTicket } from "@opencode-ai/core/pty/ticket"
9: import { SessionV2 } from "@opencode-ai/core/session"
10: import { SessionExecution } from "@opencode-ai/core/session/execution"
11: import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
12: import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
13: import { ToolOutputStore } from ...
<path>/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/server.ts</path>
<type>file</type>
<content>
200: serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
201: )
202: }),
203: ).pipe(Layer.provide(authOnlyRouterLayer))
204:
205: type RouteRequirements =
206: | HttpRouter.HttpRouter
207: | HttpRouter.Request<"Error", unknown>
208: | HttpRouter.Request<"GlobalError", unknown>
209: | HttpRouter.Request<"Requires", unknown>
210: | HttpRouter.Request<"GlobalRequires", never>
211:
212: const app = LayerNode.group([
213: Npm.node,
214: FSUtil.node,
215: Database.node,
216: Auth.node,
217: Account.node,
218: Config.node,
219: Env.node,
220: Git.node,
221: Ripgrep.node,
222: Storage.node,
223: Snapshot.node,
224: Plugin.node,
225: ModelsDev.node,
226: Provider.node,
227: ProviderAuth.node,
228: Agent.node,
229: Skill.node,
230: Discovery.node,
231: ...
No files found
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
1: export * as SessionInput from "./input"
2:
3: import { and, asc, eq, isNull, lte } from "drizzle-orm"
4: import { DateTime, Effect, Schema } from "effect"
5: import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
6: import type { Database } from "../database/database"
7: import type { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionMessage } from "./message"
10: import { Prompt } from "./prompt"
11: import { SessionSchema } from "./schema"
12: import { SessionInputTable, SessionMessageTable } from "./sql"
13:
14: type DatabaseService = Database.Interface["db"]
15:
16: export { Admitted, Delivery }
17:
18: const decodePrompt = Schema.decodeUnknownSync(Prompt)
19: const encodePrompt = Schema.encodeSync(Prompt)
20:
21: const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
22: Admitted.make({
23: ...
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
105: events,
106: hasMore: rows.length > input.limit,
107: }
108: })
109:
110: export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
111: "EventV2.SubscriberOverflow",
112: { capacity: Schema.Int },
113: ) {}
114:
115: export const define = Event.define
116: export const versionedType = Event.versionedType
117:
118: export interface PublishOptions {
119: readonly id?: ID
120: readonly metadata?: Record<string, unknown>
121: readonly location?: Location.Ref
122: /** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
123: readonly commit?: (seq: number) => Effect.Effect<void>
124: }
125:
126: export interface Interface {
127: readonly publish: <D extends Definition>(
128: definition: D,
129: data: Data<D>,
130: options?: PublishOptions,
131: ) => ...
No files found
I’m implementing this as one domain event rather than stitching together unrelated events. Its projector will validate the exact latest unfinished assistant, acquire a unique (session_id, assistant_message_id) fence, mark every uncertain tool and the Step as an unknown interruption, and admit one queued continuation in the same SQLite IMMEDIATE transaction; a competing server gets no event and no prompt.
[
{
"content": "Define V2 synthetic recovery ownership and discovery",
"status": "in_progress",
"priority": "high"
},
{
"content": "Implement interruption reconciliation and synthetic continuation",
"status": "pending",
"priority": "high"
},
{
"content": "Add restart and duplicate-claim regressions",
"status": "pending",
"priority": "high"
},
{
"content": "Run package validation and isolated restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Run independent correctness and security reviews",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
80: ...Base,
81: location: Location.Ref,
82: subdirectory: RelativePath.pipe(optional),
83: },
84: })
85: export type Moved = typeof Moved.Type
86:
87: export const Prompted = Event.define({
88: type: "session.next.prompted",
89: ...options,
90: schema: PromptFields,
91: })
92: export type Prompted = typeof Prompted.Type
93:
94: export const PromptAdmitted = Event.define({
95: type: "session.next.prompt.admitted",
96: ...options,
97: schema: PromptFields,
98: })
99: export type PromptAdmitted = typeof PromptAdmitted.Type
100:
101: export const ContextUpdated = Event.define({
102: type: "session.next.context.updated",
103: ...options,
104: schema: {
105: ...Base,
106: messageID: SessionMessage.ID,
107: text: Schema.String,
108: },
109: })
110: export type ContextUpdated = typeof ContextUpdated.Type
111:
112: export const Synthetic = ...
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
430: })
431: export type Ended = typeof Ended.Type
432: }
433:
434: export namespace RevertEvent {
435: export const Staged = Event.define({
436: type: "session.next.revert.staged",
437: ...options,
438: schema: { ...Base, revert: Revert.State },
439: })
440: export const Cleared = Event.define({ type: "session.next.revert.cleared", ...options, schema: Base })
441: export const Committed = Event.define({
442: type: "session.next.revert.committed",
443: ...options,
444: schema: { ...Base, messageID: SessionMessage.ID },
445: })
446: }
447:
448: export const DurableDefinitions = Event.inventory(
449: AgentSwitched,
450: ModelSwitched,
451: Moved,
452: Prompted,
453: PromptAdmitted,
454: ContextUpdated,
455: Synthetic,
456: Shell.Started,
457: Shell.Ended,
458: Step.Started,
459: Step.Ended,
460: Step.Failed,
461: ...
<path>/home/byk/Code/opencode/packages/schema/src/prompt.ts</path>
<type>file</type>
<content>
1: import { Schema } from "effect"
2: import { optional } from "./schema"
3: import { statics } from "./schema"
4:
5: export interface Source extends Schema.Schema.Type<typeof Source> {}
6: export const Source = Schema.Struct({
7: start: Schema.Finite,
8: end: Schema.Finite,
9: text: Schema.String,
10: }).annotate({ identifier: "Prompt.Source" })
11:
12: export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment> {}
13: export const FileAttachment = Schema.Struct({
14: uri: Schema.String,
15: mime: Schema.String,
16: name: Schema.String.pipe(optional),
17: description: Schema.String.pipe(optional),
18: source: Source.pipe(optional),
19: })
20: .annotate({ identifier: "Prompt.FileAttachment" })
21: .pipe(
22: statics((schema) => ({
23: create: (input: FileAttachment) =>
24: schema.make({
25: uri: input.uri,
26: ...
<path>/home/byk/Code/opencode/packages/core/src/database/schema.gen.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "./migration"
3:
4: export default {
5: up(tx) {
6: return Effect.gen(function* () {
7: yield* tx.run(`
8: CREATE TABLE \`workspace\` (
9: \`id\` text PRIMARY KEY,
10: \`type\` text NOT NULL,
11: \`name\` text DEFAULT '' NOT NULL,
12: \`branch\` text,
13: \`directory\` text,
14: \`extra\` text,
15: \`project_id\` text NOT NULL,
16: \`time_used\` integer NOT NULL,
17: CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
18: );
19: `)
20: yield* tx.run(`
21: CREATE TABLE \`data_migration\` (
22: \`name\` text PRIMARY KEY,
23: \`time_completed\` integer NOT NULL
24: ...
<path>/home/byk/Code/opencode/packages/core/script/migration.ts</path>
<type>file</type>
<content>
1: #!/usr/bin/env bun
2:
3: import { $ } from "bun"
4: import fs from "fs/promises"
5: import os from "os"
6: import path from "path"
7: import { pathToFileURL } from "url"
8: import { parseArgs } from "util"
9:
10: const root = path.resolve(import.meta.dirname, "../../..")
11: const snapshot = path.join(root, "packages/core/schema.json")
12: const tsDir = path.join(root, "packages/core/src/database/migration")
13: const registry = path.join(root, "packages/core/src/database/migration.gen.ts")
14: const schema = path.join(root, "packages/core/src/database/schema.gen.ts")
15: const args = parseArgs({
16: args: process.argv.slice(2),
17: options: {
18: check: { type: "boolean" },
19: name: { type: "string" },
20: },
21: })
22:
23: if (args.values.check) {
24: await check()
25: process.exit(0)
26: }
27:
28: await generate()
29:
30: async function generate() {
31: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/to-llm-message.ts</path>
<type>file</type>
<content>
65: providerMetadata,
66: })
67: }
68: }
69:
70: const assistant = (message: SessionMessage.Assistant, model: Model) => {
71: const sameModel =
72: String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
73: const reuseProviderMetadata = sameModel && message.error === undefined
74: const content = message.content.flatMap((item): ContentPart[] => {
75: if (item.type === "text") return [{ type: "text", text: item.text }]
76: if (item.type === "reasoning")
77: return sameModel
78: ? [
79: {
80: type: "reasoning",
81: text: item.text,
82: providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
83: },
84: ]
85: : item.text.length > 0
86: ? [{ type: "text", text: ...
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
1: export * as SessionEvent from "./session-event"
2:
3: import { Schema } from "effect"
4: import { optional } from "./schema"
5: import { Event } from "./event"
6: import { ProviderMetadata, ToolContent } from "./llm"
7: import { Delivery } from "./session-delivery"
8: import { Model } from "./model"
9: import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema"
10: import { FileAttachment, Prompt } from "./prompt"
11: import { SessionID } from "./session-id"
12: import { Location } from "./location"
13: import { SessionMessage } from "./session-message"
14: import { Revert } from "./revert"
15:
16: export { FileAttachment }
17:
18: export const Source = Schema.Struct({
19: start: NonNegativeInt,
20: end: NonNegativeInt,
21: text: Schema.String,
22: }).annotate({
23: identifier: "session.next.event.source",
24: })
25: export interface Source extends ...
Found 11 matches /home/byk/Code/opencode/packages/core/src/integration.ts: Line 227: const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>()) /home/byk/Code/opencode/packages/core/src/session/input.ts: Line 28: timeCreated: DateTime.makeUnsafe(row.time_created), Line 227: timestamp: DateTime.makeUnsafe(row.time_created), /home/byk/Code/opencode/packages/core/src/session/info.ts: Line 45: created: DateTime.makeUnsafe(row.time_created), Line 46: updated: DateTime.makeUnsafe(row.time_updated), Line 47: archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, /home/byk/Code/opencode/packages/core/src/session/runner/llm.ts: Line 235: const withPublication = Semaphore.makeUnsafe(1).withPermit /home/byk/Code/opencode/packages/core/src/state.ts: Line 64: const semaphore = Semaphore.makeUnsafe(1) /home/byk/Code/opencode/packages/core/src/database/migration.ts: Line 11: const ...
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path> <type>file</type> <content> 320: const boundary = anchor 321: ? order === "asc" 322: ? gt(SessionMessageTable.seq, anchor.seq) 323: : lt(SessionMessageTable.seq, anchor.seq) 324: : undefined 325: const where = boundary 326: ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) 327: : eq(SessionMessageTable.session_id, input.sessionID) 328: const query = db 329: .select() 330: .from(SessionMessageTable) 331: .where(where) 332: .orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq)) 333: const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( 334: Effect.orDie, 335: ) 336: return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode) 337: ...
Found 15 matches
/home/byk/Code/opencode/packages/core/src/project/copy.ts:
Line 123: Effect.forkScoped,
/home/byk/Code/opencode/packages/core/src/filesystem/watcher.ts:
Line 111: yield* Effect.forkScoped(
Line 123: yield* Effect.forkScoped(subscribe(vcs, ignore))
/home/byk/Code/opencode/packages/core/src/config/plugin/external.ts:
Line 89: }).pipe(Effect.forkScoped({ startImmediately: true }))
/home/byk/Code/opencode/packages/core/src/plugin/models-dev.ts:
Line 180: Effect.forkScoped({ startImmediately: true }),
/home/byk/Code/opencode/packages/core/src/plugin/internal.ts:
Line 123: ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
/home/byk/Code/opencode/packages/core/src/plugin/provider/opencode.ts:
Line 193: Effect.forkScoped({ startImmediately: true }),
Line 195: yield* refresh().pipe(Effect.forkScoped)
/home/byk/Code/opencode/packages/core/src/ripgrep.ts:
Line ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { Cause, Effect, Layer } from "effect"
2: import { LocationServiceMap } from "../../location-service-map"
3: import { makeGlobalNode } from "../../effect/app-node"
4: import { SessionRunCoordinator } from "../run-coordinator"
5: import { SessionRunner } from "../runner"
6: import { SessionSchema } from "../schema"
7: import { SessionStore } from "../store"
8: import { SessionExecution } from "../execution"
9:
10: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
11: const layer = Layer.effect(
12: SessionExecution.Service,
13: Effect.gen(function* () {
14: const store = yield* SessionStore.Service
15: const locations = yield* LocationServiceMap.Service
16: const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
17: drain: Effect.fnUntraced(function* ...
Success. Updated the following files: M packages/schema/src/session-event.ts M packages/core/src/session/sql.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path>
<type>file</type>
<content>
440: )
441: }),
442: clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
443: const session = yield* result.get(sessionID)
444: yield* SessionRevert.clear(session).pipe(
445: Effect.provideService(EventV2.Service, events),
446: Effect.provide(locations.get(session.location)),
447: )
448: }),
449: commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
450: const session = yield* result.get(sessionID)
451: yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
452: }),
453: },
454: })
455:
456: return result
457: }),
458: )
459:
460: const resolvePrompt = (input: PromptInput.Prompt) =>
461: Prompt.make({
462: text: input.text,
463: agents: input.agents,
464: files: ...
No files found
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
430: and(eq(SessionMessageTable.session_id, event.data.sessionID), gt(SessionMessageTable.seq, boundary.seq)),
431: )
432: .run()
433: .pipe(Effect.orDie)
434: yield* db
435: .delete(SessionInputTable)
436: .where(
437: and(
438: eq(SessionInputTable.session_id, event.data.sessionID),
439: or(gt(SessionInputTable.admitted_seq, boundary.seq), gt(SessionInputTable.promoted_seq, boundary.seq)),
440: ),
441: )
442: .run()
443: .pipe(Effect.orDie)
444: yield* db
445: .update(SessionTable)
446: .set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
447: .where(eq(SessionTable.id, event.data.sessionID))
448: .run()
449: .pipe(Effect.orDie)
450: ...
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
1: export * as SessionProjector from "./projector"
2:
3: import { and, desc, eq, gt, or, sql } from "drizzle-orm"
4: import { DateTime, Effect, Layer, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { EventV2 } from "../event"
7: import { makeGlobalNode } from "../effect/app-node"
8: import { SessionEvent } from "./event"
9: import { SessionV1 } from "../v1/session"
10: import { WorkspaceTable } from "../control-plane/workspace.sql"
11: import { SessionMessage } from "./message"
12: import { SessionMessageUpdater } from "./message-updater"
13: import { SessionInput } from "./input"
14: import { WorkspaceV2 } from "../workspace"
15: import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
16: import type { DeepMutable } from "../schema"
17:
18: type DatabaseService = Database.Interface["db"]
19:
20: ...
Success. Updated the following files: M packages/core/src/session/projector.ts
No files found
Found 12 matches
/home/byk/Code/opencode/packages/core/src/database/migration.ts:
Line 45: yield* db.run(
Line 62: yield* db.run(sql`
Line 84: yield* db.run(sql`
/home/byk/Code/opencode/packages/core/src/database/database.ts:
Line 27: yield* db.run("PRAGMA journal_mode = WAL")
Line 28: yield* db.run("PRAGMA synchronous = NORMAL")
Line 29: yield* db.run("PRAGMA busy_timeout = 5000")
Line 30: yield* db.run("PRAGMA cache_size = -64000")
Line 31: yield* db.run("PRAGMA foreign_keys = ON")
Line 32: yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
/home/byk/Code/opencode/packages/core/src/database/sqlite.node.ts:
Line 58: const statement = native.prepare(query)
Line 73: const statement = native.prepare(query)
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 198: initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
<path>/home/byk/Code/opencode/packages/core/src/database/sqlite.ts</path>
<type>file</type>
<content>
1: export * as Sqlite from "./sqlite"
2:
3: import { Context } from "effect"
4: import type { drizzle } from "drizzle-orm/bun-sqlite"
5:
6: export type DrizzleClient = ReturnType<typeof drizzle>
7: export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
8: export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}
(End of file - total 8 lines)
</content>
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: }
15:
16: const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
17:
18: const tokens = (usage: Usage | undefined) => {
19: const reasoning = safe(usage?.reasoningTokens)
20: const read = safe(usage?.cacheReadInputTokens)
21: const write = safe(usage?.cacheWriteInputTokens)
22: ...
Success. Updated the following files: M packages/core/src/session/runner/llm.ts A packages/core/src/session/recovery.ts M packages/server/src/routes.ts M packages/opencode/src/server/routes/instance/httpapi/server.ts
Found 19 matches /home/byk/Code/opencode/packages/core/src/tool-output-store.ts: Line 200: export const cleanupLayer = Layer.effectDiscard( /home/byk/Code/opencode/packages/core/src/instruction-context.ts: Line 22: const layer = Layer.effectDiscard( /home/byk/Code/opencode/packages/core/src/tool/write.ts: Line 47: const layer = Layer.effectDiscard( /home/byk/Code/opencode/packages/core/src/tool/websearch.ts: Line 192: const layer = Layer.effectDiscard( /home/byk/Code/opencode/packages/core/src/plugin/internal.ts: Line 63: const layer = Layer.effectDiscard( /home/byk/Code/opencode/packages/core/src/tool/webfetch.ts: Line 118: const layer = Layer.effectDiscard( /home/byk/Code/opencode/packages/core/src/tool/todowrite.ts: Line 25: const layer = Layer.effectDiscard( /home/byk/Code/opencode/packages/core/src/tool/skill.ts: Line 57: const layer = Layer.effectDiscard( /home/byk/Code/opencode/packages/core/src/tool/read.ts: Line 30: const layer = ...
<path>/home/byk/Code/opencode/packages/core/src/tool-output-store.ts</path>
<type>file</type>
<content>
190:
191: return Service.of({ limits, bound, cleanup })
192: }),
193: )
194:
195: export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] })
196:
197: export const nodeWithoutConfig = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node] })
198:
199: /** Runs retention scanning once globally rather than once per active Location. */
200: export const cleanupLayer = Layer.effectDiscard(
201: Effect.gen(function* () {
202: const store = yield* Service
203: yield* store.cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped)
204: }),
205: )
206:
207: export const cleanupNode = makeGlobalNode({
208: name: "tool-output-cleanup",
209: layer: Layer.merge(layer, cleanupLayer.pipe(Layer.provide(layer))),
210: deps: [FSUtil.node, Global.node],
211: })
(End of ...
Found 3 matches
/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/server.ts:
Line 55: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
Line 212: const app = LayerNode.group([
Line 299: AppNodeBuilderV1.build(LayerNode.group([SessionV2.node, SessionRecovery.node]), [
<path>/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/server.ts</path>
<type>file</type>
<content>
1: import { Config as EffectConfig, Context, Effect, Layer } from "effect"
2: import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi"
3: import { HttpClient, HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"
4: import * as Socket from "effect/unstable/socket/Socket"
5: import { FSUtil } from "@opencode-ai/core/fs-util"
6: import * as Observability from "@opencode-ai/core/observability"
7: import { Account } from "@/account/account"
8: import { Agent } from "@/agent/agent"
9: import { Auth } from "@/auth"
10: import { BackgroundJob } from "@/background/job"
11: import { Command } from "@/command"
12: import { Config } from "@/config/config"
13: import { Workspace } from "@/control-plane/workspace"
14: import { Env } from "@/env"
15: import { EventV2Bridge } from "@/event-v2-bridge"
16: import { Format } from ...
Success. Updated the following files: M packages/opencode/src/server/routes/instance/httpapi/server.ts
Reading config file '/tmp/opencode-core-migration-QZyuB0/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-QZyuB0/incremental/20260914170650_session-recovery/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-QZyuB0/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-QZyuB0/full/20260914170658_schema/migration.sql 🚀
$ tsgo --noEmit
src/session/recovery.ts(33,73): error TS2339: Property 'completed' does not exist on type '{ readonly created: Utc; } | { readonly created: Utc; readonly completed?: Utc | undefined; } | { readonly created: Utc; readonly completed?: Utc | undefined; }'.
Property 'completed' does not exist on type '{ readonly created: Utc; }'.
Type checking exposed a narrow union issue, but the larger lifecycle check found a more important flaw before I papered it over: a graceful scope interruption currently settles the assistant as an error, so unfinished-message discovery alone would miss the very sessions we need to continue. I’m correcting the design to persist a process-owned execution boundary before draining and clear it only when the drain reaches idle or an explicit user interruption completes; startup claims abandoned boundaries, while the unfinished Step remains the exact uncertainty target when one exists.
continue. the server was restarted so you'll have to nudge your background agents to continue too.
The recovery agent will resume on its existing session with the narrow migration and startup-layer evidence request. I’ll continue from the current implementation state, first reconciling it with the process-owned boundary requirement uncovered before restart, then complete the regression proof and validation without touching the running server.
<task id="ses_f60e159e9ffeVskuIe3XCp5fFR" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
160: super()
161: }
162: }
163:
164: const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step })
165: const continueAfterOverflowCompaction = (step: number) =>
166: new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
167:
168: const loadSystemContext = (agent: AgentV2.Selection) =>
169: Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
170: concurrency: "unbounded",
171: }).pipe(Effect.map(SystemContext.combine))
172:
173: const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
174: sessionID: SessionSchema.ID,
175: promotion: SessionInput.Delivery | undefined,
176: step: number,
177: recoverOverflow?: typeof compaction.compactAfterOverflow,
178: ) {
179: ...
The interruption path confirms the boundary must wrap the whole drain, not llm.stream: creating an assistant before streaming breaks the existing one-time overflow rebuild, and settling interrupted work erases the only projection-based startup signal. I’m replacing that premature assistant start with a durable per-Session drain claim that survives process death, then making recovery target only the latest still-unfinished assistant if one exists; the synthetic continuation also covers crashes before any assistant row was written.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
1: import { castDraft, produce, type WritableDraft } from "immer"
2: import { Effect } from "effect"
3: import { SessionEvent } from "./event"
4: import { SessionMessage } from "./message"
5:
6: export type MemoryState = {
7: messages: SessionMessage.Message[]
8: }
9:
10: export interface Adapter {
11: readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
12: readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
13: readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
14: readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
15: readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
16: readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void>
17: }
18:
19: ...
Found 9 matches
/home/byk/Code/opencode/packages/core/src/git.ts:
Line 4: import { randomUUID } from "crypto"
Line 648: const index = path.join(input.repository.gitDirectory, `preview-${randomUUID()}.index`)
/home/byk/Code/opencode/packages/core/src/observability/shared.ts:
Line 1: export const runID = crypto.randomUUID().slice(0, 8)
/home/byk/Code/opencode/packages/core/src/pty/ticket.ts:
Line 44: const ticket = crypto.randomUUID()
/home/byk/Code/opencode/packages/core/src/skill/discovery.ts:
Line 166: const token = crypto.randomUUID()
/home/byk/Code/opencode/packages/core/src/util/flock.ts:
Line 3: import { randomBytes, randomUUID } from "crypto"
Line 149: const token = randomUUID?.() ?? randomBytes(16).toString("hex")
/home/byk/Code/opencode/packages/core/src/util/effect-flock.ts:
Line 3: import { randomUUID } from "crypto"
Line 171: const token = randomUUID()
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
1: import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
2: import * as DatabasePath from "../database/path"
3: import { ProjectTable } from "../project/sql"
4: import type { SessionMessage } from "./message"
5: import type { Prompt } from "./prompt"
6: import type { SessionInput } from "./input"
7: import type { Snapshot } from "../snapshot"
8: import { PermissionV1 } from "../v1/permission"
9: import { ProjectV2 } from "../project"
10: import type { SessionSchema } from "./schema"
11: import type { MessageID, PartID, SessionV1 } from "../v1/session"
12: import { WorkspaceV2 } from "../workspace"
13: import { Timestamps } from "../database/schema.sql"
14: import type { SystemContext } from "../system-context/index"
15: import { AgentV2 } from "../agent"
16: import type { Revert } from "@opencode-ai/schema/revert"
17:
18: type ...
Found 5 matches /home/byk/Code/opencode/packages/core/src/integration.ts: Line 445: Effect.exit, Line 501: const exit = yield* authorize(callback).pipe(Effect.exit) /home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts: Line 43: Effect.exit, /home/byk/Code/opencode/packages/core/src/session/runner/llm.ts: Line 286: const stream = yield* restore(providerStream).pipe(Effect.exit) Line 303: const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
The durable boundary will use a random claim ID plus a process-owner ID, keyed uniquely by Session. Normal completion and ordinary failure delete only that owner’s claim; scope interruption leaves it behind for startup, while an explicit interrupt(sessionID) waits for local cleanup and then deletes only this process’s claim. This preserves the existing process-local coordinator now and gives clustered routing a clear table to replace with leased multi-node ownership later.
Success. Updated the following files: M packages/core/src/session/execution/local.ts
Success. Updated the following files: M packages/core/src/session/sql.ts M packages/core/src/session/recovery.ts
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
90:
91: function partData(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"]): typeof PartTable.$inferInsert.data {
92: const { id: _, messageID: __, sessionID: ___, ...rest } = part
93: return rest as DeepMutable<typeof rest>
94: }
95:
96: function applyUsage(
97: db: DatabaseService,
98: sessionID: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["sessionID"],
99: value: Usage,
100: sign = 1,
101: ) {
102: return db
103: .update(SessionTable)
104: .set({
105: cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
106: tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
107: tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
108: tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
109: tokens_cache_read: ...
Found 2 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 145: const isUserDeclined = (cause: Cause.Cause<unknown>) =>
Line 304: if (settled._tag === "Failure" && isUserDeclined(settled.cause)) {
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
130: callID: tool.id,
131: error: { type: "unknown", message: "Tool execution interrupted" },
132: provider: {
133: executed: tool.provider?.executed === true,
134: ...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }),
135: },
136: })
137: }
138: }
139: })
140:
141: const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
142: Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
143:
144: // Match V1: declining a user prompt halts the loop instead of becoming model-facing tool output.
145: const isUserDeclined = (cause: Cause.Cause<unknown>) =>
146: cause.reasons.some(
147: (reason) =>
148: Cause.isDieReason(reason) &&
149: (reason.defect instanceof ...
The claim needs one more state transition to avoid corrupting a successfully completed Step after a crash between settlement and drain cleanup. I’m tying the claim to the current assistant when Step.Started projects, clearing that target atomically with normal Step.Ended or Step.Failed, and using a distinct interruption settlement that leaves the target recoverable; startup can then rewrite only that exact uncertain Step to unknown, or add a synthetic interruption record when execution stopped between Steps.
Found 10 matches
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line 74: const startAssistant = Effect.fnUntraced(function* () {
Line 167: const assistantMessageID = yield* startAssistant()
Line 199: const failAssistant = Effect.fnUntraced(function* (message: string) {
Line 202: const assistantMessageID = yield* startAssistant()
Line 250: assistantMessageID: yield* startAssistant(),
Line 272: assistantMessageID: yield* startAssistant(),
Line 406: yield* failAssistant(event.message)
Line 414: failAssistant,
Line 416: hasActiveAssistant: () => assistantActive,
Line 420: startAssistant,
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
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 = yield* startAssistant()
203: ...
Success. Updated the following files: M packages/schema/src/session-event.ts M packages/core/src/session/sql.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts
Found 5 matches /home/byk/Code/opencode/packages/core/src/session/projector.ts: Line 389: .update(SessionExecutionTable) Line 391: .where(eq(SessionExecutionTable.session_id, event.data.sessionID)) Line 399: .update(SessionExecutionTable) Line 403: eq(SessionExecutionTable.session_id, event.data.sessionID), Line 404: eq(SessionExecutionTable.assistant_message_id, event.data.assistantMessageID),
sqliteTable(...) in matched schema files:
/home/byk/Code/opencode/packages/core/src/session/sql.ts:119-216src/**/*.sql.ts and src/**/sql.ts:
/home/byk/Code/opencode/packages/core/drizzle.config.ts:3-9/home/byk/Code/opencode/packages/core/script/migration.ts:10-20schema.json, emits at most one incremental migration, updates the snapshot, then regenerates schema.gen.ts and migration.gen.ts:
/home/byk/Code/opencode/packages/core/script/migration.ts:30-61packages/core:
/home/byk/Code/opencode/packages/core/script/migration.ts:94-106DatabaseMigration.Migration:
/home/byk/Code/opencode/packages/core/script/migration.ts:127-153/home/byk/Code/opencode/packages/core/script/migration.ts:187-195Exact command, with working directory /home/byk/Code/opencode/packages/core:
bun script/migration.ts --name session-execution
SessionExecutionTable exists in current source:
/home/byk/Code/opencode/packages/core/src/session/sql.ts:191-205SessionExecutionLocal writes to it whenever a drain starts:
/home/byk/Code/opencode/packages/core/src/session/execution/local.ts:11/home/byk/Code/opencode/packages/core/src/session/execution/local.ts:31-35But no generated database artifact contains session_execution:
session_recovery:
The resumed agent confirmed the expected generated-artifact gap: the new execution table has no migration yet. Before regenerating, I’m finishing the event contract so the migration reflects the final schema: Step.Failed clears normal failures, a new Step.Interrupted preserves crash recovery, and Step.Recovered carries the exact execution claim ID so its projector can atomically reject stale or competing recovery.
Success. Updated the following files: M packages/schema/src/session-event.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts
Found 6 matches
/home/byk/Code/opencode/packages/schema/src/session-event.ts:
Line 197: type: "session.next.step.interrupted",
Line 488: Step.Interrupted,
Line 521: Step.Interrupted,
/home/byk/Code/opencode/packages/core/src/session/projector.ts:
Line 427: yield* events.project(SessionEvent.Step.Interrupted, (event) => run(db, event))
/home/byk/Code/opencode/packages/core/src/session/message-updater.ts:
Line 230: "session.next.step.interrupted": (event) => {
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line 219: yield* events.publish(SessionEvent.Step.Interrupted, {
Reading config file '/tmp/opencode-core-migration-eikH7p/incremental.config.ts'
Error: Interactive prompts require a TTY terminal (process.stdin.isTTY or process.stdout.isTTY is false). This can happen when running in CI, piped input, or non-interactive shells.
at render (/home/byk/Code/opencode/node_modules/.bun/drizzle-kit@1.0.0-rc.2/node_modules/drizzle-kit/bin.cjs:3088:92)
at /home/byk/Code/opencode/node_modules/.bun/drizzle-kit@1.0.0-rc.2/node_modules/drizzle-kit/bin.cjs:90330:63
at ddlDiff$3 (/home/byk/Code/opencode/node_modules/.bun/drizzle-kit@1.0.0-rc.2/node_modules/drizzle-kit/bin.cjs:83912:64)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
at async handle$15 (/home/byk/Code/opencode/node_modules/.bun/drizzle-kit@1.0.0-rc.2/node_modules/drizzle-kit/bin.cjs:91506:49)
at async Object.handler (/home/byk/Code/opencode/node_modules/.bun/drizzle-kit@1.0.0-rc.2/node_modules/drizzle-kit/bin.cjs:232852:4)
at async run ...
Drizzle stopped because the first generation captured an intermediate session_recovery shape, so the final column changes look like ambiguous renames. I won’t guess through an interactive migration prompt. Since no build has shipped this same-session intermediate migration, I’m collapsing it into one clean generated migration by restoring only the four generated artifacts to their pre-change text via patches, deleting that intermediate migration, then regenerating from the final schema.
diff --git a/packages/core/schema.json b/packages/core/schema.json
index d0eeeebd5c..a350864ea8 100644
--- a/packages/core/schema.json
+++ b/packages/core/schema.json
@@ -1,8 +1,10 @@
{
"version": "7",
"dialect": "sqlite",
- "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
- "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
+ "id": "e2e885ab-f82e-4f22-aab0-38df1f49e5a3",
+ "prevIds": [
+ "f14a9b18-8207-487e-a3d3-227e629ba9ad"
+ ],
"ddl": [
{
"name": "workspace",
@@ -68,6 +70,10 @@
"name": "session_message",
"entityType": "tables"
},
+ {
+ "name": "session_recovery",
+ "entityType": "tables"
+ },
{
"name": "session",
"entityType": "tables"
@@ -1060,6 +1066,46 @@
"entityType": "columns",
"table": "session_message"
},
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ ...
Usage:
drizzle-kit generate [flags]
Flags:
--config string Path to drizzle config file
--dialect [ postgresql | mysql | sqlite | turso | singlestore | mssql | cockroach | duckdb ] Database dialect: 'postgresql', 'mysql', 'sqlite', 'turso', 'singlestore', 'duckdb' or 'mssql'
--driver [ d1-http | expo | aws-data-api | pglite | durable-sqlite | sqlite-cloud ] Database driver
--schema string Path to a schema file or folder
--out string Output folder, 'drizzle' by default
--name string Migration file name
--breakpoints Prepare SQL ...
The initial generator’s failed incremental phase still regenerated schema.gen.ts, but it left schema.json at the intermediate shape. Rather than reverse generated files by hand, I’m checking Drizzle’s supported non-interactive conflict mode; if it can encode the column replacement explicitly, we can retain an honest two-step migration and regenerate all artifacts from source.
Found 17 matches
/home/byk/Code/opencode/node_modules/.bun/drizzle-kit@1.0.0-rc.2/node_modules/drizzle-kit/bin.cjs:
Line 84449: const checkHandler = async (out, dialect, ignoreConflicts, shouldExitOnConflict = true) => {
Line 84473: if (!ignoreConflicts) {
Line 84489: if (ignoreConflicts && response.leafNodes.length > 1) return {
Line 86354: ignoreConflicts: options.ignoreConflicts !== void 0 && options.ignoreConflicts
Line 232811: const optionIgnoreConflicts = boolean("ignore-conflicts").desc("Skip commutativity conflict checks");
Line 232823: ignoreConflicts: optionIgnoreConflicts
Line 232829: "ignoreConflicts"
Line 232843: const checkResult = await checkHandler(opts.out, dialect, opts.ignoreConflicts);
Line 232875: ignoreConflicts: optionIgnoreConflicts
Line 232880: ...opts.ignoreConflicts !== void 0 && { ignoreConflicts: opts.ignoreConflicts }
Line 232887: const { dialect, schema, table, out, credentials, ignoreConflicts } = ...
<path>/home/byk/Code/opencode/node_modules/.bun/drizzle-kit@1.0.0-rc.2/node_modules/drizzle-kit/bin.cjs</path>
<type>file</type>
<content>
90280: process.exit(1);
90281: }
90282: const id = randomUUID();
90283: const prevIds = mergeLeafIds ?? [prevSnapshot.id];
90284: const snapshot = {
90285: version: "8",
90286: dialect: "postgres",
90287: id,
90288: prevIds,
90289: ddl: ddlCur.entities.list(),
90290: renames: []
90291: };
90292: const { id: _ignoredId, prevIds: _ignoredPrevIds, ...prevRest } = prevSnapshot;
90293: return {
90294: ddlPrev,
90295: ddlCur,
90296: snapshot,
90297: snapshotPrev: prevSnapshot,
90298: custom: {
90299: id,
90300: prevIds,
90301: ...prevRest
90302: }
90303: };
90304: };
90305: }));
90306: //#endregion
90307: //#region src/cli/prompts.ts
90308: var import_hanji$14, resolver;
90309: var init_prompts = __esmMin((() => {
90310: init_source();
90311: import_hanji$14 = require_hanji();
90312: ...
Found 5 matches
/home/byk/Code/opencode/node_modules/.bun/drizzle-kit@1.0.0-rc.2/node_modules/drizzle-kit/bin.cjs:
Line 28529: var import_hanji$17, warning, err, info, grey, error$1, postgresSchemaWarning, cockroachSchemaWarning, sqliteSchemaError, explain, psqlExplain, cockroachExplain, mysqlExplain, mssqlExplain, sqliteExplain, postgresSchemaError, cockroachSchemaError, mysqlSchemaError, mssqlSchemaError, isRenamePromptItem, keyFor, ResolveSelect, Spinner, IntrospectProgress, MigrateProgress, ProgressView;
Line 29247: ResolveSelect = class extends import_hanji$17.Prompt {
Line 90330: const { status, data } = await (0, import_hanji$14.render)(new ResolveSelect(newItem, [newItem, ...leftMissing.map((it) => {
Line 93240: if (recreateIdentity && Boolean(recreateIdentity.column.identity?.to) && !recreateIdentity.column.identity?.from) console.log(withStyle.warning(chalk.red.bold("You are about to add an identity property to an existing column.") + "\n" + chalk.red("This ...
There is a simpler non-interactive migration path that keeps the already-generated recovery table valid: retain its message fence, add a separate unique execution_id, and let the projector delete the matching execution claim inside the same event transaction. This also closes the duplicate-startup race: the winner consumes the claim atomically; the loser sees the durable recovery row and never emits or wakes a second continuation.
Success. Updated the following files: M packages/schema/src/session-event.ts M packages/core/src/session/sql.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq } from "drizzle-orm"
4: import { DateTime, Effect, Layer, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionMessage } from "./message"
11: import { SessionProjector } from "./projector"
12: import { Prompt } from "./prompt"
13: import { SessionExecutionTable, SessionRecoveryTable, SessionMessageTable } from "./sql"
14:
15: const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
16: const continuation = Prompt.make({ text: "continue" })
17:
18: const layer = Layer.effectDiscard(
19: Effect.gen(function* () {
20: const { db } = yield* Database.Service
21: ...
Reading config file '/tmp/opencode-core-migration-jqeABk/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-jqeABk/incremental/20260914214636_session-execution/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-jqeABk/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-jqeABk/full/20260914214643_schema/migration.sql 🚀
$ tsgo --noEmit
Core now typechecks. Before adding the restart regression, I found one state mismatch in the successful typechecked code: Step.Interrupted correctly completes the abandoned assistant for model visibility, but startup still filtered completed assistants as if all completion meant successful settlement. The execution claim is the authoritative fence, so recovery must accept that exact claimed assistant regardless of completion while still rejecting any newer assistant.
Success. Updated the following files: M packages/core/src/session/recovery.ts M packages/core/src/session/projector.ts
Found 22 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 34: import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
Line 243: const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
/home/byk/Code/opencode/packages/core/test/session-runner-recorded.test.ts:
Line 22: import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
Line 91: const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts:
Line 3: import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
Line 8: describe("SessionRunCoordinator", () => {
Line 14: const coordinator = yield* SessionRunCoordinator.make({
Line 37: const coordinator = yield* SessionRunCoordinator.make<string, never>({
Line 61: const coordinator = ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import {
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Model,
7: TransportReason,
8: InvalidRequestReason,
9: type LLMClientShape,
10: type LLMRequest,
11: } from "@opencode-ai/llm"
12: import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
13: import { Database } from "@opencode-ai/core/database/database"
14: import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
15: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
16: import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
17: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
18: import { EventV2 } from "@opencode-ai/core/event"
19: import { PermissionV2 } from "@opencode-ai/core/permission"
20: import { EventTable } from "@opencode-ai/core/event/sql"
21: import { Project } ...
<path>/home/byk/Code/opencode/packages/core/test/lib/effect.ts</path>
<type>file</type>
<content>
1: import { test, type TestOptions } from "bun:test"
2: import { Cause, Effect, Exit, Layer } from "effect"
3: import type * as Scope from "effect/Scope"
4: import * as TestClock from "effect/testing/TestClock"
5: import * as TestConsole from "effect/testing/TestConsole"
6:
7: type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
8:
9: const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
10:
11: const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
12: Effect.gen(function* () {
13: const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
14: if (Exit.isFailure(exit)) {
15: for (const err of Cause.prettyErrors(exit.cause)) {
16: yield* Effect.logError(err)
17: }
18: }
19: return yield* exit
20: ...
Found 2 matches /home/byk/Code/opencode/packages/core/test/session-projector.test.ts: Line 27: const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node]))) Line 28: const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { DateTime, Effect, Schema } from "effect"
3: import { asc, eq, sql } from "drizzle-orm"
4: import { Database } from "@opencode-ai/core/database/database"
5: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
6: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
7: import { EventV2 } from "@opencode-ai/core/event"
8: import { EventTable } from "@opencode-ai/core/event/sql"
9: import { ModelV2 } from "@opencode-ai/core/model"
10: import { Project } from "@opencode-ai/core/project"
11: import { ProjectTable } from "@opencode-ai/core/project/sql"
12: import { ProviderV2 } from "@opencode-ai/core/provider"
13: import { AbsolutePath } from "@opencode-ai/core/schema"
14: import { SessionV2 } from "@opencode-ai/core/session"
15: import { SessionEvent } from ...
The migration now represents the final two-stage schema cleanly. I’m adding the deterministic projector regression next, but first I’m closing the observable contract: assistant-level error metadata is not sent to the next model, so a recovered provider Step with no tool result would otherwise hide the unknown outcome. Recovery will add a text content item to that exact assistant while tool outcomes remain explicit tool errors; crashes before assistant creation use a synthetic user record.
Found 5 matches
/home/byk/Code/opencode/packages/schema/src/session-message.ts:
Line 140: export interface AssistantText extends Schema.Schema.Type<typeof AssistantText> {}
Line 141: export const AssistantText = Schema.Struct({
Line 142: type: Schema.Literal("text"),
Line 159: export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
Line 162: export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool
<path>/home/byk/Code/opencode/packages/schema/src/session-message.ts</path>
<type>file</type>
<content>
115:
116: export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
117: Schema.toTaggedUnion("status"),
118: )
119: export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
120:
121: export interface AssistantTool extends Schema.Schema.Type<typeof AssistantTool> {}
122: export const AssistantTool = Schema.Struct({
123: type: Schema.Literal("tool"),
124: id: Schema.String,
125: name: Schema.String,
126: provider: Schema.Struct({
127: executed: Schema.Boolean,
128: metadata: ProviderMetadata.pipe(optional),
129: resultMetadata: ProviderMetadata.pipe(optional),
130: }).pipe(optional),
131: state: ToolState,
132: time: Schema.Struct({
133: created: DateTimeUtcFromMillis,
134: ran: DateTimeUtcFromMillis.pipe(optional),
135: completed: ...
Success. Updated the following files: M packages/core/src/session/message-updater.ts
Found 1 matches
/home/byk/Code/opencode/packages/core/src/session/message-updater.ts:
Line 232: id: `recovery:${event.data.executionID}`,
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
215: if (event.data.snapshot || event.data.files)
216: draft.snapshot = {
217: ...draft.snapshot,
218: end: event.data.snapshot,
219: files: event.data.files ? Array.from(event.data.files) : undefined,
220: }
221: })
222: },
223: "session.next.step.failed": (event) => {
224: return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
225: draft.time.completed = event.data.timestamp
226: draft.finish = "error"
227: draft.error = event.data.error
228: draft.content.push(
229: castDraft(
230: SessionMessage.AssistantText.make({
231: type: "text",
232: id: `recovery:${event.data.executionID}`,
233: text: event.data.error.message,
234: ...
Success. Updated the following files: M packages/core/src/session/message-updater.ts
Found 1 matches
/home/byk/Code/opencode/packages/core/src/session/message-updater.ts:
Line 239: id: `recovery:${event.data.executionID}`,
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
220: }
221: })
222: },
223: "session.next.step.failed": (event) => {
224: return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
225: draft.time.completed = event.data.timestamp
226: draft.finish = "error"
227: draft.error = event.data.error
228: })
229: },
230: "session.next.step.interrupted": (event) => {
231: return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
232: draft.time.completed = event.data.timestamp
233: draft.finish = "error"
234: draft.error = event.data.error
235: draft.content.push(
236: castDraft(
237: SessionMessage.AssistantText.make({
238: type: "text",
239: id: `recovery:${event.data.executionID}`,
240: text: ...
Success. Updated the following files: M packages/core/src/session/message-updater.ts
Found 1 matches
/home/byk/Code/opencode/packages/core/src/session/message-updater.ts:
Line 256: id: `recovery:${event.data.executionID}`,
Success. Updated the following files: M packages/core/test/session-projector.test.ts
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 10 pass 0 fail 28 expect() calls Ran 10 tests across 1 file. [2.61s]
The projector regression passes and proves one atomic continuation plus duplicate rejection. I’m now extracting the startup scan as a reusable Effect workflow so the restart test can run it twice against the same durable database and prove idempotence through the real projector, while keeping the production layer as a thin boot hook.
Success. Updated the following files: M packages/core/src/session/recovery.ts
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
439 | if (!yielding && !this.currentPreventYield && this.currentScheduler.shouldYield(this)) {
440 | yielding = true;
441 | const prev = current;
442 | current = flatMap(yieldNow, () => prev);
443 | }
444 | current = this.currentTracerContext ? this.currentTracerContext(current, this) : current[evaluate](this);
^
error: Service not found: @opencode/v2/SessionExecution (defined at /home/byk/Code/opencode/packages/core/src/session/execution.ts:21:38)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:444:98)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The restart test reached the real recovery workflow but its nested layer replacement did not export SessionExecution into the caller’s context. I’m fixing the fixture by providing the noop service directly while retaining the outer test’s real database, event store, and projector; this keeps the regression integrated and avoids rebuilding a second database layer.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
error: Recovery target does not match execution claim: execution
Recovery target does not match execution claim: execution
(fail) SessionProjector > recovers each abandoned execution once across repeated startup scans [46.83ms]
[21:57:23.248] ERROR (#62): 223 | ;
224 | error[key] = original[key];
225 | }
226 | }
227 | } else {
228 | error = new globalThis.Error(!original ? `Unknown error: ${original}` : kind === "string" ? original : formatJson(original));
^
error: Recovery target does not match execution claim: execution
at causePrettyError (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:228:13)
10 pass
1 fail
28 expect() calls
Ran 11 tests across 1 file. [1.55s]
Success. Updated the following files: M packages/core/src/session/projector.ts
bun test v1.3.14 (0d9b296a) 11 pass 0 fail 33 expect() calls Ran 11 tests across 1 file. [1.89s]
The integrated tests now pass twice against the same durable state. I found and am fixing the required supersession edge before broader validation: if a claim points at an older assistant and a newer assistant exists, startup must delete only that obsolete execution claim and never recover, inject continue, or fail server startup.
Success. Updated the following files: M packages/core/src/session/recovery.ts M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 12 pass 0 fail 36 expect() calls Ran 12 tests across 1 file. [1389.00ms]
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit test/provider/transform.test.ts(3886,43): error TS2322: Type '"none"' is not assignable to type '"high" | "low" | "max" | "medium" | "xhigh" | undefined'.
Schema, Core, and Server typechecks pass. The OpenCode package typecheck reaches one unrelated existing failure in test/provider/transform.test.ts:3886 ("none" is outside the current reasoning-effort union); the recovery files themselves introduce no package type error. I’m moving to migration consistency, focused runner interruption tests, and a full diff audit before freezing the candidate.
/home/byk/Code/opencode/packages/core/src/database/migration/20260914214636_session-execution.ts /home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914214636_session-execution.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260914214636_session-execution",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`
9: CREATE TABLE \`session_execution\` (
10: \`id\` text PRIMARY KEY,
11: \`session_id\` text NOT NULL,
12: \`owner_id\` text NOT NULL,
13: \`assistant_message_id\` text,
14: \`time_created\` integer NOT NULL,
15: CONSTRAINT \`fk_session_execution_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
16: );
17: `)
18: yield* tx.run(`ALTER TABLE \`session_recovery\` ADD \`execution_id\` text NOT NULL;`)
19: yield* tx.run(`CREATE UNIQUE INDEX ...
Reading config file '/tmp/opencode-core-migration-check-HahvGz/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-HahvGz/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-HahvGz/full/20260914220210_schema/migration.sql 🚀
Found 55 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 250: interrupt: coordinator.interrupt,
Line 530: const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"])
Line 541: yield* Fiber.interrupt(fiber)
Line 547: error: { type: "unknown", message: "Provider turn interrupted" },
Line 550: ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }
Line 690: it.effect("interrupts a source Location runner after a Session moves", () =>
Line 1312: it.effect("interrupts overflow recovery while the summary provider is running", () =>
Line 1329: yield* session.interrupt(sessionID)
Line 1965: it.effect("preserves durable queued input for a later wake after interruption", () =>
Line 1988: prompt: Prompt.make({ text: "Run after interrupt" }),
Line 1991: yield* session.interrupt(sessionID)
Line 2004: ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
500: expect(yield* session.context(sessionID)).toMatchObject(expectedContext)
501: })
502:
503: const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
504: Effect.gen(function* () {
505: yield* setup
506: const session = yield* SessionV2.Service
507: const prompt = `Fail after ${kind}`
508: const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
509: const failure = providerUnavailable()
510: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
511: responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
512:
513: expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
514: expect(yield* session.context(sessionID)).toMatchObject([
515: { type: "user", text: prompt },
516: {
517: type: ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
2962 | yield* session.interrupt(sessionID)
2963 | toolExecutionGate = undefined
2964 |
2965 | expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
2966 | yield* session.interrupt(sessionID)
2967 | expect(yield* session.context(sessionID)).toMatchObject([
^
error: expect(received).toMatchObject(expected)
@@ -2,5 +2,10 @@
{
+ "id": "msg_0a1f1de92001KBykMrZ1RwGi3D",
"text": "Interrupt blocked tool",
+ "time": {
+ "created": {
+ "epochMilliseconds": 0,
+ "partsUtc": undefined,
+ },
+ },
"type": "user",
@@ -8,3 +13,3 @@
{
+ "agent": "build",
"content": [
@@ -12,12 +17,31 @@
"id": "call-before-interrupt",
+ "name": "echo",
+ "provider": {
+ "executed": false,
+ },
"state": {
+ "content": [],
"error": {
- "message": "Tool ...
The full runner slice exposed two expected contract changes, not runtime failures: interrupted in-flight tools now report outcome unknown after interruption, and a provider Step interrupted before settlement now records an assistant-level unknown outcome. I’m updating those assertions to prove the stronger behavior, then rerunning the whole runner file.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
2935: {
2936: type: "assistant",
2937: content: [
2938: { type: "tool", id: "call-before-failure", state: { status: "completed", structured: { text: "settle" } } },
2939: ],
2940: },
2941: ])
2942: }),
2943: )
2944:
2945: it.effect("durably fails blocked local tools when a provider turn is interrupted", () =>
2946: Effect.gen(function* () {
2947: yield* setup
2948: const session = yield* SessionV2.Service
2949: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false })
2950: executions.length = 0
2951: toolExecutionGate = yield* Deferred.make<void>()
2952: responseStream = Stream.concat(
2953: Stream.fromIterable([
2954: LLMEvent.stepStart({ index: 0 }),
2955: LLMEvent.toolCall({ id: ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
Found 4 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 547: error: { type: "unknown", message: "Provider Step outcome unknown after interruption" },
Line 603: error: { type: "unknown", message: "Provider Step outcome unknown after interruption" },
Line 2318: error: { type: "unknown", message: "Tool execution outcome unknown after interruption" },
Line 2910: error: { type: "unknown", message: "Tool execution outcome unknown after interruption" },
diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts
index cc58b43b29..044cf95c0f 100644
--- a/packages/core/test/session-runner.test.ts
+++ b/packages/core/test/session-runner.test.ts
@@ -544,7 +544,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
{
type: "assistant",
finish: "error",
- error: { type: "unknown", message: "Provider turn interrupted" },
+ error: { type: "unknown", message: "Provider Step outcome unknown after interruption" },
content: [
kind === "tool input"
? ...
The broad assertion patch matched three earlier tests with similar tool blocks, including ordinary application-context and user-decline paths that must retain their old semantics. I caught this in the diff audit and am restoring those unrelated assertions, then applying the unknown-outcome expectations only to the two interruption tests that failed.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
585: ]
586:
587: yield* session.resume(sessionID)
588:
589: expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
590: expect(contexts).toEqual([
591: {
592: sessionID,
593: agent: AgentV2.ID.make("build"),
594: assistantMessageID: expect.stringMatching(/^msg_/),
595: toolCallID: "call-application",
596: },
597: ])
598: expect(yield* session.context(sessionID)).toMatchObject([
599: { type: "user", text: "Use application context" },
600: {
601: type: "assistant",
602: finish: "error",
603: error: { type: "unknown", message: "Provider Step outcome unknown after interruption" },
604: content: [
605: {
606: type: "tool",
607: id: "call-application",
608: ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
Found 4 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 547: error: { type: "unknown", message: "Provider Step outcome unknown after interruption" },
Line 2938: error: { type: "unknown", message: "Provider Step outcome unknown after interruption" },
Line 2979: error: { type: "unknown", message: "Tool execution outcome unknown after interruption" },
Line 3054: error: { type: "unknown", message: "Tool execution outcome unknown after interruption" },
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
2925:
2926: const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
2927: while (executions.length === 0) yield* Effect.yieldNow
2928: yield* Effect.yieldNow
2929: yield* Deferred.succeed(toolExecutionGate, undefined)
2930: expect(yield* Fiber.join(run).pipe(Effect.flip)).toBe(failure)
2931: toolExecutionGate = undefined
2932:
2933: expect(yield* session.context(sessionID)).toMatchObject([
2934: { type: "user", text: "Settle before failing" },
2935: {
2936: type: "assistant",
2937: finish: "error",
2938: error: { type: "unknown", message: "Provider Step outcome unknown after interruption" },
2939: content: [
2940: { type: "tool", id: "call-before-failure", state: { status: "completed", structured: { text: "settle" } } },
2941: ],
2942: ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
Found 4 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 547: error: { type: "unknown", message: "Provider Step outcome unknown after interruption" },
Line 2972: error: { type: "unknown", message: "Provider Step outcome unknown after interruption" },
Line 2979: error: { type: "unknown", message: "Tool execution outcome unknown after interruption" },
Line 3054: error: { type: "unknown", message: "Tool execution outcome unknown after interruption" },
continue. the server was restarted so you'll have to nudge your background agents to continue too.
The only recovery agent finished its source audit before the restart, so there is no suspended worker to resume. I’ll continue from the passing Core regressions, audit the current worktree and migration state, then freeze the exact candidate and launch fresh independent correctness and security reviewers. The required repo-setup skill remains unavailable; I’m following the checked-in repository instructions and loading the Effect-specific guidance now.
<skill_content name="effect">
# Skill: effect
# Effect
This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
## Source Of Truth
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
3. Also inspect existing repo code for local house style before introducing new patterns.
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
## Guidelines
- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
- Use `Effect.gen(function* () { ... })` for multi-step workflows.
- Use `Effect.fn("Name")` or ...
[
{
"content": "Define V2 synthetic recovery ownership and discovery",
"status": "completed",
"priority": "high"
},
{
"content": "Implement interruption reconciliation and synthetic continuation",
"status": "completed",
"priority": "high"
},
{
"content": "Add restart and duplicate-claim regressions",
"status": "in_progress",
"priority": "high"
},
{
"content": "Run package validation and isolated restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Run independent correctness and security reviews",
"status": "pending",
"priority": "high"
}
]
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/session-projector.test.ts M packages/core/test/session-runner.test.ts M packages/opencode/src/server/routes/instance/httpapi/server.ts M packages/schema/src/session-event.ts M packages/server/src/routes.ts ?? packages/core/src/database/migration/20260914170650_session-recovery.ts ?? packages/core/src/database/migration/20260914214636_session-execution.ts ?? packages/core/src/session/recovery.ts ?? perf.data ?? perf.data.old
.lore.md | 132 ++++--- AGENTS.md | 6 + packages/core/schema.json | 434 ++++++++++++++++++--- packages/core/src/database/migration.gen.ts | 2 + packages/core/src/database/schema.gen.ts | 31 ++ packages/core/src/session/execution/local.ts | 25 +- packages/core/src/session/message-updater.ts | 46 +++ packages/core/src/session/projector.ts | 111 +++++- packages/core/src/session/runner/llm.ts | 5 +- .../core/src/session/runner/publish-llm-event.ts | 15 + packages/core/src/session/sql.ts | 43 ++ packages/core/test/session-projector.test.ts | 175 ++++++++- packages/core/test/session-runner.test.ts | 14 +- .../src/server/routes/instance/httpapi/server.ts | 3 +- packages/schema/src/session-event.ts | 31 ++ packages/server/src/routes.ts ...
The diff audit surfaced two lifecycle risks that the current tests do not cover: durable replay of a recovery event after its one-shot execution claim has been consumed, and a crash after normal Step settlement but before drain cleanup. I’m tracing the event replay contract and tightening claim deletion so replay reconstructs projections without requiring ephemeral ownership state and ordinary terminal failures never trigger a synthetic continuation.
Found 16 matches
/home/byk/Code/opencode/packages/core/src/event.ts:
Line 122: /** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
Line 138: readonly replay: (
Line 142: readonly replayAll: (
Line 179: const projectors = new Map<string, Subscriber[]>()
Line 180: // TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
Line 205: function commitDurableEvent(
Line 236: const list = projectors.get(event.type) ?? []
Line 379: const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
Line 441: function replay(
Line 457: const committed = yield* commitDurableEvent(definition, payload, {
Line 480: function replayAll(
Line 508: yield* replay(event, options)
Line 617: const list = projectors.get(definition.type) ?? ...
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
195: yield* PubSub.shutdown(pubsub.all)
196: yield* Effect.forEach(
197: pubsub.durable.values(),
198: (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
199: { discard: true },
200: )
201: yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
202: }),
203: )
204:
205: function commitDurableEvent(
206: definition: Definition,
207: event: Payload,
208: input?: {
209: readonly seq: number
210: readonly aggregateID: string
211: readonly ownerID?: string
212: readonly strictOwner?: boolean
213: },
214: commit?: (seq: number) => Effect.Effect<void>,
215: ) {
216: return Effect.gen(function* () {
217: const durable = definition?.durable
218: if ...
The replay trace confirms the issue: a durable projector must never require the one-shot execution claim because cross-node replay reconstructs projections from events, while the claim exists only in the source database. I’m moving claim verification and consumption into EventV2.publish(..., { commit }); the Step.Recovered projector will become purely event-derived and replayable.
Success. Updated the following files: M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts
Success. Updated the following files: M packages/core/src/session/recovery.ts M packages/core/test/session-projector.test.ts
Found 1 matches /home/byk/Code/opencode/packages/core/test/session-runner.test.ts: Line 370: const replaySessionProjection = (id: SessionV2.ID) =>
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
350: const session = yield* SessionV2.Service
351: response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
352: yield* session.prompt({
353: sessionID,
354: prompt: Prompt.make({ text: "Earlier question ".repeat(700) }),
355: resume: false,
356: })
357: yield* session.resume(sessionID)
358: currentModel = recoveryModel
359: requests.length = 0
360: return session
361: })
362:
363: const messageTexts = (request: LLMRequest, role: "user" | "system") =>
364: request.messages.flatMap((message) =>
365: message.role === role ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) : [],
366: )
367: const userTexts = (request: LLMRequest) => messageTexts(request, "user")
368: const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
369:
370: const ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
error: Recovery target is not the latest assistant: msg_recovery_assistant
Recovery target is not the latest assistant: msg_recovery_assistant
(fail) SessionProjector > atomically reconciles one abandoned execution into one continuation [181.43ms]
[22:11:30.804] ERROR (#2): 223 | ;
224 | error[key] = original[key];
225 | }
226 | }
227 | } else {
228 | error = new globalThis.Error(!original ? `Unknown error: ${original}` : kind === "string" ? original : formatJson(original));
^
error: Recovery target is not the latest assistant: msg_recovery_assistant
at causePrettyError (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:228:13)
11 pass
1 fail
33 expect() calls
Ran 12 tests across 1 file. [1.79s]
The replay regression failed for the right reason: its abandoned assistant was inserted directly into the projection table, so no durable Step.Started event existed to rebuild it. I’m correcting the fixture to create the assistant through the real event stream after inserting the execution claim; replay will then test the actual production history rather than an impossible projection-only state.
Found 1 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 599: .publish(SessionEvent.Step.Started, {
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
580: .run()
581: .pipe(Effect.orDie)
582: yield* db
583: .insert(SessionTable)
584: .values({
585: id: sessionID,
586: project_id: Project.ID.global,
587: slug: "test",
588: directory: "/project",
589: title: "test",
590: version: "test",
591: })
592: .run()
593: .pipe(Effect.orDie)
594: const events = yield* EventV2.Service
595: const id = SessionMessage.ID.make("msg_creator_collision")
596:
597: yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" })
598: const exit = yield* events
599: .publish(SessionEvent.Step.Started, {
600: sessionID,
601: assistantMessageID: id,
602: timestamp: created,
603: agent: "build",
604: ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 12 pass 0 fail 39 expect() calls Ran 12 tests across 1 file. [1.53s]
The restart and replay regressions now pass. One audit finding remains before freezing: the startup scan currently loads every assistant message in the database, which makes restart cost grow with full history, and its generic provider error is inaccurate when the provider Step settled but a local tool remained active. I’m narrowing discovery to one latest-assistant query per active claim and making the synthetic recovery text describe the actual uncertain boundary.
Success. Updated the following files: M packages/core/src/session/recovery.ts
bun test v1.3.14 (0d9b296a) 99 pass 0 fail 302 expect() calls Ran 99 tests across 2 files. [7.88s]
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
120: })
121: export type Synthetic = typeof Synthetic.Type
122:
123: export namespace Shell {
124: export const Started = Event.define({
125: type: "session.next.shell.started",
126: ...options,
127: schema: {
128: ...Base,
129: messageID: SessionMessage.ID,
130: callID: Schema.String,
131: command: Schema.String,
132: },
133: })
134: export type Started = typeof Started.Type
135:
136: export const Ended = Event.define({
137: type: "session.next.shell.ended",
138: ...options,
139: schema: {
140: ...Base,
141: callID: Schema.String,
142: output: Schema.String,
143: },
144: })
145: export type Ended = typeof Ended.Type
146: }
147:
148: export namespace Step {
149: export const Started = Event.define({
150: type: "session.next.step.started",
151: ...options,
152: schema: {
153: ...
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
1: export * as SessionEvent from "./session-event"
2:
3: import { Schema } from "effect"
4: import { optional } from "./schema"
5: import { Event } from "./event"
6: import { ProviderMetadata, ToolContent } from "./llm"
7: import { Delivery } from "./session-delivery"
8: import { Model } from "./model"
9: import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema"
10: import { FileAttachment, Prompt } from "./prompt"
11: import { SessionID } from "./session-id"
12: import { Location } from "./location"
13: import { SessionMessage } from "./session-message"
14: import { Revert } from "./revert"
15:
16: export { FileAttachment }
17:
18: export const Source = Schema.Struct({
19: start: NonNegativeInt,
20: end: NonNegativeInt,
21: text: Schema.String,
22: }).annotate({
23: identifier: "session.next.event.source",
24: })
25: export interface Source extends ...
No files found
The focused suites pass, but the current implementation still needs lifecycle proof rather than more assertion updates. I’m checking startup ordering and coordinator completion semantics now, especially whether recovery consumes the stale claim before its own wake starts a replacement drain and whether replay remains valid after claim consumption.
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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?: ...
The coordinator ordering is sound: recovery consumes the old claim inside the durable event transaction before wake schedules the replacement drain. The remaining unproven boundary is the local execution adapter itself, so I’m adding a focused service test for three cases: successful drain clears its claim, scope interruption preserves it, and explicit user interruption clears it after cleanup.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/index.ts</path>
<type>file</type>
<content>
1: export * as SessionRunner from "./index"
2:
3: import type { LLMError } from "@opencode-ai/llm"
4: import { Context, Effect } from "effect"
5: import { SessionSchema } from "../schema"
6: import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
7: import { SessionRunnerModel } from "./model"
8: import type { SystemContext } from "../../system-context/index"
9: import type { ToolOutputStore } from "../../tool-output-store"
10:
11: export type RunError =
12: | LLMError
13: | SessionRunnerModel.Error
14: | MessageDecodeError
15: | ContextSnapshotDecodeError
16: | SystemContext.InitializationBlocked
17: | ToolOutputStore.Error
18:
19: /** Runs one local continuation from already-recorded Session history. */
20: export interface Interface {
21: /** Drains eligible durable work. ...
<path>/home/byk/Code/opencode/packages/core/src/session/store.ts</path>
<type>file</type>
<content>
1: export * as SessionStore from "./store"
2:
3: import { eq } from "drizzle-orm"
4: import { Context, Effect, Layer, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { SessionHistory } from "./history"
8: import { MessageDecodeError } from "./error"
9: import { SessionMessage } from "./message"
10: import { SessionSchema } from "./schema"
11: import { SessionMessageTable, SessionTable } from "./sql"
12: import { fromRow } from "./info"
13:
14: export interface Interface {
15: readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
16: readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
17: readonly runnerContext: (
18: sessionID: SessionSchema.ID,
19: baselineSeq: number,
20: ) => ...
The lifecycle audit found a real gap in the current claim state: Step.Ended can precede local tool settlement, so clearing the assistant target there would hide uncertain tool side effects on restart. I’m correcting the target lifecycle around tool settlement and separating terminal user-decline completion from process interruption; otherwise a declined permission could be auto-continued on the next restart.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
270: name: event.name,
271: result: settlement.result,
272: output: settlement.output,
273: }),
274: settlement.outputPaths ?? [],
275: ),
276: ),
277: ),
278: ).pipe(FiberSet.run(toolFibers))
279: }),
280: ),
281: Effect.ensuring(withPublication(publisher.flush())),
282: )
283:
284: return yield* Effect.uninterruptibleMask((restore) =>
285: Effect.gen(function* () {
286: const stream = yield* restore(providerStream).pipe(Effect.exit)
287: const failure =
288: stream._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(stream.cause)) : undefined
289: if (
290: recoverOverflow &&
291: ...
The code trace corrects my earlier assumption: Step.Ended is published only after local tool fibers settle, so clearing its assistant target is safe. The real defect is replay coupling and terminal interruption: projector handlers currently mutate live execution claims during replay, and a permission decline exits with an interrupt cause that would leave a false restart marker. I’m moving all claim updates into local publish commit hooks: Started binds the target, Ended clears it, Failed removes the terminal claim, and Interrupted preserves it.
Found 1 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 225: const publisher = createLLMEventPublisher(events, {
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
205: const request = LLM.request({
206: model,
207: http: {
208: headers: {
209: "x-session-affinity": session.id,
210: "X-Session-Id": session.id,
211: ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
212: },
213: },
214: providerOptions: { openai: { promptCacheKey } },
215: system: [agent.info?.system, system.baseline]
216: .filter((part): part is string => part !== undefined && part.length > 0)
217: .map(SystemPart.make),
218: messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
219: tools: toolMaterialization?.definitions ?? [],
220: toolChoice: isLastStep ? "none" : undefined,
221: })
222: if (yield* compaction.compactIfNeeded({ sessionID: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
1: import {
2: LLM,
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Message,
7: SystemPart,
8: isContextOverflowFailure,
9: type ProviderErrorEvent,
10: } from "@opencode-ai/llm"
11: import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
12: import { AgentV2 } from "../../agent"
13: import { Config } from "../../config"
14: import { Database } from "../../database/database"
15: import { EventV2 } from "../../event"
16: import { Location } from "../../location"
17: import { ModelV2 } from "../../model"
18: import { PermissionV2 } from "../../permission"
19: import { ProviderV2 } from "../../provider"
20: import { QuestionV2 } from "../../question"
21: import { SystemContext } from "../../system-context/index"
22: import { SystemContextRegistry } from "../../system-context/registry"
23: import { SkillGuidance } from ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: }
15:
16: const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
17:
18: const tokens = (usage: Usage | undefined) => {
19: const reasoning = safe(usage?.reasoningTokens)
20: const read = safe(usage?.cacheReadInputTokens)
21: const write = safe(usage?.cacheWriteInputTokens)
22: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path> <type>file</type> <content> 80: * - Post-run maintenance 81: * - [ ] Settle final status and expose durable output events to replayable consumers. 82: * - [ ] Coalesce streamed deltas and add covering projected-history indexes. 83: * - [ ] Update title, summaries, compaction state, and cleanup in bounded background work. 84: * 85: * Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here. 86: * Durable continuation recovery remains a separate future slice with an explicit retry policy. 87: * 88: * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one 89: * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an 90: * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop. ...
I’m implementing the claim lifecycle at the event publication boundary now. This keeps durable projectors replay-only, preserves an interrupted claim for restart recovery, and consumes terminal failures such as permission decline so startup never invents a continuation for work the user deliberately stopped.
Success. Updated the following files: M packages/core/src/session/projector.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/runner/llm.ts
Found 1 matches /home/byk/Code/opencode/packages/core/src/session/projector.ts: Line 18: SessionExecutionTable,
Success. Updated the following files: M packages/core/src/session/projector.ts
$ tsgo --noEmit
test/session-runner-tool-events.test.ts(41,48): error TS2741: Property 'db' is missing in type '{ sessionID: string & Brand<"SessionID">; agent: string; model: { id: string & Brand<"ModelV2.ID">; providerID: string & Brand<"ProviderV2.ID">; }; }' but required in type 'Input'.
The typecheck caught one pure publisher test that constructs the event publisher without a database. Rather than hide this with an optional database, I’m keeping ownership at the runner boundary: the publisher will accept explicit atomic commit effects for Step start and terminal failure, and its unit fixture will provide no-op hooks. This preserves dependency direction and keeps claim policy out of the event translator.
<path>/home/byk/Code/opencode/packages/core/test/session-runner-tool-events.test.ts</path>
<type>file</type>
<content>
1: import { expect, test } from "bun:test"
2: import { Effect, Schema, Stream } from "effect"
3: import { LLMEvent } from "@opencode-ai/llm"
4: import { EventV2 } from "@opencode-ai/core/event"
5: import { SessionEvent } from "@opencode-ai/core/session/event"
6: import { SessionMessage } from "@opencode-ai/core/session/message"
7: import { SessionV2 } from "@opencode-ai/core/session"
8: import { ModelV2 } from "@opencode-ai/core/model"
9: import { ProviderV2 } from "@opencode-ai/core/provider"
10: import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
11:
12: const sessionID = SessionV2.ID.make("ses_tool_event_test")
13: const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
14:
15: const capture = () => {
16: const published: Array<{ readonly type: string; readonly data: unknown }> = []
17: const events = ...
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/runner/llm.ts M packages/core/test/session-runner-tool-events.test.ts
$ tsgo --noEmit
src/session/runner/publish-llm-event.ts(91,41): error TS2345: Argument of type '(string & Brand<"Session.Message.ID">) | undefined' is not assignable to parameter of type 'string & Brand<"Session.Message.ID">'.
Type 'undefined' is not assignable to type 'string & Brand<"Session.Message.ID">'.
Type 'undefined' is not assignable to type 'string'.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
70: let assistantMessageID: SessionMessage.ID | undefined
71: let assistantActive = false
72: let assistantFailed = false
73: let providerFailed = false
74: let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
75:
76: const startAssistant = Effect.fnUntraced(function* () {
77: if (assistantMessageID !== undefined) return assistantMessageID
78: assistantMessageID = SessionMessage.ID.create()
79: assistantActive = true
80: yield* events.publish(
81: SessionEvent.Step.Started,
82: {
83: sessionID: input.sessionID,
84: agent: input.agent,
85: model: input.model,
86: assistantMessageID,
87: timestamp: yield* timestamp,
88: snapshot: input.snapshot,
89: },
90: {
91: commit: () => ...
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 104 pass 0 fail 312 expect() calls Ran 104 tests across 3 files. [7.60s]
The event publisher and replay boundaries now pass 104 focused tests and Core typecheck. I’m adding the missing execution-adapter regression before freezing: current runner tests use a test coordinator, so they do not prove the production SessionExecutionLocal claim survives scope shutdown but clears after normal completion or explicit interruption.
Found 15 matches
/home/byk/Code/opencode/packages/core/test/location-layer.test.ts:
Line 36: AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node])),
Line 48: const locations = yield* LocationServiceMap.Service
Line 100: LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
Line 181: ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
Line 221: Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
/home/byk/Code/opencode/packages/core/test/effect/layer-node/node-build.test.ts:
Line 27: expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true)
Line 37: layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
Line 38: deps: [LocationServiceMap.node],
...
<path>/home/byk/Code/opencode/packages/core/test/location-layer.test.ts</path>
<type>file</type>
<content>
1: import fs from "fs/promises"
2: import path from "path"
3: import { describe, expect } from "bun:test"
4: import { DateTime, Effect, Equal, Hash, Schema } from "effect"
5: import { Tool } from "@opencode-ai/core/tool/tool"
6: import { define } from "@opencode-ai/plugin/v2/effect"
7: import { AgentV2 } from "@opencode-ai/core/agent"
8: import { Catalog } from "@opencode-ai/core/catalog"
9: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
10: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
11: import { LocationServiceMap } from "@opencode-ai/core/location-services"
12: import { Location } from "@opencode-ai/core/location"
13: import { PluginV2 } from "@opencode-ai/core/plugin"
14: import { ModelV2 } from "@opencode-ai/core/model"
15: import { ProjectV2 } from "@opencode-ai/core/project"
16: import { ProviderV2 } from ...
<path>/home/byk/Code/opencode/packages/core/src/location-service-map.ts</path>
<type>file</type>
<content>
1: import { Context, Effect, Layer, LayerMap } from "effect"
2: import { LayerNode } from "./effect/layer-node"
3: import { Node } from "./effect/app-node"
4: import { Location } from "./location"
5: import type { LocationError, LocationServices } from "./location-services"
6:
7: export class Service extends Context.Service<
8: Service,
9: LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>
10: >()("@opencode/example/LocationServiceMap") {
11: static get(ref: Location.Ref) {
12: return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
13: }
14: }
15:
16: export const node = LayerNode.unbound(Service, Node.tags.values.global)
17:
18: export * as LocationServiceMap from "./location-service-map"
(End of file - total 18 lines)
</content>
<path>/home/byk/Code/opencode/package.json</path>
<type>file</type>
<content>
1: {
2: "$schema": "https://json.schemastore.org/package.json",
3: "name": "opencode",
4: "description": "AI-powered development tool",
5: "private": true,
6: "type": "module",
7: "packageManager": "bun@1.3.14",
8: "scripts": {
9: "dev": "bun run --cwd packages/opencode src/index.ts",
10: "dev:desktop": "bun --cwd packages/desktop dev",
11: "dev:web": "bun --cwd packages/app dev",
12: "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
13: "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
14: "dev:storybook": "bun --cwd packages/storybook storybook",
15: "lint": "oxlint",
16: "typecheck": "bun turbo typecheck",
17: "upgrade-opentui": "bun run script/upgrade-opentui.ts",
18: "postinstall": "bun run --cwd packages/core fix-node-pty",
19: "prepare": "husky",
20: "random": "echo ...
No files found
The production adapter audit reveals a state ambiguity worth fixing before tests: a null assistant target can mean either “safe between Steps” or “provider request dispatched before the first output.” Startup must not call both unknown. I’m adding an explicit execution phase so recovery reports unknown outcomes only after dispatch; safe-boundary recovery still injects continue, but tells the model only that the server interrupted the Session.
Success. Updated the following files: M packages/core/src/session/sql.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/recovery.ts M packages/core/src/session/runner/llm.ts M packages/core/test/session-projector.test.ts
The explicit phase is now in source; I’m regenerating the migration artifacts so existing databases receive the new non-null field with a safe default, then I’ll rerun the integrated suites and inspect the generated SQL before adding adapter-level tests.
Reading config file '/tmp/opencode-core-migration-pJHZaM/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-pJHZaM/incremental/20260914222526_session-execution-phase/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-pJHZaM/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-pJHZaM/full/20260914222529_schema/migration.sql 🚀
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914222526_session-execution-phase.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260914222526_session-execution-phase",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`ALTER TABLE \`session_execution\` ADD \`phase\` text NOT NULL;`)
9: })
10: },
11: } satisfies DatabaseMigration.Migration
(End of file - total 11 lines)
</content>
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 104 pass 0 fail 312 expect() calls Ran 104 tests across 3 files. [11.83s] Reading config file '/tmp/opencode-core-migration-check-ya3Zk6/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-ya3Zk6/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-ya3Zk6/full/20260914222620_schema/migration.sql 🚀
<path>/home/byk/Code/opencode/packages/core/src/location-services.ts</path>
<type>file</type>
<content>
1: import { Effect, Layer, LayerMap } from "effect"
2: import { AgentV2 } from "./agent"
3: import { AISDK } from "./aisdk"
4: import { Catalog } from "./catalog"
5: import { CommandV2 } from "./command"
6: import { Config } from "./config"
7: import { LayerNode } from "./effect/layer-node"
8: import { Node } from "./effect/app-node"
9: import { FileMutation } from "./file-mutation"
10: import { FileSystem } from "./filesystem"
11: import { FileSystemSearch } from "./filesystem/search"
12: import { Watcher } from "./filesystem/watcher"
13: import { Image } from "./image"
14: import { Integration } from "./integration"
15: import { Location } from "./location"
16: import { LocationMutation } from "./location-mutation"
17: import { LocationServiceMap } from "./location-service-map"
18: import { PermissionV2 } from "./permission"
19: import { PluginV2 } from "./plugin"
20: import { ...
Found 1 matches /home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/test/LayerMap.test.ts: Line 23: const layerMap = yield* LayerMap.make(
<path>/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/test/LayerMap.test.ts</path>
<type>file</type>
<content>
1: import { assert, describe, it } from "@effect/vitest"
2: import { Effect, Layer, LayerMap } from "effect"
3: import { TestClock } from "effect/testing"
4:
5: const makeLayer = (key: string, acquired: Array<string>, released: Array<string>): Layer.Layer<any> =>
6: Layer.effectDiscard(
7: Effect.acquireRelease(
8: Effect.sync(() => {
9: acquired.push(key)
10: }),
11: () =>
12: Effect.sync(() => {
13: released.push(key)
14: })
15: )
16: ) as Layer.Layer<any>
17:
18: describe("LayerMap", () => {
19: it.effect("make supports dynamic idleTimeToLive", () =>
20: Effect.gen(function*() {
21: const acquired: Array<string> = []
22: const released: Array<string> = []
23: const layerMap = yield* LayerMap.make(
24: (key: string) => makeLayer(key, ...
Found 1 matches /home/byk/Code/opencode/packages/core/test/effect/layer-node/node-build.test.ts: Line 64: }) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>,
/home/byk/Code/opencode/packages/core/src/session/execution/local.ts
The phase-aware focused suites pass. I’m now testing the production claim adapter without duplicating its logic: a minimal real LayerMap will supply only SessionRunner at the bound Location, while the real database and SessionStore back the global layer. This lets the test observe the actual SessionExecutionLocal service across completion and scope interruption.
Found 1 matches
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/LayerMap.ts:
Line 69: export interface LayerMap<in out K, in out I, in out E = never> {
<path>/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/LayerMap.ts</path>
<type>file</type>
<content>
60: *
61: * // Invalidate a cached layer
62: * yield* layerMap.invalidate("development")
63: * })
64: * ```
65: *
66: * @category models
67: * @since 3.14.0
68: */
69: export interface LayerMap<in out K, in out I, in out E = never> {
70: readonly [TypeId]: typeof TypeId
71:
72: /**
73: * The internal RcMap that stores the resources.
74: */
75: readonly rcMap: RcMap.RcMap<K, Context.Context<I>, E>
76:
77: /**
78: * Retrieves a Layer for the resources associated with the key.
79: */
80: get(key: K): Layer.Layer<I, E>
81:
82: /**
83: * Retrieves the context associated with the key.
84: */
85: contextEffect(key: K): Effect.Effect<Context.Context<I>, E, Scope.Scope>
86:
87: /**
88: * Invalidates the resource associated with the key.
89: */
90: invalidate(key: K): Effect.Effect<void>
91: ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, eq } from "drizzle-orm"
2: import { Cause, Effect, Exit, Layer } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionSchema } from "../schema"
9: import { SessionStore } from "../store"
10: import { SessionExecution } from "../execution"
11: import { SessionExecutionTable } from "../sql"
12:
13: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
14: const layer = Layer.effect(
15: SessionExecution.Service,
16: Effect.gen(function* () {
17: const { db } = yield* Database.Service
18: const store = yield* SessionStore.Service
19: const ...
<path>/home/byk/Code/opencode/packages/core/test/lib/effect.ts</path>
<type>file</type>
<content>
1: import { test, type TestOptions } from "bun:test"
2: import { Cause, Effect, Exit, Layer } from "effect"
3: import type * as Scope from "effect/Scope"
4: import * as TestClock from "effect/testing/TestClock"
5: import * as TestConsole from "effect/testing/TestConsole"
6:
7: type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
8:
9: const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
10:
11: const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
12: Effect.gen(function* () {
13: const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
14: if (Exit.isFailure(exit)) {
15: for (const err of Cause.prettyErrors(exit.cause)) {
16: yield* Effect.logError(err)
17: }
18: }
19: return yield* exit
20: ...
Found 23 matches /home/byk/Code/opencode/packages/core/test/session-runner.test.ts: Line 257: Database.node, /home/byk/Code/opencode/packages/core/test/session-projector.test.ts: Line 34: const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node]))) /home/byk/Code/opencode/packages/core/test/database-migration.test.ts: Line 107: const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)] Line 351: AppNodeBuilder.build(LayerNode.group([EventV2.node, SessionProjector.node]), [[Database.node, database]]), /home/byk/Code/opencode/packages/core/test/tool-todowrite.test.ts: Line 41: Database.node, /home/byk/Code/opencode/packages/core/test/session-runner-recorded.test.ts: Line 105: Database.node, /home/byk/Code/opencode/packages/core/test/session-prompt.test.ts: Line 47: LayerNode.group([Database.node, EventV2.node, SessionProjector.node, ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
220: buffer: 3_000,
221: keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
222: }),
223: }),
224: }),
225: ]),
226: }),
227: )
228: const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
229: [Snapshot.node, Snapshot.noopLayer],
230: [LayerNodePlatform.llmClient, client],
231: [SessionRunnerModel.node, models],
232: [SystemContextRegistry.node, systemContext],
233: [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
234: [SkillGuidance.node, skillGuidance],
235: [ReferenceGuidance.node, referenceGuidance],
236: [PermissionV2.node, permission],
237: [Config.node, config],
238: ])
239: const execution = Layer.effect(
240: SessionExecution.Service,
241: Effect.gen(function* () {
242: const sessionRunner = yield* SessionRunner.Service
243: const ...
<path>/home/byk/Code/opencode/packages/core/src/location.ts</path>
<type>file</type>
<content>
1: import { Context, Effect, Layer } from "effect"
2: import { Info, Ref, response } from "@opencode-ai/schema/location"
3: import { Project } from "./project"
4: import { LayerNode } from "./effect/layer-node"
5: import { makeLocationNode, tags } from "./effect/app-node"
6:
7: export * as Location from "./location"
8:
9: export { Info, Ref, response }
10:
11: export interface Interface extends Info {
12: readonly vcs?: Project.Vcs
13: }
14:
15: export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
16:
17: export const node = LayerNode.unbound(Service, tags.values.location)
18:
19: const layer = (ref: Ref) =>
20: Layer.effect(
21: Service,
22: Effect.gen(function* () {
23: const project = yield* Project.Service
24: const resolved = yield* project.resolve(ref.directory)
25: return Service.of({
26: directory: ...
No files found
Found 2 matches
/home/byk/Code/opencode/packages/core/src/session.ts:
Line 115: readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info>
Line 208: create: Effect.fn("V2Session.create")(function* (input) {
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path>
<type>file</type>
<content>
188: const db = database.db
189: const events = yield* EventV2.Service
190: const projects = yield* ProjectV2.Service
191: const execution = yield* SessionExecution.Service
192: const store = yield* SessionStore.Service
193: const locations = yield* LocationServiceMap.Service
194: const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
195: const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
196: const decode = (row: typeof SessionMessageTable.$inferSelect) =>
197: decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
198: Effect.mapError(
199: () =>
200: new MessageDecodeError({
201: sessionID: SessionSchema.ID.make(row.session_id),
202: messageID: SessionMessage.ID.make(row.id),
203: }),
204: ),
205: )
206:
207: const result ...
The phase migration and focused tests pass. Before adding the adapter fixture, I’m checking the exact production layer replacement used by the server so the regression exercises SessionExecutionLocal with the same Location routing rather than a hand-built approximation.
Found 5 matches
/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/server.ts:
Line 104: import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services"
Line 275: const locationServiceMapV2 = buildLocationServiceMap()
Line 292: AppNodeBuilderV1.build(MoveSession.node, [[LocationServiceMap.node, locationServiceMapV2]]),
Line 301: [LocationServiceMap.node, locationServiceMapV2],
Line 305: Layer.provide(locationServiceMapV2),
<path>/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/server.ts</path>
<type>file</type>
<content>
268: ProjectCopy.node,
269: PtyTicket.node,
270: ])
271:
272: export function createRoutes(
273: corsOptions?: CorsOptions,
274: ): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
275: const locationServiceMapV2 = buildLocationServiceMap()
276:
277: return Layer.mergeAll(
278: rootApiRoutes,
279: eventApiRoutes,
280: ptyConnectApiRoutes,
281: instanceRoutes,
282: serverRoutes,
283: docRoute,
284: uiRoute,
285: ).pipe(
286: Layer.provide([
287: errorLayer,
288: compressionLayer,
289: corsVaryFix,
290: fenceLayer,
291: cors(corsOptions),
292: AppNodeBuilderV1.build(MoveSession.node, [[LocationServiceMap.node, locationServiceMapV2]]),
293: HttpServer.layerServices,
294: ]),
295: Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
296: ...
<path>/home/byk/Code/opencode/packages/core/src/database/database.ts</path>
<type>file</type>
<content>
1: export * as Database from "./database"
2:
3: import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
4: import { layer as sqliteLayer } from "#sqlite"
5: import { Context, Effect, Layer } from "effect"
6: import { Global } from "../global"
7: import { Flag } from "../flag/flag"
8: import { isAbsolute, join } from "path"
9: import { DatabaseMigration } from "./migration"
10: import { InstallationChannel } from "../installation/version"
11: import { makeGlobalNode } from "../effect/app-node"
12:
13: const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
14: type DatabaseShape = Effect.Success<typeof makeDatabase>
15:
16: export interface Interface {
17: db: DatabaseShape
18: }
19:
20: export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
21:
22: const layer = Layer.effect(
23: Service,
24: ...
<path>/home/byk/Code/opencode/packages/core/src/session/schema.ts</path>
<type>file</type>
<content>
1: export * as SessionSchema from "./schema"
2:
3: import { Session } from "@opencode-ai/schema/session"
4:
5: export const ID = Session.ID
6: export type ID = typeof ID.Type
7:
8: export const Info = Session.Info
9: export type Info = Session.Info
(End of file - total 9 lines)
</content>
<path>/home/byk/Code/opencode/packages/schema/src/session.ts</path>
<type>file</type>
<content>
1: export * as Session from "./session"
2:
3: import { Schema } from "effect"
4: import { Agent } from "./agent"
5: import { Location } from "./location"
6: import { Model } from "./model"
7: import { Project } from "./project"
8: import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema"
9: import { SessionEvent } from "./session-event"
10: import { SessionID } from "./session-id"
11: import { Revert } from "./revert"
12:
13: export const ID = SessionID
14: export type ID = SessionID
15:
16: export const Event = SessionEvent
17:
18: export interface Info extends Schema.Schema.Type<typeof Info> {}
19: export const Info = Schema.Struct({
20: id: ID,
21: parentID: ID.pipe(optional),
22: projectID: Project.ID,
23: agent: Agent.ID.pipe(optional),
24: model: Model.Ref.pipe(optional),
25: cost: Schema.Finite,
26: tokens: Schema.Struct({
27: input: ...
Success. Updated the following files: A packages/core/test/session-execution-local.test.ts
$ tsgo --noEmit
test/session-execution-local.test.ts(28,11): error TS2322: Type 'Effect<{ id: string & Brand<"SessionID">; projectID: string & Brand<"Project.ID">; cost: number; tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number; }; }; time: { ...; }; title: string; location: { ...; }; } | undefined, never, never>' is not assignable to type 'Effect<Info | undefined, never, never>'.
Type '{ id: string & Brand<"SessionID">; projectID: string & Brand<"Project.ID">; cost: number; tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number; }; }; time: { ...; }; title: string; location: { ...; }; } | undefined' is not assignable to type 'Info | undefined'.
Type '{ id: string & Brand<"SessionID">; projectID: string & Brand<"Project.ID">; cost: number; tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number; }; }; time: { ...; }; title: string; location: { ...; }; ...
The first adapter test compile exposed Effect v4 API mismatches in the fixture, not production errors. I’m correcting it against the checked-out source: Layer.effect owns the LayerMap scope, LayerNode.compile exposes the local node layer, DateTime.makeUnsafe supplies branded timestamps, and Effect.andThen replaces the old zipRight form.
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
$ tsgo --noEmit
test/session-execution-local.test.ts(50,5): error TS2345: Argument of type 'Effect<LayerMap<Ref, Service, never>, never, Scope>' is not assignable to parameter of type 'Effect<LayerMap<Ref, Service | Service | Service | Service | Service | Service | Service | Service | Service | ... 23 more ... | Service, never>, never, Scope>'.
Type 'LayerMap<Ref, Service, never>' is not assignable to type 'LayerMap<Ref, Service | Service | Service | Service | Service | Service | Service | Service | Service | ... 23 more ... | Service, never>'.
The types of 'rcMap.lookup(...)' are incompatible between these types.
Type 'Effect<Context<Service>, never, Scope>' is not assignable to type 'Effect<Context<Service | Service | Service | Service | Service | Service | Service | Service | Service | ... 23 more ... | Service>, never, Scope>'. ...
The fixture needs the full Location service union even though the adapter reads only SessionRunner, and scoped runner effects cannot satisfy its no-environment contract. I’m tightening the test rather than casting around either constraint: the Location layer will declare the real LocationServices output type, and interruption observation will use Effect.onInterrupt with explicit start/cleanup latches.
Found 42 matches /home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/test/LayerRef.test.ts: Line 40: const contextFromLayer = yield* Effect.scoped(Layer.build(ref.get)) Line 116: const contextFromLayer = yield* Effect.scoped(Layer.build(ResourceRef.get)) /home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/test/Logger.test.ts: Line 33: const context = yield* Layer.build(Logger.layer([Logger.formatJson.pipe( /home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/test/Layer.test.ts: Line 25: const env = layer1.pipe(Layer.merge(layer2), Layer.build) Line 36: const env = layer.pipe(Layer.merge(layer), Layer.build) Line 45: const env = layer.pipe(Layer.merge(layer), Layer.merge(layer), Layer.build) Line 61: yield* Effect.scoped(Layer.build(layer)) Line 63: yield* Effect.scoped(Layer.build(layer)) Line 76: ...
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
$ tsgo --noEmit
test/session-execution-local.test.ts(51,5): error TS2345: Argument of type 'Effect<LayerMap<Ref, never, never>, never, Scope>' is not assignable to parameter of type 'Effect<LayerMap<Ref, Service | Service | Service | Service | Service | Service | Service | Service | Service | ... 23 more ... | Service, never>, never, Scope>'.
Type 'LayerMap<Ref, never, never>' is not assignable to type 'LayerMap<Ref, Service | Service | Service | Service | Service | Service | Service | Service | Service | ... 23 more ... | Service, never>'.
The types of 'rcMap.lookup(...)' are incompatible between these types.
Type 'Effect<Context<never>, never, Scope>' is not assignable to type 'Effect<Context<Service | Service | Service | Service | Service | Service | Service | Service | Service | ... 23 more ... | Service>, never, Scope>'. ...
The full Location context type is intentionally closed over all location services, so a partial fake would require an unchecked cast. I’m avoiding that. I’ll expose the adapter’s dependency-injected constructor, keep the production layer as the thin Location routing wrapper, and test the same coordinator and claim lifecycle under a child scope with the real database.
Found 41 matches /home/byk/Code/opencode/packages/core/test/reference.test.ts: Line 21: const scope = yield* Scope.make() Line 35: yield* Scope.close(scope, Exit.void) /home/byk/Code/opencode/packages/core/test/session-runner-tool-registry.test.ts: Line 127: const scope = yield* Scope.make() Line 130: yield* Scope.close(scope, Exit.void) Line 138: const scope = yield* Scope.make() Line 152: yield* Scope.close(scope, Exit.void) Line 349: const scope = yield* Scope.make() Line 352: yield* Scope.close(scope, Exit.void) Line 380: const overlay = yield* Scope.make() Line 383: yield* Scope.close(overlay, Exit.void) Line 412: const scope = yield* Scope.make() Line 415: yield* Scope.close(scope, Exit.void) Line 429: const scope = yield* Scope.make() Line 445: yield* Scope.close(scope, Exit.void) /home/byk/Code/opencode/packages/core/test/background-job.test.ts: ...
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts:
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const sessionID = SessionSchema.ID.make("ses_execution_local")
const setup = (run: SessionRunner.Interface["run"]) => {
const database = Database.layerFromPath(":memory:")
const store = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
return SessionStore.Service.of({
get: (id) =>
Effect.succeed(
id === sessionID
? {
id: sessionID,
projectID: Project.ID.global,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
title: ...
The constructor refactor patch did not apply because the test fixture had diverged from the expected context. No source changed from that patch. I’m splitting the edit into exact file-level patches after rereading both files, then I’ll run typecheck and the adapter regression immediately.
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { Context, DateTime, Deferred, Effect, Layer, LayerMap } from "effect"
3: import { eq } from "drizzle-orm"
4: import { Database } from "@opencode-ai/core/database/database"
5: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
6: import { AbsolutePath } from "@opencode-ai/core/schema"
7: import { Location } from "@opencode-ai/core/location"
8: import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
9: import type { LocationServices } from "@opencode-ai/core/location-services"
10: import { Project } from "@opencode-ai/core/project"
11: import { SessionExecution } from "@opencode-ai/core/session/execution"
12: import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
13: import { SessionRunner } from "@opencode-ai/core/session/runner"
14: import { SessionSchema ...
Success. Updated the following files: M packages/core/src/session/execution/local.ts
Success. Updated the following files: D packages/core/test/session-execution-local.test.ts A packages/core/test/session-execution-local.test.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-execution-local.test.ts:
277 | }
278 |
279 | assertUnreachable(cacheStrat)
280 | }).pipe(
281 | Effect.catch((e) => {
282 | return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
^
error: Failed query: insert into "session_execution" ("id", "session_id", "owner_id", "assistant_message_id", "phase", "time_created") values (?, ?, ?, null, ?, ?)
params: c19aee90-632f-417c-9cc1-4be0fb24baa8,ses_execution_local,fdc09725-f772-468d-9a53-9dc46caabf67,safe,1789425468600
query: "insert into \"session_execution\" (\"id\", \"session_id\", \"owner_id\", \"assistant_message_id\", \"phase\", \"time_created\") values (?, ?, ?, null, ?, ?)",
params: [
"c19aee90-632f-417c-9cc1-4be0fb24baa8", "ses_execution_local", "fdc09725-f772-468d-9a53-9dc46caabf67",
"safe", 1789425468600
],
_tag: "EffectDrizzleQueryError",
at ...
The adapter test reached the real database and exposed two fixture flaws: session_execution correctly enforces its Session foreign key, and the child layer used a second in-memory database, so its start latch never completed. I’m replacing the layer fixture with direct construction of the production adapter under one database and an explicit child Scope, while inserting the owning Session row first.
Success. Updated the following files: D packages/core/test/session-execution-local.test.ts A packages/core/test/session-execution-local.test.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-execution-local.test.ts:
277 | }
278 |
279 | assertUnreachable(cacheStrat)
280 | }).pipe(
281 | Effect.catch((e) => {
282 | return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
^
error: Failed query: insert into "session" ("id", "project_id", "workspace_id", "parent_id", "slug", "directory", "path", "title", "version", "share_url", "summary_additions", "summary_deletions", "summary_files", "summary_diffs", "metadata", "cost", "tokens_input", "tokens_output", "tokens_reasoning", "tokens_cache_read", "tokens_cache_write", "revert", "permission", "agent", "model", "time_created", "time_updated", "time_compacting", "time_archived") values (?, ?, null, null, ?, ?, null, ?, ?, null, null, null, null, null, null, ?, ?, ?, ?, ?, ?, null, null, null, null, ?, ?, null, null)
params: ...
The remaining foreign-key failure is the Session’s project_id, not the execution claim. The test must insert the owning Project row before the Session row; I’m applying that fixture correction and rerunning the adapter test.
Found 1 matches
/home/byk/Code/opencode/packages/core/src/project/sql.ts:
Line 6: export const ProjectTable = sqliteTable("project", {
<path>/home/byk/Code/opencode/packages/core/src/project/sql.ts</path>
<type>file</type>
<content>
1: import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
2: import * as DatabasePath from "../database/path"
3: import { Timestamps } from "../database/schema.sql"
4: import { ProjectSchema } from "./schema"
5:
6: export const ProjectTable = sqliteTable("project", {
7: id: text().$type<ProjectSchema.ID>().primaryKey(),
8: worktree: DatabasePath.absoluteColumn().notNull(),
9: vcs: text(),
10: name: text(),
11: icon_url: text(),
12: icon_url_override: text(),
13: icon_color: text(),
14: ...Timestamps,
15: time_initialized: integer(),
16: sandboxes: DatabasePath.absoluteArrayColumn().notNull(),
17: commands: text({ mode: "json" }).$type<{ start?: string }>(),
18: })
19:
20: export const ProjectDirectoryTable = sqliteTable(
21: "project_directory",
22: {
23: project_id: text()
24: .$type<ProjectSchema.ID>()
25: ...
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
$ tsgo --noEmit
test/session-execution-local.test.ts(19,15): error TS2769: No overload matches this call.
The last overload gave the following error.
Object literal may only specify known properties, and 'id' does not exist in type '{ commands?: Placeholder<string, any> | SQL<unknown> | { start?: string | undefined; } | null | undefined; icon_color?: string | Placeholder<string, any> | SQL<...> | null | undefined; ... 9 more ...; worktree: Placeholder<...> | ... 1 more ... | (string & Brand<...>); }[]'.
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 3 pass 0 fail 4 expect() calls Ran 3 tests across 1 file. [2.44s]
The production adapter regression now passes all three lifecycle cases. I’m moving to candidate validation: first I’ll inspect the full diff and generated artifacts for accidental scope or schema drift, then run the owning package suites, migration checks, and standalone build without touching the running server.
[
{
"content": "Define V2 synthetic recovery ownership and discovery",
"status": "completed",
"priority": "high"
},
{
"content": "Implement interruption reconciliation and synthetic continuation",
"status": "completed",
"priority": "high"
},
{
"content": "Add restart and duplicate-claim regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Run package validation and isolated restart smoke",
"status": "in_progress",
"priority": "high"
},
{
"content": "Run independent correctness and security reviews",
"status": "pending",
"priority": "high"
}
]
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/session-projector.test.ts M packages/core/test/session-runner-tool-events.test.ts M packages/core/test/session-runner.test.ts M packages/opencode/src/server/routes/instance/httpapi/server.ts M packages/schema/src/session-event.ts M packages/server/src/routes.ts ?? packages/core/src/database/migration/20260914170650_session-recovery.ts ?? packages/core/src/database/migration/20260914214636_session-execution.ts ?? packages/core/src/database/migration/20260914222526_session-execution-phase.ts ?? packages/core/src/session/recovery.ts ?? ...
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 3 pass 0 fail 4 expect() calls Ran 3 tests across 1 file. [1336.00ms]
The diff audit shows generated schema churn is large but expected from adding two tables and one field; no API generation is required because public HTTP shapes did not change. I’m now inspecting the recovery projector and message lowering end to end, then I’ll run full package validation.
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, desc, eq, isNull } from "drizzle-orm"
4: import { DateTime, Effect, Layer } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionMessage } from "./message"
11: import { SessionProjector } from "./projector"
12: import { Prompt } from "./prompt"
13: import { SessionExecutionTable, SessionRecoveryTable, SessionMessageTable } from "./sql"
14:
15: const continuation = Prompt.make({ text: "continue" })
16:
17: export const recover = Effect.fn("SessionRecovery.recover")(function* () {
18: const { db } = yield* Database.Service
19: const events = yield* EventV2.Service
20: const execution = yield* ...
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
170: }),
171: )
172: },
173: "session.next.shell.ended": (event) => {
174: return Effect.gen(function* () {
175: const currentShell = yield* adapter.getCurrentShell(event.data.callID)
176: if (currentShell) {
177: yield* adapter.updateShell(
178: produce(currentShell, (draft) => {
179: draft.output = event.data.output
180: draft.time.completed = event.data.timestamp
181: }),
182: )
183: }
184: })
185: },
186: "session.next.step.started": (event) => {
187: return Effect.gen(function* () {
188: const currentAssistant = yield* adapter.getCurrentAssistant()
189: if (currentAssistant) {
190: yield* adapter.updateAssistant(
191: produce(currentAssistant, ...
Found 2 matches /home/byk/Code/opencode/packages/core/src/session/runner/to-llm-message.ts: Line 73: const reuseProviderMetadata = sameModel && message.error === undefined Line 132: case "synthetic":
<path>/home/byk/Code/opencode/packages/core/src/session/runner/to-llm-message.ts</path>
<type>file</type>
<content>
55: if (tool.state.status === "error") {
56: return ToolResultPart.make({
57: id: tool.id,
58: name: tool.name,
59: result:
60: tool.provider?.executed === true && tool.state.result !== undefined
61: ? tool.state.result
62: : { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
63: resultType: "error",
64: providerExecuted: tool.provider?.executed,
65: providerMetadata,
66: })
67: }
68: }
69:
70: const assistant = (message: SessionMessage.Assistant, model: Model) => {
71: const sameModel =
72: String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
73: const reuseProviderMetadata = sameModel && message.error === undefined
74: const content = message.content.flatMap((item): ContentPart[] => ...
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260914170650_session-recovery",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`
9: CREATE TABLE \`session_recovery\` (
10: \`session_id\` text NOT NULL,
11: \`assistant_message_id\` text NOT NULL,
12: \`continuation_message_id\` text NOT NULL,
13: \`time_created\` integer NOT NULL,
14: CONSTRAINT \`session_recovery_pk\` PRIMARY KEY(\`session_id\`, \`assistant_message_id\`),
15: CONSTRAINT \`fk_session_recovery_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE,
16: CONSTRAINT \`fk_session_recovery_assistant_message_id_session_message_id_fk\` FOREIGN KEY ...
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914214636_session-execution.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260914214636_session-execution",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`
9: CREATE TABLE \`session_execution\` (
10: \`id\` text PRIMARY KEY,
11: \`session_id\` text NOT NULL,
12: \`owner_id\` text NOT NULL,
13: \`assistant_message_id\` text,
14: \`time_created\` integer NOT NULL,
15: CONSTRAINT \`fk_session_execution_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
16: );
17: `)
18: yield* tx.run(`ALTER TABLE \`session_recovery\` ADD \`execution_id\` text NOT NULL;`)
19: yield* tx.run(`CREATE UNIQUE INDEX ...
The end-to-end audit confirms the intended lowering: unknown tools become tool-result errors, an abandoned assistant becomes an error Step, and a safe-boundary interruption becomes a synthetic user-visible recovery fact before queued continue. One migration concern is now resolved by ordering: the non-null phase is added immediately after the new execution table migration, before any runtime can create rows.
<path>/home/byk/Code/opencode/packages/schema/package.json</path>
<type>file</type>
<content>
1: {
2: "$schema": "https://json.schemastore.org/package.json",
3: "name": "@opencode-ai/schema",
4: "private": true,
5: "type": "module",
6: "license": "MIT",
7: "exports": {
8: ".": "./src/index.ts",
9: "./*": "./src/*.ts"
10: },
11: "scripts": {
12: "typecheck": "tsgo --noEmit"
13: },
14: "dependencies": {
15: "effect": "catalog:"
16: },
17: "devDependencies": {
18: "@tsconfig/bun": "catalog:",
19: "@types/bun": "catalog:",
20: "@typescript/native-preview": "catalog:"
21: }
22: }
(End of file - total 22 lines)
</content>
<path>/home/byk/Code/opencode/packages/server/package.json</path>
<type>file</type>
<content>
1: {
2: "$schema": "https://json.schemastore.org/package.json",
3: "name": "@opencode-ai/server",
4: "version": "1.18.30",
5: "private": true,
6: "type": "module",
7: "license": "MIT",
8: "exports": {
9: "./*": "./src/*.ts"
10: },
11: "scripts": {
12: "typecheck": "tsgo --noEmit"
13: },
14: "dependencies": {
15: "@opencode-ai/core": "workspace:*",
16: "@opencode-ai/protocol": "workspace:*",
17: "drizzle-orm": "catalog:",
18: "effect": "catalog:"
19: },
20: "devDependencies": {
21: "@tsconfig/bun": "catalog:",
22: "@types/bun": "catalog:",
23: "@typescript/native-preview": "catalog:"
24: }
25: }
(End of file - total 25 lines)
</content>
<path>/home/byk/Code/opencode/packages/opencode/package.json</path>
<type>file</type>
<content>
1: {
2: "$schema": "https://json.schemastore.org/package.json",
3: "version": "1.18.30",
4: "name": "opencode",
5: "type": "module",
6: "license": "MIT",
7: "private": true,
8: "scripts": {
9: "typecheck": "tsgo --noEmit",
10: "test": "bun test --timeout 30000 --only-failures",
11: "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
12: "bench:test": "bun run script/bench-test-suite.ts",
13: "profile:test": "bun run script/profile-test-files.ts",
14: "build": "bun run script/build.ts",
15: "build:server": "node script/build-server.mjs",
16: "dev": "bun run ./src/index.ts",
17: "dev:temporary": "bun run ./src/temporary.ts"
18: ...
Focused validation is clean. I’m running the full Core suite plus typechecks for Schema, Server, and OpenCode in parallel; after that I’ll build the standalone artifact and smoke it against isolated config, data, database, and loopback port.
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[22:44:05.548] ERROR (#27075): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/provider-groq.test.ts:
2307 | const parsedProviderOptions = await safeValidateTypes({
2308 | value: providerOptions[provider],
2309 | schema
2310 | });
2311 | if (!parsedProviderOptions.success) {
2312 | throw new InvalidArgumentError2({
^
AI_InvalidArgumentError: invalid groq provider options
argument: "providerOptions",
vercel.ai.error: true,
vercel.ai.error.AI_InvalidArgumentError: true,
at parseProviderOptions ...
The full Core suite ran 1,104 tests: 1,100 passed and four existing provider-option passthrough tests failed because installed Groq, Mistral, and xAI SDKs now reject unknown reasoning values. The recovery and execution tests passed; I’m treating those four as unrelated dependency drift and continuing package typechecks and build validation.
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit test/provider/transform.test.ts(3886,43): error TS2322: Type '"none"' is not assignable to type '"high" | "low" | "max" | "medium" | "xhigh" | undefined'.
The OpenCode package typecheck has one unrelated existing test fixture error: test/provider/transform.test.ts:3886 passes "none" where the installed provider type now allows "low" | "medium" | "high" | "xhigh" | "max". I’m not changing that dependency-drift test; the production packages affected by recovery typecheck cleanly.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
250: ),
251: )
252: .run()
253: .pipe(Effect.orDie, Effect.asVoid),
254: })
255: const withPublication = Semaphore.makeUnsafe(1).withPermit
256: const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
257: withPublication(publisher.publish(event, outputPaths))
258: let overflowFailure: ProviderErrorEvent | undefined
259: const providerStream = llm.stream(request).pipe(
260: Stream.runForEach((event) =>
261: Effect.gen(function* () {
262: if (overflowFailure || publisher.hasProviderError()) return
263: if (LLMEvent.is.providerError(event)) {
264: if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
265: overflowFailure = event
266: return
267: }
268: ...
The lifecycle audit found one candidate-critical branch: context-overflow compaction exits the physical attempt before any assistant output, but the new claim remains unknown unless the runner resets it. I’m tracing that sentinel through the outer loop now; if no reset exists, I’ll fix it so a crash after successful overflow recovery cannot be misreported as an uncertain provider outcome.
Found 6 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 158: | { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number }
Line 167: const continueAfterOverflowCompaction = (step: number) =>
Line 168: new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
Line 321: return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
Line 412: if (defect.transition._tag === "ContinueAfterOverflowCompaction")
Line 427: if (defect.transition._tag === "ContinueAfterOverflowCompaction")
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
404: step: number,
405: ) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError>
406:
407: const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
408: return yield* runTurnAttempt(sessionID, promotion, step).pipe(
409: Effect.catchDefect(
410: Effect.fnUntraced(function* (defect) {
411: if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
412: if (defect.transition._tag === "ContinueAfterOverflowCompaction")
413: return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
414: yield* Effect.yieldNow
415: return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
416: }),
417: ),
418: )
419: ...
The overflow sentinel has no reset. I’m fixing it at the exact safe boundary: after compaction succeeds and before scheduling the rebuilt physical attempt, the durable claim returns to safe with no assistant target. I’ll add a focused assertion to the existing overflow-recovery test so removing that reset fails deterministically.
Found 4 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 1212: it.effect("forces one compaction and retries after provider context overflow", () =>
Line 1230: { type: "compaction", summary: "## Objective\n- Recover overflow" },
Line 1264: it.effect("recovers once from a raw context overflow failure", () =>
Line 1286: { type: "compaction", summary: "## Objective\n- Recover raw overflow" },
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1190:
1191: const oversized = `OVERSIZED_BOUNDARY ${"x".repeat(4_500)} OVERSIZED_END`
1192: currentModel = compactModel
1193: requests.length = 0
1194: responses = [
1195: fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents,
1196: fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
1197: ]
1198: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: oversized }), resume: false })
1199: yield* session.resume(sessionID)
1200:
1201: expect(requests).toHaveLength(2)
1202: const summary = userTexts(requests[0])[0]
1203: const continuation = userTexts(requests[1])[0]
1204: expect(summary.match(/OVERSIZED_BOUNDARY/g)).toHaveLength(1)
1205: expect(summary).toContain(oversized)
1206: ...
Overflow recovery should reset the claim in the same durable transaction that records successful compaction, not in a later yield where a crash window remains. I’m inspecting compactAfterOverflow now to attach the reset to that durable boundary; this also gives the test a direct observable contract.
Found 5 matches
/home/byk/Code/opencode/packages/core/src/session/compaction.ts:
Line 178: const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) {
Line 242: return yield* compactAfterOverflow(input)
Line 246: compactAfterOverflow,
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 179: recoverOverflow?: typeof compaction.compactAfterOverflow,
Line 422: return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe(
<path>/home/byk/Code/opencode/packages/core/src/session/compaction.ts</path>
<type>file</type>
<content>
150: if (next > tokens) break
151: total = next
152: split = index
153: }
154: return {
155: head: conversation.slice(0, split).join("\n\n"),
156: recent: conversation.slice(split).join("\n\n"),
157: }
158: }
159:
160: export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) => {
161: const conversation = `Here is the conversation so far:\n\n<conversation>\n${input.context.join("\n\n")}\n</conversation>`
162: if (!input.previousSummary)
163: return [
164: conversation,
165: "Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.",
166: SUMMARY_TEMPLATE,
167: ].join("\n\n")
168: return [
169: conversation,
170: `Here is the summary of the conversation before the <conversation> ...
<path>/home/byk/Code/opencode/packages/core/src/session/compaction.ts</path>
<type>file</type>
<content>
1: export * as SessionCompaction from "./compaction"
2:
3: import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
4: import { DateTime, Effect, Stream } from "effect"
5: import type { Config } from "../config"
6: import type { EventV2 } from "../event"
7: import { SessionEvent } from "./event"
8: import { SessionMessage } from "./message"
9: import { SessionSchema } from "./schema"
10: import { Token } from "../util/token"
11:
12: const DEFAULT_BUFFER = 20_000
13: const DEFAULT_KEEP_TOKENS = 8_000
14: const TOOL_OUTPUT_MAX_CHARS = 2_000
15: const SUMMARY_OUTPUT_TOKENS = 4_096
16: const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response. ...
<path>/home/byk/Code/opencode/packages/core/src/session/compaction.ts</path>
<type>file</type>
<content>
57: type Entry = {
58: readonly seq: number
59: readonly message: SessionMessage.Message
60: }
61:
62: type Settings = {
63: readonly auto: boolean
64: readonly buffer: number
65: readonly tokens: number
66: }
67:
68: type Dependencies = {
69: readonly events: EventV2.Interface
70: readonly llm: {
71: readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
72: }
73: readonly config: readonly Config.Entry[]
74: }
75:
76: type Input = {
77: readonly sessionID: SessionSchema.ID
78: readonly entries: readonly Entry[]
79: readonly model: Model
80: readonly request: LLMRequest
81: }
82:
83: const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
84:
85: const truncate = (value: string) =>
86: value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
87:
88: export ...
The overflow boundary can be made atomic without widening the recovery event: compactAfterOverflow already commits Compaction.Ended. I’m adding an optional commit hook to that internal call and using it to reset the execution phase in the same transaction, so there is no crash window between durable compaction and the rebuilt attempt.
Success. Updated the following files: M packages/core/src/session/compaction.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/recovery.ts
The reset is now atomic with Compaction.Ended. I’m adding a deterministic crash-boundary regression by blocking the rebuilt provider attempt after compaction, verifying the durable execution row is safe, closing the owner scope, and running startup recovery; the resulting continuation must say only that the Session was interrupted, never that the prior provider outcome was unknown.
Found 63 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 3: LLMClient,
Line 9: type LLMClientShape,
Line 65: let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
Line 74: const client = Layer.succeed(
Line 75: LLMClient.Service,
Line 76: LLMClient.Service.of({
Line 80: if (responseStream) {
Line 81: const stream = responseStream
Line 82: responseStream = undefined
Line 87: : Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
Line 95: }) as unknown as LLMClientShape["stream"],
Line 322: responses = undefined
Line 324: responseStream = undefined
Line 511: responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
Line 533: responseStream = Stream.concat(
Line 577: responses = [
Line 619: responses = undefined
Line 641: responses = undefined
Line ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import {
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Model,
7: TransportReason,
8: InvalidRequestReason,
9: type LLMClientShape,
10: type LLMRequest,
11: } from "@opencode-ai/llm"
12: import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
13: import { Database } from "@opencode-ai/core/database/database"
14: import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
15: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
16: import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
17: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
18: import { EventV2 } from "@opencode-ai/core/event"
19: import { PermissionV2 } from "@opencode-ai/core/permission"
20: import { EventTable } from "@opencode-ai/core/event/sql"
21: import { Project } ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
300: id,
301: project_id: Project.ID.global,
302: slug: id,
303: directory: "/project",
304: title: "test",
305: version: "test",
306: })
307: .onConflictDoNothing()
308: .run()
309: .pipe(Effect.orDie)
310: })
311:
312: const setup = Effect.gen(function* () {
313: const { db } = yield* Database.Service
314: response = []
315: systemBaseline = "Initial context"
316: systemRemoved = false
317: systemUnavailable = false
318: systemLoadHook = Effect.void
319: modelResolveHook = Effect.void
320: currentModel = model
321: skillBaselines.clear()
322: responses = undefined
323: streamFailure = undefined
324: responseStream = undefined
325: streamGate = undefined
326: streamStarted = undefined
327: toolExecutionGate = undefined
328: toolExecutionsStarted = undefined
329: ...
/home/byk/Code/opencode/packages/core/test/session-compaction.test.ts
<path>/home/byk/Code/opencode/packages/core/test/session-compaction.test.ts</path>
<type>file</type>
<content>
1: import { expect, test } from "bun:test"
2: import { SessionCompaction } from "@opencode-ai/core/session/compaction"
3:
4: test("compaction prompt preserves detailed work state and relevant files", () => {
5: const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
6:
7: expect(prompt).toStartWith(
8: "Here is the conversation so far:\n\n<conversation>\nconversation history\n</conversation>",
9: )
10: expect(prompt.indexOf("</conversation>")).toBeLessThan(prompt.indexOf("Create a new anchored summary"))
11: expect(prompt).toContain("conversation history in the <conversation> tags above")
12: expect(prompt).toContain("## Work State\n### Completed")
13: expect(prompt).toContain("### Active")
14: expect(prompt).toContain("### Blocked")
15: expect(prompt).toContain("## Relevant Files")
16: })
17:
18: test("compaction prompt ...
The deeper call-order audit found a more important crash window than overflow: llm.stream(request) is currently invoked while constructing providerStream, before the execution row changes to unknown. Providers should be lazy, but the test client itself records the request eagerly, so the boundary is not guaranteed. I’m moving the sole stream call behind Stream.suspend, which makes the durable phase update happen before any provider implementation can dispatch.
No files found
Found 1 matches /home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Stream.ts: Line 902: export const suspend = <A, E, R>(stream: LazyArg<Stream<A, E, R>>): Stream<A, E, R> =>
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
The provider call is now forced behind the durable unknown marker. I’m strengthening the main runner suite by replacing its hand-built coordinator with the production SessionExecutionLocal.make; this makes every runner test exercise real execution-row creation and cleanup, and lets interruption tests assert the row reaches unknown before the provider gate opens.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
2880: [],
2881: ]
2882:
2883: const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
2884: let pending = yield* questions.list()
2885: while (pending.length === 0) {
2886: yield* Effect.yieldNow
2887: pending = yield* questions.list()
2888: }
2889: yield* questions.reject(pending[0]!.id)
2890: const exit = yield* Fiber.join(run)
2891:
2892: expect(exit._tag).toBe("Failure")
2893: if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
2894: expect(requests).toHaveLength(1)
2895: expect(yield* session.context(sessionID)).toMatchObject([
2896: { type: "user", text: "Ask then stop" },
2897: {
2898: type: "assistant",
2899: content: [
2900: {
2901: type: "tool",
2902: ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
The main runner layer now uses the production claim constructor, and the interrupted-tool regression asserts the physical attempt is durably unknown before interruption. I’m running Core typecheck and the runner, projector, adapter, and compaction suites together; this will catch any layer-sharing mistake immediately.
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 110 pass 0 fail 331 expect() calls Ran 110 tests across 5 files. [7.50s]
The integrated claim path now passes 110 focused tests. I’m checking formatting and the final lifecycle invariants next, including migration upgrade behavior and replay, before freezing the patch for independent reviews.
<path>/home/byk/Code/opencode</path> <type>directory</type> <entries> .dockerignore .editorconfig .git/ .gitattributes .github/ .gitignore .gitleaksignore .husky/ .lore.md .node-cache/ .opencode/ .oxlintrc.json .prettierignore .turbo/ .vscode/ .zed/ AGENTS.md artifacts/ bun.lock bunfig.toml CONTEXT.md CONTRIBUTING.md flake.lock flake.nix github/ infra/ install LICENSE nix/ node_modules/ package.json packages/ patches/ perf.data perf.data.old perf/ pnpm-lock.yaml pnpm-workspace.yaml README.ar.md README.bn.md README.br.md README.bs.md README.da.md README.de.md README.es.md README.fr.md README.gr.md README.it.md README.ja.md README.ko.md README.md README.no.md README.pl.md README.ru.md README.th.md README.tr.md README.uk.md README.vi.md README.zh.md README.zht.md screenshot-uk.png script/ sdks/ SECURITY.md specs/ sst-env.d.ts sst.config.ts STATS.md tsconfig.json turbo.json (70 entries) </entries>
/home/byk/Code/opencode/.prettierignore /home/byk/Code/opencode/packages/ui/src/assets/icons/file-types/prettier.svg
Checking formatting... [warn] packages/core/src/session/recovery.ts [warn] packages/core/src/session/runner/llm.ts [warn] packages/core/test/session-projector.test.ts [warn] packages/core/test/session-runner.test.ts [warn] Code style issues found in 4 files. Run Prettier with --write to fix.
Prettier reports four recovery-owned files. I’m applying the repository formatter only to those files, then rerunning typecheck and focused tests to prove formatting did not alter behavior.
packages/core/src/session/recovery.ts 148ms packages/core/src/session/runner/llm.ts 160ms packages/core/test/session-projector.test.ts 183ms packages/core/test/session-runner.test.ts 554ms
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 110 pass 0 fail 331 expect() calls Ran 110 tests across 5 files. [7.91s] Reading config file '/tmp/opencode-core-migration-check-o2VTIN/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-o2VTIN/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-o2VTIN/full/20260914225542_schema/migration.sql 🚀
The provider dispatch and overflow crash windows are now closed, and 110 focused tests pass with the production execution adapter. I’m running repository lint and package build checks now; the only known unrelated failures remain the four provider-option tests and one OpenCode provider fixture type error caused by installed SDK validation drift.
...output truncated...
Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a224dbd2001q3hwNK7rD3sxQm
`----
! typescript-eslint(no-unnecessary-type-assertion): This assertion is unnecessary since it does not change the type of the expression.
,-[packages/app/e2e/regression/session-timeline-shell-outline.spec.ts:203:94]
202 | if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
203 | columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
: ^^^^^^^^^^^^^^^^^^^^^|^^^^^^^^^^^^^^^^^^^^^
: `-- This expression already has the type 'number'
204 | }
`----
! typescript-eslint(no-unnecessary-type-assertion): This assertion is unnecessary since it does not change the type of the expression. ...
Repository lint scanned 3,297 files and failed with 4,924 existing warnings plus two errors; the displayed output was truncated before the errors. I’m locating those exact errors in the saved output to separate candidate issues from unrelated repository debt before continuing.
Found 100 matches (more matches available)
/home/byk/.local/share/opencode/tool-output/tool_07fe9d06b001oh5dIHFGlPLFEF:
Line 1: plugins/followup.ts(52,12): error TS2322: Type 'Struct<{ readonly delay: String; readonly message: String; }>' is not assignable to type 'ValueSchema<any>'.
Line 40: plugins/followup.ts(53,13): error TS2322: Type 'String' is not assignable to type 'ValueSchema<any> | undefined'.
Line 84: plugins/followup.ts(89,12): error TS2322: Type 'Struct<{}>' is not assignable to type 'ValueSchema<any>'.
Line 123: plugins/followup.ts(90,13): error TS2322: Type 'String' is not assignable to type 'ValueSchema<any> | undefined'.
Line 167: plugins/followup.ts(108,12): error TS2322: Type 'Struct<{ readonly id: String; }>' is not assignable to type 'ValueSchema<any>'.
Line 206: plugins/followup.ts(109,13): error TS2322: Type 'String' is not assignable to type 'ValueSchema<any> | undefined'.
Line 250: plugins/pty.ts(194,12): error TS2322: Type 'Struct<{ ...
<path>/home/byk/.local/share/opencode/tool-output/tool_0a224dbd2001q3hwNK7rD3sxQm</path>
<type>file</type>
<content>
1: $ oxlint
2:
3: ! eslint(no-unused-vars): Identifier 'FetchHttpClient' is imported but never used.
4: ,-[packages/core/src/skill/discovery.ts:5:10]
5: 4 | import { Context, Effect, Layer, Schedule, Schema } from "effect"
6: 5 | import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
7: : ^^^^^^^|^^^^^^^
8: : `-- 'FetchHttpClient' is imported here
9: 6 | import { FSUtil } from "../fs-util"
10: `----
11: help: Consider removing this import.
12:
13: ! eslint(no-unused-vars): Identifier 'AgentV2' is imported but never used.
14: ,-[packages/core/src/session/sql.ts:15:10]
15: 14 | import type { SystemContext } from "../system-context/index"
16: 15 | import { AgentV2 } from "../agent"
17: : ^^^|^^^
18: : `-- 'AgentV2' is imported here
19: 16 | ...
Found 100 matches (more matches available)
/home/byk/.local/share/opencode/tool-output/tool_0819941320017FmDVo0Fg4tpFV:
Line 5: × delivers a resolved sparse continuation candidate exactly once 24ms
Line 16: AssertionError: expected 'event: response.created\ndata: {"type…' to contain '{"path":"xxxxxxxxxxxxxxxxxxxxxxxxxxxx…'
/home/byk/.local/share/opencode/tool-output/tool_081976c5f0015abE2psizW3Mh6:
Line 163: 381: void writing.catch((error: unknown) => {
Line 321: 1469 1452: const onSocketError = (error: Error): void => abort(error);
Line 616: 396: let parseError: unknown;
Line 971: 315: const onError = (error: Error): void => {
/home/byk/.local/share/opencode/tool-output/tool_08187bb3b001P6f58gmPkBF6ew:
Line 6: × rejects a BOM after leading whitespace on the streaming path 28ms
Line 7: × never logs an item_reference id 4ms
Line 12: AssertionError: promise resolved "{ protocol: 'openai-responses', …(9) }" ...
<path>/home/byk/.local/share/opencode/tool-output/tool_0a224dbd2001q3hwNK7rD3sxQm</path>
<type>file</type>
<content>
4850: help: Consider removing this import.
4851:
4852: ! eslint(no-unused-vars): Identifier 'Permission' is imported but never used.
4853: ,-[packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts:2:10]
4854: 1 | import { PermissionV1 } from "@opencode-ai/core/v1/permission"
4855: 2 | import { Permission } from "@/permission"
4856: : ^^^^^|^^^^
4857: : `-- 'Permission' is imported here
4858: 3 | import { Schema } from "effect"
4859: `----
4860: help: Consider removing this import.
4861:
4862: ! eslint(no-unused-vars): Identifier 'Config' is imported but never used.
4863: ,-[packages/opencode/src/server/routes/instance/httpapi/groups/config.ts:1:10]
4864: 1 | import { Config } from "@/config/config"
4865: : ^^^|^^
4866: : `-- 'Config' is imported here
4867: 2 | import { ...
Found 2 matches /home/byk/.local/share/opencode/tool-output/tool_0a224dbd2001q3hwNK7rD3sxQm: Line 4871: x '0'-prefixed octal literals and octal escape sequences are deprecated Line 51178: x typescript(tsconfig-error): Invalid tsconfig
<path>/home/byk/.local/share/opencode/tool-output/tool_0a224dbd2001q3hwNK7rD3sxQm</path>
<type>file</type>
<content>
51160: 259 |
51161: 260 | await expect(Filesystem.readBytes(filepath)).rejects.toThrow()
51162: : ^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^|^^^^^^^^^^^^^^^^^^^^^^^^^^^
51163: : | `-- This expression is not Promise-like
51164: 261 | })
51165: `----
51166: help: Remove `await` if the value is synchronous, or change the expression to return a Promise or Thenable before awaiting it.
51167:
51168: ! typescript-eslint(await-thenable): Unexpected `await` of a non-Promise (non-"Thenable") value.
51169: ,-[packages/opencode/test/util/timeout.test.ts:19:5]
51170: 18 |
51171: 19 | await expect(withTimeout(slowPromise, 50)).rejects.toThrow("Operation timed out after 50ms")
51172: : ^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^|^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
51173: : | ...
The two repository lint errors are unrelated: an octal escape in packages/session-ui and a missing Bun type definition in another tsconfig. I’m running lint only across recovery-owned files to establish a clean candidate result, then building the standalone executable.
...output truncated...
Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a2274af200162Zwbti9EI0J7g
42 | })
`----
! typescript-eslint(consistent-return): Function expected no return value.
,-[packages/core/src/session/message-updater.ts:49:9]
48 | const shell = state.messages[index]
49 | return shell?.type === "shell" ? shell : undefined
: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
50 | })
`----
! typescript-eslint(no-unsafe-type-assertion): Unsafe type assertion: type 'Record<string, unknown>' is more narrow than the original type.
,-[packages/core/src/session/runner/publish-llm-event.ts:33:75]
32 | const record = (value: unknown): Record<string, unknown> =>
33 | typeof value === "object" && value !== null && !Array.isArray(value) ? ...
Recovery-owned lint is clean with zero errors; its 69 warnings are existing rules already present in touched files, not new blocking errors. I’m building the exact Linux standalone artifact now, then I’ll run it under disposable HOME/XDG/database paths on an unused loopback port and verify startup, /global/health, and clean termination.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a22941e0001JnrzI0rECkYEER dist/assets/alert-05-D2gbGoRH.aac 5.21 kB dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 ...
The exact linux-x64 standalone artifact built successfully and its built-in --version smoke passed. I’m now running the server itself in a disposable environment and will verify the intended binary serves health after migrations, then terminate only that isolated instance.
Found 4 matches
/home/byk/Code/opencode/packages/core/src/database/database.ts:
Line 44: if (Flag.OPENCODE_DB) {
Line 45: if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
Line 46: return join(Global.Path.data, Flag.OPENCODE_DB)
/home/byk/Code/opencode/packages/core/src/flag/flag.ts:
Line 47: OPENCODE_DB: process.env["OPENCODE_DB"],
(no output) <shell_metadata> shell tool terminated command after exceeding timeout 120000 ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds. </shell_metadata>
The isolated smoke command timed out without printing its checkpoint, so I will not infer success. I’m inspecting only the disposable PID, log, health response, and database under /tmp/opencode-recovery-smoke; if that instance survived the shell timeout, I’ll terminate that exact PID and report the precise startup or shutdown blocker.
<path>/tmp/opencode-recovery-smoke/server.log</path> <type>file</type> <content> 1: opencode server listening on http://127.0.0.1:41873 (End of file - total 1 lines) </content>
<path>/tmp/opencode-recovery-smoke/health.json</path> <type>file</type> <content> (End of file - total 0 lines) </content>
2320568 2275722 /usr/bin/bash -c ps -eo pid=,ppid=,args= | rg '/tmp/opencode-recovery-smoke|opencode-linux-x64/bin/opencode serve.*41873' || true; curl --silent --show-error --max-time 5 --user opencode:smoke-password --write-out '\nstatus=%{http_code}\n' http://127.0.0.1:41873/global/health
2320570 2320568 rg /tmp/opencode-recovery-smoke|opencode-linux-x64/bin/opencode serve.*41873
status=000
curl: (7) Failed to connect to 127.0.0.1 port 41873 after 0 ms: Couldn't connect to server
The timeout killed the disposable server, and no matching process remains. Startup succeeded, but /global/health was the wrong route, so the script entered its blocking failure cleanup. I’m locating the exact health path and will rerun with bounded cleanup that always terminates the isolated PID.
Found 100 matches (more matches available)
/home/byk/Code/opencode/packages/codemode/test/openapi.test.ts:
Line 192: expect(toolAt(result.tools, "v2.health.get")).not.toBeUndefined()
Line 365: const health = toolAt(result.tools, "v2.health.get")
Line 366: const healthInput = isRecord(health) ? health.input : undefined
Line 367: expect(healthInput).toMatchObject({ type: "object", properties: {} })
Line 368: const input = isRecord(healthInput) ? healthInput : {}
Line 381: return await tools.$codemode.search({ query: "global health", namespace: "opencode", limit: 1 })
Line 392: path: "tools.opencode.v2.health.get",
Line 397: expect(JSON.stringify(result.value)).toContain("healthy: true")
/home/byk/Code/opencode/packages/protocol/src/groups/health.ts:
Line 4: export const HealthGroup = HttpApiGroup.make("server.health").add(
Line 5: HttpApiEndpoint.get("health.get", "/api/health", {
Line 6: success: ...
<path>/home/byk/Code/opencode/packages/opencode/test/cli/serve/serve-process.test.ts</path>
<type>file</type>
<content>
1: // Subprocess integration tests for `opencode serve`. Spawns the real CLI in
2: // headless mode and exercises it over HTTP — this is the only test tier that
3: // catches bugs spanning argv → server boot → routing → instance loading.
4: //
5: // `serve` is long-lived: the harness returns a handle (url/port/kill/exited)
6: // and kills the process when the test scope closes. The OS-assigned port is
7: // parsed off the "listening on http://..." line.
8: import { describe, expect } from "bun:test"
9: import { Effect } from "effect"
10: import { HttpClient } from "effect/unstable/http"
11: import { cliIt } from "../../lib/cli-process"
12:
13: describe("opencode serve (subprocess)", () => {
14: // Smoke test: server starts, binds a port, and /global/health responds.
15: // If this fails, all other serve tests likely will too — debug here first.
16: ...
The route test confirms /global/health is unauthenticated. I’m rerunning the exact artifact with bounded curl retries and a shell trap that always terminates the disposable server, so the smoke cannot hang again.
curl: (7) Failed to connect to 127.0.0.1 port 41874 after 0 ms: Couldn't connect to server curl: (7) Failed to connect to 127.0.0.1 port 41874 after 0 ms: Couldn't connect to server curl: (22) The requested URL returned error: 401 pid=2321088 health= shutdown_exit=143 db_exists=yes
The second smoke proved the built server migrated its isolated database and bound the port, but the health request returned 401; the script then treated expected SIGTERM code 143 as failure. I’m checking the server’s exact Basic-auth username and rerunning once with the correct credential, while accepting only 200 health and 143 from the deliberate shutdown.
Found 41 matches
/home/byk/Code/opencode/packages/opencode/src/server/shared/pty-ticket.ts:
Line 7: // Auth middleware skips Basic Auth when this matches; the PTY connect handler
/home/byk/Code/opencode/packages/opencode/src/server/auth.ts:
Line 18: password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option),
Line 37: const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD
Line 41: return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
Line 45: const authorization = header(credentials)
Line 46: if (!authorization) return undefined
Line 47: return { Authorization: authorization }
/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/server.ts:
Line 78: authorizationLayer,
Line 79: authorizationRouterMiddleware,
Line 82: } from "./middleware/authorization"
Line 137: const authOnlyRouterLayer = ...
<path>/home/byk/Code/opencode/packages/opencode/src/server/auth.ts</path>
<type>file</type>
<content>
1: export * as ServerAuth from "./auth"
2:
3: import { ConfigService } from "@/effect/config-service"
4: import { Flag } from "@opencode-ai/core/flag/flag"
5: import { Config as EffectConfig, Context, Option, Redacted } from "effect"
6:
7: export type Credentials = {
8: password?: string
9: username?: string
10: }
11:
12: export type DecodedCredentials = {
13: readonly username: string
14: readonly password: Redacted.Redacted
15: }
16:
17: export class Config extends ConfigService.Service<Config>()("@opencode/ServerAuthConfig", {
18: password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option),
19: username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")),
20: }) {}
21:
22: export type Info = Context.Service.Shape<typeof Config>
23:
24: export function required(config: Info) {
25: return ...
curl: (7) Failed to connect to 127.0.0.1 port 41875 after 0 ms: Couldn't connect to server
curl: (7) Failed to connect to 127.0.0.1 port 41875 after 0 ms: Couldn't connect to server
pid=2321463 health={"healthy":true,"version":"0.0.0-byk/cumulative-202609142302"}
shutdown_exit=143 db_exists=yes
The artifact health smoke passed with isolated state and clean deliberate shutdown. I’m adding one stronger artifact test: seed a stale safe execution row in that disposable database, restart the built server, and verify startup atomically removes the claim and creates exactly one recovery fact plus one synthetic continuation without touching any live data or service.
/usr/bin/sqlite3
CREATE TABLE `session` (
`id` text PRIMARY KEY,
`project_id` text NOT NULL,
`workspace_id` text,
`parent_id` text,
`slug` text NOT NULL,
`directory` text NOT NULL,
`path` text,
`title` text NOT NULL,
`version` text NOT NULL,
`share_url` text,
`summary_additions` integer,
`summary_deletions` integer,
`summary_files` integer,
`summary_diffs` text,
`metadata` text,
`cost` real DEFAULT 0 NOT NULL,
`tokens_input` integer DEFAULT 0 NOT NULL,
`tokens_output` integer DEFAULT 0 NOT NULL,
`tokens_reasoning` integer DEFAULT 0 NOT NULL,
`tokens_cache_read` integer DEFAULT 0 NOT NULL,
`tokens_cache_write` integer DEFAULT 0 NOT NULL,
`revert` text,
`permission` text,
`agent` text,
`model` text,
`time_created` integer NOT ...
(no output)
CREATE TABLE `session_execution` (
`id` text PRIMARY KEY,
`session_id` text NOT NULL,
`owner_id` text NOT NULL,
`assistant_message_id` text,
`phase` text NOT NULL,
`time_created` integer NOT NULL,
CONSTRAINT `fk_session_execution_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
CREATE UNIQUE INDEX `session_execution_session_idx` ON `session_execution` (`session_id`);
CREATE INDEX `session_execution_owner_idx` ON `session_execution` (`owner_id`);
CREATE TABLE `session_recovery` (
`session_id` text NOT NULL,
`execution_id` text NOT NULL,
`assistant_message_id` text NOT NULL,
`continuation_message_id` text NOT NULL,
`time_created` integer NOT NULL,
CONSTRAINT `session_recovery_pk` PRIMARY KEY(`session_id`, `assistant_message_id`),
CONSTRAINT `fk_session_recovery_session_id_session_id_fk` ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, desc, eq, isNull } from "drizzle-orm"
4: import { DateTime, Effect, Layer } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionMessage } from "./message"
11: import { SessionProjector } from "./projector"
12: import { Prompt } from "./prompt"
13: import { SessionExecutionTable, SessionRecoveryTable, SessionMessageTable } from "./sql"
14:
15: const continuation = Prompt.make({ text: "continue" })
16:
17: export const recover = Effect.fn("SessionRecovery.recover")(function* () {
18: const { db } = yield* Database.Service
19: const events = yield* EventV2.Service
20: const execution = yield* ...
The disposable database schema confirms a safe execution can be seeded without an assistant projection, which is the exact crash-before-provider-output case discovery alone misses. I’m checking the canonical global project and Session ID shapes, then I’ll seed one valid Session plus one safe claim and prove the built server creates one recovery event and one continuation across two restarts.
No files found
File not found: /home/byk/Code/opencode/packages/core/src/project/index.ts
/home/byk/Code/opencode/packages/core/src/project.ts
Found 4 matches
/home/byk/Code/opencode/packages/core/src/project.ts:
Line 14: export const ID = ProjectSchema.ID
Line 15: export type ID = ProjectSchema.ID
Line 112: if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
Line 118: id: id ?? ID.global,
No files found
/home/byk/Code/opencode/packages/schema/src/project.ts /home/byk/Code/opencode/packages/schema/src/project-id.ts /home/byk/Code/opencode/packages/schema/src/project-directories.ts /home/byk/Code/opencode/packages/schema/src/project-copy.ts
<path>/home/byk/Code/opencode/packages/schema/src/project-id.ts</path>
<type>file</type>
<content>
1: import { Schema } from "effect"
2: import { statics } from "./schema"
3:
4: export const ProjectID = Schema.String.pipe(
5: Schema.brand("Project.ID"),
6: statics((schema) => ({ global: schema.make("global") })),
7: )
8: export type ProjectID = typeof ProjectID.Type
(End of file - total 8 lines)
</content>
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
1: import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
2: import * as DatabasePath from "../database/path"
3: import { ProjectTable } from "../project/sql"
4: import type { SessionMessage } from "./message"
5: import type { Prompt } from "./prompt"
6: import type { SessionInput } from "./input"
7: import type { Snapshot } from "../snapshot"
8: import { PermissionV1 } from "../v1/permission"
9: import { ProjectV2 } from "../project"
10: import type { SessionSchema } from "./schema"
11: import type { MessageID, PartID, SessionV1 } from "../v1/session"
12: import { WorkspaceV2 } from "../workspace"
13: import { Timestamps } from "../database/schema.sql"
14: import type { SystemContext } from "../system-context/index"
15: import { AgentV2 } from "../agent"
16: import type { Revert } from "@opencode-ai/schema/revert"
17:
18: type ...
The canonical global project ID is global, and the disposable schema accepts the exact crash-before-output case: a valid Session with one safe execution and no assistant row. I’m seeding that state now, restarting the built artifact twice, and checking that recovery remains exactly-once across both startups.
before|1|0|0
curl: (7) Failed to connect to 127.0.0.1 port 41876 after 0 ms: Couldn't connect to server
curl: (7) Failed to connect to 127.0.0.1 port 41876 after 0 ms: Couldn't connect to server
curl: (7) Failed to connect to 127.0.0.1 port 41876 after 0 ms: Couldn't connect to server
after-first|0|1|1|1
curl: (7) Failed to connect to 127.0.0.1 port 41877 after 0 ms: Couldn't connect to server
curl: (7) Failed to connect to 127.0.0.1 port 41877 after 0 ms: Couldn't connect to server
curl: (7) Failed to connect to 127.0.0.1 port 41877 after 0 ms: Couldn't connect to server
after-second|0|1|1|1
{"text":"continue"}|queue
The built artifact passed the restart contract: first startup changed execution/recovery/input/event counts from 1/0/0/0 to 0/1/1/1; the second startup left them at 0/1/1/1, and the sole admitted input is {"text":"continue"} with queue delivery. I’m freezing the source and artifact evidence now, then launching separate read-only correctness and security reviews against that exact patch.
[
{
"content": "Define V2 synthetic recovery ownership and discovery",
"status": "completed",
"priority": "high"
},
{
"content": "Implement interruption reconciliation and synthetic continuation",
"status": "completed",
"priority": "high"
},
{
"content": "Add restart and duplicate-claim regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Run package validation and isolated restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Run independent correctness and security reviews",
"status": "in_progress",
"priority": "high"
}
]
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session/compaction.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/session-projector.test.ts M packages/core/test/session-runner-tool-events.test.ts M packages/core/test/session-runner.test.ts M packages/opencode/src/server/routes/instance/httpapi/server.ts M packages/schema/src/session-event.ts M packages/server/src/routes.ts ?? packages/core/src/database/migration/20260914170650_session-recovery.ts ?? packages/core/src/database/migration/20260914214636_session-execution.ts ?? packages/core/src/database/migration/20260914222526_session-execution-phase.ts ?? ...
The first freeze exposed that a plain git diff omits the five untracked recovery files, so its hash is not valid review evidence. I’m correcting the gate: first I’ll run the database migration regression on a pre-change schema, then commit only the recovery files plus required .lore.md maintenance, leaving AGENTS.md and both perf captures untouched; reviewers will inspect the resulting immutable commit.
/home/byk/Code/opencode/packages/core/test/database-migration.test.ts
bun test v1.3.14 (0d9b296a) 19 pass 0 fail 41 expect() calls Ran 19 tests across 1 file. [11.30s]
The migration suite passes all 19 upgrade tests. I’m performing the required pre-commit inspection now, then I’ll create one conventional recovery commit from the validated source and generated migration artifacts while excluding AGENTS.md, perf.data, and perf.data.old.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session/compaction.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/session-projector.test.ts M packages/core/test/session-runner-tool-events.test.ts M packages/core/test/session-runner.test.ts M packages/opencode/src/server/routes/instance/httpapi/server.ts M packages/schema/src/session-event.ts M packages/server/src/routes.ts ?? packages/core/src/database/migration/20260914170650_session-recovery.ts ?? packages/core/src/database/migration/20260914214636_session-execution.ts ?? packages/core/src/database/migration/20260914222526_session-execution-phase.ts ?? ...
...output truncated...
Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a2339be3001U0eRgV7XsEPL7M
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
- "columns": ["id"],
+ "columns": [
+ "id"
+ ],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
- "columns": ["id"],
+ "columns": [
+ "id"
+ ],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
- "columns": ["id"],
+ "columns": [
+ "id"
+ ],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
- "columns": ["id"],
+ "columns": [
+ "id"
+ ],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
...
ab9408c81c feat(app): adapt vertical tab density 2e9407768d fix(server): bound Node shutdown 2c92569e62 fix(app): prevent stale WebUI asset loads b98c698390 feat(server): add Fossilize standalone builds a5a9921d9f feat(app): resizable/collapsible vertical tab rail + review-panel toggle 1124689311 feat(app): add optional vertical tab rail d23a17b77f fix(app): show worktree selector on prod and label session tabs by worktree f688e4fd05 fix(app): suppress reconnect toast flood and mobile bottom-row cutoff 0319dc8244 fix(app): faster message loading, subtle sync indicator, stable composer caret e5980d80e6 fix(app): loading indicators and faster return-from-idle recovery
The pre-commit diff matches the validated design and preserves dependency direction: Core owns recovery; Server only composes it. I’m staging the exact recovery set now, including mandatory .lore.md, and excluding unrelated AGENTS.md and perf captures; I’ll inspect the staged tree and commit it as feat(core): recover interrupted sessions.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts A packages/core/src/database/migration/20260914170650_session-recovery.ts A packages/core/src/database/migration/20260914214636_session-execution.ts A packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session/compaction.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts A packages/core/src/session/recovery.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts A packages/core/test/session-execution-local.test.ts M packages/core/test/session-projector.test.ts M packages/core/test/session-runner-tool-events.test.ts M packages/core/test/session-runner.test.ts M ...
[byk/cumulative dc661f30cb] feat(core): recover interrupted sessions 22 files changed, 1372 insertions(+), 160 deletions(-) create mode 100644 packages/core/src/database/migration/20260914170650_session-recovery.ts create mode 100644 packages/core/src/database/migration/20260914214636_session-execution.ts create mode 100644 packages/core/src/database/migration/20260914222526_session-execution-phase.ts create mode 100644 packages/core/src/session/recovery.ts create mode 100644 packages/core/test/session-execution-local.test.ts
The validated source is now immutable at dc661f30cb, parent ab9408c81c; only unrelated AGENTS.md and perf captures remain outside the commit. I’m recording the exact commit patch hash and confirming the built artifact still matches the frozen source, then the two reviewers will inspect only immutable Git objects and the named artifact hash.
M AGENTS.md ?? perf.data ?? perf.data.old ab9408c81cc9916177641ae8e9f08624fc27b5b9 dc661f30cb2055ad6248fdc59b14998d834497be e414f577c79eab2ae49be387b2f6fb48b013f9d8a61618d5298d62229bf8e4eb /tmp/opencode-recovery-dc661f30cb.patch 70a250ea7a1f6dba86d329cf09f728db00af41035975d3fb38a548ff7c9003d2 packages/opencode/dist/opencode-linux-x64/bin/opencode dc661f30cb feat(core): recover interrupted sessions .lore.md | 132 +++--- packages/core/schema.json | 444 ++++++++++++++++++--- packages/core/src/database/migration.gen.ts | 3 + .../migration/20260914170650_session-recovery.ts | 25 ++ .../migration/20260914214636_session-execution.ts | 26 ++ .../20260914222526_session-execution-phase.ts | 11 + packages/core/src/database/schema.gen.ts | 32 ++ packages/core/src/session/compaction.ts | 21 +- packages/core/src/session/execution/local.ts | 68 +++- ...
<task id="ses_f5dcb4fe7ffevQTqO0zOwZAs3s" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f5dcb0da7ffelcCoeztugZmdoU" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Two independent frozen reviews are running against exact commit dc661f30cb, parent ab9408c81c, patch hash e414f577…, and artifact hash 70a250ea…. I’ll act on every material finding, revalidate, refreeze, and replace both reviews if the patch changes.
Awaiting the independent correctness and security review results.
PASS
ab9408c81cc9916177641ae8e9f08624fc27b5b9dc661f30cb2055ad6248fdc59b14998d834497begit merge-base returned the stated base; ancestry passed.git diff --check passed.git diff --binary <base> <head> | sha256sum returned the required e414f577c79eab2ae49be387b2f6fb48b013f9d8a61618d5298d62229bf8e4eb.git show <head>:<path>..lore.md. It contains no binary blob.The required repo-setup skill was unavailable. I instead read the immutable HEAD copies of applicable AGENTS.md and CONTRIBUTING.md.
The built artifact is not an immutable object and was not read. Exact Git result:
fatal: path 'packages/opencode/dist/opencode-linux-x64/bin/opencode' exists on disk, but not in 'dc661f30cb2055ad6248fdc59b14998d834497be'
saferunTurnAttempt invokes automatic compaction before changing the execution phase:
packages/core/src/session/runner/llm.ts:224-225unknown only at packages/core/src/session/runner/llm.ts:304-312.dependencies.llm.stream(...) at packages/core/src/session/compaction.ts:202-211.Therefore yes, a provider implementation can dispatch before the phase becomes unknown. The main provider stream is correctly deferred with Stream.suspend at llm.ts:259, but proactive compaction bypasses that protection.
A crash during the compaction request leaves a safe claim. ...
The correctness review returned DO-NOT-MERGE with four substantive lifecycle blockers: compaction dispatch can occur while the claim says safe; malformed or incomplete provider termination can remove an unsettled claim; post-step-finish user decline can preserve a false claim; and recovery can commit a continuation but crash before its advisory wake, leaving it dormant. I will wait for the active security review, then fix the combined findings against a new candidate rather than changing files while that reviewer still inspects the frozen commit.
The correctness gate rejected dc661f30cb. The security review is still active; once it completes, I’ll address both reports, rerun all regressions and artifact smoke tests, then freeze a new commit for replacement reviews.
ab9408c81cc9916177641ae8e9f08624fc27b5b9dc661f30cb2055ad6248fdc59b14998d834497bee414f577c79eab2ae49be387b2f6fb48b013f9d8a61618d5298d62229bf8e4eb — exact match.git diff --check: passed.git show.owner_id and time_created are recorded, but recovery ignores both:
packages/core/src/session/execution/local.ts:17-37 creates a process owner and inserts its execution claim.packages/core/src/session/recovery.ts:21-24 selects every execution row without checking owner liveness or age.packages/core/src/session/recovery.ts:66-80 deletes the row without proving its owner died.packages/core/src/session/recovery.ts:33-35 deletes by execution ID alone.A second server can therefore classify the first server’s active execution as abandoned, delete its claim, inject a continuation, and start another drain.
The original runner is not fenced:
packages/core/src/session/runner/llm.ts:236-253packages/core/src/session/runner/llm.ts:306-312packages/core/src/session/runner/llm.ts:325-333packages/core/src/session/runner/llm.ts:391-402These mutations match only Session or assistant IDs. They never match the execution ID or owner, and they never require one affected row. A stale runner continues to llm.stream after a zero-row update. ...
Both gates rejected the frozen candidate. I’m fixing the shared blockers first: fence every claim mutation by execution ID, move compaction under the unknown-outcome boundary, make every provider exit settle or preserve the claim, clear deliberate user-decline claims, and redrive committed recovery inputs on startup. I’ll also narrow the recovery event so replay cannot choose arbitrary prompts, delivery modes, errors, or cross-Session message references.
<skill_content name="effect">
# Skill: effect
# Effect
This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
## Source Of Truth
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
3. Also inspect existing repo code for local house style before introducing new patterns.
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
## Guidelines
- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
- Use `Effect.gen(function* () { ... })` for multi-step workflows.
- Use `Effect.fn("Name")` or ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
180: ) {
181: const session = yield* getSession(sessionID)
182: if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
183: return yield* Effect.interrupt
184: const agent = yield* agents.select(session.agent)
185: const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
186: const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
187: let needsContinuation = false
188: let currentStep = step
189: if (promotion) {
190: const cutoff = yield* EventV2.latestSequence(db, session.id)
191: let promoted = 0
192: if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
193: if (promotion === "queue") {
194: promoted += ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, eq } from "drizzle-orm"
2: import { Cause, Effect, Exit, Layer } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionSchema } from "../schema"
9: import { SessionStore } from "../store"
10: import { SessionExecution } from "../execution"
11: import { SessionExecutionTable } from "../sql"
12:
13: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
14: export const make = Effect.fnUntraced(function* (input: {
15: readonly run: (sessionID: SessionSchema.ID, force: boolean) => Effect.Effect<void, SessionRunner.RunError>
16: }) {
17: const { db } = ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
60: readonly assistantMessageID: SessionMessage.ID
61: readonly name: string
62: inputEnded: boolean
63: called: boolean
64: settled: boolean
65: providerExecuted: boolean
66: providerMetadata?: ProviderMetadata
67: }
68: >()
69: const timestamp = DateTime.now
70: let assistantMessageID: SessionMessage.ID | undefined
71: let assistantActive = false
72: let assistantFailed = false
73: let providerFailed = false
74: let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
75:
76: const startAssistant = Effect.fnUntraced(function* () {
77: if (assistantMessageID !== undefined) return assistantMessageID
78: const created = SessionMessage.ID.create()
79: assistantMessageID = created
80: assistantActive = true
81: yield* events.publish(
82: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
230: const interruptAssistant = Effect.fnUntraced(function* (message: string) {
231: if (assistantFailed) return
232: yield* flush()
233: const assistantMessageID = yield* startAssistant()
234: assistantActive = false
235: assistantFailed = true
236: yield* events.publish(SessionEvent.Step.Interrupted, {
237: sessionID: input.sessionID,
238: timestamp: yield* timestamp,
239: assistantMessageID,
240: error: { type: "unknown", message },
241: })
242: })
243:
244: const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
245: message: string,
246: hostedOnly = false,
247: ) {
248: for (const [callID, tool] of tools) {
249: if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
250: tool.settled = true
251: yield* ...
The security report confirms the rejected design needs true durable owner leases, not just claim IDs. I’m tracing the runner interface and startup node lifecycle now so the replacement can pass one execution fence through every mutation, skip live owners, recover expired owners after lease timeout, and redrive committed recovery admissions without blocking server startup.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/index.ts</path>
<type>file</type>
<content>
1: export * as SessionRunner from "./index"
2:
3: import type { LLMError } from "@opencode-ai/llm"
4: import { Context, Effect } from "effect"
5: import { SessionSchema } from "../schema"
6: import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
7: import { SessionRunnerModel } from "./model"
8: import type { SystemContext } from "../../system-context/index"
9: import type { ToolOutputStore } from "../../tool-output-store"
10:
11: export type RunError =
12: | LLMError
13: | SessionRunnerModel.Error
14: | MessageDecodeError
15: | ContextSnapshotDecodeError
16: | SystemContext.InitializationBlocked
17: | ToolOutputStore.Error
18:
19: /** Runs one local continuation from already-recorded Session history. */
20: export interface Interface {
21: /** Drains eligible durable work. ...
Found 7 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 243: const sessionRunner = yield* SessionRunner.Service
Line 533: const runner = yield* SessionRunner.Service
Line 534: const fiber = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
Line 3038: const runner = yield* SessionRunner.Service
Line 3039: const run = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
/home/byk/Code/opencode/packages/core/test/session-runner-recorded.test.ts:
Line 90: const sessionRunner = yield* SessionRunner.Service
/home/byk/Code/opencode/packages/core/src/session/execution/local.ts:
Line 68: return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe(
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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?: ...
The replacement state machine is now concrete: ready means a drain still owes safe continuation, unknown means an external provider attempt may have occurred, and safe means the last Step settled with no in-memory continuation debt. Startup will recover only expired ready/unknown leases, discard expired safe rows, and separately wake every durable unpromoted inbox row so a crash after admission can never strand work.
<path>/home/byk/Code/opencode/packages/core/src/session/compaction.ts</path>
<type>file</type>
<content>
175: }
176:
177: export const make = (dependencies: Dependencies) => {
178: const config = settings(dependencies.config)
179: const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) {
180: const context = input.model.route.defaults.limits?.context
181: if (context === undefined || context <= 0) return false
182: const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
183: const selected = select(input.entries, config.tokens)
184: const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message
185: if (!selected || (selected.head.length === 0 && previousSummary?.type !== "compaction")) return false
186: const summaryPrompt = buildPrompt({
187: previousSummary: previousSummary?.type === "compaction" ? ...
Found 4 matches
/home/byk/Code/opencode/packages/core/src/event.ts:
Line 327: .onConflictDoUpdate({
/home/byk/Code/opencode/packages/core/src/session/projector.ts:
Line 276: .onConflictDoUpdate({ target: MessageTable.id, set: { data } })
Line 327: .onConflictDoUpdate({ target: PartTable.id, set: { data } })
/home/byk/Code/opencode/packages/core/src/project/directories.ts:
Line 71: ? insert.onConflictDoUpdate({
The lease alone is insufficient because a paused owner can resume after another node recovers it. I’m applying fencing at each durable provider/tool publication boundary, with the execution ID carried from claim acquisition into the runner; any stale owner must fail before it can publish a tool call or begin a local side effect.
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
100: },
101: data: event.data,
102: }),
103: )
104: return {
105: events,
106: hasMore: rows.length > input.limit,
107: }
108: })
109:
110: export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
111: "EventV2.SubscriberOverflow",
112: { capacity: Schema.Int },
113: ) {}
114:
115: export const define = Event.define
116: export const versionedType = Event.versionedType
117:
118: export interface PublishOptions {
119: readonly id?: ID
120: readonly metadata?: Record<string, unknown>
121: readonly location?: Location.Ref
122: /** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
123: readonly commit?: (seq: number) => Effect.Effect<void>
124: }
125:
126: export interface Interface {
127: readonly publish: <D extends Definition>(
128: definition: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
438: const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
439: return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe(
440: Effect.catchDefect(
441: Effect.fnUntraced(function* (defect) {
442: if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
443: yield* Effect.yieldNow
444: if (defect.transition._tag === "ContinueAfterOverflowCompaction")
445: return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
446: return yield* runTurn(sessionID, undefined, defect.transition.step)
447: }),
448: ),
449: )
450: })
451:
452: const run = Effect.fn("SessionRunner.run")(function* (input: {
453: readonly sessionID: SessionSchema.ID
454: ...
I’ve reached the key implementation boundary: a lease without a fencing token still lets a paused process resume after takeover. The replacement will carry a unique execution ID into SessionRunner, require every phase transition and event commit to match that ID, and assert the fence immediately before provider dispatch and local tool settlement; losing ownership interrupts the stale drain instead of continuing on a replacement claim.
Found 4 matches
/home/byk/Code/opencode/packages/core/src/tool/registry.ts:
Line 31: readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
Line 62: const pending = yield* settle(registration.tool, input.call, {
/home/byk/Code/opencode/packages/core/src/tool/tool.ts:
Line 66: readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, ToolFailure>
Line 150: export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context)
<path>/home/byk/Code/opencode/packages/core/src/tool/registry.ts</path>
<type>file</type>
<content>
1: export * as ToolRegistry from "./registry"
2:
3: import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm"
4: import { Context, Effect, Layer, Scope } from "effect"
5: import { AgentV2 } from "../agent"
6: import { PermissionV2 } from "../permission"
7: import { SessionMessage } from "../session/message"
8: import { SessionSchema } from "../session/schema"
9: import { ToolOutputStore } from "../tool-output-store"
10: import { Wildcard } from "../util/wildcard"
11: import { ApplicationTools } from "./application-tools"
12: import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
13: import { Tools } from "./tools"
14: import { makeLocationNode } from "../effect/app-node"
15:
16: export type ExecuteInput = {
17: readonly sessionID: SessionSchema.ID
18: readonly agent: AgentV2.ID
19: ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution.ts</path>
<type>file</type>
<content>
1: export * as SessionExecution from "./execution"
2:
3: import { Context, Effect, Layer } from "effect"
4: import { LayerNode } from "../effect/layer-node"
5: import { Node } from "../effect/app-node"
6: import { SessionRunner } from "./runner/index"
7: import { SessionSchema } from "./schema"
8:
9: export interface Interface {
10: /** Snapshots active execution owned by this process. */
11: readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
12: /** Starts execution while idle or joins the active execution. */
13: readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
14: /** Registers newly recorded work. Repeated wakeups may coalesce. */
15: readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
16: /** Interrupt active work owned by this process. Idle interruption is a no-op. ...
I’m editing the durable boundary now. The smallest complete fix adds an expiring lease and three explicit phases, passes the execution ID into the runner, fences every claim update, runs provider compaction only after entering unknown, settles all provider EOF/error paths, and makes recovery a bounded periodic redrive rather than a one-shot startup scan.
Found 6 matches
/home/byk/Code/opencode/packages/core/src/models-dev.ts:
Line 256: // Schedule.spaced runs the effect once, then waits between completions.
Line 257: yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
/home/byk/Code/opencode/packages/core/src/tool-output-store.ts:
Line 203: yield* store.cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped)
/home/byk/Code/opencode/packages/core/src/integration.ts:
Line 364: yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope))
/home/byk/Code/opencode/packages/core/src/util/effect-flock.ts:
Line 50: Schedule.either(Schedule.spaced(MAX_DELAY_MS)),
Line 265: .pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped)
/home/byk/Code/opencode/packages/core/src/session/execution/local.ts
Success. Updated the following files: M packages/core/src/session/runner/index.ts M packages/core/src/session/sql.ts M packages/core/src/session/execution/local.ts
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
185: export const Failed = Event.define({
186: type: "session.next.step.failed",
187: ...stepSettlementOptions,
188: schema: {
189: ...Base,
190: assistantMessageID: SessionMessage.ID,
191: error: UnknownError,
192: },
193: })
194: export type Failed = typeof Failed.Type
195:
196: export const Interrupted = Event.define({
197: type: "session.next.step.interrupted",
198: ...options,
199: schema: {
200: ...Base,
201: assistantMessageID: SessionMessage.ID,
202: error: UnknownError,
203: },
204: })
205: export type Interrupted = typeof Interrupted.Type
206:
207: export const Recovered = Event.define({
208: type: "session.next.step.recovered",
209: ...options,
210: schema: {
211: ...Base,
212: executionID: Schema.String,
213: recoveryMessageID: SessionMessage.ID,
214: ...
Found 17 matches
/home/byk/Code/opencode/packages/schema/src/session-event.ts:
Line 7: import { Delivery } from "./session-delivery"
Line 10: import { FileAttachment, Prompt } from "./prompt"
Line 31: const PromptFields = {
Line 34: prompt: Prompt,
Line 35: delivery: Delivery,
Line 87: export const Prompted = Event.define({
Line 90: schema: PromptFields,
Line 92: export type Prompted = typeof Prompted.Type
Line 94: export const PromptAdmitted = Event.define({
Line 97: schema: PromptFields,
Line 99: export type PromptAdmitted = typeof PromptAdmitted.Type
Line 216: prompt: Prompt,
Line 217: delivery: Delivery,
Line 479: Prompted,
Line 480: PromptAdmitted,
Line 512: Prompted,
Line 513: PromptAdmitted,
Success. Updated the following files: M packages/schema/src/session-event.ts M packages/core/src/session/projector.ts M packages/core/src/session/message-updater.ts
No files found
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
1: export * as SessionProjector from "./projector"
2:
3: import { and, desc, eq, gt, or, sql } from "drizzle-orm"
4: import { DateTime, Effect, Layer, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { EventV2 } from "../event"
7: import { makeGlobalNode } from "../effect/app-node"
8: import { SessionEvent } from "./event"
9: import { SessionV1 } from "../v1/session"
10: import { WorkspaceTable } from "../control-plane/workspace.sql"
11: import { SessionMessage } from "./message"
12: import { SessionMessageUpdater } from "./message-updater"
13: import { SessionInput } from "./input"
14: import { WorkspaceV2 } from "../workspace"
15: import {
16: MessageTable,
17: PartTable,
18: SessionInputTable,
19: SessionMessageTable,
20: SessionRecoveryTable,
21: SessionTable,
22: } from "./sql"
23: import type { DeepMutable } from "../schema"
24: ...
Success. Updated the following files: M packages/core/src/session/projector.ts
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
180: eq(SessionInputTable.session_id, sessionID),
181: isNull(SessionInputTable.promoted_seq),
182: eq(SessionInputTable.delivery, delivery),
183: ),
184: )
185: .limit(1)
186: .get()
187: .pipe(Effect.orDie)
188: return row !== undefined
189: })
190:
191: export const equivalent = (
192: input: Admitted,
193: expected: {
194: readonly sessionID: SessionSchema.ID
195: readonly prompt: Prompt
196: readonly delivery: Delivery
197: },
198: ) => input.delivery === expected.delivery && matchesPrompt(input, expected)
199:
200: const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionSchema.ID; readonly prompt: Prompt }) =>
201: input.sessionID === expected.sessionID &&
202: JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
203:
204: const matchesProjection ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, desc, eq, isNull } from "drizzle-orm"
4: import { DateTime, Effect, Layer } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionMessage } from "./message"
11: import { SessionProjector } from "./projector"
12: import { Prompt } from "./prompt"
13: import { SessionExecutionTable, SessionRecoveryTable, SessionMessageTable } from "./sql"
14:
15: const continuation = Prompt.make({ text: "continue" })
16:
17: export const recover = Effect.fn("SessionRecovery.recover")(function* () {
18: const { db } = yield* Database.Service
19: const events = yield* EventV2.Service
20: const execution = yield* ...
No files found
Success. Updated the following files: M packages/core/src/session/recovery.ts
Success. Updated the following files: M packages/core/src/session/execution/local.ts
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
const executionCommit = (sessionID: SessionSchema.ID, values: Partial<ExecutionValues>) => () =>
db
.update(SessionExecutionTable)
.set(values)
.where(eq(SessionExecutionTable.session_id, sessionID))
.run()
.pipe(Effect.orDie, Effect.asVoid)
const executionDelete = (sessionID: SessionSchema.ID) => () =>
db
.delete(SessionExecutionTable)
.where(eq(SessionExecutionTable.session_id, sessionID))
.run()
.pipe(Effect.orDie, Effect.asVoid)
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
220: messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
221: tools: toolMaterialization?.definitions ?? [],
222: toolChoice: isLastStep ? "none" : undefined,
223: })
224: if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
225: return yield* Effect.die(continueAfterCompaction(currentStep))
226: const startSnapshot = yield* snapshots.capture()
227: const publisher = createLLMEventPublisher(events, {
228: sessionID: session.id,
229: agent: agent.id,
230: model: {
231: id: ModelV2.ID.make(model.id),
232: providerID: ProviderV2.ID.make(model.provider),
233: ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
234: },
235: snapshot: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
180: ) {
181: const session = yield* getSession(sessionID)
182: if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
183: return yield* Effect.interrupt
184: const agent = yield* agents.select(session.agent)
185: const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
186: const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
187: let needsContinuation = false
188: let currentStep = step
189: if (promotion) {
190: const cutoff = yield* EventV2.latestSequence(db, session.id)
191: let promoted = 0
192: if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
193: if (promotion === "queue") {
194: promoted += ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
160: class TurnTransitionError extends Error {
161: constructor(readonly transition: TurnTransition) {
162: super()
163: }
164: }
165:
166: const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step })
167: const continueAfterOverflowCompaction = (step: number) =>
168: new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
169:
170: const loadSystemContext = (agent: AgentV2.Selection) =>
171: Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
172: concurrency: "unbounded",
173: }).pipe(Effect.map(SystemContext.combine))
174:
175: const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
176: sessionID: SessionSchema.ID,
177: promotion: SessionInput.Delivery | undefined,
178: ...
<path>/home/byk/Code/opencode/packages/core/src/session/compaction.ts</path>
<type>file</type>
<content>
1: export * as SessionCompaction from "./compaction"
2:
3: import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
4: import { DateTime, Effect, Stream } from "effect"
5: import type { Config } from "../config"
6: import type { EventV2 } from "../event"
7: import { SessionEvent } from "./event"
8: import { SessionMessage } from "./message"
9: import { SessionSchema } from "./schema"
10: import { Token } from "../util/token"
11:
12: const DEFAULT_BUFFER = 20_000
13: const DEFAULT_KEEP_TOKENS = 8_000
14: const TOOL_OUTPUT_MAX_CHARS = 2_000
15: const SUMMARY_OUTPUT_TOKENS = 4_096
16: const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response. ...
Found 2 matches
/home/byk/Code/opencode/packages/core/src/session/compaction.ts:
Line 247: return yield* compactAfterOverflow(input)
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 224: if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
Found 6 matches /home/byk/Code/opencode/packages/core/src/session/runner/llm.ts: Line 414: return yield* runTurnAttempt(sessionID, executionID, promotion, step).pipe( Line 421: return yield* runAfterOverflowCompaction(sessionID, executionID, undefined, defect.transition.step) Line 428: return yield* runTurnAttempt(sessionID, executionID, promotion, step, compaction.compactAfterOverflow).pipe( Line 434: return yield* runAfterOverflowCompaction(sessionID, executionID, undefined, defect.transition.step) Line 435: return yield* runTurn(sessionID, executionID, undefined, defect.transition.step) Line 456: const result = yield* runTurn(input.sessionID, input.executionID, promotion, step)
Found 3 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 534: const fiber = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
Line 3039: const run = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
/home/byk/Code/opencode/packages/core/src/session/execution/local.ts:
Line 96: return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force, executionID })).pipe(
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
500: yield* setup
501: const session = yield* SessionV2.Service
502: const prompt = `Fail after ${kind}`
503: const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
504: const failure = providerUnavailable()
505: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
506: responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
507:
508: expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
509: expect(yield* session.context(sessionID)).toMatchObject([
510: { type: "user", text: prompt },
511: {
512: type: "assistant",
513: finish: "error",
514: error: { type: "unknown", message: "Provider unavailable" },
515: content: [fixture.expectedContent],
516: },
517: ])
518: ...
Found 2 matches /home/byk/Code/opencode/packages/core/test/session-runner.test.ts: Line 46: SessionExecutionTable, Line 2959: yield* db.select().from(SessionExecutionTable).where(eq(SessionExecutionTable.session_id, sessionID)).get(),
Found 2 matches
/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts:
Line 10: import { SessionExecutionTable, SessionTable } from "@opencode-ai/core/session/sql"
Line 38: return yield* db.select().from(SessionExecutionTable).where(eq(SessionExecutionTable.session_id, sessionID)).all()
Found 3 matches
/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts:
Line 45: const execution = yield* SessionExecutionLocal.make({ run: () => Effect.void })
Line 57: run: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
Line 72: run: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import {
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Model,
7: TransportReason,
8: InvalidRequestReason,
9: type LLMClientShape,
10: type LLMRequest,
11: } from "@opencode-ai/llm"
12: import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
13: import { Database } from "@opencode-ai/core/database/database"
14: import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
15: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
16: import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
17: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
18: import { EventV2 } from "@opencode-ai/core/event"
19: import { PermissionV2 } from "@opencode-ai/core/permission"
20: import { EventTable } from "@opencode-ai/core/event/sql"
21: import { Project } ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
210: })
211: const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
212: const config = Layer.succeed(
213: Config.Service,
214: Config.Service.of({
215: entries: () =>
216: Effect.succeed([
217: new Config.Document({
218: type: "document",
219: info: new Config.Info({
220: compaction: new ConfigCompaction.Info({
221: buffer: 3_000,
222: keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
223: }),
224: }),
225: }),
226: ]),
227: }),
228: )
229: const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
230: [Snapshot.node, Snapshot.noopLayer],
231: [LayerNodePlatform.llmClient, client],
232: [SessionRunnerModel.node, models],
233: [SystemContextRegistry.node, systemContext],
234: ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
Success. Updated the following files: M packages/core/test/session-runner.test.ts
Success. Updated the following files: M packages/core/test/session-runner.test.ts
Found 3 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 535: const fiber = yield* runner.run({ sessionID, force: true, executionID }).pipe(Effect.forkChild)
Line 3040: const run = yield* runner.run({ sessionID, force: true, executionID }).pipe(Effect.forkChild)
/home/byk/Code/opencode/packages/core/src/session/execution/local.ts:
Line 96: return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force, executionID })).pipe(
Found 5 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 57: return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
Line 84: .values({ id: "execution", session_id: sessionID, owner_id: "dead", phase: "unknown", time_created: 0 })
Line 197: .values({ id: "execution", session_id: sessionID, owner_id: "dead", phase: "safe", time_created: 0 })
Line 248: assistant_message_id: abandonedID,
Line 250: time_created: 0,
No files found
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
70: .run()
71: yield* db
72: .insert(SessionTable)
73: .values({
74: id: sessionID,
75: project_id: Project.ID.global,
76: slug: "test",
77: directory: "/project",
78: title: "test",
79: version: "test",
80: })
81: .run()
82: yield* db
83: .insert(SessionExecutionTable)
84: .values({ id: "execution", session_id: sessionID, owner_id: "dead", phase: "unknown", time_created: 0 })
85: .run()
86: yield* events.publish(SessionEvent.Step.Started, {
87: sessionID,
88: timestamp: created,
89: assistantMessageID: assistantID,
90: agent: "build",
91: model,
92: })
93:
94: yield* events.publish(
95: SessionEvent.Step.Recovered,
96: {
97: sessionID,
98: ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
180: yield* db
181: .insert(ProjectTable)
182: .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
183: .run()
184: yield* db
185: .insert(SessionTable)
186: .values({
187: id: sessionID,
188: project_id: Project.ID.global,
189: slug: "test",
190: directory: "/project",
191: title: "test",
192: version: "test",
193: })
194: .run()
195: yield* db
196: .insert(SessionExecutionTable)
197: .values({ id: "execution", session_id: sessionID, owner_id: "dead", phase: "safe", time_created: 0 })
198: .run()
199:
200: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
201: yield* ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
Found 11 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 17: import { Prompt } from "@opencode-ai/core/session/prompt"
Line 171: prompt: Prompt.make({ text: "continue" }),
Line 383: SessionEvent.Prompted,
Line 388: prompt: Prompt.make({ text: "first" }),
Line 394: SessionEvent.Prompted,
Line 399: prompt: Prompt.make({ text: "second" }),
Line 429: it.effect("marks an inbox row promoted with the Prompted event sequence", () =>
Line 454: prompt: Prompt.make({ text: "promote me" }),
Line 457: if (!admitted) return yield* Effect.die("Prompt admission failed")
Line 459: const event = yield* events.publish(SessionEvent.Prompted, {
Line 463: prompt: Prompt.make({ text: "promote me" }),
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
145: .all()
146: yield* events.remove(sessionID)
147: yield* db.delete(SessionRecoveryTable).run()
148: yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run()
149: yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, sessionID)).run()
150: yield* events.replayAll(
151: recorded.map((event) => ({
152: id: event.id,
153: aggregateID: event.aggregate_id,
154: seq: event.seq,
155: type: event.type,
156: data: event.data,
157: })),
158: )
159: expect(yield* db.select().from(SessionRecoveryTable).all()).toHaveLength(1)
160: expect(yield* db.select().from(SessionInputTable).all()).toHaveLength(1)
161: expect(yield* db.select().from(SessionMessageTable).all()).toHaveLength(1)
162:
163: ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
Found 6 matches
/home/byk/Code/opencode/packages/schema/src/session-event.ts:
Line 487: Step.Recovered,
Line 520: Step.Recovered,
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 102: SessionEvent.Step.Recovered,
Line 164: .publish(SessionEvent.Step.Recovered, {
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 57: SessionEvent.Step.Recovered,
/home/byk/Code/opencode/packages/core/src/session/projector.ts:
Line 391: yield* events.project(SessionEvent.Step.Recovered, (event) =>
<path>/home/byk/Code/opencode/packages/core/src/session/compaction.ts</path>
<type>file</type>
<content>
76: type Input = {
77: readonly sessionID: SessionSchema.ID
78: readonly entries: readonly Entry[]
79: readonly model: Model
80: readonly request: LLMRequest
81: readonly commit?: () => Effect.Effect<void>
82: }
83:
84: const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
85:
86: const truncate = (value: string) =>
87: value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
88:
89: export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
90: content
91: .map((item) =>
92: item.type === "text" ? item.text : `[Attached ${item.mime}${item.name === undefined ? "" : `: ${item.name}`}]`,
93: )
94: .join("\n")
95:
96: const serialize = (message: SessionMessage.Message) => {
97: if (message.type === "user") {
98: const files = ...
Success. Updated the following files: M packages/core/src/session/compaction.ts
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
Found 18 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 2962: phase: "unknown",
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 88: phase: "unknown",
Line 90: expires_at: 0,
Line 110: phase: "unknown",
Line 171: phase: "unknown",
Line 206: expires_at: 0,
Line 259: phase: "unknown",
Line 261: expires_at: 0,
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 28: .where(lt(SessionExecutionTable.expires_at, Date.now()))
Line 45: .where(and(eq(SessionExecutionTable.id, claim.id), lt(SessionExecutionTable.expires_at, Date.now())))
Line 77: lt(SessionExecutionTable.expires_at, Date.now()),
/home/byk/Code/opencode/packages/core/src/session/execution/local.ts:
Line 39: .set({ expires_at: Date.now() + leaseDuration })
Line 58: expires_at: Date.now() + ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
2945: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false })
2946: executions.length = 0
2947: toolExecutionGate = yield* Deferred.make<void>()
2948: responseStream = Stream.concat(
2949: Stream.fromIterable([
2950: LLMEvent.stepStart({ index: 0 }),
2951: LLMEvent.toolCall({ id: "call-before-interrupt", name: "echo", input: { text: "blocked" } }),
2952: ]),
2953: Stream.never,
2954: )
2955:
2956: const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
2957: while (executions.length === 0) yield* Effect.yieldNow
2958: const { db } = yield* Database.Service
2959: expect(
2960: yield* db.select().from(SessionExecutionTable).where(eq(SessionExecutionTable.session_id, sessionID)).get(),
2961: ...
The first implementation pass is complete. I’m checking types before regenerating migrations; then I’ll tighten any broken fixtures around the new execution-ID contract and add the lease/takeover regressions before broader validation.
$ tsgo --noEmit
test/session-runner-recorded.test.ts(92,54): error TS2741: Property 'executionID' is missing in type '{ sessionID: string & Brand<"SessionID">; force: boolean; }' but required in type '{ readonly sessionID: string & Brand<"SessionID">; readonly force: boolean; readonly executionID: string; }'.
<path>/home/byk/Code/opencode/packages/core/test/session-runner-recorded.test.ts</path>
<type>file</type>
<content>
82: [ReferenceGuidance.node, referenceGuidance],
83: [Config.node, config],
84: [PermissionV2.node, permission],
85: [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
86: ])
87: const execution = Layer.effect(
88: SessionExecution.Service,
89: Effect.gen(function* () {
90: const sessionRunner = yield* SessionRunner.Service
91: const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
92: drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
93: })
94: return SessionExecution.Service.of({
95: active: coordinator.active,
96: resume: coordinator.run,
97: wake: coordinator.wake,
(Showing lines 82-97 of 193. Use offset=98 to continue.)
</content>
Success. Updated the following files: M packages/core/test/session-runner-recorded.test.ts
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-wrmMIg/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-wrmMIg/incremental/20260914235657_session-execution-lease/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-wrmMIg/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-wrmMIg/full/20260914235659_schema/migration.sql 🚀
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914235657_session-execution-lease.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260914235657_session-execution-lease",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`ALTER TABLE \`session_execution\` ADD \`expires_at\` integer NOT NULL;`)
9: })
10: },
11: } satisfies DatabaseMigration.Migration
(End of file - total 11 lines)
</content>
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { Deferred, Effect, Exit, Scope } from "effect"
3: import { eq } from "drizzle-orm"
4: import { Database } from "@opencode-ai/core/database/database"
5: import { Project } from "@opencode-ai/core/project"
6: import { ProjectTable } from "@opencode-ai/core/project/sql"
7: import { AbsolutePath } from "@opencode-ai/core/schema"
8: import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
9: import { SessionSchema } from "@opencode-ai/core/session/schema"
10: import { SessionExecutionTable, SessionTable } from "@opencode-ai/core/session/sql"
11: import { testEffect } from "./lib/effect"
12:
13: const sessionID = SessionSchema.ID.make("ses_execution_local")
14: const it = testEffect(Database.layerFromPath(":memory:"))
15:
16: const insertSession = Effect.gen(function* () {
17: const { db } = ...
Type checking now passes, but the lifecycle audit exposed two real gaps before tests: successful automatic compaction must return the lease to safe, and every local tool must renew and verify the execution fence immediately before settlement. I’m closing those windows, then I’ll update direct-runner tests to enter through the production execution boundary rather than inventing unclaimed execution IDs.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3025: it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
3026: Effect.gen(function* () {
3027: yield* setup
3028: const session = yield* SessionV2.Service
3029: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt tool settlement" }), resume: false })
3030: executions.length = 0
3031: toolExecutionGate = yield* Deferred.make<void>()
3032: response = [
3033: LLMEvent.stepStart({ index: 0 }),
3034: LLMEvent.toolCall({ id: "call-await-interrupt", name: "echo", input: { text: "blocked" } }),
3035: LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
3036: LLMEvent.finish({ reason: "tool-calls" }),
3037: ]
3038:
3039: const runner = yield* SessionRunner.Service
3040: const run = yield* runner.run({ sessionID, force: true, ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
275: [PermissionV2.node, permission],
276: [SessionRunnerModel.node, models],
277: [SystemContextRegistry.node, systemContext],
278: [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
279: [SkillGuidance.node, skillGuidance],
280: [ReferenceGuidance.node, referenceGuidance],
281: [Snapshot.node, Snapshot.noopLayer],
282: [SessionExecution.node, execution],
283: [Config.node, config],
284: ],
285: ),
286: )
287: const sessionID = SessionV2.ID.make("ses_runner_test")
288: const otherSessionID = SessionV2.ID.make("ses_runner_other")
289:
290: const insertSession = (id: SessionV2.ID) =>
291: Effect.gen(function* () {
292: const { db } = yield* Database.Service
293: yield* db
294: .insert(SessionTable)
295: .values({
296: id,
297: project_id: ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
319: streamFailure = undefined
320: responseStream = undefined
321: streamGate = undefined
322: streamStarted = undefined
323: toolExecutionGate = undefined
324: toolExecutionsStarted = undefined
325: toolExecutionsReady = 5
326: activeToolExecutions = 0
327: maxActiveToolExecutions = 0
328: yield* db
329: .insert(ProjectTable)
330: .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
331: .onConflictDoNothing()
332: .run()
333: .pipe(Effect.orDie)
334: yield* insertSession(sessionID)
335: })
336:
337: const providerUnavailable = () =>
338: new LLMError({
339: module: "test",
340: method: "stream",
341: reason: new TransportReason({ message: "Provider unavailable" }),
342: })
343:
344: const setupOverflowRecovery = Effect.gen(function* () {
345: yield* setup
346: const session = ...
Success. Updated the following files: M packages/core/src/session/compaction.ts M packages/core/src/session/runner/llm.ts
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
280: const providerStream = Stream.suspend(() => llm.stream(request)).pipe(
281: Stream.runForEach((event) =>
282: Effect.gen(function* () {
283: if (overflowFailure || publisher.hasProviderError()) return
284: if (LLMEvent.is.providerError(event)) {
285: if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
286: overflowFailure = event
287: return
288: }
289: }
290: yield* publish(event)
291: if (event.type !== "tool-call" || event.providerExecuted) return
292: if (!toolMaterialization) {
293: yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
294: return
295: }
296: needsContinuation = true
297: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path> <type>file</type> <content> 318: ), 319: ), 320: ), 321: ), 322: ), 323: ).pipe(FiberSet.run(toolFibers)) 324: }), 325: ), 326: Effect.ensuring(withPublication(publisher.flush())), 327: ) 328: 329: return yield* Effect.uninterruptibleMask((restore) => (Showing lines 318-329 of 504. Use offset=330 to continue.) </content>
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
676 | ).toBeUndefined()
677 |
678 | systemUnavailable = false
679 | yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) })
680 |
681 | expect(requests).toHaveLength(1)
^
error: expect(received).toHaveLength(expected)
Expected length: 1
Received length: 0
at toHaveLength (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:681:24
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The focused suite found fixture failures, not a reason to weaken fencing: two tests called SessionRunner directly without acquiring a claim, and failed drains intentionally retained claims that then blocked same-process retries. The production correction is to let the same owner reclaim its retained row with a new execution ID, while tests that bypass SessionExecutionLocal must insert the exact claim they claim to own.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
55: /** Persist one provider turn without executing tools or starting a continuation turn. */
56: export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
57: const tools = new Map<
58: string,
59: {
60: readonly assistantMessageID: SessionMessage.ID
61: readonly name: string
62: inputEnded: boolean
63: called: boolean
64: settled: boolean
65: providerExecuted: boolean
66: providerMetadata?: ProviderMetadata
67: }
68: >()
69: const timestamp = DateTime.now
70: let assistantMessageID: SessionMessage.ID | undefined
71: let assistantActive = false
72: let assistantFailed = false
73: let providerFailed = false
74: let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
75:
76: const startAssistant = ...
Found 9 matches
/home/byk/Code/opencode/packages/effect-drizzle-sqlite/src/sqlite-core/effect/insert.ts:
Line 88: /** @deprecated - use either `targetWhere` or `setWhere` */
Line 90: targetWhere?: SQL
Line 91: setWhere?: SQL
Line 275: if (config.where && (config.targetWhere || config.setWhere)) {
Line 277: 'You cannot use both "where" and "targetWhere"/"setWhere" at the same time - "where" is deprecated, use "targetWhere" or "setWhere" instead.',
Line 284: const targetWhereSql = config.targetWhere ? sql` where ${config.targetWhere}` : undefined
Line 285: const setWhereSql = config.setWhere ? sql` where ${config.setWhere}` : undefined
Line 292: sql` on conflict ${targetSql}${targetWhereSql} do update set ${setSql}${whereSql}${setWhereSql}`,
/home/byk/Code/opencode/packages/core/src/project/directories.ts:
Line 74: setWhere: input.strategy
Success. Updated the following files: M packages/core/src/session/execution/local.ts
Success. Updated the following files: M packages/core/src/session/execution/local.ts
Success. Updated the following files: M packages/core/test/session-runner.test.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a) 15 pass 0 fail 43 expect() calls Ran 15 tests across 2 files. [1383.00ms]
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
error: Session execution claim lost: 89b32bae-ddbc-451a-bd11-d673b10406b3
Session execution claim lost: 89b32bae-ddbc-451a-bd11-d673b10406b3
(fail) SessionRunnerLLM > publishes the original overflow when recovery summarization fails [54.46ms]
[00:02:40.968] ERROR (#13853): 223 | ;
224 | error[key] = original[key];
225 | }
226 | }
227 | } else {
228 | error = new globalThis.Error(!original ? `Unknown error: ${original}` : kind === "string" ? original : formatJson(original));
^
error: Session execution claim lost: 89b32bae-ddbc-451a-bd11-d673b10406b3
at causePrettyError (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:228:13)
91 pass
1 fail
271 expect() calls
Ran 92 tests across 2 files. [6.96s]
Found 1 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 1317: it.effect("publishes the original overflow when recovery summarization fails", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1295: method: "stream",
1296: reason: new InvalidRequestReason({
1297: message: "prompt too long",
1298: classification: "context-overflow",
1299: }),
1300: }),
1301: )
1302: responses = [
1303: fragmentFixture("text", "text-summary", ["## Objective\n- Recover raw overflow"]).completeEvents,
1304: fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
1305: ]
1306: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
1307: yield* session.resume(sessionID)
1308:
1309: expect(requests).toHaveLength(3)
1310: expect(yield* session.context(sessionID)).toMatchObject([
1311: { type: "compaction", summary: "## Objective\n- Recover raw overflow" },
1312: { type: "assistant", finish: "stop" },
1313: ...
The remaining failure is a real settlement-order bug: the original overflow error creates and terminally settles an assistant, so the execution claim disappears before a later claim mutation in that same Step. I’m tracing that exact post-error mutation and will move terminal claim deletion to the one actual drain boundary rather than letting an intermediate event invalidate its own owner.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
206: const flush = Effect.fn("SessionRunner.flush")(function* () {
207: yield* flushFragments()
208: })
209:
210: const failAssistant = Effect.fnUntraced(function* (message: string) {
211: if (assistantFailed) return
212: yield* flush()
213: const assistantMessageID = yield* startAssistant()
214: assistantActive = false
215: assistantFailed = true
216: yield* events.publish(
217: SessionEvent.Step.Failed,
218: {
219: sessionID: input.sessionID,
220: timestamp: yield* timestamp,
221: assistantMessageID,
222: error: { type: "unknown", message },
223: },
224: {
225: commit: () => input.terminalCommit(assistantMessageID),
226: },
227: )
228: })
229:
230: const interruptAssistant = Effect.fnUntraced(function* (message: string) {
231: if (assistantFailed) ...
The failure comes from deleting the claim inside Step.Failed while the owning drain and heartbeat still run. I’m changing terminal Step events to mark the claim safe; only SessionExecutionLocal clears it after the whole drain exits successfully, while abnormal exits leave an expiring safe record that startup discards without injecting continue.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
bun test v1.3.14 (0d9b296a) 92 pass 0 fail 274 expect() calls Ran 92 tests across 2 files. [6.99s]
The runner regressions now pass. Before adding takeover tests, I found one remaining fencing hole: the expired-row takeover updated the execution ID but retained the old owner ID, and ordinary durable stream events were not atomically fenced. I’m fixing both so a stale process cannot publish text, tool calls, or compaction records after another node acquires the Session.
No files found
Success. Updated the following files: M packages/core/src/session/execution/local.ts M packages/core/src/session/runner/publish-llm-event.ts
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: readonly startCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
15: readonly terminalCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
16: readonly eventCommit: () => Effect.Effect<void>
17: }
18:
19: const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? ...
Found 17 matches
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line 62: ) => events.publish(definition, data, { commit })
Line 140: yield* events.publish(SessionEvent.Text.Ended, {
Line 151: yield* events.publish(SessionEvent.Reasoning.Ended, {
Line 165: yield* events.publish(SessionEvent.Tool.Input.Ended, {
Line 194: yield* events.publish(SessionEvent.Tool.Input.Started, {
Line 222: yield* events.publish(
Line 242: yield* events.publish(SessionEvent.Step.Interrupted, {
Line 257: yield* events.publish(SessionEvent.Tool.Failed, {
Line 285: yield* events.publish(SessionEvent.Text.Started, {
Line 294: yield* events.publish(SessionEvent.Text.Delta, {
Line 307: yield* events.publish(SessionEvent.Reasoning.Started, {
Line 317: yield* events.publish(SessionEvent.Reasoning.Delta, {
Line 338: yield* events.publish(SessionEvent.Tool.Input.Delta, ...
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts
Found 2 matches
/home/byk/Code/opencode/packages/core/test/session-runner-tool-events.test.ts:
Line 41: publisher: createLLMEventPublisher(events, {
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 251: const publisher = createLLMEventPublisher(events, {
Success. Updated the following files: M packages/core/src/session/runner/llm.ts M packages/core/test/session-runner-tool-events.test.ts
$ tsgo --noEmit
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
175: expect(yield* db.select().from(SessionInputTable).all()).toHaveLength(1)
176: expect(yield* db.select().from(SessionRecoveryTable).all()).toHaveLength(1)
177: }),
178: )
179:
180: it.effect("recovers each abandoned execution once across repeated startup scans", () =>
181: Effect.gen(function* () {
182: const { db } = yield* Database.Service
183: yield* db
184: .insert(ProjectTable)
185: .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
186: .run()
187: yield* db
188: .insert(SessionTable)
189: .values({
190: id: sessionID,
191: project_id: Project.ID.global,
192: slug: "test",
193: directory: "/project",
194: title: "test",
195: version: "test",
196: })
197: .run()
198: ...
Found 5 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 36: const recoveryExecution = SessionExecution.Service.of({
Line 40: wake: () => Effect.void,
Line 210: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
Line 211: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
Line 265: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
25: SessionInputTable,
26: SessionMessageTable,
27: SessionRecoveryTable,
28: SessionTable,
29: } from "@opencode-ai/core/session/sql"
30: import { testEffect } from "./lib/effect"
31: import { Snapshot } from "@opencode-ai/core/snapshot"
32: import { Location } from "@opencode-ai/core/location"
33:
34: const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
35: const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])
36: const recoveryExecution = SessionExecution.Service.of({
37: active: Effect.succeed(new Set()),
38: interrupt: () => Effect.void,
39: resume: () => Effect.void,
40: wake: () => Effect.void,
41: })
42: const sessionID = SessionV2.ID.make("ses_projector_test")
43: const created = DateTime.makeUnsafe(0)
44: const model = { id: ...
Found 5 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 35: import { SessionExecutionTable } from "../sql"
Line 185: const updateExecution = (values: Partial<typeof SessionExecutionTable.$inferInsert>) =>
Line 187: .update(SessionExecutionTable)
Line 189: .where(and(eq(SessionExecutionTable.session_id, session.id), eq(SessionExecutionTable.id, executionID)))
Line 190: .returning({ id: SessionExecutionTable.id })
...output truncated...
Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a264c65c001dYL0ifQ4mL1CDc
(fail) SessionRunnerLLM > keeps interleaved assistant text blocks separate [48.13ms]
49 | }) as SelectedFieldsOrdered
50 | }
51 |
52 | export function mapUpdateSet<TTable extends SQLiteTable>(table: TTable, values: SQLiteUpdateSetSource<TTable>) {
53 | const entries = Object.entries(values).filter(([, value]) => value !== undefined)
54 | if (entries.length === 0) throw new Error("No values to set")
^
error: No values to set
at mapUpdateSet (/home/byk/Code/opencode/packages/effect-drizzle-sqlite/src/internal/drizzle-utils.ts:54:39)
at set (/home/byk/Code/opencode/packages/effect-drizzle-sqlite/src/sqlite-core/effect/update.ts:187:7)
at updateExecution (/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:346:11)
at /home/byk/Code/opencode/packages/core/src/event.ts:635:3
at ...
The broad run caught the expected mechanical bug in the new fence hook: an empty Drizzle update is invalid. More importantly, the scan order could wake a pending input and take over an expired unknown lease before reconciliation. I’m making every event renew the lease with a real write and reversing startup order so stale uncertain work is always reconciled before any pending inbox wake.
Success. Updated the following files: M packages/core/src/session/sql.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/recovery.ts
$ tsgo --noEmit
...output truncated...
Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a266a36f001Dwz7HOG2ow6uqL
[00:10:22.926] ERROR (#4759): 368 |
369 | function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
370 | return Effect.gen(function* () {
371 | if (!definition?.durable && commit)
372 | return yield* Effect.die(
373 | new InvalidDurableEventError({
^
EventV2.InvalidDurableEvent: Local commit hooks require a durable event
type: "session.next.text.delta",
_tag: "EventV2.InvalidDurableEvent",
at /home/byk/Code/opencode/packages/core/src/event.ts:373:19
at SessionRunner.run (/home/byk/Code/opencode/packages/core/src/session/execution/local.ts:77:15)
at SessionRunner.run (definition) (/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:443:24)
[00:10:23.095] ERROR (#5348): 368 |
369 | function ...
EventV2 correctly rejects transaction hooks on live-only deltas. Durable events remain atomically fenced; live deltas will verify the lease immediately before broadcast, which prevents stale durable state and limits the unavoidable takeover race to transient UI deltas that are never projected or replayed.
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 92 pass 0 fail 274 expect() calls Ran 92 tests across 2 files. [9.58s]
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
Success. Updated the following files: M packages/core/test/session-projector.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 18 pass 0 fail 50 expect() calls Ran 18 tests across 2 files. [1437.00ms]
Reading config file '/tmp/opencode-core-migration-check-TsOB3I/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-TsOB3I/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-TsOB3I/full/20260915001257_schema/migration.sql 🚀
M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session/compaction.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/session-execution-local.test.ts M packages/core/test/session-projector.test.ts M packages/core/test/session-runner-recorded.test.ts M packages/core/test/session-runner-tool-events.test.ts M packages/core/test/session-runner.test.ts M packages/schema/src/session-event.ts ?? packages/core/src/database/migration/20260914235657_session-execution-lease.ts ?? perf.data ?? perf.data.old
Lease takeover, startup reconciliation ordering, and all focused Core regressions now pass. I’m auditing the complete diff next, including generated migrations and route composition, then I’ll run every affected package’s typecheck and the isolated artifact smoke before freezing the candidate for independent correctness and security reviews.
[
{
"content": "Define V2 synthetic recovery ownership and discovery",
"status": "completed",
"priority": "high"
},
{
"content": "Implement interruption reconciliation and synthetic continuation",
"status": "completed",
"priority": "high"
},
{
"content": "Add restart, replay, fencing, and duplicate-claim regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Run package validation and isolated restart smoke",
"status": "in_progress",
"priority": "high"
},
{
"content": "Run independent correctness and security reviews",
"status": "pending",
"priority": "high"
}
]
AGENTS.md | 6 + packages/core/schema.json | 14 ++- packages/core/src/database/migration.gen.ts | 1 + packages/core/src/database/schema.gen.ts | 1 + packages/core/src/session/compaction.ts | 11 +- packages/core/src/session/execution/local.ts | 71 ++++++++--- packages/core/src/session/message-updater.ts | 13 +- packages/core/src/session/projector.ts | 5 +- packages/core/src/session/recovery.ts | 51 ++++---- packages/core/src/session/runner/index.ts | 1 + packages/core/src/session/runner/llm.ts | 132 ++++++++++----------- .../core/src/session/runner/publish-llm-event.ts | 47 +++++--- packages/core/src/session/sql.ts | 5 +- packages/core/test/session-execution-local.test.ts | 54 +++++++++ packages/core/test/session-projector.test.ts | 65 ++++++++-- ...
The second run shows definition.durable cannot distinguish the live facade from its durable inventory entry at runtime, so the publisher still attached hooks to delta events. I’m replacing that inference with explicit durable versus live publication paths; no event will guess its persistence class.
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
15:
16: export { FileAttachment }
17:
18: export const Source = Schema.Struct({
19: start: NonNegativeInt,
20: end: NonNegativeInt,
21: text: Schema.String,
22: }).annotate({
23: identifier: "session.next.event.source",
24: })
25: export interface Source extends Schema.Schema.Type<typeof Source> {}
26:
27: const Base = {
28: timestamp: DateTimeUtcFromMillis,
29: sessionID: SessionID,
30: }
31: const PromptFields = {
32: ...Base,
33: messageID: SessionMessage.ID,
34: prompt: Prompt,
35: delivery: Delivery,
36: }
37:
38: const options = {
39: durable: {
40: aggregate: "sessionID",
41: version: 1,
42: },
43: } as const
44: const stepSettlementOptions = {
(Showing lines 15-44 of 550. Use offset=45 to continue.)
</content>
No files found
Found 1 matches /home/byk/Code/opencode/packages/core/src/event.ts: Line 18: export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
1: export * as EventV2 from "./event"
2:
3: import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
4: import { Event } from "@opencode-ai/schema/event"
5: import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
6: import { and, asc, eq, gt, inArray } from "drizzle-orm"
7: import { Database } from "./database/database"
8: import { EventSequenceTable, EventTable } from "./event/sql"
9: import { Location } from "./location"
10: import { makeGlobalNode } from "./effect/app-node"
11: import { isDeepStrictEqual } from "node:util"
12: import { Durable } from "@opencode-ai/schema/durable-event-manifest"
13:
14: export const ID = Event.ID
15: export type ID = import("@opencode-ai/schema/event").ID
16: export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
17:
18: export type Subscriber<D extends Definition = Definition> = ...
<path>/home/byk/Code/opencode/packages/schema/src/event.ts</path> <type>file</type> <content> 120: keys: () => map.keys(), 121: values: () => map.values(), 122: [Symbol.iterator]: () => map[Symbol.iterator](), 123: }) 124: return result 125: } (End of file - total 125 lines) </content>
<path>/home/byk/Code/opencode/packages/schema/src/event.ts</path>
<type>file</type>
<content>
1: export * as Event from "./event"
2:
3: import { Schema } from "effect"
4: import { optional } from "./schema"
5: import { ascending } from "./identifier"
6: import { Location } from "./location"
7: import { statics } from "./schema"
8:
9: export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
10: Schema.brand("Event.ID"),
11: statics((schema) => ({ create: () => schema.make("evt_" + ascending()) })),
12: )
13: export type ID = typeof ID.Type
14:
15: export type Definition<
16: Type extends string = string,
17: DataSchema extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>,
18: > = Schema.Top & {
19: readonly type: Type
20: readonly durable?: {
21: readonly version: number
22: readonly aggregate: string
23: }
24: readonly data: DataSchema
25: }
26:
27: export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
28: ...
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts
bun test v1.3.14 (0d9b296a) 92 pass 0 fail 274 expect() calls Ran 92 tests across 2 files. [9.76s]
bun test v1.3.14 (0d9b296a) 110 pass 0 fail 324 expect() calls Ran 110 tests across 4 files. [9.23s]
One last ownership edge remains: a same-process retry could overwrite its own still-uncertain lease before recovery. I’m restricting owner re-entry to ready or safe; unknown always waits for expiry and reconciliation, so neither a new prompt nor a coalesced wake can silently replay uncertain work.
Success. Updated the following files: M packages/core/src/session/execution/local.ts
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) test/session-execution-local.test.ts: (fail) SessionExecutionLocal > never re-enters its own uncertain live lease [5000.01ms] ^ this test timed out after 5000ms. 110 pass 1 fail 324 expect() calls Ran 111 tests across 4 files. [33.96s]
The new regression timed out because it closed the service’s own scope and then tried to reuse that closed coordinator, which is not a valid runtime state. I’m rewriting it to model the real case: one drain exits abnormally after marking its lease uncertain, then the still-live service receives another resume and must refuse to run it.
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
$ tsgo --noEmit
test/session-execution-local.test.ts(142,11): error TS2322: Type 'Effect<never, EffectDrizzleQueryError, never>' is not assignable to type 'Effect<void, RunError, never>'.
Type 'EffectDrizzleQueryError' is not assignable to type 'RunError'.
Type 'EffectDrizzleQueryError' is missing the following properties from type 'VariantUnavailableError': providerID, modelID, variant
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 111 pass 0 fail 326 expect() calls Ran 111 tests across 4 files. [26.59s]
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-check-3QmVvs/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-3QmVvs/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-3QmVvs/full/20260915004443_schema/migration.sql 🚀
bun test v1.3.14 (0d9b296a)
test/session-runner-recorded.test.ts:
error: Session execution claim lost: recorded-test
Session execution claim lost: recorded-test
(fail) SessionRunnerLLM recorded > executes one recorded V2 prompt through the recorded HTTP transport [725.92ms]
0 pass
1 fail
Ran 1 test across 1 file. [3.77s]
[00:46:39.503] ERROR (#2): 223 | ;
224 | error[key] = original[key];
225 | }
226 | }
227 | } else {
228 | error = new globalThis.Error(!original ? `Unknown error: ${original}` : kind === "string" ? original : formatJson(original));
^
error: Session execution claim lost: recorded-test
at causePrettyError (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:228:13)
The cassette replay exposed one stale test harness: it still used a bare process-local coordinator and therefore bypassed the required durable claim. I’m switching that harness to SessionExecutionLocal.make, so recorded transport coverage exercises the same ownership path as production.
<path>/home/byk/Code/opencode/packages/core/test/session-runner-recorded.test.ts</path>
<type>file</type>
<content>
1: import { HttpRecorder } from "@opencode-ai/http-recorder"
2: import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
3: import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
4: import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
7: import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
8: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
9: import { EventV2 } from "@opencode-ai/core/event"
10: import { EventTable } from "@opencode-ai/core/event/sql"
11: import { PermissionV2 } from "@opencode-ai/core/permission"
12: import { AgentV2 } from "@opencode-ai/core/agent"
13: import { Config } from "@opencode-ai/core/config"
14: import { ...
Success. Updated the following files: M packages/core/test/session-runner-recorded.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 1 pass 0 fail 5 expect() calls Ran 1 test across 1 file. [1.79s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[00:49:11.716] ERROR (#27075): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
<shell_metadata>
shell tool terminated command after exceeding timeout 120000 ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.
</shell_metadata>
All targeted and cassette-backed tests pass. The full Core suite exceeded the 120-second shell limit without a summary, so I’m checking for surviving test workers, then I’ll rerun the suite with enough time and treat it as failed unless it exits cleanly.
PID PPID ELAPSED COMMAND
1 0 9695172 /usr/lib/systemd/systemd --system --deserialize=37
2 0 9695172 [kthreadd]
3 2 9695172 [pool_workqueue_release]
4 2 9695172 [kworker/R-rcu_g]
5 2 9695172 [kworker/R-rcu_p]
6 2 9695172 [kworker/R-slub_]
7 2 9695172 [kworker/R-netns]
9 2 9695172 [kworker/0:0H-events_highpri]
12 2 9695172 [kworker/R-mm_pe]
13 2 9695172 [rcu_tasks_kthread]
14 2 9695172 [rcu_tasks_rude_kthread]
15 2 9695172 [rcu_tasks_trace_kthread]
16 2 9695172 [ksoftirqd/0]
17 2 9695172 [rcu_preempt]
18 2 9695172 [migration/0]
19 2 9695172 [idle_inject/0]
20 2 9695172 [cpuhp/0]
21 2 9695172 [cpuhp/1]
22 2 9695172 [idle_inject/1]
23 2 9695172 [migration/1]
24 2 9695172 [ksoftirqd/1]
26 2 9695172 [kworker/1:0H-events_highpri]
27 ...
No Bun test worker from this workspace survived the timeout; the visible Vitest workers belong to a separate workspace. I’m running the full Core suite again with a longer bound, then I’ll verify the compiled Node artifact in disposable storage without touching the running server.
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[00:58:08.190] ERROR (#27075): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
[01:00:36.291] INFO (#195249): watcher backend {
directory: "/tmp/opencode-core-test-OV4nQT",
platform: "linux",
backend: "inotify",
}
[01:00:36.343] INFO (#195358): project copy refresh started {
projectID: "global",
}
[01:00:36.351] INFO (#195358): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[01:00:36.510] INFO (#195249): booting location services {
directory: "/tmp/opencode-core-test-OV4nQT",
workspaceID: undefined,
}
1108 pass
0 fail
3040 ...
<path>/home/byk/Code/opencode/packages/opencode/package.json</path>
<type>file</type>
<content>
1: {
2: "$schema": "https://json.schemastore.org/package.json",
3: "version": "1.18.30",
4: "name": "opencode",
5: "type": "module",
6: "license": "MIT",
7: "private": true,
8: "scripts": {
9: "typecheck": "tsgo --noEmit",
10: "test": "bun test --timeout 30000 --only-failures",
11: "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
12: "bench:test": "bun run script/bench-test-suite.ts",
13: "profile:test": "bun run script/profile-test-files.ts",
14: "build": "bun run script/build.ts",
15: "build:server": "node script/build-server.mjs",
16: "dev": "bun run ./src/index.ts",
17: "dev:temporary": "bun run ./src/temporary.ts"
18: ...
opencode script {
"channel": "byk/cumulative",
"version": "0.0.0-byk/cumulative-202609150109",
"preview": true,
"release": false,
"team": [
"adamdotdevin",
"arvsrn",
"Brendonovich",
"fwang",
"Hona",
"iamdavidhill",
"jayair",
"jlongster",
"kitlangton",
"kommander",
"ludvigrask",
"MrMushrooooom",
"neriousy",
"nexxeln",
"R44VC0RP",
"rekram1-node",
"thdxr",
"simonklee",
"Slickstef11",
"usrnk1",
"vimtor",
"StarpTech",
"actions-user",
"opencode",
"opencode-agent[bot]"
]
}
Loaded models.dev snapshot
Build complete
No files found
<path>/home/byk/Code/opencode/packages/opencode/script/build-node.ts</path>
<type>file</type>
<content>
1: #!/usr/bin/env bun
2:
3: import { Script } from "@opencode-ai/script"
4: import path from "path"
5: import { fileURLToPath } from "url"
6:
7: const __filename = fileURLToPath(import.meta.url)
8: const __dirname = path.dirname(__filename)
9: const dir = path.resolve(__dirname, "..")
10:
11: process.chdir(dir)
12:
13: const generated = await import("./generate.ts")
14:
15: await Bun.build({
16: target: "node",
17: entrypoints: ["./src/node.ts"],
18: outdir: "./dist/node",
19: format: "esm",
20: sourcemap: "linked",
21: external: ["jsonc-parser", "@lydell/node-pty"],
22: define: {
23: OPENCODE_MODELS_DEV: generated.modelsData,
24: OPENCODE_VERSION: `'${Script.version}'`,
25: OPENCODE_CHANNEL: `'${Script.channel}'`,
26: },
27: files: {
28: "opencode-web-ui.gen.ts": "",
29: },
30: })
31:
32: console.log("Build complete")
(End of file - total ...
<path>/home/byk/Code/opencode/packages/opencode/dist/node</path> <type>directory</type> <entries> node.js node.js.map photon_rs_bg-bq08arze.wasm tree-sitter-3jzf13jk.wasm tree-sitter-bash-hq5s6fxb.wasm tree-sitter-powershell-ryb2ffqs.wasm (6 entries) </entries>
<path>/home/byk/Code/opencode/packages/opencode/src/node.ts</path>
<type>file</type>
<content>
1: export { Config } from "@/config/config"
2: export { Server } from "./server/server"
3: export { bootstrap } from "./cli/bootstrap"
4: export { Database } from "@opencode-ai/core/database/database"
(End of file - total 4 lines)
</content>
<path>/home/byk/Code/opencode/packages/opencode/script/build-server.mjs</path>
<type>file</type>
<content>
1: #!/usr/bin/env node
2:
3: import { spawn } from "node:child_process"
4: import { cp, copyFile, mkdir, readFile, readdir, rename, rm } from "node:fs/promises"
5: import { createRequire } from "node:module"
6: import path from "node:path"
7: import { fileURLToPath } from "node:url"
8: import { build } from "esbuild"
9: import { fossilize } from "fossilize"
10:
11: const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
12: const root = path.resolve(dir, "../..")
13: const staging = path.join(dir, ".fossilize")
14: const outdir = path.join(dir, "dist/server")
15: const platform = process.platform === "win32" ? "win" : process.platform
16: const target = `${platform}-${process.arch}`
17: const outputName = process.platform === "win32" ? "opencode-server.exe" : "opencode-server"
18: const fossilizedName = `opencode-server-${target}${process.platform === ...
The full Core suite passes: 1,108 tests across 145 files. I’m now building the actual Node SEA, then I’ll run it on an isolated database and loopback port to verify startup, migration application, health, and clean shutdown without touching the live service.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a2a0cf82001ZRH41DC6FWMz28 dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
drwxr-xr-x 2 byk byk 4096 Sep 14 22:06 /tmp/opencode
(no output) <shell_metadata> shell tool terminated command after exceeding timeout 1000 ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds. </shell_metadata>
The SEA build succeeded. The first smoke launch proved it starts but the shell timeout killed it before a probe; I’m replacing that with one bounded Node harness that spawns only the disposable binary, waits for HTTP readiness, verifies authenticated API and database migration state, sends SIGTERM, and confirms a clean exit.
Found 24 matches
/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/public.ts:
Line 155: if ((path === "/event" || path === "/global/event" || path === "/api/event") && method === "get") {
Line 165: : path === "/global/event"
/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/server.ts:
Line 91: import { globalHandlers } from "./handlers/global"
Line 132: // - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi.
/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts:
Line 11: const STREAMING_PATHS = new Set(["/event", "/global/event"])
/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/api.ts:
Line 31: import { GlobalApi } from "./groups/global"
/home/byk/Code/opencode/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts:
Line 2: import { GlobalBus, type ...
(no output) <shell_metadata> shell tool terminated command after exceeding timeout 30000 ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds. </shell_metadata>
The isolated health probe likely succeeded, but the harness attached its exit listener after sending SIGTERM, so a fast clean exit could be missed and appear as a timeout. I’m checking only the disposable port and binary, then I’ll rerun with the exit promise registered before shutdown.
2545348 /usr/bin/bash -c pgrep -af '/tmp/opencode/session-recovery-smoke|dist/server/opencode-server|node -e' || true
{"health":{"healthy":true,"version":"1.18.30"},"database":"/tmp/opencode/session-recovery-smoke/opencode.db","exit":{"code":0,"signal":null}}
CREATE TABLE `session_execution` (
`id` text PRIMARY KEY,
`session_id` text NOT NULL,
`owner_id` text NOT NULL,
`assistant_message_id` text,
`phase` text NOT NULL,
`time_created` integer NOT NULL,
`expires_at` integer NOT NULL,
CONSTRAINT `fk_session_execution_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
CREATE UNIQUE INDEX `session_execution_session_idx` ON `session_execution` (`session_id`);
CREATE INDEX `session_execution_owner_idx` ON `session_execution` (`owner_id`);
1ecc9afe36c12b31a8892cdbfb8a1560ceec97255c3537c073b795351233be40 packages/opencode/dist/server/opencode-server
c3df65fea4c49d2b3138ab81a35bd0363a80d871b27e2337d5a50562aeaa6e20 packages/core/src/database/migration/20260914235657_session-execution-lease.ts
M .lore.md
M AGENTS.md
M packages/core/schema.json
M packages/core/src/database/migration.gen.ts
M ...
The built artifact passed isolated startup, migration, authenticated health, and clean shutdown. Before freezing reviews, I’m separating the current recovery delta from earlier committed recovery work and unrelated AGENTS.md/profiling files, then I’ll prove the new fencing regressions fail on the exact parent state rather than relying on the diff.
dc661f30cb (HEAD -> byk/cumulative) feat(core): recover interrupted sessions ab9408c81c feat(app): adapt vertical tab density 2e9407768d fix(server): bound Node shutdown 2c92569e62 fix(app): prevent stale WebUI asset loads b98c698390 feat(server): add Fossilize standalone builds a5a9921d9f feat(app): resizable/collapsible vertical tab rail + review-panel toggle 1124689311 feat(app): add optional vertical tab rail d23a17b77f fix(app): show worktree selector on prod and label session tabs by worktree f688e4fd05 fix(app): suppress reconnect toast flood and mobile bottom-row cutoff 0319dc8244 fix(app): faster message loading, subtle sync indicator, stable composer ...
The recovery feature is already committed at dc661f30cb; the current worktree contains the lease/fencing corrections discovered during validation. I’m treating dc661f30cb as the failing base, applying only the new regressions in an isolated sibling worktree, and recording their exact failures before the corrected candidate is reviewed.
No files found
drwxrwxr-x 1 byk byk 4166 Sep 14 23:06 /home/byk/Code Preparing worktree (detached HEAD dc661f30cb) Updating files: 22% (1495/6642) Updating files: 23% (1528/6642) Updating files: 24% (1595/6642) Updating files: 25% (1661/6642) Updating files: 26% (1727/6642) Updating files: 27% (1794/6642) Updating files: 28% (1860/6642) Updating files: 29% (1927/6642) Updating files: 30% (1993/6642) Updating files: 31% (2060/6642) Updating files: 32% (2126/6642) Updating files: 33% (2192/6642) Updating files: 34% (2259/6642) Updating files: 35% (2325/6642) Updating files: 36% (2392/6642) Updating files: 37% (2458/6642) Updating files: 38% (2524/6642) Updating files: 39% (2591/6642) Updating files: 40% (2657/6642) Updating files: 41% (2724/6642) Updating files: 42% (2790/6642) Updating files: 43% (2857/6642) Updating files: 44% (2923/6642) Updating files: 45% (2989/6642) Updating files: 46% (3056/6642) Updating files: 47% (3122/6642) Updating files: 48% (3189/6642) Updating ...
(no output)
bun test v1.3.14 (0d9b296a) test/session-execution-local.test.ts: # Unhandled error between tests ------------------------------- error: Cannot find package 'effect' from '/home/byk/Code/opencode-recovery-base-test/packages/core/test/session-execution-local.test.ts' ------------------------------- test/session-projector.test.ts: # Unhandled error between tests ------------------------------- error: Cannot find package 'effect' from '/home/byk/Code/opencode-recovery-base-test/packages/core/test/session-projector.test.ts' ------------------------------- 0 pass 2 fail 2 errors Ran 2 tests across 2 files. [138.00ms]
The base test did not reach code: the temporary worktree linked the repository root’s isolated dependency store, not Core’s package-level dependency links. I’m correcting only that disposable symlink and rerunning so the result proves the base behavior rather than a setup failure.
drwxr-xr-x 1 byk byk 748 Sep 14 23:03 /home/byk/Code/opencode/packages/core/node_modules
lrwxrwxrwx 1 byk byk 36 Sep 15 01:29 node_modules -> /home/byk/Code/opencode/node_modules
bun test v1.3.14 (0d9b296a)
test/session-execution-local.test.ts:
277 | }
278 |
279 | assertUnreachable(cacheStrat)
280 | }).pipe(
281 | Effect.catch((e) => {
282 | return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
^
error: Failed query: insert into "session_execution" ("id", "session_id", "owner_id", "assistant_message_id", "phase", "time_created") values (?, ?, ?, null, ?, ?)
params: dbc80663-7920-49fb-93f1-14abcd7a4b9e,ses_execution_local,3f4b46aa-12ad-4569-b68c-a5b55aae0b76,safe,1789435801236
query: "insert into \"session_execution\" (\"id\", \"session_id\", \"owner_id\", \"assistant_message_id\", \"phase\", \"time_created\") values (?, ?, ?, null, ?, ?)",
params: [
...
The failing-base run now reaches the old implementation and fails the new ownership/recovery cases, which proves the regressions are not false positives. During the final diff audit I found a policy issue in the current startup loop: its broad pending-inbox scan would also wake intentionally admit-only prompts, so I’m narrowing recovery to expired execution claims only before freezing the candidate.
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, desc, eq, isNull, lt } from "drizzle-orm"
4: import { DateTime, Effect, Layer, Schedule } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionMessage } from "./message"
11: import { SessionProjector } from "./projector"
12: import { SessionExecutionTable, SessionInputTable, SessionRecoveryTable, SessionMessageTable } from "./sql"
13:
14: export const recover = Effect.fn("SessionRecovery.recover")(function* () {
15: const { db } = yield* Database.Service
16: const events = yield* EventV2.Service
17: const execution = yield* SessionExecution.Service
18: const claims = yield* db
19: ...
Success. Updated the following files: M packages/core/src/session/recovery.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 112 pass 0 fail 331 expect() calls Ran 112 tests across 5 files. [22.35s] Reading config file '/tmp/opencode-core-migration-check-nmROhH/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-nmROhH/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-nmROhH/full/20260915013225_schema/migration.sql 🚀
.lore.md AGENTS.md packages/core/schema.json packages/core/src/database/migration.gen.ts packages/core/src/database/schema.gen.ts packages/core/src/session/compaction.ts packages/core/src/session/execution/local.ts packages/core/src/session/message-updater.ts packages/core/src/session/projector.ts packages/core/src/session/recovery.ts packages/core/src/session/runner/index.ts packages/core/src/session/runner/llm.ts packages/core/src/session/runner/publish-llm-event.ts packages/core/src/session/sql.ts packages/core/test/session-execution-local.test.ts packages/core/test/session-projector.test.ts packages/core/test/session-runner-recorded.test.ts packages/core/test/session-runner-tool-events.test.ts packages/core/test/session-runner.test.ts packages/schema/src/session-event.ts cb36de4216cf239790fb430e6ec9807d3ce898e5bba9a0927b19218b1a0c7c9a - dc661f30cb2055ad6248fdc59b14998d834497be M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M ...
The candidate hash above omitted the untracked lease migration and included unrelated AGENTS.md; it is not a valid freeze. I’m creating a canonical patch manifest that includes every recovery file plus the new migration, excludes unrelated files, and records each blob and artifact hash before launching the two read-only reviews.
06b5590ed4603338073931e0453be4794dfc092b369869b8a7a391258f909096 /tmp/opencode/session-recovery-candidate.patch 1ecc9afe36c12b31a8892cdbfb8a1560ceec97255c3537c073b795351233be40 packages/opencode/dist/server/opencode-server c3df65fea4c49d2b3138ab81a35bd0363a80d871b27e2337d5a50562aeaa6e20 packages/core/src/database/migration/20260914235657_session-execution-lease.ts dc661f30cb2055ad6248fdc59b14998d834497be ab9408c81cc9916177641ae8e9f08624fc27b5b9 12 2 packages/core/schema.json 1 0 packages/core/src/database/migration.gen.ts 1 0 packages/core/src/database/schema.gen.ts 9 2 packages/core/src/session/compaction.ts 61 16 packages/core/src/session/execution/local.ts 10 3 packages/core/src/session/message-updater.ts 3 2 packages/core/src/session/projector.ts 23 19 packages/core/src/session/recovery.ts 1 0 packages/core/src/session/runner/index.ts 62 70 packages/core/src/session/runner/llm.ts 28 19 packages/core/src/session/runner/publish-llm-event.ts 4 1 packages/core/src/session/sql.ts 81 ...
The first manifest covers only validation fixes atop dc661f30cb, but reviewers must inspect the whole recovery feature, not just the follow-up. I’m freezing the full candidate from parent ab9408c81c through the current source, including all committed and untracked migrations, then both reviewers will receive that exact immutable patch hash and artifact hash.
dc661f30cb feat(core): recover interrupted sessions .lore.md | 132 +++--- packages/core/schema.json | 444 ++++++++++++++++++--- packages/core/src/database/migration.gen.ts | 3 + .../migration/20260914170650_session-recovery.ts | 25 ++ .../migration/20260914214636_session-execution.ts | 26 ++ .../20260914222526_session-execution-phase.ts | 11 + packages/core/src/database/schema.gen.ts | 32 ++ packages/core/src/session/compaction.ts | 21 +- packages/core/src/session/execution/local.ts | 68 +++- packages/core/src/session/message-updater.ts | 46 +++ packages/core/src/session/projector.ts | 52 ++- packages/core/src/session/recovery.ts | 117 ++++++ packages/core/src/session/runner/llm.ts | 90 ++++- .../core/src/session/runner/publish-llm-event.ts | 50 ++- packages/core/src/session/sql.ts | 44 ...
f051a53df476dd924bda0a14c4cd91a63c9e7eafff53bbe71f0460571335fcbd /tmp/opencode/session-recovery-full.patch 1ecc9afe36c12b31a8892cdbfb8a1560ceec97255c3537c073b795351233be40 packages/opencode/dist/server/opencode-server
Candidate frozen: base ab9408c81c, committed head dc661f30cb, full source patch SHA-256 f051a53d…fcbd, SEA SHA-256 1ecc9afe…e40. I’m launching separate read-only correctness and security reviews against that exact patch; implementation stays frozen until both return substantive evidence.
<task id="ses_f5d4cc599ffekZ1QltlHFHOi3D" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f5d4c5a20ffe1L5P4tCGWVZvLq" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Independent correctness and security reviews are running against the frozen full patch and SEA artifact. Implementation remains frozen pending both verdicts.
continue. the server was restarted so you'll have to nudge your background agents to continue too.
The candidate remains frozen at patch SHA-256 f051a53d…fcbd. I’m resuming both independent reviewers in their existing sessions; no implementation changes will occur until both return evidence-backed verdicts.
<task id="ses_f5d4cc599ffekZ1QltlHFHOi3D" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f5d4c5a20ffe1L5P4tCGWVZvLq" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Both frozen reviews resumed successfully. I’ll act on their findings when they complete.
/tmp/opencode/session-recovery-full.patch no longer exists after the restart:
sha256sum: /tmp/opencode/session-recovery-full.patch: No such file or directory
I cannot verify the required patch hash or complete the revision-bound review against its immutable scope. The SEA still matches 1ecc9afe36c12b31a8892cdbfb8a1560ceec97255c3537c073b795351233be40, and HEAD remains dc661f30cb2055ad6248fdc59b14998d834497be, but neither proves that the current validation delta equals the canonical patch.
Restore the exact patch at the stated path and rerun the review. No security approval can survive a failed frozen-scope check.
DO-NOT-MERGE </task_result> </task>
The security reviewer correctly rejected the review because /tmp lost the immutable patch during restart. I’m reconstructing the canonical patch from the unchanged scoped source, requiring the original SHA-256 match, then I’ll resume the same reviewer against the restored evidence.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session/compaction.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/session-execution-local.test.ts M packages/core/test/session-projector.test.ts M packages/core/test/session-runner-recorded.test.ts M packages/core/test/session-runner-tool-events.test.ts M packages/core/test/session-runner.test.ts M packages/schema/src/session-event.ts ?? packages/core/src/database/migration/20260914235657_session-execution-lease.ts ?? perf.data ?? ...
f051a53df476dd924bda0a14c4cd91a63c9e7eafff53bbe71f0460571335fcbd /tmp/opencode/session-recovery-full.patch
The reconstructed patch exactly matches the original f051a53d…fcbd; source and SEA identities are unchanged. I’m resuming the same security reviewer now, while the correctness reviewer continues independently.
<task id="ses_f5d4c5a20ffe1L5P4tCGWVZvLq" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The exact frozen patch was restored with the original hash. The security review resumed; correctness review remains active.
</task_result> </task>
The resumed security session returned no review evidence, so it is invalid. Per the review gate, I’m replacing it with a fresh independent security reviewer against the same unchanged patch and artifact rather than weakening the gate or retrying that session again.
f051a53df476dd924bda0a14c4cd91a63c9e7eafff53bbe71f0460571335fcbd /tmp/opencode/session-recovery-full.patch 1ecc9afe36c12b31a8892cdbfb8a1560ceec97255c3537c073b795351233be40 packages/opencode/dist/server/opencode-server dc661f30cb2055ad6248fdc59b14998d834497be ab9408c81cc9916177641ae8e9f08624fc27b5b9
<task id="ses_f5c4af43effeQDgAccCGMjQDul" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
A fresh security reviewer is active against the unchanged frozen candidate. The prior empty review was discarded.
continue. the server was restarted so you'll have to nudge your background agents to continue too.
The server restart may have removed the frozen patch again. I’m restoring and verifying the exact immutable evidence first, then I’ll resume the existing correctness reviewer and replacement security reviewer against the same candidate.
dc661f30cb2055ad6248fdc59b14998d834497be ab9408c81cc9916177641ae8e9f08624fc27b5b9 1ecc9afe36c12b31a8892cdbfb8a1560ceec97255c3537c073b795351233be40 packages/opencode/dist/server/opencode-server c3df65fea4c49d2b3138ab81a35bd0363a80d871b27e2337d5a50562aeaa6e20 packages/core/src/database/migration/20260914235657_session-execution-lease.ts f051a53df476dd924bda0a14c4cd91a63c9e7eafff53bbe71f0460571335fcbd /tmp/opencode/session-recovery-full.patch
<task id="ses_f5d4cc599ffekZ1QltlHFHOi3D" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f5c4af43effeQDgAccCGMjQDul" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Both reviewers resumed against the verified unchanged patch and SEA hashes. The candidate remains frozen.
f051a53df476dd924bda0a14c4cd91a63c9e7eafff53bbe71f0460571335fcbd.1ecc9afe36c12b31a8892cdbfb8a1560ceec97255c3537c073b795351233be40.dc661f30cb2055ad6248fdc59b14998d834497be.ab9408c81cc9916177641ae8e9f08624fc27b5b9.repo-setup skill was not available. I used the repository and nested AGENTS.md instructions directly.renew() evaluates Date.now() while constructing the repeated Effect at packages/core/src/session/execution/local.ts:36-46. The caller constructs it once and repeats that same database operation at packages/core/src/session/execution/local.ts:82-85. Every heartbeat therefore writes the original expiry rather than a new now + leaseDuration.
The lease is only 30 seconds (packages/core/src/session/sql.ts:213). A healthy provider, compaction, or tool call that emits no event for 30 seconds becomes claimable. Another process may take over through the expired-row branch at packages/core/src/session/execution/local.ts:60-76.
The stale owner may also revive an expired claim: neither renewal nor runner phase updates require an unexpired lease. Renewal checks only execution and owner IDs (packages/core/src/session/execution/local.ts:36-45); phase updates check only Session and execution IDs (packages/core/src/session/runner/llm.ts:185-197).
The takeover test never lets the stale owner resume: its first runner is permanently Effect.never, and its scope closes only after ...
Security review returned DO-NOT-MERGE with seven lifecycle and integrity blockers, including fixed-expiry heartbeats, dispatch fencing gaps, lost recovery wakes, admit-only leakage, and non-atomic cancellation. I’m keeping the candidate frozen until the independent correctness review completes, then I’ll address the combined findings without duplicating or invalidating that active review.
Security approval failed. The candidate remains frozen pending the correctness verdict; no merge or activation will occur.
Since this is a 0 to 1 feature (meaning it did not exist before) and we won't be publishing this, I vote to get this implemented if correctness review passes, reload the server, and continue with the security review findings. WDYT?
I would not reload the current candidate. The security findings are also runtime correctness failures: a 30-second lease can create a second owner, stale execution can duplicate provider/tool side effects, and recovery can lose its wake.
The 0-to-1 status lets us change the design freely, but it does not make activation safe. Better sequence:
An intermediate isolated server reload is safe; reloading the active server is not worth the risk.
Okay sounds good
The candidate remains frozen until the correctness review returns.
</task_result> </task>
The correctness reviewer returned no evidence, so its result is invalid. I’m giving that same review session its one required evidence-or-blocker retry against the unchanged candidate; if it returns empty again, I’ll replace it before changing source.
continue. the server was restarted so you'll have to nudge your background agents to continue too.
The correctness reviewer’s one same-session retry was active when the server restarted. I’m verifying the frozen evidence, then resuming that exact reviewer; source remains unchanged until it returns a substantive verdict.
dc661f30cb2055ad6248fdc59b14998d834497be ab9408c81cc9916177641ae8e9f08624fc27b5b9 1ecc9afe36c12b31a8892cdbfb8a1560ceec97255c3537c073b795351233be40 packages/opencode/dist/server/opencode-server c3df65fea4c49d2b3138ab81a35bd0363a80d871b27e2337d5a50562aeaa6e20 packages/core/src/database/migration/20260914235657_session-execution-lease.ts f051a53df476dd924bda0a14c4cd91a63c9e7eafff53bbe71f0460571335fcbd /tmp/opencode/session-recovery-full.patch
<task id="ses_f5d4cc599ffekZ1QltlHFHOi3D" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The frozen evidence still matches exactly. The correctness reviewer resumed its final retry; source remains frozen.
PASS
f051a53df476dd924bda0a14c4cd91a63c9e7eafff53bbe71f0460571335fcbd.1ecc9afe36c12b31a8892cdbfb8a1560ceec97255c3537c073b795351233be40.dc661f30cb2055ad6248fdc59b14998d834497be.ab9408c81cc9916177641ae8e9f08624fc27b5b9 and HEAD exist as immutable commits.An expired runner can renew its lease and continue provider or tool work.
renew checks only execution and owner IDs; it never requires the current lease to remain unexpired (packages/core/src/session/execution/local.ts:36-45). Runner mutations likewise check only Session and execution IDs (packages/core/src/session/runner/llm.ts:185-197). Those mutations guard provider dispatch at packages/core/src/session/runner/llm.ts:318-321, tool execution at packages/core/src/session/runner/llm.ts:287-312, and durable event commits through packages/core/src/session/runner/publish-llm-event.ts:58-65.
After expiry—but before takeover—the stale runner can renew, change phases, publish events, and dispatch side effects. Every boundary must require the exact execution fence and an unexpired lease, with exactly one affected row.
The heartbeat repeatedly writes one fixed expiry timestamp.
Date.now() + leaseDuration is evaluated while constructing renew(executionID) (packages/core/src/session/execution/local.ts:36-40), then that same Effect is repeated at packages/core/src/session/execution/local.ts:82-84. A healthy silent provider, compaction, or tool operation therefore becomes expired after 30 seconds despite heartbeat updates.
The timestamp must be evaluated inside each repetition. Renewal must also fail once the current lease expires.
**Acquisition bypasses recovery and can silently replace uncertain ...
Both reviews agree on nine concrete blockers. I’m replacing the unsafe time-based takeover model rather than patching symptoms: first I’ll trace the database, coordinator, input-admission, cancellation, and compaction boundaries together, then implement a claim lifecycle that cannot let an expired owner revive or bypass reconciliation.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "in_progress",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic/durable",
"status": "pending",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "pending",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, and poison-row regressions",
"status": "pending",
"priority": "high"
},
{
"content": "Run package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness/security approval",
"status": "pending",
"priority": "high"
}
]
<skill_content name="effect">
# Skill: effect
# Effect
This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
## Source Of Truth
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
3. Also inspect existing repo code for local house style before introducing new patterns.
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
## Guidelines
- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
- Use `Effect.gen(function* () { ... })` for multi-step workflows.
- Use `Effect.fn("Name")` or ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, eq, inArray, lt, or } from "drizzle-orm"
2: import { Cause, Duration, Effect, Exit, Layer, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionSchema } from "../schema"
9: import { SessionStore } from "../store"
10: import { SessionExecution } from "../execution"
11: import { SESSION_EXECUTION_LEASE_MS, SessionExecutionTable } from "../sql"
12:
13: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
14: export const make = Effect.fnUntraced(function* (input: {
15: readonly run: (
16: sessionID: SessionSchema.ID,
17: force: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
100: const agents = yield* AgentV2.Service
101: const tools = yield* ToolRegistry.Service
102: const models = yield* SessionRunnerModel.Service
103: const store = yield* SessionStore.Service
104: const location = yield* Location.Service
105: const systemContext = yield* SystemContextRegistry.Service
106: const skillGuidance = yield* SkillGuidance.Service
107: const referenceGuidance = yield* ReferenceGuidance.Service
108: const config = yield* Config.Service
109: const snapshots = yield* Snapshot.Service
110: const db = (yield* Database.Service).db
111: const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
112: const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
113: const session = yield* store.get(sessionID)
114: if (!session) return ...
The reviews expose two separate state machines that the current API conflates: ordinary inbox drains and recovery drains. I’m splitting them so startup can durably redrive only the synthetic continuation, never unrelated admit-only input, while tightening every lease mutation and dispatch boundary to require an unexpired exact execution fence.
<path>/home/byk/Code/opencode/packages/core/src/session/execution.ts</path>
<type>file</type>
<content>
1: export * as SessionExecution from "./execution"
2:
3: import { Context, Effect, Layer } from "effect"
4: import { LayerNode } from "../effect/layer-node"
5: import { Node } from "../effect/app-node"
6: import { SessionRunner } from "./runner/index"
7: import { SessionSchema } from "./schema"
8:
9: export interface Interface {
10: /** Snapshots active execution owned by this process. */
11: readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
12: /** Starts execution while idle or joins the active execution. */
13: readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
14: /** Registers newly recorded work. Repeated wakeups may coalesce. */
15: readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
16: /** Interrupt active work owned by this process. Idle interruption is a no-op. ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
1: export * as SessionInput from "./input"
2:
3: import { and, asc, eq, isNull, lte } from "drizzle-orm"
4: import { DateTime, Effect, Schema } from "effect"
5: import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
6: import type { Database } from "../database/database"
7: import type { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionMessage } from "./message"
10: import { Prompt } from "./prompt"
11: import { SessionSchema } from "./schema"
12: import { SessionInputTable, SessionMessageTable } from "./sql"
13:
14: type DatabaseService = Database.Interface["db"]
15:
16: export { Admitted, Delivery }
17:
18: const decodePrompt = Schema.decodeUnknownSync(Prompt)
19: const encodePrompt = Schema.encodeSync(Prompt)
20:
21: const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
22: Admitted.make({
23: ...
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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?: ...
The central constraint is now clear: no ordinary wake may overwrite an expired claim, and recovery must carry its own durable, targeted wake intent. I’m implementing that as one ownership protocol: expired claims reconcile first, recovery continuation dispatch remains durable until its exact input is promoted, and stale owners fail every lease/phase/event/dispatch check once expiry or takeover occurs.
<task id="ses_f5a7ac281ffehBff5DHoeRi5vJ" state="completed"> <task_result> ## Design - Added lease-fenced execution claims keyed by session, execution, and owner. Every mutation checks an unexpired lease and exactly one row. - Ordinary acquisition never replaces claims. Recovery exclusively owns expired-claim reconciliation. - Added durable, exact recovery wake state. Recovery always promotes only its synthetic queued `continue` input. - Made cancellation settle `Step.Interrupted` and delete the exact claim atomically. Successor wakes start only after cleanup. - Kept compaction unsafe until `Compaction.Ended` commits. - Added bounded, deterministic recovery batches and malformed-phase quarantine. - Derived recovery message IDs from execution IDs and transactionally reject cross-session assistant relationships. - Squashed the unpublished recovery migrations into one populated-predecessor-safe migration with `NOT NULL`, phase `CHECK`, and correct generated PK metadata. ...
The implementation agent completed a coherent redesign and reports 1,124 Core tests passing. I’m auditing the actual diff now, especially the new claim primitive, targeted recovery outbox, migration squash, and whether the regressions truly exercise stale-owner resumption rather than only static claim loss.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/claim.ts</path>
<type>file</type>
<content>
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: ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, eq } from "drizzle-orm"
2: import { Cause, Clock, Duration, Effect, Exit, Layer, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { SessionExecutionClaim } from "./claim"
13: import { SESSION_EXECUTION_LEASE_MS, SessionExecutionTable, SessionRecoveryTable } from "../sql"
14:
15: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, gt, isNull, lte } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { continuation } from "./recovery-id"
13: import {
14: SESSION_EXECUTION_LEASE_MS,
15: SessionExecutionTable,
16: SessionRecoveryTable,
17: SessionMessageTable,
18: } from "./sql"
19:
20: export const BATCH_SIZE = 100
21: const Phase = Schema.Literals(["ready", "safe", "unknown"])
22:
23: export const recover = ...
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
145: session_id: text()
146: .$type<SessionSchema.ID>()
147: .notNull()
148: .references(() => SessionTable.id, { onDelete: "cascade" }),
149: prompt: text({ mode: "json" }).notNull().$type<Prompt>(),
150: delivery: text().$type<SessionInput.Delivery>().notNull(),
151: admitted_seq: integer().notNull(),
152: promoted_seq: integer(),
153: time_created: integer()
154: .notNull()
155: .$default(() => Date.now()),
156: },
157: (table) => [
158: index("session_input_session_pending_delivery_seq_idx").on(
159: table.session_id,
160: table.promoted_seq,
161: table.delivery,
162: table.admitted_seq,
163: ),
164: uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq),
165: uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery-id.ts</path>
<type>file</type>
<content>
1: import { SessionMessage } from "./message"
2:
3: export const message = (executionID: string, assistantMessageID?: SessionMessage.ID) =>
4: assistantMessageID ?? SessionMessage.ID.make(`msg_recovery_${executionID}`)
5:
6: export const continuation = (executionID: string) => SessionMessage.ID.make(`msg_recovery_continue_${executionID}`)
(End of file - total 6 lines)
</content>
Found 56 matches
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 27: const ownerID = crypto.randomUUID()
Line 44: .set({ owner_id: ownerID, expires_at: now + SESSION_EXECUTION_LEASE_MS })
Line 58: const recoveryClaim = { sessionID: claim.session_id, executionID: claim.id, ownerID }
Line 69: eq(SessionExecutionTable.owner_id, ownerID),
Line 91: yield* SessionExecutionClaim.clear(db, recoveryClaim)
Line 107: { commit: () => SessionExecutionClaim.clear(db, recoveryClaim) },
Line 144: .where(eq(SessionRecoveryTable.wake_pending, true))
/home/byk/Code/opencode/packages/core/src/session/projector.ts:
Line 430: wake_pending: true,
/home/byk/Code/opencode/packages/core/src/session/sql.ts:
Line 185: wake_pending: integer({ mode: "boolean" }).notNull().default(true),
/home/byk/Code/opencode/packages/core/src/session/context-epoch.ts:
Line 134: yield* ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
115: const session = yield* store.get(sessionID)
116: if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
117: return session
118: })
119:
120: const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
121: return yield* store.context(sessionID)
122: })
123: const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
124: sessionID: SessionSchema.ID,
125: claim: SessionExecutionClaim.Claim,
126: ) {
127: for (const message of yield* getContext(sessionID)) {
128: if (message.type !== "assistant") continue
129: for (const tool of message.content) {
130: if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue
131: yield* events.publish(
132: ...
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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, runs cleanup, then starts work that arrived while stopping. */
14: readonly interrupt: (key: Key, cleanup?: Effect.Effect<void>) => Effect.Effect<void>
15: }
16:
17: type Entry<E> = {
18: ...
The first audit found one remaining liveness hole: if a normal prompt wake coalesces with a pending recovery wake, the recovery drain intentionally promotes only continue, then can consume the only advisory wake and strand the normal prompt. The durable model must record whether each admitted input requested execution, so recovery can preserve resume: false while redriving legitimate prompts after its exact continuation.
Found 27 matches
/home/byk/Code/opencode/packages/core/src/session.ts:
Line 152: resume?: boolean
Line 158: resume?: boolean
Line 164: resume?: boolean
Line 169: readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
Line 368: const admitted = yield* SessionInput.admit(db, events, {
Line 382: if (input.resume !== false) yield* execution.wake(admitted.sessionID)
Line 426: resume: Effect.fn("V2Session.resume")(function* (sessionID) {
Line 428: yield* execution.resume(sessionID)
/home/byk/Code/opencode/packages/core/src/process.ts:
Line 102: Effect.callback<never, Error>((resume) => {
Line 104: resume(Effect.fail(abortError(signal)))
Line 107: const onabort = () => resume(Effect.fail(abortError(signal)))
/home/byk/Code/opencode/packages/core/src/cross-spawn-spawner.ts:
Line 268: Effect.callback<readonly ...
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path>
<type>file</type>
<content>
130: readonly context: (
131: sessionID: SessionSchema.ID,
132: ) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
133: readonly events: (input: {
134: sessionID: SessionSchema.ID
135: after?: number
136: }) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
137: readonly history: (input: {
138: sessionID: SessionSchema.ID
139: after?: number
140: limit: number
141: }) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
142: readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
143: readonly switchModel: (input: {
144: sessionID: SessionSchema.ID
145: model: ModelV2.Ref
146: }) => Effect.Effect<void, NotFoundError>
147: readonly prompt: (input: {
148: id?: SessionMessage.ID
149: ...
The durable wake fix needs one additional read-model field, not a broader event change: session_input.wake_pending is set atomically in the existing PromptAdmitted transaction, cleared on promotion, and scanned at startup. This preserves replay compatibility, keeps resume:false rows false, and ensures a normal wake coalesced behind recovery becomes a successor drain rather than disappearing.
<task id="ses_f5a7ac281ffehBff5DHoeRi5vJ" state="completed"> <task_result> ## Summary Implemented durable ordinary prompt wakes alongside the interrupted-session recovery protocol: - Prompt admission atomically records `session_input.wake_pending` when `resume !== false`. - Exact retries can convert admit-only input into durable execution intent. - Promotion atomically clears the pending wake. - Recovery scans pending ordinary inputs in deterministic, bounded Session batches and redrives advisory wakes. - Admit-only inputs remain excluded. - Existing lease fencing, exact recovery continuation, cancellation ordering, compaction safety, malformed-claim quarantine, and cross-Session validation remain intact. ## Files changed Production and schema: - `packages/core/schema.json` - `packages/core/src/database/migration.gen.ts` - `packages/core/src/database/schema.gen.ts` - `packages/core/src/database/migration/20260914170650_session-recovery.ts` - `packages/core/src/session.ts` - ...
Durable wake intent now survives both admission-to-wake crashes and recovery/ordinary wake coalescing; 1,129 Core tests pass. I’m completing the source audit before artifact validation, with attention to transactional promotion, interrupt ordering, recovery ownership transfer, and migration SQL rather than relying on the agent’s test totals.
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
1: export * as SessionInput from "./input"
2:
3: import { and, asc, eq, isNull, lte } from "drizzle-orm"
4: import { DateTime, Effect, Schema } from "effect"
5: import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
6: import type { Database } from "../database/database"
7: import type { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionMessage } from "./message"
10: import { Prompt } from "./prompt"
11: import { SessionSchema } from "./schema"
12: import { SessionInputTable, SessionMessageTable } from "./sql"
13:
14: type DatabaseService = Database.Interface["db"]
15:
16: export { Admitted, Delivery }
17:
18: const decodePrompt = Schema.decodeUnknownSync(Prompt)
19: const encodePrompt = Schema.encodeSync(Prompt)
20:
21: const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
22: Admitted.make({
23: ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
318: export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
319: db: DatabaseService,
320: events: EventV2.Interface,
321: sessionID: SessionSchema.ID,
322: commit?: (row: typeof SessionInputTable.$inferSelect) => Effect.Effect<void>,
323: ) {
324: const row = yield* db
325: .select()
326: .from(SessionInputTable)
327: .where(
328: and(
329: eq(SessionInputTable.session_id, sessionID),
330: isNull(SessionInputTable.promoted_seq),
331: eq(SessionInputTable.delivery, "queue"),
332: ),
333: )
334: .orderBy(asc(SessionInputTable.admitted_seq))
335: .limit(1)
336: .get()
337: .pipe(Effect.orDie)
338: return row === undefined ? false : yield* publish(db, events, sessionID, [row], commit).pipe(Effect.as(true))
339: })
340:
341: export const promoteExact = ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
130: Effect.logError("Failed to recover Session", cause).pipe(
131: Effect.annotateLogs({ sessionID: claim.session_id, executionID: claim.id }),
132: ),
133: ),
134: ),
135: { discard: true, concurrency: 1 },
136: )
137:
138: const pending = yield* db
139: .select({
140: sessionID: SessionRecoveryTable.session_id,
141: executionID: SessionRecoveryTable.execution_id,
142: continuationMessageID: SessionRecoveryTable.continuation_message_id,
143: })
144: .from(SessionRecoveryTable)
145: .where(eq(SessionRecoveryTable.wake_pending, true))
146: .orderBy(asc(SessionRecoveryTable.time_created), asc(SessionRecoveryTable.execution_id))
147: .limit(BATCH_SIZE)
148: .all()
149: .pipe(Effect.orDie)
150: yield* Effect.forEach(
151: pending,
152: (row) =>
153: ...
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260914170650_session-recovery",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`ALTER TABLE \`session_input\` ADD \`wake_pending\` integer DEFAULT false NOT NULL;`)
9: yield* tx.run(`
10: CREATE TABLE \`session_execution\` (
11: \`id\` text NOT NULL,
12: \`session_id\` text NOT NULL,
13: \`owner_id\` text NOT NULL,
14: \`assistant_message_id\` text,
15: \`phase\` text NOT NULL,
16: \`time_created\` integer NOT NULL,
17: \`expires_at\` integer NOT NULL,
18: \`recovery_error\` text,
19: CONSTRAINT \`session_execution_pk\` PRIMARY KEY(\`id\`),
20: CONSTRAINT ...
Found 15 matches
/home/byk/Code/opencode/packages/core/src/session/projector.ts:
Line 448: yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
/home/byk/Code/opencode/packages/core/src/session/compaction.ts:
Line 78: readonly eventCommit?: () => Effect.Effect<void>
Line 206: input.eventCommit ? { commit: input.eventCommit } : undefined,
Line 234: SessionEvent.Compaction.Ended,
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 269: eventCommit: () => updateExecution(),
Line 287: interruptedCommit: () => SessionExecutionClaim.clear(db, claim),
Line 288: eventCommit: () => updateExecution(),
Line 366: eventCommit: () => updateExecution(),
Line 391: yield* withPublication(publisher.interruptAssistant("Provider Step outcome unknown after interruption"))
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line ...
<path>/home/byk/Code/opencode/packages/core/src/session/compaction.ts</path>
<type>file</type>
<content>
185: const context = input.model.route.defaults.limits?.context
186: if (context === undefined || context <= 0) return false
187: const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
188: const selected = select(input.entries, config.tokens)
189: const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message
190: if (!selected || (selected.head.length === 0 && previousSummary?.type !== "compaction")) return false
191: const summaryPrompt = buildPrompt({
192: previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
193: context: [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head].filter(Boolean),
194: })
195: const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
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 assistantMessageID = yield* startAssistant()
244: assistantActive ...
<path>/home/byk/Code/opencode/packages/core/src/session/context-epoch.ts</path>
<type>file</type>
<content>
100: .from(SessionContextEpochTable)
101: .where(eq(SessionContextEpochTable.session_id, sessionID))
102: .get()
103: .pipe(Effect.orDie)) !== undefined
104: )
105: })
106:
107: const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
108: return yield* db
109: .select()
110: .from(SessionContextEpochTable)
111: .where(eq(SessionContextEpochTable.session_id, sessionID))
112: .get()
113: .pipe(Effect.orDie)
114: })
115:
116: export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
117: db: DatabaseService,
118: sessionID: SessionSchema.ID,
119: ) {
120: yield* db
121: .delete(SessionContextEpochTable)
122: .where(eq(SessionContextEpochTable.session_id, sessionID))
123: .run()
124: .pipe(Effect.orDie)
125: })
126:
127: const insert = ...
The audit found one real fencing gap that tests missed: SessionContextEpoch.advance checks the lease and then updates the epoch outside that transaction. A takeover between those statements lets the stale owner mutate durable context; I’m making that write atomic under the claim transaction and adding a takeover regression before broader validation.
<task id="ses_f5a7ac281ffehBff5DHoeRi5vJ" state="completed"> <task_result> ## Changes - `packages/core/src/session/context-epoch.ts` - Moved claim renewal and Context Epoch snapshot update into one immediate `SessionExecutionClaim.transaction(...)`. - `packages/core/test/session-projector.test.ts` - Added deterministic takeover regression through real `SessionContextEpoch.prepare`. - Added rollback regression proving a failed epoch update also rolls back claim renewal. - Mutation check against the original split implementation failed as expected. ## Adjacent audit No equivalent gaps found: - Context Epoch `insert` and `replace` already use `SessionExecutionClaim.transaction`. - Event publication, prompt promotion, recovery wake settlement, and execution-state changes run in EventV2’s immediate transaction. - Recovery claim changes use fenced single statements or transactional event commits. ...
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, and poison-row regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Audit revised source and close remaining concrete gaps",
"status": "in_progress",
"priority": "high"
},
{
"content": "Run package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": ...
The Context Epoch write is now atomic and its mutation test fails against the split implementation. Before rebuilding, I’m running one final adversarial source pass over lease expiry, wake ordering, cancellation, replay identity, and migration behavior; any source change after the build would invalidate the artifact evidence.
<task id="ses_f5a16cc73ffeEmquskzqVciuIN" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
A fresh pre-freeze adversarial audit is running read-only against the current source. I’m holding builds and further edits until it returns so the next artifact can represent one stable candidate.
continue
The pre-freeze audit found five valid lifecycle defects; the migration warning does not apply because these migrations are unpublished 0-to-1 work and compatibility with the discarded intermediate schema was explicitly excluded. I’m returning the candidate to editing to make settled tool/compaction continuation durable, make recovery outbox replay-safe, preserve user-decline cancellation, reject conflicting retries before arming wake intent, and quarantine poisoned recovery rows.
continue
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
210: "session.next.step.ended": (event) => {
211: return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
212: draft.time.completed = event.data.timestamp
213: draft.finish = event.data.finish
214: draft.cost = event.data.cost
215: draft.tokens = event.data.tokens
216: if (event.data.snapshot || event.data.files)
217: draft.snapshot = {
218: ...draft.snapshot,
219: end: event.data.snapshot,
220: files: event.data.files ? Array.from(event.data.files) : undefined,
221: }
222: })
223: },
224: "session.next.step.failed": (event) => {
225: return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
226: draft.time.completed = event.data.timestamp
227: draft.finish = "error"
228: ...
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
180: files: Schema.Array(RelativePath).pipe(optional),
181: },
182: })
183: export type Ended = typeof Ended.Type
184:
185: export const Failed = Event.define({
186: type: "session.next.step.failed",
187: ...stepSettlementOptions,
188: schema: {
189: ...Base,
190: assistantMessageID: SessionMessage.ID,
191: error: UnknownError,
192: },
193: })
194: export type Failed = typeof Failed.Type
195:
196: export const Interrupted = Event.define({
197: type: "session.next.step.interrupted",
198: ...options,
199: schema: {
200: ...Base,
201: assistantMessageID: SessionMessage.ID,
202: error: UnknownError,
203: },
204: })
205: export type Interrupted = typeof Interrupted.Type
206:
207: export const Recovered = Event.define({
208: type: "session.next.step.recovered",
209: ...options,
210: ...
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
330: .run()
331: .pipe(Effect.orDie)
332: const previous = row && usage(row.data)
333: const next = usage(event.data.part)
334: if (previous) yield* applyUsage(db, row.session_id, previous, -1)
335: if (next) yield* applyUsage(db, sessionID, next)
336: }),
337: )
338: yield* events.project(SessionEvent.AgentSwitched, (event) =>
339: db
340: .update(SessionTable)
341: .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
342: .where(eq(SessionTable.id, event.data.sessionID))
343: .run()
344: .pipe(Effect.orDie, Effect.andThen(run(db, event))),
345: )
346: yield* events.project(SessionEvent.ModelSwitched, (event) =>
347: Effect.gen(function* () {
348: yield* db
349: .update(SessionTable)
350: ...
<task id="ses_f5a7ac281ffehBff5DHoeRi5vJ" state="completed"> <task_result> [lore:context-warning] Lore could not retrieve more memory, so this response uses only the information available before the recall failed. --- </task_result> </task>
The implementation agent returned no evidence after a memory failure, so I’m checking whether it changed the worktree before deciding whether to resume that session or replace it. No result will be accepted without source inspection and validation.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
The worktree did change substantially, so the agent likely completed part of the fix before its report failed. I’m resuming the same session once with an evidence-only instruction: inspect its current edits, finish incomplete pieces, validate them, and report concrete results without relying on memory retrieval.
<task id="ses_f5a7ac281ffehBff5DHoeRi5vJ" state="completed"> <task_result> Implemented the remaining recovery durability and liveness fixes. Key changes: - Durable `"continue"` phase for settled tool Steps and compaction. - Atomic declined-tool interruption and exact claim deletion. - Replay-safe recovery wake settlement. - Retry equivalence before durable wake arming. - Full recovery-row validation and quarantine. - Protection against cross-Session poison and stale assistant recovery. - Deterministic tests for crash recovery and batches exceeding 100 poison rows. - Preserved the squashed migration approach. Validation: - Full Core suite: **1139 passed**, 0 failed. - Focused suite: **180 passed**, 0 failed. - Core and Schema typechecks passed. - Migration consistency check passed. - `git diff --check` passed. No commit or service operation performed. </task_result> </task>
The resumed implementation now reports all five fixes complete with 1,139 Core tests passing. I’m verifying the resulting state machine and regressions directly, especially the new continue phase, replay settlement, cancellation commit, and poison-row progress, before accepting those results or rebuilding.
Found 64 matches
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 24: const Phase = Schema.Literals(["ready", "safe", "unknown", "continue"])
Line 25: const RecoveryPhase = Schema.Literals(["ready", "unknown", "continue"])
Line 27: const continuePrompt = Prompt.make({ text: "continue" })
Line 38: .where(and(lte(SessionExecutionTable.expires_at, scanTime), isNull(SessionExecutionTable.recovery_error)))
Line 57: isNull(SessionExecutionTable.recovery_error),
Line 65: const phase = Schema.decodeUnknownOption(Phase)(claim.phase)
Line 66: if (Option.isNone(phase)) {
Line 70: .set({ recovery_error: `Invalid execution phase: ${String(claim.phase)}` })
Line 94: phase.value === "safe" ||
Line 100: const candidate = phase.value === "continue" || claim.assistant_message_id === null ? undefined : latest
Line 111: phase: phase.value,
Line 149: phase: ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, gt, isNull, lte, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { continuation, message } from "./recovery-id"
13: import { Prompt } from "./prompt"
14: import { SessionInput } from "./input"
15: import {
16: SESSION_EXECUTION_LEASE_MS,
17: SessionExecutionTable,
18: SessionInputTable,
19: SessionRecoveryTable,
20: SessionMessageTable,
21: } from "./sql"
22:
23: export const ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
35: const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie)
36: return row === undefined ? undefined : fromRow(row)
37: })
38:
39: export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
40: id: SessionMessage.ID,
41: }) {}
42:
43: export const admit = Effect.fn("SessionInput.admit")(function* (
44: db: DatabaseService,
45: events: EventV2.Interface,
46: input: {
47: readonly id: SessionMessage.ID
48: readonly sessionID: SessionSchema.ID
49: readonly prompt: Prompt
50: readonly delivery: Delivery
51: readonly requestExecution: boolean
52: },
53: ) {
54: const existing = yield* find(db, input.id)
55: if (existing !== undefined) {
56: if (!equivalent(existing, input)) return yield* Effect.die(new LifecycleConflict({ id: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
180: }).pipe(Effect.map(SystemContext.combine))
181:
182: const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
183: sessionID: SessionSchema.ID,
184: executionID: string,
185: promotion: Promotion | undefined,
186: step: number,
187: ownerID: string,
188: recoverOverflow?: typeof compaction.compactAfterOverflow,
189: ) {
190: const session = yield* getSession(sessionID)
191: if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
192: return yield* Effect.interrupt
193: const claim = { sessionID, executionID, ownerID }
194: const updateExecution = (values: SessionExecutionClaim.Values = {}) =>
195: SessionExecutionClaim.update(db, claim, values)
196: const execution = yield* db
197: .select({ phase: ...
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
385: yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
386: yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
387: yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
388: yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
389: yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
390: yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
391: yield* events.project(SessionEvent.Step.Interrupted, (event) => run(db, event))
392: yield* events.project(SessionEvent.Step.Recovered, (event) =>
393: Effect.gen(function* () {
394: if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
395: const continuationMessageID = ...
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
195:
196: export const Interrupted = Event.define({
197: type: "session.next.step.interrupted",
198: ...options,
199: schema: {
200: ...Base,
201: assistantMessageID: SessionMessage.ID,
202: error: UnknownError,
203: },
204: })
205: export type Interrupted = typeof Interrupted.Type
206:
207: export const Recovered = Event.define({
208: type: "session.next.step.recovered",
209: ...options,
210: schema: {
211: ...Base,
212: executionID: Schema.String,
213: assistantMessageID: SessionMessage.ID.pipe(optional),
214: phase: Schema.Literals(["ready", "unknown", "continue"]),
215: },
216: })
217: export type Recovered = typeof Recovered.Type
218: }
219:
220: export namespace Text {
221: export const Started = Event.define({
222: type: "session.next.text.started",
223: ...options,
224: ...
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
230: },
231: "session.next.step.interrupted": (event) => {
232: return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
233: draft.time.completed = event.data.timestamp
234: draft.finish = "error"
235: draft.error = event.data.error
236: for (const tool of draft.content) {
237: if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue
238: tool.time.completed = event.data.timestamp
239: tool.state = castDraft(
240: SessionMessage.ToolStateError.make({
241: status: "error",
242: error: { type: "unknown", message: "Tool execution interrupted" },
243: input: typeof tool.state.input === "string" ? {} : tool.state.input,
244: structured: ...
Found 19 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 649: prompt: Prompt.make({ text: "ordinary work after replayed recovery" }),
Line 658: expect(userTexts(requests[1]!)).toContain("ordinary work after replayed recovery")
Line 1538: if (execution?.phase === "continue") return yield* Effect.die("crash after Compaction.Ended")
Line 1884: it.effect("continues with reloaded history after durably settling one local tool call", () =>
Line 1963: if (execution?.phase === "continue") return yield* Effect.die("crash after settled Step.Ended")
Line 3744: it.effect("does not continue automatically after a provider error follows a local tool call", () =>
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 177: if (!replayedContinuation) return yield* Effect.die("Replayed recovery continuation not found")
Line 496: it.effect("quarantines a full batch of poison recovery rows ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
125: Schema.decodeUnknownSync(SessionMessage.Message)({ ...message.data, id: message.id, type: message.type }),
126: ).toMatchObject({
127: id: assistantID,
128: finish: "error",
129: error: { type: "unknown", message: "Provider Step outcome unknown after server restart" },
130: content: [
131: { type: "text", id: "recovery:execution", text: "Provider Step outcome unknown after server restart" },
132: ],
133: })
134: expect(
135: yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, continuationID)).get(),
136: ).toMatchObject({
137: session_id: sessionID,
138: prompt: { text: "continue" },
139: delivery: "queue",
140: })
141: expect(yield* db.select().from(SessionRecoveryTable).all()).toHaveLength(1)
142: expect(yield* ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
590: })
591: yield* db.run(
592: sql`UPDATE session_input SET session_id = ${other} WHERE id = ${continuation("cross-session-continuation")}`,
593: )
594: const wakes: string[] = []
595:
596: yield* SessionRecovery.recover().pipe(
597: Effect.provideService(
598: SessionExecution.Service,
599: SessionExecution.Service.of({
600: ...recoveryExecution,
601: wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid),
602: }),
603: ),
604: )
605:
606: expect(wakes).toEqual([])
607: expect(yield* db.select().from(SessionRecoveryTable).get()).toMatchObject({
608: recovery_error: "Invalid recovery continuation input",
609: })
610: }),
611: )
612:
613: it.effect("redrives durable ordinary input execution intent after a lost ...
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
135: index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq),
136: index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
137: index("session_message_time_created_idx").on(table.time_created),
138: ],
139: )
140:
141: export const SessionInputTable = sqliteTable(
142: "session_input",
143: {
144: id: text().$type<SessionMessage.ID>().primaryKey(),
145: session_id: text()
146: .$type<SessionSchema.ID>()
147: .notNull()
148: .references(() => SessionTable.id, { onDelete: "cascade" }),
149: prompt: text({ mode: "json" }).notNull().$type<Prompt>(),
150: delivery: text().$type<SessionInput.Delivery>().notNull(),
151: admitted_seq: integer().notNull(),
152: promoted_seq: integer(),
153: wake_pending: integer({ mode: "boolean" ...
Found 8 matches /home/byk/Code/opencode/packages/core/src/session/projector.ts: Line 29: const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) Line 122: const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => /home/byk/Code/opencode/packages/core/src/session/store.ts: Line 32: const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) /home/byk/Code/opencode/packages/core/src/session/revert.ts: Line 49: const decode = Schema.decodeUnknownEffect(SessionMessage.Message) /home/byk/Code/opencode/packages/core/src/session/input.ts: Line 18: const decodePrompt = Schema.decodeUnknownSync(Prompt) Line 21: export const decode = (value: unknown) => Schema.decodeUnknownOption(Prompt)(value) /home/byk/Code/opencode/packages/core/src/session/history.ts: Line 11: const decode = Schema.decodeUnknownEffect(SessionMessage.Message) Line 55: const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
The direct inspection confirms the key transitions: Step.Ended and Compaction.Ended atomically persist continue; restart recovery creates no error projection for that phase; Prompted replay clears the outbox; and decline publishes interruption while deleting the exact claim. I’m now running the focused suite and type/schema gates locally while a fresh read-only audit checks this revised source for interaction bugs.
<task id="ses_f5a16cc73ffeEmquskzqVciuIN" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
bun test v1.3.14 (0d9b296a) 186 pass 0 fail 534 expect() calls Ran 186 tests across 7 files. [16.39s]
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-check-QB4syN/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-QB4syN/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-QB4syN/full/20260915190804_schema/migration.sql 🚀
(no output)
Focused validation is clean: 186 tests, both Core and Schema typechecks, migration consistency, and diff checks passed. I’m checking the two composition consumers now; the OpenCode package may still report the known unrelated provider fixture error, which I’ll keep separate from recovery-owned results.
$ tsgo --noEmit
$ tsgo --noEmit
All four affected package typechecks pass, including OpenCode; the earlier unrelated provider-fixture error is no longer present. I’m running the full Core suite against this exact source, then I’ll use the independent audit result to decide whether the candidate is ready for an isolated SEA rebuild.
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[19:11:02.123] ERROR (#27075): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
1139 pass
0 fail
3148 expect() calls
Ran 1139 tests across 145 files. [65.99s]
[19:12:02.590] INFO (#202738): watcher backend {
directory: "/tmp/opencode-core-test-Toicqm",
platform: "linux",
backend: "inotify",
}
[19:12:02.595] INFO (#202847): project copy refresh started {
projectID: "global",
}
[19:12:02.596] INFO (#202847): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[19:12:02.636] INFO (#202738): booting location services {
directory: ...
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, and poison-row regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Audit revised source and close remaining concrete gaps",
"status": "in_progress",
"priority": "high"
},
{
"content": "Run package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": ...
Full Core validation also passes: 1,139 tests across 145 files with 3,148 assertions. The candidate remains unbuilt and unfrozen until the independent source audit returns.
</task_result> </task>
The read-only audit returned no evidence after a Lore failure, so it is invalid. I’m giving that same reviewer one evidence-or-blocker retry against the unchanged source; if it returns empty again, I’ll replace it before building.
<task id="ses_f5a16cc73ffeEmquskzqVciuIN" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The unchanged candidate remains on hold. The same reviewer is performing its one evidence-or-blocker retry; an empty result will trigger replacement.
Evidence
/home/byk/Code/opencode/packages/schema/src/session-event.ts:94-98 records PromptAdmitted using PromptFields; it does not record requestExecution./home/byk/Code/opencode/packages/core/src/session/input.ts:61-72 stores wake_pending=true only through the event’s local commit hook./home/byk/Code/opencode/packages/core/src/event.ts:122-124 states that commit hooks are not replayed or serialized./home/byk/Code/opencode/packages/core/src/session/projector.ts:371-381 reconstructs admitted prompts without execution intent./home/byk/Code/opencode/packages/core/src/session/input.ts:143-153 inserts the replayed input without wake_pending, so /home/byk/Code/opencode/packages/core/src/session/sql.ts:153 supplies the default false./home/byk/Code/opencode/packages/core/src/session/recovery.ts:263-272 redrives only inputs whose wake_pending remains true.Failure sequence
PromptAdmitted and wake_pending=true commit atomically.wake_pending=false because the durable event cannot distinguish default execution from resume:false.The current tests do not cover this failure window. /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:610-649 tests live ordinary redrive without replay. /home/byk/Code/opencode/packages/core/test/session-prompt.test.ts:314-337 tests the live exact-retry upgrade. ...
The retry found one real replay defect: ordinary wake intent exists only in a non-replayed commit hook. The fix must cover both initial default-resume admission and the exact-retry upgrade from resume:false; I’m moving both into durable event projections rather than patching replay with a heuristic that would execute admit-only prompts.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, and poison-row regressions",
"status": "in_progress",
"priority": "high"
},
{
"content": "Audit revised source and close remaining concrete gaps",
"status": "pending",
"priority": "high"
},
{
"content": "Run package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": ...
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
70: messageID: SessionMessage.ID,
71: model: Model.Ref,
72: },
73: })
74: export type ModelSwitched = typeof ModelSwitched.Type
75:
76: export const Moved = Event.define({
77: type: "session.next.moved",
78: ...options,
79: schema: {
80: ...Base,
81: location: Location.Ref,
82: subdirectory: RelativePath.pipe(optional),
83: },
84: })
85: export type Moved = typeof Moved.Type
86:
87: export const Prompted = Event.define({
88: type: "session.next.prompted",
89: ...options,
90: schema: PromptFields,
91: })
92: export type Prompted = typeof Prompted.Type
93:
94: export const PromptAdmitted = Event.define({
95: type: "session.next.prompt.admitted",
96: ...options,
97: schema: PromptFields,
98: })
99: export type PromptAdmitted = typeof PromptAdmitted.Type
100:
101: export const ContextUpdated = Event.define({
102: type: ...
Found 33 matches
/home/byk/Code/opencode/packages/core/src/cross-spawn-spawner.ts:
Line 268: Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
Line 274: resume(Effect.fail(toPlatformError("spawn", err, command)))
Line 285: resume(Effect.succeed([proc, signal]))
Line 298: return Effect.callback<void, PlatformError.PlatformError>((resume) => {
Line 300: if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command)))
Line 301: resume(Effect.void)
/home/byk/Code/opencode/packages/core/src/process.ts:
Line 102: Effect.callback<never, Error>((resume) => {
Line 104: resume(Effect.fail(abortError(signal)))
Line 107: const onabort = () => resume(Effect.fail(abortError(signal)))
/home/byk/Code/opencode/packages/core/src/session.ts:
Line 152: resume?: boolean
Line 158: resume?: boolean
Line 164: resume?: ...
<task id="ses_f5a7ac281ffehBff5DHoeRi5vJ" state="completed"> <task_result> Implemented the remaining durable wake-intent fix. ### Design - `PromptAdmitted` now records `requestExecution`, so replay restores the original `wake_pending` state. - Exact `resume:true` retries emit one deterministic durable `PromptExecutionRequested` event. - Concurrent exact retries remain idempotent. - Conflicting retries emit no execution-request event and never arm wake intent. - `resume:false` remains pending after replay and recovery. - Kept the unpublished recovery schema in the single squashed migration and removed intermediate migrations. - Regenerated the public JavaScript SDK types. ### Key files - `packages/schema/src/session-event.ts` - `packages/core/src/session/input.ts` - `packages/core/src/session/projector.ts` - `packages/core/src/session/message-updater.ts` - `packages/core/test/session-prompt.test.ts` - `packages/schema/test/event-manifest.test.ts` - ...
The fix now persists initial execution intent on PromptAdmitted and exact-retry upgrades through a dedicated durable event, so replay can preserve resume:false without inference. I’m auditing its event idempotence and generated SDK delta directly, then I’ll rerun the relevant package gates before rebuilding.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
Found 36 matches
/home/byk/Code/opencode/packages/schema/test/event-manifest.test.ts:
Line 53: SessionEvent.PromptExecutionRequested,
/home/byk/Code/opencode/packages/schema/src/session-event.ts:
Line 99: requestExecution: Schema.Boolean,
Line 104: export const PromptExecutionRequested = Event.define({
Line 112: export type PromptExecutionRequested = typeof PromptExecutionRequested.Type
Line 490: PromptExecutionRequested,
Line 524: PromptExecutionRequested,
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 699: requestExecution: true,
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 634: requestExecution: true,
Line 881: requestExecution: false,
Line 1359: requestExecution: true,
/home/byk/Code/opencode/packages/sdk/js/src/v2/gen/types.gen.ts:
Line 24: | EventSessionNextPromptExecutionRequested
Line 874: requestExecution: boolean
Line ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
43: export const admit = Effect.fn("SessionInput.admit")(function* (
44: db: DatabaseService,
45: events: EventV2.Interface,
46: input: {
47: readonly id: SessionMessage.ID
48: readonly sessionID: SessionSchema.ID
49: readonly prompt: Prompt
50: readonly delivery: Delivery
51: readonly requestExecution: boolean
52: },
53: ) {
54: const existing = yield* find(db, input.id)
55: if (existing !== undefined) {
56: if (!equivalent(existing, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
57: if (input.requestExecution) yield* requestExecution(db, events, input)
58: return existing
59: }
60: const timestamp = yield* DateTime.now
61: return yield* events
62: .publish(
63: SessionEvent.PromptAdmitted,
64: {
65: messageID: input.id,
66: sessionID: input.sessionID,
67: timestamp,
68: ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
180: export const projectExecutionRequested = Effect.fn("SessionInput.projectExecutionRequested")(function* (
181: db: DatabaseService,
182: input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
183: ) {
184: const updated = yield* db
185: .update(SessionInputTable)
186: .set({ wake_pending: true })
187: .where(
188: and(
189: eq(SessionInputTable.id, input.id),
190: eq(SessionInputTable.session_id, input.sessionID),
191: isNull(SessionInputTable.promoted_seq),
192: ),
193: )
194: .returning({ id: SessionInputTable.id })
195: .get()
196: .pipe(Effect.orDie)
197: if (updated) return
198: const stored = yield* find(db, input.id)
199: if (stored?.sessionID === input.sessionID && stored.promotedSeq !== undefined) return
200: return yield* Effect.die(new LifecycleConflict({ id: input.id ...
One compatibility point needs scrutiny before validation: PromptAdmitted predates this recovery feature, so making requestExecution required may make historical durable events undecodable even though the new recovery tables are unpublished. I’m checking event versioning and schema-default patterns now; if old events exist, the field must decode safely without confusing historical resume:false intent.
Found 6 matches
/home/byk/Code/opencode/packages/schema/src/v1/question.ts:
Line 29: custom: Schema.optional(Schema.Boolean).annotate({ description: "Allow typing a custom answer (default: true)" }),
/home/byk/Code/opencode/packages/schema/src/session-event.ts:
Line 94: export const PromptAdmitted = Event.define({
Line 102: export type PromptAdmitted = typeof PromptAdmitted.Type
Line 489: PromptAdmitted,
Line 523: PromptAdmitted,
/home/byk/Code/opencode/packages/schema/src/question.ts:
Line 38: description: "Allow typing a custom answer (default: true)",
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
1: export * as SessionEvent from "./session-event"
2:
3: import { Schema } from "effect"
4: import { optional } from "./schema"
5: import { Event } from "./event"
6: import { ProviderMetadata, ToolContent } from "./llm"
7: import { Delivery } from "./session-delivery"
8: import { Model } from "./model"
9: import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema"
10: import { FileAttachment, Prompt } from "./prompt"
11: import { SessionID } from "./session-id"
12: import { Location } from "./location"
13: import { SessionMessage } from "./session-message"
14: import { Revert } from "./revert"
15:
16: export { FileAttachment }
17:
18: export const Source = Schema.Struct({
19: start: NonNegativeInt,
20: end: NonNegativeInt,
21: text: Schema.String,
22: }).annotate({
23: identifier: "session.next.event.source",
24: })
25: export interface Source extends ...
export * as SessionEvent from "./session-event"
import { Schema } from "effect"
import { optional } from "./schema"
import { Event } from "./event"
import { ProviderMetadata, ToolContent } from "./llm"
import { Delivery } from "./session-delivery"
import { Model } from "./model"
import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema"
import { FileAttachment, Prompt } from "./prompt"
import { SessionID } from "./session-id"
import { Location } from "./location"
import { SessionMessage } from "./session-message"
import { Revert } from "./revert"
export { FileAttachment }
export const Source = Schema.Struct({
start: NonNegativeInt,
end: NonNegativeInt,
text: Schema.String,
}).annotate({
identifier: "session.next.event.source",
})
export interface Source extends Schema.Schema.Type<typeof Source> {}
const Base = {
timestamp: DateTimeUtcFromMillis,
sessionID: SessionID,
}
const PromptFields = {
...Base,
messageID: SessionMessage.ID,
prompt: ...
PromptAdmitted was already durable before this feature, and resume:false already existed, so a required field on version 1 cannot safely decode old rows and no default can recover old intent. I’m tracing the event version registry now; the correct fix is likely a new version for new admissions while retaining the old decoder as admit-only, plus the separate execution-request event for explicit upgrades.
<path>/home/byk/Code/opencode/packages/schema/src/event.ts</path>
<type>file</type>
<content>
1: export * as Event from "./event"
2:
3: import { Schema } from "effect"
4: import { optional } from "./schema"
5: import { ascending } from "./identifier"
6: import { Location } from "./location"
7: import { statics } from "./schema"
8:
9: export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
10: Schema.brand("Event.ID"),
11: statics((schema) => ({ create: () => schema.make("evt_" + ascending()) })),
12: )
13: export type ID = typeof ID.Type
14:
15: export type Definition<
16: Type extends string = string,
17: DataSchema extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>,
18: > = Schema.Top & {
19: readonly type: Type
20: readonly durable?: {
21: readonly version: number
22: readonly aggregate: string
23: }
24: readonly data: DataSchema
25: }
26:
27: export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
28: ...
Found 17 matches
/home/byk/Code/opencode/packages/schema/src/v1/legacy-event.ts:
Line 1: export * as LegacyEvent from "./legacy-event"
Line 6: import { SessionV1 } from "./session"
Line 14: messageID: SessionV1.MessageID,
/home/byk/Code/opencode/packages/schema/src/session-event.ts:
Line 47: version: 2,
/home/byk/Code/opencode/packages/schema/src/event-manifest.ts:
Line 10: import { LegacyEvent } from "./legacy-event"
Line 15: import { PermissionV1 } from "./permission-v1"
Line 21: import { QuestionV1 } from "./question-v1"
Line 28: import { SessionV1 } from "./session-v1"
Line 34: const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined)
Line 35: const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable === undefined)
Line 37: const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions)
Line ...
<path>/home/byk/Code/opencode/packages/schema/src/durable-event-manifest.ts</path>
<type>file</type>
<content>
1: export * as DurableEventManifest from "./durable-event-manifest"
2:
3: import { Event } from "./event"
4: import { SessionEvent } from "./session-event"
5: import { SessionV1 } from "./session-v1"
6:
7: export const SessionDurable = {
8: definitions: Event.durable(SessionEvent.DurableDefinitions),
9: schema: SessionEvent.Durable,
10: } as const
11:
12: export const Durable = Event.durable([
13: ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined),
14: ...SessionEvent.DurableDefinitions,
15: ])
(End of file - total 15 lines)
</content>
Found 21 matches
/home/byk/Code/opencode/packages/schema/test/event.test.ts:
Line 24: expect(Event.latest([historical, current]).get(current.type)).toBe(current)
Line 25: expect(Event.latest([current, historical]).get(current.type)).toBe(current)
/home/byk/Code/opencode/packages/schema/src/session-event.ts:
Line 484: export const DurableDefinitions = Event.inventory(
Line 556: export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
/home/byk/Code/opencode/packages/schema/src/event-manifest.ts:
Line 34: const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined)
Line 37: const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions)
Line 83: export const Latest = Event.latest(Definitions)
/home/byk/Code/opencode/packages/schema/src/durable-event-manifest.ts:
Line 8: definitions: Event.durable(SessionEvent.DurableDefinitions),
Line ...
<path>/home/byk/Code/opencode/packages/schema/src/event-manifest.ts</path>
<type>file</type>
<content>
28: import { SessionV1 } from "./session-v1"
29: import { TuiEvent } from "./tui-event"
30: import { VcsEvent } from "./vcs-event"
31: import { WorkspaceEvent } from "./workspace-event"
32: import { WorktreeEvent } from "./worktree-event"
33:
34: const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined)
35: const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable === undefined)
36:
37: const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions)
38:
39: const foundationDefinitions = Event.inventory(
40: ...ModelsDev.Event.Definitions,
41: ...Integration.Event.Definitions,
42: ...Catalog.Event.Definitions,
43: ...coreDefinitions,
44: )
45:
46: const featureDefinitions = Event.inventory(
47: ...
Found 62 matches
/home/byk/Code/opencode/packages/core/src/snapshot.ts:
Line 183: return yield* new Error({ operation, message: `Path escapes the project: ${file}` })
/home/byk/Code/opencode/packages/core/src/session.ts:
Line 70: project: ProjectV2.ID,
Line 190: const projects = yield* ProjectV2.Service
Line 212: const project = yield* projects.resolve(input.location.directory)
Line 241: const projected = yield* events
/home/byk/Code/opencode/packages/core/src/project.ts:
Line 59: const projectDirectories = yield* ProjectDirectories.Service
/home/byk/Code/opencode/packages/core/src/location.ts:
Line 23: const project = yield* Project.Service
Line 28: project: { id: resolved.id, directory: resolved.directory },
/home/byk/Code/opencode/packages/core/src/event.ts:
Line 137: readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
Line 179: const ...
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
160: ),
161: )
162: yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
163: return Stream.fromQueue(queue)
164: })
165:
166: export interface LayerOptions {
167: readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
168: }
169:
170: export const layerWith = (options?: LayerOptions) =>
171: Layer.effect(
172: Service,
173: Effect.gen(function* () {
174: const pubsub = {
175: all: yield* PubSub.unbounded<Payload>(),
176: durable: new Map<string, Set<PubSub.PubSub<void>>>(),
177: typed: new Map<string, PubSub.PubSub<Payload>>(),
178: }
179: const projectors = new Map<string, Subscriber[]>()
180: // TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads. ...
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
350: }),
351: { behavior: "immediate" },
352: )
353: .pipe(Effect.orDie)
354: if (committed) {
355: yield* Effect.forEach(
356: pubsub.durable.get(committed.aggregateID) ?? [],
357: (wake) => PubSub.publish(wake, undefined),
358: { discard: true },
359: )
360: }
361: return committed
362: }),
363: )
364: }
365: }
366: })
367: }
368:
369: function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
370: return Effect.gen(function* () {
371: if (!definition?.durable && commit)
372: return yield* ...
Event replay is keyed by versioned type but projectors are still keyed only by logical type, so introducing parallel v1/v2 projectors would invoke both and is unsafe under the current event layer. The minimal compatible design is to make requestExecution optional on the existing event, decode absent historical values as false, and always emit it for new admissions; this preserves old admit-only safety without guessing historical intent.
<task id="ses_f5a7ac281ffehBff5DHoeRi5vJ" state="completed"> <task_result> Implemented the historical compatibility correction. ### Changes - `packages/schema/src/session-event.ts` - Kept `PromptAdmitted` at durable version 1. - Changed `requestExecution` to optional. - `packages/core/src/session/projector.ts` - Projects absent historical values as `wake_pending=false` using `requestExecution ?? false`. - Explicit `true` and `false` remain unchanged. - `packages/core/src/session/input.ts` - New admissions already emit an explicit boolean; no change was needed. - `packages/core/test/session-prompt.test.ts` - Added real EventV2 replay coverage for a manually shaped v1 `PromptAdmitted` without `requestExecution`. - Proves the input stays unpromoted, has `wake_pending=false`, and recovery issues no wake. - Existing replay tests still prove new `true` remains true and new `false` remains false. - `packages/sdk/js/src/v2/gen/types.gen.ts` - Regenerated; public SDK types ...
Historical compatibility is now explicit: absent v1 intent projects as admit-only, while every new admission emits a boolean. I’m verifying the exact schema/projector/test delta, then I’ll rerun the full candidate validation because the previous full-suite evidence predates this correction.
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
87: export const Prompted = Event.define({
88: type: "session.next.prompted",
89: ...options,
90: schema: PromptFields,
91: })
92: export type Prompted = typeof Prompted.Type
93:
94: export const PromptAdmitted = Event.define({
95: type: "session.next.prompt.admitted",
96: ...options,
97: schema: {
98: ...PromptFields,
99: requestExecution: Schema.Boolean.pipe(optional),
100: },
101: })
102: export type PromptAdmitted = typeof PromptAdmitted.Type
103:
104: export const PromptExecutionRequested = Event.define({
105: type: "session.next.prompt.execution_requested",
106: ...options,
107: schema: {
108: ...Base,
109: messageID: SessionMessage.ID,
110: },
111: })
112: export type PromptExecutionRequested = typeof PromptExecutionRequested.Type
113:
114: export const ContextUpdated = Event.define({
115: type: "session.next.context.updated",
116: ...
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
365: timeCreated: event.data.timestamp,
366: promotedSeq: event.durable.seq,
367: })
368: yield* run(db, event)
369: }),
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* ...
Found 3 matches
/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts:
Line 330: it.effect("durably records execution intent before advisory wake", () =>
Line 360: it.effect("replays historical prompt admission without execution intent as admit-only", () =>
Line 581: it.effect("reprojects pending inbox input without scheduling execution", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts</path>
<type>file</type>
<content>
320: const first = yield* session.prompt(input)
321: wakeCalls.length = 0
322:
323: const retried = yield* session.prompt({ ...input, resume: true })
324:
325: expect(retried).toEqual(first)
326: expect(wakeCalls).toEqual([sessionID])
327: }),
328: )
329:
330: it.effect("durably records execution intent before advisory wake", () =>
331: Effect.gen(function* () {
332: yield* setup
333: const session = yield* SessionV2.Service
334: wakeCalls.length = 0
335:
336: const message = yield* session.prompt({
337: sessionID,
338: prompt: Prompt.make({ text: "Recover a lost advisory wake" }),
339: })
340:
341: expect(yield* admitted(message.id)).toMatchObject({ id: message.id })
342: const { db } = yield* Database.Service
343: expect(yield* ...
<path>/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts</path>
<type>file</type>
<content>
418: wakeCalls.length = 0
419: yield* replaySessionProjection()
420: expect(yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, messageID)).get()).toMatchObject({
421: wake_pending: true,
422: })
423: yield* replaySessionProjection()
424: expect(yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, messageID)).get()).toMatchObject({
425: wake_pending: true,
426: })
427: yield* recover()
428: expect(wakeCalls).toEqual([sessionID])
429: }),
430: )
431:
432: it.effect("rejects reuse of one ID with a different prompt", () =>
433: Effect.gen(function* () {
434: yield* setup
435: const session = yield* SessionV2.Service
436:
437: yield* session.prompt({
438: sessionID,
439: id: messageID,
440: prompt: Prompt.make({ text: ...
The compatibility audit passes: old v1 admissions decode as admit-only, new admissions replay explicit intent, retry upgrades replay once, and conflicting retries remain inert. I’m running the final package validation on this corrected source, including the generated SDK; any source change after these gates will invalidate the results.
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[21:37:31.478] ERROR (#27075): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
1140 pass
0 fail
3159 expect() calls
Ran 1140 tests across 145 files. [67.08s]
[21:38:32.528] INFO (#202918): watcher backend {
directory: "/tmp/opencode-core-test-JtvQpU",
platform: "linux",
backend: "inotify",
}
[21:38:32.533] INFO (#203027): project copy refresh started {
projectID: "global",
}
[21:38:32.534] INFO (#203027): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[21:38:32.573] INFO (#202918): booting location services {
directory: ...
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a) 2 pass 0 fail 23 expect() calls Ran 2 tests across 1 file. [263.00ms] $ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-check-EyfCW4/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-EyfCW4/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-EyfCW4/full/20260915214341_schema/migration.sql 🚀
(no output)
The corrected source passes 1,140 Core tests, Schema manifest tests, five affected package typechecks, migration consistency, and diff checks. I’m starting one final read-only audit of the wake-intent correction while building the exact SEA candidate; if the audit finds a defect, the artifact will be discarded rather than frozen.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, and poison-row regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Audit revised source and close remaining concrete gaps",
"status": "completed",
"priority": "high"
},
{
"content": "Run package validation and isolated artifact restart smoke",
"status": "in_progress",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": ...
<task id="ses_f58f89899ffeOXasK3GK0vX142" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a709ab9900124rMFQ1tyqwZJW dist/assets/polar-DjOs3GWb.js 4.72 kB dist/assets/sdbl-CvZs3G9c.js 4.75 kB dist/assets/nope-05-DZsXzrQW.aac 4.80 kB dist/assets/fennel-DBMsxtrh.js 4.82 kB dist/assets/bibtex-DRcCXNgM.js 4.84 kB dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 4.93 kB dist/assets/llvm-BJRUtCev.js 5.10 kB dist/assets/alert-07-0r0kiLGz.aac 5.19 kB dist/assets/wgsl-CxjTZs-b.js 5.19 kB dist/assets/alert-05-D2gbGoRH.aac 5.21 kB dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 5.21 kB dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 ...
resume:false promptSessionInput.admit calls requestExecution for every exact resume:true retry (packages/core/src/session/input.ts:54-58). If the retried input was already promoted, requestExecution correctly returns without recording new intent (packages/core/src/session/input.ts:106-117).
However, SessionV2.prompt then unconditionally calls execution.wake (packages/core/src/session.ts:381-384). The runner checks for any pending input, regardless of wake_pending (packages/core/src/session/runner/llm.ts:505-509), and promotes that input (packages/core/src/session/runner/llm.ts:518-530, packages/core/src/session/runner/llm.ts:226-232).
Concrete sequence:
resume:false.resume:true.resume:false.A post-promotion exact retry must be a no-op for execution scheduling. Existing coverage tests an unpromoted false → true retry (packages/core/test/session-prompt.test.ts:390-429) and a promoted retry with resume:false (packages/core/test/session-prompt.test.ts:605-623), but not this sequence.
The canonical schema defines optional requestExecution at durable version 1 and defines PromptExecutionRequested (packages/schema/src/session-event.ts:94-112). Both events appear in the public inventories (packages/schema/src/session-event.ts:484-490, packages/schema/src/session-event.ts:518-525) and the generated TypeScript SDK correctly includes them ...
The final audit found two valid blockers, so the just-built SEA is discarded as a candidate. I’m fixing the scheduling contract first: admission must report whether this call actually requested new execution, so an exact retry of an already-promoted prompt never wakes an unrelated admit-only input; then I’ll regenerate the checked-in public OpenAPI and SDK artifacts through repository scripts.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, and post-promotion retry regressions",
"status": "in_progress",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "pending",
"priority": "high"
},
{
"content": "Run package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
...
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path>
<type>file</type>
<content>
350: .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
351: ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
352: history: Effect.fn("V2Session.history")(function* (input) {
353: yield* result.get(input.sessionID)
354: return yield* EventV2.readAggregate(db, {
355: ...input,
356: aggregateID: input.sessionID,
357: manifest: SessionDurable,
358: })
359: }),
360: prompt: Effect.fn("V2Session.prompt")((input) =>
361: Effect.uninterruptible(
362: Effect.gen(function* () {
363: yield* result.get(input.sessionID)
364: const prompt = resolvePrompt(input.prompt)
365: const messageID = input.id ?? SessionMessage.ID.create()
366: const delivery = ...
File not found: /home/byk/Code/opencode/packages/sdk/package.json Did you mean one of these? /home/byk/Code/opencode/packages/sdk/js
No files found
No files found
Found 11 matches
/home/byk/Code/opencode/script/generate.ts:
Line 7: await $`bun dev generate > ../sdk/openapi.json`.cwd("packages/opencode")
/home/byk/Code/opencode/packages/codemode/test/openapi.test.ts:
Line 18: return Bun.file(new URL("./fixtures/opencode-v2-openapi.json", import.meta.url)).json() as Promise<Document>
/home/byk/Code/opencode/packages/server/src/routes.ts:
Line 56: return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
/home/byk/Code/opencode/packages/console/app/src/routes/openapi.json.ts:
Line 3: "https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/sdk/openapi.json",
/home/byk/Code/opencode/packages/docs/docs.json:
Line 19: "openapi": "https://opencode.ai/openapi.json"
/home/byk/Code/opencode/packages/cli/src/commands/handlers/api.ts:
Line 69: const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers ...
<path>/home/byk/Code/opencode/script/generate.ts</path>
<type>file</type>
<content>
1: #!/usr/bin/env bun
2:
3: import { $ } from "bun"
4:
5: await $`bun ./packages/sdk/js/script/build.ts`
6:
7: await $`bun dev generate > ../sdk/openapi.json`.cwd("packages/opencode")
8:
9: await $`./script/format.ts`
(End of file - total 9 lines)
</content>
The runtime fix is small but changes SessionInput.admit’s internal return contract: it must return the admitted row plus whether this invocation armed durable execution. This prevents a wake after promoted retries while retaining wake behavior for new executable prompts and unpromoted exact upgrades.
<task id="ses_f5a7ac281ffehBff5DHoeRi5vJ" state="completed">
<task_result>
Implemented the post-promotion retry scheduling fix.
### Changes
- `packages/core/src/session/input.ts`
- `SessionInput.admit()` now returns `{ admitted, shouldWake }`.
- Derives `shouldWake` from durable `wake_pending` and `promoted_seq`.
- Promoted inputs always return `shouldWake: false`.
- Existing pending wake intent returns `true`.
- New and upgraded execution intent returns the durable state after publication.
- Defect and concurrent publication paths reread durable state and never wake promoted inputs.
- `packages/core/src/session.ts`
- `SessionV2.prompt()` now calls `execution.wake()` only when `admission.shouldWake` is true.
- `packages/core/test/session-prompt.test.ts`
- Added deterministic A/B regression:
- A is promoted.
- B is admitted with `resume:false`.
- Retrying A with `resume:true` issues no wake. ...
The scheduling fix now derives advisory wake from durable input state, not the caller’s resume flag. I’m checking the promoted-retry branch and its regression, then I’ll run the repository’s canonical API generation path so packages/sdk/openapi.json and generated clients match the event schema.
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
43: export const admit = Effect.fn("SessionInput.admit")(function* (
44: db: DatabaseService,
45: events: EventV2.Interface,
46: input: {
47: readonly id: SessionMessage.ID
48: readonly sessionID: SessionSchema.ID
49: readonly prompt: Prompt
50: readonly delivery: Delivery
51: readonly requestExecution: boolean
52: },
53: ) {
54: const existing = yield* find(db, input.id)
55: if (existing !== undefined) {
56: if (!equivalent(existing, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
57: const shouldWake = input.requestExecution ? yield* requestExecution(db, events, input) : false
58: return { admitted: existing, shouldWake }
59: }
60: const timestamp = yield* DateTime.now
61: return yield* events
62: .publish(
63: SessionEvent.PromptAdmitted,
64: {
65: messageID: input.id,
66: ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
157: const executionPending = Effect.fn("SessionInput.executionPending")(function* (
158: db: DatabaseService,
159: input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
160: ) {
161: const row = yield* db
162: .select({
163: sessionID: SessionInputTable.session_id,
164: promotedSeq: SessionInputTable.promoted_seq,
165: wakePending: SessionInputTable.wake_pending,
166: })
167: .from(SessionInputTable)
168: .where(eq(SessionInputTable.id, input.id))
169: .get()
170: .pipe(Effect.orDie)
171: if (!row || row.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
172: return row.promotedSeq === null && row.wakePending
173: })
174:
175: export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
176: db: DatabaseService,
177: input: {
178: ...
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path>
<type>file</type>
<content>
360: prompt: Effect.fn("V2Session.prompt")((input) =>
361: Effect.uninterruptible(
362: Effect.gen(function* () {
363: yield* result.get(input.sessionID)
364: const prompt = resolvePrompt(input.prompt)
365: const messageID = input.id ?? SessionMessage.ID.create()
366: const delivery = input.delivery ?? "steer"
367: const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
368: const admission = yield* SessionInput.admit(db, events, {
369: id: messageID,
370: sessionID: input.sessionID,
371: prompt,
372: delivery,
373: requestExecution: input.resume !== false,
374: }).pipe(
375: Effect.catchDefect((defect) =>
376: defect instanceof SessionInput.LifecycleConflict
377: ...
Found 21 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 604: const unrelatedID = SessionMessage.ID.make("msg_recovery_unrelated")
Line 606: id: unrelatedID,
Line 608: prompt: Prompt.make({ text: "unrelated admit-only" }),
Line 637: .where(eq(SessionInputTable.id, unrelatedID))
/home/byk/Code/opencode/packages/core/test/database-migration.test.ts:
Line 226: yield* db.run(sql`CREATE TABLE unrelated (id text PRIMARY KEY)`)
/home/byk/Code/opencode/packages/core/test/snapshot.test.ts:
Line 132: testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () =>
Line 157: await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n")
Line 161: expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n")
/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts:
Line 330: it.effect("never wakes ...
<path>/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts</path>
<type>file</type>
<content>
320: const first = yield* session.prompt(input)
321: wakeCalls.length = 0
322:
323: const retried = yield* session.prompt({ ...input, resume: true })
324:
325: expect(retried).toEqual(first)
326: expect(wakeCalls).toEqual([sessionID])
327: }),
328: )
329:
330: it.effect("never wakes execution when retrying an already promoted prompt", () =>
331: Effect.gen(function* () {
332: yield* setup
333: const { db } = yield* Database.Service
334: const session = yield* SessionV2.Service
335: const events = yield* EventV2.Service
336: const pendingID = SessionMessage.ID.create()
337: yield* session.prompt({
338: sessionID,
339: id: messageID,
340: prompt: Prompt.make({ text: "Promote this prompt" }),
341: })
342: yield* SessionInput.promoteSteers(db, events, sessionID, ...
<path>/home/byk/Code/opencode/package.json</path>
<type>file</type>
<content>
1: {
2: "$schema": "https://json.schemastore.org/package.json",
3: "name": "opencode",
4: "description": "AI-powered development tool",
5: "private": true,
6: "type": "module",
7: "packageManager": "bun@1.3.14",
8: "scripts": {
9: "dev": "bun run --cwd packages/opencode src/index.ts",
10: "dev:desktop": "bun --cwd packages/desktop dev",
11: "dev:web": "bun --cwd packages/app dev",
12: "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
13: "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
14: "dev:storybook": "bun --cwd packages/storybook storybook",
15: "lint": "oxlint",
16: "typecheck": "bun turbo typecheck",
17: "upgrade-opentui": "bun run script/upgrade-opentui.ts",
18: "postinstall": "bun run --cwd packages/core fix-node-pty",
19: "prepare": "husky",
20: "random": "echo ...
The promoted-retry regression reaches the exact A/B failure and the implementation suppresses wake once the durable input is promoted. I’m regenerating the canonical checked-in OpenAPI plus legacy JavaScript SDK now; this is generation only, not a service operation.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a721ebce001ccG05A9UyWKzvs packages/ui/src/theme/themes/kanagawa.json 2ms (unchanged) packages/ui/src/theme/themes/lucent-orng.json 2ms (unchanged) packages/ui/src/theme/themes/material.json 2ms (unchanged) packages/ui/src/theme/themes/matrix.json 2ms (unchanged) packages/ui/src/theme/themes/mercury.json 2ms (unchanged) packages/ui/src/theme/themes/monokai.json 1ms (unchanged) packages/ui/src/theme/themes/nightowl.json 1ms (unchanged) packages/ui/src/theme/themes/nord.json 1ms (unchanged) packages/ui/src/theme/themes/oc-2.json 7ms (unchanged) packages/ui/src/theme/themes/one-dark.json 3ms (unchanged) packages/ui/src/theme/themes/onedarkpro.json 1ms (unchanged) packages/ui/src/theme/themes/opencode.json 2ms (unchanged) packages/ui/src/theme/themes/orng.json 2ms (unchanged) packages/ui/src/theme/themes/osaka-jade.json 3ms (unchanged) packages/ui/src/theme/themes/palenight.json 2ms ...
Canonical generation completed, but it also runs the repository-wide formatter. I’m checking the resulting diff now to separate intended generated API artifacts from any incidental generated or formatting changes before validation.
M .lore.md M AGENTS.md M packages/app/index.html M packages/app/src/components/titlebar-tab-nav.css M packages/app/src/components/titlebar-tab-rail-state.ts M packages/app/src/components/titlebar.tsx M packages/app/src/context/settings.tsx M packages/app/src/i18n/en.ts M packages/app/src/pages/session.tsx M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M ...
Generation touched eight unrelated source files and rewrote pnpm-lock.yaml through the repository-wide formatter. Those paths were clean immediately before generation, so I’ll remove only those command-produced changes while preserving the intended OpenAPI/SDK outputs and every pre-existing modification.
No files found
(no output)
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
The working tree is back to the intended recovery/API scope; unrelated formatter output is gone. I’m verifying the OpenAPI unions and fields, then I’ll rerun the full validation matrix because both scheduling source and checked-in API artifacts changed after the last complete pass.
Found 8 matches
/home/byk/Code/opencode/packages/sdk/openapi.json:
Line 17899: "requestExecution": {
Line 17919: "enum": ["session.next.prompt.execution_requested"]
Line 25709: "requestExecution": {
Line 25740: "enum": ["session.next.prompt.execution_requested.1"]
Line 28599: "requestExecution": {
Line 28622: "enum": ["session.next.prompt.execution_requested"]
Line 34811: "requestExecution": {
Line 34831: "enum": ["session.next.prompt.execution_requested"]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[22:20:07.442] ERROR (#27075): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
1141 pass
0 fail
3162 expect() calls
Ran 1141 tests across 145 files. [77.38s]
[22:21:18.243] INFO (#203105): watcher backend {
directory: "/tmp/opencode-core-test-FiCOcc",
platform: "linux",
backend: "inotify",
}
[22:21:18.247] INFO (#203214): project copy refresh started {
projectID: "global",
}
[22:21:18.248] INFO (#203214): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[22:21:18.280] INFO (#203105): booting location services {
directory: ...
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a) 2 pass 0 fail 23 expect() calls Ran 2 tests across 1 file. [1387.00ms] $ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-check-QfaQD7/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-QfaQD7/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-QfaQD7/full/20260915222652_schema/migration.sql 🚀
(no output)
All final source and contract gates pass: 1,141 Core tests, Schema manifest tests, five package typechecks, migration consistency, OpenAPI coverage, and diff checks. I’m rebuilding the exact SEA and running isolated startup plus restart-recovery smoke tests; a final narrow audit will independently check the two last fixes before freeze.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, and post-promotion retry regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run package validation and isolated artifact restart smoke",
"status": "in_progress",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security ...
<task id="ses_f58d048a4ffemday9ZrGdkZyKg" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a73338b9001qqdA17WAcH5IJV dist/assets/nope-12-BJR1Ka3c.aac 5.72 kB dist/assets/jinja-CGKoJZuT.js 5.74 kB dist/assets/nope-11-CVdXg8G-.aac 5.77 kB dist/assets/lean-C4RHkN41.js 5.83 kB dist/assets/moonbit-CR8uFLGd.js 5.95 kB dist/assets/powerquery-DHf-dtTS.js 5.96 kB dist/assets/shaderlab-CEJedz68.js 5.97 kB dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff 5.98 kB dist/assets/verilog-LfQUkHDc.js 5.98 kB dist/assets/cypher-DYQk2G82.js 6.00 kB dist/assets/vb-4AxSLKIU.js 6.14 kB dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff 6.19 kB dist/assets/red-DR6ZwibK.js ...
The exact SEA rebuilt successfully with only known bundle-size and chunking warnings. I’m reusing the prior disposable smoke design, with isolated HOME/XDG/database paths and loopback ports, to verify authenticated health, final schema, clean shutdown, and exactly-once recovery across two restarts.
continue
I’m running the rebuilt SEA only in a disposable environment. The smoke will not touch the live server or database and will verify health, schema, shutdown, and two-start recovery idempotence.
continue
The rebuilt artifact is ready. I’m recovering the exact disposable smoke parameters used earlier, then I’ll run the health/schema test and the two-restart recovery test without touching the live service.
Okay, right now Lore has this sill bug about limiting max number of recalls. Can you try using http://labs.sheep-fir.ts.net:3207/ui/search to make your recalls instead of using the recall tool for now
I’ll use the Lore search UI at http://labs.sheep-fir.ts.net:3207/ui/search instead of the recall tool for this session. I’ll first inspect how the page accepts queries so I can use it reliably without affecting Lore state.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Search - Lore</title> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGFyaWEtbGFiZWxsZWRieT0idCBkIiB2aWV3Qm94PSItMSA0IDUyIDUyIj48dGl0bGUgaWQ9InQiPkxvcmUuQUk8L3RpdGxlPjxkZXNjIGlkPSJkIj5Mb3JlLkFJIGxpbHkgbWFyayDigJQgbW9ub2Nocm9tZSBhZGFwdGl2ZSBmYXZpY29uLjwvZGVzYz48c3R5bGU+LmxpbHl7ZmlsbDojMDAwfUBtZWRpYSAocHJlZmVycy1jb2xvci1zY2hlbWU6ZGFyayl7LmxpbHl7ZmlsbDojZmZmfX08L3N0eWxlPjxwYXRoIGQ9Ik0yMi45IDExLjFjLjM1LjE5Mi42NC40MDguNzYyLjguMDI3LjQxNS0uMDM0LjU2Ni0uMzA2Ljg5NEwyMy4xIDEzbC0uMS4xYy0uNDQ3LjAzNy0uNjk1LjA1Ni0xLjA5NC0uMTU2LS4yNzMtLjMyMy0uMzA2LS41MjMtLjMwNi0uOTQ0LjIzOS0uNjU0LjU3NS0xLjAyOCAxLjMtLjltLS43NjIuNDA2Yy0uMjM4LjE5NC0uMjM4LjE5NC0uMzQ0LjQ4MS4wMDYuMzEzLjAwNi4zMTMuMTYyLjYuMjQ0LjIxMy4yNDQuMjEzLjU1LjI2My4yOTQtLjA1LjI5NC0uMDUuNDgyLS4xNjIuMTY4LS4yOC4xNTgtLjQ2OC4xMTItLjc4OC0uMjc3LS4zNTMtL...
The page uses a read-only GET interface with q, project, and scope parameters. I’ll query it with project=/home/byk/Code/opencode for project-specific history and use scope=knowledge when I only need distilled guidance; I will not call the recall tool while this workaround is active.
Search - Lore Lore [Dashboard](/ui) [Knowledge](/ui/knowledge) [Entities](/ui/entities) [Search](/ui/search) [Costs](/ui/costs) [Warming](/ui/warming) # Search All projects institutional-transition-labsentry-api-schemagetsentryprobe-chunkprojectbinpatchprojectopencode-loreattaquer-frameworkzebarsshoqmy-projectzmk-config-corneviewfossilizeCodesentry-release-registrydevinfra-coder-infrabyk.github.iocodecov-actioncalm-forestsqlishreadwise-full-contentshiny-moonspotlightopencodenpmx.devsupersetopsdevinfra-deployment-servicesentry-orbitalself-hostedneon-squidcraftsentrysentry-docspublishsentry-pythonsecurity-as-codesentry-javascriptopencodecli allprojectknowledge Search Found 60 results, showing 30 (score: 0.0667–0.0371). ### Strong Matches - 0.0667distilled gen 1 · 7h ago ### Current State - \*\*Date: Sep 15, 2026.\*\* A frozen, strictly read-only adversarial correctness review is still in progress for the complete V2 interrupted-session recovery candidate in ...
The workaround works and returns both distilled history and source-message results without the recall limit. I’m using the matched smoke record now to resume the pending isolated SEA verification.
Detail: d:c9dad2e4-0ac3-47c7-ad95-cda664052ff8 - Lore
Lore [Dashboard](/ui) [Knowledge](/ui/knowledge) [Entities](/ui/entities) [Search](/ui/search) [Costs](/ui/costs) [Warming](/ui/warming)
[Dashboard](/ui) › [Search](/ui/search) › d:c9dad2e4-0ac3-47c7-ad95-cda664052ff8
## Recall Detail: d:c9dad2e4-0ac3-47c7-ad95-cda664052ff8
#### Distilled
Date: Sep 15, 2026 \* 🟡 (01:23) Process inspection with `pgrep -af '/tmp/opencode/session-recovery-smoke|dist/server/opencode-server|node -e' || true` returned only the invoking `/usr/bin/bash` process (`2545348`), indicating no matching smoke/server process remained running. \* 🟡 (01:24) The corrected isolated SEA smoke harness succeeded: authenticated health returned `{"healthy":true,"version":"1.18.30"}`, the database was created at `/tmp/opencode/session-recovery-smoke/opencode.db`, and shutdown completed cleanly with `exit.code=0` and `exit.signal=null`. ...
Search - Lore Lore [Dashboard](/ui) [Knowledge](/ui/knowledge) [Entities](/ui/entities) [Search](/ui/search) [Costs](/ui/costs) [Warming](/ui/warming) # Search All projects institutional-transition-labsentry-api-schemagetsentryprobe-chunkprojectbinpatchprojectopencode-loreattaquer-frameworkzebarsshoqmy-projectzmk-config-corneviewfossilizeCodesentry-release-registrydevinfra-coder-infrabyk.github.iocodecov-actioncalm-forestsqlishreadwise-full-contentshiny-moonspotlightopencodenpmx.devsupersetopsdevinfra-deployment-servicesentry-orbitalself-hostedneon-squidcraftsentrysentry-docspublishsentry-pythonsecurity-as-codesentry-javascriptopencodecli allprojectknowledge Search Found 60 results, showing 30 (score: 0.0654–0.0321). ### Strong Matches - 0.0654distilled gen 0 · 21h ago Date: Sep 15, 2026 \* 🟡 (01:23) Process inspection with \`pgrep -af '/tmp/opencode/session-recovery-smoke|dist/server/opencode-server|node -e' || true\` returned only the invoking \`/usr/bin/bash\` process ...
The search UI recovered the prior outcomes but not the full harness source. I’ll reconstruct it from the current server entrypoint and CLI flags, then run a bounded in-memory Node harness with the exit listener registered before shutdown, avoiding the timeout race from the first attempt.
Found 100 matches (more matches available)
/home/byk/Code/opencode/packages/opencode/src/node-server.ts:
Line 3: import { Flag } from "@opencode-ai/core/flag/flag"
Line 4: import { InstallationVersion } from "@opencode-ai/core/installation/version"
Line 5: import { existsSync } from "node:fs"
Line 6: import { Module } from "node:module"
Line 7: import path from "node:path"
Line 8: import { fileURLToPath } from "node:url"
Line 9: import { parseArgs } from "node:util"
Line 10: import { Server } from "./server/server"
Line 19: const help = `opencode-server ${InstallationVersion}
Line 21: Usage: opencode-server [options]
Line 24: --hostname <host> Hostname to listen on (default: 127.0.0.1)
Line 25: --port <port> Port to listen on (default: 0)
Line 34: // Older Bun-compatible plugins may emit extensionless relative ESM imports, which Node does not resolve.
Line 62: hostname: { type: "string", default: "127.0.0.1" },
...
<path>/home/byk/Code/opencode/packages/opencode/src/node-server.ts</path>
<type>file</type>
<content>
19: const help = `opencode-server ${InstallationVersion}
20:
21: Usage: opencode-server [options]
22:
23: Options:
24: --hostname <host> Hostname to listen on (default: 127.0.0.1)
25: --port <port> Port to listen on (default: 0)
26: --cors <origin> Additional allowed CORS origin (repeatable)
27: --mdns Enable mDNS service discovery
28: --mdns-domain <domain> mDNS domain (default: opencode.local)
29: --help, -h Show help
30: --version, -v Show version
31: `
32:
33: async function main() {
34: // Older Bun-compatible plugins may emit extensionless relative ESM imports, which Node does not resolve.
35: registerHooks({
36: resolve(specifier, context, nextResolve) {
37: try {
38: return nextResolve(specifier, context)
39: } catch (error) {
40: if (
41: !(error ...
Found 32 matches /home/byk/Code/opencode/packages/sdk-next/test/embedded.test.ts: Line 11: const database = Flag.OPENCODE_DB Line 12: Flag.OPENCODE_DB = join(directory, "opencode.sqlite") Line 102: Flag.OPENCODE_DB = database Line 109: const database = Flag.OPENCODE_DB Line 110: Flag.OPENCODE_DB = join(directory, "opencode.sqlite") Line 141: Flag.OPENCODE_DB = database Line 148: const database = Flag.OPENCODE_DB Line 149: Flag.OPENCODE_DB = join(directory, "opencode.sqlite") Line 184: Flag.OPENCODE_DB = database Line 191: const database = Flag.OPENCODE_DB Line 192: Flag.OPENCODE_DB = join(directory, "opencode.sqlite") Line 209: Flag.OPENCODE_DB = database /home/byk/Code/opencode/packages/core/test/preload.ts: Line 3: process.env.OPENCODE_DB = ":memory:" /home/byk/Code/opencode/packages/opencode/test/preload.ts: Line 34: process.env["XDG_DATA_HOME"] = path.join(dir, "share") Line 87: ...
Found 9 matches
/home/byk/Code/opencode/packages/opencode/src/cli/cmd/run.ts:
Line 202: describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
/home/byk/Code/opencode/packages/opencode/src/cli/cmd/attach.ts:
Line 43: describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
/home/byk/Code/opencode/packages/opencode/src/effect/config-service.ts:
Line 34: * username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
/home/byk/Code/opencode/packages/opencode/src/plugin/snowflake-cortex.ts:
Line 88: return `Basic ${Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_ID}`).toString("base64")}`
/home/byk/Code/opencode/packages/opencode/src/server/shared/pty-ticket.ts:
Line 7: // Auth middleware skips Basic Auth when this matches; the PTY connect handler
/home/byk/Code/opencode/packages/opencode/src/server/auth.ts:
Line 19: username: ...
The search workaround is established. I’m continuing the pending smoke with a fresh disposable root and the rebuilt binary, first confirming the temporary parent and local SQLite tooling before creating any files.
drwxr-xr-x 3 byk byk 4096 Sep 15 20:40 /tmp/opencode
/usr/bin/sqlite3 /home/byk/.volta/bin/node
{"status":200,"health":{"healthy":true,"version":"1.18.30"},"database":"/tmp/opencode/session-recovery-smoke-final/opencode.db","exit":{"code":0,"signal":null},"stderr":""}
Authenticated health and clean shutdown passed on the rebuilt SEA: HTTP 200, version 1.18.30, exit code 0, no signal, no stderr. I’m checking the migrated final schema now, then I’ll seed one expired recoverable execution and verify exactly-once synthetic continuation across two isolated restarts.
[{"name":"session_execution_owner_idx","sql":"CREATE INDEX `session_execution_owner_idx` ON `session_execution` (`owner_id`)"},
{"name":"session_execution_session_idx","sql":"CREATE UNIQUE INDEX `session_execution_session_idx` ON `session_execution` (`session_id`)"},
{"name":"session_input_wake_pending_promoted_time_created_id_idx","sql":"CREATE INDEX `session_input_wake_pending_promoted_time_created_id_idx` ON `session_input` (`wake_pending`,`promoted_seq`,`time_created`,`id`)"},
{"name":"session_execution","sql":"CREATE TABLE `session_execution` (\n `id` text NOT NULL,\n `session_id` text NOT NULL,\n `owner_id` text NOT NULL,\n `assistant_message_id` text,\n `phase` text NOT NULL,\n `time_created` integer NOT NULL,\n `expires_at` integer NOT NULL,\n `recovery_error` text,\n CONSTRAINT `session_execution_pk` PRIMARY KEY(`id`),\n CONSTRAINT `fk_session_execution_session_id_session_id_fk` FOREIGN ...
Search - Lore Lore [Dashboard](/ui) [Knowledge](/ui/knowledge) [Entities](/ui/entities) [Search](/ui/search) [Costs](/ui/costs) [Warming](/ui/warming) # Search All projects institutional-transition-labsentry-api-schemagetsentryprobe-chunkprojectbinpatchprojectopencode-loreattaquer-frameworkzebarsshoqmy-projectzmk-config-corneviewfossilizeCodesentry-release-registrydevinfra-coder-infrabyk.github.iocodecov-actioncalm-forestsqlishreadwise-full-contentshiny-moonspotlightopencodenpmx.devsupersetopsdevinfra-deployment-servicesentry-orbitalself-hostedneon-squidcraftsentrysentry-docspublishsentry-pythonsecurity-as-codesentry-javascriptopencodecli allprojectknowledge Search Found 60 results, showing 30 (score: 0.0661–0.0415). ### Strong Matches - 0.0661distilled gen 0 · 22h ago Date: Sep 14, 2026 \* 🟡 (23:09) Disposable built-artifact health smoke on \`127.0.0.1:41875\` passed after two initial curl connection failures during startup: PID \`2321463\`, response ...
Detail: t:lore\_tm\_v1\_iVvvAyqd3xYsSXacz0ftRsZUijoBp0ZnhN0WPw2Ittc - Lore Lore [Dashboard](/ui) [Knowledge](/ui/knowledge) [Entities](/ui/entities) [Search](/ui/search) [Costs](/ui/costs) [Warming](/ui/warming) [Dashboard](/ui) › [Search](/ui/search) › t:lore\_tm\_v1\_iVvvAyqd3xYsSXacz0ftRsZUijoBp0ZnhN0WPw2Ittc ## Recall Detail: t:lore\_tm\_v1\_iVvvAyqd3xYsSXacz0ftRsZUijoBp0ZnhN0WPw2Ittc #### Conversation (assistant, 23h ago, session: 0410hkwv) The disposable database schema confirms a safe execution can be seeded without an assistant projection, which is the exact crash-before-provider-output case discovery alone misses. I’m checking the canonical global project and Session ID shapes, then I’ll seed one valid Session plus one `safe` claim and prove the built server creates one recovery event and one continuation across two restarts.
Search - Lore Lore [Dashboard](/ui) [Knowledge](/ui/knowledge) [Entities](/ui/entities) [Search](/ui/search) [Costs](/ui/costs) [Warming](/ui/warming) # Search All projects institutional-transition-labsentry-api-schemagetsentryprobe-chunkprojectbinpatchprojectopencode-loreattaquer-frameworkzebarsshoqmy-projectzmk-config-corneviewfossilizeCodesentry-release-registrydevinfra-coder-infrabyk.github.iocodecov-actioncalm-forestsqlishreadwise-full-contentshiny-moonspotlightopencodenpmx.devsupersetopsdevinfra-deployment-servicesentry-orbitalself-hostedneon-squidcraftsentrysentry-docspublishsentry-pythonsecurity-as-codesentry-javascriptopencodecli allprojectknowledge Search Found 60 results, showing 30 (score: 0.0864–0.0425). ### Strong Matches - 0.0864distilled gen 0 · 3h ago Date: Sep 15, 2026 \* 🔴 (18:20) User specified the recovery invariant \`"never recovers a live execution lease"\`: an execution with \`owner\_id: "live"\` and \`expires\_at: Date.now() + 60\_000\` must ...
[{"name":"event","sql":"CREATE TABLE `event` (\n `id` text PRIMARY KEY,\n `aggregate_id` text NOT NULL,\n `seq` integer NOT NULL,\n `type` text NOT NULL,\n `data` text NOT NULL,\n CONSTRAINT `fk_event_aggregate_id_event_sequence_aggregate_id_fk` FOREIGN KEY (`aggregate_id`) REFERENCES `event_sequence`(`aggregate_id`) ON DELETE CASCADE\n )"},
{"name":"event_sequence","sql":"CREATE TABLE `event_sequence` (\n `aggregate_id` text PRIMARY KEY,\n `seq` integer NOT NULL,\n `owner_id` text\n )"},
{"name":"project","sql":"CREATE TABLE `project` (\n `id` text PRIMARY KEY,\n `worktree` text NOT NULL,\n `vcs` text,\n `name` text,\n `icon_url` text,\n `icon_url_override` text,\n `icon_color` text,\n `time_created` integer NOT NULL,\n `time_updated` integer NOT NULL,\n `time_initialized` integer,\n ...
The final schema is correct, including the four-phase check, durable ordinary-wake index, and recovery outbox fields. For the restart test I’ll use an expired ready claim with no assistant, which models a crash before provider dispatch and must produce one deterministic synthetic recovery plus queued continue; a safe claim now correctly clears without continuation.
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, gt, isNull, lte, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { continuation, message } from "./recovery-id"
13: import { Prompt } from "./prompt"
14: import { SessionInput } from "./input"
15: import {
16: SESSION_EXECUTION_LEASE_MS,
17: SessionExecutionTable,
18: SessionInputTable,
19: SessionRecoveryTable,
20: SessionMessageTable,
21: } from "./sql"
22:
23: export const ...
(no output)
I’ve confirmed the current recovery semantics from source: an expired ready claim with no assistant publishes Step.Recovered, creates deterministic msg_recovery_<execution> and msg_recovery_continue_<execution> records, clears the claim atomically, then issues only an advisory wake. I’m seeding that exact state into the disposable database and will compare counts and identities after each restart.
[{"executions":1,"recoveries":0,"inputs":0,"events":0}]
{"exit":{"code":0,"signal":null},"stderr":""}
[{"executions":0,"recoveries":1,"inputs":1,"events":2}]
[{"execution_id":"recovery-smoke-final","session_id":"ses_recovery_smoke_final","assistant_message_id":null,"continuation_message_id":"msg_recovery_continue_recovery-smoke-final","phase":"ready","wake_pending":0,"recovery_error":null}]
[{"id":"msg_recovery_continue_recovery-smoke-final","session_id":"ses_recovery_smoke_final","prompt":"{\"text\":\"continue\"}","delivery":"queue","promoted_seq":1,"wake_pending":0}]
[{"id":"msg_recovery_recovery-smoke-final","session_id":"ses_recovery_smoke_final","type":"synthetic","seq":0,"data":"{\"time\":{\"created\":1789512242212},\"sessionID\":\"ses_recovery_smoke_final\",\"text\":\"Session execution interrupted by server ...
The first restart passed the full behavior, not just admission: it atomically replaced the expired claim with one recovery event, one synthetic interruption, and one queued continue, then promoted that exact continuation and settled the recovery wake. I’m restarting the same isolated artifact once more to prove all identities and counts remain unchanged.
{"exit":{"code":0,"signal":null},"stderr":""}
[{"executions":0,"recoveries":1,"inputs":1,"messages":2,"events":2}]
[{"execution_id":"recovery-smoke-final","continuation_message_id":"msg_recovery_continue_recovery-smoke-final","phase":"ready","wake_pending":0,"recovery_error":null}]
[{"id":"msg_recovery_continue_recovery-smoke-final","prompt":"{\"text\":\"continue\"}","delivery":"queue","promoted_seq":1,"wake_pending":0}]
[{"id":"msg_recovery_recovery-smoke-final","type":"synthetic","seq":0,"data":"{\"time\":{\"created\":1789512242212},\"sessionID\":\"ses_recovery_smoke_final\",\"text\":\"Session execution interrupted by server ...
The second restart preserved the exact state: 0 execution claims, 1 recovery row, 1 promoted queued input, 2 messages, and 2 durable events, with unchanged deterministic IDs and timestamps. The rebuilt artifact now passes startup, migration, authentication, clean shutdown, recovery, promotion, outbox settlement, and restart idempotence; I’m recording its hash and ensuring no disposable server remains.
8e567b2cce03c95263df6fd06d80de6e143858d521e70712f5dc97bbfeb3355b packages/opencode/dist/server/opencode-server 3362513 /usr/bin/bash -c sha256sum "packages/opencode/dist/server/opencode-server" && pgrep -af '/tmp/opencode/session-recovery-smoke-final|packages/opencode/dist/server/opencode-server' || true
The artifact hash is 8e567b2cce03c95263df6fd06d80de6e143858d521e70712f5dc97bbfeb3355b, and no disposable server remains. I’m freezing the complete recovery/API source delta against base ab9408c81c, using a temporary Git index so untracked recovery modules are included while unrelated AGENTS.md, .lore.md, and profiling files remain outside the candidate.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
The final candidate contains 30 tracked paths plus the two new Core modules; the three discarded intermediate migrations are absent relative to the base, as intended. I’m creating a read-only review patch from a temporary index rooted at the base so the freeze includes untracked recovery modules but excludes unrelated workspace files.
.lore.md packages/core/schema.json packages/core/src/database/migration.gen.ts packages/core/src/database/migration/20260914170650_session-recovery.ts packages/core/src/database/migration/20260914214636_session-execution.ts packages/core/src/database/migration/20260914222526_session-execution-phase.ts packages/core/src/database/schema.gen.ts packages/core/src/session.ts packages/core/src/session/compaction.ts packages/core/src/session/context-epoch.ts packages/core/src/session/execution/claim.ts packages/core/src/session/execution/local.ts packages/core/src/session/input.ts packages/core/src/session/message-updater.ts packages/core/src/session/projector.ts packages/core/src/session/recovery-id.ts packages/core/src/session/recovery.ts packages/core/src/session/run-coordinator.ts packages/core/src/session/runner/index.ts packages/core/src/session/runner/llm.ts packages/core/src/session/runner/publish-llm-event.ts packages/core/src/session/sql.ts packages/core/test/database-migration.test...
The first temporary-index export compared its base-rooted index against current HEAD, so its file listing misleadingly included .lore.md and the discarded migrations. I’m correcting the export to compare that index against the explicit base object; the candidate itself is unchanged.
packages/core/schema.json packages/core/src/database/migration.gen.ts packages/core/src/database/migration/20260914170650_session-recovery.ts packages/core/src/database/schema.gen.ts packages/core/src/session.ts packages/core/src/session/compaction.ts packages/core/src/session/context-epoch.ts packages/core/src/session/execution/claim.ts packages/core/src/session/execution/local.ts packages/core/src/session/input.ts packages/core/src/session/message-updater.ts packages/core/src/session/projector.ts packages/core/src/session/recovery-id.ts packages/core/src/session/recovery.ts packages/core/src/session/run-coordinator.ts packages/core/src/session/runner/index.ts packages/core/src/session/runner/llm.ts packages/core/src/session/runner/publish-llm-event.ts packages/core/src/session/sql.ts packages/core/test/database-migration.test.ts packages/core/test/session-execution-local.test.ts packages/core/test/session-projector.test.ts packages/core/test/session-prompt.test.ts packages/core/test/...
The corrected freeze is complete: 32 recovery/API files, base ab9408c81c, patch SHA-256 b670599f8dbb4d4e14a74691a286622077c87e4d5c96a9acd3e2843c2c01072c, artifact SHA-256 8e567b2cce03c95263df6fd06d80de6e143858d521e70712f5dc97bbfeb3355b. Unrelated .lore.md, AGENTS.md, and profiling files are excluded; source is now frozen pending the active narrow audit and two final independent reviews.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, and post-promotion retry regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security ...
The freeze is immutable and all validation is complete, so I’m launching separate correctness and security reviews against the exact patch and artifact hashes. They must inspect the frozen files only, cite current patch line evidence, and end with MERGE or DO-NOT-MERGE; no source, build, test, or service changes are allowed during review.
<task id="ses_f58bb16e9ffePzYYJIUipsmPrL" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f58ba14faffea8poBv83lQNN7w" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Lore UI search works as a recall substitute. The candidate is frozen, and independent correctness and security reviews are running against the exact patch and SEA hashes. No live service was touched.
Both prior blockers are closed by the current files.
SessionInput.admit derives shouldWake from the retried input, not Session-wide pending work (packages/core/src/session/input.ts:54-59).requestExecution queries the exact (messageID, sessionID) row and returns false when that input is promoted (packages/core/src/session/input.ts:108-120).packages/core/src/session/input.ts:209-229), and the final exact-row reread returns false after promotion (packages/core/src/session/input.ts:151-169).packages/core/src/session/input.ts:88-99).SessionV2.prompt calls execution.wake only when this returned value is true (packages/core/src/session.ts:368-384).Therefore an already-promoted exact retry cannot treat an unrelated pending resume:false input as execution intent.
The focused regression creates that exact unrelated-input condition and asserts no wake plus unchanged rows (packages/core/test/session-prompt.test.ts:330-371). Concurrent execution-request deduplication is also covered (packages/core/test/session-prompt.test.ts:440-485).
packages/sdk/openapi.json contains:
packages/sdk/openapi.json:15309-15313, packages/sdk/openapi.json:24200-24204).packages/sdk/openapi.json:23811-23815).packages/sdk/openapi.json:20577-20580).requestExecution in public/durable/sync forms (packages/sdk/openapi.json:17899-17903, packages/sdk/openapi.json:25660-25714, packages/sdk/openapi.json:28545-28604, ...The final narrow audit passed. It confirmed:
resume:false input.The frozen candidate remains unchanged while correctness and security reviews continue.
</task_result> </task>
The security reviewer returned empty output, so it provides no evidence and is invalid. I’m verifying the frozen hashes, then giving that same review session its one evidence-or-blocker retry; a second empty result will require a replacement reviewer.
b670599f8dbb4d4e14a74691a286622077c87e4d5c96a9acd3e2843c2c01072c /tmp/opencode/session-recovery-final.patch 8e567b2cce03c95263df6fd06d80de6e143858d521e70712f5dc97bbfeb3355b packages/opencode/dist/server/opencode-server
<task id="ses_f58ba14faffea8poBv83lQNN7w" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Frozen hashes remain unchanged. The security reviewer is performing its one evidence-or-blocker retry; the correctness review remains active.
</task_result> </task>
The same security reviewer returned empty output again, so that session is disqualified. The frozen candidate remains unchanged; I’m replacing it with a fresh independent security reviewer under the same immutable evidence contract.
<task id="ses_f58ae6692ffelaO0lE3URkidUf" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The empty security reviewer was replaced. The fresh security review and original correctness review now run independently against the unchanged frozen patch and artifact.
SessionRecovery.recover quarantines invalid or stale recovery rows only by setting recovery_error (packages/core/src/session/recovery.ts:220-254). It leaves the associated queued continuation input pending.
The local executor then excludes the errored recovery row from exact recovery selection (packages/core/src/session/execution/local.ts:48-56). However, ordinary queue discovery and promotion do not exclude recovery continuations:
SessionInput.hasPending: packages/core/src/session/input.ts:304-322SessionInput.promoteNextQueued: packages/core/src/session/input.ts:408-428Concrete failure sequence:
Step.Recovered creates a deterministic queued continue input.recovery_error: "Invalid recovery projection identity".continue input.For malformed continuation prompts, promotion instead fails repeatedly during prompt decoding and can block later queued work.
The existing stale-recovery test stops after asserting the error and lack of immediate wake (packages/core/test/session-projector.test.ts:698-754). It never starts later ordinary work and therefore misses this escape from quarantine.
Recovery continuations must remain reserved for exact recovery promotion and must never enter ordinary queue selection, including after quarantine. ...
The correctness review found one real blocker: quarantining a recovery row leaves its queued continue input eligible for ordinary queue promotion later. The freeze is rejected. I’m tracing all pending/queue selectors now and will reserve every continuation referenced by session_recovery for exact recovery handling only, whether active, settled, or quarantined.
</task_result> </task>
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
290: ) =>
291: db
292: .update(SessionRecoveryTable)
293: .set({ wake_pending: false })
294: .where(
295: and(
296: eq(SessionRecoveryTable.session_id, input.sessionID),
297: eq(SessionRecoveryTable.continuation_message_id, input.id),
298: eq(SessionRecoveryTable.wake_pending, true),
299: ),
300: )
301: .run()
302: .pipe(Effect.orDie)
303:
304: export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
305: db: DatabaseService,
306: sessionID: SessionSchema.ID,
307: delivery: Delivery,
308: ) {
309: const row = yield* db
310: .select({ id: SessionInputTable.id })
311: .from(SessionInputTable)
312: .where(
313: and(
314: eq(SessionInputTable.session_id, sessionID),
315: isNull(SessionInputTable.promoted_seq),
316: eq(SessionInputTable.delivery, delivery),
317: ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
1: export * as SessionInput from "./input"
2:
3: import { and, asc, eq, isNull, lte } from "drizzle-orm"
4: import { DateTime, Effect, Schema } from "effect"
5: import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
6: import type { Database } from "../database/database"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionMessage } from "./message"
10: import { Prompt } from "./prompt"
11: import { SessionSchema } from "./schema"
12: import { SessionInputTable, SessionMessageTable, SessionRecoveryTable } from "./sql"
13:
14: type DatabaseService = Database.Interface["db"]
15:
16: export { Admitted, Delivery }
17:
18: const decodePrompt = Schema.decodeUnknownSync(Prompt)
19: const encodePrompt = Schema.encodeSync(Prompt)
20:
21: export const decode = (value: unknown) => Schema.decodeUnknownOption(Prompt)(value)
22:
23: ...
The replacement security reviewer also returned no evidence and is invalid, but the correctness blocker already reopened the candidate. I’m applying the smallest fix at the shared inbox boundary: ordinary hasPending and queue promotion will exclude every input referenced by session_recovery; promoteExact remains the sole path for recovery continuations.
No files found
Found 7 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 342: it.effect("never recovers an execution superseded by a newer assistant", () =>
Line 440: it.effect("quarantines a malformed recovery row without blocking a valid wake", () =>
Line 494: it.effect("quarantines a full batch of poison recovery rows before redriving valid work", () =>
Line 559: it.effect("quarantines a continuation that belongs to another Session", () =>
Line 698: it.effect("quarantines a recovery wake behind a newer assistant", () =>
Line 753: recovery_error: "Invalid recovery projection identity",
Line 758: it.effect("quarantines a malformed claim without blocking a valid claim", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
680: })
681:
682: const recovered = yield* events
683: .publish(SessionEvent.Step.Recovered, {
684: sessionID,
685: timestamp: DateTime.makeUnsafe(1),
686: executionID: "cross-session",
687: assistantMessageID: assistantID,
688: phase: "unknown",
689: })
690: .pipe(Effect.exit)
691:
692: expect(Exit.isFailure(recovered)).toBe(true)
693: expect(yield* db.select().from(SessionRecoveryTable).all()).toEqual([])
694: expect(yield* db.select().from(SessionInputTable).all()).toEqual([])
695: }),
696: )
697:
698: it.effect("quarantines a recovery wake behind a newer assistant", () =>
699: Effect.gen(function* () {
700: const { db } = yield* Database.Service
701: const events = yield* EventV2.Service
702: const recoveredID = ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { DateTime, Deferred, Effect, Exit, Fiber, Schema } from "effect"
3: import { TestClock } from "effect/testing"
4: import { asc, eq, sql } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
7: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
8: import { EventV2 } from "@opencode-ai/core/event"
9: import { EventTable } from "@opencode-ai/core/event/sql"
10: import { ModelV2 } from "@opencode-ai/core/model"
11: import { Project } from "@opencode-ai/core/project"
12: import { ProjectTable } from "@opencode-ai/core/project/sql"
13: import { ProviderV2 } from "@opencode-ai/core/provider"
14: import { AbsolutePath } from "@opencode-ai/core/schema"
15: import { SessionV2 } from ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
43: export const admit = Effect.fn("SessionInput.admit")(function* (
44: db: DatabaseService,
45: events: EventV2.Interface,
46: input: {
47: readonly id: SessionMessage.ID
48: readonly sessionID: SessionSchema.ID
49: readonly prompt: Prompt
50: readonly delivery: Delivery
51: readonly requestExecution: boolean
52: },
53: ) {
54: const existing = yield* find(db, input.id)
55: if (existing !== undefined) {
56: if (!equivalent(existing, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
57: const shouldWake = input.requestExecution ? yield* requestExecution(db, events, input) : false
58: return { admitted: existing, shouldWake }
59: }
60: const timestamp = yield* DateTime.now
61: return yield* events
62: .publish(SessionEvent.PromptAdmitted, {
63: messageID: input.id,
64: sessionID: ...
I’m changing only SessionInput.hasPending and promoteNextQueued to anti-join session_recovery by continuation ID. The regression will quarantine a stale recovery, admit a later ordinary queued prompt, then prove ordinary draining skips the older recovery continuation and promotes only the legitimate prompt.
Success. Updated the following files: M packages/core/src/session/input.ts M packages/core/test/session-projector.test.ts
The fix and adversarial regression are in place. I’m first running the focused projector suite and Core typecheck; if either exposes a query-shape issue, I’ll correct it before the full suite.
bun test v1.3.14 (0d9b296a) 27 pass 0 fail 87 expect() calls Ran 27 tests across 1 file. [3.15s] $ tsgo --noEmit
The focused suite passes. I’m strengthening the regression before broad validation: immediately after quarantine, ordinary pending detection must return false and ordinary queue promotion must do nothing; only after adding a legitimate queued prompt may ordinary draining proceed.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 27 pass 0 fail 89 expect() calls Ran 27 tests across 1 file. [3.53s]
The stronger regression passes on the fix. Before broad validation, I’m proving it fails against the rejected frozen candidate by applying that exact patch in an isolated sibling worktree and overlaying only the new regression, not the production fix.
drwxrwxr-x 1 byk byk 4444 Sep 15 17:40 /home/byk/Code
NotFound: FileSystem.access (/home/byk/Code/opencode-recovery-quarantine-base)
Preparing worktree (detached HEAD ab9408c81c) Updating files: 94% (6282/6637) Updating files: 95% (6306/6637) Updating files: 96% (6372/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
The rejected candidate is now reproduced in an isolated worktree. I’m adding only the new test assertions there; the production anti-join remains absent, so this run directly proves whether the regression reaches the reported failure.
Success. Updated the following files: M ../opencode-recovery-quarantine-base/packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
752 | expect(yield* db.select().from(SessionRecoveryTable).get()).toMatchObject({
753 | recovery_error: "Invalid recovery projection identity",
754 | })
755 | const continuationID = continuation("stale-recovery-wake")
756 | const ordinaryID = SessionMessage.ID.make("msg_recovery_later_queue")
757 | expect(yield* SessionInput.hasPending(db, sessionID, "queue")).toBe(false)
^
error: expect(received).toBe(expected)
Expected: false
Received: true
at toBe (unknown:1:1)
at /home/byk/Code/opencode-recovery-quarantine-base/packages/core/test/session-projector.test.ts:757:70
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate ...
Fail-first proof succeeded: the new regression fails on the exact rejected candidate because hasPending(..., "queue") returns true for the quarantined continuation. The same test passes with the anti-join, establishing that the fix reaches the real bug; I’m removing the disposable worktree and running the full Core suite plus type and diff checks.
(no output)
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[23:12:53.056] ERROR (#27075): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
1141 pass
0 fail
3169 expect() calls
Ran 1141 tests across 145 files. [85.20s]
$ tsgo --noEmit
[23:14:09.683] INFO (#203102): watcher backend {
directory: "/tmp/opencode-core-test-flGfKf",
platform: "linux",
backend: "inotify",
}
[23:14:09.693] INFO (#203211): project copy refresh started {
projectID: "global",
}
[23:14:09.694] INFO (#203211): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[23:14:09.732] INFO (#203102): booting location services {
...
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-check-UytUdK/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-UytUdK/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-UytUdK/full/20260915231743_schema/migration.sql 🚀
The corrected source passes the full matrix: 1,141 Core tests with 3,169 assertions, five package typechecks, migration consistency, and diff checks. Because the production query changed, I’m rebuilding the SEA and repeating the isolated two-restart smoke before creating a new freeze.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a75d9a94001mcJZeKg12X3Qlr dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
The rebuilt SEA completed successfully; warnings are limited to known chunk-size/circular-chunk output and harmless SEA note-section injection warnings. I’m using a fresh disposable database for the post-fix artifact, then will seed and restart the same expired ready claim twice.
{"status":200,"health":{"healthy":true,"version":"1.18.30"},"exit":{"code":0,"signal":null},"stderr":""}
[{"executions":1,"recoveries":0,"inputs":0,"events":0}]
{"exit":{"code":0,"signal":null},"stderr":""}
[{"executions":0,"recoveries":1,"inputs":1,"messages":2,"events":2}]
[{"execution_id":"recovery-smoke-quarantine","continuation_message_id":"msg_recovery_continue_recovery-smoke-quarantine","phase":"ready","wake_pending":0,"recovery_error":null}]
[{"id":"msg_recovery_continue_recovery-smoke-quarantine","prompt":"{\"text\":\"continue\"}","delivery":"queue","promoted_seq":1,"wake_pending":0}]
{"exit":{"code":0,"signal":null},"stderr":""}
[{"executions":0,"recoveries":1,"inputs":1,"messages":2,"events":2}]
[{"execution_id":"recovery-smoke-quarantine","continuation_message_id":"msg_recovery_continue_recovery-smoke-quarantine","phase":"ready","wake_pending":0,"recovery_error":null}]
[{"id":"msg_recovery_continue_recovery-smoke-quarantine","prompt":"{\"text\":\"continue\"}","delivery":"queue","promoted_seq":1,"wake_pending":0}]
9bbe18549e623e6ebe4ed6f75c5403381c35ae860ad2dc77698a469ab8a6c7e6 packages/opencode/dist/server/opencode-server
3414483 /usr/bin/bash -c node -e 'const { spawn } = require("node:child_process"); const root = "/tmp/opencode/session-recovery-smoke-quarantine"; const binary = "/home/byk/Code/opencode/packages/opencode/dist/server/opencode-server"; const password = "recovery-smoke-secret"; const child = spawn(binary, ["--hostname", "127.0.0.1", "--port", "0"], { cwd: root, env: { ...process.env, HOME: root + "/home", XDG_CONFIG_HOME: root + "/config", ...
Post-fix artifact validation passed: authenticated health, clean shutdown, exactly-once recovery and promotion, unchanged second-restart state, and no surviving disposable server. The rebuilt SEA hash is 9bbe18549e623e6ebe4ed6f75c5403381c35ae860ad2dc77698a469ab8a6c7e6; I’m creating a new immutable patch now, superseding every earlier review hash.
packages/core/schema.json packages/core/src/database/migration.gen.ts packages/core/src/database/migration/20260914170650_session-recovery.ts packages/core/src/database/schema.gen.ts packages/core/src/session.ts packages/core/src/session/compaction.ts packages/core/src/session/context-epoch.ts packages/core/src/session/execution/claim.ts packages/core/src/session/execution/local.ts packages/core/src/session/input.ts packages/core/src/session/message-updater.ts packages/core/src/session/projector.ts packages/core/src/session/recovery-id.ts packages/core/src/session/recovery.ts packages/core/src/session/run-coordinator.ts packages/core/src/session/runner/index.ts packages/core/src/session/runner/llm.ts packages/core/src/session/runner/publish-llm-event.ts packages/core/src/session/sql.ts packages/core/test/database-migration.test.ts packages/core/test/session-execution-local.test.ts packages/core/test/session-projector.test.ts packages/core/test/session-prompt.test.ts packages/core/test/...
The revised candidate is frozen at patch b5878a90af5202832954778d14d2902e55ee9e51ff856592519177bafd392d90 and SEA 9bbe18549e623e6ebe4ed6f75c5403381c35ae860ad2dc77698a469ab8a6c7e6. The quarantine regression fails on the rejected candidate and passes here; I’m launching fresh correctness and security reviews, replacing all prior verdicts.
[
{
"content": "Prevent recovery continuation inputs from ordinary queue selection",
"status": "completed",
"priority": "high"
},
{
"content": "Add quarantine plus later-wake regression and rerun validation",
"status": "completed",
"priority": "high"
},
{
"content": "Rebuild and repeat isolated recovery smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Refreeze candidate and obtain independent correctness/security approval",
"status": "in_progress",
"priority": "high"
}
]
<task id="ses_f589f5d97ffegQPZFBMmuKcVO5" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f589ef97dffeuF7d215ntU7326" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The blocker is fixed and proved failing-first. The revised candidate is frozen, fully validated, and under fresh independent correctness and security review. No live service was touched.
SessionExecutionLocal inserts a ready claim before selecting or promoting any input (packages/core/src/session/execution/local.ts:31-63). Recovery converts every expired ready claim into Step.Recovered without checking whether ordinary or recovery input remains unpromoted (packages/core/src/session/recovery.ts:85-114). The projector then creates another queued continue and recovery row (packages/core/src/session/projector.ts:432-454).
Exploit sequence:
U is admitted with durable wake intent.ready claim.U is promoted.C.C, while the ordinary scan also wakes U (packages/core/src/session/recovery.ts:143-156,256,267-289).C (packages/core/src/session/runner/llm.ts:503-527,218-224).U (packages/core/src/session/run-coordinator.ts:57-68,86-97).This produces one spurious provider call for C, followed by the intended call for U.
The second-restart variant is worse: if an existing recovery continuation is pending and its new ready claim crashes before exact promotion, recovery creates a second pending recovery row. With provider output delayed, both rows pass validation and both wakes are coalesced. They then run sequentially as duplicate continuations.
The supplied tests cover crashes after Step.Ended and Compaction.Ended, but not either claim-insert-to-promotion window. The reported exactly-once SEA test therefore does not prove this boundary. ...
The security review found two concrete blockers, so the second freeze is rejected. I’m redesigning the pre-promotion path rather than adding another selector patch: an expired ready claim with no assistant will be cleared and existing durable input intent redriven, never converted into a synthetic continuation; recovery-owned input IDs will also be rejected at admission and excluded from ordinary wake scans.
[
{
"content": "Reconcile pre-promotion ready claims without synthetic continuation",
"status": "in_progress",
"priority": "high"
},
{
"content": "Reject recovery-owned retries and exclude them from ordinary wake scans",
"status": "pending",
"priority": "high"
},
{
"content": "Add failing-first crash-window and retry regressions",
"status": "pending",
"priority": "high"
},
{
"content": "Run validation, rebuild artifact, and repeat isolated smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Refreeze candidate and obtain independent correctness/security approval",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, gt, isNull, lte, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { continuation, message } from "./recovery-id"
13: import { Prompt } from "./prompt"
14: import { SessionInput } from "./input"
15: import {
16: SESSION_EXECUTION_LEASE_MS,
17: SessionExecutionTable,
18: SessionInputTable,
19: SessionRecoveryTable,
20: SessionMessageTable,
21: } from "./sql"
22:
23: export const ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, Duration, Effect, Exit, Layer, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { SessionExecutionClaim } from "./claim"
13: import { SESSION_EXECUTION_LEASE_MS, SessionExecutionTable, SessionRecoveryTable } from "../sql"
14:
15: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
180: }).pipe(Effect.map(SystemContext.combine))
181:
182: const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
183: sessionID: SessionSchema.ID,
184: executionID: string,
185: promotion: Promotion | undefined,
186: step: number,
187: ownerID: string,
188: recoverOverflow?: typeof compaction.compactAfterOverflow,
189: ) {
190: const session = yield* getSession(sessionID)
191: if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
192: return yield* Effect.interrupt
193: const claim = { sessionID, executionID, ownerID }
194: const updateExecution = (values: SessionExecutionClaim.Values = {}) =>
195: SessionExecutionClaim.update(db, claim, values)
196: const execution = yield* db
197: .select({ phase: ...
The first blocker needs a precise distinction: ready with no assistant is not always pre-promotion. I’m using durable wake state as the discriminator. If that Session still has an unpromoted execution-requested input or an active recovery outbox row, recovery clears the expired claim and redrives that existing intent; only a ready claim with no remaining durable input intent may synthesize continuation.
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
420: and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")),
421: )
422: .orderBy(desc(SessionMessageTable.seq))
423: .limit(1)
424: .get()
425: .pipe(Effect.orDie)
426: if (!latest || latest.id !== event.data.assistantMessageID)
427: return yield* Effect.die(`Recovery target is not the latest assistant: ${event.data.assistantMessageID}`)
428: const assistant = decodeMessage({ ...latest.data, id: latest.id, type: latest.type })
429: if (assistant.type !== "assistant")
430: return yield* Effect.die(`Recovery target is not an assistant: ${event.data.assistantMessageID}`)
431: }
432: yield* run(db, event)
433: yield* SessionInput.projectAdmitted(db, {
434: admittedSeq: ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
98: ),
99: ),
100: )
101: })
102:
103: const requestExecution = Effect.fn("SessionInput.requestExecution")(function* (
104: db: DatabaseService,
105: events: EventV2.Interface,
106: input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
107: ) {
108: const row = yield* db
109: .select({
110: sessionID: SessionInputTable.session_id,
111: promotedSeq: SessionInputTable.promoted_seq,
112: wakePending: SessionInputTable.wake_pending,
113: })
114: .from(SessionInputTable)
115: .where(eq(SessionInputTable.id, input.id))
116: .get()
117: .pipe(Effect.orDie)
118: if (!row || row.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
119: if (row.promotedSeq !== null) return false
120: if (row.wakePending) return true
121: yield* events
122: ...
Found 100 matches (more matches available) /home/byk/Code/opencode/packages/tui/src/editor.ts: Line 51: input.renderer.resume() /home/byk/Code/opencode/packages/opencode/src/lsp/client.ts: Line 136: input.server.process.stderr?.resume() /home/byk/Code/opencode/packages/opencode/src/cli/cmd/acp.ts: Line 64: process.stdin.resume() /home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts: Line 47: yield* execution.resume(sessionID) Line 97: yield* second.resume(sessionID) Line 124: yield* second.resume(sessionID) Line 151: yield* execution.resume(sessionID).pipe(Effect.catchDefect(() => Effect.void)) Line 152: yield* execution.resume(sessionID) /home/byk/Code/opencode/packages/core/test/session-runner.test.ts: Line 383: yield* session.resume(sessionID) Line 511: yield* session.resume(sessionID) Line 539: expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) Line ...
hasPending correctly anti-joins every session_recovery row, regardless of wake_pending or recovery_error (packages/core/src/session/input.ts:304-327). Queue promotion applies the same anti-join (packages/core/src/session/input.ts:413-438).
promoteSteers does not (packages/core/src/session/input.ts:389-410).
Concrete sequence:
continue input linked from session_recovery.steer.packages/core/src/session/recovery.ts:205-233).hasPending(..., "steer") true because the anti-join sees the ordinary input.promoteSteers selects both the ordinary steer and the quarantined recovery input.continue, so publish promotes it through the ordinary path.projectPrompted also settles its recovery wake (packages/core/src/session/input.ts:243-268).This violates the central invariant that only promoteExact may consume a recovery continuation.
The regression at packages/core/test/session-projector.test.ts:698-775 proves hasPending and promoteNextQueued, but never exercises ordinary steer promotion. Add a fail-first test that quarantines a recovery input with valid prompt plus malformed steer delivery, admits a later ordinary steer, and proves only the ordinary input is promoted.
Synthetic continuation IDs are deterministic from the public recovery execution ID (packages/core/src/session/recovery-id.ts:6; packages/schema/src/session-event.ts:220-230). ...
Both reviews confirm the retry and ordinary-redrive defects; correctness also found the same reservation gap in bulk steer promotion. I’m enforcing one ownership rule across every path: any session_input referenced by session_recovery is internal, cannot be admitted/retried through the public prompt path, cannot carry ordinary wake intent, and cannot be selected by ordinary queue or steer logic.
No files found
Found 6 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 700: const { admitted: message } = yield* SessionInput.admit(db, events, {
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 628: yield* SessionInput.admit(db, events, {
Line 759: yield* SessionInput.admit(db, events, {
Line 896: yield* SessionInput.admit(db, events, {
Line 1372: const { admitted } = yield* SessionInput.admit(db, events, {
/home/byk/Code/opencode/packages/core/src/session.ts:
Line 368: const admission = yield* SessionInput.admit(db, events, {
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
580: .run()
581: yield* events.publish(SessionEvent.Step.Recovered, {
582: sessionID,
583: timestamp: created,
584: executionID: "cross-session-continuation",
585: phase: "ready",
586: })
587: yield* db.run(
588: sql`UPDATE session_input SET session_id = ${other} WHERE id = ${continuation("cross-session-continuation")}`,
589: )
590: const wakes: string[] = []
591:
592: yield* SessionRecovery.recover().pipe(
593: Effect.provideService(
594: SessionExecution.Service,
595: SessionExecution.Service.of({
596: ...recoveryExecution,
597: wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid),
598: }),
599: ),
600: )
601:
602: expect(wakes).toEqual([])
603: expect(yield* ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
150: .select()
151: .from(EventTable)
152: .where(eq(EventTable.aggregate_id, sessionID))
153: .orderBy(asc(EventTable.seq))
154: .all()
155: yield* events.remove(sessionID)
156: yield* db.delete(SessionRecoveryTable).run()
157: yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run()
158: yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, sessionID)).run()
159: yield* events.replayAll(
160: recorded.map((event) => ({
161: id: event.id,
162: aggregateID: event.aggregate_id,
163: seq: event.seq,
164: type: event.type,
165: data: event.data,
166: })),
167: )
168: expect(yield* db.select().from(SessionRecoveryTable).all()).toHaveLength(1)
169: expect(yield* ...
The lifecycle distinction is now clear. I’m editing four owned boundaries together: expired-claim reconciliation, public/replayed execution requests, bulk steer promotion, and startup ordinary wake discovery. This keeps recovery continuations internal even when durable rows are malformed or replayed.
Success. Updated the following files: M packages/core/src/session/input.ts M packages/core/src/session/recovery.ts
The four boundaries now share the reservation rule. I’m adding deterministic regressions for both claim-insert crash windows, rearming a quarantined continuation, and malformed steer escape; each test will assert durable rows and wake counts, not just immediate return values.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
The new tests cover both claim-insert windows, exact retry rearming, ordinary redrive, and malformed-steer promotion. I’m running the focused suite and typecheck now; these query changes touch Drizzle result shapes and may need one compile correction before fail-first proof.
bun test v1.3.14 (0d9b296a) 30 pass 0 fail 105 expect() calls Ran 30 tests across 1 file. [3.79s] $ tsgo --noEmit
Focused validation passes. I’m now proving the new regressions against the exact rejected v2 freeze; this will distinguish all three fixes from tests that merely pass because of setup or unrelated state.
drwxrwxr-x 1 byk byk 4444 Sep 15 23:12 /home/byk/Code Preparing worktree (detached HEAD ab9408c81c) Updating files: 82% (5482/6637) Updating files: 83% (5509/6637) Updating files: 84% (5576/6637) Updating files: 85% (5642/6637) Updating files: 86% (5708/6637) Updating files: 87% (5775/6637) Updating files: 88% (5841/6637) Updating files: 89% (5907/6637) Updating files: 90% (5974/6637) Updating files: 91% (6040/6637) Updating files: 92% (6107/6637) Updating files: 93% (6173/6637) Updating files: 94% (6239/6637) Updating files: 95% (6306/6637) Updating files: 96% (6372/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
The rejected freeze is reproduced. I’m overlaying only the four new regression blocks, leaving its production code unchanged, then running those test names together to capture the real pre-fix failures.
Success. Updated the following files: M ../opencode-recovery-v2-base/packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
695 | wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid),
696 | }),
697 | ),
698 | )
699 |
700 | expect(wakes).toEqual([sessionID])
^
error: expect(received).toEqual(expected)
@@ -2,3 +2,3 @@
"ses_projector_test",
+ "ses_projector_test",
]
- Expected - 0
+ Received + 1
at toEqual (unknown:1:1)
at /home/byk/Code/opencode-recovery-v2-base/packages/core/test/session-projector.test.ts:700:21
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
Fail-first proof caught all three rejected behaviors: two wakes plus an extra recovery for the ordinary pre-promotion crash, successful rearming of a quarantined continuation, and promotion of the quarantined steer. I’m adding a direct durable-event assertion so replay cannot bypass public admission, plus a second recovery scan proving the rejected retry creates no recurring wake.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 30 pass 0 fail 107 expect() calls Ran 30 tests across 1 file. [2.56s] $ tsgo --noEmit
The replay/direct-event backstop also passes: a forged durable execution-request event for a recovery-owned input fails projection, leaves wake_pending false, and causes no later recovery-loop wake. I’m removing the disposable base and auditing every remaining ordinary input selector before broad validation.
(no output)
Found 13 matches /home/byk/Code/opencode/packages/core/src/session/recovery.ts: Line 88: .from(SessionInputTable) Line 187: .from(SessionInputTable) Line 294: .from(SessionInputTable) /home/byk/Code/opencode/packages/core/src/session/input.ts: Line 35: const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie) Line 116: .from(SessionInputTable) Line 140: .from(SessionInputTable) Line 167: .from(SessionInputTable) Line 218: .update(SessionInputTable) Line 248: .update(SessionInputTable) Line 315: .from(SessionInputTable) Line 416: .from(SessionInputTable) Line 450: .from(SessionInputTable) Line 479: .from(SessionInputTable)
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
230: if (updated) return
231: const stored = yield* find(db, input.id)
232: if (stored?.sessionID === input.sessionID && stored.promotedSeq !== undefined) return
233: return yield* Effect.die(new LifecycleConflict({ id: input.id }))
234: })
235:
236: export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* (
237: db: DatabaseService,
238: input: {
239: readonly id: SessionMessage.ID
240: readonly sessionID: SessionSchema.ID
241: readonly prompt: Prompt
242: readonly delivery: Delivery
243: readonly timeCreated: DateTime.Utc
244: readonly promotedSeq: number
245: },
246: ) {
247: const updated = yield* db
248: .update(SessionInputTable)
249: .set({ promoted_seq: input.promotedSeq, wake_pending: false })
250: .where(
251: and(
252: eq(SessionInputTable.id, input.id),
253: ...
The selector audit found no remaining ordinary path, but I’m adding one corruption/replay backstop: even if a quarantined recovery input somehow has wake_pending=true, startup ordinary redrive must ignore it. This proves the scan itself fails closed instead of relying only on admission rejection.
Success. Updated the following files: M packages/core/src/session/recovery.ts M packages/core/test/session-projector.test.ts
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
840: sessionID,
841: timestamp: DateTime.makeUnsafe(1),
842: executionID: "stale-recovery-wake",
843: assistantMessageID: recoveredID,
844: phase: "unknown",
845: })
846: yield* events.publish(SessionEvent.Step.Started, {
847: sessionID,
848: timestamp: DateTime.makeUnsafe(2),
849: assistantMessageID: SessionMessage.ID.make("msg_recovery_newest"),
850: agent: "build",
851: model,
852: })
853: const wakes: string[] = []
854:
855: yield* SessionRecovery.recover().pipe(
856: Effect.provideService(
857: SessionExecution.Service,
858: SessionExecution.Service.of({
859: ...recoveryExecution,
860: wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid),
861: }),
862: ),
863: )
864:
865: ...
The fail-closed scan test now corrupts wake_pending=true directly and still expects zero wakes, proving replay or malformed state cannot re-enter ordinary scheduling. I also replaced the raw SQL boolean expression in claim reconciliation with Drizzle’s typed or(...); focused validation is next.
bun test v1.3.14 (0d9b296a) 30 pass 0 fail 107 expect() calls Ran 30 tests across 1 file. [3.73s] $ tsgo --noEmit
Focused tests and typecheck pass after the full fail-closed change. Before broad validation, I’m completing fail-first evidence for the second crash window: an existing recovery continuation whose replacement ready claim dies before exact promotion must not create a second recovery row.
Preparing worktree (detached HEAD ab9408c81c) Updating files: 53% (3532/6637) Updating files: 54% (3584/6637) Updating files: 55% (3651/6637) Updating files: 56% (3717/6637) Updating files: 57% (3784/6637) Updating files: 58% (3850/6637) Updating files: 59% (3916/6637) Updating files: 60% (3983/6637) Updating files: 61% (4049/6637) Updating files: 62% (4115/6637) Updating files: 63% (4182/6637) Updating files: 64% (4248/6637) Updating files: 65% (4315/6637) Updating files: 66% (4381/6637) Updating files: 67% (4447/6637) Updating files: 68% (4514/6637) Updating files: 69% (4580/6637) Updating files: 70% (4646/6637) Updating files: 71% (4713/6637) Updating files: 72% (4779/6637) Updating files: 73% (4846/6637) Updating files: 74% (4912/6637) Updating files: 75% (4978/6637) Updating files: 76% (5045/6637) Updating files: 77% (5111/6637) Updating files: 78% (5177/6637) Updating files: 79% (5244/6637) Updating files: 80% (5310/6637) Updating files: 81% ...
(no output)
Success. Updated the following files: M ../opencode-recovery-v2-duplicate/packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
694 | wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid),
695 | }),
696 | ),
697 | )
698 |
699 | expect(wakes).toEqual([sessionID])
^
error: expect(received).toEqual(expected)
@@ -2,3 +2,3 @@
"ses_projector_test",
+ "ses_projector_test",
]
- Expected - 0
+ Received + 1
at toEqual (unknown:1:1)
at /home/byk/Code/opencode-recovery-v2-duplicate/packages/core/test/session-projector.test.ts:699:21
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The second crash-window regression also fails on the rejected freeze with two wakes, proving duplicate recovery creation. All newly added guards now have failing-first evidence. I’m removing the disposable worktree and moving to full validation.
(no output)
bun test v1.3.14 (0d9b296a)
test/npm-config.test.ts:
9 | await using tmp = await tmpdir()
10 | await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test/\n")
11 |
12 | const config = await Effect.runPromise(NpmConfig.load(tmp.path))
13 |
14 | expect(config.registry).toBe("https://registry.example.test/")
^
error: expect(received).toBe(expected)
Expected: "https://registry.example.test/"
Received: "https://registry.npmjs.org/"
at <anonymous> (/home/byk/Code/opencode/packages/core/test/npm-config.test.ts:14:29)
(fail) NpmConfig.load > reads registry from project .npmrc [22.09ms]
18 | await using tmp = await tmpdir()
19 | await Bun.write(path.join(tmp.path, ".npmrc"), "@acme:registry=https://npm.acme.test/\n")
20 |
21 | const config = await Effect.runPromise(NpmConfig.load(tmp.path))
22 |
23 | expect(config["@acme:registry"]).toBe("https://npm.acme.test/")
...
The full suite reached all recovery tests but failed five unrelated npm-config.test.ts cases because npm ignored each temporary project .npmrc and returned the default registry. I’m isolating that file with console traces before deciding whether this is transient environment contamination or a real candidate interaction; Core typecheck did not run because the chained test command failed.
bun test v1.3.14 (0d9b296a)
test/npm-config.test.ts:
9 | await using tmp = await tmpdir()
10 | await Bun.write(path.join(tmp.path, ".npmrc"), "registry=https://registry.example.test/\n")
11 |
12 | const config = await Effect.runPromise(NpmConfig.load(tmp.path))
13 |
14 | expect(config.registry).toBe("https://registry.example.test/")
^
error: expect(received).toBe(expected)
Expected: "https://registry.example.test/"
Received: "https://registry.npmjs.org/"
at <anonymous> (/home/byk/Code/opencode/packages/core/test/npm-config.test.ts:14:29)
(fail) NpmConfig.load > reads registry from project .npmrc [87.73ms]
18 | await using tmp = await tmpdir()
19 | await Bun.write(path.join(tmp.path, ".npmrc"), "@acme:registry=https://npm.acme.test/\n")
20 |
21 | const config = await Effect.runPromise(NpmConfig.load(tmp.path))
22 |
23 | expect(config["@acme:registry"]).toBe("https://npm.acme.test/")
...
The npm-config failures reproduce in isolation and are unrelated to recovery files. I’m checking the current npm executable/config environment and the unchanged loader implementation to identify whether a concurrent dependency/tooling change invalidated the test; I will not modify that unrelated area.
<path>/home/byk/Code/opencode/packages/core/src/npm-config.ts</path>
<type>file</type>
<content>
1: export * as NpmConfig from "./npm-config"
2:
3: import { fileURLToPath } from "url"
4: // @ts-expect-error npm does not publish types for this internal config API.
5: import Config from "@npmcli/config"
6: // @ts-expect-error npm does not publish types for this internal config API.
7: import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
8: import { Effect } from "effect"
9:
10: const npmPath = fileURLToPath(new URL("..", import.meta.url))
11:
12: export const load = (dir: string) =>
13: Effect.tryPromise({
14: try: async () => {
15: const config = new Config({
16: npmPath,
17: cwd: dir,
18: env: { ...process.env },
19: argv: [process.execPath, process.execPath],
20: execPath: process.execPath,
21: platform: process.platform,
22: definitions,
23: flatten,
24: ...
11.13.0 npm error code ENOWORKSPACES npm error This command does not support workspaces. npm error A complete log of this run can be found in: /home/byk/.npm/_logs/2026-09-15T23_47_37_148Z-debug-0.log
The environment now resolves npm 11.13.0; the loader swallows @npmcli/config load errors, which explains the default values, but this code and its tests are outside the recovery diff. I’m recording the five failures as pre-existing/concurrent validation debt and continuing the recovery-owned gates, affected package typechecks, migration check, and diff check separately.
bun test v1.3.14 (0d9b296a) 191 pass 0 fail 573 expect() calls Ran 191 tests across 7 files. [22.10s] $ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-check-nYkoGg/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-nYkoGg/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-nYkoGg/full/20260915235111_schema/migration.sql 🚀
Recovery-owned validation is clean: 191 affected tests, five package typechecks, migration consistency, and diff checks pass. The only broad-suite failures are five isolated, unchanged npm-config tests under npm 11.13.0; I’m running the Schema manifest gate, then rebuilding and smoking the exact revised artifact.
bun test v1.3.14 (0d9b296a) 2 pass 0 fail 23 expect() calls Ran 2 tests across 1 file. [535.00ms]
Before the build, I found a subtle test weakness in the recovery replacement window: the query should only suppress synthesis for an active, non-quarantined recovery outbox. A quarantined row with a stray wake flag must not cause an expired pre-promotion claim to be silently cleared. I’m tightening that predicate to recovery_error IS NULL so malformed recovery state cannot mask unrelated interrupted work.
Success. Updated the following files: M packages/core/src/session/recovery.ts
The tightened predicate still allowed a recovery-owned input’s corrupted session_input.wake_pending to count as ordinary intent. I’m correcting the predicate to distinguish ownership explicitly: ordinary intent counts only when no recovery row owns the input; recovery intent counts only when its non-quarantined outbox remains pending.
Success. Updated the following files: M packages/core/src/session/recovery.ts
bun test v1.3.14 (0d9b296a) 30 pass 0 fail 107 expect() calls Ran 30 tests across 1 file. [6.60s] $ tsgo --noEmit
The ownership-aware predicate now passes focused tests and typecheck. I’m rerunning the complete affected suite after that final adjustment, then the SEA build; no further source edits will occur unless those gates fail.
bun test v1.3.14 (0d9b296a) 191 pass 0 fail 573 expect() calls Ran 191 tests across 7 files. [30.07s] $ tsgo --noEmit
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a77f5665001ccSfL51qBrPlZg dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
The exact revised SEA built successfully. I’m smoke-testing two isolated restart paths: the original abandoned ready claim still produces one deterministic continuation, while an expired pre-promotion claim with existing durable wake intent produces no synthetic recovery and runs only that admitted input.
drwxr-xr-x 5 byk byk 4096 Sep 15 23:21 /tmp/opencode
{"status":200,"health":{"healthy":true,"version":"1.18.30"},"exit":{"code":0,"signal":null},"stderr":""}
[{"session_id":"ses_abandoned","executions":1},
{"session_id":"ses_prepromotion","executions":1}]
[{"session_id":"ses_prepromotion","inputs":1}]
The disposable database now contains both edge cases before startup: one truly abandoned ready claim with no input, and one expired pre-promotion claim with an existing wake_pending steer. After startup, the first must gain one recovery continuation; the second must gain none, clear its claim, and promote only its existing input.
{"exit":{"code":0,"signal":null},"stderr":""}
[{"id":"ses_abandoned","executions":0,"recoveries":1,"inputs":1,"messages":2,"events":2},
{"id":"ses_prepromotion","executions":0,"recoveries":0,"inputs":1,"messages":0,"events":0}]
[{"id":"msg_recovery_continue_abandoned-execution","session_id":"ses_abandoned","prompt":"{\"text\":\"continue\"}","delivery":"queue","promoted_seq":1,"wake_pending":0},
{"id":"msg_pending_smoke","session_id":"ses_prepromotion","prompt":"{\"text\":\"pending smoke\"}","delivery":"steer","promoted_seq":null,"wake_pending":1}]
[{"execution_id":"abandoned-execution","session_id":"ses_abandoned","phase":"ready","wake_pending":0,"recovery_error":null}]
The artifact proves the key distinction: the abandoned claim produced exactly one synthetic continuation, while the pre-promotion claim produced zero recovery rows and preserved its original durable wake intent. The manually seeded prompt lacks its originating durable admission event, so this smoke cannot validly prove provider promotion; the real-event regression covers that path. I’m performing a second restart to prove neither case duplicates or loses durable state.
{"exit":{"code":0,"signal":null},"stderr":""}
[{"id":"ses_abandoned","executions":0,"recoveries":1,"inputs":1,"messages":2,"events":2},
{"id":"ses_prepromotion","executions":0,"recoveries":0,"inputs":1,"messages":1,"events":2}]
[{"id":"msg_recovery_continue_abandoned-execution","session_id":"ses_abandoned","promoted_seq":1,"wake_pending":0},
{"id":"msg_pending_smoke","session_id":"ses_prepromotion","promoted_seq":null,"wake_pending":1}]
645dc5a687b4a30a95af11bd43d3c3a02b35dbb9419016045c2e446f75cf63f8 packages/opencode/dist/server/opencode-server
3442717 /usr/bin/bash -c node -e 'const { spawn } = require("node:child_process"); const root = "/tmp/opencode/session-recovery-smoke-v3"; const binary = "/home/byk/Code/opencode/packages/opencode/dist/server/opencode-server"; const password = "recovery-smoke-secret"; const child = spawn(binary, ["--hostname", "127.0.0.1", "--port", "0"], { cwd: root, env: { ...process.env, HOME: root + "/home", XDG_CONFIG_HOME: root + "/config", ...
The second-restart smoke stayed duplicate-free, but it exposed an audit point before freeze: input admission can race the recovery scanner between its pending-intent check and Step.Recovered publication. I’m checking transaction support now; the final decision to synthesize or redrive must observe pending intent atomically, or a concurrent prompt could still create an unnecessary continuation.
Found 2 matches
/home/byk/Code/opencode/packages/core/src/event.ts:
Line 351: { behavior: "immediate" },
/home/byk/Code/opencode/packages/core/src/session/execution/claim.ts:
Line 66: db.transaction(() => update(db, claim).pipe(Effect.andThen(effect)), { behavior: "immediate" }).pipe(Effect.orDie)
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
300: }),
301: )
302: }
303: const stored = yield* db
304: .select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
305: .from(EventTable)
306: .where(eq(EventTable.id, event.id))
307: .get()
308: .pipe(Effect.orDie)
309: if (stored)
310: yield* Effect.die(
311: new InvalidDurableEventError({
312: type: event.type,
313: message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
314: }),
315: ...
The race is real: the pending-intent read and recovery event commit currently use separate transactions. I’m checking whether Effect Drizzle supports nesting EventV2.publish inside one immediate outer transaction; if it does, the smallest correct fix is to make “no pending intent + recovery projection + claim clear” one serialized transaction.
Ripgrep JSON record exceeded 65536 bytes
No files found
Found 6 matches
/home/byk/Code/opencode/packages/opencode/src/project/project.ts:
Line 190: { behavior: "immediate" },
/home/byk/Code/opencode/packages/effect-drizzle-sqlite/test/sqlite.test.ts:
Line 57: yield* db.transaction((tx) => tx.insert(users).values({ name: "Grace" }), { behavior: "immediate" })
Line 118: .transaction((tx) => tx.insert(users).values({ name: "Blocked" }), { behavior: "immediate" })
/home/byk/Code/opencode/packages/effect-drizzle-sqlite/examples/basic.ts:
Line 65: { behavior: "immediate" },
/home/byk/Code/opencode/packages/core/src/event.ts:
Line 351: { behavior: "immediate" },
/home/byk/Code/opencode/packages/core/src/session/execution/claim.ts:
Line 66: db.transaction(() => update(db, claim).pipe(Effect.andThen(effect)), { behavior: "immediate" }).pipe(Effect.orDie)
Found 4 matches
/home/byk/Code/opencode/packages/effect-drizzle-sqlite/src/effect-sqlite/session.ts:
Line 145: id === 0 ? `begin ${config?.behavior ?? "deferred"}` : `savepoint effect_sql_${id}`,
Line 165: : this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`)
Line 168: : this.executeTransactionStatement(connection, `rollback to savepoint effect_sql_${id}`).pipe(
Line 170: this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`),
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, gt, isNull, lte, or, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { continuation, message } from "./recovery-id"
13: import { Prompt } from "./prompt"
14: import { SessionInput } from "./input"
15: import {
16: SESSION_EXECUTION_LEASE_MS,
17: SessionExecutionTable,
18: SessionInputTable,
19: SessionRecoveryTable,
20: SessionMessageTable,
21: } from "./sql"
22:
23: export ...
Nested publication would broadcast before the outer transaction commits, so I’m not wrapping EventV2.publish. Instead, the event’s existing immediate transaction will perform the final pending-intent check in its commit hook; if intent exists, the event projection rolls back, then recovery clears the exact claim and lets the durable wake scanners proceed. This serializes the decision without changing event delivery semantics.
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
126: const candidate = phase.value === "continue" || claim.assistant_message_id === null ? undefined : latest
127: const assistantMessageID = candidate?.id
128: const timestamp = yield* DateTime.now
129: yield* events
130: .publish(
131: SessionEvent.Step.Recovered,
132: {
133: sessionID: claim.session_id,
134: timestamp,
135: executionID: claim.id,
136: assistantMessageID,
137: phase: phase.value,
138: },
139: { commit: () => SessionExecutionClaim.clear(db, recoveryClaim) },
140: )
141: .pipe(
142: Effect.catchDefect((defect) =>
143: db
144: .select({ sessionID: SessionRecoveryTable.session_id })
145: .from(SessionRecoveryTable)
146: ...
The atomic decision needs one narrow marker: if a durable input appears after the initial scan but before recovery publication, the recovery event transaction aborts, then the exact claim clears outside the rolled-back transaction. I’m implementing that with a reusable pending-intent query and a deterministic interleaving test that inserts the prompt at the publication boundary.
Found 3 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 1294: publish: (definition, data, options) =>
/home/byk/Code/opencode/packages/core/test/session-runner-tool-events.test.ts:
Line 17: const events = EventV2.Service.of({
Line 18: publish: (definition, data) =>
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1260: .run()
1261: yield* db
1262: .insert(SessionExecutionTable)
1263: .values({
1264: id: "advance-transaction",
1265: session_id: sessionID,
1266: owner_id: "owner",
1267: phase: "ready",
1268: time_created: 0,
1269: expires_at: 60_000,
1270: })
1271: .run()
1272: const claim = { sessionID, executionID: "advance-transaction", ownerID: "owner" }
1273: yield* SessionContextEpoch.initialize(db, Effect.succeed(context("before")), sessionID, claim)
1274: const before = yield* db
1275: .select({ snapshot: SessionContextEpochTable.snapshot })
1276: .from(SessionContextEpochTable)
1277: .where(eq(SessionContextEpochTable.session_id, sessionID))
1278: .get()
1279: const leaseBefore = yield* db
1280: .select({ ...
The serialization point is the event commit, not the earlier scan. I’m adding a private rollback marker and moving the same pending-intent predicate into that commit hook; a deterministic test will admit the prompt immediately before Step.Recovered begins and prove the recovery projection rolls back.
Success. Updated the following files: M packages/core/src/session/recovery.ts
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { DateTime, Deferred, Effect, Exit, Fiber, Schema } from "effect"
3: import { TestClock } from "effect/testing"
4: import { asc, eq, sql } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
7: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
8: import { EventV2 } from "@opencode-ai/core/event"
9: import { EventTable } from "@opencode-ai/core/event/sql"
10: import { ModelV2 } from "@opencode-ai/core/model"
11: import { Project } from "@opencode-ai/core/project"
12: import { ProjectTable } from "@opencode-ai/core/project/sql"
13: import { ProviderV2 } from "@opencode-ai/core/provider"
14: import { AbsolutePath } from "@opencode-ai/core/schema"
15: import { SessionV2 } from ...
The commit-hook guard is implemented. I’m adding the exact concurrency regression now: the first scan sees no input, an ordinary prompt commits immediately before Step.Recovered, and the recovery event transaction must roll back rather than create a second continuation.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
286 | .run()
287 |
288 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
289 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
290 |
291 | expect(yield* db.select().from(SessionRecoveryTable).all()).toHaveLength(1)
^
error: expect(received).toHaveLength(expected)
Expected length: 1
Received length: 0
at toHaveLength (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:291:67
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate ...
The new guard correctly runs after projectors, so it sees the recovery continuation created by the same event and mistakes it for prior intent. I’m excluding only the current execution’s newly projected outbox in the commit-time check; older recovery outboxes and concurrent ordinary prompts still abort synthesis.
Success. Updated the following files: M packages/core/src/session/recovery.ts
bun test v1.3.14 (0d9b296a)
31 pass
0 fail
111 expect() calls
Ran 31 tests across 1 file. [3.16s]
$ tsgo --noEmit
src/session/recovery.ts(45,12): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type 'SQLiteColumn<{ name: string; tableName: "session_input"; dataType: "string"; data: string & Brand<"SessionID">; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; ... 4 more ...; generated: undefined; }, {}>' is not assignable to parameter of type 'never'.
test/session-projector.test.ts(799,11): error TS2367: This comparison appears to be unintentional because the types 'D' and 'Struct<{ readonly id: brand<String, "Event.ID"> & { create: () => string & Brand<"Event.ID">; }; readonly metadata: decodeTo<optional<toType<$Record<String, Unknown>>>, optionalKey<...>, never, never>; readonly type: Literal<...>; readonly durable: decodeTo<...>; readonly location: decodeTo<...>; ...
Runtime behavior is correct; typecheck found two narrow typing issues: Drizzle rejects the plain execution ID against its branded column in ne, and the generic test wrapper cannot compare schema definitions by identity. I’m replacing those with a direct SQL inequality and event-type comparison, preserving the same behavior.
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
29: class PendingExecutionIntent extends Error {}
30:
31: const hasPendingExecutionIntent = Effect.fn("SessionRecovery.hasPendingExecutionIntent")(function* (
32: db: Database.Interface["db"],
33: sessionID: string,
34: excludeRecoveryExecutionID?: string,
35: ) {
36: const pending = yield* db
37: .select({ id: SessionInputTable.id })
38: .from(SessionInputTable)
39: .leftJoin(
40: SessionRecoveryTable,
41: eq(SessionRecoveryTable.continuation_message_id, SessionInputTable.id),
42: )
43: .where(
44: and(
45: eq(SessionInputTable.session_id, sessionID),
46: isNull(SessionInputTable.promoted_seq),
47: or(
48: and(isNull(SessionRecoveryTable.execution_id), eq(SessionInputTable.wake_pending, true)),
49: and(
50: eq(SessionRecoveryTable.wake_pending, true),
51: ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
780: title: "test",
781: version: "test",
782: })
783: .run()
784: yield* db
785: .insert(SessionExecutionTable)
786: .values({
787: id: "ready-at-admission-boundary",
788: session_id: sessionID,
789: owner_id: "dead",
790: phase: "ready",
791: time_created: 0,
792: expires_at: 0,
793: })
794: .run()
795: const inputID = SessionMessage.ID.make("msg_ready_at_admission_boundary")
796: const intercepted = EventV2.Service.of({
797: ...events,
798: publish: (definition, data, options) =>
799: definition === SessionEvent.Step.Recovered
800: ? SessionInput.admit(db, events, {
801: id: inputID,
802: sessionID,
803: prompt: Prompt.make({ text: "pending" ...
Success. Updated the following files: M packages/core/src/session/recovery.ts M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 31 pass 0 fail 111 expect() calls Ran 31 tests across 1 file. [4.72s] $ tsgo --noEmit
The serialized boundary test now passes and typechecks. I’m replacing one inferred parameter type with the owning SessionSchema.ID type, then proving this new race regression fails on the rejected freeze before rerunning the full affected matrix and rebuilding the now-invalid artifact.
Success. Updated the following files: M packages/core/src/session/recovery.ts
drwxrwxr-x 1 byk byk 4444 Sep 15 23:45 /home/byk/Code Preparing worktree (detached HEAD ab9408c81c) Updating files: 21% (1424/6637) Updating files: 22% (1461/6637) Updating files: 23% (1527/6637) Updating files: 24% (1593/6637) Updating files: 25% (1660/6637) Updating files: 26% (1726/6637) Updating files: 27% (1792/6637) Updating files: 28% (1859/6637) Updating files: 29% (1925/6637) Updating files: 30% (1992/6637) Updating files: 31% (2058/6637) Updating files: 32% (2124/6637) Updating files: 33% (2191/6637) Updating files: 34% (2257/6637) Updating files: 35% (2323/6637) Updating files: 36% (2390/6637) Updating files: 37% (2456/6637) Updating files: 38% (2523/6637) Updating files: 39% (2589/6637) Updating files: 40% (2655/6637) Updating files: 41% (2722/6637) Updating files: 42% (2788/6637) Updating files: 43% (2854/6637) Updating files: 44% (2921/6637) Updating files: 45% (2987/6637) Updating files: 46% (3054/6637) Updating files: 47% (3120/6637) Updating ...
The disposable setup command applied the patch from the main worktree instead of the newly created worktree; Git rejected it atomically because those changes already exist, so no source was altered. I’m verifying that invariant, then applying the freeze in the correct sibling path.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
(no output)
Success. Updated the following files: M ../opencode-recovery-v2-race/packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
703 | wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid),
704 | }),
705 | ),
706 | )
707 |
708 | expect(wakes).toEqual([sessionID])
^
error: expect(received).toEqual(expected)
@@ -2,3 +2,3 @@
"ses_projector_test",
+ "ses_projector_test",
]
- Expected - 0
+ Received + 1
at toEqual (unknown:1:1)
at /home/byk/Code/opencode-recovery-v2-race/packages/core/test/session-projector.test.ts:708:21
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The atomic-boundary regression fails on the rejected freeze with two wakes, so the new commit guard has direct failing-first proof. I’m removing the disposable worktree, rerunning affected validation, and rebuilding once more; the earlier artifact is discarded because this guard changed source.
(no output)
bun test v1.3.14 (0d9b296a) 192 pass 0 fail 577 expect() calls Ran 192 tests across 7 files. [41.23s] $ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-check-mJ469C/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-mJ469C/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-mJ469C/full/20260916002150_schema/migration.sql 🚀
The final source passes 192 affected tests, Core typecheck, migration consistency, and diff checks. I’m rebuilding the SEA now, then I’ll repeat isolated health and two-restart recovery checks before freezing a v3 patch for replacement reviews.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a79912a0001O8mAYnyQ7UMMbE dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
The rebuilt SEA now represents the atomic admission/recovery boundary. I’m using a fresh isolated database for final smoke evidence, then I’ll hash and freeze the exact source and artifact; no live service will be touched.
drwxr-xr-x 6 byk byk 4096 Sep 15 23:56 /tmp/opencode
{"status":200,"health":{"healthy":true,"version":"1.18.30"},"exit":{"code":0,"signal":null},"stderr":""}
(no output)
{"exit":{"code":0,"signal":null},"stderr":""}
[{"id":"ses_abandoned","executions":0,"recoveries":1,"inputs":1},
{"id":"ses_prepromotion","executions":0,"recoveries":0,"inputs":1}]
[{"id":"msg_recovery_continue_abandoned-execution","session_id":"ses_abandoned","prompt":"{\"text\":\"continue\"}","delivery":"queue","promoted_seq":1,"wake_pending":0},
{"id":"msg_pending_smoke","session_id":"ses_prepromotion","prompt":"{\"text\":\"pending smoke\"}","delivery":"steer","promoted_seq":null,"wake_pending":1}]
{"exit":{"code":0,"signal":null},"stderr":""}
[{"id":"ses_abandoned","executions":0,"recoveries":1,"inputs":1},
{"id":"ses_prepromotion","executions":0,"recoveries":0,"inputs":1}]
2dd8f7fffa4b382799ebfa093932baa6dd7c5f3e896de5553edb14dbccd7b627 packages/opencode/dist/server/opencode-server
Final artifact smoke is stable across two restarts: the abandoned Session remains exactly one recovery/input, and the pre-promotion Session remains zero recoveries with its original input intact. I’m freezing the exact package-only diff now, excluding unrelated AGENTS.md, .lore.md, and profiling files, then launching fresh independent correctness and security reviews against the new patch and SEA hashes.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, and crash-window regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
...
e00ebc6167a6959b0eced57808aa31de9920a2bf15a89df01d0c281fce63e0f8 /tmp/opencode/session-recovery-final-v3.patch 2dd8f7fffa4b382799ebfa093932baa6dd7c5f3e896de5553edb14dbccd7b627 packages/opencode/dist/server/opencode-server dc661f30cb2055ad6248fdc59b14998d834497be ab9408c81cc9916177641ae8e9f08624fc27b5b9 30
The first v3 freeze is invalid: git diff omitted the two untracked production files, execution/claim.ts and recovery-id.ts. I caught this before review launch. I’m rebuilding the canonical patch with both files included, verifying it applies cleanly to the immutable base, then hashing and reviewing only that complete artifact.
(no output)
(no output)
(no output)
drwxrwxr-x 1 byk byk 4444 Sep 16 00:19 /home/byk/Code Preparing worktree (detached HEAD ab9408c81c) Updating files: 27% (1835/6637) Updating files: 28% (1859/6637) Updating files: 29% (1925/6637) Updating files: 30% (1992/6637) Updating files: 31% (2058/6637) Updating files: 32% (2124/6637) Updating files: 33% (2191/6637) Updating files: 34% (2257/6637) Updating files: 35% (2323/6637) Updating files: 36% (2390/6637) Updating files: 37% (2456/6637) Updating files: 38% (2523/6637) Updating files: 39% (2589/6637) Updating files: 40% (2655/6637) Updating files: 41% (2722/6637) Updating files: 42% (2788/6637) Updating files: 43% (2854/6637) Updating files: 44% (2921/6637) Updating files: 45% (2987/6637) Updating files: 46% (3054/6637) Updating files: 47% (3120/6637) Updating files: 48% (3186/6637) Updating files: 49% (3253/6637) Updating files: 50% (3319/6637) Updating files: 51% (3385/6637) Updating files: 52% (3452/6637) Updating files: 53% (3518/6637) Updating ...
The clean-apply check again ran in the main worktree because the shell’s working directory does not follow git worktree add; it was read-only (--check) and changed nothing. The sibling itself is clean at the base. I’m running the check from that sibling now, then removing it.
(no output)
b4b30666f3a5e9a5bcbfe7237b16e113f684568d67b9d360e65c5655e649009f /tmp/opencode/session-recovery-final-v3.patch 2dd8f7fffa4b382799ebfa093932baa6dd7c5f3e896de5553edb14dbccd7b627 packages/opencode/dist/server/opencode-server dc661f30cb2055ad6248fdc59b14998d834497be ab9408c81cc9916177641ae8e9f08624fc27b5b9
The complete v3 patch applies cleanly to base and now includes both new production files. Frozen evidence: patch b4b30666…, SEA 2dd8f7ff…, base ab9408c81c, 32 package files. I’m launching separate correctness and security reviews; the source and artifact remain frozen until both return substantive verdicts.
<task id="ses_f585f6ab8ffeYa2PXH8y5b5MXi" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f585ecec2ffeJAJtir047pPDYC" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The revised candidate is frozen for independent review.
b4b30666f3a5e9a5bcbfe7237b16e113f684568d67b9d360e65c5655e649009f2dd8f7fffa4b382799ebfa093932baa6dd7c5f3e896de5553edb14dbccd7b627npm-config.test.ts cases under npm 11.13.0</task_result> </task>
The correctness reviewer returned no evidence, so its result is invalid. I’m giving that same session its single evidence-or-blocker retry against the unchanged frozen hashes; another empty result will disqualify it and trigger a replacement reviewer.
</task_result> </task>
The security reviewer also returned empty evidence. Both reviewers now receive their one permitted evidence-or-blocker retry against the reverified, unchanged candidate; any second empty result will be replaced rather than weakening the gate.
<task id="ses_f585f6ab8ffeYa2PXH8y5b5MXi" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f585ecec2ffeJAJtir047pPDYC" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Both empty reviews were retried once against the unchanged, reverified candidate. Source and artifact remain frozen pending substantive verdicts.
b4b30666f3a5e9a5bcbfe7237b16e113f684568d67b9d360e65c5655e649009f2dd8f7fffa4b382799ebfa093932baa6dd7c5f3e896de5553edb14dbccd7b627git apply --check --reverse /tmp/opencode/session-recovery-final-v3.patch succeeds against the reviewed package files.SessionExecutionLocal selects any pending recovery row and passes its continuation directly to the runner:
packages/core/src/session/execution/local.ts:48-63That query checks only session_id, wake_pending, and recovery_error. It does not validate:
Those checks exist only in the periodic scanner:
packages/core/src/session/recovery.ts:198-310The runner then calls promoteExact:
packages/core/src/session/runner/llm.ts:218-225But promoteExact checks only ID, Session, pending status, and queue delivery:
packages/core/src/session/input.ts:470-491Concrete failure sequence:
promoteExact promotes it and clears its wake through the Prompted projector.The correctness review is substantive and rejects v3 on two real boundaries: exact recovery promotion can race ahead of scanner validation, and a mixed local/hosted-tool Step can become durable continue before unresolved tools are made model-visible. The freeze is rejected; I’m tracing those two commit paths and will add failing-first regressions before changing their shared transactional boundaries.
[
{
"content": "Validate recovery continuation atomically during exact promotion",
"status": "in_progress",
"priority": "high"
},
{
"content": "Settle unresolved tools before durable continue phase",
"status": "pending",
"priority": "high"
},
{
"content": "Add failing-first stale-promotion and mixed-tool crash regressions",
"status": "pending",
"priority": "high"
},
{
"content": "Assess bounded-scan and cleanup concerns",
"status": "pending",
"priority": "medium"
},
{
"content": "Run full validation and isolated restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain correctness/security approval",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
180: )
181: .get()
182: .pipe(
183: Effect.orDie,
184: Effect.flatMap((stored) => (stored ? Effect.void : Effect.die(defect))),
185: )
186: }),
187: )
188: }).pipe(
189: Effect.catchCause((cause) =>
190: Effect.logError("Failed to recover Session", cause).pipe(
191: Effect.annotateLogs({ sessionID: claim.session_id, executionID: claim.id }),
192: ),
193: ),
194: ),
195: { discard: true, concurrency: 1 },
196: )
197:
198: const pending = yield* db
199: .select({
200: sessionID: SessionRecoveryTable.session_id,
201: executionID: SessionRecoveryTable.execution_id,
202: assistantMessageID: SessionRecoveryTable.assistant_message_id,
203: continuationMessageID: ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
445: sessionID: SessionSchema.ID,
446: commit?: (row: typeof SessionInputTable.$inferSelect) => Effect.Effect<void>,
447: ) {
448: const row = yield* db
449: .select({ input: SessionInputTable })
450: .from(SessionInputTable)
451: .leftJoin(
452: SessionRecoveryTable,
453: eq(SessionRecoveryTable.continuation_message_id, SessionInputTable.id),
454: )
455: .where(
456: and(
457: eq(SessionInputTable.session_id, sessionID),
458: isNull(SessionInputTable.promoted_seq),
459: eq(SessionInputTable.delivery, "queue"),
460: isNull(SessionRecoveryTable.execution_id),
461: ),
462: )
463: .orderBy(asc(SessionInputTable.admitted_seq))
464: .limit(1)
465: .get()
466: .pipe(Effect.orDie)
467: return row === undefined ? false : yield* publish(db, events, sessionID, [row.input], ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
180: }).pipe(Effect.map(SystemContext.combine))
181:
182: const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
183: sessionID: SessionSchema.ID,
184: executionID: string,
185: promotion: Promotion | undefined,
186: step: number,
187: ownerID: string,
188: recoverOverflow?: typeof compaction.compactAfterOverflow,
189: ) {
190: const session = yield* getSession(sessionID)
191: if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
192: return yield* Effect.interrupt
193: const claim = { sessionID, executionID, ownerID }
194: const updateExecution = (values: SessionExecutionClaim.Values = {}) =>
195: SessionExecutionClaim.update(db, claim, values)
196: const execution = yield* db
197: .select({ phase: ...
<skill_content name="effect">
# Skill: effect
# Effect
This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
## Source Of Truth
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
3. Also inspect existing repo code for local house style before introducing new patterns.
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
## Guidelines
- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
- Use `Effect.gen(function* () { ... })` for multi-step workflows.
- Use `Effect.fn("Name")` or ...
I’ll keep recovery validation at its owning boundary: promoteExact is recovery-only, so it will atomically validate the outbox, deterministic identity, prompt, phase, assistant relationship, and newest-assistant status inside the same Prompted transaction. Separately, unresolved hosted tools will be failed before Step.Ended chooses continue, removing the crash window rather than teaching recovery to tolerate it.
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
1: export * as SessionInput from "./input"
2:
3: import { and, asc, eq, isNull, lte } from "drizzle-orm"
4: import { DateTime, Effect, Schema } from "effect"
5: import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
6: import type { Database } from "../database/database"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionMessage } from "./message"
10: import { Prompt } from "./prompt"
11: import { SessionSchema } from "./schema"
12: import { SessionInputTable, SessionMessageTable, SessionRecoveryTable } from "./sql"
13:
14: type DatabaseService = Database.Interface["db"]
15:
16: export { Admitted, Delivery }
17:
18: const decodePrompt = Schema.decodeUnknownSync(Prompt)
19: const encodePrompt = Schema.encodeSync(Prompt)
20:
21: export const decode = (value: unknown) => Schema.decodeUnknownOption(Prompt)(value)
22:
23: ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
230: if (updated) return
231: const stored = yield* find(db, input.id)
232: if (stored?.sessionID === input.sessionID && stored.promotedSeq !== undefined) return
233: return yield* Effect.die(new LifecycleConflict({ id: input.id }))
234: })
235:
236: export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* (
237: db: DatabaseService,
238: input: {
239: readonly id: SessionMessage.ID
240: readonly sessionID: SessionSchema.ID
241: readonly prompt: Prompt
242: readonly delivery: Delivery
243: readonly timeCreated: DateTime.Utc
244: readonly promotedSeq: number
245: },
246: ) {
247: const updated = yield* db
248: .update(SessionInputTable)
249: .set({ promoted_seq: input.promotedSeq, wake_pending: false })
250: .where(
251: and(
252: eq(SessionInputTable.id, input.id),
253: ...
Found 2 matches
/home/byk/Code/opencode/packages/core/src/session/projector.ts:
Line 357: yield* events.project(SessionEvent.Prompted, (event) =>
Line 360: yield* SessionInput.projectPrompted(db, {
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
345: )
346: yield* events.project(SessionEvent.ModelSwitched, (event) =>
347: Effect.gen(function* () {
348: yield* db
349: .update(SessionTable)
350: .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
351: .where(eq(SessionTable.id, event.data.sessionID))
352: .run()
353: .pipe(Effect.orDie)
354: yield* run(db, event)
355: }),
356: )
357: yield* events.project(SessionEvent.Prompted, (event) =>
358: Effect.gen(function* () {
359: if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
360: yield* SessionInput.projectPrompted(db, {
361: id: event.data.messageID,
362: sessionID: event.data.sessionID,
363: prompt: event.data.prompt,
364: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
1: import {
2: LLM,
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Message,
7: SystemPart,
8: isContextOverflowFailure,
9: type ProviderErrorEvent,
10: } from "@opencode-ai/llm"
11: import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
12: import { and, eq } from "drizzle-orm"
13: import { AgentV2 } from "../../agent"
14: import { Config } from "../../config"
15: import { Database } from "../../database/database"
16: import { EventV2 } from "../../event"
17: import { Location } from "../../location"
18: import { ModelV2 } from "../../model"
19: import { PermissionV2 } from "../../permission"
20: import { ProviderV2 } from "../../provider"
21: import { QuestionV2 } from "../../question"
22: import { SystemContext } from "../../system-context/index"
23: import { SystemContextRegistry } from "../../system-context/registry"
24: import ...
The exact promotion transaction can fail closed without broad refactoring: projectPrompted will recognize recovery-owned inputs and validate the full recovery projection before mutating anything; the existing claim commit then fences that same transaction. An invalid promotion aborts, and promoteExact quarantines its recovery row after rollback so repeated wakes cannot spin.
<path>/home/byk/Code/opencode/packages/core/src/session/recovery-id.ts</path>
<type>file</type>
<content>
1: import { SessionMessage } from "./message"
2:
3: export const message = (executionID: string, assistantMessageID?: SessionMessage.ID) =>
4: assistantMessageID ?? SessionMessage.ID.make(`msg_recovery_${executionID}`)
5:
6: export const continuation = (executionID: string) => SessionMessage.ID.make(`msg_recovery_continue_${executionID}`)
(End of file - total 6 lines)
</content>
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
170: table.time_created,
171: table.id,
172: ),
173: ],
174: )
175:
176: export const SessionRecoveryTable = sqliteTable(
177: "session_recovery",
178: {
179: session_id: text()
180: .$type<SessionSchema.ID>()
181: .notNull()
182: .references(() => SessionTable.id, { onDelete: "cascade" }),
183: execution_id: text().notNull(),
184: assistant_message_id: text()
185: .$type<SessionMessage.ID>()
186: .references(() => SessionMessageTable.id, { onDelete: "cascade" }),
187: continuation_message_id: text()
188: .$type<SessionMessage.ID>()
189: .notNull()
190: .references(() => SessionInputTable.id, { onDelete: "cascade" }),
191: phase: text().$type<"ready" | "unknown" | "continue">().notNull(),
192: wake_pending: integer({ mode: "boolean" }).notNull().default(true),
193: recovery_error: ...
I’m implementing a single reusable recovery validator in SessionInput: the scanner and promotion projector will use the same rules, but the projector remains authoritative because it runs inside the durable event transaction. This avoids two subtly different validation policies and gives promoteExact a typed reason to quarantine after rollback.
Found 8 matches
/home/byk/Code/opencode/packages/core/src/session/projector.ts:
Line 29: const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
Line 123: decodeMessage({ ...row.data, id: row.id, type: row.type })
Line 428: const assistant = decodeMessage({ ...latest.data, id: latest.id, type: latest.type })
/home/byk/Code/opencode/packages/core/src/session/history.ts:
Line 55: const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
Line 79: return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
Line 97: decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
/home/byk/Code/opencode/packages/core/src/session/store.ts:
Line 32: const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
Line 55: message: yield* decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie),
The atomic validator must reject both stale assistant-targeted recoveries and synthetic/continue recoveries superseded after admission. I’m tracing the exact recovery selection and cleanup behavior before choosing the failure type, so quarantine persists after the rolled-back Prompted transaction without leaving the replacement claim wedged.
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
20: recoveryInputID: SessionMessage.ID | undefined,
21: ) => Effect.Effect<void, SessionRunner.RunError>
22: }) {
23: const { db } = yield* Database.Service
24: const ownerID = crypto.randomUUID()
25: const leaseDuration = Duration.toMillis(SESSION_EXECUTION_LEASE_MS)
26: const activeClaims = new Map<SessionSchema.ID, SessionExecutionClaim.Claim>()
27: const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
28: drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
29: const executionID = crypto.randomUUID()
30: const now = yield* Clock.currentTimeMillis
31: const claimed = yield* db
32: .insert(SessionExecutionTable)
33: .values({
34: id: executionID,
35: session_id: sessionID,
36: owner_id: ownerID,
37: phase: "ready",
38: ...
I’m editing the two production boundaries now. Recovery validation will run only for an unpromoted recovery-owned input, preserving replay idempotence for already-promoted rows; invalid exact promotion will roll back, quarantine outside the event transaction, and fail the drain so its replacement claim clears. Hosted-tool failure publication will move before Step.Ended, so continue is never committed while those tools remain unresolved.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: readonly startCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
15: readonly terminalCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
16: readonly interruptedCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
17: readonly eventCommit: () => ...
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
125: .$type<SessionSchema.ID>()
126: .notNull()
127: .references(() => SessionTable.id, { onDelete: "cascade" }),
128: type: text().$type<SessionMessage.Type>().notNull(),
129: seq: integer().notNull(),
130: ...Timestamps,
131: data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
132: },
133: (table) => [
134: uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq),
135: index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq),
136: index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
137: index("session_message_time_created_idx").on(table.time_created),
138: ],
139: )
140:
141: export const SessionInputTable = sqliteTable(
142: "session_input",
143: {
144: id: ...
The shared validation shape is now clear: it will return the recovery row, continuation row, and one canonical error string. The scanner quarantines that result before waking; the projector throws a typed defect from the same result before promotion; promoteExact catches it only after transaction rollback, persists quarantine, then rethrows so claim cleanup proceeds.
Success. Updated the following files: M packages/core/src/session/input.ts M packages/core/src/session/runner/llm.ts
The production boundaries are changed. Before writing regressions, I’m replacing the scanner’s duplicated validation with the new shared validator so wake redrive and exact promotion cannot drift; then I’ll typecheck to catch schema/Effect issues early.
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, gt, isNull, lte, or, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { continuation, message } from "./recovery-id"
13: import { Prompt } from "./prompt"
14: import { SessionInput } from "./input"
15: import { SessionSchema } from "./schema"
16: import {
17: SESSION_EXECUTION_LEASE_MS,
18: SessionExecutionTable,
19: SessionInputTable,
20: SessionRecoveryTable,
21: ...
Success. Updated the following files: M packages/core/src/session/recovery.ts
Found 9 matches
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 3: import { and, asc, desc, eq, gt, isNull, lte, or, sql } from "drizzle-orm"
Line 4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
Line 12: import { continuation, message } from "./recovery-id"
Line 13: import { Prompt } from "./prompt"
Line 21: SessionMessageTable,
Line 100: if (Option.isNone(phase)) {
Line 127: .from(SessionMessageTable)
Line 128: .where(and(eq(SessionMessageTable.session_id, claim.session_id), eq(SessionMessageTable.type, "assistant")))
Line 129: .orderBy(desc(SessionMessageTable.seq))
Found 2 matches
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 12: import { continuation, message } from "./recovery-id"
Line 13: import { Prompt } from "./prompt"
Success. Updated the following files: M packages/core/src/session/recovery.ts
</task_result> </task>
The security reviewer returned empty output again and is disqualified. It will be replaced only after the revised candidate is frozen. The shared validator and tool-order change typecheck; I’m now adding deterministic regressions for the two rejected v3 crash/race paths.
Found 16 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 1816: LLMEvent.toolCall({ id: "call-error", name: "write", input: { path: "README.md" }, providerExecuted: true }),
Line 1823: providerExecuted: true,
Line 1836: providerExecuted: true,
Line 1979: if (execution?.phase === "continue") return yield* Effect.die("crash after settled Step.Ended")
Line 2134: providerExecuted: true,
Line 2141: providerExecuted: true,
Line 2161: providerExecuted: true,
Line 2169: providerExecuted: true,
Line 2863: providerExecuted: true,
Line 2866: { type: "tool-result", id: "call-hosted-interrupted", providerExecuted: true, result: { type: "error" } },
Line 3796: providerExecuted: true,
Line 3825: providerExecuted: true,
Line 3856: providerExecuted: true,
Line 3953: LLMEvent.toolCall({ id: ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
850: title: "test",
851: version: "test",
852: })),
853: )
854: .run()
855: yield* events.publish(SessionEvent.Step.Started, {
856: sessionID: other,
857: timestamp: created,
858: assistantMessageID: assistantID,
859: agent: "build",
860: model,
861: })
862:
863: const recovered = yield* events
864: .publish(SessionEvent.Step.Recovered, {
865: sessionID,
866: timestamp: DateTime.makeUnsafe(1),
867: executionID: "cross-session",
868: assistantMessageID: assistantID,
869: phase: "unknown",
870: })
871: .pipe(Effect.exit)
872:
873: expect(Exit.isFailure(recovered)).toBe(true)
874: expect(yield* db.select().from(SessionRecoveryTable).all()).toEqual([])
875: expect(yield* ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1750: yield* Deferred.succeed(firstGate, undefined)
1751: while (requests.length < 2) yield* Effect.yieldNow
1752:
1753: yield* session.interrupt(sessionID)
1754: expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
1755: streamGate = undefined
1756: expect(requests).toHaveLength(2)
1757: expect((yield* session.context(sessionID)).some((message) => message.type === "compaction")).toBe(false)
1758: }),
1759: )
1760:
1761: it.effect("preserves effective System updates while compaction rebaseline is blocked", () =>
1762: Effect.gen(function* () {
1763: yield* setup
1764: const session = yield* SessionV2.Service
1765: const events = yield* EventV2.Service
1766: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
1767:
1768: requests.length = ...
I’m first running the existing projector and runner suites against the boundary changes. This will expose any behavior assumptions that need updating before adding the two new negative controls.
bun test v1.3.14 (0d9b296a) 128 pass 0 fail 416 expect() calls Ran 128 tests across 2 files. [13.10s]
Existing suites pass after the boundary changes. I’m adding two focused regressions now: one invokes exact recovery promotion before the periodic scanner can quarantine a superseded target; the other crashes immediately after a mixed local/hosted-tool Step.Ended and requires the hosted tool error to already be durable.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
280: const tool = tools.get(callID)
281: return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`)
282: }
283:
284: const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
285: event: LLMEvent,
286: outputPaths: ReadonlyArray<string> = [],
287: ) {
288: switch (event.type) {
289: case "step-start":
290: return
291: case "text-start":
292: yield* text.start(event.id)
293: yield* publishEvent(SessionEvent.Text.Started, {
294: sessionID: input.sessionID,
295: assistantMessageID: yield* startAssistant(),
296: timestamp: yield* timestamp,
297: textID: event.id,
298: })
299: return
300: case "text-delta":
301: yield* text.append(event.id, event.text)
302: yield* ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
430: timestamp: yield* timestamp,
431: assistantMessageID: tool.assistantMessageID,
432: callID: event.id,
433: error: { type: "unknown", message: event.message },
434: provider: {
435: executed: tool.providerExecuted,
436: ...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
437: },
438: })
439: return
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* ...
Found 2 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 1550: if (execution?.phase === "continue") return yield* Effect.die("crash after Compaction.Ended")
Line 1979: if (execution?.phase === "continue") return yield* Effect.die("crash after settled Step.Ended")
The mixed-tool crash regression will use a SQLite trigger that aborts insertion of Tool.Failed. That creates the exact boundary without test-only production hooks: under the rejected order, Step.Ended has already committed continue; under the fix, the failure occurs first and the claim remains unknown.
Found 17 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 20: import { EventTable } from "@opencode-ai/core/event/sql"
Line 402: .from(EventTable)
Line 403: .where(eq(EventTable.aggregate_id, id))
Line 404: .orderBy(asc(EventTable.seq))
Line 515: .select({ type: EventTable.type })
Line 516: .from(EventTable)
Line 517: .where(eq(EventTable.type, EventV2.versionedType(fixture.delta.type, 1)))
Line 578: (yield* db.select({ type: EventTable.type }).from(EventTable).all()).some((event) =>
Line 743: const eventsBefore = (yield* db.select().from(EventTable).all()).length
Line 753: expect((yield* db.select().from(EventTable).all()).length).toBe(eventsBefore)
Line 769: const eventsBefore = (yield* db.select().from(EventTable).all()).length
Line 778: expect((yield* db.select().from(EventTable).all()).length).toBe(eventsBefore)
Line 814: (yield* ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import {
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Model,
7: TransportReason,
8: InvalidRequestReason,
9: type LLMClientShape,
10: type LLMRequest,
11: } from "@opencode-ai/llm"
12: import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
13: import { Database } from "@opencode-ai/core/database/database"
14: import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
15: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
16: import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
17: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
18: import { EventV2 } from "@opencode-ai/core/event"
19: import { PermissionV2 } from "@opencode-ai/core/permission"
20: import { EventTable } from "@opencode-ai/core/event/sql"
21: import { Project } ...
Found 1 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 62: import { asc, eq } from "drizzle-orm"
Success. Updated the following files: M packages/core/test/session-projector.test.ts M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) 1 pass 31 filtered out 0 fail 4 expect() calls Ran 1 test across 1 file. [1118.00ms] bun test v1.3.14 (0d9b296a) 1 pass 97 filtered out 0 fail 4 expect() calls Ran 1 test across 1 file. [1198.00ms] $ tsgo --noEmit
Both regressions pass on the fix and typecheck. I’m proving they fail against frozen v3 next. After that, I’ll assess the two reviewer concerns separately: bounded-scan starvation likely needs a small cursor or per-row durable backoff, while coordinator cleanup failure needs finalization semantics that never leave stopping wedged.
drwxrwxr-x 1 byk byk 4444 Sep 16 00:31 /home/byk/Code Preparing worktree (detached HEAD ab9408c81c) Updating files: 95% (6356/6637) Updating files: 96% (6372/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
The negative-control worktree now contains exact frozen v3. I’m overlaying only the two new tests, not the production fixes, so any failures directly establish the rejected behavior.
Success. Updated the following files: M ../opencode-recovery-v3-negative/packages/core/test/session-projector.test.ts M ../opencode-recovery-v3-negative/packages/core/test/session-runner.test.ts
contiue
The first negative control failed exactly as required: frozen v3 promoted a recovery after a newer assistant superseded it. I’m running the hosted-tool negative control separately now, then I’ll remove the disposable worktree and address the remaining starvation and coordinator-cleanup findings.
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
1959 | yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Mixed tools" }), resume: false })
1960 | responses = [[LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-local", name: "echo", input: { text: "settled" } }), LLMEvent.toolCall({ id: "call-hosted-pending", name: "web_search", input: { query: "pending" }, providerExecuted: true }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" })]]
1961 | yield* db.run(sql`CREATE TRIGGER fail_hosted_tool_reconciliation BEFORE INSERT ON event WHEN NEW.type = 'session.next.tool.failed.1' BEGIN SELECT RAISE(ABORT, 'crash before hosted tool reconciliation'); END`)
1962 | const result = yield* session.resume(sessionID).pipe(Effect.exit)
1963 | expect(Exit.isFailure(result)).toBe(true)
1964 | expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({ phase: "unknown" ...
Both fail-first controls now prove the rejected v3 behavior: stale exact promotion succeeded, and mixed-tool failure left the claim in continue. I’m removing the disposable worktree, then fixing the remaining review findings with deterministic tests before another full validation cycle.
(no output)
The remaining liveness fix can stay small if durable claims drive pagination: startup wake scans should exclude Sessions that already have an execution claim, so each successful batch leaves the next batch eligible while active drains own the earlier Sessions. I’m verifying coordinator wake semantics and then will add per-row isolation; if wake() returns before claim acquisition, I’ll use durable attempt ordering instead.
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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, runs cleanup, then starts work that arrived while stopping. */
14: readonly interrupt: (key: Key, cleanup?: Effect.Effect<void>) => Effect.Effect<void>
15: }
16:
17: type Entry<E> = {
18: ...
wake() returns after registering the drain, before the claim is guaranteed durable, so claim-based pagination cannot guarantee progress. I’ll make the scan cursor-based within each recovery pass instead: fetch deterministic pages after the last (time_created, id) key and isolate each wake() failure, ensuring one bad or early Session never pins the first 100. Separately, coordinator interruption will always finalize its state after cleanup exits, then propagate the cleanup failure.
Found 12 matches
/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts:
Line 43: Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))),
/home/byk/Code/opencode/packages/core/src/session/execution/local.ts:
Line 73: Effect.onExit((exit) =>
/home/byk/Code/opencode/packages/core/src/pty.ts:
Line 232: yield* events.publish(Event.Exited, { id, exitCode })
Line 261: if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
/home/byk/Code/opencode/packages/core/src/plugin.ts:
Line 56: if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
Line 62: Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Line 73: Effect.onExit((exit) => {
Line 94: if (current) yield* Scope.close(current, ...
Ripgrep JSON record exceeded 65536 bytes
No files found
Found 40 matches
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Effect.ts:
Line 2297: export const exit: <A, E, R>(
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Cause.ts:
Line 1405: export const done: <A = void>(value?: A) => Effect.Effect<never, Done<A>> = core.done
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/unstable/workflow/DurableDeferred.ts:
Line 504: export const done: {
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Deferred.ts:
Line 359: export const done: {
Line 846: export const doneUnsafe = <A, E>(self: Deferred<A, E>, effect: Effect<A, E>): boolean => {
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/unstable/rpc/Rpc.ts:
Line 1121: export const exitSchema = <R extends ...
Found 1 matches /home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Fiber.ts: Line 346: export const interrupt: <A, E>(self: Fiber<A, E>) => Effect<void> = effect.fiberInterrupt
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
245: SessionRecoveryTable,
246: eq(SessionRecoveryTable.continuation_message_id, SessionInputTable.id),
247: )
248: .where(
249: and(
250: eq(SessionInputTable.wake_pending, true),
251: isNull(SessionInputTable.promoted_seq),
252: isNull(SessionRecoveryTable.execution_id),
253: ),
254: )
255: .groupBy(SessionInputTable.session_id)
256: .orderBy(asc(sql`min(${SessionInputTable.time_created})`), asc(SessionInputTable.session_id))
257: .limit(BATCH_SIZE)
258: .all()
259: .pipe(Effect.orDie)
260: yield* Effect.forEach(
261: ordinary.map((row) => row.sessionID),
262: (sessionID) =>
263: execution
264: .wake(sessionID)
265: .pipe(
266: Effect.catchCause((cause) =>
267: Effect.logError("Failed to redrive Session input", cause).pipe(Effect.annotateLogs({ ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
180: )
181: }),
182: )
183: }).pipe(
184: Effect.catchCause((cause) =>
185: Effect.logError("Failed to recover Session", cause).pipe(
186: Effect.annotateLogs({ sessionID: claim.session_id, executionID: claim.id }),
187: ),
188: ),
189: ),
190: { discard: true, concurrency: 1 },
191: )
192:
193: const pending = yield* db
194: .select({
195: sessionID: SessionRecoveryTable.session_id,
196: executionID: SessionRecoveryTable.execution_id,
197: assistantMessageID: SessionRecoveryTable.assistant_message_id,
198: continuationMessageID: SessionRecoveryTable.continuation_message_id,
199: phase: SessionRecoveryTable.phase,
200: })
201: .from(SessionRecoveryTable)
202: .where(and(eq(SessionRecoveryTable.wake_pending, true), ...
The scan fix will use keyset pagination over durable rows, not offset pagination: each pass processes bounded pages ordered by (time_created, id), catches each wake failure, then advances beyond that row. This guarantees later Sessions are reached even when the first 100 remain pending. Coordinator cleanup will capture cleanup exit, finalize/delete-or-successor state unconditionally, then rethrow the cleanup cause.
/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
3: import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
4: import { testEffect } from "./lib/effect"
5:
6: const it = testEffect(Layer.empty)
7:
8: describe("SessionRunCoordinator", () => {
9: it.effect("joins concurrent resumes for one key", () =>
10: Effect.scoped(
11: Effect.gen(function* () {
12: const gate = yield* Deferred.make<void>()
13: let runs = 0
14: const coordinator = yield* SessionRunCoordinator.make({
15: drain: () => Effect.sync(() => runs++).pipe(Effect.andThen(Deferred.await(gate))),
16: })
17:
18: const first = yield* coordinator.run("session").pipe(Effect.forkChild)
19: yield* Effect.yieldNow
20: const second = yield* ...
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
247: it.effect("runs a wake registered during interruption cleanup", () =>
248: Effect.scoped(
249: Effect.gen(function* () {
250: const firstStarted = yield* Deferred.make<void>()
251: const cleanupStarted = yield* Deferred.make<void>()
252: const cleanupGate = yield* Deferred.make<void>()
253: const secondStarted = yield* Deferred.make<void>()
254: let runs = 0
255: const coordinator = yield* SessionRunCoordinator.make({
256: drain: () =>
257: Effect.sync(() => ++runs).pipe(
258: Effect.flatMap((run) =>
259: run === 1
260: ? Deferred.succeed(firstStarted, undefined).pipe(
261: Effect.andThen(Effect.never),
262: Effect.onInterrupt(() =>
263: Deferred.succeed(cleanupStarted, ...
I’m applying the coordinator finalization first because it is self-contained and directly testable. The interruption path will be uninterruptible, capture cleanup’s full exit, finalize active and any successor wake in all cases, complete waiters, then propagate the cleanup defect.
Success. Updated the following files: M packages/core/src/session/run-coordinator.ts
Success. Updated the following files: M packages/core/src/session/run-coordinator.ts
Success. Updated the following files: M packages/core/test/session-run-coordinator.test.ts
bun test v1.3.14 (0d9b296a)
[09:15:52.734] ERROR (#1): 318 | yield* Deferred.succeed(cleanupGate, undefined)
319 |
320 | const exit = yield* Fiber.join(interrupted)
321 | expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBeTrue()
322 | yield* Deferred.await(secondStarted)
323 | expect(Array.from(yield* coordinator.active)).toEqual([])
^
error: expect(received).toEqual(expected)
- []
+ [
+ "session",
+ ]
- Expected - 1
+ Received + 3
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts:323:55
test/session-run-coordinator.test.ts:
318 | yield* Deferred.succeed(cleanupGate, undefined)
319 |
320 | const exit = yield* Fiber.join(interrupted)
321 | expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBeTrue()
322 | yield* Deferred.await(secondStarted)
323 | expect(Array.from(yield* coordinator.active)).toEqual([])
...
The coordinator logic finalized correctly enough to start the successor, but the assertion raced the successor’s own onExit: its “started” latch fires just before active is removed. I’m making the regression wait on the successor through run(), which joins the active entry and deterministically observes final cleanup.
Success. Updated the following files: M packages/core/test/session-run-coordinator.test.ts
bun test v1.3.14 (0d9b296a) 1 pass 16 filtered out 0 fail 3 expect() calls Ran 1 test across 1 file. [365.00ms] $ tsgo --noEmit
Coordinator cleanup now passes deterministically and typechecks. I’m implementing keyset pagination for both recovery and ordinary wake redrive, bounded per page but exhaustive per startup pass, with per-row failure isolation preserved. Then I’ll add a regression where more than 100 early wake failures cannot starve a later Session.
Found 8 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 389: it.effect("redrives a durable recovery wake after admission", () =>
Line 498: const poisonCount = SessionRecovery.BATCH_SIZE + 1
Line 545: ).toHaveLength(SessionRecovery.BATCH_SIZE)
Line 609: it.effect("redrives durable ordinary input execution intent after a lost advisory wake", () =>
Line 651: it.effect("redrives ordinary input without recovering a claim that crashed before promotion", () =>
Line 710: it.effect("redrives one existing recovery after its replacement claim crashes before promotion", () =>
Line 1169: const ids = Array.from({ length: SessionRecovery.BATCH_SIZE + 1 }, (_, index) =>
Line 1206: expect.objectContaining({ id: `execution-${SessionRecovery.BATCH_SIZE.toString().padStart(3, "0")}` }),
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
470: timestamp: created,
471: executionID: "valid-recovery-row",
472: phase: "ready",
473: })
474: yield* db.run(sql`UPDATE session_input SET prompt = '{}' WHERE id = ${continuation("malformed-recovery-row")}`)
475: const wakes: string[] = []
476: const execution = SessionExecution.Service.of({
477: ...recoveryExecution,
478: wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid),
479: })
480:
481: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution))
482:
483: expect(wakes).toEqual([valid])
484: expect(
485: yield* db
486: .select({ error: SessionRecoveryTable.recovery_error })
487: .from(SessionRecoveryTable)
488: .where(eq(SessionRecoveryTable.execution_id, "malformed-recovery-row"))
489: ...
Success. Updated the following files: M packages/core/src/session/recovery.ts
$ tsgo --noEmit src/session/recovery.ts(193,9): error TS7022: 'redriveRecoveries' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. src/session/recovery.ts(193,47): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions. src/session/recovery.ts(259,9): error TS7022: 'redriveInputs' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. src/session/recovery.ts(259,43): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions. src/session/recovery.ts(320,3): error TS2322: Type '[Node<Service, never, Tag<"global">>, Node<Service, never, Tag<"global">>, Node<never, never, ...
TypeScript rejects self-recursive local Effects without explicit environment-heavy annotations. I’ll switch pagination to Effect.loop, which carries the cursor as immutable state and avoids both reassignment and recursive type annotations.
No files found
No files found
Found 4 matches
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Effect.ts:
Line 795: * const program = Effect.whileLoop({
Line 813: export const whileLoop: <A, E, R>(options: {
Line 817: }) => Effect<void, E, R> = internal.whileLoop
Line 14664: whileLoop({
<path>/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Effect.ts</path>
<type>file</type>
<content>
780: f: (a: Arr.ReadonlyArray.Infer<S>, i: number) => Effect<B, E, R>,
781: options?: { readonly concurrency?: Concurrency | undefined; readonly discard?: Discard | undefined } | undefined
782: ): Effect<Discard extends false ? Arr.ReadonlyArray.With<S, B> : void, E, R>
783: } = internal.forEach
784:
785: /**
786: * Executes a body effect repeatedly while a condition holds true.
787: *
788: * **Example** (Repeating an effectful loop)
789: *
790: * ```ts
791: * import { Effect } from "effect"
792: *
793: * let counter = 0
794: *
795: * const program = Effect.whileLoop({
796: * while: () => counter < 5,
797: * body: () => Effect.sync(() => ++counter),
798: * step: (n) => console.log(`Current count: ${n}`)
799: * })
800: *
801: * Effect.runPromise(program)
802: * // Output:
803: * // Current count: 1
804: * // ...
Found 2 matches
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Stream.ts:
Line 1729: export const iterate = <A>(value: A, next: (value: A) => A): Stream<A> =>
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/internal/effect.ts:
Line 4754: export const iterateEager = <S, A>(): <X, E, R, E2>(options: {
No files found
Effect.whileLoop would require mutable cursor state, which conflicts with the repository style. I’m moving each page walker into a small module-local helper with an explicit concrete Effect<void> return type; recursion then typechecks while cursor state stays immutable.
<path>/home/byk/Code/opencode/packages/core/src/database/database.ts</path>
<type>file</type>
<content>
1: export * as Database from "./database"
2:
3: import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
4: import { layer as sqliteLayer } from "#sqlite"
5: import { Context, Effect, Layer } from "effect"
6: import { Global } from "../global"
7: import { Flag } from "../flag/flag"
8: import { isAbsolute, join } from "path"
9: import { DatabaseMigration } from "./migration"
10: import { InstallationChannel } from "../installation/version"
11: import { makeGlobalNode } from "../effect/app-node"
12:
13: const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
14: type DatabaseShape = Effect.Success<typeof makeDatabase>
15:
16: export interface Interface {
17: db: DatabaseShape
18: }
19:
20: export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
21:
22: const layer = Layer.effect(
23: Service,
24: ...
Found 1 matches /home/byk/Code/opencode/packages/core/src/session/input.ts: Line 15: type DatabaseService = Database.Interface["db"]
Success. Updated the following files: M packages/core/src/session/recovery.ts
$ tsgo --noEmit
The paginated walkers now typecheck. Existing poison-batch expectations encode the old “one page per 10 seconds” behavior, so I’m updating them to require full progress in one recovery pass and adding explicit recovery plus ordinary-wake starvation tests where the first 101 wake calls fail but the later Session is still reached.
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { DateTime, Deferred, Effect, Exit, Fiber, Schema } from "effect"
3: import { TestClock } from "effect/testing"
4: import { asc, eq, sql } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
7: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
8: import { EventV2 } from "@opencode-ai/core/event"
9: import { EventTable } from "@opencode-ai/core/event/sql"
10: import { ModelV2 } from "@opencode-ai/core/model"
11: import { Project } from "@opencode-ai/core/project"
12: import { ProjectTable } from "@opencode-ai/core/project/sql"
13: import { ProviderV2 } from "@opencode-ai/core/provider"
14: import { AbsolutePath } from "@opencode-ai/core/schema"
15: import { SessionV2 } from ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
The starvation regression intentionally emits 202 caught wake failures, which would flood test logs. I’m splitting it into two smaller tests and suppressing only the expected test logger output if the repository’s test helper supports it; otherwise I’ll assert page traversal through quarantined rows plus a single failed wake at each page boundary.
Found 5 matches
/home/byk/Code/opencode/packages/core/test/plugin/provider-openai.test.ts:
Line 134: it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
/home/byk/Code/opencode/packages/core/test/effect/observability.test.ts:
Line 3: import { Effect, Layer, Logger } from "effect"
Line 7: import { fileLogger } from "../../src/observability/logging"
Line 68: Effect.provide(Logger.layer([fileLogger(file, runID)]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
Line 97: Effect.provide(Logger.layer([fileLogger(file, "run-a")]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
Success. Updated the following files: M packages/core/test/session-projector.test.ts
The regression now tests the actual starvation mechanism without noisy artificial defects: the first 101 durable rows remain unchanged after wake, yet the same pass must reach row 102 for both recovery and ordinary input scans. This fails under the old fixed first-page queries.
bun test v1.3.14 (0d9b296a) 2 pass 31 filtered out 0 fail 5 expect() calls Ran 2 tests across 1 file. [2.54s] bun test v1.3.14 (0d9b296a) 17 pass 0 fail 28 expect() calls Ran 17 tests across 1 file. [610.00ms] $ tsgo --noEmit
The keyset version fixes one pass but weakens the established bounded-pass contract and loses its cursor on restart. I’m replacing it with durable fair scheduling: each pending recovery/input row records the last wake attempt, each pass remains capped at 100, and oldest/unattempted rows sort first. A no-op or failed early row moves behind untouched rows, so later work advances on the next 10-second pass and across restarts.
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260914170650_session-recovery",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`ALTER TABLE \`session_input\` ADD \`wake_pending\` integer DEFAULT false NOT NULL;`)
9: yield* tx.run(`
10: CREATE TABLE \`session_execution\` (
11: \`id\` text NOT NULL,
12: \`session_id\` text NOT NULL,
13: \`owner_id\` text NOT NULL,
14: \`assistant_message_id\` text,
15: \`phase\` text NOT NULL,
16: \`time_created\` integer NOT NULL,
17: \`expires_at\` integer NOT NULL,
18: \`recovery_error\` text,
19: CONSTRAINT \`session_execution_pk\` PRIMARY KEY(\`id\`),
20: CONSTRAINT ...
The cursor walker already guarantees row 101 is reached, but it can make one recovery pass unbounded. I’m tightening it to the reviewer’s stronger requirement: each pass remains capped at 100, while durable wake-attempt timestamps rotate unchanged or failed rows behind untouched work across later scans and restarts.
<path>/home/byk/Code/opencode/packages/core/script/migration.ts</path>
<type>file</type>
<content>
1: #!/usr/bin/env bun
2:
3: import { $ } from "bun"
4: import fs from "fs/promises"
5: import os from "os"
6: import path from "path"
7: import { pathToFileURL } from "url"
8: import { parseArgs } from "util"
9:
10: const root = path.resolve(import.meta.dirname, "../../..")
11: const snapshot = path.join(root, "packages/core/schema.json")
12: const tsDir = path.join(root, "packages/core/src/database/migration")
13: const registry = path.join(root, "packages/core/src/database/migration.gen.ts")
14: const schema = path.join(root, "packages/core/src/database/schema.gen.ts")
15: const args = parseArgs({
16: args: process.argv.slice(2),
17: options: {
18: check: { type: "boolean" },
19: name: { type: "string" },
20: },
21: })
22:
23: if (args.values.check) {
24: await check()
25: process.exit(0)
26: }
27:
28: await generate()
29:
30: async function generate() {
31: ...
I’m using durable attempt counters rather than timestamps. Each bounded pass increments the selected recovery row or every pending ordinary input for that Session before calling wake; the next pass always prefers lower attempt counts, so failures, no-op wakes, restarts, and equal clocks cannot pin the first page.
Success. Updated the following files: M packages/core/src/session/sql.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts
I’m replacing the keyset walkers with one bounded page each. Before each advisory wake, the row’s durable attempt count advances; selection sorts by attempts, then original creation order. This preserves the 100-row bound and ensures every later row becomes eligible on subsequent scans.
Success. Updated the following files: M packages/core/src/session/recovery.ts
Found 2 matches
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 3: import { and, asc, desc, eq, gt, isNull, lte, or, sql } from "drizzle-orm"
Line 108: gt(SessionExecutionTable.expires_at, quarantinedAt),
The schema source and squashed unpublished migration now include durable attempt counters. I’m regenerating schema.json, fresh-schema SQL, and the migration registry through the Core migration script, then I’ll update the fairness regression to require two bounded passes: first exactly 100 rows, second reaches the remaining rows despite the first 100 staying pending.
Reading config file '/tmp/opencode-core-migration-jFBbZO/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-jFBbZO/incremental/20260916092601_session-recovery-fairness/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-jFBbZO/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-jFBbZO/full/20260916092607_schema/migration.sql 🚀
Generation correctly detected only the two new columns/index changes, but this feature is still unpublished and must retain one squashed recovery migration. I’m folding the generated schema snapshot forward while deleting the new incremental migration and keeping the SQL in 20260914170650_session-recovery.ts.
/home/byk/Code/opencode/packages/core/src/database/migration/20260916092601_session-recovery-fairness.ts
Success. Updated the following files: D packages/core/src/database/migration/20260916092601_session-recovery-fairness.ts M packages/core/src/database/migration.gen.ts
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
533 | ...recoveryExecution,
534 | wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid),
535 | })
536 |
537 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution))
538 | expect(wakes).toEqual([sessions[poisonCount]])
^
error: expect(received).toEqual(expected)
- [
- "ses_recovery_poison_101",
- ]
+ []
- Expected - 3
+ Received + 1
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:538:21
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate ...
The bounded fairness behavior is correct; the older poison test still expected same-pass page 2 after I switched away from unbounded keyset traversal. I’m restoring its two-pass assertions: pass 1 quarantines exactly 100, pass 2 quarantines the remainder and reaches valid work.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 2 pass 31 filtered out 0 fail 9 expect() calls Ran 2 tests across 1 file. [3.36s] 17 pass 0 fail 28 expect() calls Ran 17 tests across 1 file. [736.00ms] $ tsgo --noEmit bun test v1.3.14 (0d9b296a) Reading config file '/tmp/opencode-core-migration-check-7KPIbm/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-7KPIbm/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-7KPIbm/full/20260916092754_schema/migration.sql 🚀
The four fixes now pass focused tests, Core typecheck, and migration consistency. I’m tightening the wake-attempt compare-and-set so a concurrently settled row never triggers a stale advisory wake, then I’ll capture fail-first evidence for bounded fairness and cleanup defects before running the complete affected suite.
Success. Updated the following files: M packages/core/src/session/recovery.ts
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
220: .where(
221: and(
222: eq(SessionRecoveryTable.execution_id, row.executionID),
223: eq(SessionRecoveryTable.wake_pending, true),
224: isNull(SessionRecoveryTable.recovery_error),
225: ),
226: )
227: .run()
228: .pipe(Effect.orDie)
229: return
230: }
231: yield* db
232: .update(SessionRecoveryTable)
233: .set({ wake_attempts: row.wakeAttempts + 1 })
234: .where(
235: and(
236: eq(SessionRecoveryTable.execution_id, row.executionID),
237: eq(SessionRecoveryTable.wake_attempts, row.wakeAttempts),
238: eq(SessionRecoveryTable.wake_pending, true),
239: isNull(SessionRecoveryTable.recovery_error),
240: ),
241: )
242: ...
Success. Updated the following files: M packages/core/src/session/recovery.ts
The compare-and-set now wakes only when it actually advances a still-pending row. I’m rerunning focused gates, then I’ll apply only the fairness and cleanup regressions to frozen v3 to prove both fail without these production changes.
bun test v1.3.14 (0d9b296a) 3 pass 30 filtered out 0 fail 13 expect() calls Ran 3 tests across 1 file. [2.69s] bun test v1.3.14 (0d9b296a) 1 pass 97 filtered out 0 fail 4 expect() calls Ran 1 test across 1 file. [1384.00ms] bun test v1.3.14 (0d9b296a) 1 pass 16 filtered out 0 fail 3 expect() calls Ran 1 test across 1 file. [245.00ms] $ tsgo --noEmit
Focused gates pass. I’m now creating one disposable frozen-v3 worktree and overlaying only two tests that do not depend on the new schema: bounded recovery fairness across two scans, and coordinator recovery after cleanup dies. Both must fail before I trust the fixes.
drwxrwxr-x 1 byk byk 4444 Sep 16 09:11 /home/byk/Code Preparing worktree (detached HEAD ab9408c81c) Updating files: 96% (6400/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density error: can't open patch '/tmp/opencode/session-recovery-final-v3.patch': No such file or directory
The old /tmp freeze disappeared, so I cannot honestly call the next check exact frozen v3. I’ll use the stronger mutation control instead: reproduce the current candidate in the disposable worktree, remove only the fairness scheduler and cleanup-finalization guards, and require the new regressions to fail. This proves each test depends on its guard without relying on a missing artifact.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
I’m reconstructing the current package-only candidate directly from the base in the disposable worktree, excluding .lore.md, AGENTS.md, and profiling artifacts. Then I’ll remove only each liveness guard and run its regression.
(no output)
(no output)
The disposable worktree now matches the current package candidate. I’m mutating only two guards: wake scans revert to fixed creation-order pages without attempt advancement, and interrupt cleanup reverts to finalizing only after cleanup succeeds. No production workspace file will change.
Success. Updated the following files: M ../opencode-recovery-v3-liveness/packages/core/src/session/recovery.ts M ../opencode-recovery-v3-liveness/packages/core/src/session/run-coordinator.ts
bun test v1.3.14 (0d9b296a) test/session-projector.test.ts: # Unhandled error between tests ------------------------------- error: Cannot find module '@opencode-ai/core/session/recovery-id' from '/home/byk/Code/opencode-recovery-v3-liveness/packages/core/test/session-projector.test.ts' ------------------------------- 0 pass 1 fail 1 error Ran 1 test across 1 file. [1078.00ms]
The mutation control failed during module setup, not product behavior: the two new source files were applied under absolute-path-derived names rather than their package paths. I’m correcting only the disposable worktree layout and rerunning; this setup failure is not evidence.
/home/byk/Code/opencode-recovery-v3-liveness/home/byk/Code/opencode/packages/core/src/session/recovery-id.ts
/home/byk/Code/opencode-recovery-v3-liveness/home/byk/Code/opencode/packages/core/src/session/execution/claim.ts
compaction.ts context-epoch.ts error.ts event.ts execution execution.ts history.ts info.ts input.ts message.ts message-updater.ts projector.ts prompt.ts recovery.ts revert.ts run-coordinator.ts runner schema.ts sql.ts store.ts todo.ts local.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
619 | expect(wakes).not.toContain(recoverySessions[failedCount])
620 | expect(wakes).not.toContain(ordinarySessions[failedCount])
621 |
622 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution))
623 |
624 | expect(wakes).toContain(recoverySessions[failedCount])
^
error: expect(received).toContain(expected)
Expected to contain: "ses_recovery_failed_wake_101"
Received: [
"ses_recovery_failed_wake_000", "ses_recovery_failed_wake_001", "ses_recovery_failed_wake_002",
"ses_recovery_failed_wake_003", "ses_recovery_failed_wake_004", "ses_recovery_failed_wake_005",
"ses_recovery_failed_wake_006", "ses_recovery_failed_wake_007", "ses_recovery_failed_wake_008",
"ses_recovery_failed_wake_009", "ses_recovery_failed_wake_010", "ses_recovery_failed_wake_011",
"ses_recovery_failed_wake_012", "ses_recovery_failed_wake_013", ...
The fairness mutation failed as expected: two scans repeatedly woke only the first 100 rows, and row 101 remained starved. I’m running the cleanup mutation separately; it may block a joined waiter, so the regression uses its existing bounded test timeout rather than waiting indefinitely.
bun test v1.3.14 (0d9b296a) test/session-run-coordinator.test.ts: (fail) SessionRunCoordinator > finalizes interruption when cleanup dies [5000.01ms] ^ this test timed out after 5000ms. 0 pass 16 filtered out 1 fail 1 expect() calls Ran 1 test across 1 file. [5.25s]
Both liveness guards now have fail-first proof: fixed first-page scans starved row 101 after two passes, and failed interruption cleanup wedged the coordinator until timeout. I’m removing the mutation worktree and running the full affected suite plus package gates on the real candidate.
(no output)
Before broad validation, I found one migration-specific test risk: the populated-predecessor fixture may list the final expected columns explicitly. I’m running the recovery-owned suite first, including migration and coordinator tests, to catch any required expectation updates before spending on the full Core suite.
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
2056 |
2057 | yield* SessionRecovery.recover()
2058 | while (requests.length === 0) yield* Effect.yieldNow
2059 | while ((yield* (yield* SessionExecution.Service).active).size > 0) yield* Effect.yieldNow
2060 |
2061 | expect(executions.filter((value) => value === "settled")).toHaveLength(1)
^
error: expect(received).toHaveLength(expected)
Expected length: 1
Received length: 2
at toHaveLength (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:2061:65
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate ...
The affected suite exposed a real regression in the settled-local-tool restart test: the local tool executed twice after recovery. I’m tracing the phase transition and local-tool settlement ordering now; I will fix the state machine, not weaken the assertion.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
330: }),
331: settlement.outputPaths ?? [],
332: ),
333: ),
334: ),
335: ),
336: ),
337: ).pipe(FiberSet.run(toolFibers))
338: }),
339: ),
340: Effect.ensuring(withPublication(publisher.flush())),
341: )
342:
343: return yield* Effect.uninterruptibleMask((restore) =>
344: Effect.gen(function* () {
345: yield* updateExecution({ phase: "unknown", assistant_message_id: null })
346: const stream = yield* restore(providerStream).pipe(Effect.exit)
347: const failure =
348: stream._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(stream.cause)) : undefined
349: if (
350: recoverOverflow &&
351: ...
The duplicate is not caused by wake fairness. The restart test’s first run now fails at the hosted-tool reconciliation trigger I added later in the file, which persists across the shared test database? I’m checking fixture isolation and the exact test ordering/state first; if the trigger leaks between tests, the new regression must drop it in a finalizer rather than distort later lifecycle tests.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1940: state: {
1941: status: "completed",
1942: input: { text: "hello" },
1943: structured: { text: "hello" },
1944: content: [{ type: "text", text: "hello" }],
1945: },
1946: },
1947: ],
1948: },
1949: { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-final", text: "Done" }] },
1950: ])
1951: }),
1952: )
1953:
1954: it.effect("never commits continuation before unresolved hosted tools are reconciled", () =>
1955: Effect.gen(function* () {
1956: yield* setup
1957: const session = yield* SessionV2.Service
1958: const { db } = yield* Database.Service
1959: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Mixed tools" }), resume: false })
1960: responses = [
1961: ...
The failure came from the new SQLite trigger leaking into the next test because the shared fixture resets rows, not schema objects. I’m scoping the trigger with an ensuring finalizer so it exists only during the targeted resume; this preserves the real crash boundary without contaminating later lifecycle tests.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
The trigger is now fixture-scoped. I’m rerunning the two adjacent tool tests first, then the entire affected suite.
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
2059 |
2060 | yield* SessionRecovery.recover()
2061 | while (requests.length === 0) yield* Effect.yieldNow
2062 | while ((yield* (yield* SessionExecution.Service).active).size > 0) yield* Effect.yieldNow
2063 |
2064 | expect(executions.filter((value) => value === "settled")).toHaveLength(1)
^
error: expect(received).toHaveLength(expected)
Expected length: 1
Received length: 2
at toHaveLength (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:2064:65
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate ...
The trigger leak was real but not the whole cause. The adjacent test still shows the first local tool runs twice, meaning moving hosted reconciliation before Step.Ended changed timing around local-tool settlement. I’m inspecting when called, settled, and needsContinuation are computed relative to the tool fiber and publisher event stream, then I’ll restore exactly-once local settlement while keeping hosted tools terminal before continue.
Found 16 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 213: let needsContinuation = false
Line 240: const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
Line 256: tools: toolMaterialization?.definitions ?? [],
Line 288: withPublication(publisher.publish(event, outputPaths))
Line 302: if (!toolMaterialization) {
Line 306: needsContinuation = true
Line 314: toolMaterialization.settle({
Line 425: phase: needsContinuation ? "continue" : "safe",
Line 426: assistant_message_id: needsContinuation ? assistantMessageID : null,
Line 433: return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
Line 444: ) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError>
Line 517: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
200: and(
201: eq(SessionExecutionTable.session_id, claim.sessionID),
202: eq(SessionExecutionTable.id, claim.executionID),
203: eq(SessionExecutionTable.owner_id, claim.ownerID),
204: ),
205: )
206: .get()
207: .pipe(Effect.orDie)
208: const continuation = execution?.phase === "continue"
209: yield* updateExecution(continuation ? {} : { phase: "ready", assistant_message_id: null })
210: const agent = yield* agents.select(session.agent)
211: const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id, claim)
212: const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
213: let needsContinuation = false
214: let currentStep = step
215: if (promotion) {
216: const cutoff = yield* ...
The duplicate count includes the preceding mixed-tool regression’s local tool because that test used the same "settled" payload and the shared fixture’s setup does not clear executions before every test. This is test contamination, not a state-machine regression. I’m giving the mixed-tool call a unique payload and retaining the adjacent test as a guard that restart never re-executes its own "settled" call.
Found 15 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 338: const setup = Effect.gen(function* () {
Line 374: const setupOverflowRecovery = Effect.gen(function* () {
Line 790: executions.length = 0
Line 1450: executions.length = 0
Line 1904: executions.length = 0
Line 2235: executions.length = 0
Line 2296: executions.length = 0
Line 3480: while (executions.length === 0) yield* Effect.yieldNow
Line 3503: executions.length = 0
Line 3514: while (executions.length === 0) yield* Effect.yieldNow
Line 3587: executions.length = 0
Line 3599: while (executions.length === 0) yield* Effect.yieldNow
Line 3636: executions.length = 0
Line 3684: executions.length = 0
Line 3821: const executionCount = executions.length
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
330: time_created: Date.now(),
331: expires_at: Date.now() + 60_000,
332: },
333: })
334: .run()
335: .pipe(Effect.orDie)
336: })
337:
338: const setup = Effect.gen(function* () {
339: const { db } = yield* Database.Service
340: response = []
341: systemBaseline = "Initial context"
342: systemRemoved = false
343: systemUnavailable = false
344: systemLoadHook = Effect.void
345: modelResolveHook = Effect.void
346: currentModel = model
347: skillBaselines.clear()
348: responses = undefined
349: streamFailure = undefined
350: responseStream = undefined
351: streamGate = undefined
352: streamStarted = undefined
353: toolExecutionGate = undefined
354: toolExecutionsStarted = undefined
355: toolExecutionsReady = 5
356: activeToolExecutions = 0
357: maxActiveToolExecutions = 0
358: yield* db
359: ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) 2 pass 96 filtered out 0 fail 10 expect() calls Ran 2 tests across 1 file. [1.94s] bun test v1.3.14 (0d9b296a) 212 pass 0 fail 618 expect() calls Ran 212 tests across 8 files. [20.65s]
All 212 recovery-owned tests pass. I’m moving to complete validation on the exact source: full Core suite, Schema manifest, all five affected package typechecks, migration consistency, and diff checks. If those pass, I’ll rebuild the SEA, repeat isolated restart smoke, freeze a new immutable patch, and launch replacement correctness/security reviews.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "in_progress",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security ...
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[09:36:22.448] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
[09:37:27.312] INFO (#204053): watcher backend {
directory: "/tmp/opencode-core-test-CStSy3",
platform: "linux",
backend: "inotify",
}
[09:37:27.318] INFO (#204162): project copy refresh started {
projectID: "global",
}
[09:37:27.320] INFO (#204162): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[09:37:27.359] INFO (#204053): booting location services {
directory: "/tmp/opencode-core-test-CStSy3",
workspaceID: undefined,
}
1149 pass
0 fail
3207 ...
Full Core passes: 1,149 tests, 0 failures, 3,207 assertions. I’m running independent package gates in parallel now, including Schema manifest, Core/Schema/SDK/Server/OpenCode typechecks, migration consistency, and repository diff checks.
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a) 2 pass 0 fail 23 expect() calls Ran 2 tests across 1 file. [295.00ms] $ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-check-dmCc7P/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-dmCc7P/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-dmCc7P/full/20260916094031_schema/migration.sql 🚀
(no output)
Every source gate passes: 1,149 Core tests, Schema manifest, five package typechecks, migration consistency, and diff checks. I’m building the exact Node SEA now; afterward I’ll run fresh isolated health, final-schema, and two-restart recovery smokes without touching the live service.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a997eff1001xtilN46wzS3hVi dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
The exact SEA build succeeded with only known chunk-size and note-injection warnings. I’m running a fresh disposable smoke that verifies authenticated health, the new wake-attempt columns/indexes, clean shutdown, and exactly-once recovery across two restarts.
drwxr-xr-x 2 byk byk 4096 Sep 16 01:38 /tmp/opencode /home/byk/.volta/bin/node /usr/bin/sqlite3
(no output) <shell_metadata> shell tool terminated command after exceeding timeout 60000 ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds. </shell_metadata>
The first smoke harness timed out without evidence, so I’m treating it as invalid. I’ll inspect only the disposable binary/port processes, terminate any surviving disposable server, and rerun with explicit child-error handling plus a hard harness timer; the live service remains untouched.
3829093 /usr/bin/bash -c pgrep -af '/tmp/opencode/session-recovery-final-v4|packages/opencode/dist/server/opencode-server|node -e' || true
No disposable process survived. The likely harness fault was unconsumed child stdout filling its pipe before shutdown; I’m rerunning with stdout discarded, stderr captured, and a hard timeout that always reports an exit or blocker.
{"error":"hard timeout","exit":{"code":0,"signal":null},"stderr":""}
{"error":"early exit","exit":{"code":0,"signal":null},"stderr":""}
The server stayed alive and shut down cleanly, but the harness never saw a successful health response. I’m inspecting the disposable database and rerunning with consumed stdout plus the last HTTP status/body or socket error reported; this isolates startup/listening from auth or route failure.
account migration session_input account_state part session_message control_account permission session_recovery credential project session_share data_migration project_directory todo event session workspace event_sequence session_context_epoch message session_execution
Migrations completed, so startup reached the database. I’m running one diagnostic launch with stdout captured and the last health probe result reported, then I’ll use the discovered address behavior for the final smoke.
{"last":{"error":"TypeError: fetch failed"},"exit":{"code":null,"signal":"SIGTERM"},"stdout":"","stderr":""}
The binary starts and migrates but never binds within five seconds, then exits by signal because the diagnostic loop was intentionally short. Since stderr/stdout are empty, I’m checking startup flags and plugin initialization for a network wait caused by the disposable gateway URL; the final smoke will disable only optional startup integrations, not alter server behavior under test.
Found 86 matches
/home/byk/Code/opencode/packages/opencode/src/effect/runtime-flags.ts:
Line 19: disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
Line 20: disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
Line 21: disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
Line 22: disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD"),
Line 24: broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
Line 25: direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
Line 28: broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
Line 29: direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"),
/home/byk/Code/opencode/packages/opencode/src/share/share-next.ts:
Line 23: const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
/home/byk/Code/opencode/packages/opencode/src/cli/upgrade.ts:
Line 10: if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) ...
The prior successful smoke used isolated state and no actual Lore plugin; this candidate likely inherits the workspace’s plugin config unless project config is disabled. I’m rerunning with default plugins, project config, external skills, autoupdate, and LSP downloads disabled in the disposable environment, while preserving the same server and recovery code paths.
(no output) <shell_metadata> shell tool terminated command after exceeding timeout 45000 ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds. </shell_metadata>
The isolation flags did not change readiness, and the harness itself again outlived its deadline while the child handled shutdown. I’m checking for surviving disposable processes and reading the server bootstrap path to find the pre-listen gate rather than extending timeouts blindly.
3833065 /usr/bin/bash -c pgrep -af '41988|session-recovery-final-v4|packages/opencode/dist/server/opencode-server|node -e' || true
<path>/home/byk/Code/opencode/packages/opencode/src/node-server.ts</path>
<type>file</type>
<content>
1: #!/usr/bin/env node
2:
3: import { Flag } from "@opencode-ai/core/flag/flag"
4: import { InstallationVersion } from "@opencode-ai/core/installation/version"
5: import { existsSync } from "node:fs"
6: import { Module } from "node:module"
7: import path from "node:path"
8: import { fileURLToPath } from "node:url"
9: import { parseArgs } from "node:util"
10: import { Server } from "./server/server"
11:
12: type ResolveContext = { parentURL?: string }
13: type NextResolve = (specifier: string, context: ResolveContext) => unknown
14: type ResolveHook = (specifier: string, context: ResolveContext, nextResolve: NextResolve) => unknown
15: const registerHooks = (
16: Module as typeof Module & { registerHooks(hooks: { resolve: ResolveHook }): void }
17: ).registerHooks
18:
19: const help = `opencode-server ${InstallationVersion}
20:
21: Usage: opencode-server [options]
22:
23: ...
Found 5 matches
/home/byk/Code/opencode/packages/opencode/src/server/server.ts:
Line 73: export async function listen(opts: ListenOptions): Promise<Listener> {
Line 74: const listener = await Effect.runPromise(listenEffect(opts))
Line 83: const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unknown> = Effect.fn("Server.listen")(
Line 87: const listenerUrl = makeURL(opts.hostname, address.port)
Line 100: function listenerLayer(opts: ListenOptions, port: number) {
<path>/home/byk/Code/opencode/packages/opencode/src/server/server.ts</path>
<type>file</type>
<content>
60: request(input, init) {
61: return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
62: },
63: }
64: return { app }
65: })
66:
67: export async function openapi() {
68: return OpenApi.fromApi(PublicApi)
69: }
70:
71: export let url: URL | undefined
72:
73: export async function listen(opts: ListenOptions): Promise<Listener> {
74: const listener = await Effect.runPromise(listenEffect(opts))
75: return {
76: hostname: listener.hostname,
77: port: listener.port,
78: url: listener.url,
79: stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined),
80: }
81: }
82:
83: const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unknown> = Effect.fn("Server.listen")(
84: function* (opts: ListenOptions) {
85: const state = yield* ...
The harness bug is now clear: an individual fetch() can connect to a bound socket and then wait forever, so the outer deadline never runs. I’m adding a one-second abort signal per probe. This will distinguish “not listening” from “listening but request graph blocked,” and it guarantees bounded cleanup.
{"last":{"status":200,"body":"{\"healthy\":true,\"version\":\"1.18.30\"}"},"exit":{"code":0,"signal":null},"db":"/tmp/opencode/session-recovery-final-v4/opencode.db","stdout":"opencode server listening on http://127.0.0.1:41989/\n","stderr":""}
The corrected bounded harness passes authenticated health and clean shutdown: HTTP 200, version 1.18.30, exit code 0, empty stderr. I’m verifying the final wake-attempt schema and indexes, then seeding one expired abandoned claim and confirming exactly one continuation across two restarts.
[{"name":"session_input","sql":"CREATE TABLE `session_input` (\n `id` text PRIMARY KEY,\n `session_id` text NOT NULL,\n `prompt` text NOT NULL,\n `delivery` text NOT NULL,\n `admitted_seq` integer NOT NULL,\n `promoted_seq` integer,\n `wake_pending` integer DEFAULT false NOT NULL,\n `wake_attempts` integer DEFAULT 0 NOT NULL,\n `time_created` integer NOT NULL,\n CONSTRAINT `fk_session_input_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE\n )"},
{"name":"session_input_wake_pending_promoted_attempts_time_created_id_idx","sql":"CREATE INDEX `session_input_wake_pending_promoted_attempts_time_created_id_idx` ON `session_input` (`wake_pending`,`promoted_seq`,`wake_attempts`,`time_created`,`id`)"},
{"name":"session_recovery","sql":"CREATE TABLE `session_recovery` (\n `session_id` text NOT NULL,\n `execution_id` text NOT NULL,\n ...
(no output)
The final schema contains both counters and covering indexes. I’m seeding a minimal valid project/Session plus one expired ready claim using the migrated disposable database, then starting the same binary twice with the bounded probe.
1|0|0|0
The seed is valid: 1/0/0/0 execution/recovery/input/event. I’m running restart one, waiting 750ms after health for recovery and promotion, then shutting down cleanly and inspecting exact durable state.
{"last":{"status":200,"body":"{\"healthy\":true,\"version\":\"1.18.30\"}"},"exit":{"code":0,"signal":null},"stdout":"opencode server listening on http://127.0.0.1:41990/\n","stderr":""}
[{"executions":0,"recoveries":1,"inputs":1,"messages":1,"events":1}]
[{"execution_id":"recovery-final-v4","session_id":"ses_recovery_final_v4","continuation_message_id":"msg_recovery_continue_recovery-final-v4","phase":"ready","wake_pending":1,"wake_attempts":1,"recovery_error":null}]
[{"id":"msg_recovery_continue_recovery-final-v4","prompt":"{\"text\":\"continue\"}","delivery":"queue","promoted_seq":null,"wake_pending":0,"wake_attempts":0}]
Restart one atomically reconciled the claim but shutdown occurred before the advisory drain completed: one recovery row/input/event exists, wake_pending=1, and wake_attempts=1. This is a useful crash-window state. Restart two will poll the disposable database until the outbox settles, proving durable redrive rather than relying on a fixed delay.
{"last":{"status":200,"body":"{\"healthy\":true,\"version\":\"1.18.30\"}"},"exit":{"code":0,"signal":null},"stdout":"opencode server listening on http://127.0.0.1:41991/\n","stderr":""}
Restart two did not settle the outbox within 30 seconds; the harness reported health and clean shutdown but no settled state, so this is a real smoke failure until explained. I’m inspecting the durable rows and server logs/database state now, then tracing why the exact recovery input was not promoted despite repeated wake attempts.
[{"execution_id":"recovery-final-v4","session_id":"ses_recovery_final_v4","continuation_message_id":"msg_recovery_continue_recovery-final-v4","phase":"ready","wake_pending":1,"wake_attempts":4,"recovery_error":null}]
[{"id":"msg_recovery_continue_recovery-final-v4","session_id":"ses_recovery_final_v4","prompt":"{\"text\":\"continue\"}","delivery":"queue","promoted_seq":null,"wake_pending":0,"wake_attempts":0}]
[{"id":"msg_recovery_recovery-final-v4","session_id":"ses_recovery_final_v4","seq":0,"type":"synthetic","data":"{\"time\":{\"created\":1789552287331},\"sessionID\":\"ses_recovery_final_v4\",\"text\":\"Session execution interrupted by server restart\"}"}]
[{"aggregate_id":"ses_recovery_final_v4","seq":0,"type":"session.next.step.recovered.1","data":"{\"timestamp\":1789552287331,\"sessionID\":\"ses_recovery_final_v4\",\"executionID\":\"recovery-final-v4\",\"phase\":\"ready\"}"}]
Repeated attempt advancement proves the recovery scheduler is live; execution fails before promotion and clears its claim each time. The seeded Session points at a workspace directory that does not exist, unlike the earlier successful smoke. I’m creating only that disposable directory and restarting; if promotion then succeeds, the failure is fixture invalidity rather than candidate behavior.
drwxr-xr-x 7 byk byk 4096 Sep 16 09:52 /tmp/opencode/session-recovery-final-v4
The missing disposable workspace was the only invalid fixture element found. I’m rerunning restart two with that directory present and polling for exact outbox settlement.
{"last":{"status":200,"body":"{\"healthy\":true,\"version\":\"1.18.30\"}"},"exit":{"code":0,"signal":null},"stdout":"opencode server listening on http://127.0.0.1:41992/\n","stderr":""}
The valid workspace still did not settle, so the smoke now indicates an execution-layer problem or another incomplete fixture field. Wake attempts continue, but no durable claim remains to reveal the failure. I’m inspecting disposable logs and the Session/Location resolution requirements, then I’ll reproduce one wake through the in-process test layer if the standalone logger remains silent.
/tmp/opencode/session-recovery-final-v4/opencode.db /tmp/opencode/session-recovery-final-v4/data/opencode/log/opencode.log /tmp/opencode/session-recovery-final-v4/cache/opencode/models.json
<path>/tmp/opencode/session-recovery-final-v4/data/opencode/log/opencode.log</path>
<type>file</type>
<content>
1: timestamp=2026-09-16T09:49:58.495Z level=INFO run=c18865fd message="disposing all instances"
2: timestamp=2026-09-16T09:51:27.392Z level=ERROR run=0af9ae16 message="Failed to drain Session" cause="PlatformError: NotFound: FileSystem.realPath (/tmp/opencode/session-recovery-final-v4/workspace)\n at _3 (/home/byk/Code/opencode/packages/opencode/.fossilize/server.cjs:74:18419)\n at /home/byk/Code/opencode/packages/opencode/.fossilize/server.cjs:81:616\n at /home/byk/Code/opencode/packages/opencode/.fossilize/server.cjs:30:87345\n at gotStat (node:fs:2942:21)\n at FSReqCallback.oncomplete (node:fs:195:21)\n at Server.listen (/home/byk/Code/opencode/packages/opencode/.fossilize/server.cjs:6022:17298)\n at Server.listen (definition) (/home/byk/Code/opencode/packages/opencode/.fossilize/server.cjs:6022:19915) {\n [cause]: Error: ENOENT: no such file or ...
The logs isolate the remaining smoke failure to fixture data, not recovery: after creating the workspace, the runner reaches location services and then stops with ModelNotSelectedError. I’ll seed a valid model selection on the disposable Session, using an intentionally unreachable provider so promotion can commit before any provider call; then restart and verify the exact Prompted/outbox state.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/model.ts</path>
<type>file</type>
<content>
1: export * as SessionRunnerModel from "./model"
2:
3: import { makeLocationNode } from "../../effect/app-node"
4: import { type Model } from "@opencode-ai/llm"
5: import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages"
6: import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat"
7: import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses"
8: import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
9: import { Context, Effect, Layer, Schema } from "effect"
10: import { produce } from "immer"
11: import { Catalog } from "../../catalog"
12: import { Credential } from "../../credential"
13: import { Integration } from "../../integration"
14: import { ModelV2 } from "../../model"
15: import { ProviderV2 } from "../../provider"
16: import { SessionSchema } from "../schema"
17:
18: export class ...
No files found
<path>/home/byk/Code/opencode/packages/core/src/session/schema.ts</path>
<type>file</type>
<content>
1: export * as SessionSchema from "./schema"
2:
3: import { Session } from "@opencode-ai/schema/session"
4:
5: export const ID = Session.ID
6: export type ID = typeof ID.Type
7:
8: export const Info = Session.Info
9: export type Info = Session.Info
(End of file - total 9 lines)
</content>
Found 11 matches
/home/byk/Code/opencode/packages/schema/src/v1/session.ts:
Line 8: import { Model } from "../model"
Line 210: model: Schema.optional(
Line 213: modelID: Model.ID,
Line 347: model: Schema.Struct({
Line 349: modelID: Model.ID,
Line 443: model: Schema.optional(
Line 446: modelID: Model.ID,
Line 462: modelID: Model.ID,
Line 557: model: optional(SessionModel),
/home/byk/Code/opencode/packages/schema/src/session.ts:
Line 6: import { Model } from "./model"
Line 24: model: Model.Ref.pipe(optional),
<path>/home/byk/Code/opencode/packages/schema/src/session.ts</path>
<type>file</type>
<content>
1: export * as Session from "./session"
2:
3: import { Schema } from "effect"
4: import { Agent } from "./agent"
5: import { Location } from "./location"
6: import { Model } from "./model"
7: import { Project } from "./project"
8: import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema"
9: import { SessionEvent } from "./session-event"
10: import { SessionID } from "./session-id"
11: import { Revert } from "./revert"
12:
13: export const ID = SessionID
14: export type ID = SessionID
15:
16: export const Event = SessionEvent
17:
18: export interface Info extends Schema.Schema.Type<typeof Info> {}
19: export const Info = Schema.Struct({
20: id: ID,
21: parentID: ID.pipe(optional),
22: projectID: Project.ID,
23: agent: Agent.ID.pipe(optional),
24: model: Model.Ref.pipe(optional),
25: cost: Schema.Finite,
26: tokens: Schema.Struct({
27: input: ...
Found 3 matches /home/byk/Code/opencode/packages/core/src/session.ts: Line 145: model: ModelV2.Ref Line 230: model: input.model Line 415: model: input.model,
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path>
<type>file</type>
<content>
205: )
206:
207: const result = Service.of({
208: create: Effect.fn("V2Session.create")(function* (input) {
209: const sessionID = input.id ?? SessionSchema.ID.create()
210: const recorded = yield* store.get(sessionID)
211: if (recorded) return recorded
212: const project = yield* projects.resolve(input.location.directory)
213: yield* db
214: .insert(ProjectTable)
215: .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
216: .onConflictDoNothing()
217: .run()
218: .pipe(Effect.orDie)
219: const now = Date.now()
220: const info = SessionV1.SessionInfo.make({
221: id: sessionID,
222: slug: Slug.create(),
223: version: InstallationVersion,
224: projectID: project.id,
225: directory: ...
Found 1 matches
/home/byk/Code/opencode/packages/core/src/session/sql.ts:
Line 53: model: text({ mode: "json" }).$type<{
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
40: summary_deletions: integer(),
41: summary_files: integer(),
42: summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
43: metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
44: cost: real().notNull().default(0),
45: tokens_input: integer().notNull().default(0),
46: tokens_output: integer().notNull().default(0),
47: tokens_reasoning: integer().notNull().default(0),
48: tokens_cache_read: integer().notNull().default(0),
49: tokens_cache_write: integer().notNull().default(0),
50: revert: text({ mode: "json" }).$type<Revert.State>(),
51: permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
52: agent: text(),
53: model: text({ mode: "json" }).$type<{
54: id: string
55: providerID: string
56: variant?: string
57: }>(),
58: ...Timestamps,
59: time_compacting: ...
Promotion commits before model resolution, so the fixture only needs an explicit model reference; it need not be available or contact a provider. I’m setting a nonexistent disposable model, which will make the runner fail after promotion, then verifying the recovery outbox settles exactly once.
{"id":"smoke-model","providerID":"smoke-provider"}
{"last":{"status":200,"body":"{\"healthy\":true,\"version\":\"1.18.30\"}"},"exit":{"code":0,"signal":null},"stdout":"opencode server listening on http://127.0.0.1:41993/\n","stderr":""}
The explicit model still did not settle, so promotion is not reached before model availability checks in the actual startup path or the synthetic Session lacks another required projection. I’m reading the newest disposable log entry and current durable state; I will not claim the restart smoke passed unless the exact continuation is promoted.
<path>/tmp/opencode/session-recovery-final-v4/data/opencode/log/opencode.log</path> <type>file</type> <content> 8: timestamp=2026-09-16T09:53:44.437Z level=INFO run=14930143 message="project copy refresh started" projectID=global 9: timestamp=2026-09-16T09:53:44.452Z level=INFO run=14930143 message="project copy refresh done" projectID=global updated=[] removed=[] 10: timestamp=2026-09-16T09:53:44.598Z level=INFO run=14930143 message="booting location services" directory=/tmp/opencode/session-recovery-final-v4/workspace workspaceID=undefined 11: timestamp=2026-09-16T09:53:44.738Z level=ERROR run=14930143 message="Failed to drain Session" cause="SessionRunnerModel.ModelNotSelectedError: No model is available for session ses_recovery_final_v4\n at Object.<anonymous> (/home/byk/Code/opencode/packages/opencode/.fossilize/server.cjs:519:23048)\n at SessionRunnerModel.resolve (/home/byk/Code/opencode/packages/opencode/.fossilize/server.cjs:2852:2871)\n at SessionRunnerModel.resolve ...
The latest run logged no drain failure at all, which means the recovery scanner likely did not select the row during its 10-second cadence before the harness deadline or another scheduler instance owned timing. I’m reading wake_attempts, execution claims, and event counts to distinguish “not selected” from “selected then failed silently.”
[{"execution_id":"recovery-final-v4","wake_pending":0,"wake_attempts":5,"recovery_error":null}]
[{"id":"msg_recovery_continue_recovery-final-v4","promoted_seq":1,"wake_pending":0,"wake_attempts":0}]
[{"aggregate_id":"ses_recovery_final_v4","seq":0,"type":"session.next.step.recovered.1"},
{"aggregate_id":"ses_recovery_final_v4","seq":1,"type":"session.next.prompted.1"}]
The database confirms the restart succeeded despite the harness omitting its settled-state field: wake_pending=0, promoted_seq=1, one Prompted event, and no execution claim. I’m running one final restart and asserting the exact counts remain unchanged, then recording the artifact hash and checking no disposable server survived.
{"last":{"status":200,"body":"{\"healthy\":true,\"version\":\"1.18.30\"}"},"exit":{"code":0,"signal":null},"stdout":"opencode server listening on http://127.0.0.1:41994/\n","stderr":""}
[{"executions":0,"recoveries":1,"inputs":1,"messages":2,"events":2}]
[{"execution_id":"recovery-final-v4","continuation_message_id":"msg_recovery_continue_recovery-final-v4","phase":"ready","wake_pending":0,"wake_attempts":5,"recovery_error":null}]
[{"id":"msg_recovery_continue_recovery-final-v4","prompt":"{\"text\":\"continue\"}","delivery":"queue","promoted_seq":1,"wake_pending":0,"wake_attempts":0}]
56c1bc48b6e21397db05eb0d41f73d243a4b64da093fcfbe209baed65457f6e8 packages/opencode/dist/server/opencode-server
3843485 /usr/bin/bash -c node -e 'const {spawn}=require("node:child_process"); const path=require("node:path"); const root="/tmp/opencode/session-recovery-final-v4"; const out=[],err=[]; const ...
The final restart is idempotent: exactly 0/1/1/2/2 execution/recovery/input/message/event rows, one promoted queued continue, settled recovery outbox, and no disposable server survived. Artifact SHA-256 is 56c1bc48b6e21397db05eb0d41f73d243a4b64da093fcfbe209baed65457f6e8. Two test-only isolation edits followed the earlier full suite, so I’m rerunning full Core once on the exact final tree before freezing.
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[10:02:12.082] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
[10:03:34.667] INFO (#204054): watcher backend {
directory: "/tmp/opencode-core-test-JLFOGR",
platform: "linux",
backend: "inotify",
}
[10:03:34.676] INFO (#204054): booting location services {
directory: "/tmp/opencode-core-test-JLFOGR",
workspaceID: undefined,
}
1149 pass
0 fail
3207 expect() calls
Ran 1149 tests across 145 files. [95.86s]
The exact final tree passes full Core again: 1,149 tests, 0 failures, 3,207 assertions. I’m freezing a package-only patch against ab9408c81c, including the two untracked Core modules and excluding unrelated .lore.md, AGENTS.md, and profiling files; then I’ll verify clean application and launch fresh independent reviews.
c2c8afb07a7b6a49c1d40c5515d718ad5a934c9291e80a068693259c26c566a0 /tmp/opencode/session-recovery-final-v4.patch 56c1bc48b6e21397db05eb0d41f73d243a4b64da093fcfbe209baed65457f6e8 packages/opencode/dist/server/opencode-server packages/core/schema.json packages/core/src/database/migration.gen.ts packages/core/src/database/migration/20260914170650_session-recovery.ts packages/core/src/database/schema.gen.ts packages/core/src/session.ts packages/core/src/session/compaction.ts packages/core/src/session/context-epoch.ts packages/core/src/session/execution/claim.ts packages/core/src/session/execution/local.ts packages/core/src/session/input.ts packages/core/src/session/message-updater.ts packages/core/src/session/projector.ts packages/core/src/session/recovery-id.ts packages/core/src/session/recovery.ts packages/core/src/session/run-coordinator.ts packages/core/src/session/runner/index.ts packages/core/src/session/runner/llm.ts packages/core/src/session/runner/publish-llm-event.ts packages/cor...
The new freeze contains exactly 33 package files, 5,741 insertions and 383 deletions. Patch SHA-256 is c2c8afb07a7b6a49c1d40c5515d718ad5a934c9291e80a068693259c26c566a0; SEA hash remains 56c1bc48b6e21397db05eb0d41f73d243a4b64da093fcfbe209baed65457f6e8. I’m verifying the patch applies cleanly to the immutable base before reviews start.
drwxrwxr-x 1 byk byk 4444 Sep 16 09:32 /home/byk/Code Preparing worktree (detached HEAD ab9408c81c) Updating files: 87% (5784/6637) Updating files: 88% (5841/6637) Updating files: 89% (5907/6637) Updating files: 90% (5974/6637) Updating files: 91% (6040/6637) Updating files: 92% (6107/6637) Updating files: 93% (6173/6637) Updating files: 94% (6239/6637) Updating files: 95% (6306/6637) Updating files: 96% (6372/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density error: patch failed: packages/core/schema.json:1 error: packages/core/schema.json: patch does not apply error: patch failed: packages/core/src/database/migration.gen.ts:40 error: packages/core/src/database/migration.gen.ts: patch does not apply error: packages/core/src/database/migration/20260914170650_session-recovery.ts: already ...
The apply check ran in the main worktree because git worktree add does not change shell cwd; --check changed nothing. I’m rerunning the exact patch check from the clean sibling worktree, then removing it.
(no output)
(no output)
The immutable patch applies cleanly to base ab9408c81c; the mistaken main-worktree check was read-only. Source is frozen now. I’m launching fresh, separate correctness and security reviews against the exact patch/artifact hashes, with explicit attention to the four prior blockers and bounded fairness state.
<task id="ses_f5652b564ffeo2MHuctM39VLxA" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f565260b4ffekzIx7hI7mgPLaX" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The revised candidate is frozen and both final reviews are running against the exact patch and SEA hashes. Validation is complete, including fail-first liveness mutations, 1,149 Core tests, five package typechecks, migration/schema checks, authenticated SEA startup, crash-window redrive, and idempotent restart; the live service remains untouched.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security ...
Candidate frozen pending independent correctness and security reviews.
c2c8afb07a7b6a49c1d40c5515d718ad5a934c9291e80a068693259c26c566a056c1bc48b6e21397db05eb0d41f73d243a4b64da093fcfbe209baed65457f6e8Cancellation can expire into recovery while interruption cleanup is still running. SessionExecutionLocal races the runner against the heartbeat, so interrupting the owner stops both (packages/core/src/session/execution/local.ts:62-67). Interrupted drains preserve unknown and continue claims (packages/core/src/session/execution/local.ts:73-96). The coordinator waits for Fiber.interrupt(owner) before running claim cleanup (packages/core/src/session/run-coordinator.ts:107-108), and cleanup silently accepts a lost claim (packages/core/src/session/execution/local.ts:103-116). A finalizer lasting beyond the 30-second lease lets the recovery scanner take ownership (packages/core/src/session/recovery.ts:70-91), publish Step.Recovered (packages/core/src/session/recovery.ts:123-163), and enqueue a wake. When cleanup finishes, the old clear is ignored and the coordinator starts the queued successor. The cancelled Session then resumes. This is elapsed cleanup time, not the declared wall-clock-skew residual. The cancellation test blocks a finalizer but never advances past lease expiry or runs recovery (packages/core/test/session-execution-local.test.ts:200-238).
A failed provider stream can still commit Step.Ended, including phase continue. Hosted-tool reconciliation immediately before settlement only runs when stream._tag === "Success" (packages/core/src/session/runner/llm.ts:395-398). The code then commits Step.Ended whenever an in-memory step settlement exists and no provider-error event was observed (packages/core/src/session/runner/llm.ts:399-433), and propagates stream failure only afterward (packages/core/src/session/runner/llm.ts:436-438). A stream that emits step-finish and then dies can therefore bypass hosted-tool reconciliation and commit continuation. ...
Correctness review rejected the freeze with two concrete lifecycle bugs: cancellation can outlive its lease and be recovered before cleanup finishes, and a stream that emits step-finish then fails can still commit Step.Ended. I’m reproducing both with deterministic regressions before changing production code; the current security review may still yield useful findings, but its verdict will become stale once source changes.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "in_progress",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Fix final correctness review blockers",
"status": "in_progress",
...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, Duration, Effect, Exit, Layer, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { SessionExecutionClaim } from "./claim"
13: import { SESSION_EXECUTION_LEASE_MS, SessionExecutionTable, SessionRecoveryTable } from "../sql"
14:
15: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. ...
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
70: }
71:
72: const run = (key: Key): Effect.Effect<void, E> =>
73: Effect.uninterruptibleMask((restore) => {
74: const entry = active.get(key)
75: if (entry !== undefined) {
76: if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
77: return restore(Deferred.await(entry.done))
78: }
79:
80: const next = makeEntry()
81: active.set(key, next)
82: start(key, next, true)
83: return restore(Deferred.await(next.done))
84: })
85:
86: const wake = (key: Key) =>
87: Effect.sync(() => {
88: const entry = active.get(key)
89: if (entry !== undefined) {
90: entry.pendingWake = true
91: return
92: }
93:
94: const next = makeEntry()
95: active.set(key, next)
96: start(key, ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
340: Effect.ensuring(withPublication(publisher.flush())),
341: )
342:
343: return yield* Effect.uninterruptibleMask((restore) =>
344: Effect.gen(function* () {
345: yield* updateExecution({ phase: "unknown", assistant_message_id: null })
346: const stream = yield* restore(providerStream).pipe(Effect.exit)
347: const failure =
348: stream._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(stream.cause)) : undefined
349: if (
350: recoverOverflow &&
351: !publisher.hasAssistantStarted() &&
352: isContextOverflowFailure(overflowFailure ?? failure) &&
353: (yield* restore(
354: recoverOverflow({
355: sessionID: session.id,
356: entries,
357: model,
358: request,
359: ...
Found 3 matches /home/byk/Code/opencode/packages/core/src/session.ts: Line 432: Effect.uninterruptible(execution.interrupt(sessionID)), /home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts: Line 107: yield* Fiber.interrupt(owner) /home/byk/Code/opencode/packages/core/src/session/execution/local.ts: Line 106: return coordinator.interrupt(
Both findings are valid. The smallest fix is to keep renewing the exact claim in a scoped fiber for the entire explicit-interrupt operation, including runner finalizers and claim cleanup, while changing Step settlement so any stream failure is propagated before hosted-tool reconciliation or Step.Ended. I’m adding deterministic tests for lease-expiry cleanup and step-finish followed by stream failure, plus correcting the event-version assertion.
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
3: import { TestClock } from "effect/testing"
4: import { eq } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { Project } from "@opencode-ai/core/project"
7: import { ProjectTable } from "@opencode-ai/core/project/sql"
8: import { AbsolutePath } from "@opencode-ai/core/schema"
9: import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
10: import { SessionSchema } from "@opencode-ai/core/session/schema"
11: import { SessionExecutionTable, SessionTable } from "@opencode-ai/core/session/sql"
12: import { testEffect } from "./lib/effect"
13:
14: const sessionID = SessionSchema.ID.make("ses_execution_local")
15: const it = testEffect(Database.layerFromPath(":memory:"))
16:
17: const ...
Found 68 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 72: let streamFailure: LLMError | undefined
Line 89: const events = streamFailure
Line 90: ? Stream.fail(streamFailure)
Line 349: streamFailure = undefined
Line 452: LLMEvent.stepFinish({ index: 0, reason: "stop" }),
Line 472: LLMEvent.stepFinish({ index: 0, reason: "stop" }),
Line 621: LLMEvent.stepFinish({ index: 0, reason: "stop" }),
Line 658: LLMEvent.stepFinish({ index: 0, reason: "stop" }),
Line 680: LLMEvent.stepFinish({ index: 0, reason: "stop" }),
Line 709: LLMEvent.stepFinish({ index: 0, reason: "stop" }),
Line 794: LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
Line 904: LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
Line 1839: LLMEvent.stepFinish({
Line 1911: LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
Line ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
50: SessionRecoveryTable,
51: SessionTable,
52: } from "@opencode-ai/core/session/sql"
53: import { SessionStore } from "@opencode-ai/core/session/store"
54: import { SystemContext } from "@opencode-ai/core/system-context"
55: import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
56: import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
57: import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
58: import { ModelV2 } from "@opencode-ai/core/model"
59: import { Location } from "@opencode-ai/core/location"
60: import { ProviderV2 } from "@opencode-ai/core/provider"
61: import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
62: import { asc, eq, sql } from "drizzle-orm"
63: import { testEffect } from "./lib/effect"
64:
65: const requests: LLMRequest[] = []
66: const executionID = ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1870: status: "error",
1871: input: { path: "README.md" },
1872: error: { type: "unknown", message: "Denied" },
1873: },
1874: },
1875: {
1876: type: "tool",
1877: id: "call-provider",
1878: name: "web_search",
1879: provider: { executed: true, metadata: { fake: { source: "provider" } } },
1880: state: {
1881: status: "completed",
1882: input: { query: "hello" },
1883: structured: {},
1884: content: [
1885: { type: "text", text: "Hello" },
1886: { type: "file", mime: "image/png", uri: "data:image/png;base64,aGVsbG8=", name: "hello.png" },
1887: ],
1888: },
1889: },
1890: ...
Found 4 matches
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line 220: const failAssistant = Effect.fnUntraced(function* (message: string) {
Line 451: yield* failAssistant(event.message)
Line 459: failAssistant,
Line 464: hasProviderError: () => providerFailed,
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
205: })
206:
207: const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
208: const tool = tools.get(event.id)
209: if (!tool) return yield* Effect.die(`Tool input end before start: ${event.id}`)
210: if (tool.name !== event.name)
211: return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
212: if (tool.inputEnded) return yield* Effect.die(`Duplicate tool input end: ${event.id}`)
213: yield* toolInput.end(event.id)
214: })
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* ...
Found 8 matches
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 305: Effect.repeat(Schedule.spaced("10 seconds")),
/home/byk/Code/opencode/packages/core/src/models-dev.ts:
Line 256: // Schedule.spaced runs the effect once, then waits between completions.
Line 257: yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
/home/byk/Code/opencode/packages/core/src/tool-output-store.ts:
Line 203: yield* store.cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped)
/home/byk/Code/opencode/packages/core/src/session/execution/local.ts:
Line 65: Effect.repeat(Schedule.spaced(Duration.millis(leaseDuration / 3))),
/home/byk/Code/opencode/packages/core/src/integration.ts:
Line 364: yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope))
/home/byk/Code/opencode/packages/core/src/util/effect-flock.ts:
Line 50: ...
I’m writing the regressions first and will run them against the rejected freeze to prove both failures. The cancellation regression advances the test clock beyond the 30-second lease while cleanup is blocked; the stream regression emits Step.Finished and then fails, asserting that no v2 Step.Ended commits.
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) test/session-execution-local.test.ts: (fail) SessionExecutionLocal > settles cancellation before starting a pending successor [5000.11ms] ^ this test timed out after 5000ms. 0 pass 8 filtered out 1 fail 1 expect() calls Ran 1 test across 1 file. [6.31s]
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
2019 | yield* db
2020 | .select({ type: EventTable.type })
2021 | .from(EventTable)
2022 | .where(eq(EventTable.type, "session.next.step.ended.2"))
2023 | .all(),
2024 | ).toEqual([])
^
error: expect(received).toEqual(expected)
- []
+ [
+ {
+ "type": "session.next.step.ended.2",
+ },
+ ]
- Expected - 1
+ Received + 5
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:2024:9
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
Both regressions fail on the frozen source as expected. I’m applying the minimal production changes now: an explicit-interrupt heartbeat independent of the interrupted runner, and failure propagation before any Step settlement. I’ll also make the cancellation test release its blocked finalizer before asserting, so its negative control fails cleanly instead of timing out.
Success. Updated the following files: M packages/core/src/session/execution/local.ts M packages/core/src/session/runner/llm.ts M packages/core/test/session-execution-local.test.ts
bun test v1.3.14 (0d9b296a) bun test v1.3.14 (0d9b296a) 1 pass 8 filtered out 0 fail 3 expect() calls Ran 1 test across 1 file. [1.65s] 2 pass 97 filtered out 0 fail 6 expect() calls Ran 2 tests across 1 file. [2.25s]
Both fixes pass their focused regressions. I’m tightening the cancellation boundary once more by requiring one synchronous renewal before interrupting the runner, so the cleanup heartbeat cannot start late; then I’ll address the review’s evidence gaps with explicit failing/no-op fairness, assistant-less supersession, restart-layer reconstruction, and schema-lineage checks before rerunning the recovery suite.
Success. Updated the following files: M packages/core/src/session/execution/local.ts
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
520: sessions,
521: (id, index) =>
522: events.publish(SessionEvent.Step.Recovered, {
523: sessionID: id,
524: timestamp: DateTime.makeUnsafe(index),
525: executionID: `poison-${index.toString().padStart(3, "0")}`,
526: phase: "ready",
527: }),
528: { discard: true, concurrency: 1 },
529: )
530: yield* db.run(sql`UPDATE session_input SET prompt = '{}' WHERE time_created < ${poisonCount}`)
531: const wakes: string[] = []
532: const execution = SessionExecution.Service.of({
533: ...recoveryExecution,
534: wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid),
535: })
536:
537: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution))
538: expect(wakes).toEqual([])
539: ...
<path>/home/byk/Code/opencode/packages/core/schema.json</path>
<type>file</type>
<content>
1: {
2: "version": "7",
3: "dialect": "sqlite",
4: "id": "539b8b9f-47d0-4d6d-b29f-4282139c71eb",
5: "prevIds": [
6: "797e6838-8a41-4793-932a-d200aa8d51fb"
7: ],
8: "ddl": [
9: {
10: "name": "workspace",
11: "entityType": "tables"
12: },
13: {
14: "name": "data_migration",
15: "entityType": "tables"
(Showing lines 1-15 of 2550. Use offset=16 to continue.)
</content>
<path>/home/byk/Code/opencode/packages/core/src/session/execution.ts</path>
<type>file</type>
<content>
1: export * as SessionExecution from "./execution"
2:
3: import { Context, Effect, Layer } from "effect"
4: import { LayerNode } from "../effect/layer-node"
5: import { Node } from "../effect/app-node"
6: import { SessionRunner } from "./runner/index"
7: import { SessionSchema } from "./schema"
8:
9: export interface Interface {
10: /** Snapshots active execution owned by this process. */
11: readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
12: /** Starts execution while idle or joins the active execution. */
13: readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
14: /** Registers newly recorded work. Repeated wakeups may coalesce. */
15: readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
16: /** Interrupt active work owned by this process. Idle interruption is a no-op. ...
The implementation blockers are fixed, and I’m closing the evidence concerns rather than leaving them for another rejection. I’ll update the fairness regression to use failing wakes and a reconstructed execution service, add the missing assistant-less supersession case, and correct schema.json lineage to the immutable base snapshot if the generator output confirms the mismatch.
abf93718acd0193fa2ca6cfc205cde8714eff23d7df42a4f093a55e9921fd1a3 -
{"id":"f14a9b18-8207-487e-a3d3-227e629ba9ad","prevIds":["169a0f0f-d58f-479f-b024-fa1c7b9a09db"]}
Found 1 matches /home/byk/Code/opencode/packages/core/script/migration.ts: Line 11: const snapshot = path.join(root, "packages/core/schema.json")
<path>/home/byk/Code/opencode/packages/core/script/migration.ts</path>
<type>file</type>
<content>
1: #!/usr/bin/env bun
2:
3: import { $ } from "bun"
4: import fs from "fs/promises"
5: import os from "os"
6: import path from "path"
7: import { pathToFileURL } from "url"
8: import { parseArgs } from "util"
9:
10: const root = path.resolve(import.meta.dirname, "../../..")
11: const snapshot = path.join(root, "packages/core/schema.json")
12: const tsDir = path.join(root, "packages/core/src/database/migration")
13: const registry = path.join(root, "packages/core/src/database/migration.gen.ts")
14: const schema = path.join(root, "packages/core/src/database/schema.gen.ts")
15: const args = parseArgs({
16: args: process.argv.slice(2),
17: options: {
18: check: { type: "boolean" },
19: name: { type: "string" },
20: },
21: })
22:
23: if (args.values.check) {
24: await check()
25: process.exit(0)
26: }
27:
28: await generate()
29:
30: async function generate() {
31: ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, gt, isNull, lte, or, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { SessionInput } from "./input"
13: import { SessionSchema } from "./schema"
14: import {
15: SESSION_EXECUTION_LEASE_MS,
16: SessionExecutionTable,
17: SessionInputTable,
18: SessionRecoveryTable,
19: SessionMessageTable,
20: } from "./sql"
21:
22: export const BATCH_SIZE = 100
23: const Phase = ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
285: return
286: }
287:
288: yield* db
289: .insert(SessionInputTable)
290: .values({
291: id: input.id,
292: session_id: input.sessionID,
293: prompt: encodePrompt(input.prompt),
294: delivery: input.delivery,
295: admitted_seq: input.promotedSeq,
296: promoted_seq: input.promotedSeq,
297: time_created: DateTime.toEpochMillis(input.timeCreated),
298: })
299: .run()
300: .pipe(Effect.orDie)
301: yield* settleRecoveryWake(db, input)
302: })
303:
304: const settleRecoveryWake = (
305: db: DatabaseService,
306: input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
307: ) =>
308: db
309: .update(SessionRecoveryTable)
310: .set({ wake_pending: false })
311: .where(
312: and(
313: eq(SessionRecoveryTable.session_id, input.sessionID),
314: ...
The review’s three concerns all correspond to evidence or metadata, not new production defects. I’m strengthening those now: failed wake attempts will be used across a reconstructed service boundary, assistant-less recovery will be superseded before scanner validation, and the squashed snapshot will point directly to base snapshot f14a9b18-....
Success. Updated the following files: M packages/core/schema.json M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 5 pass 137 filtered out 0 fail 17 expect() calls Ran 5 tests across 3 files. [3.19s] $ tsgo --noEmit Reading config file '/tmp/opencode-core-migration-check-8VhSPO/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-8VhSPO/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-8VhSPO/full/20260916104633_schema/migration.sql 🚀
All five new regressions pass, Core typecheck passes, and migration consistency accepts the corrected snapshot lineage. I’m running the complete recovery-owned suite now; if it stays green, I’ll rerun full Core and affected package gates before producing a new freeze.
bun test v1.3.14 (0d9b296a) 214 pass 0 fail 624 expect() calls Ran 214 tests across 8 files. [21.45s]
The complete recovery suite passes: 214 tests, 0 failures, 624 assertions. The rejected freeze’s correctness blockers and all three evidence concerns are addressed. I’m waiting for the original security review only for additional findings while running the full exact-source gates; its final verdict cannot approve this changed source.
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[10:47:55.265] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
1151 pass
0 fail
3213 expect() calls
Ran 1151 tests across 145 files. [92.43s]
[10:49:19.711] INFO (#204708): watcher backend {
directory: "/tmp/opencode-core-test-f6BIVz",
platform: "linux",
backend: "inotify",
}
[10:49:19.736] INFO (#204817): project copy refresh started {
projectID: "global",
}
[10:49:19.737] INFO (#204817): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[10:49:19.881] INFO (#204708): booting location services {
directory: ...
Full Core passes on the revised tree: 1,151 tests, 0 failures, 3,213 assertions. I’m rerunning Schema manifest, all five typechecks, migration consistency, and diff checks; the SEA must then be rebuilt because production source changed.
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a) 2 pass 0 fail 23 expect() calls Ran 2 tests across 1 file. [1100.00ms] $ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-check-qymLXR/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-qymLXR/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-qymLXR/full/20260916110033_schema/migration.sql 🚀
(no output)
All revised source gates pass. I’m rebuilding the exact SEA and repeating the isolated authenticated health plus recovery-redrive smoke; after that I’ll freeze v5 and launch new reviews against only that immutable candidate.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0a9e248d1001BdeEpagYLQcJSp dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
The v5 SEA build succeeded with the same known warnings. I’m using a fresh disposable database and the corrected bounded health harness, then I’ll seed a fully valid Session fixture from the start and verify first-restart promotion plus second-restart idempotence.
{"last":{"status":200,"body":"{\"healthy\":true,\"version\":\"1.18.30\"}"},"exit":{"code":0,"signal":null},"stdout":"opencode server listening on http://127.0.0.1:41995/\n","stderr":""}
f08e78a61ef53f72a9810cc09facbe2d181c63f6a14231a95d66d16de44a80ed packages/opencode/dist/server/opencode-server
Authenticated health and clean shutdown pass; v5 SEA SHA-256 is f08e78a61ef53f72a9810cc09facbe2d181c63f6a14231a95d66d16de44a80ed. I’m seeding the complete fixture now, including workspace and explicit model reference, so restart one must settle rather than exercise fixture failures.
1|0|0|0
</task_result> </task>
The original security review returned empty output and is disqualified. The v5 restart harness timed out with no recovery row observed, so I’m treating the smoke as failed and inspecting the disposable log/database before freezing. This may expose a startup cadence issue or a fixture timestamp/scan problem; no live service is involved.
[{"execution_id":"recovery-final-v5","phase":"ready","wake_pending":0,"wake_attempts":1,"recovery_error":null}]
[{"id":"msg_recovery_continue_recovery-final-v5","promoted_seq":1,"wake_pending":0,"wake_attempts":0}]
[{"aggregate_id":"ses_recovery_final_v5","seq":0,"type":"session.next.step.recovered.1"},
{"aggregate_id":"ses_recovery_final_v5","seq":1,"type":"session.next.prompted.1"}]
Direct inspection confirms v5 restart one succeeded: recovery settled, continuation promoted at sequence 1, and no execution claim remains. The harness’s state variable was empty because it sampled before the recovery row appeared, then missed the settled row during its loop. I’m running the idempotence restart and asserting exact durable counts directly afterward.
{"last":{"status":200,"body":"{\"healthy\":true,\"version\":\"1.18.30\"}"},"exit":{"code":0,"signal":null},"stdout":"opencode server listening on http://127.0.0.1:41997/\n","stderr":""}
[{"executions":0,"recoveries":1,"inputs":1,"messages":2,"events":2}]
[{"execution_id":"recovery-final-v5","continuation_message_id":"msg_recovery_continue_recovery-final-v5","phase":"ready","wake_pending":0,"wake_attempts":1,"recovery_error":null}]
[{"id":"msg_recovery_continue_recovery-final-v5","prompt":"{\"text\":\"continue\"}","delivery":"queue","promoted_seq":1,"wake_pending":0,"wake_attempts":0}]
[{"seq":0,"type":"session.next.step.recovered.1"},
{"seq":1,"type":"session.next.prompted.1"}]
f08e78a61ef53f72a9810cc09facbe2d181c63f6a14231a95d66d16de44a80ed packages/opencode/dist/server/opencode-server
3946703 /usr/bin/bash -c node -e 'const {spawn}=require("node:child_process"); const path=require("node:path"); const root="/tmp/opencode/session-recovery-final-v5"; const out=[],err=[]; const ...
The revised artifact passes authenticated startup, exact recovery promotion, and a duplicate-free second restart with counts 0/1/1/2/2. I’m freezing v5 now against the same immutable base, verifying clean application, then launching replacement correctness and security reviews; the empty security review is discarded.
49183f9e082f71a26d07ce8bf7dd2b391214908f3d07637a3c02163fe20826c7 /tmp/opencode/session-recovery-final-v5.patch f08e78a61ef53f72a9810cc09facbe2d181c63f6a14231a95d66d16de44a80ed packages/opencode/dist/server/opencode-server packages/core/schema.json | 573 ++++++- packages/core/src/database/migration.gen.ts | 1 + .../migration/20260914170650_session-recovery.ts | 55 + packages/core/src/database/schema.gen.ts | 45 + packages/core/src/session.ts | 9 +- packages/core/src/session/compaction.ts | 47 +- packages/core/src/session/context-epoch.ts | 88 +- packages/core/src/session/execution/claim.ts | 66 + packages/core/src/session/execution/local.ts | 143 +- packages/core/src/session/input.ts | 373 ++++- packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 78 +- ...
drwxrwxr-x 1 byk byk 4444 Sep 16 10:04 /home/byk/Code HEAD is now at ab9408c81c feat(app): adapt vertical tab density Preparing worktree (detached HEAD ab9408c81c) Updating files: 33% (2202/6637) Updating files: 34% (2257/6637) Updating files: 35% (2323/6637) Updating files: 36% (2390/6637) Updating files: 37% (2456/6637) Updating files: 38% (2523/6637) Updating files: 39% (2589/6637) Updating files: 40% (2655/6637) Updating files: 41% (2722/6637) Updating files: 42% (2788/6637) Updating files: 43% (2854/6637) Updating files: 44% (2921/6637) Updating files: 45% (2987/6637) Updating files: 46% (3054/6637) Updating files: 47% (3120/6637) Updating files: 48% (3186/6637) Updating files: 49% (3253/6637) Updating files: 50% (3319/6637) Updating files: 51% (3385/6637) Updating files: 52% (3452/6637) Updating files: 53% (3518/6637) Updating files: 54% (3584/6637) Updating files: 55% (3651/6637) Updating files: 56% (3717/6637) Updating files: 57% (3784/6637) Updating ...
(no output)
(no output)
v5 applies cleanly to the immutable base. Patch SHA-256 is 49183f9e082f71a26d07ce8bf7dd2b391214908f3d07637a3c02163fe20826c7; SEA SHA-256 is f08e78a61ef53f72a9810cc09facbe2d181c63f6a14231a95d66d16de44a80ed. I’m launching two fresh reviewers with explicit checks for the long-cleanup lease and post-finish stream-failure fixes.
<task id="ses_f5617b95effevhQWRr5uJQeDS7" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f56173d94ffeU51LJDjgNy05Gf" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Fix final correctness review blockers",
"status": "completed",
...
v5 is frozen and under fresh correctness and security review.
49183f9e082f71a26d07ce8bf7dd2b391214908f3d07637a3c02163fe20826c7f08e78a61ef53f72a9810cc09facbe2d181c63f6a14231a95d66d16de44a80ed</task_result> </task>
The correctness reviewer returned empty output, so it provides no evidence and cannot count toward the gate. I’m retrying that same reviewer once with the required evidence-or-blocker contract; a second empty result will disqualify and replace it.
<task id="ses_f5617b95effevhQWRr5uJQeDS7" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
The v5 correctness review returned empty output and is being retried once in the same reviewer session. Security review remains active; neither gate has approved the candidate yet.
</task_result> </task>
The correctness reviewer returned empty output twice and is disqualified. I’m replacing it with a fresh independent reviewer against the unchanged v5 hashes; the security review remains active.
it hit the same recall issue you hit earlier. I think you may tell subagents that recall is broken for now and they should be using http://127.0.0.1:3207/ui/search for it instead
I’m updating both active reviewers to avoid recall entirely. They will use the local Lore search UI at http://127.0.0.1:3207/ui/search with project=/home/byk/Code/opencode and scope=knowledge only when they need distilled project guidance; immutable Git objects and the frozen patch remain authoritative for code.
<task id="ses_f5607ee04ffes0gNrJz6W3wMx2" state="running"> <summary>Background task updated</summary> <task_result> Additional context sent to the running background task. The task is still working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you sent and end your response. </task_result> </task>
<task id="ses_f56173d94ffeU51LJDjgNy05Gf" state="running"> <summary>Background task updated</summary> <task_result> Additional context sent to the running background task. The task is still working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you sent and end your response. </task_result> </task>
Both active reviewers now have explicit instructions to avoid recall and use http://127.0.0.1:3207/ui/search when needed. Frozen code review still uses immutable Git objects and the v5 patch as the source of truth.
packages/core/src/session/execution/local.ts:108-112. ...Security review found two real blockers, so v5 is rejected and both review verdicts are now stale for approval. I’ll first add deterministic regressions for ownership loss during cancellation and for continuous fresh-input starvation plus same-Session write amplification, then make the smallest protocol changes that fail closed and bound scheduling work.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Fix final correctness review blockers",
"status": "completed",
...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, Duration, Effect, Exit, Layer, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { SessionExecutionClaim } from "./claim"
13: import { SESSION_EXECUTION_LEASE_MS, SessionExecutionTable, SessionRecoveryTable } from "../sql"
14:
15: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. ...
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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, runs cleanup, then starts work that arrived while stopping. */
14: readonly interrupt: (key: Key, cleanup?: Effect.Effect<void>) => Effect.Effect<void>
15: }
16:
17: type Entry<E> = {
18: ...
<skill_content name="effect">
# Skill: effect
# Effect
This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
## Source Of Truth
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
3. Also inspect existing repo code for local house style before introducing new patterns.
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
## Guidelines
- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
- Use `Effect.gen(function* () { ... })` for multi-step workflows.
- Use `Effect.fn("Name")` or ...
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
3: import { TestClock } from "effect/testing"
4: import { eq } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { Project } from "@opencode-ai/core/project"
7: import { ProjectTable } from "@opencode-ai/core/project/sql"
8: import { AbsolutePath } from "@opencode-ai/core/schema"
9: import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
10: import { SessionSchema } from "@opencode-ai/core/session/schema"
11: import { SessionExecutionTable, SessionTable } from "@opencode-ai/core/session/sql"
12: import { testEffect } from "./lib/effect"
13:
14: const sessionID = SessionSchema.ID.make("ses_execution_local")
15: const it = testEffect(Database.layerFromPath(":memory:"))
16:
17: const ...
The cancellation fix will race the coordinator cleanup against the fenced heartbeat and propagate ownership loss; it will no longer suppress a failed final clear. For ordinary redrive, row-level minimum attempts are the wrong ownership unit: I’m moving the scheduler counter to the Session, selecting at most 100 Sessions, and CAS-incrementing one Session row per wake so new prompts cannot reset priority and one Session cannot amplify a scan into unbounded writes.
Found 1 matches /home/byk/Code/opencode/packages/core/src/session/sql.ts: Line 23: export const SessionTable = sqliteTable(
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
1: import { sql } from "drizzle-orm"
2: import { check, sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
3: import * as DatabasePath from "../database/path"
4: import { ProjectTable } from "../project/sql"
5: import type { SessionMessage } from "./message"
6: import type { Prompt } from "./prompt"
7: import type { SessionInput } from "./input"
8: import type { Snapshot } from "../snapshot"
9: import { PermissionV1 } from "../v1/permission"
10: import { ProjectV2 } from "../project"
11: import type { SessionSchema } from "./schema"
12: import type { MessageID, PartID, SessionV1 } from "../v1/session"
13: import { WorkspaceV2 } from "../workspace"
14: import { Timestamps } from "../database/schema.sql"
15: import type { SystemContext } from "../system-context/index"
16: import { AgentV2 } from "../agent"
17: import type { Revert } from ...
I’m encoding both review findings as failing-first tests before changing the schema or control flow. The cancellation test will steal the claim during a blocked finalizer and require interrupt() to fail; the scheduling tests will inject fresh zero-attempt prompts between scans and create a large same-Session cohort, requiring later Sessions to advance and each scan to mutate only one scheduler row per Session.
Found 9 matches
/home/byk/Code/opencode/packages/core/src/models-dev.ts:
Line 256: // Schedule.spaced runs the effect once, then waits between completions.
Line 257: yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
/home/byk/Code/opencode/packages/core/src/tool-output-store.ts:
Line 203: yield* store.cleanup().pipe(Effect.repeat(Schedule.spaced(Duration.hours(1))), Effect.forkScoped)
/home/byk/Code/opencode/packages/core/src/integration.ts:
Line 364: yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope))
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 305: Effect.repeat(Schedule.spaced("10 seconds")),
/home/byk/Code/opencode/packages/core/src/util/effect-flock.ts:
Line 50: Schedule.either(Schedule.spaced(MAX_DELAY_MS)),
Line 265: .pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), ...
Found 23 matches /home/byk/Code/opencode/packages/core/schema.json: Line 1089: "name": "wake_attempts", Line 1239: "name": "wake_attempts", Line 2347: "value": "wake_attempts", Line 2467: "value": "wake_attempts", /home/byk/Code/opencode/packages/core/src/database/schema.gen.ts: Line 181: \`wake_attempts\` integer DEFAULT 0 NOT NULL, Line 206: \`wake_attempts\` integer DEFAULT 0 NOT NULL, Line 295: `CREATE INDEX \`session_input_wake_pending_promoted_attempts_time_created_id_idx\` ON \`session_input\` (\`wake_pending\`,\`promoted_seq\`,\`wake_attempts\`,\`time_created\`,\`id\`);`, Line 311: `CREATE INDEX \`session_recovery_wake_pending_error_attempts_created_execution_idx\` ON \`session_recovery\` (\`wake_pending\`,\`recovery_error\`,\`wake_attempts\`,\`time_created\`,\`execution_id\`);`, /home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts: Line 9: ...
I have the minimal changes scoped. The ordinary scheduler will keep one durable attempt counter per Session and CAS only that Session row after rechecking pending work; recovery outboxes keep their per-row counters. Cancellation will observe the heartbeat through a race and exact clear failures will propagate instead of being swallowed.
Success. Updated the following files: M packages/core/src/session/execution/local.ts M packages/core/src/session/sql.ts M packages/core/src/session/recovery.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts M packages/core/test/session-execution-local.test.ts
The first implementation is in place. I’m compiling before adding the scheduler adversarial tests because the Drizzle correlated exists shape and Effect heartbeat race need exact type confirmation; then I’ll add the continuous-injection and amplification regressions against the compiled API.
$ tsgo --noEmit src/session/recovery.ts(51,17): error TS2304: Cannot find name 'sql'.
Success. Updated the following files: M packages/core/src/session/recovery.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
[11:40:55.036] ERROR (#2): 53 | ? Statement.defaultTransforms(options.transformResultNames).array
54 | : undefined
55 |
56 | const run = (query: string, params: ReadonlyArray<unknown> = []) =>
57 | Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
58 | const statement = native.query(query)
^
SQLiteError: table session has no column named wake_attempts
errno: 1,
byteOffset: -1,
at prepare (unknown:1:1)
at prepare (bun:sqlite:345:37)
at query (bun:sqlite:367:28)
at /home/byk/Code/opencode/packages/core/src/database/sqlite.bun.ts:58:34
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
Typecheck passes. The focused tests correctly exposed stale generated schema: in-memory test databases still use the pre-change generated session table. I’m regenerating the schema artifacts, folding the temporary incremental SQL into the existing unpublished recovery migration, and deleting the temporary migration before rerunning tests.
Reading config file '/tmp/opencode-core-migration-gkEXRI/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-gkEXRI/incremental/20260916114110_session-recovery-scheduling/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-gkEXRI/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-gkEXRI/full/20260916114113_schema/migration.sql 🚀
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260916114110_session-recovery-scheduling.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260916114110_session-recovery-scheduling",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`ALTER TABLE \`session\` ADD \`wake_attempts\` integer DEFAULT 0 NOT NULL;`)
9: yield* tx.run(`DROP INDEX IF EXISTS \`session_input_wake_pending_promoted_attempts_time_created_id_idx\`;`)
10: yield* tx.run(
11: `CREATE INDEX \`session_input_wake_pending_promoted_session_idx\` ON \`session_input\` (\`wake_pending\`,\`promoted_seq\`,\`session_id\`);`,
12: )
13: yield* tx.run(`ALTER TABLE \`session_input\` DROP COLUMN \`wake_attempts\`;`)
14: })
15: },
16: } satisfies DatabaseMigration.Migration
(End of file - total 16 lines)
</content>
Success. Updated the following files: D packages/core/src/database/migration/20260916114110_session-recovery-scheduling.ts
Reading config file '/tmp/opencode-core-migration-check-qMV6G8/incremental.config.ts'
No schema changes, nothing to migrate 😴
Reading config file '/tmp/opencode-core-migration-check-qMV6G8/full.config.ts'
[✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-qMV6G8/full/20260916114149_schema/migration.sql 🚀
82 | throw new Error("Current database schema is stale. Run `bun script/migration.ts` from packages/core.")
83 | }
84 |
85 | const migrations = await typescriptMigrations()
86 | if ((await Bun.file(registry).text()) !== (await formatTypescript(renderRegistry(migrations)))) {
87 | throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
^
error: Database migration registry is stale. Run `bun script/migration.ts` from packages/core.
at check (/home/byk/Code/opencode/packages/core/script/migration.ts:87:17)
Bun v1.3.14 (Linux x64)
Reading config file '/tmp/opencode-core-migration-kspE3X/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-kspE3X/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-kspE3X/full/20260916114204_schema/migration.sql 🚀
bun test v1.3.14 (0d9b296a) 2 pass 8 filtered out 0 fail 6 expect() calls Ran 2 tests across 1 file. [952.00ms] $ tsgo --noEmit Reading config file '/tmp/opencode-core-migration-check-XbJQ96/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-XbJQ96/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-XbJQ96/full/20260916114238_schema/migration.sql 🚀
Cancellation now fails closed under a deterministic owner-transfer race, and migration/typecheck gates pass. I’m adding the two scheduling regressions next: fresh prompt injection must not reset a Session’s rank, and a large same-Session input cohort must advance through one Session-level CAS rather than an input-wide update.
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { DateTime, Deferred, Effect, Exit, Fiber, Schema } from "effect"
3: import { TestClock } from "effect/testing"
4: import { asc, eq, sql } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
7: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
8: import { EventV2 } from "@opencode-ai/core/event"
9: import { EventTable } from "@opencode-ai/core/event/sql"
10: import { ModelV2 } from "@opencode-ai/core/model"
11: import { Project } from "@opencode-ai/core/project"
12: import { ProjectTable } from "@opencode-ai/core/project/sql"
13: import { ProviderV2 } from "@opencode-ai/core/provider"
14: import { AbsolutePath } from "@opencode-ai/core/schema"
15: import { SessionV2 } from ...
The fixed-workload fairness test is being upgraded into the exact exploit: after the first failed batch, each first-page Session receives a fresh zero-attempt prompt before reconstruction. A separate trigger-backed test will prove that 150 pending inputs in one Session cause zero input-row updates and exactly one Session scheduler CAS.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 2 pass 33 filtered out 0 fail 8 expect() calls Ran 2 tests across 1 file. [4.06s] bun test v1.3.14 (0d9b296a) 2 pass 8 filtered out 0 fail 6 expect() calls Ran 2 tests across 1 file. [1187.00ms]
Both adversarial regressions pass: continuous fresh-prompt injection no longer starves the later Session, and 150 pending inputs cause one Session-level scheduler update with zero input-row rewrites. The cancellation owner-transfer regression also passes and returns failure rather than silently completing. I’m auditing generated schema/migration artifacts and every remaining wake_attempts reference before the complete recovery suite.
Found 25 matches
/home/byk/Code/opencode/packages/core/schema.json:
Line 1229: "name": "wake_attempts",
Line 1549: "name": "wake_attempts",
Line 2354: "name": "session_input_wake_pending_promoted_session_idx",
Line 2459: "value": "wake_attempts",
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 703: expect(yield* db.select({ wakeAttempts: SessionTable.wake_attempts }).from(SessionTable).get()).toEqual({
/home/byk/Code/opencode/packages/core/src/database/schema.gen.ts:
Line 205: \`wake_attempts\` integer DEFAULT 0 NOT NULL,
Line 245: \`wake_attempts\` integer DEFAULT 0 NOT NULL,
Line 295: `CREATE INDEX \`session_input_wake_pending_promoted_session_idx\` ON \`session_input\` (\`wake_pending\`,\`promoted_seq\`,\`session_id\`);`,
Line 311: `CREATE INDEX \`session_recovery_wake_pending_error_attempts_created_execution_idx\` ON \`session_recovery\` ...
<path>/home/byk/Code/opencode/packages/core/schema.json</path>
<type>file</type>
<content>
1210: "entityType": "columns",
1211: "table": "session_recovery"
1212: },
1213: {
1214: "type": "integer",
1215: "notNull": true,
1216: "autoincrement": false,
1217: "default": "true",
1218: "generated": null,
1219: "name": "wake_pending",
1220: "entityType": "columns",
1221: "table": "session_recovery"
1222: },
1223: {
1224: "type": "integer",
1225: "notNull": true,
1226: "autoincrement": false,
1227: "default": "0",
1228: "generated": null,
1229: "name": "wake_attempts",
1230: "entityType": "columns",
1231: "table": "session_recovery"
1232: },
1233: {
1234: "type": "text",
1235: "notNull": false,
1236: "autoincrement": false,
1237: "default": null,
1238: "generated": null,
1239: "name": "recovery_error",
1240: ...
<path>/home/byk/Code/opencode/packages/core/schema.json</path>
<type>file</type>
<content>
1530: "entityType": "columns",
1531: "table": "session"
1532: },
1533: {
1534: "type": "integer",
1535: "notNull": false,
1536: "autoincrement": false,
1537: "default": null,
1538: "generated": null,
1539: "name": "time_archived",
1540: "entityType": "columns",
1541: "table": "session"
1542: },
1543: {
1544: "type": "integer",
1545: "notNull": true,
1546: "autoincrement": false,
1547: "default": "0",
1548: "generated": null,
1549: "name": "wake_attempts",
1550: "entityType": "columns",
1551: "table": "session"
1552: },
1553: {
1554: "type": "text",
1555: "notNull": true,
1556: "autoincrement": false,
1557: "default": null,
1558: "generated": null,
1559: "name": "session_id",
1560: "entityType": "columns",
1561: ...
The generated artifacts now contain wake_attempts only on session and session_recovery; session_input no longer carries a resettable counter. I’m checking snapshot ancestry because regeneration may have reintroduced an intermediate parent, then I’ll run all 216 recovery tests.
<path>/home/byk/Code/opencode/packages/core/schema.json</path>
<type>file</type>
<content>
1: {
2: "version": "7",
3: "dialect": "sqlite",
4: "id": "ec4e81da-4921-4488-85cd-745c52ddce17",
5: "prevIds": [
6: "539b8b9f-47d0-4d6d-b29f-4282139c71eb"
7: ],
8: "ddl": [
9: {
10: "name": "workspace",
(Showing lines 1-10 of 2542. Use offset=11 to continue.)
</content>
Regeneration restored an intermediate snapshot parent, which would repeat the previous review failure. Because this remains one unpublished squashed migration, I’m resetting prevIds directly to the immutable base snapshot f14a9b18-... before validation.
Success. Updated the following files: M packages/core/schema.json
bun test v1.3.14 (0d9b296a)
test/database-migration.test.ts:
63 | yield* Effect.addFinalizer(() => Effect.sync(() => db.close()));
64 | if (options.disableWAL !== true) {
65 | db.run("PRAGMA journal_mode = WAL;");
66 | }
67 | const run = (sql, params = []) => Effect.withFiber(fiber => {
68 | const statement = db.query(sql);
^
SQLiteError: no such column: session_id
errno: 1,
byteOffset: 115,
at prepare (bun:sqlite:345:37)
at query (bun:sqlite:367:28)
at <anonymous> (/home/byk/Code/opencode/node_modules/.bun/@effect+sql-sqlite-bun@4.0.0-beta.83+43902b222b0d7d3e/node_modules/@effect/sql-sqlite-bun/dist/SqliteClient.js:68:28)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:444:98)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:412:23)
at ...
The broader suite exposed two integration issues, not failures of the new invariants. The migration test’s predecessor schema needs the new Session-level column/index expectations, and explicit interruption can legitimately find no row because the runner already atomically cleared the same claim; cleanup must accept absence but reject a replacement owner. I’m fixing that distinction and the migration fixture, then rerunning the two failing tests.
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260914170650_session-recovery",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`ALTER TABLE \`session_input\` ADD \`wake_pending\` integer DEFAULT false NOT NULL;`)
9: yield* tx.run(`ALTER TABLE \`session\` ADD \`wake_attempts\` integer DEFAULT 0 NOT NULL;`)
10: yield* tx.run(`
11: CREATE TABLE \`session_execution\` (
12: \`id\` text NOT NULL,
13: \`session_id\` text NOT NULL,
14: \`owner_id\` text NOT NULL,
15: \`assistant_message_id\` text,
16: \`phase\` text NOT NULL,
17: \`time_created\` integer NOT NULL,
18: \`expires_at\` integer NOT NULL,
19: \`recovery_error\` text,
20: ...
<path>/home/byk/Code/opencode/packages/core/test/database-migration.test.ts</path>
<type>file</type>
<content>
100: }),
101: ),
102: ).rejects.toThrow("does not match any known migration")
103: })
104:
105: test("serializes concurrent embedded initialization for one database path", async () => {
106: await using tmp = await tmpdir()
107: const filename = path.join(tmp.path, "embedded.sqlite")
108: const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
109:
110: await Effect.runPromise(
111: Effect.all(
112: layers.map((layer) => Effect.scoped(Layer.build(layer))),
113: { concurrency: "unbounded" },
114: ),
115: )
116: })
117: if (process.platform === "linux") {
118: test("declared schema has no ungenerated migrations", async () => {
119: const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
120: .quiet()
121: ...
The claim distinction is precise: absence means the interrupted runner already cleared the exact claim, while any row still present for that Session means ownership changed or the claim expired and cleanup must fail. I’m encoding that check and updating the predecessor fixture to include the existing session_input.session_id shape required by the new covering index.
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/test/database-migration.test.ts:
yield* db.run(sql`CREATE TABLE session_input (id text PRIMARY KEY, promoted_seq integer, time_created integer NOT NULL)`)
Success. Updated the following files: M packages/core/src/session/execution/local.ts M packages/core/test/database-migration.test.ts
bun test v1.3.14 (0d9b296a) 1 pass 19 filtered out 0 fail 11 expect() calls Ran 1 test across 1 file. [1193.00ms] bun test v1.3.14 (0d9b296a) 2 pass 97 filtered out 0 fail 7 expect() calls Ran 2 tests across 1 file. [1.67s] bun test v1.3.14 (0d9b296a) 2 pass 8 filtered out 0 fail 6 expect() calls Ran 2 tests across 1 file. [815.00ms] $ tsgo --noEmit
The integration fixes pass: migration upgrade, already-cleared interruption, ownership-loss interruption, and blocked-tool interruption all remain correct. I’m rerunning the complete recovery suite now; afterward I’ll establish negative-control evidence against the rejected v5 behavior before treating these regressions as complete.
bun test v1.3.14 (0d9b296a) 216 pass 0 fail 631 expect() calls Ran 216 tests across 8 files. [22.91s]
The revised recovery suite is green: 216 tests, 0 failures, 631 assertions. I’m now proving the two new tests fail against the exact rejected v5 implementation, not merely passing on the fix; this uses an isolated sibling worktree and does not touch the live service or main working tree.
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. ...
The replacement correctness review found two additional lifecycle blockers, so the candidate remains rejected. One overlaps the cancellation change but reaches deeper: Step.Interrupted currently deletes the claim before finalizers finish. The other shows Step.Failed marks the claim safe before unresolved tools become durable. I’m adding production-path regressions for both exact crash windows before changing settlement ordering. The failed negative-control setup applied nothing because the patch command ran in the main worktree; I’ll remove that clean detached worktree and recreate evidence correctly later.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Fix final correctness review blockers",
"status": "completed",
...
(no output)
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
250: },
251: providerOptions: { openai: { promptCacheKey } },
252: system: [agent.info?.system, system.baseline]
253: .filter((part): part is string => part !== undefined && part.length > 0)
254: .map(SystemPart.make),
255: messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
256: tools: toolMaterialization?.definitions ?? [],
257: toolChoice: isLastStep ? "none" : undefined,
258: })
259: if (
260: yield* compaction.compactIfNeeded({
261: sessionID: session.id,
262: entries,
263: model,
264: request,
265: beforeStream: () => updateExecution({ phase: "unknown", assistant_message_id: null }),
266: eventCommit: () => updateExecution(),
267: commit: () => updateExecution({ ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
190: assistantMessageID,
191: name: event.name,
192: inputEnded: false,
193: called: false,
194: settled: false,
195: providerExecuted: false,
196: })
197: yield* toolInput.start(event.id)
198: yield* publishEvent(SessionEvent.Tool.Input.Started, {
199: sessionID: input.sessionID,
200: timestamp: yield* timestamp,
201: assistantMessageID,
202: callID: event.id,
203: name: event.name,
204: })
205: })
206:
207: const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
208: const tool = tools.get(event.id)
209: if (!tool) return yield* Effect.die(`Tool input end before start: ${event.id}`)
210: if (tool.name !== event.name)
211: return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ...
The reviewer’s two new blockers share one ordering rule: event projection must describe failure/interruption without surrendering execution ownership until all in-process tool/finalizer cleanup finishes. I’m separating transcript publication from claim settlement: Step.Failed and Step.Interrupted will only renew/fence during publication; the runner will mark safe or clear the claim only after tool outcomes and interruption cleanup reach their durable boundary.
Found 12 matches
/home/byk/Code/opencode/packages/core/test/session-runner-tool-events.test.ts:
Line 49: terminalCommit: () => Effect.void,
Line 50: interruptedCommit: () => Effect.void,
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 282: terminalCommit: () => updateExecution({ phase: "safe", assistant_message_id: null }),
Line 283: interruptedCommit: () => SessionExecutionClaim.clear(db, claim),
Line 370: yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
Line 376: yield* withPublication(publisher.interruptAssistant("Tool execution declined"))
Line 383: yield* withPublication(publisher.interruptAssistant("Provider Step outcome unknown after interruption"))
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line 15: readonly terminalCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
Line 16: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: readonly startCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
15: readonly terminalCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
16: readonly interruptedCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
17: readonly eventCommit: () => ...
I’m applying the settlement split now. Step.Failed and Step.Interrupted will use the ordinary fenced event commit; provider failure will mark the claim safe only after local fibers and every unresolved tool are durably settled. Explicit cancellation will leave claim deletion to SessionExecutionLocal after runner finalizers finish.
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/test/session-runner-tool-events.test.ts:
snapshot: "snapshot-start",
startCommit: () => Effect.void,
terminalCommit: () => Effect.void,
interruptedCommit: () => Effect.void,
eventCommit: () => Effect.void,
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/runner/llm.ts
<path>/home/byk/Code/opencode/packages/core/test/session-runner-tool-events.test.ts</path>
<type>file</type>
<content>
35: replayAll: () => Effect.succeed(undefined),
36: remove: () => Effect.void,
37: claim: () => Effect.void,
38: })
39: return {
40: published,
41: publisher: createLLMEventPublisher(events, {
42: sessionID,
43: agent: "build",
44: model: {
45: id: ModelV2.ID.make("model"),
46: providerID: ProviderV2.ID.make("provider"),
47: },
48: startCommit: () => Effect.void,
49: terminalCommit: () => Effect.void,
50: interruptedCommit: () => Effect.void,
51: eventCommit: () => Effect.void,
52: }),
53: }
54: }
55:
56: const call = LLMEvent.toolCall({ id: "call-image", name: "read", input: { path: "pixel.png" } })
57: const result = LLMEvent.toolResult({
58: id: "call-image",
59: name: "read",
(Showing lines 35-59 of 140. Use offset=60 to continue.)
</content>
Success. Updated the following files: M packages/core/test/session-runner-tool-events.test.ts
Found 5 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 33: import { SessionExecutionClaim } from "../execution/claim"
Line 125: claim: SessionExecutionClaim.Claim,
Line 144: { commit: () => SessionExecutionClaim.update(db, claim) },
Line 194: const updateExecution = (values: SessionExecutionClaim.Values = {}) =>
Line 195: SessionExecutionClaim.update(db, claim, values)
The production settlement split is applied. I’m compiling and running existing provider-failure, hosted-tool, and interruption regressions first; then I’ll add crash-trigger tests that abort tool reconciliation after Step.Failed and hold a production interruption finalizer after Step.Interrupted, proving the claim remains owned at both boundaries.
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 4 pass 95 filtered out 0 fail 13 expect() calls Ran 4 tests across 1 file. [6.82s] 2 pass 8 filtered out 0 fail 6 expect() calls Ran 2 tests across 1 file. [1.98s] bun test v1.3.14 (0d9b296a) bun test v1.3.14 (0d9b296a) 5 pass 0 fail 10 expect() calls Ran 5 tests across 1 file. [2.17s]
Existing tests remain green after the ordering change. I’m now adding the missing production-boundary regressions the reviewer requested, including a post-Step.Failed reconciliation abort and a full production interrupt where Step.Interrupted commits before a blocked finalizer; both must leave the claim non-safe and non-acquirable until cleanup finishes.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1880: state: {
1881: status: "completed",
1882: input: { query: "hello" },
1883: structured: {},
1884: content: [
1885: { type: "text", text: "Hello" },
1886: { type: "file", mime: "image/png", uri: "data:image/png;base64,aGVsbG8=", name: "hello.png" },
1887: ],
1888: },
1889: },
1890: ],
1891: },
1892: ])
1893: }),
1894: )
1895:
1896: it.effect("continues with reloaded history after durably settling one local tool call", () =>
1897: Effect.gen(function* () {
1898: yield* setup
1899: const session = yield* SessionV2.Service
1900: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false })
1901:
1902: requests.length = 0
1903: ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3440: output: Schema.Struct({}),
3441: execute: (_, context) =>
3442: questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
3443: }),
3444: })
3445: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
3446:
3447: requests.length = 0
3448: responses = [
3449: [
3450: LLMEvent.stepStart({ index: 0 }),
3451: LLMEvent.toolCall({ id: "call-question", name: "question", input: {} }),
3452: LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
3453: LLMEvent.finish({ reason: "tool-calls" }),
3454: ],
3455: [],
3456: ]
3457:
3458: const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
3459: let pending = yield* ...
Found 48 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 39: import { ToolRegistry } from "@opencode-ai/core/tool/registry"
Line 73: let toolExecutionGate: Deferred.Deferred<void> | undefined
Line 116: const executions: string[] = []
Line 129: ToolRegistry.Service.use((registry) =>
Line 139: executions.push(text)
Line 145: if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
Line 158: const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
Line 262: ToolRegistry.node,
Line 263: ToolRegistry.toolsNode,
Line 353: toolExecutionGate = undefined
Line 790: executions.length = 0
Line 812: expect(executions).toEqual([])
Line 1450: executions.length = 0
Line 1904: executions.length = 0
Line 1929: expect(executions).toEqual(["hello"])
Line 2086: ...
The settlement split passes existing coverage, but one typed-failure path still needs an explicit safe transition after tool reconciliation; defects must remain unknown for recovery. I’m adding three targeted tests: provider error plus hosted-tool reconciliation abort, defect after Step.Finished, and production interruption claim retention through a blocked tool finalizer.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
60: import { ProviderV2 } from "@opencode-ai/core/provider"
61: import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
62: import { asc, eq, sql } from "drizzle-orm"
63: import { testEffect } from "./lib/effect"
64:
65: const requests: LLMRequest[] = []
66: const executionID = "test-execution"
67: let response: LLMEvent[] = []
68: let responses: LLMEvent[][] | undefined
69: let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
70: let streamGate: Deferred.Deferred<void> | undefined
71: let streamStarted: Deferred.Deferred<void> | undefined
72: let streamFailure: LLMError | undefined
73: let toolExecutionGate: Deferred.Deferred<void> | undefined
74: let toolExecutionsStarted: Deferred.Deferred<void> | undefined
75: let toolExecutionsReady = 5
76: let activeToolExecutions = 0
77: let maxActiveToolExecutions = 0
78: const client = ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
325: set: {
326: id: executionID,
327: owner_id: "test",
328: phase: "ready",
329: assistant_message_id: null,
330: time_created: Date.now(),
331: expires_at: Date.now() + 60_000,
332: },
333: })
334: .run()
335: .pipe(Effect.orDie)
336: })
337:
338: const setup = Effect.gen(function* () {
339: const { db } = yield* Database.Service
340: response = []
341: systemBaseline = "Initial context"
342: systemRemoved = false
343: systemUnavailable = false
344: systemLoadHook = Effect.void
345: modelResolveHook = Effect.void
346: currentModel = model
347: skillBaselines.clear()
348: responses = undefined
349: streamFailure = undefined
350: responseStream = undefined
351: streamGate = undefined
352: streamStarted = undefined
353: toolExecutionGate = undefined
354: ...
The ordering split also requires preserving deliberate user-decline semantics: that path will clear its claim only after tool fibers and Step.Interrupted are durable, while external cancellation leaves deletion to the coordinator after all runner finalizers. Typed provider failures will transition to safe only after tool reconciliation; defects remain unknown for startup recovery.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
210: })
211: : SystemContext.empty,
212: ),
213: })
214: const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
215: const config = Layer.succeed(
216: Config.Service,
217: Config.Service.of({
218: entries: () =>
219: Effect.succeed([
220: new Config.Document({
221: type: "document",
222: info: new Config.Info({
223: compaction: new ConfigCompaction.Info({
224: buffer: 3_000,
225: keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
226: }),
227: }),
228: }),
229: ]),
230: }),
231: )
232: const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
233: [Snapshot.node, Snapshot.noopLayer],
234: [LayerNodePlatform.llmClient, client],
235: [SessionRunnerModel.node, ...
The direct settlement logic is clear: only a typed LLMError or provider-error event may become safe, and only after awaitToolFibers plus unresolved-tool publication succeed. User decline clears the exact claim after its interrupted transcript is durable; external cancellation never clears inside the runner and remains owned until coordinator cleanup.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
Found 10 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 1642: LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
Line 1670: LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
Line 1720: [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
Line 1721: [LLMEvent.providerError({ message: "summary unavailable" })],
Line 1740: [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
Line 3755: response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
Line 3774: response = [LLMEvent.providerError({ message: "Provider unavailable" })]
Line 3798: LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
Line 3847: ...
I’m implementing the crash-window provider-error test with a hosted tool and a trigger that aborts Tool.Failed after Step.Failed has committed. The required result is an unknown claim, durable Step.Failed, and still-running hosted tool, so startup can reconcile instead of discarding the claim as safe.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
2061 | yield* db
2062 | .select({ type: EventTable.type })
2063 | .from(EventTable)
2064 | .where(eq(EventTable.type, "session.next.step.failed.1"))
2065 | .all(),
2066 | ).toHaveLength(1)
^
error: expect(received).toHaveLength(expected)
Expected length: 1
Received length: 0
at toHaveLength (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:2066:9
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The crash-window behavior is correct (unknown claim), but the assertion used the wrong durable event version. I’m checking the manifest and correcting only the event-type assertion before rerunning.
Found 3 matches /home/byk/Code/opencode/packages/schema/src/session-event.ts: Line 199: type: "session.next.step.failed", Line 497: Step.Failed, Line 531: Step.Failed,
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
185: output: Schema.Finite,
186: reasoning: Schema.Finite,
187: cache: Schema.Struct({
188: read: Schema.Finite,
189: write: Schema.Finite,
190: }),
191: }),
192: snapshot: Schema.String.pipe(optional),
193: files: Schema.Array(RelativePath).pipe(optional),
194: },
195: })
196: export type Ended = typeof Ended.Type
197:
198: export const Failed = Event.define({
199: type: "session.next.step.failed",
200: ...stepSettlementOptions,
201: schema: {
202: ...Base,
203: assistantMessageID: SessionMessage.ID,
204: error: UnknownError,
205: },
206: })
207: export type Failed = typeof Failed.Type
208:
209: export const Interrupted = Event.define({
210: type: "session.next.step.interrupted",
211: ...options,
212: schema: {
213: ...Base,
214: ...
Found 3 matches
/home/byk/Code/opencode/packages/schema/src/session-event.ts:
Line 44: const stepSettlementOptions = {
Line 177: ...stepSettlementOptions,
Line 200: ...stepSettlementOptions,
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
38: const options = {
39: durable: {
40: aggregate: "sessionID",
41: version: 1,
42: },
43: } as const
44: const stepSettlementOptions = {
45: durable: {
46: aggregate: "sessionID",
47: version: 2,
48: },
49: } as const
(Showing lines 38-49 of 563. Use offset=50 to continue.)
</content>
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) 4 pass 96 filtered out 0 fail 12 expect() calls Ran 4 tests across 1 file. [3.65s] $ tsgo --noEmit
The provider-failure crash regression now passes and proves Step.Failed can commit while the claim remains unknown until hosted-tool reconciliation succeeds. I’m adding the final two correctness proofs: a defect after Step.Finished must remain unknown with no Step.Ended, and production Step.Interrupted must retain the claim through a blocked outer runner finalizer so another owner cannot start.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) 3 pass 99 filtered out 0 fail 12 expect() calls Ran 3 tests across 1 file. [2.86s] $ tsgo --noEmit
The two new production-boundary tests pass: a provider defect after Step.Finished leaves the claim unknown with no Step.Ended, and Step.Interrupted retains ownership through a blocked runner finalizer, preventing replacement acquisition. I’m running the complete recovery suite again, then I’ll add deterministic CAS race coverage for rows settled between selection and update so the review concern is closed rather than deferred.
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
576 | yield* db
577 | .select({ id: SessionExecutionTable.id })
578 | .from(SessionExecutionTable)
579 | .where(eq(SessionExecutionTable.session_id, sessionID))
580 | .get(),
581 | ).toBeUndefined()
^
error: expect(received).toBeUndefined()
Received: {
id: "test-execution",
}
at toBeUndefined (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:581:7
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The full suite exposed stale expectations plus one real ordering mismatch. Interruption tests still expect the runner to delete claims internally, which is no longer correct; production cleanup now owns deletion. The partial-tool typed-failure test expected a pending fragment, but the strengthened contract durably fails that unresolved tool before marking safe. I’m updating those tests to assert the new ownership and durability boundaries, not weakening production behavior.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
480: expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
481: expectedContent,
482: }
483: }
484: case "tool input": {
485: const partialEvents = [
486: LLMEvent.stepStart({ index: 0 }),
487: LLMEvent.toolInputStart({ id, name: "echo" }),
488: ...chunks.map((text) => LLMEvent.toolInputDelta({ id, name: "echo", text })),
489: ]
490: const expectedContent = { type: "tool", id, state: { status: "pending", input: text } }
491: return {
492: delta: SessionEvent.Tool.Input.Delta,
493: partialEvents,
494: completeEvents: [...partialEvents, LLMEvent.toolInputEnd({ id, name: "echo" })],
495: expectedAssistant: { type: "assistant", content: [expectedContent] },
496: expectedContent,
497: }
498: }
499: }
500: }
501:
502: const ...
I’m updating direct-run interruption assertions to require the claim remain unknown, because there is no SessionExecutionLocal coordinator in that harness to perform final cleanup. For typed failure of partial tool input, the expected durable result is now an errored tool, while text/reasoning fragments retain their prior partial content.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) 6 pass 96 filtered out 0 fail 15 expect() calls Ran 6 tests across 1 file. [1.92s]
The updated assertions pass for all six partial-fragment failure/interruption cases. I’m rerunning the full recovery suite once more; if green, the only remaining test gap is the between-selection-and-CAS settlement race, which I’ll inject through the database boundary without changing production APIs.
bun test v1.3.14 (0d9b296a) 219 pass 0 fail 643 expect() calls Ran 219 tests across 8 files. [21.26s]
The 219-test recovery suite is green. I’m closing the last review concern with a deterministic SQLite trigger race: each selected row will become settled inside its CAS update, and the scheduler must observe zero updated rows and issue no advisory wake for either recovery or ordinary work.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 1 pass 35 filtered out 0 fail 4 expect() calls Ran 1 test across 1 file. [1208.00ms] $ tsgo --noEmit
The CAS race regression passes for both recovery and ordinary work: rows settled during the update produce zero scheduler increments and zero wakes. I’m rerunning all recovery tests and the full Core suite on this exact tree, then I’ll establish negative-control failures against frozen v5 and revalidate package typechecks, migration lineage, and generated contracts before a new freeze.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Fix final correctness review blockers",
"status": "completed",
...
bun test v1.3.14 (0d9b296a) test/session-projector.test.ts: (fail) SessionProjector > fairly redrives recovery and ordinary work beyond a full bounded batch [5022.99ms] ^ this test timed out after 5000ms. 219 pass 1 fail 647 expect() calls Ran 220 tests across 8 files. [93.84s]
The suite has one timeout, not a behavioral failure: the strengthened fairness test now performs 201 Session admissions plus 100 injected admissions and completes just beyond Bun’s 5-second default. I’m keeping the adversarial workload and giving only that integration test an explicit timeout, then rerunning it and the complete suite.
Found 61 matches
/home/byk/Code/opencode/packages/core/test/project-copy.test.ts:
Line 290: return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
/home/byk/Code/opencode/packages/core/test/pty/pty-session.test.ts:
Line 62: Effect.timeoutOrElse({
Line 64: orElse: () => Effect.fail(new Error("timeout waiting for pty events")),
Line 87: Effect.timeoutOrElse({
Line 89: orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
Line 196: expect(yield* Deferred.await(attached.ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 0 })
/home/byk/Code/opencode/packages/core/test/config/config.test.ts:
Line 319: timeout: { startup: 5000, request: 60000 },
Line 326: timeout: { request: 10000 },
Line 334: timeout: { startup: 15000 },
Line 405: timeout: { startup: 5000, request: 60000 ...
<path>/home/byk/Code/opencode/packages/core/test/lib/effect.ts</path>
<type>file</type>
<content>
1: import { test, type TestOptions } from "bun:test"
2: import { Cause, Effect, Exit, Layer } from "effect"
3: import type * as Scope from "effect/Scope"
4: import * as TestClock from "effect/testing/TestClock"
5: import * as TestConsole from "effect/testing/TestConsole"
6:
7: type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
8:
9: const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
10:
11: const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
12: Effect.gen(function* () {
13: const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
14: if (Exit.isFailure(exit)) {
15: for (const err of Cause.prettyErrors(exit.cause)) {
16: yield* Effect.logError(err)
17: }
18: }
19: return yield* exit
20: ...
Found 1 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 559: it.effect("fairly redrives recovery and ordinary work beyond a full bounded batch", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
550: yield* db
551: .select()
552: .from(SessionRecoveryTable)
553: .where(eq(SessionRecoveryTable.recovery_error, "Invalid recovery continuation input"))
554: .all(),
555: ).toHaveLength(poisonCount)
556: }),
557: )
558:
559: it.effect("fairly redrives recovery and ordinary work beyond a full bounded batch", () =>
560: Effect.gen(function* () {
561: const { db } = yield* Database.Service
562: const events = yield* EventV2.Service
563: const failedCount = SessionRecovery.BATCH_SIZE + 1
564: const recoverySessions = Array.from({ length: failedCount + 1 }, (_, index) =>
565: SessionV2.ID.make(`ses_recovery_failed_wake_${index.toString().padStart(3, "0")}`),
566: )
567: const ordinarySessions = Array.from({ length: failedCount + 1 }, (_, index) =>
568: ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
Found 1 matches /home/byk/Code/opencode/packages/core/test/session-projector.test.ts: Line 219: 15_000,
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
208:
209: yield* events.publish(SessionEvent.Step.Recovered, {
210: sessionID,
211: timestamp: DateTime.makeUnsafe(2),
212: executionID: "execution",
213: assistantMessageID: assistantID,
214: phase: "unknown",
215: })
216: expect(yield* db.select().from(SessionInputTable).all()).toHaveLength(1)
217: expect(yield* db.select().from(SessionRecoveryTable).all()).toHaveLength(1)
218: }),
219: 15_000,
220: )
221:
222: it.effect("records a recovery continuation without forging an assistant identity", () =>
(Showing lines 208-222 of 2340. Use offset=223 to continue.)
</content>
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 1 pass 35 filtered out 0 fail 5 expect() calls Ran 1 test across 1 file. [7.53s] bun test v1.3.14 (0d9b296a) 220 pass 0 fail 647 expect() calls Ran 220 tests across 8 files. [49.89s]
All 220 recovery tests now pass with 647 assertions. I’m running the full Core suite and package/schema gates in parallel. While those run, I’ll reconstruct the frozen v5 negative-control tree correctly by applying the patch from its own worktree, then overlay only the new tests to prove the rejected implementation fails them.
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[12:23:51.712] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
1157 pass
0 fail
3236 expect() calls
Ran 1157 tests across 145 files. [82.07s]
[12:24:56.504] INFO (#206646): watcher backend {
directory: "/tmp/opencode-core-test-R9enAj",
platform: "linux",
backend: "inotify",
}
[12:24:56.515] INFO (#206646): booting location services {
directory: "/tmp/opencode-core-test-R9enAj",
workspaceID: undefined,
}
Full Core passes on the exact tree: 1,157 tests, 0 failures, 3,236 assertions. I’m checking all affected package typechecks, Schema manifest, migration consistency, formatter/diff hygiene, and generated lineage now; then I’ll rerun the exact artifact smoke because runtime schema and interruption ordering changed.
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a) 2 pass 0 fail 23 expect() calls Ran 2 tests across 1 file. [577.00ms]
Reading config file '/tmp/opencode-core-migration-check-Wl4oFO/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-Wl4oFO/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-Wl4oFO/full/20260916123018_schema/migration.sql 🚀
All five package typechecks, Schema manifest tests, and migration consistency pass. I’m checking exact diff whitespace and snapshot ancestry, then rebuilding the standalone server and repeating isolated health/schema/restart recovery against the new Session-level scheduler column.
packages/core/schema.json | 565 ++++++- packages/core/src/database/migration.gen.ts | 1 + .../migration/20260914170650_session-recovery.ts | 55 + packages/core/src/database/schema.gen.ts | 45 + packages/core/src/session.ts | 9 +- packages/core/src/session/compaction.ts | 47 +- packages/core/src/session/context-epoch.ts | 88 +- packages/core/src/session/execution/local.ts | 155 +- packages/core/src/session/input.ts | 373 +++- packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 78 +- packages/core/src/session/recovery.ts | 331 ++++ packages/core/src/session/run-coordinator.ts | 35 +- packages/core/src/session/runner/index.ts | 4 + packages/core/src/session/runner/llm.ts | 300 +++- .../core/src/session/runner/publish-llm-event.ts | 100 +- ...
Diff hygiene passes, and the package stat still excludes the two untracked production modules, so I’m verifying the exact 33-file candidate list rather than relying on git diff --stat. Snapshot ancestry must remain directly rooted at f14a9b18-...; after that I’ll rebuild and smoke the exact artifact.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
The candidate remains exactly 33 package files once the two untracked modules are included and the two superseded migrations are counted as deletions; .lore.md, AGENTS.md, and profiling files remain excluded. I’m rebuilding the standalone server now, without touching the live service.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0aa33f07a0016MeAeQg611Tidl dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
The exact SEA rebuilt successfully with only known asset-size and note-injection warnings. I’m running a fresh disposable smoke with bounded HTTP probes, isolated XDG/HOME/database paths, and an explicit model fixture: authenticated health, final schema, abandoned-claim reconciliation, crash-window redrive, second-restart settlement, and third-restart idempotence.
-rwxr-xr-x 1 byk byk 157879104 Sep 16 12:32 /home/byk/Code/opencode/packages/opencode/dist/server/opencode-server drwxr-xr-x 8 byk byk 4096 Sep 16 11:30 /tmp/opencode fbf055e063fc8dab33b6b2e0d082b56dd98e4644cfcab71fe551727bd27c550b /home/byk/Code/opencode/packages/opencode/dist/server/opencode-server
/tmp/opencode/session-recovery-review-c2c8afb0/packages/opencode/script/smoke-server.mjs /tmp/opencode/session-recovery-review-c2c8afb0/packages/opencode/test/server/sdk-v1-smoke.test.ts /tmp/opencode/session-recovery-review-v5-49183f9e/packages/opencode/script/smoke-server.mjs /tmp/opencode/session-recovery-v5-review.r03hHL/packages/opencode/script/smoke-server.mjs /tmp/opencode/session-recovery-review-v5-49183f9e/packages/opencode/test/server/sdk-v1-smoke.test.ts /tmp/opencode/session-recovery-v5-review.r03hHL/packages/opencode/test/server/sdk-v1-smoke.test.ts
<path>/tmp/opencode/session-recovery-review-v5-49183f9e/packages/opencode/script/smoke-server.mjs</path>
<type>file</type>
<content>
1: #!/usr/bin/env node
2:
3: import { once } from "node:events"
4: import { spawn } from "node:child_process"
5: import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
6: import os from "node:os"
7: import path from "node:path"
8: import { fileURLToPath, pathToFileURL } from "node:url"
9:
10: const executable = process.argv[2]
11: if (!executable) throw new Error("Usage: smoke-server.mjs <executable>")
12:
13: const expectedHtml = await readFile(
14: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../app/dist/index.html"),
15: "utf8",
16: )
17: const home = await mkdtemp(path.join(os.tmpdir(), "opencode-server-smoke-"))
18: const projectDirectory = path.join(home, "project")
19: const pluginDirectory = path.join(projectDirectory, "plugin")
20: const pluginMarker = path.join(home, "plugin-prompt.txt")
21: await ...
The built-in smoke script exercises broader server behavior but does not verify recovery persistence. I’m using a disposable custom harness under /tmp/opencode that launches the exact binary with bounded probes and isolated storage, then inspects SQLite between clean restarts. No repository file or live service will be changed.
Found 30 matches
/home/byk/Code/opencode/packages/tui/src/editor-zed.ts:
Line 41: export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): Promise<ZedSelectionResult> {
Line 42: const active = queryZedActiveEditor(dbPath, cwd)
Line 48: const selections = queryZedEditorSelections(dbPath, row)
Line 63: const contents = queryZedEditorContents(dbPath, row)
Line 89: function queryZedActiveEditor(dbPath: string, cwd: string) {
Line 92: db = new Database(dbPath, { readonly: true })
Line 133: function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
Line 136: db = new Database(dbPath, { readonly: true })
Line 161: function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
Line 164: db = new Database(dbPath, { readonly: true })
/home/byk/Code/opencode/packages/opencode/test/fixture/db.ts:
Line 7: const dbPath = Database.path()
Line 8: await rm(dbPath, { force: true }).catch(() => ...
<path>/tmp/opencode/session-recovery-final-v4</path> <type>directory</type> <entries> cache/ config/ data/ home/ opencode.db state/ workspace/ (7 entries) </entries>
Found 5 matches
/home/byk/Code/opencode/packages/core/src/database/database.ts:
Line 44: if (Flag.OPENCODE_DB) {
Line 45: if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
Line 46: return join(Global.Path.data, Flag.OPENCODE_DB)
Line 53: return join(Global.Path.data, "opencode.db")
/home/byk/Code/opencode/packages/core/src/flag/flag.ts:
Line 47: OPENCODE_DB: process.env["OPENCODE_DB"],
Server smoke checks passed at http://127.0.0.1:14096/
The exact binary passes authenticated server, WebUI, project, plugin, and PTY smoke checks with clean shutdown. I’m now running the recovery-specific isolated database smoke, reusing none of the prior fixture state, and verifying the new session.wake_attempts schema plus exactly-once continuation across restarts.
drwxr-xr-x 8 byk byk 4096 Sep 16 11:30 /tmp/opencode
CREATE TABLE `session` (
`id` text PRIMARY KEY,
`project_id` text NOT NULL,
`workspace_id` text,
`parent_id` text,
`slug` text NOT NULL,
`directory` text NOT NULL,
`path` text,
`title` text NOT NULL,
`version` text NOT NULL,
`share_url` text,
`summary_additions` integer,
`summary_deletions` integer,
`summary_files` integer,
`summary_diffs` text,
`metadata` text,
`cost` real DEFAULT 0 NOT NULL,
`tokens_input` integer DEFAULT 0 NOT NULL,
`tokens_output` integer DEFAULT 0 NOT NULL,
`tokens_reasoning` integer DEFAULT 0 NOT NULL,
`tokens_cache_read` integer DEFAULT 0 NOT NULL,
`tokens_cache_write` integer DEFAULT 0 NOT NULL,
`revert` text,
`permission` text,
`agent` text,
`model` text,
`time_created` integer NOT NULL,
...
I have the recovery smoke sequence scoped to a disposable database: migrate once, verify schema, seed one expired ready claim, restart to create the recovery outbox, stop before drain completion if possible, restart until exact promotion settles, then restart again and compare immutable counts. The fixture includes a real workspace directory and explicit model reference, avoiding the prior smoke’s two fixture defects.
Success. Updated the following files: A ../../../../tmp/opencode/session-recovery-v6-smoke.mjs
{
"initial": {
"port": 27741,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"before": [
{
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"event_count": 0
}
],
"first": {
"port": 6695,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"afterFirst": [
{
"execution_count": 1,
"recovery_count": 1,
"input_count": 1,
"message_count": 1,
"event_count": 1
}
...
The recovery-specific SEA smoke passes end to end. The first restart atomically created one recovery/outbox and was stopped with the original claim still present; the second restart cleared the claim and promoted exactly one queued {"text":"continue"}; the third restart preserved exact counts 0/1/1/2/2. Health was 200 on every launch, schema contains Session-level wake_attempts, stderr stayed empty, and no live service was touched. I’m verifying no disposable binary survived, then running the failing-first controls against the immutable v5 review tree.
4070560 /usr/bin/bash -c pgrep -af '/home/byk/Code/opencode/packages/opencode/dist/server/opencode-server' || true
The smoke left no disposable server running. Before freezing, I’m tightening one subtle point in the new cancellation observer: a heartbeat failure must report ownership loss without interrupting coordinator cleanup itself. I’m changing the race to race only fiber observers, then always await cleanup before propagating heartbeat failure; this preserves finalizer completion even under takeover.
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
35: session_id: sessionID,
36: owner_id: ownerID,
37: phase: "ready",
38: time_created: now,
39: expires_at: now + leaseDuration,
40: })
41: .onConflictDoNothing()
42: .returning({ id: SessionExecutionTable.id })
43: .get()
44: .pipe(Effect.orDie)
45: if (!claimed) return
46: const claim = { sessionID, executionID, ownerID }
47: activeClaims.set(sessionID, claim)
48: const recovery = yield* db
49: .select({ continuationMessageID: SessionRecoveryTable.continuation_message_id })
50: .from(SessionRecoveryTable)
51: .where(
52: and(
53: eq(SessionRecoveryTable.session_id, sessionID),
54: eq(SessionRecoveryTable.wake_pending, true),
55: isNull(SessionRecoveryTable.recovery_error),
56: ),
57: ...
I found a cleanup-risk in my own latest change before freeze: raceFirst cancels the losing branch, so heartbeat ownership loss could interrupt coordinator cleanup. I’m replacing it with observer fibers: detect either completion, always await coordinator cleanup, then propagate heartbeat loss only after finalizers and successor coordination finish.
No files found
Found 4 matches /home/byk/Code/opencode/packages/core/src/integration.ts: Line 501: const exit = yield* authorize(callback).pipe(Effect.exit) /home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts: Line 108: const cleanupExit = yield* Effect.exit(cleanup) /home/byk/Code/opencode/packages/core/src/session/runner/llm.ts: Line 344: const stream = yield* restore(providerStream).pipe(Effect.exit) Line 371: const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
A simpler safe shape avoids observer complexity: fork the heartbeat, run coordinator interruption to completion, then join the heartbeat only if cleanup itself failed due to ownership loss; scope closure stops a healthy heartbeat afterward. This guarantees the heartbeat never cancels cleanup, while exact clear still rejects a replacement row.
Success. Updated the following files: M packages/core/src/session/execution/local.ts
Success. Updated the following files: M packages/core/src/session/execution/local.ts
The cleanup-risk is removed: heartbeat loss can no longer cancel finalizers; exact clear remains the authoritative fail-closed result after cleanup. I’m rerunning cancellation, production interruption, typecheck, and then the full suite because this post-smoke source change invalidates the prior artifact and freeze evidence.
bun test v1.3.14 (0d9b296a) 2 pass 8 filtered out 0 fail 6 expect() calls Ran 2 tests across 1 file. [1.55s] bun test v1.3.14 (0d9b296a) 3 pass 99 filtered out 0 fail 12 expect() calls Ran 3 tests across 1 file. [2.55s] $ tsgo --noEmit
Focused cancellation and production interruption tests pass after the cleanup adjustment. I’m rerunning all 220 recovery tests and full Core now; if green, I’ll rebuild and rerun only the exact recovery artifact smoke, then freeze and review.
bun test v1.3.14 (0d9b296a)
220 pass
0 fail
647 expect() calls
Ran 220 tests across 8 files. [30.58s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[12:45:08.599] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/tool-webfetch.test.ts:
(fail) WebFetchTool registration > returns an error result when HTML-to-Markdown conversion throws [5057.69ms]
^ this test timed out after 5000ms.
test/effect/layer-node/node-build.test.ts:
1156 pass
1 fail
3236 expect() calls
Ran 1157 tests across 145 files. [140.94s]
[12:47:03.894] INFO (#206634): watcher backend {
directory: "/tmp/opencode-core-test-Sn6KG2",
platform: "linux",
backend: ...
The complete recovery suite remains green. Full Core had one unrelated 5-second timeout in tool-webfetch.test.ts; all 1,156 other tests passed, and the same full suite passed earlier on this tree’s behavior. I’m isolating that timeout with the required console trace, then rerunning full Core once to distinguish suite contention from a defect before rebuilding the changed artifact.
bun test v1.3.14 (0d9b296a)
1 pass
11 filtered out
0 fail
1 expect() calls
Ran 1 test across 1 file. [4.33s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[12:47:51.308] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/tool-webfetch.test.ts:
(fail) WebFetchTool registration > returns an error result when HTML-to-Markdown conversion throws [5235.01ms]
^ this test timed out after 5000ms.
test/effect/layer-node/node-build.test.ts:
1156 pass
1 fail
3236 expect() calls
Ran 1157 tests across 145 files. [152.75s]
[12:50:11.022] INFO (#206633): watcher backend {
directory: "/tmp/opencode-core-test-nWhBFq",
platform: "linux",
...
The isolated WebFetch test passes in 4.33 seconds but exceeds its 5-second limit only inside the full suite on two reruns, so this is aggregate contention outside the recovery scope; the exact recovery suite remains 220/220. I’m recording that as unrelated validation debt, rechecking the changed Core package, then rebuilding and rerunning the recovery smoke on the exact final source.
$ tsgo --noEmit Reading config file '/tmp/opencode-core-migration-check-iAp8Z9/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-iAp8Z9/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-iAp8Z9/full/20260916125050_schema/migration.sql 🚀
Core typecheck and migration consistency still pass after the final cleanup change. I’m rebuilding the exact SEA and rerunning the isolated recovery smoke; the broader built-in server smoke already passed before this Core-only control-flow adjustment, while the recovery smoke will directly exercise the changed startup/claim path.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0aa4635b9001o0NHxmG98V11dC dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
{
"initial": {
"port": 5293,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"before": [
{
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"event_count": 0
}
],
"first": {
"port": 16297,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"afterFirst": [
{
"execution_count": 1,
"recovery_count": 1,
"input_count": 1,
"message_count": 1,
"event_count": 1
}
...
The final smoke exposed a harness timing flaw, not a duplicate or corruption: restart one stopped while the drain still owned a live 30-second lease, and restart two’s 30-second deadline ended at the first eligible takeover boundary. The harness also failed to throw when settlement timed out. I’m fixing the smoke to simulate elapsed downtime by expiring that disposable claim between restarts and to fail hard unless promotion settles; then I’ll rerun it.
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v6-smoke.mjs
{
"initial": {
"port": 17083,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"before": [
{
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"event_count": 0
}
],
"first": {
"port": 30255,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"afterFirst": [
{
"execution_count": 0,
"recovery_count": 1,
"input_count": 1,
"message_count": 2,
"event_count": 2
}
...
The corrected recovery smoke now passes with strict timeout handling: health succeeds on all launches, schema matches, exactly one continuation is promoted, final counts remain 0/1/1/2/2, stderr is empty, and no disposable server survives. I’m formatting and freezing the exact 33-file candidate now, then I’ll verify clean application to ab9408c81c and launch fresh correctness and security reviews focused on the two latest settlement fixes.
<path>/home/byk/Code/opencode/packages/core/package.json</path>
<type>file</type>
<content>
1: {
2: "$schema": "https://json.schemastore.org/package.json",
3: "version": "1.18.30",
4: "name": "@opencode-ai/core",
5: "type": "module",
6: "license": "MIT",
7: "private": true,
8: "scripts": {
9: "db": "bun drizzle-kit",
10: "migration": "bun run script/migration.ts",
11: "fix-node-pty": "bun run script/fix-node-pty.ts",
12: "test": "bun test --only-failures",
13: "typecheck": "tsgo --noEmit"
14: },
15: "bin": {
16: "opencode": "./bin/opencode"
17: },
18: "exports": {
19: "./effect/layer-node": "./src/effect/layer-node.ts",
20: "./effect/app-node": "./src/effect/app-node.ts",
21: "./session/runner": "./src/session/runner/index.ts",
22: "./system-context": "./src/system-context/index.ts",
23: "./*": "./src/*.ts"
24: },
25: "imports": {
26: "#sqlite": {
27: "bun": "./src/database/sqlite.bun.ts",
28: ...
Resolving dependencies Resolved, downloaded and extracted [47] Saved lockfile Finished in 113ms on 6 files using 4 threads.
Formatting completed, but bunx reported saving a lockfile, so I’m checking for unintended dependency-file changes before freezing. Any formatter-created lockfile change will be excluded or removed only if it was created by this command; unrelated workspace files remain untouched.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
No unintended lockfile or dependency change was created; the formatter touched only the six intended Core files. I’m running the post-format recovery suite and Core typecheck, then I’ll freeze the package-only patch, verify its exact file list and clean application, and start replacement reviews.
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 220 pass 0 fail 647 expect() calls Ran 220 tests across 8 files. [83.35s]
Post-format Core typecheck and all 220 recovery tests pass. I’m freezing candidate v6 against immutable base ab9408c81c; the patch will include only the 33 package files and exclude .lore.md, AGENTS.md, and profiling files.
291acaf8e3ee242c68183716c74b1de4f3d732e117a231174829ce786f1b5a63 /tmp/opencode/session-recovery-final-v6.patch d09c992a0f4cce15e73dd158fe60413420109c821afed6e121d11cdad9c7b583 packages/opencode/dist/server/opencode-server 33 packages/core/schema.json | 565 +- packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 55 packages/core/src/database/schema.gen.ts | 45 packages/core/src/session.ts | 9 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 packages/core/src/session/execution/local.ts | 205 - packages/core/src/session/input.ts | 373 + packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 78 packages/core/src/session/recovery.ts | 331 + packages/core/src/session/run-coordinator.ts ...
The first v6 freeze is invalid: using bunx oxfmt from packages/core resolved a different formatter and rewrote entire files, inflating the patch from about 6,100 insertions to 11,851. I will not review or keep that noisy patch. I’m restoring repository-authoritative formatting with the checked-in root formatter, then I’ll rerun diff and validation before creating a replacement freeze.
<path>/home/byk/Code/opencode/package.json</path>
<type>file</type>
<content>
1: {
2: "$schema": "https://json.schemastore.org/package.json",
3: "name": "opencode",
4: "description": "AI-powered development tool",
5: "private": true,
6: "type": "module",
7: "packageManager": "bun@1.3.14",
8: "scripts": {
9: "dev": "bun run --cwd packages/opencode src/index.ts",
10: "dev:desktop": "bun --cwd packages/desktop dev",
11: "dev:web": "bun --cwd packages/app dev",
12: "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
13: "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
14: "dev:storybook": "bun --cwd packages/storybook storybook",
15: "lint": "oxlint",
16: "typecheck": "bun turbo typecheck",
17: "upgrade-opentui": "bun run script/upgrade-opentui.ts",
18: "postinstall": "bun run --cwd packages/core fix-node-pty",
19: "prepare": "husky",
20: "random": "echo ...
No files found
No files found
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
1: import {
2: LLM,
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Message,
7: SystemPart,
8: isContextOverflowFailure,
9: type ProviderErrorEvent,
10: } from "@opencode-ai/llm";
11: import {
12: Cause,
13: DateTime,
14: Effect,
15: FiberSet,
16: Layer,
17: Option,
18: Semaphore,
19: Stream,
20: } from "effect";
21: import { and, eq } from "drizzle-orm";
22: import { AgentV2 } from "../../agent";
23: import { Config } from "../../config";
24: import { Database } from "../../database/database";
25: import { EventV2 } from "../../event";
26: import { Location } from "../../location";
27: import { ModelV2 } from "../../model";
28: import { PermissionV2 } from "../../permission";
29: import { ProviderV2 } from "../../provider";
30: import { QuestionV2 } from "../../question";
31: import { SystemContext } from "../../system-context/index";
32: import { ...
/home/byk/Code/opencode/.editorconfig
No files found
The formatter changed syntax style, not semantics. I’m restoring the repository’s actual format with the formatter configuration used by the prior candidate rather than reverting source. First I’m checking the installed formatter/version and repository editor settings; then I’ll run it with explicit no-semicolon settings and confirm the patch returns to its prior scale.
<path>/home/byk/Code/opencode/.editorconfig</path> <type>file</type> <content> 1: root = true 2: 3: [*] 4: charset = utf-8 5: insert_final_newline = true 6: end_of_line = lf 7: indent_style = space 8: indent_size = 2 9: max_line_length = 80 (End of file - total 9 lines) </content>
Usage: [-c=PATH] [PATH]...
Mode Options:
--init Initialize `.oxfmtrc.json` with default values
--migrate=SOURCE Migrate configuration to `.oxfmtrc.json` from specified source
Available sources: prettier, biome
--lsp Start language server protocol (LSP) server
--stdin-filepath=PATH Specify the file name to use to infer which parser to use
Output Options:
--write Format and write files in place (default)
--check Check if files are formatted, also show statistics
--list-different List files that would be changed
Config Options
-c, --config=PATH Path to the configuration file (.json, .jsonc, .ts, .mts, .cts, .js,
.mjs, .cjs)
--disable-nested-config Do not search for configuration files in subdirectories
Ignore Options
--ignore-path=PATH Path to ignore file(s). ...
Created `.oxfmtrc.json`.
<path>/tmp/opencode/.oxfmtrc.json</path>
<type>file</type>
<content>
1: {
2: "ignorePatterns": []
3: }
(End of file - total 3 lines)
</content>
3.6.2
The accidental style matches default Prettier/oxfmt semicolon output; repository source uses semicolon-free Prettier style. I’m running Prettier 3.6.2 with explicit --no-semi, two spaces, trailing commas, and 120-column width on only the six files, then checking patch scale and tests.
packages/core/src/session/execution/local.ts 781ms packages/core/src/session/runner/llm.ts 475ms packages/core/src/session/runner/publish-llm-event.ts 312ms packages/core/test/session-projector.test.ts 1151ms packages/core/test/session-runner-tool-events.test.ts 82ms packages/core/test/session-runner.test.ts 1831ms
packages/core/schema.json | 565 +++++- packages/core/src/database/migration.gen.ts | 1 + .../migration/20260914170650_session-recovery.ts | 55 + packages/core/src/database/schema.gen.ts | 45 + packages/core/src/session.ts | 9 +- packages/core/src/session/compaction.ts | 47 +- packages/core/src/session/context-epoch.ts | 88 +- packages/core/src/session/execution/local.ts | 154 +- packages/core/src/session/input.ts | 373 +++- packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 78 +- packages/core/src/session/recovery.ts | 331 +++ packages/core/src/session/run-coordinator.ts | 35 +- packages/core/src/session/runner/index.ts | 4 + packages/core/src/session/runner/llm.ts | 339 +++- .../core/src/session/runner/publish-llm-event.ts | 105 +- ...
Explicit Prettier restored semicolon-free syntax but still reformatted many existing lines, so the candidate remains too noisy. I’m comparing against the immutable v5 review tree to identify its exact formatting rules; if no formatter reproduces them, I’ll restore only these six files from the frozen candidate and reapply the small reviewed semantic delta, preserving all other current work.
<path>/tmp/opencode/session-recovery-review-v5-49183f9e/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
1: import {
2: LLM,
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Message,
7: SystemPart,
8: isContextOverflowFailure,
9: type ProviderErrorEvent,
10: } from "@opencode-ai/llm"
11: import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
12: import { and, eq } from "drizzle-orm"
13: import { AgentV2 } from "../../agent"
14: import { Config } from "../../config"
15: import { Database } from "../../database/database"
16: import { EventV2 } from "../../event"
17: import { Location } from "../../location"
18: import { ModelV2 } from "../../model"
19: import { PermissionV2 } from "../../permission"
20: import { ProviderV2 } from "../../provider"
21: import { QuestionV2 } from "../../question"
22: import { SystemContext } from "../../system-context/index"
23: import { SystemContextRegistry } from ...
The v5 files confirm the intended style: semicolon-free, compact import grouping, and selective line wrapping that neither latest oxfmt nor default Prettier reproduces. I’m restoring the six files from the immutable v5 tree and then reapplying only the post-v5 fixes from the reviewed evidence: claim-retention settlement ordering, four runner regressions, the CAS race test, and the fairness timeout.
(no output)
I can recover the exact style without copying files if the formatter configuration matches v5. I’m testing oxfmt against the immutable v5 files with explicit repository settings (printWidth: 120, no semicolons); only if it reports those files unchanged will I use it on the candidate.
Success. Updated the following files: A ../../../../tmp/opencode/oxfmt-opencode.json
Checking formatting... packages/core/test/session-projector.test.ts (31ms) packages/core/test/session-runner.test.ts (49ms) Format issues found in above 2 files. Run without `--check` to fix. Finished in 80ms on 6 files using 4 threads.
The explicit oxfmt config exactly matches all three production files and the tool-event test in immutable v5; only the two large historical test files were already intentionally noncanonical. I’ll format the three production files and small test with this verified config, then restore the two large tests from v5 and reapply only their new test blocks so unrelated historical lines remain byte-for-byte unchanged.
Finished in 16ms on 4 files using 4 threads.
I’m restoring only the two large tests from the immutable v5 candidate via a generated bulk patch, then reapplying the exact post-review regression delta. This avoids thousands of formatter-only changes while retaining every new correctness proof.
(no output)
No files found
No files found
File not found: /home/byk/Code/opencode/packages/core/test/session-runner.test.ts
The no-index restore patch used absolute source labels and removed the workspace test files instead of replacing their content. I caught this before validation. I’m restoring both files immediately from the immutable v5 tree using a correctly path-labeled patch, then I’ll verify both exist and match v5 byte-for-byte before reapplying additions.
D packages/core/test/session-projector.test.ts D packages/core/test/session-runner.test.ts
(no output)
Both large tests now match immutable v5 byte-for-byte. I’m reapplying only the post-v5 deltas in small patches: the changed partial-fragment expectations, production finalizer hook, three new settlement tests, CAS race test, and fairness timeout.
Found 11 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 73: let toolExecutionGate: Deferred.Deferred<void> | undefined
Line 243: const execution = Layer.effect(
Line 338: const setup = Effect.gen(function* () {
Line 2006: it.effect("never commits a Step end after the provider stream fails", () =>
Line 3520: it.effect("durably fails blocked local tools when a provider turn is interrupted", () =>
Line 3604: it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 559: it.effect("fairly redrives recovery and ordinary work beyond a full bounded batch", () =>
/home/byk/Code/opencode/packages/core/test/tool-todowrite.test.ts:
Line 55: const setup = Effect.gen(function* () {
/home/byk/Code/opencode/packages/core/test/session-runner-recorded.test.ts:
Line 87: const execution = ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
Success. Updated the following files: M packages/core/test/session-runner.test.ts
Success. Updated the following files: M packages/core/test/session-runner.test.ts
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
it.effect("bounds ordinary redrive writes to one scheduler row per Session", () =>
Found 2 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 559: it.effect("fairly redrives recovery and ordinary work beyond a full bounded batch", () =>
Line 637: it.effect("quarantines a continuation that belongs to another Session", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
550: yield* db
551: .select()
552: .from(SessionRecoveryTable)
553: .where(eq(SessionRecoveryTable.recovery_error, "Invalid recovery continuation input"))
554: .all(),
555: ).toHaveLength(poisonCount)
556: }),
557: )
558:
559: it.effect("fairly redrives recovery and ordinary work beyond a full bounded batch", () =>
560: Effect.gen(function* () {
561: const { db } = yield* Database.Service
562: const events = yield* EventV2.Service
563: const failedCount = SessionRecovery.BATCH_SIZE + 1
564: const recoverySessions = Array.from({ length: failedCount + 1 }, (_, index) =>
565: SessionV2.ID.make(`ses_recovery_failed_wake_${index.toString().padStart(3, "0")}`),
566: )
567: const ordinarySessions = Array.from({ length: failedCount + 1 }, (_, index) =>
568: ...
The immutable v5 test file predates two fairness additions besides the CAS race: it lacks the extra fresh-admission pressure and the bounded one-write-per-Session regression. I’m restoring all three blocks, not just the latest CAS test, so the compact candidate retains the complete validated scheduling evidence.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
The compact test delta is restored, including the two pre-existing fairness additions that v5 lacked. I’m checking patch scale now; it should return close to the pre-formatter 6,100-line package diff. Then I’ll run typecheck and all 220 tests to prove no regression block was lost.
packages/core/schema.json | 565 ++++++- packages/core/src/database/migration.gen.ts | 1 + .../migration/20260914170650_session-recovery.ts | 55 + packages/core/src/database/schema.gen.ts | 45 + packages/core/src/session.ts | 9 +- packages/core/src/session/compaction.ts | 47 +- packages/core/src/session/context-epoch.ts | 88 +- packages/core/src/session/execution/local.ts | 154 +- packages/core/src/session/input.ts | 373 +++- packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 78 +- packages/core/src/session/recovery.ts | 331 ++++ packages/core/src/session/run-coordinator.ts | 35 +- packages/core/src/session/runner/index.ts | 4 + packages/core/src/session/runner/llm.ts | 339 ++-- .../core/src/session/runner/publish-llm-event.ts | 105 +- ...
The candidate diff is compact again: 6,200 insertions and 396 deletions across the 31 tracked package paths, plus the two untracked production modules. That scale matches the intended pre-formatter candidate plus the new regressions. I’m rerunning Core typecheck and all recovery tests before replacing the invalid freeze.
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 220 pass 0 fail 647 expect() calls Ran 220 tests across 8 files. [22.34s]
The compact candidate passes typecheck and all 220 recovery tests with 647 assertions. Production semantics are unchanged from the artifact smoke; only test-file formatting was repaired afterward. I’m creating the valid v6 freeze now, verifying exact file count and clean application to the immutable base, then launching independent correctness and security reviews.
a5c1bace440d1b6acb383c4fcffc52714a9373892e75b4f23bdbbf2dfdad8e16 /tmp/opencode/session-recovery-final-v6.patch d09c992a0f4cce15e73dd158fe60413420109c821afed6e121d11cdad9c7b583 packages/opencode/dist/server/opencode-server 33 packages/core/schema.json | 565 ++++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 55 + packages/core/src/database/schema.gen.ts | 45 + packages/core/src/session.ts | 9 packages/core/src/session/compaction.ts | 47 - packages/core/src/session/context-epoch.ts | 88 + packages/core/src/session/execution/local.ts | 154 ++ packages/core/src/session/input.ts | 373 ++++ packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 78 + packages/core/src/session/recovery.ts | 331 ++++ ...
Valid candidate v6 is frozen: patch SHA-256 a5c1bace..., SEA SHA-256 d09c992a..., exactly 33 package files, 6,272 insertions and 396 deletions. I’m verifying clean application in a detached sibling worktree at ab9408c81c, then the immutable patch and artifact will go to fresh reviewers.
Preparing worktree (detached HEAD ab9408c81c) Updating files: 66% (4425/6637) Updating files: 67% (4447/6637) Updating files: 68% (4514/6637) Updating files: 69% (4580/6637) Updating files: 70% (4646/6637) Updating files: 71% (4713/6637) Updating files: 72% (4779/6637) Updating files: 73% (4846/6637) Updating files: 74% (4912/6637) Updating files: 75% (4978/6637) Updating files: 76% (5045/6637) Updating files: 77% (5111/6637) Updating files: 78% (5177/6637) Updating files: 79% (5244/6637) Updating files: 80% (5310/6637) Updating files: 81% (5376/6637) Updating files: 82% (5443/6637) Updating files: 83% (5509/6637) Updating files: 84% (5576/6637) Updating files: 85% (5642/6637) Updating files: 86% (5708/6637) Updating files: 87% (5775/6637) Updating files: 88% (5841/6637) Updating files: 89% (5907/6637) Updating files: 90% (5974/6637) Updating files: 91% (6040/6637) Updating files: 92% (6107/6637) Updating files: 93% (6173/6637) Updating files: 94% ...
(no output)
Candidate v6 applies cleanly to immutable base and passes diff checks with exactly 33 files. I’m removing the verification worktree and launching two fresh read-only reviewers. They will inspect the exact hashes and must cover cancellation ownership through finalizers, provider/tool safe settlement ordering, CAS no-wake races, fairness across restart, replay/quarantine, migration lineage, and dependency boundaries.
(no output)
<task id="ses_f559f271bffeOuyOWdhMX7B7gw" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f559eb59dffem2NMEwAGxNiePH" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Both immutable v6 reviews are running independently. While they inspect the frozen candidate, I’m preparing negative-control evidence in the already isolated v5 review tree: overlay only the latest tests and confirm the rejected implementation fails claim retention, post-failure tool settlement, provider defects after Step.Finished, and CAS no-wake behavior.
continue
Candidate v6 is frozen and under independent correctness and security review. A separate isolated agent is proving the latest regressions fail against rejected v5; no live services or main-workspace source are being changed.
</task_result> </task>
The correctness reviewer returned no substantive evidence, so its verdict is invalid. The isolated v5 controls did prove three fixes fail on rejected source: unresolved-tool failure clears ownership, blocked finalizers fail claim-retention completion, and settled CAS rows still wake; the provider-defect regression already passed v5, so it serves as retained coverage rather than failing-first proof. I’m retrying the same correctness reviewer once under the evidence-or-blocker rule.
<task id="ses_f559f271bffeOuyOWdhMX7B7gw" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Correctness review retry and independent security review remain active. Candidate v6 stays frozen; no source changes are being made.
safe while sibling local-tool fibers remain active. awaitToolFibers races first failure against set emptiness at packages/core/src/session/runner/llm.ts:154-155. FiberSet.join fails when the first tool fiber fails; it does not wait for sibling fibers. The failure branch marks unsettled tools terminal at lines 410-416 and writes phase: "safe" at lines 417-420 without clearing and awaiting the remaining fibers. A blocked sibling can still execute or stall in a finalizer after its durable state says failed. This violates the requirement that provider failure never becomes safe until every local fiber and tool has a durable terminal outcome. Add a regression with two tools: one fails, one remains blocked through a finalizer; the claim must never become safe until the blocked fiber finishes.CONCERN
wake_attempts values can defeat bounded fairness permanently. Both counters lack non-negative and upper-bound checks at packages/core/src/session/sql.ts:176-205 and in the migration. ...Security review found two real ownership defects, so v6 is rejected: user-decline still clears the claim before outer runner finalizers finish, and one failing local-tool fiber can mark the Step safe while a sibling remains active. I’m reproducing both with deterministic blocked-finalizer regressions before changing production; I’ll also harden malformed wake_attempts because hostile negative or unsafe integers can monopolize bounded scans.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Fix v6 decline-finalizer and sibling-tool settlement blockers with failing-first regressions",
"status": "in_progress",
"priority": "high"
},
{
"content": "Constrain malformed wake-attempt scheduling values",
"status": "pending",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
120: if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
121: return session
122: })
123:
124: const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
125: return yield* store.context(sessionID)
126: })
127: const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
128: sessionID: SessionSchema.ID,
129: claim: SessionExecutionClaim.Claim,
130: ) {
131: for (const message of yield* getContext(sessionID)) {
132: if (message.type !== "assistant") continue
133: for (const tool of message.content) {
134: if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue
135: yield* events.publish(
136: SessionEvent.Tool.Failed,
137: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: readonly startCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
15: readonly eventCommit: () => Effect.Effect<void>
16: }
17:
18: const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3350: },
3351: { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
3352: ])
3353: }),
3354: )
3355:
3356: it.effect("returns policy-blocked tools to the model and continues", () =>
3357: Effect.gen(function* () {
3358: yield* setup
3359: const session = yield* SessionV2.Service
3360: const registry = yield* ToolRegistry.Service
3361: yield* registry.register({
3362: blocked: Tool.make({
3363: description: "Fail because policy blocked execution",
3364: input: Schema.Struct({}),
3365: output: Schema.Struct({}),
3366: execute: () =>
3367: Effect.fail(new PermissionV2.BlockedError({ rules: [] })).pipe(
3368: Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
3369: ),
3370: ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, Duration, Effect, Exit, Layer, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { SessionExecutionClaim } from "./claim"
13: import { SESSION_EXECUTION_LEASE_MS, SessionExecutionTable, SessionRecoveryTable } from "../sql"
14:
15: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. ...
<skill_content name="effect">
# Skill: effect
# Effect
This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
## Source Of Truth
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
3. Also inspect existing repo code for local house style before introducing new patterns.
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
## Guidelines
- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
- Use `Effect.gen(function* () { ... })` for multi-step workflows.
- Use `Effect.fn("Name")` or ...
Found 3 matches /home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/FiberSet.ts: Line 414: export const clear = <A, E>(self: FiberSet<A, E>): Effect.Effect<void> => Line 680: export const join = <A, E>(self: FiberSet<A, E>): Effect.Effect<void, E> => Line 708: export const awaitEmpty = <A, E>(self: FiberSet<A, E>): Effect.Effect<void> =>
<path>/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/FiberSet.ts</path>
<type>file</type>
<content>
390: * **Example** (Clearing all fibers)
391: *
392: * ```ts
393: * import { Effect, FiberSet } from "effect"
394: *
395: * const program = Effect.gen(function*() {
396: * const set = yield* FiberSet.make()
397: *
398: * // Add some fibers
399: * yield* FiberSet.run(set, Effect.never)
400: * yield* FiberSet.run(set, Effect.never)
401: *
402: * console.log(yield* FiberSet.size(set)) // 2
403: *
404: * // Clear all fibers
405: * yield* FiberSet.clear(set)
406: *
407: * console.log(yield* FiberSet.size(set)) // 0
408: * })
409: * ```
410: *
411: * @category combinators
412: * @since 2.0.0
413: */
414: export const clear = <A, E>(self: FiberSet<A, E>): Effect.Effect<void> =>
415: Effect.suspend(() => {
416: if (self.state._tag === "Closed") {
417: return Effect.void
418: }
419: return ...
No files found
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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, runs cleanup, then starts work that arrived while stopping. */
14: readonly interrupt: (key: Key, cleanup?: Effect.Effect<void>) => Effect.Effect<void>
15: }
16:
17: type Entry<E> = {
18: ...
Found 35 matches
/home/byk/Code/opencode/packages/core/src/pty.ts:
Line 232: yield* events.publish(Event.Exited, { id, exitCode })
Line 261: if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
/home/byk/Code/opencode/packages/core/src/plugin.ts:
Line 56: if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
Line 94: if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
/home/byk/Code/opencode/packages/core/src/background-job.ts:
Line 168: yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
Line 356: if (result.scope) yield* Scope.close(result.scope, Exit.void)
/home/byk/Code/opencode/packages/core/src/integration.ts:
Line 503: if (Exit.isFailure(exit)) return yield* exit
Line 513: if (attempt) yield* Scope.close(attempt.scope, ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import {
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Model,
7: TransportReason,
8: InvalidRequestReason,
9: type LLMClientShape,
10: type LLMRequest,
11: } from "@opencode-ai/llm"
12: import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
13: import { Database } from "@opencode-ai/core/database/database"
14: import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
15: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
16: import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
17: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
18: import { EventV2 } from "@opencode-ai/core/event"
19: import { PermissionV2 } from "@opencode-ai/core/permission"
20: import { EventTable } from "@opencode-ai/core/event/sql"
21: import { Project } ...
The minimal fix is clear: encode deliberate decline as safe in the atomic Step.Interrupted commit, never clear inside the runner, and let SessionExecutionLocal clear that safe claim only after the entire location/runner scope exits. For sibling tools, preserve the first failure but still await FiberSet.awaitEmpty before any terminal reconciliation or safe phase. I’m adding regressions at those exact boundaries now.
CONCERN
wake_attempts first at packages/core/src/session/recovery.ts:194-208; ordinary Sessions use the same order at recovery.ts:256-270. A continuous supply of at least 100 new rows with zero attempts can indefinitely outrank an older row with one attempt.packages/core/test/session-projector.test.ts:559-649 adds fresh prompts to already-attempted ...The correctness retry confirms the sibling-fiber defect and treats ordinary explicit interruption as fixed; security correctly distinguishes the separate user-decline path, which still clears early. I’m adding one regression where decline reaches a blocked outer finalizer and another where a failing tool leaves a sibling blocked in its finalizer; both will assert the claim remains owned and unknown until the full ownership chain settles.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) test/session-runner.test.ts: (fail) SessionRunnerLLM > retains the claim after user decline until runner finalizers finish [5000.01ms] ^ this test timed out after 5000ms. 0 pass 103 filtered out 1 fail 1 expect() calls Ran 1 test across 1 file. [6.63s] bun test v1.3.14 (0d9b296a) test/session-runner.test.ts: (fail) SessionRunnerLLM > never marks a failed Step safe while a sibling tool finalizer is active [5000.01ms] ^ this test timed out after 5000ms. 0 pass 103 filtered out 1 fail 1 expect() calls Ran 1 test across 1 file. [7.33s]
Both negative controls timed out because their first failing assertion left the deliberately blocked finalizer unreleased. I’m making teardown unconditional so rejected code fails with direct ownership assertions, then applying the narrow production changes: atomically mark decline safe without deleting the claim, defer deletion to SessionExecutionLocal after runner finalizers, and interrupt/await all sibling tool fibers before propagating the first tool failure.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/execution/local.ts
Success. Updated the following files: M packages/core/test/session-runner.test.ts
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) test/session-runner.test.ts: (fail) SessionRunnerLLM > retains the claim after user decline until runner finalizers finish [5017.79ms] ^ this test timed out after 5000ms. (fail) SessionRunnerLLM > never marks a failed Step safe while a sibling tool finalizer is active [5000.02ms] ^ this test timed out after 5000ms. 0 pass 102 filtered out 2 fail Ran 2 tests across 1 file. [11.38s]
The production change exposed a test synchronization issue rather than an immediate assertion failure: both cases still hang at a blocked finalizer boundary. I’m instrumenting each wait with named bounded failures so the next run identifies whether the runner never reaches the finalizer, release does not propagate, or coordinator cleanup does not complete; this keeps the regressions deterministic and removes opaque suite timeouts.
Found 2 matches
/home/byk/Code/opencode/packages/core/test/pty/pty-session.test.ts:
Line 196: expect(yield* Deferred.await(attached.ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 0 })
/home/byk/Code/opencode/packages/core/test/background-job.test.ts:
Line 101: yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) test/session-runner.test.ts: (fail) SessionRunnerLLM > retains the claim after user decline until runner finalizers finish [5131.12ms] ^ this test timed out after 5000ms. (fail) SessionRunnerLLM > never marks a failed Step safe while a sibling tool finalizer is active [5003.24ms] ^ this test timed out after 5000ms. 0 pass 102 filtered out 2 fail Ran 2 tests across 1 file. [14.94s]
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3450: event.type.startsWith(SessionEvent.Step.Interrupted.type),
3451: ),
3452: ).toBe(true)
3453: const { db } = yield* Database.Service
3454: expect(yield* db.select().from(SessionExecutionTable).all()).toEqual([])
3455: yield* SessionRecovery.recover()
3456: expect(yield* db.select().from(SessionRecoveryTable).all()).toEqual([])
3457: expect(requests).toHaveLength(1)
3458: }),
3459: )
3460:
3461: it.effect("retains the claim after user decline until runner finalizers finish", () =>
3462: Effect.gen(function* () {
3463: yield* setup
3464: const session = yield* SessionV2.Service
3465: const registry = yield* ToolRegistry.Service
3466: const { db } = yield* Database.Service
3467: const finalizerStarted = Deferred.makeUnsafe<void>()
3468: const releaseFinalizer = ...
The named timeout was masked because a timeout exits the test while its child fiber can then enter the unreleased finalizer during scope cleanup, recreating the five-second hang. I’m making release unconditional on every early exit, so failures report the exact missed boundary instead of wedging teardown.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) test/session-runner.test.ts: (fail) SessionRunnerLLM > retains the claim after user decline until runner finalizers finish [5079.50ms] ^ this test timed out after 5000ms. (fail) SessionRunnerLLM > never marks a failed Step safe while a sibling tool finalizer is active [5004.00ms] ^ this test timed out after 5000ms. 0 pass 102 filtered out 2 fail Ran 2 tests across 1 file. [13.70s]
The waits are progressing farther than the one-second guards, but the default five-second test deadline still interrupts cleanup before diagnostics surface. I’m raising only these two lifecycle tests to 15 seconds, matching the existing fairness regression, while keeping every internal wait bounded at one second. This will expose the exact stage without weakening behavior.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) test/session-runner.test.ts: (fail) SessionRunnerLLM > retains the claim after user decline until runner finalizers finish [15001.69ms] ^ this test timed out after 15000ms. (fail) SessionRunnerLLM > never marks a failed Step safe while a sibling tool finalizer is active [15000.01ms] ^ this test timed out after 15000ms. 0 pass 102 filtered out 2 fail Ran 2 tests across 1 file. [34.56s]
The hang came from asserting while the runner’s deliberately blocked finalizer was still active: a failed pre-release assertion exits the test fiber, and scope cleanup then waits on the unreleased finalizer. I’m changing the regressions to capture durable state first, always release and join the runner, and only then assert. This preserves the boundary proof and guarantees teardown even on rejected behavior.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) test/session-runner.test.ts: (fail) SessionRunnerLLM > retains the claim after user decline until runner finalizers finish [15102.64ms] ^ this test timed out after 15000ms. (fail) SessionRunnerLLM > never marks a failed Step safe while a sibling tool finalizer is active [15000.01ms] ^ this test timed out after 15000ms. 0 pass 102 filtered out 2 fail Ran 2 tests across 1 file. [32.89s]
Both regressions reach the blocked finalizer, but reading through the shared Effect database service while the runner is suspended can itself queue behind active transactional work, so the test never reaches release. I’m moving the observation to an independent SQLite connection against the same disposable database, which measures durable ownership without depending on the runner’s database fiber.
<path>/home/byk/Code/opencode/packages/core/src/database/database.ts</path>
<type>file</type>
<content>
1: export * as Database from "./database"
2:
3: import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
4: import { layer as sqliteLayer } from "#sqlite"
5: import { Context, Effect, Layer } from "effect"
6: import { Global } from "../global"
7: import { Flag } from "../flag/flag"
8: import { isAbsolute, join } from "path"
9: import { DatabaseMigration } from "./migration"
10: import { InstallationChannel } from "../installation/version"
11: import { makeGlobalNode } from "../effect/app-node"
12:
13: const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
14: type DatabaseShape = Effect.Success<typeof makeDatabase>
15:
16: export interface Interface {
17: db: DatabaseShape
18: }
19:
20: export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
21:
22: const layer = Layer.effect(
23: Service,
24: ...
<path>/home/byk/Code/opencode/packages/core/test/lib/effect.ts</path>
<type>file</type>
<content>
1: import { test, type TestOptions } from "bun:test"
2: import { Cause, Effect, Exit, Layer } from "effect"
3: import type * as Scope from "effect/Scope"
4: import * as TestClock from "effect/testing/TestClock"
5: import * as TestConsole from "effect/testing/TestConsole"
6:
7: type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
8:
9: const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
10:
11: const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
12: Effect.gen(function* () {
13: const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
14: if (Exit.isFailure(exit)) {
15: for (const err of Cause.prettyErrors(exit.cause)) {
16: yield* Effect.logError(err)
17: }
18: }
19: return yield* exit
20: ...
The lifecycle tests run under TestClock, so their one-second Effect.timeout guards never advance; Bun’s outer timeout then masks the blocked stage. These are real scheduler/finalizer tests, so I’m switching only them to it.live, where bounded waits use real time and teardown diagnostics work as intended.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
1464 | /** @internal */
1465 | export const timeoutOrElse = /*#__PURE__*/dual(2, (self, options) => raceFirst(self, flatMap(sleep(options.duration), options.orElse)));
1466 | /** @internal */
1467 | export const timeout = /*#__PURE__*/dual(2, (self, duration) => timeoutOrElse(self, {
1468 | duration,
1469 | orElse: () => fail(new TimeoutError())
^
error: undefined
_tag: "TimeoutError",
~effect/Cause/TimeoutError: "~effect/Cause/TimeoutError",
at orElse (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:1469:22)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The live diagnostics now expose both mechanisms precisely. Decline retains safe ownership, but the coordinator does not finish after release; the sibling test confirms phase still flips to safe while its finalizer is active, so FiberSet.clear/awaitEmpty is not a sufficient completion barrier. I’m replacing implicit set completion with explicit per-tool fiber tracking and awaiting each interrupted fiber’s full exit; for decline, I’m tracing the coordinator completion separately after removing the premature clear.
Found 3 matches
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Fiber.ts:
Line 457: * @see {@link interruptAllAs} for specifying the interrupting fiber ID
Line 515: * yield* Fiber.interruptAllAs([worker1, worker2], controllerFiber.id)
Line 525: export const interruptAllAs: {
<path>/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Fiber.ts</path>
<type>file</type>
<content>
440: *
441: * const fiber3 = yield* Effect.forkChild(
442: * Effect.gen(function*() {
443: * yield* Effect.sleep("4 seconds")
444: * yield* Console.log("Task 3 completed")
445: * return "result3"
446: * })
447: * )
448: *
449: * // Wait a bit, then interrupt all fibers
450: * yield* Effect.sleep("1 second")
451: * yield* Console.log("Interrupting all fibers...")
452: * yield* Fiber.interruptAll([fiber1, fiber2, fiber3])
453: * yield* Console.log("All fibers have been interrupted")
454: * })
455: * ```
456: *
457: * @see {@link interruptAllAs} for specifying the interrupting fiber ID
458: *
459: * @category interruption
460: * @since 2.0.0
461: */
462: export const interruptAll: <A extends Iterable<Fiber<any, any>>>(
463: fibers: A
464: ) => Effect<void> = ...
Found 6 matches
/home/byk/Code/opencode/packages/core/src/tool/registry.ts:
Line 31: readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
Line 50: const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
Line 116: settle: (input) => {
/home/byk/Code/opencode/packages/core/src/tool/tool.ts:
Line 66: readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, ToolFailure>
Line 91: settle: (call, context) =>
Line 150: export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context)
<path>/home/byk/Code/opencode/packages/core/src/tool/registry.ts</path>
<type>file</type>
<content>
35: readonly result: ToolResultValue
36: readonly output?: ToolOutput
37: readonly outputPaths?: ReadonlyArray<string>
38: }
39:
40: export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
41:
42: const registryLayer = Layer.effect(
43: Service,
44: Effect.gen(function* () {
45: const applications = yield* ApplicationTools.Service
46: const resources = yield* ToolOutputStore.Service
47: type Registration = { readonly identity: object; readonly tool: AnyTool }
48: const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
49:
50: const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
51: const registration =
52: local.get(input.call.name)?.at(-1)?.registration ?? ...
Found 2 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 401: updateExecution({ phase: "safe", assistant_message_id: null }),
Line 425: phase: "safe",
FiberSet cannot prove this lifecycle boundary because its bookkeeping may report empty independently of a tool effect’s blocked finalizer. I’m moving ownership of the actual forked tool fibers into the runner: store every returned Fiber, interrupt all tracked fibers on first failure, and await each exact fiber exit before marking the execution safe. This gives the state machine a concrete completion barrier rather than relying on aggregate set state.
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
1: import {
2: LLM,
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Message,
7: SystemPart,
8: isContextOverflowFailure,
9: type ProviderErrorEvent,
10: } from "@opencode-ai/llm"
11: import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
12: import { and, eq } from "drizzle-orm"
13: import { AgentV2 } from "../../agent"
14: import { Config } from "../../config"
15: import { Database } from "../../database/database"
16: import { EventV2 } from "../../event"
17: import { Location } from "../../location"
18: import { ModelV2 } from "../../model"
19: import { PermissionV2 } from "../../permission"
20: import { ProviderV2 } from "../../provider"
21: import { QuestionV2 } from "../../question"
22: import { SystemContext } from "../../system-context/index"
23: import { SystemContextRegistry } from "../../system-context/registry"
24: import ...
Found 11 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 11: import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
Line 154: const awaitToolFibers = Effect.fnUntraced(function* (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) {
Line 155: const settled = yield* Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers)).pipe(Effect.exit)
Line 156: if (Exit.isFailure(settled)) yield* FiberSet.clear(fibers)
Line 157: yield* FiberSet.awaitEmpty(fibers)
Line 226: const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
Line 349: ).pipe(FiberSet.run(toolFibers))
Line 395: if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
Line 396: const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
Line 398: yield* ...
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
$ tsgo --noEmit src/session/runner/llm.ts(223,13): error TS2304: Cannot find name 'Exit'.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
1464 | /** @internal */
1465 | export const timeoutOrElse = /*#__PURE__*/dual(2, (self, options) => raceFirst(self, flatMap(sleep(options.duration), options.orElse)));
1466 | /** @internal */
1467 | export const timeout = /*#__PURE__*/dual(2, (self, duration) => timeoutOrElse(self, {
1468 | duration,
1469 | orElse: () => fail(new TimeoutError())
^
error: undefined
_tag: "TimeoutError",
~effect/Cause/TimeoutError: "~effect/Cause/TimeoutError",
at orElse (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:1469:22)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The explicit fiber tracking fixed the unsafe phase observation; both tests now reach release and only fail their one-second post-release completion guard. That guard is too tight for coordinator cleanup plus WAL commits in the live integration layer. I’m extending only the post-release join bound to five seconds while retaining the 15-second outer ceiling, then I’ll inspect any remaining timeout separately.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
[14:08:10.444] ERROR (#2): 1464 | /** @internal */
1465 | export const timeoutOrElse = /*#__PURE__*/dual(2, (self, options) => raceFirst(self, flatMap(sleep(options.duration), options.orElse)));
1466 | /** @internal */
1467 | export const timeout = /*#__PURE__*/dual(2, (self, duration) => timeoutOrElse(self, {
1468 | duration,
1469 | orElse: () => fail(new TimeoutError())
^
TimeoutError: TimeoutError
_tag: "TimeoutError",
~effect/Cause/TimeoutError: "~effect/Cause/TimeoutError",
at orElse (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:1469:22)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The explicit fiber list revealed the API mistake: Fiber.await returns an Exit as a successful value, so the runner lost the first tool failure and never entered decline/failure handling. I’m switching the first barrier to concurrent Fiber.join, then using Fiber.interruptAll, whose Effect v4 contract waits for every interrupted fiber and its finalizers before returning.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 2 pass 102 filtered out 0 fail 6 expect() calls Ran 2 tests across 1 file. [4.26s]
Both blocker regressions now pass: decline retains a safe claim through the outer finalizer and clears it afterward; sibling failure keeps the claim unknown until Fiber.interruptAll finishes every tracked tool finalizer. I’m now addressing the remaining scheduler hardening by constraining persisted attempt counters and adding poison-value tests, then I’ll rerun the full recovery suite.
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
130: seq: integer().notNull(),
131: ...Timestamps,
132: data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
133: },
134: (table) => [
135: uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq),
136: index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq),
137: index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
138: index("session_message_time_created_idx").on(table.time_created),
139: ],
140: )
141:
142: export const SessionInputTable = sqliteTable(
143: "session_input",
144: {
145: id: text().$type<SessionMessage.ID>().primaryKey(),
146: session_id: text()
147: .$type<SessionSchema.ID>()
148: .notNull()
149: .references(() => SessionTable.id, { onDelete: "cascade" }),
150: prompt: text({ mode: ...
Found 3 matches /home/byk/Code/opencode/packages/core/src/session/sql.ts: Line 61: wake_attempts: integer().notNull().default(0), Line 193: wake_attempts: integer().notNull().default(0), Line 203: table.wake_attempts,
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
35: path: DatabasePath.pathColumn(),
36: title: text().notNull(),
37: version: text().notNull(),
38: share_url: text(),
39: summary_additions: integer(),
40: summary_deletions: integer(),
41: summary_files: integer(),
42: summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
43: metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
44: cost: real().notNull().default(0),
45: tokens_input: integer().notNull().default(0),
46: tokens_output: integer().notNull().default(0),
47: tokens_reasoning: integer().notNull().default(0),
48: tokens_cache_read: integer().notNull().default(0),
49: tokens_cache_write: integer().notNull().default(0),
50: revert: text({ mode: "json" }).$type<Revert.State>(),
51: permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
52: agent: text(),
53: ...
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260914170650_session-recovery",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`ALTER TABLE \`session_input\` ADD \`wake_pending\` integer DEFAULT false NOT NULL;`)
9: yield* tx.run(`ALTER TABLE \`session\` ADD \`wake_attempts\` integer DEFAULT 0 NOT NULL;`)
10: yield* tx.run(`
11: CREATE TABLE \`session_execution\` (
12: \`id\` text NOT NULL,
13: \`session_id\` text NOT NULL,
14: \`owner_id\` text NOT NULL,
15: \`assistant_message_id\` text,
16: \`phase\` text NOT NULL,
17: \`time_created\` integer NOT NULL,
18: \`expires_at\` integer NOT NULL,
19: \`recovery_error\` text,
20: ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
175: ),
176: )
177: .get()
178: .pipe(
179: Effect.orDie,
180: Effect.flatMap((stored) => (stored ? Effect.void : Effect.die(defect))),
181: )
182: }),
183: )
184: }).pipe(
185: Effect.catchCause((cause) =>
186: Effect.logError("Failed to recover Session", cause).pipe(
187: Effect.annotateLogs({ sessionID: claim.session_id, executionID: claim.id }),
188: ),
189: ),
190: ),
191: { discard: true, concurrency: 1 },
192: )
193:
194: const pending = yield* db
195: .select({
196: sessionID: SessionRecoveryTable.session_id,
197: executionID: SessionRecoveryTable.execution_id,
198: continuationMessageID: SessionRecoveryTable.continuation_message_id,
199: ...
The two ownership blockers are fixed and typecheck passes. Before expanding scheduler policy, I’m running the complete runner suite because replacing FiberSet with exact fibers changes every local-tool settlement path; this will catch any ordering or interruption regression at the narrowest layer.
bun test v1.3.14 (0d9b296a) 110 pass 0 fail 344 expect() calls Ran 110 tests across 3 files. [25.40s]
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
1: import { sql } from "drizzle-orm"
2: import { check, sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
3: import * as DatabasePath from "../database/path"
4: import { ProjectTable } from "../project/sql"
5: import type { SessionMessage } from "./message"
6: import type { Prompt } from "./prompt"
7: import type { SessionInput } from "./input"
8: import type { Snapshot } from "../snapshot"
9: import { PermissionV1 } from "../v1/permission"
10: import { ProjectV2 } from "../project"
11: import type { SessionSchema } from "./schema"
12: import type { MessageID, PartID, SessionV1 } from "../v1/session"
13: import { WorkspaceV2 } from "../workspace"
14: import { Timestamps } from "../database/schema.sql"
15: import type { SystemContext } from "../system-context/index"
16: import { AgentV2 } from "../agent"
17: import type { Revert } from ...
Runner and tool-event suites pass 110/110 after the exact-fiber change. For scheduling, I’m using a bounded two-lane policy rather than a larger redesign: each scan still processes at most 100 rows, but reserves progress for previously attempted work even under an endless supply of fresh rows; counters are constrained to safe non-negative integers and saturate instead of overflowing.
Found 1 matches
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 3: import { and, asc, desc, eq, exists, gt, isNull, lte, or, sql } from "drizzle-orm"
The scheduler hardening will preserve the 100-row cap and current throughput: reserve one slot for retried work, fill fresh work next, then backfill unused capacity with more retries. This prevents endless fresh Sessions from starving old retries without turning one pass into unbounded traversal.
Success. Updated the following files: M packages/core/src/session/sql.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts
Success. Updated the following files: M packages/core/src/session/sql.ts
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
530: yield* db.run(sql`UPDATE session_input SET prompt = '{}' WHERE time_created < ${poisonCount}`)
531: const wakes: string[] = []
532: const execution = SessionExecution.Service.of({
533: ...recoveryExecution,
534: wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid),
535: })
536:
537: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution))
538: expect(wakes).toEqual([])
539: expect(
540: yield* db
541: .select()
542: .from(SessionRecoveryTable)
543: .where(eq(SessionRecoveryTable.recovery_error, "Invalid recovery continuation input"))
544: .all(),
545: ).toHaveLength(SessionRecovery.BATCH_SIZE)
546:
547: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, ...
The runner regression suite is clean. I’m now encoding the scheduler policy in selection itself: one previously attempted row is reserved, fresh rows fill the remaining slots, and retries backfill unused slots; increments saturate at Number.MAX_SAFE_INTEGER. The regression will add 100 entirely new competing Sessions after a failed pass, proving old recovery and ordinary rows still receive service.
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/src/session/recovery.ts:
import { SessionExecutionTable, SessionInputTable, SessionMessageTable, SessionRecoveryTable, SessionTable } from "./sql"
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, exists, gt, isNull, lte, or, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { SessionInput } from "./input"
13: import { SessionSchema } from "./schema"
14: import {
15: SESSION_EXECUTION_LEASE_MS,
16: SessionExecutionTable,
17: SessionInputTable,
18: SessionRecoveryTable,
19: SessionMessageTable,
20: SessionTable,
21: } from "./sql"
22:
23: export const BATCH_SIZE = ...
Success. Updated the following files: M packages/core/src/session/recovery.ts
Success. Updated the following files: M packages/core/src/session/recovery.ts
Success. Updated the following files: M packages/core/src/session/recovery.ts
Success. Updated the following files: M packages/core/test/session-projector.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 1 pass 35 filtered out 0 fail 7 expect() calls Ran 1 test across 1 file. [4.59s]
The sustained-fresh-work regression passes with the reserved retry lane, and Core typecheck remains clean. I’m adding direct database checks for negative and unsafe-integer counters, then regenerating the canonical Drizzle snapshot so schema lineage, generated tables, and the squashed migration all encode the same constraints.
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { DateTime, Deferred, Effect, Exit, Fiber, Schema } from "effect"
3: import { TestClock } from "effect/testing"
4: import { asc, eq, sql } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
7: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
8: import { EventV2 } from "@opencode-ai/core/event"
9: import { EventTable } from "@opencode-ai/core/event/sql"
10: import { ModelV2 } from "@opencode-ai/core/model"
11: import { Project } from "@opencode-ai/core/project"
12: import { ProjectTable } from "@opencode-ai/core/project/sql"
13: import { ProviderV2 } from "@opencode-ai/core/provider"
14: import { AbsolutePath } from "@opencode-ai/core/schema"
15: import { SessionV2 } from ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
710 | title: "test",
711 | version: "test",
712 | })
713 | .run()
714 |
715 | expect(yield* db.run(sql`UPDATE session SET wake_attempts = -1 WHERE id = ${sessionID}`).pipe(Effect.exit)).toMatchObject({
^
error: expect(received).toMatchObject(expected)
{
- "_tag": "Failure",
+ "~effect/Effect/args": [],
}
- Expected - 1
+ Received + 1
at toMatchObject (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:715:115
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate ...
Found 1 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 715: expect(yield* db.run(sql`UPDATE session SET wake_attempts = -1 WHERE id = ${sessionID}`).pipe(Effect.exit)).toMatchObject({
The constraint test exposed a database API detail: raw db.run(...) returns a nested executable effect in this adapter, so Effect.exit observed the wrapper rather than executing the SQL mutation. I’m flattening the raw mutation before capturing its exit, then regenerating from the checked schema.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) test/session-projector.test.ts: error: Fiber.runLoop: Not a valid effect: Fiber.runLoop: Not a valid effect: (fail) SessionProjector > rejects invalid persisted wake attempt counters [105.85ms] 0 pass 36 filtered out 1 fail Ran 1 test across 1 file. [1031.00ms]
Found 61 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 1983: yield* db.run(sql`
Line 1994: Effect.ensuring(db.run(sql`DROP TRIGGER fail_hosted_tool_reconciliation`).pipe(Effect.orDie)),
Line 2076: yield* db.run(sql`
Line 2087: Effect.ensuring(db.run(sql`DROP TRIGGER fail_provider_tool_reconciliation`).pipe(Effect.orDie)),
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 475: yield* db.run(sql`UPDATE session_input SET prompt = '{}' WHERE id = ${continuation("malformed-recovery-row")}`)
Line 531: yield* db.run(sql`UPDATE session_input SET prompt = '{}' WHERE time_created < ${poisonCount}`)
Line 759: yield* db.run(sql`CREATE TABLE ordinary_redrive_input_updates (count integer NOT NULL)`)
Line 760: yield* db.run(sql`INSERT INTO ordinary_redrive_input_updates (count) VALUES (0)`)
Line 761: yield* db.run(sql`
Line 823: yield* db.run(sql`
...
Found 1 matches /home/byk/Code/opencode/packages/core/test/database-migration.test.ts: Line 223: .pipe(Effect.exit),
<path>/home/byk/Code/opencode/packages/core/test/database-migration.test.ts</path>
<type>file</type>
<content>
205: ),
206: ).toEqual({ required: 0 })
207: expect(
208: yield* db.get(
209: sql`SELECT "notnull" AS required FROM pragma_table_info('session_recovery') WHERE name = 'phase'`,
210: ),
211: ).toEqual({ required: 1 })
212: expect(
213: yield* db.get(
214: sql`SELECT "notnull" AS required FROM pragma_table_info('session_recovery') WHERE name = 'recovery_error'`,
215: ),
216: ).toEqual({ required: 0 })
217: expect(
218: Exit.isFailure(
219: yield* db
220: .run(
221: sql`INSERT INTO session_execution (id, session_id, owner_id, phase, time_created, expires_at) VALUES ('bad', 'ses_existing', 'owner', 'invalid', 0, 1)`,
222: )
223: .pipe(Effect.exit),
224: ),
225: ...
File not found: /home/byk/Code/opencode/packages/core/test/setup.ts
The raw SQL assertion should follow the repository’s existing Effect v4 pattern: inspect with Exit.isFailure rather than matching _tag, and never flatten db.run. I’m correcting that and covering both Session and recovery counters in the same test.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
721 |
722 | expect(
723 | Exit.isFailure(
724 | yield* db.run(sql`UPDATE session SET wake_attempts = -1 WHERE id = ${sessionID}`).pipe(Effect.exit),
725 | ),
726 | ).toBe(true)
^
error: expect(received).toBe(expected)
Expected: true
Received: false
at toBe (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:726:9
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The constraint is absent from the generated runtime schema because the checked Drizzle artifacts still describe the pre-constraint tables; editing the squashed migration alone cannot affect test databases created from the current snapshot. I’m regenerating now, then I’ll fold any temporary incremental SQL into the unpublished recovery migration and rerun on a fresh test process.
Reading config file '/tmp/opencode-core-migration-0R08sG/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-0R08sG/incremental/20260916142142_session-recovery-final/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-0R08sG/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-0R08sG/full/20260916142144_schema/migration.sql 🚀
/home/byk/Code/opencode/packages/core/src/database/migration/20260916142142_session-recovery-final.ts
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260916142142_session-recovery-final.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260916142142_session-recovery-final",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`PRAGMA foreign_keys=OFF;`)
9: yield* tx.run(`
10: CREATE TABLE \`__new_session_recovery\` (
11: \`session_id\` text NOT NULL,
12: \`execution_id\` text NOT NULL,
13: \`assistant_message_id\` text,
14: \`continuation_message_id\` text NOT NULL,
15: \`phase\` text NOT NULL,
16: \`wake_pending\` integer DEFAULT true NOT NULL,
17: \`wake_attempts\` integer DEFAULT 0 NOT NULL,
18: \`recovery_error\` text,
19: \`time_created\` integer NOT NULL,
20: CONSTRAINT \`session_recovery_pk\` PRIMARY ...
Generation exposed a Drizzle DDL trap: interpolating the numeric bound parameterizes it as ?, which is invalid for a persisted SQLite CHECK. I’m changing the schema expression to an explicit SQL literal, removing the temporary migration, and regenerating so the canonical snapshot produces literal 9007199254740991 constraints.
Success. Updated the following files: M packages/core/src/session/sql.ts D packages/core/src/database/migration/20260916142142_session-recovery-final.ts
Reading config file '/tmp/opencode-core-migration-gG9FA4/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-gG9FA4/incremental/20260916142239_session-recovery-final/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-gG9FA4/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-gG9FA4/full/20260916142242_schema/migration.sql 🚀
/home/byk/Code/opencode/packages/core/src/database/migration/20260916142239_session-recovery-final.ts
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260916142239_session-recovery-final.ts</path>
<type>file</type>
<content>
18: \`recovery_error\` text,
19: \`time_created\` integer NOT NULL,
20: CONSTRAINT \`session_recovery_pk\` PRIMARY KEY(\`execution_id\`),
21: CONSTRAINT \`fk_session_recovery_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE,
22: CONSTRAINT \`fk_session_recovery_assistant_message_id_session_message_id_fk\` FOREIGN KEY (\`assistant_message_id\`) REFERENCES \`session_message\`(\`id\`) ON DELETE CASCADE,
23: CONSTRAINT \`fk_session_recovery_continuation_message_id_session_input_id_fk\` FOREIGN KEY (\`continuation_message_id\`) REFERENCES \`session_input\`(\`id\`) ON DELETE CASCADE,
24: CONSTRAINT "session_recovery_wake_attempts_check" CHECK("wake_attempts" >= 0 AND "wake_attempts" <= 9007199254740991)
25: );
26: ...
Success. Updated the following files: D packages/core/src/database/migration/20260916142239_session-recovery-final.ts
bun test v1.3.14 (0d9b296a) test/session-projector.test.ts: # Unhandled error between tests ------------------------------- error: Cannot find module './migration/20260916142239_session-recovery-final' from '/home/byk/Code/opencode/packages/core/src/database/migration.gen.ts' ------------------------------- 0 pass 1 fail 1 error Ran 1 test across 1 file. [1200.00ms]
The generator correctly updated schema artifacts but also registered the temporary incremental migration before it was folded away. I’m removing only that generated registry entry while preserving the new snapshot/schema output; then the squashed migration remains the sole recovery migration and fresh databases exercise the constraints directly.
<path>/home/byk/Code/opencode/packages/core/src/database/migration.gen.ts</path>
<type>file</type>
<content>
1: import type { DatabaseMigration } from "./migration"
2:
3: export const migrations = (
4: await Promise.all([
5: import("./migration/20260127222353_familiar_lady_ursula"),
6: import("./migration/20260211171708_add_project_commands"),
7: import("./migration/20260213144116_wakeful_the_professor"),
8: import("./migration/20260225215848_workspace"),
9: import("./migration/20260227213759_add_session_workspace_id"),
10: import("./migration/20260228203230_blue_harpoon"),
11: import("./migration/20260303231226_add_workspace_fields"),
12: import("./migration/20260309230000_move_org_to_state"),
13: import("./migration/20260312043431_session_message_cursor"),
14: import("./migration/20260323234822_events"),
15: import("./migration/20260410174513_workspace-name"),
16: import("./migration/20260413175956_chief_energizer"),
17: ...
Success. Updated the following files: M packages/core/src/database/migration.gen.ts
bun test v1.3.14 (0d9b296a) 2 pass 35 filtered out 0 fail 9 expect() calls Ran 2 tests across 1 file. [3.32s] $ tsgo --noEmit
Scheduler starvation and poisoned counters are now covered, the squashed migration is again the only recovery migration, and Core typecheck passes. I’m running the complete 220-test recovery-owned gate plus migration consistency; if clean, I’ll run the full Core suite and all affected package typechecks before rebuilding the artifact.
bun test v1.3.14 (0d9b296a) 223 pass 0 fail 657 expect() calls Ran 223 tests across 8 files. [18.53s]
Reading config file '/tmp/opencode-core-migration-ytW1YB/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-ytW1YB/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-ytW1YB/full/20260916142511_schema/migration.sql 🚀
All 223 recovery-owned tests pass, and migration generation reports no outstanding incremental change. I’m running the full Core suite and the four remaining affected package typechecks in parallel; these are the final source gates before an exact SEA rebuild and isolated restart smoke.
<task id="ses_f556439f5ffeFhzECdcNxqBeH4" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[14:25:53.331] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
[14:26:57.548] INFO (#207607): watcher backend {
directory: "/tmp/opencode-core-test-gQbWKQ",
platform: "linux",
backend: "inotify",
}
[14:26:57.551] INFO (#207716): project copy refresh started {
projectID: "global",
}
[14:26:57.553] INFO (#207716): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[14:26:57.579] INFO (#207607): booting location services {
directory: "/tmp/opencode-core-test-gQbWKQ",
workspaceID: undefined,
}
1160 pass
0 fail
3246 ...
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
</task_result> </task>
All source gates pass: 1,160 Core tests, 223 focused recovery tests, five affected package typechecks, and migration consistency. The pre-freeze reviewer returned no evidence and is discarded; I’m proceeding with an exact SEA rebuild and isolated health/restart smoke, then I’ll freeze the new candidate and launch fresh immutable correctness and security reviews.
No files found
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0aaa04454001Nw4fqwEP2pDk4n dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
The exact SEA rebuilt successfully with only the known chunk-size and note-injection warnings. I’m verifying its hash, authenticated health, final SQLite constraints, clean shutdown, and two-restart exactly-once recovery in a disposable environment; no live service or durable user database will be touched.
Continue. Use lore recall or http://127.0.0.1:3207/ui/search for your recall tool usage at the tool is broken for now.
The source and artifact gates are complete. I’m resuming at the disposable smoke boundary: first I’ll re-establish the exact worktree/artifact identities, then run authenticated health, schema-constraint, clean-shutdown, and two-restart recovery checks without touching the live service.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
/tmp/opencode/session-recovery-v6-smoke.mjs
<path>/tmp/opencode/session-recovery-v6-smoke.mjs</path>
<type>file</type>
<content>
1: import { execFileSync, spawn } from "node:child_process"
2: import { once } from "node:events"
3: import { mkdir, rm } from "node:fs/promises"
4: import net from "node:net"
5: import path from "node:path"
6:
7: const executable = "/home/byk/Code/opencode/packages/opencode/dist/server/opencode-server"
8: const root = "/tmp/opencode/session-recovery-final-v6"
9: const database = path.join(root, "opencode.db")
10: const workspace = path.join(root, "workspace")
11: const password = "recovery-smoke"
12:
13: await rm(root, { recursive: true, force: true })
14: await Promise.all([
15: mkdir(workspace, { recursive: true }),
16: mkdir(path.join(root, "home"), { recursive: true }),
17: mkdir(path.join(root, "config"), { recursive: true }),
18: mkdir(path.join(root, "data"), { recursive: true }),
19: mkdir(path.join(root, "state"), { recursive: true }),
20: mkdir(path.join(root, "cache"), { ...
The prior smoke harness already isolates all state and exercises the intended crash window. I’m updating only its disposable namespace and schema evidence so this run also proves both persisted counter bounds exist in the packaged migration.
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v7-smoke.mjs
{
"initial": {
"port": 16851,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"constraints": [
{
"name": "session",
"sql": "CREATE TABLE `session` (\n `id` text PRIMARY KEY,\n `project_id` text NOT NULL,\n `workspace_id` text,\n `parent_id` text,\n `slug` text NOT NULL,\n `directory` text NOT NULL,\n `path` text,\n `title` text NOT NULL,\n `version` text NOT NULL,\n `share_url` text,\n ...
The packaged artifact passed authenticated health on three disposable launches, clean shutdown each time, both persisted counter constraints, one exact queued {"text":"continue"} promotion, and a duplicate-free final restart. I’m confirming no disposable server survived, then freezing the package-only patch against ab9408c81c, verifying clean application, and launching independent correctness and security reviews against the exact patch and artifact hashes.
(no output)
ab9408c81cc9916177641ae8e9f08624fc27b5b9 dc661f30cb2055ad6248fdc59b14998d834497be
The artifact identity is f02bc81487350640f989f37bb813043c88f80a8fb59a138bf08f4ff718b1b2ba; base and current committed identities remain ab9408c81c and dc661f30cb. Before writing the freeze, I’m auditing the exact package path list to ensure .lore.md, AGENTS.md, profiling files, and any unrelated package edits remain outside it.
M packages/core/schema.json M packages/core/src/database/migration.gen.ts A packages/core/src/database/migration/20260914170650_session-recovery.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts A packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts A packages/core/test/session-execution-local.test.ts M packages/core/test/session-projector.test.ts M packages/core/test/session-prompt.test.ts M packages/core/test/session-run-coordinator.test.ts M packages...
(no output)
The path audit caught two recovery-owned modules that remain untracked, so a normal git diff omitted them: execution/claim.ts and recovery-id.ts. I’m appending their full add-file diffs before hashing; this prevents another incomplete freeze.
9fdee0490a8756207a93adc96c708c0327e4a9b39c58301ce55aed70fe6fa00e /tmp/opencode/session-recovery-final-v7.patch packages/core/schema.json | 579 ++++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 58 + packages/core/src/database/schema.gen.ts | 49 + packages/core/src/session.ts | 9 packages/core/src/session/compaction.ts | 47 - packages/core/src/session/context-epoch.ts | 88 + packages/core/src/session/execution/local.ts | 154 +- packages/core/src/session/input.ts | 373 ++++ packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 78 + packages/core/src/session/recovery.ts | 413 ++++ packages/core/src/session/run-coordinator.ts | 35 packages/core/src/session/runner/index.ts | 4 ...
base-pipeline-numbered.txt base-test-numbered.txt llm-v5-current.diff local-v5-current.diff lore-ui-search-response oxfmt-opencode.json publisher-v5-current.diff restore-projector-v5-labeled.patch restore-projector-v5.patch restore-runner-v5-labeled.patch restore-runner-v5.patch session-recovery-final-v4 session-recovery-final-v4-diag session-recovery-final-v4.index session-recovery-final-v4.patch session-recovery-final-v5 session-recovery-final-v5.index session-recovery-final-v5.patch session-recovery-final-v6 session-recovery-final-v6.patch session-recovery-final-v7 session-recovery-final-v7.patch session-recovery-recheck.f3rLnS session-recovery-review.1VQHG0 session-recovery-review-c2c8afb0 session-recovery-review-v5-49183f9e session-recovery-review-v5-49183f9e.index session-recovery-v5-review.r03hHL session-recovery-v7-processes.txt session-recovery-v7-smoke.mjs Preparing worktree (detached HEAD ab9408c81c) HEAD is now at ab9408c81c feat(app): adapt vertical tab density
packages/core/schema.json | 579 +++++- packages/core/src/database/migration.gen.ts | 1 + packages/core/src/database/schema.gen.ts | 49 +- packages/core/src/session.ts | 9 +- packages/core/src/session/compaction.ts | 47 +- packages/core/src/session/context-epoch.ts | 88 +- packages/core/src/session/execution/local.ts | 154 +- packages/core/src/session/input.ts | 373 +++- packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 78 +- packages/core/src/session/run-coordinator.ts | 35 +- packages/core/src/session/runner/index.ts | 4 + packages/core/src/session/runner/llm.ts | 369 ++-- .../core/src/session/runner/publish-llm-event.ts | 108 +- packages/core/src/session/sql.ts | 79 +- packages/core/test/database-migration.test.ts | 67 +- ...
The package-only freeze now includes all 33 files, hashes to 9fdee0490a8756207a93adc96c708c0327e4a9b39c58301ce55aed70fe6fa00e, and applies cleanly to immutable base ab9408c81c. I’m launching two independent read-only gates against that exact patch and SEA hash, with explicit focus on prior lifecycle, starvation, counter-poisoning, migration-lineage, and security blockers.
<task id="ses_f55533879ffedcl8PcTEXEr7dQ" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f5552d8b4ffeuRdbaRzqllkuK7" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security ...
Candidate frozen and under independent review.
9fdee0490a8756207a93adc96c708c0327e4a9b39c58301ce55aed70fe6fa00ef02bc81487350640f989f37bb813043c88f80a8fb59a138bf08f4ff718b1b2baab9408c81cc9916177641ae8e9f08624fc27b5b9step-finish strands a running assistant permanentlystepSettlement exists only after a step-finish event (packages/core/src/session/runner/publish-llm-event.ts:445-450). On clean EOF without that event, the runner:
packages/core/src/session/runner/llm.ts:434-435);Step.Ended (packages/core/src/session/runner/llm.ts:437-469);packages/core/src/session/runner/llm.ts:470-473);packages/core/src/session/execution/local.ts:75-80).The existing test exercises this exact protocol boundary with a hosted tool call followed by EOF and no step-finish (packages/core/test/session-runner.test.ts:4138-4159), but it never asserts assistant completion or claim retention. The projected assistant retains no time.completedorfinish`, while the claim is gone, so recovery can never reconcile it.
Treat EOF without step-finish as an interrupted/unknown Step: either persist Step.Interrupted before marking the claim safe, or fail while preserving the unknown claim.
The publisher changes state before the corresponding durable event succeeds:
providerFailed is set before Step.Failed publishes (packages/core/src/session/runner/publish-llm-event.ts:453-455).assistantFailed and assistantActive change before that publish (packages/core/src/session/runner/publish-llm-event.ts:221-238).settled before Tool.Success or Tool.Failed publishes (packages/core/src/session/runner/publish-llm-event.ts:386-423).Two concrete failures follow:
Step.Failed publication defects after Step.Started committed, the stream fails but ...Correctness review found two real settlement gaps, so the frozen patch and artifact are rejected. I’m adding deterministic regressions for clean EOF, failed Step.Failed, and failed local Tool.Success publication, then I’ll move every in-memory settlement flag behind its durable event and preserve recovery ownership whenever terminal settlement does not commit.
The code checks ownership immediately before each external side effect, but the authorization can expire before the side effect starts:
packages/core/src/session/runner/llm.ts:304 renews the claim before creating/pulling the provider stream.packages/core/src/session/runner/llm.ts:326-335 renews the claim, then starts the local tool.packages/core/src/session/recovery.ts:81-97 lets another node take the claim once its lease expires.Concrete race:
toolMaterialization.settle(...).The same race exists between line 304’s renewal and provider dispatch. Later event publication remains fenced, but the external side effect has already happened. The regression at packages/core/test/session-runner.test.ts:793-830 changes ownership before the final renewal; it does not test a pause after a successful renewal.
This violates the required invariant that stale owners never dispatch providers or start local tools.
The claim scanner always selects the oldest 100 rows:
packages/core/src/session/recovery.ts:69-75Only malformed phases receive recovery_error:
packages/core/src/session/recovery.ts:99-117Other deterministic reconciliation defects are only logged:
packages/core/src/session/recovery.ts:143-190For example, a malformed latest assistant projection fails at packages/core/src/session/projector.ts:416-430, while a forged continuation-ID collision fails through SessionInput.projectAdmitted at ...
Security review also rejected the freeze. Two findings are actionable: conversion failures must quarantine poisoned execution claims, and saturated counters cannot serve as CAS tokens. The dispatch finding describes the already documented instruction-level race between the final synchronous fence and an external call; lease-only recovery cannot eliminate it without provider/tool idempotency or holding a distributed transaction across side effects, so I will not claim that boundary is solved. I’ll fix the actionable blockers and make the residual explicit in the next gate.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
180:
181: const flushFragments = Effect.fnUntraced(function* () {
182: yield* text.flush()
183: yield* reasoning.flush()
184: yield* toolInput.flush()
185: })
186:
187: const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
188: if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`)
189: const assistantMessageID = yield* startAssistant()
190: tools.set(event.id, {
191: assistantMessageID,
192: name: event.name,
193: inputEnded: false,
194: called: false,
195: settled: false,
196: providerExecuted: false,
197: })
198: yield* toolInput.start(event.id)
199: yield* publishEvent(SessionEvent.Tool.Input.Started, {
200: sessionID: input.sessionID,
201: timestamp: yield* timestamp,
202: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
260: http: {
261: headers: {
262: "x-session-affinity": session.id,
263: "X-Session-Id": session.id,
264: ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
265: },
266: },
267: providerOptions: { openai: { promptCacheKey } },
268: system: [agent.info?.system, system.baseline]
269: .filter((part): part is string => part !== undefined && part.length > 0)
270: .map(SystemPart.make),
271: messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
272: tools: toolMaterialization?.definitions ?? [],
273: toolChoice: isLastStep ? "none" : undefined,
274: })
275: if (
276: yield* compaction.compactIfNeeded({
277: sessionID: session.id,
278: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: readonly startCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
15: readonly eventCommit: () => Effect.Effect<void>
16: }
17:
18: const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, Duration, Effect, Exit, Layer, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { SessionExecutionClaim } from "./claim"
13: import { SESSION_EXECUTION_LEASE_MS, SessionExecutionTable, SessionRecoveryTable } from "../sql"
14:
15: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. ...
Found 4 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 825: event.type.startsWith(SessionEvent.Tool.Success.type) ||
Line 1984: CREATE TRIGGER fail_hosted_tool_reconciliation
Line 2015: it.effect("never commits a Step end after the provider stream fails", () =>
Line 2077: CREATE TRIGGER fail_provider_tool_reconciliation
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1900: },
1901: ])
1902: }),
1903: )
1904:
1905: it.effect("continues with reloaded history after durably settling one local tool call", () =>
1906: Effect.gen(function* () {
1907: yield* setup
1908: const session = yield* SessionV2.Service
1909: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false })
1910:
1911: requests.length = 0
1912: authorizations.length = 0
1913: executions.length = 0
1914: streamGate = undefined
1915: streamStarted = undefined
1916: responses = [
1917: [
1918: LLMEvent.stepStart({ index: 0 }),
1919: LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
1920: LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
1921: LLMEvent.finish({ reason: "tool-calls" ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
4080: )
4081:
4082: it.effect("does not continue automatically after a provider error follows a local tool call", () =>
4083: Effect.gen(function* () {
4084: yield* setup
4085: const session = yield* SessionV2.Service
4086: yield* session.prompt({
4087: sessionID,
4088: prompt: Prompt.make({ text: "Do not continue failed provider" }),
4089: resume: false,
4090: })
4091:
4092: requests.length = 0
4093: const executionCount = executions.length
4094: response = [
4095: LLMEvent.stepStart({ index: 0 }),
4096: LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
4097: LLMEvent.providerError({ message: "Provider unavailable" }),
4098: ]
4099:
4100: yield* session.resume(sessionID)
4101:
4102: ...
Found 1 matches /home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts: Line 466: hasActiveAssistant: () => assistantActive,
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, exists, gt, isNull, lte, or, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { SessionInput } from "./input"
13: import { SessionSchema } from "./schema"
14: import {
15: MAX_WAKE_ATTEMPTS,
16: SESSION_EXECUTION_LEASE_MS,
17: SessionExecutionTable,
18: SessionInputTable,
19: SessionRecoveryTable,
20: SessionMessageTable,
21: SessionTable,
22: } from "./sql"
23:
24: export ...
The settlement fixes require a narrow state-ordering change: assistantFailed, providerFailed, tool called, and tool settled will change only after their durable events commit. Clean EOF without step-finish will durably interrupt the Step before a successful drain can clear ownership. I’ll also reserve the maximum retry count as a terminal poison sentinel, exclude it from scheduling, and quarantine execution rows whose recovery conversion defects.
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260914170650_session-recovery",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`ALTER TABLE \`session_input\` ADD \`wake_pending\` integer DEFAULT false NOT NULL;`)
9: yield* tx.run(
10: `ALTER TABLE \`session\` ADD \`wake_attempts\` integer DEFAULT 0 NOT NULL CHECK(\`wake_attempts\` >= 0 AND \`wake_attempts\` <= 9007199254740991);`,
11: )
12: yield* tx.run(`
13: CREATE TABLE \`session_execution\` (
14: \`id\` text NOT NULL,
15: \`session_id\` text NOT NULL,
16: \`owner_id\` text NOT NULL,
17: \`assistant_message_id\` text,
18: \`phase\` text NOT NULL,
19: \`time_created\` integer NOT NULL,
20: ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import {
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Model,
7: TransportReason,
8: InvalidRequestReason,
9: type LLMClientShape,
10: type LLMRequest,
11: } from "@opencode-ai/llm"
12: import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
13: import { Database } from "@opencode-ai/core/database/database"
14: import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
15: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
16: import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
17: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
18: import { EventV2 } from "@opencode-ai/core/event"
19: import { PermissionV2 } from "@opencode-ai/core/permission"
20: import { EventTable } from "@opencode-ai/core/event/sql"
21: import { Project } ...
Found 3 matches /home/byk/Code/opencode/packages/core/test/session-runner.test.ts: Line 1985: BEFORE INSERT ON event Line 2078: BEFORE INSERT ON event Line 2096: .where(eq(EventTable.type, "session.next.step.failed.2"))
No files found
/home/byk/Code/opencode/packages/core/src/session/event.ts
<path>/home/byk/Code/opencode/packages/core/src/session/event.ts</path> <type>file</type> <content> 1: export * from "@opencode-ai/schema/session-event" 2: export * as SessionEvent from "@opencode-ai/schema/session-event" (End of file - total 2 lines) </content>
Found 2 matches /home/byk/Code/opencode/packages/schema/src/session-event.ts: Line 199: type: "session.next.step.failed", Line 379: type: "session.next.tool.success",
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
175: export const Ended = Event.define({
176: type: "session.next.step.ended",
177: ...stepSettlementOptions,
178: schema: {
179: ...Base,
180: assistantMessageID: SessionMessage.ID,
181: finish: Schema.String,
182: cost: Schema.Finite,
183: tokens: Schema.Struct({
184: input: Schema.Finite,
185: output: Schema.Finite,
186: reasoning: Schema.Finite,
187: cache: Schema.Struct({
188: read: Schema.Finite,
189: write: Schema.Finite,
190: }),
191: }),
192: snapshot: Schema.String.pipe(optional),
193: files: Schema.Array(RelativePath).pipe(optional),
194: },
195: })
196: export type Ended = typeof Ended.Type
197:
198: export const Failed = Event.define({
199: type: "session.next.step.failed",
200: ...stepSettlementOptions,
201: schema: {
202: ...
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
360: })
361: export type Called = typeof Called.Type
362:
363: /**
364: * Replayable bounded running-tool state. Tools should checkpoint semantic
365: * transitions or at a bounded cadence, not persist every stdout/stderr chunk.
366: */
367: export const Progress = Event.define({
368: type: "session.next.tool.progress",
369: ...options,
370: schema: {
371: ...ToolBase,
372: structured: Schema.Record(Schema.String, Schema.Unknown),
373: content: Schema.Array(ToolContent),
374: },
375: })
376: export type Progress = typeof Progress.Type
377:
378: export const Success = Event.define({
379: type: "session.next.tool.success",
380: ...options,
381: schema: {
382: ...ToolBase,
383: structured: Schema.Record(Schema.String, Schema.Unknown),
384: content: Schema.Array(ToolContent),
385: outputPaths: ...
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/runner/llm.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts
The first production ordering patch is in. Before broad validation, I’m adding the three settlement regressions and implementing the two scheduler hardenings from security review: failed execution conversion gets quarantined under the exact acquired claim, and MAX_WAKE_ATTEMPTS becomes a rotating CAS value rather than a same-value write.
Success. Updated the following files: M packages/core/src/session/recovery.ts
Success. Updated the following files: M packages/core/test/session-runner.test.ts
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
2062 | .select({ type: EventTable.type })
2063 | .from(EventTable)
2064 | .where(eq(EventTable.type, "session.next.step.failed.2"))
2065 | .all(),
2066 | ).toEqual([])
2067 | expect(yield* session.context(sessionID)).toMatchObject([
^
error: expect(received).toMatchObject(expected)
@@ -2,5 +2,10 @@
{
+ "id": "msg_0aac281ff001IQNW5GgMIIr1Jg",
"text": "Fail Step publication",
+ "time": {
+ "created": {
+ "epochMilliseconds": 0,
+ "partsUtc": undefined,
+ },
+ },
"type": "user",
@@ -8,3 +13,15 @@
{
- "finish": undefined,
+ "agent": "build",
+ "content": [],
+ "id": "msg_0aac2824f001C77LKjA94lVWsc",
+ "model": {
+ "id": "fake-model",
+ "providerID": "fake",
+ },
+ "time": {
+ "created": {
+ "epochMilliseconds": 0,
+ "partsUtc": undefined,
+ },
+ ...
The new tests already confirm clean EOF is fixed. The Step.Failed test only needs to assert the optional terminal fields are absent, while the tool-publication test entered the expected continuation path after durable fallback reconciliation; I’m making that crash boundary explicit so the test observes the first Step without waiting for a second model response.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) 3 pass 103 filtered out 0 fail 8 expect() calls Ran 3 tests across 1 file. [2.11s]
All three settlement regressions now pass. I’m completing the security evidence with two deterministic tests: one proves a conversion-defect claim is quarantined so claim 101 progresses on the next bounded scan; the other seeds multiple maximum-valued retry counters and proves each CAS changes state and rotates service rather than issuing duplicate wakes.
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
430: expect.objectContaining({
431: execution_id: "lost-wake",
432: continuation_message_id: continuation("lost-wake"),
433: assistant_message_id: null,
434: phase: "unknown",
435: wake_pending: true,
436: }),
437: ])
438: }),
439: )
440:
441: it.effect("quarantines a malformed recovery row without blocking a valid wake", () =>
442: Effect.gen(function* () {
443: const { db } = yield* Database.Service
444: const events = yield* EventV2.Service
445: const valid = SessionV2.ID.make("ses_recovery_row_valid")
446: yield* db
447: .insert(ProjectTable)
448: .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
449: .run()
450: yield* db
451: .insert(SessionTable)
452: .values(
453: ...
Found 12 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 417: recovery_error: null,
Line 487: .select({ error: SessionRecoveryTable.recovery_error })
Line 495: it.effect("quarantines a full batch of poison recovery rows before redriving valid work", () =>
Line 544: .where(eq(SessionRecoveryTable.recovery_error, "Invalid recovery continuation input"))
Line 554: .where(eq(SessionRecoveryTable.recovery_error, "Invalid recovery continuation input"))
Line 925: recovery_error: "Invalid recovery continuation input",
Line 1255: recovery_error: "Invalid recovery projection identity",
Line 1364: recovery_error: "Invalid recovery projection identity",
Line 1425: recovery_error: "Invalid recovery projection identity",
Line 1470: recovery_error: "Invalid recovery continuation input",
Line 1534: .select({ error: ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1460: phase: "ready",
1461: })
1462: const continuationID = continuation("quarantined-steer")
1463: yield* db
1464: .update(SessionInputTable)
1465: .set({ delivery: "steer" })
1466: .where(eq(SessionInputTable.id, continuationID))
1467: .run()
1468: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
1469: expect(yield* db.select().from(SessionRecoveryTable).get()).toMatchObject({
1470: recovery_error: "Invalid recovery continuation input",
1471: })
1472: expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(false)
1473: expect(yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)).toBe(0)
1474:
1475: const ordinaryID = SessionMessage.ID.make("msg_recovery_later_steer")
1476: ...
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
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* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
392: yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
393: yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
394: yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
395: yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
396: yield* events.project(SessionEvent.Step.Ended, ...
Success. Updated the following files: M packages/core/src/session/recovery.ts
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
[15:11:44.345] ERROR (#62): 1664 |
1665 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution))
1666 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution))
1667 |
1668 | expect(wakes).toEqual(expect.arrayContaining([...recoverySessions, ...ordinarySessions]))
1669 | expect(yield* db.select({ attempts: SessionRecoveryTable.wake_attempts }).from(SessionRecoveryTable).all()).toEqual([
^
error: expect(received).toEqual(expected)
@@ -2,6 +2,6 @@
{
- "attempts": 1,
+ "attempts": 2,
},
{
- "attempts": 1,
+ "attempts": 2,
},
- Expected - 2
+ Received + 2
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:1669:115
1664 |
1665 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, ...
The conversion-quarantine regression passes. The maximum-counter test failed only because it intentionally runs two recovery cycles: MAX rotates to 1, then the next successful reservation advances to 2. I’m correcting that assertion and adding per-Session wake counts, which proves no row receives duplicate wake from one scan while all saturated rows remain eligible across scans.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 2 pass 37 filtered out 0 fail 6 expect() calls Ran 2 tests across 1 file. [5.23s] $ tsgo --noEmit
The new conversion-quarantine and maximum-counter regressions pass, and Core typecheck remains clean. I’m running the complete recovery-owned suite now; this will expose any ordering regressions across cancellation, provider failures, tool continuations, projection replay, bounded scheduling, and migration behavior before another full-suite/artifact cycle.
...output truncated...
Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0aac80c14001iyeIwhQBVp3pDJ
at /home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/Semaphore.js:120:6
at runTasks (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/Scheduler.js:150:15)
at afterScheduled (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/Scheduler.js:140:10)
(fail) SessionRunnerLLM > starts a real runner turn after default prompt recording [77.89ms]
981 | expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect"])
982 | expect(requests[0]?.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
983 | { role: "user", content: [{ type: "text", text: "First" }] },
984 | { role: "user", content: [{ type: "text", text: "Second" }] },
985 | ])
986 | ...
The focused suite exposed an over-broad EOF repair: empty provider streams intentionally create no assistant and must remain no-ops. I’m narrowing interruption to streams that actually started an assistant; the one partial-tool-input fixture is a true incomplete Step and its expected projection must change from pending to interrupted error.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
460: expectedAssistant: { type: "assistant", finish: "stop", content: [expectedContent] },
461: expectedContent,
462: }
463: }
464: case "reasoning": {
465: const partialEvents = [
466: LLMEvent.stepStart({ index: 0 }),
467: LLMEvent.reasoningStart({ id }),
468: ...chunks.map((text) => LLMEvent.reasoningDelta({ id, text })),
469: ]
470: const expectedContent = { type: "reasoning", id, text }
471: return {
472: delta: SessionEvent.Reasoning.Delta,
473: partialEvents,
474: completeEvents: [
475: ...partialEvents,
476: LLMEvent.reasoningEnd({ id }),
477: LLMEvent.stepFinish({ index: 0, reason: "stop" }),
478: LLMEvent.finish({ reason: "stop" }),
479: ],
480: expectedAssistant: { type: "assistant", finish: "stop", content: ...
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
522 | .where(eq(EventTable.type, EventV2.versionedType(fixture.delta.type, 1)))
523 | .all()
524 | .pipe(Effect.orDie)
525 | expect(Array.from(yield* Fiber.join(live))).toHaveLength(32)
526 | expect(deltas).toHaveLength(0)
527 | expect(yield* session.context(sessionID)).toMatchObject(expectedContext)
^
error: expect(received).toMatchObject(expected)
@@ -2,5 +2,10 @@
{
+ "id": "msg_0aac8dad9001AxsSlIMe8kA2CD",
"text": "Stream tool input",
+ "time": {
+ "created": {
+ "epochMilliseconds": 0,
+ "partsUtc": undefined,
+ },
+ },
"type": "user",
@@ -8,3 +13,3 @@
{
+ "agent": "build",
"content": [
@@ -12,6 +17,22 @@
"id": "call-many",
+ "name": "echo",
"state": {
- "input": "0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,",
- "status": ...
The remaining failure is the generic tool-input delta fixture, which ends after tool-input-end without a tool call or step-finish. That is now correctly classified as an incomplete provider Step; I’m updating only that fixture’s durable expectation while retaining its live-delta and replay assertions.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) 227 pass 0 fail 670 expect() calls Ran 227 tests across 8 files. [36.21s]
The complete recovery-owned suite now passes: 227 tests and 670 assertions. The fixes preserve intentional empty-stream behavior, while any stream that starts an assistant but omits step-finish now durably interrupts that Step. I’m running the full Core suite, all affected typechecks, and migration consistency again before rebuilding the exact artifact.
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[15:16:35.041] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
1164 pass
0 fail
3259 expect() calls
Ran 1164 tests across 145 files. [89.71s]
[15:17:57.668] INFO (#208910): watcher backend {
directory: "/tmp/opencode-core-test-eNyy6p",
platform: "linux",
backend: "inotify",
}
[15:17:57.675] INFO (#209019): project copy refresh started {
projectID: "global",
}
[15:17:57.679] INFO (#209019): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[15:17:57.747] INFO (#208910): booting location services {
directory: ...
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-QGZ2W4/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-QGZ2W4/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-QGZ2W4/full/20260916152114_schema/migration.sql 🚀
All revised source gates pass: 1,164 Core tests, 227 focused tests, five package typechecks, and migration consistency. The remaining security observation is the accepted instruction-level interval after the final ownership check and before an external provider/tool call; this lease protocol never claims exactly-once dispatch across that interval. I’m rebuilding the SEA and repeating the isolated schema/restart smoke against the revised source.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0aacff186001woPCCQAB1vnOyJ dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
The revised SEA build succeeded with only the known warnings. I’m cloning the proven smoke harness to a fresh disposable namespace, then verifying the named migration constraint, authenticated health, clean shutdown, and duplicate-free recovery across restarts.
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v8-smoke.mjs
{
"initial": {
"port": 19755,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"constraints": [
{
"name": "session",
"sql": "CREATE TABLE `session` (\n `id` text PRIMARY KEY,\n `project_id` text NOT NULL,\n `workspace_id` text,\n `parent_id` text,\n `slug` text NOT NULL,\n `directory` text NOT NULL,\n `path` text,\n `title` text NOT NULL,\n `version` text NOT NULL,\n `share_url` text,\n ...
The revised artifact passed all three disposable launches, the named migration constraints, exactly one synthetic continuation, and a duplicate-free final restart. I’m confirming cleanup and freezing v8 now; the review prompt will state the external-dispatch instruction interval as an explicit residual, not as an exactly-once guarantee.
(no output)
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
446412b56ac31b2a60f98765b5f14955a771f6074ebeb9309f603a0ceefad1bc /tmp/opencode/session-recovery-final-v8.patch packages/core/schema.json | 579 +++++- packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 58 + packages/core/src/database/schema.gen.ts | 49 packages/core/src/session.ts | 9 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 + packages/core/src/session/execution/local.ts | 154 +- packages/core/src/session/input.ts | 373 +++- packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 78 + packages/core/src/session/recovery.ts | 449 ++++ packages/core/src/session/run-coordinator.ts | 35 packages/core/src/session/runner/index.ts | 4 ...
HEAD is now at ab9408c81c feat(app): adapt vertical tab density Removing packages/core/src/database/migration/20260914170650_session-recovery.ts Removing packages/core/src/session/execution/claim.ts Removing packages/core/src/session/recovery-id.ts Removing packages/core/src/session/recovery.ts Removing packages/core/test/session-execution-local.test.ts
The v8 freeze contains the same 33 intended files, hashes to 446412b56ac31b2a60f98765b5f14955a771f6074ebeb9309f603a0ceefad1bc, and applies cleanly to ab9408c81c. The exact SEA hashes to f4f0bd585d33bd47114b2d8d54082c2b061380771c808e909223350e62c8c72f; I’m launching replacement immutable reviews focused on the five closed blockers and the explicitly accepted external-dispatch residual.
<task id="ses_f552d7c7effePCgl7bQO4Ddd62" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f552d2a9dffenE5MLy6ZqIeZr4" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Fix EOF and durable settlement ordering blockers",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first settlement publication regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "in_progress",
...
V8 candidate frozen and under independent review.
446412b56ac31b2a60f98765b5f14955a771f6074ebeb9309f603a0ceefad1bcf4f0bd585d33bd47114b2d8d54082c2b061380771c808e909223350e62c8c72f</task_result> </task>
The security reviewer returned no substantive evidence, so its result is invalid. I’m using the required one same-session retry with an evidence-or-blocker contract; if it returns empty again, I’ll replace it without weakening the gate.
<task id="ses_f552d2a9dffenE5MLy6ZqIeZr4" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Security review returned empty and was retried in the same reviewer session. The immutable merge gate remains blocked pending substantive correctness and security verdicts.
</task_result> </task>
The same security reviewer returned empty twice and is disqualified. I’m replacing it with a fresh independent reviewer against the unchanged v8 patch and artifact; the correctness review remains separate and active.
<task id="ses_f551b04ebffedihDg3wK5qmndu" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Replacement security review launched against the unchanged v8 hashes. Merge remains blocked until both independent reviews return substantive approval.
None.
None.
The acknowledged instruction interval before external provider/tool dispatch remains irreducible. The implementation does not claim exactly-once dispatch and never replays uncertain work.
ab9408c81cc9916177641ae8e9f08624fc27b5b9; patch SHA-256 446412...ad1bc; SEA SHA-256 f4f0bd...c72f. All 33 paths and old/new blob hashes match; reverse application succeeds; index remains clean.Step.Interrupted; failed publication leaves the claim unknown for recovery. Empty streams create no assistant (runner/llm.ts:437-444, publish-llm-event.ts:241-260, tests session-runner.test.ts:958-999).publish-llm-event.ts:221-239,262-280,362-457). Publication-defect regressions cover Step.Failed and local Tool.Success (session-runner.test.ts:2049-2121).recovery.ts:69-117,143-212). The 100-poison/claim-101 regression is explicit (session-projector.test.ts:1545-1608).MAX_WAKE_ATTEMPTS to 1, retain attempts/wake/error CAS predicates, guarantee retry capacity, and rotate work (recovery.ts:215-333,335-432; regression session-projector.test.ts:1611-1683).session_wake_attempts_check and session_recovery_wake_attempts_check with identical bounds (20260914170650_session-recovery.ts:9-11,39; schema.gen.ts:212,248; schema.json:2541-2549). ...Correctness review approved the exact v8 patch and artifact with no findings. The security replacement remains the only active gate; I will not change or merge the candidate while it runs.
Correctness review: MERGE, with no must-fix findings or concerns.
Security approval remains pending. The candidate stays frozen and unmerged.
session_recovery.execution_id is globally unique (packages/core/src/session/sql.ts:204, migration line 38), but the projector checks for an existing recovery using both execution and Session IDs:
packages/core/src/session/projector.ts:403-414A corrupt row may therefore hold execution ID E for Session B with a noncanonical continuation. When recovery for Session A also uses E, the projector:
projector.ts:433-441..onConflictDoNothing() at projector.ts:442-455.A’s continuation now has no session_recovery row. Consequently:
isRecoveryInput returns false (input.ts:434-445).input.ts:62-67, 112-161).input.ts:539-558).This violates the same-Session, internal-only continuation boundary under the requested corrupt-row and forged-identity threat model.
The projector must query execution IDs globally and reject mismatched existing identity. It must also require the recovery insert to affect exactly one row rather than silently accepting conflicts. Add a regression with a malformed Session-B recovery sharing Session A’s execution ID.
Explicit interruption starts a heartbeat but discards its fiber:
packages/core/src/session/execution/local.ts:110-114It then waits for the runner and all finalizers before clearing ownership:
packages/core/src/session/execution/local.ts:115-131The replacement security review found three concrete blockers, so v8 is rejected despite correctness approval. I’m reproducing each against the current tree before editing: cross-Session execution-ID collision, cancellation heartbeat loss during blocked finalization, and quarantine after lease expiry; I’ll also fold in the assistant-start publication-order concern.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Fix EOF and durable settlement ordering blockers",
"status": "completed",
"priority": "high"
},
{
"content": "Reproduce and fix v8 security blockers",
"status": "in_progress",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, Duration, Effect, Exit, Layer, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { SessionExecutionClaim } from "./claim"
13: import { SESSION_EXECUTION_LEASE_MS, SessionExecutionTable, SessionRecoveryTable } from "../sql"
14:
15: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
45: or(
46: and(isNull(SessionRecoveryTable.execution_id), eq(SessionInputTable.wake_pending, true)),
47: and(
48: eq(SessionRecoveryTable.wake_pending, true),
49: isNull(SessionRecoveryTable.recovery_error),
50: excludeRecoveryExecutionID === undefined
51: ? undefined
52: : sql`${SessionRecoveryTable.execution_id} <> ${excludeRecoveryExecutionID}`,
53: ),
54: ),
55: ),
56: )
57: .limit(1)
58: .get()
59: .pipe(Effect.orDie)
60: return pending !== undefined
61: })
62:
63: export const recover = Effect.fn("SessionRecovery.recover")(function* () {
64: const { db } = yield* Database.Service
65: const events = yield* EventV2.Service
66: const execution = yield* SessionExecution.Service
67: const ownerID = crypto.randomUUID()
68: const scanTime = ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
65: definition.durable !== undefined
66: ? events.publish(definition, data, { commit })
67: : commit().pipe(Effect.andThen(events.publish(definition, data)))
68: const tools = new Map<
69: string,
70: {
71: readonly assistantMessageID: SessionMessage.ID
72: readonly name: string
73: inputEnded: boolean
74: called: boolean
75: settled: boolean
76: providerExecuted: boolean
77: providerMetadata?: ProviderMetadata
78: }
79: >()
80: const timestamp = DateTime.now
81: let assistantMessageID: SessionMessage.ID | undefined
82: let assistantActive = false
83: let assistantFailed = false
84: let providerFailed = false
85: let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
86:
87: const startAssistant = Effect.fnUntraced(function* () ...
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
3: import { TestClock } from "effect/testing"
4: import { eq } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { Project } from "@opencode-ai/core/project"
7: import { ProjectTable } from "@opencode-ai/core/project/sql"
8: import { AbsolutePath } from "@opencode-ai/core/schema"
9: import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
10: import { SessionSchema } from "@opencode-ai/core/session/schema"
11: import { SessionExecutionTable, SessionTable } from "@opencode-ai/core/session/sql"
12: import { testEffect } from "./lib/effect"
13:
14: const sessionID = SessionSchema.ID.make("ses_execution_local")
15: const it = testEffect(Database.layerFromPath(":memory:"))
16:
17: const ...
The first and third blockers share a transaction-boundary issue: recovery identity and quarantine must fail closed without relying on lease freshness after a failed projection. I’m changing projector conflicts to abort the event transaction and changing quarantine to fence exact ownership regardless of expiry, while treating an already-changed owner as transferred responsibility. Cancellation will supervise the renewal fiber instead of discarding it, so renewal loss interrupts cleanup rather than leaving an unobserved stale finalizer.
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
80: const next = makeEntry()
81: active.set(key, next)
82: start(key, next, true)
83: return restore(Deferred.await(next.done))
84: })
85:
86: const wake = (key: Key) =>
87: Effect.sync(() => {
88: const entry = active.get(key)
89: if (entry !== undefined) {
90: entry.pendingWake = true
91: return
92: }
93:
94: const next = makeEntry()
95: active.set(key, next)
96: start(key, next, false)
97: })
98:
99: const interrupt = (key: Key, cleanup = Effect.void): Effect.Effect<void> =>
100: Effect.suspend(() => {
101: const entry = active.get(key)
102: const owner = entry?.owner
103: if (entry === undefined || owner === undefined) return cleanup
104: entry.stopping = true
105: entry.pendingWake = false
106: ...
Success. Updated the following files: M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/runner/publish-llm-event.ts
Found 1 matches /home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts: Line 107: yield* Fiber.interrupt(owner)
Found 1 matches /home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts: Line 249: SessionEvent.Step.Interrupted,
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
250: const system =
251: initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id, claim))
252: const model = yield* models.resolve(session)
253: const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
254: const context = entries.map((entry) => entry.message)
255: const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
256: const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
257: const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
258: const request = LLM.request({
259: model,
260: http: {
261: headers: {
262: "x-session-affinity": session.id,
263: "X-Session-Id": ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
360: return yield* Effect.uninterruptibleMask((restore) =>
361: Effect.gen(function* () {
362: yield* updateExecution({
363: phase: "unknown",
364: assistant_message_id: null,
365: })
366: const stream = yield* restore(providerStream).pipe(Effect.exit)
367: const failure =
368: stream._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(stream.cause)) : undefined
369: if (
370: recoverOverflow &&
371: !publisher.hasAssistantStarted() &&
372: isContextOverflowFailure(overflowFailure ?? failure) &&
373: (yield* restore(
374: recoverOverflow({
375: sessionID: session.id,
376: entries,
377: model,
378: request,
379: beforeStream: () ...
The cancellation blocker cannot be solved by merely observing a failed heartbeat: an uninterruptible or blocked finalizer may continue after ownership is lost. I’m tracing the coordinator/Effect interruption contract now to preserve the lease until cleanup completes without falsely claiming a guarantee the lease protocol cannot enforce. The fix must either make finalizer completion part of supervised ownership or prevent recovery from treating cancellation as runnable work.
Found 38 matches /home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/FiberSet.ts: Line 315: fiber.interruptUnsafe(internalFiberId) /home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/FiberMap.ts: Line 355: fiber.interruptUnsafe(internalFiberId) Line 362: fiber.interruptUnsafe(internalFiberId) Line 367: previous.value.interruptUnsafe(internalFiberId) /home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/FiberHandle.ts: Line 316: fiber.interruptUnsafe(internalFiberId) Line 320: fiber.interruptUnsafe(internalFiberId) Line 325: self.state.fiber.interruptUnsafe(internalFiberId) /home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Fiber.ts: Line 46: * Prefer the exported functions in this module over calling `interruptUnsafe` Line 87: readonly interruptUnsafe: ...
<path>/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Fiber.ts</path>
<type>file</type>
<content>
250: * **Gotchas**
251: *
252: * Joining a failed fiber propagates the fiber's Cause. Use {@link await_ await} when
253: * you need to inspect the `Exit` instead of failing.
254: *
255: * **Example** (Joining a fiber)
256: *
257: * ```ts
258: * import { Effect, Fiber } from "effect"
259: *
260: * const program = Effect.gen(function*() {
261: * const fiber = yield* Effect.forkChild(Effect.succeed(42))
262: * const result = yield* Fiber.join(fiber)
263: * console.log(result) // 42
264: * })
265: * ```
266: *
267: * @see {@link await_ await} for inspecting the fiber outcome as an Exit
268: *
269: * @category combinators
270: * @since 2.0.0
271: */
272: export const join: <A, E>(self: Fiber<A, E>) => Effect<A, E> = effect.fiberJoin
273:
274: /**
275: * Waits for all fibers to succeed and returns their values in input ...
Success. Updated the following files: M packages/core/src/session/execution/local.ts
Found 11 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 178: if (!replayedContinuation) return yield* Effect.die("Replayed recovery continuation not found")
Line 222: it.effect("records a recovery continuation without forging an assistant identity", () =>
Line 491: ).toEqual({ error: "Invalid recovery continuation input" })
Line 544: .where(eq(SessionRecoveryTable.recovery_error, "Invalid recovery continuation input"))
Line 554: .where(eq(SessionRecoveryTable.recovery_error, "Invalid recovery continuation input"))
Line 925: recovery_error: "Invalid recovery continuation input",
Line 1255: recovery_error: "Invalid recovery projection identity",
Line 1364: recovery_error: "Invalid recovery projection identity",
Line 1425: recovery_error: "Invalid recovery projection identity",
Line 1437: it.effect("never promotes a quarantined recovery continuation as an ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1180: agent: "build",
1181: model,
1182: })
1183:
1184: const recovered = yield* events
1185: .publish(SessionEvent.Step.Recovered, {
1186: sessionID,
1187: timestamp: DateTime.makeUnsafe(1),
1188: executionID: "cross-session",
1189: assistantMessageID: assistantID,
1190: phase: "unknown",
1191: })
1192: .pipe(Effect.exit)
1193:
1194: expect(Exit.isFailure(recovered)).toBe(true)
1195: expect(yield* db.select().from(SessionRecoveryTable).all()).toEqual([])
1196: expect(yield* db.select().from(SessionInputTable).all()).toEqual([])
1197: }),
1198: )
1199:
1200: it.effect("quarantines a recovery wake behind a newer assistant", () =>
1201: Effect.gen(function* () {
1202: const { db } = yield* Database.Service
1203: const events = yield* ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1120: definition.type === SessionEvent.Step.Recovered.type
1121: ? SessionInput.admit(db, events, {
1122: id: inputID,
1123: sessionID,
1124: prompt: Prompt.make({ text: "pending" }),
1125: delivery: "steer",
1126: requestExecution: true,
1127: }).pipe(Effect.andThen(events.publish(definition, data, options)))
1128: : events.publish(definition, data, options),
1129: })
1130: const wakes: string[] = []
1131:
1132: yield* SessionRecovery.recover().pipe(
1133: Effect.provideService(EventV2.Service, intercepted),
1134: Effect.provideService(
1135: SessionExecution.Service,
1136: SessionExecution.Service.of({
1137: ...recoveryExecution,
1138: wake: (id) => Effect.sync(() => ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { DateTime, Deferred, Effect, Exit, Fiber, Schema } from "effect"
3: import { TestClock } from "effect/testing"
4: import { asc, eq, sql } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
7: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
8: import { EventV2 } from "@opencode-ai/core/event"
9: import { EventTable } from "@opencode-ai/core/event/sql"
10: import { ModelV2 } from "@opencode-ai/core/model"
11: import { Project } from "@opencode-ai/core/project"
12: import { ProjectTable } from "@opencode-ai/core/project/sql"
13: import { ProviderV2 } from "@opencode-ai/core/provider"
14: import { AbsolutePath } from "@opencode-ai/core/schema"
15: import { SessionV2 } from ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1060: session_id: sessionID,
1061: owner_id: "dead",
1062: phase: "ready",
1063: time_created: 0,
1064: expires_at: 0,
1065: })
1066: .run()
1067: const wakes: string[] = []
1068:
1069: yield* SessionRecovery.recover().pipe(
1070: Effect.provideService(
1071: SessionExecution.Service,
1072: SessionExecution.Service.of({
1073: ...recoveryExecution,
1074: wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid),
1075: }),
1076: ),
1077: )
1078:
1079: expect(wakes).toEqual([sessionID])
1080: expect(yield* db.select().from(SessionExecutionTable).all()).toEqual([])
1081: expect(yield* db.select().from(SessionRecoveryTable).all()).toHaveLength(1)
1082: expect(yield* ...
The exact fix for cancellation is narrower than a new protocol state: supervise renewal and never detach it, while keeping coordinator cleanup in the race. This catches renewal loss immediately, but it cannot make an uninterruptible arbitrary finalizer safe after durable ownership is already stolen; that is the same failure-detector limit as any lease. I’m adding tests that distinguish the enforceable guarantee: no takeover while renewal is healthy, and cancellation fails closed as soon as ownership changes.
Found 5 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 852: yield* events.publish(SessionEvent.Step.Started, {
Line 3000: yield* events.publish(SessionEvent.Step.Started, {
Line 3064: yield* events.publish(SessionEvent.Step.Started, {
Line 3124: yield* events.publish(SessionEvent.Step.Started, {
Line 4104: it.effect("projects provider errors emitted before assistant step start", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
1960: name: "echo",
1961: state: {
1962: status: "completed",
1963: input: { text: "hello" },
1964: structured: { text: "hello" },
1965: content: [{ type: "text", text: "hello" }],
1966: },
1967: },
1968: ],
1969: },
1970: { type: "assistant", finish: "stop", content: [{ type: "text", id: "text-final", text: "Done" }] },
1971: ])
1972: }),
1973: )
1974:
1975: it.effect("never commits continuation before unresolved hosted tools are reconciled", () =>
1976: Effect.gen(function* () {
1977: yield* setup
1978: const session = yield* SessionV2.Service
1979: const { db } = yield* Database.Service
1980: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Mixed tools" }), resume: false ...
I’m encoding the three reviewer attacks directly now. The collision test seeds a corrupt foreign-Session recovery identity; the expiry test advances the recovery clock past its lease before projection failure; the publication test aborts Step.Started and proves no assistant-start state survives in memory or projection.
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1490: }),
1491: )
1492:
1493: it.effect("quarantines a malformed claim without blocking a valid claim", () =>
1494: Effect.gen(function* () {
1495: const { db } = yield* Database.Service
1496: const validSession = SessionV2.ID.make("ses_recovery_valid")
1497: yield* db
1498: .insert(ProjectTable)
1499: .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
1500: .run()
1501: yield* db
1502: .insert(SessionTable)
1503: .values(
1504: [sessionID, validSession].map((id) => ({
1505: id,
1506: project_id: Project.ID.global,
1507: slug: id,
1508: directory: "/project",
1509: title: "test",
1510: version: "test",
1511: })),
1512: )
1513: .run()
1514: yield* ...
The lease model cannot stop an arbitrary uninterruptible finalizer after another node has already proved the lease stale; that is the same failure-detector boundary as provider dispatch. The enforceable fix is to supervise renewal, surface ownership loss, and ensure recovery never steals a healthy cancellation lease. I’m adding the reviewer’s concrete takeover and fault-injection cases without claiming stronger process-failure semantics.
Success. Updated the following files: M packages/core/test/session-projector.test.ts M packages/core/test/session-runner.test.ts
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1560: directory: "/project",
1561: title: "test",
1562: version: "test",
1563: })),
1564: )
1565: .run()
1566: yield* db.run(sql`PRAGMA ignore_check_constraints = ON`)
1567: yield* db.run(
1568: sql`INSERT INTO session_execution (id, session_id, owner_id, phase, time_created, expires_at) VALUES ('malformed', ${sessionID}, 'dead', 'invalid', 0, 0)`,
1569: )
1570: yield* db
1571: .insert(SessionExecutionTable)
1572: .values({
1573: id: "valid",
1574: session_id: validSession,
1575: owner_id: "dead",
1576: phase: "ready",
1577: time_created: 1,
1578: expires_at: 0,
1579: })
1580: .run()
1581:
1582: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, ...
The review’s cancellation demand is stronger than heartbeat supervision alone. A lease cannot distinguish a dead process from a live process stuck in finalization, so automatic takeover of an expired cancellation claim is unsafe. I’m making cancellation an explicit durable phase: recovery quarantines expired cancellation instead of synthesizing or starting successor work. Healthy cleanup still clears it; a crash during cancellation now fails closed rather than risking overlapping effects.
Found 14 matches
/home/byk/Code/opencode/packages/core/schema.json:
Line 2535: "value": "\"phase\" IN ('ready', 'safe', 'unknown', 'continue')",
Line 2536: "name": "session_execution_phase_check",
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 652: expect(userTexts(request)).toEqual(["Session execution interrupted by server restart", "continue"])
Line 2291: expect(userTexts(requests[0]!)).toEqual(["Recover settled tool continuation", "continue"])
/home/byk/Code/opencode/packages/core/test/session-tool-progress.test.ts:
Line 98: structured: { phase: "checkpoint" },
Line 102: state: { status: "running", structured: { phase: "checkpoint" }, content: content("saved") },
Line 124: structured: { phase: "checkpoint" },
Line 138: structured: { phase: "checkpoint" },
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 25: const Phase = Schema.Literals(["ready", ...
I’m implementing the fail-closed cancellation phase now. It will be durable before runner interruption, renewed through finalizers, cleared on healthy completion, and quarantined rather than recovered if ownership expires. This prevents another node from starting provider/tool work while stale cancellation cleanup may still run.
<path>/home/byk/Code/opencode/packages/core/src/session/execution/claim.ts</path>
<type>file</type>
<content>
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: ...
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts:
CONSTRAINT `session_execution_phase_check` CHECK(`phase` IN ('ready', 'safe', 'unknown', 'continue')),
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts</path>
<type>file</type>
<content>
15: \`session_id\` text NOT NULL,
16: \`owner_id\` text NOT NULL,
17: \`assistant_message_id\` text,
18: \`phase\` text NOT NULL,
19: \`time_created\` integer NOT NULL,
20: \`expires_at\` integer NOT NULL,
21: \`recovery_error\` text,
22: CONSTRAINT \`session_execution_pk\` PRIMARY KEY(\`id\`),
23: CONSTRAINT \`session_execution_phase_check\` CHECK(\`phase\` IN ('ready', 'safe', 'unknown', 'continue')),
24: CONSTRAINT \`fk_session_execution_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
25: );
26: `)
27: yield* tx.run(`
28: CREATE TABLE \`session_recovery\` (
29: \`session_id\` text NOT NULL,
30: \`execution_id\` text NOT NULL,
31: ...
Success. Updated the following files: M packages/core/src/session/execution/local.ts M packages/core/src/session/recovery.ts M packages/core/src/session/sql.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts
The durable cancelling phase closes the unsafe takeover path: an expired cancellation is quarantined, never converted to synthetic continuation. The cancellation caller also supervises renewal, so ownership loss becomes an explicit failure. I’m adding the durable takeover regression and then regenerating schema artifacts from the source schema.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
Reading config file '/tmp/opencode-core-migration-3bOtSU/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-3bOtSU/incremental/20260916164045_cancellation-phase/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-3bOtSU/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-3bOtSU/full/20260916164048_schema/migration.sql 🚀
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
1690 | BEGIN
1691 | SELECT RAISE(ABORT, 'poison recovery projection');
1692 | END
1693 | `)
1694 | const intercepted = EventV2.Service.of({
1695 | ...events,
^
ReferenceError: events is not defined
at /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:1695:12
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at /home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:653:9
at runTasks ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
1715 | yield* SessionRecovery.recover().pipe(
1716 | Effect.provideService(EventV2.Service, intercepted),
1717 | Effect.provideService(SessionExecution.Service, recoveryExecution),
1718 | Effect.ensuring(db.run(sql`DROP TRIGGER fail_recovery_projection`).pipe(Effect.orDie)),
1719 | )
1720 | expect(yield* db.select().from(SessionRecoveryTable).all()).toEqual([
^
error: expect(received).toEqual(expected)
- [
- ObjectContaining {
- "execution_id": "conversion-100",
- },
- ]
+ []
- Expected - 5
+ Received + 1
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:1720:67
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop ...
The expiry regression was advancing the clock for claim 101 too, so its event commit correctly lost the lease. I’m limiting the injected delay to the first 100 poisoned conversions; claim 101 then proves bounded progress on the next scan under normal timing.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
3 pass
38 filtered out
0 fail
9 expect() calls
Ran 3 tests across 1 file. [2.78s]
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
2101 | const result = yield* session.resume(sessionID).pipe(
2102 | Effect.exit,
2103 | Effect.ensuring(db.run(sql`DROP TRIGGER fail_step_start_publication`).pipe(Effect.orDie)),
2104 | )
2105 |
2106 | expect(Exit.isFailure(result)).toBe(true)
^
error: expect(received).toBe(expected)
Expected: true
Received: false
at toBe (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:2106:38
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate ...
Found 3 matches /home/byk/Code/opencode/packages/schema/src/session-event.ts: Line 163: type: "session.next.step.started", Line 495: Step.Started, Line 529: Step.Started,
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
150: type: "session.next.shell.ended",
151: ...options,
152: schema: {
153: ...Base,
154: callID: Schema.String,
155: output: Schema.String,
156: },
157: })
158: export type Ended = typeof Ended.Type
159: }
160:
161: export namespace Step {
162: export const Started = Event.define({
163: type: "session.next.step.started",
164: ...options,
165: schema: {
166: ...Base,
167: assistantMessageID: SessionMessage.ID,
168: agent: Schema.String,
169: model: Model.Ref,
170: snapshot: Schema.String.pipe(optional),
171: },
172: })
173: export type Started = typeof Started.Type
174:
175: export const Ended = Event.define({
176: type: "session.next.step.ended",
177: ...stepSettlementOptions,
178: schema: {
179: ...Base,
180: assistantMessageID: SessionMessage.ID,
181: finish: ...
Found 3 matches /home/byk/Code/opencode/packages/core/test/session-runner.test.ts: Line 2095: WHEN NEW.type = 'session.next.step.started.2' Line 2116: .where(eq(EventTable.type, "session.next.step.started.2")) /home/byk/Code/opencode/packages/core/test/session-runner-recorded.test.ts: Line 180: "session.next.step.started.1",
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
2101 | const result = yield* session.resume(sessionID).pipe(
2102 | Effect.exit,
2103 | Effect.ensuring(db.run(sql`DROP TRIGGER fail_step_start_publication`).pipe(Effect.orDie)),
2104 | )
2105 |
2106 | expect(Exit.isFailure(result)).toBe(true)
^
error: expect(received).toBe(expected)
Expected: true
Received: false
at toBe (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:2106:38
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
Found 4 matches
/home/byk/Code/opencode/packages/schema/src/session-event.ts:
Line 38: const options = {
Line 44: const stepSettlementOptions = {
Line 177: ...stepSettlementOptions,
Line 200: ...stepSettlementOptions,
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
30: }
31: const PromptFields = {
32: ...Base,
33: messageID: SessionMessage.ID,
34: prompt: Prompt,
35: delivery: Delivery,
36: }
37:
38: const options = {
39: durable: {
40: aggregate: "sessionID",
41: version: 1,
42: },
43: } as const
44: const stepSettlementOptions = {
45: durable: {
46: aggregate: "sessionID",
47: version: 2,
48: },
49: } as const
50:
51: export const UnknownError = SessionMessage.UnknownError
52: export type UnknownError = SessionMessage.UnknownError
53:
54: export const AgentSwitched = Event.define({
(Showing lines 30-54 of 563. Use offset=55 to continue.)
</content>
The Step event type trigger did not reach the actual projection write path in this harness. I’m moving the fault injection to the assistant session_message insert, which aborts the same event transaction after Step.Started publication begins and directly tests whether in-memory assistant state changes before durable projection commits.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
2101 | const result = yield* session.resume(sessionID).pipe(
2102 | Effect.exit,
2103 | Effect.ensuring(db.run(sql`DROP TRIGGER fail_step_start_publication`).pipe(Effect.orDie)),
2104 | )
2105 |
2106 | expect(Exit.isFailure(result)).toBe(true)
^
error: expect(received).toBe(expected)
Expected: true
Received: false
at toBe (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:2106:38
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
Found 100 matches (more matches available)
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 68: let responses: LLMEvent[][] | undefined
Line 69: let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
Line 85: if (responseStream) {
Line 86: const stream = responseStream
Line 87: responseStream = undefined
Line 92: : Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
Line 344: response = []
Line 352: responses = undefined
Line 354: responseStream = undefined
Line 382: response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
Line 526: response = fixture.completeEvents
Line 554: responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
Line 580: responseStream = Stream.concat(
Line 640: response = [
Line 677: response = [
Line 699: response = ...
Found 1 matches /home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts: Line 293: case "step-start":
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
280: }
281: })
282:
283: const assistantMessageIDForTool = (callID: string) => {
284: const tool = tools.get(callID)
285: return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`)
286: }
287:
288: const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
289: event: LLMEvent,
290: outputPaths: ReadonlyArray<string> = [],
291: ) {
292: switch (event.type) {
293: case "step-start":
294: return
295: case "text-start":
296: yield* text.start(event.id)
297: yield* publishEvent(SessionEvent.Text.Started, {
298: sessionID: input.sessionID,
299: assistantMessageID: yield* startAssistant(),
300: timestamp: yield* timestamp,
301: textID: event.id,
302: })
303: return
304: case ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a) 1 pass 106 filtered out 0 fail 4 expect() calls Ran 1 test across 1 file. [1.69s] bun test v1.3.14 (0d9b296a) 10 pass 0 fail 20 expect() calls Ran 10 tests across 1 file. [1288.00ms] $ tsgo --noEmit
All new security regressions pass, the full execution-local lifecycle suite passes, and Core typecheck is clean. The cancellation path now fails closed durably: expired cancellation ownership is quarantined and never recovered. I’m checking generated schema parity, then rerunning the complete recovery suite to catch interactions with replay and migration tests.
Found 2 matches
/home/byk/Code/opencode/packages/core/src/database/schema.gen.ts:
Line 169: CONSTRAINT "session_execution_phase_check" CHECK("phase" IN ('ready', 'safe', 'unknown', 'continue', 'cancelling'))
/home/byk/Code/opencode/packages/core/schema.json:
Line 2535: "value": "\"phase\" IN ('ready', 'safe', 'unknown', 'continue', 'cancelling')",
bun test v1.3.14 (0d9b296a) 230 pass 0 fail 681 expect() calls Ran 230 tests across 8 files. [27.82s]
The complete recovery suite passes: 230 tests and 681 assertions. Before the full gate, I’m tightening one remaining ownership branch: if projection quarantine changes zero rows, recovery will explicitly verify that responsibility transferred to another owner or a durable recovery row; it will never silently accept an unchanged unquarantined claim.
Success. Updated the following files: M packages/core/src/session/recovery.ts
The zero-row quarantine path now proves transfer or fails loudly. I’m starting the final source gates: full Core, five typechecks, schema manifest, migration consistency, and diff checks. If they pass, I’ll rebuild and smoke a fresh v9 artifact before freezing.
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[16:48:17.560] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
[16:49:50.079] INFO (#209720): watcher backend {
directory: "/tmp/opencode-core-test-QPVByp",
platform: "linux",
backend: "inotify",
}
[16:49:50.092] INFO (#209829): project copy refresh started {
projectID: "global",
}
[16:49:50.093] INFO (#209829): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[16:49:50.205] INFO (#209720): booting location services {
directory: "/tmp/opencode-core-test-QPVByp",
workspaceID: undefined,
}
1167 pass
0 fail
3270 ...
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a) 2 pass 0 fail 23 expect() calls Ran 2 tests across 1 file. [292.00ms]
Reading config file '/tmp/opencode-core-migration-QWbcu7/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-QWbcu7/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-QWbcu7/full/20260916165351_schema/migration.sql 🚀
All v9 source gates pass: 1,167 Core tests, 230 recovery tests, Schema manifest, five typechecks, migration consistency, and diff checks. I’m rebuilding the exact SEA and repeating the disposable migration/recovery smoke, including verification that the new cancelling phase constraint is present.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0ab24f42f001vpwoYpuYOJyJZU dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v9-smoke.mjs
{
"initial": {
"port": 32523,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"constraints": [
{
"name": "session",
"sql": "CREATE TABLE `session` (\n `id` text PRIMARY KEY,\n `project_id` text NOT NULL,\n `workspace_id` text,\n `parent_id` text,\n `slug` text NOT NULL,\n `directory` text NOT NULL,\n `path` text,\n `title` text NOT NULL,\n `version` text NOT NULL,\n `share_url` text,\n ...
The v9 artifact passed authenticated health, named schema constraints including cancelling, clean shutdown, one exact continuation, and duplicate-free third launch. I’m checking for surviving disposable servers, freezing the same 33 package files, and verifying clean application to the immutable base before replacement reviews.
M .lore.md M AGENTS.md M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M packages/core/test/database-migration.test.ts M ...
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260916164045_cancellation-phase.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260916164045_cancellation-phase",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`PRAGMA foreign_keys=OFF;`)
9: yield* tx.run(`
10: CREATE TABLE \`__new_session_execution\` (
11: \`id\` text NOT NULL,
12: \`session_id\` text NOT NULL,
13: \`owner_id\` text NOT NULL,
14: \`assistant_message_id\` text,
15: \`phase\` text NOT NULL,
16: \`time_created\` integer NOT NULL,
17: \`expires_at\` integer NOT NULL,
18: \`recovery_error\` text,
19: CONSTRAINT \`session_execution_pk\` PRIMARY KEY(\`id\`),
20: CONSTRAINT \`fk_session_execution_session_id_session_id_fk\` FOREIGN KEY ...
The generator created an incremental cancellation migration, but this feature is still unpublished and its schema belongs in the existing squashed recovery migration. I’m removing that intermediate migration and regenerating/checking artifacts so the frozen patch keeps the single 0-to-1 migration lineage.
Success. Updated the following files: D packages/core/src/database/migration/20260916164045_cancellation-phase.ts
Reading config file '/tmp/opencode-core-migration-tWljpl/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-tWljpl/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-tWljpl/full/20260916165832_schema/migration.sql 🚀
Migration parity is clean again with no incremental schema change. I’m freezing v9 from the immutable base with only recovery package files, then applying that patch in a detached clean worktree and launching fresh correctness and security reviews against the exact patch and SEA hashes.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security ...
8ba92decafd0e7b9c8c092d8bb354cc893fb685396016bd952fca6194b5c4e40 /tmp/opencode/session-recovery-final-v9.patch edf38112295131282cb05d7f2d657417e38a40e3e19125d80c02056ee5b367f3 packages/opencode/dist/server/opencode-server packages/core/schema.json | 579 +++++- packages/core/src/database/migration.gen.ts | 1 + .../migration/20260914170650_session-recovery.ts | 58 + packages/core/src/database/schema.gen.ts | 49 +- packages/core/src/session.ts | 9 +- packages/core/src/session/compaction.ts | 47 +- packages/core/src/session/context-epoch.ts | 88 +- packages/core/src/session/execution/local.ts | 155 +- packages/core/src/session/input.ts | 373 +++- packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 89 +- packages/core/src/session/recovery.ts | 484 +++++ ...
The first v9 freeze omitted the two untracked production modules, so it is invalid. I caught it before review. I’m rebuilding the patch with execution/claim.ts and recovery-id.ts, expecting 33 files, then verifying clean application.
d525a720dae01d40d14301b9bd1fa7ca726dda77c142de332f74d9f3b5f5e15e /tmp/opencode/session-recovery-final-v9.patch edf38112295131282cb05d7f2d657417e38a40e3e19125d80c02056ee5b367f3 packages/opencode/dist/server/opencode-server packages/core/schema.json | 579 +++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 58 + packages/core/src/database/schema.gen.ts | 49 packages/core/src/session.ts | 9 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 + packages/core/src/session/execution/local.ts | 155 + packages/core/src/session/input.ts | 373 +++- packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 484 +++++ ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 95% (6342/6637) Updating files: 96% (6372/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
(no output)
Frozen v9 is complete and cleanly applies to ab9408c81c: 33 files, patch SHA-256 d525a720..., SEA SHA-256 edf38112.... I’m launching independent immutable correctness and security reviews now, with the prior four blockers and the new cancellation quarantine explicitly in scope.
<task id="ses_f54d4d62effefOOMcHW9oE6KvX" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f54d440dcffeYMZOCC1359OUvD" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Frozen v9 is under independent correctness and security review.
d525a720dae01d40d14301b9bd1fa7ca726dda77c142de332f74d9f3b5f5e15eedf38112295131282cb05d7f2d657417e38a40e3e19125d80c02056ee5b367f3The lease fence protects database transactions, but not external side effects or live publications:
packages/core/src/session/runner/llm.ts:304-305 completes updateExecution() before entering llm.stream(request).packages/core/src/session/runner/llm.ts:326-335 completes another renewal before invoking local tool settlement.packages/core/src/session/runner/publish-llm-event.ts:60-67 renews the claim, then publishes non-durable stream events separately.packages/core/src/session/recovery.ts:82-96.A concrete race remains:
The heartbeat at packages/core/src/session/execution/local.ts:64-69 does not close this window. After a suspended process resumes, scheduling does not guarantee that the heartbeat detects claim loss before the runner continues.
The current tests transfer ownership before the fence check (packages/core/test/session-runner.test.ts:752-841). They do not pause after a successful check, expire and transfer the lease, then release the old owner.
This violates the required stale-owner invariants for provider dispatch, live streamed output, and local tool execution. Add deterministic regressions at each post-renewal boundary and a design that prevents resumed work from crossing takeover. ...
The correctness reviewer found only the already documented and explicitly accepted residual: a process can pause after the final synchronous ownership check and resume after takeover. Closing that requires fencing inside each external provider/tool system, not another local check. I’m asking the reviewer to distinguish this accepted physical-dispatch limit from an implementation regression and to reassess the unchanged candidate; the cancellation quarantine concern is safe but needs an operator recovery design later.
Frozen inputs remain unchanged:
ab9408c81cc9916177641ae8e9f08624fc27b5b9d525a720dae01d40d14301b9bd1fa7ca726dda77c142de332f74d9f3b5f5e15eedf38112295131282cb05d7f2d657417e38a40e3e19125d80c02056ee5b367f3The candidate performs every required immediate ownership check:
updateExecution() immediately precedes llm.stream(request) at packages/core/src/session/runner/llm.ts:304-305.beforeStream() immediately precedes dependencies.llm.stream(...) at packages/core/src/session/compaction.ts:211-221; callers bind it to updateExecution(...) at packages/core/src/session/runner/llm.ts:281 and :379-383.updateExecution() immediately precedes toolMaterialization.settle(...) at packages/core/src/session/runner/llm.ts:326-335.packages/core/src/session/runner/publish-llm-event.ts:65-67.packages/core/src/session/runner/publish-llm-event.ts:65-66; EventV2 executes projectors, the claim commit, sequence update, and event insertion in one immediate transaction at packages/core/src/event.ts:236-353.My earlier race requires suspension after the final successful check and before the physical external call. That is exactly the accepted residual. I found no larger unchecked interval, missing immediate check, or durable stale publication. ...
Correctness review now approves frozen v9 with MERGE. It confirmed every provider, compaction, local-tool, live-event, and durable-event boundary has the required immediate exact-claim check; the only remaining race is the accepted instruction-level check-to-external-call limit. Security review is still running against the unchanged hashes.
Correctness review approved frozen v9. Security review remains pending.
ab9408c81cc9916177641ae8e9f08624fc27b5b9d525a720dae01d40d14301b9bd1fa7ca726dda77c142de332f74d9f3b5f5e15eedf38112295131282cb05d7f2d657417e38a40e3e19125d80c02056ee5b367f3repo-setup skill was unavailable; I read the applicable AGENTS.md, CONTRIBUTING.md, and package manifests directly.A corrupt or malicious session_recovery row can share the expired claim’s Session and execution ID while differing in phase, assistant ID, or continuation ID.
The projector correctly detects the mismatch and aborts at packages/core/src/session/projector.ts:403-423. Because durable projection runs in the event transaction, the attempted event, synthetic message, continuation input, and claim deletion roll back atomically.
The recovery error path then searches only by Session and execution ID:
packages/core/src/session/recovery.ts:186-194If any such row exists, it returns without validating the stored identity:
packages/core/src/session/recovery.ts:197-199That skips both required outcomes:
(sessionID, executionID, ownerID) claim;The claim remains in session_execution. ...
Security found one concrete blocker in the recovery error path: a same-Session recovery row with mismatched identity is treated as proof that projection already succeeded. I’m adding the combined corrupt-row regression, then changing that branch to validate the complete recovery identity before it can suppress exact-owner quarantine.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Fix same-Session conflicting recovery-row quarantine",
"status": ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1180: agent: "build",
1181: model,
1182: })
1183:
1184: const recovered = yield* events
1185: .publish(SessionEvent.Step.Recovered, {
1186: sessionID,
1187: timestamp: DateTime.makeUnsafe(1),
1188: executionID: "cross-session",
1189: assistantMessageID: assistantID,
1190: phase: "unknown",
1191: })
1192: .pipe(Effect.exit)
1193:
1194: expect(Exit.isFailure(recovered)).toBe(true)
1195: expect(yield* db.select().from(SessionRecoveryTable).all()).toEqual([])
1196: expect(yield* db.select().from(SessionInputTable).all()).toEqual([])
1197: }),
1198: )
1199:
1200: it.effect("rejects a recovery execution identity owned by another Session", () =>
1201: Effect.gen(function* () {
1202: const { db } = yield* Database.Service
1203: const events ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
[17:45:10.232] ERROR (#2): 1286 | })
1287 | .run()
1288 |
1289 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
1290 |
1291 | expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({
^
error: expect(received).toMatchObject(expected)
Matcher error: received value must be a non-null object
at toMatchObject (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:1291:68
1286 | })
1287 | .run()
1288 |
1289 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
1290 |
1291 | expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({
^
error: expect(received).toMatchObject(expected)
Matcher error: received value must be a non-null object
at toMatchObject ...
The first regression fixture represented an already-pending valid recovery wake, so startup correctly cleared the redundant claim before projection. I’m settling that wake first while retaining the conflicting identity; this forces the exact reviewer path where projection collides and the execution claim must be quarantined.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
1291 | })
1292 | .run()
1293 |
1294 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
1295 |
1296 | expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({
^
error: expect(received).toMatchObject(expected)
Matcher error: received value must be a non-null object
at toMatchObject (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:1296:68
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
100: if (Option.isNone(phase)) {
101: const quarantinedAt = yield* Clock.currentTimeMillis
102: const quarantined = yield* db
103: .update(SessionExecutionTable)
104: .set({ recovery_error: `Invalid execution phase: ${String(claim.phase)}` })
105: .where(
106: and(
107: eq(SessionExecutionTable.id, claim.id),
108: eq(SessionExecutionTable.session_id, claim.session_id),
109: eq(SessionExecutionTable.owner_id, ownerID),
110: gt(SessionExecutionTable.expires_at, quarantinedAt),
111: ),
112: )
113: .returning({ id: SessionExecutionTable.id })
114: .get()
115: .pipe(Effect.orDie)
116: if (!quarantined) return yield* Effect.die(`Recovery execution claim changed: ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
15: MAX_WAKE_ATTEMPTS,
16: SESSION_EXECUTION_LEASE_MS,
17: SessionExecutionTable,
18: SessionInputTable,
19: SessionRecoveryTable,
20: SessionMessageTable,
21: SessionTable,
22: } from "./sql"
23:
24: export const BATCH_SIZE = 100
25: const Phase = Schema.Literals(["ready", "safe", "unknown", "continue", "cancelling"])
26:
27: class PendingExecutionIntent extends Error {}
28:
29: const hasPendingExecutionIntent = Effect.fn("SessionRecovery.hasPendingExecutionIntent")(function* (
30: db: Database.Interface["db"],
31: sessionID: SessionSchema.ID,
32: excludeRecoveryExecutionID?: string,
33: ) {
34: const pending = yield* db
35: .select({ id: SessionInputTable.id })
36: .from(SessionInputTable)
37: .leftJoin(
38: SessionRecoveryTable,
39: eq(SessionRecoveryTable.continuation_message_id, SessionInputTable.id),
40: )
41: .where(
42: ...
Found 2 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 437: continuation_message_id: continuation("lost-wake"),
Line 1248: continuation_message_id: continuation("shared-recovery-identity"),
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
395: yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
396: yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
397: yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
398: yield* events.project(SessionEvent.Step.Interrupted, (event) => run(db, event))
399: yield* events.project(SessionEvent.Step.Recovered, (event) =>
400: Effect.gen(function* () {
401: if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
402: const continuationMessageID = continuation(event.data.executionID)
403: const existing = yield* db
404: .select({
405: sessionID: SessionRecoveryTable.session_id,
406: assistantMessageID: SessionRecoveryTable.assistant_message_id,
407: ...
Found 1 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 43: const recoveryExecution = SessionExecution.Service.of({
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
35: } from "@opencode-ai/core/session/sql"
36: import { testEffect } from "./lib/effect"
37: import { Snapshot } from "@opencode-ai/core/snapshot"
38: import { Location } from "@opencode-ai/core/location"
39: import { SystemContext } from "@opencode-ai/core/system-context"
40:
41: const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
42: const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])
43: const recoveryExecution = SessionExecution.Service.of({
44: active: Effect.succeed(new Set()),
45: interrupt: () => Effect.void,
46: resume: () => Effect.void,
47: wake: () => Effect.void,
48: })
49: const sessionID = SessionV2.ID.make("ses_projector_test")
50: const created = DateTime.makeUnsafe(0)
51: const model = { id: ModelV2.ID.make("model"), ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1245: expect.objectContaining({
1246: session_id: other,
1247: execution_id: "shared-recovery-identity",
1248: continuation_message_id: continuation("shared-recovery-identity"),
1249: }),
1250: ])
1251: expect(yield* db.select().from(SessionInputTable).all()).toEqual([
1252: expect.objectContaining({ session_id: other, id: continuation("shared-recovery-identity") }),
1253: ])
1254: }),
1255: )
1256:
1257: it.effect("quarantines an execution claim blocked by a conflicting recovery identity", () =>
1258: Effect.gen(function* () {
1259: const { db } = yield* Database.Service
1260: const events = yield* EventV2.Service
1261: yield* db
1262: .insert(ProjectTable)
1263: .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
1264: ...
Found 6 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 250: .set({ wake_pending: false })
Line 251: .where(eq(SessionRecoveryTable.execution_id, "conflicting-recovery-identity"))
Line 1279: executionID: "conflicting-recovery-identity",
Line 1285: id: "conflicting-recovery-identity",
Line 1297: id: "conflicting-recovery-identity",
Line 1301: expect.objectContaining({ execution_id: "conflicting-recovery-identity", phase: "continue" }),
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
225: const events = yield* EventV2.Service
226: yield* db
227: .insert(ProjectTable)
228: .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
229: .run()
230: yield* db
231: .insert(SessionTable)
232: .values({
233: id: sessionID,
234: project_id: Project.ID.global,
235: slug: "test",
236: directory: "/project",
237: title: "test",
238: version: "test",
239: })
240: .run()
241:
242: yield* events.publish(SessionEvent.Step.Recovered, {
243: sessionID,
244: timestamp: created,
245: executionID: "continue-only",
246: phase: "continue",
247: })
248: yield* db
249: .update(SessionRecoveryTable)
250: .set({ wake_pending: false })
251: ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
1290 | })
1291 | .run()
1292 |
1293 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
1294 |
1295 | expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({
^
error: expect(received).toMatchObject(expected)
{
+ "assistant_message_id": null,
+ "expires_at": 30000,
"id": "conflicting-recovery-identity",
- "recovery_error": "Recovery projection failed",
+ "owner_id": "a3332fcf-19df-4698-adb0-616e1fa3123c",
+ "phase": "ready",
+ "recovery_error": null,
+ "session_id": "ses_projector_test",
+ "time_created": 0,
}
- Expected - 1
+ Received + 7
at toMatchObject (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:1295:68
at ~effect/Effect/successCont ...
The regression now reaches the exact defect and fails as expected: takeover renews the claim, projection detects the conflicting phase, but the error handler sees only matching Session/execution IDs and leaves the claim unquarantined. I’m changing that check to require exact Session, execution, assistant, deterministic continuation, and phase identity.
Success. Updated the following files: M packages/core/src/session/recovery.ts
No files found
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, exists, gt, isNull, lte, or, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { SessionInput } from "./input"
13: import { SessionSchema } from "./schema"
14: import {
15: MAX_WAKE_ATTEMPTS,
16: SESSION_EXECUTION_LEASE_MS,
17: SessionExecutionTable,
18: SessionInputTable,
19: SessionRecoveryTable,
20: SessionMessageTable,
21: SessionTable,
22: } from "./sql"
23:
24: export ...
Success. Updated the following files: M packages/core/src/session/recovery.ts
bun test v1.3.14 (0d9b296a) 3 pass 39 filtered out 0 fail 7 expect() calls Ran 3 tests across 1 file. [6.30s] $ tsgo --noEmit
The combined corruption regression now passes, along with cross-Session collision and expiry-quarantine coverage; Core typecheck remains clean. v9 is invalid because source changed. I’m rerunning the recovery suite and full source gates, then I’ll rebuild, smoke, freeze v10, and obtain replacement reviews.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Fix same-Session conflicting recovery-row quarantine",
"status": ...
bun test v1.3.14 (0d9b296a) 231 pass 0 fail 683 expect() calls Ran 231 tests across 8 files. [45.97s]
bun test v1.3.14 (0d9b296a)
test/session-run-coordinator.test.ts:
(fail) SessionRunCoordinator > trampolines synchronous self-waking execution [5000.62ms]
^ this test timed out after 5000ms.
test/skill-discovery.test.ts:
[17:55:16.688] ERROR (#25679): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
1167 pass
1 fail
3272 expect() calls
Ran 1168 tests across 145 files. [172.24s]
[17:57:44.624] INFO (#209779): watcher backend {
directory: "/tmp/opencode-core-test-5ozV5t",
platform: "linux",
backend: "inotify",
}
[17:57:44.642] INFO (#209888): project copy refresh started {
projectID: "global",
}
[17:57:44.643] INFO ...
The full suite had one timeout in the coordinator’s synchronous self-wake stress test; all 1,167 other tests passed, and the same coordinator file passed in the recovery suite. The quarantine change does not touch coordinator code. I’m treating this as unproven until the stress test passes repeatedly and a clean full rerun completes.
bun test v1.3.14 (0d9b296a) 10 pass 160 filtered out 0 fail 10 expect() calls Ran 10 tests across 1 file. [6.84s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[17:59:04.087] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
[18:01:05.618] INFO (#209777): watcher backend {
directory: "/tmp/opencode-core-test-23FIRm",
platform: "linux",
backend: "inotify",
}
[18:01:05.625] INFO (#209886): project copy refresh started {
projectID: "global",
}
[18:01:05.626] INFO (#209886): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[18:01:05.662] INFO (#209777): booting location services {
directory: "/tmp/opencode-core-test-23FIRm",
workspaceID: undefined,
}
1168 pass
0 fail
3272 ...
The coordinator stress test passed 10 consecutive runs, and the clean full rerun passed all 1,168 tests. I’m running the remaining exact-source package and migration gates in parallel, then rebuilding the artifact because the recovery implementation changed.
$ tsgo --noEmit
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 2 pass 0 fail 23 expect() calls Ran 2 tests across 1 file. [645.00ms]
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-UbXJ8Z/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-UbXJ8Z/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-UbXJ8Z/full/20260916180614_schema/migration.sql 🚀
All revised source gates pass. I’m rebuilding v10 and running the isolated recovery smoke again; this final smoke verifies the exact artifact, not just source behavior.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0ab66da27001jH7jx1CJbSwv6p dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v10-smoke.mjs
{
"initial": {
"port": 6937,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"constraints": [
{
"name": "session",
"sql": "CREATE TABLE `session` (\n `id` text PRIMARY KEY,\n `project_id` text NOT NULL,\n `workspace_id` text,\n `parent_id` text,\n `slug` text NOT NULL,\n `directory` text NOT NULL,\n `path` text,\n `title` text NOT NULL,\n `version` text NOT NULL,\n `share_url` text,\n ...
v10’s exact artifact passed health, migration, two-stage recovery, and duplicate-free restart; no disposable server remains. I’m freezing the 33-file patch, verifying clean application, then launching replacement reviews focused on the fixed full-identity check.
07b95915924ccf5dcfdf7ea5eb45d6cf972a6058664518935154dff9934222b2 /tmp/opencode/session-recovery-final-v10.patch 1117176c4cfbf77527216b4161b54f8d3918ca7f7415747117c24f94d8e0b090 packages/opencode/dist/server/opencode-server packages/core/schema.json | 579 +++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 58 + packages/core/src/database/schema.gen.ts | 49 packages/core/src/session.ts | 9 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 155 + packages/core/src/session/input.ts | 373 +++ packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 496 +++++ ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 95% (6306/6637) Updating files: 96% (6372/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
(no output)
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Fix same-Session conflicting recovery-row quarantine",
"status": ...
Frozen v10 cleanly applies to the immutable base: patch 07b95915..., SEA 1117176c..., 33 files. The new regression proves the old same-Session mismatch path failed before the fix. I’m launching fresh immutable correctness and security reviews now.
<task id="ses_f5495b9dcffevvbjk9Z62gNZbA" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f54953bf4ffeEtiPbcIfFm6Ze4" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Frozen v10 is under fresh correctness and security review.
07b95915924ccf5dcfdf7ea5eb45d6cf972a6058664518935154dff9934222b21117176c4cfbf77527216b4161b54f8d3918ca7f7415747117c24f94d8e0b090</task_result> </task>
The security reviewer returned no findings or verdict because Lore retrieval failed, so that result is invalid. I’m retrying the same reviewer once with an evidence-or-blocker contract and no memory lookup; if it returns empty again, I’ll replace it.
packages/core/src/session/recovery.ts:136-140 clears an expired ready claim when hasPendingExecutionIntent(...) finds pending work. That query treats any pending recovery row as execution intent without checking or excluding the current execution ID (packages/core/src/session/recovery.ts:30-60).
Therefore, when a same-Session session_recovery row has the same execution ID but mismatched assistant, continuation, or phase identity—and its normal default wake_pending=true remains set—the scanner:
Step.Recovered.packages/core/src/session/projector.ts:414-423.packages/core/src/session/recovery.ts:203-247.packages/core/src/session/recovery.ts:341-371.The new regression does not cover this route. It explicitly changes the conflicting recovery row to wake_pending=false at packages/core/test/session-projector.test.ts:1276-1279, forcing projection to run. With the default true, the phase-mismatched continue row created at lines 1270-1275 is treated as pending intent, the execution claim is deleted, and no "Recovery projection failed" quarantine occurs.
This leaves the v9 blocker open for the normal persisted state. The initial pending-intent check must exclude the current execution ID or validate its complete identity. Extend the regression with the default pending wake and assert that the exact execution+owner is quarantined without wake or promotion.
Correctness found a second branch of the same defect: the early pre-promotion check treats the malformed row’s default pending wake as valid intent and clears the claim before projection can reject it. I’m strengthening the regression to retain wake_pending=true, then separating valid same-execution recovery intent from conflicting identity: exact identity may clear the redundant claim; mismatched identity must reach projection and quarantine.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
1285 | })
1286 | .run()
1287 |
1288 | yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
1289 |
1290 | expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({
^
error: expect(received).toMatchObject(expected)
Matcher error: received value must be a non-null object
at toMatchObject (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-projector.test.ts:1290:68
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The strengthened regression now fails on the exact early-clear route. The minimal fix is to exclude only the current execution ID from the pre-promotion intent query, just as the atomic recheck already does; ordinary inputs and other recovery executions still block synthesis, while the current row must pass full projector identity validation.
Success. Updated the following files: M packages/core/src/session/recovery.ts
bun test v1.3.14 (0d9b296a) 2 pass 40 filtered out 0 fail 6 expect() calls Ran 2 tests across 1 file. [1409.00ms] $ tsgo --noEmit
The pending-wake variant now passes, and ordinary pre-promotion input behavior remains intact. v10 is rejected and its reviews are stale. I’m running the complete recovery and Core gates again before producing v11.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Fix same-Session conflicting recovery-row quarantine",
"status": "in_progress",
"priority": "high"
},
{
"content": "Validate and smoke revised candidate",
"status": "pending",
"priority": ...
bun test v1.3.14 (0d9b296a) 231 pass 0 fail 683 expect() calls Ran 231 tests across 8 files. [20.15s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[18:32:49.818] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
1168 pass
0 fail
3272 expect() calls
Ran 1168 tests across 145 files. [86.49s]
[18:34:10.174] INFO (#209783): watcher backend {
directory: "/tmp/opencode-core-test-U2Q3JN",
platform: "linux",
backend: "inotify",
}
[18:34:10.186] INFO (#209892): project copy refresh started {
projectID: "global",
}
[18:34:10.187] INFO (#209892): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[18:34:10.215] INFO (#209783): booting location services {
directory: ...
The complete recovery suite and full Core suite pass on the strengthened fix. The production delta is one recovery predicate, but I’m rebuilding and rerunning the exact artifact smoke so v11’s hash and restart evidence match the reviewed source.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Fix same-Session conflicting recovery-row quarantine",
"status": "completed",
"priority": "high"
},
{
"content": "Validate and smoke revised candidate",
"status": "in_progress",
"priority": ...
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0ab80de0f001vmcRXTShzp0efO dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v11-smoke.mjs
{
"initial": {
"port": 9649,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"constraints": [
{
"name": "session",
"sql": "CREATE TABLE `session` (\n `id` text PRIMARY KEY,\n `project_id` text NOT NULL,\n `workspace_id` text,\n `parent_id` text,\n `slug` text NOT NULL,\n `directory` text NOT NULL,\n `path` text,\n `title` text NOT NULL,\n `version` text NOT NULL,\n `share_url` text,\n ...
The v11 artifact passed the exact restart smoke and no disposable server survived. I’m freezing and clean-applying v11 now; v10’s pending reviewer is stale and cannot approve this source.
439358fbe6d556d25917c7152b1073bb0bac951927a9b28be050f89d7fc11f33 /tmp/opencode/session-recovery-final-v11.patch f629bfea3c141ae5b650902371176b605c79bdbb1aa266752c9e4641e1da1795 packages/opencode/dist/server/opencode-server packages/core/schema.json | 579 +++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 58 + packages/core/src/database/schema.gen.ts | 49 packages/core/src/session.ts | 9 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 155 + packages/core/src/session/input.ts | 373 +++ packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 496 +++++ ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 92% (6137/6637) Updating files: 93% (6173/6637) Updating files: 94% (6239/6637) Updating files: 95% (6306/6637) Updating files: 96% (6372/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
(no output)
Frozen v11 cleanly applies: patch 439358fb..., SEA f629bfea..., 33 files. I’m launching new correctness and security reviews with both same-Session collision routes called out: pending wake and settled wake.
<task id="ses_f547c8ed3ffeLv92fU33y7Ivav" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f547c3266ffem1ndz1C9ObJeYT" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Frozen v11 is under fresh correctness and security review.
439358fbe6d556d25917c7152b1073bb0bac951927a9b28be050f89d7fc11f33f629bfea3c141ae5b650902371176b605c79bdbb1aa266752c9e4641e1da1795</task_result> </task>
The v11 security reviewer returned empty output, so it provides no evidence and cannot approve the candidate. I’m retrying that reviewer once with an evidence-or-exact-blocker contract and no memory tools; a second empty result will disqualify it.
<task id="ses_f547c3266ffem1ndz1C9ObJeYT" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Security review retry is running against unchanged v11. Correctness review remains active.
ab9408c81cc9916177641ae8e9f08624fc27b5b9 (tree d3323b963b9ef2de41a7ff9884299c74b93886ba).439358fbe6d556d25917c7152b1073bb0bac951927a9b28be050f89d7fc11f33.f629bfea3c141ae5b650902371176b605c79bdbb1aa266752c9e4641e1da1795.The scanner rejects a superseded execution only when assistant_message_id is non-null:
packages/core/src/session/recovery.ts:142-156 loads the latest assistant, but line 152 gates the identity comparison with claim.assistant_message_id !== null.packages/core/src/session/recovery.ts:157-169 then emits an assistant-less recovery for every continue claim and every claim whose assistant ID is null.Assistant-less recoverable states are normal:
packages/core/src/session/runner/llm.ts:281-284 records automatic compaction continuation as phase: "continue", assistant_message_id: null.packages/core/src/session/runner/llm.ts:362-365 records pre-assistant provider uncertainty with a null assistant ID.packages/core/src/session/runner/llm.ts:385-389 does the same for overflow compaction continuation.A corrupt or replayed newer Step.Started event inserted before recovery does not stop these claims:
Security found a separate supersession gap: assistant-less claims have no durable boundary proving that no newer assistant appeared after execution began. I’m tracing claim acquisition and event sequencing now, then I’ll add a durable start sequence and failing regressions for assistant-less ready, unknown, and continue claims before changing recovery.
No correctness blocker found. I inspected all 33 patched files against the frozen base.
ab9408c81cc9916177641ae8e9f08624fc27b5b9 verified.439358fbe6d556d25917c7152b1073bb0bac951927a9b28be050f89d7fc11f33 verified.f629bfea3c141ae5b650902371176b605c79bdbb1aa266752c9e4641e1da1795 verified.git diff --check reported no whitespace errors.None.
hasPendingExecutionIntent excludes only the current recovery execution ID in the recovery-intent branch at packages/core/src/session/recovery.ts:30-62. Ordinary input remains eligible through the separate null-recovery branch at :47, while another recovery execution remains eligible through the inequality at :49-54.packages/core/src/session/recovery.ts:136-141 and :172-180. A current same-ID recovery row therefore never clears the claim before projector validation.Step.Recovered compares the full stored identity—Session, assistant, deterministic continuation, and phase—at packages/core/src/session/projector.ts:399-423. Any mismatch defects instead of returning idempotent success.packages/core/src/session/recovery.ts:187-210. It does not filter on wake_pending, so both true and false rows receive identical treatment. ...Found 100 matches (more matches available)
/home/byk/Code/opencode/packages/core/src/event.ts:
Line 8: import { EventSequenceTable, EventTable } from "./event/sql"
Line 26: .select({ seq: EventSequenceTable.seq })
Line 27: .from(EventSequenceTable)
Line 28: .where(eq(EventSequenceTable.aggregate_id, aggregateID))
Line 58: durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
Line 244: .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
Line 245: .from(EventSequenceTable)
Line 246: .where(eq(EventSequenceTable.aggregate_id, aggregateID))
Line 266: .where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
Line 276: .update(EventSequenceTable)
Line 278: ...
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
35: slug: text().notNull(),
36: directory: DatabasePath.directoryColumn().notNull(),
37: path: DatabasePath.pathColumn(),
38: title: text().notNull(),
39: version: text().notNull(),
40: share_url: text(),
41: summary_additions: integer(),
42: summary_deletions: integer(),
43: summary_files: integer(),
44: summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
45: metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
46: cost: real().notNull().default(0),
47: tokens_input: integer().notNull().default(0),
48: tokens_output: integer().notNull().default(0),
49: tokens_reasoning: integer().notNull().default(0),
50: tokens_cache_read: integer().notNull().default(0),
51: tokens_cache_write: integer().notNull().default(0),
52: revert: text({ mode: "json" }).$type<Revert.State>(),
53: ...
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
210: table.time_created,
211: table.execution_id,
212: ),
213: check(
214: "session_recovery_wake_attempts_check",
215: sql`${table.wake_attempts} >= 0 AND ${table.wake_attempts} <= ${sql.raw(String(MAX_WAKE_ATTEMPTS))}`,
216: ),
217: ],
218: )
219:
220: export const SessionExecutionTable = sqliteTable(
221: "session_execution",
222: {
223: id: text().notNull(),
224: session_id: text()
225: .$type<SessionSchema.ID>()
226: .notNull()
227: .references(() => SessionTable.id, { onDelete: "cascade" }),
228: owner_id: text().notNull(),
229: assistant_message_id: text().$type<SessionMessage.ID>(),
230: phase: text().$type<"ready" | "safe" | "unknown" | "continue" | "cancelling">().notNull(),
231: time_created: integer().notNull(),
232: expires_at: integer().notNull(),
233: recovery_error: text(),
234: ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
20: recoveryInputID: SessionMessage.ID | undefined,
21: ) => Effect.Effect<void, SessionRunner.RunError>
22: }) {
23: const { db } = yield* Database.Service
24: const ownerID = crypto.randomUUID()
25: const leaseDuration = Duration.toMillis(SESSION_EXECUTION_LEASE_MS)
26: const activeClaims = new Map<SessionSchema.ID, SessionExecutionClaim.Claim>()
27: const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
28: drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
29: const executionID = crypto.randomUUID()
30: const now = yield* Clock.currentTimeMillis
31: const claimed = yield* db
32: .insert(SessionExecutionTable)
33: .values({
34: id: executionID,
35: session_id: sessionID,
36: owner_id: ownerID,
37: phase: "ready",
38: ...
The clean design is a durable aggregate-sequence boundary on session_execution, captured atomically when ordinary ownership is acquired. Recovery can then discard every assistant-less claim if the latest assistant sequence is newer than that boundary; this covers ready, pre-assistant unknown, and compaction continue without misusing an old assistant ID.
<path>/home/byk/Code/opencode/packages/core/src/event/sql.ts</path>
<type>file</type>
<content>
1: import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"
2: import type { EventV2 } from "../event"
3:
4: export const EventSequenceTable = sqliteTable("event_sequence", {
5: aggregate_id: text().notNull().primaryKey(),
6: seq: integer().notNull(),
7: owner_id: text(),
8: })
9:
10: export const EventTable = sqliteTable(
11: "event",
12: {
13: id: text().$type<EventV2.ID>().primaryKey(),
14: aggregate_id: text()
15: .notNull()
16: .references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
17: seq: integer().notNull(),
18: type: text().notNull(),
19: data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
20: },
21: (table) => [
22: uniqueIndex("event_aggregate_seq_idx").on(table.aggregate_id, table.seq),
23: index("event_aggregate_type_seq_idx").on(table.aggregate_id, ...
Found 8 matches
/home/byk/Code/opencode/packages/core/src/event.ts:
Line 351: { behavior: "immediate" },
/home/byk/Code/opencode/packages/core/src/database/migration.ts:
Line 26: yield* db.transaction((tx) =>
Line 98: yield* db.transaction((tx) =>
/home/byk/Code/opencode/packages/core/src/github-copilot/responses/openai-responses-language-model.ts:
Line 1184: // immediately send the tool call after the input end:
/home/byk/Code/opencode/packages/core/src/system-context/index.ts:
Line 175: /** Combines contexts in order and rejects duplicate source keys immediately. */
/home/byk/Code/opencode/packages/core/src/session/compaction.ts:
Line 35: 1. [immediate concrete action, or "(none)"]
/home/byk/Code/opencode/packages/core/src/session/execution/claim.ts:
Line 35: * maximum clock skew plus one renewal interval. Execution IDs fence a node immediately after takeover;
Line 66: db.transaction(() => update(db, ...
Found 3 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 342: it.effect("never recovers an execution superseded by a newer assistant", () =>
Line 1415: it.effect("never promotes a recovery superseded before scanner validation", () =>
Line 1479: it.effect("quarantines an assistant-less recovery superseded before scanner validation", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
320: })
321: .run()
322: yield* db
323: .insert(SessionExecutionTable)
324: .values({
325: id: "execution",
326: session_id: sessionID,
327: owner_id: "live",
328: phase: "unknown",
329: time_created: Date.now(),
330: expires_at: Date.now() + 60_000,
331: })
332: .run()
333:
334: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
335:
336: expect(yield* db.select().from(SessionExecutionTable).all()).toHaveLength(1)
337: expect(yield* db.select().from(SessionRecoveryTable).all()).toEqual([])
338: expect(yield* db.select().from(SessionInputTable).all()).toEqual([])
339: }),
340: )
341:
342: it.effect("never recovers an execution superseded by a newer assistant", () =>
343: ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1460:
1461: expect(Exit.isFailure(promoted)).toBe(true)
1462: expect(yield* db.select().from(SessionRecoveryTable).get()).toMatchObject({
1463: wake_pending: true,
1464: recovery_error: "Invalid recovery projection identity",
1465: })
1466: expect(
1467: yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, continuation("exact-stale"))).get(),
1468: ).toMatchObject({ promoted_seq: null })
1469: expect(
1470: yield* db
1471: .select({ type: EventTable.type })
1472: .from(EventTable)
1473: .where(eq(EventTable.type, "session.next.prompted.1"))
1474: .all(),
1475: ).toEqual([])
1476: }),
1477: )
1478:
1479: it.effect("quarantines an assistant-less recovery superseded before scanner validation", () =>
1480: Effect.gen(function* () ...
Found 21 matches /home/byk/Code/opencode/packages/core/src/session/execution/local.ts: Line 32: .insert(SessionExecutionTable) /home/byk/Code/opencode/packages/core/test/session-runner.test.ts: Line 318: .insert(SessionExecutionTable) Line 3619: .insert(SessionExecutionTable) Line 3933: .insert(SessionExecutionTable) /home/byk/Code/opencode/packages/core/test/session-projector.test.ts: Line 90: .insert(SessionExecutionTable) Line 277: .insert(SessionExecutionTable) Line 323: .insert(SessionExecutionTable) Line 369: .insert(SessionExecutionTable) Line 998: .insert(SessionExecutionTable) Line 1056: .insert(SessionExecutionTable) Line 1105: .insert(SessionExecutionTable) Line 1277: .insert(SessionExecutionTable) Line 1619: .insert(SessionExecutionTable) Line 1664: .insert(SessionExecutionTable) Line 1722: ...
A fixed acquisition-only boundary would wrongly supersede valid assistant-less continuation after an assistant created by the same execution. The durable field must track the last event sequence atomically owned by the claim, advancing when the execution itself commits a terminal assistant/compaction boundary. I’m tracing commit-hook sequence availability before editing schema.
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
215: ) {
216: return Effect.gen(function* () {
217: const durable = definition?.durable
218: if (durable) {
219: const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
220: if (typeof aggregateID !== "string") {
221: yield* Effect.die(
222: new InvalidDurableEventError({
223: type: event.type,
224: message: `Expected string aggregate field ${durable.aggregate}`,
225: }),
226: )
227: } else {
228: if (input && input.aggregateID !== aggregateID) {
229: yield* Effect.die(
230: new InvalidDurableEventError({
231: type: event.type,
232: message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
233: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
120: if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
121: return session
122: })
123:
124: const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
125: return yield* store.context(sessionID)
126: })
127: const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
128: sessionID: SessionSchema.ID,
129: claim: SessionExecutionClaim.Claim,
130: ) {
131: for (const message of yield* getContext(sessionID)) {
132: if (message.type !== "assistant") continue
133: for (const tool of message.content) {
134: if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue
135: yield* events.publish(
136: SessionEvent.Tool.Failed,
137: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
265: },
266: },
267: providerOptions: { openai: { promptCacheKey } },
268: system: [agent.info?.system, system.baseline]
269: .filter((part): part is string => part !== undefined && part.length > 0)
270: .map(SystemPart.make),
271: messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
272: tools: toolMaterialization?.definitions ?? [],
273: toolChoice: isLastStep ? "none" : undefined,
274: })
275: if (
276: yield* compaction.compactIfNeeded({
277: sessionID: session.id,
278: entries,
279: model,
280: request,
281: beforeStream: () => updateExecution({ phase: "unknown", assistant_message_id: null }),
282: eventCommit: () => updateExecution(),
283: commit: () => ...
The correct boundary is the latest assistant projection sequence, not the latest event sequence. I’ll persist it on the execution claim: acquisition snapshots the latest assistant sequence in an immediate transaction, and Step.Started atomically advances it for the assistant created by that owner. Tool and other later events cannot mask a foreign newer assistant.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
25: input: safe(usage?.nonCachedInputTokens),
26: output: safe(usage?.visibleOutputTokens),
27: reasoning,
28: cache: { read, write },
29: }
30: }
31:
32: const record = (value: unknown): Record<string, unknown> =>
33: typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
34:
35: const message = (value: unknown) => {
36: if (typeof value === "string") return value
37: try {
38: return JSON.stringify(value) ?? ...
Found 3 matches
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line 9: type Input = {
Line 14: readonly startCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
Line 101: commit: () => input.startCommit(created),
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: readonly startCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
15: readonly eventCommit: () => Effect.Effect<void>
16: }
17:
18: const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? ...
ab9408c81cc9916177641ae8e9f08624fc27b5b9.07b95915924ccf5dcfdf7ea5eb45d6cf972a6058664518935154dff9934222b21117176c4cfbf77527216b4161b54f8d3918ca7f7415747117c24f94d8e0b090None.
packages/core/src/session/projector.ts:399-466 treats an existing recovery row as idempotent only when all identity fields match:
Any mismatch defects at packages/core/src/session/projector.ts:422.
After projection failure, packages/core/src/session/recovery.ts:187-210 independently reloads the stored row by Session and execution and suppresses quarantine only if the complete identity matches:
Otherwise, packages/core/src/session/recovery.ts:211-247 quarantines only the exact Session, execution, and recovery owner. If the update affects zero rows, lines 228-245 prove that the row was deleted or ownership transferred; retaining the same owner without quarantine defects instead of failing open.
The regression at packages/core/test/session-projector.test.ts:1251-1303 ...
Event commit hooks receive the durable aggregate sequence. I’m threading that sequence only through Step.Started so the claim’s assistant boundary advances in the same transaction as the assistant projection; all other claim renewals remain unchanged.
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
175: all: yield* PubSub.unbounded<Payload>(),
176: durable: new Map<string, Set<PubSub.PubSub<void>>>(),
177: typed: new Map<string, PubSub.PubSub<Payload>>(),
178: }
179: const projectors = new Map<string, Subscriber[]>()
180: // TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
181: const listeners = new Array<Subscriber>()
182: const { db } = yield* Database.Service
183:
184: const getOrCreate = (definition: Definition) =>
185: Effect.gen(function* () {
186: const existing = pubsub.typed.get(definition.type)
187: if (existing) return existing
188: const created = yield* PubSub.unbounded<Payload>()
189: pubsub.typed.set(definition.type, created)
190: return created
191: })
192:
193: yield* ...
Found 3 matches
/home/byk/Code/opencode/packages/core/src/event.ts:
Line 123: readonly commit?: (seq: number) => Effect.Effect<void>
Line 214: commit?: (seq: number) => Effect.Effect<void>,
Line 369: function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/src/session/execution/local.ts:
import { SessionExecutionClaim } from "./claim"
import { SessionRunCoordinator } from "../run-coordinator"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import { SESSION_EXECUTION_LEASE_MS, SessionExecutionTable, SessionRecoveryTable } from "../sql"
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, Duration, Effect, Exit, Layer, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { SessionExecutionClaim } from "./claim"
13: import { SESSION_EXECUTION_LEASE_MS, SessionExecutionTable, SessionRecoveryTable } from "../sql"
14:
15: /** Current-process routing for implicit-local Locations. Future remote placement belongs here. ...
Success. Updated the following files: M packages/core/src/session/sql.ts M packages/core/src/session/execution/claim.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/recovery.ts
Success. Updated the following files: M packages/core/src/session/execution/local.ts
No files found
Success. Updated the following files: M packages/core/src/session/execution/local.ts
Success. Updated the following files: M packages/core/src/session/sql.ts M packages/core/src/session/execution/local.ts
The new column defaults to -1, preserving existing direct fixtures while production acquisition stores the actual latest assistant sequence. I’m compiling before generating migration artifacts; then I’ll add the three phase-specific supersession regressions and a positive same-execution compaction case.
$ tsgo --noEmit src/session/runner/publish-llm-event.ts(100,7): error TS2345: Argument of type '(seq: any) => Effect<void, never, never>' is not assignable to parameter of type '() => Effect<void, never, never>'. Target signature provides too few arguments. Expected 1 or more, but got 0. src/session/runner/publish-llm-event.ts(101,18): error TS7006: Parameter 'seq' implicitly has an 'any' type.
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts
$ tsgo --noEmit src/session/runner/publish-llm-event.ts(67,9): error TS2554: Expected 1 arguments, but got 0. src/session/runner/publish-llm-event.ts(101,18): error TS7006: Parameter 'seq' implicitly has an 'any' type.
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-Lz0bOn/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-Lz0bOn/incremental/20260916190942_session-recovery-assistant-boundary/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-Lz0bOn/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-Lz0bOn/full/20260916190944_schema/migration.sql 🚀
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260916190942_session-recovery-assistant-boundary.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260916190942_session-recovery-assistant-boundary",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`ALTER TABLE \`session_execution\` ADD \`assistant_seq\` integer DEFAULT -1 NOT NULL;`)
9: })
10: },
11: } satisfies DatabaseMigration.Migration
(End of file - total 11 lines)
</content>
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260914170650_session-recovery",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(`ALTER TABLE \`session_input\` ADD \`wake_pending\` integer DEFAULT false NOT NULL;`)
9: yield* tx.run(
10: `ALTER TABLE \`session\` ADD \`wake_attempts\` integer DEFAULT 0 NOT NULL CONSTRAINT \`session_wake_attempts_check\` CHECK(\`wake_attempts\` >= 0 AND \`wake_attempts\` <= 9007199254740991);`,
11: )
12: yield* tx.run(`
13: CREATE TABLE \`session_execution\` (
14: \`id\` text NOT NULL,
15: \`session_id\` text NOT NULL,
16: \`owner_id\` text NOT NULL,
17: \`assistant_message_id\` text,
18: \`phase\` text NOT NULL,
19: ...
This remains one unpublished migration, so I’m folding the new column into the consolidated recovery migration and removing the generated incremental file. Then I’ll regenerate the registry and preserve direct snapshot ancestry to the immutable base.
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts:
`assistant_message_id` text,
`phase` text NOT NULL,
`time_created` integer NOT NULL,
Success. Updated the following files: M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260916190942_session-recovery-assistant-boundary.ts
Found 4 matches
/home/byk/Code/opencode/packages/core/schema.json:
Line 5: "prevIds": [
Line 989: "name": "assistant_seq",
/home/byk/Code/opencode/packages/core/src/database/schema.gen.ts:
Line 164: \`assistant_seq\` integer DEFAULT -1 NOT NULL,
/home/byk/Code/opencode/packages/core/src/database/migration.gen.ts:
Line 44: import("./migration/20260916190942_session-recovery-assistant-boundary"),
<path>/home/byk/Code/opencode/packages/core/schema.json</path>
<type>file</type>
<content>
1: {
2: "version": "7",
3: "dialect": "sqlite",
4: "id": "3a5ec6d4-a60d-47da-a347-3c72396a13c1",
5: "prevIds": [
6: "9e445a5f-9a7b-4d00-97f6-f80fee3c9383"
7: ],
8: "ddl": [
9: {
10: "name": "workspace",
(Showing lines 1-10 of 2564. Use offset=11 to continue.)
</content>
Success. Updated the following files: M packages/core/schema.json M packages/core/src/database/migration.gen.ts
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
65: }
66:
67: describe("SessionProjector", () => {
68: it.effect("atomically reconciles one abandoned execution into one continuation", () =>
69: Effect.gen(function* () {
70: const { db } = yield* Database.Service
71: const events = yield* EventV2.Service
72: const assistantID = SessionMessage.ID.make("msg_recovery_assistant")
73: const continuationID = continuation("execution")
74: yield* db
75: .insert(ProjectTable)
76: .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
77: .run()
78: yield* db
79: .insert(SessionTable)
80: .values({
81: id: sessionID,
82: project_id: Project.ID.global,
83: slug: "test",
84: directory: "/project",
85: title: "test",
86: version: "test",
87: })
88: ...
I’m adding one table-driven negative regression for assistant-less ready, unknown, and continue claims with a newer assistant, plus a positive continue case whose boundary already includes the latest assistant. This proves the new field rejects only foreign supersession, not valid same-execution compaction.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 4 pass 42 filtered out 0 fail 12 expect() calls Ran 4 tests across 1 file. [1397.00ms] bun test v1.3.14 (0d9b296a) 10 pass 0 fail 20 expect() calls Ran 10 tests across 1 file. [890.00ms] bun test v1.3.14 (0d9b296a) 1 pass 111 filtered out 0 fail 6 expect() calls Ran 1 test across 2 files. [1215.00ms] $ tsgo --noEmit
All four new boundary tests pass, along with execution ownership and focused runner coverage. The assistant boundary is now captured atomically at acquisition and advanced atomically with Step.Started. I’m validating the consolidated migration and complete recovery suite next.
Reading config file '/tmp/opencode-core-migration-JGZVqD/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-JGZVqD/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-JGZVqD/full/20260916191346_schema/migration.sql 🚀 bun test v1.3.14 (0d9b296a) 235 pass 0 fail 695 expect() calls Ran 235 tests across 8 files. [26.28s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[19:14:31.456] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
[19:15:48.316] INFO (#210020): watcher backend {
directory: "/tmp/opencode-core-test-32hqlj",
platform: "linux",
backend: "inotify",
}
[19:15:48.319] INFO (#210129): project copy refresh started {
projectID: "global",
}
[19:15:48.320] INFO (#210129): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[19:15:48.353] INFO (#210020): booting location services {
directory: "/tmp/opencode-core-test-32hqlj",
workspaceID: undefined,
}
1172 pass
0 fail
3284 ...
The complete recovery suite now passes 235 tests, and full Core passes all 1,172 tests. I’m running the five affected package typechecks, Schema manifest, migration/diff hygiene, and then rebuilding the exact v12 artifact.
$ tsgo --noEmit
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 2 pass 0 fail 23 expect() calls Ran 2 tests across 1 file. [1021.00ms]
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-4A6lZy/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-4A6lZy/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-4A6lZy/full/20260916192052_schema/migration.sql 🚀
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0abab1403001ROiUjs8h1bhg7S dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v12-smoke.mjs
{
"initial": {
"port": 21281,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"constraints": [
{
"name": "session",
"sql": "CREATE TABLE `session` (\n `id` text PRIMARY KEY,\n `project_id` text NOT NULL,\n `workspace_id` text,\n `parent_id` text,\n `slug` text NOT NULL,\n `directory` text NOT NULL,\n `path` text,\n `title` text NOT NULL,\n `version` text NOT NULL,\n `share_url` text,\n ...
The v12 artifact passed health, migration, recovery promotion, and duplicate-free restart. The new schema is already covered by migration generation and upgrade tests; no disposable server remains. I’m freezing the same 33-file scope and launching replacement reviews focused on the durable assistant boundary.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Fix same-Session conflicting recovery-row quarantine",
"status": "completed",
"priority": "high"
},
{
"content": "Add durable assistant-less supersession boundary",
"status": "completed",
...
a954d8f5b6923b0317200ee52048f0c649e8a8f6b166da03509473ca4b21ab11 /tmp/opencode/session-recovery-final-v12.patch 1f3de4dd2e48626680e3d52f48ab351ae7dca6d3bb4f996b6b3f1b7ec713daf6 packages/opencode/dist/server/opencode-server packages/core/schema.json | 587 +++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 59 + packages/core/src/database/schema.gen.ts | 50 packages/core/src/session.ts | 9 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 177 +- packages/core/src/session/input.ts | 373 +++ packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 498 ++++ ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 64% (4277/6637) Updating files: 65% (4315/6637) Updating files: 66% (4381/6637) Updating files: 67% (4447/6637) Updating files: 68% (4514/6637) Updating files: 69% (4580/6637) Updating files: 70% (4646/6637) Updating files: 71% (4713/6637) Updating files: 72% (4779/6637) Updating files: 73% (4846/6637) Updating files: 74% (4912/6637) Updating files: 75% (4978/6637) Updating files: 76% (5045/6637) Updating files: 77% (5111/6637) Updating files: 78% (5177/6637) Updating files: 79% (5244/6637) Updating files: 80% (5310/6637) Updating files: 81% (5376/6637) Updating files: 82% (5443/6637) Updating files: 83% (5509/6637) Updating files: 84% (5576/6637) Updating files: 85% (5642/6637) Updating files: 86% (5708/6637) Updating files: 87% (5775/6637) Updating files: 88% (5841/6637) Updating files: 89% (5907/6637) Updating files: 90% (5974/6637) Updating files: 91% (6040/6637) Updating files: 92% ...
(no output)
(no output)
Frozen v12 cleanly applies: patch a954d8f5..., SEA 1f3de4dd..., 33 files. I’m launching fresh independent reviews; both must verify acquisition and Step.Started update the assistant boundary atomically, and all three assistant-less phases reject a foreign newer assistant.
<task id="ses_f545224ceffeL6ksyga6plXxCF" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f5451b2abffeIS9qjEUVfTUjWR" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Frozen v12 is under fresh correctness and security review.
a954d8f5b6923b0317200ee52048f0c649e8a8f6b166da03509473ca4b21ab111f3de4dd2e48626680e3d52f48ab351ae7dca6d3bb4f996b6b3f1b7ec713daf6SessionExecutionClaim.update and clear require an unexpired lease (packages/core/src/session/execution/claim.ts:27-33,40-64). Recovery follows that rule for malformed phases (packages/core/src/session/recovery.ts:102-117) but bypasses it in two branches:
expires_at > now guard or affected-row check (packages/core/src/session/recovery.ts:120-134).packages/core/src/session/recovery.ts:213-249).A recovery owner can pause past its lease, resume before another scanner completes takeover, and set recovery_error. The next scanner’s takeover then fails because takeover requires recovery_error IS NULL (packages/core/src/session/recovery.ts:83-97). The expired owner has therefore mutated durable state and blocked reevaluation after losing authority.
Guard both updates with the current time and require one affected row. Treat zero rows as ownership loss. Add deterministic cancellation and projection-failure regressions that expire the first recovery owner before its quarantine write and allow a successor to take over.
The three parameterized supersession cases seed assistant_seq: -1 directly (packages/core/test/session-projector.test.ts:390-433), while the current-boundary continuation case seeds assistant_seq: 0 directly (packages/core/test/session-projector.test.ts:434-483).
They verify recovery’s comparison but remain green if either production producer breaks:
Step.Started no longer writes its commit sequence into the claim. ...The correctness review found a real fencing violation: two quarantine writes can mutate a claim after the recovery owner’s lease expires. v12 is rejected. I’m adding expiry/CAS guards with affected-row checks and producer-level tests for both assistant-boundary writers before refreezing.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Fix same-Session conflicting recovery-row quarantine",
"status": "completed",
"priority": "high"
},
{
"content": "Add durable assistant-less supersession boundary",
"status": "completed",
...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
60: .pipe(Effect.orDie)
61: return pending !== undefined
62: })
63:
64: export const recover = Effect.fn("SessionRecovery.recover")(function* () {
65: const { db } = yield* Database.Service
66: const events = yield* EventV2.Service
67: const execution = yield* SessionExecution.Service
68: const ownerID = crypto.randomUUID()
69: const scanTime = yield* Clock.currentTimeMillis
70: const claims = yield* db
71: .select()
72: .from(SessionExecutionTable)
73: .where(and(lte(SessionExecutionTable.expires_at, scanTime), isNull(SessionExecutionTable.recovery_error)))
74: .orderBy(asc(SessionExecutionTable.time_created), asc(SessionExecutionTable.id))
75: .limit(BATCH_SIZE)
76: .all()
77: .pipe(Effect.orDie)
78: yield* Effect.forEach(
79: claims,
80: (claim) =>
81: Effect.gen(function* () {
82: const now = yield* ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, exists, gt, isNull, lte, or, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { SessionInput } from "./input"
13: import { continuation } from "./recovery-id"
14: import { SessionSchema } from "./schema"
15: import {
16: MAX_WAKE_ATTEMPTS,
17: SESSION_EXECUTION_LEASE_MS,
18: SessionExecutionTable,
19: SessionInputTable,
20: SessionRecoveryTable,
21: SessionMessageTable,
22: ...
Found 17 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 3: import { TestClock } from "effect/testing"
Line 512: recovery_error: null,
Line 582: .select({ error: SessionRecoveryTable.recovery_error })
Line 639: .where(eq(SessionRecoveryTable.recovery_error, "Invalid recovery continuation input"))
Line 649: .where(eq(SessionRecoveryTable.recovery_error, "Invalid recovery continuation input"))
Line 1020: recovery_error: "Invalid recovery continuation input",
Line 1388: recovery_error: "Recovery projection failed",
Line 1451: recovery_error: "Invalid recovery projection identity",
Line 1560: recovery_error: "Invalid recovery projection identity",
Line 1621: recovery_error: "Invalid recovery projection identity",
Line 1666: recovery_error: "Invalid recovery continuation input",
Line 1730: .select({ error: ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1680: expect(yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, continuationID)).get()).toMatchObject(
1681: { promoted_seq: null },
1682: )
1683: expect(yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, ordinaryID)).get()).toMatchObject({
1684: promoted_seq: expect.any(Number),
1685: })
1686: }),
1687: )
1688:
1689: it.effect("quarantines a malformed claim without blocking a valid claim", () =>
1690: Effect.gen(function* () {
1691: const { db } = yield* Database.Service
1692: const validSession = SessionV2.ID.make("ses_recovery_valid")
1693: yield* db
1694: .insert(ProjectTable)
1695: .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
1696: .run()
1697: yield* db
1698: ...
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
3: import { TestClock } from "effect/testing"
4: import { eq } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { Project } from "@opencode-ai/core/project"
7: import { ProjectTable } from "@opencode-ai/core/project/sql"
8: import { AbsolutePath } from "@opencode-ai/core/schema"
9: import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
10: import { SessionSchema } from "@opencode-ai/core/session/schema"
11: import { SessionExecutionTable, SessionTable } from "@opencode-ai/core/session/sql"
12: import { testEffect } from "./lib/effect"
13:
14: const sessionID = SessionSchema.ID.make("ses_execution_local")
15: const it = testEffect(Database.layerFromPath(":memory:"))
16:
17: const ...
The quarantine fix is small: both branches will sample time immediately before their update, require the exact owner’s lease to remain live, and require one returned row. A zero-row result means ownership was lost and the old scanner stops without mutating or blocking its successor.
No files found
Success. Updated the following files: M packages/core/src/session/recovery.ts
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
30: SessionExecutionTable,
31: SessionInputTable,
32: SessionMessageTable,
33: SessionRecoveryTable,
34: SessionTable,
35: } from "@opencode-ai/core/session/sql"
36: import { testEffect } from "./lib/effect"
37: import { Snapshot } from "@opencode-ai/core/snapshot"
38: import { Location } from "@opencode-ai/core/location"
39: import { SystemContext } from "@opencode-ai/core/system-context"
40:
41: const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
42: const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])
43: const recoveryExecution = SessionExecution.Service.of({
44: active: Effect.succeed(new Set()),
45: interrupt: () => Effect.void,
46: resume: () => Effect.void,
47: wake: () => Effect.void,
48: })
49: const sessionID = ...
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
112: .$type<SessionSchema.ID>()
113: .notNull()
114: .references(() => SessionTable.id, { onDelete: "cascade" }),
115: content: text().notNull(),
116: status: text().notNull(),
117: priority: text().notNull(),
118: position: integer().notNull(),
119: ...Timestamps,
120: },
121: (table) => [
122: primaryKey({ columns: [table.session_id, table.position] }),
123: index("todo_session_idx").on(table.session_id),
124: ],
125: )
126:
127: export const SessionMessageTable = sqliteTable(
128: "session_message",
129: {
130: id: text().$type<SessionMessage.ID>().primaryKey(),
131: session_id: text()
132: .$type<SessionSchema.ID>()
133: .notNull()
134: .references(() => SessionTable.id, { onDelete: "cascade" }),
135: type: text().$type<SessionMessage.Type>().notNull(),
136: seq: integer().notNull(),
137: ...
Found 6 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 333: assistant_message_id: null,
Line 752: it.effect("never dispatches a provider after ownership changes", () =>
Line 2109: assistant_message_id: null,
Line 2464: const providerGate = yield* Deferred.make<void>()
Line 2480: Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final)),
Line 2500: yield* Deferred.succeed(providerGate, undefined)
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
280: [LayerNodePlatform.llmClient, client],
281: [PermissionV2.node, permission],
282: [SessionRunnerModel.node, models],
283: [SystemContextRegistry.node, systemContext],
284: [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
285: [SkillGuidance.node, skillGuidance],
286: [ReferenceGuidance.node, referenceGuidance],
287: [Snapshot.node, Snapshot.noopLayer],
288: [SessionExecution.node, execution],
289: [Config.node, config],
290: ],
291: ),
292: )
293: const sessionID = SessionV2.ID.make("ses_runner_test")
294: const otherSessionID = SessionV2.ID.make("ses_runner_other")
295:
296: const insertSession = (id: SessionV2.ID) =>
297: Effect.gen(function* () {
298: const { db } = yield* Database.Service
299: yield* db
300: .insert(SessionTable)
301: .values({
302: ...
Found 14 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 597: ).toEqual({ id: executionID, phase: "unknown" })
Line 852: yield* events.publish(SessionEvent.Step.Started, {
Line 1538: ).toEqual({ id: "compaction-successor", phase: "unknown" })
Line 2010: expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({ phase: "unknown" })
Line 2071: expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({ phase: "unknown" })
Line 2108: phase: "unknown",
Line 2116: .where(eq(EventTable.type, "session.next.step.started.1"))
Line 2173: expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({ phase: "unknown" })
Line 2215: expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({ phase: "unknown" })
Line 3037: yield* events.publish(SessionEvent.Step.Started, {
Line 3101: yield* ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
2080: expect(assistant).not.toHaveProperty("finish")
2081: expect(assistant).not.toHaveProperty("error")
2082: }),
2083: )
2084:
2085: it.effect("never records assistant start state when Step start publication defects", () =>
2086: Effect.gen(function* () {
2087: yield* setup
2088: const session = yield* SessionV2.Service
2089: const { db } = yield* Database.Service
2090: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail Step start publication" }), resume: false })
2091: response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "failed-step-start" })]
2092: yield* db.run(sql`
2093: CREATE TRIGGER fail_step_start_publication
2094: BEFORE INSERT ON session_message
2095: WHEN NEW.type = 'assistant'
2096: BEGIN
2097: SELECT RAISE(ABORT, 'crash before ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
2030: const session = yield* SessionV2.Service
2031: const { db } = yield* Database.Service
2032: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after finish" }), resume: false })
2033: responseStream = Stream.fromIterable([
2034: LLMEvent.stepStart({ index: 0 }),
2035: LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2036: ]).pipe(Stream.concat(Stream.fail(providerUnavailable())))
2037:
2038: expect(Exit.isFailure(yield* session.resume(sessionID).pipe(Effect.exit))).toBe(true)
2039: expect(
2040: yield* db
2041: .select({ type: EventTable.type })
2042: .from(EventTable)
2043: .where(eq(EventTable.type, "session.next.step.ended.2"))
2044: .all(),
2045: ).toEqual([])
2046: }),
2047: )
2048:
2049: it.effect("keeps a running ...
The existing poison-batch test had already been advancing the clock past the lease before quarantine, so it encoded the unsafe behavior. I’m splitting that evidence: poison batching will keep a live recovery lease, while two new tests prove an expired recovery owner cannot quarantine and a successor can.
ab9408c81cc9916177641ae8e9f08624fc27b5b9.a954d8f5b6923b0317200ee52048f0c649e8a8f6b166da03509473ca4b21ab11.1f3de4dd2e48626680e3d52f48ab351ae7dca6d3bb4f996b6b3f1b7ec713daf6.repo-setup skill was unavailable; I followed the repository and nested AGENTS.md files directly.The primary closure is incomplete.
Claim acquisition correctly captures the latest assistant sequence inside the same immediate transaction that inserts the claim:
packages/core/src/session/execution/local.ts:36-65Step.Started also advances assistant_seq through the exact owner’s durable event transaction:
packages/core/src/session/runner/llm.ts:297-299packages/core/src/session/runner/publish-llm-event.ts:87-105packages/core/src/session/execution/claim.ts:27-54However, recovery checks the latest assistant before entering the durable Step.Recovered transaction:
packages/core/src/session/recovery.ts:142-155packages/core/src/session/recovery.ts:162-184.The projector only repeats the latest-assistant check when assistantMessageID is present:
packages/core/src/session/projector.ts:424-440Assistant-less ready, unknown, and continue recoveries therefore have this interleaving:
assistant_seq = N.N.Step.Started commits a newer assistant at ...The security review found a second real race: assistant supersession must be rechecked inside the Step.Recovered transaction, not only before publication. v12 remains rejected. I’m folding that check into the existing commit hook so a concurrent newer assistant rolls back both projection and claim clearing for all assistant-less phases.
<path>/home/byk/Code/opencode/packages/core/src/session/projector.ts</path>
<type>file</type>
<content>
390: )
391: yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
392: yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
393: yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
394: yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
395: yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
396: yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
397: yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
398: yield* events.project(SessionEvent.Step.Interrupted, (event) => run(db, event))
399: yield* events.project(SessionEvent.Step.Recovered, (event) =>
400: Effect.gen(function* () {
401: if (event.durable === undefined) return yield* Effect.die("Durable Session event is ...
The transaction-level check will use the claim’s captured assistant_seq, not the continuation’s later admission sequence. A dedicated supersession defect will roll back Step.Recovered, then clear only the still-live exact recovery claim; ownership loss remains fail-closed.
Success. Updated the following files: M packages/core/src/session/recovery.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
[19:53:35.458] ERROR (#2): 277 | }
278 |
279 | assertUnreachable(cacheStrat)
280 | }).pipe(
281 | Effect.catch((e) => {
282 | return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
^
EffectDrizzleQueryError: Failed query:
INSERT INTO session_message (id, session_id, type, seq, time_created, data)
VALUES ('msg_execution_local_existing', ?, 'assistant', 7, 0, '{}')
params: ses_execution_local
query: "\n INSERT INTO session_message (id, session_id, type, seq, time_created, data)\n VALUES ('msg_execution_local_existing', ?, 'assistant', 7, 0, '{}')\n ",
params: [
"ses_execution_local"
],
_tag: "EffectDrizzleQueryError",
at /home/byk/Code/opencode/packages/effect-drizzle-sqlite/src/sqlite-core/effect/session.ts:282:32
at ~effect/Utils/internal ...
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
bun test v1.3.14 (0d9b296a) 1 pass 10 filtered out 0 fail 1 expect() calls Ran 1 test across 1 file. [2.05s] bun test v1.3.14 (0d9b296a) 1 pass 106 filtered out 0 fail 5 expect() calls Ran 1 test across 1 file. [3.99s] bun test v1.3.14 (0d9b296a) 3 pass 45 filtered out 0 fail 6 expect() calls Ran 3 tests across 1 file. [6.58s]
The lease-loss tests now pass, and both production boundary writers have direct coverage. I’m adding the required interleaving test by injecting Step.Started after recovery’s precheck but before Step.Recovered enters its transaction, for ready, unknown, and continue.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 7 pass 44 filtered out 0 fail 24 expect() calls Ran 7 tests across 1 file. [2.77s] $ tsgo --noEmit test/session-projector.test.ts(470,13): error TS2322: Type 'Effect<void, never, never>' is not assignable to type 'Effect<Payload<D>, never, never>'. Type 'void' is not assignable to type 'Payload<D>'.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 6 pass 45 filtered out 0 fail 18 expect() calls Ran 6 tests across 1 file. [4.81s] bun test v1.3.14 (0d9b296a) 1 pass 10 filtered out 0 fail 1 expect() calls Ran 1 test across 1 file. [1316.00ms] bun test v1.3.14 (0d9b296a) 1 pass 106 filtered out 0 fail 5 expect() calls Ran 1 test across 1 file. [2.38s]
Both review blockers now have deterministic coverage: stale recovery owners cannot quarantine after lease loss, successor recovery proceeds, and concurrent Step.Started rolls back assistant-less recovery for all three phases. I’m rerunning the complete recovery and Core suites before rebuilding.
bun test v1.3.14 (0d9b296a) 241 pass 0 fail 712 expect() calls Ran 241 tests across 8 files. [22.28s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[19:58:27.530] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
1178 pass
0 fail
3301 expect() calls
Ran 1178 tests across 145 files. [79.01s]
[19:59:40.522] INFO (#210241): watcher backend {
directory: "/tmp/opencode-core-test-ZX6H15",
platform: "linux",
backend: "inotify",
}
[19:59:40.528] INFO (#210350): project copy refresh started {
projectID: "global",
}
[19:59:40.530] INFO (#210350): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[19:59:40.560] INFO (#210241): booting location services {
directory: ...
Reading config file '/tmp/opencode-core-migration-JFGIXF/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-JFGIXF/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-JFGIXF/full/20260916200002_schema/migration.sql 🚀
Full Core now passes 1,178 tests and recovery passes 241. The schema did not change after v12, and all affected package typechecks remain valid except Core, which was rerun after the fixes. I’m rebuilding and repeating the isolated restart smoke for v13.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0abcf08e100105n8oOfqbjdXTB dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v13-smoke.mjs
{
"initial": {
"port": 16681,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"constraints": [
{
"name": "session",
"sql": "CREATE TABLE `session` (\n `id` text PRIMARY KEY,\n `project_id` text NOT NULL,\n `workspace_id` text,\n `parent_id` text,\n `slug` text NOT NULL,\n `directory` text NOT NULL,\n `path` text,\n `title` text NOT NULL,\n `version` text NOT NULL,\n `share_url` text,\n ...
v13 passes the exact artifact smoke and leaves no process behind. I’m freezing it now; replacement reviews will focus on lease-fenced quarantine and transaction-level assistant supersession.
3e9b20cb5ec05d1065b6bc78bab00b47e8ca9296bc819689a4f3d889eddcb5bf /tmp/opencode/session-recovery-final-v13.patch c3b8c5d78e473ad4104a5b54617900e28921a283029b8484be7bce4bc3cee85a packages/opencode/dist/server/opencode-server packages/core/schema.json | 587 ++++- packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 59 packages/core/src/database/schema.gen.ts | 50 packages/core/src/session.ts | 9 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 177 + packages/core/src/session/input.ts | 373 +++ packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 504 ++++ ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 93% (6204/6637) Updating files: 94% (6239/6637) Updating files: 95% (6306/6637) Updating files: 96% (6372/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
(no output)
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Fix same-Session conflicting recovery-row quarantine",
"status": "completed",
"priority": "high"
},
{
"content": "Add durable assistant-less supersession boundary",
"status": "completed",
...
Frozen v13 cleanly applies: patch 3e9b20cb..., SEA c3b8c5d7..., 33 files. Fresh reviews will inspect the exact lease-loss quarantine tests and all three transaction-boundary supersession interleavings.
<task id="ses_f542e22fcffeLz6TlB7R7lixEw" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f542dc8ecffe2GcZB6AxmkNTyL" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Frozen v13 is under fresh correctness and security review.
3e9b20cb5ec05d1065b6bc78bab00b47e8ca9296bc819689a4f3d889eddcb5bfc3b8c5d78e473ad4104a5b54617900e28921a283029b8484be7bce4bc3cee85aA stale runner can quarantine recovery work after losing its lease.
The production caller supplies an ownership guard to promoteExact at packages/core/src/session/runner/llm.ts:235-239. Normal promotion invokes that guard inside the durable event transaction through packages/core/src/session/input.ts:473-484.
However, when projection raises RecoveryConflict, the catch path writes session_recovery.recovery_error directly at packages/core/src/session/input.ts:582-596. Its predicate checks only execution identity, wake state, and existing error. It never checks the active Session execution ID, owner ID, or unexpired lease, and it never invokes the supplied guard.
Therefore, a runner paused across lease expiry or takeover can still mutate durable recovery state and quarantine work owned by its successor. This violates the stale-owner fencing invariant even though most validation errors are monotonic.
Move the quarantine update into an immediate transaction that first validates the exact live claim, then write the error, and only afterward propagate RecoveryConflict. Add a regression that transfers ownership between conflict detection and quarantine and asserts that the stale owner cannot set recovery_error.
Existing coverage at packages/core/test/session-projector.test.ts:1581-1642 calls promoteExact with Effect.void; it does not exercise ownership loss.
ab9408c81cc9916177641ae8e9f08624fc27b5b9 through a base-backed temporary index.3e9b20cb5ec05d1065b6bc78bab00b47e8ca9296bc819689a4f3d889eddcb5bf. ...The security review found another stale-owner mutation in promoteExact: conflict quarantine bypasses the supplied ownership guard. v13 is rejected. I’ll add a deterministic ownership-transfer regression, then move that quarantine into the same guarded immediate transaction used by promotion.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Fix same-Session conflicting recovery-row quarantine",
"status": "completed",
"priority": "high"
},
{
"content": "Add durable assistant-less supersession boundary",
"status": "completed",
...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
540: .select({ input: SessionInputTable })
541: .from(SessionInputTable)
542: .leftJoin(
543: SessionRecoveryTable,
544: eq(SessionRecoveryTable.continuation_message_id, SessionInputTable.id),
545: )
546: .where(
547: and(
548: eq(SessionInputTable.session_id, sessionID),
549: isNull(SessionInputTable.promoted_seq),
550: eq(SessionInputTable.delivery, "queue"),
551: isNull(SessionRecoveryTable.execution_id),
552: ),
553: )
554: .orderBy(asc(SessionInputTable.admitted_seq))
555: .limit(1)
556: .get()
557: .pipe(Effect.orDie)
558: return row === undefined ? false : yield* publish(db, events, sessionID, [row.input], commit).pipe(Effect.as(true))
559: })
560:
561: export const promoteExact = Effect.fn("SessionInput.promoteExact")(function* (
562: db: DatabaseService,
563: events: ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
230: eq(SessionInputTable.id, input.id),
231: eq(SessionInputTable.session_id, input.sessionID),
232: isNull(SessionInputTable.promoted_seq),
233: ),
234: )
235: .returning({ id: SessionInputTable.id })
236: .get()
237: .pipe(Effect.orDie)
238: if (updated) return
239: const stored = yield* find(db, input.id)
240: if (stored?.sessionID === input.sessionID && stored.promotedSeq !== undefined) return
241: return yield* Effect.die(new LifecycleConflict({ id: input.id }))
242: })
243:
244: export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* (
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 ...
Found 2 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 1623: const promoted = yield* SessionInput.promoteExact(db, events, sessionID, continuation("exact-stale"), () =>
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 235: yield* SessionInput.promoteExact(db, events, session.id, promotion.recoveryInputID, () =>
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
222: const settled = yield* Effect.forEach(toolFibers, Fiber.join, {
223: concurrency: "unbounded",
224: discard: true,
225: }).pipe(Effect.exit)
226: if (Exit.isFailure(settled)) yield* interruptToolFibers()
227: return yield* settled
228: })
229: let needsContinuation = false
230: let currentStep = step
231: if (promotion) {
232: const cutoff = yield* EventV2.latestSequence(db, session.id)
233: let promoted = 0
234: if (typeof promotion === "object") {
235: yield* SessionInput.promoteExact(db, events, session.id, promotion.recoveryInputID, () =>
236: Effect.gen(function* () {
237: yield* updateExecution()
238: }),
239: )
240: promoted = 1
241: }
242: if (promotion === "steer")
243: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
175: }
176:
177: const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step })
178: const continueAfterOverflowCompaction = (step: number) =>
179: new TurnTransitionError({
180: _tag: "ContinueAfterOverflowCompaction",
181: step,
182: })
183:
184: const loadSystemContext = (agent: AgentV2.Selection) =>
185: Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
186: concurrency: "unbounded",
187: }).pipe(Effect.map(SystemContext.combine))
188:
189: const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
190: sessionID: SessionSchema.ID,
191: executionID: string,
192: promotion: Promotion | undefined,
193: step: number,
194: ownerID: string,
195: recoverOverflow?: typeof ...
Found 1 matches
/home/byk/Code/opencode/packages/core/src/session/input.ts:
Line 464: const publish = Effect.fn("SessionInput.publish")(function* (
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
450: JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
451:
452: const matchesProjection = (
453: input: Admitted,
454: expected: {
455: readonly sessionID: SessionSchema.ID
456: readonly prompt: Prompt
457: readonly delivery: Delivery
458: readonly timeCreated: DateTime.Utc
459: },
460: ) =>
461: equivalent(input, expected) &&
462: DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated)
463:
464: const publish = Effect.fn("SessionInput.publish")(function* (
465: db: DatabaseService,
466: events: EventV2.Interface,
467: sessionID: SessionSchema.ID,
468: rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
469: commit?: (row: typeof SessionInputTable.$inferSelect) => Effect.Effect<void>,
470: ) {
471: for (const row of rows) {
472: const id = ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/claim.ts</path>
<type>file</type>
<content>
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<
18: Pick<typeof SessionExecutionTable.$inferInsert, "assistant_message_id" | "assistant_seq" | "phase">
19: >
20:
21: export class Lost extends Error {
22: constructor(readonly claim: Claim) {
23: super(`Session execution claim lost: ${claim.executionID}`)
24: }
25: }
26:
27: const where = (claim: Claim, ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1570: expect(yield* SessionInput.promoteNextQueued(db, events, sessionID)).toBe(true)
1571: expect(yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, continuationID)).get()).toMatchObject(
1572: { promoted_seq: null },
1573: )
1574: expect(yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, ordinaryID)).get()).toMatchObject({
1575: promoted_seq: expect.any(Number),
1576: })
1577: expect(yield* SessionInput.hasPending(db, sessionID, "queue")).toBe(false)
1578: }),
1579: )
1580:
1581: it.effect("never promotes a recovery superseded before scanner validation", () =>
1582: Effect.gen(function* () {
1583: const { db } = yield* Database.Service
1584: const events = yield* EventV2.Service
1585: const recoveredID = ...
The minimal fix reuses promoteExact’s existing commit callback as the ownership guard in a new immediate transaction, then quarantines exactly one pending recovery row. The original RecoveryConflict is raised only after that transaction commits, so the quarantine persists; if ownership changed, the guard defects and the write rolls back.
Success. Updated the following files: M packages/core/src/session/input.ts
Found 3 matches
/home/byk/Code/opencode/packages/core/src/event.ts:
Line 123: readonly commit?: (seq: number) => Effect.Effect<void>
Line 214: commit?: (seq: number) => Effect.Effect<void>,
Line 369: function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
350: }),
351: { behavior: "immediate" },
352: )
353: .pipe(Effect.orDie)
354: if (committed) {
355: yield* Effect.forEach(
356: pubsub.durable.get(committed.aggregateID) ?? [],
357: (wake) => PubSub.publish(wake, undefined),
358: { discard: true },
359: )
360: }
361: return committed
362: }),
363: )
364: }
365: }
366: })
367: }
368:
369: function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
370: return Effect.gen(function* () {
371: if (!definition?.durable && commit)
372: return yield* ...
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
235: }
236: const list = projectors.get(event.type) ?? []
237: return yield* Effect.uninterruptible(
238: Effect.gen(function* () {
239: const committed = yield* db
240: .transaction(
241: () =>
242: Effect.gen(function* () {
243: const row = yield* db
244: .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
245: .from(EventSequenceTable)
246: .where(eq(EventSequenceTable.aggregate_id, aggregateID))
247: .get()
248: .pipe(Effect.orDie)
249: const latest = row?.seq ?? -1
250: const encoded = ...
Found 3 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 24: import { SessionExecutionClaim } from "@opencode-ai/core/session/execution/claim"
Line 2208: SessionExecutionClaim.update(db, stale),
Line 2445: SessionExecutionClaim.update(db, {
Correctness found persistent Session-level retry debt leaking into later prompt episodes. The reset belongs only on a fresh Prompted transition, never on duplicate replay, so successful progress clears old debt without letting an old replay reset current scheduling state. I’m adding that regression alongside mutation-sensitive live-successor ownership tests.
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path> <type>file</type> <content> 700: wake: (id) => Effect.sync(() => wakes.push(id)).pipe(Effect.asVoid), 701: }) 702: 703: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution)) 704: expect(wakes).toEqual([]) 705: expect( 706: yield* db 707: .select() 708: .from(SessionRecoveryTable) 709: .where(eq(SessionRecoveryTable.recovery_error, "Invalid recovery continuation input")) 710: .all(), 711: ).toHaveLength(SessionRecovery.BATCH_SIZE) 712: 713: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution)) 714: expect(wakes).toEqual([sessions[poisonCount]]) 715: expect( 716: yield* db 717: .select() 718: .from(SessionRecoveryTable) 719: .where(eq(SessionRecoveryTable.recovery_error, ...
Success. Updated the following files: M packages/core/src/session/input.ts
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
1: export * as SessionInput from "./input"
2:
3: import { and, asc, desc, eq, isNull, lte } from "drizzle-orm"
4: import { DateTime, Effect, Option, Schema } from "effect"
5: import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
6: import type { Database } from "../database/database"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionMessage } from "./message"
10: import { Prompt } from "./prompt"
11: import { continuation, message } from "./recovery-id"
12: import { SessionSchema } from "./schema"
13: import { SessionInputTable, SessionMessageTable, SessionRecoveryTable } from "./sql"
14:
15: type DatabaseService = Database.Interface["db"]
16:
17: export { Admitted, Delivery }
18:
19: const decodePrompt = Schema.decodeUnknownSync(Prompt)
20: const encodePrompt = Schema.encodeSync(Prompt)
21: const continuePrompt = ...
Success. Updated the following files: M packages/core/src/session/input.ts
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { DateTime, Deferred, Effect, Exit, Fiber, Schema } from "effect"
3: import { TestClock } from "effect/testing"
4: import { asc, eq, sql } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
7: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
8: import { EventV2 } from "@opencode-ai/core/event"
9: import { EventTable } from "@opencode-ai/core/event/sql"
10: import { ModelV2 } from "@opencode-ai/core/model"
11: import { Project } from "@opencode-ai/core/project"
12: import { ProjectTable } from "@opencode-ai/core/project/sql"
13: import { ProviderV2 } from "@opencode-ai/core/provider"
14: import { AbsolutePath } from "@opencode-ai/core/schema"
15: import { SessionV2 } from ...
Found 1 matches
/home/byk/Code/opencode/packages/core/src/session/input.ts:
Line 37: export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
Success. Updated the following files: M packages/core/test/session-projector.test.ts
Found 1 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 904: { replay: { seq: first.promotedSeq } },
Found 1 matches /home/byk/Code/opencode/packages/core/src/event.ts: Line 138: readonly replay: (
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
115: export const define = Event.define
116: export const versionedType = Event.versionedType
117:
118: export interface PublishOptions {
119: readonly id?: ID
120: readonly metadata?: Record<string, unknown>
121: readonly location?: Location.Ref
122: /** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
123: readonly commit?: (seq: number) => Effect.Effect<void>
124: }
125:
126: export interface Interface {
127: readonly publish: <D extends Definition>(
128: definition: D,
129: data: Data<D>,
130: options?: PublishOptions,
131: ) => Effect.Effect<Payload<D>>
132: readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
133: readonly all: () => Stream.Stream<Payload>
134: readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => ...
Found 29 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 416: yield* events.replayAll(
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 160: yield* events.replayAll(
Line 199: yield* events.replayAll(
/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts:
Line 115: yield* events.replayAll(
Line 415: yield* events.replay({
/home/byk/Code/opencode/packages/core/test/session-create.test.ts:
Line 258: expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id)
Line 268: expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id)
/home/byk/Code/opencode/packages/core/test/event.test.ts:
Line 551: yield* events.replay({
Line 570: yield* events.replay({
Line 639: yield* events.replay({
Line 671: yield* events.replay({
Line 704: const source = yield* events.replayAll([
Line 731: const ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
140: delivery: "queue",
141: })
142: expect(yield* db.select().from(SessionRecoveryTable).all()).toHaveLength(1)
143: expect(yield* db.select().from(SessionRecoveryTable).get()).toMatchObject({
144: execution_id: "execution",
145: assistant_message_id: assistantID,
146: phase: "unknown",
147: })
148: expect(yield* db.select().from(SessionExecutionTable).all()).toEqual([])
149:
150: const recorded = yield* db
151: .select()
152: .from(EventTable)
153: .where(eq(EventTable.aggregate_id, sessionID))
154: .orderBy(asc(EventTable.seq))
155: .all()
156: yield* events.remove(sessionID)
157: yield* db.delete(SessionRecoveryTable).run()
158: yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run()
159: yield* ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
The stale-owner regression will transfer ownership in an intercepted publication failure, before promoteExact catches RecoveryConflict. Its quarantine transaction then runs the real SessionExecutionClaim.update guard and must roll back without writing recovery_error.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
$ tsgo --noEmit test/session-projector.test.ts(889,34): error TS2554: Expected 4-5 arguments, but got 3. test/session-projector.test.ts(1751,11): error TS2322: Type 'Effect<Payload<D>, EffectDrizzleQueryError, never>' is not assignable to type 'Effect<Payload<D>, never, never>'. Type 'EffectDrizzleQueryError' is not assignable to type 'never'.
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
485: messageID: id,
486: prompt: decodePrompt(row.prompt),
487: delivery: row.delivery,
488: },
489: commit ? { commit: () => commit(row) } : undefined,
490: )
491: .pipe(
492: Effect.catchDefect((defect) =>
493: defect instanceof LifecycleConflict
494: ? find(db, id).pipe(
495: Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)),
496: )
497: : Effect.die(defect),
498: ),
499: )
500: }
501: return rows.length
502: })
503:
504: export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
505: db: DatabaseService,
506: events: EventV2.Interface,
507: sessionID: SessionSchema.ID,
508: cutoff: number,
509: commit?: (row: typeof SessionInputTable.$inferSelect) => ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
529: .pipe(Effect.orDie)
530: return yield* publish(
531: db,
532: events,
533: sessionID,
534: rows.map((row) => row.input),
535: commit,
536: )
537: })
538:
539: export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
540: db: DatabaseService,
541: events: EventV2.Interface,
542: sessionID: SessionSchema.ID,
543: commit?: (row: typeof SessionInputTable.$inferSelect) => Effect.Effect<void>,
(Showing lines 529-543 of 621. Use offset=544 to continue.)
</content>
Success. Updated the following files: M packages/core/test/session-projector.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 3 pass 50 filtered out 0 fail 10 expect() calls Ran 3 tests across 1 file. [3.84s]
The exact-promotion stale-owner regression passes, and fresh promotion resets inherited retry debt while duplicate replay leaves current debt unchanged. I’m closing the review’s mutation-sensitivity concern with live-successor tests for both startup quarantine branches, isolating owner_id from lease expiry.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-projector.test.ts:
53 | ? Statement.defaultTransforms(options.transformResultNames).array
54 | : undefined
55 |
56 | const run = (query: string, params: ReadonlyArray<unknown> = []) =>
57 | Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
58 | const statement = native.query(query)
^
SQLiteError: trigger cannot use variables
errno: 1,
byteOffset: -1,
at prepare (unknown:1:1)
at prepare (bun:sqlite:345:37)
at query (bun:sqlite:367:28)
at /home/byk/Code/opencode/packages/core/src/database/sqlite.bun.ts:58:34
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
Success. Updated the following files: M packages/core/test/session-projector.test.ts
bun test v1.3.14 (0d9b296a) 4 pass 51 filtered out 0 fail 8 expect() calls Ran 4 tests across 1 file. [3.28s]
All three fencing mutations now have direct tests: expired lease, live ownership transfer, and exact-promotion transfer. Retry debt reset is fresh-projection-only and replay-safe. I’m running the complete gates on this revised tree.
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Fix same-Session conflicting recovery-row quarantine",
"status": "completed",
"priority": "high"
},
{
"content": "Add durable assistant-less supersession boundary",
"status": "completed",
...
bun test v1.3.14 (0d9b296a) 245 pass 0 fail 720 expect() calls Ran 245 tests across 8 files. [58.90s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[20:49:27.986] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/location-layer.test.ts:
(fail) LocationServiceMap > isolates location state while sharing location policy with catalog [7854.40ms]
^ this test timed out after 5000ms.
test/tool-webfetch.test.ts:
(fail) WebFetchTool registration > returns an error result when HTML-to-Markdown conversion throws [5580.31ms]
^ this test timed out after 5000ms.
test/effect/layer-node/node-build.test.ts:
[20:52:32.263] INFO (#210471): watcher backend {
directory: "/tmp/opencode-core-test-8mq7Mi",
platform: "linux",
backend: ...
The recovery gate remains clean at 245/245. Full Core hit two known five-second contention timeouts in unrelated Location and WebFetch tests; I’m isolating both with console traces, then rerunning the aggregate suite once rather than changing unrelated code.
bun test v1.3.14 (0d9b296a) test/location-layer.test.ts: (fail) LocationServiceMap > isolates location state while sharing location policy with catalog [5252.65ms] ^ this test timed out after 5000ms. 0 pass 3 filtered out 1 fail 4 expect() calls Ran 1 test across 1 file. [7.56s]
bun test v1.3.14 (0d9b296a)
test/tool-webfetch.test.ts:
189 | }),
190 | )
191 | const registry = yield* ToolRegistry.Service
192 | const url = "https://1.1.1.1/deep-html"
193 |
194 | expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({
^
error: expect(received).toEqual(expected)
{
- "type": "error",
- "value": "Unable to fetch https://1.1.1.1/deep-html",
+ "type": "text",
+ "value": "content",
}
- Expected - 2
+ Received + 2
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/tool-webfetch.test.ts:194:79
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate ...
The two full-Core failures are outside recovery: Location still exceeds five seconds alone, and WebFetch’s isolated conversion fixture returns "content" instead of its expected injected failure. I will report them as unrelated validation debt; the 245-test recovery gate and Core typecheck pass. I’m continuing with migration/contracts, affected package checks, and the exact artifact smoke.
bun test v1.3.14 (0d9b296a) 2 pass 0 fail 23 expect() calls Ran 2 tests across 1 file. [307.00ms] $ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-Q8O7Mw/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-Q8O7Mw/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-Q8O7Mw/full/20260916205727_schema/migration.sql 🚀
All five package typechecks, Schema manifest, migration consistency, and diff hygiene pass. Since runtime input projection changed, I’m rebuilding once more and repeating the isolated restart smoke before freezing v14.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0ac03cfda001hx1HuVhCEqOf8d dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v14-smoke.mjs
{
"initial": {
"port": 20821,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"constraints": [
{
"name": "session",
"sql": "CREATE TABLE `session` (\n `id` text PRIMARY KEY,\n `project_id` text NOT NULL,\n `workspace_id` text,\n `parent_id` text,\n `slug` text NOT NULL,\n `directory` text NOT NULL,\n `path` text,\n `title` text NOT NULL,\n `version` text NOT NULL,\n `share_url` text,\n ...
v14’s rebuilt artifact passes authenticated startup, schema checks, exactly-once recovery, and duplicate-free restart. I’m freezing the 33-file candidate and launching fresh immutable reviews; the two unrelated Core tests remain reported failures, not hidden.
5efd9d74f450e9f875790a07c860248cb526f7144a822cf6df51761237f9789b /tmp/opencode/session-recovery-final-v14.patch 3c26981f57d37d15549b76657c20a090355911e80ea94d43f957422dc3276796 packages/opencode/dist/server/opencode-server packages/core/schema.json | 587 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 59 packages/core/src/database/schema.gen.ts | 50 packages/core/src/session.ts | 9 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 177 + packages/core/src/session/input.ts | 395 +++ packages/core/src/session/message-updater.ts | 69 + packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 504 ++++ ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 51% (3394/6637) Updating files: 52% (3452/6637) Updating files: 53% (3518/6637) Updating files: 54% (3584/6637) Updating files: 55% (3651/6637) Updating files: 56% (3717/6637) Updating files: 57% (3784/6637) Updating files: 58% (3850/6637) Updating files: 59% (3916/6637) Updating files: 60% (3983/6637) Updating files: 61% (4049/6637) Updating files: 62% (4115/6637) Updating files: 63% (4182/6637) Updating files: 64% (4248/6637) Updating files: 65% (4315/6637) Updating files: 66% (4381/6637) Updating files: 67% (4447/6637) Updating files: 68% (4514/6637) Updating files: 69% (4580/6637) Updating files: 70% (4646/6637) Updating files: 71% (4713/6637) Updating files: 72% (4779/6637) Updating files: 73% (4846/6637) Updating files: 74% (4912/6637) Updating files: 75% (4978/6637) Updating files: 76% (5045/6637) Updating files: 77% (5111/6637) Updating files: 78% (5177/6637) Updating files: 79% ...
(no output)
(no output)
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Fix same-Session conflicting recovery-row quarantine",
"status": "completed",
"priority": "high"
},
{
"content": "Add durable assistant-less supersession boundary",
"status": "completed",
...
Frozen v14 applies cleanly: patch 5efd9d74..., SEA 3c26981f..., 33 files. Reviews will verify guarded promoteExact quarantine, live-successor mutation tests, and fresh-only retry-debt reset.
<task id="ses_f53f9365effeglXabLQ7OxJ7BF" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f53f8e997ffeKGCtnyYIOaDc0r" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Frozen v14 is under fresh correctness and security review.
5efd9d74f450e9f875790a07c860248cb526f7144a822cf6df51761237f9789b3c26981f57d37d15549b76657c20a090355911e80ea94d43f957422dc3276796PASS
ab9408c81cc9916177641ae8e9f08624fc27b5b9.5efd9d74f450e9f875790a07c860248cb526f7144a822cf6df51761237f9789b.3c26981f57d37d15549b76657c20a090355911e80ea94d43f957422dc3276796.None.
SessionInput.promoteExact starts an immediate transaction and invokes the supplied claim commit before changing recovery_error: packages/core/src/session/input.ts:591-617.updateExecution() guard: packages/core/src/session/runner/llm.ts:235-239.packages/core/src/session/execution/claim.ts:27-53.packages/core/test/session-projector.test.ts:1694-1780.packages/core/src/session/recovery.ts:121-138.packages/core/src/session/recovery.ts:236-255. ...Security review approved frozen v14 with MERGE. Correctness review remains pending; the merge gate stays blocked until it also approves the unchanged hashes.
PASS — frozen inputs
ab9408c81cc9916177641ae8e9f08624fc27b5b9.5efd9d74f450e9f875790a07c860248cb526f7144a822cf6df51761237f9789b.3c26981f57d37d15549b76657c20a090355911e80ea94d43f957422dc3276796.packages/core/src/session/execution/local.ts:116-118 preserves a cancelling claim only when the final exit cause still contains an interruption. During interruption, packages/core/src/session/runner/llm.ts:412-417 publishes tool and Step settlement before restoring the original interrupt cause.
If either publication defects, that defect replaces the saved interrupt before Effect.failCause(cause) runs. The local finalizer then sees a non-interrupt failure and clears the cancelling claim because that phase is neither unknown nor continue.
This permits the following sequence:
interrupt() marks the claim cancelling at execution/local.ts:132.execution/local.ts:116-118 clears the claim.execution/local.ts:136-147.A cancelling claim must always survive the runner exit. Only the coordinator’s explicit cleanup may clear it after all finalizers and settlement work finish. ...
Correctness rejected v14 on three concrete gaps: cancellation settlement defects can erase a cancelling claim, explicit resume lacks durable intent before promotion, and current client bindings were not regenerated. I’m reproducing the two lifecycle defects first, then I’ll apply the smallest ownership/intent changes, regenerate through packages/client, and add the missing recovery-scan index.
[
{
"content": "Preserve cancelling claims when interruption settlement defects",
"status": "in_progress",
"priority": "high"
},
{
"content": "Persist explicit resume intent before pending input promotion",
"status": "pending",
"priority": "high"
},
{
"content": "Add bounded recovery claim scan index",
"status": "pending",
"priority": "high"
},
{
"content": "Regenerate current client contracts through packages/client",
"status": "pending",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
70: const recovery = yield* db
71: .select({
72: continuationMessageID: SessionRecoveryTable.continuation_message_id,
73: })
74: .from(SessionRecoveryTable)
75: .where(
76: and(
77: eq(SessionRecoveryTable.session_id, sessionID),
78: eq(SessionRecoveryTable.wake_pending, true),
79: isNull(SessionRecoveryTable.recovery_error),
80: ),
81: )
82: .orderBy(asc(SessionRecoveryTable.time_created), asc(SessionRecoveryTable.execution_id))
83: .limit(1)
84: .get()
85: .pipe(Effect.orDie)
86: return yield* Effect.raceFirst(
87: input.run(claim, force, recovery?.continuationMessageID),
88: SessionExecutionClaim.update(db, claim).pipe(
89: Effect.repeat(Schedule.spaced(Duration.millis(leaseDuration / 3))),
90: ...
Found 56 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 495: error: { type: "unknown", message: "Tool execution interrupted" },
Line 577: const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"])
Line 611: ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }
Line 652: expect(userTexts(request)).toEqual(["Session execution interrupted by server restart", "continue"])
Line 787: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "fence publication" }), resume: false })
Line 793: .set({ id: "publication-successor", owner_id: "successor", expires_at: 60_000 })
Line 845: it.effect("never fails interrupted tools after ownership changes", () =>
Line 851: const assistantMessageID = SessionMessage.ID.make("msg_interrupted_tool_fence")
Line 863: callID: "call-interrupted-fence",
Line 870: callID: ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3840: expect(result).toMatchObject({ _tag: "Failure" })
3841: }),
3842: 15_000,
3843: )
3844:
3845: it.effect("durably fails blocked local tools when a provider turn is interrupted", () =>
3846: Effect.gen(function* () {
3847: yield* setup
3848: const session = yield* SessionV2.Service
3849: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false })
3850: executions.length = 0
3851: toolExecutionGate = yield* Deferred.make<void>()
3852: responseStream = Stream.concat(
3853: Stream.fromIterable([
3854: LLMEvent.stepStart({ index: 0 }),
3855: LLMEvent.toolCall({ id: "call-before-interrupt", name: "echo", input: { text: "blocked" } }),
3856: ]),
3857: Stream.never,
3858: )
3859:
3860: const run = yield* ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, desc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, Duration, Effect, Exit, Layer, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { SessionExecutionClaim } from "./claim"
13: import {
14: SESSION_EXECUTION_LEASE_MS,
15: SessionExecutionTable,
16: SessionMessageTable,
17: SessionRecoveryTable,
18: } from "../sql"
19:
20: /** Current-process routing for implicit-local Locations. ...
Found 3 matches
/home/byk/Code/opencode/packages/core/src/session.ts:
Line 169: readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
Line 427: resume: Effect.fn("V2Session.resume")(function* (sessionID) {
Line 429: yield* execution.resume(sessionID)
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path>
<type>file</type>
<content>
410: return
411: yield* events.publish(SessionEvent.ModelSwitched, {
412: sessionID: input.sessionID,
413: messageID: SessionMessage.ID.create(),
414: timestamp: yield* DateTime.now,
415: model: input.model,
416: })
417: }),
418: compact: Effect.fn("V2Session.compact")(function* (input) {
419: yield* result.get(input.sessionID)
420: return yield* new OperationUnavailableError({ operation: "compact" })
421: }),
422: wait: Effect.fn("V2Session.wait")(function* (sessionID) {
423: yield* result.get(sessionID)
424: return yield* new OperationUnavailableError({ operation: "wait" })
425: }),
426: active: execution.active,
427: resume: Effect.fn("V2Session.resume")(function* (sessionID) {
428: yield* result.get(sessionID)
429: yield* ...
Found 18 matches
/home/byk/Code/opencode/packages/core/src/session/input.ts:
Line 59: readonly requestExecution: boolean
Line 66: const shouldWake = input.requestExecution ? yield* requestExecution(db, events, input) : false
Line 77: requestExecution: input.requestExecution,
Line 93: shouldWake: input.requestExecution && shouldWake,
Line 102: if (!input.requestExecution) return Effect.succeed({ admitted: stored, shouldWake: false })
Line 103: return requestExecution(db, events, input).pipe(
Line 112: const requestExecution = Effect.fn("SessionInput.requestExecution")(function* (
Line 122: wakePending: SessionInputTable.wake_pending,
Line 146: wakePending: SessionInputTable.wake_pending,
Line 173: wakePending: SessionInputTable.wake_pending,
Line 191: readonly requestExecution: boolean
Line 210: wake_pending: input.requestExecution,
Line 227: .set({ ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path>
<type>file</type>
<content>
35: })
36:
37: export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
38: const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie)
39: return row === undefined ? undefined : fromRow(row)
40: })
41:
42: export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
43: id: SessionMessage.ID,
44: }) {}
45:
46: export class RecoveryConflict extends Schema.TaggedErrorClass<RecoveryConflict>()("SessionInput.RecoveryConflict", {
47: executionID: Schema.String,
48: reason: Schema.String,
49: }) {}
50:
51: export const admit = Effect.fn("SessionInput.admit")(function* (
52: db: DatabaseService,
53: events: EventV2.Interface,
54: input: {
55: readonly id: SessionMessage.ID
56: readonly sessionID: ...
Found 20 matches
/home/byk/Code/opencode/packages/core/src/session/input.ts:
Line 504: export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
Line 539: export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 243: promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff, () => updateExecution())
Line 245: promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id, () => updateExecution()))
Line 246: promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff, () => updateExecution())
Line 545: readonly force: boolean
Line 558: if (!recovering && !input.force && !hasSteer && !hasQueue) return
Line 567: let shouldRun = recovering || input.force || hasSteer || ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, exists, gt, isNull, lte, or, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { SessionInput } from "./input"
13: import { continuation } from "./recovery-id"
14: import { SessionSchema } from "./schema"
15: import {
16: MAX_WAKE_ATTEMPTS,
17: SESSION_EXECUTION_LEASE_MS,
18: SessionExecutionTable,
19: SessionInputTable,
20: SessionRecoveryTable,
21: SessionMessageTable,
22: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
530: return yield* runAfterOverflowCompaction(
531: sessionID,
532: executionID,
533: undefined,
534: defect.transition.step,
535: ownerID,
536: )
537: return yield* runTurn(sessionID, executionID, undefined, defect.transition.step, ownerID)
538: }),
539: ),
540: )
541: })
542:
543: const run = Effect.fn("SessionRunner.run")(function* (input: {
544: readonly sessionID: SessionSchema.ID
545: readonly force: boolean
546: readonly executionID: string
547: readonly ownerID: string
548: readonly recoveryInputID?: SessionMessage.ID
549: }) {
550: const claim = {
551: sessionID: input.sessionID,
552: executionID: input.executionID,
553: ownerID: input.ownerID,
554: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
215: const continuation = execution?.phase === "continue"
216: yield* updateExecution(continuation ? {} : { phase: "ready", assistant_message_id: null })
217: const agent = yield* agents.select(session.agent)
218: const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id, claim)
219: const toolFibers = new Set<Fiber.Fiber<void, ToolOutputStore.Error>>()
220: const interruptToolFibers = () => Fiber.interruptAll(toolFibers)
221: const awaitToolFibers = Effect.fnUntraced(function* () {
222: const settled = yield* Effect.forEach(toolFibers, Fiber.join, {
223: concurrency: "unbounded",
224: discard: true,
225: }).pipe(Effect.exit)
226: if (Exit.isFailure(settled)) yield* interruptToolFibers()
227: return yield* settled
228: })
229: let ...
<path>/home/byk/Code/opencode/packages/core/src/session/input.ts</path> <type>file</type> <content> 390: Option.isNone(prompt) || 391: JSON.stringify(encodePrompt(prompt.value)) !== JSON.stringify(encodePrompt(continuePrompt)) 392: ? "Invalid recovery continuation input" 393: : recovery.assistant_message_id !== null && assistant?.sessionID !== recovery.session_id 394: ? "Invalid recovery assistant relationship" 395: : (phaseValue === "continue" && recovery.assistant_message_id !== null) || 396: (recovery.assistant_message_id !== null && assistant?.type !== "assistant") || 397: (recovery.assistant_message_id !== null && latestAssistant?.id !== recovery.assistant_message_id) || 398: (latestAssistant !== undefined && latestAssistant.seq > input.admitted_seq) || 399: (phaseValue !== "continue" && recovery.assistant_message_id === null && ...
The resume gap can use the existing durable PromptExecutionRequested contract: before execution.resume, arm one oldest unpromoted ordinary input if none has durable wake intent. One marker is enough because execution drains the Session’s pending work; recovery-owned continuations remain excluded. A cancelling claim will now survive every runner failure cause and only explicit coordinator cleanup may delete it.
Success. Updated the following files: M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session.ts
Found 3 matches /home/byk/Code/opencode/packages/core/src/session.ts: Line 187: const database = yield* Database.Service Line 189: const events = yield* EventV2.Service Line 439: Effect.provideService(Database.Service, database),
<path>/home/byk/Code/opencode/packages/core/src/session.ts</path>
<type>file</type>
<content>
180: }
181:
182: export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
183:
184: const layer = Layer.effect(
185: Service,
186: Effect.gen(function* () {
187: const database = yield* Database.Service
188: const db = database.db
189: const events = yield* EventV2.Service
190: const projects = yield* ProjectV2.Service
191: const execution = yield* SessionExecution.Service
192: const store = yield* SessionStore.Service
193: const locations = yield* LocationServiceMap.Service
194: const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
195: const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
196: const decode = (row: typeof SessionMessageTable.$inferSelect) =>
197: decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
(Showing lines 180-197 of 488. ...
Found 40 matches
/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts:
Line 32: resume: (sessionID) =>
Line 132: resume: () => Effect.void,
Line 153: yield* session.resume(sessionID)
Line 189: resume: false,
Line 214: resume: false,
Line 233: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
Line 234: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
Line 254: it.effect("resumes through a recorded message without appending another prompt", () =>
Line 261: resume: false,
Line 266: yield* session.resume(sessionID)
Line 279: const input = { sessionID, prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false }
Line 298: resume: false,
Line 318: resume: false,
Line 323: const retried = yield* session.prompt({ ...input, resume: true })
Line 347: ...
<path>/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts</path>
<type>file</type>
<content>
245: Array.from(
246: yield* session
247: .events({ sessionID, after: streamed[0]!.durable?.seq })
248: .pipe(Stream.take(1), Stream.runCollect),
249: ).map((event) => [event.durable?.seq, event.type]),
250: ).toEqual([[1, "session.next.prompt.admitted"]])
251: }),
252: )
253:
254: it.effect("resumes through a recorded message without appending another prompt", () =>
255: Effect.gen(function* () {
256: yield* setup
257: const session = yield* SessionV2.Service
258: const message = yield* session.prompt({
259: sessionID,
260: prompt: Prompt.make({ text: "Fix the failing tests" }),
261: resume: false,
262: })
263:
264: executionCalls.length = 0
265: wakeCalls.length = 0
266: yield* session.resume(sessionID)
267:
268: expect(yield* ...
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 1 pass 28 filtered out 0 fail 4 expect() calls Ran 1 test across 1 file. [1314.00ms]
Found 18 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 186: expect(yield* db.select().from(SessionRecoveryTable).get()).toMatchObject({ wake_pending: false })
Line 208: expect(yield* db.select().from(SessionRecoveryTable).get()).toMatchObject({ wake_pending: false })
Line 252: wake_pending: true,
Line 581: wake_pending: true,
Line 600: wake_pending: true,
Line 1051: UPDATE session_recovery SET wake_pending = false WHERE execution_id = OLD.execution_id;
Line 1060: UPDATE session_input SET wake_pending = false WHERE session_id = OLD.id;
Line 1078: wake_pending: false,
Line 1083: ).toMatchObject({ wake_pending: false })
Line 1181: expect.objectContaining({ wake_pending: true, promoted_seq: null }),
Line 1186: it.effect("redrives ordinary input without recovering a claim that crashed before promotion", () =>
Line 1240: ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1170: let wakes = 0
1171: const execution = SessionExecution.Service.of({
1172: ...recoveryExecution,
1173: wake: () => Effect.sync(() => wakes++).pipe(Effect.asVoid),
1174: })
1175:
1176: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution))
1177: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution))
1178:
1179: expect(wakes).toBe(2)
1180: expect(yield* db.select().from(SessionInputTable).all()).toEqual([
1181: expect.objectContaining({ wake_pending: true, promoted_seq: null }),
1182: ])
1183: }),
1184: )
1185:
1186: it.effect("redrives ordinary input without recovering a claim that crashed before promotion", () =>
1187: Effect.gen(function* () {
1188: const { db } = yield* Database.Service
1189: ...
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
20: exit?: Exit.Exit<void, E>
21: pendingWake: boolean
22: stopping: boolean
23: }
24:
25: export const make = <Key, E>(options: {
26: readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
27: }): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
28: Effect.gen(function* () {
29: const active = new Map<Key, Entry<E>>()
30: const fork = yield* FiberSet.makeRuntime<never, void, never>()
31:
32: const makeEntry = (): Entry<E> => ({
33: done: Deferred.makeUnsafe<void, E>(),
34: pendingWake: false,
35: stopping: false,
36: })
37:
38: const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
39: const ready = Deferred.makeUnsafe<void>()
40: const owner = fork(
41: (successor ? Effect.yieldNow : Deferred.await(ready)).pipe(
42: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
392: ))
393: )
394: return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
395: if (overflowFailure) yield* publish(overflowFailure)
396: const llmFailure = failure instanceof LLMError ? failure : undefined
397: if (llmFailure && !publisher.hasProviderError()) {
398: yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
399: yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
400: }
401: if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* interruptToolFibers()
402: const settled = yield* restore(awaitToolFibers()).pipe(Effect.exit)
403: if (settled._tag === "Failure" && isUserDeclined(settled.cause)) {
404: yield* ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
220:
221: const failAssistant = Effect.fnUntraced(function* (message: string) {
222: if (assistantFailed) return
223: yield* flush()
224: const assistantMessageID = yield* startAssistant()
225: yield* publishEvent(
226: SessionEvent.Step.Failed,
227: {
228: sessionID: input.sessionID,
229: timestamp: yield* timestamp,
230: assistantMessageID,
231: error: { type: "unknown", message },
232: },
233: {
234: commit: input.eventCommit,
235: }.commit,
236: )
237: assistantActive = false
238: assistantFailed = true
239: })
240:
241: const interruptAssistant = Effect.fnUntraced(function* (
242: message: string,
243: commit: () => Effect.Effect<void> = input.eventCommit,
244: ) {
245: if (assistantFailed) return
246: yield* flush()
247: const ...
Found 1 matches /home/byk/Code/opencode/packages/core/src/session/runner/llm.ts: Line 301: const withPublication = Semaphore.makeUnsafe(1).withPermit
Found 2 matches /home/byk/Code/opencode/packages/core/src/session/execution/local.ts: Line 131: if (!claim) return coordinator.interrupt(sessionID) Line 135: coordinator.interrupt(
The runner needs a durable settlement handshake with coordinator cleanup. Step.Interrupted will atomically move cancelling to safe; cleanup clears only safe. If tool or Step publication defects, the phase remains cancelling, cleanup fails, and startup quarantines the retained claim instead of losing recovery state.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts M packages/core/src/session/execution/local.ts
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
105: .update(SessionExecutionTable)
106: .set({ recovery_error: `Invalid execution phase: ${String(claim.phase)}` })
107: .where(
108: and(
109: eq(SessionExecutionTable.id, claim.id),
110: eq(SessionExecutionTable.session_id, claim.session_id),
111: eq(SessionExecutionTable.owner_id, ownerID),
112: gt(SessionExecutionTable.expires_at, quarantinedAt),
113: ),
114: )
115: .returning({ id: SessionExecutionTable.id })
116: .get()
117: .pipe(Effect.orDie)
118: if (!quarantined) return yield* Effect.die(`Recovery execution claim changed: ${claim.id}`)
119: return
120: }
121: if (phase.value === "cancelling") {
122: const quarantinedAt = yield* ...
<path>/home/byk/Code/opencode/packages/core/test/session-prompt.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
3: import { asc, eq } from "drizzle-orm"
4: import { Database } from "@opencode-ai/core/database/database"
5: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
6: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
7: import { EventV2 } from "@opencode-ai/core/event"
8: import { EventTable } from "@opencode-ai/core/event/sql"
9: import { SessionEvent } from "@opencode-ai/core/session/event"
10: import { Project } from "@opencode-ai/core/project"
11: import { ProjectTable } from "@opencode-ai/core/project/sql"
12: import { AbsolutePath } from "@opencode-ai/core/schema"
13: import { SessionV2 } from "@opencode-ai/core/session"
14: import { Prompt } from "@opencode-ai/core/session/prompt"
15: import { SessionMessage } from ...
I’m encoding both reviewed crash windows directly: a failed interrupted-tool publication must leave a durable cancelling claim, and a resume:false input explicitly resumed before a crash must carry durable wake intent so startup redrives that exact input without synthesizing "continue".
Success. Updated the following files: M packages/core/test/session-runner.test.ts M packages/core/test/session-projector.test.ts
$ tsgo --noEmit src/session/execution/local.ts(139,19): error TS18048: 'current' is possibly 'undefined'.
Success. Updated the following files: M packages/core/src/session/execution/local.ts
$ tsgo --noEmit src/session/execution/local.ts(139,28): error TS2339: Property 'phase' does not exist on type 'never'.
<path>/home/byk/Code/opencode/packages/core/src/session/execution/claim.ts</path>
<type>file</type>
<content>
20:
21: export class Lost extends Error {
22: constructor(readonly claim: Claim) {
23: super(`Session execution claim lost: ${claim.executionID}`)
24: }
25: }
26:
27: const where = (claim: Claim, now: number) =>
28: and(
29: eq(SessionExecutionTable.session_id, claim.sessionID),
30: eq(SessionExecutionTable.id, claim.executionID),
31: eq(SessionExecutionTable.owner_id, claim.ownerID),
32: gt(SessionExecutionTable.expires_at, now),
33: )
34:
35: /**
36: * Lease timestamps use node wall clocks. The lease interval must remain larger than the deployment's
37: * maximum clock skew plus one renewal interval. Execution IDs fence a node immediately after takeover;
38: * an expired claim never renews itself.
39: */
40: export const update = Effect.fn("SessionExecutionClaim.update")(function* (
41: db: DatabaseService,
42: claim: Claim,
43: ...
Found 9 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 148: { commit: () => SessionExecutionClaim.update(db, claim) },
Line 202: SessionExecutionClaim.update(db, claim, values)
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 1843: () => SessionExecutionClaim.update(db, stale),
Line 2536: SessionExecutionClaim.update(db, stale),
Line 2773: SessionExecutionClaim.update(db, {
/home/byk/Code/opencode/packages/core/src/session/execution/local.ts:
Line 88: SessionExecutionClaim.update(db, claim).pipe(
Line 133: yield* SessionExecutionClaim.update(db, claim, { phase: "cancelling" })
Line 137: SessionExecutionClaim.update(db, claim).pipe(
Line 157: SessionExecutionClaim.update(db, claim).pipe(
Success. Updated the following files: M packages/core/src/session/execution/claim.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 2 pass 162 filtered out 0 fail 10 expect() calls Ran 2 tests across 2 files. [1.52s]
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
215: sql`${table.wake_attempts} >= 0 AND ${table.wake_attempts} <= ${sql.raw(String(MAX_WAKE_ATTEMPTS))}`,
216: ),
217: ],
218: )
219:
220: export const SessionExecutionTable = sqliteTable(
221: "session_execution",
222: {
223: id: text().notNull(),
224: session_id: text()
225: .$type<SessionSchema.ID>()
226: .notNull()
227: .references(() => SessionTable.id, { onDelete: "cascade" }),
228: owner_id: text().notNull(),
229: assistant_message_id: text().$type<SessionMessage.ID>(),
230: phase: text().$type<"ready" | "safe" | "unknown" | "continue" | "cancelling">().notNull(),
231: assistant_seq: integer().notNull().default(-1),
232: time_created: integer().notNull(),
233: expires_at: integer().notNull(),
234: recovery_error: text(),
235: },
236: (table) => [
237: primaryKey({ name: "session_execution_pk", columns: ...
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts:
CREATE UNIQUE INDEX `session_execution_session_idx` ON `session_execution` (`session_id`);
CREATE INDEX `session_execution_owner_idx` ON `session_execution` (`owner_id`);
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts</path> <type>file</type> <content> 35: \`wake_pending\` integer DEFAULT true NOT NULL, 36: \`wake_attempts\` integer DEFAULT 0 NOT NULL, 37: \`recovery_error\` text, 38: \`time_created\` integer NOT NULL, 39: CONSTRAINT \`session_recovery_pk\` PRIMARY KEY(\`execution_id\`), 40: CONSTRAINT \`session_recovery_wake_attempts_check\` CHECK(\`wake_attempts\` >= 0 AND \`wake_attempts\` <= 9007199254740991), 41: CONSTRAINT \`fk_session_recovery_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE, 42: CONSTRAINT \`fk_session_recovery_assistant_message_id_session_message_id_fk\` FOREIGN KEY (\`assistant_message_id\`) REFERENCES \`session_message\`(\`id\`) ON DELETE CASCADE, 43: CONSTRAINT ...
Success. Updated the following files: M packages/core/src/session/sql.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts
The two new lifecycle regressions pass. I’ve also added the covering claim-scan index; next I’m regenerating canonical Core schema artifacts and both current client outputs through the repository scripts, never by hand.
Reading config file '/tmp/opencode-core-migration-NqJEve/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-NqJEve/incremental/20260916213316_recovery-scan-index/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-NqJEve/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-NqJEve/full/20260916213319_schema/migration.sql 🚀
/home/byk/Code/opencode/packages/core/src/database/migration/20260916213316_recovery-scan-index.ts
<path>/home/byk/Code/opencode/packages/core/src/database/migration/20260916213316_recovery-scan-index.ts</path>
<type>file</type>
<content>
1: import { Effect } from "effect"
2: import type { DatabaseMigration } from "../migration"
3:
4: export default {
5: id: "20260916213316_recovery-scan-index",
6: up(tx) {
7: return Effect.gen(function* () {
8: yield* tx.run(
9: `CREATE INDEX \`session_execution_recovery_scan_idx\` ON \`session_execution\` (\`recovery_error\`,\`expires_at\`,\`time_created\`,\`id\`);`,
10: )
11: })
12: },
13: } satisfies DatabaseMigration.Migration
(End of file - total 13 lines)
</content>
Success. Updated the following files: D packages/core/src/database/migration/20260916213316_recovery-scan-index.ts
<path>/home/byk/Code/opencode/packages/core/src/database/migration.gen.ts</path>
<type>file</type>
<content>
35: import("./migration/20260605003541_add_session_context_snapshot"),
36: import("./migration/20260605042240_add_context_epoch_agent"),
37: import("./migration/20260611035744_credential"),
38: import("./migration/20260611192811_lush_chimera"),
39: import("./migration/20260612174303_project_dir_strategy"),
40: import("./migration/20260622142730_simplify_session_context_epoch"),
41: import("./migration/20260622170816_reset_v2_session_state"),
42: import("./migration/20260622202450_simplify_session_input"),
43: import("./migration/20260914170650_session-recovery"),
44: import("./migration/20260916213316_recovery-scan-index"),
45: ])
46: ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
(End of file - total 46 lines)
</content>
Success. Updated the following files: M packages/core/src/database/migration.gen.ts
$ bun run script/build.ts
Reading config file '/tmp/opencode-core-migration-jGJT8i/incremental.config.ts'
No schema changes, nothing to migrate 😴
Reading config file '/tmp/opencode-core-migration-jGJT8i/full.config.ts'
[✓] Your SQL migration ➜ /tmp/opencode-core-migration-jGJT8i/full/20260916213446_schema/migration.sql 🚀
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner-recorded.test.ts:
172 | .select({ type: EventTable.type })
173 | .from(EventTable)
174 | .where(eq(EventTable.aggregate_id, sessionID))
175 | .orderBy(EventTable.seq)
176 | .all()).map((event) => event.type),
177 | ).toEqual([
^
error: expect(received).toEqual(expected)
@@ -2,3 +2,3 @@
"session.next.prompt.admitted.1",
+ "session.next.prompt.execution_requested.1",
"session.next.prompted.1",
- Expected - 0
+ Received + 1
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner-recorded.test.ts:177:9
at ...
The broad suite exposed that safe is the wrong cancellation handshake: normal finalization can clear it before coordinator cleanup, while non-provider interruption paths never set it. I’m replacing it with a distinct durable cancelled phase. The runner writes cancelled only after interruption settlement commits; local cleanup retains both cancellation phases until coordinator cleanup, which clears cancelled and rejects unresolved cancelling.
Found 29 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 29: import { SessionCompaction } from "../compaction"
Line 82: * - [ ] Continue for compaction or another continuation condition when required.
Line 87: * - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
Line 113: const compaction = SessionCompaction.make({
Line 163: // Automatic compaction completed; rebuild the request from compacted history.
Line 164: | { readonly _tag: "ContinueAfterCompaction"; readonly step: number }
Line 165: // Overflow compaction completed; rebuild once through the path without overflow recovery. ...
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts:
CONSTRAINT `session_execution_phase_check` CHECK(`phase` IN ('ready', 'safe', 'unknown', 'continue', 'cancelling')),
Found 1 matches
/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts:
Line 24: CONSTRAINT \`session_execution_phase_check\` CHECK(\`phase\` IN ('ready', 'safe', 'unknown', 'continue', 'cancelling')),
Success. Updated the following files: M packages/core/src/session/sql.ts M packages/core/src/session/recovery.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/runner/llm.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts
Reading config file '/tmp/opencode-core-migration-t10wwb/incremental.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-t10wwb/incremental/20260916213728_cancellation-settled/migration.sql 🚀 Reading config file '/tmp/opencode-core-migration-t10wwb/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-t10wwb/full/20260916213730_schema/migration.sql 🚀
Success. Updated the following files: D packages/core/src/database/migration/20260916213728_cancellation-settled.ts M packages/core/src/database/migration.gen.ts
Success. Updated the following files: M packages/core/test/session-runner-recorded.test.ts M packages/core/test/session-runner.test.ts
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
70: Effect.flatMap((row) =>
71: row === undefined
72: ? Effect.die("Execution claim not found")
73: : Deferred.succeed(observed, row.assistantSeq),
74: ),
75: ),
76: })
77:
78: yield* execution.resume(sessionID)
79:
80: expect(yield* Deferred.await(observed)).toBe(7)
81: }),
82: )
83:
84: it.effect("preserves the claim when its scope closes", () =>
85: Effect.gen(function* () {
86: yield* insertSession
87: const started = Deferred.makeUnsafe<void>()
88: const child = yield* Scope.make()
89: const execution = yield* SessionExecutionLocal.make({
90: run: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
91: }).pipe(Effect.provideService(Scope.Scope, child))
92: yield* ...
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
232: it.effect("settles cancellation before starting a pending successor", () =>
233: Effect.gen(function* () {
234: yield* insertSession
235: const firstStarted = Deferred.makeUnsafe<void>()
236: const finalizerStarted = Deferred.makeUnsafe<void>()
237: const releaseFinalizer = Deferred.makeUnsafe<void>()
238: const successorStarted = Deferred.makeUnsafe<void>()
239: let runs = 0
240: const execution = yield* SessionExecutionLocal.make({
241: run: () =>
242: Effect.sync(() => ++runs).pipe(
243: Effect.flatMap((run) =>
244: run === 1
245: ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
246: : Deferred.succeed(successorStarted, undefined),
247: ),
248: Effect.ensuring(
249: ...
The integration failures split into two expected cases. Generic or pre-assistant cancellation has nothing durable to settle and may clear directly; active Steps require the runner’s settlement transition. Also, direct runner interruption must remain unknown unless the outer execution adapter first marked it cancelling. I’m tightening the transition to cancelling -> cancelled only.
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
const updateExecution = Effect.fnUntraced((values: ExecutionUpdate = {}) =>
SessionExecutionClaim.update(db, claim, values)
)
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
188:
189: const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
190: sessionID: SessionSchema.ID,
191: executionID: string,
192: promotion: Promotion | undefined,
193: step: number,
194: ownerID: string,
195: recoverOverflow?: typeof compaction.compactAfterOverflow,
196: ) {
197: const session = yield* getSession(sessionID)
198: if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
199: return yield* Effect.interrupt
200: const claim = { sessionID, executionID, ownerID }
201: const updateExecution = (values: SessionExecutionClaim.Values = {}) =>
202: SessionExecutionClaim.update(db, claim, values)
203: const execution = yield* db
204: .select({ phase: SessionExecutionTable.phase })
205: ...
Success. Updated the following files: M packages/core/src/session/execution/claim.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/runner/llm.ts
Success. Updated the following files: M packages/core/test/session-runner.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 14 pass 106 filtered out 0 fail 45 expect() calls Ran 14 tests across 3 files. [2.79s]
The cancelled handshake now passes all 14 affected interruption paths, including overflow, queued/steered work, partial projections, recorded transport, and the publication-defect regression. The explicit-resume durable marker also passes its crash/redrive regression. I’m checking generated client diffs and then running complete validation.
Found 8 matches /home/byk/Code/opencode/packages/client/src/generated/types.ts: Line 778: readonly requestExecution?: boolean Line 784: readonly type: "session.next.prompt.execution_requested" Line 895: readonly type: "session.next.step.interrupted" Line 908: readonly type: "session.next.step.recovered" Line 1272: readonly requestExecution?: boolean Line 1278: readonly type: "session.next.prompt.execution_requested" Line 1389: readonly type: "session.next.step.interrupted" Line 1402: readonly type: "session.next.step.recovered"
No files found
<path>/home/byk/Code/opencode/packages/client/src/generated-effect</path> <type>directory</type> <entries> .httpapi-codegen.json client-error.ts client.ts index.ts (4 entries) </entries>
No files found
bun test v1.3.14 (0d9b296a) 247 pass 0 fail 730 expect() calls Ran 247 tests across 8 files. [27.49s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[21:42:39.804] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
1184 pass
0 fail
3319 expect() calls
Ran 1184 tests across 145 files. [94.07s]
[21:44:04.624] INFO (#211118): watcher backend {
directory: "/tmp/opencode-core-test-HQAZJN",
platform: "linux",
backend: "inotify",
}
[21:44:04.629] INFO (#211227): project copy refresh started {
projectID: "global",
}
[21:44:04.630] INFO (#211227): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[21:44:04.665] INFO (#211118): booting location services {
directory: ...
All Core tests now pass on the revised tree: 1,184/1,184, with 247/247 in the recovery gate. I’m running all package/type/schema/migration checks, then one final artifact smoke and freeze.
bun test v1.3.14 (0d9b296a) 2 pass 0 fail 23 expect() calls Ran 2 tests across 1 file. [266.00ms] $ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
$ tsgo --noEmit
Reading config file '/tmp/opencode-core-migration-92u6iM/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-92u6iM/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-92u6iM/full/20260916214848_schema/migration.sql 🚀
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0ac32c1de0016LntRIXN00nQNA dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v15-smoke.mjs
{
"initial": {
"port": 7363,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
}
],
"constraints": [
{
"name": "session",
"sql": "CREATE TABLE `session` (\n `id` text PRIMARY KEY,\n `project_id` text NOT NULL,\n `workspace_id` text,\n `parent_id` text,\n `slug` text NOT NULL,\n `directory` text NOT NULL,\n `path` text,\n `title` text NOT NULL,\n `version` text NOT NULL,\n `share_url` text,\n ...
v15 passes the complete Core suite, all six affected package typechecks, generated client verification, migration checks, and the four-launch isolated recovery smoke. I’m freezing the expanded package set, including generated current-client outputs, and will verify exact clean application before replacement reviews.
M .lore.md M AGENTS.md M packages/client/src/generated/types.ts M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M ...
509563ba0cd17246a1d223ac7a6f3fabee1a49892c61b215c3fd7ca4744b6c77 /tmp/opencode/session-recovery-final-v15.patch 83a833aa93cc185825feffe107fba1bd8e3789d1ff5fe5f8cda7df1962aa078d packages/opencode/dist/server/opencode-server packages/client/src/generated/types.ts | 72 + packages/core/schema.json | 615 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session.ts | 10 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 184 + packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts ...
Preparing worktree (detached HEAD ab9408c81c) HEAD is now at ab9408c81c feat(app): adapt vertical tab density Updating files: 81% (5435/6637) Updating files: 82% (5443/6637) Updating files: 83% (5509/6637) Updating files: 84% (5576/6637) Updating files: 85% (5642/6637) Updating files: 86% (5708/6637) Updating files: 87% (5775/6637) Updating files: 88% (5841/6637) Updating files: 89% (5907/6637) Updating files: 90% (5974/6637) Updating files: 91% (6040/6637) Updating files: 92% (6107/6637) Updating files: 93% (6173/6637) Updating files: 94% (6239/6637) Updating files: 95% (6306/6637) Updating files: 96% (6372/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done.
(no output)
(no output)
[
{
"content": "Preserve cancelling claims when interruption settlement defects",
"status": "completed",
"priority": "high"
},
{
"content": "Persist explicit resume intent before pending input promotion",
"status": "completed",
"priority": "high"
},
{
"content": "Add bounded recovery claim scan index",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate current client contracts through packages/client",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "in_progress",
"priority": "high"
}
]
Frozen v15 applies cleanly: patch 509563ba..., SEA 83a833aa..., 34 files including current generated client types. The full Core suite is green at 1,184/1,184. I’m launching replacement immutable correctness and security reviews focused on the cancellation handshake and explicit-resume crash window.
<task id="ses_f53c8bce3ffe2dMXTOZmONz7Oh" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f53c852b9ffezOy5U3hJplVqxU" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Frozen v15 is under fresh correctness and security review.
509563ba0cd17246a1d223ac7a6f3fabee1a49892c61b215c3fd7ca4744b6c7783a833aa93cc185825feffe107fba1bd8e3789d1ff5fe5f8cda7df1962aa078dab9408c81cc9916177641ae8e9f08624fc27b5b9.509563ba0cd17246a1d223ac7a6f3fabee1a49892c61b215c3fd7ca4744b6c77.83a833aa93cc185825feffe107fba1bd8e3789d1ff5fe5f8cda7df1962aa078d.packages/core/src/session/execution/local.ts:133-135 first writes phase: "cancelling" and only then enters coordinator.interrupt(...). During that gap, the still-running owner can perform another claim update.
packages/core/src/session/execution/claim.ts:47-49 guards identity, ownership, and expiry, but it does not prevent a same-owner update from replacing cancelling or cancelled.
Several runner transitions can therefore erase the cancellation marker:
packages/core/src/session/runner/llm.ts:232-233 changes any non-continue phase, including cancelling, back to ready.packages/core/src/session/runner/llm.ts:490-494 can replace it with safe or continue.packages/core/src/session/runner/llm.ts:216-218 treats a phase that was overwritten after cancellation as an ordinary renewal instead of rejecting settlement.This creates two unsafe outcomes:
The security finding is valid. I’m enforcing cancellation as a monotonic claim state inside the shared claim primitive: ordinary runner phase transitions cannot replace cancelling or cancelled, and cancelled can only follow cancelling. This closes every Step, compaction, and tool path at one boundary rather than patching individual callers.
<path>/home/byk/Code/opencode/packages/core/src/session/execution/claim.ts</path>
<type>file</type>
<content>
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<
18: Pick<typeof SessionExecutionTable.$inferInsert, "assistant_message_id" | "assistant_seq" | "phase">
19: >
20:
21: export class Lost extends Error {
22: constructor(readonly claim: Claim) {
23: super(`Session execution claim lost: ${claim.executionID}`)
24: }
25: }
26:
27: const where = (claim: Claim, ...
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
3: import { TestClock } from "effect/testing"
4: import { eq, sql } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { Project } from "@opencode-ai/core/project"
7: import { ProjectTable } from "@opencode-ai/core/project/sql"
8: import { AbsolutePath } from "@opencode-ai/core/schema"
9: import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
10: import { SessionSchema } from "@opencode-ai/core/session/schema"
11: import { SessionExecutionTable, SessionTable } from "@opencode-ai/core/session/sql"
12: import { testEffect } from "./lib/effect"
13:
14: const sessionID = SessionSchema.ID.make("ses_execution_local")
15: const it = testEffect(Database.layerFromPath(":memory:"))
16:
17: const ...
Success. Updated the following files: M packages/core/src/session/execution/claim.ts M packages/core/test/session-execution-local.test.ts
continue. the server restarted so you need to nudge any background agents for them to resume
I’m resuming the interrupted correctness reviewer against frozen v15 for continuity, but its verdict can only inform the next candidate because source changed after the security rejection. In parallel, I’m validating the monotonic cancellation-state fix and its race regression.
<task id="ses_f53c8bce3ffe2dMXTOZmONz7Oh" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
$ tsgo --noEmit test/session-execution-local.test.ts(291,75): error TS2304: Cannot find name 'assistantMessageID'.
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
255: Effect.ensuring(
256: Effect.suspend(() =>
257: runs === 1
258: ? Deferred.succeed(finalizerStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFinalizer)))
259: : Effect.void,
260: ),
261: ),
262: ),
263: })
264: yield* execution.wake(sessionID)
265: yield* Deferred.await(firstStarted)
266: const interrupted = yield* execution.interrupt(sessionID).pipe(Effect.forkChild)
267: yield* Deferred.await(finalizerStarted)
268: yield* execution.wake(sessionID)
269: yield* TestClock.adjust("40 seconds")
270: yield* Effect.yieldNow
271: const expiry = (yield* claims)[0]?.expires_at
272: yield* Deferred.succeed(releaseFinalizer, undefined)
273: yield* Fiber.join(interrupted)
274: ...
Found 3 matches
/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts:
Line 220: const assistantMessageID = SessionMessage.ID.make("msg_cancellation_race")
Line 223: VALUES (${assistantMessageID}, ${sessionID}, 'assistant', 0, 0, 0, '{}')
Line 291: SessionExecutionClaim.update(db, claim, { assistant_message_id: assistantMessageID }).pipe(
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
208: const second = (yield* claims)[0]?.expires_at
209:
210: expect(initial).toBe(30_000)
211: expect(first).toBe(40_000)
212: expect(second).toBe(50_000)
213: }),
214: )
215:
216: it.effect("never revives an expired claim on heartbeat", () =>
217: Effect.gen(function* () {
218: yield* insertSession
219: const { db } = yield* Database.Service
220: const assistantMessageID = SessionMessage.ID.make("msg_cancellation_race")
221: yield* db.run(sql`
222: INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data)
223: VALUES (${assistantMessageID}, ${sessionID}, 'assistant', 0, 0, 0, '{}')
224: `)
225: const started = Deferred.makeUnsafe<void>()
226: const execution = yield* SessionExecutionLocal.make({
227: run: () => Deferred.succeed(started, ...
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
Found 1 matches
/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts:
Line 282: const assistantMessageID = SessionMessage.ID.make("msg_cancellation_race")
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 4 pass 8 filtered out 0 fail 9 expect() calls Ran 4 tests across 1 file. [834.00ms]
The monotonic guard now blocks every runner phase or assistant mutation once cancellation starts, while heartbeat-only renewals may preserve the cancellation phase through finalizers. The deterministic race passes: a runner’s attempted continue settlement cannot overwrite cancelling, and coordinator cleanup fails closed with the claim retained.
bun test v1.3.14 (0d9b296a) 248 pass 0 fail 732 expect() calls Ran 248 tests across 8 files. [26.88s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[22:32:40.850] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
[22:33:50.200] INFO (#211152): watcher backend {
directory: "/tmp/opencode-core-test-56khtq",
platform: "linux",
backend: "inotify",
}
[22:33:50.205] INFO (#211261): project copy refresh started {
projectID: "global",
}
[22:33:50.206] INFO (#211261): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[22:33:50.248] INFO (#211152): booting location services {
directory: "/tmp/opencode-core-test-56khtq",
workspaceID: undefined,
}
1185 pass
0 fail
3321 ...
One more boundary follows from the same race: a no-value ownership renewal is also the provider/tool pre-dispatch gate, so allowing it during cancelling would still permit a new side effect. I’m separating heartbeat renewal from ordinary claim updates. Only the execution adapter’s supervised heartbeat and cleanup may renew cancellation; all runner event, dispatch, tool, compaction, and transaction guards reject cancellation.
Success. Updated the following files: M packages/core/src/session/execution/claim.ts M packages/core/src/session/execution/local.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) test/session-runner.test.ts: (fail) SessionRunnerLLM > retains the claim after Step interruption until runner finalizers finish [5000.03ms] ^ this test timed out after 5000ms. 4 pass 115 filtered out 1 fail 11 expect() calls Ran 5 tests across 2 files. [7.07s]
The timeout proves interruption settlement itself must use a narrow cancellation-only fence; generic event commits correctly reject cancellation now. I’m adding a commit path that requires the exact claim to remain cancelling, permits only durable interruption reconciliation, and never authorizes provider or tool dispatch.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
250: {
251: sessionID: input.sessionID,
252: timestamp: yield* timestamp,
253: assistantMessageID,
254: error: { type: "unknown", message },
255: },
256: commit,
257: )
258: assistantActive = false
259: assistantFailed = true
260: })
261:
262: const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
263: message: string,
264: hostedOnly = false,
265: ) {
266: for (const [callID, tool] of tools) {
267: if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
268: yield* publishEvent(SessionEvent.Tool.Failed, {
269: sessionID: input.sessionID,
270: timestamp: yield* timestamp,
271: assistantMessageID: tool.assistantMessageID,
272: callID,
273: error: { type: "unknown", message },
274: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
20: const tokens = (usage: Usage | undefined) => {
21: const reasoning = safe(usage?.reasoningTokens)
22: const read = safe(usage?.cacheReadInputTokens)
23: const write = safe(usage?.cacheWriteInputTokens)
24: return {
25: input: safe(usage?.nonCachedInputTokens),
26: output: safe(usage?.visibleOutputTokens),
27: reasoning,
28: cache: { read, write },
29: }
30: }
31:
32: const record = (value: unknown): Record<string, unknown> =>
33: typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
34:
35: const message = (value: unknown) => {
36: if (typeof value === "string") return value
37: try {
38: return JSON.stringify(value) ?? ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: readonly startCommit: (assistantMessageID: SessionMessage.ID, seq: number) => Effect.Effect<void>
15: readonly eventCommit: () => Effect.Effect<void>
16: }
17:
18: const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? ...
The blocked test identifies the exact publication sequence: interrupted Tool.Failed events need a cancellation-only renewal while retaining cancelling; the final Step.Interrupted transaction alone changes it to cancelled. This preserves durable tool outcomes without reopening any ordinary runner transition or dispatch gate.
Success. Updated the following files: M packages/core/src/session/execution/claim.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/runner/llm.ts
$ tsgo --noEmit src/session/runner/llm.ts(422,98): error TS2345: Argument of type '() => () => Effect.Effect<undefined, never, never>' is not assignable to parameter of type '() => Effect<void, never, never>'. Type '() => Effect.Effect<undefined, never, never>' is not assignable to type 'Effect<void, never, never>'.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
198: if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
199: return yield* Effect.interrupt
200: const claim = { sessionID, executionID, ownerID }
201: const updateExecution = (values: SessionExecutionClaim.Values = {}) =>
202: SessionExecutionClaim.update(db, claim, values)
203: const renewCancellation = () => SessionExecutionClaim.settleCancellation(db, claim)
204: const settleCancellation = () =>
205: SessionExecutionClaim.settleCancellation(db, claim, { phase: "cancelled", assistant_message_id: null })
206: const execution = yield* db
207: .select({ phase: SessionExecutionTable.phase })
208: .from(SessionExecutionTable)
209: .where(
210: and(
211: eq(SessionExecutionTable.session_id, claim.sessionID),
212: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
414: }
415: const settleInterruption = <E extends RunError>(cause: Cause.Cause<E>) =>
416: Effect.gen(function* () {
417: yield* interruptToolFibers()
418: yield* withPublication(
419: publisher.failUnsettledTools("Tool execution outcome unknown after interruption", false, renewCancellation),
420: )
421: yield* withPublication(
422: publisher.interruptAssistant("Provider Step outcome unknown after interruption", () =>
423: settleCancellation,
424: ),
425: )
426: return yield* Effect.failCause(cause)
427: })
(Showing lines 414-427 of 617. Use offset=428 to continue.)
</content>
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) test/session-runner.test.ts: (fail) SessionRunnerLLM > retains the claim after Step interruption until runner finalizers finish [5000.01ms] ^ this test timed out after 5000ms. 4 pass 115 filtered out 1 fail 11 expect() calls Ran 5 tests across 2 files. [10.14s]
Found 9 matches
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line 137: const flush = Effect.fnUntraced(function* () {
Line 181: const flushFragments = Effect.fnUntraced(function* () {
Line 182: yield* text.flush()
Line 183: yield* reasoning.flush()
Line 184: yield* toolInput.flush()
Line 217: const flush = Effect.fn("SessionRunner.flush")(function* () {
Line 223: yield* flush()
Line 246: yield* flush()
Line 453: yield* flush()
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
175: text: value,
176: })
177: tool.inputEnded = true
178: }),
179: )
180:
181: const flushFragments = Effect.fnUntraced(function* () {
182: yield* text.flush()
183: yield* reasoning.flush()
184: yield* toolInput.flush()
185: })
186:
187: const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
188: if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`)
189: const assistantMessageID = yield* startAssistant()
190: tools.set(event.id, {
191: assistantMessageID,
192: name: event.name,
193: inputEnded: false,
194: called: false,
195: settled: false,
196: providerExecuted: false,
197: })
198: yield* toolInput.start(event.id)
199: yield* ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
78: }
79: >()
80: const timestamp = DateTime.now
81: let assistantMessageID: SessionMessage.ID | undefined
82: let assistantActive = false
83: let assistantFailed = false
84: let providerFailed = false
85: let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
86:
87: const startAssistant = Effect.fnUntraced(function* () {
88: if (assistantMessageID !== undefined) return assistantMessageID
89: const created = SessionMessage.ID.create()
90: yield* publishEvent(
91: SessionEvent.Step.Started,
92: {
93: sessionID: input.sessionID,
94: agent: input.agent,
95: model: input.model,
96: assistantMessageID: created,
97: timestamp: yield* timestamp,
98: snapshot: input.snapshot,
99: },
100: {
101: commit: (seq: number) ...
The remaining block is partial fragment flushing: Text.Ended and related durable events still used the ordinary commit before Step.Interrupted. I’m switching the publisher into a cancellation-settlement mode only after interruption is observed; from then until the final Step event, every durable reconciliation event uses the cancellation-only fence.
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/runner/llm.ts
Found 7 matches
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line 24: return {
Line 52: if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
Line 55: return { structured: record(settled.structured), content: settled.content }
Line 141: return { start, append, end, flush }
Line 263: const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
Line 468: return {
Line 473: failUnsettledTools,
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
460: return
461: case "provider-error":
462: yield* failAssistant(event.message)
463: providerFailed = true
464: return
465: }
466: })
467:
468: return {
469: publish,
470: flush,
471: failAssistant,
472: interruptAssistant,
473: failUnsettledTools,
474: beginCancellationSettlement: (commit: () => Effect.Effect<void>) => {
475: eventCommit = commit
476: },
477: hasActiveAssistant: () => assistantActive,
478: hasAssistantStarted: () => assistantMessageID !== undefined,
479: hasProviderError: () => providerFailed,
480: stepSettlement: () => stepSettlement,
481: startAssistant,
482: assistantMessageID: assistantMessageIDForTool,
483: }
484: }
(End of file - total 484 lines)
</content>
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 5 pass 115 filtered out 0 fail 15 expect() calls Ran 5 tests across 2 files. [4.69s]
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
4046 | while (executions.length === 0) yield* Effect.yieldNow
4047 | yield* Fiber.interrupt(run)
4048 | toolExecutionGate = undefined
4049 |
4050 | expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
4051 | expect(yield* session.context(sessionID)).toMatchObject([
^
error: expect(received).toMatchObject(expected)
@@ -2,5 +2,10 @@
{
+ "id": "msg_0ac6b94ef0017HbnX6l7BYQr1S",
"text": "Interrupt tool settlement",
+ "time": {
+ "created": {
+ "epochMilliseconds": 0,
+ "partsUtc": undefined,
+ },
+ },
"type": "user",
@@ -8,3 +13,3 @@
{
+ "agent": "build",
"content": [
@@ -12,9 +17,23 @@
"id": "call-await-interrupt",
+ "name": "echo",
+ "provider": {
+ "executed": false,
+ },
"state": {
- "error": {
- "message": "Tool execution outcome ...
The broader suite exposed direct fiber interruption, which is not an explicit user cancellation and must remain recoverable as unknown. I’m selecting the settlement fence from the durable phase: only cancelling uses the cancellation handshake; direct interruption keeps ordinary unknown-outcome reconciliation. A concurrent transition after that read still fails closed through the monotonic claim guard.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 9 pass 99 filtered out 0 fail 26 expect() calls Ran 9 tests across 1 file. [9.59s]
bun test v1.3.14 (0d9b296a) test/database-migration.test.ts: (fail) DatabaseMigration > declared schema has no ungenerated migrations [30030.81ms] ^ this test timed out after 30000ms. 247 pass 1 fail 732 expect() calls Ran 248 tests across 8 files. [171.27s]
All 227 non-migration recovery tests pass after the phase-aware settlement fix. The only combined-gate failure was the migration consistency test hitting its 30-second timeout; I’m isolating that test now rather than treating a timeout as schema evidence.
bun test v1.3.14 (0d9b296a) test/database-migration.test.ts: (fail) DatabaseMigration > declared schema has no ungenerated migrations [30028.13ms] ^ this test timed out after 30000ms. 0 pass 19 filtered out 1 fail Ran 1 test across 1 file. [34.53s]
<path>/home/byk/Code/opencode/packages/core/test/database-migration.test.ts</path>
<type>file</type>
<content>
190: yield* db.get(
191: sql`SELECT dflt_value AS value FROM pragma_table_info('session_input') WHERE name = 'wake_pending'`,
192: ),
193: ).toEqual({ value: "false" })
194: expect(
195: yield* db.get(sql`SELECT dflt_value AS value FROM pragma_table_info('session') WHERE name = 'wake_attempts'`),
196: ).toEqual({ value: "0" })
197: expect(
198: yield* db.get(
199: sql`SELECT pk, "notnull" AS required FROM pragma_table_info('session_recovery') WHERE name = 'execution_id'`,
200: ),
201: ).toEqual({ pk: 1, required: 1 })
202: expect(
203: yield* db.get(
204: sql`SELECT "notnull" AS required FROM pragma_table_info('session_recovery') WHERE name = 'assistant_message_id'`,
205: ),
206: ).toEqual({ required: 0 })
207: ...
Found 1 matches
/home/byk/Code/opencode/packages/core/test/database-migration.test.ts:
Line 118: test("declared schema has no ungenerated migrations", async () => {
<path>/home/byk/Code/opencode/packages/core/test/database-migration.test.ts</path>
<type>file</type>
<content>
105: test("serializes concurrent embedded initialization for one database path", async () => {
106: await using tmp = await tmpdir()
107: const filename = path.join(tmp.path, "embedded.sqlite")
108: const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
109:
110: await Effect.runPromise(
111: Effect.all(
112: layers.map((layer) => Effect.scoped(Layer.build(layer))),
113: { concurrency: "unbounded" },
114: ),
115: )
116: })
117: if (process.platform === "linux") {
118: test("declared schema has no ungenerated migrations", async () => {
119: const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
120: .quiet()
121: .nothrow()
122: expect(result.exitCode, result.stderr.toString()).toBe(0)
123: ...
Reading config file '/tmp/opencode-core-migration-check-pxeBht/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-pxeBht/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-pxeBht/full/20260916231517_schema/migration.sql 🚀
The migration subprocess itself passes; only its fixed 30-second wrapper timed out twice, once under the combined suite and once alone. I’m treating that as test-timeout debt, not weakening the schema gate, and rerunning full Core after the final cancellation changes.
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[23:16:03.328] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/location-layer.test.ts:
(fail) LocationServiceMap > isolates location state while sharing location policy with catalog [5696.45ms]
^ this test timed out after 5000ms.
test/tool-webfetch.test.ts:
(fail) WebFetchTool registration > returns an error result when HTML-to-Markdown conversion throws [6135.43ms]
^ this test timed out after 5000ms.
test/effect/layer-node/node-build.test.ts:
1183 pass
2 fail
3321 expect() calls
Ran 1185 tests across 145 files. [232.89s]
[23:19:38.687] INFO (#211153): watcher backend {
directory: ...
The final full suite reproduced the same two aggregate-contention timeouts: location isolation and WebFetch conversion. I’m isolating both on the exact tree, then rebuilding because the cancellation primitive changed.
bun test v1.3.14 (0d9b296a) 1 pass 3 filtered out 0 fail 4 expect() calls Ran 1 test across 1 file. [8.48s]
bun test v1.3.14 (0d9b296a) 1 pass 11 filtered out 0 fail 1 expect() calls Ran 1 test across 1 file. [7.24s]
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0ac880a0f0014MdhSe0lVjTVMr dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
apply_patch verification failed: Failed to read file to update: /tmp/opencode/session-recovery-v15-smoke.mjs
No files found
The restart removed the disposable smoke script, so I’m reconstructing the same isolated harness under /tmp/opencode: bounded authenticated health probes, disposable XDG/database/workspace state, schema checks, one expired ready claim, two-stage recovery, and an idempotent final restart.
No files found
Success. Updated the following files: A ../../../../tmp/opencode/session-recovery-v16-smoke.mjs
{
"initial": {
"port": 17607,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
}
],
"indexes": [
{
"name": "session_execution_recovery_scan_idx",
"sql": "CREATE INDEX `session_execution_recovery_scan_idx` ON `session_execution` (`recovery_error`,`expires_at`,`time_created`,`id`)"
}
],
"before": {
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"message_count": 0,
"event_count": 0
},
"first": {
"port": 28330,
"health": {
...
The exact rebuilt artifact passes authenticated health, migrated scan-index verification, exactly-once recovery, and duplicate-free restart. Before freezing v16, I’m separating evidence cleanly: all non-migration recovery tests after the final cancellation fence, plus the migration script itself, because the migration test’s 30-second subprocess wrapper now exceeds its fixed timeout.
bun test v1.3.14 (0d9b296a) 228 pass 0 fail 680 expect() calls Ran 228 tests across 7 files. [46.31s]
bun test v1.3.14 (0d9b296a) 2 pass 18 filtered out 0 fail 17 expect() calls Ran 2 tests across 1 file. [2.37s]
b0e9fdde553e70f6f606c33c555f9e37bb52d6827dd1aad8d5a932b76cd2ee30 /tmp/opencode/session-recovery-final-v16.patch a0eea521a58325461d7e533e5f3be05fccf2714a90a444eb67ac21b153f53c2f packages/opencode/dist/server/opencode-server packages/client/src/generated/types.ts | 72 + packages/core/schema.json | 615 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session.ts | 10 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 184 + packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 57% (3833/6637) Updating files: 58% (3850/6637) Updating files: 59% (3916/6637) Updating files: 60% (3983/6637) Updating files: 61% (4049/6637) Updating files: 62% (4115/6637) Updating files: 63% (4182/6637) Updating files: 64% (4248/6637) Updating files: 65% (4315/6637) Updating files: 66% (4381/6637) Updating files: 67% (4447/6637) Updating files: 68% (4514/6637) Updating files: 69% (4580/6637) Updating files: 70% (4646/6637) Updating files: 71% (4713/6637) Updating files: 72% (4779/6637) Updating files: 73% (4846/6637) Updating files: 74% (4912/6637) Updating files: 75% (4978/6637) Updating files: 76% (5045/6637) Updating files: 77% (5111/6637) Updating files: 78% (5177/6637) Updating files: 79% (5244/6637) Updating files: 80% (5310/6637) Updating files: 81% (5376/6637) Updating files: 82% (5443/6637) Updating files: 83% (5509/6637) Updating files: 84% (5576/6637) Updating files: 85% ...
(no output)
(no output)
Frozen v16 cleanly applies: patch b0e9fdde..., SEA a0eea521..., 34 files. It adds a monotonic cancellation barrier: ordinary runner guards reject cancelling/cancelled; only supervised heartbeat may renew; interruption reconciliation uses a cancellation-only fence; only the final durable Step transition sets cancelled. Fresh replacement reviews are starting now.
<task id="ses_f536faf67ffem4kWXOZJY8Joo5" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f536f2cefffeXoWsBb6YwOGlQD" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Frozen v16 is under fresh correctness and security review.
b0e9fdde553e70f6f606c33c555f9e37bb52d6827dd1aad8d5a932b76cd2ee30a0eea521a58325461d7e533e5f3be05fccf2714a90a444eb67ac21b153f53c2fBefore the restart, I verified:
509563ba0cd17246a1d223ac7a6f3fabee1a49892c61b215c3fd7ca4744b6c7783a833aa93cc185825feffe107fba1bd8e3789d1ff5fe5f8cda7df1962aa078dab9408c81cc9916177641ae8e9f08624fc27b5b9git apply --reverse --check succeeded.After the restart, the patch file no longer exists. The SEA still matches its stated hash. This report therefore continues from the verified immutable evidence and applies only to frozen v15. I ran no tests, builds, or services.
SessionExecutionClaim.update fences only by Session, execution, owner, and unexpired lease. It never checks the expected phase (packages/core/src/session/execution/claim.ts:27-33, packages/core/src/session/execution/claim.ts:40-58).
The interrupt path first commits phase = "cancelling" (packages/core/src/session/execution/local.ts:128-161). However, an already uninterruptible durable publication can subsequently call the same unrestricted update and write safe or continue while committing Step.Ended (packages/core/src/session/runner/llm.ts:477-495).
A concrete ordering is:
Step.Ended publication.interrupt commits cancelling.safe or continue.safe, the local finalizer clears the claim (packages/core/src/session/execution/local.ts:97-120).The resumed v15 reviewer independently confirmed the same merge blocker: ordinary settlement could overwrite cancelling. V16 directly closes that path with phase-aware CAS and adds the requested forced ordering regression; the v15 verdict is stale but strengthens the failing-first evidence. I’m completing exact-tree package checks while the v16 reviewers inspect the freeze.
$ tsgo --noEmit
ab9408c81cc9916177641ae8e9f08624fc27b5b9.b0e9fdde553e70f6f606c33c555f9e37bb52d6827dd1aad8d5a932b76cd2ee30.a0eea521a58325461d7e533e5f3be05fccf2714a90a444eb67ac21b153f53c2f.packages/core/src/session/runner/publish-llm-event.ts:222-240 durably publishes Step.Failed, retains the assistant claim, and sets assistantFailed = true. The runner may remain active while local tools settle at packages/core/src/session/runner/llm.ts:400-405.
If cancellation arrives during that period:
packages/core/src/session/execution/local.ts:133 moves the claim to cancelling.packages/core/src/session/runner/llm.ts:429-435.settleCancellation to interruptAssistant at packages/core/src/session/runner/llm.ts:436-440.interruptAssistant returns immediately when assistantFailed is already true at packages/core/src/session/runner/publish-llm-event.ts:242-247. It never invokes the supplied cancellation commit.The v16 security review found one distinct cancellation terminality gap: if Step.Failed already committed, interruptAssistant skips both the duplicate event and the cancellation transition. I’m adding the exact blocked-tool/provider-failure race first, then I’ll make cancellation settlement independent of whether an assistant terminal event already exists.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
215: yield* toolInput.end(event.id)
216: })
217:
218: const flush = Effect.fn("SessionRunner.flush")(function* () {
219: yield* flushFragments()
220: })
221:
222: const failAssistant = Effect.fnUntraced(function* (message: string) {
223: if (assistantFailed) return
224: yield* flush()
225: const assistantMessageID = yield* startAssistant()
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.eventCommit,
236: }.commit,
237: )
238: assistantActive = false
239: assistantFailed = true
240: })
241:
242: const interruptAssistant = Effect.fnUntraced(function* (
243: message: ...
Found 6 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 2190: it.effect("keeps provider failure unknown until unresolved tools are durable", () =>
Line 3754: it.effect("awaits started local tools before surfacing provider stream failure", () =>
Line 4170: it.effect("projects provider errors as terminal assistant step failures", () =>
Line 4192: it.effect("projects provider errors emitted before assistant step start", () =>
Line 4257: it.effect("does not continue automatically after a provider error follows a local tool call", () =>
Line 4282: it.effect("durably fails a hosted tool when its provider errors before returning a result", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3738: expect(yield* session.context(sessionID)).toMatchObject([
3739: { type: "user", text: "Ask then stop" },
3740: {
3741: type: "assistant",
3742: content: [
3743: {
3744: type: "tool",
3745: id: "call-question",
3746: state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
3747: },
3748: ],
3749: },
3750: ])
3751: }),
3752: )
3753:
3754: it.effect("awaits started local tools before surfacing provider stream failure", () =>
3755: Effect.gen(function* () {
3756: yield* setup
3757: const session = yield* SessionV2.Service
3758: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Settle before failing" }), resume: false })
3759: const failure = ...
Found 2 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 3906: it.effect("retains cancellation ownership when interrupted tool publication defects", () =>
Line 3951: it.effect("retains the claim after Step interruption until runner finalizers finish", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3880: {
3881: type: "tool",
3882: id: "call-before-interrupt",
3883: state: {
3884: status: "error",
3885: error: { type: "unknown", message: "Tool execution outcome unknown after interruption" },
3886: },
3887: },
3888: ],
3889: },
3890: ])
3891:
3892: yield* replaySessionProjection(sessionID)
3893:
3894: expect(yield* session.context(sessionID)).toMatchObject([
3895: { type: "user", text: "Interrupt blocked tool" },
3896: { type: "assistant", content: [{ type: "tool", id: "call-before-interrupt", state: { status: "error" } }] },
3897: ])
3898: requests.length = 0
3899: responseStream = undefined
3900: response = []
3901: yield* session.resume(sessionID)
3902: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
380: entries,
381: model,
382: request,
383: beforeStream: () =>
384: updateExecution({
385: phase: "unknown",
386: assistant_message_id: null,
387: }),
388: eventCommit: () => updateExecution(),
389: commit: () =>
390: updateExecution({
391: phase: "continue",
392: assistant_message_id: null,
393: }),
394: }),
395: ))
396: )
397: return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
398: if (overflowFailure) yield* publish(overflowFailure)
399: const llmFailure = failure instanceof LLMError ? failure : undefined
400: if (llmFailure && ...
The correct terminal rule is simple: cancellation must settle the ownership claim even when the assistant already has a durable Step.Failed; it must not emit a duplicate Step.Interrupted. I’m exposing that durable terminal state to the runner and applying only the cancellation phase transition after tool reconciliation.
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/runner/llm.ts M packages/core/test/session-runner.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 3 pass 106 filtered out 0 fail 14 expect() calls Ran 3 tests across 1 file. [1.99s]
The failed-assistant cancellation regression now passes: one durable Step.Failed, no duplicate Step.Interrupted, all tool reconciliation completes, the exact claim clears, and no recovery row remains. Publication-defect and blocked-finalizer cancellation tests still pass. V16 is rejected; I’m validating this revised candidate before a new freeze.
bun test v1.3.14 (0d9b296a) 229 pass 0 fail 685 expect() calls Ran 229 tests across 7 files. [17.46s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[23:54:30.972] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
[23:56:01.375] INFO (#211743): watcher backend {
directory: "/tmp/opencode-core-test-Wgejw1",
platform: "linux",
backend: "inotify",
}
[23:56:01.391] INFO (#211852): project copy refresh started {
projectID: "global",
}
[23:56:01.392] INFO (#211852): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[23:56:01.401] INFO (#211743): booting location services {
directory: "/tmp/opencode-core-test-Wgejw1",
workspaceID: undefined,
}
1186 pass
0 fail
3326 ...
The exact revised tree now has a clean full Core run: 1,186/1,186, alongside 229 recovery tests. I’m rebuilding once more because production runner code changed, then repeating the disposable artifact smoke and freezing v17.
ab9408c81cc9916177641ae8e9f08624fc27b5b9.b0e9fdde553e70f6f606c33c555f9e37bb52d6827dd1aad8d5a932b76cd2ee30.a0eea521a58325461d7e533e5f3be05fccf2714a90a444eb67ac21b153f53c2f.cancelling and cancelled; only the cancellation transition accepts cancelling, and only from cancelling. packages/core/src/session/execution/claim.ts:27-65packages/core/src/session/execution/claim.ts:67-82, packages/core/src/session/execution/local.ts:86-91,128-162packages/core/src/session/execution/claim.ts:84-109, packages/core/src/session/execution/local.ts:133-161Step.Interrupted commit changes the phase to cancelled. packages/core/src/session/runner/llm.ts:415-447, ...The v16 correctness review approved the monotonic cancellation design; its security review found the failed-assistant terminality gap now covered by the new regression. Since source changed, both verdicts are stale for v17. I’m running the rebuilt artifact through the same isolated smoke, then freezing and replacing both reviews.
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v17-smoke.mjs
{
"initial": {
"port": 15654,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
}
],
"indexes": [
{
"name": "session_execution_recovery_scan_idx",
"sql": "CREATE INDEX `session_execution_recovery_scan_idx` ON `session_execution` (`recovery_error`,`expires_at`,`time_created`,`id`)"
}
],
"before": {
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"message_count": 0,
"event_count": 0
},
"first": {
"port": 29902,
"health": {
...
0eb85245c71fb55fa2a8e169a1cd1de5579059d77aaca30b6ac254482e255a2e /tmp/opencode/session-recovery-final-v17.patch 308e9e562a3473b66bac430166db9d6cdd29f5f071d5695c1c35f28b99f26bf4 packages/opencode/dist/server/opencode-server packages/client/src/generated/types.ts | 72 + packages/core/schema.json | 615 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session.ts | 10 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 184 + packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 92% (6154/6637) Updating files: 93% (6173/6637) Updating files: 94% (6239/6637) Updating files: 95% (6306/6637) Updating files: 96% (6372/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
(no output)
V17 is frozen and cleanly applies: patch 0eb85245..., SEA 308e9e56..., 34 files. Exact validation is green: Core 1,186/1,186; recovery 229/229; migration upgrades and direct consistency pass; artifact health/index/recovery/idempotence pass. Fresh immutable reviews are starting with the already-failed-assistant cancellation race as the primary delta.
<task id="ses_f5356006bffeKRo1QusMcUUyJJ" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f535546eaffeOL4WHqSjyRr6H4" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Close monotonic cancellation and failed-assistant terminality races",
"status": "completed",
"priority": "high"
},
{
"content": "Validate and smoke exact v17 candidate",
"status": "completed",
...
Frozen v17 is now under independent correctness and security review.
0eb85245c71fb55fa2a8e169a1cd1de5579059d77aaca30b6ac254482e255a2e308e9e562a3473b66bac430166db9d6cdd29f5f071d5695c1c35f28b99f26bf4Durable Step.Failed can still be followed by duplicate Step.Interrupted.
failAssistant sets assistantFailed only after events.publish(...) returns (packages/core/src/session/runner/publish-llm-event.ts:222-240). Event publication commits the durable row inside the uninterruptible transaction, then performs interruptible notification before returning (packages/core/src/event.ts:237-363, packages/core/src/event.ts:379-390). Cancellation can interrupt during that notification window. The durable Step.Failed exists, but assistantFailed remains false. The cancellation path then publishes Step.Interrupted because it trusts that flag (packages/core/src/session/runner/llm.ts:429-443).
The regression polls for the committed event and then interrupts, but it does not hold publication between commit and return, so it does not deterministically exercise this window (packages/core/test/session-runner.test.ts:3965-3976). Add a gated listener or equivalent commit/return boundary regression. Terminal-state selection must use state established at the durable commit boundary.
ab9408c81cc9916177641ae8e9f08624fc27b5b9 exists as a commit.0eb85245c71fb55fa2a8e169a1cd1de5579059d77aaca30b6ac254482e255a2e.308e9e562a3473b66bac430166db9d6cdd29f5f071d5695c1c35f28b99f26bf4.packages/core/src/session/execution/claim.ts:27-109). ...The v17 correctness review found a commit-to-return window, not another ownership flaw: Step.Failed may be durable while the in-memory assistantFailed flag is still false. I’m replacing cancellation’s terminal-state decision with durable projected state after interruption, and adding a deterministic publication-notification gate so the test pauses exactly after commit and before publish() returns.
Found 29 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 18: import { EventV2 } from "@opencode-ai/core/event"
Line 49: SessionMessageTable,
Line 260: EventV2.node,
Line 404: const events = yield* EventV2.Service
Line 415: yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
Line 430: readonly delta: EventV2.Definition
Line 523: const events = yield* EventV2.Service
Line 524: const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped)
Line 534: .where(eq(EventTable.type, EventV2.versionedType(fixture.delta.type, 1)))
Line 623: const events = yield* EventV2.Service
Line 719: const events = yield* EventV2.Service
Line 809: const events = yield* EventV2.Service
Line 818: const unsubscribe = yield* events.listen((event) =>
Line 828: yield* ...
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
90: export const PartTable = sqliteTable(
91: "part",
92: {
93: id: text().$type<PartID>().primaryKey(),
94: message_id: text()
95: .$type<MessageID>()
96: .notNull()
97: .references(() => MessageTable.id, { onDelete: "cascade" }),
98: session_id: text().$type<SessionSchema.ID>().notNull(),
99: ...Timestamps,
100: data: text({ mode: "json" }).notNull().$type<V1PartData>(),
101: },
102: (table) => [
103: index("part_message_id_id_idx").on(table.message_id, table.id),
104: index("part_session_idx").on(table.session_id),
105: ],
106: )
107:
108: export const TodoTable = sqliteTable(
109: "todo",
110: {
111: session_id: text()
112: .$type<SessionSchema.ID>()
113: .notNull()
114: .references(() => SessionTable.id, { onDelete: "cascade" }),
115: content: text().notNull(),
116: status: ...
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
127: export const SessionMessageTable = sqliteTable(
128: "session_message",
129: {
130: id: text().$type<SessionMessage.ID>().primaryKey(),
131: session_id: text()
132: .$type<SessionSchema.ID>()
133: .notNull()
134: .references(() => SessionTable.id, { onDelete: "cascade" }),
135: type: text().$type<SessionMessage.Type>().notNull(),
136: seq: integer().notNull(),
137: ...Timestamps,
138: data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
139: },
140: (table) => [
141: uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq),
142: index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq),
143: index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
144: ...
Found 2 matches
/home/byk/Code/opencode/packages/core/src/session/sql.ts:
Line 19: type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
Line 138: data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
1: import { castDraft, produce, type WritableDraft } from "immer"
2: import { Effect } from "effect"
3: import { SessionEvent } from "./event"
4: import { SessionMessage } from "./message"
5: import { message } from "./recovery-id"
6:
7: export type MemoryState = {
8: messages: SessionMessage.Message[]
9: }
10:
11: export interface Adapter {
12: readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
13: readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
14: readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
15: readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
16: readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
17: readonly appendMessage: (message: ...
Found 15 matches /home/byk/Code/opencode/packages/core/src/session/message-updater.ts: Line 34: return assistant?.type === "assistant" && !assistant.time.completed ? assistant : undefined Line 182: draft.time.completed = event.data.timestamp Line 194: draft.time.completed = event.data.timestamp Line 213: draft.time.completed = event.data.timestamp Line 214: draft.finish = event.data.finish Line 227: draft.time.completed = event.data.timestamp Line 228: draft.finish = "error" Line 234: draft.time.completed = event.data.timestamp Line 235: draft.finish = "error" Line 239: tool.time.completed = event.data.timestamp Line 272: draft.time.completed = event.data.timestamp Line 273: draft.finish = "error" Line 286: tool.time.completed = event.data.timestamp Line 375: match.time.completed = ...
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
204: time: { created: event.data.timestamp },
205: content: [],
206: snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
207: }),
208: )
209: })
210: },
211: "session.next.step.ended": (event) => {
212: return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
213: draft.time.completed = event.data.timestamp
214: draft.finish = event.data.finish
215: draft.cost = event.data.cost
216: draft.tokens = event.data.tokens
217: if (event.data.snapshot || event.data.files)
218: draft.snapshot = {
219: ...draft.snapshot,
220: end: event.data.snapshot,
221: files: event.data.files ? Array.from(event.data.files) : undefined,
222: }
223: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
1: import {
2: LLM,
3: LLMClient,
4: LLMError,
5: LLMEvent,
6: Message,
7: SystemPart,
8: isContextOverflowFailure,
9: type ProviderErrorEvent,
10: } from "@opencode-ai/llm"
11: import { Cause, DateTime, Effect, Exit, Fiber, Layer, Option, Semaphore, Stream } from "effect"
12: import { and, eq } from "drizzle-orm"
13: import { AgentV2 } from "../../agent"
14: import { Config } from "../../config"
15: import { Database } from "../../database/database"
16: import { EventV2 } from "../../event"
17: import { Location } from "../../location"
18: import { ModelV2 } from "../../model"
19: import { PermissionV2 } from "../../permission"
20: import { ProviderV2 } from "../../provider"
21: import { QuestionV2 } from "../../question"
22: import { SystemContext } from "../../system-context/index"
23: import { SystemContextRegistry } from "../../system-context/registry"
24: ...
No files found
Found 19 matches
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line 14: readonly startCommit: (assistantMessageID: SessionMessage.ID, seq: number) => Effect.Effect<void>
Line 72: readonly assistantMessageID: SessionMessage.ID
Line 82: let assistantMessageID: SessionMessage.ID | undefined
Line 97: assistantMessageID: created,
Line 148: assistantMessageID: yield* currentAssistantMessageID(),
Line 159: assistantMessageID: yield* currentAssistantMessageID(),
Line 174: assistantMessageID: tool.assistantMessageID,
Line 275: assistantMessageID: tool.assistantMessageID,
Line 289: const assistantMessageIDForTool = (callID: string) => {
Line 305: assistantMessageID: yield* startAssistant(),
Line 314: assistantMessageID: yield* currentAssistantMessageID(),
Line 327: assistantMessageID: yield* startAssistant(),
Line 337: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
78: providerMetadata?: ProviderMetadata
79: }
80: >()
81: const timestamp = DateTime.now
82: let assistantMessageID: SessionMessage.ID | undefined
83: let assistantActive = false
84: let assistantFailed = false
85: let providerFailed = false
86: let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
87:
88: const startAssistant = Effect.fnUntraced(function* () {
89: if (assistantMessageID !== undefined) return assistantMessageID
90: const created = SessionMessage.ID.create()
91: yield* publishEvent(
92: SessionEvent.Step.Started,
93: {
94: sessionID: input.sessionID,
95: agent: input.agent,
96: model: input.model,
97: assistantMessageID: created,
98: timestamp: yield* timestamp,
99: snapshot: input.snapshot,
100: ...
The durable projection already records terminal assistant state in session_message inside the event transaction. I’m removing the in-memory flag from cancellation decisions: after tool reconciliation, the runner will read the claimed assistant projection and settle ownership directly when any terminal Step already committed. This also covers Step.Ended or Step.Interrupted committed before notification was interrupted.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts
$ tsgo --noEmit
src/session/runner/llm.ts(452,49): error TS2339: Property 'type' does not exist on type 'SessionMessageData'.
src/session/runner/llm.ts(452,71): error TS18048: 'assistant' is possibly 'undefined'.
src/session/runner/llm.ts(452,91): error TS2339: Property 'completed' does not exist on type '{ readonly created: number; } | { readonly created: number; readonly completed?: number | undefined; } | { readonly created: number; readonly completed?: number | undefined; }'.
Property 'completed' does not exist on type '{ readonly created: number; }'.
ab9408c81cc9916177641ae8e9f08624fc27b5b9.0eb85245c71fb55fa2a8e169a1cd1de5579059d77aaca30b6ac254482e255a2e.308e9e562a3473b66bac430166db9d6cdd29f5f071d5695c1c35f28b99f26bf4..lore.md, AGENTS.md, and perf.data* changes. I excluded them from the review.Step.Failed can still be followed by duplicate Step.InterruptedfailAssistant records assistantFailed = true only after events.publish(Step.Failed) returns:
packages/core/src/session/runner/publish-llm-event.ts:222-240Durable publication commits before notification completes. The transaction is uninterruptible, but notification occurs afterward and remains interruptible:
packages/core/src/event.ts:237-363packages/core/src/event.ts:378-390packages/core/src/event.ts:406-416Cancellation settlement later trusts only the in-memory flag:
packages/core/src/session/runner/llm.ts:431-443This leaves the following reproducible boundary:
Step.Failed commits durably.assistantFailed = true never executes.hasAssistantFailed() returns false.Step.Interrupted for the assistant that already has durable Step.Failed. ...Both v17 reviews found the same post-commit notification window, confirming the fix target. The revised runner now consults durable projected completion instead of the lagging in-memory flag; I’m correcting the query’s union narrowing, then adding the deterministic listener gate requested by both reviewers.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
390: requests.length = 0
391: return session
392: })
393:
394: const messageTexts = (request: LLMRequest, role: "user" | "system") =>
395: request.messages.flatMap((message) =>
396: message.role === role ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) : [],
397: )
398: const userTexts = (request: LLMRequest) => messageTexts(request, "user")
399: const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
400:
401: const replaySessionProjection = (id: SessionV2.ID) =>
402: Effect.gen(function* () {
403: const { db } = yield* Database.Service
404: const events = yield* EventV2.Service
405: const recorded = yield* db
406: .select()
407: .from(EventTable)
408: .where(eq(EventTable.aggregate_id, id))
409: .orderBy(asc(EventTable.seq))
410: .all()
411: ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
800: expect((yield* session.context(sessionID)).some((message) => message.type === "assistant")).toBe(false)
801: requests.length = 0
802: }),
803: )
804:
805: it.effect("never starts a local tool after ownership changes", () =>
806: Effect.gen(function* () {
807: yield* setup
808: const session = yield* SessionV2.Service
809: const events = yield* EventV2.Service
810: const { db } = yield* Database.Service
811: executions.length = 0
812: response = [
813: LLMEvent.stepStart({ index: 0 }),
814: LLMEvent.toolCall({ id: "call-fenced", name: "echo", input: { text: "must not run" } }),
815: LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
816: LLMEvent.finish({ reason: "tool-calls" }),
817: ]
818: const unsubscribe = yield* events.listen((event) =>
819: event.type ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3945: content: [{ type: "tool", id: "call-interrupt-publication-failure", state: { status: "running" } }],
3946: },
3947: ])
3948: }),
3949: )
3950:
3951: it.effect("settles cancellation after Step failure while a local tool is active", () =>
3952: Effect.gen(function* () {
3953: yield* setup
3954: const session = yield* SessionV2.Service
3955: const { db } = yield* Database.Service
3956: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Cancel failed Step" }), resume: false })
3957: executions.length = 0
3958: toolExecutionGate = yield* Deferred.make<void>()
3959: response = [
3960: LLMEvent.stepStart({ index: 0 }),
3961: LLMEvent.toolCall({ id: "call-failed-before-cancel", name: "echo", input: { text: "blocked" } }),
3962: LLMEvent.providerError({ message: ...
The deterministic boundary uses EventV2.listen: its callback runs after the durable transaction commits but before publish() returns. The test will block that callback for Step.Failed, interrupt the drain there, and prove cancellation reads the durable assistant projection rather than emitting a second terminal event.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 3 pass 106 filtered out 0 fail 14 expect() calls Ran 3 tests across 1 file. [2.47s]
The gated test now pauses exactly after durable Step.Failed commit and before publication returns; cancellation still emits no Step.Interrupted, settles the exact claim, and leaves no recovery. I’m rerunning the complete recovery and Core gates on this final ordering fix.
bun test v1.3.14 (0d9b296a) 229 pass 0 fail 685 expect() calls Ran 229 tests across 7 files. [18.70s]
bun test v1.3.14 (0d9b296a)
test/skill-discovery.test.ts:
[00:16:58.015] ERROR (#27084): failed to download skill file {
url: "https://skills.example.test/catalog/deploy/missing.md",
error: {
reason: {
response: [Object ...],
request: [Object ...],
description: "non 2xx status code",
_tag: "StatusCodeError",
},
_tag: "HttpClientError",
"~effect/http/HttpClientError": "~effect/http/HttpClientError",
},
}
test/effect/layer-node/node-build.test.ts:
[00:18:07.226] INFO (#211739): watcher backend {
directory: "/tmp/opencode-core-test-ykailQ",
platform: "linux",
backend: "inotify",
}
[00:18:07.256] INFO (#211848): project copy refresh started {
projectID: "global",
}
[00:18:07.257] INFO (#211848): project copy refresh done {
projectID: "global",
updated: [],
removed: [],
}
[00:18:07.275] INFO (#211739): booting location services {
directory: "/tmp/opencode-core-test-ykailQ",
workspaceID: undefined,
}
1186 pass
0 fail
3326 ...
The final commit/notification race fix is clean across 229 recovery tests and all 1,186 Core tests. I’m rebuilding the exact artifact, repeating isolated recovery smoke, then freezing v18 for replacement reviews focused on this gated boundary.
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0acbb3590001kgVldji0Tqwvx4 dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
Success. Updated the following files: M ../../../../tmp/opencode/session-recovery-v18-smoke.mjs
{
"initial": {
"port": 28830,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
}
],
"indexes": [
{
"name": "session_execution_recovery_scan_idx",
"sql": "CREATE INDEX `session_execution_recovery_scan_idx` ON `session_execution` (`recovery_error`,`expires_at`,`time_created`,`id`)"
}
],
"before": {
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"message_count": 0,
"event_count": 0
},
"first": {
"port": 21735,
"health": {
...
63c11067ed78471a8109455f164327a1c6dc2ff547e5cb78cad185fe2d844e82 /tmp/opencode/session-recovery-final-v18.patch c5730f7d6017ebd8dde2a2e91365e574469ef10494ec632632b91d7e2318885c packages/opencode/dist/server/opencode-server packages/client/src/generated/types.ts | 72 + packages/core/schema.json | 615 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session.ts | 10 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 184 + packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 99% (6593/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
V18 is frozen and cleanly applies: patch 63c11067..., SEA c5730f7d..., 34 files. The new test deterministically blocks EventV2 notification after Step.Failed commits, then cancels; terminal selection reads durable session_message completion and emits no duplicate terminal event. Fresh final reviews are starting.
<task id="ses_f53431949ffehdCfM2PmDBmJvD" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f5342a3cdffezlzQRwyEts4Bkm" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
[
{
"content": "Redesign execution ownership and stale-owner fencing from review findings",
"status": "completed",
"priority": "high"
},
{
"content": "Make cancellation, compaction settlement, and recovery wake atomic and durable",
"status": "completed",
"priority": "high"
},
{
"content": "Harden recovery claims, replay identity, batches, and migration upgrades",
"status": "completed",
"priority": "high"
},
{
"content": "Add failing-first lifecycle, migration, replay, poison-row, crash-window, and liveness regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate public OpenAPI and SDK event contracts",
"status": "completed",
"priority": "high"
},
{
"content": "Close monotonic cancellation and durable terminal publication races",
"status": "completed",
"priority": "high"
},
{
"content": "Validate and smoke exact v18 candidate",
"status": "completed",
...
Frozen v18 is under independent correctness and security review.
63c11067ed78471a8109455f164327a1c6dc2ff547e5cb78cad185fe2d844e82c5730f7d6017ebd8dde2a2e91365e574469ef10494ec632632b91d7e2318885cab9408c81cc9916177641ae8e9f08624fc27b5b9.63c11067ed78471a8109455f164327a1c6dc2ff547e5cb78cad185fe2d844e82.c5730f7d6017ebd8dde2a2e91365e574469ef10494ec632632b91d7e2318885c.Step.Started boundarystartAssistant() commits Step.Started before storing the assistant ID in publisher memory:
packages/core/src/session/runner/publish-llm-event.ts:88-107If cancellation interrupts notification after that commit but before line 105:
publisher.interruptAssistant(...):
packages/core/src/session/runner/llm.ts:417-465interruptAssistant() calls startAssistant() again:
packages/core/src/session/runner/publish-llm-event.ts:242-260cancelling phase:
packages/core/src/session/execution/claim.ts:46-55packages/core/src/session/runner/llm.ts:291-303flush() can fail even earlier because the publisher has no current assistant ID:
packages/core/src/session/runner/publish-llm-event.ts:109-112,182-185The remaining v18 blocker is the symmetric Step.Started commit-to-return window. The durable execution claim already stores the assistant ID atomically with Step.Started; cancellation must use that ID directly instead of asking publisher memory to start or locate an assistant. I’m adding a targeted existing-assistant interruption path and the same deterministic listener gate at Step.Started.
63c11067ed78471a8109455f164327a1c6dc2ff547e5cb78cad185fe2d844e82.c5730f7d6017ebd8dde2a2e91365e574469ef10494ec632632b91d7e2318885c.ab9408c81cc9916177641ae8e9f08624fc27b5b9 exists as a commit.packages/core/src/session/runner/llm.ts:415-466 — V18 fixes the V17 blocker. Cancellation reconciles tools first, reads the assistant attached to the exact claimed execution, checks durable time.completed, and settles cancelling → cancelled without publishing a duplicate Step.Interrupted.packages/core/src/event.ts:320-348, packages/core/src/event.ts:378-390 — projectors and claim commit hooks complete inside the durable event transaction before listeners run. The assistant-row check therefore observes the committed Step.Failed projection even while post-commit notification remains blocked.packages/core/test/session-runner.test.ts:3951-3997 — the regression reaches the exact boundary: the listener blocks after observing committed Step.Failed; cancellation then proves one Failed event, zero Interrupted events, no execution claim, and no recovery row.packages/core/src/session/execution/claim.ts:27-33, packages/core/src/session/execution/claim.ts:84-108 — updates, cancellation settlement, and deletion require exact Session, execution, owner, and unexpired lease identity. Expired claims never renew.packages/core/src/session/execution/local.ts:97-121, packages/core/src/session/execution/local.ts:128-162 — ...<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
109: const currentAssistantMessageID = () =>
110: assistantMessageID === undefined
111: ? Effect.die("Tool event before assistant step start")
112: : Effect.succeed(assistantMessageID)
113:
114: const fragments = (
115: name: string,
116: ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>,
117: ) => {
118: const chunks = new Map<string, string[]>()
119: const start = (id: string) =>
120: Effect.suspend(() => {
121: if (chunks.has(id)) return Effect.die(`Duplicate ${name} start: ${id}`)
122: chunks.set(id, [])
123: return Effect.void
124: })
125: const append = (id: string, value: string) =>
126: Effect.suspend(() => {
127: const current = chunks.get(id)
128: if (!current) return Effect.die(`${name} delta before start: ...
V18 correctness approved, but security found the earlier symmetric boundary at Step.Started. The minimal fix is to let cancellation adopt the assistant ID already stored on the exact durable claim before flushing fragments or publishing interruption; it will never call startAssistant() again for that assistant.
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/runner/llm.ts M packages/core/test/session-runner.test.ts
$ tsgo --noEmit src/session/runner/publish-llm-event.ts(249,11): error TS2448: Block-scoped variable 'assistantMessageID' used before its declaration. src/session/runner/publish-llm-event.ts(249,11): error TS2454: Variable 'assistantMessageID' is used before being assigned. src/session/runner/publish-llm-event.ts(249,47): error TS2448: Block-scoped variable 'assistantMessageID' used before its declaration. src/session/runner/publish-llm-event.ts(251,7): error TS2448: Block-scoped variable 'assistantMessageID' used before its declaration. src/session/runner/publish-llm-event.ts(251,7): error TS2588: Cannot assign to 'assistantMessageID' because it is a constant. test/session-runner.test.ts(4036,96): error TS18048: 'claim' is possibly 'undefined'. test/session-runner.test.ts(4043,96): error TS18048: 'claim' is possibly 'undefined'.
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts M packages/core/test/session-runner.test.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
error: Interrupted Session settlement failed: ses_runner_test
Interrupted Session settlement failed: ses_runner_test
(fail) SessionRunnerLLM > settles cancellation when Step start notification is interrupted [169.80ms]
2 pass
107 filtered out
1 fail
9 expect() calls
Ran 3 tests across 1 file. [3.18s]
[00:38:17.923] ERROR (#1186): 223 | ;
224 | error[key] = original[key];
225 | }
226 | }
227 | } else {
228 | error = new globalThis.Error(!original ? `Unknown error: ${original}` : kind === "string" ? original : formatJson(original));
^
error: Interrupted Session settlement failed: ses_runner_test
at causePrettyError (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:228:13)
The boundary is reached, but cleanup reports that interrupted settlement did not leave the expected claim state. I’m tracing the exact claim phase after the interruption transaction; the likely issue is that the runner publishes Step.Interrupted with the adopted durable assistant correctly, but the test’s listener lifecycle is also affecting cleanup completion.
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
90: ),
91: ).pipe(
92: Effect.tapCause((cause) =>
93: Cause.hasInterruptsOnly(cause)
94: ? Effect.void
95: : Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
96: ),
97: Effect.onExit((exit) =>
98: Effect.gen(function* () {
99: if (activeClaims.get(sessionID)?.executionID === executionID) activeClaims.delete(sessionID)
100: if (Exit.isSuccess(exit)) {
101: yield* SessionExecutionClaim.clear(db, claim)
102: return
103: }
104: const current = yield* db
105: .select({ phase: SessionExecutionTable.phase })
106: .from(SessionExecutionTable)
107: .where(
108: and(
109: eq(SessionExecutionTable.session_id, ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
45: | {
46: readonly structured: Record<string, unknown>
47: readonly content: ToolOutput["content"]
48: }
49: | { readonly error: { readonly type: "unknown"; readonly message: string } }
50:
51: const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
52: if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
53: const settled = value ?? ToolOutput.fromResultValue(result)
54: if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
55: return { structured: record(settled.structured), content: settled.content }
56: }
57:
58: /** Persist one provider turn without executing tools or starting a continuation turn. */
59: export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
60: let eventCommit = ...
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
360: }
361: return committed
362: }),
363: )
364: }
365: }
366: })
367: }
368:
369: function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
370: return Effect.gen(function* () {
371: if (!definition?.durable && commit)
372: return yield* Effect.die(
373: new InvalidDurableEventError({
374: type: event.type,
375: message: "Local commit hooks require a durable event",
376: }),
377: )
378: if (definition?.durable) {
379: const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
380: if (committed) {
381: event = {
382: ...event,
383: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
270: providerOptions: { openai: { promptCacheKey } },
271: system: [agent.info?.system, system.baseline]
272: .filter((part): part is string => part !== undefined && part.length > 0)
273: .map(SystemPart.make),
274: messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
275: tools: toolMaterialization?.definitions ?? [],
276: toolChoice: isLastStep ? "none" : undefined,
277: })
278: if (
279: yield* compaction.compactIfNeeded({
280: sessionID: session.id,
281: entries,
282: model,
283: request,
284: beforeStream: () => updateExecution({ phase: "unknown", assistant_message_id: null }),
285: eventCommit: () => updateExecution(),
286: commit: () => updateExecution({ phase: "continue", ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
185: Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
186: concurrency: "unbounded",
187: }).pipe(Effect.map(SystemContext.combine))
188:
189: const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
190: sessionID: SessionSchema.ID,
191: executionID: string,
192: promotion: Promotion | undefined,
193: step: number,
194: ownerID: string,
195: recoverOverflow?: typeof compaction.compactAfterOverflow,
196: ) {
197: const session = yield* getSession(sessionID)
198: if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
199: return yield* Effect.interrupt
200: const claim = { sessionID, executionID, ownerID }
201: const updateExecution = (values: SessionExecutionClaim.Values = {}) ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
290: commit,
291: )
292: tool.settled = true
293: }
294: })
295:
296: const assistantMessageIDForTool = (callID: string) => {
297: const tool = tools.get(callID)
298: return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`)
299: }
300:
301: const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
302: event: LLMEvent,
303: outputPaths: ReadonlyArray<string> = [],
304: ) {
305: switch (event.type) {
306: case "step-start":
307: return
308: case "text-start":
309: yield* text.start(event.id)
310: yield* publishEvent(SessionEvent.Text.Started, {
311: sessionID: input.sessionID,
312: assistantMessageID: yield* startAssistant(),
313: timestamp: yield* timestamp,
314: textID: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
450: callID: event.id,
451: error: { type: "unknown", message: event.message },
452: provider: {
453: executed: tool.providerExecuted,
454: ...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
455: },
456: })
457: tool.settled = true
458: return
459: }
460: case "step-finish":
461: yield* flush()
462: assistantActive = false
463: if (stepSettlement) return yield* Effect.die("Duplicate step finish")
464: stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
465: return
466: case "finish":
467: return
468: case "provider-error":
469: yield* failAssistant(event.message)
470: providerFailed = true
471: return
472: }
473: })
474:
475: ...
The adopted-ID path is correct in isolation, but external fiber interruption can bypass the runner’s settlement handler when it lands inside the blocked Step.Started notification. I’m tracing the stream’s interrupt masking boundary; cancellation settlement must run uninterruptibly after the provider stream captures the interrupt.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
300: startCommit: (assistantMessageID, assistantSeq) =>
301: updateExecution({ assistant_message_id: assistantMessageID, assistant_seq: assistantSeq }),
302: eventCommit: () => updateExecution(),
303: })
304: const withPublication = Semaphore.makeUnsafe(1).withPermit
305: const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
306: withPublication(publisher.publish(event, outputPaths))
307: let overflowFailure: ProviderErrorEvent | undefined
308: const providerStream = Stream.unwrap(updateExecution().pipe(Effect.map(() => llm.stream(request)))).pipe(
309: Stream.runForEach((event) =>
310: Effect.gen(function* () {
311: if (overflowFailure || publisher.hasProviderError()) return
312: if (LLMEvent.is.providerError(event)) {
313: if ...
The runner does enter its interruption handler under the outer uninterruptible mask, so the defect is later in settlement. I’m making the regression retain the interrupt exit long enough to inspect the durable claim and event rows instead of aborting at the first cleanup error.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
Found 1 matches /home/byk/Code/opencode/packages/core/test/session-runner.test.ts: Line 4030: const interrupted = yield* session.interrupt(sessionID).pipe(Effect.exit)
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
[00:41:52.171] ERROR (#2): 4028 | .pipe(Effect.orDie, Effect.flatMap((row) => (row ? Effect.succeed(row) : Effect.die("Missing execution claim"))))
4029 |
4030 | const interrupted = yield* session.interrupt(sessionID).pipe(Effect.exit)
4031 |
4032 | expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
4033 | expect(interrupted).toMatchObject({ _tag: "Success" })
^
error: expect(received).toMatchObject(expected)
{
- "_tag": "Success",
+ "~effect/Effect/args": CauseImpl {
+ "reasons": [
+ Die {
+ "_tag": "Die",
+ "annotations":
+ Map {
+ "effect/Cause/StackTrace" => {
+ "name": "V2Session.interrupt",
+ "parent": {
+ "name": "V2Session.interrupt (definition)",
+ "parent": undefined,
+ "stack": [Function],
+ },
+ "stack": [Function],
+ },
+ ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
4029 |
4030 | const interrupted = yield* session.interrupt(sessionID).pipe(Effect.exit)
4031 |
4032 | expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
4033 | expect(interrupted).toMatchObject({ _tag: "Failure" })
4034 | expect(yield* db.select().from(SessionExecutionTable).all()).toEqual([])
^
error: expect(received).toEqual(expected)
- []
+ [
+ {
+ "assistant_message_id": "msg_0acd05ee5001XrUWhwHTphSBAJ",
+ "assistant_seq": 3,
+ "expires_at": 30000,
+ "id": "ad5fc069-9a2e-44a8-a838-71ffa7aeb034",
+ "owner_id": "a5e84c19-cdab-4696-960d-05a59b63141b",
+ "phase": "cancelling",
+ "recovery_error": null,
+ "session_id": "ses_runner_test",
+ "time_created": 0,
+ },
+ ]
- Expected - 1
+ Received + 13
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:4034:68
at ...
The claim inspection shows the exact failure: interrupting Step.Started notification makes the stream finalizer’s fragment flush defect replace the interrupt-only cause, so the runner skips interruption settlement despite the durable cancelling phase. Explicit cancellation must dominate the incidental publication defect. I’m keying settlement on the durable cancellation phase as well as the fiber cause.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts M packages/core/test/session-runner.test.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
4028 | .pipe(Effect.orDie, Effect.flatMap((row) => (row ? Effect.succeed(row) : Effect.die("Missing execution claim"))))
4029 |
4030 | const interrupted = yield* session.interrupt(sessionID).pipe(Effect.exit)
4031 |
4032 | expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
4033 | expect(interrupted).toMatchObject({ _tag: "Success" })
^
error: expect(received).toMatchObject(expected)
{
- "_tag": "Success",
+ "~effect/Effect/args": CauseImpl {
+ "reasons": [
+ Die {
+ "_tag": "Die",
+ "annotations":
+ Map {
+ "effect/Cause/StackTrace" => {
+ "name": "V2Session.interrupt",
+ "parent": {
+ "name": "V2Session.interrupt (definition)",
+ "parent": undefined,
+ "stack": [Function],
+ },
+ "stack": [Function],
+ },
+ }
+ ...
The durable-phase check still does not reach a settled cleanup result. I’m moving diagnostics ahead of the interrupt-exit assertion to distinguish “handler never entered” from “adopted-ID interruption publication failed.”
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
4028 | .pipe(Effect.orDie, Effect.flatMap((row) => (row ? Effect.succeed(row) : Effect.die("Missing execution claim"))))
4029 |
4030 | const interrupted = yield* session.interrupt(sessionID).pipe(Effect.exit)
4031 |
4032 | expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
4033 | expect(yield* db.select().from(SessionExecutionTable).all()).toEqual([])
^
error: expect(received).toEqual(expected)
- []
+ [
+ {
+ "assistant_message_id": "msg_0acd19947001quNDlcxp4AXj4y",
+ "assistant_seq": 3,
+ "expires_at": 30000,
+ "id": "11bf28ba-3f60-4225-9240-15cc8b8ea873",
+ "owner_id": "3276a0cb-bc3e-485b-9113-a90a34f8215c",
+ "phase": "cancelling",
+ "recovery_error": null,
+ "session_id": "ses_runner_test",
+ "time_created": 0,
+ },
+ ]
- Expected - 1
+ Received + 13
at toEqual (unknown:1:1)
at ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
360: ),
361: Effect.ensuring(withPublication(publisher.flush())),
362: )
363:
364: return yield* Effect.uninterruptibleMask((restore) =>
365: Effect.gen(function* () {
366: yield* updateExecution({
367: phase: "unknown",
368: assistant_message_id: null,
369: })
370: const stream = yield* restore(providerStream).pipe(Effect.exit)
371: const failure =
372: stream._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(stream.cause)) : undefined
373: if (
374: recoverOverflow &&
375: !publisher.hasAssistantStarted() &&
376: isContextOverflowFailure(overflowFailure ?? failure) &&
377: (yield* restore(
378: recoverOverflow({
379: sessionID: session.id,
380: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
482: if (
483: stream._tag === "Failure" &&
484: (Cause.hasInterrupts(stream.cause) || (yield* cancellationRequested()))
485: )
486: return yield* settleInterruption(stream.cause)
487: if (
488: settled._tag === "Failure" &&
489: (Cause.hasInterrupts(settled.cause) || (yield* cancellationRequested()))
490: )
491: return yield* settleInterruption(settled.cause)
492: if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
493: const failure = Cause.squash(settled.cause)
494: const message = failure instanceof Error ? failure.message : String(failure)
495: yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
496: }
497: if (llmFailure || ...
The settlement handler is selected, but the claim never reaches cancelled. The remaining suspect is publisher state: the interrupted startAssistant() call may later resume its post-publish assignments during listener cleanup and conflict with the adopted ID path. I’m checking emitted events before changing ownership logic.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
4041 | yield* db
4042 | .select({ data: EventTable.data })
4043 | .from(EventTable)
4044 | .where(eq(EventTable.type, "session.next.step.interrupted.1"))
4045 | .all(),
4046 | ).toEqual([expect.objectContaining({ data: expect.objectContaining({ assistantMessageID: claim.assistant_message_id }) })])
^
error: expect(received).toEqual(expected)
- [
- ObjectContaining {
- "data": ObjectContaining {
- "assistantMessageID": "msg_0acd275f0001pueDPM9m4Tk3vO",
- },
- },
- ]
+ []
- Expected - 7
+ Received + 1
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:4046:9
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/claim.ts</path>
<type>file</type>
<content>
1: export * as SessionExecutionClaim from "./claim"
2:
3: import { and, eq, gt, notInArray } 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<
18: Pick<typeof SessionExecutionTable.$inferInsert, "assistant_message_id" | "assistant_seq" | "phase">
19: >
20:
21: export class Lost extends Error {
22: constructor(readonly claim: Claim) {
23: super(`Session execution claim lost: ${claim.executionID}`)
24: }
25: }
26:
27: const where = ...
The event trace isolates the failure: the provider registered an in-memory text fragment before durable Step.Started, but durable Text.Started never committed. After adopting the durable assistant ID, generic flush() tries to end that nonexistent fragment and aborts before Step.Interrupted. The adoption path must discard pre-start fragment buffers rather than project them as durable content.
Success. Updated the following files: M packages/core/src/session/runner/publish-llm-event.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
4041 | yield* db
4042 | .select({ data: EventTable.data })
4043 | .from(EventTable)
4044 | .where(eq(EventTable.type, "session.next.step.interrupted.1"))
4045 | .all(),
4046 | ).toEqual([expect.objectContaining({ data: expect.objectContaining({ assistantMessageID: claim.assistant_message_id }) })])
^
error: expect(received).toEqual(expected)
- [
- ObjectContaining {
- "data": ObjectContaining {
- "assistantMessageID": "msg_0acd377fa001tWy5i25TFPXgIh",
- },
- },
- ]
+ []
- Expected - 7
+ Received + 1
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:4046:9
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop ...
The adopted path now reaches cleanup far enough to remove the claim, but no interruption event appears. I’m inspecting the complete durable event sequence and interrupt exit before making another change; this distinguishes a correct direct-settlement path from a failed event publication.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
[
{
type: "session.next.prompt.admitted.1",
}, {
type: "session.next.prompt.execution_requested.1",
}, {
type: "session.next.prompted.1",
}, {
type: "session.next.step.started.1",
}
] [
{
id: "b671f09e-0232-48b0-9211-8803a2018b64",
session_id: "ses_runner_test",
owner_id: "ea790626-a0d0-4053-8e2e-54a8abcb2737",
assistant_message_id: "msg_0acd3f3500015IsdUOYtRAk9KF",
phase: "cancelling",
assistant_seq: 3,
time_created: 0,
expires_at: 30000,
recovery_error: null,
}
] {
_id: "Exit",
_tag: "Failure",
cause: {
_id: "Cause",
failures: [
[Object ...]
],
},
}
[00:46:06.717] ERROR (#2): 4046 | yield* db
4047 | .select({ data: EventTable.data })
4048 | .from(EventTable)
4049 | .where(eq(EventTable.type, "session.next.step.interrupted.1"))
4050 | .all(),
4051 | ...
The runner never publishes interruption; the interrupt exit itself carries the root cause while the claim remains cancelling. I’m printing that cause once, then removing the diagnostic.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
[
{
type: "session.next.prompt.admitted.1",
}, {
type: "session.next.prompt.execution_requested.1",
}, {
type: "session.next.prompted.1",
}, {
type: "session.next.step.started.1",
}
] [
{
id: "840cfe12-6dad-46b4-84c8-711d54a5dc71",
session_id: "ses_runner_test",
owner_id: "99838f67-e212-4f2a-8ee0-3ed657083671",
assistant_message_id: "msg_0acd44a21001BJ2duUuA4dpdCZ",
phase: "cancelling",
assistant_seq: 3,
time_created: 0,
expires_at: 30000,
recovery_error: null,
}
] Error: Interrupted Session settlement failed: ses_runner_test
at causePrettyError (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:228:28)
[00:46:28.931] ERROR (#2): 4046 | yield* db
4047 | .select({ data: EventTable.data })
4048 | .from(EventTable)
4049 | .where(eq(EventTable.type, ...
The visible error is coordinator cleanup, which hides the runner’s earlier settlement defect. I’m capturing the runner cause separately; that will identify the exact publisher operation that prevents Step.Interrupted.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
[
{
type: "session.next.prompt.admitted.1",
}, {
type: "session.next.prompt.execution_requested.1",
}, {
type: "session.next.prompted.1",
}, {
type: "session.next.step.started.1",
}
] [
{
id: "4199a566-a45a-46c3-88b9-b2397d638d22",
session_id: "ses_runner_test",
owner_id: "887b1aeb-40d6-4846-87c6-901e687fa471",
assistant_message_id: "msg_0acd4bda6001GX5gdPPllWd6nS",
phase: "cancelling",
assistant_seq: 3,
time_created: 0,
expires_at: 30000,
recovery_error: null,
}
] InterruptError: All fibers interrupted without error
at V2Session.interrupt (/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:4030:42)
at V2Session.interrupt (definition) (/home/byk/Code/opencode/packages/core/src/session.ts:432:25) {
[cause]: InterruptCause: The fiber was interrupted by:
at fiber (#206)
} Error: Interrupted Session settlement failed: ses_runner_test
...
The pure interrupt exit confirms the settlement body never gains control because providerStream’s unconditional ensuring(flush) runs during interruption and faults on the pre-commit fragment. I’m changing stream cleanup to skip that generic flush only for interrupted exits; the uninterruptible cancellation handler then owns fragment reconciliation and can adopt the durable assistant ID. Typed failures and normal EOF still flush normally.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts M packages/core/test/session-runner.test.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
4042 | yield* db
4043 | .select({ data: EventTable.data })
4044 | .from(EventTable)
4045 | .where(eq(EventTable.type, "session.next.step.interrupted.1"))
4046 | .all(),
4047 | ).toEqual([expect.objectContaining({ data: expect.objectContaining({ assistantMessageID: claim.assistant_message_id }) })])
^
error: expect(received).toEqual(expected)
- [
- ObjectContaining {
- "data": ObjectContaining {
- "assistantMessageID": "msg_0acd5cd4b001aw1BSz7NzMBuEf",
- },
- },
- ]
+ []
- Expected - 7
+ Received + 1
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:4047:9
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
4024: const claim = yield* db
4025: .select()
4026: .from(SessionExecutionTable)
4027: .get()
4028: .pipe(Effect.orDie, Effect.flatMap((row) => (row ? Effect.succeed(row) : Effect.die("Missing execution claim"))))
4029:
4030: const interrupted = yield* session.interrupt(sessionID).pipe(Effect.exit)
4031: const runExit = yield* Fiber.await(run)
4032:
4033: expect(runExit).toMatchObject({ _tag: "Failure" })
4034: expect(
4035: yield* db
4036: .select({ data: EventTable.data })
4037: .from(EventTable)
4038: .where(eq(EventTable.type, "session.next.step.started.1"))
4039: .all(),
4040: ).toEqual([expect.objectContaining({ data: expect.objectContaining({ assistantMessageID: claim.assistant_message_id }) })])
4041: expect(
4042: yield* db
4043: ...
The interruption event is still absent. I’m checking whether the claim now clears or remains cancelling; that tells whether cancellation took a direct terminal path incorrectly or still failed before publication.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
[00:49:15.951] ERROR (#2): 4036 | .select({ data: EventTable.data })
4037 | .from(EventTable)
4038 | .where(eq(EventTable.type, "session.next.step.started.1"))
4039 | .all(),
4040 | ).toEqual([expect.objectContaining({ data: expect.objectContaining({ assistantMessageID: claim.assistant_message_id }) })])
4041 | expect(yield* db.select().from(SessionExecutionTable).all()).toEqual([])
^
error: expect(received).toEqual(expected)
- []
+ [
+ {
+ "assistant_message_id": "msg_0acd6d642001C0tvg1ECbHw6ZG",
+ "assistant_seq": 3,
+ "expires_at": 30000,
+ "id": "142fe6f0-cf21-4763-b6df-e6a897ed1359",
+ "owner_id": "ff35580f-a135-4f4b-8c2a-c32125ad0dfc",
+ "phase": "cancelling",
+ "recovery_error": null,
+ "session_id": "ses_runner_test",
+ "time_created": 0,
+ },
+ ]
- Expected - 1
+ Received + 13
at toEqual (unknown:1:1)
at ...
The robust boundary is in EventV2, not cancellation cleanup: publisher memory must update after the transaction commits and before post-commit notification becomes interruptible. I’m adding an internal post-commit hook that runs uninterruptibly between those two stages, then moving assistant-start and terminal flags there. This removes both Step.Started and Step.Failed windows without making listeners uninterruptible.
Found 4 matches
/home/byk/Code/opencode/packages/core/src/event.ts:
Line 118: export interface PublishOptions {
Line 130: options?: PublishOptions,
Line 369: function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
Line 419: function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
108: })
109:
110: export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
111: "EventV2.SubscriberOverflow",
112: { capacity: Schema.Int },
113: ) {}
114:
115: export const define = Event.define
116: export const versionedType = Event.versionedType
117:
118: export interface PublishOptions {
119: readonly id?: ID
120: readonly metadata?: Record<string, unknown>
121: readonly location?: Location.Ref
122: /** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
123: readonly commit?: (seq: number) => Effect.Effect<void>
124: }
125:
126: export interface Interface {
127: readonly publish: <D extends Definition>(
128: definition: D,
129: data: Data<D>,
130: options?: PublishOptions,
131: ) => Effect.Effect<Payload<D>>
132: readonly subscribe: <D extends ...
The hook will be narrow and internal: afterCommit runs only for a newly committed durable event, never on replay, and before listener notification. I’m keeping durable projection in commit; afterCommit only synchronizes publisher memory with the committed fact.
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
205: function commitDurableEvent(
206: definition: Definition,
207: event: Payload,
208: input?: {
209: readonly seq: number
210: readonly aggregateID: string
211: readonly ownerID?: string
212: readonly strictOwner?: boolean
213: },
214: commit?: (seq: number) => Effect.Effect<void>,
215: ) {
216: return Effect.gen(function* () {
217: const durable = definition?.durable
218: if (durable) {
219: const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
220: if (typeof aggregateID !== "string") {
221: yield* Effect.die(
222: new InvalidDurableEventError({
223: type: event.type,
224: message: `Expected string aggregate field ${durable.aggregate}`,
225: ...
Found 1 matches /home/byk/Code/opencode/packages/core/src/event.ts: Line 427: return yield* publishEvent(
<path>/home/byk/Code/opencode/packages/core/src/event.ts</path>
<type>file</type>
<content>
419: function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
420: return Effect.gen(function* () {
421: const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
422: const location =
423: options?.location ??
424: (serviceLocation
425: ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
426: : undefined)
427: return yield* publishEvent(
428: definition,
429: {
430: id: options?.id ?? ID.create(),
431: ...(options?.metadata ? { metadata: options.metadata } : {}),
432: type: definition.type,
433: ...(location ? { location } : {}),
434: data,
435: } as Payload<D>,
436: ...
The durable hook is the right ownership boundary, but I’m keeping it narrow: assistant identity and terminal flags only. I’m also reordering text/reasoning fragment registration after durable assistant start, so interruption at Step.Started cannot leave an in-memory fragment whose start event never committed.
Success. Updated the following files: M packages/core/src/event.ts M packages/core/src/session/runner/publish-llm-event.ts
Assistant identity now synchronizes at the durable commit boundary, and text/reasoning buffers start only afterward. Terminal flags use the same boundary. This removes the state gap rather than compensating in cleanup. I’m compiling and rerunning both deterministic notification-window tests.
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
4036 | .select({ data: EventTable.data })
4037 | .from(EventTable)
4038 | .where(eq(EventTable.type, "session.next.step.started.1"))
4039 | .all(),
4040 | ).toEqual([expect.objectContaining({ data: expect.objectContaining({ assistantMessageID: claim.assistant_message_id }) })])
4041 | expect(yield* db.select().from(SessionExecutionTable).all()).toEqual([])
^
error: expect(received).toEqual(expected)
- []
+ [
+ {
+ "assistant_message_id": "msg_0acda4876001zhQlqsE710jqBk",
+ "assistant_seq": 3,
+ "expires_at": 30000,
+ "id": "990c5a60-d343-4c8e-a7f5-5b5295e74ec1",
+ "owner_id": "cbbabec7-68d9-4510-a5fa-2c5ae4c5914d",
+ "phase": "cancelling",
+ "recovery_error": null,
+ "session_id": "ses_runner_test",
+ "time_created": 0,
+ },
+ ]
- Expected - 1
+ Received + 13
at toEqual (unknown:1:1)
at ...
The commit-boundary hook works, but the claim still stays cancelling with a pure interrupt exit, which means interruptAssistant is returning before its commit hook. I’m inspecting its current generated control flow and publisher flags rather than changing ownership again.
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
84: let assistantActive = false
85: let assistantFailed = false
86: let providerFailed = false
87: let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
88:
89: const startAssistant = Effect.fnUntraced(function* () {
90: if (assistantMessageID !== undefined) return assistantMessageID
91: const created = SessionMessage.ID.create()
92: yield* publishEvent(
93: SessionEvent.Step.Started,
94: {
95: sessionID: input.sessionID,
96: agent: input.agent,
97: model: input.model,
98: assistantMessageID: created,
99: timestamp: yield* timestamp,
100: snapshot: input.snapshot,
101: },
102: {
103: commit: (seq: number) => input.startCommit(created, seq),
104: }.commit,
105: () =>
106: Effect.sync(() => {
107: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
245: }),
246: )
247: })
248:
249: const interruptAssistant = Effect.fnUntraced(function* (
250: message: string,
251: commit: () => Effect.Effect<void> = input.eventCommit,
252: existingAssistantMessageID?: SessionMessage.ID,
253: ) {
254: if (assistantFailed) return
255: const adoptDurableAssistant = existingAssistantMessageID !== undefined && assistantMessageID === undefined
256: if (existingAssistantMessageID !== undefined) {
257: if (assistantMessageID !== undefined && assistantMessageID !== existingAssistantMessageID)
258: return yield* Effect.die("Cancellation assistant identity changed")
259: assistantMessageID = existingAssistantMessageID
260: assistantActive = true
261: }
262: if (!adoptDurableAssistant) yield* flush()
263: const targetAssistantMessageID = yield* ...
The exact deadlock is the publisher semaphore/state boundary: after committed Step.Started notification is interrupted, cancellation must not re-enter the same publisher path. I’m removing the broader EventV2 hook experiment and handling only this proven case by publishing Step.Interrupted directly against the durable assistant ID and exact cancellation commit. Normal active assistants still use publisher flushing.
Success. Updated the following files: M packages/core/src/event.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/runner/llm.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
4036 | .select({ data: EventTable.data })
4037 | .from(EventTable)
4038 | .where(eq(EventTable.type, "session.next.step.started.1"))
4039 | .all(),
4040 | ).toEqual([expect.objectContaining({ data: expect.objectContaining({ assistantMessageID: claim.assistant_message_id }) })])
4041 | expect(yield* db.select().from(SessionExecutionTable).all()).toEqual([])
^
error: expect(received).toEqual(expected)
- []
+ [
+ {
+ "assistant_message_id": "msg_0acdc41f4001PevD38Id4T4uJg",
+ "assistant_seq": 3,
+ "expires_at": 30000,
+ "id": "c3c4c872-7d5d-46f8-b85d-98dbfb248752",
+ "owner_id": "65a9573b-54e0-4afd-8ba7-e0d3e9d2149b",
+ "phase": "cancelling",
+ "recovery_error": null,
+ "session_id": "ses_runner_test",
+ "time_created": 0,
+ },
+ ]
- Expected - 1
+ Received + 13
at toEqual (unknown:1:1)
at ...
The direct event path is not reached, confirming control never returns from the restored stream under this listener interruption. I’m verifying Effect v4 interruption semantics at this exact uninterruptibleMask boundary; the fix must capture the exit without allowing the pending interrupt to abort the continuation.
No files found
No files found
Found 41 matches
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/sql/sqlite-wasm/src/SqliteClient.ts:
Line 259: const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
Line 426: const transactionAcquirer = Effect.uninterruptibleMask(Effect.fnUntraced(function*(restore) {
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/sql/sqlite-react-native/src/SqliteClient.ts:
Line 197: const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/sql/sqlite-node/src/SqliteClient.ts:
Line 289: const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/sql/sqlite-do/src/SqliteClient.ts:
Line 272: const transactionAcquirer = Effect.uninterruptibleMask((restore) => ...
<path>/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Effect.ts</path>
<type>file</type>
<content>
7325: * @category interruption
7326: * @since 2.0.0
7327: */
7328: export const uninterruptible: <A, E, R>(
7329: self: Effect<A, E, R>
7330: ) => Effect<A, E, R> = internal.uninterruptible
7331:
7332: /**
7333: * Disables interruption and provides a restore function to restore the
7334: * interruptible state within the effect.
7335: *
7336: * **Example** (Restoring interruption in protected regions)
7337: *
7338: * ```ts
7339: * import { Console, Effect } from "effect"
7340: *
7341: * const program = Effect.uninterruptibleMask((restore) =>
7342: * Effect.gen(function*() {
7343: * yield* Console.log("Uninterruptible phase...")
7344: * yield* Effect.sleep("1 second")
7345: *
7346: * // Restore interruptibility for this part
7347: * yield* restore(
7348: * Effect.gen(function*() {
7349: * ...
Effect v4 confirms ordinary restore(...).pipe(Effect.exit) does not guarantee post-interrupt continuation for an externally interrupted fiber. The reliable mechanism is Effect.onInterrupt, whose cleanup runs before fiber exit. I’m moving explicit cancellation settlement into that finalizer and keeping the existing post-stream branch for non-external interrupt causes.
Found 1 matches
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Effect.ts:
Line 7294: export const onInterrupt: {
<path>/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Effect.ts</path>
<type>file</type>
<content>
7278: * const task = Effect.forever(Effect.succeed("working..."))
7279: *
7280: * const program = Effect.onInterrupt(
7281: * task,
7282: * () => Console.log("Task was interrupted, cleaning up...")
7283: * )
7284: *
7285: * const fiber = Effect.runFork(program)
7286: * // Later interrupt the task
7287: * Effect.runFork(Fiber.interrupt(fiber))
7288: * // Output: Task was interrupted, cleaning up...
7289: * ```
7290: *
7291: * @category interruption
7292: * @since 2.0.0
7293: */
7294: export const onInterrupt: {
7295: <XE, XR>(
7296: finalizer: (interruptors: ReadonlySet<number>) => Effect<void, XE, XR>
7297: ): <A, E, R>(self: Effect<A, E, R>) => Effect<A, E | XE, R | XR>
7298: <A, E, R, XE, XR>(
7299: self: Effect<A, E, R>,
7300: finalizer: (interruptors: ReadonlySet<number>) => Effect<void, XE, XR>
7301: ...
I’m splitting interruption reconciliation from cause propagation and attaching reconciliation with Effect.onInterrupt to the provider stream. That finalizer runs for the external fiber interrupt even when normal post-stream control never resumes; it will publish against the durable assistant ID, move cancelling → cancelled, then preserve the original interrupt exit.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
4036 | .select({ data: EventTable.data })
4037 | .from(EventTable)
4038 | .where(eq(EventTable.type, "session.next.step.started.1"))
4039 | .all(),
4040 | ).toEqual([expect.objectContaining({ data: expect.objectContaining({ assistantMessageID: claim.assistant_message_id }) })])
4041 | expect(yield* db.select().from(SessionExecutionTable).all()).toEqual([])
^
error: expect(received).toEqual(expected)
- []
+ [
+ {
+ "assistant_message_id": "msg_0acdf4545001pS0cgHy5wUYcDy",
+ "assistant_seq": 3,
+ "expires_at": 30000,
+ "id": "0b889e08-4a8c-451f-9f41-f4e04faa10cc",
+ "owner_id": "c8d0c34e-c513-464e-809c-c2c820db29ca",
+ "phase": "cancelling",
+ "recovery_error": null,
+ "session_id": "ses_runner_test",
+ "time_created": 0,
+ },
+ ]
- Expected - 1
+ Received + 13
at toEqual (unknown:1:1)
at ...
The stream finalizer is still not completing settlement reliably under nested listener interruption. The ownership adapter is the guaranteed boundary: coordinator.interrupt() always waits for runner and finalizers before its cleanup callback. I’m moving the final fail-closed cancellation reconciliation there: if the claim remains cancelling, cleanup publishes Step.Interrupted for the durable assistant ID and atomically marks cancelled; if no assistant exists it clears directly. This removes dependence on interrupted publisher memory or stream-finalizer scheduling.
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, desc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, Duration, Effect, Exit, Layer, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { SessionExecutionClaim } from "./claim"
13: import {
14: SESSION_EXECUTION_LEASE_MS,
15: SessionExecutionTable,
16: SessionMessageTable,
17: SessionRecoveryTable,
18: } from "../sql"
19:
20: /** Current-process routing for implicit-local Locations. ...
Found 1 matches
/home/byk/Code/opencode/packages/core/src/session/execution/local.ts:
Line 186: export const node = makeGlobalNode({
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
169: const layer = Layer.effect(
170: SessionExecution.Service,
171: Effect.gen(function* () {
172: const store = yield* SessionStore.Service
173: const locations = yield* LocationServiceMap.Service
174: return yield* make({
175: run: Effect.fnUntraced(function* (claim, force, recoveryInputID) {
176: const session = yield* store.get(claim.sessionID)
177: if (!session) return yield* Effect.die(`Session not found: ${claim.sessionID}`)
178: return yield* SessionRunner.Service.use((runner) => runner.run({ ...claim, force, recoveryInputID })).pipe(
179: Effect.provide(locations.get(session.location)),
180: )
181: }),
182: })
183: }),
184: )
185:
186: export const node = makeGlobalNode({
187: service: SessionExecution.Service,
188: layer,
189: deps: [Database.node, SessionStore.node, ...
Found 16 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 248: return yield* SessionExecutionLocal.make({
/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts:
Line 48: const execution = yield* SessionExecutionLocal.make({ run: () => Effect.void })
Line 63: const execution = yield* SessionExecutionLocal.make({
Line 91: const execution = yield* SessionExecutionLocal.make({
Line 106: const execution = yield* SessionExecutionLocal.make({
Line 121: const first = yield* SessionExecutionLocal.make({
Line 125: const second = yield* SessionExecutionLocal.make({
Line 145: const first = yield* SessionExecutionLocal.make({
Line 149: const second = yield* SessionExecutionLocal.make({
Line 171: const execution = yield* SessionExecutionLocal.make({
Line 197: const execution = yield* SessionExecutionLocal.make({
Line 221: const execution = ...
The blocked notification still holds the publisher semaphore while onInterrupt cleanup runs, so generic reconciliation self-blocks before reaching the direct assistant path. I’m moving the durable-assistant/no-publisher-state case ahead of every semaphore-backed reconciliation step; at this boundary no content or tool event can have committed after Step.Started.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
4036 | .select({ data: EventTable.data })
4037 | .from(EventTable)
4038 | .where(eq(EventTable.type, "session.next.step.started.1"))
4039 | .all(),
4040 | ).toEqual([expect.objectContaining({ data: expect.objectContaining({ assistantMessageID: claim.assistant_message_id }) })])
4041 | expect(yield* db.select().from(SessionExecutionTable).all()).toEqual([])
^
error: expect(received).toEqual(expected)
- []
+ [
+ {
+ "assistant_message_id": "msg_0ace214a7001HFU8P2Z0ctGP2v",
+ "assistant_seq": 3,
+ "expires_at": 30000,
+ "id": "59c494ae-79b6-4651-b5a8-a9aefe0d2791",
+ "owner_id": "cdf7957e-6a33-42a2-b34c-775a695ce66e",
+ "phase": "cancelling",
+ "recovery_error": null,
+ "session_id": "ses_runner_test",
+ "time_created": 0,
+ },
+ ]
- Expected - 1
+ Received + 13
at toEqual (unknown:1:1)
at ...
The runner-level finalizer is not a dependable external-interrupt boundary in Effect v4 for this nested stream/listener stack. I’m moving only the last-resort cancelling reconciliation to SessionExecutionLocal’s coordinator cleanup, which always runs after the runner fiber and all finalizers settle. It will inspect the durable assistant, publish one Step.Interrupted with exact claim settlement, then clear the claim; runner-owned reconciliation remains the normal path.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
220: Effect.succeed([
221: new Config.Document({
222: type: "document",
223: info: new Config.Info({
224: compaction: new ConfigCompaction.Info({
225: buffer: 3_000,
226: keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
227: }),
228: }),
229: }),
230: ]),
231: }),
232: )
233: const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
234: [Snapshot.node, Snapshot.noopLayer],
235: [LayerNodePlatform.llmClient, client],
236: [SessionRunnerModel.node, models],
237: [SystemContextRegistry.node, systemContext],
238: [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
239: [SkillGuidance.node, skillGuidance],
240: [ReferenceGuidance.node, referenceGuidance],
241: [PermissionV2.node, permission],
242: [Config.node, ...
I’m implementing the cleanup fallback as an exact durable transaction, not as a second runner attempt. It applies only after coordinator.interrupt() has joined the runner and finalizers: terminal assistant means settle and clear; incomplete durable assistant means publish one Step.Interrupted with cancelling → cancelled, then clear; no assistant means clear.
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
90: entry.pendingWake = true
91: return
92: }
93:
94: const next = makeEntry()
95: active.set(key, next)
96: start(key, next, false)
97: })
98:
99: const interrupt = (key: Key, cleanup = Effect.void): Effect.Effect<void> =>
100: Effect.suspend(() => {
101: const entry = active.get(key)
102: const owner = entry?.owner
103: if (entry === undefined || owner === undefined) return cleanup
104: entry.stopping = true
105: entry.pendingWake = false
106: return Effect.gen(function* () {
107: yield* Fiber.interrupt(owner)
108: const cleanupExit = yield* Effect.exit(cleanup)
109: yield* Effect.sync(() => {
110: if (active.get(key) !== entry) return
111: const exit = entry.exit
112: if (!exit) throw new ...
The cleanup effect executes after Fiber.interrupt(owner) and before any pending successor starts, so it is the correct ownership boundary. I’m adding event access to the execution adapter and centralizing the exact cancellation finalization there.
No files found
No files found
Found 4 matches
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 503: deps: [Database.node, EventV2.node, SessionProjector.node, SessionExecution.node],
/home/byk/Code/opencode/packages/core/src/session/projector.ts:
Line 542: export const node = makeGlobalNode({ name: "session-projector", layer, deps: [EventV2.node, Database.node] })
/home/byk/Code/opencode/packages/core/src/session/todo.ts:
Line 78: export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Database.node] })
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 680: EventV2.node,
This adds a Core-to-Core dependency only: SessionExecutionLocal already owns the durable claim lifecycle, and EventV2/SessionEvent remain Schema/Core. No Server dependency enters Core.
Success. Updated the following files: M packages/core/src/session/execution/local.ts
$ tsgo --noEmit
src/session/execution/local.ts(140,14): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type 'SQLiteColumn<{ name: string; tableName: "session_message"; dataType: "string"; data: string & Brand<"Session.Message.ID">; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: true; ... 5 more ...; generated: undefined; }, {}>' is not assignable to parameter of type 'never'.
src/session/execution/local.ts(153,9): error TS2322: Type '(string & Brand<"Session.Message.ID">) | null' is not assignable to type 'string & Brand<"Session.Message.ID">'.
Type 'null' is not assignable to type 'string & Brand<"Session.Message.ID">'.
Type 'null' is not assignable to type 'string'.
src/session/execution/local.ts(154,9): error TS2322: Type 'number' is not assignable to type 'Utc'.
test/session-execution-local.test.ts(45,58): error TS2345: Argument of type '() => Effect.Effect<void, EffectDrizzleQueryError | ...
The fallback compiles against production wiring but unnecessarily made every low-level execution test provide EventV2. I’m making event access optional at construction and required only for the incomplete-assistant fallback. I’m also tightening the phase/null narrowing and using the event schema’s UTC timestamp type.
Success. Updated the following files: M packages/core/src/session/execution/local.ts
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
3933 | Effect.exit,
3934 | Effect.ensuring(db.run(sql`DROP TRIGGER fail_interrupted_tool_publication`).pipe(Effect.orDie)),
3935 | )
3936 | toolExecutionGate = undefined
3937 |
3938 | expect(Exit.isFailure(interrupted)).toBe(true)
^
error: expect(received).toBe(expected)
Expected: true
Received: false
at toBe (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/session-runner.test.ts:3938:43
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:380:20)
at ...
The cleanup fallback closes the original publication-defect case after finalizers, so interrupt() now succeeds instead of retaining a stranded cancelling claim. The old regression’s failure expectation is stale; I’m updating it to assert durable tool/Step interruption and claim removal, while preserving the separate regression that proves ownership stays retained until cleanup can finish.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3885: error: { type: "unknown", message: "Tool execution outcome unknown after interruption" },
3886: },
3887: },
3888: ],
3889: },
3890: ])
3891:
3892: yield* replaySessionProjection(sessionID)
3893:
3894: expect(yield* session.context(sessionID)).toMatchObject([
3895: { type: "user", text: "Interrupt blocked tool" },
3896: { type: "assistant", content: [{ type: "tool", id: "call-before-interrupt", state: { status: "error" } }] },
3897: ])
3898: requests.length = 0
3899: responseStream = undefined
3900: response = []
3901: yield* session.resume(sessionID)
3902: expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
3903: }),
3904: )
3905:
3906: it.effect("retains cancellation ownership when ...
The fallback publishes Step.Interrupted; its projector reconciles unresolved tools atomically, so it does not need the failed per-tool event. I’m updating the regression to require a successful interrupt, no claim, and the tool projected as an error.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 5 pass 105 filtered out 0 fail 21 expect() calls Ran 5 tests across 1 file. [4.24s] 12 pass 0 fail 23 expect() calls Ran 12 tests across 1 file. [3.74s] bun test v1.3.14 (0d9b296a)
The two newly exposed commit-to-notification windows now pass, as do all execution-adapter lifecycle tests. I’m moving to the remaining contract and schema work: verify the recovery scan index artifacts, regenerate both current client surfaces with the prescribed command, then run the complete recovery gate before broader validation.
[
{
"content": "Preserve cancelling claims when interruption settlement defects",
"status": "completed",
"priority": "high"
},
{
"content": "Persist explicit resume intent before pending input promotion",
"status": "completed",
"priority": "high"
},
{
"content": "Add bounded recovery claim scan index",
"status": "in_progress",
"priority": "high"
},
{
"content": "Regenerate current client contracts through packages/client",
"status": "pending",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
Found 4 matches
/home/byk/Code/opencode/packages/core/schema.json:
Line 2306: "name": "session_execution_recovery_scan_idx",
/home/byk/Code/opencode/packages/core/src/database/schema.gen.ts:
Line 289: `CREATE INDEX \`session_execution_recovery_scan_idx\` ON \`session_execution\` (\`recovery_error\`,\`expires_at\`,\`time_created\`,\`id\`);`,
/home/byk/Code/opencode/packages/core/src/database/migration/20260914170650_session-recovery.ts:
Line 49: `CREATE INDEX \`session_execution_recovery_scan_idx\` ON \`session_execution\` (\`recovery_error\`, \`expires_at\`, \`time_created\`, \`id\`);`,
/home/byk/Code/opencode/packages/core/src/session/sql.ts:
Line 240: index("session_execution_recovery_scan_idx").on(
No files found
Reading config file '/tmp/opencode-core-migration-check-R4opKj/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-R4opKj/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-R4opKj/full/20260917011442_schema/migration.sql 🚀
The scan index is synchronized across the squashed migration, source schema, generated schema, and snapshot; migration consistency passes with no incremental delta. I’m running the prescribed client generator now and will audit its output so only schema-derived changes enter the candidate.
[
{
"content": "Preserve cancelling claims when interruption settlement defects",
"status": "completed",
"priority": "high"
},
{
"content": "Persist explicit resume intent before pending input promotion",
"status": "completed",
"priority": "high"
},
{
"content": "Add bounded recovery claim scan index",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate current client contracts through packages/client",
"status": "in_progress",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
$ bun run script/build.ts
Found 6 matches /home/byk/Code/opencode/packages/client/src/generated/types.ts: Line 778: readonly requestExecution?: boolean Line 784: readonly type: "session.next.prompt.execution_requested" Line 908: readonly type: "session.next.step.recovered" Line 1272: readonly requestExecution?: boolean Line 1278: readonly type: "session.next.prompt.execution_requested" Line 1402: readonly type: "session.next.step.recovered"
No files found
/home/byk/Code/opencode/packages/client/src/generated-effect/index.ts /home/byk/Code/opencode/packages/client/src/generated-effect/client.ts /home/byk/Code/opencode/packages/client/src/generated-effect/client-error.ts /home/byk/Code/opencode/packages/client/src/generated-effect/.httpapi-codegen.json
No files found
<path>/home/byk/Code/opencode/packages/client/src/generated-effect/index.ts</path>
<type>file</type>
<content>
1: export { ClientError } from "./client-error"
2: export * as OpenCode from "./client"
(End of file - total 2 lines)
</content>
<path>/home/byk/Code/opencode/packages/client/src/generated-effect/client.ts</path>
<type>file</type>
<content>
1: // Generated by @opencode-ai/httpapi-codegen. Do not edit.
2: import { Effect, Stream, Schema } from "effect"
3: import { Sse } from "effect/unstable/encoding"
4: import { HttpClientError } from "effect/unstable/http"
5: import { HttpApiClient } from "effect/unstable/httpapi"
6: import { ClientApi } from "../contract"
7: import { ClientError } from "./client-error"
8:
9: type RawClient = HttpApiClient.ForApi<typeof ClientApi>
10:
11: const mapClientError = <E>(error: E) =>
12: HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
13: ? new ClientError({ cause: error })
14: : error
15:
16: const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
17: raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
18:
19: const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
20:
21: ...
The generated current client now includes requestExecution, PromptExecutionRequested, Step.Interrupted, and Step.Recovered; the Effect client consumes the generated contract rather than duplicating event unions. Before the full gate, I’m removing the ineffective runner-level onInterrupt reconciliation attempt so cancellation has one guaranteed fallback at coordinator cleanup, not two competing finalizers.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 250 pass 0 fail 743 expect() calls Ran 250 tests across 8 files. [45.00s]
The complete recovery gate now passes: 250 tests, 743 assertions, zero failures, plus Core typecheck. The cancellation fallback is single-owner at coordinator cleanup, and the generated current client is updated. I’m auditing generated file scope, then running all affected package checks and the full Core suite on this exact tree.
[
{
"content": "Preserve cancelling claims when interruption settlement defects",
"status": "completed",
"priority": "high"
},
{
"content": "Persist explicit resume intent before pending input promotion",
"status": "completed",
"priority": "high"
},
{
"content": "Add bounded recovery claim scan index",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate current client contracts through packages/client",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "in_progress",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
M .lore.md M AGENTS.md M packages/client/src/generated/types.ts M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M ...
<path>/home/byk/Code/opencode/packages/client/package.json</path>
<type>file</type>
<content>
1: {
2: "$schema": "https://json.schemastore.org/package.json",
3: "name": "@opencode-ai/client",
4: "private": true,
5: "type": "module",
6: "license": "MIT",
7: "exports": {
8: ".": "./src/index.ts",
9: "./effect": "./src/effect.ts"
10: },
11: "scripts": {
12: "generate": "bun run script/build.ts",
13: "check:generated": "bun run generate && git diff --exit-code -- src/generated src/generated-effect",
14: "test": "bun test --timeout 5000",
15: "typecheck": "tsgo --noEmit"
16: },
17: "dependencies": {
18: "@opencode-ai/schema": "workspace:*",
19: "@opencode-ai/protocol": "workspace:*"
20: },
21: "peerDependencies": {
22: "effect": "4.0.0-beta.83"
23: },
24: "peerDependenciesMeta": {
25: "effect": {
26: "optional": true
27: }
28: },
29: "devDependencies": {
30: "@effect/platform-node": "catalog:",
31: ...
Generated scope is one intended new file, packages/client/src/generated/types.ts; generated-effect changed no bytes because it references the contract. Unrelated .lore.md, AGENTS.md, and profiling files remain untouched. I’m running the full Core suite and six affected package typechecks plus generated-client and manifest gates.
<task id="ses_f530ae2d8ffeTtcPnPzmzIZYi9" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Full validation is running in an isolated background agent: Core suite, Schema manifest, six package typechecks, and generated-client consistency. I’ll rebuild and smoke the exact artifact only after those source gates complete.
Validation is running in a background agent. I’ll continue automatically when it completes.
| Directory | Command | Exit | Result |
|---|---|---:|---|
| packages/core | bun test --printConsoleTrace | 0 | 1,187 passed, 0 failed, 3,332 assertions across 145 files in 85.32s |
| packages/schema | bun test --printConsoleTrace test/event-manifest.test.ts | 0 | 2 passed, 0 failed, 23 assertions across 1 file in 304ms |
| packages/core | bun typecheck | 0 | Passed |
| packages/schema | bun typecheck | 0 | Passed |
| packages/sdk/js | bun typecheck | 0 | Passed |
| packages/server | bun typecheck | 0 | Passed |
| packages/opencode | bun typecheck | 0 | Passed |
| packages/client | bun typecheck | 0 | Passed |
| packages/client | bun run check:generated | 1 | Failed: generated client is stale |
All typechecks ran tsgo --noEmit. They did not report durations.
check:generated ran:
bun run generate
bun run script/build.ts
git diff --exit-code -- src/generated src/generated-effect
It found stale generated output in packages/client/src/generated/types.ts, including missing generated definitions for:
requestExecution?: booleansession.next.prompt.execution_requestedsession.next.step.interruptedsession.next.step.recoveredThe generation command wrote the generated difference before failing. I did not clean or revert it because that would mutate the worktree further.
The Core test suite logged an expected HTTP error for the missing skill fixture and Linux watcher informational messages; neither caused a test failure. </task_result> </task>
All source gates passed: 1,187 Core tests, Schema manifest, and six package typechecks. check:generated failed only because it compares generated output against Git, and these intended generated changes are uncommitted; I’m verifying generator idempotence by hashing before and after a second prescribed generation, then I’ll build and smoke the exact artifact.
4e515cd762cc23cc00a55ce2a81752f087224fbfa6ec99ca71f31586cdce9ef9 src/generated/types.ts dfa4df633b11ea8de14bb9fe34121ceaca0944b1d5d23f04c3f36b0b06fe795f src/generated-effect/client.ts aba3c7acc65d55713aaa166c8130894f0cdd9890ae0ef0716d849ed888f750ee src/generated-effect/client-error.ts 7bbbdcc249c1f6b698a491e13b071d2d7f0a277712173e7e35d6b7bde28bcd84 src/generated-effect/index.ts
$ bun run script/build.ts 4e515cd762cc23cc00a55ce2a81752f087224fbfa6ec99ca71f31586cdce9ef9 src/generated/types.ts dfa4df633b11ea8de14bb9fe34121ceaca0944b1d5d23f04c3f36b0b06fe795f src/generated-effect/client.ts aba3c7acc65d55713aaa166c8130894f0cdd9890ae0ef0716d849ed888f750ee src/generated-effect/client-error.ts 7bbbdcc249c1f6b698a491e13b071d2d7f0a277712173e7e35d6b7bde28bcd84 src/generated-effect/index.ts
Generation is byte-for-byte idempotent across both client surfaces; the nonzero check was expected because Git still reflects the pre-change generated contract. The exact source tree is green. I’m rebuilding the standalone server now, then running the disposable authenticated migration/restart recovery smoke against that binary.
<path>/home/byk/Code/opencode/packages/opencode/package.json</path>
<type>file</type>
<content>
1: {
2: "$schema": "https://json.schemastore.org/package.json",
3: "version": "1.18.30",
4: "name": "opencode",
5: "type": "module",
6: "license": "MIT",
7: "private": true,
8: "scripts": {
9: "typecheck": "tsgo --noEmit",
10: "test": "bun test --timeout 30000 --only-failures",
11: "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
12: "bench:test": "bun run script/bench-test-suite.ts",
13: "profile:test": "bun run script/profile-test-files.ts",
14: "build": "bun run script/build.ts",
15: "build:server": "node script/build-server.mjs",
16: "dev": "bun run ./src/index.ts",
17: "dev:temporary": "bun run ./src/temporary.ts"
18: ...
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0acfce8a0001h2Ujj50rT7ESdB dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
File not found: /tmp/opencode/session-recovery-v15-smoke.mjs
The exact SEA build succeeded with only the known chunk-size and note-injection warnings. The previous disposable smoke script no longer exists, so I’m reconstructing it from the validated v15 procedure under /tmp/opencode, using a new namespace, bounded probes, isolated XDG paths/database, and no live-service access.
/tmp/opencode/session-recovery-v18-smoke.mjs
<path>/tmp/opencode/session-recovery-v18-smoke.mjs</path>
<type>file</type>
<content>
1: import { execFileSync, spawn } from "node:child_process"
2: import { mkdirSync, rmSync } from "node:fs"
3: import { join } from "node:path"
4:
5: const executable = "/home/byk/Code/opencode/packages/opencode/dist/server/opencode-server"
6: const root = "/tmp/opencode/session-recovery-final-v18"
7: const database = join(root, "opencode.db")
8: const workspace = join(root, "workspace")
9: const password = "disposable-recovery-smoke"
10:
11: rmSync(root, { recursive: true, force: true })
12: for (const directory of [root, workspace, "home", "config", "data", "state", "cache"])
13: mkdirSync(directory.startsWith("/") ? directory : join(root, directory), { recursive: true })
14:
15: const sql = (statement) =>
16: execFileSync("sqlite3", ["-json", database, statement], { encoding: "utf8" }).trim()
17: const rows = (statement) => JSON.parse(sql(statement) || "[]")
18: const port = () => 10_000 + ...
A complete v18 disposable harness exists and already checks the new recovery-scan index as well as health, schema defaults, exactly-once continuation, and third-restart idempotence. I’m running it against the just-built binary and hashing that exact artifact.
{
"initial": {
"port": 17281,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
}
],
"indexes": [
{
"name": "session_execution_recovery_scan_idx",
"sql": "CREATE INDEX `session_execution_recovery_scan_idx` ON `session_execution` (`recovery_error`,`expires_at`,`time_created`,`id`)"
}
],
"before": {
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"message_count": 0,
"event_count": 0
},
"first": {
"port": 25151,
"health": {
...
753b05afb86195246b211237ca5b11e26a7c08da863d70a711da2c93ee453c8a packages/opencode/dist/server/opencode-server 863782 /usr/bin/bash -c sha256sum packages/opencode/dist/server/opencode-server && pgrep -af '/home/byk/Code/opencode/packages/opencode/dist/server/opencode-server' || true
The exact artifact passed four isolated launches, authenticated health, migration/index checks, one recovery continuation, and duplicate-free restart; no disposable server remains. I’m freezing the package-only candidate now, excluding .lore.md, AGENTS.md, and profiling files, then I’ll verify clean application to the immutable base before starting replacement correctness and security reviews.
M .lore.md M AGENTS.md M packages/client/src/generated/types.ts M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M ...
No files found
drwxr-xr-x 6 byk byk 4096 Sep 17 01:31 /tmp/opencode
18af75f50e1f6aabfbaa61da329a8c14c176b405c63e11fce8f944cc664b32e4 /tmp/opencode/session-recovery-final-v19.patch 33 packages/client/src/generated/types.ts | 72 + packages/core/schema.json | 615 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 219 +- packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 504 ++++ packages/core/src/session/run-coordinator.ts | 35 packages/core/src/session/runner/index.ts | 4 ...
The first freeze exposed one pathspec omission: packages/core/src/session.ts sits beside the packages/core/src/session/ directory and was not included. I’m rebuilding the freeze with that file explicitly; the correct candidate should contain 34 paths, including both untracked production modules and generated client types.
a2bce01664538243cb8ba16c6ca8f0364c6f79463ad6677567044a1b3e1e74fe /tmp/opencode/session-recovery-final-v19.patch 34 packages/client/src/generated/types.ts | 72 + packages/core/schema.json | 615 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session.ts | 10 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 219 +- packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 504 ++++ packages/core/src/session/run-coordinator.ts | 35 ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
The corrected v19 freeze contains 34 paths, applies cleanly to immutable base ab9408c81c, and passes whitespace checks in a detached worktree. I’m starting fresh independent correctness and security reviews against exact patch a2bce016…e74fe and SEA 753b05af…53c8a, with special focus on coordinator-cleanup cancellation settlement and explicit-resume crash recovery.
<task id="ses_f52ff5084ffe7snDvSQgJjIjEs" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f52fef029ffe2a2CGDN98jGF2v" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
[
{
"content": "Preserve cancelling claims when interruption settlement defects",
"status": "completed",
"priority": "high"
},
{
"content": "Persist explicit resume intent before pending input promotion",
"status": "completed",
"priority": "high"
},
{
"content": "Add bounded recovery claim scan index",
"status": "completed",
"priority": "high"
},
{
"content": "Regenerate current client contracts through packages/client",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "in_progress",
"priority": "high"
}
]
Frozen v19 is under independent correctness and security review.
a2bce01664538243cb8ba16c6ca8f0364c6f79463ad6677567044a1b3e1e74fe753b05afb86195246b211237ca5b11e26a7c08da863d70a711da2c93ee453c8aSessionRunner publishes Step.Interrupted and atomically changes cancelling -> cancelled inside the runner at packages/core/src/session/runner/llm.ts:307-374. The enclosing scope does not finish until packages/core/src/session/runner/llm.ts:569, so runner finalizers can still be active after cancellation has become durable.
The regression at packages/core/test/session-runner.test.ts:4055-4106 confirms the wrong ordering: it observes one durable Step.Interrupted at lines 4078-4084 while the injected runner finalizer remains blocked, then releases the finalizer at line 4103.
SessionRunCoordinator correctly joins the owner before cleanup at packages/core/src/session/run-coordinator.ts:107-108, but cleanup often finds cancelled and only clears the claim at packages/core/src/session/execution/local.ts:130-131. It does not defer publication and the cancelling -> cancelled transition until after that join.
This violates the required lifecycle order. The blocked-finalizer test should assert that the claim remains cancelling and no Step.Interrupted exists until the finalizer joins, followed by exactly one atomic interruption settlement and claim clear.
Both cancellation paths create a real cancelled crash boundary before clearing:
packages/core/src/session/execution/local.ts:146-148Step.Interrupted: packages/core/src/session/execution/local.ts:151-164packages/core/src/session/runner/llm.ts:359-371Recovery treats cancelled exactly like uncertain cancelling work and quarantines it at ...
The correctness review rejected v19 on two real lifecycle gaps: cancellation can become durable before outer runner finalizers finish, and a crash after cancelled can permanently block the Session. I’m rejecting the freeze, moving all cancelling → cancelled settlement exclusively into post-join coordinator cleanup, making expired cancelled claims safely clearable on startup, and adding immediate ownership checks before both snapshot writes.
[
{
"content": "Move cancellation settlement after runner finalizers join",
"status": "in_progress",
"priority": "high"
},
{
"content": "Clear expired durably cancelled claims on recovery",
"status": "pending",
"priority": "high"
},
{
"content": "Fence snapshot writes at ownership boundaries",
"status": "pending",
"priority": "high"
},
{
"content": "Add cancellation crash and finalizer-order regressions",
"status": "pending",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
270: providerOptions: { openai: { promptCacheKey } },
271: system: [agent.info?.system, system.baseline]
272: .filter((part): part is string => part !== undefined && part.length > 0)
273: .map(SystemPart.make),
274: messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
275: tools: toolMaterialization?.definitions ?? [],
276: toolChoice: isLastStep ? "none" : undefined,
277: })
278: if (
279: yield* compaction.compactIfNeeded({
280: sessionID: session.id,
281: entries,
282: model,
283: request,
284: beforeStream: () => updateExecution({ phase: "unknown", assistant_message_id: null }),
285: eventCommit: () => updateExecution(),
286: commit: () => updateExecution({ phase: "continue", ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts</path>
<type>file</type>
<content>
180: _tag: "ContinueAfterOverflowCompaction",
181: step,
182: })
183:
184: const loadSystemContext = (agent: AgentV2.Selection) =>
185: Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
186: concurrency: "unbounded",
187: }).pipe(Effect.map(SystemContext.combine))
188:
189: const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
190: sessionID: SessionSchema.ID,
191: executionID: string,
192: promotion: Promotion | undefined,
193: step: number,
194: ownerID: string,
195: recoverOverflow?: typeof compaction.compactAfterOverflow,
196: ) {
197: const session = yield* getSession(sessionID)
198: if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
199: return yield* ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
105: .update(SessionExecutionTable)
106: .set({ recovery_error: `Invalid execution phase: ${String(claim.phase)}` })
107: .where(
108: and(
109: eq(SessionExecutionTable.id, claim.id),
110: eq(SessionExecutionTable.session_id, claim.session_id),
111: eq(SessionExecutionTable.owner_id, ownerID),
112: gt(SessionExecutionTable.expires_at, quarantinedAt),
113: ),
114: )
115: .returning({ id: SessionExecutionTable.id })
116: .get()
117: .pipe(Effect.orDie)
118: if (!quarantined) return yield* Effect.die(`Recovery execution claim changed: ${claim.id}`)
119: return
120: }
121: if (phase.value === "cancelling" || phase.value === "cancelled") {
122: const quarantinedAt = ...
I’m removing runner-owned explicit-cancellation publication entirely. During an external interrupt the runner will only stop and join tool fibers, then propagate the interrupt; after the outer scope/finalizers complete, coordinator cleanup performs the single durable interruption transaction and clear. User decline remains a separate deliberate in-runner path.
Found 17 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 203: const renewCancellation = () => SessionExecutionClaim.settleCancellation(db, claim)
Line 204: const settleCancellation = () =>
Line 205: SessionExecutionClaim.settleCancellation(db, claim, { phase: "cancelled", assistant_message_id: null })
Line 308: const settleInterruption = Effect.fnUntraced(function* () {
Line 335: { commit: settleCancellation },
Line 340: const interruptionCommit = cancelling ? renewCancellation : () => updateExecution()
Line 341: publisher.beginCancellationSettlement(interruptionCommit)
Line 365: yield* settleCancellation()
Line 370: cancelling ? settleCancellation : interruptionCommit,
Line 502: return yield* settleInterruption().pipe(Effect.andThen(Effect.failCause(stream.cause)))
Line 507: return yield* ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
440: if (tool.settled) return yield* Effect.die(`Duplicate tool error: ${event.id}`)
441: yield* publishEvent(SessionEvent.Tool.Failed, {
442: sessionID: input.sessionID,
443: timestamp: yield* timestamp,
444: assistantMessageID: tool.assistantMessageID,
445: callID: event.id,
446: error: { type: "unknown", message: event.message },
447: provider: {
448: executed: tool.providerExecuted,
449: ...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
450: },
451: })
452: tool.settled = true
453: return
454: }
455: case "step-finish":
456: yield* flush()
457: assistantActive = false
458: if (stepSettlement) return yield* Effect.die("Duplicate step finish")
459: ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
4010: ? Effect.void
4011: : Deferred.succeed(stepCommitted, undefined).pipe(Effect.andThen(Effect.never)),
4012: )
4013: yield* Effect.addFinalizer(() => unsubscribe)
4014: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Cancel committed Step start" }), resume: false })
4015: responseStream = Stream.concat(
4016: Stream.fromIterable([
4017: LLMEvent.stepStart({ index: 0 }),
4018: LLMEvent.textStart({ id: "text-after-committed-step" }),
4019: ]),
4020: Stream.never,
4021: )
4022:
4023: const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
4024: yield* Deferred.await(stepCommitted)
4025: const claim = yield* db
4026: .select()
4027: .from(SessionExecutionTable)
4028: .get()
4029: .pipe(Effect.orDie, ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
70: if (!claimed) return
71: const claim = { sessionID, executionID, ownerID }
72: activeClaims.set(sessionID, claim)
73: const recovery = yield* db
74: .select({
75: continuationMessageID: SessionRecoveryTable.continuation_message_id,
76: })
77: .from(SessionRecoveryTable)
78: .where(
79: and(
80: eq(SessionRecoveryTable.session_id, sessionID),
81: eq(SessionRecoveryTable.wake_pending, true),
82: isNull(SessionRecoveryTable.recovery_error),
83: ),
84: )
85: .orderBy(asc(SessionRecoveryTable.time_created), asc(SessionRecoveryTable.execution_id))
86: .limit(1)
87: .get()
88: .pipe(Effect.orDie)
89: return yield* Effect.raceFirst(
90: input.run(claim, force, recovery?.continuationMessageID),
91: ...
The code confirms the clean split: external cancellation branches can propagate interruption after joining tool fibers without publishing or changing phase; coordinator cleanup already has the durable assistant identity and runs after the outer finalizer. I’m applying that simplification, then changing recovery so cancelled is cleared while only cancelling is quarantined.
Success. Updated the following files: M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/recovery.ts
Found 1 matches
/home/byk/Code/opencode/packages/core/src/session/runner/llm.ts:
Line 37: import { SessionExecutionTable, SessionMessageTable, SessionRecoveryTable } from "../sql"
Success. Updated the following files: M packages/core/src/session/runner/llm.ts
Production ordering is corrected. I’m now strengthening the finalizer regression to require zero interrupted events and phase cancelling while the finalizer is blocked, then exactly one interrupted event and no claim after release. I’m also adding a startup regression for an expired cancelled claim to prove it clears without quarantine or continuation.
continue. the server restarted so you need to nudge any background agents for them to resume
The correctness reviewer has finished and v19 is rejected; the security reviewer may have been interrupted by the restart. I’m resuming that reviewer for additional evidence while continuing the post-finalizer cancellation fix and regressions locally.
<task id="ses_f52fef029ffe2a2CGDN98jGF2v" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
2000: })
2001: .run()
2002:
2003: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
2004:
2005: expect(
2006: yield* db
2007: .select({ error: SessionExecutionTable.recovery_error })
2008: .from(SessionExecutionTable)
2009: .where(eq(SessionExecutionTable.id, "malformed"))
2010: .get(),
2011: ).toEqual({ error: "Invalid execution phase: invalid" })
2012: expect(yield* db.select().from(SessionRecoveryTable).all()).toEqual([
2013: expect.objectContaining({ execution_id: "valid" }),
2014: ])
2015: }),
2016: )
2017:
2018: it.effect("never recovers an expired cancellation claim", () =>
2019: Effect.gen(function* () {
2020: const { db } = yield* Database.Service
2021: yield* db
2022: ...
The interrupted-finalizer regression currently encodes the rejected ordering. I’m changing it to the required contract and adding the distinct startup cases: expired cancelling remains quarantined, while expired cancelled is cleared as a known durable outcome.
Success. Updated the following files: M packages/core/test/session-runner.test.ts M packages/core/test/session-projector.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 1 pass 109 filtered out 0 fail 6 expect() calls Ran 1 test across 1 file. [1403.00ms] bun test v1.3.14 (0d9b296a) 2 pass 55 filtered out 0 fail 8 expect() calls Ran 2 tests across 1 file. [1362.00ms]
The corrected blocked-finalizer and cancelled-crash regressions pass, along with Core typecheck. One requested name pattern matched only one runner test, so I’m locating and rerunning the durable-Step-start interruption regression explicitly before the broader gate.
Found 3 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 4001: it.effect("settles cancellation when Step start notification is interrupted", () =>
Line 4014: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Cancel committed Step start" }), resume: false })
Line 4055: it.effect("retains the claim after Step interruption until runner finalizers finish", () =>
bun test v1.3.14 (0d9b296a) 1 pass 109 filtered out 0 fail 6 expect() calls Ran 1 test across 1 file. [1337.00ms]
Both cancellation edge cases now pass: interruption after durable Step.Started notification loss settles in coordinator cleanup, and a blocked outer finalizer prevents any durable interruption until it joins. Immediate claim renewals now guard both snapshot writes. I’m running the complete recovery gate to catch changed expectations across all interruption and replay paths.
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
3868 | yield* session.interrupt(sessionID)
3869 | toolExecutionGate = undefined
3870 |
3871 | expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
3872 | yield* session.interrupt(sessionID)
3873 | expect(yield* session.context(sessionID)).toMatchObject([
^
error: expect(received).toMatchObject(expected)
@@ -2,5 +2,10 @@
{
+ "id": "msg_0ae7448ef001rupDA6HZ4Mu542",
"text": "Interrupt blocked tool",
+ "time": {
+ "created": {
+ "epochMilliseconds": 0,
+ "partsUtc": undefined,
+ },
+ },
"type": "user",
@@ -8,3 +13,3 @@
{
+ "agent": "build",
"content": [
@@ -12,12 +17,31 @@
"id": "call-before-interrupt",
+ "name": "echo",
+ "provider": {
+ "executed": false,
+ },
"state": {
+ "content": [],
"error": {
- "message": "Tool ...
The broader gate exposed two categories. Four direct-runner tests have no SessionExecutionLocal coordinator, so they must retain unknown state without fabricating a durable interruption; their old expectations encoded the removed runner-owned settlement. One production cancellation test now receives the canonical Tool execution interrupted from post-join coordinator cleanup. I’m updating only those stale expectations, not weakening production assertions.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
520: const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks)
521: const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant]
522: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
523: const events = yield* EventV2.Service
524: const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped)
525: yield* Effect.yieldNow
526: response = fixture.completeEvents
527:
528: yield* session.resume(sessionID)
529:
530: const { db } = yield* Database.Service
531: const deltas = yield* db
532: .select({ type: EventTable.type })
533: .from(EventTable)
534: .where(eq(EventTable.type, EventV2.versionedType(fixture.delta.type, 1)))
535: .all()
536: .pipe(Effect.orDie)
537: ...
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3825: Stream.fail(failure),
3826: )
3827:
3828: const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
3829: const started = yield* Deferred.await(finalizerStarted).pipe(Effect.timeout("1 second"), Effect.exit)
3830: if (Exit.isFailure(started)) {
3831: yield* Deferred.succeed(releaseFinalizer, undefined)
3832: return yield* started
3833: }
3834:
3835: const retained = yield* db.select().from(SessionExecutionTable).get()
3836: yield* Deferred.succeed(releaseFinalizer, undefined)
3837: const result = yield* Fiber.join(run).pipe(Effect.timeout("5 seconds"))
3838:
3839: expect(retained).toMatchObject({ phase: "unknown" })
3840: expect(result).toMatchObject({ _tag: "Failure" })
3841: }),
3842: 15_000,
3843: )
3844:
3845: it.effect("durably fails blocked local ...
The direct runner harness correctly leaves the claim unknown and projection incomplete for startup reconciliation because it intentionally bypasses coordinator cleanup. I’m changing those four assertions to verify no durable interruption and no false terminal projection. Production SessionExecution.interrupt still must reconcile tools and Step after join; its canonical tool error is Tool execution interrupted.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
4135: streamStarted = undefined
4136:
4137: expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
4138: expect(requests).toHaveLength(1)
4139: yield* session.interrupt(sessionID)
4140: }),
4141: )
4142:
4143: it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
4144: Effect.gen(function* () {
4145: yield* setup
4146: const session = yield* SessionV2.Service
4147: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt tool settlement" }), resume: false })
4148: executions.length = 0
4149: toolExecutionGate = yield* Deferred.make<void>()
4150: response = [
4151: LLMEvent.stepStart({ index: 0 }),
4152: LLMEvent.toolCall({ id: "call-await-interrupt", name: "echo", input: { text: "blocked" } }),
4153: ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
598 | expect(
599 | (yield* db.select({ type: EventTable.type }).from(EventTable).all()).some((event) =>
600 | event.type.startsWith(SessionEvent.Step.Interrupted.type),
601 | ),
602 | ).toBe(false)
603 | expect(yield* session.context(sessionID)).toMatchObject([
^
error: expect(received).toMatchObject(expected)
@@ -2,5 +2,10 @@
{
+ "id": "msg_0ae757923001Y3PHfkWD6CWuEU",
"text": "Interrupt after text",
+ "time": {
+ "created": {
+ "epochMilliseconds": 0,
+ "partsUtc": undefined,
+ },
+ },
"type": "user",
@@ -8,3 +13,3 @@
{
+ "agent": "build",
"content": [
@@ -12,3 +17,3 @@
"id": "text-interrupted",
- "text": "Partial",
+ "text": "",
"type": "text",
@@ -16,4 +21,13 @@
],
- "error": undefined,
- "finish": undefined,
+ "id": "msg_0ae757936001rjwmnlxxHm1MN1",
+ ...
The direct-runner partial-fragment test must assert only durable facts: claim remains unknown, no Step.Interrupted exists, and partial live deltas were never projected. I’m replacing the stale terminal-shape assertion with those mutation-sensitive checks instead of matching optional absent fields.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
$ tsgo --noEmit test/session-runner.test.ts(608,24): error TS2339: Property 'content' does not exist on type 'Message'. Property 'content' does not exist on type 'AgentSwitched'. test/session-runner.test.ts(611,13): error TS2698: Spread types may only be created from object types.
Found 2 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 427: type FragmentKind = "text" | "reasoning" | "tool input"
Line 441: const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string[]): FragmentFixture => {
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
420: seq: event.seq,
421: type: event.type,
422: data: event.data,
423: })),
424: )
425: })
426:
427: type FragmentKind = "text" | "reasoning" | "tool input"
428:
429: type FragmentFixture = {
430: readonly delta: EventV2.Definition
431: readonly completeEvents: LLMEvent[]
432: readonly partialEvents: LLMEvent[]
433: readonly expectedAssistant: unknown
434: readonly expectedContent: unknown
435: }
436:
437: const fragmentKinds: readonly FragmentKind[] = ["text", "reasoning", "tool input"]
438:
439: const fragmentID = (kind: FragmentKind, suffix: string) => `${kind === "tool input" ? "call" : kind}-${suffix}`
440:
441: const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string[]): FragmentFixture => {
442: const text = chunks.join("")
443: switch (kind) {
444: case "text": {
445: const ...
Success. Updated the following files: M packages/core/test/session-runner.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 5 pass 105 filtered out 0 fail 28 expect() calls Ran 5 tests across 1 file. [1.58s]
The five affected runner paths now pass with the intended split: direct runner interruption leaves durable unknown state for recovery, while production interruption settles only in post-finalizer coordinator cleanup. I’m rerunning all recovery tests now.
bun test v1.3.14 (0d9b296a) 251 pass 0 fail 760 expect() calls Ran 251 tests across 8 files. [20.03s]
All 251 recovery tests pass. I’m running the full Core suite and six package typechecks again on the revised source; if green, I’ll rebuild the SEA, rerun the isolated v18 smoke, and freeze a replacement candidate.
<task id="ses_f51883971ffev3uclQPbWE9FKR" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Revised validation is running in the background. Current focused gate: 251 passed, 0 failed, 760 assertions. I’ll continue automatically with rebuild, smoke, freeze, and replacement reviews when it completes.
Total measured command time: 144.01s.
Failures
None.
Warnings And Logs
packages/core emitted:
ERROR log for the intentional missing skill URL: https://skills.example.test/catalog/deploy/missing.md, with StatusCodeError: non 2xx status code.inotify watcher and project-copy refresh logs.No other warnings or diagnostics appeared. client check:generated was not run.
</task_result>
</task>
The revised tree is fully green: 1,188 Core tests, 251 recovery tests, Schema manifest, and six package typechecks all pass. I’m running migration/diff hygiene, then rebuilding and smoking the exact binary because cancellation and snapshot boundaries changed after v19.
[
{
"content": "Move cancellation settlement after runner finalizers join",
"status": "completed",
"priority": "high"
},
{
"content": "Clear expired durably cancelled claims on recovery",
"status": "completed",
"priority": "high"
},
{
"content": "Fence snapshot writes at ownership boundaries",
"status": "completed",
"priority": "high"
},
{
"content": "Add cancellation crash and finalizer-order regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "in_progress",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
Reading config file '/tmp/opencode-core-migration-check-O8ywfg/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-O8ywfg/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-O8ywfg/full/20260917082859_schema/migration.sql 🚀
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0ae7c8bd2001EawPXFHnzXyiHY dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
node:internal/modules/cjs/loader:1503
throw err;
^
Error: Cannot find module '/tmp/opencode/session-recovery-v18-smoke.mjs'
at Module._resolveFilename (node:internal/modules/cjs/loader:1500:15)
at wrapResolveFilename (node:internal/modules/cjs/loader:1071:27)
at defaultResolveImplForCJSLoading (node:internal/modules/cjs/loader:1095:10)
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1116:12)
at Module._load (node:internal/modules/cjs/loader:1285:25)
at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47 {
code: 'MODULE_NOT_FOUND',
requireStack: []
}
Node.js v24.16.0
The server restart removed the disposable /tmp harness again; the build itself succeeded. I’m recreating the same strict harness under /tmp/opencode with a new v20 namespace, then rerunning the exact artifact smoke and hash.
drwxr-xr-x 2 byk byk 4096 Sep 17 02:06 /tmp/opencode
Success. Updated the following files: A ../../../../tmp/opencode/session-recovery-v20-smoke.mjs
{
"initial": {
"port": 19077,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
}
],
"indexes": [
{
"name": "session_execution_recovery_scan_idx",
"sql": "CREATE INDEX `session_execution_recovery_scan_idx` ON `session_execution` (`recovery_error`,`expires_at`,`time_created`,`id`)"
}
],
"before": {
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"message_count": 0,
"event_count": 0
},
"first": {
"port": 18545,
"health": {
...
The exact rebuilt artifact passed four isolated launches, authenticated health, migration/index checks, one synthetic continuation, and duplicate-free restart; no disposable server remains. I’m freezing v20 now and verifying clean application to ab9408c81c, then both reviews will inspect only the new immutable hashes.
M .lore.md M AGENTS.md M packages/client/src/generated/types.ts M packages/core/schema.json M packages/core/src/database/migration.gen.ts M packages/core/src/database/migration/20260914170650_session-recovery.ts D packages/core/src/database/migration/20260914214636_session-execution.ts D packages/core/src/database/migration/20260914222526_session-execution-phase.ts M packages/core/src/database/schema.gen.ts M packages/core/src/session.ts M packages/core/src/session/compaction.ts M packages/core/src/session/context-epoch.ts M packages/core/src/session/execution/local.ts M packages/core/src/session/input.ts M packages/core/src/session/message-updater.ts M packages/core/src/session/projector.ts M packages/core/src/session/recovery.ts M packages/core/src/session/run-coordinator.ts M packages/core/src/session/runner/index.ts M packages/core/src/session/runner/llm.ts M packages/core/src/session/runner/publish-llm-event.ts M packages/core/src/session/sql.ts M ...
24352107c74d8245fcba7f8571ba0be4b7fc514e6c73cfbf0498f0c25f29f9ba /tmp/opencode/session-recovery-final-v20.patch 34 packages/client/src/generated/types.ts | 72 + packages/core/schema.json | 615 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session.ts | 10 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 219 +- packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 508 ++++ packages/core/src/session/run-coordinator.ts | 35 ...
Preparing worktree (detached HEAD ab9408c81c) HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
Frozen v20 contains 34 paths, applies cleanly, and passes whitespace checks. The previous security review was for rejected v19 and is no longer an approval gate; I’m launching fresh correctness and security reviews focused on the post-finalizer cancellation order, cancelled crash cleanup, direct-runner unknown-state contract, and snapshot fencing.
<task id="ses_f5180d5e6ffeUS5WQw5h1K2sPR" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f51808c1affeB5LJt71L2pUREb" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
[
{
"content": "Move cancellation settlement after runner finalizers join",
"status": "completed",
"priority": "high"
},
{
"content": "Clear expired durably cancelled claims on recovery",
"status": "completed",
"priority": "high"
},
{
"content": "Fence snapshot writes at ownership boundaries",
"status": "completed",
"priority": "high"
},
{
"content": "Add cancellation crash and finalizer-order regressions",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "in_progress",
"priority": "high"
}
]
Frozen v20 is under fresh correctness and security review.
24352107c74d8245fcba7f8571ba0be4b7fc514e6c73cfbf0498f0c25f29f9bad0c78e881892d4f894fab7f6be00643c133ca21f9d2c88ae0d5ff31215393021Step.Failed can be recovered as unknown work and trigger another provider StepcreateLLMEventPublisher.failAssistant commits Step.Failed using input.eventCommit, which only renews the current claim and leaves its phase unchanged (packages/core/src/session/runner/publish-llm-event.ts:222-239). The claim is still "unknown" from packages/core/src/session/runner/llm.ts:434-440. It becomes "safe" only later at packages/core/src/session/runner/llm.ts:513-518.
A crash after the durable Step.Failed commit but before that later update leaves:
"unknown".Recovery checks only that the referenced assistant is the latest assistant (packages/core/src/session/recovery.ts:147-165). It never checks whether that assistant already has time.completed, finish, or an error. It therefore emits Step.Recovered and creates a continuation (packages/core/src/session/recovery.ts:166-206; packages/core/src/session/projector.ts:441-466).
The recovery projector then overwrites the already settled assistant with a restart error and adds recovery text (packages/core/src/session/message-updater.ts:252-297). The pending continuation is subsequently awakened.
This turns a known terminal provider failure into another model call. That can incur cost and execute tools without a new user request. It also corrupts the durable meaning of the original Step.Failed.
Recovery must distinguish incomplete unknown work from an assistant already settled by Step.Failed. The terminal event and safe execution state should share one transaction where possible; otherwise recovery must inspect the completed projection and clear or reconcile the claim without creating a continuation. ...
The resumed reviewer inspected rejected v19, but its three findings still map onto v20 code and cannot be ignored. I’m invalidating v20 now: I’ll add regressions for atomic Step.Failed → safe, concurrent interrupt joining, and correct snapshot ancestry, then refreeze only after those gates pass. The two active v20 reviews may still provide useful extra findings, but they cannot approve changed source.
[
{
"content": "Make Step.Failed and safe claim settlement atomic",
"status": "in_progress",
"priority": "high"
},
{
"content": "Serialize concurrent interruption and durable cleanup",
"status": "pending",
"priority": "high"
},
{
"content": "Restore schema snapshot ancestry to frozen base",
"status": "pending",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
200: yield* publishEvent(SessionEvent.Tool.Input.Started, {
201: sessionID: input.sessionID,
202: timestamp: yield* timestamp,
203: assistantMessageID,
204: callID: event.id,
205: name: event.name,
206: })
207: })
208:
209: const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
210: const tool = tools.get(event.id)
211: if (!tool) return yield* Effect.die(`Tool input end before start: ${event.id}`)
212: if (tool.name !== event.name)
213: return yield* Effect.die(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)
214: if (tool.inputEnded) return yield* Effect.die(`Duplicate tool input end: ${event.id}`)
215: yield* toolInput.end(event.id)
216: })
217:
218: const flush = Effect.fn("SessionRunner.flush")(function* () {
219: ...
<path>/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts</path>
<type>file</type>
<content>
1: import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
2: import { DateTime, Effect } from "effect"
3: import { EventV2 } from "../../event"
4: import { ModelV2 } from "../../model"
5: import { SessionEvent } from "../event"
6: import { SessionMessage } from "../message"
7: import { SessionSchema } from "../schema"
8:
9: type Input = {
10: readonly sessionID: SessionSchema.ID
11: readonly agent: string
12: readonly model: ModelV2.Ref
13: readonly snapshot?: string
14: readonly startCommit: (assistantMessageID: SessionMessage.ID, seq: number) => Effect.Effect<void>
15: readonly eventCommit: () => Effect.Effect<void>
16: }
17:
18: const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? ...
Step.Failed → safe cannot be applied blindly because a failed Step may still have local tool fibers or unresolved durable tools. The safe fix is recovery-aware terminal state: if the latest assistant already completed, startup must reconcile any unresolved tools without overwriting the known Step failure or enqueueing "continue"; only then may it clear the claim. I’m tracing the projector and runner ordering before encoding that regression.
Found 3 matches
/home/byk/Code/opencode/packages/core/src/session/projector.ts:
Line 397: yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
/home/byk/Code/opencode/packages/core/src/session/message-updater.ts:
Line 225: "session.next.step.failed": (event) => {
/home/byk/Code/opencode/packages/core/src/session/runner/publish-llm-event.ts:
Line 227: SessionEvent.Step.Failed,
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
210: },
211: "session.next.step.ended": (event) => {
212: return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
213: draft.time.completed = event.data.timestamp
214: draft.finish = event.data.finish
215: draft.cost = event.data.cost
216: draft.tokens = event.data.tokens
217: if (event.data.snapshot || event.data.files)
218: draft.snapshot = {
219: ...draft.snapshot,
220: end: event.data.snapshot,
221: files: event.data.files ? Array.from(event.data.files) : undefined,
222: }
223: })
224: },
225: "session.next.step.failed": (event) => {
226: return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
227: draft.time.completed = event.data.timestamp
228: draft.finish = ...
The correct terminal-failure recovery is narrower than synthetic continuation: preserve the durable Step.Failed, publish unknown outcomes only for still-unresolved tools, and atomically clear the claim with the last tool event. If no unresolved tool remains, clear immediately. This prevents a known provider failure from becoming another model call without classifying uncertain local tools as safe.
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
340: schema: {
341: ...ToolBase,
342: text: Schema.String,
343: },
344: })
345: export type Ended = typeof Ended.Type
346: }
347:
348: export const Called = Event.define({
349: type: "session.next.tool.called",
350: ...options,
351: schema: {
352: ...ToolBase,
353: tool: Schema.String,
354: input: Schema.Record(Schema.String, Schema.Unknown),
355: provider: Schema.Struct({
356: executed: Schema.Boolean,
357: metadata: ProviderMetadata.pipe(optional),
358: }),
359: },
360: })
361: export type Called = typeof Called.Type
362:
363: /**
364: * Replayable bounded running-tool state. Tools should checkpoint semantic
365: * transitions or at a bounded cadence, not persist every stdout/stderr chunk.
366: */
367: export const Progress = Event.define({
368: type: ...
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, exists, gt, isNull, lte, or, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { SessionInput } from "./input"
13: import { continuation } from "./recovery-id"
14: import { SessionSchema } from "./schema"
15: import {
16: MAX_WAKE_ATTEMPTS,
17: SESSION_EXECUTION_LEASE_MS,
18: SessionExecutionTable,
19: SessionInputTable,
20: SessionRecoveryTable,
21: SessionMessageTable,
22: ...
I found the safe idempotent boundary: recovery can publish Tool.Failed for each unresolved tool on an already-completed assistant, renewing the exact claim for intermediate tools and clearing it atomically with the final tool event. A crash midway simply retries the remaining unresolved tools; the known Step.Failed projection is never rewritten and no continuation is admitted.
No files found
<path>/home/byk/Code/opencode/packages/core/src/session/sql.ts</path>
<type>file</type>
<content>
90: export const PartTable = sqliteTable(
91: "part",
92: {
93: id: text().$type<PartID>().primaryKey(),
94: message_id: text()
95: .$type<MessageID>()
96: .notNull()
97: .references(() => MessageTable.id, { onDelete: "cascade" }),
98: session_id: text().$type<SessionSchema.ID>().notNull(),
99: ...Timestamps,
100: data: text({ mode: "json" }).notNull().$type<V1PartData>(),
101: },
102: (table) => [
103: index("part_message_id_id_idx").on(table.message_id, table.id),
104: index("part_session_idx").on(table.session_id),
105: ],
106: )
107:
108: export const TodoTable = sqliteTable(
109: "todo",
110: {
111: session_id: text()
112: .$type<SessionSchema.ID>()
113: .notNull()
114: .references(() => SessionTable.id, { onDelete: "cascade" }),
115: content: text().notNull(),
116: status: ...
Found 1 matches /home/byk/Code/opencode/packages/core/src/session/sql.ts: Line 19: type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
Found 15 matches
/home/byk/Code/opencode/packages/schema/src/session-message.ts:
Line 81: export interface ToolStatePending extends Schema.Schema.Type<typeof ToolStatePending> {}
Line 82: export const ToolStatePending = Schema.Struct({
Line 85: }).annotate({ identifier: "Session.Message.ToolState.Pending" })
Line 87: export interface ToolStateRunning extends Schema.Schema.Type<typeof ToolStateRunning> {}
Line 88: export const ToolStateRunning = Schema.Struct({
Line 93: }).annotate({ identifier: "Session.Message.ToolState.Running" })
Line 95: export interface ToolStateCompleted extends Schema.Schema.Type<typeof ToolStateCompleted> {}
Line 96: export const ToolStateCompleted = Schema.Struct({
Line 104: }).annotate({ identifier: "Session.Message.ToolState.Completed" })
Line 106: export interface ToolStateError extends Schema.Schema.Type<typeof ToolStateError> {}
Line 107: export const ToolStateError = Schema.Struct({
Line 114: }).annotate({ identifier: ...
<path>/home/byk/Code/opencode/packages/schema/src/session-message.ts</path>
<type>file</type>
<content>
70: ...Base,
71: type: Schema.Literal("shell"),
72: callID: Schema.String,
73: command: Schema.String,
74: output: Schema.String,
75: time: Schema.Struct({
76: created: DateTimeUtcFromMillis,
77: completed: DateTimeUtcFromMillis.pipe(optional),
78: }),
79: }).annotate({ identifier: "Session.Message.Shell" })
80:
81: export interface ToolStatePending extends Schema.Schema.Type<typeof ToolStatePending> {}
82: export const ToolStatePending = Schema.Struct({
83: status: Schema.Literal("pending"),
84: input: Schema.String,
85: }).annotate({ identifier: "Session.Message.ToolState.Pending" })
86:
87: export interface ToolStateRunning extends Schema.Schema.Type<typeof ToolStateRunning> {}
88: export const ToolStateRunning = Schema.Struct({
89: status: Schema.Literal("running"),
90: input: Schema.Record(Schema.String, Schema.Unknown),
91: structured: ...
<path>/home/byk/Code/opencode/packages/schema/src/session-message.ts</path>
<type>file</type>
<content>
140: export interface AssistantText extends Schema.Schema.Type<typeof AssistantText> {}
141: export const AssistantText = Schema.Struct({
142: type: Schema.Literal("text"),
143: id: Schema.String,
144: text: Schema.String,
145: }).annotate({ identifier: "Session.Message.Assistant.Text" })
146:
147: export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
148: export const AssistantReasoning = Schema.Struct({
149: type: Schema.Literal("reasoning"),
150: id: Schema.String,
151: text: Schema.String,
152: providerMetadata: ProviderMetadata.pipe(optional),
153: time: Schema.Struct({
154: created: DateTimeUtcFromMillis,
155: completed: DateTimeUtcFromMillis.pipe(optional),
156: }).pipe(optional),
157: }).annotate({ identifier: "Session.Message.Assistant.Reasoning" })
158:
159: export const AssistantContent = ...
<path>/home/byk/Code/opencode/packages/schema/src/session-event.ts</path>
<type>file</type>
<content>
300: assistantMessageID: SessionMessage.ID,
301: reasoningID: Schema.String,
302: text: Schema.String,
303: providerMetadata: ProviderMetadata.pipe(optional),
304: },
305: })
306: export type Ended = typeof Ended.Type
307: }
308:
309: export namespace Tool {
310: const ToolBase = {
311: ...Base,
312: assistantMessageID: SessionMessage.ID,
313: callID: Schema.String,
314: }
315:
316: export namespace Input {
317: export const Started = Event.define({
318: type: "session.next.tool.input.started",
319: ...options,
320: schema: {
321: ...ToolBase,
322: name: Schema.String,
323: },
324: })
325: export type Started = typeof Started.Type
326:
327: // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary.
328: export const Delta = Event.define({
329: ...
I’m implementing terminal-failure reconciliation in startup recovery now. It will preserve the original assistant error, publish only missing tool outcomes, and clear the exact claim in the final tool event transaction; no synthetic prompt or continuation will be created.
<path>/home/byk/Code/opencode/packages/core/src/session/message-updater.ts</path>
<type>file</type>
<content>
380: structured: event.data.structured,
381: content: [...event.data.content],
382: outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [],
383: result: event.data.result,
384: }),
385: )
386: }
387: })
388: },
389: "session.next.tool.failed": (event) => {
390: return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
391: const match = latestTool(draft, event.data.callID)
392: if (match && (match.state.status === "pending" || match.state.status === "running")) {
393: match.provider = {
394: executed: event.data.provider.executed || match.provider?.executed === true,
395: metadata: match.provider?.metadata,
396: resultMetadata: ...
Success. Updated the following files: M packages/core/src/session/recovery.ts
No files found
Found 12 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 100: yield* events.publish(SessionEvent.Step.Started, {
Line 474: yield* events.publish(SessionEvent.Step.Started, {
Line 1459: yield* events.publish(SessionEvent.Step.Started, {
Line 1604: yield* events.publish(SessionEvent.Step.Started, {
Line 1618: yield* events.publish(SessionEvent.Step.Started, {
Line 1719: yield* events.publish(SessionEvent.Step.Started, {
Line 1733: yield* events.publish(SessionEvent.Step.Started, {
Line 1783: yield* events.publish(SessionEvent.Step.Started, {
Line 1797: yield* events.publish(SessionEvent.Step.Started, {
Line 1877: yield* events.publish(SessionEvent.Step.Started, {
Line 3214: .publish(SessionEvent.Step.Started, {
Line 3285: yield* service.publish(SessionEvent.Step.Ended, {
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
60: id: _,
61: type,
62: ...data
63: } = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time }))
64: return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
65: }
66:
67: describe("SessionProjector", () => {
68: it.effect("atomically reconciles one abandoned execution into one continuation", () =>
69: Effect.gen(function* () {
70: const { db } = yield* Database.Service
71: const events = yield* EventV2.Service
72: const assistantID = SessionMessage.ID.make("msg_recovery_assistant")
73: const continuationID = continuation("execution")
74: yield* db
75: .insert(ProjectTable)
76: .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
77: .run()
78: yield* ...
Found 5 matches
/home/byk/Code/opencode/packages/core/test/session-runner.test.ts:
Line 819: event.type !== SessionEvent.Tool.Called.type
Line 873: yield* events.publish(SessionEvent.Tool.Called, {
Line 3064: yield* events.publish(SessionEvent.Tool.Called, {
Line 3128: yield* events.publish(SessionEvent.Tool.Called, {
/home/byk/Code/opencode/packages/core/test/session-tool-progress.test.ts:
Line 77: yield* service.publish(SessionEvent.Tool.Called, {
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3050: yield* events.publish(SessionEvent.Tool.Input.Started, {
3051: sessionID,
3052: timestamp: yield* DateTime.now,
3053: assistantMessageID,
3054: callID: "call-interrupted",
3055: name: "echo",
3056: })
3057: yield* events.publish(SessionEvent.Tool.Input.Ended, {
3058: sessionID,
3059: timestamp: yield* DateTime.now,
3060: assistantMessageID,
3061: callID: "call-interrupted",
3062: text: '{"text":"stale"}',
3063: })
3064: yield* events.publish(SessionEvent.Tool.Called, {
3065: sessionID,
3066: timestamp: yield* DateTime.now,
3067: assistantMessageID,
3068: callID: "call-interrupted",
3069: tool: "echo",
3070: input: { text: "stale" },
3071: provider: { executed: false },
3072: })
3073: requests.length ...
Found 1 matches /home/byk/Code/opencode/packages/core/test/session-runner.test.ts: Line 3960: event.type !== SessionEvent.Step.Failed.type
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { DateTime, Deferred, Effect, Exit, Fiber, Schema } from "effect"
3: import { TestClock } from "effect/testing"
4: import { asc, eq, sql } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
7: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
8: import { EventV2 } from "@opencode-ai/core/event"
9: import { EventTable } from "@opencode-ai/core/event/sql"
10: import { ModelV2 } from "@opencode-ai/core/model"
11: import { Project } from "@opencode-ai/core/project"
12: import { ProjectTable } from "@opencode-ai/core/project/sql"
13: import { ProviderV2 } from "@opencode-ai/core/provider"
14: import { AbsolutePath } from "@opencode-ai/core/schema"
15: import { SessionV2 } from ...
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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, runs cleanup, then starts work that arrived while stopping. */
14: readonly interrupt: (key: Key, cleanup?: Effect.Effect<void>) => Effect.Effect<void>
15: }
16:
17: type Entry<E> = {
18: ...
The second blocker spans two layers. I’m making interruption single-flight in both SessionExecutionLocal and the generic coordinator: concurrent callers join the same durable cleanup result, so none can re-run phase transition, omit cleanup, consume a successor wake, or finalize the entry early.
apply_patch verification failed: Error: Failed to find expected lines in /home/byk/Code/opencode/packages/core/src/session/execution/local.ts: const ownerID = crypto.randomUUID() const activeClaims = new Map<SessionSchema.ID, SessionExecutionClaim.Claim>()
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, desc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, DateTime, Duration, Effect, Exit, Layer, Option, Schedule } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { EventV2 } from "../../event"
13: import { SessionEvent } from "../event"
14: import { SessionExecutionClaim } from "./claim"
15: import {
16: SESSION_EXECUTION_LEASE_MS,
17: SessionExecutionTable,
18: SessionMessageTable,
19: ...
Success. Updated the following files: M packages/core/src/session/run-coordinator.ts M packages/core/src/session/execution/local.ts
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
250: const firstStarted = yield* Deferred.make<void>()
251: const cleanupStarted = yield* Deferred.make<void>()
252: const cleanupGate = yield* Deferred.make<void>()
253: const secondStarted = yield* Deferred.make<void>()
254: let runs = 0
255: const coordinator = yield* SessionRunCoordinator.make({
256: drain: () =>
257: Effect.sync(() => ++runs).pipe(
258: Effect.flatMap((run) =>
259: run === 1
260: ? Deferred.succeed(firstStarted, undefined).pipe(
261: Effect.andThen(Effect.never),
262: Effect.onInterrupt(() =>
263: Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
264: ),
265: )
266: ...
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
330: it.effect("starts a resume registered during interruption cleanup", () =>
331: Effect.scoped(
332: Effect.gen(function* () {
333: const firstStarted = yield* Deferred.make<void>()
334: const cleanupStarted = yield* Deferred.make<void>()
335: const cleanupGate = yield* Deferred.make<void>()
336: const secondStarted = yield* Deferred.make<void>()
337: const forces: boolean[] = []
338: const coordinator = yield* SessionRunCoordinator.make<string, never>({
339: drain: (_key, force) => {
340: forces.push(force)
341: return forces.length === 1
342: ? Deferred.succeed(firstStarted, undefined).pipe(
343: Effect.andThen(Effect.never),
344: Effect.onInterrupt(() =>
345: Deferred.succeed(cleanupStarted, ...
Single-flight coordinator state is implemented. I’m adding a forced concurrent-interrupt regression: the second caller must not run its cleanup, both callers must remain blocked behind the first cleanup, and a queued wake must start exactly once only after that cleanup completes.
Success. Updated the following files: M packages/core/test/session-run-coordinator.test.ts
Found 2 matches /home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts: Line 405: expect(yield* Fiber.poll(first)).toBeNone() Line 406: expect(yield* Fiber.poll(second)).toBeNone()
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
3: import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
4: import { testEffect } from "./lib/effect"
5:
6: const it = testEffect(Layer.empty)
7:
8: describe("SessionRunCoordinator", () => {
9: it.effect("joins concurrent resumes for one key", () =>
10: Effect.scoped(
11: Effect.gen(function* () {
12: const gate = yield* Deferred.make<void>()
(Showing lines 1-12 of 516. Use offset=13 to continue.)
</content>
<path>/home/byk/Code/opencode/packages/core/schema.json</path>
<type>file</type>
<content>
1: {
2: "version": "7",
3: "dialect": "sqlite",
4: "id": "3bc49670-06d1-4eba-9a45-8c8128bdcf6f",
5: "prevIds": [
6: "d4ff43d2-c4f9-442c-bada-a7d488b42fd2"
7: ],
8: "ddl": [
9: {
10: "name": "workspace",
11: "entityType": "tables"
12: },
(Showing lines 1-12 of 2590. Use offset=13 to continue.)
</content>
The generic coordinator regression covers wake consumption; I’m also adding a production-adapter concurrent interrupt test because activeClaims is cleared by drain finalization before durable cleanup finishes. That exact test will prove a second caller still joins the first cleanup instead of calling the coordinator without cleanup.
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
220: const started = Deferred.makeUnsafe<void>()
221: const execution = yield* SessionExecutionLocal.make({
222: run: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
223: })
224: yield* execution.wake(sessionID)
225: yield* Deferred.await(started)
226: yield* db.update(SessionExecutionTable).set({ expires_at: 0 }).run()
227: yield* TestClock.adjust("10 seconds")
228: yield* Effect.yieldNow
229:
230: expect((yield* claims)[0]?.expires_at).toBe(0)
231: }),
232: )
233:
234: it.effect("settles cancellation before starting a pending successor", () =>
235: Effect.gen(function* () {
236: yield* insertSession
237: const firstStarted = Deferred.makeUnsafe<void>()
238: const finalizerStarted = Deferred.makeUnsafe<void>()
239: const releaseFinalizer = ...
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
I’m adding the terminal-failure crash regression with a real event sequence: Step.Started, unresolved local tool, Step.Failed, expired unknown claim. Startup must preserve the provider error, fail only the unresolved tool, clear the claim, and create neither Step.Recovered nor a continuation.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
The snapshot lineage finding is confirmed: v20 still points at an intermediate ID absent from the frozen tree. Because this remains one unpublished squashed migration, I’m restoring prevIds to the immutable base snapshot ID f14a9b18-8207-487e-a3d3-227e629ba9ad, then the migration checker will verify no schema delta.
Success. Updated the following files: M packages/core/schema.json
All three blocker fixes and regressions are in place. I’m compiling and running the focused terminal-failure, concurrent-interrupt, cancellation-recovery, and migration gates before broader validation.
$ tsgo --noEmit
src/session/execution/local.ts(225,19): error TS2304: Cannot find name 'interruptions'.
src/session/execution/local.ts(225,37): error TS2304: Cannot find name 'sessionID'.
src/session/execution/local.ts(225,52): error TS2304: Cannot find name 'interruption'.
src/session/execution/local.ts(225,66): error TS2304: Cannot find name 'interruptions'.
src/session/execution/local.ts(225,87): error TS2304: Cannot find name 'sessionID'.
src/session/execution/local.ts(226,35): error TS2304: Cannot find name 'interruption'.
src/session/recovery.ts(174,37): error TS2339: Property 'content' does not exist on type 'SessionMessageData'.
src/session/recovery.ts(175,14): error TS7006: Parameter 'part' implicitly has an 'any' type.
src/session/recovery.ts(188,47): error TS2339: Property 'completed' does not exist on type '{ readonly created: number; } | { readonly created: number; readonly completed?: number | undefined; } | { readonly created: number; readonly completed?: number | ...
The first compile caught mechanical issues, not design failures: the local interruption finalizer was inserted outside its scope, Effect v4 has no Fiber.poll, and the terminal assistant query needs explicit assistant narrowing. I’m correcting those using existing Deferred completion signals rather than timing or polling.
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
160: {
161: commit: () =>
162: SessionExecutionClaim.settleCancellation(db, claim, { phase: "cancelled", assistant_message_id: null }),
163: },
164: )
165: yield* SessionExecutionClaim.clear(db, claim)
166: })
167:
168: return SessionExecution.Service.of({
169: active: coordinator.active,
170: interrupt: (sessionID) =>
171: Effect.suspend(() => {
172: const existing = interruptions.get(sessionID)
173: if (existing) return Deferred.await(existing)
174: const claim = activeClaims.get(sessionID)
175: if (!claim) return coordinator.interrupt(sessionID)
176: const interruption = Deferred.makeUnsafe<void>()
177: interruptions.set(sessionID, interruption)
178: return Effect.gen(function* () {
179: yield* SessionExecutionClaim.update(db, claim, { phase: ...
Success. Updated the following files: M packages/core/src/session/execution/local.ts M packages/core/src/session/recovery.ts M packages/core/test/session-run-coordinator.test.ts M packages/core/test/session-execution-local.test.ts
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
200: ),
201: )
202: }).pipe(
203: Effect.scoped,
204: Effect.onExit((exit) =>
205: Effect.sync(() => {
206: if (interruptions.get(sessionID) === interruption) interruptions.delete(sessionID)
207: Deferred.doneUnsafe(interruption, exit)
208: }),
209: ),
210: )
211: }),
212: resume: coordinator.run,
213: wake: coordinator.wake,
214: })
215: })
216:
217: const layer = Layer.effect(
218: SessionExecution.Service,
219: Effect.gen(function* () {
220: const store = yield* SessionStore.Service
221: const locations = yield* LocationServiceMap.Service
222: return yield* make({
223: run: Effect.fnUntraced(function* (claim, force, recoveryInputID) {
224: const session = yield* store.get(claim.sessionID)
225: if ...
Success. Updated the following files: M packages/core/src/session/execution/local.ts
$ tsgo --noEmit src/session/recovery.ts(176,37): error TS2339: Property 'content' does not exist on type 'SessionMessageData'. src/session/recovery.ts(177,14): error TS7006: Parameter 'part' implicitly has an 'any' type. src/session/recovery.ts(190,19): error TS2322: Type 'number' is not assignable to type 'Utc'. test/session-execution-local.test.ts(299,48): error TS2304: Cannot find name 'firstDone'. test/session-execution-local.test.ts(303,48): error TS2304: Cannot find name 'secondDone'. test/session-execution-local.test.ts(306,37): error TS2304: Cannot find name 'firstDone'. test/session-execution-local.test.ts(307,37): error TS2304: Cannot find name 'secondDone'. test/session-run-coordinator.test.ts(398,50): error TS2304: Cannot find name 'firstDone'. test/session-run-coordinator.test.ts(402,50): error TS2304: Cannot find name 'secondDone'. test/session-run-coordinator.test.ts(407,39): error TS2304: Cannot find name 'firstDone'. test/session-run-coordinator.test.ts(408,39): error ...
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
270: yield* Fiber.join(interrupted)
271: yield* Deferred.await(successorStarted)
272: while ((yield* execution.active).size > 0) yield* Effect.yieldNow
273:
274: expect(expiry).toBeGreaterThan(40_000)
275: expect(runs).toBe(2)
276: expect(yield* claims).toEqual([])
277: }),
278: )
279:
280: it.effect("joins concurrent interruption through durable cleanup", () =>
281: Effect.gen(function* () {
282: yield* insertSession
283: const started = Deferred.makeUnsafe<void>()
284: const finalizerStarted = Deferred.makeUnsafe<void>()
285: const releaseFinalizer = Deferred.makeUnsafe<void>()
286: const execution = yield* SessionExecutionLocal.make({
287: run: () =>
288: Deferred.succeed(started, undefined).pipe(
289: Effect.andThen(Effect.never),
290: ...
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
360: yield* Effect.all([Fiber.join(interrupt), Fiber.join(resumed)])
361: yield* Deferred.await(secondStarted)
362:
363: expect(forces).toEqual([false, true])
364: }),
365: ),
366: )
367:
368: it.effect("joins concurrent interruption behind one cleanup barrier", () =>
369: Effect.scoped(
370: Effect.gen(function* () {
371: const firstStarted = yield* Deferred.make<void>()
372: const cleanupStarted = yield* Deferred.make<void>()
373: const cleanupGate = yield* Deferred.make<void>()
374: const secondStarted = yield* Deferred.make<void>()
375: let cleanups = 0
376: let runs = 0
377: const coordinator = yield* SessionRunCoordinator.make({
378: drain: () =>
379: Effect.sync(() => ++runs).pipe(
380: Effect.flatMap((run) =>
381: ...
Found 9 matches
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 21: SessionMessageTable,
Line 153: .from(SessionMessageTable)
Line 154: .where(and(eq(SessionMessageTable.session_id, claim.session_id), eq(SessionMessageTable.type, "assistant")))
Line 155: .orderBy(desc(SessionMessageTable.seq))
Line 228: .select({ seq: SessionMessageTable.seq })
Line 229: .from(SessionMessageTable)
Line 232: eq(SessionMessageTable.session_id, claim.session_id),
Line 233: eq(SessionMessageTable.type, "assistant"),
Line 236: .orderBy(desc(SessionMessageTable.seq))
Success. Updated the following files: M packages/core/src/session/recovery.ts M packages/core/test/session-execution-local.test.ts M packages/core/test/session-run-coordinator.test.ts
$ tsgo --noEmit
src/session/recovery.ts(170,27): error TS2551: Property 'decodeUnknown' does not exist on type 'typeof import("/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/Schema")'. Did you mean 'decodeUnknownExit'?
src/session/recovery.ts(181,14): error TS7006: Parameter 'part' implicitly has an 'any' type.
src/session/recovery.ts(196,27): error TS18046: 'tool' is of type 'unknown'.
src/session/recovery.ts(199,31): error TS18046: 'tool' is of type 'unknown'.
src/session/recovery.ts(200,25): error TS18046: 'tool' is of type 'unknown'.
src/session/recovery.ts(200,82): error TS18046: 'tool' is of type 'unknown'.
src/session/recovery.ts(553,3): error TS2322: Type '[Node<Service, never, Tag<"global">>, Node<Service, never, Tag<"global">>, Node<never, never, Tag<"global">>, Node<...>]' is not assignable to type 'readonly [Node<Service, never, Tag<"global">>, Node<Service, never, Tag<"global">>, Node<never, never, Tag<"global">>, Node<...>] & { ...
Found 1 matches
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 170: ? yield* Schema.decodeUnknown(SessionMessage.Message)({ ...latest.data, id: latest.id, type: latest.type }).pipe(
Found 43 matches
/home/byk/.local/share/opencode/repos/github.com/Effect-TS/effect-smol/packages/effect/src/Schema.ts:
Line 367: * SchemaParser.decodeUnknownEffect(itemCodec)(u.value, options),
Line 1164: const decodeUnknownEffect = SchemaParser.decodeUnknownEffect(self) as (
Line 1173: Effect.match(decodeUnknownEffect(value, parseOptions), {
Line 1363: * @see {@link SchemaParser.decodeUnknownEffect} for the adapter that fails with `SchemaIssue.Issue` directly
Line 1368: export function decodeUnknownEffect<S extends Constraint>(schema: S, options?: SchemaAST.ParseOptions) {
Line 1369: const parser = SchemaParser.decodeUnknownEffect(schema, options)
Line 1390: * For `unknown` input use {@link decodeUnknownEffect}.
Line 1405: ) => Effect.Effect<S["Type"], SchemaError, S["DecodingServices"]> = decodeUnknownEffect
Line 1470: * @see {@link SchemaParser.decodeUnknownExit} for the adapter whose failure contains `SchemaIssue.Issue` directly
...
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
240: expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
241: expect(Array.from(yield* coordinator.active)).toEqual([])
242: expect(runs).toBe(1)
243: }),
244: ),
245: )
246:
247: it.effect("runs a wake registered during interruption cleanup", () =>
248: Effect.scoped(
249: Effect.gen(function* () {
250: const firstStarted = yield* Deferred.make<void>()
251: const cleanupStarted = yield* Deferred.make<void>()
252: const cleanupGate = yield* Deferred.make<void>()
253: const secondStarted = yield* Deferred.make<void>()
254: const firstDone = yield* Deferred.make<void>()
255: const secondDone = yield* Deferred.make<void>()
256: const firstDone = yield* Deferred.make<void>()
257: const secondDone = yield* Deferred.make<void>()
258: ...
Success. Updated the following files: M packages/core/src/session/recovery.ts M packages/core/test/session-run-coordinator.test.ts
$ tsgo --noEmit
src/session/recovery.ts(203,36): error TS2339: Property 'provider' does not exist on type '{ readonly type: "text"; readonly id: string; readonly text: string; } | { readonly type: "reasoning"; readonly id: string; readonly text: string; readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown; }; } | undefined; readonly time?: { ...; } | undefined; } | { ...; }'.
Property 'provider' does not exist on type '{ readonly type: "text"; readonly id: string; readonly text: string; }'.
src/session/recovery.ts(204,30): error TS2339: Property 'provider' does not exist on type '{ readonly type: "text"; readonly id: string; readonly text: string; } | { readonly type: "reasoning"; readonly id: string; readonly text: string; readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown; }; } | undefined; readonly time?: { ...; } | undefined; } | { ...; }'. ...
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
365: ),
366: )
367:
368: it.effect("joins concurrent interruption behind one cleanup barrier", () =>
369: Effect.scoped(
370: Effect.gen(function* () {
371: const firstStarted = yield* Deferred.make<void>()
372: const cleanupStarted = yield* Deferred.make<void>()
373: const cleanupGate = yield* Deferred.make<void>()
374: const secondStarted = yield* Deferred.make<void>()
375: let cleanups = 0
376: let runs = 0
377: const coordinator = yield* SessionRunCoordinator.make({
378: drain: () =>
379: Effect.sync(() => ++runs).pipe(
380: Effect.flatMap((run) =>
381: run === 1
382: ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
383: : Deferred.succeed(secondStarted, undefined),
384: ...
Success. Updated the following files: M packages/core/src/session/recovery.ts M packages/core/test/session-run-coordinator.test.ts
No files found
<path>/home/byk/Code/opencode/packages/core/src/session/message.ts</path> <type>file</type> <content> 1: export * as SessionMessage from "./message" 2: export * from "@opencode-ai/schema/session-message" (End of file - total 2 lines) </content>
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a) 3 pass 55 filtered out 0 fail 13 expect() calls Ran 3 tests across 1 file. [1.81s] bun test v1.3.14 (0d9b296a) 1 pass 17 filtered out 0 fail 6 expect() calls Ran 1 test across 1 file. [256.00ms] bun test v1.3.14 (0d9b296a) 1 pass 12 filtered out 0 fail 4 expect() calls Ran 1 test across 1 file. [629.00ms] Reading config file '/tmp/opencode-core-migration-check-xXi6Ku/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-xXi6Ku/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-xXi6Ku/full/20260917085511_schema/migration.sql 🚀
Focused gates now pass for all three new blockers, and migration consistency accepts the corrected base ancestry. I’m running the complete recovery suite next; this will exercise multi-tool replay, coordinator failure propagation, interruption cleanup, and all prior wake/recovery races together.
</task_result> </task>
The complete suite exposed test-state contamination again, not a recovery-state failure: the settled-tool test observed a previous test’s "Echo this" request, and its failed assertion then prevented scoped cleanup, causing the timeout/unhandled error. I’m inspecting that fixture’s reset point and will isolate its request state before rerunning. The v20 security reviewer returned empty output and is disqualified; source has changed anyway.
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
2215: const result = yield* session.resume(sessionID).pipe(
2216: Effect.exit,
2217: Effect.ensuring(db.run(sql`DROP TRIGGER fail_provider_tool_reconciliation`).pipe(Effect.orDie)),
2218: )
2219:
2220: expect(Exit.isFailure(result)).toBe(true)
2221: expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({ phase: "unknown" })
2222: expect(
2223: yield* db
2224: .select({ type: EventTable.type })
2225: .from(EventTable)
2226: .where(eq(EventTable.type, "session.next.step.failed.2"))
2227: .all(),
2228: ).toHaveLength(1)
2229: expect(
2230: (yield* session.context(sessionID))
2231: .filter((message) => message.type === "assistant")
2232: .flatMap((message) => message.content)
2233: .find((part) => part.type === "tool" && ...
The failure exposed a real condition in my new branch: a completed Step.Ended with phase continue is intentionally recoverable and must still enqueue the continuation. Terminal reconciliation must apply only when the claim phase is not continue; I’m narrowing it and rerunning the settled-continuation plus failed-Step regressions together.
Success. Updated the following files: M packages/core/src/session/recovery.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 1 pass 57 filtered out 0 fail 5 expect() calls Ran 1 test across 1 file. [1220.00ms] bun test v1.3.14 (0d9b296a) 1 pass 109 filtered out 0 fail 6 expect() calls Ran 1 test across 1 file. [1397.00ms]
Both terminal cases now pass: completed continue resumes exactly once, while completed failed Steps only reconcile unresolved tools and never enqueue a model call. I’m rerunning all 254 recovery tests.
bun test v1.3.14 (0d9b296a) 254 pass 0 fail 775 expect() calls Ran 254 tests across 8 files. [21.02s]
All 254 recovery tests pass. One more correctness gap remains in the terminal-failure branch: if Step.Failed has no unresolved tools, clearing is safe; if one or more unresolved tools exist, each event is idempotent and the last event clears atomically. I’m proceeding to full source validation, then the artifact must be rebuilt again.
<task id="ses_f516a1016ffeFeEenP2K872eo9" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
Post-review fixes pass 254 recovery tests and Core typecheck. Full source validation is running; artifact rebuild, smoke, refreeze, and new reviews will follow automatically.
| Package | Command | Exit | Result | Duration |
|---|---|---:|---|---:|
| packages/core | bun test --printConsoleTrace | 0 | 1,191 passed, 0 failed, 3,364 assertions, 145 files | 76.20s test / 76.28s elapsed |
| packages/core | bun typecheck | 0 | Passed | 11.44s |
| packages/schema | bun test --printConsoleTrace test/event-manifest.test.ts | 0 | 2 passed, 0 failed, 23 assertions, 1 file | 261ms test / 0.28s elapsed |
| packages/schema | bun typecheck | 0 | Passed | 2.10s |
| packages/sdk/js | bun typecheck | 0 | Passed | 0.35s |
| packages/server | bun typecheck | 0 | Passed | 11.78s |
| packages/opencode | bun typecheck | 0 | Passed | 54.44s |
| packages/client | bun typecheck | 0 | Passed | 3.45s |
Totals: 1,193 tests passed, 0 failed, 3,387 assertions across 146 files.
Warnings: none.
The core suite logged an expected StatusCodeError for https://skills.example.test/catalog/deploy/missing.md; it caused no test failure. It also emitted informational watcher and project-refresh logs.
check:generated was not run.
</task_result>
</task>
The revised source is fully green: 1,191 Core tests, 254 recovery tests, Schema manifest, and six package typechecks pass. I’m rerunning migration and diff hygiene, then rebuilding and smoking the exact post-review artifact before freezing v21.
Reading config file '/tmp/opencode-core-migration-check-eVn3Is/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-eVn3Is/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-eVn3Is/full/20260917090206_schema/migration.sql 🚀
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0ae9adb75001PVw9T34UT1GwmJ dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
The v21 build succeeded. I’m using the existing v20 harness with a fresh disposable namespace to verify the exact binary’s migration, health, recovery promotion, and idempotence, then I’ll freeze the replacement hashes.
{
"initial": {
"port": 11128,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
}
],
"indexes": [
{
"name": "session_execution_recovery_scan_idx",
"sql": "CREATE INDEX `session_execution_recovery_scan_idx` ON `session_execution` (`recovery_error`,`expires_at`,`time_created`,`id`)"
}
],
"before": {
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"message_count": 0,
"event_count": 0
},
"first": {
"port": 23786,
"health": {
...
The exact v21 binary passed four isolated starts, the recovery scan index, one deterministic queued "continue", and duplicate-free restart; no server survived. I’m freezing the 34-path candidate and clean-applying it to the immutable base now.
2ae4eea72024cafe8b69c0461063ee25cbf54608856cd258866b7f3b86620b36 /tmp/opencode/session-recovery-final-v21.patch 34 packages/client/src/generated/types.ts | 72 packages/core/schema.json | 613 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session.ts | 10 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 232 +- packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 560 ++++ packages/core/src/session/run-coordinator.ts | 40 ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 99% (6588/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
Frozen v21 cleanly applies across 34 paths. Fresh reviews will focus on the three newly closed defects: completed failed Steps cannot create continuation, concurrent interrupts join one cleanup barrier, and snapshot ancestry directly names the frozen base.
<task id="ses_f5163b08affezFg99AJO0pm85z" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f5163704cffep7FUWWYteJkfXK" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
[
{
"content": "Make failed-Step recovery terminal and tool-aware",
"status": "completed",
"priority": "high"
},
{
"content": "Serialize concurrent interruption and durable cleanup",
"status": "completed",
"priority": "high"
},
{
"content": "Restore schema snapshot ancestry to frozen base",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "in_progress",
"priority": "high"
}
]
Frozen v21 is under fresh correctness and security review.
2ae4eea72024cafe8b69c0461063ee25cbf54608856cd258866b7f3b86620b36aa98d64abb3bf6ac0ef88a940f9c41c6eb14ffeab3c3a7846921ba01ef46643drunningA provider error can publish Step.Failed while a local tool still runs, then wait for that tool at packages/core/src/session/runner/llm.ts:399-405.
If the user interrupts during that wait:
settleCancellation sees the assistant as completed.cancelled and clears the claim at packages/core/src/session/execution/local.ts:146-149.Step.Failed projector only completes the Step; it never settles pending or running tools at packages/core/src/session/message-updater.ts:225-230.The durable assistant can therefore remain completed with a running tool after the execution claim disappears. No recovery row remains to reconcile it.
The existing test follows this exact path but only checks Step and claim rows, not tool state: packages/core/test/session-runner.test.ts:3952-3997.
Cancellation cleanup must reconcile unresolved tools after all finalizers join and before clearing the claim, even when the Step was already failed.
packages/core/test/session-runner.test.ts:3906-3949 installs a failure trigger for session.next.tool.failed.1 at lines 3921-3927. Coordinator cleanup does not publish that event; it publishes Step.Interrupted at packages/core/src/session/execution/local.ts:151-163, whose projector settles the tools.
The trigger therefore never fires, and the test does not prove that failed interruption publication retains the cancelling claim. Add a deterministic failure on session.next.step.interrupted.1 and assert that:
cancelling,The completed v20 review is stale for artifact provenance but found two source gaps still present in v21. I’m rejecting v21: cancellation cleanup must reconcile unresolved tools even when Step.Failed already completed the assistant, and the publication-failure regression must fail the event cleanup actually publishes. I’ll fix both before any new freeze.
[
{
"content": "Reconcile unresolved tools before clearing completed cancellation",
"status": "in_progress",
"priority": "high"
},
{
"content": "Make interruption publication-failure regression reach real event",
"status": "pending",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
I’ll reuse the same durable per-tool reconciliation rule in coordinator cleanup: publish Tool.Failed for every unresolved tool, keep cancelling through intermediate events, atomically set cancelled with the final tool event, then clear. A completed failed assistant keeps its original error; no duplicate Step terminal event is emitted.
<path>/home/byk/Code/opencode/packages/core/src/session/execution/claim.ts</path>
<type>file</type>
<content>
75: phase: SessionExecutionTable.phase,
76: assistantMessageID: SessionExecutionTable.assistant_message_id,
77: })
78: .get()
79: .pipe(Effect.orDie)
80: if (!updated) return yield* Effect.die(new Lost(claim))
81: return updated
82: })
83:
84: export const settleCancellation = Effect.fn("SessionExecutionClaim.settleCancellation")(function* (
85: db: DatabaseService,
86: claim: Claim,
87: values: Pick<Values, "phase" | "assistant_message_id"> = {},
88: ) {
89: const now = yield* Clock.currentTimeMillis
90: const updated = yield* db
91: .update(SessionExecutionTable)
92: .set({ ...values, expires_at: now + SESSION_EXECUTION_LEASE_MS })
93: .where(and(where(claim, now), eq(SessionExecutionTable.phase, "cancelling")))
94: .returning({ id: SessionExecutionTable.id })
95: .get()
96: .pipe(Effect.orDie)
97: if ...
Success. Updated the following files: M packages/core/src/session/execution/local.ts
Success. Updated the following files: M packages/core/src/session/execution/local.ts
<path>/home/byk/Code/opencode/packages/core/test/session-runner.test.ts</path>
<type>file</type>
<content>
3895: { type: "user", text: "Interrupt blocked tool" },
3896: { type: "assistant", content: [{ type: "tool", id: "call-before-interrupt", state: { status: "error" } }] },
3897: ])
3898: requests.length = 0
3899: responseStream = undefined
3900: response = []
3901: yield* session.resume(sessionID)
3902: expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
3903: }),
3904: )
3905:
3906: it.effect("settles cancellation when interrupted tool publication defects", () =>
3907: Effect.gen(function* () {
3908: yield* setup
3909: const session = yield* SessionV2.Service
3910: const { db } = yield* Database.Service
3911: yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt publication failure" }), resume: false })
3912: ...
I’m correcting the publication-defect regression to abort Step.Interrupted, the event coordinator cleanup actually publishes for an incomplete assistant. Its contract becomes fail-closed: interrupt fails, the claim remains cancelling, the tool stays running, and no successor can acquire. I’m also extending the completed-Step.Failed test to require the active tool becomes a durable error before claim removal.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
The source and both regressions are updated. I’m running only the two cancellation paths plus typecheck first, then the complete recovery gate if they pass.
$ tsgo --noEmit
bun test v1.3.14 (0d9b296a)
test/session-runner.test.ts:
3936 | toolExecutionGate = undefined
3937 |
3938 | expect(Exit.isFailure(interrupted)).toBe(true)
3939 | expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
3940 | expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({ phase: "cancelling" })
3941 | expect(yield* session.context(sessionID)).toMatchObject([
^
error: expect(received).toMatchObject(expected)
@@ -2,5 +2,10 @@
{
+ "id": "msg_0aea275fa001iwGD87uRsU4YVm",
"text": "Interrupt publication failure",
+ "time": {
+ "created": {
+ "epochMilliseconds": 0,
+ "partsUtc": undefined,
+ },
+ },
"type": "user",
@@ -8,3 +13,3 @@
{
+ "agent": "build",
"content": [
@@ -12,7 +17,23 @@
"id": "call-interrupt-publication-failure",
+ "name": "echo",
+ "provider": {
+ "executed": false,
+ ...
The publication defect now reaches the intended event and leaves the claim cancelling; the only failure is Bun’s toMatchObject handling of optional absent finish. I’m replacing that one assertion with explicit absence plus running-tool checks.
Success. Updated the following files: M packages/core/test/session-runner.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 2 pass 108 filtered out 0 fail 13 expect() calls Ran 2 tests across 1 file. [1485.00ms] bun test v1.3.14 (0d9b296a) 254 pass 0 fail 779 expect() calls Ran 254 tests across 8 files. [21.42s]
Both cancellation regressions now reach the real production paths and all 254 recovery tests pass. Source changed after v21, so its reviews cannot approve the result; I’m rerunning full package validation, then rebuilding and freezing v22.
<task id="ses_f515c5d77ffeFN0sn7Gcr5WczX" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
V22 source validation is running. Current gate: 254 recovery tests passed, both real cancellation regressions passed, and Core typecheck passed. Rebuild, smoke, freeze, and fresh reviews follow automatically.
| Package | Command | Exit | Duration | Result |
|---|---|---:|---:|---|
| core | bun test --printConsoleTrace | 0 | 75.27s | 1,191 passed, 0 failed, 145 files, 3,368 assertions |
| core | bun typecheck | 0 | 12.54s | Passed |
| schema | bun test --printConsoleTrace test/event-manifest.test.ts | 0 | 0.30s | 2 passed, 0 failed, 1 file, 23 assertions |
| schema | bun typecheck | 0 | 1.83s | Passed |
| sdk/js | bun typecheck | 0 | 0.30s | Passed |
| server | bun typecheck | 0 | 12.99s | Passed |
| opencode | bun typecheck | 0 | 52.63s | Passed |
| client | bun typecheck | 0 | 3.77s | Passed |
Totals
core logged a failed download from the deliberate skills.example.test/.../missing.md URL. The test passed.check:generated was not run.V22 source is fully green: 1,191 Core tests, 254 recovery tests, Schema manifest, and six package typechecks pass. I’m running final migration/diff hygiene, then rebuilding and smoking the exact artifact before freezing.
Reading config file '/tmp/opencode-core-migration-check-bXcO8E/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-bXcO8E/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-bXcO8E/full/20260917091701_schema/migration.sql 🚀
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0aea87f91001eNeTHveVQUa3bt dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
V22 build succeeded. I’m running the fresh isolated restart smoke and artifact hash now; then the replacement freeze gets new reviewers only.
{
"initial": {
"port": 24098,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
}
],
"indexes": [
{
"name": "session_execution_recovery_scan_idx",
"sql": "CREATE INDEX `session_execution_recovery_scan_idx` ON `session_execution` (`recovery_error`,`expires_at`,`time_created`,`id`)"
}
],
"before": {
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"message_count": 0,
"event_count": 0
},
"first": {
"port": 13327,
"health": {
...
The exact v22 artifact passed all four isolated starts and duplicate-free recovery. I’m freezing it now, clean-applying it to the immutable base, and launching fresh correctness/security reviews centered on the two newly fixed cancellation findings.
5af96209cef23e735f09dbf2f4b2f5b0f51b208ece9e5b325e4e217b46003643 /tmp/opencode/session-recovery-final-v22.patch 34 packages/client/src/generated/types.ts | 72 packages/core/schema.json | 613 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session.ts | 10 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 279 ++ packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 560 ++++ packages/core/src/session/run-coordinator.ts | 40 ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 96% (6377/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density error: patch failed: packages/core/schema.json:1 error: packages/core/schema.json: patch does not apply error: patch failed: packages/core/src/database/migration.gen.ts:40 error: packages/core/src/database/migration.gen.ts: patch does not apply error: packages/core/src/database/migration/20260914170650_session-recovery.ts: already exists in working directory error: patch failed: packages/core/src/database/schema.gen.ts:154 error: packages/core/src/database/schema.gen.ts: patch does not apply error: patch failed: packages/core/src/session.ts:365 error: packages/core/src/session.ts: patch does not apply error: patch failed: packages/core/src/session/compaction.ts:73 error: ...
The clean-apply command repeated the earlier directory mistake: it created the detached worktree but ran the read-only git apply --check in the main populated tree. Nothing changed because the check failed before application. I’m rerunning the exact check from the detached v22 worktree.
(no output)
Frozen v22 applies cleanly in the detached base worktree. I’m launching final reviews against only the immutable v22 patch and artifact, with explicit attention to completed-Step tool reconciliation and real interruption-publication rollback.
<task id="ses_f5155b046ffevxjoS3bW2pTBdF" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f5155564affepmd7151PaF6oG9" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
[
{
"content": "Reconcile unresolved tools before clearing completed cancellation",
"status": "completed",
"priority": "high"
},
{
"content": "Make interruption publication-failure regression reach real event",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "in_progress",
"priority": "high"
}
]
Frozen v22 is under final correctness and security review.
5af96209cef23e735f09dbf2f4b2f5b0f51b208ece9e5b325e4e217b4600364310a3698a5e3b009f811a1b27eefab9e1083b560e24efee5f537f003d7edd7384aa98d64abb3bf6ac0ef88a940f9c41c6eb14ffeab3c3a7846921ba01ef46643d; final verification produced 10a3698a5e3b009f811a1b27eefab9e1083b560e24efee5f537f003d7edd7384 for /home/byk/Code/opencode/packages/opencode/dist/server/opencode-server. The reviewed artifact is no longer immutable, so this review cannot approve the candidate.No independent source-level MUST-FIX or CONCERN emerged before the artifact changed.
ab9408c81cc9916177641ae8e9f08624fc27b5b9.2ae4eea72024cafe8b69c0461063ee25cbf54608856cd258866b7f3b86620b36.packages/core/schema.json:4-7 directly names base snapshot f14a9b18-8207-487e-a3d3-227e629ba9ad.packages/core/src/session/recovery.ts:179-218).packages/core/src/session/recovery.ts:179-220).packages/core/src/session/execution/claim.ts:27-33).packages/core/src/session/execution/local.ts:168-210, packages/core/src/session/run-coordinator.ts:100-130).No tests, builds, services, or VCS mutations were performed. The unavailable repo-setup skill was the only setup limitation. The disclosed final check-call, process-local routing, and bounded clock-skew residuals remain accepted. ...
The v21 review is stale because v22 replaced its artifact. It found no additional source-level issue. Final approval remains blocked on the active v22 correctness and security reviews.
MUST-FIX — A wake can be lost during interruption setup. packages/core/src/session/execution/local.ts:179 waits for the durable phase update before calling coordinator.interrupt(...) at :180-197. During that gap, SessionRunCoordinator.wake records pendingWake = true at packages/core/src/session/run-coordinator.ts:87-92. The later interrupt unconditionally clears it at :108-109, so no successor starts at :117-123. A concurrent explicit resume can likewise join the not-yet-stopping entry and receive its interrupted result instead of retrying. This breaks the queued-wake preservation invariant.
MUST-FIX — The frozen SEA changed during review. Initial verification matched the required SHA-256 aa98d64abb3bf6ac0ef88a940f9c41c6eb14ffeab3c3a7846921ba01ef46643d. Final verification of /home/byk/Code/opencode/packages/opencode/dist/server/opencode-server produced 10a3698a5e3b009f811a1b27eefab9e1083b560e24efee5f537f003d7edd7384. The reviewed source can no longer be tied to the supplied artifact.
Other verified inputs remained stable:
ab9408c81cc9916177641ae8e9f08624fc27b5b92ae4eea72024cafe8b69c0461063ee25cbf54608856cd258866b7f3b86620b36schema.json.prevIds directly and solely references base snapshot f14a9b18-8207-487e-a3d3-227e629ba9adDO-NOT-MERGE </task_result> </task>
The stale v21 correctness review found a source race that still applies to v22: a wake arriving after the claim enters cancelling but before the coordinator marks the entry stopping can be cleared. I’m rejecting v22 and will make the coordinator enter interruption mode before the durable phase transition, while keeping cleanup authoritative and preserving concurrent-interrupt single-flight.
[
{
"content": "Preserve wakes during cancellation setup",
"status": "in_progress",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
I’m encoding the exact race first: gate the durable cancelling write, issue a wake while the coordinator entry is still running, then release cancellation setup. The successor must run once after cleanup rather than disappear.
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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, runs cleanup, then starts work that arrived while stopping. */
14: readonly interrupt: (key: Key, cleanup?: Effect.Effect<void>) => Effect.Effect<void>
15: }
16:
17: type Entry<E> = {
18: ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
155: message?.type === "assistant"
156: ? message.content.filter(
157: (part): part is SessionMessage.AssistantTool =>
158: part.type === "tool" && (part.state.status === "pending" || part.state.status === "running"),
159: )
160: : []
161: if (message?.type === "assistant" && message.time.completed !== undefined && tools.length > 0) {
162: const completed = message.time.completed
163: if (!events) return yield* Effect.die("Event service unavailable during interrupted Session settlement")
164: yield* Effect.forEach(
165: tools,
166: (tool, index) =>
167: events.publish(
168: SessionEvent.Tool.Failed,
169: {
170: sessionID: claim.sessionID,
171: assistantMessageID,
172: callID: tool.id,
173: ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
225: return Effect.gen(function* () {
226: yield* SessionExecutionClaim.update(db, claim, { phase: "cancelling" })
227: yield* Effect.raceFirst(
228: coordinator.interrupt(
229: sessionID,
230: settleCancellation(claim).pipe(
231: Effect.catchDefect((defect) => {
232: if (!(defect instanceof SessionExecutionClaim.Lost)) return Effect.die(defect)
233: return db
234: .select({ id: SessionExecutionTable.id })
235: .from(SessionExecutionTable)
236: .where(eq(SessionExecutionTable.session_id, sessionID))
237: .get()
238: .pipe(
239: Effect.orDie,
240: Effect.flatMap((current) => (current ? Effect.die(defect) : ...
The minimal correct boundary is inside SessionRunCoordinator: mark the entry stopping first, preserve any already coalesced wake, run a new pre-interrupt effect that durably writes cancelling, then interrupt the owner and perform cleanup. This also makes concurrent run() wait and retry instead of joining the soon-to-be-interrupted owner.
Success. Updated the following files: M packages/core/src/session/run-coordinator.ts M packages/core/src/session/execution/local.ts
Found 3 matches
/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts:
Line 234: it.effect("settles cancellation before starting a pending successor", () =>
/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts:
Line 247: it.effect("runs a wake registered during interruption cleanup", () =>
Line 332: it.effect("starts a resume registered during interruption cleanup", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
240: expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
241: expect(Array.from(yield* coordinator.active)).toEqual([])
242: expect(runs).toBe(1)
243: }),
244: ),
245: )
246:
247: it.effect("runs a wake registered during interruption cleanup", () =>
248: Effect.scoped(
249: Effect.gen(function* () {
250: const firstStarted = yield* Deferred.make<void>()
251: const cleanupStarted = yield* Deferred.make<void>()
252: const cleanupGate = yield* Deferred.make<void>()
253: const secondStarted = yield* Deferred.make<void>()
254: let runs = 0
255: const coordinator = yield* SessionRunCoordinator.make({
256: drain: () =>
257: Effect.sync(() => ++runs).pipe(
258: Effect.flatMap((run) =>
259: run === 1
260: ...
The coordinator now marks stopping before the durable cancellation write and no longer erases an already coalesced wake. I found one necessary failure-path detail while encoding the regression: if the pre-interrupt write loses ownership, the coordinator must still interrupt and finalize the old owner rather than leave a permanently stopping entry. I’m making that fail-closed path explicit too.
Success. Updated the following files: M packages/core/src/session/run-coordinator.ts M packages/core/test/session-run-coordinator.test.ts
The deterministic setup-gap regression is in place. I’m validating it alongside concurrent interruption, cancellation ownership loss, queued successor ordering, and typecheck before rerunning all recovery tests.
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 4 pass 15 filtered out 0 fail 9 expect() calls Ran 4 tests across 1 file. [243.00ms] bun test v1.3.14 (0d9b296a) 4 pass 9 filtered out 0 fail 12 expect() calls Ran 4 tests across 1 file. [771.00ms]
The cancellation-setup race now passes with wake and resume preservation, concurrent interruption, ownership loss, and finalizer ordering. I’m rerunning the complete recovery suite.
MUST-FIX — interruption can strand a newly committed execution claim.
packages/core/src/session/execution/local.ts:40-73 commits the claim before registering it in activeClaims, while cleanup only wraps the later race at packages/core/src/session/execution/local.ts:90-125. An interrupt during that handoff sees no claim and takes the fallback at packages/core/src/session/execution/local.ts:217-223. If interruption becomes pending while the transaction commits, the drain can exit before line 73. The durable ready claim then has no owner or heartbeat, blocks successors until lease expiry, and may trigger false recovery. Acquisition, registration, and cleanup ownership need one uninterruptible handoff plus a deterministic regression at the commit/registration boundary.
MUST-FIX — corrupt assistant projections are retried forever and can starve valid recovery.
packages/core/src/session/recovery.ts:169-177 converts message decoding errors into defects. The outer handler at packages/core/src/session/recovery.ts:316-323 only logs them, leaving recovery_error unset. Because scans always take the oldest 100 rows (packages/core/src/session/recovery.ts:72-79), use a 30-second lease, and repeat every 10 seconds (packages/core/src/session/recovery.ts:547-552, packages/core/src/session/sql.ts:253), 300 older corrupt rows can continually recycle and prevent later valid claims from entering the batch. Decode failures must quarantine the exact live claim under its execution and owner fence. ...
The stale “clear pending wake” test now contradicts the corrected invariant; a wake registered before or during interruption must survive, so I’ll update it to assert one successor. Separately, the final v22 correctness review found two real blockers: claim acquisition has an interruptible commit-to-registration handoff, and malformed assistant projections can recycle forever without quarantine. V22 is rejected; I’m adding deterministic regressions and fixing both exact boundaries.
[
{
"content": "Preserve wakes during cancellation setup",
"status": "in_progress",
"priority": "high"
},
{
"content": "Make claim acquisition-to-registration uninterruptible",
"status": "pending",
"priority": "high"
},
{
"content": "Quarantine malformed assistant projections under exact fence",
"status": "pending",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
205: }),
206: ),
207: )
208:
209: it.effect("does nothing when interrupted while idle", () =>
210: Effect.scoped(
211: Effect.gen(function* () {
212: const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void })
213: yield* coordinator.interrupt("session")
214: }),
215: ),
216: )
217:
218: it.effect("interrupts active execution and clears its pending wake", () =>
219: Effect.scoped(
220: Effect.gen(function* () {
221: const started = yield* Deferred.make<void>()
222: const interrupted = yield* Deferred.make<void>()
223: let runs = 0
224: const coordinator = yield* SessionRunCoordinator.make({
225: drain: () =>
226: Effect.sync(() => ++runs).pipe(
227: Effect.andThen(Deferred.succeed(started, undefined)),
228: ...
Success. Updated the following files: M packages/core/test/session-run-coordinator.test.ts
The coordinator tests now state one invariant consistently: every wake already recorded when interruption begins survives into one successor. I’m tracing the acquisition transaction and its cleanup scope next so registration and cleanup ownership become one uninterruptible handoff without making the runner itself uninterruptible.
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, desc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, DateTime, Deferred, Duration, Effect, Exit, Layer, Option, Schedule, Schema } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { EventV2 } from "../../event"
13: import { SessionEvent } from "../event"
14: import { SessionExecutionClaim } from "./claim"
15: import {
16: SESSION_EXECUTION_LEASE_MS,
17: SessionExecutionTable,
18: ...
The claim fix will wrap only transaction commit, local registration, and finalizer installation in an uninterruptible mask; the runner, heartbeat, database reads, and cleanup remain interruptible under the installed finalizer. I’ll use a database transaction gate to make interruption pending before commit, proving the claim is either cleaned or retained by policy, never orphaned before registration.
Found 11 matches
/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts:
Line 13: import { SessionExecutionTable, SessionTable } from "@opencode-ai/core/session/sql"
Line 41: return yield* db.select().from(SessionExecutionTable).where(eq(SessionExecutionTable.session_id, sessionID)).all()
Line 66: .select({ assistantSeq: SessionExecutionTable.assistant_seq })
Line 67: .from(SessionExecutionTable)
Line 68: .where(eq(SessionExecutionTable.id, claim.executionID))
Line 157: yield* db.update(SessionExecutionTable).set({ expires_at: 0 }).where(eq(SessionExecutionTable.id, stale.id)).run()
Line 176: .update(SessionExecutionTable)
Line 178: .where(eq(SessionExecutionTable.id, claim.executionID))
Line 226: yield* db.update(SessionExecutionTable).set({ expires_at: 0 }).run()
Line 378: .update(SessionExecutionTable)
Line 380: ...
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
3: import { TestClock } from "effect/testing"
4: import { eq, sql } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { Project } from "@opencode-ai/core/project"
7: import { ProjectTable } from "@opencode-ai/core/project/sql"
8: import { AbsolutePath } from "@opencode-ai/core/schema"
9: import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
10: import { SessionExecutionClaim } from "@opencode-ai/core/session/execution/claim"
11: import { SessionMessage } from "@opencode-ai/core/session/message"
12: import { SessionSchema } from "@opencode-ai/core/session/schema"
13: import { SessionExecutionTable, SessionTable } from "@opencode-ai/core/session/sql"
14: import { testEffect } from ...
For malformed projections, I’ll stop converting decode errors to defects. The acquired claim will be quarantined through the existing exact owner/session/execution/live-lease predicate, so poison rows leave future batches without mutating transferred ownership.
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
120: return
121: }
122: if (phase.value === "cancelled") {
123: yield* SessionExecutionClaim.clear(db, recoveryClaim)
124: return
125: }
126: if (phase.value === "cancelling") {
127: const quarantinedAt = yield* Clock.currentTimeMillis
128: const quarantined = yield* db
129: .update(SessionExecutionTable)
130: .set({ recovery_error: "Cancellation outcome unknown after owner loss" })
131: .where(
132: and(
133: eq(SessionExecutionTable.id, recoveryClaim.executionID),
134: eq(SessionExecutionTable.session_id, recoveryClaim.sessionID),
135: eq(SessionExecutionTable.owner_id, recoveryClaim.ownerID),
136: gt(SessionExecutionTable.expires_at, quarantinedAt),
137: ...
Found 24 matches
/home/byk/Code/opencode/packages/core/src/session/recovery.ts:
Line 52: isNull(SessionRecoveryTable.recovery_error),
Line 75: .where(and(lte(SessionExecutionTable.expires_at, scanTime), isNull(SessionExecutionTable.recovery_error)))
Line 94: isNull(SessionExecutionTable.recovery_error),
Line 104: const quarantinedAt = yield* Clock.currentTimeMillis
Line 105: const quarantined = yield* db
Line 107: .set({ recovery_error: `Invalid execution phase: ${String(claim.phase)}` })
Line 113: gt(SessionExecutionTable.expires_at, quarantinedAt),
Line 119: if (!quarantined) return yield* Effect.die(`Recovery execution claim changed: ${claim.id}`)
Line 127: const quarantinedAt = yield* Clock.currentTimeMillis
Line 128: const quarantined = yield* db
Line 130: .set({ recovery_error: "Cancellation outcome unknown after owner loss" })
...
I’ll quarantine decode failure at the same point the live recovery owner is already established. The update will use exact execution, Session, recovery owner, live expiry, and null prior error; if ownership moved, zero affected rows means the current pass stops without claiming success.
<path>/home/byk/Code/opencode/packages/core/src/session/recovery.ts</path>
<type>file</type>
<content>
1: export * as SessionRecovery from "./recovery"
2:
3: import { and, asc, desc, eq, exists, gt, isNull, lte, or, sql } from "drizzle-orm"
4: import { Clock, DateTime, Effect, Layer, Option, Schedule, Schema } from "effect"
5: import { Database } from "../database/database"
6: import { makeGlobalNode } from "../effect/app-node"
7: import { EventV2 } from "../event"
8: import { SessionEvent } from "./event"
9: import { SessionExecution } from "./execution"
10: import { SessionProjector } from "./projector"
11: import { SessionExecutionClaim } from "./execution/claim"
12: import { SessionInput } from "./input"
13: import { SessionMessage } from "./message"
14: import { continuation } from "./recovery-id"
15: import { SessionSchema } from "./schema"
16: import {
17: MAX_WAKE_ATTEMPTS,
18: SESSION_EXECUTION_LEASE_MS,
19: SessionExecutionTable,
20: SessionInputTable,
21: ...
Success. Updated the following files: M packages/core/src/session/recovery.ts
The poison-row path now produces a durable quarantine instead of a retrying defect. I’m wrapping acquisition through activeClaims registration and onExit installation in one uninterruptibleMask; only the runner/heartbeat body is restored to interruptibility.
Success. Updated the following files: M packages/core/src/session/execution/local.ts
Both production fixes are in place. I’m checking types before adding the two boundary regressions, since the uninterruptible handoff changes Effect environment inference across the full drain.
$ tsgo --noEmit
Typecheck passes. I’m adding one test that interrupts while claim insertion is still inside an immediate transaction, and one batch test with over 100 malformed assistant projections to prove quarantine frees the bounded scan for valid work.
Found 29 matches
/home/byk/Code/opencode/packages/core/test/session-projector.test.ts:
Line 709: it.effect("quarantines a malformed recovery row without blocking a valid wake", () =>
Line 734: executionID: "malformed-recovery-row",
Line 743: yield* db.run(sql`UPDATE session_input SET prompt = '{}' WHERE id = ${continuation("malformed-recovery-row")}`)
Line 757: .where(eq(SessionRecoveryTable.execution_id, "malformed-recovery-row"))
Line 763: it.effect("quarantines a full batch of poison recovery rows before redriving valid work", () =>
Line 767: const poisonCount = SessionRecovery.BATCH_SIZE + 1
Line 768: const sessions = Array.from({ length: poisonCount + 1 }, (_, index) =>
Line 769: SessionV2.ID.make(`ses_recovery_poison_${index.toString().padStart(3, "0")}`),
Line 794: executionID: `poison-${index.toString().padStart(3, "0")}`,
Line 799: yield* db.run(sql`UPDATE session_input SET prompt = ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
2040: .update(SessionInputTable)
2041: .set({ delivery: "steer" })
2042: .where(eq(SessionInputTable.id, continuationID))
2043: .run()
2044: yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, recoveryExecution))
2045: expect(yield* db.select().from(SessionRecoveryTable).get()).toMatchObject({
2046: recovery_error: "Invalid recovery continuation input",
2047: })
2048: expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(false)
2049: expect(yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)).toBe(0)
2050:
2051: const ordinaryID = SessionMessage.ID.make("msg_recovery_later_steer")
2052: yield* SessionInput.admit(db, events, {
2053: id: ordinaryID,
2054: sessionID,
2055: prompt: Prompt.make({ ...
Found 25 matches /home/byk/Code/opencode/packages/core/test/session-runner.test.ts: Line 1996: CREATE TRIGGER fail_hosted_tool_reconciliation Line 2063: CREATE TRIGGER fail_step_failure_publication Line 2099: CREATE TRIGGER fail_step_start_publication Line 2141: CREATE TRIGGER fail_tool_success_publication Line 2207: CREATE TRIGGER fail_provider_tool_reconciliation Line 3922: CREATE TRIGGER fail_interrupted_step_publication /home/byk/Code/opencode/packages/core/test/session-projector.test.ts: Line 1088: CREATE TRIGGER count_ordinary_redrive_input_updates Line 1150: CREATE TRIGGER settle_recovery_before_redrive_cas Line 1159: CREATE TRIGGER settle_ordinary_before_redrive_cas Line 2250: CREATE TRIGGER expire_cancellation_recovery_owner Line 2302: CREATE TRIGGER transfer_cancellation_recovery_owner Line 2355: CREATE TRIGGER fail_expired_recovery_projection ...
The existing SQLite trigger tests can force failures but cannot pause a transaction. I’m checking the test database adapter for a registered SQLite function hook; if available, the regression can block inside the session_execution insert without adding a production-only seam.
No files found
<path>/home/byk/Code/opencode/packages/core/src/database/database.ts</path>
<type>file</type>
<content>
1: export * as Database from "./database"
2:
3: import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
4: import { layer as sqliteLayer } from "#sqlite"
5: import { Context, Effect, Layer } from "effect"
6: import { Global } from "../global"
7: import { Flag } from "../flag/flag"
8: import { isAbsolute, join } from "path"
9: import { DatabaseMigration } from "./migration"
10: import { InstallationChannel } from "../installation/version"
11: import { makeGlobalNode } from "../effect/app-node"
12:
13: const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
14: type DatabaseShape = Effect.Success<typeof makeDatabase>
15:
16: export interface Interface {
17: db: DatabaseShape
18: }
19:
20: export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
21:
22: const layer = Layer.effect(
23: Service,
24: ...
Found 3 matches
/home/byk/Code/opencode/packages/codemode/src/interpreter/runtime.ts:
Line 848: private createFunction(node: AstNode): CodeModeFunction {
Line 870: this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node)
Line 1433: return Effect.sync(() => this.createFunction(node))
Found 8 matches
/home/byk/Code/opencode/packages/core/test/database-migration.test.ts:
Line 5: import { SqliteClient } from "@effect/sql-sqlite-bun"
Line 6: import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
Line 107: const filename = path.join(tmp.path, "embedded.sqlite")
Line 133: expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
Line 137: yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
Line 140: yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
Line 150: sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', ...
No files found
The handoff fix is structurally complete, but the regression needs a deterministic commit boundary. I’m adding a narrow acquisition hook to the exported local constructor so the test can hold the owner immediately after commit, request interruption, then release it; production leaves the hook absent. This proves pending interruption cannot escape registration/finalization.
Success. Updated the following files: M packages/core/src/session/execution/local.ts M packages/core/test/session-execution-local.test.ts
The regression exposed the exact ownership requirement: delaying interruption alone is not enough if the interrupt path cannot identify the committed claim. I’m registering the prospective exact claim before acquisition begins, keeping that registration through the whole drain finalizer, and letting the pre-interrupt fenced update serialize behind the immediate transaction. Thus an interrupt at commit can never take the no-claim fallback.
Success. Updated the following files: M packages/core/src/session/execution/local.ts
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
2440: .pipe(Effect.orDie)
2441: return yield* events.publish(definition, data, options)
2442: }),
2443: })
2444:
2445: yield* SessionRecovery.recover().pipe(
2446: Effect.provideService(EventV2.Service, intercepted),
2447: Effect.provideService(SessionExecution.Service, recoveryExecution),
2448: Effect.ensuring(db.run(sql`DROP TRIGGER fail_transferred_recovery_projection`).pipe(Effect.orDie)),
2449: )
2450:
2451: expect(yield* db.select().from(SessionExecutionTable).get()).toMatchObject({
2452: owner_id: "successor-owner",
2453: recovery_error: null,
2454: })
2455: }),
2456: )
2457:
2458: it.effect("quarantines failed execution conversion before scanning later claims", () =>
2459: Effect.gen(function* () {
2460: const { db } = yield* ...
No files found
Found 6 matches /home/byk/Code/opencode/packages/core/test/session-projector.test.ts: Line 465: .insert(SessionMessageTable) Line 512: .insert(SessionMessageTable) Line 626: .insert(SessionMessageTable) Line 3019: .insert(SessionMessageTable) Line 3379: .insert(SessionMessageTable) Line 3437: .insert(SessionMessageTable)
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
440: expect(yield* db.select().from(SessionRecoveryTable).all()).toEqual([])
441: expect(yield* db.select().from(SessionInputTable).all()).toEqual([])
442: }),
443: )
444:
445: it.effect("never recovers an execution superseded by a newer assistant", () =>
446: Effect.gen(function* () {
447: const { db } = yield* Database.Service
448: const abandonedID = SessionMessage.ID.make("msg_recovery_abandoned")
449: yield* db
450: .insert(ProjectTable)
451: .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
452: .run()
453: yield* db
454: .insert(SessionTable)
455: .values({
456: id: sessionID,
457: project_id: Project.ID.global,
458: slug: "test",
459: directory: "/project",
460: title: "test",
461: ...
Found 1 matches /home/byk/Code/opencode/packages/core/test/session-projector.test.ts: Line 54: const assistantRow = (
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
45: interrupt: () => Effect.void,
46: resume: () => Effect.void,
47: wake: () => Effect.void,
48: })
49: const sessionID = SessionV2.ID.make("ses_projector_test")
50: const created = DateTime.makeUnsafe(0)
51: const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
52: const encodeMessage = Schema.encodeSync(SessionMessage.Message)
53:
54: const assistantRow = (
55: id: SessionMessage.ID,
56: seq: number,
57: time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
58: ) => {
59: const {
60: id: _,
61: type,
62: ...data
63: } = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time }))
64: return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
65: }
66:
67: describe("SessionProjector", () => ...
I’m making the poison regression match the reviewer’s starvation scenario, not merely one bad row: 300 older malformed projections across three bounded passes, then advance one scan interval. Without quarantine, the first 100 recycle ahead of claim 301; with the fix, the valid claim progresses on pass four.
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { DateTime, Deferred, Effect, Exit, Fiber, Schema } from "effect"
3: import { TestClock } from "effect/testing"
4: import { asc, eq, sql } from "drizzle-orm"
5: import { Database } from "@opencode-ai/core/database/database"
6: import { LayerNode } from "@opencode-ai/core/effect/layer-node"
7: import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
8: import { EventV2 } from "@opencode-ai/core/event"
9: import { EventTable } from "@opencode-ai/core/event/sql"
10: import { ModelV2 } from "@opencode-ai/core/model"
11: import { Project } from "@opencode-ai/core/project"
12: import { ProjectTable } from "@opencode-ai/core/project/sql"
13: import { ProviderV2 } from "@opencode-ai/core/provider"
14: import { AbsolutePath } from "@opencode-ai/core/schema"
15: import { SessionV2 } from ...
<path>/home/byk/Code/opencode/packages/core/test/session-projector.test.ts</path>
<type>file</type>
<content>
25: import { SessionInput } from "@opencode-ai/core/session/input"
26: import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch"
27: import {
28: MAX_WAKE_ATTEMPTS,
29: SessionContextEpochTable,
30: SessionExecutionTable,
31: SessionInputTable,
32: SessionMessageTable,
33: SessionRecoveryTable,
34: SessionTable,
35: } from "@opencode-ai/core/session/sql"
36: import { testEffect } from "./lib/effect"
37: import { Snapshot } from "@opencode-ai/core/snapshot"
38: import { Location } from "@opencode-ai/core/location"
39: import { SystemContext } from "@opencode-ai/core/system-context"
40:
41: const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
42: const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])
43: const ...
I found and am closing the earlier edge of the same handoff: registering before the transaction lets interruption see the prospective claim, but cleanup must wait until acquisition resolves before mutating or heartbeating it. I’m adding an acquisition latch to each local registration, so interrupt setup serializes with commit whether it arrives before, during, or after the transaction.
MUST-FIX — A wake or explicit resume can be lost while interruption enters cancelling. SessionExecutionLocal.interrupt persists cancelling before calling the coordinator (packages/core/src/session/execution/local.ts:218-248). During that database write, the coordinator still treats the entry as running: wake sets pendingWake and run joins the doomed owner (packages/core/src/session/run-coordinator.ts:73-98). When interruption finally begins, it unconditionally resets pendingWake (packages/core/src/session/run-coordinator.ts:100-110). A prompt arriving in this window will not start its successor after cleanup; a concurrent explicit resume can inherit the interrupted exit. Existing tests register work only after coordinator interruption has begun, so they do not cover this gap.
MUST-FIX — Corrupt assistant projections can indefinitely starve later recovery claims. Recovery scans only the oldest 100 expired claims (packages/core/src/session/recovery.ts:71-79). After takeover, decoding the latest assistant dies on malformed stored data (packages/core/src/session/recovery.ts:169-177), but the outer handler only logs the failure (packages/core/src/session/recovery.ts:316-323); it does not quarantine the row. The row therefore expires and returns to the head of the scan. Because the lease lasts 30 seconds while scans repeat every 10 seconds (packages/core/src/session/sql.ts:253, packages/core/src/session/recovery.ts:547-551), three or more full corrupt cohorts can recycle forever and prevent later valid claims from being selected. Corrupt projection rows must be quarantined under the exact live execution/owner fence.
CONCERN — Completed cancellation does not delete the claim in the final event transaction. For a completed failed Step with unresolved tools, the final ...
The v22 security review confirms the same two blockers and adds one atomicity concern: the final cancellation event must delete the claim in its commit transaction, not mark cancelled and delete afterward. I’m folding deletion into the final Tool.Failed or Step.Interrupted commit; failed publication will roll back both projection and deletion.
Success. Updated the following files: M packages/core/src/session/execution/local.ts M packages/core/src/session/recovery.ts
The final cancellation event now deletes the exact claim inside the same durable event transaction. I’m tightening one failure edge in the new acquisition latch: if acquisition defects before producing a result, waiters must receive false and finish rather than wait forever.
Success. Updated the following files: M packages/core/src/session/execution/local.ts
I’m adding the 300-row malformed-assistant starvation test now, using valid relational rows with deliberately invalid serialized assistant data and a valid claim at sequence 301. Four bounded recovery passes must quarantine all poison and process the valid claim.
Success. Updated the following files: M packages/core/test/session-projector.test.ts
All three review fixes and their boundary tests are now present. I’m running typecheck plus acquisition-handoff, malformed-projection starvation, wake preservation, and cancellation-publication atomicity tests together.
$ tsgo --noEmit
test/session-projector.test.ts(2121,90): error TS2345: Argument of type '() => Effect.Effect<void, unknown, unknown>' is not assignable to parameter of type 'Body<void, unknown, Scope | Service | Service | TestConsole>'.
Type '() => Effect.Effect<void, unknown, unknown>' is not assignable to type '() => Effect<void, unknown, Scope | Service | Service | TestConsole>'.
Type 'Effect<void, unknown, unknown>' is not assignable to type 'Effect<void, unknown, Scope | Service | Service | TestConsole>'.
Type 'unknown' is not assignable to type 'Scope | Service | Service | TestConsole'.
test/session-projector.test.ts(2148,11): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type '{ id: string & Brand<"Session.Message.ID">; session_id: string & Brand<"SessionID">; type: "assistant"; seq: number; time_created: number; data: {}; }[]' is not assignable to parameter of type '{ data: Placeholder<string, any> | ...
Typecheck caught only test-construction issues: Drizzle correctly rejects malformed data at the typed insert boundary, and this Effect version has no repeatN. I’ll insert valid assistant rows, corrupt them afterward through raw SQL, and run four explicit bounded passes with Effect.all sequencing.
No files found
Success. Updated the following files: M packages/core/test/session-projector.test.ts
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 3 pass 11 filtered out 0 fail 9 expect() calls Ran 3 tests across 1 file. [726.00ms] bun test v1.3.14 (0d9b296a) 2 pass 57 filtered out 0 fail 7 expect() calls Ran 2 tests across 1 file. [1.76s] bun test v1.3.14 (0d9b296a) 3 pass 16 filtered out 0 fail 10 expect() calls Ran 3 tests across 1 file. [233.00ms] bun test v1.3.14 (0d9b296a) 2 pass 108 filtered out 0 fail 13 expect() calls Ran 2 tests across 1 file. [1.69s]
All focused gates pass, including 300 malformed projections across four bounded passes, commit-to-registration interruption, wake preservation, and atomic cancellation publication rollback. I’m running the complete recovery suite now.
bun test v1.3.14 (0d9b296a) 257 pass 0 fail 785 expect() calls Ran 257 tests across 8 files. [22.94s]
All 257 recovery tests pass. Before full validation, I’m running mutation controls against the frozen v22 source: the new acquisition and poison tests must fail there, and the pending-wake test must show the old cancellation setup loses the successor.
bun test v1.3.14 (0d9b296a) test/session-execution-local.test.ts: # Unhandled error between tests ------------------------------- error: Cannot find package 'effect' from '/tmp/opencode/session-recovery-v22-review/packages/core/test/session-execution-local.test.ts' ------------------------------- 0 pass 1 fail 1 error Ran 1 test across 1 file. [29.00ms] bun test v1.3.14 (0d9b296a) bun test v1.3.14 (0d9b296a) test/session-projector.test.ts: # Unhandled error between tests ------------------------------- error: Cannot find package 'effect' from '/tmp/opencode/session-recovery-v22-review/packages/core/test/session-projector.test.ts' ------------------------------- 0 pass 1 fail 1 error Ran 1 test across 1 file. [30.00ms] test/session-run-coordinator.test.ts: # Unhandled error between tests ------------------------------- error: Cannot find package 'effect' from ...
The mutation-control worktree lacks dependencies, so those commands proved only an environment blocker, not base failure. I will not count them as evidence. The fixed source already has deterministic boundary regressions; I’m moving to full validation and will report the missing old-source mutation run explicitly unless I can establish it without altering candidate source.
<task id="ses_f51395392ffejYdg0upbS1eRHq" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
V23 source validation is running.
Current gates:
Old-source mutation controls were blocked because the detached worktree has no dependencies; they are not counted as evidence.
| Package | Command | Exit | Duration | Result |
|---|---|---:|---:|---|
| packages/core | bun test --printConsoleTrace | 0 | 71.62s | 1,194 passed, 0 failed, 3,374 assertions, 145 files |
| packages/core | bun typecheck | 0 | 10.74s | No diagnostics |
| packages/schema | bun test --printConsoleTrace test/event-manifest.test.ts | 0 | 0.29s | 2 passed, 0 failed, 23 assertions, 1 file |
| packages/schema | bun typecheck | 0 | 1.88s | No diagnostics |
| packages/sdk/js | bun typecheck | 0 | 0.36s | No diagnostics |
| packages/server | bun typecheck | 0 | 12.15s | No diagnostics |
| packages/opencode | bun typecheck | 0 | 45.85s | No diagnostics |
| packages/client | bun typecheck | 0 | 3.36s | No diagnostics |
Total command runtime: 146.25s.
Failures: 0. Warnings: 0.
The core tests logged one expected error from the missing skill-download fixture and informational watcher messages. They did not cause failures.
No files were edited or formatted. VCS and services were untouched. check:generated was not run.
</task_result>
</task>
V23 is fully green: 1,194 Core tests, 257 recovery tests, Schema manifest, and six package typechecks pass. I’m running migration/diff hygiene, then rebuilding and smoking the exact artifact before freezing the next candidate.
Reading config file '/tmp/opencode-core-migration-check-MlmNuG/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-MlmNuG/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-MlmNuG/full/20260917095607_schema/migration.sql 🚀
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0aecc6e6b001NXrt5PwV65x2jA dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
V23 build succeeded. I’m running the isolated restart smoke and hashing the exact artifact, then I’ll freeze v23 and launch new reviews focused on acquisition handoff, malformed-projection quarantine, wake preservation, and atomic cancellation deletion.
{
"initial": {
"port": 29640,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
}
],
"indexes": [
{
"name": "session_execution_recovery_scan_idx",
"sql": "CREATE INDEX `session_execution_recovery_scan_idx` ON `session_execution` (`recovery_error`,`expires_at`,`time_created`,`id`)"
}
],
"before": {
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"message_count": 0,
"event_count": 0
},
"first": {
"port": 27181,
"health": {
...
The exact v23 artifact passed all isolated starts and duplicate-free recovery. I’m freezing the 34-path source and clean-applying it to the immutable base now.
14035d7631ba25b820fdc553087469935f447af034d549e2f14f8a141eda59dc /tmp/opencode/session-recovery-final-v23.patch 34 packages/client/src/generated/types.ts | 72 packages/core/schema.json | 613 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session.ts | 10 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 310 ++ packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 579 ++++ packages/core/src/session/run-coordinator.ts | 46 ...
Preparing worktree (detached HEAD ab9408c81c) HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
Frozen v23 applies cleanly. Fresh reviewers will verify the new acquisition latch, coordinator pre-interrupt boundary, exact malformed-projection quarantine, and final event transaction claim deletion, alongside all prior recovery invariants.
<task id="ses_f51317093ffeHJ1b00KMoUsuhJ" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f5130fc5affe59D154DBq9ar2J" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
[
{
"content": "Preserve wakes during cancellation setup",
"status": "completed",
"priority": "high"
},
{
"content": "Make claim acquisition-to-registration uninterruptible",
"status": "completed",
"priority": "high"
},
{
"content": "Quarantine malformed assistant projections under exact fence",
"status": "completed",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "completed",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "in_progress",
"priority": "high"
}
]
Frozen v23 is under final correctness and security review.
14035d7631ba25b820fdc553087469935f447af034d549e2f14f8a141eda59dcf875987afa4336956b9f33e14eee6de0a4af90e2bc6de899cccb7cbe5a5df998MUST-FIX — explicit resume is lost while interruption is stopping an execution.
packages/core/src/session/run-coordinator.ts:81 waits for entry.done and invokes run(key) with Effect.andThen, which only runs after success. Interruption stores the interrupted owner’s failed exit in entry.done at packages/core/src/session/run-coordinator.ts:115-128. A resume registered during cleanup therefore inherits the interruption and never starts its promised forced successor. No pendingWake is set by run. This contradicts the coordinator contract at line 13 and the regression expectation at packages/core/test/session-run-coordinator.test.ts:379-410. With no pending input, the resume disappears permanently; with durable input, recovery only retries it later.
CONCERN — an interrupt racing successful claim cleanup can defect after settlement.
Successful drain deletes the durable claim at packages/core/src/session/execution/local.ts:114-118, but the prospective claim remains visible until activeClaims cleanup at lines 141-147. An interrupt in that window observes the stale registration at lines 239-243 and runs the cancellation transition at lines 263-268. That transition dies with SessionExecutionClaim.Lost. The Lost reconciliation at lines 250-260 wraps only post-interruption cleanup, not this pre-interruption transition, so an otherwise successful interrupt request can surface an internal defect/HTTP 500.
ab9408c81cc9916177641ae8e9f08624fc27b5b914035d7631ba25b820fdc553087469935f447af034d549e2f14f8a141eda59dcf875987afa4336956b9f33e14eee6de0a4af90e2bc6de899cccb7cbe5a5df998The v23 security review found one lost-resume bug and one stale-registration race. I’m rejecting v23 and fixing both at the coordinator boundary: forced run() must retry after a stopping owner’s failed exit, and a pre-interrupt ownership loss after successful claim cleanup must resolve as an already-settled no-op rather than defect.
[
{
"content": "Preserve explicit resume across interrupted owner exit",
"status": "in_progress",
"priority": "high"
},
{
"content": "Treat post-success stale claim registration as settled interrupt",
"status": "pending",
"priority": "high"
},
{
"content": "Run full package validation and isolated artifact restart smoke",
"status": "pending",
"priority": "high"
},
{
"content": "Freeze revised candidate and obtain independent correctness and security approval",
"status": "pending",
"priority": "high"
}
]
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
60: return
61: }
62: if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) {
63: entry.pendingWake = false
64: start(key, entry, false, true)
65: return
66: }
67:
68: const successor = entry.pendingWake ? makeEntry() : undefined
69: if (successor === undefined) active.delete(key)
70: else {
71: active.set(key, successor)
72: start(key, successor, false, true)
73: }
74: Deferred.doneUnsafe(entry.done, exit)
75: }
76:
77: const run = (key: Key): Effect.Effect<void, E> =>
78: Effect.uninterruptibleMask((restore) => {
79: const entry = active.get(key)
80: if (entry !== undefined) {
81: if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
82: return ...
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
350: ),
351: ),
352: })
353:
354: yield* coordinator.wake("session")
355: yield* Deferred.await(firstStarted)
356: const interrupted = yield* coordinator
357: .interrupt(
358: "session",
359: Deferred.succeed(cleanupStarted, undefined).pipe(
360: Effect.andThen(Deferred.await(cleanupGate)),
361: Effect.andThen(Effect.die(defect)),
362: ),
363: )
364: .pipe(Effect.exit, Effect.forkChild)
365: yield* Deferred.await(cleanupStarted)
366: yield* coordinator.wake("session")
367: yield* Deferred.succeed(cleanupGate, undefined)
368:
369: const exit = yield* Fiber.join(interrupted)
370: expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBeTrue()
371: yield* ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path> <type>file</type> <content> 90: .where( 91: and( 92: eq(SessionRecoveryTable.session_id, sessionID), 93: eq(SessionRecoveryTable.wake_pending, true), 94: isNull(SessionRecoveryTable.recovery_error), 95: ), 96: ) 97: .orderBy(asc(SessionRecoveryTable.time_created), asc(SessionRecoveryTable.execution_id)) 98: .limit(1) 99: .get() 100: .pipe(Effect.orDie) 101: return yield* restore( 102: Effect.raceFirst( 103: input.run(claim, force, recovery?.continuationMessageID), 104: SessionExecutionClaim.renew(db, claim).pipe( 105: Effect.repeat(Schedule.spaced(Duration.millis(leaseDuration / 3))), 106: ), 107: ), 108: ).pipe( 109: Effect.tapCause((cause) ...
The first bug is exactly andThen propagating the interrupted owner’s failure before retry. I’m changing the stopping path to observe completion as an Exit, discard that old result, then call run(key); interruption of the waiting caller still cancels its own retry. For stale registration, I’ll apply the same ownership-loss reconciliation to the pre-interrupt transition that cleanup already uses.
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
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, runs cleanup, then starts work that arrived while stopping. */
14: readonly interrupt: (
15: key: Key,
16: cleanup?: Effect.Effect<void>,
17: beforeInterrupt?: ...
<path>/home/byk/Code/opencode/packages/core/src/session/execution/local.ts</path>
<type>file</type>
<content>
1: import { and, asc, desc, eq, isNull } from "drizzle-orm"
2: import { Cause, Clock, DateTime, Deferred, Duration, Effect, Exit, Layer, Option, Schedule, Schema } from "effect"
3: import { Database } from "../../database/database"
4: import { LocationServiceMap } from "../../location-service-map"
5: import { makeGlobalNode } from "../../effect/app-node"
6: import { SessionRunCoordinator } from "../run-coordinator"
7: import { SessionRunner } from "../runner"
8: import { SessionMessage } from "../message"
9: import { SessionSchema } from "../schema"
10: import { SessionStore } from "../store"
11: import { SessionExecution } from "../execution"
12: import { EventV2 } from "../../event"
13: import { SessionEvent } from "../event"
14: import { SessionExecutionClaim } from "./claim"
15: import {
16: SESSION_EXECUTION_LEASE_MS,
17: SessionExecutionTable,
18: ...
I’m adding one deterministic stale-registration regression with a narrow post-clear test hook: pause after successful durable claim deletion but before local registration removal, call interrupt, then prove it completes without a defect. This targets the reviewer’s exact interval.
Success. Updated the following files: M packages/core/src/session/run-coordinator.ts M packages/core/src/session/execution/local.ts
Found 1 matches
/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts:
Line 116: it.effect("owns cleanup when interruption arrives after claim commit", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-execution-local.test.ts</path>
<type>file</type>
<content>
80: yield* execution.resume(sessionID)
81:
82: expect(yield* Deferred.await(observed)).toBe(7)
83: }),
84: )
85:
86: it.effect("preserves the claim when its scope closes", () =>
87: Effect.gen(function* () {
88: yield* insertSession
89: const started = Deferred.makeUnsafe<void>()
90: const child = yield* Scope.make()
91: const execution = yield* SessionExecutionLocal.make({
92: run: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
93: }).pipe(Effect.provideService(Scope.Scope, child))
94: yield* execution.wake(sessionID)
95: yield* Deferred.await(started)
96: expect(yield* claims).toHaveLength(1)
97: yield* Scope.close(child, Exit.void)
98: expect(yield* claims).toHaveLength(1)
99: }),
100: )
101:
102: it.effect("clears the claim after ...
Success. Updated the following files: M packages/core/test/session-execution-local.test.ts
Both fixes now have direct regressions: forced resume during interruption cleanup must start with force:true, and interrupt after successful durable claim deletion must return cleanly. I’m running those plus the surrounding coordinator and ownership cases.
$ tsgo --noEmit bun test v1.3.14 (0d9b296a) 3 pass 16 filtered out 0 fail 8 expect() calls Ran 3 tests across 1 file. [311.00ms] bun test v1.3.14 (0d9b296a) 4 pass 11 filtered out 0 fail 11 expect() calls Ran 4 tests across 1 file. [875.00ms]
The exact v23 review findings now pass their regressions and Core typecheck. I’m rerunning the full 258-test recovery gate before package-wide validation.
bun test v1.3.14 (0d9b296a) 258 pass 0 fail 787 expect() calls Ran 258 tests across 8 files. [25.06s]
All 258 recovery tests pass. I’m launching complete source validation for v24; afterward I’ll rerun migration, build, isolated restart smoke, and freeze.
<task id="ses_f510df6e5ffeacm5Pb1Al1Q4NB" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
V24 source validation is running.
Current gates:
| Package | Command | Exit | Duration | Result |
|---|---|---:|---:|---|
| packages/core | bun test --printConsoleTrace | 0 | 81.03s | 1,195 passed, 0 failed, 3,376 assertions, 145 files |
| packages/core | bun typecheck | 0 | 13.99s | No diagnostics |
| packages/schema | bun test --printConsoleTrace test/event-manifest.test.ts | 0 | 0.30s | 2 passed, 0 failed, 23 assertions, 1 file |
| packages/schema | bun typecheck | 0 | 2.38s | No diagnostics |
| packages/sdk/js | bun typecheck | 0 | 0.38s | No diagnostics |
| packages/server | bun typecheck | 0 | 14.68s | No diagnostics |
| packages/opencode | bun typecheck | 0 | 73.01s | No diagnostics |
| packages/client | bun typecheck | 0 | 4.62s | No diagnostics |
Totals
https://skills.example.test/catalog/deploy/missing.md; it did not fail the test suite.No files were edited or formatted. VCS was not mutated, services were not started, recall was not used, and check:generated was not run.
</task_result>
</task>
V24 source is fully green: 1,195 Core tests, 258 recovery tests, Schema manifest, and six package typechecks pass. I’m running final migration hygiene, then rebuilding and smoking the exact v24 artifact before freezing it for replacement reviews.
Reading config file '/tmp/opencode-core-migration-check-gQf3P9/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-gQf3P9/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-gQf3P9/full/20260917104514_schema/migration.sql 🚀
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0aef95a45001vIHydYJnXGi988 dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
V24 built successfully. I’m running its isolated restart smoke and exact hash, then freezing and clean-applying the replacement patch.
{
"initial": {
"port": 13332,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
}
],
"indexes": [
{
"name": "session_execution_recovery_scan_idx",
"sql": "CREATE INDEX `session_execution_recovery_scan_idx` ON `session_execution` (`recovery_error`,`expires_at`,`time_created`,`id`)"
}
],
"before": {
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"message_count": 0,
"event_count": 0
},
"first": {
"port": 24882,
"health": {
...
c8c6d116bb57c9196fc90fea620588a1727a4194d55e3ca9596f4bafc81da3c3 /tmp/opencode/session-recovery-final-v24.patch 34 packages/client/src/generated/types.ts | 72 packages/core/schema.json | 613 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session.ts | 10 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 324 ++ packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 579 ++++ packages/core/src/session/run-coordinator.ts | 48 ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 97% (6456/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density error: patch failed: packages/core/schema.json:1 error: packages/core/schema.json: patch does not apply error: patch failed: packages/core/src/database/migration.gen.ts:40 error: packages/core/src/database/migration.gen.ts: patch does not apply error: packages/core/src/database/migration/20260914170650_session-recovery.ts: already exists in working directory error: patch failed: packages/core/src/database/schema.gen.ts:154 error: packages/core/src/database/schema.gen.ts: patch does not apply error: patch failed: packages/core/src/session.ts:365 error: packages/core/src/session.ts: patch does not apply error: patch failed: packages/core/src/session/compaction.ts:73 error: ...
The clean-apply command ran its patch check in the populated main worktree instead of the new detached worktree; git apply --check failed before applying anything, so source was not changed. I’m rerunning the check and application from /tmp/opencode/session-recovery-v24-review.
(no output)
Frozen v24 now applies cleanly to the immutable base. Fresh reviews will focus on the forced-resume retry after a failed interrupted owner and the post-success stale-registration no-op, while rechecking the full recovery design.
<task id="ses_f5104e75dffez4gh9GXSB120Mp" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
how much work is left to start testing this?
Testing is already well underway:
Before pilot/live testing, only the two active correctness and security reviews remain. If both return MERGE, no implementation work remains; we can proceed to the controlled pilot without touching the current live service. If either finds a blocker, we must fix it and repeat validation, artifact smoke, and reviews.
MUST-FIX — Explicit resumes registered while the coordinator is stopping are lost. packages/core/src/session/run-coordinator.ts:81 waits on entry.done with Effect.andThen(run(key)). Interruption completes that deferred with the old owner’s failed exit at packages/core/src/session/run-coordinator.ts:128. The failure prevents andThen from calling run(key), so the promised successor never starts. Await the old exit without propagating it, then retry run(key).
MUST-FIX — A successful claim-clear race makes interruption fail with SessionExecutionClaim.Lost. A successful drain deletes its durable claim at packages/core/src/session/execution/local.ts:116-118, but leaves it registered in activeClaims until packages/core/src/session/execution/local.ts:141-148. An interrupt in that window finds the stale registration and attempts the cancelling transition at packages/core/src/session/execution/local.ts:263-268. That transition defects because the claim is already gone. The Lost reconciliation at packages/core/src/session/execution/local.ts:248-261 covers cleanup only, not this pre-interrupt transition, so a successful settlement can still surface an interruption failure.
MUST-FIX — The frozen SEA changed during review. /home/byk/Code/opencode/packages/opencode/dist/server/opencode-server initially matched the required SHA-256 f875987afa4336956b9f33e14eee6de0a4af90e2bc6de899cccb7cbe5a5df998; the final immutable-input check produced b944e38aea927633f92ec37241bd69b1e977eec8bd5d829b57dbfbae9867e579. The reviewed artifact set is therefore no longer frozen.
ab9408c81cc9916177641ae8e9f08624fc27b5b9.14035d7631ba25b820fdc553087469935f447af034d549e2f14f8a141eda59dc.This v23 review is stale. Both source findings are fixed in frozen v24 with deterministic regressions:
Exit and starts one forced successor.SessionExecutionClaim.Lost.b944e38aea927633f92ec37241bd69b1e977eec8bd5d829b57dbfbae9867e579.Fresh v24 correctness and security reviews remain active.
MUST-FIX — forced run is lost when pendingWake exists during interruption.
run waits at packages/core/src/session/run-coordinator.ts:81. Interruption finalization starts a non-forced successor before completing entry.done at :121-128. The resumed run then joins that successor instead of starting the required forced drain. A stale advisory wake can therefore make explicit resume return without its guaranteed provider attempt.
MUST-FIX — caller interruption can enter the retry path.
packages/core/src/session/run-coordinator.ts:81 applies Effect.exit to the wait. This cannot distinguish the old owner’s interrupted Exit from interruption of the waiting caller. Under completion/interruption races, it invokes recursive run(key) before caller interruption is re-observed and can start unwanted execution. The tests cover stopping resumes and wakes separately, but not interrupted waiters.
MUST-FIX — post-clear heartbeat can defeat Lost reconciliation.
A successful drain clears its durable claim at packages/core/src/session/execution/local.ts:117-120, while the prospective registration remains until :143-150. The pre-interrupt and cleanup branches reconcile SessionExecutionClaim.Lost at :252-280, but the competing renewal branch at :284-290 does not. In the exact afterCleared window, that renewal can fail first and make interruption fail even though no current claim exists. The test at packages/core/test/session-execution-local.test.ts:138-157 reaches the window but does not deterministically control this race. ...
The v24 review found three real coordinator edges. I’m replacing recursive stopping retries with a shared deferred for the future forced successor: resumes registered while stopping will coalesce with pending wakes into one force:true drain, and an interrupted waiter cannot accidentally start work. I’ll also reconcile ownership loss in the competing heartbeat branch.
[
{
"content": "Coalesce stopping resumes into one forced successor",
"status": "in_progress",
"priority": "high"
},
{
"content": "Preserve caller interruption without recursive retry",
"status": "pending",
"priority": "high"
},
{
"content": "Reconcile post-clear heartbeat ownership loss",
"status": "pending",
"priority": "high"
},
{
"content": "Run full validation, artifact smoke, and replacement reviews",
"status": "pending",
"priority": "high"
}
]
I’m inspecting the coordinator’s cancellation tests before changing the state machine. The key choice is whether an interrupted explicit-resume caller revokes its queued forced successor; the existing contract and tests must decide that, not an ad hoc timing rule.
Found 11 matches
/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts:
Line 36: const forces: boolean[] = []
Line 38: drain: (_key, force) =>
Line 39: Effect.sync(() => forces.push(force)).pipe(
Line 52: expect(forces).toEqual([false])
Line 255: it.effect("runs a wake registered during interruption cleanup", () =>
Line 379: it.effect("starts a resume registered during interruption cleanup", () =>
Line 386: const forces: boolean[] = []
Line 388: drain: (_key, force) => {
Line 389: forces.push(force)
Line 390: return forces.length === 1
Line 410: expect(forces).toEqual([false, true])
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
1: import { describe, expect } from "bun:test"
2: import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
3: import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
4: import { testEffect } from "./lib/effect"
5:
6: const it = testEffect(Layer.empty)
7:
8: describe("SessionRunCoordinator", () => {
9: it.effect("joins concurrent resumes for one key", () =>
10: Effect.scoped(
11: Effect.gen(function* () {
12: const gate = yield* Deferred.make<void>()
13: let runs = 0
14: const coordinator = yield* SessionRunCoordinator.make({
15: drain: () => Effect.sync(() => runs++).pipe(Effect.andThen(Deferred.await(gate))),
16: })
17:
18: const first = yield* coordinator.run("session").pipe(Effect.forkChild)
19: yield* Effect.yieldNow
20: const second = yield* ...
The coordinator needs explicit successor ownership rather than recursive retry. I’m implementing stopping-time resume waiters that transfer to exactly one successor; interrupted waiters unregister before cleanup release, and a pending advisory wake plus a resume upgrades that same successor to force:true.
Success. Updated the following files: M packages/core/src/session/run-coordinator.ts M packages/core/src/session/execution/local.ts
The explicit successor state is in place. I found one necessary completion rule while reviewing it: callers attached to a current forced owner must complete with that owner when it is later interrupted, while only resumes newly registered during stopping transfer to the next successor. I’m encoding that and adding combined pending-wake/resume plus interrupted-waiter tests.
Success. Updated the following files: M packages/core/src/session/run-coordinator.ts M packages/core/test/session-run-coordinator.test.ts
The new regressions cover both review gaps: a pending wake plus resume produces one forced successor, and interrupting the waiting resume removes its request so no unwanted execution starts. I’m running the full coordinator suite and stale-registration cases now.
MUST-FIX — packages/core/src/session/run-coordinator.ts:81, :91-96, :121-127
An explicit resume registered while interruption cleanup is running loses its forced-execution intent when pendingWake is also set:
run() sees entry.stopping and waits without recording a forced successor.pendingWake.interrupt() starts the successor with force: false.Exit, then joins that advisory successor.If the advisory wake is stale or has no eligible durable work, the successor exits without a provider Step and the explicit resume returns successfully. This violates SessionRunner’s contract at packages/core/src/session/runner/index.ts:22-29: explicit runs perform one provider attempt even without eligible work.
The coordinator must coalesce the pending wake and explicit resume into exactly one force: true successor while preserving cancellation of the waiting caller. Existing tests cover resume-during-cleanup and pending-wake-during-cleanup separately, but never their combination with a failed old owner.
ab9408c81cc9916177641ae8e9f08624fc27b5b9c8c6d116bb57c9196fc90fea620588a1727a4194d55e3ca9596f4bafc81da3c3b944e38aea927633f92ec37241bd69b1e977eec8bd5d829b57dbfbae9867e579The stale activeClaims path after durable clear correctly distinguishes an absent claim from a transferred successor at packages/core/src/session/execution/local.ts:252-280; it never mutates or masks the successor. ...
The combined regression reached the intended path but released cleanup before the forked resume fiber had registered its waiter; it therefore tested a resume that began after successor creation. I’m adding a scheduler handoff before release so the test deterministically covers a registered stopping-time resume, then rerunning the full coordinator suite.
Success. Updated the following files: M packages/core/test/session-run-coordinator.test.ts
bun test v1.3.14 (0d9b296a) test/session-run-coordinator.test.ts: (fail) SessionRunCoordinator > trampolines synchronous self-waking execution [5080.69ms] ^ this test timed out after 5000ms. 20 pass 1 fail 37 expect() calls Ran 21 tests across 1 file. [6.71s]
The combined race now passes, but the full coordinator suite exposed a regression in synchronous self-wake trampolining. I’m tracing that path before proceeding; it likely comes from waiter completion ordering rather than the cancellation logic itself.
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
80: ),
81: })
82:
83: expect(Array.from(yield* coordinator.active)).toEqual([])
84: const first = yield* coordinator.run("first").pipe(Effect.forkChild)
85: yield* Deferred.await(firstStarted)
86: expect(Array.from(yield* coordinator.active)).toEqual(["first"])
87:
88: const second = yield* coordinator.run("second").pipe(Effect.forkChild)
89: yield* Deferred.await(secondStarted)
90: expect(Array.from(yield* coordinator.active)).toEqual(["first", "second"])
91:
92: yield* Deferred.succeed(firstGate, undefined)
93: yield* Fiber.join(first)
94: expect(Array.from(yield* coordinator.active)).toEqual(["second"])
95: yield* Deferred.succeed(secondGate, undefined)
96: yield* Fiber.join(second)
97: expect(Array.from(yield* ...
Found 1 matches
/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts:
Line 617: it.effect("trampolines synchronous self-waking execution", () =>
<path>/home/byk/Code/opencode/packages/core/test/session-run-coordinator.test.ts</path>
<type>file</type>
<content>
600: const coordinator = yield* SessionRunCoordinator.make({
601: drain: () =>
602: Effect.sync(() => ++active).pipe(
603: Effect.tap(() => (active === 2 ? Deferred.succeed(bothStarted, undefined) : Effect.void)),
604: Effect.andThen(Deferred.await(gate)),
605: ),
606: })
607:
608: const first = yield* coordinator.run("first").pipe(Effect.forkChild)
609: const second = yield* coordinator.run("second").pipe(Effect.forkChild)
610: yield* Deferred.await(bothStarted)
611: yield* Deferred.succeed(gate, undefined)
612: yield* Effect.all([Fiber.join(first), Fiber.join(second)])
613: }),
614: ),
615: )
616:
617: it.effect("trampolines synchronous self-waking execution", () =>
618: Effect.scoped(
619: Effect.gen(function* () {
620: ...
<path>/home/byk/Code/opencode/packages/core/src/session/run-coordinator.ts</path>
<type>file</type>
<content>
35: Effect.gen(function* () {
36: const active = new Map<Key, Entry<E>>()
37: const fork = yield* FiberSet.makeRuntime<never, void, never>()
38:
39: const makeEntry = (): Entry<E> => ({
40: done: Deferred.makeUnsafe<void, E>(),
41: pendingRuns: new Set(),
42: runWaiters: new Set(),
43: pendingWake: false,
44: stopping: false,
45: })
46:
47: const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
48: const ready = Deferred.makeUnsafe<void>()
49: const owner = fork(
50: (successor ? Effect.yieldNow : Deferred.await(ready)).pipe(
51: Effect.andThen(Effect.suspend(() => options.drain(key, force))),
52: Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))),
53: Effect.exit,
54: Effect.asVoid,
55: ),
56: )
57: ...
The self-wake path’s state transitions are unchanged and the failure hit the test’s fixed five-second ceiling at 20,000 synchronous runs. I’m rerunning that case alone to distinguish deterministic regression from aggregate scheduler contention, then I’ll profile the coordinator suite only if it repeats.
bun test v1.3.14 (0d9b296a) 3 pass 18 filtered out 0 fail 4 expect() calls Ran 3 tests across 1 file. [1.56s]
The trampoline passes alone with the two new race tests in 1.56 seconds, so the earlier timeout was aggregate scheduler contention at the existing five-second limit, not a changed state transition. I’m rerunning the whole coordinator suite once for stability, then the full recovery gate.
bun test v1.3.14 (0d9b296a) bun test v1.3.14 (0d9b296a) 21 pass 0 fail 38 expect() calls Ran 21 tests across 1 file. [1.57s] 4 pass 11 filtered out 0 fail 11 expect() calls Ran 4 tests across 1 file. [2.42s] $ tsgo --noEmit bun test v1.3.14 (0d9b296a) 260 pass 0 fail 790 expect() calls Ran 260 tests across 8 files. [54.43s]
All coordinator tests and all 260 recovery tests pass. The state machine now guarantees one forced successor when wake and resume overlap, removes interrupted resume requests, and treats post-clear heartbeat ownership loss as settled only when no successor claim exists. I’m running complete package validation for v25.
<task id="ses_f50e9b361ffepJzpEEExoJPMeD" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
V25 source validation is running.
Current gates:
| Package | Command | Exit | Duration |
|---|---|---:|---:|
| packages/core | bun test --printConsoleTrace | 1 | 401.875054311s |
| packages/core | bun typecheck | 0 | 35.232942616s |
| packages/schema | bun test --printConsoleTrace test/event-manifest.test.ts | 0 | 1.166885434s |
| packages/schema | bun typecheck | 0 | 4.653479951s |
| packages/sdk/js | bun typecheck | 0 | 0.839886308s |
| packages/server | bun typecheck | 0 | 33.247053164s |
| packages/opencode | bun typecheck | 0 | 333.113226888s |
| packages/client | bun typecheck | 0 | 10.557968700s |
Total duration: 820.686497372s
Test Totals
401.00s1111.00msCore Failures
LocationServiceMap > rejects an unavailable selected model during location model resolution
7192.99msSnapshot > isolates snapshot indexes by canonical Git worktree
5015.46msUnhandled snapshot error:
ENOENT: no such file or directory, statx '/tmp/opencode-core-test-cZf16q/snapshot/global/4ea03f6f86de8f1fc89dc5104ec417851cae8f67'WebFetchTool registration > returns an error result when HTML-to-Markdown conversion throws
14382.38msDatabaseMigration > declared schema has no ungenerated migrations
30000.19msutil.effect-flock > recovers after a crashed lock owner
5101.04ms/tmp/eflock-crash-xRyONv/ready...
The package-wide run failed from aggregate contention, not recovery assertions: six unrelated five/30-second tests timed out after a 401-second run, while all 260 recovery tests and every typecheck passed. I’m isolating those six files with --printConsoleTrace; the snapshot ENOENT occurred after its timeout teardown and will be judged from the isolated run.
[
{
"content": "Coalesce stopping resumes into one forced successor",
"status": "completed",
"priority": "high"
},
{
"content": "Preserve caller interruption without recursive retry",
"status": "completed",
"priority": "high"
},
{
"content": "Reconcile post-clear heartbeat ownership loss",
"status": "completed",
"priority": "high"
},
{
"content": "Isolate package-wide contention failures",
"status": "in_progress",
"priority": "high"
},
{
"content": "Run artifact smoke and replacement reviews",
"status": "pending",
"priority": "high"
}
]
/home/byk/Code/opencode/packages/core/test/database-migration.test.ts /home/byk/Code/opencode/packages/core/test/tool-webfetch.test.ts /home/byk/Code/opencode/packages/core/test/snapshot.test.ts /home/byk/Code/opencode/packages/core/test/npm-config.test.ts
/home/byk/Code/opencode/packages/core/test/location.test.ts /home/byk/Code/opencode/packages/core/test/location-layer.test.ts /home/byk/Code/opencode/packages/core/test/location-mutation.test.ts /home/byk/Code/opencode/packages/core/test/location-filesystem.test.ts /home/byk/Code/opencode/packages/core/test/config/config.test.ts /home/byk/Code/opencode/packages/core/test/util/effect-flock.test.ts /home/byk/Code/opencode/packages/core/test/util/flock.test.ts /home/byk/Code/opencode/packages/core/test/npm-config.test.ts
Found 1 matches
/home/byk/Code/opencode/packages/core/test/location-layer.test.ts:
Line 143: it.live("rejects an unavailable selected model during location model resolution", () =>
bun test v1.3.14 (0d9b296a) test/tool-webfetch.test.ts: (fail) WebFetchTool registration > returns an error result when HTML-to-Markdown conversion throws [5544.57ms] ^ this test timed out after 5000ms. 65 pass 1 fail 208 expect() calls Ran 66 tests across 6 files. [49.54s]
Five failure areas pass in the six-file slice; only the WebFetch conversion test still crossed its fixed five-second limit. I’m running that single test alone to determine whether it is contention-sensitive or a current deterministic failure.
bun test v1.3.14 (0d9b296a)
test/tool-webfetch.test.ts:
189 | }),
190 | )
191 | const registry = yield* ToolRegistry.Service
192 | const url = "https://1.1.1.1/deep-html"
193 |
194 | expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({
^
error: expect(received).toEqual(expected)
{
- "type": "error",
- "value": "Unable to fetch https://1.1.1.1/deep-html",
+ "type": "text",
+ "value": "content",
}
- Expected - 2
+ Received + 2
at toEqual (unknown:1:1)
at /home/byk/Code/opencode/packages/core/test/tool-webfetch.test.ts:194:79
at ~effect/Effect/successCont (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:910:23)
at runLoop (/home/byk/Code/opencode/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:420:66)
at evaluate ...
The isolated WebFetch test is a real unrelated failure, not evidence against recovery: its test converter returns "content" instead of throwing, then the test times out during teardown. Recovery does not touch WebFetch. I’m checking the test fixture and current diff to confirm no recovery-owned dependency altered that path; if clean, I’ll report it as pre-existing validation debt and continue with the validated recovery gate.
<path>/home/byk/Code/opencode/packages/core/test/tool-webfetch.test.ts</path>
<type>file</type>
<content>
145:
146: it.effect("rejects non-HTTP schemes before permission or transport", () =>
147: Effect.gen(function* () {
148: reset()
149: const registry = yield* ToolRegistry.Service
150:
151: expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({
152: type: "error",
153: value: "Unable to fetch file:///etc/passwd",
154: })
155: expect(assertions).toEqual([])
156: expect(requests).toEqual([])
157: }),
158: )
159:
160: it.effect("converts HTML to requested markdown and text", () =>
161: Effect.gen(function* () {
162: reset()
163: respond = () =>
164: Effect.succeed(
165: new Response("<h1>Hello</h1><p>world</p><script>bad()</script>", {
166: headers: { "content-type": "text/html; charset=utf-8" },
167: }),
168: ...
(no output)
No recovery-owned source, test, manifest, or lockfile touches WebFetch; the isolated failure comes from its assumption that deeply nested HTML must throw, which the current converter no longer does. I’m leaving that unrelated test untouched and proceeding with v25 using the clean 260-test recovery gate, all typechecks, and isolated passes for the other five timeout areas.
[
{
"content": "Coalesce stopping resumes into one forced successor",
"status": "completed",
"priority": "high"
},
{
"content": "Preserve caller interruption without recursive retry",
"status": "completed",
"priority": "high"
},
{
"content": "Reconcile post-clear heartbeat ownership loss",
"status": "completed",
"priority": "high"
},
{
"content": "Isolate package-wide contention failures",
"status": "completed",
"priority": "high"
},
{
"content": "Run artifact smoke and replacement reviews",
"status": "in_progress",
"priority": "high"
}
]
Reading config file '/tmp/opencode-core-migration-check-n4PSBc/incremental.config.ts' No schema changes, nothing to migrate 😴 Reading config file '/tmp/opencode-core-migration-check-n4PSBc/full.config.ts' [✓] Your SQL migration ➜ /tmp/opencode-core-migration-check-n4PSBc/full/20260917114511_schema/migration.sql 🚀
...output truncated... Full output saved to: /home/byk/.local/share/opencode/tool-output/tool_0af30dc60001phqZg1podh0Gga dist/assets/bip-bop-08-DBf7Bwjz.aac 5.30 kB dist/assets/staplebops-03-Aug82oH0.aac 5.31 kB dist/assets/alert-04-CaGsIGFP.aac 5.32 kB dist/assets/gdresource-BD-1uzY7.js 5.34 kB dist/assets/qml-DmGQGbca.js 5.38 kB dist/assets/zig-bSFrHQ7n.js 5.39 kB dist/assets/dax-oldcCaJg.js 5.41 kB dist/assets/yup-05-CuuaeyjC.aac 5.42 kB dist/assets/bicep-jWz-hQ8I.js 5.43 kB dist/assets/xml-BkAJuBCc.js 5.43 kB dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 5.47 kB dist/assets/awk-CrWgQKhV.js 5.51 kB dist/assets/coq-BBL5-mG4.js ...
V25 built successfully. I’m smoking the exact artifact under a fresh isolated namespace, then freezing it for the two replacement reviews.
{
"initial": {
"port": 10768,
"health": {
"healthy": true,
"version": "1.18.30"
},
"stderr": ""
},
"schema": [
{
"name": "session",
"column_name": "wake_attempts",
"dflt_value": "0"
},
{
"name": "session_input",
"column_name": "wake_pending",
"dflt_value": "false"
},
{
"name": "session_recovery",
"column_name": "wake_pending",
"dflt_value": "true"
},
{
"name": "session_recovery",
"column_name": "wake_attempts",
"dflt_value": "0"
}
],
"indexes": [
{
"name": "session_execution_recovery_scan_idx",
"sql": "CREATE INDEX `session_execution_recovery_scan_idx` ON `session_execution` (`recovery_error`,`expires_at`,`time_created`,`id`)"
}
],
"before": {
"execution_count": 1,
"recovery_count": 0,
"input_count": 0,
"message_count": 0,
"event_count": 0
},
"first": {
"port": 20858,
"health": {
...
541e8ae6e59a47c09302302a9d02348ad60188a35f8c6bbd633c075091e9fcea /tmp/opencode/session-recovery-final-v25.patch 34 packages/client/src/generated/types.ts | 72 packages/core/schema.json | 613 ++++ packages/core/src/database/migration.gen.ts | 1 .../migration/20260914170650_session-recovery.ts | 62 packages/core/src/database/schema.gen.ts | 53 packages/core/src/session.ts | 10 packages/core/src/session/compaction.ts | 47 packages/core/src/session/context-epoch.ts | 88 - packages/core/src/session/execution/local.ts | 315 ++ packages/core/src/session/input.ts | 422 +++ packages/core/src/session/message-updater.ts | 69 packages/core/src/session/projector.ts | 89 + packages/core/src/session/recovery.ts | 579 ++++ packages/core/src/session/run-coordinator.ts | 62 ...
Preparing worktree (detached HEAD ab9408c81c) Updating files: 95% (6332/6637) Updating files: 96% (6372/6637) Updating files: 97% (6438/6637) Updating files: 98% (6505/6637) Updating files: 99% (6571/6637) Updating files: 100% (6637/6637) Updating files: 100% (6637/6637), done. HEAD is now at ab9408c81c feat(app): adapt vertical tab density
(no output)
V25 is frozen and cleanly applied. I’m launching final replacement reviews with the exact artifact hash and explicit attention to successor coalescing, waiter cancellation, and heartbeat ownership transfer.
<task id="ses_f50cc4778ffeVeqvjMnaW5md58" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
<task id="ses_f50cbd7d8ffee6Hia3LhZuRi5i" state="running"> <summary>Background task started</summary> <task_result> The task is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response. </task_result> </task>
V25 is frozen and under final correctness and security review.
Current evidence:
541e8ae6e59a47c09302302a9d02348ad60188a35f8c6bbd633c075091e9fcea3db39e240bd3a779d74bc4c801397e49bb2763f1ee1c19c6a179f2541c52c36dThe full Core run had unrelated timeout contention and an isolated pre-existing WebFetch test assumption failure; recovery-owned tests remain green. Pilot testing remains blocked only on both reviews returning MERGE.
Date: Sep 17, 2026 * 🟡 (09:56) V23 source validation passed with 0 failures and 0 warnings in 146.25s: `packages/core` `bun test --printConsoleTrace` — 1,194 passed, 0 failed, 3,374 assertions across 145 files in 71.62s; `packages/core` `bun typecheck` — no diagnostics in 10.74s; `packages/schema` `bun test --printConsoleTrace test/event-manifest.test.ts` — 2 passed, 0 failed, 23 assertions in 1…
Date: Sep 17, 2026 * 🟡 (09:58) V23 isolated restart smoke passed across three healthy starts of opencode-server version 1.18.30 on ports 29640, 27181, and 18791, followed by a healthy third restart on port 29261; all reported empty stderr. * 🟡 (09:58) V23 schema verification confirmed defaults: session.wake_attempts=0; session_input.wake_pending=false; session_recovery.wake_pending=true; sessio…
Date: Sep 17, 2026 * 🟡 (09:57) V23 build succeeded. * 🟡 (09:57) Began the isolated restart smoke and hashing of the exact V23 artifact. * 🟡 (09:57) Planned to freeze V23 and launch new reviews focused on acquisition handoff, malformed-projection quarantine, wake preservation, and atomic cancellation deletion.
Date: Sep 17, 2026 * 🟡 (09:47) Updated `packages/core/src/session/execution/local.ts` so the final cancellation event deletes the exact claim inside the same durable event transaction, resolving the crash window between marking a claim `cancelled` and deleting it. * 🟡 (09:47) Tightened the acquisition latch in `packages/core/src/session/execution/local.ts`: if acquisition defects before produci…
Date: Sep 17, 2026 * 🟡 (09:46) Claim-handoff investigation found an additional race in `packages/core/src/session/execution/local.ts`: pre-registering a prospective claim lets interruption identify it, but cleanup or heartbeat must not mutate it until acquisition resolves. Planned fix: add an acquisition latch to every local registration so interrupt setup serializes with claim commit whether in…
Date: Sep 17, 2026 * 🟡 (09:36) Updated `packages/core/test/session-run-coordinator.test.ts` so coordinator tests consistently enforce that every wake already recorded when interruption begins survives into exactly one successor. * 🟡 (09:36) Claim-handoff design for `packages/core/src/session/execution/local.ts`: use `Effect.uninterruptibleMask` only around transaction commit, local `activeClaim…
Date: Sep 17, 2026 * 🟡 (09:30) Completed V21 correctness review found a source race still applicable to V22: `packages/core/src/session/execution/local.ts:179` waited for the durable phase update before `coordinator.interrupt(...)` at lines 180-197; a wake during that gap set `pendingWake=true` in `packages/core/src/session/run-coordinator.ts:87-92`, but interruption then unconditionally cleared…
Date: Sep 17, 2026 * 🟡 (09:18) V22 isolated artifact smoke completed across four healthy starts on ports `24098`, `13327`, `29752`, and `18472`; every start reported version `1.18.30` with empty stderr. * 🟡 (09:18) V22 smoke verified schema defaults: `session.wake_attempts=0`, `session_input.wake_pending=false`, `session_recovery.wake_pending=true`, and `session_recovery.wake_attempts=0`. It al…
Date: Sep 17, 2026 * 🔴 (09:18) V22 build completed successfully and generated the `dist/assets/` bundle; notable output included `dist/assets/sprite-Fb-TFjRY.svg` at 943.96 kB (243.53 kB gzip) and `dist/assets/shiki-JaaGU9RH.js` at 9,575.87 kB (1,664.38 kB gzip; 12,683.64 kB source map). * 🟡 (09:18) Assistant began a fresh isolated restart smoke test and artifact hash verification after the suc…
Date: Sep 17, 2026 * 🔴 (09:16) Background validation of v22 source completed successfully: 8 commands passed, 0 failed; 1,193 tests passed, 0 failed across 146 files; 3,391 assertions; cumulative command duration 159.63s; no typecheck diagnostics or warnings. * 🔴 (09:16) `packages/core` validation: `bun test --printConsoleTrace` exited 0 in 75.27s with 1,191 passed, 0 failed across 145 files an…
### Current State - **Date:** Sep 17, 2026, latest observed time **08:57**. - **Repository / branch:** `/home/byk/Code/opencode`, branch `byk/cumulative`. - **Git identities:** - Current committed `HEAD`: `dc661f30cb2055ad6248fdc59b14998d834497be` (`feat(core): recover interrupted sessions`). - Immutable review base: `ab9408c81cc9916177641ae8e9f08624fc27b5b9` (`feat(app): adapt vertical tab …
Date: Sep 17, 2026 * 🔴 (09:09) User-provided correctness review stated that the `Step.Failed` projector “never settles pending or running tools” at `packages/core/src/session/message-updater.ts:225-230`. A provider error can publish `Step.Failed` while a local tool remains active, after which `packages/core/src/session/runner/llm.ts:399-405` waits for that tool; if interrupted, joined finalizers…
Date: Sep 17, 2026 * 🟡 (09:03) Exact v21 SEA artifact smoke tested `packages/opencode/dist/server/opencode-server` with SHA-256 `aa98d64abb3bf6ac0ef88a940f9c41c6eb14ffeab3c3a7846921ba01ef46643d`; the binary passed four isolated starts on ports `11128`, `23786`, `16095`, and `26618`, each reporting `{"healthy":true,"version":"1.18.30"}` with empty stderr. * 🟡 (09:03) Smoke-test migration schema …
Date: Sep 17, 2026 * 🟡 (09:03) The v21 build completed successfully; full build output was saved to `/home/byk/.local/share/opencode/tool-output/tool_0ae9adb75001PVw9T34UT1GwmJ`. * 🟡 (09:03) Build output included Rollup’s advisory to use `build.rollupOptions.output.manualChunks` to improve chunking; it did not prevent a successful build. * 🟡 (09:03) Assistant planned to test the exact v21 bina…
Date: Sep 17, 2026 * 🟡 (09:02) Background task `ses_f516a1016ffeFeEenP2K872eo9` completed post-review validation successfully: 1,193 tests passed, 0 failed, with 3,387 assertions across 146 files; no warnings. * 🟡 (09:02) `packages/core` command `bun test --printConsoleTrace` exited 0 with 1,191 passed, 0 failed, 3,364 assertions across 145 files in 76.20s test time / 76.28s elapsed; `bun typec…
Date: Sep 17, 2026 * 🟡 (08:57) Complete recovery suite passed: `bun test v1.3.14 (0d9b296a)` reported 254 pass, 0 fail, and 775 `expect()` calls across 8 files in 21.02s. * 🟡 (08:57) Assistant identified terminal-failure branch behavior: when `Step.Failed` has no unresolved tools, clearing is safe; when one or more unresolved tools exist, each event is idempotent and the final event clears atom…
Date: Sep 17, 2026 * 🟡 (08:56) Background task `ses_f51808c1affeB5LJt71L2pUREb` (“Review v20 security”) completed with empty output after Lore recall failed; assistant disqualified the reviewer, noting the source had changed. * 🟡 (08:56) Complete-suite investigation initially attributed the failure to test-state contamination: the settled-tool test saw a prior test’s `"Echo this"` request, and …
Date: Sep 17, 2026 * 🟡 (08:49) `packages/core/test/session-execution-local.test.ts` was updated with the concurrent-interruption regression. * 🟡 (08:49) Assistant added a terminal-failure crash regression to `packages/core/test/session-projector.test.ts` using the sequence `Step.Started` → unresolved local tool → `Step.Failed` → expired `unknown` claim. Required behavior: preserve the provider …
Date: Sep 17, 2026 * 🟡 (08:48) `/home/byk/Code/opencode/packages/core/schema.json` has `version: "7"`, `dialect: "sqlite"`, ID `3bc49670-06d1-4eba-9a45-8c8128bdcf6f`, previous ID `d4ff43d2-c4f9-442c-bada-a7d488b42fd2`, and includes the `workspace` table in its DDL. * 🟡 (08:48) Assistant planned a production-adapter concurrent-interrupt regression in `packages/core/test/session-execution-local.t…
Date: Sep 17, 2026 * 🟡 (08:46) Assistant chose a two-layer single-flight interruption design spanning generic `SessionRunCoordinator` and `SessionExecutionLocal`: concurrent callers join the same durable cleanup result so they cannot repeat the phase transition, omit cleanup, consume a successor wake, or finalize the coordinator entry early. * 🟡 (08:46) Initial patch to `/home/byk/Code/opencode…
Date: Sep 17, 2026 * 🔴 (08:41) User supplied completed security review task `ses_f52fef029ffe2a2CGDN98jGF2v`, which returned `DO-NOT-MERGE` with 3 MUST-FIX findings. * 🔴 (08:41) User stated recovery only verifies that the referenced assistant is the latest assistant and never checks whether that assistant already has `time.completed`, `finish`, or an error (`packages/core/src/session/recovery.t…
Date: Sep 17, 2026 * 🟡 (08:31) Recreated strict disposable smoke harness at `/tmp/opencode/session-recovery-v20-smoke.mjs`; `/tmp/opencode` existed with permissions `drwxr-xr-x`. * 🟡 (08:31) Exact rebuilt artifact smoke passed four isolated launches using authenticated health checks; every launch reported `healthy: true`, version `1.18.30`, and empty stderr. Ports were `19077` initially, then `…
Date: Sep 17, 2026 * 🟡 (08:30) Client build succeeded and emitted generated assets; build output included Rollup’s recommendation to use `build.rollupOptions.output.manualChunks` to improve chunking. Full truncated output was saved to `/home/byk/.local/share/opencode/tool-output/tool_0ae7c8bd2001EawPXFHnzXyiHY`. * 🟡 (08:30) Exact artifact smoke could not start because Node.js v24.16.0 raised `M…
Date: Sep 17, 2026 * 🟡 (08:28) Revised full validation completed successfully in 144.01s measured command time: `core` command `bun test --printConsoleTrace` passed 1,188 tests with 0 failures and 3,349 assertions across 145 files in 67.08s (Bun: 67.00s); `core` `bun typecheck` passed in 10.81s; `schema` command `bun test --printConsoleTrace test/event-manifest.test.ts` passed 2 tests with 0 fai…
Date: Sep 17, 2026 * 🔴 [requested-agent-resumption] (08:18) User stated the server restarted and directed the assistant to nudge any background agents so they resume. * 🟡 (08:18) Assistant stated correctness review had finished with v19 rejected and resumed the potentially interrupted security reviewer as background task `ses_f52fef029ffe2a2CGDN98jGF2v`, while continuing the post-finalizer canc…
Date: Sep 17, 2026 * 🟡 (02:01) Independent correctness review task `ses_f52ff5084ffe7snDvSQgJjIjEs` completed with verdict `DO-NOT-MERGE` for frozen patch SHA-256 `a2bce01664538243cb8ba16c6ca8f0364c6f79463ad6677567044a1b3e1e74fe` against HEAD `ab9408c81cc9916177641ae8e9f08624fc27b5b9`, tree `d3323b963b9ef2de41a7ff9884299c74b93886ba`, and SEA SHA-256 `753b05afb86195246b211237ca5b11e26a7c08da863d7…
Date: Sep 17, 2026 * 🟡 (01:31) Disposable recovery harness `/tmp/opencode/session-recovery-v18-smoke.mjs` targeted `/home/byk/Code/opencode/packages/opencode/dist/server/opencode-server` with isolated root `/tmp/opencode/session-recovery-final-v18`, database `/tmp/opencode/session-recovery-final-v18/opencode.db`, workspace `/tmp/opencode/session-recovery-final-v18/workspace`, randomized ports fr…
Date: Sep 17, 2026 * 🟡 (01:31) Assistant reported the exact SEA build succeeded with only the known chunk-size and note-injection warnings. * 🟡 (01:31) Disposable smoke script `/tmp/opencode/session-recovery-v15-smoke.mjs` was not found. * 🟡 (01:31) Assistant reconstructed the validated v15 recovery-smoke procedure as `/tmp/opencode/session-recovery-v18-smoke.mjs`, using a new namespace, bound…
Date: Sep 17, 2026 * 🟡 (01:29) Background validation task `ses_f530ae2d8ffeTtcPnPzmzIZYi9` completed. In `packages/core`, `bun test --printConsoleTrace` exited 0 with exactly 1,187 passed, 0 failed, and 3,332 assertions across 145 files in 85.32s. * 🟡 (01:29) Schema manifest validation in `packages/schema` used `bun test --printConsoleTrace test/event-manifest.test.ts` and exited 0 with exactly…
Date: Sep 17, 2026 * 🟡 (01:21) Complete recovery gate passed on the current tree: `tsgo --noEmit` succeeded; Bun `v1.3.14 (0d9b296a)` ran 250 tests across 8 files with 743 `expect()` calls, 250 pass, and 0 fail in 45.00s. * 🟡 (01:21) Assistant confirmed cancellation fallback now has a single owner at coordinator cleanup and the generated current client is updated; next steps were generated-file…
Date: Sep 17, 2026 * 🟡 (01:03) Assistant specified the `SessionExecutionLocal` cleanup fallback as an exact durable transaction executed only after `coordinator.interrupt()` joins the runner and its finalizers: a terminal assistant settles and clears the claim; an incomplete durable assistant gets exactly one `Step.Interrupted` publication with `cancelling → cancelled`, then the claim is cleared…
Date: Sep 17, 2026 * 🟡 (00:52) Assistant initially implemented a narrow durable commit-boundary synchronization experiment in `packages/core/src/event.ts` and `packages/core/src/session/runner/publish-llm-event.ts`: synchronize assistant identity/active state after committed `SessionEvent.Step.Started`, synchronize terminal flags at the same boundary, and register text/reasoning fragment buffers…
Date: Sep 17, 2026 * 🟡 (00:49) Diagnostic run confirmed the interrupted `Step.Started` path leaves a `SessionExecutionTable` claim present in phase `"cancelling"` rather than clearing it: `assistant_message_id="msg_0acd6d642001C0tvg1ECbHw6ZG"`, `assistant_seq=3`, `expires_at=30000`, `id="142fe6f0-cf21-4763-b6df-e6a897ed1359"`, `owner_id="ff35580f-a135-4f4b-8c2a-c32125ad0dfc"`, `session_id="ses_r…
Date: Sep 17, 2026 * 🟡 (00:48) Post-fix targeted run of `packages/core/test/session-runner.test.ts` still failed `SessionRunnerLLM > settles cancellation when Step start notification is interrupted` at `packages/core/test/session-runner.test.ts:4047`: query for `EventTable.type = "session.next.step.interrupted.1"` returned `[]` instead of an event whose `data.assistantMessageID` matched `claim.a…
Date: Sep 17, 2026 * 🟡 (00:47) Separate runner-cause diagnostics identified `InterruptError: All fibers interrupted without error` at `V2Session.interrupt` in `packages/core/test/session-runner.test.ts:4030:42`, originating from `packages/core/src/session.ts:432:25`; the coordinator still surfaced `Error: Interrupted Session settlement failed: ses_runner_test`. * 🟡 (00:47) The diagnostic run re…
Date: Sep 17, 2026 * 🟡 (00:45) Post-fix validation ran `tsgo --noEmit` and targeted Bun tests in `packages/core/test/session-runner.test.ts`; `SessionRunnerLLM > settles cancellation when Step start notification is interrupted` still failed because the query for `session.next.step.interrupted.1` returned `[]` instead of an event with `data.assistantMessageID: "msg_0acd377fa001tWy5i25TFPXgIh"`. R…
Date: Sep 17, 2026 * 🟡 (00:38) Validation ran `tsgo --noEmit` and then targeted Bun tests in `packages/core/test/session-runner.test.ts`; `SessionRunnerLLM > settles cancellation when Step start notification is interrupted` failed with `Interrupted Session settlement failed: ses_runner_test`. Result: 2 pass, 107 filtered out, 1 fail, 9 `expect()` calls across 1 file in 3.18s. * 🟡 (00:38) Initia…
Date: Sep 17, 2026 * 🟡 (00:36) Assistant specified the minimal fix for the symmetric `Step.Started` cancellation boundary: cancellation should adopt the assistant ID already stored on the exact durable execution claim before flushing fragments or publishing interruption, and must not call `startAssistant()` again for that assistant. * 🟡 (00:37) The targeted `Step.Started` cancellation fix modif…
Date: Sep 17, 2026 * 🟡 (00:35) Security review task `ses_f5342a3cdffezlzQRwyEts4Bkm` completed with `DO-NOT-MERGE` because cancellation can strand a Session at the post-commit `Step.Started` boundary. * 🟡 (00:35) Security review’s failure sequence: `startAssistant()` commits `Step.Started` before storing the assistant ID in publisher memory (`packages/core/src/session/runner/publish-llm-event.t…
Date: Sep 17, 2026 * 🟡 (00:20) Frozen v18 patch `/tmp/opencode/session-recovery-final-v18.patch` had SHA-256 `63c11067ed78471a8109455f164327a1c6dc2ff547e5cb78cad185fe2d844e82`; diff covered 34 files with `8293 insertions(+)` and `464 deletions(-)`. * 🟡 (00:20) Frozen v18 changed: `packages/client/src/generated/types.ts` (+72), `packages/core/schema.json` (+615), `packages/core/src/database/migr…
Date: Sep 17, 2026 * 🟡 (00:19) Production artifact rebuild completed and emitted the frontend bundle under `dist/assets/`; notable large assets included `dist/assets/sprite-Fb-TFjRY.svg` at `943.96 kB` (`243.53 kB` gzip) and `dist/assets/shiki-JaaGU9RH.js` at `9,575.87 kB` (`1,664.38 kB` gzip; `12,683.64 kB` source map). * 🟡 (00:19) Recovery smoke script was updated at `/tmp/opencode/session-re…
Date: Sep 17, 2026 * 🔴 (00:18) Full Core test suite passed after the commit/notification race fix: `1186 pass`, `0 fail`, `3326 expect()` calls across `145` files in `76.35s`, using Bun `v1.3.14 (0d9b296a)`. * 🟡 (00:18) Full-suite logs included an expected skill-download error for `https://skills.example.test/catalog/deploy/missing.md` with `StatusCodeError` (`non 2xx status code`) in `test/ski…
Date: Sep 17, 2026 * 🔴 (00:11) Frozen-v17 correctness review independently verified base `ab9408c81cc9916177641ae8e9f08624fc27b5b9`, patch SHA-256 `0eb85245c71fb55fa2a8e169a1cd1de5579059d77aaca30b6ac254482e255a2e`, SEA SHA-256 `308e9e562a3473b66bac430166db9d6cdd29f5f071d5695c1c35f28b99f26bf4`, exactly `34` paths, matching base/postimage blobs, and successful reverse-apply validation; all `34` pa…
Date: Sep 16, 2026 * 🔴 (23:50) Resumed frozen-v15 correctness review verified patch SHA-256 `509563ba0cd17246a1d223ac7a6f3fabee1a49892c61b215c3fd7ca4744b6c77`, SEA SHA-256 `83a833aa93cc185825feffe107fba1bd8e3789d1ff5fe5f8cda7df1962aa078d`, base commit `ab9408c81cc9916177641ae8e9f08624fc27b5b9`, exactly `34` changed paths, matching pre/postimage blobs, successful `git apply --reverse --check`, an…
Date: Sep 16, 2026 * 🟡 (23:32) Assistant confirmed frozen v16 patch `b0e9fdde553e70f6f606c33c555f9e37bb52d6827dd1aad8d5a932b76cd2ee30` cleanly applies across `34 files`. The implementation adds a monotonic cancellation barrier: ordinary runner guards reject `cancelling` and `cancelled`; only supervised heartbeat may renew; interruption reconciliation uses a cancellation-only fence; and only the …
Date: Sep 16, 2026 * 🟡 (23:27) Replacement harness `/tmp/opencode/session-recovery-v16-smoke.mjs` passed against `packages/opencode/dist/server/opencode-server` SHA-256 `a0eea521a58325461d7e533e5f3be05fccf2714a90a444eb67ac21b153f53c2f`: initial authenticated health succeeded on port `17607`, version `1.18.30`, with empty stderr. * 🟡 (23:27) Smoke-test schema verification found `session.wake_att…
Date: Sep 16, 2026 * 🟡 (23:24) Build completed with generated assets under `dist/assets/`; notable large output included `dist/assets/shiki-JaaGU9RH.js` at `9,575.87 kB` (`1,664.38 kB` gzip, `12,683.64 kB` source map) and `dist/assets/sprite-Fb-TFjRY.svg` at `943.96 kB` (`243.53 kB` gzip). * 🟡 (23:24) An `apply_patch` attempt failed because restart had removed the disposable smoke-test script `…
Date: Sep 16, 2026 * 🟡 (23:20) Final full Core suite under Bun `v1.3.14 (0d9b296a)` produced `1183 pass`, `2 fail`, and `3321 expect() calls` across `1185 tests` in `145 files` over `232.89s`. * 🟡 (23:20) Full-suite failures were aggregate-contention timeouts: `LocationServiceMap > isolates location state while sharing location policy with catalog` exceeded its `5000ms` timeout (`5696.45ms`), a…
Date: Sep 16, 2026 * 🟡 (23:09) Isolated run of `test/database-migration.test.ts` under Bun `v1.3.14 (0d9b296a)` again timed out on `DatabaseMigration > declared schema has no ungenerated migrations` after `30000ms` (`30028.13ms`): `0 pass`, `19 filtered out`, `1 fail`, across `1 file` in `34.53s`. * 🟡 (23:12) Inspection of `/home/byk/Code/opencode/packages/core/test/database-migration.test.ts` …
Date: Sep 16, 2026 * 🟡 (23:04) Combined recovery gate reported `247 pass`, `1 fail`, and `732 expect() calls` across `8 files` in `171.27s` using Bun `v1.3.14 (0d9b296a)`. The sole failure was `DatabaseMigration > declared schema has no ungenerated migrations` in `test/database-migration.test.ts`, which timed out after `30000ms` (`30030.81ms`). * 🟡 (23:04) Assistant stated all `227` non-migrati…