idle-draft: clear attempts on success so forced redos start fresh

Claude-Session: https://claude.ai/code/session_01YQDoWNM7XPPii28khFWoMc
This commit is contained in:
Paul O'Reilly
2026-08-02 21:54:53 +12:00
parent 1f008eebcb
commit ec25f02f01
3 changed files with 67 additions and 6 deletions

View File

@@ -1096,11 +1096,30 @@ def log_gates(repo: Path, gates: dict, credential_ok: dict) -> None:
def apply_success_state(state: dict, candidate: dict) -> None:
# Successful dispatch doesn't itself set a human gate; nothing to flip here.
# (human_edit_done / research_sampled / approved are human-only via `mark`.)
# Present for symmetry with apply_failure_state and as the extension point
# if a future work type needs to record something on success.
"""On success, clear any failed-attempt history for this (item, work_type).
Without this, a success following one or more content failures leaves
attempts[work_type] at its prior nonzero value; if a human later deletes
the produced file to force a redo, the item would start already one
content failure away from `blocked` instead of fresh. Pruning (rather
than zeroing) the key/subkey keeps state minimal and matches how
get_item_state()/validate_state() already treat an absent attempts entry
as equivalent to zero -- nothing else (human_edit_done / research_sampled
/ approved) is a success-path concern; those are human-only via `mark`."""
if candidate["kind"] != "item":
return
key = candidate["item_key"]
work_type = candidate["work_type"]
if work_type not in ALLOWED_ATTEMPT_WORK_TYPES:
return
entry = state.get("items", {}).get(key)
if entry is None:
return
attempts = entry.get("attempts")
if not attempts or work_type not in attempts:
return
del attempts[work_type]
if not attempts:
del entry["attempts"]
def apply_failure_state(state: dict, candidate: dict, max_attempts: int) -> str | None:

View File

@@ -265,7 +265,14 @@ For the selected `(item_or_dossier, work_type, provider)`:
`max_attempts` (2) content failures for that work type, set
`state.items[item].blocked` with a reason (the item is then excluded from all
further dispatch until a human runs `mark unblock`).
- **Exit 0 and validation passes** → success.
- **Exit 0 and validation passes** → success. Clears (deletes, not zeroes)
`state.items[item].attempts[work_type]` if present, and drops `attempts`
entirely if it's now empty — a success following one or more prior content
failures for that work type resets the item's attempt history for that work
type, so a later human-forced redo (deleting the produced file and letting
idle-draft regenerate it) starts fresh rather than already one content
failure from `blocked`. Other work types' counters on the same item are
untouched.
7. Validation (on exit 0, before promotion): the temp output file must be non-empty and
its first non-blank line must be a top-level Markdown heading (`# ...`). For
`research` work type specifically: every absolute path matching `/home/\S+` cited in
@@ -400,6 +407,7 @@ a lighter-touch check); `draft` and `review` carry no `allowed_tools` key.
|---|---|---|---|
| Transient | timeout, HTTP 429/5xx, network reset | not incremented | retried next eligible cycle |
| Content | empty output, missing heading, dead citation path, non-retryable non-zero exit | incremented | `.rejected` kept; `blocked` at `max_attempts` (2) |
| Success | — | cleared (key deleted, `attempts` dropped if now empty) for that work type only | output promoted, committed |
## Exit codes

View File

@@ -670,6 +670,40 @@ check("signal handling: install_signal_handlers installs a non-default SIGINT ha
_signal.signal(_signal.SIGTERM, _signal.SIG_DFL)
_signal.signal(_signal.SIGINT, _signal.SIG_DFL)
# --- apply_success_state: clears attempts for the succeeded work type only ---
success_state = {"items": {"ai/01-topic-one": {"human_edit_done": True, "attempts": {"draft": 1, "research": 2}}}}
success_candidate = {"kind": "item", "item_key": "ai/01-topic-one", "work_type": "draft"}
m.apply_success_state(success_state, success_candidate)
entry_after = success_state["items"]["ai/01-topic-one"]
check("apply_success_state: clears the succeeded work type's attempts key",
"draft" not in entry_after.get("attempts", {}), entry_after)
check("apply_success_state: leaves an unrelated work type's attempts counter untouched",
entry_after.get("attempts", {}).get("research") == 2, entry_after)
check("apply_success_state: leaves other item fields (e.g. human_edit_done) untouched",
entry_after.get("human_edit_done") is True, entry_after)
# When the succeeded work type was the item's only attempts entry, the whole
# `attempts` key is dropped (not left behind as an empty {}).
success_state2 = {"items": {"ai/01-topic-one": {"attempts": {"draft": 1}}}}
m.apply_success_state(success_state2, success_candidate)
entry_after2 = success_state2["items"]["ai/01-topic-one"]
check("apply_success_state: drops the attempts key entirely once it's empty",
"attempts" not in entry_after2, entry_after2)
# No prior attempts entry at all -> no-op, no KeyError, no spurious key created.
success_state3 = {"items": {"ai/01-topic-one": {}}}
m.apply_success_state(success_state3, success_candidate)
check("apply_success_state: no-op when the item has no attempts history",
success_state3["items"]["ai/01-topic-one"] == {}, success_state3)
# Result is well-formed state per validate_state (schema still satisfied post-clear).
try:
m.validate_state(success_state, repo)
validate_ok = True
except m.StateValidationError:
validate_ok = False
check("apply_success_state: resulting state still passes validate_state", validate_ok, success_state)
for status, name, detail in results:
print(f"{status}: {name}", detail if detail else "")