@smartmemory/compose 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +1 -1
- package/bin/compose.js +33 -14
- 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/build-cancel.js +205 -0
- package/lib/build.js +552 -87
- 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 +24 -1
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -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/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +28 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/package.json +7 -5
- 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/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
|
@@ -13,8 +13,9 @@ import { join, resolve, dirname, 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';
|
|
@@ -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,6 +91,7 @@ import {
|
|
|
76
91
|
ConsumerArtifactError,
|
|
77
92
|
ConsumerFanoutArtifacts,
|
|
78
93
|
ConsumerMergeDecisionError,
|
|
94
|
+
MergeAfterCancelError,
|
|
79
95
|
isConsumerDescriptor,
|
|
80
96
|
recoverAdvancedConsumerArtifacts,
|
|
81
97
|
verifyConsumerRunRevision,
|
|
@@ -687,6 +703,16 @@ function responseReissuesStep(response, stepId) {
|
|
|
687
703
|
return response.ready.some((ready) => ready?.id === stepId && ready?.previousFailure);
|
|
688
704
|
}
|
|
689
705
|
|
|
706
|
+
async function callFlowWithCancellation(stratum, method, flowId, buildCancel, ...args) {
|
|
707
|
+
if (buildCancel.cancelled) throw buildCancel.signal.reason;
|
|
708
|
+
try {
|
|
709
|
+
return await stratum[method](flowId, ...args);
|
|
710
|
+
} catch (error) {
|
|
711
|
+
await confirmCancellation(error, { stratum, flowId, buildCancel });
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
690
716
|
async function reportConsumerStepDone({
|
|
691
717
|
descriptor,
|
|
692
718
|
flowId,
|
|
@@ -695,7 +721,9 @@ async function reportConsumerStepDone({
|
|
|
695
721
|
artifacts,
|
|
696
722
|
progress,
|
|
697
723
|
streamWriter,
|
|
724
|
+
buildCancel,
|
|
698
725
|
}) {
|
|
726
|
+
if (buildCancel?.cancelled) throw buildCancel.signal.reason;
|
|
699
727
|
try {
|
|
700
728
|
return {
|
|
701
729
|
response: await stratum.stepDone(
|
|
@@ -704,6 +732,7 @@ async function reportConsumerStepDone({
|
|
|
704
732
|
skipped: false,
|
|
705
733
|
};
|
|
706
734
|
} catch (error) {
|
|
735
|
+
if (await confirmCancellation(error, { stratum, flowId, buildCancel })) throw error;
|
|
707
736
|
if (!isStaleOrDuplicateConsumerReportError(error)) throw error;
|
|
708
737
|
|
|
709
738
|
const summary = `consumer item ${descriptor.id} (index ${descriptor.itemIndex}) step_done skipped:`
|
|
@@ -761,6 +790,9 @@ export async function runConsumerIssuance({
|
|
|
761
790
|
// events for this item and halts a spinning run.
|
|
762
791
|
perItemTimeoutMs = null,
|
|
763
792
|
stuckDetector = null,
|
|
793
|
+
// COMP-BUILD-CANCEL S03-5: the build-level cancel handle, so an item's agent dies with
|
|
794
|
+
// the build. Optional — lib/gsd.js calls this without one.
|
|
795
|
+
buildCancel = null,
|
|
764
796
|
// D6: the full agent profile string for this fanout step (from the compose
|
|
765
797
|
// sidecar), applied at invocation so e.g. an isolation:none review fanout runs
|
|
766
798
|
// read-only. Absent → the descriptor's bare provider literal (no restrictions).
|
|
@@ -782,7 +814,7 @@ export async function runConsumerIssuance({
|
|
|
782
814
|
const envelope = { failure: `${artifactError.code}: ${artifactError.message}` };
|
|
783
815
|
artifacts.prepareArtifactFailure(descriptor, envelope, artifactError);
|
|
784
816
|
const report = await reportConsumerStepDone({
|
|
785
|
-
descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter,
|
|
817
|
+
descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter, buildCancel,
|
|
786
818
|
});
|
|
787
819
|
if (report.skipped) return report.response;
|
|
788
820
|
const { response } = report;
|
|
@@ -801,6 +833,7 @@ export async function runConsumerIssuance({
|
|
|
801
833
|
descriptor,
|
|
802
834
|
flowId,
|
|
803
835
|
envelope: recovery.envelope,
|
|
836
|
+
buildCancel,
|
|
804
837
|
stratum,
|
|
805
838
|
artifacts,
|
|
806
839
|
progress,
|
|
@@ -943,6 +976,14 @@ export async function runConsumerIssuance({
|
|
|
943
976
|
reviewMode: reviewOpts.reviewMode,
|
|
944
977
|
confidenceGate: reviewOpts.confidenceGate,
|
|
945
978
|
lens: reviewOpts.lens,
|
|
979
|
+
// COMP-BUILD-CANCEL S03-4: tag the item so `stratum_flow_cancel` can reach it
|
|
980
|
+
// from another process, and chain the build handle so a local `isolation: none`
|
|
981
|
+
// item — which never enters stratum — dies with the build.
|
|
982
|
+
...(flowTag(flowId, descriptor.step ?? descriptor.id, descriptor.itemIndex)
|
|
983
|
+
? { flow: flowTag(flowId, descriptor.step ?? descriptor.id, descriptor.itemIndex) }
|
|
984
|
+
: {}),
|
|
985
|
+
flowId, buildCancel,
|
|
986
|
+
buildSignal: buildCancel?.signal,
|
|
946
987
|
telemetry: {
|
|
947
988
|
site: context.gsd ? 'gsd' : 'consumer',
|
|
948
989
|
project_cwd: context.projectCwd ?? context.cwd,
|
|
@@ -956,6 +997,7 @@ export async function runConsumerIssuance({
|
|
|
956
997
|
localExecution: descriptor.policy?.isolation === 'none',
|
|
957
998
|
});
|
|
958
999
|
} catch (error) {
|
|
1000
|
+
if (buildCancel?.cancelled) throw error;
|
|
959
1001
|
const failedUsage = failureUsageFields(error);
|
|
960
1002
|
// Control failures do not settle the item, so record known dispatch usage
|
|
961
1003
|
// before aborting the pump. Never retry work with uncertain termination.
|
|
@@ -1089,7 +1131,7 @@ export async function runConsumerIssuance({
|
|
|
1089
1131
|
}
|
|
1090
1132
|
|
|
1091
1133
|
const report = await reportConsumerStepDone({
|
|
1092
|
-
descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter,
|
|
1134
|
+
descriptor, flowId, envelope, stratum, artifacts, progress, streamWriter, buildCancel,
|
|
1093
1135
|
});
|
|
1094
1136
|
if (report.skipped) return report.response;
|
|
1095
1137
|
const { response } = report;
|
|
@@ -1243,7 +1285,7 @@ function usagePayload(usage, usages) {
|
|
|
1243
1285
|
|
|
1244
1286
|
/** Send one surface-15 receipt per underlying model dispatch. */
|
|
1245
1287
|
export async function reportUsageReceipts(context, usage, meta = {}) {
|
|
1246
|
-
if (!context?.receiptsMode || !context.flowId || typeof context.stratum?.usageReport !== 'function') {
|
|
1288
|
+
if (context?.buildCancel?.cancelled || !context?.receiptsMode || !context.flowId || typeof context.stratum?.usageReport !== 'function') {
|
|
1247
1289
|
return [];
|
|
1248
1290
|
}
|
|
1249
1291
|
const entries = Array.isArray(usage)
|
|
@@ -1251,6 +1293,7 @@ export async function reportUsageReceipts(context, usage, meta = {}) {
|
|
|
1251
1293
|
: (Array.isArray(usage?.usages) ? usage.usages : (usage ? [usage] : []));
|
|
1252
1294
|
const responses = [];
|
|
1253
1295
|
for (const entry of entries) {
|
|
1296
|
+
if (context.buildCancel?.cancelled) break;
|
|
1254
1297
|
if (!entry || typeof entry !== 'object') continue;
|
|
1255
1298
|
const engineUsage = toEngineUsage(entry);
|
|
1256
1299
|
if (!engineUsage) continue;
|
|
@@ -1292,6 +1335,7 @@ export async function reportUsageReceipts(context, usage, meta = {}) {
|
|
|
1292
1335
|
try {
|
|
1293
1336
|
responses.push(await context.stratum.usageReport(context.flowId, receipt));
|
|
1294
1337
|
} catch (error) {
|
|
1338
|
+
if (await confirmCancellation(error, context)) break;
|
|
1295
1339
|
console.warn(`[usage-receipt] failed for ${receipt.dispatchId}: ${error?.message ?? error}`);
|
|
1296
1340
|
}
|
|
1297
1341
|
}
|
|
@@ -1466,12 +1510,38 @@ function readActiveBuild(dataDir) {
|
|
|
1466
1510
|
}
|
|
1467
1511
|
}
|
|
1468
1512
|
|
|
1469
|
-
|
|
1513
|
+
/**
|
|
1514
|
+
* COMP-BUILD-CANCEL §3.7 — re-read and CLAIM before ANY mutation of the active-build
|
|
1515
|
+
* record. Strict on purpose: a missing flow id must never match anything.
|
|
1516
|
+
*
|
|
1517
|
+
* Returns the claimed record, or a REASON. C48: the fallback validates PRESENCE before
|
|
1518
|
+
* equality, because `undefined === undefined` is true and two records that both lack a
|
|
1519
|
+
* pid would otherwise claim each other.
|
|
1520
|
+
*/
|
|
1521
|
+
function claimActiveBuild(dataDir, active) {
|
|
1522
|
+
const cur = readActiveBuild(dataDir);
|
|
1523
|
+
if (!cur) return { ok: false, reason: 'ownership_lost' };
|
|
1524
|
+
if (cur.featureCode !== active.featureCode) return { ok: false, reason: 'ownership_lost' };
|
|
1525
|
+
if (cur.flowId && active.flowId) {
|
|
1526
|
+
return cur.flowId === active.flowId
|
|
1527
|
+
? { ok: true, record: cur }
|
|
1528
|
+
: { ok: false, reason: 'ownership_lost' };
|
|
1529
|
+
}
|
|
1530
|
+
// No flow id on one side: fall back to pid + startedAt, but ONLY when all four are present.
|
|
1531
|
+
const present = Boolean(cur.pid) && Boolean(active.pid)
|
|
1532
|
+
&& Boolean(cur.startedAt) && Boolean(active.startedAt);
|
|
1533
|
+
if (!present) return { ok: false, reason: 'ownership_unverifiable' };
|
|
1534
|
+
return (cur.pid === active.pid && cur.startedAt === active.startedAt)
|
|
1535
|
+
? { ok: true, record: cur }
|
|
1536
|
+
: { ok: false, reason: 'ownership_lost' };
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
export function writeActiveBuild(dataDir, state, { stampPid = true } = {}) {
|
|
1470
1540
|
mkdirSync(dataDir, { recursive: true });
|
|
1471
|
-
//
|
|
1472
|
-
state.pid = process.pid;
|
|
1541
|
+
// The aborter must preserve the driver's identity when it owns the terminal write.
|
|
1542
|
+
if (stampPid) state.pid = process.pid;
|
|
1473
1543
|
const target = activeBuildPath(dataDir);
|
|
1474
|
-
const tmp = target
|
|
1544
|
+
const tmp = `${target}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
1475
1545
|
writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
1476
1546
|
renameSync(tmp, target);
|
|
1477
1547
|
}
|
|
@@ -1971,8 +2041,15 @@ export function makeAskAgent(stratum, context, gateDispatch, gateExtras) {
|
|
|
1971
2041
|
`${fileRef} this question concisely:\n\n` +
|
|
1972
2042
|
`${question}\n\n` +
|
|
1973
2043
|
`Keep your answer brief — 2-3 sentences max.`;
|
|
2044
|
+
if (context.buildCancel?.cancelled) throw context.buildCancel.signal.reason;
|
|
2045
|
+
const taggedFlow = flowTag(context.flowId, gateDispatch.step_id ?? gateDispatch.id);
|
|
1974
2046
|
const text = await stratum.runAgentText('claude', qaPrompt, {
|
|
1975
2047
|
cwd: context.cwd,
|
|
2048
|
+
// S03-4 (R1): a gate pause leaves the run `running`, so a gate-time agent IS
|
|
2049
|
+
// admitted and tags successfully (C1). Conditional on `context.flowId` because
|
|
2050
|
+
// `makeAskAgent` is exported and called with a bare, flow-less context in tests.
|
|
2051
|
+
...(taggedFlow ? { flow: taggedFlow } : {}),
|
|
2052
|
+
signal: context.buildCancel?.signal,
|
|
1976
2053
|
telemetry: {
|
|
1977
2054
|
site: 'gate-qa',
|
|
1978
2055
|
project_cwd: context.projectCwd ?? context.cwd,
|
|
@@ -1990,6 +2067,9 @@ export function makeAskAgent(stratum, context, gateDispatch, gateExtras) {
|
|
|
1990
2067
|
budgetExhausted = true;
|
|
1991
2068
|
}
|
|
1992
2069
|
},
|
|
2070
|
+
}).catch(async error => {
|
|
2071
|
+
await confirmCancellation(error, { stratum, flowId: context.flowId, buildCancel: context.buildCancel, tagged: Boolean(taggedFlow) });
|
|
2072
|
+
throw error;
|
|
1993
2073
|
});
|
|
1994
2074
|
return text || '(no answer)';
|
|
1995
2075
|
};
|
|
@@ -2009,8 +2089,8 @@ export function deleteActiveBuild(dataDir) {
|
|
|
2009
2089
|
* never produce more steps. Used to detect stale lock files and decide
|
|
2010
2090
|
* whether a resumed flow needs a fresh start.
|
|
2011
2091
|
*/
|
|
2012
|
-
function isTerminalFlow(status) {
|
|
2013
|
-
return ['completed', 'failed', 'budget_exhausted'].includes(status);
|
|
2092
|
+
export function isTerminalFlow(status) {
|
|
2093
|
+
return ['completed', 'failed', 'budget_exhausted', 'cancelled'].includes(status);
|
|
2014
2094
|
}
|
|
2015
2095
|
|
|
2016
2096
|
function isRecoverableFlowProbeError(err) {
|
|
@@ -2158,7 +2238,34 @@ export async function emitBugCheckpointOnTerminalFailure(context, exhaustedStepI
|
|
|
2158
2238
|
}
|
|
2159
2239
|
}
|
|
2160
2240
|
|
|
2241
|
+
/** Guard the actual vision mutation, after updateItemStatus's async server probe.
|
|
2242
|
+
* Use a per-call receiver so a late best-effort update cannot outlive its claim. */
|
|
2243
|
+
async function killOwnedBuildVision(visionWriter, itemId, claimOwnership) {
|
|
2244
|
+
const ownedWriter = Object.create(visionWriter);
|
|
2245
|
+
for (const method of ['_directUpdateItemStatus', '_restUpdateItemStatus']) {
|
|
2246
|
+
ownedWriter[method] = (...args) => {
|
|
2247
|
+
const claim = claimOwnership();
|
|
2248
|
+
if (!claim.ok) throw Object.assign(new Error(claim.reason), { reason: claim.reason });
|
|
2249
|
+
return visionWriter[method](...args);
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
return ownedWriter.updateItemStatus(itemId, 'killed');
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
/** Claims protect the cancelled driver's record AND its vision projection. */
|
|
2256
|
+
async function terminalizeCancelledBuild({ dataDir, buildIdentity, visionWriter, itemId, failureReason }) {
|
|
2257
|
+
if (!buildIdentity) return;
|
|
2258
|
+
const claim = claimActiveBuild(dataDir, buildIdentity);
|
|
2259
|
+
if (!claim.ok) return;
|
|
2260
|
+
writeActiveBuild(dataDir, {
|
|
2261
|
+
...claim.record, status: 'aborted', failureReason,
|
|
2262
|
+
completedAt: claim.record.completedAt ?? new Date().toISOString(),
|
|
2263
|
+
});
|
|
2264
|
+
try { await killOwnedBuildVision(visionWriter, itemId, () => claimActiveBuild(dataDir, buildIdentity)); } catch { /* best-effort projection */ }
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2161
2267
|
async function terminalizeThrownBuild({
|
|
2268
|
+
buildCancel, buildIdentity,
|
|
2162
2269
|
cwd,
|
|
2163
2270
|
dataDir,
|
|
2164
2271
|
cfg,
|
|
@@ -2175,7 +2282,9 @@ async function terminalizeThrownBuild({
|
|
|
2175
2282
|
}) {
|
|
2176
2283
|
const flowId = response?.runId ?? null;
|
|
2177
2284
|
if (!flowId) return false;
|
|
2178
|
-
|
|
2285
|
+
if (buildCancel?.cancelled) {
|
|
2286
|
+
await terminalizeCancelledBuild({ dataDir, buildIdentity, visionWriter, itemId, failureReason });
|
|
2287
|
+
} else await writeFailedBuildTerminalState({
|
|
2179
2288
|
cwd,
|
|
2180
2289
|
dataDir,
|
|
2181
2290
|
cfg,
|
|
@@ -2190,7 +2299,7 @@ async function terminalizeThrownBuild({
|
|
|
2190
2299
|
featureCode,
|
|
2191
2300
|
flowId,
|
|
2192
2301
|
mode,
|
|
2193
|
-
status: 'failed',
|
|
2302
|
+
status: buildCancel?.cancelled ? 'aborted' : 'failed',
|
|
2194
2303
|
startedAt: buildStartedAt,
|
|
2195
2304
|
completedAt: new Date().toISOString(),
|
|
2196
2305
|
durationMs: Date.now() - new Date(buildStartedAt).getTime(),
|
|
@@ -2249,8 +2358,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2249
2358
|
// Handle --abort early (featureCode may be null). C2: pass the project root so
|
|
2250
2359
|
// the engine is resolved from this project's capabilities, not process.cwd().
|
|
2251
2360
|
if (opts.abort) {
|
|
2252
|
-
await abortBuild(dataDir, featureCode, cwd);
|
|
2253
|
-
return;
|
|
2361
|
+
return await abortBuild(dataDir, featureCode, cwd);
|
|
2254
2362
|
}
|
|
2255
2363
|
|
|
2256
2364
|
const {
|
|
@@ -2265,6 +2373,15 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2265
2373
|
let accumulatorPersisted = !isNewAccumulator;
|
|
2266
2374
|
const buildStartedAt = new Date().toISOString();
|
|
2267
2375
|
let buildStatus = 'failed';
|
|
2376
|
+
// COMP-BUILD-CANCEL §3.4: one handle per runBuild. `signal` is the D-F chain into every
|
|
2377
|
+
// dispatch; `cancelled` is the merge guard's flag; `teardownStarted` is what a second
|
|
2378
|
+
// Ctrl-C keys on. Registered by flow id once the runId exists (S03-5b).
|
|
2379
|
+
const buildCancel = createBuildCancel();
|
|
2380
|
+
let buildIdentity = null;
|
|
2381
|
+
// The flow id this build registered its handle under, so the outermost finally can
|
|
2382
|
+
// unregister without reaching into the inner try's `response`.
|
|
2383
|
+
let registeredFlowId = null;
|
|
2384
|
+
const { cancelMs: cancelTimeoutMs, drainMs: drainTimeoutMs, joinMs: teardownJoinMs } = cancelBudgets();
|
|
2268
2385
|
let attemptStarted = true;
|
|
2269
2386
|
let attemptFinalized = false;
|
|
2270
2387
|
// COMP-TRIAGE-6-4: function-scoped so finalizeBuildAttempt's ownership guard
|
|
@@ -2294,6 +2411,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2294
2411
|
};
|
|
2295
2412
|
const finalizeBuildAttempt = () => {
|
|
2296
2413
|
if (!attemptStarted || attemptFinalized) return;
|
|
2414
|
+
if (buildCancel.teardown || buildCancel.cancelled) buildStatus = 'aborted';
|
|
2297
2415
|
const terminalStatus = buildStatus === 'complete'
|
|
2298
2416
|
? 'complete'
|
|
2299
2417
|
: (buildStatus === 'killed' || buildStatus === 'aborted')
|
|
@@ -2766,16 +2884,36 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2766
2884
|
&& isProcessAlive(activeForDecision.pid)
|
|
2767
2885
|
);
|
|
2768
2886
|
const sameMode = !activeForDecision?.mode || activeForDecision.mode === mode;
|
|
2769
|
-
let flowTerminal = !activeForDecision?.flowId
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2887
|
+
let flowTerminal = !activeForDecision?.flowId
|
|
2888
|
+
|| ['complete', 'aborted', 'killed'].includes(activeForDecision.status);
|
|
2889
|
+
const probeFlowId = opts.resumeFlowId ?? activeForDecision?.flowId;
|
|
2890
|
+
// Probe only when the engine can tell us something the local record cannot: an
|
|
2891
|
+
// explicit resume target, or a record that is not already terminal. A locally
|
|
2892
|
+
// terminal record needs no probe (pre-S05 semantics, kept).
|
|
2893
|
+
if (probeFlowId && !opts.fresh && (opts.resumeFlowId || !flowTerminal)) {
|
|
2773
2894
|
try {
|
|
2774
|
-
const audit = await stratum.audit(
|
|
2895
|
+
const audit = await stratum.audit(probeFlowId);
|
|
2896
|
+
if (audit?.status === 'cancelled') {
|
|
2897
|
+
// C42/C49: a resume probe is not necessarily the owning driver.
|
|
2898
|
+
const handle = lookupBuildCancel(probeFlowId);
|
|
2899
|
+
if (handle) {
|
|
2900
|
+
handle.cancel('flow_cancelled');
|
|
2901
|
+
} else if (activeForDecision?.flowId === probeFlowId) {
|
|
2902
|
+
const claim = claimActiveBuild(dataDir, activeForDecision);
|
|
2903
|
+
if (claim.ok) writeActiveBuild(dataDir, {
|
|
2904
|
+
...claim.record, status: 'aborted', failureReason: 'flow_cancelled',
|
|
2905
|
+
completedAt: new Date().toISOString(),
|
|
2906
|
+
});
|
|
2907
|
+
}
|
|
2908
|
+
buildStatus = 'aborted';
|
|
2909
|
+
throw Object.assign(new Error(`Flow ${probeFlowId} was cancelled; it cannot be resumed. Run with --fresh to start a new build.`), {
|
|
2910
|
+
code: 'FLOW_CANCELLED', reason: 'flow_cancelled',
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
2775
2913
|
flowTerminal = isTerminalFlow(audit?.status);
|
|
2776
2914
|
if (flowTerminal) {
|
|
2777
2915
|
recoverAdvancedConsumerArtifacts({
|
|
2778
|
-
runId:
|
|
2916
|
+
runId: probeFlowId,
|
|
2779
2917
|
targetCwd: agentCwd,
|
|
2780
2918
|
artifactRoot: opts.consumerArtifactsRoot,
|
|
2781
2919
|
audit,
|
|
@@ -2783,7 +2921,10 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2783
2921
|
}
|
|
2784
2922
|
} catch (err) {
|
|
2785
2923
|
if (isRecoverableFlowProbeError(err)) {
|
|
2786
|
-
|
|
2924
|
+
// An unknown flow is only evidence the build is over when its driver is
|
|
2925
|
+
// gone. With a live driver pid it is not evidence of anything, and the
|
|
2926
|
+
// same-feature conflict guard below must still hold (pre-S05 semantics).
|
|
2927
|
+
if (!pidAlive) flowTerminal = true;
|
|
2787
2928
|
} else {
|
|
2788
2929
|
throw err;
|
|
2789
2930
|
}
|
|
@@ -2902,6 +3043,58 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2902
3043
|
if (triageEstimate) emitTriageEstimate(cwd, triageEstimate);
|
|
2903
3044
|
}
|
|
2904
3045
|
|
|
3046
|
+
// COMP-BUILD-CANCEL S03-5b (C36): register the in-process handle the moment the run id
|
|
3047
|
+
// exists — BEFORE the Codex worktree preflight below, which can hold for up to
|
|
3048
|
+
// PROBE_AGENT_TIMEOUT_MS. `active-build.json` already advertises this flow id and pid,
|
|
3049
|
+
// so a same-process abort arriving in that window would otherwise find no handle, fall
|
|
3050
|
+
// through to the foreign-pid branch, and SIGTERM the compose server itself.
|
|
3051
|
+
registeredFlowId = response?.runId ?? null;
|
|
3052
|
+
registerBuildCancel(registeredFlowId, buildCancel);
|
|
3053
|
+
buildIdentity = { featureCode, flowId: registeredFlowId, pid: process.pid,
|
|
3054
|
+
startedAt: readActiveBuild(dataDir)?.startedAt };
|
|
3055
|
+
|
|
3056
|
+
// SIGINT/SIGTERM: cancel the flow, tear down, exit (COMP-BUILD-CANCEL §9). A SECOND
|
|
3057
|
+
// signal during teardown exits immediately — a user pressing Ctrl-C twice is asking to
|
|
3058
|
+
// stop waiting, and the durable state a bounded teardown would have written is worth
|
|
3059
|
+
// less than obeying that.
|
|
3060
|
+
signalHandler = (signal) => {
|
|
3061
|
+
// No local `tearingDown` flag: the handle owns that state (C27), so a teardown
|
|
3062
|
+
// started by any other path is visible here too.
|
|
3063
|
+
if (buildCancel.teardownStarted) { process.exit(signal === 'SIGINT' ? 130 : 143); return; }
|
|
3064
|
+
buildStatus = 'aborted';
|
|
3065
|
+
// ASSIGN, do not fire and forget (C45). The promise is what the outer catch reads to
|
|
3066
|
+
// stand down and what the outermost finally and the CLI join on. `runCancelTeardown`
|
|
3067
|
+
// sets `teardownStarted` synchronously, so a second signal takes the branch above.
|
|
3068
|
+
buildCancel.teardown = runCancelTeardown({
|
|
3069
|
+
buildCancel,
|
|
3070
|
+
signal,
|
|
3071
|
+
flowId: response?.runId,
|
|
3072
|
+
flowCancel: (id) => stratum.flowCancel(id),
|
|
3073
|
+
timeoutMs: cancelTimeoutMs,
|
|
3074
|
+
drainMs: drainTimeoutMs,
|
|
3075
|
+
claimOwnership: () => claimActiveBuild(dataDir, buildIdentity),
|
|
3076
|
+
killVision: () => killOwnedBuildVision(visionWriter, itemId, () => claimActiveBuild(dataDir, buildIdentity)),
|
|
3077
|
+
// The teardown re-claims after vision, immediately before this write (§3.7).
|
|
3078
|
+
writeTerminal: (record) => {
|
|
3079
|
+
writeActiveBuild(dataDir, { ...record, status: 'aborted', completedAt: new Date().toISOString() });
|
|
3080
|
+
},
|
|
3081
|
+
removeListeners: () => {
|
|
3082
|
+
process.removeListener('SIGINT', onSigint);
|
|
3083
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
3084
|
+
},
|
|
3085
|
+
exit: (code) => process.exit(code),
|
|
3086
|
+
// eslint-disable-next-line no-console
|
|
3087
|
+
log: (message) => console.warn(`[build] ${message}`),
|
|
3088
|
+
});
|
|
3089
|
+
};
|
|
3090
|
+
// Named wrappers, because the two existing removeListener sites must remove the same
|
|
3091
|
+
// function references that were registered.
|
|
3092
|
+
const onSigint = () => signalHandler('SIGINT');
|
|
3093
|
+
const onSigterm = () => signalHandler('SIGTERM');
|
|
3094
|
+
signalHandler.listeners = { onSigint, onSigterm };
|
|
3095
|
+
process.on('SIGINT', onSigint);
|
|
3096
|
+
process.on('SIGTERM', onSigterm);
|
|
3097
|
+
|
|
2905
3098
|
// COMP-CODEX-IMPL: verify Codex can write inside a detached git worktree (the
|
|
2906
3099
|
// execute step's isolation primitive) before any step dispatches. Runs on the
|
|
2907
3100
|
// EFFECTIVE role — after resume restoration above — so a resumed Codex build is
|
|
@@ -2922,8 +3115,12 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2922
3115
|
stratum,
|
|
2923
3116
|
dataDir,
|
|
2924
3117
|
ts: new Date().toISOString().replace(/[:.]/g, '-'),
|
|
3118
|
+
// C36/C51: an abort during the probe stops the probe too.
|
|
3119
|
+
signal: buildCancel.signal,
|
|
2925
3120
|
});
|
|
2926
3121
|
if (!probe.ok) {
|
|
3122
|
+
// The signal teardown owns terminal writes; let the catch/finally drain it.
|
|
3123
|
+
if (buildCancel.teardown) throw new Error(codexProbeAbortMessage(probe.reason));
|
|
2927
3124
|
// The plan/resume above already persisted feature.json=IN_PROGRESS and
|
|
2928
3125
|
// active-build.json='running'. A preflight abort throws before the normal
|
|
2929
3126
|
// terminal handlers run, so roll BOTH back here (mirrors the killed/failed
|
|
@@ -2972,14 +3169,6 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
2972
3169
|
specPath: `pipelines/${templateName}.stratum.yaml`,
|
|
2973
3170
|
});
|
|
2974
3171
|
|
|
2975
|
-
// SIGINT/SIGTERM: mark build as killed
|
|
2976
|
-
signalHandler = () => {
|
|
2977
|
-
buildStatus = 'killed';
|
|
2978
|
-
streamWriter.close('killed');
|
|
2979
|
-
};
|
|
2980
|
-
process.on('SIGINT', signalHandler);
|
|
2981
|
-
process.on('SIGTERM', signalHandler);
|
|
2982
|
-
|
|
2983
3172
|
// V4: merge the runtime profile map over the static sidecar (runtime wins).
|
|
2984
3173
|
// Tiers/templates carried by $.input.* agents (e.g. --implementer=claude::critical)
|
|
2985
3174
|
// are stripped to the bare provider at resolution for the engine; recover the
|
|
@@ -3020,6 +3209,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3020
3209
|
const context = {
|
|
3021
3210
|
stratum,
|
|
3022
3211
|
flowId: response.runId,
|
|
3212
|
+
buildCancel,
|
|
3023
3213
|
receiptsMode,
|
|
3024
3214
|
cwd: agentCwd,
|
|
3025
3215
|
projectCwd: cwd,
|
|
@@ -3207,6 +3397,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3207
3397
|
return runConsumerIssuance({
|
|
3208
3398
|
descriptor,
|
|
3209
3399
|
flowId,
|
|
3400
|
+
buildCancel,
|
|
3210
3401
|
stratum,
|
|
3211
3402
|
artifacts,
|
|
3212
3403
|
audit,
|
|
@@ -3224,7 +3415,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3224
3415
|
};
|
|
3225
3416
|
|
|
3226
3417
|
const launchPendingConsumers = () => {
|
|
3227
|
-
while (!consumerFatalError
|
|
3418
|
+
while (!consumerFatalError && !buildCancel.cancelled
|
|
3228
3419
|
&& consumerInFlight.size < consumerConcurrency
|
|
3229
3420
|
&& consumerPending.length > 0) {
|
|
3230
3421
|
const work = consumerPending.shift();
|
|
@@ -3286,6 +3477,10 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3286
3477
|
let current = engineResponse;
|
|
3287
3478
|
let latestNonReady = current?.status !== 'ready' ? current : null;
|
|
3288
3479
|
while (true) {
|
|
3480
|
+
if (buildCancel.cancelled) {
|
|
3481
|
+
consumerFatalError ??= buildCancel.signal.reason;
|
|
3482
|
+
await drainConsumerFatal();
|
|
3483
|
+
}
|
|
3289
3484
|
let ordinary;
|
|
3290
3485
|
try {
|
|
3291
3486
|
ordinary = await enqueueConsumerReady(current);
|
|
@@ -3331,7 +3526,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3331
3526
|
throw error;
|
|
3332
3527
|
};
|
|
3333
3528
|
try {
|
|
3334
|
-
while (!isTerminalFlow(response.status)) {
|
|
3529
|
+
while (!isTerminalFlow(response.status) && !buildCancel.cancelled) {
|
|
3335
3530
|
if (response?.revisionDigest) runRevisionDigest = response.revisionDigest;
|
|
3336
3531
|
response = await mergeConsumerReady(response);
|
|
3337
3532
|
const readyStep = response.status === 'ready' ? response.ready?.[0] : null;
|
|
@@ -3479,8 +3674,8 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3479
3674
|
const shipStepResult = response.status === 'ready'
|
|
3480
3675
|
? { output: tsShipOutput }
|
|
3481
3676
|
: shipResult;
|
|
3482
|
-
response = await stratum
|
|
3483
|
-
|
|
3677
|
+
response = await callFlowWithCancellation(stratum, 'stepDone', flowId, buildCancel,
|
|
3678
|
+
stepId, shipStepResult, readyStep?.dispatchToken,
|
|
3484
3679
|
);
|
|
3485
3680
|
streamWriter.write({
|
|
3486
3681
|
type: 'build_step_done',
|
|
@@ -3519,6 +3714,9 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3519
3714
|
profile: resolveStepProfile(context.stepProfiles, 'fix')
|
|
3520
3715
|
?? resolveStepProfile(context.stepProfiles, stepId),
|
|
3521
3716
|
sandboxMode: 'workspace-write',
|
|
3717
|
+
...(flowTag(flowId, stepId) ? { flow: flowTag(flowId, stepId) } : {}),
|
|
3718
|
+
flowId, buildCancel,
|
|
3719
|
+
buildSignal: buildCancel.signal,
|
|
3522
3720
|
telemetry: {
|
|
3523
3721
|
site: 'review-repair',
|
|
3524
3722
|
project_cwd: cwd,
|
|
@@ -3538,6 +3736,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3538
3736
|
} catch { /* fail-open */ }
|
|
3539
3737
|
}
|
|
3540
3738
|
} catch (err) {
|
|
3739
|
+
if (buildCancel.cancelled) throw err;
|
|
3541
3740
|
if (err?.usage && typeof context.recordBuildUsage === 'function') {
|
|
3542
3741
|
try {
|
|
3543
3742
|
await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
|
|
@@ -3603,6 +3802,9 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3603
3802
|
reviewMode: isReviewMain,
|
|
3604
3803
|
confidenceGate: confGateMain,
|
|
3605
3804
|
lens: response.inputs?.lens_name ?? response.lens_name ?? 'general',
|
|
3805
|
+
...(flowTag(flowId, stepId) ? { flow: flowTag(flowId, stepId) } : {}),
|
|
3806
|
+
flowId, buildCancel,
|
|
3807
|
+
buildSignal: buildCancel.signal,
|
|
3606
3808
|
telemetry: {
|
|
3607
3809
|
site: isReviewMain ? 'review' : 'build-step',
|
|
3608
3810
|
project_cwd: cwd,
|
|
@@ -3617,6 +3819,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3617
3819
|
profile: resolveStepProfile(effectiveProfiles, stepId),
|
|
3618
3820
|
});
|
|
3619
3821
|
} catch (err) {
|
|
3822
|
+
if (buildCancel.cancelled) throw err;
|
|
3620
3823
|
const failedUsage = failureUsageFields(err);
|
|
3621
3824
|
if (err instanceof UserInterruptError) {
|
|
3622
3825
|
if (err.action === 'skip') {
|
|
@@ -3692,6 +3895,9 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3692
3895
|
reviewMode: isReviewMain,
|
|
3693
3896
|
confidenceGate: confGateMain,
|
|
3694
3897
|
profile: resolveStepProfile(effectiveProfiles, stepId),
|
|
3898
|
+
...(flowTag(flowId, stepId) ? { flow: flowTag(flowId, stepId) } : {}),
|
|
3899
|
+
flowId, buildCancel,
|
|
3900
|
+
buildSignal: buildCancel.signal,
|
|
3695
3901
|
telemetry: {
|
|
3696
3902
|
site: 'policy-revision',
|
|
3697
3903
|
project_cwd: cwd,
|
|
@@ -3739,6 +3945,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3739
3945
|
}
|
|
3740
3946
|
// The second result stands either way — no further passes.
|
|
3741
3947
|
} catch (err) {
|
|
3948
|
+
if (buildCancel.cancelled) throw err;
|
|
3742
3949
|
if (err?.usage && typeof context.recordBuildUsage === 'function') {
|
|
3743
3950
|
try {
|
|
3744
3951
|
await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
|
|
@@ -3942,8 +4149,8 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
3942
4149
|
});
|
|
3943
4150
|
streamWriter.writeUsage(stepId, stepUsage);
|
|
3944
4151
|
}
|
|
3945
|
-
response = await stratum
|
|
3946
|
-
|
|
4152
|
+
response = await callFlowWithCancellation(stratum, 'stepDone', flowId, buildCancel,
|
|
4153
|
+
stepId, stepDoneResult, readyStep?.dispatchToken,
|
|
3947
4154
|
);
|
|
3948
4155
|
{
|
|
3949
4156
|
const isEnsureRetry = responseReissuesStep(response, stepId);
|
|
@@ -4187,6 +4394,21 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4187
4394
|
}
|
|
4188
4395
|
|
|
4189
4396
|
const resolveGateWithConsumerMerge = async (requestedOutcome, requestedRationale, resolvedBy) => {
|
|
4397
|
+
let mergeApplied = false;
|
|
4398
|
+
const reverseCancelledMerge = () => {
|
|
4399
|
+
buildCancel.cancel('flow_cancelled');
|
|
4400
|
+
try {
|
|
4401
|
+
consumerMergeArtifacts.restoreMergeBaseline(consumerMergeTransaction, gateAudit, { reason: 'cancelled' });
|
|
4402
|
+
} catch (revertError) {
|
|
4403
|
+
// #mutate cannot persist anything when the restore callback throws.
|
|
4404
|
+
// This separate mutation is the durable indeterminate-tree finding.
|
|
4405
|
+
let journalFailure = '';
|
|
4406
|
+
try { consumerMergeArtifacts.markRollbackFailed(consumerMergeTransaction, revertError); }
|
|
4407
|
+
catch (error) { journalFailure = `; rollback failure journal also failed: ${error.message}`; }
|
|
4408
|
+
throw new MergeAfterCancelError(`merge reversal FAILED after cancel; working tree is indeterminate: ${revertError.message}${journalFailure}`);
|
|
4409
|
+
}
|
|
4410
|
+
throw new MergeAfterCancelError('merge reversed: the run was cancelled while the merge was applying');
|
|
4411
|
+
};
|
|
4190
4412
|
let outcome = requestedOutcome;
|
|
4191
4413
|
let rationale = requestedRationale;
|
|
4192
4414
|
const repairOutcome = gateStep?.gate?.on_revise ? 'revise' : 'kill';
|
|
@@ -4220,8 +4442,14 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4220
4442
|
if (consumerMergePreparationError) {
|
|
4221
4443
|
repairFor(consumerMergePreparationError);
|
|
4222
4444
|
} else {
|
|
4445
|
+
// Both fences are outside the repairFor catch: cancellation cannot revise.
|
|
4446
|
+
if (buildCancel.cancelled) {
|
|
4447
|
+
streamWriter.write({ type: 'build_note', note: `merge skipped: build cancelled (${buildCancel.reason})`, flowId });
|
|
4448
|
+
throw new MergeAfterCancelError(`merge refused: build was cancelled at ${buildCancel.at}`);
|
|
4449
|
+
}
|
|
4223
4450
|
try {
|
|
4224
4451
|
await consumerMergeArtifacts.applyMerge(consumerMergeTransaction);
|
|
4452
|
+
mergeApplied = true;
|
|
4225
4453
|
try {
|
|
4226
4454
|
const changed = execSync(
|
|
4227
4455
|
'git diff --name-only HEAD; git ls-files --others --exclude-standard',
|
|
@@ -4238,6 +4466,10 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4238
4466
|
if (!(error instanceof ConsumerMergeDecisionError)) throw error;
|
|
4239
4467
|
repairFor(error);
|
|
4240
4468
|
}
|
|
4469
|
+
const runCancelled = mergeApplied && !buildCancel.cancelled && await isRunCancelled(stratum, flowId);
|
|
4470
|
+
if (mergeApplied && (buildCancel.cancelled || runCancelled)) {
|
|
4471
|
+
reverseCancelledMerge();
|
|
4472
|
+
}
|
|
4241
4473
|
}
|
|
4242
4474
|
}
|
|
4243
4475
|
// Seam between a completed applyMerge (issuances now journaled `merged`)
|
|
@@ -4257,9 +4489,17 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4257
4489
|
consumerMergeArtifacts.restoreMergeBaseline(consumerMergeTransaction, gateAudit);
|
|
4258
4490
|
}
|
|
4259
4491
|
|
|
4260
|
-
let next
|
|
4261
|
-
|
|
4262
|
-
|
|
4492
|
+
let next;
|
|
4493
|
+
try {
|
|
4494
|
+
next = await callFlowWithCancellation(stratum, 'gateResolve', flowId, buildCancel,
|
|
4495
|
+
stepId, outcome, rationale, resolvedBy, gateToken,
|
|
4496
|
+
);
|
|
4497
|
+
if (next.status === 'cancelled') buildCancel.cancel('flow_cancelled');
|
|
4498
|
+
if (buildCancel.cancelled) throw buildCancel.signal.reason;
|
|
4499
|
+
} catch (error) {
|
|
4500
|
+
if (mergeApplied && buildCancel.cancelled) reverseCancelledMerge();
|
|
4501
|
+
throw error;
|
|
4502
|
+
}
|
|
4263
4503
|
if (consumerMergeArtifacts) {
|
|
4264
4504
|
if (typeof consumerMergeArtifacts.hooks.afterGateResolve === 'function') {
|
|
4265
4505
|
await consumerMergeArtifacts.hooks.afterGateResolve({
|
|
@@ -4270,6 +4510,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4270
4510
|
response: next,
|
|
4271
4511
|
});
|
|
4272
4512
|
}
|
|
4513
|
+
if (mergeApplied && buildCancel.cancelled) reverseCancelledMerge();
|
|
4273
4514
|
consumerMergeArtifacts.markGateResolved(consumerMergeTransaction, outcome);
|
|
4274
4515
|
if (outcome === 'approve' || outcome === 'kill' || isTerminalFlow(next.status)) {
|
|
4275
4516
|
consumerMergeArtifacts.cleanupWorktrees(
|
|
@@ -4374,6 +4615,12 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4374
4615
|
const gateFixResult = await runAndNormalize(undefined, fixPrompt, { step_id: 'review_fix', agent: fixAgent, flow_id: flowId }, {
|
|
4375
4616
|
progress, streamWriter, maxDurationMs: STEP_TIMEOUT_MS.review_merge ?? DEFAULT_TIMEOUT_MS,
|
|
4376
4617
|
stratum, cwd: agentCwd, sandboxMode: 'workspace-write', profile: resolveStepProfile(effectiveProfiles, 'fix'),
|
|
4618
|
+
// S03-4: tag with `gateStepId`, not the literal `review_fix` this dispatch
|
|
4619
|
+
// carries as its step_id — the run's real step is the gate, and flow.stepId
|
|
4620
|
+
// is audit decoration (the server validates it against nothing).
|
|
4621
|
+
...(flowTag(flowId, gateStepId) ? { flow: flowTag(flowId, gateStepId) } : {}),
|
|
4622
|
+
flowId, buildCancel,
|
|
4623
|
+
buildSignal: buildCancel.signal,
|
|
4377
4624
|
telemetry: {
|
|
4378
4625
|
site: 'review-repair',
|
|
4379
4626
|
project_cwd: cwd,
|
|
@@ -4393,6 +4640,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4393
4640
|
} catch { /* fail-open */ }
|
|
4394
4641
|
}
|
|
4395
4642
|
} catch (err) {
|
|
4643
|
+
if (buildCancel.cancelled) throw err;
|
|
4396
4644
|
if (err?.usage && typeof context.recordBuildUsage === 'function') {
|
|
4397
4645
|
try {
|
|
4398
4646
|
await context.recordBuildUsage(usagePayload(err.usage, err.usages), {
|
|
@@ -4482,13 +4730,20 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4482
4730
|
if (serverUp) {
|
|
4483
4731
|
gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate', round });
|
|
4484
4732
|
console.log('Gate delegated to web UI. Waiting for resolution...');
|
|
4485
|
-
|
|
4733
|
+
let resolved;
|
|
4734
|
+
try {
|
|
4735
|
+
resolved = await pollGateResolution(visionWriter, gateId, 2000, buildCancel.signal);
|
|
4736
|
+
} catch (error) {
|
|
4737
|
+
await confirmCancellation(error, { stratum, flowId, buildCancel });
|
|
4738
|
+
throw error;
|
|
4739
|
+
}
|
|
4486
4740
|
if (resolved) {
|
|
4487
4741
|
outcome = resolved.outcome;
|
|
4488
4742
|
rationale = resolved.comment ?? '';
|
|
4489
4743
|
} else {
|
|
4490
4744
|
const result = await promptGate(gateDispatch, {
|
|
4491
4745
|
...(opts.gateOpts ?? {}),
|
|
4746
|
+
signal: buildCancel.signal,
|
|
4492
4747
|
artifact: context.cwd,
|
|
4493
4748
|
askAgent,
|
|
4494
4749
|
gateExtras,
|
|
@@ -4500,6 +4755,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4500
4755
|
gateId = await visionWriter.createGate(flowId, stepId, itemId, { ...gateExtras, policyMode: 'gate', round });
|
|
4501
4756
|
const result = await promptGate(gateDispatch, {
|
|
4502
4757
|
...(opts.gateOpts ?? {}),
|
|
4758
|
+
signal: buildCancel.signal,
|
|
4503
4759
|
artifact: context.cwd,
|
|
4504
4760
|
askAgent,
|
|
4505
4761
|
gateExtras,
|
|
@@ -4566,16 +4822,26 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4566
4822
|
consumerArtifacts.cleanupWorktrees('run terminalized');
|
|
4567
4823
|
}
|
|
4568
4824
|
|
|
4825
|
+
if (response.status === 'cancelled') buildCancel.cancel('flow_cancelled');
|
|
4826
|
+
|
|
4569
4827
|
// Flow complete — write terminal state (file retained per STRAT-COMP-4 contract).
|
|
4570
4828
|
// COMP-COMPLETION-GATE slice 2: set when the flow completed and the feature is
|
|
4571
4829
|
// eligible to be completed — the actual completion runs after the health gate.
|
|
4572
4830
|
let pendingCompletion = null;
|
|
4573
|
-
if (response.status === 'completed') buildStatus = 'complete';
|
|
4574
|
-
if ((response.status === 'failed' || response.status === 'budget_exhausted') && !killedByGate) {
|
|
4831
|
+
if (!buildCancel.cancelled && response.status === 'completed') buildStatus = 'complete';
|
|
4832
|
+
if (!buildCancel.cancelled && (response.status === 'failed' || response.status === 'budget_exhausted') && !killedByGate) {
|
|
4575
4833
|
buildStatus = 'failed';
|
|
4576
4834
|
terminalFailureReason = response.failure?.reason ?? null;
|
|
4577
4835
|
}
|
|
4578
|
-
if (
|
|
4836
|
+
if (buildCancel.teardown) {
|
|
4837
|
+
// A successful final stepDone can race SIGINT. The teardown still owns
|
|
4838
|
+
// terminal state; retain aborted for history, stream closure and actuals.
|
|
4839
|
+
buildStatus = 'aborted';
|
|
4840
|
+
} else if (buildCancel.cancelled) {
|
|
4841
|
+
buildStatus = 'aborted';
|
|
4842
|
+
terminalFailureReason = buildCancel.reason;
|
|
4843
|
+
await terminalizeCancelledBuild({ dataDir, buildIdentity, visionWriter, itemId, failureReason: buildCancel.reason });
|
|
4844
|
+
} else if (response.status === 'completed' && buildStatus === 'complete') {
|
|
4579
4845
|
console.log('\nBuild complete.');
|
|
4580
4846
|
// COMP-COMPLETION-GATE slice 2: the COMPLETION does not happen here.
|
|
4581
4847
|
//
|
|
@@ -4640,9 +4906,13 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4640
4906
|
// for the history record — health-gate failures have no failed step, so
|
|
4641
4907
|
// the lastFailedStep-derived failureReason would otherwise be generic.
|
|
4642
4908
|
let healthDowngradeReason = null;
|
|
4643
|
-
if (streamWriter) {
|
|
4909
|
+
if (streamWriter && !buildCancel.teardown && !buildCancel.cancelled) {
|
|
4910
|
+
const checkHealthOwnership = () => {
|
|
4911
|
+
if (buildCancel.teardown || buildCancel.cancelled) throw buildCancel.signal.reason;
|
|
4912
|
+
};
|
|
4644
4913
|
try {
|
|
4645
4914
|
// Runtime errors signal — accumulated across all steps
|
|
4915
|
+
checkHealthOwnership();
|
|
4646
4916
|
if (allViolations.length > 0) {
|
|
4647
4917
|
buildSignals.runtime_errors = allViolations;
|
|
4648
4918
|
} else if (!buildSignals.runtime_errors) {
|
|
@@ -4657,6 +4927,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4657
4927
|
// chain + mtimes, so it needs no marker to be written first.
|
|
4658
4928
|
try {
|
|
4659
4929
|
const { findStaleArtifacts } = await import('./lineage.js');
|
|
4930
|
+
checkHealthOwnership();
|
|
4660
4931
|
buildSignals.doc_freshness = findStaleArtifacts(resolveItemDir(featureCode));
|
|
4661
4932
|
} catch { /* staleness check is optional — skip on error */ }
|
|
4662
4933
|
|
|
@@ -4669,6 +4940,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4669
4940
|
// lifecycle-writer surface that materialises wasGeneratedBy/wasDerivedFrom.
|
|
4670
4941
|
try {
|
|
4671
4942
|
const { stampFeatureLineage } = await import('./lineage.js');
|
|
4943
|
+
checkHealthOwnership();
|
|
4672
4944
|
stampFeatureLineage(resolveItemDir(featureCode));
|
|
4673
4945
|
} catch { /* lineage stamping is optional — skip on error */ }
|
|
4674
4946
|
|
|
@@ -4688,10 +4960,12 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4688
4960
|
);
|
|
4689
4961
|
|
|
4690
4962
|
// Emit to build stream
|
|
4963
|
+
checkHealthOwnership();
|
|
4691
4964
|
streamWriter.writeHealthScore(score, breakdown, missing);
|
|
4692
4965
|
|
|
4693
4966
|
// Persist to history
|
|
4694
4967
|
try {
|
|
4968
|
+
checkHealthOwnership();
|
|
4695
4969
|
recordScore(cwd, { featureCode, phase: buildStatus, score, breakdown });
|
|
4696
4970
|
} catch (err) {
|
|
4697
4971
|
console.warn(`[health] Failed to persist score: ${err.message}`);
|
|
@@ -4701,6 +4975,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4701
4975
|
// If health score is below the configured threshold, mark the build as failed
|
|
4702
4976
|
// so downstream consumers (vision item status, exit code) reflect the rejection.
|
|
4703
4977
|
const threshold = healthSettings.gate_threshold;
|
|
4978
|
+
checkHealthOwnership();
|
|
4704
4979
|
if (typeof threshold === 'number' && score < threshold) {
|
|
4705
4980
|
streamWriter.write({
|
|
4706
4981
|
type: 'gate_health_rejection',
|
|
@@ -4711,12 +4986,14 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4711
4986
|
});
|
|
4712
4987
|
console.warn(` [health] Build health score ${score} is below gate threshold ${threshold} — marking build as failed`);
|
|
4713
4988
|
// Enforce: downgrade build status so the build is reported as failed
|
|
4989
|
+
checkHealthOwnership();
|
|
4714
4990
|
buildStatus = 'failed';
|
|
4715
4991
|
// COMP-MOBILE-1-1: re-persist the downgrade to active-build.json so the
|
|
4716
4992
|
// file watcher re-broadcasts buildState over /ws/vision. Without this,
|
|
4717
4993
|
// the terminal write above already said 'complete' and clients never
|
|
4718
4994
|
// learn the build actually failed (mobile compensated via history).
|
|
4719
4995
|
// Identity-guarded: no-ops if a concurrent build replaced the file.
|
|
4996
|
+
checkHealthOwnership();
|
|
4720
4997
|
const downgraded = persistHealthGateDowngrade(dataDir, {
|
|
4721
4998
|
score,
|
|
4722
4999
|
threshold,
|
|
@@ -4730,7 +5007,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4730
5007
|
console.log(` Health score: ${score}/100 (${Object.keys(breakdown).length} dimensions scored)`);
|
|
4731
5008
|
} catch (err) {
|
|
4732
5009
|
// Non-fatal — health scoring never blocks the build
|
|
4733
|
-
console.warn(`[health] Score computation failed: ${err.message}`);
|
|
5010
|
+
if (!buildCancel.teardown && !buildCancel.cancelled) console.warn(`[health] Score computation failed: ${err.message}`);
|
|
4734
5011
|
}
|
|
4735
5012
|
}
|
|
4736
5013
|
|
|
@@ -4741,7 +5018,7 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4741
5018
|
// a health-rejected build completes nothing: no completion record, no
|
|
4742
5019
|
// COMPLETE status, no vision completion, no guard transition.
|
|
4743
5020
|
// ---------------------------------------------------------------------
|
|
4744
|
-
if (pendingCompletion && buildStatus === 'complete') {
|
|
5021
|
+
if (pendingCompletion && !buildCancel.teardown && !buildCancel.cancelled && buildStatus === 'complete') {
|
|
4745
5022
|
const ev = context.completionEvidence || {};
|
|
4746
5023
|
const acc = readBuildAccumulator(cwd, featureCode);
|
|
4747
5024
|
// Persisted, because it survives a resume; the in-memory value wins when
|
|
@@ -4829,6 +5106,10 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4829
5106
|
// the COMP-HEALTH gate above may have downgraded buildStatus to 'failed'.
|
|
4830
5107
|
// Assembled from the in-memory build context for THIS run (never re-read
|
|
4831
5108
|
// active-build.json, which is last-writer-wins across concurrent builds).
|
|
5109
|
+
if (buildCancel.teardown || buildCancel.cancelled) {
|
|
5110
|
+
buildStatus = 'aborted';
|
|
5111
|
+
terminalFailureReason = buildCancel.reason;
|
|
5112
|
+
}
|
|
4832
5113
|
if (['complete', 'aborted', 'failed', 'killed'].includes(buildStatus)) {
|
|
4833
5114
|
const failureReason = buildFailureReason({
|
|
4834
5115
|
buildStatus,
|
|
@@ -4929,10 +5210,18 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4929
5210
|
// File retained on disk per STRAT-COMP-4 — overwritten on next build start
|
|
4930
5211
|
|
|
4931
5212
|
} catch (err) {
|
|
4932
|
-
|
|
4933
|
-
|
|
5213
|
+
// COMP-BUILD-CANCEL §3.6: a pending teardown is the single terminal owner. Stand down —
|
|
5214
|
+
// do NOT terminalize (two writers, one record) and do NOT await it (the teardown is
|
|
5215
|
+
// waiting on `drained`, which this rethrow is what releases).
|
|
5216
|
+
if (buildCancel.teardown) {
|
|
5217
|
+
buildStatus = 'aborted';
|
|
5218
|
+
throw err;
|
|
5219
|
+
}
|
|
5220
|
+
buildStatus = buildCancel.cancelled ? 'aborted' : 'failed';
|
|
5221
|
+
const failureReason = buildCancel.cancelled ? buildCancel.reason : (err?.message ?? 'Build failed');
|
|
4934
5222
|
try {
|
|
4935
5223
|
await terminalizeThrownBuild({
|
|
5224
|
+
buildCancel, buildIdentity,
|
|
4936
5225
|
cwd,
|
|
4937
5226
|
dataDir,
|
|
4938
5227
|
cfg,
|
|
@@ -4963,13 +5252,26 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4963
5252
|
if (streamWriter) {
|
|
4964
5253
|
streamWriter.close(buildStatus, buildCostTotals);
|
|
4965
5254
|
}
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
5255
|
+
// §3.6: the teardown removes the listeners itself, AFTER its writes. Removing them here
|
|
5256
|
+
// while it is pending would send a second Ctrl-C to the default handler, killing the
|
|
5257
|
+
// process mid-write.
|
|
5258
|
+
if (signalHandler && !buildCancel.teardown) {
|
|
5259
|
+
process.removeListener('SIGINT', signalHandler.listeners.onSigint);
|
|
5260
|
+
process.removeListener('SIGTERM', signalHandler.listeners.onSigterm);
|
|
4969
5261
|
}
|
|
4970
5262
|
progress.finish();
|
|
4971
|
-
|
|
5263
|
+
// A pending teardown may still have a `flowCancel` in flight over this client; closing it
|
|
5264
|
+
// underneath would abandon the cancel. The teardown exits the process, so the deferred
|
|
5265
|
+
// close is a courtesy, not a leak.
|
|
5266
|
+
if (buildCancel.teardown) {
|
|
5267
|
+
void buildCancel.teardown.then(() => stratum.close()).catch(() => undefined);
|
|
5268
|
+
} else {
|
|
5269
|
+
await stratum.close();
|
|
5270
|
+
}
|
|
4972
5271
|
runtimeResourcesFinalized = true;
|
|
5272
|
+
// §3.6: release the teardown's bounded wait — this build's resources are now closed and
|
|
5273
|
+
// finalizeBuildAttempt has emitted its actuals, so the teardown's writes cannot race it.
|
|
5274
|
+
buildCancel.resolveDrained();
|
|
4973
5275
|
if (actualsError) throw actualsError;
|
|
4974
5276
|
}
|
|
4975
5277
|
} finally {
|
|
@@ -4979,15 +5281,30 @@ export async function runBuild(featureCode, opts = {}) {
|
|
|
4979
5281
|
try {
|
|
4980
5282
|
if (!runtimeResourcesFinalized) {
|
|
4981
5283
|
if (streamWriter) streamWriter.close(buildStatus);
|
|
4982
|
-
if (signalHandler) {
|
|
4983
|
-
process.removeListener('SIGINT', signalHandler);
|
|
4984
|
-
process.removeListener('SIGTERM', signalHandler);
|
|
5284
|
+
if (signalHandler && !buildCancel.teardown) {
|
|
5285
|
+
process.removeListener('SIGINT', signalHandler.listeners.onSigint);
|
|
5286
|
+
process.removeListener('SIGTERM', signalHandler.listeners.onSigterm);
|
|
4985
5287
|
}
|
|
4986
5288
|
progress?.finish();
|
|
4987
|
-
if (stratum) await stratum.close();
|
|
5289
|
+
if (stratum && !buildCancel.teardown) await stratum.close();
|
|
4988
5290
|
runtimeResourcesFinalized = true;
|
|
5291
|
+
buildCancel.resolveDrained();
|
|
5292
|
+
}
|
|
5293
|
+
// §3.6: join the teardown so the CLI cannot exit out from under it. The bound is
|
|
5294
|
+
// DERIVED from the teardown's own two deadlines (C46), never chosen.
|
|
5295
|
+
if (buildCancel.teardown) {
|
|
5296
|
+
await withDeadline(buildCancel.teardown, teardownJoinMs).catch(() => undefined);
|
|
4989
5297
|
}
|
|
4990
5298
|
} finally {
|
|
5299
|
+
// S03-5b: a crashed build must leave no stale handle behind.
|
|
5300
|
+
// A join deadline is not settlement. Keep the handle visible to the CLI
|
|
5301
|
+
// until teardown finishes, even when this outer finally stops waiting.
|
|
5302
|
+
if (buildCancel.teardown) {
|
|
5303
|
+
const unregister = () => unregisterBuildCancel(registeredFlowId);
|
|
5304
|
+
void buildCancel.teardown.then(unregister, unregister);
|
|
5305
|
+
} else {
|
|
5306
|
+
unregisterBuildCancel(registeredFlowId);
|
|
5307
|
+
}
|
|
4991
5308
|
// COMP-MCP-MIGRATION-1: restore COMPOSE_BUILD_ID env to its prior value
|
|
4992
5309
|
// (or unset) so subsequent processes / tests don't inherit a stale UUID.
|
|
4993
5310
|
_restoreBuildIdEnv();
|
|
@@ -5654,18 +5971,22 @@ function syncStepHistory(dataDir, stepHistory) {
|
|
|
5654
5971
|
* @param {VisionWriter} visionWriter
|
|
5655
5972
|
* @param {string} gateId
|
|
5656
5973
|
* @param {number} [intervalMs=2000]
|
|
5974
|
+
* @param {AbortSignal} [signal]
|
|
5657
5975
|
* @returns {Promise<object|null>} resolved gate or null (server lost mid-poll)
|
|
5658
5976
|
*/
|
|
5659
|
-
async function pollGateResolution(visionWriter, gateId, intervalMs = 2000) {
|
|
5977
|
+
export async function pollGateResolution(visionWriter, gateId, intervalMs = 2000, signal) {
|
|
5660
5978
|
let consecutiveFailures = 0;
|
|
5661
5979
|
while (true) {
|
|
5980
|
+
signal?.throwIfAborted();
|
|
5662
5981
|
try {
|
|
5663
5982
|
const gate = await visionWriter.getGate(gateId, { requireServer: true });
|
|
5983
|
+
signal?.throwIfAborted();
|
|
5664
5984
|
consecutiveFailures = 0;
|
|
5665
5985
|
if (!gate) throw new Error(`Gate ${gateId} not found (404)`);
|
|
5666
5986
|
if (gate.status === 'expired') throw new Error(`Gate ${gateId} expired`);
|
|
5667
5987
|
if (gate.status !== 'pending') return gate;
|
|
5668
5988
|
} catch (err) {
|
|
5989
|
+
signal?.throwIfAborted();
|
|
5669
5990
|
if (err instanceof ServerUnreachableError) {
|
|
5670
5991
|
consecutiveFailures++;
|
|
5671
5992
|
if (consecutiveFailures >= 3) {
|
|
@@ -5676,7 +5997,7 @@ async function pollGateResolution(visionWriter, gateId, intervalMs = 2000) {
|
|
|
5676
5997
|
throw err;
|
|
5677
5998
|
}
|
|
5678
5999
|
}
|
|
5679
|
-
await
|
|
6000
|
+
await sleep(intervalMs, undefined, { signal });
|
|
5680
6001
|
}
|
|
5681
6002
|
}
|
|
5682
6003
|
|
|
@@ -5796,51 +6117,195 @@ function loadFeatureDescription(featureDir, featureCode) {
|
|
|
5796
6117
|
return featureCode;
|
|
5797
6118
|
}
|
|
5798
6119
|
|
|
6120
|
+
const ABORT_TERMINAL_STATUSES = new Set(['complete', 'aborted', 'killed', 'failed']);
|
|
6121
|
+
|
|
6122
|
+
function abortSetting(name, fallback) {
|
|
6123
|
+
const value = Number(process.env[name] ?? fallback);
|
|
6124
|
+
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
|
|
6125
|
+
}
|
|
6126
|
+
|
|
6127
|
+
function abortRefusal(error) {
|
|
6128
|
+
if (['CANCELLATION_UNCONFIRMED', 'CANCELLATION_TEARDOWN_TIMEOUT', 'FLOW_NOT_FOUND'].includes(error?.code)) return error;
|
|
6129
|
+
if (isUnknownFlowError(error)) {
|
|
6130
|
+
return { code: 'FLOW_NOT_FOUND', reason: 'flow_not_found', status: null, flowSettled: false, agents: null };
|
|
6131
|
+
}
|
|
6132
|
+
return asTransportRefusal(error);
|
|
6133
|
+
}
|
|
6134
|
+
|
|
6135
|
+
function reportAbort(result) {
|
|
6136
|
+
console.log(`Flow status: ${result.status ?? 'unknown'} (settled: ${result.flowSettled}).`);
|
|
6137
|
+
if (result.reason) console.log(`Reason: ${result.reason}${result.holderPid == null ? '' : `; holderPid: ${result.holderPid}`}.`);
|
|
6138
|
+
for (const [counter, count] of Object.entries(result.agents ?? {})) {
|
|
6139
|
+
if (count > 0) console.log(`Agents ${counter}: ${count}.`);
|
|
6140
|
+
}
|
|
6141
|
+
console.log(`Driver: ${result.driverMode}; signalled: ${result.driverSignalled}; exited: ${result.driverExited}; terminal writer: ${result.terminalWriter}.`);
|
|
6142
|
+
console.log(result.ok ? 'Build aborted.' : `Build NOT aborted — ${result.reason}.`);
|
|
6143
|
+
return result;
|
|
6144
|
+
}
|
|
6145
|
+
|
|
6146
|
+
/** Retry only pre-settle lock refusals; a settled timeout gets one idempotent sweep. */
|
|
6147
|
+
async function cancelAbortFlow(stratum, flowId, result) {
|
|
6148
|
+
const retries = abortSetting('COMPOSE_ABORT_RETRIES', 2);
|
|
6149
|
+
let lockRetries = 0;
|
|
6150
|
+
let reswept = false;
|
|
6151
|
+
let settled = null;
|
|
6152
|
+
for (;;) {
|
|
6153
|
+
result.attempts++;
|
|
6154
|
+
let outcome;
|
|
6155
|
+
try {
|
|
6156
|
+
outcome = await stratum.flowCancel(flowId);
|
|
6157
|
+
} catch (error) {
|
|
6158
|
+
outcome = abortRefusal(error);
|
|
6159
|
+
}
|
|
6160
|
+
// Once durable settlement is known, a later broken pipe cannot undo it. Keep
|
|
6161
|
+
// the last settled counters/status and report the failed re-sweep separately.
|
|
6162
|
+
if (settled && outcome.flowSettled !== true) {
|
|
6163
|
+
console.log(`Re-sweep failed: ${outcome.reason ?? outcome.code ?? 'transport'}.`);
|
|
6164
|
+
return settled;
|
|
6165
|
+
}
|
|
6166
|
+
if (outcome.flowSettled === true) {
|
|
6167
|
+
settled = outcome;
|
|
6168
|
+
if (outcome.code === 'CANCELLATION_TEARDOWN_TIMEOUT' && !reswept) {
|
|
6169
|
+
reswept = true;
|
|
6170
|
+
console.log('Flow settled; re-sweeping agent teardown once.');
|
|
6171
|
+
continue;
|
|
6172
|
+
}
|
|
6173
|
+
return outcome;
|
|
6174
|
+
}
|
|
6175
|
+
if (outcome.code === 'CANCELLATION_UNCONFIRMED' && outcome.reason === 'run_lock_held'
|
|
6176
|
+
&& Object.values(outcome.agents ?? {}).every(count => count === 0) && lockRetries < retries) {
|
|
6177
|
+
lockRetries++;
|
|
6178
|
+
console.log(`Cancel lock held${outcome.holderPid == null ? '' : ` by ${outcome.holderPid}`}; retry ${lockRetries}/${retries}.`);
|
|
6179
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
6180
|
+
continue;
|
|
6181
|
+
}
|
|
6182
|
+
return outcome;
|
|
6183
|
+
}
|
|
6184
|
+
}
|
|
6185
|
+
|
|
6186
|
+
/** The driver owns all terminal writes while it can still finish them itself. */
|
|
6187
|
+
async function waitForAbortDriver(dataDir, active, handle) {
|
|
6188
|
+
const deadline = Date.now() + abortSetting('COMPOSE_ABORT_DRIVER_WAIT_MS', 20000);
|
|
6189
|
+
for (;;) {
|
|
6190
|
+
const claim = claimActiveBuild(dataDir, active);
|
|
6191
|
+
if (!claim.ok) return claim;
|
|
6192
|
+
if (ABORT_TERMINAL_STATUSES.has(claim.record.status)
|
|
6193
|
+
&& (!handle || !lookupBuildCancel(active.flowId))) {
|
|
6194
|
+
return { ...claim, exited: true };
|
|
6195
|
+
}
|
|
6196
|
+
const remaining = deadline - Date.now();
|
|
6197
|
+
if (remaining <= 0) return { ...claim, exited: false };
|
|
6198
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(50, remaining)));
|
|
6199
|
+
}
|
|
6200
|
+
}
|
|
6201
|
+
|
|
5799
6202
|
export async function abortBuild(dataDir, featureCode, cwd, opts = {}) {
|
|
5800
6203
|
const active = readActiveBuild(dataDir);
|
|
6204
|
+
const result = {
|
|
6205
|
+
ok: false, flowId: active?.flowId ?? null, status: null, flowSettled: false,
|
|
6206
|
+
acknowledged: false, code: null, reason: null, holderPid: null, agents: null,
|
|
6207
|
+
attempts: 0, driverMode: 'none', driverSignalled: false, driverExited: false,
|
|
6208
|
+
terminalWriter: 'none', localCleanup: false,
|
|
6209
|
+
};
|
|
6210
|
+
const refuse = reason => reportAbort({ ...result, ok: false, reason, terminalWriter: 'none', localCleanup: false });
|
|
5801
6211
|
if (!active) {
|
|
5802
6212
|
console.log('No active build to abort.');
|
|
5803
|
-
return;
|
|
6213
|
+
return refuse('no_active_build');
|
|
5804
6214
|
}
|
|
5805
|
-
|
|
5806
6215
|
if (featureCode && active.featureCode !== featureCode) {
|
|
5807
6216
|
console.log(`Active build is for ${active.featureCode}, not ${featureCode}.`);
|
|
5808
|
-
return;
|
|
6217
|
+
return refuse('feature_mismatch');
|
|
5809
6218
|
}
|
|
6219
|
+
if (ABORT_TERMINAL_STATUSES.has(active.status)) return refuse(`already_${active.status}`);
|
|
5810
6220
|
|
|
6221
|
+
const handle = lookupBuildCancel(active.flowId);
|
|
6222
|
+
const foreignPid = !handle && active.pid && active.pid !== process.pid
|
|
6223
|
+
&& active.status === 'running' && isProcessAlive(active.pid) ? active.pid : null;
|
|
6224
|
+
result.driverMode = handle ? 'in-process' : foreignPid ? 'foreign-pid' : 'none';
|
|
5811
6225
|
console.log(`Aborting build for ${active.featureCode}...`);
|
|
5812
6226
|
|
|
5813
|
-
//
|
|
5814
|
-
//
|
|
5815
|
-
const
|
|
5816
|
-
const
|
|
6227
|
+
// Configuration errors (notably the retired Python pin) still reject BEFORE
|
|
6228
|
+
// connecting. Only a connection/transport failure belongs in the outcome table.
|
|
6229
|
+
const connection = active.flowId ? resolveStratumMcpConnection(cwd) : null;
|
|
6230
|
+
const stratum = connection ? (opts.stratum ?? new StratumMcpClient()) : null;
|
|
5817
6231
|
try {
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
6232
|
+
let outcome = { flowSettled: true };
|
|
6233
|
+
if (stratum) {
|
|
6234
|
+
try {
|
|
6235
|
+
await stratum.connect({ ...connection,
|
|
6236
|
+
env: { ...process.env, STRATUM_CANCEL_LOCK_WAIT_MS: String(abortSetting('COMPOSE_ABORT_LOCK_WAIT_MS', 10000)) },
|
|
6237
|
+
});
|
|
6238
|
+
} catch (error) {
|
|
6239
|
+
outcome = asTransportRefusal(error);
|
|
6240
|
+
}
|
|
6241
|
+
if (!outcome.code) outcome = await cancelAbortFlow(stratum, active.flowId, result);
|
|
6242
|
+
}
|
|
6243
|
+
for (const key of ['status', 'code', 'reason', 'holderPid', 'agents']) result[key] = outcome[key] ?? null;
|
|
6244
|
+
result.flowSettled = outcome.flowSettled === true;
|
|
6245
|
+
result.acknowledged = outcome.acknowledged === true;
|
|
6246
|
+
const alreadyTerminal = !outcome.code && ['completed', 'failed', 'budget_exhausted'].includes(outcome.status)
|
|
6247
|
+
&& outcome.reason === `already_${outcome.status}`;
|
|
6248
|
+
if (!result.flowSettled && !alreadyTerminal) return refuse(result.reason ?? 'transport');
|
|
6249
|
+
|
|
6250
|
+
// C41/C48: the awaits above may have handed ownership to a different build.
|
|
6251
|
+
// Claim BEFORE even cancelling the local controller or signalling a driver.
|
|
6252
|
+
let claim = claimActiveBuild(dataDir, active);
|
|
6253
|
+
if (!claim.ok) return refuse(claim.reason);
|
|
6254
|
+
if (result.flowSettled) {
|
|
6255
|
+
if (handle) handle.cancel('abort');
|
|
6256
|
+
else if (foreignPid && isProcessAlive(foreignPid)) {
|
|
6257
|
+
try { process.kill(foreignPid, 'SIGTERM'); result.driverSignalled = true; }
|
|
6258
|
+
catch (error) {
|
|
6259
|
+
if (error.code !== 'ESRCH') console.log(`Driver SIGTERM failed: ${error.code ?? error.message}.`);
|
|
6260
|
+
}
|
|
6261
|
+
}
|
|
6262
|
+
}
|
|
6263
|
+
if (result.driverMode !== 'none') {
|
|
6264
|
+
const driver = await waitForAbortDriver(dataDir, active, handle);
|
|
6265
|
+
if (!driver.ok) return refuse(driver.reason);
|
|
6266
|
+
if (driver.exited) {
|
|
6267
|
+
result.driverExited = true;
|
|
6268
|
+
result.terminalWriter = 'driver';
|
|
6269
|
+
result.ok = ['aborted', 'killed'].includes(driver.record.status);
|
|
6270
|
+
result.localCleanup = result.ok;
|
|
6271
|
+
if (!result.ok) result.reason = `already_${driver.record.status}`;
|
|
6272
|
+
return reportAbort(result);
|
|
6273
|
+
}
|
|
5822
6274
|
}
|
|
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
6275
|
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
6276
|
+
// Re-claim after the bounded wait, covering vision + state + actuals as one
|
|
6277
|
+
// cleanup. The aborter never restamps the driver's pid.
|
|
6278
|
+
claim = claimActiveBuild(dataDir, active);
|
|
6279
|
+
if (!claim.ok) return refuse(claim.reason);
|
|
6280
|
+
if (ABORT_TERMINAL_STATUSES.has(claim.record.status)) return refuse(`already_${claim.record.status}`);
|
|
6281
|
+
const visionWriter = new VisionWriter(dataDir);
|
|
6282
|
+
const item = await visionWriter.findFeatureItem(active.featureCode);
|
|
6283
|
+
claim = claimActiveBuild(dataDir, active);
|
|
6284
|
+
if (!claim.ok) return refuse(claim.reason);
|
|
6285
|
+
if (ABORT_TERMINAL_STATUSES.has(claim.record.status)) return refuse(`already_${claim.record.status}`);
|
|
6286
|
+
if (item?.id) {
|
|
6287
|
+
try { await killOwnedBuildVision(visionWriter, item.id, () => claimActiveBuild(dataDir, active)); }
|
|
6288
|
+
catch (error) {
|
|
6289
|
+
if (['ownership_lost', 'ownership_unverifiable'].includes(error.reason)) return refuse(error.reason);
|
|
6290
|
+
throw error;
|
|
6291
|
+
}
|
|
6292
|
+
}
|
|
6293
|
+
claim = claimActiveBuild(dataDir, active);
|
|
6294
|
+
if (!claim.ok) return refuse(claim.reason);
|
|
6295
|
+
if (ABORT_TERMINAL_STATUSES.has(claim.record.status)) return refuse(`already_${claim.record.status}`);
|
|
6296
|
+
writeActiveBuild(dataDir, { ...claim.record, status: 'aborted', completedAt: new Date().toISOString() }, { stampPid: false });
|
|
6297
|
+
const accumulator = readBuildAccumulator(cwd, active.featureCode);
|
|
6298
|
+
if (accumulator) emitBuildActuals(cwd, accumulator, 'aborted');
|
|
6299
|
+
result.ok = true;
|
|
6300
|
+
result.localCleanup = true;
|
|
6301
|
+
result.terminalWriter = 'abort';
|
|
6302
|
+
return reportAbort(result);
|
|
6303
|
+
} finally {
|
|
6304
|
+
if (stratum) {
|
|
6305
|
+
try { await stratum.close(); }
|
|
6306
|
+
catch (error) { console.warn(`Abort client close failed: ${error.message}`); }
|
|
6307
|
+
}
|
|
5842
6308
|
}
|
|
5843
|
-
console.log('Build aborted.');
|
|
5844
6309
|
}
|
|
5845
6310
|
|
|
5846
6311
|
/** Optional revision failures keep the draft; only user control or uncertain teardown stops the build. */
|