kimi-plugin-cc 2.4.1 → 2.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +12 -12
- package/dist/cli-client.js +17 -7
- package/dist/commands/cancel.js +14 -3
- package/dist/commands/recover-swarm.js +135 -0
- package/dist/commands/status.js +1 -1
- package/dist/commands/swarm.js +51 -54
- package/dist/companion.js +5 -1
- package/dist/git.js +5 -3
- package/dist/help.js +3 -2
- package/dist/hooks/config-safety.js +3 -2
- package/dist/job-store.js +13 -0
- package/dist/jobs.js +3 -0
- package/dist/kimi-engine.js +1 -0
- package/dist/kimi-errors.js +27 -21
- package/dist/parsing.js +1 -0
- package/dist/prompt-size.js +18 -0
- package/dist/swarm-activation.js +24 -3
- package/dist/swarm-recovery.js +134 -0
- package/dist/version.js +1 -1
- package/docs/registry-releases.md +14 -1
- package/docs/safety.md +9 -7
- package/docs/swarm-recovery.md +33 -0
- package/docs/windows-validation.md +39 -0
- package/package.json +6 -3
- package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/kimi-codex/dist/cli-client.js +17 -7
- package/plugins/kimi-codex/dist/commands/cancel.js +14 -3
- package/plugins/kimi-codex/dist/commands/recover-swarm.js +135 -0
- package/plugins/kimi-codex/dist/commands/status.js +1 -1
- package/plugins/kimi-codex/dist/commands/swarm.js +51 -54
- package/plugins/kimi-codex/dist/companion.js +5 -1
- package/plugins/kimi-codex/dist/git.js +5 -3
- package/plugins/kimi-codex/dist/help.js +3 -2
- package/plugins/kimi-codex/dist/hooks/config-safety.js +3 -2
- package/plugins/kimi-codex/dist/job-store.js +13 -0
- package/plugins/kimi-codex/dist/jobs.js +3 -0
- package/plugins/kimi-codex/dist/kimi-engine.js +1 -0
- package/plugins/kimi-codex/dist/kimi-errors.js +27 -21
- package/plugins/kimi-codex/dist/parsing.js +1 -0
- package/plugins/kimi-codex/dist/prompt-size.js +18 -0
- package/plugins/kimi-codex/dist/swarm-activation.js +24 -3
- package/plugins/kimi-codex/dist/swarm-recovery.js +134 -0
- package/plugins/kimi-codex/dist/version.js +1 -1
|
@@ -594,7 +594,7 @@ function validateVersionSensitiveEvents(events, version) {
|
|
|
594
594
|
}
|
|
595
595
|
// Exact schemas reviewed at 2.0.0 (1b89e4b0), 2.0.1 (caf7d4e2),
|
|
596
596
|
// 2.0.2 (9d07f634), 2.1.0 (52437299), 2.1.1 (f67e6398), 2.2.0 (472192c3),
|
|
597
|
-
// 2.3.0 (b5e28fd6), 2.3.1 (684bae9e)
|
|
597
|
+
// 2.3.0 (b5e28fd6), 2.3.1 (684bae9e) 2.3.2 (b8dd3a13), and 2.3.3 (ec495f0c): the additive
|
|
598
598
|
// events and HookDefSchema are unchanged. Require the full version string AND matching components;
|
|
599
599
|
// no inferred 2.x compatibility.
|
|
600
600
|
if (version.major === 2 && ((version.minor === 0 && ((version.version === "2.0.0" && version.patch === 0) ||
|
|
@@ -605,7 +605,8 @@ function validateVersionSensitiveEvents(events, version) {
|
|
|
605
605
|
(version.minor === 2 && ((version.version === "2.2.0" && version.patch === 0))) ||
|
|
606
606
|
(version.minor === 3 && ((version.version === "2.3.0" && version.patch === 0) ||
|
|
607
607
|
(version.version === "2.3.1" && version.patch === 1) ||
|
|
608
|
-
(version.version === "2.3.2" && version.patch === 2)
|
|
608
|
+
(version.version === "2.3.2" && version.patch === 2) ||
|
|
609
|
+
(version.version === "2.3.3" && version.patch === 3))))) {
|
|
609
610
|
return { valid: true };
|
|
610
611
|
}
|
|
611
612
|
if (version.major !== 0) {
|
|
@@ -122,6 +122,8 @@ export class JobStore {
|
|
|
122
122
|
CREATE INDEX IF NOT EXISTS jobs_repo_updated_idx
|
|
123
123
|
ON jobs (repo_id, updated_at DESC);
|
|
124
124
|
|
|
125
|
+
CREATE TABLE IF NOT EXISTS swarm_recovery (job_id TEXT PRIMARY KEY, payload TEXT NOT NULL, recovery_owner TEXT);
|
|
126
|
+
|
|
125
127
|
CREATE INDEX IF NOT EXISTS jobs_repo_type_updated_idx
|
|
126
128
|
ON jobs (repo_id, command_type, updated_at DESC);
|
|
127
129
|
|
|
@@ -186,6 +188,17 @@ export class JobStore {
|
|
|
186
188
|
throw translateSqliteError(error);
|
|
187
189
|
}
|
|
188
190
|
}
|
|
191
|
+
getSwarmRecovery(jobId) {
|
|
192
|
+
const row = this.db.get("SELECT payload, recovery_owner FROM swarm_recovery WHERE job_id = ?", jobId);
|
|
193
|
+
return row ? { record: JSON.parse(row.payload), owner: row.recovery_owner } : null;
|
|
194
|
+
}
|
|
195
|
+
saveSwarmRecovery(jobId, record) {
|
|
196
|
+
this.db.run("INSERT INTO swarm_recovery (job_id, payload) VALUES (?, ?) ON CONFLICT(job_id) DO UPDATE SET payload = excluded.payload", [jobId, JSON.stringify(record)]);
|
|
197
|
+
}
|
|
198
|
+
claimSwarmRecovery(jobId, expected, owner) {
|
|
199
|
+
this.db.run("UPDATE swarm_recovery SET recovery_owner = ? WHERE job_id = ? AND recovery_owner IS ?", [owner, jobId, expected]);
|
|
200
|
+
return this.db.get("SELECT changes() AS count").count === 1;
|
|
201
|
+
}
|
|
189
202
|
close() {
|
|
190
203
|
this.db.close();
|
|
191
204
|
}
|
|
@@ -85,6 +85,9 @@ export async function sweepStaleJobs(store, paths) {
|
|
|
85
85
|
for (const job of store.listRunningJobsWithProcessHints()) {
|
|
86
86
|
if (store.getMcpTask(job.job_id))
|
|
87
87
|
continue;
|
|
88
|
+
// Recovery owns orphaned swarm resources; PID-only sweeping must not signal replacements.
|
|
89
|
+
if (store.getSwarmRecovery(job.job_id))
|
|
90
|
+
continue;
|
|
88
91
|
if (!isStaleEnoughToSweep(job)) {
|
|
89
92
|
continue;
|
|
90
93
|
}
|
|
@@ -44,6 +44,11 @@ export function classifyManagedCommandFailure(error, commandType, jobId, options
|
|
|
44
44
|
const details = { availability: classification.kind };
|
|
45
45
|
if (error instanceof RuntimeError) {
|
|
46
46
|
details.cause_code = error.code;
|
|
47
|
+
for (const key of ["spawn_code", "spawn_errno", "spawn_message"]) {
|
|
48
|
+
if (error.details[key] !== undefined)
|
|
49
|
+
details[key] = key === "spawn_message" && typeof error.details[key] === "string"
|
|
50
|
+
? truncateCauseMessageForDetails(error.details[key]) : error.details[key];
|
|
51
|
+
}
|
|
47
52
|
if (error.code === "RESPONSE_TIMEOUT") {
|
|
48
53
|
for (const key of ["budget_ms", "record_count", "partial_output_chars", "aborted", "settled_post_abort", "hook_escapes"]) {
|
|
49
54
|
if (error.details[key] !== undefined)
|
|
@@ -129,28 +134,29 @@ function classifyKimiAvailability(error) {
|
|
|
129
134
|
authProbe: "failed",
|
|
130
135
|
};
|
|
131
136
|
}
|
|
132
|
-
if (error instanceof RuntimeError && error.code === "CLI_SPAWN_FAILED") {
|
|
133
|
-
return {
|
|
134
|
-
kind: "binary_unavailable",
|
|
135
|
-
summary: "the Kimi CLI is missing from PATH or not executable in this environment.",
|
|
136
|
-
nextStep: "Run `/kimi:setup` to verify the install, then expose `kimi` on PATH and retry.",
|
|
137
|
-
runtimeProbe: "failed",
|
|
138
|
-
authProbe: "failed",
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
// v1.0 cli-client surfaces async ENOENT (Bun) as CLI_PROCESS_ERROR with
|
|
142
|
-
// "spawn ... ENOENT" in the message. Map that to binary_unavailable so
|
|
143
|
-
// the failure message points users at /kimi:setup.
|
|
144
137
|
if (error instanceof RuntimeError &&
|
|
145
|
-
error.code === "CLI_PROCESS_ERROR"
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
138
|
+
(error.code === "CLI_SPAWN_FAILED" || error.code === "CLI_PROCESS_ERROR")) {
|
|
139
|
+
const spawnCode = error.details.spawn_code;
|
|
140
|
+
const binaryUnavailable = typeof spawnCode === "string" && ["ENOENT", "EACCES", "ENOEXEC"].includes(spawnCode);
|
|
141
|
+
if (typeof spawnCode === "string" && !binaryUnavailable) {
|
|
142
|
+
return {
|
|
143
|
+
kind: "startup_failed",
|
|
144
|
+
summary: "the Kimi subprocess could not be started or used.",
|
|
145
|
+
nextStep: "Check the underlying process error; shorten the prompt or environment if an argument-size limit was exceeded, then retry.",
|
|
146
|
+
runtimeProbe: "failed",
|
|
147
|
+
authProbe: "failed",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// Keep opaque synchronous failures and legacy async ENOENT diagnostics compatible.
|
|
151
|
+
if (binaryUnavailable || error.code === "CLI_SPAWN_FAILED" || /\bENOENT\b/.test(message)) {
|
|
152
|
+
return {
|
|
153
|
+
kind: "binary_unavailable",
|
|
154
|
+
summary: "the Kimi CLI is missing from PATH or not executable in this environment.",
|
|
155
|
+
nextStep: "Run `/kimi:setup` to verify the install, then expose `kimi` on PATH and retry.",
|
|
156
|
+
runtimeProbe: "failed",
|
|
157
|
+
authProbe: "failed",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
154
160
|
}
|
|
155
161
|
// v1.0 cli-client surfaces non-ENOENT process errors and non-zero
|
|
156
162
|
// exits as CLI_PROCESS_ERROR / CLI_NONZERO_EXIT. Treat both as
|
|
@@ -18,6 +18,7 @@ export const RESCUE_SUPPORTED_FLAGS = "-m/--model <name>, --thinking <off|low|me
|
|
|
18
18
|
export const REVIEW_SUPPORTED_FLAGS = "--base <ref>, -m/--model <name>, --thinking <off|low|medium|high>, --budget <30m|1h|90s>";
|
|
19
19
|
export const JOB_LOOKUP_SUPPORTED_FLAGS = "--type <review|challenge|rescue|review_gate|ask>, --json";
|
|
20
20
|
export const SETUP_SUPPORTED_FLAGS = "--check, --uninstall, --all (with --uninstall), --enable-review-gate, --disable-review-gate, --models [--json] (without other setup flags)";
|
|
21
|
+
export const RECOVER_SWARM_SUPPORTED_FLAGS = "--apply";
|
|
21
22
|
export const REPAIR_SESSIONS_SUPPORTED_FLAGS = "--apply, --all";
|
|
22
23
|
export const REPLAY_SUPPORTED_FLAGS = "none (one job-id required)";
|
|
23
24
|
/** Validate syntax only; upstream per-model supportEfforts remains authoritative. */
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { RuntimeError } from "./errors.js";
|
|
2
|
+
// Node/libuv's default CreateProcess argument quoting (UTF-16 code units).
|
|
3
|
+
export function windowsCommandLineLength(command, args) {
|
|
4
|
+
const quotedLength = (value) => {
|
|
5
|
+
if (value.length > 0 && !/[\s"]/u.test(value))
|
|
6
|
+
return value.length;
|
|
7
|
+
return 2 + value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1').length;
|
|
8
|
+
};
|
|
9
|
+
return [command, ...args].reduce((size, value) => size + quotedLength(value) + 1, 0);
|
|
10
|
+
}
|
|
11
|
+
export function assertPromptFitsCommandLine(command, args, platform = process.platform) {
|
|
12
|
+
if (platform !== "win32")
|
|
13
|
+
return;
|
|
14
|
+
const size = windowsCommandLineLength(command, args);
|
|
15
|
+
if (size <= 32_767)
|
|
16
|
+
return;
|
|
17
|
+
throw new RuntimeError("CLI_COMMAND_LINE_TOO_LONG", "The Kimi prompt exceeds the Windows command-line limit; the CLI was not started. Narrow the review or split it into smaller scopes. A short ask prompt can reference a saved diff inside the workspace. Reinstalling Kimi or running setup will not fix this. The current print transport does not support stdin prompts.", "cli-client.pre-spawn", { details: { command_line_utf16_units: size, limit_utf16_units: 32_767, platform } });
|
|
18
|
+
}
|
|
@@ -9,7 +9,7 @@ import { assertExecutionPlanMatchesSpawn, assertNoUnsafeExperimentalSelector, }
|
|
|
9
9
|
import { resolveKimiHome } from "./kimi-home.js";
|
|
10
10
|
import { assertNativeV2Preflight } from "./native-v2-preflight.js";
|
|
11
11
|
import { collectDescendantIdentities, collectProcessGroupsIdentities, probeProcessIdentity, revalidateProcessIdentities, waitForProcessTreeExit, } from "./process-tree.js";
|
|
12
|
-
const
|
|
12
|
+
const PREACTIVATION_VERSIONS = ["2.3.2", "2.3.3"];
|
|
13
13
|
const READY_TIMEOUT_MS = 10_000;
|
|
14
14
|
const REST_TIMEOUT_MS = 5_000;
|
|
15
15
|
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
@@ -25,7 +25,7 @@ const ESCALATION_MS = 1_500;
|
|
|
25
25
|
const POST_KILL_QUIESCENCE_MS = 500;
|
|
26
26
|
const ENV_MODEL_ALIAS = "__kimi_env_model__";
|
|
27
27
|
export function requiresSwarmPreactivation(version) {
|
|
28
|
-
return version
|
|
28
|
+
return version !== null && PREACTIVATION_VERSIONS.includes(version);
|
|
29
29
|
}
|
|
30
30
|
export async function preactivateSwarmSession(options) {
|
|
31
31
|
const failureCode = "SWARM_ACTIVATION_FAILED";
|
|
@@ -39,6 +39,7 @@ export async function preactivateSwarmSession(options) {
|
|
|
39
39
|
await verifyMeta(web, prepared.expectedVersion, options.signal, failureCode);
|
|
40
40
|
const model = await resolveProfileModel(web, options, failureCode);
|
|
41
41
|
identity = await createSession(web, options, failureCode);
|
|
42
|
+
options.onRecoveryEvent?.({ kind: "session", ...identity });
|
|
42
43
|
profileMutationAttempted = true;
|
|
43
44
|
await postSwarmProfile(web, identity, true, model, options.signal, failureCode);
|
|
44
45
|
await verifySwarmStatus(web, identity, true, model, options.signal, failureCode);
|
|
@@ -88,6 +89,7 @@ export async function deactivateSwarmSession(options) {
|
|
|
88
89
|
await verifySwarmStatus(web, options, false, undefined, options.signal, failureCode);
|
|
89
90
|
await shutdownWeb(web, options.signal, failureCode);
|
|
90
91
|
await requireSwarmJournal(prepared.kimiHome, options, "exit", options.signal, failureCode);
|
|
92
|
+
options.onRecoveryEvent?.({ kind: "disabled" });
|
|
91
93
|
throwIfAborted(options.signal, failureCode, "swarm-deactivation.complete");
|
|
92
94
|
}
|
|
93
95
|
catch (error) {
|
|
@@ -114,7 +116,7 @@ async function prepareWebSpawn(options, failureCode) {
|
|
|
114
116
|
}
|
|
115
117
|
const expectedVersion = options.expectedVersion ?? options.executionPlan.kimiVersion;
|
|
116
118
|
if (expectedVersion === null || !requiresSwarmPreactivation(expectedVersion)) {
|
|
117
|
-
throw failure(failureCode, "swarm-activation.version", `Swarm web preactivation requires exact kimi-code ${
|
|
119
|
+
throw failure(failureCode, "swarm-activation.version", `Swarm web preactivation requires exact kimi-code ${PREACTIVATION_VERSIONS.join(" or ")}.`);
|
|
118
120
|
}
|
|
119
121
|
const kimiHome = resolveKimiHome(options.env, options.cwd);
|
|
120
122
|
if (options.executionPlan.intendedEngine === "native-v2") {
|
|
@@ -144,6 +146,7 @@ async function startWebChild(options, env, failureCode) {
|
|
|
144
146
|
"error",
|
|
145
147
|
];
|
|
146
148
|
let child;
|
|
149
|
+
options.onRecoveryEvent?.({ kind: "web-spawn" });
|
|
147
150
|
try {
|
|
148
151
|
child = spawn(options.command, args, {
|
|
149
152
|
cwd: options.cwd,
|
|
@@ -153,6 +156,7 @@ async function startWebChild(options, env, failureCode) {
|
|
|
153
156
|
});
|
|
154
157
|
}
|
|
155
158
|
catch {
|
|
159
|
+
options.onRecoveryEvent?.({ kind: "web-exit" });
|
|
156
160
|
throw failure(failureCode, "swarm-activation.spawn", "Failed to spawn the Kimi web lifecycle process.");
|
|
157
161
|
}
|
|
158
162
|
let exited = false;
|
|
@@ -184,6 +188,7 @@ async function startWebChild(options, env, failureCode) {
|
|
|
184
188
|
child,
|
|
185
189
|
closePromise,
|
|
186
190
|
descendants: [],
|
|
191
|
+
onRecoveryEvent: options.onRecoveryEvent,
|
|
187
192
|
trackingStopped: false,
|
|
188
193
|
lastSnapshotComplete: false,
|
|
189
194
|
snapshotIncomplete: false,
|
|
@@ -198,6 +203,8 @@ async function startWebChild(options, env, failureCode) {
|
|
|
198
203
|
try {
|
|
199
204
|
const ready = await waitForReadyLine(owned, options.signal, failureCode);
|
|
200
205
|
await refreshOwnedDescendants(owned);
|
|
206
|
+
if (owned.recoveryFailed)
|
|
207
|
+
throw failure(failureCode, "swarm-activation.recovery", "Cannot persist web ownership; refusing session mutation.");
|
|
201
208
|
return Object.assign(owned, ready);
|
|
202
209
|
}
|
|
203
210
|
catch (error) {
|
|
@@ -610,12 +617,26 @@ async function refreshOwnedDescendants(owned) {
|
|
|
610
617
|
owned.snapshotIncomplete = true;
|
|
611
618
|
owned.lastSnapshotComplete = false;
|
|
612
619
|
}
|
|
620
|
+
try {
|
|
621
|
+
owned.onRecoveryEvent?.({ kind: "web-processes", identities: [...(owned.rootIdentity ? [owned.rootIdentity] : []), ...owned.descendants], complete: owned.lastSnapshotComplete });
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
owned.recoveryFailed = true;
|
|
625
|
+
}
|
|
613
626
|
})().finally(() => {
|
|
614
627
|
owned.refreshPromise = undefined;
|
|
615
628
|
});
|
|
616
629
|
return await owned.refreshPromise;
|
|
617
630
|
}
|
|
618
631
|
async function teardownOwnedChild(owned) {
|
|
632
|
+
const quiescent = await teardownOwnedChildInner(owned);
|
|
633
|
+
if (quiescent && !owned.recoveryFailed) {
|
|
634
|
+
owned.onRecoveryEvent?.({ kind: "web-exit" });
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
async function teardownOwnedChildInner(owned) {
|
|
619
640
|
await stopDescendantTracking(owned);
|
|
620
641
|
const childPid = owned.child.pid;
|
|
621
642
|
if (childPid === undefined)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { RuntimeError } from "./errors.js";
|
|
2
|
+
import { collectDescendantIdentities, collectProcessGroupsIdentities, probeProcessIdentity, revalidateProcessIdentities } from "./process-tree.js";
|
|
3
|
+
export function recordRecoveryEvent(store, record, event) {
|
|
4
|
+
record.complete = false;
|
|
5
|
+
if (event.kind === "session") {
|
|
6
|
+
record.session = { sessionId: event.sessionId, workspaceId: event.workspaceId };
|
|
7
|
+
// Write-ahead: this must commit before the profile mutation is attempted.
|
|
8
|
+
record.sessionDirty = true;
|
|
9
|
+
}
|
|
10
|
+
else if (event.kind === "disabled") {
|
|
11
|
+
record.sessionDirty = false;
|
|
12
|
+
}
|
|
13
|
+
else if (event.kind.endsWith("-spawn")) {
|
|
14
|
+
record.launches.push({ kind: event.kind === "web-spawn" ? "web" : "print", pending: true, identities: [], quiescent: false, uncertain: false });
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
const kind = event.kind.startsWith("web-") ? "web" : "print";
|
|
18
|
+
const launch = [...record.launches].reverse().find((entry) => entry.kind === kind);
|
|
19
|
+
if (!launch)
|
|
20
|
+
throw recoveryError("Missing durable launch intent.");
|
|
21
|
+
if (event.kind === "web-exit" || event.kind === "print-exit") {
|
|
22
|
+
launch.quiescent = true;
|
|
23
|
+
launch.pending = false;
|
|
24
|
+
}
|
|
25
|
+
else if ("identities" in event) {
|
|
26
|
+
const all = [...launch.identities, ...event.identities];
|
|
27
|
+
launch.identities = [...new Map(all.map((identity) => [`${identity.pid}:${identity.startToken}:${identity.processGroupId}`, identity])).values()];
|
|
28
|
+
launch.pending = launch.identities.length === 0;
|
|
29
|
+
launch.uncertain ||= !event.complete;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
store.saveSwarmRecovery(record.jobId, record);
|
|
33
|
+
}
|
|
34
|
+
export function finishRecoveryRecord(store, record) {
|
|
35
|
+
record.complete = !record.sessionDirty && record.launches.every((entry) => entry.quiescent)
|
|
36
|
+
&& (record.workspaceRoot === null || record.patchPath !== null);
|
|
37
|
+
store.saveSwarmRecovery(record.jobId, record);
|
|
38
|
+
}
|
|
39
|
+
export function recoveryError(message) {
|
|
40
|
+
return new RuntimeError("SWARM_RECOVERY_REQUIRED", message, "swarm.recovery");
|
|
41
|
+
}
|
|
42
|
+
export async function assertRecoveryOwnerGone(record) {
|
|
43
|
+
if (!record.owner || process.platform === "win32")
|
|
44
|
+
throw recoveryError("No durable, verifiable owner identity; recovery requires manual inspection on this host.");
|
|
45
|
+
const probe = await probeProcessIdentity(record.ownerPid);
|
|
46
|
+
if (!probe.complete)
|
|
47
|
+
throw recoveryError("Cannot verify the original companion's identity.");
|
|
48
|
+
if (probe.identity?.startToken === record.owner.startToken && !/^[ZX]/i.test(probe.identity.state)) {
|
|
49
|
+
throw recoveryError("The original companion is still alive. Use cancel for a running job, not recovery.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Linux may fall back from /proc to ps for a whole-group snapshot while targeted
|
|
54
|
+
* identity reads still use /proc. Those start tokens are different namespaces,
|
|
55
|
+
* not different processes. Prove both representations current around the known
|
|
56
|
+
* identity read; never accept a member based on PID/PGID equality alone.
|
|
57
|
+
*/
|
|
58
|
+
export async function validateRecoveryGroupMembers(known, members, validate = revalidateProcessIdentities) {
|
|
59
|
+
const membership = await validate(members);
|
|
60
|
+
const ownership = await validate(known);
|
|
61
|
+
const membershipAfter = await validate(membership.identities);
|
|
62
|
+
for (const proof of [membership, ownership, membershipAfter]) {
|
|
63
|
+
if (!proof.complete || proof.groupChanged?.length)
|
|
64
|
+
throw recoveryError("Process ownership or group membership is uncertain.");
|
|
65
|
+
}
|
|
66
|
+
for (const member of membershipAfter.identities) {
|
|
67
|
+
const owner = ownership.identities.find((id) => id.pid === member.pid && id.processGroupId === member.processGroupId);
|
|
68
|
+
const crossNamespace = owner && ((owner.startToken.startsWith("proc:") && member.startToken.startsWith("ps:")) ||
|
|
69
|
+
(owner.startToken.startsWith("ps:") && member.startToken.startsWith("proc:")));
|
|
70
|
+
if (!owner || (owner.startToken !== member.startToken && !crossNamespace)) {
|
|
71
|
+
throw recoveryError("An unrecorded process survives in the recorded group; refusing group cleanup.");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return ownership.identities;
|
|
75
|
+
}
|
|
76
|
+
/** Never signal an unproven group, PID replacement, or changed-PGID process. */
|
|
77
|
+
export async function recoverLaunch(launch, persist, signal) {
|
|
78
|
+
if (launch.quiescent)
|
|
79
|
+
return;
|
|
80
|
+
if (launch.pending || launch.uncertain || launch.identities.length === 0) {
|
|
81
|
+
throw recoveryError("Launch ownership was not fully recorded before interruption; manual inspection is required. No process was signalled.");
|
|
82
|
+
}
|
|
83
|
+
const root = launch.identities[0];
|
|
84
|
+
const rootProbe = await probeProcessIdentity(root.pid);
|
|
85
|
+
if (!rootProbe.complete)
|
|
86
|
+
throw recoveryError("Process identity probe is incomplete.");
|
|
87
|
+
if (rootProbe.identity?.startToken === root.startToken) {
|
|
88
|
+
if (rootProbe.identity.processGroupId !== root.processGroupId)
|
|
89
|
+
throw recoveryError("The recorded root changed process groups; refusing to signal it.");
|
|
90
|
+
const descendants = await collectDescendantIdentities(root.pid);
|
|
91
|
+
// Revalidate the ancestry anchor after collection before retaining new identities.
|
|
92
|
+
const after = await probeProcessIdentity(root.pid);
|
|
93
|
+
if (!after.complete || after.identity?.startToken !== root.startToken || after.identity.processGroupId !== root.processGroupId) {
|
|
94
|
+
throw recoveryError("Process ownership changed during recovery inspection.");
|
|
95
|
+
}
|
|
96
|
+
launch.identities = [...new Map([...launch.identities, ...descendants].map((id) => [`${id.pid}:${id.startToken}:${id.processGroupId}`, id])).values()];
|
|
97
|
+
persist();
|
|
98
|
+
}
|
|
99
|
+
const inspect = async () => {
|
|
100
|
+
const members = await collectProcessGroupsIdentities(launch.identities.map((id) => id.processGroupId));
|
|
101
|
+
if (members === undefined)
|
|
102
|
+
throw recoveryError("Process group membership is unreadable.");
|
|
103
|
+
return validateRecoveryGroupMembers(launch.identities, members);
|
|
104
|
+
};
|
|
105
|
+
for (const [killSignal, waitMs] of [["SIGTERM", 1500], ["SIGKILL", 500]]) {
|
|
106
|
+
signal?.throwIfAborted();
|
|
107
|
+
for (const identity of await inspect()) {
|
|
108
|
+
signal?.throwIfAborted();
|
|
109
|
+
const current = await revalidateProcessIdentities([identity]);
|
|
110
|
+
if (!current.complete || current.groupChanged?.length)
|
|
111
|
+
throw recoveryError("Process identity changed before signalling.");
|
|
112
|
+
if (current.identities.length) {
|
|
113
|
+
try {
|
|
114
|
+
process.kill(identity.pid, killSignal);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error.code !== "ESRCH")
|
|
118
|
+
throw recoveryError("Unable to stop the recorded process.");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const deadline = Date.now() + waitMs;
|
|
123
|
+
do {
|
|
124
|
+
signal?.throwIfAborted();
|
|
125
|
+
if ((await inspect()).length === 0) {
|
|
126
|
+
launch.quiescent = true;
|
|
127
|
+
persist();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
131
|
+
} while (Date.now() < deadline);
|
|
132
|
+
}
|
|
133
|
+
throw recoveryError("Recorded processes did not become quiescent; recovery remains pending.");
|
|
134
|
+
}
|