Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
76df802
feat(dag): add workflow draft action for schema-checked graph authoring
LeXwDeX Aug 14, 2026
1f606ad
test(tool): regenerate workflow wire-shape snapshot for the draft action
LeXwDeX Aug 14, 2026
20b488c
docs(dag): restore the Progressive guidance heading swallowed by an edit
LeXwDeX Aug 14, 2026
3cd2d0d
fix(dag): drop regex escapes in driftHint for the oxlint warning ratchet
LeXwDeX Aug 14, 2026
8e1240c
fix(schema): stop lexicographic id ordering breakage from the 48-bit …
LeXwDeX Aug 14, 2026
feb3b33
fix(tui): keep the session store id-ordered; recency is a display con…
LeXwDeX Aug 14, 2026
65ac4ec
test(schema): drop non-null assertions the oxlint ratchet flags
LeXwDeX Aug 14, 2026
4367b77
Merge pull request #264 from LeXwDeX/feat/workflow-draft-action
LeXwDeX Aug 14, 2026
9ba6efa
fix(dag): enforce execution-location ownership on adoption, recovery,…
LeXwDeX Aug 13, 2026
813c633
fix(dag): close review findings — goal-side ownership, spawnReady rev…
LeXwDeX Aug 13, 2026
70dfd4c
fix(dag): never end deadline supervision on a transient store defect …
LeXwDeX Aug 13, 2026
7ed147f
test(dag): stamp lease-lifecycle seed rows for the execution-location…
LeXwDeX Aug 14, 2026
959bae7
fix(dag): latch the WorkflowStarted adoption slot against duplicate-p…
LeXwDeX Aug 14, 2026
9fc0576
test(dag): add issue #238 evidence probes C1/C3/C4/C5 + R7-ext write-…
LeXwDeX Aug 14, 2026
9a97e88
test(dag): add issue #238 async race probes C2/C6 + Deterministic par…
LeXwDeX Aug 14, 2026
dff662d
Merge pull request #265 from LeXwDeX/fix/msgid-time-prefix-wrap
LeXwDeX Aug 14, 2026
ab4cddb
test(dag): add H1 adopt-exactly-once probe for the WorkflowStarted la…
LeXwDeX Aug 14, 2026
a90537b
Merge pull request #266 from LeXwDeX/fix/dag-loc-ownership
LeXwDeX Aug 14, 2026
48ff26f
fix(dag): stamp the bootstrap-recovery fixture and license the locati…
LeXwDeX Aug 14, 2026
7effbc5
Merge pull request #267 from LeXwDeX/fix/dag-loc-ci-followup
LeXwDeX Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
3 changes: 1 addition & 2 deletions packages/core/src/id/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ export function create(prefix: string, direction: "descending" | "ascending", ti
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
return Number(BigInt("0x" + hex))
}

export * as Identifier from "./id"
44 changes: 36 additions & 8 deletions packages/core/src/plugin/command/workflow-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,45 @@ DAG; for a verdict, the matching audit is primary. Do not concatenate two comple

Read the selected reference, retarget its objective and instructions, and
remove phases current evidence already covers. Start its saved `spec_path`
directly only when target and acceptance evidence match. If none fits, load
`guide(topic="blocks")` and compose a task-local YAML. Load
directly only when target and acceptance evidence match. If none fits, compose
a task-local graph. Load `guide(topic="blocks")` for block contracts and
`guide(topic="patterns")` only when domains overlap. Use low-level nodes only
for fields blocks cannot express.

Write the graph to YAML and validate that `spec_path` before start. Fix every
diagnostic in the same file and revalidate; validation creates no workflow. A
successful start returns the exact workflow ID. The parent owns the graph,
controls, and final report; children own bounded work. End after start and let
the workflow wake the parent. Do not poll merely to wait or claim an unstarted
graph is running.
Prefer `workflow(action="draft")` over hand-writing YAML: pass the structured
`config` (same fields as the YAML below) and the tool renders and validates the
spec file, returning the `spec_path` to start. Field-name drift is impossible
because the parameter schema rejects unknown fields. Hand-write YAML only for
features draft does not carry (admission, custom bindings). The exact start
shape, for that fallback and for reading draft output:

```yaml
title: Implement session recovery
config:
name: implement-session-recovery
objective: Implement session recovery with focused tests and review.
blocks:
- id: map
kind: explore
instruction: Locate the ownership and persistence seams.
- id: coding
kind: coding
depends_on: [map]
- id: review
kind: review
depends_on: [coding]
```

Top level is `title`/`mode`/`admission` (optional) and `config` (required);
`objective` lives INSIDE `config`; every block field is one of `id` (required),
`kind` (required), `depends_on`, `instruction`, `worker_type`, `required`,
`report_to_parent` — never `worker`, `prompt`, or `agent`.

Validate that `spec_path` before start. Fix every diagnostic in the same file
and revalidate; validation creates no workflow. A successful start returns the
exact workflow ID. The parent owns the graph, controls, and final report;
children own bounded work. End after start and let the workflow wake the
parent. Do not poll merely to wait or claim an unstarted graph is running.

## Progressive guidance

Expand Down
4 changes: 3 additions & 1 deletion packages/core/test/plugin/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ describe("CommandPlugin.Plugin", () => {

it.effect("keeps always-on guidance small and loads detailed topics progressively", () =>
Effect.sync(() => {
expect(Buffer.byteLength(CommandPlugin.WorkflowContent)).toBeLessThan(5_000)
// Budget admits the inline start-spec example (one-hop field reference
// for hand-written YAML) while keeping per-action manuals progressive.
expect(Buffer.byteLength(CommandPlugin.WorkflowContent)).toBeLessThan(6_500)
expect(CommandPlugin.WorkflowContent).toContain("project-level source or test changes")
expect(CommandPlugin.WorkflowContent).toMatch(/even one project\s+file/)
expect(CommandPlugin.WorkflowContent).toMatch(/isolated utility\s+scripts/)
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
137 changes: 137 additions & 0 deletions packages/opencode/src/dag/location.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: 2026 LeXwDeX
// SPDX-License-Identifier: AGPL-3.0-or-later

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