@yeaft/webchat-agent 1.0.366 → 1.0.367

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,6 +5,8 @@ import { createHash, randomUUID } from 'node:crypto';
5
5
  import { normalizeEvidence } from './evidence.js';
6
6
  import { normalizeActionCheckpoint } from './action-checkpoint.js';
7
7
  import { currentActionInputEventIds, runMatchesActionIdentity } from './action-identity.js';
8
+ import { isDynamicWorkItem, usesLegacyGraph } from './execution-mode.js';
9
+ import { normalizeDynamicCompletion } from './dynamic-coordination.js';
8
10
  import { canonicalActionInstruction, withoutActionInputContext } from './workflow.js';
9
11
  import {
10
12
  WORK_CENTER_SCHEMA_VERSION,
@@ -84,6 +86,7 @@ function actionSpecHash(action) {
84
86
  assignmentPolicy: action.assignmentPolicy || null,
85
87
  modelPolicy: action.modelPolicy || null,
86
88
  dependsOnStageIds: [...new Set(action.dependsOnStageIds || [])].sort(),
89
+ sourceActionIds: [...new Set(action.sourceActionIds || [])].sort(),
87
90
  workspaceMode: action.workspaceMode || 'shared',
88
91
  changesRequestedStageId: action.changesRequestedStageId || null,
89
92
  requiredRole: action.requiredRole || '',
@@ -103,6 +106,8 @@ function mapWorkItem(row) {
103
106
  planRevision: Math.max(0, Number(row.plan_revision) || 0),
104
107
  executionSchemaVersion: Math.max(1, Number(row.execution_schema_version) || 1),
105
108
  ledgerRevision: Math.max(0, Number(row.ledger_revision) || 0),
109
+ coordinationMode: row.coordination_mode || 'legacy',
110
+ finalResult: parseJson(row.final_result, null),
106
111
  title: row.title,
107
112
  goal: row.goal,
108
113
  acceptanceCriteria: parseJson(row.acceptance_criteria, []),
@@ -144,12 +149,29 @@ function mapWorkItem(row) {
144
149
  };
145
150
  }
146
151
 
147
- function isGraphWorkItem(workItem) {
148
- return workItem?.workflowSnapshot?.executionMode === 'graph';
152
+ function dynamicExecutionState(workItem, actions) {
153
+ if (!isDynamicWorkItem(workItem)) return workItem;
154
+ const current = (Array.isArray(actions) ? actions : [])
155
+ .filter(action => !['superseded', 'cancelled'].includes(action.status));
156
+ const activeActionIds = current.filter(action => ['ready', 'running'].includes(action.status))
157
+ .map(action => action.id);
158
+ const waitingIds = current.filter(action => action.status === 'waiting').map(action => action.id);
159
+ const failedIds = current.filter(action => action.status === 'failed').map(action => action.id);
160
+ return {
161
+ ...workItem,
162
+ lifecycle: workItem.status === 'cancelled' ? 'cancelled'
163
+ : workItem.status === 'done' ? 'done'
164
+ : workItem.status === 'draft' ? 'draft' : 'active',
165
+ attentionState: waitingIds.length > 0 && failedIds.length > 0 ? 'mixed'
166
+ : waitingIds.length > 0 ? 'waiting'
167
+ : failedIds.length > 0 ? 'failed' : 'none',
168
+ activeActionIds,
169
+ attentionActionIds: [...waitingIds, ...failedIds],
170
+ };
149
171
  }
150
172
 
151
173
  function graphExecutionState(workItem, actions) {
152
- if (!isGraphWorkItem(workItem)) return workItem;
174
+ if (!usesLegacyGraph(workItem)) return dynamicExecutionState(workItem, actions);
153
175
  const current = (Array.isArray(actions) ? actions : [])
154
176
  .filter(action => !['superseded', 'cancelled'].includes(action.status));
155
177
  const activeActionIds = current
@@ -188,6 +210,7 @@ function mapAction(row) {
188
210
  assignmentPolicy: parseJson(row.assignment_policy, null),
189
211
  modelPolicy: parseJson(row.model_policy, null),
190
212
  dependsOnStageIds: parseJson(row.depends_on_stage_ids, []),
213
+ sourceActionIds: parseJson(row.source_action_ids, []),
191
214
  workspaceMode: row.workspace_mode || 'shared',
192
215
  changesRequestedStageId: row.changes_requested_stage_id || null,
193
216
  workspace: parseJson(row.workspace, null),
@@ -248,6 +271,7 @@ function mapRun(row) {
248
271
  response: row.response || '',
249
272
  summary: row.summary || '',
250
273
  evidence: normalizeEvidence(parseJson(row.evidence, [])),
274
+ acceptanceChecks: parseJson(row.acceptance_checks, []),
251
275
  waitingReason: row.waiting_reason || null,
252
276
  error: row.error || null,
253
277
  failureKind: row.failure_kind || null,
@@ -804,6 +828,8 @@ export class WorkItemStore {
804
828
  plan_revision INTEGER NOT NULL DEFAULT 0,
805
829
  execution_schema_version INTEGER NOT NULL DEFAULT 2,
806
830
  ledger_revision INTEGER NOT NULL DEFAULT 0,
831
+ coordination_mode TEXT NOT NULL DEFAULT 'legacy',
832
+ final_result TEXT,
807
833
  title TEXT NOT NULL,
808
834
  goal TEXT NOT NULL,
809
835
  acceptance_criteria TEXT NOT NULL,
@@ -834,6 +860,7 @@ export class WorkItemStore {
834
860
  assignment_policy TEXT,
835
861
  model_policy TEXT,
836
862
  depends_on_stage_ids TEXT NOT NULL DEFAULT '[]',
863
+ source_action_ids TEXT NOT NULL DEFAULT '[]',
837
864
  workspace_mode TEXT NOT NULL DEFAULT 'shared',
838
865
  changes_requested_stage_id TEXT,
839
866
  workspace TEXT,
@@ -873,6 +900,7 @@ export class WorkItemStore {
873
900
  execution_manifest TEXT,
874
901
  summary TEXT,
875
902
  evidence TEXT NOT NULL DEFAULT '[]',
903
+ acceptance_checks TEXT NOT NULL DEFAULT '[]',
876
904
  waiting_reason TEXT,
877
905
  error TEXT,
878
906
  failure_kind TEXT,
@@ -1072,6 +1100,15 @@ export class WorkItemStore {
1072
1100
  if (!hasColumn(this.db, 'work_items', 'ledger_revision')) {
1073
1101
  this.db.exec('ALTER TABLE work_items ADD COLUMN ledger_revision INTEGER NOT NULL DEFAULT 0');
1074
1102
  }
1103
+ if (!hasColumn(this.db, 'work_items', 'coordination_mode')) {
1104
+ this.db.exec("ALTER TABLE work_items ADD COLUMN coordination_mode TEXT NOT NULL DEFAULT 'legacy'");
1105
+ }
1106
+ if (!hasColumn(this.db, 'work_items', 'final_result')) {
1107
+ this.db.exec('ALTER TABLE work_items ADD COLUMN final_result TEXT');
1108
+ }
1109
+ if (!hasColumn(this.db, 'actions', 'source_action_ids')) {
1110
+ this.db.exec("ALTER TABLE actions ADD COLUMN source_action_ids TEXT NOT NULL DEFAULT '[]'");
1111
+ }
1075
1112
  if (!hasColumn(this.db, 'actions', 'generation')) {
1076
1113
  this.db.exec('ALTER TABLE actions ADD COLUMN generation INTEGER NOT NULL DEFAULT 1');
1077
1114
  }
@@ -1118,6 +1155,9 @@ export class WorkItemStore {
1118
1155
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pending_action_inputs_identity
1119
1156
  ON pending_action_inputs(action_id, action_generation, action_spec_hash,
1120
1157
  run_id, consumed_at, superseded_at, event_id)`);
1158
+ if (!hasColumn(this.db, 'runs', 'acceptance_checks')) {
1159
+ this.db.exec("ALTER TABLE runs ADD COLUMN acceptance_checks TEXT NOT NULL DEFAULT '[]'");
1160
+ }
1121
1161
  if (!hasColumn(this.db, 'runs', 'context_snapshot')) {
1122
1162
  this.db.exec('ALTER TABLE runs ADD COLUMN context_snapshot TEXT');
1123
1163
  }
@@ -1322,6 +1362,21 @@ export class WorkItemStore {
1322
1362
  });
1323
1363
  }
1324
1364
 
1365
+ listPendingDynamicCoordinatorWakes() {
1366
+ return this.db.prepare(`SELECT c.id, c.work_item_id, c.kind, c.payload, c.source_key
1367
+ FROM coordinator_mailbox_entries c
1368
+ JOIN work_items w ON w.id = c.work_item_id
1369
+ WHERE w.coordination_mode = 'dynamic' AND w.status NOT IN ('done', 'cancelled')
1370
+ AND (c.status = 'pending' OR (c.status = 'claimed' AND c.lease_expires_at <= ?))
1371
+ ORDER BY c.created_at, c.sequence`).all(this.now()).map(row => ({
1372
+ id: row.id,
1373
+ workItemId: row.work_item_id,
1374
+ kind: row.kind,
1375
+ sourceKey: row.source_key,
1376
+ payload: parseJson(row.payload, {}),
1377
+ }));
1378
+ }
1379
+
1325
1380
  claimCoordinatorMailbox(workItemId, owner, leaseMs = 60_000) {
1326
1381
  return withTransaction(this.db, () => {
1327
1382
  const now = this.now();
@@ -1341,6 +1396,16 @@ export class WorkItemStore {
1341
1396
  });
1342
1397
  }
1343
1398
 
1399
+ releaseCoordinatorMailboxClaim(id, owner, claimEpoch) {
1400
+ const now = this.now();
1401
+ const changed = this.db.prepare(`UPDATE coordinator_mailbox_entries SET status = 'pending',
1402
+ claim_owner = NULL, claimed_at = NULL, lease_expires_at = NULL, updated_at = ?
1403
+ WHERE id = ? AND status = 'claimed' AND claim_owner = ? AND claim_epoch = ?`).run(
1404
+ now, id, owner, claimEpoch,
1405
+ );
1406
+ return Number(changed.changes) === 1;
1407
+ }
1408
+
1344
1409
  renewCoordinatorMailbox(id, owner, claimEpoch, leaseMs = 60_000) {
1345
1410
  const now = this.now();
1346
1411
  const changed = this.db.prepare(`UPDATE coordinator_mailbox_entries SET lease_expires_at = ?,
@@ -1772,8 +1837,8 @@ export class WorkItemStore {
1772
1837
  if (!Number.isInteger(expectedGeneration) || expectedGeneration < 1) {
1773
1838
  throw new Error('Action input generation must be a positive integer');
1774
1839
  }
1775
- const graphMode = isGraphWorkItem(workItem);
1776
- const inputStatuses = graphMode
1840
+ const concurrentMode = usesLegacyGraph(workItem) || isDynamicWorkItem(workItem);
1841
+ const inputStatuses = concurrentMode
1777
1842
  ? ['ready', 'running', 'waiting', 'needs_attention']
1778
1843
  : ['ready', 'running'];
1779
1844
  if (!inputStatuses.includes(workItem.status)) {
@@ -1783,7 +1848,7 @@ export class WorkItemStore {
1783
1848
  const actionMatches = action?.workItemId === id
1784
1849
  && action.generation === expectedGeneration
1785
1850
  && ['ready', 'running'].includes(action.status)
1786
- && (graphMode || action.id === workItem.currentActionId);
1851
+ && (concurrentMode || action.id === workItem.currentActionId);
1787
1852
  const activeRun = action?.currentRunId ? this.getRun(action.currentRunId) : null;
1788
1853
  const runMatches = action?.status !== 'running'
1789
1854
  || (activeRun?.status === 'running' && activeRun.acceptingInput !== false
@@ -2229,11 +2294,14 @@ export class WorkItemStore {
2229
2294
  const id = input.id || randomUUID();
2230
2295
  const workspaceKey = canonicalWorkspaceKey(input.workDir);
2231
2296
  this.db.prepare(`INSERT INTO work_items
2232
- (id, revision, execution_schema_version, ledger_revision, title, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
2297
+ (id, revision, execution_schema_version, ledger_revision, coordination_mode, final_result,
2298
+ title, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
2233
2299
  current_action_id, current_run_id, work_dir, workspace_key, reuse_memory, origin, linked_session_ids,
2234
2300
  session_context, attachments, created_at, updated_at)
2235
- VALUES (?, 1, 2, 0, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
2301
+ VALUES (?, 1, ?, 0, ?, NULL, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
2236
2302
  id,
2303
+ Number.isInteger(input.executionSchemaVersion) ? input.executionSchemaVersion : 2,
2304
+ input.coordinationMode || 'legacy',
2237
2305
  input.title,
2238
2306
  input.goal,
2239
2307
  stringify(input.acceptanceCriteria || []),
@@ -2262,7 +2330,7 @@ export class WorkItemStore {
2262
2330
 
2263
2331
  #insertAction(workItemId, input, sequence, now = this.now()) {
2264
2332
  const stageId = input.stageId || input.type;
2265
- const activeStage = isGraphWorkItem(this.getWorkItem(workItemId))
2333
+ const activeStage = usesLegacyGraph(this.getWorkItem(workItemId))
2266
2334
  ? this.db.prepare(`SELECT id FROM actions WHERE work_item_id = ? AND stage_id = ?
2267
2335
  AND status NOT IN ('superseded', 'cancelled') LIMIT 1`).get(workItemId, stageId)
2268
2336
  : null;
@@ -2278,6 +2346,7 @@ export class WorkItemStore {
2278
2346
  assignmentPolicy: input.assignmentPolicy || null,
2279
2347
  modelPolicy: input.modelPolicy || null,
2280
2348
  dependsOnStageIds: Array.isArray(input.dependsOnStageIds) ? input.dependsOnStageIds : [],
2349
+ sourceActionIds: Array.isArray(input.sourceActionIds) ? input.sourceActionIds : [],
2281
2350
  workspaceMode: input.workspaceMode || 'shared',
2282
2351
  changesRequestedStageId: input.changesRequestedStageId || null,
2283
2352
  workspace: input.workspace || null,
@@ -2302,10 +2371,10 @@ export class WorkItemStore {
2302
2371
  action.identityHistory = actionIdentityHistory(action);
2303
2372
  this.db.prepare(`INSERT INTO actions
2304
2373
  (id, work_item_id, sequence, type, required_role, stage_id, assignment_policy, model_policy,
2305
- depends_on_stage_ids, workspace_mode, changes_requested_stage_id, workspace, instruction, brief, context, contract_revision,
2306
- generation, spec_hash, identity_history, result_run_id, status, attempt, max_attempts, current_run_id, lease_epoch,
2307
- replaces_action_id, created_at, updated_at)
2308
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)`).run(
2374
+ depends_on_stage_ids, source_action_ids, workspace_mode, changes_requested_stage_id, workspace,
2375
+ instruction, brief, context, contract_revision, generation, spec_hash, identity_history, result_run_id,
2376
+ status, attempt, max_attempts, current_run_id, lease_epoch, replaces_action_id, created_at, updated_at)
2377
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?, ?)`).run(
2309
2378
  action.id,
2310
2379
  workItemId,
2311
2380
  action.sequence,
@@ -2315,6 +2384,7 @@ export class WorkItemStore {
2315
2384
  stringify(action.assignmentPolicy),
2316
2385
  stringify(action.modelPolicy),
2317
2386
  stringify(action.dependsOnStageIds),
2387
+ stringify(action.sourceActionIds),
2318
2388
  action.workspaceMode,
2319
2389
  action.changesRequestedStageId,
2320
2390
  stringify(action.workspace),
@@ -2697,12 +2767,12 @@ export class WorkItemStore {
2697
2767
  const remaining = this.db.prepare(`SELECT id, status FROM actions WHERE work_item_id = ?
2698
2768
  AND status IN ('ready', 'running', 'waiting', 'failed') ORDER BY sequence`).all(workItemId);
2699
2769
  const blocked = remaining.find(candidate => candidate.status === 'waiting' || candidate.status === 'failed');
2700
- const runnable = remaining.find(candidate => candidate.status === 'ready' || candidate.status === 'running');
2770
+ const running = remaining.find(candidate => candidate.status === 'running');
2771
+ const ready = remaining.find(candidate => candidate.status === 'ready');
2701
2772
  return {
2702
2773
  status: blocked ? (blocked.status === 'waiting' ? 'waiting' : 'needs_attention')
2703
- : runnable ? (remaining.some(candidate => candidate.status === 'running') ? 'running' : 'ready')
2704
- : 'done',
2705
- currentActionId: blocked?.id || runnable?.id || null,
2774
+ : running ? 'running' : ready ? 'ready' : 'done',
2775
+ currentActionId: blocked?.id || running?.id || ready?.id || null,
2706
2776
  };
2707
2777
  }
2708
2778
 
@@ -2727,9 +2797,9 @@ export class WorkItemStore {
2727
2797
  if (Number(changedAction.changes) !== 1) {
2728
2798
  throw new Error('Work Center deferred Run lost the current Action fence');
2729
2799
  }
2730
- const graphMode = isGraphWorkItem(workItem);
2731
- const graphState = graphMode ? this.#graphWorkItemState(workItem.id) : null;
2732
- const changedWorkItem = graphMode
2800
+ const concurrentMode = usesLegacyGraph(workItem) || isDynamicWorkItem(workItem);
2801
+ const graphState = concurrentMode ? this.#graphWorkItemState(workItem.id) : null;
2802
+ const changedWorkItem = concurrentMode
2733
2803
  ? this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
2734
2804
  updated_at = ? WHERE id = ? AND status IN ('ready', 'running', 'waiting', 'needs_attention')`).run(
2735
2805
  graphState.status, graphState.currentActionId, now, workItem.id,
@@ -2819,6 +2889,22 @@ export class WorkItemStore {
2819
2889
  });
2820
2890
  }
2821
2891
 
2892
+ listActionSources(workItemId, actionIds) {
2893
+ if (!Array.isArray(actionIds) || actionIds.length === 0) return [];
2894
+ const placeholders = actionIds.map(() => '?').join(',');
2895
+ return this.db.prepare(`SELECT a.*, r.summary AS dependency_summary,
2896
+ r.evidence AS dependency_evidence, r.vp_snapshot AS dependency_vp_snapshot
2897
+ FROM actions a JOIN runs r ON r.id = a.result_run_id
2898
+ WHERE a.work_item_id = ? AND a.id IN (${placeholders})
2899
+ AND a.status = 'completed' AND r.status = 'completed'
2900
+ ORDER BY a.sequence`).all(workItemId, ...actionIds).map(row => ({
2901
+ ...mapAction(row),
2902
+ summary: row.dependency_summary || '',
2903
+ evidence: normalizeEvidence(parseJson(row.dependency_evidence, [])),
2904
+ vpId: parseJson(row.dependency_vp_snapshot, null)?.id || null,
2905
+ }));
2906
+ }
2907
+
2822
2908
  listActionDependencies(workItemId, stageIds) {
2823
2909
  if (!Array.isArray(stageIds) || stageIds.length === 0) return [];
2824
2910
  const placeholders = stageIds.map(() => '?').join(',');
@@ -2840,7 +2926,7 @@ export class WorkItemStore {
2840
2926
 
2841
2927
  getWorkItem(id) {
2842
2928
  const workItem = mapWorkItem(this.db.prepare('SELECT * FROM work_items WHERE id = ?').get(id));
2843
- if (!isGraphWorkItem(workItem)) return workItem;
2929
+ if (!usesLegacyGraph(workItem) && !isDynamicWorkItem(workItem)) return workItem;
2844
2930
  const actions = this.db.prepare('SELECT * FROM actions WHERE work_item_id = ? ORDER BY sequence')
2845
2931
  .all(id).map(mapAction);
2846
2932
  return graphExecutionState(workItem, actions);
@@ -2874,6 +2960,7 @@ export class WorkItemStore {
2874
2960
  AND recovery_event.action_generation = a.generation
2875
2961
  AND recovery_event.type = 'coordinator.recovery_started'
2876
2962
  WHERE a.status = 'failed' AND w.status NOT IN ('done', 'cancelled')
2963
+ AND COALESCE(w.coordination_mode, 'legacy') != 'dynamic'
2877
2964
  GROUP BY a.id
2878
2965
  ORDER BY a.updated_at, a.sequence, a.id`).all().map(row => ({
2879
2966
  workItemId: row.work_item_id,
@@ -3064,6 +3151,7 @@ export class WorkItemStore {
3064
3151
  (detail.actions || []).filter(action => !['completed', 'superseded', 'cancelled'].includes(action.status)),
3065
3152
  ),
3066
3153
  recovery: assistant.recovery ? { ...assistant.recovery } : null,
3154
+ automatic: assistant.automatic === true,
3067
3155
  claim: {
3068
3156
  mailboxId: claim.mailboxId,
3069
3157
  ownerBootId: claim.ownerBootId,
@@ -3073,6 +3161,71 @@ export class WorkItemStore {
3073
3161
  };
3074
3162
  }
3075
3163
 
3164
+ beginDynamicCoordinatorTurn(mailboxId, expected = {}) {
3165
+ return withTransaction(this.db, () => {
3166
+ const mailbox = this.db.prepare(`SELECT * FROM coordinator_mailbox_entries WHERE id = ?
3167
+ AND status = 'claimed' AND claim_owner = ? AND claim_epoch = ? AND lease_expires_at > ?`).get(
3168
+ mailboxId, expected.ownerBootId, expected.claimEpoch, this.now(),
3169
+ );
3170
+ if (!mailbox) return null;
3171
+ const workItem = this.getWorkItem(mailbox.work_item_id);
3172
+ if (!isDynamicWorkItem(workItem) || ['done', 'cancelled'].includes(workItem.status)) return null;
3173
+ const latest = (workItem.messages || []).at(-1);
3174
+ if (latest?.role === 'assistant' && latest.status === 'thinking') return null;
3175
+ const now = this.now();
3176
+ const turnId = randomUUID();
3177
+ const payload = parseJson(mailbox.payload, {});
3178
+ const assistantMessage = {
3179
+ id: randomUUID(), turnId, role: 'assistant', text: '', status: 'thinking',
3180
+ createdAt: now, updatedAt: now,
3181
+ automatic: true,
3182
+ trigger: { kind: mailbox.kind, sourceKey: mailbox.source_key, ...(payload.trigger || {}) },
3183
+ };
3184
+ const messages = [...(workItem.messages || []), assistantMessage].slice(-100);
3185
+ const coordinatorRevision = workItem.coordinatorRevision + 1;
3186
+ const changedMailbox = this.db.prepare(`UPDATE coordinator_mailbox_entries SET payload = ?,
3187
+ updated_at = ? WHERE id = ? AND status = 'claimed' AND claim_owner = ? AND claim_epoch = ?`).run(
3188
+ stringify({ ...payload, turnId }), now, mailbox.id, expected.ownerBootId, expected.claimEpoch,
3189
+ );
3190
+ if (Number(changedMailbox.changes) !== 1) return null;
3191
+ const changed = this.db.prepare(`UPDATE work_items SET messages = ?, coordinator_revision = ?,
3192
+ updated_at = ? WHERE id = ? AND revision = ? AND plan_revision = ?
3193
+ AND ledger_revision = ? AND coordinator_revision = ?`).run(
3194
+ stringify(messages), coordinatorRevision, now, workItem.id, workItem.revision,
3195
+ workItem.planRevision, workItem.ledgerRevision, workItem.coordinatorRevision,
3196
+ );
3197
+ if (Number(changed.changes) !== 1) throw new Error('Dynamic Coordinator turn lost its revision fence');
3198
+ this.#appendConversationEntry(
3199
+ workItem.id, assistantMessage, `coordinator:turn:${turnId}:assistant`,
3200
+ );
3201
+ this.appendEvent(workItem.id, 'coordinator.advance_started', {
3202
+ turnId, triggerKind: mailbox.kind, sourceKey: mailbox.source_key,
3203
+ });
3204
+ const detail = this.getWorkItemDetail(workItem.id);
3205
+ return {
3206
+ turnId,
3207
+ detail,
3208
+ fence: {
3209
+ workItemId: workItem.id,
3210
+ revision: workItem.revision,
3211
+ planRevision: workItem.planRevision,
3212
+ ledgerRevision: workItem.ledgerRevision,
3213
+ coordinatorRevision,
3214
+ status: workItem.status,
3215
+ actionFence: coordinatorActionFence(
3216
+ (detail.actions || []).filter(action => !['completed', 'superseded', 'cancelled'].includes(action.status)),
3217
+ ),
3218
+ automatic: true,
3219
+ claim: {
3220
+ mailboxId: mailbox.id,
3221
+ ownerBootId: expected.ownerBootId,
3222
+ claimEpoch: Number(expected.claimEpoch),
3223
+ },
3224
+ },
3225
+ };
3226
+ });
3227
+ }
3228
+
3076
3229
  claimStartedCoordinatorTurn(started, ownerBootId, leaseMs = 60_000) {
3077
3230
  if (!started?.turnId || !started?.detail?.id) return null;
3078
3231
  const claim = this.claimCoordinatorTurn(started.detail.id, started.turnId, ownerBootId, leaseMs);
@@ -3261,8 +3414,13 @@ export class WorkItemStore {
3261
3414
  throw new Error('WorkItem Actions changed while the Coordinator was responding; send the message again');
3262
3415
  }
3263
3416
  this.#assertNoIntegrationReservation(activeActions, now);
3417
+ if (isDynamicWorkItem(workItem)) {
3418
+ return this.#completeDynamicCoordinatorTurn({
3419
+ turnId, result, expected, workItem, messages, assistantIndex, activeActions, now,
3420
+ });
3421
+ }
3264
3422
 
3265
- const graphMode = isGraphWorkItem(workItem);
3423
+ const graphMode = usesLegacyGraph(workItem);
3266
3424
  if (decision.kind === 'replan'
3267
3425
  && (!graphMode || workItem.workflowSnapshot?.planningMode !== 'ai')) {
3268
3426
  throw new Error('Coordinator replan requires an AI-planned Action graph');
@@ -3472,6 +3630,163 @@ export class WorkItemStore {
3472
3630
  });
3473
3631
  }
3474
3632
 
3633
+ #completeDynamicCoordinatorTurn({
3634
+ turnId, result, expected, workItem, messages, assistantIndex, activeActions, now,
3635
+ }) {
3636
+ const decision = result?.decision || {};
3637
+ let affectedActionIds = [];
3638
+ let nextStatus = workItem.status;
3639
+ let currentActionId = workItem.currentActionId;
3640
+ let finalResult = null;
3641
+
3642
+ if (decision.kind === 'create_actions') {
3643
+ if (activeActions.some(action => action.status === 'running')) {
3644
+ throw new Error('Dynamic Coordinator cannot mutate Actions while a Run is active');
3645
+ }
3646
+ const mutation = result.mutation;
3647
+ if (!mutation || !Array.isArray(mutation.createdActions) || mutation.createdActions.length === 0) {
3648
+ throw new Error('Dynamic Coordinator Action creation is missing a validated mutation');
3649
+ }
3650
+ for (const actionId of mutation.supersedeActionIds || []) {
3651
+ const action = activeActions.find(candidate => candidate.id === actionId);
3652
+ if (!action || !['ready', 'waiting', 'failed'].includes(action.status)) {
3653
+ throw new Error('Dynamic Coordinator supersede target changed before apply');
3654
+ }
3655
+ this.#supersedePendingActionInputs([action], 'Superseded by WorkItem Coordinator', now);
3656
+ const changed = this.db.prepare(`UPDATE actions SET status = 'superseded', current_run_id = NULL,
3657
+ lease_epoch = lease_epoch + 1, updated_at = ? WHERE id = ? AND generation = ?
3658
+ AND status IN ('ready', 'waiting', 'failed')`).run(now, action.id, action.generation);
3659
+ if (Number(changed.changes) !== 1) throw new Error('Dynamic Coordinator lost an Action fence');
3660
+ }
3661
+ const patch = mutation.contractPatch || null;
3662
+ const title = patch?.title ?? workItem.title;
3663
+ const goal = patch?.goal ?? workItem.goal;
3664
+ const criteria = patch?.acceptanceCriteria ?? workItem.acceptanceCriteria;
3665
+ const contractChanged = title !== workItem.title || goal !== workItem.goal
3666
+ || JSON.stringify(criteria) !== JSON.stringify(workItem.acceptanceCriteria);
3667
+ const snapshot = { ...workItem.workflowSnapshot, workItemType: mutation.workItemType };
3668
+ const changedPlan = this.db.prepare(`UPDATE work_items SET title = ?, goal = ?,
3669
+ acceptance_criteria = ?, workflow_snapshot = ?, revision = revision + ?,
3670
+ plan_revision = plan_revision + 1, updated_at = ? WHERE id = ? AND revision = ?
3671
+ AND plan_revision = ? AND ledger_revision = ? AND coordinator_revision = ?`).run(
3672
+ title, goal, stringify(criteria), stringify(snapshot), contractChanged ? 1 : 0,
3673
+ now, workItem.id, workItem.revision, workItem.planRevision,
3674
+ workItem.ledgerRevision, workItem.coordinatorRevision,
3675
+ );
3676
+ if (Number(changedPlan.changes) !== 1) throw new Error('Dynamic Coordinator lost the WorkItem plan fence');
3677
+ const current = this.getWorkItem(workItem.id);
3678
+ for (const candidate of mutation.createdActions) {
3679
+ const action = this.#insertAction(workItem.id, {
3680
+ ...candidate, status: 'ready', contractRevision: current.revision,
3681
+ }, this.#nextSequence(workItem.id), now);
3682
+ affectedActionIds.push(action.id);
3683
+ }
3684
+ nextStatus = 'ready';
3685
+ currentActionId = affectedActionIds[0];
3686
+ } else if (decision.kind === 'guide_actions') {
3687
+ const byId = new Map(decision.guidance.map(entry => [entry.actionId, entry.instruction]));
3688
+ for (const action of activeActions) {
3689
+ const guidance = byId.get(action.id);
3690
+ if (!guidance) continue;
3691
+ if (!['ready', 'waiting', 'failed'].includes(action.status)) {
3692
+ throw new Error('Dynamic Coordinator can guide only non-running unfinished Actions');
3693
+ }
3694
+ const context = [...withoutActionInputContext(action.context), {
3695
+ type: 'coordinator-guidance', role: 'user', summary: guidance, evidence: [],
3696
+ }];
3697
+ const candidate = { ...action, context, generation: action.generation + 1 };
3698
+ candidate.instruction = canonicalActionInstruction(workItem, candidate, context);
3699
+ const specHash = actionSpecHash(candidate);
3700
+ const changed = this.db.prepare(`UPDATE actions SET status = 'ready', attempt = 0,
3701
+ current_run_id = NULL, context = ?, instruction = ?, generation = generation + 1,
3702
+ spec_hash = ?, identity_history = ?, result_run_id = NULL, workspace = NULL, updated_at = ?
3703
+ WHERE id = ? AND generation = ? AND status IN ('ready', 'waiting', 'failed')`).run(
3704
+ stringify(context), candidate.instruction, specHash,
3705
+ stringify(actionIdentityHistory(action, candidate.generation, specHash)),
3706
+ now, action.id, action.generation,
3707
+ );
3708
+ if (Number(changed.changes) !== 1) throw new Error('Dynamic Coordinator guidance lost its Action fence');
3709
+ affectedActionIds.push(action.id);
3710
+ }
3711
+ if (affectedActionIds.length !== decision.guidance.length) {
3712
+ throw new Error('Dynamic Coordinator guidance target changed before apply');
3713
+ }
3714
+ nextStatus = 'ready';
3715
+ currentActionId = affectedActionIds[0];
3716
+ } else if (decision.kind === 'request_human') {
3717
+ nextStatus = 'waiting';
3718
+ currentActionId = null;
3719
+ } else if (decision.kind === 'complete') {
3720
+ if (activeActions.length > 0) throw new Error('Work Center cannot complete with unfinished Actions');
3721
+ if (this.#hasBlockingOperation(workItem.id)) {
3722
+ throw new Error('WorkItem has an unsafe blocking Operation and cannot complete');
3723
+ }
3724
+ finalResult = normalizeDynamicCompletion(decision.completion, workItem.acceptanceCriteria);
3725
+ const canonicalRuns = new Map(this.db.prepare(`SELECT r.* FROM runs r JOIN actions a ON a.id = r.action_id
3726
+ WHERE r.work_item_id = ? AND r.status = 'completed' AND a.result_run_id = r.id`).all(workItem.id)
3727
+ .map(row => {
3728
+ const run = mapRun(row);
3729
+ return [run.id, run];
3730
+ }));
3731
+ const criteria = Array.isArray(workItem.acceptanceCriteria) ? workItem.acceptanceCriteria : [];
3732
+ for (const runId of finalResult.evidenceRunIds) {
3733
+ const run = canonicalRuns.get(runId);
3734
+ if (!run) throw new Error(`Completion evidence is not a canonical owned Run: ${runId}`);
3735
+ if (run.evidence.length === 0) {
3736
+ throw new Error(`Completion evidence Run has no concrete evidence: ${runId}`);
3737
+ }
3738
+ if (!Array.isArray(run.acceptanceChecks) || run.acceptanceChecks.length !== criteria.length
3739
+ || run.acceptanceChecks.some((check, index) => (
3740
+ check?.criterion !== criteria[index] || !check?.status || !String(check?.evidence || '').trim()
3741
+ ))) {
3742
+ throw new Error(`Completion evidence Run has incomplete acceptance checks: ${runId}`);
3743
+ }
3744
+ }
3745
+ for (const [index, acceptanceResult] of finalResult.acceptanceResults.entries()) {
3746
+ const provesCriterion = acceptanceResult.evidenceRunIds.some(runId => (
3747
+ canonicalRuns.get(runId)?.acceptanceChecks?.[index]?.criterion === criteria[index]
3748
+ && canonicalRuns.get(runId)?.acceptanceChecks?.[index]?.status === 'passed'
3749
+ ));
3750
+ if (!provesCriterion) {
3751
+ throw new Error(`Completion criterion lacks a passing canonical Run check: ${criteria[index]}`);
3752
+ }
3753
+ }
3754
+ nextStatus = 'done';
3755
+ currentActionId = null;
3756
+ } else if (decision.kind !== 'answer') {
3757
+ throw new Error(`Unsupported dynamic Coordinator decision: ${decision.kind || '(missing)'}`);
3758
+ }
3759
+
3760
+ messages[assistantIndex] = {
3761
+ ...messages[assistantIndex], text: result.reply, status: 'completed', updatedAt: now,
3762
+ ...(result.speaker ? { speaker: result.speaker } : {}),
3763
+ decision: { kind: decision.kind, reason: decision.reason, affectedActionIds },
3764
+ };
3765
+ this.#appendConversationEntry(
3766
+ workItem.id, messages[assistantIndex], `coordinator:turn:${turnId}:assistant`,
3767
+ );
3768
+ const claim = expected.claim || {};
3769
+ if (!this.ackCoordinatorMailbox(claim.mailboxId, claim.ownerBootId, claim.claimEpoch)) {
3770
+ throw new Error('Dynamic Coordinator completion lost its mailbox claim');
3771
+ }
3772
+ const current = this.getWorkItem(workItem.id);
3773
+ const changed = this.db.prepare(`UPDATE work_items SET messages = ?,
3774
+ coordinator_revision = coordinator_revision + 1, status = ?, current_action_id = ?,
3775
+ current_run_id = NULL, final_result = COALESCE(final_result, ?), updated_at = ?
3776
+ WHERE id = ? AND coordinator_revision = ? AND revision = ? AND plan_revision = ?
3777
+ AND ledger_revision = ? AND status NOT IN ('done', 'cancelled')`).run(
3778
+ stringify(messages), nextStatus, currentActionId, finalResult ? stringify(finalResult) : null,
3779
+ now, workItem.id, current.coordinatorRevision, current.revision,
3780
+ current.planRevision, current.ledgerRevision,
3781
+ );
3782
+ if (Number(changed.changes) !== 1) throw new Error('Dynamic Coordinator completion lost its turn fence');
3783
+ this.appendEvent(workItem.id, decision.kind === 'complete'
3784
+ ? 'work_item.completed' : `coordinator.${decision.kind}`, {
3785
+ turnId, reason: decision.reason, affectedActionIds,
3786
+ });
3787
+ return this.getWorkItemDetail(workItem.id);
3788
+ }
3789
+
3475
3790
  failCoordinatorTurn(turnId, error, expected = {}) {
3476
3791
  return withTransaction(this.db, () => {
3477
3792
  const claim = expected.claim || {};
@@ -3499,7 +3814,14 @@ export class WorkItemStore {
3499
3814
  messages[index],
3500
3815
  `coordinator:turn:${turnId}:assistant`,
3501
3816
  );
3502
- if (!this.ackCoordinatorMailbox(claim.mailboxId, claim.ownerBootId, claim.claimEpoch)) return null;
3817
+ const dynamicAutomatic = isDynamicWorkItem(workItem) && expected.automatic === true;
3818
+ if (dynamicAutomatic) {
3819
+ if (!this.releaseCoordinatorMailboxClaim(
3820
+ claim.mailboxId, claim.ownerBootId, claim.claimEpoch,
3821
+ )) return null;
3822
+ } else if (!this.ackCoordinatorMailbox(
3823
+ claim.mailboxId, claim.ownerBootId, claim.claimEpoch,
3824
+ )) return null;
3503
3825
  const changed = this.db.prepare(`UPDATE work_items SET messages = ?, coordinator_revision = coordinator_revision + 1,
3504
3826
  updated_at = ? WHERE id = ? AND coordinator_revision = ?`).run(
3505
3827
  stringify(messages), now, workItem.id, workItem.coordinatorRevision,
@@ -3507,6 +3829,7 @@ export class WorkItemStore {
3507
3829
  if (Number(changed.changes) !== 1) return null;
3508
3830
  this.appendEvent(workItem.id, 'coordinator.turn_failed', {
3509
3831
  turnId, error: messages[index].error,
3832
+ retryScheduled: dynamicAutomatic,
3510
3833
  });
3511
3834
  return this.getWorkItemDetail(workItem.id);
3512
3835
  });
@@ -3541,38 +3864,71 @@ export class WorkItemStore {
3541
3864
  return withTransaction(this.db, () => {
3542
3865
  const workItem = this.getWorkItem(id);
3543
3866
  if (!workItem) return null;
3544
- const graphMode = isGraphWorkItem(workItem);
3867
+ const graphMode = usesLegacyGraph(workItem);
3868
+ const concurrentMode = graphMode || isDynamicWorkItem(workItem);
3545
3869
  const expectedAction = this.getAction(expected.actionId);
3546
3870
  const expectedGeneration = Number(expected.generation);
3547
3871
  const hasExpectedGeneration = Number.isInteger(expectedGeneration) && expectedGeneration > 0;
3548
3872
  const expectedMatches = expectedAction?.workItemId === id
3549
- && (graphMode || workItem.currentActionId === expected.actionId)
3550
- && (hasExpectedGeneration ? expectedAction.generation === expectedGeneration : !graphMode);
3873
+ && (concurrentMode || workItem.currentActionId === expected.actionId)
3874
+ && (hasExpectedGeneration ? expectedAction.generation === expectedGeneration : !concurrentMode);
3551
3875
  if (!expectedMatches || workItem.revision !== expected.revision) {
3552
3876
  throw new Error('Action changed before guidance was applied; refresh and try again');
3553
3877
  }
3554
- const guidanceStatuses = graphMode
3878
+ const guidanceStatuses = concurrentMode
3555
3879
  ? ['ready', 'running', 'waiting', 'needs_attention']
3556
3880
  : ['ready', 'running'];
3557
3881
  if (!guidanceStatuses.includes(workItem.status)) {
3558
3882
  throw new Error(`WorkItem in ${workItem.status} cannot accept Action guidance`);
3559
3883
  }
3560
- const previous = graphMode ? expectedAction
3884
+ const previous = concurrentMode ? expectedAction
3561
3885
  : (workItem.currentActionId ? this.getAction(workItem.currentActionId) : null);
3562
- const actionableStatuses = graphMode
3886
+ const actionableStatuses = concurrentMode
3563
3887
  ? ['ready', 'running', 'waiting', 'needs_attention']
3564
3888
  : ['ready', 'running'];
3565
3889
  if (!previous || !actionableStatuses.includes(previous.status)) {
3566
3890
  throw new Error('WorkItem has no active Action for guidance');
3567
3891
  }
3568
3892
  const now = this.now();
3569
- const revision = workItem.revision + 1;
3893
+ const revision = workItem.revision + (isDynamicWorkItem(workItem) ? 0 : 1);
3570
3894
  const replacement = {
3571
3895
  ...makeAction(workItem, previous),
3572
3896
  contractRevision: previous.contractRevision,
3573
3897
  };
3574
3898
  let action;
3575
- if (graphMode) {
3899
+ if (isDynamicWorkItem(workItem)) {
3900
+ this.#supersedePendingActionInputs([previous], 'Action restarted after user guidance', now);
3901
+ if (previous.status === 'running' && previous.currentRunId) {
3902
+ this.db.prepare(`UPDATE runs SET status = 'superseded', ended_at = ?, error = ?
3903
+ WHERE id = ? AND status = 'running'`).run(
3904
+ now, 'Action restarted after user guidance', previous.currentRunId,
3905
+ );
3906
+ }
3907
+ const candidate = {
3908
+ ...replacement,
3909
+ id: previous.id,
3910
+ context: withoutActionInputContext(replacement.context),
3911
+ generation: previous.generation + 1,
3912
+ attempt: 0,
3913
+ currentRunId: null,
3914
+ resultRunId: null,
3915
+ workspace: null,
3916
+ };
3917
+ candidate.instruction = canonicalActionInstruction(workItem, candidate, candidate.context);
3918
+ const specHash = actionSpecHash(candidate);
3919
+ const changed = this.db.prepare(`UPDATE actions SET status = 'ready', attempt = 0,
3920
+ current_run_id = NULL, lease_epoch = lease_epoch + ?, context = ?, instruction = ?,
3921
+ generation = generation + 1, spec_hash = ?, identity_history = ?, result_run_id = NULL,
3922
+ workspace = NULL, updated_at = ? WHERE id = ? AND generation = ?
3923
+ AND status NOT IN ('completed', 'superseded', 'cancelled')`).run(
3924
+ previous.status === 'running' ? 1 : 0,
3925
+ stringify(candidate.context), candidate.instruction, specHash,
3926
+ stringify(actionIdentityHistory(previous, candidate.generation, specHash)),
3927
+ now, previous.id, previous.generation,
3928
+ );
3929
+ if (Number(changed.changes) !== 1) throw new Error('Dynamic Action guidance lost its generation fence');
3930
+ action = this.getAction(previous.id);
3931
+ } else if (graphMode) {
3576
3932
  action = this.#resetGraphFromStage(
3577
3933
  id,
3578
3934
  previous.stageId,
@@ -3682,12 +4038,20 @@ export class WorkItemStore {
3682
4038
  let action = null;
3683
4039
  if (contractChanged) {
3684
4040
  const updated = this.getWorkItem(id);
3685
- action = this.#insertAction(id, {
3686
- ...makeInitialAction(updated),
3687
- contractRevision: revision,
3688
- }, this.#nextSequence(id), now);
3689
- this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
3690
- current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
4041
+ if (isDynamicWorkItem(updated)) {
4042
+ this.db.prepare(`UPDATE work_items SET status = 'running', current_action_id = NULL,
4043
+ current_run_id = NULL, updated_at = ? WHERE id = ?`).run(now, id);
4044
+ this.enqueueCoordinatorMailbox(id, 'contract_changed', {
4045
+ trigger: { revision, changedFields: Object.keys(patch || {}) },
4046
+ }, `dynamic:contract:${id}:${revision}`);
4047
+ } else {
4048
+ action = this.#insertAction(id, {
4049
+ ...makeInitialAction(updated),
4050
+ contractRevision: revision,
4051
+ }, this.#nextSequence(id), now);
4052
+ this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
4053
+ current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
4054
+ }
3691
4055
  }
3692
4056
  this.appendEvent(id, contractChanged ? 'workflow.retriaged' : 'work_item.updated', {
3693
4057
  revision,
@@ -3733,6 +4097,30 @@ export class WorkItemStore {
3733
4097
  const cancelledActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
3734
4098
  AND status = 'cancelled' ORDER BY sequence`).all(id).map(mapAction);
3735
4099
  this.#assertNoIntegrationReservation(cancelledActions, now);
4100
+ if (isDynamicWorkItem(workItem)) {
4101
+ this.#supersedePendingActionInputs(
4102
+ cancelledActions,
4103
+ 'WorkItem resume returned control to the Coordinator',
4104
+ now,
4105
+ );
4106
+ this.db.prepare(`UPDATE actions SET status = 'superseded', current_run_id = NULL,
4107
+ lease_epoch = lease_epoch + 1, updated_at = ? WHERE work_item_id = ? AND status = 'cancelled'`).run(
4108
+ now, id,
4109
+ );
4110
+ const changed = this.db.prepare(`UPDATE work_items SET status = 'running',
4111
+ current_action_id = NULL, current_run_id = NULL, updated_at = ?
4112
+ WHERE id = ? AND status = 'cancelled' AND revision = ?`).run(now, id, expectedRevision);
4113
+ if (Number(changed.changes) !== 1) {
4114
+ throw new Error('WorkItem changed before it was resumed; refresh and try again');
4115
+ }
4116
+ this.enqueueCoordinatorMailbox(id, 'work_item_resumed', {
4117
+ trigger: { workItemId: id, revision: expectedRevision },
4118
+ }, `dynamic:resume:${id}:${expectedRevision}`);
4119
+ this.appendEvent(id, 'work_item.resumed', {
4120
+ supersededActionIds: cancelledActions.map(action => action.id),
4121
+ });
4122
+ return this.getWorkItemDetail(id);
4123
+ }
3736
4124
  this.#supersedePendingActionInputs(
3737
4125
  cancelledActions,
3738
4126
  'WorkItem resume started a new Action generation',
@@ -3818,6 +4206,15 @@ export class WorkItemStore {
3818
4206
  throw new Error(`WorkItem in ${workItem.status} must be resumed with retry`);
3819
4207
  }
3820
4208
  const now = this.now();
4209
+ if (isDynamicWorkItem(workItem)) {
4210
+ this.db.prepare(`UPDATE work_items SET status = 'running', current_action_id = NULL,
4211
+ current_run_id = NULL, updated_at = ? WHERE id = ?`).run(now, id);
4212
+ this.enqueueCoordinatorMailbox(id, 'work_item_started', {
4213
+ trigger: { workItemId: id },
4214
+ }, `dynamic:start:${id}:${workItem.revision}`);
4215
+ this.appendEvent(id, 'work_item.started');
4216
+ return this.getWorkItemDetail(id);
4217
+ }
3821
4218
  const action = this.#insertAction(id, {
3822
4219
  ...makeInitialAction(workItem),
3823
4220
  contractRevision: workItem.revision,
@@ -3847,7 +4244,7 @@ export class WorkItemStore {
3847
4244
  }
3848
4245
  const workItem = this.getWorkItem(id);
3849
4246
  if (!workItem) return null;
3850
- const graphMode = isGraphWorkItem(workItem);
4247
+ const graphMode = usesLegacyGraph(workItem);
3851
4248
  const retryableWorkItemStatuses = graphMode
3852
4249
  ? ['ready', 'running', 'waiting', 'needs_attention']
3853
4250
  : ['waiting', 'needs_attention'];
@@ -3949,6 +4346,7 @@ export class WorkItemStore {
3949
4346
  SELECT 1 FROM actions failed_recovery
3950
4347
  WHERE failed_recovery.work_item_id = a.work_item_id
3951
4348
  AND failed_recovery.status = 'failed'
4349
+ AND COALESCE(w.coordination_mode, 'legacy') != 'dynamic'
3952
4350
  )
3953
4351
  AND NOT (
3954
4352
  json_extract(w.messages, '$[#-1].role') = 'assistant'
@@ -3956,7 +4354,11 @@ export class WorkItemStore {
3956
4354
  AND json_type(w.messages, '$[#-1].recovery') IS NOT NULL
3957
4355
  )
3958
4356
  AND (
3959
- (COALESCE(json_extract(w.workflow_snapshot, '$.executionMode'), 'linear') != 'graph'
4357
+ (COALESCE(w.coordination_mode, 'legacy') = 'dynamic'
4358
+ AND w.status IN ('ready', 'running', 'waiting', 'needs_attention'))
4359
+ OR
4360
+ (COALESCE(w.coordination_mode, 'legacy') != 'dynamic'
4361
+ AND COALESCE(json_extract(w.workflow_snapshot, '$.executionMode'), 'linear') != 'graph'
3960
4362
  AND w.status = 'ready' AND w.current_action_id = a.id AND w.current_run_id IS NULL)
3961
4363
  OR
3962
4364
  (json_extract(w.workflow_snapshot, '$.executionMode') = 'graph'
@@ -4042,8 +4444,8 @@ export class WorkItemStore {
4042
4444
  );
4043
4445
  if (Number(changedAction.changes) !== 1) return null;
4044
4446
  const workItem = this.getWorkItem(action.workItemId);
4045
- const graphMode = isGraphWorkItem(workItem);
4046
- const changedWorkItem = graphMode
4447
+ const concurrentMode = usesLegacyGraph(workItem) || isDynamicWorkItem(workItem);
4448
+ const changedWorkItem = concurrentMode
4047
4449
  ? this.db.prepare(`UPDATE work_items SET status = 'running', current_action_id = ?,
4048
4450
  current_run_id = NULL, updated_at = ? WHERE id = ?
4049
4451
  AND status IN ('ready', 'running', 'waiting', 'needs_attention')`).run(
@@ -4107,7 +4509,7 @@ export class WorkItemStore {
4107
4509
  );
4108
4510
  if (!row) return null;
4109
4511
  const workItem = this.getWorkItem(row.work_item_id);
4110
- if (isGraphWorkItem(workItem)) return row;
4512
+ if (usesLegacyGraph(workItem) || isDynamicWorkItem(workItem)) return row;
4111
4513
  return workItem?.status === 'running' && workItem.currentActionId === row.action_id
4112
4514
  && workItem.currentRunId === row.id ? row : null;
4113
4515
  }
@@ -4188,8 +4590,8 @@ export class WorkItemStore {
4188
4590
  );
4189
4591
  if (Number(actionChanged.changes) !== 1) throw new Error('Run interruption lost the Action fence');
4190
4592
  const activeWorkItem = this.getWorkItem(active.work_item_id);
4191
- const graphMode = isGraphWorkItem(activeWorkItem);
4192
- const itemChanged = graphMode
4593
+ const concurrentMode = usesLegacyGraph(activeWorkItem) || isDynamicWorkItem(activeWorkItem);
4594
+ const itemChanged = concurrentMode
4193
4595
  ? this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
4194
4596
  updated_at = ? WHERE id = ? AND status = 'running'`).run(
4195
4597
  retryable ? 'ready' : 'needs_attention', action.id, now, active.work_item_id,
@@ -4311,15 +4713,31 @@ export class WorkItemStore {
4311
4713
  const priorRuns = this.db.prepare(`SELECT * FROM runs
4312
4714
  WHERE work_item_id = ? AND id != ? AND status != 'running'
4313
4715
  ORDER BY started_at ASC`).all(workItem.id, runId).map(mapRun);
4314
- const transition = makeTransition({ run: mapRun(active), action, workItem, priorRuns });
4716
+ const transition = isDynamicWorkItem(workItem)
4717
+ ? {
4718
+ actionStatus: result.outcome === 'completed' ? 'completed'
4719
+ : result.outcome === 'waiting' ? 'waiting'
4720
+ : result.outcome === 'retryable' && action.attempt < action.maxAttempts ? 'ready' : 'failed',
4721
+ workItemStatus: result.outcome === 'retryable' && action.attempt < action.maxAttempts
4722
+ ? 'ready' : 'running',
4723
+ keepCurrentAction: false,
4724
+ eventType: result.outcome === 'completed' ? 'action.completed'
4725
+ : result.outcome === 'waiting' ? 'action.waiting' : 'action.failed',
4726
+ eventData: {
4727
+ reviewDecision: result.reviewDecision,
4728
+ error: result.error,
4729
+ reason: result.waitingReason,
4730
+ },
4731
+ }
4732
+ : makeTransition({ run: mapRun(active), action, workItem, priorRuns });
4315
4733
  if (!transition || !transition.actionStatus || !transition.workItemStatus) {
4316
4734
  throw new Error('Work Center transition plan is incomplete');
4317
4735
  }
4318
4736
  const now = this.now();
4319
- const ledgerIncrement = workItem.executionSchemaVersion === 2
4737
+ const ledgerIncrement = workItem.executionSchemaVersion >= 2
4320
4738
  && ['completed', 'failed', 'waiting'].includes(result.outcome) ? 1 : 0;
4321
4739
  this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, response = ?, summary = ?, evidence = ?,
4322
- waiting_reason = ?, error = ?, failure_kind = ?, failure_code = ?, review_decision = ?, contract_patch = ?, checkpoint = ?,
4740
+ acceptance_checks = ?, waiting_reason = ?, error = ?, failure_kind = ?, failure_code = ?, review_decision = ?, contract_patch = ?, checkpoint = ?,
4323
4741
  loop_count = ?, tool_count = ?, llm_request_count = ?, input_tokens = ?, output_tokens = ?,
4324
4742
  cache_read_tokens = ?, cache_write_tokens = ?, total_tokens = ?,
4325
4743
  progress_revision = progress_revision + 1 WHERE id = ?`).run(
@@ -4328,6 +4746,7 @@ export class WorkItemStore {
4328
4746
  normalizeRunResponse(result.response),
4329
4747
  result.summary || '',
4330
4748
  stringify(normalizeEvidence(result.evidence)),
4749
+ stringify(Array.isArray(result.acceptanceChecks) ? result.acceptanceChecks : []),
4331
4750
  result.waitingReason || null,
4332
4751
  result.error || null,
4333
4752
  result.failureKind || null,
@@ -4583,7 +5002,27 @@ export class WorkItemStore {
4583
5002
  }
4584
5003
  let currentActionId = nextAction?.id ?? (transition.keepCurrentAction ? action.id : null);
4585
5004
  let changedWorkItem;
4586
- if (transition.planConflict) {
5005
+ if (isDynamicWorkItem(workItem)) {
5006
+ const remaining = this.db.prepare(`SELECT id, status FROM actions WHERE work_item_id = ?
5007
+ AND status IN ('ready', 'running', 'waiting', 'failed') ORDER BY sequence`).all(workItem.id);
5008
+ const running = remaining.find(candidate => candidate.status === 'running');
5009
+ const ready = remaining.find(candidate => candidate.status === 'ready');
5010
+ const settledBlocker = remaining.find(candidate => ['waiting', 'failed'].includes(candidate.status));
5011
+ if (running || ready) {
5012
+ workItemStatus = running ? 'running' : 'ready';
5013
+ currentActionId = running?.id || ready.id;
5014
+ } else {
5015
+ workItemStatus = settledBlocker?.status === 'waiting' ? 'waiting'
5016
+ : settledBlocker ? 'needs_attention' : 'running';
5017
+ currentActionId = settledBlocker?.id || null;
5018
+ }
5019
+ changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
5020
+ current_run_id = NULL, ledger_revision = ledger_revision + ?, updated_at = ?
5021
+ WHERE id = ? AND status IN ('ready', 'running', 'waiting', 'needs_attention')
5022
+ AND revision = ?`).run(
5023
+ workItemStatus, currentActionId, ledgerIncrement, now, workItem.id, nextWorkItem.revision,
5024
+ );
5025
+ } else if (transition.planConflict) {
4587
5026
  changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
4588
5027
  current_run_id = NULL, ledger_revision = ledger_revision + ?, updated_at = ?
4589
5028
  WHERE id = ? AND status IN ('ready', 'running', 'waiting', 'needs_attention') AND revision = ?`).run(
@@ -4641,19 +5080,37 @@ export class WorkItemStore {
4641
5080
  actionId: action.id,
4642
5081
  runId,
4643
5082
  });
5083
+ if (isDynamicWorkItem(workItem)) {
5084
+ this.#enqueueDynamicReconciliation(workItem.id, { runId });
5085
+ }
4644
5086
  return this.getWorkItemDetail(workItem.id);
4645
5087
  });
4646
5088
  }
4647
5089
 
5090
+ #enqueueDynamicReconciliation(workItemId, trigger = {}) {
5091
+ const detail = this.getWorkItemDetail(workItemId);
5092
+ if (!isDynamicWorkItem(detail)
5093
+ || (detail.actions || []).some(action => ['ready', 'running'].includes(action.status))) return null;
5094
+ return this.enqueueCoordinatorMailbox(workItemId, 'action_settled', {
5095
+ trigger: {
5096
+ ...trigger,
5097
+ ledgerRevision: detail.ledgerRevision,
5098
+ actionIds: (detail.actions || [])
5099
+ .filter(action => !['completed', 'superseded', 'cancelled'].includes(action.status))
5100
+ .map(action => action.id),
5101
+ },
5102
+ }, `dynamic:reconcile:${workItemId}:${detail.ledgerRevision}`);
5103
+ }
5104
+
4648
5105
  #refreshGraphWorkItem(workItemId, now) {
4649
5106
  const remaining = this.db.prepare(`SELECT id, status FROM actions WHERE work_item_id = ?
4650
5107
  AND status IN ('ready', 'running', 'waiting', 'failed') ORDER BY sequence`).all(workItemId);
4651
5108
  const blocked = remaining.find(action => action.status === 'waiting' || action.status === 'failed');
4652
- const runnable = remaining.find(action => action.status === 'ready' || action.status === 'running');
5109
+ const running = remaining.find(action => action.status === 'running');
5110
+ const ready = remaining.find(action => action.status === 'ready');
4653
5111
  const status = blocked ? (blocked.status === 'waiting' ? 'waiting' : 'needs_attention')
4654
- : runnable ? (remaining.some(action => action.status === 'running') ? 'running' : 'ready')
4655
- : 'done';
4656
- const currentActionId = blocked?.id || runnable?.id || null;
5112
+ : running ? 'running' : ready ? 'ready' : 'done';
5113
+ const currentActionId = blocked?.id || running?.id || ready?.id || null;
4657
5114
  this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
4658
5115
  updated_at = ? WHERE id = ?`).run(status, currentActionId, now, workItemId);
4659
5116
  }
@@ -4686,9 +5143,17 @@ export class WorkItemStore {
4686
5143
  if (Number(changed.changes) !== 1) continue;
4687
5144
  const turnId = messages[index].turnId || null;
4688
5145
  if (turnId) {
4689
- this.db.prepare(`UPDATE coordinator_mailbox_entries SET status = 'acked', acked_at = ?,
4690
- claim_owner = NULL, claimed_at = NULL, lease_expires_at = NULL, updated_at = ?
4691
- WHERE json_extract(payload, '$.turnId') = ? AND status != 'acked'`).run(now, now, turnId);
5146
+ const providerStatus = this.db.prepare(`SELECT status FROM coordinator_provider_turns
5147
+ WHERE coordinator_turn_id = ? ORDER BY attempt_number DESC LIMIT 1`).get(turnId)?.status || null;
5148
+ if (messages[index].automatic === true && providerStatus === null) {
5149
+ this.db.prepare(`UPDATE coordinator_mailbox_entries SET status = 'pending', acked_at = NULL,
5150
+ claim_owner = NULL, claimed_at = NULL, lease_expires_at = NULL, updated_at = ?
5151
+ WHERE json_extract(payload, '$.turnId') = ? AND status != 'cancelled'`).run(now, turnId);
5152
+ } else {
5153
+ this.db.prepare(`UPDATE coordinator_mailbox_entries SET status = 'acked', acked_at = ?,
5154
+ claim_owner = NULL, claimed_at = NULL, lease_expires_at = NULL, updated_at = ?
5155
+ WHERE json_extract(payload, '$.turnId') = ? AND status != 'acked'`).run(now, now, turnId);
5156
+ }
4692
5157
  }
4693
5158
  this.appendEvent(row.id, 'coordinator.turn_interrupted', {
4694
5159
  turnId: messages[index].turnId || null,
@@ -4710,12 +5175,13 @@ export class WorkItemStore {
4710
5175
  const action = this.getAction(row.action_id);
4711
5176
  if (this.#hasActiveIntegrationReservation(action, now)) continue;
4712
5177
  const workItem = this.getWorkItem(row.work_item_id);
4713
- const graphMode = isGraphWorkItem(workItem);
5178
+ const graphMode = usesLegacyGraph(workItem);
5179
+ const concurrentMode = graphMode || isDynamicWorkItem(workItem);
4714
5180
  const isCurrent = action?.status === 'running'
4715
5181
  && action.currentRunId === row.id
4716
5182
  && action.leaseEpoch === row.lease_epoch
4717
5183
  && workItem?.status === 'running'
4718
- && (graphMode || (workItem.currentActionId === action.id && workItem.currentRunId === row.id));
5184
+ && (concurrentMode || (workItem.currentActionId === action.id && workItem.currentRunId === row.id));
4719
5185
  if (!isCurrent) {
4720
5186
  const staleStatus = workItem?.status === 'cancelled' ? 'cancelled' : 'superseded';
4721
5187
  const stillOwnsAction = action?.status === 'running'
@@ -4739,7 +5205,11 @@ export class WorkItemStore {
4739
5205
  actionId: row.action_id,
4740
5206
  runId: row.id,
4741
5207
  });
4742
- if (graphMode && workItem) this.#refreshGraphWorkItem(workItem.id, now);
5208
+ if (concurrentMode && workItem) {
5209
+ const state = this.#graphWorkItemState(workItem.id);
5210
+ this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
5211
+ updated_at = ? WHERE id = ?`).run(state.status, state.currentActionId, now, workItem.id);
5212
+ }
4743
5213
  recovered += 1;
4744
5214
  continue;
4745
5215
  }
@@ -4750,8 +5220,10 @@ export class WorkItemStore {
4750
5220
  const retryable = action.type !== 'deliver' && action.attempt < action.maxAttempts;
4751
5221
  this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL, updated_at = ?
4752
5222
  WHERE id = ?`).run(retryable ? 'ready' : 'failed', now, action.id);
4753
- if (graphMode) {
4754
- this.#refreshGraphWorkItem(workItem.id, now);
5223
+ if (concurrentMode) {
5224
+ const state = this.#graphWorkItemState(workItem.id);
5225
+ this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
5226
+ updated_at = ? WHERE id = ?`).run(state.status, state.currentActionId, now, workItem.id);
4755
5227
  } else {
4756
5228
  this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
4757
5229
  current_run_id = NULL, updated_at = ? WHERE id = ?`).run(
@@ -4765,6 +5237,9 @@ export class WorkItemStore {
4765
5237
  actionId: action.id,
4766
5238
  runId: row.id,
4767
5239
  });
5240
+ if (isDynamicWorkItem(workItem) && !retryable) {
5241
+ this.#enqueueDynamicReconciliation(workItem.id, { runId: row.id, interrupted: true });
5242
+ }
4768
5243
  recovered += 1;
4769
5244
  }
4770
5245
  return recovered;