From 67d1ca2b164c25ce736dfcb4b330bd177ada3faa Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 14:45:02 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix(dag):=20eliminate=20phantom=20cancell?= =?UTF-8?q?ed=20node=20state=20=E2=80=94=20align=20transition=20table=20T5?= =?UTF-8?q?=20with=20projection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../node-lifecycle-transitions.md | 6 +- packages/core/src/dag/projector.ts | 12 ++ .../dag-node-cancelled-projection.test.ts | 106 ++++++++++++++++++ .../core/test/dag-projector-drift.test.ts | 11 ++ 4 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 packages/core/test/dag-node-cancelled-projection.test.ts diff --git a/.opencode/grill-batch-a/node-lifecycle-transitions.md b/.opencode/grill-batch-a/node-lifecycle-transitions.md index c37a5c02d8..9e142df277 100644 --- a/.opencode/grill-batch-a/node-lifecycle-transitions.md +++ b/.opencode/grill-batch-a/node-lifecycle-transitions.md @@ -8,7 +8,9 @@ ## 状态空间 -**主状态**(workflow_node.status):`pending` / `queued` / `running` / 终态 `completed` / `failed` / `cancelled` / `skipped` +**主状态**(workflow_node.status):`pending` / `queued` / `running` / 终态 `completed` / `failed` / `skipped` + +> **节点级无独立 `cancelled` 终态**(method-A 对齐实现):`NodeCancelled` 事件投影为 `status=failed` + `error_reason='cancelled via replan'`,取消语义经 error_reason 承载,行永不持有 `status='cancelled'`(`NodeStatus` 枚举无 CANCELLED,`getValidNextNodeStatuses` 对任何 from 均不返回 cancelled)。工作流级 `cancelled`(`WorkflowStatusProjection.cancelled`)是合法独立终态,与节点级无关。见 T5。 **running 扩展维度**(子状态): | 维度 | 语义 | 契约来源 | @@ -26,7 +28,7 @@ | T2 | queued | nodeStarted | runtime spawn | running | **清 escalation_pending + 重置 timeout_extensions=0**(新 attempt) | 子会话启动 | [现状] | | T3 | running | nodeCompleted | 子会话结果 | completed | **清 escalation_pending**(终态无裁决对象) | 结果交付(终态交付臂) | [目标] ADR-0001 | | T4 | running/queued | nodeFailed(reason + trigger) | 子会话失败 / watchdog cap / recovery | failed | **清 escalation_pending**;trigger 入 error 语义 | `[DAG Node Result]`/wake 承载 reason+trigger(错误即状态→处置依据) | [目标] ADR-0001 | -| T5 | pending/queued/running | nodeCancelled | replan cancel / workflow cancel | cancelled | **清 escalation_pending**(cancel 即裁决) | 取消交付 | [目标] ADR-0001 | +| T5 | pending/queued/running | nodeCancelled | replan cancel / workflow cancel | failed(cancelled) | **status=failed + error_reason='cancelled via replan' + 清 escalation_pending**(cancel 即裁决;节点级无独立 cancelled 终态,取消语义经 error_reason 承载) | 取消交付 | [目标] ADR-0001 | | T6 | pending/queued | nodeSkipped | 依赖失败级联 | skipped | — | 跳过级联 | [现状] | | T7 | failed | nodeRestarted | replan restart | running | 清旗 + 重置计数(新 attempt) | 重试 | [现状] | | T8 | running | nodeTimeoutEscalated | **watchdog(提议者)** | running | timeout_extensions+1、escalation_pending=true、wake re-arm(wake_reported=false) | `[DAG Node Timeout]` wake(extend 或 cancel 的裁决请求) | [现状] | diff --git a/packages/core/src/dag/projector.ts b/packages/core/src/dag/projector.ts index e85d767787..49b901b58d 100644 --- a/packages/core/src/dag/projector.ts +++ b/packages/core/src/dag/projector.ts @@ -32,6 +32,12 @@ export const NodeStatusProjection = { completed: { to: "completed", from: ["running"] }, failed: { to: "failed", from: ["running", "pending", "queued"] }, skipped: { to: "skipped", from: ["pending", "queued", "running", "paused"] }, + // NodeCancelled has NO independent terminal status — the NodeStatus enum has + // no CANCELLED and getValidNextNodeStatuses never returns it. A cancelled + // node lands on `failed` with the cancellation carried by `error_reason` + // ("cancelled via replan"), never on a phantom node-level "cancelled" status. + // Workflow-level cancelled (WorkflowStatusProjection.cancelled below) is a + // legitimate, separate terminal — this entry is node-scoped only. cancelled: { to: "failed", from: ["pending", "queued", "running", "paused"] }, restarted: { to: "pending", from: ["running"] }, } as const @@ -342,6 +348,12 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie), ) + // NodeCancelled carries no independent terminal status: it projects to + // status="failed" with the cancellation marker in error_reason and clears + // the adjudication flag (cancel is itself an adjudication). A node row can + // therefore never hold status="cancelled"; see NodeStatusProjection.cancelled + // above and the canonical proof in + // packages/opencode/test/dag/dag-escalation-clear-flag.test.ts:130-148. yield* events.project(DagEvent.NodeCancelled, (event) => db .update(WorkflowNodeTable) diff --git a/packages/core/test/dag-node-cancelled-projection.test.ts b/packages/core/test/dag-node-cancelled-projection.test.ts new file mode 100644 index 0000000000..e23ebbe55b --- /dev/null +++ b/packages/core/test/dag-node-cancelled-projection.test.ts @@ -0,0 +1,106 @@ +/** + * Regression guard for the NodeCancelled projection contract (ticket A, + * method-A: align to implementation). + * + * NodeCancelled has NO independent terminal status. It projects to + * `status="failed"` carrying the cancellation marker in `error_reason` + * ("cancelled via replan") and clears `escalation_pending` (cancel is an + * adjudication). The NodeStatus enum has no CANCELLED value and + * getValidNextNodeStatuses never returns cancelled, so a node row can never + * hold status="cancelled". This test exercises the real projector SQL + * (projector.ts NodeCancelled handler) end-to-end at the core layer so the + * semantic cannot silently drift back to a phantom node-level "cancelled" + * status. + * + * The end-to-end canonical proof lives in + * packages/opencode/test/dag/dag-escalation-clear-flag.test.ts:130-148; this + * core-level test mirrors it without depending on the opencode Dag command + * layer. + */ +import { describe, expect, test } from "bun:test" +import { DateTime, Effect, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { WorkflowNodeTable, WorkflowTable } from "@opencode-ai/core/dag/sql" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { DagEvent } from "@opencode-ai/schema/dag-event" + +function projectorLayer() { + const database = Database.layerFromPath(":memory:") + const eventLayer = EventV2.layer.pipe(Layer.provide(database)) + const projector = DagProjector.layer.pipe(Layer.provide(Layer.merge(database, eventLayer))) + const store = DagStore.layer.pipe(Layer.provide(database)) + return Layer.mergeAll(database, eventLayer, projector, store) +} + +function seed() { + return Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db.insert(ProjectTable).values({ + id: "project-1" as never, + worktree: process.cwd() as never, + sandboxes: [], + }).run().pipe(Effect.orDie) + yield* db.insert(SessionTable).values({ + id: "ses_parent" as never, + project_id: "project-1" as never, + slug: "parent", + directory: process.cwd() as never, + title: "Parent", + version: "test", + }).run().pipe(Effect.orDie) + yield* db.insert(WorkflowTable).values({ + id: "dag_cancel", + project_id: "project-1" as never, + session_id: "ses_parent" as never, + title: "Cancel projection", + status: "running", + config: "{}", + seq: 1, + wake_reported: false, + }).run().pipe(Effect.orDie) + yield* db.insert(WorkflowNodeTable).values({ + id: "n1", + workflow_id: "dag_cancel", + name: "N1", + worker_type: "build", + status: "running", + required: true, + depends_on: [], + wake_eligible: false, + wake_reported: false, + // Pre-set an adjudication flag so the projection's clear is observable. + escalation_pending: true, + seq: 1, + }).run().pipe(Effect.orDie) + }) +} + +describe("NodeCancelled projection (no phantom node-level cancelled status)", () => { + test("projects NodeCancelled to status=failed + error_reason='cancelled via replan' and clears escalation_pending", async () => { + await Effect.runPromise( + Effect.gen(function* () { + yield* seed() + const events = yield* EventV2.Service + const store = yield* DagStore.Service + + yield* events.publish(DagEvent.NodeCancelled, { + dagID: DagEvent.DagID.make("dag_cancel"), + nodeID: DagEvent.NodeID.make("n1"), + timestamp: yield* DateTime.now, + }) + + const row = yield* store.getNode("dag_cancel", "n1") + // NodeCancelled has no independent terminal status: it lands on failed + // with the cancellation carried by error_reason, never status="cancelled". + expect(row?.status).toBe("failed") + expect(row?.errorReason).toBe("cancelled via replan") + // Cancel is an adjudication — the pending-escalation flag must clear. + expect(row?.escalationPending).toBe(false) + }).pipe(Effect.provide(projectorLayer()), Effect.scoped), + ) + }) +}) diff --git a/packages/core/test/dag-projector-drift.test.ts b/packages/core/test/dag-projector-drift.test.ts index 5accf890c6..b721b97a08 100644 --- a/packages/core/test/dag-projector-drift.test.ts +++ b/packages/core/test/dag-projector-drift.test.ts @@ -67,3 +67,14 @@ describe("projector from-guards vs declared transition tables", () => { // third encoding of the same machine with zero production callers — a // capability reservoir kept for the event-semantics mapping. It is exercised // by dag-core.test.ts only and intentionally not welded here. +// +// Note (ticket A, method-A): NodeStatusProjection.cancelled.to === "failed" +// is intentional, not a missing target. NodeCancelled has no independent +// terminal status — the NodeStatus enum has no CANCELLED and +// getValidNextNodeStatuses never returns cancelled, so a node row can never +// hold status="cancelled". The drift test passes for cancelled because +// "failed" is a legal target from every cancelled.from state; a phantom +// node-level "cancelled" target is what this alignment rules out. The +// cancellation marker rides on error_reason ("cancelled via replan"), locked +// by dag-node-cancelled-projection.test.ts and the opencode canonical proof +// at packages/opencode/test/dag/dag-escalation-clear-flag.test.ts:130-148. From b356304861030f8c91b2a4961112a8b9fdccbe54 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 14:45:23 +0800 Subject: [PATCH 02/17] fix(dag): stale watchdog read no longer consumes timeout extension budget --- packages/opencode/src/dag/dag.ts | 28 ++- packages/opencode/src/dag/runtime/spawn.ts | 8 +- .../test/dag/dag-retime-stale-read.test.ts | 222 ++++++++++++++++++ 3 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/test/dag/dag-retime-stale-read.test.ts diff --git a/packages/opencode/src/dag/dag.ts b/packages/opencode/src/dag/dag.ts index d203ceaa91..1545a2d5bc 100644 --- a/packages/opencode/src/dag/dag.ts +++ b/packages/opencode/src/dag/dag.ts @@ -318,7 +318,7 @@ export interface Interface { readonly nodeSkipped: (dagID: string, nodeID: string, reason: string) => Effect.Effect readonly nodeCancelled: (dagID: string, nodeID: string) => Effect.Effect readonly nodeRestarted: (dagID: string, nodeID: string, childSessionID: string) => Effect.Effect - readonly nodeTimeoutEscalated: (dagID: string, nodeID: string, childSessionID: string, timeoutExtensions: number) => Effect.Effect + readonly nodeTimeoutEscalated: (dagID: string, nodeID: string, childSessionID: string, timeoutExtensions: number, staleDeadlineMs?: number | null) => Effect.Effect readonly nodeExtendTimeout: (dagID: string, nodeID: string, newDeadlineMs: number) => Effect.Effect } @@ -887,8 +887,28 @@ export const layer = Layer.effect( // Timeout escalation publishes no status transition — the node stays // RUNNING (see the NodeTimeoutEscalated projector). Only the extension // count, seq, and wake flag change. - const nodeTimeoutEscalated = Effect.fn("Dag.nodeTimeoutEscalated")(function* (lock: WorkflowLock, dagID: string, nodeID: string, childSessionID: string, timeoutExtensions: number) { + // + // Ticket B (method-A — stale-read suppression): the deadline watcher reads + // the durable row WITHOUT the workflow lock (spawn.ts readNode). Between + // that stale snapshot and this command acquiring the lock, a replan's + // nodeExtendTimeout may have moved the deadline into the future. Escalating + // then would charge a max_timeout_extensions budget unit for a node that is + // no longer overdue — a spurious T8 (the cosmetic residue self-documented + // at loop.ts:870-880). The caller passes the deadline it OBSERVED + // (node.deadlineMs); this command re-reads the node FRESH under the workflow + // lock and, when the deadline has moved strictly past the observed value, + // suppresses the escalation (no publish, no budget increment). Budget only + // counts a real extension (a deadline that actually moved), not a stale-read + // cosmetic recount. Suppression returns void, exactly like a publish, so the + // watcher's self-renewal loop (S1) keeps supervising — a running node is + // never orphaned (N1). When staleDeadlineMs is omitted (existing callers, + // test setups) the guard is inert: back-compat is unconditional publish. + const nodeTimeoutEscalated = Effect.fn("Dag.nodeTimeoutEscalated")(function* (lock: WorkflowLock, dagID: string, nodeID: string, childSessionID: string, timeoutExtensions: number, staleDeadlineMs?: number | null) { yield* guardWorkflowNotTerminal(dagID, "timeout escalation") + if (staleDeadlineMs != null) { + const node = yield* store.getNode(dagID, nodeID).pipe(Effect.orDie) + if (node && node.status === "running" && node.deadlineMs != null && node.deadlineMs > staleDeadlineMs) return + } yield* events.publish(DagEvent.NodeTimeoutEscalated, { dagID: dagID as ID, nodeID: nodeID as never, @@ -959,8 +979,8 @@ export const layer = Layer.effect( nodeSkipped: (dagID, nodeID, reason) => withWorkflowLock(dagID)((lock) => nodeSkipped(lock, dagID, nodeID, reason)), nodeCancelled: (dagID, nodeID) => withWorkflowLock(dagID)((lock) => nodeCancelled(lock, dagID, nodeID)), nodeRestarted: (dagID, nodeID, childSessionID) => withWorkflowLock(dagID)((lock) => nodeRestarted(lock, dagID, nodeID, childSessionID)), - nodeTimeoutEscalated: (dagID, nodeID, childSessionID, timeoutExtensions) => - withWorkflowLock(dagID)((lock) => nodeTimeoutEscalated(lock, dagID, nodeID, childSessionID, timeoutExtensions)), + nodeTimeoutEscalated: (dagID, nodeID, childSessionID, timeoutExtensions, staleDeadlineMs) => + withWorkflowLock(dagID)((lock) => nodeTimeoutEscalated(lock, dagID, nodeID, childSessionID, timeoutExtensions, staleDeadlineMs)), nodeExtendTimeout: (dagID, nodeID, newDeadlineMs) => withWorkflowLock(dagID)((lock) => nodeExtendTimeout(lock, dagID, nodeID, newDeadlineMs)), }) }), diff --git a/packages/opencode/src/dag/runtime/spawn.ts b/packages/opencode/src/dag/runtime/spawn.ts index 1adee59822..008ef15ad2 100644 --- a/packages/opencode/src/dag/runtime/spawn.ts +++ b/packages/opencode/src/dag/runtime/spawn.ts @@ -178,7 +178,13 @@ export function makeDeadlineWatcher( // slot unbounded. Log and fall through to the sleep — the next iteration // re-reads the row and escalates again. Mirrors the read path's R13 // hardening above, which the write path previously lacked. - const escalated = yield* dag.nodeTimeoutEscalated(input.dagID, input.nodeID, node.childSessionId as never, extensions + 1).pipe( + // The deadline this watcher OBSERVED may be stale — it was read WITHOUT + // the workflow lock (readNode above). nodeTimeoutEscalated re-reads the + // node under the lock and suppresses the escalation when the deadline has + // moved past this observed value (ticket B — spurious T8 suppression), + // so a budget unit is only charged when the node is genuinely still + // overdue. Pass node.deadlineMs, the value this snapshot read. + const escalated = yield* dag.nodeTimeoutEscalated(input.dagID, input.nodeID, node.childSessionId as never, extensions + 1, node.deadlineMs).pipe( Effect.catchIf( isTransitionRejection, () => Effect.logWarning("nodeTimeoutEscalated guard rejected — node already terminal"), diff --git a/packages/opencode/test/dag/dag-retime-stale-read.test.ts b/packages/opencode/test/dag/dag-retime-stale-read.test.ts new file mode 100644 index 0000000000..f8e8b94b19 --- /dev/null +++ b/packages/opencode/test/dag/dag-retime-stale-read.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "bun:test" +import { Effect, Layer } from "effect" +import { sql } from "drizzle-orm" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { EventTable } from "@opencode-ai/core/event/sql" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { Session } from "@opencode-ai/schema/session" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Dag, type NodeConfig } from "@/dag/dag" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceRef } from "@/effect/instance-ref" + +// ============================================================================ +// Ticket B — spurious T8 (stale-read budget consumption), method-A. +// +// The deadline watcher reads the durable row WITHOUT the workflow lock +// (spawn.ts readNode). When that stale snapshot shows an expired deadline it +// calls dag.nodeTimeoutEscalated, which acquires the workflow lock and would +// unconditionally publish NodeTimeoutEscalated (incrementing timeout_extensions). +// If a replan's nodeExtendTimeout moved the deadline into the future BETWEEN the +// stale read and the lock acquisition, the escalation charges a budget unit for +// a node that is no longer overdue — a spurious T8 (domain 3; the cosmetic +// residue self-documented at loop.ts:870-880). +// +// Method-A fix: the watchdog passes the deadline it observed (node.deadlineMs). +// nodeTimeoutEscalated re-reads the node FRESH under the workflow lock and, when +// the deadline has moved strictly past the observed value, suppresses the +// escalation (no publish, no budget increment). Budget only counts a real +// extension (a deadline that actually moved), not a stale-read cosmetic recount. +// +// N1 (running node never loses its watcher): nodeTimeoutEscalated returns void +// whether it publishes or suppresses, and the watcher's self-renewal loop +// (spawn.ts:126-199) only exits on a terminal status or fiber interrupt — a +// suppressed escalation flows into the same post-escalation sleep+re-read as a +// published one, so supervision cannot end on the suppression path. +// ============================================================================ + +function node(id: string, timeoutMs?: number): NodeConfig { + return { + id, + name: id, + worker_type: "build", + depends_on: [], + required: true, + prompt_template: { inline: id }, + ...(timeoutMs !== undefined ? { worker_config: { timeout_ms: timeoutMs } } : {}), + } +} + +const harness = (() => { + const database = Database.layerFromPath(":memory:") + const events = EventV2.layer.pipe(Layer.provide(database)) + const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) + const store = DagStore.layer.pipe(Layer.provide(database)) + const projector = DagProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) + const dag = Dag.layer.pipe(Layer.provide(bridge), Layer.provide(store)) + return Layer.mergeAll(database, events, bridge, store, projector, dag) +})() + +function runTest( + test: (services: { readonly dag: Dag.Interface; readonly store: DagStore.Interface; readonly db: Database.Interface["db"] }) => Effect.Effect, +) { + return Effect.gen(function* () { + return yield* Effect.gen(function* () { + const database = yield* Database.Service + yield* database.db.insert(ProjectTable).values({ + id: Project.ID.make("project-1"), + worktree: AbsolutePath.make(process.cwd()), + sandboxes: [], + }).run().pipe(Effect.orDie) + yield* database.db.insert(SessionTable).values({ + id: Session.ID.make("ses_parent"), + project_id: Project.ID.make("project-1"), + slug: "parent", + directory: AbsolutePath.make(process.cwd()), + title: "Parent", + version: "test", + }).run().pipe(Effect.orDie) + const dag = yield* Dag.Service + const store = yield* DagStore.Service + return yield* test({ dag, store, db: database.db }) + }).pipe( + Effect.provide(harness), + Effect.provideService(InstanceRef, { + directory: process.cwd(), + worktree: process.cwd(), + project: { + id: Project.ID.make("project-1"), + worktree: process.cwd(), + time: { created: 0, updated: 0 }, + sandboxes: [], + }, + }), + Effect.scoped, + ) + }) +} + +function createWorkflow(dag: Dag.Interface, title: string, nodeID = "a") { + return dag.create({ + projectID: Project.ID.make("project-1"), + sessionID: Session.ID.make("ses_parent"), + title, + config: { name: title, nodes: [node(nodeID)] }, + }) +} + +// Count durable NodeTimeoutEscalated rows for a node. The stored type is +// versioned (`dag.node.timeout_escalated.1`), so match the prefix. Narrowing +// the JSON `data` column to a struct is a safe downcast (not an unsafe +// assertion) — it never inflates the no-unsafe-type-assertion ratchet. +function timeoutEscalatedCount(db: Database.Interface["db"], dagID: string, nodeID: string) { + return Effect.gen(function* () { + const rows = yield* db + .select({ type: EventTable.type, data: EventTable.data }) + .from(EventTable) + .where(sql`${EventTable.aggregate_id} = ${dagID} AND ${EventTable.type} LIKE 'dag.node.timeout_escalated.%'`) + .all() + .pipe(Effect.orDie) + return rows.filter((row) => (row.data as { nodeID?: string }).nodeID === nodeID).length + }) +} + +describe("nodeTimeoutEscalated stale-read suppression (ticket B, method-A)", () => { + it("suppresses the escalation when the deadline was extended after the watcher's stale read (spurious T8)", async () => { + await Effect.runPromise( + runTest(({ dag, store, db }) => + Effect.gen(function* () { + const dagID = yield* createWorkflow(dag, "stale-suppressed") + // The node started with an EXPIRED deadline — this is the value the + // watcher's stale snapshot would have read. + const expiredDeadline = Date.now() - 5_000 + yield* dag.nodeQueued(dagID, "a", expiredDeadline) + yield* dag.nodeStarted(dagID, "a", "ses_child_1", expiredDeadline, true) + + // A replan adjudicates the timeout by extending the deadline into the + // future AFTER the watcher's snapshot read but BEFORE its escalation + // acquires the workflow lock. nodeExtendTimeout publishes + // NodeDeadlineExtended (no budget change — extensions never increment + // timeout_extensions). + const extendedDeadline = Date.now() + 60_000 + const written = yield* dag.nodeExtendTimeout(dagID, "a", extendedDeadline) + expect(written).toBe(1) + const extended = yield* store.getNode(dagID, "a") + expect(extended?.deadlineMs).toBe(extendedDeadline) + expect(extended?.timeoutExtensions).toBe(0) + + // The watcher fires nodeTimeoutEscalated carrying the deadline it + // OBSERVED (the stale expired value). Under the workflow lock the + // command re-reads the node: its deadline is now strictly past the + // observed value → the stale read is invalidated → the escalation is + // suppressed. No NodeTimeoutEscalated event, no budget increment. + yield* dag.nodeTimeoutEscalated(dagID, "a", "ses_child_1", 1, expiredDeadline) + + const eventCount = yield* timeoutEscalatedCount(db, dagID, "a") + expect(eventCount).toBe(0) + + const row = yield* store.getNode(dagID, "a") + expect(row?.status).toBe("running") + expect(row?.timeoutExtensions).toBe(0) + expect(row?.escalationPending).toBe(false) + expect(row?.deadlineMs).toBe(extendedDeadline) + }), + ), + ) + }) + + it("still escalates when the deadline was NOT extended (legitimate escalation — regression arm)", async () => { + await Effect.runPromise( + runTest(({ dag, store, db }) => + Effect.gen(function* () { + const dagID = yield* createWorkflow(dag, "stale-legitimate") + const expiredDeadline = Date.now() - 5_000 + yield* dag.nodeQueued(dagID, "a", expiredDeadline) + yield* dag.nodeStarted(dagID, "a", "ses_child_1", expiredDeadline, true) + // No replan extend: the fresh in-lock deadline equals the observed + // value, so the escalation is NOT a stale read and must publish. + yield* dag.nodeTimeoutEscalated(dagID, "a", "ses_child_1", 1, expiredDeadline) + + const eventCount = yield* timeoutEscalatedCount(db, dagID, "a") + expect(eventCount).toBe(1) + + const row = yield* store.getNode(dagID, "a") + expect(row?.status).toBe("running") + expect(row?.timeoutExtensions).toBe(1) + expect(row?.escalationPending).toBe(true) + expect(row?.wakeReported).toBe(false) + }), + ), + ) + }) + + it("preserves back-compat: a 4-arg call (no observed deadline) never suppresses", async () => { + await Effect.runPromise( + runTest(({ dag, store, db }) => + Effect.gen(function* () { + const dagID = yield* createWorkflow(dag, "stale-backcompat") + // A future-deadline node is escalated directly (the idiom existing + // tests use to set up escalation_pending). With no observed-deadline + // argument the suppression guard is inert — callers that do not opt + // into the stale-read protocol keep the unconditional-publish + // behavior, so existing 4-arg call sites are unaffected. + yield* dag.nodeQueued(dagID, "a", Date.now() + 60_000) + yield* dag.nodeStarted(dagID, "a", "ses_child_1", Date.now() + 60_000, true) + yield* dag.nodeTimeoutEscalated(dagID, "a", "ses_child_1", 1) + + const eventCount = yield* timeoutEscalatedCount(db, dagID, "a") + expect(eventCount).toBe(1) + + const row = yield* store.getNode(dagID, "a") + expect(row?.timeoutExtensions).toBe(1) + expect(row?.escalationPending).toBe(true) + }), + ), + ) + }) +}) From 222c17277d9e44e3c99dc5ddc042146720d60d26 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 14:45:43 +0800 Subject: [PATCH 03/17] =?UTF-8?q?test(dag):=20type-safe=20fixtures=20remov?= =?UTF-8?q?e=2036=20lint=20warnings,=20ratchet=20restored=204888=E2=86=924?= =?UTF-8?q?852?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +- .../test/dag/dag-deadline-extended.test.ts | 52 +++++++++------- .../dag/dag-escalation-clear-flag.test.ts | 22 ++++--- .../test/dag/dag-timeout-escalation.test.ts | 60 ++++++++++++++----- 4 files changed, 90 insertions(+), 48 deletions(-) diff --git a/package.json b/package.json index d39dd725d4..9a8ee27513 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "packageManager": "bun@1.3.14", - "_lint_ratchet_note": "Ratchet set to the CI type-aware baseline (4888). CI lints ~3 more files than a local run (install/platform-generated artifacts on an identical git tree), producing ~10 extra same-category type-aware warnings (4888 CI vs 4878 local, 0 errors) — NOT new code warnings. This batch raised the baseline by ~36 type-aware no-unsafe-type-assertion warnings from two new dag test files (dag-deadline-extended.test.ts, dag-escalation-clear-flag.test.ts) using the established `as never` test-data idiom — same category as the prior 4842. When you fix existing warnings locally, lower --max-warnings in the lint script to match so the gate keeps tightening. See .oxlintrc.json header for the ratchet contract.", + "_lint_ratchet_note": "Ratchet lowered to 4852 (the pre-batch-A CI baseline) after replacing the `as never` test-data idiom in the three dag timeout/escalation test files (dag-deadline-extended, dag-escalation-clear-flag, dag-timeout-escalation) with schema brand makers (Project.ID.make, Session.ID.make, DagEvent.NodeID.make, AbsolutePath.make) and fully-typed InstanceRef/Session mocks — their no-unsafe-type-assertion warnings are gone. CI lints ~3 extra install/platform-generated artifacts on an identical tree, adding ~10 same-category type-aware warnings (~4841 CI vs ~4831 local, 0 errors) — NOT new code warnings; 4852 keeps a small margin over the projected CI count. When you fix existing warnings locally, lower --max-warnings to match so the gate keeps tightening. See .oxlintrc.json header for the ratchet contract.", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop dev", @@ -13,7 +13,7 @@ "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", - "lint": "oxlint --max-warnings=4888", + "lint": "oxlint --max-warnings=4852", "typecheck": "bun turbo typecheck", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/core fix-node-pty", diff --git a/packages/opencode/test/dag/dag-deadline-extended.test.ts b/packages/opencode/test/dag/dag-deadline-extended.test.ts index 9ce570d42a..ce67d2a8e8 100644 --- a/packages/opencode/test/dag/dag-deadline-extended.test.ts +++ b/packages/opencode/test/dag/dag-deadline-extended.test.ts @@ -7,6 +7,7 @@ import { EventTable, EventSequenceTable } from "@opencode-ai/core/event/sql" import { DagProjector } from "@opencode-ai/core/dag/projector" import { DagStore } from "@opencode-ai/core/dag/store" import { DagEvent } from "@opencode-ai/schema/dag-event" +import { Session } from "@opencode-ai/schema/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { SessionTable } from "@opencode-ai/core/session/sql" @@ -50,15 +51,15 @@ function runTest( return yield* Effect.gen(function* () { const database = yield* Database.Service yield* database.db.insert(ProjectTable).values({ - id: "project-1" as never, - worktree: process.cwd() as never, + id: Project.ID.make("project-1"), + worktree: AbsolutePath.make(process.cwd()), sandboxes: [], }).run().pipe(Effect.orDie) yield* database.db.insert(SessionTable).values({ - id: "ses_parent" as never, - project_id: "project-1" as never, + id: Session.ID.make("ses_parent"), + project_id: Project.ID.make("project-1"), slug: "parent", - directory: process.cwd() as never, + directory: AbsolutePath.make(process.cwd()), title: "Parent", version: "test", }).run().pipe(Effect.orDie) @@ -70,8 +71,13 @@ function runTest( Effect.provideService(InstanceRef, { directory: process.cwd(), worktree: process.cwd(), - project: { id: "project-1" }, - } as never), + project: { + id: Project.ID.make("project-1"), + worktree: process.cwd(), + time: { created: 0, updated: 0 }, + sandboxes: [], + }, + }), Effect.scoped, ) }) @@ -118,7 +124,7 @@ function setupFKs() { return Effect.gen(function* () { const { db } = yield* Database.Service yield* db.insert(ProjectTable).values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }).run().pipe(Effect.orDie) - yield* db.insert(SessionTable).values({ id: "ses_replay" as never, project_id: Project.ID.global, slug: "replay", directory: "/project", title: "replay", version: "test" }).run().pipe(Effect.orDie) + yield* db.insert(SessionTable).values({ id: Session.ID.make("ses_replay"), project_id: Project.ID.global, slug: "replay", directory: "/project", title: "replay", version: "test" }).run().pipe(Effect.orDie) }) } @@ -261,15 +267,15 @@ describe("NodeDeadlineExtended projector fold + replay (Q3)", () => { yield* setupFKs() const events = yield* EventV2.Service const store = yield* DagStore.Service - const dagID = "dag_replay_extend" as never + const dagID = DagEvent.DagID.descending("dag_replay_extend") - yield* events.publish(DagEvent.WorkflowCreated, { dagID, projectID: Project.ID.global as never, sessionID: "ses_replay" as never, title: "extend-replay", config: "{}", status: "pending", timestamp: ts(0) }) - yield* events.publish(DagEvent.NodeRegistered, { dagID, nodeID: "a" as never, name: "A", workerType: "build", dependsOn: [], required: true, timestamp: ts(1) }) + yield* events.publish(DagEvent.WorkflowCreated, { dagID, projectID: Project.ID.global, sessionID: Session.ID.make("ses_replay"), title: "extend-replay", config: "{}", status: "pending", timestamp: ts(0) }) + yield* events.publish(DagEvent.NodeRegistered, { dagID, nodeID: DagEvent.NodeID.make("a"), name: "A", workerType: "build", dependsOn: [], required: true, timestamp: ts(1) }) yield* events.publish(DagEvent.WorkflowStarted, { dagID, timestamp: ts(2) }) - yield* events.publish(DagEvent.NodeStarted, { dagID, nodeID: "a" as never, childSessionID: "ses_child" as never, deadlineMs: 5_000, wakeEligible: true, timestamp: ts(3) }) + yield* events.publish(DagEvent.NodeStarted, { dagID, nodeID: DagEvent.NodeID.make("a"), childSessionID: Session.ID.make("ses_child"), deadlineMs: 5_000, wakeEligible: true, timestamp: ts(3) }) // Escalate, then adjudicate by extending the deadline. - yield* events.publish(DagEvent.NodeTimeoutEscalated, { dagID, nodeID: "a" as never, childSessionID: "ses_child" as never, timeoutExtensions: 1, timestamp: ts(4) }) - yield* events.publish(DagEvent.NodeDeadlineExtended, { dagID, nodeID: "a" as never, deadlineMs: 99_999, timeoutExtensions: 1, timestamp: ts(5) }) + yield* events.publish(DagEvent.NodeTimeoutEscalated, { dagID, nodeID: DagEvent.NodeID.make("a"), childSessionID: Session.ID.make("ses_child"), timeoutExtensions: 1, timestamp: ts(4) }) + yield* events.publish(DagEvent.NodeDeadlineExtended, { dagID, nodeID: DagEvent.NodeID.make("a"), deadlineMs: 99_999, timeoutExtensions: 1, timestamp: ts(5) }) const before = yield* store.getNode(dagID, "a") expect(before?.deadlineMs).toBe(99_999) @@ -288,7 +294,7 @@ describe("NodeDeadlineExtended projector fold + replay (Q3)", () => { expect(replayed?.escalationPending).toBe(false) expect(replayed?.wakeReported).toBe(true) expect(replayed?.timeoutExtensions).toBe(1) - }).pipe(Effect.provide(projectorLayer)) as Effect.Effect, + }).pipe(Effect.provide(projectorLayer)), ) }) @@ -298,17 +304,17 @@ describe("NodeDeadlineExtended projector fold + replay (Q3)", () => { yield* setupFKs() const events = yield* EventV2.Service const store = yield* DagStore.Service - const dagID = "dag_replay_stale_extend" as never + const dagID = DagEvent.DagID.descending("dag_replay_stale_extend") - yield* events.publish(DagEvent.WorkflowCreated, { dagID, projectID: Project.ID.global as never, sessionID: "ses_replay" as never, title: "stale-extend", config: "{}", status: "pending", timestamp: ts(0) }) - yield* events.publish(DagEvent.NodeRegistered, { dagID, nodeID: "a" as never, name: "A", workerType: "build", dependsOn: [], required: true, timestamp: ts(1) }) + yield* events.publish(DagEvent.WorkflowCreated, { dagID, projectID: Project.ID.global, sessionID: Session.ID.make("ses_replay"), title: "stale-extend", config: "{}", status: "pending", timestamp: ts(0) }) + yield* events.publish(DagEvent.NodeRegistered, { dagID, nodeID: DagEvent.NodeID.make("a"), name: "A", workerType: "build", dependsOn: [], required: true, timestamp: ts(1) }) yield* events.publish(DagEvent.WorkflowStarted, { dagID, timestamp: ts(2) }) - yield* events.publish(DagEvent.NodeStarted, { dagID, nodeID: "a" as never, childSessionID: "ses_child" as never, deadlineMs: 5_000, wakeEligible: true, timestamp: ts(3) }) - yield* events.publish(DagEvent.NodeDeadlineExtended, { dagID, nodeID: "a" as never, deadlineMs: 50_000, timeoutExtensions: 1, timestamp: ts(4) }) + yield* events.publish(DagEvent.NodeStarted, { dagID, nodeID: DagEvent.NodeID.make("a"), childSessionID: Session.ID.make("ses_child"), deadlineMs: 5_000, wakeEligible: true, timestamp: ts(3) }) + yield* events.publish(DagEvent.NodeDeadlineExtended, { dagID, nodeID: DagEvent.NodeID.make("a"), deadlineMs: 50_000, timeoutExtensions: 1, timestamp: ts(4) }) // Node completes AFTER the extension was logged... - yield* events.publish(DagEvent.NodeCompleted, { dagID, nodeID: "a" as never, output: "done", durationMs: 0, timestamp: ts(5) }) + yield* events.publish(DagEvent.NodeCompleted, { dagID, nodeID: DagEvent.NodeID.make("a"), output: "done", durationMs: 0, timestamp: ts(5) }) // ...then a stale/late extension races in (crash-recovery replay order). - yield* events.publish(DagEvent.NodeDeadlineExtended, { dagID, nodeID: "a" as never, deadlineMs: 99_999, timeoutExtensions: 1, timestamp: ts(6) }) + yield* events.publish(DagEvent.NodeDeadlineExtended, { dagID, nodeID: DagEvent.NodeID.make("a"), deadlineMs: 99_999, timeoutExtensions: 1, timestamp: ts(6) }) const row = yield* store.getNode(dagID, "a") // The projector's status='running' WHERE guard means the stale fold is a @@ -317,7 +323,7 @@ describe("NodeDeadlineExtended projector fold + replay (Q3)", () => { expect(row?.status).toBe("completed") expect(row?.deadlineMs).toBe(50_000) expect(row?.output).toBe("done") - }).pipe(Effect.provide(projectorLayer)) as Effect.Effect, + }).pipe(Effect.provide(projectorLayer)), ) }) }) diff --git a/packages/opencode/test/dag/dag-escalation-clear-flag.test.ts b/packages/opencode/test/dag/dag-escalation-clear-flag.test.ts index e322c5ecab..235dfac9e1 100644 --- a/packages/opencode/test/dag/dag-escalation-clear-flag.test.ts +++ b/packages/opencode/test/dag/dag-escalation-clear-flag.test.ts @@ -4,8 +4,11 @@ import { Database } from "@opencode-ai/core/database/database" import { DagProjector } from "@opencode-ai/core/dag/projector" import { DagStore } from "@opencode-ai/core/dag/store" import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionTable } from "@opencode-ai/core/session/sql" +import { Session } from "@opencode-ai/schema/session" import { Dag, type NodeConfig } from "@/dag/dag" import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceRef } from "@/effect/instance-ref" @@ -39,15 +42,15 @@ function runTest( return yield* Effect.gen(function* () { const database = yield* Database.Service yield* database.db.insert(ProjectTable).values({ - id: "project-1" as never, - worktree: process.cwd() as never, + id: Project.ID.make("project-1"), + worktree: AbsolutePath.make(process.cwd()), sandboxes: [], }).run().pipe(Effect.orDie) yield* database.db.insert(SessionTable).values({ - id: "ses_parent" as never, - project_id: "project-1" as never, + id: Session.ID.make("ses_parent"), + project_id: Project.ID.make("project-1"), slug: "parent", - directory: process.cwd() as never, + directory: AbsolutePath.make(process.cwd()), title: "Parent", version: "test", }).run().pipe(Effect.orDie) @@ -59,8 +62,13 @@ function runTest( Effect.provideService(InstanceRef, { directory: process.cwd(), worktree: process.cwd(), - project: { id: "project-1" }, - } as never), + project: { + id: Project.ID.make("project-1"), + worktree: process.cwd(), + time: { created: 0, updated: 0 }, + sandboxes: [], + }, + }), Effect.scoped, ) }) diff --git a/packages/opencode/test/dag/dag-timeout-escalation.test.ts b/packages/opencode/test/dag/dag-timeout-escalation.test.ts index ae299a3a25..1ba5489df6 100644 --- a/packages/opencode/test/dag/dag-timeout-escalation.test.ts +++ b/packages/opencode/test/dag/dag-timeout-escalation.test.ts @@ -5,15 +5,19 @@ import { Database } from "@opencode-ai/core/database/database" import { DagProjector } from "@opencode-ai/core/dag/projector" import { DagStore } from "@opencode-ai/core/dag/store" import { EventV2 } from "@opencode-ai/core/event" +import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionTable } from "@opencode-ai/core/session/sql" +import { Model } from "@opencode-ai/schema/model" +import { Provider } from "@opencode-ai/schema/provider" import { Agent } from "@/agent/agent" import { Dag, type NodeConfig } from "@/dag/dag" import { DagLoop } from "@/dag/runtime/loop" import { InstanceRef } from "@/effect/instance-ref" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionPrompt } from "@/session/prompt" -import { MessageID } from "@/session/schema" +import { MessageID, PartID, SessionID } from "@/session/schema" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout } from "../lib/effect" @@ -39,23 +43,24 @@ function takeWithin(queue: Queue.Queue, message: string) { } function reply(sessionID: string, text: string): SessionV1.WithParts { + const id = MessageID.ascending() return { info: { - id: MessageID.ascending(), + id, role: "assistant", parentID: MessageID.ascending(), - sessionID: sessionID as never, + sessionID: SessionID.make(sessionID), mode: "build", agent: "build", cost: 0, path: { cwd: process.cwd(), root: process.cwd() }, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: "test-model" as never, - providerID: "test" as never, + modelID: Model.ID.make("test-model"), + providerID: Provider.ID.make("test"), time: { created: Date.now() }, finish: "stop", }, - parts: text ? [{ type: "text", text }] as never : [], + parts: text ? [{ id: PartID.ascending(), sessionID: SessionID.make(sessionID), messageID: id, type: "text", text }] : [], } } @@ -113,13 +118,31 @@ function loopLayer(input: { const created: string[] = [] let cancelCount = 0 const session = Layer.mock(Session.Service, { - get: () => Effect.succeed({ id: "ses_parent", permission: [], agent: "build" } as never), + get: () => Effect.succeed({ + id: SessionID.make("ses_parent"), + slug: "parent", + projectID: Project.ID.make("project-1"), + directory: process.cwd(), + title: "Parent", + version: "test", + time: { created: 0, updated: 0 }, + permission: [], + agent: "build", + }), create: (value) => Effect.sync(() => { const id = `ses_child_${created.length + 1}` created.push(id) childTitles.set(id, (value?.title ?? id).replace(" (DAG node)", "")) - return { id } as never + return { + id: SessionID.make(id), + slug: "child", + projectID: Project.ID.make("project-1"), + directory: process.cwd(), + title: value?.title ?? id, + version: "test", + time: { created: 0, updated: 0 }, + } }), messages: () => Effect.succeed([]), }) @@ -153,7 +176,7 @@ function loopLayer(input: { options: {}, description: "", prompt: "", - model: { providerID: "test" as never, modelID: "test-model" as never }, + model: { providerID: Provider.ID.make("test"), modelID: Model.ID.make("test-model") }, tools: {}, hooks: {}, }), @@ -190,15 +213,15 @@ function runLoopTest( const store = yield* DagStore.Service const database = yield* Database.Service yield* database.db.insert(ProjectTable).values({ - id: "project-1" as never, - worktree: process.cwd() as never, + id: Project.ID.make("project-1"), + worktree: AbsolutePath.make(process.cwd()), sandboxes: [], }).run().pipe(Effect.orDie) yield* database.db.insert(SessionTable).values({ - id: "ses_parent" as never, - project_id: "project-1" as never, + id: SessionID.make("ses_parent"), + project_id: Project.ID.make("project-1"), slug: "parent", - directory: process.cwd() as never, + directory: AbsolutePath.make(process.cwd()), title: "Parent", version: "test", }).run().pipe(Effect.orDie) @@ -215,8 +238,13 @@ function runLoopTest( Effect.provideService(InstanceRef, { directory: process.cwd(), worktree: process.cwd(), - project: { id: "project-1" }, - } as never), + project: { + id: Project.ID.make("project-1"), + worktree: process.cwd(), + time: { created: 0, updated: 0 }, + sandboxes: [], + }, + }), Effect.scoped, ) }) From 711be35f9fa913c162ce12591cb1a0ed6841ea59 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 19:37:37 +0800 Subject: [PATCH 04/17] docs: close ledger tickets 10/11 (#189 evidence) and add batch-B handoff --- .opencode/handoff-batch-b.md | 48 +++++++++++++++++++ .../10-backlog-phantom-cancelled-state.md | 16 +++++-- .../issues/11-backlog-spurious-t8-budget.md | 15 ++++-- 3 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 .opencode/handoff-batch-b.md diff --git a/.opencode/handoff-batch-b.md b/.opencode/handoff-batch-b.md new file mode 100644 index 0000000000..02a8726146 --- /dev/null +++ b/.opencode/handoff-batch-b.md @@ -0,0 +1,48 @@ +# 批次 B 交接文档(新会话入口) + +> 本会话(批 A 全链路)已极长,批次 B 在新对话执行。引用本文件 + 下述证据路径即可开工。 + +## 起点状态(交接时) +- 基线:**dev**(`bee78d7ed` 起算,开工前 `git fetch && git switch dev && git pull`) +- 批次 A 全闭环:Q1-Q6 引擎语义 + 接受期绑定校验 + flaky 根治(豁免清单已清零)+ 技术债清零(PR #185-#189 全合入) +- lint 棘轮:4852(CI 口径;本地 = CI − 10 生成物差,本地基线 ≤4842)——**只紧不松** +- 台账:.scratch/batch-a/issues/01-11(01-09 完成,10/11 closed) + +## 批次 B 四组票(Ask Matt 路由:agent-ready,证据已在案) + +### 组 1:U-1 / U-2 + Transport mid-stream-stall(一个 spec 三张票) +三个 abort-path 集成测试,同源同批。先 /to-spec 收敛三票边界,再逐票 /implement。 + +### 组 2:F3 / F4 测试卫生债 +小票直接做(无需 grill),每票新上下文 /implement。 + +### 组 3:O1 remote config last-known-good 缓存 +小 feature;离线降级的 last-known-good 语义在批 A 前置调研中有上下文(config-offline-degrade 已合入 dev,先读现状再定增量)。 + +### 组 4:S7 recovery INVENTED 推断 ⚠️ +唯一带 bug 气味的——**必须走 /diagnosing-bugs**:先 tight feedback loop(一条命令红灯复现)再修,禁止先理论后复现。修复以回归测试收口。 + +## 证据路径(不用重新调查,票据直接引用) +- .opencode/promotion-review-round1/*.md(U-1/U-2/F3/F4/O1/S7/P8 全部 finding + 根因记录) +- .opencode/promotion-review-round2.yaml 相关证据(若引用深审轮次) + +## 工程纪律(仓库铁律 + 本项目惯例) +- 分支:从 **dev** 切 `feat/**` 或 `fix/**`(AGENTS.md 原文写 main,本项目当前惯例:批 B 基于 dev——dev 领先 main 且为集成层) +- PR → dev:Typecheck 门禁;push dev 自动触发全量测试(Typecheck + Unit + E2E×2) +- 测试从包目录跑(packages/opencode 等),禁根目录;typecheck 用 `bun typecheck` 不用裸 tsc +- 每票一个新上下文会话执行(/implement 内含 /tdd),票间清上下文 +- HTTP API 路由若被触及:再生 SDK(./packages/sdk/js/script/build.ts)+ 更新 httpapi-exercise 场景 + +## 收束清单(批 B + 批 C 全部完成后) +1. dev 全量 CI 绿 → 一次性 dev→main 晋级 PR(四项门禁)→ 手动 release-fork +2. 分支一并清理(用户确认后手跑,dcg 拦 agent 删除): + ```bash + git worktree prune # 先清 opencode/* 残留 worktree(git worktree list 查路径) + git branch -d feat/dag-timeout-escalation feat/event-batch-publish feat/goal-pause-resume \ + feat/llm-request-timeout feat/session-runner-hotpath fix/config-offline-degrade \ + fix/deep-review-fixes review/dev-promotion <批B/C分支> + ``` +3. 台账惯例:新票记 .scratch/batch-b/issues/,完成翻 closed 附 PR/commit 实证 + +## 批 C(观测后再动,勿提前) +- P8 spawnReady O(ready×nodes):当前规模无实感,挂 /improve-codebase-architecture 巡检候选,疼了再做 diff --git a/.scratch/batch-a/issues/10-backlog-phantom-cancelled-state.md b/.scratch/batch-a/issues/10-backlog-phantom-cancelled-state.md index 57d3c09cf1..7f54f181c3 100644 --- a/.scratch/batch-a/issues/10-backlog-phantom-cancelled-state.md +++ b/.scratch/batch-a/issues/10-backlog-phantom-cancelled-state.md @@ -14,9 +14,15 @@ **Blocked by:** None(独立设计决策) -**Status:** backlog(需先设计裁决,非 ready-for-agent) +**Status:** closed(方案 A 已实施 — PR #189,commit 67d1ca2b1) -- [ ] 设计裁决 A/B(含消费方影响面清单) -- [ ] 按裁决实施 + 测试 -- [ ] 转移表 v2 与 CONTEXT.md 状态机词汇同步 -- [ ] typecheck + dag 套件绿 +## 裁决与实施记录(batch-a-residuals DAG,终审 PASS) +- 裁决:方案 A(对齐实现)——消费方核验确认仅 TUI 存在 phantom dead branch 读节点级 cancelled,投影写 status=failed 故永不触发,无真实依赖 +- T5 改写 to=failed(cancelled);状态空间删除节点级 cancelled 目标态(保留工作流级);CONTEXT.md 同步;projector 投影注释固化契约 +- 新增 core 测试断言 NodeCancelled 重放 → status=failed + error_reason 承载取消语义 +- 对抗审查:检察官/辩护人/证据矩阵三路 + 第四方 claim 核验,终审 PASS + +- [x] 设计裁决 A/B(含消费方影响面清单) +- [x] 按裁决实施 + 测试 +- [x] 转移表 v2 与 CONTEXT.md 状态机词汇同步 +- [x] typecheck + dag 套件绿 diff --git a/.scratch/batch-a/issues/11-backlog-spurious-t8-budget.md b/.scratch/batch-a/issues/11-backlog-spurious-t8-budget.md index 675e877cbd..8b01cd76c3 100644 --- a/.scratch/batch-a/issues/11-backlog-spurious-t8-budget.md +++ b/.scratch/batch-a/issues/11-backlog-spurious-t8-budget.md @@ -14,9 +14,14 @@ **Blocked by:** None(独立设计决策) -**Status:** backlog(需先裁决 A/B/C,非 ready-for-agent) +**Status:** closed(方案 A 已实施 — PR #189,commit b35630486) -- [ ] 裁决修复方向(A/B/C,含锁交互与预算语义影响面) -- [ ] 按裁决实施 + 测试(含陈旧读复现场景) -- [ ] 若 C:ADR + 转移表语义注记落地 -- [ ] typecheck + dag 套件绿 +## 裁决与实施记录(batch-a-residuals DAG,终审 PASS) +- 裁决:方案 A(锁内新鲜读)——watchdog 以 staleDeadlineMs 守卫判定:陈旧读触发的延长被新鲜读否决时不发布 NodeDeadlineExtended、不递增 timeoutExtensions +- 真红→绿:test/dag/dag-retime-stale-read.test.ts,case1(陈旧读抑制)vs case2(真实超时延长)对照 +- 不变式保持:-2/0/1 三值契约、N1 监督不变式(running 节点总有 watcher) +- 对抗审查两项开放担忧裁决:U1(Effect.timeout 败者中断产生孤立节点)经 Effect v4 源码分析 REFUTED(TimeoutError=Cause.Fail,raceAllFirst 败者中断不经 hasInterrupts 匹配);N1(抑制守卫 >staleDeadlineMs 缺 >now)经三方一致论证为有界自愈(下一 tick 必发布,延迟 ≤1 escalateIntervalMs,预算不丢) + +- [x] 裁决修复方向(A/B/C,含锁交互与预算语义影响面) +- [x] 按裁决实施 + 测试(含陈旧读复现场景) +- [x] typecheck + dag 套件绿 From 2ad7fb974992720ebb09117aa97814570c410cae Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 20:08:40 +0800 Subject: [PATCH 05/17] docs: prepare batch B implementation tickets --- .opencode/handoff-batch-b.md | 17 +++--- .../01-q1-escalation-pending-lifecycle.md | 14 +++-- .../issues/02-q2-delivery-gated-retime.md | 14 +++-- .../03-q3-node-deadline-extended-event.md | 16 ++--- .../issues/04-q3-sdk-regen-consumers.md | 14 +++-- .../issues/05-s5-workflow-lock-timeout.md | 14 +++-- .../issues/06-flaky-stdout-pollution.md | 12 ++-- .../issues/07-flaky-sharenext-timing.md | 10 +-- .../issues/08-flaky-workspace-timing.md | 10 +-- .../batch-a/issues/09-promote-dev-to-main.md | 12 ++-- .scratch/batch-b/README.md | 23 +++++++ .scratch/batch-b/abort-path-contracts.md | 53 ++++++++++++++++ .scratch/batch-b/evidence.md | 61 +++++++++++++++++++ .../batch-b/issues/01-u1-fork-rollback.md | 15 +++++ .../issues/02-u2-transport-timeout-abort.md | 15 +++++ .../issues/03-transport-midstream-stall.md | 15 +++++ .../issues/04-f3-subscription-readiness.md | 14 +++++ .../05-f4-type-safe-dag-store-fixtures.md | 14 +++++ .scratch/batch-b/issues/06-o1-lkg-spec.md | 14 +++++ .../batch-b/issues/07-o1-lkg-implement.md | 14 +++++ .../08-s7-recovery-invented-diagnosis.md | 15 +++++ .../issues/09-promote-dev-main-release.md | 12 ++++ .../issues/01-p8-spawn-ready-observation.md | 12 ++++ 23 files changed, 353 insertions(+), 57 deletions(-) create mode 100644 .scratch/batch-b/README.md create mode 100644 .scratch/batch-b/abort-path-contracts.md create mode 100644 .scratch/batch-b/evidence.md create mode 100644 .scratch/batch-b/issues/01-u1-fork-rollback.md create mode 100644 .scratch/batch-b/issues/02-u2-transport-timeout-abort.md create mode 100644 .scratch/batch-b/issues/03-transport-midstream-stall.md create mode 100644 .scratch/batch-b/issues/04-f3-subscription-readiness.md create mode 100644 .scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md create mode 100644 .scratch/batch-b/issues/06-o1-lkg-spec.md create mode 100644 .scratch/batch-b/issues/07-o1-lkg-implement.md create mode 100644 .scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md create mode 100644 .scratch/batch-b/issues/09-promote-dev-main-release.md create mode 100644 .scratch/batch-c/issues/01-p8-spawn-ready-observation.md diff --git a/.opencode/handoff-batch-b.md b/.opencode/handoff-batch-b.md index 02a8726146..09e58a754b 100644 --- a/.opencode/handoff-batch-b.md +++ b/.opencode/handoff-batch-b.md @@ -3,31 +3,32 @@ > 本会话(批 A 全链路)已极长,批次 B 在新对话执行。引用本文件 + 下述证据路径即可开工。 ## 起点状态(交接时) -- 基线:**dev**(`bee78d7ed` 起算,开工前 `git fetch && git switch dev && git pull`) +- 基线:**dev**(批 B 规划基线 `3e8368f37`;每票开工前重新同步最新 `dev`) - 批次 A 全闭环:Q1-Q6 引擎语义 + 接受期绑定校验 + flaky 根治(豁免清单已清零)+ 技术债清零(PR #185-#189 全合入) - lint 棘轮:4852(CI 口径;本地 = CI − 10 生成物差,本地基线 ≤4842)——**只紧不松** - 台账:.scratch/batch-a/issues/01-11(01-09 完成,10/11 closed) -## 批次 B 四组票(Ask Matt 路由:agent-ready,证据已在案) +## 批次 B 四组票(审计后路由,证据已在案) ### 组 1:U-1 / U-2 + Transport mid-stream-stall(一个 spec 三张票) -三个 abort-path 集成测试,同源同批。先 /to-spec 收敛三票边界,再逐票 /implement。 +三个 abort-path 集成测试,同源同批。规格已收敛到 `.scratch/batch-b/abort-path-contracts.md`,按 01→02→03 逐票 /implement。 ### 组 2:F3 / F4 测试卫生债 小票直接做(无需 grill),每票新上下文 /implement。 ### 组 3:O1 remote config last-known-good 缓存 -小 feature;离线降级的 last-known-good 语义在批 A 前置调研中有上下文(config-offline-degrade 已合入 dev,先读现状再定增量)。 +批 A 已完成“网络/响应体失败时 warn + skip”;剩余增量只有持久化 last-known-good。先完成 `.scratch/batch-b/issues/06-o1-lkg-spec.md` 的小规格,再按 07 实现;不得重新实现离线降级。 ### 组 4:S7 recovery INVENTED 推断 ⚠️ -唯一带 bug 气味的——**必须走 /diagnosing-bugs**:先 tight feedback loop(一条命令红灯复现)再修,禁止先理论后复现。修复以回归测试收口。 +当前已有 ownership-lost 后暂停工作流的缓解,尚无用户态缺陷实证。**必须走 /diagnosing-bugs**:先建立一条确定性、快速、可红灯的复现命令;不能建立反馈回路则记录尝试并停止,不得先改生产代码。若红灯成立,另开修复票与新上下文。 ## 证据路径(不用重新调查,票据直接引用) -- .opencode/promotion-review-round1/*.md(U-1/U-2/F3/F4/O1/S7/P8 全部 finding + 根因记录) -- .opencode/promotion-review-round2.yaml 相关证据(若引用深审轮次) +- `.scratch/batch-b/evidence.md`:已追踪的稳定证据快照,含当前代码路径纠偏与验收边界;新 worktree 只依赖此文件 +- `.opencode/promotion-review-round1/*.md`、`.opencode/.dag-specs/evidence/*.md`:原始本地评审产物,当前未追踪,仅用于复核来源,不作为跨 worktree 前置 +- `.scratch/batch-b/abort-path-contracts.md`:U-1/U-2/mid-stream-stall 的已追踪规格;本地 OpenSpec 原件受 `.gitignore` 约束,不作为跨 worktree 前置 ## 工程纪律(仓库铁律 + 本项目惯例) -- 分支:从 **dev** 切 `feat/**` 或 `fix/**`(AGENTS.md 原文写 main,本项目当前惯例:批 B 基于 dev——dev 领先 main 且为集成层) +- 分支:从最新 **dev** 切票据指定分支;生产 feature 用 `feat/**`,测试/规格债用 `test/**` 或 `docs/**`,均符合 branch-naming ruleset - PR → dev:Typecheck 门禁;push dev 自动触发全量测试(Typecheck + Unit + E2E×2) - 测试从包目录跑(packages/opencode 等),禁根目录;typecheck 用 `bun typecheck` 不用裸 tsc - 每票一个新上下文会话执行(/implement 内含 /tdd),票间清上下文 diff --git a/.scratch/batch-a/issues/01-q1-escalation-pending-lifecycle.md b/.scratch/batch-a/issues/01-q1-escalation-pending-lifecycle.md index a6ac3c5844..62c43fde33 100644 --- a/.scratch/batch-a/issues/01-q1-escalation-pending-lifecycle.md +++ b/.scratch/batch-a/issues/01-q1-escalation-pending-lifecycle.md @@ -6,10 +6,12 @@ **Blocked by:** None — can start immediately -**Status:** ready-for-agent +**Status:** closed(PR #186,merge commit `4ddeaf2fc`) -- [ ] 节点终态转移(completed/failed/aborted)与取消路径清 escalation_pending -- [ ] wake_reported 在清旗路径上不被触碰(两旗正交测试) -- [ ] 已有 NodeStarted/NodeRestarted 清旗点保持不回退 -- [ ] replay/恢复场景下清旗经事件折叠重放一致 -- [ ] dag 测试套件 + typecheck 绿 +**Completion evidence:** 批次 A 实现与测试随 PR #186 合入 `dev`,并随 PR #188 通过 main 全量门禁。 + +- [x] 节点终态转移(completed/failed/aborted)与取消路径清 escalation_pending +- [x] wake_reported 在清旗路径上不被触碰(两旗正交测试) +- [x] 已有 NodeStarted/NodeRestarted 清旗点保持不回退 +- [x] replay/恢复场景下清旗经事件折叠重放一致 +- [x] dag 测试套件 + typecheck 绿 diff --git a/.scratch/batch-a/issues/02-q2-delivery-gated-retime.md b/.scratch/batch-a/issues/02-q2-delivery-gated-retime.md index 70cf200f88..955ae776e6 100644 --- a/.scratch/batch-a/issues/02-q2-delivery-gated-retime.md +++ b/.scratch/batch-a/issues/02-q2-delivery-gated-retime.md @@ -6,10 +6,12 @@ **Blocked by:** None — can start immediately -**Status:** ready-for-agent +**Status:** closed(PR #186,merge commit `4ddeaf2fc`) -- [ ] skip 合取项落在 re-time 唯一发起点,全 re-time 触发路径逐条覆盖(测试枚举,不只抄规格) -- [ ] 初始升级(deadline ⟹ 首次 wake)不受门控影响 -- [ ] 裁决写入后 re-time 能力恢复的测试 -- [ ] watchdog 无状态写(仅提案)的断言保持 -- [ ] dag 测试套件 + typecheck 绿 +**Completion evidence:** 批次 A 实现与测试随 PR #186 合入 `dev`,并随 PR #188 通过 main 全量门禁。 + +- [x] skip 合取项落在 re-time 唯一发起点,全 re-time 触发路径逐条覆盖(测试枚举,不只抄规格) +- [x] 初始升级(deadline ⟹ 首次 wake)不受门控影响 +- [x] 裁决写入后 re-time 能力恢复的测试 +- [x] watchdog 无状态写(仅提案)的断言保持 +- [x] dag 测试套件 + typecheck 绿 diff --git a/.scratch/batch-a/issues/03-q3-node-deadline-extended-event.md b/.scratch/batch-a/issues/03-q3-node-deadline-extended-event.md index 3456af0a29..f09f418f09 100644 --- a/.scratch/batch-a/issues/03-q3-node-deadline-extended-event.md +++ b/.scratch/batch-a/issues/03-q3-node-deadline-extended-event.md @@ -6,11 +6,13 @@ **Blocked by:** 01 — Q1:escalation_pending 裁决旗生命周期闭环(projector 折叠侧写集串行) -**Status:** ready-for-agent +**Status:** closed(PR #186,merge commit `4ddeaf2fc`) -- [ ] Schema 定义 NodeDeadlineExtended + 入 EventManifest.Definitions -- [ ] 命令层执行 guard:拒绝时命令失败并携带 typed 错误,编排器可区分拒绝与成功 -- [ ] 直写 deadline 旧路径废除(无遗留调用方) -- [ ] projector 纯折叠:无事件发布、无返回值契约依赖 -- [ ] 恢复/replay 一致性测试(事件日志重放 ⟺ 活跃态) -- [ ] dag 测试套件 + typecheck 绿 +**Completion evidence:** 批次 A 实现与测试随 PR #186 合入 `dev`,并随 PR #188 通过 main 全量门禁。 + +- [x] Schema 定义 NodeDeadlineExtended + 入 EventManifest.Definitions +- [x] 命令层执行 guard:拒绝时命令失败并携带 typed 错误,编排器可区分拒绝与成功 +- [x] 直写 deadline 旧路径废除(无遗留调用方) +- [x] projector 纯折叠:无事件发布、无返回值契约依赖 +- [x] 恢复/replay 一致性测试(事件日志重放 ⟺ 活跃态) +- [x] dag 测试套件 + typecheck 绿 diff --git a/.scratch/batch-a/issues/04-q3-sdk-regen-consumers.md b/.scratch/batch-a/issues/04-q3-sdk-regen-consumers.md index 694c50366b..b099b39682 100644 --- a/.scratch/batch-a/issues/04-q3-sdk-regen-consumers.md +++ b/.scratch/batch-a/issues/04-q3-sdk-regen-consumers.md @@ -4,10 +4,12 @@ **Blocked by:** 03 — Q3:NodeDeadlineExtended durable 事件 + guard 前移命令层 -**Status:** ready-for-agent +**Status:** closed(PR #186,merge commit `4ddeaf2fc`) -- [ ] SDK 再生脚本执行,生成物提交 -- [ ] 事件联合类型包含 NodeDeadlineExtended,消费方编译绿 -- [ ] `check:generated`(SDK + client)零 diff -- [ ] 涉及响应/事件形状的 httpapi-exercise 场景已更新(如有) -- [ ] 全量单元测试(含 httpapi 契约)绿 +**Completion evidence:** 批次 A 生成物与消费者更新随 PR #186 合入 `dev`,并随 PR #188 通过 main 全量门禁。 + +- [x] SDK 再生脚本执行,生成物提交 +- [x] 事件联合类型包含 NodeDeadlineExtended,消费方编译绿 +- [x] `check:generated`(SDK + client)零 diff +- [x] 涉及响应/事件形状的 httpapi-exercise 场景已更新(如有) +- [x] 全量单元测试(含 httpapi 契约)绿 diff --git a/.scratch/batch-a/issues/05-s5-workflow-lock-timeout.md b/.scratch/batch-a/issues/05-s5-workflow-lock-timeout.md index d0df3f56e4..d568bd49c1 100644 --- a/.scratch/batch-a/issues/05-s5-workflow-lock-timeout.md +++ b/.scratch/batch-a/issues/05-s5-workflow-lock-timeout.md @@ -6,10 +6,12 @@ **Blocked by:** None — can start immediately -**Status:** ready-for-agent +**Status:** closed(PR #186,merge commit `4ddeaf2fc`) -- [ ] 唯一改动点在 withWorkflowLock 包装层(一行 + 常量) -- [ ] 30s 超限产生 TimeoutException,编排器按既有 error_class 分诊规则处置 -- [ ] 无新错误类、无 per-caller 分支的断言 -- [ ] watchdog 自续行为在锁超时后仍正确的测试 -- [ ] dag 测试套件 + typecheck 绿 +**Completion evidence:** 批次 A 实现与测试随 PR #186 合入 `dev`,并随 PR #188 通过 main 全量门禁。 + +- [x] 唯一改动点在 withWorkflowLock 包装层(一行 + 常量) +- [x] 30s 超限产生 TimeoutException,编排器按既有 error_class 分诊规则处置 +- [x] 无新错误类、无 per-caller 分支的断言 +- [x] watchdog 自续行为在锁超时后仍正确的测试 +- [x] dag 测试套件 + typecheck 绿 diff --git a/.scratch/batch-a/issues/06-flaky-stdout-pollution.md b/.scratch/batch-a/issues/06-flaky-stdout-pollution.md index ca83a2c3e2..57abd202ff 100644 --- a/.scratch/batch-a/issues/06-flaky-stdout-pollution.md +++ b/.scratch/batch-a/issues/06-flaky-stdout-pollution.md @@ -6,9 +6,11 @@ **Blocked by:** None — can start immediately -**Status:** ready-for-agent +**Status:** closed(PR #186,merge commit `4ddeaf2fc`) -- [ ] 污染源定位经可复现测试验证(修复前红、修复后绿) -- [ ] run-process 9 项断言不削弱、不删除,本地重复跑(≥5 次)稳定绿 -- [ ] ShareNext 的 stdout 污染分量同步修复(计时问题归 07 票) -- [ ] opencode 包测试套全绿(除豁免清单剩余项) +**Completion evidence:** flaky 根因修复与验证随 PR #186 合入 `dev`,并随 PR #188 通过 main 全量门禁。 + +- [x] 污染源定位经可复现测试验证(修复前红、修复后绿) +- [x] run-process 9 项断言不削弱、不删除,本地重复跑(≥5 次)稳定绿 +- [x] ShareNext 的 stdout 污染分量同步修复(计时问题归 07 票) +- [x] opencode 包测试套全绿(除豁免清单剩余项) diff --git a/.scratch/batch-a/issues/07-flaky-sharenext-timing.md b/.scratch/batch-a/issues/07-flaky-sharenext-timing.md index 173a89dbdd..ed5980f57e 100644 --- a/.scratch/batch-a/issues/07-flaky-sharenext-timing.md +++ b/.scratch/batch-a/issues/07-flaky-sharenext-timing.md @@ -6,8 +6,10 @@ **Blocked by:** 06 — Flaky:stdout 污染族根治(同一测试文件,写集串行) -**Status:** ready-for-agent +**Status:** closed(PR #186,merge commit `4ddeaf2fc`) -- [ ] 修复走信号等待惯用法;若改预算须附 CI 计时证据 -- [ ] 本地重复跑(≥5 次)+ 模拟负载下稳定绿 -- [ ] 无新增 Effect.sleep 等待 forked fiber 的反模式 +**Completion evidence:** flaky 稳定化与验证随 PR #186 合入 `dev`,并随 PR #188 通过 main 全量门禁。 + +- [x] 修复走信号等待惯用法;若改预算须附 CI 计时证据 +- [x] 本地重复跑(≥5 次)+ 模拟负载下稳定绿 +- [x] 无新增 Effect.sleep 等待 forked fiber 的反模式 diff --git a/.scratch/batch-a/issues/08-flaky-workspace-timing.md b/.scratch/batch-a/issues/08-flaky-workspace-timing.md index 0a265c8df4..0402fbbc16 100644 --- a/.scratch/batch-a/issues/08-flaky-workspace-timing.md +++ b/.scratch/batch-a/issues/08-flaky-workspace-timing.md @@ -6,8 +6,10 @@ **Blocked by:** None — can start immediately -**Status:** ready-for-agent +**Status:** closed(PR #186,merge commit `4ddeaf2fc`) -- [ ] 先复现并确认根因(计时 vs 其他),根因记录入票 -- [ ] 修复后本地重复跑(≥5 次)+ 模拟负载下稳定绿 -- [ ] 无新增固定 sleep 反模式 +**Completion evidence:** flaky 稳定化与验证随 PR #186 合入 `dev`,并随 PR #188 通过 main 全量门禁。 + +- [x] 先复现并确认根因(计时 vs 其他),根因记录入票 +- [x] 修复后本地重复跑(≥5 次)+ 模拟负载下稳定绿 +- [x] 无新增固定 sleep 反模式 diff --git a/.scratch/batch-a/issues/09-promote-dev-to-main.md b/.scratch/batch-a/issues/09-promote-dev-to-main.md index d9a6a51793..bca0d7dada 100644 --- a/.scratch/batch-a/issues/09-promote-dev-to-main.md +++ b/.scratch/batch-a/issues/09-promote-dev-to-main.md @@ -4,9 +4,11 @@ **Blocked by:** 01、02、03、04、05、06、07、08 全部合入 dev -**Status:** ready-for-agent +**Status:** closed(PR #188,merge commit `e837dcbfa`) -- [ ] dev 最新 push 的 CI 四项检查全绿(Typecheck、Unit、E2E linux、E2E windows) -- [ ] 豁免清单清零或逐项重新裁决留档 -- [ ] PR 描述附批次 A 交付清单(Q1/Q2/Q3/S5 + flaky 根因修复)与两轮深审 PASS 证据链接 -- [ ] 合并后 main 可手动 release-fork +**Completion evidence:** dev→main 晋级 PR #188 的 Typecheck、Unit Tests (linux)、E2E Tests (linux/windows) 全部通过并合入。 + +- [x] dev 最新 push 的 CI 四项检查全绿(Typecheck、Unit、E2E linux、E2E windows) +- [x] 豁免清单清零或逐项重新裁决留档 +- [x] PR 描述附批次 A 交付清单(Q1/Q2/Q3/S5 + flaky 根因修复)与两轮深审 PASS 证据链接 +- [x] 合并后 main 可手动 release-fork diff --git a/.scratch/batch-b/README.md b/.scratch/batch-b/README.md new file mode 100644 index 0000000000..7a94dbb54e --- /dev/null +++ b/.scratch/batch-b/README.md @@ -0,0 +1,23 @@ +# 批次 B 执行台账 + +**规划基线:** `dev@3e8368f37`(PR #190) + +**当前状态:** 规格与票据已就绪;实现代码尚未开始。 + +## 审计结论 + +- 批次 A 已经 PR #188 晋级 `main`;残余修复 PR #189 与交接 PR #190 已进入 `dev`。 +- U-1/U-2/mid-stream-stall 的 OpenSpec 已完成并通过校验;仓库规定 `/openspec/` local-only,跨 worktree 使用已追踪镜像 `.scratch/batch-b/abort-path-contracts.md`。 +- 原始 `.opencode/promotion-review-round1/` 与 `.opencode/.dag-specs/evidence/` 是未追踪本地文件;跨 worktree 统一引用 `.scratch/batch-b/evidence.md`。 +- O1 不是直接实现票:离线降级已完成,剩余 LKG 的持久化、键、失效和安全边界需先写小规格。 +- S7 只有静态 bug 气味;先诊断,不能复现就无代码收口。P8 维持批 C 的观测候选,不阻塞批 B。 + +## 串行顺序 + +1. 01 U-1 → 02 U-2 → 03 mid-stream-stall;02/03 写同一测试文件,禁止并行。 +2. 04 F3 → 05 F4;两票只清测试债,不顺带改运行时。 +3. 06 O1 规格 → 07 O1 实现。 +4. 08 S7 诊断;只有红灯成立才新建独立修复票。 +5. 批 B 全部处置 + 批 C P8 观测记录关闭后,执行 09 的 dev→main 晋级与 release-fork。 + +每票从最新 `dev` 创建符合仓库规则的分支,单独 PR → `dev`,单独新任务执行。票据完成时将状态改为 `closed`,附 PR、merge commit 与验证命令。 diff --git a/.scratch/batch-b/abort-path-contracts.md b/.scratch/batch-b/abort-path-contracts.md new file mode 100644 index 0000000000..eaaa7dde92 --- /dev/null +++ b/.scratch/batch-b/abort-path-contracts.md @@ -0,0 +1,53 @@ +# 批次 B:abort-path integrity 规格 + +**Status:** accepted +**Applies to:** 01 U-1、02 U-2、03 Transport mid-stream-stall +**Local OpenSpec source:** `openspec/changes/batch-b-abort-path-contracts/`(仓库规定 local-only;`openspec validate --changes` 已通过) + +## 目标 + +三个边界目前只有实现/注释声明,没有真实失败路径证据:Session fork 嵌套发布失败时的批次回滚、HTTP timeout 对真实连接的取消传播、合法首帧后的逐帧间隔 timeout。本规格只补集成测试;红灯暴露违约时,才允许做对应契约所需的最小生产修复。 + +## Requirement 1:fork 复制批次原子回滚 + +系统 SHALL 在 `Session.fork` 的消息/part 复制批次中保持原子性:任一嵌套 durable event 发布失败时,同一外层事务内此前写入的复制事件及其投影全部回滚。 + +### Scenario:部分复制后嵌套发布失败 + +- WHEN fork 已创建目标 session,至少一个 message/part 发布完成,随后嵌套发布失败 +- THEN fork 调用失败,目标 session 不存在本批复制出的 message/part projections 与 durable copy events +- THEN 源 session 的 message/part 保持不变 +- THEN 复制事务外已经提交的目标 Session Created 记录可以保留 + +**设计裁决:** 必须扩展 `packages/opencode/test/session/fork-batch.test.ts` 的真实 SQLite fixture;adapter-only savepoint 测试不足以证明 EventV2/projector 共用外层连接。 + +## Requirement 2:provider timeout 取消真实 transport + +系统 MUST 在 provider HTTP timeout 到期时,以现有 Transport/Timeout 结束 LLM stream,并取消仍在进行的真实 HTTP response stream,使 provider 端观察到连接或响应体取消。 + +### Scenario:真实 provider response 超时后仍保持打开 + +- WHEN loopback provider 已接收请求并返回超过 timeout 仍保持打开的 response stream +- THEN 客户端在有界时间内以现有 `LLMError` Transport/Timeout 失败 +- THEN provider 在有界时间内观察到 response cancellation 或等价 request abort + +**设计裁决:** 使用真实 `Bun.serve` loopback 与生产 fetch-backed client。以 response cancellation 为确定性主信号,request abort 可作补充;内存 HttpClient 和显式 Fiber interrupt 都不能替代本场景。 + +## Requirement 3:timeout 约束每个帧间隔 + +系统 SHALL 将 stream timeout 作为相邻数据帧之间的最大间隔,而不是只覆盖 response headers 或首帧等待。 + +### Scenario:合法首帧后永久停顿 + +- WHEN provider 在 timeout 内发出至少一个合法 SSE frame,随后不关闭且不再发送数据 +- THEN timeout 前到达的 frame 已交付消费者 +- THEN 停顿超过 timeout 后,stream 以现有 Transport/Timeout 失败 + +**设计裁决:** 使用 fence 证明首帧已交付,再用 TestClock 越过下一帧间隔;本场景验证 stream timing,不重复真实 socket 取消测试。 + +## 非目标与顺序 + +- 不要求把 fork session creation 与复制批次合并为同一事务。 +- 不改变 timeout 默认值、错误词汇、retry policy 或 provider protocol。 +- 01 → 02 → 03 串行落地;02/03 都修改 `packages/llm/test/transport-timeout.test.ts`。 +- 若 Bun 在目标 CI 平台无法提供可重复的服务端取消信号,02 停在诊断结论,不能退化为重复断言 Timeout 错误。 diff --git a/.scratch/batch-b/evidence.md b/.scratch/batch-b/evidence.md new file mode 100644 index 0000000000..b2cfb8887e --- /dev/null +++ b/.scratch/batch-b/evidence.md @@ -0,0 +1,61 @@ +# 批次 B 证据快照 + +**代码基线:** `dev@3e8368f37` +**用途:** 给每票的新任务/worktree 提供稳定证据;无需重新调查原始评审。 +**原始来源:** 本地未追踪的 `.opencode/promotion-review-round1/*.md` 与 `.opencode/.dag-specs/evidence/*.md`。 + +## 组 1:abort-path 契约 + +### U-1 — Session fork 嵌套事务回滚 + +- 原始 finding 引用的 `packages/core/src/session.ts` 已过期;当前实现位于 `packages/opencode/src/session/session.ts` 的 `Session.fork`。 +- fork 的消息/part 复制由一个外层 `db.transaction` 包裹,嵌套 `events.publish` 会进入 Effect-Drizzle SQLite savepoint。 +- `packages/effect-drizzle-sqlite/src/effect-sqlite/session.ts` 已实现嵌套事务的 savepoint/rollback。 +- `packages/opencode/test/session/fork-batch.test.ts` 已验证成功路径与事务/savepoint 数量,缺口仅是“部分发布成功后失败”的整体回滚。 +- 目标契约、测试边界与非目标见 `.scratch/batch-b/abort-path-contracts.md`。 + +### U-2 — timeout 传播到底层 HTTP 取消 + +- `packages/llm/src/route/transport/http.ts` 对请求执行使用 `Effect.timeout`,对响应 stream 使用 `Stream.timeoutOrElse`。 +- `packages/llm/test/transport-timeout.test.ts` 已覆盖 headers 挂起、body 从不发帧、正常完成、默认 timeout 与选项合并,但使用内存 HTTP client,不能证明真实 socket/response body 被取消。 +- `packages/opencode/test/session/llm.test.ts` 已覆盖显式 Fiber interrupt 导致 provider response body 取消;本票必须验证“timeout 驱动”的取消,不能复制该场景。 +- 验收必须使用真实 loopback `Bun.serve` + 生产 fetch-backed client,并以服务端可观察的 response cancellation 为主信号。 + +### Transport mid-stream-stall + +- 当前 timeout suite 没有“合法首帧已交付,随后永久停顿”的场景。 +- 本票验证逐帧间隔 timeout;可使用 TestClock,不承担真实 socket 取消证明。 +- U-2 与本票都修改 `packages/llm/test/transport-timeout.test.ts`,必须先 02 后 03。 + +## 组 2:测试卫生债 + +### F3 — 固定订阅 settle sleep + +- `packages/opencode/test/goal/e2e-loop.test.ts` 定义 `SUBSCRIPTION_SETTLE_MS = 200`,共有 8 个固定 `Effect.sleep` 等待点。 +- `GoalLoop` 初始化主要读取实例状态并 fork 事件订阅;票据应以可观察 readiness/fence 或最小调度让步替代墙钟等待。 +- 验收要求旧的固定 settle sleep 全部消失,并重复运行目标测试;不把生产行为修改当作默认方案。 + +### F4 — DagStore 双重断言 + +- `packages/opencode/test/dag/dag-timeout-escalation-fixes.test.ts` 有两处 `as unknown as DagStore.Interface`,原始证据只记录了第一处。 +- 两处都需改为类型安全的 `Layer.mock`/fixture factory;不得只清一处。 + +## 组 3/4 与批 C + +### O1 — remote config last-known-good + +- PR #182/#189 前的现状已变化:`packages/opencode/src/config/config.ts` 在 remote transport/body 失败时会 warn + skip;HTML 登录页/auth 与 schema decode 仍硬失败。 +- 剩余需求仅是持久化 LKG。实施前需裁决:缓存内容、稳定键、原子写与权限、何种失败允许回退、损坏缓存行为、TTL。 +- 安全下限:缓存键不得含 header/token;LKG 不得掩盖 auth/decode 错误;损坏缓存只能 warn + skip。 + +### S7 — recovery INVENTED 推断 + +- `packages/opencode/src/dag/runtime/recovery.ts` 的 session checker 从最后一条 assistant finish 推断 active/terminal;tool-calls、unknown 或无 finish 会落入 active/unknown,并可能在 reconcile 中写入 `exec_failed`。 +- `packages/opencode/src/dag/runtime/loop.ts` 在 `ownershipLost` 后会暂停 workflow,现有测试已覆盖该缓解;目前没有已复现的用户态缺陷。 +- 只能按 `/diagnosing-bugs` 先建红灯反馈回路。若“durable transcript 已语义完成却被判 active 并写失败”无法稳定复现,结论应是无修复,不得凭静态推断改代码。 + +### P8 — spawnReady 复杂度 + +- `packages/opencode/src/dag/runtime/loop.ts` 的 `spawnReady` 对 ready 节点反复在全节点数组中 `.find`,静态复杂度为 `O(ready × nodes)`。 +- 原始性能评审明确标记“>50 节点的实际调度开销未实测”;当前无用户痛点或 benchmark。 +- 批 C 只记录观测结论;没有 trace/benchmark 证明影响时,以 no-code 关闭,不阻塞最终晋级。 diff --git a/.scratch/batch-b/issues/01-u1-fork-rollback.md b/.scratch/batch-b/issues/01-u1-fork-rollback.md new file mode 100644 index 0000000000..fa0ae38611 --- /dev/null +++ b/.scratch/batch-b/issues/01-u1-fork-rollback.md @@ -0,0 +1,15 @@ +# 01 — U-1:Session fork 中途失败整体回滚 + +**What to build:** 在真实 SQLite 的 `Session.fork` 集成测试中注入确定性中途失败,证明一个嵌套 durable publication 失败会回滚同一复制批次中已写入的消息/part 事件及投影。 + +**Spec:** `.scratch/batch-b/abort-path-contracts.md` 的 Requirement 1 +**Evidence:** `.scratch/batch-b/evidence.md#u-1--session-fork-嵌套事务回滚` +**Branch:** `test/fork-rollback` +**Blocked by:** None +**Status:** ready-for-agent + +- [ ] 复用 `packages/opencode/test/session/fork-batch.test.ts` 的真实 SQLite fixture;不写 adapter-only 替代测试 +- [ ] 至少一个 message/part 发布完成后再确定性失败,旧实现若违约时测试能红 +- [ ] 目标 session 无复制出的 durable events 与 projections,源 session 不变;Session Created 可保留 +- [ ] 若红灯暴露生产缺陷,只做本契约所需的最小修复 +- [ ] 在 `packages/opencode` 运行目标测试与 `bun typecheck`,结果附入票据 diff --git a/.scratch/batch-b/issues/02-u2-transport-timeout-abort.md b/.scratch/batch-b/issues/02-u2-transport-timeout-abort.md new file mode 100644 index 0000000000..5983901932 --- /dev/null +++ b/.scratch/batch-b/issues/02-u2-transport-timeout-abort.md @@ -0,0 +1,15 @@ +# 02 — U-2:HTTP timeout 传播到真实 response 取消 + +**What to build:** 使用 loopback `Bun.serve` 与生产 fetch-backed HTTP client,证明 provider timeout 不只返回 Transport/Timeout,还会取消仍打开的真实响应流。 + +**Spec:** `.scratch/batch-b/abort-path-contracts.md` 的 Requirement 2 +**Evidence:** `.scratch/batch-b/evidence.md#u-2--timeout-传播到底层-http-取消` +**Branch:** `test/transport-abort` +**Blocked by:** 01(同一 OpenSpec 串行落地) +**Status:** blocked + +- [ ] fixture 提供“请求已接收”与“response 已取消”的有界 fence,禁止用固定 sleep 猜时序 +- [ ] timeout 后断言现有 `LLMError` Transport/Timeout 形状 +- [ ] 服务端确定性观察到 response stream cancellation;request abort 只作补充信号 +- [ ] 不用内存 HttpClient 或显式 Fiber interrupt 重复现有覆盖 +- [ ] 在 `packages/llm` 连续运行目标测试至少 3 次并运行 `bun typecheck` diff --git a/.scratch/batch-b/issues/03-transport-midstream-stall.md b/.scratch/batch-b/issues/03-transport-midstream-stall.md new file mode 100644 index 0000000000..ebc3f10eee --- /dev/null +++ b/.scratch/batch-b/issues/03-transport-midstream-stall.md @@ -0,0 +1,15 @@ +# 03 — Transport:合法首帧后的 stall 触发逐帧超时 + +**What to build:** 增加“合法 SSE 首帧已交付,连接随后永久停顿”的测试,固定 `Stream.timeoutOrElse` 是相邻帧间隔上界的契约。 + +**Spec:** `.scratch/batch-b/abort-path-contracts.md` 的 Requirement 3 +**Evidence:** `.scratch/batch-b/evidence.md#transport-mid-stream-stall` +**Branch:** `test/midstream-timeout` +**Blocked by:** 02(共同修改 `packages/llm/test/transport-timeout.test.ts`) +**Status:** blocked + +- [ ] 用 fence 证明 timeout 前合法首帧已经交付给消费者 +- [ ] 用 TestClock 越过下一帧间隔,随后得到现有 Transport/Timeout +- [ ] 不引入真实墙钟 sleep,不改变 timeout 默认值与错误词汇 +- [ ] 完整 `transport-timeout.test.ts` 覆盖保持绿色 +- [ ] 在 `packages/llm` 运行目标测试与 `bun typecheck` diff --git a/.scratch/batch-b/issues/04-f3-subscription-readiness.md b/.scratch/batch-b/issues/04-f3-subscription-readiness.md new file mode 100644 index 0000000000..4ac2da220e --- /dev/null +++ b/.scratch/batch-b/issues/04-f3-subscription-readiness.md @@ -0,0 +1,14 @@ +# 04 — F3:用确定性 readiness 替代订阅 settle sleep + +**What to build:** 清除 `packages/opencode/test/goal/e2e-loop.test.ts` 的 `SUBSCRIPTION_SETTLE_MS = 200` 与 8 个固定 settle sleeps,用可观察 readiness/fence 或最小调度让步同步 GoalLoop 订阅就绪。 + +**Evidence:** `.scratch/batch-b/evidence.md#f3--固定订阅-settle-sleep` +**Branch:** `test/goal-readiness` +**Blocked by:** 03(批次串行;代码写集独立) +**Status:** blocked + +- [ ] 先证明每个 sleep 等待的具体事件/状态,不用另一个超时数值替换 200ms +- [ ] 8 个固定 settle sleeps 全部删除或由同一确定性同步机制取代 +- [ ] 默认不改生产行为;确需生产 readiness 信号时先在票内写明边界 +- [ ] 目标测试连续运行至少 5 次稳定绿色 +- [ ] 在 `packages/opencode` 运行目标测试与 `bun typecheck` diff --git a/.scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md b/.scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md new file mode 100644 index 0000000000..fed1d221b7 --- /dev/null +++ b/.scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md @@ -0,0 +1,14 @@ +# 05 — F4:移除 DagStore fixture 的双重类型断言 + +**What to build:** 清除 `packages/opencode/test/dag/dag-timeout-escalation-fixes.test.ts` 中两处 `as unknown as DagStore.Interface`,改用类型安全的 `Layer.mock` 或测试 fixture factory。 + +**Evidence:** `.scratch/batch-b/evidence.md#f4--dagstore-双重断言` +**Branch:** `test/dag-store-fixtures` +**Blocked by:** 04(批次串行;代码写集独立) +**Status:** blocked + +- [ ] 两处双重断言都消失,不能只修原评审记录的第一处 +- [ ] fixture 缺少/签名漂移的方法能在 typecheck 时暴露 +- [ ] 不复制 DagStore 生产逻辑到测试 +- [ ] timeout escalation 目标测试行为与断言不削弱 +- [ ] 在 `packages/opencode` 运行目标测试与 `bun typecheck` diff --git a/.scratch/batch-b/issues/06-o1-lkg-spec.md b/.scratch/batch-b/issues/06-o1-lkg-spec.md new file mode 100644 index 0000000000..b2410cb777 --- /dev/null +++ b/.scratch/batch-b/issues/06-o1-lkg-spec.md @@ -0,0 +1,14 @@ +# 06 — O1:remote config LKG 小规格 + +**What to build:** 只为 remote config 的持久化 last-known-good 增量产出一份小规格;不修改生产代码。OpenSpec 原件在 local-only `/openspec/` 生成并校验,同时把可执行镜像写入 `.scratch/batch-b/config-lkg-spec.md`,再更新 07 的具体文件、验收与分支边界。 + +**Evidence:** `.scratch/batch-b/evidence.md#o1--remote-config-last-known-good` +**Branch:** `docs/config-lkg-spec` +**Blocked by:** 05(批次串行) +**Status:** blocked + +- [ ] 定义缓存内容与写入时机:只缓存已验证结构,明确环境替换前后边界 +- [ ] 定义稳定 cache key、原子写、文件权限;key/内容不得泄露 header/token +- [ ] 仅 transport/body 失败允许回退;auth/HTML login/schema decode 不得被 LKG 掩盖 +- [ ] 定义损坏缓存、空缓存与 TTL/不过期策略 +- [ ] `openspec validate --changes` 通过,已追踪镜像与原件一致,07 获得可执行验收标准 diff --git a/.scratch/batch-b/issues/07-o1-lkg-implement.md b/.scratch/batch-b/issues/07-o1-lkg-implement.md new file mode 100644 index 0000000000..d8c81a913d --- /dev/null +++ b/.scratch/batch-b/issues/07-o1-lkg-implement.md @@ -0,0 +1,14 @@ +# 07 — O1:实现 remote config last-known-good 缓存 + +**What to build:** 按 06 产出的已校验 OpenSpec 实现 LKG,并扩展现有 `packages/opencode/test/config/wellknown-offline.test.ts`;不得重新实现已存在的 warn + skip 离线降级。 + +**Evidence:** `.scratch/batch-b/evidence.md#o1--remote-config-last-known-good` +**Branch:** `feat/config-lkg` +**Blocked by:** 06 规格通过校验并补齐本票验收 +**Status:** blocked + +- [ ] 本票开工前把 06 的 OpenSpec requirement/scenarios 链接写入此处 +- [ ] 在线成功后产生可复用 LKG,随后 transport/body 失败按规格回退 +- [ ] auth/HTML login/decode 失败仍保持硬失败 +- [ ] 损坏缓存不崩溃、不覆盖错误类别,且日志不含凭据 +- [ ] 在 `packages/opencode` 运行目标测试与 `bun typecheck` diff --git a/.scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md b/.scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md new file mode 100644 index 0000000000..8508c80cda --- /dev/null +++ b/.scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md @@ -0,0 +1,15 @@ +# 08 — S7:诊断 recovery INVENTED 推断 + +**What to build:** 仅诊断“已语义完成的 durable transcript 被 recovery 判为 active/ownershipLost 并写入 `exec_failed`”是否可复现。当前票不预设存在缺陷,也不授权先改生产代码。 + +**Method:** `/diagnosing-bugs` +**Evidence:** `.scratch/batch-b/evidence.md#s7--recovery-invented-推断` +**Branch:** `test/recovery-diagnosis` +**Blocked by:** 07(批次串行) +**Status:** blocked + +- [ ] 第一项产出是一条确定性、快速、可由 agent 重复运行且能红灯的命令;在此之前不写理论/修复 +- [ ] 症状必须包含“durable transcript 语义完成”与“reconcile 实际写 failed”,不能只单测 helper 返回 active +- [ ] 红灯成立后才列 3–5 个可证伪假设、最小化复现并另开独立修复票 +- [ ] 无法建立反馈回路时记录尝试和阻塞原因,以 no-fix 关闭 +- [ ] 不把既有 ownershipLost → workflow pause 缓解误报为未覆盖 diff --git a/.scratch/batch-b/issues/09-promote-dev-main-release.md b/.scratch/batch-b/issues/09-promote-dev-main-release.md new file mode 100644 index 0000000000..a7bce5d9fa --- /dev/null +++ b/.scratch/batch-b/issues/09-promote-dev-main-release.md @@ -0,0 +1,12 @@ +# 09 — 收束:批 B+C 后 dev → main → release-fork + +**What to build:** 批 B 所有票关闭、批 C P8 完成观测处置后,确认最新 `dev` 全量 CI 绿色,发 dev→main 晋级 PR;四项门禁全绿后合并并手动运行 release-fork。 + +**Blocked by:** 01–08 全部关闭;`.scratch/batch-c/issues/01-p8-spawn-ready-observation.md` 已处置 +**Status:** blocked + +- [ ] 最新 dev push 的 Typecheck、Unit Tests (linux)、E2E Tests (linux/windows) 全绿 +- [ ] 每票 PR/merge commit/验证命令已回填,S7 若确认缺陷则其独立修复票也关闭 +- [ ] dev→main PR 描述列出批 B 交付与批 C no-code/benchmark 裁决 +- [ ] 四项 main PR 门禁全绿后合并 +- [ ] release-fork 从 main 成功产出正式版,再执行用户确认过的分支/worktree 清理 diff --git a/.scratch/batch-c/issues/01-p8-spawn-ready-observation.md b/.scratch/batch-c/issues/01-p8-spawn-ready-observation.md new file mode 100644 index 0000000000..4f5313f378 --- /dev/null +++ b/.scratch/batch-c/issues/01-p8-spawn-ready-observation.md @@ -0,0 +1,12 @@ +# 01 — P8:spawnReady 复杂度观测处置 + +**What to build:** 记录 `spawnReady O(ready × nodes)` 是否有实际性能证据。没有 trace/benchmark/用户痛点时,以 no-code 关闭;不得仅凭静态复杂度实施缓存或索引改造。 + +**Evidence:** `.scratch/batch-b/evidence.md#p8--spawnready-复杂度` +**Blocked by:** None +**Status:** deferred-nonblocking + +- [ ] 搜集已有生产 trace、benchmark 或明确用户场景,不为本票新造大规模优化工程 +- [ ] 无量化证据:记录“当前不做”与重开阈值,状态改 closed-no-code +- [ ] 有量化证据:另开 `/improve-codebase-architecture` 设计票,写明基线与目标 +- [ ] 本观测票本身不改 `spawnReady` From 668b1e2dd5686ecc47963a055180d29031ab6221 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 20:29:47 +0800 Subject: [PATCH 06/17] test(opencode): verify fork rollback --- .../batch-b/issues/01-u1-fork-rollback.md | 20 +++-- .../opencode/test/session/fork-batch.test.ts | 82 +++++++++++++++++-- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/.scratch/batch-b/issues/01-u1-fork-rollback.md b/.scratch/batch-b/issues/01-u1-fork-rollback.md index fa0ae38611..6c5c8af49d 100644 --- a/.scratch/batch-b/issues/01-u1-fork-rollback.md +++ b/.scratch/batch-b/issues/01-u1-fork-rollback.md @@ -6,10 +6,18 @@ **Evidence:** `.scratch/batch-b/evidence.md#u-1--session-fork-嵌套事务回滚` **Branch:** `test/fork-rollback` **Blocked by:** None -**Status:** ready-for-agent +**Status:** done -- [ ] 复用 `packages/opencode/test/session/fork-batch.test.ts` 的真实 SQLite fixture;不写 adapter-only 替代测试 -- [ ] 至少一个 message/part 发布完成后再确定性失败,旧实现若违约时测试能红 -- [ ] 目标 session 无复制出的 durable events 与 projections,源 session 不变;Session Created 可保留 -- [ ] 若红灯暴露生产缺陷,只做本契约所需的最小修复 -- [ ] 在 `packages/opencode` 运行目标测试与 `bun typecheck`,结果附入票据 +- [x] 复用 `packages/opencode/test/session/fork-batch.test.ts` 的真实 SQLite fixture;不写 adapter-only 替代测试 +- [x] 至少一个 message/part 发布完成后再确定性失败,旧实现若违约时测试能红 +- [x] 目标 session 无复制出的 durable events 与 projections,源 session 不变;Session Created 可保留 +- [x] 若红灯暴露生产缺陷,只做本契约所需的最小修复 +- [x] 在 `packages/opencode` 运行目标测试与 `bun typecheck`,结果附入票据 + +## 验证证据 + +- 基线:`dev@8f8465753b6517b3deeb6ad37002263d3da287fe`;分支:`test/fork-rollback`。 +- 失败注入:真实 SQLite trigger 在第二条复制 part 的 durable event insert 上执行 `RAISE(ABORT)`;此前 3 个嵌套 publication 已释放 savepoint。 +- mutation 红灯:临时移除外层复制事务后,目标 projection 残留 2 条 message(第一条含已复制 part),新增用例 0 pass / 1 fail;mutation 未保留。 +- `cd packages/opencode && bun test test/session/fork-batch.test.ts`:3 pass,0 fail,52 expect。 +- `cd packages/opencode && bun typecheck`:`tsgo --noEmit`,exit 0;现有生产实现满足契约,无生产代码修改。 diff --git a/packages/opencode/test/session/fork-batch.test.ts b/packages/opencode/test/session/fork-batch.test.ts index 51633bd9dd..ab7225d4a9 100644 --- a/packages/opencode/test/session/fork-batch.test.ts +++ b/packages/opencode/test/session/fork-batch.test.ts @@ -2,15 +2,17 @@ import { describe, expect } from "bun:test" import { Database as BunDatabase, type SQLQueryBindings } from "bun:sqlite" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" +import { EventTable } from "@opencode-ai/core/event/sql" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionV1 } from "@opencode-ai/core/v1/session" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect" +import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect" import * as Client from "effect/unstable/sql/SqlClient" import type { Connection } from "effect/unstable/sql/SqlConnection" import { SqlError, classifySqliteError } from "effect/unstable/sql/SqlError" import * as Statement from "effect/unstable/sql/Statement" import * as Reactivity from "effect/unstable/reactivity/Reactivity" +import { eq, sql } from "drizzle-orm" import { Session as SessionNs } from "@/session/session" import { MessageID, PartID } from "../../src/session/schema" import { testInstanceStoreLayer } from "../fixture/fixture" @@ -24,9 +26,11 @@ interface SqlCounter { begins: number commits: number savepoints: number + releases: number + savepointRollbacks: number } -const counter: SqlCounter = { begins: 0, commits: 0, savepoints: 0 } +const counter: SqlCounter = { begins: 0, commits: 0, savepoints: 0, releases: 0, savepointRollbacks: 0 } // The Database layer's sqlite client is a closed graph (its native provider // cannot be overridden from outside), so this test builds its own SqlClient @@ -51,6 +55,8 @@ const countingClientLayer = Layer.effect( if (/^\s*begin\b/i.test(sql)) counter.begins++ else if (/^\s*commit\b/i.test(sql)) counter.commits++ else if (/^\s*savepoint\b/i.test(sql)) counter.savepoints++ + else if (/^\s*release\b/i.test(sql)) counter.releases++ + else if (/^\s*rollback to\b/i.test(sql)) counter.savepointRollbacks++ return target.query(sql) } } @@ -65,7 +71,9 @@ const countingClientLayer = Layer.effect( // @ts-ignore bun-types missing safeIntegers method statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers)) try { - return Effect.succeed((statement.all(...(params as SQLQueryBindings[])) ?? []) as Array>) + return Effect.succeed( + (statement.all(...(params as SQLQueryBindings[])) ?? []) as Array>, + ) } catch (cause) { return Effect.fail( new SqlError({ @@ -125,15 +133,14 @@ const countingClientLayer = Layer.effect( }), ) -const dbLayer = Database.layer.pipe( - Layer.provide(countingClientLayer.pipe(Layer.provide(Reactivity.layer))), -) +const dbLayer = Database.layer.pipe(Layer.provide(countingClientLayer.pipe(Layer.provide(Reactivity.layer)))) const eventV2Layer = EventV2.layer.pipe(Layer.provide(dbLayer)) const eventV2BridgeLayer = EventV2Bridge.layer.pipe(Layer.provide(eventV2Layer)) const projectorLayer = SessionProjector.layer.pipe(Layer.provide(eventV2Layer), Layer.provide(dbLayer)) const it = testEffect( Layer.mergeAll( + dbLayer, SessionNs.layer.pipe( Layer.provide(Storage.defaultLayer), Layer.provide(dbLayer), @@ -250,6 +257,8 @@ describe("Session.fork", () => { counter.begins = 0 counter.commits = 0 counter.savepoints = 0 + counter.releases = 0 + counter.savepointRollbacks = 0 const fork = yield* Effect.acquireRelease(session.fork({ sessionID: original.id }), (info) => session.remove(info.id).pipe(Effect.ignore), @@ -268,4 +277,65 @@ describe("Session.fork", () => { for (const msg of target) expect(msg.parts.length).toBe(2) }), ) + + it.instance("fork rolls back copied durable events and projections when a nested publication fails", () => + Effect.gen(function* () { + const database = yield* Database.Service + const session = yield* SessionNs.Service + const original = yield* Effect.acquireRelease(session.create({ title: "fork-source" }), (info) => + session.remove(info.id).pipe(Effect.ignore), + ) + + const firstMessageID = MessageID.ascending() + const failingMessageID = MessageID.ascending() + yield* session.updateMessage(userInfo(original.id, firstMessageID)) + yield* session.updatePart(textPart(original.id, firstMessageID, "copied before failure")) + yield* session.updateMessage(userInfo(original.id, failingMessageID)) + yield* session.updatePart(textPart(original.id, failingMessageID, "force fork copy failure")) + const sourceBefore = yield* session.messages({ sessionID: original.id }) + + yield* database.db + .run( + sql` + CREATE TRIGGER reject_fork_copy + BEFORE INSERT ON event + WHEN NEW.type = 'message.part.updated.1' + AND json_extract(NEW.data, '$.part.text') = 'force fork copy failure' + BEGIN + SELECT RAISE(ABORT, 'forced fork copy failure'); + END + `, + ) + .pipe(Effect.orDie) + + counter.begins = 0 + counter.commits = 0 + counter.savepoints = 0 + counter.releases = 0 + counter.savepointRollbacks = 0 + + const forkExit = yield* Effect.exit(session.fork({ sessionID: original.id })) + + expect(Exit.isFailure(forkExit)).toBe(true) + + const forkedSessions = (yield* session.list()).filter((info) => info.id !== original.id) + expect(forkedSessions).toHaveLength(1) + const forked = forkedSessions[0] + if (!forked) return + yield* Effect.addFinalizer(() => session.remove(forked.id).pipe(Effect.ignore)) + + expect(yield* session.messages({ sessionID: forked.id })).toEqual([]) + expect(yield* session.messages({ sessionID: original.id })).toEqual(sourceBefore) + + const durableEvents = yield* database.db + .select({ type: EventTable.type }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, forked.id)) + expect(durableEvents).toEqual([{ type: "session.created.1" }]) + + expect(counter.savepoints).toBe(4) + expect(counter.releases).toBe(4) + expect(counter.savepointRollbacks).toBe(1) + }), + ) }) From 8ec1ef19297593561f16890f4a8ec217d1617dfc Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 21:14:55 +0800 Subject: [PATCH 07/17] test(llm): verify timeout cancels response stream --- packages/llm/test/transport-timeout.test.ts | 66 ++++++++++++++++++++- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/llm/test/transport-timeout.test.ts b/packages/llm/test/transport-timeout.test.ts index bb1e19f2e1..b62d05dd99 100644 --- a/packages/llm/test/transport-timeout.test.ts +++ b/packages/llm/test/transport-timeout.test.ts @@ -1,20 +1,28 @@ import { describe, expect, test } from "bun:test" import { Cause, Duration, Effect, Exit, Fiber, Option, Stream } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import * as TestClock from "effect/testing/TestClock" import { LLM, LLMError, LLMEvent } from "../src" import * as OpenAIChat from "../src/protocols/openai-chat" import { HttpOptions, Model, mergeHttpOptions } from "../src/schema" import { LLMClient } from "../src/route" import { testEffect } from "./lib/effect" -import { dynamicResponse, fixedResponse } from "./lib/http" +import { dynamicResponse, fixedResponse, runtimeLayer } from "./lib/http" import { deltaChunk } from "./lib/openai-chunks" import { sseEvents } from "./lib/sse" const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) -const request = (timeout?: number) => +const request = (timeout?: number, baseURL?: string) => LLM.request({ - model, + model: + baseURL === undefined + ? model + : Model.make({ + id: "fake-model", + provider: "fake", + route: OpenAIChat.route.with({ endpoint: { baseURL } }), + }), prompt: "Say hello.", http: timeout === undefined ? undefined : { timeout: Duration.millis(timeout) }, }) @@ -37,7 +45,59 @@ const expectTimeoutExit = (exit: Exit.Exit) => { expect(error.reason).toMatchObject({ _tag: "Transport", kind: "Timeout" }) } +const waitForFence = (name: string, promise: Promise) => + Effect.promise(() => promise).pipe( + Effect.timeout(Duration.seconds(2)), + Effect.mapError(() => new Error(`${name} was not observed within 2000ms`)), + ) + +const timeoutProvider = () => { + const requestReceived = Promise.withResolvers() + const responseCanceled = Promise.withResolvers() + const encoder = new TextEncoder() + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + requestReceived.resolve() + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(": connected\n\n")) + }, + cancel() { + responseCanceled.resolve() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + }, + }) + return { server, requestReceived: requestReceived.promise, responseCanceled: responseCanceled.promise } +} + +const networkRuntime = runtimeLayer(FetchHttpClient.layer) + describe("http transport timeout", () => { + testEffect(networkRuntime).live( + "cancels the provider response stream when a real HTTP request times out", + () => + Effect.gen(function* () { + const provider = yield* Effect.acquireRelease( + Effect.sync(timeoutProvider), + (fixture) => Effect.promise(() => fixture.server.stop(true)), + ) + const fiber = yield* LLMClient.stream(request(200, provider.server.url.origin)).pipe( + Stream.runCollect, + Effect.forkScoped, + ) + + yield* waitForFence("provider request", provider.requestReceived) + expectTimeoutExit(yield* Fiber.join(fiber).pipe(Effect.exit)) + yield* waitForFence("provider response cancellation", provider.responseCanceled) + }), + ) + testEffect(hangingHeaders).effect( "ends the stream with a Timeout error when the provider never sends response headers", () => From 38b497f79bd80b4359dab355fe5874dd656d4221 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 21:18:55 +0800 Subject: [PATCH 08/17] docs(batch-b): close transport abort ticket --- .../issues/02-u2-transport-timeout-abort.md | 21 +++++++++++++------ .../issues/03-transport-midstream-stall.md | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.scratch/batch-b/issues/02-u2-transport-timeout-abort.md b/.scratch/batch-b/issues/02-u2-transport-timeout-abort.md index 5983901932..c140dc049e 100644 --- a/.scratch/batch-b/issues/02-u2-transport-timeout-abort.md +++ b/.scratch/batch-b/issues/02-u2-transport-timeout-abort.md @@ -6,10 +6,19 @@ **Evidence:** `.scratch/batch-b/evidence.md#u-2--timeout-传播到底层-http-取消` **Branch:** `test/transport-abort` **Blocked by:** 01(同一 OpenSpec 串行落地) -**Status:** blocked +**Status:** closed -- [ ] fixture 提供“请求已接收”与“response 已取消”的有界 fence,禁止用固定 sleep 猜时序 -- [ ] timeout 后断言现有 `LLMError` Transport/Timeout 形状 -- [ ] 服务端确定性观察到 response stream cancellation;request abort 只作补充信号 -- [ ] 不用内存 HttpClient 或显式 Fiber interrupt 重复现有覆盖 -- [ ] 在 `packages/llm` 连续运行目标测试至少 3 次并运行 `bun typecheck` +- [x] fixture 提供“请求已接收”与“response 已取消”的有界 fence,禁止用固定 sleep 猜时序 +- [x] timeout 后断言现有 `LLMError` Transport/Timeout 形状 +- [x] 服务端确定性观察到 response stream cancellation;request abort 只作补充信号 +- [x] 不用内存 HttpClient 或显式 Fiber interrupt 重复现有覆盖 +- [x] 在 `packages/llm` 连续运行目标测试至少 3 次并运行 `bun typecheck` + +## 验证证据 + +- 基线:`dev@55dd345491de4542dbf6fa7a4ba126a2c23104c4`;分支:`test/transport-abort`。 +- 实现提交:`8ec1ef192`;PR:[LeXwDeX/OpenCode-GraphAgent#193](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/193) → `dev`。 +- 真实 transport:公开 `LLMClient.stream(...)` 经 `FetchHttpClient.layer` 请求 loopback `Bun.serve`;2 秒有界 fence 分别证明请求已接收与服务端 response `cancel()` 已触发。 +- mutation 红灯:临时移除 response stream 的 `Stream.timeoutOrElse` 后,新增场景在 1 秒测试边界超时,0 pass / 1 fail;mutation 已恢复,生产文件无 diff。 +- `cd packages/llm && bun test test/transport-timeout.test.ts --timeout 30000`:连续 3 次均为 7 pass、0 fail、14 expect;`bun typecheck`:`tsgo --noEmit`,exit 0。 +- 现有生产实现满足 OpenSpec Requirement 2,无生产代码修改;Requirement 3 留给 03 票。 diff --git a/.scratch/batch-b/issues/03-transport-midstream-stall.md b/.scratch/batch-b/issues/03-transport-midstream-stall.md index ebc3f10eee..5303984f16 100644 --- a/.scratch/batch-b/issues/03-transport-midstream-stall.md +++ b/.scratch/batch-b/issues/03-transport-midstream-stall.md @@ -5,8 +5,8 @@ **Spec:** `.scratch/batch-b/abort-path-contracts.md` 的 Requirement 3 **Evidence:** `.scratch/batch-b/evidence.md#transport-mid-stream-stall` **Branch:** `test/midstream-timeout` -**Blocked by:** 02(共同修改 `packages/llm/test/transport-timeout.test.ts`) -**Status:** blocked +**Blocked by:** None(02 已完成) +**Status:** ready-for-agent - [ ] 用 fence 证明 timeout 前合法首帧已经交付给消费者 - [ ] 用 TestClock 越过下一帧间隔,随后得到现有 Transport/Timeout From 1d5d08f76f7ed028f17a2381514582f520cb2fdf Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 21:57:23 +0800 Subject: [PATCH 09/17] test(llm): cover midstream timeout gaps --- packages/llm/test/transport-timeout.test.ts | 46 ++++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/llm/test/transport-timeout.test.ts b/packages/llm/test/transport-timeout.test.ts index b62d05dd99..94de6e2e20 100644 --- a/packages/llm/test/transport-timeout.test.ts +++ b/packages/llm/test/transport-timeout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Cause, Duration, Effect, Exit, Fiber, Option, Stream } from "effect" +import { Cause, Deferred, Duration, Effect, Exit, Fiber, Option, Stream } from "effect" import { FetchHttpClient } from "effect/unstable/http" import * as TestClock from "effect/testing/TestClock" import { LLM, LLMError, LLMEvent } from "../src" @@ -9,7 +9,7 @@ import { LLMClient } from "../src/route" import { testEffect } from "./lib/effect" import { dynamicResponse, fixedResponse, runtimeLayer } from "./lib/http" import { deltaChunk } from "./lib/openai-chunks" -import { sseEvents } from "./lib/sse" +import { sseEvents, sseRaw } from "./lib/sse" const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) @@ -35,6 +35,23 @@ const hangingBody = dynamicResponse((input) => ), ) +const stalledAfterFirstFrame = dynamicResponse((input) => + Effect.sync(() => + input.respond( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + sseRaw(`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}`), + ), + ) + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ), + ), +) + const expectTimeoutExit = (exit: Exit.Exit) => { if (Exit.isSuccess(exit)) { throw new Error(`expected a Timeout failure, stream completed with ${exit.value.length} events`) @@ -118,6 +135,31 @@ describe("http transport timeout", () => { }), ) + testEffect(stalledAfterFirstFrame).effect( + "delivers the first frame before timing out the next inter-frame gap", + () => + Effect.gen(function* () { + const firstFrameDelivered = yield* Deferred.make() + const fiber = yield* LLMClient.stream(request(1000)).pipe( + Stream.tap((event) => + LLMEvent.is.textDelta(event) && event.text === "Hello" + ? Deferred.succeed(firstFrameDelivered, undefined) + : Effect.void, + ), + Stream.runCollect, + Effect.forkChild, + ) + + yield* Deferred.await(firstFrameDelivered) + yield* TestClock.adjust(2000) + yield* Effect.yieldNow + + const exit = fiber.pollUnsafe() + if (exit === undefined) throw new Error("expected the stalled stream to time out") + expectTimeoutExit(exit) + }), + ) + testEffect(fixedResponse(sseEvents(deltaChunk({ role: "assistant", content: "Hello" })))).effect( "completes normally when the stream finishes within the timeout", () => From eadf7d0d6ada9096a7dd164c17b5d6758adc73a9 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 21:59:27 +0800 Subject: [PATCH 10/17] docs: close midstream timeout ticket --- .../issues/03-transport-midstream-stall.md | 21 +++++++++++++------ .../issues/04-f3-subscription-readiness.md | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.scratch/batch-b/issues/03-transport-midstream-stall.md b/.scratch/batch-b/issues/03-transport-midstream-stall.md index 5303984f16..0e57d6a6c7 100644 --- a/.scratch/batch-b/issues/03-transport-midstream-stall.md +++ b/.scratch/batch-b/issues/03-transport-midstream-stall.md @@ -6,10 +6,19 @@ **Evidence:** `.scratch/batch-b/evidence.md#transport-mid-stream-stall` **Branch:** `test/midstream-timeout` **Blocked by:** None(02 已完成) -**Status:** ready-for-agent +**Status:** closed -- [ ] 用 fence 证明 timeout 前合法首帧已经交付给消费者 -- [ ] 用 TestClock 越过下一帧间隔,随后得到现有 Transport/Timeout -- [ ] 不引入真实墙钟 sleep,不改变 timeout 默认值与错误词汇 -- [ ] 完整 `transport-timeout.test.ts` 覆盖保持绿色 -- [ ] 在 `packages/llm` 运行目标测试与 `bun typecheck` +- [x] 用 fence 证明 timeout 前合法首帧已经交付给消费者 +- [x] 用 TestClock 越过下一帧间隔,随后得到现有 Transport/Timeout +- [x] 不引入真实墙钟 sleep,不改变 timeout 默认值与错误词汇 +- [x] 完整 `transport-timeout.test.ts` 覆盖保持绿色 +- [x] 在 `packages/llm` 运行目标测试与 `bun typecheck` + +## 验证证据 + +- 基线:`dev@1a8635400f3f4b7c4985b06f0776e13d6f2e5e05`;分支:`test/midstream-timeout`。 +- 实现提交:`1d5d08f76`;PR:[LeXwDeX/OpenCode-GraphAgent#194](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/194) → `dev`。 +- 公开 seam:`LLMClient.stream(...)` 消费合法 SSE text delta;`Deferred` fence 只在 `text-delta` 的 `text === "Hello"` 已交付时解除,随后 `TestClock` 将 1000ms 帧间 timeout 推进到 2000ms。 +- mutation 红灯:临时绕过 response stream 的 `Stream.timeoutOrElse` 后,首帧 fence 仍解除,但新增场景因 stream 未结束而 0 pass / 1 fail;mutation 已恢复,生产文件无 diff。 +- `cd packages/llm && bun test test/transport-timeout.test.ts`:连续 3 次均为 8 pass、0 fail、16 expect;`bun typecheck`:`tsgo --noEmit`,exit 0。 +- 现有生产实现满足 OpenSpec Requirement 3;无生产代码、timeout 默认值、错误词汇或 provider protocol 修改。 diff --git a/.scratch/batch-b/issues/04-f3-subscription-readiness.md b/.scratch/batch-b/issues/04-f3-subscription-readiness.md index 4ac2da220e..88375cef16 100644 --- a/.scratch/batch-b/issues/04-f3-subscription-readiness.md +++ b/.scratch/batch-b/issues/04-f3-subscription-readiness.md @@ -4,8 +4,8 @@ **Evidence:** `.scratch/batch-b/evidence.md#f3--固定订阅-settle-sleep` **Branch:** `test/goal-readiness` -**Blocked by:** 03(批次串行;代码写集独立) -**Status:** blocked +**Blocked by:** None(03 已完成;代码写集独立) +**Status:** ready-for-agent - [ ] 先证明每个 sleep 等待的具体事件/状态,不用另一个超时数值替换 200ms - [ ] 8 个固定 settle sleeps 全部删除或由同一确定性同步机制取代 From f77106bc03b5c4ec6816dd82d9bba8485974f790 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 22:55:39 +0800 Subject: [PATCH 11/17] test(goal): replace subscription settle sleeps --- packages/opencode/test/goal/e2e-loop.test.ts | 29 +++++++++----------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/opencode/test/goal/e2e-loop.test.ts b/packages/opencode/test/goal/e2e-loop.test.ts index 8a539c7ae5..679c2e012b 100644 --- a/packages/opencode/test/goal/e2e-loop.test.ts +++ b/packages/opencode/test/goal/e2e-loop.test.ts @@ -36,11 +36,9 @@ const captureEvents = (events: EventV2Bridge.Service["Service"]) => // Scripted assistant response — afterIdle extracts its text as the judge input. const assistantText = "I have made progress on the feature." // GoalLoop.init forks the idle-event subscription (loop.ts Effect.forkScoped). -// No observable latch exists for stream-subscription registration, so the only -// sync point before publishing the first idle event is this bounded fork-window -// wait. Kept intentionally: replacing it needs a subscribe-ready signal in -// EventV2Bridge (production change, out of scope for test hygiene). -const SUBSCRIPTION_SETTLE_MS = 200 +// Each scenario yields one scheduler turn before its first idle publish so that +// fiber can acquire the PubSub subscription. No business event exists yet, so +// outcome completion remains separately observed through public state/events. const mkAssistant = () => ({ info: { role: "assistant", time: { created: Date.now() } }, @@ -141,9 +139,9 @@ describe("GoalLoop end-to-end — continue → done lifecycle (P2b)", () => { yield* loop.init() const sid = SessionID.descending() yield* goal.set(sid, "ship the feature", 10) - // Let the idle subscription finish wiring (InstanceState is built on the - // first init) before publishing, so the first idle event is not missed. - yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + // Give the forkScoped idle subscriber one scheduler turn to acquire its + // PubSub subscription. Business completion is observed below. + yield* Effect.yieldNow // ── Turn 1: idle → judge(continue) → continuation prompt ── yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) @@ -231,8 +229,7 @@ describe("GoalLoop — continuation dispatch failure → recoverable pause (D1)" yield* loop.init() const sid = SessionID.descending() yield* goal.set(sid, "ship the feature", 10) - // Let the idle subscription wire (InstanceState builds on first init). - yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + yield* Effect.yieldNow // idle → judge(continue) → continuation prompt fails → catchCause → pause yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) @@ -267,7 +264,7 @@ describe("GoalLoop — continuation dispatch failure → recoverable pause (D1)" yield* loop.init() const sid = SessionID.descending() yield* goal.set(sid, "ship the feature", 10) - yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + yield* Effect.yieldNow yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) yield* pollWithTimeout( Effect.gen(function* () { @@ -332,7 +329,7 @@ describe("GoalLoop — no assistant in window → visible pause (branch 1)", () yield* loop.init() const sid = SessionID.descending() yield* goal.set(sid, "ship the feature", 10) - yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + yield* Effect.yieldNow yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) yield* pollWithTimeout( @@ -400,7 +397,7 @@ describe("GoalLoop — empty assistant text → synthetic continue, no stall (br yield* loop.init() const sid = SessionID.descending() yield* goal.set(sid, "ship the feature", 10) - yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + yield* Effect.yieldNow yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) // The synthetic continue dispatches a continuation prompt (non-noReply). @@ -477,7 +474,7 @@ describe("GoalLoop — status changed during judge → visible pause (branch 3)" // busy. The raw idle-event publish below drives afterIdle WITHOUT // touching the status map, so the busy entry persists. yield* status.set(sid, { type: "busy" }) - yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + yield* Effect.yieldNow yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) yield* pollWithTimeout( @@ -558,7 +555,7 @@ describe("GoalLoop — continuation interrupted → no pause, goal stays active yield* loop.init() const sid = SessionID.descending() yield* goal.set(sid, "ship the feature", 10) - yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + yield* Effect.yieldNow // Turn 1: idle → judge(continue) → continuation fails with interrupt → // branch 4: log + return, NO pause. @@ -608,7 +605,7 @@ describe("GoalLoop — continuation interrupted → no pause, goal stays active yield* loop.init() const sid = SessionID.descending() yield* goal.set(sid, "ship the feature", 10) - yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + yield* Effect.yieldNow // idle → judge(continue) → continuation fails with an anonymous interrupt // → branch 4: log + return, NO pause (the F1 fix; old code paused here). From cadd3822b5455499868975d64f1a6949fc9da12e Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 22:58:04 +0800 Subject: [PATCH 12/17] docs(batch-b): close goal readiness ticket --- .../issues/04-f3-subscription-readiness.md | 20 +++++++++++++------ .../05-f4-type-safe-dag-store-fixtures.md | 4 ++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.scratch/batch-b/issues/04-f3-subscription-readiness.md b/.scratch/batch-b/issues/04-f3-subscription-readiness.md index 88375cef16..6233c2602d 100644 --- a/.scratch/batch-b/issues/04-f3-subscription-readiness.md +++ b/.scratch/batch-b/issues/04-f3-subscription-readiness.md @@ -5,10 +5,18 @@ **Evidence:** `.scratch/batch-b/evidence.md#f3--固定订阅-settle-sleep` **Branch:** `test/goal-readiness` **Blocked by:** None(03 已完成;代码写集独立) -**Status:** ready-for-agent +**Status:** closed -- [ ] 先证明每个 sleep 等待的具体事件/状态,不用另一个超时数值替换 200ms -- [ ] 8 个固定 settle sleeps 全部删除或由同一确定性同步机制取代 -- [ ] 默认不改生产行为;确需生产 readiness 信号时先在票内写明边界 -- [ ] 目标测试连续运行至少 5 次稳定绿色 -- [ ] 在 `packages/opencode` 运行目标测试与 `bun typecheck` +- [x] 先证明每个 sleep 等待的具体事件/状态,不用另一个超时数值替换 200ms +- [x] 8 个固定 settle sleeps 全部删除或由同一确定性同步机制取代 +- [x] 默认不改生产行为;确需生产 readiness 信号时先在票内写明边界 +- [x] 目标测试连续运行至少 5 次稳定绿色 +- [x] 在 `packages/opencode` 运行目标测试与 `bun typecheck` + +## 完成证据 + +- Commit: `f77106bc03b5c4ec6816dd82d9bba8485974f790`(`test(goal): replace subscription settle sleeps`) +- PR: [#195](https://github.com/LeXwDeX/OpenCode-GraphAgent/pull/195) → `dev` +- TDD: 首场景保留 200ms sleep 时基线绿色;删除同步点后首次 idle 被漏掉,`judge call 1` 未触发;加入一次 `Effect.yieldNow` 后恢复绿色,再机械推广到其余 7 处。 +- 验证:目标文件连续 5 次全绿(每次 8/8);`packages/opencode` 的 `bun typecheck` 通过;提交钩子 lint 0 error、全仓 typecheck 29/29。 +- 范围:旧常量与 8 个固定 sleep 全部消失;生产代码零 diff。 diff --git a/.scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md b/.scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md index fed1d221b7..34e652f38e 100644 --- a/.scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md +++ b/.scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md @@ -4,8 +4,8 @@ **Evidence:** `.scratch/batch-b/evidence.md#f4--dagstore-双重断言` **Branch:** `test/dag-store-fixtures` -**Blocked by:** 04(批次串行;代码写集独立) -**Status:** blocked +**Blocked by:** None(04 已完成;代码写集独立) +**Status:** ready-for-agent - [ ] 两处双重断言都消失,不能只修原评审记录的第一处 - [ ] fixture 缺少/签名漂移的方法能在 typecheck 时暴露 From 017693b11ec3c22566dd5b17558d83230089e080 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 8 Aug 2026 23:38:43 +0800 Subject: [PATCH 13/17] test(dag): use typed store fixtures --- .../05-f4-type-safe-dag-store-fixtures.md | 19 ++++-- .scratch/batch-b/issues/06-o1-lkg-spec.md | 2 +- .../dag/dag-timeout-escalation-fixes.test.ts | 65 +++++++++++-------- 3 files changed, 51 insertions(+), 35 deletions(-) diff --git a/.scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md b/.scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md index 34e652f38e..5a196308ce 100644 --- a/.scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md +++ b/.scratch/batch-b/issues/05-f4-type-safe-dag-store-fixtures.md @@ -5,10 +5,17 @@ **Evidence:** `.scratch/batch-b/evidence.md#f4--dagstore-双重断言` **Branch:** `test/dag-store-fixtures` **Blocked by:** None(04 已完成;代码写集独立) -**Status:** ready-for-agent +**Status:** closed -- [ ] 两处双重断言都消失,不能只修原评审记录的第一处 -- [ ] fixture 缺少/签名漂移的方法能在 typecheck 时暴露 -- [ ] 不复制 DagStore 生产逻辑到测试 -- [ ] timeout escalation 目标测试行为与断言不削弱 -- [ ] 在 `packages/opencode` 运行目标测试与 `bun typecheck` +- [x] 两处双重断言都消失,不能只修原评审记录的第一处 +- [x] fixture 缺少/签名漂移的方法能在 typecheck 时暴露 +- [x] 不复制 DagStore 生产逻辑到测试 +- [x] timeout escalation 目标测试行为与断言不削弱 +- [x] 在 `packages/opencode` 运行目标测试与 `bun typecheck` + +## 红绿验证(基线 `403461e831aa8cda65d449f4a873db1d1686b44f`) + +- **红灯:** 直接删除两处 `as unknown as DagStore.Interface` 后,在 `packages/opencode` 运行 `bun typecheck`,退出码 2。`TS2740` 分别出现在原第 322、370 行:仅含 `getNode` 的对象缺少 `getWorkflow`、`listWorkflows`、`listBySession`、`listByProject` 与另外 13 个 `DagStore.Interface` 成员。 +- **绿灯:** 改为 `Layer.mock(DagStore.Service)`,通过 `Layer.unwrap` 将类型安全的 store fixture 注入 `Layer.mock(Dag.Service)`;`bun typecheck` 退出码 0。 +- **重复验证:** `bun test test/dag/dag-timeout-escalation-fixes.test.ts` 连续运行 3 次,每次均为 12 pass、0 fail、38 次断言。 +- **范围验证:** 相对上述基线,生产文件零 diff;只修改目标测试与批次 B 的票 05/06。 diff --git a/.scratch/batch-b/issues/06-o1-lkg-spec.md b/.scratch/batch-b/issues/06-o1-lkg-spec.md index b2410cb777..026f6b514d 100644 --- a/.scratch/batch-b/issues/06-o1-lkg-spec.md +++ b/.scratch/batch-b/issues/06-o1-lkg-spec.md @@ -5,7 +5,7 @@ **Evidence:** `.scratch/batch-b/evidence.md#o1--remote-config-last-known-good` **Branch:** `docs/config-lkg-spec` **Blocked by:** 05(批次串行) -**Status:** blocked +**Status:** ready-for-agent - [ ] 定义缓存内容与写入时机:只缓存已验证结构,明确环境替换前后边界 - [ ] 定义稳定 cache key、原子写、文件权限;key/内容不得泄露 header/token diff --git a/packages/opencode/test/dag/dag-timeout-escalation-fixes.test.ts b/packages/opencode/test/dag/dag-timeout-escalation-fixes.test.ts index 3150a331ea..88afe34ed3 100644 --- a/packages/opencode/test/dag/dag-timeout-escalation-fixes.test.ts +++ b/packages/opencode/test/dag/dag-timeout-escalation-fixes.test.ts @@ -318,27 +318,35 @@ describe("Dag timeout escalation fixes (unit)", () => { it("retries transient store read failures instead of exiting supervision (R13)", async () => { let reads = 0 let escalations = 0 - const dagLayer = Layer.mock(Dag.Service, { - store: { - getNode: () => - Effect.sync(() => { - reads++ - // 3 transient failures (e.g. SQLite lock blips) — the watcher - // must survive them and keep supervising. - if (reads <= 3) throw new Error("database locked") - return makeNodeRow({ - id: "a", - workflowId: "dag-r13", - name: "a", - status: "running", - deadlineMs: 1, - timeoutExtensions: 0, - childSessionId: "ses_child_1", - }) - }), - } as unknown as DagStore.Interface, - nodeTimeoutEscalated: () => Effect.sync(() => { escalations++ }), + const storeLayer = Layer.mock(DagStore.Service)({ + getNode: () => + Effect.sync(() => { + reads++ + // 3 transient failures (e.g. SQLite lock blips) — the watcher + // must survive them and keep supervising. + if (reads <= 3) throw new Error("database locked") + return makeNodeRow({ + id: "a", + workflowId: "dag-r13", + name: "a", + status: "running", + deadlineMs: 1, + timeoutExtensions: 0, + childSessionId: "ses_child_1", + }) + }), }) + const dagLayer = Layer.unwrap( + Effect.map(DagStore.Service, (store) => + Layer.mock(Dag.Service)({ + store, + nodeTimeoutEscalated: () => + Effect.sync(() => { + escalations++ + }), + }), + ), + ).pipe(Layer.provide(storeLayer)) const promptLayer = Layer.mock(SessionPrompt.Service, { cancel: () => Effect.void, }) @@ -366,15 +374,16 @@ describe("Dag timeout escalation fixes (unit)", () => { it("continues supervision after store read retries fail (R13/F1-product)", async () => { let reads = 0 - const dagLayer = Layer.mock(Dag.Service, { - store: { - getNode: () => - Effect.sync(() => { - reads++ - throw new Error("database locked") - }), - } as unknown as DagStore.Interface, + const storeLayer = Layer.mock(DagStore.Service)({ + getNode: () => + Effect.sync(() => { + reads++ + throw new Error("database locked") + }), }) + const dagLayer = Layer.unwrap( + Effect.map(DagStore.Service, (store) => Layer.mock(Dag.Service)({ store })), + ).pipe(Layer.provide(storeLayer)) const promptLayer = Layer.mock(SessionPrompt.Service, { cancel: () => Effect.void, }) From 72630c3fe2764f07b7ebdac1ce2dda0066943d2b Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 9 Aug 2026 00:44:38 +0800 Subject: [PATCH 14/17] docs(config): specify remote config LKG --- .scratch/batch-b/config-lkg-spec.md | 269 ++++++++++++++++++ .scratch/batch-b/issues/06-o1-lkg-spec.md | 20 +- .../batch-b/issues/07-o1-lkg-implement.md | 28 +- 3 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 .scratch/batch-b/config-lkg-spec.md diff --git a/.scratch/batch-b/config-lkg-spec.md b/.scratch/batch-b/config-lkg-spec.md new file mode 100644 index 0000000000..ac2b7c7b05 --- /dev/null +++ b/.scratch/batch-b/config-lkg-spec.md @@ -0,0 +1,269 @@ +# remote-config-lkg — OpenSpec apply-ready 镜像 + +- **基线:** `dev@4675435d94d462d2f9317d6688ddab2f0105c746` +- **change:** `remote-config-lkg` +- **local-only 原件:** `openspec/changes/remote-config-lkg/` +- **状态:** `4/4 artifacts complete`,apply-ready +- **校验:** `openspec validate --changes` → `1 passed, 0 failed` +- **严格校验:** `openspec validate remote-config-lkg --type change --strict --no-interactive` → `valid` +- **实施入口:** 按末尾 `tasks.md` 由票 07 以 TDD 执行;本镜像不包含生产代码。 + +以下四段在提交前按字节与 local-only 原件逐段核对。 + + +## Why + +remote config 在 transport 或 body 读取失败时会告警并跳过来源;长期离线时,这会让最近一次已验证的在线配置不可用。需要一个持久化 last-known-good(LKG),同时严格避免缓存或日志泄露认证材料,并且不掩盖认证与 schema 错误。 + +## What Changes + +- 为每个规范化 remote URL 保存最近一次完整通过解析与 schema 验证的远端响应;缓存保留 Environment 替换前的响应内容,不保存请求 header、token 或替换后的秘密。 +- 仅在既有可降级错误类别上读取 LKG;401/403、HTML 登录/认证响应和 schema decode 错误继续硬失败。 +- 使用同目录临时文件、原子 rename 和最终 `0600` 文件模式更新 LKG;写入失败不影响在线读取,也不破坏旧 LKG。 +- 损坏或空 LKG 告警后按既有 warn + skip 行为继续;日志不包含缓存正文或凭据。 +- LKG 不因年龄自动过期;记录 `writtenAt`,年龄只用于安全诊断,下一次合法在线成功原子覆盖旧值。 +- 用目标测试锁定在线写入后离线回退、失败写入保留旧值、硬失败边界、损坏缓存、安全键与日志以及原子文件语义。 + +## Capabilities + +### New Capabilities + +- `remote-config-lkg`: 定义 remote config 已验证响应的持久化、回退边界、隐私约束、耐久写入和诊断行为。 + +### Modified Capabilities + +无。 + +## Impact + +- 主要影响 `packages/opencode/src/config/config.ts`、一个位于 `packages/opencode/src/config/` 的 LKG 持久化模块,以及 `packages/opencode/test/config/wellknown-offline.test.ts` 的集成场景。 +- 在 OpenCode 的 XDG cache 根下新增用户私有的 remote-config LKG 文件;不改变 HTTP API、SDK、配置 schema 或依赖。 +- 没有 breaking change;没有可配置 TTL,也不重写当前无可用来源时的 warn + skip 降级。 + + + +## Context + +`Config.layer` 目前按以下顺序加载一个 well-known 认证来源:请求 `/.well-known/opencode`,解析并 decode `ConfigV1.WellKnown`,对 `remote_config.url` 与请求 headers 做 Environment 替换,可选请求第二跳 JSON,把内嵌与第二跳配置合并,最后由 `loadConfig` 执行 Environment 替换、JSONC 解析和 `ConfigV1.Info` schema 验证。transport、非认证 HTTP 不可用和 body 读取失败当前会 warn + skip;HTML 登录响应与 decode 失败会中止该配置加载。 + +LKG 必须接在这条流程上,而不能缓存最终的 `Info`。最终 `Info` 已经包含 Environment 替换结果,可能固化 token 或文件引用中的秘密。缓存还必须区分“在线 body 不是 JSON”与“JSON 可解析但不符合 schema”:前者属于允许降级的 body 失败,后者属于必须暴露的配置错误。 + +## Goals / Non-Goals + +**Goals:** + +- 在长期离线或远端暂时不可用时,复用同一 remote URL 最近一次完整验证成功的原始响应 body。 +- 保持认证、HTML 登录和 schema 错误为硬失败,保证 LKG 不掩盖需要用户处理的问题。 +- 让缓存更新具备用户私有权限和单文件原子替换语义;任何写入失败都不改变在线成功结果或旧 LKG。 +- 保持没有可用 LKG 时现有 warn + skip 的结果与合并边界。 + +**Non-Goals:** + +- 不增加 TTL 配置、后台刷新、跨设备同步、缓存清理命令或多版本迁移框架。 +- 不缓存请求 header、认证 token、Environment 替换后的正文、最终合并后的 `Info` 或 HTTP API 数据。 +- 不改变 remote config 的优先级、插件解析、普通本地配置加载、HTTP API 或 SDK。 +- 不借本变更重写现有 warn + skip 流程;只在允许降级的失败点插入 LKG 读取。 + +## Decisions + +### 1. 每个 HTTP remote URL 保存一个原始响应 LKG + +well-known 第一跳与可选 remote-config 第二跳各自以其请求 URL 标识缓存记录。记录格式固定为版本化 JSON envelope: + +```json +{ + "version": 1, + "writtenAt": "2026-08-09T00:00:00.000Z", + "body": "{...the exact response text...}" +} +``` + +`body` 是 Environment 替换前的响应文本。envelope 不保存响应 headers/status,也不保存请求 URL、请求 headers、认证 token、Environment map 或 decode 后对象。读取 LKG 后,body 必须重新经过与在线 body 相同的 JSON 解析、请求级 schema decode、remote-config 对象检查、Environment 替换和最终 `ConfigV1.Info` 验证。 + +在线 body 先进入暂存结果,只有该 well-known 来源的完整下游流程通过解析与 schema 验证后才允许写入本次在线取得的记录。任一在线 auth、HTML、JSON shape、对象检查、Environment 替换或最终 `ConfigV1.Info` 失败时,不写本次来源暂存的任何 LKG。选择延迟提交而不是在 `fetchRemoteJson` decode 后立即写,是为了避免把“JSON 合法但最终配置无效”的响应提升为 LKG。 + +未选择缓存最终 `Info`,因为它已经过 Environment 替换;也未选择缓存 request/response headers,因为它们不是离线重放配置所需的数据,并可能携带凭据。 + +### 2. cache key 只有规范化 URL 的稳定摘要 + +URL 用 WHATWG `URL` 规范化:清除不会随 HTTP 请求发送的 fragment,依赖 URL 实现统一 scheme/host 大小写、默认端口与转义形式,并保留会改变资源身份的 path 与 query。缓存文件名是规范化 URL UTF-8 字节的 SHA-256 小写十六进制摘要加 `.json`;目录固定为 `Global.Path.cache/remote-config-lkg/`。 + +文件名和 envelope 都不拼接原始 URL、认证 header、token、Environment 变量值或配置正文。摘要是 key 中唯一由 URL 派生的值;headers、token、Environment map 与 body 不参与额外的 key 组成。remote-config 日志使用稳定摘要和 `well-known`/`remote-config` 角色标识,不记录请求 headers、token、Environment 值、缓存 body 或响应 body;错误原因先归类,不直接序列化可能回显请求的底层错误对象。 + +未选择可读 URL 文件名,因为 query/userinfo 可能携带凭据;未选择 header/token 分区,因为它会把认证材料引入持久身份并违反本票边界。 + +### 3. 明确在线失败分类,再决定是否读取 LKG + +`fetchRemoteJson` 将在线结果表达为成功、允许降级失败或硬失败,而不是用一个捕获所有错误的分支: + +- 允许降级并尝试 LKG:DNS/连接/timeout 等 transport 错误;除 401/403 外的现有不可用 HTTP 状态;body stream 读取失败;内容不是 HTML 登录页但 JSON 语法不可解析。 +- 直接硬失败且不得读取 LKG:HTTP 401/403;content-type 或 body 特征识别出的 HTML/login/auth 响应;JSON 可解析但请求级 schema decode 失败;第二跳结果不是对象;Environment 替换、JSONC 解析或最终 `ConfigV1.Info` schema decode 失败。 + +在线 JSON 语法解析与 schema decode 必须分成两个可观察步骤,才能保持上述边界。401/403 在进入通用非 2xx 降级分支前转换为现有 `RemoteAuthError` 语义。已有 LKG 也不能改变硬失败结果,且硬失败不能覆盖旧 LKG。 + +第一跳允许降级失败且没有可用 LKG时,继续跳过整个 well-known 来源。第二跳同类失败且没有可用 LKG时,继续返回空的 fetched config,使 well-known 内嵌 config 按现状合并。这个分支只复用原行为,不重新定义 warn + skip。 + +### 4. 损坏、空或缺失缓存不成为新的硬错误 + +仅在在线失败属于允许降级类别时读取 LKG。文件缺失表示没有 LKG,保留原在线失败告警并执行原 skip;空文件、JSON envelope 损坏、版本不支持、`writtenAt` 无效、空 body 或缓存 body 无法重新解析/decode 则额外记录不含正文与凭据的 warning,并把缓存视为不可用,随后执行同一 skip 分支。缓存自身的 schema 错误属于缓存损坏,不提升为在线 schema 硬失败。 + +未选择让损坏缓存中止启动,因为 LKG 是可丢弃的恢复材料,不能比当前无缓存流程更脆弱。 + +### 5. 同目录临时文件保证旧值不被失败更新破坏 + +实现放在新的 `packages/opencode/src/config/remote-lkg.ts` 自包含模块,公开一个窄的读取/写入接口给 `config.ts`。写入顺序为:确保专用目录存在;在目标同目录创建唯一临时文件并以 `0600` 写完整 envelope;关闭文件;原子 rename 到摘要目标;确认最终目标模式为 `0600`。成功 rename 前的任意失败只做安全 warning 和临时文件 best-effort 清理,旧目标保持不变。因为临时文件从创建起就是 `0600`,rename 后不会出现更宽权限窗口。 + +缓存写入是在线成功路径的 best-effort side effect。任一记录写失败只保留对应 URL 的旧文件并继续返回已验证在线配置;多条暂存记录独立提交,失败不会回滚配置加载。并发更新采用“最后一个完整 rename 获胜”,每个可见文件始终是完整 envelope,不增加锁服务。 + +未选择直接 truncate 目标文件,因为进程崩溃或磁盘错误会破坏旧 LKG;未选择跨目录临时文件,因为 rename 可能失去原子性。 + +### 6. LKG 永不过期,年龄只用于诊断 + +读取逻辑不以 `writtenAt` 或文件 mtime 拒绝 LKG。回退成功时可以记录 `writtenAt` 或计算后的非敏感年龄诊断,但年龄不改变控制流。下一次合法在线成功按上述原子写流程覆盖同 URL 的记录。 + +未选择固定或可配置 TTL:LKG 的目标是支持长期离线,自动过期会在最需要它时恢复到 warn + skip,并引入本票不需要的策略面。 + +### 7. TDD 边界 + +07 先扩展 `packages/opencode/test/config/wellknown-offline.test.ts`,用真实 `Config.layer` 锁定两跳在线写入后离线回退、预置 LKG 下的 auth/decode 硬失败、损坏/空缓存和日志无凭据。持久化细节放在 `packages/opencode/test/config/remote-lkg.test.ts`,用隔离 cache 根验证稳定摘要文件名、Environment 替换前 body、同目录 rename、写失败保留旧值和 POSIX `0600`。测试可为 rename 失败提供最窄的文件系统故障注入点,其余路径使用真实文件系统。 + +生产改动限定为 `packages/opencode/src/config/config.ts` 与新的 `packages/opencode/src/config/remote-lkg.ts`;不改 `packages/core`、schema、路由或生成物。 + +## Risks / Trade-offs + +- [永不过期的 LKG 可能很旧] → 每次回退记录安全的 `writtenAt`/年龄诊断,并由下一次合法在线成功覆盖;不静默声称缓存新鲜。 +- [只按 URL 分区会让同一用户下不同认证上下文共享该 URL 的 LKG] → 文件保持用户私有 `0600`,不把凭据加入 key;这是安全 key 约束与认证维度隔离之间的明确取舍。 +- [磁盘写入、rename 或 chmod 失败] → 在线配置仍成功,旧 LKG 在 rename 前保持完整,日志只包含摘要与分类。 +- [Windows 不提供等价的 POSIX mode 语义] → 创建与替换仍请求 `0600`;POSIX 测试断言精确 mode,Windows 保留原子替换与不扩宽应用请求的行为。 +- [缓存 body 本身包含用户配置] → 只写入专用 cache 目录的 `0600` 文件,绝不写入 key 或日志,也不保存经过 Environment 替换的版本。 + +## Migration Plan + +无需迁移:首次合法在线成功按需创建 version 1 文件;没有文件时行为与当前版本相同。回滚代码后这些 cache 文件无人读取,可安全保留;未来不兼容版本按损坏/不支持缓存的 warn + skip 语义处理。 + +## Open Questions + +无。本票明确采用永不过期策略,不增加可配置 TTL 或后续扩展点。 + + + +## ADDED Requirements + +### Requirement: 只持久化完整验证成功的原始远端响应 +系统 MUST 以版本、`writtenAt` 和原始响应 body 组成 LKG;MUST 在对应 well-known 来源的请求级解析、schema decode、第二跳对象检查、Environment 替换和最终 `ConfigV1.Info` schema 验证全部成功后,才持久化本次在线取得的响应。系统 MUST 保存 Environment 替换前的 body,且 MUST NOT 把请求 headers、认证 token、Environment map、替换后的正文或最终合并 `Info` 作为缓存字段。 + +#### Scenario: 在线成功后写入并可离线复用 +- **WHEN** well-known 与第二跳 remote-config 在线响应均成功,完整配置通过最终 schema 验证,随后同一 URL 的 transport 请求失败 +- **THEN** 系统写入各 URL 的原始响应 LKG,并在后续离线加载中重新验证和应用 LKG,得到与上一次合法在线读取相同的配置语义 + +#### Scenario: Environment 秘密不被固化 +- **WHEN** 原始远端 body 包含 Environment 占位符,在线加载用当前 Environment 值完成替换并通过验证 +- **THEN** LKG body 保留占位符形式,缓存 envelope 不包含替换后的 Environment 值、请求 header 或 token,回退时使用当次 Environment 重新执行替换 + +#### Scenario: 下游验证失败不产生新 LKG +- **WHEN** 在线 body 可解析,但第二跳不是对象、Environment 替换失败或最终 `ConfigV1.Info` schema decode 失败 +- **THEN** 系统硬失败,MUST NOT 写入本次暂存响应,也 MUST NOT 覆盖已有 LKG + +### Requirement: 缓存身份与诊断不得泄露凭据 +系统 MUST 用规范化 remote URL 的 SHA-256 小写十六进制摘要作为唯一文件标识。规范化 MUST 清除 fragment,并统一 WHATWG URL 定义的 scheme/host 大小写、默认端口与转义形式,同时保留改变资源身份的 path 与 query。key、文件名和日志 MUST NOT 拼接原始 URL、认证 headers、token、Environment 变量值、缓存正文或配置正文;remote-config 诊断 MUST 只使用摘要、端点角色、失败分类和非敏感年龄信息。 + +#### Scenario: 等价 URL 使用同一稳定文件名 +- **WHEN** 两个 remote URL 仅在 host 大小写、默认 HTTPS 端口或 fragment 上不同 +- **THEN** 系统规范化后生成相同的 64 位十六进制摘要文件名 + +#### Scenario: 凭据与正文不出现在 key 或日志 +- **WHEN** remote config 请求包含认证 header、token、Environment 替换值和可识别的配置正文标记,并发生在线成功、缓存写入、离线回退及缓存错误诊断 +- **THEN** 缓存文件名、key 和捕获到的日志均不包含这些 header、token、Environment 值或正文标记,缓存 envelope 也不包含请求认证元数据 + +### Requirement: LKG 更新必须原子且用户私有 +系统 MUST 在目标文件同目录创建唯一临时文件,以 `0600` 写入完整 envelope,关闭后通过原子 rename 替换目标,并保证最终文件模式为 `0600`。缓存写入 MUST 是在线成功路径的 best-effort side effect;写入失败 MUST NOT 使已验证在线配置失败,也 MUST NOT 修改或删除旧 LKG。 + +#### Scenario: 同目录原子替换并设置文件模式 +- **WHEN** 系统首次写入或覆盖一个 LKG +- **THEN** 完整内容先写入目标同目录的临时文件,再由 rename 发布,最终目标是完整 envelope 且权限模式为 `0600` + +#### Scenario: rename 前写入失败保留旧 LKG +- **WHEN** 已存在可用旧 LKG,而新在线响应验证成功但临时写入、关闭或 rename 失败 +- **THEN** 系统记录不含正文和凭据的 warning,仍返回新在线配置,并保留旧 LKG 原封不动供后续允许降级的失败使用 + +#### Scenario: 并发写入不暴露部分文件 +- **WHEN** 同一 URL 的两个合法在线加载并发更新 LKG +- **THEN** 最终读者只能观察到某一个完整 envelope,不能观察到截断或混合内容 + +### Requirement: 只有允许降级的在线失败可以回退 +系统 MUST 先按现有 remote-config 错误语义分类在线失败。transport 错误、除 401/403 外的非认证不可用 HTTP 状态、body stream 读取失败以及非 HTML 的 JSON 语法不可解析 body MUST 尝试读取 LKG。HTTP 401/403、HTML/login/auth 响应、JSON 可解析后的请求级 schema decode 错误、第二跳非对象、Environment 替换错误和最终配置 schema decode 错误 MUST 硬失败,且 MUST NOT 读取 LKG 掩盖错误。 + +#### Scenario: transport 或非认证不可用状态回退 +- **WHEN** 已有可用 LKG,在线请求发生 DNS、连接、timeout 或除 401/403 外的既有不可用 HTTP 状态 +- **THEN** 系统告警该在线失败,重新验证 LKG,并用其继续 remote config 加载 + +#### Scenario: body 读取或 JSON 语法失败回退 +- **WHEN** 已有可用 LKG,在线 response body stream 读取失败,或 body 不是 HTML/login 响应但不是可解析 JSON +- **THEN** 系统把失败归入允许降级 body 类别并使用 LKG + +#### Scenario: 401 或 403 不回退 +- **WHEN** 已有可用 LKG,但在线 remote endpoint 返回 401 或 403 +- **THEN** 系统保持认证硬失败语义,不读取 LKG,也不覆盖旧 LKG + +#### Scenario: HTML 登录页不回退 +- **WHEN** 已有可用 LKG,但在线响应由 content-type 或 body 特征识别为 HTML/login/auth 页面 +- **THEN** 系统产生现有 `RemoteAuthError` 语义,不读取 LKG,也不覆盖旧 LKG + +#### Scenario: schema decode 错误不回退 +- **WHEN** 已有可用 LKG,但在线 body 是合法 JSON,随后在 well-known schema、第二跳对象检查或最终 `ConfigV1.Info` schema decode 中失败 +- **THEN** 系统暴露硬失败,不读取 LKG,也不覆盖旧 LKG + +### Requirement: 不可用缓存保留原 warn + skip 语义 +系统 MUST 把缺失 LKG 视为没有恢复材料;MUST 把空文件、损坏 envelope、不支持版本、无效 `writtenAt`、空 body 或无法重新解析/decode 的缓存视为不可用。损坏或空缓存 MUST 产生不含正文与凭据的 warning,随后 MUST 执行原有允许降级分支,而不是崩溃或改变在线错误类别。 + +#### Scenario: 第一跳损坏或空缓存告警后跳过来源 +- **WHEN** well-known 在线失败允许降级,但对应 LKG 为空或损坏 +- **THEN** 系统告警缓存不可用并跳过整个 well-known 来源,本地配置继续按现有行为加载 + +#### Scenario: 第二跳损坏或空缓存保留内嵌配置 +- **WHEN** 第二跳 remote-config 在线失败允许降级,但对应 LKG 为空或损坏 +- **THEN** 系统告警缓存不可用并按现有空 fetched-config 分支继续,well-known 内嵌配置仍可合并 + +#### Scenario: 缓存告警不回显缓存内容 +- **WHEN** 损坏缓存包含可识别的凭据或配置正文标记 +- **THEN** warning 只包含安全摘要、端点角色和损坏分类,不包含文件正文、底层解析输入或凭据标记 + +### Requirement: LKG 不因年龄自动过期 +系统 MUST 保存合法 RFC 3339 `writtenAt`,但 MUST NOT 以 `writtenAt`、文件 mtime 或任何固定/可配置 TTL 拒绝 LKG。年龄 MUST 只用于安全诊断;下一次同 URL 的合法在线成功 MUST 通过原子更新覆盖旧记录。系统 MUST NOT 为本能力增加可配置 TTL 扩展点。 + +#### Scenario: 很旧的 LKG 仍支持长期离线 +- **WHEN** 在线失败允许降级且可用 LKG 的 `writtenAt` 已经过任意长时间 +- **THEN** 系统仍重新验证并使用该 LKG,同时可记录不含凭据的年龄诊断,不因年龄执行 warn + skip + +#### Scenario: 合法在线成功覆盖旧记录 +- **WHEN** 使用旧 LKG 后,同一规范化 URL 再次获得并完整验证合法在线响应 +- **THEN** 系统原子覆盖旧 LKG,更新 `writtenAt`,且不创建或读取 TTL 配置 + + + +## 1. 红灯:锁定外部行为 + +- [ ] 1.1 在 `packages/opencode/test/config/wellknown-offline.test.ts` 增加两跳在线成功写入、换实例后第一跳/第二跳 transport 与非认证 body 失败使用 LKG 的场景,并先运行目标文件确认新断言因 LKG 尚未实现而失败;对应[在线成功后写入并可离线复用](specs/remote-config-lkg/spec.md#scenario-在线成功后写入并可离线复用)与[允许降级回退](specs/remote-config-lkg/spec.md#scenario-transport-或非认证不可用状态回退)。 +- [ ] 1.2 在同一集成测试预置可用 LKG,再覆盖 401、403、HTML login、合法 JSON 的 well-known schema 错误、第二跳非对象和最终 `ConfigV1.Info` decode 错误;逐项断言硬失败、未使用/未覆盖 LKG,对应[401/403](specs/remote-config-lkg/spec.md#scenario-401-或-403-不回退)、[HTML](specs/remote-config-lkg/spec.md#scenario-html-登录页不回退)与[schema decode](specs/remote-config-lkg/spec.md#scenario-schema-decode-错误不回退)。 +- [ ] 1.3 在同一集成测试加入缺失、空、损坏和超旧缓存;断言第一跳保持 warn + skip、本地配置可用,第二跳保持内嵌 config 合并,超旧记录仍使用且只给安全年龄诊断;不得改写现有降级分支的结果。 +- [ ] 1.4 新建 `packages/opencode/test/config/remote-lkg.test.ts`,用隔离 cache 根和真实文件系统锁定 URL 规范化摘要、原始 Environment 占位符、同目录 rename、完整 envelope、POSIX `0600`、并发完整性,并用最窄 rename 故障注入锁定失败更新后旧 LKG 仍可读。 +- [ ] 1.5 在两份目标测试放置独特的 header/token/Environment/正文标记,断言文件名、key 和所有 remote-config/cache 日志不含标记,envelope 不含请求认证元数据;确认新增安全断言先红。 + +## 2. 绿灯:实现私有原子 LKG 模块 + +- [ ] 2.1 新建 `packages/opencode/src/config/remote-lkg.ts` 并按 `src/config` 自导出规范提供窄接口:WHATWG URL 去 fragment、SHA-256 文件名、version 1 envelope decode,以及从 `Global.Path.cache/remote-config-lkg/` 读取原始 body;损坏、空和不支持版本返回可分类的不可用结果,不抛出正文。 +- [ ] 2.2 在该模块实现 best-effort 写入:目标同目录唯一临时文件以 `0600` 写完整 envelope,关闭后原子 rename,最终模式 `0600`;失败时安全告警、best-effort 清理临时文件且不触碰旧目标。 +- [ ] 2.3 保持 `writtenAt` 为合法 RFC 3339 诊断字段;读取不检查 TTL/mtime,不增加配置项、清理器、后台刷新或 TTL 扩展接口。 + +## 3. 绿灯:接入当前 remote config 流程 + +- [ ] 3.1 仅在 `packages/opencode/src/config/config.ts` 调整 `fetchRemoteJson` 附近:把 JSON 语法解析与 schema decode 分开,并将结果分类为在线成功、允许降级失败和硬失败;401/403 与 HTML/login/auth 继续使用硬认证错误,合法 JSON 的 schema/object/final-config 错误继续硬失败。 +- [ ] 3.2 well-known 与第二跳在线响应只暂存 Environment 替换前 body;完整来源通过 `loadConfig` 最终 schema 验证后才调用 LKG 写入。仅允许降级失败读取并重验 LKG;无可用 LKG 时分别复用现有“第一跳 skip 来源”和“第二跳空 fetched config”分支。 +- [ ] 3.3 把触及的 remote-config/cache 诊断限定为摘要、端点角色、失败分类和非敏感年龄;不得序列化原始 URL、底层可能回显请求的错误对象、headers、token、Environment 值或 body。 + +## 4. 验收与范围门禁 + +- [ ] 4.1 从 `packages/opencode` 运行 `bun test test/config/remote-lkg.test.ts test/config/wellknown-offline.test.ts`,确认在线→离线、旧 LKG、auth/decode、损坏/空缓存、永不过期、key/log 安全及原子/权限场景全绿。 +- [ ] 4.2 从 `packages/opencode` 运行 `bun typecheck`;不得用 `bun run build` 代替类型门禁。 +- [ ] 4.3 检查实现 diff 只涉及 `packages/opencode/src/config/config.ts`、`packages/opencode/src/config/remote-lkg.ts` 和上述两份 config 测试;如确需测试 fixture 的最小改动须在提交说明中列出,`packages/core`、HTTP routes、SDK 生成物、依赖与既有 warn + skip 语义保持零改动。 + diff --git a/.scratch/batch-b/issues/06-o1-lkg-spec.md b/.scratch/batch-b/issues/06-o1-lkg-spec.md index 026f6b514d..862d90e665 100644 --- a/.scratch/batch-b/issues/06-o1-lkg-spec.md +++ b/.scratch/batch-b/issues/06-o1-lkg-spec.md @@ -5,10 +5,18 @@ **Evidence:** `.scratch/batch-b/evidence.md#o1--remote-config-last-known-good` **Branch:** `docs/config-lkg-spec` **Blocked by:** 05(批次串行) -**Status:** ready-for-agent +**Status:** closed -- [ ] 定义缓存内容与写入时机:只缓存已验证结构,明确环境替换前后边界 -- [ ] 定义稳定 cache key、原子写、文件权限;key/内容不得泄露 header/token -- [ ] 仅 transport/body 失败允许回退;auth/HTML login/schema decode 不得被 LKG 掩盖 -- [ ] 定义损坏缓存、空缓存与 TTL/不过期策略 -- [ ] `openspec validate --changes` 通过,已追踪镜像与原件一致,07 获得可执行验收标准 +- [x] 定义缓存内容与写入时机:只缓存完整下游验证成功的原始响应 body,持久化发生在 Environment 替换前,envelope 不保存请求 header/token +- [x] 定义稳定 cache key、原子写、文件权限:规范化 URL 的 SHA-256 文件名、同目录临时文件 + rename、最终 `0600`,key/日志不含凭据或正文 +- [x] 定义回退矩阵:transport、非 401/403 的不可用状态、body read 与非 HTML JSON 语法失败可回退;401/403、HTML/login/auth、schema/object/final decode 硬失败 +- [x] 定义损坏/空缓存 warn + skip 与 LKG 永不过期策略;`writtenAt`/年龄只做安全诊断,下一次合法在线成功原子覆盖 +- [x] OpenSpec 4/4 apply-ready,原件与镜像逐 artifact 字节一致,07 已获得 TDD 文件边界与可执行验收 + +## 交付记录 + +- **Change 原件(local-only):** `openspec/changes/remote-config-lkg/`(`proposal.md`、`design.md`、`specs/remote-config-lkg/spec.md`、`tasks.md`) +- **Tracked 镜像:** `.scratch/batch-b/config-lkg-spec.md` +- **校验 1:** `openspec validate --changes` → `✓ change/remote-config-lkg`,`1 passed, 0 failed` +- **校验 2:** `openspec validate remote-config-lkg --type change --strict --no-interactive` → `Change 'remote-config-lkg' is valid` +- **基线/范围:** `dev@4675435d94d462d2f9317d6688ddab2f0105c746`;本票没有修改 `packages/**` 或生产代码 diff --git a/.scratch/batch-b/issues/07-o1-lkg-implement.md b/.scratch/batch-b/issues/07-o1-lkg-implement.md index d8c81a913d..d55099218c 100644 --- a/.scratch/batch-b/issues/07-o1-lkg-implement.md +++ b/.scratch/batch-b/issues/07-o1-lkg-implement.md @@ -4,11 +4,33 @@ **Evidence:** `.scratch/batch-b/evidence.md#o1--remote-config-last-known-good` **Branch:** `feat/config-lkg` -**Blocked by:** 06 规格通过校验并补齐本票验收 -**Status:** blocked +**Blocked by:** 无(06 已关闭;OpenSpec `remote-config-lkg` 为 apply-ready) +**Status:** ready-for-agent -- [ ] 本票开工前把 06 的 OpenSpec requirement/scenarios 链接写入此处 +- [x] 06 的 OpenSpec requirements/scenarios 已写入下方“规格入口”;实施以 tracked 镜像为稳定入口,以 local-only change 为 OpenSpec 原件 - [ ] 在线成功后产生可复用 LKG,随后 transport/body 失败按规格回退 - [ ] auth/HTML login/decode 失败仍保持硬失败 - [ ] 损坏缓存不崩溃、不覆盖错误类别,且日志不含凭据 - [ ] 在 `packages/opencode` 运行目标测试与 `bun typecheck` + +## 规格入口 + +- [只持久化完整验证成功的原始远端响应](../config-lkg-spec.md#requirement-只持久化完整验证成功的原始远端响应):[在线写入→离线复用](../config-lkg-spec.md#scenario-在线成功后写入并可离线复用)、[Environment 秘密不固化](../config-lkg-spec.md#scenario-environment-秘密不被固化)、[验证失败不写入](../config-lkg-spec.md#scenario-下游验证失败不产生新-lkg) +- [缓存身份与诊断不得泄露凭据](../config-lkg-spec.md#requirement-缓存身份与诊断不得泄露凭据):[规范化 URL 稳定摘要](../config-lkg-spec.md#scenario-等价-url-使用同一稳定文件名)、[key/log 无凭据正文](../config-lkg-spec.md#scenario-凭据与正文不出现在-key-或日志) +- [LKG 更新必须原子且用户私有](../config-lkg-spec.md#requirement-lkg-更新必须原子且用户私有):[同目录 rename + `0600`](../config-lkg-spec.md#scenario-同目录原子替换并设置文件模式)、[失败更新保留旧 LKG](../config-lkg-spec.md#scenario-rename-前写入失败保留旧-lkg) +- [只有允许降级的在线失败可以回退](../config-lkg-spec.md#requirement-只有允许降级的在线失败可以回退):[transport/body 回退](../config-lkg-spec.md#scenario-transport-或非认证不可用状态回退)、[401/403 不回退](../config-lkg-spec.md#scenario-401-或-403-不回退)、[HTML 不回退](../config-lkg-spec.md#scenario-html-登录页不回退)、[decode 不回退](../config-lkg-spec.md#scenario-schema-decode-错误不回退) +- [不可用缓存保留原 warn + skip](../config-lkg-spec.md#requirement-不可用缓存保留原-warn--skip-语义)与[LKG 不因年龄自动过期](../config-lkg-spec.md#requirement-lkg-不因年龄自动过期):[第一/第二跳损坏缓存边界](../config-lkg-spec.md#scenario-第一跳损坏或空缓存告警后跳过来源)、[长期离线继续使用](../config-lkg-spec.md#scenario-很旧的-lkg-仍支持长期离线) + +## 实现边界 + +- **生产文件:** 仅修改 `packages/opencode/src/config/config.ts`,新增 `packages/opencode/src/config/remote-lkg.ts` +- **测试文件:** 扩展 `packages/opencode/test/config/wellknown-offline.test.ts`,新增 `packages/opencode/test/config/remote-lkg.test.ts` +- **禁止扩张:** 不修改 `packages/core`、HTTP routes、SDK/生成物、依赖或配置 schema;不重写当前第一跳 skip / 第二跳空 fetched-config 的 warn + skip 结果 +- **方法:** 严格按镜像末尾 `tasks.md` 红-绿顺序实施;rename 失败只允许最窄文件系统故障注入,其余路径使用真实实现 + +## 可执行验收 + +1. `cd packages/opencode && bun test test/config/remote-lkg.test.ts test/config/wellknown-offline.test.ts` +2. `cd packages/opencode && bun typecheck` +3. 核对目标测试覆盖在线→离线、失败写入后旧 LKG、401/403/HTML/decode 硬失败、损坏/空缓存、key/log 无凭据、永久 LKG、`0600` 与同目录原子 rename +4. 核对实现 diff 只包含上述四个主要文件;任何最小 fixture 例外必须在提交说明中单列 From caf6ed4aa67580184d6b76aebf45b780458b844f Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 9 Aug 2026 01:46:33 +0800 Subject: [PATCH 15/17] feat(config): add remote config LKG --- .scratch/batch-b/config-lkg-spec.md | 28 +- .../batch-b/issues/07-o1-lkg-implement.md | 19 +- .../08-s7-recovery-invented-diagnosis.md | 4 +- packages/opencode/src/config/config.ts | 156 +++++-- packages/opencode/src/config/remote-lkg.ts | 140 ++++++ .../opencode/test/config/remote-lkg.test.ts | 190 ++++++++ .../test/config/wellknown-offline.test.ts | 423 ++++++++++++++++-- 7 files changed, 860 insertions(+), 100 deletions(-) create mode 100644 packages/opencode/src/config/remote-lkg.ts create mode 100644 packages/opencode/test/config/remote-lkg.test.ts diff --git a/.scratch/batch-b/config-lkg-spec.md b/.scratch/batch-b/config-lkg-spec.md index ac2b7c7b05..3b6e329459 100644 --- a/.scratch/batch-b/config-lkg-spec.md +++ b/.scratch/batch-b/config-lkg-spec.md @@ -243,27 +243,27 @@ URL 用 WHATWG `URL` 规范化:清除不会随 HTTP 请求发送的 fragment ## 1. 红灯:锁定外部行为 -- [ ] 1.1 在 `packages/opencode/test/config/wellknown-offline.test.ts` 增加两跳在线成功写入、换实例后第一跳/第二跳 transport 与非认证 body 失败使用 LKG 的场景,并先运行目标文件确认新断言因 LKG 尚未实现而失败;对应[在线成功后写入并可离线复用](specs/remote-config-lkg/spec.md#scenario-在线成功后写入并可离线复用)与[允许降级回退](specs/remote-config-lkg/spec.md#scenario-transport-或非认证不可用状态回退)。 -- [ ] 1.2 在同一集成测试预置可用 LKG,再覆盖 401、403、HTML login、合法 JSON 的 well-known schema 错误、第二跳非对象和最终 `ConfigV1.Info` decode 错误;逐项断言硬失败、未使用/未覆盖 LKG,对应[401/403](specs/remote-config-lkg/spec.md#scenario-401-或-403-不回退)、[HTML](specs/remote-config-lkg/spec.md#scenario-html-登录页不回退)与[schema decode](specs/remote-config-lkg/spec.md#scenario-schema-decode-错误不回退)。 -- [ ] 1.3 在同一集成测试加入缺失、空、损坏和超旧缓存;断言第一跳保持 warn + skip、本地配置可用,第二跳保持内嵌 config 合并,超旧记录仍使用且只给安全年龄诊断;不得改写现有降级分支的结果。 -- [ ] 1.4 新建 `packages/opencode/test/config/remote-lkg.test.ts`,用隔离 cache 根和真实文件系统锁定 URL 规范化摘要、原始 Environment 占位符、同目录 rename、完整 envelope、POSIX `0600`、并发完整性,并用最窄 rename 故障注入锁定失败更新后旧 LKG 仍可读。 -- [ ] 1.5 在两份目标测试放置独特的 header/token/Environment/正文标记,断言文件名、key 和所有 remote-config/cache 日志不含标记,envelope 不含请求认证元数据;确认新增安全断言先红。 +- [x] 1.1 在 `packages/opencode/test/config/wellknown-offline.test.ts` 增加两跳在线成功写入、换实例后第一跳/第二跳 transport 与非认证 body 失败使用 LKG 的场景,并先运行目标文件确认新断言因 LKG 尚未实现而失败;对应[在线成功后写入并可离线复用](specs/remote-config-lkg/spec.md#scenario-在线成功后写入并可离线复用)与[允许降级回退](specs/remote-config-lkg/spec.md#scenario-transport-或非认证不可用状态回退)。 +- [x] 1.2 在同一集成测试预置可用 LKG,再覆盖 401、403、HTML login、合法 JSON 的 well-known schema 错误、第二跳非对象和最终 `ConfigV1.Info` decode 错误;逐项断言硬失败、未使用/未覆盖 LKG,对应[401/403](specs/remote-config-lkg/spec.md#scenario-401-或-403-不回退)、[HTML](specs/remote-config-lkg/spec.md#scenario-html-登录页不回退)与[schema decode](specs/remote-config-lkg/spec.md#scenario-schema-decode-错误不回退)。 +- [x] 1.3 在同一集成测试加入缺失、空、损坏和超旧缓存;断言第一跳保持 warn + skip、本地配置可用,第二跳保持内嵌 config 合并,超旧记录仍使用且只给安全年龄诊断;不得改写现有降级分支的结果。 +- [x] 1.4 新建 `packages/opencode/test/config/remote-lkg.test.ts`,用隔离 cache 根和真实文件系统锁定 URL 规范化摘要、原始 Environment 占位符、同目录 rename、完整 envelope、POSIX `0600`、并发完整性,并用最窄 rename 故障注入锁定失败更新后旧 LKG 仍可读。 +- [x] 1.5 在两份目标测试放置独特的 header/token/Environment/正文标记,断言文件名、key 和所有 remote-config/cache 日志不含标记,envelope 不含请求认证元数据;确认新增安全断言先红。 ## 2. 绿灯:实现私有原子 LKG 模块 -- [ ] 2.1 新建 `packages/opencode/src/config/remote-lkg.ts` 并按 `src/config` 自导出规范提供窄接口:WHATWG URL 去 fragment、SHA-256 文件名、version 1 envelope decode,以及从 `Global.Path.cache/remote-config-lkg/` 读取原始 body;损坏、空和不支持版本返回可分类的不可用结果,不抛出正文。 -- [ ] 2.2 在该模块实现 best-effort 写入:目标同目录唯一临时文件以 `0600` 写完整 envelope,关闭后原子 rename,最终模式 `0600`;失败时安全告警、best-effort 清理临时文件且不触碰旧目标。 -- [ ] 2.3 保持 `writtenAt` 为合法 RFC 3339 诊断字段;读取不检查 TTL/mtime,不增加配置项、清理器、后台刷新或 TTL 扩展接口。 +- [x] 2.1 新建 `packages/opencode/src/config/remote-lkg.ts` 并按 `src/config` 自导出规范提供窄接口:WHATWG URL 去 fragment、SHA-256 文件名、version 1 envelope decode,以及从 `Global.Path.cache/remote-config-lkg/` 读取原始 body;损坏、空和不支持版本返回可分类的不可用结果,不抛出正文。 +- [x] 2.2 在该模块实现 best-effort 写入:目标同目录唯一临时文件以 `0600` 写完整 envelope,关闭后原子 rename,最终模式 `0600`;失败时安全告警、best-effort 清理临时文件且不触碰旧目标。 +- [x] 2.3 保持 `writtenAt` 为合法 RFC 3339 诊断字段;读取不检查 TTL/mtime,不增加配置项、清理器、后台刷新或 TTL 扩展接口。 ## 3. 绿灯:接入当前 remote config 流程 -- [ ] 3.1 仅在 `packages/opencode/src/config/config.ts` 调整 `fetchRemoteJson` 附近:把 JSON 语法解析与 schema decode 分开,并将结果分类为在线成功、允许降级失败和硬失败;401/403 与 HTML/login/auth 继续使用硬认证错误,合法 JSON 的 schema/object/final-config 错误继续硬失败。 -- [ ] 3.2 well-known 与第二跳在线响应只暂存 Environment 替换前 body;完整来源通过 `loadConfig` 最终 schema 验证后才调用 LKG 写入。仅允许降级失败读取并重验 LKG;无可用 LKG 时分别复用现有“第一跳 skip 来源”和“第二跳空 fetched config”分支。 -- [ ] 3.3 把触及的 remote-config/cache 诊断限定为摘要、端点角色、失败分类和非敏感年龄;不得序列化原始 URL、底层可能回显请求的错误对象、headers、token、Environment 值或 body。 +- [x] 3.1 仅在 `packages/opencode/src/config/config.ts` 调整 `fetchRemoteJson` 附近:把 JSON 语法解析与 schema decode 分开,并将结果分类为在线成功、允许降级失败和硬失败;401/403 与 HTML/login/auth 继续使用硬认证错误,合法 JSON 的 schema/object/final-config 错误继续硬失败。 +- [x] 3.2 well-known 与第二跳在线响应只暂存 Environment 替换前 body;完整来源通过 `loadConfig` 最终 schema 验证后才调用 LKG 写入。仅允许降级失败读取并重验 LKG;无可用 LKG 时分别复用现有“第一跳 skip 来源”和“第二跳空 fetched config”分支。 +- [x] 3.3 把触及的 remote-config/cache 诊断限定为摘要、端点角色、失败分类和非敏感年龄;不得序列化原始 URL、底层可能回显请求的错误对象、headers、token、Environment 值或 body。 ## 4. 验收与范围门禁 -- [ ] 4.1 从 `packages/opencode` 运行 `bun test test/config/remote-lkg.test.ts test/config/wellknown-offline.test.ts`,确认在线→离线、旧 LKG、auth/decode、损坏/空缓存、永不过期、key/log 安全及原子/权限场景全绿。 -- [ ] 4.2 从 `packages/opencode` 运行 `bun typecheck`;不得用 `bun run build` 代替类型门禁。 -- [ ] 4.3 检查实现 diff 只涉及 `packages/opencode/src/config/config.ts`、`packages/opencode/src/config/remote-lkg.ts` 和上述两份 config 测试;如确需测试 fixture 的最小改动须在提交说明中列出,`packages/core`、HTTP routes、SDK 生成物、依赖与既有 warn + skip 语义保持零改动。 +- [x] 4.1 从 `packages/opencode` 运行 `bun test test/config/remote-lkg.test.ts test/config/wellknown-offline.test.ts`,确认在线→离线、旧 LKG、auth/decode、损坏/空缓存、永不过期、key/log 安全及原子/权限场景全绿。 +- [x] 4.2 从 `packages/opencode` 运行 `bun typecheck`;不得用 `bun run build` 代替类型门禁。 +- [x] 4.3 检查实现 diff 只涉及 `packages/opencode/src/config/config.ts`、`packages/opencode/src/config/remote-lkg.ts` 和上述两份 config 测试;如确需测试 fixture 的最小改动须在提交说明中列出,`packages/core`、HTTP routes、SDK 生成物、依赖与既有 warn + skip 语义保持零改动。 diff --git a/.scratch/batch-b/issues/07-o1-lkg-implement.md b/.scratch/batch-b/issues/07-o1-lkg-implement.md index d55099218c..d0c367c4b1 100644 --- a/.scratch/batch-b/issues/07-o1-lkg-implement.md +++ b/.scratch/batch-b/issues/07-o1-lkg-implement.md @@ -5,13 +5,22 @@ **Evidence:** `.scratch/batch-b/evidence.md#o1--remote-config-last-known-good` **Branch:** `feat/config-lkg` **Blocked by:** 无(06 已关闭;OpenSpec `remote-config-lkg` 为 apply-ready) -**Status:** ready-for-agent +**Status:** closed - [x] 06 的 OpenSpec requirements/scenarios 已写入下方“规格入口”;实施以 tracked 镜像为稳定入口,以 local-only change 为 OpenSpec 原件 -- [ ] 在线成功后产生可复用 LKG,随后 transport/body 失败按规格回退 -- [ ] auth/HTML login/decode 失败仍保持硬失败 -- [ ] 损坏缓存不崩溃、不覆盖错误类别,且日志不含凭据 -- [ ] 在 `packages/opencode` 运行目标测试与 `bun typecheck` +- [x] 在线成功后产生可复用 LKG,随后 transport/body 失败按规格回退 +- [x] auth/HTML login/decode 失败仍保持硬失败 +- [x] 损坏缓存不崩溃、不覆盖错误类别,且日志不含凭据 +- [x] 在 `packages/opencode` 运行目标测试与 `bun typecheck` + +## 实施证据 + +- **基线/分支:** `af0be65b0831c352cad28e6a32ac1c3c883fc5d8` → `feat/config-lkg` +- **公开 seam:** `Config.Service.get()` 的 well-known/remote-config 加载结果;持久化细节通过 `RemoteLkg.digest/read/write` 窄接口验证 +- **RED:** 在线→transport 新实例期望 `lkg/transport-model`、实际 `undefined`;持久化测试因 `@/config/remote-lkg` 不存在失败;401/403 预实现错误地返回成功 +- **GREEN:** `bun test test/config/remote-lkg.test.ts test/config/wellknown-offline.test.ts` 连续 3 次 `24 pass / 0 fail`;`bun typecheck` 绿色 +- **OpenSpec:** `remote-config-lkg` 为 `14/14`、`all_done`;`openspec validate --changes` 为 `1 passed, 0 failed`,strict change validate 为 valid;tracked 镜像 4/4 artifacts 与当前 local-only 原件逐字节一致 +- **范围:** 4 个指定代码/测试文件;无 fixture、依赖、lockfile、core、HTTP/SDK 改动 ## 规格入口 diff --git a/.scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md b/.scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md index 8508c80cda..1f44853a0d 100644 --- a/.scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md +++ b/.scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md @@ -5,8 +5,8 @@ **Method:** `/diagnosing-bugs` **Evidence:** `.scratch/batch-b/evidence.md#s7--recovery-invented-推断` **Branch:** `test/recovery-diagnosis` -**Blocked by:** 07(批次串行) -**Status:** blocked +**Blocked by:** 无(07 已关闭) +**Status:** ready-for-agent - [ ] 第一项产出是一条确定性、快速、可由 agent 重复运行且能红灯的命令;在此之前不写理论/修复 - [ ] 症状必须包含“durable transcript 语义完成”与“reconcile 实际写 failed”,不能只单测 helper 返回 active diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index cfbf519955..d1344689ba 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -31,6 +31,7 @@ import { ConfigManaged } from "./managed" import { ConfigParse } from "./parse" import { ConfigPaths } from "./paths" import { ConfigPlugin } from "./plugin" +import { RemoteLkg } from "./remote-lkg" import { ConfigVariable } from "./variable" import { Npm } from "@opencode-ai/core/npm" import { withTransientReadRetry } from "@/util/effect-http-client" @@ -183,47 +184,89 @@ export const layer = Layer.effect( const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) - const fetchRemoteJson = Effect.fnUntraced(function* ( + const readRemoteLkg = Effect.fnUntraced(function* >( + url: string, + schema: S, + role: RemoteLkg.Role, + ) { + const cached = yield* RemoteLkg.read({ url, role }) + if (cached.status !== "available") return undefined + const parsed = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(cached.body) + if (Option.isNone(parsed)) { + yield* Effect.logWarning("remote config LKG unavailable", { + digest: cached.digest, + role, + reason: "invalid-json", + }) + return undefined + } + const data = Schema.decodeUnknownOption(schema)(parsed.value) + if (Option.isNone(data)) { + yield* Effect.logWarning("remote config LKG unavailable", { + digest: cached.digest, + role, + reason: "schema-decode", + }) + return undefined + } + yield* Effect.logInfo("using remote config LKG", { + digest: cached.digest, + role, + ageSeconds: cached.ageSeconds, + }) + return { data: data.value, source: "lkg" as const } + }) + + const fallbackRemoteJson = Effect.fnUntraced(function* >( + url: string, + schema: S, + role: RemoteLkg.Role, + reason: "transport" | "http-status" | "body-read" | "json-syntax", + status?: number, + ) { + yield* Effect.logWarning( + reason === "body-read" || reason === "json-syntax" + ? "failed to read remote config, skipping source" + : "failed to fetch remote config, skipping source", + { digest: RemoteLkg.digest(url), role, reason, status }, + ) + return yield* readRemoteLkg(url, schema, role) + }) + + const fetchRemoteJson = Effect.fnUntraced(function* >( url: string, headers: Record | undefined, schema: S, loginOrigin: string, + role: RemoteLkg.Role, ) { - // Transport-level failures (DNS/connection/timeout, non-2xx status, body - // read failures) always degrade to a warning — an unreachable well-known - // source cannot crash config loading. Auth errors (RemoteAuthError) and - // decode failures stay hard errors: they indicate a broken credential or - // a malformed remote config, not an offline environment. - const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http)) + const response = yield* withTransientReadRetry(http) .execute( HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})), ) - .pipe( - Effect.catch((error) => - Effect.logWarning("failed to fetch remote config, skipping source", { - url, - reason: String(error), - }).pipe(Effect.as(undefined)), - ), - ) - if (response === undefined) return undefined - const body = yield* response.text.pipe( - Effect.catch((error) => - Effect.logWarning("failed to read remote config, skipping source", { - url, - reason: String(error), - }).pipe(Effect.as(undefined)), - ), - ) - if (body === undefined) return undefined + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (response === undefined) return yield* fallbackRemoteJson(url, schema, role, "transport") + if (response.status === 401 || response.status === 403) { + return yield* Effect.die(new RemoteAuthError({ url: loginOrigin, remote: url })) + } + if (response.status < 200 || response.status >= 300) { + return yield* fallbackRemoteJson(url, schema, role, "http-status", response.status) + } + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(undefined))) + if (body === undefined) return yield* fallbackRemoteJson(url, schema, role, "body-read") // An auth proxy can answer with an HTML login page at HTTP 200 (passes filterStatusOk); treat it as a re-auth error, not a decode failure. const contentType = (response.headers["content-type"] ?? "").toLowerCase() if (contentType.includes("html") || /^\s* Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))), + const parsed = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(body) + if (Option.isNone(parsed)) return yield* fallbackRemoteJson(url, schema, role, "json-syntax") + const data = yield* Schema.decodeUnknownEffect(schema)(parsed.value).pipe( + Effect.catch(() => + Effect.die(new Error(`failed to decode remote config (${RemoteLkg.digest(url)}): schema-decode`)), + ), ) + return { data, source: "online" as const, body } }) const loadConfig = Effect.fnUntraced(function* ( @@ -373,32 +416,58 @@ export const layer = Layer.effect( const url = key.replace(/\/+$/, "") authEnv[value.key] = value.token const wellknownURL = `${url}/.well-known/opencode` - yield* Effect.logDebug("fetching remote config", { url: wellknownURL }) - const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, ConfigV1.WellKnown, url) + yield* Effect.logDebug("fetching remote config", { + digest: RemoteLkg.digest(wellknownURL), + role: "well-known", + }) + const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, ConfigV1.WellKnown, url, "well-known") // Unreachable source: the warning was logged by fetchRemoteJson; skip this source entirely // without merging anything so the local config stays fully usable. if (wellknown === undefined) continue const remote = yield* Effect.promise(() => substituteWellKnownRemoteConfig({ - value: wellknown.remote_config, + value: wellknown.data.remote_config, dir: url, source: wellknownURL, env: authEnv, }), ) - const fetchedConfig = remote + const fetched = remote ? yield* Effect.gen(function* () { - yield* Effect.logDebug("fetching remote config", { url: remote.url }) - const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json, url) - if (data === undefined) return {} - if (isRecord(data) && isRecord(data.config)) return data.config - if (isRecord(data)) return data + yield* Effect.logDebug("fetching remote config", { + digest: RemoteLkg.digest(remote.url), + role: "remote-config", + }) + const hit = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json, url, "remote-config") + if (hit === undefined) return { config: {} } + const data = hit.data + if (isRecord(data) && isRecord(data.config)) { + return { + config: data.config, + cache: + hit.source === "online" + ? { url: remote.url, role: "remote-config" as const, body: hit.body } + : undefined, + } + } + if (isRecord(data)) { + return { + config: data, + cache: + hit.source === "online" + ? { url: remote.url, role: "remote-config" as const, body: hit.body } + : undefined, + } + } return yield* Effect.die( - new Error(`failed to decode remote config from ${remote.url}: expected object`), + new Error(`failed to decode remote config (${RemoteLkg.digest(remote.url)}): expected object`), ) }) - : {} - const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig) + : { config: {} } + const remoteConfig = mergeConfig( + isRecord(wellknown.data.config) ? wellknown.data.config : {}, + fetched.config, + ) if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" const source = wellknownURL const next = yield* loadConfig( @@ -409,8 +478,15 @@ export const layer = Layer.effect( }, authEnv, ) + if (wellknown.source === "online") { + yield* RemoteLkg.write({ url: wellknownURL, role: "well-known", body: wellknown.body }) + } + if (fetched.cache) yield* RemoteLkg.write(fetched.cache) yield* merge(source, next, "global") - yield* Effect.logDebug("loaded remote config from well-known", { url }) + yield* Effect.logDebug("loaded remote config from well-known", { + digest: RemoteLkg.digest(wellknownURL), + role: "well-known", + }) } } diff --git a/packages/opencode/src/config/remote-lkg.ts b/packages/opencode/src/config/remote-lkg.ts new file mode 100644 index 0000000000..4032d354d7 --- /dev/null +++ b/packages/opencode/src/config/remote-lkg.ts @@ -0,0 +1,140 @@ +export * as RemoteLkg from "./remote-lkg" + +import { Global } from "@opencode-ai/core/global" +import { DateTime, Effect, Option, Schema } from "effect" +import { chmod, rename, rm } from "fs/promises" +import path from "path" +import { randomUUID } from "crypto" + +export type Role = "well-known" | "remote-config" + +export type UnavailableReason = + | "read-failed" + | "empty-file" + | "invalid-envelope" + | "unsupported-version" + | "invalid-written-at" + | "empty-body" + +export type ReadResult = + | { readonly status: "missing"; readonly digest: string } + | { readonly status: "unavailable"; readonly digest: string; readonly reason: UnavailableReason } + | { + readonly status: "available" + readonly digest: string + readonly writtenAt: string + readonly ageSeconds: number + readonly body: string + } + +export interface ReadInput { + readonly url: string + readonly role: Role +} + +export interface WriteInput extends ReadInput { + readonly body: string +} + +type Rename = (source: string, target: string) => Promise +type WriteFailure = "write" | "rename" | "chmod" + +export function digest(url: string) { + const normalized = new URL(url) + normalized.hash = "" + return new Bun.CryptoHasher("sha256").update(normalized.href).digest("hex") +} + +export function read(input: ReadInput): Effect.Effect { + const key = digest(input.url) + const target = cacheFile(key) + return Effect.gen(function* () { + if (!(yield* Effect.promise(() => Bun.file(target).exists()))) return { status: "missing", digest: key } + const content = yield* Effect.tryPromise({ + try: () => Bun.file(target).text(), + catch: () => "read-failed" as const, + }).pipe(Effect.catch((reason) => unavailable(input.role, key, reason))) + if (typeof content !== "string") return content + if (!content.length) return yield* unavailable(input.role, key, "empty-file") + + const parsed = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(content) + if (Option.isNone(parsed) || !isRecord(parsed.value)) { + return yield* unavailable(input.role, key, "invalid-envelope") + } + if (parsed.value.version !== 1) { + return yield* unavailable( + input.role, + key, + typeof parsed.value.version === "number" ? "unsupported-version" : "invalid-envelope", + ) + } + if (typeof parsed.value.writtenAt !== "string") { + return yield* unavailable(input.role, key, "invalid-envelope") + } + const writtenAt = Schema.decodeUnknownOption(Schema.DateTimeUtcFromString)(parsed.value.writtenAt) + if (Option.isNone(writtenAt)) return yield* unavailable(input.role, key, "invalid-written-at") + if (typeof parsed.value.body !== "string") return yield* unavailable(input.role, key, "invalid-envelope") + if (!parsed.value.body.length) return yield* unavailable(input.role, key, "empty-body") + + const result: ReadResult = { + status: "available", + digest: key, + writtenAt: parsed.value.writtenAt, + ageSeconds: Math.max(0, Math.floor((Date.now() - DateTime.toEpochMillis(writtenAt.value)) / 1000)), + body: parsed.value.body, + } + return result + }) +} + +export function write(input: WriteInput, move: Rename = rename): Effect.Effect { + const key = digest(input.url) + const target = cacheFile(key) + const temporary = path.join(path.dirname(target), `.${key}.${process.pid}.${randomUUID()}.tmp`) + const update = Effect.gen(function* () { + yield* Effect.tryPromise({ + try: () => + Bun.write(temporary, JSON.stringify({ version: 1, writtenAt: new Date().toISOString(), body: input.body }), { + mode: 0o600, + createPath: true, + }), + catch: () => "write" as const, + }) + yield* Effect.tryPromise({ + try: () => move(temporary, target), + catch: () => "rename" as const, + }) + yield* Effect.tryPromise({ + try: () => chmod(target, 0o600), + catch: () => "chmod" as const, + }) + return true + }) + return update.pipe( + Effect.catch((reason: WriteFailure) => + Effect.gen(function* () { + yield* Effect.logWarning("failed to update remote config LKG", { + digest: key, + role: input.role, + reason, + }) + yield* Effect.promise(() => rm(temporary, { force: true }).catch(() => undefined)) + return false + }), + ), + ) +} + +function cacheFile(key: string) { + return path.join(Global.Path.cache, "remote-config-lkg", `${key}.json`) +} + +function unavailable(role: Role, key: string, reason: UnavailableReason): Effect.Effect { + return Effect.logWarning("remote config LKG unavailable", { digest: key, role, reason }).pipe( + Effect.as({ status: "unavailable", digest: key, reason } as const), + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/opencode/test/config/remote-lkg.test.ts b/packages/opencode/test/config/remote-lkg.test.ts new file mode 100644 index 0000000000..91125d9060 --- /dev/null +++ b/packages/opencode/test/config/remote-lkg.test.ts @@ -0,0 +1,190 @@ +import { expect } from "bun:test" +import { Global } from "@opencode-ai/core/global" +import { Effect, Layer, Schema } from "effect" +import { logLines } from "effect/testing/TestConsole" +import { readdir, stat } from "fs/promises" +import path from "path" + +import { RemoteLkg } from "@/config/remote-lkg" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.empty) + +const file = (url: string) => path.join(Global.Path.cache, "remote-config-lkg", `${RemoteLkg.digest(url)}.json`) + +const Envelope = Schema.Struct({ + version: Schema.Literal(1), + writtenAt: Schema.String, + body: Schema.String, +}) + +it.live( + "normalizes equivalent URLs to one stable credential-free digest", + Effect.sync(() => { + const first = RemoteLkg.digest( + "HTTPS://LKG-Key.Example.COM:443/path/config?QUERY_CREDENTIAL_MARKER=1#IGNORED_FRAGMENT_MARKER", + ) + const second = RemoteLkg.digest("https://lkg-key.example.com/path/config?QUERY_CREDENTIAL_MARKER=1") + + expect(first).toBe("5eb35456bf3eca724774dbab344826a95d8531c2c37aba3520a3dbeb3055f4b2") + expect(second).toBe(first) + expect(first).toMatch(/^[a-f0-9]{64}$/) + expect(first).not.toContain("QUERY_CREDENTIAL_MARKER") + expect(RemoteLkg.digest("https://lkg-key.example.com/path/config?different=1")).not.toBe(first) + }), +) + +it.live( + "writes the exact pre-expansion body in a minimal private envelope", + Effect.gen(function* () { + const url = "https://lkg-envelope.example.com/config?QUERY_ENVELOPE_SECRET_MARKER=1" + const body = JSON.stringify({ + username: "{env:TEST_TOKEN}", + marker: "RAW_REMOTE_BODY_MARKER", + }) + const expandedSecret = "EXPANDED_ENV_SECRET_MARKER" + const headerSecret = "AUTHORIZATION_HEADER_SECRET_MARKER" + + expect(yield* RemoteLkg.write({ url, role: "remote-config", body })).toBe(true) + + const content = yield* Effect.promise(() => Bun.file(file(url)).text()) + const unknown = Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(content) + if (typeof unknown !== "object" || unknown === null || Array.isArray(unknown)) { + throw new Error("LKG envelope is not an object") + } + const envelope = Schema.decodeUnknownSync(Schema.fromJsonString(Envelope))(content) + expect(envelope.version).toBe(1) + expect(envelope.writtenAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + expect(envelope.body).toBe(body) + expect(Object.keys(unknown).sort()).toEqual(["body", "version", "writtenAt"]) + expect(content).not.toContain(expandedSecret) + expect(content).not.toContain(headerSecret) + expect(path.basename(file(url))).not.toContain("QUERY_ENVELOPE_SECRET_MARKER") + + if (process.platform !== "win32") { + expect((yield* Effect.promise(() => stat(file(url)))).mode & 0o777).toBe(0o600) + } + const digest = RemoteLkg.digest(url) + expect( + (yield* Effect.promise(() => readdir(path.dirname(file(url))))).filter((name) => name.includes(digest)), + ).toEqual([`${digest}.json`]) + }), +) + +it.live( + "publishes one complete envelope under concurrent writes", + Effect.gen(function* () { + const url = "https://lkg-concurrent.example.com/config.json" + const bodies = ["CONCURRENT_BODY_ALPHA", "CONCURRENT_BODY_BETA"] + + yield* Effect.all( + bodies.map((body) => RemoteLkg.write({ url, role: "remote-config", body })), + { concurrency: "unbounded" }, + ) + + const result = yield* RemoteLkg.read({ url, role: "remote-config" }) + expect(result.status).toBe("available") + if (result.status !== "available") return + expect(bodies).toContain(result.body) + const content = yield* Effect.promise(() => Bun.file(file(url)).text()) + expect(Schema.decodeUnknownSync(Schema.fromJsonString(Envelope))(content).body).toBe(result.body) + }), +) + +it.live( + "uses a same-directory rename and preserves the old LKG when rename fails", + Effect.gen(function* () { + const url = "https://lkg-rename.example.com/config?RENAME_QUERY_SECRET_MARKER=1" + const calls: { source?: string; target?: string } = {} + expect(yield* RemoteLkg.write({ url, role: "remote-config", body: "OLD_LKG_BODY" })).toBe(true) + + const updated = yield* RemoteLkg.write( + { url, role: "remote-config", body: "NEW_LKG_BODY_SECRET_MARKER" }, + async (source, target) => { + calls.source = source + calls.target = target + throw new Error("RENAME_ERROR_SECRET_MARKER") + }, + ) + + expect(updated).toBe(false) + const source = calls.source + const target = calls.target + if (!source || !target) throw new Error("rename was not attempted") + expect(path.dirname(source)).toBe(path.dirname(target)) + expect(target).toBe(file(url)) + expect(yield* Effect.promise(() => Bun.file(source).exists())).toBe(false) + const result = yield* RemoteLkg.read({ url, role: "remote-config" }) + expect(result.status).toBe("available") + if (result.status === "available") expect(result.body).toBe("OLD_LKG_BODY") + + const logs = JSON.stringify(yield* logLines) + expect(logs).toContain("failed to update remote config LKG") + expect(logs).not.toContain("RENAME_QUERY_SECRET_MARKER") + expect(logs).not.toContain("NEW_LKG_BODY_SECRET_MARKER") + expect(logs).not.toContain("RENAME_ERROR_SECRET_MARKER") + }), +) + +it.live( + "classifies empty and damaged cache records without logging their content", + Effect.gen(function* () { + const cases: { name: string; content: string; reason: RemoteLkg.UnavailableReason }[] = [ + { name: "empty-file", content: "", reason: "empty-file" }, + { name: "invalid-envelope", content: "{CORRUPT_ENVELOPE_SECRET_MARKER", reason: "invalid-envelope" }, + { + name: "unsupported-version", + content: JSON.stringify({ version: 2, writtenAt: "2026-08-09T00:00:00.000Z", body: "body" }), + reason: "unsupported-version", + }, + { + name: "invalid-written-at", + content: JSON.stringify({ version: 1, writtenAt: "not-a-date", body: "body" }), + reason: "invalid-written-at", + }, + { + name: "empty-body", + content: JSON.stringify({ version: 1, writtenAt: "2026-08-09T00:00:00.000Z", body: "" }), + reason: "empty-body", + }, + ] + + yield* Effect.forEach( + cases, + (scenario) => + Effect.gen(function* () { + const url = `https://lkg-unavailable-${scenario.name}.example.com/config.json` + yield* Effect.promise(() => Bun.write(file(url), scenario.content, { mode: 0o600 })) + const result = yield* RemoteLkg.read({ url, role: "remote-config" }) + expect(result.status).toBe("unavailable") + if (result.status === "unavailable") expect(result.reason).toBe(scenario.reason) + }), + { discard: true }, + ) + + const logs = JSON.stringify(yield* logLines) + expect(logs).toContain("remote config LKG unavailable") + expect(logs).not.toContain("CORRUPT_ENVELOPE_SECRET_MARKER") + }), +) + +it.live( + "returns a very old valid record without applying TTL", + Effect.gen(function* () { + const url = "https://lkg-no-ttl.example.com/config.json" + yield* Effect.promise(() => + Bun.write( + file(url), + JSON.stringify({ version: 1, writtenAt: "2000-01-01T00:00:00.000Z", body: "VERY_OLD_VALID_BODY" }), + { mode: 0o600 }, + ), + ) + + const result = yield* RemoteLkg.read({ url, role: "remote-config" }) + expect(result.status).toBe("available") + if (result.status !== "available") return + expect(result.body).toBe("VERY_OLD_VALID_BODY") + expect(result.writtenAt).toBe("2000-01-01T00:00:00.000Z") + expect(result.ageSeconds).toBeGreaterThan(20 * 365 * 24 * 60 * 60) + }), +) diff --git a/packages/opencode/test/config/wellknown-offline.test.ts b/packages/opencode/test/config/wellknown-offline.test.ts index 80b03a0ca8..30a8900df7 100644 --- a/packages/opencode/test/config/wellknown-offline.test.ts +++ b/packages/opencode/test/config/wellknown-offline.test.ts @@ -1,11 +1,14 @@ import { expect } from "bun:test" -import { Effect, Exit, Layer } from "effect" -import * as TestConsole from "effect/testing/TestConsole" +import { Effect, Exit, Layer, Schema } from "effect" +import { logLines } from "effect/testing/TestConsole" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http" +import { readdir } from "fs/promises" +import path from "path" import { Config } from "@/config/config" import { Auth } from "../../src/auth" @@ -13,6 +16,7 @@ import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" import { Env } from "../../src/env" +import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const infra = CrossSpawnSpawner.defaultLayer.pipe( @@ -21,19 +25,19 @@ const infra = CrossSpawnSpawner.defaultLayer.pipe( const testFlock = EffectFlock.defaultLayer -const wellKnownAuth = (url: string) => +const wellKnownAuth = (url: string, token = "test-token") => Layer.mock(Auth.Service)({ all: () => Effect.succeed({ - [url]: new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }), + [url]: new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token }), }), }) -const configLayer = (client: HttpClient.HttpClient) => +const configLayer = (client: HttpClient.HttpClient, url = "https://example.com", token = "test-token") => Config.layer.pipe( Layer.provide(testFlock), Layer.provide(Env.defaultLayer), - Layer.provide(wellKnownAuth("https://example.com")), + Layer.provide(wellKnownAuth(url, token)), Layer.provide(AccountTest.empty), Layer.provideMerge(infra), Layer.provide(NpmTest.noop), @@ -41,7 +45,7 @@ const configLayer = (client: HttpClient.HttpClient) => Layer.provideMerge(FSUtil.defaultLayer), ) -const it = (client: HttpClient.HttpClient) => testEffect(configLayer(client)) +const it = (client: HttpClient.HttpClient, url?: string, token?: string) => testEffect(configLayer(client, url, token)) const json = (request: Parameters[0], body: unknown, status = 200) => HttpClientResponse.fromWeb( @@ -52,6 +56,21 @@ const json = (request: Parameters[0], body: u }), ) +const jsonText = (request: Parameters[0], body: string) => + HttpClientResponse.fromWeb( + request, + new Response(body, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + +const LkgEnvelope = Schema.Struct({ + version: Schema.Literal(1), + writtenAt: Schema.String, + body: Schema.String, +}) + const transportFailure = (request: Parameters[0], description: string) => Effect.fail( new HttpClientError.HttpClientError({ @@ -59,11 +78,16 @@ const transportFailure = (request: Parameters }), ) +const lkgFile = (digest: string) => path.join(Global.Path.cache, "remote-config-lkg", `${digest}.json`) + +const writeLkgFile = (digest: string, content: string) => + Effect.promise(() => Bun.write(lkgFile(digest), content, { mode: 0o600 })) + // Well-known endpoint unreachable (DNS/connection failure): the transport never answers. const unreachable = HttpClient.make((request) => transportFailure(request, "connect ECONNREFUSED")) // Well-known endpoint answers, but the remote_config URL is unreachable. -const remoteConfigUnreachable = (seen: { wellKnown?: string; remote?: string }) => +const remoteConfigUnreachable = (seen: { wellKnown?: string; remote?: string }, remoteUrl: string) => HttpClient.make((request) => { const parsedUrl = new URL(request.url) if (parsedUrl.pathname.includes("/.well-known/opencode")) { @@ -71,11 +95,11 @@ const remoteConfigUnreachable = (seen: { wellKnown?: string; remote?: string }) return Effect.succeed( json(request, { config: { model: "embedded/model" }, - remote_config: { url: "https://config.example.com/opencode.json" }, + remote_config: { url: remoteUrl }, }), ) } - if (parsedUrl.hostname === "config.example.com") { + if (request.url === remoteUrl) { seen.remote = request.url return transportFailure(request, "connect timeout") } @@ -149,7 +173,323 @@ const bodyReadFails = HttpClient.make((request) => { return Effect.succeed(json(request, {}, 404)) }) -const unreachableIt = it(unreachable) +const onlineThenFailureState = { mode: "online" } +const onlineThenAllowedFailure = HttpClient.make((request) => { + if (request.url.includes("/.well-known/opencode")) { + if (onlineThenFailureState.mode === "wellknown-transport") { + return transportFailure(request, "offline after initial success") + } + if (onlineThenFailureState.mode === "wellknown-status") { + return Effect.succeed(json(request, { error: "temporarily unavailable" }, 503)) + } + if (onlineThenFailureState.mode === "wellknown-body") { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("{not-json", { status: 200, headers: { "content-type": "application/json" } }), + ), + ) + } + return Effect.succeed( + json(request, { remote_config: { url: "https://lkg-transport-config.example.com/opencode.json" } }), + ) + } + if (new URL(request.url).hostname === "lkg-transport-config.example.com") { + if (onlineThenFailureState.mode === "remote-transport") { + return transportFailure(request, "remote config offline after initial success") + } + if (onlineThenFailureState.mode === "remote-status") { + return Effect.succeed(json(request, { error: "temporarily unavailable" }, 502)) + } + if (onlineThenFailureState.mode === "remote-body") { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("{not-json", { status: 200, headers: { "content-type": "application/json" } }), + ), + ) + } + return Effect.succeed(json(request, { config: { model: "lkg/transport-model" } })) + } + return Effect.succeed(json(request, {}, 404)) +}) + +const onlineThenAllowedFailureIt = it(onlineThenAllowedFailure, "https://lkg-transport.example.com") + +onlineThenAllowedFailureIt.live( + "online success persists both remote responses for allowed-failure reuse in new instances", + Effect.gen(function* () { + const online = yield* provideTmpdirInstance(() => Config.use.get()) + expect(online.model).toBe("lkg/transport-model") + + yield* Effect.forEach( + ["wellknown-transport", "remote-transport", "wellknown-status", "remote-status", "wellknown-body", "remote-body"], + (mode) => + Effect.gen(function* () { + onlineThenFailureState.mode = mode + const fallback = yield* provideTmpdirInstance(() => Config.use.get()) + expect(fallback.model).toBe("lkg/transport-model") + }), + { discard: true }, + ) + }), +) + +type HardFailureMode = + | "online" + | "transport" + | "remote-401" + | "remote-403" + | "remote-html" + | "wellknown-schema" + | "remote-nonobject" + | "final-config-decode" + +const hardFailureClient = (origin: string, state: { mode: HardFailureMode }) => + HttpClient.make((request) => { + if (state.mode === "transport") return transportFailure(request, "offline after hard failure") + if (request.url.includes("/.well-known/opencode")) { + if (state.mode === "wellknown-schema") return Effect.succeed(json(request, ["not-an-object"])) + return Effect.succeed(json(request, { remote_config: { url: `${origin}/remote-config.json` } })) + } + if (state.mode === "remote-401") return Effect.succeed(json(request, { error: "sign in" }, 401)) + if (state.mode === "remote-403") return Effect.succeed(json(request, { error: "forbidden" }, 403)) + if (state.mode === "remote-html") { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("Sign in", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ), + ) + } + if (state.mode === "remote-nonobject") return Effect.succeed(json(request, ["not-an-object"])) + if (state.mode === "final-config-decode") { + return Effect.succeed(json(request, { config: { model: 42 } })) + } + return Effect.succeed(json(request, { config: { model: "lkg/hard-boundary-model" } })) + }) + +const hardFailureCases: { mode: HardFailureMode; title: string }[] = [ + { mode: "remote-401", title: "401" }, + { mode: "remote-403", title: "403" }, + { mode: "remote-html", title: "HTML login" }, + { mode: "wellknown-schema", title: "well-known schema decode" }, + { mode: "remote-nonobject", title: "remote non-object" }, + { mode: "final-config-decode", title: "final config decode" }, +] + +hardFailureCases.forEach((scenario) => { + const origin = `https://lkg-hard-${scenario.mode}.example.com` + const state: { mode: HardFailureMode } = { mode: "online" } + const boundaryIt = it(hardFailureClient(origin, state), origin) + + boundaryIt.live( + `${scenario.title} remains a hard failure and preserves the previous LKG`, + Effect.gen(function* () { + const online = yield* provideTmpdirInstance(() => Config.use.get()) + expect(online.model).toBe("lkg/hard-boundary-model") + + state.mode = scenario.mode + const failure = yield* provideTmpdirInstance(() => Config.use.get().pipe(Effect.exit)) + expect(Exit.isFailure(failure)).toBe(true) + + state.mode = "transport" + const fallback = yield* provideTmpdirInstance(() => Config.use.get()) + expect(fallback.model).toBe("lkg/hard-boundary-model") + }), + ) +}) + +const unavailableWellKnownCases = [ + { + title: "empty", + origin: "https://lkg-empty-first.example.com", + digest: "89173d6feff949ddb9265e9b19b142875971bf40065fd88b76223612d0b0e705", + content: "", + }, + { + title: "corrupt", + origin: "https://lkg-corrupt-first.example.com", + digest: "f92edb1dcc30a66dee91cb91d73925019660bcd6a87b9db15dd00e527a89245b", + content: "{CORRUPT_CACHE_CREDENTIAL_MARKER", + }, +] + +unavailableWellKnownCases.forEach((scenario) => { + const unavailableIt = it(unreachable, scenario.origin) + + unavailableIt.instance( + `${scenario.title} first-hop LKG warns and preserves the existing skip result`, + () => + Effect.gen(function* () { + yield* writeLkgFile(scenario.digest, scenario.content) + const config = yield* Config.use.get() + expect(config.model).toBe("local/unavailable-cache-model") + const logs = JSON.stringify(yield* logLines) + expect(logs).toContain("remote config LKG unavailable") + expect(logs).not.toContain("CORRUPT_CACHE_CREDENTIAL_MARKER") + }), + { config: { model: "local/unavailable-cache-model" } }, + ) +}) + +const unavailableRemoteCases = [ + { + title: "empty", + origin: "https://lkg-empty-remote.example.com", + digest: "8c123b509f8a3a6c1f06c8103d2c1da2f12d4ba58d12d0059da22a986ea8018a", + content: "", + }, + { + title: "corrupt", + origin: "https://lkg-corrupt-remote.example.com", + digest: "74f3bb458fa277ba39d92a3874d58a0c0095831a90fa0cbc39ad701ca3d68c8b", + content: "{CORRUPT_REMOTE_CACHE_CREDENTIAL_MARKER", + }, +] + +unavailableRemoteCases.forEach((scenario) => { + const client = HttpClient.make((request) => { + if (request.url.includes("/.well-known/opencode")) { + return Effect.succeed( + json(request, { + config: { model: "embedded/unavailable-cache-model" }, + remote_config: { url: `${scenario.origin}/remote-config.json` }, + }), + ) + } + return transportFailure(request, "remote config unavailable") + }) + const unavailableIt = it(client, scenario.origin) + + unavailableIt.live( + `${scenario.title} second-hop LKG warns and preserves embedded well-known config`, + Effect.gen(function* () { + yield* writeLkgFile(scenario.digest, scenario.content) + const config = yield* provideTmpdirInstance(() => Config.use.get()) + expect(config.model).toBe("embedded/unavailable-cache-model") + const logs = JSON.stringify(yield* logLines) + expect(logs).toContain("remote config LKG unavailable") + expect(logs).not.toContain("CORRUPT_REMOTE_CACHE_CREDENTIAL_MARKER") + }), + ) +}) + +const oldLkgOrigin = "https://lkg-old.example.com" +const oldLkgIt = it(unreachable, oldLkgOrigin) + +oldLkgIt.live( + "very old LKG remains usable and reports only safe age diagnostics", + Effect.gen(function* () { + yield* writeLkgFile( + "1675a350c4b8aa9887c0fad04fa378818761901440a26f5e78e0429786712889", + JSON.stringify({ + version: 1, + writtenAt: "2000-01-01T00:00:00.000Z", + body: JSON.stringify({ remote_config: { url: `${oldLkgOrigin}/remote-config.json` } }), + }), + ) + yield* writeLkgFile( + "0c467a2ce6c5e997d510fec8d4d3a3115a53ba26509732ad07b84069605e7ad1", + JSON.stringify({ + version: 1, + writtenAt: "2000-01-01T00:00:00.000Z", + body: JSON.stringify({ config: { model: "lkg/very-old-model" } }), + }), + ) + + const config = yield* provideTmpdirInstance(() => Config.use.get()) + expect(config.model).toBe("lkg/very-old-model") + const logs = JSON.stringify(yield* logLines) + expect(logs).toContain("using remote config LKG") + expect(logs).toContain("ageSeconds") + expect(logs).not.toContain("lkg/very-old-model") + }), +) + +const safetyOrigin = "https://lkg-safety.example.com" +const safetyRemoteUrl = "https://lkg-safety-config.example.com/opencode.json?credential=QUERY_SECRET_MARKER" +const safetyToken = "AUTH_TOKEN_SECRET_MARKER" +const safetyWellKnownBody = JSON.stringify({ + config: { username: "{env:TEST_TOKEN}" }, + remote_config: { + url: safetyRemoteUrl, + headers: { + Authorization: "Bearer {env:TEST_TOKEN}", + "X-Header-Marker": "HEADER_SECRET_MARKER", + }, + }, +}) +const safetyRemoteBody = JSON.stringify({ + config: { + model: "BODY_MARKER/model", + username: "{env:TEST_TOKEN}", + }, +}) +const safetyState = { online: true } +const safetyClient = HttpClient.make((request) => { + if (!safetyState.online) return transportFailure(request, "offline for safety diagnostics") + if (request.url.includes("/.well-known/opencode")) return Effect.succeed(jsonText(request, safetyWellKnownBody)) + return Effect.succeed(jsonText(request, safetyRemoteBody)) +}) +const safetyIt = it(safetyClient, safetyOrigin, safetyToken) + +safetyIt.live( + "cache identity, envelope metadata, and remote diagnostics do not leak credentials or body values", + Effect.gen(function* () { + const online = yield* provideTmpdirInstance(() => Config.use.get()) + expect(online.model).toBe("BODY_MARKER/model") + expect(online.username).toBe(safetyToken) + + const digests = [ + "505f8fee6f34341c3236d9240b99654fa1e3d237a020a450b0cc5e0fc373ad0e", + "690e1cb0aeb039cd6a0206ada563856113c8f76c7345fadca285e7bf369b54ba", + ] + const filenames = yield* Effect.promise(() => readdir(path.join(Global.Path.cache, "remote-config-lkg"))) + expect(filenames.filter((name) => digests.some((digest) => name === `${digest}.json`)).toSorted()).toEqual( + digests.map((digest) => `${digest}.json`).toSorted(), + ) + expect(filenames.join(" ")).not.toContain("QUERY_SECRET_MARKER") + expect(filenames.join(" ")).not.toContain("HEADER_SECRET_MARKER") + expect(filenames.join(" ")).not.toContain("BODY_MARKER") + expect(filenames.join(" ")).not.toContain(safetyToken) + + const wellKnownContent = yield* Effect.promise(() => Bun.file(lkgFile(digests[0])).text()) + const remoteContent = yield* Effect.promise(() => Bun.file(lkgFile(digests[1])).text()) + const wellKnownUnknown = Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(wellKnownContent) + const remoteUnknown = Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(remoteContent) + if (typeof wellKnownUnknown !== "object" || wellKnownUnknown === null || Array.isArray(wellKnownUnknown)) { + throw new Error("well-known LKG is not an object") + } + if (typeof remoteUnknown !== "object" || remoteUnknown === null || Array.isArray(remoteUnknown)) { + throw new Error("remote-config LKG is not an object") + } + expect(Object.keys(wellKnownUnknown).sort()).toEqual(["body", "version", "writtenAt"]) + expect(Object.keys(remoteUnknown).sort()).toEqual(["body", "version", "writtenAt"]) + expect(Schema.decodeUnknownSync(Schema.fromJsonString(LkgEnvelope))(wellKnownContent).body).toBe( + safetyWellKnownBody, + ) + expect(Schema.decodeUnknownSync(Schema.fromJsonString(LkgEnvelope))(remoteContent).body).toBe(safetyRemoteBody) + expect(wellKnownContent).not.toContain(safetyToken) + expect(remoteContent).not.toContain(safetyToken) + + safetyState.online = false + const fallback = yield* provideTmpdirInstance(() => Config.use.get()) + expect(fallback.model).toBe("BODY_MARKER/model") + expect(fallback.username).toBe(safetyToken) + + const logs = JSON.stringify(yield* logLines) + for (const marker of ["QUERY_SECRET_MARKER", "HEADER_SECRET_MARKER", "BODY_MARKER", safetyToken]) { + expect(logs).not.toContain(marker) + } + }), +) + +const missingWellKnownOrigin = "https://missing-wellknown.example.com" +const unreachableIt = it(unreachable, missingWellKnownOrigin) unreachableIt.instance( "wellknown transport failure degrades: config loads, local config intact, warning logged", @@ -158,9 +498,11 @@ unreachableIt.instance( const config = yield* Config.use.get() expect(config.model).toBe("local/model") expect(config.mcp?.jira?.enabled).toBe(true) - const logs = JSON.stringify(yield* TestConsole.logLines) + const logs = JSON.stringify(yield* logLines) expect(logs).toContain("failed to fetch remote config") - expect(logs).toContain("https://example.com/.well-known/opencode") + expect(logs).toContain("6b8f8396ae9f582f48dad65f38f88caf722d177638e6d3cadc1d1e7cea36b312") + expect(logs).toContain("well-known") + expect(logs).not.toContain(`${missingWellKnownOrigin}/.well-known/opencode`) }), { config: { @@ -171,51 +513,52 @@ unreachableIt.instance( ) const remoteUnreachableSeen: { wellKnown?: string; remote?: string } = {} -const remoteUnreachableIt = it(remoteConfigUnreachable(remoteUnreachableSeen)) +const missingRemoteOrigin = "https://missing-remote.example.com" +const missingRemoteUrl = "https://missing-remote-config.example.com/opencode.json" +const remoteUnreachableIt = it(remoteConfigUnreachable(remoteUnreachableSeen, missingRemoteUrl), missingRemoteOrigin) remoteUnreachableIt.instance( "remote_config transport failure degrades: embedded wellknown config still merges, warning logged", () => Effect.gen(function* () { const config = yield* Config.use.get() - expect(remoteUnreachableSeen.wellKnown).toBe("https://example.com/.well-known/opencode") - expect(remoteUnreachableSeen.remote).toBe("https://config.example.com/opencode.json") + expect(remoteUnreachableSeen.wellKnown).toBe(`${missingRemoteOrigin}/.well-known/opencode`) + expect(remoteUnreachableSeen.remote).toBe(missingRemoteUrl) expect(config.model).toBe("embedded/model") - const logs = JSON.stringify(yield* TestConsole.logLines) + const logs = JSON.stringify(yield* logLines) expect(logs).toContain("failed to fetch remote config") - expect(logs).toContain("https://config.example.com/opencode.json") + expect(logs).toContain("f71d054e157edfcdd072de9b3c9ccbe82c4330d9a054116dc0c920236a7a8e92") + expect(logs).toContain("remote-config") + expect(logs).not.toContain(missingRemoteUrl) }), ) const remoteOkSeen: { wellKnown?: string; remote?: string } = {} const remoteOkIt = it(remoteOk(remoteOkSeen)) -remoteOkIt.instance( - "success path unchanged: remote config merges, no degradation warning", - () => - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(remoteOkSeen.wellKnown).toBe("https://example.com/.well-known/opencode") - expect(remoteOkSeen.remote).toBe("https://config.example.com/opencode.json") - expect(config.mcp?.confluence?.enabled).toBe(true) - expect(JSON.stringify(yield* TestConsole.logLines)).not.toContain("failed to fetch remote config") - }), +remoteOkIt.instance("success path unchanged: remote config merges, no degradation warning", () => + Effect.gen(function* () { + const config = yield* Config.use.get() + expect(remoteOkSeen.wellKnown).toBe("https://example.com/.well-known/opencode") + expect(remoteOkSeen.remote).toBe("https://config.example.com/opencode.json") + expect(config.mcp?.confluence?.enabled).toBe(true) + expect(JSON.stringify(yield* logLines)).not.toContain("failed to fetch remote config") + }), ) const loginPageSeen: { wellKnown?: string; remote?: string } = {} const loginPageIt = it(loginPage(loginPageSeen)) -loginPageIt.instance( - "HTML login page stays a hard auth failure even with degradation enabled", - () => - Effect.gen(function* () { - const exit = yield* Config.use.get().pipe(Effect.exit) - expect(loginPageSeen.remote).toBe("https://config.example.com/opencode.json") - expect(Exit.isFailure(exit)).toBe(true) - }), +loginPageIt.instance("HTML login page stays a hard auth failure even with degradation enabled", () => + Effect.gen(function* () { + const exit = yield* Config.use.get().pipe(Effect.exit) + expect(loginPageSeen.remote).toBe("https://config.example.com/opencode.json") + expect(Exit.isFailure(exit)).toBe(true) + }), ) -const bodyReadFailsIt = it(bodyReadFails) +const bodyReadMissingOrigin = "https://body-read-missing.example.com" +const bodyReadFailsIt = it(bodyReadFails, bodyReadMissingOrigin) bodyReadFailsIt.instance( "wellknown body-read failure degrades: config loads, local config intact, warning logged", @@ -224,9 +567,11 @@ bodyReadFailsIt.instance( const config = yield* Config.use.get() expect(config.model).toBe("local/model") expect(config.mcp?.jira?.enabled).toBe(true) - const logs = JSON.stringify(yield* TestConsole.logLines) + const logs = JSON.stringify(yield* logLines) expect(logs).toContain("failed to read remote config") - expect(logs).toContain("https://example.com/.well-known/opencode") + expect(logs).toContain("d0d0bbaef7a071d010d7223883169215741dd80cbb362b68b93e30d5f531013a") + expect(logs).toContain("well-known") + expect(logs).not.toContain(`${bodyReadMissingOrigin}/.well-known/opencode`) }), { config: { From 9b0897db97615a3f85c414b663557ac75dac97cb Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 9 Aug 2026 02:47:35 +0800 Subject: [PATCH 16/17] docs(dag): close recovery diagnosis no-fix --- .../08-s7-recovery-invented-diagnosis.md | 20 ++++--- .scratch/batch-b/s7-diagnosis.md | 53 +++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 .scratch/batch-b/s7-diagnosis.md diff --git a/.scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md b/.scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md index 1f44853a0d..fcc13269c1 100644 --- a/.scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md +++ b/.scratch/batch-b/issues/08-s7-recovery-invented-diagnosis.md @@ -6,10 +6,18 @@ **Evidence:** `.scratch/batch-b/evidence.md#s7--recovery-invented-推断` **Branch:** `test/recovery-diagnosis` **Blocked by:** 无(07 已关闭) -**Status:** ready-for-agent +**Status:** closed-no-fix -- [ ] 第一项产出是一条确定性、快速、可由 agent 重复运行且能红灯的命令;在此之前不写理论/修复 -- [ ] 症状必须包含“durable transcript 语义完成”与“reconcile 实际写 failed”,不能只单测 helper 返回 active -- [ ] 红灯成立后才列 3–5 个可证伪假设、最小化复现并另开独立修复票 -- [ ] 无法建立反馈回路时记录尝试和阻塞原因,以 no-fix 关闭 -- [ ] 不把既有 ownershipLost → workflow pause 缓解误报为未覆盖 +- [x] 第一项产出是一条确定性、快速、可由 agent 重复运行且能红灯的命令;在此之前不写理论/修复 +- [x] 症状必须包含“durable transcript 语义完成”与“reconcile 实际写 failed”,不能只单测 helper 返回 active +- [x] 红灯成立后才列 3–5 个可证伪假设、最小化复现并另开独立修复票(未出现红灯,因此未进入该阶段) +- [x] 无法建立反馈回路时记录尝试和阻塞原因,以 no-fix 关闭(已建立反馈回路且症状未复现,按 no-fix 关闭) +- [x] 不把既有 ownershipLost → workflow pause 缓解误报为未覆盖 + +## 关闭证据 + +- 命令:`cd packages/opencode && bun test test/dag/dag-recovery-transcript-diagnosis.test.ts` +- 三次结果:均为 `2 pass / 0 fail`,约 `1.13s`。 +- 完成态:真实 durable transcript `finish: "stop"` 经 `DagLoop.init → reconcileWorkflow` 后持久化为 `completed`,未写 `exec_failed`。 +- 对照态:真实 durable transcript `finish: "tool-calls"` 经同一路径持久化为 `failed/exec_failed`,workflow 随后被既有 recovery-pause 置为 `paused`。 +- 完整报告:`.scratch/batch-b/s7-diagnosis.md`;未创建票 09。 diff --git a/.scratch/batch-b/s7-diagnosis.md b/.scratch/batch-b/s7-diagnosis.md new file mode 100644 index 0000000000..aae0f0126c --- /dev/null +++ b/.scratch/batch-b/s7-diagnosis.md @@ -0,0 +1,53 @@ +# S7 — recovery INVENTED 推断诊断 + +- **Status:** closed-no-fix +- **基线:** `dev@18273554f4f2c18cab1370922eb1ec004ba5bad9` +- **分支:** `test/recovery-diagnosis` +- **日期:** 2026-08-09 + +## 反馈回路 + +从 `packages/opencode` 运行: + +```bash +bun test test/dag/dag-recovery-transcript-diagnosis.test.ts +``` + +诊断期间使用的 throwaway 测试已删除。它走过以下真实持久化链路: + +1. `Session.layer` 通过 `Session.updateMessage` 发布 durable transcript 事件,`SessionProjector` 写入数据库;测试再用 `Session.messages` 读回并断言完成边界。 +2. `DagLoop.init` 扫描 durable running workflow,进入 `recoverWorkflow`。 +3. `makeSessionStatusChecker` 通过真实 `Session.get/messages` 读取 child transcript,`reconcileWorkflow` 作出 settlement。 +4. `Dag.nodeCompleted/nodeFailed` 发布事件,`DagProjector` 投影到真实 `DagStore`;测试直接读取 node/workflow 持久化结果。 + +观测断言: + +| 输入 | transcript 证据 | 实际持久化结果 | workflow 结果 | +|---|---|---|---| +| 语义完成 | 最后一条 assistant 为 `finish: "stop"`,并带 `time.completed` | node `completed`,`errorClass: null` | `completed` | +| red-capable 对照 | 最后一条 assistant 为 `finish: "tool-calls"` | node `failed`,`errorClass: "exec_failed"` | `paused` | + +## 运行结果 + +最终 `DagLoop.init` seam 连续运行三次,均为 `2 pass / 0 fail`: + +| 次数 | 结果 | 耗时 | +|---|---|---| +| 1 | `2 pass / 0 fail` | `1.129s` | +| 2 | `2 pass / 0 fail` | `1.131s` | +| 3 | `2 pass / 0 fail` | `1.133s` | + +首次运行因工作区尚未安装 `@opentui/solid/preload`,在加载测试前退出;执行 `bun install --no-save` 后依赖就绪,未修改 lockfile。随后先在真实 `Session → reconcileWorkflow → DagStore` seam 连续跑绿三次,再收紧到上述 `DagLoop.init` seam 并连续跑绿三次。 + +## 语义边界核对 + +- `packages/opencode/src/session/prompt.ts` 的真实 loop 只有在最后 assistant 已有 finish、finish 不是 `tool-calls`、没有待处理 tool call 且 assistant 位于最后 user 之后时才走完成退出。 +- 同一 loop 将 `tool-calls` 与 `unknown` 明确视为需要 continuation;因此这两类 transcript 不能作为“系统语义已经完成”的证据。 +- 测试没有使用自定义完成布尔值。完成态由真实持久化 transcript 中的 `finish: "stop"` 与 `time.completed` 证明,并经生产 `Session.messages` 读回。 +- `tool-calls` 对照确实经过 recovery 写入 `exec_failed`,随后触发现有 `ownershipLost → workflow pause` 缓解;该行为用于证明反馈回路可红,不作为新缺陷上报。 + +## 结论 + +精确症状“已语义完成的 durable transcript 被 recovery 判为 active/ownershipLost,并实际持久化 `exec_failed`”未复现。完成态 transcript 在真实 recovery/loop 调用链中稳定投影为 node/workflow `completed`;会写 `exec_failed` 的对照 transcript 按现有 Session loop 语义仍需 continuation。 + +本票不修改生产代码,不保留诊断测试,不创建猜测性修复票,也不创建票 09。 From 63103cf398b109b362ca4356f7260577137a8069 Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 9 Aug 2026 03:32:52 +0800 Subject: [PATCH 17/17] docs(dag): close p8 observation --- .../issues/01-p8-spawn-ready-observation.md | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/.scratch/batch-c/issues/01-p8-spawn-ready-observation.md b/.scratch/batch-c/issues/01-p8-spawn-ready-observation.md index 4f5313f378..d4be883719 100644 --- a/.scratch/batch-c/issues/01-p8-spawn-ready-observation.md +++ b/.scratch/batch-c/issues/01-p8-spawn-ready-observation.md @@ -3,10 +3,28 @@ **What to build:** 记录 `spawnReady O(ready × nodes)` 是否有实际性能证据。没有 trace/benchmark/用户痛点时,以 no-code 关闭;不得仅凭静态复杂度实施缓存或索引改造。 **Evidence:** `.scratch/batch-b/evidence.md#p8--spawnready-复杂度` +**Branch:** `docs/p8-observation` **Blocked by:** None -**Status:** deferred-nonblocking +**Status:** closed-no-code -- [ ] 搜集已有生产 trace、benchmark 或明确用户场景,不为本票新造大规模优化工程 -- [ ] 无量化证据:记录“当前不做”与重开阈值,状态改 closed-no-code -- [ ] 有量化证据:另开 `/improve-codebase-architecture` 设计票,写明基线与目标 -- [ ] 本观测票本身不改 `spawnReady` +- [x] 搜集已有生产 trace、benchmark 或明确用户场景,不为本票新造大规模优化工程 +- [x] 无量化证据:记录“当前不做”与重开阈值,状态改 closed-no-code +- [x] 有量化证据:另开 `/improve-codebase-architecture` 设计票,写明基线与目标(本次无量化证据,因此未开票) +- [x] 本观测票本身不改 `spawnReady` + +## 关闭结论 + +- 已复核 promotion evidence 与 DAG deep-review 报告;只有静态 `O(ready × nodes)` 推断。 +- 未发现生产 trace、可重复 benchmark 或明确用户场景能把可感知延迟归因到 `spawnReady`。 +- 当前不增加索引或缓存;在没有收益基线时,这类状态会扩大一致性与失效维护面。 +- 本票仅记录裁决,生产代码零改动。 + +## 重开阈值 + +满足任一条件时重开独立性能设计票: + +1. 用户态或生产 profile 将调度延迟明确归因到 `spawnReady`。 +2. 可重复 benchmark 显示 `spawnReady` 占一次 wake 调度耗时的 10% 以上。 +3. 实际工作流规模长期超过当前评审采用的 50 节点观察区间。 + +重开后必须先记录基线、目标与代表性图规模,再选择索引或缓存方案。