@wemuda/launchrail 1.12.0 → 1.14.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/README.md +3 -1
- package/assets/agents-docs/domain.md +2 -2
- package/assets/agents-docs/issue-tracker-github.md +1 -1
- package/assets/agents-docs/issue-tracker-linear.md +1 -1
- package/assets/ralph.workflow.js +640 -273
- package/assets/skills/NOTICE.md +4 -0
- package/assets/skills/launchrail/launch/SKILL.md +9 -7
- package/assets/skills/launchrail/launch/workflow.md +57 -4
- package/assets/skills/launchrail/launch-code-review/SKILL.md +1 -1
- package/assets/skills/launchrail/launch-design-handoff/SKILL.md +3 -2
- package/assets/skills/launchrail/launch-design-validation/SKILL.md +3 -3
- package/assets/skills/launchrail/launch-discovery/SKILL.md +3 -3
- package/assets/skills/launchrail/launch-grill/SKILL.md +51 -15
- package/assets/skills/launchrail/launch-grill/domain-modeling.md +3 -1
- package/assets/skills/launchrail/launch-implement/SKILL.md +10 -10
- package/assets/skills/launchrail/launch-project-alignment/SKILL.md +3 -3
- package/assets/skills/launchrail/launch-ralph/SKILL.md +79 -59
- package/assets/skills/launchrail/launch-ralph-implement/SKILL.md +8 -7
- package/assets/skills/launchrail/launch-resolving-merge-conflicts/SKILL.md +1 -1
- package/assets/skills/launchrail/launch-spec/SKILL.md +8 -6
- package/assets/skills/launchrail/launch-tickets/SKILL.md +10 -7
- package/assets/skills/launchrail/launch-vision-creation/SKILL.md +2 -2
- package/assets/skills/launchrail/launch-wayfinder/SKILL.md +15 -9
- package/dist/commands/add.js +2 -1
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +2 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/verify.d.ts +11 -2
- package/dist/commands/verify.js +22 -7
- package/dist/commands/verify.js.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/adr.d.ts +27 -0
- package/dist/lib/adr.js +87 -0
- package/dist/lib/adr.js.map +1 -0
- package/dist/lib/manifest.d.ts +7 -1
- package/dist/lib/manifest.js +8 -1
- package/dist/lib/manifest.js.map +1 -1
- package/dist/lib/project.d.ts +2 -0
- package/dist/lib/project.js +2 -1
- package/dist/lib/project.js.map +1 -1
- package/dist/lib/seeds.d.ts +2 -0
- package/dist/lib/seeds.js +14 -6
- package/dist/lib/seeds.js.map +1 -1
- package/package.json +1 -1
package/assets/ralph.workflow.js
CHANGED
|
@@ -6,20 +6,31 @@
|
|
|
6
6
|
// so long or wide runs cannot compact away their own state. This is the engine for
|
|
7
7
|
// every multi-ticket run (ADR-0022); the launch-ralph skill carries the same policy
|
|
8
8
|
// block as the supervisor's contract and the declared-exception watchable mode, and
|
|
9
|
-
// a policy change belongs in both places (ADR-0005, field-revised by ADR-0010,
|
|
9
|
+
// a policy change belongs in both places (ADR-0005, field-revised by ADR-0010,
|
|
10
|
+
// ADR-0022, and ADR-0032).
|
|
11
|
+
//
|
|
12
|
+
// The lean shape (ADR-0032): builders push their branch from the first commit on, so
|
|
13
|
+
// a lost container costs minutes, not a build; each finished branch is landed by the
|
|
14
|
+
// loop itself — a local squash-merge onto the integration base gated by the FAST
|
|
15
|
+
// verification tier, in this checkout, one land at a time — and pushed; the FULL
|
|
16
|
+
// gate runs at checkpoints (every N lands) and at release, with one bounded repair
|
|
17
|
+
// when a checkpoint is red. No per-ticket PR, no cloud-CI wait anywhere in the loop:
|
|
18
|
+
// cloud CI runs once, on the release PR the run offers. A work pool keeps `width`
|
|
19
|
+
// builders busy continuously — a slow ticket never holds a round.
|
|
10
20
|
export const meta = {
|
|
11
21
|
name: 'ralph',
|
|
12
22
|
description: 'Autonomous Ralph loop: implement ready tickets with fresh-context subagents, verification-gated',
|
|
13
23
|
whenToUse:
|
|
14
|
-
'The engine for any multi-ticket Ralph run. Scope a run via args: { only: [9, 10], width: 2 }, just [9, 10], or { max: 5 } to stop after 5 verified
|
|
24
|
+
'The engine for any multi-ticket Ralph run. Scope a run via args: { only: [9, 10], width: 2 }, just [9, 10], or { max: 5 } to stop after 5 verified lands ("the next five" — the frontier picks which, in dependency order). The front door consolidates by DEFAULT (ADR-0026): it passes { target: "spec/44-mvp" } to collect the campaign onto that branch (default branch untouched; released later by one offered PR). In a session pinned to a designated working branch (hosted sessions), the front door passes that branch as the target (ADR-0028). Omitting target is the explicit trunk opt-in — each ticket landed straight onto the default branch. { canary: true } holds width at 1 until the first verified land. { knownGreen: "<sha>" } lets a relaunch skip the preflight gate when the base still sits at a sha a previous run verified. Args must be JSON — resolve any natural-language scope to ticket numbers, a cap, and a target before launching. For a watchable run (an explicit user ask, or a targeted intervention), use the launch-ralph skill instead — and say why.',
|
|
15
25
|
phases: [
|
|
16
|
-
{ title: 'Preflight', detail: 'read project config,
|
|
26
|
+
{ title: 'Preflight', detail: 'read project config, sync the integration base into this checkout, run the full gate (or honor knownGreen)' },
|
|
17
27
|
{ title: 'Graph', detail: 'list ready tickets and their blocking edges, verbatim' },
|
|
18
|
-
{ title: 'Build', detail: 'one fresh-context implementer per ticket, handing off at
|
|
19
|
-
{ title: '
|
|
20
|
-
{ title: 'Verify', detail: 'remote ground truth for every claimed
|
|
28
|
+
{ title: 'Build', detail: 'one fresh-context implementer per ticket on a pushed ralph/<n>-* branch, handing off at a green fast gate' },
|
|
29
|
+
{ title: 'Land', detail: 'local squash-merge onto the base under the fast gate, push, explicit close — one land at a time' },
|
|
30
|
+
{ title: 'Verify', detail: 'remote ground truth for every claimed land' },
|
|
31
|
+
{ title: 'Checkpoint', detail: 'the full gate on the base every N lands, with one bounded repair when red' },
|
|
21
32
|
{ title: 'Park', detail: 'comment failure history, label needs-info' },
|
|
22
|
-
{ title: 'Release', detail: 'final
|
|
33
|
+
{ title: 'Release', detail: 'final full gate and smoke, prune landed branches, the where-it-lives recap' },
|
|
23
34
|
],
|
|
24
35
|
}
|
|
25
36
|
|
|
@@ -48,42 +59,56 @@ const A = resolveArgs(args)
|
|
|
48
59
|
const POLICY = {
|
|
49
60
|
// Scope the run to specific ticket numbers ([] = the whole ready frontier).
|
|
50
61
|
only: A.only ?? [],
|
|
51
|
-
// Stop after this many verified
|
|
52
|
-
// decides which five, in dependency order.
|
|
53
|
-
// run has at most `max`
|
|
62
|
+
// Stop after this many verified lands (0 = no cap). "The next five": the frontier
|
|
63
|
+
// decides which five, in dependency order. Dispatch never exceeds the remainder, so a
|
|
64
|
+
// run has at most `max` lands and leaves the rest of the frontier ready, not parked.
|
|
54
65
|
max: A.max ?? 0,
|
|
55
|
-
// Parallel implementers
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
66
|
+
// Parallel implementers, kept busy continuously by a work pool: when one finishes the
|
|
67
|
+
// next ready ticket dispatches — a slow ticket never holds the others. Width also caps
|
|
68
|
+
// local build concurrency — several implementers share one machine, and fanning out
|
|
69
|
+
// test runs buys backpressure, not speed. Use 1 until a run has landed tickets cleanly
|
|
70
|
+
// on this project — or pass canary: true, which does it for you. Tickets that add DB
|
|
71
|
+
// migrations are serialized by the loop itself (see the graph's `migration` flag).
|
|
60
72
|
width: A.width ?? 3,
|
|
61
73
|
// Integration target. The front door consolidates by DEFAULT (ADR-0026): it resolves a
|
|
62
74
|
// scope-native branch name and passes it here, so the campaign collects on that branch and
|
|
63
75
|
// the default branch is never touched — the run ends by offering ONE release PR
|
|
64
|
-
// target -> default. A session pinned to a designated
|
|
65
|
-
// branch here (ADR-0028) — the pin re-targets the run, it never
|
|
76
|
+
// target -> default, which is also where cloud CI runs. A session pinned to a designated
|
|
77
|
+
// working branch passes that branch here (ADR-0028) — the pin re-targets the run, it never
|
|
78
|
+
// changes the engine. '' is the explicit trunk opt-in: each ticket landed straight onto
|
|
66
79
|
// the default branch; a bare launch with no target is therefore trunk mode — non-default.
|
|
67
80
|
target: A.target ?? '',
|
|
68
|
-
// Canary: hold width at 1 until the run's first verified
|
|
69
|
-
// end to end (branch,
|
|
81
|
+
// Canary: hold width at 1 until the run's first verified land proves the plumbing
|
|
82
|
+
// end to end (branch, push, land, gate, close). For a project's first campaign.
|
|
70
83
|
canary: A.canary ?? false,
|
|
71
|
-
// Tries per ticket: 1 attempt + 1 retry with a fresh context, then park.
|
|
72
|
-
//
|
|
84
|
+
// Tries per ticket: 1 attempt + 1 retry with a fresh context, then park. A retry adopts
|
|
85
|
+
// the pushed branch and fixes forward — the work persists, only the context is fresh.
|
|
86
|
+
// Deferrals (a declared blocker had not landed yet) hand their attempt back, capped
|
|
87
|
+
// separately at the same number.
|
|
73
88
|
attempts: A.attempts ?? 2,
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
//
|
|
89
|
+
// Land hand-backs that spend no attempt: the base moved under a finished branch and the
|
|
90
|
+
// squash-merge conflicted, or the merged tree failed the loop's gate although the branch
|
|
91
|
+
// was fine on its own. The builder did nothing wrong, so a fresh implementer re-syncs the
|
|
92
|
+
// pushed branch (minutes, not a rebuild) — up to this many times per ticket before it
|
|
93
|
+
// counts as a real failure.
|
|
94
|
+
resyncs: A.resyncs ?? 2,
|
|
95
|
+
// Run the FULL verification gate on the base after this many lands (0 = only at release).
|
|
96
|
+
// Every land already passed the fast gate on the merged tree; the full suite (browser
|
|
97
|
+
// journeys included) is paid once per checkpoint instead of once per ticket, and a red
|
|
98
|
+
// checkpoint has at most this many suspects.
|
|
99
|
+
checkpointEvery: A.checkpointEvery ?? 5,
|
|
100
|
+
// A sha the caller vouches for: a previous run verified the base green at exactly this
|
|
101
|
+
// commit. Preflight still syncs and installs, but skips the full gate when the base tip
|
|
102
|
+
// equals it — relaunching after a lost container costs minutes, not a re-proof.
|
|
103
|
+
knownGreen: A.knownGreen ?? '',
|
|
104
|
+
// Backstop against a graph that never drains: total build dispatches (attempts, retries,
|
|
105
|
+
// deferrals, re-syncs, repairs included).
|
|
106
|
+
maxBuilds: A.maxBuilds ?? 60,
|
|
107
|
+
// Re-read the tracker when the frontier is empty but tickets remain, so externally closed
|
|
108
|
+
// blockers unblock things.
|
|
84
109
|
refreshGraph: A.refreshGraph ?? true,
|
|
85
|
-
// Stop
|
|
86
|
-
//
|
|
110
|
+
// Stop dispatching when the remaining token budget drops below this — a build that starts
|
|
111
|
+
// without enough budget to land is worse than one that never starts.
|
|
87
112
|
reserve: A.reserve ?? 200_000,
|
|
88
113
|
}
|
|
89
114
|
|
|
@@ -92,12 +117,12 @@ const POLICY = {
|
|
|
92
117
|
// ---------------------------------------------------------------------------
|
|
93
118
|
const INTEGRITY = `INTEGRITY: No placeholders, no stubs, no "simplified for now". Never delete,
|
|
94
119
|
skip, or weaken a test to get a green run; if a test is genuinely wrong, fix it deliberately
|
|
95
|
-
and say so in the
|
|
120
|
+
and say so in the commit message. Never claim a gate passed without having run it.`
|
|
96
121
|
|
|
97
122
|
const IDEMPOTENCY = `IDEMPOTENCY: This step can be replayed after an interruption, so check
|
|
98
123
|
before you act: if the ticket is already closed, report status "already-done" and stop; if a
|
|
99
|
-
ralph/<n>-* branch
|
|
100
|
-
|
|
124
|
+
pushed ralph/<n>-* branch for this ticket already exists, adopt it and continue from its last
|
|
125
|
+
commit — do not start over, and never create a second branch for the same ticket.`
|
|
101
126
|
|
|
102
127
|
// ---------------------------------------------------------------------------
|
|
103
128
|
// Schemas — stages return validated structure, never prose the script must parse.
|
|
@@ -105,10 +130,11 @@ where it left off — do not start over. Never open a second PR for the same tic
|
|
|
105
130
|
const PREFLIGHT_SCHEMA = {
|
|
106
131
|
type: 'object',
|
|
107
132
|
additionalProperties: false,
|
|
108
|
-
required: ['green', 'base', 'defaultBranch', 'trackerAccess', 'verifyCommand', 'localCommands', 'failures'],
|
|
133
|
+
required: ['green', 'base', 'defaultBranch', 'trackerAccess', 'installCommand', 'verifyCommand', 'fastGateCommand', 'localCommands', 'pushedBranches', 'failures'],
|
|
109
134
|
properties: {
|
|
110
|
-
green: { type: 'boolean', description: 'base is synced and the
|
|
111
|
-
headSha: { type: 'string', description: '
|
|
135
|
+
green: { type: 'boolean', description: 'base is synced into this checkout and the full gate passed (or was skipped as known green)' },
|
|
136
|
+
headSha: { type: 'string', description: 'the base tip the gate ran against (or that matched knownGreen)' },
|
|
137
|
+
skippedGate: { type: 'boolean', description: 'true when the gate was skipped because the base tip equals knownGreen' },
|
|
112
138
|
repo: { type: 'string', description: 'owner/name from the git remote, or empty' },
|
|
113
139
|
base: { type: 'string', description: "the run's integration base: the declared target branch when one is set, else the default branch" },
|
|
114
140
|
defaultBranch: { type: 'string', description: 'the repository default branch name' },
|
|
@@ -119,13 +145,28 @@ const PREFLIGHT_SCHEMA = {
|
|
|
119
145
|
description:
|
|
120
146
|
'instruction for reaching the tracker from THIS execution environment (exact CLI or MCP tools available here), for inclusion in dispatch prompts',
|
|
121
147
|
},
|
|
122
|
-
|
|
148
|
+
installCommand: { type: 'string', description: 'the verbatim dependency install command (e.g. pnpm install --frozen-lockfile)' },
|
|
149
|
+
verifyCommand: { type: 'string', description: 'the FULL verification gate command, verbatim' },
|
|
150
|
+
fastGateCommand: { type: 'string', description: 'the FAST verification gate command, verbatim' },
|
|
123
151
|
localCommands: {
|
|
124
152
|
type: 'array',
|
|
125
153
|
items: { type: 'string' },
|
|
126
|
-
description: 'other verbatim local commands (
|
|
154
|
+
description: 'other verbatim local commands (typecheck, lint, unit tests) implementers should use',
|
|
127
155
|
},
|
|
128
156
|
browserTesting: { type: 'boolean', description: '.launchrail.yml modules.browser-testing' },
|
|
157
|
+
pushedBranches: {
|
|
158
|
+
type: 'array',
|
|
159
|
+
items: {
|
|
160
|
+
type: 'object',
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
required: ['branch', 'sha'],
|
|
163
|
+
properties: {
|
|
164
|
+
branch: { type: 'string', description: 'a ralph/<n>-<slug> branch present on the remote' },
|
|
165
|
+
sha: { type: 'string' },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
description: 'every ralph/* branch on the remote — in-flight work a previous run pushed, which this run adopts',
|
|
169
|
+
},
|
|
129
170
|
failures: { type: 'array', items: { type: 'string' } },
|
|
130
171
|
},
|
|
131
172
|
}
|
|
@@ -140,7 +181,7 @@ const GRAPH_SCHEMA = {
|
|
|
140
181
|
items: {
|
|
141
182
|
type: 'object',
|
|
142
183
|
additionalProperties: false,
|
|
143
|
-
required: ['number', 'title', 'blockedByLine'],
|
|
184
|
+
required: ['number', 'title', 'blockedByLine', 'migration'],
|
|
144
185
|
properties: {
|
|
145
186
|
number: { type: 'integer' },
|
|
146
187
|
title: { type: 'string' },
|
|
@@ -149,6 +190,11 @@ const GRAPH_SCHEMA = {
|
|
|
149
190
|
description:
|
|
150
191
|
'The ticket\'s "Blocked by" line copied VERBATIM (e.g. "**Blocked by:** #11, #9"), or "" if it has none. Do NOT interpret or resolve the edges — copy the characters; the caller parses the #n itself.',
|
|
151
192
|
},
|
|
193
|
+
migration: {
|
|
194
|
+
type: 'boolean',
|
|
195
|
+
description:
|
|
196
|
+
'true when the ticket plainly adds or changes a database schema or migration (a new table, column, or migration file); false otherwise or when unsure. Used only to serialize such tickets — never to skip one.',
|
|
197
|
+
},
|
|
152
198
|
},
|
|
153
199
|
},
|
|
154
200
|
},
|
|
@@ -168,12 +214,16 @@ const BUILD_SCHEMA = {
|
|
|
168
214
|
properties: {
|
|
169
215
|
status: {
|
|
170
216
|
type: 'string',
|
|
171
|
-
enum: ['
|
|
217
|
+
enum: ['ready', 'already-done', 'blocked', 'conflict', 'verify-failed', 'failed'],
|
|
172
218
|
description:
|
|
173
|
-
'"
|
|
219
|
+
'"ready" is the normal hand-off: the branch is pushed and the fast gate is green on it (the loop lands it); "already-done" when the ticket turned out to be closed already (idempotency)',
|
|
220
|
+
},
|
|
221
|
+
branch: { type: 'string', description: 'the pushed ralph/<n>-<slug> branch' },
|
|
222
|
+
headSha: { type: 'string', description: 'the pushed tip, as the remote reports it' },
|
|
223
|
+
commitTitle: {
|
|
224
|
+
type: 'string',
|
|
225
|
+
description: 'a Conventional Commit title for the squash the loop will make, e.g. "feat(auth): add magic-link sign-in"',
|
|
174
226
|
},
|
|
175
|
-
pr: { type: 'integer', description: 'PR number, when one was opened or adopted' },
|
|
176
|
-
mergeCommit: { type: 'string' },
|
|
177
227
|
summary: { type: 'string', description: 'what happened, short; on failure, enough for a retry to act on' },
|
|
178
228
|
failure: {
|
|
179
229
|
type: 'string',
|
|
@@ -187,20 +237,26 @@ const BUILD_SCHEMA = {
|
|
|
187
237
|
},
|
|
188
238
|
}
|
|
189
239
|
|
|
190
|
-
const
|
|
240
|
+
const LAND_SCHEMA = {
|
|
191
241
|
type: 'object',
|
|
192
242
|
additionalProperties: false,
|
|
193
243
|
required: ['status', 'summary'],
|
|
194
244
|
properties: {
|
|
195
245
|
status: {
|
|
196
246
|
type: 'string',
|
|
197
|
-
enum: ['
|
|
247
|
+
enum: ['landed', 'conflict', 'gate-failed', 'stale', 'failed'],
|
|
248
|
+
description:
|
|
249
|
+
'"landed": squashed onto the base, gate green, pushed, remote confirmed; "conflict": the squash-merge conflicted; "gate-failed": the merged tree failed the gate; "stale": the remote base kept moving outside this loop; "failed": a precondition (dirty or diverged checkout, empty squash)',
|
|
250
|
+
},
|
|
251
|
+
mergeCommit: { type: 'string', description: 'on landed: the landing commit sha, as the remote reports it' },
|
|
252
|
+
baseMoved: {
|
|
253
|
+
type: 'boolean',
|
|
254
|
+
description: 'true when the base had moved since the implementer synced (its tip was not contained in the branch)',
|
|
198
255
|
},
|
|
199
|
-
mergeCommit: { type: 'string' },
|
|
200
256
|
issueClosed: { type: 'boolean' },
|
|
201
257
|
summary: {
|
|
202
258
|
type: 'string',
|
|
203
|
-
description: 'on
|
|
259
|
+
description: 'on landed: the facts (sha, gate); on conflict: the conflicting files; on gate-failed: the failing checks and key lines a fresh implementer can act on',
|
|
204
260
|
},
|
|
205
261
|
},
|
|
206
262
|
}
|
|
@@ -208,11 +264,11 @@ const GATE_SCHEMA = {
|
|
|
208
264
|
const VERIFY_SCHEMA = {
|
|
209
265
|
type: 'object',
|
|
210
266
|
additionalProperties: false,
|
|
211
|
-
required: ['
|
|
267
|
+
required: ['landed', 'issueClosed', 'evidence'],
|
|
212
268
|
properties: {
|
|
213
|
-
|
|
269
|
+
landed: {
|
|
214
270
|
type: 'boolean',
|
|
215
|
-
description: 'true only if the
|
|
271
|
+
description: 'true only if the reported landing commit exists on the remote AND is on the base branch',
|
|
216
272
|
},
|
|
217
273
|
issueClosed: { type: 'boolean' },
|
|
218
274
|
mergeCommit: { type: 'string' },
|
|
@@ -220,14 +276,31 @@ const VERIFY_SCHEMA = {
|
|
|
220
276
|
},
|
|
221
277
|
}
|
|
222
278
|
|
|
279
|
+
const CHECKPOINT_SCHEMA = {
|
|
280
|
+
type: 'object',
|
|
281
|
+
additionalProperties: false,
|
|
282
|
+
required: ['green', 'headSha', 'summary'],
|
|
283
|
+
properties: {
|
|
284
|
+
green: { type: 'boolean', description: 'the full verification gate exited 0 on the synced base' },
|
|
285
|
+
headSha: { type: 'string', description: 'the base tip the gate ran against' },
|
|
286
|
+
summary: { type: 'string' },
|
|
287
|
+
failures: {
|
|
288
|
+
type: 'array',
|
|
289
|
+
items: { type: 'string' },
|
|
290
|
+
description: 'on red: the failing checks/tests with their key lines — a repair implementer acts on exactly this',
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
223
295
|
const RELEASE_SCHEMA = {
|
|
224
296
|
type: 'object',
|
|
225
297
|
additionalProperties: false,
|
|
226
298
|
required: ['verified', 'summary'],
|
|
227
299
|
properties: {
|
|
228
|
-
verified: { type: 'boolean', description: 'verification gate green on the final base' },
|
|
300
|
+
verified: { type: 'boolean', description: 'the full verification gate is green on the final base (run now, or already proven at this exact tip)' },
|
|
229
301
|
headSha: { type: 'string' },
|
|
230
302
|
smokeBundle: { type: 'string', description: 'path of the smoke evidence bundle, when one was produced' },
|
|
303
|
+
prunedBranches: { type: 'array', items: { type: 'string' }, description: 'the landed ralph/* branches deleted from the remote' },
|
|
231
304
|
summary: { type: 'string' },
|
|
232
305
|
failures: { type: 'array', items: { type: 'string' } },
|
|
233
306
|
},
|
|
@@ -237,57 +310,128 @@ const RELEASE_SCHEMA = {
|
|
|
237
310
|
// Dispatch prompts
|
|
238
311
|
// ---------------------------------------------------------------------------
|
|
239
312
|
function preamble(pre) {
|
|
240
|
-
return `You are working in a Launchrail project${pre.repo ? ` (${pre.repo})` : ''}; base
|
|
241
|
-
Binding docs: AGENTS.md and CLAUDE.md, plus the vision
|
|
313
|
+
return `You are working in a Launchrail project${pre.repo ? ` (${pre.repo})` : ''}; integration base: ${pre.base}.
|
|
314
|
+
Binding docs: AGENTS.md and CLAUDE.md, plus the vision and specs under docs/. Read them before touching code.
|
|
315
|
+
Architecture decisions: read the registry index (docs/adr/README.md) and open only the ADRs touching your ticket's area — an ADR records a decision, not the current system; the code is the evidence for what exists.
|
|
242
316
|
Tracker access from this environment: ${pre.trackerAccess}
|
|
243
317
|
Blocking edges live on tickets as "Blocked by: #n" lines.
|
|
244
|
-
Verbatim local commands: ${
|
|
245
|
-
The
|
|
318
|
+
Verbatim local commands: install: ${pre.installCommand}${pre.localCommands.length > 0 ? ` ; ${pre.localCommands.join(' ; ')}` : ''}
|
|
319
|
+
Two gates. The FAST gate is ${pre.fastGateCommand} — run it before every hand-off; it must exit 0. The FULL gate (${pre.verifyCommand}) belongs to the loop, which runs it on the base at its checkpoints — do not spend your turn on the whole suite; run only the slow test files your change touches (a browser journey you edited).
|
|
246
320
|
Several implementers share this machine — run single test files while iterating and save full runs for the gate.`
|
|
247
321
|
}
|
|
248
322
|
|
|
249
|
-
function buildPrompt(pre, ticket,
|
|
323
|
+
function buildPrompt(pre, ticket, s, pushed, resync) {
|
|
250
324
|
const retry =
|
|
251
|
-
|
|
252
|
-
? `\nThis is a RETRY with a fresh context. Prior attempt failed: ${
|
|
253
|
-
|
|
325
|
+
s.attempts > 1
|
|
326
|
+
? `\nThis is a RETRY with a fresh context. Prior attempt failed: ${s.failures.join(' | ')}
|
|
327
|
+
The pushed branch (if any) holds the previous attempt's work — adopt it and fix forward from the failure. Only if the failure shows the approach itself was wrong, reset the branch to origin/${pre.base} and start over (force-pushing your own ralph/${ticket.number}-* branch is allowed; nothing else ever is).\n`
|
|
254
328
|
: ''
|
|
329
|
+
const resyncNote = resync
|
|
330
|
+
? `\nThe loop could not land the pushed branch because ${pre.base} moved under it: ${resync}
|
|
331
|
+
This is a RE-SYNC, not a failure: adopt the branch, merge the latest ${pre.base} into it (conflicts are ordinary work — the launch-resolving-merge-conflicts skill; regenerate migrations that now collide), make the fast gate green on the merged result, push, and hand off again.\n`
|
|
332
|
+
: ''
|
|
333
|
+
const adopt = pushed
|
|
334
|
+
? `A pushed branch already exists for this ticket: ${pushed.branch} at ${pushed.sha} — a previous session's work. Adopt it (\`git fetch origin ${pushed.branch} && git checkout -b ${pushed.branch} origin/${pushed.branch}\`), read its log and its diff against origin/${pre.base}, and continue from where it stopped — never start over.`
|
|
335
|
+
: `Check the remote first (\`git ls-remote --heads origin 'ralph/${ticket.number}-*'\`) and adopt any pushed branch you find (\`git fetch origin <branch> && git checkout -b <branch> origin/<branch>\`, then continue from its last commit). Otherwise branch from a fresh fetch and push at once: \`git fetch origin ${pre.base} && git checkout -b ralph/${ticket.number}-<short-slug> origin/${pre.base} && git push -u origin HEAD\`. Never check out ${pre.base} itself in this worktree — the loop lands in the main checkout.`
|
|
255
336
|
return `${preamble(pre)}
|
|
256
337
|
|
|
257
|
-
Implement ticket #${ticket.number} ("${ticket.title}") through to
|
|
258
|
-
${retry}
|
|
338
|
+
Implement ticket #${ticket.number} ("${ticket.title}") through to a pushed, fast-gate-green branch. You own the build alone; assume no knowledge of any other session. Other implementers are working on other tickets against the same base right now, so ${pre.base} will move under you. That is expected.
|
|
339
|
+
${retry}${resyncNote}
|
|
259
340
|
Steps, in order:
|
|
260
|
-
1. Dependency gate: before anything else, confirm every ticket on this ticket's "Blocked by" line is CLOSED with its work
|
|
341
|
+
1. Dependency gate: before anything else, confirm every ticket on this ticket's "Blocked by" line is CLOSED with its work landed on ${pre.base}. If any blocker is still open, do NOT build on a missing dependency — report status "blocked", name the open blocker in "failure", and stop. That is a deferral, not a failure; the loop retries you after the blocker lands.
|
|
261
342
|
2. Read the ticket and everything it links (spec sections, ADRs, journeys). If the tracker tool truncates the body (long code spans are a known trigger), fetch the full text by another route — the tracker's search API, the spec file in the repo — and never implement from a truncated ticket. Report status "already-done" if the ticket is already closed.
|
|
262
343
|
3. Label the ticket ralph:building so a lost session leaves a trace.
|
|
263
|
-
4. Branch
|
|
264
|
-
5. Implement by invoking the launch-ralph-implement skill — it owns the per-ticket contract: TDD, the
|
|
265
|
-
6. Pre-
|
|
266
|
-
7.
|
|
344
|
+
4. Branch and push immediately. ${adopt} From here on the pushed branch is your checkpoint: commit and push after every green step (a passing test slice, a finished subtask) — a session that dies keeps everything up to its last push, and its successor resumes from there instead of rebuilding. The pushes are also the loop's liveness signal.
|
|
345
|
+
5. Implement by invoking the launch-ralph-implement skill — it owns the per-ticket contract: TDD, commit-and-push cadence, the fast gate, browser smoke for user-facing changes, self-review via launch-code-review, commit conventions.
|
|
346
|
+
6. Pre-land sync: merge the latest origin/${pre.base} into your branch. Conflicts are ordinary work — resolve them with the launch-resolving-merge-conflicts skill. If ${pre.base} gained DB migrations since you branched, regenerate yours to follow them with the project's migration tool — never hand-edit the migration journal. Re-run the fast gate if anything changed, then push.
|
|
347
|
+
7. Hand off: confirm the tip is pushed (\`git ls-remote origin refs/heads/<branch>\` equals \`git rev-parse HEAD\`) and report status "ready" with branch, headSha, and commitTitle — a Conventional Commit title for the squash the loop will make (e.g. "feat(auth): add magic-link sign-in"). Then STOP: the loop lands your branch (a local squash-merge onto ${pre.base} under its own gate run, the push, the explicit issue close). Never push to ${pre.base} yourself, never merge your work into ${pre.base}, and never open a PR — the campaign is released by one PR at the end.
|
|
267
348
|
|
|
268
349
|
${INTEGRITY}
|
|
269
350
|
|
|
270
351
|
${IDEMPOTENCY}
|
|
271
352
|
|
|
272
|
-
Report honestly via the schema: "
|
|
353
|
+
Report honestly via the schema: "ready" once the branch is pushed with the fast gate green; "already-done" when the ticket was closed already; "blocked" when a declared blocker had not landed; "verify-failed" when the fast gate would not go green; "conflict" when a conflict was too ambiguous to resolve without losing behavior (say which files and why); "failed" otherwise, with a summary a fresh retry can act on. List deliberately-out-of-scope discoveries in "punted".`
|
|
273
354
|
}
|
|
274
355
|
|
|
275
|
-
|
|
276
|
-
|
|
356
|
+
// The lander is the loop's own gate: it re-runs the gate on the merged tree in THIS
|
|
357
|
+
// checkout (warm caches, seconds to minutes), never trusting the builder's word, and
|
|
358
|
+
// pushes only a green base. One land at a time — the script serializes them.
|
|
359
|
+
function landPrompt(pre, subject, full) {
|
|
360
|
+
const gate = full ? `the FULL gate: ${pre.verifyCommand}` : `the FAST gate: ${pre.fastGateCommand}`
|
|
361
|
+
const what = subject.repair
|
|
362
|
+
? `a repair of the red base`
|
|
363
|
+
: `ticket #${subject.number} ("${subject.title}")`
|
|
364
|
+
const title = subject.commitTitle || (subject.repair ? `fix: repair ${pre.base}` : `feat: ${subject.title}`)
|
|
365
|
+
const trailer = subject.repair ? '' : ` -m "Closes #${subject.number}"`
|
|
366
|
+
return `You are landing ${what}: the implementer pushed branch ${subject.branch} (head ${subject.headSha || 'see remote'}) and reports the fast gate green there. You own the local squash-merge onto ${pre.base}, the loop's own gate run, the push, and the tracker bookkeeping — nothing else. You never write code and never repair a branch: a squash that conflicts or a gate that fails is reported, not fixed here. Work in THIS checkout (no worktree); exactly one land runs at a time, so nothing else touches it while you do.
|
|
277
367
|
Tracker access from this environment: ${pre.trackerAccess}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
368
|
+
1. Preconditions: \`git status --porcelain\` must show no modified or staged files (untracked files are fine) — a dirty tree is status "failed" ("dirty checkout"); never stash or discard anything. Then \`git fetch origin ${pre.base} ${subject.branch}\`.
|
|
369
|
+
2. Check out the base: \`git checkout ${pre.base}\` (or \`git checkout -b ${pre.base} --track origin/${pre.base}\` when no local branch exists), then \`git merge --ff-only origin/${pre.base}\`. If the fast-forward is refused, the local ${pre.base} has diverged from the remote — report status "failed" saying so; do not reset it.
|
|
370
|
+
3. Record whether the base moved since the implementer synced: \`git merge-base --is-ancestor origin/${pre.base} origin/${subject.branch}\` — exit 0 means the branch already contains the base tip (baseMoved: false); otherwise baseMoved: true.
|
|
371
|
+
4. Squash-merge: \`git merge --squash origin/${subject.branch}\`. On conflicts, restore the exact clean state of step 2 with \`git reset --hard origin/${pre.base}\` and report status "conflict" naming the conflicting files. Otherwise commit: \`git commit -m ${JSON.stringify(subject.repair ? title : `${title} (#${subject.number})`)}${trailer} -m "Landed by the Ralph loop from ${subject.branch}@${subject.headSha || 'HEAD'}"\`. Nothing to commit → status "failed" (the branch adds nothing to the base).
|
|
372
|
+
5. If the landed change touched a dependency manifest or lockfile (\`git diff --name-only HEAD~1 HEAD\`), run the install command first: ${pre.installCommand}. Then run ${gate}. It must exit 0. On failure, undo the landing with \`git reset --hard origin/${pre.base}\` and report status "gate-failed" with the failing checks/tests and their key lines — enough for a fresh implementer to act on. A red base is never pushed.
|
|
373
|
+
6. \`git push origin ${pre.base}\`. A rejected (non-fast-forward) push means the remote moved outside this loop: \`git fetch origin ${pre.base} && git reset --hard origin/${pre.base}\` and redo steps 3–6 ONCE; a second rejection is status "stale".
|
|
374
|
+
7. Confirm on the remote: \`git ls-remote origin refs/heads/${pre.base}\` must equal \`git rev-parse HEAD\`. That sha is mergeCommit.${
|
|
375
|
+
subject.repair
|
|
376
|
+
? ''
|
|
377
|
+
: `
|
|
378
|
+
8. Tracker bookkeeping: close issue #${subject.number} explicitly (auto-close never fires off the default branch) and read it back closed; remove the ralph:building label; post one comment: "Landed on ${pre.base} at <sha> (squash of ${subject.branch}) by the Ralph loop." Report status "landed" with mergeCommit and issueClosed.`
|
|
379
|
+
}
|
|
380
|
+
Report "landed" only after step 7 confirmed the remote; every other outcome by its status, with a summary the loop can act on.`
|
|
283
381
|
}
|
|
284
382
|
|
|
285
|
-
function verifyPrompt(pre, ticket,
|
|
286
|
-
return `Establish ground truth for ticket #${ticket.number} using the tracker
|
|
383
|
+
function verifyPrompt(pre, ticket, land) {
|
|
384
|
+
return `Establish ground truth for ticket #${ticket.number} using the tracker/API tools only.
|
|
287
385
|
Tracker access: ${pre.trackerAccess}
|
|
288
|
-
|
|
289
|
-
Check, against the remote: (1)
|
|
290
|
-
Report
|
|
386
|
+
The loop reports it landed on ${pre.base} as commit ${land.mergeCommit}.
|
|
387
|
+
Check, against the remote: (1) commit ${land.mergeCommit} exists in the repository and is on branch ${pre.base} (list the branch's recent commits, or compare the branch with the sha); (2) issue #${ticket.number} is closed.
|
|
388
|
+
Report landed: true only when (1) holds, issueClosed from (2). A comment or a report is NOT evidence — only API state counts. Do not run local git, do not use a shell. Fix nothing, close nothing; report only.`
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function checkpointPrompt(pre, k, suspects) {
|
|
392
|
+
return `Full verification checkpoint ${k} for the Ralph loop's base ${pre.base}, in THIS checkout (exactly one land or checkpoint runs at a time, so nothing else touches it). Fix nothing.
|
|
393
|
+
Since the last green checkpoint these tickets landed: ${suspects.map((t) => `#${t.number} (${t.title})`).join(', ') || 'none'}.
|
|
394
|
+
1. \`git fetch origin ${pre.base}\`; \`git checkout ${pre.base}\` and \`git merge --ff-only origin/${pre.base}\`; record \`git rev-parse HEAD\` as headSha.
|
|
395
|
+
2. Run the install command (${pre.installCommand}) so the tree's dependencies are current, then the FULL verification gate: ${pre.verifyCommand}. Report the actual exit code, not the reassuring summary line.
|
|
396
|
+
green means: the gate exited 0. On red, list every failing check or test with its key lines in failures — a repair implementer will act on exactly that.`
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function repairPrompt(pre, cp, suspects, k) {
|
|
400
|
+
return `${preamble(pre)}
|
|
401
|
+
|
|
402
|
+
The Ralph loop's base ${pre.base} is RED at ${cp.headSha}: the full verification gate failed at checkpoint ${k} after these tickets landed since the last green base (${cp.greenSha || 'the run start'}): ${suspects.map((t) => `#${t.number} (${t.title}, landed as ${t.mergeCommit})`).join(', ') || 'none'}.
|
|
403
|
+
Failures: ${(cp.failures ?? []).join(' | ') || cp.summary}
|
|
404
|
+
|
|
405
|
+
Repair the base through to a pushed, green branch:
|
|
406
|
+
1. Branch from a fresh fetch and push at once: \`git fetch origin ${pre.base} && git checkout -b ralph/repair-${k}-<short-slug> origin/${pre.base} && git push -u origin HEAD\`. Never check out ${pre.base} itself in this worktree.
|
|
407
|
+
2. Reproduce the failure — the failing test files first, the full gate if needed — and find the root cause among the listed landings (\`git log ${cp.greenSha || ''}..${cp.headSha}\`): an integration break between two tickets, a migration-number collision, a journey a landing invalidated. Fix it properly; regenerate colliding migrations with the project's migration tool.
|
|
408
|
+
3. Make the fast gate green, then the FULL gate (${pre.verifyCommand}) green — this repair lands under the full gate. Self-review via the launch-code-review skill, commit conventionally, push after every green step.
|
|
409
|
+
4. Hand off: report status "ready" with branch, headSha, and a commitTitle like "fix(<scope>): <what>". Then STOP — the loop lands it. Never push to ${pre.base} yourself.
|
|
410
|
+
Report "failed" with the one fact a human must know if the base cannot be repaired without losing behavior.
|
|
411
|
+
|
|
412
|
+
${INTEGRITY}`
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function releasePrompt(pre, opts) {
|
|
416
|
+
const gateStep = opts.baseRed
|
|
417
|
+
? `2. The base is known RED (${opts.baseRedReason}) — skip the gate and report verified: false with that reason in failures.`
|
|
418
|
+
: opts.needsGate
|
|
419
|
+
? `2. Run the install command (${pre.installCommand}), then the FULL verification gate: ${pre.verifyCommand}. Report the actual exit code.`
|
|
420
|
+
: `2. The full gate already passed at exactly this tip (${opts.greenSha}) during the run — report verified: true without re-running it, unless the tip you synced differs, in which case run ${pre.verifyCommand}.`
|
|
421
|
+
const smokeStep =
|
|
422
|
+
!opts.baseRed && opts.smoke
|
|
423
|
+
? `
|
|
424
|
+
3. The browser-testing module is enabled and tickets landed: start the app (node scripts/dev.mjs --background), scaffold an evidence bundle (npx @wemuda/launchrail smoke), and drive the smoke journeys from docs/testing/smoke-journeys.md per the launch-browser-smoke skill. Report the bundle path. A journey you could not complete is a failure, never a pass.`
|
|
425
|
+
: ''
|
|
426
|
+
const pruneStep =
|
|
427
|
+
opts.prune.length > 0
|
|
428
|
+
? `
|
|
429
|
+
4. Prune the remote branches of the verified-landed tickets, and ONLY these: ${opts.prune.join(', ')} (\`git push origin --delete <branch>\` each; a branch already gone is fine). Never delete any other branch. Report prunedBranches.`
|
|
430
|
+
: ''
|
|
431
|
+
return `Release verification for a finished Ralph loop run, in THIS checkout. Fix nothing.
|
|
432
|
+
1. Sync a fresh ${pre.base} (\`git fetch origin ${pre.base}\`, \`git checkout ${pre.base}\`, \`git merge --ff-only origin/${pre.base}\`) and record its head sha.
|
|
433
|
+
${gateStep}${smokeStep}${pruneStep}
|
|
434
|
+
verified means: the full verification gate is green on the final base${!opts.baseRed && opts.smoke ? ' AND no smoke journey failed' : ''}.`
|
|
291
435
|
}
|
|
292
436
|
|
|
293
437
|
const graphPrompt = (pre) => `List the open, ready tickets for a Ralph loop run. Change nothing on the tracker.
|
|
@@ -295,7 +439,8 @@ Tracker access: ${pre.trackerAccess}
|
|
|
295
439
|
Include every open ticket labeled ready-for-agent, excluding any labeled needs-info.
|
|
296
440
|
An open issue wearing ready-for-agent that is plainly not an implementable ticket — a published spec, research notes, an epic — is a labeling error: leave it out of tickets and report its number in notTickets instead. When in doubt, include it as a ticket.
|
|
297
441
|
For each, report its number, its exact title, and its "Blocked by" line copied VERBATIM (the whole line, e.g. "**Blocked by:** #11, #9"), or "" when it has none. If the tracker records blocking through native relations instead of a body line, render those relations as one "Blocked by: #n, #m" line and nothing else.
|
|
298
|
-
Do NOT interpret, resolve, or filter the edges — copy the characters and let the caller parse the #n. Getting a blocker wrong dispatches a ticket before its dependency lands
|
|
442
|
+
Do NOT interpret, resolve, or filter the edges — copy the characters and let the caller parse the #n. Getting a blocker wrong dispatches a ticket before its dependency lands.
|
|
443
|
+
Also flag migration: true for a ticket that plainly adds or changes a database schema or migration (a new table, column, or migration file) — false otherwise or when unsure. The loop only uses it to run such tickets one at a time.`
|
|
299
444
|
|
|
300
445
|
// A non-ticket wearing ready-for-agent (a published spec, research notes) is excluded from
|
|
301
446
|
// the frontier but never silently: the label is the bug, and the supervisor should fix it.
|
|
@@ -311,6 +456,7 @@ function parseGraph(graph) {
|
|
|
311
456
|
return (graph?.tickets ?? []).map((t) => ({
|
|
312
457
|
number: t.number,
|
|
313
458
|
title: t.title,
|
|
459
|
+
migration: t.migration === true,
|
|
314
460
|
blockedBy: [...(t.blockedByLine ?? '').matchAll(/#(\d+)/g)]
|
|
315
461
|
.map((m) => Number(m[1]))
|
|
316
462
|
.filter((n) => n !== t.number),
|
|
@@ -318,130 +464,226 @@ function parseGraph(graph) {
|
|
|
318
464
|
}
|
|
319
465
|
|
|
320
466
|
// ---------------------------------------------------------------------------
|
|
321
|
-
//
|
|
467
|
+
// State
|
|
322
468
|
// ---------------------------------------------------------------------------
|
|
323
|
-
|
|
469
|
+
// number -> { ticket, attempts, defers, resyncs, failures[], status, branch, mergeCommit, punted[], waiting }
|
|
470
|
+
// status: pending | landed | done-before | external | parked | held
|
|
471
|
+
const state = new Map()
|
|
324
472
|
|
|
325
473
|
function entry(ticket) {
|
|
326
474
|
if (!state.has(ticket.number)) {
|
|
327
|
-
state.set(ticket.number, {
|
|
475
|
+
state.set(ticket.number, {
|
|
476
|
+
ticket,
|
|
477
|
+
attempts: 0,
|
|
478
|
+
defers: 0,
|
|
479
|
+
resyncs: 0,
|
|
480
|
+
failures: [],
|
|
481
|
+
status: 'pending',
|
|
482
|
+
branch: '',
|
|
483
|
+
mergeCommit: '',
|
|
484
|
+
punted: [],
|
|
485
|
+
waiting: false,
|
|
486
|
+
})
|
|
328
487
|
}
|
|
329
488
|
return state.get(ticket.number)
|
|
330
489
|
}
|
|
331
490
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
491
|
+
let builds = 0 // every build-type dispatch: attempts, retries, deferrals, re-syncs, repairs
|
|
492
|
+
let landedCount = 0 // verified lands
|
|
493
|
+
const landedSinceGreen = [] // { number, title, mergeCommit } landed since the last green full gate
|
|
494
|
+
let sinceCheckpoint = 0
|
|
495
|
+
let lastGreenSha = ''
|
|
496
|
+
let baseTip = ''
|
|
497
|
+
let baseRed = false
|
|
498
|
+
let baseRedReason = ''
|
|
499
|
+
let checkpointRuns = 0
|
|
500
|
+
const checkpoints = [] // every full-gate verdict on the base, repairs included
|
|
501
|
+
|
|
502
|
+
// The land lock: exactly one land or checkpoint touches the main checkout at a time.
|
|
503
|
+
// A promise chain, so it needs no timers and survives a resumed run unchanged.
|
|
504
|
+
let landLock = Promise.resolve()
|
|
505
|
+
function withLandLock(fn) {
|
|
506
|
+
const run = landLock.then(fn, fn)
|
|
507
|
+
landLock = run.then(
|
|
508
|
+
() => {},
|
|
509
|
+
() => {},
|
|
510
|
+
)
|
|
511
|
+
return run
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// Stages
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
// A green full gate on the base: after every POLICY.checkpointEvery lands, and whenever
|
|
519
|
+
// a repair lands. Called holding the land lock. A red checkpoint gets exactly one repair
|
|
520
|
+
// dispatch (a fresh implementer with the failures and the suspects); if that does not land
|
|
521
|
+
// green, the base is declared red and nothing lands until a human looks — built tickets
|
|
522
|
+
// keep their pushed branches and are reported as held.
|
|
523
|
+
async function checkpoint(pre) {
|
|
524
|
+
checkpointRuns += 1
|
|
525
|
+
const k = checkpointRuns
|
|
526
|
+
const suspects = [...landedSinceGreen]
|
|
527
|
+
const cp = await agent(checkpointPrompt(pre, k, suspects), {
|
|
528
|
+
label: `checkpoint:${k}`,
|
|
529
|
+
phase: 'Checkpoint',
|
|
530
|
+
schema: CHECKPOINT_SCHEMA,
|
|
531
|
+
effort: 'low',
|
|
340
532
|
})
|
|
341
|
-
if (!
|
|
342
|
-
|
|
343
|
-
return
|
|
533
|
+
if (!cp) {
|
|
534
|
+
log(`checkpoint ${k}: agent died (infrastructure) — the next land re-runs it`)
|
|
535
|
+
return
|
|
344
536
|
}
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
537
|
+
checkpoints.push({ k, headSha: cp.headSha, green: cp.green, suspects: suspects.map((t) => t.number) })
|
|
538
|
+
if (cp.green) {
|
|
539
|
+
lastGreenSha = cp.headSha
|
|
540
|
+
sinceCheckpoint = 0
|
|
541
|
+
landedSinceGreen.length = 0
|
|
542
|
+
log(`checkpoint ${k}: full gate green at ${cp.headSha}`)
|
|
543
|
+
return
|
|
349
544
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
545
|
+
log(`checkpoint ${k}: full gate RED at ${cp.headSha} after ${suspects.map((t) => `#${t.number}`).join(', ') || 'no lands'} — dispatching one repair`)
|
|
546
|
+
builds += 1
|
|
547
|
+
const fix = await agent(repairPrompt(pre, { ...cp, greenSha: lastGreenSha }, suspects, k), {
|
|
548
|
+
label: `repair:${k}`,
|
|
549
|
+
phase: 'Build',
|
|
550
|
+
schema: BUILD_SCHEMA,
|
|
551
|
+
isolation: 'worktree',
|
|
552
|
+
})
|
|
553
|
+
if (fix && fix.status === 'ready' && fix.branch) {
|
|
554
|
+
const land = await agent(landPrompt(pre, { repair: true, ...fix }, true), {
|
|
555
|
+
label: `land:repair:${k}`,
|
|
556
|
+
phase: 'Land',
|
|
557
|
+
schema: LAND_SCHEMA,
|
|
558
|
+
effort: 'low',
|
|
559
|
+
})
|
|
560
|
+
if (land && land.status === 'landed') {
|
|
561
|
+
lastGreenSha = land.mergeCommit
|
|
562
|
+
baseTip = land.mergeCommit
|
|
563
|
+
sinceCheckpoint = 0
|
|
564
|
+
landedSinceGreen.length = 0
|
|
565
|
+
checkpoints.push({ k, headSha: land.mergeCommit, green: true, suspects: [], repair: fix.branch })
|
|
566
|
+
log(`repair landed at ${land.mergeCommit} under the full gate — base green again`)
|
|
567
|
+
return
|
|
358
568
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (build.status !== 'pr-open' && build.status !== 'merged') {
|
|
363
|
-
s.failures.push(`[attempt ${s.attempts}] ${build.status}: ${build.failure ?? build.summary}`)
|
|
364
|
-
return { ticket, ok: false }
|
|
569
|
+
baseRedReason = `repair branch ${fix.branch} did not land: ${land ? `${land.status}: ${land.summary}` : 'lander died'}`
|
|
570
|
+
} else {
|
|
571
|
+
baseRedReason = `repair did not produce a branch: ${fix ? (fix.failure ?? fix.summary) : 'repair agent died'}`
|
|
365
572
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
573
|
+
baseRed = true
|
|
574
|
+
log(`base RED at ${cp.headSha} and the repair did not land (${baseRedReason}) — no further lands; finished tickets stay on their pushed branches as held`)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Land a finished branch: one at a time, in the main checkout, under the fast gate.
|
|
578
|
+
function landTicket(pre, ticket, build) {
|
|
579
|
+
return withLandLock(async () => {
|
|
580
|
+
if (baseRed) return { status: 'held', summary: baseRedReason }
|
|
581
|
+
const land = await agent(landPrompt(pre, { number: ticket.number, title: ticket.title, ...build }, false), {
|
|
582
|
+
label: `land:#${ticket.number}${build.resyncLabel ?? ''}`,
|
|
583
|
+
phase: 'Land',
|
|
584
|
+
schema: LAND_SCHEMA,
|
|
585
|
+
effort: 'low',
|
|
586
|
+
})
|
|
587
|
+
if (land && land.status === 'landed') {
|
|
588
|
+
baseTip = land.mergeCommit
|
|
589
|
+
sinceCheckpoint += 1
|
|
590
|
+
landedSinceGreen.push({ number: ticket.number, title: ticket.title, mergeCommit: land.mergeCommit })
|
|
591
|
+
if (POLICY.checkpointEvery > 0 && sinceCheckpoint >= POLICY.checkpointEvery) await checkpoint(pre)
|
|
592
|
+
}
|
|
593
|
+
return land
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function drive(pre, ticket, pushed) {
|
|
598
|
+
const s = entry(ticket)
|
|
599
|
+
let resync = null
|
|
600
|
+
for (;;) {
|
|
601
|
+
s.attempts += 1
|
|
602
|
+
builds += 1
|
|
603
|
+
const label = `build:#${ticket.number}${s.attempts > 1 ? ':retry' : ''}${resync ? `:resync${s.resyncs}` : ''}`
|
|
604
|
+
const build = await agent(buildPrompt(pre, ticket, s, pushed, resync), {
|
|
605
|
+
label,
|
|
606
|
+
phase: 'Build',
|
|
607
|
+
schema: BUILD_SCHEMA,
|
|
608
|
+
isolation: 'worktree', // parallel implementers must never fight over one checkout
|
|
609
|
+
})
|
|
610
|
+
if (!build) {
|
|
611
|
+
s.failures.push('implementer died (infrastructure)')
|
|
612
|
+
return { ticket, ok: false, dead: true }
|
|
613
|
+
}
|
|
614
|
+
s.punted.push(...(build.punted ?? []))
|
|
615
|
+
if (build.branch) s.branch = build.branch
|
|
616
|
+
if (build.status === 'already-done') {
|
|
617
|
+
s.status = 'done-before'
|
|
618
|
+
return { ticket, ok: false, doneBefore: true }
|
|
619
|
+
}
|
|
620
|
+
if (build.status === 'blocked') {
|
|
621
|
+
// A declared blocker had not actually landed — the frontier's view was stale, or the
|
|
622
|
+
// blocker is open but outside the ready set. Hand the attempt back: a deferral is not
|
|
623
|
+
// a failure. Capped so a permanently missing dependency still parks eventually.
|
|
624
|
+
s.defers += 1
|
|
625
|
+
if (s.defers <= POLICY.attempts) {
|
|
626
|
+
s.attempts -= 1
|
|
627
|
+
return { ticket, ok: false, deferred: true, why: build.failure ?? build.summary }
|
|
628
|
+
}
|
|
629
|
+
s.failures.push(`still blocked after ${s.defers} deferrals: ${build.failure ?? build.summary}`)
|
|
630
|
+
return { ticket, ok: false }
|
|
631
|
+
}
|
|
632
|
+
if (build.status !== 'ready') {
|
|
633
|
+
s.failures.push(`[attempt ${s.attempts}] ${build.status}: ${build.failure ?? build.summary}`)
|
|
634
|
+
return { ticket, ok: false }
|
|
635
|
+
}
|
|
636
|
+
if (!build.branch) {
|
|
637
|
+
s.failures.push(`[attempt ${s.attempts}] reported ready but returned no branch`)
|
|
638
|
+
return { ticket, ok: false }
|
|
639
|
+
}
|
|
640
|
+
// The loop owns the landing (ADR-0022, ADR-0032): a local squash-merge onto the base
|
|
641
|
+
// under the fast gate, one at a time. No PR, no CI wait — the builder's branch is
|
|
642
|
+
// pushed, so nothing is lost whatever happens next.
|
|
643
|
+
const land = await landTicket(pre, ticket, { ...build, resyncLabel: resync ? `:resync${s.resyncs}` : '' })
|
|
644
|
+
if (!land) {
|
|
645
|
+
s.failures.push('lander died (infrastructure)')
|
|
646
|
+
return { ticket, ok: false, dead: true }
|
|
647
|
+
}
|
|
648
|
+
if (land.status === 'held') {
|
|
649
|
+
s.status = 'held'
|
|
650
|
+
return { ticket, ok: false, held: true }
|
|
651
|
+
}
|
|
652
|
+
if (land.status === 'landed') {
|
|
653
|
+
// Nothing is trusted from a report — a claimed land is checked against the remote
|
|
654
|
+
// by a separate, cheap agent with tracker access only.
|
|
655
|
+
const verdict = await agent(verifyPrompt(pre, ticket, land), {
|
|
656
|
+
label: `verify:#${ticket.number}`,
|
|
657
|
+
phase: 'Verify',
|
|
658
|
+
schema: VERIFY_SCHEMA,
|
|
659
|
+
model: 'haiku',
|
|
390
660
|
effort: 'low',
|
|
391
661
|
})
|
|
392
|
-
if (
|
|
393
|
-
s.
|
|
394
|
-
|
|
662
|
+
if (verdict?.landed && verdict.issueClosed) {
|
|
663
|
+
s.status = 'landed'
|
|
664
|
+
s.mergeCommit = verdict.mergeCommit || land.mergeCommit
|
|
665
|
+
return { ticket, ok: true }
|
|
395
666
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (gate.status !== 'merged') {
|
|
400
|
-
s.failures.push(`[attempt ${s.attempts}] PR #${build.pr} ${gate.status}: ${gate.summary}`)
|
|
667
|
+
// Landed-but-issue-open fails verification too: the retry finds the ticket's work on
|
|
668
|
+
// the base (empty squash) or the issue open, finishes the bookkeeping, and settles.
|
|
669
|
+
s.failures.push(`[attempt ${s.attempts}] claimed landed, remote disagrees: ${verdict ? verdict.evidence : 'verifier died'}`)
|
|
401
670
|
return { ticket, ok: false }
|
|
402
671
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
s.status
|
|
416
|
-
|
|
417
|
-
s.mergeCommit = verdict.mergeCommit || mergeCommit
|
|
418
|
-
return { ticket, ok: true }
|
|
672
|
+
// The base moved under a finished branch: a conflict, a gate that only fails on the
|
|
673
|
+
// merged tree, or a remote that kept moving. The builder did nothing wrong — hand the
|
|
674
|
+
// pushed branch to a fresh implementer to re-sync, without spending an attempt.
|
|
675
|
+
const integration = land.status === 'conflict' || land.status === 'stale' || (land.status === 'gate-failed' && land.baseMoved === true)
|
|
676
|
+
if (integration && s.resyncs < POLICY.resyncs) {
|
|
677
|
+
s.resyncs += 1
|
|
678
|
+
s.attempts -= 1
|
|
679
|
+
resync = `${land.status}: ${land.summary}`
|
|
680
|
+
log(`#${ticket.number}: land hand-back (${land.status}) — re-syncing the pushed branch (${s.resyncs}/${POLICY.resyncs}), no attempt spent`)
|
|
681
|
+
pushed = { branch: build.branch, sha: build.headSha ?? '' }
|
|
682
|
+
continue
|
|
683
|
+
}
|
|
684
|
+
s.failures.push(`[attempt ${s.attempts}] land ${land.status}: ${land.summary}`)
|
|
685
|
+
return { ticket, ok: false }
|
|
419
686
|
}
|
|
420
|
-
// Merged-but-issue-open fails verification too: the retry adopts the merged PR (the
|
|
421
|
-
// idempotency clause), closes the issue explicitly, and the ticket settles cleanly.
|
|
422
|
-
s.failures.push(
|
|
423
|
-
`[attempt ${s.attempts}] claimed merged, remote disagrees: ${verdict ? verdict.evidence : 'verifier died'}`,
|
|
424
|
-
)
|
|
425
|
-
return { ticket, ok: false }
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// A ticket is ready when it isn't settled, hasn't exhausted its attempts, every blocker
|
|
429
|
-
// is closed-before-the-run or merged-by-us, and no blocker is parked. Pure code — the
|
|
430
|
-
// orchestrator never asks a subagent what's ready. The implementer's dependency gate is
|
|
431
|
-
// the backstop for what this check cannot see (a blocker that is open but never entered
|
|
432
|
-
// the ready set).
|
|
433
|
-
function frontier(tickets, closedBefore) {
|
|
434
|
-
return tickets.filter((t) => {
|
|
435
|
-
const s = entry(t)
|
|
436
|
-
if (s.status !== 'pending') return false
|
|
437
|
-
if (s.attempts >= POLICY.attempts) return false
|
|
438
|
-
if (POLICY.only.length > 0 && !POLICY.only.includes(t.number)) return false
|
|
439
|
-
return t.blockedBy.every((b) => {
|
|
440
|
-
if (closedBefore.has(b)) return true
|
|
441
|
-
const blocker = state.get(b)
|
|
442
|
-
return blocker !== undefined && blocker.status === 'merged'
|
|
443
|
-
})
|
|
444
|
-
})
|
|
445
687
|
}
|
|
446
688
|
|
|
447
689
|
// ---------------------------------------------------------------------------
|
|
@@ -449,16 +691,22 @@ function frontier(tickets, closedBefore) {
|
|
|
449
691
|
// ---------------------------------------------------------------------------
|
|
450
692
|
phase('Preflight')
|
|
451
693
|
const pre = await agent(
|
|
452
|
-
`Preflight for a Ralph loop run in
|
|
694
|
+
`Preflight for a Ralph loop run in THIS checkout — the loop lands tickets here (one land at a time), while builders use their own worktrees. Report actual state; fix nothing — the permitted mutations are creating the declared integration branch (step 2) and syncing this checkout onto the base.
|
|
453
695
|
1. Read .launchrail.yml (issueTracker, testing commands, modules) and AGENTS.md (verbatim commands).
|
|
454
696
|
2. Identify the repo (git remote) and its default branch; report the default branch name as defaultBranch. ${
|
|
455
697
|
POLICY.target
|
|
456
698
|
? `This run consolidates onto the integration branch "${POLICY.target}" — that branch is the base. If it does not exist on the remote, create it from the default branch's tip (no force; the default branch itself is never touched) and report targetCreated: true. A missing DEFAULT branch is still not green — do not guess.`
|
|
457
|
-
: `This run
|
|
458
|
-
} Sync the base
|
|
699
|
+
: `This run lands onto the default branch (trunk) — that branch is the base. If it does not exist on the remote, report not green and say the base is missing — do not guess another branch.`
|
|
700
|
+
} Sync the base INTO THIS CHECKOUT: the tree must be clean (\`git status --porcelain\` shows no modified or staged files — untracked files are fine; a dirty tree is not green: say so, stash nothing); \`git fetch origin\`, check out the base (tracking origin) and \`git merge --ff-only origin/<base>\` — a local base that has diverged from origin is not green (say "push or reset it first"). Report the base name as base and its tip as headSha.
|
|
459
701
|
3. Determine how the tracker is reachable from THIS environment: check whether the CLI the project docs assume (e.g. gh) is installed; if not, name the concrete substitute available here (e.g. GitHub MCP tools) as an instruction future agents can follow.
|
|
460
|
-
4.
|
|
461
|
-
|
|
702
|
+
4. List in-flight work from previous sessions: \`git ls-remote --heads origin 'ralph/*'\` — report every branch with its sha as pushedBranches (the loop adopts them; delete nothing).
|
|
703
|
+
5. Run the project's install command (report it verbatim as installCommand). ${
|
|
704
|
+
POLICY.knownGreen
|
|
705
|
+
? `Then compare headSha with "${POLICY.knownGreen}": if they are EQUAL, a previous run verified the base green at exactly this commit — skip the verification gate, report skippedGate: true and green: true. If they differ, run the FULL gate as below.`
|
|
706
|
+
: 'Then run the FULL verification gate'
|
|
707
|
+
}: npx @wemuda/launchrail verify. Report the actual exit codes, not the reassuring summary line. An empty verification contract failing the gate is a refusal condition, not something to work around.
|
|
708
|
+
Report verifyCommand as "npx @wemuda/launchrail verify" and fastGateCommand as "npx @wemuda/launchrail verify --fast" (the fast tier: testing.checkCommand, else the unit command — never e2e).
|
|
709
|
+
green means: base synced in this checkout AND (the full gate exited 0 OR it was skipped as known green).`,
|
|
462
710
|
{ label: 'preflight', phase: 'Preflight', schema: PREFLIGHT_SCHEMA },
|
|
463
711
|
)
|
|
464
712
|
if (!pre) throw new Error('preflight agent died — refusing to start')
|
|
@@ -470,18 +718,30 @@ if (!pre.green) {
|
|
|
470
718
|
if ((pre.issueTracker ?? 'none') === 'none') {
|
|
471
719
|
return { refused: true, reason: 'no issue tracker configured (.launchrail.yml issueTracker: none) — Ralph needs tickets' }
|
|
472
720
|
}
|
|
721
|
+
lastGreenSha = pre.headSha ?? ''
|
|
722
|
+
baseTip = pre.headSha ?? ''
|
|
723
|
+
|
|
724
|
+
// Pushed ralph/<n>-* branches are a previous session's in-flight work; the number in the
|
|
725
|
+
// branch name says whose. The dispatch tells the builder to adopt it — resume costs the
|
|
726
|
+
// minutes since its last push, never a rebuild.
|
|
727
|
+
const pushedByTicket = new Map()
|
|
728
|
+
for (const b of pre.pushedBranches ?? []) {
|
|
729
|
+
const m = /^(?:refs\/heads\/)?ralph\/(\d+)-/.exec(b.branch ?? '')
|
|
730
|
+
if (m) pushedByTicket.set(Number(m[1]), { branch: b.branch.replace(/^refs\/heads\//, ''), sha: b.sha })
|
|
731
|
+
}
|
|
473
732
|
|
|
474
733
|
phase('Graph')
|
|
475
734
|
log(
|
|
476
|
-
`Base green at ${pre.headSha ?? pre.base} on ${pre.base}. ` +
|
|
735
|
+
`Base green at ${pre.headSha ?? pre.base} on ${pre.base}${pre.skippedGate ? ' (known green — gate skipped)' : ''}. ` +
|
|
477
736
|
(POLICY.target
|
|
478
737
|
? `Consolidating onto ${pre.base}${pre.targetCreated ? ' (created from the default branch tip)' : ''}; ${pre.defaultBranch || 'the default branch'} stays untouched. `
|
|
479
|
-
: `Trunk mode — each ticket
|
|
738
|
+
: `Trunk mode — each ticket lands on ${pre.base}. `) +
|
|
480
739
|
(POLICY.only.length > 0
|
|
481
740
|
? `Scoped to ${POLICY.only.map((n) => `#${n}`).join(', ')}.`
|
|
482
741
|
: 'No scope — building the whole ready frontier.') +
|
|
483
|
-
(POLICY.max > 0 ? ` Stopping after ${POLICY.max} verified
|
|
484
|
-
` Width ${POLICY.width}${POLICY.canary ? ' (canary: width 1 until the first verified
|
|
742
|
+
(POLICY.max > 0 ? ` Stopping after ${POLICY.max} verified land(s).` : '') +
|
|
743
|
+
` Width ${POLICY.width}${POLICY.canary ? ' (canary: width 1 until the first verified land)' : ''}, ${POLICY.attempts} attempts per ticket, full gate every ${POLICY.checkpointEvery || 'release-only'} land(s).` +
|
|
744
|
+
(pushedByTicket.size > 0 ? ` Adopting pushed branches for ${[...pushedByTicket.keys()].map((n) => `#${n}`).join(', ')}.` : ''),
|
|
485
745
|
)
|
|
486
746
|
let graph = await agent(graphPrompt(pre), { label: 'read-graph', phase: 'Graph', schema: GRAPH_SCHEMA, model: 'haiku', effort: 'low' })
|
|
487
747
|
if (!graph) throw new Error('graph agent died — refusing to start')
|
|
@@ -496,98 +756,196 @@ for (const t of tickets) {
|
|
|
496
756
|
}
|
|
497
757
|
}
|
|
498
758
|
|
|
499
|
-
|
|
759
|
+
function settled(n) {
|
|
760
|
+
if (closedBefore.has(n)) return true
|
|
761
|
+
const s = state.get(n)
|
|
762
|
+
return s !== undefined && (s.status === 'landed' || s.status === 'done-before' || s.status === 'external')
|
|
763
|
+
}
|
|
500
764
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
765
|
+
// Transitive dependents: the tickets that cannot start until this one lands. The frontier
|
|
766
|
+
// dispatches the most-depended-on first, so a wide graph unblocks quickly — pure code.
|
|
767
|
+
function dependents(n) {
|
|
768
|
+
const seen = new Set()
|
|
769
|
+
const queue = [n]
|
|
770
|
+
while (queue.length > 0) {
|
|
771
|
+
const x = queue.pop()
|
|
772
|
+
for (const t of tickets) {
|
|
773
|
+
if (t.blockedBy.includes(x) && !seen.has(t.number)) {
|
|
774
|
+
seen.add(t.number)
|
|
775
|
+
queue.push(t.number)
|
|
776
|
+
}
|
|
777
|
+
}
|
|
507
778
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
779
|
+
return seen.size
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const inFlight = new Map() // number -> promise of a drive() result
|
|
783
|
+
|
|
784
|
+
// A ticket is ready when it isn't settled, isn't in flight or waiting on a state change,
|
|
785
|
+
// hasn't exhausted its attempts, every blocker is closed-before-the-run or landed-by-us,
|
|
786
|
+
// and no blocker is parked or held. Pure code — the orchestrator never asks a subagent
|
|
787
|
+
// what's ready. The implementer's dependency gate is the backstop for what this check
|
|
788
|
+
// cannot see (a blocker that is open but never entered the ready set).
|
|
789
|
+
function frontier() {
|
|
790
|
+
return tickets
|
|
791
|
+
.filter((t) => {
|
|
792
|
+
const s = entry(t)
|
|
793
|
+
if (s.status !== 'pending' || s.waiting || inFlight.has(t.number)) return false
|
|
794
|
+
if (s.attempts >= POLICY.attempts) return false
|
|
795
|
+
if (POLICY.only.length > 0 && !POLICY.only.includes(t.number)) return false
|
|
796
|
+
return t.blockedBy.every(settled)
|
|
797
|
+
})
|
|
798
|
+
.sort((a, b) => dependents(b.number) - dependents(a.number) || a.number - b.number)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function unsettledRemain() {
|
|
802
|
+
return tickets.some((t) => {
|
|
803
|
+
const s = entry(t)
|
|
804
|
+
return s.status === 'pending' && s.attempts < POLICY.attempts && (POLICY.only.length === 0 || POLICY.only.includes(t.number))
|
|
805
|
+
})
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function refreshGraph(reason) {
|
|
809
|
+
graph = await agent(graphPrompt(pre), { label: `read-graph:${reason}`, phase: 'Graph', schema: GRAPH_SCHEMA, model: 'haiku', effort: 'low' })
|
|
810
|
+
if (!graph) return
|
|
811
|
+
warnNotTickets(graph)
|
|
812
|
+
const fresh = parseGraph(graph)
|
|
813
|
+
for (const t of fresh) {
|
|
814
|
+
if (!tickets.some((x) => x.number === t.number)) tickets.push(t)
|
|
516
815
|
}
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const landed = results.filter((r) => r?.ok)
|
|
527
|
-
for (const r of results.filter((x) => x?.deferred)) {
|
|
528
|
-
log(`#${r.ticket.number} deferred (blocker not landed yet): ${r.why}`)
|
|
816
|
+
for (const t of tickets) {
|
|
817
|
+
const still = fresh.some((x) => x.number === t.number)
|
|
818
|
+
const s = state.get(t.number)
|
|
819
|
+
// Ticket left the ready set without us touching it (closed or re-labeled
|
|
820
|
+
// externally): treat it as settled for blockers and never dispatch it.
|
|
821
|
+
if (!still && (!s || (s.status === 'pending' && s.attempts === 0 && !inFlight.has(t.number)))) {
|
|
822
|
+
entry(t).status = 'external'
|
|
823
|
+
closedBefore.add(t.number)
|
|
824
|
+
}
|
|
529
825
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
826
|
+
for (const s of state.values()) s.waiting = false
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// The work pool: keep `width` builders busy, land each as it finishes, dispatch the next.
|
|
830
|
+
let stopReason = ''
|
|
831
|
+
let maxReached = false
|
|
832
|
+
let deadStreak = 0
|
|
833
|
+
let refreshedAt = -1
|
|
834
|
+
for (;;) {
|
|
835
|
+
const budgetLow = Boolean(budget.total) && budget.remaining() < POLICY.reserve
|
|
836
|
+
// Canary: the first verified land proves the plumbing end to end (branch, push, land,
|
|
837
|
+
// gate, explicit close); until it does, one ticket at a time.
|
|
838
|
+
const width = POLICY.canary && landedCount === 0 ? 1 : POLICY.width
|
|
839
|
+
const migrationInFlight = () => [...inFlight.keys()].some((n) => tickets.find((t) => t.number === n)?.migration)
|
|
840
|
+
while (!baseRed && !budgetLow && inFlight.size < width && builds < POLICY.maxBuilds) {
|
|
841
|
+
const ready = frontier()
|
|
842
|
+
// Migration-adding tickets collide on the next migration number when built in
|
|
843
|
+
// parallel — the loop keeps one such ticket in flight at a time.
|
|
844
|
+
const next = ready.find((t) => !t.migration || !migrationInFlight())
|
|
845
|
+
if (!next) break
|
|
846
|
+
// The cap counts verified lands; in-flight tickets reserve their share so even a
|
|
847
|
+
// fully successful pool cannot overshoot.
|
|
848
|
+
if (POLICY.max > 0 && landedCount + inFlight.size >= POLICY.max) break
|
|
849
|
+
const pushed = pushedByTicket.get(next.number) ?? null
|
|
850
|
+
log(`dispatch #${next.number}${pushed ? ` (adopting ${pushed.branch})` : ''} — ${inFlight.size + 1} in flight, ${ready.length - 1} more ready`)
|
|
851
|
+
inFlight.set(
|
|
852
|
+
next.number,
|
|
853
|
+
drive(pre, next, pushed)
|
|
854
|
+
.catch((err) => ({ ticket: next, ok: false, dead: true, error: String(err) }))
|
|
855
|
+
.then((r) => ({ ...r, number: next.number })),
|
|
856
|
+
)
|
|
533
857
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
858
|
+
if (inFlight.size === 0) {
|
|
859
|
+
if (baseRed) {
|
|
860
|
+
stopReason = 'base red'
|
|
861
|
+
break
|
|
862
|
+
}
|
|
863
|
+
if (budgetLow) {
|
|
864
|
+
stopReason = `token budget at reserve (${Math.round(budget.remaining() / 1000)}k left)`
|
|
865
|
+
break
|
|
866
|
+
}
|
|
867
|
+
if (builds >= POLICY.maxBuilds) {
|
|
868
|
+
stopReason = `maxBuilds (${POLICY.maxBuilds}) reached`
|
|
869
|
+
break
|
|
870
|
+
}
|
|
871
|
+
if (POLICY.max > 0 && landedCount >= POLICY.max) {
|
|
872
|
+
maxReached = true
|
|
873
|
+
stopReason = `cap reached: ${POLICY.max} verified land(s) — the rest of the frontier stays ready`
|
|
874
|
+
break
|
|
875
|
+
}
|
|
876
|
+
// Nothing running and nothing ready: a tracker refresh is the only thing that can
|
|
877
|
+
// change that (an externally closed blocker), and it is worth one look per change.
|
|
878
|
+
if (POLICY.refreshGraph && unsettledRemain() && refreshedAt !== landedCount) {
|
|
879
|
+
refreshedAt = landedCount
|
|
880
|
+
await refreshGraph(`l${landedCount}`)
|
|
881
|
+
continue
|
|
882
|
+
}
|
|
883
|
+
stopReason = unsettledRemain() ? 'frontier stuck — remaining tickets wait on parked, held, or open blockers' : 'frontier drained'
|
|
884
|
+
break
|
|
537
885
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
for (const t of tickets) {
|
|
548
|
-
const still = fresh.some((x) => x.number === t.number)
|
|
549
|
-
const s = state.get(t.number)
|
|
550
|
-
// Ticket left the ready set without us touching it (closed or re-labeled
|
|
551
|
-
// externally): treat it as settled for blockers and never dispatch it.
|
|
552
|
-
if (!still && (!s || (s.status === 'pending' && s.attempts === 0))) {
|
|
553
|
-
entry(t).status = 'external'
|
|
554
|
-
closedBefore.add(t.number)
|
|
555
|
-
}
|
|
556
|
-
}
|
|
886
|
+
const done = await Promise.race([...inFlight.values()])
|
|
887
|
+
inFlight.delete(done.number)
|
|
888
|
+
const s = entry(done.ticket)
|
|
889
|
+
if (done.dead) {
|
|
890
|
+
deadStreak += 1
|
|
891
|
+
log(`#${done.number}: agent died (infrastructure)${done.error ? ` — ${done.error}` : ''}`)
|
|
892
|
+
if (deadStreak >= 3) {
|
|
893
|
+
stopReason = 'three agents in a row died — infrastructure, not tickets'
|
|
894
|
+
break
|
|
557
895
|
}
|
|
896
|
+
} else {
|
|
897
|
+
deadStreak = 0
|
|
898
|
+
}
|
|
899
|
+
if (done.ok) {
|
|
900
|
+
landedCount += 1
|
|
901
|
+
log(`#${done.number} landed on ${pre.base} at ${s.mergeCommit} (verified) — ${landedCount} landed`)
|
|
902
|
+
for (const other of state.values()) other.waiting = false // a land is the state change deferred tickets wait for
|
|
903
|
+
} else if (done.doneBefore) {
|
|
904
|
+
log(`#${done.number} was already closed — settled without a land`)
|
|
905
|
+
for (const other of state.values()) other.waiting = false
|
|
906
|
+
} else if (done.deferred) {
|
|
907
|
+
s.waiting = true
|
|
908
|
+
log(`#${done.number} deferred (blocker not landed yet): ${done.why}`)
|
|
909
|
+
} else if (done.held) {
|
|
910
|
+
log(`#${done.number} built and pushed on ${s.branch} but NOT landed — base red; a relaunch adopts it`)
|
|
911
|
+
}
|
|
912
|
+
if (s.status === 'pending' && s.attempts >= POLICY.attempts) {
|
|
913
|
+
s.status = 'parked'
|
|
914
|
+
log(`#${done.number} parked after ${s.attempts} attempts: ${s.failures[s.failures.length - 1]}`)
|
|
558
915
|
}
|
|
559
916
|
}
|
|
917
|
+
log(`stopping: ${stopReason}`)
|
|
560
918
|
|
|
561
|
-
const
|
|
919
|
+
const landed = [...state.values()].filter((s) => s.status === 'landed')
|
|
562
920
|
const parked = [...state.values()].filter((s) => s.status === 'parked')
|
|
921
|
+
const held = [...state.values()].filter((s) => s.status === 'held')
|
|
563
922
|
const stuck = tickets.filter((t) => {
|
|
564
923
|
const s = state.get(t.number)
|
|
565
|
-
return !s || s.status === 'pending'
|
|
924
|
+
return (!s || s.status === 'pending') && (POLICY.only.length === 0 || POLICY.only.includes(t.number))
|
|
566
925
|
})
|
|
567
|
-
const settledNumber = (b) => closedBefore.has(b) || state.get(b)?.status === 'merged'
|
|
568
926
|
|
|
569
927
|
phase('Park')
|
|
570
928
|
if (parked.length > 0) {
|
|
571
929
|
await agent(
|
|
572
|
-
`On the tracker (${pre.trackerAccess}), for each of these parked tickets: post one comment containing its accumulated failure history verbatim, remove the ralph:building label if present, and add the needs-info label. Change nothing else. Fix nothing.
|
|
573
|
-
${parked.map((s) => `#${s.ticket.number} (${s.ticket.title}): ${s.failures.join(' | ')}`).join('\n')}`,
|
|
930
|
+
`On the tracker (${pre.trackerAccess}), for each of these parked tickets: post one comment containing its accumulated failure history verbatim (and the pushed branch that holds its work, when there is one), remove the ralph:building label if present, and add the needs-info label. Change nothing else. Fix nothing.
|
|
931
|
+
${parked.map((s) => `#${s.ticket.number} (${s.ticket.title})${s.branch ? ` — branch ${s.branch}` : ''}: ${s.failures.join(' | ')}`).join('\n')}`,
|
|
574
932
|
{ label: 'park', phase: 'Park', schema: { type: 'object', properties: { done: { type: 'boolean' } }, required: ['done'], additionalProperties: false }, model: 'haiku', effort: 'low' },
|
|
575
933
|
)
|
|
576
934
|
}
|
|
577
935
|
|
|
578
936
|
// The completion contract: the loop cannot declare success while required
|
|
579
|
-
// verification fails on the final, post-
|
|
937
|
+
// verification fails on the final, post-land base. The last green checkpoint counts
|
|
938
|
+
// when the base has not moved since; otherwise the full gate runs once more here.
|
|
580
939
|
phase('Release')
|
|
581
940
|
const release = await agent(
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
}
|
|
590
|
-
verified means: the verification gate exited 0${pre.browserTesting && merged.length > 0 ? ' AND no smoke journey failed' : ''}.`,
|
|
941
|
+
releasePrompt(pre, {
|
|
942
|
+
baseRed,
|
|
943
|
+
baseRedReason,
|
|
944
|
+
needsGate: baseTip !== lastGreenSha,
|
|
945
|
+
greenSha: lastGreenSha,
|
|
946
|
+
smoke: Boolean(pre.browserTesting) && landed.length > 0,
|
|
947
|
+
prune: landed.map((s) => s.branch).filter(Boolean),
|
|
948
|
+
}),
|
|
591
949
|
{ label: 'release-verification', phase: 'Release', schema: RELEASE_SCHEMA },
|
|
592
950
|
)
|
|
593
951
|
|
|
@@ -595,21 +953,30 @@ verified means: the verification gate exited 0${pre.browserTesting && merged.len
|
|
|
595
953
|
// next step, as data — the supervisor relays it, never reconstructs it.
|
|
596
954
|
const mode = POLICY.target ? 'consolidation' : 'trunk'
|
|
597
955
|
return {
|
|
598
|
-
|
|
599
|
-
|
|
956
|
+
builds,
|
|
957
|
+
stopReason,
|
|
958
|
+
verified: !baseRed && (release?.verified ?? false),
|
|
600
959
|
maxReached,
|
|
601
|
-
target: { mode, base: pre.base, defaultBranch: pre.defaultBranch ?? '', headSha: release?.headSha ??
|
|
960
|
+
target: { mode, base: pre.base, defaultBranch: pre.defaultBranch ?? '', headSha: release?.headSha ?? baseTip },
|
|
961
|
+
baseRed: baseRed ? baseRedReason : null,
|
|
962
|
+
checkpoints,
|
|
602
963
|
nextStep:
|
|
603
|
-
mode === 'consolidation'
|
|
604
|
-
? `All campaign work is on ${pre.base}; ${pre.defaultBranch || 'the default branch'} is untouched. Release it with one PR ${pre.base} -> ${pre.defaultBranch || 'the default branch'} — offer it, and open it only when the user says so.`
|
|
605
|
-
: `Every
|
|
964
|
+
(mode === 'consolidation'
|
|
965
|
+
? `All campaign work is on ${pre.base}; ${pre.defaultBranch || 'the default branch'} is untouched. Release it with one PR ${pre.base} -> ${pre.defaultBranch || 'the default branch'} — cloud CI runs there, once — offer it, and open it only when the user says so.`
|
|
966
|
+
: `Every landed ticket is live on ${pre.base}; nothing is left to integrate.`) +
|
|
967
|
+
(baseRed
|
|
968
|
+
? ` The base is RED (${baseRedReason}): fix it by hand, then relaunch the loop with knownGreen set to the sha you verified — it adopts the held branches.`
|
|
969
|
+
: held.length > 0
|
|
970
|
+
? ' Relaunch the loop (knownGreen: the last green sha) to land the held tickets.'
|
|
971
|
+
: ''),
|
|
606
972
|
release,
|
|
607
|
-
|
|
608
|
-
|
|
973
|
+
landed: landed.map((s) => ({ ticket: s.ticket.number, title: s.ticket.title, mergeCommit: s.mergeCommit, branch: s.branch })),
|
|
974
|
+
held: held.map((s) => ({ ticket: s.ticket.number, title: s.ticket.title, branch: s.branch })),
|
|
975
|
+
parked: parked.map((s) => ({ ticket: s.ticket.number, title: s.ticket.title, branch: s.branch, failures: s.failures })),
|
|
609
976
|
stuck: stuck.map((t) => ({
|
|
610
977
|
ticket: t.number,
|
|
611
978
|
title: t.title,
|
|
612
|
-
blockedBy: t.blockedBy.filter((b) => !
|
|
979
|
+
blockedBy: t.blockedBy.filter((b) => !settled(b)),
|
|
613
980
|
})),
|
|
614
981
|
followUps: [...state.values()].flatMap((s) => s.punted),
|
|
615
982
|
}
|