@yeaft/webchat-agent 1.0.295 → 1.0.298

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.295",
3
+ "version": "1.0.298",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -1838,7 +1838,7 @@ export class Engine {
1838
1838
  * string-prompt shape (no regression for existing callers).
1839
1839
  * @yields {EngineEvent}
1840
1840
  */
1841
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, closePendingUserInput = null, collabToolPolicy = null } = {}) {
1841
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null } = {}) {
1842
1842
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1843
1843
  yield {
1844
1844
  type: 'error',
@@ -1915,7 +1915,7 @@ export class Engine {
1915
1915
  };
1916
1916
  try {
1917
1917
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
1918
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName, retryLifecycle });
1918
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, prepareProviderRequest, startProviderRequest, finishProviderRequest, failProviderRequest, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName, retryLifecycle });
1919
1919
  } finally {
1920
1920
  // Closing the async generator at a visible retry boundary means the
1921
1921
  // continuation never reached a provider. Keep it out of history and
@@ -1963,7 +1963,7 @@ export class Engine {
1963
1963
  * in a try/finally without indenting the whole loop.
1964
1964
  * @private
1965
1965
  */
1966
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null, retryLifecycle }) {
1966
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null, retryLifecycle }) {
1967
1967
 
1968
1968
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
1969
1969
  ? collabToolPolicy
@@ -2367,6 +2367,7 @@ export class Engine {
2367
2367
  let currentModel = this.#config.model;
2368
2368
  let cumulativeInputTokens = 0;
2369
2369
  let cumulativeOutputTokens = 0;
2370
+ let activeProviderRequest = null;
2370
2371
  // task-707: tool-callable end-turn signal. Tools (currently only
2371
2372
  // `route_forward`) can set this via toolCtx.requestEndTurn(reason)
2372
2373
  // to break out of the tool-loop after the current batch finishes
@@ -2645,6 +2646,16 @@ export class Engine {
2645
2646
  retryLifecycle.pendingContinuation = null;
2646
2647
  }
2647
2648
 
2649
+ activeProviderRequest = typeof prepareProviderRequest === 'function'
2650
+ ? prepareProviderRequest({
2651
+ turnNumber,
2652
+ entries: appendedBeforeStream,
2653
+ system: systemPrompt,
2654
+ messages: wireMessages.map(mapDebugMessage),
2655
+ model: currentModel,
2656
+ }) || null
2657
+ : null;
2658
+
2648
2659
  // Snapshot task results carried by this exact request. Request start
2649
2660
  // is not delivery: fetch may remain pending and then be aborted before
2650
2661
  // the provider processes anything. Ack only after a normal stream end
@@ -2661,6 +2672,7 @@ export class Engine {
2661
2672
  effortSource: userEffort ? 'user' : 'auto',
2662
2673
  signal,
2663
2674
  onRawExchange: captureRawExchange,
2675
+ onRequestStart: () => startProviderRequest?.(activeProviderRequest),
2664
2676
  })) {
2665
2677
  // task-325a (abort-stop fix): per-event abort short-circuit.
2666
2678
  // The adapter is expected to throw AbortError when fetch's
@@ -2768,6 +2780,14 @@ export class Engine {
2768
2780
  // escrow so retry or final rescue can deliver the payload.
2769
2781
  if (sawProviderStop) {
2770
2782
  this.#confirmAsyncTaskResults(requestAsyncTaskIds);
2783
+ const completedProviderRequest = activeProviderRequest;
2784
+ activeProviderRequest = null;
2785
+ finishProviderRequest?.(completedProviderRequest, {
2786
+ responseText,
2787
+ stopReason,
2788
+ toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
2789
+ thinkingBlocks,
2790
+ });
2771
2791
  }
2772
2792
  traceRequest('llm.request_complete', {
2773
2793
  durationMs: perfNowMs() - requestPerfStart,
@@ -2785,6 +2805,10 @@ export class Engine {
2785
2805
  consecutiveRetryableErrors = 0;
2786
2806
  } catch (err) {
2787
2807
  const latencyMs = Date.now() - startTime;
2808
+ if (activeProviderRequest) {
2809
+ failProviderRequest?.(activeProviderRequest, err);
2810
+ activeProviderRequest = null;
2811
+ }
2788
2812
 
2789
2813
  const endAttemptTrace = (attemptStopReason) => {
2790
2814
  this.#trace.endTurn(turnId, {
@@ -3571,6 +3595,7 @@ export class Engine {
3571
3595
  let displayImages = [];
3572
3596
  let isError = false;
3573
3597
  let toolErrorOutput = null;
3598
+ let fatalToolError = null;
3574
3599
  currentToolCallForAsyncTask = {
3575
3600
  id: tc.id,
3576
3601
  name: tc.name,
@@ -3644,6 +3669,7 @@ export class Engine {
3644
3669
  output = `Error: ${err.message}`;
3645
3670
  isError = true;
3646
3671
  yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
3672
+ if (err?.fatalToolTimeout === true) fatalToolError = err;
3647
3673
  }
3648
3674
  }
3649
3675
 
@@ -3732,6 +3758,7 @@ export class Engine {
3732
3758
  isError,
3733
3759
  }));
3734
3760
  queryToolCount += 1;
3761
+ if (fatalToolError) throw fatalToolError;
3735
3762
  }
3736
3763
 
3737
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
- return saveSessionConfig(yeaftDir, sessionId, partial || {});
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;
@@ -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();
@@ -194,23 +194,31 @@ export function normalizeToolOutput(output) {
194
194
  return text;
195
195
  }
196
196
 
197
- export function isToolErrorOutput(output) {
197
+ function parseToolErrorOutput(output) {
198
198
  const text = normalizeToolOutput(output).trim();
199
- if (!text.startsWith('{')) return false;
199
+ if (!text.startsWith('{')) return null;
200
200
  try {
201
201
  const parsed = JSON.parse(text);
202
- return Boolean(
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 false;
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
- const output = useTimeout
439
- ? await runWithTimeout(tool.execute(input, ctx), rawTimeout, name)
440
- : await tool.execute(input, ctx);
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
  }
@@ -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,
@@ -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 = `yeaft-${Date.now()}`;
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 = `yeaft-${Date.now()}`;
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
  },
@@ -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', 'resume', 'action_input', 'retry_action', 'guide', 'retry',
23
+ 'get', 'create', 'update', 'start', 'cancel', 'resume', 'post_work_item_message', '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
@@ -29,6 +29,10 @@ const BROWSER_FILE_FIELDS = Object.freeze({
29
29
  create: [
30
30
  'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'reuseMemory', 'files', 'start',
31
31
  ],
32
+ post_work_item_message: [
33
+ 'id', 'clientMessageId', 'text', 'target', 'revision', 'planRevision', 'ledgerRevision',
34
+ 'coordinatorRevision', 'files',
35
+ ],
32
36
  work_item_message: [
33
37
  'id', 'text', 'revision', 'planRevision', 'ledgerRevision', 'coordinatorRevision', 'files',
34
38
  ],
@@ -227,6 +227,8 @@ export class WorkflowController {
227
227
  }
228
228
 
229
229
  input(id, input = {}) {
230
+ const existingClientMessage = this.store.hasActionInputClientMessage(id, input.actionId, input.clientMessageId);
231
+ if (existingClientMessage) return this.store.getWorkItemDetail(id);
230
232
  const text = typeof input.text === 'string' ? input.text.trim().slice(0, 8_000) : '';
231
233
  const addedAttachmentCount = Math.max(0, Number(input.addedAttachmentCount) || 0);
232
234
  if (!text && addedAttachmentCount === 0) throw new Error('Action input or attachments are required');
@@ -253,7 +255,7 @@ export class WorkflowController {
253
255
  actionId: input.actionId,
254
256
  generation: expectedGeneration,
255
257
  revision: input.revision,
256
- }, input.attachments, input.addedAttachments);
258
+ }, input.attachments, input.addedAttachments, input.clientMessageId);
257
259
  }
258
260
  if (!['waiting', 'failed'].includes(targetAction.status)) {
259
261
  throw new Error(`Action in ${targetAction.status} cannot accept input`);
@@ -264,7 +266,9 @@ export class WorkflowController {
264
266
  expected: { actionId: input.actionId, generation: input.generation, revision: input.revision },
265
267
  attachments: input.attachments,
266
268
  inputEvent: {
267
- inputId: randomUUID(),
269
+ inputId: input.clientMessageId || randomUUID(),
270
+ clientMessageId: input.clientMessageId || null,
271
+ targetActionId: input.actionId,
268
272
  text: text || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`,
269
273
  attachments: input.addedAttachments,
270
274
  },
@@ -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
- const started = this.store.beginCoordinatorTurn(id, text, {
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),
@@ -558,8 +560,15 @@ export class WorkItemCoordinator {
558
560
  }, {
559
561
  attachments: input.attachments,
560
562
  addedAttachments,
563
+ clientMessageId: input.clientMessageId,
561
564
  });
562
565
  if (!started) throw new Error(`WorkItem not found: ${id}`);
566
+ if (started.duplicate) {
567
+ return { detail: started.detail, task: Promise.resolve(started.detail), duplicate: true };
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;
563
572
  options.onUpdate?.('coordinator.turn_started', started.detail);
564
573
  return this.#scheduleTurn(started, {
565
574
  text: promptText,
@@ -569,6 +578,18 @@ export class WorkItemCoordinator {
569
578
  });
570
579
  }
571
580
 
581
+ resume(started, options = {}) {
582
+ if (!started?.turnId || !started?.detail || !started?.fence) {
583
+ throw new Error('Coordinator provider recovery target is invalid');
584
+ }
585
+ return this.#scheduleTurn(started, {
586
+ text: typeof options.text === 'string' ? options.text : '',
587
+ recovery: options.recovery === true,
588
+ addedAttachments: Array.isArray(options.addedAttachments) ? options.addedAttachments : [],
589
+ options,
590
+ });
591
+ }
592
+
572
593
  recover(id, options = {}) {
573
594
  if (this.shuttingDown) throw new Error('Work Center Coordinator is shutting down');
574
595
  const detail = this.store.getWorkItemDetail(id);
@@ -595,11 +616,13 @@ export class WorkItemCoordinator {
595
616
  },
596
617
  });
597
618
  if (!started) return null;
598
- options.onUpdate?.('coordinator.recovery_started', started.detail);
619
+ const claimed = this.store.claimStartedCoordinatorTurn(started, this.ownerBootId, this.claimLeaseMs);
620
+ if (!claimed) return null;
621
+ options.onUpdate?.('coordinator.recovery_started', claimed.detail);
599
622
  const text = `Action stage "${action.stageId}" failed. Decide the next safe control transition. `
600
623
  + 'Failure is not a terminal WorkItem state: guide or replan executable work whenever possible. '
601
624
  + 'Request human input only when the snapshot lacks information required for a safe decision.';
602
- return this.#scheduleTurn(started, { text, recovery: true, options });
625
+ return this.#scheduleTurn(claimed, { text, recovery: true, options });
603
626
  }
604
627
 
605
628
  #scheduleTurn(started, {
@@ -607,11 +630,19 @@ export class WorkItemCoordinator {
607
630
  }) {
608
631
  const abortController = new AbortController();
609
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?.();
610
640
  const task = new Promise(resolve => setTimeout(resolve, 0))
611
641
  .then(() => this.#executeTurn(started, {
612
642
  text, recovery, addedAttachments, options, abortController,
613
643
  }))
614
644
  .finally(() => {
645
+ if (renewalTimer) clearInterval(renewalTimer);
615
646
  this.activeTurns.delete(started.turnId);
616
647
  this.activeTasks.delete(started.turnId);
617
648
  });
@@ -622,6 +653,7 @@ export class WorkItemCoordinator {
622
653
  async #executeTurn(started, {
623
654
  text, recovery, addedAttachments, options, abortController,
624
655
  }) {
656
+ let providerTurn = null;
625
657
  try {
626
658
  let normalized = null;
627
659
  let mutation = null;
@@ -682,6 +714,7 @@ export class WorkItemCoordinator {
682
714
  const correction = lastError
683
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.`
684
716
  : '';
717
+ providerTurn = null;
685
718
  try {
686
719
  let result;
687
720
  try {
@@ -689,19 +722,46 @@ export class WorkItemCoordinator {
689
722
  const content = attachmentContext.promptParts.length > 0
690
723
  ? [{ type: 'text', text: latestMessage }, ...attachmentContext.promptParts]
691
724
  : latestMessage;
692
- result = await Promise.race([
725
+ const requestBody = {
726
+ model: resolved.model,
727
+ system: coordinatorSystemPrompt(language),
728
+ messages: [{ role: 'user', content }],
729
+ maxTokens: Math.min(
730
+ resolveMaxOutputTokens(resolved.model, runtime.config),
731
+ COORDINATOR_MAX_OUTPUT_TOKENS,
732
+ ),
733
+ effort: resolved.effort,
734
+ effortSource: resolved.source,
735
+ effortContext: { scenario: 'work-center-coordinator' },
736
+ };
737
+ const claim = started.fence.claim;
738
+ providerTurn = this.store.prepareCoordinatorProviderTurn(
739
+ started.detail.id, started.turnId, attemptCount, requestBody, claim,
740
+ );
741
+ if (!providerTurn) return started.detail;
742
+ if (providerTurn.status === 'unknown') {
743
+ throw new Error('Coordinator provider dispatch outcome is unknown and requires review');
744
+ }
745
+ if (providerTurn.status === 'responded') {
746
+ result = providerTurn.response;
747
+ } else {
748
+ result = await Promise.race([
693
749
  runtime.adapter.call({
694
- model: resolved.model,
695
- system: coordinatorSystemPrompt(language),
696
- messages: [{ role: 'user', content }],
697
- maxTokens: Math.min(
698
- resolveMaxOutputTokens(resolved.model, runtime.config),
699
- COORDINATOR_MAX_OUTPUT_TOKENS,
700
- ),
701
- effort: resolved.effort,
702
- effortSource: resolved.source,
703
- effortContext: { scenario: 'work-center-coordinator' },
750
+ ...requestBody,
704
751
  signal: abortController.signal,
752
+ onRequestStart: () => {
753
+ if (!this.store.dispatchCoordinatorProviderTurn(providerTurn.id, claim)) {
754
+ abortController.abort('work_center_coordinator_dispatch_fence_lost');
755
+ throw new Error('Coordinator provider turn lost its dispatch fence');
756
+ }
757
+ },
758
+ }).then(response => {
759
+ const persisted = this.store.respondCoordinatorProviderTurn(
760
+ providerTurn.id, providerTurn.requestHash, response, claim,
761
+ );
762
+ if (!persisted) throw new Error('Coordinator provider response lost its CAS fence');
763
+ providerTurn = persisted;
764
+ return response;
705
765
  }),
706
766
  new Promise((_, reject) => {
707
767
  abortController.signal.addEventListener('abort', () => {
@@ -709,6 +769,7 @@ export class WorkItemCoordinator {
709
769
  }, { once: true });
710
770
  }),
711
771
  ]);
772
+ }
712
773
  } catch (error) {
713
774
  if (abortController.signal.aborted || this.shuttingDown) throw error;
714
775
  throw coordinatorExecutionError(error, 'provider', language);
@@ -738,6 +799,9 @@ export class WorkItemCoordinator {
738
799
  break;
739
800
  } catch (error) {
740
801
  normalized = null;
802
+ if (providerTurn?.status === 'responded') {
803
+ this.store.rejectCoordinatorProviderTurn(providerTurn.id, error, started.fence.claim);
804
+ }
741
805
  if (abortController.signal.aborted || this.shuttingDown || error?.coordinatorClassified) {
742
806
  throw error;
743
807
  }
@@ -784,16 +848,19 @@ export class WorkItemCoordinator {
784
848
  mutation,
785
849
  attemptCount,
786
850
  }, started.fence);
787
- if (!detail) throw new Error('Work Center Coordinator turn is stale or already completed');
851
+ if (!detail) return this.store.getWorkItemDetail(started.detail.id);
788
852
  options.onUpdate?.(recovery ? 'coordinator.recovery_completed' : 'coordinator.turn_completed', detail);
789
853
  return detail;
790
854
  } catch (error) {
855
+ if (providerTurn?.status === 'responded') {
856
+ this.store.rejectCoordinatorProviderTurn(providerTurn.id, error, started.fence.claim);
857
+ }
791
858
  const detail = this.store.failCoordinatorTurn(started.turnId, error, started.fence);
792
859
  if (detail) {
793
860
  options.onUpdate?.('coordinator.turn_failed', detail);
794
861
  return detail;
795
862
  }
796
- throw error;
863
+ return this.store.getWorkItemDetail(started.detail.id);
797
864
  }
798
865
  }
799
866