@yeaft/webchat-agent 1.0.166 → 1.0.168

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.
@@ -5,7 +5,7 @@ import { 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 = 8;
8
+ const SCHEMA_VERSION = 11;
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;
@@ -47,6 +47,7 @@ function mapWorkItem(row) {
47
47
  reuseMemory: row.reuse_memory !== 0,
48
48
  origin: parseJson(row.origin, null),
49
49
  linkedSessionIds: parseJson(row.linked_session_ids, []),
50
+ sessionContext: parseJson(row.session_context, []),
50
51
  attachments: parseJson(row.attachments, []),
51
52
  executionStats: {
52
53
  llmRequestCount: Math.max(0, Number(row.usage_llm_request_count) || 0),
@@ -73,6 +74,10 @@ function mapAction(row) {
73
74
  stageId: row.stage_id || row.type,
74
75
  assignmentPolicy: parseJson(row.assignment_policy, null),
75
76
  modelPolicy: parseJson(row.model_policy, null),
77
+ dependsOnStageIds: parseJson(row.depends_on_stage_ids, []),
78
+ workspaceMode: row.workspace_mode || 'shared',
79
+ changesRequestedStageId: row.changes_requested_stage_id || null,
80
+ workspace: parseJson(row.workspace, null),
76
81
  requiredRole: row.required_role || '',
77
82
  instruction: row.instruction,
78
83
  brief: parseJson(row.brief, null),
@@ -189,6 +194,7 @@ export class WorkItemStore {
189
194
  reuse_memory INTEGER NOT NULL DEFAULT 1,
190
195
  origin TEXT,
191
196
  linked_session_ids TEXT NOT NULL DEFAULT '[]',
197
+ session_context TEXT NOT NULL DEFAULT '[]',
192
198
  attachments TEXT NOT NULL DEFAULT '[]',
193
199
  created_at INTEGER NOT NULL,
194
200
  updated_at INTEGER NOT NULL
@@ -202,6 +208,10 @@ export class WorkItemStore {
202
208
  stage_id TEXT,
203
209
  assignment_policy TEXT,
204
210
  model_policy TEXT,
211
+ depends_on_stage_ids TEXT NOT NULL DEFAULT '[]',
212
+ workspace_mode TEXT NOT NULL DEFAULT 'shared',
213
+ changes_requested_stage_id TEXT,
214
+ workspace TEXT,
205
215
  instruction TEXT NOT NULL,
206
216
  brief TEXT,
207
217
  context TEXT NOT NULL DEFAULT '[]',
@@ -325,6 +335,9 @@ export class WorkItemStore {
325
335
  if (!hasColumn(this.db, 'work_items', 'workflow_snapshot')) {
326
336
  this.db.exec('ALTER TABLE work_items ADD COLUMN workflow_snapshot TEXT');
327
337
  }
338
+ if (!hasColumn(this.db, 'work_items', 'session_context')) {
339
+ this.db.exec("ALTER TABLE work_items ADD COLUMN session_context TEXT NOT NULL DEFAULT '[]'");
340
+ }
328
341
  if (!hasColumn(this.db, 'work_items', 'attachments')) {
329
342
  this.db.exec("ALTER TABLE work_items ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]'");
330
343
  }
@@ -337,6 +350,18 @@ export class WorkItemStore {
337
350
  if (!hasColumn(this.db, 'actions', 'model_policy')) {
338
351
  this.db.exec('ALTER TABLE actions ADD COLUMN model_policy TEXT');
339
352
  }
353
+ if (!hasColumn(this.db, 'actions', 'depends_on_stage_ids')) {
354
+ this.db.exec("ALTER TABLE actions ADD COLUMN depends_on_stage_ids TEXT NOT NULL DEFAULT '[]'");
355
+ }
356
+ if (!hasColumn(this.db, 'actions', 'workspace_mode')) {
357
+ this.db.exec("ALTER TABLE actions ADD COLUMN workspace_mode TEXT NOT NULL DEFAULT 'shared'");
358
+ }
359
+ if (!hasColumn(this.db, 'actions', 'changes_requested_stage_id')) {
360
+ this.db.exec('ALTER TABLE actions ADD COLUMN changes_requested_stage_id TEXT');
361
+ }
362
+ if (!hasColumn(this.db, 'actions', 'workspace')) {
363
+ this.db.exec('ALTER TABLE actions ADD COLUMN workspace TEXT');
364
+ }
340
365
  this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
341
366
  ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
342
367
  }
@@ -367,8 +392,8 @@ export class WorkItemStore {
367
392
  this.db.prepare(`INSERT INTO work_items
368
393
  (id, revision, title, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
369
394
  current_action_id, current_run_id, work_dir, workspace_key, reuse_memory, origin, linked_session_ids,
370
- attachments, created_at, updated_at)
371
- VALUES (?, 1, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
395
+ session_context, attachments, created_at, updated_at)
396
+ VALUES (?, 1, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
372
397
  id,
373
398
  input.title,
374
399
  input.goal,
@@ -381,6 +406,7 @@ export class WorkItemStore {
381
406
  input.reuseMemory === false ? 0 : 1,
382
407
  stringify(input.origin || null),
383
408
  stringify(input.linkedSessionIds || []),
409
+ stringify(input.sessionContext || []),
384
410
  stringify(input.attachments || []),
385
411
  now,
386
412
  now,
@@ -404,6 +430,10 @@ export class WorkItemStore {
404
430
  stageId: input.stageId || input.type,
405
431
  assignmentPolicy: input.assignmentPolicy || null,
406
432
  modelPolicy: input.modelPolicy || null,
433
+ dependsOnStageIds: Array.isArray(input.dependsOnStageIds) ? input.dependsOnStageIds : [],
434
+ workspaceMode: input.workspaceMode || 'shared',
435
+ changesRequestedStageId: input.changesRequestedStageId || null,
436
+ workspace: input.workspace || null,
407
437
  requiredRole: input.requiredRole || '',
408
438
  instruction: input.instruction || '',
409
439
  brief: input.brief && typeof input.brief === 'object' ? input.brief : null,
@@ -419,9 +449,9 @@ export class WorkItemStore {
419
449
  };
420
450
  this.db.prepare(`INSERT INTO actions
421
451
  (id, work_item_id, sequence, type, required_role, stage_id, assignment_policy, model_policy,
422
- instruction, brief, context, contract_revision, status, attempt, max_attempts, current_run_id,
423
- lease_epoch, created_at, updated_at)
424
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
452
+ depends_on_stage_ids, workspace_mode, changes_requested_stage_id, workspace, instruction, brief, context, contract_revision,
453
+ status, attempt, max_attempts, current_run_id, lease_epoch, created_at, updated_at)
454
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?)`).run(
425
455
  action.id,
426
456
  workItemId,
427
457
  action.sequence,
@@ -430,6 +460,10 @@ export class WorkItemStore {
430
460
  action.stageId,
431
461
  stringify(action.assignmentPolicy),
432
462
  stringify(action.modelPolicy),
463
+ stringify(action.dependsOnStageIds),
464
+ action.workspaceMode,
465
+ action.changesRequestedStageId,
466
+ stringify(action.workspace),
433
467
  action.instruction,
434
468
  stringify(action.brief),
435
469
  stringify(action.context),
@@ -448,10 +482,188 @@ export class WorkItemStore {
448
482
  return Number(row.seq) + 1;
449
483
  }
450
484
 
485
+ #hasActiveIntegrationReservation(action, now = this.now()) {
486
+ const reservation = action?.workspace?.integration?.reservation;
487
+ return !!reservation && Number(reservation.expiresAt) > now;
488
+ }
489
+
490
+ #assertNoIntegrationReservation(actions, now = this.now()) {
491
+ if (actions.some(action => this.#hasActiveIntegrationReservation(action, now))) {
492
+ throw new Error('Work Center integration finalization currently owns the Action lease');
493
+ }
494
+ }
495
+
496
+ #resetGraphFromStage(workItemId, targetStageId, replacement, reason, now) {
497
+ const actions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
498
+ AND status NOT IN ('superseded', 'cancelled') ORDER BY sequence`).all(workItemId).map(mapAction);
499
+ const target = actions.find(action => action.stageId === targetStageId);
500
+ if (!target) throw new Error('Work Center graph reset target is missing');
501
+ const affected = new Set([targetStageId]);
502
+ for (const action of actions) {
503
+ if (action.status === 'running') affected.add(action.stageId);
504
+ }
505
+ let changed = true;
506
+ while (changed) {
507
+ changed = false;
508
+ for (const action of actions) {
509
+ if (affected.has(action.stageId)) continue;
510
+ if (action.dependsOnStageIds.some(stageId => affected.has(stageId))) {
511
+ affected.add(action.stageId);
512
+ changed = true;
513
+ }
514
+ }
515
+ }
516
+ const affectedActions = actions.filter(action => affected.has(action.stageId));
517
+ this.#assertNoIntegrationReservation(affectedActions, now);
518
+ const preservedTargetWorkspace = target.workspaceMode === 'integrate'
519
+ && ['prepared', 'finalized'].includes(target.workspace?.integration?.status)
520
+ && (!replacement || replacement.workspaceMode === 'integrate')
521
+ ? target.workspace
522
+ : null;
523
+ const ids = affectedActions.map(action => action.id);
524
+ const running = affectedActions.filter(action => action.status === 'running' && action.currentRunId);
525
+ const nextEpoch = new Map(actions.map(action => [action.id, action.leaseEpoch]));
526
+ for (const action of running) nextEpoch.set(action.id, action.leaseEpoch + 1);
527
+ const placeholders = ids.map(() => '?').join(',');
528
+ if (ids.length > 0) {
529
+ this.db.prepare(`UPDATE runs SET status = 'superseded', ended_at = ?, error = ?
530
+ WHERE action_id IN (${placeholders}) AND status = 'running'`).run(now, reason, ...ids);
531
+ for (const action of affectedActions) {
532
+ const workspace = action.id === target.id ? preservedTargetWorkspace : null;
533
+ this.db.prepare(`UPDATE actions SET status = 'ready', attempt = 0, current_run_id = NULL,
534
+ lease_epoch = ?, workspace = ?, updated_at = ? WHERE id = ?`).run(
535
+ nextEpoch.get(action.id), stringify(workspace), now, action.id,
536
+ );
537
+ }
538
+ }
539
+ if (replacement) {
540
+ this.db.prepare(`UPDATE actions SET type = ?, required_role = ?, assignment_policy = ?,
541
+ model_policy = ?, depends_on_stage_ids = ?, workspace_mode = ?, changes_requested_stage_id = ?,
542
+ instruction = ?, brief = ?, context = ?, max_attempts = ?, workspace = ?, updated_at = ?
543
+ WHERE id = ?`).run(
544
+ replacement.type || target.type,
545
+ replacement.requiredRole || '',
546
+ stringify(replacement.assignmentPolicy || null),
547
+ stringify(replacement.modelPolicy || null),
548
+ stringify(Array.isArray(replacement.dependsOnStageIds) ? replacement.dependsOnStageIds : []),
549
+ replacement.workspaceMode || 'shared',
550
+ replacement.changesRequestedStageId || null,
551
+ replacement.instruction || '',
552
+ stringify(replacement.brief || null),
553
+ stringify(Array.isArray(replacement.context) ? replacement.context : []),
554
+ Number.isInteger(replacement.maxAttempts) ? replacement.maxAttempts : 2,
555
+ stringify(preservedTargetWorkspace),
556
+ now,
557
+ target.id,
558
+ );
559
+ }
560
+ return this.getAction(target.id);
561
+ }
562
+
451
563
  createNextAction(workItemId, input) {
452
564
  return this.#insertAction(workItemId, input, this.#nextSequence(workItemId));
453
565
  }
454
566
 
567
+ setActionWorkspace(actionId, workspace, workspaceMode = null) {
568
+ const changed = this.db.prepare(`UPDATE actions SET workspace = ?,
569
+ workspace_mode = COALESCE(?, workspace_mode), updated_at = ? WHERE id = ?`).run(
570
+ stringify(workspace), workspaceMode, this.now(), actionId,
571
+ );
572
+ return Number(changed.changes) === 1 ? this.getAction(actionId) : null;
573
+ }
574
+
575
+ setIntegrationWorkspaceForRun(actionId, runId, ownerBootId, leaseEpoch, workspace) {
576
+ return withTransaction(this.db, () => {
577
+ const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
578
+ if (!active || active.action_id !== actionId) return null;
579
+ const changed = this.db.prepare(`UPDATE actions SET workspace = ?, workspace_mode = 'integrate',
580
+ updated_at = ? WHERE id = ? AND status = 'running' AND current_run_id = ? AND lease_epoch = ?`).run(
581
+ stringify(workspace), this.now(), actionId, runId, leaseEpoch,
582
+ );
583
+ return Number(changed.changes) === 1 ? this.getAction(actionId) : null;
584
+ });
585
+ }
586
+
587
+ acquireIntegrationFinalization(actionId, runId, ownerBootId, leaseEpoch, reservationMs = 300_000) {
588
+ return withTransaction(this.db, () => {
589
+ const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
590
+ if (!active || active.action_id !== actionId) return null;
591
+ const action = this.getAction(actionId);
592
+ if (action?.workspaceMode !== 'integrate' || action.workspace?.integration?.status !== 'prepared') return null;
593
+ const now = this.now();
594
+ const token = randomUUID();
595
+ const expiresAt = now + Math.max(10_000, Number(reservationMs) || 300_000);
596
+ const workspace = {
597
+ ...action.workspace,
598
+ integration: {
599
+ ...action.workspace.integration,
600
+ reservation: { token, runId, ownerBootId, leaseEpoch, expiresAt },
601
+ },
602
+ };
603
+ const changed = this.db.prepare(`UPDATE actions SET workspace = ?, updated_at = ?
604
+ WHERE id = ? AND status = 'running' AND current_run_id = ? AND lease_epoch = ?`).run(
605
+ stringify(workspace), now, actionId, runId, leaseEpoch,
606
+ );
607
+ if (Number(changed.changes) !== 1) return null;
608
+ this.db.prepare(`UPDATE runs SET expires_at = ? WHERE id = ? AND owner_boot_id = ?
609
+ AND lease_epoch = ? AND status = 'running'`).run(expiresAt, runId, ownerBootId, leaseEpoch);
610
+ return { action: this.getAction(actionId), token, expiresAt };
611
+ });
612
+ }
613
+
614
+ finishIntegrationFinalization(actionId, runId, ownerBootId, leaseEpoch, token, workspace) {
615
+ return withTransaction(this.db, () => {
616
+ const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, false);
617
+ if (!active || active.action_id !== actionId) return null;
618
+ const action = this.getAction(actionId);
619
+ const reservation = action?.workspace?.integration?.reservation;
620
+ if (!reservation || reservation.token !== token || reservation.runId !== runId
621
+ || reservation.ownerBootId !== ownerBootId || reservation.leaseEpoch !== leaseEpoch
622
+ || Number(reservation.expiresAt) <= this.now()) return null;
623
+ const changed = this.db.prepare(`UPDATE actions SET workspace = ?, workspace_mode = 'integrate',
624
+ updated_at = ? WHERE id = ? AND status = 'running' AND current_run_id = ? AND lease_epoch = ?`).run(
625
+ stringify(workspace), this.now(), actionId, runId, leaseEpoch,
626
+ );
627
+ return Number(changed.changes) === 1 ? this.getAction(actionId) : null;
628
+ });
629
+ }
630
+
631
+ rollbackIntegrationFinalization(actionId, runId, ownerBootId, leaseEpoch, token) {
632
+ return withTransaction(this.db, () => {
633
+ const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, false);
634
+ if (!active || active.action_id !== actionId) return null;
635
+ const action = this.getAction(actionId);
636
+ const reservation = action?.workspace?.integration?.reservation;
637
+ if (!reservation || reservation.token !== token || reservation.runId !== runId
638
+ || reservation.ownerBootId !== ownerBootId || reservation.leaseEpoch !== leaseEpoch
639
+ || Number(reservation.expiresAt) <= this.now()) return null;
640
+ const changed = this.db.prepare(`UPDATE actions SET workspace = NULL, workspace_mode = 'integrate',
641
+ updated_at = ? WHERE id = ? AND status = 'running' AND current_run_id = ? AND lease_epoch = ?`).run(
642
+ this.now(), actionId, runId, leaseEpoch,
643
+ );
644
+ return Number(changed.changes) === 1 ? this.getAction(actionId) : null;
645
+ });
646
+ }
647
+
648
+ listActionDependencies(workItemId, stageIds) {
649
+ if (!Array.isArray(stageIds) || stageIds.length === 0) return [];
650
+ const placeholders = stageIds.map(() => '?').join(',');
651
+ return this.db.prepare(`SELECT a.*, r.summary AS dependency_summary,
652
+ r.evidence AS dependency_evidence, r.vp_snapshot AS dependency_vp_snapshot
653
+ FROM actions a LEFT JOIN runs r ON r.id = (
654
+ SELECT completed.id FROM runs completed WHERE completed.action_id = a.id
655
+ AND completed.status = 'completed'
656
+ ORDER BY completed.ended_at DESC LIMIT 1
657
+ ) WHERE a.work_item_id = ? AND COALESCE(a.stage_id, a.type) IN (${placeholders})
658
+ AND a.status != 'superseded'
659
+ ORDER BY a.sequence`).all(workItemId, ...stageIds).map(row => ({
660
+ ...mapAction(row),
661
+ summary: row.dependency_summary || '',
662
+ evidence: normalizeEvidence(parseJson(row.dependency_evidence, [])),
663
+ vpId: parseJson(row.dependency_vp_snapshot, null)?.id || null,
664
+ }));
665
+ }
666
+
455
667
  getWorkItem(id) {
456
668
  return mapWorkItem(this.db.prepare('SELECT * FROM work_items WHERE id = ?').get(id));
457
669
  }
@@ -582,6 +794,9 @@ export class WorkItemStore {
582
794
  }
583
795
 
584
796
  #invalidateExecution(workItem, actionStatus, runStatus, reason, now) {
797
+ const openActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
798
+ AND status IN (${OPEN_ACTION_STATUSES})`).all(workItem.id).map(mapAction);
799
+ this.#assertNoIntegrationReservation(openActions, now);
585
800
  if (workItem.currentRunId) {
586
801
  this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, error = ?
587
802
  WHERE id = ? AND status = 'running'`).run(runStatus, now, reason, workItem.currentRunId);
@@ -704,8 +919,23 @@ export class WorkItemStore {
704
919
  AND status != 'running' ORDER BY ended_at DESC, started_at DESC LIMIT 1`).get(id, previous.id))
705
920
  : null;
706
921
  const now = this.now();
922
+ const replacement = makeAction(workItem, previous, previousRun);
923
+ if (workItem.workflowSnapshot?.executionMode === 'graph') {
924
+ if (!previous) throw new Error('WorkItem graph retry target is missing');
925
+ const action = this.#resetGraphFromStage(
926
+ id,
927
+ previous.stageId,
928
+ replacement,
929
+ 'Superseded by manual graph retry',
930
+ now,
931
+ );
932
+ this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
933
+ current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
934
+ this.appendEvent(id, 'work_item.retried', { targetStageId: action.stageId }, { actionId: action.id });
935
+ return this.getWorkItemDetail(id);
936
+ }
707
937
  const action = this.#insertAction(id, {
708
- ...makeAction(workItem, previous, previousRun),
938
+ ...replacement,
709
939
  contractRevision: workItem.revision,
710
940
  }, this.#nextSequence(id), now);
711
941
  this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
@@ -720,7 +950,31 @@ export class WorkItemStore {
720
950
  const row = this.db.prepare(`SELECT a.* FROM actions a
721
951
  JOIN work_items w ON w.id = a.work_item_id
722
952
  WHERE a.status = 'ready' AND a.current_run_id IS NULL
723
- AND w.status = 'ready' AND w.current_action_id = a.id AND w.current_run_id IS NULL
953
+ AND (
954
+ (COALESCE(json_extract(w.workflow_snapshot, '$.executionMode'), 'linear') != 'graph'
955
+ AND w.status = 'ready' AND w.current_action_id = a.id AND w.current_run_id IS NULL)
956
+ OR
957
+ (json_extract(w.workflow_snapshot, '$.executionMode') = 'graph'
958
+ AND w.status IN ('ready', 'running')
959
+ AND NOT EXISTS (
960
+ SELECT 1 FROM json_each(a.depends_on_stage_ids) dependency
961
+ LEFT JOIN actions required ON required.work_item_id = a.work_item_id
962
+ AND required.stage_id = dependency.value
963
+ WHERE required.id IS NULL OR required.status != 'completed'
964
+ )
965
+ )
966
+ )
967
+ AND NOT EXISTS (
968
+ SELECT 1 FROM actions running
969
+ JOIN work_items running_item ON running_item.id = running.work_item_id
970
+ WHERE running.status = 'running'
971
+ AND running_item.workspace_key != ''
972
+ AND running_item.workspace_key = w.workspace_key
973
+ AND (a.workspace_mode IN ('shared', 'integrate')
974
+ OR running.workspace_mode IN ('shared', 'integrate')
975
+ OR (running.work_item_id != a.work_item_id
976
+ AND a.workspace_mode != 'read' AND running.workspace_mode != 'read'))
977
+ )
724
978
  ORDER BY a.updated_at ASC, a.sequence ASC LIMIT 1`).get();
725
979
  if (!row) return null;
726
980
  const now = this.now();
@@ -732,35 +986,30 @@ export class WorkItemStore {
732
986
  const changedAction = this.db.prepare(`UPDATE actions SET status = 'running', attempt = attempt + 1,
733
987
  current_run_id = ?, lease_epoch = ?, updated_at = ?
734
988
  WHERE id = ? AND status = 'ready' AND current_run_id IS NULL`).run(
735
- runId,
736
- leaseEpoch,
737
- now,
738
- row.id,
989
+ runId, leaseEpoch, now, row.id,
739
990
  );
740
991
  if (Number(changedAction.changes) !== 1) return null;
741
- const changedWorkItem = this.db.prepare(`UPDATE work_items SET status = 'running',
742
- current_run_id = ?, updated_at = ? WHERE id = ? AND status = 'ready'
743
- AND current_action_id = ? AND current_run_id IS NULL`).run(
744
- runId,
745
- now,
746
- row.work_item_id,
747
- row.id,
748
- );
749
- if (Number(changedWorkItem.changes) !== 1) throw new Error('WorkItem claim lost its current Action');
992
+ const workItem = this.getWorkItem(row.work_item_id);
993
+ const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
994
+ const changedWorkItem = graphMode
995
+ ? this.db.prepare(`UPDATE work_items SET status = 'running', current_action_id = ?,
996
+ current_run_id = NULL, updated_at = ? WHERE id = ? AND status IN ('ready', 'running')`).run(
997
+ row.id, now, row.work_item_id,
998
+ )
999
+ : this.db.prepare(`UPDATE work_items SET status = 'running', current_run_id = ?, updated_at = ?
1000
+ WHERE id = ? AND status = 'ready' AND current_action_id = ? AND current_run_id IS NULL`).run(
1001
+ runId, now, row.work_item_id, row.id,
1002
+ );
1003
+ if (Number(changedWorkItem.changes) !== 1) throw new Error('WorkItem claim lost its Action fence');
750
1004
  this.db.prepare(`INSERT INTO runs
751
1005
  (id, action_id, work_item_id, owner_boot_id, lease_epoch, status, started_at,
752
1006
  expires_at, evidence, progress_revision)
753
1007
  VALUES (?, ?, ?, ?, ?, 'running', ?, ?, '[]', ?)`).run(
754
- runId,
755
- row.id,
756
- row.work_item_id,
757
- ownerBootId,
758
- leaseEpoch,
759
- now,
760
- now + leaseMs,
761
- progressRevision,
1008
+ runId, row.id, row.work_item_id, ownerBootId, leaseEpoch, now, now + leaseMs, progressRevision,
762
1009
  );
763
- this.appendEvent(row.work_item_id, 'run.claimed', { ownerBootId, leaseEpoch }, { actionId: row.id, runId });
1010
+ this.appendEvent(row.work_item_id, 'run.claimed', { ownerBootId, leaseEpoch }, {
1011
+ actionId: row.id, runId,
1012
+ });
764
1013
  return {
765
1014
  workItem: this.getWorkItem(row.work_item_id),
766
1015
  action: this.getAction(row.id),
@@ -770,18 +1019,20 @@ export class WorkItemStore {
770
1019
  }
771
1020
 
772
1021
  #activeRunRow(runId, ownerBootId, leaseEpoch, requireUnexpired = true) {
773
- return this.db.prepare(`SELECT r.* FROM runs r
1022
+ const row = this.db.prepare(`SELECT r.* FROM runs r
774
1023
  JOIN actions a ON a.id = r.action_id
775
1024
  JOIN work_items w ON w.id = r.work_item_id
776
1025
  WHERE r.id = ? AND r.owner_boot_id = ? AND r.lease_epoch = ? AND r.status = 'running'
777
1026
  AND a.status = 'running' AND a.current_run_id = r.id AND a.lease_epoch = r.lease_epoch
778
- AND w.status = 'running' AND w.current_action_id = a.id AND w.current_run_id = r.id
1027
+ AND w.status IN ('ready', 'running', 'waiting')
779
1028
  ${requireUnexpired ? 'AND r.expires_at > ?' : ''}`).get(
780
- runId,
781
- ownerBootId,
782
- leaseEpoch,
783
- ...(requireUnexpired ? [this.now()] : []),
1029
+ runId, ownerBootId, leaseEpoch, ...(requireUnexpired ? [this.now()] : []),
784
1030
  );
1031
+ if (!row) return null;
1032
+ const workItem = this.getWorkItem(row.work_item_id);
1033
+ if (workItem?.workflowSnapshot?.executionMode === 'graph') return row;
1034
+ return workItem?.status === 'running' && workItem.currentActionId === row.action_id
1035
+ && workItem.currentRunId === row.id ? row : null;
785
1036
  }
786
1037
 
787
1038
  renewLease(runId, ownerBootId, leaseEpoch, leaseMs = 60_000) {
@@ -814,6 +1065,7 @@ export class WorkItemStore {
814
1065
  if (!active) return false;
815
1066
  const action = this.getAction(active.action_id);
816
1067
  const now = this.now();
1068
+ this.#assertNoIntegrationReservation([action], now);
817
1069
  const retryable = action.type !== 'deliver' && action.attempt < action.maxAttempts;
818
1070
  const hasFinalProgress = finalProgress && typeof finalProgress === 'object';
819
1071
  const runChanged = hasFinalProgress
@@ -858,15 +1110,17 @@ export class WorkItemStore {
858
1110
  leaseEpoch,
859
1111
  );
860
1112
  if (Number(actionChanged.changes) !== 1) throw new Error('Run interruption lost the Action fence');
861
- const itemChanged = this.db.prepare(`UPDATE work_items SET status = ?, current_run_id = NULL,
862
- updated_at = ? WHERE id = ? AND status = 'running' AND current_action_id = ?
863
- AND current_run_id = ?`).run(
864
- retryable ? 'ready' : 'needs_attention',
865
- now,
866
- active.work_item_id,
867
- action.id,
868
- runId,
869
- );
1113
+ const graphMode = this.getWorkItem(active.work_item_id)?.workflowSnapshot?.executionMode === 'graph';
1114
+ const itemChanged = graphMode
1115
+ ? this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
1116
+ updated_at = ? WHERE id = ? AND status = 'running'`).run(
1117
+ retryable ? 'ready' : 'needs_attention', action.id, now, active.work_item_id,
1118
+ )
1119
+ : this.db.prepare(`UPDATE work_items SET status = ?, current_run_id = NULL,
1120
+ updated_at = ? WHERE id = ? AND status = 'running' AND current_action_id = ?
1121
+ AND current_run_id = ?`).run(
1122
+ retryable ? 'ready' : 'needs_attention', now, active.work_item_id, action.id, runId,
1123
+ );
870
1124
  if (Number(itemChanged.changes) !== 1) throw new Error('Run interruption lost the WorkItem fence');
871
1125
  this.appendEvent(active.work_item_id, 'run.interrupted', { retryable, reason }, {
872
1126
  actionId: action.id,
@@ -1018,24 +1272,52 @@ export class WorkItemStore {
1018
1272
  }
1019
1273
 
1020
1274
  let nextAction = null;
1275
+ if (transition.graphResetStageId) {
1276
+ nextAction = this.#resetGraphFromStage(
1277
+ workItem.id,
1278
+ transition.graphResetStageId,
1279
+ transition.graphResetAction,
1280
+ 'Superseded by review changes request',
1281
+ now,
1282
+ );
1283
+ }
1284
+ const nextActions = Array.isArray(transition.nextActions) ? transition.nextActions : [];
1285
+ for (const candidate of nextActions) {
1286
+ const inserted = this.#insertAction(workItem.id, {
1287
+ ...candidate,
1288
+ contractRevision: nextWorkItem.revision,
1289
+ }, this.#nextSequence(workItem.id), now);
1290
+ if (!nextAction) nextAction = inserted;
1291
+ }
1021
1292
  if (transition.nextAction) {
1022
1293
  nextAction = this.#insertAction(workItem.id, {
1023
1294
  ...transition.nextAction,
1024
1295
  contractRevision: nextWorkItem.revision,
1025
1296
  }, this.#nextSequence(workItem.id), now);
1026
1297
  }
1027
- const currentActionId = nextAction?.id
1028
- ?? (transition.keepCurrentAction ? action.id : null);
1029
- const changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
1030
- current_run_id = NULL, updated_at = ? WHERE id = ? AND current_run_id = ?
1031
- AND current_action_id = ? AND status = 'running'`).run(
1032
- transition.workItemStatus,
1033
- currentActionId,
1034
- now,
1035
- workItem.id,
1036
- runId,
1037
- action.id,
1038
- );
1298
+ let workItemStatus = transition.workItemStatus;
1299
+ let currentActionId = nextAction?.id ?? (transition.keepCurrentAction ? action.id : null);
1300
+ let changedWorkItem;
1301
+ if (transition.graphAdvance) {
1302
+ const remaining = this.db.prepare(`SELECT id, status FROM actions WHERE work_item_id = ?
1303
+ AND status IN ('ready', 'running', 'waiting', 'failed') ORDER BY sequence`).all(workItem.id);
1304
+ const blocked = remaining.find(candidate => candidate.status === 'waiting' || candidate.status === 'failed');
1305
+ const runnable = remaining.find(candidate => candidate.status === 'ready' || candidate.status === 'running');
1306
+ workItemStatus = blocked ? (blocked.status === 'waiting' ? 'waiting' : 'needs_attention')
1307
+ : runnable ? (remaining.some(candidate => candidate.status === 'running') ? 'running' : 'ready')
1308
+ : 'done';
1309
+ currentActionId = blocked?.id || runnable?.id || null;
1310
+ changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
1311
+ current_run_id = NULL, updated_at = ? WHERE id = ? AND status IN ('ready', 'running')`).run(
1312
+ workItemStatus, currentActionId, now, workItem.id,
1313
+ );
1314
+ } else {
1315
+ changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
1316
+ current_run_id = NULL, updated_at = ? WHERE id = ? AND current_run_id = ?
1317
+ AND current_action_id = ? AND status = 'running'`).run(
1318
+ workItemStatus, currentActionId, now, workItem.id, runId, action.id,
1319
+ );
1320
+ }
1039
1321
  if (Number(changedWorkItem.changes) !== 1) {
1040
1322
  throw new Error('Work Center terminal transition lost the current WorkItem fence');
1041
1323
  }
@@ -1048,22 +1330,47 @@ export class WorkItemStore {
1048
1330
  });
1049
1331
  }
1050
1332
 
1333
+ #refreshGraphWorkItem(workItemId, now) {
1334
+ const remaining = this.db.prepare(`SELECT id, status FROM actions WHERE work_item_id = ?
1335
+ AND status IN ('ready', 'running', 'waiting', 'failed') ORDER BY sequence`).all(workItemId);
1336
+ const blocked = remaining.find(action => action.status === 'waiting' || action.status === 'failed');
1337
+ const runnable = remaining.find(action => action.status === 'ready' || action.status === 'running');
1338
+ const status = blocked ? (blocked.status === 'waiting' ? 'waiting' : 'needs_attention')
1339
+ : runnable ? (remaining.some(action => action.status === 'running') ? 'running' : 'ready')
1340
+ : 'done';
1341
+ const currentActionId = blocked?.id || runnable?.id || null;
1342
+ this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
1343
+ updated_at = ? WHERE id = ?`).run(status, currentActionId, now, workItemId);
1344
+ }
1345
+
1051
1346
  recoverInterruptedRuns(ownerBootId) {
1052
1347
  return withTransaction(this.db, () => {
1053
1348
  const now = this.now();
1054
1349
  const rows = this.db.prepare(`SELECT * FROM runs
1055
1350
  WHERE status = 'running' AND (owner_boot_id != ? OR expires_at <= ?)`).all(ownerBootId, now);
1351
+ let recovered = 0;
1056
1352
  for (const row of rows) {
1057
1353
  const action = this.getAction(row.action_id);
1354
+ if (this.#hasActiveIntegrationReservation(action, now)) continue;
1058
1355
  const workItem = this.getWorkItem(row.work_item_id);
1356
+ const graphMode = workItem?.workflowSnapshot?.executionMode === 'graph';
1059
1357
  const isCurrent = action?.status === 'running'
1060
1358
  && action.currentRunId === row.id
1061
1359
  && action.leaseEpoch === row.lease_epoch
1062
1360
  && workItem?.status === 'running'
1063
- && workItem.currentActionId === action.id
1064
- && workItem.currentRunId === row.id;
1361
+ && (graphMode || (workItem.currentActionId === action.id && workItem.currentRunId === row.id));
1065
1362
  if (!isCurrent) {
1066
1363
  const staleStatus = workItem?.status === 'cancelled' ? 'cancelled' : 'superseded';
1364
+ const stillOwnsAction = action?.status === 'running'
1365
+ && action.currentRunId === row.id
1366
+ && action.leaseEpoch === row.lease_epoch;
1367
+ if (stillOwnsAction) {
1368
+ const retryable = action.type !== 'deliver' && action.attempt < action.maxAttempts;
1369
+ this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL, updated_at = ?
1370
+ WHERE id = ? AND status = 'running' AND current_run_id = ? AND lease_epoch = ?`).run(
1371
+ retryable ? 'ready' : 'failed', now, action.id, row.id, row.lease_epoch,
1372
+ );
1373
+ }
1067
1374
  this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, error = ?
1068
1375
  WHERE id = ? AND status = 'running'`).run(
1069
1376
  staleStatus,
@@ -1075,6 +1382,8 @@ export class WorkItemStore {
1075
1382
  actionId: row.action_id,
1076
1383
  runId: row.id,
1077
1384
  });
1385
+ if (graphMode && workItem) this.#refreshGraphWorkItem(workItem.id, now);
1386
+ recovered += 1;
1078
1387
  continue;
1079
1388
  }
1080
1389
 
@@ -1084,19 +1393,24 @@ export class WorkItemStore {
1084
1393
  const retryable = action.type !== 'deliver' && action.attempt < action.maxAttempts;
1085
1394
  this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL, updated_at = ?
1086
1395
  WHERE id = ?`).run(retryable ? 'ready' : 'failed', now, action.id);
1087
- this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
1088
- current_run_id = NULL, updated_at = ? WHERE id = ?`).run(
1089
- retryable ? 'ready' : 'needs_attention',
1090
- action.id,
1091
- now,
1092
- workItem.id,
1093
- );
1396
+ if (graphMode) {
1397
+ this.#refreshGraphWorkItem(workItem.id, now);
1398
+ } else {
1399
+ this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
1400
+ current_run_id = NULL, updated_at = ? WHERE id = ?`).run(
1401
+ retryable ? 'ready' : 'needs_attention',
1402
+ action.id,
1403
+ now,
1404
+ workItem.id,
1405
+ );
1406
+ }
1094
1407
  this.appendEvent(workItem.id, 'run.interrupted', { retryable }, {
1095
1408
  actionId: action.id,
1096
1409
  runId: row.id,
1097
1410
  });
1411
+ recovered += 1;
1098
1412
  }
1099
- return rows.length;
1413
+ return recovered;
1100
1414
  });
1101
1415
  }
1102
1416
  }