@yeaft/webchat-agent 1.0.246 → 1.0.248

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.
@@ -4,9 +4,10 @@ import { dirname, resolve } from 'node:path';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { normalizeEvidence } from './evidence.js';
6
6
  import { normalizeActionCheckpoint } from './action-checkpoint.js';
7
- import { runMatchesActionIdentity } from './action-identity.js';
7
+ import { currentActionInputEventIds, runMatchesActionIdentity } from './action-identity.js';
8
+ import { canonicalActionInstruction, withoutActionInputContext } from './workflow.js';
8
9
 
9
- const SCHEMA_VERSION = 17;
10
+ const SCHEMA_VERSION = 22;
10
11
  const OPEN_ACTION_STATUSES = "'ready','running','waiting'";
11
12
  const MAX_REUSABLE_CONTEXT_ITEMS = 12;
12
13
  const MAX_RUN_RESPONSE_CHARS = 65_536;
@@ -38,6 +39,17 @@ function stableJson(value) {
38
39
  return JSON.stringify(value);
39
40
  }
40
41
 
42
+ function coordinatorActionFence(actions) {
43
+ return createHash('sha256').update(stableJson((actions || []).map(action => ({
44
+ id: action.id,
45
+ generation: action.generation,
46
+ status: action.status,
47
+ currentRunId: action.currentRunId || null,
48
+ leaseEpoch: action.leaseEpoch,
49
+ resultRunId: action.resultRunId || null,
50
+ })).sort((left, right) => left.id.localeCompare(right.id))), 'utf8').digest('hex');
51
+ }
52
+
41
53
  function actionSpecHash(action) {
42
54
  const spec = {
43
55
  type: action.type || '',
@@ -88,6 +100,7 @@ function mapWorkItem(row) {
88
100
  completedActionCount: Math.max(0, Number(row.completed_action_count) || 0),
89
101
  sessionContext: parseJson(row.session_context, []),
90
102
  messages: parseJson(row.messages, []),
103
+ coordinatorRevision: Math.max(0, Number(row.coordinator_revision) || 0),
91
104
  attachments: parseJson(row.attachments, []),
92
105
  executionStats: {
93
106
  llmRequestCount: Math.max(0, Number(row.usage_llm_request_count) || 0),
@@ -274,6 +287,459 @@ function hasColumn(db, table, column) {
274
287
  return db.prepare(`PRAGMA table_info(${table})`).all().some(row => row.name === column);
275
288
  }
276
289
 
290
+ function normalizeLegacyWorkItemMessages(db) {
291
+ const updateMessages = db.prepare('UPDATE work_items SET messages = ? WHERE id = ?');
292
+ for (const row of db.prepare('SELECT id, messages FROM work_items').all()) {
293
+ const messages = parseJson(row.messages, []);
294
+ if (!Array.isArray(messages) || messages.length === 0) continue;
295
+ let changed = false;
296
+ const normalized = messages.map(message => {
297
+ if (!message || typeof message !== 'object' || Array.isArray(message) || message.role) return message;
298
+ changed = true;
299
+ return {
300
+ ...message,
301
+ turnId: message.turnId || message.id || randomUUID(),
302
+ role: 'legacy_instruction',
303
+ status: message.status || 'completed',
304
+ updatedAt: message.updatedAt || message.createdAt || 0,
305
+ };
306
+ });
307
+ if (changed) updateMessages.run(stringify(normalized), row.id);
308
+ }
309
+ }
310
+
311
+ function carryCurrentActionInputContext(db, action, extraInputs = [], explicitOnly = false) {
312
+ const events = db.prepare(`SELECT * FROM events WHERE action_id = ? ORDER BY id`)
313
+ .all(action.id).map(mapEvent);
314
+ const extraRows = (Array.isArray(extraInputs) ? extraInputs : []).map(value => (
315
+ value && typeof value === 'object' ? value : { event_id: value }
316
+ ));
317
+ const validInputEventIds = explicitOnly ? new Set() : currentActionInputEventIds(events, action);
318
+ const fallbackAttachments = new Map();
319
+ for (const row of extraRows) {
320
+ validInputEventIds.add(String(row.event_id));
321
+ const attachments = parseJson(row.attachments, []);
322
+ if (Array.isArray(attachments) && attachments.length > 0) {
323
+ fallbackAttachments.set(String(row.event_id), attachments);
324
+ }
325
+ }
326
+ const inputAttachments = event => {
327
+ const attachments = Array.isArray(event?.data?.attachments) ? event.data.attachments : [];
328
+ return attachments.length > 0
329
+ ? attachments
330
+ : fallbackAttachments.get(String(event?.id)) || [];
331
+ };
332
+ const inputEvents = events.filter(event => event.type === 'action.input_added'
333
+ && validInputEventIds.has(String(event.id)));
334
+ const eventByInputId = new Map(inputEvents
335
+ .filter(event => event.data?.inputId)
336
+ .map(event => [event.data.inputId, event]));
337
+ const eventById = new Map(inputEvents.map(event => [String(event.id), event]));
338
+ const usedEventIds = new Set();
339
+ const seenInputIds = new Set();
340
+ const context = (Array.isArray(action.context) ? action.context : []).flatMap(entry => {
341
+ if (entry?.type !== 'input') return [entry];
342
+ let event = entry.inputId ? eventByInputId.get(entry.inputId) : null;
343
+ if (!event && typeof entry.inputId === 'string' && entry.inputId.startsWith('legacy-event:')) {
344
+ event = eventById.get(entry.inputId.slice('legacy-event:'.length)) || null;
345
+ }
346
+ if (!event && !entry.inputId) {
347
+ event = inputEvents.find(candidate => !usedEventIds.has(candidate.id)
348
+ && (candidate.data?.text || '') === (entry.summary || '')) || null;
349
+ }
350
+ if (!entry.inputId && !event) return explicitOnly ? [entry] : [];
351
+ const inputId = entry.inputId || event.data?.inputId || `legacy-event:${event.id}`;
352
+ if (seenInputIds.has(inputId)) return [];
353
+ seenInputIds.add(inputId);
354
+ if (event) usedEventIds.add(event.id);
355
+ return [{
356
+ ...entry,
357
+ inputId,
358
+ attachments: Array.isArray(entry.attachments) && entry.attachments.length > 0
359
+ ? entry.attachments
360
+ : inputAttachments(event),
361
+ }];
362
+ });
363
+ for (const event of inputEvents) {
364
+ if (usedEventIds.has(event.id)) continue;
365
+ const inputId = event.data?.inputId || `legacy-event:${event.id}`;
366
+ if (seenInputIds.has(inputId)) continue;
367
+ seenInputIds.add(inputId);
368
+ context.push({
369
+ type: 'input',
370
+ role: 'user',
371
+ inputId,
372
+ summary: event.data?.text || '',
373
+ attachments: inputAttachments(event),
374
+ evidence: [],
375
+ });
376
+ }
377
+ return context;
378
+ }
379
+
380
+ function promoteReadyActionInputs(db, action, rows, now, reason) {
381
+ const pendingRows = Array.isArray(rows) ? rows : [];
382
+ if (pendingRows.length === 0) return action;
383
+ if (action?.status !== 'ready' || action.currentRunId) {
384
+ throw new Error('Work Center can only promote pending input for an unowned ready Action');
385
+ }
386
+ const context = carryCurrentActionInputContext(db, action, pendingRows, true);
387
+ const contextChanged = stableJson(context) !== stableJson(action.context || []);
388
+ const workItem = mapWorkItem(db.prepare('SELECT * FROM work_items WHERE id = ?').get(action.workItemId));
389
+ if (!workItem) throw new Error('Work Center pending input Action lost its WorkItem');
390
+ const candidate = { ...action, context };
391
+ candidate.instruction = contextChanged
392
+ ? canonicalActionInstruction(workItem, candidate, context)
393
+ : action.instruction;
394
+ candidate.specHash = contextChanged ? actionSpecHash(candidate) : action.specHash;
395
+ const specChanged = candidate.specHash !== action.specHash;
396
+ const target = specChanged
397
+ ? { ...candidate, generation: action.generation + 1 }
398
+ : action;
399
+ if (specChanged) {
400
+ const changed = db.prepare(`UPDATE actions SET context = ?, instruction = ?, attempt = 0,
401
+ generation = ?, spec_hash = ?, identity_history = ?, result_run_id = NULL, workspace = NULL, updated_at = ?
402
+ WHERE id = ? AND status = 'ready' AND current_run_id IS NULL AND generation = ? AND spec_hash = ?`).run(
403
+ stringify(context),
404
+ candidate.instruction,
405
+ target.generation,
406
+ target.specHash,
407
+ stringify(actionIdentityHistory(action, target.generation, target.specHash)),
408
+ now,
409
+ action.id,
410
+ action.generation,
411
+ action.specHash,
412
+ );
413
+ if (Number(changed.changes) !== 1) {
414
+ throw new Error('Work Center could not promote ready Action input atomically');
415
+ }
416
+ }
417
+ const insertReboundEvent = db.prepare(`INSERT INTO events
418
+ (work_item_id, action_id, run_id, action_generation, type, data, created_at)
419
+ VALUES (?, ?, NULL, ?, 'action.input_rebound', ?, ?)`);
420
+ const settleReady = db.prepare(`UPDATE pending_action_inputs SET run_id = NULL,
421
+ action_generation = ?, action_spec_hash = ?, consumed_at = COALESCE(consumed_at, ?)
422
+ WHERE event_id = ? AND action_id = ? AND superseded_at IS NULL`);
423
+ for (const row of pendingRows) {
424
+ if (!row.skipReboundAudit) {
425
+ insertReboundEvent.run(
426
+ action.workItemId,
427
+ action.id,
428
+ target.generation,
429
+ stringify({
430
+ sourceEventId: Number(row.event_id),
431
+ sourceRunId: row.run_id || null,
432
+ sourceGeneration: Math.max(1, Number(row.sourceGeneration ?? row.action_generation) || 1),
433
+ sourceSpecHash: row.sourceSpecHash ?? row.action_spec_hash ?? '',
434
+ reason: row.repairedRun ? 'schema19_legacy_repair' : reason,
435
+ targetSpecHash: target.specHash,
436
+ }),
437
+ now,
438
+ );
439
+ }
440
+ const settled = settleReady.run(
441
+ target.generation,
442
+ target.specHash,
443
+ now,
444
+ Number(row.event_id),
445
+ action.id,
446
+ );
447
+ if (Number(settled.changes) !== 1) {
448
+ throw new Error('Work Center could not settle canonical ready Action input atomically');
449
+ }
450
+ }
451
+ return specChanged ? mapAction(db.prepare('SELECT * FROM actions WHERE id = ?').get(action.id)) : action;
452
+ }
453
+
454
+ function reconcilePendingActionInputIdentity(db, now) {
455
+ const rows = db.prepare(`SELECT p.*, e.type AS event_type, e.action_generation AS event_generation,
456
+ a.status AS action_status, a.current_run_id, a.generation AS current_generation,
457
+ a.spec_hash AS current_spec_hash, r.status AS run_status, r.error AS run_error,
458
+ r.action_generation AS run_generation, r.action_spec_hash AS run_spec_hash
459
+ FROM pending_action_inputs p
460
+ JOIN events e ON e.id = p.event_id
461
+ JOIN actions a ON a.id = p.action_id
462
+ LEFT JOIN runs r ON r.id = p.run_id
463
+ WHERE p.superseded_at IS NULL
464
+ ORDER BY p.event_id`).all();
465
+ const bindActive = db.prepare(`UPDATE pending_action_inputs SET action_generation = ?, action_spec_hash = ?
466
+ WHERE event_id = ? AND superseded_at IS NULL`);
467
+ const supersede = db.prepare(`UPDATE pending_action_inputs SET superseded_at = ?
468
+ WHERE event_id = ? AND superseded_at IS NULL`);
469
+ const insertSupersededEvent = db.prepare(`INSERT INTO events
470
+ (work_item_id, action_id, run_id, action_generation, type, data, created_at)
471
+ VALUES (?, ?, ?, ?, 'action.input_superseded', ?, ?)`);
472
+ const readyRowsByAction = new Map();
473
+ for (const row of rows) {
474
+ const currentGeneration = Math.max(1, Number(row.current_generation) || 1);
475
+ const currentSpecHash = row.current_spec_hash || '';
476
+ const activeRunMatches = row.action_status === 'running'
477
+ && row.current_run_id === row.run_id
478
+ && row.run_status === 'running'
479
+ && Math.max(1, Number(row.run_generation) || 1) === currentGeneration
480
+ && (row.run_spec_hash || '') === currentSpecHash;
481
+ if (row.event_type === 'action.input_added' && activeRunMatches) {
482
+ bindActive.run(currentGeneration, currentSpecHash, row.event_id);
483
+ continue;
484
+ }
485
+ const sourceGeneration = Math.max(
486
+ 1,
487
+ Number(row.run_generation) || Number(row.event_generation) || Number(row.action_generation) || 1,
488
+ );
489
+ const sourceSpecHash = row.run_spec_hash || row.action_spec_hash || '';
490
+ const repairedRun = row.run_status === 'superseded'
491
+ && row.run_error === 'Superseded by Work Center schema 19 legacy instruction repair';
492
+ const sameIdentity = sourceGeneration === currentGeneration
493
+ && (!sourceSpecHash || !currentSpecHash || sourceSpecHash === currentSpecHash);
494
+ if (row.event_type === 'action.input_added' && row.action_status === 'ready'
495
+ && (sameIdentity || repairedRun)) {
496
+ const readyRows = readyRowsByAction.get(row.action_id) || [];
497
+ readyRows.push({ ...row, sourceGeneration, sourceSpecHash, repairedRun });
498
+ readyRowsByAction.set(row.action_id, readyRows);
499
+ continue;
500
+ }
501
+ if (row.consumed_at != null) continue;
502
+ supersede.run(now, row.event_id);
503
+ insertSupersededEvent.run(
504
+ row.work_item_id,
505
+ row.action_id,
506
+ row.run_id || null,
507
+ sourceGeneration,
508
+ stringify({
509
+ reason: 'schema20_identity_mismatch',
510
+ sourceEventIds: [row.event_id],
511
+ sourceGeneration,
512
+ sourceSpecHash,
513
+ currentGeneration,
514
+ currentSpecHash,
515
+ }),
516
+ now,
517
+ );
518
+ }
519
+ for (const [actionId, readyRows] of readyRowsByAction) {
520
+ const action = mapAction(db.prepare('SELECT * FROM actions WHERE id = ?').get(actionId));
521
+ promoteReadyActionInputs(db, action, readyRows, now, 'schema20_identity_backfill');
522
+ }
523
+ }
524
+
525
+ function repairReviewBuildActionInputIdentity(db, now) {
526
+ const actions = db.prepare(`SELECT a.* FROM actions a
527
+ JOIN work_items w ON w.id = a.work_item_id
528
+ WHERE a.status = 'ready' AND a.current_run_id IS NULL
529
+ AND w.status NOT IN ('done', 'cancelled')
530
+ ORDER BY a.work_item_id, a.sequence, a.id`).all();
531
+ const inputRows = db.prepare(`SELECT p.*, e.work_item_id AS event_work_item_id,
532
+ e.action_id AS event_action_id, e.data AS event_data,
533
+ e.action_generation AS event_generation, r.work_item_id AS run_work_item_id,
534
+ r.action_id AS run_action_id, r.status AS run_status, r.error AS run_error,
535
+ r.action_generation AS run_generation, r.action_spec_hash AS run_spec_hash
536
+ FROM pending_action_inputs p
537
+ JOIN events e ON e.id = p.event_id
538
+ LEFT JOIN runs r ON r.id = p.run_id
539
+ WHERE p.action_id = ? AND p.superseded_at IS NULL AND e.type = 'action.input_added'
540
+ ORDER BY p.event_id`);
541
+ const actionEvents = db.prepare('SELECT * FROM events WHERE action_id = ? ORDER BY id');
542
+ for (const actionRow of actions) {
543
+ const action = mapAction(actionRow);
544
+ const missingInputs = action.context.filter(entry => entry?.type === 'input' && !entry.inputId);
545
+ if (missingInputs.length === 0 || !action.specHash) continue;
546
+ const existingInputIds = new Set(action.context
547
+ .filter(entry => entry?.type === 'input' && entry.inputId)
548
+ .map(entry => String(entry.inputId)));
549
+ const events = actionEvents.all(action.id).map(mapEvent);
550
+ const currentEventIds = currentActionInputEventIds(events, action);
551
+ const history = new Set(actionIdentityHistory(action)
552
+ .map(identity => `${identity.generation}\u0000${identity.specHash}`));
553
+ const currentCanonicalRows = [];
554
+ const eligible = inputRows.all(action.id).flatMap(row => {
555
+ const event = mapEvent({
556
+ id: row.event_id,
557
+ work_item_id: row.work_item_id,
558
+ action_id: row.action_id,
559
+ run_id: row.run_id,
560
+ action_generation: row.event_generation,
561
+ type: 'action.input_added',
562
+ data: row.event_data,
563
+ created_at: row.created_at,
564
+ });
565
+ const eventInputId = event.data?.inputId || `legacy-event:${event.id}`;
566
+ const sameOwner = row.work_item_id === action.workItemId
567
+ && row.event_work_item_id === action.workItemId
568
+ && row.event_action_id === action.id
569
+ && (!row.run_id || (
570
+ row.run_work_item_id === action.workItemId && row.run_action_id === action.id
571
+ ));
572
+ const sourceRunStopped = !row.run_id || (row.run_status && row.run_status !== 'running');
573
+ const eventText = event.data?.text || '';
574
+ if (!sameOwner || !sourceRunStopped || (row.text || '') !== eventText) return [];
575
+ const alreadyCurrent = currentEventIds.has(String(event.id))
576
+ && Math.max(1, Number(row.action_generation) || 1) === action.generation
577
+ && (row.action_spec_hash || '') === action.specHash;
578
+ if (existingInputIds.has(String(eventInputId))) {
579
+ if (alreadyCurrent) {
580
+ currentCanonicalRows.push({
581
+ ...row,
582
+ sourceGeneration: action.generation,
583
+ sourceSpecHash: action.specHash,
584
+ skipReboundAudit: true,
585
+ canonicalInputId: String(eventInputId),
586
+ eventText,
587
+ });
588
+ }
589
+ return [];
590
+ }
591
+ const runGeneration = Math.max(1, Number(row.run_generation) || 1);
592
+ const runSpecHash = row.run_spec_hash || '';
593
+ const malformedCurrentConsumed = row.consumed_at != null
594
+ && row.run_id
595
+ && row.run_status === 'interrupted'
596
+ && runGeneration === action.generation
597
+ && runSpecHash === action.specHash
598
+ && Math.max(1, Number(row.event_generation) || 1) === runGeneration
599
+ && Math.max(1, Number(row.action_generation) || 1) === runGeneration
600
+ && !row.action_spec_hash;
601
+ const historicalInterruptedConsumed = row.consumed_at != null
602
+ && row.run_id
603
+ && row.run_status === 'interrupted'
604
+ && runGeneration < action.generation
605
+ && runSpecHash
606
+ && history.has(`${runGeneration}\u0000${runSpecHash}`)
607
+ && Math.max(1, Number(row.event_generation) || 1) === runGeneration
608
+ && Math.max(1, Number(row.action_generation) || 1) === runGeneration
609
+ && !row.action_spec_hash;
610
+ const repairedConsumed = row.consumed_at != null
611
+ && row.run_id
612
+ && row.run_status === 'superseded'
613
+ && row.run_error === 'Superseded by Work Center schema 19 legacy instruction repair'
614
+ && runGeneration < action.generation
615
+ && runSpecHash
616
+ && history.has(`${runGeneration}\u0000${runSpecHash}`)
617
+ && Math.max(1, Number(row.event_generation) || 1) === runGeneration
618
+ && Math.max(1, Number(row.action_generation) || 1) === runGeneration
619
+ && !row.action_spec_hash;
620
+ if (!alreadyCurrent && !malformedCurrentConsumed
621
+ && !historicalInterruptedConsumed && !repairedConsumed) return [];
622
+ return [{
623
+ ...row,
624
+ sourceGeneration: alreadyCurrent ? action.generation : runGeneration,
625
+ sourceSpecHash: alreadyCurrent ? action.specHash : runSpecHash,
626
+ skipReboundAudit: alreadyCurrent,
627
+ eventText,
628
+ }];
629
+ });
630
+ if (eligible.length !== missingInputs.length) continue;
631
+ const remaining = [...eligible];
632
+ const matched = [];
633
+ for (const entry of missingInputs) {
634
+ const index = remaining.findIndex(row => row.eventText === (entry.summary || ''));
635
+ if (index < 0) break;
636
+ matched.push(remaining.splice(index, 1)[0]);
637
+ }
638
+ if (matched.length !== missingInputs.length || remaining.length !== 0) continue;
639
+ const repairRows = [...matched, ...currentCanonicalRows]
640
+ .sort((left, right) => Number(left.event_id) - Number(right.event_id));
641
+ const currentCanonicalInputIds = new Set(currentCanonicalRows.map(row => row.canonicalInputId));
642
+ const repairAction = currentCanonicalInputIds.size === 0
643
+ ? action
644
+ : {
645
+ ...action,
646
+ context: action.context.map(entry => {
647
+ if (entry?.type !== 'input' || !currentCanonicalInputIds.has(String(entry.inputId || ''))) {
648
+ return entry;
649
+ }
650
+ const { inputId: ignoredInputId, ...inputSlot } = entry;
651
+ return { ...inputSlot, attachments: [] };
652
+ }),
653
+ };
654
+ promoteReadyActionInputs(
655
+ db,
656
+ repairAction,
657
+ repairRows,
658
+ now,
659
+ 'schema22_review_build_repair',
660
+ );
661
+ }
662
+ }
663
+
664
+ function repairLegacyActionInstructions(db, now) {
665
+ const legacyActions = db.prepare(`SELECT a.*, w.title AS work_item_title,
666
+ w.goal AS work_item_goal, w.acceptance_criteria AS work_item_acceptance_criteria,
667
+ w.workflow_snapshot AS work_item_workflow_snapshot, w.session_context AS work_item_session_context,
668
+ w.current_action_id AS work_item_current_action_id
669
+ FROM actions a JOIN work_items w ON w.id = a.work_item_id
670
+ WHERE w.execution_schema_version = 1
671
+ AND w.status NOT IN ('done', 'cancelled')
672
+ AND a.status IN ('ready', 'running', 'waiting', 'failed')
673
+ ORDER BY a.work_item_id, a.sequence`).all();
674
+ const supersedeRuns = db.prepare(`UPDATE runs SET status = 'superseded', ended_at = ?,
675
+ error = ?, accepting_input = 0 WHERE action_id = ? AND status = 'running'`);
676
+ const hasRunningRun = db.prepare(`SELECT 1 FROM runs
677
+ WHERE action_id = ? AND status = 'running' LIMIT 1`);
678
+ const updateAction = db.prepare(`UPDATE actions SET context = ?, instruction = ?, status = ?, attempt = 0,
679
+ current_run_id = NULL, lease_epoch = ?, generation = ?, spec_hash = ?, identity_history = ?,
680
+ result_run_id = NULL, workspace = NULL, updated_at = ? WHERE id = ?`);
681
+ const updateWorkItem = db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
682
+ current_run_id = NULL, updated_at = ? WHERE id = ?`);
683
+ const deleteLegacyPendingInput = db.prepare(`DELETE FROM pending_action_inputs
684
+ WHERE action_id = ? AND consumed_at IS NULL AND event_id IN (
685
+ SELECT id FROM events WHERE action_id = ? AND type = 'work_item.message_applied'
686
+ )`);
687
+ const repairedGraphWorkItems = new Set();
688
+ for (const row of legacyActions) {
689
+ const action = mapAction(row);
690
+ const workflowSnapshot = parseJson(row.work_item_workflow_snapshot, null);
691
+ const workItem = {
692
+ title: row.work_item_title,
693
+ goal: row.work_item_goal,
694
+ acceptanceCriteria: parseJson(row.work_item_acceptance_criteria, []),
695
+ workflowSnapshot,
696
+ sessionContext: parseJson(row.work_item_session_context, []),
697
+ };
698
+ const context = carryCurrentActionInputContext(db, action);
699
+ const repairedAction = { ...action, context };
700
+ const instruction = canonicalActionInstruction(workItem, repairedAction, context);
701
+ const generation = action.generation + 1;
702
+ const specHash = actionSpecHash({ ...repairedAction, instruction });
703
+ const priorIdentityHistory = actionIdentityHistory(action);
704
+ const activeRun = Boolean(hasRunningRun.get(action.id));
705
+ supersedeRuns.run(
706
+ now,
707
+ 'Superseded by Work Center schema 19 legacy instruction repair',
708
+ action.id,
709
+ );
710
+ const status = action.status === 'running' ? 'ready' : action.status;
711
+ const leaseEpoch = action.leaseEpoch + (action.status === 'running' || activeRun ? 1 : 0);
712
+ updateAction.run(
713
+ stringify(context),
714
+ instruction,
715
+ status,
716
+ leaseEpoch,
717
+ generation,
718
+ specHash,
719
+ stringify(actionIdentityHistory({ ...action, identityHistory: priorIdentityHistory }, generation, specHash)),
720
+ now,
721
+ action.id,
722
+ );
723
+ deleteLegacyPendingInput.run(action.id, action.id);
724
+ if (workflowSnapshot?.executionMode === 'graph') {
725
+ repairedGraphWorkItems.add(action.workItemId);
726
+ } else if (row.work_item_current_action_id === action.id) {
727
+ updateWorkItem.run(status === 'failed' ? 'needs_attention' : status, action.id, now, action.workItemId);
728
+ }
729
+ }
730
+ for (const workItemId of repairedGraphWorkItems) {
731
+ const candidates = db.prepare(`SELECT id, status FROM actions WHERE work_item_id = ?
732
+ AND status IN ('ready', 'running', 'waiting', 'failed') ORDER BY sequence`).all(workItemId);
733
+ const attention = candidates.find(candidate => ['waiting', 'failed'].includes(candidate.status));
734
+ const active = candidates.find(candidate => ['ready', 'running'].includes(candidate.status));
735
+ const current = attention || active || null;
736
+ const status = attention
737
+ ? (attention.status === 'waiting' ? 'waiting' : 'needs_attention')
738
+ : active ? (active.status === 'running' ? 'running' : 'ready') : 'done';
739
+ updateWorkItem.run(status, current?.id || null, now, workItemId);
740
+ }
741
+ }
742
+
277
743
  export class WorkItemStore {
278
744
  constructor(dbPath, options = {}) {
279
745
  mkdirSync(dirname(dbPath), { recursive: true });
@@ -286,6 +752,7 @@ export class WorkItemStore {
286
752
  this.db.exec('PRAGMA synchronous = NORMAL;');
287
753
  this.db.exec('PRAGMA foreign_keys = ON;');
288
754
  this.#initSchema();
755
+ this.recoverInterruptedCoordinatorTurns();
289
756
  }
290
757
 
291
758
  #initSchema() {
@@ -315,6 +782,7 @@ export class WorkItemStore {
315
782
  linked_session_ids TEXT NOT NULL DEFAULT '[]',
316
783
  session_context TEXT NOT NULL DEFAULT '[]',
317
784
  messages TEXT NOT NULL DEFAULT '[]',
785
+ coordinator_revision INTEGER NOT NULL DEFAULT 0,
318
786
  attachments TEXT NOT NULL DEFAULT '[]',
319
787
  created_at INTEGER NOT NULL,
320
788
  updated_at INTEGER NOT NULL
@@ -405,9 +873,12 @@ export class WorkItemStore {
405
873
  work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
406
874
  action_id TEXT NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
407
875
  run_id TEXT,
876
+ action_generation INTEGER NOT NULL DEFAULT 1,
877
+ action_spec_hash TEXT NOT NULL DEFAULT '',
408
878
  text TEXT NOT NULL,
409
879
  attachments TEXT NOT NULL DEFAULT '[]',
410
- consumed_at INTEGER
880
+ consumed_at INTEGER,
881
+ superseded_at INTEGER
411
882
  );
412
883
  CREATE TABLE IF NOT EXISTS plan_audits (
413
884
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -465,6 +936,9 @@ export class WorkItemStore {
465
936
  if (!hasColumn(this.db, 'work_items', 'messages')) {
466
937
  this.db.exec("ALTER TABLE work_items ADD COLUMN messages TEXT NOT NULL DEFAULT '[]'");
467
938
  }
939
+ if (!hasColumn(this.db, 'work_items', 'coordinator_revision')) {
940
+ this.db.exec('ALTER TABLE work_items ADD COLUMN coordinator_revision INTEGER NOT NULL DEFAULT 0');
941
+ }
468
942
  if (!hasColumn(this.db, 'actions', 'brief')) {
469
943
  this.db.exec('ALTER TABLE actions ADD COLUMN brief TEXT');
470
944
  }
@@ -592,6 +1066,18 @@ export class WorkItemStore {
592
1066
  if (!hasColumn(this.db, 'events', 'action_generation')) {
593
1067
  this.db.exec('ALTER TABLE events ADD COLUMN action_generation INTEGER');
594
1068
  }
1069
+ if (!hasColumn(this.db, 'pending_action_inputs', 'action_generation')) {
1070
+ this.db.exec('ALTER TABLE pending_action_inputs ADD COLUMN action_generation INTEGER NOT NULL DEFAULT 1');
1071
+ }
1072
+ if (!hasColumn(this.db, 'pending_action_inputs', 'action_spec_hash')) {
1073
+ this.db.exec("ALTER TABLE pending_action_inputs ADD COLUMN action_spec_hash TEXT NOT NULL DEFAULT ''");
1074
+ }
1075
+ if (!hasColumn(this.db, 'pending_action_inputs', 'superseded_at')) {
1076
+ this.db.exec('ALTER TABLE pending_action_inputs ADD COLUMN superseded_at INTEGER');
1077
+ }
1078
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pending_action_inputs_identity
1079
+ ON pending_action_inputs(action_id, action_generation, action_spec_hash,
1080
+ run_id, consumed_at, superseded_at, event_id)`);
595
1081
  if (!hasColumn(this.db, 'runs', 'context_snapshot')) {
596
1082
  this.db.exec('ALTER TABLE runs ADD COLUMN context_snapshot TEXT');
597
1083
  }
@@ -600,6 +1086,8 @@ export class WorkItemStore {
600
1086
  }
601
1087
  if (storedSchemaVersion < SCHEMA_VERSION) {
602
1088
  withTransaction(this.db, () => {
1089
+ if (storedSchemaVersion < 18) normalizeLegacyWorkItemMessages(this.db);
1090
+ if (storedSchemaVersion < 19) repairLegacyActionInstructions(this.db, this.now());
603
1091
  const updateIdentity = this.db.prepare(`UPDATE runs SET action_generation = ?,
604
1092
  action_spec_hash = ?, action_attempt = ? WHERE id = ?`);
605
1093
  const attempts = new Map();
@@ -617,10 +1105,12 @@ export class WorkItemStore {
617
1105
  row.id,
618
1106
  );
619
1107
  }
1108
+ if (storedSchemaVersion < 20) reconcilePendingActionInputIdentity(this.db, this.now());
1109
+ if (storedSchemaVersion < 22) repairReviewBuildActionInputIdentity(this.db, this.now());
1110
+ this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
1111
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
620
1112
  });
621
1113
  }
622
- this.db.prepare(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
623
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(SCHEMA_VERSION));
624
1114
  }
625
1115
 
626
1116
  close() {
@@ -645,11 +1135,15 @@ export class WorkItemStore {
645
1135
  return Number(result.lastInsertRowid);
646
1136
  }
647
1137
 
648
- addActionInput(id, input, expected, updateReadyAction, attachments = null, addedAttachments = []) {
1138
+ addActionInput(id, input, expected, attachments = null, addedAttachments = []) {
649
1139
  return withTransaction(this.db, () => {
650
1140
  const workItem = this.getWorkItem(id);
651
1141
  if (!workItem) return null;
652
- const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
1142
+ const expectedGeneration = Number(expected.generation);
1143
+ if (!Number.isInteger(expectedGeneration) || expectedGeneration < 1) {
1144
+ throw new Error('Action input generation must be a positive integer');
1145
+ }
1146
+ const graphMode = isGraphWorkItem(workItem);
653
1147
  const inputStatuses = graphMode
654
1148
  ? ['ready', 'running', 'waiting', 'needs_attention']
655
1149
  : ['ready', 'running'];
@@ -657,36 +1151,17 @@ export class WorkItemStore {
657
1151
  throw new Error(`WorkItem in ${workItem.status} cannot accept Action input`);
658
1152
  }
659
1153
  const action = this.getAction(expected.actionId);
660
- const actionMatches = graphMode
661
- ? action?.workItemId === id && action.generation === expected.generation
662
- && ['ready', 'running'].includes(action.status)
663
- : action?.id === workItem.currentActionId && ['ready', 'running'].includes(action?.status);
1154
+ const actionMatches = action?.workItemId === id
1155
+ && action.generation === expectedGeneration
1156
+ && ['ready', 'running'].includes(action.status)
1157
+ && (graphMode || action.id === workItem.currentActionId);
664
1158
  const activeRun = action?.currentRunId ? this.getRun(action.currentRunId) : null;
665
- if (!actionMatches || activeRun?.acceptingInput === false || workItem.revision !== expected.revision) {
1159
+ const runMatches = action?.status !== 'running'
1160
+ || (activeRun?.status === 'running' && activeRun.acceptingInput !== false
1161
+ && runMatchesActionIdentity(activeRun, action));
1162
+ if (!actionMatches || !runMatches || workItem.revision !== expected.revision) {
666
1163
  throw new Error('Action changed before input was applied; refresh and try again');
667
1164
  }
668
- const now = this.now();
669
- const revision = workItem.revision + 1;
670
- const updated = updateReadyAction(workItem, action);
671
- const changedAction = this.db.prepare(`UPDATE actions SET context = ?, instruction = ?, updated_at = ?
672
- WHERE id = ? AND status = ? AND current_run_id IS ?`).run(
673
- stringify(updated.context || []),
674
- updated.instruction || action.instruction,
675
- now,
676
- action.id,
677
- action.status,
678
- action.currentRunId,
679
- );
680
- if (Number(changedAction.changes) !== 1) {
681
- throw new Error('Action changed before input was applied; refresh and try again');
682
- }
683
- this.db.prepare(`UPDATE work_items SET attachments = ?, revision = ?, updated_at = ?
684
- WHERE id = ?`).run(
685
- stringify(Array.isArray(attachments) ? attachments : workItem.attachments),
686
- revision,
687
- now,
688
- id,
689
- );
690
1165
  const projectedAttachments = (Array.isArray(addedAttachments) ? addedAttachments : []).map(attachment => ({
691
1166
  id: attachment.id,
692
1167
  name: attachment.name,
@@ -694,18 +1169,94 @@ export class WorkItemStore {
694
1169
  size: Math.max(0, Number(attachment.size) || 0),
695
1170
  isImage: attachment.isImage === true,
696
1171
  }));
1172
+ if (action.status === 'running' && projectedAttachments.length > 0) {
1173
+ throw new Error('Files cannot be added while an Action is running');
1174
+ }
1175
+ const now = this.now();
1176
+ const inputId = randomUUID();
1177
+ let eventGeneration = action.generation;
1178
+ let eventSpecHash = action.specHash;
1179
+ let eventRunId = action.currentRunId;
1180
+ if (action.status === 'ready') {
1181
+ const inheritedPendingInputs = this.db.prepare(`SELECT p.* FROM pending_action_inputs p
1182
+ LEFT JOIN runs source_run ON source_run.id = p.run_id
1183
+ WHERE p.action_id = ? AND p.action_generation = ? AND p.action_spec_hash = ?
1184
+ AND p.consumed_at IS NULL AND p.superseded_at IS NULL
1185
+ AND (p.run_id IS NULL OR source_run.status != 'running') ORDER BY p.event_id`).all(
1186
+ action.id, action.generation, action.specHash,
1187
+ );
1188
+ const nextGeneration = action.generation + 1;
1189
+ const context = carryCurrentActionInputContext(this.db, action);
1190
+ context.push({
1191
+ type: 'input',
1192
+ role: 'user',
1193
+ inputId,
1194
+ summary: input,
1195
+ attachments: projectedAttachments,
1196
+ evidence: [],
1197
+ });
1198
+ const nextAction = { ...action, context, generation: nextGeneration };
1199
+ nextAction.instruction = canonicalActionInstruction(workItem, nextAction, context);
1200
+ nextAction.specHash = actionSpecHash(nextAction);
1201
+ const changedAction = this.db.prepare(`UPDATE actions SET context = ?, instruction = ?, attempt = 0,
1202
+ generation = generation + 1, spec_hash = ?, identity_history = ?, result_run_id = NULL,
1203
+ workspace = NULL, updated_at = ? WHERE id = ? AND status = 'ready' AND current_run_id IS NULL
1204
+ AND generation = ? AND spec_hash = ?`).run(
1205
+ stringify(context),
1206
+ nextAction.instruction,
1207
+ nextAction.specHash,
1208
+ stringify(actionIdentityHistory(action, nextAction.generation, nextAction.specHash)),
1209
+ now,
1210
+ action.id,
1211
+ action.generation,
1212
+ action.specHash,
1213
+ );
1214
+ if (Number(changedAction.changes) !== 1) {
1215
+ throw new Error('Action changed before input was applied; refresh and try again');
1216
+ }
1217
+ this.#supersedePendingActionInputs(
1218
+ [action],
1219
+ 'Action spec changed after ready input',
1220
+ now,
1221
+ inheritedPendingInputs.map(row => row.event_id),
1222
+ );
1223
+ this.#rebindPendingActionInputs(action, inheritedPendingInputs, {
1224
+ runId: null,
1225
+ generation: nextAction.generation,
1226
+ specHash: nextAction.specHash,
1227
+ }, 'ready_action_input', now);
1228
+ eventGeneration = nextAction.generation;
1229
+ eventSpecHash = nextAction.specHash;
1230
+ eventRunId = null;
1231
+ }
1232
+ const revision = workItem.revision + 1;
1233
+ const changedWorkItem = this.db.prepare(`UPDATE work_items SET attachments = ?, revision = ?, updated_at = ?
1234
+ WHERE id = ? AND revision = ?`).run(
1235
+ stringify(Array.isArray(attachments) ? attachments : workItem.attachments),
1236
+ revision,
1237
+ now,
1238
+ id,
1239
+ workItem.revision,
1240
+ );
1241
+ if (Number(changedWorkItem.changes) !== 1) {
1242
+ throw new Error('Action changed before input was applied; refresh and try again');
1243
+ }
697
1244
  const eventId = this.appendEvent(id, 'action.input_added', {
1245
+ inputId,
698
1246
  text: input,
699
1247
  attachments: projectedAttachments,
700
- }, { actionId: action.id, runId: action.currentRunId });
1248
+ }, { actionId: action.id, runId: eventRunId, actionGeneration: eventGeneration });
701
1249
  if (action.status === 'running') {
702
1250
  this.db.prepare(`INSERT INTO pending_action_inputs
703
- (event_id, work_item_id, action_id, run_id, text, attachments, consumed_at)
704
- VALUES (?, ?, ?, ?, ?, ?, NULL)`).run(
1251
+ (event_id, work_item_id, action_id, run_id, action_generation, action_spec_hash,
1252
+ text, attachments, consumed_at, superseded_at)
1253
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)`).run(
705
1254
  eventId,
706
1255
  id,
707
1256
  action.id,
708
1257
  action.currentRunId,
1258
+ eventGeneration,
1259
+ eventSpecHash,
709
1260
  input,
710
1261
  stringify(projectedAttachments),
711
1262
  );
@@ -718,7 +1269,10 @@ export class WorkItemStore {
718
1269
  const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
719
1270
  if (!active || active.action_id !== actionId) return [];
720
1271
  return this.db.prepare(`SELECT * FROM pending_action_inputs
721
- WHERE action_id = ? AND consumed_at IS NULL ORDER BY event_id`).all(actionId).map(row => ({
1272
+ WHERE action_id = ? AND run_id = ? AND action_generation = ? AND action_spec_hash = ?
1273
+ AND consumed_at IS NULL AND superseded_at IS NULL ORDER BY event_id`).all(
1274
+ actionId, runId, active.action_generation, active.action_spec_hash,
1275
+ ).map(row => ({
722
1276
  id: String(row.event_id),
723
1277
  text: row.text || '',
724
1278
  attachments: parseJson(row.attachments, []),
@@ -730,8 +1284,9 @@ export class WorkItemStore {
730
1284
  const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
731
1285
  if (!active || active.action_id !== actionId) return false;
732
1286
  const result = this.db.prepare(`UPDATE pending_action_inputs SET consumed_at = ?
733
- WHERE event_id = ? AND action_id = ? AND consumed_at IS NULL`).run(
734
- this.now(), Number(eventId), actionId,
1287
+ WHERE event_id = ? AND action_id = ? AND run_id = ? AND action_generation = ?
1288
+ AND action_spec_hash = ? AND consumed_at IS NULL AND superseded_at IS NULL`).run(
1289
+ this.now(), Number(eventId), actionId, runId, active.action_generation, active.action_spec_hash,
735
1290
  );
736
1291
  return Number(result.changes) === 1;
737
1292
  });
@@ -833,12 +1388,20 @@ export class WorkItemStore {
833
1388
  }
834
1389
 
835
1390
  #insertAction(workItemId, input, sequence, now = this.now()) {
1391
+ const stageId = input.stageId || input.type;
1392
+ const activeStage = isGraphWorkItem(this.getWorkItem(workItemId))
1393
+ ? this.db.prepare(`SELECT id FROM actions WHERE work_item_id = ? AND stage_id = ?
1394
+ AND status NOT IN ('superseded', 'cancelled') LIMIT 1`).get(workItemId, stageId)
1395
+ : null;
1396
+ if (activeStage) {
1397
+ throw new Error(`Work Center Action stage identity is already active: ${stageId}`);
1398
+ }
836
1399
  const action = {
837
1400
  id: input.id || randomUUID(),
838
1401
  workItemId,
839
1402
  sequence,
840
1403
  type: input.type,
841
- stageId: input.stageId || input.type,
1404
+ stageId,
842
1405
  assignmentPolicy: input.assignmentPolicy || null,
843
1406
  modelPolicy: input.modelPolicy || null,
844
1407
  dependsOnStageIds: Array.isArray(input.dependsOnStageIds) ? input.dependsOnStageIds : [],
@@ -916,7 +1479,57 @@ export class WorkItemStore {
916
1479
  }
917
1480
  }
918
1481
 
919
- #resetGraphFromStage(workItemId, targetStageId, replacement, reason, now) {
1482
+ #supersedePendingActionInputs(actions, reason, now, keepEventIds = []) {
1483
+ const keep = new Set((keepEventIds || []).map(Number));
1484
+ const pending = this.db.prepare(`SELECT event_id FROM pending_action_inputs
1485
+ WHERE action_id = ? AND consumed_at IS NULL AND superseded_at IS NULL ORDER BY event_id`);
1486
+ const supersede = this.db.prepare(`UPDATE pending_action_inputs SET superseded_at = ?
1487
+ WHERE event_id = ? AND consumed_at IS NULL AND superseded_at IS NULL`);
1488
+ for (const action of actions) {
1489
+ const eventIds = pending.all(action.id).map(row => Number(row.event_id))
1490
+ .filter(eventId => !keep.has(eventId));
1491
+ if (eventIds.length === 0) continue;
1492
+ for (const eventId of eventIds) supersede.run(now, eventId);
1493
+ this.appendEvent(action.workItemId, 'action.input_superseded', {
1494
+ reason,
1495
+ sourceEventIds: eventIds,
1496
+ }, { actionId: action.id, actionGeneration: action.generation });
1497
+ }
1498
+ }
1499
+
1500
+ #rebindPendingActionInputs(action, rows, target, reason, now) {
1501
+ if (!Array.isArray(rows) || rows.length === 0) return 0;
1502
+ const update = this.db.prepare(`UPDATE pending_action_inputs SET run_id = ?, action_generation = ?,
1503
+ action_spec_hash = ? WHERE event_id = ? AND consumed_at IS NULL AND superseded_at IS NULL`);
1504
+ const rebound = [];
1505
+ for (const row of rows) {
1506
+ const changed = update.run(
1507
+ target.runId || null,
1508
+ target.generation,
1509
+ target.specHash,
1510
+ Number(row.event_id),
1511
+ );
1512
+ if (Number(changed.changes) === 1) rebound.push(Number(row.event_id));
1513
+ }
1514
+ if (rebound.length > 0) {
1515
+ this.appendEvent(action.workItemId, 'action.input_rebound', {
1516
+ reason,
1517
+ sourceEventIds: rebound,
1518
+ sourceRunIds: [...new Set(rows.map(row => row.run_id).filter(Boolean))],
1519
+ targetRunId: target.runId || null,
1520
+ targetSpecHash: target.specHash,
1521
+ }, {
1522
+ actionId: action.id,
1523
+ runId: target.runId || null,
1524
+ actionGeneration: target.generation,
1525
+ });
1526
+ }
1527
+ return rebound.length;
1528
+ }
1529
+
1530
+ #resetGraphFromStage(workItemId, targetStageId, replacement, reason, now, options = {}) {
1531
+ const workItem = this.getWorkItem(workItemId);
1532
+ if (!workItem) throw new Error('Work Center graph reset WorkItem is missing');
920
1533
  const actions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
921
1534
  AND status NOT IN ('superseded', 'cancelled') ORDER BY sequence`).all(workItemId).map(mapAction);
922
1535
  const target = actions.find(action => action.stageId === targetStageId);
@@ -935,6 +1548,7 @@ export class WorkItemStore {
935
1548
  }
936
1549
  const affectedActions = actions.filter(action => affected.has(action.stageId));
937
1550
  this.#assertNoIntegrationReservation(affectedActions, now);
1551
+ this.#supersedePendingActionInputs(affectedActions, reason, now);
938
1552
  const preservedTargetWorkspace = target.workspaceMode === 'integrate'
939
1553
  && ['prepared', 'finalized'].includes(target.workspace?.integration?.status)
940
1554
  && (!replacement || replacement.workspaceMode === 'integrate')
@@ -950,14 +1564,20 @@ export class WorkItemStore {
950
1564
  WHERE action_id IN (${placeholders}) AND status = 'running'`).run(now, reason, ...ids);
951
1565
  for (const action of affectedActions) {
952
1566
  const workspace = action.id === target.id ? preservedTargetWorkspace : null;
1567
+ const replacementContext = action.id === target.id && replacement
1568
+ ? replacement.context
1569
+ : action.context;
1570
+ const preserveInputIds = action.id === target.id ? options.preserveInputIds : [];
1571
+ const context = withoutActionInputContext(replacementContext, preserveInputIds);
953
1572
  const nextAction = action.id === target.id && replacement
954
- ? { ...action, ...replacement, generation: action.generation + 1 }
955
- : { ...action, generation: action.generation + 1 };
1573
+ ? { ...action, ...replacement, context, generation: action.generation + 1 }
1574
+ : { ...action, context, generation: action.generation + 1 };
1575
+ nextAction.instruction = canonicalActionInstruction(workItem, nextAction, context);
956
1576
  const nextSpecHash = actionSpecHash(nextAction);
957
1577
  this.db.prepare(`UPDATE actions SET status = 'ready', attempt = 0, current_run_id = NULL,
958
- lease_epoch = ?, generation = generation + 1, spec_hash = ?, identity_history = ?, result_run_id = NULL,
959
- workspace = ?, updated_at = ? WHERE id = ?`).run(
960
- nextEpoch.get(action.id), nextSpecHash,
1578
+ lease_epoch = ?, generation = generation + 1, context = ?, instruction = ?, spec_hash = ?, identity_history = ?,
1579
+ result_run_id = NULL, workspace = ?, updated_at = ? WHERE id = ?`).run(
1580
+ nextEpoch.get(action.id), stringify(context), nextAction.instruction, nextSpecHash,
961
1581
  stringify(actionIdentityHistory(action, nextAction.generation, nextSpecHash)),
962
1582
  stringify(workspace), now, action.id,
963
1583
  );
@@ -967,14 +1587,16 @@ export class WorkItemStore {
967
1587
  const replacementAction = {
968
1588
  ...target,
969
1589
  ...replacement,
1590
+ context: withoutActionInputContext(replacement.context, options.preserveInputIds),
970
1591
  generation: target.generation + 1,
971
1592
  contractRevision: replacement.contractRevision ?? target.contractRevision,
972
1593
  };
1594
+ replacementAction.instruction = canonicalActionInstruction(workItem, replacementAction);
973
1595
  const specHash = actionSpecHash(replacementAction);
974
1596
  this.db.prepare(`UPDATE actions SET type = ?, required_role = ?, assignment_policy = ?,
975
1597
  model_policy = ?, depends_on_stage_ids = ?, workspace_mode = ?, changes_requested_stage_id = ?,
976
1598
  instruction = ?, brief = ?, context = ?, max_attempts = ?, workspace = ?, spec_hash = ?, updated_at = ?
977
- WHERE id = ?`).run(
1599
+ WHERE id = ? AND generation = ? AND spec_hash = ?`).run(
978
1600
  replacement.type || target.type,
979
1601
  replacement.requiredRole || '',
980
1602
  stringify(replacement.assignmentPolicy || null),
@@ -982,14 +1604,16 @@ export class WorkItemStore {
982
1604
  stringify(Array.isArray(replacement.dependsOnStageIds) ? replacement.dependsOnStageIds : []),
983
1605
  replacement.workspaceMode || 'shared',
984
1606
  replacement.changesRequestedStageId || null,
985
- replacement.instruction || '',
1607
+ replacementAction.instruction,
986
1608
  stringify(replacement.brief || null),
987
- stringify(Array.isArray(replacement.context) ? replacement.context : []),
1609
+ stringify(replacementAction.context),
988
1610
  Number.isInteger(replacement.maxAttempts) ? replacement.maxAttempts : 2,
989
1611
  stringify(preservedTargetWorkspace),
990
1612
  specHash,
991
1613
  now,
992
1614
  target.id,
1615
+ target.generation + 1,
1616
+ specHash,
993
1617
  );
994
1618
  }
995
1619
  return this.getAction(target.id);
@@ -1005,21 +1629,39 @@ export class WorkItemStore {
1005
1629
  if (!action) return null;
1006
1630
  const nextWorkspaceMode = workspaceMode || action.workspaceMode;
1007
1631
  const specChanged = nextWorkspaceMode !== action.workspaceMode;
1008
- const nextAction = { ...action, workspaceMode: nextWorkspaceMode };
1632
+ const nextContext = specChanged ? carryCurrentActionInputContext(this.db, action) : action.context;
1633
+ const nextAction = { ...action, context: nextContext, workspaceMode: nextWorkspaceMode };
1634
+ if (specChanged) {
1635
+ nextAction.instruction = canonicalActionInstruction(
1636
+ this.getWorkItem(action.workItemId),
1637
+ nextAction,
1638
+ nextContext,
1639
+ );
1640
+ }
1009
1641
  const nextSpecHash = specChanged ? actionSpecHash(nextAction) : action.specHash;
1010
- const changed = this.db.prepare(`UPDATE actions SET workspace = ?, workspace_mode = ?,
1642
+ const now = this.now();
1643
+ if (specChanged) {
1644
+ this.#supersedePendingActionInputs(
1645
+ [action],
1646
+ 'Superseded by Action workspace mode change',
1647
+ now,
1648
+ );
1649
+ }
1650
+ const changed = this.db.prepare(`UPDATE actions SET workspace = ?, workspace_mode = ?, context = ?, instruction = ?,
1011
1651
  generation = generation + ?, spec_hash = ?, identity_history = ?,
1012
1652
  result_run_id = CASE WHEN ? = 1 THEN NULL ELSE result_run_id END,
1013
1653
  updated_at = ? WHERE id = ? AND generation = ?`).run(
1014
1654
  stringify(workspace),
1015
1655
  nextWorkspaceMode,
1656
+ stringify(nextContext),
1657
+ nextAction.instruction,
1016
1658
  specChanged ? 1 : 0,
1017
1659
  nextSpecHash,
1018
1660
  stringify(specChanged
1019
1661
  ? actionIdentityHistory(action, action.generation + 1, nextSpecHash)
1020
1662
  : action.identityHistory),
1021
1663
  specChanged ? 1 : 0,
1022
- this.now(),
1664
+ now,
1023
1665
  actionId,
1024
1666
  action.generation,
1025
1667
  );
@@ -1043,7 +1685,15 @@ export class WorkItemStore {
1043
1685
  if (!action || action.generation !== expectedGeneration) return null;
1044
1686
  const nextWorkspaceMode = workspaceMode || action.workspaceMode;
1045
1687
  const specChanged = nextWorkspaceMode !== action.workspaceMode;
1046
- const nextAction = { ...action, workspaceMode: nextWorkspaceMode };
1688
+ const nextContext = specChanged ? carryCurrentActionInputContext(this.db, action) : action.context;
1689
+ const nextAction = { ...action, context: nextContext, workspaceMode: nextWorkspaceMode };
1690
+ if (specChanged) {
1691
+ nextAction.instruction = canonicalActionInstruction(
1692
+ this.getWorkItem(action.workItemId),
1693
+ nextAction,
1694
+ nextContext,
1695
+ );
1696
+ }
1047
1697
  const nextGeneration = action.generation + (specChanged ? 1 : 0);
1048
1698
  const nextSpecHash = specChanged ? actionSpecHash(nextAction) : action.specHash;
1049
1699
  const now = this.now();
@@ -1061,13 +1711,15 @@ export class WorkItemStore {
1061
1711
  throw error;
1062
1712
  }
1063
1713
  }
1064
- const changed = this.db.prepare(`UPDATE actions SET workspace = ?, workspace_mode = ?,
1714
+ const changed = this.db.prepare(`UPDATE actions SET workspace = ?, workspace_mode = ?, context = ?, instruction = ?,
1065
1715
  generation = generation + ?, spec_hash = ?, identity_history = ?,
1066
1716
  result_run_id = CASE WHEN ? = 1 THEN NULL ELSE result_run_id END,
1067
1717
  updated_at = ? WHERE id = ? AND status = 'running' AND current_run_id = ?
1068
1718
  AND lease_epoch = ? AND generation = ?`).run(
1069
1719
  stringify(workspace),
1070
1720
  nextWorkspaceMode,
1721
+ stringify(nextContext),
1722
+ nextAction.instruction,
1071
1723
  specChanged ? 1 : 0,
1072
1724
  nextSpecHash,
1073
1725
  stringify(specChanged
@@ -1082,6 +1734,11 @@ export class WorkItemStore {
1082
1734
  );
1083
1735
  if (Number(changed.changes) !== 1) return null;
1084
1736
  if (specChanged) {
1737
+ const pendingInputs = this.db.prepare(`SELECT * FROM pending_action_inputs
1738
+ WHERE action_id = ? AND run_id = ? AND action_generation = ? AND action_spec_hash = ?
1739
+ AND consumed_at IS NULL AND superseded_at IS NULL ORDER BY event_id`).all(
1740
+ action.id, runId, action.generation, action.specHash,
1741
+ );
1085
1742
  const rebound = this.db.prepare(`UPDATE runs SET action_generation = ?, action_spec_hash = ?
1086
1743
  WHERE id = ? AND action_id = ? AND owner_boot_id = ? AND lease_epoch = ? AND status = 'running'
1087
1744
  AND action_generation = ? AND action_spec_hash = ?`).run(
@@ -1097,6 +1754,29 @@ export class WorkItemStore {
1097
1754
  if (Number(rebound.changes) !== 1) {
1098
1755
  throw new Error('Work Center could not rebind the owned Run after workspace fallback');
1099
1756
  }
1757
+ const reboundCount = this.#rebindPendingActionInputs(action, pendingInputs, {
1758
+ runId,
1759
+ generation: nextGeneration,
1760
+ specHash: nextSpecHash,
1761
+ }, 'workspace_fallback', now);
1762
+ if (reboundCount !== pendingInputs.length) {
1763
+ throw new Error('Work Center could not rebind every pending input after workspace fallback');
1764
+ }
1765
+ const consumeInput = this.db.prepare(`UPDATE pending_action_inputs SET consumed_at = ?
1766
+ WHERE event_id = ? AND run_id = ? AND action_generation = ? AND action_spec_hash = ?
1767
+ AND consumed_at IS NULL AND superseded_at IS NULL`);
1768
+ for (const pendingInput of pendingInputs) {
1769
+ const consumed = consumeInput.run(
1770
+ now,
1771
+ Number(pendingInput.event_id),
1772
+ runId,
1773
+ nextGeneration,
1774
+ nextSpecHash,
1775
+ );
1776
+ if (Number(consumed.changes) !== 1) {
1777
+ throw new Error('Work Center could not consume canonical input after workspace fallback');
1778
+ }
1779
+ }
1100
1780
  }
1101
1781
 
1102
1782
  if (action.workspaceMode === 'isolated-write' && nextWorkspaceMode === 'shared') {
@@ -1105,12 +1785,25 @@ export class WorkItemStore {
1105
1785
  AND status = 'ready' AND current_run_id IS NULL`).all(action.workItemId, action.id);
1106
1786
  for (const row of pendingRows) {
1107
1787
  const pending = mapAction(row);
1108
- const fallback = { ...pending, workspaceMode: 'shared', workspace: null };
1788
+ const nextContext = carryCurrentActionInputContext(this.db, pending);
1789
+ const fallback = { ...pending, context: nextContext, workspaceMode: 'shared', workspace: null };
1790
+ fallback.instruction = canonicalActionInstruction(
1791
+ this.getWorkItem(pending.workItemId),
1792
+ fallback,
1793
+ nextContext,
1794
+ );
1109
1795
  const fallbackSpecHash = actionSpecHash(fallback);
1110
- const repaired = this.db.prepare(`UPDATE actions SET workspace = NULL, workspace_mode = 'shared',
1111
- generation = generation + 1, spec_hash = ?, identity_history = ?, result_run_id = NULL, updated_at = ?
1796
+ this.#supersedePendingActionInputs(
1797
+ [pending],
1798
+ 'Superseded by workspace serialization fallback',
1799
+ now,
1800
+ );
1801
+ const repaired = this.db.prepare(`UPDATE actions SET workspace = NULL, workspace_mode = 'shared', context = ?,
1802
+ instruction = ?, generation = generation + 1, spec_hash = ?, identity_history = ?, result_run_id = NULL, updated_at = ?
1112
1803
  WHERE id = ? AND status = 'ready' AND current_run_id IS NULL
1113
1804
  AND generation = ? AND workspace_mode = ?`).run(
1805
+ stringify(nextContext),
1806
+ fallback.instruction,
1114
1807
  fallbackSpecHash,
1115
1808
  stringify(actionIdentityHistory(pending, pending.generation + 1, fallbackSpecHash)),
1116
1809
  now,
@@ -1439,66 +2132,263 @@ export class WorkItemStore {
1439
2132
  return this.db.prepare(`SELECT * FROM events WHERE action_id = ? ORDER BY id`).all(actionId).map(mapEvent);
1440
2133
  }
1441
2134
 
1442
- addWorkItemMessage(id, text, expectedRevision, updateActionInstruction) {
2135
+ beginCoordinatorTurn(id, text, expected = {}) {
1443
2136
  return withTransaction(this.db, () => {
1444
2137
  const workItem = this.getWorkItem(id);
1445
2138
  if (!workItem) return null;
1446
2139
  if (['done', 'cancelled'].includes(workItem.status)) {
1447
- throw new Error(`WorkItem in ${workItem.status} cannot accept messages`);
2140
+ throw new Error(`WorkItem in ${workItem.status} cannot accept Coordinator messages`);
1448
2141
  }
1449
- if (workItem.revision !== expectedRevision) {
1450
- throw new Error('WorkItem changed before the message was applied; refresh and try again');
2142
+ if (workItem.revision !== expected.revision
2143
+ || workItem.planRevision !== expected.planRevision
2144
+ || workItem.ledgerRevision !== expected.ledgerRevision
2145
+ || workItem.coordinatorRevision !== expected.coordinatorRevision) {
2146
+ throw new Error('WorkItem changed before the Coordinator turn started; refresh and try again');
1451
2147
  }
1452
- const openActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
1453
- AND status IN ('ready', 'running') ORDER BY sequence`).all(id).map(mapAction);
1454
- for (const action of openActions.filter(candidate => candidate.status === 'running')) {
1455
- const run = action.currentRunId ? this.getRun(action.currentRunId) : null;
1456
- if (!run || run.status !== 'running' || run.acceptingInput === false) {
1457
- throw new Error('A running Action closed its input window before the WorkItem message was applied; refresh and try again');
1458
- }
2148
+ const latest = (workItem.messages || []).at(-1);
2149
+ if (latest?.role === 'assistant' && latest.status === 'thinking') {
2150
+ throw new Error('WorkItem Coordinator is already responding');
1459
2151
  }
1460
2152
  const now = this.now();
1461
- const revision = workItem.revision + 1;
1462
- const message = { id: randomUUID(), text, createdAt: now };
1463
- const messages = [...(workItem.messages || []), message].slice(-100);
1464
- this.db.prepare(`UPDATE work_items SET messages = ?, revision = ?, updated_at = ? WHERE id = ?`)
1465
- .run(stringify(messages), revision, now, id);
1466
- const updatedWorkItem = { ...workItem, messages, revision };
1467
- for (const action of openActions) {
1468
- if (action.status === 'ready') {
1469
- const instruction = updateActionInstruction(updatedWorkItem, action);
1470
- const nextGeneration = action.generation + 1;
1471
- const nextSpecHash = actionSpecHash({ ...action, instruction, generation: nextGeneration });
1472
- this.db.prepare(`UPDATE actions SET instruction = ?, generation = ?, spec_hash = ?,
1473
- identity_history = ?, result_run_id = NULL, updated_at = ?
1474
- WHERE id = ? AND status = 'ready' AND generation = ?`).run(
1475
- instruction,
1476
- nextGeneration,
1477
- nextSpecHash,
1478
- stringify(actionIdentityHistory(action, nextGeneration, nextSpecHash)),
2153
+ const activeActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
2154
+ AND status NOT IN ('completed', 'superseded', 'cancelled') ORDER BY sequence`).all(id).map(mapAction);
2155
+ this.#assertNoIntegrationReservation(activeActions, now);
2156
+ const turnId = randomUUID();
2157
+ const userMessage = { id: randomUUID(), turnId, role: 'user', text, status: 'completed', createdAt: now };
2158
+ const assistantMessage = {
2159
+ id: randomUUID(), turnId, role: 'assistant', text: '', status: 'thinking',
2160
+ createdAt: now, updatedAt: now, decision: null,
2161
+ };
2162
+ const messages = [...(workItem.messages || []), userMessage, assistantMessage].slice(-100);
2163
+ const coordinatorRevision = workItem.coordinatorRevision + 1;
2164
+ const changed = this.db.prepare(`UPDATE work_items SET messages = ?, coordinator_revision = ?, updated_at = ?
2165
+ WHERE id = ? AND coordinator_revision = ? AND revision = ? AND plan_revision = ?
2166
+ AND ledger_revision = ?`).run(
2167
+ stringify(messages), coordinatorRevision, now, id, workItem.coordinatorRevision,
2168
+ workItem.revision, workItem.planRevision, workItem.ledgerRevision,
2169
+ );
2170
+ if (Number(changed.changes) !== 1) throw new Error('Coordinator turn lost its revision fence');
2171
+ this.appendEvent(id, 'coordinator.turn_started', {
2172
+ turnId, status: 'thinking', coordinatorRevision,
2173
+ });
2174
+ const detail = this.getWorkItemDetail(id);
2175
+ return {
2176
+ turnId,
2177
+ detail,
2178
+ fence: {
2179
+ workItemId: id,
2180
+ revision: workItem.revision,
2181
+ planRevision: workItem.planRevision,
2182
+ ledgerRevision: workItem.ledgerRevision,
2183
+ coordinatorRevision,
2184
+ status: workItem.status,
2185
+ actionFence: coordinatorActionFence(activeActions),
2186
+ },
2187
+ };
2188
+ });
2189
+ }
2190
+
2191
+ completeCoordinatorTurn(turnId, result, expected = {}) {
2192
+ return withTransaction(this.db, () => {
2193
+ const workItem = this.getWorkItem(expected.workItemId);
2194
+ if (!workItem) return null;
2195
+ if (workItem.revision !== expected.revision
2196
+ || workItem.planRevision !== expected.planRevision
2197
+ || workItem.ledgerRevision !== expected.ledgerRevision
2198
+ || workItem.coordinatorRevision !== expected.coordinatorRevision
2199
+ || workItem.status !== expected.status) {
2200
+ throw new Error('WorkItem changed while the Coordinator was responding; send the message again');
2201
+ }
2202
+ const messages = [...(workItem.messages || [])];
2203
+ const assistantIndex = messages.findIndex(message => (
2204
+ message?.turnId === turnId && message.role === 'assistant' && message.status === 'thinking'
2205
+ ));
2206
+ if (assistantIndex < 0) return null;
2207
+ const decision = result?.decision || {};
2208
+ const now = this.now();
2209
+ const activeActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
2210
+ AND status NOT IN ('completed', 'superseded', 'cancelled') ORDER BY sequence`).all(workItem.id).map(mapAction);
2211
+ if (coordinatorActionFence(activeActions) !== expected.actionFence) {
2212
+ throw new Error('WorkItem Actions changed while the Coordinator was responding; send the message again');
2213
+ }
2214
+ this.#assertNoIntegrationReservation(activeActions, now);
2215
+
2216
+ const graphMode = isGraphWorkItem(workItem);
2217
+ if (decision.kind !== 'answer'
2218
+ && (!graphMode || workItem.workflowSnapshot?.planningMode !== 'ai')) {
2219
+ throw new Error('Coordinator Action changes require an AI-planned Action graph');
2220
+ }
2221
+ let nextWorkItem = workItem;
2222
+ let affectedActionIds = [];
2223
+ if (decision.kind === 'guide_actions') {
2224
+ const guidanceByStage = new Map(decision.guidance.map(entry => [entry.stageId, entry.instruction]));
2225
+ for (const action of activeActions) {
2226
+ const instruction = guidanceByStage.get(action.stageId);
2227
+ if (!instruction) continue;
2228
+ this.#supersedePendingActionInputs(
2229
+ [action],
2230
+ 'Superseded by WorkItem Coordinator guidance',
1479
2231
  now,
1480
- action.id,
1481
- action.generation,
1482
2232
  );
1483
- continue;
2233
+ if (action.status === 'running' && action.currentRunId) {
2234
+ this.db.prepare(`UPDATE runs SET status = 'superseded', ended_at = ?, error = ?
2235
+ WHERE id = ? AND status = 'running'`).run(
2236
+ now, 'Superseded by WorkItem Coordinator guidance', action.currentRunId,
2237
+ );
2238
+ }
2239
+ const context = [...withoutActionInputContext(action.context), {
2240
+ type: 'coordinator-guidance', role: 'user', summary: instruction, evidence: [],
2241
+ }];
2242
+ const nextAction = {
2243
+ ...action, context, generation: action.generation + 1,
2244
+ };
2245
+ nextAction.instruction = canonicalActionInstruction(workItem, nextAction, context);
2246
+ const specHash = actionSpecHash(nextAction);
2247
+ const changed = this.db.prepare(`UPDATE actions SET status = 'ready', attempt = 0,
2248
+ current_run_id = NULL, lease_epoch = lease_epoch + ?, context = ?, instruction = ?,
2249
+ generation = generation + 1, spec_hash = ?, identity_history = ?, result_run_id = NULL,
2250
+ workspace = NULL, updated_at = ? WHERE id = ? AND generation = ?
2251
+ AND status NOT IN ('completed', 'superseded', 'cancelled')`).run(
2252
+ action.status === 'running' ? 1 : 0,
2253
+ stringify(context), nextAction.instruction, specHash,
2254
+ stringify(actionIdentityHistory(action, nextAction.generation, specHash)),
2255
+ now, action.id, action.generation,
2256
+ );
2257
+ if (Number(changed.changes) !== 1) throw new Error('Coordinator guidance lost the Action generation fence');
2258
+ this.appendEvent(workItem.id, 'action.guidance_added', {
2259
+ guidance: instruction,
2260
+ source: 'coordinator',
2261
+ turnId,
2262
+ }, {
2263
+ actionId: action.id,
2264
+ actionGeneration: nextAction.generation,
2265
+ });
2266
+ affectedActionIds.push(action.id);
1484
2267
  }
1485
- const run = this.getRun(action.currentRunId);
1486
- const eventId = this.appendEvent(id, 'work_item.message_applied', { message }, {
1487
- actionId: action.id,
1488
- runId: action.currentRunId,
1489
- });
1490
- this.db.prepare(`INSERT INTO pending_action_inputs
1491
- (event_id, work_item_id, action_id, run_id, text, attachments, consumed_at)
1492
- VALUES (?, ?, ?, ?, ?, '[]', NULL)`).run(
1493
- eventId,
1494
- id,
1495
- action.id,
1496
- action.currentRunId,
1497
- `WorkItem-level message: ${text}`,
2268
+ if (affectedActionIds.length !== decision.guidance.length) {
2269
+ throw new Error('Coordinator guidance target changed before the decision was applied');
2270
+ }
2271
+ } else if (decision.kind === 'replan') {
2272
+ const mutation = result.mutation;
2273
+ if (!mutation || mutation.basePlanRevision !== workItem.planRevision) {
2274
+ throw new Error('Coordinator replan lost the plan revision fence');
2275
+ }
2276
+ for (const action of mutation.unfinished) {
2277
+ this.#supersedePendingActionInputs(
2278
+ [action],
2279
+ 'Superseded by WorkItem Coordinator replan',
2280
+ now,
2281
+ );
2282
+ if (action.status === 'running' && action.currentRunId) {
2283
+ this.db.prepare(`UPDATE runs SET status = 'superseded', ended_at = ?, error = ?
2284
+ WHERE id = ? AND status = 'running'`).run(
2285
+ now, 'Superseded by WorkItem Coordinator replan', action.currentRunId,
2286
+ );
2287
+ }
2288
+ const superseded = this.db.prepare(`UPDATE actions SET status = 'superseded', current_run_id = NULL,
2289
+ lease_epoch = lease_epoch + ?, workspace = NULL, updated_at = ?
2290
+ WHERE id = ? AND generation = ? AND status NOT IN ('completed', 'superseded', 'cancelled')`).run(
2291
+ action.status === 'running' ? 1 : 0, now, action.id, action.generation,
2292
+ );
2293
+ if (Number(superseded.changes) !== 1) {
2294
+ throw new Error('Coordinator replan lost an unfinished Action generation fence');
2295
+ }
2296
+ }
2297
+ const patch = decision.contractPatch || null;
2298
+ const title = patch?.title ?? workItem.title;
2299
+ const goal = patch?.goal ?? workItem.goal;
2300
+ const criteria = patch?.acceptanceCriteria ?? workItem.acceptanceCriteria;
2301
+ const contractChanged = title !== workItem.title || goal !== workItem.goal
2302
+ || JSON.stringify(criteria) !== JSON.stringify(workItem.acceptanceCriteria);
2303
+ const changedPlan = this.db.prepare(`UPDATE work_items SET title = ?, goal = ?,
2304
+ acceptance_criteria = ?, workflow_snapshot = ?, plan_revision = plan_revision + 1,
2305
+ revision = revision + ?, updated_at = ? WHERE id = ? AND revision = ? AND plan_revision = ?
2306
+ AND ledger_revision = ? AND coordinator_revision = ?`).run(
2307
+ title, goal, stringify(criteria), stringify(mutation.workflowSnapshot),
2308
+ contractChanged ? 1 : 0, now, workItem.id, workItem.revision, workItem.planRevision,
2309
+ workItem.ledgerRevision, workItem.coordinatorRevision,
2310
+ );
2311
+ if (Number(changedPlan.changes) !== 1) throw new Error('Coordinator replan lost the WorkItem contract fence');
2312
+ nextWorkItem = this.getWorkItem(workItem.id);
2313
+ for (const entry of mutation.nextActions) {
2314
+ const inserted = this.#insertAction(workItem.id, {
2315
+ ...entry.nextAction,
2316
+ status: 'ready',
2317
+ contractRevision: nextWorkItem.revision,
2318
+ replacesActionId: entry.prior?.id || null,
2319
+ }, this.#nextSequence(workItem.id), now);
2320
+ affectedActionIds.push(inserted.id);
2321
+ }
2322
+ this.db.prepare(`INSERT INTO plan_audits
2323
+ (work_item_id, proposal_id, base_plan_revision, plan_revision, kind, action_id, run_id, data, created_at)
2324
+ VALUES (?, ?, ?, ?, 'coordinator', ?, ?, ?, ?)`).run(
2325
+ workItem.id, mutation.proposalId, workItem.planRevision, nextWorkItem.planRevision,
2326
+ affectedActionIds[0], `coordinator:${turnId}`,
2327
+ stringify({ reason: mutation.reason, actionCount: affectedActionIds.length }), now,
1498
2328
  );
1499
2329
  }
1500
- this.appendEvent(id, 'work_item.message_added', { message });
1501
- return this.getWorkItemDetail(id);
2330
+
2331
+ const graphState = decision.kind === 'answer' ? null : this.#graphWorkItemState(workItem.id);
2332
+ messages[assistantIndex] = {
2333
+ ...messages[assistantIndex], text: result.reply, status: 'completed', updatedAt: now,
2334
+ decision: {
2335
+ kind: decision.kind,
2336
+ reason: decision.reason,
2337
+ changedContract: !!decision.contractPatch,
2338
+ affectedActionIds,
2339
+ },
2340
+ };
2341
+ const current = this.getWorkItem(workItem.id);
2342
+ const coordinatorRevision = current.coordinatorRevision + 1;
2343
+ const changed = decision.kind === 'answer'
2344
+ ? this.db.prepare(`UPDATE work_items SET messages = ?, coordinator_revision = ?, updated_at = ?
2345
+ WHERE id = ? AND coordinator_revision = ? AND revision = ? AND plan_revision = ?
2346
+ AND ledger_revision = ? AND status = ? AND current_action_id IS ? AND current_run_id IS ?`).run(
2347
+ stringify(messages), coordinatorRevision, now, workItem.id, current.coordinatorRevision,
2348
+ current.revision, current.planRevision, current.ledgerRevision, workItem.status,
2349
+ workItem.currentActionId, workItem.currentRunId,
2350
+ )
2351
+ : this.db.prepare(`UPDATE work_items SET messages = ?, coordinator_revision = ?,
2352
+ status = ?, current_action_id = ?, current_run_id = NULL, updated_at = ?
2353
+ WHERE id = ? AND coordinator_revision = ? AND revision = ? AND plan_revision = ?
2354
+ AND ledger_revision = ?`).run(
2355
+ stringify(messages), coordinatorRevision, graphState.status, graphState.currentActionId,
2356
+ now, workItem.id, current.coordinatorRevision, current.revision,
2357
+ current.planRevision, current.ledgerRevision,
2358
+ );
2359
+ if (Number(changed.changes) !== 1) throw new Error('Coordinator completion lost its turn fence');
2360
+ this.appendEvent(workItem.id, `coordinator.${decision.kind}`, {
2361
+ turnId, reason: decision.reason, affectedActionIds,
2362
+ previousPlanRevision: workItem.planRevision,
2363
+ planRevision: this.getWorkItem(workItem.id).planRevision,
2364
+ });
2365
+ return this.getWorkItemDetail(workItem.id);
2366
+ });
2367
+ }
2368
+
2369
+ failCoordinatorTurn(turnId, error, expected = {}) {
2370
+ return withTransaction(this.db, () => {
2371
+ const workItem = this.getWorkItem(expected.workItemId);
2372
+ if (!workItem || workItem.coordinatorRevision !== expected.coordinatorRevision) return null;
2373
+ const messages = [...(workItem.messages || [])];
2374
+ const index = messages.findIndex(message => (
2375
+ message?.turnId === turnId && message.role === 'assistant' && message.status === 'thinking'
2376
+ ));
2377
+ if (index < 0) return null;
2378
+ const now = this.now();
2379
+ messages[index] = {
2380
+ ...messages[index], status: 'failed', updatedAt: now,
2381
+ error: String(error?.message || error || 'Coordinator failed').slice(0, 8_000),
2382
+ };
2383
+ const changed = this.db.prepare(`UPDATE work_items SET messages = ?, coordinator_revision = coordinator_revision + 1,
2384
+ updated_at = ? WHERE id = ? AND coordinator_revision = ?`).run(
2385
+ stringify(messages), now, workItem.id, workItem.coordinatorRevision,
2386
+ );
2387
+ if (Number(changed.changes) !== 1) return null;
2388
+ this.appendEvent(workItem.id, 'coordinator.turn_failed', {
2389
+ turnId, error: messages[index].error,
2390
+ });
2391
+ return this.getWorkItemDetail(workItem.id);
1502
2392
  });
1503
2393
  }
1504
2394
 
@@ -1533,9 +2423,11 @@ export class WorkItemStore {
1533
2423
  if (!workItem) return null;
1534
2424
  const graphMode = isGraphWorkItem(workItem);
1535
2425
  const expectedAction = this.getAction(expected.actionId);
1536
- const expectedMatches = graphMode
1537
- ? expectedAction?.workItemId === id && expectedAction.generation === expected.generation
1538
- : workItem.currentActionId === expected.actionId;
2426
+ const expectedGeneration = Number(expected.generation);
2427
+ const hasExpectedGeneration = Number.isInteger(expectedGeneration) && expectedGeneration > 0;
2428
+ const expectedMatches = expectedAction?.workItemId === id
2429
+ && (graphMode || workItem.currentActionId === expected.actionId)
2430
+ && (hasExpectedGeneration ? expectedAction.generation === expectedGeneration : !graphMode);
1539
2431
  if (!expectedMatches || workItem.revision !== expected.revision) {
1540
2432
  throw new Error('Action changed before guidance was applied; refresh and try again');
1541
2433
  }
@@ -1604,6 +2496,7 @@ export class WorkItemStore {
1604
2496
  const openActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
1605
2497
  AND status IN (${OPEN_ACTION_STATUSES})`).all(workItem.id).map(mapAction);
1606
2498
  this.#assertNoIntegrationReservation(openActions, now);
2499
+ this.#supersedePendingActionInputs(openActions, reason, now);
1607
2500
  if (workItem.currentRunId) {
1608
2501
  this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, error = ?
1609
2502
  WHERE id = ? AND status = 'running'`).run(runStatus, now, reason, workItem.currentRunId);
@@ -1730,11 +2623,12 @@ export class WorkItemStore {
1730
2623
  const allowedExpectedStatuses = Array.isArray(options.expected.statuses)
1731
2624
  ? options.expected.statuses
1732
2625
  : ['waiting', 'failed'];
1733
- const expectedMatches = graphMode
1734
- ? expectedAction?.workItemId === id && expectedAction.generation === options.expected.generation
1735
- && allowedExpectedStatuses.includes(expectedAction.status)
1736
- : workItem.currentActionId === options.expected.actionId
1737
- && allowedExpectedStatuses.includes(expectedAction?.status);
2626
+ const expectedGeneration = Number(options.expected.generation);
2627
+ const hasExpectedGeneration = Number.isInteger(expectedGeneration) && expectedGeneration > 0;
2628
+ const expectedMatches = expectedAction?.workItemId === id
2629
+ && allowedExpectedStatuses.includes(expectedAction.status)
2630
+ && (graphMode || workItem.currentActionId === options.expected.actionId)
2631
+ && (hasExpectedGeneration ? expectedAction.generation === expectedGeneration : !graphMode);
1738
2632
  if (!expectedMatches || workItem.revision !== options.expected.revision) {
1739
2633
  throw new Error('Action changed before input was applied; refresh and try again');
1740
2634
  }
@@ -1750,6 +2644,9 @@ export class WorkItemStore {
1750
2644
  ...makeAction(workItem, previous, previousRun),
1751
2645
  contractRevision: previous?.contractRevision ?? workItem.revision,
1752
2646
  };
2647
+ const inputEvent = options.inputEvent && typeof options.inputEvent === 'object'
2648
+ ? options.inputEvent
2649
+ : null;
1753
2650
  if (graphMode) {
1754
2651
  if (!previous) throw new Error('WorkItem graph retry target is missing');
1755
2652
  const action = this.#resetGraphFromStage(
@@ -1758,6 +2655,7 @@ export class WorkItemStore {
1758
2655
  replacement,
1759
2656
  'Superseded by manual graph retry',
1760
2657
  now,
2658
+ { preserveInputIds: inputEvent?.inputId ? [inputEvent.inputId] : [] },
1761
2659
  );
1762
2660
  this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
1763
2661
  current_run_id = NULL, attachments = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
@@ -1767,16 +2665,23 @@ export class WorkItemStore {
1767
2665
  now,
1768
2666
  id,
1769
2667
  );
1770
- const inputEvent = options.inputEvent && typeof options.inputEvent === 'object'
1771
- ? options.inputEvent
1772
- : null;
1773
2668
  if (inputEvent) {
1774
- this.appendEvent(id, 'action.input_added', inputEvent, { actionId: action.id });
2669
+ this.appendEvent(id, 'action.input_added', inputEvent, {
2670
+ actionId: action.id,
2671
+ actionGeneration: action.generation,
2672
+ });
1775
2673
  } else {
1776
2674
  this.appendEvent(id, 'work_item.retried', { targetStageId: action.stageId }, { actionId: action.id });
1777
2675
  }
1778
2676
  return this.getWorkItemDetail(id);
1779
2677
  }
2678
+ if (previous) {
2679
+ this.#supersedePendingActionInputs(
2680
+ [previous],
2681
+ 'Superseded by linear Action retry',
2682
+ now,
2683
+ );
2684
+ }
1780
2685
  const action = this.#insertAction(id, replacement, this.#nextSequence(id), now);
1781
2686
  this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
1782
2687
  current_run_id = NULL, attachments = ?, revision = ?, updated_at = ? WHERE id = ?`).run(
@@ -1786,11 +2691,11 @@ export class WorkItemStore {
1786
2691
  now,
1787
2692
  id,
1788
2693
  );
1789
- const inputEvent = options.inputEvent && typeof options.inputEvent === 'object'
1790
- ? options.inputEvent
1791
- : null;
1792
2694
  if (inputEvent) {
1793
- this.appendEvent(id, 'action.input_added', inputEvent, { actionId: action.id });
2695
+ this.appendEvent(id, 'action.input_added', inputEvent, {
2696
+ actionId: action.id,
2697
+ actionGeneration: action.generation,
2698
+ });
1794
2699
  } else {
1795
2700
  this.appendEvent(id, 'work_item.retried', {}, { actionId: action.id });
1796
2701
  }
@@ -1811,9 +2716,17 @@ export class WorkItemStore {
1811
2716
  AND w.status IN ('ready', 'running', 'waiting', 'needs_attention')
1812
2717
  AND NOT EXISTS (
1813
2718
  SELECT 1 FROM json_each(a.depends_on_stage_ids) dependency
1814
- LEFT JOIN actions required ON required.work_item_id = a.work_item_id
1815
- AND required.stage_id = dependency.value
1816
- WHERE required.id IS NULL OR required.status != 'completed'
2719
+ WHERE NOT EXISTS (
2720
+ SELECT 1 FROM actions required
2721
+ WHERE required.id = (
2722
+ SELECT canonical.id FROM actions canonical
2723
+ WHERE canonical.work_item_id = a.work_item_id
2724
+ AND canonical.stage_id = dependency.value
2725
+ AND canonical.status NOT IN ('superseded', 'cancelled')
2726
+ ORDER BY canonical.sequence DESC LIMIT 1
2727
+ )
2728
+ AND required.status = 'completed'
2729
+ )
1817
2730
  )
1818
2731
  )
1819
2732
  )
@@ -1844,32 +2757,44 @@ export class WorkItemStore {
1844
2757
  ORDER BY a.updated_at ASC, a.sequence ASC LIMIT 1`).get();
1845
2758
  if (!row) return null;
1846
2759
  const now = this.now();
2760
+ let action = mapAction(row);
2761
+ const readyInputs = this.db.prepare(`SELECT p.* FROM pending_action_inputs p
2762
+ JOIN events source_event ON source_event.id = p.event_id
2763
+ LEFT JOIN runs source_run ON source_run.id = p.run_id
2764
+ WHERE p.action_id = ? AND p.action_generation = ? AND p.action_spec_hash = ?
2765
+ AND p.superseded_at IS NULL
2766
+ AND source_event.type = 'action.input_added'
2767
+ AND (p.run_id IS NOT NULL OR p.consumed_at IS NULL)
2768
+ AND (p.run_id IS NULL OR source_run.status != 'running') ORDER BY p.event_id`).all(
2769
+ action.id, action.generation, action.specHash,
2770
+ );
2771
+ action = promoteReadyActionInputs(this.db, action, readyInputs, now, 'run_claim');
1847
2772
  const runId = randomUUID();
1848
- const leaseEpoch = Number(row.lease_epoch) + 1;
2773
+ const leaseEpoch = Number(action.leaseEpoch) + 1;
1849
2774
  const priorProgress = this.db.prepare(`SELECT MAX(progress_revision) AS value FROM runs
1850
- WHERE action_id = ?`).get(row.id);
2775
+ WHERE action_id = ?`).get(action.id);
1851
2776
  const progressRevision = Math.max(0, Number(priorProgress?.value) || 0) + 1;
1852
- const actionGeneration = Math.max(1, Number(row.generation) || 1);
2777
+ const actionGeneration = action.generation;
1853
2778
  const priorAttempt = this.db.prepare(`SELECT MAX(action_attempt) AS value FROM runs
1854
- WHERE action_id = ? AND action_generation = ?`).get(row.id, actionGeneration);
2779
+ WHERE action_id = ? AND action_generation = ?`).get(action.id, actionGeneration);
1855
2780
  const actionAttempt = Math.max(0, Number(priorAttempt?.value) || 0) + 1;
1856
2781
  const changedAction = this.db.prepare(`UPDATE actions SET status = 'running', attempt = attempt + 1,
1857
2782
  current_run_id = ?, lease_epoch = ?, updated_at = ?
1858
- WHERE id = ? AND status = 'ready' AND current_run_id IS NULL`).run(
1859
- runId, leaseEpoch, now, row.id,
2783
+ WHERE id = ? AND status = 'ready' AND current_run_id IS NULL AND generation = ? AND spec_hash = ?`).run(
2784
+ runId, leaseEpoch, now, action.id, action.generation, action.specHash,
1860
2785
  );
1861
2786
  if (Number(changedAction.changes) !== 1) return null;
1862
- const workItem = this.getWorkItem(row.work_item_id);
2787
+ const workItem = this.getWorkItem(action.workItemId);
1863
2788
  const graphMode = isGraphWorkItem(workItem);
1864
2789
  const changedWorkItem = graphMode
1865
2790
  ? this.db.prepare(`UPDATE work_items SET status = 'running', current_action_id = ?,
1866
2791
  current_run_id = NULL, updated_at = ? WHERE id = ?
1867
2792
  AND status IN ('ready', 'running', 'waiting', 'needs_attention')`).run(
1868
- row.id, now, row.work_item_id,
2793
+ action.id, now, action.workItemId,
1869
2794
  )
1870
2795
  : this.db.prepare(`UPDATE work_items SET status = 'running', current_run_id = ?, updated_at = ?
1871
2796
  WHERE id = ? AND status = 'ready' AND current_action_id = ? AND current_run_id IS NULL`).run(
1872
- runId, now, row.work_item_id, row.id,
2797
+ runId, now, action.workItemId, action.id,
1873
2798
  );
1874
2799
  if (Number(changedWorkItem.changes) !== 1) throw new Error('WorkItem claim lost its Action fence');
1875
2800
  this.db.prepare(`INSERT INTO runs
@@ -1877,23 +2802,35 @@ export class WorkItemStore {
1877
2802
  expires_at, evidence, progress_revision, action_generation, action_spec_hash, action_attempt)
1878
2803
  VALUES (?, ?, ?, ?, ?, 'running', ?, ?, '[]', ?, ?, ?, ?)`).run(
1879
2804
  runId,
1880
- row.id,
1881
- row.work_item_id,
2805
+ action.id,
2806
+ action.workItemId,
1882
2807
  ownerBootId,
1883
2808
  leaseEpoch,
1884
2809
  now,
1885
2810
  now + leaseMs,
1886
2811
  progressRevision,
1887
2812
  actionGeneration,
1888
- row.spec_hash || '',
2813
+ action.specHash,
1889
2814
  actionAttempt,
1890
2815
  );
1891
- this.appendEvent(row.work_item_id, 'run.claimed', { ownerBootId, leaseEpoch }, {
1892
- actionId: row.id, runId,
2816
+ const pendingInputs = this.db.prepare(`SELECT p.* FROM pending_action_inputs p
2817
+ LEFT JOIN runs source_run ON source_run.id = p.run_id
2818
+ WHERE p.action_id = ? AND p.action_generation = ? AND p.action_spec_hash = ?
2819
+ AND p.consumed_at IS NULL AND p.superseded_at IS NULL
2820
+ AND (p.run_id IS NULL OR source_run.status != 'running') ORDER BY p.event_id`).all(
2821
+ action.id, actionGeneration, action.specHash,
2822
+ );
2823
+ this.#rebindPendingActionInputs(action, pendingInputs, {
2824
+ runId,
2825
+ generation: actionGeneration,
2826
+ specHash: action.specHash,
2827
+ }, 'run_claim', now);
2828
+ this.appendEvent(action.workItemId, 'run.claimed', { ownerBootId, leaseEpoch }, {
2829
+ actionId: action.id, runId,
1893
2830
  });
1894
2831
  return {
1895
- workItem: this.getWorkItem(row.work_item_id),
1896
- action: this.getAction(row.id),
2832
+ workItem: this.getWorkItem(action.workItemId),
2833
+ action: this.getAction(action.id),
1897
2834
  run: this.getRun(runId),
1898
2835
  };
1899
2836
  });
@@ -1905,6 +2842,7 @@ export class WorkItemStore {
1905
2842
  JOIN work_items w ON w.id = r.work_item_id
1906
2843
  WHERE r.id = ? AND r.owner_boot_id = ? AND r.lease_epoch = ? AND r.status = 'running'
1907
2844
  AND a.status = 'running' AND a.current_run_id = r.id AND a.lease_epoch = r.lease_epoch
2845
+ AND r.action_generation = a.generation AND r.action_spec_hash = a.spec_hash
1908
2846
  AND w.status IN ('ready', 'running', 'waiting', 'needs_attention')
1909
2847
  ${requireUnexpired ? 'AND r.expires_at > ?' : ''}`).get(
1910
2848
  runId, ownerBootId, leaseEpoch, ...(requireUnexpired ? [this.now()] : []),
@@ -2088,7 +3026,10 @@ export class WorkItemStore {
2088
3026
  const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
2089
3027
  if (!active) return false;
2090
3028
  const pendingInput = this.db.prepare(`SELECT event_id FROM pending_action_inputs
2091
- WHERE action_id = ? AND consumed_at IS NULL LIMIT 1`).get(active.action_id);
3029
+ WHERE action_id = ? AND run_id = ? AND action_generation = ? AND action_spec_hash = ?
3030
+ AND consumed_at IS NULL AND superseded_at IS NULL LIMIT 1`).get(
3031
+ active.action_id, runId, active.action_generation, active.action_spec_hash,
3032
+ );
2092
3033
  if (pendingInput) return false;
2093
3034
  const changed = this.db.prepare(`UPDATE runs SET accepting_input = 0
2094
3035
  WHERE id = ? AND owner_boot_id = ? AND lease_epoch = ? AND status = 'running'
@@ -2104,7 +3045,10 @@ export class WorkItemStore {
2104
3045
  const action = this.getAction(active.action_id);
2105
3046
  const workItem = this.getWorkItem(active.work_item_id);
2106
3047
  const pendingInput = this.db.prepare(`SELECT event_id FROM pending_action_inputs
2107
- WHERE action_id = ? AND consumed_at IS NULL LIMIT 1`).get(action.id);
3048
+ WHERE action_id = ? AND run_id = ? AND action_generation = ? AND action_spec_hash = ?
3049
+ AND consumed_at IS NULL AND superseded_at IS NULL LIMIT 1`).get(
3050
+ action.id, runId, active.action_generation, active.action_spec_hash,
3051
+ );
2108
3052
  if (pendingInput) throw new Error('Run has unconsumed Action input and cannot finish yet');
2109
3053
  const priorRuns = this.db.prepare(`SELECT * FROM runs
2110
3054
  WHERE work_item_id = ? AND id != ? AND status != 'running'
@@ -2210,9 +3154,34 @@ export class WorkItemStore {
2210
3154
  ...(actionPatch.dependsOnStageIds || []),
2211
3155
  ...(patch.addDependsOnActionIds || []),
2212
3156
  ])];
2213
- const changed = this.db.prepare(`UPDATE actions SET depends_on_stage_ids = ?, updated_at = ?
2214
- WHERE id = ? AND work_item_id = ? AND status = 'ready' AND attempt = 0 AND current_run_id IS NULL`).run(
2215
- stringify(dependencies), now, actionPatch.id, workItem.id,
3157
+ const nextContext = carryCurrentActionInputContext(this.db, actionPatch);
3158
+ const nextAction = {
3159
+ ...actionPatch,
3160
+ context: nextContext,
3161
+ dependsOnStageIds: dependencies,
3162
+ generation: actionPatch.generation + 1,
3163
+ };
3164
+ nextAction.instruction = canonicalActionInstruction(nextWorkItem, nextAction, nextContext);
3165
+ nextAction.specHash = actionSpecHash(nextAction);
3166
+ this.#supersedePendingActionInputs(
3167
+ [actionPatch],
3168
+ 'Superseded by Work Center dependency patch',
3169
+ now,
3170
+ );
3171
+ const changed = this.db.prepare(`UPDATE actions SET depends_on_stage_ids = ?, context = ?, instruction = ?,
3172
+ generation = generation + 1, spec_hash = ?, identity_history = ?, result_run_id = NULL,
3173
+ workspace = NULL, updated_at = ? WHERE id = ? AND work_item_id = ? AND status = 'ready'
3174
+ AND attempt = 0 AND current_run_id IS NULL AND generation = ? AND spec_hash = ?`).run(
3175
+ stringify(dependencies),
3176
+ stringify(nextContext),
3177
+ nextAction.instruction,
3178
+ nextAction.specHash,
3179
+ stringify(actionIdentityHistory(actionPatch, nextAction.generation, nextAction.specHash)),
3180
+ now,
3181
+ actionPatch.id,
3182
+ workItem.id,
3183
+ actionPatch.generation,
3184
+ actionPatch.specHash,
2216
3185
  );
2217
3186
  if (Number(changed.changes) !== 1) {
2218
3187
  throw new Error('Work Center dependency patch lost its unattempted Action fence');
@@ -2229,6 +3198,11 @@ export class WorkItemStore {
2229
3198
  AND status NOT IN ('superseded', 'cancelled') ORDER BY sequence`).all(workItem.id).map(mapAction);
2230
3199
  this.#assertNoIntegrationReservation(activeActions, now);
2231
3200
  const unfinished = activeActions.filter(candidate => candidate.id !== action.id && candidate.status !== 'completed');
3201
+ this.#supersedePendingActionInputs(
3202
+ unfinished,
3203
+ 'Superseded by Work Center replan barrier',
3204
+ now,
3205
+ );
2232
3206
  for (const candidate of unfinished) {
2233
3207
  if (candidate.status === 'running' && candidate.currentRunId) {
2234
3208
  this.db.prepare(`UPDATE runs SET status = 'superseded', ended_at = ?, error = ?
@@ -2254,7 +3228,7 @@ export class WorkItemStore {
2254
3228
  nextAction = this.#insertAction(workItem.id, {
2255
3229
  ...barrier.action,
2256
3230
  context: [
2257
- ...(Array.isArray(barrier.action.context) ? barrier.action.context : []),
3231
+ ...withoutActionInputContext(barrier.action.context),
2258
3232
  {
2259
3233
  type: 'replan-barrier',
2260
3234
  proposalId: barrier.proposalId,
@@ -2267,11 +3241,17 @@ export class WorkItemStore {
2267
3241
  }, this.#nextSequence(workItem.id), now);
2268
3242
  }
2269
3243
  if (transition.replanMutation) {
3244
+ this.#supersedePendingActionInputs(
3245
+ transition.replanMutation.retain.map(entry => entry.action),
3246
+ 'Superseded by Work Center replan mutation',
3247
+ now,
3248
+ );
2270
3249
  for (const retained of transition.replanMutation.retain) {
2271
3250
  const prior = retained.action;
2272
3251
  const candidate = {
2273
3252
  ...prior,
2274
3253
  ...retained.nextAction,
3254
+ context: withoutActionInputContext(retained.nextAction.context ?? prior.context),
2275
3255
  status: 'ready',
2276
3256
  generation: prior.generation + 1,
2277
3257
  attempt: 0,
@@ -2279,6 +3259,7 @@ export class WorkItemStore {
2279
3259
  resultRunId: null,
2280
3260
  contractRevision: nextWorkItem.revision,
2281
3261
  };
3262
+ candidate.instruction = canonicalActionInstruction(nextWorkItem, candidate);
2282
3263
  const candidateSpecHash = actionSpecHash(candidate);
2283
3264
  const changed = this.db.prepare(`UPDATE actions SET type = ?, required_role = ?, stage_id = ?,
2284
3265
  assignment_policy = ?, model_policy = ?, depends_on_stage_ids = ?, workspace_mode = ?,
@@ -2289,7 +3270,7 @@ export class WorkItemStore {
2289
3270
  candidate.type, candidate.requiredRole || '', candidate.stageId,
2290
3271
  stringify(candidate.assignmentPolicy || null), stringify(candidate.modelPolicy || null),
2291
3272
  stringify(candidate.dependsOnStageIds || []), candidate.workspaceMode || 'shared',
2292
- candidate.changesRequestedStageId || null, candidate.instruction || '', stringify(candidate.brief || null),
3273
+ candidate.changesRequestedStageId || null, candidate.instruction, stringify(candidate.brief || null),
2293
3274
  stringify(candidate.context || []), candidate.contractRevision, candidate.generation,
2294
3275
  candidateSpecHash, stringify(actionIdentityHistory(prior, candidate.generation, candidateSpecHash)),
2295
3276
  candidate.maxAttempts || 2, now,
@@ -2416,6 +3397,38 @@ export class WorkItemStore {
2416
3397
  updated_at = ? WHERE id = ?`).run(status, currentActionId, now, workItemId);
2417
3398
  }
2418
3399
 
3400
+ recoverInterruptedCoordinatorTurns() {
3401
+ return withTransaction(this.db, () => {
3402
+ const now = this.now();
3403
+ let recovered = 0;
3404
+ for (const row of this.db.prepare(`SELECT id, messages, coordinator_revision FROM work_items
3405
+ WHERE json_extract(messages, '$[#-1].role') = 'assistant'
3406
+ AND json_extract(messages, '$[#-1].status') = 'thinking'`).all()) {
3407
+ const messages = parseJson(row.messages, []);
3408
+ const index = messages.length - 1;
3409
+ if (index < 0 || messages[index]?.role !== 'assistant' || messages[index]?.status !== 'thinking') continue;
3410
+ messages[index] = {
3411
+ ...messages[index],
3412
+ status: 'failed',
3413
+ updatedAt: now,
3414
+ error: 'Coordinator turn was interrupted before it produced a decision',
3415
+ };
3416
+ const changed = this.db.prepare(`UPDATE work_items SET messages = ?,
3417
+ coordinator_revision = coordinator_revision + 1, updated_at = ?
3418
+ WHERE id = ? AND coordinator_revision = ?`).run(
3419
+ stringify(messages), now, row.id, row.coordinator_revision,
3420
+ );
3421
+ if (Number(changed.changes) !== 1) continue;
3422
+ this.appendEvent(row.id, 'coordinator.turn_interrupted', {
3423
+ turnId: messages[index].turnId || null,
3424
+ error: messages[index].error,
3425
+ });
3426
+ recovered += 1;
3427
+ }
3428
+ return recovered;
3429
+ });
3430
+ }
3431
+
2419
3432
  recoverInterruptedRuns(ownerBootId) {
2420
3433
  return withTransaction(this.db, () => {
2421
3434
  const now = this.now();