@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.
- package/local-runtime/server/auth/login.js +2 -0
- package/local-runtime/server/db/connection.js +13 -0
- package/local-runtime/server/db/session-db.js +3 -0
- package/local-runtime/server/db/yeaft-session-db.js +9 -0
- package/local-runtime/server/handlers/agent-output.js +27 -0
- package/local-runtime/server/handlers/client-conversation.js +2 -2
- package/local-runtime/server/handlers/client-work-center.js +4 -3
- package/local-runtime/server/routes/auth-routes.js +8 -1
- package/local-runtime/server/routes/user-routes.js +1 -0
- package/local-runtime/server/session-catalog.js +4 -7
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +110 -95
- 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/engine.js +3 -0
- package/yeaft/sessions/session-crud.js +9 -6
- package/yeaft/sessions/session-store.js +1 -0
- package/yeaft/tools/file-edit.js +8 -6
- package/yeaft/tools/file-write.js +2 -2
- package/yeaft/tools/registry.js +28 -10
- package/yeaft/tools/types.js +4 -0
- package/yeaft/web-bridge.js +10 -2
- package/yeaft/work-center/coordinator.js +37 -10
- package/yeaft/work-center/durable-model.js +59 -3
- package/yeaft/work-center/projection.js +2 -0
- package/yeaft/work-center/runner.js +14 -5
- package/yeaft/work-center/service.js +59 -16
- 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({
|
|
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({
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
1335
|
-
|
|
1336
|
-
stringify(requestBody), requestHash,
|
|
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
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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'
|
|
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
|
-
|
|
1478
|
-
WHERE idempotency_key = ? AND execution_status = 'not_started'
|
|
1479
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
|
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
|
-
|
|
3188
|
-
|
|
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
|
-
|
|
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,
|