From 28d65863732bc658abb24cd5807274c5c2e908bd Mon Sep 17 00:00:00 2001 From: AndreasS Date: Sat, 3 Oct 2026 05:34:02 +0200 Subject: [PATCH 1/3] feat(loop): crew claims as shared files --- packages/core/schema.json | 255 +++++++++++++++++ packages/core/src/database/migration.gen.ts | 1 + .../migration/20260919055032_add_claims.ts | 37 +++ packages/core/src/database/schema.gen.ts | 27 ++ packages/opencode/src/loop/crew/claims.ts | 256 ++++++++++++++++++ .../opencode/test/loop/crew/claims.test.ts | 146 ++++++++++ 6 files changed, 722 insertions(+) create mode 100644 packages/core/src/database/migration/20260919055032_add_claims.ts create mode 100644 packages/opencode/src/loop/crew/claims.ts create mode 100644 packages/opencode/test/loop/crew/claims.test.ts diff --git a/packages/core/schema.json b/packages/core/schema.json index d0eeeebd5c41..b1a30f6ebfc0 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -80,6 +80,10 @@ "name": "session_share", "entityType": "tables" }, + { + "name": "claims", + "entityType": "tables" + }, { "type": "text", "notNull": false, @@ -1480,6 +1484,186 @@ "entityType": "columns", "table": "session_share" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "implement", + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "holder_session", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "holder_harness", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "holder_name", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "gate", + "entityType": "columns", + "table": "claims" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "since", + "entityType": "columns", + "table": "claims" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "heartbeat", + "entityType": "columns", + "table": "claims" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "released_at", + "entityType": "columns", + "table": "claims" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "release_reason", + "entityType": "columns", + "table": "claims" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "claims" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "claims" + }, { "columns": ["project_id"], "tableTo": "project", @@ -1623,6 +1807,17 @@ "entityType": "fks", "table": "session_share" }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_claims_project_id_project_id_fk", + "entityType": "fks", + "table": "claims" + }, { "columns": ["email", "url"], "nameExplicit": false, @@ -2065,6 +2260,66 @@ "name": "todo_session_idx", "entityType": "indexes", "table": "todo" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "where": "released_at IS NULL", + "origin": "manual", + "name": "claims_project_slug_live_idx", + "entityType": "indexes", + "table": "claims" + }, + { + "columns": [ + { + "value": "holder_session", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "claims_holder_idx", + "entityType": "indexes", + "table": "claims" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "claims_project_idx", + "entityType": "indexes", + "table": "claims" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "claims_slug_idx", + "entityType": "indexes", + "table": "claims" } ], "renames": [] diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index e6ea4eaa1477..f39a321bcc4c 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -40,5 +40,6 @@ export const migrations = ( import("./migration/20260622142730_simplify_session_context_epoch"), import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622202450_simplify_session_input"), + import("./migration/20260919055032_add_claims"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260919055032_add_claims.ts b/packages/core/src/database/migration/20260919055032_add_claims.ts new file mode 100644 index 000000000000..50eda66d4aa2 --- /dev/null +++ b/packages/core/src/database/migration/20260919055032_add_claims.ts @@ -0,0 +1,37 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260919055032_add_claims", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`claims\` ( + \`id\` text PRIMARY KEY, + \`project_id\` text NOT NULL, + \`slug\` text NOT NULL, + \`kind\` text DEFAULT 'implement' NOT NULL, + \`holder_session\` text, + \`holder_harness\` text, + \`holder_name\` text, + \`branch\` text, + \`worktree\` text, + \`provider_id\` text, + \`model_id\` text, + \`gate\` text, + \`since\` integer NOT NULL, + \`heartbeat\` integer NOT NULL, + \`released_at\` integer, + \`release_reason\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`fk_claims_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`CREATE UNIQUE INDEX \`claims_project_slug_live_idx\` ON \`claims\` (\`project_id\`,\`slug\`) WHERE \`released_at\` IS NULL;`) + yield* tx.run(`CREATE INDEX \`claims_holder_idx\` ON \`claims\` (\`holder_session\`);`) + yield* tx.run(`CREATE INDEX \`claims_project_idx\` ON \`claims\` (\`project_id\`);`) + yield* tx.run(`CREATE INDEX \`claims_slug_idx\` ON \`claims\` (\`slug\`);`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index ed60fde6c55f..05e27e0643b7 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -236,6 +236,33 @@ export default { CONSTRAINT \`fk_session_share_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`claims\` ( + \`id\` text PRIMARY KEY, + \`project_id\` text NOT NULL, + \`slug\` text NOT NULL, + \`kind\` text DEFAULT 'implement' NOT NULL, + \`holder_session\` text, + \`holder_harness\` text, + \`holder_name\` text, + \`branch\` text, + \`worktree\` text, + \`provider_id\` text, + \`model_id\` text, + \`gate\` text, + \`since\` integer NOT NULL, + \`heartbeat\` integer NOT NULL, + \`released_at\` integer, + \`release_reason\` text, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`fk_claims_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run(`CREATE UNIQUE INDEX \`claims_project_slug_live_idx\` ON \`claims\` (\`project_id\`,\`slug\`) WHERE \`released_at\` IS NULL;`) + yield* tx.run(`CREATE INDEX \`claims_holder_idx\` ON \`claims\` (\`holder_session\`);`) + yield* tx.run(`CREATE INDEX \`claims_project_idx\` ON \`claims\` (\`project_id\`);`) + yield* tx.run(`CREATE INDEX \`claims_slug_idx\` ON \`claims\` (\`slug\`);`) yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`) yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`) yield* tx.run( diff --git a/packages/opencode/src/loop/crew/claims.ts b/packages/opencode/src/loop/crew/claims.ts new file mode 100644 index 000000000000..8caeb520be85 --- /dev/null +++ b/packages/opencode/src/loop/crew/claims.ts @@ -0,0 +1,256 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { ClaimTable, ClaimKind, ReleaseReason } from "@opencode-ai/core/loop/sql" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Context, Effect, Layer, Schema } from "effect" +import { statics } from "@opencode-ai/schema/schema" +import { Identifier } from "@/id/id" +import { and, eq, isNull } from "drizzle-orm" + +export const DefaultStaleAfterMs = 60_000 + +export const ClaimID = Schema.String.check(Schema.isStartsWith("claim")).pipe( + Schema.brand("ClaimID"), + statics((s) => ({ + ascending: () => s.make(Identifier.create("claim", "ascending")), + })), +) +export type ClaimID = Schema.Schema.Type + +export const ClaimInfo = Schema.Struct({ + id: ClaimID, + projectID: ProjectV2.ID, + slug: Schema.String, + kind: Schema.Literals([...ClaimKind]), + holderSession: Schema.optional(Schema.String), + holderHarness: Schema.optional(Schema.String), + holderName: Schema.optional(Schema.String), + branch: Schema.optional(Schema.String), + worktree: Schema.optional(Schema.String), + providerID: Schema.optional(ProviderV2.ID), + modelID: Schema.optional(ModelV2.ID), + gate: Schema.optional(Schema.String), + since: Schema.Finite, + heartbeat: Schema.Finite, + releasedAt: Schema.optional(Schema.Finite), + releaseReason: Schema.optional(Schema.Literals([...ReleaseReason])), +}) +.annotate({ identifier: "ClaimInfo" }) +export type ClaimInfo = Schema.Schema.Type + +export interface ClaimRow { + id: string + project_id: string + slug: string + kind: string + holder_session: string | null + holder_harness: string | null + holder_name: string | null + branch: string | null + worktree: AbsolutePath | null + provider_id: string | null + model_id: string | null + gate: string | null + since: number + heartbeat: number + released_at: number | null + release_reason: string | null +} + +function fromRow(row: ClaimRow): ClaimInfo { + return { + id: ClaimID.make(row.id), + projectID: ProjectV2.ID.make(row.project_id), + slug: row.slug, + kind: row.kind as ClaimKind, + holderSession: row.holder_session ?? undefined, + holderHarness: row.holder_harness ?? undefined, + holderName: row.holder_name ?? undefined, + branch: row.branch ?? undefined, + worktree: row.worktree ? AbsolutePath.make(row.worktree) : undefined, + providerID: row.provider_id ? ProviderV2.ID.make(row.provider_id) : undefined, + modelID: row.model_id ? ModelV2.ID.make(row.model_id) : undefined, + gate: row.gate ?? undefined, + since: row.since, + heartbeat: row.heartbeat, + releasedAt: row.released_at ?? undefined, + releaseReason: row.release_reason as ReleaseReason | undefined, + } +} + +export interface Interface { + readonly claim: (input: CreateClaimInput) => Effect.Effect + readonly heartbeat: (id: ClaimID) => Effect.Effect + readonly release: (id: ClaimID, reason?: ReleaseReason) => Effect.Effect + readonly listLive: (projectID: ProjectV2.ID) => Effect.Effect + readonly listAbandoned: (projectID: ProjectV2.ID, now?: number) => Effect.Effect +} + +export class ClaimExistsError extends Schema.TaggedErrorClass()("ClaimExistsError", { + slug: Schema.String, +}) {} + +export class ClaimNotFoundError extends Schema.TaggedErrorClass()("ClaimNotFoundError", { + id: ClaimID, +}) {} + +/** + * Distinct from `ClaimNotFoundError`: the row exists, but this claim is no + * longer live. A heartbeat on a released claim must say so rather than + * succeed silently — the caller needs to know its claim was lost (taken over + * as abandoned, or released from under it), not that the world is fine. + */ +export class ClaimReleasedError extends Schema.TaggedErrorClass()("ClaimReleasedError", { + id: ClaimID, +}) {} + +export interface CreateClaimInput { + projectID: ProjectV2.ID + slug: string + kind?: ClaimKind + holderSession?: string + holderHarness?: string + holderName?: string + branch?: string + worktree?: AbsolutePath + providerID?: ProviderV2.ID + modelID?: ModelV2.ID + gate?: string +} + +export class Service extends Context.Service()("@opencode/CrewClaim") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + + const get = Effect.fn("Claim.get")(function* (id: ClaimID) { + const rows = yield* db.select().from(ClaimTable).where(eq(ClaimTable.id, id as string)).all().pipe(Effect.orDie) + const [found] = rows + if (!found) return yield* new ClaimNotFoundError({ id }) + return fromRow(found as ClaimRow) + }) + + const claim = Effect.fn("Claim.claim")(function* (input: CreateClaimInput) { + const now = Date.now() + const id = ClaimID.ascending() + const inserted = Effect.gen(function* () { + yield* db + .insert(ClaimTable) + .values([ + { + id, + project_id: input.projectID, + slug: input.slug, + kind: input.kind ?? "implement", + holder_session: input.holderSession ?? null, + holder_harness: input.holderHarness ?? null, + holder_name: input.holderName ?? null, + branch: input.branch ?? null, + worktree: input.worktree ?? null, + provider_id: input.providerID ?? null, + model_id: input.modelID ?? null, + gate: input.gate ?? null, + since: now, + heartbeat: now, + }, + ]) + .run() + }).pipe(Effect.catch((err: unknown) => + isUniqueViolation(err) + ? Effect.fail(new ClaimExistsError({ slug: input.slug })) + : Effect.die(err as never), + )) + yield* inserted + return yield* get(ClaimID.make(id)) + }) + + // `heartbeat` and `release` are both guarded UPDATEs that must not + // silently no-op: a heartbeat that WHERE-excludes a released row and + // still reports success is indistinguishable, to its caller, from a + // heartbeat that actually landed — the exact bug an exclusivity + // primitive cannot have. `.returning()` gives the affected row (or none) + // in the same round trip, with no separate read-then-write race window + // and no reliance on a driver-specific rows-changed count. + + const heartbeat = Effect.fn("Claim.heartbeat")(function* (id: ClaimID) { + const now = Date.now() + const touched = yield* db + .update(ClaimTable) + .set({ heartbeat: now }) + .where(and(eq(ClaimTable.id, id as string), isNull(ClaimTable.released_at))) + .returning({ id: ClaimTable.id }) + .get() + .pipe(Effect.orDie) + if (!touched) { + // Either the id never existed, or it did and is released — get() + // tells the two apart, propagating ClaimNotFoundError itself when + // that's the real answer, so the caller hears the right one. + const current = yield* get(id) + return yield* new ClaimReleasedError({ id: current.id }) + } + return yield* get(id) + }) + + const release = Effect.fn("Claim.release")(function* (id: ClaimID, reason?: ReleaseReason) { + const now = Date.now() + // Guarded the same way: a second release must not overwrite the first + // release's timestamp and reason with its own — the claim was already + // let go, and whatever released it first is the true history. + yield* db + .update(ClaimTable) + .set({ released_at: now, release_reason: reason ?? null }) + .where(and(eq(ClaimTable.id, id as string), isNull(ClaimTable.released_at))) + .returning({ id: ClaimTable.id }) + .get() + .pipe(Effect.orDie) + // Releasing twice is a normal race to say "I'm done" — idempotent by + // design, unlike heartbeat, where the caller specifically needs to + // know it lost the claim. Either way the WHERE guard already made sure + // only the FIRST release's timestamp and reason stick; what changed or + // didn't, get() reports the same either way. + return yield* get(id) + }) + + const listLive = Effect.fn("Claim.listLive")(function* (projectID: ProjectV2.ID) { + const rows = yield* db + .select() + .from(ClaimTable) + .where(and(eq(ClaimTable.project_id, projectID), isNull(ClaimTable.released_at))) + .all() + .pipe(Effect.orDie) + return rows.map(fromRow) + }) + + const listAbandoned = Effect.fn("Claim.listAbandoned")(function* (projectID: ProjectV2.ID, now = Date.now()) { + const rows = yield* db + .select() + .from(ClaimTable) + .where(and(eq(ClaimTable.project_id, projectID), isNull(ClaimTable.released_at))) + .all() + .pipe(Effect.orDie) + return rows.filter((row) => now - row.heartbeat >= DefaultStaleAfterMs).map(fromRow) + }) + + return Service.of({ claim, heartbeat, release, listLive, listAbandoned }) + }), +) + +export const node = LayerNode.make({ service: Service, layer, deps: [Database.node] }) + +function isUniqueViolation(err: unknown): boolean { + if (err == null) return false + const text = + err instanceof Error + ? `${err.message} ${err.cause ? String(err.cause) : ""}` + : String(err) + return text.includes("UNIQUE constraint failed") +} + +export { ClaimTable, ClaimKind, ReleaseReason } + +export * as Claim from "./claims" \ No newline at end of file diff --git a/packages/opencode/test/loop/crew/claims.test.ts b/packages/opencode/test/loop/crew/claims.test.ts new file mode 100644 index 000000000000..bae3a19a15bc --- /dev/null +++ b/packages/opencode/test/loop/crew/claims.test.ts @@ -0,0 +1,146 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import { Database } from "@opencode-ai/core/database/database" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ClaimTable } from "@opencode-ai/core/loop/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Project, ProjectV2 } from "@opencode-ai/core/project" +import { testEffect } from "../../lib/effect" +import { Claim } from "@/loop/crew/claims" + +const it = testEffect( + LayerNode.compile(LayerNode.group([Claim.node, Database.node, Project.node])), +) + +const projectID = ProjectV2.ID.make("test-project") + +function seedProject() { + return Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values([ + { + id: projectID, + worktree: AbsolutePath.make("/tmp/test-project"), + vcs: "git", + name: "Test", + sandboxes: [], + }, + ]) + .run() + .pipe(Effect.orDie) + }) +} + +describe("Claim", () => { + it.effect("atomic claim rejects a second holder", () => + Effect.gen(function* () { + yield* seedProject() + const service = yield* Claim.Service + + yield* service.claim({ + projectID, + slug: "crew-loop-1", + holderSession: "ses-first", + holderName: "agent-1", + worktree: AbsolutePath.make("/tmp/crew-loop-1"), + }) + + const conflict = yield* service + .claim({ projectID, slug: "crew-loop-1", holderSession: "ses-second" }) + .pipe(Effect.catch(() => Effect.succeed(true))) + expect(conflict).toBe(true) + }), + ) + + it.effect("heartbeat refreshes staleness", () => + Effect.gen(function* () { + yield* seedProject() + const service = yield* Claim.Service + + const created = yield* service.claim({ projectID, slug: "crew-loop-2", holderSession: "ses-1" }) + const refreshed = yield* service.heartbeat(created.id) + expect(refreshed.heartbeat).toBeGreaterThanOrEqual(created.heartbeat) + }), + ) + + it.effect("release marks a claim released and removes it from live", () => + Effect.gen(function* () { + yield* seedProject() + const service = yield* Claim.Service + + const created = yield* service.claim({ projectID, slug: "crew-loop-3", holderSession: "ses-1" }) + const released = yield* service.release(created.id, "completed") + expect(released.releasedAt).toBeDefined() + const live = yield* service.listLive(projectID) + expect(live).toHaveLength(0) + }), + ) + + it.effect("release is idempotent: a second release does not overwrite the first", () => + Effect.gen(function* () { + yield* seedProject() + const service = yield* Claim.Service + + const created = yield* service.claim({ projectID, slug: "crew-loop-3b", holderSession: "ses-1" }) + const first = yield* service.release(created.id, "completed") + // A colleague, or the same holder retrying, releasing again must not + // stamp a new time or a different reason over the original release. + const second = yield* service.release(created.id, "abandoned") + expect(second.releasedAt).toBe(first.releasedAt) + expect(second.releaseReason).toBe("completed") + }), + ) + + it.effect("heartbeat on an already-released claim reports the loss, not success", () => + Effect.gen(function* () { + yield* seedProject() + const service = yield* Claim.Service + + const created = yield* service.claim({ projectID, slug: "crew-loop-3c", holderSession: "ses-1" }) + yield* service.release(created.id, "abandoned") + + const tag = yield* service.heartbeat(created.id).pipe(Effect.catch((err) => Effect.succeed(err._tag))) + expect(tag).toBe("ClaimReleasedError") + }), + ) + + it.effect("listLive shows only live claims", () => + Effect.gen(function* () { + yield* seedProject() + const service = yield* Claim.Service + + yield* service.claim({ projectID, slug: "crew-loop-4", holderSession: "ses-1" }) + yield* service.claim({ projectID, slug: "crew-loop-5", holderSession: "ses-2" }) + const live = yield* service.listLive(projectID) + expect(live).toHaveLength(2) + }), + ) + + it.effect("listAbandoned detects stale claims", () => + Effect.gen(function* () { + yield* seedProject() + const service = yield* Claim.Service + + yield* service.claim({ projectID, slug: "crew-loop-6", holderSession: "ses-1" }) + yield* service.claim({ projectID, slug: "crew-loop-7", holderSession: "ses-2" }) + + const { db } = yield* Database.Service + const staleSince = Date.now() - 20 * Claim.DefaultStaleAfterMs + yield* db + .update(ClaimTable) + .set({ heartbeat: staleSince }) + .where(eq(ClaimTable.slug, "crew-loop-6")) + .run() + .pipe(Effect.orDie) + + const abandoned = yield* service.listAbandoned(projectID) + const abandonedSlugs = abandoned.map((c) => c.slug) + expect(abandonedSlugs).toContain("crew-loop-6") + expect(abandonedSlugs).not.toContain("crew-loop-7") + }), + ) +}) From c660592db3858df779e3f69922d40e12a7c54102 Mon Sep 17 00:00:00 2001 From: AndreasS Date: Sat, 3 Oct 2026 05:35:45 +0200 Subject: [PATCH 2/3] feat(loop): add core/loop/sql.ts ClaimTable shared module --- packages/core/src/loop/sql.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 packages/core/src/loop/sql.ts diff --git a/packages/core/src/loop/sql.ts b/packages/core/src/loop/sql.ts new file mode 100644 index 000000000000..b06be2cbbb5c --- /dev/null +++ b/packages/core/src/loop/sql.ts @@ -0,0 +1,33 @@ +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../database/schema.sql" +import { ProjectTable } from "../project/sql" +import { AbsolutePath } from "../schema" + +export const ClaimKind = ["implement", "review", "integrate", "triage", "decide"] as const +export type ClaimKind = (typeof ClaimKind)[number] + +export const ReleaseReason = ["completed", "quarantined", "abandoned", "cancelled", "human"] as const +export type ReleaseReason = (typeof ReleaseReason)[number] + +export const ClaimTable = sqliteTable("claims", { + id: text().primaryKey(), + project_id: text() + .$type() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + slug: text().notNull(), + kind: text().$type().notNull().default("implement"), + holder_session: text(), + holder_harness: text(), + holder_name: text(), + branch: text(), + worktree: text().$type(), + provider_id: text(), + model_id: text(), + gate: text(), + since: integer().notNull(), + heartbeat: integer().notNull(), + released_at: integer(), + release_reason: text().$type(), + ...Timestamps, +}) From 3e9de1f8b08d5bd85c4ebaa3477fd7bfc26c746c Mon Sep 17 00:00:00 2001 From: AndreasS Date: Sat, 3 Oct 2026 05:56:17 +0200 Subject: [PATCH 3/3] chore(fork): register crew claims files in manifest --- fork/manifest.json | 213 +++++++++++++++++++++++---------------------- 1 file changed, 110 insertions(+), 103 deletions(-) diff --git a/fork/manifest.json b/fork/manifest.json index f9ea483df4ca..2144a882ceea 100644 --- a/fork/manifest.json +++ b/fork/manifest.json @@ -11,10 +11,23 @@ }, "owned": [ "packages/app/src/components/dialog-local-discovery.tsx", + "packages/app/src/components/settings-v2/models-discover.tsx", + "packages/app/src/components/settings-v2/models-gallery.test.ts", + "packages/app/src/components/settings-v2/models-gallery.ts", + "packages/app/src/components/settings-v2/models-hosts.tsx", + "packages/app/src/components/settings-v2/models-installed.tsx", + "packages/app/src/components/settings-v2/models-operations.tsx", + "packages/core/schema.json", "packages/core/src/config/event-journal.ts", + "packages/core/src/database/migration.gen.ts", + "packages/core/src/database/migration/20260919055032_add_claims.ts", + "packages/core/src/database/schema.gen.ts", "packages/core/src/event/retention.ts", "packages/core/src/local/theme-state.ts", + "packages/core/src/loop/sql.ts", "packages/core/test/event-retention.test.ts", + "packages/opencode/script/build-local.ts", + "packages/opencode/src/agent/presence-claude.ts", "packages/opencode/src/agent/presence-status.ts", "packages/opencode/src/agent/presence.ts", "packages/opencode/src/auto-mode/service.ts", @@ -22,9 +35,12 @@ "packages/opencode/src/beads/config.ts", "packages/opencode/src/beads/mapping.ts", "packages/opencode/src/beads/sync.ts", + "packages/opencode/src/cli/cmd/agents.ts", "packages/opencode/src/cli/cmd/beads.ts", + "packages/opencode/src/cli/cmd/debug/claude-sidecar-entry.ts", "packages/opencode/src/cli/cmd/lead.ts", "packages/opencode/src/cli/cmd/loop.ts", + "packages/opencode/src/cli/cmd/permission.ts", "packages/opencode/src/context/README.md", "packages/opencode/src/context/index.ts", "packages/opencode/src/fork/commands.ts", @@ -32,6 +48,7 @@ "packages/opencode/src/local/capacity.ts", "packages/opencode/src/local/config-lock.ts", "packages/opencode/src/local/ctx-fit.ts", + "packages/opencode/src/local/ignored.ts", "packages/opencode/src/local/llama-skein/gen/client.gen.ts", "packages/opencode/src/local/llama-skein/gen/client/client.gen.ts", "packages/opencode/src/local/llama-skein/gen/client/index.ts", @@ -49,7 +66,6 @@ "packages/opencode/src/local/llama-skein/gen/sdk.gen.ts", "packages/opencode/src/local/llama-skein/gen/types.gen.ts", "packages/opencode/src/local/mdns.ts", - "packages/opencode/src/local/peer-registry-node.ts", "packages/opencode/src/local/model-catalog/adoption-manifest.ts", "packages/opencode/src/local/model-catalog/cache.ts", "packages/opencode/src/local/model-catalog/family.ts", @@ -63,27 +79,48 @@ "packages/opencode/src/local/model-gallery/filter.ts", "packages/opencode/src/local/model-gallery/fit.ts", "packages/opencode/src/local/model-gallery/hosts.ts", + "packages/opencode/src/local/model-gallery/install.ts", "packages/opencode/src/local/model-gallery/join.ts", + "packages/opencode/src/local/model-gallery/manage.ts", + "packages/opencode/src/local/model-gallery/operations.ts", "packages/opencode/src/local/model-gallery/rank.ts", + "packages/opencode/src/local/model-gallery/search.ts", + "packages/opencode/src/local/peer-registry-node.ts", "packages/opencode/src/local/placement.ts", "packages/opencode/src/local/skein-loading.ts", "packages/opencode/src/local/sync.ts", "packages/opencode/src/loop/completion.ts", "packages/opencode/src/loop/continuation.ts", + "packages/opencode/src/loop/crew/claims.ts", "packages/opencode/src/loop/loop.ts", "packages/opencode/src/loop/similarity.ts", "packages/opencode/src/loop/spec-queue/authority.ts", + "packages/opencode/src/loop/spec-queue/branch-read.ts", "packages/opencode/src/loop/spec-queue/brief.ts", "packages/opencode/src/loop/spec-queue/gates.ts", "packages/opencode/src/loop/spec-queue/personas.ts", "packages/opencode/src/loop/spec-queue/queue.ts", "packages/opencode/src/loop/spec-queue/tasks-md.ts", - "packages/opencode/src/loop/spec-queue/branch-read.ts", "packages/opencode/src/loop/stop-reason.ts", + "packages/opencode/src/peer/claude/client.ts", + "packages/opencode/src/peer/claude/codec.ts", + "packages/opencode/src/peer/claude/lifecycle.ts", + "packages/opencode/src/peer/claude/registry.ts", + "packages/opencode/src/peer/claude/resolve.ts", + "packages/opencode/src/peer/claude/sidecar-entry.ts", + "packages/opencode/src/peer/claude/sidecar-manager.ts", + "packages/opencode/src/peer/claude/sidecar-registry.ts", + "packages/opencode/src/peer/claude/sidecar-server.ts", + "packages/opencode/src/peer/delegate.ts", + "packages/opencode/src/peer/envelope.ts", + "packages/opencode/src/peer/inbox.ts", "packages/opencode/src/peer/lead-confirm.ts", "packages/opencode/src/peer/lead-issue.ts", "packages/opencode/src/peer/lead-runtime.ts", "packages/opencode/src/peer/lead.ts", + "packages/opencode/src/peer/recent-ids.ts", + "packages/opencode/src/peer/repeat-guard.ts", + "packages/opencode/src/peer/route.ts", "packages/opencode/src/policy/drivers.ts", "packages/opencode/src/policy/merge-run.ts", "packages/opencode/src/policy/publish-policy.ts", @@ -101,25 +138,30 @@ "packages/opencode/src/session/loop-detect.ts", "packages/opencode/src/session/peers.ts", "packages/opencode/src/session/prompt/instella.txt", - "packages/opencode/src/side-question/index.ts", - "packages/opencode/src/cli/cmd/permission.ts", "packages/opencode/src/session/unattended.ts", + "packages/opencode/src/side-question/index.ts", "packages/opencode/src/tool/peers.ts", "packages/opencode/src/tool/peers.txt", "packages/opencode/src/tool/send-peer-message.ts", "packages/opencode/src/tool/send-peer-message.txt", + "packages/opencode/src/util/git-branch.ts", "packages/opencode/src/util/which.ts", + "packages/opencode/test/agent/presence-claude.test.ts", "packages/opencode/test/agent/presence.test.ts", "packages/opencode/test/agent/repo-personas.test.ts", + "packages/opencode/test/config/env-reference-survives.test.ts", "packages/opencode/test/context/index.test.ts", "packages/opencode/test/fixtures/mcp-dual-era/server.ts", "packages/opencode/test/fixtures/mcp-legacy/server.ts", "packages/opencode/test/local/capacity.test.ts", "packages/opencode/test/local/config-lock.test.ts", + "packages/opencode/test/local/ctx-fit.test.ts", "packages/opencode/test/local/discovery.integration.test.ts", "packages/opencode/test/local/fixtures/fleet-model-gallery/family-version-cases.json", "packages/opencode/test/local/fixtures/fleet-model-gallery/quant-cases.json", "packages/opencode/test/local/fixtures/fleet-model-gallery/upgrade-fresh-cases.json", + "packages/opencode/test/local/ignored.test.ts", + "packages/opencode/test/local/llama-skein-client-smoke.test.ts", "packages/opencode/test/local/mdns.test.ts", "packages/opencode/test/local/model-catalog-adoption-manifest.test.ts", "packages/opencode/test/local/model-catalog-cache.test.ts", @@ -131,25 +173,57 @@ "packages/opencode/test/local/model-catalog-seed.test.ts", "packages/opencode/test/local/model-gallery-api.test.ts", "packages/opencode/test/local/model-gallery-dataplane.test.ts", + "packages/opencode/test/local/model-gallery-install.test.ts", + "packages/opencode/test/local/model-gallery-manage.test.ts", "packages/opencode/test/local/model-gallery-ranking.test.ts", "packages/opencode/test/local/placement-policy.test.ts", "packages/opencode/test/local/placement.test.ts", "packages/opencode/test/local/skein-independence.test.ts", - "packages/opencode/test/local/ctx-fit.test.ts", - "packages/opencode/test/local/llama-skein-client-smoke.test.ts", + "packages/opencode/test/local/sync.test.ts", + "packages/opencode/test/loop/branch-read.test.ts", "packages/opencode/test/loop/completion.test.ts", "packages/opencode/test/loop/continuation.test.ts", + "packages/opencode/test/loop/crew/claims.test.ts", "packages/opencode/test/loop/loop-args.test.ts", + "packages/opencode/test/loop/loop-defaults.test.ts", "packages/opencode/test/loop/loop.test.ts", "packages/opencode/test/loop/personas.test.ts", "packages/opencode/test/loop/queue-authority.test.ts", "packages/opencode/test/loop/queue-mode.test.ts", "packages/opencode/test/loop/similarity.test.ts", "packages/opencode/test/loop/spec-queue.test.ts", - "packages/opencode/test/loop/branch-read.test.ts", - "packages/opencode/test/util/git-branch-repo.test.ts", + "packages/opencode/test/loop/stop-reason.test.ts", "packages/opencode/test/mcp/fixtures.test.ts", "packages/opencode/test/mcp/protocol-mode.test.ts", + "packages/opencode/test/peer/claude/client.test.ts", + "packages/opencode/test/peer/claude/codec.test.ts", + "packages/opencode/test/peer/claude/lifecycle-inject.test.ts", + "packages/opencode/test/peer/claude/probe-deliver-runtime.mts", + "packages/opencode/test/peer/claude/pty-repro.test.ts", + "packages/opencode/test/peer/claude/registry.test.ts", + "packages/opencode/test/peer/claude/sidecar-diagnostic-routing.test.ts", + "packages/opencode/test/peer/claude/sidecar-e2e.test.ts", + "packages/opencode/test/peer/claude/sidecar-manager.test.ts", + "packages/opencode/test/peer/claude/sidecar-registry.test.ts", + "packages/opencode/test/peer/claude/sidecar-server-name.test.ts", + "packages/opencode/test/peer/delegate.test.ts", + "packages/opencode/test/peer/envelope.test.ts", + "packages/opencode/test/peer/inbox.test.ts", + "packages/opencode/test/peer/lead-confirm.test.ts", + "packages/opencode/test/peer/lead-issue.test.ts", + "packages/opencode/test/peer/lead-runtime.test.ts", + "packages/opencode/test/peer/lead.test.ts", + "packages/opencode/test/peer/recent-ids.test.ts", + "packages/opencode/test/peer/repeat-guard.test.ts", + "packages/opencode/test/peer/route-delivery.test.ts", + "packages/opencode/test/peer/route.test.ts", + "packages/opencode/test/policy/drivers.test.ts", + "packages/opencode/test/policy/merge-run.test.ts", + "packages/opencode/test/policy/publish-policy-cache.test.ts", + "packages/opencode/test/policy/publish-policy-git.test.ts", + "packages/opencode/test/policy/publish-policy.test.ts", + "packages/opencode/test/policy/push-run.test.ts", + "packages/opencode/test/policy/review-record.test.ts", "packages/opencode/test/provider/merge-discovered-model.test.ts", "packages/opencode/test/provider/skein-loading.test.ts", "packages/opencode/test/server/local-ctx-size-guard.test.ts", @@ -157,10 +231,16 @@ "packages/opencode/test/session/host-paced-deadline.test.ts", "packages/opencode/test/session/loop-detect.test.ts", "packages/opencode/test/session/peers.test.ts", + "packages/opencode/test/session/unattended.test.ts", + "packages/opencode/test/tool/send-peer-message-ladder.test.ts", + "packages/opencode/test/tool/send-peer-message-text.test.ts", + "packages/opencode/test/tool/task-delegate.test.ts", "packages/opencode/test/tui/jsx-component-contract.test.ts", + "packages/opencode/test/util/git-branch-repo.test.ts", + "packages/tui/src/component/dialog-gallery.tsx", "packages/tui/src/component/dialog-loop-list.tsx", "packages/tui/src/component/dialog-model-ctx.tsx", - "packages/tui/test/component/dialog-model.test.ts", + "packages/tui/src/component/dialog-model-manage.tsx", "packages/tui/src/component/dialog-tuning.tsx", "packages/tui/src/local/llama-skein/gen/client.gen.ts", "packages/tui/src/local/llama-skein/gen/client/client.gen.ts", @@ -180,101 +260,28 @@ "packages/tui/src/local/model-fit.ts", "packages/tui/src/theme/assets/knight-rider.json", "packages/tui/src/theme/assets/pip-boy.json", + "packages/tui/test/component/dialog-model.test.ts", "packages/tui/test/loop-command-match.test.ts", "packages/tui/test/model-fit.test.ts", - "packages/tui/test/ui/dialog-select-footer.test.ts", "packages/tui/test/ui/dialog-model-fit.test.ts", "packages/tui/test/ui/dialog-select-fit.test.ts", - "packages/tui/test/ui/sidebar-context.test.ts", - "packages/opencode/src/agent/presence-claude.ts", - "packages/opencode/test/agent/presence-claude.test.ts", - "packages/opencode/src/peer/claude/registry.ts", - "packages/opencode/src/peer/claude/codec.ts", - "packages/opencode/src/peer/claude/client.ts", - "packages/opencode/src/peer/claude/resolve.ts", - "packages/opencode/src/peer/claude/sidecar-registry.ts", - "packages/opencode/test/peer/claude/registry.test.ts", - "packages/opencode/test/peer/claude/codec.test.ts", - "packages/opencode/test/peer/claude/client.test.ts", - "packages/opencode/test/peer/claude/sidecar-registry.test.ts", - "packages/opencode/src/util/git-branch.ts", - "packages/opencode/src/cli/cmd/agents.ts", - "packages/opencode/src/peer/claude/sidecar-server.ts", - "packages/opencode/src/peer/claude/sidecar-entry.ts", - "packages/opencode/src/peer/claude/sidecar-manager.ts", - "packages/opencode/test/peer/claude/sidecar-e2e.test.ts", - "packages/opencode/test/peer/claude/sidecar-manager.test.ts", - "packages/opencode/test/peer/claude/lifecycle-inject.test.ts", - "packages/opencode/src/peer/claude/lifecycle.ts", - "packages/opencode/src/peer/delegate.ts", - "packages/opencode/test/peer/delegate.test.ts", - "packages/opencode/script/build-local.ts", - "packages/opencode/src/cli/cmd/debug/claude-sidecar-entry.ts", - "packages/opencode/src/local/ignored.ts", - "packages/opencode/test/local/ignored.test.ts", - "packages/opencode/test/local/sync.test.ts", - "packages/opencode/test/config/env-reference-survives.test.ts", - "packages/opencode/src/peer/route.ts", - "packages/opencode/test/peer/route.test.ts", - "packages/opencode/src/peer/envelope.ts", - "packages/opencode/test/peer/envelope.test.ts", - "packages/opencode/src/peer/inbox.ts", - "packages/opencode/test/peer/inbox.test.ts", - "packages/opencode/src/peer/recent-ids.ts", - "packages/opencode/test/loop/stop-reason.test.ts", - "packages/opencode/test/peer/recent-ids.test.ts", - "packages/opencode/src/peer/repeat-guard.ts", - "packages/opencode/test/peer/repeat-guard.test.ts", - "packages/opencode/test/peer/route-delivery.test.ts", - "packages/opencode/test/peer/claude/pty-repro.test.ts", - "packages/opencode/test/peer/claude/probe-deliver-runtime.mts", - "packages/opencode/test/peer/claude/sidecar-diagnostic-routing.test.ts", - "packages/opencode/test/peer/claude/sidecar-server-name.test.ts", - "packages/opencode/src/local/model-gallery/search.ts", - "packages/opencode/src/local/model-gallery/install.ts", - "packages/opencode/src/local/model-gallery/operations.ts", - "packages/opencode/test/local/model-gallery-install.test.ts", - "packages/app/src/components/settings-v2/models-discover.tsx", - "packages/app/src/components/settings-v2/models-operations.tsx", - "packages/opencode/test/peer/lead.test.ts", - "packages/opencode/test/policy/drivers.test.ts", - "packages/opencode/test/policy/merge-run.test.ts", - "packages/opencode/test/policy/publish-policy-cache.test.ts", - "packages/opencode/test/policy/publish-policy-git.test.ts", - "packages/opencode/test/policy/publish-policy.test.ts", - "packages/opencode/test/policy/push-run.test.ts", - "packages/opencode/test/policy/review-record.test.ts", - "packages/opencode/test/session/unattended.test.ts", - "packages/opencode/test/tool/send-peer-message-ladder.test.ts", - "packages/tui/src/component/dialog-gallery.tsx", - "packages/opencode/src/local/model-gallery/manage.ts", - "packages/opencode/test/local/model-gallery-manage.test.ts", - "packages/tui/src/component/dialog-model-manage.tsx", - "packages/app/src/components/settings-v2/models-gallery.ts", - "packages/app/src/components/settings-v2/models-gallery.test.ts", - "packages/app/src/components/settings-v2/models-hosts.tsx", - "packages/app/src/components/settings-v2/models-installed.tsx", - "packages/opencode/test/peer/lead-confirm.test.ts", - "packages/opencode/test/peer/lead-issue.test.ts", - "packages/opencode/test/peer/lead-runtime.test.ts", - "packages/opencode/test/tool/send-peer-message-text.test.ts", - "packages/opencode/test/tool/task-delegate.test.ts", - "packages/opencode/test/loop/loop-defaults.test.ts" + "packages/tui/test/ui/dialog-select-footer.test.ts", + "packages/tui/test/ui/sidebar-context.test.ts" ], "_ownedRemoved_2026-09-05": { - "note": "packages/opencode/src/session/stream-stalled.ts, packages/opencode/src/session/tool-activity.ts, and packages/opencode/test/session/tool-activity.test.ts were deleted by a90c49880b — confirmed intentional, not a merge regression: a full, reasoned commit message explains that the stream-inactivity watchdog these files implemented only ever fired on false positives (host-paced hybrid loads, foreground subagents, compaction's single big call, cold-load chunks — each already special-cased before this commit finished the trend) and never once caught a genuinely wedged socket, while its false positives were abandoning working turns fleet-wide. Same commit also removed the fork patches this manifest tracked in llm.ts (marker @/local/skein-loading — the SkeinLoading cold-load liveness tick that fed only this watchdog), message-v2.ts (marker StreamStalledError — the error-mapping case for the watchdog's failure type; message-v2.ts no longer diverges from upstream at all and was fully removed from `patched`), and tools.ts (marker \"fork: the stream watchdog must not count\" — the per-tool-call liveness ticks). llm.ts and tools.ts each still diverge from upstream for unrelated, still-live fork reasons and were re-registered in `patched` with fresh markers (see those entries) rather than dropped — only the watchdog-specific tracking was retired.", + "note": "packages/opencode/src/session/stream-stalled.ts, packages/opencode/src/session/tool-activity.ts, and packages/opencode/test/session/tool-activity.test.ts were deleted by a90c49880b \u2014 confirmed intentional, not a merge regression: a full, reasoned commit message explains that the stream-inactivity watchdog these files implemented only ever fired on false positives (host-paced hybrid loads, foreground subagents, compaction's single big call, cold-load chunks \u2014 each already special-cased before this commit finished the trend) and never once caught a genuinely wedged socket, while its false positives were abandoning working turns fleet-wide. Same commit also removed the fork patches this manifest tracked in llm.ts (marker @/local/skein-loading \u2014 the SkeinLoading cold-load liveness tick that fed only this watchdog), message-v2.ts (marker StreamStalledError \u2014 the error-mapping case for the watchdog's failure type; message-v2.ts no longer diverges from upstream at all and was fully removed from `patched`), and tools.ts (marker \"fork: the stream watchdog must not count\" \u2014 the per-tool-call liveness ticks). llm.ts and tools.ts each still diverge from upstream for unrelated, still-live fork reasons and were re-registered in `patched` with fresh markers (see those entries) rather than dropped \u2014 only the watchdog-specific tracking was retired.", "commits": [ "a90c49880b" ] }, "_ownedRemoved_2026-08-14": { - "note": "packages/opencode/src/util/sync-log.ts was deleted in the origin/dev merge — confirmed intentional, not a merge regression. It was added by 21c1ac9dd6 to give non-Effect call sites (provider.ts, local/placement.ts) a TUI-safe logger after the core/util/log shim was deleted. The dev-side design solved the same problem differently: those helpers now return outcomes (PickOutcome, DiscoveryResult.warnings) and the Effect caller logs (tool/task.ts, provider.ts). That left zero call sites, so the file was removed rather than carried as dead code.", + "note": "packages/opencode/src/util/sync-log.ts was deleted in the origin/dev merge \u2014 confirmed intentional, not a merge regression. It was added by 21c1ac9dd6 to give non-Effect call sites (provider.ts, local/placement.ts) a TUI-safe logger after the core/util/log shim was deleted. The dev-side design solved the same problem differently: those helpers now return outcomes (PickOutcome, DiscoveryResult.warnings) and the Effect caller logs (tool/task.ts, provider.ts). That left zero call sites, so the file was removed rather than carried as dead code.", "commits": [ "21c1ac9dd6" ] }, "_ownedRemoved_2026-08-13": { - "note": "packages/core/src/util/log.ts (the legacy-logger compatibility shim, upstream's own module removed in #31310) was deleted 2026-08-12 — confirmed intentional, not a merge regression: upstream now has its own logger, making the shim genuinely obsolete." + "note": "packages/core/src/util/log.ts (the legacy-logger compatibility shim, upstream's own module removed in #31310) was deleted 2026-08-12 \u2014 confirmed intentional, not a merge regression: upstream now has its own logger, making the shim genuinely obsolete." }, "_ownedRemoved_2026-08-12": { "note": "auto-reply.ts, automation-features.ts, pattern-detection.ts (+ its two CLI commands), scheduler.ts, and hook.ts were deliberately deleted 2026-08-07 (commits 88731dfc6f, 0530944759) for being dead/broken code, not lost in a merge. fork:verify was reporting all 7 as regressions because this manifest was never updated at deletion time. Removed here, not re-added.", @@ -322,17 +329,17 @@ { "file": "packages/core/src/git.ts", "marker": "LC_ALL", - "note": "Pins LC_ALL=C on git spawns. This code matches git's human-readable output by text (forceRequired), and git localises it — on a non-English machine those checks silently read false." + "note": "Pins LC_ALL=C on git spawns. This code matches git's human-readable output by text (forceRequired), and git localises it \u2014 on a non-English machine those checks silently read false." }, { "file": "packages/core/src/installation/version.ts", "marker": "InstallationReleaseChannels", - "note": "Fork edit to an upstream file. Adds InstallationIsRelease (channel is latest/beta/prod) alongside the existing InstallationLocal, shared by database.ts's session-db path and upgrade.ts's self-upgrade eligibility check so any non-release build — not just literally channel \"local\" — is treated as never having been published. Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." + "note": "Fork edit to an upstream file. Adds InstallationIsRelease (channel is latest/beta/prod) alongside the existing InstallationLocal, shared by database.ts's session-db path and upgrade.ts's self-upgrade eligibility check so any non-release build \u2014 not just literally channel \"local\" \u2014 is treated as never having been published. Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." }, { "file": "packages/core/src/npm.ts", "marker": "basePackage", - "note": "Splits a package specifier from its export subpath. `@ai-sdk/amazon-bedrock/mantle` is not an installable npm spec — npa cannot parse it, so npm treats it as a local folder and arborist reads /@ai-sdk/... Install the base package, resolve the full specifier for the entrypoint." + "note": "Splits a package specifier from its export subpath. `@ai-sdk/amazon-bedrock/mantle` is not an installable npm spec \u2014 npa cannot parse it, so npm treats it as a local folder and arborist reads /@ai-sdk/... Install the base package, resolve the full specifier for the entrypoint." }, { "file": "packages/core/src/v1/config/agent.ts", @@ -396,7 +403,7 @@ }, { "file": "packages/opencode/src/cli/cmd/run.ts", - "marker": "fork: auto-mode — `--auto` (or OPENCODE_", + "marker": "fork: auto-mode \u2014 `--auto` (or OPENCODE_", "note": "Fork edit to an upstream file (+7/-0 vs baseline). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." }, { @@ -427,7 +434,7 @@ { "file": "packages/opencode/src/cli/upgrade.ts", "marker": "InstallationIsRelease", - "note": "Fork edit to an upstream file. Skips the self-upgrade check for any non-release channel (was only the literal \"local\" channel, which missed a local build on the fork's own \"dev\" channel — confirmed live: it read as needing a release patch and silently curl-installed over a running dev binary mid-session). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." + "note": "Fork edit to an upstream file. Skips the self-upgrade check for any non-release channel (was only the literal \"local\" channel, which missed a local build on the fork's own \"dev\" channel \u2014 confirmed live: it read as needing a release patch and silently curl-installed over a running dev binary mid-session). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." }, { "file": "packages/opencode/src/command/index.ts", @@ -542,11 +549,11 @@ { "file": "packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts", "marker": "SUBSCRIBER_QUEUE_CAPACITY", - "note": "Fork edit: bounds the per-subscriber SSE event queue (was Queue.unbounded — upstream #45215) via core's EventV2.allBounded; a stalled client fails and resyncs instead of retaining every event forever." + "note": "Fork edit: bounds the per-subscriber SSE event queue (was Queue.unbounded \u2014 upstream #45215) via core's EventV2.allBounded; a stalled client fails and resyncs instead of retaining every event forever." }, { "file": "packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts", - "marker": "fork: name the binary location — the com", + "marker": "fork: name the binary location \u2014 the com", "note": "Fork edit to an upstream file (+7/-1 vs baseline). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." }, { @@ -567,7 +574,7 @@ { "file": "packages/opencode/src/server/routes/instance/httpapi/server.ts", "marker": "ClaudeSidecarLifecycle", - "note": "Fork edit: joins ClaudeSidecarLifecycle.node to THIS file's own separate LayerNode graph (the actual root the HTTP server boots from — distinct from effect/app-runtime.ts's AppLayer, which app-runtime.ts's own consumers use). Both graphs need the node or session-lifecycle events published during real HTTP-served requests never reach it. See openspec/changes/claude-peer-messaging, Phase 3." + "note": "Fork edit: joins ClaudeSidecarLifecycle.node to THIS file's own separate LayerNode graph (the actual root the HTTP server boots from \u2014 distinct from effect/app-runtime.ts's AppLayer, which app-runtime.ts's own consumers use). Both graphs need the node or session-lifecycle events published during real HTTP-served requests never reach it. See openspec/changes/claude-peer-messaging, Phase 3." }, { "file": "packages/opencode/src/session/compaction.ts", @@ -581,13 +588,13 @@ }, { "file": "packages/opencode/src/session/prompt.ts", - "marker": "fork: loop detection — if consecutive as", + "marker": "fork: loop detection \u2014 if consecutive as", "note": "Fork edit to an upstream file (+287/-121 vs baseline). Marker is a string the fork's edit introduces and upstream does not contain, so a merge that reverts the edit fails fork:verify." }, { "file": "packages/opencode/src/session/session.ts", "marker": "A shallow copy is the snapshot that matters", - "note": "Fork edit: updatePart publishes a shallow copy of the part instead of structuredClone (upstream #35107 direction) — same snapshot semantics for the in-place-mutated text field, without copying part bytes per publish." + "note": "Fork edit: updatePart publishes a shallow copy of the part instead of structuredClone (upstream #35107 direction) \u2014 same snapshot semantics for the in-place-mutated text field, without copying part bytes per publish." }, { "file": "packages/opencode/src/session/summary.ts", @@ -597,7 +604,7 @@ { "file": "packages/opencode/src/session/tools.ts", "marker": "QueueAuthority", - "note": "Fork edit to an upstream file (+110/-82 vs upstream/dev as of 2026-09-05): the loop-spec-queue authority ceiling (deny-rules-before-auto-mode-skip ordering, credential-less shell for push-denied sessions). Re-registered here with a fresh marker after a90c49880b retired the previous marker (`fork: the stream watchdog must not count`) along with the watchdog's tool-liveness ticks — tools.ts still diverges substantially from upstream for this unrelated, load-bearing reason and must stay tracked." + "note": "Fork edit to an upstream file (+110/-82 vs upstream/dev as of 2026-09-05): the loop-spec-queue authority ceiling (deny-rules-before-auto-mode-skip ordering, credential-less shell for push-denied sessions). Re-registered here with a fresh marker after a90c49880b retired the previous marker (`fork: the stream watchdog must not count`) along with the watchdog's tool-liveness ticks \u2014 tools.ts still diverges substantially from upstream for this unrelated, load-bearing reason and must stay tracked." }, { "file": "packages/opencode/src/tool/code-mode.ts", @@ -647,7 +654,7 @@ { "file": "packages/opencode/test/fixture/fixture.ts", "marker": "disposeDirectory", - "note": "Disposes each test's instance when the test ends. InstanceStore otherwise holds every instance the suite creates — each with its own :memory: SQLite and slice of the app graph — until the process exits, which measured as the bulk of the suite's memory (provider dir: 3004 -> 2577 MB)." + "note": "Disposes each test's instance when the test ends. InstanceStore otherwise holds every instance the suite creates \u2014 each with its own :memory: SQLite and slice of the app graph \u2014 until the process exits, which measured as the bulk of the suite's memory (provider dir: 3004 -> 2577 MB)." }, { "file": "packages/opencode/test/fixture/mcp-session-recovery.ts", @@ -787,7 +794,7 @@ { "file": "packages/tui/src/context/theme.tsx", "marker": "ThemeState.set", - "note": "createEffect writing ThemeState.set(store.active) — the writer half of the local-backend themed-loading-screen feature (reader is provider.ts ThemeState.get). theme-state.ts now lives in packages/core/src/local so both the TUI (writer) and the opencode provider (reader) can share the singleton across the package boundary." + "note": "createEffect writing ThemeState.set(store.active) \u2014 the writer half of the local-backend themed-loading-screen feature (reader is provider.ts ThemeState.get). theme-state.ts now lives in packages/core/src/local so both the TUI (writer) and the opencode provider (reader) can share the singleton across the package boundary." }, { "file": "packages/tui/src/feature-plugins/home/tips-view.tsx", @@ -817,7 +824,7 @@ { "file": "packages/tui/src/routes/session/index.tsx", "marker": "transcriptWindow", - "note": "Fork edit: transcript windowing — messages older than the most recent `transcript_window` (TUI config, default 40, 0 disables) render as a one-line CollapsedMessage until clicked, so a long session no longer keeps every part's markdown/syntax/code renderer mounted for its whole life (long-running-session OOM)." + "note": "Fork edit: transcript windowing \u2014 messages older than the most recent `transcript_window` (TUI config, default 40, 0 disables) render as a one-line CollapsedMessage until clicked, so a long session no longer keeps every part's markdown/syntax/code renderer mounted for its whole life (long-running-session OOM)." }, { "file": "packages/tui/src/routes/session/permission.tsx", @@ -871,7 +878,7 @@ } ], "divergence": { - "note": "Ratchet for the failure this fork keeps hitting: a merge silently deleting a fork feature (see the self-healing compaction entry). Any file under `scope` that differs from baseline.upstreamRef and is neither owned/patched nor listed in `accepted` fails fork:verify. `accepted` is the divergence that already existed when the gate was added — classify those as feature or drift over time; new files must be classified immediately. The single remaining accepted entry is packages/core/src/effect/dfdf, which upstream committed by accident — its contents are an editor's \"File to save in:\" prompt. It is deleted here on purpose; it stays accepted rather than registered because there is no file left to carry a marker. Re-baseline deliberately with `bun run fork:verify --accept-divergence`.", + "note": "Ratchet for the failure this fork keeps hitting: a merge silently deleting a fork feature (see the self-healing compaction entry). Any file under `scope` that differs from baseline.upstreamRef and is neither owned/patched nor listed in `accepted` fails fork:verify. `accepted` is the divergence that already existed when the gate was added \u2014 classify those as feature or drift over time; new files must be classified immediately. The single remaining accepted entry is packages/core/src/effect/dfdf, which upstream committed by accident \u2014 its contents are an editor's \"File to save in:\" prompt. It is deleted here on purpose; it stays accepted rather than registered because there is no file left to carry a marker. Re-baseline deliberately with `bun run fork:verify --accept-divergence`.", "scope": [ "^packages/[^/]+/(src|test)/" ],