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/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
|
+
}
|