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/test_author.py
ADDED
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
"""Dispatch a `type="test-author"` session (#931, docs/ORACLE_LOOP.md).
|
|
2
|
+
|
|
3
|
+
An independent, feature-level black-box acceptance-suite author — zero
|
|
4
|
+
shared context with the worker under test, the same independence principle
|
|
5
|
+
as the adversarial reviewer (`coord/review.py`). Authored **at the Gate-A
|
|
6
|
+
arch gate, before any work**, then extended **just-in-time** as each issue
|
|
7
|
+
firms up its slice of the surface.
|
|
8
|
+
|
|
9
|
+
Like `coord/smoke.py` / `coord/review.py` / `coord/conflict_fix.py`, this
|
|
10
|
+
bypasses `coord.dispatch.dispatch()` (the `Proposal`/brain-oriented path)
|
|
11
|
+
and POSTs directly to the agent's `/assign` endpoint. That matters here
|
|
12
|
+
specifically: `coord.dispatch.dispatch()` auto-forbids `tests/acceptance/`
|
|
13
|
+
for every proposal when the repo has an acceptance driver configured (the
|
|
14
|
+
seal that keeps a `type="work"` worker from editing the oracle it's
|
|
15
|
+
graded against) — but a test-author session's entire job IS writing there.
|
|
16
|
+
Bypassing `dispatch()` means that seal never applies to this type, by
|
|
17
|
+
construction, instead of needing a type-gated exception in a shared
|
|
18
|
+
hot path.
|
|
19
|
+
|
|
20
|
+
The dispatcher does NOT embed `contract.md`'s text into the worker's
|
|
21
|
+
briefing — no local checkout is required to dispatch, and the worker is
|
|
22
|
+
told, in its briefing, to read the contract from its own checkout. This
|
|
23
|
+
mirrors `coord.acceptance.oracle_loop_contract_block` (#945), which points
|
|
24
|
+
the *worker's* briefing at the same path rather than embedding the contract
|
|
25
|
+
text. (The coordinator itself now does fetch `contract.md`'s content
|
|
26
|
+
server-side, via `gate_a_signoff_status`'s #2063 sign-off check below — the
|
|
27
|
+
invariant this docstring is actually protecting is "never in the worker's
|
|
28
|
+
prompt", not "never read at all".)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
import uuid
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
import click
|
|
40
|
+
import httpx
|
|
41
|
+
|
|
42
|
+
from coord import github_ops
|
|
43
|
+
from coord.acceptance import ACCEPTANCE_DIRNAME
|
|
44
|
+
from coord.config import Config
|
|
45
|
+
from coord.dispatch import AGENT_PORT, DispatchRefused
|
|
46
|
+
from coord.machine_pause import paused_set
|
|
47
|
+
from coord.milestone_dispatch import (
|
|
48
|
+
MilestoneDispatchError,
|
|
49
|
+
fetch_milestone_context,
|
|
50
|
+
gate_a_signoff_status,
|
|
51
|
+
)
|
|
52
|
+
from coord.models import Assignment, Machine
|
|
53
|
+
|
|
54
|
+
# The test-author never needs `gh` — every fact it needs (tracking issue,
|
|
55
|
+
# milestone membership, the JIT issue's title/body) is fetched by the
|
|
56
|
+
# coordinator and embedded in the briefing before dispatch. Mirrors
|
|
57
|
+
# `conflict_fix.CONFLICT_FIX_DENY_COMMANDS`'s "the coordinator owns GitHub
|
|
58
|
+
# interactions" rationale.
|
|
59
|
+
TEST_AUTHOR_DENY_COMMANDS: list[str] = [
|
|
60
|
+
"Bash(gh *)",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
TEST_AUTHOR_SYSTEM_PROMPT = """\
|
|
64
|
+
You are an INDEPENDENT acceptance-test author dispatched by the coordinator.
|
|
65
|
+
|
|
66
|
+
You have ZERO shared context with whoever implements this milestone's issues \
|
|
67
|
+
— the same independence principle as an adversarial code reviewer. You write \
|
|
68
|
+
to the CONTRACT, never to an implementation. Do not read, diff, or reason \
|
|
69
|
+
about any work branch, PR, or commit for the issue(s) in your briefing — if \
|
|
70
|
+
one exists, ignore it. Your tests must be derivable from the contract and \
|
|
71
|
+
the issue description alone. If you find yourself wanting to peek at code to \
|
|
72
|
+
"see what it actually does," stop — that would make you the worker grading \
|
|
73
|
+
its own homework, which is exactly what this assignment type exists to \
|
|
74
|
+
prevent.
|
|
75
|
+
|
|
76
|
+
Your job:
|
|
77
|
+
1. Read the contract at the path given in your briefing \
|
|
78
|
+
(`tests/acceptance/ms-NN/contract.md`) from YOUR OWN checkout. If it does \
|
|
79
|
+
not exist, STOP and output:
|
|
80
|
+
STUCK: contract.md missing at <path> — Gate-A hasn't produced it yet
|
|
81
|
+
1b. Also open the rendered mock(s) named `MOCKS:` in your briefing (under \
|
|
82
|
+
`tests/acceptance/ms-NN/mocks/`), if any exist. For a `tui-tuidriver` \
|
|
83
|
+
driver the `.screen` grid IS the assertion fixture (compare against it \
|
|
84
|
+
directly). For a `web-playwright` driver the `.html` file(s) are \
|
|
85
|
+
hand-authored wireframes, NOT auto-consumed fixtures — read their markup \
|
|
86
|
+
yourself and write your Playwright assertions (roles, visible text, \
|
|
87
|
+
`data-testid` locators, structure) against exactly what the mock renders. \
|
|
88
|
+
The mock is part of the contract; contract.md alone may not spell out \
|
|
89
|
+
every DOM detail.
|
|
90
|
+
Do not invent a contract yourself.
|
|
91
|
+
1c. For a `web-playwright` driver ONLY: seed your slice with a fixture \
|
|
92
|
+
file, not `page.route()`. Write `tests/acceptance/ms-NN/fixtures/<name>.json` \
|
|
93
|
+
(schema: `coord/dashboard/fixture.py`'s module docstring — same shape as \
|
|
94
|
+
`tests/fixtures/board-pipeline-basic.json`, which is a worked example) and \
|
|
95
|
+
`playwright.acceptance.config.ts` will automatically boot a real \
|
|
96
|
+
`coord web --fixture` process seeded from it for your slice (#1818) — no \
|
|
97
|
+
`page.route()` interception needed anywhere in your spec, and no dev server \
|
|
98
|
+
guesswork about which endpoints exist. Write AT MOST ONE `*.json` file under \
|
|
99
|
+
that `fixtures/` directory; the config raises if it finds more than one. \
|
|
100
|
+
(The one exception in this repo is `tests/acceptance/ms-51/` — an earlier, \
|
|
101
|
+
deliberately-unmigrated `page.route()` slice kept as-is; do not use it as a \
|
|
102
|
+
template.)
|
|
103
|
+
2. Author (or extend) the acceptance suite in `tests/acceptance/ms-NN/`, \
|
|
104
|
+
using the repo's declared driver framework (kind + run command are in your \
|
|
105
|
+
briefing) — the tests must be runnable by that exact command.
|
|
106
|
+
2b. WIRE THE SLICE IN. If your briefing names an `ENTRY POINT:` path, that \
|
|
107
|
+
file is the driver's crate root and is part of your sealed surface: your \
|
|
108
|
+
slice does NOT execute until you register it there (for a Rust/cargo \
|
|
109
|
+
driver, an `include!("../../tests/acceptance/ms-NN/<slice>.rs");` line). \
|
|
110
|
+
ADD that line. Do not rewrite, reorder, or delete anything already in the \
|
|
111
|
+
file, and register nothing beyond your own new slice files. If the briefing \
|
|
112
|
+
says the driver has no entry point, it discovers tests by directory and \
|
|
113
|
+
there is nothing to wire.
|
|
114
|
+
3. Update `tests/acceptance/ms-NN/manifest.(yml|json)` mapping every test id \
|
|
115
|
+
you added/kept to its issue number, in either accepted shape:
|
|
116
|
+
tests: {<test-id>: <issue-number>, ...}
|
|
117
|
+
or
|
|
118
|
+
issues: {<issue-number>: [<test-id>, ...], ...}
|
|
119
|
+
Merge with the existing manifest rather than clobbering it — other \
|
|
120
|
+
issues' slices may already be authored.
|
|
121
|
+
4. Your tests MUST be RED right now (the implementation doesn't exist yet). \
|
|
122
|
+
Run the driver's run command yourself and confirm the new/changed tests \
|
|
123
|
+
fail (not error out from a missing framework hookup) — a red suite that \
|
|
124
|
+
doesn't even execute is not useful to the worker who inherits it. If the \
|
|
125
|
+
run reports ZERO tests for your ids, your slice is not wired in (see 2b) — \
|
|
126
|
+
that is a failure, not a pass.
|
|
127
|
+
5. Do NOT touch anything outside `tests/acceptance/ms-NN/**`, with exactly \
|
|
128
|
+
ONE exception: the `ENTRY POINT:` file named in your briefing, and only to \
|
|
129
|
+
ADD the registration lines for your own slice (step 2b). You are not \
|
|
130
|
+
implementing, refactoring, or fixing anything else in the repo. NEVER drop \
|
|
131
|
+
the registration line to make your diff look narrower — an unwired slice is \
|
|
132
|
+
dead code that reports zero tests and fails the gate late and expensively \
|
|
133
|
+
(#1552).
|
|
134
|
+
6. Commit and push your branch. Do not open a PR — the coordinator handles \
|
|
135
|
+
that.
|
|
136
|
+
|
|
137
|
+
If the contract is ambiguous or silent on a case you think matters, don't \
|
|
138
|
+
guess: write the test with a `// TODO(test-author): contract doesn't specify
|
|
139
|
+
X` comment (or the language's equivalent) rather than inventing behavior, \
|
|
140
|
+
and call it out in your final summary.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
# #1173: the human-attended (`coord acceptance author --interactive`) variant
|
|
144
|
+
# of the system prompt above. The independence contract is UNCHANGED — same
|
|
145
|
+
# rules, same STOP-on-missing-contract, same "don't peek at the work branch"
|
|
146
|
+
# — this only tells the model an operator is attached, since that's a
|
|
147
|
+
# material fact about the session it would otherwise have no way to know.
|
|
148
|
+
TEST_AUTHOR_INTERACTIVE_SYSTEM_PROMPT = (
|
|
149
|
+
TEST_AUTHOR_SYSTEM_PROMPT
|
|
150
|
+
+ "\n\nThis session is HUMAN-ATTENDED: an operator is at the keyboard "
|
|
151
|
+
"with you, watching your output and able to redirect you. That does "
|
|
152
|
+
"NOT relax anything above — you still author from the contract alone, "
|
|
153
|
+
"with zero shared context with the implementation. The operator is "
|
|
154
|
+
"here to catch a bad contract/mock read, a stuck session, or an "
|
|
155
|
+
"ambiguous case worth discussing out loud — not to hand you "
|
|
156
|
+
"implementation details or steer you toward a specific test shape "
|
|
157
|
+
"beyond what the contract says.\n"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_author_deny_commands(config: Config, repo_name: str) -> list[str]:
|
|
162
|
+
"""Merge the repo's configured deny-list with :data:`TEST_AUTHOR_DENY_COMMANDS`.
|
|
163
|
+
|
|
164
|
+
Shared by :func:`dispatch_test_author` (the original dispatch) and
|
|
165
|
+
``coord.auto_loop._dispatch_fix`` (a bounced test-author fix, #1176) so
|
|
166
|
+
the two call sites can't drift — a fix session POSTs `type="test-author"`
|
|
167
|
+
directly to `/assign` the same way the original dispatch does, and needs
|
|
168
|
+
the identical guardrails.
|
|
169
|
+
"""
|
|
170
|
+
repo_cfg = config.repo(repo_name)
|
|
171
|
+
repo_deny = (
|
|
172
|
+
repo_cfg.worker_permissions.deny
|
|
173
|
+
if repo_cfg and repo_cfg.worker_permissions
|
|
174
|
+
else []
|
|
175
|
+
)
|
|
176
|
+
return list(dict.fromkeys(list(repo_deny) + TEST_AUTHOR_DENY_COMMANDS))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def pick_test_author_machine(
|
|
180
|
+
config: Config, repo_name: str, required_capability: str = ""
|
|
181
|
+
) -> Machine | None:
|
|
182
|
+
"""Pick a machine to run the test-author session on.
|
|
183
|
+
|
|
184
|
+
Any qualified, unpaused machine that has the repo cloned and (when the
|
|
185
|
+
repo's driver declares one) the required capability — mirrors
|
|
186
|
+
`refine_chat.pick_refinement_machine`'s "any qualified machine works"
|
|
187
|
+
simplicity; test-author is a one-off CLI-triggered dispatch (Gate A /
|
|
188
|
+
JIT), not a high-frequency auto-dispatch, so no idle/busy weighting is
|
|
189
|
+
needed. Unlike smoke, there's no "different from the worker" axis here
|
|
190
|
+
— there is no single worker machine to avoid, by design.
|
|
191
|
+
"""
|
|
192
|
+
paused = paused_set(config.machines)
|
|
193
|
+
for m in config.machines:
|
|
194
|
+
if not m.can_work_on(repo_name):
|
|
195
|
+
continue
|
|
196
|
+
if m.repo_path(repo_name) is None:
|
|
197
|
+
continue
|
|
198
|
+
if m.name in paused:
|
|
199
|
+
continue
|
|
200
|
+
if required_capability and required_capability not in m.capabilities:
|
|
201
|
+
continue
|
|
202
|
+
return m
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def build_test_author_briefing(
|
|
207
|
+
*,
|
|
208
|
+
repo_name: str,
|
|
209
|
+
repo_github: str,
|
|
210
|
+
ms_dir: str,
|
|
211
|
+
tracking_issue: int,
|
|
212
|
+
milestone_number: int,
|
|
213
|
+
milestone_issue_numbers: list[int],
|
|
214
|
+
driver_kind: str,
|
|
215
|
+
driver_run: str,
|
|
216
|
+
issue_number: int | None,
|
|
217
|
+
issue_title: str | None,
|
|
218
|
+
issue_body: str | None,
|
|
219
|
+
driver_entrypoint: str = "",
|
|
220
|
+
driver_mock: str = "",
|
|
221
|
+
) -> str:
|
|
222
|
+
"""Compose the test-author's briefing (its first/only user message).
|
|
223
|
+
|
|
224
|
+
Two modes, matching docs/ORACLE_LOOP.md's "authored red ... before the
|
|
225
|
+
work, then extended just-in-time": *milestone mode* (`issue_number` is
|
|
226
|
+
None) authors the full initial suite from the contract; *JIT mode*
|
|
227
|
+
(`issue_number` set) extends just that issue's slice.
|
|
228
|
+
|
|
229
|
+
*driver_entrypoint* (#1552) is the driver's declared ``entrypoint:`` —
|
|
230
|
+
the crate root a slice must be registered in before the run command can
|
|
231
|
+
see it, for a framework that links tests through an entry point rather
|
|
232
|
+
than discovering them by directory. Stated explicitly in BOTH directions:
|
|
233
|
+
naming it authorises the one out-of-tree write the author needs, and
|
|
234
|
+
saying "none" stops an author on a directory-discovered route (pytest)
|
|
235
|
+
from inventing one.
|
|
236
|
+
|
|
237
|
+
*driver_mock* (#1542) is the driver's declared ``mock:`` glob (e.g.
|
|
238
|
+
``"*.screen"``, ``"*.html"``) — named explicitly so the author knows
|
|
239
|
+
which files under ``mocks/`` to open. For a mock-format whose kind
|
|
240
|
+
treats the mock as source (``tui-tuidriver``'s ``.screen`` grid IS the
|
|
241
|
+
assertion fixture), this is just where to find it; for ``web-playwright``
|
|
242
|
+
the ``.html`` mock is a hand-authored wireframe the author must read and
|
|
243
|
+
transcribe into DOM assertions (see :data:`TEST_AUTHOR_SYSTEM_PROMPT`
|
|
244
|
+
step 1b) — contract.md alone is not guaranteed to spell out every
|
|
245
|
+
role/text/test-id.
|
|
246
|
+
|
|
247
|
+
For ``driver_kind == "web-playwright"`` a ``FIXTURES:`` line is also
|
|
248
|
+
emitted (#1818): ``playwright.acceptance.config.ts`` boots a real
|
|
249
|
+
``coord web --fixture`` process seeded from
|
|
250
|
+
``tests/acceptance/ms-NN/fixtures/*.json`` when present, so the author
|
|
251
|
+
should seed the slice that way instead of hand-rolling a
|
|
252
|
+
``page.route()`` interception (see :data:`TEST_AUTHOR_SYSTEM_PROMPT`
|
|
253
|
+
step 1c).
|
|
254
|
+
"""
|
|
255
|
+
contract_path = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/contract.md"
|
|
256
|
+
manifest_glob = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/manifest.(yml|json)"
|
|
257
|
+
mocks_glob = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/mocks/{driver_mock}" if driver_mock else (
|
|
258
|
+
f"{ACCEPTANCE_DIRNAME}/{ms_dir}/mocks/ (glob not declared)"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
parts: list[str] = []
|
|
262
|
+
parts.append(
|
|
263
|
+
f"=== Independent test-author session for {repo_github} — "
|
|
264
|
+
f"milestone #{milestone_number} (tracking issue #{tracking_issue}) ===\n"
|
|
265
|
+
)
|
|
266
|
+
parts.append(f"CONTRACT: {contract_path}")
|
|
267
|
+
parts.append(f"MOCKS: {mocks_glob}")
|
|
268
|
+
if driver_kind == "web-playwright":
|
|
269
|
+
parts.append(
|
|
270
|
+
f"FIXTURES: {ACCEPTANCE_DIRNAME}/{ms_dir}/fixtures/<name>.json "
|
|
271
|
+
"(at most one file — schema: coord/dashboard/fixture.py, worked "
|
|
272
|
+
"example: tests/fixtures/board-pipeline-basic.json). Seed your "
|
|
273
|
+
"slice with this instead of page.route() — #1818."
|
|
274
|
+
)
|
|
275
|
+
parts.append(f"MANIFEST: {manifest_glob}")
|
|
276
|
+
parts.append(f"DRIVER: kind={driver_kind!r} run={driver_run!r}")
|
|
277
|
+
if driver_entrypoint:
|
|
278
|
+
parts.append(f"ENTRY POINT: {driver_entrypoint}")
|
|
279
|
+
parts.append(
|
|
280
|
+
f" This driver links its slices through `{driver_entrypoint}` — "
|
|
281
|
+
"your slice files are INVISIBLE to the run command above until "
|
|
282
|
+
"they are registered there (for cargo: an "
|
|
283
|
+
f"`include!(\"../../{ACCEPTANCE_DIRNAME}/{ms_dir}/<slice>.rs\");` "
|
|
284
|
+
"line). Adding those registration lines is part of your job and "
|
|
285
|
+
"is explicitly allowed even though the file sits outside "
|
|
286
|
+
f"`{ACCEPTANCE_DIRNAME}/{ms_dir}/` — it is part of the sealed "
|
|
287
|
+
"oracle, declared as this driver's `entrypoint:` (#1552). ADD "
|
|
288
|
+
"only; do not rewrite, reorder, or delete what is already there, "
|
|
289
|
+
"and never remove a registration line to narrow your diff."
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
parts.append(
|
|
293
|
+
"ENTRY POINT: (none — this driver discovers tests by directory, "
|
|
294
|
+
"so there is nothing to wire up and nothing to touch outside "
|
|
295
|
+
f"`{ACCEPTANCE_DIRNAME}/{ms_dir}/`)"
|
|
296
|
+
)
|
|
297
|
+
parts.append(
|
|
298
|
+
f"MILESTONE WORK-ORDER ISSUES: {milestone_issue_numbers or '(none recorded yet)'}"
|
|
299
|
+
)
|
|
300
|
+
parts.append("")
|
|
301
|
+
|
|
302
|
+
if issue_number is None:
|
|
303
|
+
parts.append(
|
|
304
|
+
"MODE: full milestone authoring (Gate A). Author the initial red "
|
|
305
|
+
f"acceptance suite under `{ACCEPTANCE_DIRNAME}/{ms_dir}/` covering "
|
|
306
|
+
"the whole black-box surface in the contract, with at least one "
|
|
307
|
+
"test per issue in the work-order list above, and write the "
|
|
308
|
+
"manifest mapping every test id to its issue number."
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
parts.append(
|
|
312
|
+
f"MODE: just-in-time slice extension for issue #{issue_number}. "
|
|
313
|
+
"Extend the existing suite with tests covering ONLY this issue's "
|
|
314
|
+
"slice of the black-box surface — leave other issues' tests as "
|
|
315
|
+
"they are, and merge (don't clobber) the manifest."
|
|
316
|
+
)
|
|
317
|
+
parts.append("")
|
|
318
|
+
parts.append(f"ISSUE #{issue_number}: {issue_title or '(no title)'}")
|
|
319
|
+
parts.append(issue_body.strip() if issue_body and issue_body.strip() else "(no body)")
|
|
320
|
+
|
|
321
|
+
parts.append("")
|
|
322
|
+
parts.append("---")
|
|
323
|
+
parts.append(
|
|
324
|
+
"Follow the steps in your system prompt (read contract → author/"
|
|
325
|
+
"extend → "
|
|
326
|
+
+ ("wire into the entry point → " if driver_entrypoint else "")
|
|
327
|
+
+ "update manifest → verify red → commit + push, no PR)."
|
|
328
|
+
)
|
|
329
|
+
return "\n".join(parts)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def dispatch_test_author(
|
|
333
|
+
repo_name: str,
|
|
334
|
+
tracking_issue: int,
|
|
335
|
+
config: Config,
|
|
336
|
+
*,
|
|
337
|
+
issue_number: int | None = None,
|
|
338
|
+
machine_override: str | None = None,
|
|
339
|
+
path: str | None = None,
|
|
340
|
+
http_client: httpx.Client | None = None,
|
|
341
|
+
) -> tuple[str, str]:
|
|
342
|
+
"""End-to-end: resolve the milestone, pick a machine, seed the
|
|
343
|
+
briefing, dispatch a `type="test-author"` assignment.
|
|
344
|
+
|
|
345
|
+
*path* (#1125, repo-root-relative, e.g. ``"coord/foo.py"``) resolves
|
|
346
|
+
which driver to use when the repo's acceptance config is routed
|
|
347
|
+
(``acceptance.drivers.<repo>.routes``) — pass the milestone/issue's
|
|
348
|
+
representative subtree (see `AcceptanceConfig.driver_for` for the
|
|
349
|
+
single-path-per-call resolution rule). Unused (and unneeded) when the
|
|
350
|
+
repo has a flat, unrouted driver.
|
|
351
|
+
|
|
352
|
+
Returns `(assignment_id, machine_name)`. Raises `RuntimeError` on any
|
|
353
|
+
resolution failure (unknown repo, no acceptance driver configured (or,
|
|
354
|
+
for a routed repo, no driver resolves for *path*), bad tracking issue,
|
|
355
|
+
`issue_number` not a member of the milestone's work order, no qualified
|
|
356
|
+
machine, or the agent rejecting the dispatch) — EXCEPT the #2063 Gate-A
|
|
357
|
+
sign-off refusal (contract exists but carries no recorded human
|
|
358
|
+
verdict), which raises `coord.dispatch.DispatchRefused` (a `ValueError`
|
|
359
|
+
subclass) instead, so callers can tell a deterministic, operator-fixable
|
|
360
|
+
refusal apart from every other, non-recoverable failure mode here.
|
|
361
|
+
|
|
362
|
+
Branch: milestone mode (`issue_number=None`) shares one branch/PR across
|
|
363
|
+
repeated calls, keyed on *tracking_issue*. JIT mode (`issue_number` set)
|
|
364
|
+
gets its own per-slice branch keyed on `(tracking_issue, issue_number)`
|
|
365
|
+
— required so each member issue's slice can merge independently without
|
|
366
|
+
stranding the next slice on an already-closed PR (#1171).
|
|
367
|
+
"""
|
|
368
|
+
repo_cfg = config.repo(repo_name)
|
|
369
|
+
if repo_cfg is None:
|
|
370
|
+
raise RuntimeError(f"repo {repo_name!r} not in coordinator.yml")
|
|
371
|
+
|
|
372
|
+
driver_cfg = config.acceptance.driver_for(repo_name, path)
|
|
373
|
+
if driver_cfg is None:
|
|
374
|
+
if config.acceptance.has_driver(repo_name):
|
|
375
|
+
raise RuntimeError(
|
|
376
|
+
f"repo {repo_name!r} has a routed acceptance driver "
|
|
377
|
+
"(acceptance.drivers routes) but no route matched — pass "
|
|
378
|
+
"--for-path to select the milestone's subtree (e.g. "
|
|
379
|
+
"'coord/**')"
|
|
380
|
+
)
|
|
381
|
+
raise RuntimeError(
|
|
382
|
+
f"no acceptance driver configured for repo {repo_name!r} "
|
|
383
|
+
"(add it under acceptance.drivers in coordinator.yml)"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
ctx = fetch_milestone_context(repo_cfg, tracking_issue)
|
|
388
|
+
except MilestoneDispatchError as e:
|
|
389
|
+
raise RuntimeError(str(e)) from e
|
|
390
|
+
|
|
391
|
+
if issue_number is not None and ctx.work_order.node(issue_number) is None:
|
|
392
|
+
raise RuntimeError(
|
|
393
|
+
f"issue #{issue_number} is not a member of milestone "
|
|
394
|
+
f"#{ctx.milestone_number}'s work order (tracking issue #{tracking_issue})"
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# #2063: refuse to author a sealed slice against a contract no human has
|
|
398
|
+
# signed off on. This is the path that actually burned money on
|
|
399
|
+
# coord-portal ms-2 — an independent test-author authored ~$2.70 of
|
|
400
|
+
# sealed suite against an unapproved surface, and it happened to be a
|
|
401
|
+
# good contract by luck, not process. The strings a contract pins
|
|
402
|
+
# (button text, data-testid hooks, status vocabulary) become assertions
|
|
403
|
+
# the worker may never edit, so a wrong one is not cosmetic: it is a
|
|
404
|
+
# suite that enforces the wrong product.
|
|
405
|
+
#
|
|
406
|
+
# Raised as `DispatchRefused` (a `ValueError` subclass), NOT a plain
|
|
407
|
+
# `RuntimeError` like every other failure mode in this function: this
|
|
408
|
+
# refusal is deterministic and operator-fixable (`coord gate-a
|
|
409
|
+
# --approved`), not a crash, and `coord acceptance author`'s CLI handler
|
|
410
|
+
# maps THIS exception — and only this one — to `EXIT_DISPATCH_REFUSED`
|
|
411
|
+
# so `coord drive-queue`'s tick can park the entry (#1891/#1892) instead
|
|
412
|
+
# of burning attempts toward terminal `blocked` (#2040) on an unapproved
|
|
413
|
+
# contract. Same distinguishing pattern `enforce_oracle_readiness` (the
|
|
414
|
+
# Work-dispatch guard this mirrors) already uses in `coord/dispatch.py`.
|
|
415
|
+
signoff_refusal = gate_a_signoff_status(repo_cfg, config, ctx.milestone_number)
|
|
416
|
+
if signoff_refusal is not None:
|
|
417
|
+
raise DispatchRefused(signoff_refusal)
|
|
418
|
+
|
|
419
|
+
if machine_override:
|
|
420
|
+
machine = next(
|
|
421
|
+
(m for m in config.machines if m.name == machine_override), None
|
|
422
|
+
)
|
|
423
|
+
if machine is None:
|
|
424
|
+
raise RuntimeError(f"machine {machine_override!r} not in coordinator.yml")
|
|
425
|
+
if not machine.can_work_on(repo_name):
|
|
426
|
+
raise RuntimeError(
|
|
427
|
+
f"machine {machine_override!r} does not list repo {repo_name!r}"
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
machine = pick_test_author_machine(config, repo_name, driver_cfg.capability)
|
|
431
|
+
if machine is None:
|
|
432
|
+
cap_note = (
|
|
433
|
+
f" with capability {driver_cfg.capability!r}"
|
|
434
|
+
if driver_cfg.capability else ""
|
|
435
|
+
)
|
|
436
|
+
raise RuntimeError(
|
|
437
|
+
f"no machine claims repo {repo_name!r}{cap_note} — "
|
|
438
|
+
"test-author needs a machine with the repo cloned"
|
|
439
|
+
+ (" and the driver's required capability" if cap_note else "")
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
repo_path = machine.repo_path(repo_name)
|
|
443
|
+
if repo_path is None:
|
|
444
|
+
raise RuntimeError(
|
|
445
|
+
f"machine {machine.name!r} has no repo_path for {repo_name!r}"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
issue_title: str | None = None
|
|
449
|
+
issue_body: str | None = None
|
|
450
|
+
if issue_number is not None:
|
|
451
|
+
try:
|
|
452
|
+
issue_data = github_ops.get_issue(repo_cfg.github, issue_number)
|
|
453
|
+
except RuntimeError as e:
|
|
454
|
+
raise RuntimeError(f"could not fetch #{issue_number}: {e}") from e
|
|
455
|
+
issue_title = issue_data.get("title") or ""
|
|
456
|
+
issue_body = issue_data.get("body") or ""
|
|
457
|
+
|
|
458
|
+
ms_dir = f"ms-{ctx.milestone_number}"
|
|
459
|
+
briefing = build_test_author_briefing(
|
|
460
|
+
repo_name=repo_name,
|
|
461
|
+
repo_github=repo_cfg.github,
|
|
462
|
+
ms_dir=ms_dir,
|
|
463
|
+
tracking_issue=tracking_issue,
|
|
464
|
+
milestone_number=ctx.milestone_number,
|
|
465
|
+
milestone_issue_numbers=list(ctx.work_order.issue_numbers),
|
|
466
|
+
driver_kind=driver_cfg.kind,
|
|
467
|
+
driver_run=driver_cfg.run,
|
|
468
|
+
driver_entrypoint=driver_cfg.entrypoint,
|
|
469
|
+
driver_mock=driver_cfg.mock,
|
|
470
|
+
issue_number=issue_number,
|
|
471
|
+
issue_title=issue_title,
|
|
472
|
+
issue_body=issue_body,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# #1171: milestone-mode dispatches (issue_number is None) keep a single
|
|
476
|
+
# FIXED assignment title so repeated calls derive the SAME shared branch
|
|
477
|
+
# (issue-{tracking_issue}-{slug(title)}, see AgentServer._setup_worktree)
|
|
478
|
+
# — that's Gate A's "extend the same in-flight suite" case, and the
|
|
479
|
+
# branch's PR does not merge until the whole milestone's suite is ready.
|
|
480
|
+
#
|
|
481
|
+
# JIT slices (issue_number set) must NOT reuse that shared branch: each
|
|
482
|
+
# member issue's slice needs its own PR because #1138's oracle gate
|
|
483
|
+
# merges it to unblock that issue's own Work — so the shared branch's PR
|
|
484
|
+
# is guaranteed closed before the next slice is authored, silently
|
|
485
|
+
# stranding it (#1171). Key the branch on (milestone, member issue)
|
|
486
|
+
# instead via an explicit `target_branch`, deliberately OUTSIDE the
|
|
487
|
+
# `issue-{N}-*` namespace: if this PR is squash-merged (ancestry breaks)
|
|
488
|
+
# and deleteBranchOnMerge=false lets the branch survive, an
|
|
489
|
+
# `issue-{issue_number}-*` name would false-positive `coord.claim`'s
|
|
490
|
+
# remote-branch check for that same member issue's own Work dispatch —
|
|
491
|
+
# re-wedging the exact stall this fix removes. A retry/continuation for
|
|
492
|
+
# the SAME (tracking_issue, issue_number) pair still resolves to the
|
|
493
|
+
# same branch name, so extending an already-authored slice keeps
|
|
494
|
+
# pushing to its own still-open PR rather than forking a new one.
|
|
495
|
+
if issue_number is None:
|
|
496
|
+
assignment_title = f"[test-author] ms-{ctx.milestone_number} acceptance suite"
|
|
497
|
+
target_branch: str | None = None
|
|
498
|
+
else:
|
|
499
|
+
assignment_title = (
|
|
500
|
+
f"[test-author] ms-{ctx.milestone_number} slice #{issue_number}"
|
|
501
|
+
)
|
|
502
|
+
target_branch = f"test-author-ms-{ctx.milestone_number}-slice-{issue_number}"
|
|
503
|
+
|
|
504
|
+
# #1172: resolve the branch this dispatch would push onto UP FRONT (same
|
|
505
|
+
# formula `AgentServer._setup_worktree` / the post-dispatch recording
|
|
506
|
+
# below use) and fail loudly if its PR has already merged, instead of
|
|
507
|
+
# silently dispatching a worker whose commits would land on a dead
|
|
508
|
+
# branch with no open PR for review/merge to ever pick up (#947/#1115 —
|
|
509
|
+
# a day-long invisible strand). This is defence-in-depth on top of
|
|
510
|
+
# #1171's branch-per-slice fix: it also catches a *retry* of the SAME
|
|
511
|
+
# (tracking_issue, issue_number) pair after that slice's own PR already
|
|
512
|
+
# merged (e.g. via #1138's oracle gate), and a stale milestone-mode
|
|
513
|
+
# dispatch after Gate A's shared-suite PR merged out from under it.
|
|
514
|
+
from coord.agent import _slugify # noqa: PLC0415
|
|
515
|
+
|
|
516
|
+
branch = target_branch or f"issue-{tracking_issue}-{_slugify(assignment_title)}"
|
|
517
|
+
|
|
518
|
+
if github_ops.pr_is_merged(repo_cfg.github, branch):
|
|
519
|
+
raise RuntimeError(
|
|
520
|
+
f"branch {branch!r} already has a merged PR — dispatching would "
|
|
521
|
+
"push new commits onto a dead branch with nothing left to open "
|
|
522
|
+
"a PR against them (#1172). "
|
|
523
|
+
+ (
|
|
524
|
+
f"Issue #{issue_number}'s JIT slice already landed; if it "
|
|
525
|
+
"needs more tests, this needs a fresh branch (not "
|
|
526
|
+
"auto-forked yet) — do not retry as-is."
|
|
527
|
+
if issue_number is not None else
|
|
528
|
+
"The milestone's Gate-A suite PR already merged; if the "
|
|
529
|
+
"suite needs more work, open a fresh branch by hand — do "
|
|
530
|
+
"not retry as-is."
|
|
531
|
+
)
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
deny_commands = test_author_deny_commands(config, repo_name)
|
|
535
|
+
|
|
536
|
+
payload = {
|
|
537
|
+
"repo_name": repo_name,
|
|
538
|
+
"repo_path": repo_path,
|
|
539
|
+
"issue_number": tracking_issue,
|
|
540
|
+
"issue_title": assignment_title,
|
|
541
|
+
"briefing": briefing,
|
|
542
|
+
"files_allowed": [],
|
|
543
|
+
"files_forbidden": [],
|
|
544
|
+
"pull_repos": [],
|
|
545
|
+
"type": "test-author",
|
|
546
|
+
"system_prompt": TEST_AUTHOR_SYSTEM_PROMPT,
|
|
547
|
+
"deny_commands": deny_commands,
|
|
548
|
+
"branch": repo_cfg.default_branch or "main",
|
|
549
|
+
}
|
|
550
|
+
if target_branch:
|
|
551
|
+
payload["target_branch"] = target_branch
|
|
552
|
+
|
|
553
|
+
url = f"http://{machine.host}:{AGENT_PORT}/assign"
|
|
554
|
+
client = http_client or httpx
|
|
555
|
+
resp = client.post(url, json=payload, timeout=15)
|
|
556
|
+
resp.raise_for_status()
|
|
557
|
+
agent_response = resp.json()
|
|
558
|
+
|
|
559
|
+
assignment_id = agent_response.get("id") or uuid.uuid4().hex[:12]
|
|
560
|
+
|
|
561
|
+
# #1171: record the deterministic branch name up front (mirrors #706's
|
|
562
|
+
# `state._record_dispatched_local`) instead of leaving it NULL for
|
|
563
|
+
# `reconcile.py`'s `issue-{tracking_issue}-*` backfill sweep (#1083) to
|
|
564
|
+
# guess later — that sweep's prefix search can never find a JIT slice's
|
|
565
|
+
# `target_branch` (deliberately outside the `issue-{N}-*` namespace, see
|
|
566
|
+
# above), so the branch must be known at dispatch time here. (`branch`
|
|
567
|
+
# itself was already resolved above, ahead of the POST, for the #1172
|
|
568
|
+
# merged-PR guard.)
|
|
569
|
+
|
|
570
|
+
asg = Assignment(
|
|
571
|
+
machine_name=machine.name,
|
|
572
|
+
repo_name=repo_name,
|
|
573
|
+
issue_number=tracking_issue,
|
|
574
|
+
issue_title=assignment_title,
|
|
575
|
+
files_allowed=[],
|
|
576
|
+
files_forbidden=[],
|
|
577
|
+
briefing=briefing,
|
|
578
|
+
assignment_id=assignment_id,
|
|
579
|
+
status="running",
|
|
580
|
+
dispatched_at=time.time(),
|
|
581
|
+
branch=branch,
|
|
582
|
+
type="test-author",
|
|
583
|
+
# #1084: correlate this JIT dispatch back to the specific member
|
|
584
|
+
# issue it's extending, so the TUI's per-issue Acceptance-Authoring
|
|
585
|
+
# mini-pipeline can tell "issue #1039's slice" apart from a sibling
|
|
586
|
+
# issue's slice sharing the same tracking-issue-keyed assignment
|
|
587
|
+
# row. None in milestone mode (issue_number is None) — Gate A's own
|
|
588
|
+
# mock-author track doesn't need this field.
|
|
589
|
+
for_issue_number=issue_number,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
from coord.state import record_dispatched_assignment # noqa: PLC0415
|
|
593
|
+
|
|
594
|
+
record_dispatched_assignment(assignment=asg, repo_github=repo_cfg.github)
|
|
595
|
+
|
|
596
|
+
return assignment_id, machine.name
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def dispatch_test_author_interactive(
|
|
600
|
+
repo_name: str,
|
|
601
|
+
tracking_issue: int,
|
|
602
|
+
config: Config,
|
|
603
|
+
*,
|
|
604
|
+
issue_number: int | None = None,
|
|
605
|
+
machine_override: str | None = None,
|
|
606
|
+
path: str | None = None,
|
|
607
|
+
dry_run: bool = False,
|
|
608
|
+
) -> int:
|
|
609
|
+
"""Human-attended counterpart to :func:`dispatch_test_author` (#1173,
|
|
610
|
+
``coord acceptance author --interactive``).
|
|
611
|
+
|
|
612
|
+
Reuses the SAME interactive-launch machinery every other attended stage
|
|
613
|
+
uses, instead of rebuilding any of it:
|
|
614
|
+
|
|
615
|
+
* :func:`coord.commands.dispatch._build_interactive_launch_setup` for the
|
|
616
|
+
shared ``ClaudePtyProvider`` / local-vs-remote detection / per-issue
|
|
617
|
+
context digest (#603) that every ``coord assign --interactive`` flavour
|
|
618
|
+
shares — including its #2086 ``_require_interactive_tty`` gate, which
|
|
619
|
+
refuses to claim/record this assignment when stdin is not a TTY (and
|
|
620
|
+
this isn't a dry run), the same protection ``coord assign
|
|
621
|
+
--interactive`` gets.
|
|
622
|
+
* :func:`coord.agent.setup_interactive_worktree` (local) / a raw
|
|
623
|
+
``git worktree add`` shell command over ssh+tmux (remote) — the same
|
|
624
|
+
primitives :func:`~coord.commands.dispatch_workers._dispatch_rework_of`
|
|
625
|
+
uses to land on a NAMED branch rather than a fresh one.
|
|
626
|
+
* :func:`coord.interactive.launch_human_attended_interactive` /
|
|
627
|
+
:func:`~coord.interactive.finalize_interactive_exit` /
|
|
628
|
+
:func:`~coord.interactive.finalize_remote_interactive_exit` for the
|
|
629
|
+
actual PTY/tmux attach and the #466 git-floor completion backstop.
|
|
630
|
+
|
|
631
|
+
The session lands on the EXACT same derived branch a headless dispatch
|
|
632
|
+
of the same milestone/JIT slice would use — ``issue-{tracking_issue}-
|
|
633
|
+
{slug(assignment_title)}``, the same derivation
|
|
634
|
+
:meth:`coord.agent.AgentServer._setup_worktree` uses for
|
|
635
|
+
``type="test-author"`` — so headless and interactive test-author
|
|
636
|
+
dispatches continue the SAME branch/PR rather than forking one each.
|
|
637
|
+
|
|
638
|
+
Keeps the independence seal unchanged: the briefing content is built by
|
|
639
|
+
the SAME :func:`build_test_author_briefing` the headless path uses, and
|
|
640
|
+
the system prompt is :data:`TEST_AUTHOR_INTERACTIVE_SYSTEM_PROMPT` — the
|
|
641
|
+
headless prompt plus a one-paragraph "an operator is watching" note.
|
|
642
|
+
``--interactive`` changes who supervises the authoring, never who writes
|
|
643
|
+
the tests or what they may read.
|
|
644
|
+
|
|
645
|
+
Records a ``type="test-author"`` :class:`~coord.models.Assignment` with
|
|
646
|
+
``provider_name="claude-pty"`` BEFORE launching (mirrors every other
|
|
647
|
+
interactive flavour) so the board always reflects the in-flight session.
|
|
648
|
+
That field is also what keeps this row out of automatic headless review
|
|
649
|
+
dispatch (#555's generic ``provider_name != "claude-pty"`` guard in
|
|
650
|
+
:func:`coord.review.dispatch_pending_reviews` — ``test-author`` is
|
|
651
|
+
already in :data:`coord.models.WORK_LIKE_TYPES`, so no type-specific
|
|
652
|
+
exclusion was needed). The explicit human-attended handoffs
|
|
653
|
+
(``coord review``, ``coord assign --interactive --review-of/--merge-of``)
|
|
654
|
+
pick this row up exactly like an interactive ``work`` completion — same
|
|
655
|
+
board membership, same ``.branch`` — so there's no new stall to plumb
|
|
656
|
+
around, only the same "human drives Test→Review→Merge" path #1173 asks
|
|
657
|
+
for.
|
|
658
|
+
|
|
659
|
+
Returns the child process's exit code (``0`` on a clean exit, or on a
|
|
660
|
+
dry run). Raises :class:`RuntimeError` on any resolution failure — the
|
|
661
|
+
same failure modes as :func:`dispatch_test_author` (unknown repo/driver/
|
|
662
|
+
milestone/issue-membership/machine), plus a worktree/remote-launch setup
|
|
663
|
+
failure (mirrors ``--rework-of``'s #618 failure-reason backstop: the
|
|
664
|
+
reason is recorded on the assignment row before the error is raised, so
|
|
665
|
+
the TUI can explain a red box with no log file) — EXCEPT the #2063
|
|
666
|
+
Gate-A sign-off refusal, which (as in :func:`dispatch_test_author`)
|
|
667
|
+
raises :class:`coord.dispatch.DispatchRefused` instead, so `coord
|
|
668
|
+
acceptance author --interactive`'s CLI handler can map it to
|
|
669
|
+
``EXIT_DISPATCH_REFUSED``.
|
|
670
|
+
"""
|
|
671
|
+
from coord.agent import ( # noqa: PLC0415
|
|
672
|
+
AssignmentSpec,
|
|
673
|
+
_GitError,
|
|
674
|
+
_slugify,
|
|
675
|
+
setup_interactive_worktree,
|
|
676
|
+
)
|
|
677
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
678
|
+
TmuxHost,
|
|
679
|
+
_launch_via_tmux,
|
|
680
|
+
finalize_interactive_exit,
|
|
681
|
+
finalize_remote_interactive_exit,
|
|
682
|
+
launch_human_attended_interactive,
|
|
683
|
+
tmux_available,
|
|
684
|
+
tmux_session_alive,
|
|
685
|
+
tmux_session_name,
|
|
686
|
+
)
|
|
687
|
+
from coord.state import ( # noqa: PLC0415
|
|
688
|
+
build_board,
|
|
689
|
+
record_dispatched_assignment,
|
|
690
|
+
save_board,
|
|
691
|
+
set_assignment_failure_reason,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
repo_cfg = config.repo(repo_name)
|
|
695
|
+
if repo_cfg is None:
|
|
696
|
+
raise RuntimeError(f"repo {repo_name!r} not in coordinator.yml")
|
|
697
|
+
|
|
698
|
+
driver_cfg = config.acceptance.driver_for(repo_name, path)
|
|
699
|
+
if driver_cfg is None:
|
|
700
|
+
if config.acceptance.has_driver(repo_name):
|
|
701
|
+
raise RuntimeError(
|
|
702
|
+
f"repo {repo_name!r} has a routed acceptance driver "
|
|
703
|
+
"(acceptance.drivers routes) but no route matched — pass "
|
|
704
|
+
"--for-path to select the milestone's subtree (e.g. "
|
|
705
|
+
"'coord/**')"
|
|
706
|
+
)
|
|
707
|
+
raise RuntimeError(
|
|
708
|
+
f"no acceptance driver configured for repo {repo_name!r} "
|
|
709
|
+
"(add it under acceptance.drivers in coordinator.yml)"
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
try:
|
|
713
|
+
ctx = fetch_milestone_context(repo_cfg, tracking_issue)
|
|
714
|
+
except MilestoneDispatchError as e:
|
|
715
|
+
raise RuntimeError(str(e)) from e
|
|
716
|
+
|
|
717
|
+
if issue_number is not None and ctx.work_order.node(issue_number) is None:
|
|
718
|
+
raise RuntimeError(
|
|
719
|
+
f"issue #{issue_number} is not a member of milestone "
|
|
720
|
+
f"#{ctx.milestone_number}'s work order (tracking issue #{tracking_issue})"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
# #2063: refuse to author a sealed slice against a contract no human has
|
|
724
|
+
# signed off on. This is the path that actually burned money on
|
|
725
|
+
# coord-portal ms-2 — an independent test-author authored ~$2.70 of
|
|
726
|
+
# sealed suite against an unapproved surface, and it happened to be a
|
|
727
|
+
# good contract by luck, not process. The strings a contract pins
|
|
728
|
+
# (button text, data-testid hooks, status vocabulary) become assertions
|
|
729
|
+
# the worker may never edit, so a wrong one is not cosmetic: it is a
|
|
730
|
+
# suite that enforces the wrong product.
|
|
731
|
+
#
|
|
732
|
+
# Raised as `DispatchRefused` (a `ValueError` subclass), NOT a plain
|
|
733
|
+
# `RuntimeError` like every other failure mode in this function: this
|
|
734
|
+
# refusal is deterministic and operator-fixable (`coord gate-a
|
|
735
|
+
# --approved`), not a crash, and `coord acceptance author`'s CLI handler
|
|
736
|
+
# maps THIS exception — and only this one — to `EXIT_DISPATCH_REFUSED`
|
|
737
|
+
# so `coord drive-queue`'s tick can park the entry (#1891/#1892) instead
|
|
738
|
+
# of burning attempts toward terminal `blocked` (#2040) on an unapproved
|
|
739
|
+
# contract. Same distinguishing pattern `enforce_oracle_readiness` (the
|
|
740
|
+
# Work-dispatch guard this mirrors) already uses in `coord/dispatch.py`.
|
|
741
|
+
signoff_refusal = gate_a_signoff_status(repo_cfg, config, ctx.milestone_number)
|
|
742
|
+
if signoff_refusal is not None:
|
|
743
|
+
raise DispatchRefused(signoff_refusal)
|
|
744
|
+
|
|
745
|
+
if machine_override:
|
|
746
|
+
machine = next(
|
|
747
|
+
(m for m in config.machines if m.name == machine_override), None
|
|
748
|
+
)
|
|
749
|
+
if machine is None:
|
|
750
|
+
raise RuntimeError(f"machine {machine_override!r} not in coordinator.yml")
|
|
751
|
+
if not machine.can_work_on(repo_name):
|
|
752
|
+
raise RuntimeError(
|
|
753
|
+
f"machine {machine_override!r} does not list repo {repo_name!r}"
|
|
754
|
+
)
|
|
755
|
+
else:
|
|
756
|
+
machine = pick_test_author_machine(config, repo_name, driver_cfg.capability)
|
|
757
|
+
if machine is None:
|
|
758
|
+
cap_note = (
|
|
759
|
+
f" with capability {driver_cfg.capability!r}"
|
|
760
|
+
if driver_cfg.capability else ""
|
|
761
|
+
)
|
|
762
|
+
raise RuntimeError(
|
|
763
|
+
f"no machine claims repo {repo_name!r}{cap_note} — "
|
|
764
|
+
"test-author needs a machine with the repo cloned"
|
|
765
|
+
+ (" and the driver's required capability" if cap_note else "")
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
repo_path_cfg = machine.repo_path(repo_name)
|
|
769
|
+
if repo_path_cfg is None:
|
|
770
|
+
raise RuntimeError(
|
|
771
|
+
f"machine {machine.name!r} has no repo_path for {repo_name!r}"
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
issue_title: str | None = None
|
|
775
|
+
issue_body: str | None = None
|
|
776
|
+
if issue_number is not None:
|
|
777
|
+
try:
|
|
778
|
+
issue_data = github_ops.get_issue(repo_cfg.github, issue_number)
|
|
779
|
+
except RuntimeError as e:
|
|
780
|
+
raise RuntimeError(f"could not fetch #{issue_number}: {e}") from e
|
|
781
|
+
issue_title = issue_data.get("title") or ""
|
|
782
|
+
issue_body = issue_data.get("body") or ""
|
|
783
|
+
|
|
784
|
+
ms_dir = f"ms-{ctx.milestone_number}"
|
|
785
|
+
briefing = build_test_author_briefing(
|
|
786
|
+
repo_name=repo_name,
|
|
787
|
+
repo_github=repo_cfg.github,
|
|
788
|
+
ms_dir=ms_dir,
|
|
789
|
+
tracking_issue=tracking_issue,
|
|
790
|
+
milestone_number=ctx.milestone_number,
|
|
791
|
+
milestone_issue_numbers=list(ctx.work_order.issue_numbers),
|
|
792
|
+
driver_kind=driver_cfg.kind,
|
|
793
|
+
driver_run=driver_cfg.run,
|
|
794
|
+
driver_entrypoint=driver_cfg.entrypoint,
|
|
795
|
+
driver_mock=driver_cfg.mock,
|
|
796
|
+
issue_number=issue_number,
|
|
797
|
+
issue_title=issue_title,
|
|
798
|
+
issue_body=issue_body,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
# Same FIXED title as the headless dispatch (see dispatch_test_author's
|
|
802
|
+
# comment above `assignment_title`) so both modes derive the SAME
|
|
803
|
+
# branch name and continue the same branch/PR across repeated dispatches.
|
|
804
|
+
assignment_title = f"[test-author] ms-{ctx.milestone_number} acceptance suite"
|
|
805
|
+
repo_deny = repo_cfg.worker_permissions.deny if repo_cfg.worker_permissions else []
|
|
806
|
+
deny_commands = list(dict.fromkeys(list(repo_deny) + TEST_AUTHOR_DENY_COMMANDS))
|
|
807
|
+
default_branch = repo_cfg.default_branch or "main"
|
|
808
|
+
branch_name = f"issue-{tracking_issue}-{_slugify(assignment_title)}"
|
|
809
|
+
|
|
810
|
+
from coord.commands.dispatch import _build_interactive_launch_setup # noqa: PLC0415
|
|
811
|
+
|
|
812
|
+
setup = _build_interactive_launch_setup(
|
|
813
|
+
machine=machine.name, repo=repo_name, issue=tracking_issue, machine_obj=machine,
|
|
814
|
+
dry_run=dry_run,
|
|
815
|
+
)
|
|
816
|
+
provider = setup.provider
|
|
817
|
+
is_local = setup.is_local
|
|
818
|
+
issue_ctx = setup.issue_ctx
|
|
819
|
+
svc = setup.svc
|
|
820
|
+
|
|
821
|
+
if is_local:
|
|
822
|
+
ta_repo_path = str(Path(repo_path_cfg).expanduser())
|
|
823
|
+
else:
|
|
824
|
+
ta_repo_path = repo_path_cfg
|
|
825
|
+
|
|
826
|
+
resolved_model = config.models.default
|
|
827
|
+
assignment_id = uuid.uuid4().hex[:12]
|
|
828
|
+
|
|
829
|
+
scope = f"issue #{issue_number} slice" if issue_number is not None else "full milestone"
|
|
830
|
+
report_reminder = (
|
|
831
|
+
f"[Coordinator test-author assignment {assignment_id}] HUMAN-ATTENDED "
|
|
832
|
+
f"interactive test-authoring ({scope}) for {repo_cfg.github} milestone "
|
|
833
|
+
f"#{ctx.milestone_number} (tracking issue #{tracking_issue}). Before "
|
|
834
|
+
f"you exit, run `coord report-result --assignment {assignment_id} "
|
|
835
|
+
"--status <done|blocked> --summary <text>` so the coordinator "
|
|
836
|
+
"records the result.\n\n"
|
|
837
|
+
)
|
|
838
|
+
effective_briefing = issue_ctx + report_reminder + briefing
|
|
839
|
+
|
|
840
|
+
spec = AssignmentSpec(
|
|
841
|
+
repo_name=repo_name,
|
|
842
|
+
repo_path=ta_repo_path,
|
|
843
|
+
issue_number=tracking_issue,
|
|
844
|
+
issue_title=assignment_title,
|
|
845
|
+
briefing=effective_briefing,
|
|
846
|
+
model=resolved_model,
|
|
847
|
+
type="test-author",
|
|
848
|
+
provider="claude-pty",
|
|
849
|
+
system_prompt=TEST_AUTHOR_INTERACTIVE_SYSTEM_PROMPT,
|
|
850
|
+
deny_commands=deny_commands,
|
|
851
|
+
)
|
|
852
|
+
argv = provider.build_command(spec, resolved_model=resolved_model)
|
|
853
|
+
# Remote: a bare "claude" is not on the SSH login PATH (#424/#425).
|
|
854
|
+
if not is_local:
|
|
855
|
+
argv = ["~/.local/bin/claude"] + list(argv)[1:]
|
|
856
|
+
|
|
857
|
+
location = "local TTY" if is_local else f"{machine.host} (remote tmux)"
|
|
858
|
+
click.echo(
|
|
859
|
+
f"{machine.name} ({location}) → TEST-AUTHOR for {repo_cfg.github} "
|
|
860
|
+
f"milestone #{ctx.milestone_number} (tracking issue #{tracking_issue}, "
|
|
861
|
+
f"{scope})"
|
|
862
|
+
)
|
|
863
|
+
click.echo(" mode: HUMAN-ATTENDED interactive test-authoring (#1173)")
|
|
864
|
+
click.echo(f" assignment id: {assignment_id} (branch: {branch_name})")
|
|
865
|
+
|
|
866
|
+
if dry_run:
|
|
867
|
+
click.echo(" (dry run — not launched)")
|
|
868
|
+
click.echo(f" would exec: {argv}")
|
|
869
|
+
return 0
|
|
870
|
+
|
|
871
|
+
ta_assignment = Assignment(
|
|
872
|
+
machine_name=machine.name,
|
|
873
|
+
repo_name=repo_name,
|
|
874
|
+
issue_number=tracking_issue,
|
|
875
|
+
issue_title=assignment_title,
|
|
876
|
+
briefing=effective_briefing,
|
|
877
|
+
assignment_id=assignment_id,
|
|
878
|
+
status="running",
|
|
879
|
+
branch=branch_name,
|
|
880
|
+
dispatched_at=time.time(),
|
|
881
|
+
type="test-author",
|
|
882
|
+
for_issue_number=issue_number,
|
|
883
|
+
model=resolved_model,
|
|
884
|
+
provider_name="claude-pty",
|
|
885
|
+
)
|
|
886
|
+
record_dispatched_assignment(assignment=ta_assignment, repo_github=repo_cfg.github)
|
|
887
|
+
if svc is None:
|
|
888
|
+
save_board(build_board())
|
|
889
|
+
os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
|
|
890
|
+
|
|
891
|
+
if is_local:
|
|
892
|
+
try:
|
|
893
|
+
wt_path, _ = setup_interactive_worktree(
|
|
894
|
+
Path(ta_repo_path),
|
|
895
|
+
issue_number=tracking_issue,
|
|
896
|
+
issue_title=assignment_title,
|
|
897
|
+
assignment_id=assignment_id,
|
|
898
|
+
default_branch=default_branch,
|
|
899
|
+
existing_branch=branch_name,
|
|
900
|
+
)
|
|
901
|
+
worktree_path = str(wt_path)
|
|
902
|
+
except (_GitError, OSError) as wt_err:
|
|
903
|
+
reason = f"worktree-add failed for branch {branch_name}: {wt_err}"
|
|
904
|
+
click.echo(f" error: {reason}", err=True)
|
|
905
|
+
set_assignment_failure_reason(assignment_id, reason)
|
|
906
|
+
raise RuntimeError(reason) from wt_err
|
|
907
|
+
click.echo(f" worktree: {worktree_path} (branch: {branch_name})")
|
|
908
|
+
|
|
909
|
+
started_at = time.time()
|
|
910
|
+
exit_code = launch_human_attended_interactive(
|
|
911
|
+
argv, effective_briefing, assignment_id=assignment_id, cwd=worktree_path,
|
|
912
|
+
)
|
|
913
|
+
if exit_code != 0:
|
|
914
|
+
click.echo(f" claude exited with status {exit_code}", err=True)
|
|
915
|
+
|
|
916
|
+
sname = tmux_session_name(assignment_id) if tmux_available() else None
|
|
917
|
+
if sname and tmux_session_alive(sname):
|
|
918
|
+
click.echo(
|
|
919
|
+
f" session still running in tmux: {sname}\n"
|
|
920
|
+
f" reattach with: coord reattach {assignment_id}"
|
|
921
|
+
)
|
|
922
|
+
return 0
|
|
923
|
+
|
|
924
|
+
try:
|
|
925
|
+
finalize_result = finalize_interactive_exit(
|
|
926
|
+
assignment_id=assignment_id,
|
|
927
|
+
repo_name=repo_name,
|
|
928
|
+
repo_github=repo_cfg.github,
|
|
929
|
+
issue_number=tracking_issue,
|
|
930
|
+
machine_name=machine.name,
|
|
931
|
+
worktree_path=worktree_path,
|
|
932
|
+
base_branch=default_branch,
|
|
933
|
+
exit_code=exit_code,
|
|
934
|
+
started_at=started_at,
|
|
935
|
+
log_path=None,
|
|
936
|
+
repo_path=ta_repo_path,
|
|
937
|
+
branch=branch_name,
|
|
938
|
+
)
|
|
939
|
+
if finalize_result.already_recorded:
|
|
940
|
+
click.echo(
|
|
941
|
+
" result recorded via `coord report-result`; backstop "
|
|
942
|
+
"did not overwrite"
|
|
943
|
+
)
|
|
944
|
+
else:
|
|
945
|
+
click.echo(
|
|
946
|
+
f" backstop: status={finalize_result.terminal_status} "
|
|
947
|
+
f"commits_ahead={finalize_result.commits_ahead}"
|
|
948
|
+
)
|
|
949
|
+
if not finalize_result.push_ok:
|
|
950
|
+
click.echo(
|
|
951
|
+
f" warning: git push failed: {finalize_result.push_error}",
|
|
952
|
+
err=True,
|
|
953
|
+
)
|
|
954
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
955
|
+
click.echo(
|
|
956
|
+
f" warning: backstop failed to record test-author exit: {exc}",
|
|
957
|
+
err=True,
|
|
958
|
+
)
|
|
959
|
+
return exit_code
|
|
960
|
+
|
|
961
|
+
# ── REMOTE (#1173) ──────────────────────────────────────────────────
|
|
962
|
+
# Mirrors _dispatch_rework_of's remote shape (named-branch continuation
|
|
963
|
+
# + finalize_remote_interactive_exit) but WITHOUT its holder-detection
|
|
964
|
+
# retry maze (#759/#814) — test-author dispatch is a low-frequency Gate-A
|
|
965
|
+
# / JIT call, not the hot auto-loop path, so a branch/worktree collision
|
|
966
|
+
# is far less likely than for --rework-of's "resume a specific in-flight
|
|
967
|
+
# session" case. Lift that block from dispatch_workers._dispatch_rework_of
|
|
968
|
+
# if this turns out to need it.
|
|
969
|
+
import shlex
|
|
970
|
+
|
|
971
|
+
remote_wt = "$HOME/.coord/worktrees/" + assignment_id
|
|
972
|
+
rp_sh = (
|
|
973
|
+
"$HOME/" + ta_repo_path[2:]
|
|
974
|
+
if ta_repo_path.startswith("~/")
|
|
975
|
+
else ("$HOME" if ta_repo_path == "~" else ta_repo_path)
|
|
976
|
+
)
|
|
977
|
+
claude_args = shlex.join(list(argv)[1:])
|
|
978
|
+
br_q = shlex.quote(branch_name)
|
|
979
|
+
orig_ref = shlex.quote(f"origin/{branch_name}")
|
|
980
|
+
remote_cmd = (
|
|
981
|
+
f"mkdir -p $HOME/.coord/worktrees"
|
|
982
|
+
f" && cd {rp_sh}"
|
|
983
|
+
f" && git fetch origin --prune 2>/dev/null || true"
|
|
984
|
+
f" && git worktree prune 2>/dev/null || true"
|
|
985
|
+
f" && (git worktree add -B {br_q} {remote_wt} {orig_ref} 2>/dev/null"
|
|
986
|
+
f" || git worktree add -b {br_q} {remote_wt} origin/{default_branch})"
|
|
987
|
+
f" && cd {remote_wt}"
|
|
988
|
+
f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {claude_args}"
|
|
989
|
+
)
|
|
990
|
+
tmux_host = TmuxHost(ssh_target=machine.host)
|
|
991
|
+
sname = tmux_session_name(assignment_id)
|
|
992
|
+
click.echo(
|
|
993
|
+
f" remote worktree: $HOME/.coord/worktrees/{assignment_id} on "
|
|
994
|
+
f"{machine.host} (branch: {branch_name})"
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
if effective_briefing.strip():
|
|
998
|
+
hdr = (
|
|
999
|
+
"--- seeded briefing -- review below; "
|
|
1000
|
+
"submit the pre-filled input in Claude to send ---"
|
|
1001
|
+
)
|
|
1002
|
+
ftr = "-" * len(hdr)
|
|
1003
|
+
preview = f"\n{hdr}\n{effective_briefing.rstrip()}\n{ftr}\n\n"
|
|
1004
|
+
try:
|
|
1005
|
+
os.write(sys.stdout.fileno(), preview.encode("utf-8"))
|
|
1006
|
+
except OSError:
|
|
1007
|
+
pass
|
|
1008
|
+
|
|
1009
|
+
started_at = time.time()
|
|
1010
|
+
rc = _launch_via_tmux(
|
|
1011
|
+
argv, effective_briefing, sname, cwd=None, host=tmux_host,
|
|
1012
|
+
raw_shell_cmd=remote_cmd,
|
|
1013
|
+
)
|
|
1014
|
+
if rc is None:
|
|
1015
|
+
reason = f"could not create remote tmux session on {machine.host}"
|
|
1016
|
+
click.echo(f" error: {reason}", err=True)
|
|
1017
|
+
set_assignment_failure_reason(assignment_id, reason)
|
|
1018
|
+
raise RuntimeError(reason)
|
|
1019
|
+
exit_code = rc
|
|
1020
|
+
|
|
1021
|
+
if tmux_session_alive(sname, host=tmux_host):
|
|
1022
|
+
click.echo(
|
|
1023
|
+
f" session still running in remote tmux: {sname}\n"
|
|
1024
|
+
f" reattach with: ssh -t {machine.host} tmux attach-session -t {sname}"
|
|
1025
|
+
)
|
|
1026
|
+
return 0
|
|
1027
|
+
|
|
1028
|
+
try:
|
|
1029
|
+
remote_result = finalize_remote_interactive_exit(
|
|
1030
|
+
assignment_id=assignment_id,
|
|
1031
|
+
repo_name=repo_name,
|
|
1032
|
+
repo_github=repo_cfg.github,
|
|
1033
|
+
issue_number=tracking_issue,
|
|
1034
|
+
machine_name=machine.name,
|
|
1035
|
+
ssh_target=machine.host,
|
|
1036
|
+
remote_worktree_sh=remote_wt,
|
|
1037
|
+
remote_repo_sh=rp_sh,
|
|
1038
|
+
branch=branch_name,
|
|
1039
|
+
base_branch=default_branch,
|
|
1040
|
+
exit_code=exit_code,
|
|
1041
|
+
started_at=started_at,
|
|
1042
|
+
)
|
|
1043
|
+
if remote_result.already_recorded:
|
|
1044
|
+
click.echo(
|
|
1045
|
+
" result recorded via `coord report-result`; remote "
|
|
1046
|
+
"backstop did not overwrite"
|
|
1047
|
+
)
|
|
1048
|
+
else:
|
|
1049
|
+
click.echo(
|
|
1050
|
+
f" remote backstop: status={remote_result.terminal_status} "
|
|
1051
|
+
f"commits_ahead={remote_result.commits_ahead} "
|
|
1052
|
+
f"pushed={remote_result.push_ok}"
|
|
1053
|
+
)
|
|
1054
|
+
if not remote_result.push_ok:
|
|
1055
|
+
click.echo(
|
|
1056
|
+
f" warning: remote push failed: {remote_result.push_error}",
|
|
1057
|
+
err=True,
|
|
1058
|
+
)
|
|
1059
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
1060
|
+
click.echo(
|
|
1061
|
+
f" warning: remote backstop failed to record test-author exit: {exc}",
|
|
1062
|
+
err=True,
|
|
1063
|
+
)
|
|
1064
|
+
return exit_code
|