Summary
When a script cron's subprocess dies without producing structured stdout, the
reported failure text is the first 500 bytes of its stderr. A process that
dies hard leaves its diagnosis last — the traceback is the final thing
written — so anything a startup path logged first displaces the actual cause,
and the operator reads a cron failure whose message describes something that did
not kill the job.
src/kiro_crew/cron_script.py:708 on main at d25d9baac:
if proc.returncode != 0 and not stdout.strip():
error_text = stderr[:500] or f"exit {proc.returncode}"
error_text = redact(error_text)
return {"status": "error", "error": error_text}
That value becomes the job's user-visible failure. slack/gateway.py:2625 turns
it into raise RuntimeError(err), which :2654 catches and records as
job.last_error (after redact) and logs as
Script cron '<name>' failed: <err>.
Why the head is the wrong end
The launcher execs the user module outside its try/except
(cron_script.py:624), so a module-level failure — a bad import, a missing
dependency, a raise during setup — reaches this branch with an unhandled
traceback on stderr, a non-zero exit, and nothing on stdout. The traceback is
what identifies the failure, and it is at the end.
Anything written to stderr before it competes for the same 500 bytes. The
data-home conflict warning is a worked example: config/paths.py:255 emits a
~300-character logger.warning to stderr when a non-empty legacy ~/.kirocrew
survives alongside the migrated home, which is a common post-upgrade state. It
is purely advisory — nothing in the cron path consults
detect_data_home_conflict(), and paths.py contains no raise at all — but
it lands first, so a cron that failed for an unrelated reason reports:
Script cron 'x' failed: data-home conflict: completion marker present at ... but a
non-empty legacy home ... also exists — the new home is authoritative ... Investigate
and remove it manually once confirmed stale (kirocrew doctor surfaces this).
RuntimeError: data-home conflict: ...
The operator is sent to delete a directory that is not why their job died, and
the reason it did die was truncated away. Any sufficiently chatty startup
warning produces the same effect; the migration warning is simply the one that
is easy to reproduce today.
Suggested fix
Report the terminal stderr context instead of the leading one, keeping the same
bound and the same redact call:
tail = stderr.rstrip()
error_text = tail[-500:] if tail else f"exit {proc.returncode}"
Deliberately not a filter for the migration warning's text — that would put
knowledge of one config-layer message inside the cron runner, and would not help
with the next warning. The generalisation that fits is about which end of a
failing process's output carries its diagnosis.
Behaviour that must not change, and is worth pinning: a stderr already shorter
than the bound stays whole; an empty stderr still falls back to
exit <returncode>; and the reported text still goes through redact.
Scope
run_script_sandboxed's failure-reporting branch only. Not the stdout JSON
parsing path, not the gateway's delivery or notification semantics, not
auto-pause, and not the data-home migration or its warning — that warning is
correct as advisory output and is left alone.
Discovered while investigating #4157, whose reported symptom this explains; the
causal chain there is not the one that issue describes, so this is filed
separately.
Summary
When a script cron's subprocess dies without producing structured stdout, the
reported failure text is the first 500 bytes of its stderr. A process that
dies hard leaves its diagnosis last — the traceback is the final thing
written — so anything a startup path logged first displaces the actual cause,
and the operator reads a cron failure whose message describes something that did
not kill the job.
src/kiro_crew/cron_script.py:708onmainatd25d9baac:That value becomes the job's user-visible failure.
slack/gateway.py:2625turnsit into
raise RuntimeError(err), which:2654catches and records asjob.last_error(afterredact) and logs asScript cron '<name>' failed: <err>.Why the head is the wrong end
The launcher
execs the user module outside itstry/except(
cron_script.py:624), so a module-level failure — a bad import, a missingdependency, a raise during setup — reaches this branch with an unhandled
traceback on stderr, a non-zero exit, and nothing on stdout. The traceback is
what identifies the failure, and it is at the end.
Anything written to stderr before it competes for the same 500 bytes. The
data-home conflict warning is a worked example:
config/paths.py:255emits a~300-character
logger.warningto stderr when a non-empty legacy~/.kirocrewsurvives alongside the migrated home, which is a common post-upgrade state. It
is purely advisory — nothing in the cron path consults
detect_data_home_conflict(), andpaths.pycontains noraiseat all — butit lands first, so a cron that failed for an unrelated reason reports:
The operator is sent to delete a directory that is not why their job died, and
the reason it did die was truncated away. Any sufficiently chatty startup
warning produces the same effect; the migration warning is simply the one that
is easy to reproduce today.
Suggested fix
Report the terminal stderr context instead of the leading one, keeping the same
bound and the same
redactcall:Deliberately not a filter for the migration warning's text — that would put
knowledge of one config-layer message inside the cron runner, and would not help
with the next warning. The generalisation that fits is about which end of a
failing process's output carries its diagnosis.
Behaviour that must not change, and is worth pinning: a stderr already shorter
than the bound stays whole; an empty stderr still falls back to
exit <returncode>; and the reported text still goes throughredact.Scope
run_script_sandboxed's failure-reporting branch only. Not the stdout JSONparsing path, not the gateway's delivery or notification semantics, not
auto-pause, and not the data-home migration or its warning — that warning is
correct as advisory output and is left alone.
Discovered while investigating #4157, whose reported symptom this explains; the
causal chain there is not the one that issue describes, so this is filed
separately.