@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
|
Binary file
|
package/package.json
CHANGED
package/yeaft/engine.js
CHANGED
|
@@ -3595,6 +3595,7 @@ export class Engine {
|
|
|
3595
3595
|
let displayImages = [];
|
|
3596
3596
|
let isError = false;
|
|
3597
3597
|
let toolErrorOutput = null;
|
|
3598
|
+
let fatalToolError = null;
|
|
3598
3599
|
currentToolCallForAsyncTask = {
|
|
3599
3600
|
id: tc.id,
|
|
3600
3601
|
name: tc.name,
|
|
@@ -3668,6 +3669,7 @@ export class Engine {
|
|
|
3668
3669
|
output = `Error: ${err.message}`;
|
|
3669
3670
|
isError = true;
|
|
3670
3671
|
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
|
|
3672
|
+
if (err?.fatalToolTimeout === true) fatalToolError = err;
|
|
3671
3673
|
}
|
|
3672
3674
|
}
|
|
3673
3675
|
|
|
@@ -3756,6 +3758,7 @@ export class Engine {
|
|
|
3756
3758
|
isError,
|
|
3757
3759
|
}));
|
|
3758
3760
|
queryToolCount += 1;
|
|
3761
|
+
if (fatalToolError) throw fatalToolError;
|
|
3759
3762
|
}
|
|
3760
3763
|
|
|
3761
3764
|
// PR-L: flush any duplicate-call reminders queued during the batch.
|
|
@@ -541,7 +541,7 @@ export function renameSession(yeaftDir, sessionId, newName) {
|
|
|
541
541
|
if (!name) throw new SessionCrudError('invalid_name', sessionId);
|
|
542
542
|
const handle = requireSession(yeaftDir, sessionId);
|
|
543
543
|
const meta = handle.getMeta();
|
|
544
|
-
handle.saveMeta({ ...meta, name });
|
|
544
|
+
handle.saveMeta({ ...meta, name, metadataUpdatedAt: new Date().toISOString() });
|
|
545
545
|
const next = handle.getMeta();
|
|
546
546
|
handle.close();
|
|
547
547
|
return next;
|
|
@@ -561,7 +561,7 @@ export function updateSessionAnnouncement(yeaftDir, sessionId, text) {
|
|
|
561
561
|
const announcement = text.trim();
|
|
562
562
|
const handle = requireSession(yeaftDir, sessionId);
|
|
563
563
|
const meta = handle.getMeta();
|
|
564
|
-
handle.saveMeta({ ...meta, announcement });
|
|
564
|
+
handle.saveMeta({ ...meta, announcement, metadataUpdatedAt: new Date().toISOString() });
|
|
565
565
|
const next = handle.getMeta();
|
|
566
566
|
handle.close();
|
|
567
567
|
return next;
|
|
@@ -577,7 +577,10 @@ export function updateSessionAnnouncement(yeaftDir, sessionId, text) {
|
|
|
577
577
|
export function updateSessionConfig(yeaftDir, sessionId, partial) {
|
|
578
578
|
const handle = requireSession(yeaftDir, sessionId);
|
|
579
579
|
try {
|
|
580
|
-
|
|
580
|
+
const saved = saveSessionConfig(yeaftDir, sessionId, partial || {});
|
|
581
|
+
const meta = handle.getMeta();
|
|
582
|
+
handle.saveMeta({ ...meta, metadataUpdatedAt: new Date().toISOString() });
|
|
583
|
+
return saved;
|
|
581
584
|
} finally {
|
|
582
585
|
handle.close();
|
|
583
586
|
}
|
|
@@ -708,7 +711,7 @@ export function addMember(yeaftDir, sessionId, vpId) {
|
|
|
708
711
|
try {
|
|
709
712
|
const meta = handle.getMeta();
|
|
710
713
|
const next = rosterAdd(meta, vpId);
|
|
711
|
-
handle.saveMeta(next);
|
|
714
|
+
handle.saveMeta({ ...next, metadataUpdatedAt: new Date().toISOString() });
|
|
712
715
|
return handle.getMeta();
|
|
713
716
|
} finally {
|
|
714
717
|
handle.close();
|
|
@@ -728,7 +731,7 @@ export function removeMember(yeaftDir, sessionId, vpId) {
|
|
|
728
731
|
return meta;
|
|
729
732
|
}
|
|
730
733
|
const next = rosterRemove(meta, vpId);
|
|
731
|
-
handle.saveMeta(next);
|
|
734
|
+
handle.saveMeta({ ...next, metadataUpdatedAt: new Date().toISOString() });
|
|
732
735
|
return handle.getMeta();
|
|
733
736
|
} finally {
|
|
734
737
|
handle.close();
|
|
@@ -741,7 +744,7 @@ export function setSessionDefaultVp(yeaftDir, sessionId, vpId) {
|
|
|
741
744
|
try {
|
|
742
745
|
const meta = handle.getMeta();
|
|
743
746
|
const next = setDefaultVp(meta, vpId);
|
|
744
|
-
handle.saveMeta(next);
|
|
747
|
+
handle.saveMeta({ ...next, metadataUpdatedAt: new Date().toISOString() });
|
|
745
748
|
return handle.getMeta();
|
|
746
749
|
} finally {
|
|
747
750
|
handle.close();
|
|
@@ -145,6 +145,7 @@ export function createSession(sessionsRoot, spec) {
|
|
|
145
145
|
workDir: typeof spec.workDir === 'string' ? spec.workDir.trim() : '',
|
|
146
146
|
workspaceKey: typeof spec.workspaceKey === 'string' ? spec.workspaceKey.trim() : '',
|
|
147
147
|
createdAt: spec.createdAt || new Date().toISOString(),
|
|
148
|
+
metadataUpdatedAt: spec.metadataUpdatedAt || spec.createdAt || new Date().toISOString(),
|
|
148
149
|
};
|
|
149
150
|
h.saveMeta(meta);
|
|
150
151
|
return h;
|
package/yeaft/tools/file-edit.js
CHANGED
|
@@ -75,16 +75,16 @@ Guidelines:
|
|
|
75
75
|
isDestructive: () => false,
|
|
76
76
|
async execute(input, ctx) {
|
|
77
77
|
const { file_path, old_string, new_string, replace_all = false } = input;
|
|
78
|
-
if (!file_path) return JSON.stringify({ error: 'file_path is required' });
|
|
79
|
-
if (old_string === undefined) return JSON.stringify({ error: 'old_string is required' });
|
|
80
|
-
if (new_string === undefined) return JSON.stringify({ error: 'new_string is required' });
|
|
81
|
-
if (old_string === new_string) return JSON.stringify({ error: 'old_string and new_string are identical' });
|
|
78
|
+
if (!file_path) return JSON.stringify({ errorEffect: 'none', error: 'file_path is required' });
|
|
79
|
+
if (old_string === undefined) return JSON.stringify({ errorEffect: 'none', error: 'old_string is required' });
|
|
80
|
+
if (new_string === undefined) return JSON.stringify({ errorEffect: 'none', error: 'new_string is required' });
|
|
81
|
+
if (old_string === new_string) return JSON.stringify({ errorEffect: 'none', error: 'old_string and new_string are identical' });
|
|
82
82
|
|
|
83
83
|
const cwd = ctx?.cwd || process.cwd();
|
|
84
84
|
const absPath = resolve(cwd, file_path);
|
|
85
85
|
|
|
86
86
|
if (!existsSync(absPath)) {
|
|
87
|
-
return JSON.stringify({ error: `File not found: ${absPath}` });
|
|
87
|
+
return JSON.stringify({ errorEffect: 'none', error: `File not found: ${absPath}` });
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
try {
|
|
@@ -106,6 +106,7 @@ Guidelines:
|
|
|
106
106
|
? old_string.slice(0, 100) + '...'
|
|
107
107
|
: old_string;
|
|
108
108
|
return JSON.stringify({
|
|
109
|
+
errorEffect: 'none',
|
|
109
110
|
error: `old_string not found in file`,
|
|
110
111
|
hint: `The exact text "${preview}" was not found in ${absPath}. Check whitespace and indentation.`,
|
|
111
112
|
});
|
|
@@ -113,6 +114,7 @@ Guidelines:
|
|
|
113
114
|
|
|
114
115
|
if (count > 1 && !replace_all) {
|
|
115
116
|
return JSON.stringify({
|
|
117
|
+
errorEffect: 'none',
|
|
116
118
|
error: `old_string found ${count} times — not unique. Use replace_all: true to replace all occurrences, or provide more context to make it unique.`,
|
|
117
119
|
occurrences: count,
|
|
118
120
|
});
|
|
@@ -137,7 +139,7 @@ Guidelines:
|
|
|
137
139
|
message: `Replaced ${replace_all ? count : 1} occurrence(s) in ${absPath}`,
|
|
138
140
|
});
|
|
139
141
|
} catch (err) {
|
|
140
|
-
return JSON.stringify({ error: `Failed to edit file: ${err.message}` });
|
|
142
|
+
return JSON.stringify({ errorEffect: 'unknown', error: `Failed to edit file: ${err.message}` });
|
|
141
143
|
}
|
|
142
144
|
},
|
|
143
145
|
});
|
|
@@ -56,9 +56,9 @@ Guidelines:
|
|
|
56
56
|
isDestructive: () => false,
|
|
57
57
|
async execute(input, ctx) {
|
|
58
58
|
const { file_path, content } = input;
|
|
59
|
-
if (!file_path) return JSON.stringify({ error: 'file_path is required' });
|
|
59
|
+
if (!file_path) return JSON.stringify({ errorEffect: 'none', error: 'file_path is required' });
|
|
60
60
|
if (content === undefined || content === null) {
|
|
61
|
-
return JSON.stringify({ error: 'content is required' });
|
|
61
|
+
return JSON.stringify({ errorEffect: 'none', error: 'content is required' });
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
const cwd = ctx?.cwd || process.cwd();
|
package/yeaft/tools/registry.js
CHANGED
|
@@ -194,23 +194,31 @@ export function normalizeToolOutput(output) {
|
|
|
194
194
|
return text;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
function parseToolErrorOutput(output) {
|
|
198
198
|
const text = normalizeToolOutput(output).trim();
|
|
199
|
-
if (!text.startsWith('{')) return
|
|
199
|
+
if (!text.startsWith('{')) return null;
|
|
200
200
|
try {
|
|
201
201
|
const parsed = JSON.parse(text);
|
|
202
|
-
return
|
|
203
|
-
parsed
|
|
202
|
+
return parsed
|
|
204
203
|
&& typeof parsed === 'object'
|
|
205
204
|
&& !Array.isArray(parsed)
|
|
206
205
|
&& typeof parsed.error === 'string'
|
|
207
|
-
&& parsed.error.trim()
|
|
208
|
-
|
|
206
|
+
&& parsed.error.trim()
|
|
207
|
+
? parsed
|
|
208
|
+
: null;
|
|
209
209
|
} catch {
|
|
210
|
-
return
|
|
210
|
+
return null;
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
export function isToolErrorOutput(output) {
|
|
215
|
+
return parseToolErrorOutput(output) !== null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function toolErrorEffect(output) {
|
|
219
|
+
return parseToolErrorOutput(output)?.errorEffect === 'none' ? 'none' : 'unknown';
|
|
220
|
+
}
|
|
221
|
+
|
|
214
222
|
function truncateUtf8(text, maxBytes) {
|
|
215
223
|
if (maxBytes <= 0) return '';
|
|
216
224
|
const buffer = Buffer.from(String(text), 'utf8');
|
|
@@ -435,9 +443,19 @@ export class ToolRegistry {
|
|
|
435
443
|
const rawTimeout = Number.isFinite(tool.timeoutMs) ? tool.timeoutMs : DEFAULT_TOOL_TIMEOUT_MS;
|
|
436
444
|
const useTimeout = rawTimeout > 0;
|
|
437
445
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
446
|
+
let output;
|
|
447
|
+
try {
|
|
448
|
+
output = useTimeout
|
|
449
|
+
? await runWithTimeout(tool.execute(input, ctx), rawTimeout, name)
|
|
450
|
+
: await tool.execute(input, ctx);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (error instanceof ToolExecutionTimeoutError
|
|
453
|
+
&& tool.sideEffectScope !== 'run'
|
|
454
|
+
&& tool.isReadOnly?.(input) !== true) {
|
|
455
|
+
error.fatalToolTimeout = true;
|
|
456
|
+
}
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
441
459
|
|
|
442
460
|
return normalizeToolOutput(output);
|
|
443
461
|
}
|
package/yeaft/tools/types.js
CHANGED
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
* @property {(input?: object) => boolean} [isReadOnly] — read-only operation?
|
|
64
64
|
* @property {(input?: object) => boolean} [isDestructive] — destructive operation?
|
|
65
65
|
* @property {'json-error-envelope' | null} [errorOutput] — explicit returned-output error contract; null means only thrown errors fail
|
|
66
|
+
* @property {'external' | 'run'} [sideEffectScope] — whether mutations escape the current Run collector
|
|
66
67
|
*/
|
|
67
68
|
|
|
68
69
|
/**
|
|
@@ -77,6 +78,7 @@
|
|
|
77
78
|
* isReadOnly?: (input?: object) => boolean,
|
|
78
79
|
* isDestructive?: (input?: object) => boolean,
|
|
79
80
|
* errorOutput?: 'json-error-envelope' | null,
|
|
81
|
+
* sideEffectScope?: 'external' | 'run',
|
|
80
82
|
* timeoutMs?: number,
|
|
81
83
|
* }} def
|
|
82
84
|
* @returns {ToolDef}
|
|
@@ -91,6 +93,7 @@ export function defineTool({
|
|
|
91
93
|
isReadOnly = () => false,
|
|
92
94
|
isDestructive = () => false,
|
|
93
95
|
errorOutput = 'json-error-envelope',
|
|
96
|
+
sideEffectScope = 'external',
|
|
94
97
|
timeoutMs,
|
|
95
98
|
}) {
|
|
96
99
|
if (!name) throw new Error('Tool must have a name');
|
|
@@ -105,6 +108,7 @@ export function defineTool({
|
|
|
105
108
|
isReadOnly,
|
|
106
109
|
isDestructive,
|
|
107
110
|
errorOutput,
|
|
111
|
+
sideEffectScope,
|
|
108
112
|
};
|
|
109
113
|
// Legacy tool-name aliases. Registered as extra lookup keys so old
|
|
110
114
|
// jsonl tool_calls (e.g. `SendMessage` → `PromptAgent`) keep resolving,
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -1098,6 +1098,10 @@ const ESCALATE_AFTER_ABORT_MS = 15_000;
|
|
|
1098
1098
|
/** Virtual conversationId for the Yeaft session */
|
|
1099
1099
|
let yeaftConversationId = null;
|
|
1100
1100
|
|
|
1101
|
+
function createYeaftConversationId() {
|
|
1102
|
+
return `yeaft-${randomUUID()}`;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1101
1105
|
/** Last agent-level Yeaft slash command payload. Replayed after the web side
|
|
1102
1106
|
* creates/replaces the virtual Yeaft conversation id so `/` autocomplete never
|
|
1103
1107
|
* falls back to built-ins while full Session metadata is still loading. */
|
|
@@ -1316,7 +1320,7 @@ function loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq = null)
|
|
|
1316
1320
|
|
|
1317
1321
|
function ensureYeaftConversationId() {
|
|
1318
1322
|
if (!yeaftConversationId) {
|
|
1319
|
-
yeaftConversationId =
|
|
1323
|
+
yeaftConversationId = createYeaftConversationId();
|
|
1320
1324
|
replayCachedSkillSlashCommandsToYeaftConversation();
|
|
1321
1325
|
}
|
|
1322
1326
|
return yeaftConversationId;
|
|
@@ -3110,6 +3114,7 @@ function sendSessionRosterChanged(session) {
|
|
|
3110
3114
|
roster: session.roster,
|
|
3111
3115
|
defaultVpId: session.defaultVpId,
|
|
3112
3116
|
workDir: session.workDir || '',
|
|
3117
|
+
metadataUpdatedAt: session.metadataUpdatedAt || session.createdAt || null,
|
|
3113
3118
|
};
|
|
3114
3119
|
sendSessionEvent({ type: 'session_roster_changed', ...payload });
|
|
3115
3120
|
}
|
|
@@ -7029,7 +7034,7 @@ export async function resetYeaftSession() {
|
|
|
7029
7034
|
claimRuntimeOwnership(session);
|
|
7030
7035
|
installYeaftRuntimeBridge(session);
|
|
7031
7036
|
|
|
7032
|
-
yeaftConversationId =
|
|
7037
|
+
yeaftConversationId = createYeaftConversationId();
|
|
7033
7038
|
scheduleBaseRuntimeLoad();
|
|
7034
7039
|
hydrateYeaftStatusFromSession(session, { reason: 'reset', emitEvent: true });
|
|
7035
7040
|
broadcastSkillSlashCommands(session);
|
|
@@ -7346,6 +7351,9 @@ export const __testHooks = {
|
|
|
7346
7351
|
ensureYeaftConversationIdForTest() {
|
|
7347
7352
|
return ensureYeaftConversationId();
|
|
7348
7353
|
},
|
|
7354
|
+
setYeaftConversationIdForTest(value) {
|
|
7355
|
+
yeaftConversationId = value || null;
|
|
7356
|
+
},
|
|
7349
7357
|
preloadYeaftSkillSlashCommandsForTest() {
|
|
7350
7358
|
return broadcastSkillSlashCommands(session);
|
|
7351
7359
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { resolveMaxOutputTokens } from '../models.js';
|
|
3
3
|
import {
|
|
4
4
|
LLMAuthError,
|
|
@@ -531,6 +531,8 @@ export class WorkItemCoordinator {
|
|
|
531
531
|
this.runtimeProvider = options.runtimeProvider;
|
|
532
532
|
this.policyProvider = typeof options.policyProvider === 'function' ? options.policyProvider : async () => ({});
|
|
533
533
|
this.registry = options.registry;
|
|
534
|
+
this.ownerBootId = options.ownerBootId || randomUUID();
|
|
535
|
+
this.claimLeaseMs = Math.max(5_000, Number(options.claimLeaseMs) || 60_000);
|
|
534
536
|
this.attachmentRoot = options.attachmentRoot || null;
|
|
535
537
|
this.languageProvider = typeof options.languageProvider === 'function'
|
|
536
538
|
? options.languageProvider
|
|
@@ -550,7 +552,7 @@ export class WorkItemCoordinator {
|
|
|
550
552
|
throw new Error('Work Center Coordinator message or attachments are required');
|
|
551
553
|
}
|
|
552
554
|
const promptText = text || `The user added ${addedAttachments.length} attachment(s) for this WorkItem.`;
|
|
553
|
-
|
|
555
|
+
let started = this.store.beginCoordinatorTurn(id, text, {
|
|
554
556
|
revision: Number(input.revision),
|
|
555
557
|
planRevision: Number(input.planRevision),
|
|
556
558
|
ledgerRevision: Number(input.ledgerRevision),
|
|
@@ -564,6 +566,9 @@ export class WorkItemCoordinator {
|
|
|
564
566
|
if (started.duplicate) {
|
|
565
567
|
return { detail: started.detail, task: Promise.resolve(started.detail), duplicate: true };
|
|
566
568
|
}
|
|
569
|
+
const claimed = this.store.claimStartedCoordinatorTurn(started, this.ownerBootId, this.claimLeaseMs);
|
|
570
|
+
if (!claimed) return { detail: started.detail, task: Promise.resolve(started.detail), duplicate: true };
|
|
571
|
+
started = claimed;
|
|
567
572
|
options.onUpdate?.('coordinator.turn_started', started.detail);
|
|
568
573
|
return this.#scheduleTurn(started, {
|
|
569
574
|
text: promptText,
|
|
@@ -611,11 +616,13 @@ export class WorkItemCoordinator {
|
|
|
611
616
|
},
|
|
612
617
|
});
|
|
613
618
|
if (!started) return null;
|
|
614
|
-
|
|
619
|
+
const claimed = this.store.claimStartedCoordinatorTurn(started, this.ownerBootId, this.claimLeaseMs);
|
|
620
|
+
if (!claimed) return null;
|
|
621
|
+
options.onUpdate?.('coordinator.recovery_started', claimed.detail);
|
|
615
622
|
const text = `Action stage "${action.stageId}" failed. Decide the next safe control transition. `
|
|
616
623
|
+ 'Failure is not a terminal WorkItem state: guide or replan executable work whenever possible. '
|
|
617
624
|
+ 'Request human input only when the snapshot lacks information required for a safe decision.';
|
|
618
|
-
return this.#scheduleTurn(
|
|
625
|
+
return this.#scheduleTurn(claimed, { text, recovery: true, options });
|
|
619
626
|
}
|
|
620
627
|
|
|
621
628
|
#scheduleTurn(started, {
|
|
@@ -623,11 +630,19 @@ export class WorkItemCoordinator {
|
|
|
623
630
|
}) {
|
|
624
631
|
const abortController = new AbortController();
|
|
625
632
|
this.activeTurns.set(started.turnId, abortController);
|
|
633
|
+
const claim = started.fence?.claim;
|
|
634
|
+
const renewalTimer = claim ? setInterval(() => {
|
|
635
|
+
if (!this.store.renewCoordinatorMailbox(
|
|
636
|
+
claim.mailboxId, claim.ownerBootId, claim.claimEpoch, this.claimLeaseMs,
|
|
637
|
+
)) abortController.abort('work_center_coordinator_claim_lost');
|
|
638
|
+
}, Math.max(1_000, Math.floor(this.claimLeaseMs / 3))) : null;
|
|
639
|
+
renewalTimer?.unref?.();
|
|
626
640
|
const task = new Promise(resolve => setTimeout(resolve, 0))
|
|
627
641
|
.then(() => this.#executeTurn(started, {
|
|
628
642
|
text, recovery, addedAttachments, options, abortController,
|
|
629
643
|
}))
|
|
630
644
|
.finally(() => {
|
|
645
|
+
if (renewalTimer) clearInterval(renewalTimer);
|
|
631
646
|
this.activeTurns.delete(started.turnId);
|
|
632
647
|
this.activeTasks.delete(started.turnId);
|
|
633
648
|
});
|
|
@@ -638,6 +653,7 @@ export class WorkItemCoordinator {
|
|
|
638
653
|
async #executeTurn(started, {
|
|
639
654
|
text, recovery, addedAttachments, options, abortController,
|
|
640
655
|
}) {
|
|
656
|
+
let providerTurn = null;
|
|
641
657
|
try {
|
|
642
658
|
let normalized = null;
|
|
643
659
|
let mutation = null;
|
|
@@ -698,6 +714,7 @@ export class WorkItemCoordinator {
|
|
|
698
714
|
const correction = lastError
|
|
699
715
|
? `\n\nYour previous decision was rejected by the deterministic validator:\n${String(lastError.message || lastError).slice(0, 2_000)}\nReturn a corrected complete JSON decision.`
|
|
700
716
|
: '';
|
|
717
|
+
providerTurn = null;
|
|
701
718
|
try {
|
|
702
719
|
let result;
|
|
703
720
|
try {
|
|
@@ -717,9 +734,11 @@ export class WorkItemCoordinator {
|
|
|
717
734
|
effortSource: resolved.source,
|
|
718
735
|
effortContext: { scenario: 'work-center-coordinator' },
|
|
719
736
|
};
|
|
720
|
-
const
|
|
721
|
-
|
|
737
|
+
const claim = started.fence.claim;
|
|
738
|
+
providerTurn = this.store.prepareCoordinatorProviderTurn(
|
|
739
|
+
started.detail.id, started.turnId, attemptCount, requestBody, claim,
|
|
722
740
|
);
|
|
741
|
+
if (!providerTurn) return started.detail;
|
|
723
742
|
if (providerTurn.status === 'unknown') {
|
|
724
743
|
throw new Error('Coordinator provider dispatch outcome is unknown and requires review');
|
|
725
744
|
}
|
|
@@ -731,15 +750,17 @@ export class WorkItemCoordinator {
|
|
|
731
750
|
...requestBody,
|
|
732
751
|
signal: abortController.signal,
|
|
733
752
|
onRequestStart: () => {
|
|
734
|
-
if (!this.store.dispatchCoordinatorProviderTurn(providerTurn.id)) {
|
|
753
|
+
if (!this.store.dispatchCoordinatorProviderTurn(providerTurn.id, claim)) {
|
|
754
|
+
abortController.abort('work_center_coordinator_dispatch_fence_lost');
|
|
735
755
|
throw new Error('Coordinator provider turn lost its dispatch fence');
|
|
736
756
|
}
|
|
737
757
|
},
|
|
738
758
|
}).then(response => {
|
|
739
759
|
const persisted = this.store.respondCoordinatorProviderTurn(
|
|
740
|
-
providerTurn.id, providerTurn.requestHash, response,
|
|
760
|
+
providerTurn.id, providerTurn.requestHash, response, claim,
|
|
741
761
|
);
|
|
742
762
|
if (!persisted) throw new Error('Coordinator provider response lost its CAS fence');
|
|
763
|
+
providerTurn = persisted;
|
|
743
764
|
return response;
|
|
744
765
|
}),
|
|
745
766
|
new Promise((_, reject) => {
|
|
@@ -778,6 +799,9 @@ export class WorkItemCoordinator {
|
|
|
778
799
|
break;
|
|
779
800
|
} catch (error) {
|
|
780
801
|
normalized = null;
|
|
802
|
+
if (providerTurn?.status === 'responded') {
|
|
803
|
+
this.store.rejectCoordinatorProviderTurn(providerTurn.id, error, started.fence.claim);
|
|
804
|
+
}
|
|
781
805
|
if (abortController.signal.aborted || this.shuttingDown || error?.coordinatorClassified) {
|
|
782
806
|
throw error;
|
|
783
807
|
}
|
|
@@ -824,16 +848,19 @@ export class WorkItemCoordinator {
|
|
|
824
848
|
mutation,
|
|
825
849
|
attemptCount,
|
|
826
850
|
}, started.fence);
|
|
827
|
-
if (!detail)
|
|
851
|
+
if (!detail) return this.store.getWorkItemDetail(started.detail.id);
|
|
828
852
|
options.onUpdate?.(recovery ? 'coordinator.recovery_completed' : 'coordinator.turn_completed', detail);
|
|
829
853
|
return detail;
|
|
830
854
|
} catch (error) {
|
|
855
|
+
if (providerTurn?.status === 'responded') {
|
|
856
|
+
this.store.rejectCoordinatorProviderTurn(providerTurn.id, error, started.fence.claim);
|
|
857
|
+
}
|
|
831
858
|
const detail = this.store.failCoordinatorTurn(started.turnId, error, started.fence);
|
|
832
859
|
if (detail) {
|
|
833
860
|
options.onUpdate?.('coordinator.turn_failed', detail);
|
|
834
861
|
return detail;
|
|
835
862
|
}
|
|
836
|
-
|
|
863
|
+
return this.store.getWorkItemDetail(started.detail.id);
|
|
837
864
|
}
|
|
838
865
|
}
|
|
839
866
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
|
-
export const WORK_CENTER_SCHEMA_VERSION =
|
|
3
|
+
export const WORK_CENTER_SCHEMA_VERSION = 35;
|
|
4
4
|
|
|
5
5
|
const MIGRATIONS = [
|
|
6
6
|
['23-conversation-stream', migrateConversationStream],
|
|
@@ -14,6 +14,8 @@ const MIGRATIONS = [
|
|
|
14
14
|
['31-reliability-guards', migrateReliabilityGuards],
|
|
15
15
|
['32-engine-turn-status-contract', migrateEngineTurnStatusContract],
|
|
16
16
|
['33-coordinator-provider-turns', migrateCoordinatorProviderTurns],
|
|
17
|
+
['34-engine-turn-status-repair', repairEngineTurnStatusContract],
|
|
18
|
+
['35-coordinator-provider-claims', migrateCoordinatorProviderClaims],
|
|
17
19
|
];
|
|
18
20
|
|
|
19
21
|
const MIGRATION_ALIASES = new Map([
|
|
@@ -295,9 +297,14 @@ function migrateRuntimeIndexes(db) {
|
|
|
295
297
|
`);
|
|
296
298
|
}
|
|
297
299
|
|
|
298
|
-
|
|
300
|
+
const ENGINE_TURN_STATUS_CHECK = /status\s+TEXT\s+NOT\s+NULL\s+CHECK\s*\(\s*status\s+IN\s*\(\s*'prepared'\s*,\s*'dispatching'\s*,\s*'responded'\s*,\s*'unknown'\s*,\s*'cancelled'\s*,\s*'legacy_imported'\s*\)\s*\)/i;
|
|
301
|
+
|
|
302
|
+
function hasEngineTurnStatusContract(db) {
|
|
299
303
|
const sql = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'engine_turns'`).get()?.sql || '';
|
|
300
|
-
|
|
304
|
+
return ENGINE_TURN_STATUS_CHECK.test(sql);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function rebuildEngineTurnStatusContract(db, now) {
|
|
301
308
|
db.exec('PRAGMA defer_foreign_keys = ON');
|
|
302
309
|
db.exec(`
|
|
303
310
|
CREATE TABLE engine_turns_new (
|
|
@@ -389,6 +396,40 @@ function migrateEngineTurnStatusContract(db, now) {
|
|
|
389
396
|
if (foreignKeyViolations.length > 0) throw new Error('EngineTurn status migration violated foreign keys');
|
|
390
397
|
}
|
|
391
398
|
|
|
399
|
+
function migrateEngineTurnStatusContract(db, now) {
|
|
400
|
+
if (hasEngineTurnStatusContract(db)) return;
|
|
401
|
+
rebuildEngineTurnStatusContract(db, now);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function repairEngineTurnStatusContract(db, now) {
|
|
405
|
+
if (!hasEngineTurnStatusContract(db)) rebuildEngineTurnStatusContract(db, now);
|
|
406
|
+
if (!hasEngineTurnStatusContract(db)) {
|
|
407
|
+
throw new Error('EngineTurn status repair did not install the required status contract');
|
|
408
|
+
}
|
|
409
|
+
const foreignKeys = db.prepare('PRAGMA foreign_key_list(engine_turns)').all();
|
|
410
|
+
const referencedTables = foreignKeys.map(row => row.table).sort();
|
|
411
|
+
if (foreignKeys.length !== 3
|
|
412
|
+
|| JSON.stringify(referencedTables) !== JSON.stringify(['actions', 'runs', 'work_items'])) {
|
|
413
|
+
throw new Error('EngineTurn status repair did not preserve required foreign keys');
|
|
414
|
+
}
|
|
415
|
+
const indexes = db.prepare('PRAGMA index_list(engine_turns)').all();
|
|
416
|
+
const hasIndex = columns => indexes.some(index => {
|
|
417
|
+
const actual = db.prepare(`PRAGMA index_info(${JSON.stringify(index.name)})`).all()
|
|
418
|
+
.map(row => row.name);
|
|
419
|
+
return actual.length === columns.length && actual.every((value, offset) => value === columns[offset]);
|
|
420
|
+
});
|
|
421
|
+
if (!hasIndex(['status', 'updated_at'])
|
|
422
|
+
|| !hasIndex(['request_key'])
|
|
423
|
+
|| !hasIndex(['run_id', 'ordinal'])) {
|
|
424
|
+
throw new Error('EngineTurn status repair did not preserve required indexes');
|
|
425
|
+
}
|
|
426
|
+
const immutableTrigger = db.prepare(`SELECT 1 AS present FROM sqlite_master
|
|
427
|
+
WHERE type = 'trigger' AND name = 'trg_engine_turn_request_immutable'`).get();
|
|
428
|
+
if (!immutableTrigger) throw new Error('EngineTurn status repair did not preserve request immutability');
|
|
429
|
+
const foreignKeyViolations = db.prepare('PRAGMA foreign_key_check').all();
|
|
430
|
+
if (foreignKeyViolations.length > 0) throw new Error('EngineTurn status repair violated foreign keys');
|
|
431
|
+
}
|
|
432
|
+
|
|
392
433
|
function migrateCoordinatorProviderTurns(db) {
|
|
393
434
|
db.exec(`
|
|
394
435
|
CREATE TABLE IF NOT EXISTS coordinator_provider_turns (
|
|
@@ -422,6 +463,21 @@ function migrateCoordinatorProviderTurns(db) {
|
|
|
422
463
|
`);
|
|
423
464
|
}
|
|
424
465
|
|
|
466
|
+
function migrateCoordinatorProviderClaims(db) {
|
|
467
|
+
for (const [column, definition] of [
|
|
468
|
+
['claim_owner', 'TEXT'],
|
|
469
|
+
['claim_epoch', 'INTEGER NOT NULL DEFAULT 0'],
|
|
470
|
+
]) {
|
|
471
|
+
if (!hasColumn(db, 'coordinator_provider_turns', column)) {
|
|
472
|
+
db.exec(`ALTER TABLE coordinator_provider_turns ADD COLUMN ${column} ${definition}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
db.exec(`
|
|
476
|
+
CREATE INDEX IF NOT EXISTS idx_coordinator_provider_turns_claim
|
|
477
|
+
ON coordinator_provider_turns(coordinator_turn_id, claim_owner, claim_epoch, status);
|
|
478
|
+
`);
|
|
479
|
+
}
|
|
480
|
+
|
|
425
481
|
function migrateReliabilityGuards(db) {
|
|
426
482
|
for (const [column, definition] of [
|
|
427
483
|
['dispatch_capability', "TEXT NOT NULL DEFAULT 'unknown'"],
|
|
@@ -1036,6 +1036,8 @@ export function projectWorkCenterEvent(event) {
|
|
|
1036
1036
|
type,
|
|
1037
1037
|
...(eventActionId ? { actionId: eventActionId } : {}),
|
|
1038
1038
|
...(typeof event?.runId === 'string' && event.runId ? { runId: event.runId } : {}),
|
|
1039
|
+
...(typeof event?.clientMessageId === 'string' && event.clientMessageId
|
|
1040
|
+
? { clientMessageId: truncateUtf8(event.clientMessageId, 256) } : {}),
|
|
1039
1041
|
workItem: {
|
|
1040
1042
|
...projectWorkItemSummary(event?.workItem),
|
|
1041
1043
|
actionStats: projectActionStats(event?.workItem, liveActionId),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Engine } from '../engine.js';
|
|
2
|
-
import { ToolRegistry } from '../tools/registry.js';
|
|
2
|
+
import { ToolRegistry, isToolErrorOutput, toolErrorEffect } from '../tools/registry.js';
|
|
3
3
|
import { defineTool } from '../tools/types.js';
|
|
4
4
|
import { allTools } from '../tools/index.js';
|
|
5
5
|
import { parsePatch } from '../tools/apply-patch.js';
|
|
@@ -274,6 +274,7 @@ function wrapWorkItemTool(tool, canonicalDir, canonicalAttachmentFiles, isRunAct
|
|
|
274
274
|
? input
|
|
275
275
|
: assertToolInput(tool.name, input, canonicalDir, canonicalAttachmentFiles);
|
|
276
276
|
const trackOperation = typeof operationLifecycle === 'function'
|
|
277
|
+
&& tool.sideEffectScope !== 'run'
|
|
277
278
|
&& tool.isReadOnly?.(checkedInput) !== true;
|
|
278
279
|
const operation = trackOperation ? operationLifecycle(tool.name, checkedInput) : null;
|
|
279
280
|
let output;
|
|
@@ -288,7 +289,12 @@ function wrapWorkItemTool(tool, canonicalDir, canonicalAttachmentFiles, isRunAct
|
|
|
288
289
|
operation?.complete('unknown', { error: String(error?.message || error) });
|
|
289
290
|
throw error;
|
|
290
291
|
}
|
|
291
|
-
|
|
292
|
+
const outputHash = hashMainlineSnapshot({ output: String(output || '') });
|
|
293
|
+
const returnedError = tool.errorOutput === 'json-error-envelope' && isToolErrorOutput(output);
|
|
294
|
+
const effectStatus = returnedError
|
|
295
|
+
? toolErrorEffect(output) === 'none' ? 'failed_no_effect' : 'unknown'
|
|
296
|
+
: 'applied';
|
|
297
|
+
operation?.complete(effectStatus, { outputHash });
|
|
292
298
|
if (!isRunActive()) throw new Error('Work Center Run lease was lost during tool execution');
|
|
293
299
|
if (['FileRead', 'ViewImage'].includes(tool.name) && typeof output === 'string') {
|
|
294
300
|
const withoutFilePaths = canonicalAttachmentFiles.reduce(
|
|
@@ -386,6 +392,7 @@ export function createSubmitWorkItemPlanTool({
|
|
|
386
392
|
},
|
|
387
393
|
isConcurrencySafe: () => false,
|
|
388
394
|
isReadOnly: () => false,
|
|
395
|
+
sideEffectScope: 'run',
|
|
389
396
|
});
|
|
390
397
|
}
|
|
391
398
|
|
|
@@ -444,6 +451,7 @@ export function createProposeWorkItemActionsTool({
|
|
|
444
451
|
ctx.requestEndTurn?.({ kind: 'work_item_actions_proposed', proposalId: input.proposalId });
|
|
445
452
|
return JSON.stringify({ submitted: true, proposalId: input.proposalId, actionCount: input.actions.length });
|
|
446
453
|
},
|
|
454
|
+
sideEffectScope: 'run',
|
|
447
455
|
});
|
|
448
456
|
}
|
|
449
457
|
|
|
@@ -465,6 +473,7 @@ export function createRequestWorkItemReplanTool({ workItem, collector, isRunActi
|
|
|
465
473
|
ctx.requestEndTurn?.({ kind: 'work_item_replan_requested', proposalId: input.proposalId });
|
|
466
474
|
return JSON.stringify({ submitted: true, proposalId: input.proposalId });
|
|
467
475
|
},
|
|
476
|
+
sideEffectScope: 'run',
|
|
468
477
|
});
|
|
469
478
|
}
|
|
470
479
|
|
|
@@ -519,6 +528,7 @@ export function createSubmitWorkItemReplanTool({ vps, workItem, action, actions,
|
|
|
519
528
|
},
|
|
520
529
|
isConcurrencySafe: () => false,
|
|
521
530
|
isReadOnly: () => false,
|
|
531
|
+
sideEffectScope: 'run',
|
|
522
532
|
});
|
|
523
533
|
}
|
|
524
534
|
|
|
@@ -1038,7 +1048,7 @@ export class WorkItemRunner {
|
|
|
1038
1048
|
const operationLifecycle = (toolName, input) => {
|
|
1039
1049
|
operationOrdinal += 1;
|
|
1040
1050
|
const idempotencyKey = `${run.id}:tool:${operationOrdinal}`;
|
|
1041
|
-
this.store.
|
|
1051
|
+
const claimed = this.store.createAndClaimOperation({
|
|
1042
1052
|
workItemId: workItem.id,
|
|
1043
1053
|
actionId: action.id,
|
|
1044
1054
|
runId: run.id,
|
|
@@ -1046,8 +1056,7 @@ export class WorkItemRunner {
|
|
|
1046
1056
|
idempotencyKey,
|
|
1047
1057
|
replayPolicy: 'never_automatic',
|
|
1048
1058
|
payload: { inputHash: hashMainlineSnapshot(input) },
|
|
1049
|
-
});
|
|
1050
|
-
const claimed = this.store.claimOperation(idempotencyKey, ownerBootId, run.leaseEpoch, false);
|
|
1059
|
+
}, ownerBootId, run.leaseEpoch, false);
|
|
1051
1060
|
if (!claimed) throw new Error(`Work Center could not claim Operation ${idempotencyKey}`);
|
|
1052
1061
|
return {
|
|
1053
1062
|
complete: (effectStatus, result) => {
|