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/conflict_fix.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""Auto-dispatch a worker to rebase a merge-conflicted branch (#241).
|
|
2
|
+
|
|
3
|
+
When ``coord merge`` fails with a mechanical rebase conflict (classified by
|
|
4
|
+
:func:`coord.merge_queue.classify_conflict` as ``"rebaseable"``), the
|
|
5
|
+
coordinator queues a ``type="conflict-fix"`` assignment that:
|
|
6
|
+
|
|
7
|
+
1. Pulls the latest target branch.
|
|
8
|
+
2. Rebases the worker's branch on top of it.
|
|
9
|
+
3. Resolves obvious additive merges (non-overlapping struct fields, list
|
|
10
|
+
entries, imports).
|
|
11
|
+
4. Runs the project's test command.
|
|
12
|
+
5. ``git push --force-with-lease`` to the same branch.
|
|
13
|
+
|
|
14
|
+
On success, the coordinator re-enqueues the original merge entry so
|
|
15
|
+
``coord merge`` retries. On failure, the merge entry is marked
|
|
16
|
+
:data:`coord.merge_queue.HUMAN_REQUIRED` and surfaced in the TUI.
|
|
17
|
+
|
|
18
|
+
Why a separate module: same reason :mod:`coord.review` lives apart from
|
|
19
|
+
``coord.dispatch`` — conflict-fix is triggered by a merge_queue event, not
|
|
20
|
+
by a planner proposal, so it shares little with the work-dispatch shape.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import time
|
|
26
|
+
import uuid
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
from coord.config import Config
|
|
31
|
+
from coord.dispatch import AGENT_PORT
|
|
32
|
+
from coord.merge_queue import QueuedMerge
|
|
33
|
+
from coord.models import Assignment, Board, Machine
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
CONFLICT_FIX_SYSTEM_PROMPT = """\
|
|
37
|
+
You are a Claude Code conflict-fix worker. The merge of a worker's branch \
|
|
38
|
+
into the project's target branch failed because the branch is out of date \
|
|
39
|
+
or has a conflict. Your job is to rebase the branch and push it back.
|
|
40
|
+
|
|
41
|
+
Rules:
|
|
42
|
+
- The coordinator denies `gh` and `git push --force` for this worker. \
|
|
43
|
+
Don't try to use them — the harness will reject the call.
|
|
44
|
+
- Stay on the worker's branch — do NOT push to main / develop / target.
|
|
45
|
+
- Use git push --force-with-lease (NOT --force).
|
|
46
|
+
- If conflicts are mechanical (non-overlapping struct fields, list entries, \
|
|
47
|
+
imports, separate functions), resolve them additively — keep both sides.
|
|
48
|
+
- If conflicts are SEMANTIC (same function modified two ways, contradictory \
|
|
49
|
+
logic), DO NOT GUESS. Exit non-zero with a STUCK: line that starts with the \
|
|
50
|
+
marker `coord:conflict=semantic` and then names the conflicting files and \
|
|
51
|
+
line ranges, e.g.
|
|
52
|
+
STUCK: coord:conflict=semantic src/foo.py:40-72 — both sides rewrote \
|
|
53
|
+
parse_args() differently
|
|
54
|
+
The coordinator reads that marker and decides what happens next.
|
|
55
|
+
|
|
56
|
+
Progress reporting:
|
|
57
|
+
- After each significant step (rebase started, conflicts resolved, tests \
|
|
58
|
+
passed, pushed), output:
|
|
59
|
+
STATUS: [what you just did] → [what you're about to do] → [confidence]
|
|
60
|
+
- If you've tried and failed, output:
|
|
61
|
+
STUCK: [what you tried] [why it failed]
|
|
62
|
+
Then stop and wait for guidance.\
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
# Denied for conflict-fix workers. The agent's deny_commands enforcement
|
|
66
|
+
# (coord/agent.py:build_deny_prompt + harness gate) refuses these patterns
|
|
67
|
+
# regardless of what the prompt asks for. Keeps CLAUDE.md's "gh is denied"
|
|
68
|
+
# claim honest (#243-review-2).
|
|
69
|
+
CONFLICT_FIX_DENY_COMMANDS = [
|
|
70
|
+
"Bash(gh *)",
|
|
71
|
+
"Bash(git push --force *)",
|
|
72
|
+
"Bash(git push -f *)",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── #1291: semantic-conflict escalation ─────────────────────────────────────
|
|
77
|
+
#
|
|
78
|
+
# A conflict-fix worker decides for itself whether a conflict is mechanical or
|
|
79
|
+
# semantic (see the "When NOT to guess" section of the briefing below) and
|
|
80
|
+
# reports the verdict on its STUCK: line. The verdict is machine-parseable —
|
|
81
|
+
# a fixed marker, NOT a prose regex over "semantic"/"same function"/… which
|
|
82
|
+
# would rot the first time a worker phrased its give-up differently.
|
|
83
|
+
SEMANTIC_STUCK_MARKER = "coord:conflict=semantic"
|
|
84
|
+
|
|
85
|
+
# Prefix on the escalated worker's `issue_title`. Coordinator-generated (so
|
|
86
|
+
# matching it is not prose-matching), and it is what makes the escalation
|
|
87
|
+
# visible: the TUI Pipeline renders the conflict-fix row's title, and
|
|
88
|
+
# `has_prior_semantic_escalation` uses it to enforce exactly-one-retry.
|
|
89
|
+
SEMANTIC_FIX_TITLE_PREFIX = "[semantic-merge]"
|
|
90
|
+
|
|
91
|
+
SEMANTIC_CONFLICT_SYSTEM_PROMPT = """\
|
|
92
|
+
You are a Claude Code merge worker. A first worker rebased a branch onto its \
|
|
93
|
+
target branch, hit a conflict it judged SEMANTIC — the two sides changed the \
|
|
94
|
+
same behaviour in incompatible ways — and stopped rather than guess. You are \
|
|
95
|
+
the second attempt: your job is to understand both intents and produce a \
|
|
96
|
+
resolution that honours both.
|
|
97
|
+
|
|
98
|
+
Constraints:
|
|
99
|
+
- The coordinator denies `gh` and `git push --force` for this worker.
|
|
100
|
+
- Stay on the worker's branch — never push to main / develop / the target \
|
|
101
|
+
branch. Push with `git push --force-with-lease`.
|
|
102
|
+
- The project's tests must pass before you push. A resolution that compiles \
|
|
103
|
+
but breaks behaviour is worse than no resolution.
|
|
104
|
+
- If you cannot honour both intents with confidence, stop and say so with a \
|
|
105
|
+
`STUCK:` line. The coordinator escalates to a human; that is a fine outcome \
|
|
106
|
+
and much better than a plausible guess.
|
|
107
|
+
|
|
108
|
+
Progress reporting: emit `STATUS:` lines as you go, and a `STUCK:` line if \
|
|
109
|
+
you stop.\
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_semantic_conflict_briefing(
|
|
114
|
+
*,
|
|
115
|
+
entry: QueuedMerge,
|
|
116
|
+
repo_path: str,
|
|
117
|
+
test_command: str | None,
|
|
118
|
+
stuck_summary: str | None = None,
|
|
119
|
+
) -> str:
|
|
120
|
+
"""#1291: briefing for the escalated (semantic) second attempt.
|
|
121
|
+
|
|
122
|
+
Deliberately goal-and-constraint shaped rather than a numbered recipe —
|
|
123
|
+
the mechanical briefing's step list is the right shape for a rebase, and
|
|
124
|
+
the wrong shape here: over-prescriptive prompts measurably reduce the
|
|
125
|
+
stronger model's output quality on open-ended reasoning. The first
|
|
126
|
+
worker already proved the mechanical path doesn't apply.
|
|
127
|
+
"""
|
|
128
|
+
test_cmd = test_command or "echo '(no test command configured)'"
|
|
129
|
+
lines: list[str] = [
|
|
130
|
+
f"# Semantic merge: {entry.repo_github} `{entry.branch}` → "
|
|
131
|
+
f"`{entry.target_branch}`",
|
|
132
|
+
"",
|
|
133
|
+
f"Issue: #{entry.issue_number} — {entry.issue_title}",
|
|
134
|
+
"",
|
|
135
|
+
"A first conflict-fix worker attempted the rebase and stopped: it "
|
|
136
|
+
"judged the conflict **semantic** — both sides changed the same "
|
|
137
|
+
"behaviour in incompatible ways, so keeping both hunks is not a "
|
|
138
|
+
"resolution.",
|
|
139
|
+
"",
|
|
140
|
+
f"You are already in a dedicated git worktree on `{entry.branch}`. "
|
|
141
|
+
f"Work here — do NOT `cd {repo_path}` (the machine's shared base "
|
|
142
|
+
"checkout) and do NOT `git checkout` / `git switch` anywhere; that is "
|
|
143
|
+
"what leaves the base parked on a feature branch and breaks later "
|
|
144
|
+
"dispatches (#1694).",
|
|
145
|
+
"",
|
|
146
|
+
]
|
|
147
|
+
if stuck_summary:
|
|
148
|
+
lines += [f"What it reported: {stuck_summary}", ""]
|
|
149
|
+
lines += [
|
|
150
|
+
"## Goal",
|
|
151
|
+
"",
|
|
152
|
+
f"`{entry.branch}` rebased onto `{entry.target_branch}`, with a "
|
|
153
|
+
"resolution that preserves what BOTH sides were trying to do, "
|
|
154
|
+
"tests green, pushed to the same branch.",
|
|
155
|
+
"",
|
|
156
|
+
"Read enough of the history and the surrounding code to know what "
|
|
157
|
+
"each side intended before you write the resolution. Intent, not "
|
|
158
|
+
"textual reconciliation, is the whole job here.",
|
|
159
|
+
"",
|
|
160
|
+
"## Constraints",
|
|
161
|
+
"",
|
|
162
|
+
f"- Tests must pass: `{test_cmd}`. Do not push a red tree.",
|
|
163
|
+
f"- Push only `{entry.branch}`, only with `git push --force-with-lease`.",
|
|
164
|
+
"- `gh` and `git push --force` are denied by the harness. The "
|
|
165
|
+
"coordinator owns PR retries, merges, and issue comments.",
|
|
166
|
+
"- Every merge gate still applies after you finish (tests, CI, "
|
|
167
|
+
"verify-merge, review). Nothing here is force-merged, so a "
|
|
168
|
+
"resolution you are not confident in will be caught — but it will "
|
|
169
|
+
"cost a human a review cycle.",
|
|
170
|
+
"- If both intents cannot be honoured together, stop and explain on "
|
|
171
|
+
"a `STUCK:` line. Handing this to a human is the correct outcome; "
|
|
172
|
+
"guessing is not.",
|
|
173
|
+
"",
|
|
174
|
+
f"Last merge error: {entry.error or 'unknown conflict'}",
|
|
175
|
+
]
|
|
176
|
+
return "\n".join(lines)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build_conflict_fix_briefing(
|
|
180
|
+
*,
|
|
181
|
+
entry: QueuedMerge,
|
|
182
|
+
repo_path: str,
|
|
183
|
+
test_command: str | None,
|
|
184
|
+
) -> str:
|
|
185
|
+
"""Assemble the conflict-fix worker's briefing. Pure function — testable."""
|
|
186
|
+
test_cmd = test_command or "echo '(no test command configured)'"
|
|
187
|
+
lines: list[str] = [
|
|
188
|
+
f"# Conflict fix: {entry.repo_github} branch `{entry.branch}`",
|
|
189
|
+
"",
|
|
190
|
+
f"The merge of `{entry.branch}` → `{entry.target_branch}` failed.",
|
|
191
|
+
f"Reason: {entry.error or 'unknown conflict'}",
|
|
192
|
+
"",
|
|
193
|
+
f"Issue: #{entry.issue_number} — {entry.issue_title}",
|
|
194
|
+
"",
|
|
195
|
+
"## Where you are",
|
|
196
|
+
"",
|
|
197
|
+
f"You are already in a dedicated git worktree checked out on "
|
|
198
|
+
f"`{entry.branch}` — the coordinator created it for you. Work HERE.",
|
|
199
|
+
"",
|
|
200
|
+
f"Do **NOT** `cd {repo_path}` (that is the machine's shared base "
|
|
201
|
+
"checkout) and do NOT `git checkout` / `git switch` anywhere. Leaving "
|
|
202
|
+
f"the base checkout parked on `{entry.branch}` breaks every later "
|
|
203
|
+
"dispatch against that branch on this machine (#1694).",
|
|
204
|
+
"",
|
|
205
|
+
"## Steps",
|
|
206
|
+
"",
|
|
207
|
+
"1. `git fetch origin`",
|
|
208
|
+
f"2. `git pull --rebase origin {entry.target_branch}`",
|
|
209
|
+
"3. Resolve any conflict markers. Prefer additive merges; preserve",
|
|
210
|
+
" both sides when the conflict is in non-overlapping struct fields,",
|
|
211
|
+
" list entries, imports, or separate functions.",
|
|
212
|
+
f"4. Run tests: `{test_cmd}`",
|
|
213
|
+
f"5. `git push --force-with-lease origin {entry.branch}`",
|
|
214
|
+
"6. Exit 0 if push succeeds; non-zero otherwise.",
|
|
215
|
+
"",
|
|
216
|
+
"## When NOT to guess",
|
|
217
|
+
"",
|
|
218
|
+
"If the conflict is **semantic** — the same function modified two",
|
|
219
|
+
"different ways, contradictory logic, an API rename that the other",
|
|
220
|
+
"side doesn't know about — DO NOT guess. Exit non-zero with a",
|
|
221
|
+
"`STUCK:` line that begins with the exact marker",
|
|
222
|
+
f"`{SEMANTIC_STUCK_MARKER}` and then names the file(s) and line",
|
|
223
|
+
"ranges in conflict, e.g.",
|
|
224
|
+
"",
|
|
225
|
+
f" STUCK: {SEMANTIC_STUCK_MARKER} src/foo.py:40-72 — both sides",
|
|
226
|
+
" rewrote parse_args() differently",
|
|
227
|
+
"",
|
|
228
|
+
"The coordinator reads that marker to decide what happens next: the",
|
|
229
|
+
f"outcome is posted on issue #{entry.issue_number} and the merge",
|
|
230
|
+
"entry is either escalated for one stronger attempt or marked as",
|
|
231
|
+
"needing human resolution.",
|
|
232
|
+
"",
|
|
233
|
+
"You will NOT use `gh` or `git push --force` — both are denied by",
|
|
234
|
+
"the harness. The coordinator owns PR retries and issue posting.",
|
|
235
|
+
]
|
|
236
|
+
return "\n".join(lines)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ── Semantic-verdict detection (#1291) ──────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _decode_worker_text(raw: str) -> str:
|
|
243
|
+
"""Return the human-readable text of a worker log.
|
|
244
|
+
|
|
245
|
+
Handles both plain-text logs and the stream-json format (each line a
|
|
246
|
+
JSON event) the agent writes by default — in the latter case the
|
|
247
|
+
assistant text blocks are concatenated. Mirrors the detection used by
|
|
248
|
+
:func:`coord.progress.parse_completion_summary_from_agent`.
|
|
249
|
+
|
|
250
|
+
#1710 inventory: kept as a direct ``coord.worker_events`` import — this
|
|
251
|
+
decodes the generic Anthropic-Messages-API ``type: "assistant"`` /
|
|
252
|
+
``message.content`` envelope (not claude business semantics), same
|
|
253
|
+
reasoning as the equivalent helpers in ``coord.review``/
|
|
254
|
+
``coord.plan_parser``.
|
|
255
|
+
"""
|
|
256
|
+
stream_json = False
|
|
257
|
+
for line in raw.splitlines():
|
|
258
|
+
stripped = line.strip()
|
|
259
|
+
if not stripped or stripped.startswith("#"):
|
|
260
|
+
continue
|
|
261
|
+
stream_json = stripped.startswith("{")
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
if not stream_json:
|
|
265
|
+
return raw
|
|
266
|
+
|
|
267
|
+
from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
|
|
268
|
+
|
|
269
|
+
decoded: list[str] = []
|
|
270
|
+
for line in raw.splitlines():
|
|
271
|
+
event = parse_event(line.rstrip("\n"))
|
|
272
|
+
if event is None or event.type != "assistant":
|
|
273
|
+
continue
|
|
274
|
+
text = _assistant_text(event)
|
|
275
|
+
if text:
|
|
276
|
+
decoded.append(text)
|
|
277
|
+
return "\n".join(decoded)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def semantic_verdict_in_text(text: str | None) -> bool:
|
|
281
|
+
"""True when a worker log carries the semantic-conflict marker."""
|
|
282
|
+
if not text:
|
|
283
|
+
return False
|
|
284
|
+
return SEMANTIC_STUCK_MARKER in _decode_worker_text(text)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def detect_semantic_conflict(
|
|
288
|
+
*,
|
|
289
|
+
log_path: str | None = None,
|
|
290
|
+
host: str | None = None,
|
|
291
|
+
assignment_id: str | None = None,
|
|
292
|
+
port: int = AGENT_PORT,
|
|
293
|
+
timeout: float = 15.0,
|
|
294
|
+
) -> bool:
|
|
295
|
+
"""True when the finished conflict-fix worker reported a SEMANTIC conflict.
|
|
296
|
+
|
|
297
|
+
Tries the local log file first (coordinator-local worker), then falls
|
|
298
|
+
back to the agent's ``/logs/<id>`` endpoint for remote workers — the
|
|
299
|
+
same two-step every other log-parsing consumer uses (review findings,
|
|
300
|
+
plans, completion summaries). Best-effort: any read/transport failure
|
|
301
|
+
returns ``False``, which means "not semantic" and preserves today's
|
|
302
|
+
HUMAN_REQUIRED behaviour.
|
|
303
|
+
"""
|
|
304
|
+
if log_path:
|
|
305
|
+
try:
|
|
306
|
+
from pathlib import Path # noqa: PLC0415
|
|
307
|
+
|
|
308
|
+
p = Path(log_path)
|
|
309
|
+
if p.exists():
|
|
310
|
+
raw = p.read_text(encoding="utf-8", errors="replace")
|
|
311
|
+
if semantic_verdict_in_text(raw):
|
|
312
|
+
return True
|
|
313
|
+
except OSError:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
if host and assignment_id:
|
|
317
|
+
try:
|
|
318
|
+
resp = httpx.get(
|
|
319
|
+
f"http://{host}:{port}/logs/{assignment_id}", timeout=timeout
|
|
320
|
+
)
|
|
321
|
+
resp.raise_for_status()
|
|
322
|
+
return semantic_verdict_in_text(resp.text)
|
|
323
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def has_prior_semantic_escalation(board: Board, merge_entry_id: str | None) -> bool:
|
|
330
|
+
"""True when this merge entry already had its ONE escalated attempt.
|
|
331
|
+
|
|
332
|
+
Matches on any status — running, done, failed — so the escalation can
|
|
333
|
+
never fire twice for the same entry. A second semantic failure falls
|
|
334
|
+
through to HUMAN_REQUIRED exactly as before (#1291: one retry, no loop).
|
|
335
|
+
"""
|
|
336
|
+
if merge_entry_id is None:
|
|
337
|
+
return False
|
|
338
|
+
for a in list(board.active) + list(board.completed):
|
|
339
|
+
if a.type != "conflict-fix":
|
|
340
|
+
continue
|
|
341
|
+
if a.review_of_assignment_id != merge_entry_id:
|
|
342
|
+
continue
|
|
343
|
+
if (a.issue_title or "").startswith(SEMANTIC_FIX_TITLE_PREFIX):
|
|
344
|
+
return True
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ── Retry-cap guard ─────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _has_active_conflict_fix(board: Board, merge_entry_id: str | None) -> bool:
|
|
352
|
+
"""True when a conflict-fix for *merge_entry_id* is running or pending."""
|
|
353
|
+
if merge_entry_id is None:
|
|
354
|
+
return False
|
|
355
|
+
return any(
|
|
356
|
+
a.type == "conflict-fix"
|
|
357
|
+
and a.review_of_assignment_id == merge_entry_id
|
|
358
|
+
and a.status in ("running", "pending")
|
|
359
|
+
for a in list(board.active) + list(board.completed)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def has_prior_conflict_fix(board: Board, merge_entry_id: str | None) -> bool:
|
|
364
|
+
"""True when a second conflict-fix dispatch for *merge_entry_id* is blocked.
|
|
365
|
+
|
|
366
|
+
Blocks when a conflict-fix is **active** (running/pending — don't spawn a
|
|
367
|
+
duplicate) or has **genuinely failed** (failed/advisory — retry cap
|
|
368
|
+
consumed, escalate to human).
|
|
369
|
+
|
|
370
|
+
#784: a conflict-fix that completed **successfully** (``status="done"``)
|
|
371
|
+
does *not* block a subsequent dispatch. A successful rebase can be
|
|
372
|
+
followed by a new conflict if other PRs merged in the meantime; that is a
|
|
373
|
+
fresh situation and warrants a fresh fix attempt. Only actual failures
|
|
374
|
+
consume the one-per-entry cap.
|
|
375
|
+
"""
|
|
376
|
+
if merge_entry_id is None:
|
|
377
|
+
return False
|
|
378
|
+
for a in list(board.active) + list(board.completed):
|
|
379
|
+
if a.type != "conflict-fix":
|
|
380
|
+
continue
|
|
381
|
+
if a.review_of_assignment_id != merge_entry_id:
|
|
382
|
+
continue
|
|
383
|
+
# Active attempt in flight — prevent duplicate dispatch.
|
|
384
|
+
if a.status in ("running", "pending"):
|
|
385
|
+
return True
|
|
386
|
+
# Genuine failure — retry cap consumed, surface to human.
|
|
387
|
+
if a.status in ("failed", "advisory"):
|
|
388
|
+
return True
|
|
389
|
+
# "done" = successful rebase → cap not consumed; a re-conflict is new.
|
|
390
|
+
# "cancelled" falls through here too — a cancelled attempt did no work,
|
|
391
|
+
# so it is treated the same as "done": re-dispatch is allowed.
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ── Machine selection ───────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
def pick_conflict_fix_machine(
|
|
398
|
+
repo_name: str,
|
|
399
|
+
board: Board,
|
|
400
|
+
config: Config,
|
|
401
|
+
*,
|
|
402
|
+
prefer_machine: str | None = None,
|
|
403
|
+
) -> Machine | None:
|
|
404
|
+
"""Pick a machine that has *repo_name* checked out. ``prefer_machine``
|
|
405
|
+
wins if it can handle the repo (typically the original worker), so the
|
|
406
|
+
rebase uses an existing local checkout.
|
|
407
|
+
|
|
408
|
+
Returns ``None`` when no configured machine can handle the repo.
|
|
409
|
+
"""
|
|
410
|
+
candidates = [m for m in config.machines if m.can_work_on(repo_name)]
|
|
411
|
+
if not candidates:
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
busy = {a.machine_name for a in board.active if a.status in ("pending", "running")}
|
|
415
|
+
|
|
416
|
+
# 1. The preferred machine if it's idle and can handle the repo.
|
|
417
|
+
if prefer_machine is not None:
|
|
418
|
+
preferred = next((m for m in candidates if m.name == prefer_machine), None)
|
|
419
|
+
if preferred is not None and preferred.name not in busy:
|
|
420
|
+
return preferred
|
|
421
|
+
|
|
422
|
+
# 2. Any idle machine that handles the repo.
|
|
423
|
+
idle = [m for m in candidates if m.name not in busy]
|
|
424
|
+
if idle:
|
|
425
|
+
return idle[0]
|
|
426
|
+
|
|
427
|
+
# 3. Anyone (including busy) — the assignment will queue on the agent.
|
|
428
|
+
return candidates[0]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ── Dispatch ────────────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def dispatch_conflict_fix(
|
|
435
|
+
entry: QueuedMerge,
|
|
436
|
+
board: Board,
|
|
437
|
+
config: Config,
|
|
438
|
+
*,
|
|
439
|
+
http_client: httpx.Client | None = None,
|
|
440
|
+
prefer_machine: str | None = None,
|
|
441
|
+
now: float | None = None,
|
|
442
|
+
semantic: bool = False,
|
|
443
|
+
model: str | None = None,
|
|
444
|
+
stuck_summary: str | None = None,
|
|
445
|
+
) -> Assignment | None:
|
|
446
|
+
"""Send a ``type="conflict-fix"`` assignment for *entry* to an agent.
|
|
447
|
+
|
|
448
|
+
Returns the new ``Assignment``, or ``None`` when dispatch couldn't proceed
|
|
449
|
+
(no capable machine, no ``repo_path`` configured, agent unreachable, …).
|
|
450
|
+
The caller is responsible for persisting the board.
|
|
451
|
+
|
|
452
|
+
Retry cap: blocks on two conditions — (1) an **active** conflict-fix
|
|
453
|
+
(``running``/``pending``) for this entry is already in flight, preventing
|
|
454
|
+
duplicate dispatch; or (2) a **failed** conflict-fix (``failed``/
|
|
455
|
+
``advisory``) already completed, consuming the one-per-entry retry cap so
|
|
456
|
+
the caller marks the entry ``HUMAN_REQUIRED``. A ``done`` (successful)
|
|
457
|
+
conflict-fix does *not* block a new dispatch — a successful rebase can be
|
|
458
|
+
followed by a fresh conflict if other PRs merged in the meantime, and that
|
|
459
|
+
warrants a new attempt rather than an immediate human escalation (#784).
|
|
460
|
+
|
|
461
|
+
``semantic=True`` (#1291) dispatches the escalated second attempt: a
|
|
462
|
+
different, less prescriptive briefing (see
|
|
463
|
+
:func:`build_semantic_conflict_briefing`) and, with *model*, a stronger
|
|
464
|
+
model. It deliberately bypasses the *failed-prior* half of the retry
|
|
465
|
+
cap — the mechanical attempt that just failed is precisely what triggers
|
|
466
|
+
it — but is itself capped at one per entry by
|
|
467
|
+
:func:`has_prior_semantic_escalation`, and still refuses to dispatch
|
|
468
|
+
while another conflict-fix is in flight. Because the escalated attempt
|
|
469
|
+
is itself a ``conflict-fix`` row, its failure consumes the ordinary
|
|
470
|
+
retry cap and the entry goes HUMAN_REQUIRED — no loop.
|
|
471
|
+
"""
|
|
472
|
+
if semantic:
|
|
473
|
+
if has_prior_semantic_escalation(board, entry.assignment_id):
|
|
474
|
+
return None
|
|
475
|
+
if _has_active_conflict_fix(board, entry.assignment_id):
|
|
476
|
+
return None
|
|
477
|
+
elif has_prior_conflict_fix(board, entry.assignment_id):
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
repo = config.repo(entry.repo_name)
|
|
481
|
+
if repo is None:
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
machine = pick_conflict_fix_machine(
|
|
485
|
+
entry.repo_name, board, config, prefer_machine=prefer_machine,
|
|
486
|
+
)
|
|
487
|
+
if machine is None:
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
repo_path = machine.repo_path(entry.repo_name)
|
|
491
|
+
if repo_path is None:
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
if semantic:
|
|
495
|
+
briefing = build_semantic_conflict_briefing(
|
|
496
|
+
entry=entry,
|
|
497
|
+
repo_path=repo_path,
|
|
498
|
+
test_command=repo.test_command,
|
|
499
|
+
stuck_summary=stuck_summary,
|
|
500
|
+
)
|
|
501
|
+
system_prompt = SEMANTIC_CONFLICT_SYSTEM_PROMPT
|
|
502
|
+
# #1291 visibility: the title is what the TUI Pipeline row shows, so
|
|
503
|
+
# the operator can see a semantic merge was attempted (and by which
|
|
504
|
+
# model) rather than discovering it post-merge.
|
|
505
|
+
title = f"{SEMANTIC_FIX_TITLE_PREFIX} {entry.issue_title}"
|
|
506
|
+
if model:
|
|
507
|
+
title = f"{SEMANTIC_FIX_TITLE_PREFIX}[{model}] {entry.issue_title}"
|
|
508
|
+
else:
|
|
509
|
+
briefing = build_conflict_fix_briefing(
|
|
510
|
+
entry=entry,
|
|
511
|
+
repo_path=repo_path,
|
|
512
|
+
test_command=repo.test_command,
|
|
513
|
+
)
|
|
514
|
+
system_prompt = CONFLICT_FIX_SYSTEM_PROMPT
|
|
515
|
+
title = f"[conflict-fix] {entry.issue_title}"
|
|
516
|
+
|
|
517
|
+
# Merge repo-level deny rules with the conflict-fix-specific ones so
|
|
518
|
+
# `gh` and `git push --force` are actually denied (not just discouraged
|
|
519
|
+
# by the system prompt). Dedupe by simple set conversion — patterns
|
|
520
|
+
# are exact strings on the agent side, so collisions are safe to fold.
|
|
521
|
+
repo_deny = (
|
|
522
|
+
list(repo.worker_permissions.deny) if repo.worker_permissions else []
|
|
523
|
+
)
|
|
524
|
+
deny_commands = list(dict.fromkeys(repo_deny + CONFLICT_FIX_DENY_COMMANDS))
|
|
525
|
+
|
|
526
|
+
payload = {
|
|
527
|
+
"repo_name": entry.repo_name,
|
|
528
|
+
"repo_path": repo_path,
|
|
529
|
+
"issue_number": entry.issue_number,
|
|
530
|
+
"issue_title": title,
|
|
531
|
+
"briefing": briefing,
|
|
532
|
+
"files_allowed": [],
|
|
533
|
+
"files_forbidden": [],
|
|
534
|
+
"pull_repos": [],
|
|
535
|
+
"deny_commands": deny_commands,
|
|
536
|
+
"type": "conflict-fix",
|
|
537
|
+
"system_prompt": system_prompt,
|
|
538
|
+
"review_target": entry.branch,
|
|
539
|
+
# #1694 (review finding): `branch` is the wire field every other
|
|
540
|
+
# dispatcher (dispatch.py/review.py/smoke.py/gate_b.py/auto_loop.py/
|
|
541
|
+
# reconcile.py) fills with the REPO'S REAL default/integration branch
|
|
542
|
+
# — `_setup_worktree`'s and `_restore_base_checkout`'s `default_branch`
|
|
543
|
+
# come straight from it (`assignment.spec.branch or "main"`). This
|
|
544
|
+
# dispatcher used to send `entry.branch` here too — the SAME value as
|
|
545
|
+
# `target_branch` below (the work branch) — so a base checkout parked
|
|
546
|
+
# on `entry.branch` looked identical to "already on the default
|
|
547
|
+
# branch" and both Part A's restore and Part B's `_git_worktree_add`
|
|
548
|
+
# remedy silently no-op'd (`branch == default_branch` short-circuit).
|
|
549
|
+
# `entry.target_branch` is the merge target `QueuedMerge` actually
|
|
550
|
+
# carries for this entry (repo.default_branch, or the milestone
|
|
551
|
+
# feature branch when the repo opted into that git model — see
|
|
552
|
+
# merge_queue.enqueue) — exactly the "default branch" Part A/B need,
|
|
553
|
+
# and never equal to `entry.branch` by construction.
|
|
554
|
+
"branch": entry.target_branch or "main",
|
|
555
|
+
# #277: pin the agent to the original branch — otherwise it derives a
|
|
556
|
+
# slug from the "[conflict-fix] …" issue_title and pushes the rebase
|
|
557
|
+
# to an orphan branch, leaving the real PR stale.
|
|
558
|
+
"target_branch": entry.branch,
|
|
559
|
+
}
|
|
560
|
+
if model:
|
|
561
|
+
# Same wire shape the fix/review dispatchers use: the alias is
|
|
562
|
+
# resolved to a pinned exact model id when `models.versions` maps it.
|
|
563
|
+
payload["model"] = config.models.resolve(model)
|
|
564
|
+
|
|
565
|
+
url = f"http://{machine.host}:{AGENT_PORT}/assign"
|
|
566
|
+
client = http_client or httpx
|
|
567
|
+
try:
|
|
568
|
+
resp = client.post(url, json=payload, timeout=15)
|
|
569
|
+
resp.raise_for_status()
|
|
570
|
+
agent_response = resp.json()
|
|
571
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
fix_assignment = Assignment(
|
|
575
|
+
machine_name=machine.name,
|
|
576
|
+
repo_name=entry.repo_name,
|
|
577
|
+
issue_number=entry.issue_number,
|
|
578
|
+
issue_title=title,
|
|
579
|
+
files_allowed=[],
|
|
580
|
+
files_forbidden=[],
|
|
581
|
+
briefing=briefing,
|
|
582
|
+
model=model,
|
|
583
|
+
assignment_id=agent_response.get("id") or uuid.uuid4().hex[:12],
|
|
584
|
+
status="running",
|
|
585
|
+
branch=entry.branch,
|
|
586
|
+
dispatched_at=now if now is not None else time.time(),
|
|
587
|
+
type="conflict-fix",
|
|
588
|
+
review_target=entry.branch,
|
|
589
|
+
review_of_assignment_id=entry.assignment_id,
|
|
590
|
+
)
|
|
591
|
+
board.active.append(fix_assignment)
|
|
592
|
+
|
|
593
|
+
from coord.state import record_dispatched_assignment # noqa: PLC0415
|
|
594
|
+
|
|
595
|
+
record_dispatched_assignment(
|
|
596
|
+
assignment=fix_assignment,
|
|
597
|
+
repo_github=repo.github,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
# #1038: operational-tier row alongside the business-tier "dispatched"
|
|
601
|
+
# row `record_dispatched_assignment` already writes. The business row
|
|
602
|
+
# marks WHAT happened (a conflict-fix assignment was dispatched); this
|
|
603
|
+
# marks that it was the coordinator's own automatic mechanical-conflict
|
|
604
|
+
# classification/retry-cap logic that decided to do it, not a human
|
|
605
|
+
# picking this entry — the same distinction the other #1038 hooks draw.
|
|
606
|
+
from coord.audit import record_audit # noqa: PLC0415
|
|
607
|
+
|
|
608
|
+
record_audit(
|
|
609
|
+
tier="operational",
|
|
610
|
+
category="merge",
|
|
611
|
+
event_type=(
|
|
612
|
+
"semantic_conflict_escalated" if semantic else "conflict_fix_dispatched"
|
|
613
|
+
),
|
|
614
|
+
actor="daemon",
|
|
615
|
+
summary=(
|
|
616
|
+
f"semantic conflict escalated to {model or 'default model'}: "
|
|
617
|
+
f"{entry.repo_name}#{entry.issue_number} → {machine.name}"
|
|
618
|
+
if semantic
|
|
619
|
+
else f"conflict-fix dispatched: {entry.repo_name}#{entry.issue_number} "
|
|
620
|
+
f"→ {machine.name}"
|
|
621
|
+
),
|
|
622
|
+
repo=entry.repo_name,
|
|
623
|
+
issue=entry.issue_number,
|
|
624
|
+
assignment_id=fix_assignment.assignment_id,
|
|
625
|
+
machine=machine.name,
|
|
626
|
+
details={
|
|
627
|
+
"merge_entry_id": entry.assignment_id,
|
|
628
|
+
"semantic": semantic,
|
|
629
|
+
"model": model,
|
|
630
|
+
},
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
return fix_assignment
|