sneakoscope 3.0.4 → 3.1.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.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/crates/sks-core/src/main.rs +1 -1
  5. package/dist/.sks-build-stamp.json +4 -4
  6. package/dist/bin/sks.js +1 -1
  7. package/dist/cli/command-registry.js +1 -0
  8. package/dist/cli/context7-command.js +29 -5
  9. package/dist/cli/install-helpers.js +15 -7
  10. package/dist/commands/zellij-slot-column-anchor.js +3 -1
  11. package/dist/commands/zellij-slot-pane.js +19 -2
  12. package/dist/core/agents/agent-janitor.js +10 -1
  13. package/dist/core/agents/agent-orchestrator.js +1 -0
  14. package/dist/core/agents/agent-runner-ollama.js +11 -4
  15. package/dist/core/agents/native-cli-session-swarm.js +69 -9
  16. package/dist/core/agents/runtime-proof-summary.js +4 -0
  17. package/dist/core/codex-control/codex-task-runner.js +9 -0
  18. package/dist/core/commands/goal-command.js +19 -1
  19. package/dist/core/commands/loop-command.js +176 -0
  20. package/dist/core/commands/naruto-command.js +26 -17
  21. package/dist/core/commands/team-command.js +1 -0
  22. package/dist/core/fsx.js +1 -1
  23. package/dist/core/init.js +6 -1
  24. package/dist/core/locks/file-lock.js +88 -0
  25. package/dist/core/loops/goal-to-loop-compat.js +23 -0
  26. package/dist/core/loops/loop-artifacts.js +72 -0
  27. package/dist/core/loops/loop-checkpoint.js +22 -0
  28. package/dist/core/loops/loop-decomposer.js +56 -0
  29. package/dist/core/loops/loop-finalizer.js +54 -0
  30. package/dist/core/loops/loop-gate-ladder.js +16 -0
  31. package/dist/core/loops/loop-gate-registry.js +96 -0
  32. package/dist/core/loops/loop-gate-runner.js +177 -0
  33. package/dist/core/loops/loop-gate-selector.js +52 -0
  34. package/dist/core/loops/loop-gpt-final-arbiter.js +61 -0
  35. package/dist/core/loops/loop-integration-merge.js +75 -0
  36. package/dist/core/loops/loop-iteration-runner.js +2 -0
  37. package/dist/core/loops/loop-lease.js +91 -0
  38. package/dist/core/loops/loop-observability.js +19 -0
  39. package/dist/core/loops/loop-owner-inference.js +57 -0
  40. package/dist/core/loops/loop-owner-ledger.js +2 -0
  41. package/dist/core/loops/loop-planner.js +170 -0
  42. package/dist/core/loops/loop-proof-summary.js +10 -0
  43. package/dist/core/loops/loop-proof.js +2 -0
  44. package/dist/core/loops/loop-risk-classifier.js +42 -0
  45. package/dist/core/loops/loop-runtime-control.js +25 -0
  46. package/dist/core/loops/loop-runtime.js +314 -0
  47. package/dist/core/loops/loop-scheduler.js +69 -0
  48. package/dist/core/loops/loop-schema.js +63 -0
  49. package/dist/core/loops/loop-state.js +61 -0
  50. package/dist/core/loops/loop-worker-prompts.js +43 -0
  51. package/dist/core/loops/loop-worker-runtime.js +275 -0
  52. package/dist/core/loops/loop-worktree-runtime.js +92 -0
  53. package/dist/core/naruto/naruto-finalizer.js +7 -2
  54. package/dist/core/naruto/naruto-loop-mesh.js +39 -0
  55. package/dist/core/naruto/naruto-loop-worker-router.js +38 -0
  56. package/dist/core/pipeline-internals/runtime-core.js +82 -2
  57. package/dist/core/proof/proof-schema.js +6 -0
  58. package/dist/core/proof/proof-writer.js +5 -2
  59. package/dist/core/proof/root-cause-policy.js +70 -0
  60. package/dist/core/proof/route-adapter.js +18 -1
  61. package/dist/core/proof/route-proof-gate.js +4 -0
  62. package/dist/core/release/release-gate-batch-runner.js +56 -10
  63. package/dist/core/release/release-gate-cache-v2.js +18 -3
  64. package/dist/core/release/release-gate-dag.js +65 -17
  65. package/dist/core/release/release-gate-node.js +2 -1
  66. package/dist/core/release/release-gate-resource-governor.js +27 -6
  67. package/dist/core/skills/core-skill-meta-update.js +24 -0
  68. package/dist/core/skills/core-skill-reflection.js +94 -0
  69. package/dist/core/skills/core-skill-trainer.js +103 -0
  70. package/dist/core/trust-kernel/completion-contract.js +4 -0
  71. package/dist/core/trust-kernel/route-contract.js +4 -1
  72. package/dist/core/version.js +1 -1
  73. package/dist/core/zellij/zellij-right-column-manager.js +13 -2
  74. package/dist/core/zellij/zellij-slot-column-anchor.js +45 -5
  75. package/dist/core/zellij/zellij-slot-pane-renderer.js +37 -10
  76. package/dist/core/zellij/zellij-slot-telemetry.js +96 -44
  77. package/dist/core/zellij/zellij-worker-pane-manager.js +42 -4
  78. package/dist/scripts/loop-directive-check-lib.js +388 -0
  79. package/dist/scripts/loop-worker-fixture-child.js +53 -0
  80. package/dist/scripts/naruto-real-local-gpt-final-smoke.js +10 -1
  81. package/package.json +38 -3
  82. package/schemas/loops/loop-node.schema.json +21 -0
  83. package/schemas/loops/loop-plan.schema.json +21 -0
  84. package/schemas/loops/loop-proof.schema.json +20 -0
  85. package/schemas/loops/loop-state.schema.json +19 -0
@@ -1213,9 +1213,17 @@ export async function recordContext7Evidence(root, state, payload) {
1213
1213
  return null;
1214
1214
  if (!await shouldWritePipelineEvidence(root, state))
1215
1215
  return null;
1216
- const record = { ts: nowIso(), stage, tool: context7ToolName(payload), payload_keys: Object.keys(payload || {}).sort() };
1217
1216
  const id = state?.mission_id;
1218
1217
  const file = id ? path.join(missionDir(root, id), 'context7-evidence.jsonl') : path.join(root, '.sneakoscope', 'state', 'context7-evidence.jsonl');
1218
+ const record = {
1219
+ ts: nowIso(),
1220
+ stage,
1221
+ tool: context7ToolName(payload),
1222
+ payload_keys: Object.keys(payload || {}).sort(),
1223
+ dedupe_key: context7DedupeKey(stage, payload)
1224
+ };
1225
+ if (await hasContext7EvidenceRecord(file, record.dedupe_key))
1226
+ return null;
1219
1227
  await appendJsonl(file, record);
1220
1228
  if (id) {
1221
1229
  const evidence = await context7Evidence(root, state);
@@ -1297,7 +1305,11 @@ function context7ToolName(payload) {
1297
1305
  return String(obj.tool_name || obj.name || obj.tool?.name || obj.mcp_tool || obj.command || obj.type || '');
1298
1306
  }
1299
1307
  function context7Stage(payload) {
1300
- const hay = JSON.stringify(payload || {});
1308
+ const tool = context7ToolName(payload);
1309
+ const direct = context7DirectSignal(payload);
1310
+ if (!direct && !context7ToolLooksRelevant(tool))
1311
+ return null;
1312
+ const hay = [tool, direct].filter(Boolean).join('\n');
1301
1313
  if (!/(context7|resolve[-_]?library[-_]?id|get[-_]?library[-_]?docs|query[-_]?docs)/i.test(hay))
1302
1314
  return null;
1303
1315
  if (/resolve[-_]?library[-_]?id/i.test(hay))
@@ -1306,6 +1318,74 @@ function context7Stage(payload) {
1306
1318
  return 'get-library-docs';
1307
1319
  return 'context7';
1308
1320
  }
1321
+ function context7ToolLooksRelevant(tool) {
1322
+ return /(^|[_:/.-])(context7|resolve[-_]?library[-_]?id|get[-_]?library[-_]?docs|query[-_]?docs)($|[_:/.-])/i.test(String(tool || ''));
1323
+ }
1324
+ function context7DirectSignal(payload = {}) {
1325
+ const source = String(payload.source || '');
1326
+ const tool = context7ToolName(payload);
1327
+ if (/^sks context7 evidence/i.test(source)) {
1328
+ return [
1329
+ tool,
1330
+ source,
1331
+ payload.library,
1332
+ payload.library_id,
1333
+ payload.docs_tool
1334
+ ].filter(Boolean).join('\n');
1335
+ }
1336
+ if (context7ToolLooksRelevant(tool)) {
1337
+ const input = payload.tool_input || payload.toolInput || payload.input || payload.tool?.input || {};
1338
+ return JSON.stringify({
1339
+ tool,
1340
+ library: payload.library,
1341
+ library_id: payload.library_id,
1342
+ docs_tool: payload.docs_tool,
1343
+ input: context7SafeInput(input)
1344
+ });
1345
+ }
1346
+ return '';
1347
+ }
1348
+ function context7SafeInput(input) {
1349
+ if (!input || typeof input !== 'object' || Array.isArray(input))
1350
+ return input ?? null;
1351
+ const out = {};
1352
+ for (const key of ['name', 'tool', 'library', 'libraryName', 'library_id', 'libraryId', 'context7CompatibleLibraryID', 'query', 'topic', 'tokens']) {
1353
+ if (Object.prototype.hasOwnProperty.call(input, key))
1354
+ out[key] = input[key];
1355
+ }
1356
+ return out;
1357
+ }
1358
+ function context7DedupeKey(stage, payload = {}) {
1359
+ const input = payload.tool_input || payload.toolInput || payload.input || payload.tool?.input || {};
1360
+ const library = payload.library_id
1361
+ || payload.library
1362
+ || input.libraryId
1363
+ || input.context7CompatibleLibraryID
1364
+ || input.libraryName
1365
+ || input.library
1366
+ || '';
1367
+ const query = input.query || input.topic || payload.query || payload.topic || '';
1368
+ return [
1369
+ stage,
1370
+ context7ToolName(payload),
1371
+ String(library).trim().toLowerCase(),
1372
+ String(query).trim().toLowerCase()
1373
+ ].join('|');
1374
+ }
1375
+ async function hasContext7EvidenceRecord(file, key) {
1376
+ const text = await readText(file, '');
1377
+ for (const line of text.split(/\n/)) {
1378
+ if (!line.trim())
1379
+ continue;
1380
+ try {
1381
+ const entry = JSON.parse(line);
1382
+ if (entry.dedupe_key === key)
1383
+ return true;
1384
+ }
1385
+ catch { }
1386
+ }
1387
+ return false;
1388
+ }
1309
1389
  export async function context7Evidence(root, state) {
1310
1390
  const id = state?.mission_id;
1311
1391
  if (!id)
@@ -38,6 +38,12 @@ export function emptyCompletionProof(overrides = {}) {
38
38
  claims: [],
39
39
  unverified: [],
40
40
  blockers: [],
41
+ failure_analysis: {
42
+ status: 'not_required',
43
+ root_cause: null,
44
+ corrective_action: null,
45
+ evidence: []
46
+ },
41
47
  next_human_actions: [],
42
48
  ...overrides
43
49
  };
@@ -76,10 +76,13 @@ export function renderProofMarkdown(proof = {}, validation = validateCompletionP
76
76
  `- Wrongness: ${proof.evidence?.wrongness?.active_count ?? 0} active (${proof.evidence?.wrongness?.high_severity_active ?? 0} high)`,
77
77
  `- Evidence router: ${proof.evidence?.evidence_router?.records ?? 0} record(s)`,
78
78
  `- Trust report: ${proof.evidence?.trust_report || 'not_recorded'}`,
79
- '',
80
- '## Unverified',
81
79
  ''
82
80
  ];
81
+ const failureAnalysis = proof.failure_analysis;
82
+ if (failureAnalysis && (failureAnalysis.status !== 'not_required' || failureAnalysis.root_cause || failureAnalysis.corrective_action)) {
83
+ lines.push('## Failure Analysis', '', `- Status: ${failureAnalysis.status || 'unknown'}`, `- Root cause: ${failureAnalysis.root_cause || 'not_recorded'}`, `- Corrective action: ${failureAnalysis.corrective_action || 'not_recorded'}`, `- Evidence: ${Array.isArray(failureAnalysis.evidence) ? failureAnalysis.evidence.length : failureAnalysis.evidence ? 1 : 0}`, '');
84
+ }
85
+ lines.push('## Unverified', '');
83
86
  const unverified = proof.unverified?.length ? proof.unverified : ['No unverified claims recorded.'];
84
87
  for (const item of unverified)
85
88
  lines.push(`- ${typeof item === 'string' ? item : JSON.stringify(item)}`);
@@ -0,0 +1,70 @@
1
+ const PROBLEM_PATTERN = /\b(fallback|workaround|bypass|temporary|synthetic|stale|missing|failed|failure|error|blocked|not_ok|not ok|fixture_child_missing|native_agent_proof_false)\b/i;
2
+ const COMPLETE_STATUSES = new Set(['complete', 'completed', 'corrected', 'resolved', 'fixed']);
3
+ const BLOCKING_STATUSES = new Set(['blocked', 'failed', 'not_verified']);
4
+ function asRecord(value) {
5
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
6
+ }
7
+ function asList(value) {
8
+ return Array.isArray(value) ? value : [];
9
+ }
10
+ function meaningfulString(value, minLength = 12) {
11
+ return typeof value === 'string' && value.trim().length >= minLength;
12
+ }
13
+ function hasEvidence(value) {
14
+ if (typeof value === 'string')
15
+ return value.trim().length >= 6;
16
+ if (Array.isArray(value))
17
+ return value.length > 0;
18
+ if (value && typeof value === 'object')
19
+ return Object.keys(value).length > 0;
20
+ return false;
21
+ }
22
+ export function rootCauseAnalysisRequired(proof = {}, validationIssues = []) {
23
+ const proofRecord = asRecord(proof);
24
+ if (!Object.keys(proofRecord).length)
25
+ return false;
26
+ const evidence = asRecord(proofRecord.evidence);
27
+ const agents = asRecord(evidence.agents);
28
+ const routeGate = asRecord(evidence.route_gate);
29
+ if (BLOCKING_STATUSES.has(String(proofRecord.status || '')))
30
+ return true;
31
+ if (asList(proofRecord.blockers).length > 0)
32
+ return true;
33
+ if (validationIssues.some((issue) => String(issue) !== 'root_cause_analysis_missing'))
34
+ return true;
35
+ const problemSurface = {
36
+ status: proofRecord.status,
37
+ unverified: proofRecord.unverified,
38
+ blockers: proofRecord.blockers,
39
+ claims: proofRecord.claims,
40
+ route_gate: routeGate,
41
+ agents: {
42
+ ok: agents.ok,
43
+ status: agents.status,
44
+ blockers: agents.blockers,
45
+ issues: agents.issues
46
+ },
47
+ wrongness: evidence.wrongness,
48
+ trust_report: evidence.trust_report
49
+ };
50
+ return PROBLEM_PATTERN.test(JSON.stringify(problemSurface));
51
+ }
52
+ export function rootCauseAnalysisComplete(proof = {}) {
53
+ const proofRecord = asRecord(proof);
54
+ const analysis = asRecord(proofRecord.failure_analysis || asRecord(proofRecord.evidence).root_cause_analysis);
55
+ if (!Object.keys(analysis).length)
56
+ return false;
57
+ const status = String(analysis.status || '').toLowerCase();
58
+ if (!COMPLETE_STATUSES.has(status))
59
+ return false;
60
+ const rootCause = analysis.root_cause ?? analysis.cause;
61
+ const correctiveAction = analysis.corrective_action ?? analysis.fix ?? analysis.correction;
62
+ const evidence = analysis.evidence ?? analysis.proof ?? analysis.references;
63
+ return meaningfulString(rootCause) && meaningfulString(correctiveAction) && hasEvidence(evidence);
64
+ }
65
+ export function rootCauseAnalysisIssue(proof = {}, validationIssues = []) {
66
+ if (!rootCauseAnalysisRequired(proof, validationIssues))
67
+ return null;
68
+ return rootCauseAnalysisComplete(proof) ? null : 'root_cause_analysis_missing';
69
+ }
70
+ //# sourceMappingURL=root-cause-policy.js.map
@@ -5,7 +5,7 @@ import { normalizeProofRoute, routeRequiresImageVoxelAnchors } from './route-pro
5
5
  import { linkProofClaimsToEvidence, proofEvidenceSummary } from '../evidence/evidence-proof-linker.js';
6
6
  import { writeTrustArtifactsForProof } from '../trust-kernel/trust-report.js';
7
7
  import { enforceRetention } from '../retention.js';
8
- export async function writeRouteCompletionProof(root, { missionId = null, route = null, status = 'verified_partial', gate = null, summary = {}, artifacts = [], evidence = {}, claims = [], unverified = [], blockers = [], nextHumanActions = [] } = {}) {
8
+ export async function writeRouteCompletionProof(root, { missionId = null, route = null, status = 'verified_partial', gate = null, summary = {}, artifacts = [], evidence = {}, claims = [], unverified = [], blockers = [], failureAnalysis = null, nextHumanActions = [] } = {}) {
9
9
  const collected = await collectProofEvidence(root);
10
10
  const normalizedRoute = normalizeProofRoute(route);
11
11
  const mergedEvidence = {
@@ -36,6 +36,7 @@ export async function writeRouteCompletionProof(root, { missionId = null, route
36
36
  claims,
37
37
  unverified,
38
38
  blockers,
39
+ failure_analysis: normalizeFailureAnalysis(failureAnalysis || evidence.failure_analysis || evidence.root_cause_analysis),
39
40
  next_human_actions: nextHumanActions
40
41
  }, {
41
42
  command: {
@@ -68,6 +69,22 @@ export async function writeRouteCompletionProof(root, { missionId = null, route
68
69
  const retention = await runPostRouteRetention(root, missionId);
69
70
  return { ...enriched, trust, retention };
70
71
  }
72
+ function normalizeFailureAnalysis(value) {
73
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
74
+ return {
75
+ status: 'not_required',
76
+ root_cause: null,
77
+ corrective_action: null,
78
+ evidence: []
79
+ };
80
+ }
81
+ return {
82
+ status: value.status || 'complete',
83
+ root_cause: value.root_cause || value.cause || null,
84
+ corrective_action: value.corrective_action || value.fix || value.correction || null,
85
+ evidence: value.evidence || value.proof || value.references || []
86
+ };
87
+ }
71
88
  function normalizeRouteProofStatus(status, { route, evidence, blockers, unverified }) {
72
89
  if (blockers?.length)
73
90
  return status === 'failed' ? 'failed' : 'blocked';
@@ -3,6 +3,7 @@ import { readRouteProof } from './proof-reader.js';
3
3
  import { validateCompletionProof } from './validation.js';
4
4
  import { normalizeProofRoute, proofStatusBlocks, routeRequiresCompletionProof, routeRequiresImageVoxelAnchors } from './route-proof-policy.js';
5
5
  import { routeRequiresAgentIntake } from '../agents/agent-plan.js';
6
+ import { rootCauseAnalysisIssue } from './root-cause-policy.js';
6
7
  export async function validateRouteCompletionProof(root, { missionId = null, route = null, state = {}, visualClaim = undefined } = {}) {
7
8
  const proofRequired = state.proof_required === true || routeRequiresCompletionProof(route);
8
9
  if (!proofRequired)
@@ -61,6 +62,9 @@ export async function validateRouteCompletionProof(root, { missionId = null, rou
61
62
  issues.push('active_wrongness_high');
62
63
  if (proof.status === 'verified' && Number(wrongness?.active_count || 0) > 0)
63
64
  issues.push('active_wrongness_requires_partial');
65
+ const rootCauseIssue = rootCauseAnalysisIssue(proof, issues);
66
+ if (rootCauseIssue)
67
+ issues.push(rootCauseIssue);
64
68
  return {
65
69
  ok: issues.length === 0,
66
70
  required: true,
@@ -1,7 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import path from 'node:path';
3
+ import { createReleaseGateHermeticEnv } from './release-gate-hermetic-env.js';
3
4
  import { writeReleaseGateJson } from './release-gate-report.js';
4
- const DISALLOWED_BATCH_RESOURCES = new Set(['zellij-real', 'git-worktree', 'local-llm-real', 'remote-model-real', 'publish', 'global-config']);
5
+ import { guardedProcessKill, guardContextForRoute } from '../safety/mutation-guard.js';
6
+ import { createRequestedScopeContract } from '../safety/requested-scope-contract.js';
7
+ const DISALLOWED_BATCH_RESOURCES = new Set(['zellij-real', 'git-worktree', 'local-llm-real', 'remote-model-real', 'publish', 'global-config', 'timing-sensitive']);
5
8
  export function isReleaseGateBatchable(gate) {
6
9
  if (gate.side_effect !== 'hermetic')
7
10
  return false;
@@ -11,6 +14,8 @@ export function isReleaseGateBatchable(gate) {
11
14
  }
12
15
  export async function runReleaseGateBatch(root, gates, input = {}) {
13
16
  const concurrency = Math.max(1, Math.floor(Number(input.concurrency || process.env.SKS_RELEASE_BATCH_CONCURRENCY || 4)));
17
+ const runId = `rgb-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
18
+ const reportRoot = input.reportRoot || path.join(root, '.sneakoscope', 'reports', 'release-gate-batches', runId);
14
19
  const nonBatchable = gates.filter((gate) => !isReleaseGateBatchable(gate));
15
20
  if (nonBatchable.length) {
16
21
  return {
@@ -19,7 +24,7 @@ export async function runReleaseGateBatch(root, gates, input = {}) {
19
24
  batch_size: gates.length,
20
25
  completed: 0,
21
26
  failed: nonBatchable.length,
22
- results: nonBatchable.map((gate) => ({ id: gate.id, ok: false, exit_code: null, duration_ms: 0 }))
27
+ results: nonBatchable.map((gate) => ({ id: gate.id, ok: false, exit_code: null, signal: null, timed_out: false, duration_ms: 0 }))
23
28
  };
24
29
  }
25
30
  const queue = [...gates];
@@ -29,7 +34,7 @@ export async function runReleaseGateBatch(root, gates, input = {}) {
29
34
  const gate = queue.shift();
30
35
  if (!gate)
31
36
  continue;
32
- const result = await runOne(root, gate);
37
+ const result = await runOne(root, runId, reportRoot, gate);
33
38
  results.push(result);
34
39
  if (input.reportRoot)
35
40
  writeChildResult(input.reportRoot, result);
@@ -46,19 +51,60 @@ export async function runReleaseGateBatch(root, gates, input = {}) {
46
51
  results
47
52
  };
48
53
  }
49
- function runOne(root, gate) {
54
+ function runOne(root, runId, reportRoot, gate) {
50
55
  const started = Date.now();
56
+ const hermetic = createReleaseGateHermeticEnv({ root, runId, gate, reportRoot });
51
57
  return new Promise((resolve) => {
52
- const child = spawn(gate.command, { cwd: root, shell: true, stdio: ['ignore', 'ignore', 'ignore'] });
53
- const timer = setTimeout(() => child.kill('SIGTERM'), gate.timeout_ms);
54
- child.on('close', (code) => {
55
- clearTimeout(timer);
56
- resolve({ id: gate.id, ok: code === 0, exit_code: code, duration_ms: Date.now() - started });
58
+ const child = spawn(gate.command, { cwd: root, shell: true, env: hermetic.env, stdio: ['ignore', 'ignore', 'ignore'], detached: process.platform !== 'win32' });
59
+ let timedOut = false;
60
+ let timeoutCleanup = null;
61
+ const timer = setTimeout(() => {
62
+ timedOut = true;
63
+ timeoutCleanup = cleanupTimedOutGateProcessTree(root, child);
64
+ }, gate.timeout_ms);
65
+ timer.unref?.();
66
+ child.on('close', (code, signal) => {
67
+ void (async () => {
68
+ clearTimeout(timer);
69
+ if (timeoutCleanup)
70
+ await timeoutCleanup;
71
+ const exitCode = timedOut ? 124 : code;
72
+ resolve({ id: gate.id, ok: exitCode === 0, exit_code: exitCode, signal, timed_out: timedOut, duration_ms: Date.now() - started, report_dir: hermetic.report_dir });
73
+ })();
57
74
  });
58
75
  });
59
76
  }
77
+ async function cleanupTimedOutGateProcessTree(root, child) {
78
+ await killGateProcessTree(root, child, 'SIGTERM');
79
+ await sleep(1500);
80
+ await killGateProcessTree(root, child, 'SIGKILL');
81
+ await sleep(100);
82
+ }
83
+ async function killGateProcessTree(root, child, signal) {
84
+ if (!child.pid)
85
+ return;
86
+ const pid = process.platform !== 'win32' ? -child.pid : child.pid;
87
+ const contract = createRequestedScopeContract({
88
+ route: 'release:gate-batch-runner',
89
+ userRequest: 'Terminate only the batched release gate child process tree after its configured timeout.',
90
+ projectRoot: root,
91
+ overrides: { codex_app_process: true }
92
+ });
93
+ try {
94
+ await guardedProcessKill(guardContextForRoute(root, contract, 'release gate batch timeout cleanup'), pid, { signal, confirmed: true });
95
+ }
96
+ catch {
97
+ try {
98
+ child.kill(signal);
99
+ }
100
+ catch { }
101
+ }
102
+ }
103
+ function sleep(ms) {
104
+ return new Promise((resolve) => setTimeout(resolve, ms));
105
+ }
60
106
  function writeChildResult(reportRoot, result) {
61
- const dir = path.join(reportRoot, result.id.replace(/[^A-Za-z0-9_.:-]/g, '_'));
107
+ const dir = result.report_dir || path.join(reportRoot, result.id.replace(/[^A-Za-z0-9_.:-]/g, '_'));
62
108
  writeReleaseGateJson(path.join(dir, 'result.json'), {
63
109
  schema: 'sks.release-gate-batch-child-result.v1',
64
110
  ...result
@@ -114,15 +114,29 @@ export function hashDirectoryRecursive(dir) {
114
114
  return out.sort();
115
115
  }
116
116
  export function readReleaseGateCacheHit(root, gate) {
117
+ return Boolean(readReleaseGateCacheRecord(root, gate));
118
+ }
119
+ export function readReleaseGateCacheRecord(root, gate) {
117
120
  try {
118
121
  const parsed = JSON.parse(fs.readFileSync(releaseGateCacheFile(root), 'utf8'));
119
- return parsed.schema === RELEASE_GATE_CACHE_V2_SCHEMA && parsed.records?.[releaseGateCacheKey(root, gate)]?.ok === true;
122
+ const record = parsed.schema === RELEASE_GATE_CACHE_V2_SCHEMA ? parsed.records?.[releaseGateCacheKey(root, gate)] : null;
123
+ if (record?.ok !== true)
124
+ return null;
125
+ return {
126
+ ok: true,
127
+ gate_id: String(record.gate_id || gate.id),
128
+ command: String(record.command || gate.command),
129
+ resource: Array.isArray(record.resource) ? record.resource.map(String) : gate.resource,
130
+ preset: Array.isArray(record.preset) ? record.preset.map(String) : gate.preset,
131
+ duration_ms: Math.max(0, Math.floor(Number(record.duration_ms) || 0)),
132
+ recorded_at: String(record.recorded_at || '')
133
+ };
120
134
  }
121
135
  catch {
122
- return false;
136
+ return null;
123
137
  }
124
138
  }
125
- export function writeReleaseGateCacheHit(root, gate) {
139
+ export function writeReleaseGateCacheHit(root, gate, durationMs = 0) {
126
140
  const file = releaseGateCacheFile(root);
127
141
  let parsed = { schema: RELEASE_GATE_CACHE_V2_SCHEMA, records: {} };
128
142
  try {
@@ -137,6 +151,7 @@ export function writeReleaseGateCacheHit(root, gate) {
137
151
  command: gate.command,
138
152
  resource: gate.resource,
139
153
  preset: gate.preset,
154
+ duration_ms: Math.max(0, Math.floor(Number(durationMs) || 0)),
140
155
  recorded_at: new Date().toISOString()
141
156
  };
142
157
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -4,10 +4,12 @@ import { spawn } from 'node:child_process';
4
4
  import { createReleaseGateHermeticEnv } from './release-gate-hermetic-env.js';
5
5
  import { appendReleaseGateJsonl, writeReleaseGateJson } from './release-gate-report.js';
6
6
  import { findReadyReleaseGateNodes, findReleaseGatesBlockedByFailedDeps, pickReadyLaunchableReleaseGates } from './release-gate-scheduler.js';
7
- import { readReleaseGateCacheHit, writeReleaseGateCacheHit } from './release-gate-cache-v2.js';
7
+ import { readReleaseGateCacheRecord, writeReleaseGateCacheHit } from './release-gate-cache-v2.js';
8
8
  import { RELEASE_GATE_NODE_SCHEMA, validateReleaseGateManifest } from './release-gate-node.js';
9
9
  import { countReleaseGateResources, defaultReleaseGateBudget, summarizeReleaseGateBudget } from './release-gate-resource-governor.js';
10
10
  import { selectAffectedReleaseGates } from './release-gate-affected-selector.js';
11
+ import { guardedProcessKill, guardContextForRoute } from '../safety/mutation-guard.js';
12
+ import { createRequestedScopeContract } from '../safety/requested-scope-contract.js';
11
13
  export function loadReleaseGateManifest(root, file = 'release-gates.v2.json') {
12
14
  const manifestPath = path.join(root, file);
13
15
  const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
@@ -48,7 +50,7 @@ export async function runReleaseGateDag(input) {
48
50
  let peakRunning = 0;
49
51
  const writeSummarySnapshot = (finished = false) => {
50
52
  const wallMs = Date.now() - started;
51
- const failures = [...failed.values()].map((row) => ({ id: row.id, exit_code: row.exit_code, stderr_tail: row.stderr_tail }));
53
+ const failures = [...failed.values()].map((row) => ({ id: row.id, exit_code: row.exit_code, stderr_tail: row.stderr_tail, timed_out: row.timed_out, signal: row.signal }));
52
54
  const snapshot = {
53
55
  schema: 'sks.release-gate-dag-run.v1',
54
56
  ok: failures.length === 0,
@@ -97,14 +99,15 @@ export async function runReleaseGateDag(input) {
97
99
  let progressed = false;
98
100
  for (const gate of launchable) {
99
101
  pending.delete(gate.id);
100
- const cacheHit = !input.noCache && gate.cache.enabled && readReleaseGateCacheHit(root, gate);
102
+ const cacheHit = !input.noCache && gate.cache.enabled ? readReleaseGateCacheRecord(root, gate) : null;
101
103
  if (cacheHit) {
102
- const result = { id: gate.id, ok: true, exit_code: 0, duration_ms: 0, cached: true, stderr_tail: '' };
104
+ const result = { id: gate.id, ok: true, exit_code: 0, signal: null, timed_out: false, duration_ms: cacheHit.duration_ms, cached: true, stderr_tail: '' };
103
105
  completed.set(gate.id, result);
104
106
  cached += 1;
105
107
  cachedGates.push(gate.id);
108
+ sumGateMs += result.duration_ms;
106
109
  progressed = true;
107
- appendReleaseGateJsonl(timeline, { event: 'cache_hit', gate_id: gate.id, at: new Date().toISOString() });
110
+ appendReleaseGateJsonl(timeline, { event: 'cache_hit', gate_id: gate.id, duration_ms: result.duration_ms, at: new Date().toISOString() });
108
111
  writeSummarySnapshot(false);
109
112
  continue;
110
113
  }
@@ -127,6 +130,8 @@ export async function runReleaseGateDag(input) {
127
130
  id: gate.id,
128
131
  ok: false,
129
132
  exit_code: null,
133
+ signal: null,
134
+ timed_out: false,
130
135
  duration_ms: 0,
131
136
  cached: false,
132
137
  stderr_tail: `blocked by failed dependency: ${gate.deps.filter((dep) => failed.has(dep)).join(', ')}`
@@ -148,7 +153,7 @@ export async function runReleaseGateDag(input) {
148
153
  completed.set(result.id, result);
149
154
  const gate = selected.find((row) => row.id === result.id);
150
155
  if (gate?.cache.enabled && !input.noCache)
151
- writeReleaseGateCacheHit(root, gate);
156
+ writeReleaseGateCacheHit(root, gate, result.duration_ms);
152
157
  }
153
158
  else {
154
159
  failed.set(result.id, result);
@@ -175,22 +180,65 @@ function runGate(root, runId, reportRoot, gate) {
175
180
  const out = fs.createWriteStream(stdoutFile);
176
181
  const err = fs.createWriteStream(stderrFile);
177
182
  return new Promise((resolve) => {
178
- const child = spawn(gate.command, { cwd: root, shell: true, env: hermetic.env, stdio: ['ignore', 'pipe', 'pipe'] });
179
- const timer = setTimeout(() => child.kill('SIGTERM'), gate.timeout_ms);
183
+ const child = spawn(gate.command, { cwd: root, shell: true, env: hermetic.env, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
184
+ let timedOut = false;
185
+ let timeoutCleanup = null;
186
+ const timer = setTimeout(() => {
187
+ timedOut = true;
188
+ timeoutCleanup = cleanupTimedOutGateProcessTree(root, child);
189
+ }, gate.timeout_ms);
190
+ timer.unref?.();
180
191
  child.stdout.pipe(out);
181
192
  child.stderr.pipe(err);
182
- child.on('close', (code) => {
183
- clearTimeout(timer);
184
- out.end();
185
- err.end();
186
- const durationMs = Date.now() - started;
187
- const stderrTail = tail(fs.existsSync(stderrFile) ? fs.readFileSync(stderrFile, 'utf8') : '');
188
- const result = { id: gate.id, ok: code === 0, exit_code: code, duration_ms: durationMs, cached: false, stderr_tail: stderrTail };
189
- writeReleaseGateJson(path.join(hermetic.report_dir, 'result.json'), { schema: 'sks.release-gate-result.v1', ...result, stdout_log: stdoutFile, stderr_log: stderrFile });
190
- resolve(result);
193
+ child.on('close', (code, signal) => {
194
+ void (async () => {
195
+ clearTimeout(timer);
196
+ if (timeoutCleanup)
197
+ await timeoutCleanup;
198
+ out.end();
199
+ err.end();
200
+ const durationMs = Date.now() - started;
201
+ const stderrText = fs.existsSync(stderrFile) ? fs.readFileSync(stderrFile, 'utf8') : '';
202
+ const timeoutTail = timedOut ? `release_gate_timeout:${gate.id}:${gate.timeout_ms}ms` : '';
203
+ const signalTail = !timedOut && signal ? `release_gate_signal:${gate.id}:${signal}` : '';
204
+ const stderrTail = tail([stderrText, timeoutTail, signalTail].filter(Boolean).join('\n'));
205
+ const exitCode = timedOut ? 124 : code;
206
+ const result = { id: gate.id, ok: exitCode === 0, exit_code: exitCode, signal, timed_out: timedOut, duration_ms: durationMs, cached: false, stderr_tail: stderrTail };
207
+ writeReleaseGateJson(path.join(hermetic.report_dir, 'result.json'), { schema: 'sks.release-gate-result.v1', ...result, stdout_log: stdoutFile, stderr_log: stderrFile });
208
+ resolve(result);
209
+ })();
191
210
  });
192
211
  });
193
212
  }
213
+ async function cleanupTimedOutGateProcessTree(root, child) {
214
+ await killGateProcessTree(root, child, 'SIGTERM');
215
+ await sleep(1500);
216
+ await killGateProcessTree(root, child, 'SIGKILL');
217
+ await sleep(100);
218
+ }
219
+ async function killGateProcessTree(root, child, signal) {
220
+ if (!child.pid)
221
+ return;
222
+ const pid = process.platform !== 'win32' ? -child.pid : child.pid;
223
+ const contract = createRequestedScopeContract({
224
+ route: 'release:gate-runner',
225
+ userRequest: 'Terminate only the release gate child process tree after its configured timeout.',
226
+ projectRoot: root,
227
+ overrides: { codex_app_process: true }
228
+ });
229
+ try {
230
+ await guardedProcessKill(guardContextForRoute(root, contract, 'release gate timeout cleanup'), pid, { signal, confirmed: true });
231
+ }
232
+ catch {
233
+ try {
234
+ child.kill(signal);
235
+ }
236
+ catch { }
237
+ }
238
+ }
239
+ function sleep(ms) {
240
+ return new Promise((resolve) => setTimeout(resolve, ms));
241
+ }
194
242
  function estimateCriticalPath(gates, completed) {
195
243
  const byId = new Map(gates.map((gate) => [gate.id, gate]));
196
244
  const memo = new Map();
@@ -13,7 +13,8 @@ export const RELEASE_GATE_RESOURCE_CLASSES = [
13
13
  'network',
14
14
  'global-config',
15
15
  'publish',
16
- 'fs-read'
16
+ 'fs-read',
17
+ 'timing-sensitive'
17
18
  ];
18
19
  export function validateReleaseGateManifest(input) {
19
20
  const errors = [];
@@ -1,8 +1,9 @@
1
1
  import os from 'node:os';
2
+ const EXCLUSIVE_RESOURCES = new Set(['timing-sensitive']);
2
3
  export function defaultReleaseGateBudget() {
3
4
  const cores = Math.max(1, os.cpus().length || 1);
4
5
  const base = {
5
- 'cpu-light': Math.min(48, cores * 6),
6
+ 'cpu-light': Math.min(24, cores * 3),
6
7
  'cpu-heavy': Math.max(1, cores),
7
8
  'io-light': Math.min(96, cores * 10),
8
9
  'io-heavy': Math.min(12, Math.max(1, cores)),
@@ -15,11 +16,12 @@ export function defaultReleaseGateBudget() {
15
16
  'remote-model-real': 6,
16
17
  'global-config': 1,
17
18
  publish: 1,
18
- 'fs-read': Math.min(96, cores * 10)
19
+ 'fs-read': Math.min(96, cores * 10),
20
+ 'timing-sensitive': 1
19
21
  };
20
22
  for (const key of Object.keys(base)) {
21
23
  const envName = `SKS_RELEASE_MAX_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
22
- base[key] = envInt(envName, base[key]);
24
+ base[key] = envInt(envName, base[key], { max: base[key] });
23
25
  }
24
26
  return base;
25
27
  }
@@ -34,23 +36,39 @@ export function countReleaseGateResources(running) {
34
36
  }
35
37
  export function pickLaunchableReleaseGates(input) {
36
38
  const budget = input.budget || defaultReleaseGateBudget();
39
+ if (input.running.some(isExclusiveGate))
40
+ return [];
37
41
  const used = usedResources(input.running);
38
42
  const launchable = [];
39
- const maxTotal = envInt('SKS_RELEASE_MAX_TOTAL', Number.POSITIVE_INFINITY);
43
+ const maxTotal = envInt('SKS_RELEASE_MAX_TOTAL', defaultReleaseGateMaxTotal(), { max: defaultReleaseGateMaxTotal() });
40
44
  for (const gate of input.ready) {
41
45
  if (input.running.length + launchable.length >= maxTotal)
42
46
  break;
47
+ const exclusive = isExclusiveGate(gate);
48
+ if (exclusive && (input.running.length > 0 || launchable.length > 0))
49
+ continue;
50
+ if (!exclusive && launchable.some(isExclusiveGate))
51
+ continue;
43
52
  if (fits(gate, used, budget)) {
44
53
  launchable.push(gate);
45
54
  for (const resource of gate.resource)
46
55
  used[resource] = (used[resource] || 0) + 1;
56
+ if (exclusive)
57
+ break;
47
58
  }
48
59
  }
49
60
  return launchable;
50
61
  }
51
- function envInt(name, fallback) {
62
+ export function defaultReleaseGateMaxTotal() {
63
+ const cores = Math.max(1, os.cpus().length || 1);
64
+ return Math.max(8, Math.min(32, cores * 3));
65
+ }
66
+ function envInt(name, fallback, opts = {}) {
52
67
  const parsed = Number(process.env[name]);
53
- return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
68
+ if (!Number.isFinite(parsed) || parsed <= 0)
69
+ return fallback;
70
+ const value = Math.floor(parsed);
71
+ return typeof opts.max === 'number' ? Math.min(value, opts.max) : value;
54
72
  }
55
73
  function usedResources(running) {
56
74
  const used = {};
@@ -63,4 +81,7 @@ function usedResources(running) {
63
81
  function fits(gate, used, budget) {
64
82
  return gate.resource.every((resource) => (used[resource] || 0) < budget[resource]);
65
83
  }
84
+ function isExclusiveGate(gate) {
85
+ return gate.resource.some((resource) => EXCLUSIVE_RESOURCES.has(resource));
86
+ }
66
87
  //# sourceMappingURL=release-gate-resource-governor.js.map