pi-lens 4.1.0 → 4.1.2
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/CHANGELOG.md +185 -0
- package/README.md +106 -0
- package/dist/clients/actionable-warnings-logger.js +2 -1
- package/dist/clients/actionable-warnings.js +14 -5
- package/dist/clients/advisory-provenance.js +48 -18
- package/dist/clients/ast-grep-client.js +1 -3
- package/dist/clients/atomic-write-staging.js +16 -4
- package/dist/clients/bash-file-access.js +99 -6
- package/dist/clients/biome-client.js +1 -3
- package/dist/clients/blocker-freshness.js +21 -8
- package/dist/clients/blocker-past-eof.js +16 -6
- package/dist/clients/bounded-cache.js +24 -8
- package/dist/clients/bounded-pid-file-lock.js +4 -3
- package/dist/clients/bounded-telemetry.js +14 -0
- package/dist/clients/cache-manager.js +6 -7
- package/dist/clients/cache-observability.js +782 -110
- package/dist/clients/call-graph.js +238 -63
- package/dist/clients/codebase-model.js +13 -5
- package/dist/clients/complexity-client.js +1 -3
- package/dist/clients/dead-code-client.js +19 -8
- package/dist/clients/dead-code-logger.js +1 -2
- package/dist/clients/deadline-utils.js +3 -1
- package/dist/clients/debug-heap.js +8 -1
- package/dist/clients/degradation-ledger.js +22 -1
- package/dist/clients/demoted-finding-render.js +155 -0
- package/dist/clients/dependency-checker.js +11 -7
- package/dist/clients/deps/pi-tui.js +8 -2
- package/dist/clients/deps/typebox.js +8 -3
- package/dist/clients/diagnostic-dispositions.js +39 -2
- package/dist/clients/diagnostic-line-freshness.js +5 -1
- package/dist/clients/dispatch/auxiliary-lsp.js +2 -3
- package/dist/clients/dispatch/dispatcher.js +13 -1
- package/dist/clients/dispatch/facts/function-facts.js +3 -1
- package/dist/clients/dispatch/facts/import-facts.js +2 -1
- package/dist/clients/dispatch/facts/tree-sitter-facts.js +1 -1
- package/dist/clients/dispatch/integration.js +9 -4
- package/dist/clients/dispatch/rules/error-swallowing.js +3 -1
- package/dist/clients/dispatch/rules/pass-through-wrappers.js +6 -2
- package/dist/clients/dispatch/rules/unsafe-boundary.js +31 -7
- package/dist/clients/dispatch/runners/actionlint.js +5 -2
- package/dist/clients/dispatch/runners/biome-check.js +10 -14
- package/dist/clients/dispatch/runners/cpp-check.js +5 -1
- package/dist/clients/dispatch/runners/detekt.js +9 -6
- package/dist/clients/dispatch/runners/dotnet-build.js +1 -1
- package/dist/clients/dispatch/runners/elixir-check.js +3 -1
- package/dist/clients/dispatch/runners/eslint.js +25 -6
- package/dist/clients/dispatch/runners/fish-indent.js +1 -1
- package/dist/clients/dispatch/runners/gleam-check.js +1 -1
- package/dist/clients/dispatch/runners/golangci-lint.js +3 -1
- package/dist/clients/dispatch/runners/hadolint.js +22 -15
- package/dist/clients/dispatch/runners/helm-lint.js +4 -1
- package/dist/clients/dispatch/runners/htmlhint.js +33 -16
- package/dist/clients/dispatch/runners/javac.js +1 -1
- package/dist/clients/dispatch/runners/ktlint.js +1 -1
- package/dist/clients/dispatch/runners/lsp.js +1 -1
- package/dist/clients/dispatch/runners/markdownlint.js +34 -12
- package/dist/clients/dispatch/runners/mypy.js +15 -17
- package/dist/clients/dispatch/runners/oxlint.js +231 -15
- package/dist/clients/dispatch/runners/php-lint.js +1 -1
- package/dist/clients/dispatch/runners/phpstan.js +52 -15
- package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -6
- package/dist/clients/dispatch/runners/pyright.js +23 -14
- package/dist/clients/dispatch/runners/rust-clippy.js +1 -1
- package/dist/clients/dispatch/runners/shellcheck.js +7 -9
- package/dist/clients/dispatch/runners/shfmt.js +5 -2
- package/dist/clients/dispatch/runners/spellcheck.js +10 -15
- package/dist/clients/dispatch/runners/sqlfluff.js +25 -17
- package/dist/clients/dispatch/runners/stylelint.js +40 -20
- package/dist/clients/dispatch/runners/swiftlint.js +8 -8
- package/dist/clients/dispatch/runners/taplo.js +110 -23
- package/dist/clients/dispatch/runners/terragrunt.js +13 -8
- package/dist/clients/dispatch/runners/tflint.js +25 -15
- package/dist/clients/dispatch/runners/tree-sitter.js +17 -3
- package/dist/clients/dispatch/runners/utils/candidate-probe.js +7 -1
- package/dist/clients/dispatch/runners/utils/runner-helpers.js +24 -1
- package/dist/clients/dispatch/runners/utils/tool-failure.js +152 -1
- package/dist/clients/dispatch/runners/vale.js +16 -14
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +1 -3
- package/dist/clients/dispatch/runners/yamllint.js +13 -16
- package/dist/clients/dispatch/runners/zig-check.js +1 -1
- package/dist/clients/dispatch/suppress-writer.js +2 -1
- package/dist/clients/dispatch/types.js +6 -1
- package/dist/clients/dispatch/utils/lsp-diagnostics.js +6 -2
- package/dist/clients/extension-log.js +5 -3
- package/dist/clients/feature-hints.js +8 -1
- package/dist/clients/file-kinds.js +34 -129
- package/dist/clients/file-utils.js +17 -1
- package/dist/clients/finding-delivery-gate.js +79 -2
- package/dist/clients/formatters.js +144 -27
- package/dist/clients/freshness.js +38 -0
- package/dist/clients/git-guard.js +43 -13
- package/dist/clients/go-client.js +1 -3
- package/dist/clients/grammar-source.js +1 -1
- package/dist/clients/gzip-stage-write.js +30 -4
- package/dist/clients/host-edit-normalize.js +1 -3
- package/dist/clients/install-diagnostics.js +4 -2
- package/dist/clients/installer/index.js +86 -77
- package/dist/clients/instance-reaper.js +5 -2
- package/dist/clients/instance-registry.js +4 -2
- package/dist/clients/jscpd-client.js +11 -3
- package/dist/clients/knip-client.js +29 -15
- package/dist/clients/language-profile.js +11 -3
- package/dist/clients/latency-logger.js +13 -2
- package/dist/clients/lens-config.js +1 -1
- package/dist/clients/lens-map.js +8 -3
- package/dist/clients/live-bus-emitter.js +1 -1
- package/dist/clients/lsp/cascade-tier.js +27 -2
- package/dist/clients/lsp/client.js +144 -5
- package/dist/clients/lsp/config.js +4 -6
- package/dist/clients/lsp/edits.js +119 -32
- package/dist/clients/lsp/index.js +245 -41
- package/dist/clients/lsp/inferred-project.js +2 -1
- package/dist/clients/lsp/jvm-runtime.js +4 -1
- package/dist/clients/lsp/launch.js +11 -3
- package/dist/clients/lsp/pending-aux-coverage.js +152 -0
- package/dist/clients/lsp/server.js +19 -7
- package/dist/clients/lsp/tsserver-sync.js +10 -2
- package/dist/clients/lsp/workspace-diagnostics-cache.js +9 -4
- package/dist/clients/lsp-document-symbols.js +1 -3
- package/dist/clients/lsp-mutation.js +10 -20
- package/dist/clients/mcp/host-shim.js +1 -1
- package/dist/clients/memory-sampler.js +73 -7
- package/dist/clients/metrics-client.js +1 -3
- package/dist/clients/middle-man-analysis.js +8 -3
- package/dist/clients/module-report-lsp.js +7 -1
- package/dist/clients/module-report.js +28 -18
- package/dist/clients/ndjson-logger.js +117 -28
- package/dist/clients/opaque-mutation-scan.js +220 -0
- package/dist/clients/package-manager.js +6 -1
- package/dist/clients/pipeline.js +57 -9
- package/dist/clients/project-conventions.js +2 -8
- package/dist/clients/project-diagnostics/cache.js +10 -7
- package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +2 -1
- package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +3 -1
- package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +13 -9
- package/dist/clients/project-diagnostics/runner-adapters/trivy.js +3 -1
- package/dist/clients/project-diagnostics/scanner.js +22 -10
- package/dist/clients/project-lens-config.js +12 -5
- package/dist/clients/project-report.js +62 -27
- package/dist/clients/project-snapshot-fingerprint.js +48 -0
- package/dist/clients/project-snapshot-persist-worker.js +15 -1
- package/dist/clients/project-snapshot.js +472 -66
- package/dist/clients/read-expansion.js +12 -2
- package/dist/clients/read-guard-logger.js +12 -6
- package/dist/clients/read-guard-tool-lines.js +5 -2
- package/dist/clients/read-guard.js +88 -19
- package/dist/clients/resource-sampler.js +1 -1
- package/dist/clients/reverse-deps.js +6 -5
- package/dist/clients/review-graph/builder.js +236 -48
- package/dist/clients/review-graph/git-identity.js +8 -2
- package/dist/clients/review-graph/import-resolvers.js +10 -1
- package/dist/clients/review-graph/query.js +2 -1
- package/dist/clients/review-graph/revision-drift.js +21 -0
- package/dist/clients/review-graph/tsconfig-paths.js +4 -2
- package/dist/clients/review-graph/workspace-modules.js +8 -5
- package/dist/clients/ruff-client.js +6 -5
- package/dist/clients/runtime-agent-end.js +63 -31
- package/dist/clients/runtime-context.js +12 -4
- package/dist/clients/runtime-coordinator.js +127 -4
- package/dist/clients/runtime-session.js +55 -0
- package/dist/clients/runtime-tool-call.js +59 -3
- package/dist/clients/runtime-tool-result.js +148 -34
- package/dist/clients/runtime-turn.js +194 -8
- package/dist/clients/rust-client.js +1 -3
- package/dist/clients/safe-spawn.js +150 -11
- package/dist/clients/security-scan-client.js +1 -3
- package/dist/clients/session-event-guard.js +184 -0
- package/dist/clients/session-summary.js +3 -1
- package/dist/clients/sg-runner.js +1 -3
- package/dist/clients/source-filter.js +16 -8
- package/dist/clients/source-walker.js +1 -1
- package/dist/clients/spawn-timeout-cooldown.js +96 -0
- package/dist/clients/startup-scan.js +9 -3
- package/dist/clients/tool-definition.js +7 -0
- package/dist/clients/tool-policy.js +26 -11
- package/dist/clients/tool-render.js +2 -1
- package/dist/clients/tree-sitter-client.js +10 -4
- package/dist/clients/tree-sitter-logger.js +2 -1
- package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
- package/dist/clients/warm-attach.js +12 -5
- package/dist/clients/widget-state.js +27 -11
- package/dist/clients/word-index.js +3 -1
- package/dist/clients/workspace-topology.js +8 -2
- package/dist/clients/zizmor-config.js +9 -1
- package/dist/i18n.js +13 -5
- package/dist/index.js +15811 -12810
- package/dist/mcp/server.js +3 -1
- package/dist/tools/ast-grep-replace.js +68 -12
- package/dist/tools/lens-diagnostic-mark.js +21 -7
- package/dist/tools/lens-diagnostics.js +174 -38
- package/dist/tools/lsp-diagnostics.js +24 -7
- package/dist/tools/lsp-navigation.js +3 -1
- package/dist/tools/module-report.js +1 -1
- package/dist/tools/project-report.js +1 -1
- package/dist/tools/symbol-search.js +5 -2
- package/docs/durable-store-audit-1202.md +1 -1
- package/docs/servercapabilities.md +1 -1
- package/package.json +160 -143
- package/rules/typos/_typos.toml +2 -2
- package/scripts/analyze-pi-lens-logs.mjs +31 -13
- package/scripts/download-grammars.js +9 -3
- package/scripts/install-selftest.mjs +99 -11
- package/scripts/lib/host-provided-deps.mjs +75 -0
- package/scripts/lib/warm-loader-cache.mjs +285 -0
- package/scripts/rpc-load-check.mjs +30 -9
- package/scripts/warm-loader-cache.mjs +233 -0
|
@@ -37,6 +37,38 @@ export function hasSpawnFailureKind(error, kind) {
|
|
|
37
37
|
"kind" in error &&
|
|
38
38
|
error.kind === kind);
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* #2010: derive teardown evidence from observed timestamps.
|
|
42
|
+
*
|
|
43
|
+
* Pure by design so every branch is deterministically unit-testable,
|
|
44
|
+
* including the natural-exit-at-budget-expiry race where the timeout
|
|
45
|
+
* callback arms a kill against an already-dead child.
|
|
46
|
+
*
|
|
47
|
+
* - No recorded kill start (the guard saw the child already dying), no
|
|
48
|
+
* observed death at all, or death PREDATING the kill start: the child
|
|
49
|
+
* left on its own -> "exited", ms 0. Claiming a kill we cannot
|
|
50
|
+
* distinguish from a natural exit would be invented evidence.
|
|
51
|
+
* - Death observed after the kill started: "escalate-kill" when the
|
|
52
|
+
* SIGKILL escalation fired, otherwise "killed-by-signal"; ms is the
|
|
53
|
+
* observed gap (floor 0 - clock jitter must never produce a negative).
|
|
54
|
+
*/
|
|
55
|
+
export function resolveTeardownOutcome(input) {
|
|
56
|
+
// Another path (abort / output-limit) owns this death: emitting teardown
|
|
57
|
+
// evidence here would describe a kill this timeout didn't perform.
|
|
58
|
+
if (input.killStartedAtMs === undefined ||
|
|
59
|
+
input.deathObservedAtMs === undefined) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
// True natural-exit race: the child beat the budget-expired kill by a
|
|
63
|
+
// hair - death observed before (or exactly at) the kill start.
|
|
64
|
+
if (input.deathObservedAtMs <= input.killStartedAtMs) {
|
|
65
|
+
return { ms: 0, outcome: "exited" };
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
ms: Math.max(0, input.deathObservedAtMs - input.killStartedAtMs),
|
|
69
|
+
outcome: input.escalated ? "escalate-kill" : "killed-by-signal",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
40
72
|
function toError(error) {
|
|
41
73
|
return error instanceof Error ? error : new Error(String(error));
|
|
42
74
|
}
|
|
@@ -74,7 +106,12 @@ function cwdIsUnresolvableSync(cwd) {
|
|
|
74
106
|
*/
|
|
75
107
|
function commandProbablyPresent(command) {
|
|
76
108
|
const exts = process.platform === "win32"
|
|
77
|
-
? [
|
|
109
|
+
? [
|
|
110
|
+
"",
|
|
111
|
+
...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
|
112
|
+
.split(";")
|
|
113
|
+
.filter(Boolean),
|
|
114
|
+
]
|
|
78
115
|
: [""];
|
|
79
116
|
const existsWithExt = (base) => {
|
|
80
117
|
for (const ext of exts) {
|
|
@@ -192,7 +229,15 @@ function killPidTreeSync(pid) {
|
|
|
192
229
|
return;
|
|
193
230
|
}
|
|
194
231
|
try {
|
|
195
|
-
process
|
|
232
|
+
// #2026: negative pid = whole process group (POSIX children spawn
|
|
233
|
+
// detached), reaching grandchildren too. Any failure (ESRCH = already
|
|
234
|
+
// gone, EPERM = raced) falls through to the direct-child attempt.
|
|
235
|
+
try {
|
|
236
|
+
process.kill(-pid, "SIGKILL");
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
process.kill(pid, "SIGKILL");
|
|
240
|
+
}
|
|
196
241
|
}
|
|
197
242
|
catch {
|
|
198
243
|
// Child already exited.
|
|
@@ -268,10 +313,16 @@ function cmdEscapeArg(arg) {
|
|
|
268
313
|
* that already worked. The `chcp 65001` prefix forces the UTF-8 code page (so
|
|
269
314
|
* tool output isn't mangled by the system code page) and, as a side benefit,
|
|
270
315
|
* keeps the (possibly quoted) command off the front of the line, avoiding
|
|
271
|
-
* cmd.exe's `/s` outer-quote-stripping quirk.
|
|
316
|
+
* cmd.exe's `/s` outer-quote-stripping quirk. #2023: chcp is invoked via its
|
|
317
|
+
* absolute `%SystemRoot%\System32\chcp.com` path and chained with `&`, not
|
|
318
|
+
* `&&` — a child environment whose PATH cannot resolve System32 used to fail
|
|
319
|
+
* the bare `chcp` lookup, and `&&` then short-circuited EVERY .cmd/.bat spawn
|
|
320
|
+
* into exit 1 with empty output. With the pin plus `&`, even a chcp failure
|
|
321
|
+
* can never suppress the real command.
|
|
272
322
|
*/
|
|
273
323
|
export function buildWindowsShellCommand(command, args) {
|
|
274
|
-
|
|
324
|
+
const chcp = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\chcp.com`;
|
|
325
|
+
return `${chcp} 65001 >nul 2>&1 & ${[command, ...args].map(cmdEscapeArg).join(" ")}`;
|
|
275
326
|
}
|
|
276
327
|
/**
|
|
277
328
|
* The extensions this resolver treats as executable suffixes on their own,
|
|
@@ -859,6 +910,15 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
859
910
|
// a ref'd 1s timer that outlives the child it was escalating against
|
|
860
911
|
// would keep a one-shot `pi --print` process alive for up to 1s.
|
|
861
912
|
let escalationTimer;
|
|
913
|
+
let teardownStartedAtMs;
|
|
914
|
+
let teardownEscalated = false;
|
|
915
|
+
// #2010: teardown evidence for the spawn's OWN timeout. The wall clock
|
|
916
|
+
// starts when the budget expires and the tree-kill is issued, and stops
|
|
917
|
+
// when the process tree is observed dead (`await killPromise` settles)
|
|
918
|
+
// - measured SEPARATELY from the budget itself, so a slow teardown is
|
|
919
|
+
// visible instead of silently inflating "how long the tool ran".
|
|
920
|
+
// `teardownEscalated` names whether SIGTERM sufficed or the SIGKILL
|
|
921
|
+
// escalation fired (POSIX; Windows taskkill /F is always forceful).
|
|
862
922
|
// #1114: `child.killed` is set by Node the moment `kill()` successfully
|
|
863
923
|
// SENDS a signal, not when the child actually dies — so gating the
|
|
864
924
|
// escalation on `!child.killed` right after a successful `SIGTERM` send
|
|
@@ -994,6 +1054,14 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
994
1054
|
return;
|
|
995
1055
|
}
|
|
996
1056
|
let child;
|
|
1057
|
+
// #2026: on POSIX, run the child in its OWN process group (detached).
|
|
1058
|
+
// killTree/killPidTreeSync then signal the whole GROUP (-pid), which
|
|
1059
|
+
// makes grandchildren (npm -> node, sh -> sleep, etc.) die with their
|
|
1060
|
+
// tool instead of surviving every timeout as orphans. Detachment stops
|
|
1061
|
+
// direct terminal-signal delivery, so EVERY POSIX pid registers for
|
|
1062
|
+
// lifetime cleanup - pi's signal/exit handlers forward the kill,
|
|
1063
|
+
// preserving die-with-host semantics.
|
|
1064
|
+
const posixProcessGroup = process.platform !== "win32";
|
|
997
1065
|
try {
|
|
998
1066
|
child = spawn(spawnCmd, spawnArgs, {
|
|
999
1067
|
cwd: spawnCwd,
|
|
@@ -1001,6 +1069,7 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1001
1069
|
windowsHide: true,
|
|
1002
1070
|
shell: false,
|
|
1003
1071
|
windowsVerbatimArguments,
|
|
1072
|
+
detached: posixProcessGroup,
|
|
1004
1073
|
});
|
|
1005
1074
|
}
|
|
1006
1075
|
catch (err) {
|
|
@@ -1022,7 +1091,10 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1022
1091
|
}));
|
|
1023
1092
|
return;
|
|
1024
1093
|
}
|
|
1025
|
-
if (options?.lifetimeCoupled
|
|
1094
|
+
if (child.pid && (posixProcessGroup || options?.lifetimeCoupled)) {
|
|
1095
|
+
// #2026: POSIX registers unconditionally - detached children no
|
|
1096
|
+
// longer receive terminal signals directly, so the lifetime
|
|
1097
|
+
// cleanup's signal forwarding IS their die-with-host path.
|
|
1026
1098
|
installLifetimeCleanup();
|
|
1027
1099
|
lifetimeState.pids.add(child.pid);
|
|
1028
1100
|
}
|
|
@@ -1066,11 +1138,56 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1066
1138
|
child.kill("SIGKILL");
|
|
1067
1139
|
}
|
|
1068
1140
|
}
|
|
1141
|
+
else if (posixProcessGroup && child.pid && child.pid > 0) {
|
|
1142
|
+
// #2026: signal the whole process group. Grandchildren spawned
|
|
1143
|
+
// by the tool share its group, so one signal reaches the whole
|
|
1144
|
+
// tree; a negative-pid ESRCH means it already exited.
|
|
1145
|
+
const pgid = -child.pid;
|
|
1146
|
+
try {
|
|
1147
|
+
process.kill(pgid, "SIGTERM");
|
|
1148
|
+
}
|
|
1149
|
+
catch {
|
|
1150
|
+
child.kill("SIGTERM");
|
|
1151
|
+
}
|
|
1152
|
+
// #2027 round-1: gate the SIGKILL escalation on GROUP liveness,
|
|
1153
|
+
// not direct-child death - a tool can exit instantly on SIGTERM
|
|
1154
|
+
// while a SIGTERM-hardy grandchild keeps the group alive. The
|
|
1155
|
+
// timer stays REF'D for group mode (max 1s tail after resolve):
|
|
1156
|
+
// it is the only backstop for this group once finalize deletes
|
|
1157
|
+
// the pid from lifetimeState.
|
|
1158
|
+
escalationTimer = setTimeout(() => {
|
|
1159
|
+
let groupAlive = true;
|
|
1160
|
+
try {
|
|
1161
|
+
process.kill(pgid, 0);
|
|
1162
|
+
}
|
|
1163
|
+
catch {
|
|
1164
|
+
groupAlive = false;
|
|
1165
|
+
}
|
|
1166
|
+
if (!groupAlive)
|
|
1167
|
+
return;
|
|
1168
|
+
teardownEscalated = true;
|
|
1169
|
+
logLatency({
|
|
1170
|
+
type: "phase",
|
|
1171
|
+
phase: "spawn_group_kill_escalation",
|
|
1172
|
+
filePath: "",
|
|
1173
|
+
durationMs: 0,
|
|
1174
|
+
metadata: { pgid: child.pid },
|
|
1175
|
+
});
|
|
1176
|
+
try {
|
|
1177
|
+
process.kill(pgid, "SIGKILL");
|
|
1178
|
+
}
|
|
1179
|
+
catch {
|
|
1180
|
+
// Raced with group exit.
|
|
1181
|
+
}
|
|
1182
|
+
}, 1000);
|
|
1183
|
+
}
|
|
1069
1184
|
else {
|
|
1070
1185
|
child.kill("SIGTERM");
|
|
1071
1186
|
escalationTimer = setTimeout(() => {
|
|
1072
|
-
if (!closed)
|
|
1187
|
+
if (!closed) {
|
|
1188
|
+
teardownEscalated = true;
|
|
1073
1189
|
child.kill("SIGKILL");
|
|
1190
|
+
}
|
|
1074
1191
|
}, 1000);
|
|
1075
1192
|
}
|
|
1076
1193
|
};
|
|
@@ -1119,14 +1236,21 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1119
1236
|
// boolean here, before any await, means `waitForPipeIdle` can check
|
|
1120
1237
|
// "did close already happen" synchronously the moment it runs.
|
|
1121
1238
|
let closeSeen = false;
|
|
1239
|
+
// #2010: WHEN death was observed - the stop-clock for teardown evidence
|
|
1240
|
+
// and the discriminator for its outcome. A close that predates the
|
|
1241
|
+
// budget-expired kill means the child exited on its own ("exited"), no
|
|
1242
|
+
// matter that the timer callback raced and armed a kill anyway.
|
|
1243
|
+
let closedAtMs;
|
|
1122
1244
|
child.on("close", () => {
|
|
1123
1245
|
closeSeen = true;
|
|
1246
|
+
closedAtMs ??= Date.now();
|
|
1124
1247
|
});
|
|
1125
1248
|
// Timeout handling - KILL the process, don't just abandon it
|
|
1126
1249
|
const timeoutId = setTimeout(() => {
|
|
1127
1250
|
timedOut = true;
|
|
1128
1251
|
if (!killed && !child.killed) {
|
|
1129
1252
|
killed = true;
|
|
1253
|
+
teardownStartedAtMs = Date.now();
|
|
1130
1254
|
killPromise = killTree();
|
|
1131
1255
|
}
|
|
1132
1256
|
}, timeout);
|
|
@@ -1267,11 +1391,14 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1267
1391
|
await killPromise;
|
|
1268
1392
|
if (resolved)
|
|
1269
1393
|
return;
|
|
1270
|
-
// #1109: the child has exited
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
|
|
1394
|
+
// #1109: the child has exited - clear the non-Windows escalation
|
|
1395
|
+
// timer so it doesn't linger as a ref'd handle. #2027 EXCEPTION:
|
|
1396
|
+
// in POSIX group mode the timer is the liveness-gated SIGKILL
|
|
1397
|
+
// backstop for SIGTERM-hardy grandchildren; it stays armed and
|
|
1398
|
+
// self-cleans within 1s of firing (max 1s ref'd tail).
|
|
1399
|
+
if (escalationTimer && !posixProcessGroup) {
|
|
1274
1400
|
clearTimeout(escalationTimer);
|
|
1401
|
+
}
|
|
1275
1402
|
// #1673 review F1: latch the verdict as DECIDED here, before the
|
|
1276
1403
|
// idle-pipe wait — not after it. `code`/`signal`/`timedOut`/`aborted`
|
|
1277
1404
|
// are already fixed by this point (they don't change during the
|
|
@@ -1294,6 +1421,15 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1294
1421
|
const signalInfo = signal ? { signal } : {};
|
|
1295
1422
|
if (timedOut) {
|
|
1296
1423
|
const cause = new Error(`Process timed out after ${timeout}ms (killed with ${signal || "SIGTERM"})`);
|
|
1424
|
+
// #2010: teardown evidence lives ONLY on the timeout path - it
|
|
1425
|
+
// describes how long the tree took to die after the budget
|
|
1426
|
+
// expired, and how it died ("exited" = the child beat the signal;
|
|
1427
|
+
// "killed-by-signal" / "escalate-kill" = how WE ended it).
|
|
1428
|
+
const timeoutTeardown = resolveTeardownOutcome({
|
|
1429
|
+
killStartedAtMs: teardownStartedAtMs,
|
|
1430
|
+
deathObservedAtMs: closedAtMs,
|
|
1431
|
+
escalated: teardownEscalated,
|
|
1432
|
+
});
|
|
1297
1433
|
resolve({
|
|
1298
1434
|
stdout,
|
|
1299
1435
|
stderr,
|
|
@@ -1301,6 +1437,7 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1301
1437
|
error: cause,
|
|
1302
1438
|
failure: "timeout",
|
|
1303
1439
|
...signalInfo,
|
|
1440
|
+
...(timeoutTeardown && { timeoutTeardown }),
|
|
1304
1441
|
spawnFailure: new SpawnFailureError("timeout", cause.message, cause),
|
|
1305
1442
|
...outputInfo,
|
|
1306
1443
|
resourceUsage,
|
|
@@ -1383,8 +1520,10 @@ export async function safeSpawnAsync(command, args, options) {
|
|
|
1383
1520
|
closed = true;
|
|
1384
1521
|
clearTimeout(timeoutId);
|
|
1385
1522
|
abortSignal?.removeEventListener("abort", onAbort);
|
|
1386
|
-
|
|
1523
|
+
// #2027: group-mode timer stays armed as the grandchild backstop.
|
|
1524
|
+
if (escalationTimer && !posixProcessGroup) {
|
|
1387
1525
|
clearTimeout(escalationTimer);
|
|
1526
|
+
}
|
|
1388
1527
|
if (child.pid)
|
|
1389
1528
|
lifetimeState.pids.delete(child.pid);
|
|
1390
1529
|
const resourceUsage = finishResourceUsage();
|
|
@@ -89,9 +89,7 @@ export class SecurityScanClient {
|
|
|
89
89
|
*/
|
|
90
90
|
constructor(toolName, verbose = false) {
|
|
91
91
|
this.toolName = toolName;
|
|
92
|
-
this.log = verbose
|
|
93
|
-
? createSubsystemLogger(toolName)
|
|
94
|
-
: () => { };
|
|
92
|
+
this.log = verbose ? createSubsystemLogger(toolName) : () => { };
|
|
95
93
|
}
|
|
96
94
|
/**
|
|
97
95
|
* Resolve (once) whether the scanner is usable, sharing the probe promise
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One home for "this event arrived on a ctx the SDK already invalidated"
|
|
3
|
+
* (#1925).
|
|
4
|
+
*
|
|
5
|
+
* pi invalidates a captured extension ctx when the session is replaced —
|
|
6
|
+
* `ctx.newSession()`, `ctx.fork()`, `ctx.switchSession()`, `ctx.reload()`.
|
|
7
|
+
* Every accessor on that ctx then throws from the SDK's `assertActive()`
|
|
8
|
+
* (`core/extensions/loader.js` in the installed
|
|
9
|
+
* `@earendil-works/pi-coding-agent`). An event already queued when the swap
|
|
10
|
+
* happens still reaches pi-lens, carrying the dead ctx, and the first
|
|
11
|
+
* unguarded `ctx.signal` / `ctx.ui` / `ctx.cwd` read throws.
|
|
12
|
+
*
|
|
13
|
+
* That throw does not escape into pi's loop. `ExtensionRunner.emit` wraps every
|
|
14
|
+
* handler call in a try/catch and routes the error to `emitError`
|
|
15
|
+
* (`core/extensions/runner.js:586-606` in the installed SDK), so the throw
|
|
16
|
+
* surfaces as an extension error report against pi-lens. The report names the
|
|
17
|
+
* wrong cause. It reads as a pi-lens handler bug when the real event is a
|
|
18
|
+
* benign race with a session swap, it says nothing about which handler keeps
|
|
19
|
+
* losing, and it counts nothing. This wrapper converts that noisy misattributed
|
|
20
|
+
* error into a counted, bounded, attributable skip.
|
|
21
|
+
*
|
|
22
|
+
* #1924 fixed that for `agent_settled` with an inline try/catch. #1925 found
|
|
23
|
+
* four more handlers with the same shape. Five inline copies of one policy is
|
|
24
|
+
* the parallel-state defect AGENTS.md names, so the policy lives here instead.
|
|
25
|
+
* `agent_settled` is a consumer of this path, not a special case.
|
|
26
|
+
*
|
|
27
|
+
* ## What this module actually covers
|
|
28
|
+
*
|
|
29
|
+
* Not every `pi.on` registration in `index.ts` goes through here, and the doc
|
|
30
|
+
* used to read as if they all did. Seven do today:
|
|
31
|
+
*
|
|
32
|
+
* - {@link wrapSessionEventHandler} — `tool_result`, `turn_start`,
|
|
33
|
+
* `agent_end`, `turn_end`, `agent_settled` (#1925) and `session_start`
|
|
34
|
+
* (#1929). All six return nothing, so a skipped event resolving to
|
|
35
|
+
* `undefined` is exactly their own early-exit value.
|
|
36
|
+
* - {@link wrapSessionEventHandlerWithResult} — `context` (#1929), which must
|
|
37
|
+
* hand the host back a message list on the live path.
|
|
38
|
+
*
|
|
39
|
+
* Five registrations stay unwrapped on purpose: `resources_discover`,
|
|
40
|
+
* `session_before_fork`, `tool_call`, `session_shutdown`, and `message_end`.
|
|
41
|
+
* `tests/clients/session-event-guard-sweep.test.ts` is the source of truth for
|
|
42
|
+
* that split. It scans every registration in `index.ts` and reds unless the
|
|
43
|
+
* handler is wrapped or carries a written reason, so this paragraph can go
|
|
44
|
+
* stale but the contract cannot.
|
|
45
|
+
*
|
|
46
|
+
* The wrapper does three things and nothing else:
|
|
47
|
+
*
|
|
48
|
+
* 1. **Probes once, before dispatch.** {@link probeCtxActive} answers `false`
|
|
49
|
+
* only when an `assertActive()`-wrapped accessor threw the SDK's own stale
|
|
50
|
+
* message. `undefined` (no ctx, unexpected shape, unrecognised throw) is
|
|
51
|
+
* inconclusive and always dispatches — never guess a session dead.
|
|
52
|
+
* 2. **Still classifies a stale throw that races in mid-handler.** The probe
|
|
53
|
+
* is a point-in-time read; the swap can land between the probe and the
|
|
54
|
+
* handler's own first ctx read, and for an async handler it can land at any
|
|
55
|
+
* await. Both the synchronous throw and the rejected promise are
|
|
56
|
+
* classified by {@link isStaleExtensionCtxError}.
|
|
57
|
+
* 3. **Makes the skip visible.** One bounded record and one ledger kind for
|
|
58
|
+
* the whole class, keyed by the event name so aggregation still answers
|
|
59
|
+
* WHICH handler is being skipped. A silent guard and a guard that never
|
|
60
|
+
* fires read identically from a log, which is exactly how vacuous guards
|
|
61
|
+
* survive review.
|
|
62
|
+
*
|
|
63
|
+
* Anything that is NOT the SDK's stale-ctx error propagates unchanged. The
|
|
64
|
+
* wrapper narrows nothing else: a handler bug must stay as loud as it was.
|
|
65
|
+
*/
|
|
66
|
+
import { emitBounded } from "./bounded-telemetry.js";
|
|
67
|
+
import { probeCtxActive } from "./session-lifecycle.js";
|
|
68
|
+
/**
|
|
69
|
+
* The pi SDK invalidates a captured `pi`/command ctx after a session
|
|
70
|
+
* replacement or reload; every later `pi.*` call then throws with this
|
|
71
|
+
* signature. Matched by MESSAGE — not by `===` against a captured instance —
|
|
72
|
+
* so a fire-and-forget task that races a session swap can recognise the benign
|
|
73
|
+
* stale-ctx throw and degrade to a no-op. Substring-matched on the stable
|
|
74
|
+
* "stale after session replacement or reload" phrase so it survives incidental
|
|
75
|
+
* wording changes around it.
|
|
76
|
+
*
|
|
77
|
+
* Deliberately a NARROWER match than {@link probeCtxActive}'s (which accepts
|
|
78
|
+
* the shorter "stale after session replacement", the message
|
|
79
|
+
* `ExtensionRunner.invalidate()` uses for its own probe path). Widening this
|
|
80
|
+
* one is a behavior change: it decides which throws get swallowed.
|
|
81
|
+
*/
|
|
82
|
+
export function isStaleExtensionCtxError(err) {
|
|
83
|
+
return (err instanceof Error &&
|
|
84
|
+
err.message.includes("stale after session replacement or reload"));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Record one skipped session event. Bounded per event name: the ledger counts
|
|
88
|
+
* every occurrence exactly, and only the first per event name this session
|
|
89
|
+
* also writes the detailed `latency.log` row, so a replaced session whose
|
|
90
|
+
* queue drains a hundred stale events cannot storm the log.
|
|
91
|
+
*/
|
|
92
|
+
function recordStaleSkip(eventName, detectedAt) {
|
|
93
|
+
emitBounded("session_event_stale_ctx_skip", eventName, {
|
|
94
|
+
durationMs: 0,
|
|
95
|
+
metadata: { event: eventName, detectedAt },
|
|
96
|
+
}, {
|
|
97
|
+
ledgerKind: "extension-ctx-stale",
|
|
98
|
+
risingEdgePer: "identity",
|
|
99
|
+
reason: `${eventName} skipped: extension ctx is stale (${detectedAt})`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function isThenable(value) {
|
|
103
|
+
return (typeof value?.then === "function");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The one policy. Both public wrappers are this function with a different
|
|
107
|
+
* stale-path value, so the probe point, the classification, and the record can
|
|
108
|
+
* never drift apart between a void handler and a value-returning one.
|
|
109
|
+
*/
|
|
110
|
+
function guardSessionEvent(eventName, handler, onStaleResult, options) {
|
|
111
|
+
const skip = (event, detectedAt) => {
|
|
112
|
+
recordStaleSkip(eventName, detectedAt);
|
|
113
|
+
try {
|
|
114
|
+
options.dbg?.(`${eventName} skipped: extension ctx is stale after session replacement or reload`);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// A debug sink must never decide whether an event is handled.
|
|
118
|
+
}
|
|
119
|
+
return onStaleResult(event);
|
|
120
|
+
};
|
|
121
|
+
return (event, ctx) => {
|
|
122
|
+
// `false` is the only confirmed verdict. `undefined` means the probe
|
|
123
|
+
// could not tell, and an inconclusive probe must dispatch.
|
|
124
|
+
if (probeCtxActive(ctx) === false)
|
|
125
|
+
// SAFETY: `R` is the handler's declared return type, which is either a
|
|
126
|
+
// plain value or a promise of one. `skip` yields `Awaited<R>`, the
|
|
127
|
+
// settled form. TypeScript cannot see that a host awaiting an `R` is
|
|
128
|
+
// equally satisfied by the settled value, but every `pi.on` caller
|
|
129
|
+
// either awaits the result or ignores it, so both forms behave the
|
|
130
|
+
// same at the call site. The same reasoning covers the two casts
|
|
131
|
+
// below.
|
|
132
|
+
return skip(event, "pre-dispatch");
|
|
133
|
+
try {
|
|
134
|
+
const result = handler(event, ctx);
|
|
135
|
+
if (isThenable(result)) {
|
|
136
|
+
// Recover the rejection in place. The host awaits the same promise
|
|
137
|
+
// it would have awaited anyway; it just resolves instead.
|
|
138
|
+
// SAFETY: the recovered promise settles to `Awaited<R>`, which the
|
|
139
|
+
// host consumes exactly as it would an `R` — see the note above.
|
|
140
|
+
return Promise.resolve(result).catch((err) => {
|
|
141
|
+
if (isStaleExtensionCtxError(err))
|
|
142
|
+
return skip(event, "mid-handler");
|
|
143
|
+
throw err;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (isStaleExtensionCtxError(err))
|
|
150
|
+
// SAFETY: a synchronous handler's `R` is already its settled form,
|
|
151
|
+
// so `Awaited<R>` and `R` coincide here — see the note above.
|
|
152
|
+
return skip(event, "mid-handler");
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Wrap one `pi.on` handler so a stale ctx becomes an observable no-op instead
|
|
159
|
+
* of a throw into the host.
|
|
160
|
+
*
|
|
161
|
+
* The returned function keeps the handler's own signature and its return
|
|
162
|
+
* value, so a wrapped registration is a drop-in for the bare one. A skipped
|
|
163
|
+
* event resolves to `undefined`, which is what every handler wrapped this way
|
|
164
|
+
* already returns on its early-exit paths.
|
|
165
|
+
*
|
|
166
|
+
* Use {@link wrapSessionEventHandlerWithResult} when `undefined` is NOT the
|
|
167
|
+
* handler's own no-op value.
|
|
168
|
+
*/
|
|
169
|
+
export function wrapSessionEventHandler(eventName, handler, options = {}) {
|
|
170
|
+
return guardSessionEvent(eventName, handler, () => undefined, options);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* {@link wrapSessionEventHandler} for a handler whose return value the host
|
|
174
|
+
* consumes (#1929).
|
|
175
|
+
*
|
|
176
|
+
* The live path returns exactly what the handler returned. The stale path
|
|
177
|
+
* returns `onStaleResult(event)` instead of assuming `undefined`, so a handler
|
|
178
|
+
* whose no-op value is something else does not get one silently substituted.
|
|
179
|
+
* Probe, classification, and the bounded record are identical to the void
|
|
180
|
+
* wrapper's — they are the same function underneath.
|
|
181
|
+
*/
|
|
182
|
+
export function wrapSessionEventHandlerWithResult(eventName, handler, options) {
|
|
183
|
+
return guardSessionEvent(eventName, handler, options.onStaleResult, options);
|
|
184
|
+
}
|
|
@@ -31,7 +31,9 @@ export function formatSlopScoreSummary(summary) {
|
|
|
31
31
|
}
|
|
32
32
|
const topRules = summary.ruleCounts.slice(0, 3);
|
|
33
33
|
const detail = topRules.length > 0
|
|
34
|
-
? " (" +
|
|
34
|
+
? " (" +
|
|
35
|
+
topRules.map((entry) => entry.ruleId + " ×" + entry.count).join(", ") +
|
|
36
|
+
")"
|
|
35
37
|
: "";
|
|
36
38
|
return `Slop score: ${summary.scorePerKloc.toFixed(1)}/KLOC${detail}`;
|
|
37
39
|
}
|
|
@@ -151,9 +151,7 @@ export class SgRunner {
|
|
|
151
151
|
*/
|
|
152
152
|
sweepDurablyMissing = [];
|
|
153
153
|
constructor(verbose = false) {
|
|
154
|
-
this.log = verbose
|
|
155
|
-
? createSubsystemLogger("sg-runner")
|
|
156
|
-
: () => { };
|
|
154
|
+
this.log = verbose ? createSubsystemLogger("sg-runner") : () => { };
|
|
157
155
|
}
|
|
158
156
|
/**
|
|
159
157
|
* Check if ast-grep CLI is available, auto-install if not.
|
|
@@ -72,9 +72,9 @@ export function sourceTwinCandidates(filePath) {
|
|
|
72
72
|
: ext === ".cjs"
|
|
73
73
|
? [".cts", ".ts", ".tsx", ".mts"]
|
|
74
74
|
: ext === ".jsx"
|
|
75
|
-
// Deliberately retain the broad fallback: a .jsx next to a .ts
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
? // Deliberately retain the broad fallback: a .jsx next to a .ts
|
|
76
|
+
// is treated as a build artifact even without a .tsx sibling.
|
|
77
|
+
[".tsx", ".ts", ".mts", ".cts"]
|
|
78
78
|
: ext === ".js"
|
|
79
79
|
? [".ts", ".tsx", ".mts", ".cts"]
|
|
80
80
|
: [];
|
|
@@ -251,7 +251,7 @@ function classifySkipGeneratedOrArtifact(filePath, options) {
|
|
|
251
251
|
});
|
|
252
252
|
}
|
|
253
253
|
function shouldSkipGeneratedOrArtifact(filePath, options) {
|
|
254
|
-
return classifySkipGeneratedOrArtifact(filePath, options).verdict === "generated";
|
|
254
|
+
return (classifySkipGeneratedOrArtifact(filePath, options).verdict === "generated");
|
|
255
255
|
}
|
|
256
256
|
/**
|
|
257
257
|
* Extract the basename (filename without extension) from a path.
|
|
@@ -281,7 +281,8 @@ export function findSourceSibling(filePath, probeCache) {
|
|
|
281
281
|
const dir = getDir(filePath);
|
|
282
282
|
const base = getBasename(filePath);
|
|
283
283
|
for (const [sourceExt, shadowedExts] of Object.entries(SOURCE_PRECEDENCE)) {
|
|
284
|
-
if (!sourceExtsForCompiledExt(sourceExt).length &&
|
|
284
|
+
if (!sourceExtsForCompiledExt(sourceExt).length &&
|
|
285
|
+
shadowedExts.includes(ext)) {
|
|
285
286
|
const siblingPath = path.join(dir, base + sourceExt);
|
|
286
287
|
if (probeExists(siblingPath, probeCache))
|
|
287
288
|
return siblingPath;
|
|
@@ -296,7 +297,10 @@ export function findSourceSibling(filePath, probeCache) {
|
|
|
296
297
|
* Omit for the original, uncached behavior.
|
|
297
298
|
*/
|
|
298
299
|
function sourceExtsForCompiledExt(sourceExt) {
|
|
299
|
-
return sourceExt === ".ts" ||
|
|
300
|
+
return sourceExt === ".ts" ||
|
|
301
|
+
sourceExt === ".tsx" ||
|
|
302
|
+
sourceExt === ".mts" ||
|
|
303
|
+
sourceExt === ".cts"
|
|
300
304
|
? [sourceExt]
|
|
301
305
|
: [];
|
|
302
306
|
}
|
|
@@ -391,11 +395,15 @@ function classifyEntry(entry, fullPath, cfg, probeCache, skipCounters) {
|
|
|
391
395
|
return { recurseInto: fullPath };
|
|
392
396
|
}
|
|
393
397
|
if (entry.isFile()) {
|
|
394
|
-
|
|
395
|
-
|
|
398
|
+
// #1974: extension gate before isIgnored — isIgnored recompiles minimatch
|
|
399
|
+
// patterns per ancestor dir per call, so a large ignored-but-not-dir-
|
|
400
|
+
// prunable pile (e.g. wal/*.log) should never pay that cost for files
|
|
401
|
+
// the extension gate would have dropped anyway.
|
|
396
402
|
const ext = path.extname(entry.name).toLowerCase();
|
|
397
403
|
if (!extensions.has(ext))
|
|
398
404
|
return {};
|
|
405
|
+
if (ignoreMatcher.isIgnored(fullPath, false))
|
|
406
|
+
return {};
|
|
399
407
|
// Skip if this is a build artifact or generated/codegen output.
|
|
400
408
|
// #1107: counted separately (both are observability-only — neither
|
|
401
409
|
// changes what gets skipped) so an invisible coverage hole (a real
|
|
@@ -31,7 +31,7 @@ import * as fs from "node:fs";
|
|
|
31
31
|
import * as path from "node:path";
|
|
32
32
|
import { isExcludedDirName } from "./file-utils.js";
|
|
33
33
|
import { isGeneratedArtifactDirectoryName } from "./generated-artifacts.js";
|
|
34
|
-
import { createDeadline, yieldIfOverBudget
|
|
34
|
+
import { createDeadline, yieldIfOverBudget } from "./cooperative-budget.js";
|
|
35
35
|
/**
|
|
36
36
|
* Read a directory's entries, returning `[]` for a permission-denied or
|
|
37
37
|
* missing directory instead of throwing. Shared by every walker below — a
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped cooldown for commands whose spawn TIMED OUT (#1995).
|
|
3
|
+
*
|
|
4
|
+
* Live evidence: one wedged `markdownlint-cli2.cmd` on Windows charged its
|
|
5
|
+
* failure budget three times for a single edit — availability verification
|
|
6
|
+
* (30s, SIGTERM), autofix `--fix` (30s), and the lint runner (15s) each paid
|
|
7
|
+
* separately, because a positive probe/cache verdict said nothing about the
|
|
8
|
+
* command's ability to COMPLETE. Total post-edit cost: 52-60 seconds.
|
|
9
|
+
*
|
|
10
|
+
* This seam is the shared negative evidence:
|
|
11
|
+
* - `noteSpawnTimeout` records that a resolved command timed out, in which
|
|
12
|
+
* phase, and what wrapper kind it had; every record emits one bounded
|
|
13
|
+
* latency row so the session stays auditable.
|
|
14
|
+
* - `isInSpawnTimeoutCooldown` is consulted by every invocation path for that
|
|
15
|
+
* command — verification, autofix, and lint — so ONE wedged executable
|
|
16
|
+
* consumes at most ONE bounded failure budget per edit (invariant 1) and a
|
|
17
|
+
* timeout/skipped result is never re-reported as clean (invariant 4).
|
|
18
|
+
*
|
|
19
|
+
* Durability contract: SESSION. A hot loop of edits must not retry (a wedged
|
|
20
|
+
* `.cmd` shim does not heal between edits), but a new session may — the
|
|
21
|
+
* executable or its environment may have changed. Reset is registered in the
|
|
22
|
+
* session-state registry (`spawn-timeout-cooldown:latches`) and wired into
|
|
23
|
+
* `handleSessionStart`.
|
|
24
|
+
*/
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { logLatency } from "./latency-logger.js";
|
|
27
|
+
const COOLDOWN_STATE_KEY = Symbol.for("pi-lens:spawn-timeout-cooldown");
|
|
28
|
+
const _global = globalThis;
|
|
29
|
+
if (!_global[COOLDOWN_STATE_KEY]) {
|
|
30
|
+
_global[COOLDOWN_STATE_KEY] = { timedOutByCommand: new Map() };
|
|
31
|
+
}
|
|
32
|
+
const timedOutByCommand = _global[COOLDOWN_STATE_KEY].timedOutByCommand;
|
|
33
|
+
/** Wrapper kind for the telemetry record: how the command will be launched. */
|
|
34
|
+
function wrapperKindOf(command) {
|
|
35
|
+
const ext = path.extname(command);
|
|
36
|
+
return ext ? ext.slice(1) : "bare";
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Cooldown key: the command's basename minus extension, lowercased.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately NOT the full resolved path: the three lanes resolve the same
|
|
42
|
+
* physical binary through different helpers (PATH-resolved absolute,
|
|
43
|
+
* `<cwd>/node_modules/.bin/...`, bare name), and cross-lane sharing is the
|
|
44
|
+
* entire point (#1995 - otherwise each lane pays its own first budget).
|
|
45
|
+
*/
|
|
46
|
+
function cooldownKey(command) {
|
|
47
|
+
return path
|
|
48
|
+
.basename(command)
|
|
49
|
+
.replace(/\.[^.]+$/, "")
|
|
50
|
+
.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
export function noteSpawnTimeout(input) {
|
|
53
|
+
const key = cooldownKey(input.command);
|
|
54
|
+
timedOutByCommand.set(key, {
|
|
55
|
+
tool: input.tool,
|
|
56
|
+
command: input.command,
|
|
57
|
+
phase: input.phase,
|
|
58
|
+
atMs: Date.now(),
|
|
59
|
+
});
|
|
60
|
+
logLatency({
|
|
61
|
+
type: "phase",
|
|
62
|
+
toolName: input.tool,
|
|
63
|
+
phase: "spawn_timeout_cooldown",
|
|
64
|
+
filePath: input.command,
|
|
65
|
+
durationMs: input.durationMs ?? 0,
|
|
66
|
+
status: "cooldown_armed",
|
|
67
|
+
metadata: {
|
|
68
|
+
command: input.command,
|
|
69
|
+
wrapperKind: wrapperKindOf(input.command),
|
|
70
|
+
timeoutPhase: input.phase,
|
|
71
|
+
...(input.durationMs !== undefined && {
|
|
72
|
+
timeoutBudgetMs: input.durationMs,
|
|
73
|
+
}),
|
|
74
|
+
...(input.teardown && {
|
|
75
|
+
teardownMs: input.teardown.ms,
|
|
76
|
+
teardownOutcome: input.teardown.outcome,
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* True while `command` is cooling down after a spawn timeout. Every
|
|
83
|
+
* invocation path for this command should skip WITHOUT spawning and report
|
|
84
|
+
* an honest "not checked" outcome (skipped / 0-fixes), never clean.
|
|
85
|
+
*/
|
|
86
|
+
export function isInSpawnTimeoutCooldown(command) {
|
|
87
|
+
return timedOutByCommand.has(cooldownKey(command));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Session-start re-arm seam (registered in the session-state registry):
|
|
91
|
+
* a new session may retry a command the previous session cooled down -
|
|
92
|
+
* the executable or its environment may have changed.
|
|
93
|
+
*/
|
|
94
|
+
export function resetSpawnTimeoutCooldowns() {
|
|
95
|
+
timedOutByCommand.clear();
|
|
96
|
+
}
|