@yeaft/webchat-agent 1.0.193 → 1.0.195

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
2
  import { mkdirSync, realpathSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
- import { randomUUID } from 'node:crypto';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
5
  import { normalizeEvidence } from './evidence.js';
6
6
  import { normalizeActionCheckpoint } from './action-checkpoint.js';
7
7
 
8
- const SCHEMA_VERSION = 12;
8
+ const SCHEMA_VERSION = 14;
9
9
  const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
10
10
  const MAX_REUSABLE_CONTEXT_ITEMS = 12;
11
11
  const MAX_RUN_RESPONSE_CHARS = 65_536;
@@ -29,12 +29,40 @@ function stringify(value) {
29
29
  return JSON.stringify(value ?? null);
30
30
  }
31
31
 
32
+ function stableJson(value) {
33
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
34
+ if (value && typeof value === 'object') {
35
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
36
+ }
37
+ return JSON.stringify(value);
38
+ }
39
+
40
+ function actionSpecHash(action) {
41
+ const spec = {
42
+ type: action.type || '',
43
+ stageId: action.stageId || action.type || '',
44
+ assignmentPolicy: action.assignmentPolicy || null,
45
+ modelPolicy: action.modelPolicy || null,
46
+ dependsOnStageIds: [...new Set(action.dependsOnStageIds || [])].sort(),
47
+ workspaceMode: action.workspaceMode || 'shared',
48
+ changesRequestedStageId: action.changesRequestedStageId || null,
49
+ requiredRole: action.requiredRole || '',
50
+ instruction: action.instruction || '',
51
+ brief: action.brief || null,
52
+ context: Array.isArray(action.context) ? action.context : [],
53
+ contractRevision: Number.isInteger(action.contractRevision) ? action.contractRevision : 1,
54
+ };
55
+ return createHash('sha256').update(stableJson(spec), 'utf8').digest('hex');
56
+ }
57
+
32
58
  function mapWorkItem(row) {
33
59
  if (!row) return null;
34
60
  return {
35
61
  id: row.id,
36
62
  revision: row.revision,
37
63
  planRevision: Math.max(0, Number(row.plan_revision) || 0),
64
+ executionSchemaVersion: Math.max(1, Number(row.execution_schema_version) || 1),
65
+ ledgerRevision: Math.max(0, Number(row.ledger_revision) || 0),
38
66
  title: row.title,
39
67
  goal: row.goal,
40
68
  acceptanceCriteria: parseJson(row.acceptance_criteria, []),
@@ -65,6 +93,39 @@ function mapWorkItem(row) {
65
93
  };
66
94
  }
67
95
 
96
+ function isGraphWorkItem(workItem) {
97
+ return workItem?.workflowSnapshot?.executionMode === 'graph';
98
+ }
99
+
100
+ function graphExecutionState(workItem, actions) {
101
+ if (!isGraphWorkItem(workItem)) return workItem;
102
+ const current = (Array.isArray(actions) ? actions : [])
103
+ .filter(action => !['superseded', 'cancelled'].includes(action.status));
104
+ const activeActionIds = current
105
+ .filter(action => ['ready', 'running'].includes(action.status))
106
+ .map(action => action.id);
107
+ const waitingIds = current.filter(action => action.status === 'waiting').map(action => action.id);
108
+ const failedIds = current.filter(action => action.status === 'failed').map(action => action.id);
109
+ const attentionActionIds = current
110
+ .filter(action => ['waiting', 'failed'].includes(action.status))
111
+ .map(action => action.id);
112
+ const lifecycle = workItem.status === 'cancelled' ? 'cancelled'
113
+ : current.length === 0 || workItem.status === 'draft' ? 'draft'
114
+ : current.every(action => action.status === 'completed') ? 'done'
115
+ : 'active';
116
+ const attentionState = waitingIds.length > 0 && failedIds.length > 0 ? 'mixed'
117
+ : waitingIds.length > 0 ? 'waiting'
118
+ : failedIds.length > 0 ? 'failed'
119
+ : 'none';
120
+ return {
121
+ ...workItem,
122
+ lifecycle,
123
+ attentionState,
124
+ activeActionIds,
125
+ attentionActionIds,
126
+ };
127
+ }
128
+
68
129
  function mapAction(row) {
69
130
  if (!row) return null;
70
131
  return {
@@ -84,6 +145,9 @@ function mapAction(row) {
84
145
  brief: parseJson(row.brief, null),
85
146
  context: parseJson(row.context, []),
86
147
  contractRevision: row.contract_revision,
148
+ generation: Math.max(1, Number(row.generation) || 1),
149
+ specHash: row.spec_hash || '',
150
+ resultRunId: row.result_run_id || null,
87
151
  status: row.status,
88
152
  attempt: row.attempt,
89
153
  maxAttempts: row.max_attempts,
@@ -110,11 +174,15 @@ function mapRun(row) {
110
174
  vpSnapshot: parseJson(row.vp_snapshot, null),
111
175
  modelSnapshot: parseJson(row.model_snapshot, null),
112
176
  toolPolicySnapshot: parseJson(row.tool_policy_snapshot, null),
177
+ contextSnapshot: parseJson(row.context_snapshot, null),
178
+ executionManifest: parseJson(row.execution_manifest, null),
113
179
  response: row.response || '',
114
180
  summary: row.summary || '',
115
181
  evidence: normalizeEvidence(parseJson(row.evidence, [])),
116
182
  waitingReason: row.waiting_reason || null,
117
183
  error: row.error || null,
184
+ failureKind: row.failure_kind || null,
185
+ failureCode: row.failure_code || null,
118
186
  reviewDecision: row.review_decision || null,
119
187
  contractPatch: parseJson(row.contract_patch, null),
120
188
  loopCount: Math.max(0, Number(row.loop_count) || 0),
@@ -130,6 +198,22 @@ function mapRun(row) {
130
198
  };
131
199
  }
132
200
 
201
+ function mapPlanConflict(row) {
202
+ if (!row) return null;
203
+ return {
204
+ id: row.id,
205
+ workItemId: row.work_item_id,
206
+ actionId: row.action_id || null,
207
+ generation: Math.max(1, Number(row.generation) || 1),
208
+ kind: row.kind,
209
+ status: row.status,
210
+ details: parseJson(row.details, {}),
211
+ createdAt: row.created_at,
212
+ updatedAt: row.updated_at,
213
+ resolvedAt: row.resolved_at || null,
214
+ };
215
+ }
216
+
133
217
  function mapEvent(row) {
134
218
  if (!row) return null;
135
219
  return {
@@ -183,6 +267,8 @@ export class WorkItemStore {
183
267
  id TEXT PRIMARY KEY,
184
268
  revision INTEGER NOT NULL DEFAULT 1,
185
269
  plan_revision INTEGER NOT NULL DEFAULT 0,
270
+ execution_schema_version INTEGER NOT NULL DEFAULT 2,
271
+ ledger_revision INTEGER NOT NULL DEFAULT 0,
186
272
  title TEXT NOT NULL,
187
273
  goal TEXT NOT NULL,
188
274
  acceptance_criteria TEXT NOT NULL,
@@ -218,6 +304,9 @@ export class WorkItemStore {
218
304
  brief TEXT,
219
305
  context TEXT NOT NULL DEFAULT '[]',
220
306
  contract_revision INTEGER NOT NULL DEFAULT 1,
307
+ generation INTEGER NOT NULL DEFAULT 1,
308
+ spec_hash TEXT NOT NULL DEFAULT '',
309
+ result_run_id TEXT,
221
310
  status TEXT NOT NULL,
222
311
  attempt INTEGER NOT NULL DEFAULT 0,
223
312
  max_attempts INTEGER NOT NULL DEFAULT 2,
@@ -241,10 +330,14 @@ export class WorkItemStore {
241
330
  vp_snapshot TEXT,
242
331
  model_snapshot TEXT,
243
332
  tool_policy_snapshot TEXT,
333
+ context_snapshot TEXT,
334
+ execution_manifest TEXT,
244
335
  summary TEXT,
245
336
  evidence TEXT NOT NULL DEFAULT '[]',
246
337
  waiting_reason TEXT,
247
338
  error TEXT,
339
+ failure_kind TEXT,
340
+ failure_code TEXT,
248
341
  review_decision TEXT,
249
342
  contract_patch TEXT,
250
343
  response TEXT NOT NULL DEFAULT '',
@@ -281,6 +374,19 @@ export class WorkItemStore {
281
374
  created_at INTEGER NOT NULL,
282
375
  UNIQUE(work_item_id, proposal_id)
283
376
  );
377
+ CREATE TABLE IF NOT EXISTS plan_conflicts (
378
+ id TEXT PRIMARY KEY,
379
+ work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
380
+ action_id TEXT REFERENCES actions(id) ON DELETE SET NULL,
381
+ generation INTEGER NOT NULL DEFAULT 1,
382
+ kind TEXT NOT NULL,
383
+ status TEXT NOT NULL DEFAULT 'open',
384
+ details TEXT NOT NULL DEFAULT '{}',
385
+ created_at INTEGER NOT NULL,
386
+ updated_at INTEGER NOT NULL,
387
+ resolved_at INTEGER
388
+ );
389
+ CREATE INDEX IF NOT EXISTS idx_plan_conflicts_work_item ON plan_conflicts(work_item_id, created_at, id);
284
390
  CREATE INDEX IF NOT EXISTS idx_work_items_status_updated ON work_items(status, updated_at DESC);
285
391
  CREATE INDEX IF NOT EXISTS idx_actions_ready ON actions(status, updated_at, sequence);
286
392
  CREATE INDEX IF NOT EXISTS idx_runs_active ON runs(status, expires_at);
@@ -320,6 +426,12 @@ export class WorkItemStore {
320
426
  if (!hasColumn(this.db, 'runs', 'contract_patch')) {
321
427
  this.db.exec('ALTER TABLE runs ADD COLUMN contract_patch TEXT');
322
428
  }
429
+ if (!hasColumn(this.db, 'runs', 'failure_kind')) {
430
+ this.db.exec('ALTER TABLE runs ADD COLUMN failure_kind TEXT');
431
+ }
432
+ if (!hasColumn(this.db, 'runs', 'failure_code')) {
433
+ this.db.exec('ALTER TABLE runs ADD COLUMN failure_code TEXT');
434
+ }
323
435
  if (!hasColumn(this.db, 'runs', 'loop_count')) {
324
436
  this.db.exec('ALTER TABLE runs ADD COLUMN loop_count INTEGER NOT NULL DEFAULT 0');
325
437
  }
@@ -380,6 +492,27 @@ export class WorkItemStore {
380
492
  if (!hasColumn(this.db, 'work_items', 'plan_revision')) {
381
493
  this.db.exec('ALTER TABLE work_items ADD COLUMN plan_revision INTEGER NOT NULL DEFAULT 0');
382
494
  }
495
+ if (!hasColumn(this.db, 'work_items', 'execution_schema_version')) {
496
+ this.db.exec('ALTER TABLE work_items ADD COLUMN execution_schema_version INTEGER NOT NULL DEFAULT 1');
497
+ }
498
+ if (!hasColumn(this.db, 'work_items', 'ledger_revision')) {
499
+ this.db.exec('ALTER TABLE work_items ADD COLUMN ledger_revision INTEGER NOT NULL DEFAULT 0');
500
+ }
501
+ if (!hasColumn(this.db, 'actions', 'generation')) {
502
+ this.db.exec('ALTER TABLE actions ADD COLUMN generation INTEGER NOT NULL DEFAULT 1');
503
+ }
504
+ if (!hasColumn(this.db, 'actions', 'spec_hash')) {
505
+ this.db.exec("ALTER TABLE actions ADD COLUMN spec_hash TEXT NOT NULL DEFAULT ''");
506
+ }
507
+ if (!hasColumn(this.db, 'actions', 'result_run_id')) {
508
+ this.db.exec('ALTER TABLE actions ADD COLUMN result_run_id TEXT');
509
+ }
510
+ if (!hasColumn(this.db, 'runs', 'context_snapshot')) {
511
+ this.db.exec('ALTER TABLE runs ADD COLUMN context_snapshot TEXT');
512
+ }
513
+ if (!hasColumn(this.db, 'runs', 'execution_manifest')) {
514
+ this.db.exec('ALTER TABLE runs ADD COLUMN execution_manifest TEXT');
515
+ }
383
516
  this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
384
517
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
385
518
  }
@@ -402,16 +535,58 @@ export class WorkItemStore {
402
535
  return Number(result.lastInsertRowid);
403
536
  }
404
537
 
538
+ createPlanConflict(workItemId, input = {}) {
539
+ const id = input.id || randomUUID();
540
+ const now = this.now();
541
+ this.db.prepare(`INSERT INTO plan_conflicts
542
+ (id, work_item_id, action_id, generation, kind, status, details, created_at, updated_at, resolved_at)
543
+ VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, NULL)`).run(
544
+ id,
545
+ workItemId,
546
+ input.actionId || null,
547
+ Number.isInteger(input.generation) && input.generation > 0 ? input.generation : 1,
548
+ input.kind || 'plan',
549
+ stringify(input.details || {}),
550
+ now,
551
+ now,
552
+ );
553
+ return this.getPlanConflict(id);
554
+ }
555
+
556
+ getPlanConflict(id) {
557
+ return mapPlanConflict(this.db.prepare('SELECT * FROM plan_conflicts WHERE id = ?').get(id));
558
+ }
559
+
560
+ listPlanConflicts(workItemId, options = {}) {
561
+ const status = typeof options.status === 'string' && options.status ? options.status : null;
562
+ return this.db.prepare(`SELECT * FROM plan_conflicts WHERE work_item_id = ?
563
+ AND (? IS NULL OR status = ?) ORDER BY created_at, id`).all(workItemId, status, status).map(mapPlanConflict);
564
+ }
565
+
566
+ resolvePlanConflict(id, details = null) {
567
+ const now = this.now();
568
+ const changed = details && typeof details === 'object'
569
+ ? this.db.prepare(`UPDATE plan_conflicts SET status = 'resolved', details = ?,
570
+ updated_at = ?, resolved_at = ? WHERE id = ? AND status = 'open'`).run(stringify(details), now, now, id)
571
+ : this.db.prepare(`UPDATE plan_conflicts SET status = 'resolved',
572
+ updated_at = ?, resolved_at = ? WHERE id = ? AND status = 'open'`).run(now, now, id);
573
+ return Number(changed.changes) === 1 ? this.getPlanConflict(id) : null;
574
+ }
575
+
576
+ deletePlanConflict(id) {
577
+ return Number(this.db.prepare('DELETE FROM plan_conflicts WHERE id = ?').run(id).changes) === 1;
578
+ }
579
+
405
580
  createWorkItem(input, firstAction) {
406
581
  return withTransaction(this.db, () => {
407
582
  const now = this.now();
408
583
  const id = input.id || randomUUID();
409
584
  const workspaceKey = canonicalWorkspaceKey(input.workDir);
410
585
  this.db.prepare(`INSERT INTO work_items
411
- (id, revision, title, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
586
+ (id, revision, execution_schema_version, ledger_revision, title, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
412
587
  current_action_id, current_run_id, work_dir, workspace_key, reuse_memory, origin, linked_session_ids,
413
588
  session_context, attachments, created_at, updated_at)
414
- VALUES (?, 1, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
589
+ VALUES (?, 1, 2, 0, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
415
590
  id,
416
591
  input.title,
417
592
  input.goal,
@@ -457,6 +632,9 @@ export class WorkItemStore {
457
632
  brief: input.brief && typeof input.brief === 'object' ? input.brief : null,
458
633
  context: Array.isArray(input.context) ? input.context : [],
459
634
  contractRevision: Number.isInteger(input.contractRevision) ? input.contractRevision : 1,
635
+ generation: Number.isInteger(input.generation) && input.generation > 0 ? input.generation : 1,
636
+ specHash: '',
637
+ resultRunId: input.resultRunId || null,
460
638
  status: input.status || 'ready',
461
639
  attempt: Number.isInteger(input.attempt) ? input.attempt : 0,
462
640
  maxAttempts: Number.isInteger(input.maxAttempts) ? input.maxAttempts : 2,
@@ -465,11 +643,12 @@ export class WorkItemStore {
465
643
  createdAt: now,
466
644
  updatedAt: now,
467
645
  };
646
+ action.specHash = actionSpecHash(action);
468
647
  this.db.prepare(`INSERT INTO actions
469
648
  (id, work_item_id, sequence, type, required_role, stage_id, assignment_policy, model_policy,
470
649
  depends_on_stage_ids, workspace_mode, changes_requested_stage_id, workspace, instruction, brief, context, contract_revision,
471
- status, attempt, max_attempts, current_run_id, lease_epoch, created_at, updated_at)
472
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
650
+ generation, spec_hash, result_run_id, status, attempt, max_attempts, current_run_id, lease_epoch, created_at, updated_at)
651
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
473
652
  action.id,
474
653
  workItemId,
475
654
  action.sequence,
@@ -486,6 +665,9 @@ export class WorkItemStore {
486
665
  stringify(action.brief),
487
666
  stringify(action.context),
488
667
  action.contractRevision,
668
+ action.generation,
669
+ action.specHash,
670
+ action.resultRunId,
489
671
  action.status,
490
672
  action.attempt,
491
673
  action.maxAttempts,
@@ -517,9 +699,6 @@ export class WorkItemStore {
517
699
  const target = actions.find(action => action.stageId === targetStageId);
518
700
  if (!target) throw new Error('Work Center graph reset target is missing');
519
701
  const affected = new Set([targetStageId]);
520
- for (const action of actions) {
521
- if (action.status === 'running') affected.add(action.stageId);
522
- }
523
702
  let changed = true;
524
703
  while (changed) {
525
704
  changed = false;
@@ -548,16 +727,27 @@ export class WorkItemStore {
548
727
  WHERE action_id IN (${placeholders}) AND status = 'running'`).run(now, reason, ...ids);
549
728
  for (const action of affectedActions) {
550
729
  const workspace = action.id === target.id ? preservedTargetWorkspace : null;
730
+ const nextAction = action.id === target.id && replacement
731
+ ? { ...action, ...replacement, generation: action.generation + 1 }
732
+ : { ...action, generation: action.generation + 1 };
551
733
  this.db.prepare(`UPDATE actions SET status = 'ready', attempt = 0, current_run_id = NULL,
552
- lease_epoch = ?, workspace = ?, updated_at = ? WHERE id = ?`).run(
553
- nextEpoch.get(action.id), stringify(workspace), now, action.id,
734
+ lease_epoch = ?, generation = generation + 1, spec_hash = ?, result_run_id = NULL,
735
+ workspace = ?, updated_at = ? WHERE id = ?`).run(
736
+ nextEpoch.get(action.id), actionSpecHash(nextAction), stringify(workspace), now, action.id,
554
737
  );
555
738
  }
556
739
  }
557
740
  if (replacement) {
741
+ const replacementAction = {
742
+ ...target,
743
+ ...replacement,
744
+ generation: target.generation + 1,
745
+ contractRevision: replacement.contractRevision ?? target.contractRevision,
746
+ };
747
+ const specHash = actionSpecHash(replacementAction);
558
748
  this.db.prepare(`UPDATE actions SET type = ?, required_role = ?, assignment_policy = ?,
559
749
  model_policy = ?, depends_on_stage_ids = ?, workspace_mode = ?, changes_requested_stage_id = ?,
560
- instruction = ?, brief = ?, context = ?, max_attempts = ?, workspace = ?, updated_at = ?
750
+ instruction = ?, brief = ?, context = ?, max_attempts = ?, workspace = ?, spec_hash = ?, updated_at = ?
561
751
  WHERE id = ?`).run(
562
752
  replacement.type || target.type,
563
753
  replacement.requiredRole || '',
@@ -571,6 +761,7 @@ export class WorkItemStore {
571
761
  stringify(Array.isArray(replacement.context) ? replacement.context : []),
572
762
  Number.isInteger(replacement.maxAttempts) ? replacement.maxAttempts : 2,
573
763
  stringify(preservedTargetWorkspace),
764
+ specHash,
574
765
  now,
575
766
  target.id,
576
767
  );
@@ -583,11 +774,62 @@ export class WorkItemStore {
583
774
  }
584
775
 
585
776
  setActionWorkspace(actionId, workspace, workspaceMode = null) {
586
- const changed = this.db.prepare(`UPDATE actions SET workspace = ?,
587
- workspace_mode = COALESCE(?, workspace_mode), updated_at = ? WHERE id = ?`).run(
588
- stringify(workspace), workspaceMode, this.now(), actionId,
589
- );
590
- return Number(changed.changes) === 1 ? this.getAction(actionId) : null;
777
+ return withTransaction(this.db, () => {
778
+ const action = this.getAction(actionId);
779
+ if (!action) return null;
780
+ const nextWorkspaceMode = workspaceMode || action.workspaceMode;
781
+ const specChanged = nextWorkspaceMode !== action.workspaceMode;
782
+ const nextAction = { ...action, workspaceMode: nextWorkspaceMode };
783
+ const changed = this.db.prepare(`UPDATE actions SET workspace = ?, workspace_mode = ?,
784
+ generation = generation + ?, spec_hash = ?, result_run_id = CASE WHEN ? = 1 THEN NULL ELSE result_run_id END,
785
+ updated_at = ? WHERE id = ? AND generation = ?`).run(
786
+ stringify(workspace),
787
+ nextWorkspaceMode,
788
+ specChanged ? 1 : 0,
789
+ specChanged ? actionSpecHash(nextAction) : action.specHash,
790
+ specChanged ? 1 : 0,
791
+ this.now(),
792
+ actionId,
793
+ action.generation,
794
+ );
795
+ return Number(changed.changes) === 1 ? this.getAction(actionId) : null;
796
+ });
797
+ }
798
+
799
+ setActionWorkspaceForRun(
800
+ actionId,
801
+ runId,
802
+ ownerBootId,
803
+ leaseEpoch,
804
+ expectedGeneration,
805
+ workspace,
806
+ workspaceMode = null,
807
+ ) {
808
+ return withTransaction(this.db, () => {
809
+ const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
810
+ if (!active || active.action_id !== actionId) return null;
811
+ const action = this.getAction(actionId);
812
+ if (!action || action.generation !== expectedGeneration) return null;
813
+ const nextWorkspaceMode = workspaceMode || action.workspaceMode;
814
+ const specChanged = nextWorkspaceMode !== action.workspaceMode;
815
+ const nextAction = { ...action, workspaceMode: nextWorkspaceMode };
816
+ const changed = this.db.prepare(`UPDATE actions SET workspace = ?, workspace_mode = ?,
817
+ generation = generation + ?, spec_hash = ?, result_run_id = CASE WHEN ? = 1 THEN NULL ELSE result_run_id END,
818
+ updated_at = ? WHERE id = ? AND status = 'running' AND current_run_id = ?
819
+ AND lease_epoch = ? AND generation = ?`).run(
820
+ stringify(workspace),
821
+ nextWorkspaceMode,
822
+ specChanged ? 1 : 0,
823
+ specChanged ? actionSpecHash(nextAction) : action.specHash,
824
+ specChanged ? 1 : 0,
825
+ this.now(),
826
+ actionId,
827
+ runId,
828
+ leaseEpoch,
829
+ expectedGeneration,
830
+ );
831
+ return Number(changed.changes) === 1 ? this.getAction(actionId) : null;
832
+ });
591
833
  }
592
834
 
593
835
  setIntegrationWorkspaceForRun(actionId, runId, ownerBootId, leaseEpoch, workspace) {
@@ -683,7 +925,11 @@ export class WorkItemStore {
683
925
  }
684
926
 
685
927
  getWorkItem(id) {
686
- return mapWorkItem(this.db.prepare('SELECT * FROM work_items WHERE id = ?').get(id));
928
+ const workItem = mapWorkItem(this.db.prepare('SELECT * FROM work_items WHERE id = ?').get(id));
929
+ if (!isGraphWorkItem(workItem)) return workItem;
930
+ const actions = this.db.prepare('SELECT * FROM actions WHERE work_item_id = ? ORDER BY sequence')
931
+ .all(id).map(mapAction);
932
+ return graphExecutionState(workItem, actions);
687
933
  }
688
934
 
689
935
  getAction(id) {
@@ -734,18 +980,25 @@ export class WorkItemStore {
734
980
  FROM work_items w LEFT JOIN runs r ON r.work_item_id = w.id
735
981
  ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
736
982
  GROUP BY w.id ORDER BY w.updated_at DESC LIMIT ?`;
737
- return this.db.prepare(sql).all(...values, limit).map(mapWorkItem);
983
+ return this.db.prepare(sql).all(...values, limit).map(mapWorkItem).map(workItem => {
984
+ if (!isGraphWorkItem(workItem)) return workItem;
985
+ const actions = this.db.prepare('SELECT * FROM actions WHERE work_item_id = ? ORDER BY sequence')
986
+ .all(workItem.id).map(mapAction);
987
+ return graphExecutionState(workItem, actions);
988
+ });
738
989
  }
739
990
 
740
991
  getWorkItemDetail(id) {
741
992
  const workItem = this.getWorkItem(id);
742
993
  if (!workItem) return null;
743
- return {
994
+ const detail = {
744
995
  ...workItem,
745
996
  actions: this.db.prepare('SELECT * FROM actions WHERE work_item_id = ? ORDER BY sequence').all(id).map(mapAction),
746
997
  runs: this.db.prepare('SELECT * FROM runs WHERE work_item_id = ? ORDER BY started_at DESC').all(id).map(mapRun),
998
+ planConflicts: this.listPlanConflicts(id),
747
999
  events: this.db.prepare('SELECT * FROM events WHERE work_item_id = ? ORDER BY id DESC LIMIT 500').all(id).map(mapEvent),
748
1000
  };
1001
+ return graphExecutionState(detail, detail.actions);
749
1002
  }
750
1003
 
751
1004
  getReusableContext(workDir, excludeWorkItemId = null) {
@@ -777,14 +1030,26 @@ export class WorkItemStore {
777
1030
  return withTransaction(this.db, () => {
778
1031
  const workItem = this.getWorkItem(id);
779
1032
  if (!workItem) return null;
780
- if (workItem.currentActionId !== expected.actionId || workItem.revision !== expected.revision) {
1033
+ const graphMode = isGraphWorkItem(workItem);
1034
+ const expectedAction = this.getAction(expected.actionId);
1035
+ const expectedMatches = graphMode
1036
+ ? expectedAction?.workItemId === id && expectedAction.generation === expected.generation
1037
+ : workItem.currentActionId === expected.actionId;
1038
+ if (!expectedMatches || workItem.revision !== expected.revision) {
781
1039
  throw new Error('Action changed before guidance was applied; refresh and try again');
782
1040
  }
783
- if (!['ready', 'running'].includes(workItem.status)) {
1041
+ const guidanceStatuses = graphMode
1042
+ ? ['ready', 'running', 'waiting', 'needs_attention']
1043
+ : ['ready', 'running'];
1044
+ if (!guidanceStatuses.includes(workItem.status)) {
784
1045
  throw new Error(`WorkItem in ${workItem.status} cannot accept Action guidance`);
785
1046
  }
786
- const previous = workItem.currentActionId ? this.getAction(workItem.currentActionId) : null;
787
- if (!previous || !['ready', 'running'].includes(previous.status)) {
1047
+ const previous = graphMode ? expectedAction
1048
+ : (workItem.currentActionId ? this.getAction(workItem.currentActionId) : null);
1049
+ const actionableStatuses = graphMode
1050
+ ? ['ready', 'running', 'waiting', 'needs_attention']
1051
+ : ['ready', 'running'];
1052
+ if (!previous || !actionableStatuses.includes(previous.status)) {
788
1053
  throw new Error('WorkItem has no active Action for guidance');
789
1054
  }
790
1055
  const now = this.now();
@@ -794,7 +1059,7 @@ export class WorkItemStore {
794
1059
  contractRevision: previous.contractRevision,
795
1060
  };
796
1061
  let action;
797
- if (workItem.workflowSnapshot?.executionMode === 'graph') {
1062
+ if (graphMode) {
798
1063
  action = this.#resetGraphFromStage(
799
1064
  id,
800
1065
  previous.stageId,
@@ -954,12 +1219,13 @@ export class WorkItemStore {
954
1219
  if (!['waiting', 'needs_attention'].includes(workItem.status)) {
955
1220
  throw new Error(`WorkItem in ${workItem.status} does not need retry`);
956
1221
  }
957
- const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
1222
+ const graphMode = isGraphWorkItem(workItem);
958
1223
  let previous = workItem.currentActionId ? this.getAction(workItem.currentActionId) : null;
959
1224
  if (options.expected) {
960
1225
  const expectedAction = this.getAction(options.expected.actionId);
961
1226
  const expectedMatches = graphMode
962
- ? expectedAction?.workItemId === id && ['waiting', 'failed'].includes(expectedAction.status)
1227
+ ? expectedAction?.workItemId === id && expectedAction.generation === options.expected.generation
1228
+ && ['waiting', 'failed'].includes(expectedAction.status)
963
1229
  : workItem.currentActionId === options.expected.actionId;
964
1230
  if (!expectedMatches || workItem.revision !== options.expected.revision) {
965
1231
  throw new Error('Action changed before input was applied; refresh and try again');
@@ -1034,7 +1300,7 @@ export class WorkItemStore {
1034
1300
  AND w.status = 'ready' AND w.current_action_id = a.id AND w.current_run_id IS NULL)
1035
1301
  OR
1036
1302
  (json_extract(w.workflow_snapshot, '$.executionMode') = 'graph'
1037
- AND w.status IN ('ready', 'running')
1303
+ AND w.status IN ('ready', 'running', 'waiting', 'needs_attention')
1038
1304
  AND NOT EXISTS (
1039
1305
  SELECT 1 FROM json_each(a.depends_on_stage_ids) dependency
1040
1306
  LEFT JOIN actions required ON required.work_item_id = a.work_item_id
@@ -1069,10 +1335,11 @@ export class WorkItemStore {
1069
1335
  );
1070
1336
  if (Number(changedAction.changes) !== 1) return null;
1071
1337
  const workItem = this.getWorkItem(row.work_item_id);
1072
- const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
1338
+ const graphMode = isGraphWorkItem(workItem);
1073
1339
  const changedWorkItem = graphMode
1074
1340
  ? this.db.prepare(`UPDATE work_items SET status = 'running', current_action_id = ?,
1075
- current_run_id = NULL, updated_at = ? WHERE id = ? AND status IN ('ready', 'running')`).run(
1341
+ current_run_id = NULL, updated_at = ? WHERE id = ?
1342
+ AND status IN ('ready', 'running', 'waiting', 'needs_attention')`).run(
1076
1343
  row.id, now, row.work_item_id,
1077
1344
  )
1078
1345
  : this.db.prepare(`UPDATE work_items SET status = 'running', current_run_id = ?, updated_at = ?
@@ -1103,13 +1370,13 @@ export class WorkItemStore {
1103
1370
  JOIN work_items w ON w.id = r.work_item_id
1104
1371
  WHERE r.id = ? AND r.owner_boot_id = ? AND r.lease_epoch = ? AND r.status = 'running'
1105
1372
  AND a.status = 'running' AND a.current_run_id = r.id AND a.lease_epoch = r.lease_epoch
1106
- AND w.status IN ('ready', 'running', 'waiting')
1373
+ AND w.status IN ('ready', 'running', 'waiting', 'needs_attention')
1107
1374
  ${requireUnexpired ? 'AND r.expires_at > ?' : ''}`).get(
1108
1375
  runId, ownerBootId, leaseEpoch, ...(requireUnexpired ? [this.now()] : []),
1109
1376
  );
1110
1377
  if (!row) return null;
1111
1378
  const workItem = this.getWorkItem(row.work_item_id);
1112
- if (workItem?.workflowSnapshot?.executionMode === 'graph') return row;
1379
+ if (isGraphWorkItem(workItem)) return row;
1113
1380
  return workItem?.status === 'running' && workItem.currentActionId === row.action_id
1114
1381
  && workItem.currentRunId === row.id ? row : null;
1115
1382
  }
@@ -1189,7 +1456,8 @@ export class WorkItemStore {
1189
1456
  leaseEpoch,
1190
1457
  );
1191
1458
  if (Number(actionChanged.changes) !== 1) throw new Error('Run interruption lost the Action fence');
1192
- const graphMode = this.getWorkItem(active.work_item_id)?.workflowSnapshot?.executionMode === 'graph';
1459
+ const activeWorkItem = this.getWorkItem(active.work_item_id);
1460
+ const graphMode = isGraphWorkItem(activeWorkItem);
1193
1461
  const itemChanged = graphMode
1194
1462
  ? this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
1195
1463
  updated_at = ? WHERE id = ? AND status = 'running'`).run(
@@ -1213,13 +1481,16 @@ export class WorkItemStore {
1213
1481
  return withTransaction(this.db, () => {
1214
1482
  if (!this.#activeRunRow(runId, ownerBootId, leaseEpoch, true)) return false;
1215
1483
  const result = this.db.prepare(`UPDATE runs SET role_snapshot = ?, vp_snapshot = ?,
1216
- model_snapshot = ?, tool_policy_snapshot = ? WHERE id = ? AND status = 'running'
1484
+ model_snapshot = ?, tool_policy_snapshot = ?, context_snapshot = ?, execution_manifest = ?
1485
+ WHERE id = ? AND status = 'running'
1217
1486
  AND role_snapshot IS NULL AND vp_snapshot IS NULL AND model_snapshot IS NULL
1218
- AND tool_policy_snapshot IS NULL`).run(
1487
+ AND tool_policy_snapshot IS NULL AND context_snapshot IS NULL AND execution_manifest IS NULL`).run(
1219
1488
  stringify(snapshots.roleSnapshot || null),
1220
1489
  stringify(snapshots.vpSnapshot || null),
1221
1490
  stringify(snapshots.modelSnapshot || null),
1222
1491
  stringify(snapshots.toolPolicySnapshot || null),
1492
+ stringify(snapshots.contextSnapshot || null),
1493
+ stringify(snapshots.executionManifest || null),
1223
1494
  runId,
1224
1495
  );
1225
1496
  return Number(result.changes) === 1;
@@ -1287,8 +1558,10 @@ export class WorkItemStore {
1287
1558
  throw new Error('Work Center transition plan is incomplete');
1288
1559
  }
1289
1560
  const now = this.now();
1561
+ const ledgerIncrement = workItem.executionSchemaVersion === 2
1562
+ && ['completed', 'failed', 'waiting'].includes(result.outcome) ? 1 : 0;
1290
1563
  this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, response = ?, summary = ?, evidence = ?,
1291
- waiting_reason = ?, error = ?, review_decision = ?, contract_patch = ?, checkpoint = ?,
1564
+ waiting_reason = ?, error = ?, failure_kind = ?, failure_code = ?, review_decision = ?, contract_patch = ?, checkpoint = ?,
1292
1565
  loop_count = ?, tool_count = ?, llm_request_count = ?, input_tokens = ?, output_tokens = ?,
1293
1566
  cache_read_tokens = ?, cache_write_tokens = ?, total_tokens = ?,
1294
1567
  progress_revision = progress_revision + 1 WHERE id = ?`).run(
@@ -1299,6 +1572,8 @@ export class WorkItemStore {
1299
1572
  stringify(normalizeEvidence(result.evidence)),
1300
1573
  result.waitingReason || null,
1301
1574
  result.error || null,
1575
+ result.failureKind || null,
1576
+ result.failureCode || null,
1302
1577
  result.reviewDecision || null,
1303
1578
  stringify(result.contractPatch || null),
1304
1579
  stringify(normalizeActionCheckpoint(result.checkpoint)),
@@ -1314,12 +1589,17 @@ export class WorkItemStore {
1314
1589
  );
1315
1590
  this.onTransitionStep?.('after_run_update');
1316
1591
 
1317
- const changedAction = this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL, updated_at = ?
1318
- WHERE id = ? AND status = 'running' AND current_run_id = ?`).run(
1592
+ const changedAction = this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL,
1593
+ result_run_id = CASE WHEN ? = 'completed' THEN ? ELSE result_run_id END, updated_at = ?
1594
+ WHERE id = ? AND status = 'running' AND current_run_id = ? AND lease_epoch = ? AND generation = ?`).run(
1319
1595
  transition.actionStatus,
1596
+ result.outcome,
1597
+ runId,
1320
1598
  now,
1321
1599
  action.id,
1322
1600
  runId,
1601
+ leaseEpoch,
1602
+ action.generation,
1323
1603
  );
1324
1604
  if (Number(changedAction.changes) !== 1) {
1325
1605
  throw new Error('Work Center terminal transition lost the current Action fence');
@@ -1446,14 +1726,15 @@ export class WorkItemStore {
1446
1726
  : 'done';
1447
1727
  currentActionId = blocked?.id || runnable?.id || null;
1448
1728
  changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
1449
- current_run_id = NULL, updated_at = ? WHERE id = ? AND status IN ('ready', 'running')`).run(
1450
- workItemStatus, currentActionId, now, workItem.id,
1729
+ current_run_id = NULL, ledger_revision = ledger_revision + ?, updated_at = ?
1730
+ WHERE id = ? AND status IN ('ready', 'running', 'waiting', 'needs_attention')`).run(
1731
+ workItemStatus, currentActionId, ledgerIncrement, now, workItem.id,
1451
1732
  );
1452
1733
  } else {
1453
1734
  changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
1454
- current_run_id = NULL, updated_at = ? WHERE id = ? AND current_run_id = ?
1735
+ current_run_id = NULL, ledger_revision = ledger_revision + ?, updated_at = ? WHERE id = ? AND current_run_id = ?
1455
1736
  AND current_action_id = ? AND status = 'running'`).run(
1456
- workItemStatus, currentActionId, now, workItem.id, runId, action.id,
1737
+ workItemStatus, currentActionId, ledgerIncrement, now, workItem.id, runId, action.id,
1457
1738
  );
1458
1739
  }
1459
1740
  if (Number(changedWorkItem.changes) !== 1) {
@@ -1516,7 +1797,7 @@ export class WorkItemStore {
1516
1797
  const action = this.getAction(row.action_id);
1517
1798
  if (this.#hasActiveIntegrationReservation(action, now)) continue;
1518
1799
  const workItem = this.getWorkItem(row.work_item_id);
1519
- const graphMode = workItem?.workflowSnapshot?.executionMode === 'graph';
1800
+ const graphMode = isGraphWorkItem(workItem);
1520
1801
  const isCurrent = action?.status === 'running'
1521
1802
  && action.currentRunId === row.id
1522
1803
  && action.leaseEpoch === row.lease_epoch