Execution Log: one the-loop start for every service the config enables
Append-only log for issue-228. Ticket: #228.
How this session ran the loop
One cloud session, one pass — the posture of issue-208 through issue-224, with the same two consequences a reviewer should hold:
phase-selectionwas not run as a gate. The session was started from the ticket; there was nobody to tick the checklist. Phases assumed: full spec chain, implementation, verification, self-review.brainstormingnot taken (the ticket enumerates the wanted commands itself); the opt-indesign-critic-reviewnot taken (no second model available to this session).- The chain was authored before the code, but approved by nobody. All four artifacts are
status: draft— a proposal to ratify with the PR. Risk tier 3 (seerequirements.md§Risk tier) ⇒ human approves the PR; no separate named security sign-off.
Mid-session the operator asked (chat) for a rebase onto the freshly merged main (issue-225, v9.15.0) before implementation; done — recorded here as the paper trail for that instruction.
Phase transitions
| Phase | Entered | Reviewed/approved by | Notes |
|---|---|---|---|
| phase-selection | 2026-08-14 | — | Not run as a gate; see above |
| requirements-definition | 2026-08-14 | requirements.md — 5 requirements, 5 NFRs, security §, risk tier 3 | |
| design | 2026-08-14 | design.md — 5 design points, decision-084 | |
| test-planning | 2026-08-14 | testing-plan.md — 12 activities, 5 n/a rows with reasons | |
| tasks-breakdown | 2026-08-14 | tasks.md — 12 tasks | |
| implementation | 2026-08-14 | 12 tasks; ~60 files (4 new modules, 1 deleted command, both schema copies, 30+ documents) | |
| verification | 2026-08-14 | Testing plan executed; see evidence/verification.md | |
| needs-review | 2026-08-14 | Handed to the PR |
Pull requests
| PR | Scope / tasks | Status |
|---|---|---|
claude/github-issue-228-weq41j | the whole work item | open, awaiting human approval |
Progress entries
2026-08-14 — orientation
Read the ticket, CLAUDE.md, the skill, the harness config, and the whole current lifecycle surface: commands/poll.py (629 lines — parser, run loop, daemonize, status, stop), commands/gh_webhook.py, commands/service_cmd.py, api/serve.py + api/app.py + api/mcp.py, core/daemons.py, daemon_entry.py, client auto-start, both CLI-config schema copies. Three findings shaped the design:
- No
enabledexists anywhere in the CLI config — the flags the ticket assumes have to be added, and their defaults argued (design D1). - The poller's only implementation lives inside the command being removed —
daemon_entryliterally re-parsespoll start's parser. Extraction before deletion. - A service cannot synchronously restart itself over its own API — hence the scheduled-restart contract (design D5).
2026-08-14 — building it
Order: the four enabled flags (schema copies + template + service_config), the MCP mount flag, then the poller extraction, then the composition layer, then the API route, then tests and documents. Four things came out of doing it rather than planning it:
- The decision number collided. The rebase the operator asked for brought in issue-225, which had already minted decision-083; this work item's record was renumbered to decision-084 and issue-225's file restored untouched. The kind of conflict two same-day cloud sessions produce — caught because the Write reported an update where a create was expected.
- The receiver's pidfile was not a lock.
gh-webhook startwrote a plain pid file, whilecore.daemons.daemon_status— and nowthe-loop start's honest-start wait — answer liveness from the pidfile's flock. A foreground receiver therefore read as not running on the daemons API before this change. The receiver now takes aRunLockexactly as the poller does (and itsstopbecame verified and blocking), which is in scope becausestartcannot prove a receiver came up without it. daemonize()died with its only caller. The issue-191 double-fork existed forpoll start --daemon; every remaining detached start isPopen(start_new_session=True)with the logfile on fds 1/2, andstartproves liveness by waiting for the lock instead of the pipe handshake.open_logfilesurvives (both spawn paths use it).- The heartbeat detail had to travel.
poll status's JSON carried the last cycle's counters;daemon_statusdoes not.status_allreads the heartbeat into the poller row (lastCycle,intervalSeconds) so removing the command lost no fact (R2.4).
2026-08-14 — re-pointing the tests
The poll-command suites were re-pointed, not deleted (testing-plan §2): every scenario keeps its Gherkin and asserts through the new entry points — daemon_entry poller [--once] for the run-loop properties (ttyd parity, the single-instance lock, crash recovery), the-loop start for the detach properties (own session, outliving the starter's process group, honest failure reporting, stale pidfile cleanup), the-loop status for the liveness-is-the-lock invariants (forged heartbeat, stale pidfile, heartbeat-as-enrichment). The fork-specific scenarios (zombie reaping, --daemon --once contradiction) died with the mechanism. Two new files (test_core_lifecycle.py, test_lifecycle_cmd.py) cover composition; the restart route and the MCP flag landed in the existing integration suites; the OpenAPI contract gained /api/v1/restart (parity-tested).
2026-08-14 — self-review
Three rounds over the full diff, findings fixed in place:
- Round 1 — the collision and the tables. The decision-083 clobbering (above), and markdownlint catching unescaped
|inside code spans in the three new capability history rows. - Round 2 — what a save must report.
service.mcpis boot-time (the mount happens increate_app), so it was added tocore.config.RESTART_REQUIRED— without it the issue-222 editor would claim a saved MCP toggle was live. - Round 3 — the stragglers. A final
the-loop pollsweep caught four code docstrings still naming the removed command;_print_rowson an empty list; the test race wherestopreturns at lock-release, a moment before the process finishes dying.
No round produced a repeated finding, so nothing escalated.
2026-08-14 — owner review on PR #229 (the paper trail)
Two instructions arrived as a PR comment from the owner, both applied in a second commit on the same branch:
- "Why is there a need for this? It should all fold into
the-loop start, right?" — thegh-webhookandservicecommands are removed after all (R5.1 superseded; decision-084 §7). The receiver's run loop relocated towebhook/daemon.pyexactly as the poller's had (options from config, lock, serve;daemon_entry gh-webhookis the foreground form),service_cmd's remains were already incore.lifecycle, and with the last import-time config cache gone,cli.py's_refresh_cli_config_pathsshim was deleted too. Their doc pages moved out of the command namespace to/cli/serviceand/cli/receiver— the MCP-connect and receiver-guard content is operator documentation about the services, which outlived the commands. - "why? do it." — the dashboard restart shipped now (R4.6):
restart()on the API client (live and demo transports), a Service card on Settings (POST /api/v1/restart, with the upgrade as a checkbox, rendering the response as a schedule), and a "Restart now" follow-through in the config editor whenever a save reportsrestartRequiredkeys. Two new UI tests; 91 pass.
2026-08-15 — owner review round 2: single-process mode (issue-231)
The owner asked (PR #229 comment) whether the poller now runs inside the FastAPI service; the honest answer was no — three processes, only MCP in-service — and the follow-up ticket for folding them became issue-231, filed by the owner. The owner then blocked the merge on it: "merging this PR will cause a regression. Can we implement this … so that all functionality will remain?" — so single-process mode landed in this PR as a third commit (R6, design D6, decision-084 §8):
service.hostIngresses(default true):the-loop startboots one process, the service, whose lifespan runs the enabled poller and receiver as threads — each still acquiring its own pidfile flock, now under the service's pid.- Hosted-ness is detected from the lock (holder pid == service pid), never recorded in a file; contention with a standalone daemon is a skip-with-warning; an enabled poller with no sources refuses to host while the API keeps serving;
stopstops the one process and reports hosted rows only once their locks free. - Enablers:
poller.daemongainedstop_event/install_signal_handlers(signal handlers are main-thread-only),webhook.daemonsplit intobuild_receiver+_serve,api/ingress.pyis the hosting glue, andcore.lifecycleawaits locks instead of spawning when hosting. - Verified as T14: 5 new unit cases (15 total in
test_core_lifecycle.py), a new real-process integration file (one pid holds all three locks; receiver answers on its port;statussays hosted;stopreleases everything; a standalone lock is not fought over), full suite 2041 passed / 1 skipped.
Capability docs
docs/capabilities/cli.md— the lifecycle-surface behaviour rewritten aroundstart|stop|status|restart(the oldpoll start --daemon/poll statusclauses replaced by the honest-start and unified-status rules); history row added.docs/capabilities/control-plane.md— the restart endpoint, its MCP exclusion, the disableable/mcp, and the local-by-nature command list updated; history row added.docs/capabilities/webhook-triggers.md— the poller-lifecycle clauses re-pointed at the new surface (the double-fork clause replaced by the lock-wait rule); history row added.docs/capabilities/interactive-sessions.md— two ingress-naming clauses reworded; no behaviour change.- All three issue-228 history rows amended for the PR #229 review round (the fold and the dashboard restart), and again for round 2 (single-process mode, issue-231) — with new SHALL clauses in
cli.md(one process by default),control-plane.md(the service as the default host process) andwebhook-triggers.md(both ingresses hosted by default).
Documentation
docs/cli/commands/—poll.mddeleted;start.md,stop.md,status.md,restart.mdadded;index.md,gh-webhook.md,migrate-config.mdre-pointed; sidebar (docs/.vitepress/config.mts) updated.docs/config/cli/—enableddocumented on the polling/webhook pages,enabled+mcp.enabledon the service page;index.md,routing-options.mdre-pointed.docs/cli/getting-started.md,installation.md,state.md,index.md— quickstart and state pages follow the new surface (daemon_entry poller [--once]as the cron/systemd form).docs/api-specs/openapi/the-loop.v1.yaml—/api/v1/restart+RestartBody.README.mdandcli/README.md— the front-page command examples.skills/the-loop/templates/cli-config.yaml— the four new keys, commented.skills/the-loop/reference/observability.md— process naming only.docs/decisions/decision-084.md+ index row.skills/the-loop/SKILL.mdandreference/automation.mdneeded no change: neither names the poll commands.- Review round 2 (issue-231):
service.hostIngressesdocumented ondocs/config/cli/service-options.md(and pointed at from the polling/webhookenabledsections), the one-process-by-default sections onstart.md, hosted rows onstop.md/status.md, hosting paragraphs ondocs/cli/service.mdanddocs/cli/receiver.md, decision-084 §8, requirements R6 and design D6.