@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.
Files changed (114) hide show
  1. package/.claude/agents/compose-architect.md +40 -0
  2. package/.claude/agents/compose-explorer.md +35 -0
  3. package/.claude/hooks/canon-guard.mjs +52 -0
  4. package/README.md +1 -1
  5. package/bin/compose.js +33 -14
  6. package/bin/git-hooks/pre-push.template +26 -1
  7. package/bin/receipts-gate.js +39 -0
  8. package/contracts/fluid-record.schema.json +5 -0
  9. package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
  10. package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
  11. package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
  14. package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
  15. package/dist/assets/channel-B-7ZRCKC.js +1 -0
  16. package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
  17. package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
  18. package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
  19. package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
  20. package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
  21. package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
  22. package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
  23. package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
  24. package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
  25. package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
  26. package/dist/assets/clone-CfNV0lUO.js +1 -0
  27. package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
  28. package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
  29. package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
  30. package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
  31. package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
  32. package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
  33. package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
  34. package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
  35. package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
  36. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
  37. package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
  38. package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
  39. package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
  43. package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
  44. package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
  45. package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
  59. package/dist/index.html +1 -1
  60. package/lib/build-cancel.js +205 -0
  61. package/lib/build.js +552 -87
  62. package/lib/canon-guard.js +3 -24
  63. package/lib/canon-registry.js +2 -71
  64. package/lib/codex-preflight.js +8 -0
  65. package/lib/colleague/context.js +123 -0
  66. package/lib/consumer-fanout.js +24 -1
  67. package/lib/decision-blocks.js +38 -0
  68. package/lib/dispatch-ledger.js +7 -0
  69. package/lib/fluid/factory.js +112 -1
  70. package/lib/fluid/ideabox-manifest.js +203 -0
  71. package/lib/fluid/ideabox-migrate.js +177 -29
  72. package/lib/fluid/ideabox-preamble.js +155 -0
  73. package/lib/fluid/ideabox-readable.js +83 -0
  74. package/lib/fluid/ideabox-recover.js +393 -0
  75. package/lib/fluid/import-ideabox.js +188 -45
  76. package/lib/fluid/local-provider.js +6 -0
  77. package/lib/fluid/portfolio.js +255 -0
  78. package/lib/fluid/record-shape.js +7 -0
  79. package/lib/fluid/render-ideabox.js +153 -7
  80. package/lib/fluid/smartmemory-provider.js +6 -0
  81. package/lib/gate-prompt.js +14 -7
  82. package/lib/ideabox-cli.js +68 -0
  83. package/lib/ideabox.js +209 -9
  84. package/lib/maya-identity.js +16 -2
  85. package/lib/process-termination.js +121 -3
  86. package/lib/receipts-gate.js +268 -0
  87. package/lib/result-normalizer.js +28 -1
  88. package/lib/smartmemory-client.js +68 -1
  89. package/lib/stratum-mcp-client.js +104 -5
  90. package/lib/tool-inventory.js +0 -1
  91. package/lib/version-check.js +9 -3
  92. package/package.json +7 -5
  93. package/server/build-stream-bridge.js +43 -1
  94. package/server/cc-session-watcher.js +54 -5
  95. package/server/compose-mcp-tools.js +48 -50
  96. package/server/compose-mcp.js +0 -2
  97. package/server/design-routes.js +1 -1
  98. package/server/file-watcher.js +14 -0
  99. package/server/ideabox-routes.js +10 -0
  100. package/server/index.js +5 -1
  101. package/server/lifecycle-guard.js +13 -0
  102. package/server/maya-routes.js +111 -7
  103. package/server/mcp-tool-defs.js +0 -25
  104. package/server/mcp-tool-policy.js +6 -13
  105. package/server/stratum-client.js +61 -15
  106. package/server/supervisor.js +18 -4
  107. package/server/vision-routes.js +9 -3
  108. package/dist/assets/channel-SnZzzh7k.js +0 -1
  109. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
  110. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
  111. package/dist/assets/clone-DgklGjHm.js +0 -1
  112. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
  113. package/lib/append-integrity.js +0 -81
  114. 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
- function writeActiveBuild(dataDir, state) {
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
- // Always stamp PID so concurrent processes can detect each other
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 + '.tmp';
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
- await writeFailedBuildTerminalState({
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
- if (activeForDecision?.flowId && ['complete', 'aborted', 'killed'].includes(activeForDecision.status)) {
2771
- flowTerminal = true;
2772
- } else if (activeForDecision?.flowId && !pidAlive) {
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(activeForDecision.flowId);
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: activeForDecision.flowId,
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
- flowTerminal = true;
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.stepDone(
3483
- flowId, stepId, shipStepResult, readyStep?.dispatchToken,
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.stepDone(
3946
- flowId, stepId, stepDoneResult, readyStep?.dispatchToken,
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 = await stratum.gateResolve(
4261
- flowId, stepId, outcome, rationale, resolvedBy, gateToken,
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
- const resolved = await pollGateResolution(visionWriter, gateId);
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 (response.status === 'completed' && buildStatus === 'complete') {
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
- buildStatus = 'failed';
4933
- const failureReason = err?.message ?? 'Build failed';
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
- if (signalHandler) {
4967
- process.removeListener('SIGINT', signalHandler);
4968
- process.removeListener('SIGTERM', signalHandler);
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
- await stratum.close();
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 new Promise(r => setTimeout(r, intervalMs));
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
- // Probe the persisted TS run before abandoning the local build. Foreground TS
5814
- // runs advance only through this client, so closing it prevents more dispatch.
5815
- const stratum = opts.stratum ?? new StratumMcpClient();
5816
- const connection = resolveStratumMcpConnection(cwd);
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
- await stratum.connect(connection);
5819
- const audit = await stratum.audit(active.flowId);
5820
- if (isTerminalFlow(audit.status)) {
5821
- console.log(`Flow already ${audit.status}.`);
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
- // Write terminal state (file retained per STRAT-COMP-4 contract)
5838
- writeActiveBuild(dataDir, { ...active, status: 'aborted', completedAt: new Date().toISOString() });
5839
- const accumulator = readBuildAccumulator(cwd, active.featureCode);
5840
- if (accumulator) {
5841
- emitBuildActuals(cwd, accumulator, 'aborted');
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. */