code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/dead_end.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""The driver's dead-end predicate (#2019).
|
|
2
|
+
|
|
3
|
+
``coord drive`` could not tell **"still working"** apart from **"finished in
|
|
4
|
+
a state I cannot act on."** Both rendered as ``no state change in N m`` and
|
|
5
|
+
the second one looped forever — a counter incrementing against an event that
|
|
6
|
+
can never occur, holding a tmux session, a queue slot and (since #1972) an
|
|
7
|
+
entire repo's capacity lane. The live case this module is named for burned
|
|
8
|
+
**140 minutes** on claude-coordinator#1956 with ``review=done/-`` on screen
|
|
9
|
+
the whole time.
|
|
10
|
+
|
|
11
|
+
WHAT THIS MODULE IS
|
|
12
|
+
-------------------
|
|
13
|
+
One pure function, :func:`detect_dead_end`, over the driver's already-computed
|
|
14
|
+
:class:`coord.drive_state.IssueState`. It answers a single question:
|
|
15
|
+
|
|
16
|
+
Is this row **terminal AND unactionable** — i.e. is there no board
|
|
17
|
+
transition left that any amount of polling could produce?
|
|
18
|
+
|
|
19
|
+
It is **not** a stall detector. Per #2019 ask 4, elapsed time is deliberately
|
|
20
|
+
NOT an input: a healthy long-running Work stage is legitimately quiet for
|
|
21
|
+
hours, and a dead end is knowable at minute zero. The only clock-shaped
|
|
22
|
+
guard here is the hard precondition ``active_count == 0`` — if anything at all
|
|
23
|
+
is running on the fleet, this function returns ``None``, however long it has
|
|
24
|
+
been running. That guard is what makes a false positive on a healthy stage
|
|
25
|
+
structurally impossible rather than merely unlikely.
|
|
26
|
+
|
|
27
|
+
WHY THE 140 MINUTES HAPPENED (the field-mismatch bug)
|
|
28
|
+
-----------------------------------------------------
|
|
29
|
+
``coord.drive._decide_review`` has had a ``_die`` for "review finished with no
|
|
30
|
+
verdict" since the original bash port — but it keys on
|
|
31
|
+
``state.work_review_state``, the **work** row's projected ``review_state``.
|
|
32
|
+
The incident's board showed ``review=done/-``, which is
|
|
33
|
+
``state.review_status`` — the **review assignment's own** status. On #1956
|
|
34
|
+
the review row reached ``status="done"`` while the work row's ``review_state``
|
|
35
|
+
was never advanced (advancing it is exactly what recording the verdict does),
|
|
36
|
+
so the die never fired and ``_decide_review`` fell through to a bare
|
|
37
|
+
``_wait()``. Two readings of "the review is done", one checked, one not.
|
|
38
|
+
:data:`_TERMINAL_REVIEW_STATUSES` closes that by keying on the review row.
|
|
39
|
+
|
|
40
|
+
WHAT IS DELIBERATELY *NOT* COVERED
|
|
41
|
+
----------------------------------
|
|
42
|
+
* **A Test stage that was never dispatched, with no policy saying so**
|
|
43
|
+
(``test_state=""``, no smoke row, no ``test-mode:*`` label) — from board
|
|
44
|
+
state alone that is indistinguishable from "the daemon will dispatch it on
|
|
45
|
+
the next tick", and the rest of the dispatcher's refusal set
|
|
46
|
+
(``coord.smoke.dispatch_smoke`` returns ``None`` for a superseded row, a
|
|
47
|
+
capability-rule miss on a non-``work`` type, a missing smoke command, …) is
|
|
48
|
+
not re-derivable here without duplicating it. Guessing would escalate
|
|
49
|
+
healthy rows. Two variants ARE covered, both because something on the board
|
|
50
|
+
positively states that no dispatch is coming: ``test_state="blocked"``, the
|
|
51
|
+
marker the dispatcher leaves when it gives up (#1672, shape 2), and
|
|
52
|
+
``test-mode:smoke``, the per-issue POLICY that switches the headless Test
|
|
53
|
+
stage off entirely (#685/#2024, shape 3).
|
|
54
|
+
* **A ``done`` smoke row whose verdict has not landed yet** — #1605 already
|
|
55
|
+
established that this has an expected, bounded propagation lag and is not a
|
|
56
|
+
defect. Only the contradictions #1605 itself detects are terminal.
|
|
57
|
+
* **A zero-commit advisory / a refused pre-dispatch guard** — already
|
|
58
|
+
escalated correctly by ``coord.drive._decide_advisory``, the "finished with
|
|
59
|
+
no branch" die, and #1844's ``EXIT_DISPATCH_REFUSED``. vimcode#634 proved
|
|
60
|
+
that path works; this module deliberately does not touch it.
|
|
61
|
+
|
|
62
|
+
The registry below is meant to grow one *proven* shape at a time. A shape
|
|
63
|
+
belongs here only when the board state alone makes the row unactionable — not
|
|
64
|
+
when it merely looks quiet.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
from dataclasses import dataclass
|
|
70
|
+
|
|
71
|
+
# A review assignment that reached one of these is FINISHED — no worker is
|
|
72
|
+
# coming back to add a verdict. `"failed"` is deliberately absent: #1584
|
|
73
|
+
# owns it with a bounded `coord review` re-dispatch, and a dead-end verdict
|
|
74
|
+
# there would steal a retry that genuinely can succeed.
|
|
75
|
+
_TERMINAL_REVIEW_STATUSES = frozenset({"done", "cancelled"})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class DeadEnd:
|
|
80
|
+
"""One terminal-and-unactionable board shape, fully described.
|
|
81
|
+
|
|
82
|
+
``reason`` names the SPECIFIC dead end (#2019 ask 3) — "review reached
|
|
83
|
+
done with no verdict" rather than "no state change in 140.558m" — and
|
|
84
|
+
``recovery`` is the command an operator can paste. Both end up on the
|
|
85
|
+
``coord escalate record`` row, in the drive-queue entry's ``last_reason``,
|
|
86
|
+
and in the GitHub escalation comment, so all three surfaces say the same
|
|
87
|
+
thing.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
kind: str
|
|
91
|
+
"""Stable slug for the shape, e.g. ``review_terminal_no_verdict``."""
|
|
92
|
+
|
|
93
|
+
stage: str
|
|
94
|
+
"""Pipeline stage the dead end sits in — ``coord escalate record --stage``."""
|
|
95
|
+
|
|
96
|
+
reason: str
|
|
97
|
+
"""Why no poll can change this. One paragraph, operator-facing."""
|
|
98
|
+
|
|
99
|
+
recovery: str
|
|
100
|
+
"""The command that unblocks it, ready to paste."""
|
|
101
|
+
|
|
102
|
+
assignment_id: str = ""
|
|
103
|
+
"""The row an operator should look at first (``""`` when unknown)."""
|
|
104
|
+
|
|
105
|
+
gates: tuple[tuple[str, str], ...] = ()
|
|
106
|
+
"""Observed gate readings, ``(key, value)`` — mirrors ``_escalate_merge``."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _gates(state) -> tuple[tuple[str, str], ...]:
|
|
110
|
+
"""The board readings worth recording on any dead end.
|
|
111
|
+
|
|
112
|
+
Same shape as ``coord.drive._escalate_merge``'s ``gate_pairs`` so the two
|
|
113
|
+
escalation kinds render identically in ``coord escalate list``.
|
|
114
|
+
"""
|
|
115
|
+
return (
|
|
116
|
+
("work_status", state.work_status or "(empty)"),
|
|
117
|
+
("test_state", state.work_test_state or "(none)"),
|
|
118
|
+
# #2024: the per-issue Test-stage POLICY is a reading in its own right
|
|
119
|
+
# — for shape 3 it is the entire proof, and on every other shape it is
|
|
120
|
+
# the difference between "no verdict yet" and "no verdict, ever".
|
|
121
|
+
("issue_test_mode", state.issue_test_mode or "(none)"),
|
|
122
|
+
("review_status", state.review_status or "(none)"),
|
|
123
|
+
("review_verdict", state.review_verdict or "(none)"),
|
|
124
|
+
("active", str(state.active_count)),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def detect_dead_end(state, *, can_waive_test_gate: bool = False) -> DeadEnd | None:
|
|
129
|
+
"""The predicate: ``None`` when the row can still move on its own.
|
|
130
|
+
|
|
131
|
+
*state* is a :class:`coord.drive_state.IssueState` (untyped here purely to
|
|
132
|
+
keep this module import-light — it reads a handful of attributes and
|
|
133
|
+
nothing else).
|
|
134
|
+
|
|
135
|
+
*can_waive_test_gate* (#2024) is the caller saying "I still have a Test-
|
|
136
|
+
stage move of my own" — today that is ``coord drive --skip-test``, whose
|
|
137
|
+
``_decide_test`` records ``skipped`` for a verdict-less row. Shape 3 below
|
|
138
|
+
is exactly the shape that flag exists to unblock, so with it set that shape
|
|
139
|
+
is not a dead end: the driver must be allowed to use the move the operator
|
|
140
|
+
explicitly asked for rather than escalate past it.
|
|
141
|
+
|
|
142
|
+
Pure, cheap, and safe to call on every poll: no I/O, no clock, no
|
|
143
|
+
counters. The caller (``coord.drive.decide``) turns a non-``None`` result
|
|
144
|
+
into an ``EXIT`` action carrying ``coord.drive.EXIT_DEAD_END``.
|
|
145
|
+
"""
|
|
146
|
+
# #1672's "the fleet cannot route this Test stage" marker. IMPORTED (not
|
|
147
|
+
# mirrored as a literal) so the two can never drift: `dispatch_smoke`
|
|
148
|
+
# bails out on this exact value, and its refusal to re-probe is precisely
|
|
149
|
+
# why a driver polling against it can never make progress. Deferred to
|
|
150
|
+
# call time purely to keep THIS module importable on its own without
|
|
151
|
+
# dragging in `coord.smoke`'s `httpx` dependency — `coord.drive` already
|
|
152
|
+
# pulls httpx in via `coord.usage_limits`, so nothing is saved there, but
|
|
153
|
+
# a predicate this small should stay cheap to import and test in
|
|
154
|
+
# isolation.
|
|
155
|
+
from coord.smoke import TEST_STATE_BLOCKED # noqa: PLC0415
|
|
156
|
+
# THE conservative guard, and it stays first. #2019 acceptance: "a
|
|
157
|
+
# genuinely long-running work stage (active=1) does NOT escalate, however
|
|
158
|
+
# long it runs." Anything in flight — work, test, review, a fix round,
|
|
159
|
+
# the acceptance author — makes every question below premature.
|
|
160
|
+
if state.active_count > 0:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
# ── shape 1: the review row is terminal and carries no verdict ──────────
|
|
164
|
+
# claude-coordinator#1956, 2026-08-08. The driver can neither dispatch a
|
|
165
|
+
# fix (there is no `request-changes` to fix) nor proceed to merge (there
|
|
166
|
+
# is no `approve` to merge on), and the row that would have supplied
|
|
167
|
+
# either is finished. Nothing about waiting changes that.
|
|
168
|
+
if (
|
|
169
|
+
state.review_aid
|
|
170
|
+
and state.review_status in _TERMINAL_REVIEW_STATUSES
|
|
171
|
+
and not state.review_verdict
|
|
172
|
+
# Belt-and-braces: an actionable failed test outranks a stale review
|
|
173
|
+
# on the same work row — let `_decide_test`'s bounded fix loop have
|
|
174
|
+
# it rather than dead-ending a row that still has a live move.
|
|
175
|
+
and state.work_test_state != "failed"
|
|
176
|
+
):
|
|
177
|
+
if state.review_status == "cancelled":
|
|
178
|
+
return DeadEnd(
|
|
179
|
+
kind="review_cancelled_no_verdict",
|
|
180
|
+
stage="review",
|
|
181
|
+
reason=(
|
|
182
|
+
f"review {state.review_aid} was CANCELLED before recording "
|
|
183
|
+
"a verdict — no verdict is coming, and this driver has "
|
|
184
|
+
"nothing to approve, fix, or merge on. Terminal on the "
|
|
185
|
+
"board; polling cannot change it (#2019)."
|
|
186
|
+
),
|
|
187
|
+
recovery=f"coord review {state.work_aid}",
|
|
188
|
+
assignment_id=state.review_aid,
|
|
189
|
+
gates=_gates(state),
|
|
190
|
+
)
|
|
191
|
+
# status == "done": the session finalised CLEANLY. Saying so matters
|
|
192
|
+
# — #2019 acceptance requires this text to distinguish an
|
|
193
|
+
# END_REVIEW-without-verdict from a crashed session, and to stop
|
|
194
|
+
# citing the CLOSED #812 (which was about INTERACTIVE reviews that
|
|
195
|
+
# never started) at a headless review that ran to completion. A
|
|
196
|
+
# review worker that actually died lands `status="failed"`, which is
|
|
197
|
+
# `_decide_review`'s #1584 bounded-retry arm, not this one.
|
|
198
|
+
#
|
|
199
|
+
# #812 is deliberately absent from the text below — even as a
|
|
200
|
+
# disclaimer. An operator who greps the reason for an issue number
|
|
201
|
+
# to open must never land on a closed one, and "not #812" reads as
|
|
202
|
+
# "#812" to every mechanical reader (and to a tired one at 2am). The
|
|
203
|
+
# correction lives here, in the source, where it belongs.
|
|
204
|
+
return DeadEnd(
|
|
205
|
+
kind="review_terminal_no_verdict",
|
|
206
|
+
stage="review",
|
|
207
|
+
reason=(
|
|
208
|
+
f"review {state.review_aid} reached status=done carrying NO "
|
|
209
|
+
"verdict. The review session finalised cleanly — a crashed, "
|
|
210
|
+
"killed or never-started session lands status='failed' (which "
|
|
211
|
+
"this driver retries), so this is the END_REVIEW-without-"
|
|
212
|
+
"verdict class: the reviewer finished and its REVIEW_VERDICT "
|
|
213
|
+
"header was never emitted or never parsed (#1956, "
|
|
214
|
+
"coord.review.detect_end_review_without_verdict). The verdict "
|
|
215
|
+
"is very likely already sitting in the transcript. With no "
|
|
216
|
+
"verdict there is no fix to dispatch and no approval to merge "
|
|
217
|
+
"on, and the review row is terminal — no number of polls "
|
|
218
|
+
"changes that (#2019)."
|
|
219
|
+
),
|
|
220
|
+
# Straight out of docs/OPERATING_GOTCHAS.md's "Recovery — do NOT
|
|
221
|
+
# re-dispatch" block: re-running the review costs a full cycle to
|
|
222
|
+
# re-derive a conclusion already in the log, and the drop
|
|
223
|
+
# reproduces at a documented ~14% rate (#873).
|
|
224
|
+
recovery=(
|
|
225
|
+
f"coord report-result --assignment {state.review_aid} "
|
|
226
|
+
"--status done --verdict <approve|request-changes> "
|
|
227
|
+
"--verdict-source recovered --verdict-reason 'REVIEW_VERDICT "
|
|
228
|
+
"header missing, recovered from transcript (#1956)' "
|
|
229
|
+
"--body-file <extracted-review.md>"
|
|
230
|
+
),
|
|
231
|
+
assignment_id=state.review_aid,
|
|
232
|
+
gates=_gates(state),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# ── shape 2: the Test stage is BLOCKED (#1672) ─────────────────────────
|
|
236
|
+
# `dispatch_smoke` stamps `test_state="blocked"` when no capability-
|
|
237
|
+
# matched machine could run the suite, and then REFUSES to re-probe on
|
|
238
|
+
# every tick — deliberately, that spin is what #1672 closed. So the
|
|
239
|
+
# driver polling for a verdict is polling for something the dispatcher
|
|
240
|
+
# has already decided not to produce. vimcode#635's shape, in the one
|
|
241
|
+
# variant the board makes provable.
|
|
242
|
+
if state.work_test_state == TEST_STATE_BLOCKED:
|
|
243
|
+
return DeadEnd(
|
|
244
|
+
kind="test_stage_blocked",
|
|
245
|
+
stage="test",
|
|
246
|
+
reason=(
|
|
247
|
+
f"the Test stage for {state.work_aid} is BLOCKED: "
|
|
248
|
+
f"{state.work_test_reason or 'no reason recorded'} — "
|
|
249
|
+
"coord.smoke.dispatch_smoke found no capability-matched "
|
|
250
|
+
"machine and recorded 'blocked' rather than re-probing a "
|
|
251
|
+
"broken fleet on every tick (#1672). It will not try again "
|
|
252
|
+
"on its own, so waiting for a verdict here waits forever "
|
|
253
|
+
"(#2019). Fix the fleet (or the capability rules), then "
|
|
254
|
+
"clear the marker."
|
|
255
|
+
),
|
|
256
|
+
recovery=(
|
|
257
|
+
f"coord diagnose {state.repo} {state.issue} --stage test --reset"
|
|
258
|
+
),
|
|
259
|
+
assignment_id=state.work_aid,
|
|
260
|
+
gates=_gates(state),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# ── shape 3: the Test stage is HUMAN-ATTENDED by policy (#685/#2024) ────
|
|
264
|
+
# `test-mode:smoke` means "the headless Test stage does not run for this
|
|
265
|
+
# issue; the TUI offers an interactive smoke agent instead" —
|
|
266
|
+
# `dispatch_pending_smoke` skips the row unconditionally, on every tick,
|
|
267
|
+
# forever. Review dispatch is meanwhile gated on a passed/skipped verdict
|
|
268
|
+
# for THIS work row (`pipeline.test_precedes_review()`, honoured by both
|
|
269
|
+
# `dispatch_pending_reviews` and `auto_loop.run_for_fix_transition`), so a
|
|
270
|
+
# completed row with no verdict of its own cannot advance: one component
|
|
271
|
+
# requires a verdict and, by policy, no component will produce one.
|
|
272
|
+
#
|
|
273
|
+
# Why this bites `--fix-of` rounds specifically (#2024): a fix round is a
|
|
274
|
+
# NEW work row on the SAME branch, and it carries its own empty
|
|
275
|
+
# `test_state`. The parent's verdict satisfies the branch-scoped MERGE
|
|
276
|
+
# gate (`coord gates` reads `test: passed` off it — see the note
|
|
277
|
+
# `coord.gates.build_gate_report` now emits), which is why the stall reads
|
|
278
|
+
# as "slow" rather than "blocked". Round 0 gets attended because a human is
|
|
279
|
+
# watching the first pass; rounds 1..N complete unattended at 3am and sit
|
|
280
|
+
# there. Observed twice on JDonaghy/vimcode#635 (2026-08-08): 25 minutes,
|
|
281
|
+
# then 160 minutes on the same issue, each cleared within minutes of an
|
|
282
|
+
# operator running `coord test <fix_aid> --passed` by hand.
|
|
283
|
+
#
|
|
284
|
+
# Deliberately narrow, so it can never fire on a healthy row:
|
|
285
|
+
# * `active_count == 0` (above) — an interactive smoke session IS a live
|
|
286
|
+
# board row, so an attended Test stage in progress never reaches here.
|
|
287
|
+
# * `smoke_aid` empty — a Test-stage child dispatched for THIS row (by a
|
|
288
|
+
# human, `--smoke-of`, or an earlier auto pass) means the stage did
|
|
289
|
+
# happen; #1605 owns a `done` smoke whose verdict hasn't landed yet.
|
|
290
|
+
# * `work_test_state` empty — not "running" (#1395's transient marker,
|
|
291
|
+
# `_decide_test` waits on it), not a terminal verdict, not "blocked"
|
|
292
|
+
# (shape 2 above owns that, and reads better).
|
|
293
|
+
if (
|
|
294
|
+
not can_waive_test_gate
|
|
295
|
+
and state.issue_test_mode == "smoke"
|
|
296
|
+
and state.work_status == "done"
|
|
297
|
+
and state.work_aid
|
|
298
|
+
and not state.work_test_state
|
|
299
|
+
and not state.smoke_aid
|
|
300
|
+
):
|
|
301
|
+
return DeadEnd(
|
|
302
|
+
kind="test_stage_human_attended",
|
|
303
|
+
stage="test",
|
|
304
|
+
reason=(
|
|
305
|
+
f"work {state.work_aid} finished with NO Test verdict, and "
|
|
306
|
+
f"{state.repo}#{state.issue} is labelled `test-mode:smoke` — "
|
|
307
|
+
"the per-issue policy (#685) that switches the HEADLESS Test "
|
|
308
|
+
"stage off: coord.smoke.dispatch_pending_smoke skips this "
|
|
309
|
+
"issue on every tick by design, so no smoke assignment is "
|
|
310
|
+
"coming. Review dispatch is meanwhile held until THIS row "
|
|
311
|
+
"carries a passed/skipped verdict "
|
|
312
|
+
"(pipeline.test_precedes_review), so nothing can advance "
|
|
313
|
+
"without a human. This is the #2024 shape: a --fix-of round "
|
|
314
|
+
"is a new work row with its own empty test_state, and the "
|
|
315
|
+
"parent's verdict satisfies only the branch-scoped merge gate "
|
|
316
|
+
"(`coord gates` reads `test: passed` off the parent row) — "
|
|
317
|
+
"which is why this reads as slow rather than blocked (#2019)."
|
|
318
|
+
),
|
|
319
|
+
# Two doors, cheapest first: record the verdict directly, or run
|
|
320
|
+
# the attended Test stage the label asked for. Both are real; the
|
|
321
|
+
# operator picks by whether the suite actually needs running.
|
|
322
|
+
recovery=(
|
|
323
|
+
f"coord test {state.work_aid} --passed # or --skipped "
|
|
324
|
+
"--reason '<why>'; to actually run it, `coord assign "
|
|
325
|
+
f"<machine> {state.repo} {state.issue} --smoke-of "
|
|
326
|
+
f"{state.work_aid} --interactive`"
|
|
327
|
+
),
|
|
328
|
+
assignment_id=state.work_aid,
|
|
329
|
+
gates=_gates(state),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return None
|
coord/deploy/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# `coord/deploy/` — the packaged systemd units (#1927)
|
|
2
|
+
|
|
3
|
+
**This directory is a byte-identical copy of the repo-root `deploy/` unit
|
|
4
|
+
files, and it is the copy that ships in the wheel.**
|
|
5
|
+
|
|
6
|
+
Edit `deploy/<name>` at the repo root — that is the reviewed source of
|
|
7
|
+
truth — then copy it here:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
cp deploy/*.service deploy/*.timer coord/deploy/
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`tests/test_packaged_deploy_units.py` fails if the two ever disagree, so the
|
|
14
|
+
copy cannot drift silently.
|
|
15
|
+
|
|
16
|
+
## Why the copy exists
|
|
17
|
+
|
|
18
|
+
`coord release verify`'s unit-drift check
|
|
19
|
+
(`coord/health/checks/unit_drift.py`, #1831) diffs each host's *installed*
|
|
20
|
+
unit under `~/.config/systemd/user/` against a reference. Until #1927 that
|
|
21
|
+
reference was `<checkout>/deploy/<name>` — a file in the host's own git
|
|
22
|
+
working copy, which nothing verifies is current.
|
|
23
|
+
|
|
24
|
+
Installed units and checkouts go stale for the same reason (nobody pulled),
|
|
25
|
+
so they go stale *together*, and the diff then reports clean — the check was
|
|
26
|
+
least reliable in exactly the #1831 case it exists to catch, and its printed
|
|
27
|
+
remedy (`cp <checkout>/deploy/... ~/.config/systemd/user/...`) cemented the
|
|
28
|
+
stale unit.
|
|
29
|
+
|
|
30
|
+
Files under `coord/` ship in the wheel, so on a pip-installed host this
|
|
31
|
+
directory is the unit set *as of the installed version*. It cannot drift
|
|
32
|
+
with the host. The `*.sh` helpers in the repo-root `deploy/` are not copied
|
|
33
|
+
here: the drift check only reads `*.service`/`*.timer`.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# coord agent — per-machine worker/dispatch server (#1570/#1671).
|
|
2
|
+
#
|
|
3
|
+
# A systemd *user* unit — the FIRST of the four coord services (every
|
|
4
|
+
# worker box runs this one; coord-serve/coord-web/coord-notify run only on
|
|
5
|
+
# the box that owns ~/.coord/coord.db). Runs `coord agent`, which accepts
|
|
6
|
+
# dispatched assignments, spawns `claude -p` workers, and answers /health
|
|
7
|
+
# with this machine's resolved tool versions (coord/prereqs.py) so
|
|
8
|
+
# `coord doctor` can see capability drift without SSHing in.
|
|
9
|
+
#
|
|
10
|
+
# `install-agent.sh` generates a unit like this one inline (heredoc) as
|
|
11
|
+
# part of the curl|bash installer flow and stays the SOURCE OF TRUTH for a
|
|
12
|
+
# fresh install — keep the two in sync by hand. This checked-in copy
|
|
13
|
+
# exists so:
|
|
14
|
+
# (a) a manual install / install from a checkout has something to `cp`
|
|
15
|
+
# (mirrors coord-serve/coord-web/coord-notify below, all `cp`-installed
|
|
16
|
+
# from this same directory), and
|
|
17
|
+
# (b) the PATH fix below is documented in the repo instead of living only
|
|
18
|
+
# inside a shell heredoc nobody reads until #1671 bites again.
|
|
19
|
+
#
|
|
20
|
+
# #1671: PATH MUST include ~/.cargo/bin (and ~/.local/bin). A systemd user
|
|
21
|
+
# unit's default PATH is minimal — it omits anything rustup/pipx/etc.
|
|
22
|
+
# installed under $HOME. Without this, the `rust` capability probe
|
|
23
|
+
# (coord/prereqs.py, literal `cargo --version`) reads "not found" even
|
|
24
|
+
# though cargo IS installed, and #1570 D's dispatch guard then refuses to
|
|
25
|
+
# route ANY tui/** work to this machine forever — a silent 30s retry loop,
|
|
26
|
+
# no smoke row at all (see #1671).
|
|
27
|
+
#
|
|
28
|
+
# Do NOT "fix" this by symlinking just the `cargo` binary onto the
|
|
29
|
+
# existing PATH instead of adding the whole directory. `cargo build` also
|
|
30
|
+
# shells out to `rustc`, which lives in the same `~/.cargo/bin` — a probe
|
|
31
|
+
# that resolves `cargo` alone reports the capability MET while
|
|
32
|
+
# `cargo build` still fails inside the worker. That is a false green,
|
|
33
|
+
# strictly worse than the honest refusal this issue started from. Also
|
|
34
|
+
# note #402: a worker's PATH is derived from *this agent's own* PATH
|
|
35
|
+
# (venv stripped) — so probe-side and worker-side resolution move
|
|
36
|
+
# together only if the fix is "widen the agent's PATH here," not "make
|
|
37
|
+
# the probe search harder while the worker still can't find the tool."
|
|
38
|
+
#
|
|
39
|
+
# #1678: the `browser` capability (Playwright acceptance suites, e.g.
|
|
40
|
+
# `coord/dashboard/webapp` -> `npm run test:e2e`) needs `node`/`npm` here
|
|
41
|
+
# too, but Node must NOT be added the way ~/.cargo/bin was. nvm installs
|
|
42
|
+
# into a version-stamped directory (~/.nvm/versions/node/vX.Y.Z/bin), so a
|
|
43
|
+
# literal entry on the PATH line below would break silently at the next
|
|
44
|
+
# `nvm install` and put the capability right back where #1678 found it.
|
|
45
|
+
# Instead `install-agent.sh` drops run-time-resolving shims (node/npm/npx ->
|
|
46
|
+
# coord-node-shim, see deploy/node-shim.sh) into ~/.local/bin, which is
|
|
47
|
+
# ALREADY on the PATH line below — so nothing here changes and a Node
|
|
48
|
+
# version bump is a no-op. Keep ~/.local/bin on the PATH for that reason.
|
|
49
|
+
#
|
|
50
|
+
# Placeholders to fill in before installing (install-agent.sh fills the
|
|
51
|
+
# equivalents in automatically from its --machine/--port flags):
|
|
52
|
+
# <MACHINE_NAME> — this machine's name in coordinator.yml (often the
|
|
53
|
+
# short hostname; `hostname -s`)
|
|
54
|
+
# <PORT> — 7433 unless this box intentionally runs more than
|
|
55
|
+
# one agent
|
|
56
|
+
#
|
|
57
|
+
# Install (manual / from a checkout — normally use install-agent.sh
|
|
58
|
+
# instead, which does not need this file):
|
|
59
|
+
# mkdir -p ~/.config/systemd/user
|
|
60
|
+
# sed -e "s/<MACHINE_NAME>/$(hostname -s)/" -e 's/<PORT>/7433/' \
|
|
61
|
+
# deploy/coord-agent.service > ~/.config/systemd/user/coord-agent.service
|
|
62
|
+
# loginctl enable-linger "$USER" # survive logout / reboot
|
|
63
|
+
# systemctl --user daemon-reload
|
|
64
|
+
# systemctl --user enable --now coord-agent
|
|
65
|
+
#
|
|
66
|
+
# Already have a coord-agent unit without this PATH entry (e.g. installed
|
|
67
|
+
# before #1671)? A systemd drop-in is the least-disruptive fix — no need
|
|
68
|
+
# to replace the whole unit:
|
|
69
|
+
# mkdir -p ~/.config/systemd/user/coord-agent.service.d
|
|
70
|
+
# printf '[Service]\nEnvironment=PATH=%%h/.coord-venv/bin:%%h/.cargo/bin:%%h/.local/bin:/usr/local/bin:/usr/bin:/bin\n' \
|
|
71
|
+
# > ~/.config/systemd/user/coord-agent.service.d/path.conf
|
|
72
|
+
# systemctl --user daemon-reload
|
|
73
|
+
# systemctl --user restart coord-agent
|
|
74
|
+
#
|
|
75
|
+
# See docs/AGENT_OPERATIONS.md → "Install a new agent (first time)".
|
|
76
|
+
|
|
77
|
+
[Unit]
|
|
78
|
+
Description=Coordinator agent server (port <PORT>)
|
|
79
|
+
After=network-online.target
|
|
80
|
+
|
|
81
|
+
[Service]
|
|
82
|
+
Type=simple
|
|
83
|
+
ExecStart=%h/.coord-venv/bin/coord agent --machine <MACHINE_NAME> --port <PORT>
|
|
84
|
+
Restart=on-failure
|
|
85
|
+
RestartSec=5
|
|
86
|
+
Environment=PATH=%h/.coord-venv/bin:%h/.cargo/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
87
|
+
|
|
88
|
+
[Install]
|
|
89
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# coord-db-backup — hourly coord.db snapshot to the external SSD, interim
|
|
2
|
+
# stopgap pending #1822 (continuous backup + verified restore, tier:large,
|
|
3
|
+
# unstarted), paired with coord-db-backup.timer.
|
|
4
|
+
#
|
|
5
|
+
# A systemd *user* unit (mirrors coord-serve/coord-web/coord-notify/
|
|
6
|
+
# coord-drive-queue), Type=oneshot, timer-fired. Runs
|
|
7
|
+
# ~/.local/bin/coord-db-backup.sh (deploy/coord-db-backup.sh), which:
|
|
8
|
+
# - refuses to run unless the destination directory's parent is an actual
|
|
9
|
+
# mountpoint (default /media/crucial) — the external SSD must be
|
|
10
|
+
# physically present, or it would otherwise write "backups" onto the
|
|
11
|
+
# very disk (the daemon host's root filesystem) it exists to protect
|
|
12
|
+
# against,
|
|
13
|
+
# - takes a live, WAL-safe snapshot via `sqlite3 ... VACUUM INTO`, never
|
|
14
|
+
# `cp` (a plain copy of a WAL-mode db under concurrent writes can capture
|
|
15
|
+
# a torn file — the failure you only discover at restore),
|
|
16
|
+
# - `PRAGMA integrity_check`s the snapshot and sanity-checks it has a
|
|
17
|
+
# non-empty `assignments` table before it counts; either failure keeps
|
|
18
|
+
# the bad file on disk renamed `.REJECTED` rather than deleting it or
|
|
19
|
+
# leaving it indistinguishable from a good backup,
|
|
20
|
+
# - prunes snapshots beyond `COORD_BACKUP_RETAIN` (default 168 ≈ 7 days
|
|
21
|
+
# hourly), never touching `.REJECTED` files.
|
|
22
|
+
#
|
|
23
|
+
# Install ONLY on the box that owns ~/.coord/coord.db (dellserver in
|
|
24
|
+
# production — the same host that runs coord-serve/coord-web/coord-notify/
|
|
25
|
+
# coord-drive-queue).
|
|
26
|
+
#
|
|
27
|
+
# NOT a substitute for off-box backup: this protects against db corruption,
|
|
28
|
+
# a bad migration, accidental deletion and OS-disk failure. It does NOT
|
|
29
|
+
# protect against the machine itself being lost, stolen or burned — see
|
|
30
|
+
# #1822, which owns the real (continuous + verified-restore + off-box)
|
|
31
|
+
# answer. This interim unit is deliberately small in scope.
|
|
32
|
+
#
|
|
33
|
+
# Install:
|
|
34
|
+
# mkdir -p ~/.local/bin ~/.config/systemd/user
|
|
35
|
+
# cp deploy/coord-db-backup.sh ~/.local/bin/ && chmod +x ~/.local/bin/coord-db-backup.sh
|
|
36
|
+
# cp deploy/coord-db-backup.service deploy/coord-db-backup.timer ~/.config/systemd/user/
|
|
37
|
+
# loginctl enable-linger "$USER" # survive logout / reboot
|
|
38
|
+
# systemctl --user daemon-reload
|
|
39
|
+
# systemctl --user enable --now coord-db-backup.timer
|
|
40
|
+
#
|
|
41
|
+
# This is a Type=oneshot service activated BY THE TIMER — do not
|
|
42
|
+
# `systemctl --user enable coord-db-backup.service` directly (it has no
|
|
43
|
+
# [Install] section; the timer unit is what you enable).
|
|
44
|
+
#
|
|
45
|
+
# See docs/AGENT_OPERATIONS.md → "coord.db backups to the external SSD".
|
|
46
|
+
|
|
47
|
+
[Unit]
|
|
48
|
+
Description=Snapshot coord.db to the external SSD (interim, pending #1822)
|
|
49
|
+
Documentation=https://github.com/JDonaghy/claude-coordinator/issues/1822
|
|
50
|
+
|
|
51
|
+
[Service]
|
|
52
|
+
Type=oneshot
|
|
53
|
+
ExecStart=%h/.local/bin/coord-db-backup.sh
|
|
54
|
+
# `VACUUM INTO` on a live, busy coord.db can stall under lock contention.
|
|
55
|
+
# Generous relative to a normal run (a snapshot this size takes seconds),
|
|
56
|
+
# well below the timer's hourly cadence (coord-db-backup.timer) so a
|
|
57
|
+
# genuinely wedged run can't run into the next fire indefinitely — same
|
|
58
|
+
# pattern as every other oneshot unit in this directory (see
|
|
59
|
+
# coord-drive-queue.service, coord-web-dist-build.service).
|
|
60
|
+
TimeoutStartSec=600
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Interim coord.db snapshot to the external SSD (pending #1822).
|
|
3
|
+
#
|
|
4
|
+
# VACUUM INTO, not cp: it takes a consistent snapshot of a live SQLite database
|
|
5
|
+
# while coord-serve keeps writing. A plain cp of a WAL-mode db under load can
|
|
6
|
+
# capture a torn file, which is the failure mode you only discover at restore.
|
|
7
|
+
#
|
|
8
|
+
# Every snapshot is integrity-checked and sanity-checked (assignments table
|
|
9
|
+
# present and non-empty) before it is allowed to count, and any failed check
|
|
10
|
+
# leaves the snapshot on disk named .REJECTED so it can be looked at rather
|
|
11
|
+
# than silently deleted OR silently left lying around under its normal name,
|
|
12
|
+
# indistinguishable from a good backup.
|
|
13
|
+
#
|
|
14
|
+
# NOT a substitute for off-box backup: this protects against db corruption, a
|
|
15
|
+
# bad migration, accidental deletion and OS-disk failure. It does NOT protect
|
|
16
|
+
# against the machine being lost, stolen or burned. See #1822.
|
|
17
|
+
set -uo pipefail
|
|
18
|
+
|
|
19
|
+
SRC="${COORD_DB:-$HOME/.coord/coord.db}"
|
|
20
|
+
DEST_DIR="${COORD_BACKUP_DIR:-/media/crucial/coord-backups}"
|
|
21
|
+
RETAIN="${COORD_BACKUP_RETAIN:-168}" # hourly x 7 days
|
|
22
|
+
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
23
|
+
OUT="$DEST_DIR/coord.db.$STAMP"
|
|
24
|
+
|
|
25
|
+
fail() { echo "coord-db-backup: FAILED: $*" >&2; exit 1; }
|
|
26
|
+
|
|
27
|
+
[ -f "$SRC" ] || fail "source db not found: $SRC"
|
|
28
|
+
|
|
29
|
+
# The mount must actually be a mount. If the SSD is unplugged, /media/crucial
|
|
30
|
+
# is still a directory on the root filesystem, and we would cheerfully write
|
|
31
|
+
# "backups" onto the very disk we are protecting against.
|
|
32
|
+
mountpoint -q "$(dirname "$DEST_DIR")" || fail "$(dirname "$DEST_DIR") is not a mountpoint — external SSD not mounted"
|
|
33
|
+
|
|
34
|
+
mkdir -p "$DEST_DIR" || fail "cannot create $DEST_DIR"
|
|
35
|
+
|
|
36
|
+
sqlite3 "$SRC" "VACUUM INTO '$OUT';" || fail "VACUUM INTO failed"
|
|
37
|
+
|
|
38
|
+
CHECK="$(sqlite3 "$OUT" 'PRAGMA integrity_check;' 2>&1)"
|
|
39
|
+
if [ "$CHECK" != "ok" ]; then
|
|
40
|
+
mv "$OUT" "$OUT.REJECTED"
|
|
41
|
+
fail "integrity_check on snapshot: $CHECK (kept as $OUT.REJECTED)"
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Prove it is a coord db and not an empty file that passed integrity_check.
|
|
45
|
+
# Same "keep it as .REJECTED, never silently delete or silently leave it
|
|
46
|
+
# lying around under the normal name" contract as the integrity_check
|
|
47
|
+
# rejection above -- an unlinked, unmarked snapshot sitting next to good
|
|
48
|
+
# ones is exactly the kind of thing retention pruning would eventually
|
|
49
|
+
# rotate out indistinguishably from a real backup.
|
|
50
|
+
ROWS="$(sqlite3 "$OUT" 'SELECT COUNT(*) FROM assignments;' 2>&1)"
|
|
51
|
+
RC=$?
|
|
52
|
+
if [ "$RC" -ne 0 ]; then
|
|
53
|
+
mv "$OUT" "$OUT.REJECTED"
|
|
54
|
+
fail "snapshot has no assignments table: $ROWS (kept as $OUT.REJECTED)"
|
|
55
|
+
fi
|
|
56
|
+
case "$ROWS" in
|
|
57
|
+
''|*[!0-9]*)
|
|
58
|
+
mv "$OUT" "$OUT.REJECTED"
|
|
59
|
+
fail "unexpected assignments count: $ROWS (kept as $OUT.REJECTED)"
|
|
60
|
+
;;
|
|
61
|
+
esac
|
|
62
|
+
if [ "$ROWS" -eq 0 ]; then
|
|
63
|
+
mv "$OUT" "$OUT.REJECTED"
|
|
64
|
+
fail "snapshot has 0 assignments — refusing to count this as a backup (kept as $OUT.REJECTED)"
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
ln -sfn "$OUT" "$DEST_DIR/coord.db.latest"
|
|
68
|
+
|
|
69
|
+
# Prune oldest beyond RETAIN. Never touches .REJECTED files.
|
|
70
|
+
mapfile -t OLD < <(ls -1 "$DEST_DIR"/coord.db.2* 2>/dev/null | grep -v '\.REJECTED$' | sort | head -n -"$RETAIN")
|
|
71
|
+
for f in "${OLD[@]:-}"; do [ -n "$f" ] && rm -f "$f"; done
|
|
72
|
+
|
|
73
|
+
SIZE="$(du -h "$OUT" | cut -f1)"
|
|
74
|
+
echo "coord-db-backup: ok $OUT ($SIZE, ${ROWS} assignments, $(ls -1 "$DEST_DIR"/coord.db.2* 2>/dev/null | grep -vc '\.REJECTED$') snapshots retained)"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Fires coord-db-backup.service hourly. See that unit's header for the full
|
|
2
|
+
# rationale, install steps and safety guarantees.
|
|
3
|
+
#
|
|
4
|
+
# Persistent=true: if the daemon host was down/rebooted when a fire was
|
|
5
|
+
# missed, the next boot catches it up immediately rather than waiting up to
|
|
6
|
+
# an hour for the next scheduled tick.
|
|
7
|
+
|
|
8
|
+
[Unit]
|
|
9
|
+
Description=Hourly coord.db snapshot to the external SSD (interim, pending #1822)
|
|
10
|
+
|
|
11
|
+
[Timer]
|
|
12
|
+
OnBootSec=5min
|
|
13
|
+
OnUnitActiveSec=1h
|
|
14
|
+
AccuracySec=5min
|
|
15
|
+
Persistent=true
|
|
16
|
+
|
|
17
|
+
[Install]
|
|
18
|
+
WantedBy=timers.target
|