@yeaft/webchat-agent 1.0.296 → 1.0.299

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/local-runtime/server/auth/login.js +2 -0
  2. package/local-runtime/server/db/connection.js +13 -0
  3. package/local-runtime/server/db/session-db.js +3 -0
  4. package/local-runtime/server/db/yeaft-session-db.js +9 -0
  5. package/local-runtime/server/handlers/agent-output.js +27 -0
  6. package/local-runtime/server/handlers/client-conversation.js +2 -2
  7. package/local-runtime/server/handlers/client-work-center.js +4 -3
  8. package/local-runtime/server/routes/auth-routes.js +8 -1
  9. package/local-runtime/server/routes/user-routes.js +1 -0
  10. package/local-runtime/server/session-catalog.js +4 -7
  11. package/local-runtime/version.json +1 -1
  12. package/local-runtime/web/app.bundle.js +110 -95
  13. package/local-runtime/web/app.bundle.js.gz +0 -0
  14. package/local-runtime/web/index.html +2 -2
  15. package/local-runtime/web/style.bundle.css +1 -1
  16. package/local-runtime/web/style.bundle.css.gz +0 -0
  17. package/package.json +1 -1
  18. package/yeaft/engine.js +3 -0
  19. package/yeaft/sessions/session-crud.js +9 -6
  20. package/yeaft/sessions/session-store.js +1 -0
  21. package/yeaft/tools/file-edit.js +8 -6
  22. package/yeaft/tools/file-write.js +2 -2
  23. package/yeaft/tools/registry.js +28 -10
  24. package/yeaft/tools/types.js +4 -0
  25. package/yeaft/web-bridge.js +10 -2
  26. package/yeaft/work-center/coordinator.js +37 -10
  27. package/yeaft/work-center/durable-model.js +59 -3
  28. package/yeaft/work-center/projection.js +2 -0
  29. package/yeaft/work-center/runner.js +14 -5
  30. package/yeaft/work-center/service.js +59 -16
  31. package/yeaft/work-center/store.js +242 -53
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.296",
3
+ "version": "1.0.299",
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
@@ -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
- 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
  },
@@ -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),
@@ -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
- 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);
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(started, { text, recovery: true, options });
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 providerTurn = this.store.prepareCoordinatorProviderTurn(
721
- started.detail.id, started.turnId, attemptCount, requestBody,
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) throw new Error('Work Center Coordinator turn is stale or already completed');
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
- throw error;
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 = 33;
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
- function migrateEngineTurnStatusContract(db, now) {
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
- if (/dispatching/.test(sql) && /responded/.test(sql) && /legacy_imported/.test(sql)) return;
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
- operation?.complete('applied', { outputHash: hashMainlineSnapshot({ output: String(output || '') }) });
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.createOperation({
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) => {