@botbuddy/cli 1.63.43 → 1.64.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,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.43",
3
- "source_commit": "580bdbe602c9281c639efbd317018a75011ea70c",
4
- "source_sha256": "d04edc606580d8c6304eb6ed88d1fbb9b5a6c8d9b0aad2fea9f92e1cc1cedbb7",
2
+ "release_version": "1.64.0",
3
+ "source_commit": "e6cc3786c39bf5326fe5b5dabccd80c1fba8b17d",
4
+ "source_sha256": "0f97518d4f2ad1ac4d1195fd80ce4fa875a587b3ab2f102fbb5e28d6d5d62172",
5
5
  "target": "darwin-arm64",
6
- "sha256": "ec91fd06a1a4da1a271c5343619f66f18ea16c2d676247fbd22e5bfaa9a9a976"
6
+ "sha256": "675ce80ef07e7ef87f3a9111e911cd6241a775d5af4812dd9f53fb29846b9832"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.63.43",
3
- "source_commit": "580bdbe602c9281c639efbd317018a75011ea70c",
4
- "source_sha256": "d04edc606580d8c6304eb6ed88d1fbb9b5a6c8d9b0aad2fea9f92e1cc1cedbb7",
2
+ "release_version": "1.64.0",
3
+ "source_commit": "e6cc3786c39bf5326fe5b5dabccd80c1fba8b17d",
4
+ "source_sha256": "0f97518d4f2ad1ac4d1195fd80ce4fa875a587b3ab2f102fbb5e28d6d5d62172",
5
5
  "target": "linux-x64",
6
- "sha256": "8437df29241846a8dddfad4ce6c5a77237c0dc38bf8006512e4c22fe911fb380"
6
+ "sha256": "3223c862b52f1e81777e7c9403f6c854c5192f618c2c7ee46c56ad5b0a9ccfd9"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.63.43",
4
- "source_commit": "580bdbe602c9281c639efbd317018a75011ea70c",
5
- "source_sha256": "d04edc606580d8c6304eb6ed88d1fbb9b5a6c8d9b0aad2fea9f92e1cc1cedbb7",
3
+ "release_version": "1.64.0",
4
+ "source_commit": "e6cc3786c39bf5326fe5b5dabccd80c1fba8b17d",
5
+ "source_sha256": "0f97518d4f2ad1ac4d1195fd80ce4fa875a587b3ab2f102fbb5e28d6d5d62172",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "ec91fd06a1a4da1a271c5343619f66f18ea16c2d676247fbd22e5bfaa9a9a976",
11
+ "sha256": "675ce80ef07e7ef87f3a9111e911cd6241a775d5af4812dd9f53fb29846b9832",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "8437df29241846a8dddfad4ce6c5a77237c0dc38bf8006512e4c22fe911fb380",
18
+ "sha256": "3223c862b52f1e81777e7c9403f6c854c5192f618c2c7ee46c56ad5b0a9ccfd9",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.63.43",
3
+ "version": "1.64.0",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,81 @@
1
+ import { callToolJson, resolveCallAuth } from "../api.mjs";
2
+ import {
3
+ assertSshHost,
4
+ installedBinaryPath,
5
+ stageRemoteDaemonRelease,
6
+ } from "./installer.mjs";
7
+ import { validateDaemonRelease } from "./release.mjs";
8
+ import { nativeHandoverCommand } from "./handover.mjs";
9
+ const fail = (code) => {
10
+ throw new Error(code);
11
+ };
12
+ // One explicit administrator installation grants only the fixed native
13
+ // coordinator/schedule. Persisted operator opt-in remains a separate drawer act.
14
+ export async function installAutomaticDaemonUpdater(spec, {
15
+ validateRelease = validateDaemonRelease,
16
+ resolveAuth = resolveCallAuth,
17
+ identity = callToolJson,
18
+ stage = stageRemoteDaemonRelease,
19
+ native = nativeHandoverCommand,
20
+ } = {}) {
21
+ if (
22
+ !spec || typeof spec !== "object" || Array.isArray(spec) ||
23
+ Object.keys(spec).some((key) =>
24
+ ![
25
+ "platform",
26
+ "arch",
27
+ "sshHost",
28
+ "serviceUser",
29
+ "releaseRoot",
30
+ "expectedHostKey",
31
+ "tenant",
32
+ ].includes(key)
33
+ ) || !["linux", "darwin"].includes(spec.platform) ||
34
+ typeof spec.serviceUser !== "string" || spec.serviceUser === "root" ||
35
+ !/^[a-z_][a-z0-9_-]*$/i.test(spec.serviceUser) ||
36
+ !/^bbh-[0-9a-f]{24}$/.test(spec.expectedHostKey) ||
37
+ typeof spec.tenant !== "string" || !spec.tenant
38
+ ) fail("daemon_automatic_install_spec_invalid");
39
+ assertSshHost(spec.sshHost);
40
+ const release = await validateRelease({ releaseRoot: spec.releaseRoot }, {
41
+ platform: spec.platform,
42
+ arch: spec.arch,
43
+ });
44
+ const resolved = await resolveAuth({
45
+ toolName: "daemon_handover",
46
+ preferSession: false,
47
+ });
48
+ if (resolved?.error || !resolved?.auth) {
49
+ fail("daemon_automatic_install_auth_required");
50
+ }
51
+ const who = await identity("whoami", {}, {
52
+ auth: resolved.auth,
53
+ tenant: spec.tenant,
54
+ });
55
+ if (
56
+ !who?.ok || who.isError || typeof who.data?.owner_email !== "string" ||
57
+ !who.data.owner_email.includes("@") || who.data.tenant_id !== spec.tenant
58
+ ) fail("daemon_automatic_install_owner_unavailable");
59
+ const staged = await stage({
60
+ platform: spec.platform,
61
+ host: spec.sshHost,
62
+ user: spec.serviceUser,
63
+ release,
64
+ });
65
+ const binary = installedBinaryPath(release);
66
+ if (staged?.binary !== binary) fail("daemon_automatic_install_stage_invalid");
67
+ const result = await native({
68
+ host: spec.sshHost,
69
+ binary,
70
+ mode: "automatic-install",
71
+ input: {
72
+ host_key: spec.expectedHostKey,
73
+ owner_email: who.data.owner_email,
74
+ tenant_id: who.data.tenant_id,
75
+ },
76
+ });
77
+ if (result?.installed !== true || result.protocol !== 2) {
78
+ fail("daemon_automatic_install_receipt_invalid");
79
+ }
80
+ return { installed: true, protocol: 2, host_key: spec.expectedHostKey };
81
+ }
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { registerContainerHost } from "../api.mjs";
8
+ import { installAutomaticDaemonUpdater } from "./automatic.mjs";
8
9
  import { upgradeDaemonHost } from "./handover.mjs";
9
10
  import { awaitHostApproval } from "./approval-wait.mjs";
10
11
  import { bootstrapFreshDaemon } from "./bootstrap.mjs";
@@ -49,6 +50,7 @@ export const daemonHelp = `Usage:
49
50
  bb daemon config-render '<json host policy>'
50
51
  bb daemon enroll '<json host-bound enrollment spec>'
51
52
  bb daemon bootstrap '<json fresh-host bootstrap spec>'
53
+ bb daemon updater-install '<json automatic updater installation spec>'
52
54
  bb daemon upgrade '<json controlled host upgrade spec>'
53
55
 
54
56
  The Go payload is verified from the extracted universal package before sudo.
@@ -293,6 +295,7 @@ export async function cmdDaemon(args, {
293
295
  verifyRemoteHelperIdentity = verifyRemoteHelperIdentityOnTarget,
294
296
  bootstrap = bootstrapFreshDaemon,
295
297
  upgrade = upgradeDaemonHost,
298
+ installAutomaticUpdater = installAutomaticDaemonUpdater,
296
299
  registerHost = registerContainerHost,
297
300
  createBootstrapIdentity = createRemoteBootstrapIdentity,
298
301
  inspectBootstrapIdentity = inspectRemoteBootstrapIdentity,
@@ -305,6 +308,11 @@ export async function cmdDaemon(args, {
305
308
  log(daemonHelp);
306
309
  return 0;
307
310
  }
311
+ if(args[0]==="updater-install"){
312
+ if(args.length!==2)fail("Usage: bb daemon updater-install '<json automatic updater installation spec>'");
313
+ let spec;try{spec=JSON.parse(args[1]);}catch{fail("daemon_automatic_install_spec_invalid");}
314
+ log(JSON.stringify(await installAutomaticUpdater(spec)));return 0;
315
+ }
308
316
  if(args[0]==="upgrade"){
309
317
  if(args.length!==2)fail("Usage: bb daemon upgrade '<json controlled host upgrade spec>'");
310
318
  let spec;try{spec=JSON.parse(args[1]);}catch{fail("daemon_handover_spec_invalid");}
@@ -27,9 +27,9 @@ export function nativeHandoverCommand(
27
27
  { spawnImpl = spawn } = {},
28
28
  ) {
29
29
  if (
30
- !immutableBinaryRE.test(binary) || !["inspect", "submit"].includes(mode)
30
+ !immutableBinaryRE.test(binary) || !["inspect", "submit", "automatic-install"].includes(mode)
31
31
  ) fail("daemon_handover_command_invalid");
32
- const flag = mode === "inspect"
32
+ const flag = mode === "automatic-install" ? "--native-automatic-update-install" : mode === "inspect"
33
33
  ? "--native-handover-inspect"
34
34
  : "--native-handover-submit";
35
35
  let command = "sudo", args = ["-n", binary, flag];
@@ -13,7 +13,10 @@
13
13
  // path, hostname, env value, argv or command output can reach the server. The
14
14
  // server re-validates the same allowlist (supabase/functions/_shared/lib/stackRunOutcome.ts).
15
15
 
16
+ import { spawnSync } from "node:child_process";
16
17
  import { randomUUID } from "node:crypto";
18
+ import { lstatSync } from "node:fs";
19
+ import { basename, isAbsolute } from "node:path";
17
20
  import { VERSION } from "./version.mjs";
18
21
  import { validStackLeaseId } from "./stack-lease-correlation.mjs";
19
22
 
@@ -23,7 +26,15 @@ export const STACK_RUN_OUTCOME_TOOL = "record_stack_run_outcome";
23
26
  export const STACK_RUN_OUTCOME_TIMEOUT_MS = 2000;
24
27
  /** `=off` disables the record (the CLI test suites set it; nothing else should). */
25
28
  export const STACK_RUN_OUTCOME_OPT_OUT_ENV = "BOTBUDDY_STACK_RUN_OUTCOME";
26
- export const STACK_RUN_OUTCOME_CATEGORIES = Object.freeze(["platform", "script", "interrupted", "invocation"]);
29
+ export const STACK_RUN_OUTCOME_CATEGORIES = Object.freeze(["platform", "script", "interrupted", "invocation", "released"]);
30
+ /**
31
+ * BOT-2881: a signal carries no sender, so a hold's owner says so first. The hold sets this to an
32
+ * absolute path when it starts `bb stack run`, and its stop command creates that file before
33
+ * sending SIGTERM. Without it, every signal stays `interrupted`.
34
+ */
35
+ export const STACK_RELEASE_INTENT_ENV = "BOTBUDDY_STACK_RELEASE_INTENT_FILE";
36
+ export const STACK_SIGNAL_SOURCE_OWNER_RELEASE = "owner_release";
37
+ export const STACK_RELEASE_INTENT_MTIME_SLACK_MS = 2000;
27
38
 
28
39
  const CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
29
40
  const TOKEN_RE = /^[a-z0-9_-]{1,64}$/;
@@ -31,6 +42,7 @@ const LANE_RE = /^[A-Za-z0-9:_.-]{1,128}$/;
31
42
  const TICKET_RE = /^[A-Za-z][A-Za-z0-9]*-\d{1,9}$/;
32
43
  const REPO_RE = /^[A-Za-z0-9._/-]{1,128}$/;
33
44
  const VERSION_RE = /^[0-9A-Za-z.+-]{1,32}$/;
45
+ const SIGNAL_RE = /^SIG[A-Z]{1,12}$/;
34
46
  const AUTHORIZATION_REASON_RE = /^[a-z0-9_.:-]{1,64}$/;
35
47
 
36
48
  // Mirrors stack.mjs EXIT (kept local so this module stays dependency-free for tests).
@@ -57,12 +69,16 @@ function boundedCode(value) {
57
69
  * (`--hard-ttl` stop → code HARD_TTL)
58
70
  * platform — the stack, its transport, the credential or the backend failed
59
71
  * interrupted — a signal ended the run (operator Ctrl-C / parent kill)
72
+ * released — the owner of a hold stopped it on purpose (BOT-2881), and its lease and cleanup held
60
73
  * invocation — exit 4: bad arguments or a refused request before any lease
61
74
  */
62
75
  export function classifyStackRunOutcome(fields = {}, exitCode) {
63
76
  const code = boundedCode(fields.code);
64
77
  const child = Number.isInteger(fields.child_exit_code) ? fields.child_exit_code : null;
65
78
  const outcome = typeof fields.outcome === "string" ? fields.outcome : null;
79
+ // BOT-2881: the owner's stop never hides an unproven teardown (exit 143 would otherwise read as a signal).
80
+ if (fields.stop_signal?.source === STACK_SIGNAL_SOURCE_OWNER_RELEASE && fields.cleanup === false) return { category: "platform", code: "CLEANUP_FAILED" };
81
+ if (outcome === "released") return { category: "released", code };
66
82
  if (outcome === "interrupted") return { category: "interrupted", code };
67
83
  if (code && PLATFORM_CODES.has(code)) return { category: "platform", code };
68
84
  // The stdout fence also covers unresolved cleanup. The lifecycle supplies its
@@ -92,6 +108,61 @@ export function classifyStackRunOutcome(fields = {}, exitCode) {
92
108
  }
93
109
  }
94
110
 
111
+ /**
112
+ * BOT-2881: whether a lifecycle result is an owner's release that held: the signal decided the
113
+ * exit, not fenced, not a typed platform loss, and cleanup not refused. Anything else keeps its
114
+ * own outcome and category.
115
+ */
116
+ export function ownerReleaseHeld(result = {}) {
117
+ return result.stopSignal?.source === STACK_SIGNAL_SOURCE_OWNER_RELEASE && SIGNAL_EXITS.has(result.exitCode)
118
+ // Any typed code (a platform loss, CALLBACK_CLEANUP_UNCERTAIN, …) keeps the run's own outcome.
119
+ && result.fenced !== true && result.cleanup?.ok !== false && result.code == null;
120
+ }
121
+
122
+ /** BOT-2881: the declared intent exists, is a regular file and was written after this run began. */
123
+ export function stackReleaseIntentDeclared({ env = process.env, startedAtMs } = {}) {
124
+ const file = env?.[STACK_RELEASE_INTENT_ENV];
125
+ if (typeof file !== "string" || !isAbsolute(file) || !Number.isFinite(startedAtMs)) return false;
126
+ try {
127
+ const entry = lstatSync(file);
128
+ // A filesystem clock may trail Date.now() by a few ms (coarse Linux mtimes); an earlier run's
129
+ // file is minutes or hours old, so a small slack never admits it.
130
+ return entry.isFile() && entry.mtimeMs >= startedAtMs - STACK_RELEASE_INTENT_MTIME_SLACK_MS;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /** BOT-2881: the parent's process name as a bounded token, or null. A name only: never its argv. */
137
+ export function readParentProcessName(ppid, { run = spawnSync } = {}) {
138
+ if (!Number.isInteger(ppid) || ppid <= 1) return null;
139
+ try {
140
+ const ps = run("ps", ["-o", "comm=", "-p", String(ppid)], { encoding: "utf8", timeout: 300 });
141
+ if (ps?.status !== 0 || typeof ps.stdout !== "string") return null;
142
+ const name = basename(ps.stdout.trim()).replace(/^-/, "").toLowerCase();
143
+ return TOKEN_RE.test(name) ? name : null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * BOT-2881: what this process can know about the signal that is ending it. Node exposes no sender,
151
+ * so `source` is one of: the owner's declared release, a terminal (SIGINT/SIGHUP with a TTY), a
152
+ * parent that had already gone, or unknown, with the parent that could have sent it named.
153
+ * `lossFirst`: the lease or its transport was already lost, so this stop did not end the run.
154
+ */
155
+ export function observeStackRunSignal(signal, {
156
+ env = process.env, startedAtMs, startPpid = null, ppid = process.ppid, stdinIsTty = process.stdin?.isTTY === true,
157
+ lossFirst = false, intentDeclared = stackReleaseIntentDeclared, parentName = readParentProcessName,
158
+ } = {}) {
159
+ if (signal === "SIGTERM" && !lossFirst && intentDeclared({ env, startedAtMs })) {
160
+ return { signal, source: STACK_SIGNAL_SOURCE_OWNER_RELEASE, parent: null };
161
+ }
162
+ if (startPpid != null && ppid !== startPpid) return { signal, source: "parent_exited", parent: null };
163
+ return { signal, source: stdinIsTty && signal !== "SIGTERM" ? "terminal" : "unknown", parent: parentName(ppid) };
164
+ }
165
+
95
166
  /** The lane id from `$BOTBUDDY_LANE_SOURCE_RUN_ID` (`lefthook:<invocation>:<lane>`), or null. */
96
167
  export function laneFromSourceRunId(value) {
97
168
  if (typeof value !== "string" || !value.startsWith("lefthook:")) return null;
@@ -110,6 +181,8 @@ const exitInt = (value) => (Number.isInteger(value) && value >= 0 && value <= 25
110
181
  */
111
182
  export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = randomUUID(), opts = {}, env = process.env, durationMs = null }) {
112
183
  const { category, code } = classifyStackRunOutcome(fields, exitCode);
184
+ // BOT-2881: present only when a signal ended the run, so an unsignalled run's payload is unchanged.
185
+ const signal = pick(fields.stop_signal?.signal, SIGNAL_RE);
113
186
  // BOT-2880: sent only when present, because an older server refuses an unknown key.
114
187
  const callbackAuthorizationReason = pick(fields.callback_authorization_reason, AUTHORIZATION_REASON_RE);
115
188
  return {
@@ -129,6 +202,11 @@ export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = ran
129
202
  repo: pick(opts.repo, REPO_RE),
130
203
  duration_ms: Number.isFinite(durationMs) && durationMs >= 0 ? Math.round(durationMs) : null,
131
204
  cli_version: pick(VERSION, VERSION_RE) ?? "unknown",
205
+ ...(signal ? {
206
+ signal,
207
+ signal_source: pick(fields.stop_signal.source, TOKEN_RE),
208
+ signal_parent: pick(fields.stop_signal.parent, TOKEN_RE),
209
+ } : {}),
132
210
  ...(callbackAuthorizationReason ? { callback_authorization_reason: callbackAuthorizationReason } : {}),
133
211
  };
134
212
  }
package/src/stack.mjs CHANGED
@@ -58,7 +58,7 @@ import { FORWARD_ATTESTATION_ENV, startForwardAttestation, readForwardAttestatio
58
58
  import { stackServicesForForwards } from "./stack-service-discovery.mjs";
59
59
  import { ENGINE_PUBLISHES_BEYOND_LOOPBACK, engineSocketIsLocal, formatPublishIp, isLoopbackIp, publishIpFix, resolvePublishIp } from "./docker-publish-ip.mjs";
60
60
  import { bold, dim, yellow } from "./utils.mjs";
61
- import { reportStackRunOutcome } from "./stack-run-outcome.mjs";
61
+ import { STACK_RELEASE_INTENT_ENV, observeStackRunSignal, ownerReleaseHeld, reportStackRunOutcome } from "./stack-run-outcome.mjs";
62
62
 
63
63
  export const STACK_SCHEMA_VERSION = 1;
64
64
 
@@ -253,6 +253,9 @@ ${bold("run OPTIONS")}
253
253
  goes IDLE (no reap wait; receipt cleanup {idle, idle_since}) and the stack
254
254
  directory + port block stay reserved (24 h, renewed on re-adoption).
255
255
  Interrupts, --hard-ttl, a fenced heartbeat or a lost tunnel always reap.
256
+ A hold that outlives its starter sets ${STACK_RELEASE_INTENT_ENV}
257
+ to an absolute path and creates that file just before its stop sends SIGTERM:
258
+ the run is then recorded as released, not interrupted (BOT-2881).
256
259
  --connection-file P Optional empty file path for the mode-0600 connection JSON.
257
260
  --hard-ttl N Bound the child runtime; timeout requests normal cleanup.
258
261
  --local-exec Rejected: stack run never starts Docker directly.
@@ -3752,7 +3755,8 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
3752
3755
  const withExecution = incidentBox.execution && !withIncident?.execution ? { ...withIncident, execution: incidentBox.execution } : withIncident;
3753
3756
  const withIdle = incidentBox.idleLease && !withExecution?.idleLease ? { ...withExecution, idleLease: incidentBox.idleLease } : withExecution;
3754
3757
  const withPortBlock = incidentBox.portBlock && !withIdle?.portBlock ? { ...withIdle, portBlock: incidentBox.portBlock } : withIdle;
3755
- const final = incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
3758
+ const withWebUrl = incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
3759
+ const final = incidentBox.stopSignal && withWebUrl ? { ...withWebUrl, stopSignal: incidentBox.stopSignal } : withWebUrl;
3756
3760
  // BOT-2467: non-enumerable, so the receipt fields and result comparisons never see it.
3757
3761
  if (final && typeof final === "object" && incidentBox.outcomeChannel) {
3758
3762
  Object.defineProperty(final, OUTCOME_CHANNEL, { value: incidentBox.outcomeChannel, enumerable: false });
@@ -3875,6 +3879,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3875
3879
  let execution = null;
3876
3880
  let leaseProvisioned = false;
3877
3881
  let receivedSignal = null;
3882
+ // BOT-2881: an intent counts only when written after this run began; a parent change is an orphan.
3883
+ const observeSignal = adapters.observeSignal ?? observeStackRunSignal;
3884
+ const runStartedAtMs = Date.now();
3885
+ const startPpid = process.ppid;
3878
3886
  let forcedStopCode = null;
3879
3887
  let fencing = false;
3880
3888
  let activeWaitAbort = null;
@@ -4080,6 +4088,11 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
4080
4088
  const onSignal = (signal) => {
4081
4089
  if (receivedSignal) return;
4082
4090
  receivedSignal = signal;
4091
+ try {
4092
+ incidentBox.stopSignal = observeSignal(signal, { startedAtMs: runStartedAtMs, startPpid, lossFirst: fencing || forcedStopCode != null });
4093
+ } catch {
4094
+ incidentBox.stopSignal = { signal, source: "unknown", parent: null };
4095
+ }
4083
4096
  activeWaitAbort?.abort("interrupted");
4084
4097
  stopChild(signal);
4085
4098
  // If capacity is still queued/provisioning there is no child to hold open;
@@ -5151,7 +5164,9 @@ export function runReceiptFields(result) {
5151
5164
  return {
5152
5165
  // BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
5153
5166
  // so automation can branch on it instead of matching the message text.
5154
- command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
5167
+ command: "run", outcome: ownerReleaseHeld(result) ? "released" : result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
5168
+ // BOT-2881: the signal that ended this run and what is known of its source (never on a run no signal touched).
5169
+ ...(result.stopSignal ? { stop_signal: result.stopSignal } : {}),
5155
5170
  ...(result.state ? { state: result.state } : {}),
5156
5171
  // BOT-2468: the lease's /docker?lease= deeplink (null when the server sent none).
5157
5172
  web_url: result.webUrl ?? null,