@bridge_gpt/mcp-server 0.2.62 → 0.2.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -8
- package/build/agent-launchers/claude-executor-adapter.js +134 -3
- package/build/agent-launchers/executor-adapter.js +54 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +335 -22
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +324 -0
- package/build/conductor/epic-implementer-cli.js +142 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +22 -11
- package/build/conductor/recovery-cli.js +354 -13
- package/build/conductor/recovery-operations.js +251 -5
- package/build/conductor/repair-contract.js +8 -0
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +50 -12
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +77 -11
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1211 -116
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +35 -29
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-profile.js +14 -5
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +6 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +76 -4
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1294 -26
- package/build/executor/job-types.js +10 -2
- package/build/executor/preflight.js +99 -0
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +262 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +145 -0
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +12 -0
- package/build/executor/worker-command.js +49 -13
- package/build/executor/worker-finalization.js +88 -11
- package/build/executor/worker-guard-hook.js +29 -4
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +537 -943
- package/build/install-bridge-conductor.js +231 -41
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +8 -7
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/supervisor.js +142 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +28 -17
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/docs/CONDUCTOR.md +116 -2
- package/package.json +2 -1
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job-owned process containment (BAPI-1301, D4a — plan Steps 7 and 8).
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS. A covered child attempt (`implement`, `resume`, `remediate`,
|
|
5
|
+
* `ci_fix`) may only report success when the executor has CONFIRMED that no
|
|
6
|
+
* process the job started is still running. This module produces that
|
|
7
|
+
* confirmation as a closed `CompletionCleanup` verdict. The verdict is
|
|
8
|
+
* SUCCESS-BLOCKING: anything other than `confirmed` with zero survivors refuses
|
|
9
|
+
* success (`successRefusalReason` in `completion-contract.ts`).
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS IS NOT. It is not the adapter's configuration lifecycle cleanup in
|
|
12
|
+
* `agent-launchers/executor-adapter.ts` (temporary config files, isolation
|
|
13
|
+
* directories). That cleanup stays separate and best-effort, and it can never
|
|
14
|
+
* stand in for this verdict.
|
|
15
|
+
*
|
|
16
|
+
* THE MECHANISM (measured in the BAPI-1292 probe addendum §4). Every covered
|
|
17
|
+
* worker environment carries a unique, unpredictable per-job tag in
|
|
18
|
+
* `BRIDGE_EXECUTOR_JOB_TAG`. Descendants that keep the inherited environment keep
|
|
19
|
+
* the tag, even after they double-fork, `setsid`, and reparent to PID 1. After
|
|
20
|
+
* every termination path, the executor:
|
|
21
|
+
* 1. enumerates same-user processes with their parent pids and environments
|
|
22
|
+
* (`ps -E`),
|
|
23
|
+
* 2. freezes (SIGSTOP) each OWNED process, re-verifying PID + start time
|
|
24
|
+
* immediately before the signal. Owned means: its tag EXACTLY matches the
|
|
25
|
+
* job token, OR its (pid, start time) exactly matches a descendant the
|
|
26
|
+
* executor tracked by parent pid while the worker ran, OR its parent is an
|
|
27
|
+
* owned process in the same listing,
|
|
28
|
+
* 3. repeats until several consecutive rounds find nothing new (this is what
|
|
29
|
+
* catches work spawned while the enumeration was running),
|
|
30
|
+
* 4. re-verifies identity again and SIGKILLs each frozen process,
|
|
31
|
+
* 5. re-enumerates to confirm nothing owned remains.
|
|
32
|
+
* Any owned process found at all means the job left work behind: the verdict is
|
|
33
|
+
* `failed` even when the sweep then removed it. Cleanup stops the work; it does
|
|
34
|
+
* not turn leftover work into a success.
|
|
35
|
+
*
|
|
36
|
+
* ACCEPTED RESIDUALS (macOS lane). Every verdict names both:
|
|
37
|
+
* - `environment_scrubbing_daemon`: a daemon that detaches AND clears its own
|
|
38
|
+
* environment carries no tag and is invisible to every user-space method the
|
|
39
|
+
* probe tested on macOS. The controlled workload excludes such a daemon.
|
|
40
|
+
* - `platform_binary_environment_hidden`: macOS hides the environment of a
|
|
41
|
+
* platform (Apple-signed) binary such as `/bin/bash`, `/bin/zsh` or
|
|
42
|
+
* `/bin/sleep` from `ps -E`, so it shows no tag. Descendant tracking covers
|
|
43
|
+
* the common case; one that detaches from the tracked tree between samples
|
|
44
|
+
* (reparented to launchd) is not attributable.
|
|
45
|
+
* Nothing here claims to detect either, and there is no heuristic that pretends to.
|
|
46
|
+
*
|
|
47
|
+
* NO KERNEL-BOUNDARY CLAIM. This module makes no Linux cgroup claim and no
|
|
48
|
+
* separate-OS-user-per-job claim. On any platform other than darwin the default
|
|
49
|
+
* inspector reports enumeration as failed, so cleanup is `unconfirmed` and a
|
|
50
|
+
* covered job can never succeed there. Support is never advertised off macOS.
|
|
51
|
+
*
|
|
52
|
+
* SIGNAL SEAM. The ONLY raw process signalling in this module is the default
|
|
53
|
+
* `signal` implementation of the macOS inspector (`defaultSignal`). Everything
|
|
54
|
+
* else signals through the injected `ProcessInspector.signal` seam, so tests fake
|
|
55
|
+
* it and no other executor module needs `process.kill`.
|
|
56
|
+
*
|
|
57
|
+
* NEVER RETAINED. The enumeration parser keeps only a pid, a start time, and the
|
|
58
|
+
* tag value. It never keeps command text, arguments, or any other environment
|
|
59
|
+
* value, and nothing here ever logs the token.
|
|
60
|
+
*/
|
|
61
|
+
import { createHash, randomBytes as nodeRandomBytes } from "node:crypto";
|
|
62
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
63
|
+
import path from "node:path";
|
|
64
|
+
import { CLEANUP_RESIDUAL_ENV_SCRUBBING_DAEMON, CLEANUP_RESIDUAL_PLATFORM_BINARY_ENV_HIDDEN, } from "./completion-contract.js";
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Ownership token
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
/** The environment variable carrying the per-job ownership tag. */
|
|
69
|
+
export const OWNERSHIP_TAG_ENV = "BRIDGE_EXECUTOR_JOB_TAG";
|
|
70
|
+
/** A token is exactly 32 random bytes, hex encoded. */
|
|
71
|
+
const TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
72
|
+
const HEX64_RE = /^[0-9a-f]{64}$/;
|
|
73
|
+
/**
|
|
74
|
+
* Create a fresh, unpredictable ownership token (32 random bytes → 64 hex).
|
|
75
|
+
* `randomBytes` is injectable for tests only; production uses node:crypto.
|
|
76
|
+
*/
|
|
77
|
+
export function createOwnershipToken(randomBytes = nodeRandomBytes) {
|
|
78
|
+
return randomBytes(32).toString("hex");
|
|
79
|
+
}
|
|
80
|
+
export function isOwnershipToken(value) {
|
|
81
|
+
return typeof value === "string" && TOKEN_RE.test(value);
|
|
82
|
+
}
|
|
83
|
+
/** SHA-256 hex of the token. The digest may leave the executor; the token never does. */
|
|
84
|
+
export function ownershipTokenDigest(token) {
|
|
85
|
+
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
86
|
+
}
|
|
87
|
+
/** SHA-256 hex of the executor host identity (pass `os.hostname()`). */
|
|
88
|
+
export function hostIdentityDigest(hostname) {
|
|
89
|
+
return createHash("sha256").update(hostname, "utf8").digest("hex");
|
|
90
|
+
}
|
|
91
|
+
/** A copy of `env` with any ambient ownership tag removed. */
|
|
92
|
+
export function stripAmbientOwnershipTag(env) {
|
|
93
|
+
const next = { ...env };
|
|
94
|
+
delete next[OWNERSHIP_TAG_ENV];
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A NEW environment carrying `token` as the ownership tag. Any ambient tag is
|
|
99
|
+
* stripped first, so neither the parent environment nor a job payload can ever
|
|
100
|
+
* choose the value. Call this AFTER the adapter/base environment is built.
|
|
101
|
+
*/
|
|
102
|
+
export function withOwnershipTag(env, token) {
|
|
103
|
+
if (!isOwnershipToken(token)) {
|
|
104
|
+
throw new Error("ownership token must be 64 lowercase hex characters");
|
|
105
|
+
}
|
|
106
|
+
const next = stripAmbientOwnershipTag(env);
|
|
107
|
+
next[OWNERSHIP_TAG_ENV] = token;
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
110
|
+
/** Per-`ps` call bound. The whole sweep is further bounded by its deadline. */
|
|
111
|
+
const PS_TIMEOUT_MS = 10_000;
|
|
112
|
+
/**
|
|
113
|
+
* Output bound. `runCommand` caps its own buffer (10 MiB, which fails the call
|
|
114
|
+
* and so fails enumeration closed); this second cap refuses to parse anything
|
|
115
|
+
* larger if a different runner is injected.
|
|
116
|
+
*/
|
|
117
|
+
const PS_MAX_OUTPUT_CHARS = 16 * 1024 * 1024;
|
|
118
|
+
// `lstart` is fixed-shape: "Wed Sep 25 15:14:00 2026" (day may be space-padded).
|
|
119
|
+
const PS_ROW_RE = /^\s*(\d+)\s+(\d+)\s+([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})(?:\s+([\s\S]*))?$/;
|
|
120
|
+
const LSTART_RE = /^[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4}$/;
|
|
121
|
+
// The tag must stand as its own whitespace-delimited word and be a full token.
|
|
122
|
+
const TAG_RE = new RegExp(`(?:^|\\s)${OWNERSHIP_TAG_ENV}=([0-9a-f]{64})(?=\\s|$)`, "g");
|
|
123
|
+
function normalizeLstart(raw) {
|
|
124
|
+
return raw.trim().replace(/\s+/g, " ");
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Extract only the tag value from the command+environment tail of a `ps -E`
|
|
128
|
+
* row. The environment follows the arguments, so the LAST well-formed
|
|
129
|
+
* occurrence is taken. Nothing else from the tail is returned.
|
|
130
|
+
*/
|
|
131
|
+
export function extractOwnershipTag(tail) {
|
|
132
|
+
let last = null;
|
|
133
|
+
TAG_RE.lastIndex = 0;
|
|
134
|
+
for (let m = TAG_RE.exec(tail); m !== null; m = TAG_RE.exec(tail))
|
|
135
|
+
last = m[1];
|
|
136
|
+
return last;
|
|
137
|
+
}
|
|
138
|
+
/** Parse `ps -E -ww -o pid=,ppid=,lstart=,command=` output into restricted rows. */
|
|
139
|
+
export function parsePsEnvironmentListing(stdout, selfPid) {
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const line of stdout.split("\n")) {
|
|
142
|
+
if (!line.trim())
|
|
143
|
+
continue;
|
|
144
|
+
const m = PS_ROW_RE.exec(line);
|
|
145
|
+
if (!m)
|
|
146
|
+
continue;
|
|
147
|
+
const pid = Number(m[1]);
|
|
148
|
+
const ppid = Number(m[2]);
|
|
149
|
+
if (!Number.isSafeInteger(pid) || pid <= 1 || pid === selfPid)
|
|
150
|
+
continue;
|
|
151
|
+
if (!Number.isSafeInteger(ppid) || ppid < 0)
|
|
152
|
+
continue;
|
|
153
|
+
out.push({ pid, ppid, startedAt: normalizeLstart(m[3]), envTag: extractOwnershipTag(m[4] ?? "") });
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The default raw signal delivery (BAPI-1301). This is the single place the
|
|
159
|
+
* containment sweep touches `process.kill`; every caller goes through
|
|
160
|
+
* `ProcessInspector.signal`.
|
|
161
|
+
*/
|
|
162
|
+
export function defaultSignal(pid, sig) {
|
|
163
|
+
if (!Number.isSafeInteger(pid) || pid <= 1)
|
|
164
|
+
return "denied";
|
|
165
|
+
try {
|
|
166
|
+
process.kill(pid, sig);
|
|
167
|
+
return "sent";
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const code = err.code;
|
|
171
|
+
if (code === "ESRCH")
|
|
172
|
+
return "gone";
|
|
173
|
+
return "denied";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The measured macOS inspector. On any other platform (or without a resolvable
|
|
178
|
+
* uid) enumeration always fails, so cleanup can never be `confirmed` there.
|
|
179
|
+
*/
|
|
180
|
+
export function createMacProcessInspector(options) {
|
|
181
|
+
const platform = options.platform ?? process.platform;
|
|
182
|
+
const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined);
|
|
183
|
+
const signal = options.signal ?? defaultSignal;
|
|
184
|
+
if (platform !== "darwin" || uid === undefined || !Number.isSafeInteger(uid) || uid < 0) {
|
|
185
|
+
return createUnsupportedProcessInspector();
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
async enumerateSameUser() {
|
|
189
|
+
try {
|
|
190
|
+
const res = await options.runCommand("ps", ["-E", "-ww", "-U", String(uid), "-o", "pid=,ppid=,lstart=,command="], { timeoutMs: PS_TIMEOUT_MS });
|
|
191
|
+
if (res.exitCode !== 0 || res.stdout.length > PS_MAX_OUTPUT_CHARS) {
|
|
192
|
+
return { ok: false, reason: "enumeration_failed" };
|
|
193
|
+
}
|
|
194
|
+
const procs = parsePsEnvironmentListing(res.stdout, options.selfPid);
|
|
195
|
+
// `ps` itself is a same-user process, so an empty listing means the
|
|
196
|
+
// enumeration did not really run.
|
|
197
|
+
if (procs.length === 0)
|
|
198
|
+
return { ok: false, reason: "enumeration_failed" };
|
|
199
|
+
return { ok: true, procs };
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return { ok: false, reason: "enumeration_failed" };
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
async identity(pid) {
|
|
206
|
+
if (!Number.isSafeInteger(pid) || pid <= 1 || pid === options.selfPid)
|
|
207
|
+
return null;
|
|
208
|
+
try {
|
|
209
|
+
const res = await options.runCommand("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
210
|
+
timeoutMs: PS_TIMEOUT_MS,
|
|
211
|
+
});
|
|
212
|
+
if (res.exitCode !== 0)
|
|
213
|
+
return null;
|
|
214
|
+
const startedAt = normalizeLstart(res.stdout);
|
|
215
|
+
return LSTART_RE.test(startedAt) ? { startedAt } : null;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
signal(pid, sig) {
|
|
222
|
+
if (pid === options.selfPid)
|
|
223
|
+
return "denied";
|
|
224
|
+
return signal(pid, sig);
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** An inspector that never claims support: enumeration fails, nothing is signalled. */
|
|
229
|
+
export function createUnsupportedProcessInspector() {
|
|
230
|
+
return {
|
|
231
|
+
enumerateSameUser: async () => ({ ok: false, reason: "enumeration_failed" }),
|
|
232
|
+
identity: async () => null,
|
|
233
|
+
signal: () => "denied",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Cleanup verdicts
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
/**
|
|
240
|
+
* The residuals named on EVERY verdict (BAPI-1301):
|
|
241
|
+
* - `environment_scrubbing_daemon`: a daemon that detaches AND clears its
|
|
242
|
+
* environment carries no tag and is invisible to the sweep.
|
|
243
|
+
* - `platform_binary_environment_hidden`: macOS hides a platform (Apple-signed)
|
|
244
|
+
* binary's environment from `ps -E`, so `/bin/bash`, `/bin/zsh` or
|
|
245
|
+
* `/bin/sleep` shows no tag even when it inherited one. Tracked descendants
|
|
246
|
+
* (see `createDescendantTracker`) cover the common case; a platform-binary
|
|
247
|
+
* process that detaches from the tracked tree between samples (reparented to
|
|
248
|
+
* launchd) is not attributable.
|
|
249
|
+
* The server only requires the first token to be present; both are always sent.
|
|
250
|
+
*/
|
|
251
|
+
export const CLEANUP_RESIDUALS_NOT_COVERED = [
|
|
252
|
+
CLEANUP_RESIDUAL_ENV_SCRUBBING_DAEMON,
|
|
253
|
+
CLEANUP_RESIDUAL_PLATFORM_BINARY_ENV_HIDDEN,
|
|
254
|
+
];
|
|
255
|
+
/** Build a closed cleanup verdict. Every verdict names both accepted residuals. */
|
|
256
|
+
export function buildCleanupVerdict(verdict, survivors, reasons) {
|
|
257
|
+
return {
|
|
258
|
+
verdict,
|
|
259
|
+
method: "env_tag_sweep",
|
|
260
|
+
survivors: Math.max(0, Math.floor(survivors)),
|
|
261
|
+
reasons: [...new Set(reasons)],
|
|
262
|
+
residual_not_covered: [...CLEANUP_RESIDUALS_NOT_COVERED],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/** The verdict for a covered attempt whose sweep never ran (e.g. executor interruption). */
|
|
266
|
+
export function unconfirmedCleanup(reason) {
|
|
267
|
+
return buildCleanupVerdict("unconfirmed", 0, [reason]);
|
|
268
|
+
}
|
|
269
|
+
/** Hard cap on distinct processes tracked by one sweep or one descendant tracker. */
|
|
270
|
+
const MAX_TRACKED_PROCESSES = 1024;
|
|
271
|
+
/** Confirmation enumerations after SIGKILL (a killed process can take a moment to leave). */
|
|
272
|
+
const CONFIRM_ATTEMPTS = 3;
|
|
273
|
+
const keyOf = (p) => `${p.pid}|${p.startedAt}`;
|
|
274
|
+
/**
|
|
275
|
+
* Tracks the worker's process tree by parent pid (BAPI-1301). This is what
|
|
276
|
+
* attributes platform binaries whose environment `ps -E` hides: a child's ppid
|
|
277
|
+
* is a kernel fact, so a process whose parent is a verified member (same pid AND
|
|
278
|
+
* start time in the same listing) is ours. A process that has already
|
|
279
|
+
* reparented to launchd before a sample cannot be attributed this way — that is
|
|
280
|
+
* the `platform_binary_environment_hidden` residual. The integrator samples
|
|
281
|
+
* about every 2 s while the worker runs and once right before the sweep.
|
|
282
|
+
*/
|
|
283
|
+
export function createDescendantTracker(options) {
|
|
284
|
+
const { inspector, rootPid } = options;
|
|
285
|
+
let rootStartedAt = options.rootStartedAt ?? null;
|
|
286
|
+
let rootExited = false;
|
|
287
|
+
let overflow = false;
|
|
288
|
+
const members = new Map();
|
|
289
|
+
return {
|
|
290
|
+
async sample() {
|
|
291
|
+
try {
|
|
292
|
+
const listing = await inspector.enumerateSameUser();
|
|
293
|
+
if (!listing.ok)
|
|
294
|
+
return;
|
|
295
|
+
const childrenOf = new Map();
|
|
296
|
+
const byPid = new Map();
|
|
297
|
+
for (const proc of listing.procs) {
|
|
298
|
+
byPid.set(proc.pid, proc);
|
|
299
|
+
const list = childrenOf.get(proc.ppid);
|
|
300
|
+
if (list)
|
|
301
|
+
list.push(proc);
|
|
302
|
+
else
|
|
303
|
+
childrenOf.set(proc.ppid, [proc]);
|
|
304
|
+
}
|
|
305
|
+
// Seeds: the root (when its identity matches) and every tracked member
|
|
306
|
+
// still live with the SAME start time. A reused pid is not a seed.
|
|
307
|
+
const frontier = [];
|
|
308
|
+
const rootNow = byPid.get(rootPid);
|
|
309
|
+
if (rootNow) {
|
|
310
|
+
if (rootStartedAt === null)
|
|
311
|
+
rootStartedAt = rootNow.startedAt;
|
|
312
|
+
if (rootNow.startedAt === rootStartedAt)
|
|
313
|
+
frontier.push(rootPid);
|
|
314
|
+
}
|
|
315
|
+
for (const member of members.values()) {
|
|
316
|
+
const live = byPid.get(member.pid);
|
|
317
|
+
if (live && live.startedAt === member.startedAt)
|
|
318
|
+
frontier.push(member.pid);
|
|
319
|
+
}
|
|
320
|
+
const visited = new Set(frontier);
|
|
321
|
+
while (frontier.length > 0) {
|
|
322
|
+
const parent = frontier.pop();
|
|
323
|
+
for (const child of childrenOf.get(parent) ?? []) {
|
|
324
|
+
if (visited.has(child.pid))
|
|
325
|
+
continue;
|
|
326
|
+
visited.add(child.pid);
|
|
327
|
+
const key = keyOf(child);
|
|
328
|
+
if (!members.has(key)) {
|
|
329
|
+
if (members.size >= MAX_TRACKED_PROCESSES) {
|
|
330
|
+
overflow = true;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
members.set(key, { pid: child.pid, startedAt: child.startedAt });
|
|
334
|
+
}
|
|
335
|
+
frontier.push(child.pid);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
/* sampling is best-effort; the sweep's own verdict stays authoritative */
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
owned() {
|
|
344
|
+
const out = [...members.values()].filter((m) => !(m.pid === rootPid && m.startedAt === rootStartedAt));
|
|
345
|
+
if (rootExited && rootStartedAt !== null)
|
|
346
|
+
out.push({ pid: rootPid, startedAt: rootStartedAt });
|
|
347
|
+
return out;
|
|
348
|
+
},
|
|
349
|
+
overflowed() {
|
|
350
|
+
return overflow;
|
|
351
|
+
},
|
|
352
|
+
markRootExited() {
|
|
353
|
+
rootExited = true;
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Which processes in one listing are owned. A process is owned when:
|
|
359
|
+
* (a) its environment tag exactly matches the token, OR
|
|
360
|
+
* (b) its (pid, startedAt) exactly matches a tracked descendant, OR
|
|
361
|
+
* (c) its parent (same listing) is an owned process — a frozen one or one
|
|
362
|
+
* owned by (a)/(b) — which catches children spawned during the sweep.
|
|
363
|
+
* Nothing else is ever owned.
|
|
364
|
+
*/
|
|
365
|
+
function ownedInListing(procs, token, tracked, frozen) {
|
|
366
|
+
const owned = new Map();
|
|
367
|
+
const childrenOf = new Map();
|
|
368
|
+
for (const proc of procs) {
|
|
369
|
+
const list = childrenOf.get(proc.ppid);
|
|
370
|
+
if (list)
|
|
371
|
+
list.push(proc);
|
|
372
|
+
else
|
|
373
|
+
childrenOf.set(proc.ppid, [proc]);
|
|
374
|
+
const key = keyOf(proc);
|
|
375
|
+
if (proc.envTag === token || tracked.has(key) || frozen.has(key))
|
|
376
|
+
owned.set(proc.pid, proc);
|
|
377
|
+
}
|
|
378
|
+
const frontier = [...owned.keys()];
|
|
379
|
+
while (frontier.length > 0) {
|
|
380
|
+
const parent = frontier.pop();
|
|
381
|
+
for (const child of childrenOf.get(parent) ?? []) {
|
|
382
|
+
if (owned.has(child.pid))
|
|
383
|
+
continue;
|
|
384
|
+
owned.set(child.pid, child);
|
|
385
|
+
frontier.push(child.pid);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return [...owned.values()];
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The bounded enumerate → freeze → kill → confirm sweep (BAPI-1301 Step 7).
|
|
392
|
+
* Identity (pid + start time) is re-verified right before every signal; a
|
|
393
|
+
* reused pid is never signalled. Never throws; any unexpected error yields
|
|
394
|
+
* `unconfirmed`.
|
|
395
|
+
*/
|
|
396
|
+
export async function runOwnedProcessSweep(options) {
|
|
397
|
+
const { token, inspector, deps, deadlineAtMs } = options;
|
|
398
|
+
const quietRounds = Math.max(1, options.quietRounds ?? 3);
|
|
399
|
+
const maxRounds = Math.max(quietRounds, options.maxRounds ?? 200);
|
|
400
|
+
const roundDelayMs = Math.max(0, options.roundDelayMs ?? 15);
|
|
401
|
+
if (!isOwnershipToken(token))
|
|
402
|
+
return unconfirmedCleanup("ownership_unverifiable");
|
|
403
|
+
const reasons = new Set();
|
|
404
|
+
if (options.trackerOverflowed)
|
|
405
|
+
reasons.add("ownership_unverifiable");
|
|
406
|
+
const tracked = new Set((options.trackedDescendants ?? []).slice(0, MAX_TRACKED_PROCESSES).map((p) => keyOf(p)));
|
|
407
|
+
const seen = new Map();
|
|
408
|
+
const frozen = new Map();
|
|
409
|
+
let enumerationFailed = false;
|
|
410
|
+
let deadlineHit = false;
|
|
411
|
+
try {
|
|
412
|
+
// Freeze until a fixed point: `quietRounds` consecutive rounds find nothing new.
|
|
413
|
+
let quiet = 0;
|
|
414
|
+
let rounds = 0;
|
|
415
|
+
while (quiet < quietRounds) {
|
|
416
|
+
if (rounds >= maxRounds || deps.now() >= deadlineAtMs) {
|
|
417
|
+
deadlineHit = true;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
rounds += 1;
|
|
421
|
+
const listing = await inspector.enumerateSameUser();
|
|
422
|
+
if (!listing.ok) {
|
|
423
|
+
enumerationFailed = true;
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
let sawNew = false;
|
|
427
|
+
for (const proc of ownedInListing(listing.procs, token, tracked, frozen)) {
|
|
428
|
+
const key = keyOf(proc);
|
|
429
|
+
if (frozen.has(key))
|
|
430
|
+
continue;
|
|
431
|
+
if (!seen.has(key)) {
|
|
432
|
+
if (seen.size >= MAX_TRACKED_PROCESSES) {
|
|
433
|
+
deadlineHit = true;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
seen.set(key, { pid: proc.pid, startedAt: proc.startedAt });
|
|
437
|
+
sawNew = true;
|
|
438
|
+
}
|
|
439
|
+
// Re-verify identity immediately before the signal. A changed start
|
|
440
|
+
// time means the pid was reused: that process is not ours.
|
|
441
|
+
const live = await inspector.identity(proc.pid);
|
|
442
|
+
if (live === null)
|
|
443
|
+
continue; // gone (or unreadable; the confirm pass decides)
|
|
444
|
+
if (live.startedAt !== proc.startedAt)
|
|
445
|
+
continue;
|
|
446
|
+
const result = inspector.signal(proc.pid, "SIGSTOP");
|
|
447
|
+
if (result === "sent")
|
|
448
|
+
frozen.set(key, { pid: proc.pid, startedAt: proc.startedAt });
|
|
449
|
+
else if (result === "denied")
|
|
450
|
+
reasons.add("ownership_unverifiable");
|
|
451
|
+
}
|
|
452
|
+
quiet = sawNew ? 0 : quiet + 1;
|
|
453
|
+
if (quiet < quietRounds)
|
|
454
|
+
await deps.sleep(roundDelayMs);
|
|
455
|
+
}
|
|
456
|
+
// Kill every frozen process, re-verifying identity once more. This runs even
|
|
457
|
+
// after a deadline or enumeration failure so nothing is left merely frozen.
|
|
458
|
+
for (const proc of frozen.values()) {
|
|
459
|
+
const live = await inspector.identity(proc.pid);
|
|
460
|
+
if (live === null || live.startedAt !== proc.startedAt)
|
|
461
|
+
continue;
|
|
462
|
+
const result = inspector.signal(proc.pid, "SIGKILL");
|
|
463
|
+
if (result === "denied")
|
|
464
|
+
reasons.add("ownership_unverifiable");
|
|
465
|
+
}
|
|
466
|
+
// Confirm nothing owned remains.
|
|
467
|
+
if (!enumerationFailed) {
|
|
468
|
+
let remaining = -1;
|
|
469
|
+
for (let attempt = 0; attempt < CONFIRM_ATTEMPTS; attempt += 1) {
|
|
470
|
+
const listing = await inspector.enumerateSameUser();
|
|
471
|
+
if (!listing.ok) {
|
|
472
|
+
enumerationFailed = true;
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
const still = ownedInListing(listing.procs, token, tracked, frozen);
|
|
476
|
+
for (const p of still) {
|
|
477
|
+
const key = keyOf(p);
|
|
478
|
+
if (!seen.has(key) && seen.size < MAX_TRACKED_PROCESSES) {
|
|
479
|
+
seen.set(key, { pid: p.pid, startedAt: p.startedAt });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
remaining = still.length;
|
|
483
|
+
if (remaining === 0)
|
|
484
|
+
break;
|
|
485
|
+
await deps.sleep(roundDelayMs);
|
|
486
|
+
}
|
|
487
|
+
// An owned process still present after the sweep is one we could not
|
|
488
|
+
// verify or control.
|
|
489
|
+
if (remaining > 0)
|
|
490
|
+
reasons.add("ownership_unverifiable");
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
enumerationFailed = true;
|
|
495
|
+
}
|
|
496
|
+
const found = seen.size;
|
|
497
|
+
if (found > 0)
|
|
498
|
+
reasons.add("survivors_detected");
|
|
499
|
+
if (enumerationFailed)
|
|
500
|
+
reasons.add("enumeration_failed");
|
|
501
|
+
if (deadlineHit)
|
|
502
|
+
reasons.add("sweep_deadline_exhausted");
|
|
503
|
+
if (found > 0)
|
|
504
|
+
return buildCleanupVerdict("failed", found, reasons);
|
|
505
|
+
if (enumerationFailed || deadlineHit || reasons.size > 0) {
|
|
506
|
+
return buildCleanupVerdict("unconfirmed", 0, reasons);
|
|
507
|
+
}
|
|
508
|
+
return buildCleanupVerdict("confirmed", 0, []);
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Inspect for survivors of an interrupted covered job.
|
|
512
|
+
*
|
|
513
|
+
* With only a digest, each observed tag is hashed and compared; matching
|
|
514
|
+
* processes are REPORTED, never signalled — a digest alone is not enough
|
|
515
|
+
* authority to kill. When the matching token is available locally, ownership is
|
|
516
|
+
* verified exactly and the full sweep runs. Never throws.
|
|
517
|
+
*/
|
|
518
|
+
export async function inspectInterruptedOwnership(options) {
|
|
519
|
+
const { tokenDigest, inspector, deps, deadlineAtMs } = options;
|
|
520
|
+
if (!HEX64_RE.test(tokenDigest))
|
|
521
|
+
return { outcome: "unconfirmed", survivors: 0 };
|
|
522
|
+
if (isOwnershipToken(options.token) && ownershipTokenDigest(options.token) === tokenDigest) {
|
|
523
|
+
const verdict = await runOwnedProcessSweep({ token: options.token, inspector, deps, deadlineAtMs });
|
|
524
|
+
if (verdict.verdict === "confirmed")
|
|
525
|
+
return { outcome: "no_tagged_survivors", survivors: 0 };
|
|
526
|
+
if (verdict.survivors > 0)
|
|
527
|
+
return { outcome: "survivors", survivors: verdict.survivors };
|
|
528
|
+
if (verdict.reasons.includes("enumeration_failed"))
|
|
529
|
+
return { outcome: "enumeration_failed", survivors: 0 };
|
|
530
|
+
return { outcome: "unconfirmed", survivors: 0 };
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
if (deps.now() >= deadlineAtMs)
|
|
534
|
+
return { outcome: "unconfirmed", survivors: 0 };
|
|
535
|
+
const listing = await inspector.enumerateSameUser();
|
|
536
|
+
if (!listing.ok)
|
|
537
|
+
return { outcome: "enumeration_failed", survivors: 0 };
|
|
538
|
+
let survivors = 0;
|
|
539
|
+
let unverified = false;
|
|
540
|
+
for (const proc of listing.procs) {
|
|
541
|
+
if (proc.envTag === null || ownershipTokenDigest(proc.envTag) !== tokenDigest)
|
|
542
|
+
continue;
|
|
543
|
+
if (deps.now() >= deadlineAtMs) {
|
|
544
|
+
unverified = true;
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
const live = await inspector.identity(proc.pid);
|
|
548
|
+
if (live === null)
|
|
549
|
+
continue; // exited since enumeration
|
|
550
|
+
if (live.startedAt !== proc.startedAt) {
|
|
551
|
+
unverified = true; // pid reused mid-inspection; cannot attribute either way
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
survivors += 1;
|
|
555
|
+
}
|
|
556
|
+
if (survivors > 0)
|
|
557
|
+
return { outcome: "survivors", survivors };
|
|
558
|
+
if (unverified)
|
|
559
|
+
return { outcome: "unconfirmed", survivors: 0 };
|
|
560
|
+
return { outcome: "no_tagged_survivors", survivors: 0 };
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return { outcome: "unconfirmed", survivors: 0 };
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
export function createDefaultOwnershipRecordFs() {
|
|
567
|
+
return {
|
|
568
|
+
mkdir: (dirPath, options) => mkdir(dirPath, options),
|
|
569
|
+
writeFile: (filePath, data, options) => writeFile(filePath, data, { encoding: "utf-8", mode: options.mode }),
|
|
570
|
+
readFile: (filePath) => readFile(filePath, "utf-8"),
|
|
571
|
+
readdir: (dirPath) => readdir(dirPath),
|
|
572
|
+
removeFile: (filePath) => rm(filePath, { force: true }),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
const isSafePid = (v) => typeof v === "number" && Number.isSafeInteger(v) && v > 1;
|
|
576
|
+
const OWNED_JOBS_DIR = "owned-jobs";
|
|
577
|
+
const SAFE_JOB_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
578
|
+
/** Ownership records are tiny; a larger file is not ours and is not parsed. */
|
|
579
|
+
const MAX_RECORD_CHARS = 4096;
|
|
580
|
+
/** Bound on records inspected in one startup pass. */
|
|
581
|
+
const MAX_RECORDS = 256;
|
|
582
|
+
function safeJobId(jobId) {
|
|
583
|
+
const s = String(jobId);
|
|
584
|
+
return SAFE_JOB_ID_RE.test(s) ? s : null;
|
|
585
|
+
}
|
|
586
|
+
export function ownershipRecordDir(stateDir) {
|
|
587
|
+
return path.join(stateDir, OWNED_JOBS_DIR);
|
|
588
|
+
}
|
|
589
|
+
export async function writeOwnershipRecord(stateDir, record, fsDeps) {
|
|
590
|
+
const id = safeJobId(record.jobId);
|
|
591
|
+
if (id === null)
|
|
592
|
+
throw new Error("ownership record job id is not a safe identifier");
|
|
593
|
+
if (!isOwnershipToken(record.token))
|
|
594
|
+
throw new Error("ownership record token is malformed");
|
|
595
|
+
if (!HEX64_RE.test(record.hostDigest))
|
|
596
|
+
throw new Error("ownership record host digest is malformed");
|
|
597
|
+
if (record.executorPid !== undefined && !isSafePid(record.executorPid)) {
|
|
598
|
+
throw new Error("ownership record executor pid is malformed");
|
|
599
|
+
}
|
|
600
|
+
const dir = ownershipRecordDir(stateDir);
|
|
601
|
+
await fsDeps.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
602
|
+
const body = {
|
|
603
|
+
jobId: record.jobId,
|
|
604
|
+
repoName: record.repoName,
|
|
605
|
+
token: record.token,
|
|
606
|
+
hostDigest: record.hostDigest,
|
|
607
|
+
createdAt: record.createdAt,
|
|
608
|
+
...(record.executorPid !== undefined ? { executorPid: record.executorPid } : {}),
|
|
609
|
+
};
|
|
610
|
+
await fsDeps.writeFile(path.join(dir, `${id}.json`), JSON.stringify(body), { mode: 0o600 });
|
|
611
|
+
}
|
|
612
|
+
function parseOwnershipRecord(raw) {
|
|
613
|
+
if (raw.length > MAX_RECORD_CHARS)
|
|
614
|
+
return null;
|
|
615
|
+
try {
|
|
616
|
+
const v = JSON.parse(raw);
|
|
617
|
+
if (!v || typeof v !== "object")
|
|
618
|
+
return null;
|
|
619
|
+
const jobId = v.jobId;
|
|
620
|
+
if (typeof jobId !== "number" && typeof jobId !== "string")
|
|
621
|
+
return null;
|
|
622
|
+
if (safeJobId(jobId) === null)
|
|
623
|
+
return null;
|
|
624
|
+
if (typeof v.repoName !== "string" || typeof v.createdAt !== "string")
|
|
625
|
+
return null;
|
|
626
|
+
if (!isOwnershipToken(v.token))
|
|
627
|
+
return null;
|
|
628
|
+
if (typeof v.hostDigest !== "string" || !HEX64_RE.test(v.hostDigest))
|
|
629
|
+
return null;
|
|
630
|
+
if (v.executorPid !== undefined && !isSafePid(v.executorPid))
|
|
631
|
+
return null;
|
|
632
|
+
return {
|
|
633
|
+
jobId,
|
|
634
|
+
repoName: v.repoName,
|
|
635
|
+
token: v.token,
|
|
636
|
+
hostDigest: v.hostDigest,
|
|
637
|
+
createdAt: v.createdAt,
|
|
638
|
+
...(v.executorPid !== undefined ? { executorPid: v.executorPid } : {}),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/** List ownership records. An absent directory is an empty list. Never throws. */
|
|
646
|
+
export async function listOwnershipRecords(stateDir, fsDeps) {
|
|
647
|
+
const dir = ownershipRecordDir(stateDir);
|
|
648
|
+
let names;
|
|
649
|
+
try {
|
|
650
|
+
names = await fsDeps.readdir(dir);
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
return [];
|
|
654
|
+
}
|
|
655
|
+
const out = [];
|
|
656
|
+
for (const name of names.slice().sort()) {
|
|
657
|
+
if (out.length >= MAX_RECORDS)
|
|
658
|
+
break;
|
|
659
|
+
if (!name.endsWith(".json"))
|
|
660
|
+
continue;
|
|
661
|
+
const fileJobId = name.slice(0, -".json".length);
|
|
662
|
+
if (!SAFE_JOB_ID_RE.test(fileJobId))
|
|
663
|
+
continue;
|
|
664
|
+
let record = null;
|
|
665
|
+
try {
|
|
666
|
+
record = parseOwnershipRecord(await fsDeps.readFile(path.join(dir, name)));
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
record = null;
|
|
670
|
+
}
|
|
671
|
+
if (record !== null && String(record.jobId) !== fileJobId)
|
|
672
|
+
record = null;
|
|
673
|
+
out.push({ fileJobId, record });
|
|
674
|
+
}
|
|
675
|
+
return out;
|
|
676
|
+
}
|
|
677
|
+
/** Remove one ownership record, tolerating absence. Never throws. */
|
|
678
|
+
export async function removeOwnershipRecord(stateDir, jobId, fsDeps) {
|
|
679
|
+
const id = safeJobId(jobId);
|
|
680
|
+
if (id === null)
|
|
681
|
+
return;
|
|
682
|
+
try {
|
|
683
|
+
await fsDeps.removeFile(path.join(ownershipRecordDir(stateDir), `${id}.json`));
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
/* absent or unremovable: tolerated */
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/** True unless the owner pid is verifiably gone. An unreadable answer counts as alive (never sweep on doubt). */
|
|
690
|
+
async function ownerMayBeAlive(inspector, pid) {
|
|
691
|
+
try {
|
|
692
|
+
return (await inspector.identity(pid)) !== null;
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Executor startup recovery. Each leftover record means a covered job was
|
|
700
|
+
* interrupted before its own sweep finished. The record holds the exact token,
|
|
701
|
+
* so ownership is verified and the full sweep runs. Only the job id and the
|
|
702
|
+
* verdict are logged — never the token. A malformed record cannot prove
|
|
703
|
+
* ownership: it is reported `unconfirmed / ownership_unverifiable` and removed
|
|
704
|
+
* without signalling anything. Never throws.
|
|
705
|
+
*/
|
|
706
|
+
export async function recoverInterruptedOwnership(options) {
|
|
707
|
+
const { stateDir, inspector, deps, fsDeps, deadlineMs } = options;
|
|
708
|
+
const log = deps.log ?? (() => { });
|
|
709
|
+
const results = [];
|
|
710
|
+
const records = await listOwnershipRecords(stateDir, fsDeps);
|
|
711
|
+
for (const { fileJobId, record } of records) {
|
|
712
|
+
let verdict;
|
|
713
|
+
try {
|
|
714
|
+
if (record === null) {
|
|
715
|
+
verdict = unconfirmedCleanup("ownership_unverifiable");
|
|
716
|
+
}
|
|
717
|
+
else if (options.hostDigest !== undefined && record.hostDigest !== options.hostDigest) {
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
else if (record.executorPid !== undefined &&
|
|
721
|
+
record.executorPid !== options.selfPid &&
|
|
722
|
+
(await ownerMayBeAlive(inspector, record.executorPid))) {
|
|
723
|
+
// Another executor on this host still owns this job: its worker is
|
|
724
|
+
// live work, not an interrupted leftover. Leave the record alone.
|
|
725
|
+
try {
|
|
726
|
+
log(`[executor] BAPI-1301 ownership recovery job=${fileJobId} skipped: owning executor is still running`);
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
/* logging is best-effort */
|
|
730
|
+
}
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
verdict = await runOwnedProcessSweep({
|
|
735
|
+
token: record.token,
|
|
736
|
+
inspector,
|
|
737
|
+
deps,
|
|
738
|
+
deadlineAtMs: deps.now() + Math.max(0, deadlineMs),
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
verdict = unconfirmedCleanup("enumeration_failed");
|
|
744
|
+
}
|
|
745
|
+
try {
|
|
746
|
+
log(`[executor] BAPI-1301 ownership recovery job=${fileJobId} cleanup=${verdict.verdict} ` +
|
|
747
|
+
`survivors=${verdict.survivors} reasons=${verdict.reasons.join(",") || "none"}`);
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
/* logging is best-effort */
|
|
751
|
+
}
|
|
752
|
+
await removeOwnershipRecord(stateDir, fileJobId, fsDeps);
|
|
753
|
+
results.push({ jobId: fileJobId, verdict });
|
|
754
|
+
}
|
|
755
|
+
return results;
|
|
756
|
+
}
|