@sema-agent/server 7.47.0 → 7.48.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.
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { hostShell, resolveHostShell } from "../plugins/host-platform.js";
3
3
  import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, } from "@sema-agent/registry-core/hooks";
4
+ import { boundedNoticeText } from "../fleet/fleet-bus.js";
5
+ import { recordFailOpen } from "../observability/fail-open.js";
4
6
  import { redactSecrets } from "../trace/redact.js";
5
7
  import { ccPromptSystemFor, wrapCondition, parseCcVerdict, CC_EVALUATOR_MAX_OUTPUT_TOKENS } from "./cc-stop-prompt.js";
6
8
  import { ccAgentHookSystemFor } from "./cc-agent-hook-prompt.js";
@@ -144,6 +146,52 @@ const DEFAULT_AGENT_HOOK_TIMEOUT_SECONDS = 120;
144
146
  function clip(s, n) {
145
147
  return s.length > n ? `${s.slice(0, n)}…(${s.length})` : s;
146
148
  }
149
+ function hookDisplayName(entry) {
150
+ if (typeof entry.statusMessage === "string" && entry.statusMessage.length > 0)
151
+ return entry.statusMessage;
152
+ switch (entry.type) {
153
+ case "command":
154
+ return entry.command;
155
+ case "http":
156
+ return entry.url.split(/[?#]/, 1)[0] ?? entry.url;
157
+ case "prompt":
158
+ case "agent":
159
+ return entry.prompt;
160
+ }
161
+ }
162
+ function toolNameOf(payload) {
163
+ const v = payload?.tool_name;
164
+ return typeof v === "string" ? v : undefined;
165
+ }
166
+ function eventOfPayload(payload) {
167
+ const v = payload?.hook_event_name;
168
+ return typeof v === "string" ? v : "";
169
+ }
170
+ function emitHookNotice(ctx, n) {
171
+ try {
172
+ ctx.onHookNotice?.(n);
173
+ }
174
+ catch (e) {
175
+ recordFailOpen("server.hooks.notice-sink-threw", `kind=${n.kind} event=${n.event}`);
176
+ ctx.logger.warn("hook_notice_sink_threw", { kind: n.kind, event: n.event, error: clip(String(e), 200) });
177
+ }
178
+ }
179
+ function noteHookFailure(ctx, args) {
180
+ if (!ctx.onHookNotice)
181
+ return;
182
+ const stderr = boundedNoticeText((args.stderr ?? "").trim());
183
+ emitHookNotice(ctx, {
184
+ kind: "hook_non_blocking_failure",
185
+ event: args.event,
186
+ reason: args.reason,
187
+ hookName: boundedNoticeText(hookDisplayName(args.entry)),
188
+ entryType: args.entry.type,
189
+ ...(typeof args.exitCode === "number" ? { exitCode: args.exitCode } : {}),
190
+ ...(args.toolName !== undefined ? { toolName: args.toolName } : {}),
191
+ ...(stderr.length > 0 ? { stderr } : {}),
192
+ ...(args.detail !== undefined ? { detail: boundedNoticeText(args.detail) } : {}),
193
+ });
194
+ }
147
195
  export function buildHookContext(parts) {
148
196
  const present = parts.filter((p) => p.length > 0).map(readHookContextPart);
149
197
  if (present.length === 0)
@@ -276,7 +324,9 @@ function fireAsyncCommandHook(entry, payload, ctx, event) {
276
324
  stdio: ["pipe", "ignore", rewake ? "pipe" : "ignore"],
277
325
  detached: true,
278
326
  });
327
+ let killedByTimeout = false;
279
328
  const timer = setTimeout(() => {
329
+ killedByTimeout = true;
280
330
  try {
281
331
  if (child.pid)
282
332
  process.kill(-child.pid, "SIGKILL");
@@ -296,9 +346,14 @@ function fireAsyncCommandHook(entry, payload, ctx, event) {
296
346
  });
297
347
  child.stderr?.unref?.();
298
348
  }
299
- child.on("error", () => clearTimeout(timer));
349
+ child.on("error", (e) => {
350
+ clearTimeout(timer);
351
+ ctx.logger.warn("hook_async_failed", { event, reason: "spawn_failed", error: clip(String(e), 300) });
352
+ noteHookFailure(ctx, { event, entry, reason: "spawn_failed", stderr: String(e), toolName: toolNameOf(payload) });
353
+ });
300
354
  child.on("close", (code) => {
301
355
  clearTimeout(timer);
356
+ noteAsyncOutcome(entry, ctx, event, payload, { code, rewake, killedByTimeout });
302
357
  if (!rewake || code !== 2)
303
358
  return;
304
359
  const text = stderr.trim();
@@ -321,9 +376,32 @@ function fireAsyncCommandHook(entry, payload, ctx, event) {
321
376
  child.stdin?.end(`${JSON.stringify(boundedToolInputPayload(payload))}\n`);
322
377
  child.unref();
323
378
  }
324
- catch {
379
+ catch (e) {
380
+ ctx.logger.warn("hook_async_failed", { event, reason: "spawn_failed", error: clip(String(e), 300) });
381
+ noteHookFailure(ctx, { event, entry, reason: "spawn_failed", stderr: String(e), toolName: toolNameOf(payload) });
325
382
  }
326
383
  }
384
+ function noteAsyncOutcome(entry, ctx, event, payload, outcome) {
385
+ const { code, rewake, killedByTimeout } = outcome;
386
+ if (killedByTimeout) {
387
+ ctx.logger.warn("hook_async_failed", { event, reason: "timeout", timeoutSec: Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) });
388
+ noteHookFailure(ctx, { event, entry, reason: "timeout", toolName: toolNameOf(payload) });
389
+ return;
390
+ }
391
+ if (code === 0 || code === null)
392
+ return;
393
+ if (rewake && code === 2)
394
+ return;
395
+ ctx.logger.warn("hook_async_failed", { event, reason: "exit_nonzero", code });
396
+ noteHookFailure(ctx, {
397
+ event,
398
+ entry,
399
+ reason: "exit_nonzero",
400
+ exitCode: code,
401
+ toolName: toolNameOf(payload),
402
+ ...(code === 2 ? { detail: "exit 2 on a fire-and-forget async hook: there is no blocking or wake face to honor it (asyncRewake would wake the model)" } : {}),
403
+ });
404
+ }
327
405
  async function runLlmHook(entry, payload, ctx, extra) {
328
406
  const call = entry.type === "prompt" ? ctx.hookLlm : ctx.hookAgent;
329
407
  if (!call)
@@ -349,17 +427,19 @@ async function runLlmHook(entry, payload, ctx, extra) {
349
427
  hardTop,
350
428
  ]).catch((e) => ({ ok: false, error: String(e) }));
351
429
  let res = await invoke();
430
+ let noticed = false;
352
431
  if (extra && !res.ok && res.code === "no_content") {
353
432
  ctx.logger.warn("hook_llm_no_content_retry", { event: extra.event });
354
433
  res = await invoke();
355
434
  if (!res.ok && res.code === "no_content") {
356
- ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: extra.event, reason: "no_content", detail: "carrier returned no content (after one retry)" });
435
+ emitHookNotice(ctx, { kind: "hook_decision_unavailable", event: extra.event, reason: "no_content", detail: "carrier returned no content (after one retry)" });
436
+ noticed = true;
357
437
  }
358
438
  }
359
439
  if (!res.ok) {
360
440
  return res.code === "hard_timeout"
361
- ? { code: null, stdout: "", stderr: "", timedOut: true }
362
- : { code: null, stdout: "", stderr: "", timedOut: false, spawnError: clip(res.error, 500) };
441
+ ? { code: null, stdout: "", stderr: "", timedOut: true, ...(noticed ? { noticed } : {}) }
442
+ : { code: null, stdout: "", stderr: "", timedOut: false, spawnError: clip(res.error, 500), ...(noticed ? { noticed } : {}) };
363
443
  }
364
444
  return { code: 0, stdout: clip(res.text, 1024 * 1024), stderr: "", timedOut: false };
365
445
  }
@@ -416,6 +496,14 @@ async function runHttpHook(entry, payload, ctx) {
416
496
  return { code: 0, stdout: "{}", stderr: "", timedOut: false };
417
497
  if (!parseHookStdout(body)) {
418
498
  ctx.logger.warn("hook_http_non_json_response", { url: clip(entry.url, 300), preview: clip(trimmed, 200) });
499
+ noteHookFailure(ctx, {
500
+ event: eventOfPayload(payload),
501
+ entry,
502
+ reason: "bad_json",
503
+ exitCode: 0,
504
+ stderr: trimmed,
505
+ toolName: toolNameOf(payload),
506
+ });
419
507
  return { code: 0, stdout: "", stderr: "", timedOut: false };
420
508
  }
421
509
  return { code: 0, stdout: body, stderr: "", timedOut: false };
@@ -452,6 +540,17 @@ function boundedToolInputPayload(payload) {
452
540
  const bounded = boundedInputValue(p.tool_input);
453
541
  return bounded === p.tool_input ? payload : { ...p, tool_input: bounded };
454
542
  }
543
+ function readHookStdout(run, entry, event, ctx, payload) {
544
+ const out = parseHookStdout(run.stdout);
545
+ if (out !== undefined)
546
+ return out;
547
+ const t = run.stdout.trim();
548
+ if (t.startsWith("{") || t.startsWith("[")) {
549
+ ctx.logger.warn("hook_bad_json_output", { event, type: entry.type, preview: clip(t, 200) });
550
+ noteHookFailure(ctx, { event, entry, reason: "bad_json", exitCode: run.code, stderr: run.stderr, toolName: toolNameOf(payload) });
551
+ }
552
+ return undefined;
553
+ }
455
554
  function parseHookStdout(stdout) {
456
555
  const t = stdout.trim();
457
556
  if (!t.startsWith("{"))
@@ -528,10 +627,14 @@ async function runMatchingEntries(event, groups, matchValue, payload, ctx, onceF
528
627
  : await runCommandHook(entry, payload, ctx);
529
628
  if (run.spawnError) {
530
629
  ctx.logger.warn(entry.type === "http" ? "hook_http_request_failed" : entry.type === "prompt" || entry.type === "agent" ? "hook_llm_failed" : "hook_command_spawn_failed", { event, type: entry.type, error: clip(run.spawnError, 300) });
630
+ if (!run.noticed)
631
+ noteHookFailure(ctx, { event, entry, reason: "spawn_failed", exitCode: run.code, stderr: run.spawnError, toolName: toolNameOf(payload) });
531
632
  continue;
532
633
  }
533
634
  if (run.timedOut) {
534
635
  ctx.logger.warn("hook_command_timeout", { event, timeoutSec: Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) });
636
+ if (!run.noticed)
637
+ noteHookFailure(ctx, { event, entry, reason: "timeout", exitCode: run.code, stderr: run.stderr, toolName: toolNameOf(payload) });
535
638
  continue;
536
639
  }
537
640
  results.push({ entry, run });
@@ -552,12 +655,21 @@ function ignoreMatchers(groups) {
552
655
  }
553
656
  async function runObserveOnlyEvent(event, groups, matchValue, payload, ctx, onceFired) {
554
657
  const singles = await runMatchingEntries(event, groups, matchValue, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
555
- for (const { run } of singles) {
658
+ for (const { entry, run } of singles) {
556
659
  if (run.code !== 0) {
557
660
  ctx.logger.warn("hook_command_failed", { event, code: run.code, stderr: clip(run.stderr, 300) });
661
+ noteHookFailure(ctx, {
662
+ event,
663
+ entry,
664
+ reason: "exit_nonzero",
665
+ exitCode: run.code,
666
+ stderr: run.stderr,
667
+ toolName: toolNameOf(payload),
668
+ ...(run.code === 2 ? { detail: `exit 2 on ${event}: this event has no blocking face, so the hook's blocking intent could not be honored` } : {}),
669
+ });
558
670
  continue;
559
671
  }
560
- const out = parseHookStdout(run.stdout);
672
+ const out = readHookStdout(run, entry, event, ctx, payload);
561
673
  if (!out)
562
674
  continue;
563
675
  if (typeof out.systemMessage === "string")
@@ -621,16 +733,17 @@ export function createTaskHooks(config, ctx) {
621
733
  tool_use_id: tctx.toolCallId,
622
734
  };
623
735
  const singles = await runMatchingEntries("PreToolUse", [{ ...group, matcher: undefined }], toolName, payload, ctx, onceFired, deadlineAt, `PreToolUse:${gi}`);
624
- for (const { run } of singles) {
736
+ for (const { entry, run } of singles) {
625
737
  if (run.code === 2) {
626
738
  const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreToolUse hook";
627
739
  return { action: "deny", message: reason, ...buildHookContextField(contexts) };
628
740
  }
629
741
  if (run.code !== 0) {
630
742
  ctx.logger.warn("hook_command_failed", { event: "PreToolUse", code: run.code, stderr: clip(run.stderr, 300) });
743
+ noteHookFailure(ctx, { event: "PreToolUse", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr, toolName });
631
744
  continue;
632
745
  }
633
- const out = parseHookStdout(run.stdout);
746
+ const out = readHookStdout(run, entry, "PreToolUse", ctx, payload);
634
747
  if (!out)
635
748
  continue;
636
749
  if (typeof out.systemMessage === "string")
@@ -716,7 +829,7 @@ export function createTaskHooks(config, ctx) {
716
829
  tool_use_id: tctx.toolCallId,
717
830
  };
718
831
  const singles = await runMatchingEntries("PostToolUse", post, toolName, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
719
- for (const { run } of singles) {
832
+ for (const { entry, run } of singles) {
720
833
  if (run.code === 2) {
721
834
  const fb = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS);
722
835
  if (fb)
@@ -725,9 +838,10 @@ export function createTaskHooks(config, ctx) {
725
838
  }
726
839
  if (run.code !== 0) {
727
840
  ctx.logger.warn("hook_command_failed", { event: "PostToolUse", code: run.code, stderr: clip(run.stderr, 300) });
841
+ noteHookFailure(ctx, { event: "PostToolUse", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr, toolName });
728
842
  continue;
729
843
  }
730
- const out = parseHookStdout(run.stdout);
844
+ const out = readHookStdout(run, entry, "PostToolUse", ctx, payload);
731
845
  if (!out)
732
846
  continue;
733
847
  if (typeof out.systemMessage === "string")
@@ -765,7 +879,7 @@ export function createTaskHooks(config, ctx) {
765
879
  is_interrupt: f.isInterrupt,
766
880
  };
767
881
  const singles = await runMatchingEntries("PostToolUseFailure", postFailure, toolName, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
768
- for (const { run } of singles) {
882
+ for (const { entry, run } of singles) {
769
883
  if (run.code === 2) {
770
884
  const fb = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS);
771
885
  if (fb)
@@ -774,9 +888,10 @@ export function createTaskHooks(config, ctx) {
774
888
  }
775
889
  if (run.code !== 0) {
776
890
  ctx.logger.warn("hook_command_failed", { event: "PostToolUseFailure", code: run.code, stderr: clip(run.stderr, 300) });
891
+ noteHookFailure(ctx, { event: "PostToolUseFailure", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr, toolName });
777
892
  continue;
778
893
  }
779
- const out = parseHookStdout(run.stdout);
894
+ const out = readHookStdout(run, entry, "PostToolUseFailure", ctx, payload);
780
895
  if (!out)
781
896
  continue;
782
897
  if (typeof out.systemMessage === "string")
@@ -811,7 +926,7 @@ export function createTaskHooks(config, ctx) {
811
926
  })),
812
927
  };
813
928
  const singles = await runMatchingEntries("PostToolBatch", ignoreMatchers(postBatch), "", payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
814
- for (const { run } of singles) {
929
+ for (const { entry, run } of singles) {
815
930
  if (run.code === 2) {
816
931
  const fb = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS);
817
932
  if (fb)
@@ -820,9 +935,10 @@ export function createTaskHooks(config, ctx) {
820
935
  }
821
936
  if (run.code !== 0) {
822
937
  ctx.logger.warn("hook_command_failed", { event: "PostToolBatch", code: run.code, stderr: clip(run.stderr, 300) });
938
+ noteHookFailure(ctx, { event: "PostToolBatch", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr });
823
939
  continue;
824
940
  }
825
- const out = parseHookStdout(run.stdout);
941
+ const out = readHookStdout(run, entry, "PostToolBatch", ctx, payload);
826
942
  if (!out)
827
943
  continue;
828
944
  if (typeof out.systemMessage === "string")
@@ -844,16 +960,17 @@ export function createTaskHooks(config, ctx) {
844
960
  const contexts = [];
845
961
  const payload = { ...basePayload(ctx), hook_event_name: "UserPromptSubmit", prompt: clip(prompt, MAX_TOOL_INPUT_CHARS) };
846
962
  const singles = await runMatchingEntries("UserPromptSubmit", ignoreMatchers(promptSubmit), "", payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
847
- for (const { run } of singles) {
963
+ for (const { entry, run } of singles) {
848
964
  if (run.code === 2) {
849
965
  const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a UserPromptSubmit hook";
850
966
  return { block: reason, ...buildHookContextField(contexts) };
851
967
  }
852
968
  if (run.code !== 0) {
853
969
  ctx.logger.warn("hook_command_failed", { event: "UserPromptSubmit", code: run.code, stderr: clip(run.stderr, 300) });
970
+ noteHookFailure(ctx, { event: "UserPromptSubmit", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr });
854
971
  continue;
855
972
  }
856
- const out = parseHookStdout(run.stdout);
973
+ const out = readHookStdout(run, entry, "UserPromptSubmit", ctx, payload);
857
974
  if (!out)
858
975
  continue;
859
976
  if (typeof out.systemMessage === "string")
@@ -886,7 +1003,7 @@ export function createTaskHooks(config, ctx) {
886
1003
  if (wantsPrompt) {
887
1004
  if (ctx.stopPromptTranscript === false) {
888
1005
  ctx.logger.warn("hook_entries_skipped", { event: "Stop", type: "prompt", reason: "stop_prompt_transcript_disabled" });
889
- ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: "Stop", reason: "skipped", detail: "transcript disabled" });
1006
+ emitHookNotice(ctx, { kind: "hook_decision_unavailable", event: "Stop", reason: "skipped", detail: "transcript disabled" });
890
1007
  }
891
1008
  else {
892
1009
  let branch;
@@ -902,7 +1019,7 @@ export function createTaskHooks(config, ctx) {
902
1019
  }
903
1020
  else {
904
1021
  ctx.logger.warn("hook_entries_skipped", { event: "Stop", type: "prompt", reason: `stop_prompt_no_evidence:${rendered.reason}` });
905
- ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: "Stop", reason: "skipped", detail: `no evidence: ${rendered.reason}` });
1022
+ emitHookNotice(ctx, { kind: "hook_decision_unavailable", event: "Stop", reason: "skipped", detail: `no evidence: ${rendered.reason}` });
906
1023
  }
907
1024
  }
908
1025
  }
@@ -922,6 +1039,7 @@ export function createTaskHooks(config, ctx) {
922
1039
  }
923
1040
  if (run.code !== 0) {
924
1041
  ctx.logger.warn("hook_command_failed", { event: "Stop", code: run.code, stderr: clip(run.stderr, 300) });
1042
+ noteHookFailure(ctx, { event: "Stop", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr });
925
1043
  continue;
926
1044
  }
927
1045
  if (llmExtra && entry.type === "prompt" && parseCcVerdict(run.stdout)) {
@@ -936,11 +1054,11 @@ export function createTaskHooks(config, ctx) {
936
1054
  block = clip(v.reason?.trim() || "stop condition not satisfied", MAX_HOOK_FEEDBACK_CHARS);
937
1055
  continue;
938
1056
  }
939
- const out = parseHookStdout(run.stdout);
1057
+ const out = llmExtra && entry.type === "prompt" ? parseHookStdout(run.stdout) : readHookStdout(run, entry, "Stop", ctx, payload);
940
1058
  if (!out) {
941
1059
  if (llmExtra && entry.type === "prompt") {
942
1060
  ctx.logger.warn("hook_llm_unparsed_verdict", { event: "Stop", head: clip(run.stdout.trim(), 200) });
943
- ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: "Stop", reason: "unparsed" });
1061
+ emitHookNotice(ctx, { kind: "hook_decision_unavailable", event: "Stop", reason: "unparsed" });
944
1062
  }
945
1063
  continue;
946
1064
  }
@@ -991,15 +1109,16 @@ export function createTaskHooks(config, ctx) {
991
1109
  custom_instructions: pctx.customInstructions ?? null,
992
1110
  };
993
1111
  const singles = await runMatchingEntries("PreCompact", preCompactEntries, pctx.trigger, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
994
- for (const { run } of singles) {
1112
+ for (const { entry, run } of singles) {
995
1113
  if (run.code === 2) {
996
1114
  return { block: clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreCompact hook" };
997
1115
  }
998
1116
  if (run.code !== 0) {
999
1117
  ctx.logger.warn("hook_command_failed", { event: "PreCompact", code: run.code, stderr: clip(run.stderr, 300) });
1118
+ noteHookFailure(ctx, { event: "PreCompact", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr });
1000
1119
  continue;
1001
1120
  }
1002
- const out = parseHookStdout(run.stdout);
1121
+ const out = readHookStdout(run, entry, "PreCompact", ctx, payload);
1003
1122
  if (!out)
1004
1123
  continue;
1005
1124
  if (typeof out.systemMessage === "string")
@@ -237,6 +237,30 @@ export declare function noteResumeClientGone(res: ServerResponse, out: {
237
237
  }, logger: {
238
238
  info?: (msg: string, fields?: Record<string, unknown>) => void;
239
239
  } | undefined): void;
240
+ /**
241
+ * #359②(A-075.6)—— resume 族四腿(`/decide` / `/assistant/tasks/:id/resume` / `/plan_review` /
242
+ * `/wake`)的**唯一**回执发送口。
243
+ *
244
+ * 为什么是一个函数而不是「在四处各加一行 `res.setHeader`」:这四条腿此前已经各自记得调
245
+ * {@link noteResumeClientGone},而那正是「靠人记得」的形 —— S-1 落地时就漏挂过一腿(三腿有、
246
+ * `/decide` 没有,见 `approvals-assistant.ts` 那条注)。把留痕 + 退避头 + 发送折进一次调用,
247
+ * 第五条腿加进来时只有一个正确写法。
248
+ *
249
+ * 🔴 **`Retry-After` 的判据与体里那份同源**:头只在体**真带** `retryAfterSec` 且是**有限非负数**时发。
250
+ * 全仓每一处带 `retryAfterSec` 的响应(429 `limit.rate_exceeded` / `limit.cost_quota_exceeded` /
251
+ * `quota_exhausted` / `usage.window_exhausted` / rule-import retry)都同发这个标准头,唯独 resume 族的
252
+ * retriable 409 漏了。头与体不是重复,是**两个读者**:体那份给应用逻辑,头那份给通道
253
+ * (代理 / SDK / fetch 重试中间件读的是头,读不到就只能盲等或立刻重投 —— 而立刻重投必然又是一次空转)。
254
+ * 体里没有等待 ⇒ 头也不发:凭空补一个退避值就是编造一条服务端并没有做出的时间承诺。
255
+ *
256
+ * @param extraBody 这条腿自己要并进回执的键(今天只有 `/decide` 的 `rememberApplied`)。
257
+ */
258
+ export declare function sendResumeOutcome(res: ServerResponse, out: {
259
+ status: number;
260
+ body: object;
261
+ }, logger: {
262
+ info?: (msg: string, fields?: Record<string, unknown>) => void;
263
+ } | undefined, extraBody?: Record<string, unknown>): void;
240
264
  export interface RouteCtxBase {
241
265
  deps: FlatServiceDeps;
242
266
  registry: RunRegistry;
@@ -1,3 +1,4 @@
1
+ import { sendJson } from "./send.js";
1
2
  export function noteResumeClientGone(res, out, logger) {
2
3
  if (out.status !== 200 || out.body.status !== "resuming")
3
4
  return;
@@ -9,6 +10,13 @@ export function noteResumeClientGone(res, out, logger) {
9
10
  ...(out.body.sessionId !== undefined ? { sessionId: out.body.sessionId } : {}),
10
11
  });
11
12
  }
13
+ export function sendResumeOutcome(res, out, logger, extraBody) {
14
+ noteResumeClientGone(res, out, logger);
15
+ const wait = out.body.retryAfterSec;
16
+ if (typeof wait === "number" && Number.isFinite(wait) && wait >= 0)
17
+ res.setHeader("retry-after", String(Math.ceil(wait)));
18
+ sendJson(res, out.status, extraBody === undefined ? out.body : { ...out.body, ...extraBody });
19
+ }
12
20
  export const RUN_NOT_FOUND_MESSAGE = "run not found — the id belongs to no run in this deployment's run store (a run from another server process, or an in-memory store that did not survive a restart, is not visible here)";
13
21
  export function runNotFoundMessage(id) {
14
22
  const aStar = /^(?:a|wa)[0-9a-f]{4,}$/i.test(id);
@@ -9,7 +9,7 @@ import { bindSseLifecycle } from "../sse-lifecycle.js";
9
9
  import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
10
10
  import { gatedPrincipal, explicitOperatorOk, isOperator } from "../principal-gate.js";
11
11
  import { governanceOriginOf } from "../active-run-conflict.js";
12
- import { noteResumeClientGone } from "../route-ctx.js";
12
+ import { sendResumeOutcome } from "../route-ctx.js";
13
13
  export const ASSISTANT_PREEMPT_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/preempt$/;
14
14
  export const ASSISTANT_RESUME_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/resume$/;
15
15
  export const ASSISTANT_PLAN_REVIEW_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/plan_review$/;
@@ -249,8 +249,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
249
249
  return;
250
250
  }
251
251
  const out = await resumePreempted(run.sessionId, req, true);
252
- noteResumeClientGone(res, out, deps.logger);
253
- sendJson(res, out.status, out.body);
252
+ sendResumeOutcome(res, out, deps.logger);
254
253
  return;
255
254
  }
256
255
  const planReviewMatch = req.method === "POST" ? ASSISTANT_PLAN_REVIEW_RE.exec(url) : null;
@@ -316,8 +315,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
316
315
  return;
317
316
  }
318
317
  const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req, { taskId, principalPresent: principal !== undefined }, true);
319
- noteResumeClientGone(res, out, deps.logger);
320
- sendJson(res, out.status, out.body);
318
+ sendResumeOutcome(res, out, deps.logger);
321
319
  return;
322
320
  }
323
321
  const m = /^\/v1\/approvals\/([^/]+)\/decide$/.exec(url);
@@ -442,12 +440,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
442
440
  }
443
441
  : undefined;
444
442
  const out = await resumeCheckpoint(sessionId, decision, body.reason ?? undefined, "human", req, answer, binding, grantOnCommit, true);
445
- noteResumeClientGone(res, out, deps.logger);
446
- if (remember && deps.approvalExemptionStore) {
447
- sendJson(res, out.status, { ...out.body, rememberApplied });
448
- return;
449
- }
450
- sendJson(res, out.status, out.body);
443
+ sendResumeOutcome(res, out, deps.logger, remember && deps.approvalExemptionStore ? { rememberApplied } : undefined);
451
444
  return;
452
445
  }
453
446
  sendError(res, 404, "not_found.route", "not found");
@@ -49,7 +49,7 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
49
49
  sessions: Boolean(deps.sessionAudit),
50
50
  sessionEvents: Boolean(deps.sessionWatch && deps.sessionStorage?.getLeafId && deps.sessionStorage?.ownerOf),
51
51
  fleet: deps.fleetBus
52
- ? { stream: true, sessionScope: true, observe: true, resume: "snapshot", bgNotifyFailClosed: true, steerPriority: "advisory" }
52
+ ? { stream: true, sessionScope: true, observe: true, resume: "snapshot", bgNotifyFailClosed: true, steerPriority: "advisory", hookFailureNotice: true }
53
53
  : false,
54
54
  workspace: (() => {
55
55
  const wfs = deps.fileSnapshotStore;
@@ -1,3 +1,4 @@
1
+ import { hookNoticeWire } from "../../fleet/fleet-bus.js";
1
2
  import { FLEET_SNAPSHOT_TERMINAL_MAX_ROWS, isTerminalWorkflowRowStatus, recentTerminalWorkflowRows } from "../../fleet/fleet-terminal-window.js";
2
3
  import { taskNotificationFoldKey } from "../../orchestration/workflow-completion-inbox.js";
3
4
  import { sendJson, sendError, sseHeaders } from "../send.js";
@@ -142,8 +143,7 @@ function streamFleet(req, res, bus, callerScope, callerSession = null, completio
142
143
  const hScopeOk = callerScope === null || hn.ownerScope === callerScope;
143
144
  const hSessionOk = callerSession === null || hn.ownerSessionId === callerSession;
144
145
  if (hScopeOk && hSessionOk) {
145
- const { ownerScope: _hs, ownerSessionId: _hsid, ...wire } = hn;
146
- send("hook_notice", { type: "hook_notice", ...wire, ts: frame.ts });
146
+ send("hook_notice", { type: "hook_notice", ...hookNoticeWire(hn), ts: frame.ts });
147
147
  }
148
148
  break;
149
149
  }
@@ -1,7 +1,7 @@
1
1
  import { taskNotificationInboxEntry } from "../../orchestration/workflow-completion-inbox.js";
2
2
  import { sendJson, sendError } from "../send.js";
3
3
  import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
4
- import { noteResumeClientGone } from "../route-ctx.js";
4
+ import { sendResumeOutcome } from "../route-ctx.js";
5
5
  export const SESSION_WAKE_RE = /^\/v1\/sessions\/[^/]+\/wake$/;
6
6
  export async function handleNotifyWake(req, res, url, ctx) {
7
7
  const miss = { fell: false };
@@ -129,8 +129,7 @@ async function handleNotifyWakeBody(req, res, url, ctx, miss) {
129
129
  return;
130
130
  }
131
131
  const out = await resumeWake(wakeSession, wakeBody.message, principal, req, true);
132
- noteResumeClientGone(res, out, deps.logger);
133
- sendJson(res, out.status, out.body);
132
+ sendResumeOutcome(res, out, deps.logger);
134
133
  return;
135
134
  }
136
135
  miss.fell = true;
@@ -21,6 +21,7 @@ import { normalizeRunEventType } from "../../trace/project.js";
21
21
  import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
22
22
  import { buildActiveRunConflict } from "../active-run-conflict.js";
23
23
  import { clearTurnActivity, readTurnActivityMs } from "../../turn-activity.js";
24
+ import { buildCrossSliceUsage, readResourceWindow, recordResourceWindow } from "../../resource-window.js";
24
25
  import { headerStr, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
25
26
  import { ActorAssertionWire } from "@sema-agent/registry-core";
26
27
  import { RUN_NOT_FOUND_MESSAGE, runNotFoundMessage } from "../route-ctx.js";
@@ -192,7 +193,20 @@ async function handleRunsBody(req, res, url, ctx, miss) {
192
193
  const infraRates = deps.config.infraCostRates;
193
194
  const needCost = Boolean(run.result?.stats && !stale && infraRates && hasInfraPricing(infraRates));
194
195
  const needSuggestions = !stale && run.status === "completed";
195
- const events = (needCost || needSuggestions) && deps.runStore.getEvents ? await deps.runStore.getEvents(taskId, 0).catch(() => []) : undefined;
196
+ const resourceWindow = run.status === "running" && !stale ? readResourceWindow(run.taskId) : undefined;
197
+ let events;
198
+ let eventsUnavailable = false;
199
+ if (needCost || needSuggestions || resourceWindow !== undefined) {
200
+ if (deps.runStore.getEvents) {
201
+ events = await deps.runStore.getEvents(taskId, 0).catch(() => {
202
+ eventsUnavailable = true;
203
+ return [];
204
+ });
205
+ }
206
+ else {
207
+ eventsUnavailable = true;
208
+ }
209
+ }
196
210
  let supervisorCost;
197
211
  if (needCost && events && run.result?.stats) {
198
212
  const durMs = new Date(run.updatedAt).getTime() - new Date(run.createdAt).getTime();
@@ -208,11 +222,19 @@ async function handleRunsBody(req, res, url, ctx, miss) {
208
222
  suggestions = arr.map((s) => String(s));
209
223
  }
210
224
  const lastActivityAt = run.status === "running" && !stale ? readTurnActivityMs(run.taskId) : undefined;
225
+ let crossSliceUsage;
226
+ if (resourceWindow !== undefined) {
227
+ if (eventsUnavailable || events === undefined)
228
+ recordFailOpen("server.runs.cross-slice-usage-unavailable");
229
+ else
230
+ crossSliceUsage = buildCrossSliceUsage(resourceWindow, events);
231
+ }
211
232
  sendJson(res, 200, {
212
233
  taskId: run.taskId,
213
234
  sessionId: run.sessionId,
214
235
  status: stale ? "failed" : run.status,
215
236
  ...(lastActivityAt !== undefined ? { msSinceLastActivity: Math.max(0, Date.now() - lastActivityAt) } : {}),
237
+ ...(crossSliceUsage !== undefined ? { crossSliceUsage } : {}),
216
238
  result: run.result ?? undefined,
217
239
  supervisorCost,
218
240
  suggestions,
@@ -1070,7 +1092,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1070
1092
  }
1071
1093
  const { status, body: respBody } = await deps.toolApproval.respond(id, principal, body, req);
1072
1094
  const retryAfterSec = respBody.retryAfterSec;
1073
- if (status === 429 && typeof retryAfterSec === "number" && Number.isFinite(retryAfterSec) && retryAfterSec >= 0) {
1095
+ if (typeof retryAfterSec === "number" && Number.isFinite(retryAfterSec) && retryAfterSec >= 0) {
1074
1096
  res.setHeader("retry-after", String(Math.ceil(retryAfterSec)));
1075
1097
  }
1076
1098
  sendJson(res, status, respBody);
@@ -1330,6 +1352,8 @@ export async function createDurableRun(req, res, ctx, prepared, idemKey) {
1330
1352
  const created = await runStore.createRun(taskId, sessionId, prepared.auth?.principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
1331
1353
  if (created.ok)
1332
1354
  clearTurnActivity(taskId);
1355
+ if (created.ok)
1356
+ recordResourceWindow(taskId, prepared.spec.resourceSuspend);
1333
1357
  if (created.ok)
1334
1358
  deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id);
1335
1359
  if (!created.ok) {
@@ -7,6 +7,7 @@ import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotific
7
7
  import { createLedgerSink } from "../../trace/ledger-sink.js";
8
8
  import { registerEngineNoticeLeg } from "../../trace/engine-notice-wire.js";
9
9
  import { clearTurnActivity, readTurnActivityMs, recordTurnActivity } from "../../turn-activity.js";
10
+ import { recordResourceWindow } from "../../resource-window.js";
10
11
  import { redactSecrets } from "../../trace/redact.js";
11
12
  import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, wiringManifestOperatorEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
12
13
  import { cascadeConfig, runMeta } from "../run-meta.js";
@@ -192,6 +193,8 @@ async function handleTasksBody(req, res, url, ctx, miss) {
192
193
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
193
194
  if (created.ok)
194
195
  clearTurnActivity(tid);
196
+ if (created.ok)
197
+ recordResourceWindow(tid, prepared.spec.resourceSuspend);
195
198
  if (created.ok)
196
199
  deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id);
197
200
  if (!created.ok) {
@@ -715,6 +718,8 @@ async function handleTasksBody(req, res, url, ctx, miss) {
715
718
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
716
719
  if (created.ok)
717
720
  clearTurnActivity(tid);
721
+ if (created.ok)
722
+ recordResourceWindow(tid, prepared.spec.resourceSuspend);
718
723
  if (!created.ok)
719
724
  return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config, runStaleSec: deps.config.runStaleSec, turnActivity: readTurnActivityMs }, prepared.spec.sessionId, created.activeTaskId) };
720
725
  deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id);
@@ -14,6 +14,7 @@ import { normalizeApproachNotice, validateTaskAgents } from "../spec-fields.js";
14
14
  import { isValidCwd, MAX_ADDITIONAL_DIRS } from "../task-cwd.js";
15
15
  import { runInBackground, evictIfConflict, stripCheckpointToken, parkToolCallId, TurnAnchorCapture, HEARTBEAT_MS, markChildrenStoppedByUserOnAbort } from "../runs.js";
16
16
  import { readTurnActivityMs, recordTurnActivity } from "../turn-activity.js";
17
+ import { recordResumeResourceWindow } from "../resource-window.js";
17
18
  import { publicStoreProbeError } from "../store-live-probe.js";
18
19
  import { looksLikeJwt } from "@sema-agent/registry-core/api/auth-bridge";
19
20
  import {} from "../orchestration/workflow-agent-steer.js";
@@ -1140,6 +1141,8 @@ export function createHttpServer(rawDeps) {
1140
1141
  ? fleetRunPublisher(deps.fleetBus, { runId: taskId, scope: fleetScope, rootTaskId: taskId, ...fleetRunLabels(resumeObjective) })
1141
1142
  : undefined;
1142
1143
  const resumeTaskConfig = taskId ? { ...taskConfig, taskId } : taskConfig;
1144
+ if (taskId && resumeTaskConfig.resourceSuspend)
1145
+ recordResumeResourceWindow(taskId, resumeTaskConfig.resourceSuspend);
1143
1146
  let fleetSettled = false;
1144
1147
  const settleFleet = (status, residuals) => {
1145
1148
  if (fleetSettled)
@@ -1608,6 +1611,7 @@ export function createHttpServer(rawDeps) {
1608
1611
  return {
1609
1612
  status,
1610
1613
  body: {
1614
+ ...(taskId !== undefined ? { taskId } : {}),
1611
1615
  error: e.message,
1612
1616
  errorCode: e.code === "checkpoint.invalid_outcome"
1613
1617
  ? (outcome.gate === "policy_ask" ? "approval_binding_mismatch" : "resume_outcome_invalid")