@sema-agent/core 1.450.0 → 1.452.0

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 (57) hide show
  1. package/dist/agents/observer.js +3 -0
  2. package/dist/agents/subagent.js +51 -12
  3. package/dist/core/ask-question.js +5 -5
  4. package/dist/core/background-agent-store.d.ts +2 -0
  5. package/dist/core/background-agent-store.js +5 -1
  6. package/dist/core/exec-output-tail.d.ts +18 -1
  7. package/dist/core/exec-output-tail.js +38 -5
  8. package/dist/core/lsp-session.d.ts +1 -0
  9. package/dist/core/lsp-session.js +24 -6
  10. package/dist/core/lsp.d.ts +10 -0
  11. package/dist/core/lsp.js +63 -6
  12. package/dist/core/mailbox-store.d.ts +3 -2
  13. package/dist/core/mailbox-store.js +19 -4
  14. package/dist/core/memory.js +1 -1
  15. package/dist/core/runner/prepare-task.js +11 -1
  16. package/dist/core/runner/runtask.js +1 -1
  17. package/dist/core/session-reconcile.js +5 -2
  18. package/dist/core/task-notification.d.ts +1 -0
  19. package/dist/core/task-registry.d.ts +15 -9
  20. package/dist/core/task-registry.js +290 -53
  21. package/dist/core/tool-result-store.js +2 -2
  22. package/dist/core/tools.d.ts +5 -0
  23. package/dist/core/tools.js +3 -0
  24. package/dist/core/types.d.ts +2 -0
  25. package/dist/core/workflow-journal-store.d.ts +2 -0
  26. package/dist/core/workflow-journal-store.js +14 -0
  27. package/dist/engine/execution-env/node-execution-env.d.ts +1 -0
  28. package/dist/engine/execution-env/node-execution-env.js +130 -20
  29. package/dist/engine/lsp/node-lsp-manager.d.ts +3 -1
  30. package/dist/engine/lsp/node-lsp-manager.js +22 -5
  31. package/dist/engine/lsp/stdio-lsp-transport.d.ts +1 -1
  32. package/dist/engine/lsp/stdio-lsp-transport.js +17 -6
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -2
  35. package/dist/orchestration/run-workflow-tool.d.ts +2 -0
  36. package/dist/orchestration/run-workflow-tool.js +35 -6
  37. package/dist/orchestration/workflow.d.ts +9 -0
  38. package/dist/orchestration/workflow.js +80 -5
  39. package/dist/stores/cc/mailbox-store.js +6 -1
  40. package/dist/stores/file/background-agent-store.js +3 -2
  41. package/dist/stores/file/mailbox-store.d.ts +1 -1
  42. package/dist/stores/file/mailbox-store.js +9 -7
  43. package/dist/tools/fs/encoding.d.ts +5 -0
  44. package/dist/tools/fs/encoding.js +6 -0
  45. package/dist/tools/fs/index.js +184 -120
  46. package/dist/tools/fs/notebook.d.ts +43 -0
  47. package/dist/tools/fs/notebook.js +141 -0
  48. package/dist/tools/fs/repo-map.js +2 -2
  49. package/dist/tools/fs/search.js +141 -12
  50. package/dist/tools/gitea-issue.js +4 -2
  51. package/dist/tools/monitor.js +12 -8
  52. package/dist/tools/scheduler-tools.js +16 -16
  53. package/dist/tools/task-list.js +34 -12
  54. package/dist/tools/web.d.ts +2 -0
  55. package/dist/tools/web.js +105 -19
  56. package/dist/tools/worktree.js +14 -14
  57. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { Type } from "typebox";
2
- import { defineTool } from "../core/tools.js";
2
+ import { defineTool, errorResult } from "../core/tools.js";
3
3
  import { redactSecrets, redactHostLeaks, boundedRedactedSummary } from "../core/untrusted-egress.js";
4
4
  import { startWorkflow } from "./workflow.js";
5
5
  import { buildWorkflowPrimitives } from "./workflow-primitives.js";
@@ -27,8 +27,19 @@ function completionSummary(name, run) {
27
27
  const failed = run.agents.filter((a) => a.status === "failed").length;
28
28
  const secs = run.endedAt !== undefined ? Math.max(0, Math.round((run.endedAt - run.startedAt) / 1000)) : undefined;
29
29
  const elapsed = secs === undefined ? "" : secs >= 60 ? ` in ${Math.floor(secs / 60)}m${secs % 60}s` : ` in ${secs}s`;
30
+ const resumeClause = run.resume === undefined
31
+ ? ""
32
+ : run.resume.journalEntries === 0
33
+ ? "; resume found NO journal entries — nothing replayed, the whole script re-ran live"
34
+ : `; resume: replayed ${run.resume.replayed} of ${run.resume.journalEntries} journaled results`;
30
35
  return (`workflow${cleanName ? ` "${cleanName}"` : ""} completed${elapsed} — agents ${done}/${run.agents.length} done` +
31
- `${failed > 0 ? ` (${failed} failed)` : ""}; full result + per-agent rows via TaskOutput("${run.id}")`);
36
+ `${failed > 0 ? ` (${failed} failed)` : ""}${resumeClause}; full result + per-agent rows via TaskOutput("${run.id}")`);
37
+ }
38
+ function completionIdFromRejection(err) {
39
+ if (err === null || (typeof err !== "object" && typeof err !== "function"))
40
+ return undefined;
41
+ const run = err.workflowRun;
42
+ return run?.completionId;
32
43
  }
33
44
  function workflowUsageBlock(run) {
34
45
  const agents = run.agents;
@@ -38,19 +49,26 @@ function workflowUsageBlock(run) {
38
49
  agents_done: agents.filter((a) => a.status === "completed").length,
39
50
  agents_error: agents.filter((a) => a.status === "failed").length,
40
51
  agents_empty_result: agents.filter((a) => a.status === "completed" && (a.output === undefined || a.output === "")).length,
52
+ agents_replayed: agents.filter((a) => a.replayed === true).length,
53
+ ...(run.journalSkips !== undefined && run.journalSkips > 0 ? { journal_skipped: run.journalSkips } : {}),
54
+ ...(run.resume !== undefined ? { resume_journal_entries: run.resume.journalEntries } : {}),
41
55
  subagent_tokens: run.stats.tokens + run.stats.nested.tokens,
42
56
  tool_uses: agents.reduce((n, a) => n + (a.toolCalls ?? 0), 0),
43
57
  ...(run.endedAt !== undefined ? { duration_ms: run.endedAt - run.startedAt } : {}),
44
58
  };
45
59
  }
46
- function workflowDiagnostics(runId, journalRef) {
60
+ function workflowDiagnostics(runId, journalRef, resumeMiss) {
47
61
  const journalTeach = journalRef === undefined
48
62
  ? ""
49
63
  : journalRef.includes("://")
50
64
  ? ` Full journal: ${journalRef}.`
51
65
  : ` Full journal: ${journalRef} (a deployment data path — if Read is denied there, use the TaskOutput route above instead).`;
66
+ const resumeMissTeach = resumeMiss
67
+ ? ` NOTE: this was a resume, but the prior run's journal was not usable under this scope — verify the runId and that this deployment wires a journalStore; the results above are from a full live re-run.`
68
+ : "";
52
69
  return (`Per-agent rows (status / error / elapsed): TaskOutput({ task_id: "${runId}" }) — read them before assuming an empty or unexpected result.` +
53
70
  journalTeach +
71
+ resumeMissTeach +
54
72
  ` To continue after editing the script: re-invoke with resumeFromRunId: "${runId}" (completed agents replay from the journal; only new/changed calls run).`);
55
73
  }
56
74
  const SCRIPT_ERROR_CODES = new Set([
@@ -151,7 +169,7 @@ export async function createRunWorkflowTool(d) {
151
169
  const builtinsEnabled = d.builtinWorkflows !== false;
152
170
  const namedWorkflowSection = renderNamedWorkflowListing(await collectNamedWorkflowListings(d.scriptStore, builtinsEnabled));
153
171
  const sizeGuidelineSection = workflowSizeGuidelineSection(d.sizeGuideline ?? lim.sizeGuideline);
154
- const structuredError = (message) => ({ content: JSON.stringify({ error: message }) });
172
+ const structuredError = (message) => errorResult(JSON.stringify({ error: message }));
155
173
  return defineTool({
156
174
  name: RUN_WORKFLOW_TOOL_NAME,
157
175
  aliases: ["RunWorkflow", "run_workflow"],
@@ -460,6 +478,7 @@ export async function createRunWorkflowTool(d) {
460
478
  ...(notification.result !== undefined ? { result: redactHostLeaks(notification.result) } : {}),
461
479
  ...(notification.usage !== undefined ? { usage: notification.usage } : {}),
462
480
  ...(notification.diagnostics !== undefined ? { diagnostics: redactHostLeaks(notification.diagnostics) } : {}),
481
+ ...(notification.completionId !== undefined ? { completionId: notification.completionId } : {}),
463
482
  }))
464
483
  .catch(() => { });
465
484
  };
@@ -472,7 +491,8 @@ export async function createRunWorkflowTool(d) {
472
491
  summary: completionSummary(meta.name, res.run),
473
492
  result: boundedRedactedSummary(res.result, 4000),
474
493
  usage: workflowUsageBlock(res.run),
475
- diagnostics: workflowDiagnostics(runId, d.journalStore?.locator?.(runId, res.run.scope)),
494
+ diagnostics: workflowDiagnostics(runId, d.journalStore?.locator?.(runId, res.run.scope), res.run.resume?.journalEntries === 0),
495
+ completionId: res.run.completionId,
476
496
  }), (err) => fire({
477
497
  task_id: runId,
478
498
  task_type: "workflow",
@@ -480,6 +500,7 @@ export async function createRunWorkflowTool(d) {
480
500
  status: "failed",
481
501
  summary: failureSummary(err, runId),
482
502
  diagnostics: workflowDiagnostics(runId, undefined),
503
+ ...(completionIdFromRejection(err) !== undefined ? { completionId: completionIdFromRejection(err) } : {}),
483
504
  }))
484
505
  .catch(() => { });
485
506
  }
@@ -504,7 +525,8 @@ export async function createRunWorkflowTool(d) {
504
525
  summary: completionSummary(meta.name, res.run),
505
526
  result: boundedRedactedSummary(res.result, 4000),
506
527
  usage: workflowUsageBlock(res.run),
507
- diagnostics: workflowDiagnostics(runId, d.journalStore?.locator?.(runId, res.run.scope)),
528
+ diagnostics: workflowDiagnostics(runId, d.journalStore?.locator?.(runId, res.run.scope), res.run.resume?.journalEntries === 0),
529
+ completionId: res.run.completionId,
508
530
  }), (err) => fire({
509
531
  task_id: runId,
510
532
  task_type: "workflow",
@@ -512,6 +534,7 @@ export async function createRunWorkflowTool(d) {
512
534
  status: "failed",
513
535
  summary: failureSummary(err, runId),
514
536
  diagnostics: workflowDiagnostics(runId, undefined),
537
+ ...(completionIdFromRejection(err) !== undefined ? { completionId: completionIdFromRejection(err) } : {}),
515
538
  }))
516
539
  .catch(() => { });
517
540
  }
@@ -524,10 +547,16 @@ export async function createRunWorkflowTool(d) {
524
547
  ...(persistedScriptPath !== undefined ? { scriptPath: persistedScriptPath } : {}),
525
548
  note: (() => {
526
549
  const pollExpr = d.taskRegistry ? `TaskOutput({ task_id: "${runId}" })` : undefined;
550
+ const blockingPollExpr = d.taskRegistry ? `TaskOutput({ task_id: "${runId}", block: true })` : undefined;
527
551
  const journalRef = d.journalStore?.locator?.(runId, taskScope ?? d.scope ?? "");
528
552
  const carries = pollExpr
529
553
  ? ` A terminal ${pollExpr} reply carries its result AND per-agent rows — read them${journalRef !== undefined ? ` (full journal: ${journalRef})` : ""} before assuming an empty or unexpected result.`
530
554
  : "";
555
+ if (d.oneShot === true) {
556
+ return blockingPollExpr
557
+ ? `This is a ONE-SHOT submission (e.g. headless \`-p\`) — there is no later turn for a background notification to land in, so do NOT end your turn expecting one. Actively wait instead: ${blockingPollExpr}. If it is still running after the wait, wait again (bounded) rather than ending the turn, or write out your best available answer now if you are near your own time budget.${carries}`
558
+ : "This is a one-shot submission (e.g. headless `-p`) and no poll path is available — the workflow's outcome may not be recoverable after this turn ends.";
559
+ }
531
560
  if (d.notifier) {
532
561
  return `The workflow runs asynchronously. You will be NOTIFIED when it completes (the notification carries the result). End your turn and wait — do not poll unless the user asks for progress.${carries}`;
533
562
  }
@@ -93,6 +93,15 @@ export interface WorkflowRun {
93
93
  error?: string;
94
94
  result?: string;
95
95
  resultFull?: string;
96
+ completionId?: string;
97
+ resume?: {
98
+ fromRunId: string;
99
+ journalEntries: number;
100
+ replayed: number;
101
+ divergedAtOrdinal?: number;
102
+ divergedReason?: string;
103
+ };
104
+ journalSkips?: number;
96
105
  }
97
106
  export type WorkflowEvent = {
98
107
  type: "run_start";
@@ -2,10 +2,11 @@ import { resolveModelDisplayLabel } from "../core/roles.js";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { availableParallelism } from "node:os";
5
+ import { uuidv7 } from "../internal/harness.js";
5
6
  import { builtinAgentDefinitions } from "../agents/builtin-agents.js";
6
7
  import { GENERAL_PURPOSE_SUBAGENT_TYPE } from "../agents/subagent.js";
7
8
  import { combinePolicies, createAllowDenyPolicy } from "../core/tool-policy.js";
8
- import { callKeyOrdinal } from "../core/workflow-journal-store.js";
9
+ import { callKeyOrdinal, oversizeJournalResult, journalOversizeTombstone, JOURNAL_OVERSIZE_ERROR_CODE, MAX_JOURNAL_RESULT_BYTES } from "../core/workflow-journal-store.js";
9
10
  import { isWorkflowRunActive, closeWorkflowChannel, markWorkflowActive, publishWorkflowEvent } from "./workflow-observe.js";
10
11
  import { isDurablePause, mapNestedSuspend } from "../agents/suspend-guard.js";
11
12
  import { boundInputHashOf } from "../core/canonical-json.js";
@@ -551,13 +552,39 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
551
552
  });
552
553
  };
553
554
  let journalTail = Promise.resolve();
554
- const journalAppend = async (callKey, result) => {
555
+ const journalAppend = async (callKey, result, label) => {
555
556
  const dbg = typeof process !== "undefined" && process.env?.SEMA_DEBUG_WORKFLOW_JOURNAL === "1";
556
557
  if (!journalStore) {
557
558
  if (dbg)
558
559
  console.error(`[sema:wf-journal] runId=${runId} callKey=${callKey} SKIP (no journalStore on this run)`);
559
560
  return;
560
561
  }
562
+ let serialized;
563
+ try {
564
+ serialized = JSON.stringify(result);
565
+ }
566
+ catch {
567
+ serialized = undefined;
568
+ }
569
+ if (serialized !== undefined && oversizeJournalResult(serialized)) {
570
+ const bytes = Buffer.byteLength(serialized, "utf8");
571
+ if (!finalized)
572
+ run.journalSkips = (run.journalSkips ?? 0) + 1;
573
+ emitRunLog(`resume-journal: agent #${callKeyOrdinal(callKey)}${label !== undefined ? ` "${label.slice(0, 80)}"` : ""} result is ${bytes} bytes, ` +
574
+ `over the ${MAX_JOURNAL_RESULT_BYTES}-byte per-entry cap — NOT cached. A resume from this run re-runs this agent and everything after it live.`);
575
+ const tombstone = journalStore.append(runId, scope, { callKey, result: journalOversizeTombstone(result, bytes) });
576
+ journalTail = journalTail.then(() => tombstone).catch(() => undefined);
577
+ try {
578
+ await tombstone;
579
+ if (dbg)
580
+ console.error(`[sema:wf-journal] runId=${runId} callKey=${callKey} OVERSIZE-TOMBSTONE (${bytes} bytes)`);
581
+ }
582
+ catch (err) {
583
+ if (dbg)
584
+ console.error(`[sema:wf-journal] runId=${runId} callKey=${callKey} TOMBSTONE-ERROR ${err instanceof Error ? err.message : String(err)}`);
585
+ }
586
+ return;
587
+ }
561
588
  const p = journalStore.append(runId, scope, { callKey, result });
562
589
  journalTail = journalTail.then(() => p).catch(() => undefined);
563
590
  try {
@@ -623,6 +650,17 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
623
650
  : message;
624
651
  emit({ type: "log", runId, message: capped, ts: now() });
625
652
  };
653
+ let divergenceNoted = false;
654
+ const noteDivergence = (ordinal, reason) => {
655
+ if (opts.resumeFromRunId === undefined || divergenceNoted)
656
+ return;
657
+ divergenceNoted = true;
658
+ if (run.resume) {
659
+ run.resume.divergedAtOrdinal = ordinal;
660
+ run.resume.divergedReason = reason;
661
+ }
662
+ emitRunLog(`resume: replay stopped at agent #${ordinal} (${reason}) — this call and every later call run live.`);
663
+ };
626
664
  const settleAgentError = (rec, result, label, rawOutput) => {
627
665
  if (result.status !== "completed") {
628
666
  if (result.errorCode !== undefined)
@@ -695,6 +733,8 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
695
733
  ...(rs.toolCalls !== undefined ? { toolCalls: rs.toolCalls } : {}),
696
734
  output: cachedOutput,
697
735
  };
736
+ if (run.resume)
737
+ run.resume.replayed += 1;
698
738
  run.agents.push(replayRec);
699
739
  if (phaseInstance)
700
740
  agentPhaseOf.set(replayRec, phaseInstance);
@@ -710,6 +750,17 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
710
750
  }
711
751
  return r;
712
752
  }
753
+ noteDivergence(run.agents.length, cached === undefined
754
+ ? run.resume?.journalEntries === 0
755
+ ? "the journal loaded no entries"
756
+ : run.agents.length >= replayByOrdinal.length
757
+ ? "a new call past the journaled prefix — the prior run ended before this point (the intended extend-the-script resume shape)"
758
+ : "no journal entry at this ordinal (never recorded, e.g. an oversize result under a pre-tombstone engine, or dropped)"
759
+ : cached.callKey !== callKey
760
+ ? "the call key changed — the script or its args differ here"
761
+ : cached.result.errorCode === JOURNAL_OVERSIZE_ERROR_CODE
762
+ ? "the prior result exceeded the journal size cap and was never cached"
763
+ : `the journaled result was ${cached.result.status}, not completed`);
713
764
  diverged = true;
714
765
  }
715
766
  if (budgetTotal !== null && spent() >= budgetTotal) {
@@ -963,7 +1014,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
963
1014
  status: "failed",
964
1015
  result: boundedRedactedSummary(err instanceof Error ? err.message : String(err), 500),
965
1016
  stats: { turns: 0, tokens: 0, costMicroUsd: 0 },
966
- }).catch(() => undefined);
1017
+ }, label).catch(() => undefined);
967
1018
  bceTerminal(callKey, "failed", rec.output ?? (err instanceof Error ? err.message : String(err)), rec.sessionId, rec.stats);
968
1019
  }
969
1020
  throw err;
@@ -992,7 +1043,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
992
1043
  emit({ type: "agent_end", runId, label, phase, ...(groupId !== undefined ? { groupId } : {}), status: rec.status, output, ...(rec.errorCode !== undefined ? { errorCode: rec.errorCode } : {}), ...(result.stats.toolCalls !== undefined ? { toolCalls: result.stats.toolCalls } : {}), ts: rec.endedAt });
993
1044
  void persist("update");
994
1045
  bceTerminal(callKey, rec.status === "completed" ? "completed" : "failed", output, result.sessionId || undefined, rec.stats);
995
- await journalAppend(callKey, result);
1046
+ await journalAppend(callKey, result, label);
996
1047
  if (agentOpts.schema && result.status === "completed" && result.structuredOutput === undefined) {
997
1048
  throw new WorkflowAgentSchemaError(label, result);
998
1049
  }
@@ -1026,6 +1077,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1026
1077
  const callKey = workflowAgentCallKey(run.agents.length, specForIdentity, agentOpts);
1027
1078
  const prompt = boundedRedactedSummary(spec.systemPrompt ? `${spec.systemPrompt}\n\n${spec.objective}` : spec.objective, MAX_TRANSCRIPT_CHARS);
1028
1079
  const model = workflowModelLabel(specForIdentity);
1080
+ noteDivergence(run.agents.length, "ctx.agentStream results are never replayed");
1029
1081
  diverged = true;
1030
1082
  const { tail: activityTail, onActivity } = makeActivityCapture(callKey, label, groupId);
1031
1083
  const rec = { label, callKey, ...(groupId !== undefined ? { groupId } : {}), phase, prompt, ...(model !== undefined ? { model } : {}), status: "running", queuedAt: now() };
@@ -1189,7 +1241,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1189
1241
  emit({ type: "agent_end", runId, label, phase, ...(groupId !== undefined ? { groupId } : {}), status: rec.status, output, ...(rec.errorCode !== undefined ? { errorCode: rec.errorCode } : {}), ...(result.stats.toolCalls !== undefined ? { toolCalls: result.stats.toolCalls } : {}), ts: rec.endedAt });
1190
1242
  void persist("update");
1191
1243
  bceTerminal(callKey, rec.status === "completed" ? "completed" : "failed", output, result.sessionId || undefined, rec.stats);
1192
- await journalAppend(callKey, result).catch(() => undefined);
1244
+ await journalAppend(callKey, result, label).catch(() => undefined);
1193
1245
  if (agentOpts.schema && result.status === "completed" && result.structuredOutput === undefined) {
1194
1246
  throw new WorkflowAgentSchemaError(label, result);
1195
1247
  }
@@ -1239,6 +1291,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1239
1291
  const fanOutOpts = trailing !== undefined && typeof trailing !== "function" ? trailing : undefined;
1240
1292
  const stages = (fanOutOpts !== undefined ? stagesAndOpts.slice(0, -1) : stagesAndOpts);
1241
1293
  const errorsOut = fanOutOpts?.errors;
1294
+ noteDivergence(run.agents.length, "ctx.pipeline ordinals are latency-dependent, so its calls always run live on a resume");
1242
1295
  diverged = true;
1243
1296
  const settled = await Promise.all(items.map((item, index) => stages
1244
1297
  .reduce((acc, stage) => acc.then((prev) => stage(prev, item, index)), Promise.resolve(item))
@@ -1370,6 +1423,19 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1370
1423
  const entries = await journalStore.load(opts.resumeFromRunId, scope);
1371
1424
  for (const e of entries)
1372
1425
  replayByOrdinal[callKeyOrdinal(e.callKey)] = e;
1426
+ run.resume = {
1427
+ fromRunId: opts.resumeFromRunId.replace(/[^A-Za-z0-9_.:-]/g, "").slice(0, 64),
1428
+ journalEntries: entries.length,
1429
+ replayed: 0,
1430
+ };
1431
+ if (entries.length === 0) {
1432
+ emitRunLog("resume: the prior run's journal yielded NO entries under this run's scope — nothing will replay; the entire script runs live. " +
1433
+ "The prior run id may be unknown or pruned, recorded under a different scope, or its journal unreadable.");
1434
+ }
1435
+ else {
1436
+ emitRunLog(`resume: loaded ${entries.length} journaled result${entries.length === 1 ? "" : "s"} — the longest unchanged prefix replays; the first changed/new call and everything after run live.`);
1437
+ }
1438
+ void persist("update");
1373
1439
  }
1374
1440
  return fn(ctx);
1375
1441
  };
@@ -1416,6 +1482,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1416
1482
  if (run.result !== fullResult)
1417
1483
  run.resultFull = fullResult;
1418
1484
  run.status = "completed";
1485
+ run.completionId ??= uuidv7();
1419
1486
  run.endedAt = now();
1420
1487
  closeAbandonedAgents(run.endedAt);
1421
1488
  restampPhaseFailures();
@@ -1431,6 +1498,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1431
1498
  settleActiveUsageBeats();
1432
1499
  closeOpenMarker("failed");
1433
1500
  run.status = "failed";
1501
+ run.completionId ??= uuidv7();
1434
1502
  run.endedAt = now();
1435
1503
  run.error = err instanceof Error ? err.message : String(err);
1436
1504
  closeAbandonedAgents(run.endedAt);
@@ -1440,6 +1508,13 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1440
1508
  run.agentFailures = failedRunFailures;
1441
1509
  emit({ type: "run_end", runId, status: "failed", ...(failedRunFailures > 0 ? { agentFailures: failedRunFailures } : {}), ts: run.endedAt });
1442
1510
  await persist("update");
1511
+ if (err !== null && (typeof err === "object" || typeof err === "function")) {
1512
+ try {
1513
+ Object.defineProperty(err, "workflowRun", { value: run, enumerable: false, configurable: true });
1514
+ }
1515
+ catch {
1516
+ }
1517
+ }
1443
1518
  throw err;
1444
1519
  }
1445
1520
  finally {
@@ -89,9 +89,12 @@ export function createCcFileMailboxStore(opts) {
89
89
  leases.set(key, { owner, expiresAt: t + ttlMs, maxSeq });
90
90
  return Promise.resolve({ maxSeq, messages: pending });
91
91
  },
92
- ack: (scope, handle, upToSeq) => {
92
+ ack: (scope, handle, owner, upToSeq) => {
93
93
  requireDefaultScope(scope);
94
94
  const path = inboxPath(handle);
95
+ const held = leases.get(path);
96
+ if (held === undefined || held.owner !== owner)
97
+ return Promise.resolve();
95
98
  if (!existsSync(path))
96
99
  return Promise.resolve();
97
100
  return withCcLock(path, () => {
@@ -105,6 +108,8 @@ export function createCcFileMailboxStore(opts) {
105
108
  }
106
109
  if (changed)
107
110
  saveBox(path, box);
111
+ if (held.maxSeq <= upToSeq)
112
+ leases.delete(path);
108
113
  });
109
114
  },
110
115
  releaseLease: (scope, handle, owner) => {
@@ -1,5 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { BackgroundAgentStoreError, } from "../../core/background-agent-store.js";
2
+ import { BackgroundAgentStoreError, STALE_RUNNING_REAP_ATTRIBUTION, } from "../../core/background-agent-store.js";
3
3
  import { canonicalStoreKey, AppendLog, atomicWriteFile, ensureDir, readJsonlRecords } from "./fs-atomic.js";
4
4
  const sharedAgentDirs = new Map();
5
5
  export class FileBackgroundAgentStore {
@@ -205,7 +205,8 @@ export class FileBackgroundAgentStore {
205
205
  const next = structuredClone(live);
206
206
  next.status = "failed";
207
207
  next.stoppedBy = "system";
208
- next.summary = next.summary ?? "host process interrupted (stale running row reaped)";
208
+ next.summary = next.summary ?? STALE_RUNNING_REAP_ATTRIBUTION;
209
+ next.error = next.error ?? STALE_RUNNING_REAP_ATTRIBUTION;
209
210
  next.settledAt = now;
210
211
  next.updatedAt = now;
211
212
  next.rev = live.rev + 1;
@@ -21,7 +21,7 @@ export declare class FileMailboxStore implements MailboxStore {
21
21
  sentAt: number;
22
22
  }): Promise<number>;
23
23
  claimLease(scope: string, handle: string, owner: string, ttlMs: number, now?: number): Promise<MailboxLease | null>;
24
- ack(scope: string, handle: string, upToSeq: number): Promise<void>;
24
+ ack(scope: string, handle: string, owner: string, upToSeq: number): Promise<void>;
25
25
  releaseLease(scope: string, handle: string, owner: string): Promise<void>;
26
26
  peekCount(scope: string, handle: string): Promise<number>;
27
27
  drop(scope: string, handle: string): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { join, resolve, sep } from "node:path";
2
2
  import { existsSync, realpathSync } from "node:fs";
3
- import {} from "../../core/mailbox-store.js";
3
+ import { newestSentAt, } from "../../core/mailbox-store.js";
4
4
  import { AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords, sanitizeScope, sanitizePathComponent } from "./fs-atomic.js";
5
5
  function realpathSyncSafe(p) {
6
6
  try {
@@ -86,7 +86,7 @@ export class FileMailboxStore {
86
86
  lines.push(JSON.stringify({ t: "lease", ...b.lease }));
87
87
  b.log.closeForSwap();
88
88
  atomicWriteFile(this.tmpDir, path, lines.length ? `${lines.join("\n")}\n` : "");
89
- b.events = lines.length;
89
+ b.events = 0;
90
90
  }
91
91
  async append(scope, handle, msg) {
92
92
  if (scope === undefined || scope === "")
@@ -111,9 +111,11 @@ export class FileMailboxStore {
111
111
  return { messages: b.messages.map((m) => ({ ...m })), maxSeq };
112
112
  });
113
113
  }
114
- async ack(scope, handle, upToSeq) {
114
+ async ack(scope, handle, owner, upToSeq) {
115
115
  return withPathLock(this.lockKey(scope, handle), () => {
116
116
  const b = this.load(scope, handle);
117
+ if (b.lease === undefined || b.lease.owner !== owner)
118
+ return;
117
119
  this.commit(b, this.boxPath(scope, handle), { t: "ack", upToSeq });
118
120
  });
119
121
  }
@@ -160,15 +162,15 @@ export class FileMailboxStore {
160
162
  for (const [key, b] of [...sharedBoxes]) {
161
163
  if (!b.path.startsWith(prefix))
162
164
  continue;
163
- const seen = b.messages[b.messages.length - 1];
164
- if (seen === undefined || seen.sentAt >= now - maxAgeMs)
165
+ const seen = newestSentAt(b.messages);
166
+ if (seen === undefined || seen >= now - maxAgeMs)
165
167
  continue;
166
168
  const swept = await withPathLock(key, () => {
167
169
  const live = sharedBoxes.get(key);
168
170
  if (live === undefined || live !== b)
169
171
  return false;
170
- const newest = b.messages[b.messages.length - 1];
171
- if (newest === undefined || newest.sentAt >= now - maxAgeMs)
172
+ const newest = newestSentAt(b.messages);
173
+ if (newest === undefined || newest >= now - maxAgeMs)
172
174
  return false;
173
175
  b.messages = [];
174
176
  delete b.lease;
@@ -13,3 +13,8 @@ export declare function detectFileEncoding(bytes: Uint8Array): DetectedFileEncod
13
13
  export declare function decodeTextBytes(bytes: Uint8Array): DecodedTextFile;
14
14
  export declare function encodeTextForFile(text: string, encoding: DetectedFileEncoding, endings: DetectedLineEndings | "preserve"): string | Uint8Array;
15
15
  export declare function normalizeEditText(s: string): string;
16
+ export declare function splitLeadingBom(text: string): {
17
+ hadBom: boolean;
18
+ text: string;
19
+ };
20
+ export declare function normalizeFileText(s: string): string;
@@ -41,3 +41,9 @@ export function encodeTextForFile(text, encoding, endings) {
41
41
  export function normalizeEditText(s) {
42
42
  return s.replaceAll("\r\n", "\n");
43
43
  }
44
+ export function splitLeadingBom(text) {
45
+ return text.charCodeAt(0) === 0xfeff ? { hadBom: true, text: text.slice(1) } : { hadBom: false, text };
46
+ }
47
+ export function normalizeFileText(s) {
48
+ return normalizeEditText(splitLeadingBom(s).text);
49
+ }