pi-better-harness 0.38.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/node_modules/pi-better-background-tasks/README.md +64 -4
  2. package/node_modules/pi-better-background-tasks/package.json +1 -1
  3. package/node_modules/pi-better-background-tasks/src/output.ts +22 -2
  4. package/node_modules/pi-better-background-tasks/src/process.ts +100 -1
  5. package/node_modules/pi-better-background-tasks/src/runtime.ts +179 -10
  6. package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +18 -1
  7. package/node_modules/pi-better-background-tasks/src/shared-failure-observations.ts +78 -24
  8. package/node_modules/pi-better-background-tasks/src/shared-log-utils.ts +277 -31
  9. package/node_modules/pi-better-background-tasks/src/stall.ts +4 -1
  10. package/node_modules/pi-better-background-tasks/src/tools.ts +14 -3
  11. package/node_modules/pi-better-background-tasks/src/types.ts +33 -1
  12. package/node_modules/pi-better-subagents/cleanup.ts +58 -2
  13. package/node_modules/pi-better-subagents/continuation.ts +58 -7
  14. package/node_modules/pi-better-subagents/docs/usage.md +76 -9
  15. package/node_modules/pi-better-subagents/finalization.ts +1 -1
  16. package/node_modules/pi-better-subagents/index.ts +595 -287
  17. package/node_modules/pi-better-subagents/output-payload.ts +40 -3
  18. package/node_modules/pi-better-subagents/package.json +1 -1
  19. package/node_modules/pi-better-subagents/registry.ts +215 -4
  20. package/node_modules/pi-better-subagents/shared-callback-batcher.ts +18 -1
  21. package/node_modules/pi-better-subagents/shared-failure-observations.ts +78 -24
  22. package/node_modules/pi-better-subagents/shared-log-utils.ts +277 -31
  23. package/node_modules/pi-better-subagents/stop.ts +60 -2
  24. package/node_modules/pi-better-subagents/timing.ts +45 -4
  25. package/node_modules/pi-better-subagents/tools.ts +29 -6
  26. package/package.json +3 -3
@@ -116,16 +116,32 @@ new Pi process, a task whose process is gone is marked lost.
116
116
 
117
117
  A task can remain `running` without recent progress evidence. Compact status and
118
118
  navigator use the same observation. Process tasks use recorded progress and the
119
- log's modification time. Watches use recorded progress and completed polls. Before
120
- any progress, the task's launch time anchors the silence duration. This does not
121
- measure CPU activity, network waits, or useful application work.
119
+ log's modification time. A watch advances only when a poll observes a changed
120
+ outcome: running a poll, appending its result to the watch log, or polling on
121
+ schedule is activity, not progress. Before any progress, the task's launch time
122
+ anchors the silence duration. This does not measure CPU activity, network waits,
123
+ or useful application work.
122
124
 
123
125
  At 60 seconds without observed progress, work is `quiet`. At 5 minutes, it has a
124
126
  `suspected stall`. Both thresholds are inclusive. Set `PI_BETTER_STALL_QUIET_MS`
125
127
  and `PI_BETTER_STALL_MS` to positive millisecond durations to override them.
126
128
  Compact status shows the last observed time, silence duration, and a warning:
127
129
  silence alone does not prove the task is hung. Terminal tasks are not reported as
128
- suspected stalls. New output or a completed watch poll refreshes the observation.
130
+ suspected stalls. New process output or a changed watch outcome refreshes the
131
+ observation, so a watch that keeps reporting the same thing becomes quiet and
132
+ then a suspected stall while it keeps polling on schedule.
133
+
134
+ A watch's outcome is compared by content, not by when it ran. JSON stdout is
135
+ compared with sorted keys and without insignificant whitespace, terminal control
136
+ sequences and carriage-return redraws are ignored, and the poll's start time, end
137
+ time, duration, and check count are never compared. Exit status, signal, error,
138
+ stdout, and stderr content are compared. A watch whose output carries its own
139
+ changing timestamps or elapsed durations will therefore read as progress: project
140
+ the fields that matter into stdout JSON (for example with `jq`) instead of
141
+ printing a whole status page. The comparison is stored with the task, so it
142
+ survives `/reload` and session resume; a watch restored from an older record
143
+ adopts a comparison without resetting its silence, so reloads cannot hide a
144
+ stationary watch.
129
145
 
130
146
  A status cursor changes when the observation crosses either threshold, even if
131
147
  metadata and log bytes have not changed. Within the same observation state,
@@ -293,6 +309,50 @@ history count line with no incident cursor; pass `history: true` to
293
309
  (120-byte excerpt, evidence such as `output.log#poll=3`); the raw log keeps the
294
310
  full evidence path.
295
311
 
312
+ ## Chained commands and the step gate
313
+
314
+ A shell command that chains steps with `;` or newlines reports only its last
315
+ command's exit status. `ctest; write-summary` therefore reads as success when
316
+ `ctest` fails and the summary exits 0. That is ordinary shell behavior, and it
317
+ stays the default.
318
+
319
+ Pass `require_successful_steps: true` on `bg_task_spawn` (or `bg_task` with
320
+ `action:spawn`) to opt into a step gate:
321
+
322
+ ```json
323
+ { "command": "ctest --output-on-failure\nnpm run report", "require_successful_steps": true }
324
+ ```
325
+
326
+ The gate is a bash `errexit` contract, not shell parsing: the command runs under
327
+ `set -e`, so its first unhandled failing step ends the task with that step's
328
+ exit code and the remaining steps do not run. That covers the same list, shell
329
+ functions and subshells, and it is why a summary command
330
+ after a failed `ctest` can no longer turn the task green. A failure the command
331
+ handles itself (`|| true`, `if`/`while`/`until` conditions, `!`, `&&`/`||`
332
+ lists) is not a gate failure, and neither is a failing process substitution
333
+ whose status bash discards. Status reports the real process exit with
334
+ `(step gate)` plus the failing step:
335
+
336
+ ```text
337
+ exit=8 (step gate)
338
+ step gate: ctest --output-on-failure exited 8; the shell stopped at that step
339
+ ```
340
+
341
+ A command that switches errexit off (`set +e`) keeps a different own exit; when
342
+ that exit is 0 the gate still fails the task with the failing step's code and
343
+ the status says so (`the shell's own exit was 0`).
344
+
345
+ The gate is refused (and the task is not started) with `shell:false`, whose
346
+ argv already reports the real exit code, and with structured `ssh`, whose
347
+ command runs under remote semantics the local gate cannot observe. It applies
348
+ to spawns, not to watch checks: a check is one polled command whose own exit
349
+ code is the outcome. Limits inherited from bash's own errexit rules: assignment
350
+ builtins such as `local` and `export` mask a command substitution's status.
351
+ Bash also clears errexit inside command substitutions by default, so
352
+ `x=$(false; echo summary)` can still succeed. A command that disables errexit
353
+ and clears the ERR trap can bypass the gate, and
354
+ `exec` replaces the shell before the gate can decide.
355
+
296
356
  ## When To Use
297
357
 
298
358
  Use this package for shell commands that need logs, status, cancellation, or completion notifications across a Pi turn.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -224,7 +224,20 @@ function formatDecision(meta: BackgroundTaskMeta): string | undefined {
224
224
  lines.push(formatCondition(result.matchedCondition, result.matchedValue));
225
225
  }
226
226
  if (meta.lastExitCode !== undefined || meta.lastSignal) {
227
- lines.push(`exit=${meta.lastExitCode ?? "null"}${meta.lastSignal ? ` signal=${meta.lastSignal}` : ""}`);
227
+ // A gated task's exit can be a failing step's code rather than the last command's (#619).
228
+ const gate = meta.stepGateFailure ? " (step gate)" : "";
229
+ lines.push(`exit=${meta.lastExitCode ?? "null"}${meta.lastSignal ? ` signal=${meta.lastSignal}` : ""}${gate}`);
230
+ }
231
+ if (meta.stepGateFailure) {
232
+ // "stopped at that step" is the usual errexit case; a differing shell exit means the
233
+ // command disabled errexit and the gate's EXIT override or the command decided the exit.
234
+ const gate = meta.stepGateFailure;
235
+ const ending = gate.shellExitCode === null
236
+ ? "the shell exit is unknown"
237
+ : gate.shellExitCode === gate.stepExitCode
238
+ ? "the shell stopped at that step"
239
+ : `the shell's own exit was ${gate.shellExitCode}`;
240
+ lines.push(`step gate: ${oneLine(gate.command || "a chained step", 120)} exited ${gate.stepExitCode}; ${ending}`);
228
241
  }
229
242
  if (result.reason && !result.matchedCondition) lines.push(oneLine(result.reason, 240));
230
243
  if (meta.error && meta.error !== meta.stopError && meta.error !== result.reason) {
@@ -288,6 +301,11 @@ function formatProcessDiagnostics(diagnostics: TaskProcessDiagnostics | undefine
288
301
  return `process diagnostics: ${facts}\nA snapshot cannot prove useful work or a hang.`;
289
302
  }
290
303
 
304
+ function formatStepGate(meta: BackgroundTaskMeta): string | undefined {
305
+ if (!meta.requireSuccessfulSteps) return undefined;
306
+ return "step gate: on; the command runs under bash errexit, so its first unhandled failing step ends the task.";
307
+ }
308
+
291
309
  function formatLaunchSandbox(meta: BackgroundTaskMeta): string | undefined {
292
310
  const sandbox = meta.sandbox;
293
311
  if (!sandbox) return "sandbox: unknown; launch-time policy was not recorded.";
@@ -330,6 +348,8 @@ function contentRevision(meta: BackgroundTaskMeta, stall?: StallObservation): st
330
348
  meta.lastProgressAt ?? null,
331
349
  meta.lastExitCode ?? null,
332
350
  meta.lastSignal ?? null,
351
+ meta.requireSuccessfulSteps ?? null,
352
+ meta.stepGateFailure ?? null,
333
353
  meta.error ?? null,
334
354
  meta.stopError ?? null,
335
355
  meta.logGeneration ?? 0,
@@ -493,7 +513,7 @@ export function formatLaunch(meta: BackgroundTaskMeta, firstCheck?: FirstWatchCh
493
513
  identity: `Started background ${meta.kind} ${label}. Status: ${meta.status}.`,
494
514
  failure: incidentSection(meta.id, {}),
495
515
  decision: formatDecision(meta),
496
- diagnostics: [formatLaunchSandbox(meta), ...remoteLines, ...(checkText ? [checkText] : [])].filter(Boolean).join("\n") || undefined,
516
+ diagnostics: [formatLaunchSandbox(meta), formatStepGate(meta), ...remoteLines, ...(checkText ? [checkText] : [])].filter(Boolean).join("\n") || undefined,
497
517
  progress: withLog ? logLine : undefined,
498
518
  },
499
519
  gaps: taskGaps(meta),
@@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, writeSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import type { ChildProcess } from "node:child_process";
5
- import type { CommandResult, CommandSpec } from "./types.js";
5
+ import type { CommandResult, CommandSpec, StepGateFailure } from "./types.js";
6
6
 
7
7
  /** Known Git for Windows locations; `bash -lc` needs a real bash, not the WSL shim. */
8
8
  const WINDOWS_BASH_CANDIDATES = [
@@ -75,6 +75,105 @@ export function bashSingleQuote(value: string): string {
75
75
  return `'${value.replace(/'/g, "'\\''")}'`;
76
76
  }
77
77
 
78
+ /**
79
+ * Env var carrying the operator's own command into a gated launch (#619).
80
+ *
81
+ * The gate script `eval`s this value instead of concatenating it, so a command
82
+ * containing quotes, heredocs, or multiple lines is parsed exactly as bash would
83
+ * parse it alone. The value is also kept out of the spawned argv.
84
+ */
85
+ export const STEP_GATE_COMMAND_ENV = "PI_BG_STEP_GATE_COMMAND";
86
+
87
+ /** Longest failing-step text recorded in task metadata; the log keeps it whole. */
88
+ export const STEP_GATE_COMMAND_CHARS = 160;
89
+
90
+ /**
91
+ * Bash script that runs an opt-in step gate around the operator's command (#619).
92
+ *
93
+ * The gate is a bash `errexit` contract, not shell parsing: `set -e` ends the
94
+ * command at the first unhandled failing step with that step's exit code, so a
95
+ * later command — in the same list, a function, a subshell, or a command
96
+ * substitution — can no longer turn a red gate green. A failure the command
97
+ * handles itself (`|| true`, `if`/`while`/`until` conditions, `!`, `&&`/`||`
98
+ * lists) is not a gate failure, and a discarded process-substitution status is
99
+ * not one either. `set -E` lets the ERR trap name a failure inside a shell
100
+ * function (functions run in the main shell); the trap records only main-shell
101
+ * failures (`BASH_SUBSHELL` 0), so a status bash itself discards is never claimed
102
+ * as the verdict — an unhandled command substitution still fails its assignment
103
+ * in the main shell. The EXIT trap writes the verdict to stderr, where the
104
+ * harness reads it back, and forces a failing step's code when the command
105
+ * disabled errexit and would have exited 0.
106
+ *
107
+ * Exposed for tests and reuse.
108
+ */
109
+ export function stepGateScript(id: string): string {
110
+ return [
111
+ "# Step gate (#619) from pi-better-background-tasks: bash errexit ends the command at its first unhandled failing step.",
112
+ "set -E",
113
+ "set -e",
114
+ `__pi_step_gate_id=${bashSingleQuote(id)}`,
115
+ "__pi_step_gate_first=0",
116
+ "__pi_step_gate_command=",
117
+ "__pi_step_gate_record() {",
118
+ " __pi_step_gate_status=$?",
119
+ " if [ \"${BASH_SUBSHELL:-0}\" -eq 0 ] && [ \"${__pi_step_gate_first:-0}\" -eq 0 ] && [ \"$__pi_step_gate_status\" -ne 0 ]; then",
120
+ " __pi_step_gate_first=$__pi_step_gate_status",
121
+ " __pi_step_gate_command=${BASH_COMMAND:-}",
122
+ " fi",
123
+ " return 0",
124
+ "}",
125
+ "__pi_step_gate_finish() {",
126
+ " __pi_step_gate_status=$?",
127
+ " if [ \"${__pi_step_gate_first:-0}\" -ne 0 ]; then",
128
+ " __pi_step_gate_text=${__pi_step_gate_command//$'\\n'/ }",
129
+ " printf '\\n--- step gate [%s]: first failing step exit=%s; shell exit=%s: %s ---\\n' \"$__pi_step_gate_id\" \"$__pi_step_gate_first\" \"$__pi_step_gate_status\" \"$__pi_step_gate_text\" >&2",
130
+ " if [ \"$__pi_step_gate_status\" -eq 0 ]; then exit \"$__pi_step_gate_first\"; fi",
131
+ " fi",
132
+ " exit \"$__pi_step_gate_status\"",
133
+ "}",
134
+ "trap '__pi_step_gate_record' ERR",
135
+ "trap '__pi_step_gate_finish' EXIT",
136
+ `if [ -z "\${${STEP_GATE_COMMAND_ENV}:-}" ]; then`,
137
+ " printf '\\n--- step gate [%s]: the gated command was not provided; nothing ran ---\\n' \"$__pi_step_gate_id\" >&2",
138
+ " exit 125",
139
+ "fi",
140
+ `eval "\${${STEP_GATE_COMMAND_ENV}}"`,
141
+ ].join("\n");
142
+ }
143
+
144
+ /**
145
+ * Read the gate's verdict marker from a task log (#619). Bash writes it as the last
146
+ * line of stderr, so log retention cannot turn a red gate green; a missing marker
147
+ * only costs the failing command's name, never the verdict carried by the exit code.
148
+ *
149
+ * Exposed for tests and reuse.
150
+ */
151
+ export function parseStepGateFailure(logText: string, id: string): StepGateFailure | undefined {
152
+ const marker = `--- step gate [${id}]: first failing step exit=`;
153
+ let found: StepGateFailure | undefined;
154
+ for (const rawLine of logText.split("\n")) {
155
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
156
+ if (!line.startsWith(marker) || !line.endsWith(" ---")) continue;
157
+ const match = /^--- step gate \[[^\]]+\]: first failing step exit=(-?\d+); shell exit=(-?\d+): (.*) ---$/.exec(line);
158
+ if (!match) continue;
159
+ const stepExitCode = Number(match[1]);
160
+ const shellExitCode = Number(match[2]);
161
+ if (!Number.isInteger(stepExitCode) || stepExitCode === 0) continue;
162
+ // The marker is the newest evidence, and the shell writes only the first failing step.
163
+ found = {
164
+ stepExitCode,
165
+ shellExitCode: Number.isInteger(shellExitCode) ? shellExitCode : null,
166
+ command: boundedStepCommand(match[3]),
167
+ };
168
+ }
169
+ return found;
170
+ }
171
+
172
+ function boundedStepCommand(value: string): string {
173
+ const oneLine = value.replace(/\s+/g, " ").trim();
174
+ return oneLine.length > STEP_GATE_COMMAND_CHARS ? `${oneLine.slice(0, STEP_GATE_COMMAND_CHARS - 1)}…` : oneLine;
175
+ }
176
+
78
177
  /**
79
178
  * On Windows, numeric fds above 2 are unusable as child stdio: Node spawns the
80
179
  * process, but its output handles end up broken, every write fails, and shell
@@ -1,8 +1,9 @@
1
- import { statSync } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, statSync } from "node:fs";
2
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
4
  import { appendLine, appendTaskOutput, appendWatchResult, retainLogTail, resolveMaxLogBytes } from "./logs.js";
4
5
  import { evaluateCondition, validateCondition } from "./conditions.js";
5
- import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
6
+ import { parseStepGateFailure, processExists, runCommandOnce, spawnCommand, STEP_GATE_COMMAND_ENV, stepGateScript, stopProcessGroup } from "./process.js";
6
7
  import { currentProcessStartToken, readProcessStartToken } from "./process-identity.js";
7
8
  import { DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS, expandSshRemoteTaskPreset } from "./remote-task-preset.js";
8
9
  import type { RemoteRunner, ResolvedSshRemoteTask } from "./remote-task-preset.js";
@@ -22,6 +23,7 @@ import type {
22
23
  FirstWatchCheck,
23
24
  RemoteTaskParams,
24
25
  SshConnectionParams,
26
+ StepGateFailure,
25
27
  TerminalResult,
26
28
  } from "./types.js";
27
29
  import { observeTaskStallAttention } from "./stall-attention.js";
@@ -170,6 +172,12 @@ export interface SpawnTaskParams extends CommandSpec, TaskIntentParams {
170
172
  callback?: boolean;
171
173
  timeout_seconds?: number;
172
174
  max_log_bytes?: number;
175
+ /**
176
+ * Opt-in step gate (#619): run the command under bash errexit, so the first
177
+ * unhandled failing step ends the task with that step's exit code and a later
178
+ * command cannot mask it.
179
+ */
180
+ require_successful_steps?: boolean;
173
181
  ssh?: SshConnectionParams;
174
182
  remote?: RemoteTaskParams;
175
183
  }
@@ -182,6 +190,12 @@ export interface WatchTaskParams extends CommandSpec, TaskIntentParams {
182
190
  max_log_bytes?: number;
183
191
  success_when: Condition;
184
192
  failure_when?: Condition;
193
+ /**
194
+ * Rejected (#619): a watch check is one polled command whose own exit code is the
195
+ * outcome, so the step gate cannot apply. Accepted in the shared schema only where
196
+ * the same wrapper also spawns.
197
+ */
198
+ require_successful_steps?: boolean;
185
199
  /** Consecutive blind checks before the watch is flagged (#359). Default 3; 0 turns it off. */
186
200
  blind_checks?: number;
187
201
  ssh?: SshConnectionParams;
@@ -190,6 +204,12 @@ export interface WatchTaskParams extends CommandSpec, TaskIntentParams {
190
204
 
191
205
  export interface TaskRuntimeDependencies {
192
206
  remoteRunner?: RemoteRunner;
207
+ /**
208
+ * Embedding/test seam for a local watch's poll command. Production polls run
209
+ * the persisted command through `runCommandOnce`; a remote watch keeps using
210
+ * its SSH runner instead.
211
+ */
212
+ watchPollRunner?: WatchPollRunner;
193
213
  }
194
214
 
195
215
  /**
@@ -201,6 +221,46 @@ function withCommandPolicy(meta: BackgroundTaskMeta, seconds: number | undefined
201
221
  return seconds === undefined ? meta : { ...meta, commandTimeoutSeconds: seconds };
202
222
  }
203
223
 
224
+ /**
225
+ * The step gate applies to a shell command only (#619). A direct argv launch already
226
+ * reports the real exit code, and a structured SSH command runs under remote
227
+ * semantics the local gate cannot observe, so both are refused rather than silently
228
+ * ignored: a gate that quietly does nothing is the masking bug it exists to fix.
229
+ */
230
+ function stepGateRefusal(params: SpawnTaskParams): string | undefined {
231
+ if (params.require_successful_steps !== true) return undefined;
232
+ if (params.shell === false) {
233
+ return "Invalid step gate: require_successful_steps needs a shell command; argv with shell:false already reports the real exit code. The task was not started.";
234
+ }
235
+ if (!params.command || params.command.trim().length === 0) {
236
+ return "Invalid step gate: require_successful_steps needs a non-empty shell command. The task was not started.";
237
+ }
238
+ if (params.ssh) {
239
+ return "Invalid step gate: require_successful_steps is local-only and cannot observe a structured ssh command. The task was not started.";
240
+ }
241
+ return undefined;
242
+ }
243
+
244
+ /** Read the gate verdict bash wrote at the end of the task log (#619). */
245
+ function readStepGateFailure(meta: BackgroundTaskMeta): StepGateFailure | undefined {
246
+ try {
247
+ return parseStepGateFailure(readFileSync(meta.logPath, "utf8"), meta.id);
248
+ } catch {
249
+ // A swept temp dir or an unreadable log costs the reason, never the verdict.
250
+ return undefined;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * The command actually spawned for a gated task: bash runs the gate script and
256
+ * `eval`s the operator's own command from `STEP_GATE_COMMAND_ENV`. Meta keeps the
257
+ * operator's command verbatim, so status, log, and recovery never show the wrapper.
258
+ */
259
+ function withStepGate(spec: CommandSpec, id: string, gated: boolean): CommandSpec {
260
+ if (!gated) return spec;
261
+ return { ...spec, command: stepGateScript(id), env: { ...spec.env, [STEP_GATE_COMMAND_ENV]: spec.command! } };
262
+ }
263
+
204
264
  function persistedCommandCapSeconds(meta: BackgroundTaskMeta): number | undefined {
205
265
  const value = meta.commandTimeoutSeconds;
206
266
  return isCommandTimeoutSeconds(value) ? value : undefined;
@@ -231,6 +291,10 @@ export function spawnTask(
231
291
  // Resolved before any task directory, log, or metadata exists so a blocked
232
292
  // launch leaves nothing behind. Structured SSH must also honor launch restrictions.
233
293
  const intent = readTaskIntent(params);
294
+ // A gate that could only be silently ignored is refused before any task state exists (#619).
295
+ const stepGateRefused = stepGateRefusal(params);
296
+ if (stepGateRefused) throw new Error(stepGateRefused);
297
+ const stepGated = params.require_successful_steps === true;
234
298
  const cwd = params.cwd ?? defaultCwd;
235
299
  const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh, params);
236
300
  // The application/shell switches apply before any task state exists, so a
@@ -253,7 +317,8 @@ export function spawnTask(
253
317
  remote: params.remote,
254
318
  }, dependencies.remoteRunner)
255
319
  : undefined;
256
- const commandSpec: CommandSpec = remoteTask?.commandSpec ?? { ...params, cwd, shell: params.shell ?? true };
320
+ const operatorSpec: CommandSpec = remoteTask?.commandSpec ?? { ...params, cwd, shell: params.shell ?? true };
321
+ const commandSpec = withStepGate(operatorSpec, id, stepGated);
257
322
  const sandboxNotices: string[] = [];
258
323
  const launchSpec = remoteTask
259
324
  ? commandSpec
@@ -292,6 +357,7 @@ export function spawnTask(
292
357
  command: params.command,
293
358
  argv: commandSpec.argv,
294
359
  shell: commandSpec.shell,
360
+ ...(stepGated ? { requireSuccessfulSteps: true } : {}),
295
361
  cwd,
296
362
  env: params.env,
297
363
  launchArgv: launchArgvOf(commandSpec, launchSpec),
@@ -321,8 +387,17 @@ export function spawnTask(
321
387
  if (!latest) return;
322
388
  enforceLogRetention(latest);
323
389
  if (isTerminalStatus(latest.status)) return;
324
- if (exitCode !== 0) recordExitFailure(latest, "execution", `Process exited with code ${exitCode ?? "unknown"}${signal ? ` (${signal})` : ""}`, exitCode, "close", { at: Date.now() });
325
- else {
390
+ // The gate's own marker names the failing step (#619). It never decides the
391
+ // verdict: bash errexit already exits with that step's code (and the gate's EXIT
392
+ // trap forces it when a command disabled errexit), so a missing marker loses the
393
+ // reason, not the failure.
394
+ const stepGateFailure = latest.requireSuccessfulSteps ? readStepGateFailure(latest) : undefined;
395
+ const detail = `Process exited with code ${exitCode ?? "unknown"}${signal ? ` (${signal})` : ""}`;
396
+ if (exitCode !== 0) {
397
+ recordExitFailure(latest, "execution", stepGateFailure
398
+ ? `${detail}; step gate: ${stepGateFailure.command || "a chained step"} exited ${stepGateFailure.stepExitCode}`
399
+ : detail, exitCode, "close", { at: Date.now() });
400
+ } else {
326
401
  recoverFailure(latest, "execution", "close");
327
402
  recoverDeclaredOperation(latest);
328
403
  }
@@ -330,6 +405,7 @@ export function spawnTask(
330
405
  latest.endedAt = Date.now();
331
406
  latest.lastExitCode = exitCode;
332
407
  latest.lastSignal = signal;
408
+ if (stepGateFailure) latest.stepGateFailure = stepGateFailure;
333
409
  latest.result = { exitCode, signal };
334
410
  writeMeta(latest);
335
411
  void notifyTerminal(pi, latest, getActiveSession);
@@ -527,6 +603,9 @@ export function startWatchTask(
527
603
  const error = condition && validateCondition(condition);
528
604
  if (error) throw new Error(`${name}: ${error}`);
529
605
  }
606
+ if (params.require_successful_steps === true) {
607
+ throw new Error("Invalid step gate: require_successful_steps is for a spawned shell command; a watch poll reports its own exit code as the outcome. The task was not started.");
608
+ }
530
609
  const blindChecks = readBlindChecks(params.blind_checks);
531
610
  const intent = readTaskIntent(params);
532
611
  const cwd = params.cwd ?? defaultCwd;
@@ -591,7 +670,7 @@ export function startWatchTask(
591
670
  firstCheckWaiters.set(id, { promise: firstCheck, resolve: resolveFirst });
592
671
  scheduleWatch(pi, id, 0, getActiveSession, remoteTask
593
672
  ? (timeoutMs) => remoteTask.runOnce(undefined, timeoutMs)
594
- : undefined);
673
+ : dependencies.watchPollRunner);
595
674
  return meta;
596
675
  }
597
676
 
@@ -642,7 +721,7 @@ export function resumeRunningTask(
642
721
  if (meta.kind === "command_watch") {
643
722
  scheduleWatch(pi, meta.id, 0, getActiveSession, remoteTask
644
723
  ? (timeoutMs) => remoteTask.runOnce(undefined, timeoutMs)
645
- : undefined);
724
+ : dependencies.watchPollRunner);
646
725
  return meta;
647
726
  }
648
727
 
@@ -912,10 +991,10 @@ async function pollWatch(
912
991
  applyCaptureOverflow(latest, result);
913
992
  enforceLogRetention(latest);
914
993
  latest.lastCheckedAt = Date.now();
915
- latest.lastProgressAt = latest.lastCheckedAt;
916
994
  latest.lastExitCode = result.exitCode;
917
995
  latest.lastSignal = result.signal;
918
996
  latest.lastState = extractLastState(result);
997
+ observeWatchOutcome(latest, result);
919
998
 
920
999
  const pollKey = result.startedAt;
921
1000
  if (result.timedOut) {
@@ -1386,7 +1465,10 @@ function enforceLogRetention(meta: BackgroundTaskMeta): void {
1386
1465
  const log = statSync(meta.logPath);
1387
1466
  const mtimeMs = Math.trunc(log.mtimeMs);
1388
1467
  const maintenance = meta.logMaintenance?.mtimeMs === mtimeMs && meta.logMaintenance.size === log.size;
1389
- if (!maintenance && mtimeMs > (meta.lastProgressAt ?? meta.startedAt)) {
1468
+ // A watch's log only records its own polls, so its mtime is poll activity and
1469
+ // never evidence that the watch's observed outcome moved. Spawned processes
1470
+ // keep taking real log writes as progress.
1471
+ if (meta.kind !== "command_watch" && !maintenance && mtimeMs > (meta.lastProgressAt ?? meta.startedAt)) {
1390
1472
  meta.lastProgressAt = mtimeMs;
1391
1473
  writeMeta(meta);
1392
1474
  }
@@ -1428,4 +1510,91 @@ function extractLastState(result: { stdout: string }): unknown {
1428
1510
  } catch {
1429
1511
  return result.stdout.slice(0, 4000);
1430
1512
  }
1431
- }
1513
+ }
1514
+
1515
+ /** Version tag of the watch outcome fingerprint. A value from another version is adopted, never compared. */
1516
+ const WATCH_FINGERPRINT_VERSION = "v1";
1517
+ /** OSC and CSI control sequences are display markup, not content. */
1518
+ const TERMINAL_ESCAPE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-?]*[ -/]*[@-~]/g;
1519
+
1520
+ /**
1521
+ * Text as the terminal would settle it. Redraw frames and control sequences are
1522
+ * display churn: repainting the same visible line is not a new observation.
1523
+ */
1524
+ function settledTerminalText(text: string): string {
1525
+ return text
1526
+ .replace(TERMINAL_ESCAPE, "")
1527
+ .split("\n")
1528
+ .map((line) => {
1529
+ // A CR immediately before LF ends the line; any other CR overwrites it in place.
1530
+ const body = line.endsWith("\r") ? line.slice(0, -1) : line;
1531
+ return (body.split("\r").at(-1) ?? "").replace(/[ \t]+$/, "");
1532
+ })
1533
+ .join("\n")
1534
+ .replace(/\n+$/, "");
1535
+ }
1536
+
1537
+ /** Objects compare by content regardless of key order, so reordered keys are not progress. */
1538
+ function canonicalJsonText(value: unknown): string {
1539
+ if (Array.isArray(value)) return `[${value.map(canonicalJsonText).join(",")}]`;
1540
+ if (value && typeof value === "object") {
1541
+ const record = value as Record<string, unknown>;
1542
+ const entries = Object.keys(record).sort()
1543
+ .map((key) => `${JSON.stringify(key)}:${canonicalJsonText(record[key])}`);
1544
+ return `{${entries.join(",")}}`;
1545
+ }
1546
+ return JSON.stringify(value) ?? "null";
1547
+ }
1548
+
1549
+ /** stdout compares as JSON content when it parses, and as settled text otherwise. */
1550
+ function canonicalWatchStdout(stdout: string): string {
1551
+ const settled = settledTerminalText(stdout);
1552
+ try {
1553
+ return `json:${canonicalJsonText(JSON.parse(settled))}`;
1554
+ } catch {
1555
+ return `text:${settled}`;
1556
+ }
1557
+ }
1558
+
1559
+ /**
1560
+ * What a poll observed, independent of when it ran. Exit status, signal, error,
1561
+ * stdout content, and stderr content are included; poll metadata (start time,
1562
+ * end time, duration, check count) is deliberately not, so it can never hide a
1563
+ * watch that reports the same outcome forever. Watch stdout may carry its own
1564
+ * timestamps: projecting meaningful fields is the caller's job, not this
1565
+ * fingerprint's guess.
1566
+ */
1567
+ function watchOutcomeFingerprint(result: CommandResult): string {
1568
+ // A runner may report why the check itself could not run; that is part of the
1569
+ // observed outcome, not poll metadata.
1570
+ const reported = (result as CommandResult & { error?: unknown }).error;
1571
+ const error = typeof reported === "string" ? settledTerminalText(reported) : null;
1572
+ const payload = JSON.stringify([
1573
+ result.exitCode,
1574
+ result.signal ?? null,
1575
+ result.timedOut === true,
1576
+ error,
1577
+ canonicalWatchStdout(result.stdout),
1578
+ settledTerminalText(result.stderr),
1579
+ ]);
1580
+ return `${WATCH_FINGERPRINT_VERSION}:${createHash("sha256").update(payload).digest("hex")}`;
1581
+ }
1582
+
1583
+ /**
1584
+ * A watch's state clock advances only when its observed outcome changes. The
1585
+ * poll itself is activity (`lastCheckedAt`), not progress, so a stationary
1586
+ * watch still becomes quiet and then a suspected stall while it keeps polling.
1587
+ * A record with no current fingerprint is adopted without moving the clock, so
1588
+ * an upgrade or reload cannot keep resetting that silence.
1589
+ */
1590
+ function observeWatchOutcome(meta: BackgroundTaskMeta, result: CommandResult): void {
1591
+ const fingerprint = watchOutcomeFingerprint(result);
1592
+ const prior = meta.watchFingerprint;
1593
+ meta.watchFingerprint = fingerprint;
1594
+ const comparable = typeof prior === "string" && prior.startsWith(`${WATCH_FINGERPRINT_VERSION}:`);
1595
+ if (!comparable) {
1596
+ if (meta.lastProgressAt === undefined) meta.lastProgressAt = meta.startedAt;
1597
+ return;
1598
+ }
1599
+ if (prior !== fingerprint) meta.lastProgressAt = meta.lastCheckedAt ?? Date.now();
1600
+ }
@@ -604,7 +604,7 @@ export function createCallbackBatcher(
604
604
 
605
605
  const enqueue = (event: CallbackBatchEvent): boolean => {
606
606
  if (event.callback === false) return false;
607
- const key = eventKey(event);
607
+ const key = completionKey(event);
608
608
  if (pending.has(key) || inFlight.has(key)) return false;
609
609
  pending.set(key, { event, sequence: sequence++ });
610
610
  schedule(windowMs);
@@ -884,6 +884,23 @@ function eventKey(event: Pick<CallbackBatchEvent, "source" | "id" | "status">):
884
884
  return `${event.source}\u0000${event.id}\u0000${event.status}`;
885
885
  }
886
886
 
887
+ /**
888
+ * Ordinary completion identity is the run/task id, not the status wording (#624).
889
+ *
890
+ * The status field is a snapshot taken when a notification is enqueued (failure
891
+ * notes, observation gaps, timing reason). Including it in the key meant a
892
+ * not-yet-delivered run whose status changed between enqueues entered the
893
+ * pending map twice; one flush then rendered both rows and the caller saw one
894
+ * completion twice. Urgent events keep the status in their key: those are
895
+ * distinct transitions (orphaned, lost, a timing wake, a failure key), not
896
+ * restatements of a single ordinary completion. The first queued snapshot is the
897
+ * one delivered; a later status wording for the same id is dropped rather than
898
+ * re-queued, and the durable result stays the authority for failure detail.
899
+ */
900
+ function completionKey(event: Pick<CallbackBatchEvent, "source" | "id">): string {
901
+ return `${event.source}\u0000${event.id}`;
902
+ }
903
+
887
904
  function eventDisposition(
888
905
  event: Pick<CallbackBatchEvent, "isDelivered" | "getSuppressionReason">,
889
906
  ): | { kind: "deliver" } | { kind: "delivered" } | { kind: "deferred" } | { kind: "suppressed"; reason: string } {