mandrel 1.68.0 → 1.69.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/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +2 -4
- package/.agents/schemas/agentrc.schema.json +1 -5
- package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
- package/.agents/scripts/epic-deliver-preflight.js +30 -13
- package/.agents/scripts/epic-deliver-prepare.js +40 -53
- package/.agents/scripts/epic-execute-record-wave.js +119 -133
- package/.agents/scripts/lib/baselines/refresh-service.js +13 -1
- package/.agents/scripts/lib/config/explain.js +0 -2
- package/.agents/scripts/lib/config/limits.js +19 -8
- package/.agents/scripts/lib/config-settings-schema.js +1 -2
- package/.agents/scripts/lib/maintainability-utils.js +32 -9
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
- package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -78
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
- package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
- package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
- package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
- package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
- package/.agents/scripts/lib/story-adjacency.js +14 -10
- package/.agents/scripts/lib/story-body/story-body.js +36 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
- package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
- package/.agents/scripts/lib/wave-runner/tick.js +312 -206
- package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
- package/.agents/scripts/lint-label-vocabulary.js +1 -1
- package/.agents/scripts/stories-wave-tick.js +262 -161
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/deliver.md +12 -9
- package/.agents/workflows/helpers/deliver-epic.md +126 -90
- package/.agents/workflows/helpers/deliver-stories.md +131 -85
- package/.agents/workflows/helpers/plan-epic.md +13 -10
- package/.agents/workflows/plan.md +1 -1
- package/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
|
@@ -15,11 +15,28 @@ import {
|
|
|
15
15
|
* 2-tier is the only published hierarchy after Story #4041 removed the
|
|
16
16
|
* Feature tier: the prompt emits Stories only (direct Epic children) and
|
|
17
17
|
* asks the planner to carry acceptance/verify as top-level ticket arrays.
|
|
18
|
+
*
|
|
19
|
+
* **Single source of the prompt body (Story #4162).** This module is the sole
|
|
20
|
+
* carrier of the full decomposer system-prompt body. The
|
|
21
|
+
* `epic-plan-decompose-author` SKILL no longer embeds a second verbatim copy —
|
|
22
|
+
* it references this rendered prompt (delivered to the host LLM in the
|
|
23
|
+
* `systemPrompt` field of the authoring context envelope built by
|
|
24
|
+
* `epic-plan-decompose/phases/context.js`) instead, so the two surfaces cannot
|
|
25
|
+
* drift. A guard test (`tests/ticket-decomposer.test.js`) fails if the SKILL
|
|
26
|
+
* re-grows a full copy of the prompt preamble.
|
|
27
|
+
*
|
|
28
|
+
* **Token-budget sizing input (Story #4162).** `maxTokenBudget` is the real
|
|
29
|
+
* one-pass delivery envelope (the task-prompt hydration cap surfaced into the
|
|
30
|
+
* authoring envelope by `context.js`, Story #3875). It is threaded into the
|
|
31
|
+
* rendered prompt as a sizing input so the planner sizes Stories against the
|
|
32
|
+
* envelope a single agent can actually deliver in one pass, rather than leading
|
|
33
|
+
* with the file-count proxy alone.
|
|
18
34
|
*/
|
|
19
35
|
export function renderDecomposerSystemPrompt({
|
|
20
36
|
maxTickets = LIMITS_DEFAULTS.maxTickets,
|
|
37
|
+
maxTokenBudget = LIMITS_DEFAULTS.maxTokenBudget,
|
|
21
38
|
} = {}) {
|
|
22
|
-
return render2TierPrompt({ maxTickets });
|
|
39
|
+
return render2TierPrompt({ maxTickets, maxTokenBudget });
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
/**
|
|
@@ -28,7 +45,7 @@ export function renderDecomposerSystemPrompt({
|
|
|
28
45
|
* on the Story body so the executing agent has everything it needs in one
|
|
29
46
|
* ticket. Thematic grouping lives as prose in the Epic body / Tech Spec.
|
|
30
47
|
*/
|
|
31
|
-
function render2TierPrompt({ maxTickets }) {
|
|
48
|
+
function render2TierPrompt({ maxTickets, maxTokenBudget }) {
|
|
32
49
|
// Sizing thresholds are sourced from the single DEFAULT_TASK_SIZING constant
|
|
33
50
|
// (ticket-validator-sizing.js) so the prompt and the validator cannot drift.
|
|
34
51
|
const { softFiles, hardFiles, maxAcceptance, softAcceptanceCount } =
|
|
@@ -103,6 +120,7 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
103
120
|
- **changes** (in body string): Each entry is an object \`{ path, assumption }\` where \`assumption\` is one of \`creates | refactors-existing | deletes\`. Acceptable path shapes include explicit files (\`src/components/Foo.tsx\`), glob patterns (\`tests/e2e/*.spec.ts\`, \`**/*.astro\`), and module identifiers that resolve to files. Use \`refactors-existing\` for in-place edits to a file already on \`main\`; \`creates\` for net-new files; \`deletes\` for removals.
|
|
104
121
|
- **acceptance** (top-level array on the ticket object): Items MUST be observable from outside the agent. Acceptable shapes: a specific command exits 0, a file exists at a given path, a snapshot test matches, a \`data-testid\` resolves under a given selector, a row count in a fixture matches. UNACCEPTABLE: "verify by reading the diff", "looks good", "matches the spec" — push these down into a \`verify\` command instead.
|
|
105
122
|
- **verify** (top-level array on the ticket object): Each entry MUST name a testing tier in parentheses, drawn from \`unit\` / \`contract\` / \`e2e\` / \`validate\`. Example: \`npm run test -- src/x.test.ts (unit)\`, \`npm run validate (validate)\`. Stories with zero verify entries SHOULD fail validation; if a story is genuinely unverifiable in isolation (e.g., a copy edit auditor will eyeball), the literal entry \`manual:<reason>\` is allowed so the absence is intentional, not lazy. Manual entries without a reason are rejected.
|
|
123
|
+
- **reason to exist** (REQUIRED, encoded as the \`reason_to_exist\` field of the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): One sentence stating the single coherent reason this Story exists, distinct from its broader \`## Goal\` prose. Every Story MUST carry a non-empty \`reason_to_exist\`; it is the machine-checkable form of the cohesion rule (**one Story = one coherent change with one reason to exist**) and the \`epic-plan-consolidate\` critic flags any Story whose body carries no non-empty reason to exist. Encode it as \`<!-- meta: {"reason_to_exist": "..."} -->\`.
|
|
106
124
|
- **estimated_test_files** (optional, encoded in the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): Integer estimate of how many test files this Story creates or modifies. Omit when the number is not estimable. Informational only — it does not gate the decompose.
|
|
107
125
|
|
|
108
126
|
#### STORY SIZING — COHESION FIRST (the numeric ceiling is only a backstop):
|
|
@@ -111,7 +129,9 @@ The serialized \`body\` string renders these markdown sections (in order):
|
|
|
111
129
|
|
|
112
130
|
The primary question is **cohesion, not count**: *is this one coherent change with one reason to exist?* File count cannot tell a trivial ${softFiles}-file rename from a hard 3-file parser+caller+config change — so lead with the change's reason, not its size.
|
|
113
131
|
|
|
114
|
-
-
|
|
132
|
+
**Size against the real one-pass delivery envelope.** Each Story is delivered and self-verified by a single agent in one pass, whose context is capped by the delivery token budget \`maxTokenBudget = ${maxTokenBudget}\` tokens (the task-prompt hydration cap). Use that envelope — not the file count alone — as the leading sizing input: a Story is correctly sized when one agent can hold its full change, acceptance, and verification in a single pass within \`maxTokenBudget\`. The numeric file thresholds below are a coarse backstop on top of this envelope, not the primary signal.
|
|
133
|
+
|
|
134
|
+
- **One Story = one coherent change with one reason to exist.** If you cannot state that reason in a sentence, the Story is probably two Stories — or two Stories that should be one. State that sentence explicitly in the Story's \`reason_to_exist\` meta field (see STORY BODY RULES) so the consolidate critic can check it.
|
|
115
135
|
- ${singleConsumerRule}
|
|
116
136
|
- **Split independent, parallelizable work** into sibling Stories — but only when the pieces genuinely have separate reasons to exist.
|
|
117
137
|
- **Declare \`wide\` with a one-line reason when a change is legitimately broad** (a cohesive cutover that spans many files for one reason). Declaring \`wide\` lifts the hard file-width ceiling — see below.
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/wave-runner/ready-set.js — the path-agnostic ready-set scheduling
|
|
3
|
+
* core.
|
|
4
|
+
*
|
|
5
|
+
* This module is the scheduling kernel both the Epic and standalone
|
|
6
|
+
* delivery paths dispatch through. It replaces wave-*batch* selection
|
|
7
|
+
* (group N must fully drain before group N+1 opens) with *continuous*,
|
|
8
|
+
* dependency-driven selection: a Story becomes dispatchable the instant
|
|
9
|
+
* **its own** dependencies are satisfied, regardless of whether unrelated
|
|
10
|
+
* Stories in some nominal wave are still running. There is no false
|
|
11
|
+
* barrier — a Story C that depends only on a done Story A is selected even
|
|
12
|
+
* while an unrelated Story B is still pending.
|
|
13
|
+
*
|
|
14
|
+
* It is deliberately **path-agnostic and side-effect-free**: it neither
|
|
15
|
+
* reads GitHub, the lifecycle ledger, nor a checkpoint, and it dispatches
|
|
16
|
+
* nothing. Callers supply the live Story records (already fetched), the
|
|
17
|
+
* resolved `inFlight` count, and the `globalCap`, and receive back the set
|
|
18
|
+
* of Stories that are safe to dispatch on this beat. Later Stories wire the
|
|
19
|
+
* Epic / standalone adapters on top of this core; this Story ships the core
|
|
20
|
+
* alone and does not modify `tick.js` or `stories-wave-tick.js`.
|
|
21
|
+
*
|
|
22
|
+
* Three exports:
|
|
23
|
+
* - `classifyStory(story)` — live-label classifier mapping a Story
|
|
24
|
+
* record's labels + issue state to one of `done | blocked | executing |
|
|
25
|
+
* ready`. Mirrors the done-predicate `tick.js` already uses
|
|
26
|
+
* (`agent::done` OR closed issue) so a Story closed manually through
|
|
27
|
+
* the GitHub UI is recognised as done.
|
|
28
|
+
* - `storiesOverlap(a, b)` — the file-overlap co-dispatch guard: true
|
|
29
|
+
* when two Stories' declared file footprints intersect. Two Stories
|
|
30
|
+
* that would touch the same file MUST NOT be dispatched onto parallel
|
|
31
|
+
* `story-<id>` branches in the same beat (they would race the same
|
|
32
|
+
* path and produce a merge conflict at close).
|
|
33
|
+
* - `selectReadySet({ stories, doneIds, inFlight, globalCap })` — the
|
|
34
|
+
* scheduler. Returns the deterministic, overlap-free set of ready
|
|
35
|
+
* Stories, capped at `globalCap − inFlight`.
|
|
36
|
+
*
|
|
37
|
+
* Adjacency is re-derived from the supplied records via the shared
|
|
38
|
+
* `buildStoryAdjacency` builder (`lib/story-adjacency.js`) — the same
|
|
39
|
+
* `blocked by #NNN` / `dependencies[]` source order the dispatch manifest
|
|
40
|
+
* and the existing wave wrappers use — so this core never disagrees with
|
|
41
|
+
* the manifest about what depends on what.
|
|
42
|
+
*
|
|
43
|
+
* @module lib/wave-runner/ready-set
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { AGENT_LABELS } from '../label-constants.js';
|
|
47
|
+
import { buildStoryAdjacency } from '../story-adjacency.js';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {object} StoryRecord
|
|
51
|
+
* @property {number|string} [id] Story id (preferred).
|
|
52
|
+
* @property {number} [number] Story id (GitHub issue-number shape).
|
|
53
|
+
* @property {string} [title]
|
|
54
|
+
* @property {string} [body] Used by `buildStoryAdjacency` to parse
|
|
55
|
+
* `blocked by #NNN` / `depends on #NNN` references.
|
|
56
|
+
* @property {string[]} [labels] Live `agent::*` labels.
|
|
57
|
+
* @property {string} [state] GitHub issue state (`open` | `closed`).
|
|
58
|
+
* @property {Array<number|string>} [dependencies] Explicit dependency ids.
|
|
59
|
+
* @property {Array<number|string>} [dependsOn] Operator-DAG dependency ids.
|
|
60
|
+
* @property {string[]} [files] Declared file footprint (one of the
|
|
61
|
+
* accepted footprint shapes — see `storyFootprint`).
|
|
62
|
+
* @property {string[]} [changes] Alternate footprint shape.
|
|
63
|
+
* @property {Array<{path?: string}>} [changeset] Alternate footprint shape.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/** @typedef {'done'|'blocked'|'executing'|'ready'} StoryClass */
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize a Story record's id to a positive integer, or `null` when it is
|
|
70
|
+
* absent / non-integer. Accepts both the ticket shape (`id`) and the raw
|
|
71
|
+
* GitHub issue shape (`number`), matching `buildStoryAdjacency`.
|
|
72
|
+
*
|
|
73
|
+
* @param {StoryRecord|number|string} story
|
|
74
|
+
* @returns {number|null}
|
|
75
|
+
*/
|
|
76
|
+
export function storyIdOf(story) {
|
|
77
|
+
if (typeof story === 'number') {
|
|
78
|
+
return Number.isInteger(story) && story > 0 ? story : null;
|
|
79
|
+
}
|
|
80
|
+
const raw = story?.id ?? story?.number;
|
|
81
|
+
const id = Number(raw);
|
|
82
|
+
return Number.isInteger(id) && id > 0 ? id : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Classify a Story from its **live** labels and issue state.
|
|
87
|
+
*
|
|
88
|
+
* Precedence (highest first):
|
|
89
|
+
* 1. `done` — carries `agent::done` OR the issue is `state === 'closed'`.
|
|
90
|
+
* The closed-state arm aligns with `tick.js#isStoryDone`
|
|
91
|
+
* so a Story closed manually in the GitHub UI (issue
|
|
92
|
+
* closed, label not flipped) still reads as done and is
|
|
93
|
+
* never re-dispatched.
|
|
94
|
+
* 2. `blocked` — carries `agent::blocked`.
|
|
95
|
+
* 3. `executing` — carries `agent::executing` OR `agent::closing` (both
|
|
96
|
+
* are in-flight: an executing or closing Story occupies a
|
|
97
|
+
* slot and must not be re-dispatched).
|
|
98
|
+
* 4. `ready` — none of the above; the Story is eligible for dispatch
|
|
99
|
+
* once its dependencies are satisfied.
|
|
100
|
+
*
|
|
101
|
+
* `done` wins over every in-progress label so a stale `agent::executing`
|
|
102
|
+
* left behind on an issue that has since closed never masks completion.
|
|
103
|
+
*
|
|
104
|
+
* @param {StoryRecord} story
|
|
105
|
+
* @returns {StoryClass}
|
|
106
|
+
*/
|
|
107
|
+
export function classifyStory(story) {
|
|
108
|
+
const labels = Array.isArray(story?.labels) ? story.labels : [];
|
|
109
|
+
if (labels.includes(AGENT_LABELS.DONE) || story?.state === 'closed') {
|
|
110
|
+
return 'done';
|
|
111
|
+
}
|
|
112
|
+
if (labels.includes(AGENT_LABELS.BLOCKED)) return 'blocked';
|
|
113
|
+
if (
|
|
114
|
+
labels.includes(AGENT_LABELS.EXECUTING) ||
|
|
115
|
+
labels.includes(AGENT_LABELS.CLOSING)
|
|
116
|
+
) {
|
|
117
|
+
return 'executing';
|
|
118
|
+
}
|
|
119
|
+
return 'ready';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Extract a Story's declared file footprint as a normalized set of path
|
|
124
|
+
* strings. Accepts the three footprint shapes a Story record can carry:
|
|
125
|
+
*
|
|
126
|
+
* - `files: string[]` — explicit footprint.
|
|
127
|
+
* - `changes: string[]` — string-array sketch.
|
|
128
|
+
* - `changeset: Array<{ path }>` / — object-array sketch (the
|
|
129
|
+
* `changes: Array<{ path }>` `{ path, assumption }`
|
|
130
|
+
* shape from a Story body).
|
|
131
|
+
*
|
|
132
|
+
* Paths are trimmed; empty / non-string entries are dropped. A Story with
|
|
133
|
+
* no declared footprint yields an empty set, which (by `storiesOverlap`'s
|
|
134
|
+
* contract) means it overlaps with nothing and is never withheld by the
|
|
135
|
+
* co-dispatch guard.
|
|
136
|
+
*
|
|
137
|
+
* @param {StoryRecord} story
|
|
138
|
+
* @returns {Set<string>}
|
|
139
|
+
*/
|
|
140
|
+
export function storyFootprint(story) {
|
|
141
|
+
const out = new Set();
|
|
142
|
+
const push = (entry) => {
|
|
143
|
+
const path =
|
|
144
|
+
typeof entry === 'string'
|
|
145
|
+
? entry
|
|
146
|
+
: typeof entry?.path === 'string'
|
|
147
|
+
? entry.path
|
|
148
|
+
: null;
|
|
149
|
+
if (!path) return;
|
|
150
|
+
const trimmed = path.trim();
|
|
151
|
+
if (trimmed) out.add(trimmed);
|
|
152
|
+
};
|
|
153
|
+
if (Array.isArray(story?.files)) for (const e of story.files) push(e);
|
|
154
|
+
if (Array.isArray(story?.changes)) for (const e of story.changes) push(e);
|
|
155
|
+
if (Array.isArray(story?.changeset)) for (const e of story.changeset) push(e);
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* File-overlap co-dispatch guard. Returns `true` when two Stories' declared
|
|
161
|
+
* file footprints intersect on at least one path — meaning they would race
|
|
162
|
+
* the same file if dispatched onto parallel `story-<id>` branches in the
|
|
163
|
+
* same beat. Two Stories that overlap MUST NOT both appear in one dispatch
|
|
164
|
+
* set; one is withheld until the other clears.
|
|
165
|
+
*
|
|
166
|
+
* An empty footprint on either side means "no known overlap" → `false`. A
|
|
167
|
+
* Story that declares no files is therefore never withheld by this guard.
|
|
168
|
+
*
|
|
169
|
+
* @param {StoryRecord} a
|
|
170
|
+
* @param {StoryRecord} b
|
|
171
|
+
* @returns {boolean}
|
|
172
|
+
*/
|
|
173
|
+
export function storiesOverlap(a, b) {
|
|
174
|
+
const fa = storyFootprint(a);
|
|
175
|
+
if (fa.size === 0) return false;
|
|
176
|
+
const fb = storyFootprint(b);
|
|
177
|
+
if (fb.size === 0) return false;
|
|
178
|
+
for (const path of fa) {
|
|
179
|
+
if (fb.has(path)) return true;
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Select the set of Stories safe to dispatch on this beat.
|
|
186
|
+
*
|
|
187
|
+
* Algorithm (continuous, dependency-driven — no wave barrier):
|
|
188
|
+
*
|
|
189
|
+
* 1. **Adjacency.** Re-derive `Map<id, depIds[]>` from the supplied
|
|
190
|
+
* records via `buildStoryAdjacency`. The `dropForeign` flag controls
|
|
191
|
+
* how a dependency on an id **outside** the supplied set is treated:
|
|
192
|
+
* - `dropForeign: false` (default, standalone-path semantics) — the
|
|
193
|
+
* foreign dependency still gates the dependent: an absent dependency
|
|
194
|
+
* is treated as not-yet-done and withholds the dependent until it
|
|
195
|
+
* completes (preserves the operator-DAG contract).
|
|
196
|
+
* - `dropForeign: true` (Epic-path semantics) — a foreign edge is
|
|
197
|
+
* pruned so the DAG stays closed over the scheduled Story set. An
|
|
198
|
+
* Epic's Stories depend only on siblings, so a `blocked by #N` whose
|
|
199
|
+
* target is out-of-scope (a foreign id, or a typo) must be dropped,
|
|
200
|
+
* not treated as a permanent unsatisfiable gate — otherwise the
|
|
201
|
+
* dependent Story is never schedulable and the run silently strands
|
|
202
|
+
* it. This matches `build-wave-dag.js`, which builds the Epic
|
|
203
|
+
* wave DAG with the same default-`dropForeign` builder.
|
|
204
|
+
* 2. **Done set.** Union the caller-supplied `doneIds` with every record
|
|
205
|
+
* that classifies as `done` (live label / closed issue). A Story's
|
|
206
|
+
* dependency counts as satisfied iff it is in this union.
|
|
207
|
+
* 3. **Eligibility.** A Story is *eligible* when it classifies as `ready`
|
|
208
|
+
* (not done / blocked / executing) **and** every one of its
|
|
209
|
+
* dependencies is in the done set. This is the no-false-barrier
|
|
210
|
+
* property: C depending only on A is eligible the instant A is done,
|
|
211
|
+
* even while an unrelated B is still pending.
|
|
212
|
+
* 4. **Capacity.** The dispatch set never exceeds `slots = max(0,
|
|
213
|
+
* globalCap − inFlight)`. `inFlight` is the caller's count of Stories
|
|
214
|
+
* already occupying a slot (executing / closing / dispatched-not-yet-
|
|
215
|
+
* labelled). When `slots <= 0`, the result is empty.
|
|
216
|
+
* 5. **Overlap guard.** Greedily admit eligible Stories in ascending-id
|
|
217
|
+
* order, skipping any whose file footprint overlaps an
|
|
218
|
+
* already-admitted Story (`storiesOverlap`). A withheld Story stays
|
|
219
|
+
* eligible and is naturally re-considered on the next beat once its
|
|
220
|
+
* overlapping peer has cleared.
|
|
221
|
+
*
|
|
222
|
+
* The result is deterministic: eligible Stories are considered in
|
|
223
|
+
* ascending-id order, so the same inputs always yield the same set.
|
|
224
|
+
*
|
|
225
|
+
* @param {object} args
|
|
226
|
+
* @param {StoryRecord[]} args.stories Live Story records in scope.
|
|
227
|
+
* @param {Array<number|string>|Set<number|string>} [args.doneIds]
|
|
228
|
+
* Ids the caller already knows are done (e.g. from a prior beat). Merged
|
|
229
|
+
* with records that classify as done.
|
|
230
|
+
* @param {number} [args.inFlight=0] Count of Stories already occupying a
|
|
231
|
+
* slot. Subtracted from `globalCap` to compute remaining capacity.
|
|
232
|
+
* @param {number} args.globalCap Hard ceiling on total concurrent
|
|
233
|
+
* Stories.
|
|
234
|
+
* @param {boolean} [args.dropForeign=false] Adjacency closure policy (see
|
|
235
|
+
* step 1 above). `false` keeps a foreign dependency as a gate
|
|
236
|
+
* (standalone / operator-DAG semantics); `true` prunes foreign edges so
|
|
237
|
+
* the DAG stays closed over the scheduled set (Epic semantics).
|
|
238
|
+
* @returns {StoryRecord[]} The dispatch set: a subset of `stories`,
|
|
239
|
+
* ascending by id, overlap-free, length ≤ `globalCap − inFlight`.
|
|
240
|
+
*/
|
|
241
|
+
export function selectReadySet({
|
|
242
|
+
stories,
|
|
243
|
+
doneIds = [],
|
|
244
|
+
inFlight = 0,
|
|
245
|
+
globalCap,
|
|
246
|
+
dropForeign = false,
|
|
247
|
+
} = {}) {
|
|
248
|
+
const records = Array.isArray(stories) ? stories : [];
|
|
249
|
+
const cap = Number.isInteger(globalCap) ? globalCap : 0;
|
|
250
|
+
const inFlightCount =
|
|
251
|
+
Number.isInteger(inFlight) && inFlight > 0 ? inFlight : 0;
|
|
252
|
+
const slots = Math.max(0, cap - inFlightCount);
|
|
253
|
+
if (slots <= 0 || records.length === 0) return [];
|
|
254
|
+
|
|
255
|
+
// Step 1 — adjacency keyed by id. The `dropForeign` policy decides whether
|
|
256
|
+
// a dependency on an id outside the supplied set gates the dependent
|
|
257
|
+
// (false) or is pruned (true). See the JSDoc above for the per-path
|
|
258
|
+
// rationale.
|
|
259
|
+
const adjacency = buildStoryAdjacency(records, { dropForeign });
|
|
260
|
+
|
|
261
|
+
// Step 2 — done set = caller-supplied ids ∪ records that classify done.
|
|
262
|
+
const done = new Set();
|
|
263
|
+
for (const raw of doneIds instanceof Set ? doneIds : (doneIds ?? [])) {
|
|
264
|
+
const id = Number(raw);
|
|
265
|
+
if (Number.isInteger(id)) done.add(id);
|
|
266
|
+
}
|
|
267
|
+
const byId = new Map();
|
|
268
|
+
for (const rec of records) {
|
|
269
|
+
const id = storyIdOf(rec);
|
|
270
|
+
if (id === null) continue;
|
|
271
|
+
byId.set(id, rec);
|
|
272
|
+
if (classifyStory(rec) === 'done') done.add(id);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Step 3 — eligible: ready AND all dependencies done. Ascending id for
|
|
276
|
+
// deterministic admission order.
|
|
277
|
+
const eligibleIds = [];
|
|
278
|
+
for (const id of [...byId.keys()].sort((a, b) => a - b)) {
|
|
279
|
+
const rec = byId.get(id);
|
|
280
|
+
if (classifyStory(rec) !== 'ready') continue;
|
|
281
|
+
const deps = adjacency.get(id) ?? [];
|
|
282
|
+
if (deps.every((dep) => done.has(dep))) eligibleIds.push(id);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
|
|
286
|
+
// collisions against the already-admitted set.
|
|
287
|
+
const selected = [];
|
|
288
|
+
for (const id of eligibleIds) {
|
|
289
|
+
if (selected.length >= slots) break;
|
|
290
|
+
const rec = byId.get(id);
|
|
291
|
+
if (selected.some((picked) => storiesOverlap(picked, rec))) continue;
|
|
292
|
+
selected.push(rec);
|
|
293
|
+
}
|
|
294
|
+
return selected;
|
|
295
|
+
}
|