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.
- package/node_modules/pi-better-background-tasks/README.md +64 -4
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/output.ts +22 -2
- package/node_modules/pi-better-background-tasks/src/process.ts +100 -1
- package/node_modules/pi-better-background-tasks/src/runtime.ts +179 -10
- package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +18 -1
- package/node_modules/pi-better-background-tasks/src/shared-failure-observations.ts +78 -24
- package/node_modules/pi-better-background-tasks/src/shared-log-utils.ts +277 -31
- package/node_modules/pi-better-background-tasks/src/stall.ts +4 -1
- package/node_modules/pi-better-background-tasks/src/tools.ts +14 -3
- package/node_modules/pi-better-background-tasks/src/types.ts +33 -1
- package/node_modules/pi-better-subagents/cleanup.ts +58 -2
- package/node_modules/pi-better-subagents/continuation.ts +58 -7
- package/node_modules/pi-better-subagents/docs/usage.md +76 -9
- package/node_modules/pi-better-subagents/finalization.ts +1 -1
- package/node_modules/pi-better-subagents/index.ts +595 -287
- package/node_modules/pi-better-subagents/output-payload.ts +40 -3
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/registry.ts +215 -4
- package/node_modules/pi-better-subagents/shared-callback-batcher.ts +18 -1
- package/node_modules/pi-better-subagents/shared-failure-observations.ts +78 -24
- package/node_modules/pi-better-subagents/shared-log-utils.ts +277 -31
- package/node_modules/pi-better-subagents/stop.ts +60 -2
- package/node_modules/pi-better-subagents/timing.ts +45 -4
- package/node_modules/pi-better-subagents/tools.ts +29 -6
- 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.
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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.
|
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
|
|
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 =
|
|
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 } {
|