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/models.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
"""Dataclasses for the coordinator: repos, machines, assignments, board."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, time, timedelta, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from zoneinfo import ZoneInfo
|
|
10
|
+
|
|
11
|
+
# #316: pattern that distinguishes a file-path value for `new_issue_guidance`
|
|
12
|
+
# from inline markdown text. Matches paths like `docs/ISSUE_GUIDANCE.md` or
|
|
13
|
+
# `GUIDANCE.txt` but not multi-line or space-containing strings.
|
|
14
|
+
#
|
|
15
|
+
# The negative lookaheads reject (a) traversal sequences (`../`) and (b) any
|
|
16
|
+
# value starting with `/` or `\` (an absolute path). Both protections matter
|
|
17
|
+
# because `Path("/repo") / "/etc/passwd.md"` silently discards the base and
|
|
18
|
+
# returns `Path("/etc/passwd.md")`, so absolute paths would otherwise escape
|
|
19
|
+
# the repo root just as effectively as `../`. `resolve_new_issue_guidance`
|
|
20
|
+
# adds a second belt-and-braces check via `Path.resolve()` containment.
|
|
21
|
+
_GUIDANCE_PATH_RE: re.Pattern[str] = re.compile(
|
|
22
|
+
r"^(?![/\\])(?!.*\.\.[/\\])[\w./\-]+\.(md|txt)$", re.IGNORECASE
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class WorkerPermissionsConfig:
|
|
28
|
+
"""Per-repo allow/deny lists for worker commands.
|
|
29
|
+
|
|
30
|
+
When ``deny`` is non-empty the coordinator injects a "forbidden commands"
|
|
31
|
+
section into the worker system prompt so that ``claude -p`` refuses to run
|
|
32
|
+
the listed patterns. An empty ``deny`` list (``deny: []``) means no
|
|
33
|
+
restrictions.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
allow: list[str] = field(default_factory=list)
|
|
37
|
+
deny: list[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Repo:
|
|
42
|
+
name: str
|
|
43
|
+
github: str
|
|
44
|
+
depends_on: list[str] = field(default_factory=list)
|
|
45
|
+
default_branch: str = "main"
|
|
46
|
+
# #934 (Pipeline v2 Phase 4, docs/PIPELINE_V2.md "Git model"): when set,
|
|
47
|
+
# this repo has opted into the develop + feature-branch-per-milestone
|
|
48
|
+
# git model — `develop` is the integration branch (`default_branch`
|
|
49
|
+
# becomes the release branch) and milestone issues branch off
|
|
50
|
+
# `feature/ms-NN` (see coord/branch_model.py) instead of
|
|
51
|
+
# `default_branch` directly. `None` (the default) means the repo is
|
|
52
|
+
# unaffected: every branch-resolution call site falls back to today's
|
|
53
|
+
# flat `default_branch` behavior. This is the opt-in guard that keeps
|
|
54
|
+
# in-flight work on `main` from breaking when `develop` appears for
|
|
55
|
+
# repos that adopt the new model.
|
|
56
|
+
develop_branch: str | None = None
|
|
57
|
+
build_command: str | None = None
|
|
58
|
+
test_command: str | None = None
|
|
59
|
+
# #296: optional shell command to interactively run the app for manual
|
|
60
|
+
# smoke testing. Surfaced in the TUI Test stage detail panel so the
|
|
61
|
+
# tester knows exactly what to launch.
|
|
62
|
+
run_cmd: str | None = None
|
|
63
|
+
worker_permissions: WorkerPermissionsConfig | None = None
|
|
64
|
+
housekeeping: list[str] = field(default_factory=list)
|
|
65
|
+
coordinator_only_files: list[str] = field(default_factory=list)
|
|
66
|
+
# #268: repos a worker may reference for context but doesn't actually
|
|
67
|
+
# build against. Common cases: sister projects extracted from a
|
|
68
|
+
# common ancestor (quadraui ← vimcode), reference implementations,
|
|
69
|
+
# "lift X out of Y into Z" issues.
|
|
70
|
+
#
|
|
71
|
+
# Honoured by the freshness check (pulled alongside `depends_on`)
|
|
72
|
+
# but ignored by the cycle detector — so a repo can list a sibling
|
|
73
|
+
# that already points back via `depends_on` without tripping the
|
|
74
|
+
# validator. Reference entries do NOT walk transitively — they're
|
|
75
|
+
# a flat list.
|
|
76
|
+
reference_repos: list[str] = field(default_factory=list)
|
|
77
|
+
# #316: per-repo guidance for drafting new GitHub issues. Accepts either
|
|
78
|
+
# an inline markdown string OR a file path relative to the repo root
|
|
79
|
+
# (e.g. `docs/ISSUE_GUIDANCE.md`). See `resolve_new_issue_guidance`.
|
|
80
|
+
new_issue_guidance: str | None = None
|
|
81
|
+
# #305: glob patterns (relative to the worktree root) for build artifacts
|
|
82
|
+
# to stash before the worktree is removed. Matches are copied to
|
|
83
|
+
# ~/.coord/artifacts/<repo>/<branch>/ on the agent with latest-wins
|
|
84
|
+
# semantics per (repo, branch) pair. Files under 100 bytes or ending
|
|
85
|
+
# in `.d` are excluded (dependency files, not binaries).
|
|
86
|
+
artifact_paths: list[str] = field(default_factory=list)
|
|
87
|
+
# #323: optional provider override for workers dispatched to this repo.
|
|
88
|
+
# When set, overrides providers.default from coordinator.yml. The value
|
|
89
|
+
# must match a key in providers.definitions (or be "claude" which is
|
|
90
|
+
# always implicit). None means "use the global default".
|
|
91
|
+
provider: str | None = None
|
|
92
|
+
|
|
93
|
+
def resolve_new_issue_guidance(self, repo_path: Path) -> str:
|
|
94
|
+
"""Return the new-issue guidance string for this repo.
|
|
95
|
+
|
|
96
|
+
Resolution order:
|
|
97
|
+
1. If ``new_issue_guidance`` is ``None`` (or empty), return a
|
|
98
|
+
generic default describing the required issue sections.
|
|
99
|
+
2. If the value matches ``[\\w/.-]+\\.(md|txt)$`` **and** the file
|
|
100
|
+
exists at ``repo_path / value``, return the file contents.
|
|
101
|
+
3. If the pattern matches but the file is missing, return the value
|
|
102
|
+
verbatim as inline text (so a misconfigured path is still visible
|
|
103
|
+
to the worker rather than silently replaced).
|
|
104
|
+
4. Otherwise, return the value verbatim (it is inline markdown).
|
|
105
|
+
"""
|
|
106
|
+
_DEFAULT = (
|
|
107
|
+
"Required sections: "
|
|
108
|
+
"Title (active voice, ≤80 chars), "
|
|
109
|
+
"What (1-3 sentences), "
|
|
110
|
+
"Acceptance (bulleted, observable), "
|
|
111
|
+
"Out of scope"
|
|
112
|
+
)
|
|
113
|
+
if not self.new_issue_guidance or not self.new_issue_guidance.strip():
|
|
114
|
+
return _DEFAULT
|
|
115
|
+
value = self.new_issue_guidance.strip()
|
|
116
|
+
if _GUIDANCE_PATH_RE.match(value):
|
|
117
|
+
# Belt-and-braces against an escape from `repo_path`: resolve the
|
|
118
|
+
# candidate and the base, then confirm the candidate stays under
|
|
119
|
+
# the base. This guards against any future regex regression as
|
|
120
|
+
# well as edge cases like symlinks pointing outside the tree.
|
|
121
|
+
try:
|
|
122
|
+
base = repo_path.resolve()
|
|
123
|
+
candidate = (repo_path / value).resolve()
|
|
124
|
+
except (OSError, RuntimeError):
|
|
125
|
+
# Resolution failure (e.g. permission denied, symlink loop) —
|
|
126
|
+
# treat as inline so we never silently read a surprising file.
|
|
127
|
+
return value
|
|
128
|
+
try:
|
|
129
|
+
candidate.relative_to(base)
|
|
130
|
+
except ValueError:
|
|
131
|
+
# Path escapes the repo root — treat as inline rather than
|
|
132
|
+
# reading a file outside the trusted tree.
|
|
133
|
+
return value
|
|
134
|
+
try:
|
|
135
|
+
return candidate.read_text(encoding="utf-8", errors="replace")
|
|
136
|
+
except (OSError, FileNotFoundError):
|
|
137
|
+
# File missing — fall back to inline so the value is at least
|
|
138
|
+
# surfaced in the prompt rather than silently defaulting.
|
|
139
|
+
return value
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class QuietHours:
|
|
145
|
+
"""A machine's recurring daily no-new-dispatch window (#1862).
|
|
146
|
+
|
|
147
|
+
``start``/``end`` are wall-clock ``datetime.time`` values evaluated in
|
|
148
|
+
``tz`` — a REQUIRED IANA zone name (see ``coord.config._parse_machines``,
|
|
149
|
+
which refuses to parse a ``quiet_hours`` block with a missing/invalid
|
|
150
|
+
``tz`` rather than silently defaulting). `coord serve` runs on UTC; a
|
|
151
|
+
naive ``"23:00"`` compared against the daemon's own clock would fire at
|
|
152
|
+
the wrong wall-clock hour for any non-UTC operator — exactly the bug
|
|
153
|
+
this field exists to prevent.
|
|
154
|
+
|
|
155
|
+
This reuses ``coord.machine_pause``'s existing routing-pause semantics:
|
|
156
|
+
it governs the ROUTING decision for *new* dispatch only and never
|
|
157
|
+
cancels an in-flight assignment — a task still running when the window
|
|
158
|
+
opens finishes normally.
|
|
159
|
+
|
|
160
|
+
``start == end`` is rejected at config-parse time (ambiguous — "quiet
|
|
161
|
+
all day" vs "never quiet" — rather than guessed; a machine that wants to
|
|
162
|
+
be quiet all day should just be `coord pause`d).
|
|
163
|
+
|
|
164
|
+
Known limitation (#1862 review, not blocking, no issue filed yet):
|
|
165
|
+
``covers()``/``window_end_instant()`` do plain wall-clock arithmetic via
|
|
166
|
+
``ZoneInfo`` with no special-casing for the two annual DST-transition
|
|
167
|
+
days in ``tz``. A window boundary that falls in a nonexistent (spring-
|
|
168
|
+
forward) or ambiguous (fall-back) local hour on those two days could be
|
|
169
|
+
off by up to an hour. Not called out by #1862 and not a regression from
|
|
170
|
+
anything that existed before it — a fix would need to decide which of
|
|
171
|
+
the two ambiguous instants "wins" and is a candidate for its own
|
|
172
|
+
follow-up rather than folding into this feature.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
start: time
|
|
176
|
+
end: time
|
|
177
|
+
tz: str
|
|
178
|
+
|
|
179
|
+
def covers(self, now: datetime | None = None) -> bool:
|
|
180
|
+
"""True when *now* falls inside this window, evaluated in ``tz``.
|
|
181
|
+
|
|
182
|
+
Half-open interval ``[start, end)``: the instant ``start`` is
|
|
183
|
+
covered, the instant ``end`` is not — the machine wakes up exactly
|
|
184
|
+
at ``end``, not a minute after. ``start > end`` denotes a window
|
|
185
|
+
that wraps midnight (e.g. ``23:00`` → ``08:00``).
|
|
186
|
+
"""
|
|
187
|
+
moment = self._local_time(now)
|
|
188
|
+
if self.start <= self.end:
|
|
189
|
+
return self.start <= moment < self.end
|
|
190
|
+
return moment >= self.start or moment < self.end
|
|
191
|
+
|
|
192
|
+
def window_end_instant(self, now: datetime | None = None) -> datetime:
|
|
193
|
+
"""The absolute UTC instant the window containing *now* ends.
|
|
194
|
+
|
|
195
|
+
Only meaningful when ``self.covers(now)`` is true — used by
|
|
196
|
+
``coord.machine_pause`` to size a ``coord unpause`` override so it
|
|
197
|
+
expires exactly when the window would have anyway, rather than a
|
|
198
|
+
fixed duration that could outlive or undershoot it.
|
|
199
|
+
"""
|
|
200
|
+
now = self._aware(now)
|
|
201
|
+
zone = ZoneInfo(self.tz)
|
|
202
|
+
local_now = now.astimezone(zone)
|
|
203
|
+
end_dt = local_now.replace(
|
|
204
|
+
hour=self.end.hour, minute=self.end.minute, second=0, microsecond=0,
|
|
205
|
+
)
|
|
206
|
+
if self.start > self.end and local_now.time() >= self.start:
|
|
207
|
+
# Wrapping window, currently in the "evening" half — the window
|
|
208
|
+
# ends tomorrow's clock time, not today's.
|
|
209
|
+
end_dt = end_dt + timedelta(days=1)
|
|
210
|
+
return end_dt.astimezone(timezone.utc)
|
|
211
|
+
|
|
212
|
+
def _local_time(self, now: datetime | None) -> time:
|
|
213
|
+
return self._aware(now).astimezone(ZoneInfo(self.tz)).time()
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _aware(now: datetime | None) -> datetime:
|
|
217
|
+
now = now if now is not None else datetime.now(timezone.utc)
|
|
218
|
+
return now if now.tzinfo is not None else now.replace(tzinfo=timezone.utc)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass
|
|
222
|
+
class Machine:
|
|
223
|
+
name: str
|
|
224
|
+
host: str
|
|
225
|
+
capabilities: list[str] = field(default_factory=list)
|
|
226
|
+
repos: list[str] = field(default_factory=list)
|
|
227
|
+
repo_paths: dict[str, str] = field(default_factory=dict)
|
|
228
|
+
# #1417: optional per-machine override of `concurrency.max_workers`.
|
|
229
|
+
# `None` means "no override" — the machine's effective cap is the
|
|
230
|
+
# fleet-wide default. Set this lower on hardware that can't keep up with
|
|
231
|
+
# the fleet norm (e.g. a 4-core box among 20-core desktops) so automated
|
|
232
|
+
# capacity checks (`coord retry`) don't pile concurrent workers onto it.
|
|
233
|
+
max_workers: int | None = None
|
|
234
|
+
# #1862: optional recurring daily no-new-dispatch window. `None` (unset,
|
|
235
|
+
# the default) means "behaves exactly as before this feature" — see
|
|
236
|
+
# `QuietHours` above. Routing consults this only through
|
|
237
|
+
# `coord.machine_pause.paused_set()`, never directly.
|
|
238
|
+
quiet_hours: QuietHours | None = None
|
|
239
|
+
|
|
240
|
+
def can_work_on(self, repo_name: str) -> bool:
|
|
241
|
+
return repo_name in self.repos
|
|
242
|
+
|
|
243
|
+
def repo_path(self, repo_name: str) -> str | None:
|
|
244
|
+
return self.repo_paths.get(repo_name)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# #930 review fix: assignment ``type`` values that should flow through the
|
|
248
|
+
# normal Work → Test → Review → Merge pipeline like any other "work" — i.e.
|
|
249
|
+
# their completion is eligible for `coord.review.dispatch_review` /
|
|
250
|
+
# `dispatch_pending_reviews` and for the merge-queue auto-enqueue
|
|
251
|
+
# (`coord.merge_queue.enqueue_approved_work`, `coord.commands.merge`'s
|
|
252
|
+
# auto-enqueue scan). "work" is the default worker type; "mock-author"
|
|
253
|
+
# (#930, Gate A) commits `tests/acceptance/ms-NN/contract.md` and its own
|
|
254
|
+
# docstring/system-prompt promise it dispatches through the *same* pipeline
|
|
255
|
+
# as any other branch — so every completion-side filter that only matched
|
|
256
|
+
# ``type == "work"`` must also match this set, or a mock-author branch can
|
|
257
|
+
# never actually reach a review or the merge queue. "test-author" (#931,
|
|
258
|
+
# #1141) is structurally identical — it commits only
|
|
259
|
+
# `tests/acceptance/ms-NN/**` and needs a `skipped` test verdict — but was
|
|
260
|
+
# never added when mock-author landed, so every per-issue JIT acceptance
|
|
261
|
+
# slice silently stalled before review/merge with no error (confirmed live,
|
|
262
|
+
# #1141). Keep this set — not a bare string check — as the single source of
|
|
263
|
+
# truth so a future work-like type only needs to be added here.
|
|
264
|
+
WORK_LIKE_TYPES: frozenset[str] = frozenset({"work", "mock-author", "test-author"})
|
|
265
|
+
|
|
266
|
+
# #1175: subset of WORK_LIKE_TYPES whose entire job is writing under a
|
|
267
|
+
# repo's sealed acceptance paths (docs/ORACLE_LOOP.md — today just
|
|
268
|
+
# `tests/acceptance/`). `coord.review`'s oracle-tamper rule normally treats
|
|
269
|
+
# ANY diff touching a sealed path as a mandatory `request-changes` — correct
|
|
270
|
+
# for a `type="work"` PR (a worker must never edit the suite it's graded
|
|
271
|
+
# against) but a guaranteed false positive for these types, since authoring
|
|
272
|
+
# `tests/acceptance/ms-NN/**` (contract.md + mocks for "mock-author", the
|
|
273
|
+
# acceptance slice itself for "test-author") *is* the assignment. For these
|
|
274
|
+
# types the rule inverts: the violation is touching anything OUTSIDE the
|
|
275
|
+
# sealed path, not touching it. Keep this as a set here (not a bare string
|
|
276
|
+
# check in coord/review.py) so a future sealed-path-authoring type only
|
|
277
|
+
# needs to be added in one place.
|
|
278
|
+
SEALED_PATH_AUTHOR_TYPES: frozenset[str] = frozenset({"test-author", "mock-author"})
|
|
279
|
+
|
|
280
|
+
# #1077: subset of WORK_LIKE_TYPES whose ``issue_number`` is the issue the PR
|
|
281
|
+
# actually *resolves* — i.e. merging it should auto-close that issue. "work"
|
|
282
|
+
# qualifies. "mock-author" (Gate A) is WORK_LIKE (it flows through the same
|
|
283
|
+
# Work → Test → Review → Merge pipeline) but its ``issue_number`` is the
|
|
284
|
+
# *milestone's tracking/epic issue* (see ``coord/mock_author.py``), not
|
|
285
|
+
# something the contract PR resolves — merging it must NOT close that issue,
|
|
286
|
+
# or the epic reads as "done" while its real sub-issues are still open/
|
|
287
|
+
# untouched (claude-coordinator#1041 got closed this way). This is the single
|
|
288
|
+
# source of truth for both the PR-body "Closes #N" vs "Refs #N" keyword
|
|
289
|
+
# choice (``coord/review.py``, ``coord/merge_queue.py``'s ``_briefing_body``)
|
|
290
|
+
# and the deterministic post-merge ``close_issue`` call
|
|
291
|
+
# (``coord/merge_queue.py``'s ``process``). "test-author" (#1141) deliberately
|
|
292
|
+
# stays OUT of this set too — its ``issue_number`` is likewise always the
|
|
293
|
+
# milestone's tracking issue (see ``for_issue_number`` below for the actual
|
|
294
|
+
# per-slice issue), never something the contract/fixture PR resolves.
|
|
295
|
+
# Verified against confirmed-live behaviour (PR #1139 correctly used
|
|
296
|
+
# "Refs #1117"), not merely assumed.
|
|
297
|
+
CLOSES_ISSUE_TYPES: frozenset[str] = frozenset({"work"})
|
|
298
|
+
|
|
299
|
+
# #1142: the assignment `type` `coord pr` gives its PR-opening helper session
|
|
300
|
+
# when the *original* assignment it's opening a PR for is NOT itself in
|
|
301
|
+
# ``CLOSES_ISSUE_TYPES`` (e.g. "test-author"/"mock-author", whose
|
|
302
|
+
# ``issue_number`` is the milestone's tracking issue, not something this PR
|
|
303
|
+
# resolves — see ``CLOSES_ISSUE_TYPES`` above). Before #1142, `coord pr`
|
|
304
|
+
# unconditionally dispatched its helper with the default `type="work"`,
|
|
305
|
+
# which made ``coord.stage_projection.merge_stage_status_for``'s #775
|
|
306
|
+
# fallback (and ``issue_has_any_approved_review``) mistake a merged
|
|
307
|
+
# PR-helper for the *tracking issue's own* merged work, showing an open epic
|
|
308
|
+
# as "Done". Giving the helper this distinct type keeps it out of both
|
|
309
|
+
# ``WORK_LIKE_TYPES`` and ``CLOSES_ISSUE_TYPES`` (and therefore out of every
|
|
310
|
+
# heuristic keyed on either) while still being dispatched exactly like a
|
|
311
|
+
# normal "work" session otherwise — see ``coord.agent.WRITE_CAPABLE_SPEC_TYPES``,
|
|
312
|
+
# which must also list it since it mutates GitHub via `gh pr create`.
|
|
313
|
+
PR_HELPER_TYPE = "pr-helper"
|
|
314
|
+
|
|
315
|
+
# #685: the per-issue Test-stage POLICY labels, and the one pure function that
|
|
316
|
+
# reads them. ``test-mode:auto`` → the headless Test stage auto-dispatches
|
|
317
|
+
# (`coord.smoke.dispatch_pending_smoke`); ``test-mode:smoke`` → it deliberately
|
|
318
|
+
# does NOT, because the Test stage for that issue is human-attended (the TUI
|
|
319
|
+
# offers an interactive smoke agent instead); no label → back-compat, respect
|
|
320
|
+
# ``smoke_tests.auto_queue``.
|
|
321
|
+
#
|
|
322
|
+
# #2024: this used to be an inline pair of `in labels` checks inside
|
|
323
|
+
# ``coord.state._get_issue_test_mode_local`` and nowhere else, because only the
|
|
324
|
+
# dispatcher ever asked. The DRIVER has to ask too — a `test-mode:smoke` issue
|
|
325
|
+
# is precisely the shape where a completed fix round's Test stage will never be
|
|
326
|
+
# dispatched by anything automatic (``coord.dead_end`` shape 3), and a driver
|
|
327
|
+
# that can't see the policy counts "no state change" against a transition that
|
|
328
|
+
# is never coming (vimcode#635: 25 min, then 160 min, on one issue). Hoisted
|
|
329
|
+
# here — import-light, no DB — so the dispatcher's reading and the driver's
|
|
330
|
+
# reading are the same three lines and cannot drift.
|
|
331
|
+
TEST_MODE_AUTO_LABEL = "test-mode:auto"
|
|
332
|
+
TEST_MODE_SMOKE_LABEL = "test-mode:smoke"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_mode_from_labels(labels) -> str | None:
|
|
336
|
+
"""``"auto"`` / ``"smoke"`` / ``None`` for an issue's GitHub *labels*.
|
|
337
|
+
|
|
338
|
+
``auto`` wins when both are present (an explicit opt-in to the headless
|
|
339
|
+
path beats the human-attended default), matching the original
|
|
340
|
+
``_get_issue_test_mode_local`` ordering exactly. Tolerates ``None`` and
|
|
341
|
+
any non-list input by returning ``None`` — every caller here fails OPEN
|
|
342
|
+
onto "no policy set", never onto a policy nobody asked for.
|
|
343
|
+
"""
|
|
344
|
+
if not labels:
|
|
345
|
+
return None
|
|
346
|
+
try:
|
|
347
|
+
names = list(labels)
|
|
348
|
+
except TypeError:
|
|
349
|
+
return None
|
|
350
|
+
if TEST_MODE_AUTO_LABEL in names:
|
|
351
|
+
return "auto"
|
|
352
|
+
if TEST_MODE_SMOKE_LABEL in names:
|
|
353
|
+
return "smoke"
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass
|
|
358
|
+
class Assignment:
|
|
359
|
+
machine_name: str
|
|
360
|
+
repo_name: str
|
|
361
|
+
issue_number: int
|
|
362
|
+
issue_title: str
|
|
363
|
+
files_allowed: list[str] = field(default_factory=list)
|
|
364
|
+
files_forbidden: list[str] = field(default_factory=list)
|
|
365
|
+
briefing: str = ""
|
|
366
|
+
assignment_id: str | None = None
|
|
367
|
+
status: str = "pending" # pending | running | done | failed | advisory
|
|
368
|
+
branch: str | None = None
|
|
369
|
+
pr_url: str | None = None
|
|
370
|
+
dispatched_at: float | None = None
|
|
371
|
+
finished_at: float | None = None
|
|
372
|
+
smoke_test: str | None = None # None | pass | fail
|
|
373
|
+
smoke_test_reason: str | None = None
|
|
374
|
+
# "work" (default), "review", "plan", "smoke", "conflict-fix", "audit",
|
|
375
|
+
# or a handful of other human-attended flavours (troubleshoot, chat,
|
|
376
|
+
# merge). Review assignments target an existing PR rather than
|
|
377
|
+
# implementing a fresh issue. Plan assignments are read-only: the worker
|
|
378
|
+
# analyses the codebase and outputs a structured plan without writing any
|
|
379
|
+
# code. conflict-fix is dispatched when a merge fails with a mechanical
|
|
380
|
+
# (non-semantic) conflict — the worker rebases, resolves obvious
|
|
381
|
+
# additive merges, and force-pushes; the coordinator owns the retry.
|
|
382
|
+
# audit (#885) is a read-only, human-attended milestone-outcome analyst —
|
|
383
|
+
# dispatched via `--audit-of <epic_issue>`, never through the headless
|
|
384
|
+
# Work → Test → Review → Merge pipeline.
|
|
385
|
+
type: str = "work"
|
|
386
|
+
review_target: str | None = None
|
|
387
|
+
review_of_assignment_id: str | None = None
|
|
388
|
+
unreachable_count: int = 0
|
|
389
|
+
# Model tier the worker was dispatched with (e.g. "haiku", "sonnet",
|
|
390
|
+
# "opus"). None means the worker used claude's default. Tracked on the
|
|
391
|
+
# board so escalation in `coord fix` / `coord retry` / `coord resume-stuck`
|
|
392
|
+
# can step up the ladder.
|
|
393
|
+
model: str | None = None
|
|
394
|
+
# Parsed structured plan from a plan-only worker (type="plan"). Stored as
|
|
395
|
+
# a plain dict (the serialised form of WorkerPlan.to_dict()) so it round-
|
|
396
|
+
# trips cleanly through JSON without a custom encoder.
|
|
397
|
+
plan: dict | None = None
|
|
398
|
+
# Review lifecycle state for type="work" assignments.
|
|
399
|
+
# None — not applicable (review/smoke/plan assignments, or pre-feature boards)
|
|
400
|
+
# "pending" — work done, review not yet dispatched
|
|
401
|
+
# "dispatched" — review assignment is in flight
|
|
402
|
+
# "done" — review assignment completed
|
|
403
|
+
review_state: str | None = None
|
|
404
|
+
# #1627: human-readable reason `dispatch_review()` set on THIS assignment
|
|
405
|
+
# the last time it returned ``None`` without dispatching a review — e.g.
|
|
406
|
+
# "assignment is type 'smoke', not reviewable work" or "a work/fix
|
|
407
|
+
# assignment is actively rewriting the branch for this issue". Transient:
|
|
408
|
+
# set in-memory on every early-return guard so a caller in the same
|
|
409
|
+
# process (the `coord review` CLI, chiefly) can report *why* nothing was
|
|
410
|
+
# dispatched instead of guessing. Not read back out of storage by
|
|
411
|
+
# `row_to_assignment` — it isn't meant to survive a reload, only the one
|
|
412
|
+
# dispatch_review() call that produced it.
|
|
413
|
+
review_dispatch_reason: str | None = None
|
|
414
|
+
# Pipeline gate requirements — controls which approval steps are enforced.
|
|
415
|
+
# Empty list means "use config.pipeline.default_gates".
|
|
416
|
+
# Examples: ["review", "merge"], ["merge"], ["review", "smoke", "merge"]
|
|
417
|
+
required_gates: list[str] = field(default_factory=list)
|
|
418
|
+
# Auto-loop iteration counter. For the original work assignment this is 0.
|
|
419
|
+
# Each fix worker dispatched by auto_loop increments this by 1. Used to
|
|
420
|
+
# enforce pipeline.max_review_iterations and stop runaway loops.
|
|
421
|
+
review_iteration: int = 0
|
|
422
|
+
# Timestamp when review findings were successfully posted to GitHub (as a
|
|
423
|
+
# PR review or issue comment). None means findings have not been posted
|
|
424
|
+
# yet — either the review is still running, the worker produced no
|
|
425
|
+
# structured output, or notify never saw the completion event.
|
|
426
|
+
review_posted_at: float | None = None
|
|
427
|
+
# #200: human-driven Test gate verdict for type="work" assignments.
|
|
428
|
+
# None | "passed" | "failed" | "skipped". Review auto-dispatch is gated on
|
|
429
|
+
# this being passed/skipped (or no Test stage configured).
|
|
430
|
+
# #1395: also "running" — a transient, non-verdict marker an unattended
|
|
431
|
+
# driver (scripts/drive-issue.sh) sets while it runs the suite locally
|
|
432
|
+
# (bypassing dispatch_smoke), so coord.stage_projection.test_stage_status_for
|
|
433
|
+
# can show the Test box Active instead of indistinguishable-from-idle
|
|
434
|
+
# Pending. Every gate that reads this field keys off the terminal
|
|
435
|
+
# passed/skipped/failed values explicitly, so "running" fails closed
|
|
436
|
+
# everywhere by construction — never add a bare `is not None` check here.
|
|
437
|
+
test_state: str | None = None
|
|
438
|
+
test_reason: str | None = None
|
|
439
|
+
# #1479: staleness anchor for a terminal (passed/skipped) Test-gate
|
|
440
|
+
# verdict — captured once, best-effort, when the verdict is recorded
|
|
441
|
+
# (``coord.state._record_test_verdict_local``). Mirrors the review gate's
|
|
442
|
+
# ``review_head_sha``/``review_patch_id`` (#821/#1475) but adds a THIRD
|
|
443
|
+
# value the review gate deliberately doesn't need: the merge base's own
|
|
444
|
+
# HEAD SHA at test time. Review staleness is about what changed in the
|
|
445
|
+
# branch; test staleness is also about what the branch was combined
|
|
446
|
+
# with — a rebase onto a moved base can break tests without changing the
|
|
447
|
+
# branch's own diff, so the merge gate (``coord.merge_queue.
|
|
448
|
+
# has_smoke_verdict``) must re-verify even when the content is byte-
|
|
449
|
+
# identical. All three are None for rows predating this feature or where
|
|
450
|
+
# the anchor could not be captured (fails open — the staleness check is
|
|
451
|
+
# skipped, matching #821/#1475's convention).
|
|
452
|
+
test_head_sha: str | None = None
|
|
453
|
+
test_patch_id: str | None = None
|
|
454
|
+
test_base_sha: str | None = None
|
|
455
|
+
# #1629 (H-2): the toolchain that produced this verdict — e.g.
|
|
456
|
+
# "rustc 1.95.0" or "python 3.12.4, node 20.11.0" — captured (best-effort,
|
|
457
|
+
# via coord.health.checks.toolchain) alongside a terminal test_state
|
|
458
|
+
# write. Annotation only: no gate reads this field to block anything (see
|
|
459
|
+
# coord.health.checks.toolchain.probe_toolchain_skew, the fleet-scope
|
|
460
|
+
# check that judges skew — it is advisory, same as every other fleet
|
|
461
|
+
# check). None for every row predating this feature or where the
|
|
462
|
+
# producing toolchain could not be resolved — renders as "unknown", not
|
|
463
|
+
# as a mismatch.
|
|
464
|
+
test_toolchain: str | None = None
|
|
465
|
+
# #253: parsed adversarial-review verdict for type="review" assignments.
|
|
466
|
+
# None | "approve" | "request-changes". Set when notify or auto_loop
|
|
467
|
+
# extracts the structured REVIEW_VERDICT from the reviewer's log; consumed
|
|
468
|
+
# by the merge-queue gate (`has_approved_review`) to refuse merging work
|
|
469
|
+
# whose review has not approved.
|
|
470
|
+
review_verdict: str | None = None
|
|
471
|
+
# #1456: audit trail for a coordinator-side verdict override. When the
|
|
472
|
+
# #476 approve-with-nits gate downgrades a reviewer's "request-changes" to
|
|
473
|
+
# "approve", the reviewer's OWN verdict is preserved here and the evidence
|
|
474
|
+
# that justified the override (the parsed finding counts) in
|
|
475
|
+
# `review_verdict_override_reason`. Both are None for the overwhelming
|
|
476
|
+
# majority of reviews — a non-None `review_verdict_original` is the signal
|
|
477
|
+
# that `review_verdict` is the coordinator's opinion, not the reviewer's.
|
|
478
|
+
# A verdict that changes must be auditable, never overwritten in place: the
|
|
479
|
+
# #1445 incident (a well-formed request-changes silently rewritten to
|
|
480
|
+
# approve) was invisible precisely because only the final value was stored.
|
|
481
|
+
review_verdict_original: str | None = None
|
|
482
|
+
review_verdict_override_reason: str | None = None
|
|
483
|
+
# #1956: provenance for `review_verdict` — WHO decided it and HOW, not
|
|
484
|
+
# just what was decided. A relayed verdict (`coord report-result
|
|
485
|
+
# --verdict` posted by an operator, not parsed from the reviewer's own
|
|
486
|
+
# log) is otherwise indistinguishable from one the reviewer agent
|
|
487
|
+
# produced itself — every downstream reader (merge gate, `coord gates`,
|
|
488
|
+
# the TUI) sees a plain "approve" either way. Three values:
|
|
489
|
+
# "agent" — parsed from the reviewer's own transcript (the
|
|
490
|
+
# overwhelming common case; also the default when this
|
|
491
|
+
# column is NULL, for every row predating this feature).
|
|
492
|
+
# "recovered" — the reviewer reached a verdict and said so in prose,
|
|
493
|
+
# but never emitted the machine-readable REVIEW_VERDICT
|
|
494
|
+
# header; an operator (or the #1956 transcript-floor
|
|
495
|
+
# fallback) rescued it from the transcript. Asserts
|
|
496
|
+
# "the reviewer decided this, we merely restored it."
|
|
497
|
+
# "overridden" — a human (or the #476 approve-with-nits gate) recorded
|
|
498
|
+
# a DIFFERENT verdict than the reviewer's own. Asserts
|
|
499
|
+
# "the reviewer decided otherwise and this overrides
|
|
500
|
+
# it." Always paired with `review_verdict_original`
|
|
501
|
+
# when the override happened automatically (#476); a
|
|
502
|
+
# manual override may have no prior agent verdict at
|
|
503
|
+
# all (e.g. an interactive review that never finished).
|
|
504
|
+
# `verdict_source_reason` is a required, human-readable justification for
|
|
505
|
+
# anything that isn't "agent" — see issue_store._validate_result.
|
|
506
|
+
verdict_source: str | None = None
|
|
507
|
+
verdict_source_reason: str | None = None
|
|
508
|
+
# #821: SHA of the branch HEAD captured at the time the review assignment
|
|
509
|
+
# ran. When set, `has_approved_review` compares this against the merge
|
|
510
|
+
# queue entry's `branch_head_sha` to reject stale approvals — if the
|
|
511
|
+
# branch gained commits after the review ran, the approval no longer
|
|
512
|
+
# covers the current HEAD and the entry is re-blocked until re-reviewed.
|
|
513
|
+
# None for review assignments predating this field or where SHA tracking
|
|
514
|
+
# is not available.
|
|
515
|
+
review_head_sha: str | None = None
|
|
516
|
+
# #1475: content-addressed fingerprint (`git patch-id --stable`) of the
|
|
517
|
+
# diff the review covered, captured alongside `review_head_sha`. When a
|
|
518
|
+
# later commit-bound staleness check finds the SHAs differ (e.g. a
|
|
519
|
+
# conflict-fix rebase moved the head), `has_approved_review` falls back
|
|
520
|
+
# to comparing this against the branch's *current* patch-id — identical
|
|
521
|
+
# ⇒ the rebase changed no content and the approval still covers it;
|
|
522
|
+
# different (or either side unavailable) ⇒ stale, same as before this
|
|
523
|
+
# field existed. Stored alongside `review_head_sha`, never replacing it
|
|
524
|
+
# — the SHA remains the audit/#821 trail. None for rows predating #1475
|
|
525
|
+
# or where the diff/patch-id could not be computed.
|
|
526
|
+
review_patch_id: str | None = None
|
|
527
|
+
# #1476: True when this ``type="review"`` assignment is a SCOPED
|
|
528
|
+
# re-review — dispatched because a conflict-fix rebase changed content
|
|
529
|
+
# under an already-`approve`d review (`review_patch_id` mismatch), with
|
|
530
|
+
# no other intervening work/fix commit. The reviewer was handed the
|
|
531
|
+
# prior approved diff as established context plus only the resolution
|
|
532
|
+
# delta, not the full PR — so a `False`/default here means "read the
|
|
533
|
+
# whole diff" for every audit consumer. `review_scope_base_sha` records
|
|
534
|
+
# the prior review's `review_head_sha`, i.e. which commit the delta was
|
|
535
|
+
# computed FROM, so the audit trail can reconstruct exactly what was
|
|
536
|
+
# (and wasn't) re-read. Both None/False for every row predating this
|
|
537
|
+
# feature and for ordinary full reviews.
|
|
538
|
+
review_scoped: bool = False
|
|
539
|
+
review_scope_base_sha: str | None = None
|
|
540
|
+
# #208: parsed worker cost from the final stream-json `result` event.
|
|
541
|
+
# None means "not yet captured" (older rows, in-flight workers, or
|
|
542
|
+
# workers whose log lacked usage data). Set on completion by
|
|
543
|
+
# notify.py / reconcile via coord.usage.parse_usage_from_log.
|
|
544
|
+
cost_usd: float | None = None
|
|
545
|
+
# #252: worker-emitted smoke-test list parsed from the SMOKE_TESTS
|
|
546
|
+
# block. None = no block emitted (graceful TUI placeholder); [] =
|
|
547
|
+
# explicit "(none — change is internal)"; non-empty list = bullets.
|
|
548
|
+
smoke_tests: list[str] | None = None
|
|
549
|
+
# #324: resolved provider name recorded at dispatch time so the TUI
|
|
550
|
+
# can surface it in the assignment detail panel (#327). None means
|
|
551
|
+
# "dispatched before #324 landed or via a path that doesn't set this
|
|
552
|
+
# field" — the TUI should show the implicit default ("claude") in that
|
|
553
|
+
# case. Always the *resolved* name (after the spec > repo > default
|
|
554
|
+
# precedence chain), not just the raw proposal.provider field.
|
|
555
|
+
provider_name: str | None = None
|
|
556
|
+
# #546: token counts for automated (claude -p) assignments. Parsed from
|
|
557
|
+
# the final stream-json result event at the same time as cost_usd. All
|
|
558
|
+
# default to 0; interactive (Max/OAuth) sessions stay at 0 and the TUI
|
|
559
|
+
# labels them "Max (subscription)" rather than projecting a dollar figure.
|
|
560
|
+
input_tokens: int = 0
|
|
561
|
+
output_tokens: int = 0
|
|
562
|
+
cache_creation_tokens: int = 0
|
|
563
|
+
cache_read_tokens: int = 0
|
|
564
|
+
# #618: short one-liner written immediately when an interactive session
|
|
565
|
+
# fails to launch (e.g. "branch already checked out at <path>"). Lets
|
|
566
|
+
# the TUI explain the red box without any log file being present.
|
|
567
|
+
# None for assignments that launched successfully.
|
|
568
|
+
failure_reason: str | None = None
|
|
569
|
+
# #944: Acceptance-gate verdict (oracle loop, docs/ORACLE_LOOP.md) for
|
|
570
|
+
# type="work" assignments. None | "passed" | "failed" — set by `coord
|
|
571
|
+
# acceptance record --issue N --sha <sha>`, the coordinator's external
|
|
572
|
+
# re-run of the sealed suite against the pushed SHA (the trust gate a
|
|
573
|
+
# headless worker's in-session "green" claim can't fake).
|
|
574
|
+
acceptance_state: str | None = None
|
|
575
|
+
acceptance_reason: str | None = None
|
|
576
|
+
# SHA the last `acceptance record` verdict was recorded against — lets a
|
|
577
|
+
# future gate detect staleness (new commits since the last record) the
|
|
578
|
+
# same way review_head_sha detects a stale review approval.
|
|
579
|
+
acceptance_sha: str | None = None
|
|
580
|
+
# #932: per-test counts from the same verdict, so the Acceptance box can
|
|
581
|
+
# read as partial progress ("3/7 acceptance green") rather than a bare
|
|
582
|
+
# pass/fail — a growing suite is expected to be sub-100% until the
|
|
583
|
+
# feature completes (docs/ORACLE_LOOP.md). None for rows predating #932.
|
|
584
|
+
acceptance_total: int | None = None
|
|
585
|
+
acceptance_passed: int | None = None
|
|
586
|
+
# #874: prose summary extracted from the worker's "### Summary" block
|
|
587
|
+
# and persisted at completion time. NULL when the worker emitted no
|
|
588
|
+
# summary. A durable, board-sourced complement to the ephemeral GitHub
|
|
589
|
+
# comment (which already contained the same text).
|
|
590
|
+
completion_summary: str | None = None
|
|
591
|
+
# #886 Phase 2: Milestone Outcome Audit structured verdict, set only on
|
|
592
|
+
# type="audit" assignments (see #885's --audit-of). audit_goals_json is
|
|
593
|
+
# a raw JSON string — a list of {goal, metric_before, metric_after,
|
|
594
|
+
# verdict, evidence} — deliberately NOT decoded here (mirrors
|
|
595
|
+
# review_findings: the coord-tui client consumes it as Option<String>).
|
|
596
|
+
# audit_run_number increments once per `--audit-of <epic>` run against
|
|
597
|
+
# the same (repo_name, issue_number) so later runs can diff against
|
|
598
|
+
# earlier ones. All None for rows predating this feature.
|
|
599
|
+
audit_goals_json: str | None = None
|
|
600
|
+
audit_bottom_line: str | None = None
|
|
601
|
+
audit_run_number: int | None = None
|
|
602
|
+
# #1084: for type="test-author" assignments, the specific work-order
|
|
603
|
+
# member issue this JIT dispatch is extending the acceptance suite for
|
|
604
|
+
# (`coord.test_author.dispatch_test_author`'s `issue_number` argument).
|
|
605
|
+
# NOT the same as `issue_number` above, which test-author always sets to
|
|
606
|
+
# the milestone's *tracking* issue (every JIT dispatch for a milestone
|
|
607
|
+
# shares one branch/PR, so `issue_number` alone can't distinguish "this
|
|
608
|
+
# is issue #1039's slice" from "issue #1042's slice" — see #1084's
|
|
609
|
+
# friction log). None for milestone-mode (Gate A) authoring and for
|
|
610
|
+
# every other assignment type.
|
|
611
|
+
#
|
|
612
|
+
# #1553: this is now the *attribution* field for the whole oracle-loop
|
|
613
|
+
# slice chain, not just the originating test-author dispatch. Every
|
|
614
|
+
# follow-up derived from a slice (its review, its `[fix-N]` bounces, its
|
|
615
|
+
# smoke, a retry) inherits the same value, either explicitly at the
|
|
616
|
+
# dispatch site or via the parent lookup in
|
|
617
|
+
# `coord.state._record_dispatched_assignment_local`. Read it through
|
|
618
|
+
# :func:`effective_issue_number` rather than by hand so "which issue is
|
|
619
|
+
# this work actually for" has one answer everywhere.
|
|
620
|
+
for_issue_number: int | None = None
|
|
621
|
+
# #1499: durable provenance — set when this assignment was dispatched by
|
|
622
|
+
# `coord drive` (never by a hand `coord assign`). Carries
|
|
623
|
+
# `f"drive:{repo_name}#{issue_number}"` (the DRIVEN issue, which for most
|
|
624
|
+
# assignment types is the same repo/issue this row is already keyed on,
|
|
625
|
+
# but is spelled out explicitly so the value is self-describing on its
|
|
626
|
+
# own in the audit log / board without a join). `None` means "dispatched
|
|
627
|
+
# by hand" (or predates this column) — the whole point is that a drive's
|
|
628
|
+
# own dispatches are distinguishable from a human's after the driver
|
|
629
|
+
# process has exited and left nothing else behind. Threaded through
|
|
630
|
+
# `coord assign --driven-by` (`coord.commands.dispatch.assign`), which
|
|
631
|
+
# `coord/drive.py`'s work-stage `Action` sets on every `coord assign` it
|
|
632
|
+
# shells out to.
|
|
633
|
+
driven_by: str | None = None
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def effective_issue_number(assignment: "Assignment | dict") -> int:
|
|
637
|
+
"""The issue this assignment's work is *attributed* to (#1553).
|
|
638
|
+
|
|
639
|
+
For ordinary work this is simply ``issue_number``. For oracle-loop
|
|
640
|
+
acceptance-slice work (``coord acceptance author <repo> <tracking>
|
|
641
|
+
--issue N`` and everything derived from it — its review, its
|
|
642
|
+
``[fix-N]`` bounces, its smoke, a retry) ``issue_number`` is the
|
|
643
|
+
milestone's **tracking/epic** issue, because the whole milestone's JIT
|
|
644
|
+
slices share one branch and one PR. The child issue the work is really
|
|
645
|
+
*for* lives in ``for_issue_number``; this helper prefers it.
|
|
646
|
+
|
|
647
|
+
Both halves are deliberately kept on the row:
|
|
648
|
+
|
|
649
|
+
* ``issue_number`` stays the tracking issue, so the epic keeps its
|
|
650
|
+
parent link, the shared branch/PR bookkeeping keeps working, and
|
|
651
|
+
``coord.stage_projection``'s merge attribution keeps deliberately
|
|
652
|
+
skipping non-``CLOSES_ISSUE_TYPES`` rows (re-attributing *that* was
|
|
653
|
+
tried in #1203 and reverted in #1652 — do not repeat it).
|
|
654
|
+
* ``for_issue_number`` is the effective/attributed issue, which is what
|
|
655
|
+
"is this child being worked on right now?" and "what did this child
|
|
656
|
+
cost?" must key on.
|
|
657
|
+
|
|
658
|
+
Accepts either an :class:`Assignment` or a wire/DB ``dict`` row so the
|
|
659
|
+
board-JSON consumers (``coord.usage_rollup``) share one definition.
|
|
660
|
+
Returns ``0`` for a row carrying neither field rather than raising —
|
|
661
|
+
every caller here is a display/aggregation path.
|
|
662
|
+
|
|
663
|
+
Readers that deliberately still key on the RAW ``issue_number``, so a
|
|
664
|
+
future change doesn't "fix" them by accident:
|
|
665
|
+
|
|
666
|
+
* ``coord.stage_projection.compute_board_stage_projection`` — the
|
|
667
|
+
per-issue stage grouping and its merge-queue attribution. #1203 tried
|
|
668
|
+
re-attributing that half by ``for_issue_number`` and #1652 reverted it
|
|
669
|
+
(it moved a false "merged/Done" green from the epic onto the child).
|
|
670
|
+
* ``coord.notify`` — ``_pipeline_heads``/``_has_live_session_for`` and
|
|
671
|
+
every GitHub-comment target. Stall notices are posted to the issue
|
|
672
|
+
whose branch/PR the work lives on, which for a slice IS the tracking
|
|
673
|
+
issue; re-keying only the heads would desync the two.
|
|
674
|
+
* ``coord.pipeline.PipelineView.issue_number`` — the CLI's per-assignment
|
|
675
|
+
pipeline view, which reports the row as dispatched.
|
|
676
|
+
|
|
677
|
+
``coord.claim.has_active_work_followup`` itself keys its scan on the
|
|
678
|
+
effective issue (it calls this helper internally), so BOTH of its
|
|
679
|
+
callers — ``coord.review.dispatch_review`` and
|
|
680
|
+
``coord.review.dispatch_pending_reviews`` — must pass
|
|
681
|
+
``effective_issue_number(...)`` too, not the raw ``.issue_number``.
|
|
682
|
+
Passing the raw value there silently reopens the #459 stale-review
|
|
683
|
+
guard for exactly the oracle-loop slices this function exists for: an
|
|
684
|
+
in-flight ``type="work"`` retry for child A (carrying
|
|
685
|
+
``for_issue_number``) would no longer be detected as "actively
|
|
686
|
+
rewriting the branch" when checking a completed round for that same
|
|
687
|
+
child, because the two sides would be comparing effective-vs-raw
|
|
688
|
+
instead of effective-vs-effective.
|
|
689
|
+
"""
|
|
690
|
+
if isinstance(assignment, dict):
|
|
691
|
+
raw_for = assignment.get("for_issue_number")
|
|
692
|
+
raw_own = assignment.get("issue_number")
|
|
693
|
+
else:
|
|
694
|
+
raw_for = getattr(assignment, "for_issue_number", None)
|
|
695
|
+
raw_own = getattr(assignment, "issue_number", None)
|
|
696
|
+
for raw in (raw_for, raw_own):
|
|
697
|
+
if raw is None or raw == "":
|
|
698
|
+
continue
|
|
699
|
+
try:
|
|
700
|
+
return int(raw)
|
|
701
|
+
except (TypeError, ValueError):
|
|
702
|
+
continue
|
|
703
|
+
return 0
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@dataclass
|
|
707
|
+
class Proposal:
|
|
708
|
+
id: int
|
|
709
|
+
machine_name: str
|
|
710
|
+
repo_name: str
|
|
711
|
+
issue_number: int
|
|
712
|
+
issue_title: str
|
|
713
|
+
rationale: str
|
|
714
|
+
files_likely: list[str] = field(default_factory=list)
|
|
715
|
+
briefing: str = ""
|
|
716
|
+
# Optional model override. When None, the dispatcher falls back to
|
|
717
|
+
# config.models.default.
|
|
718
|
+
model: str | None = None
|
|
719
|
+
# "work" (default) or "plan". Plan proposals dispatch read-only planning
|
|
720
|
+
# workers that analyse the codebase and produce a structured plan without
|
|
721
|
+
# writing any code.
|
|
722
|
+
type: str = "work"
|
|
723
|
+
# Pipeline gate requirements — mirrors Assignment.required_gates.
|
|
724
|
+
# Set by the coordinator before dispatch so the ledger records intent.
|
|
725
|
+
required_gates: list[str] = field(default_factory=list)
|
|
726
|
+
# Optional explicit branch the agent must check out, bypassing the
|
|
727
|
+
# slugified-title-derived branch name. Used by follow-up dispatches
|
|
728
|
+
# (pr, fix-up, continuation) so prefixed issue titles like
|
|
729
|
+
# `[fix-1] …` or `[conflict-fix] …` don't push to a new orphan
|
|
730
|
+
# branch — the worker must land commits on the parent assignment's
|
|
731
|
+
# branch instead.
|
|
732
|
+
target_branch: str | None = None
|
|
733
|
+
# #315: when set, the dispatch payload includes `--resume <session_id>`
|
|
734
|
+
# so the worker loads the prior claude conversation and continues it.
|
|
735
|
+
# Only set by `coord chat-continue`; regular dispatches leave this None.
|
|
736
|
+
resume_session_id: str | None = None
|
|
737
|
+
# #324: optional provider override for this proposal's worker. Mirrors
|
|
738
|
+
# ``Repo.provider`` and ``AssignmentSpec.provider`` — uses the same
|
|
739
|
+
# precedence chain: spec > repo > providers.default. When None the
|
|
740
|
+
# coordinator and agent both fall back to the global default. Set by
|
|
741
|
+
# ``coord assign --provider`` (#1707, the human escape hatch — validated
|
|
742
|
+
# against ``providers.definitions`` at the CLI before it ever reaches
|
|
743
|
+
# here) for a hand dispatch; brain-side automatic selection is not yet
|
|
744
|
+
# implemented (deliberately out of scope for #1707).
|
|
745
|
+
provider: str | None = None
|
|
746
|
+
# #934: the GitHub Milestone number the target issue belongs to, when
|
|
747
|
+
# known. Set by callers that already fetched the issue (e.g.
|
|
748
|
+
# ``coord.milestone_dispatch.dispatch_entry``) so ``coord.dispatch.
|
|
749
|
+
# dispatch()`` can resolve the worker's base branch via
|
|
750
|
+
# ``coord.branch_model.resolve_base_branch`` — `feature/ms-NN` for a repo
|
|
751
|
+
# that opted into the #934 git model, otherwise the existing
|
|
752
|
+
# `default_branch` behavior. `None` (the default) preserves today's
|
|
753
|
+
# behavior exactly: brain-proposed and other non-milestone-aware
|
|
754
|
+
# dispatches are unaffected.
|
|
755
|
+
milestone_number: int | None = None
|
|
756
|
+
# #1430: the issue's GitHub label names, when the caller already fetched
|
|
757
|
+
# them (avoids a redundant GH call). ``coord.dispatch.dispatch()``
|
|
758
|
+
# consults this via ``config.models.model_for_labels()`` to resolve
|
|
759
|
+
# ``models.labels`` for ``type="work"`` proposals when *model* wasn't
|
|
760
|
+
# already set by the caller. Empty by default — callers that don't
|
|
761
|
+
# populate it simply get today's ``models.default`` behavior.
|
|
762
|
+
issue_labels: list[str] = field(default_factory=list)
|
|
763
|
+
# #1499: mirrors `Assignment.driven_by` — set by `coord/drive.py`'s
|
|
764
|
+
# work-stage dispatch so the resulting assignment row (recorded via
|
|
765
|
+
# `_record_dispatched_local`, the Proposal-based INSERT path every plain
|
|
766
|
+
# non-`--interactive` `coord assign` — including `coord drive`'s — goes
|
|
767
|
+
# through) carries durable provenance. `None` for every other caller
|
|
768
|
+
# (brain-proposed, milestone dispatch, ...).
|
|
769
|
+
driven_by: str | None = None
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
@dataclass
|
|
773
|
+
class SplitChunk:
|
|
774
|
+
title: str
|
|
775
|
+
scope: str
|
|
776
|
+
files_likely: list[str] = field(default_factory=list)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
@dataclass
|
|
780
|
+
class SplitProposal:
|
|
781
|
+
id: int
|
|
782
|
+
repo_name: str
|
|
783
|
+
issue_number: int
|
|
784
|
+
issue_title: str
|
|
785
|
+
rationale: str
|
|
786
|
+
chunks: list[SplitChunk] = field(default_factory=list)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@dataclass
|
|
790
|
+
class Board:
|
|
791
|
+
repos: list[Repo] = field(default_factory=list)
|
|
792
|
+
machines: list[Machine] = field(default_factory=list)
|
|
793
|
+
active: list[Assignment] = field(default_factory=list)
|
|
794
|
+
completed: list[Assignment] = field(default_factory=list)
|
|
795
|
+
round_number: int = 0
|
|
796
|
+
|
|
797
|
+
def repo(self, name: str) -> Repo | None:
|
|
798
|
+
return next((r for r in self.repos if r.name == name), None)
|
|
799
|
+
|
|
800
|
+
def machine(self, name: str) -> Machine | None:
|
|
801
|
+
return next((m for m in self.machines if m.name == name), None)
|
|
802
|
+
|
|
803
|
+
def idle_machines(self) -> list[Machine]:
|
|
804
|
+
busy = {a.machine_name for a in self.active if a.status == "running"}
|
|
805
|
+
return [m for m in self.machines if m.name not in busy]
|
|
806
|
+
|
|
807
|
+
def active_files_by_repo(self) -> dict[str, list[str]]:
|
|
808
|
+
"""Map of repo_name -> files currently being touched by running assignments."""
|
|
809
|
+
result: dict[str, list[str]] = {}
|
|
810
|
+
for a in self.active:
|
|
811
|
+
if a.status != "running":
|
|
812
|
+
continue
|
|
813
|
+
result.setdefault(a.repo_name, []).extend(a.files_allowed)
|
|
814
|
+
return result
|
|
815
|
+
|
|
816
|
+
def mark_done(
|
|
817
|
+
self,
|
|
818
|
+
machine_name: str,
|
|
819
|
+
branch: str | None = None,
|
|
820
|
+
pr_url: str | None = None,
|
|
821
|
+
) -> Assignment | None:
|
|
822
|
+
for a in self.active:
|
|
823
|
+
if a.machine_name == machine_name and a.status == "running":
|
|
824
|
+
a.status = "done"
|
|
825
|
+
a.branch = branch
|
|
826
|
+
a.pr_url = pr_url
|
|
827
|
+
self.completed.append(a)
|
|
828
|
+
self.active.remove(a)
|
|
829
|
+
return a
|
|
830
|
+
return None
|
|
831
|
+
|
|
832
|
+
def mark_failed(self, machine_name: str) -> Assignment | None:
|
|
833
|
+
for a in self.active:
|
|
834
|
+
if a.machine_name == machine_name and a.status == "running":
|
|
835
|
+
a.status = "failed"
|
|
836
|
+
self.completed.append(a)
|
|
837
|
+
self.active.remove(a)
|
|
838
|
+
return a
|
|
839
|
+
return None
|
|
840
|
+
|
|
841
|
+
def find_by_id(self, assignment_id: str) -> Assignment | None:
|
|
842
|
+
for a in self.active:
|
|
843
|
+
if a.assignment_id == assignment_id:
|
|
844
|
+
return a
|
|
845
|
+
for a in self.completed:
|
|
846
|
+
if a.assignment_id == assignment_id:
|
|
847
|
+
return a
|
|
848
|
+
return None
|
|
849
|
+
|
|
850
|
+
def mark_done_by_id(
|
|
851
|
+
self,
|
|
852
|
+
assignment_id: str,
|
|
853
|
+
branch: str | None = None,
|
|
854
|
+
pr_url: str | None = None,
|
|
855
|
+
finished_at: float | None = None,
|
|
856
|
+
) -> Assignment | None:
|
|
857
|
+
for a in self.active:
|
|
858
|
+
if a.assignment_id == assignment_id:
|
|
859
|
+
a.status = "done"
|
|
860
|
+
if branch is not None:
|
|
861
|
+
a.branch = branch
|
|
862
|
+
if pr_url is not None:
|
|
863
|
+
a.pr_url = pr_url
|
|
864
|
+
a.finished_at = finished_at
|
|
865
|
+
self.completed.append(a)
|
|
866
|
+
self.active.remove(a)
|
|
867
|
+
return a
|
|
868
|
+
return None
|
|
869
|
+
|
|
870
|
+
def mark_failed_by_id(
|
|
871
|
+
self,
|
|
872
|
+
assignment_id: str,
|
|
873
|
+
finished_at: float | None = None,
|
|
874
|
+
) -> Assignment | None:
|
|
875
|
+
for a in self.active:
|
|
876
|
+
if a.assignment_id == assignment_id:
|
|
877
|
+
a.status = "failed"
|
|
878
|
+
a.finished_at = finished_at
|
|
879
|
+
self.completed.append(a)
|
|
880
|
+
self.active.remove(a)
|
|
881
|
+
return a
|
|
882
|
+
return None
|
|
883
|
+
|
|
884
|
+
def gc(self, keep: int = 50) -> int:
|
|
885
|
+
"""Remove oldest completed assignments beyond *keep*. Returns count removed."""
|
|
886
|
+
if len(self.completed) <= keep:
|
|
887
|
+
return 0
|
|
888
|
+
by_time = sorted(self.completed, key=lambda a: a.finished_at or 0)
|
|
889
|
+
to_remove = len(self.completed) - keep
|
|
890
|
+
self.completed = by_time[to_remove:]
|
|
891
|
+
return to_remove
|