mandrel 1.69.0 → 1.70.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/.agents/README.md +1 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/scripts/agents-update-preflight.js +235 -0
- package/.agents/scripts/apply-quality-bootstrap.js +79 -0
- package/.agents/scripts/audit-labels-bootstrap.js +52 -30
- package/.agents/scripts/audit-to-stories.js +54 -0
- package/.agents/scripts/bootstrap.js +13 -3
- package/.agents/scripts/generate-config-docs.js +189 -94
- package/.agents/scripts/lib/audit-suite/findings.js +0 -4
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
- package/.agents/scripts/lib/baseline-snapshot.js +163 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
- package/.agents/scripts/lib/config/baselines.js +0 -20
- package/.agents/scripts/lib/config/temp-paths.js +0 -31
- package/.agents/scripts/lib/crap-utils.js +281 -0
- package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
- package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
- package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
- package/.agents/scripts/lib/story-body/story-body.js +110 -65
- package/.agents/scripts/lib/test-tiers.js +13 -7
- package/.agents/scripts/lib/wave-runner/tick.js +177 -53
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
- package/.agents/scripts/providers/github/issues.js +48 -0
- package/.agents/scripts/providers/github.js +1 -0
- package/.agents/workflows/agents-update.md +205 -28
- package/README.md +20 -0
- package/docs/CHANGELOG.md +32 -0
- package/lib/cli/registry.js +49 -6
- package/lib/cli/update.js +335 -332
- package/package.json +16 -11
|
@@ -157,6 +157,45 @@ function sanitizeLabels(labels) {
|
|
|
157
157
|
return out.length > 0 ? out : undefined;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Descriptor table for the structured-body → markdown projection, in
|
|
162
|
+
* canonical emit order (`## Goal`, `## Changes`, `## Acceptance`,
|
|
163
|
+
* `## Verify`). Each descriptor reads one body field and returns the
|
|
164
|
+
* section's markdown block when the field is present and non-empty, or `null`
|
|
165
|
+
* to omit it. Adding a section is a one-line data edit rather than a new
|
|
166
|
+
* branch in {@link renderBody}.
|
|
167
|
+
*
|
|
168
|
+
* @type {Array<{ field: string, render: (value: unknown) => string | null }>}
|
|
169
|
+
*/
|
|
170
|
+
const SPEC_BODY_SECTIONS = [
|
|
171
|
+
{
|
|
172
|
+
field: 'goal',
|
|
173
|
+
render: (goal) =>
|
|
174
|
+
typeof goal === 'string' && goal.length > 0 ? `## Goal\n${goal}` : null,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
field: 'changes',
|
|
178
|
+
render: (changes) =>
|
|
179
|
+
Array.isArray(changes) && changes.length > 0
|
|
180
|
+
? `## Changes\n${changes.map((c) => `- ${String(c)}`).join('\n')}`
|
|
181
|
+
: null,
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
field: 'acceptance',
|
|
185
|
+
render: (acceptance) =>
|
|
186
|
+
Array.isArray(acceptance) && acceptance.length > 0
|
|
187
|
+
? `## Acceptance\n${acceptance.map((a) => `- [ ] ${String(a)}`).join('\n')}`
|
|
188
|
+
: null,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
field: 'verify',
|
|
192
|
+
render: (verify) =>
|
|
193
|
+
Array.isArray(verify) && verify.length > 0
|
|
194
|
+
? `## Verify\n${verify.map((v) => `- ${String(v)}`).join('\n')}`
|
|
195
|
+
: null,
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
|
|
160
199
|
/**
|
|
161
200
|
* Convert a decomposer body value into a spec `body` string. The
|
|
162
201
|
* decomposer schema admits two shapes for a Story body:
|
|
@@ -190,20 +229,9 @@ function renderBody(body) {
|
|
|
190
229
|
if (typeof body !== 'object') return undefined;
|
|
191
230
|
|
|
192
231
|
const sections = [];
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (Array.isArray(body.changes) && body.changes.length > 0) {
|
|
197
|
-
const items = body.changes.map((c) => `- ${String(c)}`).join('\n');
|
|
198
|
-
sections.push(`## Changes\n${items}`);
|
|
199
|
-
}
|
|
200
|
-
if (Array.isArray(body.acceptance) && body.acceptance.length > 0) {
|
|
201
|
-
const items = body.acceptance.map((a) => `- [ ] ${String(a)}`).join('\n');
|
|
202
|
-
sections.push(`## Acceptance\n${items}`);
|
|
203
|
-
}
|
|
204
|
-
if (Array.isArray(body.verify) && body.verify.length > 0) {
|
|
205
|
-
const items = body.verify.map((v) => `- ${String(v)}`).join('\n');
|
|
206
|
-
sections.push(`## Verify\n${items}`);
|
|
232
|
+
for (const descriptor of SPEC_BODY_SECTIONS) {
|
|
233
|
+
const block = descriptor.render(body[descriptor.field]);
|
|
234
|
+
if (block !== null) sections.push(block);
|
|
207
235
|
}
|
|
208
236
|
return sections.length > 0 ? sections.join('\n\n') : undefined;
|
|
209
237
|
}
|
|
@@ -56,6 +56,9 @@ import { parseLedger } from './lifecycle/trace-logger.js';
|
|
|
56
56
|
* coordinated under a shared identity) and no assignee PATCH ever writes a
|
|
57
57
|
* literal `[USERNAME]` (HTTP 422).
|
|
58
58
|
*/
|
|
59
|
+
// kept (dead-export allowlist): public config sentinel — the distributed
|
|
60
|
+
// `.agentrc.json` / templates carry this literal; exported so consumers and
|
|
61
|
+
// future call sites resolve it by symbol rather than re-typing the string.
|
|
59
62
|
export const OPERATOR_HANDLE_PLACEHOLDER = '@[USERNAME]';
|
|
60
63
|
const OPERATOR_HANDLE_PLACEHOLDER_BARE = '[USERNAME]';
|
|
61
64
|
|
|
@@ -683,6 +683,108 @@ function serializePathEntry(entry) {
|
|
|
683
683
|
return JSON.stringify({ path: entry.path, assumption: entry.assumption });
|
|
684
684
|
}
|
|
685
685
|
|
|
686
|
+
/**
|
|
687
|
+
* Descriptor table for the human-readable Story-body sections, in canonical
|
|
688
|
+
* emit order (`## Goal`, `## Changes`, `## Acceptance`, `## Verify`,
|
|
689
|
+
* `## References`). Each descriptor reads one body field and returns the
|
|
690
|
+
* section's markdown block when the field is present and non-empty, or `null`
|
|
691
|
+
* to omit the section.
|
|
692
|
+
*
|
|
693
|
+
* Standardising the section ladder as a single data table makes adding a new
|
|
694
|
+
* optional section a one-line edit here rather than a new control-flow branch
|
|
695
|
+
* in {@link serialize}.
|
|
696
|
+
*
|
|
697
|
+
* @type {Array<{ field: string, render: (value: unknown) => string | null }>}
|
|
698
|
+
*/
|
|
699
|
+
const SERIALIZE_SECTIONS = [
|
|
700
|
+
{
|
|
701
|
+
field: 'goal',
|
|
702
|
+
render: (goal) =>
|
|
703
|
+
typeof goal === 'string' && goal.trim().length > 0
|
|
704
|
+
? `## Goal\n${goal.trim()}`
|
|
705
|
+
: null,
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
field: 'changes',
|
|
709
|
+
render: (changes) =>
|
|
710
|
+
Array.isArray(changes) && changes.length > 0
|
|
711
|
+
? `## Changes\n${changes.map((c) => `- ${serializePathEntry(c)}`).join('\n')}`
|
|
712
|
+
: null,
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
field: 'acceptance',
|
|
716
|
+
render: (acceptance) =>
|
|
717
|
+
Array.isArray(acceptance) && acceptance.length > 0
|
|
718
|
+
? `## Acceptance\n${acceptance.map((a) => `- [ ] ${a}`).join('\n')}`
|
|
719
|
+
: null,
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
field: 'verify',
|
|
723
|
+
render: (verify) =>
|
|
724
|
+
Array.isArray(verify) && verify.length > 0
|
|
725
|
+
? `## Verify\n${verify.map((v) => `- ${v}`).join('\n')}`
|
|
726
|
+
: null,
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
field: 'references',
|
|
730
|
+
render: (references) =>
|
|
731
|
+
Array.isArray(references) && references.length > 0
|
|
732
|
+
? `## References\n${references.map((r) => `- ${serializePathEntry(r)}`).join('\n')}`
|
|
733
|
+
: null,
|
|
734
|
+
},
|
|
735
|
+
];
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Build the trailing `<!-- meta: {...} -->` block carrying the fields that
|
|
739
|
+
* have no human-readable section (`wide`, `reason_to_exist`,
|
|
740
|
+
* `estimated_test_files`). Returns the empty string when no meta field is
|
|
741
|
+
* present so {@link serialize} appends nothing.
|
|
742
|
+
*
|
|
743
|
+
* Key insertion order (`wide` → `reason_to_exist` → `estimated_test_files`)
|
|
744
|
+
* is load-bearing: it fixes the serialized JSON byte sequence the parser's
|
|
745
|
+
* meta round-trip and the unit suite assert against.
|
|
746
|
+
*
|
|
747
|
+
* @param {StoryBody} body
|
|
748
|
+
* @returns {string}
|
|
749
|
+
*/
|
|
750
|
+
function serializeMetaBlock(body) {
|
|
751
|
+
const metaFields = {};
|
|
752
|
+
const wide = normalizeWide(body.wide);
|
|
753
|
+
if (wide !== null) {
|
|
754
|
+
metaFields.wide = wide;
|
|
755
|
+
}
|
|
756
|
+
const reasonToExist = normalizeReasonToExist(body.reason_to_exist);
|
|
757
|
+
if (reasonToExist !== null) {
|
|
758
|
+
metaFields.reason_to_exist = reasonToExist;
|
|
759
|
+
}
|
|
760
|
+
if (typeof body.estimated_test_files === 'number') {
|
|
761
|
+
metaFields.estimated_test_files = body.estimated_test_files;
|
|
762
|
+
}
|
|
763
|
+
if (Object.keys(metaFields).length === 0) return '';
|
|
764
|
+
return `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Build the optional `---` footer block (`parent` / `Epic` / `blocked by`
|
|
769
|
+
* lines). Returns the empty string when `opts.includeFooter` is falsy.
|
|
770
|
+
*
|
|
771
|
+
* @param {StoryBody} body
|
|
772
|
+
* @param {SerializeOptions} opts
|
|
773
|
+
* @returns {string}
|
|
774
|
+
*/
|
|
775
|
+
function serializeFooter(body, opts) {
|
|
776
|
+
if (!opts.includeFooter) return '';
|
|
777
|
+
const footerLines = ['---'];
|
|
778
|
+
if (opts.footer?.parent) footerLines.push(`parent: #${opts.footer.parent}`);
|
|
779
|
+
if (opts.footer?.epic) footerLines.push(`Epic: #${opts.footer.epic}`);
|
|
780
|
+
if (Array.isArray(body.depends_on)) {
|
|
781
|
+
for (const dep of body.depends_on) {
|
|
782
|
+
footerLines.push(`blocked by ${dep}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return `\n\n${footerLines.join('\n')}`;
|
|
786
|
+
}
|
|
787
|
+
|
|
686
788
|
/**
|
|
687
789
|
* Serialize a structured {@link StoryBody} back to the canonical markdown
|
|
688
790
|
* format written to GitHub issue bodies.
|
|
@@ -707,73 +809,16 @@ export function serialize(body, opts = {}) {
|
|
|
707
809
|
}
|
|
708
810
|
|
|
709
811
|
const sections = [];
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
sections.push(`## Goal\n${body.goal.trim()}`);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// ## Changes
|
|
717
|
-
if (Array.isArray(body.changes) && body.changes.length > 0) {
|
|
718
|
-
const items = body.changes
|
|
719
|
-
.map((c) => `- ${serializePathEntry(c)}`)
|
|
720
|
-
.join('\n');
|
|
721
|
-
sections.push(`## Changes\n${items}`);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// ## Acceptance
|
|
725
|
-
if (Array.isArray(body.acceptance) && body.acceptance.length > 0) {
|
|
726
|
-
const items = body.acceptance.map((a) => `- [ ] ${a}`).join('\n');
|
|
727
|
-
sections.push(`## Acceptance\n${items}`);
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// ## Verify
|
|
731
|
-
if (Array.isArray(body.verify) && body.verify.length > 0) {
|
|
732
|
-
const items = body.verify.map((v) => `- ${v}`).join('\n');
|
|
733
|
-
sections.push(`## Verify\n${items}`);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// ## References (only when non-empty)
|
|
737
|
-
if (Array.isArray(body.references) && body.references.length > 0) {
|
|
738
|
-
const items = body.references
|
|
739
|
-
.map((r) => `- ${serializePathEntry(r)}`)
|
|
740
|
-
.join('\n');
|
|
741
|
-
sections.push(`## References\n${items}`);
|
|
812
|
+
for (const descriptor of SERIALIZE_SECTIONS) {
|
|
813
|
+
const block = descriptor.render(body[descriptor.field]);
|
|
814
|
+
if (block !== null) sections.push(block);
|
|
742
815
|
}
|
|
743
816
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
if (wide !== null) {
|
|
750
|
-
metaFields.wide = wide;
|
|
751
|
-
}
|
|
752
|
-
const reasonToExist = normalizeReasonToExist(body.reason_to_exist);
|
|
753
|
-
if (reasonToExist !== null) {
|
|
754
|
-
metaFields.reason_to_exist = reasonToExist;
|
|
755
|
-
}
|
|
756
|
-
if (typeof body.estimated_test_files === 'number') {
|
|
757
|
-
metaFields.estimated_test_files = body.estimated_test_files;
|
|
758
|
-
}
|
|
759
|
-
if (Object.keys(metaFields).length > 0) {
|
|
760
|
-
out += `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// Footer
|
|
764
|
-
if (opts.includeFooter) {
|
|
765
|
-
const footerLines = ['---'];
|
|
766
|
-
if (opts.footer?.parent) footerLines.push(`parent: #${opts.footer.parent}`);
|
|
767
|
-
if (opts.footer?.epic) footerLines.push(`Epic: #${opts.footer.epic}`);
|
|
768
|
-
if (Array.isArray(body.depends_on)) {
|
|
769
|
-
for (const dep of body.depends_on) {
|
|
770
|
-
footerLines.push(`blocked by ${dep}`);
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
out += `\n\n${footerLines.join('\n')}`;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
return out;
|
|
817
|
+
return (
|
|
818
|
+
sections.join('\n\n') +
|
|
819
|
+
serializeMetaBlock(body) +
|
|
820
|
+
serializeFooter(body, opts)
|
|
821
|
+
);
|
|
777
822
|
}
|
|
778
823
|
|
|
779
824
|
// ---------------------------------------------------------------------------
|
|
@@ -33,20 +33,26 @@ const matchesIntegration = picomatch(INTEGRATION_INCLUDE, { dot: true });
|
|
|
33
33
|
* `tests` holds the framework's suite tree; `lib` holds the published CLI
|
|
34
34
|
* (under `lib/cli` and `lib/migrations`) whose tests are colocated in
|
|
35
35
|
* `__tests__` directories per the unit-tier convention in
|
|
36
|
-
* `rules/testing-standards.md`.
|
|
37
|
-
*
|
|
36
|
+
* `rules/testing-standards.md`. `.agents/scripts` holds the orchestration
|
|
37
|
+
* engine; some of its modules colocate tests in `__tests__` directories the
|
|
38
|
+
* same way (Story #4195). Without each root here, both the quick /
|
|
39
|
+
* integration walk and the full-tier glob set miss the colocated tests,
|
|
38
40
|
* leaving that coverage dark in `npm test`. The matching full-tier globs
|
|
39
41
|
* live in `FULL_TIER_GLOBS`.
|
|
40
42
|
*/
|
|
41
|
-
const TEST_WALK_ROOTS = ['tests', 'lib'];
|
|
43
|
+
const TEST_WALK_ROOTS = ['tests', 'lib', '.agents/scripts'];
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Glob targets for the `full` tier — one per walk root in `TEST_WALK_ROOTS`.
|
|
45
|
-
* The `tests` glob is a flat recursive sweep; the `lib`
|
|
46
|
-
* `__tests__` subtrees so
|
|
47
|
-
* source modules themselves.
|
|
47
|
+
* The `tests` glob is a flat recursive sweep; the `lib` and `.agents/scripts`
|
|
48
|
+
* globs are scoped to `__tests__` subtrees so they only match colocated
|
|
49
|
+
* tests, never the shipped source modules themselves.
|
|
48
50
|
*/
|
|
49
|
-
const FULL_TIER_GLOBS = [
|
|
51
|
+
const FULL_TIER_GLOBS = [
|
|
52
|
+
'tests/**/*.test.js',
|
|
53
|
+
'lib/**/__tests__/**/*.test.js',
|
|
54
|
+
'.agents/scripts/**/__tests__/**/*.test.js',
|
|
55
|
+
];
|
|
50
56
|
|
|
51
57
|
/**
|
|
52
58
|
* @param {string} dir
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* state) for every Story in scope,
|
|
13
13
|
* 3. classifies each by live label (`classifyStory`), re-derives
|
|
14
14
|
* adjacency from the live bodies (`buildStoryAdjacency`, inside
|
|
15
|
-
* `selectReadySet`)
|
|
15
|
+
* `selectReadySet`) and selects the ready set under a global
|
|
16
16
|
* in-flight cap with the file-overlap co-dispatch guard
|
|
17
17
|
* (`storiesOverlap`),
|
|
18
18
|
* 4. returns a `WaveTickResult` describing the next action.
|
|
@@ -29,6 +29,20 @@
|
|
|
29
29
|
* stories) flow back through result fields; unexpected failures (GH 5xx,
|
|
30
30
|
* malformed / old-shape checkpoint) throw `WaveRunnerError`.
|
|
31
31
|
*
|
|
32
|
+
* Story #4183 — the `tick(args)` orchestrator was a 252-line SRP /
|
|
33
|
+
* cognitive-load hotspot carrying six distinct responsibilities in one
|
|
34
|
+
* body. It is now a thin coordinator (Coordinator-plus-Phases pattern,
|
|
35
|
+
* `docs/patterns.md`) that wires four extracted stages:
|
|
36
|
+
* `resolveTickCollaborators` (collaborator/fallback resolution),
|
|
37
|
+
* `readAndValidateCheckpoint` (checkpoint read + shape validation, folding
|
|
38
|
+
* in `assertNotOldShape`), `refetchStoryRecords` (force-fresh re-fetch),
|
|
39
|
+
* and the **pure** `planTick` (classification → cycle detection → ready-set
|
|
40
|
+
* selection → dispatch decision, returning the signals to emit rather than
|
|
41
|
+
* emitting them, so it carries no I/O). The exported `tick(args)`
|
|
42
|
+
* signature, the `tickResult` / `withInFlight` envelope shapes, and every
|
|
43
|
+
* `WaveRunnerError` code are preserved verbatim — callers and tests are
|
|
44
|
+
* unchanged.
|
|
45
|
+
*
|
|
32
46
|
* @module lib/wave-runner/tick
|
|
33
47
|
*/
|
|
34
48
|
|
|
@@ -80,6 +94,13 @@ const OLD_SHAPE_FIELDS = Object.freeze(['plan', 'currentWave', 'totalWaves']);
|
|
|
80
94
|
* dependency cycle among the in-scope Stories is likewise surfaced as a
|
|
81
95
|
* `halt` (with the offending `cycle`), never collapsed to `epic-complete`.
|
|
82
96
|
*
|
|
97
|
+
* Coordinator (Story #4183): this function is a thin dispatcher. It resolves
|
|
98
|
+
* collaborators, reads + validates the checkpoint, re-fetches the live Story
|
|
99
|
+
* records, runs the best-effort recurring-failure scan, delegates the pure
|
|
100
|
+
* dispatch decision to `planTick`, then drains the `signals` `planTick`
|
|
101
|
+
* returned through the configured emitter. Each stage is an independently
|
|
102
|
+
* testable helper below.
|
|
103
|
+
*
|
|
83
104
|
* @typedef {object} WaveTickArgs
|
|
84
105
|
* @property {number | { id: number }} epic
|
|
85
106
|
* @property {{
|
|
@@ -94,13 +115,81 @@ const OLD_SHAPE_FIELDS = Object.freeze(['plan', 'currentWave', 'totalWaves']);
|
|
|
94
115
|
* @param {WaveTickArgs} args
|
|
95
116
|
*/
|
|
96
117
|
export async function tick(args = {}) {
|
|
118
|
+
const { epicId, provider, epicRunStateStore, emit, inFlightReader, ctx } =
|
|
119
|
+
resolveTickCollaborators(args);
|
|
120
|
+
|
|
121
|
+
const state = await readAndValidateCheckpoint(epicRunStateStore, epicId);
|
|
122
|
+
|
|
123
|
+
const storyIds = checkpointStoryIds(state);
|
|
124
|
+
|
|
125
|
+
if (storyIds.length === 0) {
|
|
126
|
+
// No Stories in scope — the Epic has nothing to dispatch.
|
|
127
|
+
return tickResult({
|
|
128
|
+
nextAction: withInFlight({ kind: 'epic-complete' }, []),
|
|
129
|
+
readyCount: 0,
|
|
130
|
+
inFlight: [],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Re-fetch the live Story records (body + labels + issue state) for every
|
|
135
|
+
// Story in scope. In-flight Stories are force-fresh-fetched so a label that
|
|
136
|
+
// flipped since the last tick is observed; every other Story serves from
|
|
137
|
+
// the provider's in-process cache.
|
|
138
|
+
const inFlight = await safeReadInFlight(inFlightReader);
|
|
139
|
+
const inFlightSet = new Set(inFlight);
|
|
140
|
+
const records = await refetchStoryRecords(provider, storyIds, inFlightSet);
|
|
141
|
+
|
|
142
|
+
// Best-effort recurring-failure scan (≥2 distinct Stories sharing the same
|
|
143
|
+
// `close-validate.end` failedGate). Idempotent across re-ticks; a reporter
|
|
144
|
+
// throw must not crash the planner.
|
|
145
|
+
const recurringFailureReporter =
|
|
146
|
+
args.collaborators?.recurringFailureReporter ??
|
|
147
|
+
defaultRecurringFailureReporter({ provider, epicId, config: ctx?.config });
|
|
148
|
+
await safeReportRecurringFailures(recurringFailureReporter);
|
|
149
|
+
|
|
150
|
+
// Decide the next action from the live records + ledger in-flight set. The
|
|
151
|
+
// decision is pure (no I/O); the signals it wants emitted come back in
|
|
152
|
+
// `plan.signals` and are drained by the coordinator below.
|
|
153
|
+
const plan = planTick(state, records, inFlight);
|
|
154
|
+
for (const signal of plan.signals) {
|
|
155
|
+
await emit(signal);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return tickResult({
|
|
159
|
+
nextAction: withInFlight(plan.nextAction, inFlight),
|
|
160
|
+
blockedStories: plan.blockedStories,
|
|
161
|
+
gateFailures: plan.gateFailures,
|
|
162
|
+
readyCount: plan.readyCount,
|
|
163
|
+
inFlight,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the Epic id and the five injectable collaborators (with their
|
|
169
|
+
* production-default fallbacks) from the `tick` args. The single home for the
|
|
170
|
+
* collaborator/fallback wiring so the coordinator stays declarative.
|
|
171
|
+
*
|
|
172
|
+
* Throws `WaveRunnerError('invalid-input')` when the epic id is not a
|
|
173
|
+
* positive integer (or `{ id: positiveInt }`) or when no provider is supplied
|
|
174
|
+
* via either `collaborators.provider` or `ctx.provider`.
|
|
175
|
+
*
|
|
176
|
+
* @param {WaveTickArgs} args
|
|
177
|
+
* @returns {{
|
|
178
|
+
* epicId: number,
|
|
179
|
+
* provider: object,
|
|
180
|
+
* epicRunStateStore: { read: () => Promise<object|null> },
|
|
181
|
+
* emit: (signal: object) => Promise<unknown>,
|
|
182
|
+
* inFlightReader: () => Promise<number[]>,
|
|
183
|
+
* ctx: object,
|
|
184
|
+
* }}
|
|
185
|
+
*/
|
|
186
|
+
function resolveTickCollaborators(args) {
|
|
97
187
|
const epicId = resolveEpicId(args.epic);
|
|
98
188
|
const {
|
|
99
189
|
provider: collabProvider,
|
|
100
190
|
epicRunStateStore: collabStore,
|
|
101
191
|
signalEmit,
|
|
102
192
|
inFlightReader: collabInFlightReader,
|
|
103
|
-
recurringFailureReporter: collabRecurringFailureReporter,
|
|
104
193
|
} = args.collaborators ?? {};
|
|
105
194
|
const ctx = args.ctx ?? {};
|
|
106
195
|
const provider = collabProvider ?? ctx.provider;
|
|
@@ -108,18 +197,41 @@ export async function tick(args = {}) {
|
|
|
108
197
|
throw new WaveRunnerError('invalid-input', 'provider is required');
|
|
109
198
|
}
|
|
110
199
|
// The ready-set tick is stateless. When the caller does not supply a
|
|
111
|
-
// collaborator shim, read the `epic-run-state` structured comment
|
|
112
|
-
//
|
|
200
|
+
// collaborator shim, read the `epic-run-state` structured comment directly
|
|
201
|
+
// via the function-based store.
|
|
113
202
|
const epicRunStateStore = collabStore ?? {
|
|
114
203
|
read: () => epicRunStateStoreModule.read({ provider, epicId }),
|
|
115
204
|
};
|
|
116
205
|
const emit = signalEmit ?? defaultSignalEmit(epicId, ctx);
|
|
117
206
|
const inFlightReader =
|
|
118
207
|
collabInFlightReader ?? (() => defaultInFlightReader(epicId, ctx?.config));
|
|
208
|
+
return { epicId, provider, epicRunStateStore, emit, inFlightReader, ctx };
|
|
209
|
+
}
|
|
119
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Read the `epic-run-state` checkpoint via the store, validate its shape, and
|
|
213
|
+
* fail closed on a pre-ready-set (wave-batch) checkpoint.
|
|
214
|
+
*
|
|
215
|
+
* Throws:
|
|
216
|
+
* - `WaveRunnerError('checkpoint-read')` when the store read rejects,
|
|
217
|
+
* - `WaveRunnerError('checkpoint-missing')` when the read resolves to a
|
|
218
|
+
* non-object (no comment),
|
|
219
|
+
* - `WaveRunnerError('old-shape-checkpoint')` when the checkpoint still
|
|
220
|
+
* carries a `plan` / `currentWave` / `totalWaves` field (via
|
|
221
|
+
* `assertNotOldShape`). A `plan` / `currentWave` / `totalWaves` comment
|
|
222
|
+
* predates the ready-set cutover (Story #4155); the ready-set runtime
|
|
223
|
+
* would otherwise ignore those fields and re-derive readiness from live
|
|
224
|
+
* labels — silently discarding an in-progress wave-batch run's resume
|
|
225
|
+
* pointer. Refuse with an explicit operator remediation instead.
|
|
226
|
+
*
|
|
227
|
+
* @param {{ read: () => Promise<object|null> }} store
|
|
228
|
+
* @param {number} epicId
|
|
229
|
+
* @returns {Promise<object>} the validated checkpoint state.
|
|
230
|
+
*/
|
|
231
|
+
async function readAndValidateCheckpoint(store, epicId) {
|
|
120
232
|
let state;
|
|
121
233
|
try {
|
|
122
|
-
state = await
|
|
234
|
+
state = await store.read();
|
|
123
235
|
} catch (err) {
|
|
124
236
|
throw new WaveRunnerError('checkpoint-read', err);
|
|
125
237
|
}
|
|
@@ -129,39 +241,30 @@ export async function tick(args = {}) {
|
|
|
129
241
|
`no epic-run-state comment on Epic #${epicId}`,
|
|
130
242
|
);
|
|
131
243
|
}
|
|
132
|
-
|
|
133
|
-
// Fail closed on an old-shape (wave-batch) checkpoint. A `plan` /
|
|
134
|
-
// `currentWave` / `totalWaves` comment predates the ready-set cutover
|
|
135
|
-
// (Story #4155); the ready-set runtime would otherwise ignore those
|
|
136
|
-
// fields and re-derive readiness from live labels — silently discarding
|
|
137
|
-
// an in-progress wave-batch run's resume pointer. Refuse with an explicit
|
|
138
|
-
// operator remediation instead.
|
|
139
244
|
assertNotOldShape(state, epicId);
|
|
245
|
+
return state;
|
|
246
|
+
}
|
|
140
247
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// in-process cache.
|
|
160
|
-
const inFlight = await safeReadInFlight(inFlightReader);
|
|
161
|
-
const inFlightSet = new Set(inFlight);
|
|
162
|
-
let records;
|
|
248
|
+
/**
|
|
249
|
+
* Re-fetch the live Story records (body + labels + issue state) for every
|
|
250
|
+
* Story in scope. The body feeds `buildStoryAdjacency` (inside
|
|
251
|
+
* `selectReadySet`) so the dependency edges are always read from the current
|
|
252
|
+
* ticket text, never a stale checkpoint snapshot. Stories in `inFlightSet`
|
|
253
|
+
* are force-fresh-fetched (`{ fresh: true }`) so a label that flipped since
|
|
254
|
+
* the last tick is observed; every other Story serves from the provider's
|
|
255
|
+
* in-process cache.
|
|
256
|
+
*
|
|
257
|
+
* Throws `WaveRunnerError('story-fetch')` when any `provider.getTicket`
|
|
258
|
+
* rejects.
|
|
259
|
+
*
|
|
260
|
+
* @param {{ getTicket: (id: number, opts?: object) => Promise<object> }} provider
|
|
261
|
+
* @param {number[]} storyIds Ascending, deduped in-scope Story ids.
|
|
262
|
+
* @param {Set<number>} inFlightSet Ledger-derived in-flight Story ids.
|
|
263
|
+
* @returns {Promise<Array<object>>} normalized Story records.
|
|
264
|
+
*/
|
|
265
|
+
async function refetchStoryRecords(provider, storyIds, inFlightSet) {
|
|
163
266
|
try {
|
|
164
|
-
|
|
267
|
+
return await Promise.all(
|
|
165
268
|
storyIds.map(async (id) => {
|
|
166
269
|
const opts = inFlightSet.has(id) ? { fresh: true } : {};
|
|
167
270
|
const ticket = await provider.getTicket(id, opts);
|
|
@@ -185,14 +288,42 @@ export async function tick(args = {}) {
|
|
|
185
288
|
} catch (err) {
|
|
186
289
|
throw new WaveRunnerError('story-fetch', err);
|
|
187
290
|
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Pure dispatch planner — the scheduler tick's decision core with **no I/O**.
|
|
295
|
+
* Given the parsed checkpoint, the live Story records, and the ledger-derived
|
|
296
|
+
* in-flight id list, it classifies every Story, detects a sibling dependency
|
|
297
|
+
* cycle, selects the ready set under the global in-flight cap, and decides the
|
|
298
|
+
* `nextAction`. It performs no fetching, no signal emission, and no ledger
|
|
299
|
+
* read: the two wave-window forensics signals are returned in the `signals`
|
|
300
|
+
* array for the coordinator to drain, so this function stays independently
|
|
301
|
+
* unit-testable against fixture records without a provider stub or an emitter.
|
|
302
|
+
*
|
|
303
|
+
* @param {object} state Parsed `epic-run-state` checkpoint (for the global
|
|
304
|
+
* cap and the per-Story `failed` rows surfaced as gate failures).
|
|
305
|
+
* @param {Array<object>} records Live Story records (id, title, body, labels,
|
|
306
|
+
* state, file-footprint shapes).
|
|
307
|
+
* @param {number[]} inFlight Ledger-derived dispatched-not-yet-ended ids.
|
|
308
|
+
* @returns {{
|
|
309
|
+
* nextAction: object,
|
|
310
|
+
* blockedStories: Array<{ storyId: number, reason: string, detail?: string }>,
|
|
311
|
+
* gateFailures: Array<{ storyId: number, gate: string, detail?: string }>,
|
|
312
|
+
* readyCount: number,
|
|
313
|
+
* signals: Array<object>,
|
|
314
|
+
* }}
|
|
315
|
+
*/
|
|
316
|
+
export function planTick(state, records, inFlight) {
|
|
317
|
+
const globalCap = positiveIntOrZero(state.concurrencyCap);
|
|
318
|
+
const inFlightSet = new Set(inFlight);
|
|
188
319
|
|
|
189
|
-
//
|
|
320
|
+
// 1. Classify by live label. `done` / `blocked` / `executing` / `ready`.
|
|
190
321
|
const byClass = { done: [], blocked: [], executing: [], ready: [] };
|
|
191
322
|
for (const rec of records) {
|
|
192
323
|
byClass[classifyStory(rec)].push(rec);
|
|
193
324
|
}
|
|
194
325
|
|
|
195
|
-
//
|
|
326
|
+
// 1a. Detect a dependency cycle among the in-scope Stories BEFORE selecting.
|
|
196
327
|
// A cycle makes every Story on it permanently un-eligible (no member's
|
|
197
328
|
// deps can all be done), so `selectReadySet` would return an empty set
|
|
198
329
|
// and the terminal decision could otherwise mistake the stall for
|
|
@@ -204,7 +335,7 @@ export async function tick(args = {}) {
|
|
|
204
335
|
const epicAdjacency = buildStoryAdjacency(records, { dropForeign: true });
|
|
205
336
|
const cycle = detectCycle(epicAdjacency);
|
|
206
337
|
|
|
207
|
-
//
|
|
338
|
+
// 2. Select the ready set under the GLOBAL in-flight cap. The selector
|
|
208
339
|
// re-derives adjacency from the live bodies (with `dropForeign: true` so
|
|
209
340
|
// a `blocked by #N` whose target is outside this Epic's Story set — a
|
|
210
341
|
// foreign id or a typo — is pruned rather than treated as a permanent
|
|
@@ -248,14 +379,6 @@ export async function tick(args = {}) {
|
|
|
248
379
|
dropForeign: true,
|
|
249
380
|
});
|
|
250
381
|
|
|
251
|
-
// 4. Best-effort recurring-failure scan (≥2 distinct Stories sharing the
|
|
252
|
-
// same `close-validate.end` failedGate). Idempotent across re-ticks; a
|
|
253
|
-
// reporter throw must not crash the planner.
|
|
254
|
-
const recurringFailureReporter =
|
|
255
|
-
collabRecurringFailureReporter ??
|
|
256
|
-
defaultRecurringFailureReporter({ provider, epicId, config: ctx?.config });
|
|
257
|
-
await safeReportRecurringFailures(recurringFailureReporter);
|
|
258
|
-
|
|
259
382
|
const blockedStories = byClass.blocked.map((s) => ({
|
|
260
383
|
storyId: s.id,
|
|
261
384
|
reason: 'agent::blocked',
|
|
@@ -263,7 +386,7 @@ export async function tick(args = {}) {
|
|
|
263
386
|
}));
|
|
264
387
|
const gateFailures = readGateFailures(state);
|
|
265
388
|
|
|
266
|
-
//
|
|
389
|
+
// 3. Decide nextAction.
|
|
267
390
|
// - A blocked Story halts the Epic → observe (the workflow flips the
|
|
268
391
|
// Epic to agent::blocked and parks).
|
|
269
392
|
// - A dependency cycle among the in-scope Stories halts the Epic → halt
|
|
@@ -278,6 +401,7 @@ export async function tick(args = {}) {
|
|
|
278
401
|
// unsatisfiable dependency that survived adjacency closure). Halt and
|
|
279
402
|
// name the stuck Story ids — never silently report the Epic complete.
|
|
280
403
|
const allDone = byClass.done.length === records.length;
|
|
404
|
+
const signals = [];
|
|
281
405
|
let nextAction;
|
|
282
406
|
if (blockedStories.length) {
|
|
283
407
|
nextAction = {
|
|
@@ -300,7 +424,7 @@ export async function tick(args = {}) {
|
|
|
300
424
|
byClass.done.length === 0 &&
|
|
301
425
|
inFlight.length === 0
|
|
302
426
|
) {
|
|
303
|
-
|
|
427
|
+
signals.push({
|
|
304
428
|
kind: 'wave-start',
|
|
305
429
|
stories: records.map((s) => ({ id: s.id, title: s.title })),
|
|
306
430
|
});
|
|
@@ -319,7 +443,7 @@ export async function tick(args = {}) {
|
|
|
319
443
|
nextAction = { kind: 'observe', waitingOn };
|
|
320
444
|
} else if (allDone) {
|
|
321
445
|
// Every Story is done and nothing is in flight: the run is complete.
|
|
322
|
-
|
|
446
|
+
signals.push({ kind: 'wave-complete' });
|
|
323
447
|
nextAction = { kind: 'epic-complete' };
|
|
324
448
|
} else {
|
|
325
449
|
// Ready set empty, nothing in flight, but not all Stories are done — a
|
|
@@ -338,13 +462,13 @@ export async function tick(args = {}) {
|
|
|
338
462
|
};
|
|
339
463
|
}
|
|
340
464
|
|
|
341
|
-
return
|
|
342
|
-
nextAction
|
|
465
|
+
return {
|
|
466
|
+
nextAction,
|
|
343
467
|
blockedStories,
|
|
344
468
|
gateFailures,
|
|
345
469
|
readyCount: readySet.length,
|
|
346
|
-
|
|
347
|
-
}
|
|
470
|
+
signals,
|
|
471
|
+
};
|
|
348
472
|
}
|
|
349
473
|
|
|
350
474
|
/**
|