la-machina-engine 0.21.0 → 0.22.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.
package/dist/index.cjs CHANGED
@@ -2291,39 +2291,7 @@ var AISdkAdapter = class {
2291
2291
  ...request.toolChoice === "required" ? { toolChoice: "required" } : {},
2292
2292
  maxRetries: this.options.maxRetries ?? 2
2293
2293
  });
2294
- for await (const event of result.fullStream) {
2295
- switch (event.type) {
2296
- case "text-delta":
2297
- yield {
2298
- type: "text",
2299
- index: 0,
2300
- text: event.text ?? event.textDelta ?? ""
2301
- };
2302
- break;
2303
- case "tool-call":
2304
- yield {
2305
- type: "tool_use",
2306
- index: 0,
2307
- id: event.toolCallId ?? "",
2308
- name: event.toolName ?? "",
2309
- // AI SDK v4: tool args are in `event.input` (not `event.args`)
2310
- input: event.input ?? event.args ?? {}
2311
- };
2312
- break;
2313
- case "finish": {
2314
- const usage = event.totalUsage ?? event.usage ?? {};
2315
- yield {
2316
- type: "message_stop",
2317
- stopReason: mapFinishReason(event.finishReason),
2318
- usage: {
2319
- input: usage.inputTokens ?? usage.promptTokens ?? 0,
2320
- output: usage.outputTokens ?? usage.completionTokens ?? 0
2321
- }
2322
- };
2323
- break;
2324
- }
2325
- }
2326
- }
2294
+ yield* normalizeAiSdkStream(result.fullStream);
2327
2295
  }
2328
2296
  async getModel() {
2329
2297
  if (this.model !== null) return this.model;
@@ -2368,6 +2336,53 @@ var AISdkAdapter = class {
2368
2336
  return this.model;
2369
2337
  }
2370
2338
  };
2339
+ async function* normalizeAiSdkStream(fullStream) {
2340
+ let sawFinish = false;
2341
+ for await (const event of fullStream) {
2342
+ switch (event.type) {
2343
+ case "text-delta":
2344
+ yield {
2345
+ type: "text",
2346
+ index: 0,
2347
+ text: event.text ?? event.textDelta ?? ""
2348
+ };
2349
+ break;
2350
+ case "tool-call":
2351
+ yield {
2352
+ type: "tool_use",
2353
+ index: 0,
2354
+ id: event.toolCallId ?? "",
2355
+ name: event.toolName ?? "",
2356
+ // AI SDK v4: tool args are in `event.input` (not `event.args`)
2357
+ input: event.input ?? event.args ?? {}
2358
+ };
2359
+ break;
2360
+ case "error": {
2361
+ const status = extractStatus(event.error);
2362
+ const message = errorMessage(event.error);
2363
+ throw new ApiError(status !== null ? `${status} ${message}` : message, status);
2364
+ }
2365
+ case "finish": {
2366
+ sawFinish = true;
2367
+ const usage = event.totalUsage ?? event.usage ?? {};
2368
+ yield {
2369
+ type: "message_stop",
2370
+ stopReason: mapFinishReason(event.finishReason),
2371
+ usage: {
2372
+ input: usage.inputTokens ?? usage.promptTokens ?? 0,
2373
+ output: usage.outputTokens ?? usage.completionTokens ?? 0
2374
+ }
2375
+ };
2376
+ break;
2377
+ }
2378
+ }
2379
+ }
2380
+ if (!sawFinish) {
2381
+ throw new StreamIncompleteError(
2382
+ "AI SDK stream ended without a finish event; assistant output is partial"
2383
+ );
2384
+ }
2385
+ }
2371
2386
  function mapFinishReason(reason) {
2372
2387
  switch (reason) {
2373
2388
  case "stop":
@@ -2380,6 +2395,23 @@ function mapFinishReason(reason) {
2380
2395
  return "end_turn";
2381
2396
  }
2382
2397
  }
2398
+ function errorMessage(err) {
2399
+ if (err instanceof Error) return err.message;
2400
+ if (typeof err === "string") return err;
2401
+ try {
2402
+ return JSON.stringify(err);
2403
+ } catch {
2404
+ return String(err);
2405
+ }
2406
+ }
2407
+ function extractStatus(err) {
2408
+ if (err !== null && typeof err === "object") {
2409
+ const o = err;
2410
+ const s = o.statusCode ?? o.status;
2411
+ if (typeof s === "number") return s;
2412
+ }
2413
+ return null;
2414
+ }
2383
2415
 
2384
2416
  // src/model/factory.ts
2385
2417
  function createModelAdapter(config, options = {}) {
@@ -2858,7 +2890,28 @@ function normalizeMessages(messages) {
2858
2890
  }
2859
2891
  return ensureToolResultPairing(fixed);
2860
2892
  }
2861
- function ensureToolResultPairing(messages) {
2893
+ function ensureToolResultPairing(input) {
2894
+ const allToolUseIds = /* @__PURE__ */ new Set();
2895
+ for (const msg of input) {
2896
+ if (!Array.isArray(msg.content)) continue;
2897
+ for (const b of msg.content) {
2898
+ const block = b;
2899
+ if (block.type === "tool_use" && block.id) allToolUseIds.add(block.id);
2900
+ }
2901
+ }
2902
+ const isOrphanResult = (b) => {
2903
+ const x = b;
2904
+ return x.type === "tool_result" && x.tool_use_id !== void 0 && !allToolUseIds.has(x.tool_use_id);
2905
+ };
2906
+ const messages = [];
2907
+ for (const msg of input) {
2908
+ if (!Array.isArray(msg.content) || !msg.content.some(isOrphanResult)) {
2909
+ messages.push(msg);
2910
+ continue;
2911
+ }
2912
+ const filtered = msg.content.filter((b) => !isOrphanResult(b));
2913
+ if (filtered.length > 0) messages.push({ ...msg, content: filtered });
2914
+ }
2862
2915
  const pendingToolUseIds = /* @__PURE__ */ new Set();
2863
2916
  for (const msg of messages) {
2864
2917
  if (!Array.isArray(msg.content)) continue;
@@ -2899,15 +2952,45 @@ init_cjs_shims();
2899
2952
 
2900
2953
  // src/compact/dropMiddle.ts
2901
2954
  init_cjs_shims();
2955
+
2956
+ // src/compact/grouping.ts
2957
+ init_cjs_shims();
2958
+ function groupByRound(messages) {
2959
+ if (messages.length === 0) return [];
2960
+ const groups = [];
2961
+ let current = [];
2962
+ for (const msg of messages) {
2963
+ if (msg.role === "assistant" && current.length > 0) {
2964
+ groups.push(current);
2965
+ current = [];
2966
+ }
2967
+ current.push(msg);
2968
+ }
2969
+ if (current.length > 0) groups.push(current);
2970
+ return groups;
2971
+ }
2972
+ function trailingRounds(messages, minMessages) {
2973
+ if (messages.length === 0) return { tail: [], start: 0 };
2974
+ const groups = groupByRound(messages);
2975
+ let count = 0;
2976
+ let g = groups.length;
2977
+ while (g > 0 && count < minMessages) {
2978
+ g--;
2979
+ count += groups[g].length;
2980
+ }
2981
+ const tail = groups.slice(g).flat();
2982
+ return { tail, start: messages.length - tail.length };
2983
+ }
2984
+
2985
+ // src/compact/dropMiddle.ts
2902
2986
  function dropMiddle(messages, keepLast) {
2903
2987
  const firstUserIndex = messages.findIndex((m) => m.role === "user");
2904
2988
  if (firstUserIndex === -1) {
2905
2989
  return { messages: [...messages], strategy: "drop-middle", dropped: 0 };
2906
2990
  }
2907
- const tailStart = Math.max(messages.length - keepLast, firstUserIndex + 1);
2908
- const tail = messages.slice(tailStart);
2909
- const middleStart = firstUserIndex + 1;
2910
- const droppedCount = Math.max(0, tailStart - middleStart);
2991
+ const body = messages.slice(firstUserIndex + 1);
2992
+ const { tail } = trailingRounds(body, keepLast);
2993
+ const droppedCount = body.length - tail.length;
2911
2994
  if (droppedCount === 0) {
2912
2995
  return { messages: [...messages], strategy: "drop-middle", dropped: 0 };
2913
2996
  }
@@ -3047,15 +3130,16 @@ REMINDER: Do NOT call any tools. Respond with plain text only \u2014 an <analysi
3047
3130
  }
3048
3131
 
3049
3132
  // src/compact/summarize.ts
3133
+ var SUMMARY_TIMEOUT_MS = 6e4;
3050
3134
  async function summarizeCompact(options) {
3051
- const { messages, config, client, system } = options;
3135
+ const { messages, config, client, system, signal } = options;
3052
3136
  const firstUserIndex = messages.findIndex((m) => m.role === "user");
3053
3137
  if (firstUserIndex === -1) {
3054
3138
  return { messages: [...messages], strategy: "summarize", dropped: 0 };
3055
3139
  }
3056
- const tailStart = Math.max(messages.length - config.keepLast, firstUserIndex + 1);
3057
- const tail = messages.slice(tailStart);
3058
- const middle = messages.slice(firstUserIndex + 1, tailStart);
3140
+ const body = messages.slice(firstUserIndex + 1);
3141
+ const { tail } = trailingRounds(body, config.keepLast);
3142
+ const middle = body.slice(0, body.length - tail.length);
3059
3143
  const droppedCount = middle.length;
3060
3144
  if (droppedCount === 0) {
3061
3145
  return { messages: [...messages], strategy: "summarize", dropped: 0 };
@@ -3070,7 +3154,13 @@ async function summarizeCompact(options) {
3070
3154
  ];
3071
3155
  let summaryText;
3072
3156
  try {
3073
- summaryText = await generateSummary(client, system, summaryMessages, config.summaryMaxTokens);
3157
+ summaryText = await generateSummary(
3158
+ client,
3159
+ system,
3160
+ summaryMessages,
3161
+ config.summaryMaxTokens,
3162
+ signal
3163
+ );
3074
3164
  } catch {
3075
3165
  const marker = {
3076
3166
  role: "user",
@@ -3095,13 +3185,16 @@ ${summaryText}`
3095
3185
  summaryLength: summaryText.length
3096
3186
  };
3097
3187
  }
3098
- async function generateSummary(client, system, messages, maxTokens) {
3188
+ async function generateSummary(client, system, messages, maxTokens, signal) {
3099
3189
  const pieces = [];
3190
+ const timeoutSignal = AbortSignal.timeout(SUMMARY_TIMEOUT_MS);
3191
+ const abortSignal = signal !== void 0 ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
3100
3192
  for await (const event of client.streamMessage({
3101
3193
  messages,
3102
3194
  system,
3103
3195
  maxTokens,
3104
- temperature: 0
3196
+ temperature: 0,
3197
+ abortSignal
3105
3198
  })) {
3106
3199
  if (event.type === "text") {
3107
3200
  pieces.push(event.text);
@@ -3117,8 +3210,8 @@ async function generateSummary(client, system, messages, maxTokens) {
3117
3210
 
3118
3211
  // src/compact/compactor.ts
3119
3212
  async function compactIfNeeded(options) {
3120
- const { messages, usage, contextLimit, config, client, system } = options;
3121
- const used = usage.input + usage.output + (usage.cacheReadInput ?? 0) + (usage.cacheCreationInput ?? 0);
3213
+ const { messages, usage, contextLimit, config, client, system, signal, liveContextTokens } = options;
3214
+ const used = liveContextTokens !== void 0 && liveContextTokens > 0 ? liveContextTokens : usage.input + usage.output + (usage.cacheReadInput ?? 0) + (usage.cacheCreationInput ?? 0);
3122
3215
  const ratio = used / contextLimit;
3123
3216
  if (ratio <= config.threshold) {
3124
3217
  if (config.microcompact && messages.length > config.keepLast + 4) {
@@ -3132,10 +3225,10 @@ async function compactIfNeeded(options) {
3132
3225
  }
3133
3226
  return { compacted: false, result: null, messages: [...messages] };
3134
3227
  }
3135
- const result = await executeStrategy(messages, config, client, system);
3136
- return { compacted: true, result, messages: result.messages };
3228
+ const result = await executeStrategy(messages, config, client, system, signal);
3229
+ return { compacted: result.dropped > 0, result, messages: result.messages };
3137
3230
  }
3138
- async function executeStrategy(messages, config, client, system) {
3231
+ async function executeStrategy(messages, config, client, system, signal) {
3139
3232
  let workingMessages = [...messages];
3140
3233
  if (config.microcompact) {
3141
3234
  const mcResult = microcompact({
@@ -3152,14 +3245,16 @@ async function executeStrategy(messages, config, client, system) {
3152
3245
  messages: workingMessages,
3153
3246
  config,
3154
3247
  client,
3155
- system
3248
+ system,
3249
+ ...signal !== void 0 ? { signal } : {}
3156
3250
  });
3157
3251
  case "session-memory":
3158
3252
  return summarizeCompact({
3159
3253
  messages: workingMessages,
3160
3254
  config,
3161
3255
  client,
3162
- system
3256
+ system,
3257
+ ...signal !== void 0 ? { signal } : {}
3163
3258
  });
3164
3259
  case "auto":
3165
3260
  default:
@@ -3168,7 +3263,8 @@ async function executeStrategy(messages, config, client, system) {
3168
3263
  messages: workingMessages,
3169
3264
  config,
3170
3265
  client,
3171
- system
3266
+ system,
3267
+ ...signal !== void 0 ? { signal } : {}
3172
3268
  });
3173
3269
  } catch {
3174
3270
  return dropMiddle(workingMessages, config.keepLast);
@@ -3333,7 +3429,8 @@ async function agentLoop(options) {
3333
3429
  let recoveryCount = 0;
3334
3430
  let maxTokensEscalated = false;
3335
3431
  let escalatedMaxTokens;
3336
- let compactedThisTurn = false;
3432
+ const MAX_EMERGENCY_413 = 1;
3433
+ let emergency413Count = 0;
3337
3434
  const MAX_API_RETRIES = 3;
3338
3435
  const BASE_BACKOFF_MS = 1e3;
3339
3436
  let apiRetryCount = 0;
@@ -3352,7 +3449,6 @@ async function agentLoop(options) {
3352
3449
  }
3353
3450
  };
3354
3451
  for (; ; ) {
3355
- compactedThisTurn = false;
3356
3452
  const turnStartedAt = Date.now();
3357
3453
  const turnStartTokens = { ...ctx.getTokensUsed() };
3358
3454
  if (options.runSignal?.aborted === true) {
@@ -3403,7 +3499,7 @@ async function agentLoop(options) {
3403
3499
  });
3404
3500
  }
3405
3501
  let messages = ctx.getMessages();
3406
- const mightCompact = ctx.getTokensUsed().input + ctx.getTokensUsed().output > contextLimit * compactionConfig.threshold;
3502
+ const mightCompact = ctx.getLastPromptTokens() > contextLimit * compactionConfig.threshold;
3407
3503
  if (mightCompact) await fireProgress("compacting");
3408
3504
  const compactResult = await compactIfNeeded({
3409
3505
  messages,
@@ -3411,7 +3507,10 @@ async function agentLoop(options) {
3411
3507
  contextLimit,
3412
3508
  config: compactionConfig,
3413
3509
  client,
3414
- system
3510
+ system,
3511
+ liveContextTokens: ctx.getLastPromptTokens(),
3512
+ // WS-3b — live, not cumulative
3513
+ ...options.runSignal !== void 0 ? { signal: options.runSignal } : {}
3415
3514
  });
3416
3515
  if (compactResult.compacted) {
3417
3516
  messages = compactResult.messages;
@@ -3459,6 +3558,7 @@ async function agentLoop(options) {
3459
3558
  const normalizedMessages = normalizeMessages(
3460
3559
  messagesForApi
3461
3560
  );
3561
+ const effectiveToolChoice = options.toolChoice === "required" && cumulativeToolCalls > 0 ? "auto" : options.toolChoice;
3462
3562
  try {
3463
3563
  for await (const event of client.streamMessage({
3464
3564
  messages: normalizedMessages,
@@ -3466,7 +3566,7 @@ async function agentLoop(options) {
3466
3566
  tools: anthropicTools,
3467
3567
  ...options.runSignal !== void 0 ? { abortSignal: options.runSignal } : {},
3468
3568
  ...escalatedMaxTokens !== void 0 ? { maxTokens: escalatedMaxTokens } : {},
3469
- ...options.toolChoice !== void 0 ? { toolChoice: options.toolChoice } : {}
3569
+ ...effectiveToolChoice !== void 0 ? { toolChoice: effectiveToolChoice } : {}
3470
3570
  })) {
3471
3571
  const handled = consumeEvent(event);
3472
3572
  if (handled.text !== void 0) textBlocks.push(handled.text);
@@ -3481,8 +3581,17 @@ async function agentLoop(options) {
3481
3581
  if (isAbortSignalAborted(options.runSignal)) {
3482
3582
  return failed(new RunTimeoutError(options.runTimeoutMs ?? 0), transcript);
3483
3583
  }
3484
- if (isPromptTooLong(err) && !compactedThisTurn) {
3485
- compactedThisTurn = true;
3584
+ if (isPromptTooLong(err)) {
3585
+ if (emergency413Count >= MAX_EMERGENCY_413) {
3586
+ return failed(
3587
+ new EngineError(
3588
+ "ERR_PROMPT_TOO_LONG",
3589
+ "Prompt exceeds the model context window even after emergency compaction"
3590
+ ),
3591
+ transcript
3592
+ );
3593
+ }
3594
+ emergency413Count++;
3486
3595
  const emergency = await compactIfNeeded({
3487
3596
  messages,
3488
3597
  usage: ctx.getTokensUsed(),
@@ -3490,9 +3599,11 @@ async function agentLoop(options) {
3490
3599
  // force below threshold
3491
3600
  config: { ...compactionConfig, threshold: 0, keepLast: 4 },
3492
3601
  client,
3493
- system
3602
+ system,
3603
+ ...options.runSignal !== void 0 ? { signal: options.runSignal } : {}
3494
3604
  });
3495
- if (emergency.compacted) {
3605
+ const madeProgress = emergency.compacted && emergency.messages.length < messages.length;
3606
+ if (madeProgress) {
3496
3607
  await options.inspect?.appendEvent({
3497
3608
  type: "compaction_413",
3498
3609
  turn: ctx.getTurnCount(),
@@ -3508,6 +3619,13 @@ async function agentLoop(options) {
3508
3619
  });
3509
3620
  continue;
3510
3621
  }
3622
+ return failed(
3623
+ new EngineError(
3624
+ "ERR_PROMPT_TOO_LONG",
3625
+ "Prompt too long and compaction could not reduce it"
3626
+ ),
3627
+ transcript
3628
+ );
3511
3629
  }
3512
3630
  if (isRetryable(err) && apiRetryCount < MAX_API_RETRIES) {
3513
3631
  apiRetryCount++;
@@ -3979,6 +4097,15 @@ var RunContext = class {
3979
4097
  messages = [];
3980
4098
  turnCount = 0;
3981
4099
  tokensUsed = { input: 0, output: 0 };
4100
+ /**
4101
+ * Engine 056 / WS-3b — size of the most recent request's prompt
4102
+ * (`input + cacheReadInput` from the last `message_stop`). This is the
4103
+ * LIVE context size, used to decide compaction. `tokensUsed` is a
4104
+ * cumulative BILLING counter that grows ~quadratically over a long run
4105
+ * (each turn re-sends the whole context), so using it for the threshold
4106
+ * triggered compaction far too early and then re-summarized every turn.
4107
+ */
4108
+ lastPromptTokens = 0;
3982
4109
  lastUuid = null;
3983
4110
  /**
3984
4111
  * Plan 019 — names of tools whose capability-stub returned an
@@ -4133,6 +4260,13 @@ var RunContext = class {
4133
4260
  getTokensUsed() {
4134
4261
  return this.tokensUsed;
4135
4262
  }
4263
+ /**
4264
+ * Engine 056 / WS-3b — live context size: `input + cacheReadInput` of
4265
+ * the most recent request. 0 before the first response of a run/resume.
4266
+ */
4267
+ getLastPromptTokens() {
4268
+ return this.lastPromptTokens;
4269
+ }
4136
4270
  /** Plan 019 — record that a capability-stubbed tool fired during this run. */
4137
4271
  recordCapabilityMissing(toolName) {
4138
4272
  this.capabilitiesMissing.add(toolName);
@@ -4187,6 +4321,7 @@ var RunContext = class {
4187
4321
  if (usage.cacheReadInput !== void 0) {
4188
4322
  this.tokensUsed.cacheReadInput = (this.tokensUsed.cacheReadInput ?? 0) + usage.cacheReadInput;
4189
4323
  }
4324
+ this.lastPromptTokens = usage.input + (usage.cacheReadInput ?? 0);
4190
4325
  }
4191
4326
  // ---------- internal ----------
4192
4327
  async writeEntry(entry) {
@@ -4511,13 +4646,18 @@ var TranscriptWriter = class {
4511
4646
  await writeMeta(this.storage, this.logPath, this.meta);
4512
4647
  }
4513
4648
  }
4514
- flushLog() {
4515
- if (this.flushInFlight) return this.flushInFlight;
4516
- if (this.buffer.length === 0) return Promise.resolve();
4517
- this.flushInFlight = this.doFlush().finally(() => {
4518
- this.flushInFlight = null;
4519
- });
4520
- return this.flushInFlight;
4649
+ async flushLog() {
4650
+ for (; ; ) {
4651
+ if (this.flushInFlight !== null) {
4652
+ await this.flushInFlight;
4653
+ continue;
4654
+ }
4655
+ if (this.buffer.length === 0) return;
4656
+ this.flushInFlight = this.doFlush().finally(() => {
4657
+ this.flushInFlight = null;
4658
+ });
4659
+ await this.flushInFlight;
4660
+ }
4521
4661
  }
4522
4662
  async setStatus(status) {
4523
4663
  this.meta = {
@@ -4530,9 +4670,7 @@ var TranscriptWriter = class {
4530
4670
  async close() {
4531
4671
  if (this.closed) return;
4532
4672
  this.clearIdleTimer();
4533
- if (this.buffer.length > 0) {
4534
- await this.flushLog();
4535
- }
4673
+ await this.flushLog();
4536
4674
  this.closed = true;
4537
4675
  }
4538
4676
  // ---------- internals ----------
@@ -4589,8 +4727,16 @@ function formatShardName(index) {
4589
4727
  async function loadWriterState(storage, logPath) {
4590
4728
  const existing = await readMeta(storage, logPath);
4591
4729
  if (existing === null) return null;
4592
- const nextShardIndex = existing.lastShardIndex === null ? 0 : existing.lastShardIndex + 1;
4593
- return { nextShardIndex, meta: existing };
4730
+ let maxShard = existing.lastShardIndex ?? -1;
4731
+ try {
4732
+ const names = await storage.listDir(logPath);
4733
+ for (const name of names) {
4734
+ const m = /^(\d{6})\.jsonl$/.exec(name);
4735
+ if (m !== null) maxShard = Math.max(maxShard, parseInt(m[1], 10));
4736
+ }
4737
+ } catch {
4738
+ }
4739
+ return { nextShardIndex: maxShard + 1, meta: existing };
4594
4740
  }
4595
4741
 
4596
4742
  // src/subagent/runner.ts
@@ -7007,7 +7153,14 @@ var McpClient = class {
7007
7153
  }
7008
7154
  let tools;
7009
7155
  try {
7010
- const response = await client.listTools();
7156
+ const response = await withTimeout(
7157
+ client.listTools(),
7158
+ this.options.connectTimeoutMs,
7159
+ () => new McpConnectionError(
7160
+ this.serverName,
7161
+ `tools/list did not complete within ${this.options.connectTimeoutMs}ms`
7162
+ )
7163
+ );
7011
7164
  tools = normalizeToolList(response, this.serverName);
7012
7165
  } catch (err) {
7013
7166
  await safeClose(client);
@@ -7016,6 +7169,9 @@ var McpClient = class {
7016
7169
  this.sdkClient = client;
7017
7170
  this.toolCache = tools;
7018
7171
  this.connected = true;
7172
+ client.onclose = () => {
7173
+ this.connected = false;
7174
+ };
7019
7175
  this.options.logger.info("mcp.connect ok", {
7020
7176
  server: this.serverName,
7021
7177
  type: this.options.config.type,
@@ -7054,12 +7210,22 @@ var McpClient = class {
7054
7210
  }
7055
7211
  async close() {
7056
7212
  const client = this.sdkClient;
7213
+ const pid = this.childPid;
7057
7214
  this.sdkClient = null;
7058
7215
  this.connected = false;
7059
7216
  this.toolCache = null;
7060
7217
  this.childPid = null;
7061
7218
  if (client !== null) {
7062
- await safeClose(client);
7219
+ try {
7220
+ await safeClose(client);
7221
+ } finally {
7222
+ if (pid !== null) {
7223
+ try {
7224
+ process.kill(pid, "SIGKILL");
7225
+ } catch {
7226
+ }
7227
+ }
7228
+ }
7063
7229
  }
7064
7230
  }
7065
7231
  /**
@@ -7681,7 +7847,6 @@ function buildPermissionPolicy(config) {
7681
7847
  const rules = parseRules(config.rules);
7682
7848
  return {
7683
7849
  check: (toolName) => {
7684
- if (isSafeTool(toolName)) return ALLOW;
7685
7850
  for (const rule of rules) {
7686
7851
  if (matchesRule(toolName, rule)) {
7687
7852
  if (rule.action === "allow") return ALLOW;
@@ -7691,6 +7856,7 @@ function buildPermissionPolicy(config) {
7691
7856
  };
7692
7857
  }
7693
7858
  }
7859
+ if (isSafeTool(toolName)) return ALLOW;
7694
7860
  return {
7695
7861
  allowed: false,
7696
7862
  reason: `Permission denied: "${toolName}" \u2014 no matching rule (mode: rules, fall-closed)`
@@ -8091,7 +8257,7 @@ function getMcpSection(options) {
8091
8257
  if (options.mcpTools.length === 0) return null;
8092
8258
  const byServer = /* @__PURE__ */ new Map();
8093
8259
  for (const tool of options.mcpTools) {
8094
- const match = tool.name.match(/^mcp__([^_]+)__(.+)$/);
8260
+ const match = tool.name.match(/^mcp__(.+?)__(.+)$/);
8095
8261
  if (!match) continue;
8096
8262
  const server = match[1];
8097
8263
  const toolName = match[2];
@@ -8442,6 +8608,7 @@ init_contract();
8442
8608
  var ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
8443
8609
  var DEFAULT_MAX_BODY_BYTES = 256 * 1024;
8444
8610
  var DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024;
8611
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
8445
8612
  var DEFAULT_MAX_PAGES = 5;
8446
8613
  var MAX_PAGES_HARD_CAP = 50;
8447
8614
  var DEFAULT_MAX_ITEMS = 500;
@@ -8473,6 +8640,7 @@ function createApiCallTool(opts) {
8473
8640
  }
8474
8641
  const fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);
8475
8642
  const maxResponseBytes = opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
8643
+ const requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
8476
8644
  const inputSchema19 = import_zod23.z.object({
8477
8645
  service: import_zod23.z.enum(serviceNames),
8478
8646
  method: import_zod23.z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
@@ -8534,6 +8702,7 @@ function createApiCallTool(opts) {
8534
8702
  input,
8535
8703
  fetchFn,
8536
8704
  maxResponseBytes,
8705
+ requestTimeoutMs,
8537
8706
  env: opts.env,
8538
8707
  resolveAuth: opts.resolveAuth,
8539
8708
  resolveBaseUrl: opts.resolveBaseUrl,
@@ -8621,14 +8790,18 @@ function createApiCallTool(opts) {
8621
8790
  ...authHeaders
8622
8791
  // wins last — model cannot override
8623
8792
  },
8624
- ...bodyText !== void 0 ? { body: bodyText } : {}
8793
+ ...bodyText !== void 0 ? { body: bodyText } : {},
8794
+ signal: AbortSignal.timeout(requestTimeoutMs)
8795
+ // WS-5c
8625
8796
  });
8626
8797
  } catch (err) {
8627
8798
  const msg = err instanceof Error ? err.message : String(err);
8628
8799
  return errResult(`network error: ${msg}`);
8629
8800
  }
8801
+ const tooLarge = responseTooLarge(res);
8802
+ if (tooLarge !== null) return errResult(tooLarge);
8630
8803
  const raw = await res.text();
8631
- const content = raw.length > maxResponseBytes ? raw.slice(0, maxResponseBytes) + "\n\u2026[TRUNCATED]" : raw;
8804
+ const content = byteLength2(raw) > maxResponseBytes ? raw.slice(0, maxResponseBytes) + "\n\u2026[TRUNCATED]" : raw;
8632
8805
  await invokeHook(opts.onResponse, {
8633
8806
  service: svc.name,
8634
8807
  method: input.method,
@@ -8648,7 +8821,21 @@ function createApiCallTool(opts) {
8648
8821
  function errResult(msg) {
8649
8822
  return { content: msg, isError: true };
8650
8823
  }
8824
+ function hasDotDotSegment(path) {
8825
+ let decoded = path;
8826
+ for (let i = 0; i < 2; i++) {
8827
+ try {
8828
+ const next = decodeURIComponent(decoded);
8829
+ if (next === decoded) break;
8830
+ decoded = next;
8831
+ } catch {
8832
+ break;
8833
+ }
8834
+ }
8835
+ return /(^|[/\\])\.\.([/\\]|$)/.test(decoded);
8836
+ }
8651
8837
  function pathAllowed(path, allowed) {
8838
+ if (hasDotDotSegment(path)) return false;
8652
8839
  if (!allowed || allowed.length === 0) return true;
8653
8840
  for (const a of allowed) {
8654
8841
  if (typeof a === "string") {
@@ -8680,6 +8867,25 @@ function resolveAllowedPaths(svc) {
8680
8867
  }
8681
8868
  return void 0;
8682
8869
  }
8870
+ var RESPONSE_HARD_CAP_BYTES = 25 * 1024 * 1024;
8871
+ function resolveSameOriginLink(nextLink, base) {
8872
+ try {
8873
+ const resolved = new URL(nextLink, base);
8874
+ if (resolved.origin !== new URL(base).origin) return null;
8875
+ return resolved.toString();
8876
+ } catch {
8877
+ return null;
8878
+ }
8879
+ }
8880
+ function responseTooLarge(res) {
8881
+ const cl = res.headers.get("content-length");
8882
+ if (cl === null) return null;
8883
+ const declared = Number.parseInt(cl, 10);
8884
+ if (Number.isFinite(declared) && declared > RESPONSE_HARD_CAP_BYTES) {
8885
+ return `ERR_API_RESPONSE_TOO_LARGE: upstream declared ${declared} bytes (hard cap ${RESPONSE_HARD_CAP_BYTES})`;
8886
+ }
8887
+ return null;
8888
+ }
8683
8889
  function byteLength2(s) {
8684
8890
  return new TextEncoder().encode(s).byteLength;
8685
8891
  }
@@ -8900,7 +9106,16 @@ async function executeAutoPaginated(args) {
8900
9106
  pageQuery[p.request.cursorParam] = nextCursor;
8901
9107
  }
8902
9108
  } else if (p.mode === "link-header") {
8903
- if (nextLink !== null) pageUrl = nextLink;
9109
+ if (nextLink !== null) {
9110
+ const safe = resolveSameOriginLink(nextLink, effectiveBaseUrl);
9111
+ if (safe === null) break;
9112
+ try {
9113
+ if (!pathAllowed(new URL(safe).pathname, resolveAllowedPaths(svc))) break;
9114
+ } catch {
9115
+ break;
9116
+ }
9117
+ pageUrl = safe;
9118
+ }
8904
9119
  }
8905
9120
  const url = pageUrl ?? buildUrl(effectiveBaseUrl, input.path, pageQuery);
8906
9121
  await invokeHook(args.onRequest, {
@@ -8917,7 +9132,9 @@ async function executeAutoPaginated(args) {
8917
9132
  ...svc.defaultHeaders ?? {},
8918
9133
  ...userHeaders,
8919
9134
  ...authHeaders
8920
- }
9135
+ },
9136
+ signal: AbortSignal.timeout(args.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS)
9137
+ // WS-5c
8921
9138
  });
8922
9139
  } catch (err) {
8923
9140
  const msg = err instanceof Error ? err.message : String(err);
@@ -8930,6 +9147,16 @@ async function executeAutoPaginated(args) {
8930
9147
  });
8931
9148
  }
8932
9149
  lastStatus = res.status;
9150
+ const tooLargePage = responseTooLarge(res);
9151
+ if (tooLargePage !== null) {
9152
+ return paginationErr({
9153
+ code: "ERR_API_PAGINATION_PAGE_FAILED",
9154
+ message: `${input.method} ${input.path} page ${pagesFetched + 1}: ${tooLargePage}`,
9155
+ pagesFetched,
9156
+ itemsFetched: aggregated.length,
9157
+ partialItems: aggregated
9158
+ });
9159
+ }
8933
9160
  const raw = await res.text();
8934
9161
  const captured = raw.length > maxResponseBytes ? raw.slice(0, maxResponseBytes) + "\n\u2026[TRUNCATED]" : raw;
8935
9162
  await invokeHook(args.onResponse, {
@@ -10658,7 +10885,15 @@ var R2BindingStorageAdapter = class {
10658
10885
  var ENGINE_DATA_FOLDER = ".claude";
10659
10886
  var WORKSPACES_FOLDER = "workspaces";
10660
10887
  var KNOWLEDGE_FOLDER = "knowledge";
10888
+ function assertSafeWorkspaceId(workspaceId) {
10889
+ if (!/^[A-Za-z0-9._-]+$/.test(workspaceId) || workspaceId === "." || workspaceId === "..") {
10890
+ throw new StorageError(
10891
+ `Invalid storage.workspaceId "${workspaceId}": must be non-empty and match [A-Za-z0-9._-] with no path separators or "."/".." traversal`
10892
+ );
10893
+ }
10894
+ }
10661
10895
  async function createEngineStorage(config, options = {}) {
10896
+ assertSafeWorkspaceId(config.workspaceId);
10662
10897
  switch (config.provider) {
10663
10898
  case "local":
10664
10899
  return createLocalStorage(config, options);
@@ -10670,7 +10905,12 @@ async function createEngineStorage(config, options = {}) {
10670
10905
  }
10671
10906
  async function createLocalStorage(config, options) {
10672
10907
  const path = await import("path");
10673
- const tenantRoot = path.join(config.rootPath, WORKSPACES_FOLDER, config.workspaceId);
10908
+ let rootPath = config.rootPath;
10909
+ if (rootPath === "~" || rootPath.startsWith("~/")) {
10910
+ const os = await import("os");
10911
+ rootPath = path.join(os.homedir(), rootPath.slice(1));
10912
+ }
10913
+ const tenantRoot = path.join(rootPath, WORKSPACES_FOLDER, config.workspaceId);
10674
10914
  const workspaceRoot = path.join(tenantRoot, ENGINE_DATA_FOLDER);
10675
10915
  const out = { workspace: new LocalStorageAdapter(workspaceRoot) };
10676
10916
  if (options.withKnowledge) {
@@ -11153,7 +11393,7 @@ function extractPausedData(pending) {
11153
11393
 
11154
11394
  // src/engine/state.ts
11155
11395
  init_cjs_shims();
11156
- var RunStateManager = class {
11396
+ var RunStateManager = class _RunStateManager {
11157
11397
  constructor(storage) {
11158
11398
  this.storage = storage;
11159
11399
  }
@@ -11161,9 +11401,24 @@ var RunStateManager = class {
11161
11401
  path(runId, nodeId) {
11162
11402
  return `projects/${runId}/nodes/${nodeId}/state.json`;
11163
11403
  }
11404
+ /** A terminal status is final — once set it must never be overwritten. */
11405
+ static isTerminal(status) {
11406
+ return status === "done" || status === "failed" || status === "cancelled";
11407
+ }
11408
+ /**
11409
+ * Persist a full state. Returns the persisted state so mutators can
11410
+ * forward it. Deliberately NO read-before-write: `write` is on the hot
11411
+ * path (heartbeat + every async-timing patch), so it must stay a single
11412
+ * I/O — adding a read here measurably slowed the async lifecycle and
11413
+ * widened existing read-modify-write windows. Terminal-status
11414
+ * precedence (Engine 056 / WS-1c) lives in `finalize` instead — the
11415
+ * only path that writes a terminal status, and one that already reads
11416
+ * `current`, so the guard costs nothing extra there.
11417
+ */
11164
11418
  async write(state) {
11165
11419
  const content = JSON.stringify(state, null, 2);
11166
11420
  await this.storage.writeFile(this.path(state.runId, state.nodeId), content);
11421
+ return state;
11167
11422
  }
11168
11423
  async read(runId, nodeId) {
11169
11424
  try {
@@ -11184,8 +11439,7 @@ var RunStateManager = class {
11184
11439
  throw new Error(`RunStateManager.update: no state found for ${runId}/${nodeId}`);
11185
11440
  }
11186
11441
  const next = { ...current, ...patch };
11187
- await this.write(next);
11188
- return next;
11442
+ return this.write(next);
11189
11443
  }
11190
11444
  /**
11191
11445
  * Merge async lifecycle timing fields into the durable state.
@@ -11201,8 +11455,7 @@ var RunStateManager = class {
11201
11455
  ...current,
11202
11456
  asyncTiming: { ...current.asyncTiming ?? {}, ...patch }
11203
11457
  };
11204
- await this.write(next);
11205
- return next;
11458
+ return this.write(next);
11206
11459
  }
11207
11460
  async appendManualWebhookRetry(runId, nodeId, row) {
11208
11461
  const current = await this.read(runId, nodeId);
@@ -11216,8 +11469,7 @@ var RunStateManager = class {
11216
11469
  ...current,
11217
11470
  manualWebhookRetries: [...retries, row]
11218
11471
  };
11219
- await this.write(next);
11220
- return next;
11472
+ return this.write(next);
11221
11473
  }
11222
11474
  /**
11223
11475
  * Update just the heartbeat + progress (cheap, called every turn).
@@ -11234,13 +11486,32 @@ var RunStateManager = class {
11234
11486
  }
11235
11487
  /**
11236
11488
  * Mark terminal state with response. Used at end of run/resume.
11489
+ *
11490
+ * Engine 056 / WS-1c: create-or-overwrite — does NOT throw when prior
11491
+ * state is missing (a transient read failure in the start() catch path
11492
+ * must still be able to record a terminal state rather than leaving the
11493
+ * run stranded). When state exists its fields (webhook config, timing)
11494
+ * are merged; otherwise a minimal base is synthesized.
11495
+ *
11496
+ * Also sets the `webhookPending` outbox marker (WS-1b) in the same write
11497
+ * iff a webhook is configured and this status is a subscribed event.
11237
11498
  */
11238
11499
  async finalize(runId, nodeId, response) {
11239
- return this.update(runId, nodeId, {
11240
- status: response.status === "done" ? "done" : response.status === "paused" ? "paused" : "failed",
11500
+ const status = response.status === "done" ? "done" : response.status === "paused" ? "paused" : "failed";
11501
+ const current = await this.read(runId, nodeId);
11502
+ if (current !== null && _RunStateManager.isTerminal(current.status) && current.status !== status) {
11503
+ return current;
11504
+ }
11505
+ const base = current ?? _RunStateManager.initial(runId, nodeId);
11506
+ const webhookPending = base.webhook !== void 0 && base.webhook.events.includes(status);
11507
+ const next = {
11508
+ ...base,
11509
+ status,
11241
11510
  lastHeartbeat: Date.now(),
11242
- response
11243
- });
11511
+ response,
11512
+ webhookPending
11513
+ };
11514
+ return this.write(next);
11244
11515
  }
11245
11516
  /**
11246
11517
  * List all state files under a runId (one per node). Returns empty array
@@ -11263,6 +11534,14 @@ var RunStateManager = class {
11263
11534
  /**
11264
11535
  * Scan all state files and return those with stale heartbeats.
11265
11536
  * Used by recoverOrphanedRuns().
11537
+ *
11538
+ * Engine 056 / WS-1a: includes stale `queued` as well as `running`. A
11539
+ * run can be stranded in `queued` when the initial `running` write (or
11540
+ * the scheduled background work) fails after `start()` returns — its
11541
+ * heartbeat never advances past creation, so a `queued` state older than
11542
+ * the threshold is orphaned and must be recoverable. `paused` is
11543
+ * intentionally excluded (human-in-the-loop waits are legitimately
11544
+ * long-lived; the orchestrator reconciles those separately).
11266
11545
  */
11267
11546
  async findOrphaned(staleThresholdMs) {
11268
11547
  const projectsRoot = "projects";
@@ -11273,7 +11552,8 @@ var RunStateManager = class {
11273
11552
  for (const runId of runIds) {
11274
11553
  const states = await this.scanRun(runId);
11275
11554
  for (const state of states) {
11276
- if (state.status === "running" && now - state.lastHeartbeat > staleThresholdMs) {
11555
+ const recoverable = state.status === "running" || state.status === "queued";
11556
+ if (recoverable && now - state.lastHeartbeat > staleThresholdMs) {
11277
11557
  orphaned.push(state);
11278
11558
  }
11279
11559
  }
@@ -11340,8 +11620,12 @@ var NodeBackgroundExecutor = class {
11340
11620
  // src/engine/webhook.ts
11341
11621
  init_cjs_shims();
11342
11622
  var RETRY_DELAYS_MS = [
11343
- 0
11344
- // attempt 1: immediate (Plan 046 — single attempt only)
11623
+ 0,
11624
+ // attempt 1: immediate
11625
+ 300,
11626
+ // attempt 2: +300ms
11627
+ 1200
11628
+ // attempt 3: +1.2s
11345
11629
  ];
11346
11630
  var MAX_ATTEMPTS = RETRY_DELAYS_MS.length;
11347
11631
  async function signPayload(secret, timestamp, body) {
@@ -11443,6 +11727,41 @@ var WebhookDispatcher = class {
11443
11727
  };
11444
11728
 
11445
11729
  // src/engine/engine.ts
11730
+ var HANDOFF_POST_TIMEOUT_MS = 3e4;
11731
+ function buildUnpairedSiblingResults(messages, pendingId) {
11732
+ let lastAssistant;
11733
+ for (let i = messages.length - 1; i >= 0; i--) {
11734
+ if (messages[i]?.role === "assistant") {
11735
+ lastAssistant = messages[i];
11736
+ break;
11737
+ }
11738
+ }
11739
+ if (lastAssistant === void 0 || !Array.isArray(lastAssistant.content)) return [];
11740
+ const toolUseIds = [];
11741
+ for (const block of lastAssistant.content) {
11742
+ const b = block;
11743
+ if (b.type === "tool_use" && typeof b.id === "string") toolUseIds.push(b.id);
11744
+ }
11745
+ const resolved = /* @__PURE__ */ new Set();
11746
+ for (const m of messages) {
11747
+ if (!Array.isArray(m.content)) continue;
11748
+ for (const block of m.content) {
11749
+ const b = block;
11750
+ if (b.type === "tool_result" && typeof b.tool_use_id === "string") resolved.add(b.tool_use_id);
11751
+ }
11752
+ }
11753
+ const blocks = [];
11754
+ for (const id of toolUseIds) {
11755
+ if (id === pendingId || resolved.has(id)) continue;
11756
+ blocks.push({
11757
+ type: "tool_result",
11758
+ tool_use_id: id,
11759
+ content: "[not executed \u2014 the run paused on a sibling tool call in the same turn; this tool was never dispatched]",
11760
+ is_error: true
11761
+ });
11762
+ }
11763
+ return blocks;
11764
+ }
11446
11765
  var Engine = class {
11447
11766
  config;
11448
11767
  internals;
@@ -11656,6 +11975,18 @@ var Engine = class {
11656
11975
  logPath,
11657
11976
  ...capabilitiesMissing.length > 0 ? { capabilitiesMissing } : {}
11658
11977
  });
11978
+ } catch (err) {
11979
+ const message = err instanceof Error ? err.message : String(err);
11980
+ await writer.setStatus("failed").catch(() => {
11981
+ });
11982
+ return {
11983
+ runId,
11984
+ status: "failed",
11985
+ data: null,
11986
+ meta: { nodeId: options.nodeId, durationMs: Date.now() - startTime },
11987
+ errors: [{ code: "ERR_RUN_FAILED", message }],
11988
+ timestamp: Date.now()
11989
+ };
11659
11990
  } finally {
11660
11991
  runTimeout.clear();
11661
11992
  await writer.close();
@@ -11814,6 +12145,7 @@ var Engine = class {
11814
12145
  });
11815
12146
  if (snapshot.pendingToolCall) {
11816
12147
  const pending = snapshot.pendingToolCall;
12148
+ const siblingBlocks = buildUnpairedSiblingResults(ctx.getMessages(), pending.toolUseId);
11817
12149
  if (snapshot.pauseReason === "awaiting_tool_result") {
11818
12150
  if (options.toolResult === void 0) {
11819
12151
  throw new EngineError(
@@ -11827,14 +12159,33 @@ var Engine = class {
11827
12159
  `toolResult.id "${options.toolResult.id}" does not match pending toolUseId "${pending.toolUseId}" \u2014 resume aborted`
11828
12160
  );
11829
12161
  }
11830
- await ctx.addToolResult(
11831
- pending.toolUseId,
11832
- options.toolResult.content,
11833
- options.toolResult.isError ?? false
11834
- );
12162
+ if (siblingBlocks.length === 0) {
12163
+ await ctx.addToolResult(
12164
+ pending.toolUseId,
12165
+ options.toolResult.content,
12166
+ options.toolResult.isError ?? false
12167
+ );
12168
+ } else {
12169
+ await ctx.addMixedUserMessage([
12170
+ {
12171
+ type: "tool_result",
12172
+ tool_use_id: pending.toolUseId,
12173
+ content: options.toolResult.content,
12174
+ ...options.toolResult.isError === true ? { is_error: true } : {}
12175
+ },
12176
+ ...siblingBlocks
12177
+ ]);
12178
+ }
11835
12179
  } else if (options.gateAnswer !== void 0) {
11836
12180
  const answer = typeof options.gateAnswer === "string" ? options.gateAnswer : JSON.stringify(options.gateAnswer);
11837
- await ctx.addToolResult(pending.toolUseId, answer, false);
12181
+ if (siblingBlocks.length === 0) {
12182
+ await ctx.addToolResult(pending.toolUseId, answer, false);
12183
+ } else {
12184
+ await ctx.addMixedUserMessage([
12185
+ { type: "tool_result", tool_use_id: pending.toolUseId, content: answer },
12186
+ ...siblingBlocks
12187
+ ]);
12188
+ }
11838
12189
  } else {
11839
12190
  const inputJson = JSON.stringify(pending.input ?? {}, null, 2);
11840
12191
  await ctx.addMixedUserMessage([
@@ -11843,6 +12194,7 @@ var Engine = class {
11843
12194
  tool_use_id: pending.toolUseId,
11844
12195
  content: `APPROVAL_GATE_RELEASED: the prior ${pending.toolName} call was paused for human approval and has now been approved. Retry is required.`
11845
12196
  },
12197
+ ...siblingBlocks,
11846
12198
  {
11847
12199
  type: "text",
11848
12200
  text: `The human has approved the paused ${pending.toolName} tool call. You MUST now re-issue the EXACT same tool call to complete the work \u2014 do not change the arguments, do not answer in text, do not declare the task done. Approved arguments (copy verbatim):
@@ -11983,8 +12335,8 @@ ${inputJson}
11983
12335
  backgroundStartedAt: Date.now()
11984
12336
  });
11985
12337
  if (signal.aborted) return;
11986
- await stateManager.update(runId, options.nodeId, { status: "running" });
11987
12338
  try {
12339
+ await stateManager.update(runId, options.nodeId, { status: "running" });
11988
12340
  await this.recordAsyncTiming(stateManager, runId, options.nodeId, {
11989
12341
  runCallStartedAt: Date.now()
11990
12342
  });
@@ -12329,12 +12681,12 @@ ${inputJson}
12329
12681
  completedAt: Date.now()
12330
12682
  });
12331
12683
  } catch (err) {
12332
- const errorMessage = err instanceof Error ? err.message : String(err);
12684
+ const errorMessage2 = err instanceof Error ? err.message : String(err);
12333
12685
  await this.recordManualWebhookRetry(stateManager, runId, targetNodeId, {
12334
12686
  deliveryId,
12335
12687
  startedAt: retryStartedAt,
12336
12688
  completedAt: Date.now(),
12337
- errorMessage
12689
+ errorMessage: errorMessage2
12338
12690
  });
12339
12691
  throw err;
12340
12692
  }
@@ -12409,7 +12761,8 @@ ${inputJson}
12409
12761
  body: JSON.stringify({
12410
12762
  runId,
12411
12763
  workspaceId: this.config.storage.workspaceId
12412
- })
12764
+ }),
12765
+ signal: AbortSignal.timeout(HANDOFF_POST_TIMEOUT_MS)
12413
12766
  });
12414
12767
  if (!res.ok) {
12415
12768
  return {
@@ -12457,11 +12810,29 @@ ${inputJson}
12457
12810
  } catch {
12458
12811
  }
12459
12812
  }
12813
+ /**
12814
+ * Engine 056 / WS-1b: read state.json with a few retries. `read` maps
12815
+ * ALL errors (incl. transient storage failures) to `null`; at webhook
12816
+ * time the terminal state was just written by `finalize`, so a `null`
12817
+ * here is far more likely a transient read than a genuinely-absent
12818
+ * state. Without this, a single blip silently drops the webhook with no
12819
+ * trace — a stuck node. Returns `null` only if every attempt comes back
12820
+ * empty (then the run is left terminal + `webhookPending` for the
12821
+ * caller's reconciler).
12822
+ */
12823
+ async readStateResilient(stateManager, runId, nodeId, attempts = 3) {
12824
+ for (let i = 0; i < attempts; i++) {
12825
+ const s = await stateManager.read(runId, nodeId);
12826
+ if (s !== null) return s;
12827
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, 100));
12828
+ }
12829
+ return null;
12830
+ }
12460
12831
  async maybeFireWebhook(stateManager, runId, nodeId, response) {
12461
12832
  await this.recordAsyncTiming(stateManager, runId, nodeId, {
12462
12833
  webhookCheckStartedAt: Date.now()
12463
12834
  });
12464
- const state = await stateManager.read(runId, nodeId);
12835
+ const state = await this.readStateResilient(stateManager, runId, nodeId);
12465
12836
  if (state === null || state.webhook === void 0) return;
12466
12837
  const event = response.status === "done" ? "done" : response.status === "paused" ? "paused" : "failed";
12467
12838
  if (!state.webhook.events.includes(event)) return;
@@ -12500,7 +12871,10 @@ ${inputJson}
12500
12871
  ...latest.webhook,
12501
12872
  deliveries: [...latest.webhook.deliveries, result.delivery]
12502
12873
  };
12503
- await stateManager.update(runId, nodeId, { webhook: updated });
12874
+ await stateManager.update(runId, nodeId, {
12875
+ webhook: updated,
12876
+ webhookPending: result.delivery.status !== "delivered"
12877
+ });
12504
12878
  await this.recordAsyncTiming(stateManager, runId, nodeId, {
12505
12879
  webhookStatePersistedAt: Date.now()
12506
12880
  });