@miller-tech/uap 1.165.0 → 1.168.0
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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +47 -2
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/coord.d.ts +1 -1
- package/dist/cli/coord.d.ts.map +1 -1
- package/dist/cli/coord.js +59 -0
- package/dist/cli/coord.js.map +1 -1
- package/dist/cli/merge-queue.d.ts +53 -0
- package/dist/cli/merge-queue.d.ts.map +1 -0
- package/dist/cli/merge-queue.js +239 -0
- package/dist/cli/merge-queue.js.map +1 -0
- package/dist/cli/worktree.d.ts +69 -1
- package/dist/cli/worktree.d.ts.map +1 -1
- package/dist/cli/worktree.js +560 -26
- package/dist/cli/worktree.js.map +1 -1
- package/dist/config/policy-recommendations.d.ts.map +1 -1
- package/dist/config/policy-recommendations.js +4 -0
- package/dist/config/policy-recommendations.js.map +1 -1
- package/dist/coordination/index.d.ts +1 -0
- package/dist/coordination/index.d.ts.map +1 -1
- package/dist/coordination/index.js +1 -0
- package/dist/coordination/index.js.map +1 -1
- package/dist/coordination/ownership.d.ts +51 -0
- package/dist/coordination/ownership.d.ts.map +1 -0
- package/dist/coordination/ownership.js +175 -0
- package/dist/coordination/ownership.js.map +1 -0
- package/docs/INDEX.md +2 -1
- package/docs/guides/PARALLEL_AGENTS.md +209 -0
- package/docs/guides/POLICIES.md +1 -0
- package/docs/guides/WORKTREE_WORKFLOW.md +5 -2
- package/docs/reference/CLI.md +24 -1
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/branch_freshness.py +134 -0
- package/src/policies/enforcers/coord_overlap.py +126 -47
- package/src/policies/schemas/policies/branch-freshness.md +81 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/coordinate-file.sh +153 -3
- package/templates/hooks/session-end.sh +15 -0
- package/templates/hooks/session-start.sh +24 -2
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# Parallel Agents: Staying Collision-Free and Current
|
|
2
|
+
|
|
3
|
+
How several agents work one codebase at once without overwriting each other, and
|
|
4
|
+
without drifting away from the branch everything has to merge into.
|
|
5
|
+
|
|
6
|
+
## The two failure modes
|
|
7
|
+
|
|
8
|
+
Only one of these is what people mean by "merge conflict", and it is the less
|
|
9
|
+
expensive one.
|
|
10
|
+
|
|
11
|
+
| | Concurrent collision | Sequential drift |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| **What happens** | Two agents hold the same file at the same moment | Agent A lands a change; agent B's branch predates it and was never re-synced, so B edits a stale copy |
|
|
14
|
+
| **Who notices** | Git, loudly, at merge time | Nobody. B's merge silently reverts A's work, or a model "resolves" the conflict by keeping its own version |
|
|
15
|
+
| **Covered by** | Live-agent lock in `coordinate-file.sh` | Everything on this page |
|
|
16
|
+
|
|
17
|
+
This repo currently has **151 worktrees; the worst is ~1242 commits behind
|
|
18
|
+
`origin/master`; 24 hold unmerged commits and 15 have uncommitted files.** A
|
|
19
|
+
worktree created during the audit that produced this system was born 1 commit
|
|
20
|
+
behind, because creation read local `HEAD` and never fetched.
|
|
21
|
+
|
|
22
|
+
Those numbers are the *starting* state, not a solved problem — the layers below
|
|
23
|
+
stop it getting worse and make it visible. Reconciling the existing backlog is a
|
|
24
|
+
`uap worktree hygiene` / `uap worktree prune` job.
|
|
25
|
+
|
|
26
|
+
## The layers
|
|
27
|
+
|
|
28
|
+
Each layer catches what the one before it cannot. All of them fail **open** — no
|
|
29
|
+
remote, no git, or an unavailable coordination DB always allows the edit.
|
|
30
|
+
|
|
31
|
+
### 1. Fresh at birth
|
|
32
|
+
|
|
33
|
+
`uap worktree create <slug>` fetches and bases the new branch on
|
|
34
|
+
`origin/<default>`, not on whatever the local checkout happens to have at HEAD.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uap worktree create my-feature # branches from the fetched remote tip
|
|
38
|
+
uap worktree create my-feature --from x # explicit base still wins
|
|
39
|
+
uap worktree create my-feature --no-fetch # offline; accepts a possibly stale base
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The default branch is resolved from `origin/HEAD`, then `origin/master`, then
|
|
43
|
+
`origin/main`, then the current local branch, with a final literal `master`
|
|
44
|
+
fallback if even that fails.
|
|
45
|
+
|
|
46
|
+
> If your integration branch is neither `master` nor `main` and `origin/HEAD` is
|
|
47
|
+
> unset (common on `--single-branch` clones), resolution falls through to *the
|
|
48
|
+
> branch you are standing on*. Set it once — `git remote set-head origin -a` — or
|
|
49
|
+
> pass `--from` explicitly.
|
|
50
|
+
|
|
51
|
+
### 2. Fresh while you work
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uap worktree sync # run from INSIDE a worktree — syncs the cwd
|
|
55
|
+
uap worktree sync --id 117 # a specific worktree, from anywhere
|
|
56
|
+
uap worktree sync --all # every worktree
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
With no flags it operates on the current directory and does not verify that it is
|
|
60
|
+
a worktree — run it from the main checkout and it merges into whatever branch that
|
|
61
|
+
checkout is on. Worktrees with uncommitted changes are skipped, not merged.
|
|
62
|
+
|
|
63
|
+
Previously the only sync happened at `uap worktree finish` — the most expensive
|
|
64
|
+
possible moment to discover a conflict. Sync early and often instead.
|
|
65
|
+
|
|
66
|
+
### 3. Blocked from overwriting landed work
|
|
67
|
+
|
|
68
|
+
Before any worktree edit, `coordinate-file.sh` checks whether **that specific
|
|
69
|
+
file** changed on the integration branch since your branch's merge-base. If it
|
|
70
|
+
did, the edit is blocked:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
STALE FILE: src/cli/worktree.ts has changed on origin/master since your branch
|
|
74
|
+
point (you are 12 commits behind). Editing this copy risks silently reverting
|
|
75
|
+
work that already landed. Run 'uap worktree sync' first.
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Scoped to the single file, so a stale branch touching untouched files keeps
|
|
79
|
+
working — no blanket freeze. The check depends only on git, so it still runs when
|
|
80
|
+
the coordination DB is missing. Its `git fetch` is throttled (default 600s) so it
|
|
81
|
+
never turns every edit into a network round-trip.
|
|
82
|
+
|
|
83
|
+
| Variable | Effect |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `UAP_COORD_DRIFT=warn` | warn instead of block |
|
|
86
|
+
| `UAP_COORD_DRIFT=off` | disable |
|
|
87
|
+
| `UAP_COORD_FETCH_SECONDS` | fetch throttle (default 600) |
|
|
88
|
+
|
|
89
|
+
An in-progress merge, rebase, cherry-pick or revert is exempt — the files you must
|
|
90
|
+
edit to resolve a conflict are by definition the ones that moved upstream.
|
|
91
|
+
|
|
92
|
+
### 4. Blocked from drifting too far
|
|
93
|
+
|
|
94
|
+
The `branch-freshness` enforcer (`src/policies/enforcers/branch_freshness.py`)
|
|
95
|
+
warns at 50 commits behind and blocks at 200 — the backstop for a branch whose
|
|
96
|
+
whole model of the codebase has gone stale, where the eventual merge is a rewrite
|
|
97
|
+
rather than a merge. Where layer 3 is file-precise, this is branch-coarse.
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
branch-freshness: this worktree is 1242 commits behind origin/master (limit 200).
|
|
101
|
+
Edits here are being written against a codebase that has moved on, and the merge
|
|
102
|
+
will be a rewrite rather than a merge. Run `uap worktree sync` first.
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
| Variable | Effect |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `UAP_FRESHNESS_WARN` | advisory threshold (default 50) |
|
|
108
|
+
| `UAP_FRESHNESS_BLOCK` | blocking threshold (default 200) |
|
|
109
|
+
| `UAP_NO_FRESHNESS=1` | disable |
|
|
110
|
+
|
|
111
|
+
Only worktree edits are in scope; the main checkout is governed by
|
|
112
|
+
`worktree-required`. Fails open on a repo with no remote. It is part of the
|
|
113
|
+
`team` policy scenario — `uap policy select` to enable it.
|
|
114
|
+
|
|
115
|
+
The same 200-commit figure drives the `STALE — safe to prune` marker in
|
|
116
|
+
`uap worktree hygiene`, which is a report, not a gate.
|
|
117
|
+
|
|
118
|
+
The 200-commit figure also drives the `STALE — safe to prune` marker in
|
|
119
|
+
`uap worktree hygiene`, which is a report, not a gate.
|
|
120
|
+
|
|
121
|
+
### 5. Serialized landing
|
|
122
|
+
|
|
123
|
+
Two independently-green PRs can still break each other on merge. That is not
|
|
124
|
+
hypothetical: PR #577 landed a legitimate infra file while a stale test asserting
|
|
125
|
+
its absence lived on master, turning CI red and blocking every version bump.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
uap merge queue # prints the plan only — never merges without --yes
|
|
129
|
+
uap merge queue --yes # land one at a time, re-syncing impacted PRs after each
|
|
130
|
+
uap merge queue --yes --limit 3
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Merging is irreversible and unattended, so **`--yes` is required**; the bare
|
|
134
|
+
command is a plan. At most 10 PRs per run by default (`--limit`). `--force` lands
|
|
135
|
+
PRs whose checks are not green — the CI safety this page argues for, deliberately
|
|
136
|
+
switched off.
|
|
137
|
+
|
|
138
|
+
Priority bands, highest first:
|
|
139
|
+
|
|
140
|
+
| Band | Matches |
|
|
141
|
+
|---|---|
|
|
142
|
+
| 0 | labels containing `p0`, `critical`, `hotfix`, `security` |
|
|
143
|
+
| 1 | labels containing `bug`, `fix`, `p1`; or branch `fix/…`, `hotfix/…` |
|
|
144
|
+
| 2 | everything else |
|
|
145
|
+
| 3 | branch `chore/…`, `docs/…` |
|
|
146
|
+
|
|
147
|
+
Note the docs/chore band matches on **branch name**, not title — a PR titled
|
|
148
|
+
`docs: …` on `feature/x` sorts into band 2. Ties break on smaller diff first, then
|
|
149
|
+
least-recently-updated. After each merge, every PR that touches the same files
|
|
150
|
+
**or the same ownership lane** is re-synced onto the new base before the next
|
|
151
|
+
merge. PRs whose checks are still running are skipped rather than waited on, so a
|
|
152
|
+
second run is often needed to drain the queue.
|
|
153
|
+
|
|
154
|
+
### 6. Prevention: ownership lanes
|
|
155
|
+
|
|
156
|
+
The layers above react to collisions. Lanes make them avoidable: partition the
|
|
157
|
+
tree so concurrent agents can be pointed at different areas.
|
|
158
|
+
|
|
159
|
+
`.uap-ownership.json` in the repo root — **tracked on purpose**, since a lane map
|
|
160
|
+
that cannot be committed cannot be shared between agents, clones or CI. An
|
|
161
|
+
untracked `.uap/ownership.json` overrides it for per-machine tweaks.
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"lanes": {
|
|
166
|
+
"cli": ["src/cli/**", "src/bin/**"],
|
|
167
|
+
"delivery": ["src/delivery/**"],
|
|
168
|
+
"policy": ["src/policies/**", "policies/**"]
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
uap coord ownership # show the lane map
|
|
175
|
+
uap coord ownership src/cli/worktree.ts # which lane, and is it held?
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Unmapped paths belong to no lane and are never blocked, so a partial map degrades
|
|
179
|
+
to previous behavior rather than freezing work. Lanes feed the merge queue, where
|
|
180
|
+
they catch the semantic conflict that file-overlap misses entirely: two PRs
|
|
181
|
+
editing *different* files in the same module.
|
|
182
|
+
|
|
183
|
+
> Lanes are currently **advisory plus merge-queue input**. The scheduling
|
|
184
|
+
> primitive that would hand agents disjoint lanes automatically
|
|
185
|
+
> (`selectDisjoint`) is exported and tested but has no production caller yet — no
|
|
186
|
+
> scheduler consumes it. Today you consult lanes; nothing assigns by them.
|
|
187
|
+
|
|
188
|
+
### 7. Hygiene
|
|
189
|
+
|
|
190
|
+
Silent accumulation is how parallel work gets lost.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
uap worktree hygiene # per-worktree drift, unmerged commits, dirty files
|
|
194
|
+
uap worktree hygiene --brief # one-line advisory (used by the session banner)
|
|
195
|
+
uap worktree prune --older-than 30
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The session banner surfaces the advisory automatically when anything needs
|
|
199
|
+
attention, and stays silent otherwise.
|
|
200
|
+
|
|
201
|
+
## Recommended loop
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
uap worktree create my-feature # fresh base, guaranteed
|
|
205
|
+
# ... work; the drift check blocks you if a file moves under you ...
|
|
206
|
+
uap worktree sync # cheap, do it often
|
|
207
|
+
uap worktree pr <id>
|
|
208
|
+
uap merge queue # land serialized, re-sync the rest
|
|
209
|
+
```
|
package/docs/guides/POLICIES.md
CHANGED
|
@@ -64,6 +64,7 @@ Each enforcer guards a specific station of the pipeline. The enforcers in
|
|
|
64
64
|
| `worktree_required` | Edit/Write/MultiEdit must target a `.worktrees/` path |
|
|
65
65
|
| `task_required` | A UAP task must be `in_progress` before mutating work |
|
|
66
66
|
| `coord_overlap` | Checks for in-flight agent path reservations (parallel-agent overlap) |
|
|
67
|
+
| `branch_freshness` | Worktree edits blocked once the branch drifts too far from the integration branch (warn 50, block 200) |
|
|
67
68
|
| `delivery_enforcement` | Route substantive coding through `uap deliver` |
|
|
68
69
|
|
|
69
70
|
### Plan discipline
|
|
@@ -43,7 +43,9 @@ uap worktree finish <id> # 4. land: sync, push, merge the PR, then clean u
|
|
|
43
43
|
1. **Create** — `create` allocates the next numeric ID from a registry,
|
|
44
44
|
builds the branch name `feature/NNN-<slug>`, and runs
|
|
45
45
|
`git worktree add -b <branch> .worktrees/NNN-<slug> <base>`. The base branch
|
|
46
|
-
defaults to
|
|
46
|
+
defaults to the freshly-fetched `origin/<default>` — not your current branch —
|
|
47
|
+
so a worktree is never born stale (override with `--from`, skip the fetch with
|
|
48
|
+
`--no-fetch`). See [Parallel Agents](PARALLEL_AGENTS.md). The new worktree is
|
|
47
49
|
recorded in a SQLite registry at `.uap/worktree_registry.db` so concurrent
|
|
48
50
|
`create` calls never race on the same ID.
|
|
49
51
|
2. **Work** — `cd` into the worktree and make changes. Everything stays on the
|
|
@@ -104,7 +106,8 @@ Create a new worktree and feature branch for `<slug>`.
|
|
|
104
106
|
|
|
105
107
|
| Flag | Description |
|
|
106
108
|
|------|-------------|
|
|
107
|
-
| `-f, --from <branch>` | Base branch (defaults to the
|
|
109
|
+
| `-f, --from <branch>` | Base branch (defaults to the fetched `origin/<default>`) |
|
|
110
|
+
| `--no-fetch` | Skip the base fetch (offline; accepts a possibly stale base) |
|
|
108
111
|
| `-d, --description <description>` | Optional worktree description |
|
|
109
112
|
|
|
110
113
|
```bash
|
package/docs/reference/CLI.md
CHANGED
|
@@ -250,8 +250,10 @@ worktree under `.worktrees/NNN-<slug>/`.
|
|
|
250
250
|
|
|
251
251
|
| Subcommand | Key flags | Purpose |
|
|
252
252
|
|------------|-----------|---------|
|
|
253
|
-
| `create <slug>` | `-f, --from <branch>`, `-d, --description` | Create a
|
|
253
|
+
| `create <slug>` | `-f, --from <branch>`, `--no-fetch`, `-d, --description` | Create a worktree, based on the fetched `origin/<default>` |
|
|
254
254
|
| `list` | — | List all worktrees |
|
|
255
|
+
| `sync` | `-i, --id <id>`, `-a, --all` | Merge the integration branch into a worktree (mid-flight re-base) |
|
|
256
|
+
| `hygiene` | `-b, --brief` | Report drift, unmerged work, and stale worktrees |
|
|
255
257
|
| `pr <id>` | `--draft` | Create a PR from a worktree |
|
|
256
258
|
| `finish <id>` | — | Sync, merge PR, and auto-cleanup the worktree |
|
|
257
259
|
| `cleanup <id>` | — | Remove a worktree and delete its branch |
|
|
@@ -261,12 +263,33 @@ worktree under `.worktrees/NNN-<slug>/`.
|
|
|
261
263
|
```bash
|
|
262
264
|
uap worktree ensure --strict
|
|
263
265
|
uap worktree create add-user-auth
|
|
266
|
+
uap worktree sync # re-base this worktree mid-flight
|
|
267
|
+
uap worktree hygiene # drift + unmerged work across all worktrees
|
|
264
268
|
uap worktree finish 042
|
|
265
269
|
uap worktree prune --older-than 14 --dry-run
|
|
266
270
|
```
|
|
267
271
|
|
|
268
272
|
---
|
|
269
273
|
|
|
274
|
+
## `merge`
|
|
275
|
+
|
|
276
|
+
Serialized landing of concurrent agent PRs. See
|
|
277
|
+
[Parallel Agents](../guides/PARALLEL_AGENTS.md).
|
|
278
|
+
|
|
279
|
+
| Subcommand | Key flags | Purpose |
|
|
280
|
+
|------------|-----------|---------|
|
|
281
|
+
| `queue` | `-y, --yes`, `-n, --dry-run`, `-l, --limit <n>` (10), `--force` | Land PRs one at a time, re-syncing every PR each merge invalidates |
|
|
282
|
+
|
|
283
|
+
Without `--yes` the command only prints its plan — merging is irreversible and
|
|
284
|
+
unattended. `--force` lands PRs whose checks are not green.
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
uap merge queue # plan only
|
|
288
|
+
uap merge queue --yes --limit 3
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
270
293
|
## `sync`
|
|
271
294
|
|
|
272
295
|
Sync configuration between platforms.
|
package/package.json
CHANGED
|
Binary file
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""branch-freshness enforcer: a worktree branch must not drift far from the
|
|
3
|
+
integration branch before it is allowed to keep accumulating edits.
|
|
4
|
+
|
|
5
|
+
The file-level companion (coordinate-file.sh) blocks precisely: only when the
|
|
6
|
+
FILE being edited moved on the integration branch. That is the right default —
|
|
7
|
+
it never freezes work that cannot conflict. This enforcer is the coarse backstop
|
|
8
|
+
for the other failure mode: a branch so far behind that its whole mental model of
|
|
9
|
+
the codebase is wrong, where the eventual merge is a rewrite rather than a merge.
|
|
10
|
+
|
|
11
|
+
Measured on this repo before the gate existed: 151 worktrees, the worst 1241
|
|
12
|
+
commits behind origin/master, 23 holding unmerged commits. None of that drift was
|
|
13
|
+
visible to any gate.
|
|
14
|
+
|
|
15
|
+
Env:
|
|
16
|
+
UAP_NO_FRESHNESS=1 disable entirely
|
|
17
|
+
UAP_FRESHNESS_WARN=<n> advisory threshold (default 50)
|
|
18
|
+
UAP_FRESHNESS_BLOCK=<n> blocking threshold (default 200)
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
26
|
+
from _common import emit, parse_cli, run, worktree_root # noqa: E402
|
|
27
|
+
|
|
28
|
+
EDIT_OPS = {"Edit", "Write", "MultiEdit", "edit", "write", "multiedit"}
|
|
29
|
+
|
|
30
|
+
DEFAULT_WARN = 50
|
|
31
|
+
DEFAULT_BLOCK = 200
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _int_env(name: str, default: int) -> int:
|
|
35
|
+
raw = os.environ.get(name)
|
|
36
|
+
if not raw:
|
|
37
|
+
return default
|
|
38
|
+
try:
|
|
39
|
+
value = int(raw)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return default
|
|
42
|
+
return value if value > 0 else default
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def integration_ref(root: Path) -> str | None:
|
|
46
|
+
"""origin/HEAD -> origin/master -> origin/main, or None when there is no remote."""
|
|
47
|
+
code, out, _ = run(["git", "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], cwd=root)
|
|
48
|
+
if code == 0 and out.strip():
|
|
49
|
+
return out.strip().replace("refs/remotes/", "")
|
|
50
|
+
for candidate in ("master", "main"):
|
|
51
|
+
code, _, _ = run(
|
|
52
|
+
["git", "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{candidate}"],
|
|
53
|
+
cwd=root,
|
|
54
|
+
)
|
|
55
|
+
if code == 0:
|
|
56
|
+
return f"origin/{candidate}"
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def commits_behind(root: Path, ref: str) -> int | None:
|
|
61
|
+
code, out, _ = run(["git", "rev-list", "--count", f"HEAD..{ref}"], cwd=root)
|
|
62
|
+
if code != 0:
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
return int(out.strip())
|
|
66
|
+
except ValueError:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> None:
|
|
71
|
+
op, args = parse_cli()
|
|
72
|
+
if op not in EDIT_OPS:
|
|
73
|
+
emit(True, "not a file-edit operation")
|
|
74
|
+
|
|
75
|
+
if os.environ.get("UAP_NO_FRESHNESS") == "1":
|
|
76
|
+
emit(True, "UAP_NO_FRESHNESS override set")
|
|
77
|
+
|
|
78
|
+
target = args.get("file_path") or args.get("path") or args.get("target") or ""
|
|
79
|
+
if not target:
|
|
80
|
+
emit(True, "no file path in args")
|
|
81
|
+
|
|
82
|
+
# Only worktree edits are in scope. Edits in the main checkout are governed by
|
|
83
|
+
# worktree-required, and non-repo paths are none of our business.
|
|
84
|
+
if ".worktrees/" not in str(Path(target)):
|
|
85
|
+
emit(True, "not a worktree edit")
|
|
86
|
+
|
|
87
|
+
root = worktree_root()
|
|
88
|
+
# A linked worktree has .git as a FILE, not a directory — both are valid.
|
|
89
|
+
git_marker = root / ".git"
|
|
90
|
+
if not git_marker.exists():
|
|
91
|
+
emit(True, "not a git working tree — fail open")
|
|
92
|
+
|
|
93
|
+
ref = integration_ref(root)
|
|
94
|
+
if ref is None:
|
|
95
|
+
emit(True, "no integration ref (no remote) — fail open")
|
|
96
|
+
|
|
97
|
+
behind = commits_behind(root, ref)
|
|
98
|
+
if behind is None:
|
|
99
|
+
emit(True, "drift not measurable — fail open")
|
|
100
|
+
|
|
101
|
+
warn_at = _int_env("UAP_FRESHNESS_WARN", DEFAULT_WARN)
|
|
102
|
+
block_at = _int_env("UAP_FRESHNESS_BLOCK", DEFAULT_BLOCK)
|
|
103
|
+
# A warn threshold above the block threshold would silently never fire.
|
|
104
|
+
if warn_at > block_at:
|
|
105
|
+
warn_at = block_at
|
|
106
|
+
|
|
107
|
+
if behind >= block_at:
|
|
108
|
+
emit(
|
|
109
|
+
False,
|
|
110
|
+
f"branch-freshness: this worktree is {behind} commits behind {ref} "
|
|
111
|
+
f"(limit {block_at}). Edits here are being written against a codebase that "
|
|
112
|
+
"has moved on, and the merge will be a rewrite rather than a merge. "
|
|
113
|
+
"Run `uap worktree sync` first. Override: UAP_NO_FRESHNESS=1.",
|
|
114
|
+
route="worktree-sync",
|
|
115
|
+
worktreeHint="uap worktree sync",
|
|
116
|
+
behind=behind,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if behind >= warn_at:
|
|
120
|
+
emit(
|
|
121
|
+
True,
|
|
122
|
+
f"branch-freshness: {behind} commits behind {ref} — consider "
|
|
123
|
+
"`uap worktree sync` before this grows into a conflict.",
|
|
124
|
+
behind=behind,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Report `behind` on EVERY path, not just warn/block — callers and tests read
|
|
128
|
+
# it for observability, and a field that appears only on failure is a field
|
|
129
|
+
# nobody can build a dashboard on.
|
|
130
|
+
emit(True, f"fresh ({behind} behind {ref})", behind=behind)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
main()
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""coord-overlap enforcer:
|
|
2
|
+
"""coord-overlap enforcer: don't spawn an agent onto paths a LIVE agent holds.
|
|
3
|
+
|
|
4
|
+
Rewritten. The previous implementation was effectively decorative:
|
|
5
|
+
|
|
6
|
+
* it scraped candidate paths out of the raw prompt STRING by splitting on
|
|
7
|
+
whitespace and keeping tokens containing "/", so "see src/foo.ts for
|
|
8
|
+
context" registered as a reservation target while a structured `paths`
|
|
9
|
+
list was the only input that ever worked properly;
|
|
10
|
+
* it then iterated EVERY table in the coordination DB looking for a column
|
|
11
|
+
coincidentally named path/paths/file/scope and substring-matched against it,
|
|
12
|
+
so an unrelated table could veto a spawn;
|
|
13
|
+
* it never consulted agent liveness, so a reservation left behind by an agent
|
|
14
|
+
that died weeks ago blocked new work forever.
|
|
15
|
+
|
|
16
|
+
This version queries the real `work_announcements` schema, joins
|
|
17
|
+
`agent_registry` for heartbeat liveness (matching coordinate-file.sh's
|
|
18
|
+
semantics), and only considers paths it can defend as actual targets.
|
|
19
|
+
|
|
20
|
+
Env:
|
|
21
|
+
UAP_NO_COORD_OVERLAP=1 disable
|
|
22
|
+
UAP_COORD_LIVE_SECONDS=<n> liveness window, shared with coordinate-file.sh (default 120)
|
|
23
|
+
"""
|
|
3
24
|
from __future__ import annotations
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
4
27
|
import sqlite3
|
|
5
28
|
import sys
|
|
6
29
|
from pathlib import Path
|
|
@@ -8,73 +31,129 @@ from pathlib import Path
|
|
|
8
31
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
9
32
|
from _common import emit, parse_cli, repo_root # noqa: E402
|
|
10
33
|
|
|
11
|
-
AGENT_OPS = {"Agent", "spawn-agent", "subagent", "delegate"}
|
|
34
|
+
AGENT_OPS = {"Agent", "spawn-agent", "subagent", "delegate", "task"}
|
|
35
|
+
|
|
36
|
+
DEFAULT_LIVE_SECONDS = 120
|
|
37
|
+
|
|
38
|
+
# A path-ish token: has a separator, a file extension or a known source dir, and
|
|
39
|
+
# no spaces. Deliberately conservative — a false positive here BLOCKS work.
|
|
40
|
+
PATH_TOKEN = re.compile(
|
|
41
|
+
r"(?:^|[\s'\"`(])((?:src|test|tests|lib|scripts|docs|policies|templates)/[\w./-]+"
|
|
42
|
+
r"|[\w./-]+\.(?:ts|tsx|js|jsx|py|sh|md|json|yaml|yml|sql))(?:[\s'\"`),:;]|$)"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _live_seconds() -> int:
|
|
47
|
+
raw = os.environ.get("UAP_COORD_LIVE_SECONDS")
|
|
48
|
+
if not raw:
|
|
49
|
+
return DEFAULT_LIVE_SECONDS
|
|
50
|
+
try:
|
|
51
|
+
value = int(raw)
|
|
52
|
+
except ValueError:
|
|
53
|
+
return DEFAULT_LIVE_SECONDS
|
|
54
|
+
return value if value > 0 else DEFAULT_LIVE_SECONDS
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def candidate_paths(args: dict) -> list[str]:
|
|
58
|
+
"""Paths this spawn is going to work on.
|
|
59
|
+
|
|
60
|
+
Prefers STRUCTURED input (`paths`, `scope`, `files`). Falls back to a
|
|
61
|
+
conservative regex over the prompt, which is a hint, not a contract.
|
|
62
|
+
"""
|
|
63
|
+
for key in ("paths", "scope", "files"):
|
|
64
|
+
raw = args.get(key)
|
|
65
|
+
if isinstance(raw, list) and raw:
|
|
66
|
+
return [str(p).strip() for p in raw if str(p).strip()]
|
|
67
|
+
if isinstance(raw, str) and raw.strip():
|
|
68
|
+
return [p for p in re.split(r"[\s,]+", raw.strip()) if p]
|
|
69
|
+
|
|
70
|
+
prompt = args.get("prompt") or args.get("description") or ""
|
|
71
|
+
if not isinstance(prompt, str):
|
|
72
|
+
return []
|
|
73
|
+
return sorted({m.group(1) for m in PATH_TOKEN.finditer(prompt)})
|
|
12
74
|
|
|
13
75
|
|
|
14
|
-
def
|
|
76
|
+
def live_holders(root: Path, paths: list[str]) -> list[tuple[str, str]]:
|
|
77
|
+
"""(holder, resource) pairs for paths held by a LIVE agent other than us."""
|
|
15
78
|
db = root / "agents" / "data" / "coordination" / "coordination.db"
|
|
16
79
|
if not db.exists() or not paths:
|
|
17
80
|
return []
|
|
81
|
+
|
|
82
|
+
me = os.environ.get("UAP_AGENT_ID") or ""
|
|
83
|
+
window = _live_seconds()
|
|
84
|
+
hits: list[tuple[str, str]] = []
|
|
85
|
+
|
|
18
86
|
try:
|
|
19
87
|
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=1.0)
|
|
20
|
-
# Best-effort: look for any reservations table with path-like column
|
|
21
|
-
cur = con.execute(
|
|
22
|
-
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
23
|
-
)
|
|
24
|
-
tables = [r[0] for r in cur.fetchall()]
|
|
25
|
-
hits: list[str] = []
|
|
26
|
-
for t in tables:
|
|
27
|
-
try:
|
|
28
|
-
cols = [r[1] for r in con.execute(f"PRAGMA table_info({t})")]
|
|
29
|
-
path_col = next(
|
|
30
|
-
(c for c in cols if c.lower() in ("path", "paths", "file", "scope")),
|
|
31
|
-
None,
|
|
32
|
-
)
|
|
33
|
-
status_col = next(
|
|
34
|
-
(c for c in cols if c.lower() in ("status", "state", "active")), None
|
|
35
|
-
)
|
|
36
|
-
if not path_col:
|
|
37
|
-
continue
|
|
38
|
-
where = f" WHERE {status_col} IN ('active','in_progress',1)" if status_col else ""
|
|
39
|
-
rows = con.execute(f"SELECT {path_col} FROM {t}{where}").fetchall()
|
|
40
|
-
for (v,) in rows:
|
|
41
|
-
if not v:
|
|
42
|
-
continue
|
|
43
|
-
for p in paths:
|
|
44
|
-
if p and p in str(v):
|
|
45
|
-
hits.append(f"{t}:{v}")
|
|
46
|
-
except sqlite3.Error:
|
|
47
|
-
continue
|
|
48
|
-
con.close()
|
|
49
|
-
return hits
|
|
50
88
|
except sqlite3.Error:
|
|
51
89
|
return []
|
|
52
90
|
|
|
91
|
+
try:
|
|
92
|
+
# Schema guard: if work_announcements is absent this DB predates the
|
|
93
|
+
# coordination system — fail open rather than guessing at other tables.
|
|
94
|
+
exists = con.execute(
|
|
95
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='work_announcements'"
|
|
96
|
+
).fetchone()
|
|
97
|
+
if not exists:
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
rows = con.execute(
|
|
101
|
+
"""
|
|
102
|
+
SELECT wa.resource,
|
|
103
|
+
COALESCE(wa.agent_name, wa.agent_id) AS holder,
|
|
104
|
+
wa.agent_id
|
|
105
|
+
FROM work_announcements wa
|
|
106
|
+
LEFT JOIN agent_registry ar ON ar.id = wa.agent_id
|
|
107
|
+
WHERE wa.completed_at IS NULL
|
|
108
|
+
AND COALESCE(ar.status, 'active') = 'active'
|
|
109
|
+
AND (strftime('%s','now')
|
|
110
|
+
- strftime('%s', COALESCE(ar.last_heartbeat, wa.announced_at))) < ?
|
|
111
|
+
""",
|
|
112
|
+
(window,),
|
|
113
|
+
).fetchall()
|
|
114
|
+
except sqlite3.Error:
|
|
115
|
+
return []
|
|
116
|
+
finally:
|
|
117
|
+
con.close()
|
|
118
|
+
|
|
119
|
+
for resource, holder, agent_id in rows:
|
|
120
|
+
if not resource or (me and agent_id == me):
|
|
121
|
+
continue
|
|
122
|
+
res = str(resource)
|
|
123
|
+
for p in paths:
|
|
124
|
+
# Match on path containment in EITHER direction: reserving a
|
|
125
|
+
# directory covers files under it, and reserving a file conflicts
|
|
126
|
+
# with a spawn scoped to its directory.
|
|
127
|
+
if res == p or res.startswith(p.rstrip("/") + "/") or p.startswith(res.rstrip("/") + "/"):
|
|
128
|
+
hits.append((str(holder), res))
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
return hits
|
|
132
|
+
|
|
53
133
|
|
|
54
134
|
def main() -> None:
|
|
55
135
|
op, args = parse_cli()
|
|
56
136
|
if op not in AGENT_OPS:
|
|
57
137
|
emit(True, "not an agent-spawn op")
|
|
58
138
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
paths = [p for p in str(paths_raw).split() if "/" in p]
|
|
68
|
-
|
|
69
|
-
hits = overlapping_reservations(repo_root(), paths)
|
|
139
|
+
if os.environ.get("UAP_NO_COORD_OVERLAP") == "1":
|
|
140
|
+
emit(True, "UAP_NO_COORD_OVERLAP override set")
|
|
141
|
+
|
|
142
|
+
paths = candidate_paths(args)
|
|
143
|
+
if not paths:
|
|
144
|
+
emit(True, "no resolvable target paths")
|
|
145
|
+
|
|
146
|
+
hits = live_holders(repo_root(), paths)
|
|
70
147
|
if hits:
|
|
148
|
+
detail = ", ".join(f"{holder} holds {res}" for holder, res in hits[:5])
|
|
71
149
|
emit(
|
|
72
150
|
False,
|
|
73
|
-
f"coord-overlap:
|
|
74
|
-
"
|
|
151
|
+
f"coord-overlap: {detail}. Spawning onto the same paths risks two agents "
|
|
152
|
+
"diverging on one file. Narrow the spawn's scope, wait for them to finish, "
|
|
153
|
+
"or check `uap coord board`. Override: UAP_NO_COORD_OVERLAP=1.",
|
|
75
154
|
)
|
|
76
155
|
|
|
77
|
-
emit(True, "no
|
|
156
|
+
emit(True, f"no live holders for {len(paths)} path(s)")
|
|
78
157
|
|
|
79
158
|
|
|
80
159
|
if __name__ == "__main__":
|