Tasks: the poller's process lifecycle becomes as idempotent as its ledger (issue-159)
Phase 3 of 3. Derived from
bugfix.md+design.md(both locked). TDD per task; each task names the ACs it delivers.
DAG
flowchart LR
T1[T1 RunLock] --> T2[T2 poll start]
T1 --> T3[T3 poll stop]
T4[T4 PollState.flush] --> T5[T5 per-item flush]
T5 --> T6[T6 cooperative shutdown]
T7[T7 dispatcher.stop returns] --> T8[T8 release_abandoned]
T8 --> T9[T9 wire the shutdown]
T2 --> T9
T6 --> T10[T10 integration tests]
T9 --> T10
T3 --> T10
T10 --> T11[T11 docs + capability docs]
T11 --> T12[T12 execution log, full check]Task list
[x] T1 —
the_loop/runlock.py: the lock primitive (AC1.3, AC1.4, AC1.5).RunLockover a pidfile:acquire/release/holder/is_held/wait_until_free, context manager,flockwith a documented pid-liveness fallback. Newcli/tests/test_runlock.py: acquire/refuse, pid recorded, stale file re-acquired, released on process death (realfork),wait_until_freeboth ways, corrupt/empty file.[x] T2 —
poll starttakes the lock (AC1.1, AC1.2). Acquire before building anything (config, providers, dependency checks, ttyd); refuse with holder pid + pidfile + remedy andpoller.blocked; hold for--oncetoo; release infinally(which is now what removes the pidfile).[x] T3 —
poll stopverifies and waits (AC2.1, AC2.2, AC2.3). Stale ⇒ do not signal, remove the pidfile, exit 1. Held ⇒SIGTERM, thenwait_until_free(--timeout); new--timeoutflag (default 30s); a holder that outlives it exits 1.[x] T4 —
PollState.flush(ref)(AC3.1). Write one dirty record and clear its dirty flag;save()keeps flushing whatever is left.[x] T5 — flush per work item (AC3.1, AC3.2, AC3.3).
_poll_providerflushes in afinallyaround_process_item, so a raising item persists the attempt it spent. Unit test: the record on disk is complete before the next item is processed.[x] T6 — cooperative shutdown inside a cycle (AC4.1, AC4.2, AC4.3).
poll_once(stop_event=None)/_poll_provider(..., stop_event); check between items and between providers, never inside one; skip_reconcile_closureswhen the listing loop was cut short;PollSummary.interrupted; the cycle log line andpoll.cyclecarry it;run()passes its own stop event down.[x] T7 —
Dispatcher.stop()reports what it abandoned (AC5.1). Drain the queues after the join and return the undelivered delivery ids; existing callers ignore the return.[x] T8 —
Poller.release_abandoned+PollStatereleases (AC5.2, AC5.3).release_comment_attempt/release_spawn_attempt(decrement, drop at zero, never touchseenComments); the poller's{delivery_id: (ref, comment_id)}map, populated when an attempt is noted and dropped when a delivery resolves.[x] T9 — wire the shutdown (AC5.2).
poll.pyfinally:poller.release_abandoned(dispatcher.stop()).[x] T10 — integration tests (Gherkin,
testing.gherkinDocstrings): secondstartrefuses and leaves the ledger byte-identical; an abandoned cycle leaves the finished item's record complete and does not re-forward it; an abandoned comment is retried next start with a full budget and is not baselined; an interrupted cycle closes no session.[x] T11 — documentation (docs-parity P1/P2 stay green).
docs/cli/commands/poll.md: the stale--state-filein the synopsis, the single-instance rule,stop --timeout, the restart contract.docs/capabilities/webhook-triggers.md: behaviour + history row.docs/cli/state.md: whatpoll.pidnow means.[x] T12 — execution log +
make check. Append the progress entries, then lint / format / typecheck / validate / test all green.