@lumenflow/initiatives 2.18.2 → 2.19.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/dist/initiative-orchestrator.d.ts +19 -469
- package/dist/initiative-orchestrator.d.ts.map +1 -1
- package/dist/initiative-orchestrator.js +25 -1783
- package/dist/initiative-orchestrator.js.map +1 -1
- package/dist/orchestrator/checkpoint.d.ts +105 -0
- package/dist/orchestrator/checkpoint.d.ts.map +1 -0
- package/dist/orchestrator/checkpoint.js +444 -0
- package/dist/orchestrator/checkpoint.js.map +1 -0
- package/dist/orchestrator/execution-planning.d.ts +50 -0
- package/dist/orchestrator/execution-planning.d.ts.map +1 -0
- package/dist/orchestrator/execution-planning.js +364 -0
- package/dist/orchestrator/execution-planning.js.map +1 -0
- package/dist/orchestrator/formatting.d.ts +101 -0
- package/dist/orchestrator/formatting.d.ts.map +1 -0
- package/dist/orchestrator/formatting.js +415 -0
- package/dist/orchestrator/formatting.js.map +1 -0
- package/dist/orchestrator/index.d.ts +14 -0
- package/dist/orchestrator/index.d.ts.map +1 -0
- package/dist/orchestrator/index.js +14 -0
- package/dist/orchestrator/index.js.map +1 -0
- package/dist/orchestrator/initiative-loading.d.ts +30 -0
- package/dist/orchestrator/initiative-loading.d.ts.map +1 -0
- package/dist/orchestrator/initiative-loading.js +51 -0
- package/dist/orchestrator/initiative-loading.js.map +1 -0
- package/dist/orchestrator/lane-policy.d.ts +44 -0
- package/dist/orchestrator/lane-policy.d.ts.map +1 -0
- package/dist/orchestrator/lane-policy.js +129 -0
- package/dist/orchestrator/lane-policy.js.map +1 -0
- package/dist/orchestrator/shared.d.ts +49 -0
- package/dist/orchestrator/shared.d.ts.map +1 -0
- package/dist/orchestrator/shared.js +57 -0
- package/dist/orchestrator/shared.js.map +1 -0
- package/dist/orchestrator/spawn-status.d.ts +56 -0
- package/dist/orchestrator/spawn-status.d.ts.map +1 -0
- package/dist/orchestrator/spawn-status.js +95 -0
- package/dist/orchestrator/spawn-status.js.map +1 -0
- package/dist/orchestrator/types.d.ts +148 -0
- package/dist/orchestrator/types.d.ts.map +1 -0
- package/dist/orchestrator/types.js +9 -0
- package/dist/orchestrator/types.js.map +1 -0
- package/package.json +2 -2
|
@@ -1,1792 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Initiative Orchestrator (WU-1581, WU-1821)
|
|
2
|
+
* Initiative Orchestrator (WU-1581, WU-1821, WU-1648)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Thin composition layer that re-exports domain modules for initiative orchestration.
|
|
5
|
+
* Actual logic lives in the orchestrator/ subdirectory, split by domain responsibility:
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
* - Wave manifest files for idempotent resumption
|
|
16
|
-
* - Compact output for token discipline
|
|
7
|
+
* - orchestrator/types.ts -- Shared type definitions
|
|
8
|
+
* - orchestrator/shared.ts -- Shared utilities (hasStamp, getAllDependencies)
|
|
9
|
+
* - orchestrator/execution-planning.ts -- Wave-based execution plan building
|
|
10
|
+
* - orchestrator/checkpoint.ts -- Checkpoint mode, wave manifests, auto-detection
|
|
11
|
+
* - orchestrator/formatting.ts -- Output formatting (plans, progress, spawn XML)
|
|
12
|
+
* - orchestrator/spawn-status.ts -- WU spawn status checking
|
|
13
|
+
* - orchestrator/lane-policy.ts -- Lane lock policy management
|
|
14
|
+
* - orchestrator/initiative-loading.ts -- Initiative/WU loading
|
|
17
15
|
*
|
|
18
16
|
* @see {@link packages/@lumenflow/cli/src/orchestrate-initiative.ts} - CLI entry point
|
|
19
17
|
* @see {@link packages/@lumenflow/cli/src/lib/initiative-yaml.ts} - Initiative loading
|
|
20
18
|
* @see {@link packages/@lumenflow/cli/src/lib/dependency-graph.ts} - Dependency graph utilities
|
|
21
19
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
* @param {object} doc - WU document
|
|
37
|
-
* @returns {string[]} Combined list of all dependency WU IDs (deduplicated)
|
|
38
|
-
*/
|
|
39
|
-
function getAllDependencies(doc) {
|
|
40
|
-
const blockedBy = doc.blocked_by ?? [];
|
|
41
|
-
const dependencies = doc.dependencies ?? [];
|
|
42
|
-
// Combine and deduplicate
|
|
43
|
-
const allDeps = new Set([...blockedBy, ...dependencies]);
|
|
44
|
-
return Array.from(allDeps);
|
|
45
|
-
}
|
|
46
|
-
import { createError, ErrorCodes } from '@lumenflow/core/error-handler';
|
|
47
|
-
import { WU_STATUS, STRING_LITERALS } from '@lumenflow/core/wu-constants';
|
|
48
|
-
import { WU_PATHS } from '@lumenflow/core/wu-paths';
|
|
49
|
-
import { parseYAML } from '@lumenflow/core/wu-yaml';
|
|
50
|
-
// WU-2027: Import spawn generation for embedding in orchestration output
|
|
51
|
-
import { generateTaskInvocation } from '@lumenflow/core/wu-spawn';
|
|
52
|
-
import { SpawnStrategyFactory } from '@lumenflow/core/spawn-strategy';
|
|
53
|
-
/**
|
|
54
|
-
* Wave manifest directory path (gitignored).
|
|
55
|
-
*/
|
|
56
|
-
const WAVE_MANIFEST_DIR = '.lumenflow/artifacts/waves';
|
|
57
|
-
/**
|
|
58
|
-
* Stamps directory path.
|
|
59
|
-
*/
|
|
60
|
-
const STAMPS_DIR = '.lumenflow/stamps';
|
|
61
|
-
/**
|
|
62
|
-
* Log prefix for orchestrator messages.
|
|
63
|
-
*/
|
|
64
|
-
const LOG_PREFIX = '[orchestrate:initiative]';
|
|
65
|
-
/**
|
|
66
|
-
* WU-2280: Banner separator for ACTION REQUIRED output.
|
|
67
|
-
* Used to make it unambiguous that agents have NOT been spawned yet.
|
|
68
|
-
*/
|
|
69
|
-
const BANNER_SEPARATOR = '==============================================================================';
|
|
70
|
-
/**
|
|
71
|
-
* WU-2040: XML tag patterns for Task invocation extraction.
|
|
72
|
-
* Split to avoid XML parsing issues in agent tools.
|
|
73
|
-
*/
|
|
74
|
-
const ANTML_NS = 'antml:';
|
|
75
|
-
const XML_PATTERNS = {
|
|
76
|
-
FUNCTION_CALLS_OPEN: `<${ANTML_NS}function_calls>`,
|
|
77
|
-
FUNCTION_CALLS_CLOSE: `</${ANTML_NS}function_calls>`,
|
|
78
|
-
INVOKE_OPEN: `<${ANTML_NS}invoke`,
|
|
79
|
-
INVOKE_CLOSE: `</${ANTML_NS}invoke>`,
|
|
80
|
-
};
|
|
81
|
-
/**
|
|
82
|
-
* WU-1828: Auto-detection thresholds for checkpoint mode.
|
|
83
|
-
*
|
|
84
|
-
* These thresholds determine when checkpoint mode is automatically enabled
|
|
85
|
-
* to prevent "prompt too long" errors for large initiatives.
|
|
86
|
-
*
|
|
87
|
-
* @type {{WU_COUNT: number, WAVE_COUNT: number}}
|
|
88
|
-
*/
|
|
89
|
-
export const CHECKPOINT_AUTO_THRESHOLDS = {
|
|
90
|
-
/** Auto-enable checkpoint mode if pending WU count exceeds this (>3 = 4+) */
|
|
91
|
-
WU_COUNT: 3,
|
|
92
|
-
/** Auto-enable checkpoint mode if wave count exceeds this (>2 = 3+) */
|
|
93
|
-
WAVE_COUNT: 2,
|
|
94
|
-
};
|
|
95
|
-
/**
|
|
96
|
-
* WU-1200: Wave manifest WU status constant.
|
|
97
|
-
*
|
|
98
|
-
* Changed from 'spawned' to 'queued' to prevent confusion.
|
|
99
|
-
* 'spawned' implies an agent was launched, but the manifest is written
|
|
100
|
-
* BEFORE an agent is actually invoked (when the prompt is output, not when
|
|
101
|
-
* the user copies and executes it).
|
|
102
|
-
*
|
|
103
|
-
* Using 'queued' makes it clear the WU is ready to be spawned but not yet running.
|
|
104
|
-
*/
|
|
105
|
-
const MANIFEST_WU_STATUS = 'queued';
|
|
106
|
-
/**
|
|
107
|
-
* Default reason string for deferred WUs when no specific reason is provided.
|
|
108
|
-
* Extracted to constant to avoid sonarjs/no-duplicate-string lint warnings.
|
|
109
|
-
*/
|
|
110
|
-
const DEFAULT_DEFERRED_REASON = 'waiting for dependencies';
|
|
111
|
-
/**
|
|
112
|
-
* WU-1200: Get the status string used in wave manifests for WUs.
|
|
113
|
-
*
|
|
114
|
-
* Returns 'queued' instead of 'spawned' to prevent confusion.
|
|
115
|
-
* A WU is 'queued' in the manifest when the spawn prompt is output,
|
|
116
|
-
* but it's not actually 'spawned' until an agent claims it.
|
|
117
|
-
*
|
|
118
|
-
* @returns {string} The manifest WU status ('queued')
|
|
119
|
-
*/
|
|
120
|
-
export function getManifestWUStatus() {
|
|
121
|
-
return MANIFEST_WU_STATUS;
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* WU-1200: Check if a WU has actually been spawned (agent launched).
|
|
125
|
-
*
|
|
126
|
-
* This checks the WU YAML status, not the wave manifest. A WU is considered
|
|
127
|
-
* "actually spawned" only if:
|
|
128
|
-
* - Its YAML status is 'in_progress' (agent has claimed it)
|
|
129
|
-
* - OR its YAML status is 'done' (agent has completed it)
|
|
130
|
-
*
|
|
131
|
-
* Wave manifests can have stale 'spawned' statuses from previous runs where
|
|
132
|
-
* the prompt was output but no agent was ever invoked. This function provides
|
|
133
|
-
* the authoritative check based on YAML status.
|
|
134
|
-
*
|
|
135
|
-
* @param {string} wuId - WU ID (e.g., 'WU-001')
|
|
136
|
-
* @returns {boolean} True if the WU is actually in progress or done
|
|
137
|
-
*/
|
|
138
|
-
export function isWUActuallySpawned(wuId) {
|
|
139
|
-
const wuPath = WU_PATHS.WU(wuId);
|
|
140
|
-
if (!existsSync(wuPath)) {
|
|
141
|
-
// WU file not found - can't determine status, assume not spawned
|
|
142
|
-
return false;
|
|
143
|
-
}
|
|
144
|
-
try {
|
|
145
|
-
const text = readFileSync(wuPath, 'utf8');
|
|
146
|
-
const doc = parseYAML(text);
|
|
147
|
-
const status = doc.status ?? 'unknown';
|
|
148
|
-
// WU is "actually spawned" if status indicates active or completed work
|
|
149
|
-
return status === WU_STATUS.IN_PROGRESS || status === WU_STATUS.DONE;
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
// Error reading/parsing WU file - assume not spawned
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* WU-1200: Get spawn candidates with YAML status verification.
|
|
158
|
-
*
|
|
159
|
-
* Filters WUs to find candidates that can be spawned, checking YAML status
|
|
160
|
-
* instead of relying solely on wave manifests. This prevents stale manifests
|
|
161
|
-
* from blocking new orchestration runs.
|
|
162
|
-
*
|
|
163
|
-
* A WU is a spawn candidate if:
|
|
164
|
-
* - Its YAML status is 'ready' (not in_progress, done, blocked, etc.)
|
|
165
|
-
* - It's in the provided WU list (part of the initiative)
|
|
166
|
-
*
|
|
167
|
-
* This function ignores wave manifest status because:
|
|
168
|
-
* - Manifests can be stale (prompt output but agent never launched)
|
|
169
|
-
* - YAML status is the authoritative source of truth
|
|
170
|
-
*
|
|
171
|
-
* @param {string} _initId - Initiative ID (for logging/context, not used for filtering)
|
|
172
|
-
* @param {Array<{id: string, doc: object}>} wus - WUs to filter
|
|
173
|
-
* @returns {Array<{id: string, doc: object}>} WUs that can be spawned
|
|
174
|
-
*/
|
|
175
|
-
export function getSpawnCandidatesWithYAMLCheck(_initId, wus) {
|
|
176
|
-
// Filter to only 'ready' status WUs based on YAML, not manifest
|
|
177
|
-
return wus.filter((wu) => {
|
|
178
|
-
const status = wu.doc.status ?? 'unknown';
|
|
179
|
-
return status === WU_STATUS.READY;
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Load initiative and its WUs.
|
|
184
|
-
*
|
|
185
|
-
* @param {string} initRef - Initiative ID or slug
|
|
186
|
-
* @returns {{initiative: object, wus: Array<{id: string, doc: object}>}}
|
|
187
|
-
* @throws {Error} If initiative not found
|
|
188
|
-
*/
|
|
189
|
-
export function loadInitiativeWUs(initRef) {
|
|
190
|
-
const initiative = findInitiative(initRef);
|
|
191
|
-
if (!initiative) {
|
|
192
|
-
throw createError(ErrorCodes.INIT_NOT_FOUND, `Initiative '${initRef}' not found. Check the ID or slug.`, { initRef });
|
|
193
|
-
}
|
|
194
|
-
const wus = getInitiativeWUs(initRef);
|
|
195
|
-
return {
|
|
196
|
-
initiative: initiative.doc,
|
|
197
|
-
wus,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Load multiple initiatives and combine their WUs.
|
|
202
|
-
*
|
|
203
|
-
* Used for cross-initiative parallel execution.
|
|
204
|
-
*
|
|
205
|
-
* @param {string[]} initRefs - Array of initiative IDs or slugs
|
|
206
|
-
* @returns {Array<{id: string, doc: object}>} Combined WUs from all initiatives
|
|
207
|
-
* @throws {Error} If any initiative not found
|
|
208
|
-
*/
|
|
209
|
-
export function loadMultipleInitiatives(initRefs) {
|
|
210
|
-
const allWUs = [];
|
|
211
|
-
const seenIds = new Set();
|
|
212
|
-
for (const ref of initRefs) {
|
|
213
|
-
const { wus } = loadInitiativeWUs(ref);
|
|
214
|
-
for (const wu of wus) {
|
|
215
|
-
if (!seenIds.has(wu.id)) {
|
|
216
|
-
seenIds.add(wu.id);
|
|
217
|
-
allWUs.push(wu);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
return allWUs;
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* Build execution plan from WUs.
|
|
225
|
-
*
|
|
226
|
-
* Groups WUs into waves based on dependencies:
|
|
227
|
-
* - Wave 0: All WUs with no blockers (can run in parallel)
|
|
228
|
-
* - Wave 1: WUs blocked by wave 0 WUs only
|
|
229
|
-
* - Wave N: WUs blocked by wave N-1 WUs
|
|
230
|
-
*
|
|
231
|
-
* WU-2430: Enhanced filtering:
|
|
232
|
-
* - Only schedules status: ready WUs (not blocked/in_progress)
|
|
233
|
-
* - Reports skipped WUs with reasons (skippedWithReasons)
|
|
234
|
-
* - Defers WUs with unstamped external dependencies (deferred)
|
|
235
|
-
*
|
|
236
|
-
* @param {Array<{id: string, doc: object}>} wus - WUs to plan
|
|
237
|
-
* @returns {{waves: Array<Array<{id: string, doc: object}>>, skipped: string[], skippedWithReasons: Array<{id: string, reason: string}>, deferred: Array<{id: string, blockedBy: string[], reason: string}>}}
|
|
238
|
-
* @throws {Error} If circular dependencies detected
|
|
239
|
-
*/
|
|
240
|
-
// eslint-disable-next-line sonarjs/cognitive-complexity -- wave-building logic inherently complex
|
|
241
|
-
export function buildExecutionPlan(wus) {
|
|
242
|
-
// WU-2430: Enhanced categorisation of WUs
|
|
243
|
-
const skipped = []; // IDs of done WUs (backwards compat)
|
|
244
|
-
const skippedWithReasons = []; // WU-2430: Non-ready WUs with reasons
|
|
245
|
-
const deferred = []; // WU-2430: Ready WUs waiting on external blockers
|
|
246
|
-
const doneStatuses = new Set([WU_STATUS.DONE, WU_STATUS.COMPLETED]);
|
|
247
|
-
// Categorise WUs by status
|
|
248
|
-
for (const wu of wus) {
|
|
249
|
-
const status = wu.doc.status ?? 'unknown';
|
|
250
|
-
if (doneStatuses.has(status)) {
|
|
251
|
-
skipped.push(wu.id);
|
|
252
|
-
}
|
|
253
|
-
else if (status !== WU_STATUS.READY) {
|
|
254
|
-
skippedWithReasons.push({ id: wu.id, reason: `status: ${status}` });
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
// WU-2430: Only ready WUs are candidates for execution
|
|
258
|
-
const readyWUs = wus.filter((wu) => wu.doc.status === WU_STATUS.READY);
|
|
259
|
-
if (readyWUs.length === 0) {
|
|
260
|
-
return { waves: [], skipped, skippedWithReasons, deferred };
|
|
261
|
-
}
|
|
262
|
-
// Build a map for quick lookup
|
|
263
|
-
const wuMap = new Map(readyWUs.map((wu) => [wu.id, wu]));
|
|
264
|
-
const wuIds = new Set(wuMap.keys());
|
|
265
|
-
const allWuMap = new Map(wus.map((wu) => [wu.id, wu]));
|
|
266
|
-
const allWuIds = new Set(allWuMap.keys());
|
|
267
|
-
// Build dependency graph for validation (check cycles)
|
|
268
|
-
const graph = buildDependencyGraph();
|
|
269
|
-
const { cycles } = validateGraph(graph);
|
|
270
|
-
// Filter cycles to only those involving our WUs
|
|
271
|
-
const relevantCycles = cycles.filter((cycle) => cycle.some((id) => wuIds.has(id)));
|
|
272
|
-
if (relevantCycles.length > 0) {
|
|
273
|
-
const cycleStr = relevantCycles.map((c) => c.join(' → ')).join('; ');
|
|
274
|
-
throw createError(ErrorCodes.VALIDATION_ERROR, `Circular dependencies detected: ${cycleStr}`, {
|
|
275
|
-
cycles: relevantCycles,
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
// WU-2430: Check for external blockers without stamps
|
|
279
|
-
// A WU with blocked_by dependencies that are NOT in the initiative
|
|
280
|
-
// and do NOT have stamps should be deferred
|
|
281
|
-
const deferredIds = new Set();
|
|
282
|
-
const deferredReasons = new Map();
|
|
283
|
-
const deferredBlockers = new Map();
|
|
284
|
-
const addDeferredEntry = (wuId, blockers, reason) => {
|
|
285
|
-
deferredIds.add(wuId);
|
|
286
|
-
if (!deferredReasons.has(wuId)) {
|
|
287
|
-
deferredReasons.set(wuId, new Set());
|
|
288
|
-
}
|
|
289
|
-
if (!deferredBlockers.has(wuId)) {
|
|
290
|
-
deferredBlockers.set(wuId, new Set());
|
|
291
|
-
}
|
|
292
|
-
const reasonSet = deferredReasons.get(wuId);
|
|
293
|
-
const blockerSet = deferredBlockers.get(wuId);
|
|
294
|
-
for (const blockerId of blockers) {
|
|
295
|
-
blockerSet.add(blockerId);
|
|
296
|
-
}
|
|
297
|
-
reasonSet.add(reason);
|
|
298
|
-
};
|
|
299
|
-
for (const wu of readyWUs) {
|
|
300
|
-
// WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
|
|
301
|
-
const blockers = getAllDependencies(wu.doc);
|
|
302
|
-
const externalBlockers = blockers.filter((blockerId) => !allWuIds.has(blockerId));
|
|
303
|
-
const internalBlockers = blockers.filter((blockerId) => allWuIds.has(blockerId));
|
|
304
|
-
if (externalBlockers.length > 0) {
|
|
305
|
-
// Check if any external blockers lack stamps
|
|
306
|
-
const unstampedBlockers = externalBlockers.filter((blockerId) => !hasStamp(blockerId));
|
|
307
|
-
if (unstampedBlockers.length > 0) {
|
|
308
|
-
addDeferredEntry(wu.id, unstampedBlockers, `waiting for external: ${unstampedBlockers.join(', ')}`);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
if (internalBlockers.length > 0) {
|
|
312
|
-
const nonReadyInternal = internalBlockers.filter((blockerId) => {
|
|
313
|
-
const blocker = allWuMap.get(blockerId);
|
|
314
|
-
const status = blocker?.doc?.status ?? 'unknown';
|
|
315
|
-
if (status === WU_STATUS.READY) {
|
|
316
|
-
return false;
|
|
317
|
-
}
|
|
318
|
-
return !doneStatuses.has(status);
|
|
319
|
-
});
|
|
320
|
-
if (nonReadyInternal.length > 0) {
|
|
321
|
-
const details = nonReadyInternal.map((blockerId) => {
|
|
322
|
-
const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
|
|
323
|
-
return `${blockerId} (status: ${status})`;
|
|
324
|
-
});
|
|
325
|
-
addDeferredEntry(wu.id, nonReadyInternal, `waiting for internal: ${details.join(', ')}`);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
let hasNewDeferral = true;
|
|
330
|
-
while (hasNewDeferral) {
|
|
331
|
-
hasNewDeferral = false;
|
|
332
|
-
for (const wu of readyWUs) {
|
|
333
|
-
if (deferredIds.has(wu.id)) {
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
// WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
|
|
337
|
-
const blockers = getAllDependencies(wu.doc);
|
|
338
|
-
const deferredInternal = blockers.filter((blockerId) => allWuIds.has(blockerId) && deferredIds.has(blockerId));
|
|
339
|
-
if (deferredInternal.length > 0) {
|
|
340
|
-
const details = deferredInternal.map((blockerId) => {
|
|
341
|
-
const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
|
|
342
|
-
return `${blockerId} (status: ${status})`;
|
|
343
|
-
});
|
|
344
|
-
addDeferredEntry(wu.id, deferredInternal, `waiting for internal: ${details.join(', ')}`);
|
|
345
|
-
hasNewDeferral = true;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
for (const wu of readyWUs) {
|
|
350
|
-
if (deferredIds.has(wu.id)) {
|
|
351
|
-
const blockerSet = deferredBlockers.get(wu.id) || new Set();
|
|
352
|
-
const reasonSet = deferredReasons.get(wu.id) || new Set();
|
|
353
|
-
deferred.push({
|
|
354
|
-
id: wu.id,
|
|
355
|
-
blockedBy: Array.from(blockerSet),
|
|
356
|
-
reason: reasonSet.size > 0 ? Array.from(reasonSet).join('; ') : DEFAULT_DEFERRED_REASON,
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
// Remove deferred WUs from candidates
|
|
361
|
-
const schedulableWUs = readyWUs.filter((wu) => !deferredIds.has(wu.id));
|
|
362
|
-
const schedulableMap = new Map(schedulableWUs.map((wu) => [wu.id, wu]));
|
|
363
|
-
const schedulableIds = new Set(schedulableMap.keys());
|
|
364
|
-
if (schedulableIds.size === 0) {
|
|
365
|
-
return { waves: [], skipped, skippedWithReasons, deferred };
|
|
366
|
-
}
|
|
367
|
-
// Build waves using Kahn's algorithm (topological sort by levels)
|
|
368
|
-
// WU-1618: Also enforce lane WIP=1 constraint (no two WUs with same lane in same wave)
|
|
369
|
-
const waves = [];
|
|
370
|
-
const remaining = new Set(schedulableIds);
|
|
371
|
-
const completed = new Set(skipped); // Treat done WUs as completed for dependency resolution
|
|
372
|
-
// Also treat stamped external deps as completed
|
|
373
|
-
for (const wu of wus) {
|
|
374
|
-
// WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
|
|
375
|
-
const blockers = getAllDependencies(wu.doc);
|
|
376
|
-
for (const blockerId of blockers) {
|
|
377
|
-
if (!allWuIds.has(blockerId) && hasStamp(blockerId)) {
|
|
378
|
-
completed.add(blockerId);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
while (remaining.size > 0) {
|
|
383
|
-
const wave = [];
|
|
384
|
-
const lanesInWave = new Set(); // WU-1618: Track lanes used in this wave
|
|
385
|
-
const deferredToNextWave = []; // WUs that could run but lane is occupied
|
|
386
|
-
for (const id of remaining) {
|
|
387
|
-
const wu = schedulableMap.get(id);
|
|
388
|
-
// WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
|
|
389
|
-
const blockers = getAllDependencies(wu.doc);
|
|
390
|
-
// Check if all blockers are either done or completed in previous waves
|
|
391
|
-
const allBlockersDone = blockers.every((blockerId) => completed.has(blockerId));
|
|
392
|
-
if (allBlockersDone) {
|
|
393
|
-
// WU-1618: Check if lane is already occupied in this wave
|
|
394
|
-
const lane = wu.doc.lane;
|
|
395
|
-
if (lanesInWave.has(lane)) {
|
|
396
|
-
// Defer to next wave (lane conflict)
|
|
397
|
-
deferredToNextWave.push(wu);
|
|
398
|
-
}
|
|
399
|
-
else {
|
|
400
|
-
wave.push(wu);
|
|
401
|
-
lanesInWave.add(lane);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
// Deadlock detection: if no WUs can be scheduled but remaining exist
|
|
406
|
-
// WU-1618: Account for deferred WUs (they can run in next wave, not stuck)
|
|
407
|
-
if (wave.length === 0 && remaining.size > 0 && deferredToNextWave.length === 0) {
|
|
408
|
-
const stuckIds = Array.from(remaining);
|
|
409
|
-
throw createError(ErrorCodes.VALIDATION_ERROR, `Circular or unresolvable dependencies detected. Stuck WUs: ${stuckIds.join(', ')}`, { stuckIds });
|
|
410
|
-
}
|
|
411
|
-
// Add wave and mark WUs as completed
|
|
412
|
-
waves.push(wave);
|
|
413
|
-
for (const wu of wave) {
|
|
414
|
-
remaining.delete(wu.id);
|
|
415
|
-
completed.add(wu.id);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
return { waves, skipped, skippedWithReasons, deferred };
|
|
419
|
-
}
|
|
420
|
-
/**
|
|
421
|
-
* Build execution plan from WUs asynchronously.
|
|
422
|
-
*
|
|
423
|
-
* @param {Array<{id: string, doc: object}>} wus - WUs to plan
|
|
424
|
-
* @returns {Promise<ExecutionPlan>}
|
|
425
|
-
*/
|
|
426
|
-
export async function buildExecutionPlanAsync(wus) {
|
|
427
|
-
// WU-2430: Enhanced categorisation of WUs
|
|
428
|
-
const skipped = []; // IDs of done WUs (backwards compat)
|
|
429
|
-
const skippedWithReasons = []; // WU-2430: Non-ready WUs with reasons
|
|
430
|
-
const deferred = []; // WU-2430: Ready WUs waiting on external blockers
|
|
431
|
-
const doneStatuses = new Set([WU_STATUS.DONE, WU_STATUS.COMPLETED]);
|
|
432
|
-
// Categorise WUs by status
|
|
433
|
-
for (const wu of wus) {
|
|
434
|
-
const status = wu.doc.status ?? 'unknown';
|
|
435
|
-
if (doneStatuses.has(status)) {
|
|
436
|
-
skipped.push(wu.id);
|
|
437
|
-
}
|
|
438
|
-
else if (status !== WU_STATUS.READY) {
|
|
439
|
-
skippedWithReasons.push({ id: wu.id, reason: `status: ${status}` });
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
// WU-2430: Only ready WUs are candidates for execution
|
|
443
|
-
const readyWUs = wus.filter((wu) => wu.doc.status === WU_STATUS.READY);
|
|
444
|
-
if (readyWUs.length === 0) {
|
|
445
|
-
return { waves: [], skipped, skippedWithReasons, deferred };
|
|
446
|
-
}
|
|
447
|
-
// Build a map for quick lookup
|
|
448
|
-
const wuMap = new Map(readyWUs.map((wu) => [wu.id, wu]));
|
|
449
|
-
const wuIds = new Set(wuMap.keys());
|
|
450
|
-
const allWuMap = new Map(wus.map((wu) => [wu.id, wu]));
|
|
451
|
-
const allWuIds = new Set(allWuMap.keys());
|
|
452
|
-
// Build dependency graph for validation (check cycles)
|
|
453
|
-
const graph = await buildDependencyGraphAsync();
|
|
454
|
-
const { cycles } = validateGraph(graph);
|
|
455
|
-
// Filter cycles to only those involving our WUs
|
|
456
|
-
const relevantCycles = cycles.filter((cycle) => cycle.some((id) => wuIds.has(id)));
|
|
457
|
-
if (relevantCycles.length > 0) {
|
|
458
|
-
const cycleStr = relevantCycles.map((c) => c.join(' → ')).join('; ');
|
|
459
|
-
throw createError(ErrorCodes.VALIDATION_ERROR, `Circular dependencies detected: ${cycleStr}`, {
|
|
460
|
-
cycles: relevantCycles,
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
// WU-2430: Check for external blockers without stamps
|
|
464
|
-
// A WU with blocked_by dependencies that are NOT in the initiative
|
|
465
|
-
// and do NOT have stamps should be deferred
|
|
466
|
-
const deferredIds = new Set();
|
|
467
|
-
const deferredReasons = new Map();
|
|
468
|
-
const deferredBlockers = new Map();
|
|
469
|
-
const addDeferredEntry = (wuId, blockers, reason) => {
|
|
470
|
-
deferredIds.add(wuId);
|
|
471
|
-
if (!deferredReasons.has(wuId)) {
|
|
472
|
-
deferredReasons.set(wuId, new Set());
|
|
473
|
-
}
|
|
474
|
-
if (!deferredBlockers.has(wuId)) {
|
|
475
|
-
deferredBlockers.set(wuId, new Set());
|
|
476
|
-
}
|
|
477
|
-
const reasonSet = deferredReasons.get(wuId);
|
|
478
|
-
const blockerSet = deferredBlockers.get(wuId);
|
|
479
|
-
for (const blockerId of blockers) {
|
|
480
|
-
blockerSet.add(blockerId);
|
|
481
|
-
}
|
|
482
|
-
reasonSet.add(reason);
|
|
483
|
-
};
|
|
484
|
-
for (const wu of readyWUs) {
|
|
485
|
-
// WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
|
|
486
|
-
const blockers = getAllDependencies(wu.doc);
|
|
487
|
-
const externalBlockers = blockers.filter((blockerId) => !allWuIds.has(blockerId));
|
|
488
|
-
const internalBlockers = blockers.filter((blockerId) => allWuIds.has(blockerId));
|
|
489
|
-
if (externalBlockers.length > 0) {
|
|
490
|
-
// Check if any external blockers lack stamps
|
|
491
|
-
const unstampedBlockers = externalBlockers.filter((blockerId) => !hasStamp(blockerId));
|
|
492
|
-
if (unstampedBlockers.length > 0) {
|
|
493
|
-
addDeferredEntry(wu.id, unstampedBlockers, `waiting for external: ${unstampedBlockers.join(', ')}`);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
if (internalBlockers.length > 0) {
|
|
497
|
-
const nonReadyInternal = internalBlockers.filter((blockerId) => {
|
|
498
|
-
const blocker = allWuMap.get(blockerId);
|
|
499
|
-
const status = blocker?.doc?.status ?? 'unknown';
|
|
500
|
-
if (status === WU_STATUS.READY) {
|
|
501
|
-
return false;
|
|
502
|
-
}
|
|
503
|
-
return !doneStatuses.has(status);
|
|
504
|
-
});
|
|
505
|
-
if (nonReadyInternal.length > 0) {
|
|
506
|
-
const details = nonReadyInternal.map((blockerId) => {
|
|
507
|
-
const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
|
|
508
|
-
return `${blockerId} (status: ${status})`;
|
|
509
|
-
});
|
|
510
|
-
addDeferredEntry(wu.id, nonReadyInternal, `waiting for internal: ${details.join(', ')}`);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
let hasNewDeferral = true;
|
|
515
|
-
while (hasNewDeferral) {
|
|
516
|
-
hasNewDeferral = false;
|
|
517
|
-
for (const wu of readyWUs) {
|
|
518
|
-
if (deferredIds.has(wu.id)) {
|
|
519
|
-
continue;
|
|
520
|
-
}
|
|
521
|
-
// WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
|
|
522
|
-
const blockers = getAllDependencies(wu.doc);
|
|
523
|
-
const deferredInternal = blockers.filter((blockerId) => allWuIds.has(blockerId) && deferredIds.has(blockerId));
|
|
524
|
-
if (deferredInternal.length > 0) {
|
|
525
|
-
const details = deferredInternal.map((blockerId) => {
|
|
526
|
-
const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
|
|
527
|
-
return `${blockerId} (status: ${status})`;
|
|
528
|
-
});
|
|
529
|
-
addDeferredEntry(wu.id, deferredInternal, `waiting for internal: ${details.join(', ')}`);
|
|
530
|
-
hasNewDeferral = true;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
for (const wu of readyWUs) {
|
|
535
|
-
if (deferredIds.has(wu.id)) {
|
|
536
|
-
const blockerSet = deferredBlockers.get(wu.id) || new Set();
|
|
537
|
-
const reasonSet = deferredReasons.get(wu.id) || new Set();
|
|
538
|
-
deferred.push({
|
|
539
|
-
id: wu.id,
|
|
540
|
-
blockedBy: Array.from(blockerSet),
|
|
541
|
-
reason: reasonSet.size > 0 ? Array.from(reasonSet).join('; ') : DEFAULT_DEFERRED_REASON,
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
// Remove deferred WUs from candidates
|
|
546
|
-
const schedulableWUs = readyWUs.filter((wu) => !deferredIds.has(wu.id));
|
|
547
|
-
const schedulableMap = new Map(schedulableWUs.map((wu) => [wu.id, wu]));
|
|
548
|
-
const schedulableIds = new Set(schedulableMap.keys());
|
|
549
|
-
if (schedulableIds.size === 0) {
|
|
550
|
-
return { waves: [], skipped, skippedWithReasons, deferred };
|
|
551
|
-
}
|
|
552
|
-
// Build waves using Kahn's algorithm (topological sort by levels)
|
|
553
|
-
// WU-1618: Also enforce lane WIP=1 constraint (no two WUs with same lane in same wave)
|
|
554
|
-
const waves = [];
|
|
555
|
-
const remaining = new Set(schedulableIds);
|
|
556
|
-
const completed = new Set(skipped); // Treat done WUs as completed for dependency resolution
|
|
557
|
-
// Also treat stamped external deps as completed
|
|
558
|
-
for (const wu of wus) {
|
|
559
|
-
// WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
|
|
560
|
-
const blockers = getAllDependencies(wu.doc);
|
|
561
|
-
for (const blockerId of blockers) {
|
|
562
|
-
if (!allWuIds.has(blockerId) && hasStamp(blockerId)) {
|
|
563
|
-
completed.add(blockerId);
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
while (remaining.size > 0) {
|
|
568
|
-
const wave = [];
|
|
569
|
-
const lanesInWave = new Set(); // WU-1618: Track lanes used in this wave
|
|
570
|
-
const deferredToNextWave = []; // WUs that could run but lane is occupied
|
|
571
|
-
for (const id of remaining) {
|
|
572
|
-
const wu = schedulableMap.get(id);
|
|
573
|
-
// WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
|
|
574
|
-
const blockers = getAllDependencies(wu.doc);
|
|
575
|
-
// Check if all blockers are either done or completed in previous waves
|
|
576
|
-
const allBlockersDone = blockers.every((blockerId) => completed.has(blockerId));
|
|
577
|
-
if (allBlockersDone) {
|
|
578
|
-
// WU-1618: Check if lane is already occupied in this wave
|
|
579
|
-
const lane = wu.doc.lane;
|
|
580
|
-
if (lanesInWave.has(lane)) {
|
|
581
|
-
// Defer to next wave (lane conflict)
|
|
582
|
-
deferredToNextWave.push(wu);
|
|
583
|
-
}
|
|
584
|
-
else {
|
|
585
|
-
wave.push(wu);
|
|
586
|
-
lanesInWave.add(lane);
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
// Deadlock detection: if no WUs can be scheduled but remaining exist
|
|
591
|
-
// WU-1618: Account for deferred WUs (they can run in next wave, not stuck)
|
|
592
|
-
if (wave.length === 0 && remaining.size > 0 && deferredToNextWave.length === 0) {
|
|
593
|
-
const stuckIds = Array.from(remaining);
|
|
594
|
-
throw createError(ErrorCodes.VALIDATION_ERROR, `Circular or unresolvable dependencies detected. Stuck WUs: ${stuckIds.join(', ')}`, { stuckIds });
|
|
595
|
-
}
|
|
596
|
-
// Add wave and mark WUs as completed
|
|
597
|
-
waves.push(wave);
|
|
598
|
-
for (const wu of wave) {
|
|
599
|
-
remaining.delete(wu.id);
|
|
600
|
-
completed.add(wu.id);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
return { waves, skipped, skippedWithReasons, deferred };
|
|
604
|
-
}
|
|
605
|
-
/**
|
|
606
|
-
* WU-1828: Determine if checkpoint mode should be auto-enabled based on initiative size.
|
|
607
|
-
*
|
|
608
|
-
* Auto-detection triggers checkpoint mode when:
|
|
609
|
-
* - Pending WU count exceeds WU_COUNT threshold (>3)
|
|
610
|
-
* - OR wave count exceeds WAVE_COUNT threshold (>2)
|
|
611
|
-
*
|
|
612
|
-
* This prevents "prompt too long" errors for large initiatives by using
|
|
613
|
-
* checkpoint-per-wave execution instead of polling mode.
|
|
614
|
-
*
|
|
615
|
-
* @param {Array<{id: string, doc: object}>} wus - WUs to analyse
|
|
616
|
-
* @returns {{autoEnabled: boolean, reason: string, pendingCount: number, waveCount: number}}
|
|
617
|
-
*/
|
|
618
|
-
export function shouldAutoEnableCheckpoint(wus) {
|
|
619
|
-
// Count only pending WUs (not done)
|
|
620
|
-
const pendingWUs = wus.filter((wu) => wu.doc.status !== WU_STATUS.DONE);
|
|
621
|
-
const pendingCount = pendingWUs.length;
|
|
622
|
-
// Check WU count threshold first (faster check)
|
|
623
|
-
if (pendingCount > CHECKPOINT_AUTO_THRESHOLDS.WU_COUNT) {
|
|
624
|
-
return {
|
|
625
|
-
autoEnabled: true,
|
|
626
|
-
reason: `${pendingCount} pending WUs exceeds threshold (>${CHECKPOINT_AUTO_THRESHOLDS.WU_COUNT})`,
|
|
627
|
-
pendingCount,
|
|
628
|
-
waveCount: -1, // Not computed (early return)
|
|
629
|
-
};
|
|
630
|
-
}
|
|
631
|
-
// Only compute waves if WU count didn't trigger
|
|
632
|
-
if (pendingCount === 0) {
|
|
633
|
-
return {
|
|
634
|
-
autoEnabled: false,
|
|
635
|
-
reason: 'No pending WUs',
|
|
636
|
-
pendingCount: 0,
|
|
637
|
-
waveCount: 0,
|
|
638
|
-
};
|
|
639
|
-
}
|
|
640
|
-
// Build execution plan to count waves
|
|
641
|
-
const plan = buildExecutionPlan(wus);
|
|
642
|
-
const waveCount = plan.waves.length;
|
|
643
|
-
// Check wave count threshold
|
|
644
|
-
if (waveCount > CHECKPOINT_AUTO_THRESHOLDS.WAVE_COUNT) {
|
|
645
|
-
return {
|
|
646
|
-
autoEnabled: true,
|
|
647
|
-
reason: `${waveCount} waves exceeds threshold (>${CHECKPOINT_AUTO_THRESHOLDS.WAVE_COUNT})`,
|
|
648
|
-
pendingCount,
|
|
649
|
-
waveCount,
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
return {
|
|
653
|
-
autoEnabled: false,
|
|
654
|
-
reason: `${pendingCount} pending WUs and ${waveCount} waves within thresholds`,
|
|
655
|
-
pendingCount,
|
|
656
|
-
waveCount,
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
/**
|
|
660
|
-
* WU-1828: Determine if checkpoint mode should be auto-enabled based on initiative size asynchronously.
|
|
661
|
-
*
|
|
662
|
-
* @param {Array<{id: string, doc: object}>} wus - WUs to analyse
|
|
663
|
-
* @returns {Promise<{autoEnabled: boolean, reason: string, pendingCount: number, waveCount: number}>}
|
|
664
|
-
*/
|
|
665
|
-
export async function shouldAutoEnableCheckpointAsync(wus) {
|
|
666
|
-
// Count only pending WUs (not done)
|
|
667
|
-
const pendingWUs = wus.filter((wu) => wu.doc.status !== WU_STATUS.DONE);
|
|
668
|
-
const pendingCount = pendingWUs.length;
|
|
669
|
-
// Check WU count threshold first (faster check)
|
|
670
|
-
if (pendingCount > CHECKPOINT_AUTO_THRESHOLDS.WU_COUNT) {
|
|
671
|
-
return {
|
|
672
|
-
autoEnabled: true,
|
|
673
|
-
reason: `${pendingCount} pending WUs exceeds threshold (>${CHECKPOINT_AUTO_THRESHOLDS.WU_COUNT})`,
|
|
674
|
-
pendingCount,
|
|
675
|
-
waveCount: -1, // Not computed (early return)
|
|
676
|
-
};
|
|
677
|
-
}
|
|
678
|
-
// Only compute waves if WU count didn't trigger
|
|
679
|
-
if (pendingCount === 0) {
|
|
680
|
-
return {
|
|
681
|
-
autoEnabled: false,
|
|
682
|
-
reason: 'No pending WUs',
|
|
683
|
-
pendingCount: 0,
|
|
684
|
-
waveCount: 0,
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
|
-
// Build execution plan to count waves
|
|
688
|
-
const plan = await buildExecutionPlanAsync(wus);
|
|
689
|
-
const waveCount = plan.waves.length;
|
|
690
|
-
// Check wave count threshold
|
|
691
|
-
if (waveCount > CHECKPOINT_AUTO_THRESHOLDS.WAVE_COUNT) {
|
|
692
|
-
return {
|
|
693
|
-
autoEnabled: true,
|
|
694
|
-
reason: `${waveCount} waves exceeds threshold (>${CHECKPOINT_AUTO_THRESHOLDS.WAVE_COUNT})`,
|
|
695
|
-
pendingCount,
|
|
696
|
-
waveCount,
|
|
697
|
-
};
|
|
698
|
-
}
|
|
699
|
-
return {
|
|
700
|
-
autoEnabled: false,
|
|
701
|
-
reason: `${pendingCount} pending WUs and ${waveCount} waves within thresholds`,
|
|
702
|
-
pendingCount,
|
|
703
|
-
waveCount,
|
|
704
|
-
};
|
|
705
|
-
}
|
|
706
|
-
/**
|
|
707
|
-
* WU-1828: Resolve checkpoint mode from CLI flags and auto-detection.
|
|
708
|
-
* WU-2430: Updated to suppress auto-detection in dry-run mode.
|
|
709
|
-
*
|
|
710
|
-
* Flag precedence:
|
|
711
|
-
* 1. --checkpoint-per-wave (-c): Explicitly enables checkpoint mode
|
|
712
|
-
* 2. --no-checkpoint: Explicitly disables checkpoint mode (overrides auto-detection)
|
|
713
|
-
* 3. --dry-run: Suppresses auto-detection (dry-run uses polling mode for preview)
|
|
714
|
-
* 4. Auto-detection: Enabled based on initiative size if no explicit flags
|
|
715
|
-
*
|
|
716
|
-
* @param {{checkpointPerWave?: boolean, noCheckpoint?: boolean, dryRun?: boolean}} options - CLI options
|
|
717
|
-
* @param {Array<{id: string, doc: object}>} wus - WUs for auto-detection
|
|
718
|
-
* @returns {{enabled: boolean, source: 'explicit'|'override'|'auto'|'dryrun', reason?: string}}
|
|
719
|
-
*/
|
|
720
|
-
export function resolveCheckpointMode(options, wus) {
|
|
721
|
-
const { checkpointPerWave = false, noCheckpoint = false, dryRun = false } = options;
|
|
722
|
-
// Explicit enable via -c flag
|
|
723
|
-
if (checkpointPerWave) {
|
|
724
|
-
return {
|
|
725
|
-
enabled: true,
|
|
726
|
-
source: 'explicit',
|
|
727
|
-
reason: 'Enabled via -c/--checkpoint-per-wave flag',
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
// Explicit disable via --no-checkpoint flag
|
|
731
|
-
if (noCheckpoint) {
|
|
732
|
-
return {
|
|
733
|
-
enabled: false,
|
|
734
|
-
source: 'override',
|
|
735
|
-
reason: 'Disabled via --no-checkpoint flag',
|
|
736
|
-
};
|
|
737
|
-
}
|
|
738
|
-
// WU-2430: Dry-run suppresses auto-detection (preview should use polling mode)
|
|
739
|
-
if (dryRun) {
|
|
740
|
-
return {
|
|
741
|
-
enabled: false,
|
|
742
|
-
source: 'dryrun',
|
|
743
|
-
reason: 'Disabled in dry-run mode (preview uses polling mode)',
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
// Auto-detection
|
|
747
|
-
const autoResult = shouldAutoEnableCheckpoint(wus);
|
|
748
|
-
return {
|
|
749
|
-
enabled: autoResult.autoEnabled,
|
|
750
|
-
source: 'auto',
|
|
751
|
-
reason: autoResult.reason,
|
|
752
|
-
};
|
|
753
|
-
}
|
|
754
|
-
/**
|
|
755
|
-
* WU-1828: Resolve checkpoint mode from CLI flags and auto-detection asynchronously.
|
|
756
|
-
*
|
|
757
|
-
* @param {{checkpointPerWave?: boolean, noCheckpoint?: boolean, dryRun?: boolean}} options - CLI options
|
|
758
|
-
* @param {Array<{id: string, doc: object}>} wus - WUs for auto-detection
|
|
759
|
-
* @returns {Promise<{enabled: boolean, source: 'explicit'|'override'|'auto'|'dryrun', reason?: string}>}
|
|
760
|
-
*/
|
|
761
|
-
export async function resolveCheckpointModeAsync(options, wus) {
|
|
762
|
-
const { checkpointPerWave = false, noCheckpoint = false, dryRun = false } = options;
|
|
763
|
-
// Explicit enable via -c flag
|
|
764
|
-
if (checkpointPerWave) {
|
|
765
|
-
return {
|
|
766
|
-
enabled: true,
|
|
767
|
-
source: 'explicit',
|
|
768
|
-
reason: 'Enabled via -c/--checkpoint-per-wave flag',
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
// Explicit disable via --no-checkpoint flag
|
|
772
|
-
if (noCheckpoint) {
|
|
773
|
-
return {
|
|
774
|
-
enabled: false,
|
|
775
|
-
source: 'override',
|
|
776
|
-
reason: 'Disabled via --no-checkpoint flag',
|
|
777
|
-
};
|
|
778
|
-
}
|
|
779
|
-
// WU-2430: Dry-run suppresses auto-detection (preview should use polling mode)
|
|
780
|
-
if (dryRun) {
|
|
781
|
-
return {
|
|
782
|
-
enabled: false,
|
|
783
|
-
source: 'dryrun',
|
|
784
|
-
reason: 'Disabled in dry-run mode (preview uses polling mode)',
|
|
785
|
-
};
|
|
786
|
-
}
|
|
787
|
-
// Auto-detection
|
|
788
|
-
const autoResult = await shouldAutoEnableCheckpointAsync(wus);
|
|
789
|
-
return {
|
|
790
|
-
enabled: autoResult.autoEnabled,
|
|
791
|
-
source: 'auto',
|
|
792
|
-
reason: autoResult.reason,
|
|
793
|
-
};
|
|
794
|
-
}
|
|
795
|
-
/**
|
|
796
|
-
* Get bottleneck WUs from a set of WUs based on how many downstream WUs they block.
|
|
797
|
-
* A bottleneck is a WU that blocks multiple other WUs.
|
|
798
|
-
*
|
|
799
|
-
* @param {Array<{id: string, doc: object}>} wus - WUs to analyse
|
|
800
|
-
* @param {number} [limit=5] - Maximum number of bottlenecks to return
|
|
801
|
-
* @returns {Array<{id: string, title: string, blocksCount: number}>} Bottleneck WUs sorted by impact
|
|
802
|
-
*/
|
|
803
|
-
export function getBottleneckWUs(wus, limit = 5) {
|
|
804
|
-
// Build a map of WU ID -> count of WUs that depend on it
|
|
805
|
-
const blocksCounts = new Map();
|
|
806
|
-
// Initialise all WUs with 0
|
|
807
|
-
for (const wu of wus) {
|
|
808
|
-
blocksCounts.set(wu.id, 0);
|
|
809
|
-
}
|
|
810
|
-
// Count how many WUs each WU blocks
|
|
811
|
-
for (const wu of wus) {
|
|
812
|
-
// WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
|
|
813
|
-
const blockers = getAllDependencies(wu.doc);
|
|
814
|
-
for (const blockerId of blockers) {
|
|
815
|
-
if (blocksCounts.has(blockerId)) {
|
|
816
|
-
blocksCounts.set(blockerId, blocksCounts.get(blockerId) + 1);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
// Convert to array and filter out WUs that don't block anything
|
|
821
|
-
const bottlenecks = [];
|
|
822
|
-
for (const wu of wus) {
|
|
823
|
-
const blocksCount = blocksCounts.get(wu.id);
|
|
824
|
-
if (blocksCount !== undefined && blocksCount > 0) {
|
|
825
|
-
bottlenecks.push({
|
|
826
|
-
id: wu.id,
|
|
827
|
-
title: wu.doc.title ?? wu.id,
|
|
828
|
-
blocksCount,
|
|
829
|
-
});
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
// Sort by blocks count descending
|
|
833
|
-
bottlenecks.sort((a, b) => b.blocksCount - a.blocksCount);
|
|
834
|
-
return bottlenecks.slice(0, limit);
|
|
835
|
-
}
|
|
836
|
-
/**
|
|
837
|
-
* Format execution plan for display.
|
|
838
|
-
*
|
|
839
|
-
* WU-2430: Enhanced to show skippedWithReasons and deferred WUs.
|
|
840
|
-
*
|
|
841
|
-
* @param {object} initiative - Initiative document
|
|
842
|
-
* @param {{waves: Array<Array<{id: string, doc: object}>>, skipped: string[], skippedWithReasons?: Array<{id: string, reason: string}>, deferred?: Array<{id: string, blockedBy: string[], reason: string}>}} plan - Execution plan
|
|
843
|
-
* @returns {string} Formatted plan output
|
|
844
|
-
*/
|
|
845
|
-
// eslint-disable-next-line sonarjs/cognitive-complexity -- display formatting inherently complex
|
|
846
|
-
export function formatExecutionPlan(initiative, plan) {
|
|
847
|
-
const lines = [];
|
|
848
|
-
lines.push(`Initiative: ${initiative.id} — ${initiative.title}`);
|
|
849
|
-
lines.push('');
|
|
850
|
-
if (plan.skipped.length > 0) {
|
|
851
|
-
lines.push(`Skipped (already done): ${plan.skipped.join(', ')}`);
|
|
852
|
-
lines.push('');
|
|
853
|
-
}
|
|
854
|
-
// WU-2430: Show WUs skipped due to non-ready status
|
|
855
|
-
if (plan.skippedWithReasons && plan.skippedWithReasons.length > 0) {
|
|
856
|
-
lines.push('Skipped (not ready):');
|
|
857
|
-
for (const entry of plan.skippedWithReasons) {
|
|
858
|
-
lines.push(` - ${entry.id}: ${entry.reason}`);
|
|
859
|
-
}
|
|
860
|
-
lines.push('');
|
|
861
|
-
}
|
|
862
|
-
// WU-2430: Show WUs deferred due to unmet dependencies
|
|
863
|
-
if (plan.deferred && plan.deferred.length > 0) {
|
|
864
|
-
lines.push('Deferred (waiting for dependencies):');
|
|
865
|
-
for (const entry of plan.deferred) {
|
|
866
|
-
lines.push(` - ${entry.id}: ${entry.reason}`);
|
|
867
|
-
if (entry.blockedBy && entry.blockedBy.length > 0) {
|
|
868
|
-
lines.push(` blocked by: ${entry.blockedBy.join(', ')}`);
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
lines.push('');
|
|
872
|
-
}
|
|
873
|
-
if (plan.waves.length === 0) {
|
|
874
|
-
lines.push('No pending WUs to execute.');
|
|
875
|
-
return lines.join(STRING_LITERALS.NEWLINE);
|
|
876
|
-
}
|
|
877
|
-
lines.push(`Execution Plan: ${plan.waves.length} wave(s)`);
|
|
878
|
-
lines.push('');
|
|
879
|
-
// Identify bottleneck WUs (WU-1596)
|
|
880
|
-
const allWUs = plan.waves.flat();
|
|
881
|
-
const bottleneckWUs = getBottleneckWUs(allWUs);
|
|
882
|
-
if (bottleneckWUs.length > 0) {
|
|
883
|
-
lines.push('Bottleneck WUs (prioritise these for fastest unblocking):');
|
|
884
|
-
for (const bottleneck of bottleneckWUs) {
|
|
885
|
-
lines.push(` - ${bottleneck.id}: ${bottleneck.title} [blocks ${bottleneck.blocksCount} WU${bottleneck.blocksCount !== 1 ? 's' : ''}]`);
|
|
886
|
-
}
|
|
887
|
-
lines.push('');
|
|
888
|
-
}
|
|
889
|
-
for (let i = 0; i < plan.waves.length; i++) {
|
|
890
|
-
const wave = plan.waves[i];
|
|
891
|
-
lines.push(`Wave ${i} (${wave.length} WU${wave.length !== 1 ? 's' : ''} in parallel):`);
|
|
892
|
-
for (const wu of wave) {
|
|
893
|
-
// WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
|
|
894
|
-
const blockers = getAllDependencies(wu.doc);
|
|
895
|
-
const blockerStr = blockers.length > 0 ? ` [blocked by: ${blockers.join(', ')}]` : '';
|
|
896
|
-
// Mark bottleneck WUs (WU-1596)
|
|
897
|
-
const isBottleneck = bottleneckWUs.some((b) => b.id === wu.id);
|
|
898
|
-
const bottleneckMarker = isBottleneck ? ' *BOTTLENECK*' : '';
|
|
899
|
-
lines.push(` - ${wu.id}: ${wu.doc.title}${blockerStr}${bottleneckMarker}`);
|
|
900
|
-
}
|
|
901
|
-
lines.push('');
|
|
902
|
-
}
|
|
903
|
-
// Add coordination guidance for multi-wave plans (WU-1592)
|
|
904
|
-
if (plan.waves.length > 1) {
|
|
905
|
-
lines.push('Coordination Guidance:');
|
|
906
|
-
lines.push(' - Poll mem:inbox between waves: pnpm mem:inbox --since 10m');
|
|
907
|
-
lines.push(' - Check for bug discoveries from sub-agents');
|
|
908
|
-
lines.push(' - Review signals before proceeding to next wave');
|
|
909
|
-
lines.push('');
|
|
910
|
-
}
|
|
911
|
-
return lines.join(STRING_LITERALS.NEWLINE);
|
|
912
|
-
}
|
|
913
|
-
/**
|
|
914
|
-
* Generate spawn commands for a wave of WUs.
|
|
915
|
-
*
|
|
916
|
-
* @param {Array<{id: string, doc: object}>} wave - WUs in the wave
|
|
917
|
-
* @returns {string[]} Array of spawn command strings
|
|
918
|
-
*/
|
|
919
|
-
export function generateSpawnCommands(wave) {
|
|
920
|
-
return wave.map((wu) => `pnpm wu:delegate --id ${wu.id} --parent-wu <PARENT-WU-ID> --client claude-code`);
|
|
921
|
-
}
|
|
922
|
-
/**
|
|
923
|
-
* Calculate progress statistics for WUs.
|
|
924
|
-
*
|
|
925
|
-
* @param {Array<{id: string, doc: object}>} wus - WUs to calculate progress for
|
|
926
|
-
* @returns {{total: number, done: number, active: number, pending: number, blocked: number, percentage: number}}
|
|
927
|
-
*/
|
|
928
|
-
export function calculateProgress(wus) {
|
|
929
|
-
const stats = {
|
|
930
|
-
total: wus.length,
|
|
931
|
-
done: 0,
|
|
932
|
-
active: 0,
|
|
933
|
-
pending: 0,
|
|
934
|
-
blocked: 0,
|
|
935
|
-
percentage: 0,
|
|
936
|
-
};
|
|
937
|
-
for (const { doc } of wus) {
|
|
938
|
-
switch (doc.status) {
|
|
939
|
-
case WU_STATUS.DONE:
|
|
940
|
-
stats.done++;
|
|
941
|
-
break;
|
|
942
|
-
case WU_STATUS.IN_PROGRESS:
|
|
943
|
-
stats.active++;
|
|
944
|
-
break;
|
|
945
|
-
case WU_STATUS.BLOCKED:
|
|
946
|
-
stats.blocked++;
|
|
947
|
-
break;
|
|
948
|
-
case WU_STATUS.READY:
|
|
949
|
-
stats.pending++;
|
|
950
|
-
break;
|
|
951
|
-
default:
|
|
952
|
-
// Skip other statuses (e.g., cancelled) - counted in total only
|
|
953
|
-
break;
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
stats.percentage = stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0;
|
|
957
|
-
return stats;
|
|
958
|
-
}
|
|
959
|
-
/**
|
|
960
|
-
* Format progress for display.
|
|
961
|
-
*
|
|
962
|
-
* @param {{total: number, done: number, active: number, pending: number, blocked: number, percentage: number}} progress
|
|
963
|
-
* @returns {string} Formatted progress string
|
|
964
|
-
*/
|
|
965
|
-
export function formatProgress(progress) {
|
|
966
|
-
const bar = createProgressBar(progress.percentage);
|
|
967
|
-
return [
|
|
968
|
-
`Progress: ${bar} ${progress.percentage}%`,
|
|
969
|
-
` Done: ${progress.done}/${progress.total}`,
|
|
970
|
-
` Active: ${progress.active}`,
|
|
971
|
-
` Pending: ${progress.pending}`,
|
|
972
|
-
` Blocked: ${progress.blocked}`,
|
|
973
|
-
].join(STRING_LITERALS.NEWLINE);
|
|
974
|
-
}
|
|
975
|
-
/**
|
|
976
|
-
* Create a visual progress bar.
|
|
977
|
-
*
|
|
978
|
-
* @param {number} percentage - Completion percentage (0-100)
|
|
979
|
-
* @param {number} [width=20] - Bar width in characters
|
|
980
|
-
* @returns {string} Visual progress bar
|
|
981
|
-
*/
|
|
982
|
-
function createProgressBar(percentage, width = 20) {
|
|
983
|
-
const filled = Math.round((percentage / 100) * width);
|
|
984
|
-
const empty = width - filled;
|
|
985
|
-
return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`;
|
|
986
|
-
}
|
|
987
|
-
/**
|
|
988
|
-
* Check if a stamp file exists for a WU.
|
|
989
|
-
*
|
|
990
|
-
* @param {string} wuId - WU ID (e.g., 'WU-001')
|
|
991
|
-
* @returns {boolean} True if stamp exists
|
|
992
|
-
*/
|
|
993
|
-
function hasStamp(wuId) {
|
|
994
|
-
const stampPath = join(STAMPS_DIR, `${wuId}.done`);
|
|
995
|
-
return existsSync(stampPath);
|
|
996
|
-
}
|
|
997
|
-
/**
|
|
998
|
-
* WU-2040: Filter WUs by dependency stamp status.
|
|
999
|
-
* WU-1251: Now checks both blocked_by AND dependencies arrays.
|
|
1000
|
-
*
|
|
1001
|
-
* A WU is only spawnable if ALL its dependencies have stamps.
|
|
1002
|
-
* This implements the wait-for-completion pattern per Anthropic multi-agent research.
|
|
1003
|
-
*
|
|
1004
|
-
* @param {Array<{id: string, doc: {blocked_by?: string[], dependencies?: string[], lane: string, status: string}}>} candidates - WU candidates
|
|
1005
|
-
* @returns {{spawnable: Array<object>, blocked: Array<object>, blockingDeps: string[], waitingMessage: string}}
|
|
1006
|
-
*/
|
|
1007
|
-
export function filterByDependencyStamps(candidates) {
|
|
1008
|
-
const spawnable = [];
|
|
1009
|
-
const blocked = [];
|
|
1010
|
-
const blockingDeps = new Set();
|
|
1011
|
-
for (const wu of candidates) {
|
|
1012
|
-
// WU-1251: Use getAllDependencies to combine blocked_by and dependencies arrays
|
|
1013
|
-
const deps = getAllDependencies(wu.doc);
|
|
1014
|
-
// Check if ALL dependencies have stamps
|
|
1015
|
-
const unmetDeps = deps.filter((depId) => !hasStamp(depId));
|
|
1016
|
-
if (unmetDeps.length === 0) {
|
|
1017
|
-
// All deps satisfied (or no deps)
|
|
1018
|
-
spawnable.push(wu);
|
|
1019
|
-
}
|
|
1020
|
-
else {
|
|
1021
|
-
// Has unmet dependencies
|
|
1022
|
-
blocked.push(wu);
|
|
1023
|
-
for (const depId of unmetDeps) {
|
|
1024
|
-
blockingDeps.add(depId);
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
// Build waiting message if needed
|
|
1029
|
-
let waitingMessage = '';
|
|
1030
|
-
if (spawnable.length === 0 && blockingDeps.size > 0) {
|
|
1031
|
-
const depsArray = Array.from(blockingDeps);
|
|
1032
|
-
waitingMessage = `Waiting for ${depsArray.join(', ')} to complete. No WUs can spawn until ${depsArray.length === 1 ? 'this dependency has' : 'these dependencies have'} a stamp.`;
|
|
1033
|
-
}
|
|
1034
|
-
return {
|
|
1035
|
-
spawnable,
|
|
1036
|
-
blocked,
|
|
1037
|
-
blockingDeps: Array.from(blockingDeps),
|
|
1038
|
-
waitingMessage,
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1041
|
-
/**
|
|
1042
|
-
* Get existing wave manifests for an initiative.
|
|
1043
|
-
*
|
|
1044
|
-
* @param {string} initId - Initiative ID
|
|
1045
|
-
* @returns {Array<{wave: number, wus: Array<{id: string}>}>} Parsed manifests
|
|
1046
|
-
*/
|
|
1047
|
-
function getExistingWaveManifests(initId) {
|
|
1048
|
-
if (!existsSync(WAVE_MANIFEST_DIR)) {
|
|
1049
|
-
return [];
|
|
1050
|
-
}
|
|
1051
|
-
const files = readdirSync(WAVE_MANIFEST_DIR);
|
|
1052
|
-
// eslint-disable-next-line security/detect-non-literal-regexp -- initId from internal state, not user input
|
|
1053
|
-
const pattern = new RegExp(`^${initId}-wave-(\\d+)\\.json$`);
|
|
1054
|
-
const manifests = [];
|
|
1055
|
-
for (const file of files) {
|
|
1056
|
-
const match = file.match(pattern);
|
|
1057
|
-
if (match) {
|
|
1058
|
-
try {
|
|
1059
|
-
const content = readFileSync(join(WAVE_MANIFEST_DIR, file), 'utf8');
|
|
1060
|
-
const manifest = JSON.parse(content);
|
|
1061
|
-
manifests.push(manifest);
|
|
1062
|
-
}
|
|
1063
|
-
catch {
|
|
1064
|
-
// Skip invalid manifests
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
return manifests.sort((a, b) => a.wave - b.wave);
|
|
1069
|
-
}
|
|
1070
|
-
/**
|
|
1071
|
-
* Get WU IDs that have already been spawned in previous manifests.
|
|
1072
|
-
*
|
|
1073
|
-
* @param {string} initId - Initiative ID
|
|
1074
|
-
* @returns {Set<string>} Set of WU IDs already in manifests
|
|
1075
|
-
*/
|
|
1076
|
-
function _getSpawnedWUIds(initId) {
|
|
1077
|
-
const manifests = getExistingWaveManifests(initId);
|
|
1078
|
-
const spawnedIds = new Set();
|
|
1079
|
-
for (const manifest of manifests) {
|
|
1080
|
-
if (manifest.wus) {
|
|
1081
|
-
for (const wu of manifest.wus) {
|
|
1082
|
-
spawnedIds.add(wu.id);
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
return spawnedIds;
|
|
1087
|
-
}
|
|
1088
|
-
/**
|
|
1089
|
-
* Determine the next wave number for an initiative.
|
|
1090
|
-
*
|
|
1091
|
-
* @param {string} initId - Initiative ID
|
|
1092
|
-
* @returns {number} Next wave number (0-indexed)
|
|
1093
|
-
*/
|
|
1094
|
-
function getNextWaveNumber(initId) {
|
|
1095
|
-
const manifests = getExistingWaveManifests(initId);
|
|
1096
|
-
if (manifests.length === 0) {
|
|
1097
|
-
return 0;
|
|
1098
|
-
}
|
|
1099
|
-
const maxWave = Math.max(...manifests.map((m) => m.wave));
|
|
1100
|
-
return maxWave + 1;
|
|
1101
|
-
}
|
|
1102
|
-
/**
|
|
1103
|
-
* Validate checkpoint-per-wave flag combinations.
|
|
1104
|
-
*
|
|
1105
|
-
* WU-1828: Extended to validate --no-checkpoint flag combinations.
|
|
1106
|
-
*
|
|
1107
|
-
* @param {{checkpointPerWave?: boolean, dryRun?: boolean, noCheckpoint?: boolean}} options - CLI options
|
|
1108
|
-
* @throws {Error} If invalid flag combination
|
|
1109
|
-
*/
|
|
1110
|
-
export function validateCheckpointFlags(options) {
|
|
1111
|
-
if (options.checkpointPerWave && options.dryRun) {
|
|
1112
|
-
throw createError(ErrorCodes.VALIDATION_ERROR, 'Cannot combine --checkpoint-per-wave (-c) with --dry-run (-d). ' +
|
|
1113
|
-
'Checkpoint mode writes manifests and spawns agents.', { flags: { checkpointPerWave: true, dryRun: true } });
|
|
1114
|
-
}
|
|
1115
|
-
// WU-1828: Validate -c and --no-checkpoint are mutually exclusive
|
|
1116
|
-
if (options.checkpointPerWave && options.noCheckpoint) {
|
|
1117
|
-
throw createError(ErrorCodes.VALIDATION_ERROR, 'Cannot combine --checkpoint-per-wave (-c) with --no-checkpoint. ' +
|
|
1118
|
-
'These flags are mutually exclusive.', { flags: { checkpointPerWave: true, noCheckpoint: true } });
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
/**
|
|
1122
|
-
* Build a checkpoint wave for an initiative.
|
|
1123
|
-
*
|
|
1124
|
-
* WU-1821: Creates a wave manifest file and returns spawn candidates.
|
|
1125
|
-
* Implements idempotency: skips WUs with stamps or already in previous manifests.
|
|
1126
|
-
*
|
|
1127
|
-
* Idempotency precedence (single source of truth):
|
|
1128
|
-
* 1. Stamp (highest): .lumenflow/stamps/WU-XXXX.done exists → WU is done
|
|
1129
|
-
* 2. Manifest: WU already in previous wave manifest → skip
|
|
1130
|
-
* 3. Status: Only spawn status: ready WUs
|
|
1131
|
-
*
|
|
1132
|
-
* @param {string} initRef - Initiative ID or slug
|
|
1133
|
-
* @returns {{wave: number, wus: Array<{id: string, lane: string, status: string}>, manifestPath: string, initiative: string}|null}
|
|
1134
|
-
* Wave data or null if all WUs complete
|
|
1135
|
-
*/
|
|
1136
|
-
export function buildCheckpointWave(initRef, options = {}) {
|
|
1137
|
-
const { dryRun = false } = options;
|
|
1138
|
-
// Load initiative and WUs
|
|
1139
|
-
const initData = findInitiative(initRef);
|
|
1140
|
-
if (!initData) {
|
|
1141
|
-
throw createError(ErrorCodes.INIT_NOT_FOUND, `Initiative '${initRef}' not found.`, { initRef });
|
|
1142
|
-
}
|
|
1143
|
-
const initId = initData.id;
|
|
1144
|
-
const wus = getInitiativeWUs(initRef);
|
|
1145
|
-
// WU-1200: Check YAML status, not just wave manifests
|
|
1146
|
-
// Wave manifests can be stale (prompt output but agent never launched)
|
|
1147
|
-
// YAML status is the authoritative source of truth
|
|
1148
|
-
// Filter to spawn candidates:
|
|
1149
|
-
// 1. status: ready only (from YAML - authoritative)
|
|
1150
|
-
// 2. No stamp exists (idempotency)
|
|
1151
|
-
// 3. WU is not actually spawned (YAML status not in_progress/done)
|
|
1152
|
-
//
|
|
1153
|
-
// Note: We no longer rely on wave manifests for exclusion because:
|
|
1154
|
-
// - Manifests can be stale (AC3: Stale wave manifests don't block new runs)
|
|
1155
|
-
// - YAML status is updated when an agent actually claims the WU
|
|
1156
|
-
const readyCandidates = wus.filter((wu) => {
|
|
1157
|
-
// Only ready WUs (YAML status - authoritative)
|
|
1158
|
-
if (wu.doc.status !== WU_STATUS.READY) {
|
|
1159
|
-
return false;
|
|
1160
|
-
}
|
|
1161
|
-
// Skip if stamp exists (highest precedence - WU is complete)
|
|
1162
|
-
if (hasStamp(wu.id)) {
|
|
1163
|
-
return false;
|
|
1164
|
-
}
|
|
1165
|
-
// WU-1200: Check YAML status, not manifest status
|
|
1166
|
-
// If YAML says 'ready', the WU hasn't been claimed yet, so it's spawnable
|
|
1167
|
-
// (even if a stale manifest says it was 'spawned')
|
|
1168
|
-
return true;
|
|
1169
|
-
});
|
|
1170
|
-
// If no ready candidates, all work is done
|
|
1171
|
-
if (readyCandidates.length === 0) {
|
|
1172
|
-
return null;
|
|
1173
|
-
}
|
|
1174
|
-
// WU-2040: Filter by dependency stamps (wait-for-completion pattern)
|
|
1175
|
-
// A WU is only spawnable if ALL its blocked_by dependencies have stamps
|
|
1176
|
-
const depResult = filterByDependencyStamps(readyCandidates);
|
|
1177
|
-
// If no spawnable WUs due to unmet dependencies, return blocking info
|
|
1178
|
-
if (depResult.spawnable.length === 0) {
|
|
1179
|
-
return {
|
|
1180
|
-
initiative: initId,
|
|
1181
|
-
wave: -1,
|
|
1182
|
-
wus: [],
|
|
1183
|
-
manifestPath: null,
|
|
1184
|
-
blockedBy: depResult.blockingDeps,
|
|
1185
|
-
waitingMessage: depResult.waitingMessage,
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
// Apply lane WIP=1 constraint: max one WU per lane per wave
|
|
1189
|
-
const selectedWUs = [];
|
|
1190
|
-
const usedLanes = new Set();
|
|
1191
|
-
for (const wu of depResult.spawnable) {
|
|
1192
|
-
const lane = wu.doc.lane;
|
|
1193
|
-
if (!usedLanes.has(lane)) {
|
|
1194
|
-
selectedWUs.push(wu);
|
|
1195
|
-
usedLanes.add(lane);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
// Determine wave number
|
|
1199
|
-
const waveNum = getNextWaveNumber(initId);
|
|
1200
|
-
// Build manifest
|
|
1201
|
-
// WU-1200: Use 'queued' status instead of 'spawned' to prevent confusion
|
|
1202
|
-
// 'queued' indicates the WU is ready to spawn, not that an agent was launched
|
|
1203
|
-
const manifest = {
|
|
1204
|
-
initiative: initId,
|
|
1205
|
-
wave: waveNum,
|
|
1206
|
-
created_at: new Date().toISOString(),
|
|
1207
|
-
wus: selectedWUs.map((wu) => ({
|
|
1208
|
-
id: wu.id,
|
|
1209
|
-
lane: wu.doc.lane,
|
|
1210
|
-
status: MANIFEST_WU_STATUS,
|
|
1211
|
-
})),
|
|
1212
|
-
lane_validation: 'pass',
|
|
1213
|
-
done_criteria: 'All stamps exist in .lumenflow/stamps/',
|
|
1214
|
-
};
|
|
1215
|
-
// WU-2277: Skip file creation in dry-run mode
|
|
1216
|
-
const manifestPath = join(WAVE_MANIFEST_DIR, `${initId}-wave-${waveNum}.json`);
|
|
1217
|
-
if (!dryRun) {
|
|
1218
|
-
// Ensure directory exists
|
|
1219
|
-
if (!existsSync(WAVE_MANIFEST_DIR)) {
|
|
1220
|
-
mkdirSync(WAVE_MANIFEST_DIR, { recursive: true });
|
|
1221
|
-
}
|
|
1222
|
-
// Write manifest
|
|
1223
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
1224
|
-
}
|
|
1225
|
-
return {
|
|
1226
|
-
initiative: initId,
|
|
1227
|
-
wave: waveNum,
|
|
1228
|
-
wus: manifest.wus,
|
|
1229
|
-
manifestPath,
|
|
1230
|
-
};
|
|
1231
|
-
}
|
|
1232
|
-
/**
|
|
1233
|
-
* Format checkpoint wave output with Task invocations.
|
|
1234
|
-
*
|
|
1235
|
-
* WU-1821: Token discipline - keep output minimal for context management.
|
|
1236
|
-
* WU-2040: Output full Task invocation blocks instead of pnpm wu:spawn meta-prompts.
|
|
1237
|
-
* WU-2280: Prevent false wave spawned confusion - use markdown code blocks and ACTION REQUIRED banner.
|
|
1238
|
-
* WU-2430: Handle dry-run mode - indicate preview mode clearly.
|
|
1239
|
-
*
|
|
1240
|
-
* @param {{initiative: string, wave: number, wus: Array<{id: string, lane: string}>, manifestPath: string, blockedBy?: string[], waitingMessage?: string, dryRun?: boolean}} waveData
|
|
1241
|
-
* @returns {string} Formatted output with embedded Task invocations
|
|
1242
|
-
*/
|
|
1243
|
-
export function formatCheckpointOutput(waveData) {
|
|
1244
|
-
const lines = [];
|
|
1245
|
-
const isDryRun = waveData.dryRun === true;
|
|
1246
|
-
// WU-2040: Handle blocked case with waiting message
|
|
1247
|
-
if (waveData.blockedBy && waveData.blockedBy.length > 0) {
|
|
1248
|
-
lines.push(`Waiting for dependencies to complete:`);
|
|
1249
|
-
for (const depId of waveData.blockedBy) {
|
|
1250
|
-
lines.push(` - ${depId}`);
|
|
1251
|
-
}
|
|
1252
|
-
lines.push('');
|
|
1253
|
-
lines.push(waveData.waitingMessage || 'No WUs can spawn until dependencies have stamps.');
|
|
1254
|
-
lines.push('');
|
|
1255
|
-
lines.push('Check dependency progress with:');
|
|
1256
|
-
lines.push(` pnpm mem:inbox --since 10m`);
|
|
1257
|
-
lines.push(` pnpm orchestrate:initiative -i ${waveData.initiative} -c`);
|
|
1258
|
-
return lines.join(STRING_LITERALS.NEWLINE);
|
|
1259
|
-
}
|
|
1260
|
-
// WU-2430: Dry-run header
|
|
1261
|
-
if (isDryRun) {
|
|
1262
|
-
lines.push('[DRY-RUN PREVIEW] Checkpoint mode output (no manifest written)');
|
|
1263
|
-
lines.push('');
|
|
1264
|
-
}
|
|
1265
|
-
lines.push(`Wave ${waveData.wave} manifest: ${waveData.manifestPath}`);
|
|
1266
|
-
lines.push(`WUs in this wave: ${waveData.wus.length}`);
|
|
1267
|
-
for (const wu of waveData.wus) {
|
|
1268
|
-
lines.push(` - ${wu.id} (${wu.lane})`);
|
|
1269
|
-
}
|
|
1270
|
-
lines.push('');
|
|
1271
|
-
// WU-2280: ACTION REQUIRED banner - per Anthropic skill best practices
|
|
1272
|
-
// Make it unambiguous that agents have NOT been spawned yet
|
|
1273
|
-
lines.push(BANNER_SEPARATOR);
|
|
1274
|
-
lines.push('ACTION REQUIRED: Agents have NOT been spawned yet.');
|
|
1275
|
-
lines.push('');
|
|
1276
|
-
lines.push('To spawn agents, copy the XML below and invoke the Task tool.');
|
|
1277
|
-
lines.push('The output below is documentation only - it will NOT execute automatically.');
|
|
1278
|
-
lines.push(BANNER_SEPARATOR);
|
|
1279
|
-
lines.push('');
|
|
1280
|
-
// WU-2280: Wrap XML in markdown code block to prevent confusion with actual tool calls
|
|
1281
|
-
// Raw XML output could be mistaken for a tool invocation by agents
|
|
1282
|
-
lines.push('```xml');
|
|
1283
|
-
// Build the Task invocation content
|
|
1284
|
-
const xmlLines = [];
|
|
1285
|
-
xmlLines.push(XML_PATTERNS.FUNCTION_CALLS_OPEN);
|
|
1286
|
-
for (const wu of waveData.wus) {
|
|
1287
|
-
try {
|
|
1288
|
-
// Generate full Task invocation with embedded spawn prompt
|
|
1289
|
-
const fullInvocation = generateEmbeddedSpawnPrompt(wu.id);
|
|
1290
|
-
// Extract just the inner invoke block (remove outer function_calls wrapper)
|
|
1291
|
-
const startIdx = fullInvocation.indexOf(XML_PATTERNS.INVOKE_OPEN);
|
|
1292
|
-
const endIdx = fullInvocation.indexOf(XML_PATTERNS.INVOKE_CLOSE);
|
|
1293
|
-
if (startIdx !== -1 && endIdx !== -1) {
|
|
1294
|
-
const invokeBlock = fullInvocation.substring(startIdx, endIdx + XML_PATTERNS.INVOKE_CLOSE.length);
|
|
1295
|
-
xmlLines.push(invokeBlock);
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
catch {
|
|
1299
|
-
// Fallback to simple reference if WU file not found
|
|
1300
|
-
xmlLines.push(`<!-- Could not generate Task invocation for ${wu.id} -->`);
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
xmlLines.push(XML_PATTERNS.FUNCTION_CALLS_CLOSE);
|
|
1304
|
-
lines.push(xmlLines.join(STRING_LITERALS.NEWLINE));
|
|
1305
|
-
lines.push('```');
|
|
1306
|
-
lines.push('');
|
|
1307
|
-
lines.push('Resume with:');
|
|
1308
|
-
lines.push(` pnpm mem:ready --wu WU-ORCHESTRATOR`);
|
|
1309
|
-
lines.push(` pnpm orchestrate:initiative -i ${waveData.initiative} -c`);
|
|
1310
|
-
return lines.join(STRING_LITERALS.NEWLINE);
|
|
1311
|
-
}
|
|
1312
|
-
/**
|
|
1313
|
-
* WU-2027: Generate embedded spawn prompt for a WU.
|
|
1314
|
-
*
|
|
1315
|
-
* Instead of outputting a meta-prompt like "Run: pnpm wu:spawn --id WU-XXX",
|
|
1316
|
-
* this function runs the spawn logic internally and returns the full ~3KB
|
|
1317
|
-
* prompt content ready for embedding in a Task invocation.
|
|
1318
|
-
*
|
|
1319
|
-
* This follows Anthropic guidance that sub-agent prompts must be fully
|
|
1320
|
-
* self-contained to prevent delegation failures.
|
|
1321
|
-
*
|
|
1322
|
-
* @param {string} wuId - WU ID (e.g., 'WU-001')
|
|
1323
|
-
* @returns {string} Escaped spawn prompt content ready for XML embedding
|
|
1324
|
-
* @throws {Error} If WU file not found or cannot be parsed
|
|
1325
|
-
*/
|
|
1326
|
-
export function generateEmbeddedSpawnPrompt(wuId) {
|
|
1327
|
-
const wuPath = WU_PATHS.WU(wuId);
|
|
1328
|
-
if (!existsSync(wuPath)) {
|
|
1329
|
-
throw createError(ErrorCodes.WU_NOT_FOUND, `WU file not found: ${wuPath}`, {
|
|
1330
|
-
wuId,
|
|
1331
|
-
path: wuPath,
|
|
1332
|
-
});
|
|
1333
|
-
}
|
|
1334
|
-
// Read and parse WU YAML
|
|
1335
|
-
const text = readFileSync(wuPath, 'utf8');
|
|
1336
|
-
const doc = parseYAML(text);
|
|
1337
|
-
// Generate the full Task invocation (includes XML wrapper)
|
|
1338
|
-
// The prompt is already XML-escaped in generateTaskInvocation
|
|
1339
|
-
return generateTaskInvocation(doc, wuId, SpawnStrategyFactory.create('claude-code'));
|
|
1340
|
-
}
|
|
1341
|
-
/**
|
|
1342
|
-
* WU-2027: Format a Task invocation with embedded spawn content for a WU.
|
|
1343
|
-
*
|
|
1344
|
-
* Creates a complete Task tool invocation block with the full spawn prompt
|
|
1345
|
-
* embedded directly, rather than a meta-prompt referencing wu:spawn.
|
|
1346
|
-
*
|
|
1347
|
-
* @param {{id: string, doc: object}} wu - WU with id and YAML doc
|
|
1348
|
-
* @returns {string} Complete Task invocation with embedded spawn content
|
|
1349
|
-
*/
|
|
1350
|
-
export function formatTaskInvocationWithEmbeddedSpawn(wu) {
|
|
1351
|
-
// Generate the full Task invocation for this WU
|
|
1352
|
-
return generateTaskInvocation(wu.doc, wu.id, SpawnStrategyFactory.create('claude-code'));
|
|
1353
|
-
}
|
|
1354
|
-
/**
|
|
1355
|
-
* WU-2027: Format execution plan with embedded spawns (no meta-prompts).
|
|
1356
|
-
* WU-2280: Updated to use markdown code blocks and ACTION REQUIRED banner.
|
|
1357
|
-
*
|
|
1358
|
-
* Generates Task invocation blocks for all WUs in the execution plan,
|
|
1359
|
-
* with full spawn content embedded directly. This replaces the meta-prompt
|
|
1360
|
-
* pattern that was causing delegation failures.
|
|
1361
|
-
*
|
|
1362
|
-
* @param {{waves: Array<Array<{id: string, doc: object}>>, skipped: string[]}} plan - Execution plan
|
|
1363
|
-
* @returns {string} Formatted output with embedded Task invocations
|
|
1364
|
-
*/
|
|
1365
|
-
export function formatExecutionPlanWithEmbeddedSpawns(plan) {
|
|
1366
|
-
const lines = [];
|
|
1367
|
-
if (plan.waves.length === 0) {
|
|
1368
|
-
return 'No pending WUs to execute.';
|
|
1369
|
-
}
|
|
1370
|
-
for (let waveIndex = 0; waveIndex < plan.waves.length; waveIndex++) {
|
|
1371
|
-
const wave = plan.waves[waveIndex];
|
|
1372
|
-
lines.push(`## Wave ${waveIndex} (${wave.length} WU${wave.length !== 1 ? 's' : ''} in parallel)`);
|
|
1373
|
-
lines.push('');
|
|
1374
|
-
// WU-2280: ACTION REQUIRED banner - per Anthropic skill best practices
|
|
1375
|
-
lines.push(BANNER_SEPARATOR);
|
|
1376
|
-
lines.push('ACTION REQUIRED: Agents have NOT been spawned yet.');
|
|
1377
|
-
lines.push('');
|
|
1378
|
-
lines.push('To spawn agents, copy the XML below and invoke the Task tool.');
|
|
1379
|
-
lines.push('The output below is documentation only - it will NOT execute automatically.');
|
|
1380
|
-
lines.push(BANNER_SEPARATOR);
|
|
1381
|
-
lines.push('');
|
|
1382
|
-
// WU-2280: Wrap XML in markdown code block to prevent confusion with actual tool calls
|
|
1383
|
-
lines.push('```xml');
|
|
1384
|
-
// Build parallel spawn block for this wave
|
|
1385
|
-
const xmlLines = [];
|
|
1386
|
-
const openTag = '<' + 'antml:function_calls>';
|
|
1387
|
-
const closeTag = '</' + 'antml:function_calls>';
|
|
1388
|
-
xmlLines.push(openTag);
|
|
1389
|
-
for (const wu of wave) {
|
|
1390
|
-
const fullInvocation = generateTaskInvocation(wu.doc, wu.id, SpawnStrategyFactory.create('claude-code'));
|
|
1391
|
-
// Extract just the inner invoke block (remove outer function_calls wrapper)
|
|
1392
|
-
// Use indexOf for reliable extraction (regex can have escaping issues)
|
|
1393
|
-
const startPattern = '<' + 'antml:invoke';
|
|
1394
|
-
const endPattern = '</' + 'antml:invoke>';
|
|
1395
|
-
const startIdx = fullInvocation.indexOf(startPattern);
|
|
1396
|
-
const endIdx = fullInvocation.indexOf(endPattern);
|
|
1397
|
-
if (startIdx !== -1 && endIdx !== -1) {
|
|
1398
|
-
let invokeBlock = fullInvocation.substring(startIdx, endIdx + endPattern.length);
|
|
1399
|
-
// Add run_in_background parameter for parallel execution
|
|
1400
|
-
if (!invokeBlock.includes('run_in_background')) {
|
|
1401
|
-
const paramOpen = '<' + 'antml:parameter name="';
|
|
1402
|
-
const paramClose = '</' + 'antml:parameter>';
|
|
1403
|
-
const invokeTag = '<' + 'antml:invoke name="Task">';
|
|
1404
|
-
invokeBlock = invokeBlock.replace(invokeTag, `${invokeTag}\n${paramOpen}run_in_background">true${paramClose}`);
|
|
1405
|
-
}
|
|
1406
|
-
xmlLines.push(invokeBlock);
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
xmlLines.push(closeTag);
|
|
1410
|
-
lines.push(xmlLines.join(STRING_LITERALS.NEWLINE));
|
|
1411
|
-
lines.push('```');
|
|
1412
|
-
lines.push('');
|
|
1413
|
-
if (waveIndex < plan.waves.length - 1) {
|
|
1414
|
-
lines.push(`After all Wave ${waveIndex} agents complete, proceed to Wave ${waveIndex + 1}.`);
|
|
1415
|
-
lines.push('Before next wave: pnpm mem:inbox --since 10m (check for bug discoveries)');
|
|
1416
|
-
lines.push('');
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
return lines.join(STRING_LITERALS.NEWLINE);
|
|
1420
|
-
}
|
|
1421
|
-
/**
|
|
1422
|
-
* WU-1326: Get lock_policy for a lane from configuration.
|
|
1423
|
-
*
|
|
1424
|
-
* Returns the lock_policy from config if specified, otherwise defaults to 'all'
|
|
1425
|
-
* for backward compatibility.
|
|
1426
|
-
*
|
|
1427
|
-
* @param {string} lane - Lane name (e.g., 'Framework: Core')
|
|
1428
|
-
* @param {Record<string, LaneConfig> | undefined} laneConfigs - Lane configurations
|
|
1429
|
-
* @returns {LockPolicy} The lock_policy for the lane ('all' | 'active' | 'none')
|
|
1430
|
-
*/
|
|
1431
|
-
export function getLockPolicyForLane(lane, laneConfigs) {
|
|
1432
|
-
if (!laneConfigs) {
|
|
1433
|
-
return 'all'; // Default for backward compatibility
|
|
1434
|
-
}
|
|
1435
|
-
const config = laneConfigs[lane];
|
|
1436
|
-
if (!config || !config.lock_policy) {
|
|
1437
|
-
return 'all'; // Default for unspecified lanes
|
|
1438
|
-
}
|
|
1439
|
-
return config.lock_policy;
|
|
1440
|
-
}
|
|
1441
|
-
/**
|
|
1442
|
-
* WU-1326: Check if a WU status holds the lane lock based on lock_policy.
|
|
1443
|
-
*
|
|
1444
|
-
* - policy=all: both 'in_progress' and 'blocked' hold lane lock
|
|
1445
|
-
* - policy=active: only 'in_progress' holds lane lock
|
|
1446
|
-
* - policy=none: nothing holds lane lock (no WIP checking)
|
|
1447
|
-
*
|
|
1448
|
-
* @param {string} status - WU status
|
|
1449
|
-
* @param {LockPolicy} policy - Lane lock policy
|
|
1450
|
-
* @returns {boolean} True if status holds lane lock
|
|
1451
|
-
*/
|
|
1452
|
-
function _statusHoldsLaneLock(status, policy) {
|
|
1453
|
-
if (policy === 'none') {
|
|
1454
|
-
return false; // No WIP checking
|
|
1455
|
-
}
|
|
1456
|
-
if (policy === 'active') {
|
|
1457
|
-
// Only in_progress holds lane lock
|
|
1458
|
-
return status === WU_STATUS.IN_PROGRESS;
|
|
1459
|
-
}
|
|
1460
|
-
// policy === 'all' (default) - both in_progress and blocked hold lane
|
|
1461
|
-
return status === WU_STATUS.IN_PROGRESS || status === WU_STATUS.BLOCKED;
|
|
1462
|
-
}
|
|
1463
|
-
/**
|
|
1464
|
-
* WU-1326: Build execution plan respecting lock_policy per lane.
|
|
1465
|
-
*
|
|
1466
|
-
* This is an enhanced version of buildExecutionPlan that respects lock_policy
|
|
1467
|
-
* when determining lane occupancy for wave building.
|
|
1468
|
-
*
|
|
1469
|
-
* When policy=active, blocked WUs do NOT prevent ready WUs in the same lane
|
|
1470
|
-
* from being scheduled in the same wave.
|
|
1471
|
-
*
|
|
1472
|
-
* @param {Array<{id: string, doc: object}>} wus - WUs to plan
|
|
1473
|
-
* @param {LockPolicyOptions} options - Lock policy options including laneConfigs
|
|
1474
|
-
* @returns {ExecutionPlan} Execution plan with waves, skipped, and deferred WUs
|
|
1475
|
-
*/
|
|
1476
|
-
// eslint-disable-next-line sonarjs/cognitive-complexity -- wave-building logic inherently complex
|
|
1477
|
-
export function buildExecutionPlanWithLockPolicy(wus, options = {}) {
|
|
1478
|
-
const { laneConfigs = {} } = options;
|
|
1479
|
-
// WU-2430: Enhanced categorisation of WUs
|
|
1480
|
-
const skipped = []; // IDs of done WUs (backwards compat)
|
|
1481
|
-
const skippedWithReasons = []; // WU-2430: Non-ready WUs with reasons
|
|
1482
|
-
const deferred = []; // WU-2430: Ready WUs waiting on external blockers
|
|
1483
|
-
const doneStatuses = new Set([WU_STATUS.DONE, WU_STATUS.COMPLETED]);
|
|
1484
|
-
// Categorise WUs by status
|
|
1485
|
-
for (const wu of wus) {
|
|
1486
|
-
const status = wu.doc.status ?? 'unknown';
|
|
1487
|
-
if (doneStatuses.has(status)) {
|
|
1488
|
-
skipped.push(wu.id);
|
|
1489
|
-
}
|
|
1490
|
-
else if (status !== WU_STATUS.READY) {
|
|
1491
|
-
skippedWithReasons.push({ id: wu.id, reason: `status: ${status}` });
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
// WU-2430: Only ready WUs are candidates for execution
|
|
1495
|
-
const readyWUs = wus.filter((wu) => wu.doc.status === WU_STATUS.READY);
|
|
1496
|
-
if (readyWUs.length === 0) {
|
|
1497
|
-
return { waves: [], skipped, skippedWithReasons, deferred };
|
|
1498
|
-
}
|
|
1499
|
-
// Build a map for quick lookup
|
|
1500
|
-
const wuMap = new Map(readyWUs.map((wu) => [wu.id, wu]));
|
|
1501
|
-
const wuIds = new Set(wuMap.keys());
|
|
1502
|
-
const allWuMap = new Map(wus.map((wu) => [wu.id, wu]));
|
|
1503
|
-
const allWuIds = new Set(allWuMap.keys());
|
|
1504
|
-
// Build dependency graph for validation (check cycles)
|
|
1505
|
-
const graph = buildDependencyGraph();
|
|
1506
|
-
const { cycles } = validateGraph(graph);
|
|
1507
|
-
// Filter cycles to only those involving our WUs
|
|
1508
|
-
const relevantCycles = cycles.filter((cycle) => cycle.some((id) => wuIds.has(id)));
|
|
1509
|
-
if (relevantCycles.length > 0) {
|
|
1510
|
-
const cycleStr = relevantCycles.map((c) => c.join(' → ')).join('; ');
|
|
1511
|
-
throw createError(ErrorCodes.VALIDATION_ERROR, `Circular dependencies detected: ${cycleStr}`, {
|
|
1512
|
-
cycles: relevantCycles,
|
|
1513
|
-
});
|
|
1514
|
-
}
|
|
1515
|
-
// WU-2430: Check for external blockers without stamps
|
|
1516
|
-
const deferredIds = new Set();
|
|
1517
|
-
const deferredReasons = new Map();
|
|
1518
|
-
const deferredBlockers = new Map();
|
|
1519
|
-
const addDeferredEntry = (wuId, blockers, reason) => {
|
|
1520
|
-
deferredIds.add(wuId);
|
|
1521
|
-
let reasonSet = deferredReasons.get(wuId);
|
|
1522
|
-
let blockerSet = deferredBlockers.get(wuId);
|
|
1523
|
-
if (!reasonSet) {
|
|
1524
|
-
reasonSet = new Set();
|
|
1525
|
-
deferredReasons.set(wuId, reasonSet);
|
|
1526
|
-
}
|
|
1527
|
-
if (!blockerSet) {
|
|
1528
|
-
blockerSet = new Set();
|
|
1529
|
-
deferredBlockers.set(wuId, blockerSet);
|
|
1530
|
-
}
|
|
1531
|
-
for (const blockerId of blockers) {
|
|
1532
|
-
blockerSet.add(blockerId);
|
|
1533
|
-
}
|
|
1534
|
-
reasonSet.add(reason);
|
|
1535
|
-
};
|
|
1536
|
-
for (const wu of readyWUs) {
|
|
1537
|
-
const blockers = getAllDependencies(wu.doc);
|
|
1538
|
-
const externalBlockers = blockers.filter((blockerId) => !allWuIds.has(blockerId));
|
|
1539
|
-
const internalBlockers = blockers.filter((blockerId) => allWuIds.has(blockerId));
|
|
1540
|
-
if (externalBlockers.length > 0) {
|
|
1541
|
-
const unstampedBlockers = externalBlockers.filter((blockerId) => !hasStamp(blockerId));
|
|
1542
|
-
if (unstampedBlockers.length > 0) {
|
|
1543
|
-
addDeferredEntry(wu.id, unstampedBlockers, `waiting for external: ${unstampedBlockers.join(', ')}`);
|
|
1544
|
-
}
|
|
1545
|
-
}
|
|
1546
|
-
if (internalBlockers.length > 0) {
|
|
1547
|
-
const nonReadyInternal = internalBlockers.filter((blockerId) => {
|
|
1548
|
-
const blocker = allWuMap.get(blockerId);
|
|
1549
|
-
const status = blocker?.doc?.status ?? 'unknown';
|
|
1550
|
-
if (status === WU_STATUS.READY) {
|
|
1551
|
-
return false;
|
|
1552
|
-
}
|
|
1553
|
-
return !doneStatuses.has(status);
|
|
1554
|
-
});
|
|
1555
|
-
if (nonReadyInternal.length > 0) {
|
|
1556
|
-
const details = nonReadyInternal.map((blockerId) => {
|
|
1557
|
-
const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
|
|
1558
|
-
return `${blockerId} (status: ${status})`;
|
|
1559
|
-
});
|
|
1560
|
-
addDeferredEntry(wu.id, nonReadyInternal, `waiting for internal: ${details.join(', ')}`);
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
}
|
|
1564
|
-
let hasNewDeferral = true;
|
|
1565
|
-
while (hasNewDeferral) {
|
|
1566
|
-
hasNewDeferral = false;
|
|
1567
|
-
for (const wu of readyWUs) {
|
|
1568
|
-
if (deferredIds.has(wu.id)) {
|
|
1569
|
-
continue;
|
|
1570
|
-
}
|
|
1571
|
-
const blockers = getAllDependencies(wu.doc);
|
|
1572
|
-
const deferredInternal = blockers.filter((blockerId) => allWuIds.has(blockerId) && deferredIds.has(blockerId));
|
|
1573
|
-
if (deferredInternal.length > 0) {
|
|
1574
|
-
const details = deferredInternal.map((blockerId) => {
|
|
1575
|
-
const status = allWuMap.get(blockerId)?.doc?.status ?? 'unknown';
|
|
1576
|
-
return `${blockerId} (status: ${status})`;
|
|
1577
|
-
});
|
|
1578
|
-
addDeferredEntry(wu.id, deferredInternal, `waiting for internal: ${details.join(', ')}`);
|
|
1579
|
-
hasNewDeferral = true;
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1582
|
-
}
|
|
1583
|
-
for (const wu of readyWUs) {
|
|
1584
|
-
if (deferredIds.has(wu.id)) {
|
|
1585
|
-
const blockerSet = deferredBlockers.get(wu.id) || new Set();
|
|
1586
|
-
const reasonSet = deferredReasons.get(wu.id) || new Set();
|
|
1587
|
-
deferred.push({
|
|
1588
|
-
id: wu.id,
|
|
1589
|
-
blockedBy: Array.from(blockerSet),
|
|
1590
|
-
reason: reasonSet.size > 0 ? Array.from(reasonSet).join('; ') : DEFAULT_DEFERRED_REASON,
|
|
1591
|
-
});
|
|
1592
|
-
}
|
|
1593
|
-
}
|
|
1594
|
-
// Remove deferred WUs from candidates
|
|
1595
|
-
const schedulableWUs = readyWUs.filter((wu) => !deferredIds.has(wu.id));
|
|
1596
|
-
const schedulableMap = new Map(schedulableWUs.map((wu) => [wu.id, wu]));
|
|
1597
|
-
const schedulableIds = new Set(schedulableMap.keys());
|
|
1598
|
-
if (schedulableIds.size === 0) {
|
|
1599
|
-
return { waves: [], skipped, skippedWithReasons, deferred };
|
|
1600
|
-
}
|
|
1601
|
-
// WU-1326: Build set of lanes currently occupied based on policy
|
|
1602
|
-
// Track which lanes are occupied by in_progress or blocked WUs
|
|
1603
|
-
const lanesOccupiedByInProgress = new Set();
|
|
1604
|
-
const lanesOccupiedByBlocked = new Set();
|
|
1605
|
-
for (const wu of wus) {
|
|
1606
|
-
const status = wu.doc.status ?? 'unknown';
|
|
1607
|
-
const lane = wu.doc.lane;
|
|
1608
|
-
if (lane) {
|
|
1609
|
-
if (status === WU_STATUS.IN_PROGRESS) {
|
|
1610
|
-
lanesOccupiedByInProgress.add(lane);
|
|
1611
|
-
}
|
|
1612
|
-
else if (status === WU_STATUS.BLOCKED) {
|
|
1613
|
-
lanesOccupiedByBlocked.add(lane);
|
|
1614
|
-
}
|
|
1615
|
-
}
|
|
1616
|
-
}
|
|
1617
|
-
// Build waves using Kahn's algorithm (topological sort by levels)
|
|
1618
|
-
// WU-1326: Enforce lane WIP based on lock_policy
|
|
1619
|
-
const waves = [];
|
|
1620
|
-
const remaining = new Set(schedulableIds);
|
|
1621
|
-
const completed = new Set(skipped);
|
|
1622
|
-
// Also treat stamped external deps as completed
|
|
1623
|
-
for (const wu of wus) {
|
|
1624
|
-
const blockers = getAllDependencies(wu.doc);
|
|
1625
|
-
for (const blockerId of blockers) {
|
|
1626
|
-
if (!allWuIds.has(blockerId) && hasStamp(blockerId)) {
|
|
1627
|
-
completed.add(blockerId);
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
while (remaining.size > 0) {
|
|
1632
|
-
const wave = [];
|
|
1633
|
-
const lanesInWave = new Set(); // Track lanes used in this wave
|
|
1634
|
-
const deferredToNextWave = []; // WUs that could run but lane is occupied
|
|
1635
|
-
for (const id of remaining) {
|
|
1636
|
-
const wu = schedulableMap.get(id);
|
|
1637
|
-
if (!wu)
|
|
1638
|
-
continue; // Should not happen - remaining only contains valid IDs
|
|
1639
|
-
const blockers = getAllDependencies(wu.doc);
|
|
1640
|
-
// Check if all blockers are either done or completed in previous waves
|
|
1641
|
-
const allBlockersDone = blockers.every((blockerId) => completed.has(blockerId));
|
|
1642
|
-
if (allBlockersDone) {
|
|
1643
|
-
const lane = wu.doc.lane ?? '';
|
|
1644
|
-
// WU-1326: Get lock_policy for this lane
|
|
1645
|
-
const policy = getLockPolicyForLane(lane, laneConfigs);
|
|
1646
|
-
// WU-1326: Check if lane is already occupied in this wave
|
|
1647
|
-
// Skip this check when policy=none (allows unlimited parallel WUs in same lane)
|
|
1648
|
-
if (policy !== 'none' && lanesInWave.has(lane)) {
|
|
1649
|
-
// Defer to next wave (lane conflict within this wave)
|
|
1650
|
-
deferredToNextWave.push(wu);
|
|
1651
|
-
continue;
|
|
1652
|
-
}
|
|
1653
|
-
// WU-1326: Check if lane is occupied by existing WUs based on policy
|
|
1654
|
-
// policy=none: laneBlocked stays false (no WIP checking)
|
|
1655
|
-
// policy=active: only in_progress blocks
|
|
1656
|
-
// policy=all: both in_progress and blocked block
|
|
1657
|
-
let laneBlocked = false;
|
|
1658
|
-
if (policy === 'active') {
|
|
1659
|
-
// Only in_progress WUs block the lane
|
|
1660
|
-
laneBlocked = lanesOccupiedByInProgress.has(lane);
|
|
1661
|
-
}
|
|
1662
|
-
else if (policy === 'all') {
|
|
1663
|
-
// policy === 'all' (default): both in_progress and blocked block lane
|
|
1664
|
-
laneBlocked = lanesOccupiedByInProgress.has(lane) || lanesOccupiedByBlocked.has(lane);
|
|
1665
|
-
}
|
|
1666
|
-
// policy === 'none': laneBlocked remains false
|
|
1667
|
-
if (laneBlocked) {
|
|
1668
|
-
// Lane is occupied by existing WU based on policy
|
|
1669
|
-
deferredToNextWave.push(wu);
|
|
1670
|
-
}
|
|
1671
|
-
else {
|
|
1672
|
-
wave.push(wu);
|
|
1673
|
-
lanesInWave.add(lane);
|
|
1674
|
-
}
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
// Deadlock detection: if no WUs can be scheduled but remaining exist
|
|
1678
|
-
if (wave.length === 0 && remaining.size > 0 && deferredToNextWave.length === 0) {
|
|
1679
|
-
const stuckIds = Array.from(remaining);
|
|
1680
|
-
throw createError(ErrorCodes.VALIDATION_ERROR, `Circular or unresolvable dependencies detected. Stuck WUs: ${stuckIds.join(', ')}`, { stuckIds });
|
|
1681
|
-
}
|
|
1682
|
-
// Add wave and mark WUs as completed
|
|
1683
|
-
if (wave.length > 0) {
|
|
1684
|
-
waves.push(wave);
|
|
1685
|
-
for (const wu of wave) {
|
|
1686
|
-
remaining.delete(wu.id);
|
|
1687
|
-
completed.add(wu.id);
|
|
1688
|
-
}
|
|
1689
|
-
}
|
|
1690
|
-
// Add deferred WUs back to remaining for next wave (if wave had items)
|
|
1691
|
-
// If wave was empty but we have deferred items, we need to make progress
|
|
1692
|
-
if (wave.length === 0 && deferredToNextWave.length > 0) {
|
|
1693
|
-
// Schedule one deferred WU per lane to make progress
|
|
1694
|
-
const processedLanes = new Set();
|
|
1695
|
-
for (const wu of deferredToNextWave) {
|
|
1696
|
-
const lane = wu.doc.lane ?? '';
|
|
1697
|
-
if (!processedLanes.has(lane)) {
|
|
1698
|
-
wave.push(wu);
|
|
1699
|
-
processedLanes.add(lane);
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
if (wave.length > 0) {
|
|
1703
|
-
waves.push(wave);
|
|
1704
|
-
for (const wu of wave) {
|
|
1705
|
-
remaining.delete(wu.id);
|
|
1706
|
-
completed.add(wu.id);
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
return { waves, skipped, skippedWithReasons, deferred };
|
|
1712
|
-
}
|
|
1713
|
-
/**
|
|
1714
|
-
* WU-1326: Get lane availability respecting lock_policy.
|
|
1715
|
-
*
|
|
1716
|
-
* Returns availability status for each lane based on current WU states
|
|
1717
|
-
* and configured lock_policy.
|
|
1718
|
-
*
|
|
1719
|
-
* @param {Array<{id: string, doc: object}>} wus - WUs to check
|
|
1720
|
-
* @param {LockPolicyOptions} options - Lock policy options
|
|
1721
|
-
* @returns {Record<string, LaneAvailabilityResult>} Lane availability map
|
|
1722
|
-
*/
|
|
1723
|
-
// eslint-disable-next-line sonarjs/cognitive-complexity -- lane availability logic with multiple policy branches
|
|
1724
|
-
export function getLaneAvailability(wus, options = {}) {
|
|
1725
|
-
const { laneConfigs = {} } = options;
|
|
1726
|
-
const result = {};
|
|
1727
|
-
// Group WUs by lane
|
|
1728
|
-
const wusByLane = new Map();
|
|
1729
|
-
for (const wu of wus) {
|
|
1730
|
-
const lane = wu.doc.lane;
|
|
1731
|
-
if (lane) {
|
|
1732
|
-
const laneWUs = wusByLane.get(lane);
|
|
1733
|
-
if (laneWUs) {
|
|
1734
|
-
laneWUs.push(wu);
|
|
1735
|
-
}
|
|
1736
|
-
else {
|
|
1737
|
-
wusByLane.set(lane, [wu]);
|
|
1738
|
-
}
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
// Calculate availability for each lane
|
|
1742
|
-
for (const [lane, laneWUs] of wusByLane) {
|
|
1743
|
-
const policy = getLockPolicyForLane(lane, laneConfigs);
|
|
1744
|
-
let inProgressCount = 0;
|
|
1745
|
-
let blockedCount = 0;
|
|
1746
|
-
let occupiedBy;
|
|
1747
|
-
for (const wu of laneWUs) {
|
|
1748
|
-
const status = wu.doc.status ?? 'unknown';
|
|
1749
|
-
if (status === WU_STATUS.IN_PROGRESS) {
|
|
1750
|
-
inProgressCount++;
|
|
1751
|
-
if (!occupiedBy) {
|
|
1752
|
-
occupiedBy = wu.id;
|
|
1753
|
-
}
|
|
1754
|
-
}
|
|
1755
|
-
else if (status === WU_STATUS.BLOCKED) {
|
|
1756
|
-
blockedCount++;
|
|
1757
|
-
// Only set occupiedBy for blocked if policy=all
|
|
1758
|
-
if (policy === 'all' && !occupiedBy) {
|
|
1759
|
-
occupiedBy = wu.id;
|
|
1760
|
-
}
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1763
|
-
// Determine availability based on policy
|
|
1764
|
-
let available = false;
|
|
1765
|
-
if (policy === 'none') {
|
|
1766
|
-
// No WIP checking - always available
|
|
1767
|
-
available = true;
|
|
1768
|
-
occupiedBy = undefined;
|
|
1769
|
-
}
|
|
1770
|
-
else if (policy === 'active') {
|
|
1771
|
-
// Only in_progress blocks
|
|
1772
|
-
available = inProgressCount === 0;
|
|
1773
|
-
if (available) {
|
|
1774
|
-
occupiedBy = undefined;
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
else {
|
|
1778
|
-
// policy === 'all': both in_progress and blocked block
|
|
1779
|
-
available = inProgressCount === 0 && blockedCount === 0;
|
|
1780
|
-
}
|
|
1781
|
-
result[lane] = {
|
|
1782
|
-
available,
|
|
1783
|
-
policy,
|
|
1784
|
-
occupiedBy,
|
|
1785
|
-
blockedCount,
|
|
1786
|
-
inProgressCount,
|
|
1787
|
-
};
|
|
1788
|
-
}
|
|
1789
|
-
return result;
|
|
1790
|
-
}
|
|
1791
|
-
export { LOG_PREFIX };
|
|
20
|
+
// ── Shared utilities ──────────────────────────────────────────────────────────
|
|
21
|
+
export { LOG_PREFIX } from './orchestrator/shared.js';
|
|
22
|
+
// ── Execution planning ────────────────────────────────────────────────────────
|
|
23
|
+
export { buildExecutionPlan, buildExecutionPlanAsync, buildExecutionPlanWithLockPolicy, } from './orchestrator/execution-planning.js';
|
|
24
|
+
// ── Checkpoint ────────────────────────────────────────────────────────────────
|
|
25
|
+
export { CHECKPOINT_AUTO_THRESHOLDS, filterByDependencyStamps, shouldAutoEnableCheckpoint, shouldAutoEnableCheckpointAsync, resolveCheckpointMode, resolveCheckpointModeAsync, validateCheckpointFlags, buildCheckpointWave, } from './orchestrator/checkpoint.js';
|
|
26
|
+
// ── Formatting ────────────────────────────────────────────────────────────────
|
|
27
|
+
export { formatExecutionPlan, generateSpawnCommands, calculateProgress, formatProgress, getBottleneckWUs, formatCheckpointOutput, generateEmbeddedSpawnPrompt, formatTaskInvocationWithEmbeddedSpawn, formatExecutionPlanWithEmbeddedSpawns, } from './orchestrator/formatting.js';
|
|
28
|
+
// ── Spawn status ──────────────────────────────────────────────────────────────
|
|
29
|
+
export { getManifestWUStatus, isWUActuallySpawned, getSpawnCandidatesWithYAMLCheck, } from './orchestrator/spawn-status.js';
|
|
30
|
+
// ── Lane policy ───────────────────────────────────────────────────────────────
|
|
31
|
+
export { getLockPolicyForLane, getLaneAvailability } from './orchestrator/lane-policy.js';
|
|
32
|
+
// ── Initiative loading ────────────────────────────────────────────────────────
|
|
33
|
+
export { loadInitiativeWUs, loadMultipleInitiatives } from './orchestrator/initiative-loading.js';
|
|
1792
34
|
//# sourceMappingURL=initiative-orchestrator.js.map
|