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.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
coord/agent_update.py ADDED
@@ -0,0 +1,417 @@
1
+ """Blue/green, version-pinned venv swap for `coord agent update` (#1241).
2
+
3
+ `POST /update` used to run `pip install --upgrade` **in place** on the live
4
+ `~/.coord-venv` (see the old ``coord.agent_app._do_update``). That leaves a
5
+ window — while pip is rewriting site-packages file by file — during which a
6
+ concurrent `coord` invocation can observe a *partial* install: some modules
7
+ already the new version, others still the old one. This repo hit that for
8
+ real: mid-upgrade, ``state.py`` had already been swapped to a version that
9
+ imports ``coord.board_service``, but ``board_service.py`` hadn't landed yet,
10
+ so a concurrent ``coord report-result`` crashed with ``ModuleNotFoundError``.
11
+ An update must be all-or-nothing.
12
+
13
+ The fix: never write into the venv that's live. Install the target version
14
+ into a **fresh** venv — one of two fixed "slots" next to the live one
15
+ (``~/.coord-venv.blue`` / ``~/.coord-venv.green``) — smoke-check it, and
16
+ only then flip a symlink so ``~/.coord-venv`` always resolves to one
17
+ *complete* slot, old or new, never a mix. Rename of a symlink onto an
18
+ existing path is atomic on POSIX (same filesystem), so any `coord`
19
+ invocation racing the flip sees either the fully-old or the fully-new
20
+ install — there is no observable in-between state.
21
+
22
+ Using exactly two named slots (rather than a fresh directory per release)
23
+ also gives rollback for free: the slot that was live before the swap is
24
+ left untouched, so it's still there — one generation back — until the
25
+ *next* update reuses it. See :func:`rollback`.
26
+
27
+ #2140: "the slot that's live" means two different things that usually —
28
+ but not always — agree: the slot ``venv_dir`` *symlinks to*, and the slot
29
+ whatever process is *actually running perform_update* was started from
30
+ (``sys.executable``). A swap flips the symlink without restarting anyone,
31
+ so the moment that happens the two diverge — normal on a fleet where
32
+ restarts are gated on a drain (#2138/#2136), not a rare race. If a second
33
+ update then reuses the slot the symlink no longer points at, it is
34
+ deleting the running caller's own interpreter and site-packages out from
35
+ under it: the subprocess spawn of ``sys.executable`` fails because the
36
+ path is now gone, cleanup deletes it a second time for good measure, and
37
+ the generation that was the rollback target is destroyed along with it.
38
+ Two independent guards below close this: :func:`perform_update` refuses
39
+ outright when the slot it would rebuild is the one backing its own
40
+ ``sys.executable`` (recoverable — the caller just needs a restart first),
41
+ and venv creation always uses the *symlinked* slot's python rather than
42
+ ``sys.executable``, so the tool building the new environment is never the
43
+ thing this update is about to delete.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import shutil
49
+ import subprocess
50
+ import sys
51
+ from dataclasses import dataclass
52
+ from pathlib import Path
53
+
54
+ #: Suffixes for the two blue/green slots, relative to the live venv dir
55
+ #: (e.g. ``~/.coord-venv`` -> ``~/.coord-venv.blue`` / ``~/.coord-venv.green``).
56
+ _BLUE_SUFFIX = ".blue"
57
+ _GREEN_SUFFIX = ".green"
58
+
59
+ #: What the smoke check imports to prove the new install actually boots —
60
+ #: the two modules whose disagreement caused the ModuleNotFoundError this
61
+ #: whole mechanism exists to prevent (state.py -> board_service.py).
62
+ _SMOKE_IMPORTS = "coord.state, coord.commands.review"
63
+
64
+
65
+ @dataclass
66
+ class UpdateResult:
67
+ """Outcome of :func:`perform_update` or :func:`rollback`.
68
+
69
+ ``new_version`` is read from the new slot *before* it goes live (via the
70
+ smoke check), so it's available even though the caller's own
71
+ ``importlib.metadata`` read of ``~/.coord-venv`` won't reflect it until
72
+ that process's next fresh read after the swap.
73
+ """
74
+
75
+ ok: bool
76
+ swapped: bool
77
+ slot: Path | None = None
78
+ previous_slot: Path | None = None
79
+ new_version: str | None = None
80
+ error: str | None = None
81
+ log: str = ""
82
+
83
+
84
+ def _slots(venv_dir: Path) -> tuple[Path, Path]:
85
+ """Return the ``(blue, green)`` sibling directories for *venv_dir*."""
86
+ parent = venv_dir.parent
87
+ name = venv_dir.name
88
+ return parent / f"{name}{_BLUE_SUFFIX}", parent / f"{name}{_GREEN_SUFFIX}"
89
+
90
+
91
+ def current_slot(venv_dir: Path) -> Path | None:
92
+ """Return the slot ``venv_dir`` currently resolves to.
93
+
94
+ ``None`` when *venv_dir* doesn't exist yet, or exists as a plain
95
+ directory that hasn't been migrated to the blue/green layout (see
96
+ :func:`ensure_symlink_layout`) — every pre-#1241 install starts this
97
+ way, since ``install-agent.sh`` creates ``~/.coord-venv`` as a real
98
+ directory.
99
+ """
100
+ if not venv_dir.is_symlink():
101
+ return None
102
+ target = venv_dir.readlink()
103
+ if not target.is_absolute():
104
+ target = (venv_dir.parent / target).resolve()
105
+ return target
106
+
107
+
108
+ def ensure_symlink_layout(venv_dir: Path) -> Path:
109
+ """Migrate *venv_dir* to the blue/green symlink layout if it isn't already.
110
+
111
+ Idempotent: if *venv_dir* is already a symlink, just returns its current
112
+ target. Otherwise renames the existing plain directory into the
113
+ ``.blue`` slot and replaces *venv_dir* with a symlink pointing at it.
114
+ This is the one-time, one-machine migration every pre-#1241 install
115
+ needs; every update after that stays in the symlink layout, so this
116
+ becomes a no-op for the rest of that machine's life.
117
+ """
118
+ existing = current_slot(venv_dir)
119
+ if existing is not None:
120
+ return existing
121
+ if not venv_dir.exists():
122
+ raise FileNotFoundError(f"no venv at {venv_dir} to migrate")
123
+ blue, _green = _slots(venv_dir)
124
+ if blue.exists():
125
+ # Should be unreachable — `blue`/`green` only ever come into being
126
+ # via this function or `perform_update`, both gated on `venv_dir`
127
+ # not already being a symlink. Refuse rather than clobber whatever
128
+ # is there.
129
+ raise FileExistsError(
130
+ f"{blue} already exists — refusing to migrate {venv_dir} over it"
131
+ )
132
+ venv_dir.rename(blue)
133
+ venv_dir.symlink_to(blue, target_is_directory=True)
134
+ return blue
135
+
136
+
137
+ def _other_slot(venv_dir: Path, active: Path) -> Path:
138
+ blue, green = _slots(venv_dir)
139
+ return green if active == blue else blue
140
+
141
+
142
+ def _slot_backing_interpreter(venv_dir: Path, interpreter: Path) -> Path | None:
143
+ """Return whichever blue/green slot *interpreter* physically lives under.
144
+
145
+ ``None`` if *interpreter* resolves to neither slot (e.g. a dev/editable
146
+ install not using the blue/green layout at all).
147
+
148
+ #2140: deliberately keyed off the slot *directories* themselves, not
149
+ off :func:`current_slot` — ``sys.executable`` is the literal path baked
150
+ into a process at start time (e.g. ``~/.coord-venv.blue/bin/python3``)
151
+ and stays pinned to that slot for the process's whole life, even after
152
+ a later swap moves the ``venv_dir`` symlink onto the other slot. Those
153
+ two — "what the symlink currently says" and "what this process is
154
+ actually running from" — regularly disagree for hours on a fleet where
155
+ restarts are gated on a drain (#2138/#2136); this function answers the
156
+ second question, which is the one that matters before deleting a slot.
157
+
158
+ #2140 review: this must NOT call ``.resolve()`` on *interpreter*
159
+ wholesale. ``bin/python3`` inside a real ``python -m venv`` slot is
160
+ itself a symlink chain (PEP 405) ending at the shared base
161
+ interpreter — e.g. ``~/.coord-venv.blue/bin/python3 -> ... ->
162
+ /usr/bin/python3.12``. Fully resolving that follows the chain straight
163
+ out of the slot to a path that is neither blue nor green, so the
164
+ comparison below would silently never match and this refuse-guard
165
+ would never fire against a real venv — verified empirically against
166
+ real ``python3 -m venv`` output. Only the *directory* the interpreter
167
+ lives in is resolved (to normalize e.g. a symlinked home directory
168
+ somewhere above the slot); the interpreter's own filename is kept
169
+ exactly as given, so the comparison stays anchored inside the slot
170
+ that path actually names instead of following through to whatever
171
+ that file ultimately points at.
172
+ """
173
+ try:
174
+ resolved = interpreter.parent.resolve() / interpreter.name
175
+ except OSError:
176
+ return None
177
+ blue, green = _slots(venv_dir)
178
+ for slot in (blue, green):
179
+ try:
180
+ resolved.relative_to(slot.resolve())
181
+ except (OSError, ValueError):
182
+ continue
183
+ return slot
184
+ return None
185
+
186
+
187
+ def _atomic_swap(venv_dir: Path, new_slot: Path) -> None:
188
+ """Flip *venv_dir* to point at *new_slot* in one filesystem operation.
189
+
190
+ Builds the new symlink at a temp path next to *venv_dir* and renames it
191
+ directly onto *venv_dir* — ``rename()`` replacing an existing path is
192
+ atomic on POSIX when both are on the same filesystem (true here: both
193
+ are siblings under the same parent directory), so any `coord`
194
+ invocation racing this always sees either the old, complete slot or the
195
+ new, complete slot — never a half-updated ``venv_dir``.
196
+ """
197
+ tmp_link = venv_dir.parent / f".{venv_dir.name}.next-link"
198
+ if tmp_link.is_symlink() or tmp_link.exists():
199
+ tmp_link.unlink()
200
+ tmp_link.symlink_to(new_slot, target_is_directory=True)
201
+ tmp_link.replace(venv_dir)
202
+
203
+
204
+ def _smoke_check(slot: Path, *, target_version: str | None) -> tuple[bool, str | None, str]:
205
+ """Run the two smoke checks against a freshly-installed *slot*.
206
+
207
+ Returns ``(ok, detected_version, log)``. ``detected_version`` is parsed
208
+ out of the import-check's own ``importlib.metadata`` read (the same
209
+ mechanism :func:`_installed_version` uses) so a single subprocess call
210
+ covers both "does it import" and "what version is this."
211
+
212
+ #2103: the version print resolves via ``coord.dist_name.resolve_installed``
213
+ (tries `code-coordinator` then falls back to `claude-coordinator`) rather
214
+ than a hardcoded ``m.version('claude-coordinator')`` — *slot* was just
215
+ installed from a pkg spec that itself resolved tolerantly (see
216
+ ``coord.agent_app._agent_pkg_spec``), so a hardcoded name here would
217
+ raise inside the new slot's own interpreter the moment that pkg spec
218
+ picked the other name, failing every smoke check fleet-wide the instant
219
+ the rename lands. Raising when *neither* name is installed is still the
220
+ right behavior here (not caught) — that means the fresh install is
221
+ genuinely broken, which is exactly what a failed smoke check should
222
+ report.
223
+ """
224
+ python = slot / "bin" / "python"
225
+ coord_bin = slot / "bin" / "coord"
226
+ lines: list[str] = []
227
+
228
+ try:
229
+ result = subprocess.run(
230
+ [
231
+ str(python),
232
+ "-c",
233
+ f"import {_SMOKE_IMPORTS}\n"
234
+ "from coord.dist_name import resolve_installed\n"
235
+ "print(resolve_installed().version)",
236
+ ],
237
+ capture_output=True,
238
+ text=True,
239
+ timeout=30,
240
+ )
241
+ except (OSError, subprocess.SubprocessError) as exc:
242
+ return False, None, f"smoke import check raised {type(exc).__name__}: {exc}"
243
+
244
+ lines.append(
245
+ f"$ python -c 'import {_SMOKE_IMPORTS}'\n{result.stdout}{result.stderr}"
246
+ )
247
+ if result.returncode != 0:
248
+ return False, None, "\n".join(lines)
249
+ detected_version = result.stdout.strip() or None
250
+
251
+ try:
252
+ result = subprocess.run(
253
+ [str(coord_bin), "--version"],
254
+ capture_output=True,
255
+ text=True,
256
+ timeout=30,
257
+ )
258
+ except (OSError, subprocess.SubprocessError) as exc:
259
+ lines.append(f"coord --version raised {type(exc).__name__}: {exc}")
260
+ return False, detected_version, "\n".join(lines)
261
+
262
+ lines.append(f"$ coord --version\n{result.stdout}{result.stderr}")
263
+ if result.returncode != 0:
264
+ return False, detected_version, "\n".join(lines)
265
+
266
+ if target_version and target_version not in (result.stdout + result.stderr):
267
+ lines.append(
268
+ f"version mismatch: expected {target_version!r} in `coord --version` output"
269
+ )
270
+ return False, detected_version, "\n".join(lines)
271
+
272
+ return True, detected_version, "\n".join(lines)
273
+
274
+
275
+ def perform_update(
276
+ venv_dir: Path,
277
+ pkg_spec: str,
278
+ *,
279
+ target_version: str | None = None,
280
+ pip_timeout: float = 180.0,
281
+ ) -> UpdateResult:
282
+ """Install ``pkg_spec`` (optionally pinned to *target_version*) into a
283
+ fresh slot, smoke-check it, and atomically swap it into place.
284
+
285
+ Never mutates *venv_dir*'s currently-live slot. On any failure — venv
286
+ creation, pip, or the smoke check — the half-built next slot is removed
287
+ and *venv_dir* is left exactly as it was; the caller's process keeps
288
+ running the old code with no restart needed. Returns a failed
289
+ :class:`UpdateResult` rather than raising, except for genuinely
290
+ unexpected setup errors (e.g. *venv_dir* doesn't exist at all).
291
+
292
+ #2140: also refuses — before touching anything — if *next_slot* (the
293
+ one about to be rebuilt) is the slot backing this very process's own
294
+ ``sys.executable``. That happens when a previous swap flipped the
295
+ symlink without a restart following it; proceeding would delete the
296
+ caller's own running interpreter and site-packages, and destroy the
297
+ rollback generation along with it. A refusal here is recoverable (the
298
+ caller just needs restarting first); reaching into that slot is not.
299
+ """
300
+ log_parts: list[str] = []
301
+ active = ensure_symlink_layout(venv_dir)
302
+ next_slot = _other_slot(venv_dir, active)
303
+
304
+ running_slot = _slot_backing_interpreter(venv_dir, Path(sys.executable))
305
+ if running_slot is not None and running_slot == next_slot:
306
+ return UpdateResult(
307
+ ok=False,
308
+ swapped=False,
309
+ error=(
310
+ f"refusing to update: this process's own interpreter "
311
+ f"({sys.executable}) is running from {next_slot}, the slot "
312
+ "this update would delete and rebuild. venv_dir currently "
313
+ f"symlinks to {active}, so a prior swap flipped it without "
314
+ "this process restarting — restart the caller (or wait for "
315
+ "idle self-restart, #2139) and retry (#2140)."
316
+ ),
317
+ )
318
+
319
+ # Always build fresh — a stale, possibly half-built slot left over from
320
+ # an interrupted update two generations back must never be reused.
321
+ if next_slot.exists():
322
+ shutil.rmtree(next_slot, ignore_errors=True)
323
+
324
+ def _fail(error: str) -> UpdateResult:
325
+ shutil.rmtree(next_slot, ignore_errors=True)
326
+ return UpdateResult(ok=False, swapped=False, error=error, log="\n".join(log_parts))
327
+
328
+ # #2140: build with the *symlinked* slot's python, not sys.executable —
329
+ # `active` is guaranteed to differ from `next_slot` (they're the two
330
+ # distinct blue/green slots), so the interpreter doing the building can
331
+ # never be the thing this update is about to rmtree, regardless of
332
+ # which slot the calling process itself happens to be running from.
333
+ builder_python = active / "bin" / "python"
334
+ if not builder_python.exists():
335
+ builder_python = Path(sys.executable)
336
+ try:
337
+ result = subprocess.run(
338
+ [str(builder_python), "-m", "venv", str(next_slot)],
339
+ capture_output=True,
340
+ text=True,
341
+ timeout=60,
342
+ )
343
+ except subprocess.TimeoutExpired as exc:
344
+ return _fail(f"venv creation timed out: {exc}")
345
+ log_parts.append(f"$ python -m venv {next_slot}\n{result.stdout}{result.stderr}")
346
+ if result.returncode != 0:
347
+ return _fail(f"venv creation failed (exit {result.returncode})")
348
+
349
+ pip = str(next_slot / "bin" / "pip")
350
+ install_spec = f"{pkg_spec}=={target_version}" if target_version else pkg_spec
351
+ try:
352
+ result = subprocess.run(
353
+ [pip, "install", "--no-cache-dir", install_spec],
354
+ capture_output=True,
355
+ text=True,
356
+ timeout=pip_timeout,
357
+ )
358
+ except subprocess.TimeoutExpired as exc:
359
+ return _fail(f"pip install timed out: {exc}")
360
+ log_parts.append(f"$ pip install --no-cache-dir {install_spec}\n{result.stdout}{result.stderr}")
361
+ if result.returncode != 0:
362
+ return _fail(f"pip install failed (exit {result.returncode})")
363
+
364
+ ok, new_version, smoke_log = _smoke_check(next_slot, target_version=target_version)
365
+ log_parts.append(smoke_log)
366
+ if not ok:
367
+ return _fail("smoke check failed on the new install; see log")
368
+
369
+ previous = active
370
+ _atomic_swap(venv_dir, next_slot)
371
+ return UpdateResult(
372
+ ok=True,
373
+ swapped=True,
374
+ slot=next_slot,
375
+ previous_slot=previous,
376
+ new_version=new_version,
377
+ log="\n".join(log_parts),
378
+ )
379
+
380
+
381
+ def rollback(venv_dir: Path) -> UpdateResult:
382
+ """Flip *venv_dir* back onto the previous generation, if one exists.
383
+
384
+ The previous slot is smoke-checked before the swap — a rollback that
385
+ would land on a broken install is refused, leaving the current
386
+ (presumably also broken, but at least known) slot in place rather than
387
+ trading one failure for another.
388
+ """
389
+ active = current_slot(venv_dir)
390
+ if active is None:
391
+ return UpdateResult(
392
+ ok=False, swapped=False, error=f"{venv_dir} is not a migrated blue/green venv"
393
+ )
394
+ previous = _other_slot(venv_dir, active)
395
+ if not previous.exists():
396
+ return UpdateResult(
397
+ ok=False, swapped=False, error=f"no previous generation at {previous}"
398
+ )
399
+
400
+ ok, version, log = _smoke_check(previous, target_version=None)
401
+ if not ok:
402
+ return UpdateResult(
403
+ ok=False,
404
+ swapped=False,
405
+ error=f"previous slot {previous} fails its smoke check — refusing to roll back onto it",
406
+ log=log,
407
+ )
408
+
409
+ _atomic_swap(venv_dir, previous)
410
+ return UpdateResult(
411
+ ok=True,
412
+ swapped=True,
413
+ slot=previous,
414
+ previous_slot=active,
415
+ new_version=version,
416
+ log=log,
417
+ )
@@ -0,0 +1,13 @@
1
+ # opencode auto-bootstraps a plugin dependency (@opencode-ai/plugin) into
2
+ # whatever directory OPENCODE_CONFIG_DIR points at — confirmed: every real
3
+ # `opencode run`/`opencode agent list` invocation against this directory
4
+ # (which is exactly what OpenCodeProvider.env() sets OPENCODE_CONFIG_DIR to,
5
+ # see coord/providers/opencode.py) writes node_modules/, package.json,
6
+ # package-lock.json, and its own bun.lock here. None of this is authored or
7
+ # tracked by coord — it's a side effect of opencode's own plugin loading,
8
+ # not a real project manifest, and it can be tens of MB. Same pattern as
9
+ # coord/dashboard/webapp/.gitignore.
10
+ node_modules
11
+ package.json
12
+ package-lock.json
13
+ bun.lock
@@ -0,0 +1,129 @@
1
+ ---
2
+ description: coord `work` assignment worker — implements a GitHub issue in its own git worktree, commits, and pushes a branch for the coordinator to review and merge.
3
+ mode: primary
4
+ permission:
5
+ # Deny-baseline (#1705): the catch-all "*": deny must come first — opencode
6
+ # resolves overlapping rules last-match-wins, so a catch-all listed after a
7
+ # specific rule would silently swallow it (confirmed against a real
8
+ # opencode binary, see docs/OPENCODE_VERIFICATION.md "Rule precedence is
9
+ # last-match-wins, not first-match-wins"). Every allow below is a narrow,
10
+ # deliberate carve-out for exactly what a `work` assignment's flow needs —
11
+ # git only as far as branch/commit/push/inspect, plus the specific
12
+ # build/test toolchains this file's own instructions name below. Nothing
13
+ # else — no shell escape hatches (`sh -c`, `bash -c`, bare `python -c`),
14
+ # no network tools (`curl`, `wget`, `ssh`, `nc`) — survives to close the
15
+ # indirection routes (`sh -c "gh ..."`, a `subprocess.run(["gh", ...])`
16
+ # one-liner, a raw `curl` against the GitHub REST API) that a wide-open
17
+ # bash baseline would otherwise leave for reaching `gh`/GitHub even with
18
+ # an explicit "gh *": deny in place.
19
+ bash:
20
+ "*": deny
21
+ "git status*": allow
22
+ "git diff*": allow
23
+ "git log*": allow
24
+ "git show*": allow
25
+ "git add*": allow
26
+ "git commit*": allow
27
+ "git checkout*": allow
28
+ "git branch*": allow
29
+ "git rev-parse*": allow
30
+ "git push*": allow
31
+ "cargo *": allow
32
+ "make*": allow
33
+ "pytest*": allow
34
+ "python3 -m pytest*": allow
35
+ "python -m pytest*": allow
36
+ "npm *": allow
37
+ "pip install*": allow
38
+ "pip3 install*": allow
39
+ "gh *": deny
40
+ edit:
41
+ "*": allow
42
+ "tests/acceptance/**": deny
43
+ external_directory: deny
44
+ ---
45
+ You are an opencode worker executing an assignment from the coordinator.
46
+
47
+ Rules:
48
+ - Do NOT run gh commands. The coordinator owns all GitHub interactions \
49
+ (issues, PRs, comments). Use regular git commands only.
50
+ - Stay within the files listed in your briefing. If you need to touch \
51
+ other files, do so only if strictly necessary and note it.
52
+ - If the briefing lists forbidden files, do NOT read or modify them. \
53
+ They are managed by the coordinator.
54
+ - You are already on a feature branch. Commit your work to this branch. \
55
+ Push with `git push origin HEAD`. \
56
+ NEVER commit or push to main or develop directly. \
57
+ Do NOT open a PR — the coordinator handles that.
58
+ - Work only inside your current working directory. It is your own git \
59
+ worktree, checked out from a repo that also lives at `~/src/<repo>` on this \
60
+ machine — never read or write anything under `~/src/<repo>` (or any other \
61
+ absolute path outside your cwd). That shared checkout is not yours: edits \
62
+ there are lost, or collide with other workers running at the same time. If \
63
+ your worktree looks unexpectedly empty or unwritable, STOP and report it — \
64
+ do not fall back to editing the base checkout and copying files over.
65
+
66
+ This session is ONE-SHOT and non-interactive:
67
+ - There is no next turn and no human to reply to you. Background-task \
68
+ completion notifications will NEVER reach you — nothing wakes you up.
69
+ - NEVER start a long-running command in the background and then end your \
70
+ turn waiting for it. Run it in the FOREGROUND and block until it returns, \
71
+ or raise the timeout, or skip it and say so. If you end your turn waiting, \
72
+ the session is over and your work is thrown away.
73
+ - ALWAYS commit and push (`git add`, `git commit`, `git push origin HEAD`) \
74
+ BEFORE your final message — even if the build is broken, the tests are \
75
+ failing, or you ran out of time. Uncommitted changes are destroyed when the \
76
+ session ends. A committed work-in-progress with an honest final message is \
77
+ strictly better than a perfect uncommitted diff, which is worth nothing.
78
+ - Your final message is the LAST thing you will ever say. Never end it with \
79
+ "I'll continue", "waiting for X", or "will follow up" — finish or report \
80
+ the blocker.
81
+
82
+ Before writing any code, verify the feature or fix isn't already implemented. \
83
+ Grep for relevant function names, check existing modules, and read related files. \
84
+ If it already exists, report back instead of reimplementing.
85
+
86
+ Progress reporting:
87
+ - After each significant step (first build, test run, approach change), \
88
+ output a status line in exactly this format:
89
+ STATUS: [what you just did] → [what you're about to do] → [confidence: high/medium/low]
90
+ - If you've tried 2 approaches and neither worked, STOP and output:
91
+ STUCK: [what you tried] [why it failed] [what you think the blocker is]
92
+ Then wait for guidance rather than trying a third approach.
93
+
94
+ Before declaring done:
95
+ - Run the project's build command (detect it from the repo: \
96
+ `cargo build` for Cargo.toml, `pytest` for pyproject.toml with pytest, \
97
+ `make` for Makefile, `npm run build` for package.json, etc.).
98
+ - If the build emits warnings — unused vars, dead code, deprecated APIs, \
99
+ ambiguous lifetimes, missing docs on public items — FIX THEM. \
100
+ Compiler warnings are part of the diff you're shipping; the human \
101
+ shouldn't have to clean up after you. Treat warnings as failures for \
102
+ the purposes of "done".
103
+ - If a warning genuinely can't be fixed in scope (third-party crate, \
104
+ intentional allow-with-reason, a deferred refactor flagged elsewhere), \
105
+ explicitly call it out in your final message with the reason. Don't \
106
+ silently ship warnings.
107
+ - Re-run the build after fixes to confirm clean output.
108
+ - Run the project's test command (`cargo test`, `pytest`, etc.) and \
109
+ confirm it passes before declaring done.
110
+
111
+ Before exiting, emit a SMOKE_TESTS block telling the human what to manually \
112
+ verify. You changed the code; you know what's worth poking.
113
+
114
+ SMOKE_TESTS:
115
+ - [scenario] — [how to trigger] — [what to look for]
116
+ - [scenario] — [how to trigger] — [what to look for]
117
+ END_SMOKE_TESTS
118
+
119
+ Keep it to 2-5 items, one bullet per line. Each bullet has three \
120
+ em-dash-separated parts: the scenario, the trigger, and the success \
121
+ signal. Prefer scenarios that exercise the changed code paths, not \
122
+ generic app sanity. Include any commands the human should re-run on \
123
+ their hardware.
124
+
125
+ If the change is purely internal — no user-visible behaviour, no new \
126
+ codepaths the existing test suite already covered — emit exactly:
127
+
128
+ SMOKE_TESTS: (none — change is internal)
129
+ END_SMOKE_TESTS
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ // #1705 (added scope, 2026-08-03): pin OpenRouter upstream routing so a
4
+ // pinned model slug (ProviderDef.model in coordinator.yml) doesn't
5
+ // silently get served by a different upstream host — and therefore a
6
+ // different quantization / context limit / tool-calling fidelity — from
7
+ // run to run. See coord/providers/opencode.py's module docstring for the
8
+ // full rationale and the evidence this mechanism is built on (decompiled
9
+ // strings from a real installed `opencode` 1.18.11 binary — no OpenRouter
10
+ // credential was available on the machine that authored this to capture
11
+ // a live request, so this was NOT verified end-to-end against OpenRouter
12
+ // itself; only that it is syntactically inert on a non-OpenRouter
13
+ // credential, which IS verified — see
14
+ // test_opencode_routing_pin_inert_without_openrouter_credential_end_to_end
15
+ // in tests/test_providers.py).
16
+ //
17
+ // Mechanism: `provider.<id>.options` is threaded into
18
+ // `providerOptions.<id>` for every request opencode resolves against
19
+ // that provider (regardless of which model — this file names no model
20
+ // slug, matching the requirement that model selection stays in
21
+ // ProviderDef.model). For the "openrouter" provider specifically
22
+ // (npm package @openrouter/ai-sdk-provider), that provider's own
23
+ // doGenerate/doStream spreads `providerOptions.openrouter` (minus a
24
+ // `cacheControl` field it handles separately) directly into the request
25
+ // body sent to OpenRouter's REST API — which is exactly where
26
+ // OpenRouter's own documented `provider: {order, allow_fallbacks, ...}`
27
+ // upstream-routing-preference field lives.
28
+ //
29
+ // allow_fallbacks: false is unambiguous and safe to commit unconditionally:
30
+ // it turns "pinned upstream unavailable" into a visible failure instead of
31
+ // a silent reroute to a different host — the actual acceptance criterion.
32
+ //
33
+ // `order` (naming the SPECIFIC upstream host to pin, e.g. "Fireworks") is
34
+ // deliberately left UNSET here. The issue that added this scope names no
35
+ // specific upstream to pin, and guessing one and shipping it unreviewed
36
+ // would defeat the "committed, reviewable artifact" property this file
37
+ // exists for. An operator who has chosen a specific upstream adds
38
+ // `"order": ["<upstream name>"]` alongside allow_fallbacks below — a
39
+ // one-line, reviewable PR diff, same as the rest of this file.
40
+ "provider": {
41
+ "openrouter": {
42
+ "options": {
43
+ "provider": {
44
+ "allow_fallbacks": false
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }