@smartmemory/compose 0.2.11-beta → 0.2.13-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/lib/build.js +408 -72
- package/lib/step-prompt.js +40 -5
- package/package.json +1 -1
- package/pipelines/build.stratum.yaml +5 -0
package/lib/build.js
CHANGED
|
@@ -18,7 +18,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
18
18
|
import { StratumMcpClient, StratumError } from './stratum-mcp-client.js';
|
|
19
19
|
import { runAndNormalize, AgentTimeoutError, UserInterruptError, AgentError } from './result-normalizer.js';
|
|
20
20
|
import { checkCapabilityViolation } from './capability-checker.js';
|
|
21
|
-
import { buildStepPrompt, buildRetryPrompt, buildGateContext, clearAmbientContextCache } from './step-prompt.js';
|
|
21
|
+
import { buildStepPrompt, buildRetryPrompt, buildGateContext, clearAmbientContextCache, formatBounceForPrompt } from './step-prompt.js';
|
|
22
22
|
import { promptGate } from './gate-prompt.js';
|
|
23
23
|
import { VisionWriter, ServerUnreachableError } from './vision-writer.js';
|
|
24
24
|
import { resolvePort } from './resolve-port.js';
|
|
@@ -60,6 +60,7 @@ import { appendHypothesisEntry, readHypotheses } from './bug-ledger.js';
|
|
|
60
60
|
import { tier1CodexReview, tier2FreshAgent } from './bug-escalation.js';
|
|
61
61
|
import { recordTaskStates, writeTimingSidecar } from './gsd-timing.js';
|
|
62
62
|
import { writeGsdTaskDiff } from './gsd-diff-capture.js';
|
|
63
|
+
import { resolvePreMergeGate } from './gsd.js';
|
|
63
64
|
|
|
64
65
|
// ---------------------------------------------------------------------------
|
|
65
66
|
// COMP-FIX-HARD T5: per-step retries cap parser
|
|
@@ -626,6 +627,15 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
626
627
|
try { composeConfig = JSON.parse(readFileSync(configPath, 'utf-8')); } catch { /* use defaults */ }
|
|
627
628
|
const contextDirPath = join(cwd, composeConfig.paths?.context ?? 'docs/context');
|
|
628
629
|
|
|
630
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (D5): per-task pre-merge gate is opt-in.
|
|
631
|
+
// Resolve ONCE and thread into startFresh's planInputs only when the capability
|
|
632
|
+
// is on. Left `undefined` ⇒ the `pre_merge_gate` key is omitted from the plan
|
|
633
|
+
// envelope (not `[]`), so the default-OFF path is byte-identical to before.
|
|
634
|
+
let preMergeGate;
|
|
635
|
+
if (composeConfig?.capabilities?.preMergeGate) {
|
|
636
|
+
preMergeGate = resolvePreMergeGate(agentCwd, opts.preMergeGate);
|
|
637
|
+
}
|
|
638
|
+
|
|
629
639
|
// ---------------------------------------------------------------------------
|
|
630
640
|
// Pre-build triage — runs before spec loading so profile can toggle skip_if.
|
|
631
641
|
// Skipped when:
|
|
@@ -868,10 +878,10 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
868
878
|
`Previous build for ${featureCode} was in ${active.mode} mode, ` +
|
|
869
879
|
`current invocation is ${mode} mode. Starting fresh.`
|
|
870
880
|
);
|
|
871
|
-
response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode);
|
|
881
|
+
response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
|
|
872
882
|
} else if (active.status && active.status !== 'running') {
|
|
873
883
|
console.log(`Previous build ${active.status}. Starting fresh.`);
|
|
874
|
-
response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode);
|
|
884
|
+
response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
|
|
875
885
|
} else if (active.pid && active.pid !== process.pid && isProcessAlive(active.pid)) {
|
|
876
886
|
// Same feature, different live process — block
|
|
877
887
|
throw new Error(
|
|
@@ -884,7 +894,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
884
894
|
response = await stratum.resume(active.flowId);
|
|
885
895
|
if (isTerminalFlow(response.status)) {
|
|
886
896
|
console.log(`Previous build already ${response.status}. Starting fresh.`);
|
|
887
|
-
response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode);
|
|
897
|
+
response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
|
|
888
898
|
} else {
|
|
889
899
|
console.log(`Resuming from step: ${response.step_id}`);
|
|
890
900
|
isFreshStart = false;
|
|
@@ -895,7 +905,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
895
905
|
|| err?.message?.includes('No active flow');
|
|
896
906
|
if (recoverable) {
|
|
897
907
|
console.log('Previous flow not found. Starting fresh.');
|
|
898
|
-
response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode);
|
|
908
|
+
response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
|
|
899
909
|
} else {
|
|
900
910
|
throw err;
|
|
901
911
|
}
|
|
@@ -905,7 +915,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
905
915
|
// Different feature or no active build — start fresh.
|
|
906
916
|
// active-build.json is last-writer-wins: concurrent builds for
|
|
907
917
|
// different features are allowed; the UI shows the most recent.
|
|
908
|
-
response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode);
|
|
918
|
+
response = await startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode, preMergeGate);
|
|
909
919
|
}
|
|
910
920
|
|
|
911
921
|
// Update vision state
|
|
@@ -1638,6 +1648,17 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
1638
1648
|
response = await stratum.stepDone(parentFlowId, parentStepId, childResult);
|
|
1639
1649
|
|
|
1640
1650
|
} else if (response.status === 'ensure_failed' || response.status === 'schema_failed') {
|
|
1651
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W4): a parallel step that exhausted its
|
|
1652
|
+
// OWN retry loop must NOT be single-agent-retried here. Terminate the build.
|
|
1653
|
+
if (isParallelRetriesExhausted(response)) {
|
|
1654
|
+
streamWriter?.write({
|
|
1655
|
+
type: 'build_error', stepId: response.step_id ?? stepId,
|
|
1656
|
+
message: 'parallel step failed after retries', flowId: response.flow_id,
|
|
1657
|
+
});
|
|
1658
|
+
buildStatus = 'failed';
|
|
1659
|
+
response.status = 'killed';
|
|
1660
|
+
break;
|
|
1661
|
+
}
|
|
1641
1662
|
{
|
|
1642
1663
|
// COMP-HEALTH: track contract compliance failure
|
|
1643
1664
|
contractCompliance.push({ passed: false, stepId: response.step_id ?? stepId, status: response.status });
|
|
@@ -2591,7 +2612,7 @@ async function runCrossModelReview(mergedResult, filesChanged, cwd, stratum, str
|
|
|
2591
2612
|
* Handles the child's internal step loop (execute_step, await_gate, ensure_failed, etc.)
|
|
2592
2613
|
* including nested execute_flow (recursive).
|
|
2593
2614
|
*/
|
|
2594
|
-
async function executeChildFlow(
|
|
2615
|
+
export async function executeChildFlow(
|
|
2595
2616
|
flowDispatch, stratum, context,
|
|
2596
2617
|
visionWriter, itemId, dataDir, gateOpts, progress,
|
|
2597
2618
|
streamWriter
|
|
@@ -2782,6 +2803,17 @@ async function executeChildFlow(
|
|
|
2782
2803
|
}
|
|
2783
2804
|
|
|
2784
2805
|
} else if (resp.status === 'ensure_failed' || resp.status === 'schema_failed') {
|
|
2806
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W4): a child-flow parallel step that
|
|
2807
|
+
// exhausted its OWN retry loop must NOT be single-agent-fixed here (no double-
|
|
2808
|
+
// handle). Terminate the child flow; the marker keeps it out of the fix branch.
|
|
2809
|
+
if (isParallelRetriesExhausted(resp)) {
|
|
2810
|
+
streamWriter?.write({
|
|
2811
|
+
type: 'build_error', stepId: resp.step_id,
|
|
2812
|
+
message: 'parallel step failed after retries', flowId: childFlowId,
|
|
2813
|
+
});
|
|
2814
|
+
resp.status = 'killed';
|
|
2815
|
+
continue;
|
|
2816
|
+
}
|
|
2785
2817
|
{
|
|
2786
2818
|
const currentState = readActiveBuild(dataDir);
|
|
2787
2819
|
const violationList = (resp.violations || []).slice(-10);
|
|
@@ -2901,6 +2933,18 @@ export function resolveBuildStatusForCompleteResponse(response) {
|
|
|
2901
2933
|
return 'complete';
|
|
2902
2934
|
}
|
|
2903
2935
|
|
|
2936
|
+
/**
|
|
2937
|
+
* COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W4): a parallel_dispatch step that exhausted
|
|
2938
|
+
* its OWN bounded retry loop (executeParallelDispatch) tags its terminal envelope
|
|
2939
|
+
* with `_parallelRetriesExhausted: true`. The outer dispatch loops (runBuild and
|
|
2940
|
+
* executeChildFlow) must recognise that marker and terminate — never fall through
|
|
2941
|
+
* to the single-agent fix/retry path, which cannot re-run parallel tasks. Keyed on
|
|
2942
|
+
* the explicit marker, NOT a brittle "does response.tasks exist" heuristic.
|
|
2943
|
+
*/
|
|
2944
|
+
export function isParallelRetriesExhausted(response) {
|
|
2945
|
+
return !!(response && response._parallelRetriesExhausted);
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2904
2948
|
export function shouldUseServerDispatch(dispatchResponse) {
|
|
2905
2949
|
if (process.env.COMPOSE_SERVER_DISPATCH !== '1') return false;
|
|
2906
2950
|
const isolation = dispatchResponse?.isolation ?? 'worktree';
|
|
@@ -3574,7 +3618,121 @@ function applyServerDispatchDiffs(taskList, pollTasks, baseCwd, streamWriter, st
|
|
|
3574
3618
|
}
|
|
3575
3619
|
}
|
|
3576
3620
|
|
|
3577
|
-
|
|
3621
|
+
// ---------------------------------------------------------------------------
|
|
3622
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W2) — anchor-commit + entry-snapshot
|
|
3623
|
+
// helpers for the consumer-path parallel retry loop. All three operate through
|
|
3624
|
+
// a TEMP git index so the base repo's real index, working tree, and HEAD are
|
|
3625
|
+
// never mutated. The temp index lives in os.tmpdir() (NOT inside the repo):
|
|
3626
|
+
// `git add -A` would otherwise stage the temp-index file itself into the
|
|
3627
|
+
// snapshot tree, since `.compose/` is not guaranteed to be gitignored in target
|
|
3628
|
+
// repos. Putting it outside the worktree sidesteps that self-capture entirely.
|
|
3629
|
+
// The dangling commits these create carry no ref → git GC reclaims them.
|
|
3630
|
+
// ---------------------------------------------------------------------------
|
|
3631
|
+
|
|
3632
|
+
let anchorSeq = 0;
|
|
3633
|
+
|
|
3634
|
+
function tempIndexEnv(baseCwd) {
|
|
3635
|
+
const idx = join(tmpdir(), `compose-par-idx-${process.pid}-${anchorSeq++}`);
|
|
3636
|
+
// Deterministic identity so commit-tree works even in repos without a
|
|
3637
|
+
// configured git user. These commits never reach a ref.
|
|
3638
|
+
const env = {
|
|
3639
|
+
...process.env,
|
|
3640
|
+
GIT_INDEX_FILE: idx,
|
|
3641
|
+
GIT_AUTHOR_NAME: 'compose', GIT_AUTHOR_EMAIL: 'compose@local',
|
|
3642
|
+
GIT_COMMITTER_NAME: 'compose', GIT_COMMITTER_EMAIL: 'compose@local',
|
|
3643
|
+
};
|
|
3644
|
+
return { idx, env };
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
/**
|
|
3648
|
+
* Build a dangling anchor commit = HEAD + `goodDiffs` (applied index-only, in
|
|
3649
|
+
* the order given). Returns the commit SHA. The base working tree, real index,
|
|
3650
|
+
* and HEAD are untouched. Used to seed round-N retry worktrees with the round's
|
|
3651
|
+
* already-successful work (`git worktree add <sha> --detach`).
|
|
3652
|
+
*/
|
|
3653
|
+
export function buildAnchorCommit(baseCwd, goodDiffs = [], label = 'par-anchor') {
|
|
3654
|
+
const { idx, env } = tempIndexEnv(baseCwd);
|
|
3655
|
+
try {
|
|
3656
|
+
execSync('git read-tree HEAD', { cwd: baseCwd, env, stdio: 'pipe' });
|
|
3657
|
+
for (const d of goodDiffs) {
|
|
3658
|
+
if (!d) continue;
|
|
3659
|
+
execSync('git apply --cached -', { cwd: baseCwd, env, input: d, stdio: 'pipe' });
|
|
3660
|
+
}
|
|
3661
|
+
const tree = execSync('git write-tree', { cwd: baseCwd, env, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
3662
|
+
const sha = execFileSync('git', ['commit-tree', tree, '-p', 'HEAD', '-m', label], {
|
|
3663
|
+
cwd: baseCwd, env, encoding: 'utf-8', stdio: 'pipe',
|
|
3664
|
+
}).trim();
|
|
3665
|
+
return sha;
|
|
3666
|
+
} finally {
|
|
3667
|
+
try { rmSync(idx, { force: true }); } catch { /* best effort */ }
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
/**
|
|
3672
|
+
* Capture the FULL entry working tree (tracked + untracked, honoring
|
|
3673
|
+
* .gitignore) as a dangling commit, through a temp index so the real index and
|
|
3674
|
+
* working tree are never touched. Returns the snapshot commit SHA. Restored
|
|
3675
|
+
* between retry rounds via restoreToSnapshot.
|
|
3676
|
+
*/
|
|
3677
|
+
export function captureEntrySnapshot(baseCwd, label = 'par-entry-snapshot') {
|
|
3678
|
+
const { idx, env } = tempIndexEnv(baseCwd);
|
|
3679
|
+
try {
|
|
3680
|
+
execSync('git read-tree HEAD', { cwd: baseCwd, env, stdio: 'pipe' });
|
|
3681
|
+
execSync('git add -A', { cwd: baseCwd, env, stdio: 'pipe' }); // tracked + untracked → TEMP index only
|
|
3682
|
+
const tree = execSync('git write-tree', { cwd: baseCwd, env, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
3683
|
+
const sha = execFileSync('git', ['commit-tree', tree, '-p', 'HEAD', '-m', label], {
|
|
3684
|
+
cwd: baseCwd, env, encoding: 'utf-8', stdio: 'pipe',
|
|
3685
|
+
}).trim();
|
|
3686
|
+
return sha;
|
|
3687
|
+
} finally {
|
|
3688
|
+
try { rmSync(idx, { force: true }); } catch { /* best effort */ }
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
/**
|
|
3693
|
+
* Restore the base working-tree CONTENT (tracked + untracked) to a snapshot
|
|
3694
|
+
* captured by captureEntrySnapshot. The staged-vs-unstaged split is NOT
|
|
3695
|
+
* preserved (the next round's applyTaskDiffsToBaseCwd stash normalizes the
|
|
3696
|
+
* index anyway). Drops this round's tracked + untracked changes, then
|
|
3697
|
+
* re-materializes the entry tree, then resets the index to HEAD.
|
|
3698
|
+
*/
|
|
3699
|
+
export function restoreToSnapshot(baseCwd, snap) {
|
|
3700
|
+
const run = (cmd) => execSync(cmd, { cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe' });
|
|
3701
|
+
try { run('git checkout -- .'); } catch { /* nothing tracked to drop */ }
|
|
3702
|
+
try { run('git clean -fd'); } catch { /* nothing untracked to drop */ }
|
|
3703
|
+
run(`git checkout ${snap} -- .`); // re-materialize entry tracked + untracked content
|
|
3704
|
+
try { run('git reset -q'); } catch { /* unstage; best effort */ }
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
/**
|
|
3708
|
+
* Return the diffs in `diffMap` ordered by the tasks' depends_on topology (the
|
|
3709
|
+
* same DFS applyTaskDiffsToBaseCwd uses), so an anchor commit replays the good
|
|
3710
|
+
* diffs in a consistent order. COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W2).
|
|
3711
|
+
*/
|
|
3712
|
+
function topoOrderedDiffs(tasks, diffMap) {
|
|
3713
|
+
const taskMap = new Map(tasks.map(t => [t.id, t]));
|
|
3714
|
+
const order = [];
|
|
3715
|
+
const visited = new Set();
|
|
3716
|
+
const visiting = new Set();
|
|
3717
|
+
const visit = (id) => {
|
|
3718
|
+
if (visited.has(id) || visiting.has(id)) return;
|
|
3719
|
+
visiting.add(id);
|
|
3720
|
+
const t = taskMap.get(id);
|
|
3721
|
+
if (t) for (const dep of (t.depends_on ?? [])) visit(dep);
|
|
3722
|
+
visiting.delete(id);
|
|
3723
|
+
visited.add(id);
|
|
3724
|
+
order.push(id);
|
|
3725
|
+
};
|
|
3726
|
+
for (const t of tasks) visit(t.id);
|
|
3727
|
+
const out = [];
|
|
3728
|
+
for (const id of order) {
|
|
3729
|
+
const d = diffMap.get(id);
|
|
3730
|
+
if (d) out.push(d);
|
|
3731
|
+
}
|
|
3732
|
+
return out;
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3735
|
+
export async function executeParallelDispatch(
|
|
3578
3736
|
dispatchResponse,
|
|
3579
3737
|
stratum,
|
|
3580
3738
|
context,
|
|
@@ -3620,8 +3778,52 @@ async function executeParallelDispatch(
|
|
|
3620
3778
|
const worktreeIsolation = useWorktrees && isGitRepo;
|
|
3621
3779
|
|
|
3622
3780
|
const maxConcurrent = Math.max(1, dispatchResponse.max_concurrent ?? 3);
|
|
3781
|
+
|
|
3782
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W3): the consumer-dispatch path owns a
|
|
3783
|
+
// bounded, bounce-injected retry loop. Each round re-runs ONLY the failed
|
|
3784
|
+
// subset; successful diffs are replayed onto a throwaway per-round anchor
|
|
3785
|
+
// commit so re-run tasks see prior good work. The real base is restored to an
|
|
3786
|
+
// entry snapshot between rounds (no cross-round double-apply), the union is
|
|
3787
|
+
// applied to the base BEFORE parallelDone every round (today's order), and the
|
|
3788
|
+
// terminal build_step_done fires exactly once — after the terminal
|
|
3789
|
+
// parallelDone — mirroring the server-dispatch discipline.
|
|
3790
|
+
// Cap = the step's declared `retries` (Stratum's field is declarative-only on the
|
|
3791
|
+
// consumer path; Compose enforces it here), then a per-dispatch override, then 2.
|
|
3792
|
+
// Hard-ceilinged at 10 as a runaway backstop.
|
|
3793
|
+
const RETRY_CAP = Math.min(10, Math.max(0, dispatchResponse.retries ?? dispatchResponse.max_par_retries ?? 2));
|
|
3794
|
+
|
|
3795
|
+
// Entry snapshot: full working tree (tracked + untracked), captured ONCE before
|
|
3796
|
+
// round 0 mutates the base (its state is gone after the round-0 apply), for
|
|
3797
|
+
// restore-between-rounds. Skipped when retries are disabled. On the single-round
|
|
3798
|
+
// happy path it is a captured-but-never-used dangling commit (GC-reclaimable) —
|
|
3799
|
+
// the real index/worktree and the parallelDone call stay byte-identical to today.
|
|
3800
|
+
let entrySnapshot = null;
|
|
3801
|
+
if (worktreeIsolation && RETRY_CAP > 0) {
|
|
3802
|
+
try {
|
|
3803
|
+
entrySnapshot = captureEntrySnapshot(baseCwd);
|
|
3804
|
+
} catch (err) {
|
|
3805
|
+
// Capture only fails when the base has no HEAD (unborn branch — the worktree
|
|
3806
|
+
// dispatch itself can't run) or git/disk is broken. We DEGRADE to a single
|
|
3807
|
+
// pass: retryable is gated on `entrySnapshot !== null`, so no round ever runs
|
|
3808
|
+
// against an unrecoverable base. A failed step then leaves round 0's partial
|
|
3809
|
+
// merge in place — identical to the pre-feature consumer path, which never
|
|
3810
|
+
// rolled back either (a coarse `checkout/clean` is NOT a safe fallback: it
|
|
3811
|
+
// would also discard the user's entry uncommitted changes).
|
|
3812
|
+
entrySnapshot = null;
|
|
3813
|
+
console.warn(`[par-retry] entry snapshot capture failed (${err?.message ?? err}); parallel retry disabled for ${dispStepId}`);
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
// Cross-round state.
|
|
3818
|
+
const goodDiffs = new Map(); // taskId -> captured diff of a non-failed task (replayed each round)
|
|
3819
|
+
const goodResults = new Map(); // taskId -> { task_id, status:'complete', result }
|
|
3820
|
+
let subset = tasks; // round 0 runs all tasks
|
|
3821
|
+
let inboundBounces = new Map(); // taskId -> ParMergeBounce from the prior round (drives W1 injection)
|
|
3822
|
+
let round = 0;
|
|
3823
|
+
|
|
3824
|
+
// Per-round mutable state (reset at the top of each round).
|
|
3623
3825
|
let activeSlots = 0;
|
|
3624
|
-
|
|
3826
|
+
let slotWaiters = [];
|
|
3625
3827
|
const acquireSlot = () => {
|
|
3626
3828
|
if (activeSlots < maxConcurrent) { activeSlots++; return Promise.resolve(); }
|
|
3627
3829
|
return new Promise(res => slotWaiters.push(res));
|
|
@@ -3630,12 +3832,24 @@ async function executeParallelDispatch(
|
|
|
3630
3832
|
activeSlots--;
|
|
3631
3833
|
if (slotWaiters.length > 0) { activeSlots++; slotWaiters.shift()(); }
|
|
3632
3834
|
};
|
|
3835
|
+
let worktreePaths = new Map();
|
|
3836
|
+
let taskDiffs = new Map();
|
|
3837
|
+
let anchorRef = 'HEAD';
|
|
3633
3838
|
|
|
3634
|
-
|
|
3635
|
-
|
|
3839
|
+
while (true) {
|
|
3840
|
+
// Reset per-round state.
|
|
3841
|
+
activeSlots = 0;
|
|
3842
|
+
slotWaiters = [];
|
|
3843
|
+
worktreePaths = new Map();
|
|
3844
|
+
taskDiffs = new Map();
|
|
3845
|
+
// Round 0 worktrees off HEAD (byte-identical). Round N off a dangling anchor
|
|
3846
|
+
// = HEAD + replayed good diffs (topo order) so re-run tasks SEE prior good work.
|
|
3847
|
+
anchorRef = round === 0
|
|
3848
|
+
? 'HEAD'
|
|
3849
|
+
: buildAnchorCommit(baseCwd, topoOrderedDiffs(tasks, goodDiffs), `par-anchor-round-${round}`);
|
|
3636
3850
|
|
|
3637
3851
|
const settled = await Promise.allSettled(
|
|
3638
|
-
|
|
3852
|
+
subset.map(async (task) => {
|
|
3639
3853
|
await acquireSlot();
|
|
3640
3854
|
const taskId = task.id;
|
|
3641
3855
|
let taskCwd = baseCwd;
|
|
@@ -3643,7 +3857,7 @@ async function executeParallelDispatch(
|
|
|
3643
3857
|
|
|
3644
3858
|
if (worktreeIsolation) {
|
|
3645
3859
|
try {
|
|
3646
|
-
execSync(`git worktree add "${wtPath}" --detach
|
|
3860
|
+
execSync(`git worktree add "${wtPath}" --detach ${anchorRef}`, {
|
|
3647
3861
|
cwd: baseCwd, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
|
|
3648
3862
|
});
|
|
3649
3863
|
worktreePaths.set(taskId, wtPath);
|
|
@@ -3700,7 +3914,16 @@ async function executeParallelDispatch(
|
|
|
3700
3914
|
}
|
|
3701
3915
|
|
|
3702
3916
|
try {
|
|
3703
|
-
|
|
3917
|
+
let baseTaskPrompt = buildStepPrompt(syntheticDispatch, context);
|
|
3918
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (W1): inject the prior round's bounce
|
|
3919
|
+
// (gate failure / merge conflict) into a re-run task's prompt. Empty on round 0.
|
|
3920
|
+
const inbound = inboundBounces.get(taskId);
|
|
3921
|
+
if (inbound) {
|
|
3922
|
+
try {
|
|
3923
|
+
const bounceText = formatBounceForPrompt(inbound);
|
|
3924
|
+
if (bounceText) baseTaskPrompt = `${baseTaskPrompt}\n\n${bounceText}`;
|
|
3925
|
+
} catch { /* degrade — never block a task on injection */ }
|
|
3926
|
+
}
|
|
3704
3927
|
const taskTimeout = STEP_TIMEOUT_MS[dispStepId] ?? DEFAULT_TIMEOUT_MS;
|
|
3705
3928
|
// review_mode is passed via inputs (as string "true") since top-level step props are Stratum-validated.
|
|
3706
3929
|
// Fallback: parallel_dispatch steps with output_contract=ReviewResult are review by definition.
|
|
@@ -3776,6 +3999,11 @@ async function executeParallelDispatch(
|
|
|
3776
3999
|
|
|
3777
4000
|
try {
|
|
3778
4001
|
execSync('git add -A', { cwd: wtPath, encoding: 'utf-8', timeout: 10000, stdio: 'pipe' });
|
|
4002
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY: never let the worktree-ownership
|
|
4003
|
+
// marker leak into a task's captured diff — every task writes `.owner`
|
|
4004
|
+
// at its worktree root, so capturing it makes each task's diff add the
|
|
4005
|
+
// same path and the SECOND task's merge conflicts ("already exists").
|
|
4006
|
+
try { execSync('git reset -q -- .owner', { cwd: wtPath, encoding: 'utf-8', timeout: 10000, stdio: 'pipe' }); } catch { /* .owner absent — fine */ }
|
|
3779
4007
|
const diff = execSync('git diff --cached HEAD', {
|
|
3780
4008
|
cwd: wtPath, encoding: 'utf-8', timeout: 30000, stdio: 'pipe',
|
|
3781
4009
|
});
|
|
@@ -3815,82 +4043,190 @@ async function executeParallelDispatch(
|
|
|
3815
4043
|
})
|
|
3816
4044
|
);
|
|
3817
4045
|
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
4046
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER: collect structured bounce records (gate-failed
|
|
4047
|
+
// here, merge-conflict below) to surface on the parallelDone envelope.
|
|
4048
|
+
const bouncedTasks = [];
|
|
4049
|
+
const roundResults = settled.map(outcome => {
|
|
4050
|
+
if (outcome.status === 'rejected') {
|
|
4051
|
+
return { task_id: 'unknown', status: 'failed', error: String(outcome.reason) };
|
|
4052
|
+
}
|
|
4053
|
+
const { taskId, status, result, error, gateBounce } = outcome.value;
|
|
4054
|
+
if (gateBounce) bouncedTasks.push(gateBounce);
|
|
4055
|
+
return status === 'complete'
|
|
4056
|
+
? { task_id: taskId, status: 'complete', result }
|
|
4057
|
+
: { task_id: taskId, status: 'failed', error };
|
|
4058
|
+
});
|
|
3831
4059
|
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
);
|
|
3837
|
-
|
|
4060
|
+
// Union re-applied to the real base each round = carried good diffs + this
|
|
4061
|
+
// round's fresh diffs. The base was restored to the entry snapshot before
|
|
4062
|
+
// this round, so applyTaskDiffsToBaseCwd never sees a prior round's union
|
|
4063
|
+
// → no cross-round double-apply. Round 0: goodDiffs empty ⇒ union = today's diffs.
|
|
4064
|
+
const unionDiffs = new Map(goodDiffs);
|
|
4065
|
+
for (const [id, d] of taskDiffs) unionDiffs.set(id, d);
|
|
4066
|
+
|
|
4067
|
+
let mergeStatus = 'clean';
|
|
4068
|
+
if (worktreeIsolation && unionDiffs.size > 0) {
|
|
4069
|
+
const result = applyTaskDiffsToBaseCwd(
|
|
4070
|
+
tasks, unionDiffs, baseCwd, streamWriter, dispStepId, parDir,
|
|
4071
|
+
);
|
|
4072
|
+
mergeStatus = result.mergeStatus;
|
|
3838
4073
|
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
4074
|
+
// Merge applied files into context (matches existing behavior)
|
|
4075
|
+
if (mergeStatus !== 'conflict' && result.appliedFiles.length > 0) {
|
|
4076
|
+
const existing = new Set(context.filesChanged ?? []);
|
|
4077
|
+
for (const f of result.appliedFiles) existing.add(f);
|
|
4078
|
+
context.filesChanged = [...existing];
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
// Mark conflicted task as failed in THIS round's results (matches existing behavior)
|
|
4082
|
+
if (mergeStatus === 'conflict' && result.conflictedTaskId) {
|
|
4083
|
+
const idx = roundResults.findIndex(r => r.task_id === result.conflictedTaskId);
|
|
4084
|
+
if (idx >= 0) {
|
|
4085
|
+
roundResults[idx].status = 'failed';
|
|
4086
|
+
roundResults[idx].error = `merge conflict: ${result.conflictError}`;
|
|
4087
|
+
}
|
|
4088
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER: structured merge-conflict bounce.
|
|
4089
|
+
bouncedTasks.push(
|
|
4090
|
+
buildMergeConflictBounce(result.conflictedTaskId, result.conflictError, result.conflictFiles),
|
|
4091
|
+
);
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
if (worktreeIsolation) {
|
|
4096
|
+
try { execSync(`rm -rf "${parDir}"`, { cwd: baseCwd, timeout: 5000, stdio: 'pipe' }); } catch { /* ignore */ }
|
|
3844
4097
|
}
|
|
3845
4098
|
|
|
3846
|
-
//
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
4099
|
+
// Full aggregate over ALL tasks = carried good results (complete) + this round's.
|
|
4100
|
+
const taskResults = [...goodResults.values(), ...roundResults];
|
|
4101
|
+
|
|
4102
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER: when there are bounce records, send a structured
|
|
4103
|
+
// merge_status {status, bounced_tasks} so Stratum threads them onto the failure
|
|
4104
|
+
// envelope (and derives readable violations). Bare string otherwise — byte-identical.
|
|
4105
|
+
const mergeArg = bouncedTasks.length > 0
|
|
4106
|
+
? { status: mergeStatus, bounced_tasks: bouncedTasks }
|
|
4107
|
+
: mergeStatus;
|
|
4108
|
+
|
|
4109
|
+
// isolation:none (e.g. the review lenses): nothing to merge, no retry semantics.
|
|
4110
|
+
// Preserve the pre-feature single-pass behavior EXACTLY — emit the parent
|
|
4111
|
+
// build_step_done (mergeStatus-based summary) BEFORE parallelDone, then return
|
|
4112
|
+
// the raw envelope so the parent fix-loop owns any ensure_failed. No snapshot,
|
|
4113
|
+
// no marker, no event-ordering change for this path.
|
|
4114
|
+
if (!worktreeIsolation) {
|
|
4115
|
+
if (streamWriter) {
|
|
4116
|
+
const nComplete = taskResults.filter(r => r.status === 'complete').length;
|
|
4117
|
+
streamWriter.write({
|
|
4118
|
+
type: 'build_step_done', stepId: dispStepId,
|
|
4119
|
+
summary: `parallel_dispatch: ${nComplete}/${taskResults.length} tasks ${mergeStatus === 'clean' ? 'merged' : 'conflict'}`,
|
|
4120
|
+
retries: 0,
|
|
4121
|
+
violations: [],
|
|
4122
|
+
flowId: dispFlowId, parallel: true,
|
|
4123
|
+
...(parentFlowId ? { parentFlowId } : {}),
|
|
4124
|
+
});
|
|
3852
4125
|
}
|
|
3853
|
-
|
|
3854
|
-
bouncedTasks.push(
|
|
3855
|
-
buildMergeConflictBounce(result.conflictedTaskId, result.conflictError, result.conflictFiles),
|
|
3856
|
-
);
|
|
4126
|
+
return stratum.parallelDone(dispFlowId, dispStepId, taskResults, mergeArg);
|
|
3857
4127
|
}
|
|
3858
|
-
}
|
|
3859
4128
|
|
|
3860
|
-
|
|
3861
|
-
try { execSync(`rm -rf "${parDir}"`, { cwd: baseCwd, timeout: 5000, stdio: 'pipe' }); } catch { /* ignore */ }
|
|
3862
|
-
}
|
|
4129
|
+
const env = await stratum.parallelDone(dispFlowId, dispStepId, taskResults, mergeArg);
|
|
3863
4130
|
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
4131
|
+
// The terminal build_step_done (parent step) — emitted exactly ONCE, after the
|
|
4132
|
+
// terminal parallelDone, mirroring executeParallelDispatchServer's discipline.
|
|
4133
|
+
// (The one deliberate departure from the pre-feature consumer path, which emitted
|
|
4134
|
+
// unconditionally before parallelDone; terminality is only known after the call.)
|
|
4135
|
+
const emitTerminalDone = () => {
|
|
4136
|
+
if (!streamWriter) return;
|
|
4137
|
+
const nComplete = taskResults.filter(r => r.status === 'complete').length;
|
|
4138
|
+
streamWriter.write({
|
|
4139
|
+
type: 'build_step_done', stepId: dispStepId,
|
|
4140
|
+
summary: `parallel_dispatch: ${nComplete}/${taskResults.length} tasks ${env.status === 'complete' ? 'merged' : 'failed'}`,
|
|
4141
|
+
retries: round,
|
|
4142
|
+
violations: [],
|
|
4143
|
+
flowId: dispFlowId, parallel: true,
|
|
4144
|
+
...(parentFlowId ? { parentFlowId } : {}),
|
|
4145
|
+
});
|
|
4146
|
+
};
|
|
3875
4147
|
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
4148
|
+
// Terminal complete — the base holds the merged union and context.filesChanged
|
|
4149
|
+
// is set. Emit the single terminal build_step_done and return the envelope.
|
|
4150
|
+
// On round 0 this is one apply + one parallelDone + one emit — same final base,
|
|
4151
|
+
// same event content, same stratum call as the pre-feature path.
|
|
4152
|
+
if (env.status === 'complete') {
|
|
4153
|
+
emitTerminalDone();
|
|
4154
|
+
return env;
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4157
|
+
// Retry — same gate the server-dispatch path uses (mirror), bounded by RETRY_CAP.
|
|
4158
|
+
// REQUIRES a clean-base snapshot: a successful entry snapshot is the only thing
|
|
4159
|
+
// that lets a later round start from the pristine base. If capture failed
|
|
4160
|
+
// (entrySnapshot === null) we must NOT retry — retrying against round 0's already-
|
|
4161
|
+
// applied union would double-apply / corrupt the base. Degrade to a tagged
|
|
4162
|
+
// terminal instead (the W4 guard then fails the build cleanly).
|
|
4163
|
+
const retryable = (env.status === 'ensure_failed' || env.status === 'schema_failed')
|
|
4164
|
+
&& Array.isArray(env.tasks)
|
|
4165
|
+
&& env.step_id === dispStepId
|
|
4166
|
+
&& round < RETRY_CAP
|
|
4167
|
+
&& entrySnapshot !== null;
|
|
4168
|
+
|
|
4169
|
+
if (retryable) {
|
|
4170
|
+
// Restore the base to the entry snapshot so the next round starts clean (no
|
|
4171
|
+
// leftover/partial union). Idempotent on a conflict round, which
|
|
4172
|
+
// applyTaskDiffsToBaseCwd already rolled back. If the restore FAILS we cannot
|
|
4173
|
+
// guarantee a clean base for the next round, so we abort the retry and fall
|
|
4174
|
+
// through to the tagged terminal rather than running against residual state.
|
|
4175
|
+
let restored = true;
|
|
4176
|
+
try { restoreToSnapshot(baseCwd, entrySnapshot); } catch { restored = false; }
|
|
4177
|
+
if (restored) {
|
|
4178
|
+
// Accumulate every non-failed task's diff/result so it replays next round.
|
|
4179
|
+
for (const r of roundResults) {
|
|
4180
|
+
if (r.status !== 'failed') {
|
|
4181
|
+
goodResults.set(r.task_id, r);
|
|
4182
|
+
const d = taskDiffs.get(r.task_id);
|
|
4183
|
+
if (d) goodDiffs.set(r.task_id, d);
|
|
4184
|
+
}
|
|
4185
|
+
}
|
|
4186
|
+
// Authoritative failed set (gate-failed + schema_failed + conflict-loser)
|
|
4187
|
+
// becomes the next subset — never inferred from per-file apply output.
|
|
4188
|
+
const taskById = new Map(tasks.map(t => [t.id, t]));
|
|
4189
|
+
subset = taskResults
|
|
4190
|
+
.filter(r => r.status === 'failed')
|
|
4191
|
+
.map(r => taskById.get(r.task_id))
|
|
4192
|
+
.filter(Boolean);
|
|
4193
|
+
inboundBounces = new Map((bouncedTasks ?? []).map(b => [b.task_id, b]));
|
|
4194
|
+
round++;
|
|
4195
|
+
continue; // no build_step_done this round
|
|
4196
|
+
}
|
|
4197
|
+
if (streamWriter) {
|
|
4198
|
+
streamWriter.write({
|
|
4199
|
+
type: 'build_error', stepId: dispStepId,
|
|
4200
|
+
message: 'parallel retry aborted: base snapshot restore failed', flowId: dispFlowId,
|
|
4201
|
+
});
|
|
4202
|
+
}
|
|
4203
|
+
// fall through to the tagged terminal below
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
// Worktree path, non-complete terminal: cap exceeded or a terminal we cannot
|
|
4207
|
+
// retry. Restore the base (the step failed → leave no partial merge behind) and
|
|
4208
|
+
// tag the envelope so the outer loop terminates instead of single-agent-retrying
|
|
4209
|
+
// a parallel step (W4). (isolation:none already returned above.)
|
|
4210
|
+
emitTerminalDone();
|
|
4211
|
+
if (entrySnapshot) {
|
|
4212
|
+
try { restoreToSnapshot(baseCwd, entrySnapshot); } catch { /* best effort */ }
|
|
4213
|
+
}
|
|
4214
|
+
return { ...env, _parallelRetriesExhausted: true };
|
|
4215
|
+
}
|
|
3883
4216
|
}
|
|
3884
4217
|
|
|
3885
|
-
async function startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode = 'feature') {
|
|
4218
|
+
export async function startFresh(stratum, specYaml, featureCode, description, dataDir, templateName, mode = 'feature', preMergeGate) {
|
|
3886
4219
|
const flowName = extractFlowName(specYaml, templateName);
|
|
3887
4220
|
console.log(`Starting ${flowName} for ${featureCode}...`);
|
|
3888
4221
|
// COMP-FIX-HARD T4: bug-mode flows take input as { task: <description> }
|
|
3889
4222
|
// because pipelines/bug-fix.stratum.yaml's flow input contract uses `task`,
|
|
3890
4223
|
// not the feature flow's `{ featureCode, description }`.
|
|
4224
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (D5): fold pre_merge_gate into the
|
|
4225
|
+
// feature plan envelope ONLY when resolved (undefined ⇒ key omitted, not [])
|
|
4226
|
+
// so the default-OFF path is byte-identical to pre-feature behavior.
|
|
3891
4227
|
const planInputs = mode === 'bug'
|
|
3892
4228
|
? { task: description }
|
|
3893
|
-
: { featureCode, description };
|
|
4229
|
+
: { featureCode, description, ...(preMergeGate !== undefined ? { pre_merge_gate: preMergeGate } : {}) };
|
|
3894
4230
|
const response = await stratum.plan(specYaml, flowName, planInputs);
|
|
3895
4231
|
|
|
3896
4232
|
writeActiveBuild(dataDir, {
|
package/lib/step-prompt.js
CHANGED
|
@@ -162,11 +162,46 @@ function buildConflictSection(conflicts) {
|
|
|
162
162
|
return lines.join('\n');
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
// COMP-PAR-MERGE-QUEUE
|
|
166
|
-
// is injected into a re-dispatched task's prompt
|
|
167
|
-
//
|
|
168
|
-
// from flow state on each re-dispatch
|
|
169
|
-
//
|
|
165
|
+
// COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY: pre-merge bounce context (gate_failed /
|
|
166
|
+
// merge_conflict) is injected into a re-dispatched task's prompt on BOTH paths:
|
|
167
|
+
// - server-dispatch: SERVER-SIDE in Stratum's ParallelExecutor._render_prompt
|
|
168
|
+
// (the server re-resolves the task list from flow state on each re-dispatch).
|
|
169
|
+
// - consumer-dispatch: COMPOSE-SIDE via formatBounceForPrompt() below, appended
|
|
170
|
+
// in executeParallelDispatch's retry loop (Compose, not Stratum, builds the
|
|
171
|
+
// per-task prompt on that path). This is the Compose mirror of
|
|
172
|
+
// parallel_exec.py `_format_bounce_for_prompt`.
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Format a ParMergeBounce record (contracts/par-merge-bounce.json) as a prompt
|
|
176
|
+
* section telling a re-dispatched task its prior attempt was rejected before
|
|
177
|
+
* merge. Mirrors Stratum's `_format_bounce_for_prompt` (parallel_exec.py).
|
|
178
|
+
* Degrades to '' on null/non-object input so a malformed bounce never breaks a
|
|
179
|
+
* re-run prompt.
|
|
180
|
+
*
|
|
181
|
+
* @param {{task_id?:string, reason?:string, command?:string|null,
|
|
182
|
+
* exit_code?:number|null, files?:string[], excerpt?:string}} bounce
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
export function formatBounceForPrompt(bounce) {
|
|
186
|
+
if (!bounce || typeof bounce !== 'object') return '';
|
|
187
|
+
const files = Array.isArray(bounce.files) && bounce.files.length
|
|
188
|
+
? bounce.files.join(', ')
|
|
189
|
+
: '(none reported)';
|
|
190
|
+
const lines = ['## Previous attempt was rejected before merge — fix this before finishing'];
|
|
191
|
+
if (bounce.reason === 'gate_failed') {
|
|
192
|
+
const code = bounce.exit_code == null ? '?' : String(bounce.exit_code);
|
|
193
|
+
lines.push(`Your last attempt FAILED the pre-merge gate \`${bounce.command ?? '?'}\` (exit ${code}). It was not merged.`);
|
|
194
|
+
} else if (bounce.reason === 'merge_conflict') {
|
|
195
|
+
lines.push("Your last attempt produced changes that CONFLICTED with another task's changes at merge time. It was not merged.");
|
|
196
|
+
} else {
|
|
197
|
+
lines.push('Your last attempt was rejected before merge.');
|
|
198
|
+
}
|
|
199
|
+
lines.push(`Files involved: ${files}`);
|
|
200
|
+
if (bounce.excerpt) {
|
|
201
|
+
lines.push('Failure output:', '```', String(bounce.excerpt), '```');
|
|
202
|
+
}
|
|
203
|
+
return lines.join('\n');
|
|
204
|
+
}
|
|
170
205
|
|
|
171
206
|
/**
|
|
172
207
|
* Build a retry prompt when postconditions failed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13-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
|
description:
|
|
19
19
|
type: string
|
|
20
20
|
required: true
|
|
21
|
+
pre_merge_gate:
|
|
22
|
+
type: array
|
|
23
|
+
required: false # COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY: default-OFF per-task gate (lint+build), omitted unless capabilities.preMergeGate
|
|
21
24
|
|
|
22
25
|
contracts:
|
|
23
26
|
PhaseResult:
|
|
@@ -189,6 +192,7 @@ flows:
|
|
|
189
192
|
input:
|
|
190
193
|
featureCode: {type: string}
|
|
191
194
|
description: {type: string}
|
|
195
|
+
pre_merge_gate: {type: array} # COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY: optional per-task pre-merge gate
|
|
192
196
|
output: PhaseResult
|
|
193
197
|
max_rounds: 10
|
|
194
198
|
steps:
|
|
@@ -345,6 +349,7 @@ flows:
|
|
|
345
349
|
isolation: worktree
|
|
346
350
|
capture_diff: true
|
|
347
351
|
defer_advance: true
|
|
352
|
+
pre_merge_verify: "$.input.pre_merge_gate" # COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY: optional per-task fast gate (default-OFF)
|
|
348
353
|
require: all
|
|
349
354
|
merge: sequential_apply
|
|
350
355
|
intent_template: >
|