Skip to content
Merged
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"_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.",
"_lint_ratchet_note": "Ratchet lowered to 4850 after DAG-LOC-01: the new execution-location guards (dag/location.ts authority, DagLoop guard replacements, GoalLoop idle guard) and the dag-location-guards probe harness carry no net new warnings \u2014 the probe harness's `as never` fixture shims are file-scoped suppressed (mirrors dag-loop-guards.test.ts idiom), and two pre-existing `directory: process.cwd() as never` session-seed casts in dag-wake-integration/dag-adoption-step-races were replaced with plain strings (session.directory accepts them). 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) \u2014 NOT new code warnings; 4850 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",
"dev:web": "bun --cwd packages/app dev",
"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=4852",
"lint": "oxlint --max-warnings=4850",
"typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
Expand Down
14 changes: 12 additions & 2 deletions packages/core/schema.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "7e8e00e9-7bbb-443e-996b-f646ec030c2b",
"id": "4142b961-0712-4834-b475-16ea4a74c43c",
"prevIds": [
"cce2163c-da01-4239-86fa-776d48a58d89"
"7e8e00e9-7bbb-443e-996b-f646ec030c2b"
],
"ddl": [
{
Expand Down Expand Up @@ -762,6 +762,16 @@
"entityType": "columns",
"table": "workflow"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "workflow"
},
{
"type": "text",
"notNull": true,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/dag/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ export const layer = Layer.effectDiscard(
id: event.data.dagID,
project_id: event.data.projectID,
session_id: event.data.sessionID,
// DAG-LOC-01: the execution-location key, stamped at dag.create.
// A legacy event without the field projects to NULL — a row that
// matches no instance directory and is never adopted.
directory: event.data.directory ?? null,
title: event.data.title,
status: event.data.status,
config: event.data.config,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/dag/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export const WorkflowTable = sqliteTable(
session_id: text()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
// Execution-location key (DAG-LOC-01): the creating instance's directory,
// stamped at dag.create. Only the instance whose directory matches may
// adopt, recover, wake, or spawn for this workflow. Nullable: legacy rows
// predating the column match no instance (conservative — never adopted
// until recreated).
directory: text(),
title: text().notNull(),
status: text().notNull(),
config: text().notNull(), // YAML string
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/dag/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface WorkflowRow {
id: string
projectId: string
sessionId: string
/** Execution-location key (DAG-LOC-01): the creating instance's directory. */
directory: string | null
title: string
status: string
config: string
Expand Down Expand Up @@ -83,6 +85,7 @@ const mapWorkflow = (r: typeof WorkflowTable.$inferSelect): WorkflowRow => ({
id: r.id,
projectId: r.project_id,
sessionId: r.session_id,
directory: r.directory,
title: r.title,
status: r.status,
config: r.config,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"

export default {
id: "20260813040429_workflow_directory",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`workflow\` ADD \`directory\` text;`)
// DAG-LOC-01 backfill: the ownership key is the workflow row's own
// directory, so existing installs must carry the owning session's
// directory forward or every in-flight workflow would turn foreign
// (never adopted / orphan-pending rows never terminalized). Rows whose
// session is already gone stay NULL — conservative: NULL matches no
// instance directory and is never adopted.
yield* tx.run(`
UPDATE \`workflow\`
SET \`directory\` = (
SELECT \`directory\` FROM \`session\` WHERE \`session\`.\`id\` = \`workflow\`.\`session_id\`
)
WHERE \`directory\` IS NULL;
`)
})
},
} satisfies DatabaseMigration.Migration
1 change: 1 addition & 0 deletions packages/core/src/database/schema.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export default {
\`id\` text PRIMARY KEY,
\`project_id\` text NOT NULL,
\`session_id\` text NOT NULL,
\`directory\` text,
\`title\` text NOT NULL,
\`status\` text NOT NULL,
\`config\` text NOT NULL,
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/dag/dag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import {
} from "./admission"
import { unresolvedReviewOutcomes } from "./review-lifecycle"
import { DagValidation, StructuralValidationError } from "./validation"
import { DagLocation } from "./location"
import { SessionLocation } from "@/session/location"
import { SessionID } from "@/session/schema"

export { StructuralValidationError } from "./validation"

Expand Down Expand Up @@ -400,6 +403,18 @@ export const layer = Layer.effect(
config: JSON.stringify(durableConfig),
status: "pending",
timestamp: ts,
// DAG-LOC-01 stamp (P2-F): the execution-location key is the TARGET
// SESSION's durable directory — the single source of truth. Stamping
// the ambient request instance's directory would let a request on
// directory A create a workflow for B's session stamped A, orphaning
// it from B's loops. Fall back to the ambient instance only when the
// session has no durable row (the workflow insert would fail its
// session FK anyway).
directory: yield* Effect.flatMap(SessionLocation.sessionDirectory(SessionID.make(input.sessionID)), (durable) =>
durable._tag === "Some"
? Effect.succeed(DagLocation.canonicalDirectory(durable.value))
: DagLocation.stampDirectory(),
),
})
for (const node of durableConfig.nodes) {
yield* events.publish(DagEvent.NodeRegistered, {
Expand Down
134 changes: 134 additions & 0 deletions packages/opencode/src/dag/location.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
export * as DagLocation from "./location"

/**
* DAG-LOC-01 — the execution-location authority.
*
* The DAG runtime (DagLoop, GoalLoop) is per-directory InstanceState, but the
* durable store, the event bus, and the workflow rows are process-global. A
* multi-directory server (sibling worktrees of ONE project — same project id)
* would otherwise let every instance adopt, recover-cancel, wake, and spawn
* for every workflow. This module is the SINGLE authority that decides which
* instance may act: the location key is the DIRECTORY, not the project id.
*
* The key lives on the workflow row itself (WorkflowTable.directory), stamped
* at dag.create from the creating instance's directory. Ownership predicates
* re-read the durable row on every check, so a row whose durable identity was
* repainted (identity migration) or deleted stops matching and its in-memory
* runtime entry loses the right to publish transitions.
*
* Callers (the loops) pass their own instance directory and know nothing about
* the SQL or the realpath internals. The Database service is resolved lazily
* via serviceOption so the loops' static requirements stay unchanged (the
* optional-cross-dependency pattern); production graphs always carry it.
*/

import { eq } from "drizzle-orm"
import { realpathSync } from "node:fs"
import { Effect } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { WorkflowTable } from "@opencode-ai/core/dag/sql"
import { InstanceRef } from "@/effect/instance-ref"

/**
* Canonical execution-location key: the directory's realpath when resolvable,
* else the raw path (test directories like /wtA do not exist on disk; the
* fallback keeps the comparison a plain string equality in that case). Both
* stamping (dag.create) and checking go through this, so the two sides are
* always comparable under the same normalization.
*/
export const canonicalDirectory = (directory: string): string => {
try {
return realpathSync(directory)
} catch {
return directory
}
}

/** The directory to stamp on a workflow created by the ambient instance. */
export const stampDirectory = (): Effect.Effect<string> =>
Effect.map(InstanceRef, (instance) => (instance ? canonicalDirectory(instance.directory) : ""))

/**
* P2-D: a workflow whose directory stamp is NULL (created by a pre-DAG-LOC-01
* build after the one-shot backfill) matches no instance and is silently
* skipped by every adoption/recovery/wake path forever. Log that skip once
* per workflow per process so the zombie is visible; the conservative
* never-match policy stays.
*/
const nullDirectoryWarned = new Set<string>()

const warnNullDirectory = (row: { id: string; directory: string | null }): Effect.Effect<void> =>
Effect.suspend(() => {
if (row.directory !== null || nullDirectoryWarned.has(row.id)) return Effect.void
nullDirectoryWarned.add(row.id)
return Effect.logWarning(
"DagLocation skipping workflow with a NULL execution-location directory (created before the DAG-LOC-01 stamp and never backfilled) — it will never be adopted, recovered, or woken; recreate the workflow to re-enable it",
{ dagID: row.id },
)
})

/**
* Owns the workflow iff its DURABLE row (re-read on every check) still belongs
* to the ambient instance: the project id matches (fast-reject + R6 identity
* revalidation — a repainted project_id must not keep driving the old entry)
* and the stamped directory matches the caller's directory (the deciding
* guard: sibling worktrees share the project id). Fail-closed: a missing
* instance or a row without a stamp is never adopted.
*/
export const ownsWorkflow = (workflowID: string, directory: string): Effect.Effect<boolean> =>
Effect.gen(function* () {
const instance = yield* InstanceRef
if (!instance) return false
const db = yield* Effect.serviceOption(Database.Service)
if (db._tag === "None") return false
const row = yield* db.value.db
.select()
.from(WorkflowTable)
.where(eq(WorkflowTable.id, workflowID))
.get()
.pipe(Effect.orDie)
if (!row) return false
if (row.project_id !== instance.project.id) return false
if (row.directory === null) {
yield* warnNullDirectory(row)
return false
}
return canonicalDirectory(row.directory) === canonicalDirectory(directory)
})

/**
* Owns the session iff every durable workflow row of the session still belongs
* to the ambient instance (same project id + directory conjunct as
* ownsWorkflow). Vacuous-true when the session has no workflow rows: there is
* no wake data to deliver and goal-only sessions predate workflow stamping.
* Also vacuous-true when the Database service is absent from the runtime graph
* (synthetic goal tests; every production graph carries it) — ownership cannot
* be disproven there and the gate must not silently disable pre-existing
* loops. The workflow-row key keeps this module free of session-table reads:
* the execution-location key belongs on the workflow row itself (R7).
*/
export const ownsSession = (sessionID: string, directory: string): Effect.Effect<boolean> =>
Effect.gen(function* () {
const instance = yield* InstanceRef
if (!instance) return false
const db = yield* Effect.serviceOption(Database.Service)
if (db._tag === "None") return true
const rows = yield* db.value.db
.select()
.from(WorkflowTable)
.where(eq(WorkflowTable.session_id, sessionID))
.all()
.pipe(Effect.orDie)
let owned = true
for (const row of rows) {
if (row.project_id !== instance.project.id) {
owned = false
} else if (row.directory === null) {
yield* warnNullDirectory(row)
owned = false
} else if (canonicalDirectory(row.directory) !== canonicalDirectory(directory)) {
owned = false
}
}
return owned
})
Loading
Loading