@smartmemory/compose 0.2.9-beta → 0.2.11-beta
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/contracts/par-merge-bounce.json +39 -0
- package/lib/build.js +202 -18
- package/lib/gsd.js +58 -4
- package/lib/step-prompt.js +6 -0
- package/lib/stratum-mcp-client.js +6 -2
- package/package.json +1 -1
- package/pipelines/gsd.stratum.yaml +12 -4
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "par-merge-bounce.json",
|
|
4
|
+
"_source": "docs/features/COMP-PAR-MERGE-QUEUE/design.md",
|
|
5
|
+
"_roadmap": "COMP-PAR-MERGE-QUEUE",
|
|
6
|
+
"title": "ParMergeBounce",
|
|
7
|
+
"description": "A single bounce record produced by parallel_dispatch's post-dispatch merge gate. Unifies both failure kinds (a failed pre-merge verify gate, or a merge conflict during diff apply) into one structured contract. It is surfaced on the ensure_failed.bounced_tasks[] envelope AND persisted onto the failed task's state so that when the step is re-dispatched, Stratum's ParallelExecutor._render_prompt injects the failure context into the re-run task's prompt (server-side, because the server re-resolves the task list on each re-dispatch). Convention: _source = design doc origin, _roadmap = feature code.",
|
|
8
|
+
"type": "object",
|
|
9
|
+
"required": ["task_id", "reason", "files", "excerpt"],
|
|
10
|
+
"properties": {
|
|
11
|
+
"task_id": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"description": "Which task to bounce / re-dispatch."
|
|
14
|
+
},
|
|
15
|
+
"reason": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"enum": ["gate_failed", "merge_conflict"],
|
|
18
|
+
"description": "Why the task bounced. 'gate_failed' = a pre_merge_verify command exited non-zero in the task worktree; 'merge_conflict' = git apply --check/apply failed when merging the task diff onto base."
|
|
19
|
+
},
|
|
20
|
+
"files": {
|
|
21
|
+
"type": "array",
|
|
22
|
+
"items": { "type": "string" },
|
|
23
|
+
"description": "Files involved. gate_failed: from `git diff --name-only HEAD` in the worktree. merge_conflict: parsed from the git-apply error, falling back to the task's owned files."
|
|
24
|
+
},
|
|
25
|
+
"command": {
|
|
26
|
+
"type": ["string", "null"],
|
|
27
|
+
"description": "The gate command that failed, e.g. 'pnpm build'. Populated for gate_failed only; null for merge_conflict."
|
|
28
|
+
},
|
|
29
|
+
"exit_code": {
|
|
30
|
+
"type": ["integer", "null"],
|
|
31
|
+
"description": "Exit code of the failed gate command. Populated for gate_failed only; null for merge_conflict."
|
|
32
|
+
},
|
|
33
|
+
"excerpt": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "Bounded (~2KB) tail of the gate stdout+stderr (gate_failed) OR the git-apply error text (merge_conflict). Bounded to avoid unbounded prompt growth and to limit credential leakage; do not echo environment."
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"additionalProperties": true
|
|
39
|
+
}
|
package/lib/build.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Gates resolved via CLI readline prompt.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
|
12
12
|
import { join, resolve, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { homedir, tmpdir } from 'node:os';
|
|
@@ -3027,6 +3027,13 @@ export async function executeParallelDispatchServer(
|
|
|
3027
3027
|
const captureTiming = context?.gsd === true && !!context?.featureCode;
|
|
3028
3028
|
const taskTiming = {};
|
|
3029
3029
|
|
|
3030
|
+
// COMP-PAR-MERGE-QUEUE: hold the merge+advance outcome (and its summary) so the
|
|
3031
|
+
// re-dispatch check can run AFTER the finally (unsubscribe) below, and so the
|
|
3032
|
+
// terminal `build_step_done` event fires only for the FINAL attempt — not for an
|
|
3033
|
+
// attempt that is immediately re-dispatched. Terminal stuck/budget returns inside
|
|
3034
|
+
// the try short-circuit and never set these.
|
|
3035
|
+
let _redispatchOutcome = null;
|
|
3036
|
+
let _finalSummary = null;
|
|
3030
3037
|
try {
|
|
3031
3038
|
// Poll until outcome is present (NOT can_advance — see design §3)
|
|
3032
3039
|
let pollResult;
|
|
@@ -3160,7 +3167,7 @@ export async function executeParallelDispatchServer(
|
|
|
3160
3167
|
if (hasServerMerge) {
|
|
3161
3168
|
if (pollResult.outcome?.status === 'awaiting_consumer_advance') {
|
|
3162
3169
|
// DEFER PATH: merge locally, report merge_status, let flow advance with truth.
|
|
3163
|
-
const { mergeStatus, conflictedTaskId, conflictError } = applyServerDispatchDiffsCore(
|
|
3170
|
+
const { mergeStatus, conflictedTaskId, conflictError, conflictFiles } = applyServerDispatchDiffsCore(
|
|
3164
3171
|
dispatchResponse.tasks ?? [],
|
|
3165
3172
|
pollResult.tasks,
|
|
3166
3173
|
baseCwd,
|
|
@@ -3178,7 +3185,14 @@ export async function executeParallelDispatchServer(
|
|
|
3178
3185
|
});
|
|
3179
3186
|
}
|
|
3180
3187
|
|
|
3181
|
-
|
|
3188
|
+
// COMP-PAR-MERGE-QUEUE: on conflict, send a structured payload carrying a
|
|
3189
|
+
// merge_conflict bounce record so Stratum surfaces it on ensure_failed and
|
|
3190
|
+
// Compose's retry prompt can name the task/files (back-compat: 'clean'
|
|
3191
|
+
// stays a bare string).
|
|
3192
|
+
const advancePayload = mergeStatus === 'conflict'
|
|
3193
|
+
? { status: 'conflict', bounced_tasks: [buildMergeConflictBounce(conflictedTaskId, conflictError, conflictFiles)] }
|
|
3194
|
+
: 'clean';
|
|
3195
|
+
const advanceResult = await stratum.parallelAdvance(flowId, stepId, advancePayload);
|
|
3182
3196
|
if (advanceResult?.error) {
|
|
3183
3197
|
throw new Error(
|
|
3184
3198
|
`stratum_parallel_advance failed: ${advanceResult.error}: ${advanceResult.message || ''}`,
|
|
@@ -3210,18 +3224,57 @@ export async function executeParallelDispatchServer(
|
|
|
3210
3224
|
}
|
|
3211
3225
|
}
|
|
3212
3226
|
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
});
|
|
3219
|
-
}
|
|
3220
|
-
|
|
3221
|
-
return pollResult.outcome;
|
|
3227
|
+
_redispatchOutcome = pollResult.outcome;
|
|
3228
|
+
_finalSummary = pollResult.summary;
|
|
3229
|
+
// Fall through to the finally (unsubscribe), then the re-dispatch check below.
|
|
3230
|
+
// build_step_done is emitted AFTER that check so a re-dispatched attempt does
|
|
3231
|
+
// not surface a terminal step event.
|
|
3222
3232
|
} finally {
|
|
3223
3233
|
if (unsubscribePush) { try { unsubscribePush(); } catch { /* ignore */ } }
|
|
3224
3234
|
}
|
|
3235
|
+
|
|
3236
|
+
// COMP-PAR-MERGE-QUEUE: a parallel step that failed its pre-merge gate / require
|
|
3237
|
+
// policy / merge returns `ensure_failed`/`schema_failed` carrying the parallel
|
|
3238
|
+
// surface (tasks). Re-dispatch it as a PARALLEL step rather than leaking the
|
|
3239
|
+
// failure to the outer single-agent retry path (which can't re-run parallel
|
|
3240
|
+
// tasks). Stratum reverted current_idx and persisted the bounce onto each
|
|
3241
|
+
// failed task, so the re-run task prompts carry the failure context. The
|
|
3242
|
+
// server's retry cap bounds this — it returns retries_exhausted/error (a
|
|
3243
|
+
// terminal, non-parallel envelope) when exhausted, ending the recursion.
|
|
3244
|
+
const out = _redispatchOutcome;
|
|
3245
|
+
// Defensive cap so a misbehaving server/stub that keeps returning ensure_failed
|
|
3246
|
+
// can't recurse without bound (Stratum's own retry cap should end it first).
|
|
3247
|
+
const redispatchDepth = (opts._redispatchDepth ?? 0);
|
|
3248
|
+
if (
|
|
3249
|
+
out
|
|
3250
|
+
&& (out.status === 'ensure_failed' || out.status === 'schema_failed')
|
|
3251
|
+
&& Array.isArray(out.tasks)
|
|
3252
|
+
&& out.step_id === stepId
|
|
3253
|
+
&& redispatchDepth < 10
|
|
3254
|
+
) {
|
|
3255
|
+
// The ensure_failed envelope is built from Stratum's parallel *surface*, which
|
|
3256
|
+
// omits the merge-config fields (capture_diff/isolation) that gate the
|
|
3257
|
+
// worktree-merge path (hasServerMerge). Carry them over from THIS dispatch (the
|
|
3258
|
+
// same step) so the re-run still merges its diffs instead of silently skipping.
|
|
3259
|
+
const redispatch = {
|
|
3260
|
+
...out,
|
|
3261
|
+
isolation: dispatchResponse.isolation ?? out.isolation,
|
|
3262
|
+
capture_diff: dispatchResponse.capture_diff ?? out.capture_diff,
|
|
3263
|
+
};
|
|
3264
|
+
return await executeParallelDispatchServer(
|
|
3265
|
+
redispatch, stratum, context, progress, streamWriter, baseCwd,
|
|
3266
|
+
{ ...opts, _redispatchDepth: redispatchDepth + 1 },
|
|
3267
|
+
);
|
|
3268
|
+
}
|
|
3269
|
+
// Terminal attempt — now surface the step-done lifecycle event.
|
|
3270
|
+
if (streamWriter) {
|
|
3271
|
+
streamWriter.write({
|
|
3272
|
+
type: 'build_step_done', stepId,
|
|
3273
|
+
parallel: true,
|
|
3274
|
+
summary: _finalSummary, flowId,
|
|
3275
|
+
});
|
|
3276
|
+
}
|
|
3277
|
+
return out;
|
|
3225
3278
|
}
|
|
3226
3279
|
|
|
3227
3280
|
/**
|
|
@@ -3237,9 +3290,103 @@ export async function executeParallelDispatchServer(
|
|
|
3237
3290
|
* @param {string} patchDir — directory to write temporary .patch files
|
|
3238
3291
|
* @returns {{mergeStatus:'clean'|'conflict', appliedFiles:string[], conflictedTaskId:string|null, conflictError:string|null}}
|
|
3239
3292
|
*/
|
|
3293
|
+
/**
|
|
3294
|
+
* COMP-PAR-MERGE-QUEUE: best-effort extraction of the conflicting files from a
|
|
3295
|
+
* `git apply` error message, falling back to the task's declared owned files.
|
|
3296
|
+
* Used to populate a merge_conflict bounce record's `files`.
|
|
3297
|
+
*/
|
|
3298
|
+
export function extractConflictFiles(conflictError, ownedFiles = []) {
|
|
3299
|
+
const files = new Set();
|
|
3300
|
+
if (conflictError) {
|
|
3301
|
+
// "error: patch failed: path/to/file:LINE"
|
|
3302
|
+
for (const m of conflictError.matchAll(/patch failed:\s+(.+?):\d+/g)) files.add(m[1]);
|
|
3303
|
+
// "error: path/to/file: patch does not apply"
|
|
3304
|
+
for (const m of conflictError.matchAll(/error:\s+(.+?):\s+patch does not apply/g)) files.add(m[1]);
|
|
3305
|
+
}
|
|
3306
|
+
if (files.size === 0) for (const f of (ownedFiles ?? [])) files.add(f);
|
|
3307
|
+
return [...files];
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
/**
|
|
3311
|
+
* COMP-PAR-MERGE-QUEUE: build a structured merge_conflict bounce record (the
|
|
3312
|
+
* Compose-side counterpart of Stratum's gate_failed bounce). Shape matches
|
|
3313
|
+
* contracts/par-merge-bounce.json.
|
|
3314
|
+
*/
|
|
3315
|
+
export function buildMergeConflictBounce(taskId, error, files) {
|
|
3316
|
+
return {
|
|
3317
|
+
task_id: taskId,
|
|
3318
|
+
reason: 'merge_conflict',
|
|
3319
|
+
files: files ?? [],
|
|
3320
|
+
command: null,
|
|
3321
|
+
exit_code: null,
|
|
3322
|
+
excerpt: (error ?? '').slice(-2048),
|
|
3323
|
+
};
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
/**
|
|
3327
|
+
* COMP-PAR-MERGE-QUEUE-CONSUMER: best-effort list of files changed in a worktree
|
|
3328
|
+
* (tracked + untracked), for a gate_failed bounce's `files`. Never throws.
|
|
3329
|
+
*/
|
|
3330
|
+
function gateChangedFiles(cwd) {
|
|
3331
|
+
const out = [];
|
|
3332
|
+
const seen = new Set();
|
|
3333
|
+
for (const cmd of [
|
|
3334
|
+
'git -c core.hooksPath=/dev/null diff --name-only HEAD',
|
|
3335
|
+
'git -c core.hooksPath=/dev/null ls-files --others --exclude-standard',
|
|
3336
|
+
]) {
|
|
3337
|
+
try {
|
|
3338
|
+
const res = execSync(cmd, { cwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe' });
|
|
3339
|
+
for (const line of res.split('\n')) {
|
|
3340
|
+
const f = line.trim();
|
|
3341
|
+
if (f && !seen.has(f)) { seen.add(f); out.push(f); }
|
|
3342
|
+
}
|
|
3343
|
+
} catch { /* best-effort */ }
|
|
3344
|
+
}
|
|
3345
|
+
return out;
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
/**
|
|
3349
|
+
* COMP-PAR-MERGE-QUEUE-CONSUMER: run a per-task pre-merge gate inside a Compose-run
|
|
3350
|
+
* task worktree (the consumer-dispatch mirror of Stratum's worktree.run_pre_merge_gate).
|
|
3351
|
+
* Best-effort symlinks node_modules from base (the worktree is bare). Each command
|
|
3352
|
+
* runs in `cwd` with `timeoutMs`; the first non-zero exit (or launch failure) returns a
|
|
3353
|
+
* structured gate_failed bounce record (caller stamps task_id). All-pass ⇒ null.
|
|
3354
|
+
*/
|
|
3355
|
+
export function runPreMergeGateLocal(cwd, commands, baseCwd, timeoutMs) {
|
|
3356
|
+
if (!Array.isArray(commands) || commands.length === 0) return null;
|
|
3357
|
+
// Best-effort node_modules symlink (bare worktree lacks gitignored deps).
|
|
3358
|
+
if (baseCwd) {
|
|
3359
|
+
try {
|
|
3360
|
+
const baseNm = join(baseCwd, 'node_modules');
|
|
3361
|
+
const wtNm = join(cwd, 'node_modules');
|
|
3362
|
+
if (existsSync(baseNm) && !existsSync(wtNm)) symlinkSync(baseNm, wtNm, 'dir');
|
|
3363
|
+
} catch { /* non-fatal */ }
|
|
3364
|
+
}
|
|
3365
|
+
for (const cmd of commands) {
|
|
3366
|
+
try {
|
|
3367
|
+
execSync(cmd, { cwd, encoding: 'utf-8', timeout: timeoutMs, stdio: 'pipe' });
|
|
3368
|
+
} catch (err) {
|
|
3369
|
+
// execSync throws on non-zero exit, ENOENT, or timeout.
|
|
3370
|
+
const exitCode = (typeof err.status === 'number') ? err.status : null;
|
|
3371
|
+
const out = (err.stdout != null ? String(err.stdout) : '');
|
|
3372
|
+
const errOut = (err.stderr != null ? String(err.stderr) : '');
|
|
3373
|
+
const sep = (out && errOut) ? '\n' : '';
|
|
3374
|
+
const excerpt = (out + sep + errOut + (out || errOut ? '' : (err.message ?? ''))).slice(-2048);
|
|
3375
|
+
return {
|
|
3376
|
+
reason: 'gate_failed',
|
|
3377
|
+
command: cmd,
|
|
3378
|
+
exit_code: exitCode,
|
|
3379
|
+
files: gateChangedFiles(cwd),
|
|
3380
|
+
excerpt,
|
|
3381
|
+
};
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
return null;
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3240
3387
|
function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId, patchDir) {
|
|
3241
3388
|
if (diffMap.size === 0) {
|
|
3242
|
-
return { mergeStatus: 'clean', appliedFiles: [], conflictedTaskId: null, conflictError: null };
|
|
3389
|
+
return { mergeStatus: 'clean', appliedFiles: [], conflictedTaskId: null, conflictError: null, conflictFiles: [] };
|
|
3243
3390
|
}
|
|
3244
3391
|
|
|
3245
3392
|
// Topological sort on depends_on edges (DFS)
|
|
@@ -3272,6 +3419,7 @@ function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId,
|
|
|
3272
3419
|
let mergeStatus = 'clean';
|
|
3273
3420
|
let conflictedTaskId = null;
|
|
3274
3421
|
let conflictError = null;
|
|
3422
|
+
let conflictFiles = [];
|
|
3275
3423
|
const appliedFiles = new Set();
|
|
3276
3424
|
|
|
3277
3425
|
for (const taskId of topoOrder) {
|
|
@@ -3300,6 +3448,7 @@ function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId,
|
|
|
3300
3448
|
mergeStatus = 'conflict';
|
|
3301
3449
|
conflictedTaskId = taskId;
|
|
3302
3450
|
conflictError = err.message;
|
|
3451
|
+
conflictFiles = extractConflictFiles(err.message, taskMap.get(taskId)?.files_owned ?? []);
|
|
3303
3452
|
if (streamWriter) {
|
|
3304
3453
|
streamWriter.write({
|
|
3305
3454
|
type: 'build_error',
|
|
@@ -3336,6 +3485,7 @@ function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId,
|
|
|
3336
3485
|
appliedFiles: [...appliedFiles],
|
|
3337
3486
|
conflictedTaskId,
|
|
3338
3487
|
conflictError,
|
|
3488
|
+
conflictFiles,
|
|
3339
3489
|
};
|
|
3340
3490
|
}
|
|
3341
3491
|
|
|
@@ -3378,12 +3528,12 @@ function applyServerDispatchDiffsCore(taskList, pollTasks, baseCwd, streamWriter
|
|
|
3378
3528
|
}
|
|
3379
3529
|
|
|
3380
3530
|
if (diffMap.size === 0) {
|
|
3381
|
-
return { mergeStatus: 'clean', conflictedTaskId: null, conflictError: null, appliedFiles: [] };
|
|
3531
|
+
return { mergeStatus: 'clean', conflictedTaskId: null, conflictError: null, appliedFiles: [], conflictFiles: [] };
|
|
3382
3532
|
}
|
|
3383
3533
|
|
|
3384
3534
|
const patchDir = mkdtempSync(join(tmpdir(), 'compose-server-patch-'));
|
|
3385
3535
|
try {
|
|
3386
|
-
const { mergeStatus, conflictedTaskId, conflictError, appliedFiles } =
|
|
3536
|
+
const { mergeStatus, conflictedTaskId, conflictError, appliedFiles, conflictFiles } =
|
|
3387
3537
|
applyTaskDiffsToBaseCwd(taskList, diffMap, baseCwd, streamWriter, stepId, patchDir);
|
|
3388
3538
|
|
|
3389
3539
|
if (mergeStatus !== 'conflict' && appliedFiles.length > 0 && context) {
|
|
@@ -3392,7 +3542,7 @@ function applyServerDispatchDiffsCore(taskList, pollTasks, baseCwd, streamWriter
|
|
|
3392
3542
|
context.filesChanged = [...set];
|
|
3393
3543
|
}
|
|
3394
3544
|
|
|
3395
|
-
return { mergeStatus, conflictedTaskId, conflictError, appliedFiles };
|
|
3545
|
+
return { mergeStatus, conflictedTaskId, conflictError, appliedFiles, conflictFiles };
|
|
3396
3546
|
} finally {
|
|
3397
3547
|
try { rmSync(patchDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
3398
3548
|
}
|
|
@@ -3592,6 +3742,26 @@ async function executeParallelDispatch(
|
|
|
3592
3742
|
});
|
|
3593
3743
|
|
|
3594
3744
|
if (worktreeIsolation && worktreePaths.has(taskId)) {
|
|
3745
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER: run the per-task pre-merge gate in the
|
|
3746
|
+
// worktree BEFORE capturing the diff. A gate failure fails the task,
|
|
3747
|
+
// records a gate_failed bounce, and skips diff capture so the bad work
|
|
3748
|
+
// never merges. No-op when no gate is configured (byte-identical).
|
|
3749
|
+
const gateCmds = dispatchResponse.pre_merge_verify ?? [];
|
|
3750
|
+
if (gateCmds.length > 0) {
|
|
3751
|
+
const gateTimeout = STEP_TIMEOUT_MS[dispStepId] ?? DEFAULT_TIMEOUT_MS;
|
|
3752
|
+
const bounce = runPreMergeGateLocal(wtPath, gateCmds, baseCwd, gateTimeout);
|
|
3753
|
+
if (bounce) {
|
|
3754
|
+
bounce.task_id = taskId;
|
|
3755
|
+
if (streamWriter) {
|
|
3756
|
+
streamWriter.write({
|
|
3757
|
+
type: 'build_error', stepId: taskId,
|
|
3758
|
+
message: `pre-merge gate failed for ${taskId}: ${bounce.command} (exit ${bounce.exit_code ?? '?'}). Diff NOT merged.`,
|
|
3759
|
+
});
|
|
3760
|
+
}
|
|
3761
|
+
return { taskId, status: 'failed', error: `pre_merge_verify failed: ${bounce.command}`, gateBounce: bounce };
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3595
3765
|
const diskQuotaMB = dispatchResponse.diskQuotaMB ?? 500;
|
|
3596
3766
|
try {
|
|
3597
3767
|
const duOut = execSync(`du -sk "${wtPath}"`, { encoding: 'utf-8', timeout: 10000, stdio: 'pipe' }).trim();
|
|
@@ -3645,11 +3815,15 @@ async function executeParallelDispatch(
|
|
|
3645
3815
|
})
|
|
3646
3816
|
);
|
|
3647
3817
|
|
|
3818
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER: collect structured bounce records (gate-failed
|
|
3819
|
+
// here, merge-conflict below) to surface on the parallelDone envelope.
|
|
3820
|
+
const bouncedTasks = [];
|
|
3648
3821
|
const taskResults = settled.map(outcome => {
|
|
3649
3822
|
if (outcome.status === 'rejected') {
|
|
3650
3823
|
return { task_id: 'unknown', status: 'failed', error: String(outcome.reason) };
|
|
3651
3824
|
}
|
|
3652
|
-
const { taskId, status, result, error } = outcome.value;
|
|
3825
|
+
const { taskId, status, result, error, gateBounce } = outcome.value;
|
|
3826
|
+
if (gateBounce) bouncedTasks.push(gateBounce);
|
|
3653
3827
|
return status === 'complete'
|
|
3654
3828
|
? { task_id: taskId, status: 'complete', result }
|
|
3655
3829
|
: { task_id: taskId, status: 'failed', error };
|
|
@@ -3676,6 +3850,10 @@ async function executeParallelDispatch(
|
|
|
3676
3850
|
taskResults[idx].status = 'failed';
|
|
3677
3851
|
taskResults[idx].error = `merge conflict: ${result.conflictError}`;
|
|
3678
3852
|
}
|
|
3853
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER: structured merge-conflict bounce.
|
|
3854
|
+
bouncedTasks.push(
|
|
3855
|
+
buildMergeConflictBounce(result.conflictedTaskId, result.conflictError, result.conflictFiles),
|
|
3856
|
+
);
|
|
3679
3857
|
}
|
|
3680
3858
|
}
|
|
3681
3859
|
|
|
@@ -3695,7 +3873,13 @@ async function executeParallelDispatch(
|
|
|
3695
3873
|
});
|
|
3696
3874
|
}
|
|
3697
3875
|
|
|
3698
|
-
|
|
3876
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER: when there are bounce records, send a structured
|
|
3877
|
+
// merge_status {status, bounced_tasks} so Stratum threads them onto the failure
|
|
3878
|
+
// envelope (and derives readable violations). Bare string otherwise — byte-identical.
|
|
3879
|
+
const mergeArg = bouncedTasks.length > 0
|
|
3880
|
+
? { status: mergeStatus, bounced_tasks: bouncedTasks }
|
|
3881
|
+
: mergeStatus;
|
|
3882
|
+
return stratum.parallelDone(dispFlowId, dispStepId, taskResults, mergeArg);
|
|
3699
3883
|
}
|
|
3700
3884
|
|
|
3701
3885
|
async function startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode = 'feature') {
|
package/lib/gsd.js
CHANGED
|
@@ -38,6 +38,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
38
38
|
const PACKAGE_ROOT = resolve(__dirname, '..');
|
|
39
39
|
|
|
40
40
|
const DEFAULT_GATE_COMMANDS = ['pnpm lint', 'pnpm build', 'pnpm test'];
|
|
41
|
+
// COMP-PAR-MERGE-QUEUE: the fast per-task pre-merge gate (lint + build, no full
|
|
42
|
+
// test suite). Enforced in each task's worktree before its diff merges; the full
|
|
43
|
+
// `pnpm test` runs once at ship_gsd. Single-sourced into both the enforced gate
|
|
44
|
+
// (execute.pre_merge_verify) and the instructed gate (task descriptions).
|
|
45
|
+
const DEFAULT_FAST_GATE = ['pnpm lint', 'pnpm build'];
|
|
41
46
|
|
|
42
47
|
// ---------- Public API ----------
|
|
43
48
|
|
|
@@ -122,6 +127,8 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
122
127
|
// 4. Resolve gateCommands. loadProjectConfig() does not merge defaults, so
|
|
123
128
|
// explicit fallback here.
|
|
124
129
|
const gateCommands = resolveGateCommands(cwd, opts.gateCommands);
|
|
130
|
+
// COMP-PAR-MERGE-QUEUE: the fast per-task pre-merge gate (lint+build).
|
|
131
|
+
const preMergeGate = resolvePreMergeGate(cwd, opts.preMergeGate);
|
|
125
132
|
|
|
126
133
|
// 4. Load pipeline spec
|
|
127
134
|
const specPath = join(PACKAGE_ROOT, 'pipelines', 'gsd.stratum.yaml');
|
|
@@ -208,7 +215,7 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
208
215
|
// can stage them. executeShipStep's default filter only stages feature
|
|
209
216
|
// docs unless context.filesChanged is provided.
|
|
210
217
|
stepCtx = {
|
|
211
|
-
stratum, cwd, featureCode, blueprintText, gateCommands,
|
|
218
|
+
stratum, cwd, featureCode, blueprintText, gateCommands, preMergeGate,
|
|
212
219
|
filesChanged: [],
|
|
213
220
|
stuckDetector,
|
|
214
221
|
resumeTaskGraph,
|
|
@@ -260,6 +267,7 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
260
267
|
let response = await stratum.plan(specYaml, 'gsd', {
|
|
261
268
|
featureCode,
|
|
262
269
|
gateCommands,
|
|
270
|
+
pre_merge_gate: preMergeGate,
|
|
263
271
|
});
|
|
264
272
|
const flowId = response.flow_id;
|
|
265
273
|
flushState(stepCtx, { flowId, phase: 'decompose' });
|
|
@@ -273,11 +281,30 @@ export async function runGsd(featureCode, opts = {}) {
|
|
|
273
281
|
response.status !== 'complete' &&
|
|
274
282
|
response.status !== 'killed' &&
|
|
275
283
|
response.status !== 'stuck' &&
|
|
276
|
-
response.status !== 'budget_exhausted'
|
|
284
|
+
response.status !== 'budget_exhausted' &&
|
|
285
|
+
response.status !== 'error' // COMP-PAR-MERGE-QUEUE: terminal step failure (e.g. retries_exhausted)
|
|
277
286
|
) {
|
|
278
287
|
response = await runOneStep(response, stepCtx);
|
|
279
288
|
}
|
|
280
289
|
|
|
290
|
+
// COMP-PAR-MERGE-QUEUE: a step that exhausted its retries (e.g. the execute
|
|
291
|
+
// step after repeated pre-merge gate failures) surfaces as a terminal `error`
|
|
292
|
+
// envelope rather than silently advancing to ship. Stop here with the failure
|
|
293
|
+
// and its bounce context instead of throwing `unknown response status`.
|
|
294
|
+
if (response.status === 'error') {
|
|
295
|
+
emitCompletionDeltas(stepCtx);
|
|
296
|
+
flushState(stepCtx, { status: 'failed' });
|
|
297
|
+
return {
|
|
298
|
+
status: 'failed',
|
|
299
|
+
flowId,
|
|
300
|
+
stepId: response.step_id ?? stepCtx.lastStepId ?? null,
|
|
301
|
+
errorType: response.error_type ?? 'step_failed',
|
|
302
|
+
message: response.message ?? 'GSD step failed',
|
|
303
|
+
violations: response.violations ?? [],
|
|
304
|
+
bouncedTasks: response.bounced_tasks ?? [],
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
281
308
|
if (response.status === 'stuck') {
|
|
282
309
|
// Artifacts (stuck.md/json + pause.json) were written by runOneStep.
|
|
283
310
|
// COMP-GSD-7-EVENTLOG: flush any completions that finished before the stuck
|
|
@@ -422,8 +449,32 @@ export function resolveGateCommands(cwd, override) {
|
|
|
422
449
|
return [...DEFAULT_GATE_COMMANDS];
|
|
423
450
|
}
|
|
424
451
|
|
|
452
|
+
// COMP-PAR-MERGE-QUEUE: resolve the fast per-task pre-merge gate. Mirrors
|
|
453
|
+
// resolveGateCommands but defaults to lint+build (no full test suite). Honors
|
|
454
|
+
// `.compose/compose.json#preMergeGate`, else falls back to the non-test subset
|
|
455
|
+
// of `gateCommands`, else DEFAULT_FAST_GATE.
|
|
456
|
+
export function resolvePreMergeGate(cwd, override) {
|
|
457
|
+
if (Array.isArray(override) && override.length > 0) return override;
|
|
458
|
+
const configPath = join(cwd, '.compose', 'compose.json');
|
|
459
|
+
if (existsSync(configPath)) {
|
|
460
|
+
try {
|
|
461
|
+
const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
462
|
+
if (Array.isArray(cfg.preMergeGate) && cfg.preMergeGate.length > 0) {
|
|
463
|
+
return cfg.preMergeGate;
|
|
464
|
+
}
|
|
465
|
+
if (Array.isArray(cfg.gateCommands) && cfg.gateCommands.length > 0) {
|
|
466
|
+
const fast = cfg.gateCommands.filter((c) => !/\btest\b/.test(c));
|
|
467
|
+
if (fast.length > 0) return fast;
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
/* fall through to default */
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return [...DEFAULT_FAST_GATE];
|
|
474
|
+
}
|
|
475
|
+
|
|
425
476
|
async function runOneStep(response, ctx) {
|
|
426
|
-
const { stratum, cwd, featureCode, blueprintText, gateCommands } = ctx;
|
|
477
|
+
const { stratum, cwd, featureCode, blueprintText, gateCommands, preMergeGate } = ctx;
|
|
427
478
|
const flowId = response.flow_id;
|
|
428
479
|
const stepId = response.step_id;
|
|
429
480
|
const stepType = response.type ?? response.step_type;
|
|
@@ -526,7 +577,10 @@ async function runOneStep(response, ctx) {
|
|
|
526
577
|
|
|
527
578
|
// T6 step 7: validate decompose_gsd output and repair missing descriptions.
|
|
528
579
|
if (stepId === 'decompose_gsd') {
|
|
529
|
-
|
|
580
|
+
// COMP-PAR-MERGE-QUEUE: single-source the per-task instructed gate to the
|
|
581
|
+
// fast pre-merge gate (== the enforced execute.pre_merge_verify). Full
|
|
582
|
+
// `pnpm test` is instructed only at ship_gsd.
|
|
583
|
+
result = validateAndRepairTaskGraph(result, blueprintText, preMergeGate ?? gateCommands);
|
|
530
584
|
// COMP-GSD-5: remember the ENRICHED graph so a later stuck halt can
|
|
531
585
|
// persist the full task definitions (with descriptions/produces/consumes)
|
|
532
586
|
// into pause.json — resume re-dispatches these without re-enriching.
|
package/lib/step-prompt.js
CHANGED
|
@@ -162,6 +162,12 @@ function buildConflictSection(conflicts) {
|
|
|
162
162
|
return lines.join('\n');
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// COMP-PAR-MERGE-QUEUE note: pre-merge bounce context (gate_failed / merge_conflict)
|
|
166
|
+
// is injected into a re-dispatched task's prompt SERVER-SIDE, in Stratum's
|
|
167
|
+
// ParallelExecutor._render_prompt — because the server re-resolves the task list
|
|
168
|
+
// from flow state on each re-dispatch, so a Compose-side prompt edit here would
|
|
169
|
+
// never reach the re-run task. See parallel_exec.py `_format_bounce_for_prompt`.
|
|
170
|
+
|
|
165
171
|
/**
|
|
166
172
|
* Build a retry prompt when postconditions failed.
|
|
167
173
|
*
|
|
@@ -394,7 +394,9 @@ export class StratumMcpClient {
|
|
|
394
394
|
* @param {string} flowId
|
|
395
395
|
* @param {string} stepId
|
|
396
396
|
* @param {Array<{task_id: string, status: string, result?: object, error?: string}>} taskResults
|
|
397
|
-
* @param {'clean'|'conflict'|'fallback'|'manual_required'} mergeStatus
|
|
397
|
+
* @param {'clean'|'conflict'|'fallback'|'manual_required'|{status:string,bounced_tasks?:object[]}} mergeStatus
|
|
398
|
+
* COMP-PAR-MERGE-QUEUE-CONSUMER: either a bare status string (back-compat) or a
|
|
399
|
+
* structured object carrying gate/conflict bounce records. Serializes as-is.
|
|
398
400
|
* @returns {Promise<object>} Next dispatch response
|
|
399
401
|
*/
|
|
400
402
|
async parallelDone(flowId, stepId, taskResults, mergeStatus) {
|
|
@@ -444,7 +446,9 @@ export class StratumMcpClient {
|
|
|
444
446
|
*
|
|
445
447
|
* @param {string} flowId
|
|
446
448
|
* @param {string} stepId
|
|
447
|
-
* @param {'clean'|'conflict'} mergeStatus
|
|
449
|
+
* @param {'clean'|'conflict'|{status:'clean'|'conflict',bounced_tasks?:object[]}} mergeStatus
|
|
450
|
+
* COMP-PAR-MERGE-QUEUE: either a bare status string (back-compat) or a
|
|
451
|
+
* structured object carrying merge_conflict bounce records. Serializes as-is.
|
|
448
452
|
* @returns {Promise<object>}
|
|
449
453
|
*/
|
|
450
454
|
async parallelAdvance(flowId, stepId, mergeStatus) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11-beta",
|
|
4
4
|
"description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
|
|
5
5
|
"author": "SmartMemory",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,9 @@ workflow:
|
|
|
18
18
|
gateCommands:
|
|
19
19
|
type: array
|
|
20
20
|
required: true # injected by runGsd from loadProjectConfig() with default fallback
|
|
21
|
+
pre_merge_gate:
|
|
22
|
+
type: array
|
|
23
|
+
required: false # COMP-PAR-MERGE-QUEUE: fast per-task enforced gate (lint+build); injected by runGsd
|
|
21
24
|
|
|
22
25
|
contracts:
|
|
23
26
|
PhaseResult:
|
|
@@ -36,6 +39,7 @@ flows:
|
|
|
36
39
|
input:
|
|
37
40
|
featureCode: {type: string}
|
|
38
41
|
gateCommands: {type: array}
|
|
42
|
+
pre_merge_gate: {type: array} # COMP-PAR-MERGE-QUEUE
|
|
39
43
|
output: PhaseResult
|
|
40
44
|
max_rounds: 10
|
|
41
45
|
steps:
|
|
@@ -61,7 +65,7 @@ flows:
|
|
|
61
65
|
- "Upstream tasks (spec-level summary; their code lands at end-of-step
|
|
62
66
|
merge):" + one line per upstream task listing its declared produces
|
|
63
67
|
- "GATES — you MUST run each command and they MUST pass before
|
|
64
|
-
declaring done:" + each command from the
|
|
68
|
+
declaring done:" + each command from the pre_merge_gate array
|
|
65
69
|
- "Fix and re-run within this invocation. Do NOT declare done while
|
|
66
70
|
gates are red."
|
|
67
71
|
|
|
@@ -75,10 +79,12 @@ flows:
|
|
|
75
79
|
has no matching task or its File Plan files are split across
|
|
76
80
|
multiple tasks.
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
pre_merge_gate commands to embed (the fast per-task gate; the full
|
|
83
|
+
test suite runs once at ship): {input.pre_merge_gate}
|
|
79
84
|
inputs:
|
|
80
|
-
featureCode:
|
|
81
|
-
gateCommands:
|
|
85
|
+
featureCode: "$.input.featureCode"
|
|
86
|
+
gateCommands: "$.input.gateCommands"
|
|
87
|
+
pre_merge_gate: "$.input.pre_merge_gate"
|
|
82
88
|
output_contract: TaskGraph
|
|
83
89
|
ensure:
|
|
84
90
|
- "no_file_conflicts(result.tasks)"
|
|
@@ -93,6 +99,8 @@ flows:
|
|
|
93
99
|
max_concurrent: 1
|
|
94
100
|
isolation: worktree
|
|
95
101
|
capture_diff: true
|
|
102
|
+
defer_advance: true # COMP-PAR-MERGE-QUEUE: consumer merges + reports status, enabling conflict-bounce
|
|
103
|
+
pre_merge_verify: "$.input.pre_merge_gate" # COMP-PAR-MERGE-QUEUE: enforced fast gate per task worktree
|
|
96
104
|
require: all
|
|
97
105
|
merge: sequential_apply
|
|
98
106
|
retries: 2
|