@smartmemory/compose 0.4.1 → 0.5.1
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/.claude/agents/compose-architect.md +40 -0
- package/.claude/agents/compose-explorer.md +35 -0
- package/.claude/hooks/canon-guard.mjs +52 -0
- package/README.md +15 -1
- package/bin/compose.js +57 -17
- package/bin/git-hooks/pre-push.template +26 -1
- package/bin/receipts-gate.js +39 -0
- package/contracts/fluid-record.schema.json +5 -0
- package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
- package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
- package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
- package/dist/assets/channel-B-7ZRCKC.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
- package/dist/assets/clone-CfNV0lUO.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
- package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
- package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
- package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
- package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
- package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/agent-string.js +9 -4
- package/lib/build-cancel.js +205 -0
- package/lib/build-stream-writer.js +6 -0
- package/lib/build.js +1189 -165
- package/lib/canon-guard.js +3 -24
- package/lib/canon-registry.js +2 -71
- package/lib/codex-preflight.js +8 -0
- package/lib/colleague/context.js +123 -0
- package/lib/consumer-fanout.js +427 -17
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -0
- package/lib/experiment-pricing.js +5 -1
- package/lib/flow-state.js +38 -0
- package/lib/fluid/factory.js +112 -1
- package/lib/fluid/ideabox-manifest.js +203 -0
- package/lib/fluid/ideabox-migrate.js +177 -29
- package/lib/fluid/ideabox-preamble.js +155 -0
- package/lib/fluid/ideabox-readable.js +83 -0
- package/lib/fluid/ideabox-recover.js +393 -0
- package/lib/fluid/import-ideabox.js +188 -45
- package/lib/fluid/local-provider.js +6 -0
- package/lib/fluid/portfolio.js +255 -0
- package/lib/fluid/record-shape.js +7 -0
- package/lib/fluid/render-ideabox.js +153 -7
- package/lib/fluid/smartmemory-provider.js +6 -0
- package/lib/gate-prompt.js +14 -7
- package/lib/gsd.js +95 -48
- package/lib/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/model-pricing.js +4 -1
- package/lib/output-gate.js +81 -0
- package/lib/pipeline-profiles.js +200 -0
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +41 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/team-flag.js +1 -1
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/lib/wave-checkpoint.js +100 -0
- package/package.json +7 -5
- package/presets/team-fable-astra.profiles.json +18 -0
- package/presets/team-fable-astra.stratum.yaml +236 -0
- package/server/build-stream-bridge.js +43 -1
- package/server/cc-session-watcher.js +54 -5
- package/server/compose-mcp-tools.js +48 -50
- package/server/compose-mcp.js +0 -2
- package/server/design-routes.js +1 -1
- package/server/file-watcher.js +14 -0
- package/server/ideabox-routes.js +10 -0
- package/server/index.js +5 -1
- package/server/lifecycle-guard.js +13 -0
- package/server/maya-routes.js +111 -7
- package/server/mcp-tool-defs.js +0 -25
- package/server/mcp-tool-policy.js +6 -13
- package/server/model-tiers.js +14 -6
- package/server/stratum-client.js +61 -15
- package/server/supervisor.js +18 -4
- package/server/vision-routes.js +9 -3
- package/dist/assets/channel-SnZzzh7k.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
- package/dist/assets/clone-DgklGjHm.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
- package/lib/append-integrity.js +0 -81
- package/lib/canon-override.js +0 -196
package/lib/build.js
CHANGED
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, symlinkSync } from 'node:fs';
|
|
12
|
-
import { join, resolve, dirname, relative, posix } from 'node:path';
|
|
12
|
+
import { join, resolve, dirname, basename, relative, posix } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { execSync, execFileSync } from 'node:child_process';
|
|
15
15
|
import { createHash, randomUUID } from 'node:crypto';
|
|
16
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
16
17
|
|
|
17
|
-
import { StratumMcpClient, StratumError, resolvePlanSpecValues, resolveStepProfile } from './stratum-mcp-client.js';
|
|
18
|
+
import { StratumMcpClient, StratumError, resolvePlanSpecValues, resolveStepProfile, asTransportRefusal, isUnknownFlowError } from './stratum-mcp-client.js';
|
|
18
19
|
import { resolveStratumMcpConnection } from './stratum-engine.js';
|
|
19
20
|
import { runAndNormalize, mergeUsage, AgentTimeoutError, AgentAbortedError, UserInterruptError, AgentError } from './result-normalizer.js';
|
|
20
21
|
import { checkCapabilityViolation } from './capability-checker.js';
|
|
@@ -26,14 +27,14 @@ import { preflightCodexWorktreeProbe, codexProbeAbortMessage } from './codex-pre
|
|
|
26
27
|
import { buildStepPrompt, buildGateContext, clearAmbientContextCache } from './step-prompt.js';
|
|
27
28
|
import { promptGate } from './gate-prompt.js';
|
|
28
29
|
import { VisionWriter, ServerUnreachableError } from './vision-writer.js';
|
|
29
|
-
import { readFlowRound } from './flow-state.js';
|
|
30
|
+
import { readFlowRound, readFlowSnapshot, readFlowSpend } from './flow-state.js';
|
|
30
31
|
import { resolvePort } from './resolve-port.js';
|
|
31
32
|
import { probeServer } from './server-probe.js';
|
|
32
33
|
import { CliProgress } from './cli-progress.js';
|
|
33
34
|
import { BuildStreamWriter } from './build-stream-writer.js';
|
|
34
35
|
import { appendBuildHistory, projectHistorySteps, stepOutcomeToStatus } from './build-history.js';
|
|
35
36
|
import { KNOWN_VERSIONS } from './build-stream-schema.js';
|
|
36
|
-
import { resolveAgentConfig, parseAgentString } from './agent-string.js';
|
|
37
|
+
import { resolveAgentConfig, parseAgentString, validateAgentString } from './agent-string.js';
|
|
37
38
|
import { emitSections as emitPlanSections, appendTrailers as appendSectionTrailers, analyzeRollup, writeRollup } from './sections.js';
|
|
38
39
|
import { SECTIONS_DIR } from './constants.js';
|
|
39
40
|
import { rtkPrefix } from './rtk.js';
|
|
@@ -58,6 +59,20 @@ import { isTriageStale } from './triage.js';
|
|
|
58
59
|
import { applyFrontTriage, maybeEscalateLane } from './lane-gate.js';
|
|
59
60
|
import { LENS_DEFINITIONS } from './review-lenses.js';
|
|
60
61
|
import { injectCertInstructions } from './cert-inject.js';
|
|
62
|
+
// COMP-BUILD-CANCEL: the build-level cancel handle, its registry and the bounded
|
|
63
|
+
// SIGINT/SIGTERM teardown (blueprint §3.4-§3.6).
|
|
64
|
+
import {
|
|
65
|
+
createBuildCancel,
|
|
66
|
+
confirmCancellation,
|
|
67
|
+
isRunCancelled,
|
|
68
|
+
flowTag,
|
|
69
|
+
runCancelTeardown,
|
|
70
|
+
cancelBudgets,
|
|
71
|
+
withDeadline,
|
|
72
|
+
registerBuildCancel,
|
|
73
|
+
unregisterBuildCancel,
|
|
74
|
+
lookupBuildCancel,
|
|
75
|
+
} from './build-cancel.js';
|
|
61
76
|
import { buildReviewPrompt } from './review-prompt.js';
|
|
62
77
|
import { detectTestFramework, scaffoldTestFramework, parseTestSummary, deriveTestsPass, deriveTestsAttested, isTestFile } from './test-bootstrap.js';
|
|
63
78
|
import { classifyStepAsTier, evaluateTiers } from './gate-tiers.js';
|
|
@@ -76,10 +91,14 @@ import {
|
|
|
76
91
|
ConsumerArtifactError,
|
|
77
92
|
ConsumerFanoutArtifacts,
|
|
78
93
|
ConsumerMergeDecisionError,
|
|
94
|
+
MergeAfterCancelError,
|
|
79
95
|
isConsumerDescriptor,
|
|
80
96
|
recoverAdvancedConsumerArtifacts,
|
|
81
97
|
verifyConsumerRunRevision,
|
|
82
98
|
} from './consumer-fanout.js';
|
|
99
|
+
import { preflightPipelineProfiles as preflightProfiles, mergeRuntimeProfiles, validateWaveAdmission, resolveConsumerProfile, profilesDigest, PipelineProfileError } from './pipeline-profiles.js';
|
|
100
|
+
import { decideGateFromOutput } from './output-gate.js';
|
|
101
|
+
import { readCheckpointRef, worktreeBaseFor, squashOntoBase, removeCheckpointRef, WaveCheckpointError } from './wave-checkpoint.js';
|
|
83
102
|
import { appendEvent as appendDispatchEvent, readEvents as readDispatchEvents } from './dispatch-ledger.js';
|
|
84
103
|
import { appendEvent as appendFeatureEvent } from './feature-events.js';
|
|
85
104
|
|
|
@@ -687,6 +706,16 @@ function responseReissuesStep(response, stepId) {
|
|
|
687
706
|
return response.ready.some((ready) => ready?.id === stepId && ready?.previousFailure);
|
|
688
707
|
}
|
|
689
708
|
|
|
709
|
+
async function callFlowWithCancellation(stratum, method, flowId, buildCancel, ...args) {
|
|
710
|
+
if (buildCancel.cancelled) throw buildCancel.signal.reason;
|
|
711
|
+
try {
|
|
712
|
+
return await stratum[method](flowId, ...args);
|
|
713
|
+
} catch (error) {
|
|
714
|
+
await confirmCancellation(error, { stratum, flowId, buildCancel });
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
690
719
|
async function reportConsumerStepDone({
|
|
691
720
|
descriptor,
|
|
692
721
|
flowId,
|
|
@@ -695,7 +724,9 @@ async function reportConsumerStepDone({
|
|
|
695
724
|
artifacts,
|
|
696
725
|
progress,
|
|
697
726
|
streamWriter,
|
|
727
|
+
buildCancel,
|
|
698
728
|
}) {
|
|
729
|
+
if (buildCancel?.cancelled) throw buildCancel.signal.reason;
|
|
699
730
|
try {
|
|
700
731
|
return {
|
|
701
732
|
response: await stratum.stepDone(
|
|
@@ -704,6 +735,7 @@ async function reportConsumerStepDone({
|
|
|
704
735
|
skipped: false,
|
|
705
736
|
};
|
|
706
737
|
} catch (error) {
|
|
738
|
+
if (await confirmCancellation(error, { stratum, flowId, buildCancel })) throw error;
|
|
707
739
|
if (!isStaleOrDuplicateConsumerReportError(error)) throw error;
|
|
708
740
|
|
|
709
741
|
const summary = `consumer item ${descriptor.id} (index ${descriptor.itemIndex}) step_done skipped:`
|
|
@@ -744,6 +776,257 @@ async function reportConsumerStepDone({
|
|
|
744
776
|
}
|
|
745
777
|
}
|
|
746
778
|
|
|
779
|
+
export function waveProfilesEnabled(profiles) {
|
|
780
|
+
return Object.entries(profiles ?? {}).some(([id, entry]) =>
|
|
781
|
+
id === '_consumer' || id === '_costCeiling' || (!id.startsWith('_') && typeof entry === 'object'));
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** One durable delivery spool for metadata and paid-call receipts. */
|
|
785
|
+
export async function flushWaveReceipts(context, dispatchIds) {
|
|
786
|
+
const artifacts = context.artifacts;
|
|
787
|
+
for (const pending of artifacts?.journal.pendingUsageReceipts ?? []) {
|
|
788
|
+
if (pending.state === 'acknowledged' || dispatchIds && !dispatchIds.includes(pending.dispatchId)) continue;
|
|
789
|
+
if (context.buildCancel?.cancelled || typeof context.stratum?.usageReport !== 'function') {
|
|
790
|
+
throw new ConsumerArtifactError('WAVE_EVIDENCE_INCOMPLETE', 'Receipt retained locally; replication unavailable or cancelled');
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
const ack = await context.stratum.usageReport(context.flowId, pending.receipt);
|
|
794
|
+
if (!['ok', 'accepted', 'duplicate'].includes(ack?.status)) throw new Error('Receipt acknowledgement missing');
|
|
795
|
+
artifacts.acknowledgeUsageReceipt({ dispatchId: pending.dispatchId, seq: ack.receipt?.seq ?? ack.seq });
|
|
796
|
+
} catch (error) {
|
|
797
|
+
await confirmCancellation(error, context);
|
|
798
|
+
throw new ConsumerArtifactError('WAVE_EVIDENCE_INCOMPLETE', `Receipt ${pending.dispatchId} retained locally: ${error.message}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export async function reportWaveEvidence(context, kind, token, detail, state) {
|
|
804
|
+
const dispatchId = `compose:${kind}:${context.flowId}:${token}${state ? `:${state}` : ''}`;
|
|
805
|
+
const receipt = { dispatchId, source: `compose:${kind}`, usage: {}, detail };
|
|
806
|
+
context.artifacts.recordPendingUsageReceipt({ dispatchId, receipt });
|
|
807
|
+
await flushWaveReceipts(context, [dispatchId]);
|
|
808
|
+
return dispatchId;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Validate the recorded full input, before a concurrency-truncated ready list runs. */
|
|
812
|
+
export async function admitConsumerWave({ descriptor, descriptors = [descriptor], audit, localSpec,
|
|
813
|
+
profiles = {}, artifacts, stratum, flowId }) {
|
|
814
|
+
const policy = profiles._consumer?.[descriptor.step] ?? {};
|
|
815
|
+
if (!profiles[descriptor.step]?.tier_from && !profiles._consumer?.[descriptor.step]) return null;
|
|
816
|
+
audit ??= await stratum.audit(flowId);
|
|
817
|
+
const steps = localSpec.flows[localSpec.flows.entry]?.steps ?? [];
|
|
818
|
+
const step = steps.find(s => s.id === descriptor.step);
|
|
819
|
+
const state = audit?.steps?.[descriptor.step];
|
|
820
|
+
// Fresh engine fanouts omit the parent epoch but stamp every audit item with 0.
|
|
821
|
+
// Derive from that recorded item evidence; never admit an unrecorded epoch.
|
|
822
|
+
const epoch = state?.epoch ?? state?.fanout?.items?.[0]?.epoch;
|
|
823
|
+
const findings = [];
|
|
824
|
+
const add = message => findings.push({ code: 'WAVE_INPUT_INVALID', message });
|
|
825
|
+
let items;
|
|
826
|
+
let sourceProvenance;
|
|
827
|
+
try {
|
|
828
|
+
const reference = step?.fanout?.over;
|
|
829
|
+
const expression = typeof reference === 'string' && reference.match(/^\$\{([a-z][a-z0-9_-]*)((?:\.[A-Za-z_]\w*|\[\d+\])*)\}$/);
|
|
830
|
+
if (!expression || ['item', 'prev'].includes(expression[1])) throw new Error('Unsupported recorded fanout reference');
|
|
831
|
+
const root = expression[1];
|
|
832
|
+
const path = [...expression[2].matchAll(/\.([A-Za-z_]\w*)|\[(\d+)\]/g)].map(m => m[1] ?? Number(m[2]));
|
|
833
|
+
const access = (value, segments) => {
|
|
834
|
+
for (const key of segments) {
|
|
835
|
+
if (value === null || typeof value !== 'object' || !Object.hasOwn(value, key)
|
|
836
|
+
|| ['__proto__', 'prototype', 'constructor'].includes(key)) return undefined;
|
|
837
|
+
value = value[key];
|
|
838
|
+
}
|
|
839
|
+
return value;
|
|
840
|
+
};
|
|
841
|
+
if (root === 'input' && path.length) {
|
|
842
|
+
const snapshot = readFlowSnapshot(flowId, { revisionDigest: artifacts.journal.revisionDigest });
|
|
843
|
+
items = access(snapshot.input, path);
|
|
844
|
+
sourceProvenance = { reference, inputDigest: profilesDigest(snapshot.input) };
|
|
845
|
+
} else if (path[0] === 'output') {
|
|
846
|
+
const source = audit.steps?.[root];
|
|
847
|
+
// Ordinary source steps also omit their initial epoch (the engine uses 0).
|
|
848
|
+
const sourceEpoch = source?.epoch ?? 0;
|
|
849
|
+
if (source?.status !== 'succeeded' || sourceEpoch !== epoch) throw new Error('Wave source is not current and succeeded');
|
|
850
|
+
items = access(source.output, path.slice(1));
|
|
851
|
+
sourceProvenance = { reference, step: root, epoch: sourceEpoch,
|
|
852
|
+
acceptedDispatchToken: source.acceptedDispatchToken, outputDigest: profilesDigest(source.output) };
|
|
853
|
+
} else {
|
|
854
|
+
// Engine carry expressions are bare names (${wave.tasks}), not ${carry.wave.tasks}.
|
|
855
|
+
const carry = Object.hasOwn(audit.carry ?? {}, root) ? audit.carry[root] : undefined;
|
|
856
|
+
items = access(carry?.value, path);
|
|
857
|
+
sourceProvenance = { reference, carry: carry ?? null };
|
|
858
|
+
}
|
|
859
|
+
if (!Array.isArray(items) || !Number.isInteger(epoch) || epoch < 0
|
|
860
|
+
|| !Array.isArray(state?.fanout?.items) || items.length !== state.fanout.items.length) {
|
|
861
|
+
throw new Error('Recorded wave length/epoch differs from fanout');
|
|
862
|
+
}
|
|
863
|
+
for (const [index, item] of state.fanout.items.entries()) {
|
|
864
|
+
if ((item.epoch ?? state.epoch) !== epoch || item.index !== undefined && item.index !== index) {
|
|
865
|
+
throw new Error('Recorded fanout item index/epoch differs from wave');
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
for (const d of descriptors.filter(d => d.step === descriptor.step)) {
|
|
869
|
+
const recorded = state.fanout.items[d.itemIndex];
|
|
870
|
+
if (!recorded || !Number.isInteger(d.itemIndex) || d.itemIndex < 0
|
|
871
|
+
|| !Number.isInteger(d.stage) || d.stage < 0 || d.stage >= step.fanout.steps.length
|
|
872
|
+
|| recorded.generation !== d.generation
|
|
873
|
+
|| d.epoch !== undefined && d.epoch !== epoch
|
|
874
|
+
|| profilesDigest(d.item) !== profilesDigest(items[d.itemIndex])) throw new Error('Descriptor differs from recorded wave item');
|
|
875
|
+
}
|
|
876
|
+
} catch (error) { add(error.message); }
|
|
877
|
+
const profilesByStage = [];
|
|
878
|
+
if (!findings.length) for (const stage of step.fanout.steps) {
|
|
879
|
+
const result = validateWaveAdmission(profiles[step.id] ?? stage.agent ?? 'claude', items,
|
|
880
|
+
{ provider: stage.agent?.startsWith('$.input')
|
|
881
|
+
? resolveConsumerProfile(profiles[step.id], {}).provider : stage.agent ?? 'claude',
|
|
882
|
+
ownership: policy.ownership, independent: policy.independent });
|
|
883
|
+
findings.push(...result.findings);
|
|
884
|
+
profilesByStage.push(result.profiles.map(p => ({ ...p, profilesDigest: artifacts.journal.profilesDigest })));
|
|
885
|
+
}
|
|
886
|
+
if (findings.length) {
|
|
887
|
+
const failure = `${findings[0].code}: ${findings.map(f => `item ${(f.itemIndex ?? -1) + 1} ${f.message}`).join('; ')}`;
|
|
888
|
+
await reportWaveEvidence({ artifacts, stratum, flowId }, 'wave_admission',
|
|
889
|
+
`${descriptor.step}:${epoch ?? 'unknown'}`, { findings, failure, items: items ?? null });
|
|
890
|
+
return { failure, findings };
|
|
891
|
+
}
|
|
892
|
+
if (policy.checkpoint_gate) artifacts.initializeWave({ ref: `refs/heads/compose/wave/${flowId}`,
|
|
893
|
+
profilesDigest: artifacts.journal.profilesDigest });
|
|
894
|
+
if (artifacts.journal.wave?.checkpoints.some(c => c.state !== 'published' || !c.evidenceReceiptId)) {
|
|
895
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Checkpoint publication/evidence pending before admission');
|
|
896
|
+
}
|
|
897
|
+
const previous = artifacts.journal.waveAdmissions?.find(a => a.fanoutStepId === step.id && a.epoch === epoch);
|
|
898
|
+
const baseCommit = previous?.baseCommit ?? (artifacts.journal.wave
|
|
899
|
+
? worktreeBaseFor({ journal: artifacts.journal, ref: readCheckpointRef({ cwd: artifacts.targetCwd, ref: artifacts.journal.wave.ref }) })
|
|
900
|
+
: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: artifacts.targetCwd, encoding: 'utf8' }).trim());
|
|
901
|
+
artifacts.recordWaveAdmission({ fanoutStepId: step.id, epoch, inputDigest: profilesDigest(items),
|
|
902
|
+
sourceProvenance, baseCommit, items: items.map((item, itemIndex) => ({ itemIndex,
|
|
903
|
+
itemDigest: profilesDigest(item), filesOwned: item.files_owned ?? [],
|
|
904
|
+
profilesByStage: profilesByStage.map(stage => stage[itemIndex]) })) });
|
|
905
|
+
const bindings = {};
|
|
906
|
+
for (const d of descriptors.filter(d => d.step === descriptor.step)) {
|
|
907
|
+
bindings[d.dispatchToken] = artifacts.recordDispatchBinding({ dispatchToken: d.dispatchToken,
|
|
908
|
+
itemBinding: { item: items[d.itemIndex], itemDigest: profilesDigest(items[d.itemIndex]), epoch, sourceProvenance },
|
|
909
|
+
resolvedProfile: profilesByStage[d.stage][d.itemIndex] });
|
|
910
|
+
}
|
|
911
|
+
return { bindings };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** recoverCheckpoint owns prepared-fsync -> CAS -> published, using the merge witness. */
|
|
915
|
+
export async function publishConsumerCheckpoint(context, transaction) {
|
|
916
|
+
const artifacts = context.artifacts;
|
|
917
|
+
if (!artifacts?.journal.wave || !transaction) return;
|
|
918
|
+
artifacts.recoverCheckpoint(transaction);
|
|
919
|
+
await replicateCheckpoints(context);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function capturedWavePaths(artifacts) {
|
|
923
|
+
return [...new Set((artifacts.journal.wave?.checkpoints ?? []).flatMap(checkpoint =>
|
|
924
|
+
execFileSync('git', ['diff', '--name-only', '-z', '--no-renames', checkpoint.baselineTree, checkpoint.tree, '--'],
|
|
925
|
+
{ cwd: artifacts.targetCwd, encoding: 'utf8' }).split('\0').filter(Boolean)))];
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
async function replicateCheckpoints(context) {
|
|
929
|
+
const artifacts = context.artifacts;
|
|
930
|
+
for (const checkpoint of artifacts?.journal.wave?.checkpoints ?? []) {
|
|
931
|
+
if (checkpoint.evidenceReceiptId) continue;
|
|
932
|
+
const { publishedAt, materializedTree, preparedAt, state, ...identity } = checkpoint;
|
|
933
|
+
const evidenceReceiptId = await reportWaveEvidence(context, 'checkpoint', checkpoint.gateToken, identity, 'published');
|
|
934
|
+
artifacts.markCheckpointPublished({ gateToken: checkpoint.gateToken, commit: checkpoint.commit, evidenceReceiptId });
|
|
935
|
+
context.streamWriter?.write({ type: 'wave_checkpoint', ...identity, flowId: context.flowId });
|
|
936
|
+
}
|
|
937
|
+
if (artifacts?.journal.wave) {
|
|
938
|
+
context.filesChanged = capturedWavePaths(artifacts);
|
|
939
|
+
context.recordFilesChanged?.(context.filesChanged);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Current audit only; a token's durable human hold survives a ceiling override. */
|
|
944
|
+
export async function evaluateConfiguredGate(context, { localSpec, gateStepId, gateToken, audit, costCeilingUsd }) {
|
|
945
|
+
const profiles = context.pipelineProfiles ?? {};
|
|
946
|
+
const config = profiles[gateStepId]?.decide_from ? profiles[gateStepId] : null;
|
|
947
|
+
const budget = profiles._costCeiling?.gates.includes(gateStepId) ? profiles._costCeiling : null;
|
|
948
|
+
if (!config && !budget) return null;
|
|
949
|
+
audit ??= await context.stratum.audit(context.flowId);
|
|
950
|
+
let decision = null;
|
|
951
|
+
let ceiling;
|
|
952
|
+
if (budget) {
|
|
953
|
+
try {
|
|
954
|
+
if (!context.receiptsMode) throw new Error('Usage receipt surface unavailable');
|
|
955
|
+
await flushWaveReceipts(context);
|
|
956
|
+
const read = readFlowSpend(context.flowId, { revisionDigest: context.artifacts.journal.revisionDigest,
|
|
957
|
+
gateStepId, gateToken }, context.artifacts.journal.pendingUsageReceipts);
|
|
958
|
+
const limit = costCeilingUsd ?? read.input?.[budget.input] ?? budget.default;
|
|
959
|
+
if (!Number.isFinite(limit) || limit <= 0) throw new Error('Ceiling must be finite and positive');
|
|
960
|
+
ceiling = { spent: read.spent, ceiling: limit };
|
|
961
|
+
const control = { ceiling: limit, input: budget.input, override: costCeilingUsd ?? null };
|
|
962
|
+
await reportWaveEvidence(context, 'cost_ceiling', gateToken, control, profilesDigest(control));
|
|
963
|
+
if (read.spent > limit) decision = { outcome: null, reason: 'WAVE_COST_CEILING_EXCEEDED', ...ceiling };
|
|
964
|
+
} catch (error) {
|
|
965
|
+
decision = { outcome: null, reason: 'WAVE_COST_UNVERIFIED', message: error.message };
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
const held = context.artifacts.journal.pendingUsageReceipts?.find(p =>
|
|
969
|
+
p.dispatchId === `compose:gate_hold:${context.flowId}:${gateToken}`);
|
|
970
|
+
if (held) return { ...held.receipt.detail, outcome: null };
|
|
971
|
+
if (!decision && config) {
|
|
972
|
+
const steps = localSpec.flows[localSpec.flows.entry].steps;
|
|
973
|
+
const execute = steps.find(s => s.id === 'execute' && s.fanout?.dispatch === 'consumer')
|
|
974
|
+
?? steps.find(s => profiles._consumer?.[s.id]?.independent)
|
|
975
|
+
?? steps.find(s => profiles[s.id]?.tier_from)
|
|
976
|
+
?? steps.find(s => profiles._consumer?.[s.id]) ?? steps.find(s => s.fanout?.dispatch === 'consumer');
|
|
977
|
+
const executeProfile = profiles[execute?.id] ?? execute?.fanout?.steps?.[0]?.agent ?? 'claude';
|
|
978
|
+
const executeProvider = resolveConsumerProfile(executeProfile, {}).provider;
|
|
979
|
+
// The engine omits `epoch` on a step that has never been revised (initial epoch
|
|
980
|
+
// = 0, the same convention admission applies at the wave seam); the gate's
|
|
981
|
+
// staleness fence compares integers, so materialise the convention here rather
|
|
982
|
+
// than reading a fresh run as stale (GATE_SOURCE_STALE on every first pass).
|
|
983
|
+
const states = Object.fromEntries(Object.entries(audit.steps ?? {}).map(([id, state]) =>
|
|
984
|
+
[id, state && typeof state === 'object' ? { ...state, epoch: state.epoch ?? 0 } : state]));
|
|
985
|
+
decision = decideGateFromOutput(config, states, { gateStepId, gateToken, ceiling, executeProfile, executeProvider });
|
|
986
|
+
}
|
|
987
|
+
if (!decision) return null;
|
|
988
|
+
if (!decision.outcome) {
|
|
989
|
+
// Queue first even if the cost-delivery barrier itself is unavailable.
|
|
990
|
+
try { await reportWaveEvidence(context, 'gate_hold', gateToken, decision); }
|
|
991
|
+
catch (error) { if (context.buildCancel?.cancelled) throw error; }
|
|
992
|
+
} else {
|
|
993
|
+
await reportWaveEvidence(context, 'gate_decision', gateToken, decision, 'proposed');
|
|
994
|
+
const fresh = await context.stratum.audit(context.flowId);
|
|
995
|
+
if (fresh.steps?.[gateStepId]?.gateToken !== gateToken
|
|
996
|
+
|| [config.decide_from.step, ...(config.validators ?? []).map(v => v.review_step)].some(id =>
|
|
997
|
+
profilesDigest(fresh.steps?.[id] ?? null) !== profilesDigest(audit.steps?.[id] ?? null))) {
|
|
998
|
+
return { outcome: null, reason: 'GATE_SOURCE_STALE' };
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return decision;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
export function prepareWaveShip(context) {
|
|
1005
|
+
const artifacts = context?.artifacts;
|
|
1006
|
+
const wave = artifacts?.journal.wave;
|
|
1007
|
+
if (!wave?.checkpoints.length) return;
|
|
1008
|
+
const checkpoint = wave.checkpoints.at(-1);
|
|
1009
|
+
if (checkpoint.state !== 'published' || !checkpoint.evidenceReceiptId) {
|
|
1010
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Ship requires acknowledged checkpoint evidence');
|
|
1011
|
+
}
|
|
1012
|
+
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: artifacts.targetCwd, encoding: 'utf8' }).trim();
|
|
1013
|
+
if (head !== wave.baseCommit) {
|
|
1014
|
+
const shipped = artifacts.journal.pendingUsageReceipts?.find(p =>
|
|
1015
|
+
p.receipt.source === 'compose:wave_ship' && p.receipt.detail.commit === head);
|
|
1016
|
+
const parent = execFileSync('git', ['rev-parse', `${head}^`], { cwd: artifacts.targetCwd, encoding: 'utf8' }).trim();
|
|
1017
|
+
if (!shipped || parent !== wave.baseCommit || shipped.receipt.detail.checkpointTree !== checkpoint.tree) {
|
|
1018
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'HEAD differs from base and recorded ship');
|
|
1019
|
+
}
|
|
1020
|
+
context.filesChanged = shipped.receipt.detail.filesChanged;
|
|
1021
|
+
return { replay: shipped };
|
|
1022
|
+
}
|
|
1023
|
+
const tree = squashOntoBase({ cwd: artifacts.targetCwd, ref: wave.ref, base: wave.baseCommit });
|
|
1024
|
+
if (tree !== checkpoint.tree) throw new WaveCheckpointError('WAVE_CHECKPOINT_DIVERGED', 'Ship tree differs from checkpoint');
|
|
1025
|
+
context.filesChanged = [...new Set([...(context.filesChanged ?? []), ...capturedWavePaths(artifacts)])];
|
|
1026
|
+
context.recordFilesChanged?.(context.filesChanged);
|
|
1027
|
+
return tree;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
747
1030
|
export async function runConsumerIssuance({
|
|
748
1031
|
descriptor,
|
|
749
1032
|
flowId,
|
|
@@ -761,11 +1044,37 @@ export async function runConsumerIssuance({
|
|
|
761
1044
|
// events for this item and halts a spinning run.
|
|
762
1045
|
perItemTimeoutMs = null,
|
|
763
1046
|
stuckDetector = null,
|
|
1047
|
+
// COMP-BUILD-CANCEL S03-5: the build-level cancel handle, so an item's agent dies with
|
|
1048
|
+
// the build. Optional — lib/gsd.js calls this without one.
|
|
1049
|
+
buildCancel = null,
|
|
764
1050
|
// D6: the full agent profile string for this fanout step (from the compose
|
|
765
1051
|
// sidecar), applied at invocation so e.g. an isolation:none review fanout runs
|
|
766
1052
|
// read-only. Absent → the descriptor's bare provider literal (no restrictions).
|
|
767
1053
|
profile = null,
|
|
1054
|
+
admission = null,
|
|
768
1055
|
}) {
|
|
1056
|
+
if (profile && typeof profile !== 'string') throw new Error('Consumer profile must be a resolved string; pass pipelineProfiles for item admission');
|
|
1057
|
+
const configured = context.pipelineProfiles?.[descriptor.step]?.tier_from
|
|
1058
|
+
|| context.pipelineProfiles?._consumer?.[descriptor.step];
|
|
1059
|
+
if (configured && !admission) admission = await admitConsumerWave({
|
|
1060
|
+
descriptor, descriptors: [descriptor], audit: await stratum.audit(flowId),
|
|
1061
|
+
localSpec, profiles: context.pipelineProfiles, artifacts, stratum, flowId,
|
|
1062
|
+
});
|
|
1063
|
+
const bound = admission?.bindings?.[descriptor.dispatchToken];
|
|
1064
|
+
if (bound) profile = bound.resolvedProfile.profile;
|
|
1065
|
+
const evidenceContext = { ...context, stratum, flowId, artifacts, buildCancel };
|
|
1066
|
+
if (admission?.failure) {
|
|
1067
|
+
const envelope = { failure: admission.failure };
|
|
1068
|
+
const entry = artifacts.prepareArtifactFailure(descriptor, envelope,
|
|
1069
|
+
new ConsumerArtifactError(admission.findings[0]?.code ?? 'WAVE_INPUT_INVALID', admission.failure));
|
|
1070
|
+
await reportWaveEvidence(evidenceContext, 'wave_rejected', descriptor.dispatchToken,
|
|
1071
|
+
{ findings: admission.findings });
|
|
1072
|
+
const report = await reportConsumerStepDone({ descriptor, flowId, envelope: entry.envelope,
|
|
1073
|
+
stratum, artifacts, progress, streamWriter, buildCancel });
|
|
1074
|
+
if (!report.skipped) artifacts.reconcileAudit(await stratum.audit(flowId),
|
|
1075
|
+
{ fanoutStepId: descriptor.step, itemIndex: descriptor.itemIndex });
|
|
1076
|
+
return report.response;
|
|
1077
|
+
}
|
|
769
1078
|
let recovery;
|
|
770
1079
|
try {
|
|
771
1080
|
recovery = artifacts.reconcileDescriptor(descriptor, audit);
|
|
@@ -782,7 +1091,7 @@ export async function runConsumerIssuance({
|
|
|
782
1091
|
const envelope = { failure: `${artifactError.code}: ${artifactError.message}` };
|
|
783
1092
|
artifacts.prepareArtifactFailure(descriptor, envelope, artifactError);
|
|
784
1093
|
const report = await reportConsumerStepDone({
|
|
785
|
-
descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter,
|
|
1094
|
+
descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter, buildCancel,
|
|
786
1095
|
});
|
|
787
1096
|
if (report.skipped) return report.response;
|
|
788
1097
|
const { response } = report;
|
|
@@ -797,10 +1106,13 @@ export async function runConsumerIssuance({
|
|
|
797
1106
|
throw new Error(`accepted consumer issuance ${descriptor.id} unexpectedly remained ready`);
|
|
798
1107
|
}
|
|
799
1108
|
if (recovery.action === 'report') {
|
|
1109
|
+
const entry = artifacts.journal.issuances.find(e => e.dispatchToken === descriptor.dispatchToken);
|
|
1110
|
+
if (entry?.findings?.length) await reportWaveEvidence(evidenceContext, 'ownership', descriptor.dispatchToken, { findings: entry.findings });
|
|
800
1111
|
const report = await reportConsumerStepDone({
|
|
801
1112
|
descriptor,
|
|
802
1113
|
flowId,
|
|
803
1114
|
envelope: recovery.envelope,
|
|
1115
|
+
buildCancel,
|
|
804
1116
|
stratum,
|
|
805
1117
|
artifacts,
|
|
806
1118
|
progress,
|
|
@@ -866,8 +1178,7 @@ export async function runConsumerIssuance({
|
|
|
866
1178
|
// D3: stable per-item key for the stuck detector's per-task bookkeeping. In
|
|
867
1179
|
// GSD mode this becomes the operator-facing stuck task id (stuck.json/pause.json/
|
|
868
1180
|
// stuck.md), so it MUST be the decompose task id (T01), matching the blackboard
|
|
869
|
-
// + milestone report.
|
|
870
|
-
// so the real runGsd call site threads the resolved id via context.gsdTaskId
|
|
1181
|
+
// + milestone report. GSD threads descriptor.item.id via context.gsdTaskId
|
|
871
1182
|
// (same precedence as gsdTaskId below); build-mode fanout passes no gsdTaskId and
|
|
872
1183
|
// keeps the item-id/index key unchanged (byte-identical).
|
|
873
1184
|
const stuckTaskId = context?.gsdTaskId ?? descriptor.item?.id ?? `${descriptor.step ?? descriptor.id}:${descriptor.itemIndex}`;
|
|
@@ -928,6 +1239,12 @@ export async function runConsumerIssuance({
|
|
|
928
1239
|
lane,
|
|
929
1240
|
});
|
|
930
1241
|
|
|
1242
|
+
if (bound) {
|
|
1243
|
+
streamWriter.write({ type: 'step_model', stepId: descriptor.id, flowId,
|
|
1244
|
+
itemIndex: descriptor.itemIndex, ...bound.resolvedProfile });
|
|
1245
|
+
await reportWaveEvidence(evidenceContext, 'item_model', descriptor.dispatchToken,
|
|
1246
|
+
{ intended: bound.resolvedProfile, itemBinding: bound.itemBinding }, 'proposed');
|
|
1247
|
+
}
|
|
931
1248
|
let mainResult;
|
|
932
1249
|
try {
|
|
933
1250
|
mainResult = await runAndNormalize(null, prompt, dispatch, {
|
|
@@ -943,6 +1260,14 @@ export async function runConsumerIssuance({
|
|
|
943
1260
|
reviewMode: reviewOpts.reviewMode,
|
|
944
1261
|
confidenceGate: reviewOpts.confidenceGate,
|
|
945
1262
|
lens: reviewOpts.lens,
|
|
1263
|
+
// COMP-BUILD-CANCEL S03-4: tag the item so `stratum_flow_cancel` can reach it
|
|
1264
|
+
// from another process, and chain the build handle so a local `isolation: none`
|
|
1265
|
+
// item — which never enters stratum — dies with the build.
|
|
1266
|
+
...(flowTag(flowId, descriptor.step ?? descriptor.id, descriptor.itemIndex)
|
|
1267
|
+
? { flow: flowTag(flowId, descriptor.step ?? descriptor.id, descriptor.itemIndex) }
|
|
1268
|
+
: {}),
|
|
1269
|
+
flowId, buildCancel,
|
|
1270
|
+
buildSignal: buildCancel?.signal,
|
|
946
1271
|
telemetry: {
|
|
947
1272
|
site: context.gsd ? 'gsd' : 'consumer',
|
|
948
1273
|
project_cwd: context.projectCwd ?? context.cwd,
|
|
@@ -956,6 +1281,7 @@ export async function runConsumerIssuance({
|
|
|
956
1281
|
localExecution: descriptor.policy?.isolation === 'none',
|
|
957
1282
|
});
|
|
958
1283
|
} catch (error) {
|
|
1284
|
+
if (buildCancel?.cancelled) throw error;
|
|
959
1285
|
const failedUsage = failureUsageFields(error);
|
|
960
1286
|
// Control failures do not settle the item, so record known dispatch usage
|
|
961
1287
|
// before aborting the pump. Never retry work with uncertain termination.
|
|
@@ -1020,6 +1346,9 @@ export async function runConsumerIssuance({
|
|
|
1020
1346
|
const { result, normalizationFailure } = mainResult;
|
|
1021
1347
|
// D2(b): forward the item's agent usage so GSD can debit the cumulative
|
|
1022
1348
|
// budget ledger. Build mode passes no onUsage sink → byte-identical no-op.
|
|
1349
|
+
if (context.pipelineProfiles?._costCeiling && !mainResult?.usage) {
|
|
1350
|
+
await context.onUsage?.({ dispatch_id: mainResult.dispatchIds?.primary ?? descriptor.dispatchToken }, { stepId: descriptor.step, source: 'fanout' });
|
|
1351
|
+
}
|
|
1023
1352
|
if (typeof context?.onUsage === 'function' && mainResult?.usage) {
|
|
1024
1353
|
await context.onUsage(usagePayload(mainResult.usage, mainResult.usages), {
|
|
1025
1354
|
dispatchId: mainResult.dispatchIds?.primary,
|
|
@@ -1042,7 +1371,7 @@ export async function runConsumerIssuance({
|
|
|
1042
1371
|
);
|
|
1043
1372
|
if (gateFailure) localFailure = `pre_merge failed: ${JSON.stringify(gateFailure)}`;
|
|
1044
1373
|
}
|
|
1045
|
-
|
|
1374
|
+
let envelope = localFailure
|
|
1046
1375
|
? { failure: String(localFailure) }
|
|
1047
1376
|
: contract.hasOutContract
|
|
1048
1377
|
? result != null
|
|
@@ -1066,8 +1395,18 @@ export async function runConsumerIssuance({
|
|
|
1066
1395
|
});
|
|
1067
1396
|
}
|
|
1068
1397
|
const preparedEntry = artifacts.prepareIssuance(descriptor, envelope, {
|
|
1069
|
-
finalStage,
|
|
1398
|
+
finalStage, ...(bound ?? {}),
|
|
1399
|
+
ownership: context.pipelineProfiles?._consumer?.[descriptor.step]?.ownership,
|
|
1070
1400
|
});
|
|
1401
|
+
// Legacy injected artifact adapters predate returned envelopes. Configured
|
|
1402
|
+
// admission/ownership requires the authoritative primitive result.
|
|
1403
|
+
if (configured || bound || preparedEntry?.envelope) envelope = preparedEntry.envelope;
|
|
1404
|
+
localFailure = envelope.failure ?? localFailure;
|
|
1405
|
+
if (preparedEntry?.findings?.length) await reportWaveEvidence(evidenceContext, 'ownership',
|
|
1406
|
+
descriptor.dispatchToken, { findings: preparedEntry.findings });
|
|
1407
|
+
if (bound) await reportWaveEvidence(evidenceContext, 'item_model', descriptor.dispatchToken,
|
|
1408
|
+
{ intended: bound.resolvedProfile, observed: { normalizedUsageModel: mainResult.usage?.model ?? null,
|
|
1409
|
+
connectorIdentityVerified: false } }, 'observed');
|
|
1071
1410
|
// H3: record this item's timing + (final-stage) diff snapshot for the GSD
|
|
1072
1411
|
// milestone report. `preparedEntry.diff` is the cumulative worktree diff the
|
|
1073
1412
|
// artifacts journal already computed at final stage (null otherwise) — tapped
|
|
@@ -1089,7 +1428,7 @@ export async function runConsumerIssuance({
|
|
|
1089
1428
|
}
|
|
1090
1429
|
|
|
1091
1430
|
const report = await reportConsumerStepDone({
|
|
1092
|
-
descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter,
|
|
1431
|
+
descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter, buildCancel,
|
|
1093
1432
|
});
|
|
1094
1433
|
if (report.skipped) return report.response;
|
|
1095
1434
|
const { response } = report;
|
|
@@ -1114,7 +1453,7 @@ export async function runConsumerIssuance({
|
|
|
1114
1453
|
stepId: descriptor.id,
|
|
1115
1454
|
summary: result?.summary ?? `consumer item ${descriptor.itemIndex} stage ${descriptor.stage} reported`,
|
|
1116
1455
|
retries: Math.max(0, (descriptor.attempt ?? 1) - 1),
|
|
1117
|
-
violations: [],
|
|
1456
|
+
violations: preparedEntry?.findings ?? [],
|
|
1118
1457
|
flowId,
|
|
1119
1458
|
consumer: true,
|
|
1120
1459
|
// H6: matches the item's start stepId so the UI decrements the same task
|
|
@@ -1123,7 +1462,7 @@ export async function runConsumerIssuance({
|
|
|
1123
1462
|
// COMP-AGENT-LANES (C4): terminal status is explicit at source — the UI
|
|
1124
1463
|
// must not infer "complete" from the done event's existence.
|
|
1125
1464
|
status: localFailure ? 'failed' : 'succeeded',
|
|
1126
|
-
outcome:
|
|
1465
|
+
outcome: localFailure ? 'failed' : (result?.outcome ?? 'succeeded'),
|
|
1127
1466
|
itemIndex: descriptor.itemIndex,
|
|
1128
1467
|
stage: descriptor.stage,
|
|
1129
1468
|
generation: descriptor.generation,
|
|
@@ -1171,28 +1510,160 @@ function extractFilesChanged(response) {
|
|
|
1171
1510
|
* @param {Array<{id?:string, files_owned?:string[]}>} tasks
|
|
1172
1511
|
* @returns {string|null}
|
|
1173
1512
|
*/
|
|
1513
|
+
/**
|
|
1514
|
+
* A local copy of a bundled preset whose sidecar carries EXECUTION configuration
|
|
1515
|
+
* (object-form agent entries with tier_from, gate decide_from/validators,
|
|
1516
|
+
* `_consumer`, `_costCeiling`) must keep that sidecar: without it the output-driven
|
|
1517
|
+
* gate, per-item routing, ownership and the ceiling silently vanish and a blocking
|
|
1518
|
+
* repair decision ships (slice 4 review r1 #1). A string-only sidecar carries tool
|
|
1519
|
+
* restrictions and tiers, for which "missing → bare defaults" stays the documented
|
|
1520
|
+
* behaviour — and a custom spec that merely shares a bundled basename (every build
|
|
1521
|
+
* test fixture writes its own pipelines/build.stratum.yaml) is not a copy.
|
|
1522
|
+
*/
|
|
1523
|
+
export function sidecarCarriesExecutionConfig(profiles) {
|
|
1524
|
+
if (!profiles || typeof profiles !== 'object') return false;
|
|
1525
|
+
return Object.entries(profiles).some(([key, value]) =>
|
|
1526
|
+
key === '_consumer' || key === '_costCeiling'
|
|
1527
|
+
|| (!key.startsWith('_') && value !== null && typeof value === 'object'));
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
export function requirePipelineSidecar(specPath) {
|
|
1531
|
+
const localPath = resolve(specPath);
|
|
1532
|
+
const name = basename(localPath).replace(/\.stratum\.ya?ml$/, '');
|
|
1533
|
+
if (name === basename(localPath)) return;
|
|
1534
|
+
const sidecarName = `${name}.profiles.json`;
|
|
1535
|
+
if (existsSync(join(dirname(localPath), sidecarName))) return;
|
|
1536
|
+
|
|
1537
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
1538
|
+
const bundledSpecs = ['presets', 'pipelines'].flatMap(directory =>
|
|
1539
|
+
['yaml', 'yml'].map(ext => join(packageRoot, directory, `${name}.stratum.${ext}`)));
|
|
1540
|
+
if (bundledSpecs.includes(localPath)) return;
|
|
1541
|
+
for (const path of bundledSpecs) {
|
|
1542
|
+
const bundledSidecar = join(dirname(path), sidecarName);
|
|
1543
|
+
if (!existsSync(path) || !existsSync(bundledSidecar)) continue;
|
|
1544
|
+
let bundledProfiles;
|
|
1545
|
+
try { bundledProfiles = JSON.parse(readFileSync(bundledSidecar, 'utf-8')); } catch { continue; }
|
|
1546
|
+
if (sidecarCarriesExecutionConfig(bundledProfiles)) {
|
|
1547
|
+
throw Object.assign(new Error(
|
|
1548
|
+
`PROFILE_SIDECAR_REQUIRED: ${specPath} has no adjacent ${sidecarName} but the bundled ${name} ships one that configures gates/routing/ownership — copy both files (see docs/team-presets.md)`,
|
|
1549
|
+
), { code: 'PROFILE_SIDECAR_REQUIRED' });
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1174
1554
|
/**
|
|
1175
1555
|
* D6: load the compose-owned profile sidecar next to a pipeline spec. The engine
|
|
1176
1556
|
* accepts only the literal claude|codex agent, so the full profile strings that
|
|
1177
1557
|
* carry tool restrictions + model tiers (claude:read-only-reviewer,
|
|
1178
1558
|
* claude::critical, claude:orchestrator, ...) live in <spec>.profiles.json keyed
|
|
1179
1559
|
* by step id and are applied compose-side at invocation. Absent → {} (bare
|
|
1180
|
-
* literals; no restrictions).
|
|
1560
|
+
* literals; no restrictions), after runBuild enforces required bundled sidecars.
|
|
1561
|
+
* Malformed or non-object sidecars fail closed.
|
|
1181
1562
|
*
|
|
1182
1563
|
* @param {string} specPath path to the .stratum.yaml spec
|
|
1183
1564
|
* @returns {Record<string,string>} step id → agent profile string
|
|
1184
1565
|
*/
|
|
1185
1566
|
export function loadPipelineProfiles(specPath) {
|
|
1567
|
+
const sidecar = String(specPath).replace(/\.stratum\.ya?ml$/, '.profiles.json');
|
|
1568
|
+
if (sidecar === String(specPath)) return {};
|
|
1186
1569
|
try {
|
|
1187
|
-
const sidecar = String(specPath).replace(/\.stratum\.ya?ml$/, '.profiles.json');
|
|
1188
|
-
if (sidecar === String(specPath) || !existsSync(sidecar)) return {};
|
|
1189
1570
|
const parsed = JSON.parse(readFileSync(sidecar, 'utf-8'));
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1571
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1572
|
+
throw new Error('expected an object mapping step ids to profiles');
|
|
1573
|
+
}
|
|
1574
|
+
return parsed;
|
|
1575
|
+
} catch (error) {
|
|
1576
|
+
if (error.code === 'ENOENT') return {};
|
|
1577
|
+
throw new Error(`Profile sidecar ${sidecar} is invalid: ${error.message}`);
|
|
1193
1578
|
}
|
|
1194
1579
|
}
|
|
1195
1580
|
|
|
1581
|
+
/**
|
|
1582
|
+
* Pure profile validation across all flows and fanout stages. Runtime references
|
|
1583
|
+
* use the supplied role inputs (build defaults for the initial static check).
|
|
1584
|
+
* No tier with modelID null means the documented connector default and is legal;
|
|
1585
|
+
* an explicit tier with modelID null is unavailable and must fail closed.
|
|
1586
|
+
* Metadata keys starting with '_' are ignored; stale step keys are errors.
|
|
1587
|
+
*/
|
|
1588
|
+
export function preflightPipelineProfiles(stepProfiles, specYaml, specName = '<spec>', inputs = {
|
|
1589
|
+
implementer_agent: 'claude', reviewer_agent: 'codex',
|
|
1590
|
+
}) {
|
|
1591
|
+
const rawProfiles = stepProfiles;
|
|
1592
|
+
if (Array.isArray(rawProfiles._costCeiling?.gates) && rawProfiles._costCeiling.gates.includes('review_gate')) {
|
|
1593
|
+
throw new PipelineProfileError('WAVE_COST_CEILING_RESERVED_GATE',
|
|
1594
|
+
`Profile preflight failed for ${specName}: _costCeiling.gates cannot target reserved review_gate`);
|
|
1595
|
+
}
|
|
1596
|
+
stepProfiles = Object.fromEntries(Object.entries(stepProfiles).filter(([, entry]) => !entry?.decide_from).map(([id, entry]) => [id, entry?.default ?? entry]));
|
|
1597
|
+
const rawSpec = typeof specYaml === 'string' ? YAML.parse(specYaml) : structuredClone(specYaml);
|
|
1598
|
+
const spec = resolvePlanSpecValues(structuredClone(rawSpec), inputs);
|
|
1599
|
+
const rawSteps = Object.values(rawSpec?.flows ?? {}).flatMap(flow => flow?.steps ?? []);
|
|
1600
|
+
const rawById = new Map(rawSteps.map(step => [step.id, step]));
|
|
1601
|
+
const steps = Object.values(spec?.flows ?? {}).flatMap(flow => flow?.steps ?? []);
|
|
1602
|
+
const stepIds = new Set(steps.map(step => step.id));
|
|
1603
|
+
const failures = [];
|
|
1604
|
+
const resolved = {};
|
|
1605
|
+
const check = (id, profile, { engineDispatch = false } = {}) => {
|
|
1606
|
+
try {
|
|
1607
|
+
if (typeof profile !== 'string' || !profile.trim()) {
|
|
1608
|
+
throw new Error('profile must be a non-empty agent string');
|
|
1609
|
+
}
|
|
1610
|
+
validateAgentString(profile);
|
|
1611
|
+
const { provider, template, tier, modelID } = resolveAgentConfig(profile);
|
|
1612
|
+
if (tier && modelID === null) throw new Error(`tier "${tier}" has no model for provider "${provider}"`);
|
|
1613
|
+
// Review r1 #3: an engine-dispatched fanout invokes its connector inside
|
|
1614
|
+
// Stratum, so compose-side tiers/templates never reach the call — certifying
|
|
1615
|
+
// a model here that the engine will not use is worse than not checking.
|
|
1616
|
+
if (engineDispatch && (tier || template)) {
|
|
1617
|
+
throw new Error(`profile "${profile}" carries a tier/template but the fanout is dispatch: engine, where compose profiles are not applied`);
|
|
1618
|
+
}
|
|
1619
|
+
resolved[id] = { profile, provider, tier, modelID };
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
failures.push(`step "${id}": ${error.message}`);
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
for (const id of Object.keys(stepProfiles)) {
|
|
1625
|
+
if (!id.startsWith('_') && !stepIds.has(id)) failures.push(`step "${id}": not found in spec`);
|
|
1626
|
+
}
|
|
1627
|
+
for (const step of steps) {
|
|
1628
|
+
const hasProfile = !step.id.startsWith('_') && Object.hasOwn(stepProfiles, step.id);
|
|
1629
|
+
const stages = step.fanout?.steps ?? [];
|
|
1630
|
+
const engineDispatch = step.fanout?.dispatch === 'engine';
|
|
1631
|
+
if (Object.hasOwn(step, 'agent') || (hasProfile && stages.length === 0)) {
|
|
1632
|
+
check(step.id, hasProfile ? stepProfiles[step.id] : step.agent);
|
|
1633
|
+
}
|
|
1634
|
+
// Review r1 #1: profiles (sidecar and runtime) are keyed by the FANOUT id and
|
|
1635
|
+
// applied to every stage at invocation (resolvePlanSpecValues records the
|
|
1636
|
+
// last stage's runtime profile under step.id). A multi-stage fanout whose raw
|
|
1637
|
+
// stage agents differ therefore cannot be routed honestly — fail closed.
|
|
1638
|
+
if (stages.length > 1) {
|
|
1639
|
+
const rawStages = rawById.get(step.id)?.fanout?.steps ?? [];
|
|
1640
|
+
const rawAgents = new Set(rawStages.map(stage => JSON.stringify(stage?.agent ?? null)));
|
|
1641
|
+
const runtimeRef = rawStages.some(stage => typeof stage?.agent === 'string' && stage.agent.startsWith('$.input'));
|
|
1642
|
+
// Bare literals with no profile run as written — nothing collapses.
|
|
1643
|
+
if (rawAgents.size > 1 && (hasProfile || runtimeRef)) {
|
|
1644
|
+
failures.push(`step "${step.id}": multi-stage fanout stages declare different agents (${[...rawAgents].join(', ')}); profiles are keyed by the fanout id and apply to every stage`);
|
|
1645
|
+
continue;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
for (const [index, stage] of stages.entries()) {
|
|
1649
|
+
// Review r1 #2: a stage with no explicit agent inherits claude but still
|
|
1650
|
+
// consumes the enclosing sidecar profile at invocation — check it too.
|
|
1651
|
+
if (!Object.hasOwn(stage, 'agent') && !hasProfile) continue;
|
|
1652
|
+
// Single-stage fanouts use the enclosing id, as invocation profile lookup does.
|
|
1653
|
+
const id = stages.length === 1 ? step.id : `${step.id}/${index}`;
|
|
1654
|
+
check(id, hasProfile ? stepProfiles[step.id] : stage.agent, { engineDispatch });
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
if (failures.length) throw new Error(`Profile preflight failed for ${specName}: ${failures.join('; ')}`);
|
|
1658
|
+
let checked;
|
|
1659
|
+
try { checked = preflightProfiles(rawProfiles, spec); }
|
|
1660
|
+
catch (error) { throw new Error(`Profile preflight failed for ${specName}: ${error.message}`, { cause: error }); }
|
|
1661
|
+
// Keep the public legacy projection; runner-only pins are additive.
|
|
1662
|
+
return Object.defineProperties({ ok: true, resolved }, {
|
|
1663
|
+
normalized: { value: checked.normalized }, profilesDigest: { value: checked.profilesDigest },
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1196
1667
|
/**
|
|
1197
1668
|
* V1: convert a compose usage record (runAndNormalize's normalized shape, or the
|
|
1198
1669
|
* raw TS complete usage) into the engine Budget shape `{tokens?, usd?, ms?}` the
|
|
@@ -1243,7 +1714,8 @@ function usagePayload(usage, usages) {
|
|
|
1243
1714
|
|
|
1244
1715
|
/** Send one surface-15 receipt per underlying model dispatch. */
|
|
1245
1716
|
export async function reportUsageReceipts(context, usage, meta = {}) {
|
|
1246
|
-
if (!context?.
|
|
1717
|
+
if ((!context?.pipelineProfiles?._costCeiling && context?.buildCancel?.cancelled)
|
|
1718
|
+
|| !context?.receiptsMode || !context.flowId || typeof context.stratum?.usageReport !== 'function') {
|
|
1247
1719
|
return [];
|
|
1248
1720
|
}
|
|
1249
1721
|
const entries = Array.isArray(usage)
|
|
@@ -1251,9 +1723,10 @@ export async function reportUsageReceipts(context, usage, meta = {}) {
|
|
|
1251
1723
|
: (Array.isArray(usage?.usages) ? usage.usages : (usage ? [usage] : []));
|
|
1252
1724
|
const responses = [];
|
|
1253
1725
|
for (const entry of entries) {
|
|
1726
|
+
if (context.buildCancel?.cancelled && !context.pipelineProfiles?._costCeiling) break;
|
|
1254
1727
|
if (!entry || typeof entry !== 'object') continue;
|
|
1255
|
-
const engineUsage = toEngineUsage(entry);
|
|
1256
|
-
if (!engineUsage) continue;
|
|
1728
|
+
const engineUsage = toEngineUsage(entry) ?? (context.pipelineProfiles?._costCeiling ? {} : null);
|
|
1729
|
+
if (!engineUsage && !context.pipelineProfiles?._costCeiling) continue;
|
|
1257
1730
|
// Surface 15 requires explicit USD provenance. Normalized UsageRecords carry
|
|
1258
1731
|
// `usd_source`; raw engine usage ({tokens, usd, ms}) does not. Preserve raw
|
|
1259
1732
|
// token/time usage, but fail closed on an unlabelled dollar value instead of
|
|
@@ -1262,7 +1735,7 @@ export async function reportUsageReceipts(context, usage, meta = {}) {
|
|
|
1262
1735
|
? entry.usd_source
|
|
1263
1736
|
: null;
|
|
1264
1737
|
if (Object.hasOwn(engineUsage, 'usd') && !usdSource) delete engineUsage.usd;
|
|
1265
|
-
if (Object.keys(engineUsage).length === 0) continue;
|
|
1738
|
+
if (Object.keys(engineUsage).length === 0 && !context.pipelineProfiles?._costCeiling) continue;
|
|
1266
1739
|
const input = entry.input_tokens;
|
|
1267
1740
|
const output = entry.output_tokens;
|
|
1268
1741
|
const receipt = {
|
|
@@ -1288,10 +1761,16 @@ export async function reportUsageReceipts(context, usage, meta = {}) {
|
|
|
1288
1761
|
} }
|
|
1289
1762
|
: {}),
|
|
1290
1763
|
...(Object.hasOwn(engineUsage, 'usd') ? { usdSource } : {}),
|
|
1764
|
+
...(context.pipelineProfiles?._costCeiling && (!usdSource || !Number.isFinite(entry.cost_usd ?? entry.usd))
|
|
1765
|
+
? { detail: { costUnknown: true } } : {}),
|
|
1291
1766
|
};
|
|
1292
1767
|
try {
|
|
1293
|
-
|
|
1768
|
+
if (context.pipelineProfiles?._costCeiling) {
|
|
1769
|
+
context.artifacts.recordPendingUsageReceipt({ dispatchId: receipt.dispatchId, receipt });
|
|
1770
|
+
await flushWaveReceipts(context, [receipt.dispatchId]);
|
|
1771
|
+
} else responses.push(await context.stratum.usageReport(context.flowId, receipt));
|
|
1294
1772
|
} catch (error) {
|
|
1773
|
+
if (await confirmCancellation(error, context)) break;
|
|
1295
1774
|
console.warn(`[usage-receipt] failed for ${receipt.dispatchId}: ${error?.message ?? error}`);
|
|
1296
1775
|
}
|
|
1297
1776
|
}
|
|
@@ -1466,12 +1945,38 @@ function readActiveBuild(dataDir) {
|
|
|
1466
1945
|
}
|
|
1467
1946
|
}
|
|
1468
1947
|
|
|
1469
|
-
|
|
1948
|
+
/**
|
|
1949
|
+
* COMP-BUILD-CANCEL §3.7 — re-read and CLAIM before ANY mutation of the active-build
|
|
1950
|
+
* record. Strict on purpose: a missing flow id must never match anything.
|
|
1951
|
+
*
|
|
1952
|
+
* Returns the claimed record, or a REASON. C48: the fallback validates PRESENCE before
|
|
1953
|
+
* equality, because `undefined === undefined` is true and two records that both lack a
|
|
1954
|
+
* pid would otherwise claim each other.
|
|
1955
|
+
*/
|
|
1956
|
+
function claimActiveBuild(dataDir, active) {
|
|
1957
|
+
const cur = readActiveBuild(dataDir);
|
|
1958
|
+
if (!cur) return { ok: false, reason: 'ownership_lost' };
|
|
1959
|
+
if (cur.featureCode !== active.featureCode) return { ok: false, reason: 'ownership_lost' };
|
|
1960
|
+
if (cur.flowId && active.flowId) {
|
|
1961
|
+
return cur.flowId === active.flowId
|
|
1962
|
+
? { ok: true, record: cur }
|
|
1963
|
+
: { ok: false, reason: 'ownership_lost' };
|
|
1964
|
+
}
|
|
1965
|
+
// No flow id on one side: fall back to pid + startedAt, but ONLY when all four are present.
|
|
1966
|
+
const present = Boolean(cur.pid) && Boolean(active.pid)
|
|
1967
|
+
&& Boolean(cur.startedAt) && Boolean(active.startedAt);
|
|
1968
|
+
if (!present) return { ok: false, reason: 'ownership_unverifiable' };
|
|
1969
|
+
return (cur.pid === active.pid && cur.startedAt === active.startedAt)
|
|
1970
|
+
? { ok: true, record: cur }
|
|
1971
|
+
: { ok: false, reason: 'ownership_lost' };
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
export function writeActiveBuild(dataDir, state, { stampPid = true } = {}) {
|
|
1470
1975
|
mkdirSync(dataDir, { recursive: true });
|
|
1471
|
-
//
|
|
1472
|
-
state.pid = process.pid;
|
|
1976
|
+
// The aborter must preserve the driver's identity when it owns the terminal write.
|
|
1977
|
+
if (stampPid) state.pid = process.pid;
|
|
1473
1978
|
const target = activeBuildPath(dataDir);
|
|
1474
|
-
const tmp = target
|
|
1979
|
+
const tmp = `${target}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
1475
1980
|
writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
1476
1981
|
renameSync(tmp, target);
|
|
1477
1982
|
}
|
|
@@ -1971,8 +2476,15 @@ export function makeAskAgent(stratum, context, gateDispatch, gateExtras) {
|
|
|
1971
2476
|
`${fileRef} this question concisely:\n\n` +
|
|
1972
2477
|
`${question}\n\n` +
|
|
1973
2478
|
`Keep your answer brief — 2-3 sentences max.`;
|
|
2479
|
+
if (context.buildCancel?.cancelled) throw context.buildCancel.signal.reason;
|
|
2480
|
+
const taggedFlow = flowTag(context.flowId, gateDispatch.step_id ?? gateDispatch.id);
|
|
1974
2481
|
const text = await stratum.runAgentText('claude', qaPrompt, {
|
|
1975
2482
|
cwd: context.cwd,
|
|
2483
|
+
// S03-4 (R1): a gate pause leaves the run `running`, so a gate-time agent IS
|
|
2484
|
+
// admitted and tags successfully (C1). Conditional on `context.flowId` because
|
|
2485
|
+
// `makeAskAgent` is exported and called with a bare, flow-less context in tests.
|
|
2486
|
+
...(taggedFlow ? { flow: taggedFlow } : {}),
|
|
2487
|
+
signal: context.buildCancel?.signal,
|
|
1976
2488
|
telemetry: {
|
|
1977
2489
|
site: 'gate-qa',
|
|
1978
2490
|
project_cwd: context.projectCwd ?? context.cwd,
|
|
@@ -1990,6 +2502,9 @@ export function makeAskAgent(stratum, context, gateDispatch, gateExtras) {
|
|
|
1990
2502
|
budgetExhausted = true;
|
|
1991
2503
|
}
|
|
1992
2504
|
},
|
|
2505
|
+
}).catch(async error => {
|
|
2506
|
+
await confirmCancellation(error, { stratum, flowId: context.flowId, buildCancel: context.buildCancel, tagged: Boolean(taggedFlow) });
|
|
2507
|
+
throw error;
|
|
1993
2508
|
});
|
|
1994
2509
|
return text || '(no answer)';
|
|
1995
2510
|
};
|
|
@@ -2009,8 +2524,8 @@ export function deleteActiveBuild(dataDir) {
|
|
|
2009
2524
|
* never produce more steps. Used to detect stale lock files and decide
|
|
2010
2525
|
* whether a resumed flow needs a fresh start.
|
|
2011
2526
|
*/
|
|
2012
|
-
function isTerminalFlow(status) {
|
|
2013
|
-
return ['completed', 'failed', 'budget_exhausted'].includes(status);
|
|
2527
|
+
export function isTerminalFlow(status) {
|
|
2528
|
+
return ['completed', 'failed', 'budget_exhausted', 'cancelled'].includes(status);
|
|
2014
2529
|
}
|
|
2015
2530
|
|
|
2016
2531
|
function isRecoverableFlowProbeError(err) {
|
|
@@ -2158,7 +2673,34 @@ export async function emitBugCheckpointOnTerminalFailure(context, exhaustedStepI
|
|
|
2158
2673
|
}
|
|
2159
2674
|
}
|
|
2160
2675
|
|
|
2676
|
+
/** Guard the actual vision mutation, after updateItemStatus's async server probe.
|
|
2677
|
+
* Use a per-call receiver so a late best-effort update cannot outlive its claim. */
|
|
2678
|
+
async function killOwnedBuildVision(visionWriter, itemId, claimOwnership) {
|
|
2679
|
+
const ownedWriter = Object.create(visionWriter);
|
|
2680
|
+
for (const method of ['_directUpdateItemStatus', '_restUpdateItemStatus']) {
|
|
2681
|
+
ownedWriter[method] = (...args) => {
|
|
2682
|
+
const claim = claimOwnership();
|
|
2683
|
+
if (!claim.ok) throw Object.assign(new Error(claim.reason), { reason: claim.reason });
|
|
2684
|
+
return visionWriter[method](...args);
|
|
2685
|
+
};
|
|
2686
|
+
}
|
|
2687
|
+
return ownedWriter.updateItemStatus(itemId, 'killed');
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
/** Claims protect the cancelled driver's record AND its vision projection. */
|
|
2691
|
+
async function terminalizeCancelledBuild({ dataDir, buildIdentity, visionWriter, itemId, failureReason }) {
|
|
2692
|
+
if (!buildIdentity) return;
|
|
2693
|
+
const claim = claimActiveBuild(dataDir, buildIdentity);
|
|
2694
|
+
if (!claim.ok) return;
|
|
2695
|
+
writeActiveBuild(dataDir, {
|
|
2696
|
+
...claim.record, status: 'aborted', failureReason,
|
|
2697
|
+
completedAt: claim.record.completedAt ?? new Date().toISOString(),
|
|
2698
|
+
});
|
|
2699
|
+
try { await killOwnedBuildVision(visionWriter, itemId, () => claimActiveBuild(dataDir, buildIdentity)); } catch { /* best-effort projection */ }
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2161
2702
|
async function terminalizeThrownBuild({
|
|
2703
|
+
buildCancel, buildIdentity,
|
|
2162
2704
|
cwd,
|
|
2163
2705
|
dataDir,
|
|
2164
2706
|
cfg,
|
|
@@ -2175,7 +2717,9 @@ async function terminalizeThrownBuild({
|
|
|
2175
2717
|
}) {
|
|
2176
2718
|
const flowId = response?.runId ?? null;
|
|
2177
2719
|
if (!flowId) return false;
|
|
2178
|
-
|
|
2720
|
+
if (buildCancel?.cancelled) {
|
|
2721
|
+
await terminalizeCancelledBuild({ dataDir, buildIdentity, visionWriter, itemId, failureReason });
|
|
2722
|
+
} else await writeFailedBuildTerminalState({
|
|
2179
2723
|
cwd,
|
|
2180
2724
|
dataDir,
|
|
2181
2725
|
cfg,
|
|
@@ -2190,7 +2734,7 @@ async function terminalizeThrownBuild({
|
|
|
2190
2734
|
featureCode,
|
|
2191
2735
|
flowId,
|
|
2192
2736
|
mode,
|
|
2193
|
-
status: 'failed',
|
|
2737
|
+
status: buildCancel?.cancelled ? 'aborted' : 'failed',
|
|
2194
2738
|
startedAt: buildStartedAt,
|
|
2195
2739
|
completedAt: new Date().toISOString(),
|
|
2196
2740
|
durationMs: Date.now() - new Date(buildStartedAt).getTime(),
|
|
@@ -2249,8 +2793,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2249
2793
|
// Handle --abort early (featureCode may be null). C2: pass the project root so
|
|
2250
2794
|
// the engine is resolved from this project's capabilities, not process.cwd().
|
|
2251
2795
|
if (opts.abort) {
|
|
2252
|
-
await abortBuild(dataDir, featureCode, cwd);
|
|
2253
|
-
return;
|
|
2796
|
+
return await abortBuild(dataDir, featureCode, cwd);
|
|
2254
2797
|
}
|
|
2255
2798
|
|
|
2256
2799
|
const {
|
|
@@ -2265,6 +2808,15 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2265
2808
|
let accumulatorPersisted = !isNewAccumulator;
|
|
2266
2809
|
const buildStartedAt = new Date().toISOString();
|
|
2267
2810
|
let buildStatus = 'failed';
|
|
2811
|
+
// COMP-BUILD-CANCEL §3.4: one handle per runBuild. `signal` is the D-F chain into every
|
|
2812
|
+
// dispatch; `cancelled` is the merge guard's flag; `teardownStarted` is what a second
|
|
2813
|
+
// Ctrl-C keys on. Registered by flow id once the runId exists (S03-5b).
|
|
2814
|
+
const buildCancel = createBuildCancel();
|
|
2815
|
+
let buildIdentity = null;
|
|
2816
|
+
// The flow id this build registered its handle under, so the outermost finally can
|
|
2817
|
+
// unregister without reaching into the inner try's `response`.
|
|
2818
|
+
let registeredFlowId = null;
|
|
2819
|
+
const { cancelMs: cancelTimeoutMs, drainMs: drainTimeoutMs, joinMs: teardownJoinMs } = cancelBudgets();
|
|
2268
2820
|
let attemptStarted = true;
|
|
2269
2821
|
let attemptFinalized = false;
|
|
2270
2822
|
// COMP-TRIAGE-6-4: function-scoped so finalizeBuildAttempt's ownership guard
|
|
@@ -2278,6 +2830,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2278
2830
|
let streamWriter = null;
|
|
2279
2831
|
let signalHandler = null;
|
|
2280
2832
|
let runtimeResourcesFinalized = false;
|
|
2833
|
+
let suspended = false;
|
|
2281
2834
|
|
|
2282
2835
|
const _priorBuildIdEnv = process.env.COMPOSE_BUILD_ID;
|
|
2283
2836
|
if (_priorBuildIdEnv !== undefined) {
|
|
@@ -2293,7 +2846,8 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2293
2846
|
else process.env.COMPOSE_BUILD_ID = _priorBuildIdEnv;
|
|
2294
2847
|
};
|
|
2295
2848
|
const finalizeBuildAttempt = () => {
|
|
2296
|
-
if (!attemptStarted || attemptFinalized) return;
|
|
2849
|
+
if (!attemptStarted || attemptFinalized || (suspended && !buildCancel.cancelled)) return;
|
|
2850
|
+
if (buildCancel.teardown || buildCancel.cancelled) buildStatus = 'aborted';
|
|
2297
2851
|
const terminalStatus = buildStatus === 'complete'
|
|
2298
2852
|
? 'complete'
|
|
2299
2853
|
: (buildStatus === 'killed' || buildStatus === 'aborted')
|
|
@@ -2470,11 +3024,16 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2470
3024
|
if (!existsSync(specPath)) {
|
|
2471
3025
|
throw new Error(`Lifecycle spec not found: ${specPath}`);
|
|
2472
3026
|
}
|
|
3027
|
+
requirePipelineSidecar(specPath);
|
|
2473
3028
|
// D6: compose-owned per-step agent profiles (tool restrictions + model tiers)
|
|
2474
3029
|
// for this pipeline. The engine ships only bare claude|codex; these restore
|
|
2475
|
-
// the stripped profiles at invocation.
|
|
3030
|
+
// the stripped profiles at invocation. Optional absent sidecar → {} (bare literals).
|
|
2476
3031
|
const stepProfiles = loadPipelineProfiles(specPath);
|
|
2477
3032
|
let specYaml = readFileSync(specPath, 'utf-8');
|
|
3033
|
+
preflightPipelineProfiles(stepProfiles, specYaml, specPath);
|
|
3034
|
+
if (opts.costCeilingUsd !== undefined && (!Number.isFinite(opts.costCeilingUsd) || opts.costCeilingUsd <= 0 || !stepProfiles._costCeiling)) {
|
|
3035
|
+
throw new Error('costCeilingUsd requires a finite positive value and a configured _costCeiling');
|
|
3036
|
+
}
|
|
2478
3037
|
|
|
2479
3038
|
// COMP-PIPELINE-QUARANTINE: refuse a retired-dialect spec HERE, at the one
|
|
2480
3039
|
// seam every template passes through (build, fix, plan, --quick, --template,
|
|
@@ -2745,6 +3304,28 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2745
3304
|
}
|
|
2746
3305
|
}
|
|
2747
3306
|
const roles = { implementerAgent, reviewerAgent };
|
|
3307
|
+
// Resolve and validate runtime overrides BEFORE startFresh can create a flow.
|
|
3308
|
+
// Recompute on resume when persisted roles replace this invocation's flags.
|
|
3309
|
+
let pipelineProfiles;
|
|
3310
|
+
let effectiveProfiles;
|
|
3311
|
+
let profilePreflight;
|
|
3312
|
+
const refreshProfilePreflight = () => {
|
|
3313
|
+
const runtimeProfiles = {};
|
|
3314
|
+
const inputs = { implementer_agent: implementerAgent, reviewer_agent: reviewerAgent };
|
|
3315
|
+
resolvePlanSpecValues(structuredClone(localSpec), inputs, runtimeProfiles);
|
|
3316
|
+
pipelineProfiles = stepProfiles;
|
|
3317
|
+
for (const [id, override] of Object.entries(runtimeProfiles)) {
|
|
3318
|
+
try { pipelineProfiles = mergeRuntimeProfiles(pipelineProfiles, { [id]: override }); }
|
|
3319
|
+
catch (error) { throw new Error(`Profile preflight failed for ${specPath}: step "${id}": ${error.message}`, { cause: error }); }
|
|
3320
|
+
}
|
|
3321
|
+
// Pass the RAW local spec: the preflight resolves inputs itself and needs the
|
|
3322
|
+
// unresolved stage agents to detect a multi-stage runtime-profile collapse.
|
|
3323
|
+
profilePreflight = preflightPipelineProfiles(pipelineProfiles, localSpec, specPath, inputs);
|
|
3324
|
+
effectiveProfiles = Object.fromEntries(Object.entries(pipelineProfiles)
|
|
3325
|
+
.filter(([id, entry]) => !id.startsWith('_') && !entry?.decide_from)
|
|
3326
|
+
.map(([id, entry]) => [id, entry?.default ?? entry]));
|
|
3327
|
+
};
|
|
3328
|
+
refreshProfilePreflight();
|
|
2748
3329
|
// Restore persisted roles when (and only when) a resume actually happens.
|
|
2749
3330
|
const restoreRolesFromActive = (src) => {
|
|
2750
3331
|
if (mode !== 'feature' || !src || !src.implementerAgent) return;
|
|
@@ -2756,6 +3337,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2756
3337
|
}
|
|
2757
3338
|
implementerAgent = src.implementerAgent;
|
|
2758
3339
|
reviewerAgent = src.reviewerAgent ?? reviewerAgent;
|
|
3340
|
+
refreshProfilePreflight();
|
|
2759
3341
|
};
|
|
2760
3342
|
|
|
2761
3343
|
const activeForDecision = active && active.featureCode === featureCode ? active : null;
|
|
@@ -2766,24 +3348,55 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2766
3348
|
&& isProcessAlive(activeForDecision.pid)
|
|
2767
3349
|
);
|
|
2768
3350
|
const sameMode = !activeForDecision?.mode || activeForDecision.mode === mode;
|
|
2769
|
-
let flowTerminal = !activeForDecision?.flowId
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
3351
|
+
let flowTerminal = !activeForDecision?.flowId
|
|
3352
|
+
|| ['complete', 'aborted', 'killed'].includes(activeForDecision.status);
|
|
3353
|
+
const probeFlowId = opts.resumeFlowId ?? activeForDecision?.flowId;
|
|
3354
|
+
// Probe only when the engine can tell us something the local record cannot: an
|
|
3355
|
+
// explicit resume target, or a record that is not already terminal. A locally
|
|
3356
|
+
// terminal record needs no probe (pre-S05 semantics, kept).
|
|
3357
|
+
if (probeFlowId && !opts.fresh && (opts.resumeFlowId || !flowTerminal)) {
|
|
2773
3358
|
try {
|
|
2774
|
-
const audit = await stratum.audit(
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
artifactRoot: opts.consumerArtifactsRoot,
|
|
2781
|
-
|
|
3359
|
+
const audit = await stratum.audit(probeFlowId);
|
|
3360
|
+
if (isTerminalFlow(audit?.status)) {
|
|
3361
|
+
const terminalJournal = verifyConsumerRunRevision({ runId: probeFlowId, targetCwd: agentCwd,
|
|
3362
|
+
artifactRoot: opts.consumerArtifactsRoot, specDigest: localSpecDigest,
|
|
3363
|
+
profilesDigest: waveProfilesEnabled(pipelineProfiles) ? profilePreflight.profilesDigest : undefined });
|
|
3364
|
+
recoverAdvancedConsumerArtifacts({ runId: probeFlowId, targetCwd: agentCwd,
|
|
3365
|
+
artifactRoot: opts.consumerArtifactsRoot, audit });
|
|
3366
|
+
if (terminalJournal?.wave) {
|
|
3367
|
+
const artifacts = new ConsumerFanoutArtifacts({ runId: probeFlowId, targetCwd: agentCwd, artifactRoot: opts.consumerArtifactsRoot });
|
|
3368
|
+
try { await replicateCheckpoints({ artifacts, stratum, flowId: probeFlowId, buildCancel }); }
|
|
3369
|
+
catch (error) {
|
|
3370
|
+
if (audit.status !== 'cancelled') throw error;
|
|
3371
|
+
console.warn(`[wave-checkpoint] ${error.message}; local checkpoint preserved, replication incomplete`);
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
if (audit?.status === 'cancelled') {
|
|
3376
|
+
// C42/C49: a resume probe is not necessarily the owning driver.
|
|
3377
|
+
const handle = lookupBuildCancel(probeFlowId);
|
|
3378
|
+
if (handle) {
|
|
3379
|
+
handle.cancel('flow_cancelled');
|
|
3380
|
+
} else if (activeForDecision?.flowId === probeFlowId) {
|
|
3381
|
+
const claim = claimActiveBuild(dataDir, activeForDecision);
|
|
3382
|
+
if (claim.ok) writeActiveBuild(dataDir, {
|
|
3383
|
+
...claim.record, status: 'aborted', failureReason: 'flow_cancelled',
|
|
3384
|
+
completedAt: new Date().toISOString(),
|
|
3385
|
+
});
|
|
3386
|
+
}
|
|
3387
|
+
buildStatus = 'aborted';
|
|
3388
|
+
throw Object.assign(new Error(`Flow ${probeFlowId} was cancelled; it cannot be resumed. Run with --fresh to start a new build.`), {
|
|
3389
|
+
code: 'FLOW_CANCELLED', reason: 'flow_cancelled',
|
|
2782
3390
|
});
|
|
2783
3391
|
}
|
|
3392
|
+
flowTerminal = isTerminalFlow(audit?.status);
|
|
3393
|
+
|
|
2784
3394
|
} catch (err) {
|
|
2785
3395
|
if (isRecoverableFlowProbeError(err)) {
|
|
2786
|
-
|
|
3396
|
+
// An unknown flow is only evidence the build is over when its driver is
|
|
3397
|
+
// gone. With a live driver pid it is not evidence of anything, and the
|
|
3398
|
+
// same-feature conflict guard below must still hold (pre-S05 semantics).
|
|
3399
|
+
if (!pidAlive) flowTerminal = true;
|
|
2787
3400
|
} else {
|
|
2788
3401
|
throw err;
|
|
2789
3402
|
}
|
|
@@ -2798,8 +3411,18 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2798
3411
|
sameMode,
|
|
2799
3412
|
});
|
|
2800
3413
|
isFreshStart = verdict.action === 'fresh';
|
|
3414
|
+
if (isFreshStart && opts.fresh && activeForDecision?.flowId) {
|
|
3415
|
+
if (pidAlive) throw new Error('Cannot remove a wave ref owned by a live driver');
|
|
3416
|
+
const prior = new ConsumerFanoutArtifacts({ runId: activeForDecision.flowId,
|
|
3417
|
+
targetCwd: agentCwd, artifactRoot: opts.consumerArtifactsRoot }).journal;
|
|
3418
|
+
if (prior?.wave) {
|
|
3419
|
+
const expected = prior.wave.checkpoints.at(-1)?.commit;
|
|
3420
|
+
if (expected) removeCheckpointRef({ cwd: agentCwd, ref: prior.wave.ref, expected });
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
2801
3423
|
|
|
2802
3424
|
if (verdict.action === 'resume') {
|
|
3425
|
+
restoreRolesFromActive(activeForDecision);
|
|
2803
3426
|
const resumeFlowId = verdict.flowId;
|
|
2804
3427
|
console.log(`Resuming flow ${resumeFlowId} for ${featureCode}...`);
|
|
2805
3428
|
response = await stratum.resume(resumeFlowId);
|
|
@@ -2813,6 +3436,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2813
3436
|
artifactRoot: opts.consumerArtifactsRoot,
|
|
2814
3437
|
specDigest: localSpecDigest,
|
|
2815
3438
|
resumeRevisionDigest: response?.revisionDigest,
|
|
3439
|
+
profilesDigest: waveProfilesEnabled(pipelineProfiles) ? profilePreflight.profilesDigest : undefined,
|
|
2816
3440
|
});
|
|
2817
3441
|
try {
|
|
2818
3442
|
recoverAdvancedConsumerArtifacts({
|
|
@@ -2822,7 +3446,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2822
3446
|
audit: await stratum.audit(resumeFlowId),
|
|
2823
3447
|
});
|
|
2824
3448
|
} catch (error) {
|
|
2825
|
-
if (error instanceof ConsumerArtifactError) throw error;
|
|
3449
|
+
if (error instanceof ConsumerArtifactError || error instanceof WaveCheckpointError) throw error;
|
|
2826
3450
|
// Audit/cleanup projection is best-effort for ordinary non-consumer runs.
|
|
2827
3451
|
}
|
|
2828
3452
|
// A BARE programmatic resumeFlowId (no --resume flag) resumes the named
|
|
@@ -2902,6 +3526,58 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2902
3526
|
if (triageEstimate) emitTriageEstimate(cwd, triageEstimate);
|
|
2903
3527
|
}
|
|
2904
3528
|
|
|
3529
|
+
// COMP-BUILD-CANCEL S03-5b (C36): register the in-process handle the moment the run id
|
|
3530
|
+
// exists — BEFORE the Codex worktree preflight below, which can hold for up to
|
|
3531
|
+
// PROBE_AGENT_TIMEOUT_MS. `active-build.json` already advertises this flow id and pid,
|
|
3532
|
+
// so a same-process abort arriving in that window would otherwise find no handle, fall
|
|
3533
|
+
// through to the foreign-pid branch, and SIGTERM the compose server itself.
|
|
3534
|
+
registeredFlowId = response?.runId ?? null;
|
|
3535
|
+
registerBuildCancel(registeredFlowId, buildCancel);
|
|
3536
|
+
buildIdentity = { featureCode, flowId: registeredFlowId, pid: process.pid,
|
|
3537
|
+
startedAt: readActiveBuild(dataDir)?.startedAt };
|
|
3538
|
+
|
|
3539
|
+
// SIGINT/SIGTERM: cancel the flow, tear down, exit (COMP-BUILD-CANCEL §9). A SECOND
|
|
3540
|
+
// signal during teardown exits immediately — a user pressing Ctrl-C twice is asking to
|
|
3541
|
+
// stop waiting, and the durable state a bounded teardown would have written is worth
|
|
3542
|
+
// less than obeying that.
|
|
3543
|
+
signalHandler = (signal) => {
|
|
3544
|
+
// No local `tearingDown` flag: the handle owns that state (C27), so a teardown
|
|
3545
|
+
// started by any other path is visible here too.
|
|
3546
|
+
if (buildCancel.teardownStarted) { process.exit(signal === 'SIGINT' ? 130 : 143); return; }
|
|
3547
|
+
buildStatus = 'aborted';
|
|
3548
|
+
// ASSIGN, do not fire and forget (C45). The promise is what the outer catch reads to
|
|
3549
|
+
// stand down and what the outermost finally and the CLI join on. `runCancelTeardown`
|
|
3550
|
+
// sets `teardownStarted` synchronously, so a second signal takes the branch above.
|
|
3551
|
+
buildCancel.teardown = runCancelTeardown({
|
|
3552
|
+
buildCancel,
|
|
3553
|
+
signal,
|
|
3554
|
+
flowId: response?.runId,
|
|
3555
|
+
flowCancel: (id) => stratum.flowCancel(id),
|
|
3556
|
+
timeoutMs: cancelTimeoutMs,
|
|
3557
|
+
drainMs: drainTimeoutMs,
|
|
3558
|
+
claimOwnership: () => claimActiveBuild(dataDir, buildIdentity),
|
|
3559
|
+
killVision: () => killOwnedBuildVision(visionWriter, itemId, () => claimActiveBuild(dataDir, buildIdentity)),
|
|
3560
|
+
// The teardown re-claims after vision, immediately before this write (§3.7).
|
|
3561
|
+
writeTerminal: (record) => {
|
|
3562
|
+
writeActiveBuild(dataDir, { ...record, status: 'aborted', completedAt: new Date().toISOString() });
|
|
3563
|
+
},
|
|
3564
|
+
removeListeners: () => {
|
|
3565
|
+
process.removeListener('SIGINT', onSigint);
|
|
3566
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
3567
|
+
},
|
|
3568
|
+
exit: (code) => process.exit(code),
|
|
3569
|
+
// eslint-disable-next-line no-console
|
|
3570
|
+
log: (message) => console.warn(`[build] ${message}`),
|
|
3571
|
+
});
|
|
3572
|
+
};
|
|
3573
|
+
// Named wrappers, because the two existing removeListener sites must remove the same
|
|
3574
|
+
// function references that were registered.
|
|
3575
|
+
const onSigint = () => signalHandler('SIGINT');
|
|
3576
|
+
const onSigterm = () => signalHandler('SIGTERM');
|
|
3577
|
+
signalHandler.listeners = { onSigint, onSigterm };
|
|
3578
|
+
process.on('SIGINT', onSigint);
|
|
3579
|
+
process.on('SIGTERM', onSigterm);
|
|
3580
|
+
|
|
2905
3581
|
// COMP-CODEX-IMPL: verify Codex can write inside a detached git worktree (the
|
|
2906
3582
|
// execute step's isolation primitive) before any step dispatches. Runs on the
|
|
2907
3583
|
// EFFECTIVE role — after resume restoration above — so a resumed Codex build is
|
|
@@ -2922,8 +3598,12 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2922
3598
|
stratum,
|
|
2923
3599
|
dataDir,
|
|
2924
3600
|
ts: new Date().toISOString().replace(/[:.]/g, '-'),
|
|
3601
|
+
// C36/C51: an abort during the probe stops the probe too.
|
|
3602
|
+
signal: buildCancel.signal,
|
|
2925
3603
|
});
|
|
2926
3604
|
if (!probe.ok) {
|
|
3605
|
+
// The signal teardown owns terminal writes; let the catch/finally drain it.
|
|
3606
|
+
if (buildCancel.teardown) throw new Error(codexProbeAbortMessage(probe.reason));
|
|
2927
3607
|
// The plan/resume above already persisted feature.json=IN_PROGRESS and
|
|
2928
3608
|
// active-build.json='running'. A preflight abort throws before the normal
|
|
2929
3609
|
// terminal handlers run, so roll BOTH back here (mirrors the killed/failed
|
|
@@ -2972,27 +3652,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2972
3652
|
specPath: `pipelines/${templateName}.stratum.yaml`,
|
|
2973
3653
|
});
|
|
2974
3654
|
|
|
2975
|
-
|
|
2976
|
-
signalHandler = () => {
|
|
2977
|
-
buildStatus = 'killed';
|
|
2978
|
-
streamWriter.close('killed');
|
|
2979
|
-
};
|
|
2980
|
-
process.on('SIGINT', signalHandler);
|
|
2981
|
-
process.on('SIGTERM', signalHandler);
|
|
2982
|
-
|
|
2983
|
-
// V4: merge the runtime profile map over the static sidecar (runtime wins).
|
|
2984
|
-
// Tiers/templates carried by $.input.* agents (e.g. --implementer=claude::critical)
|
|
2985
|
-
// are stripped to the bare provider at resolution for the engine; recover the
|
|
2986
|
-
// full string here so the tier/capability profile still binds at invocation.
|
|
2987
|
-
const runtimeProfiles = {};
|
|
2988
|
-
try {
|
|
2989
|
-
resolvePlanSpecValues(
|
|
2990
|
-
YAML.parse(readFileSync(specPath, 'utf-8')),
|
|
2991
|
-
{ implementer_agent: implementerAgent, reviewer_agent: reviewerAgent },
|
|
2992
|
-
runtimeProfiles,
|
|
2993
|
-
);
|
|
2994
|
-
} catch { /* best-effort — a malformed spec fails later at plan() */ }
|
|
2995
|
-
const effectiveProfiles = { ...stepProfiles, ...runtimeProfiles };
|
|
3655
|
+
streamWriter.write({ type: 'profile_preflight', steps: profilePreflight.resolved });
|
|
2996
3656
|
// H1: reducer steps — ReviewResult-out steps that MERGE/deduplicate rather
|
|
2997
3657
|
// than review (e.g. review_merge). They still get review normalization +
|
|
2998
3658
|
// confidence handling, but must NOT get the reviewer scaffold. python's
|
|
@@ -3020,6 +3680,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3020
3680
|
const context = {
|
|
3021
3681
|
stratum,
|
|
3022
3682
|
flowId: response.runId,
|
|
3683
|
+
buildCancel,
|
|
3023
3684
|
receiptsMode,
|
|
3024
3685
|
cwd: agentCwd,
|
|
3025
3686
|
projectCwd: cwd,
|
|
@@ -3031,6 +3692,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3031
3692
|
// V4: merged runtime + static agent profiles let scoped consumer steps
|
|
3032
3693
|
// recover their tier/capability profile via resolveStepProfile normalization.
|
|
3033
3694
|
stepProfiles: effectiveProfiles,
|
|
3695
|
+
pipelineProfiles,
|
|
3034
3696
|
// COMP-BUILD-QUICK-1: the pipeline template (e.g. 'build-quick') so the ship
|
|
3035
3697
|
// step can stamp built_via onto feature.json for the validator's exemption.
|
|
3036
3698
|
templateName,
|
|
@@ -3149,12 +3811,23 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3149
3811
|
// unpinned journal a drifted spec could re-pin (R1).
|
|
3150
3812
|
revisionDigest: pins?.revisionDigest,
|
|
3151
3813
|
specDigest: pins?.specDigest,
|
|
3814
|
+
profilesDigest: waveProfilesEnabled(pipelineProfiles) ? profilePreflight.profilesDigest : undefined,
|
|
3152
3815
|
});
|
|
3153
3816
|
} else if (consumerArtifacts.runId !== runId) {
|
|
3154
3817
|
throw new Error(`consumer artifact manager is bound to ${consumerArtifacts.runId}, not ${runId}`);
|
|
3155
3818
|
}
|
|
3819
|
+
context.artifacts = consumerArtifacts;
|
|
3156
3820
|
return consumerArtifacts;
|
|
3157
3821
|
};
|
|
3822
|
+
if (waveProfilesEnabled(pipelineProfiles)) {
|
|
3823
|
+
artifactsForRun(context.flowId, { revisionDigest: response.revisionDigest, specDigest: localSpecDigest });
|
|
3824
|
+
context.streamWriter = streamWriter;
|
|
3825
|
+
// A ceiling gate turns delivery failures into a durable human hold below.
|
|
3826
|
+
try { await flushWaveReceipts(context); } catch (error) {
|
|
3827
|
+
if (!pipelineProfiles._costCeiling || buildCancel.cancelled) throw error;
|
|
3828
|
+
}
|
|
3829
|
+
await replicateCheckpoints(context);
|
|
3830
|
+
}
|
|
3158
3831
|
|
|
3159
3832
|
|
|
3160
3833
|
// COMP-PLAN-GATE-LOOP: per-step gate re-entry counter. A `revise` that
|
|
@@ -3195,7 +3868,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3195
3868
|
}
|
|
3196
3869
|
};
|
|
3197
3870
|
|
|
3198
|
-
const runConsumerDescriptor = async (descriptor, sourceResponse) => {
|
|
3871
|
+
const runConsumerDescriptor = async (descriptor, sourceResponse, admission) => {
|
|
3199
3872
|
const flowId = sourceResponse.runId ?? sourceResponse.flow_id;
|
|
3200
3873
|
const artifacts = artifactsForRun(flowId);
|
|
3201
3874
|
const audit = await stratum.audit(flowId);
|
|
@@ -3206,7 +3879,9 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3206
3879
|
});
|
|
3207
3880
|
return runConsumerIssuance({
|
|
3208
3881
|
descriptor,
|
|
3882
|
+
admission,
|
|
3209
3883
|
flowId,
|
|
3884
|
+
buildCancel,
|
|
3210
3885
|
stratum,
|
|
3211
3886
|
artifacts,
|
|
3212
3887
|
audit,
|
|
@@ -3218,18 +3893,18 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3218
3893
|
// claude:read-only-reviewer, or a runtime --implementer=claude::critical)
|
|
3219
3894
|
// so an isolation:none review item runs read-only instead of with
|
|
3220
3895
|
// Edit/Write/Bash in the target workspace.
|
|
3221
|
-
profile: resolveStepProfile(
|
|
3222
|
-
?? resolveStepProfile(
|
|
3896
|
+
profile: resolveStepProfile(context.stepProfiles, descriptor.step)
|
|
3897
|
+
?? resolveStepProfile(context.stepProfiles, descriptor.id),
|
|
3223
3898
|
});
|
|
3224
3899
|
};
|
|
3225
3900
|
|
|
3226
3901
|
const launchPendingConsumers = () => {
|
|
3227
|
-
while (!consumerFatalError
|
|
3902
|
+
while (!consumerFatalError && !buildCancel.cancelled
|
|
3228
3903
|
&& consumerInFlight.size < consumerConcurrency
|
|
3229
3904
|
&& consumerPending.length > 0) {
|
|
3230
3905
|
const work = consumerPending.shift();
|
|
3231
3906
|
const token = work.descriptor.dispatchToken;
|
|
3232
|
-
const task = runConsumerDescriptor(work.descriptor, work.sourceResponse)
|
|
3907
|
+
const task = runConsumerDescriptor(work.descriptor, work.sourceResponse, work.admission)
|
|
3233
3908
|
.then((nextResponse) => {
|
|
3234
3909
|
consumerCompleted.push(nextResponse);
|
|
3235
3910
|
}, (error) => {
|
|
@@ -3255,7 +3930,8 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3255
3930
|
for (const descriptor of descriptors) {
|
|
3256
3931
|
if (consumerSeenTokens.has(descriptor.dispatchToken)) continue;
|
|
3257
3932
|
const flowId = engineResponse.runId ?? engineResponse.flow_id;
|
|
3258
|
-
const runPins = { revisionDigest: descriptor.revisionDigest, specDigest: localSpecDigest
|
|
3933
|
+
const runPins = { revisionDigest: descriptor.revisionDigest, specDigest: localSpecDigest,
|
|
3934
|
+
profilesDigest: waveProfilesEnabled(pipelineProfiles) ? profilePreflight.profilesDigest : undefined };
|
|
3259
3935
|
// Preflight a whole ready batch before launching it. This preserves the
|
|
3260
3936
|
// shipped crash-before-first-bind boundary (zero issuances may execute)
|
|
3261
3937
|
// while allowing the work after revision fencing to overlap.
|
|
@@ -3264,8 +3940,11 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3264
3940
|
await artifacts.hooks.beforeRevisionBind({ descriptor, journal: artifacts.journal });
|
|
3265
3941
|
}
|
|
3266
3942
|
artifacts.bindRunRevision(runPins);
|
|
3943
|
+
const admission = await admitConsumerWave({ descriptor, descriptors,
|
|
3944
|
+
localSpec, profiles: pipelineProfiles,
|
|
3945
|
+
artifacts, stratum, flowId });
|
|
3267
3946
|
consumerSeenTokens.add(descriptor.dispatchToken);
|
|
3268
|
-
consumerPending.push({ descriptor, sourceResponse: engineResponse });
|
|
3947
|
+
consumerPending.push({ descriptor, sourceResponse: engineResponse, admission });
|
|
3269
3948
|
}
|
|
3270
3949
|
launchPendingConsumers();
|
|
3271
3950
|
}
|
|
@@ -3286,6 +3965,10 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3286
3965
|
let current = engineResponse;
|
|
3287
3966
|
let latestNonReady = current?.status !== 'ready' ? current : null;
|
|
3288
3967
|
while (true) {
|
|
3968
|
+
if (buildCancel.cancelled) {
|
|
3969
|
+
consumerFatalError ??= buildCancel.signal.reason;
|
|
3970
|
+
await drainConsumerFatal();
|
|
3971
|
+
}
|
|
3289
3972
|
let ordinary;
|
|
3290
3973
|
try {
|
|
3291
3974
|
ordinary = await enqueueConsumerReady(current);
|
|
@@ -3331,7 +4014,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3331
4014
|
throw error;
|
|
3332
4015
|
};
|
|
3333
4016
|
try {
|
|
3334
|
-
while (!isTerminalFlow(response.status)) {
|
|
4017
|
+
while (!isTerminalFlow(response.status) && !buildCancel.cancelled) {
|
|
3335
4018
|
if (response?.revisionDigest) runRevisionDigest = response.revisionDigest;
|
|
3336
4019
|
response = await mergeConsumerReady(response);
|
|
3337
4020
|
const readyStep = response.status === 'ready' ? response.ready?.[0] : null;
|
|
@@ -3479,8 +4162,8 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3479
4162
|
const shipStepResult = response.status === 'ready'
|
|
3480
4163
|
? { output: tsShipOutput }
|
|
3481
4164
|
: shipResult;
|
|
3482
|
-
response = await stratum
|
|
3483
|
-
|
|
4165
|
+
response = await callFlowWithCancellation(stratum, 'stepDone', flowId, buildCancel,
|
|
4166
|
+
stepId, shipStepResult, readyStep?.dispatchToken,
|
|
3484
4167
|
);
|
|
3485
4168
|
streamWriter.write({
|
|
3486
4169
|
type: 'build_step_done',
|
|
@@ -3516,9 +4199,12 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3516
4199
|
// here handed the fixer an unrestricted profile; the sibling
|
|
3517
4200
|
// review-repair site keys off `fix` the same way. Identity still
|
|
3518
4201
|
// comes from the dispatch (`agent: fixAgent`).
|
|
3519
|
-
profile: resolveStepProfile(
|
|
4202
|
+
profile: resolveStepProfile(effectiveProfiles, 'fix')
|
|
3520
4203
|
?? resolveStepProfile(context.stepProfiles, stepId),
|
|
3521
4204
|
sandboxMode: 'workspace-write',
|
|
4205
|
+
...(flowTag(flowId, stepId) ? { flow: flowTag(flowId, stepId) } : {}),
|
|
4206
|
+
flowId, buildCancel,
|
|
4207
|
+
buildSignal: buildCancel.signal,
|
|
3522
4208
|
telemetry: {
|
|
3523
4209
|
site: 'review-repair',
|
|
3524
4210
|
project_cwd: cwd,
|
|
@@ -3538,6 +4224,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3538
4224
|
} catch { /* fail-open */ }
|
|
3539
4225
|
}
|
|
3540
4226
|
} catch (err) {
|
|
4227
|
+
if (buildCancel.cancelled) throw err;
|
|
3541
4228
|
if (err?.usage && typeof context.recordBuildUsage === 'function') {
|
|
3542
4229
|
try {
|
|
3543
4230
|
await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
|
|
@@ -3603,6 +4290,9 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3603
4290
|
reviewMode: isReviewMain,
|
|
3604
4291
|
confidenceGate: confGateMain,
|
|
3605
4292
|
lens: response.inputs?.lens_name ?? response.lens_name ?? 'general',
|
|
4293
|
+
...(flowTag(flowId, stepId) ? { flow: flowTag(flowId, stepId) } : {}),
|
|
4294
|
+
flowId, buildCancel,
|
|
4295
|
+
buildSignal: buildCancel.signal,
|
|
3606
4296
|
telemetry: {
|
|
3607
4297
|
site: isReviewMain ? 'review' : 'build-step',
|
|
3608
4298
|
project_cwd: cwd,
|
|
@@ -3614,9 +4304,10 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3614
4304
|
// D6/V4: apply this ordinary step's compose-side profile (e.g.
|
|
3615
4305
|
// blueprint → claude::critical, review_merge → claude:orchestrator),
|
|
3616
4306
|
// normalizing scoped subflow ready ids to the bare step id.
|
|
3617
|
-
profile: resolveStepProfile(
|
|
4307
|
+
profile: resolveStepProfile(context.stepProfiles, stepId),
|
|
3618
4308
|
});
|
|
3619
4309
|
} catch (err) {
|
|
4310
|
+
if (buildCancel.cancelled) throw err;
|
|
3620
4311
|
const failedUsage = failureUsageFields(err);
|
|
3621
4312
|
if (err instanceof UserInterruptError) {
|
|
3622
4313
|
if (err.action === 'skip') {
|
|
@@ -3691,7 +4382,10 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3691
4382
|
progress, streamWriter, maxDurationMs, stratum, cwd: agentCwd,
|
|
3692
4383
|
reviewMode: isReviewMain,
|
|
3693
4384
|
confidenceGate: confGateMain,
|
|
3694
|
-
profile: resolveStepProfile(
|
|
4385
|
+
profile: resolveStepProfile(context.stepProfiles, stepId),
|
|
4386
|
+
...(flowTag(flowId, stepId) ? { flow: flowTag(flowId, stepId) } : {}),
|
|
4387
|
+
flowId, buildCancel,
|
|
4388
|
+
buildSignal: buildCancel.signal,
|
|
3695
4389
|
telemetry: {
|
|
3696
4390
|
site: 'policy-revision',
|
|
3697
4391
|
project_cwd: cwd,
|
|
@@ -3739,6 +4433,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3739
4433
|
}
|
|
3740
4434
|
// The second result stands either way — no further passes.
|
|
3741
4435
|
} catch (err) {
|
|
4436
|
+
if (buildCancel.cancelled) throw err;
|
|
3742
4437
|
if (err?.usage && typeof context.recordBuildUsage === 'function') {
|
|
3743
4438
|
try {
|
|
3744
4439
|
await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
|
|
@@ -3931,6 +4626,9 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3931
4626
|
// Report each model dispatch before the outcome it funded. The merged
|
|
3932
4627
|
// step usage remains the accumulator/build-stream shape used by existing
|
|
3933
4628
|
// callers; receipts use mainResult.usages to preserve per-dispatch data.
|
|
4629
|
+
if (context.pipelineProfiles?._costCeiling && !toEngineUsage(stepUsage)) {
|
|
4630
|
+
await context.recordBuildUsage({ dispatch_id: mainResult.dispatchIds?.primary ?? readyStep?.dispatchToken }, { stepId, source: 'main' });
|
|
4631
|
+
}
|
|
3934
4632
|
if (toEngineUsage(stepUsage)) {
|
|
3935
4633
|
buildCostTotals.input_tokens += stepUsage.input_tokens ?? 0;
|
|
3936
4634
|
buildCostTotals.output_tokens += stepUsage.output_tokens ?? 0;
|
|
@@ -3942,8 +4640,8 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3942
4640
|
});
|
|
3943
4641
|
streamWriter.writeUsage(stepId, stepUsage);
|
|
3944
4642
|
}
|
|
3945
|
-
response = await stratum
|
|
3946
|
-
|
|
4643
|
+
response = await callFlowWithCancellation(stratum, 'stepDone', flowId, buildCancel,
|
|
4644
|
+
stepId, stepDoneResult, readyStep?.dispatchToken,
|
|
3947
4645
|
);
|
|
3948
4646
|
{
|
|
3949
4647
|
const isEnsureRetry = responseReissuesStep(response, stepId);
|
|
@@ -4183,10 +4881,28 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4183
4881
|
{ gateStepId, localFanoutStepId: consumerFanoutStep.id, journaledFanoutStepId: journaledFanoutId },
|
|
4184
4882
|
);
|
|
4185
4883
|
}
|
|
4884
|
+
if (pipelineProfiles._consumer?.[consumerFanoutStep.id]?.checkpoint_gate === gateStepId) {
|
|
4885
|
+
consumerMergeArtifacts.initializeWave({ ref: `refs/heads/compose/wave/${flowId}`, profilesDigest: profilePreflight.profilesDigest });
|
|
4886
|
+
}
|
|
4186
4887
|
consumerMergeArtifacts.recordGateBinding({ gateStepId, fanoutStepId: consumerFanoutStep.id });
|
|
4187
4888
|
}
|
|
4188
4889
|
|
|
4189
4890
|
const resolveGateWithConsumerMerge = async (requestedOutcome, requestedRationale, resolvedBy) => {
|
|
4891
|
+
let mergeApplied = false;
|
|
4892
|
+
const reverseCancelledMerge = () => {
|
|
4893
|
+
buildCancel.cancel('flow_cancelled');
|
|
4894
|
+
try {
|
|
4895
|
+
consumerMergeArtifacts.restoreMergeBaseline(consumerMergeTransaction, gateAudit, { reason: 'cancelled' });
|
|
4896
|
+
} catch (revertError) {
|
|
4897
|
+
// #mutate cannot persist anything when the restore callback throws.
|
|
4898
|
+
// This separate mutation is the durable indeterminate-tree finding.
|
|
4899
|
+
let journalFailure = '';
|
|
4900
|
+
try { consumerMergeArtifacts.markRollbackFailed(consumerMergeTransaction, revertError); }
|
|
4901
|
+
catch (error) { journalFailure = `; rollback failure journal also failed: ${error.message}`; }
|
|
4902
|
+
throw new MergeAfterCancelError(`merge reversal FAILED after cancel; working tree is indeterminate: ${revertError.message}${journalFailure}`);
|
|
4903
|
+
}
|
|
4904
|
+
throw new MergeAfterCancelError('merge reversed: the run was cancelled while the merge was applying');
|
|
4905
|
+
};
|
|
4190
4906
|
let outcome = requestedOutcome;
|
|
4191
4907
|
let rationale = requestedRationale;
|
|
4192
4908
|
const repairOutcome = gateStep?.gate?.on_revise ? 'revise' : 'kill';
|
|
@@ -4220,9 +4936,16 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4220
4936
|
if (consumerMergePreparationError) {
|
|
4221
4937
|
repairFor(consumerMergePreparationError);
|
|
4222
4938
|
} else {
|
|
4939
|
+
// Both fences are outside the repairFor catch: cancellation cannot revise.
|
|
4940
|
+
if (buildCancel.cancelled) {
|
|
4941
|
+
streamWriter.write({ type: 'build_note', note: `merge skipped: build cancelled (${buildCancel.reason})`, flowId });
|
|
4942
|
+
throw new MergeAfterCancelError(`merge refused: build was cancelled at ${buildCancel.at}`);
|
|
4943
|
+
}
|
|
4223
4944
|
try {
|
|
4224
4945
|
await consumerMergeArtifacts.applyMerge(consumerMergeTransaction);
|
|
4946
|
+
mergeApplied = true;
|
|
4225
4947
|
try {
|
|
4948
|
+
if (!consumerMergeArtifacts.journal.wave) {
|
|
4226
4949
|
const changed = execSync(
|
|
4227
4950
|
'git diff --name-only HEAD; git ls-files --others --exclude-standard',
|
|
4228
4951
|
{ cwd: agentCwd, encoding: 'utf8', timeout: 5000, stdio: 'pipe' },
|
|
@@ -4233,11 +4956,16 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4233
4956
|
context.filesChanged = [...files];
|
|
4234
4957
|
context.recordFilesChanged(context.filesChanged);
|
|
4235
4958
|
}
|
|
4959
|
+
}
|
|
4236
4960
|
} catch { /* best-effort build context projection */ }
|
|
4237
4961
|
} catch (error) {
|
|
4238
4962
|
if (!(error instanceof ConsumerMergeDecisionError)) throw error;
|
|
4239
4963
|
repairFor(error);
|
|
4240
4964
|
}
|
|
4965
|
+
const runCancelled = mergeApplied && !buildCancel.cancelled && await isRunCancelled(stratum, flowId);
|
|
4966
|
+
if (mergeApplied && (buildCancel.cancelled || runCancelled)) {
|
|
4967
|
+
reverseCancelledMerge();
|
|
4968
|
+
}
|
|
4241
4969
|
}
|
|
4242
4970
|
}
|
|
4243
4971
|
// Seam between a completed applyMerge (issuances now journaled `merged`)
|
|
@@ -4257,9 +4985,44 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4257
4985
|
consumerMergeArtifacts.restoreMergeBaseline(consumerMergeTransaction, gateAudit);
|
|
4258
4986
|
}
|
|
4259
4987
|
|
|
4260
|
-
let next
|
|
4261
|
-
|
|
4262
|
-
|
|
4988
|
+
let next;
|
|
4989
|
+
try {
|
|
4990
|
+
next = await callFlowWithCancellation(stratum, 'gateResolve', flowId, buildCancel,
|
|
4991
|
+
stepId, outcome, rationale, resolvedBy, gateToken,
|
|
4992
|
+
);
|
|
4993
|
+
if (consumerMergeArtifacts?.journal.wave && outcome === 'approve') {
|
|
4994
|
+
consumerMergeArtifacts.markGateResolved(consumerMergeTransaction, outcome);
|
|
4995
|
+
// Acknowledged approval creates an obligation even if cancellation follows.
|
|
4996
|
+
consumerMergeArtifacts.recoverCheckpoint(consumerMergeTransaction);
|
|
4997
|
+
mergeApplied = false; // confirmed checkpoint must never be rolled back on cancel
|
|
4998
|
+
await replicateCheckpoints(context);
|
|
4999
|
+
}
|
|
5000
|
+
if (next.status === 'cancelled') buildCancel.cancel('flow_cancelled');
|
|
5001
|
+
if (buildCancel.cancelled) throw buildCancel.signal.reason;
|
|
5002
|
+
} catch (error) {
|
|
5003
|
+
if (waveProfilesEnabled(pipelineProfiles)) {
|
|
5004
|
+
const current = await stratum.audit(flowId);
|
|
5005
|
+
const ordinal = (gateAudit.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === stepId).length;
|
|
5006
|
+
const decision = (current.events ?? []).filter(e => e.type === 'gate_resolved' && e.stepId === stepId)[ordinal]?.detail?.decision;
|
|
5007
|
+
if (decision) {
|
|
5008
|
+
outcome = decision;
|
|
5009
|
+
if (consumerMergeArtifacts && decision === 'approve') {
|
|
5010
|
+
consumerMergeArtifacts.markGateResolved(consumerMergeTransaction, decision);
|
|
5011
|
+
consumerMergeArtifacts.recoverCheckpoint(consumerMergeTransaction);
|
|
5012
|
+
mergeApplied = false;
|
|
5013
|
+
await replicateCheckpoints(context);
|
|
5014
|
+
}
|
|
5015
|
+
if (current.status === 'cancelled') throw error;
|
|
5016
|
+
next = await stratum.resume(flowId);
|
|
5017
|
+
} else {
|
|
5018
|
+
if (mergeApplied && buildCancel.cancelled) reverseCancelledMerge();
|
|
5019
|
+
throw error;
|
|
5020
|
+
}
|
|
5021
|
+
} else {
|
|
5022
|
+
if (mergeApplied && buildCancel.cancelled) reverseCancelledMerge();
|
|
5023
|
+
throw error;
|
|
5024
|
+
}
|
|
5025
|
+
}
|
|
4263
5026
|
if (consumerMergeArtifacts) {
|
|
4264
5027
|
if (typeof consumerMergeArtifacts.hooks.afterGateResolve === 'function') {
|
|
4265
5028
|
await consumerMergeArtifacts.hooks.afterGateResolve({
|
|
@@ -4270,10 +5033,13 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4270
5033
|
response: next,
|
|
4271
5034
|
});
|
|
4272
5035
|
}
|
|
5036
|
+
if (mergeApplied && buildCancel.cancelled) reverseCancelledMerge();
|
|
4273
5037
|
consumerMergeArtifacts.markGateResolved(consumerMergeTransaction, outcome);
|
|
4274
5038
|
if (outcome === 'approve' || outcome === 'kill' || isTerminalFlow(next.status)) {
|
|
4275
5039
|
consumerMergeArtifacts.cleanupWorktrees(
|
|
4276
5040
|
outcome === 'approve' ? 'merge gate approved and advanced' : 'run terminalized',
|
|
5041
|
+
consumerMergeArtifacts.journal.wave && consumerMergeTransaction
|
|
5042
|
+
? { dispatchTokens: consumerMergeTransaction.acceptedDispatchTokens } : {},
|
|
4277
5043
|
);
|
|
4278
5044
|
}
|
|
4279
5045
|
}
|
|
@@ -4374,6 +5140,12 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4374
5140
|
const gateFixResult = await runAndNormalize(undefined, fixPrompt, { step_id: 'review_fix', agent: fixAgent, flow_id: flowId }, {
|
|
4375
5141
|
progress, streamWriter, maxDurationMs: STEP_TIMEOUT_MS.review_merge ?? DEFAULT_TIMEOUT_MS,
|
|
4376
5142
|
stratum, cwd: agentCwd, sandboxMode: 'workspace-write', profile: resolveStepProfile(effectiveProfiles, 'fix'),
|
|
5143
|
+
// S03-4: tag with `gateStepId`, not the literal `review_fix` this dispatch
|
|
5144
|
+
// carries as its step_id — the run's real step is the gate, and flow.stepId
|
|
5145
|
+
// is audit decoration (the server validates it against nothing).
|
|
5146
|
+
...(flowTag(flowId, gateStepId) ? { flow: flowTag(flowId, gateStepId) } : {}),
|
|
5147
|
+
flowId, buildCancel,
|
|
5148
|
+
buildSignal: buildCancel.signal,
|
|
4377
5149
|
telemetry: {
|
|
4378
5150
|
site: 'review-repair',
|
|
4379
5151
|
project_cwd: cwd,
|
|
@@ -4393,6 +5165,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4393
5165
|
} catch { /* fail-open */ }
|
|
4394
5166
|
}
|
|
4395
5167
|
} catch (err) {
|
|
5168
|
+
if (buildCancel.cancelled) throw err;
|
|
4396
5169
|
if (err?.usage && typeof context.recordBuildUsage === 'function') {
|
|
4397
5170
|
try {
|
|
4398
5171
|
await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
|
|
@@ -4415,8 +5188,22 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4415
5188
|
continue;
|
|
4416
5189
|
}
|
|
4417
5190
|
|
|
5191
|
+
const outputDecision = await evaluateConfiguredGate(context, {
|
|
5192
|
+
localSpec, gateStepId: stepId, gateToken, costCeilingUsd: opts.costCeilingUsd,
|
|
5193
|
+
});
|
|
5194
|
+
if (outputDecision && !outputDecision.outcome && (opts.gateOpts?.nonInteractive ?? !process.stdin.isTTY)) {
|
|
5195
|
+
suspended = true;
|
|
5196
|
+
const reason = outputDecision.reason;
|
|
5197
|
+
writeActiveBuild(dataDir, { ...readActiveBuild(dataDir), status: 'waiting_gate', gateToken, reason });
|
|
5198
|
+
streamWriter.pause({ flowId, gateToken, reason });
|
|
5199
|
+
console.log(`Build paused at ${stepId}: ${reason}. Resume with a human gate decision.`);
|
|
5200
|
+
return { status: 'waiting_gate', flowId, gateToken, reason };
|
|
5201
|
+
}
|
|
5202
|
+
|
|
4418
5203
|
// ── Policy evaluation (ITEM-23) ────────────────────────────────────
|
|
4419
|
-
const policy =
|
|
5204
|
+
const policy = outputDecision
|
|
5205
|
+
? { mode: outputDecision.outcome ? 'skip' : 'gate', reason: outputDecision.rationale ?? outputDecision.reason }
|
|
5206
|
+
: evaluatePolicy(policySettings, stepId, {
|
|
4420
5207
|
fromPhase: synthFromPhase,
|
|
4421
5208
|
// Gate policy is keyed by the gate step id (evaluatePolicy falls back
|
|
4422
5209
|
// to stepId when toPhase is absent); the synthesized approval target
|
|
@@ -4425,17 +5212,19 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4425
5212
|
|
|
4426
5213
|
if (policy.mode === 'skip') {
|
|
4427
5214
|
// Silent pass-through — no gate record, no UI
|
|
4428
|
-
const resolved = await resolveGateWithConsumerMerge('approve', policy.reason, 'system');
|
|
5215
|
+
const resolved = await resolveGateWithConsumerMerge(outputDecision?.outcome ?? 'approve', policy.reason, 'system');
|
|
4429
5216
|
response = resolved.response;
|
|
4430
5217
|
streamWriter.write({
|
|
4431
5218
|
type: 'build_gate_resolved',
|
|
4432
|
-
stepId, outcome: resolved.outcome, rationale: resolved.rationale, flowId, policyMode: 'skip',
|
|
5219
|
+
stepId, outcome: resolved.outcome, rationale: resolved.rationale, flowId, policyMode: outputDecision ? 'output' : 'skip',
|
|
4433
5220
|
});
|
|
4434
5221
|
// COMP-PLAN-SECTIONS T6: emit sections after plan_gate auto-approve
|
|
4435
5222
|
if (resolved.outcome === 'approve') {
|
|
4436
5223
|
maybeEmitSectionsAfterPlanGate(stepId, featureDir, { streamWriter, featureCode });
|
|
4437
5224
|
}
|
|
4438
|
-
|
|
5225
|
+
if (outputDecision?.outcome) await reportWaveEvidence(context, 'gate_decision', gateToken,
|
|
5226
|
+
{ ...outputDecision, outcome: resolved.outcome, rationale: resolved.rationale }, 'accepted');
|
|
5227
|
+
stepHistory.push({ stepId, artifact: null, summary: `Gate ${outputDecision ? 'output' : 'skip'}: ${resolved.rationale}`, outcome: resolved.outcome });
|
|
4439
5228
|
syncStepHistory(dataDir, stepHistory);
|
|
4440
5229
|
|
|
4441
5230
|
} else if (policy.mode === 'flag') {
|
|
@@ -4482,13 +5271,20 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4482
5271
|
if (serverUp) {
|
|
4483
5272
|
gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate', round });
|
|
4484
5273
|
console.log('Gate delegated to web UI. Waiting for resolution...');
|
|
4485
|
-
|
|
5274
|
+
let resolved;
|
|
5275
|
+
try {
|
|
5276
|
+
resolved = await pollGateResolution(visionWriter, gateId, 2000, buildCancel.signal);
|
|
5277
|
+
} catch (error) {
|
|
5278
|
+
await confirmCancellation(error, { stratum, flowId, buildCancel });
|
|
5279
|
+
throw error;
|
|
5280
|
+
}
|
|
4486
5281
|
if (resolved) {
|
|
4487
5282
|
outcome = resolved.outcome;
|
|
4488
5283
|
rationale = resolved.comment ?? '';
|
|
4489
5284
|
} else {
|
|
4490
5285
|
const result = await promptGate(gateDispatch, {
|
|
4491
5286
|
...(opts.gateOpts ?? {}),
|
|
5287
|
+
signal: buildCancel.signal,
|
|
4492
5288
|
artifact: context.cwd,
|
|
4493
5289
|
askAgent,
|
|
4494
5290
|
gateExtras,
|
|
@@ -4500,6 +5296,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4500
5296
|
gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate', round });
|
|
4501
5297
|
const result = await promptGate(gateDispatch, {
|
|
4502
5298
|
...(opts.gateOpts ?? {}),
|
|
5299
|
+
signal: buildCancel.signal,
|
|
4503
5300
|
artifact: context.cwd,
|
|
4504
5301
|
askAgent,
|
|
4505
5302
|
gateExtras,
|
|
@@ -4508,7 +5305,10 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4508
5305
|
rationale = result.rationale;
|
|
4509
5306
|
}
|
|
4510
5307
|
|
|
5308
|
+
if (outputDecision) await reportWaveEvidence(context, 'gate_human', gateToken, { outcome, rationale, hold: outputDecision }, 'proposed');
|
|
4511
5309
|
const resolved = await resolveGateWithConsumerMerge(outcome, rationale, 'human');
|
|
5310
|
+
if (outputDecision) await reportWaveEvidence(context, 'gate_human', gateToken,
|
|
5311
|
+
{ outcome: resolved.outcome, rationale: resolved.rationale, hold: outputDecision }, 'accepted');
|
|
4512
5312
|
response = resolved.response;
|
|
4513
5313
|
outcome = resolved.outcome;
|
|
4514
5314
|
rationale = resolved.rationale;
|
|
@@ -4566,16 +5366,26 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4566
5366
|
consumerArtifacts.cleanupWorktrees('run terminalized');
|
|
4567
5367
|
}
|
|
4568
5368
|
|
|
5369
|
+
if (response.status === 'cancelled') buildCancel.cancel('flow_cancelled');
|
|
5370
|
+
|
|
4569
5371
|
// Flow complete — write terminal state (file retained per STRAT-COMP-4 contract).
|
|
4570
5372
|
// COMP-COMPLETION-GATE slice 2: set when the flow completed and the feature is
|
|
4571
5373
|
// eligible to be completed — the actual completion runs after the health gate.
|
|
4572
5374
|
let pendingCompletion = null;
|
|
4573
|
-
if (response.status === 'completed') buildStatus = 'complete';
|
|
4574
|
-
if ((response.status === 'failed' || response.status === 'budget_exhausted') && !killedByGate) {
|
|
5375
|
+
if (!buildCancel.cancelled && response.status === 'completed') buildStatus = 'complete';
|
|
5376
|
+
if (!buildCancel.cancelled && (response.status === 'failed' || response.status === 'budget_exhausted') && !killedByGate) {
|
|
4575
5377
|
buildStatus = 'failed';
|
|
4576
5378
|
terminalFailureReason = response.failure?.reason ?? null;
|
|
4577
5379
|
}
|
|
4578
|
-
if (
|
|
5380
|
+
if (buildCancel.teardown) {
|
|
5381
|
+
// A successful final stepDone can race SIGINT. The teardown still owns
|
|
5382
|
+
// terminal state; retain aborted for history, stream closure and actuals.
|
|
5383
|
+
buildStatus = 'aborted';
|
|
5384
|
+
} else if (buildCancel.cancelled) {
|
|
5385
|
+
buildStatus = 'aborted';
|
|
5386
|
+
terminalFailureReason = buildCancel.reason;
|
|
5387
|
+
await terminalizeCancelledBuild({ dataDir, buildIdentity, visionWriter, itemId, failureReason: buildCancel.reason });
|
|
5388
|
+
} else if (response.status === 'completed' && buildStatus === 'complete') {
|
|
4579
5389
|
console.log('\nBuild complete.');
|
|
4580
5390
|
// COMP-COMPLETION-GATE slice 2: the COMPLETION does not happen here.
|
|
4581
5391
|
//
|
|
@@ -4640,9 +5450,13 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4640
5450
|
// for the history record — health-gate failures have no failed step, so
|
|
4641
5451
|
// the lastFailedStep-derived failureReason would otherwise be generic.
|
|
4642
5452
|
let healthDowngradeReason = null;
|
|
4643
|
-
if (streamWriter) {
|
|
5453
|
+
if (streamWriter && !buildCancel.teardown && !buildCancel.cancelled) {
|
|
5454
|
+
const checkHealthOwnership = () => {
|
|
5455
|
+
if (buildCancel.teardown || buildCancel.cancelled) throw buildCancel.signal.reason;
|
|
5456
|
+
};
|
|
4644
5457
|
try {
|
|
4645
5458
|
// Runtime errors signal — accumulated across all steps
|
|
5459
|
+
checkHealthOwnership();
|
|
4646
5460
|
if (allViolations.length > 0) {
|
|
4647
5461
|
buildSignals.runtime_errors = allViolations;
|
|
4648
5462
|
} else if (!buildSignals.runtime_errors) {
|
|
@@ -4657,6 +5471,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4657
5471
|
// chain + mtimes, so it needs no marker to be written first.
|
|
4658
5472
|
try {
|
|
4659
5473
|
const { findStaleArtifacts } = await import('./lineage.js');
|
|
5474
|
+
checkHealthOwnership();
|
|
4660
5475
|
buildSignals.doc_freshness = findStaleArtifacts(resolveItemDir(featureCode));
|
|
4661
5476
|
} catch { /* staleness check is optional — skip on error */ }
|
|
4662
5477
|
|
|
@@ -4669,6 +5484,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4669
5484
|
// lifecycle-writer surface that materialises wasGeneratedBy/wasDerivedFrom.
|
|
4670
5485
|
try {
|
|
4671
5486
|
const { stampFeatureLineage } = await import('./lineage.js');
|
|
5487
|
+
checkHealthOwnership();
|
|
4672
5488
|
stampFeatureLineage(resolveItemDir(featureCode));
|
|
4673
5489
|
} catch { /* lineage stamping is optional — skip on error */ }
|
|
4674
5490
|
|
|
@@ -4688,10 +5504,12 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4688
5504
|
);
|
|
4689
5505
|
|
|
4690
5506
|
// Emit to build stream
|
|
5507
|
+
checkHealthOwnership();
|
|
4691
5508
|
streamWriter.writeHealthScore(score, breakdown, missing);
|
|
4692
5509
|
|
|
4693
5510
|
// Persist to history
|
|
4694
5511
|
try {
|
|
5512
|
+
checkHealthOwnership();
|
|
4695
5513
|
recordScore(cwd, { featureCode, phase: buildStatus, score, breakdown });
|
|
4696
5514
|
} catch (err) {
|
|
4697
5515
|
console.warn(`[health] Failed to persist score: ${err.message}`);
|
|
@@ -4701,6 +5519,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4701
5519
|
// If health score is below the configured threshold, mark the build as failed
|
|
4702
5520
|
// so downstream consumers (vision item status, exit code) reflect the rejection.
|
|
4703
5521
|
const threshold = healthSettings.gate_threshold;
|
|
5522
|
+
checkHealthOwnership();
|
|
4704
5523
|
if (typeof threshold === 'number' && score < threshold) {
|
|
4705
5524
|
streamWriter.write({
|
|
4706
5525
|
type: 'gate_health_rejection',
|
|
@@ -4711,12 +5530,14 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4711
5530
|
});
|
|
4712
5531
|
console.warn(` [health] Build health score ${score} is below gate threshold ${threshold} — marking build as failed`);
|
|
4713
5532
|
// Enforce: downgrade build status so the build is reported as failed
|
|
5533
|
+
checkHealthOwnership();
|
|
4714
5534
|
buildStatus = 'failed';
|
|
4715
5535
|
// COMP-MOBILE-1-1: re-persist the downgrade to active-build.json so the
|
|
4716
5536
|
// file watcher re-broadcasts buildState over /ws/vision. Without this,
|
|
4717
5537
|
// the terminal write above already said 'complete' and clients never
|
|
4718
5538
|
// learn the build actually failed (mobile compensated via history).
|
|
4719
5539
|
// Identity-guarded: no-ops if a concurrent build replaced the file.
|
|
5540
|
+
checkHealthOwnership();
|
|
4720
5541
|
const downgraded = persistHealthGateDowngrade(dataDir, {
|
|
4721
5542
|
score,
|
|
4722
5543
|
threshold,
|
|
@@ -4730,7 +5551,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4730
5551
|
console.log(` Health score: ${score}/100 (${Object.keys(breakdown).length} dimensions scored)`);
|
|
4731
5552
|
} catch (err) {
|
|
4732
5553
|
// Non-fatal — health scoring never blocks the build
|
|
4733
|
-
console.warn(`[health] Score computation failed: ${err.message}`);
|
|
5554
|
+
if (!buildCancel.teardown && !buildCancel.cancelled) console.warn(`[health] Score computation failed: ${err.message}`);
|
|
4734
5555
|
}
|
|
4735
5556
|
}
|
|
4736
5557
|
|
|
@@ -4741,7 +5562,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4741
5562
|
// a health-rejected build completes nothing: no completion record, no
|
|
4742
5563
|
// COMPLETE status, no vision completion, no guard transition.
|
|
4743
5564
|
// ---------------------------------------------------------------------
|
|
4744
|
-
if (pendingCompletion && buildStatus === 'complete') {
|
|
5565
|
+
if (pendingCompletion && !buildCancel.teardown && !buildCancel.cancelled && buildStatus === 'complete') {
|
|
4745
5566
|
const ev = context.completionEvidence || {};
|
|
4746
5567
|
const acc = readBuildAccumulator(cwd, featureCode);
|
|
4747
5568
|
// Persisted, because it survives a resume; the in-memory value wins when
|
|
@@ -4829,6 +5650,10 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4829
5650
|
// the COMP-HEALTH gate above may have downgraded buildStatus to 'failed'.
|
|
4830
5651
|
// Assembled from the in-memory build context for THIS run (never re-read
|
|
4831
5652
|
// active-build.json, which is last-writer-wins across concurrent builds).
|
|
5653
|
+
if (buildCancel.teardown || buildCancel.cancelled) {
|
|
5654
|
+
buildStatus = 'aborted';
|
|
5655
|
+
terminalFailureReason = buildCancel.reason;
|
|
5656
|
+
}
|
|
4832
5657
|
if (['complete', 'aborted', 'failed', 'killed'].includes(buildStatus)) {
|
|
4833
5658
|
const failureReason = buildFailureReason({
|
|
4834
5659
|
buildStatus,
|
|
@@ -4929,10 +5754,18 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4929
5754
|
// File retained on disk per STRAT-COMP-4 — overwritten on next build start
|
|
4930
5755
|
|
|
4931
5756
|
} catch (err) {
|
|
4932
|
-
|
|
4933
|
-
|
|
5757
|
+
// COMP-BUILD-CANCEL §3.6: a pending teardown is the single terminal owner. Stand down —
|
|
5758
|
+
// do NOT terminalize (two writers, one record) and do NOT await it (the teardown is
|
|
5759
|
+
// waiting on `drained`, which this rethrow is what releases).
|
|
5760
|
+
if (buildCancel.teardown) {
|
|
5761
|
+
buildStatus = 'aborted';
|
|
5762
|
+
throw err;
|
|
5763
|
+
}
|
|
5764
|
+
buildStatus = buildCancel.cancelled ? 'aborted' : 'failed';
|
|
5765
|
+
const failureReason = buildCancel.cancelled ? buildCancel.reason : (err?.message ?? 'Build failed');
|
|
4934
5766
|
try {
|
|
4935
5767
|
await terminalizeThrownBuild({
|
|
5768
|
+
buildCancel, buildIdentity,
|
|
4936
5769
|
cwd,
|
|
4937
5770
|
dataDir,
|
|
4938
5771
|
cfg,
|
|
@@ -4961,15 +5794,29 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4961
5794
|
}
|
|
4962
5795
|
// Close stream writer with appropriate status (idempotent — signal handler may have already closed)
|
|
4963
5796
|
if (streamWriter) {
|
|
4964
|
-
streamWriter.
|
|
5797
|
+
if (suspended && !buildCancel.cancelled) streamWriter.pause();
|
|
5798
|
+
else streamWriter.close(buildStatus, buildCostTotals);
|
|
4965
5799
|
}
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
5800
|
+
// §3.6: the teardown removes the listeners itself, AFTER its writes. Removing them here
|
|
5801
|
+
// while it is pending would send a second Ctrl-C to the default handler, killing the
|
|
5802
|
+
// process mid-write.
|
|
5803
|
+
if (signalHandler && !buildCancel.teardown) {
|
|
5804
|
+
process.removeListener('SIGINT', signalHandler.listeners.onSigint);
|
|
5805
|
+
process.removeListener('SIGTERM', signalHandler.listeners.onSigterm);
|
|
4969
5806
|
}
|
|
4970
5807
|
progress.finish();
|
|
4971
|
-
|
|
5808
|
+
// A pending teardown may still have a `flowCancel` in flight over this client; closing it
|
|
5809
|
+
// underneath would abandon the cancel. The teardown exits the process, so the deferred
|
|
5810
|
+
// close is a courtesy, not a leak.
|
|
5811
|
+
if (buildCancel.teardown) {
|
|
5812
|
+
void buildCancel.teardown.then(() => stratum.close()).catch(() => undefined);
|
|
5813
|
+
} else {
|
|
5814
|
+
await stratum.close();
|
|
5815
|
+
}
|
|
4972
5816
|
runtimeResourcesFinalized = true;
|
|
5817
|
+
// §3.6: release the teardown's bounded wait — this build's resources are now closed and
|
|
5818
|
+
// finalizeBuildAttempt has emitted its actuals, so the teardown's writes cannot race it.
|
|
5819
|
+
buildCancel.resolveDrained();
|
|
4973
5820
|
if (actualsError) throw actualsError;
|
|
4974
5821
|
}
|
|
4975
5822
|
} finally {
|
|
@@ -4978,16 +5825,34 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4978
5825
|
} finally {
|
|
4979
5826
|
try {
|
|
4980
5827
|
if (!runtimeResourcesFinalized) {
|
|
4981
|
-
if (streamWriter)
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
5828
|
+
if (streamWriter) {
|
|
5829
|
+
if (suspended && !buildCancel.cancelled) streamWriter.pause();
|
|
5830
|
+
else streamWriter.close(buildStatus);
|
|
5831
|
+
}
|
|
5832
|
+
if (signalHandler && !buildCancel.teardown) {
|
|
5833
|
+
process.removeListener('SIGINT', signalHandler.listeners.onSigint);
|
|
5834
|
+
process.removeListener('SIGTERM', signalHandler.listeners.onSigterm);
|
|
4985
5835
|
}
|
|
4986
5836
|
progress?.finish();
|
|
4987
|
-
if (stratum) await stratum.close();
|
|
5837
|
+
if (stratum && !buildCancel.teardown) await stratum.close();
|
|
4988
5838
|
runtimeResourcesFinalized = true;
|
|
5839
|
+
buildCancel.resolveDrained();
|
|
5840
|
+
}
|
|
5841
|
+
// §3.6: join the teardown so the CLI cannot exit out from under it. The bound is
|
|
5842
|
+
// DERIVED from the teardown's own two deadlines (C46), never chosen.
|
|
5843
|
+
if (buildCancel.teardown) {
|
|
5844
|
+
await withDeadline(buildCancel.teardown, teardownJoinMs).catch(() => undefined);
|
|
4989
5845
|
}
|
|
4990
5846
|
} finally {
|
|
5847
|
+
// S03-5b: a crashed build must leave no stale handle behind.
|
|
5848
|
+
// A join deadline is not settlement. Keep the handle visible to the CLI
|
|
5849
|
+
// until teardown finishes, even when this outer finally stops waiting.
|
|
5850
|
+
if (buildCancel.teardown) {
|
|
5851
|
+
const unregister = () => unregisterBuildCancel(registeredFlowId);
|
|
5852
|
+
void buildCancel.teardown.then(unregister, unregister);
|
|
5853
|
+
} else {
|
|
5854
|
+
unregisterBuildCancel(registeredFlowId);
|
|
5855
|
+
}
|
|
4991
5856
|
// COMP-MCP-MIGRATION-1: restore COMPOSE_BUILD_ID env to its prior value
|
|
4992
5857
|
// (or unset) so subsequent processes / tests don't inherit a stale UUID.
|
|
4993
5858
|
_restoreBuildIdEnv();
|
|
@@ -5179,6 +6044,18 @@ export function toPhaseResultOutput(shipResult) {
|
|
|
5179
6044
|
* Returns a PhaseResult-shaped object.
|
|
5180
6045
|
*/
|
|
5181
6046
|
export async function executeShipStep(featureCode, agentCwd, cwd, context, description, progress) {
|
|
6047
|
+
const waveShipTree = prepareWaveShip(context);
|
|
6048
|
+
if (waveShipTree?.replay) {
|
|
6049
|
+
const { dispatchId, receipt } = waveShipTree.replay;
|
|
6050
|
+
const { result, completionEvidence } = receipt.detail;
|
|
6051
|
+
if (!result || result.outcome !== 'complete' || result.commit !== receipt.detail.commit) {
|
|
6052
|
+
throw new WaveCheckpointError('WAVE_CHECKPOINT_EVIDENCE_MISSING', 'Recorded ship result is unavailable');
|
|
6053
|
+
}
|
|
6054
|
+
await flushWaveReceipts(context, [dispatchId]);
|
|
6055
|
+
context.recordFilesChanged?.(result.filesChanged, { authoritativeShip: true });
|
|
6056
|
+
context.recordCompletionEvidence?.(completionEvidence);
|
|
6057
|
+
return structuredClone(result);
|
|
6058
|
+
}
|
|
5182
6059
|
// COMP-FIX-HARD T4: bug mode stages docs/bugs/<code>/ instead of <featuresDir>/<code>/
|
|
5183
6060
|
// COMP-MCP-MIGRATION-2: feature mode honors paths.features override.
|
|
5184
6061
|
const featuresDir = loadFeaturesDir(cwd);
|
|
@@ -5485,6 +6362,25 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
|
|
|
5485
6362
|
if (filesChanged.length === 0 && sha) filesChanged = stagedFiles;
|
|
5486
6363
|
context.recordFilesChanged?.(filesChanged, { authoritativeShip: true });
|
|
5487
6364
|
|
|
6365
|
+
const result = {
|
|
6366
|
+
phase: 'ship',
|
|
6367
|
+
artifact: sha ?? '',
|
|
6368
|
+
outcome: 'complete',
|
|
6369
|
+
summary: sha
|
|
6370
|
+
? `Committed ${sha.slice(0, 8)}: ${commitMsg} (${stagedFiles.length} files)`
|
|
6371
|
+
: `Committed: ${commitMsg} (${stagedFiles.length} files)`,
|
|
6372
|
+
commit: sha,
|
|
6373
|
+
filesChanged,
|
|
6374
|
+
testsAttested,
|
|
6375
|
+
// Preserve structured test metrics on a replay as well as the first result.
|
|
6376
|
+
...(testSummary.parsed ? { test_count: testSummary.test_count, pass_rate: testSummary.pass_rate } : {}),
|
|
6377
|
+
};
|
|
6378
|
+
const completionEvidence = { commitSha: sha, filesChanged, notes: shortDesc, builtVia, testsAttested, testSummary };
|
|
6379
|
+
if (waveShipTree && sha) await reportWaveEvidence(context, 'wave_ship',
|
|
6380
|
+
context.artifacts.journal.wave.checkpoints.at(-1).gateToken,
|
|
6381
|
+
{ commit: sha, baseCommit: context.artifacts.journal.wave.baseCommit, checkpointTree: waveShipTree,
|
|
6382
|
+
filesChanged, result, completionEvidence });
|
|
6383
|
+
|
|
5488
6384
|
// COMP-COMPLETION-GATE slice 2: the ship step no longer completes the feature.
|
|
5489
6385
|
//
|
|
5490
6386
|
// It used to call recordCompletion here, catch ANY failure, and still return
|
|
@@ -5495,34 +6391,14 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
|
|
|
5495
6391
|
//
|
|
5496
6392
|
// Ship now collects evidence and stops. Exactly one completion happens, at
|
|
5497
6393
|
// terminalization, through the gate, after health. See §2.3, §2.3c.
|
|
5498
|
-
context.recordCompletionEvidence?.(
|
|
5499
|
-
commitSha: sha,
|
|
5500
|
-
filesChanged,
|
|
5501
|
-
notes: shortDesc,
|
|
5502
|
-
builtVia,
|
|
5503
|
-
testsAttested,
|
|
5504
|
-
});
|
|
6394
|
+
context.recordCompletionEvidence?.(completionEvidence);
|
|
5505
6395
|
|
|
5506
6396
|
// COMP-PATHS-EXTERNAL D6a: if ROADMAP / the feature folder resolved into a
|
|
5507
6397
|
// DIFFERENT git repo, they were written but not committed here — tell the
|
|
5508
6398
|
// user to commit them there (v1 does not auto-commit other repos).
|
|
5509
6399
|
noticeExternalArtifacts(cwd, featureCode, gitToplevel(agentCwd));
|
|
5510
6400
|
|
|
5511
|
-
return
|
|
5512
|
-
phase: 'ship',
|
|
5513
|
-
artifact: sha ?? '',
|
|
5514
|
-
outcome: 'complete',
|
|
5515
|
-
summary: sha
|
|
5516
|
-
? `Committed ${sha.slice(0, 8)}: ${commitMsg} (${stagedFiles.length} files)`
|
|
5517
|
-
: `Committed: ${commitMsg} (${stagedFiles.length} files)`,
|
|
5518
|
-
commit: sha,
|
|
5519
|
-
filesChanged,
|
|
5520
|
-
testsAttested,
|
|
5521
|
-
// COMP-MODEL-AB: thread structured test counts into the step result so the
|
|
5522
|
-
// main loop can persist them to build-history.jsonl for metrics consumers.
|
|
5523
|
-
// Only present when testSummary.parsed=true (framework detected + output parsed).
|
|
5524
|
-
...(testSummary.parsed ? { test_count: testSummary.test_count, pass_rate: testSummary.pass_rate } : {}),
|
|
5525
|
-
};
|
|
6401
|
+
return result;
|
|
5526
6402
|
|
|
5527
6403
|
} catch (err) {
|
|
5528
6404
|
return {
|
|
@@ -5654,18 +6530,22 @@ function syncStepHistory(dataDir, stepHistory) {
|
|
|
5654
6530
|
* @param {VisionWriter} visionWriter
|
|
5655
6531
|
* @param {string} gateId
|
|
5656
6532
|
* @param {number} [intervalMs=2000]
|
|
6533
|
+
* @param {AbortSignal} [signal]
|
|
5657
6534
|
* @returns {Promise<object|null>} resolved gate or null (server lost mid-poll)
|
|
5658
6535
|
*/
|
|
5659
|
-
async function pollGateResolution(visionWriter, gateId, intervalMs = 2000) {
|
|
6536
|
+
export async function pollGateResolution(visionWriter, gateId, intervalMs = 2000, signal) {
|
|
5660
6537
|
let consecutiveFailures = 0;
|
|
5661
6538
|
while (true) {
|
|
6539
|
+
signal?.throwIfAborted();
|
|
5662
6540
|
try {
|
|
5663
6541
|
const gate = await visionWriter.getGate(gateId, { requireServer: true });
|
|
6542
|
+
signal?.throwIfAborted();
|
|
5664
6543
|
consecutiveFailures = 0;
|
|
5665
6544
|
if (!gate) throw new Error(`Gate ${gateId} not found (404)`);
|
|
5666
6545
|
if (gate.status === 'expired') throw new Error(`Gate ${gateId} expired`);
|
|
5667
6546
|
if (gate.status !== 'pending') return gate;
|
|
5668
6547
|
} catch (err) {
|
|
6548
|
+
signal?.throwIfAborted();
|
|
5669
6549
|
if (err instanceof ServerUnreachableError) {
|
|
5670
6550
|
consecutiveFailures++;
|
|
5671
6551
|
if (consecutiveFailures >= 3) {
|
|
@@ -5676,7 +6556,7 @@ async function pollGateResolution(visionWriter, gateId, intervalMs = 2000) {
|
|
|
5676
6556
|
throw err;
|
|
5677
6557
|
}
|
|
5678
6558
|
}
|
|
5679
|
-
await
|
|
6559
|
+
await sleep(intervalMs, undefined, { signal });
|
|
5680
6560
|
}
|
|
5681
6561
|
}
|
|
5682
6562
|
|
|
@@ -5796,51 +6676,195 @@ function loadFeatureDescription(featureDir, featureCode) {
|
|
|
5796
6676
|
return featureCode;
|
|
5797
6677
|
}
|
|
5798
6678
|
|
|
6679
|
+
const ABORT_TERMINAL_STATUSES = new Set(['complete', 'aborted', 'killed', 'failed']);
|
|
6680
|
+
|
|
6681
|
+
function abortSetting(name, fallback) {
|
|
6682
|
+
const value = Number(process.env[name] ?? fallback);
|
|
6683
|
+
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
|
|
6684
|
+
}
|
|
6685
|
+
|
|
6686
|
+
function abortRefusal(error) {
|
|
6687
|
+
if (['CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT', 'FLOW_NOT_FOUND'].includes(error?.code)) return error;
|
|
6688
|
+
if (isUnknownFlowError(error)) {
|
|
6689
|
+
return { code: 'FLOW_NOT_FOUND', reason: 'flow_not_found', status: null, flowSettled: false, agents: null };
|
|
6690
|
+
}
|
|
6691
|
+
return asTransportRefusal(error);
|
|
6692
|
+
}
|
|
6693
|
+
|
|
6694
|
+
function reportAbort(result) {
|
|
6695
|
+
console.log(`Flow status: ${result.status ?? 'unknown'} (settled: ${result.flowSettled}).`);
|
|
6696
|
+
if (result.reason) console.log(`Reason: ${result.reason}${result.holderPid == null ? '' : `; holderPid: ${result.holderPid}`}.`);
|
|
6697
|
+
for (const [counter, count] of Object.entries(result.agents ?? {})) {
|
|
6698
|
+
if (count > 0) console.log(`Agents ${counter}: ${count}.`);
|
|
6699
|
+
}
|
|
6700
|
+
console.log(`Driver: ${result.driverMode}; signalled: ${result.driverSignalled}; exited: ${result.driverExited}; terminal writer: ${result.terminalWriter}.`);
|
|
6701
|
+
console.log(result.ok ? 'Build aborted.' : `Build NOT aborted — ${result.reason}.`);
|
|
6702
|
+
return result;
|
|
6703
|
+
}
|
|
6704
|
+
|
|
6705
|
+
/** Retry only pre-settle lock refusals; a settled timeout gets one idempotent sweep. */
|
|
6706
|
+
async function cancelAbortFlow(stratum, flowId, result) {
|
|
6707
|
+
const retries = abortSetting('COMPOSE_ABORT_RETRIES', 2);
|
|
6708
|
+
let lockRetries = 0;
|
|
6709
|
+
let reswept = false;
|
|
6710
|
+
let settled = null;
|
|
6711
|
+
for (;;) {
|
|
6712
|
+
result.attempts++;
|
|
6713
|
+
let outcome;
|
|
6714
|
+
try {
|
|
6715
|
+
outcome = await stratum.flowCancel(flowId);
|
|
6716
|
+
} catch (error) {
|
|
6717
|
+
outcome = abortRefusal(error);
|
|
6718
|
+
}
|
|
6719
|
+
// Once durable settlement is known, a later broken pipe cannot undo it. Keep
|
|
6720
|
+
// the last settled counters/status and report the failed re-sweep separately.
|
|
6721
|
+
if (settled && outcome.flowSettled !== true) {
|
|
6722
|
+
console.log(`Re-sweep failed: ${outcome.reason ?? outcome.code ?? 'transport'}.`);
|
|
6723
|
+
return settled;
|
|
6724
|
+
}
|
|
6725
|
+
if (outcome.flowSettled === true) {
|
|
6726
|
+
settled = outcome;
|
|
6727
|
+
if (outcome.code === 'CANCELLATION_TEARDOWN_TIMEOUT' && !reswept) {
|
|
6728
|
+
reswept = true;
|
|
6729
|
+
console.log('Flow settled; re-sweeping agent teardown once.');
|
|
6730
|
+
continue;
|
|
6731
|
+
}
|
|
6732
|
+
return outcome;
|
|
6733
|
+
}
|
|
6734
|
+
if (outcome.code === 'CANCELLATION_UNCONFIRMED' && outcome.reason === 'run_lock_held'
|
|
6735
|
+
&& Object.values(outcome.agents ?? {}).every(count => count === 0) && lockRetries < retries) {
|
|
6736
|
+
lockRetries++;
|
|
6737
|
+
console.log(`Cancel lock held${outcome.holderPid == null ? '' : ` by ${outcome.holderPid}`}; retry ${lockRetries}/${retries}.`);
|
|
6738
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
6739
|
+
continue;
|
|
6740
|
+
}
|
|
6741
|
+
return outcome;
|
|
6742
|
+
}
|
|
6743
|
+
}
|
|
6744
|
+
|
|
6745
|
+
/** The driver owns all terminal writes while it can still finish them itself. */
|
|
6746
|
+
async function waitForAbortDriver(dataDir, active, handle) {
|
|
6747
|
+
const deadline = Date.now() + abortSetting('COMPOSE_ABORT_DRIVER_WAIT_MS', 20000);
|
|
6748
|
+
for (;;) {
|
|
6749
|
+
const claim = claimActiveBuild(dataDir, active);
|
|
6750
|
+
if (!claim.ok) return claim;
|
|
6751
|
+
if (ABORT_TERMINAL_STATUSES.has(claim.record.status)
|
|
6752
|
+
&& (!handle || !lookupBuildCancel(active.flowId))) {
|
|
6753
|
+
return { ...claim, exited: true };
|
|
6754
|
+
}
|
|
6755
|
+
const remaining = deadline - Date.now();
|
|
6756
|
+
if (remaining <= 0) return { ...claim, exited: false };
|
|
6757
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining)));
|
|
6758
|
+
}
|
|
6759
|
+
}
|
|
6760
|
+
|
|
5799
6761
|
export async function abortBuild(dataDir, featureCode, cwd, opts = {}) {
|
|
5800
6762
|
const active = readActiveBuild(dataDir);
|
|
6763
|
+
const result = {
|
|
6764
|
+
ok: false, flowId: active?.flowId ?? null, status: null, flowSettled: false,
|
|
6765
|
+
acknowledged: false, code: null, reason: null, holderPid: null, agents: null,
|
|
6766
|
+
attempts: 0, driverMode: 'none', driverSignalled: false, driverExited: false,
|
|
6767
|
+
terminalWriter: 'none', localCleanup: false,
|
|
6768
|
+
};
|
|
6769
|
+
const refuse = reason => reportAbort({ ...result, ok: false, reason, terminalWriter: 'none', localCleanup: false });
|
|
5801
6770
|
if (!active) {
|
|
5802
6771
|
console.log('No active build to abort.');
|
|
5803
|
-
return;
|
|
6772
|
+
return refuse('no_active_build');
|
|
5804
6773
|
}
|
|
5805
|
-
|
|
5806
6774
|
if (featureCode && active.featureCode !== featureCode) {
|
|
5807
6775
|
console.log(`Active build is for ${active.featureCode}, not ${featureCode}.`);
|
|
5808
|
-
return;
|
|
6776
|
+
return refuse('feature_mismatch');
|
|
5809
6777
|
}
|
|
6778
|
+
if (ABORT_TERMINAL_STATUSES.has(active.status)) return refuse(`already_${active.status}`);
|
|
5810
6779
|
|
|
6780
|
+
const handle = lookupBuildCancel(active.flowId);
|
|
6781
|
+
const foreignPid = !handle && active.pid && active.pid !== process.pid
|
|
6782
|
+
&& active.status === 'running' && isProcessAlive(active.pid) ? active.pid : null;
|
|
6783
|
+
result.driverMode = handle ? 'in-process' : foreignPid ? 'foreign-pid' : 'none';
|
|
5811
6784
|
console.log(`Aborting build for ${active.featureCode}...`);
|
|
5812
6785
|
|
|
5813
|
-
//
|
|
5814
|
-
//
|
|
5815
|
-
const
|
|
5816
|
-
const
|
|
6786
|
+
// Configuration errors (notably the retired Python pin) still reject BEFORE
|
|
6787
|
+
// connecting. Only a connection/transport failure belongs in the outcome table.
|
|
6788
|
+
const connection = active.flowId ? resolveStratumMcpConnection(cwd) : null;
|
|
6789
|
+
const stratum = connection ? (opts.stratum ?? new StratumMcpClient()) : null;
|
|
5817
6790
|
try {
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
6791
|
+
let outcome = { flowSettled: true };
|
|
6792
|
+
if (stratum) {
|
|
6793
|
+
try {
|
|
6794
|
+
await stratum.connect({ ...connection,
|
|
6795
|
+
env: { ...process.env, STRATUM_CANCEL_LOCK_WAIT_MS: String(abortSetting('COMPOSE_ABORT_LOCK_WAIT_MS', 10000)) },
|
|
6796
|
+
});
|
|
6797
|
+
} catch (error) {
|
|
6798
|
+
outcome = asTransportRefusal(error);
|
|
6799
|
+
}
|
|
6800
|
+
if (!outcome.code) outcome = await cancelAbortFlow(stratum, active.flowId, result);
|
|
6801
|
+
}
|
|
6802
|
+
for (const key of ['status', 'code', 'reason', 'holderPid', 'agents']) result[key] = outcome[key] ?? null;
|
|
6803
|
+
result.flowSettled = outcome.flowSettled === true;
|
|
6804
|
+
result.acknowledged = outcome.acknowledged === true;
|
|
6805
|
+
const alreadyTerminal = !outcome.code && ['completed', 'failed', 'budget_exhausted'].includes(outcome.status)
|
|
6806
|
+
&& outcome.reason === `already_${outcome.status}`;
|
|
6807
|
+
if (!result.flowSettled && !alreadyTerminal) return refuse(result.reason ?? 'transport');
|
|
6808
|
+
|
|
6809
|
+
// C41/C48: the awaits above may have handed ownership to a different build.
|
|
6810
|
+
// Claim BEFORE even cancelling the local controller or signalling a driver.
|
|
6811
|
+
let claim = claimActiveBuild(dataDir, active);
|
|
6812
|
+
if (!claim.ok) return refuse(claim.reason);
|
|
6813
|
+
if (result.flowSettled) {
|
|
6814
|
+
if (handle) handle.cancel('abort');
|
|
6815
|
+
else if (foreignPid && isProcessAlive(foreignPid)) {
|
|
6816
|
+
try { process.kill(foreignPid, 'SIGTERM'); result.driverSignalled = true; }
|
|
6817
|
+
catch (error) {
|
|
6818
|
+
if (error.code !== 'ESRCH') console.log(`Driver SIGTERM failed: ${error.code ?? error.message}.`);
|
|
6819
|
+
}
|
|
6820
|
+
}
|
|
6821
|
+
}
|
|
6822
|
+
if (result.driverMode !== 'none') {
|
|
6823
|
+
const driver = await waitForAbortDriver(dataDir, active, handle);
|
|
6824
|
+
if (!driver.ok) return refuse(driver.reason);
|
|
6825
|
+
if (driver.exited) {
|
|
6826
|
+
result.driverExited = true;
|
|
6827
|
+
result.terminalWriter = 'driver';
|
|
6828
|
+
result.ok = ['aborted', 'killed'].includes(driver.record.status);
|
|
6829
|
+
result.localCleanup = result.ok;
|
|
6830
|
+
if (!result.ok) result.reason = `already_${driver.record.status}`;
|
|
6831
|
+
return reportAbort(result);
|
|
6832
|
+
}
|
|
5822
6833
|
}
|
|
5823
|
-
} catch {
|
|
5824
|
-
// A missing/unreadable run does not prevent local cleanup.
|
|
5825
|
-
} finally {
|
|
5826
|
-
await stratum.close();
|
|
5827
|
-
}
|
|
5828
|
-
|
|
5829
|
-
// Update vision state
|
|
5830
|
-
const visionWriter = new VisionWriter(dataDir);
|
|
5831
|
-
const item = await visionWriter.findFeatureItem(active.featureCode);
|
|
5832
|
-
const itemId = item?.id;
|
|
5833
|
-
if (itemId) {
|
|
5834
|
-
await visionWriter.updateItemStatus(itemId, 'killed');
|
|
5835
|
-
}
|
|
5836
6834
|
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
6835
|
+
// Re-claim after the bounded wait, covering vision + state + actuals as one
|
|
6836
|
+
// cleanup. The aborter never restamps the driver's pid.
|
|
6837
|
+
claim = claimActiveBuild(dataDir, active);
|
|
6838
|
+
if (!claim.ok) return refuse(claim.reason);
|
|
6839
|
+
if (ABORT_TERMINAL_STATUSES.has(claim.record.status)) return refuse(`already_${claim.record.status}`);
|
|
6840
|
+
const visionWriter = new VisionWriter(dataDir);
|
|
6841
|
+
const item = await visionWriter.findFeatureItem(active.featureCode);
|
|
6842
|
+
claim = claimActiveBuild(dataDir, active);
|
|
6843
|
+
if (!claim.ok) return refuse(claim.reason);
|
|
6844
|
+
if (ABORT_TERMINAL_STATUSES.has(claim.record.status)) return refuse(`already_${claim.record.status}`);
|
|
6845
|
+
if (item?.id) {
|
|
6846
|
+
try { await killOwnedBuildVision(visionWriter, item.id, () => claimActiveBuild(dataDir, active)); }
|
|
6847
|
+
catch (error) {
|
|
6848
|
+
if (['ownership_lost', 'ownership_unverifiable'].includes(error.reason)) return refuse(error.reason);
|
|
6849
|
+
throw error;
|
|
6850
|
+
}
|
|
6851
|
+
}
|
|
6852
|
+
claim = claimActiveBuild(dataDir, active);
|
|
6853
|
+
if (!claim.ok) return refuse(claim.reason);
|
|
6854
|
+
if (ABORT_TERMINAL_STATUSES.has(claim.record.status)) return refuse(`already_${claim.record.status}`);
|
|
6855
|
+
writeActiveBuild(dataDir, { ...claim.record, status: 'aborted', completedAt: new Date().toISOString() }, { stampPid: false });
|
|
6856
|
+
const accumulator = readBuildAccumulator(cwd, active.featureCode);
|
|
6857
|
+
if (accumulator) emitBuildActuals(cwd, accumulator, 'aborted');
|
|
6858
|
+
result.ok = true;
|
|
6859
|
+
result.localCleanup = true;
|
|
6860
|
+
result.terminalWriter = 'abort';
|
|
6861
|
+
return reportAbort(result);
|
|
6862
|
+
} finally {
|
|
6863
|
+
if (stratum) {
|
|
6864
|
+
try { await stratum.close(); }
|
|
6865
|
+
catch (error) { console.warn(`Abort client close failed: ${error.message}`); }
|
|
6866
|
+
}
|
|
5842
6867
|
}
|
|
5843
|
-
console.log('Build aborted.');
|
|
5844
6868
|
}
|
|
5845
6869
|
|
|
5846
6870
|
/** Optional revision failures keep the draft; only user control or uncertain teardown stops the build. */
|