Execution Log: a comment made before the-loop start is never delivered, and nothing says so
Append-only log of progress for the user's visibility.
Phase transitions
| Phase | Entered | Reviewed/approved by | Notes |
|---|---|---|---|
| not-started | 2026-08-18 | — | ticket #270, split out of #269 § Related casualty |
| phase-selection | 2026-08-18 | — | see Deviations: the loop was run by hand in a cloud session, not by a daemon |
| requirements-definition | 2026-08-18 | pending | bugfix.md |
| design | 2026-08-18 | pending | design.md |
| test-planning | 2026-08-18 | pending | testing-plan.md |
| tasks-breakdown | 2026-08-18 | pending | tasks.md |
| implementation | 2026-08-18 | — | tasks 1–6, test-first per tdd.mode: standard |
| verification | 2026-08-18 | — | every activity in the testing plan ran; results recorded there |
| needs-review | 2026-08-18 | pending | human approval of the pull request (tier 3: human-approves-pr) |
Pull requests
| Repository | PR | Loop state | Status |
|---|---|---|---|
| MadaraUchiha-314/the-loop | #272 | outer loop only — one repository, one delivery | open, briefing posted |
Progress entries
2026-08-18 — the product decision was already made; the ticket names its two consequences
The ticket is explicitly a product decision ("a call for the owner, not for the loop"), and the owner made it in a one-word comment: Option-3 — the session re-reads the thread, so the content of a pre-start comment is not lost; only the delivery accounting is wrong. Option 3's own text carries the two things that then have to happen: "it should be written down, and commentAttempts should stop implying a pending retry." This work item is those two, and nothing else — no replay (option 1), no durable refusal marker (option 2).
2026-08-18 — reading the accounting turned up a worse tail than the ticket describes
The ticket says the comment "stays at commentAttempts: 1 forever". True while the daemon lives. Two things happen after that, and both are worse:
- The dedup mark is a process-local LRU. On a restart — or after
dedupCacheSizedeliveries —delivery_statusflips frominflighttounhandled, the poller spends attempts 2 and 3, and emitspoll.comment_failedat error level plus a comment on the ticket telling the human their comment never reached the session after three attempts. Nothing was attempted; nothing failed. - That give-up is written into
gaveUpwith the CLI version, andrearm_gave_up_comments(issue-146) un-resolves anything a different version abandoned. So the first upgrade after the false give-up re-forwards the comment — delivered late if the item has been started by then. Today's behaviour is not "never replayed"; it is replay-on-upgrade, which is option 1's semantics arrived at by accident on a schedule nobody chose.
Recorded because it changes what "stop implying a pending retry" has to mean: the comment must be resolved, and resolved as baselined, not as given up.
2026-08-18 — the fix, red→green, task by task
Written test-first: the 17 tests in evidence/red.md all failed before any production code changed.
| Task | Red → green | What landed |
|---|---|---|
1 Deduper | test_router_deduper_remembers_a_delivery_outcome | the LRU's value is now the delivery's outcome (add(id, outcome=""), mark_settled, outcome) instead of None. One entry, one bound, one eviction |
| 2 the five sites | 8 tests in test_routing.py | _settle() at _on_unmatched (membership-gated on SETTLED_SUPPRESSED), the all-paused match in handle, _dispatch_one's pre-dispatch pause, _apply_control, _reject_control, control.ambiguous; delivery_status → settled (after done), plus delivery_outcome |
| 3 catalogue | test_eventlog.py parity | poll.comment_settled, and dispatch.dropped / the control.* entries now say a suppressed or consumed delivery is reported to the poll path as settled |
| 4 the poller | 5 tests in test_poller.py | _settle_comment (baseline, no gave_up, no notice, one event), the two branches in _process_comment, the settled branch in _try_spawn, and PollState's docstring on what commentAttempts counts |
| 5 the repro | 2 Gherkin scenarios in test_poller_integration.py | the ticket's reproduction across three cycles and a restart, and the upgrade that now re-arms nothing |
| 6 write it down | test_the_spawn_prompt_tells_the_session_to_read_the_whole_thread + make lint | the capability doc, docs/cli/state.md, polling-options.md, reference/observability.md, decision-097 — and the sentence in both spawn-prompt copies that makes option 3 true rather than merely stated |
Two decisions taken during implementation, both recorded in design.md §Trade-offs and decision-097:
- The three control outcomes settle too (D7).
_apply_controland_reject_controlkeep their delivery ids exactly asawaiting-startdoes, so athe-loop stopbefore any start was stuck in the ledger identically — and its restart tail is worse than a false notice: the poller re-forwards the comment and the-loop executes the command again. Forcleanup, that releases local resources twice. session-occupiedwas left alone (D8). It looks like the same bug, but its stuck entry is what lets a redelivery succeed after the operator kills the stale tmux session. Baselining it would remove a recovery path to fix a cosmetic one.
Capability docs
docs/capabilities/webhook-triggers.md— a new behaviour bullet ("an event refused on purpose is never replayed, and never counted as a pending delivery"),delivery_status's new answer in the poll-retry bullet, and anissue-270history row.
Documentation
docs/cli/state.md—commentAttemptscounts only deliveries that may still be retried; a refused or consumed comment is baselined instead.docs/config/cli/polling-options.md—maxRetriescounts only deliveries that could still succeed.skills/the-loop/reference/observability.md— the questionpoll.comment_settledanswers, next topoll.comment_failed.skills/the-loop/templates/webhook-autoexecute-prompt.mdandDEFAULT_SPAWN_TEMPLATE— the spawned session is told to read the item's whole thread, including what was posted before the start. This is the user-facing half of the fix, not a doc chore: without it, "the content is not lost" rested on nothing.docs/decisions/decision-097.mdand the index.
2026-08-18 — rebased onto main; the decision is 097, not 096
The owner asked for a rebase and a decision renumber on the pull request. main had moved by two commits — issue-248 (a repository may bring its own hooks to the process graph) and the 11.1.0 bump — and issue-248 took decision-096. So this work item's decision is now decision-097: the file was renamed, its heading renumbered, its eleven references updated (design.md, tasks.md, this log, evidence/security-review.md, the capability doc's history row) and the index rebuilt from main's so both rows stand. Nothing about the decision's content changed.
Two conflicts, both in that renumbering: docs/decisions/decision-096.md (add/add — resolved by restoring main's file verbatim and writing this one to 097) and decisions.md (the index row).
The uv.lock drift survived the rebase, because main has it too: the 11.0.1 → 11.1.0 bump commit did not refresh the lock either, so the first uv run in any checkout rewrites two lines — the workspace package's version, and a python_full_version < '3.11' marker uv now resolves for exceptiongroup's own dependency. Carried rather than reverted, so the branch is not left with a lockfile that disagrees with pyproject.toml; it is not part of this change.
Re-verified on the rebased tree rather than assumed: the full suite, ruff, ruff format, pyright, markdownlint (818 files) and validate_config.py all clean. The suite is 2476 passed, 1 skipped — main's own count rose with issue-248; this work item's 18 tests are unchanged.
Reviews
reviews.selfReviewCount: 3, stopOnNoNewFindings: true. Five rounds ran rather than three, because round 3 was still finding things: round 4 was the last with findings and round 5 — a re-read of the whole final diff — found nothing new, which is where the loop stops.
| # | Type | Focus | Findings | Resolution |
|---|---|---|---|---|
| 1 | self | the production diff, site by site | 2. SETTLED_OUTCOMES was defined and never read — a constant nobody consults is a vocabulary nobody enforces. And _settle_comment's log line said "the session reads the thread itself when it starts", which is false for a control-executed settlement (nothing is waiting to start) | the constant is now pinned by test_the_settled_vocabulary_is_exactly_the_five_documented_outcomes, which also checks each outcome appears in the event catalogue's description; the log line now reads "was not delivered as an event… nothing is replayed, and a session reads the thread itself" |
| 2 | self | the paths not changed, hunting for a lockout | 0 changes, 3 risks checked and cleared. (a) _try_spawn's settled branch calls reset_spawn, which clears spawn = {} — including deliveryId — so the next cycle does not read the settled id again and refuse to ever spawn. (b) A start that spawns never reaches _apply_control's settle: it returns through _on_unmatched, and the spawned session records the delivery itself (done). (c) mark_settled moves the id to the end of the LRU, so webhook duplicate suppression gets marginally stronger, never weaker | nothing to fix; recorded here so the next reader does not have to re-derive them |
| 3 | self | the spec chain against what actually landed | 2. design.md §D listed docs/capabilities/observability.md as a doc to update — that file enumerates no event types, and the change belongs in skills/the-loop/reference/observability.md; and it called _settle_comment "four lines" when it is ten | both corrected in design.md, and tasks.md task 6 now names the files that actually changed (including polling-options.md) |
| 4 | self | the new tests | 2. the integration scenario's Gherkin promised "the comment is refused once (dispatch.dropped / awaiting-start)" and asserted no such thing; and its three cycles were asserted in two mismatched statements | the scenario now reads the event log and asserts exactly one dispatch.dropped/awaiting-start, exactly one poll.comment_settled (outcome, comment id, will_retry: false) and no poll.comment_failed; the three cycles are one list comparison |
| 5 | self | the final diff, whole | 0 new findings — stopOnNoNewFindings | — |
No finding repeated across rounds, so nothing escalated under escalateOnRepeatFinding.
Deviations from the standard gates
- The loop was walked by hand. This is a Claude Code cloud session in the-loop's own repository, where the plugin's SessionStart hook does not fire and no daemon drives the graph (the gap
CLAUDE.mdexists to cover). The spec chain, the phase labels and this log were produced by followingskills/the-loop/SKILL.mddirectly. Nophase-selectionchecklist was posted and nothe-loop executewas signed, because there was no daemon to post one — the artifacts stand in for the gate, and the human approval is the pull request review. - The critic rounds could not be run as specified.
reviews.criticReviewCount: 3asks for three rounds from a different harness/model, andreviews.criticsis empty in this repository's config, so there is no critic to invoke (the-loop critic runhas nothing to run). Three self-review rounds ran instead and are recorded above with what each found; the missing rounds are stated here rather than reported as done. - The security review used the checklist, not the skill.
security.review.mechanismisauto, which prefers a built-in security-review skill; the checklist is its shipped fallback and is what ran here, recorded as what it was.
Security review
- [x] Checklist (
reference/security.md), effective risk tier 3 → no named human security sign-off required (humanSignOffMinTier: 4); pass with no findings, one residual risk recorded —evidence/security-review.md.