@yeaft/webchat-agent 1.0.296 → 1.0.299

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.
Files changed (31) hide show
  1. package/local-runtime/server/auth/login.js +2 -0
  2. package/local-runtime/server/db/connection.js +13 -0
  3. package/local-runtime/server/db/session-db.js +3 -0
  4. package/local-runtime/server/db/yeaft-session-db.js +9 -0
  5. package/local-runtime/server/handlers/agent-output.js +27 -0
  6. package/local-runtime/server/handlers/client-conversation.js +2 -2
  7. package/local-runtime/server/handlers/client-work-center.js +4 -3
  8. package/local-runtime/server/routes/auth-routes.js +8 -1
  9. package/local-runtime/server/routes/user-routes.js +1 -0
  10. package/local-runtime/server/session-catalog.js +4 -7
  11. package/local-runtime/version.json +1 -1
  12. package/local-runtime/web/app.bundle.js +110 -95
  13. package/local-runtime/web/app.bundle.js.gz +0 -0
  14. package/local-runtime/web/index.html +2 -2
  15. package/local-runtime/web/style.bundle.css +1 -1
  16. package/local-runtime/web/style.bundle.css.gz +0 -0
  17. package/package.json +1 -1
  18. package/yeaft/engine.js +3 -0
  19. package/yeaft/sessions/session-crud.js +9 -6
  20. package/yeaft/sessions/session-store.js +1 -0
  21. package/yeaft/tools/file-edit.js +8 -6
  22. package/yeaft/tools/file-write.js +2 -2
  23. package/yeaft/tools/registry.js +28 -10
  24. package/yeaft/tools/types.js +4 -0
  25. package/yeaft/web-bridge.js +10 -2
  26. package/yeaft/work-center/coordinator.js +37 -10
  27. package/yeaft/work-center/durable-model.js +59 -3
  28. package/yeaft/work-center/projection.js +2 -0
  29. package/yeaft/work-center/runner.js +14 -5
  30. package/yeaft/work-center/service.js +59 -16
  31. package/yeaft/work-center/store.js +242 -53
@@ -60,6 +60,14 @@ function parseBoardCursor(value) {
60
60
  }
61
61
  }
62
62
 
63
+ function removeUnpersistedAttachmentFiles(root, workItemId, addedAttachments, detail) {
64
+ if (!Array.isArray(addedAttachments) || addedAttachments.length === 0) return;
65
+ const persistedIds = new Set((Array.isArray(detail?.attachments) ? detail.attachments : [])
66
+ .map(attachment => attachment?.id).filter(Boolean));
67
+ const unpersisted = addedAttachments.filter(attachment => !persistedIds.has(attachment?.id));
68
+ if (unpersisted.length > 0) removeWorkItemAttachmentFiles(root, workItemId, unpersisted);
69
+ }
70
+
63
71
  function listBoardItems(store, payload) {
64
72
  const limit = Math.min(Math.max(Number(payload.limit) || 100, 1), 200);
65
73
  const cursor = parseBoardCursor(payload.cursor);
@@ -106,6 +114,7 @@ export class WorkCenterService {
106
114
  listAvailableVpIds: options.listAvailableVpIds,
107
115
  });
108
116
  this.coordinator = options.coordinator || null;
117
+ if (this.coordinator) this.coordinator.ownerBootId = this.ownerBootId;
109
118
  this.onEvent = typeof options.onEvent === 'function' ? options.onEvent : () => {};
110
119
  this.recoveryTasks = new Map();
111
120
  this.recoveryQueue = new Map();
@@ -319,9 +328,14 @@ export class WorkCenterService {
319
328
  const clientMessageId = requiredString(payload.clientMessageId, 'clientMessageId');
320
329
  const target = payload.target && typeof payload.target === 'object' ? payload.target : {};
321
330
  if (target.kind === 'coordinator') {
331
+ const receipt = this.store.getCoordinatorClientMessageReceipt(payload.id, clientMessageId);
332
+ if (receipt) return { accepted: true, turnId: receipt.turnId || null, duplicate: true };
322
333
  return this.handle('work_item_message', { ...payload, clientMessageId }, requestContext);
323
334
  }
324
335
  if (target.kind === 'action') {
336
+ if (this.store.hasActionInputClientMessage(payload.id, target.actionId, clientMessageId)) {
337
+ return this.store.getWorkItemDetail(payload.id);
338
+ }
325
339
  return this.handle('action_input', {
326
340
  ...payload,
327
341
  clientMessageId,
@@ -379,7 +393,12 @@ export class WorkCenterService {
379
393
  }, {
380
394
  onUpdate: (type, nextWorkItem) => {
381
395
  this.watcher.abortInvalidWorkItemRuns(id);
382
- this.#emit({ type, workItem: nextWorkItem });
396
+ this.#emit({
397
+ type,
398
+ clientMessageId: typeof payload.clientMessageId === 'string'
399
+ ? payload.clientMessageId : null,
400
+ workItem: nextWorkItem,
401
+ });
383
402
  },
384
403
  });
385
404
  } catch (error) {
@@ -392,6 +411,7 @@ export class WorkCenterService {
392
411
  } catch {}
393
412
  throw error;
394
413
  }
414
+ removeUnpersistedAttachmentFiles(this.attachmentRoot, id, addedAttachments, turn.detail);
395
415
  turn.task.catch(() => {});
396
416
  return { accepted: true, turnId: turn.detail.messages?.at(-1)?.turnId || null };
397
417
  }
@@ -444,9 +464,15 @@ export class WorkCenterService {
444
464
  } catch {}
445
465
  throw error;
446
466
  }
467
+ removeUnpersistedAttachmentFiles(this.attachmentRoot, id, addedAttachments, detail);
447
468
  this.watcher.abortInvalidWorkItemRuns(id);
448
469
  this.watcher.notifyActionInput(id, payload.actionId);
449
- this.#emit({ type: 'action.input_added', workItem: detail });
470
+ this.#emit({
471
+ type: 'action.input_added',
472
+ actionId: payload.actionId,
473
+ clientMessageId: typeof payload.clientMessageId === 'string' ? payload.clientMessageId : null,
474
+ workItem: detail,
475
+ });
450
476
  return detail;
451
477
  }
452
478
  case 'guide': {
@@ -566,6 +592,35 @@ export class WorkCenterService {
566
592
  this.#drainFailureRecoveryQueue();
567
593
  }
568
594
 
595
+ #scanCoordinatorProviderRecoveries() {
596
+ if (this.shuttingDown || !this.coordinator) return;
597
+ this.store.recoverCoordinatorProviderTurns();
598
+ this.store.recoverCoordinatorMailbox();
599
+ for (const recoverable of this.store.getRecoverableCoordinatorTurns?.() || []) {
600
+ const claim = this.store.claimCoordinatorTurn(
601
+ recoverable.workItemId, recoverable.turnId, this.ownerBootId,
602
+ );
603
+ if (!claim) continue;
604
+ const started = this.store.resumeCoordinatorTurn(
605
+ recoverable.workItemId, recoverable.turnId, claim,
606
+ );
607
+ if (!started) continue;
608
+ const turn = this.coordinator.resume(started, {
609
+ text: recoverable.text || '',
610
+ recovery: Boolean(recoverable.recovery),
611
+ addedAttachments: Array.isArray(recoverable.addedAttachments)
612
+ ? recoverable.addedAttachments : [],
613
+ onUpdate: (type, detail) => this.#emit({ type, workItem: detail }),
614
+ });
615
+ turn?.task?.catch?.(() => {});
616
+ }
617
+ }
618
+
619
+ #scanRecoveries() {
620
+ this.#scanCoordinatorProviderRecoveries();
621
+ this.#scanFailureRecoveries();
622
+ }
623
+
569
624
  #scanFailureRecoveries() {
570
625
  if (this.shuttingDown || !this.coordinator) return;
571
626
  const now = Date.now();
@@ -627,22 +682,10 @@ export class WorkCenterService {
627
682
  }
628
683
 
629
684
  start() {
630
- for (const recoverable of this.store.getRecoverableCoordinatorTurns?.() || []) {
631
- const started = this.store.resumeCoordinatorTurn(recoverable.workItemId, recoverable.turnId);
632
- if (!started) continue;
633
- const task = this.coordinator.resume(started, {
634
- text: recoverable.text || '',
635
- recovery: Boolean(recoverable.recovery),
636
- addedAttachments: Array.isArray(recoverable.addedAttachments)
637
- ? recoverable.addedAttachments : [],
638
- onUpdate: (type, detail) => this.#emit({ type, workItem: detail }),
639
- }).task;
640
- task?.catch?.(() => {});
641
- }
642
- this.#scanFailureRecoveries();
685
+ this.#scanRecoveries();
643
686
  if (!this.recoveryTimer) {
644
687
  this.recoveryTimer = setInterval(
645
- () => this.#scanFailureRecoveries(),
688
+ () => this.#scanRecoveries(),
646
689
  this.recoveryPollIntervalMs,
647
690
  );
648
691
  this.recoveryTimer.unref?.();
@@ -1282,6 +1282,43 @@ export class WorkItemStore {
1282
1282
  return this.db.isTransaction ? enqueue() : withTransaction(this.db, enqueue);
1283
1283
  }
1284
1284
 
1285
+ claimCoordinatorTurn(workItemId, turnId, owner, leaseMs = 60_000) {
1286
+ return withTransaction(this.db, () => {
1287
+ const now = this.now();
1288
+ const row = this.db.prepare(`SELECT * FROM coordinator_mailbox_entries
1289
+ WHERE work_item_id = ? AND json_extract(payload, '$.turnId') = ?
1290
+ AND (status = 'pending' OR (status = 'claimed' AND lease_expires_at <= ?))`).get(
1291
+ workItemId, turnId, now,
1292
+ );
1293
+ if (!row) return null;
1294
+ const changed = this.db.prepare(`UPDATE coordinator_mailbox_entries SET status = 'claimed',
1295
+ claim_owner = ?, claim_epoch = claim_epoch + 1, claimed_at = ?, lease_expires_at = ?,
1296
+ updated_at = ? WHERE id = ? AND claim_epoch = ?
1297
+ AND (status = 'pending' OR (status = 'claimed' AND lease_expires_at <= ?))`).run(
1298
+ owner, now, now + leaseMs, now, row.id, row.claim_epoch, now,
1299
+ );
1300
+ if (Number(changed.changes) !== 1) return null;
1301
+ const claimed = this.db.prepare('SELECT * FROM coordinator_mailbox_entries WHERE id = ?').get(row.id);
1302
+ const providerChanged = this.db.prepare(`UPDATE coordinator_provider_turns SET claim_owner = ?,
1303
+ claim_epoch = ?, updated_at = ? WHERE coordinator_turn_id = ?
1304
+ AND status IN ('prepared', 'responded')`).run(
1305
+ owner, claimed.claim_epoch, now, turnId,
1306
+ );
1307
+ const providerCount = Number(this.db.prepare(`SELECT COUNT(*) AS count FROM coordinator_provider_turns
1308
+ WHERE coordinator_turn_id = ? AND status IN ('prepared', 'responded')`).get(turnId)?.count) || 0;
1309
+ if (providerCount !== Number(providerChanged.changes)) {
1310
+ throw new Error('Coordinator provider turn cannot transfer an in-flight dispatch');
1311
+ }
1312
+ return {
1313
+ mailboxId: claimed.id,
1314
+ ownerBootId: owner,
1315
+ claimEpoch: Number(claimed.claim_epoch),
1316
+ leaseExpiresAt: Number(claimed.lease_expires_at),
1317
+ payload: parseJson(claimed.payload, {}),
1318
+ };
1319
+ });
1320
+ }
1321
+
1285
1322
  claimCoordinatorMailbox(workItemId, owner, leaseMs = 60_000) {
1286
1323
  return withTransaction(this.db, () => {
1287
1324
  const now = this.now();
@@ -1301,6 +1338,14 @@ export class WorkItemStore {
1301
1338
  });
1302
1339
  }
1303
1340
 
1341
+ renewCoordinatorMailbox(id, owner, claimEpoch, leaseMs = 60_000) {
1342
+ const now = this.now();
1343
+ const changed = this.db.prepare(`UPDATE coordinator_mailbox_entries SET lease_expires_at = ?,
1344
+ updated_at = ? WHERE id = ? AND status = 'claimed' AND claim_owner = ? AND claim_epoch = ?
1345
+ AND lease_expires_at > ?`).run(now + leaseMs, now, id, owner, claimEpoch, now);
1346
+ return Number(changed.changes) === 1;
1347
+ }
1348
+
1304
1349
  ackCoordinatorMailbox(id, owner, claimEpoch) {
1305
1350
  const now = this.now();
1306
1351
  const changed = this.db.prepare(`UPDATE coordinator_mailbox_entries SET status = 'acked',
@@ -1316,8 +1361,10 @@ export class WorkItemStore {
1316
1361
  WHERE status = 'claimed' AND lease_expires_at <= ?`).run(now, now).changes);
1317
1362
  }
1318
1363
 
1319
- prepareCoordinatorProviderTurn(workItemId, coordinatorTurnId, attemptNumber, requestBody) {
1364
+ prepareCoordinatorProviderTurn(workItemId, coordinatorTurnId, attemptNumber, requestBody, claim = {}) {
1320
1365
  return withTransaction(this.db, () => {
1366
+ const mailbox = this.#activeCoordinatorMailboxClaim(coordinatorTurnId, claim);
1367
+ if (!mailbox || mailbox.work_item_id !== workItemId) return null;
1321
1368
  const existing = this.db.prepare(`SELECT * FROM coordinator_provider_turns
1322
1369
  WHERE coordinator_turn_id = ? AND attempt_number = ?`).get(coordinatorTurnId, attemptNumber);
1323
1370
  const requestHash = createHash('sha256').update(stableJson(requestBody), 'utf8').digest('hex');
@@ -1325,20 +1372,32 @@ export class WorkItemStore {
1325
1372
  if (existing.request_hash !== requestHash) {
1326
1373
  throw new Error('Prepared Coordinator provider request changed before dispatch');
1327
1374
  }
1375
+ if (existing.claim_owner !== claim.ownerBootId
1376
+ || Number(existing.claim_epoch) !== Number(claim.claimEpoch)) return null;
1328
1377
  return this.#mapCoordinatorProviderTurn(existing);
1329
1378
  }
1330
1379
  const now = this.now();
1331
1380
  const id = durableId('coordinator-provider-turn');
1332
1381
  this.db.prepare(`INSERT INTO coordinator_provider_turns
1333
1382
  (id, work_item_id, coordinator_turn_id, attempt_number, status, request_body, request_hash,
1334
- prepared_at, updated_at) VALUES (?, ?, ?, ?, 'prepared', ?, ?, ?, ?)`).run(
1335
- id, workItemId, coordinatorTurnId, attemptNumber,
1336
- stringify(requestBody), requestHash, now, now,
1383
+ claim_owner, claim_epoch, prepared_at, updated_at)
1384
+ VALUES (?, ?, ?, ?, 'prepared', ?, ?, ?, ?, ?, ?)`).run(
1385
+ id, workItemId, coordinatorTurnId, attemptNumber, stringify(requestBody), requestHash,
1386
+ claim.ownerBootId, claim.claimEpoch, now, now,
1337
1387
  );
1338
1388
  return this.getCoordinatorProviderTurn(id);
1339
1389
  });
1340
1390
  }
1341
1391
 
1392
+ #activeCoordinatorMailboxClaim(coordinatorTurnId, claim = {}) {
1393
+ if (!claim.mailboxId || !claim.ownerBootId || !Number.isInteger(Number(claim.claimEpoch))) return null;
1394
+ return this.db.prepare(`SELECT * FROM coordinator_mailbox_entries WHERE id = ? AND status = 'claimed'
1395
+ AND claim_owner = ? AND claim_epoch = ? AND lease_expires_at > ?
1396
+ AND json_extract(payload, '$.turnId') = ?`).get(
1397
+ claim.mailboxId, claim.ownerBootId, Number(claim.claimEpoch), this.now(), coordinatorTurnId,
1398
+ );
1399
+ }
1400
+
1342
1401
  #mapCoordinatorProviderTurn(row) {
1343
1402
  return {
1344
1403
  id: row.id,
@@ -1351,6 +1410,8 @@ export class WorkItemStore {
1351
1410
  response: parseJson(row.response, null),
1352
1411
  responseHash: row.response_hash || null,
1353
1412
  error: row.error || null,
1413
+ claimOwner: row.claim_owner || null,
1414
+ claimEpoch: Number(row.claim_epoch) || 0,
1354
1415
  };
1355
1416
  }
1356
1417
 
@@ -1359,31 +1420,63 @@ export class WorkItemStore {
1359
1420
  return row ? this.#mapCoordinatorProviderTurn(row) : null;
1360
1421
  }
1361
1422
 
1362
- dispatchCoordinatorProviderTurn(id) {
1363
- const existing = this.getCoordinatorProviderTurn(id);
1364
- if (existing?.status === 'dispatching') return existing;
1365
- const now = this.now();
1366
- const changed = this.db.prepare(`UPDATE coordinator_provider_turns SET status = 'dispatching',
1367
- dispatched_at = ?, updated_at = ? WHERE id = ? AND status = 'prepared'`).run(now, now, id);
1368
- return Number(changed.changes) === 1 ? this.getCoordinatorProviderTurn(id) : null;
1423
+ dispatchCoordinatorProviderTurn(id, claim = {}) {
1424
+ return withTransaction(this.db, () => {
1425
+ const existing = this.getCoordinatorProviderTurn(id);
1426
+ if (!existing || !this.#activeCoordinatorMailboxClaim(existing.coordinatorTurnId, claim)) return null;
1427
+ const now = this.now();
1428
+ const changed = this.db.prepare(`UPDATE coordinator_provider_turns SET status = 'dispatching',
1429
+ dispatched_at = ?, updated_at = ? WHERE id = ? AND status = 'prepared'
1430
+ AND claim_owner = ? AND claim_epoch = ?`).run(
1431
+ now, now, id, claim.ownerBootId, Number(claim.claimEpoch),
1432
+ );
1433
+ return Number(changed.changes) === 1 ? this.getCoordinatorProviderTurn(id) : null;
1434
+ });
1369
1435
  }
1370
1436
 
1371
- respondCoordinatorProviderTurn(id, requestHash, response) {
1372
- const now = this.now();
1373
- const responseHash = createHash('sha256').update(stableJson(response), 'utf8').digest('hex');
1374
- const changed = this.db.prepare(`UPDATE coordinator_provider_turns SET status = 'responded',
1375
- response = ?, response_hash = ?, responded_at = ?, updated_at = ?
1376
- WHERE id = ? AND status = 'dispatching' AND request_hash = ?`).run(
1377
- stringify(response), responseHash, now, now, id, requestHash,
1378
- );
1379
- return Number(changed.changes) === 1 ? this.getCoordinatorProviderTurn(id) : null;
1437
+ respondCoordinatorProviderTurn(id, requestHash, response, claim = {}) {
1438
+ return withTransaction(this.db, () => {
1439
+ const existing = this.getCoordinatorProviderTurn(id);
1440
+ if (!existing || !this.#activeCoordinatorMailboxClaim(existing.coordinatorTurnId, claim)) return null;
1441
+ const now = this.now();
1442
+ const responseHash = createHash('sha256').update(stableJson(response), 'utf8').digest('hex');
1443
+ const changed = this.db.prepare(`UPDATE coordinator_provider_turns SET status = 'responded',
1444
+ response = ?, response_hash = ?, responded_at = ?, updated_at = ?
1445
+ WHERE id = ? AND status = 'dispatching' AND request_hash = ?
1446
+ AND claim_owner = ? AND claim_epoch = ?`).run(
1447
+ stringify(response), responseHash, now, now, id, requestHash,
1448
+ claim.ownerBootId, Number(claim.claimEpoch),
1449
+ );
1450
+ return Number(changed.changes) === 1 ? this.getCoordinatorProviderTurn(id) : null;
1451
+ });
1452
+ }
1453
+
1454
+ rejectCoordinatorProviderTurn(id, error, claim = {}) {
1455
+ return withTransaction(this.db, () => {
1456
+ const existing = this.getCoordinatorProviderTurn(id);
1457
+ if (!existing || !this.#activeCoordinatorMailboxClaim(existing.coordinatorTurnId, claim)) return null;
1458
+ const now = this.now();
1459
+ const changed = this.db.prepare(`UPDATE coordinator_provider_turns SET status = 'cancelled',
1460
+ error = ?, updated_at = ? WHERE id = ? AND status = 'responded'
1461
+ AND claim_owner = ? AND claim_epoch = ?`).run(
1462
+ String(error?.message || error || 'Coordinator provider response was rejected').slice(0, 8_000),
1463
+ now, id, claim.ownerBootId, Number(claim.claimEpoch),
1464
+ );
1465
+ return Number(changed.changes) === 1 ? this.getCoordinatorProviderTurn(id) : null;
1466
+ });
1380
1467
  }
1381
1468
 
1382
1469
  recoverCoordinatorProviderTurns() {
1383
1470
  const now = this.now();
1384
1471
  return Number(this.db.prepare(`UPDATE coordinator_provider_turns SET status = 'unknown',
1385
1472
  error = 'Coordinator provider dispatch outcome is unknown after Agent restart', updated_at = ?
1386
- WHERE status = 'dispatching'`).run(now).changes);
1473
+ WHERE status = 'dispatching' AND NOT EXISTS (
1474
+ SELECT 1 FROM coordinator_mailbox_entries mailbox
1475
+ WHERE json_extract(mailbox.payload, '$.turnId') = coordinator_provider_turns.coordinator_turn_id
1476
+ AND mailbox.status = 'claimed' AND mailbox.claim_owner = coordinator_provider_turns.claim_owner
1477
+ AND mailbox.claim_epoch = coordinator_provider_turns.claim_epoch
1478
+ AND mailbox.lease_expires_at > ?
1479
+ )`).run(now, now).changes);
1387
1480
  }
1388
1481
 
1389
1482
  createOperation(input = {}) {
@@ -1467,16 +1560,42 @@ export class WorkItemStore {
1467
1560
  return rows.some(row => !this.operationSafeToProceed(row.idempotency_key));
1468
1561
  }
1469
1562
 
1563
+ createAndClaimOperation(input = {}, ownerBootId, leaseEpoch, automatic = true) {
1564
+ return withTransaction(this.db, () => {
1565
+ const active = this.#activeRunRow(input.runId, ownerBootId, leaseEpoch, true);
1566
+ if (!active || active.work_item_id !== input.workItemId || active.action_id !== input.actionId) return null;
1567
+ const operation = this.createOperation(input);
1568
+ if (!operation
1569
+ || operation.workItemId !== input.workItemId
1570
+ || operation.actionId !== input.actionId
1571
+ || operation.runId !== input.runId
1572
+ || operation.operationType !== (input.operationType || 'unknown')
1573
+ || operation.executionStatus !== 'not_started') return null;
1574
+ if (automatic && operation.replayPolicy !== 'safe') return null;
1575
+ const now = this.now();
1576
+ const changed = this.db.prepare(`UPDATE operations SET execution_status = 'running',
1577
+ execution_epoch = execution_epoch + 1, owner_boot_id = ?, owner_lease_epoch = ?,
1578
+ claimed_at = ?, updated_at = ? WHERE idempotency_key = ? AND execution_status = 'not_started'
1579
+ AND work_item_id = ? AND action_id = ? AND run_id = ?`).run(
1580
+ ownerBootId, leaseEpoch, now, now, input.idempotencyKey,
1581
+ input.workItemId, input.actionId, input.runId,
1582
+ );
1583
+ return Number(changed.changes) === 1 ? this.getOperationByKey(input.idempotencyKey) : null;
1584
+ });
1585
+ }
1586
+
1470
1587
  claimOperation(idempotencyKey, ownerBootId, leaseEpoch, automatic = true) {
1471
1588
  return withTransaction(this.db, () => {
1472
1589
  const operation = this.getOperationByKey(idempotencyKey);
1473
1590
  if (!operation || operation.executionStatus !== 'not_started') return null;
1591
+ if (!this.#activeRunRow(operation.runId, ownerBootId, leaseEpoch, true)) return null;
1474
1592
  if (automatic && operation.replayPolicy !== 'safe') return null;
1475
1593
  const now = this.now();
1476
1594
  const changed = this.db.prepare(`UPDATE operations SET execution_status = 'running',
1477
- owner_boot_id = ?, owner_lease_epoch = ?, claimed_at = ?, updated_at = ?
1478
- WHERE idempotency_key = ? AND execution_status = 'not_started'`).run(
1479
- ownerBootId, leaseEpoch, now, now, idempotencyKey,
1595
+ execution_epoch = execution_epoch + 1, owner_boot_id = ?, owner_lease_epoch = ?,
1596
+ claimed_at = ?, updated_at = ? WHERE idempotency_key = ? AND execution_status = 'not_started'
1597
+ AND run_id = ?`).run(
1598
+ ownerBootId, leaseEpoch, now, now, idempotencyKey, operation.runId,
1480
1599
  );
1481
1600
  return Number(changed.changes) === 1 ? this.getOperationByKey(idempotencyKey) : null;
1482
1601
  });
@@ -1484,23 +1603,59 @@ export class WorkItemStore {
1484
1603
 
1485
1604
  completeOperation(idempotencyKey, ownerBootId, leaseEpoch, effectStatus, result = null) {
1486
1605
  if (!['applied', 'not_applied', 'failed_no_effect', 'unknown'].includes(effectStatus)) return false;
1487
- const now = this.now();
1488
- const changed = this.db.prepare(`UPDATE operations SET effect_status = ?, execution_status = ?,
1489
- effect_cutoff = ?, result = ?, completed_at = ?, updated_at = ? WHERE idempotency_key = ?
1490
- AND execution_status = 'running' AND owner_boot_id = ? AND owner_lease_epoch = ?`).run(
1491
- effectStatus,
1492
- 'quiescent',
1493
- stringify({ status: 'current', closureType: 'quiescent', closedAt: now }),
1494
- stringify(result), now, now, idempotencyKey, ownerBootId, leaseEpoch,
1495
- );
1496
- return Number(changed.changes) === 1;
1606
+ return withTransaction(this.db, () => {
1607
+ const operation = this.getOperationByKey(idempotencyKey);
1608
+ if (!operation || operation.executionStatus !== 'running'
1609
+ || operation.payload == null) return false;
1610
+ const ownerMatches = this.db.prepare(`SELECT 1 AS present FROM operations
1611
+ WHERE idempotency_key = ? AND execution_status = 'running'
1612
+ AND owner_boot_id = ? AND owner_lease_epoch = ?`).get(
1613
+ idempotencyKey, ownerBootId, leaseEpoch,
1614
+ );
1615
+ if (!ownerMatches) return false;
1616
+ const now = this.now();
1617
+ const active = this.#activeRunRow(operation.runId, ownerBootId, leaseEpoch, true);
1618
+ if (!active || active.work_item_id !== operation.workItemId
1619
+ || active.action_id !== operation.actionId) {
1620
+ this.db.prepare(`UPDATE operations SET effect_status = 'unknown',
1621
+ execution_status = 'hazardous_orphan', effect_cutoff = ?, result = ?, completed_at = ?,
1622
+ updated_at = ? WHERE idempotency_key = ? AND execution_status = 'running'
1623
+ AND owner_boot_id = ? AND owner_lease_epoch = ?`).run(
1624
+ stringify({ status: 'stale', closureType: 'late_completion', closedAt: now }),
1625
+ stringify({ attemptedEffectStatus: effectStatus, reportedResult: result }),
1626
+ now, now, idempotencyKey, ownerBootId, leaseEpoch,
1627
+ );
1628
+ return false;
1629
+ }
1630
+ const changed = this.db.prepare(`UPDATE operations SET effect_status = ?, execution_status = ?,
1631
+ effect_cutoff = ?, result = ?, completed_at = ?, updated_at = ? WHERE idempotency_key = ?
1632
+ AND execution_status = 'running' AND owner_boot_id = ? AND owner_lease_epoch = ?`).run(
1633
+ effectStatus,
1634
+ 'quiescent',
1635
+ stringify({ status: 'current', closureType: 'quiescent', closedAt: now }),
1636
+ stringify(result), now, now, idempotencyKey, ownerBootId, leaseEpoch,
1637
+ );
1638
+ return Number(changed.changes) === 1;
1639
+ });
1497
1640
  }
1498
1641
 
1499
1642
  recoverOperations() {
1500
- const now = this.now();
1501
- return Number(this.db.prepare(`UPDATE operations SET execution_status = 'hazardous_orphan',
1502
- effect_status = CASE WHEN effect_status = 'pending' THEN 'unknown' ELSE effect_status END,
1503
- updated_at = ? WHERE execution_status IN ('running', 'cancel_requested')`).run(now).changes);
1643
+ return withTransaction(this.db, () => {
1644
+ const now = this.now();
1645
+ const unstarted = this.db.prepare(`UPDATE operations SET effect_status = 'failed_no_effect',
1646
+ execution_status = 'quiescent', effect_cutoff = ?, result = ?, completed_at = ?, updated_at = ?
1647
+ WHERE effect_status = 'pending' AND execution_status = 'not_started'`).run(
1648
+ stringify({ status: 'current', closureType: 'recovered_before_dispatch', closedAt: now }),
1649
+ stringify({ recovered: true, reason: 'Operation was never claimed before restart' }),
1650
+ now, now,
1651
+ );
1652
+ const hazardous = this.db.prepare(`UPDATE operations SET execution_status = 'hazardous_orphan',
1653
+ effect_status = CASE WHEN effect_status = 'pending' THEN 'unknown' ELSE effect_status END,
1654
+ effect_cutoff = ?, updated_at = ? WHERE execution_status IN ('running', 'cancel_requested')`).run(
1655
+ stringify({ status: 'stale', closureType: 'restart_unknown', closedAt: now }), now,
1656
+ );
1657
+ return Number(unstarted.changes) + Number(hazardous.changes);
1658
+ });
1504
1659
  }
1505
1660
 
1506
1661
  appendEvent(workItemId, type, data = {}, refs = {}) {
@@ -1521,6 +1676,17 @@ export class WorkItemStore {
1521
1676
  return Number(result.lastInsertRowid);
1522
1677
  }
1523
1678
 
1679
+ getCoordinatorClientMessageReceipt(workItemId, clientMessageId) {
1680
+ if (typeof clientMessageId !== 'string' || !clientMessageId) return null;
1681
+ const sourceKey = `client:message:${workItemId}:${clientMessageId}`;
1682
+ const actionReceipt = this.db.prepare('SELECT action_id FROM action_entries WHERE source_key = ?')
1683
+ .get(sourceKey);
1684
+ if (actionReceipt) throw new Error('clientMessageId already belongs to an Action message');
1685
+ const row = this.db.prepare(`SELECT payload FROM coordinator_mailbox_entries
1686
+ WHERE work_item_id = ? AND source_key = ?`).get(workItemId, sourceKey);
1687
+ return row ? parseJson(row.payload, {}) : null;
1688
+ }
1689
+
1524
1690
  hasActionInputClientMessage(workItemId, actionId, clientMessageId) {
1525
1691
  if (typeof clientMessageId !== 'string' || !clientMessageId) return false;
1526
1692
  const sourceKey = `client:message:${workItemId}:${clientMessageId}`;
@@ -1651,6 +1817,7 @@ export class WorkItemStore {
1651
1817
  }
1652
1818
  const eventId = this.appendEvent(id, 'action.input_added', {
1653
1819
  inputId,
1820
+ clientMessageId,
1654
1821
  text: input,
1655
1822
  attachments: projectedAttachments,
1656
1823
  }, { actionId: action.id, runId: eventRunId, actionGeneration: eventGeneration });
@@ -2804,23 +2971,23 @@ export class WorkItemStore {
2804
2971
  getRecoverableCoordinatorTurns() {
2805
2972
  return this.db.prepare(`SELECT w.id AS work_item_id, c.payload FROM coordinator_mailbox_entries c
2806
2973
  JOIN work_items w ON w.id = c.work_item_id
2807
- WHERE c.status = 'pending'
2974
+ WHERE c.status IN ('pending', 'claimed')
2808
2975
  AND EXISTS (SELECT 1 FROM json_each(w.messages) message
2809
2976
  WHERE json_extract(message.value, '$.turnId') = json_extract(c.payload, '$.turnId')
2810
2977
  AND json_extract(message.value, '$.role') = 'assistant'
2811
2978
  AND json_extract(message.value, '$.status') = 'thinking')
2812
2979
  AND EXISTS (SELECT 1 FROM coordinator_provider_turns p
2813
2980
  WHERE p.coordinator_turn_id = json_extract(c.payload, '$.turnId')
2814
- AND p.status IN ('prepared', 'responded'))
2981
+ AND p.status IN ('prepared', 'dispatching', 'responded'))
2815
2982
  ORDER BY c.created_at`).all().map(row => ({
2816
2983
  workItemId: row.work_item_id,
2817
2984
  ...parseJson(row.payload, {}),
2818
2985
  }));
2819
2986
  }
2820
2987
 
2821
- resumeCoordinatorTurn(workItemId, turnId) {
2988
+ resumeCoordinatorTurn(workItemId, turnId, claim = {}) {
2822
2989
  const detail = this.getWorkItemDetail(workItemId);
2823
- if (!detail) return null;
2990
+ if (!detail || !this.#activeCoordinatorMailboxClaim(turnId, claim)) return null;
2824
2991
  const assistant = [...(detail.messages || [])].reverse().find(message => (
2825
2992
  message.turnId === turnId && message.role === 'assistant' && message.status === 'thinking'
2826
2993
  ));
@@ -2839,6 +3006,28 @@ export class WorkItemStore {
2839
3006
  (detail.actions || []).filter(action => !['completed', 'superseded', 'cancelled'].includes(action.status)),
2840
3007
  ),
2841
3008
  recovery: assistant.recovery ? { ...assistant.recovery } : null,
3009
+ claim: {
3010
+ mailboxId: claim.mailboxId,
3011
+ ownerBootId: claim.ownerBootId,
3012
+ claimEpoch: Number(claim.claimEpoch),
3013
+ },
3014
+ },
3015
+ };
3016
+ }
3017
+
3018
+ claimStartedCoordinatorTurn(started, ownerBootId, leaseMs = 60_000) {
3019
+ if (!started?.turnId || !started?.detail?.id) return null;
3020
+ const claim = this.claimCoordinatorTurn(started.detail.id, started.turnId, ownerBootId, leaseMs);
3021
+ if (!claim) return null;
3022
+ return {
3023
+ ...started,
3024
+ fence: {
3025
+ ...started.fence,
3026
+ claim: {
3027
+ mailboxId: claim.mailboxId,
3028
+ ownerBootId: claim.ownerBootId,
3029
+ claimEpoch: claim.claimEpoch,
3030
+ },
2842
3031
  },
2843
3032
  };
2844
3033
  }
@@ -2954,6 +3143,7 @@ export class WorkItemStore {
2954
3143
  if (Number(changed.changes) !== 1) throw new Error('Coordinator turn lost its revision fence');
2955
3144
  this.appendEvent(id, automaticRecovery ? 'coordinator.recovery_started' : 'coordinator.turn_started', {
2956
3145
  turnId,
3146
+ clientMessageId,
2957
3147
  status: 'thinking',
2958
3148
  coordinatorRevision,
2959
3149
  addedAttachmentCount: projectedAttachments.length,
@@ -2981,6 +3171,8 @@ export class WorkItemStore {
2981
3171
 
2982
3172
  completeCoordinatorTurn(turnId, result, expected = {}) {
2983
3173
  return withTransaction(this.db, () => {
3174
+ const claim = expected.claim || {};
3175
+ if (!this.#activeCoordinatorMailboxClaim(turnId, claim)) return null;
2984
3176
  const workItem = this.getWorkItem(expected.workItemId);
2985
3177
  if (!workItem) return null;
2986
3178
  if (workItem.revision !== expected.revision
@@ -3184,11 +3376,8 @@ export class WorkItemStore {
3184
3376
  messages[assistantIndex],
3185
3377
  `coordinator:turn:${turnId}:assistant`,
3186
3378
  );
3187
- const mailbox = this.db.prepare(`SELECT * FROM coordinator_mailbox_entries
3188
- WHERE json_extract(payload, '$.turnId') = ?`).get(turnId);
3189
- if (mailbox && mailbox.status !== 'acked') {
3190
- this.db.prepare(`UPDATE coordinator_mailbox_entries SET status = 'acked', acked_at = ?,
3191
- updated_at = ? WHERE id = ?`).run(now, now, mailbox.id);
3379
+ if (!this.ackCoordinatorMailbox(claim.mailboxId, claim.ownerBootId, claim.claimEpoch)) {
3380
+ throw new Error('Coordinator completion lost its mailbox claim');
3192
3381
  }
3193
3382
  const current = this.getWorkItem(workItem.id);
3194
3383
  const coordinatorRevision = current.coordinatorRevision + 1;
@@ -3220,6 +3409,11 @@ export class WorkItemStore {
3220
3409
 
3221
3410
  failCoordinatorTurn(turnId, error, expected = {}) {
3222
3411
  return withTransaction(this.db, () => {
3412
+ const claim = expected.claim || {};
3413
+ if (!this.#activeCoordinatorMailboxClaim(turnId, claim)) return null;
3414
+ const responded = this.db.prepare(`SELECT 1 AS present FROM coordinator_provider_turns
3415
+ WHERE coordinator_turn_id = ? AND status = 'responded'`).get(turnId);
3416
+ if (responded) return null;
3223
3417
  const workItem = this.getWorkItem(expected.workItemId);
3224
3418
  if (!workItem || workItem.coordinatorRevision !== expected.coordinatorRevision) return null;
3225
3419
  const messages = [...(workItem.messages || [])];
@@ -3237,12 +3431,7 @@ export class WorkItemStore {
3237
3431
  messages[index],
3238
3432
  `coordinator:turn:${turnId}:assistant`,
3239
3433
  );
3240
- const mailbox = this.db.prepare(`SELECT * FROM coordinator_mailbox_entries
3241
- WHERE json_extract(payload, '$.turnId') = ?`).get(turnId);
3242
- if (mailbox && mailbox.status !== 'acked') {
3243
- this.db.prepare(`UPDATE coordinator_mailbox_entries SET status = 'acked', acked_at = ?,
3244
- updated_at = ? WHERE id = ?`).run(now, now, mailbox.id);
3245
- }
3434
+ if (!this.ackCoordinatorMailbox(claim.mailboxId, claim.ownerBootId, claim.claimEpoch)) return null;
3246
3435
  const changed = this.db.prepare(`UPDATE work_items SET messages = ?, coordinator_revision = coordinator_revision + 1,
3247
3436
  updated_at = ? WHERE id = ? AND coordinator_revision = ?`).run(
3248
3437
  stringify(messages), now, workItem.id, workItem.coordinatorRevision,