karajan-code 4.1.9 → 4.1.11
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/package.json +1 -1
- package/src/audit/osv-findings.js +5 -5
- package/src/audit/semgrep-findings.js +6 -6
- package/src/checks/orphans.js +77 -0
- package/src/cli/register-meta.js +5 -1
- package/src/cli/register-pipeline.js +5 -1
- package/src/commands/doctor.js +2 -0
- package/src/commands/hu.js +43 -0
- package/src/environment/briefs.js +2 -1
- package/src/environment/playbook.js +1 -1
- package/src/spec-review/run-spec-review.js +12 -5
- package/src/utils/cli-ask-question.js +14 -6
- package/src/utils/tool-governor.js +116 -0
package/package.json
CHANGED
|
@@ -19,10 +19,7 @@
|
|
|
19
19
|
* out-of-control result list cannot blow the token budget.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
import { promisify } from "node:util";
|
|
24
|
-
|
|
25
|
-
const execFileAsync = promisify(execFile);
|
|
22
|
+
import { runGoverned } from "../utils/tool-governor.js";
|
|
26
23
|
|
|
27
24
|
const SCAN_TIMEOUT_MS = 60_000;
|
|
28
25
|
const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "MODERATE"];
|
|
@@ -42,7 +39,10 @@ export async function collectOsvFindings(projectDir, logger = null) {
|
|
|
42
39
|
}
|
|
43
40
|
let stdout;
|
|
44
41
|
try {
|
|
45
|
-
|
|
42
|
+
// KJC-TSK-0668: governed run — single-flight across kj processes and a
|
|
43
|
+
// timeout that kills the whole process tree (osv-scanner is I/O-bound,
|
|
44
|
+
// no jobs flag to cap).
|
|
45
|
+
const { stdout: out } = await runGoverned(
|
|
46
46
|
"osv-scanner",
|
|
47
47
|
["--format=json", "--recursive", projectDir || "."],
|
|
48
48
|
{ cwd: projectDir, timeout: SCAN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }
|
|
@@ -18,10 +18,7 @@
|
|
|
18
18
|
* into the `security` dimension using the rule ID as the rule field.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
import { promisify } from "node:util";
|
|
23
|
-
|
|
24
|
-
const execFileAsync = promisify(execFile);
|
|
21
|
+
import { runGoverned, jobsCap } from "../utils/tool-governor.js";
|
|
25
22
|
|
|
26
23
|
const SCAN_TIMEOUT_MS = 120_000;
|
|
27
24
|
const SEVERITY_ORDER = ["ERROR", "WARNING", "INFO"];
|
|
@@ -44,9 +41,12 @@ export async function collectSemgrepFindings(projectDir, logger = null) {
|
|
|
44
41
|
}
|
|
45
42
|
let stdout;
|
|
46
43
|
try {
|
|
47
|
-
|
|
44
|
+
// KJC-TSK-0668: governed run — machine-wide single-flight, --jobs capped
|
|
45
|
+
// at half the CPUs (semgrep grabs 14 by default), nice +10, and a timeout
|
|
46
|
+
// that kills the whole process tree (no more orphaned semgrep-core).
|
|
47
|
+
const { stdout: out } = await runGoverned(
|
|
48
48
|
"semgrep",
|
|
49
|
-
["--config", "auto", "--json", "--quiet", `--timeout=${Math.floor(SCAN_TIMEOUT_MS / 1000)}`, projectDir || "."],
|
|
49
|
+
["--config", "auto", "--json", "--quiet", `--jobs=${jobsCap()}`, `--timeout=${Math.floor(SCAN_TIMEOUT_MS / 1000)}`, projectDir || "."],
|
|
50
50
|
{ cwd: projectDir, timeout: SCAN_TIMEOUT_MS + 5000, maxBuffer: 32 * 1024 * 1024 }
|
|
51
51
|
);
|
|
52
52
|
stdout = out;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orphaned scanner detection for `kj doctor` (KJC-TSK-0668). After the CPU
|
|
3
|
+
* storm, timed-out scans left semgrep-core processes reparented to init /
|
|
4
|
+
* systemd, silently burning cores. This check finds processes of tools kj
|
|
5
|
+
* governs whose parent is init (pid 1) or a systemd reaper, reports them,
|
|
6
|
+
* and — strategy "prompt" — offers to kill them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { STRATEGY } from "./types.js";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
const GOVERNED_TOOLS = new Set(["semgrep", "semgrep-core", "osv-scanner", "lighthouse"]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Scan the process table for governed tools whose parent is init or a
|
|
18
|
+
* systemd reaper (where the kernel sends orphans). `runPs` injectable.
|
|
19
|
+
*/
|
|
20
|
+
export async function findOrphanToolProcesses(runPs = execFileAsync) {
|
|
21
|
+
if (process.platform === "win32") return { supported: false, orphans: [] };
|
|
22
|
+
let stdout;
|
|
23
|
+
try {
|
|
24
|
+
({ stdout } = await runPs("ps", ["-eo", "pid=,ppid=,comm="]));
|
|
25
|
+
} catch {
|
|
26
|
+
return { supported: false, orphans: [] };
|
|
27
|
+
}
|
|
28
|
+
const rows = [];
|
|
29
|
+
const commByPid = new Map();
|
|
30
|
+
for (const line of String(stdout).split("\n")) {
|
|
31
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
32
|
+
if (!m) continue;
|
|
33
|
+
const row = { pid: Number(m[1]), ppid: Number(m[2]), comm: m[3].trim() };
|
|
34
|
+
rows.push(row);
|
|
35
|
+
commByPid.set(row.pid, row.comm);
|
|
36
|
+
}
|
|
37
|
+
const orphans = rows.filter((r) =>
|
|
38
|
+
GOVERNED_TOOLS.has(r.comm) && (r.ppid === 1 || commByPid.get(r.ppid) === "systemd")
|
|
39
|
+
);
|
|
40
|
+
return { supported: true, orphans };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createOrphanCheck() {
|
|
44
|
+
return {
|
|
45
|
+
name: "tool-orphans",
|
|
46
|
+
label: "Orphaned scanner processes",
|
|
47
|
+
strategy: STRATEGY.PROMPT,
|
|
48
|
+
describe: "Kill orphaned scanner processes left behind by interrupted runs",
|
|
49
|
+
async detect({ runPs } = {}) {
|
|
50
|
+
const { supported, orphans } = await findOrphanToolProcesses(runPs || execFileAsync);
|
|
51
|
+
if (!supported) return { ok: true, severity: "info", detail: "orphan detection not supported on this platform" };
|
|
52
|
+
if (orphans.length === 0) return { ok: true, severity: "info", detail: "no orphaned scanner processes" };
|
|
53
|
+
const list = orphans.map((o) => `${o.comm}(${o.pid})`).join(", ");
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
severity: "warn",
|
|
57
|
+
detail: `${orphans.length} orphaned scanner process(es) burning CPU: ${list}`,
|
|
58
|
+
fix: `kill -9 ${orphans.map((o) => o.pid).join(" ")}`,
|
|
59
|
+
extra: { orphans },
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
async remediate({ extra, kill = process.kill } = {}) {
|
|
63
|
+
let killed = 0;
|
|
64
|
+
for (const o of extra?.orphans || []) {
|
|
65
|
+
try {
|
|
66
|
+
kill(o.pid, "SIGKILL");
|
|
67
|
+
killed += 1;
|
|
68
|
+
} catch { /* already gone */ }
|
|
69
|
+
}
|
|
70
|
+
return { fixed: killed > 0, detail: `killed ${killed} orphaned scanner process(es)` };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getOrphanChecks() {
|
|
76
|
+
return [createOrphanCheck()];
|
|
77
|
+
}
|
package/src/cli/register-meta.js
CHANGED
|
@@ -403,10 +403,14 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
403
403
|
.description("Resume a paused session")
|
|
404
404
|
.argument("<sessionId>")
|
|
405
405
|
.option("--answer <text>", "Answer to the question that caused the pause")
|
|
406
|
+
.option("--non-interactive", "Agents/CI: auto-answer prompts with their safe defaults; stop when there is none. Also enabled by KJ_NON_INTERACTIVE=1.")
|
|
406
407
|
.option("--json", "Output JSON only")
|
|
407
408
|
.action(async (sessionId, flags) => {
|
|
408
409
|
await withConfig(pkgVersion, "resume", flags, async ({ config, logger }) => {
|
|
409
|
-
await resumeCommand({ sessionId, answer: flags.answer, config, logger, flags });
|
|
410
|
+
const res = await resumeCommand({ sessionId, answer: flags.answer, config, logger, flags });
|
|
411
|
+
// KJC-TSK-0674 (issue #1289): same contract as kj run — an aborted
|
|
412
|
+
// resume must be observable by CI.
|
|
413
|
+
if (res?.aborted) process.exitCode = 1;
|
|
410
414
|
});
|
|
411
415
|
});
|
|
412
416
|
|
|
@@ -54,6 +54,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
54
54
|
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
55
55
|
.option("--task-file <path>", "Read the task from a file (e.g. .md). Alternative to the positional task argument.")
|
|
56
56
|
.option("-y, --yes", "Skip the cwd confirmation prompt (CI / scripted runs).")
|
|
57
|
+
.option("--non-interactive", "Agents/CI: auto-answer prompts with their safe defaults (warn → continue) and stop with exit code 1 when there is none (fail). Also enabled by KJ_NON_INTERACTIVE=1.")
|
|
57
58
|
.option("--planner <name>")
|
|
58
59
|
.option("--coder <name>")
|
|
59
60
|
.option("--reviewer <name>")
|
|
@@ -145,7 +146,10 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
145
146
|
}
|
|
146
147
|
const { resolveTaskInput } = await import("../utils/task-file.js");
|
|
147
148
|
const resolvedTask = await resolveTaskInput({ task: effectiveTask, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
|
|
148
|
-
await runCommandHandler({ task: resolvedTask, config, logger, flags });
|
|
149
|
+
const res = await runCommandHandler({ task: resolvedTask, config, logger, flags });
|
|
150
|
+
// KJC-TSK-0674 (issue #1289): an aborted run (spec-review cancel,
|
|
151
|
+
// unanswerable non-interactive gate) must be observable by CI.
|
|
152
|
+
if (res?.aborted) process.exitCode = 1;
|
|
149
153
|
});
|
|
150
154
|
});
|
|
151
155
|
|
package/src/commands/doctor.js
CHANGED
|
@@ -35,6 +35,7 @@ import { getSkillsChecks } from "../checks/skills.js";
|
|
|
35
35
|
import { getDirSetupChecks } from "../checks/dir-setup.js";
|
|
36
36
|
import { getProjectChecks } from "../checks/project-checks.js";
|
|
37
37
|
import { getHardwareChecks } from "../checks/hardware.js";
|
|
38
|
+
import { getOrphanChecks } from "../checks/orphans.js";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Build the list of Check objects applicable to the current config.
|
|
@@ -72,6 +73,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
|
|
|
72
73
|
...getRtkChecks(),
|
|
73
74
|
...getSqueezrChecks(),
|
|
74
75
|
...getAiTrashChecks(),
|
|
76
|
+
...getOrphanChecks(),
|
|
75
77
|
...getQmdChecks(),
|
|
76
78
|
...getRagHooksChecks({ projectDir }),
|
|
77
79
|
...getProjectChecks({ projectDir }),
|
package/src/commands/hu.js
CHANGED
|
@@ -20,6 +20,29 @@ export function creatorLabel() {
|
|
|
20
20
|
export const HU_STATUSES = ["pending", "running", "done", "failed", "skipped"];
|
|
21
21
|
const BACKLOG_NAME = "brain-backlog";
|
|
22
22
|
|
|
23
|
+
// KJC-TSK-0675 (issue #1288): agents that skip `kj hu list` created twins of
|
|
24
|
+
// existing work — sometimes in a DIFFERENT plan. Titles are compared
|
|
25
|
+
// normalized (case/punctuation-insensitive) across every live HU of every
|
|
26
|
+
// plan: identical → refuse; mostly-overlapping tokens → warn with candidates.
|
|
27
|
+
const normTitle = (t) => String(t || "").toLowerCase().replaceAll(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
28
|
+
const titleTokens = (t) => new Set(normTitle(t).split(" ").filter((w) => w.length > 2));
|
|
29
|
+
|
|
30
|
+
export function findTitleMatches(title, allHus) {
|
|
31
|
+
const norm = normTitle(title);
|
|
32
|
+
const toks = titleTokens(title);
|
|
33
|
+
const identical = [];
|
|
34
|
+
const similar = [];
|
|
35
|
+
for (const h of allHus) {
|
|
36
|
+
if (h.status === "skipped") continue; // discarded cards don't reserve titles
|
|
37
|
+
if (normTitle(h.title) === norm) { identical.push(h); continue; }
|
|
38
|
+
const other = titleTokens(h.title);
|
|
39
|
+
const shared = [...toks].filter((w) => other.has(w)).length;
|
|
40
|
+
const smaller = Math.min(toks.size, other.size);
|
|
41
|
+
if (smaller > 0 && shared / smaller >= 0.8) similar.push(h);
|
|
42
|
+
}
|
|
43
|
+
return { identical, similar };
|
|
44
|
+
}
|
|
45
|
+
|
|
23
46
|
async function backlogPlan(projectDir) {
|
|
24
47
|
const plans = await listPlans(projectDir);
|
|
25
48
|
const existing = plans.find((p) => p.alias === BACKLOG_NAME || p.name === BACKLOG_NAME);
|
|
@@ -65,6 +88,26 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
|
|
|
65
88
|
if (action === "add") {
|
|
66
89
|
const title = args[0];
|
|
67
90
|
if (!title || !title.trim()) throw new Error("kj hu add requires a title: kj hu add \"<story>\"");
|
|
91
|
+
// Cross-plan title dedup (KJC-TSK-0675) — check BEFORE creating anything.
|
|
92
|
+
const allHus = [];
|
|
93
|
+
for (const meta of await listPlans(projectDir)) {
|
|
94
|
+
const p = await loadPlan(projectDir, meta.planId);
|
|
95
|
+
for (const h of p.hus || []) allHus.push({ ...h, plan: p.alias || p.name || p.planId });
|
|
96
|
+
}
|
|
97
|
+
const { identical, similar } = findTitleMatches(title, allHus);
|
|
98
|
+
if (identical.length > 0) {
|
|
99
|
+
const ref = identical[0];
|
|
100
|
+
throw new Error(
|
|
101
|
+
`An HU with this title already exists: ${ref.short_id || ref.id} (status: ${ref.status}, plan: ${ref.plan}). `
|
|
102
|
+
+ `HUs are never duplicated — work that card, move it with: kj hu move ${ref.short_id || ref.id} <status>, `
|
|
103
|
+
+ `or run kj hu list before adding.`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (similar.length > 0) {
|
|
107
|
+
const plural = similar.length === 1 ? "" : "s";
|
|
108
|
+
const candidates = similar.map((h) => ` · ${h.short_id || h.id} (${h.status}, plan: ${h.plan}) "${h.title}"`).join("\n");
|
|
109
|
+
console.warn(`⚠ possible duplicate${plural} — check before working it:\n${candidates}`);
|
|
110
|
+
}
|
|
68
111
|
const plan = await backlogPlan(projectDir);
|
|
69
112
|
// KJC-TSK-0669 (absolute rule): cards are permanent. The delete-and-
|
|
70
113
|
// recreate "fix" an agent improvises loses history and duplicates ids —
|
|
@@ -87,9 +87,10 @@ const B = {
|
|
|
87
87
|
render: () => brief("Board",
|
|
88
88
|
"the HU Board is the permanent record of work: every card traces to who created it and what happened to it — a dashboard warning is fixed with the command it names, never by rebuilding the board.",
|
|
89
89
|
[
|
|
90
|
+
"Before `kj hu add`, ALWAYS run `kj hu list`: if a card already covers the work (same scope, any plan), work THAT card instead of creating a twin.",
|
|
90
91
|
"Cards are NEVER deleted or recreated — not to fix warnings, not to tidy up. Discard = `kj hu move <id> skipped` (archived, history kept).",
|
|
91
92
|
"States move only via `kj hu move <id> <pending|running|done|failed|skipped>`.",
|
|
92
|
-
"A duplicate short_id means the card already exists: update that card, never add a twin.",
|
|
93
|
+
"A duplicate short_id or title means the card already exists: update that card, never add a twin.",
|
|
93
94
|
"A warning is information, not damage: run the command it names; if it looks like a kj bug, `kj report-issue` — never work around it by destroying state.",
|
|
94
95
|
],
|
|
95
96
|
"the board reflects reality: the same cards before and after your action, with truthful states and provenance intact."),
|
|
@@ -29,7 +29,7 @@ const TARGET_FILES = {
|
|
|
29
29
|
// ENV-D1 (KJC-TSK-0642): the tracking invariant names the CHOSEN state
|
|
30
30
|
// backend — a playbook that says "board or PG" makes the host guess.
|
|
31
31
|
const BACKEND_TRACKING = {
|
|
32
|
-
"hu-board": "Every piece of work has a tracked story/bug in the HU Board (`kj hu add` / `kj board`) before it starts. Cards are permanent — never delete or recreate one; discard with `kj hu move <id> skipped`. `kj brief board` explains the board.",
|
|
32
|
+
"hu-board": "Every piece of work has a tracked story/bug in the HU Board (`kj hu add` / `kj board`) before it starts — and `kj hu list` BEFORE `kj hu add`: reuse the existing card if one covers the work. Cards are permanent — never delete or recreate one; discard with `kj hu move <id> skipped`. `kj brief board` explains the board.",
|
|
33
33
|
"planning-game": "Every piece of work has a tracked card in the Planning Game MCP before it starts (In Progress while you work it).",
|
|
34
34
|
};
|
|
35
35
|
|
|
@@ -78,17 +78,24 @@ export async function runSpecReview({ spec, config, logger, askQuestion, flags =
|
|
|
78
78
|
action = await decideGateAutonomously(resolve, { severity, findings });
|
|
79
79
|
} else {
|
|
80
80
|
if (!askQuestion) return { proceed: true, severity, findings, finalSpec: currentSpec };
|
|
81
|
+
// KJC-TSK-0674 (issue #1289): at severity FAIL the default flips to
|
|
82
|
+
// cancel — continuing past fails requires an explicit answer, both
|
|
83
|
+
// interactively (Enter cancels) and unattended (no declared default,
|
|
84
|
+
// so --yes / KJ_NON_INTERACTIVE stops with exit ≠ 0). warn/info keep
|
|
85
|
+
// the KJC-BUG-0109 contract: --yes presses Enter → continue.
|
|
86
|
+
const fallback = severity === "fail" ? "cancel" : "continue";
|
|
81
87
|
const answer = await askQuestion(
|
|
82
|
-
`Spec review: ${findings.length} finding${findings.length === 1 ? "" : "s"} at severity ${severity}. [c]ontinue / [r]efine / [x]cancel? (default:
|
|
88
|
+
`Spec review: ${findings.length} finding${findings.length === 1 ? "" : "s"} at severity ${severity}. [c]ontinue / [r]efine / [x]cancel? (default: ${fallback})`,
|
|
83
89
|
{
|
|
84
90
|
detail: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))], iteration: iter + 1, findings: findingsForPrompt(findings) },
|
|
85
|
-
|
|
86
|
-
defaultAnswer: "continue",
|
|
91
|
+
defaultAnswer: severity === "fail" ? undefined : "continue",
|
|
87
92
|
},
|
|
88
93
|
);
|
|
89
94
|
if (answer === null) return { proceed: false, cancelled: true, severity, findings };
|
|
90
|
-
const n = String(answer).trim().toLowerCase();
|
|
91
|
-
|
|
95
|
+
const n = String(answer).trim().toLowerCase() || fallback;
|
|
96
|
+
if (n.startsWith("x") || n === "cancel") action = "cancel";
|
|
97
|
+
else if (n.startsWith("r") || n === "refine") action = "refine";
|
|
98
|
+
else action = "continue";
|
|
92
99
|
}
|
|
93
100
|
if (action === "cancel") return { proceed: false, cancelled: true, severity, findings };
|
|
94
101
|
if (action === "refine") {
|
|
@@ -28,22 +28,28 @@ export function createCliAskQuestion(opts = {}) {
|
|
|
28
28
|
return async (question, context) => {
|
|
29
29
|
const stdinReadable = process.stdin && process.stdin.readable !== false;
|
|
30
30
|
const isInteractive = Boolean(process.stdin?.isTTY) && stdinReadable;
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// KJC-TSK-0674 (issue #1289): agents and CI enable the same contract
|
|
32
|
+
// as --yes explicitly, via --non-interactive or KJ_NON_INTERACTIVE=1,
|
|
33
|
+
// instead of blocking forever on a board modal nobody is watching.
|
|
34
|
+
// Explicit mode wins even when a (pseudo-)TTY is attached.
|
|
35
|
+
const forcedNonInteractive = flags?.nonInteractive || process.env.KJ_NON_INTERACTIVE === "1";
|
|
36
|
+
if (!isInteractive || forcedNonInteractive) {
|
|
37
|
+
const nonInteractive = forcedNonInteractive || flags?.yes;
|
|
38
|
+
if (nonInteractive) {
|
|
33
39
|
// KJC-BUG-0109: a question that declares a safe default is answered
|
|
34
40
|
// with it — --yes means "press Enter for me", not "abort on any
|
|
35
41
|
// gate". Questions without a declared default keep failing loud.
|
|
36
42
|
const fallback = typeof context?.defaultAnswer === "string" && context.defaultAnswer.trim()
|
|
37
43
|
? context.defaultAnswer.trim() : null;
|
|
38
44
|
if (fallback) {
|
|
39
|
-
process.stderr.write(`\n[non-interactive
|
|
45
|
+
process.stderr.write(`\n[non-interactive] Auto-answering with declared default "${fallback}": ${question}\n`);
|
|
40
46
|
return fallback;
|
|
41
47
|
}
|
|
42
48
|
process.stderr.write(
|
|
43
|
-
`\n[non-interactive
|
|
49
|
+
`\n[non-interactive] Pipeline asked a question that cannot be auto-answered:\n`
|
|
44
50
|
+ ` ❓ ${question}\n`
|
|
45
51
|
+ (context?.detail ? ` Context: ${JSON.stringify(context.detail)}\n` : "")
|
|
46
|
-
+ ` Stopping the session. Re-run
|
|
52
|
+
+ ` Stopping the session. Re-run interactively (TTY or HU Board) to answer it,\n`
|
|
47
53
|
+ ` or pass --skip-spec-review / a more complete spec so the question never appears.\n`
|
|
48
54
|
);
|
|
49
55
|
return null;
|
|
@@ -56,7 +62,9 @@ export function createCliAskQuestion(opts = {}) {
|
|
|
56
62
|
console.log(
|
|
57
63
|
"\n[non-interactive] Routing the prompt to the HU Board.\n"
|
|
58
64
|
+ " Open http://localhost:4000 — a modal will appear asking for your answer.\n"
|
|
59
|
-
+ " This process is now waiting; closing the board does NOT cancel the run
|
|
65
|
+
+ " This process is now waiting; closing the board does NOT cancel the run.\n"
|
|
66
|
+
+ " Running from an agent or CI with nobody watching the board? Re-run with\n"
|
|
67
|
+
+ " --non-interactive (or KJ_NON_INTERACTIVE=1) to auto-answer safe defaults instead."
|
|
60
68
|
);
|
|
61
69
|
try {
|
|
62
70
|
return await askThroughBoard({ sessionId, question, context });
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-governor — resource governance for external tools launched by kj
|
|
3
|
+
* (KJC-TSK-0668), born from a real semgrep CPU storm: single-flight across
|
|
4
|
+
* processes (pid-stamped lockfile, stale locks stolen), jobs cap (half the
|
|
5
|
+
* CPUs), nice +10 best-effort, and hard timeouts that kill the PROCESS TREE
|
|
6
|
+
* (own process group on POSIX). In-process cousin: async-lock.js.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_LOCKS_DIR = path.join(os.homedir(), ".karajan", "locks");
|
|
15
|
+
const NICENESS = 10;
|
|
16
|
+
|
|
17
|
+
/** Parallelism cap for scanner `--jobs` flags: half the CPUs, min 1. */
|
|
18
|
+
export function jobsCap(cpuCount = os.cpus().length) {
|
|
19
|
+
return Math.max(1, Math.floor(cpuCount / 2));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isPidAlive(pid) {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return err.code === "EPERM"; // alive but owned by another user
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Machine-wide lock for `tool` → {release}; ELOCKTIMEOUT if the live holder outlasts `timeoutMs`. */
|
|
32
|
+
export async function acquireToolLock(tool, { locksDir = DEFAULT_LOCKS_DIR, timeoutMs = 180_000, pollMs = 500 } = {}) {
|
|
33
|
+
fs.mkdirSync(locksDir, { recursive: true });
|
|
34
|
+
const lockPath = path.join(locksDir, `${tool}.lock`);
|
|
35
|
+
const deadline = Date.now() + timeoutMs;
|
|
36
|
+
for (;;) {
|
|
37
|
+
try {
|
|
38
|
+
// wx = atomic create-or-fail: the filesystem is the arbiter.
|
|
39
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
40
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
41
|
+
fs.closeSync(fd);
|
|
42
|
+
return { release: () => fs.rmSync(lockPath, { force: true }) };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err.code !== "EEXIST") throw err;
|
|
45
|
+
let holder = null;
|
|
46
|
+
try {
|
|
47
|
+
holder = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
48
|
+
} catch { /* unreadable → stale */ }
|
|
49
|
+
if (!holder || !Number.isInteger(holder.pid) || !isPidAlive(holder.pid)) {
|
|
50
|
+
fs.rmSync(lockPath, { force: true });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (Date.now() >= deadline) {
|
|
54
|
+
const e = new Error(`${tool} is already running (pid ${holder.pid}, since ${holder.startedAt}) — gave up waiting after ${timeoutMs}ms`);
|
|
55
|
+
e.code = "ELOCKTIMEOUT";
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Governed spawn: own process group (POSIX), lowered priority, timeout kills
|
|
65
|
+
* the whole tree. Rejections mirror execFile's contract (err.code /
|
|
66
|
+
* err.killed / err.stdout) so scanner collectors keep their error handling.
|
|
67
|
+
*/
|
|
68
|
+
export function governedExecFile(bin, args, { cwd, timeout = 0, maxBuffer = 16 * 1024 * 1024, niceness = NICENESS } = {}) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const posix = process.platform !== "win32";
|
|
71
|
+
const child = spawn(bin, args, { cwd, detached: posix, stdio: ["ignore", "pipe", "pipe"] });
|
|
72
|
+
let stdout = "";
|
|
73
|
+
let stderr = "";
|
|
74
|
+
let killedByGovernor = false, timer = null;
|
|
75
|
+
const killTree = () => {
|
|
76
|
+
killedByGovernor = true;
|
|
77
|
+
try {
|
|
78
|
+
if (posix) process.kill(-child.pid, "SIGKILL");
|
|
79
|
+
else child.kill("SIGKILL");
|
|
80
|
+
} catch { /* tree already gone */ }
|
|
81
|
+
};
|
|
82
|
+
if (timeout > 0) timer = setTimeout(killTree, timeout);
|
|
83
|
+
child.on("spawn", () => {
|
|
84
|
+
try {
|
|
85
|
+
os.setPriority(child.pid, niceness);
|
|
86
|
+
} catch { /* best-effort */ }
|
|
87
|
+
});
|
|
88
|
+
const bufferBlown = () => stdout.length + stderr.length > maxBuffer;
|
|
89
|
+
child.stdout.on("data", (d) => { stdout += d; if (bufferBlown()) killTree(); });
|
|
90
|
+
child.stderr.on("data", (d) => { stderr += d; if (bufferBlown()) killTree(); });
|
|
91
|
+
child.on("error", (err) => {
|
|
92
|
+
if (timer) clearTimeout(timer);
|
|
93
|
+
reject(Object.assign(err, { stdout, stderr }));
|
|
94
|
+
});
|
|
95
|
+
child.on("close", (code) => {
|
|
96
|
+
if (timer) clearTimeout(timer);
|
|
97
|
+
if (killedByGovernor) {
|
|
98
|
+
reject(Object.assign(new Error(`${bin} killed by governor after ${timeout}ms (process tree included)`), { killed: true, stdout, stderr }));
|
|
99
|
+
} else if (code === 0) {
|
|
100
|
+
resolve({ stdout, stderr });
|
|
101
|
+
} else {
|
|
102
|
+
reject(Object.assign(new Error(`${bin} exited with code ${code}`), { code, stdout, stderr }));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Lock + governed exec + guaranteed release. `opts.bin` overrides the executable. */
|
|
109
|
+
export async function runGoverned(tool, args, opts = {}) {
|
|
110
|
+
const lock = await acquireToolLock(tool, opts.lock);
|
|
111
|
+
try {
|
|
112
|
+
return await governedExecFile(opts.bin || tool, args, opts);
|
|
113
|
+
} finally {
|
|
114
|
+
lock.release();
|
|
115
|
+
}
|
|
116
|
+
}
|