@yeaft/webchat-agent 1.0.255 → 1.0.257
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 +21 -16
- 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 +16 -8
- package/yeaft/work-center/bridge.js +5 -2
- package/yeaft/work-center/controller.js +24 -0
- package/yeaft/work-center/coordinator.js +17 -6
- package/yeaft/work-center/projection.js +5 -0
- package/yeaft/work-center/runner.js +16 -3
- package/yeaft/work-center/service.js +63 -0
- package/yeaft/work-center/store.js +140 -31
|
Binary file
|
package/package.json
CHANGED
|
@@ -8,23 +8,31 @@ export function eventMatchesActionGeneration(event, action) {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function currentActionInputEventIds(events, action) {
|
|
11
|
-
const
|
|
12
|
-
.filter(event => event?.
|
|
11
|
+
const actionEvents = (Array.isArray(events) ? events : [])
|
|
12
|
+
.filter(event => event?.actionId === action?.id);
|
|
13
|
+
const inputEvents = new Map(actionEvents
|
|
14
|
+
.filter(event => event.type === 'action.input_added')
|
|
13
15
|
.map(event => [String(event.id), event]));
|
|
14
16
|
const valid = new Set();
|
|
17
|
+
const superseded = new Set();
|
|
15
18
|
for (const event of inputEvents.values()) {
|
|
16
19
|
if (eventMatchesActionGeneration(event, action)) valid.add(String(event.id));
|
|
17
20
|
}
|
|
18
|
-
for (const event of
|
|
19
|
-
if (event?.type !== 'action.input_rebound' || event.actionId !== action?.id
|
|
20
|
-
|| !eventMatchesActionGeneration(event, action)) continue;
|
|
21
|
+
for (const event of actionEvents) {
|
|
21
22
|
const sourceEventIds = [
|
|
22
23
|
event.data?.sourceEventId,
|
|
23
24
|
...(Array.isArray(event.data?.sourceEventIds) ? event.data.sourceEventIds : []),
|
|
24
|
-
].filter(value => value != null);
|
|
25
|
+
].filter(value => value != null).map(String);
|
|
26
|
+
if (event.type === 'action.input_superseded') {
|
|
27
|
+
for (const eventId of sourceEventIds) {
|
|
28
|
+
superseded.add(eventId);
|
|
29
|
+
valid.delete(eventId);
|
|
30
|
+
}
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (event.type !== 'action.input_rebound' || !eventMatchesActionGeneration(event, action)) continue;
|
|
25
34
|
for (const eventId of sourceEventIds) {
|
|
26
|
-
|
|
27
|
-
if (inputEvents.has(key)) valid.add(key);
|
|
35
|
+
if (inputEvents.has(eventId) && !superseded.has(eventId)) valid.add(eventId);
|
|
28
36
|
}
|
|
29
37
|
}
|
|
30
38
|
return valid;
|
|
@@ -20,7 +20,7 @@ let shutdownPromise = null;
|
|
|
20
20
|
let serviceFactory = null;
|
|
21
21
|
|
|
22
22
|
const BROWSER_DETAIL_OPS = new Set([
|
|
23
|
-
'get', 'create', 'update', 'start', 'cancel', 'action_input', 'retry_action', 'guide', 'retry',
|
|
23
|
+
'get', 'create', 'update', 'start', 'cancel', 'resume', 'action_input', 'retry_action', 'guide', 'retry',
|
|
24
24
|
]);
|
|
25
25
|
const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_requests', 'get_action_request']);
|
|
26
26
|
// `files` is an internal server-to-Agent field. The browser relay rejects any
|
|
@@ -34,6 +34,7 @@ const BROWSER_FILE_FIELDS = Object.freeze({
|
|
|
34
34
|
],
|
|
35
35
|
action_input: ['id', 'text', 'actionId', 'revision', 'generation', 'files'],
|
|
36
36
|
retry_action: ['id', 'actionId', 'revision', 'generation'],
|
|
37
|
+
resume: ['id', 'revision'],
|
|
37
38
|
delete: ['id', 'revision'],
|
|
38
39
|
guide: ['id', 'guidance', 'actionId', 'revision', 'generation', 'files'],
|
|
39
40
|
get_action_messages: ['id', 'actionId', 'generation', 'cursor', 'limit'],
|
|
@@ -220,7 +221,9 @@ export async function handleWorkCenterRequest(msg) {
|
|
|
220
221
|
: (BROWSER_ACTION_DEBUG_OPS.has(op) ? browserFilePayload(op, msg.payload) : (msg.payload || {}));
|
|
221
222
|
data = await workCenter.handle(op, payload);
|
|
222
223
|
}
|
|
223
|
-
if (BROWSER_DETAIL_OPS.has(op)
|
|
224
|
+
if (BROWSER_DETAIL_OPS.has(op) && data?.accepted !== true) {
|
|
225
|
+
data = workCenter.projectBrowserDetail(data);
|
|
226
|
+
}
|
|
224
227
|
send({
|
|
225
228
|
type: 'work_center_response',
|
|
226
229
|
requestId,
|
|
@@ -173,6 +173,30 @@ export class WorkflowController {
|
|
|
173
173
|
return this.store.getWorkItemDetail(id);
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
resume(id, input = {}) {
|
|
177
|
+
const revision = Number(input.revision);
|
|
178
|
+
if (!Number.isInteger(revision) || revision < 1) {
|
|
179
|
+
throw new Error('revision is required to resume a WorkItem');
|
|
180
|
+
}
|
|
181
|
+
const detail = this.store.resumeWorkItemAtomic(id, revision, workItem => {
|
|
182
|
+
const action = initialActionFor(workItem);
|
|
183
|
+
if (workItem.reuseMemory === false) return action;
|
|
184
|
+
const context = this.store.getReusableContext(workItem.workDir, workItem.id);
|
|
185
|
+
return {
|
|
186
|
+
...action,
|
|
187
|
+
context,
|
|
188
|
+
instruction: actionInstruction(
|
|
189
|
+
action,
|
|
190
|
+
workItem,
|
|
191
|
+
context,
|
|
192
|
+
renderSessionContextSnapshot(workItem.sessionContext),
|
|
193
|
+
),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
if (!detail) throw new Error(`WorkItem not found: ${id}`);
|
|
197
|
+
return detail;
|
|
198
|
+
}
|
|
199
|
+
|
|
176
200
|
guide(id, input = {}) {
|
|
177
201
|
const guidance = typeof input.guidance === 'string' ? input.guidance.trim().slice(0, 8_000) : '';
|
|
178
202
|
const addedAttachmentCount = Math.max(0, Number(input.addedAttachmentCount) || 0);
|
|
@@ -16,6 +16,7 @@ const COORDINATOR_MAX_REPLY_CHARS = 8_000;
|
|
|
16
16
|
const COORDINATOR_MAX_INSTRUCTION_CHARS = 8_000;
|
|
17
17
|
const COORDINATOR_MAX_OUTPUT_TOKENS = 8_192;
|
|
18
18
|
const COORDINATOR_MAX_SNAPSHOT_BYTES = 64 * 1024;
|
|
19
|
+
const COORDINATOR_DECISION_ATTEMPTS = 2;
|
|
19
20
|
const COORDINATOR_RECOVERY_DECISION_ATTEMPTS = 2;
|
|
20
21
|
const COORDINATOR_MAX_CONVERSATION_MESSAGES = 20;
|
|
21
22
|
const COORDINATOR_MAX_ACTIONS = 64;
|
|
@@ -302,7 +303,9 @@ export function normalizeCoordinatorResponse(value, detail, options = {}) {
|
|
|
302
303
|
: {};
|
|
303
304
|
const allowedKinds = options.recovery === true
|
|
304
305
|
? ['guide_actions', 'replan', 'request_human']
|
|
305
|
-
:
|
|
306
|
+
: options.controlRequired === true
|
|
307
|
+
? ['guide_actions', 'replan']
|
|
308
|
+
: ['answer', 'guide_actions', 'replan'];
|
|
306
309
|
const kind = allowedKinds.includes(source.kind) ? source.kind : '';
|
|
307
310
|
if (!kind) throw new Error('Work Center Coordinator decision kind is invalid');
|
|
308
311
|
const reason = cleanText(source.reason, 2_000, 'decision reason');
|
|
@@ -494,12 +497,15 @@ export class WorkItemCoordinator {
|
|
|
494
497
|
}, {
|
|
495
498
|
attachments: input.attachments,
|
|
496
499
|
addedAttachments,
|
|
500
|
+
recovery: input.recovery,
|
|
501
|
+
requireWaitingRecovery: input.controlRequired === true,
|
|
497
502
|
});
|
|
498
503
|
if (!started) throw new Error(`WorkItem not found: ${id}`);
|
|
499
504
|
options.onUpdate?.('coordinator.turn_started', started.detail);
|
|
500
505
|
return this.#scheduleTurn(started, {
|
|
501
506
|
text: promptText,
|
|
502
507
|
recovery: false,
|
|
508
|
+
controlRequired: input.controlRequired === true,
|
|
503
509
|
addedAttachments,
|
|
504
510
|
options,
|
|
505
511
|
});
|
|
@@ -535,15 +541,17 @@ export class WorkItemCoordinator {
|
|
|
535
541
|
const text = `Action stage "${action.stageId}" failed. Decide the next safe control transition. `
|
|
536
542
|
+ 'Failure is not a terminal WorkItem state: guide or replan executable work whenever possible. '
|
|
537
543
|
+ 'Request human input only when the snapshot lacks information required for a safe decision.';
|
|
538
|
-
return this.#scheduleTurn(started, { text, recovery: true, options });
|
|
544
|
+
return this.#scheduleTurn(started, { text, recovery: true, controlRequired: false, options });
|
|
539
545
|
}
|
|
540
546
|
|
|
541
|
-
#scheduleTurn(started, {
|
|
547
|
+
#scheduleTurn(started, {
|
|
548
|
+
text, recovery, controlRequired = false, addedAttachments = [], options,
|
|
549
|
+
}) {
|
|
542
550
|
const abortController = new AbortController();
|
|
543
551
|
this.activeTurns.set(started.turnId, abortController);
|
|
544
552
|
const task = new Promise(resolve => setTimeout(resolve, 0))
|
|
545
553
|
.then(() => this.#executeTurn(started, {
|
|
546
|
-
text, recovery, addedAttachments, options, abortController,
|
|
554
|
+
text, recovery, controlRequired, addedAttachments, options, abortController,
|
|
547
555
|
}))
|
|
548
556
|
.finally(() => {
|
|
549
557
|
this.activeTurns.delete(started.turnId);
|
|
@@ -554,7 +562,7 @@ export class WorkItemCoordinator {
|
|
|
554
562
|
}
|
|
555
563
|
|
|
556
564
|
async #executeTurn(started, {
|
|
557
|
-
text, recovery, addedAttachments, options, abortController,
|
|
565
|
+
text, recovery, controlRequired, addedAttachments, options, abortController,
|
|
558
566
|
}) {
|
|
559
567
|
try {
|
|
560
568
|
let normalized = null;
|
|
@@ -605,7 +613,9 @@ export class WorkItemCoordinator {
|
|
|
605
613
|
} catch (error) {
|
|
606
614
|
throw coordinatorExecutionError(error, 'selection');
|
|
607
615
|
}
|
|
608
|
-
const maxAttempts = recovery
|
|
616
|
+
const maxAttempts = recovery
|
|
617
|
+
? COORDINATOR_RECOVERY_DECISION_ATTEMPTS
|
|
618
|
+
: COORDINATOR_DECISION_ATTEMPTS;
|
|
609
619
|
for (let index = 0; index < maxAttempts; index += 1) {
|
|
610
620
|
attemptCount = index + 1;
|
|
611
621
|
mutation = null;
|
|
@@ -645,6 +655,7 @@ export class WorkItemCoordinator {
|
|
|
645
655
|
}
|
|
646
656
|
normalized = normalizeCoordinatorResponse(result?.text, started.detail, {
|
|
647
657
|
recovery,
|
|
658
|
+
controlRequired,
|
|
648
659
|
recoveryActionId: started.fence.recovery?.actionId || null,
|
|
649
660
|
});
|
|
650
661
|
if (normalized.decision.kind === 'replan') {
|
|
@@ -901,6 +901,11 @@ export function projectWorkItemDetail(detail, options = {}) {
|
|
|
901
901
|
affectedActionIds: Array.isArray(message.decision.affectedActionIds)
|
|
902
902
|
? message.decision.affectedActionIds.map(id => String(id)).slice(0, 8) : [],
|
|
903
903
|
} : null,
|
|
904
|
+
recovery: message.recovery && typeof message.recovery === 'object' ? {
|
|
905
|
+
actionId: truncateUtf8(String(message.recovery.actionId || ''), 256),
|
|
906
|
+
actionGeneration: Math.max(0, count(message.recovery.actionGeneration)),
|
|
907
|
+
stageId: truncateUtf8(String(message.recovery.stageId || ''), 256),
|
|
908
|
+
} : null,
|
|
904
909
|
createdAt: count(message.createdAt),
|
|
905
910
|
updatedAt: count(message.updatedAt || message.createdAt),
|
|
906
911
|
})),
|
|
@@ -32,6 +32,7 @@ import { MCPManager } from '../mcp.js';
|
|
|
32
32
|
import { buildMcpFlattenedTools } from '../tools/mcp-tools.js';
|
|
33
33
|
import { recallWorkspaceSessionContext } from './workspace-context.js';
|
|
34
34
|
import { applyGeneratedPlan, BUILT_IN_ACTION_TYPES } from './workflow.js';
|
|
35
|
+
import { applyAdditivePlanProposal } from './plan-mutation.js';
|
|
35
36
|
import { normalizeContractPatch, validateCompletedResult } from './completion-contract.js';
|
|
36
37
|
import { normalizeEvidence } from './evidence.js';
|
|
37
38
|
import {
|
|
@@ -395,13 +396,18 @@ function plannedActionSchema(vpIds, { requireCandidates = true } = {}) {
|
|
|
395
396
|
} };
|
|
396
397
|
}
|
|
397
398
|
|
|
398
|
-
export function createProposeWorkItemActionsTool({
|
|
399
|
+
export function createProposeWorkItemActionsTool({
|
|
400
|
+
vps, workItem, actions, collector, isRunActive, currentAction = null,
|
|
401
|
+
}) {
|
|
399
402
|
const vpCatalog = planningVpCatalog(vps);
|
|
400
403
|
const vpIds = vpCatalog.map(vp => vp.id);
|
|
401
404
|
const existing = actions.filter(action => !['superseded', 'cancelled'].includes(action.status));
|
|
405
|
+
const currentIdentity = currentAction
|
|
406
|
+
? ` Current Action: stageId=${currentAction.stageId}; internalActionId=${currentAction.id}. Its graph references must use stageId.`
|
|
407
|
+
: '';
|
|
402
408
|
return defineTool({
|
|
403
409
|
name: 'ProposeWorkItemActions',
|
|
404
|
-
description: `Propose an additive change to the current WorkItem DAG. It is applied only if this Action completes and its Run lease plus basePlanRevision remain valid. Existing Actions: ${existing.map(action =>
|
|
410
|
+
description: `Propose an additive change to the current WorkItem DAG. It is applied only if this Action completes and its Run lease plus basePlanRevision remain valid. Use stable stageId values in dependsOnActionIds, changesRequestedActionId, and dependencyPatches[].addDependsOnActionIds. The only internal id field is dependencyPatches[].actionId, which must use the displayed internalActionId of an eligible ready attempt=0 target.${currentIdentity} Existing Actions: ${existing.map(action => `stageId=${action.stageId} (internalActionId=${action.id}, ${action.status}, attempt ${action.attempt})`).join('; ')}. Available VPs: ${vpCatalog.map(vp => `${vp.id} (${vp.role || vp.area || 'VP'})`).join('; ')}. Only add new Actions and optionally add dependencies to attempt=0 ready Actions. This tool validates the complete additive DAG immediately; if validation fails, correct the proposal in the same turn.`,
|
|
405
411
|
parameters: { type: 'object', additionalProperties: false,
|
|
406
412
|
required: ['summary', 'evidence', 'acceptanceChecks', 'proposalId', 'basePlanRevision', 'actions'],
|
|
407
413
|
properties: {
|
|
@@ -413,6 +419,13 @@ export function createProposeWorkItemActionsTool({ vps, workItem, actions, colle
|
|
|
413
419
|
async execute(input, ctx = {}) {
|
|
414
420
|
if (!isRunActive()) throw new Error('Work Center Run is no longer active');
|
|
415
421
|
if (collector.value) throw new Error('A WorkItem plan mutation was already submitted for this Run');
|
|
422
|
+
applyAdditivePlanProposal({
|
|
423
|
+
workItem,
|
|
424
|
+
actions,
|
|
425
|
+
proposal: input,
|
|
426
|
+
availableVpIds: vpIds,
|
|
427
|
+
});
|
|
428
|
+
if (!isRunActive()) throw new Error('Work Center Run is no longer active');
|
|
416
429
|
collector.value = { kind: 'expand', input: structuredClone(input) };
|
|
417
430
|
ctx.requestEndTurn?.({ kind: 'work_item_actions_proposed', proposalId: input.proposalId });
|
|
418
431
|
return JSON.stringify({ submitted: true, proposalId: input.proposalId, actionCount: input.actions.length });
|
|
@@ -981,7 +994,7 @@ export class WorkItemRunner {
|
|
|
981
994
|
runTools.push(createProposeWorkItemActionsTool({
|
|
982
995
|
vps: this.registry.listVps(), workItem,
|
|
983
996
|
actions: this.store.getWorkItemDetail(workItem.id).actions,
|
|
984
|
-
collector: mutationCollector, isRunActive,
|
|
997
|
+
collector: mutationCollector, isRunActive, currentAction: executionAction,
|
|
985
998
|
}));
|
|
986
999
|
runTools.push(createRequestWorkItemReplanTool({ workItem, collector: mutationCollector, isRunActive }));
|
|
987
1000
|
}
|
|
@@ -60,6 +60,19 @@ function parseBoardCursor(value) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function coordinatorHumanRequest(detail, action, generation) {
|
|
64
|
+
if (detail?.status !== 'waiting'
|
|
65
|
+
|| action?.status !== 'waiting'
|
|
66
|
+
|| Number(action.generation) !== Number(generation)) return null;
|
|
67
|
+
return [...(Array.isArray(detail.messages) ? detail.messages : [])].reverse().find(message => (
|
|
68
|
+
message?.role === 'assistant'
|
|
69
|
+
&& message.status === 'completed'
|
|
70
|
+
&& message.decision?.kind === 'request_human'
|
|
71
|
+
&& message.recovery?.actionId === action.id
|
|
72
|
+
&& message.recovery?.actionGeneration === action.generation
|
|
73
|
+
)) || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
function listBoardItems(store, payload) {
|
|
64
77
|
const limit = Math.min(Math.max(Number(payload.limit) || 100, 1), 200);
|
|
65
78
|
const cursor = parseBoardCursor(payload.cursor);
|
|
@@ -294,6 +307,13 @@ export class WorkCenterService {
|
|
|
294
307
|
this.#emit({ type: 'work_item.cancelled', workItem: detail });
|
|
295
308
|
return detail;
|
|
296
309
|
}
|
|
310
|
+
case 'resume': {
|
|
311
|
+
const id = requiredString(payload.id, 'id');
|
|
312
|
+
const detail = this.controller.resume(id, { revision: payload.revision });
|
|
313
|
+
this.watcher.abortInvalidWorkItemRuns(id);
|
|
314
|
+
this.#emit({ type: 'work_item.resumed', workItem: detail });
|
|
315
|
+
return detail;
|
|
316
|
+
}
|
|
297
317
|
case 'delete': {
|
|
298
318
|
const id = requiredString(payload.id, 'id');
|
|
299
319
|
const deleted = this.store.deleteWorkItemAtomic(id, Number(payload.revision));
|
|
@@ -367,6 +387,49 @@ export class WorkCenterService {
|
|
|
367
387
|
throw new Error('generation must be a positive integer');
|
|
368
388
|
}
|
|
369
389
|
const workItem = this.#requiredItem(id);
|
|
390
|
+
const targetAction = this.#requiredAction(workItem, payload.actionId);
|
|
391
|
+
const humanRequest = coordinatorHumanRequest(workItem, targetAction, generation);
|
|
392
|
+
if (humanRequest) {
|
|
393
|
+
let addedAttachments = [];
|
|
394
|
+
let turn;
|
|
395
|
+
try {
|
|
396
|
+
addedAttachments = appendWorkItemAttachments(workItem.attachments, payload.files, {
|
|
397
|
+
root: this.attachmentRoot,
|
|
398
|
+
workItemId: id,
|
|
399
|
+
});
|
|
400
|
+
turn = this.coordinator.message(id, {
|
|
401
|
+
text: typeof payload.text === 'string' ? payload.text : '',
|
|
402
|
+
revision: payload.revision,
|
|
403
|
+
planRevision: workItem.planRevision,
|
|
404
|
+
ledgerRevision: workItem.ledgerRevision,
|
|
405
|
+
coordinatorRevision: workItem.coordinatorRevision,
|
|
406
|
+
controlRequired: true,
|
|
407
|
+
recovery: { ...humanRequest.recovery },
|
|
408
|
+
addedAttachments,
|
|
409
|
+
attachments: [...(workItem.attachments || []), ...addedAttachments],
|
|
410
|
+
}, {
|
|
411
|
+
onUpdate: (type, nextWorkItem) => {
|
|
412
|
+
this.watcher.abortInvalidWorkItemRuns(id);
|
|
413
|
+
this.#emit({ type, workItem: nextWorkItem });
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
} catch (error) {
|
|
417
|
+
try {
|
|
418
|
+
if ((workItem.attachments || []).length === 0 && addedAttachments.length > 0) {
|
|
419
|
+
removeWorkItemAttachments(this.attachmentRoot, id);
|
|
420
|
+
} else {
|
|
421
|
+
removeWorkItemAttachmentFiles(this.attachmentRoot, id, addedAttachments);
|
|
422
|
+
}
|
|
423
|
+
} catch {}
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
turn.task.catch(() => {});
|
|
427
|
+
return {
|
|
428
|
+
accepted: true,
|
|
429
|
+
routedTo: 'coordinator',
|
|
430
|
+
turnId: turn.detail.messages?.at(-1)?.turnId || null,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
370
433
|
let addedAttachments = [];
|
|
371
434
|
let detail;
|
|
372
435
|
try {
|
|
@@ -8,7 +8,7 @@ import { currentActionInputEventIds, runMatchesActionIdentity } from './action-i
|
|
|
8
8
|
import { canonicalActionInstruction, withoutActionInputContext } from './workflow.js';
|
|
9
9
|
|
|
10
10
|
const SCHEMA_VERSION = 22;
|
|
11
|
-
const
|
|
11
|
+
const UNFINISHED_ACTION_STATUSES = "'ready','running','waiting','failed'";
|
|
12
12
|
const MAX_REUSABLE_CONTEXT_ITEMS = 12;
|
|
13
13
|
const MAX_RUN_RESPONSE_CHARS = 65_536;
|
|
14
14
|
|
|
@@ -2207,27 +2207,33 @@ export class WorkItemStore {
|
|
|
2207
2207
|
let recovery = options.recovery && typeof options.recovery === 'object'
|
|
2208
2208
|
? { ...options.recovery } : null;
|
|
2209
2209
|
if (recovery) {
|
|
2210
|
-
const
|
|
2210
|
+
const recoveryAction = activeActions.find(action => action.id === recovery.actionId);
|
|
2211
|
+
const requiredStatus = options.requireWaitingRecovery === true ? 'waiting' : 'failed';
|
|
2211
2212
|
if (['done', 'cancelled'].includes(workItem.status)
|
|
2212
|
-
||
|
|
2213
|
-
||
|
|
2214
|
-
||
|
|
2215
|
-
throw new Error(
|
|
2213
|
+
|| recoveryAction?.status !== requiredStatus
|
|
2214
|
+
|| recoveryAction.generation !== recovery.actionGeneration
|
|
2215
|
+
|| recoveryAction.stageId !== recovery.stageId) {
|
|
2216
|
+
throw new Error(options.requireWaitingRecovery === true
|
|
2217
|
+
? 'WorkItem human input target changed before the Coordinator turn started'
|
|
2218
|
+
: 'WorkItem failure changed before Coordinator recovery started');
|
|
2219
|
+
}
|
|
2220
|
+
if (options.requireWaitingRecovery !== true) {
|
|
2221
|
+
const priorAttempts = Number(this.db.prepare(`SELECT COUNT(*) AS count FROM events
|
|
2222
|
+
WHERE work_item_id = ? AND type = 'coordinator.recovery_started'
|
|
2223
|
+
AND action_id = ? AND action_generation = ?`).get(
|
|
2224
|
+
id,
|
|
2225
|
+
recoveryAction.id,
|
|
2226
|
+
recoveryAction.generation,
|
|
2227
|
+
)?.count) || 0;
|
|
2228
|
+
recovery = { ...recovery, attempt: priorAttempts + 1 };
|
|
2216
2229
|
}
|
|
2217
|
-
const priorAttempts = Number(this.db.prepare(`SELECT COUNT(*) AS count FROM events
|
|
2218
|
-
WHERE work_item_id = ? AND type = 'coordinator.recovery_started'
|
|
2219
|
-
AND action_id = ? AND action_generation = ?`).get(
|
|
2220
|
-
id,
|
|
2221
|
-
failedAction.id,
|
|
2222
|
-
failedAction.generation,
|
|
2223
|
-
)?.count) || 0;
|
|
2224
|
-
recovery = { ...recovery, attempt: priorAttempts + 1 };
|
|
2225
2230
|
}
|
|
2226
|
-
|
|
2231
|
+
const automaticRecovery = recovery && options.requireWaitingRecovery !== true;
|
|
2232
|
+
if (!automaticRecovery && !String(text || '').trim() && projectedAttachments.length === 0) {
|
|
2227
2233
|
throw new Error('WorkItem Coordinator message or attachments are required');
|
|
2228
2234
|
}
|
|
2229
2235
|
const turnId = randomUUID();
|
|
2230
|
-
const userMessage =
|
|
2236
|
+
const userMessage = automaticRecovery ? null : {
|
|
2231
2237
|
id: randomUUID(), turnId, role: 'user', text, attachments: projectedAttachments,
|
|
2232
2238
|
status: 'completed', createdAt: now,
|
|
2233
2239
|
};
|
|
@@ -2250,7 +2256,7 @@ export class WorkItemStore {
|
|
|
2250
2256
|
id, workItem.coordinatorRevision, workItem.revision, workItem.planRevision, workItem.ledgerRevision,
|
|
2251
2257
|
);
|
|
2252
2258
|
if (Number(changed.changes) !== 1) throw new Error('Coordinator turn lost its revision fence');
|
|
2253
|
-
this.appendEvent(id,
|
|
2259
|
+
this.appendEvent(id, automaticRecovery ? 'coordinator.recovery_started' : 'coordinator.turn_started', {
|
|
2254
2260
|
turnId,
|
|
2255
2261
|
status: 'thinking',
|
|
2256
2262
|
coordinatorRevision,
|
|
@@ -2313,17 +2319,17 @@ export class WorkItemStore {
|
|
|
2313
2319
|
throw new Error('Coordinator replan requires an AI-planned Action graph');
|
|
2314
2320
|
}
|
|
2315
2321
|
if (recovery && decision.kind === 'guide_actions') {
|
|
2316
|
-
const
|
|
2322
|
+
const recoveryAction = activeActions.find(action => (
|
|
2317
2323
|
action.id === recovery.actionId
|
|
2318
2324
|
&& action.generation === recovery.actionGeneration
|
|
2319
2325
|
&& action.stageId === recovery.stageId
|
|
2320
|
-
&& action.status
|
|
2326
|
+
&& ['failed', 'waiting'].includes(action.status)
|
|
2321
2327
|
));
|
|
2322
|
-
if (!
|
|
2328
|
+
if (!recoveryAction
|
|
2323
2329
|
|| !Array.isArray(decision.guidance)
|
|
2324
2330
|
|| decision.guidance.length !== 1
|
|
2325
|
-
|| decision.guidance[0]?.stageId !==
|
|
2326
|
-
throw new Error('Coordinator recovery guidance must target only the
|
|
2331
|
+
|| decision.guidance[0]?.stageId !== recoveryAction.stageId) {
|
|
2332
|
+
throw new Error('Coordinator recovery guidance must target only the fenced Action identity');
|
|
2327
2333
|
}
|
|
2328
2334
|
}
|
|
2329
2335
|
let nextWorkItem = workItem;
|
|
@@ -2637,16 +2643,32 @@ export class WorkItemStore {
|
|
|
2637
2643
|
}
|
|
2638
2644
|
|
|
2639
2645
|
#invalidateExecution(workItem, actionStatus, runStatus, reason, now) {
|
|
2640
|
-
const
|
|
2641
|
-
AND status IN (${
|
|
2642
|
-
this.#assertNoIntegrationReservation(
|
|
2643
|
-
this.#supersedePendingActionInputs(
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2646
|
+
const unfinishedActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
|
|
2647
|
+
AND status IN (${UNFINISHED_ACTION_STATUSES})`).all(workItem.id).map(mapAction);
|
|
2648
|
+
this.#assertNoIntegrationReservation(unfinishedActions, now);
|
|
2649
|
+
this.#supersedePendingActionInputs(unfinishedActions, reason, now);
|
|
2650
|
+
const actionIds = unfinishedActions.map(action => action.id);
|
|
2651
|
+
if (actionIds.length === 0) return;
|
|
2652
|
+
const placeholders = actionIds.map(() => '?').join(',');
|
|
2653
|
+
this.db.prepare(`UPDATE runs SET status = ?, ended_at = ?, error = ?, accepting_input = 0
|
|
2654
|
+
WHERE work_item_id = ? AND action_id IN (${placeholders}) AND status = 'running'`).run(
|
|
2655
|
+
runStatus,
|
|
2656
|
+
now,
|
|
2657
|
+
reason,
|
|
2658
|
+
workItem.id,
|
|
2659
|
+
...actionIds,
|
|
2660
|
+
);
|
|
2661
|
+
const changed = this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL,
|
|
2662
|
+
lease_epoch = lease_epoch + 1, updated_at = ? WHERE work_item_id = ?
|
|
2663
|
+
AND id IN (${placeholders}) AND status IN (${UNFINISHED_ACTION_STATUSES})`).run(
|
|
2664
|
+
actionStatus,
|
|
2665
|
+
now,
|
|
2666
|
+
workItem.id,
|
|
2667
|
+
...actionIds,
|
|
2668
|
+
);
|
|
2669
|
+
if (Number(changed.changes) !== actionIds.length) {
|
|
2670
|
+
throw new Error('Work Center execution changed while it was invalidated');
|
|
2647
2671
|
}
|
|
2648
|
-
this.db.prepare(`UPDATE actions SET status = ?, current_run_id = NULL, updated_at = ?
|
|
2649
|
-
WHERE work_item_id = ? AND status IN (${OPEN_ACTION_STATUSES})`).run(actionStatus, now, workItem.id);
|
|
2650
2672
|
}
|
|
2651
2673
|
|
|
2652
2674
|
updateWorkItemAtomic(id, patch, makeInitialAction) {
|
|
@@ -2726,6 +2748,93 @@ export class WorkItemStore {
|
|
|
2726
2748
|
});
|
|
2727
2749
|
}
|
|
2728
2750
|
|
|
2751
|
+
resumeWorkItemAtomic(id, expectedRevision, makeInitialAction) {
|
|
2752
|
+
return withTransaction(this.db, () => {
|
|
2753
|
+
const workItem = this.getWorkItem(id);
|
|
2754
|
+
if (!workItem) return null;
|
|
2755
|
+
if (!Number.isInteger(expectedRevision) || workItem.revision !== expectedRevision) {
|
|
2756
|
+
throw new Error('WorkItem changed before it was resumed; refresh and try again');
|
|
2757
|
+
}
|
|
2758
|
+
if (workItem.status !== 'cancelled') {
|
|
2759
|
+
throw new Error(`WorkItem in ${workItem.status} cannot be resumed`);
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
const now = this.now();
|
|
2763
|
+
const cancelledActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
|
|
2764
|
+
AND status = 'cancelled' ORDER BY sequence`).all(id).map(mapAction);
|
|
2765
|
+
this.#assertNoIntegrationReservation(cancelledActions, now);
|
|
2766
|
+
this.#supersedePendingActionInputs(
|
|
2767
|
+
cancelledActions,
|
|
2768
|
+
'WorkItem resume started a new Action generation',
|
|
2769
|
+
now,
|
|
2770
|
+
);
|
|
2771
|
+
const resumedActions = [];
|
|
2772
|
+
for (const action of cancelledActions) {
|
|
2773
|
+
const context = carryCurrentActionInputContext(this.db, action);
|
|
2774
|
+
const candidate = {
|
|
2775
|
+
...action,
|
|
2776
|
+
context,
|
|
2777
|
+
status: 'ready',
|
|
2778
|
+
attempt: 0,
|
|
2779
|
+
currentRunId: null,
|
|
2780
|
+
resultRunId: null,
|
|
2781
|
+
workspace: null,
|
|
2782
|
+
generation: action.generation + 1,
|
|
2783
|
+
};
|
|
2784
|
+
candidate.instruction = canonicalActionInstruction(workItem, candidate, context);
|
|
2785
|
+
candidate.specHash = actionSpecHash(candidate);
|
|
2786
|
+
const changed = this.db.prepare(`UPDATE actions SET status = 'ready', attempt = 0,
|
|
2787
|
+
current_run_id = NULL, lease_epoch = lease_epoch + 1, generation = generation + 1,
|
|
2788
|
+
context = ?, instruction = ?, spec_hash = ?, identity_history = ?, result_run_id = NULL,
|
|
2789
|
+
workspace = NULL, updated_at = ? WHERE id = ? AND work_item_id = ?
|
|
2790
|
+
AND status = 'cancelled' AND generation = ? AND spec_hash = ?`).run(
|
|
2791
|
+
stringify(context),
|
|
2792
|
+
candidate.instruction,
|
|
2793
|
+
candidate.specHash,
|
|
2794
|
+
stringify(actionIdentityHistory(action, candidate.generation, candidate.specHash)),
|
|
2795
|
+
now,
|
|
2796
|
+
action.id,
|
|
2797
|
+
id,
|
|
2798
|
+
action.generation,
|
|
2799
|
+
action.specHash,
|
|
2800
|
+
);
|
|
2801
|
+
if (Number(changed.changes) !== 1) {
|
|
2802
|
+
throw new Error('WorkItem Action changed before it was resumed');
|
|
2803
|
+
}
|
|
2804
|
+
resumedActions.push(this.getAction(action.id));
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
if (resumedActions.length === 0) {
|
|
2808
|
+
const initialAction = makeInitialAction(workItem);
|
|
2809
|
+
resumedActions.push(this.#insertAction(id, {
|
|
2810
|
+
...initialAction,
|
|
2811
|
+
contractRevision: workItem.revision,
|
|
2812
|
+
}, this.#nextSequence(id), now));
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
const currentAction = resumedActions
|
|
2816
|
+
.find(action => action.dependsOnStageIds.length === 0) || resumedActions[0];
|
|
2817
|
+
const changedWorkItem = this.db.prepare(`UPDATE work_items SET status = 'ready',
|
|
2818
|
+
current_action_id = ?, current_run_id = NULL, updated_at = ?
|
|
2819
|
+
WHERE id = ? AND status = 'cancelled' AND revision = ?`).run(
|
|
2820
|
+
currentAction.id,
|
|
2821
|
+
now,
|
|
2822
|
+
id,
|
|
2823
|
+
expectedRevision,
|
|
2824
|
+
);
|
|
2825
|
+
if (Number(changedWorkItem.changes) !== 1) {
|
|
2826
|
+
throw new Error('WorkItem changed before it was resumed; refresh and try again');
|
|
2827
|
+
}
|
|
2828
|
+
this.appendEvent(id, 'work_item.resumed', {
|
|
2829
|
+
resumedActionIds: resumedActions.map(action => action.id),
|
|
2830
|
+
}, {
|
|
2831
|
+
actionId: currentAction.id,
|
|
2832
|
+
actionGeneration: currentAction.generation,
|
|
2833
|
+
});
|
|
2834
|
+
return this.getWorkItemDetail(id);
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2729
2838
|
startWorkItemAtomic(id, makeInitialAction) {
|
|
2730
2839
|
return withTransaction(this.db, () => {
|
|
2731
2840
|
const workItem = this.getWorkItem(id);
|