@gr8ful/spf 0.5.1 → 0.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/README.md +168 -29
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +42 -6
- package/assets/prompts/refiner/user.md +46 -8
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +192 -5
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +12 -2
- package/dist/chains/index.d.ts +11 -0
- package/dist/chains/index.js +38 -3
- package/dist/chains/repo_chains.js +1 -0
- package/dist/chains/simple_sdlc.js +1 -1
- package/dist/chains/steps.d.ts +8 -17
- package/dist/chains/steps.js +102 -14
- package/dist/cli/commands/doctor.js +112 -3
- package/dist/cli/commands/estimate.d.ts +82 -0
- package/dist/cli/commands/estimate.js +317 -0
- package/dist/cli/commands/fanout.d.ts +40 -0
- package/dist/cli/commands/fanout.js +401 -0
- package/dist/cli/commands/init.js +23 -1
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +95 -33
- package/dist/cli/index.js +13 -1
- package/dist/cli/interview.js +9 -5
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +340 -5
- package/dist/core/data_types.js +185 -5
- package/dist/core/fanout.d.ts +229 -0
- package/dist/core/fanout.js +313 -0
- package/dist/core/gates.d.ts +8 -0
- package/dist/core/gates.js +48 -2
- package/dist/core/git_helper.d.ts +98 -0
- package/dist/core/git_helper.js +127 -0
- package/dist/core/issues/github_provider.d.ts +46 -6
- package/dist/core/issues/github_provider.js +120 -5
- package/dist/core/issues/jira_provider.d.ts +83 -12
- package/dist/core/issues/jira_provider.js +110 -4
- package/dist/core/issues/provider.d.ts +124 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +16 -0
- package/dist/core/notify/notifier.js +36 -0
- package/dist/core/otel.d.ts +64 -9
- package/dist/core/otel.js +78 -14
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +7 -0
- package/dist/core/session.d.ts +25 -0
- package/dist/core/session.js +97 -28
- package/dist/core/tiering.d.ts +145 -0
- package/dist/core/tiering.js +235 -0
- package/dist/core/tracer.d.ts +10 -0
- package/dist/core/tracer.js +12 -0
- package/dist/core/watch.d.ts +174 -11
- package/dist/core/watch.js +531 -33
- package/dist/ui/server/db.d.ts +39 -0
- package/dist/ui/server/db.js +61 -0
- package/package.json +2 -1
- package/dist/test/agent_cc.test.d.ts +0 -1
- package/dist/test/agent_cc.test.js +0 -95
- package/dist/test/agent_flue.test.d.ts +0 -1
- package/dist/test/agent_flue.test.js +0 -83
- package/dist/test/chains.test.d.ts +0 -12
- package/dist/test/chains.test.js +0 -92
- package/dist/test/data_types.test.d.ts +0 -10
- package/dist/test/data_types.test.js +0 -220
- package/dist/test/env_file.test.d.ts +0 -1
- package/dist/test/env_file.test.js +0 -74
- package/dist/test/fake_asker.d.ts +0 -23
- package/dist/test/fake_asker.js +0 -30
- package/dist/test/git_helper.test.d.ts +0 -1
- package/dist/test/git_helper.test.js +0 -59
- package/dist/test/hermetic_git.d.ts +0 -1
- package/dist/test/hermetic_git.js +0 -22
- package/dist/test/init_command.test.d.ts +0 -14
- package/dist/test/init_command.test.js +0 -136
- package/dist/test/interview.test.d.ts +0 -15
- package/dist/test/interview.test.js +0 -425
- package/dist/test/notify.test.d.ts +0 -1
- package/dist/test/notify.test.js +0 -174
- package/dist/test/ollama_provider.test.d.ts +0 -1
- package/dist/test/ollama_provider.test.js +0 -103
- package/dist/test/otel.test.d.ts +0 -26
- package/dist/test/otel.test.js +0 -512
- package/dist/test/paths.test.d.ts +0 -1
- package/dist/test/paths.test.js +0 -68
- package/dist/test/refine.test.d.ts +0 -1
- package/dist/test/refine.test.js +0 -189
- package/dist/test/repo_chains.test.d.ts +0 -21
- package/dist/test/repo_chains.test.js +0 -416
- package/dist/test/signoff.test.d.ts +0 -1
- package/dist/test/signoff.test.js +0 -329
- package/dist/test/ui_server.test.d.ts +0 -7
- package/dist/test/ui_server.test.js +0 -120
- package/dist/test/watch.test.d.ts +0 -1
- package/dist/test/watch.test.js +0 -687
package/dist/core/watch.js
CHANGED
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
* tree -> publish those as real issues -> done/blocked. A spec is not
|
|
8
8
|
* individually workable, so this lane never opens a PR — it hands the build
|
|
9
9
|
* lane its next batch of `ready`-able work instead (see `core/refine.ts`).
|
|
10
|
+
* When the refiner raises material ambiguity instead of a tree, this lane
|
|
11
|
+
* loops through a human instead of guessing or blocking: `escalateSpec`
|
|
12
|
+
* posts the questions and moves the spec to `needs-feedback`; a human
|
|
13
|
+
* answers in the issue's comments and adds `continue-refinement`;
|
|
14
|
+
* `claimSpecs` resumes it — the SAME `adw_id`, comment thread folded into
|
|
15
|
+
* the prompt (`buildSpecPrompt`) — for as many rounds as it takes. See
|
|
16
|
+
* `provider.ts`'s `WatchState` doc comment for the full state diagram.
|
|
10
17
|
*
|
|
11
18
|
* Provider-agnostic (drives whatever `IssueProvider` it's given) and
|
|
12
19
|
* chain-agnostic (drives whatever `runChain`/`runRefine` callback it's
|
|
@@ -34,11 +41,27 @@
|
|
|
34
41
|
* - Worktree-per-issue, isolated outside the repo, made cheap by SPF's
|
|
35
42
|
* own `--cwd` support: no new chain-dispatch plumbing needed, just
|
|
36
43
|
* pointing an existing chain at a different working tree.
|
|
44
|
+
*
|
|
45
|
+
* What's NEW since the lean v1 above: `claimNewWork` no longer walks
|
|
46
|
+
* `listEligible()` in whatever order the tracker happened to return —
|
|
47
|
+
* `orderEligible` sorts by priority (a `<prefix>:priority:pN` label), then
|
|
48
|
+
* sibling affinity (prefer a leaf whose parent already has work in flight),
|
|
49
|
+
* then creation order; and it refuses to claim a leaf whose `blocked_by`
|
|
50
|
+
* isn't fully `<prefix>:done` yet (`frontierBlockedOn`) — making real, at
|
|
51
|
+
* last, the frontier `assets/prompts/refiner/system.md` has always promised
|
|
52
|
+
* the refiner. `finishReviews` also now closes a landed leaf and rolls a
|
|
53
|
+
* container up to `done` + closed once every child under it carries
|
|
54
|
+
* `<prefix>:done` (`rollUp`) — still no Projects v2 mirroring, no CI-fix
|
|
55
|
+
* retry loop, no auto-merge; those remain deliberately out of scope.
|
|
37
56
|
*/
|
|
38
57
|
import path from "node:path";
|
|
58
|
+
import { PRIORITY_RANK } from "./data_types.js";
|
|
59
|
+
import { parseRefineMarker } from "./refine.js";
|
|
39
60
|
const MAX_ORPHAN_ATTEMPTS = 2;
|
|
61
|
+
/** GitHub's own documented sub-issue nesting cap (see `github_provider.ts`'s `linkChild` doc comment) — `rollUp`'s own recursion bound, so a malformed/cyclic hierarchy can't spin forever. */
|
|
62
|
+
const MAX_ROLLUP_DEPTH = 8;
|
|
40
63
|
export function createWatchState() {
|
|
41
|
-
return { inflight: new Set(), refining: new Set() };
|
|
64
|
+
return { inflight: new Set(), refining: new Set(), inflightParents: new Map() };
|
|
42
65
|
}
|
|
43
66
|
function slugifyTitle(title) {
|
|
44
67
|
return (title
|
|
@@ -124,20 +147,30 @@ export async function reconcileOrphans(deps, state) {
|
|
|
124
147
|
}
|
|
125
148
|
}
|
|
126
149
|
/**
|
|
127
|
-
* Post the summary comment on a decomposed spec and
|
|
128
|
-
* —
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
150
|
+
* Post the summary comment on a decomposed spec and move it to
|
|
151
|
+
* `spec-in-progress` — deliberately NOT `done`: a product manager watching
|
|
152
|
+
* this spec's status must not see "done" until every issue the refiner
|
|
153
|
+
* produced is itself `<prefix>:done` (`finishTrackedSpecs`, below, is what
|
|
154
|
+
* makes THAT move, once it's actually true). Reached from both the normal
|
|
155
|
+
* path (`runSpec`, right after a successful publish) and the orphan-resume
|
|
156
|
+
* path (`reconcileRefining`, when a completed publish's marker survived a
|
|
157
|
+
* crash the `transition` itself didn't). `created` only has titles/kinds in
|
|
158
|
+
* the normal path — an orphan resume has nothing but the ids
|
|
159
|
+
* `WatchMarker.refined` recorded, and the comment degrades to a bare list of
|
|
160
|
+
* `#id`s rather than blocking on a re-fetch.
|
|
161
|
+
*
|
|
162
|
+
* One exception: a decomposition that produced ZERO issues (shouldn't
|
|
163
|
+
* happen — `gates.refinementWellFormed` requires at least one leaf, but this
|
|
164
|
+
* stays defensive rather than assumed) has nothing for `finishTrackedSpecs`
|
|
165
|
+
* to ever wait on, so it goes straight to `done` instead of `spec-in-progress`.
|
|
135
166
|
*/
|
|
136
|
-
async function
|
|
167
|
+
async function announceRefined(deps, issue, created, rounds = 0) {
|
|
168
|
+
const roundsNote = rounds > 0 ? ` (after ${rounds} round${rounds === 1 ? "" : "s"} of feedback)` : "";
|
|
137
169
|
const body = created.length > 0
|
|
138
|
-
? `spf watch refined this spec into ${created.length} issue(s):\n\n` +
|
|
170
|
+
? `spf watch refined this spec into ${created.length} issue(s)${roundsNote}:\n\n` +
|
|
139
171
|
created.map((c) => (c.title ? `- #${c.id} (${c.kind}): ${c.title}` : `- #${c.id}`)).join("\n") +
|
|
140
|
-
`\n\nPromote any of them to \`${deps.labelPrefix}:ready\` when it's worth building
|
|
172
|
+
`\n\nPromote any of them to \`${deps.labelPrefix}:ready\` when it's worth building. ` +
|
|
173
|
+
`This spec moves to \`${deps.labelPrefix}:done\` once every one of them does.`
|
|
141
174
|
: `spf watch refined this spec but the refiner produced no issues.`;
|
|
142
175
|
deps.notify({
|
|
143
176
|
kind: "spec_refined",
|
|
@@ -148,15 +181,205 @@ async function finishSpec(deps, issue, created) {
|
|
|
148
181
|
});
|
|
149
182
|
if (!deps.dryRun) {
|
|
150
183
|
await deps.provider.comment(issue, body);
|
|
151
|
-
|
|
184
|
+
if (created.length === 0) {
|
|
185
|
+
await deps.provider.transition(issue, "done");
|
|
186
|
+
// Best-effort and never fatal, same reasoning as finishTrackedSpecs'
|
|
187
|
+
// own closeIssue call below: the spec is already `done` by the time
|
|
188
|
+
// this runs, so a tracker that can't close must not turn a
|
|
189
|
+
// successfully refined spec into `blocked`.
|
|
190
|
+
await deps.provider.closeIssue?.(issue).catch((error) => {
|
|
191
|
+
deps.log(`watch: spec ${issue.id}: closeIssue failed — left open, still \`${deps.labelPrefix}:done\`: ${error.message}`);
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
await deps.provider.transition(issue, "spec-in-progress");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Poll every `spec-in-progress` spec: once every id `WatchMarker.refined`
|
|
200
|
+
* recorded — every issue the refiner produced, leaf or container — carries
|
|
201
|
+
* `<prefix>:done`, the spec's own decomposed work is actually finished, and
|
|
202
|
+
* only then does this move it `-> done`. This is the whole point of
|
|
203
|
+
* `announceRefined` landing on `spec-in-progress` rather than `done`
|
|
204
|
+
* straight away: the spec's status is what a product manager reads to know
|
|
205
|
+
* whether the work is finished, and "done" the instant a tree gets PUBLISHED
|
|
206
|
+
* would be a lie — the work hasn't started yet, let alone finished.
|
|
207
|
+
*
|
|
208
|
+
* A container in the refined list is done exactly when `rollUp` (see
|
|
209
|
+
* `finishReviews`) has already rolled it up — by the time every id here is
|
|
210
|
+
* `<prefix>:done`, every leaf beneath every container is too, transitively,
|
|
211
|
+
* with no need to walk the hierarchy again from this side.
|
|
212
|
+
*
|
|
213
|
+
* A referenced id that 404s (deleted from the tracker) is treated as
|
|
214
|
+
* satisfied — same policy as `frontierBlockedOn`'s blockers: a removed issue
|
|
215
|
+
* must not wedge the spec's completion forever. A spec with no marker, or an
|
|
216
|
+
* empty `refined` list, is left alone with a log line rather than assumed
|
|
217
|
+
* done — data that shouldn't exist given the gate's at-least-one-leaf rule,
|
|
218
|
+
* but never silently marked complete on that assumption.
|
|
219
|
+
*/
|
|
220
|
+
export async function finishTrackedSpecs(deps) {
|
|
221
|
+
const tracked = await deps.provider.listInState("spec-in-progress");
|
|
222
|
+
const doneLabel = `${deps.labelPrefix}:done`;
|
|
223
|
+
for (const issue of tracked) {
|
|
224
|
+
const marker = await deps.provider.readMarker(issue);
|
|
225
|
+
const refined = marker?.refined ?? [];
|
|
226
|
+
if (refined.length === 0) {
|
|
227
|
+
deps.log(`watch: spec ${issue.id} is spec-in-progress with no refined issues recorded — leaving it alone`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const finished = [];
|
|
231
|
+
let allDone = true;
|
|
232
|
+
for (const id of refined) {
|
|
233
|
+
const child = await deps.provider.getIssue(id);
|
|
234
|
+
if (!child) {
|
|
235
|
+
deps.log(`watch: spec ${issue.id}: refined issue #${id} no longer exists — treating it as done rather than wedging the spec forever`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!child.labels.includes(doneLabel)) {
|
|
239
|
+
allDone = false;
|
|
240
|
+
break; // one unfinished issue is enough to know — no need to check the rest this tick
|
|
241
|
+
}
|
|
242
|
+
finished.push(child);
|
|
243
|
+
}
|
|
244
|
+
if (!allDone) {
|
|
245
|
+
deps.log(`watch: spec ${issue.id}: still waiting on work — not every refined issue is \`${doneLabel}\` yet`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
deps.log(`watch: spec ${issue.id}: every refined issue is \`${doneLabel}\` — closing`);
|
|
249
|
+
deps.notify({
|
|
250
|
+
kind: "spec_done",
|
|
251
|
+
level: "info",
|
|
252
|
+
title: `spec ${issue.id} done`,
|
|
253
|
+
detail: `${refined.length} issue(s) all landed.`,
|
|
254
|
+
fields: [["issue", issue.id], ["title", issue.title], ["refined", String(refined.length)]],
|
|
255
|
+
});
|
|
256
|
+
if (!deps.dryRun) {
|
|
257
|
+
const body = `spf watch: every issue decomposed from this spec is now \`${doneLabel}\`:\n\n` +
|
|
258
|
+
finished.map((c) => `- #${c.id}: ${c.title}`).join("\n");
|
|
259
|
+
await deps.provider.comment(issue, body);
|
|
260
|
+
await deps.provider.transition(issue, "done");
|
|
261
|
+
await deps.provider.closeIssue?.(issue).catch((error) => {
|
|
262
|
+
deps.log(`watch: spec ${issue.id}: closeIssue failed — left open, still \`${doneLabel}\`: ${error.message}`);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const MAX_THREAD_CHARS = 20_000;
|
|
268
|
+
/**
|
|
269
|
+
* Render the spec issue's comment thread for the refiner's prompt, replacing
|
|
270
|
+
* the previous bare `title\n\nbody` — the whole reason a human's answer
|
|
271
|
+
* could never reach a resumed run before this feature. When `feedback` names
|
|
272
|
+
* the timestamp of the most recent question comment, everything at or after
|
|
273
|
+
* it is surfaced as "answers to your open questions" (what a resumed run
|
|
274
|
+
* most needs to read); everything earlier is "earlier discussion" — context,
|
|
275
|
+
* not necessarily an answer. A spec that has never been escalated (no
|
|
276
|
+
* `feedback`) has no such split: any pre-existing comments are all "earlier
|
|
277
|
+
* discussion".
|
|
278
|
+
*
|
|
279
|
+
* Truncates the RENDERED thread, not the comment list, to roughly
|
|
280
|
+
* `MAX_THREAD_CHARS`, dropping the oldest comments first — an explicit
|
|
281
|
+
* "N earlier comment(s) omitted" line, never a silent truncation.
|
|
282
|
+
*
|
|
283
|
+
* `priority` — the spec's own `<prefix>:priority:pN` label, read by
|
|
284
|
+
* `runSpec` below — renders as its own `## Priority` section right after the
|
|
285
|
+
* header, present or absent independent of whether there's any comment
|
|
286
|
+
* thread at all: a spec with no priority label (the common case today) omits
|
|
287
|
+
* the section entirely, exactly the prompt this function produced before
|
|
288
|
+
* priority existed. `core/refine.ts`'s `publish()` is what actually ENFORCES
|
|
289
|
+
* the ceiling this section only asks for — see its own doc comment.
|
|
290
|
+
*
|
|
291
|
+
* Exported and pure (no provider, no I/O) so it's directly unit-testable.
|
|
292
|
+
*/
|
|
293
|
+
export function buildSpecPrompt(issue, comments, feedback, priority) {
|
|
294
|
+
const withoutPriority = `${issue.title}\n\n${issue.body}`.trim();
|
|
295
|
+
const header = priority
|
|
296
|
+
? `${withoutPriority}\n\n## Priority\n\nThis spec is labeled ${priority}. That's a CEILING for everything you produce: no node may be more urgent than ${priority} — less urgent is fine, more urgent is not.`
|
|
297
|
+
: withoutPriority;
|
|
298
|
+
if (comments.length === 0)
|
|
299
|
+
return header;
|
|
300
|
+
const render = (list) => list.map((c) => `**@${c.author}** (${c.created_at}):\n${c.body.trim()}`).join("\n\n");
|
|
301
|
+
let kept = comments;
|
|
302
|
+
let dropped = 0;
|
|
303
|
+
while (render(kept).length > MAX_THREAD_CHARS && kept.length > 1) {
|
|
304
|
+
kept = kept.slice(1);
|
|
305
|
+
dropped++;
|
|
306
|
+
}
|
|
307
|
+
const askedAt = feedback?.asked_at ? Date.parse(feedback.asked_at) : null;
|
|
308
|
+
const answers = askedAt !== null ? kept.filter((c) => Date.parse(c.created_at) >= askedAt) : [];
|
|
309
|
+
const earlier = askedAt !== null ? kept.filter((c) => Date.parse(c.created_at) < askedAt) : kept;
|
|
310
|
+
const parts = [];
|
|
311
|
+
if (dropped > 0)
|
|
312
|
+
parts.push(`_... ${dropped} earlier comment(s) omitted for length._`);
|
|
313
|
+
if (answers.length > 0)
|
|
314
|
+
parts.push(`### Answers to your open questions (round ${feedback.rounds})\n\n${render(answers)}`);
|
|
315
|
+
if (earlier.length > 0)
|
|
316
|
+
parts.push(`### Earlier discussion\n\n${render(earlier)}`);
|
|
317
|
+
if (parts.length === 0)
|
|
318
|
+
return header;
|
|
319
|
+
return `${header}\n\n## Discussion on the spec issue\n\n${parts.join("\n\n")}`;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* The refine lane's own finishing move for "the refiner can't proceed
|
|
323
|
+
* without a human" — the escalation twin of `announceRefined` above. Posts every
|
|
324
|
+
* question as its own rich comment section (the question, why it matters,
|
|
325
|
+
* the options considered, its recommendation, and the evidence it read — so
|
|
326
|
+
* a human can often answer in a word or two), records the round in the
|
|
327
|
+
* marker so a resumed run's prompt (`buildSpecPrompt`) can tell "answers"
|
|
328
|
+
* from "earlier discussion", and moves the spec to `needs-feedback` rather
|
|
329
|
+
* than `blocked` — the refiner correctly refusing to guess is a legitimate
|
|
330
|
+
* outcome, not a failure. `marker` is spread first so `worktree`/`branch`
|
|
331
|
+
* (just written by the caller) survive into the marker this writes.
|
|
332
|
+
*/
|
|
333
|
+
async function escalateSpec(deps, issue, marker, questions, adwId, round) {
|
|
334
|
+
const body = `## spf needs your input (round ${round})\n\n` +
|
|
335
|
+
questions
|
|
336
|
+
.map((q) => {
|
|
337
|
+
const lines = [`### ${q.question}`];
|
|
338
|
+
if (q.why_it_matters)
|
|
339
|
+
lines.push(`**Why it matters:** ${q.why_it_matters}`);
|
|
340
|
+
if (q.options.length > 0)
|
|
341
|
+
lines.push(`**Options considered:**\n${q.options.map((o) => `- ${o}`).join("\n")}`);
|
|
342
|
+
if (q.recommendation)
|
|
343
|
+
lines.push(`**Recommendation:** ${q.recommendation}`);
|
|
344
|
+
if (q.evidence.length > 0)
|
|
345
|
+
lines.push(`**Evidence:**\n${q.evidence.map((e) => `- ${e}`).join("\n")}`);
|
|
346
|
+
return lines.join("\n\n");
|
|
347
|
+
})
|
|
348
|
+
.join("\n\n---\n\n") +
|
|
349
|
+
`\n\n---\n\nAnswer inline, then add the \`${deps.labelPrefix}:continue-refinement\` label — refinement resumes from where it left off (adw_id \`${adwId}\`).`;
|
|
350
|
+
deps.notify({
|
|
351
|
+
// "error" level, not "info" — this is the same class of event as
|
|
352
|
+
// issue_blocked ("spf needs a human"), and it belongs on an `errors`-scope
|
|
353
|
+
// channel just as much as an `all`-scope one.
|
|
354
|
+
kind: "spec_needs_feedback",
|
|
355
|
+
level: "error",
|
|
356
|
+
title: `spec ${issue.id} needs feedback`,
|
|
357
|
+
detail: `${questions.length} question(s), round ${round}.`,
|
|
358
|
+
fields: [
|
|
359
|
+
["issue", issue.id],
|
|
360
|
+
["title", issue.title],
|
|
361
|
+
["chain", deps.refineChain],
|
|
362
|
+
["adw_id", adwId],
|
|
363
|
+
["round", String(round)],
|
|
364
|
+
],
|
|
365
|
+
});
|
|
366
|
+
if (!deps.dryRun) {
|
|
367
|
+
await deps.provider.comment(issue, body);
|
|
368
|
+
await deps.provider.writeMarker(issue, { ...marker, feedback: { rounds: round, asked_at: new Date().toISOString() } });
|
|
369
|
+
await deps.provider.transition(issue, "needs-feedback");
|
|
152
370
|
}
|
|
153
371
|
}
|
|
154
372
|
/**
|
|
155
373
|
* The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
|
|
156
|
-
* process isn't tracking is
|
|
157
|
-
* its own `transition(issue, "done")` ran (resume: finish it,
|
|
158
|
-
*
|
|
159
|
-
*
|
|
374
|
+
* process isn't tracking is one of three things: a completed publish that
|
|
375
|
+
* crashed before its own `transition(issue, "done")` ran (resume: finish it,
|
|
376
|
+
* no re-run), a completed escalation that crashed before its own
|
|
377
|
+
* `transition(issue, "needs-feedback")` ran (resume: finish THAT transition,
|
|
378
|
+
* no re-asking — `escalateSpec` writes the marker's `feedback` before it
|
|
379
|
+
* transitions, so seeing `feedback` on a still-`refining` spec can only mean
|
|
380
|
+
* that last step didn't complete), or a genuine orphan (retry up to
|
|
381
|
+
* `MAX_ORPHAN_ATTEMPTS`, then give up). A no-op entirely when `watch.refine`
|
|
382
|
+
* is off — see `WatchDeps.refineEnabled`.
|
|
160
383
|
*/
|
|
161
384
|
export async function reconcileRefining(deps, state) {
|
|
162
385
|
if (!deps.refineEnabled)
|
|
@@ -168,7 +391,20 @@ export async function reconcileRefining(deps, state) {
|
|
|
168
391
|
const marker = await deps.provider.readMarker(issue);
|
|
169
392
|
if (marker?.refined && marker.refined.length > 0) {
|
|
170
393
|
deps.log(`watch: spec ${issue.id} orphaned after publish already completed — finishing`);
|
|
171
|
-
await
|
|
394
|
+
await announceRefined(deps, issue, marker.refined.map((id) => ({ id })), marker.feedback?.rounds ?? 0);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (marker?.feedback) {
|
|
398
|
+
deps.log(`watch: spec ${issue.id} orphaned after asking round ${marker.feedback.rounds} — finishing the transition to needs-feedback`);
|
|
399
|
+
deps.notify({
|
|
400
|
+
kind: "spec_needs_feedback",
|
|
401
|
+
level: "error",
|
|
402
|
+
title: `spec ${issue.id} needs feedback`,
|
|
403
|
+
detail: `Round ${marker.feedback.rounds}.`,
|
|
404
|
+
fields: [["issue", issue.id], ["title", issue.title], ["round", String(marker.feedback.rounds)]],
|
|
405
|
+
});
|
|
406
|
+
if (!deps.dryRun)
|
|
407
|
+
await deps.provider.transition(issue, "needs-feedback");
|
|
172
408
|
continue;
|
|
173
409
|
}
|
|
174
410
|
const attempt = (marker?.attempt ?? 0) + 1;
|
|
@@ -195,6 +431,70 @@ export async function reconcileRefining(deps, state) {
|
|
|
195
431
|
}
|
|
196
432
|
}
|
|
197
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* `containerId`'s parent's parent's ... — the chain of `parent`s a nested
|
|
436
|
+
* epic-of-features roll-up needs to climb, each hop read straight out of an
|
|
437
|
+
* already-fetched `Issue.body`'s hidden `spf-refine:` marker (no extra
|
|
438
|
+
* tracker call). `depth` guards against a cycle a malformed marker could
|
|
439
|
+
* otherwise spin on forever, bounded to `MAX_ROLLUP_DEPTH` — GitHub's own
|
|
440
|
+
* documented sub-issue nesting cap (see `github_provider.ts`'s `linkChild`
|
|
441
|
+
* doc comment).
|
|
442
|
+
*/
|
|
443
|
+
function parentOf(issue) {
|
|
444
|
+
return parseRefineMarker(issue.body).parent;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* A container is `done` the instant every child `listChildren` reports
|
|
448
|
+
* carries `<prefix>:done` — reached reactively, from the child that just
|
|
449
|
+
* finished (`finishReviews` below), never a periodic full scan: cheap on a
|
|
450
|
+
* quiet tick, and it means the moment the LAST child lands is the moment the
|
|
451
|
+
* container notices, not up to a poll interval later.
|
|
452
|
+
*
|
|
453
|
+
* A no-op, logged once, when `deps.listChildren` is unset (any tracker but
|
|
454
|
+
* GitHub today — see `WatchDeps`'s own doc comment) or when `containerId`
|
|
455
|
+
* is `null` (a top-level leaf has no container to roll up at all).
|
|
456
|
+
*
|
|
457
|
+
* Recurses on the container's OWN parent once it finishes, so an epic whose
|
|
458
|
+
* features each roll up in turn eventually rolls up itself — bounded by
|
|
459
|
+
* `MAX_ROLLUP_DEPTH` against a cyclic or absurdly deep marker.
|
|
460
|
+
*/
|
|
461
|
+
async function rollUp(deps, containerId, depth = 0) {
|
|
462
|
+
if (!containerId || depth >= MAX_ROLLUP_DEPTH)
|
|
463
|
+
return;
|
|
464
|
+
if (!deps.listChildren) {
|
|
465
|
+
deps.log(`watch: ${containerId}: no listChildren on this tracker — container roll-up is GitHub-only, skipping`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const container = await deps.provider.getIssue(containerId);
|
|
469
|
+
if (!container)
|
|
470
|
+
return; // deleted — nothing left to roll up
|
|
471
|
+
const doneLabel = `${deps.labelPrefix}:done`;
|
|
472
|
+
if (container.labels.includes(doneLabel))
|
|
473
|
+
return; // already rolled up — never re-process, never loop on its own parent again
|
|
474
|
+
const children = await deps.listChildren(container);
|
|
475
|
+
const unfinished = children.filter((c) => !c.labels.includes(doneLabel));
|
|
476
|
+
if (unfinished.length > 0) {
|
|
477
|
+
deps.log(`watch: ${containerId} waiting on ${unfinished.map((c) => `#${c.id}`).join(", ")} before it can roll up`);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
deps.log(`watch: ${containerId}: every child is done — rolling up`);
|
|
481
|
+
deps.notify({
|
|
482
|
+
kind: "feature_done",
|
|
483
|
+
level: "info",
|
|
484
|
+
title: `feature ${containerId} done`,
|
|
485
|
+
detail: `${children.length} child issue(s) all landed.`,
|
|
486
|
+
fields: [["issue", containerId], ["title", container.title], ["children", String(children.length)]],
|
|
487
|
+
});
|
|
488
|
+
if (!deps.dryRun) {
|
|
489
|
+
const body = `spf watch: every child issue landed:\n\n${children.map((c) => `- #${c.id}: ${c.title}`).join("\n")}`;
|
|
490
|
+
await deps.provider.comment(container, body);
|
|
491
|
+
await deps.provider.transition(container, "done");
|
|
492
|
+
await deps.provider.closeIssue?.(container).catch((error) => {
|
|
493
|
+
deps.log(`watch: ${containerId}: closeIssue failed — left open, still \`${doneLabel}\`: ${error.message}`);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
await rollUp(deps, parentOf(container), depth + 1);
|
|
497
|
+
}
|
|
198
498
|
/** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
|
|
199
499
|
export async function finishReviews(deps) {
|
|
200
500
|
const reviewing = await deps.provider.listInState("review", { includeAll: true });
|
|
@@ -214,6 +514,15 @@ export async function finishReviews(deps) {
|
|
|
214
514
|
});
|
|
215
515
|
if (!deps.dryRun) {
|
|
216
516
|
await deps.provider.transition(issue, "done");
|
|
517
|
+
// Best-effort, never fatal — same pattern as announceRefined's own
|
|
518
|
+
// closeIssue call: the issue is already correctly `<prefix>:done`
|
|
519
|
+
// by the time this runs, so a tracker that can't close (or doesn't
|
|
520
|
+
// implement it at all) must not turn a successfully landed issue
|
|
521
|
+
// into `blocked`.
|
|
522
|
+
await deps.provider.closeIssue?.(issue).catch((error) => {
|
|
523
|
+
deps.log(`watch: ${issue.id}: closeIssue failed — left open, still \`${deps.labelPrefix}:done\`: ${error.message}`);
|
|
524
|
+
});
|
|
525
|
+
await rollUp(deps, parentOf(issue));
|
|
217
526
|
cleanupWorktree(deps, marker);
|
|
218
527
|
}
|
|
219
528
|
}
|
|
@@ -254,7 +563,7 @@ async function runIssue(deps, issue) {
|
|
|
254
563
|
deps.linkDataDir(worktreePath);
|
|
255
564
|
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
|
|
256
565
|
const prompt = `${issue.title}\n\n${issue.body}`.trim();
|
|
257
|
-
const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
|
|
566
|
+
const result = await deps.runChain({ prompt, cwd: worktreePath, adwId, chainOptions: deps.chainOptions });
|
|
258
567
|
if (!result.accepted) {
|
|
259
568
|
deps.log(`watch: ${issue.id}: chain "${deps.chain}" did not succeed — blocked`);
|
|
260
569
|
const detail = result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
|
|
@@ -335,6 +644,24 @@ async function runIssue(deps, issue) {
|
|
|
335
644
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
336
645
|
}
|
|
337
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* The spec's own `<prefix>:priority:pN` label, or `null` if it was never
|
|
649
|
+
* set. Deliberately NOT `issuePriority`'s default-to-`p2` behavior: `null`
|
|
650
|
+
* here means "no ceiling" (`clampPriority`'s own no-op case, `core/refine.ts`),
|
|
651
|
+
* so a spec predating this feature — the common case, since nothing sets
|
|
652
|
+
* this label automatically on a spec issue — publishes exactly as it always
|
|
653
|
+
* has, uncapped, rather than silently forcing every generated leaf down to
|
|
654
|
+
* `p2`. A spec's OWN priority never comes from a hidden `spf-refine:`
|
|
655
|
+
* marker: only refine-lane-created issues carry that marker, and a spec is,
|
|
656
|
+
* by definition, not one of those.
|
|
657
|
+
*/
|
|
658
|
+
function specPriorityLabel(issue, labelPrefix) {
|
|
659
|
+
for (const p of ["p0", "p1", "p2", "p3"]) {
|
|
660
|
+
if (issue.labels.includes(`${labelPrefix}:priority:${p}`))
|
|
661
|
+
return p;
|
|
662
|
+
}
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
338
665
|
/**
|
|
339
666
|
* One spec's full claim -> decompose -> publish path, run in the background
|
|
340
667
|
* — `claimSpecs` doesn't await this. The build lane's `runIssue`, minus the
|
|
@@ -356,7 +683,7 @@ async function runSpec(deps, issue) {
|
|
|
356
683
|
const existingMarker = await deps.provider.readMarker(issue);
|
|
357
684
|
if (existingMarker?.refined && existingMarker.refined.length > 0) {
|
|
358
685
|
deps.log(`watch: spec ${issue.id}: a previous attempt already published ${existingMarker.refined.length} issue(s) — finishing without re-running the refiner`);
|
|
359
|
-
await
|
|
686
|
+
await announceRefined(deps, issue, existingMarker.refined.map((id) => ({ id })), existingMarker.feedback?.rounds ?? 0);
|
|
360
687
|
return;
|
|
361
688
|
}
|
|
362
689
|
// See runIssue's identical comment: worktreePath/branch are deterministic
|
|
@@ -366,9 +693,30 @@ async function runSpec(deps, issue) {
|
|
|
366
693
|
deps.git.fetch("origin", deps.baseBranch);
|
|
367
694
|
deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
|
|
368
695
|
deps.linkDataDir(worktreePath);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
696
|
+
// Spread existingMarker (not a bare object) so a spec resuming after a
|
|
697
|
+
// prior escalation carries its `feedback` (round count, last-asked-at)
|
|
698
|
+
// through to whatever this run writes next — escalateSpec below and the
|
|
699
|
+
// final writeMarker on a successful publish both build on this object.
|
|
700
|
+
const marker = { ...existingMarker, worktree: worktreePath, branch, attempt: 0 };
|
|
701
|
+
await deps.provider.writeMarker(issue, marker);
|
|
702
|
+
// The comment thread is what makes this a HUMAN-in-the-loop, not just a
|
|
703
|
+
// one-shot prompt: a resumed run needs the human's answers, and
|
|
704
|
+
// buildSpecPrompt tells them apart from any earlier discussion using
|
|
705
|
+
// existingMarker.feedback.asked_at (unset on a spec that's never been
|
|
706
|
+
// escalated, in which case every comment is "earlier discussion").
|
|
707
|
+
const comments = await deps.provider.listComments(issue);
|
|
708
|
+
// The spec's own priority label, threaded two ways: into the prompt
|
|
709
|
+
// (buildSpecPrompt's `## Priority` section, so the refiner's per-node
|
|
710
|
+
// judgment is informed) AND into chainOptions (steps.publishIssues()
|
|
711
|
+
// reads `options["priority"]` and passes it to `core/refine.ts`'s
|
|
712
|
+
// `publish()` as a hard ceiling) — the prompt makes the rule sensible,
|
|
713
|
+
// the ceiling is what makes it TRUE regardless of what the model does
|
|
714
|
+
// with the prompt. `null` (no label set) reaches both as "no ceiling",
|
|
715
|
+
// unchanged from before this feature existed.
|
|
716
|
+
const specPriority = specPriorityLabel(issue, deps.labelPrefix);
|
|
717
|
+
const prompt = buildSpecPrompt(issue, comments, existingMarker?.feedback, specPriority);
|
|
718
|
+
const chainOptions = specPriority ? { ...deps.chainOptions, priority: specPriority } : deps.chainOptions;
|
|
719
|
+
const result = await deps.runRefine({ prompt, cwd: worktreePath, adwId, issueId: issue.id, chainOptions });
|
|
372
720
|
if (!result.accepted) {
|
|
373
721
|
deps.log(`watch: spec ${issue.id}: refine chain "${deps.refineChain}" did not succeed — blocked`);
|
|
374
722
|
const detail = result.detail || `Refine chain "${deps.refineChain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
|
|
@@ -383,8 +731,17 @@ async function runSpec(deps, issue) {
|
|
|
383
731
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
384
732
|
return;
|
|
385
733
|
}
|
|
386
|
-
|
|
387
|
-
|
|
734
|
+
// Mutually exclusive by construction — gates.refinementWellFormed
|
|
735
|
+
// guarantees a `questions`-bearing envelope publishes no `issues` — so
|
|
736
|
+
// this branches before, never alongside, the publish path below.
|
|
737
|
+
if (result.questions.length > 0) {
|
|
738
|
+
const round = (existingMarker?.feedback?.rounds ?? 0) + 1;
|
|
739
|
+
await escalateSpec(deps, issue, marker, result.questions, adwId, round);
|
|
740
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
await deps.provider.writeMarker(issue, { ...marker, refined: result.created.map((c) => c.id) });
|
|
744
|
+
await announceRefined(deps, issue, result.created, existingMarker?.feedback?.rounds ?? 0);
|
|
388
745
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
389
746
|
}
|
|
390
747
|
catch (error) {
|
|
@@ -401,16 +758,119 @@ async function runSpec(deps, issue) {
|
|
|
401
758
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
402
759
|
}
|
|
403
760
|
}
|
|
404
|
-
/**
|
|
761
|
+
/**
|
|
762
|
+
* `issue`'s priority, as `claimNewWork` schedules by: the `<prefix>:priority:pN`
|
|
763
|
+
* LABEL first — a human relabeling an issue is the whole override mechanism
|
|
764
|
+
* (see `RefinedPrioritySchema`'s doc comment and the "Priority" section of
|
|
765
|
+
* `assets/prompts/refiner/system.md`), so it must win over whatever the
|
|
766
|
+
* hidden `spf-refine:` marker still says from publish time — and the marker
|
|
767
|
+
* only as a fallback, for an issue whose label was never applied at all (a
|
|
768
|
+
* hand-created issue with no `spf:priority:*` label, or one predating this
|
|
769
|
+
* feature). `parseRefineMarker` itself defaults to `p2` absent a marker, so
|
|
770
|
+
* this never needs its own fallback beyond that.
|
|
771
|
+
*/
|
|
772
|
+
function issuePriority(issue, labelPrefix) {
|
|
773
|
+
for (const p of ["p0", "p1", "p2", "p3"]) {
|
|
774
|
+
if (issue.labels.includes(`${labelPrefix}:priority:${p}`))
|
|
775
|
+
return p;
|
|
776
|
+
}
|
|
777
|
+
return parseRefineMarker(issue.body).priority;
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Sort `issues` the way `claimNewWork` walks them: priority first (p0 ahead
|
|
781
|
+
* of p2 regardless of creation order), then sibling affinity (a leaf whose
|
|
782
|
+
* hidden marker names a parent already in `inflightParents.values()` sorts
|
|
783
|
+
* ahead of an equal-priority leaf from an unrelated feature — the mechanism
|
|
784
|
+
* that tends to finish one feature before starting the next, without giving
|
|
785
|
+
* up the one-PR-per-story design), then creation order (oldest first,
|
|
786
|
+
* matching `listByLabel`'s own `sort=created&direction=asc` — the final,
|
|
787
|
+
* stable tiebreaker when priority and affinity both tie).
|
|
788
|
+
*
|
|
789
|
+
* Pure and exported so it's directly unit-testable without a provider, same
|
|
790
|
+
* spirit as `buildSpecPrompt` below. Two honest limits, both already true of
|
|
791
|
+
* what feeds it: affinity only reflects a sibling ACTUALLY in flight in this
|
|
792
|
+
* process right now — a daemon restart begins with `inflightParents` empty,
|
|
793
|
+
* so ordering degrades to priority + created-asc until it rebuilds itself
|
|
794
|
+
* over the next few ticks; and priority is read from the label, so an issue
|
|
795
|
+
* a human just relabeled sorts by its NEW priority starting next tick, never
|
|
796
|
+
* retroactively re-ordering claims a previous tick already made.
|
|
797
|
+
*/
|
|
798
|
+
export function orderEligible(issues, inflightParents, labelPrefix) {
|
|
799
|
+
const inflightParentIds = new Set(inflightParents.values());
|
|
800
|
+
return issues
|
|
801
|
+
.map((issue, index) => ({
|
|
802
|
+
issue,
|
|
803
|
+
index, // preserves listEligible's own created-asc order as the final tiebreaker
|
|
804
|
+
priority: PRIORITY_RANK[issuePriority(issue, labelPrefix)],
|
|
805
|
+
hasAffinity: (() => {
|
|
806
|
+
const parent = parseRefineMarker(issue.body).parent;
|
|
807
|
+
return parent !== null && inflightParentIds.has(parent);
|
|
808
|
+
})(),
|
|
809
|
+
}))
|
|
810
|
+
.sort((a, b) => {
|
|
811
|
+
if (a.priority !== b.priority)
|
|
812
|
+
return a.priority - b.priority;
|
|
813
|
+
if (a.hasAffinity !== b.hasAffinity)
|
|
814
|
+
return a.hasAffinity ? -1 : 1;
|
|
815
|
+
return a.index - b.index;
|
|
816
|
+
})
|
|
817
|
+
.map((w) => w.issue);
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Whether every id in `blockedBy` currently carries `<prefix>:done` — the
|
|
821
|
+
* frontier check `assets/prompts/refiner/system.md:62` has always promised
|
|
822
|
+
* the refiner ("the factory works the frontier: any leaf whose blockers are
|
|
823
|
+
* all done") but nothing enforced before this. `cache` is per-tick, shared
|
|
824
|
+
* across every candidate `claimNewWork` considers in one pass, so a blocker
|
|
825
|
+
* several leaves share costs exactly one `getIssue` call, not one per leaf.
|
|
826
|
+
* A blocker that 404s (deleted) is treated as satisfied, with a warning —
|
|
827
|
+
* a removed blocker must not wedge its dependents forever.
|
|
828
|
+
*/
|
|
829
|
+
async function frontierBlockedOn(deps, blockedBy, cache) {
|
|
830
|
+
const doneLabel = `${deps.labelPrefix}:done`;
|
|
831
|
+
for (const id of blockedBy) {
|
|
832
|
+
let done = cache.get(id);
|
|
833
|
+
if (done === undefined) {
|
|
834
|
+
const blocker = await deps.provider.getIssue(id);
|
|
835
|
+
if (!blocker) {
|
|
836
|
+
deps.log(`watch: blocker #${id} no longer exists — treating it as satisfied rather than wedging its dependents`);
|
|
837
|
+
done = true;
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
done = blocker.labels.includes(doneLabel);
|
|
841
|
+
}
|
|
842
|
+
cache.set(id, done);
|
|
843
|
+
}
|
|
844
|
+
if (!done)
|
|
845
|
+
return id;
|
|
846
|
+
}
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Claim as many `ready` issues as the concurrency budget allows, in priority
|
|
851
|
+
* + sibling-affinity + created-asc order (`orderEligible`), skipping any
|
|
852
|
+
* whose `blocked_by` isn't fully `<prefix>:done` yet (`frontierBlockedOn`),
|
|
853
|
+
* and kick off `runIssue` for each claimed one in the background.
|
|
854
|
+
*/
|
|
405
855
|
export async function claimNewWork(deps, state) {
|
|
406
856
|
if (state.inflight.size >= deps.concurrency)
|
|
407
857
|
return;
|
|
408
858
|
const eligible = await deps.provider.listEligible();
|
|
409
|
-
|
|
859
|
+
const ordered = orderEligible(eligible, state.inflightParents, deps.labelPrefix);
|
|
860
|
+
const blockerCache = new Map(); // per-tick — see frontierBlockedOn's doc comment
|
|
861
|
+
for (const issue of ordered) {
|
|
410
862
|
if (state.inflight.size >= deps.concurrency)
|
|
411
863
|
break;
|
|
412
864
|
if (state.inflight.has(issue.id))
|
|
413
865
|
continue;
|
|
866
|
+
const marker = parseRefineMarker(issue.body);
|
|
867
|
+
if (marker.blocked_by.length > 0) {
|
|
868
|
+
const waitingOn = await frontierBlockedOn(deps, marker.blocked_by, blockerCache);
|
|
869
|
+
if (waitingOn) {
|
|
870
|
+
deps.log(`watch: ${issue.id} waiting on #${waitingOn} — not yet at the frontier`);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
414
874
|
if (deps.dryRun) {
|
|
415
875
|
deps.log(`watch: [dry-run] would claim ${issue.id} (${issue.title}) and run chain "${deps.chain}"`);
|
|
416
876
|
continue;
|
|
@@ -428,16 +888,40 @@ export async function claimNewWork(deps, state) {
|
|
|
428
888
|
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
|
|
429
889
|
});
|
|
430
890
|
state.inflight.add(issue.id);
|
|
431
|
-
|
|
891
|
+
if (marker.parent)
|
|
892
|
+
state.inflightParents.set(issue.id, marker.parent);
|
|
893
|
+
runIssue(deps, issue).finally(() => {
|
|
894
|
+
state.inflight.delete(issue.id);
|
|
895
|
+
state.inflightParents.delete(issue.id);
|
|
896
|
+
});
|
|
432
897
|
}
|
|
433
898
|
}
|
|
434
|
-
/**
|
|
435
|
-
|
|
899
|
+
/**
|
|
900
|
+
* Claim as many specs in `from` as `refineConcurrency` allows, and kick off
|
|
901
|
+
* `runSpec` for each in the background. A no-op when `watch.refine` is off.
|
|
902
|
+
*
|
|
903
|
+
* `from` defaults to `spec-ready` (a fresh spec) but `tick()` also calls this
|
|
904
|
+
* with `"continue-refinement"` — a human's signal that they've answered a
|
|
905
|
+
* prior round's questions and refinement should resume. Both share this same
|
|
906
|
+
* function (and `state.refining`'s budget) rather than a second copy of the
|
|
907
|
+
* claim loop, the same way the build lane's `claim()` itself takes a
|
|
908
|
+
* parameterized `{from, to}` rather than a hardcoded `ready -> working`.
|
|
909
|
+
*
|
|
910
|
+
* Known, deliberate wart: `claim()` only removes `from`'s label, so while a
|
|
911
|
+
* resumed run is in flight the issue briefly still carries
|
|
912
|
+
* `<prefix>:needs-feedback` alongside the (now claimed) `refining` state that
|
|
913
|
+
* replaced `continue-refinement`. Nothing polls `needs-feedback` on its own,
|
|
914
|
+
* and the run's own terminating `transition()` — to `needs-feedback` again,
|
|
915
|
+
* `done`, or `blocked` — strips every state label from the fresh snapshot it
|
|
916
|
+
* reads at that point, so this self-heals on the very next transition rather
|
|
917
|
+
* than needing a second mutator alongside `transition()`.
|
|
918
|
+
*/
|
|
919
|
+
export async function claimSpecs(deps, state, from = "spec-ready") {
|
|
436
920
|
if (!deps.refineEnabled)
|
|
437
921
|
return;
|
|
438
922
|
if (state.refining.size >= deps.refineConcurrency)
|
|
439
923
|
return;
|
|
440
|
-
const eligible = await deps.provider.listInState(
|
|
924
|
+
const eligible = await deps.provider.listInState(from);
|
|
441
925
|
for (const issue of eligible) {
|
|
442
926
|
if (state.refining.size >= deps.refineConcurrency)
|
|
443
927
|
break;
|
|
@@ -447,7 +931,7 @@ export async function claimSpecs(deps, state) {
|
|
|
447
931
|
deps.log(`watch: [dry-run] would claim spec ${issue.id} (${issue.title}) and run refine chain "${deps.refineChain}"`);
|
|
448
932
|
continue;
|
|
449
933
|
}
|
|
450
|
-
const claimed = await deps.provider.claim(issue, { from
|
|
934
|
+
const claimed = await deps.provider.claim(issue, { from, to: "refining" });
|
|
451
935
|
if (!claimed) {
|
|
452
936
|
deps.log(`watch: spec ${issue.id} lost the claim race this tick — skipping`);
|
|
453
937
|
continue;
|
|
@@ -470,11 +954,25 @@ function tickErrorHandler(deps, stage) {
|
|
|
470
954
|
deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
|
|
471
955
|
};
|
|
472
956
|
}
|
|
473
|
-
/**
|
|
957
|
+
/**
|
|
958
|
+
* One poll tick: reconcile both lanes, finish reviews, then claim both
|
|
959
|
+
* lanes — each stage independently caught, so one stage's error never blocks
|
|
960
|
+
* the rest. `claimSpecs` runs twice: resumed specs (`continue-refinement`,
|
|
961
|
+
* a human who already answered and is waiting) before fresh ones
|
|
962
|
+
* (`spec-ready`) — both share `state.refining`'s budget, so
|
|
963
|
+
* `refine.concurrency` still caps the lane as a whole either way.
|
|
964
|
+
*/
|
|
474
965
|
export async function tick(deps, state) {
|
|
475
966
|
await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
|
|
476
967
|
await reconcileRefining(deps, state).catch(tickErrorHandler(deps, "reconcileRefining"));
|
|
477
968
|
await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
|
|
969
|
+
// Right after finishReviews, not before it: a leaf that just landed this
|
|
970
|
+
// very tick (and, transitively, any container rollUp() rolled up because
|
|
971
|
+
// of it) can also be the last thing a spec-in-progress spec was waiting
|
|
972
|
+
// on — checking in the same tick is strictly cheaper than making a product
|
|
973
|
+
// manager wait one extra poll interval to see it.
|
|
974
|
+
await finishTrackedSpecs(deps).catch(tickErrorHandler(deps, "finishTrackedSpecs"));
|
|
975
|
+
await claimSpecs(deps, state, "continue-refinement").catch(tickErrorHandler(deps, "claimSpecs(resume)"));
|
|
478
976
|
await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
|
|
479
977
|
await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
|
|
480
978
|
}
|