Skip to content

Commit bfe30f9

Browse files
authored
Merge pull request #276 from LeXwDeX/dev
release: current-revision DAG view + node-output file refs
2 parents b56a364 + 422495f commit bfe30f9

24 files changed

Lines changed: 2237 additions & 113 deletions

packages/core/schema.json

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"version": "7",
33
"dialect": "sqlite",
4-
"id": "4142b961-0712-4834-b475-16ea4a74c43c",
4+
"id": "874d8e74-d354-4dcb-b98c-c893660c9371",
55
"prevIds": [
6-
"7e8e00e9-7bbb-443e-996b-f646ec030c2b"
6+
"4142b961-0712-4834-b475-16ea4a74c43c"
77
],
88
"ddl": [
99
{
@@ -682,6 +682,16 @@
682682
"entityType": "columns",
683683
"table": "workflow_node"
684684
},
685+
{
686+
"type": "integer",
687+
"notNull": true,
688+
"autoincrement": false,
689+
"default": "false",
690+
"generated": null,
691+
"name": "superseded",
692+
"entityType": "columns",
693+
"table": "workflow_node"
694+
},
685695
{
686696
"type": "integer",
687697
"notNull": true,
@@ -822,6 +832,16 @@
822832
"entityType": "columns",
823833
"table": "workflow"
824834
},
835+
{
836+
"type": "integer",
837+
"notNull": true,
838+
"autoincrement": false,
839+
"default": "1",
840+
"generated": null,
841+
"name": "graph_rev",
842+
"entityType": "columns",
843+
"table": "workflow"
844+
},
825845
{
826846
"type": "integer",
827847
"notNull": false,

packages/core/src/dag/projector.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -141,33 +141,62 @@ export const layer = Layer.effectDiscard(
141141

142142
yield* events.project(DagEvent.WorkflowReplanned, (event) =>
143143
Effect.gen(function* () {
144-
// Atomic wake can reach the parent only after a leaf checkpoint has
145-
// completed the current graph. An additive extend emits this event to
146-
// reopen that completed workflow without changing completed nodes.
144+
// Rev-view (v1.0.15 Train A): every replan opens a new graph
145+
// revision. The two legs run in THIS order so each event bumps
146+
// graph_rev exactly once: the seq-bump leg matches the active
147+
// statuses first (status unchanged), and the reopen leg then matches
148+
// completed rows still untouched by it — reversing the legs would let
149+
// the reopened (now running) row match the seq-bump leg too and
150+
// double-bump.
147151
yield* db
148152
.update(WorkflowTable)
149153
.set({
150-
status: "running",
151-
wake_reported: false,
152-
completed_at: null,
154+
graph_rev: sql`${WorkflowTable.graph_rev} + 1`,
153155
seq: event.durable!.seq,
154156
time_updated: toMillis(event.data.timestamp),
155157
})
156158
.where(and(
157159
eq(WorkflowTable.id, event.data.dagID),
158-
inArray(WorkflowTable.status, [...WorkflowStatusProjection.replanReopen.from]),
160+
inArray(WorkflowTable.status, ["pending", "running", "paused", "stepping"]),
159161
))
160162
.run()
161163
.pipe(Effect.orDie)
164+
// Atomic wake can reach the parent only after a leaf checkpoint has
165+
// completed the current graph. An additive extend emits this event to
166+
// reopen that completed workflow without changing completed nodes.
162167
yield* db
163168
.update(WorkflowTable)
164-
.set({ seq: event.durable!.seq, time_updated: toMillis(event.data.timestamp) })
169+
.set({
170+
status: "running",
171+
wake_reported: false,
172+
completed_at: null,
173+
graph_rev: sql`${WorkflowTable.graph_rev} + 1`,
174+
seq: event.durable!.seq,
175+
time_updated: toMillis(event.data.timestamp),
176+
})
165177
.where(and(
166178
eq(WorkflowTable.id, event.data.dagID),
167-
inArray(WorkflowTable.status, ["pending", "running", "paused", "stepping"]),
179+
inArray(WorkflowTable.status, [...WorkflowStatusProjection.replanReopen.from]),
168180
))
169181
.run()
170182
.pipe(Effect.orDie)
183+
// Rev-view: mark the nodes this replan pushed out of the current
184+
// revision — terminal rows the fragment bypassed (a failed node the
185+
// new path routes around). plan.cancel rows are marked via the
186+
// NodeCancelled projection instead. Idempotent fold: the marker is
187+
// monotonic, replaying the event never resurrects a marked row.
188+
const superseded = event.data.superseded
189+
if (superseded && superseded.length > 0) {
190+
yield* db
191+
.update(WorkflowNodeTable)
192+
.set({ superseded: true, seq: event.durable!.seq, time_updated: toMillis(event.data.timestamp) })
193+
.where(and(
194+
eq(WorkflowNodeTable.workflow_id, event.data.dagID),
195+
inArray(WorkflowNodeTable.id, [...superseded]),
196+
))
197+
.run()
198+
.pipe(Effect.orDie)
199+
}
171200
}),
172201
)
173202

@@ -361,10 +390,16 @@ export const layer = Layer.effectDiscard(
361390
// therefore never hold status="cancelled"; see NodeStatusProjection.cancelled
362391
// above and the canonical proof in
363392
// packages/opencode/test/dag/dag-escalation-clear-flag.test.ts:130-148.
393+
//
394+
// Rev-view (v1.0.15 Train A): a cancelled node leaves the current graph
395+
// revision — the projection also sets the superseded marker so view and
396+
// aggregation reads (summaries, status, node lists, rebuild input, wake
397+
// attribution) show only the current rev. This covers both plan.cancel
398+
// rows and explicit dag.nodeCancelled publishes (U1: shared semantics).
364399
yield* events.project(DagEvent.NodeCancelled, (event) =>
365400
db
366401
.update(WorkflowNodeTable)
367-
.set({ status: "failed", error_reason: "cancelled via replan", escalation_pending: false, seq: event.durable!.seq, time_updated: toMillis(event.data.timestamp) })
402+
.set({ status: "failed", superseded: true, error_reason: "cancelled via replan", escalation_pending: false, seq: event.durable!.seq, time_updated: toMillis(event.data.timestamp) })
368403
.where(and(
369404
eq(WorkflowNodeTable.workflow_id, event.data.dagID),
370405
eq(WorkflowNodeTable.id, event.data.nodeID),

packages/core/src/dag/sql.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export const WorkflowTable = sqliteTable(
4747
config: text().notNull(), // YAML string
4848
seq: integer().notNull(), // latest durable event seq
4949
wake_reported: integer({ mode: "boolean" }).notNull().default(false), // D3: has workflow terminal been reported to parent?
50+
// Rev-view (v1.0.15 Train A): the current graph-revision counter. Bumped
51+
// by the WorkflowReplanned projection; audit/telemetry only — the view
52+
// predicate is the per-node `superseded` marker below. Default 1: legacy
53+
// rows predate the concept and render exactly as before.
54+
graph_rev: integer().notNull().default(1),
5055
started_at: integer(),
5156
completed_at: integer(),
5257
...Timestamps,
@@ -77,13 +82,20 @@ export const WorkflowNodeTable = sqliteTable(
7782
output: text({ mode: "json" }).$type<unknown>(),
7883
error_reason: text(),
7984
error_class: text(), // dag.node.failed trigger (timeout/exec_failed/verdict_fail/push_exhausted) for failure triage
80-
captured_output: text({ mode: "json" }).$type<unknown>(), // durable payload from submit_result; survives a process crash, reset to null on a replan-restart via NodeStarted
85+
captured_output: text({ mode: "json" }).$type<unknown>(), // durable payload from submit_result, or a Train B file-ref record ({content_ref, size, sha256, summary}); survives a process crash, reset to null on a replan-restart via NodeStarted
8186
deadline_ms: integer(), // absolute deadline (spawnedAt + timeout_ms) for D0 termination boundary
8287
wake_eligible: integer({ mode: "boolean" }).notNull().default(false), // D6: node has report_to_parent=true
8388
wake_reported: integer({ mode: "boolean" }).notNull().default(false), // D3: has this node's terminal event been injected into the parent session?
8489
replan_attempts: integer().notNull().default(0), // D4: per-node replan counter for circuit breaker
8590
timeout_extensions: integer().notNull().default(0), // timeout escalation count (node stays running; main agent adjudicates)
8691
escalation_pending: integer({ mode: "boolean" }).notNull().default(false), // set on escalate, cleared on adjudication (extend) or new attempt — "awaiting main-agent adjudication"
92+
// Rev-view (v1.0.15 Train A): this node was pushed OUT of the current
93+
// graph revision by a replan (cancelled via replan, or a terminal row the
94+
// fragment bypassed). Durable data is untouched — the marker only filters
95+
// VIEW/aggregation reads (summaries, status, node lists, rebuild input,
96+
// wake attribution) to the current revision. Monotonic: once true, stays
97+
// true. Default false: legacy rows render exactly as before.
98+
superseded: integer({ mode: "boolean" }).notNull().default(false),
8799
seq: integer().notNull(), // latest durable event seq for this node
88100
started_at: integer(),
89101
completed_at: integer(),

packages/core/src/dag/store.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface WorkflowRow {
2424
config: string
2525
seq: number
2626
wakeReported: boolean
27+
/** Rev-view (v1.0.15 Train A): current graph-revision counter (audit/telemetry). */
28+
graphRev: number
2729
startedAt: number | null
2830
completedAt: number | null
2931
timeCreated: number
@@ -51,6 +53,8 @@ export interface NodeRow {
5153
replanAttempts: number
5254
timeoutExtensions: number
5355
escalationPending: boolean
56+
/** Rev-view (v1.0.15 Train A): pushed out of the current graph revision by a replan. */
57+
superseded: boolean
5458
seq: number
5559
startedAt: number | null
5660
completedAt: number | null
@@ -91,6 +95,7 @@ const mapWorkflow = (r: typeof WorkflowTable.$inferSelect): WorkflowRow => ({
9195
config: r.config,
9296
seq: r.seq,
9397
wakeReported: r.wake_reported,
98+
graphRev: r.graph_rev,
9499
startedAt: r.started_at,
95100
completedAt: r.completed_at,
96101
timeCreated: r.time_created,
@@ -118,6 +123,7 @@ const mapNode = (r: typeof WorkflowNodeTable.$inferSelect): NodeRow => ({
118123
replanAttempts: r.replan_attempts,
119124
timeoutExtensions: r.timeout_extensions,
120125
escalationPending: r.escalation_pending,
126+
superseded: r.superseded,
121127
seq: r.seq,
122128
startedAt: r.started_at,
123129
completedAt: r.completed_at,
@@ -156,6 +162,15 @@ export interface Interface {
156162
readonly getWorkflowSummaries: (sessionId: string) => Effect.Effect<WorkflowSummary[]>
157163

158164
readonly getNodes: (workflowId: string) => Effect.Effect<NodeRow[]>
165+
/**
166+
* Rev-view (v1.0.15 Train A): the CURRENT graph revision only — rows the
167+
* replan pushed out of the graph (superseded) are filtered out. This is the
168+
* read for VIEW and terminal-aggregation consumers: summaries, status/node
169+
* listings, the loop's rebuild/recovery/completion input, and wake failure
170+
* attribution. Durable truth is untouched — getNodes still returns every
171+
* row, and completed old-rev outputs stay resolvable for input mapping.
172+
*/
173+
readonly getCurrentNodes: (workflowId: string) => Effect.Effect<NodeRow[]>
159174
readonly getNode: (workflowId: string, nodeId: string) => Effect.Effect<NodeRow | undefined>
160175
readonly getRunningNodes: (workflowId: string) => Effect.Effect<NodeRow[]>
161176
readonly setCapturedOutput: (childSessionID: string, payload: unknown) => Effect.Effect<void>
@@ -257,6 +272,9 @@ export const layer = Layer.effect(
257272
if (wfRows.length === 0) return []
258273
// P1-4: aggregate in SQL — pulling every node row into JS made each
259274
// dag.* event burst scale with total node count across the session.
275+
// Rev-view (v1.0.15 Train A): superseded rows are filtered out so the
276+
// counts reflect ONLY the current graph revision — a replaced segment
277+
// neither counts toward nodeCount nor inflates failedNodes.
260278
const countRows = yield* db
261279
.select({
262280
workflowId: WorkflowNodeTable.workflow_id,
@@ -265,7 +283,7 @@ export const layer = Layer.effect(
265283
})
266284
.from(WorkflowNodeTable)
267285
.innerJoin(WorkflowTable, eq(WorkflowNodeTable.workflow_id, WorkflowTable.id))
268-
.where(eq(WorkflowTable.session_id, sessionId))
286+
.where(and(eq(WorkflowTable.session_id, sessionId), eq(WorkflowNodeTable.superseded, false)))
269287
.groupBy(WorkflowNodeTable.workflow_id, WorkflowNodeTable.status)
270288
.all()
271289
.pipe(Effect.orDie)
@@ -284,6 +302,7 @@ export const layer = Layer.effect(
284302
eq(WorkflowTable.session_id, sessionId),
285303
eq(WorkflowNodeTable.status, "running"),
286304
eq(WorkflowNodeTable.escalation_pending, true),
305+
eq(WorkflowNodeTable.superseded, false),
287306
))
288307
.groupBy(WorkflowNodeTable.workflow_id)
289308
.all()
@@ -320,6 +339,17 @@ export const layer = Layer.effect(
320339
return rows.map(mapNode)
321340
}),
322341

342+
getCurrentNodes: Effect.fn("DagStore.getCurrentNodes")(function* (workflowId) {
343+
const rows = yield* db
344+
.select()
345+
.from(WorkflowNodeTable)
346+
.where(and(eq(WorkflowNodeTable.workflow_id, workflowId), eq(WorkflowNodeTable.superseded, false)))
347+
.orderBy(desc(WorkflowNodeTable.seq))
348+
.all()
349+
.pipe(Effect.orDie)
350+
return rows.map(mapNode)
351+
}),
352+
323353
getNode: Effect.fn("DagStore.getNode")(function* (workflowId, nodeId) {
324354
const row = yield* db
325355
.select()

packages/core/src/database/migration.gen.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// SPDX-FileCopyrightText: 2026 LeXwDeX
2+
// SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
import { Effect } from "effect"
5+
import type { DatabaseMigration } from "../migration"
6+
7+
// Rev-view (v1.0.15 Train A, workflows/dag-engine-optimization.md). Legacy
8+
// policy: existing rows migrate in place with superseded=false / graph_rev=1,
9+
// so every pre-feature workflow renders EXACTLY as before — including its
10+
// cancelled-via-replan rows, which stay visible and counted (config
11+
// membership cannot be the current-rev predicate: <=v1.0.14 merged configs
12+
// already drop cancelled nodes, so it would hide rows that render today).
13+
// Marking only ever happens via the WorkflowReplanned and NodeCancelled
14+
// projections after this migration runs.
15+
export default {
16+
id: "20260815044858_dag_graph_rev_view",
17+
up(tx) {
18+
return Effect.gen(function* () {
19+
yield* tx.run(`ALTER TABLE \`workflow_node\` ADD \`superseded\` integer DEFAULT false NOT NULL;`)
20+
yield* tx.run(`ALTER TABLE \`workflow\` ADD \`graph_rev\` integer DEFAULT 1 NOT NULL;`)
21+
})
22+
},
23+
} satisfies DatabaseMigration.Migration

packages/core/src/database/schema.gen.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export default {
9191
\`replan_attempts\` integer DEFAULT 0 NOT NULL,
9292
\`timeout_extensions\` integer DEFAULT 0 NOT NULL,
9393
\`escalation_pending\` integer DEFAULT false NOT NULL,
94+
\`superseded\` integer DEFAULT false NOT NULL,
9495
\`seq\` integer NOT NULL,
9596
\`started_at\` integer,
9697
\`completed_at\` integer,
@@ -111,6 +112,7 @@ export default {
111112
\`config\` text NOT NULL,
112113
\`seq\` integer NOT NULL,
113114
\`wake_reported\` integer DEFAULT false NOT NULL,
115+
\`graph_rev\` integer DEFAULT 1 NOT NULL,
114116
\`started_at\` integer,
115117
\`completed_at\` integer,
116118
\`time_created\` integer NOT NULL,

0 commit comments

Comments
 (0)