@smartmemory/compose 0.2.9-beta → 0.2.10-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 +104 -15
- package/lib/gsd.js +58 -4
- package/lib/step-prompt.js +6 -0
- package/lib/stratum-mcp-client.js +3 -1
- 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
|
@@ -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,42 @@ 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
|
+
|
|
3240
3326
|
function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId, patchDir) {
|
|
3241
3327
|
if (diffMap.size === 0) {
|
|
3242
|
-
return { mergeStatus: 'clean', appliedFiles: [], conflictedTaskId: null, conflictError: null };
|
|
3328
|
+
return { mergeStatus: 'clean', appliedFiles: [], conflictedTaskId: null, conflictError: null, conflictFiles: [] };
|
|
3243
3329
|
}
|
|
3244
3330
|
|
|
3245
3331
|
// Topological sort on depends_on edges (DFS)
|
|
@@ -3272,6 +3358,7 @@ function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId,
|
|
|
3272
3358
|
let mergeStatus = 'clean';
|
|
3273
3359
|
let conflictedTaskId = null;
|
|
3274
3360
|
let conflictError = null;
|
|
3361
|
+
let conflictFiles = [];
|
|
3275
3362
|
const appliedFiles = new Set();
|
|
3276
3363
|
|
|
3277
3364
|
for (const taskId of topoOrder) {
|
|
@@ -3300,6 +3387,7 @@ function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId,
|
|
|
3300
3387
|
mergeStatus = 'conflict';
|
|
3301
3388
|
conflictedTaskId = taskId;
|
|
3302
3389
|
conflictError = err.message;
|
|
3390
|
+
conflictFiles = extractConflictFiles(err.message, taskMap.get(taskId)?.files_owned ?? []);
|
|
3303
3391
|
if (streamWriter) {
|
|
3304
3392
|
streamWriter.write({
|
|
3305
3393
|
type: 'build_error',
|
|
@@ -3336,6 +3424,7 @@ function applyTaskDiffsToBaseCwd(tasks, diffMap, baseCwd, streamWriter, stepId,
|
|
|
3336
3424
|
appliedFiles: [...appliedFiles],
|
|
3337
3425
|
conflictedTaskId,
|
|
3338
3426
|
conflictError,
|
|
3427
|
+
conflictFiles,
|
|
3339
3428
|
};
|
|
3340
3429
|
}
|
|
3341
3430
|
|
|
@@ -3378,12 +3467,12 @@ function applyServerDispatchDiffsCore(taskList, pollTasks, baseCwd, streamWriter
|
|
|
3378
3467
|
}
|
|
3379
3468
|
|
|
3380
3469
|
if (diffMap.size === 0) {
|
|
3381
|
-
return { mergeStatus: 'clean', conflictedTaskId: null, conflictError: null, appliedFiles: [] };
|
|
3470
|
+
return { mergeStatus: 'clean', conflictedTaskId: null, conflictError: null, appliedFiles: [], conflictFiles: [] };
|
|
3382
3471
|
}
|
|
3383
3472
|
|
|
3384
3473
|
const patchDir = mkdtempSync(join(tmpdir(), 'compose-server-patch-'));
|
|
3385
3474
|
try {
|
|
3386
|
-
const { mergeStatus, conflictedTaskId, conflictError, appliedFiles } =
|
|
3475
|
+
const { mergeStatus, conflictedTaskId, conflictError, appliedFiles, conflictFiles } =
|
|
3387
3476
|
applyTaskDiffsToBaseCwd(taskList, diffMap, baseCwd, streamWriter, stepId, patchDir);
|
|
3388
3477
|
|
|
3389
3478
|
if (mergeStatus !== 'conflict' && appliedFiles.length > 0 && context) {
|
|
@@ -3392,7 +3481,7 @@ function applyServerDispatchDiffsCore(taskList, pollTasks, baseCwd, streamWriter
|
|
|
3392
3481
|
context.filesChanged = [...set];
|
|
3393
3482
|
}
|
|
3394
3483
|
|
|
3395
|
-
return { mergeStatus, conflictedTaskId, conflictError, appliedFiles };
|
|
3484
|
+
return { mergeStatus, conflictedTaskId, conflictError, appliedFiles, conflictFiles };
|
|
3396
3485
|
} finally {
|
|
3397
3486
|
try { rmSync(patchDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
3398
3487
|
}
|
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
|
*
|
|
@@ -444,7 +444,9 @@ export class StratumMcpClient {
|
|
|
444
444
|
*
|
|
445
445
|
* @param {string} flowId
|
|
446
446
|
* @param {string} stepId
|
|
447
|
-
* @param {'clean'|'conflict'} mergeStatus
|
|
447
|
+
* @param {'clean'|'conflict'|{status:'clean'|'conflict',bounced_tasks?:object[]}} mergeStatus
|
|
448
|
+
* COMP-PAR-MERGE-QUEUE: either a bare status string (back-compat) or a
|
|
449
|
+
* structured object carrying merge_conflict bounce records. Serializes as-is.
|
|
448
450
|
* @returns {Promise<object>}
|
|
449
451
|
*/
|
|
450
452
|
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.10-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
|