Files
small-scripts/specs/idle-draft.spec.md
Paul O'Reilly 56c621713d idle-draft: fill all free worker slots, not one per completion
try_submit() returned after a single submission and was only called at
startup and once per completion, capping real concurrency at 1 task
regardless of the parallel setting — the ThreadPoolExecutor pool was
sized but never filled. Loop until every free slot is filled or no
eligible candidate remains, per spec §10 (wording sharpened to make the
whole-pool semantics explicit). No stub-claude harness exists yet to
test dispatch concurrency end-to-end; verified live against the real
queue (36 candidates, parallel=20).

Claude-Session: https://claude.ai/code/session_01Lgv4Qn82boNFC1jn8QXSNw
2026-08-03 07:29:52 +12:00

33 KiB
Raw Blame History

idle-draft

Purpose

Cron-invoked dispatcher that consumes idle Anthropic/MiniMax subscription capacity to advance the O'Reilly Consulting writing pipeline (~/dev/claude/writing/oreillyconsulting) unattended: probes usage via agent-subscriptions, computes idle capacity per provider, dispatches ready work items to headless claude -p with bounded parallelism, re-probes on every completion, validates output, and commits results.

Design contract: ~/dev/claude/writing/oreillyconsulting/IDLE-DRAFT-PLAN.md. This spec translates that plan into the repo's implementation contract; where the two disagree, the plan wins and this file should be corrected.

Usage

idle-draft [OPTIONS]
idle-draft mark <dossier/NN-slug> edited|sampled|approved|unblock [OPTIONS]
idle-draft status [OPTIONS]

Options (dispatch mode and status)

Flag Default Description
--config FILE ./idle-draft.config.json Path to the config file
--repo DIR config file's directory Writing repo root (contains the dossiers)
--once off Dispatch at most one task, then exit (pilot mode)
--parallel N from config, or 2 Max concurrent tasks (overrides config)
--dryrun, -n off Probe + gate + select, print what would be dispatched, execute and mutate nothing
--probe-json FILE none Read probe output from FILE instead of running agent-subscriptions (test hook / pilot aid)
--help, -h Show usage and exit 0

mark subcommand

idle-draft mark <dossier/NN-slug> edited|sampled|approved|unblock [--config FILE] [--repo DIR]

The only human mutation path into idle-draft.state.json. Validates the item resolves to an existing <repo>/<dossier>/NN-slug.overview.md, loads and schema-validates the state file, applies exactly one field mutation, writes atomically.

Action Effect
edited human_edit_done = true
sampled research_sampled = true
approved approved = true
unblock blocked = null

status subcommand

Human-readable report: per-item derived stage, human-gate flags, next eligible action (or the reason it is not eligible), and per-provider gate values (idle_points, five_hour_pct, eligibility) from a live probe (or --probe-json). Read-only; takes no lock, mutates nothing.

Behaviour

1. Locking

Before doing anything else (dispatch mode only — mark and status do not take the lock), flock (fcntl.flock, LOCK_EX | LOCK_NB) on <repo>/.idle-draft.lock. If already locked by another invocation, log one line and exit 0 — a concurrent cron tick is not a failure.

Guarantee: the lock is acquired before any other work and held for the entire dispatch loop, including while dispatched children are running — not released until the process is about to exit. A second cron-fired invocation therefore always exits 0 immediately for as long as the first is alive, however long that is (a low-usage provider can let one instance run for hours "catching up"; the lock does not expire or time out). This is the primary concurrency guard; §13 covers what happens when the lock-holding process itself dies uncleanly rather than exiting normally.

2. Log rotation

At startup, if <repo>/idle-draft.log exceeds 5 MB, rename it to idle-draft.log.1 (overwriting any existing .1) before appending further.

3. Config load

Parse --config as JSON. Required top-level keys: parallel, providers, work_types, dossiers, review_score_threshold, max_unreviewed_research_per_dossier, max_open_topic_proposals, evidence_dirs — matching data/idle-draft/config.example.json verbatim. Missing required keys or malformed structure → log loudly, exit 2. --parallel on the CLI overrides the config value.

Optional key sample_override (bool, default false): when true, the cold-start throttle (§ dispatch, below) is bypassed — research candidates are offered even when the dossier is at max_unreviewed_research_per_dossier. Nothing else changes: research_sampled bookkeeping, mark … sampled, and the status flags are unaffected, so unsampled research remains visible and awaiting human verification.

4. State load and validation

Read <repo>/idle-draft.state.json. Missing file is not an error — treat as {"items": {}}. If present:

  • Must parse as JSON.
  • Top level must be an object with only the key items (unknown top-level keys rejected).
  • items must be an object. Each key must be of the form <dossier>/<NN-slug> and must resolve to an existing <repo>/<dossier>/<NN-slug>.overview.md — an item entry for a file that doesn't exist is rejected.
  • Each item value must be an object containing only these keys (all optional, defaults shown): human_edit_done (bool, default false), research_sampled (bool, default false), approved (bool, default false), blocked (string or null, default null), attempts (object mapping research/draft/review → non-negative int, default {}). Any other key, or a wrong-typed value, is rejected.

Any validation failure aborts the run loudly (message to stderr and the log) with exit 2 — never "best effort," never silently ignored. This applies to mark and status too (both load and validate the state file before proceeding).

5. Probe

Run agent-subscriptions --output json as a subprocess and parse stdout as JSON, unless --probe-json FILE is given, in which case that file's contents are used verbatim instead (no subprocess call — the test hook / pilot aid). A subprocess failure (non-zero exit, unparseable stdout, timeout) is logged loudly; the run exits 1 if no usable probe data was obtained at all (dispatch loop never starts).

6. Gates (evaluated per provider, every cycle — including on re-probe after each

completion)

For each provider in config["providers"], using that provider's seven_day and five_hour window records from the probe report:

idle_points = threshold_pct × elapsed_pct  usage_pct        (on the seven_day window)

A provider is eligible for a new dispatch iff all of:

  • the probe marks it available: true
  • seven_day.elapsed_pct is non-null (null → "cannot pace", fail closed)
  • seven_day.utilization_pct is non-null
  • five_hour.utilization_pct is non-null
  • idle_points > min_idle
  • five_hour.utilization_pct < five_hour_ceiling
  • the credential-parity check (§7) passes for that provider

All three gate values (idle_points, five_hour.utilization_pct, eligibility) are logged for every provider on every cycle, whether or not the provider ends up used.

7. Credential parity

Before a provider can be selected, idle-draft resolves the credential its profile would export at launch and compares it (by content, via SHA-256) against the credential agent-subscriptions used to probe that same provider:

  • Profile side: if <profile>/provider.env defines ANTHROPIC_API_KEY_FILE, read that file's content. If the profile has no provider.env (the default Anthropic profile), use the same token file agent-subscriptions reads for Anthropic (~/dev/claude/secrets/anthropic/api_key).
  • Probe side: for anthropic, the same ~/dev/claude/secrets/anthropic/api_key file content. For minimax, the SOPS-decrypted ANTHROPIC_AUTH_TOKEN value from agent-subscriptions' MiniMax path (same SOPS file, same key file, same dotenv key — these constants are replicated locally and must be kept in sync with scripts/agent-subscriptions if that script's paths change).

A mismatch (or either side unreadable) makes the provider ineligible for this cycle and logs a loud line: credential mismatch: <provider> profile≠probe (<reason>). The gate must meter the account that actually spends, not the account the profile file merely names.

8. Work-item discovery and stage derivation

For each dossier in config["dossiers"], glob <repo>/<dossier>/*.overview.md matching ^(\d+)-(.+)\.overview\.md$. For each NN-slug, derive the next eligible work type (or "not eligible, because...") purely from which sibling files exist plus the item's state entry:

Condition Result
state.blocked is set not eligible: blocked
state.approved is true not eligible: approved (terminal)
NN-slug.agent.md missing not eligible: no commissioning brief
NN-slug.research.md missing next: research
NN-slug.draft.md missing next: draft
NN-slug.review.md exists not eligible: awaiting human revise/approve
state.human_edit_done is not true not eligible: waiting on human edit
(all of the above pass) next: review

In-flight items (a task currently dispatched for that item in this run) are excluded from consideration for further dispatch until the in-flight task completes.

Cold-start throttle: per dossier, count NN-slug.research.md files that exist and whose state entry does not have research_sampled: true. Once that count reaches max_unreviewed_research_per_dossier, no further research candidates are offered for that dossier this cycle (draft/review candidates in that dossier are unaffected). If config sample_override is true, this throttle is skipped entirely; the unsampled counts and research_sampled state are still maintained exactly as above.

topic_ideas: dossier-level (not tied to an NN-slug), considered only when the combined candidate list above (across all dossiers) is empty. For each dossier, eligible iff the count of proposals already recorded in <dossier>/TOPIC-PROPOSALS.md (one ## heading per proposal; file absent counts as 0) is below max_open_topic_proposals.

9. Prioritisation

Ready (item, work_type) candidates (plus, only when the list would otherwise be empty, (dossier, topic_ideas) candidates) are sorted by:

  1. Stage rank, descending: review (3) > draft (2) > research (1) > topic_ideas (0).
  2. Numeric filename prefix, ascending (topic_ideas sorts as 0, always last within its own rank tier — moot since it only appears when nothing else is ready).
  3. Dossier config order (config["dossiers"] index), ascending, as the tiebreak.

For each candidate in this order, the provider is the first entry in config["work_types"][work_type]["providers"] that is currently eligible (§6, §7). A candidate with no eligible provider is skipped (not dispatched this cycle, tried again next cycle); the walk continues to the next candidate.

10. Dispatch loop (worker pool, event-driven)

Up to parallel tasks run concurrently (concurrent.futures.ThreadPoolExecutor). Each completion is handled serially in the main thread (state updates and git commits never race). On every completion — and before the very first dispatch — the gates (§6) are recomputed from a fresh probe (§5); tasks are then submitted until every free worker slot is filled or no eligible (item, work_type, provider) triple remains — one completion may therefore trigger multiple submissions, and the initial call before the first dispatch fills the whole pool, not one slot. The loop exits (dispatch mode, non-dryrun) when no eligible candidate remains. --once dispatches at most one task total, then exits without waiting for further slots.

11. Task execution

For the selected (item_or_dossier, work_type, provider):

  1. Resolve the prompt template: data/idle-draft/prompts/{research,draft,review-suggest,topic-ideas}.md (review work type uses review-suggest.md).
  2. Render the template (string.Template, $placeholder substitution) with the resolved paths for that item (overview, agent, research, draft as applicable), dossier name, slug, style directory, source register path, and a temp output path. The temp output path is <canonical>.tmp.<dispatcher-pid> — unique per dispatcher invocation (all tasks within one dispatch run share the same suffix, since it's this process's own pid), not per task. This is what makes tmp paths collision-proof across dispatcher instances even when one instance dies leaving orphaned children behind — see §13.
  3. Resolve the profile directory from config["providers"][provider]["profile"] (~ expanded). Build the child environment: CLAUDE_CONFIG_DIR=<profile>, plus — if <profile>/provider.env exists — ANTHROPIC_BASE_URL (if set), ANTHROPIC_API_KEY (read from ANTHROPIC_API_KEY_FILE, if set), and any other KEY=value line verbatim. A profile with no provider.env (plain Anthropic) gets only CLAUDE_CONFIG_DIR. This logic is a local re-implementation of scripts/claude-profile lines ~320440 — claude-profile itself is never invoked (it has interactive pickers and terminal theming unsuitable for headless cron use).
  4. Build the argv: claude -p --max-turns <N> (N = 25 unless overridden), plus --model <id> and --effort <level> resolved through the full precedence chain — per-provider override on the selected provider's providers-list entry, then work-type-level model/effort, then (for model only) provider.env's MODEL_ID; the flag is omitted at each layer where nothing resolves (see "Per-work-type effort and model" and "Per-provider effort and model overrides" below) — plus --add-dir <dir> for each entry in config["evidence_dirs"] only for research and topic_ideas work types (the only ones that cite external evidence), plus --allowedTools "<comma-joined list>" if config["work_types"][work_type]["allowed_tools"] is present and non-empty (see Config schema below). Headless claude -p runs cannot answer permission prompts, so any tool beyond the CLI's defaults must be granted this way or the child run stalls/fails; when the allowed_tools key is absent or an empty list, that flag is omitted entirely (unchanged pre-existing behaviour). Same for effort/model: absent key -> no corresponding flag, argv unchanged from before these keys existed.
  5. Run the subprocess: cwd=<repo>, env=<built env>, prompt piped via stdin (not as an argv element — avoids ARG_MAX on large rendered prompts, same lesson claude-profile already applies to its system-prompt injection), timeout = per-task timeout (1800s unless overridden). The child is started in its own process group (start_new_session=True) and its pid is tracked in a live-children registry for the duration of the call — see §13. On timeout, the whole process group is sent SIGKILL (not just the immediate claude process), so any grandchild it spawned dies too, before the task is classified as a transient failure.
  6. Classify the result:
    • Timeout → transient failure. Do not increment attempts.
    • Non-zero exit whose stderr matches a retryable signature (429, 5xx, rate limit, overloaded, timeout, temporarily unavailable, connection-reset markers) → transient failure. Do not increment attempts.
    • Non-zero exit, no retryable signature, or exit 0 but validation fails below → content failure. Increment state.items[item].attempts[work_type]. At 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. 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 the file must exist on disk (Path.exists()); any dead path fails validation. Citations may carry a trailing line-range suffix in path:120-145 or path:120 form (per the research prompt template's required citation format); this suffix is stripped — via re.sub(r":\d+(-\d+)?$", ...), only when the remainder still looks like a path — before the existence check, so a valid ranged citation is not flagged as a dead path.
  8. On validation pass: os.replace() the temp file to the canonical path (<repo>/<dossier>/<NN-slug>.<work_type>.md, or append to <dossier>/TOPIC-PROPOSALS.md for topic_ideas) — atomic, never a partial file visible under the canonical name.
  9. On content-failure: os.replace() the temp file to <repo>/<dossier>/<NN-slug>.<work_type>.md.rejected (kept for human inspection, never promoted, never committed to the canonical name; topic_ideas content failures are simply discarded — nothing is appended, nothing is blocked, since topic_ideas has no per-item state entry to carry an attempt counter). The .rejected path is derived from the canonical path, not the tmp path, so it is unaffected by the .tmp.<dispatcher-pid> suffix (§13) — always <canonical>.rejected, never <canonical>.rejected.<pid>.
  10. On transient failure: temp file is discarded; no state change; no commit; the candidate may be retried on a later cycle.
  11. On success: write idle-draft.state.json atomically (temp file + os.replace() in the same directory), then:
    git -C <repo> add -- <produced-file>
    git -C <repo> commit -m "<message>" -- <produced-file>
    
    Never git add -A, never git commit -a. idle-draft.state.json is machine-managed churn and is gitignored in the target repo — it is written atomically but never staged or committed (explicitly git add-ing an ignored path fails, which would break every machine commit). Commit message: idle-draft: <work_type> <item> via <provider> (7d <before>%→<after>%), where before/after are that provider's seven_day.utilization_pct immediately before dispatch and immediately after re-probe on completion. No push.

12. Logging

One line per event (gate decision, dispatch, completion, failure, commit) appended to <repo>/idle-draft.log with an ISO-8601 timestamp, mirrored to stderr.

13. Concurrency protection beyond the lock

§1's flock is the primary guard and handles the common case: a second cron-fired invocation always sees the lock held and exits 0. This section covers the gap the lock alone cannot close — a dispatcher process that dies without releasing its children cleanly (SIGTERM, SIGKILL, OOM kill, machine reboot). The flock itself is released the instant the holding process's file descriptors close (on any death, clean or not), but a claude child spawned via subprocess is a separate OS process and does not die automatically when its parent does — left alone it becomes an orphan, reparented to init, still writing its output. A fresh cron-fired instance starting immediately after would then see the lock free, pick up the same ready item, and dispatch a second claude process against the same deterministic output path — two writers racing on the "same" canonical file. Two independent mechanisms close this:

a. Children die with the dispatcher (the common death path). Every claude child is spawned with start_new_session=True, putting it (and any process it forks) in its own process group with the child's pid as the group id. The dispatcher tracks each live child's pid in an in-process registry for the duration of the call (register_child / unregister_child, guarded by a lock since tasks run in worker threads). On SIGTERM or SIGINT, an installed handler walks the registry and os.killpgs each group with SIGKILL, then exits — no child outlives a dispatcher that receives a catchable signal. The same cleanup also runs via atexit, so a normal unhandled-exception exit (no signal involved) still cannot leave children behind. This cannot catch SIGKILL of the dispatcher itself — no userspace handler can — which is exactly the case mechanism (b) exists for.

b. Tmp-path collisions are structurally impossible (the uncatchable-death path). Each task's temp output file is named <canonical>.tmp.<dispatcher-pid> (§11 step 2), not the old bare <canonical>.tmp. Every dispatcher process has a distinct pid, so even an orphaned child that outlives a SIGKILLed dispatcher is writing to a filename no other dispatcher instance — past, present, or future — will ever target. There is no race to resolve: the collision that used to be structurally possible (two writers, one path) is now structurally impossible (two writers, two paths).

Startup sweep. After acquiring the lock, before touching config-driven state, a fresh dispatch instance globs each configured dossier directory for leftover *.tmp.<pid> files. Any whose mtime is older than the per-task timeout (1800s unless overridden) is deleted and logged as swept — it is orphaned litter from a dispatcher that died mid-task; since it was never promoted (only the exact run_task() call that created it ever calls os.replace() on it) it can never silently become the canonical file no matter how long it sits there. Files younger than the timeout are left alone — they may belong to a live orphan (mechanism (a) failed to reap it, e.g. it was itself SIGKILLed independently, or belongs to another concurrent dispatch cycle that legitimately still has it in flight) still finishing up; touching them risks nothing since, again, nothing ever silently adopts a tmp file it didn't create — at worst a stale one sits unswept until the next startup sweep, once it ages past the timeout.

Dryrun behaviour

--dryrun runs the full probe (or reads --probe-json), computes all gates, builds the ready queue, and selects up to parallel (or 1, under --once) (item, work_type, provider) triples exactly as the real dispatch loop's first wave would — without simulating gate depletion across a re-probe (a real run only knows that after actually dispatching; the preview shows the initial wave only, noted as such). For each selected triple it prints, and executes nothing:

  • item or dossier identifier, work type, chosen provider
  • resolved prompt template path
  • the exact claude argv list that would run
  • the child env deltas (CLAUDE_CONFIG_DIR, ANTHROPIC_BASE_URL if set, whether ANTHROPIC_API_KEY would be exported)
  • the temp output path and the canonical destination path

No subprocess is run, no file is written, no state is mutated, no lock is required to be free for the preview to work (the lock is still attempted and its outcome reported, but a held lock does not block the dryrun preview from computing and printing — real dispatch mode is what respects the lock as a hard gate). Exit 0.

State schema

idle-draft.state.json, repo root — see §4. Humans never hand-edit this file; the mark subcommand is the only mutation path. Derived state (anything the filesystem already says) is never written here.

Config schema

idle-draft.config.json — see data/idle-draft/config.example.json for the exact structure. All top-level keys listed in §3 are required.

Each entry in work_types may carry an optional allowed_tools key: a list of Claude Code tool names (e.g. ["WebSearch", "WebFetch"]) granted to that work type's headless child via --allowedTools (§11 step 4). Present-but-empty and absent are both treated as "no extra tools" — the flag is omitted. If present, it must be a JSON array of strings; any other shape (a string, a number, a list containing a non-string) is a config validation failure — exit 2 at config load, same as a missing required key. config.example.json sets allowed_tools: ["WebSearch", "WebFetch"] on research and topic_ideas (the research prompt template requires at least one WebSearch call before writing its Subjective stories section; the topic-ideas template requires one as a lighter-touch check); draft and review carry no allowed_tools key.

Per-work-type effort and model

Each entry in work_types may also carry two further optional keys, both consumed by §11 step 4 when building the child's argv:

  • effort (string): appends --effort <value> to the child argv. Validated at config-load time against the fixed set of levels the claude CLI itself accepts: low, medium, high, xhigh, max. This set was determined empirically on 2026-08-02 by running claude --effort obviously-bogus-level -p "" — an invalid --effort value is rejected by the CLI's own argument parsing before any network call is made, so the probe is safe to run offline — and reading the resulting error, which enumerated the valid values verbatim (cross-checked against claude --help's --effort <level> description, which lists the same five). The set is hard-coded as CLAUDE_EFFORT_LEVELS in scripts/idle-draft; if a future CLI version changes it, re-run the same probe and update the constant (with a fresh date in the comment). A value that is not a non-empty string, or not a member of this set, is a config validation failure — exit 2, same as a malformed allowed_tools.
  • model (string): appends --model <value> to the child argv, replacing — not adding to — the --model <id> that would otherwise come from the provider profile's provider.env MODEL_ID (§11 step 4). Only type-checked at config-load time (must be a non-empty string) — model identifiers are not enumerated, since new ones ship independently of this tool and hard-coding a set would go stale. A non-string or empty value is a config validation failure — exit 2.

Both keys are optional and independent of each other and of allowed_tools. Absent on a given work type -> the corresponding flag is omitted / the profile's model is used unchanged, matching the tool's behaviour before these keys existed.

Per-provider effort and model overrides (within a work type)

model/effort can be pinned even more narrowly than per-work-type: per (work_type, provider) pair. Each entry in a work_types[*].providers list may be either:

  • a plain provider-name string (the original, unchanged form) — no per-provider override, resolution falls through to the work-type-level model/effort (above); or
  • an object {"provider": "<name>", "model": "<optional>", "effort": "<optional>"}model/effort here apply only when this provider is the one actually selected (§9) for a dispatch of this work type.

Both forms may be mixed freely within the same providers list; list order still encodes provider preference exactly as before (§9) — the object form does not change which provider is tried first, only what argv that provider gets if chosen. config.example.json demonstrates this: research's providers is ["anthropic", {"provider": "minimax", "effort": "medium"}]anthropic stays a plain string (falls through to research's work-type-level effort), minimax pins its own effort via the object form.

Full precedence chain, evaluated once a (work_type, provider) pair has been selected (§9), for each of model and effort independently:

  1. The per-provider override on the matching providers-list entry for that provider (object form only; a plain-string entry has none).
  2. The work-type-level model/effort (the keys directly under work_types[work_type], previous section).
  3. Ultimate fallback: for model, the profile's provider.env MODEL_ID (or no --model flag if unset); for effort, no --effort flag (the CLI's own default).

Validation (config-load time, exit 2 on any failure, same style as every other config check):

  • Every providers-list entry — string or object form — must name a provider that exists as a key in the top-level providers map. An entry (of either form) naming an unrecognised provider is rejected; this closes a gap that predates the object form — a typo'd plain-string provider name was previously silently ineligible (never selected, no error) rather than rejected at load time.
  • An object-form entry must have a "provider" key whose value is a non-empty string.
  • An object-form entry's keys are restricted to provider, model, effort; any other key is rejected.
  • An object-form entry's model, if present, must be a non-empty string (same rule as the work-type-level model).
  • An object-form entry's effort, if present, must be a non-empty string and a member of CLAUDE_EFFORT_LEVELS (same rule as the work-type-level effort).
  • A providers-list entry that is neither a string nor an object is rejected.

--dryrun prints the fully resolved argv (§11 step 4's --model/--effort included, after the full precedence chain above), so a preview reveals exactly which provider, model, and effort level a given work type would route to before anything is dispatched for real.

Failure classes

Class Examples attempts effect Item outcome
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

Code Meaning
0 Nothing to do, or all dispatched tasks completed without a fatal problem (individual task failures are logged, not fatal)
1 Transient/probe problem prevented the run from proceeding (no usable probe data, lock held is exit 0 not 1 — see §1)
2 Config or state validation failure

Edge cases

Scenario Handling
Lock already held Log one line, exit 0 (not an error — another cron tick is running), regardless of how long the holder has been running
Dispatcher receives SIGTERM/SIGINT while children are running Handler os.killpgs every live child's process group, then exits (§13a)
Dispatcher is SIGKILLed (uncatchable) Children orphaned, but each was writing to a .tmp.<dispatcher-pid> path unique to that dead instance — no live instance can ever collide with it; stale ones swept once they age past the task timeout (§13b)
idle-draft.state.json missing Treated as {"items": {}}, not an error
idle-draft.state.json present but invalid Exit 2, loud message, run never starts
Item's .agent.md missing Item excluded from all dispatch (commissioning briefs exist today for every current item; this guards future additions)
seven_day.elapsed_pct null Provider ineligible this cycle, never assumed idle
Provider available: false Provider ineligible this cycle
Credential mismatch Provider ineligible this cycle, loud log line
research output cites a path that doesn't exist Content failure, quarantined as .rejected, attempts incremented
max_attempts reached Item blocked, excluded until mark unblock
topic_ideas content failure Discarded silently (logged, not blocked — no per-item state key exists for a dossier-level work type)
Two work types both ready in the same dossier Higher stage rank wins (review > draft > research); topic_ideas never competes (last-resort only)
--probe-json combined with --dryrun Composes normally — no subprocess call either way
--once with no eligible work Exits 0 immediately, no task dispatched
Config missing a required key Exit 2
work_types[*].allowed_tools present but not a list of strings Exit 2
work_types[*].effort present but not one of low/medium/high/xhigh/max Exit 2
work_types[*].effort present but empty string / not a string Exit 2
work_types[*].model present but empty string / not a string Exit 2
work_types[*].model set alongside a profile whose provider.env also sets MODEL_ID Work-type model wins (absent a per-provider override); argv carries exactly one --model flag
work_types[*].providers[*] (string or object form) names a provider not present in the top-level providers map Exit 2
work_types[*].providers[*] object form missing/empty "provider" key Exit 2
work_types[*].providers[*] object form has an unknown key (anything besides provider/model/effort) Exit 2
work_types[*].providers[*] object form model/effort invalid (same rules as the work-type-level keys) Exit 2
work_types[*].providers[*] entry is neither a string nor an object Exit 2
A provider's providers-list entry sets its own model/effort, and the work type also sets a work-type-level model/effort Per-provider entry wins for that provider; a sibling provider in the same list with a plain-string entry still falls through to the work-type-level value
agent-subscriptions subprocess fails entirely Exit 1, loud log line, no dispatch attempted

Examples

idle-draft --dryrun                                    # preview one dispatch wave
idle-draft --once                                       # pilot: one real task, then exit
idle-draft --probe-json /tmp/fake-probe.json --dryrun    # preview against fixture data
idle-draft --config ~/dev/claude/writing/oreillyconsulting/idle-draft.config.json
idle-draft mark ai/03-shadow-agents-are-the-new-shadow-it edited
idle-draft mark ai/03-shadow-agents-are-the-new-shadow-it sampled
idle-draft status