From ec25f02f016e3f745453a59853be63ddcf43f169 Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Sun, 2 Aug 2026 21:54:53 +1200 Subject: [PATCH] idle-draft: clear attempts on success so forced redos start fresh Claude-Session: https://claude.ai/code/session_01YQDoWNM7XPPii28khFWoMc --- scripts/idle-draft | 29 ++++++++++++++++++++++++----- specs/idle-draft.spec.md | 10 +++++++++- tests/test-idle-draft.sh | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/scripts/idle-draft b/scripts/idle-draft index ee664bf..a346449 100755 --- a/scripts/idle-draft +++ b/scripts/idle-draft @@ -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. - return + """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: diff --git a/specs/idle-draft.spec.md b/specs/idle-draft.spec.md index f7d1f0a..e19e829 100644 --- a/specs/idle-draft.spec.md +++ b/specs/idle-draft.spec.md @@ -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 diff --git a/tests/test-idle-draft.sh b/tests/test-idle-draft.sh index 742b42a..6a1a7f4 100755 --- a/tests/test-idle-draft.sh +++ b/tests/test-idle-draft.sh @@ -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 "")