Skip to content

read_frontmatter: an OSError on the Stop path escapes eight status gates #97

Description

@pbean

Context

Split out of the #95 review thread so the analysis does not evaporate when that PR merges. Pre-existing; #95 neither introduces nor touches it (verify.py is not in that PR's diff).

verify.read_frontmatter (verify.py:854) guards the decode fault but not the I/O fault:

def read_frontmatter(path: Path) -> dict[str, Any]:
    if not path.is_file():
        return {}
    try:
        text = path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        return {}

An OSError propagates — a permission fault, or a TOCTOU delete/replace landing between is_file() and read_text(). The window is real: specs are rewritten by the dev skill while the orchestrator polls them.

Unguarded call sites

Eight, none of which catch OSError:

  • engine.py:1829 (_reconcile_generic_terminal_status), engine.py:1865, engine.py:1897
  • sweep.py:1051
  • verify.py:1087, verify.py:1368, verify.py:1389, verify.py:1404
  • devcontract.py:227 (inside synthesize_result, reached from the Stop path)

The sole guarded caller is stories.resolve_story_spec (stories.py:327-334), which wraps it in except (OSError, UnicodeDecodeError) and degrades to status = "". That guard is therefore load-bearing today and must not be removed as "redundant".

Why the obvious fix is wrong

Do not widen read_frontmatter to except (OSError, UnicodeDecodeError).

A genuine disk or permission fault would then degrade silently to "status unknown → clean retry" across ~12 status gates in verify, engine, sweep, and stories — trading loudness for resilience on a fault that should be loud. A missing status and an unreadable disk are different conditions; only the first is safe to treat as "not finished yet."

Any fix belongs at the call sites, decided per gate: which of these twelve should surface a disk fault to the operator, and which should degrade? That is a real design pass, not a one-line widening.

Not affected

#95's _post_kill_reconcile wraps its _synth_result call in except (OSError, UnicodeDecodeError) -> return result, so the post-kill rescue keeps its verdict on any read fault and is immune to this by construction. This issue is about the Stop path only.

Related

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions