@brainervirus/workit-core 7.6.0 → 7.7.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/package.json +1 -1
- package/skills/workit-fanout/SKILL.md +46 -46
- package/skills/workit-fanout/references/brief.md +15 -8
- package/skills/workit-fanout/references/orchestration.md +117 -0
- package/src/fanout-brief.ts +178 -0
- package/src/fanout-check.ts +2 -1
- package/src/fanout-landed.ts +41 -16
- package/src/fanout-status.ts +7 -3
- package/src/fanout-worktree.ts +89 -19
- package/src/fanout.ts +148 -3
- package/src/ledger.ts +157 -1
package/package.json
CHANGED
|
@@ -8,60 +8,60 @@ description: Run independent slices in parallel - one worker per isolated worktr
|
|
|
8
8
|
Fan out only independent slices: disjoint files, no shared mutable state, each
|
|
9
9
|
verifiable alone. Code-coupled work stays with one owner, who fans out after
|
|
10
10
|
the blocking part lands. A worker whose whole job is re-running one command is
|
|
11
|
-
ceremony; do it yourself.
|
|
11
|
+
ceremony; do it yourself. Workit never spawns agents; you do.
|
|
12
12
|
|
|
13
13
|
1. **Plan the slices** (workit-shape) in a plan file (`references/brief.md`):
|
|
14
|
-
per slice an id, branch, TIER,
|
|
15
|
-
`
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
`workit ledger check --branch <b>` per slice, land in `fanout
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
14
|
+
per slice an id, branch, TIER, SCOPE globs, `owns` for shared files,
|
|
15
|
+
`dependsOn` only for a real dependency, and the brief fields. `workit fanout
|
|
16
|
+
plan <plan.json>` refuses an empty or placeholder brief field (exit 2) and
|
|
17
|
+
two slices that may write one file (exit 3) with a fix; apply it and re-run:
|
|
18
|
+
refuse to spawn while a field is empty or the plan is refused. One PR per
|
|
19
|
+
slice is the default; `"fanIn": "integration"` only when the user wants one
|
|
20
|
+
PR (`references/orchestration.md`).
|
|
21
|
+
2. **Record standing orders** (`workit ledger standing add "<order>"`): every
|
|
22
|
+
user directive all workers share. Directives decay across resumes.
|
|
23
|
+
3. **Brief each worker verbatim** with `workit fanout brief <slice>`: plan
|
|
24
|
+
fields, standing orders, SCRATCH, session id, fan-in rule. Never hand-edit
|
|
25
|
+
it; change the plan or the standing orders and render again.
|
|
26
|
+
4. **Spawn in a rolling window**, in the background, each in its own worktree:
|
|
27
|
+
keep 4-6 in flight and refill from `spawnable` in `workit fanout status` as
|
|
28
|
+
each one finishes. Pick the model from TIER (`references/orchestration.md`).
|
|
29
|
+
Claude Code: the `implementer` agent; elsewhere first `workit fanout
|
|
30
|
+
worktree create <slice>`.
|
|
31
|
+
5. **Judge liveness by side effects only:** `workit fanout status` (head age,
|
|
32
|
+
PR, CI, verdict, landed; STUCK past the TIMEBOX). Stop a stuck worker and
|
|
33
|
+
observe that it exited (a timeout is not proof). Removing a worktree drops
|
|
34
|
+
its uncommitted changes: `workit fanout worktree release <slice>` records
|
|
35
|
+
`git status` in the ledger first and refuses them without `--force`. Never
|
|
36
|
+
two live workers on one branch.
|
|
37
|
+
6. **Retry once, then escalate.** A stuck or failed slice gets one retry with
|
|
38
|
+
a fresh brief (`workit fanout brief <slice> --mode resume`, same branch);
|
|
39
|
+
if that fails too, re-slice it, take it over, or report the gap. Never
|
|
40
|
+
chain resumes. A hard slice may race instead: N attempts, keep the best
|
|
41
|
+
verdict (`references/orchestration.md`).
|
|
42
|
+
7. **Verify by the workspace `verification` setting** (`workit grant show`).
|
|
43
|
+
A report is a pointer, never evidence. `self`: a verifier that wrote none
|
|
44
|
+
of the slices, or you if you wrote none. `independent` or high risk: a
|
|
45
|
+
separate verifier session, never yours (doctrine: the ledger only refuses
|
|
46
|
+
authors). One verifier may take a batch of slices, one verdict per branch
|
|
47
|
+
(`workit ledger verdict <result> --branch <b> --how "<evidence>"`), in a
|
|
48
|
+
session never chosen by the author. A review panel on separate models only at high risk.
|
|
49
|
+
8. **Fan in** with `workit fanout check`: out-of-scope files (any file outside
|
|
50
|
+
it stops the fan-in) and `git merge-tree` conflicts with trunk and between
|
|
51
|
+
siblings. Fix what it names until it exits 0; a merged slice reads as
|
|
52
|
+
landed. Then `workit ledger check --branch <b>` per slice, land in `fanout
|
|
53
|
+
status` order, release the worktrees you made, and `workit ledger standing
|
|
54
|
+
clear`. Only you touch topology: workers never rebase, retarget or merge,
|
|
55
|
+
except the integration-tip merge in integration mode. Then ship
|
|
56
|
+
(workit-ship).
|
|
56
57
|
|
|
57
58
|
## Example
|
|
58
59
|
|
|
59
60
|
Bad brief: "Do the API part and add tests." (no scope, no acceptance, no
|
|
60
61
|
verify command, so nobody can tell when it is done)
|
|
61
62
|
|
|
62
|
-
Good brief:
|
|
63
|
-
|
|
64
|
-
`workit check test`; ...).
|
|
63
|
+
Good brief: the output of `workit fanout brief usage-endpoint`
|
|
64
|
+
(`references/brief.md` shows one).
|
|
65
65
|
|
|
66
66
|
## Check
|
|
67
67
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# Worker brief template
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
`workit fanout brief <slice>` renders this brief from the plan, the standing
|
|
4
|
+
orders in force (`workit ledger standing list`) and the slice's scratch dir;
|
|
5
|
+
pass its output verbatim. The template below is what it fills in. Every field
|
|
6
|
+
is required. A brief with an empty field is not spawned. Point to files and
|
|
7
|
+
ledger rows instead of pasting their content. Size the slice so the worker
|
|
8
|
+
finishes it in about 150k tokens of context; split a larger one first.
|
|
6
9
|
|
|
7
10
|
```md
|
|
8
11
|
MODE: <new | resume (a replacement continuing an existing branch)>
|
|
@@ -16,11 +19,12 @@ VERIFY: <exact commands, e.g. `workit check test`, the verify-<app> feature to d
|
|
|
16
19
|
TIER: <mundane | standard | hard: how much model the slice needs>
|
|
17
20
|
TIMEBOX: <wall clock or turn budget; past it without a new commit you will be replaced>
|
|
18
21
|
SCRATCH: <your own temp dir: the one `fanout worktree create` printed, else `mktemp -d`; never a shared path>
|
|
19
|
-
FORBIDDEN: <no edits outside SCOPE; no
|
|
22
|
+
FORBIDDEN: <no edits outside SCOPE; no new dependencies; ...>
|
|
23
|
+
FAN-IN: <one PR per slice: no rebase, retarget, merge or force-push | integration: merge the integration tip before reporting, nothing else>
|
|
20
24
|
REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
|
|
21
25
|
each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),
|
|
22
26
|
anything out of scope as a follow-up, not a diff.
|
|
23
|
-
STANDING: <the standing orders, verbatim:
|
|
27
|
+
STANDING: <the standing orders, verbatim: `workit ledger standing add` records each one>
|
|
24
28
|
export WORKIT_SESSION_ID=<lead>-w<n> (set by the lead; a verifier brief gets <lead>-v<n>)
|
|
25
29
|
```
|
|
26
30
|
|
|
@@ -40,7 +44,8 @@ VERIFY: `workit check test`; verify-api feature "usage"
|
|
|
40
44
|
TIER: standard
|
|
41
45
|
TIMEBOX: 45 minutes
|
|
42
46
|
SCRATCH: ../app-wt/usage-endpoint/.workit-scratch
|
|
43
|
-
FORBIDDEN: no edits outside SCOPE; no schema migration; no
|
|
47
|
+
FORBIDDEN: no edits outside SCOPE; no schema migration; no new packages
|
|
48
|
+
FAN-IN: one PR per slice. Never rebase, retarget, merge or force-push: the lead owns topology.
|
|
44
49
|
REPORT: as in the template
|
|
45
50
|
STANDING: conventional commits; no comments that restate code; ask nothing, record rulings instead
|
|
46
51
|
```
|
|
@@ -55,7 +60,9 @@ stack); `worktree` defaults to `../<repo>-wt/<id>`. `owns` claims a shared
|
|
|
55
60
|
file another slice's glob also matches. Globs that match no file yet are compared
|
|
56
61
|
through a sample path. Use `/` as the separator; a backslash only escapes
|
|
57
62
|
literal brackets: `"app/\\[id\\]/page.tsx"`. A `timebox` such as `45 minutes`
|
|
58
|
-
is the slice's STUCK threshold in `workit fanout status`.
|
|
63
|
+
is the slice's STUCK threshold in `workit fanout status`. `"fanIn":
|
|
64
|
+
"integration"` (with an integration branch as `trunk`) is the one-PR mode in
|
|
65
|
+
`orchestration.md`; the default `"prs"` lands one PR per slice.
|
|
59
66
|
|
|
60
67
|
```json
|
|
61
68
|
{
|
|
@@ -90,7 +97,7 @@ is the slice's STUCK threshold in `workit fanout status`.
|
|
|
90
97
|
}
|
|
91
98
|
```
|
|
92
99
|
|
|
93
|
-
## Worker rules (
|
|
100
|
+
## Worker rules (`workit fanout brief` appends them as RULES)
|
|
94
101
|
|
|
95
102
|
1. First command. `MODE: new`: `workit git branch <branch> --base <base>` (the
|
|
96
103
|
worktree may start on a name that breaks branch policy). `MODE: resume`:
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Running a fanout
|
|
2
|
+
|
|
3
|
+
How the lead picks models, keeps workers flowing, retries, races, verifies
|
|
4
|
+
and fans in. Workit records and checks; the host's own subagents run the work.
|
|
5
|
+
|
|
6
|
+
## TIER to model
|
|
7
|
+
|
|
8
|
+
TIER is a field of each slice and of its brief. Map it where the host lets you
|
|
9
|
+
pick a model per spawn; nothing in the workspace config routes it.
|
|
10
|
+
|
|
11
|
+
| TIER | Claude Code (`model` on the Agent call) | Hosts without a per-spawn model |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| scouting (read-only search, not a slice) | `haiku` | host default |
|
|
14
|
+
| mundane | `sonnet` | host default |
|
|
15
|
+
| standard | omit it (inherits yours) | host default |
|
|
16
|
+
| hard | omit it (inherits yours); consider a race | host default |
|
|
17
|
+
|
|
18
|
+
OpenCode takes a subagent's model from its agent config: pick a configured
|
|
19
|
+
agent whose model fits the TIER, else the default. Codex, Cursor and Pi run
|
|
20
|
+
every worker on the host's model, so TIER only sizes the timebox and decides
|
|
21
|
+
whether to race.
|
|
22
|
+
|
|
23
|
+
## Rolling window
|
|
24
|
+
|
|
25
|
+
Keep 4-6 workers in flight on Claude Code; elsewhere as many as the host runs
|
|
26
|
+
in the background, never more than 6. Do not wait for a whole wave: when one
|
|
27
|
+
worker finishes (its notification, or a new head or PR in `workit fanout
|
|
28
|
+
status`), verify it, then spawn the next slice from `spawnable` in the same
|
|
29
|
+
status output. A slice whose dependencies landed, or a stacked child whose
|
|
30
|
+
parent is verified, appears there by itself.
|
|
31
|
+
|
|
32
|
+
## Retry, then escalate
|
|
33
|
+
|
|
34
|
+
A slice is retried when it is STUCK, its worker exits without meeting
|
|
35
|
+
ACCEPTANCE, or its verdict fails. Stop the worker and observe that it exited.
|
|
36
|
+
Release its worktree: `workit fanout worktree release <slice>` (native
|
|
37
|
+
worktrees: record `git -C <wt> status --short` first). Then spawn one fresh
|
|
38
|
+
worker with `workit fanout brief <slice> --mode resume`: same branch, and the
|
|
39
|
+
brief points at its head and its ledger rows (last report, verdicts,
|
|
40
|
+
rulings). If the retry fails too, escalate instead of a third worker:
|
|
41
|
+
re-slice it smaller (`workit fanout plan` again), take it over yourself, or
|
|
42
|
+
report the gap to the user.
|
|
43
|
+
|
|
44
|
+
## Race a hard slice
|
|
45
|
+
|
|
46
|
+
When a hard slice has an uncertain approach and a cheap VERIFY, run 2-3
|
|
47
|
+
attempts at once instead of retrying in series. `workit fanout brief <slice>
|
|
48
|
+
--attempt <n>` gives each attempt its own branch (`<branch>-try<n>`), session
|
|
49
|
+
and scratch dir. Each attempt needs its own worktree: Claude Code's
|
|
50
|
+
`implementer` isolates natively; on other hosts `fanout worktree create` makes
|
|
51
|
+
only the slice's own worktree, so run the attempts one after another there.
|
|
52
|
+
Verify every attempt, keep the one with the best verdict (accepted first,
|
|
53
|
+
then the fewest changed files, then the earliest), point the slice branch at
|
|
54
|
+
it (`git branch <branch> <winner>`), have the verifier record the verdict on
|
|
55
|
+
`<branch>` (same head), and delete the losing attempt branches.
|
|
56
|
+
|
|
57
|
+
## Verification
|
|
58
|
+
|
|
59
|
+
Follow the workspace `verification` setting (`workit grant show`); the
|
|
60
|
+
worker never records a verdict on its own work.
|
|
61
|
+
|
|
62
|
+
- `self` (the default): a verifier that wrote none of the slices records the
|
|
63
|
+
verdicts. A lead that authored none of the slices may record them in its
|
|
64
|
+
own session.
|
|
65
|
+
- `independent`, and any slice at high risk: a separate verifier session
|
|
66
|
+
records them, never the lead's own; at high risk one verifier per slice,
|
|
67
|
+
plus a review panel (below). This is doctrine, not enforced: the ledger
|
|
68
|
+
only tells a branch's authors from everyone else, and a lead that wrote
|
|
69
|
+
none of it counts as independent.
|
|
70
|
+
|
|
71
|
+
Which session id a verdict carries, per host:
|
|
72
|
+
|
|
73
|
+
- Claude Code: subagents inherit your `WORKIT_SESSION_ID`, so the
|
|
74
|
+
SubagentStart hook names each `verifier` and `reviewer` its own session
|
|
75
|
+
(`<lead>:<agent id>`), passed as `--session`; that one wins over any
|
|
76
|
+
`<lead>-v<n>` in a brief. An `implementer` uses the session its brief
|
|
77
|
+
exports for its workit commands.
|
|
78
|
+
- Other hosts: the session the brief sets wins: `export
|
|
79
|
+
WORKIT_SESSION_ID=<lead>-w-<slice>` from `workit fanout brief` for a
|
|
80
|
+
worker, `<lead>-v<n>` (or `--as verifier`) for a verifier you start.
|
|
81
|
+
|
|
82
|
+
A batch verifier takes several slices on one surface in one session: for
|
|
83
|
+
each branch it checks out the head, runs that slice's VERIFY, and records its
|
|
84
|
+
own `workit ledger verdict <result> --branch <b> --how "<evidence>"`. Every
|
|
85
|
+
verdict is keyed to that branch's head SHA; a slice whose head moves after
|
|
86
|
+
it needs a new one. Batch at most what fits one context, about 4 slices.
|
|
87
|
+
|
|
88
|
+
## Review panel (high risk only)
|
|
89
|
+
|
|
90
|
+
At risk=high only, 2-3 fresh reviewers (workit-review), each on a model
|
|
91
|
+
other than the author's (Claude Code: a different `model`), each records
|
|
92
|
+
`workit ledger verdict <result> --kind review --branch <b> --how "<findings>"`.
|
|
93
|
+
Land only when every panel verdict passes. Below high risk a panel costs
|
|
94
|
+
more than it finds.
|
|
95
|
+
|
|
96
|
+
## Integration-branch fan-in (one PR)
|
|
97
|
+
|
|
98
|
+
Only when the user wants one PR for the whole fanout:
|
|
99
|
+
|
|
100
|
+
1. Cut the integration branch (`workit git branch <integration> --base
|
|
101
|
+
<trunk>`) and plan with `"fanIn": "integration"` and `--trunk
|
|
102
|
+
<integration>`. Planning refuses integration mode on origin's default
|
|
103
|
+
branch.
|
|
104
|
+
2. Every brief then tells the worker to merge the integration tip into its
|
|
105
|
+
branch before reporting (the fast-forward rule). That merge is the only
|
|
106
|
+
one a worker makes, and only in this mode.
|
|
107
|
+
3. Land each verified slice with `git merge --ff-only <slice-branch>` in the
|
|
108
|
+
integration checkout. Not a fast-forward (the tip moved since the worker
|
|
109
|
+
merged)? Resume the worker to merge again; never a merge commit of your own.
|
|
110
|
+
4. `workit fanout check` gates against the integration branch; a slice whose
|
|
111
|
+
tip is on it reads as landed. When all have landed, ship the integration
|
|
112
|
+
branch as one PR (workit-ship).
|
|
113
|
+
|
|
114
|
+
## Stacks
|
|
115
|
+
|
|
116
|
+
For stacked slices (`dependsOn` one parent): `workit stack plan <bottom> …
|
|
117
|
+
<top>` once, then `workit stack sync` and `workit stack land`.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// `workit fanout brief <slice>`: the complete worker brief, rendered from the
|
|
2
|
+
// plan, the standing orders in force for the fanout (`workit ledger standing`)
|
|
3
|
+
// and the slice's scratch dir, so the lead passes it to the worker verbatim.
|
|
4
|
+
//
|
|
5
|
+
// MODE is `resume` when the branch already exists (a retry continues it),
|
|
6
|
+
// else `new`; `--attempt <n>` renders one attempt of a race on its own branch
|
|
7
|
+
// `<branch>-try<n>` (refused when another slice has that branch), worktree
|
|
8
|
+
// `<worktree>+try<n>` and session `<lead>-w-<slice>+try<n>`: slice ids cannot
|
|
9
|
+
// contain `+`, so no attempt collides with another slice's path or session.
|
|
10
|
+
// The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
|
|
11
|
+
// worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
|
|
12
|
+
// one PR per slice forbids every merge; integration mode has the worker merge
|
|
13
|
+
// the integration tip before reporting, and nothing else. It only reads, apart
|
|
14
|
+
// from hiding `.workit-scratch/` in the repository's info/exclude.
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { activeStanding, readLedger, type LedgerActor } from "./ledger";
|
|
17
|
+
import { branchRef, fanoutFail, planLeads, type FanoutFile, type FanoutResult } from "./fanout";
|
|
18
|
+
import {
|
|
19
|
+
SCRATCH_DIR,
|
|
20
|
+
excludeScratch,
|
|
21
|
+
findSlice,
|
|
22
|
+
listWorktrees,
|
|
23
|
+
samePath,
|
|
24
|
+
slicePath,
|
|
25
|
+
} from "./fanout-worktree";
|
|
26
|
+
|
|
27
|
+
export type BriefMode = "new" | "resume";
|
|
28
|
+
|
|
29
|
+
export type BriefOutcome = {
|
|
30
|
+
fanout: string;
|
|
31
|
+
slice: string;
|
|
32
|
+
mode: BriefMode;
|
|
33
|
+
attempt: number | null;
|
|
34
|
+
branch: string;
|
|
35
|
+
base: string;
|
|
36
|
+
tier: string;
|
|
37
|
+
/** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
|
|
38
|
+
scratch: string;
|
|
39
|
+
session: string;
|
|
40
|
+
standing: Array<{ id: string; what: string }>;
|
|
41
|
+
text: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_TIMEBOX = "30 minutes";
|
|
45
|
+
|
|
46
|
+
const REPORT = [
|
|
47
|
+
"REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,",
|
|
48
|
+
" each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),",
|
|
49
|
+
" anything out of scope as a follow-up, not a diff.",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const rules = (mode: BriefMode, branch: string, base: string): string[] => [
|
|
53
|
+
"RULES:",
|
|
54
|
+
mode === "new"
|
|
55
|
+
? ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`
|
|
56
|
+
: ` 1. First command: \`git switch ${branch}\`, then continue from its head.`,
|
|
57
|
+
' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
|
|
58
|
+
" 3. Commit with `workit git commit -m <msg> -- <paths in SCOPE>`; push only if this brief says so.",
|
|
59
|
+
" 4. Never record a verdict on your own work.",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** A session id fragment that the ledger accepts ([A-Za-z0-9_.:@/+-]). */
|
|
63
|
+
const sessionSafe = (text: string): string => text.replace(/[^A-Za-z0-9_.:@/+-]/gu, "-");
|
|
64
|
+
|
|
65
|
+
/** The ledger's session id limit. */
|
|
66
|
+
const SESSION_MAX = 128;
|
|
67
|
+
|
|
68
|
+
/** `<lead>-w-<slice>[+try<n>]`, with the slice part hashed when the whole would be too long. */
|
|
69
|
+
function workerSession(lead: string, slice: string, attempt: number | null): string {
|
|
70
|
+
const tail = attempt === null ? "" : `+try${attempt}`;
|
|
71
|
+
const full = `${lead}-w-${slice}${tail}`;
|
|
72
|
+
if (full.length <= SESSION_MAX) return full;
|
|
73
|
+
const digest = createHash("sha256").update(slice).digest("hex").slice(0, 12);
|
|
74
|
+
return `${lead.slice(0, SESSION_MAX - 3 - digest.length - tail.length)}-w-${digest}${tail}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderBrief(
|
|
78
|
+
cwd: string,
|
|
79
|
+
plan: FanoutFile,
|
|
80
|
+
input: { slice: string; mode: BriefMode | null; attempt: number | null; actor: LedgerActor },
|
|
81
|
+
): FanoutResult<BriefOutcome> {
|
|
82
|
+
const found = findSlice(plan, input.slice);
|
|
83
|
+
if (!found.ok) return found;
|
|
84
|
+
const slice = found.data;
|
|
85
|
+
const ledger = readLedger(cwd);
|
|
86
|
+
if (!ledger.ok) return fanoutFail(ledger.code, ledger.error, ledger.unblock);
|
|
87
|
+
if (!input.actor.session)
|
|
88
|
+
return fanoutFail(
|
|
89
|
+
"blocked",
|
|
90
|
+
"WORKIT_SESSION_ID is not set: worker session ids derive from the lead's",
|
|
91
|
+
"export WORKIT_SESSION_ID=<your session id>, then render the brief again",
|
|
92
|
+
);
|
|
93
|
+
const branch = input.attempt === null ? slice.branch : `${slice.branch}-try${input.attempt}`;
|
|
94
|
+
const taken = plan.slices.find((other) => other.id !== slice.id && other.branch === branch);
|
|
95
|
+
if (taken)
|
|
96
|
+
return fanoutFail(
|
|
97
|
+
"invalid_input",
|
|
98
|
+
`attempt branch ${branch} is slice ${taken.id}'s branch`,
|
|
99
|
+
"pick another --attempt number, or rename one of the branches in the plan",
|
|
100
|
+
);
|
|
101
|
+
const head = branchRef(cwd, branch);
|
|
102
|
+
const mode: BriefMode = input.mode ?? (head ? "resume" : "new");
|
|
103
|
+
if (mode === "resume" && !head)
|
|
104
|
+
return fanoutFail(
|
|
105
|
+
"not_found",
|
|
106
|
+
`MODE resume needs the branch ${branch}, which does not exist here`,
|
|
107
|
+
`workit fanout brief ${slice.id} --mode new`,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const planned = `${slicePath(cwd, slice)}${input.attempt === null ? "" : `+try${input.attempt}`}`;
|
|
111
|
+
const exists = listWorktrees(cwd).some((entry) => samePath(entry.path, planned));
|
|
112
|
+
excludeScratch(cwd);
|
|
113
|
+
const scratch = exists ? `${planned}/${SCRATCH_DIR}` : SCRATCH_DIR;
|
|
114
|
+
const scratchLine = exists
|
|
115
|
+
? `SCRATCH: ${scratch} (yours alone; temp files go here, never a shared path)`
|
|
116
|
+
: `SCRATCH: ${SCRATCH_DIR}/ at your worktree root (mkdir -p it; git ignores it; never a shared path)`;
|
|
117
|
+
|
|
118
|
+
const standing = activeStanding(ledger.value.rows, plan.name, planLeads(plan)).map((row) => ({
|
|
119
|
+
id: row.id,
|
|
120
|
+
what: String(row.what),
|
|
121
|
+
}));
|
|
122
|
+
const session = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
|
|
123
|
+
const fanIn =
|
|
124
|
+
plan.fanIn === "integration"
|
|
125
|
+
? [
|
|
126
|
+
`FAN-IN: integration branch ${plan.trunk}. Before you report, merge its tip into your branch`,
|
|
127
|
+
` (\`git merge --no-edit ${plan.trunk}\`), resolve conflicts inside SCOPE and re-run VERIFY, so the`,
|
|
128
|
+
" lead's merge is a fast-forward. No other merge, and no rebase, retarget or force-push.",
|
|
129
|
+
]
|
|
130
|
+
: [
|
|
131
|
+
"FAN-IN: one PR per slice. Never rebase, retarget, merge or force-push: the lead owns topology.",
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const lines = [
|
|
135
|
+
`MODE: ${mode}${input.attempt === null ? "" : ` (race attempt ${input.attempt}: the lead keeps the attempt with the best verdict)`}`,
|
|
136
|
+
`GOAL: ${slice.goal}`,
|
|
137
|
+
`SCOPE: ${slice.scope.join(", ")}`,
|
|
138
|
+
...(slice.owns.length ? [` owns: ${slice.owns.join(", ")}`] : []),
|
|
139
|
+
` branch: ${branch} base: ${slice.base}`,
|
|
140
|
+
`CONTEXT: ${slice.context ?? "none given: read the files in SCOPE and the neighbours they import"}`,
|
|
141
|
+
...(mode === "resume" && head
|
|
142
|
+
? [
|
|
143
|
+
` resume: continue ${branch} from ${head.slice(0, 12)}; read \`git log ${slice.base}..${branch}\` and \`workit ledger list --branch ${branch}\` (the last report, verdicts, rulings)`,
|
|
144
|
+
]
|
|
145
|
+
: []),
|
|
146
|
+
"ACCEPTANCE:",
|
|
147
|
+
...slice.acceptance.map((line) => ` - ${line}`),
|
|
148
|
+
`VERIFY: ${slice.verify.join("; ")}`,
|
|
149
|
+
`TIER: ${slice.tier}`,
|
|
150
|
+
`TIMEBOX: ${slice.timebox ?? DEFAULT_TIMEBOX}; past it without a new commit you will be replaced`,
|
|
151
|
+
scratchLine,
|
|
152
|
+
`FORBIDDEN: ${slice.forbidden.join("; ")}`,
|
|
153
|
+
...fanIn,
|
|
154
|
+
...REPORT,
|
|
155
|
+
"STANDING:",
|
|
156
|
+
...(standing.length
|
|
157
|
+
? standing.map((order) => ` - ${order.what}`)
|
|
158
|
+
: [" - none recorded beyond this brief"]),
|
|
159
|
+
` export WORKIT_SESSION_ID=${session}`,
|
|
160
|
+
...rules(mode, branch, slice.base),
|
|
161
|
+
];
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
data: {
|
|
165
|
+
fanout: plan.name,
|
|
166
|
+
slice: slice.id,
|
|
167
|
+
mode,
|
|
168
|
+
attempt: input.attempt,
|
|
169
|
+
branch,
|
|
170
|
+
base: slice.base,
|
|
171
|
+
tier: slice.tier,
|
|
172
|
+
scratch,
|
|
173
|
+
session,
|
|
174
|
+
standing,
|
|
175
|
+
text: `${lines.join("\n")}\n`,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/fanout-check.ts
CHANGED
|
@@ -293,7 +293,8 @@ export function sliceLandings(
|
|
|
293
293
|
createdFrom: sliceStart(cwd, rows, plan.name, slice),
|
|
294
294
|
parentHead,
|
|
295
295
|
fanout: plan.name,
|
|
296
|
-
since: plan.createdAt || null,
|
|
296
|
+
since: slice.hashSince || plan.createdAt || null,
|
|
297
|
+
sliceHash: slice.hash || null,
|
|
297
298
|
rows,
|
|
298
299
|
}),
|
|
299
300
|
);
|
package/src/fanout-landed.ts
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
// commit (a squash merge that applied without the trunk touching the same
|
|
13
13
|
// lines). Anything else reads as not landed.
|
|
14
14
|
// A merged PR whose branch is gone counts only when the ledger links that
|
|
15
|
-
// branch to this slice
|
|
16
|
-
//
|
|
15
|
+
// branch to this slice: a worktree row made under the slice's current hash,
|
|
16
|
+
// or a row recorded on its merged head since that hash was planned. A reused
|
|
17
|
+
// branch name from older work, an older plan of the same name, or an earlier
|
|
18
|
+
// definition of this slice never links; editing a sibling changes nothing.
|
|
17
19
|
// A landing whose changed paths read on the trunk exactly as before it (a
|
|
18
20
|
// revert) is not landed; the note says so.
|
|
19
21
|
// The forge is asked through a breaker: after the first unavailable answer
|
|
@@ -184,24 +186,44 @@ function trunkPatchIds(ctx: LandingContext, fork: string): Set<string> {
|
|
|
184
186
|
return ids;
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Does `row` belong to the slice's current run? A worktree row made under its
|
|
191
|
+
* hash does; any other row only when recorded since that hash was planned.
|
|
192
|
+
*/
|
|
193
|
+
export function inSliceRun(
|
|
194
|
+
row: Record<string, unknown>,
|
|
195
|
+
slice: Pick<Slice, "hash" | "hashSince">,
|
|
196
|
+
): boolean {
|
|
197
|
+
if (row.type === "fanout.worktree.created" && typeof row.sliceHash === "string" && slice.hash)
|
|
198
|
+
return row.sliceHash === slice.hash;
|
|
199
|
+
const since = Date.parse(slice.hashSince);
|
|
200
|
+
return Number.isNaN(since) || Date.parse(String(row.at)) >= since;
|
|
201
|
+
}
|
|
202
|
+
|
|
187
203
|
/** The ledger links `branch` to this slice: a worktree row, or a row recorded on `head`. */
|
|
188
204
|
function linked(
|
|
189
205
|
rows: readonly Record<string, unknown>[],
|
|
190
|
-
input: Pick<LandingInput, "fanout" | "since">,
|
|
206
|
+
input: Pick<LandingInput, "fanout" | "since" | "sliceHash">,
|
|
191
207
|
slice: Pick<Slice, "id" | "branch">,
|
|
192
208
|
head: string | null,
|
|
193
209
|
): boolean {
|
|
194
|
-
// Only rows from this run of the
|
|
195
|
-
// earlier
|
|
210
|
+
// Only rows from this run of the slice: a plan of the same name made
|
|
211
|
+
// earlier, or this slice re-planned with another definition, lends nothing.
|
|
196
212
|
const since = input.since ? Date.parse(input.since) : Number.NaN;
|
|
197
|
-
|
|
198
|
-
(row)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
213
|
+
const recent = (row: Record<string, unknown>) =>
|
|
214
|
+
Number.isNaN(since) || Date.parse(String(row.at)) >= since;
|
|
215
|
+
return rows.some((row) => {
|
|
216
|
+
if (
|
|
217
|
+
row.type === "fanout.worktree.created" &&
|
|
218
|
+
row.fanout === input.fanout &&
|
|
219
|
+
row.slice === slice.id
|
|
220
|
+
)
|
|
221
|
+
// A row from before slice hashes falls back to its time.
|
|
222
|
+
return typeof row.sliceHash === "string" && input.sliceHash
|
|
223
|
+
? row.sliceHash === input.sliceHash
|
|
224
|
+
: recent(row);
|
|
225
|
+
return recent(row) && head !== null && row.branch === slice.branch && row.head === head;
|
|
226
|
+
});
|
|
205
227
|
}
|
|
206
228
|
|
|
207
229
|
export type LandingInput = {
|
|
@@ -212,8 +234,10 @@ export type LandingInput = {
|
|
|
212
234
|
/** Its dependency branch's tip (or merged head) for a stacked slice. */
|
|
213
235
|
parentHead: string | null;
|
|
214
236
|
fanout: string;
|
|
215
|
-
/** When this
|
|
237
|
+
/** When this slice definition was first planned (Slice.hashSince): older rows do not link. */
|
|
216
238
|
since: string | null;
|
|
239
|
+
/** The slice's definition hash: worktree rows link only under the same one. */
|
|
240
|
+
sliceHash: string | null;
|
|
217
241
|
rows: readonly Record<string, unknown>[];
|
|
218
242
|
};
|
|
219
243
|
|
|
@@ -298,7 +322,7 @@ export function sliceStart(
|
|
|
298
322
|
cwd: string,
|
|
299
323
|
rows: readonly Record<string, unknown>[],
|
|
300
324
|
fanout: string,
|
|
301
|
-
slice: Pick<Slice, "id" | "branch">,
|
|
325
|
+
slice: Pick<Slice, "id" | "branch" | "hash" | "hashSince">,
|
|
302
326
|
): string | null {
|
|
303
327
|
const reflog = branchCreatedAt(cwd, slice.branch);
|
|
304
328
|
if (reflog) return reflog;
|
|
@@ -307,7 +331,8 @@ export function sliceStart(
|
|
|
307
331
|
candidate.type === "fanout.worktree.created" &&
|
|
308
332
|
candidate.fanout === fanout &&
|
|
309
333
|
candidate.slice === slice.id &&
|
|
310
|
-
typeof candidate.baseSha === "string"
|
|
334
|
+
typeof candidate.baseSha === "string" &&
|
|
335
|
+
inSliceRun(candidate, slice),
|
|
311
336
|
);
|
|
312
337
|
return (row?.baseSha as string | undefined) ?? null;
|
|
313
338
|
}
|
package/src/fanout-status.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { checksState, gatingChecks, missingRequired, type ChecksState } from "./
|
|
|
20
20
|
import type { ResolvedForge } from "./forge/resolve";
|
|
21
21
|
import { checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
|
|
22
22
|
import { sliceLandings } from "./fanout-check";
|
|
23
|
-
import { forgeBreaker } from "./fanout-landed";
|
|
23
|
+
import { forgeBreaker, inSliceRun } from "./fanout-landed";
|
|
24
24
|
import {
|
|
25
25
|
branchRef,
|
|
26
26
|
fanoutFail,
|
|
@@ -123,6 +123,8 @@ function activity(cwd: string, plan: FanoutFile, slice: Slice, rows: readonly Re
|
|
|
123
123
|
]).stdout,
|
|
124
124
|
);
|
|
125
125
|
const reflog = moved ? isoOf(moved[1]) : null;
|
|
126
|
+
// Liveness reads every row on the branch or slice: re-planning a slice
|
|
127
|
+
// never makes a live worker look idle.
|
|
126
128
|
const ledger = rows
|
|
127
129
|
.filter(
|
|
128
130
|
(row) => row.branch === slice.branch || (row.fanout === plan.name && row.slice === slice.id),
|
|
@@ -136,7 +138,8 @@ function activity(cwd: string, plan: FanoutFile, slice: Slice, rows: readonly Re
|
|
|
136
138
|
(row) =>
|
|
137
139
|
row.type === "fanout.worktree.created" &&
|
|
138
140
|
row.fanout === plan.name &&
|
|
139
|
-
row.slice === slice.id
|
|
141
|
+
row.slice === slice.id &&
|
|
142
|
+
inSliceRun(row, slice),
|
|
140
143
|
),
|
|
141
144
|
};
|
|
142
145
|
}
|
|
@@ -267,7 +270,8 @@ export function fanoutStatus(
|
|
|
267
270
|
(candidate) =>
|
|
268
271
|
candidate.type === "fanout.worktree.created" &&
|
|
269
272
|
candidate.fanout === plan.name &&
|
|
270
|
-
candidate.slice === id
|
|
273
|
+
candidate.slice === id &&
|
|
274
|
+
inSliceRun(candidate, byId.get(id) as Slice),
|
|
271
275
|
);
|
|
272
276
|
row.state = created ? "active" : "not_started";
|
|
273
277
|
if (!created) spawnable.push(id);
|
package/src/fanout-worktree.ts
CHANGED
|
@@ -12,10 +12,17 @@
|
|
|
12
12
|
// (`fanout.worktree.released`), refuses while it has uncommitted changes
|
|
13
13
|
// unless forced, then runs `git worktree remove` (which takes the scratch dir
|
|
14
14
|
// with it). The branch is kept. It only removes a worktree that `create` made
|
|
15
|
-
// for this fanout, slice and path
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// for this fanout, slice and path, on the slice's branch or detached; never
|
|
16
|
+
// the main checkout, the one the command runs in, or a worktree someone else
|
|
17
|
+
// made there, not even with --force. "Made by create" is exact: create writes
|
|
18
|
+
// a random token into git's admin dir for the worktree
|
|
19
|
+
// (`.git/worktrees/<id>/workit-fanout-token`) and records it with the admin
|
|
20
|
+
// dir's id and inode; release and adopt need all three to match. A worktree
|
|
21
|
+
// removed by hand and re-added at the path gets a new admin dir without the
|
|
22
|
+
// token, on any filesystem (no birth time needed). A row from before the
|
|
23
|
+
// token is matched by time only, so release refuses --force for it. An empty
|
|
18
24
|
// directory that existed before `create` is left in place.
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
19
26
|
import fs from "node:fs";
|
|
20
27
|
import path from "node:path";
|
|
21
28
|
import { gitBranch } from "./git/ops";
|
|
@@ -68,7 +75,7 @@ const canonical = (target: string): string => {
|
|
|
68
75
|
}
|
|
69
76
|
};
|
|
70
77
|
|
|
71
|
-
const samePath = (a: string, b: string): boolean => {
|
|
78
|
+
export const samePath = (a: string, b: string): boolean => {
|
|
72
79
|
const left = canonical(a);
|
|
73
80
|
const right = canonical(b);
|
|
74
81
|
return process.platform === "win32" || process.platform === "darwin"
|
|
@@ -82,7 +89,7 @@ const inside = (child: string, parent: string): boolean => {
|
|
|
82
89
|
};
|
|
83
90
|
|
|
84
91
|
/** `git worktree list --porcelain -z`; the first entry is the main worktree. */
|
|
85
|
-
function listWorktrees(cwd: string): WorktreeEntry[] {
|
|
92
|
+
export function listWorktrees(cwd: string): WorktreeEntry[] {
|
|
86
93
|
const out: WorktreeEntry[] = [];
|
|
87
94
|
let current: WorktreeEntry | null = null;
|
|
88
95
|
for (const field of gitRun(cwd, ["worktree", "list", "--porcelain", "-z"]).stdout.split("\0")) {
|
|
@@ -98,7 +105,7 @@ function listWorktrees(cwd: string): WorktreeEntry[] {
|
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
/** The slice's worktree path: absolute since plan time; an older relative one is the main checkout's. */
|
|
101
|
-
const slicePath = (cwd: string, slice: Slice): string =>
|
|
108
|
+
export const slicePath = (cwd: string, slice: Slice): string =>
|
|
102
109
|
path.isAbsolute(slice.worktree)
|
|
103
110
|
? slice.worktree
|
|
104
111
|
: path.resolve(mainCheckout(cwd), slice.worktree);
|
|
@@ -110,8 +117,10 @@ const CLOCK_SLACK_MS = 2000;
|
|
|
110
117
|
* The live `fanout.worktree.created` row for this fanout, slice and path:
|
|
111
118
|
* the newest one, unless a later release removed that worktree (a row is
|
|
112
119
|
* void once its worktree is gone), and only while the worktree at the path
|
|
113
|
-
* is the one it recorded
|
|
114
|
-
*
|
|
120
|
+
* is the one it recorded. `exact`: the admin dir's token, id and inode match
|
|
121
|
+
* the row. A row without a token (written before it) matches when the admin
|
|
122
|
+
* dir is no older than the row, which a re-added worktree also passes, so it
|
|
123
|
+
* is never exact. Anything else is someone else's worktree.
|
|
115
124
|
*/
|
|
116
125
|
function createdRow(cwd: string, plan: FanoutFile, slice: Slice, target: string) {
|
|
117
126
|
const ledger = readLedger(cwd);
|
|
@@ -134,24 +143,75 @@ function createdRow(cwd: string, plan: FanoutFile, slice: Slice, target: string)
|
|
|
134
143
|
atPath(candidate),
|
|
135
144
|
);
|
|
136
145
|
if (removedSince) return null;
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
139
|
-
|
|
146
|
+
const admin = adminDir(target);
|
|
147
|
+
if (!admin) return null;
|
|
148
|
+
if (typeof row.adminToken === "string")
|
|
149
|
+
return row.adminToken === admin.token && row.adminId === admin.id && row.adminIno === admin.ino
|
|
150
|
+
? { row, exact: true }
|
|
151
|
+
: null;
|
|
152
|
+
const made = admin.birthMs ?? admin.mtimeMs;
|
|
153
|
+
if (made + CLOCK_SLACK_MS < Date.parse(row.at)) return null;
|
|
154
|
+
return { row, exact: false };
|
|
140
155
|
}
|
|
141
156
|
|
|
142
|
-
|
|
143
|
-
|
|
157
|
+
const TOKEN_FILE = "workit-fanout-token";
|
|
158
|
+
|
|
159
|
+
/** The filesystem calls identity relies on; tests stub them. */
|
|
160
|
+
export const worktreeDeps = {
|
|
161
|
+
stat: (file: string): fs.BigIntStats => fs.statSync(file, { bigint: true }),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
type AdminDir = {
|
|
165
|
+
path: string;
|
|
166
|
+
/** `<id>` of `.git/worktrees/<id>`. */
|
|
167
|
+
id: string;
|
|
168
|
+
ino: string;
|
|
169
|
+
/** The token create wrote there, or null. */
|
|
170
|
+
token: string | null;
|
|
171
|
+
birthMs: number | null;
|
|
172
|
+
mtimeMs: number;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Git's admin dir for the worktree: what tells this worktree from a later one at the path. */
|
|
176
|
+
function adminDir(worktree: string): AdminDir | null {
|
|
144
177
|
const dir = gitRun(worktree, ["rev-parse", "--absolute-git-dir"]);
|
|
145
178
|
if (!dir.ok) return null;
|
|
179
|
+
const file = dir.stdout.trim();
|
|
180
|
+
let token: string | null = null;
|
|
181
|
+
try {
|
|
182
|
+
token = fs.readFileSync(path.join(file, TOKEN_FILE), "utf8").trim() || null;
|
|
183
|
+
} catch {
|
|
184
|
+
token = null;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const stat = worktreeDeps.stat(file);
|
|
188
|
+
return {
|
|
189
|
+
path: file,
|
|
190
|
+
id: path.basename(file),
|
|
191
|
+
ino: stat.ino.toString(),
|
|
192
|
+
token,
|
|
193
|
+
birthMs: stat.birthtimeNs > 0n ? Number(stat.birthtimeMs) : null,
|
|
194
|
+
mtimeMs: Number(stat.mtimeMs),
|
|
195
|
+
};
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Mark the admin dir of a worktree create just made; null when it cannot. */
|
|
202
|
+
function markAdminDir(worktree: string): (AdminDir & { token: string }) | null {
|
|
203
|
+
const admin = adminDir(worktree);
|
|
204
|
+
if (!admin) return null;
|
|
205
|
+
const token = randomBytes(16).toString("hex");
|
|
146
206
|
try {
|
|
147
|
-
|
|
148
|
-
return stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs;
|
|
207
|
+
fs.writeFileSync(path.join(admin.path, TOKEN_FILE), `${token}\n`);
|
|
149
208
|
} catch {
|
|
150
209
|
return null;
|
|
151
210
|
}
|
|
211
|
+
return { ...admin, token };
|
|
152
212
|
}
|
|
153
213
|
|
|
154
|
-
function findSlice(plan: FanoutFile, id: string): FanoutResult<Slice> {
|
|
214
|
+
export function findSlice(plan: FanoutFile, id: string): FanoutResult<Slice> {
|
|
155
215
|
const slice = plan.slices.find((candidate) => candidate.id === id);
|
|
156
216
|
return slice
|
|
157
217
|
? { ok: true, data: slice }
|
|
@@ -163,7 +223,7 @@ function findSlice(plan: FanoutFile, id: string): FanoutResult<Slice> {
|
|
|
163
223
|
}
|
|
164
224
|
|
|
165
225
|
/** Hide the scratch dir from git status and `git add -A` in every worktree. */
|
|
166
|
-
function excludeScratch(cwd: string): void {
|
|
226
|
+
export function excludeScratch(cwd: string): void {
|
|
167
227
|
const common = gitCommonDir(cwd);
|
|
168
228
|
if (!common) return;
|
|
169
229
|
const file = path.join(common, "info", "exclude");
|
|
@@ -325,6 +385,7 @@ export function createSliceWorktree(
|
|
|
325
385
|
const scratch = ensureScratch(target);
|
|
326
386
|
const head = gitRun(target, ["rev-parse", "HEAD"]).stdout.trim();
|
|
327
387
|
const baseSha = mode === "new" ? head : null;
|
|
388
|
+
const admin = markAdminDir(target);
|
|
328
389
|
appendObserved(cwd, {
|
|
329
390
|
type: "fanout.worktree.created",
|
|
330
391
|
actor: input.actor,
|
|
@@ -337,6 +398,8 @@ export function createSliceWorktree(
|
|
|
337
398
|
head,
|
|
338
399
|
dirExisted,
|
|
339
400
|
planCreatedAt: plan.createdAt,
|
|
401
|
+
sliceHash: slice.hash,
|
|
402
|
+
...(admin ? { adminId: admin.id, adminIno: admin.ino, adminToken: admin.token } : {}),
|
|
340
403
|
...(baseSha ? { baseSha } : {}),
|
|
341
404
|
});
|
|
342
405
|
return { ok: true, data: { ...base, scratch, head, mode, created: true, notes } };
|
|
@@ -410,13 +473,20 @@ export function releaseSliceWorktree(
|
|
|
410
473
|
"blocked",
|
|
411
474
|
`${entry.path} is on ${entry.branch.replace(/^refs\/heads\//u, "")}, not slice ${slice.id}'s ${slice.branch}; nothing was removed`,
|
|
412
475
|
);
|
|
413
|
-
const
|
|
414
|
-
if (!
|
|
476
|
+
const owned = createdRow(cwd, plan, slice, entry.path);
|
|
477
|
+
if (!owned)
|
|
415
478
|
return fanoutFail(
|
|
416
479
|
"blocked",
|
|
417
480
|
`${entry.path} was not made by workit fanout worktree create for slice ${slice.id} (no ledger row); nothing was removed`,
|
|
418
481
|
`remove it yourself (git worktree remove ${entry.path}) once you know what it holds`,
|
|
419
482
|
);
|
|
483
|
+
if (input.force && !owned.exact)
|
|
484
|
+
return fanoutFail(
|
|
485
|
+
"blocked",
|
|
486
|
+
`${entry.path} was created before workit recorded worktree identity, so it cannot be told from a worktree someone re-added there; --force is refused and nothing was removed`,
|
|
487
|
+
`release it without --force, or remove it yourself (git worktree remove ${entry.path}) once you know what it holds`,
|
|
488
|
+
);
|
|
489
|
+
const made = owned.row;
|
|
420
490
|
out.head = entry.head;
|
|
421
491
|
const status = gitRun(entry.path, [
|
|
422
492
|
"status",
|
package/src/fanout.ts
CHANGED
|
@@ -18,12 +18,33 @@
|
|
|
18
18
|
// deterministic suggestion: an owner for known shared files (lockfiles,
|
|
19
19
|
// manifests, barrels, CI config), else a dependency that serializes them.
|
|
20
20
|
//
|
|
21
|
+
// Each slice's `hash` covers what it is (goal, acceptance, verify, forbidden,
|
|
22
|
+
// scope, owns, branch, base, dependencies), not how it is briefed (tier,
|
|
23
|
+
// timebox, context) or where its worktree goes. A re-plan that changes a
|
|
24
|
+
// slice starts a new run for that slice alone: its `hashSince` moves to now,
|
|
25
|
+
// and only ledger rows recorded under its hash (worktree rows carry
|
|
26
|
+
// `sliceHash`) or since then link a gone branch's merged PR to it or count as
|
|
27
|
+
// its worktree. Liveness still reads every row. Its siblings keep their runs.
|
|
28
|
+
//
|
|
29
|
+
// `fanIn: "integration"` is the one-PR mode: the trunk is an integration
|
|
30
|
+
// branch (never origin's default branch), workers merge its tip into their
|
|
31
|
+
// branch before reporting, and the lead fast-forwards it.
|
|
32
|
+
//
|
|
21
33
|
// The CLI never spawns, waits or wakes anything: it only records and checks.
|
|
22
34
|
import { spawnSync } from "node:child_process";
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
23
36
|
import fs from "node:fs";
|
|
24
37
|
import path from "node:path";
|
|
25
38
|
import { GIT_TIMEOUTS, gitCommonDir, pushRemoteName, resolveRef } from "./git/rev";
|
|
26
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
activeStanding,
|
|
41
|
+
appendObserved,
|
|
42
|
+
isDelegateSession,
|
|
43
|
+
defaultBase,
|
|
44
|
+
readLedger,
|
|
45
|
+
storeRoot,
|
|
46
|
+
type LedgerActor,
|
|
47
|
+
} from "./ledger";
|
|
27
48
|
import { stackFileName } from "./stack";
|
|
28
49
|
|
|
29
50
|
export const FANOUT_VERSION = 1;
|
|
@@ -48,15 +69,28 @@ export type Slice = {
|
|
|
48
69
|
forbidden: string[];
|
|
49
70
|
context: string | null;
|
|
50
71
|
timebox: string | null;
|
|
72
|
+
/** sliceHash of this definition: which run of the slice a ledger row belongs to. */
|
|
73
|
+
hash: string;
|
|
74
|
+
/** When a slice with this hash was first planned: older rows are not its run. */
|
|
75
|
+
hashSince: string;
|
|
51
76
|
};
|
|
52
77
|
|
|
78
|
+
export const FAN_IN_MODES = ["prs", "integration"] as const;
|
|
79
|
+
export type FanInMode = (typeof FAN_IN_MODES)[number];
|
|
80
|
+
|
|
53
81
|
export type FanoutFile = {
|
|
54
82
|
v: number;
|
|
55
83
|
name: string;
|
|
56
84
|
trunk: string;
|
|
85
|
+
/** prs: one PR per slice (default); integration: slices fast-forward one integration branch. */
|
|
86
|
+
fanIn: FanInMode;
|
|
57
87
|
/** Extra shared-file globs on top of DEFAULT_SHARED. */
|
|
58
88
|
shared: string[];
|
|
59
89
|
slices: Slice[];
|
|
90
|
+
/** The lead: the session that made the plan or took it over (`--take-lead`). */
|
|
91
|
+
leadSession: string | null;
|
|
92
|
+
/** Leads it took over from: their standing orders stay in force until cleared. */
|
|
93
|
+
formerLeads: string[];
|
|
60
94
|
createdAt: string;
|
|
61
95
|
updatedAt: string;
|
|
62
96
|
};
|
|
@@ -308,6 +342,9 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
|
|
|
308
342
|
if ("error" in normalized) problems.push(`shared "${entry}" ${normalized.error}`);
|
|
309
343
|
else shared.push(normalized.pattern);
|
|
310
344
|
}
|
|
345
|
+
const fanIn = raw.fanIn ?? "prs";
|
|
346
|
+
if (!(FAN_IN_MODES as readonly unknown[]).includes(fanIn))
|
|
347
|
+
problems.push(`fanIn must be one of ${FAN_IN_MODES.join(", ")}`);
|
|
311
348
|
if (!Array.isArray(raw.slices) || raw.slices.length === 0)
|
|
312
349
|
return { ok: false, problems: [...problems, "slices: list at least one slice"] };
|
|
313
350
|
|
|
@@ -388,6 +425,8 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
|
|
|
388
425
|
forbidden,
|
|
389
426
|
context: optional("context"),
|
|
390
427
|
timebox: optional("timebox"),
|
|
428
|
+
hash: "",
|
|
429
|
+
hashSince: "",
|
|
391
430
|
});
|
|
392
431
|
});
|
|
393
432
|
|
|
@@ -419,8 +458,11 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
|
|
|
419
458
|
v: FANOUT_VERSION,
|
|
420
459
|
name: planName,
|
|
421
460
|
trunk,
|
|
461
|
+
fanIn: fanIn as FanInMode,
|
|
422
462
|
shared,
|
|
423
463
|
slices,
|
|
464
|
+
leadSession: null,
|
|
465
|
+
formerLeads: [],
|
|
424
466
|
createdAt: "",
|
|
425
467
|
updatedAt: "",
|
|
426
468
|
},
|
|
@@ -728,6 +770,29 @@ export function worktreeRoot(cwd: string): string {
|
|
|
728
770
|
return path.join(path.dirname(main), `${path.basename(main)}-wt`);
|
|
729
771
|
}
|
|
730
772
|
|
|
773
|
+
/** Whose standing orders count: the lead and those it took over from; null before leads. */
|
|
774
|
+
export const planLeads = (
|
|
775
|
+
plan: Pick<FanoutFile, "leadSession" | "formerLeads">,
|
|
776
|
+
): string[] | null => (plan.leadSession ? [plan.leadSession, ...plan.formerLeads] : null);
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* sha256 (16 hex) of the slice's definition. Brief-only fields (tier,
|
|
780
|
+
* timebox, context) and the worktree path are left out, so fixing a typo in
|
|
781
|
+
* them never re-dispatches landed work.
|
|
782
|
+
*/
|
|
783
|
+
export function sliceHash(slice: Slice): string {
|
|
784
|
+
const {
|
|
785
|
+
worktree: _worktree,
|
|
786
|
+
hash: _hash,
|
|
787
|
+
hashSince: _since,
|
|
788
|
+
tier: _tier,
|
|
789
|
+
timebox: _timebox,
|
|
790
|
+
context: _context,
|
|
791
|
+
...definition
|
|
792
|
+
} = slice;
|
|
793
|
+
return createHash("sha256").update(JSON.stringify(definition)).digest("hex").slice(0, 16);
|
|
794
|
+
}
|
|
795
|
+
|
|
731
796
|
// ---------------------------------------------------------------------------
|
|
732
797
|
// store
|
|
733
798
|
|
|
@@ -777,8 +842,12 @@ function parseStored(raw: unknown): FanoutFile | null {
|
|
|
777
842
|
forbidden: list(item.forbidden),
|
|
778
843
|
context: typeof item.context === "string" ? item.context : null,
|
|
779
844
|
timebox: typeof item.timebox === "string" ? item.timebox : null,
|
|
845
|
+
hash: text(item.hash),
|
|
846
|
+
// A plan written before slice hashes: its run began at createdAt.
|
|
847
|
+
hashSince: text(item.hashSince) || text(raw.createdAt),
|
|
780
848
|
});
|
|
781
849
|
}
|
|
850
|
+
for (const slice of slices) slice.hash ||= sliceHash(slice);
|
|
782
851
|
const shared = globs(raw.shared);
|
|
783
852
|
const ids = new Set(slices.map((slice) => slice.id));
|
|
784
853
|
if (
|
|
@@ -788,13 +857,18 @@ function parseStored(raw: unknown): FanoutFile | null {
|
|
|
788
857
|
findCycle(slices)
|
|
789
858
|
)
|
|
790
859
|
return null;
|
|
860
|
+
const fanIn = raw.fanIn === "integration" ? "integration" : "prs";
|
|
861
|
+
const createdAt = text(raw.createdAt);
|
|
791
862
|
return {
|
|
792
863
|
v: typeof raw.v === "number" ? raw.v : FANOUT_VERSION,
|
|
793
864
|
name: raw.name,
|
|
794
865
|
trunk: raw.trunk,
|
|
866
|
+
fanIn,
|
|
795
867
|
shared,
|
|
796
868
|
slices,
|
|
797
|
-
|
|
869
|
+
leadSession: typeof raw.leadSession === "string" && raw.leadSession ? raw.leadSession : null,
|
|
870
|
+
formerLeads: list(raw.formerLeads),
|
|
871
|
+
createdAt,
|
|
798
872
|
updatedAt: text(raw.updatedAt),
|
|
799
873
|
};
|
|
800
874
|
}
|
|
@@ -890,6 +964,7 @@ function writeFanout(cwd: string, plan: FanoutFile): FanoutResult<string> {
|
|
|
890
964
|
export type PlanOutcome = {
|
|
891
965
|
name: string;
|
|
892
966
|
trunk: string;
|
|
967
|
+
fanIn: FanInMode;
|
|
893
968
|
file: string;
|
|
894
969
|
created: boolean;
|
|
895
970
|
slices: Array<
|
|
@@ -899,6 +974,8 @@ export type PlanOutcome = {
|
|
|
899
974
|
waves: string[][];
|
|
900
975
|
landingOrder: string[];
|
|
901
976
|
notes: string[];
|
|
977
|
+
/** A new plan under a name that still has standing orders in force (an earlier run's?). */
|
|
978
|
+
warnings: string[];
|
|
902
979
|
};
|
|
903
980
|
|
|
904
981
|
/** Above this many slices in one wave, the lead queues the rest (4-6 in flight). */
|
|
@@ -906,11 +983,31 @@ export const IN_FLIGHT_CAP = 6;
|
|
|
906
983
|
|
|
907
984
|
export function planFanout(
|
|
908
985
|
cwd: string,
|
|
909
|
-
input: {
|
|
986
|
+
input: {
|
|
987
|
+
raw: unknown;
|
|
988
|
+
name: string | null;
|
|
989
|
+
trunk: string | null;
|
|
990
|
+
actor: LedgerActor;
|
|
991
|
+
/** Record this session as the lead (a lead resuming in a new session). */
|
|
992
|
+
takeLead?: boolean;
|
|
993
|
+
},
|
|
910
994
|
now: Date = new Date(),
|
|
911
995
|
): FanoutResult<PlanOutcome> {
|
|
912
996
|
const dir = fanoutsDir(cwd);
|
|
913
997
|
if (!dir.ok) return dir;
|
|
998
|
+
const session = input.actor.session;
|
|
999
|
+
if (input.takeLead && !session)
|
|
1000
|
+
return fanoutFail(
|
|
1001
|
+
"blocked",
|
|
1002
|
+
"--take-lead needs a session: WORKIT_SESSION_ID is not set",
|
|
1003
|
+
"export WORKIT_SESSION_ID=<your session id>, then plan again with --take-lead",
|
|
1004
|
+
);
|
|
1005
|
+
if (input.takeLead && isDelegateSession(session))
|
|
1006
|
+
return fanoutFail(
|
|
1007
|
+
"blocked",
|
|
1008
|
+
`session ${session} is a worker or verifier id; only a lead takes the lead`,
|
|
1009
|
+
"run --take-lead from the lead's own session",
|
|
1010
|
+
);
|
|
914
1011
|
const parsed = parsePlan(
|
|
915
1012
|
input.trunk && isRecord(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw,
|
|
916
1013
|
{
|
|
@@ -928,6 +1025,12 @@ export function planFanout(
|
|
|
928
1025
|
{ problems: parsed.problems },
|
|
929
1026
|
);
|
|
930
1027
|
const plan = parsed.plan;
|
|
1028
|
+
if (plan.fanIn === "integration" && plan.trunk === defaultTrunk(cwd))
|
|
1029
|
+
return fanoutFail(
|
|
1030
|
+
"invalid_input",
|
|
1031
|
+
`fanIn "integration" needs an integration branch as the trunk, not ${plan.trunk}`,
|
|
1032
|
+
`workit git branch <integration-branch> --base ${plan.trunk}, then plan with --trunk <integration-branch>`,
|
|
1033
|
+
);
|
|
931
1034
|
const badBranches = plan.slices
|
|
932
1035
|
.filter((slice) => !gitRun(cwd, ["check-ref-format", "--branch", slice.branch]).ok)
|
|
933
1036
|
.map((slice) => `slice ${slice.id}: ${slice.branch} is not a valid branch name`);
|
|
@@ -975,20 +1078,60 @@ export function planFanout(
|
|
|
975
1078
|
const existing = readFanout(cwd, plan.name);
|
|
976
1079
|
const previous = existing.ok ? existing.data : null;
|
|
977
1080
|
plan.createdAt = previous?.createdAt || now.toISOString();
|
|
1081
|
+
// A re-plan keeps the lead (and a plan from before leads stays without one)
|
|
1082
|
+
// unless this session takes it over.
|
|
1083
|
+
plan.leadSession = input.takeLead ? session : previous ? previous.leadSession : session;
|
|
1084
|
+
plan.formerLeads = [
|
|
1085
|
+
...new Set([
|
|
1086
|
+
...(previous?.formerLeads ?? []),
|
|
1087
|
+
...(previous?.leadSession && previous.leadSession !== plan.leadSession
|
|
1088
|
+
? [previous.leadSession]
|
|
1089
|
+
: []),
|
|
1090
|
+
]),
|
|
1091
|
+
].filter((former) => former !== plan.leadSession);
|
|
978
1092
|
plan.updatedAt = now.toISOString();
|
|
1093
|
+
// Per slice: the same definition continues its run; a changed one starts anew.
|
|
1094
|
+
for (const slice of plan.slices) {
|
|
1095
|
+
slice.hash = sliceHash(slice);
|
|
1096
|
+
const before = previous?.slices.find((old) => old.id === slice.id);
|
|
1097
|
+
slice.hashSince =
|
|
1098
|
+
before && before.hash === slice.hash ? before.hashSince || plan.updatedAt : plan.updatedAt;
|
|
1099
|
+
}
|
|
979
1100
|
const written = writeFanout(cwd, plan);
|
|
980
1101
|
if (!written.ok) return written;
|
|
1102
|
+
if (previous && plan.leadSession !== previous.leadSession)
|
|
1103
|
+
appendObserved(cwd, {
|
|
1104
|
+
type: "fanout.lead.changed",
|
|
1105
|
+
actor: input.actor,
|
|
1106
|
+
fanout: plan.name,
|
|
1107
|
+
from: previous.leadSession,
|
|
1108
|
+
to: plan.leadSession,
|
|
1109
|
+
});
|
|
981
1110
|
appendObserved(cwd, {
|
|
982
1111
|
type: "fanout.planned",
|
|
983
1112
|
actor: input.actor,
|
|
984
1113
|
fanout: plan.name,
|
|
985
1114
|
trunk: plan.trunk,
|
|
1115
|
+
fanIn: plan.fanIn,
|
|
986
1116
|
slices: plan.slices.length,
|
|
987
1117
|
ids: plan.slices.slice(0, 20).map((slice) => slice.id),
|
|
988
1118
|
});
|
|
989
1119
|
|
|
990
1120
|
const grouped = waves(plan.slices);
|
|
991
1121
|
const notes: string[] = [];
|
|
1122
|
+
const warnings: string[] = [];
|
|
1123
|
+
if (!input.takeLead && previous?.leadSession && session && session !== previous.leadSession)
|
|
1124
|
+
notes.push(
|
|
1125
|
+
`the lead stays ${previous.leadSession}; this session is ${session}. Leading from this session now? workit fanout plan <plan.json> --take-lead`,
|
|
1126
|
+
);
|
|
1127
|
+
if (previous === null) {
|
|
1128
|
+
const ledger = readLedger(cwd);
|
|
1129
|
+
const inForce = ledger.ok ? activeStanding(ledger.value.rows, plan.name, planLeads(plan)) : [];
|
|
1130
|
+
if (inForce.length)
|
|
1131
|
+
warnings.push(
|
|
1132
|
+
`${inForce.length} standing order${inForce.length === 1 ? " is" : "s are"} already in force for fanout ${plan.name}, and every brief will carry ${inForce.length === 1 ? "it" : "them"}: ${inForce.map((row) => `"${String(row.what)}"`).join("; ")}. From an earlier run? workit ledger standing clear --fanout ${plan.name}`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
992
1135
|
if (!ref)
|
|
993
1136
|
notes.push(
|
|
994
1137
|
`trunk ${plan.trunk} does not resolve here; only sample paths of each glob were compared`,
|
|
@@ -1009,6 +1152,7 @@ export function planFanout(
|
|
|
1009
1152
|
data: {
|
|
1010
1153
|
name: plan.name,
|
|
1011
1154
|
trunk: plan.trunk,
|
|
1155
|
+
fanIn: plan.fanIn,
|
|
1012
1156
|
file: written.data,
|
|
1013
1157
|
created: previous === null,
|
|
1014
1158
|
slices: plan.slices.map(({ id, branch, base, worktree, tier, dependsOn, scope, owns }) => ({
|
|
@@ -1025,6 +1169,7 @@ export function planFanout(
|
|
|
1025
1169
|
waves: grouped,
|
|
1026
1170
|
landingOrder: landingOrder(plan.slices),
|
|
1027
1171
|
notes,
|
|
1172
|
+
warnings,
|
|
1028
1173
|
},
|
|
1029
1174
|
};
|
|
1030
1175
|
}
|
package/src/ledger.ts
CHANGED
|
@@ -130,8 +130,22 @@ export type VerdictRow = RowCommon & {
|
|
|
130
130
|
evidenceRefs: string[];
|
|
131
131
|
};
|
|
132
132
|
export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
|
|
133
|
+
/** A lead's standing order for every worker of a fanout ("no new dependencies"). */
|
|
134
|
+
export type StandingRow = RowCommon & { type: "standing"; fanout: string; what: string };
|
|
135
|
+
/** Ends one standing order (`target`), or every one recorded before it (null). */
|
|
136
|
+
export type StandingClearedRow = RowCommon & {
|
|
137
|
+
type: "standing.cleared";
|
|
138
|
+
fanout: string;
|
|
139
|
+
target: string | null;
|
|
140
|
+
};
|
|
133
141
|
|
|
134
|
-
export type LedgerRow =
|
|
142
|
+
export type LedgerRow =
|
|
143
|
+
| DecisionRow
|
|
144
|
+
| RulingRow
|
|
145
|
+
| VerdictRow
|
|
146
|
+
| HandoffRow
|
|
147
|
+
| StandingRow
|
|
148
|
+
| StandingClearedRow;
|
|
135
149
|
/** A row as read back: any known or future type, plus its position. */
|
|
136
150
|
export type ReadRow = RowCommon &
|
|
137
151
|
Record<string, unknown> & {
|
|
@@ -1076,6 +1090,142 @@ export function recordRuling(
|
|
|
1076
1090
|
);
|
|
1077
1091
|
}
|
|
1078
1092
|
|
|
1093
|
+
/**
|
|
1094
|
+
* May this row speak for the fanout's lead? With recorded leads (the current
|
|
1095
|
+
* one and any it took over from) only their rows count; a plan from before
|
|
1096
|
+
* leads (null) takes any session.
|
|
1097
|
+
*/
|
|
1098
|
+
const fromLead = (row: ReadRow, leads: readonly string[] | null): boolean =>
|
|
1099
|
+
leads === null || (row.actor.session !== null && leads.includes(row.actor.session));
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* The standing orders in force for `fanout`, oldest first (D17: odd rows are
|
|
1103
|
+
* skipped). Given the plan's lead session, orders and clears recorded by any
|
|
1104
|
+
* other session are ignored, so a worker can neither add nor clear one.
|
|
1105
|
+
*/
|
|
1106
|
+
export function activeStanding(
|
|
1107
|
+
rows: readonly ReadRow[],
|
|
1108
|
+
fanout: string,
|
|
1109
|
+
leads: readonly string[] | null = null,
|
|
1110
|
+
): ReadRow[] {
|
|
1111
|
+
const active: ReadRow[] = [];
|
|
1112
|
+
for (const row of rows) {
|
|
1113
|
+
if (row.fanout !== fanout || !fromLead(row, leads)) continue;
|
|
1114
|
+
if (row.type === "standing" && typeof row.what === "string" && row.what.trim()) {
|
|
1115
|
+
if (!row.superseded) active.push(row);
|
|
1116
|
+
} else if (row.type === "standing.cleared") {
|
|
1117
|
+
const target = str(row.target);
|
|
1118
|
+
if (target === null) active.length = 0;
|
|
1119
|
+
else {
|
|
1120
|
+
const index = active.findIndex((order) => order.id === target);
|
|
1121
|
+
if (index >= 0) active.splice(index, 1);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
return active;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* A worker or verifier session by the ids workit hands out: `fanout brief`'s
|
|
1130
|
+
* `<lead>-w-<slice>[+try<n>]`, a verifier's `<lead>-v<n>`, the Claude Code
|
|
1131
|
+
* hook's `<lead>:<agent id>` and `--as` ids (`<session>:<role>:<hex>`). Host
|
|
1132
|
+
* session ids carry no colon. Used where no recorded lead decides: plans that
|
|
1133
|
+
* predate `leadSession`, and `fanout plan --take-lead`.
|
|
1134
|
+
*/
|
|
1135
|
+
export const isDelegateSession = (session: string | null): boolean =>
|
|
1136
|
+
session !== null &&
|
|
1137
|
+
(/-w-[a-z0-9._-]+(?:\+try\d)?$/u.test(session) ||
|
|
1138
|
+
/-v\d+$/u.test(session) ||
|
|
1139
|
+
session.includes(":"));
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Standing orders come from the fanout's lead: the session that first made
|
|
1143
|
+
* the plan (`lead`). A plan without one falls back to refusing the session
|
|
1144
|
+
* ids handed to workers and verifiers.
|
|
1145
|
+
*/
|
|
1146
|
+
function leadOnly(context: RecordContext, lead: string | null): LedgerResult<void> {
|
|
1147
|
+
const session = context.actor.session;
|
|
1148
|
+
if (lead !== null ? session === lead : !isDelegateSession(session))
|
|
1149
|
+
return { ok: true, value: undefined };
|
|
1150
|
+
return err(
|
|
1151
|
+
"blocked",
|
|
1152
|
+
lead !== null
|
|
1153
|
+
? `session ${session ?? "(none)"} is not the fanout's lead (${lead}): standing orders come from the lead`
|
|
1154
|
+
: `session ${session} is a worker or verifier: standing orders come from the lead`,
|
|
1155
|
+
lead !== null
|
|
1156
|
+
? `the lead in a new session? workit fanout plan <plan.json> --take-lead from this session; a worker or verifier reports the order to the lead instead`
|
|
1157
|
+
: "a worker or verifier reports the order to the lead instead",
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
export function recordStanding(
|
|
1162
|
+
context: RecordContext,
|
|
1163
|
+
input: { fanout: string; lead: string | null; what?: string },
|
|
1164
|
+
): LedgerResult<StandingRow> {
|
|
1165
|
+
const what = required(input.what, "<order>");
|
|
1166
|
+
if (!what.ok) return what;
|
|
1167
|
+
// One order per line of every brief: a newline or control character would
|
|
1168
|
+
// let an order forge other brief fields.
|
|
1169
|
+
if (/\p{Cc}/u.test(what.value))
|
|
1170
|
+
return err(
|
|
1171
|
+
"invalid_input",
|
|
1172
|
+
"a standing order is one line: no newlines or control characters",
|
|
1173
|
+
'add each order separately: workit ledger standing add "<order>"',
|
|
1174
|
+
);
|
|
1175
|
+
const lead = leadOnly(context, input.lead);
|
|
1176
|
+
if (!lead.ok) return lead;
|
|
1177
|
+
const link = checkSupersede(context, "standing");
|
|
1178
|
+
if (!link.ok) return link;
|
|
1179
|
+
return appendRow<StandingRow>(
|
|
1180
|
+
context.cwd,
|
|
1181
|
+
{
|
|
1182
|
+
...common(context, noteKey(context)),
|
|
1183
|
+
type: "standing",
|
|
1184
|
+
fanout: input.fanout,
|
|
1185
|
+
what: what.value,
|
|
1186
|
+
},
|
|
1187
|
+
{ now: context.now },
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** Clear one standing order by id (it must be in force), or all of them; lead only. */
|
|
1192
|
+
export function clearStanding(
|
|
1193
|
+
context: RecordContext,
|
|
1194
|
+
input: {
|
|
1195
|
+
fanout: string;
|
|
1196
|
+
lead: string | null;
|
|
1197
|
+
leads?: readonly string[] | null;
|
|
1198
|
+
target: string | null;
|
|
1199
|
+
},
|
|
1200
|
+
): LedgerResult<StandingClearedRow> {
|
|
1201
|
+
const lead = leadOnly(context, input.lead);
|
|
1202
|
+
if (!lead.ok) return lead;
|
|
1203
|
+
if (input.target !== null) {
|
|
1204
|
+
const ledger = readLedger(context.cwd);
|
|
1205
|
+
if (!ledger.ok) return ledger;
|
|
1206
|
+
if (
|
|
1207
|
+
!activeStanding(ledger.value.rows, input.fanout, input.leads ?? null).some(
|
|
1208
|
+
(row) => row.id === input.target,
|
|
1209
|
+
)
|
|
1210
|
+
)
|
|
1211
|
+
return err(
|
|
1212
|
+
"not_found",
|
|
1213
|
+
`no standing order ${input.target} in force for fanout ${input.fanout}`,
|
|
1214
|
+
`workit ledger standing list --fanout ${input.fanout}`,
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
return appendRow<StandingClearedRow>(
|
|
1218
|
+
context.cwd,
|
|
1219
|
+
{
|
|
1220
|
+
...common(context, noteKey(context)),
|
|
1221
|
+
type: "standing.cleared",
|
|
1222
|
+
fanout: input.fanout,
|
|
1223
|
+
target: input.target,
|
|
1224
|
+
},
|
|
1225
|
+
{ now: context.now },
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1079
1229
|
/**
|
|
1080
1230
|
* Record an agent-asserted verdict on a branch's committed head. Refused on a
|
|
1081
1231
|
* dirty worktree (the head would not be what was judged) and, for an author
|
|
@@ -1221,6 +1371,12 @@ export function summarizeRow(row: ReadRow): RowSummary {
|
|
|
1221
1371
|
case "handoff":
|
|
1222
1372
|
summary = `next: ${text("next")}${text("note") ? ` (note: ${text("note")})` : ""}`;
|
|
1223
1373
|
break;
|
|
1374
|
+
case "standing":
|
|
1375
|
+
summary = `${text("what")} (fanout ${text("fanout")})`;
|
|
1376
|
+
break;
|
|
1377
|
+
case "standing.cleared":
|
|
1378
|
+
summary = `cleared ${text("target") || "every standing order"} (fanout ${text("fanout")})`;
|
|
1379
|
+
break;
|
|
1224
1380
|
case "commit.recorded":
|
|
1225
1381
|
summary = `${(row.head ?? "?").slice(0, 12)} ${text("subject")}${row.actor.session ? "" : " (no session)"}`;
|
|
1226
1382
|
break;
|