@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.
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +59 -23
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/work-center/action-identity.js +23 -0
- package/yeaft/work-center/bridge.js +17 -2
- package/yeaft/work-center/completion-contract.js +25 -3
- package/yeaft/work-center/controller.js +52 -75
- package/yeaft/work-center/coordinator.js +323 -0
- package/yeaft/work-center/mainline-projection.js +86 -36
- package/yeaft/work-center/plan-mutation.js +99 -2
- package/yeaft/work-center/projection.js +23 -0
- package/yeaft/work-center/runner.js +1 -1
- package/yeaft/work-center/service.js +19 -5
- package/yeaft/work-center/store.js +1160 -147
- package/yeaft/work-center/workflow.js +75 -5
|
@@ -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 =
|
|
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,
|
|
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
|
|
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 =
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
704
|
-
|
|
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
|
|
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
|
|
734
|
-
|
|
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
|
|
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
|
-
#
|
|
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,
|
|
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
|
-
|
|
1607
|
+
replacementAction.instruction,
|
|
986
1608
|
stringify(replacement.brief || null),
|
|
987
|
-
stringify(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
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 !==
|
|
1450
|
-
|
|
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
|
|
1453
|
-
|
|
1454
|
-
|
|
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
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
const
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
(
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
1501
|
-
|
|
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
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
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(
|
|
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(
|
|
2775
|
+
WHERE action_id = ?`).get(action.id);
|
|
1851
2776
|
const progressRevision = Math.max(0, Number(priorProgress?.value) || 0) + 1;
|
|
1852
|
-
const actionGeneration =
|
|
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(
|
|
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
|
|
1859
|
-
runId, leaseEpoch, now,
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1881
|
-
|
|
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
|
-
|
|
2813
|
+
action.specHash,
|
|
1889
2814
|
actionAttempt,
|
|
1890
2815
|
);
|
|
1891
|
-
this.
|
|
1892
|
-
|
|
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(
|
|
1896
|
-
action: this.getAction(
|
|
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
|
|
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
|
|
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
|
|
2214
|
-
|
|
2215
|
-
|
|
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
|
-
...(
|
|
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
|
|
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();
|