@smartmemory/compose 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/compose-architect.md +40 -0
- package/.claude/agents/compose-explorer.md +35 -0
- package/.claude/hooks/canon-guard.mjs +52 -0
- package/README.md +15 -1
- package/bin/compose.js +57 -17
- package/bin/git-hooks/pre-push.template +26 -1
- package/bin/receipts-gate.js +39 -0
- package/contracts/fluid-record.schema.json +5 -0
- package/dist/assets/{App-Z4MU-H_F.js → App-DC7paCZv.js} +190 -190
- package/dist/assets/{_baseUniq-ClWoCPFl.js → _baseUniq-Czad7yiy.js} +1 -1
- package/dist/assets/{arc-DY26UIVo.js → arc-EquvLk8y.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-6Ggq4DqJ.js → architectureDiagram-Q4EWVU46-Dr_qinWi.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CH3Ked0l.js → blockDiagram-DXYQGD6D-D2z46ED_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Bk8dYilu.js → c4Diagram-AHTNJAMY-BHob1Yt0.js} +1 -1
- package/dist/assets/channel-B-7ZRCKC.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BMR0XaAQ.js → chunk-4BX2VUAB-DomWBRa_.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-JytR14a9.js → chunk-4TB4RGXK-WyC_x_DH.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4Q97BCP.js → chunk-55IACEB6-BajRv3zx.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-R_qarkSf.js → chunk-EDXVE4YY-rMnedK_r.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C9s7KR9m.js → chunk-FMBD7UC4-BPi03Hcb.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-BySQzVxc.js → chunk-OYMX7WX6-B7J_mKX0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DdpSYZsW.js → chunk-QZHKN3VN-BLXTVr8N.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-iE_tzriw.js → chunk-YZCP3GAM-BYWjo2OJ.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Balz1OEB.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Balz1OEB.js +1 -0
- package/dist/assets/clone-CfNV0lUO.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BdlU6ZX_.js → cose-bilkent-S5V4N54A-Coaq0xaU.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-Cp3F5KTn.js → dagre-KV5264BT-DvUvAxlj.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DiR6_2q_.js → diagram-5BDNPKRD-70bXRUXV.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-w0i-p5HX.js → diagram-G4DWMVQ6-hMA8wgzx.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-tIHhwUv3.js → diagram-MMDJMWI5-BNir7C6i.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BAeY3B19.js → diagram-TYMM5635-BCYl1xrE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-Ckx_Knko.js → erDiagram-SMLLAGMA-bjxP0_bt.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-DeoNka6J.js → flowDiagram-DWJPFMVM-CBn9fhEp.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-BmGnFbEg.js → ganttDiagram-T4ZO3ILL-y1O7mWzn.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Dk48IHsx.js → gitGraphDiagram-UUTBAWPF-DIxwDXHB.js} +1 -1
- package/dist/assets/{graph-BNzKGvoy.js → graph-9D1ZumWp.js} +1 -1
- package/dist/assets/{index-BEfrNBp8.js → index-Ds_IXQo3.js} +2 -2
- package/dist/assets/{infoDiagram-42DDH7IO-BRf827i0.js → infoDiagram-42DDH7IO-DsWLGhaY.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-0kCZaeCM.js → ishikawaDiagram-UXIWVN3A-CipZIE90.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-rvU7ayRt.js → journeyDiagram-VCZTEJTY-Vr5xqcQm.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-DpQwX1C5.js → kanban-definition-6JOO6SKY-EqUYneyh.js} +1 -1
- package/dist/assets/{layout-BI8cXFPI.js → layout-hfWIIs0-.js} +1 -1
- package/dist/assets/{linear-a0glcDiw.js → linear-BdDWoN0t.js} +1 -1
- package/dist/assets/{min-vPHfnXcC.js → min-Bn_xAS7n.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D14eF-7C.js → mindmap-definition-QFDTVHPH-qsgubzCF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Cno-gETh.js → pieDiagram-DEJITSTG-Bv1xq_58.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BUQM1Hfm.js → quadrantDiagram-34T5L4WZ-DwMbAegF.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-pOXlN2-q.js → requirementDiagram-MS252O5E-BJVmLNcp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Crynd3_b.js → sankeyDiagram-XADWPNL6-o5GZb8Y1.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-D9fZdCM8.js → sequenceDiagram-FGHM5R23-ocqJp2qk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CW9qVec8.js → stateDiagram-FHFEXIEX-DGaDUFxP.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-Dz-15i-r.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-BcHzhm_8.js → timeline-definition-GMOUNBTQ-C4YwFvAn.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-BfytJcWk.js → vennDiagram-DHZGUBPP-uOKn9j-y.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DLj-IjyB.js → wardley-RL74JXVD-DIQSmQde.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Ds0Ue68c.js → wardleyDiagram-NUSXRM2D-CdamsEDC.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-vjWDXFL6.js → xychartDiagram-5P7HB3ND-DhLs41yk.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/agent-string.js +9 -4
- package/lib/build-cancel.js +205 -0
- package/lib/build-stream-writer.js +6 -0
- package/lib/build.js +1189 -165
- package/lib/canon-guard.js +3 -24
- package/lib/canon-registry.js +2 -71
- package/lib/codex-preflight.js +8 -0
- package/lib/colleague/context.js +123 -0
- package/lib/consumer-fanout.js +427 -17
- package/lib/decision-blocks.js +38 -0
- package/lib/dispatch-ledger.js +7 -0
- package/lib/experiment-pricing.js +5 -1
- package/lib/flow-state.js +38 -0
- package/lib/fluid/factory.js +112 -1
- package/lib/fluid/ideabox-manifest.js +203 -0
- package/lib/fluid/ideabox-migrate.js +177 -29
- package/lib/fluid/ideabox-preamble.js +155 -0
- package/lib/fluid/ideabox-readable.js +83 -0
- package/lib/fluid/ideabox-recover.js +393 -0
- package/lib/fluid/import-ideabox.js +188 -45
- package/lib/fluid/local-provider.js +6 -0
- package/lib/fluid/portfolio.js +255 -0
- package/lib/fluid/record-shape.js +7 -0
- package/lib/fluid/render-ideabox.js +153 -7
- package/lib/fluid/smartmemory-provider.js +6 -0
- package/lib/gate-prompt.js +14 -7
- package/lib/gsd.js +95 -48
- package/lib/ideabox-cli.js +68 -0
- package/lib/ideabox.js +209 -9
- package/lib/maya-identity.js +16 -2
- package/lib/model-pricing.js +4 -1
- package/lib/output-gate.js +81 -0
- package/lib/pipeline-profiles.js +200 -0
- package/lib/process-termination.js +121 -3
- package/lib/receipts-gate.js +268 -0
- package/lib/result-normalizer.js +41 -1
- package/lib/smartmemory-client.js +68 -1
- package/lib/stratum-mcp-client.js +104 -5
- package/lib/team-flag.js +1 -1
- package/lib/tool-inventory.js +0 -1
- package/lib/version-check.js +9 -3
- package/lib/wave-checkpoint.js +100 -0
- package/package.json +7 -5
- package/presets/team-fable-astra.profiles.json +18 -0
- package/presets/team-fable-astra.stratum.yaml +236 -0
- package/server/build-stream-bridge.js +43 -1
- package/server/cc-session-watcher.js +54 -5
- package/server/compose-mcp-tools.js +48 -50
- package/server/compose-mcp.js +0 -2
- package/server/design-routes.js +1 -1
- package/server/file-watcher.js +14 -0
- package/server/ideabox-routes.js +10 -0
- package/server/index.js +5 -1
- package/server/lifecycle-guard.js +13 -0
- package/server/maya-routes.js +111 -7
- package/server/mcp-tool-defs.js +0 -25
- package/server/mcp-tool-policy.js +6 -13
- package/server/model-tiers.js +14 -6
- package/server/stratum-client.js +61 -15
- package/server/supervisor.js +18 -4
- package/server/vision-routes.js +9 -3
- package/dist/assets/channel-SnZzzh7k.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +0 -1
- package/dist/assets/clone-DgklGjHm.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +0 -1
- package/lib/append-integrity.js +0 -81
- package/lib/canon-override.js +0 -196
package/lib/maya-identity.js
CHANGED
|
@@ -136,12 +136,26 @@ export async function provisionIdentity({ smBaseUrl, fetchFn = fetch }) {
|
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/** The oldest NDA version this client knows; the server names a newer one. */
|
|
140
|
+
const NDA_VERSION_FLOOR = 'v1';
|
|
141
|
+
|
|
139
142
|
/** Accept the beta NDA for a provisioned identity (403 `nda_required` gates
|
|
140
143
|
* every memory route until this runs — FOH-4 ledger prereq 3). */
|
|
141
144
|
export async function acceptNda({ smBaseUrl, token, fetchFn = fetch }) {
|
|
142
|
-
const
|
|
143
|
-
body: { version
|
|
145
|
+
const attempt = (version) => requestJson(`${smBaseUrl}/memory/beta/nda/accept`, {
|
|
146
|
+
body: { version }, token, fetchFn,
|
|
144
147
|
});
|
|
148
|
+
let res = await attempt(NDA_VERSION_FLOOR);
|
|
149
|
+
// The NDA version is upstream's to bump, not ours to track: a 409
|
|
150
|
+
// `version_mismatch` names the version currently in force, and accepting the
|
|
151
|
+
// one the server names is the only answer that survives the next bump.
|
|
152
|
+
// FOH-7 live-fire (2026-09-06) found v1 hardcoded after upstream moved to v2 —
|
|
153
|
+
// every provisioned colleague identity failed its first turn.
|
|
154
|
+
const named = res.status === 409 && res.body?.detail?.code === 'version_mismatch'
|
|
155
|
+
? res.body.detail.current_version : null;
|
|
156
|
+
if (typeof named === 'string' && named && named !== NDA_VERSION_FLOOR) {
|
|
157
|
+
res = await attempt(named);
|
|
158
|
+
}
|
|
145
159
|
if (res.status < 200 || res.status >= 300) {
|
|
146
160
|
throw new MayaIdentityError(`maya: NDA accept failed (HTTP ${res.status})`);
|
|
147
161
|
}
|
package/lib/model-pricing.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* model-pricing.js — Token cost lookup and USD calculation.
|
|
3
3
|
*
|
|
4
|
-
* Prices are per-million tokens (MTok)
|
|
4
|
+
* Prices are per-million tokens (MTok) updated for COMP-FABLE-ASTRA slice 1 (2026-09).
|
|
5
5
|
* Input price includes standard prompt tokens.
|
|
6
6
|
* Cache write tokens (cache_creation_input_tokens) are billed at 1.25x input rate.
|
|
7
7
|
* Cache read tokens (cache_read_input_tokens) are billed at 0.1x input rate.
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* Keys are matched by prefix so 'claude-sonnet-4-6' matches 'claude-sonnet-4-6-20250514' etc.
|
|
13
13
|
*/
|
|
14
14
|
export const MODEL_PRICING = {
|
|
15
|
+
'claude-fable-5-1': { inputPerMTok: 10, outputPerMTok: 50 },
|
|
16
|
+
'claude-opus-5': { inputPerMTok: 5, outputPerMTok: 25 },
|
|
17
|
+
'claude-sonnet-5': { inputPerMTok: 2, outputPerMTok: 10 },
|
|
15
18
|
'claude-opus-4-7': { inputPerMTok: 5, outputPerMTok: 25 },
|
|
16
19
|
'claude-opus-4-6': { inputPerMTok: 5, outputPerMTok: 25 },
|
|
17
20
|
'claude-sonnet-4-6': { inputPerMTok: 3, outputPerMTok: 15 },
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Pure output-gate decision. Callers supply current recorded step states. */
|
|
2
|
+
import { ownField, normalizeOwnedPath, validateGateConfig, validateWaveAdmission, profilesDigest } from './pipeline-profiles.js';
|
|
3
|
+
|
|
4
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
+
const strings = value => Array.isArray(value) && value.every(v => typeof v === 'string');
|
|
6
|
+
function validateDecision(decision, review, validator, { executeProfile, executeProvider }) {
|
|
7
|
+
const findings = [];
|
|
8
|
+
const add = (code, message) => findings.push({ code, message });
|
|
9
|
+
const tasks = ownField(decision, validator.tasks_field ?? 'tasks');
|
|
10
|
+
const findingShape = f => object(f) && typeof f.severity === 'string' && strings(f.files)
|
|
11
|
+
&& typeof f.claim === 'string' && typeof f.evidence === 'string';
|
|
12
|
+
const taskShape = t => object(t) && typeof t.id === 'string' && t.id.length > 0
|
|
13
|
+
&& typeof t.description === 'string' && strings(t.files_owned) && strings(t.files_read)
|
|
14
|
+
&& strings(t.depends_on) && (t.tier_rationale === undefined || typeof t.tier_rationale === 'string');
|
|
15
|
+
if (!object(decision) || !['repair', 'implement', 'complete', 'blocked'].includes(decision.action)
|
|
16
|
+
|| typeof decision.rationale !== 'string' || typeof decision.blocking !== 'boolean'
|
|
17
|
+
|| !Number.isInteger(decision.open_count) || decision.open_count < 0
|
|
18
|
+
|| !Array.isArray(decision.open_findings) || !decision.open_findings.every(findingShape)
|
|
19
|
+
|| !Array.isArray(decision.addressed_findings) || !decision.addressed_findings.every(findingShape)
|
|
20
|
+
|| !Array.isArray(tasks) || !tasks.every(taskShape)) {
|
|
21
|
+
add('WAVE_DECISION_SHAPE', 'Invalid WaveDecision/task/finding shape');
|
|
22
|
+
return findings;
|
|
23
|
+
}
|
|
24
|
+
if (decision.open_count !== decision.open_findings.length) add('WAVE_OPEN_COUNT_MISMATCH', 'open_count differs from open_findings length');
|
|
25
|
+
if (typeof review?.blocking !== 'boolean' || review.blocking !== decision.blocking) add('WAVE_BLOCKING_MISMATCH', 'blocking differs from recorded review');
|
|
26
|
+
if (decision.action === 'complete' && (decision.open_count !== 0 || decision.blocking)) add('WAVE_COMPLETE_WITH_OPEN_FINDINGS', 'Complete requires no open findings and no blocking');
|
|
27
|
+
if (decision.action === 'blocked' && decision.open_count === 0) add('WAVE_BLOCKED_WITHOUT_FINDINGS', 'Blocked requires open findings');
|
|
28
|
+
if (['repair', 'implement'].includes(decision.action)) {
|
|
29
|
+
if (tasks.length < 1 || tasks.length > 6) add('WAVE_REPAIR_EMPTY', 'Implementation/repair requires 1–6 tasks');
|
|
30
|
+
findings.push(...validateWaveAdmission(executeProfile, tasks,
|
|
31
|
+
{ provider: executeProvider, ownership: true, independent: true }).findings);
|
|
32
|
+
}
|
|
33
|
+
if (decision.action === 'repair') {
|
|
34
|
+
try {
|
|
35
|
+
const files = new Set(decision.open_findings.flatMap(f => f.files.map(normalizeOwnedPath)));
|
|
36
|
+
for (const task of tasks) if (!task.files_owned.some(file => files.has(normalizeOwnedPath(file)))) {
|
|
37
|
+
add('WAVE_REPAIR_UNOWNED_FINDING', `Repair task ${task.id} owns no open finding file`);
|
|
38
|
+
}
|
|
39
|
+
} catch (error) { add('WAVE_DECISION_SHAPE', error.message); }
|
|
40
|
+
}
|
|
41
|
+
return findings;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve only from current recorded source, waiting gate and configured review states.
|
|
45
|
+
* executeProfile (the configured execute entry) and executeProvider are required.
|
|
46
|
+
* reviewOutput is ignored; validators always use the configured review step's recorded output.
|
|
47
|
+
*/
|
|
48
|
+
export function decideGateFromOutput(config, stepOutputs, { gateStepId, gateToken, reviewOutput, ceiling, executeProfile, executeProvider } = {}) {
|
|
49
|
+
const hold = (reason, extra = {}) => ({ outcome: null, reason, ...extra });
|
|
50
|
+
try { validateGateConfig(config); }
|
|
51
|
+
catch (error) { return hold('GATE_CONFIG_INVALID', { findings: [{ code: 'GATE_CONFIG_INVALID', message: error.message }] }); }
|
|
52
|
+
if (!executeProfile || typeof executeProvider !== 'string' || !executeProvider.trim()) return hold('GATE_CONFIG_INVALID');
|
|
53
|
+
if (ceiling !== undefined) {
|
|
54
|
+
if (!Number.isFinite(ceiling.spent) || ceiling.spent < 0 || !Number.isFinite(ceiling.ceiling) || ceiling.ceiling <= 0) return hold('COST_CEILING_INVALID');
|
|
55
|
+
if (ceiling.spent > ceiling.ceiling) return hold('COST_CEILING_BREACHED', { breach: { spent: ceiling.spent, ceiling: ceiling.ceiling } });
|
|
56
|
+
}
|
|
57
|
+
const sourceState = stepOutputs?.[config.decide_from.step];
|
|
58
|
+
if (!object(sourceState) || sourceState.status !== 'succeeded' || !object(sourceState.output)) return hold('GATE_SOURCE_MISSING');
|
|
59
|
+
const gate = stepOutputs?.[gateStepId];
|
|
60
|
+
if (!object(gate) || gate.status !== 'waiting_gate' || typeof gateToken !== 'string' || !gateToken
|
|
61
|
+
|| gate.gateToken !== gateToken || !Number.isInteger(gate.epoch) || gate.epoch < 0
|
|
62
|
+
|| sourceState.epoch !== gate.epoch) return hold('GATE_SOURCE_STALE');
|
|
63
|
+
const output = sourceState.output;
|
|
64
|
+
const action = ownField(output, config.decide_from.field);
|
|
65
|
+
const outcome = ['approve', 'revise', 'kill'].find(key => config.decide_from[key].includes(action));
|
|
66
|
+
if (!outcome) return hold('GATE_ACTION_UNKNOWN');
|
|
67
|
+
const findings = [];
|
|
68
|
+
for (const validator of config.validators ?? []) {
|
|
69
|
+
const reviewState = stepOutputs?.[validator.review_step];
|
|
70
|
+
const review = reviewState?.status === 'succeeded' ? reviewState.output : undefined;
|
|
71
|
+
if (reviewState && reviewState.epoch !== gate.epoch) return hold('GATE_SOURCE_STALE');
|
|
72
|
+
findings.push(...validateDecision(output, review, validator, { executeProfile, executeProvider }));
|
|
73
|
+
}
|
|
74
|
+
if (findings.length) return hold('GATE_VALIDATION_FAILED', { findings });
|
|
75
|
+
return {
|
|
76
|
+
outcome, rationale: output.rationale ?? `Mapped ${String(action)} to ${outcome}`,
|
|
77
|
+
source: { step: config.decide_from.step, field: config.decide_from.field, action, gateStepId, gateToken,
|
|
78
|
+
epoch: sourceState.epoch, acceptedDispatchToken: sourceState.acceptedDispatchToken,
|
|
79
|
+
outputDigest: profilesDigest(output), output: structuredClone(output) },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** Compose-owned sidecar schema and whole-wave admission. No dispatch or I/O. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { posix } from 'node:path';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import { validateAgentString, resolveAgentConfig } from './agent-string.js';
|
|
6
|
+
|
|
7
|
+
export class PipelineProfileError extends Error {
|
|
8
|
+
constructor(code, message) { super(message); this.name = 'PipelineProfileError'; this.code = code; }
|
|
9
|
+
}
|
|
10
|
+
const fail = (message, code = 'PIPELINE_PROFILE_INVALID') => { throw new PipelineProfileError(code, message); };
|
|
11
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
12
|
+
const keys = (value, allowed) => {
|
|
13
|
+
if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail(`Invalid configuration fields: ${JSON.stringify(value)}`);
|
|
14
|
+
};
|
|
15
|
+
export function ownField(value, path) {
|
|
16
|
+
if (typeof path !== 'string' || !/^[A-Za-z_][\w]*(\.[A-Za-z_][\w]*)*$/.test(path)
|
|
17
|
+
|| path.split('.').some(key => ['__proto__', 'prototype', 'constructor'].includes(key))) fail('Invalid field path');
|
|
18
|
+
for (const key of path.split('.')) {
|
|
19
|
+
if (!object(value) || !Object.hasOwn(value, key)) return undefined;
|
|
20
|
+
value = value[key];
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function normalizeOwnedPath(path) {
|
|
25
|
+
if (typeof path !== 'string' || !path.length || /^[\\/]|^[A-Za-z]:/.test(path)
|
|
26
|
+
|| /[\0*?\[\]{}]/.test(path)) fail('Ownership requires literal repository-relative file paths', 'WAVE_OWNERSHIP_INVALID');
|
|
27
|
+
const parts = path.replaceAll('\\', '/').split('/');
|
|
28
|
+
if (parts.includes('..') || parts.some(part => part.toLowerCase() === '.git')) fail(`Unsafe ownership path: ${path}`, 'WAVE_OWNERSHIP_INVALID');
|
|
29
|
+
const normalized = posix.normalize(parts.join('/'));
|
|
30
|
+
if (normalized === '.' || normalized.endsWith('/')) fail(`Not a file path: ${path}`, 'WAVE_OWNERSHIP_INVALID');
|
|
31
|
+
return normalized;
|
|
32
|
+
}
|
|
33
|
+
function agent(profile, provider) {
|
|
34
|
+
if (typeof profile !== 'string' || !profile.trim() || profile.split(':').length > 3) fail('Profile must be a non-empty agent string');
|
|
35
|
+
validateAgentString(profile);
|
|
36
|
+
const resolved = resolveAgentConfig(profile);
|
|
37
|
+
if (provider && resolved.provider !== provider) fail(`Profile provider ${resolved.provider} differs from stage ${provider}`);
|
|
38
|
+
return { ...resolved, profile };
|
|
39
|
+
}
|
|
40
|
+
export function validateGateConfig(entry) {
|
|
41
|
+
keys(entry, ['decide_from', 'validators']);
|
|
42
|
+
keys(entry.decide_from, ['step', 'field', 'approve', 'revise', 'kill']);
|
|
43
|
+
const mapping = entry.decide_from;
|
|
44
|
+
if (typeof mapping.step !== 'string' || !mapping.step) fail('decide_from.step is required');
|
|
45
|
+
ownField({}, mapping.field);
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
for (const outcome of ['approve', 'revise', 'kill']) {
|
|
48
|
+
if (!Array.isArray(mapping[outcome])) fail(`${outcome} must be a value array`);
|
|
49
|
+
for (const value of mapping[outcome]) {
|
|
50
|
+
if (typeof value !== 'string' || !value || seen.has(value)) fail('Gate values must be nonempty, disjoint strings');
|
|
51
|
+
seen.add(value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (entry.validators !== undefined && !Array.isArray(entry.validators)) fail('validators must be an array');
|
|
55
|
+
for (const validator of entry.validators ?? []) {
|
|
56
|
+
keys(validator, ['name', 'review_step', 'tasks_field']);
|
|
57
|
+
if (validator.name !== 'WaveDecision' || typeof validator.review_step !== 'string' || !validator.review_step) fail('Unknown validator or missing review_step');
|
|
58
|
+
ownField({}, validator.tasks_field ?? 'tasks');
|
|
59
|
+
}
|
|
60
|
+
return entry;
|
|
61
|
+
}
|
|
62
|
+
function ancestor(steps, from, gate, visited = new Set()) {
|
|
63
|
+
if (visited.has(gate)) return false;
|
|
64
|
+
visited.add(gate);
|
|
65
|
+
return (steps.find(step => step.id === gate)?.after ?? []).some(id => id === from || ancestor(steps, from, id, visited));
|
|
66
|
+
}
|
|
67
|
+
export function normalizePipelineProfiles(raw, spec) {
|
|
68
|
+
if (!object(raw)) fail('Profiles must be an object');
|
|
69
|
+
const parsed = typeof spec === 'string' ? YAML.parse(spec) : spec;
|
|
70
|
+
const flows = Object.values(parsed?.flows ?? {}).filter(flow => Array.isArray(flow?.steps));
|
|
71
|
+
const steps = flows.flatMap(flow => flow.steps);
|
|
72
|
+
const normalized = structuredClone(raw);
|
|
73
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
74
|
+
if (id.startsWith('_')) continue;
|
|
75
|
+
const matches = steps.filter(step => step.id === id);
|
|
76
|
+
if (!matches.length) fail(`Step ${id} not found in spec`);
|
|
77
|
+
for (const step of matches) {
|
|
78
|
+
if (object(entry) && Object.hasOwn(entry, 'decide_from')) {
|
|
79
|
+
validateGateConfig(entry);
|
|
80
|
+
if (!step.gate || step.agent || step.fanout || id === 'review_gate') fail(`Step ${id} is not an available output gate`);
|
|
81
|
+
const flowSteps = flows.find(flow => flow.steps.includes(step)).steps;
|
|
82
|
+
for (const source of [entry.decide_from.step, ...(entry.validators ?? []).map(v => v.review_step)]) {
|
|
83
|
+
if (!ancestor(flowSteps, source, id)) fail(`Source ${source} must be an ancestor of ${id}`);
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (step.gate && !step.agent && !step.fanout) fail(`Gate ${id} requires decide_from`);
|
|
88
|
+
if (object(entry)) {
|
|
89
|
+
keys(entry, ['default', 'tier_from']);
|
|
90
|
+
if (entry.tier_from !== undefined && (entry.tier_from !== 'item.tier' || step.fanout?.dispatch !== 'consumer')) fail('tier_from requires a consumer fanout and item.tier');
|
|
91
|
+
} else if (typeof entry !== 'string') fail(`Invalid profile for ${id}`);
|
|
92
|
+
for (const stage of step.fanout?.steps ?? [step]) {
|
|
93
|
+
const resolved = agent(typeof entry === 'string' ? entry : entry.default, stage.agent ?? 'claude');
|
|
94
|
+
if (step.fanout?.dispatch === 'engine' && (resolved.tier || resolved.template)) fail('Engine dispatch cannot apply Compose templates or tiers');
|
|
95
|
+
if (entry.tier_from) for (const tier of ['critical', 'standard', 'fast']) resolveConsumerProfile(entry, { tier }, resolved.provider);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (raw._consumer !== undefined) {
|
|
100
|
+
if (!object(raw._consumer)) fail('_consumer must be an object');
|
|
101
|
+
for (const [id, policy] of Object.entries(raw._consumer)) {
|
|
102
|
+
keys(policy, ['ownership', 'independent', 'checkpoint_gate']);
|
|
103
|
+
if (policy.ownership !== undefined && policy.ownership !== 'item.files_owned') fail('ownership must be item.files_owned');
|
|
104
|
+
if (policy.independent !== undefined && typeof policy.independent !== 'boolean') fail('independent must be boolean');
|
|
105
|
+
const matches = steps.filter(step => step.id === id);
|
|
106
|
+
if (!matches.length) fail(`Consumer ${id} not found`);
|
|
107
|
+
for (const step of matches) {
|
|
108
|
+
if (step.fanout?.dispatch !== 'consumer') fail(`${id} is not consumer-dispatched`);
|
|
109
|
+
if ((policy.ownership || policy.checkpoint_gate) && step.fanout.isolation !== 'worktree') fail('Ownership/checkpoints require worktree isolation');
|
|
110
|
+
if (policy.checkpoint_gate !== undefined) {
|
|
111
|
+
const flow = flows.find(flow => flow.steps.includes(step));
|
|
112
|
+
const gate = flow.steps.find(s => s.id === policy.checkpoint_gate);
|
|
113
|
+
if (!gate?.gate || gate.after?.length !== 1 || gate.after[0] !== id || gate.when) fail('checkpoint_gate must be the direct unconditional merge gate');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (raw._costCeiling !== undefined) {
|
|
119
|
+
const c = raw._costCeiling;
|
|
120
|
+
keys(c, ['input', 'default', 'gates']);
|
|
121
|
+
if (typeof c.input !== 'string' || !/^[A-Za-z_]\w*$/.test(c.input)
|
|
122
|
+
|| !Number.isFinite(c.default) || c.default <= 0 || !Array.isArray(c.gates) || !c.gates.length
|
|
123
|
+
|| c.gates.some(id => !steps.some(s => s.id === id && s.gate))) fail('Invalid _costCeiling');
|
|
124
|
+
}
|
|
125
|
+
return normalized;
|
|
126
|
+
}
|
|
127
|
+
/** Replacing a default must never erase the per-item routing policy. Re-preflight the result. */
|
|
128
|
+
export function mergeRuntimeProfiles(normalized, runtime = {}) {
|
|
129
|
+
const result = structuredClone(normalized);
|
|
130
|
+
for (const [id, override] of Object.entries(runtime)) {
|
|
131
|
+
if (id.startsWith('_') || object(result[id]) && result[id].decide_from) fail(`Runtime override is not an agent profile: ${id}`);
|
|
132
|
+
const previous = result[id];
|
|
133
|
+
if (object(override)) {
|
|
134
|
+
keys(override, ['default', 'tier_from']);
|
|
135
|
+
if (override.tier_from !== undefined && override.tier_from !== previous?.tier_from) fail('Runtime overrides cannot change tier_from');
|
|
136
|
+
} else if (typeof override !== 'string') fail('Runtime override must be an agent profile');
|
|
137
|
+
result[id] = object(previous) ? { ...previous, default: typeof override === 'string' ? override : override.default } : structuredClone(override);
|
|
138
|
+
agent(typeof result[id] === 'string' ? result[id] : result[id].default);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
export function resolveConsumerProfile(entry, item, provider) {
|
|
143
|
+
const resolved = agent(typeof entry === 'string' ? entry : entry?.default, provider);
|
|
144
|
+
if (!entry?.tier_from) return resolved;
|
|
145
|
+
if (entry.tier_from !== 'item.tier') fail('Unsupported tier_from');
|
|
146
|
+
const tier = ownField({ item }, entry.tier_from);
|
|
147
|
+
if (tier === undefined && !Object.hasOwn(item ?? {}, 'tier')) return resolved;
|
|
148
|
+
if (!['critical', 'standard', 'fast'].includes(tier)) fail(`Unknown item tier: ${String(tier)}`, 'WAVE_TIER_INVALID');
|
|
149
|
+
return agent(`${resolved.provider}:${resolved.template ?? ''}:${tier}`, provider);
|
|
150
|
+
}
|
|
151
|
+
export function validateWaveAdmission(entry, items, opts = {}) {
|
|
152
|
+
const findings = [];
|
|
153
|
+
const profiles = [];
|
|
154
|
+
const owners = new Map();
|
|
155
|
+
const add = (code, itemIndex, message) => findings.push({ code, itemIndex, message, severity: 'error' });
|
|
156
|
+
if (!Array.isArray(items)) return { ok: false, findings: [{ code: 'WAVE_INPUT_INVALID', message: 'Recorded wave input must be an array' }] };
|
|
157
|
+
items.forEach((item, itemIndex) => {
|
|
158
|
+
try { profiles.push(resolveConsumerProfile(entry, item, opts.provider)); }
|
|
159
|
+
catch (error) { add(error.code ?? 'WAVE_TIER_INVALID', itemIndex, error.message); }
|
|
160
|
+
if (opts.independent && (!Array.isArray(item?.depends_on) || item.depends_on.length)) add('WAVE_DEPENDENCIES_NOT_EMPTY', itemIndex, 'Independent tasks require empty depends_on');
|
|
161
|
+
if (opts.ownership || Object.hasOwn(item ?? {}, 'files_owned')) {
|
|
162
|
+
try {
|
|
163
|
+
if (!Array.isArray(item?.files_owned)) fail('files_owned is required', 'WAVE_OWNERSHIP_INVALID');
|
|
164
|
+
for (const file of item.files_owned.map(normalizeOwnedPath)) {
|
|
165
|
+
if (owners.has(file) && owners.get(file) !== itemIndex) add('WAVE_OWNERSHIP_CONFLICT', itemIndex, `Multiple tasks own ${file}`);
|
|
166
|
+
owners.set(file, itemIndex);
|
|
167
|
+
}
|
|
168
|
+
} catch (error) { add(error.code, itemIndex, error.message); }
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
return { ok: findings.length === 0, findings, profiles };
|
|
172
|
+
}
|
|
173
|
+
export function profilesDigest(normalized) {
|
|
174
|
+
const canonical = value => Array.isArray(value) ? value.map(canonical) : object(value)
|
|
175
|
+
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
|
|
176
|
+
return createHash('sha256').update(JSON.stringify(canonical(normalized))).digest('hex');
|
|
177
|
+
}
|
|
178
|
+
/** Dispatch 2 replaces the string-only wrapper with this, after resolving spec inputs. */
|
|
179
|
+
export function preflightPipelineProfiles(raw, spec, runtime = {}) {
|
|
180
|
+
const normalized = normalizePipelineProfiles(mergeRuntimeProfiles(normalizePipelineProfiles(raw, spec), runtime), spec);
|
|
181
|
+
const resolved = {};
|
|
182
|
+
const overrides = {};
|
|
183
|
+
for (const [id, entry] of Object.entries(normalized)) {
|
|
184
|
+
if (!id.startsWith('_') && !entry?.decide_from) {
|
|
185
|
+
resolved[id] = resolveConsumerProfile(entry, {});
|
|
186
|
+
if (entry.tier_from) overrides[id] = ['critical', 'standard', 'fast'].map(tier => resolveConsumerProfile(entry, { tier }));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const parsed = typeof spec === 'string' ? YAML.parse(spec) : spec;
|
|
190
|
+
for (const step of Object.values(parsed?.flows ?? {}).flatMap(flow => flow?.steps ?? [])) {
|
|
191
|
+
if (Object.hasOwn(normalized, step.id)) continue;
|
|
192
|
+
const stages = step.fanout?.steps ?? (step.agent ? [step] : []);
|
|
193
|
+
for (const [index, stage] of stages.entries()) {
|
|
194
|
+
const resolution = agent(stage.agent ?? 'claude');
|
|
195
|
+
if (step.fanout?.dispatch === 'engine' && (resolution.template || resolution.tier)) fail('Engine dispatch cannot apply Compose templates or tiers');
|
|
196
|
+
resolved[stages.length === 1 ? step.id : `${step.id}/${index}`] = resolution;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, normalized, resolved, profilesDigest: profilesDigest({ normalized, resolved, overrides }) };
|
|
200
|
+
}
|
|
@@ -11,8 +11,35 @@
|
|
|
11
11
|
* alive → await leader close and group disappearance (bounded at 2 seconds).
|
|
12
12
|
*
|
|
13
13
|
* Grace period: `COMPOSE_CANCEL_GRACE_MS` (default 5000ms).
|
|
14
|
+
*
|
|
15
|
+
* D-TERM-1 (2026-09-07): `-pid` IS still signalled after the leader is reaped.
|
|
16
|
+
* ------------------------------------------------------------------------
|
|
17
|
+
* The open question was whether signalling the group after `close` aims at a
|
|
18
|
+
* pgid the kernel may have recycled to a stranger. It does not, for the whole
|
|
19
|
+
* window that matters:
|
|
20
|
+
*
|
|
21
|
+
* POSIX 4.13 — "if there exists a process group whose process group ID is
|
|
22
|
+
* equal to that process ID, the process ID shall not be reused until the
|
|
23
|
+
* process group lifetime ends" — and a group's lifetime ends only when its
|
|
24
|
+
* LAST member leaves.
|
|
25
|
+
*
|
|
26
|
+
* So while our group has any living member, `-pid` provably names OUR group,
|
|
27
|
+
* and a living member is exactly the condition teardown is waiting on.
|
|
28
|
+
* Measured on Darwin 25.6.0 rather than taken on faith: with the leader reaped
|
|
29
|
+
* and one grandchild left, 400,000 fork/exit cycles never got the pgid handed
|
|
30
|
+
* back (the pid space is ~100k, so that is four wraps). Emptying the group
|
|
31
|
+
* first, the same pid came back at iteration 98,102 — one full wrap.
|
|
32
|
+
*
|
|
33
|
+
* That measurement RETIRES the fear rather than confirming it: the recycled
|
|
34
|
+
* pgid needs roughly 98,000 process creations between our group emptying and
|
|
35
|
+
* our probe, inside a 2s reap deadline. It is not a millisecond race, and it
|
|
36
|
+
* is the LEAST likely reading of a group-signal failure, not the diagnosis.
|
|
37
|
+
* Keep signalling the group after close — "the group outlives the leader" is
|
|
38
|
+
* correct and load-bearing.
|
|
14
39
|
*/
|
|
15
40
|
|
|
41
|
+
import { execFileSync } from 'node:child_process';
|
|
42
|
+
|
|
16
43
|
const DEFAULT_GRACE_MS = 5000;
|
|
17
44
|
|
|
18
45
|
/** Read the configured cancellation grace period. */
|
|
@@ -35,6 +62,89 @@ function graceMsFromEnv(env = process.env) {
|
|
|
35
62
|
* @param {number} [graceMs]
|
|
36
63
|
* @returns {{ close: Promise<void>, terminate: () => Promise<void>, finish: () => Promise<void> }}
|
|
37
64
|
*/
|
|
65
|
+
/**
|
|
66
|
+
* Every process currently in `pgid`, so a group-signal failure can be
|
|
67
|
+
* attributed instead of argued about.
|
|
68
|
+
*
|
|
69
|
+
* `ps -g` is NOT portable — BSD reads it as a pgid list, procps as a
|
|
70
|
+
* session/group list — so the whole table is read and filtered here.
|
|
71
|
+
*
|
|
72
|
+
* @returns {Array<object> | {error: string}} never throws: this runs on an
|
|
73
|
+
* error path, where a second failure would replace the first one.
|
|
74
|
+
*/
|
|
75
|
+
function groupMembers(pgid) {
|
|
76
|
+
try {
|
|
77
|
+
const table = execFileSync('ps', ['-eo', 'pid=,pgid=,ppid=,uid=,comm='], {
|
|
78
|
+
encoding: 'utf8', timeout: 2000, maxBuffer: 4 * 1024 * 1024,
|
|
79
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
80
|
+
});
|
|
81
|
+
return table.split('\n')
|
|
82
|
+
.map((line) => line.trim().split(/\s+/))
|
|
83
|
+
.filter((f) => f.length >= 5 && Number(f[1]) === pgid)
|
|
84
|
+
// `comm` is last and may contain spaces (it is a path), so it takes the tail.
|
|
85
|
+
.map((f) => ({ pid: Number(f[0]), ppid: Number(f[2]), uid: Number(f[3]), comm: f.slice(4).join(' ') }));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { error: e?.code ?? e?.message ?? 'unknown' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Why a group signal failed, stamped on the error itself.
|
|
93
|
+
*
|
|
94
|
+
* A non-ESRCH failure here is rethrown, relabelled `CANCELLATION_UNCONFIRMED`
|
|
95
|
+
* by `terminate`, and reaches the caller carrying only Node's bare message —
|
|
96
|
+
* `kill EPERM` and nothing else, which is unattributable after the fact: two
|
|
97
|
+
* call sites send group signals.
|
|
98
|
+
*
|
|
99
|
+
* WHAT EPERM MEANS HERE. Measured on Darwin 25.6.0: `kill(-pgid, 0)` against a
|
|
100
|
+
* group of processes we may not signal answers EPERM, not ESRCH. So EPERM says
|
|
101
|
+
* exactly one thing — *a group with this pgid exists and every member refused
|
|
102
|
+
* our signal*. Three sub-causes, deliberately unranked except for the last:
|
|
103
|
+
*
|
|
104
|
+
* 1. a member runs as another uid (a tool child that escalated), or
|
|
105
|
+
* 2. a policy — MAC / sandbox — refused the signal for a member that shares
|
|
106
|
+
* our uid, so "same uid" does NOT rule this out, or
|
|
107
|
+
* 3. our group is gone and a stranger holds a recycled pgid.
|
|
108
|
+
*
|
|
109
|
+
* (3) was the original hypothesis and is the LEAST likely of the three: per
|
|
110
|
+
* D-TERM-1 above it needs ~98,000 process creations inside a 2s deadline.
|
|
111
|
+
*
|
|
112
|
+
* `killGroupMembers` is what actually separates them, and it is why this
|
|
113
|
+
* function exists at all: uids other than ours point at (1) or (2), and
|
|
114
|
+
* processes plainly unrelated to the run point at (3).
|
|
115
|
+
*
|
|
116
|
+
* `killLeader` is kept but is NOT the discriminator it shipped as. After
|
|
117
|
+
* `close` the leader has been reaped, so it reads `gone` in every realistic
|
|
118
|
+
* recurrence; `ours` / `not-ours` need the same implausible wrap as (3). Do not
|
|
119
|
+
* read `not-ours` as "recycled pgid confirmed" — that inference was wrong.
|
|
120
|
+
*
|
|
121
|
+
* Diagnostic only: it changes no control flow and rethrows the same error.
|
|
122
|
+
*/
|
|
123
|
+
function describeGroupSignalFailure(error, { site, pid, signal }) {
|
|
124
|
+
error.killSite = site;
|
|
125
|
+
error.killTarget = -pid;
|
|
126
|
+
error.killSignal = signal;
|
|
127
|
+
error.killerPgid = typeof process.getpgrp === 'function' ? process.getpgrp() : null;
|
|
128
|
+
try {
|
|
129
|
+
// The LEADER as a plain pid, not the group. Probing must never throw out of
|
|
130
|
+
// an error path, so every outcome is recorded rather than raised.
|
|
131
|
+
process.kill(pid, 0);
|
|
132
|
+
error.killLeader = 'ours';
|
|
133
|
+
} catch (probe) {
|
|
134
|
+
error.killLeader = probe.code === 'EPERM' ? 'not-ours' : (probe.code === 'ESRCH' ? 'gone' : probe.code);
|
|
135
|
+
}
|
|
136
|
+
error.killGroupMembers = groupMembers(pid);
|
|
137
|
+
const members = Array.isArray(error.killGroupMembers)
|
|
138
|
+
? (error.killGroupMembers.length
|
|
139
|
+
? error.killGroupMembers.map((m) => `${m.pid}/uid ${m.uid} ${m.comm}`).join(', ')
|
|
140
|
+
: 'none')
|
|
141
|
+
: `unreadable (${error.killGroupMembers.error})`;
|
|
142
|
+
error.message = `${error.message} (${site} ${String(signal)} → pgid ${pid}; `
|
|
143
|
+
+ `leader ${error.killLeader}; our pgid ${error.killerPgid}; our uid ${process.getuid?.() ?? '?'}; `
|
|
144
|
+
+ `group members: ${members})`;
|
|
145
|
+
return error;
|
|
146
|
+
}
|
|
147
|
+
|
|
38
148
|
export function processTermination(child, group, graceMs = graceMsFromEnv(), reapTimeoutMs = 2000) {
|
|
39
149
|
let closed = false;
|
|
40
150
|
const close = new Promise((resolve) => child.once('close', () => { closed = true; resolve(); }));
|
|
@@ -45,7 +155,9 @@ export function processTermination(child, group, graceMs = graceMsFromEnv(), rea
|
|
|
45
155
|
try {
|
|
46
156
|
process.kill(-child.pid, signal);
|
|
47
157
|
} catch (error) {
|
|
48
|
-
if (error.code !== 'ESRCH')
|
|
158
|
+
if (error.code !== 'ESRCH') {
|
|
159
|
+
throw describeGroupSignalFailure(error, { site: 'send', pid: child.pid, signal });
|
|
160
|
+
}
|
|
49
161
|
}
|
|
50
162
|
} else if (!closed) {
|
|
51
163
|
child.kill(signal);
|
|
@@ -61,15 +173,21 @@ export function processTermination(child, group, graceMs = graceMsFromEnv(), rea
|
|
|
61
173
|
return true;
|
|
62
174
|
} catch (error) {
|
|
63
175
|
if (error.code === 'ESRCH') return false;
|
|
64
|
-
throw error;
|
|
176
|
+
throw describeGroupSignalFailure(error, { site: 'alive', pid: child.pid, signal: 0 });
|
|
65
177
|
}
|
|
66
178
|
};
|
|
67
179
|
|
|
68
180
|
const terminate = () => {
|
|
69
181
|
teardown ??= (async () => {
|
|
70
|
-
send('SIGTERM');
|
|
71
182
|
let timer;
|
|
72
183
|
try {
|
|
184
|
+
// INSIDE the try. Outside it, a refused opening SIGTERM escaped with
|
|
185
|
+
// Node's bare `EPERM` as its code, so the one failure the caller is
|
|
186
|
+
// told to expect from a group signal — CANCELLATION_UNCONFIRMED, per
|
|
187
|
+
// `describeGroupSignalFailure` — was the one code it never got. Only
|
|
188
|
+
// the later `alive()` and SIGKILL sites were ever labelled. Found by
|
|
189
|
+
// the D-TERM-1 test below, which is the first test this path ever had.
|
|
190
|
+
send('SIGTERM');
|
|
73
191
|
await Promise.race([
|
|
74
192
|
new Promise((resolve) => { timer = setTimeout(resolve, graceMs); }),
|
|
75
193
|
// A leader that closed while its group lives must still wait out the
|