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.
- package/README.md +1 -1
- package/crates/sks-core/Cargo.lock +1 -1
- package/crates/sks-core/Cargo.toml +1 -1
- package/crates/sks-core/src/main.rs +1 -1
- package/dist/.sks-build-stamp.json +4 -4
- package/dist/bin/sks.js +1 -1
- package/dist/cli/command-registry.js +1 -0
- package/dist/cli/context7-command.js +29 -5
- package/dist/cli/install-helpers.js +15 -7
- package/dist/commands/zellij-slot-column-anchor.js +3 -1
- package/dist/commands/zellij-slot-pane.js +19 -2
- package/dist/core/agents/agent-janitor.js +10 -1
- package/dist/core/agents/agent-orchestrator.js +1 -0
- package/dist/core/agents/agent-runner-ollama.js +11 -4
- package/dist/core/agents/native-cli-session-swarm.js +69 -9
- package/dist/core/agents/runtime-proof-summary.js +4 -0
- package/dist/core/codex-control/codex-task-runner.js +9 -0
- package/dist/core/commands/goal-command.js +19 -1
- package/dist/core/commands/loop-command.js +176 -0
- package/dist/core/commands/naruto-command.js +26 -17
- package/dist/core/commands/team-command.js +1 -0
- package/dist/core/fsx.js +1 -1
- package/dist/core/init.js +6 -1
- package/dist/core/locks/file-lock.js +88 -0
- package/dist/core/loops/goal-to-loop-compat.js +23 -0
- package/dist/core/loops/loop-artifacts.js +72 -0
- package/dist/core/loops/loop-checkpoint.js +22 -0
- package/dist/core/loops/loop-decomposer.js +56 -0
- package/dist/core/loops/loop-finalizer.js +54 -0
- package/dist/core/loops/loop-gate-ladder.js +16 -0
- package/dist/core/loops/loop-gate-registry.js +96 -0
- package/dist/core/loops/loop-gate-runner.js +177 -0
- package/dist/core/loops/loop-gate-selector.js +52 -0
- package/dist/core/loops/loop-gpt-final-arbiter.js +61 -0
- package/dist/core/loops/loop-integration-merge.js +75 -0
- package/dist/core/loops/loop-iteration-runner.js +2 -0
- package/dist/core/loops/loop-lease.js +91 -0
- package/dist/core/loops/loop-observability.js +19 -0
- package/dist/core/loops/loop-owner-inference.js +57 -0
- package/dist/core/loops/loop-owner-ledger.js +2 -0
- package/dist/core/loops/loop-planner.js +170 -0
- package/dist/core/loops/loop-proof-summary.js +10 -0
- package/dist/core/loops/loop-proof.js +2 -0
- package/dist/core/loops/loop-risk-classifier.js +42 -0
- package/dist/core/loops/loop-runtime-control.js +25 -0
- package/dist/core/loops/loop-runtime.js +314 -0
- package/dist/core/loops/loop-scheduler.js +69 -0
- package/dist/core/loops/loop-schema.js +63 -0
- package/dist/core/loops/loop-state.js +61 -0
- package/dist/core/loops/loop-worker-prompts.js +43 -0
- package/dist/core/loops/loop-worker-runtime.js +275 -0
- package/dist/core/loops/loop-worktree-runtime.js +92 -0
- package/dist/core/naruto/naruto-finalizer.js +7 -2
- package/dist/core/naruto/naruto-loop-mesh.js +39 -0
- package/dist/core/naruto/naruto-loop-worker-router.js +38 -0
- package/dist/core/pipeline-internals/runtime-core.js +82 -2
- package/dist/core/proof/proof-schema.js +6 -0
- package/dist/core/proof/proof-writer.js +5 -2
- package/dist/core/proof/root-cause-policy.js +70 -0
- package/dist/core/proof/route-adapter.js +18 -1
- package/dist/core/proof/route-proof-gate.js +4 -0
- package/dist/core/release/release-gate-batch-runner.js +56 -10
- package/dist/core/release/release-gate-cache-v2.js +18 -3
- package/dist/core/release/release-gate-dag.js +65 -17
- package/dist/core/release/release-gate-node.js +2 -1
- package/dist/core/release/release-gate-resource-governor.js +27 -6
- package/dist/core/skills/core-skill-meta-update.js +24 -0
- package/dist/core/skills/core-skill-reflection.js +94 -0
- package/dist/core/skills/core-skill-trainer.js +103 -0
- package/dist/core/trust-kernel/completion-contract.js +4 -0
- package/dist/core/trust-kernel/route-contract.js +4 -1
- package/dist/core/version.js +1 -1
- package/dist/core/zellij/zellij-right-column-manager.js +13 -2
- package/dist/core/zellij/zellij-slot-column-anchor.js +45 -5
- package/dist/core/zellij/zellij-slot-pane-renderer.js +37 -10
- package/dist/core/zellij/zellij-slot-telemetry.js +96 -44
- package/dist/core/zellij/zellij-worker-pane-manager.js +42 -4
- package/dist/scripts/loop-directive-check-lib.js +388 -0
- package/dist/scripts/loop-worker-fixture-child.js +53 -0
- package/dist/scripts/naruto-real-local-gpt-final-smoke.js +10 -1
- package/package.json +38 -3
- package/schemas/loops/loop-node.schema.json +21 -0
- package/schemas/loops/loop-plan.schema.json +21 -0
- package/schemas/loops/loop-proof.schema.json +20 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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();
|
|
@@ -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(
|
|
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',
|
|
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
|
|
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
|
-
|
|
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
|