Fifth review found that a cancel landing while a stderr consumer was
still delivering the script's last lines - after "step 1" but before the
error - made the reason "step 1: seeding": 76b619e23 let a cancel end the
post-exit wait at once, and ede786396 then took the line as it stood.
4edac0170 gave the error, because its 500 ms settle ran whatever the
caller did. A cancel now ends the wait only once that first settle has
passed, so the consumer gets as long as it always had and a cancel waits
no longer than it did before the wait could stretch. The abort leaves
the race once it has fired, so the loop does not spin while it waits.
Fourth review found that a cancel landing within 500 ms of a stderr
consumer's late line dropped that line from the reason: the settle ended
on the abort before stderr had been quiet for 500 ms, and the reason fell
back to the line as bash exited - empty, the consumer not having written
yet. A cancel says nothing about whether a job is still writing, so a
settle it ended now takes the stderr line as it stands.
Fourth review found that a block of the tool server's event loop across
the 500 ms mark - a synchronous call in an I/O callback - made the reason
freeze on the line before the script's error. After such a block the
due timer runs before the poll that reads what arrived meanwhile, so both
the quiet watch and the settle judged stderr quiet on stale data: with a
stderr consumer and a surviving job, the reason ended with "step 1:
seeding" instead of "FATAL: the real error". Each now lets one turn go by
after a timer, so pending pipe data is read before the judgement, and the
watch stops once the settle ends so a late check cannot take a line the
stop provoked.
Third review found that fd41a28b5 let a job's stray stderr line become a
bash step's reason: a mock server that logs requests on stdout keeps the
settle open, and its one stderr line a second after bash exited
("listening on :8080") was taken over the script's error, which
4edac0170 reported. After bash exits, stderr carries the script's own
lines late - a consumer working through its backlog - and a job's lines;
the first come as one run from the exit, the second whenever the job
writes. The reason now takes the line stderr stood on when it first went
quiet for 500 ms after the exit. Streams that close while stderr is
still running on count in full; stderr that never goes quiet is a job
still writing, and then the line is the one bash exited on. The docs say
the same.
Second review found that 76b619e23 ended the post-exit wait at once for
every cancelled run, including one cancelled before the script answered.
That run was already stopped, and a job in a group of its own outlives
the stop: before, the wait still saw it writing and marked the log cut;
after, the log stopped short with no flag and no note. The settle now
takes the abort only when it had not happened by the script's exit, so
only a cancel during the wait itself ends it. The cancel case also pins
that a job still writing then marks the log cut.
Second review found that a1913b052 read the settle's "cut" as a sign that
stderr was still being written, when a job chattering on stdout alone
also ran the settle to its limit. With a stderr consumer in front of the
script's error and such a job, the reason took the line as bash exited -
empty, the consumer not having written yet - and dropped the error that
4edac0170 reported. The line as bash exited is now used only when stderr
itself was still being written as the settle ended.
Review found the case that pins the log-cut note tight under load: its
job wrote its first line 200 ms after bash exited, and a stall of that
size between the runner's answer and the read of stderr would put the
job's line in the reason. The job now waits 300 ms, still inside the
500 ms quiet window the case needs. The case's name and comment no
longer say Argent stops the job, which it cannot do everywhere.
Review found that 06b7891da's longer settle ignored the request's abort:
the timer and the abort listener are removed when the script's process
exits, so a run cancelled while a job the script left running was still
writing waited up to 3 s more (before: at most 0.5 s). The settle now
takes the request's signal and ends at once; if output was still
arriving, the log is marked cut as it is at the limit.
Review found a regression in edbdf4dfb: when stderr went through a consumer
(`exec 2> >(…)`) and a quiet background job held the streams open, the
reason took the stderr line as it stood when bash exited - often nothing,
since the consumer had not written yet - and dropped the script's error
that 4edac0170 reported.
The line now follows how the settle ended: the last line overall when the
streams closed on their own, the line stderr ended on before Argent's stop
when what held them went quiet, and the line as bash exited only when a
process was still writing at the settle's limit. A job's answer to the
stop still never becomes the reason.
After the script's process exited, the executor waited at most 500 ms for
the output pipes and then stopped the process group. A stderr consumer
still working through its backlog - `exec 2> >(…)`, the plain-bash
timestamp idiom - was killed mid-drain: the log lost its tail with
scriptLogTruncated still false, and a failing step's reason ended with a
progress line instead of the script's error.
The settle now stretches while the tree keeps writing: it ends when the
streams close, after 500 ms of quiet, or 3 s after the exit. A tree still
writing at that limit is stopped, the log is marked cut, and the step
carries a note saying so.
The bash version probe ran in the tool server's own working directory and
dropped the candidate's stderr. A version-manager shim picks its bash from
the directory it starts in (asdf reads .tool-versions), so a shim on PATH
was refused and the step fell through to /bin/bash - Apple's 3.2 on a Mac -
with nothing said, and a shim pinned in scripts.bash errored with "is not a
bash" or passed depending on where the tool server was started.
The probe now runs in the step's working directory, the refusal quotes the
last line the candidate wrote to stderr, and a step that runs under a later
candidate after a refused PATH bash carries a note naming the refusal.
A failing script that left a background job running took its reason from
whatever that job wrote to stderr after bash exited, often the line that
Argent's own SIGTERM provoked ("mock-server: SIGTERM received, closing").
The reason now takes the stderr line as it stood when the runner reported
bash's exit whenever the stop had to end a process still holding stderr.
A step whose streams close on their own keeps the last line overall.
The parent classifies a runner that ended with no verdict by how it ended,
and reads the clock rather than its own timer for one case: a SIGKILL past
the step's limit is the deadline watchdog's stop, not an unexplained kill,
because a stall in the tool server's event loop can hold the timer behind
the exit it is racing.
Windows has no signal for that rule to see. The watchdog's `taskkill` ends
the runner with exit code 1, so a stalled tool server's deadline stop there
read as "The script stopped its own process with exit code 1" - the case
the Windows runner kept reporting as `exit` after the runner itself stopped
sending bash's forced exit. Past the limit, a runner that ended with no
verdict on Windows is now that same stop. POSIX is unchanged: the rule
still needs a signal there.
The deadline case asserts the whole failure now, so a wrong verdict says
which side produced it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2EFhLrnjAkfgPkN1fva1k
Two more cases read their descendant's pid from `sleep & echo $!`: the time
limit and the cancellation, both of which then ask `process.kill(pid, 0)`
whether it is gone. Under Git Bash `$!` is an MSYS number, not a Windows pid,
and a small one can belong to an unrelated Windows process - the time-limit
case passed on one Windows run and failed on the next at "no process left
behind".
One helper now starts the descendant as a Node process that writes its own
`process.pid`, as the lifeline case already did, and the deadline case uses
it too. The POSIX-only cases keep `$!`, which is a real pid there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2EFhLrnjAkfgPkN1fva1k
With the IPC fix in, ten bash cases still failed on the Windows runner.
None is a product failure; each asserts something Windows does not do, or
measures it in a way that cannot work there.
- A named pipe as the document: Git Bash's `mkfifo` makes a Cygwin FIFO
that Node sees as no file at all. The directory row still runs there.
- Three CRLF cases: Git for Windows' bash drops a carriage return from the
line it reads, so a CRLF script runs there as its LF twin - all three
ran clean on the runner. POSIX only.
- The stray-sibling case wrote a literal carriage return inside `$'...'`,
which that same rule turned into an empty word, so the redirection
emptied the real document. It now writes U+F00D on Windows, the name
msys2 gives such a file, as the case beside it already does.
- Three signal-verdict cases: the step dies by a signal, and Windows has
none to send. POSIX only, as the file already says of signal verdicts.
- The deadline-watchdog case found its descendant through `sleep & echo
$!`, and under Git Bash that pid is an MSYS number `process.kill` cannot
ask Windows about, so it read as dead from the start. A Node descendant
writes its own pid now, as the lifeline case does - which makes this the
case that runs the watchdog's `taskkill` arm on Windows.
- The sweep's 12 ms stall bound sits under Windows' ~15.6 ms timer tick,
so a 5 ms heartbeat cannot resolve it there. Skipped on Windows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2EFhLrnjAkfgPkN1fva1k
Both were written against macOS and had never run on Linux: until this
branch targeted `main`, only the description check ran on its pull request.
The mode case read a file's mode with `stat -f '%Lp' "$1" || stat -c '%a'
"$1"`. GNU `stat -f` is `--file-system`, so on Linux it treats `%Lp` as a
file it cannot find and still prints the file system `$1` sits on to
stdout before the fallback runs - and that report became the document the
script wrote. GNU's `-c` comes first now; BSD `stat` refuses it with
nothing on stdout.
The body `kill 0` case pinned macOS's answer, where the signal reaches bash
alone and arrives as a plain signal. On Linux it reaches the whole process
group, the runner that leads it included, which is the case the runner
already reports as a group signal. Both reports name `kill 0` and the
remedy, so the case now accepts either.
Reproduced in a `node:20-bookworm` container (GNU bash 5.2.15) before the
change - `expected 'exit' to be 'signal'`, and a document of `undefined`
- and green there after it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2EFhLrnjAkfgPkN1fva1k
A `.sh` step now reports what it prints the way a `.mjs` step does on
`main`: bash inherits the runner's stdout and stderr, which are the pipes
`ScriptLogCapture` reads, so the log, its limits, the drain and the secret
scrub apply with no bash-specific code.
The failure reason comes from stderr rather than from a file. bash has no
`throw`; what it has is an exit code and stderr, and every command a
script runs already writes its error there. So a non-zero exit reports the
runner's exit line followed by the last non-blank line the script wrote to
stderr - under `set -euo pipefail`, the error of the command that stopped
it, with no Argent-specific code in the script.
The runner never reads stderr: bash writes straight into the parent's
pipe. The executor's capture keeps the line (`LastLineTracker`), from the
raw text ahead of the scrub and the limits, so a script that floods stderr
and then says why still says it. Only the head of a line is kept - 1,000
characters and the usual omission marker - so a cut always lands at the
end of the message, where `redactTruncated` repairs one, and the line
joins the failure text before it is clamped and redacted. A line of
nothing but whitespace is blank at any length. stdout is never the
reason, and only the `exit` kind gets the line.
`$ARGENT_REASON` goes, and with it the runner's bounded read of the
reason file, its kept-count marker and `REASON_KEPT_RE`, its UTF-8
refusal, the CRLF hint for a reason that strayed, and the second exchange
file. `ARGENT_REASON` is an ordinary environment name again; the CRLF hint
for `$ARGENT_OUTPUT` stays.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2EFhLrnjAkfgPkN1fva1k
The abort added after `resolveBashInterpreter` was found to hold a step
un-cancellable for about six seconds per candidate was pinned by one case, and
that case calls `pinGlobalConfig` first - so it covers the configured branch and
none of the search. Three deletions left all 517 green:
- the check inside the candidate loop, which is the PATH search every ordinary
install takes. Without it the cancellation is swallowed and the resolver
reports success, or `spawn`/"install bash" on a host where nothing answers.
- the entry check, which makes an already-raised signal cost nothing. Without
it an aborted call replayed is a full probe round per replay, plus the
detached probe children each round leaves.
- `request.signal` in the executor's own call, so nothing asserted the executor
wires it through at all and the `"cancelled" in found` arm was reachable
through no test.
One case each. All three fail on their deletion - 310 ms against 6008 ms and
6016 ms - and the executor one aborts DURING the lookup, because the check at
the top of `runOne` answers a signal already raised and would pass either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`readConfigObject` answers an empty document for a file that is absent, one a
`chmod` made unreadable and one an interrupted `updateConfig` left half-written,
so `scripts.bash` read as unset in all four cases. The same file content then
gave opposite outcomes:
readable, bash=/nonexistent/bash -> ok=false "The configured bash … does not exist."
chmod 000, same content -> ok=true notes=[] bash 5.2.37 from the PATH
truncated JSON, same content -> ok=true notes=[] bash 5.2.37 from the PATH
The last two are exactly "a wrong path papered over by a fallback that happens
to exist on this machine" - the outcome the resolver's first rule says it
prevents, and `configuration.mdx` stated to users as a rule.
`configDocumentProblem` in configuration-core says why a document could not be
read; it is additive and changes what no key resolves to. The resolver asks it,
and a step that fell through to the PATH now carries a note naming the file and
the reason. Refusing instead would break every `.sh` step on a host whose
corrupt file never held the key.
The docstring and `configuration.mdx` now separate the two cases: a value that
is READ and unusable errors the step; a document that could not be read at all
is a note.
(The JSON `null` half of this thread was already resolved in 1971840c7 -
`asPresentText` maps `null` to absent, as every other parser in the file does,
so `{"scripts":{"bash":null}}` means unset by decision.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`startedAt` is taken before `resolveBashInterpreter` and the step's own timer is
armed after it, inside `runChild`, so the lookup is inside `durationMs` and
outside `timeoutMs`. It is not the queue either, so `queuedMs` is 0. A step
declared `timeoutMs: 500` measured:
candidate slow only for the version probe durationMs 3398 notes []
candidate that traps SIGTERM and never answers durationMs 6054 notes []
.mjs control durationMs 41 notes []
A `.mjs` step has no such gap - everything between those two points there is a
`statSync` and a `JSON.stringify` - so nothing anywhere said why a `.sh` step
ran seven or twelve times its declared limit.
The step now says so, when the lookup is worth a sentence beside that limit, and
names the remedy the search already has. `flow-yaml.mdx` said the queue was the
one source of an over-run and that the step reports it; it now names both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`STRICT_UTF8`'s docstring names `toString("utf8")` as the thing it exists to
avoid - "silent, unequal to what the script wrote" - and `readReasonFile` did
exactly that, so the two exchange files had opposite policies for the same
bytes. `printf 'caf\351 unreachable' > "$ARGENT_REASON"; exit 3` reached the
report as `caf<U+FFFD> unreachable`, while the same two bytes in $ARGENT_OUTPUT
were refused. The reason now gets the parallel sentence.
`markReason` had the mirror-image problem. The BYTE read lands on a UTF-8
boundary through `utf8SafeCut`, but the CHARACTER cut after it counts UTF-16
units, so a cut between the halves of an astral character left a lone surrogate
at the end of the message: a reason of 9000 U+1F600 came back with
`isWellFormed()` false, carried through `JSON.stringify` as `\ud83d` and turned
into U+FFFD by any UTF-8 write of the report.
Both pinned in `flow-script-bash.test.ts`, and both cases fail against a runner
copy with the two changes reverted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`commitOutput` is the whole of a `.sh` document's validation - the document is
JSON text, so it never meets the runner's `walk` - and two values `walk` refuses
reached flow state unchecked.
A number JSON can spell and JavaScript cannot hold. `printf '{"n":1e999}'`
parses to `Infinity`, and the step passed carrying a value every later encode
turns into `null`, while the same `.mjs` document was refused with "output
numbers must be finite". Both interpreters now answer with that sentence.
Depth. The 1 MiB cap does not bound it - nested arrays cost two bytes a level -
and `renderOutput` in `flow-add-script.ts` is a bare `JSON.stringify` reached
AFTER the step has been appended to the flow file, so an over-deep document made
the recorder write the step and then die with an uncaught `RangeError`.
`MAX_OUTPUT_DEPTH` is 4096, measured to sit in the gap between two stack-derived
ceilings on Node 20, 22, 24 and 26: the runner's recursive `walk` gives out at
~3450-3925, so no `.mjs` document that reaches this file is newly refused, and
V8's `JSON.stringify` gives out at ~6100 up to Node 24. A number rather than a
try/catch around a trial encode, because Node 26 encodes any depth a 1 MiB
document can reach, and a verdict that follows the host's Node is one a flow
file cannot be written against.
`findOwnProtoKey` becomes `documentProblem`, one iterative pass for all three
rules. The deep-document case in `flow-script-protocol.test.ts` now runs at 4000
- still past the recursive-walk ceiling it was written for - and a second case
pins that a 20 000-deep document from a mismatched runner is a verdict, not a
throw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
Both hard-coded the bash 5.x verdict, so on a stock Mac - Apple's `/bin/bash`
3.2.57, the interpreter the POSIX fallback list exists for - the file was 65/67
with `expected undefined to be 'exit'`, which reads as a source regression. No
job sees it: unit tests run on ubuntu, the macOS jobs run no unit tests, and the
Windows job runs Git bash 5.x.
They now assert the way their SIGQUIT sibling already does. The single case
takes the document when the step passes and the `kill 0` guidance when it fails;
the repeated case asserts ONE verdict over 120 runs and never `signal`, which is
the race it was written for rather than the name of the winner.
Verified under both: `PATH=/usr/bin:/bin` (3.2.57) 67/67, ordinary PATH
(5.2.37) 67/67. Both still fail on mutation - dropping the held-signal branch
gives `kind: signal`, and reporting half of them as `signal` gives
`kinds: ["exit","signal"]`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z6tHq3XazgZbrS4YT73xs
`scripts.bash` was the one key in `CONFIG_SCHEMA` taking project scope while
being host-specific. Its `expected` requires the value be "spelled the way the
host running the tool server spells one", and `asAbsolutePath` enforces that
against `process.platform` - so a committed `.argent/config.json` could not
satisfy both for a team that is not all on one OS family.
On macOS, with a project config a Windows teammate committed
(`{"scripts":{"bash":"C:\\Program Files\\Git\\bin\\bash.exe"}}`), through
`argent flow run`:
✗ 1 script prove.sh — The configured bash (scripts.bash = C:\Program
Files\Git\bin\bash.exe, from …/.argent/config.json) is not an absolute
path. Point scripts.bash at a bash executable, or unset it to use the one
on this host's PATH.
Every `.sh` step errored, and there was no PATH fallback because the value was
set. `merge: "prioritize-local"` made it worse: the committed value shadowed
the developer's own valid global pin, and the message then named the working
bash it was declining to use. Editing the shared file was the only local fix,
and it un-fixed the teammate who committed it - `config set scripts.bash
'C:\...' --scope project` is refused on macOS, and the POSIX spelling is
refused the same way on Windows, so neither developer could write the other's
value into the file they share.
The key now takes the global scope alone, like `scripts.maxTimeoutMs` and
`scripts.heapLimitMb` above it. `readScopeValue` gates READS on `scopes` as
well as writes, so a committed value is not merely unwritable - it is unread,
and the resolver falls through to its PATH search rather than refusing the
step. Each developer pins their own bash.
That leaves one scope for the resolver to read, so the machinery that existed
to serve two goes with it: `resolveBashInterpreter` no longer takes an anchor
it cannot use, `configuredSource` and `unsetAdvice` are gone, and the message
names the global file and offers PATH - advice that is now always true.
Four behaviours confirmed through `argent flow run`, on an isolated
tool-server and home so the shared ones were untouched:
committed project value PASS (ignored, falls through to PATH)
wrong global pin errors, names the global file, no PATH fallback
good global pin honoured
set --scope project "cannot be set at project scope (allowed: global)"
Full tool-server suite 6277 passed, matching the pre-change baseline;
configuration-core 152; argent-cli 615; lint, knip and the docs build clean.
`commitOutput` reported a parse failure as `The script's output did not parse:
${errorMessage(err)}`, and V8's `SyntaxError` embeds about ten characters of
the offending document verbatim, mid-sentence:
The script's output did not parse: Unexpected token 's',
"{"auth":s3cr3t-tok"... is not valid JSON
On the `.mjs` path that branch is not reachable by ordinary script behaviour -
the runner's `encodeOutput` always emits valid JSON. On the `.sh` path any
script that writes non-JSON to `$ARGENT_OUTPUT` lands there, and what it wrote
is script-controlled text, so this branch adds the reachability.
The excerpt also defeats the redaction that runs afterwards.
`scrubSecretValues` matches whole values, so the prefix of a secret the
excerpt cut in half matches nothing, and `redactTruncated` repairs a cut
marked at the END of the message - by the runner - not one V8 made in the
middle of it. The same secret on the `.mjs` path comes back
`{"auth":"{{secret:API_KEY}}"}`.
Every double-quoted run is dropped from the message rather than the exact
sentence matched: the wording is V8's and is not a stability contract, but the
quoting is where a document's own bytes are, and the rest of that family
(`… at position 6`, `Unexpected end of JSON input`) quotes only with
apostrophes and survives unchanged. The author has the file; a ten-character
excerpt of it was worth nothing to them.
Reproduced through `argent flow run`: before, the message carried
`s3cr3t-tok`; after, `Unexpected token 's' is not valid JSON`.
`GROUP_SIGNAL_SETTLE_MS` is 1000 ms and was weighed against exactly one other
number, the parent's 1500 ms stop grace. It was never weighed against the
step's own time limit, whose legal floor is `MIN_SCRIPT_TIMEOUT_MS` = 100 and
whose entire range up to about 1050 ms sat below that settle.
So when bash died from SIGTERM, SIGINT or SIGHUP without the same signal
reaching the runner, `whenGroupSignalHeld` parked for the full second; the
executor's timer fired first, sealed the interruption and discarded the
terminal message that was already correct, and `classifyOutcome` fell through
to `timedOut`. The step was reported as a time limit that was never exceeded,
about the one fact that explains the failure - and the `.mjs` twin does not
behave this way, because this wait is on the bash path alone. No step-level
`timeout` was needed to reach it: `scripts.maxTimeoutMs` alone did.
The request now carries the parent's own limit, and the wait takes what is
left of it minus the room the verdict needs to travel back. A limit too short
to spare any of it reports at once, which is what the signal alone would have
said.
Measured through the executor with `kill -s TERM $$; sleep 30`:
before {100,timeout,177ms} {500,timeout,562} {900,timeout,965} {1200,signal} {3000,signal}
after {100,signal,64ms} {500,signal,292} {900,signal,695} {1200,signal} {3000,signal}
The `kill 0` group case still classifies as `exit` at the default limit, where
the settle is what it was.
The docstring claimed "a mixed-ending script is the only kind that ever gets
here: a fully CRLF one dies at `set -euo pipefail\r` with exit 2". Neither
half holds.
A fully CRLF script dies there only if it HAS that line. Without one it runs
to completion and leaves exactly the stray sibling this function looks for:
$ printf 'echo hi > "$OUT"\r\n' > noexit.sh
$ OUT=out2 /opt/homebrew/bin/bash noexit.sh ; echo "exit=$?"
exit=0
$ ls -b .
out2\r
The exit code is the bash's own too: the same `set -euo pipefail\r` script
exits 2 under GNU bash 5.3 (`set: pipefail: invalid option name`) and 1 under
the Apple bash 3.2 the docs point macOS users at.
The comment now says both, and a wholly CRLF script that runs to the end is
pinned beside the mixed-ending one.
`carriageReturnProblem` iterated both exchange files, but its only call site
was the exit-0 branch - and the non-zero exit branch is the only one that ever
reads `$ARGENT_REASON`. On a CRLF script, `echo "…" > "$ARGENT_REASON"` writes
`reason.txt\r`, `readReasonFile` reads the empty `reason.txt` the parent
seeded and returns "", and the step reported the bare exit line.
So the author whose script did explain itself got nothing - no reason text, no
note, and no CRLF hint, since `exitCodeHint` names CRLF only for 126 and 127 -
while the identical stray file on the exit-0 path produced a full remediation
message. This is the platform `windows-e2e.yml` is being added for, and CRLF
is git's default checkout there.
The exit path now asks the same question when it read no reason, and says what
the stray file cost: the explanation the script wrote. Reproduced through
`argent flow run` with a CRLF script that writes its reason and exits: before,
`The script exited with code 2 (bash: …).` and nothing else; after, the same
line followed by the CRLF remediation.
The marker was built from `cut` - the budget - rather than from how much text
survived. `readReasonFile` decodes the first `maxBytes`, trims, and then takes
the truncation path whenever the file held more, regardless of how long the
trimmed text is. So when leading whitespace consumed the head, `text.slice(0,
cut)` yielded far fewer characters than `cut` and the marker still reported
`cut`.
A step writing 25 000 spaces, 5000 letters and exiting 9 reported "keeps the
first 7168 characters" over 3672 letters; one writing 100 000 spaces and
nothing else showed no reason text at all and still announced 7168, so its
author went looking for a lost report rather than for a blank reason file.
The same number is what `REASON_KEPT_RE` and `redactTruncated` read back.
The cut now starts at the length of the text there is, so the count is the
head the report really carries. Reproduced through `argent flow run`: before,
"keeps the first 7168 characters"; after, "keeps the first 3672" and "keeps
the first 0".
The same budget was counted in two different units across the exchange: the
runner bounded what it read from `$ARGENT_OUTPUT` in FILE bytes, and the
parent bounded the document it accepts in post-decode UTF-8 bytes. Those agree
only for text that survives decoding unchanged, and `toString("utf8")` turned
every invalid sequence into a three-byte U+FFFD - so a file the runner
accepted at exactly the limit was over it by the time the parent measured, and
the step was refused for a size the script did not write.
Decoding strictly is what closes it: a document that decodes at all decodes to
exactly its own byte count, so the two bounds are the same number. A document
that does not is now refused for what is wrong with it.
Pinned by a case that writes exactly `SCRIPT_MAX_OUTPUT_BYTES` of file, every
padding byte invalid on its own. Before: "The script returned 3.0 MiB of
encoded output; the limit is 1.0 MiB." After: "not valid UTF-8", and no
mention of a limit.
`toString("utf8")` is lossy: an invalid byte sequence becomes U+FFFD. Nothing
downstream re-validated - `commitOutput` in the executor checks the size,
`JSON.parse`, object-ness and an own `__proto__`, none of which notices a
substituted character - so bytes the script wrote were silently rewritten and
the step was a pass.
A step running `printf '{"token":"\xc3\x28abc"}' > "$ARGENT_OUTPUT"` came back
`{"ok":true,"output":{"token":"�(abc"}}`. The corrupted value flowed into
flow state for later steps to read and compare against, with no failure, no
note, and nothing that lets the author see the document differs from what the
script emitted. A `.mjs` step cannot reach that state, because there the
document is a live JavaScript value - so this was a difference between the two
languages the step result did not surface.
The read now decodes with a fatal `TextDecoder` and reports the bytes rather
than rewriting them. Reproduced through `argent flow run`: before, `✓ 1
script`; after, `✗ … the document the script wrote to $ARGENT_OUTPUT is not
valid UTF-8`.
`GROUP_SIGNALS` held SIGTERM, SIGINT and SIGHUP, and the comment called it
"the signals a script can aim at its own process group". SIGQUIT is the
omission where bash and Node disagree in the direction that matters: GNU bash
5.x ignores SIGQUIT in a non-interactive shell, and Node's default kills on
it.
So a script whose body or EXIT trap ran `kill -QUIT 0` killed the runner and
left bash running - the failure `holdGroupSignals` exists to prevent. With no
terminal message the parent fell to the signal row of `classifyOutcome` and
reported "The script process was killed by SIGQUIT before it returned output.
It did not stop itself." Both halves were false: bash was not killed, it
ignored the signal, and the script's own line is what killed the runner. Its
sharpest spelling was `trap 'kill -QUIT 0' EXIT` on a script that had already
written a complete `$ARGENT_OUTPUT` and exited 0 - reported as a fault of the
machine it ran on, its document discarded.
The verdict now follows the bash: 5.x survives and the step passes on the
document it wrote; Apple's 3.2 dies on SIGQUIT and the step reports the same
`kill 0` guidance the SIGTERM case does.
Reproduced through `argent flow run`. Before: `✗ … killed by SIGQUIT … It did
not stop itself`. After, under bash 5.3: `✓ 1 script`, the background job
reaped. Under `scripts.bash = /bin/bash` (3.2): `killed by SIGQUIT … A
`kill 0` in the body of the script ends bash the same way`.
A `script` step's work is as often a shell one-liner as a Node program:
`curl` an internal API, `psql -f` a seed file, `adb shell` a settings
change. This adds `.sh` as a second language on the existing step - one
new extension, no new key, no new tool, no new step kind and no new
report field.
The contract is one line each: input is the environment, the output
document is the file named by `$ARGENT_OUTPUT`, the verdict is the exit
status, and a failure reason is the file named by `$ARGENT_REASON`.
Bash runs inside the runner rather than instead of it. The executor forks
the same `flow-script-runner.mjs`, as the entry module rather than as an
`--import` preload, and the runner spawns bash as its own child. That
keeps one process group per step, so the parent's group stop and both
watchdogs' group kills reach a bash descendant unchanged - which matters
because a bash polling loop is exactly the orphan the watchdogs exist for,
and a shell has no thread to watch a descriptor from.