Skip to content

A terminal artifact truncated mid-write crashes the whole run, not just the session #96

Description

@pbean

Context

Surfaced by CodeRabbit's review of #95 (post-kill reconcile, fixes #61), but pre-existing and independent of it: the two raise sites below are reached from the ordinary Stop and crash paths, not only from #95's new hook. Filing separately so the fix that ships in #95 has a scope of its own.

The read-back decodes artifacts as UTF-8. A spec the CLI was killed mid-write can end inside a multi-byte sequence; read_text(encoding="utf-8") then raises UnicodeDecodeError — which is a ValueError, not an OSError, so it sails through every except OSError guard on the way out. verify.read_frontmatter's own comment already warned about exactly this trap.

The two raise sites (at 37a01f1, the #95 base)

  1. devcontract.find_result_artifact (devcontract.py:286-287) — reads each candidate to check it carries a real, non-fenced terminal section ([BUG] find_result_artifact / parse_auto_run_result treat a fence-quoted heading as a real terminal section #52), guarded by except OSError. A torn multi-byte sequence escapes. A candidate that cannot be shown to carry a terminal section does not qualify, so it should be skipped like any other unreadable file.

  2. devcontract.synthesize_result (devcontract.py:211) — spec_path.read_text(encoding="utf-8") if spec_path.is_file() else "" is unguarded entirely. It is reached with a file the finder never read, because the bmad-dev-auto-result-*.md fallback marker is matched by name (it has no ## Auto Run Result heading). The preceding read_frontmatter(spec_path) call swallows the decode error and returns {}, so the raise lands on the very next line.

Why it takes down the whole run, not just the session

Nothing between the read and the top-level handler contains it. adapter.run(spec) is called bare from Engine._run_session (engine.py:2068); task.record_session(...) and _save both sit after it (engine.py:2083+). The only catch is Engine.run()'s broad except Exception (engine.py:265), which sets crashed=True / crash_error (engine.py:293-294).

Verified end-to-end against the real Engine.run() on a pre-fix tree:

  • Pre-fix: run ends crashed=True, crash_error="UnicodeDecodeError: ... byte 0xff", crash.txt written, the task pinned in DEV_RUNNING with no SessionRecord persisted, and every remaining story abandoned.
  • Post-fix: the same scenario lands in the ordinary retry-exhausted path — crashed=False, task DEFERRED, session records persisted.

Fix

Landed in #95 as 1f8918e:

  • find_result_artifact widens to except (OSError, UnicodeDecodeError) and skips the candidate.
  • synthesize_result's bare read routes through a _read_text_or_empty helper that degrades to "".

An unreadable spec carries no parseable result section, so it now reads exactly like a spec that has not terminated yet — the caller waits, nudges, or keeps its verdict.

The repair path deliberately keeps the opposite contract. reset_spec_status and strip_auto_run_result still raise on a present-but-unreadable spec: silently skipping a rewrite the caller believes succeeded would leave the re-opened spec carrying its stale terminal section, which is precisely the state that makes the re-driven session's first save read as a result.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions