@pentoshi/clai 3.8.46 → 3.8.47
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/dist/agent/compaction-summary.js +1 -1
- package/dist/agent/compaction-summary.js.map +1 -1
- package/dist/agent/plan-tool.js +110 -14
- package/dist/agent/plan-tool.js.map +1 -1
- package/dist/agent/reliability-policy.d.ts +4 -3
- package/dist/agent/reliability-policy.js +4 -3
- package/dist/agent/reliability-policy.js.map +1 -1
- package/dist/agent/responder-context.d.ts +10 -0
- package/dist/agent/responder-context.js +50 -0
- package/dist/agent/responder-context.js.map +1 -0
- package/dist/agent/runner.js +160 -12
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/task-plan.d.ts +4 -0
- package/dist/agent/task-plan.js.map +1 -1
- package/dist/app/adapters/current-jobs-adapter.js +7 -0
- package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
- package/dist/app/controllers/job-controller.d.ts +9 -6
- package/dist/app/controllers/job-controller.js +21 -0
- package/dist/app/controllers/job-controller.js.map +1 -1
- package/dist/app/controllers/session-controller.d.ts +14 -33
- package/dist/app/controllers/session-controller.js +71 -132
- package/dist/app/controllers/session-controller.js.map +1 -1
- package/dist/app/controllers/session-prompt-queue.d.ts +34 -0
- package/dist/app/controllers/session-prompt-queue.js +141 -0
- package/dist/app/controllers/session-prompt-queue.js.map +1 -0
- package/dist/app/controllers/session-responder.d.ts +49 -0
- package/dist/app/controllers/session-responder.js +311 -0
- package/dist/app/controllers/session-responder.js.map +1 -0
- package/dist/app/ports/jobs-port.d.ts +10 -7
- package/dist/prompts/embedded.js +1 -1
- package/dist/prompts/embedded.js.map +1 -1
- package/dist/prompts/system.agent.md +14 -9
- package/dist/repl.js +24 -14
- package/dist/repl.js.map +1 -1
- package/dist/safety/engagement-policy.js +39 -7
- package/dist/safety/engagement-policy.js.map +1 -1
- package/dist/store/config.js +1 -1
- package/dist/store/config.js.map +1 -1
- package/dist/store/plan.d.ts +11 -0
- package/dist/store/plan.js +66 -3
- package/dist/store/plan.js.map +1 -1
- package/dist/tools/definitions.js +35 -4
- package/dist/tools/definitions.js.map +1 -1
- package/dist/tools/jobs.d.ts +72 -3
- package/dist/tools/jobs.js +743 -121
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/pentest-workflows.js +80 -4
- package/dist/tools/pentest-workflows.js.map +1 -1
- package/dist/tools/registry.js +60 -19
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/tool-types.d.ts +5 -0
- package/dist/tools/wordlists.js +74 -87
- package/dist/tools/wordlists.js.map +1 -1
- package/dist/tui-v2/actions/action-id.js +1 -1
- package/dist/tui-v2/actions/action-id.js.map +1 -1
- package/dist/tui-v2/actions/keymap.js +1 -1
- package/dist/tui-v2/actions/keymap.js.map +1 -1
- package/dist/tui-v2/app/App.js +82 -12
- package/dist/tui-v2/app/App.js.map +1 -1
- package/dist/tui-v2/app/commands/session-commands.js +0 -5
- package/dist/tui-v2/app/commands/session-commands.js.map +1 -1
- package/dist/tui-v2/bootstrap/composition-root.js +2 -0
- package/dist/tui-v2/bootstrap/composition-root.js.map +1 -1
- package/dist/tui-v2/components/jobs/jobs-panel.d.ts +12 -0
- package/dist/tui-v2/components/jobs/jobs-panel.js +184 -14
- package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
- package/dist/tui-v2/components/plan/plan-view.js +7 -3
- package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
- package/dist/tui-v2/components/status/status-line.js +2 -3
- package/dist/tui-v2/components/status/status-line.js.map +1 -1
- package/dist/tui-v2/components/toast/toast-host.js +16 -1
- package/dist/tui-v2/components/toast/toast-host.js.map +1 -1
- package/dist/tui-v2/components/transcript/tool-card.d.ts +3 -0
- package/dist/tui-v2/components/transcript/tool-card.js +12 -2
- package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
- package/dist/tui-v2/components/transcript/transcript-row.js +1 -1
- package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
- package/dist/tui-v2/composer/composer-editor.d.ts +6 -0
- package/dist/tui-v2/composer/composer-editor.js +10 -17
- package/dist/tui-v2/composer/composer-editor.js.map +1 -1
- package/dist/tui-v2/composer/composer-image-paste.d.ts +1 -0
- package/dist/tui-v2/composer/composer-image-paste.js +22 -1
- package/dist/tui-v2/composer/composer-image-paste.js.map +1 -1
- package/dist/tui-v2/state/transcript-store.d.ts +4 -0
- package/dist/tui-v2/state/transcript-store.js +13 -0
- package/dist/tui-v2/state/transcript-store.js.map +1 -1
- package/dist/ui/intro-card.js +3 -3
- package/dist/ui/intro-card.js.map +1 -1
- package/dist/ui/mentions.d.ts +11 -13
- package/dist/ui/mentions.js +67 -26
- package/dist/ui/mentions.js.map +1 -1
- package/dist/ui/plan-pane.js +6 -2
- package/dist/ui/plan-pane.js.map +1 -1
- package/dist/version.generated.d.ts +2 -2
- package/dist/version.generated.js +2 -2
- package/package.json +1 -1
package/dist/tools/jobs.js
CHANGED
|
@@ -3,10 +3,24 @@ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, wri
|
|
|
3
3
|
import { mkdir, open, stat } from "node:fs/promises";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { StringDecoder } from "node:string_decoder";
|
|
6
7
|
import { redactSecrets } from "../llm/provider.js";
|
|
7
8
|
import { safeCwd } from "../os/cwd.js";
|
|
8
9
|
import { getJobsDir } from "../store/paths.js";
|
|
9
10
|
import { resolveShell } from "./shell.js";
|
|
11
|
+
export function formatJobElapsed(job, now = Date.now()) {
|
|
12
|
+
const startedAt = Date.parse(job.startedAt);
|
|
13
|
+
const endedAt = job.endedAt ? Date.parse(job.endedAt) : now;
|
|
14
|
+
if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt))
|
|
15
|
+
return "unknown";
|
|
16
|
+
const seconds = Math.max(0, Math.floor((endedAt - startedAt) / 1000));
|
|
17
|
+
if (seconds < 1)
|
|
18
|
+
return "<1s";
|
|
19
|
+
if (seconds < 60)
|
|
20
|
+
return `${seconds}s`;
|
|
21
|
+
const minutes = Math.floor(seconds / 60);
|
|
22
|
+
return `${minutes}m${seconds % 60}s`;
|
|
23
|
+
}
|
|
10
24
|
function displayArg(value) {
|
|
11
25
|
return /^[A-Za-z0-9_./:=@+-]+$/.test(value)
|
|
12
26
|
? value
|
|
@@ -18,15 +32,18 @@ function commandDisplay(command) {
|
|
|
18
32
|
return command.display ?? [command.command, ...command.argv].map(displayArg).join(" ");
|
|
19
33
|
}
|
|
20
34
|
const PER_FILE_BYTES = 1024 * 1024;
|
|
21
|
-
const
|
|
35
|
+
const MAX_STREAM_BYTES = 16 * 1024 * 1024;
|
|
22
36
|
const DEFAULT_TAIL_BYTES = 8_000;
|
|
23
37
|
const REGISTRY_FILE = "registry-v1.json";
|
|
38
|
+
const TRANSIENT_V2_REGISTRY_FILE = "registry-v2.json";
|
|
24
39
|
/** Cap durable terminal jobs kept on disk/in memory (per process). */
|
|
25
40
|
const MAX_DURABLE_TERMINAL_JOBS = 80;
|
|
26
41
|
/** Drop terminal durable jobs older than this on load/list. */
|
|
27
42
|
const TERMINAL_JOB_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
|
28
43
|
/** Max lines shell.jobs returns to the model (running first, then recent). */
|
|
29
44
|
const LIST_JOBS_MAX_LINES = 40;
|
|
45
|
+
/** Coalesce window for chatty stdout/stderr progress persistence + UI events. */
|
|
46
|
+
const PROGRESS_FLUSH_MS = 250;
|
|
30
47
|
/**
|
|
31
48
|
* Number of trailing bytes in `buf` that form an incomplete multi-byte UTF-8
|
|
32
49
|
* sequence (a lead byte whose continuation bytes were cut off by the read
|
|
@@ -64,7 +81,13 @@ function processIdentity(pid) {
|
|
|
64
81
|
if (!pid || process.platform === "win32")
|
|
65
82
|
return undefined;
|
|
66
83
|
try {
|
|
67
|
-
|
|
84
|
+
// Start time only: it is invariant for a pid's lifetime and changes on
|
|
85
|
+
// reuse, so it is a sufficient pid-reuse guard. The command line is
|
|
86
|
+
// deliberately excluded — `sh -c "cmd"` execs into `cmd`, mutating
|
|
87
|
+
// `ps command=` mid-run, which made a live job fail its OWN identity check
|
|
88
|
+
// (stop refused with "process identity no longer matches", liveness falsely
|
|
89
|
+
// marked lost, and double-Esc cancel unable to kill the process).
|
|
90
|
+
const value = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
68
91
|
encoding: "utf8",
|
|
69
92
|
timeout: 2_000,
|
|
70
93
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -96,19 +119,26 @@ function looksLikeEphemeralToolTrack(job) {
|
|
|
96
119
|
class RotatingRedactedWriter {
|
|
97
120
|
receipt;
|
|
98
121
|
stream;
|
|
122
|
+
streamDone;
|
|
123
|
+
completedStreams = [];
|
|
99
124
|
index = 0;
|
|
100
125
|
currentBytes = 0;
|
|
101
126
|
hash = createHash("sha256");
|
|
127
|
+
decoder = new StringDecoder("utf8");
|
|
102
128
|
pending = "";
|
|
129
|
+
closePromise;
|
|
130
|
+
writeError;
|
|
131
|
+
closed = false;
|
|
132
|
+
acceptedBytes;
|
|
103
133
|
static REDACTION_OVERLAP_CHARS = 4096;
|
|
104
134
|
constructor(receipt) {
|
|
105
135
|
this.receipt = receipt;
|
|
136
|
+
this.acceptedBytes = receipt.bytes;
|
|
106
137
|
}
|
|
107
138
|
append(raw) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// overlap window for the next event.
|
|
139
|
+
if (this.closed)
|
|
140
|
+
throw new Error("Cannot append to a closed job artifact");
|
|
141
|
+
this.pending += Buffer.isBuffer(raw) ? this.decoder.write(raw) : raw;
|
|
112
142
|
const lastNewline = this.pending.lastIndexOf("\n");
|
|
113
143
|
if (lastNewline >= 0) {
|
|
114
144
|
this.writeRedacted(this.pending.slice(0, lastNewline + 1));
|
|
@@ -121,59 +151,94 @@ class RotatingRedactedWriter {
|
|
|
121
151
|
this.pending = this.pending.slice(flushLength);
|
|
122
152
|
}
|
|
123
153
|
close() {
|
|
154
|
+
if (this.closePromise)
|
|
155
|
+
return this.closePromise;
|
|
156
|
+
this.pending += this.decoder.end();
|
|
124
157
|
if (this.pending)
|
|
125
158
|
this.writeRedacted(this.pending);
|
|
126
159
|
this.pending = "";
|
|
127
|
-
this.
|
|
128
|
-
this.
|
|
160
|
+
this.closed = true;
|
|
161
|
+
this.finishCurrentStream();
|
|
162
|
+
this.closePromise = Promise.all(this.completedStreams).then(() => {
|
|
163
|
+
if (this.writeError)
|
|
164
|
+
throw this.writeError;
|
|
165
|
+
});
|
|
166
|
+
return this.closePromise;
|
|
129
167
|
}
|
|
130
168
|
writeRedacted(source) {
|
|
131
169
|
const safe = redactSecrets(source);
|
|
132
170
|
if (safe !== source)
|
|
133
171
|
this.receipt.redacted = true;
|
|
134
172
|
let data = Buffer.from(safe, "utf8");
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (this.receipt.bytes + data.length > TOTAL_STREAM_BYTES) {
|
|
140
|
-
const keep = TOTAL_STREAM_BYTES - this.receipt.bytes;
|
|
141
|
-
this.receipt.droppedBytes += data.length - keep;
|
|
142
|
-
data = data.subarray(0, keep);
|
|
173
|
+
const remaining = Math.max(0, MAX_STREAM_BYTES - this.acceptedBytes);
|
|
174
|
+
if (data.length > remaining) {
|
|
175
|
+
this.receipt.droppedBytes += data.length - remaining;
|
|
176
|
+
data = data.subarray(0, remaining);
|
|
143
177
|
}
|
|
178
|
+
this.acceptedBytes += data.length;
|
|
144
179
|
while (data.length > 0) {
|
|
145
180
|
if (!this.stream || this.currentBytes >= PER_FILE_BYTES)
|
|
146
181
|
this.rotate();
|
|
147
182
|
const room = PER_FILE_BYTES - this.currentBytes;
|
|
148
183
|
const part = data.subarray(0, room);
|
|
149
|
-
this.stream
|
|
150
|
-
|
|
184
|
+
const stream = this.stream;
|
|
185
|
+
stream.write(part, (error) => {
|
|
186
|
+
if (error) {
|
|
187
|
+
this.writeError ??= error;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this.hash.update(part);
|
|
191
|
+
this.receipt.bytes += part.length;
|
|
192
|
+
this.receipt.sha256 = this.hash.copy().digest("hex");
|
|
193
|
+
});
|
|
151
194
|
this.currentBytes += part.length;
|
|
152
|
-
this.receipt.bytes += part.length;
|
|
153
195
|
data = data.subarray(part.length);
|
|
154
196
|
}
|
|
155
|
-
|
|
197
|
+
}
|
|
198
|
+
finishCurrentStream() {
|
|
199
|
+
if (!this.stream || !this.streamDone)
|
|
200
|
+
return;
|
|
201
|
+
const stream = this.stream;
|
|
202
|
+
const done = this.streamDone;
|
|
203
|
+
this.stream = undefined;
|
|
204
|
+
this.streamDone = undefined;
|
|
205
|
+
stream.end();
|
|
206
|
+
this.completedStreams.push(done);
|
|
156
207
|
}
|
|
157
208
|
rotate() {
|
|
158
|
-
this.
|
|
209
|
+
this.finishCurrentStream();
|
|
159
210
|
const path = this.index === 0 ? this.receipt.path : `${this.receipt.path}.${this.index}`;
|
|
160
211
|
this.index += 1;
|
|
161
212
|
this.currentBytes = 0;
|
|
162
213
|
this.receipt.chunks.push(path);
|
|
163
|
-
|
|
214
|
+
const stream = createWriteStream(path, { flags: "w", mode: 0o600 });
|
|
215
|
+
this.stream = stream;
|
|
216
|
+
this.streamDone = new Promise((resolve) => {
|
|
217
|
+
stream.once("finish", resolve);
|
|
218
|
+
stream.once("error", (error) => {
|
|
219
|
+
this.writeError ??= error;
|
|
220
|
+
resolve();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
164
223
|
}
|
|
165
224
|
}
|
|
166
225
|
export class JobManager {
|
|
167
226
|
jobsDir;
|
|
168
227
|
jobs = new Map();
|
|
228
|
+
notifications = new Map();
|
|
169
229
|
processes = new Map();
|
|
170
230
|
writers = new Map();
|
|
171
231
|
abortControllers = new Map();
|
|
172
232
|
deadlineTimers = new Map();
|
|
233
|
+
finalizations = new Map();
|
|
234
|
+
listeners = new Set();
|
|
235
|
+
registryRetryTimer;
|
|
173
236
|
registryPath;
|
|
237
|
+
transientV2RegistryPath;
|
|
174
238
|
constructor(jobsDir = getJobsDir()) {
|
|
175
239
|
this.jobsDir = jobsDir;
|
|
176
240
|
this.registryPath = join(this.jobsDir, REGISTRY_FILE);
|
|
241
|
+
this.transientV2RegistryPath = join(this.jobsDir, TRANSIENT_V2_REGISTRY_FILE);
|
|
177
242
|
this.loadAndReconcile();
|
|
178
243
|
}
|
|
179
244
|
isDurable(job) {
|
|
@@ -184,10 +249,24 @@ export class JobManager {
|
|
|
184
249
|
job.status === "starting" ||
|
|
185
250
|
job.status === "stopping");
|
|
186
251
|
}
|
|
252
|
+
isTerminalStatus(status) {
|
|
253
|
+
return status === "exited" || status === "failed" || status === "killed" || status === "lost";
|
|
254
|
+
}
|
|
255
|
+
emit(change) {
|
|
256
|
+
for (const listener of this.listeners) {
|
|
257
|
+
try {
|
|
258
|
+
listener(change);
|
|
259
|
+
}
|
|
260
|
+
catch { /* listeners cannot break job persistence */ }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
subscribe(listener) {
|
|
264
|
+
this.listeners.add(listener);
|
|
265
|
+
return () => this.listeners.delete(listener);
|
|
266
|
+
}
|
|
187
267
|
matchesSession(job, sessionId) {
|
|
188
268
|
if (!sessionId)
|
|
189
269
|
return true;
|
|
190
|
-
// Strict: only this session's durable jobs (never leak other sessions).
|
|
191
270
|
return job.ownerSessionId === sessionId;
|
|
192
271
|
}
|
|
193
272
|
clearJobDeadline(id) {
|
|
@@ -196,6 +275,131 @@ export class JobManager {
|
|
|
196
275
|
clearTimeout(timer);
|
|
197
276
|
this.deadlineTimers.delete(id);
|
|
198
277
|
}
|
|
278
|
+
cloneReceipt(receipt) {
|
|
279
|
+
return { ...receipt, chunks: [...receipt.chunks] };
|
|
280
|
+
}
|
|
281
|
+
notificationForJob(jobId) {
|
|
282
|
+
return [...this.notifications.values()].find((notification) => notification.jobId === jobId);
|
|
283
|
+
}
|
|
284
|
+
ensureCompletionNotification(job) {
|
|
285
|
+
if (!this.isDurable(job) ||
|
|
286
|
+
!this.isTerminalStatus(job.status) ||
|
|
287
|
+
!job.responder) {
|
|
288
|
+
return { created: false, updated: false };
|
|
289
|
+
}
|
|
290
|
+
const existing = this.notificationForJob(job.id);
|
|
291
|
+
const endedAt = job.endedAt ?? new Date().toISOString();
|
|
292
|
+
const stdoutArtifact = this.cloneReceipt(job.artifacts.stdout);
|
|
293
|
+
const stderrArtifact = this.cloneReceipt(job.artifacts.stderr);
|
|
294
|
+
const completionChanged = Boolean(existing &&
|
|
295
|
+
(existing.status !== job.status ||
|
|
296
|
+
existing.endedAt !== endedAt ||
|
|
297
|
+
existing.exitCode !== job.exitCode ||
|
|
298
|
+
existing.signal !== job.signal ||
|
|
299
|
+
JSON.stringify(existing.stdoutArtifact) !==
|
|
300
|
+
JSON.stringify(stdoutArtifact) ||
|
|
301
|
+
JSON.stringify(existing.stderrArtifact) !==
|
|
302
|
+
JSON.stringify(stderrArtifact)));
|
|
303
|
+
const reopenSettlement = Boolean(existing?.acknowledgedAt && completionChanged);
|
|
304
|
+
const notification = {
|
|
305
|
+
id: existing?.id ??
|
|
306
|
+
`completion:${createHash("sha256").update(`${job.ownerSessionId}\0${job.id}\0${job.startedAt}`).digest("hex").slice(0, 24)}`,
|
|
307
|
+
ownerSessionId: job.ownerSessionId,
|
|
308
|
+
jobId: job.id,
|
|
309
|
+
status: job.status,
|
|
310
|
+
createdAt: existing?.createdAt ?? endedAt,
|
|
311
|
+
startedAt: job.startedAt,
|
|
312
|
+
endedAt,
|
|
313
|
+
stdoutArtifact,
|
|
314
|
+
stderrArtifact,
|
|
315
|
+
commandDisplay: job.commandDisplay,
|
|
316
|
+
wakeOnCompletion: job.wakeOnCompletion ?? false,
|
|
317
|
+
responder: job.responder ?? false,
|
|
318
|
+
...(job.taskId ? { taskId: job.taskId } : {}),
|
|
319
|
+
...(job.parentTaskId ? { parentTaskId: job.parentTaskId } : {}),
|
|
320
|
+
...(job.exitCode !== undefined ? { exitCode: job.exitCode } : {}),
|
|
321
|
+
...(job.signal !== undefined ? { signal: job.signal } : {}),
|
|
322
|
+
...(job.monitor !== undefined ? { monitor: job.monitor } : {}),
|
|
323
|
+
...(existing?.deliveredAt ? { deliveredAt: existing.deliveredAt } : {}),
|
|
324
|
+
...((existing?.analyzedAt ?? existing?.acknowledgedAt)
|
|
325
|
+
? { analyzedAt: existing?.analyzedAt ?? existing?.acknowledgedAt }
|
|
326
|
+
: {}),
|
|
327
|
+
...(!reopenSettlement && existing?.acknowledgedAt
|
|
328
|
+
? { acknowledgedAt: existing.acknowledgedAt }
|
|
329
|
+
: {}),
|
|
330
|
+
};
|
|
331
|
+
const updated = Boolean(existing && JSON.stringify(existing) !== JSON.stringify(notification));
|
|
332
|
+
this.notifications.set(notification.id, notification);
|
|
333
|
+
return { notification, created: !existing, updated };
|
|
334
|
+
}
|
|
335
|
+
async closeWriters(id) {
|
|
336
|
+
const writers = this.writers.get(id);
|
|
337
|
+
if (!writers)
|
|
338
|
+
return true;
|
|
339
|
+
const results = await Promise.allSettled([writers.stdout.close(), writers.stderr.close()]);
|
|
340
|
+
this.writers.delete(id);
|
|
341
|
+
return results.every((result) => result.status === "fulfilled");
|
|
342
|
+
}
|
|
343
|
+
async finalizeJob(job, status, details = {}) {
|
|
344
|
+
const pending = this.finalizations.get(job.id);
|
|
345
|
+
if (pending)
|
|
346
|
+
return pending;
|
|
347
|
+
const correctsLost = job.status === "lost" && status !== "lost";
|
|
348
|
+
if (this.isTerminalStatus(job.status) && !correctsLost) {
|
|
349
|
+
const completion = this.ensureCompletionNotification(job);
|
|
350
|
+
if ((!completion.created && !completion.updated) ||
|
|
351
|
+
!completion.notification) {
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
const persisted = this.persistSync();
|
|
355
|
+
if (!persisted)
|
|
356
|
+
this.scheduleRegistryRetry();
|
|
357
|
+
this.emit({ type: "job", jobId: job.id });
|
|
358
|
+
this.emit({
|
|
359
|
+
type: "notification",
|
|
360
|
+
jobId: job.id,
|
|
361
|
+
notificationId: completion.notification.id,
|
|
362
|
+
});
|
|
363
|
+
return persisted;
|
|
364
|
+
}
|
|
365
|
+
const finalization = (async () => {
|
|
366
|
+
this.clearJobDeadline(job.id);
|
|
367
|
+
const streamsFlushed = await this.closeWriters(job.id);
|
|
368
|
+
job.status = streamsFlushed ? status : "failed";
|
|
369
|
+
if (details.exitCode !== undefined)
|
|
370
|
+
job.exitCode = details.exitCode;
|
|
371
|
+
if (!streamsFlushed && (job.exitCode === undefined || job.exitCode === 0)) {
|
|
372
|
+
job.exitCode = 1;
|
|
373
|
+
}
|
|
374
|
+
if (details.signal !== undefined)
|
|
375
|
+
job.signal = details.signal;
|
|
376
|
+
job.endedAt = details.endedAt ?? new Date().toISOString();
|
|
377
|
+
this.abortControllers.delete(job.id);
|
|
378
|
+
this.processes.delete(job.id);
|
|
379
|
+
const completion = this.ensureCompletionNotification(job);
|
|
380
|
+
this.pruneTerminalJobs();
|
|
381
|
+
const persisted = this.persistSync();
|
|
382
|
+
if (!persisted)
|
|
383
|
+
this.scheduleRegistryRetry();
|
|
384
|
+
this.emit({ type: "job", jobId: job.id });
|
|
385
|
+
if ((completion.created || completion.updated) &&
|
|
386
|
+
completion.notification) {
|
|
387
|
+
this.emit({
|
|
388
|
+
type: "notification",
|
|
389
|
+
jobId: job.id,
|
|
390
|
+
notificationId: completion.notification.id,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return persisted;
|
|
394
|
+
})();
|
|
395
|
+
this.finalizations.set(job.id, finalization);
|
|
396
|
+
try {
|
|
397
|
+
return await finalization;
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
this.finalizations.delete(job.id);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
199
403
|
/** Reconcile a restored process that no longer has a ChildProcess close event. */
|
|
200
404
|
refreshJobLiveness(job) {
|
|
201
405
|
if (!this.isLive(job) || this.processes.has(job.id))
|
|
@@ -210,7 +414,19 @@ export class JobManager {
|
|
|
210
414
|
job.status = "lost";
|
|
211
415
|
job.endedAt = new Date().toISOString();
|
|
212
416
|
this.clearJobDeadline(job.id);
|
|
213
|
-
|
|
417
|
+
const completion = this.ensureCompletionNotification(job);
|
|
418
|
+
this.pruneTerminalJobs();
|
|
419
|
+
if (!this.persistSync())
|
|
420
|
+
this.scheduleRegistryRetry();
|
|
421
|
+
this.emit({ type: "job", jobId: job.id });
|
|
422
|
+
if ((completion.created || completion.updated) &&
|
|
423
|
+
completion.notification) {
|
|
424
|
+
this.emit({
|
|
425
|
+
type: "notification",
|
|
426
|
+
jobId: job.id,
|
|
427
|
+
notificationId: completion.notification.id,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
214
430
|
}
|
|
215
431
|
/** Arm or restore a persisted finite-job deadline. */
|
|
216
432
|
scheduleJobDeadline(job) {
|
|
@@ -240,34 +456,37 @@ export class JobManager {
|
|
|
240
456
|
this.abortControllers.set(id, ac);
|
|
241
457
|
if (child)
|
|
242
458
|
this.processes.set(id, child);
|
|
243
|
-
// Ephemeral tool-track rows must not bloat registry-v1.json (was the
|
|
244
|
-
// root cause of 1000+ fake "jobs" and multi-GB RAM).
|
|
245
459
|
if (this.isDurable(tracked))
|
|
246
|
-
|
|
460
|
+
this.persistSync();
|
|
461
|
+
this.emit({ type: "job", jobId: id });
|
|
247
462
|
}
|
|
248
463
|
updateJobStatus(id, status, exitCode) {
|
|
249
464
|
const job = this.jobs.get(id);
|
|
250
465
|
if (!job)
|
|
251
466
|
return;
|
|
467
|
+
if (this.isDurable(job) && this.isTerminalStatus(status)) {
|
|
468
|
+
void this.finalizeJob(job, status, { ...(exitCode !== undefined ? { exitCode } : {}) });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
252
471
|
job.status = status;
|
|
253
472
|
if (exitCode !== undefined)
|
|
254
473
|
job.exitCode = exitCode;
|
|
255
|
-
if (!
|
|
474
|
+
if (!this.isLive(job))
|
|
256
475
|
job.endedAt = new Date().toISOString();
|
|
257
|
-
}
|
|
258
476
|
this.abortControllers.delete(id);
|
|
259
477
|
this.processes.delete(id);
|
|
260
478
|
if (!this.isLive(job))
|
|
261
479
|
this.clearJobDeadline(id);
|
|
262
|
-
// Drop finished tool-track rows immediately — they are not background jobs.
|
|
263
480
|
if (!this.isDurable(job) && !this.isLive(job)) {
|
|
264
481
|
this.jobs.delete(id);
|
|
482
|
+
this.emit({ type: "job", jobId: id });
|
|
265
483
|
return;
|
|
266
484
|
}
|
|
267
485
|
if (this.isDurable(job)) {
|
|
268
486
|
this.pruneTerminalJobs();
|
|
269
|
-
|
|
487
|
+
this.persistSync();
|
|
270
488
|
}
|
|
489
|
+
this.emit({ type: "job", jobId: id });
|
|
271
490
|
}
|
|
272
491
|
async startJob(command, options) {
|
|
273
492
|
if (options?.authorization?.expiresAt) {
|
|
@@ -312,6 +531,13 @@ export class JobManager {
|
|
|
312
531
|
const stderrArtifact = join(this.jobsDir, `${prefix}.stderr.log`);
|
|
313
532
|
const makeReceipt = (path) => ({ path, chunks: [], bytes: 0, droppedBytes: 0, redacted: false, sha256: "" });
|
|
314
533
|
const safeDisplay = redactSecrets(commandDisplay(command));
|
|
534
|
+
const monitor = options?.monitor !== undefined || options?.profile !== undefined || options?.estimatedSeconds !== undefined
|
|
535
|
+
? {
|
|
536
|
+
...(options?.monitor ?? {}),
|
|
537
|
+
...(options?.profile !== undefined ? { profile: options.profile } : {}),
|
|
538
|
+
...(options?.estimatedSeconds !== undefined ? { estimatedSeconds: options.estimatedSeconds } : {}),
|
|
539
|
+
}
|
|
540
|
+
: undefined;
|
|
315
541
|
const job = {
|
|
316
542
|
id,
|
|
317
543
|
command: safeDisplay,
|
|
@@ -327,6 +553,11 @@ export class JobManager {
|
|
|
327
553
|
ownerSessionId: options?.ownerSessionId ?? "unknown",
|
|
328
554
|
kind: "durable",
|
|
329
555
|
...(options?.name ? { name: options.name } : {}),
|
|
556
|
+
...(options?.taskId ? { taskId: options.taskId } : {}),
|
|
557
|
+
...(options?.parentTaskId ? { parentTaskId: options.parentTaskId } : {}),
|
|
558
|
+
...(options?.wakeOnCompletion !== undefined ? { wakeOnCompletion: options.wakeOnCompletion } : {}),
|
|
559
|
+
...(options?.responder !== undefined ? { responder: options.responder } : {}),
|
|
560
|
+
...(monitor !== undefined ? { monitor } : {}),
|
|
330
561
|
...(options?.timeoutMs !== undefined
|
|
331
562
|
? {
|
|
332
563
|
timeoutAt: new Date(Date.now() + Math.max(1_000, Math.floor(options.timeoutMs))).toISOString(),
|
|
@@ -336,7 +567,18 @@ export class JobManager {
|
|
|
336
567
|
};
|
|
337
568
|
this.jobs.set(id, job);
|
|
338
569
|
this.pruneTerminalJobs();
|
|
339
|
-
|
|
570
|
+
try {
|
|
571
|
+
await this.persist();
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
this.jobs.delete(id);
|
|
575
|
+
return {
|
|
576
|
+
ok: false,
|
|
577
|
+
output: "Background command launch error [PERSIST_FAILED]: could not persist the job registry; the command was not started.",
|
|
578
|
+
exitCode: 1,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
this.emit({ type: "job", jobId: id });
|
|
340
582
|
let launchConfirmed = false;
|
|
341
583
|
try {
|
|
342
584
|
const detached = process.platform !== "win32";
|
|
@@ -356,6 +598,7 @@ export class JobManager {
|
|
|
356
598
|
let child = spawnChild();
|
|
357
599
|
const stdout = new RotatingRedactedWriter(job.artifacts.stdout);
|
|
358
600
|
const stderr = new RotatingRedactedWriter(job.artifacts.stderr);
|
|
601
|
+
this.writers.set(id, { stdout, stderr });
|
|
359
602
|
stdout.append(`$ ${job.commandDisplay}\n\n`);
|
|
360
603
|
// A ChildProcess object is returned before the OS confirms launch. Wait
|
|
361
604
|
// for `spawn` (or `error`) so shell.start never reports a phantom running
|
|
@@ -399,12 +642,7 @@ export class JobManager {
|
|
|
399
642
|
`${fields.join("; ")}\n` +
|
|
400
643
|
"The command did not start. Do not rewrite its syntax to work around this infrastructure error; verify the target and cwd.";
|
|
401
644
|
stderr.append(`${detail}\n`);
|
|
402
|
-
|
|
403
|
-
stderr.close();
|
|
404
|
-
job.status = "failed";
|
|
405
|
-
job.exitCode = 127;
|
|
406
|
-
job.endedAt = new Date().toISOString();
|
|
407
|
-
await this.persist();
|
|
645
|
+
await this.finalizeJob(job, "failed", { exitCode: 127 });
|
|
408
646
|
return {
|
|
409
647
|
ok: false,
|
|
410
648
|
output: detail,
|
|
@@ -438,46 +676,88 @@ export class JobManager {
|
|
|
438
676
|
: undefined;
|
|
439
677
|
expiryTimer?.unref?.();
|
|
440
678
|
this.scheduleJobDeadline(job);
|
|
441
|
-
|
|
442
|
-
|
|
679
|
+
let lastProgressFlush = 0;
|
|
680
|
+
let progressDirty = false;
|
|
681
|
+
let progressTimer;
|
|
682
|
+
const flushProgress = () => {
|
|
683
|
+
progressDirty = false;
|
|
684
|
+
lastProgressFlush = Date.now();
|
|
685
|
+
this.persistSync();
|
|
686
|
+
this.emit({ type: "job", jobId: id });
|
|
687
|
+
};
|
|
688
|
+
// Coalesce high-frequency stdout/stderr chunks: a chatty job (scanner,
|
|
689
|
+
// dev server) must not force a registry write + UI rerender per data
|
|
690
|
+
// event. Flush at most every PROGRESS_FLUSH_MS, with a trailing flush so
|
|
691
|
+
// the last chunk before a quiet gap is never dropped. Terminal
|
|
692
|
+
// finalize/close always persists authoritatively and clears the timer.
|
|
693
|
+
const scheduleProgressFlush = () => {
|
|
694
|
+
const elapsed = Date.now() - lastProgressFlush;
|
|
695
|
+
if (elapsed >= PROGRESS_FLUSH_MS) {
|
|
696
|
+
if (progressTimer) {
|
|
697
|
+
clearTimeout(progressTimer);
|
|
698
|
+
progressTimer = undefined;
|
|
699
|
+
}
|
|
700
|
+
flushProgress();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
progressDirty = true;
|
|
704
|
+
if (progressTimer)
|
|
705
|
+
return;
|
|
706
|
+
progressTimer = setTimeout(() => {
|
|
707
|
+
progressTimer = undefined;
|
|
708
|
+
if (progressDirty)
|
|
709
|
+
flushProgress();
|
|
710
|
+
}, PROGRESS_FLUSH_MS - elapsed);
|
|
711
|
+
progressTimer.unref?.();
|
|
712
|
+
};
|
|
713
|
+
const stopProgressFlush = () => {
|
|
714
|
+
if (progressTimer) {
|
|
715
|
+
clearTimeout(progressTimer);
|
|
716
|
+
progressTimer = undefined;
|
|
717
|
+
}
|
|
718
|
+
progressDirty = false;
|
|
719
|
+
};
|
|
720
|
+
const recordOutput = (writer, chunk) => {
|
|
721
|
+
writer.append(chunk);
|
|
722
|
+
job.heartbeatAt = new Date().toISOString();
|
|
723
|
+
scheduleProgressFlush();
|
|
724
|
+
};
|
|
725
|
+
child.stdout?.on("data", (chunk) => recordOutput(stdout, chunk));
|
|
726
|
+
child.stderr?.on("data", (chunk) => recordOutput(stderr, chunk));
|
|
443
727
|
child.on("close", (code, signal) => {
|
|
444
728
|
if (expiryTimer)
|
|
445
729
|
clearTimeout(expiryTimer);
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
job
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
stderr.close();
|
|
453
|
-
this.processes.delete(id);
|
|
454
|
-
this.writers.delete(id);
|
|
455
|
-
void this.persist();
|
|
730
|
+
stopProgressFlush();
|
|
731
|
+
const status = signal ? "killed" : code === 0 ? "exited" : "failed";
|
|
732
|
+
void this.finalizeJob(job, status, {
|
|
733
|
+
...(code !== null ? { exitCode: code } : {}),
|
|
734
|
+
...(signal !== null ? { signal } : {}),
|
|
735
|
+
});
|
|
456
736
|
});
|
|
457
737
|
child.on("error", (error) => {
|
|
458
738
|
if (expiryTimer)
|
|
459
739
|
clearTimeout(expiryTimer);
|
|
460
|
-
|
|
740
|
+
stopProgressFlush();
|
|
461
741
|
const code = error.code ?? "UNKNOWN";
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
stderr.close();
|
|
468
|
-
this.processes.delete(id);
|
|
469
|
-
this.writers.delete(id);
|
|
470
|
-
void this.persist();
|
|
742
|
+
try {
|
|
743
|
+
stderr.append(`Background process error [${code}]: ${error.message}\n`);
|
|
744
|
+
}
|
|
745
|
+
catch { /* finalization already owns the writers */ }
|
|
746
|
+
void this.finalizeJob(job, "failed", { exitCode: 127 });
|
|
471
747
|
});
|
|
748
|
+
this.persistSync();
|
|
749
|
+
this.emit({ type: "job", jobId: id });
|
|
472
750
|
// Best-effort detection for shell-level failures such as command-not-found
|
|
473
751
|
// immediately after the command shell launches. This bounded window is
|
|
474
752
|
// not an application readiness guarantee; callers must tail/probe.
|
|
753
|
+
let closedEarly = false;
|
|
475
754
|
await new Promise((resolve) => {
|
|
476
755
|
if (!this.isLive(job)) {
|
|
477
756
|
resolve();
|
|
478
757
|
return;
|
|
479
758
|
}
|
|
480
759
|
const onClose = () => {
|
|
760
|
+
closedEarly = true;
|
|
481
761
|
clearTimeout(timer);
|
|
482
762
|
resolve();
|
|
483
763
|
};
|
|
@@ -487,6 +767,11 @@ export class JobManager {
|
|
|
487
767
|
}, 30);
|
|
488
768
|
child.once("close", onClose);
|
|
489
769
|
});
|
|
770
|
+
if (closedEarly) {
|
|
771
|
+
const finalization = this.finalizations.get(id);
|
|
772
|
+
if (finalization)
|
|
773
|
+
await finalization;
|
|
774
|
+
}
|
|
490
775
|
const earlyStatus = this.getJob(id)?.status;
|
|
491
776
|
if (earlyStatus === "failed") {
|
|
492
777
|
await this.persist();
|
|
@@ -528,12 +813,14 @@ export class JobManager {
|
|
|
528
813
|
const detail = error instanceof Error ? error.message : String(error);
|
|
529
814
|
if (launchConfirmed) {
|
|
530
815
|
const alive = processAlive(job.pid);
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
816
|
+
if (!alive && this.isLive(job)) {
|
|
817
|
+
await this.finalizeJob(job, "failed", { exitCode: job.exitCode ?? 1 });
|
|
818
|
+
}
|
|
819
|
+
else if (alive) {
|
|
820
|
+
job.status = "running";
|
|
821
|
+
this.persistSync();
|
|
822
|
+
this.emit({ type: "job", jobId: id });
|
|
535
823
|
}
|
|
536
|
-
await this.persist().catch(() => undefined);
|
|
537
824
|
return {
|
|
538
825
|
ok: alive,
|
|
539
826
|
output: `Background command launched as pid=${job.pid ?? "?"}, but job setup/persistence failed: ${detail}\n` +
|
|
@@ -549,10 +836,7 @@ export class JobManager {
|
|
|
549
836
|
},
|
|
550
837
|
};
|
|
551
838
|
}
|
|
552
|
-
job
|
|
553
|
-
job.exitCode = 127;
|
|
554
|
-
job.endedAt = new Date().toISOString();
|
|
555
|
-
await this.persist().catch(() => undefined);
|
|
839
|
+
await this.finalizeJob(job, "failed", { exitCode: 127 });
|
|
556
840
|
return {
|
|
557
841
|
ok: false,
|
|
558
842
|
output: `Background command launch error [UNKNOWN]: ${detail}\n` +
|
|
@@ -588,16 +872,12 @@ export class JobManager {
|
|
|
588
872
|
const shown = ordered.slice(0, LIST_JOBS_MAX_LINES);
|
|
589
873
|
const omitted = ordered.length - shown.length;
|
|
590
874
|
const format = (job) => {
|
|
591
|
-
const elapsedEnd = job.endedAt
|
|
592
|
-
? new Date(job.endedAt).getTime()
|
|
593
|
-
: Date.now();
|
|
594
|
-
const elapsed = Math.max(0, Math.round((elapsedEnd - new Date(job.startedAt).getTime()) / 1000));
|
|
595
875
|
const health = this.isLive(job)
|
|
596
876
|
? processAlive(job.pid)
|
|
597
877
|
? "alive"
|
|
598
878
|
: "unresponsive"
|
|
599
879
|
: "terminal";
|
|
600
|
-
return `[${job.id}] ${job.status} health=${health} exit=${job.exitCode ?? "?"} ${
|
|
880
|
+
return `[${job.id}] ${job.status} health=${health} exit=${job.exitCode ?? "?"} ${formatJobElapsed(job)} ${job.commandDisplay.slice(0, 80)}`;
|
|
601
881
|
};
|
|
602
882
|
const header = sessionId
|
|
603
883
|
? `Session background jobs (${durable.length} total, session ${sessionId}):`
|
|
@@ -637,6 +917,133 @@ export class JobManager {
|
|
|
637
917
|
this.refreshJobLiveness(job);
|
|
638
918
|
return job;
|
|
639
919
|
}
|
|
920
|
+
getPendingNotifications(sessionId) {
|
|
921
|
+
return [...this.notifications.values()]
|
|
922
|
+
.filter((notification) => !notification.acknowledgedAt &&
|
|
923
|
+
(!sessionId || notification.ownerSessionId === sessionId))
|
|
924
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
925
|
+
}
|
|
926
|
+
pendingNotifications(sessionId) {
|
|
927
|
+
return this.getPendingNotifications(sessionId);
|
|
928
|
+
}
|
|
929
|
+
markDelivered(notificationId) {
|
|
930
|
+
const notification = this.notifications.get(notificationId);
|
|
931
|
+
if (!notification)
|
|
932
|
+
return false;
|
|
933
|
+
if (!notification.deliveredAt) {
|
|
934
|
+
const deliveredAt = new Date().toISOString();
|
|
935
|
+
notification.deliveredAt = deliveredAt;
|
|
936
|
+
if (!this.persistSync()) {
|
|
937
|
+
notification.deliveredAt = undefined;
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
this.emit({ type: "notification", jobId: notification.jobId, notificationId });
|
|
941
|
+
}
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
markAnalyzed(notificationId) {
|
|
945
|
+
const notification = this.notifications.get(notificationId);
|
|
946
|
+
if (!notification?.deliveredAt)
|
|
947
|
+
return false;
|
|
948
|
+
if (!notification.analyzedAt) {
|
|
949
|
+
notification.analyzedAt = new Date().toISOString();
|
|
950
|
+
if (!this.persistSync()) {
|
|
951
|
+
notification.analyzedAt = undefined;
|
|
952
|
+
return false;
|
|
953
|
+
}
|
|
954
|
+
this.emit({
|
|
955
|
+
type: "notification",
|
|
956
|
+
jobId: notification.jobId,
|
|
957
|
+
notificationId,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
acknowledge(notificationId) {
|
|
963
|
+
const notification = this.notifications.get(notificationId);
|
|
964
|
+
if (!notification?.analyzedAt)
|
|
965
|
+
return false;
|
|
966
|
+
if (!notification.acknowledgedAt) {
|
|
967
|
+
notification.acknowledgedAt = new Date().toISOString();
|
|
968
|
+
if (!this.persistSync()) {
|
|
969
|
+
notification.acknowledgedAt = undefined;
|
|
970
|
+
return false;
|
|
971
|
+
}
|
|
972
|
+
this.pruneTerminalJobs();
|
|
973
|
+
this.persistSync();
|
|
974
|
+
this.emit({ type: "notification", jobId: notification.jobId, notificationId });
|
|
975
|
+
}
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
978
|
+
linkJob(jobId, metadata) {
|
|
979
|
+
const resolved = this.resolveJobId(jobId);
|
|
980
|
+
const job = resolved ? this.jobs.get(resolved) : undefined;
|
|
981
|
+
if (!job || !this.isDurable(job))
|
|
982
|
+
return undefined;
|
|
983
|
+
const previousJob = {
|
|
984
|
+
taskId: job.taskId,
|
|
985
|
+
parentTaskId: job.parentTaskId,
|
|
986
|
+
wakeOnCompletion: job.wakeOnCompletion,
|
|
987
|
+
monitor: job.monitor,
|
|
988
|
+
responder: job.responder,
|
|
989
|
+
};
|
|
990
|
+
const existingNotification = this.notificationForJob(job.id);
|
|
991
|
+
const previousNotification = existingNotification
|
|
992
|
+
? {
|
|
993
|
+
taskId: existingNotification.taskId,
|
|
994
|
+
parentTaskId: existingNotification.parentTaskId,
|
|
995
|
+
wakeOnCompletion: existingNotification.wakeOnCompletion,
|
|
996
|
+
monitor: existingNotification.monitor,
|
|
997
|
+
responder: existingNotification.responder,
|
|
998
|
+
}
|
|
999
|
+
: undefined;
|
|
1000
|
+
if (metadata.taskId !== undefined)
|
|
1001
|
+
job.taskId = metadata.taskId;
|
|
1002
|
+
if (metadata.parentTaskId !== undefined)
|
|
1003
|
+
job.parentTaskId = metadata.parentTaskId;
|
|
1004
|
+
if (metadata.wakeOnCompletion !== undefined)
|
|
1005
|
+
job.wakeOnCompletion = metadata.wakeOnCompletion;
|
|
1006
|
+
if (metadata.monitor !== undefined)
|
|
1007
|
+
job.monitor = metadata.monitor;
|
|
1008
|
+
if (metadata.responder !== undefined)
|
|
1009
|
+
job.responder = metadata.responder;
|
|
1010
|
+
const completion = this.ensureCompletionNotification(job);
|
|
1011
|
+
if (completion.notification) {
|
|
1012
|
+
if (metadata.taskId !== undefined)
|
|
1013
|
+
completion.notification.taskId = metadata.taskId;
|
|
1014
|
+
if (metadata.parentTaskId !== undefined)
|
|
1015
|
+
completion.notification.parentTaskId = metadata.parentTaskId;
|
|
1016
|
+
if (metadata.wakeOnCompletion !== undefined)
|
|
1017
|
+
completion.notification.wakeOnCompletion = metadata.wakeOnCompletion;
|
|
1018
|
+
if (metadata.monitor !== undefined)
|
|
1019
|
+
completion.notification.monitor = metadata.monitor;
|
|
1020
|
+
if (metadata.responder !== undefined)
|
|
1021
|
+
completion.notification.responder = metadata.responder;
|
|
1022
|
+
}
|
|
1023
|
+
if (!this.persistSync()) {
|
|
1024
|
+
job.taskId = previousJob.taskId;
|
|
1025
|
+
job.parentTaskId = previousJob.parentTaskId;
|
|
1026
|
+
job.wakeOnCompletion = previousJob.wakeOnCompletion;
|
|
1027
|
+
job.monitor = previousJob.monitor;
|
|
1028
|
+
job.responder = previousJob.responder;
|
|
1029
|
+
if (completion.created && completion.notification) {
|
|
1030
|
+
this.notifications.delete(completion.notification.id);
|
|
1031
|
+
}
|
|
1032
|
+
else if (completion.notification && previousNotification) {
|
|
1033
|
+
completion.notification.taskId = previousNotification.taskId;
|
|
1034
|
+
completion.notification.parentTaskId = previousNotification.parentTaskId;
|
|
1035
|
+
completion.notification.wakeOnCompletion = previousNotification.wakeOnCompletion;
|
|
1036
|
+
completion.notification.monitor = previousNotification.monitor;
|
|
1037
|
+
completion.notification.responder = previousNotification.responder;
|
|
1038
|
+
}
|
|
1039
|
+
return undefined;
|
|
1040
|
+
}
|
|
1041
|
+
this.emit({ type: "job", jobId: job.id });
|
|
1042
|
+
if (completion.notification) {
|
|
1043
|
+
this.emit({ type: "notification", jobId: job.id, notificationId: completion.notification.id });
|
|
1044
|
+
}
|
|
1045
|
+
return job;
|
|
1046
|
+
}
|
|
640
1047
|
async tailJob(id, bytesOrCursor) {
|
|
641
1048
|
const resolved = this.resolveJobId(id);
|
|
642
1049
|
const job = resolved ? this.jobs.get(resolved) : undefined;
|
|
@@ -720,66 +1127,235 @@ export class JobManager {
|
|
|
720
1127
|
if (!job || !resolved)
|
|
721
1128
|
return { ok: false, output: `Job "${id}" not found.`, exitCode: 1 };
|
|
722
1129
|
id = resolved;
|
|
723
|
-
|
|
1130
|
+
const inFlightFinalization = this.finalizations.get(id);
|
|
1131
|
+
if (inFlightFinalization) {
|
|
1132
|
+
const persisted = await inFlightFinalization;
|
|
1133
|
+
return persisted
|
|
1134
|
+
? { ok: true, output: `Job "${id}" completed while stop was requested.` }
|
|
1135
|
+
: { ok: false, output: `Job "${id}" completed, but its terminal state could not be persisted.`, exitCode: 1 };
|
|
1136
|
+
}
|
|
1137
|
+
if (!this.isLive(job))
|
|
724
1138
|
return { ok: false, output: `Job "${id}" is already ${job.status}.`, exitCode: 1 };
|
|
1139
|
+
const pid = job.pid;
|
|
1140
|
+
if (!pid)
|
|
1141
|
+
return { ok: false, output: `Job "${id}" has no process id to stop.`, exitCode: 1 };
|
|
1142
|
+
const processGroupId = process.platform !== "win32" ? job.processGroupId : undefined;
|
|
1143
|
+
const targetAlive = () => {
|
|
1144
|
+
if (!processGroupId)
|
|
1145
|
+
return processAlive(pid);
|
|
1146
|
+
try {
|
|
1147
|
+
process.kill(-processGroupId, 0);
|
|
1148
|
+
return true;
|
|
1149
|
+
}
|
|
1150
|
+
catch (error) {
|
|
1151
|
+
return error.code === "EPERM";
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
const identityMatches = () => {
|
|
1155
|
+
if (process.platform === "win32")
|
|
1156
|
+
return processAlive(pid);
|
|
1157
|
+
const current = processIdentity(pid);
|
|
1158
|
+
return Boolean(current && job.processIdentity && current === job.processIdentity);
|
|
1159
|
+
};
|
|
1160
|
+
if (targetAlive() && (!processAlive(pid) || !identityMatches())) {
|
|
1161
|
+
return {
|
|
1162
|
+
ok: false,
|
|
1163
|
+
output: `Refused to signal job "${id}": its persisted process identity no longer matches pid ${pid}.`,
|
|
1164
|
+
exitCode: 1,
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
const child = this.processes.get(id);
|
|
1168
|
+
const childClose = child
|
|
1169
|
+
? new Promise((resolve) => child.once("close", () => resolve()))
|
|
1170
|
+
: undefined;
|
|
725
1171
|
this.clearJobDeadline(id);
|
|
1172
|
+
const previousStatus = job.status;
|
|
1173
|
+
const previousWakeOnCompletion = job.wakeOnCompletion;
|
|
1174
|
+
if (options?.suppressWake)
|
|
1175
|
+
job.wakeOnCompletion = false;
|
|
726
1176
|
job.status = "stopping";
|
|
727
|
-
|
|
1177
|
+
if (!this.persistSync()) {
|
|
1178
|
+
job.status = previousStatus;
|
|
1179
|
+
job.wakeOnCompletion = previousWakeOnCompletion;
|
|
1180
|
+
if (!job.timeoutAt || Date.parse(job.timeoutAt) > Date.now())
|
|
1181
|
+
this.scheduleJobDeadline(job);
|
|
1182
|
+
return { ok: false, output: `Failed to persist stop state for job "${id}"; no signal was sent.`, exitCode: 1 };
|
|
1183
|
+
}
|
|
1184
|
+
this.emit({ type: "job", jobId: id });
|
|
728
1185
|
this.abortControllers.get(id)?.abort();
|
|
729
|
-
|
|
730
|
-
const send = (signal) => {
|
|
731
|
-
if (!
|
|
732
|
-
return
|
|
1186
|
+
let processGroupVerified = false;
|
|
1187
|
+
const send = (signal, allowVerifiedGroup) => {
|
|
1188
|
+
if (!targetAlive())
|
|
1189
|
+
return "gone";
|
|
1190
|
+
if (processAlive(pid) && identityMatches()) {
|
|
1191
|
+
processGroupVerified = true;
|
|
1192
|
+
}
|
|
1193
|
+
else if (!(processGroupId && processGroupVerified && allowVerifiedGroup)) {
|
|
1194
|
+
return "identity-mismatch";
|
|
1195
|
+
}
|
|
733
1196
|
try {
|
|
734
|
-
|
|
735
|
-
return
|
|
1197
|
+
processGroupId ? process.kill(-processGroupId, signal) : process.kill(pid, signal);
|
|
1198
|
+
return "sent";
|
|
736
1199
|
}
|
|
737
1200
|
catch {
|
|
738
|
-
return
|
|
1201
|
+
return targetAlive() ? "failed" : "gone";
|
|
739
1202
|
}
|
|
740
1203
|
};
|
|
741
|
-
const
|
|
742
|
-
if (!send(graceful) && processAlive(pid)) {
|
|
1204
|
+
const restoreRunning = () => {
|
|
743
1205
|
job.status = "running";
|
|
744
1206
|
if (!job.timeoutAt || Date.parse(job.timeoutAt) > Date.now())
|
|
745
1207
|
this.scheduleJobDeadline(job);
|
|
746
|
-
|
|
747
|
-
|
|
1208
|
+
this.persistSync();
|
|
1209
|
+
this.emit({ type: "job", jobId: id });
|
|
1210
|
+
};
|
|
1211
|
+
const graceful = options?.signal ?? "SIGTERM";
|
|
1212
|
+
const firstSignal = send(graceful, false);
|
|
1213
|
+
if (firstSignal === "identity-mismatch" || firstSignal === "failed") {
|
|
1214
|
+
restoreRunning();
|
|
1215
|
+
return {
|
|
1216
|
+
ok: false,
|
|
1217
|
+
output: firstSignal === "identity-mismatch"
|
|
1218
|
+
? `Refused to signal job "${id}": process identity changed immediately before stop.`
|
|
1219
|
+
: `Failed to signal job "${id}".`,
|
|
1220
|
+
exitCode: 1,
|
|
1221
|
+
};
|
|
748
1222
|
}
|
|
749
1223
|
const waitForExit = async (ms) => {
|
|
750
1224
|
const deadline = Date.now() + ms;
|
|
751
1225
|
while (Date.now() < deadline) {
|
|
752
|
-
if (!
|
|
1226
|
+
if (!targetAlive())
|
|
753
1227
|
return true;
|
|
754
1228
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
755
1229
|
}
|
|
756
|
-
return !
|
|
1230
|
+
return !targetAlive();
|
|
757
1231
|
};
|
|
758
|
-
let stopped = await waitForExit(options?.graceMs ?? 2_000);
|
|
1232
|
+
let stopped = firstSignal === "gone" || await waitForExit(options?.graceMs ?? 2_000);
|
|
759
1233
|
let actualSignal = graceful;
|
|
760
1234
|
if (!stopped && options?.escalate !== false) {
|
|
761
1235
|
actualSignal = "SIGKILL";
|
|
762
|
-
send(actualSignal);
|
|
763
|
-
|
|
1236
|
+
const escalated = send(actualSignal, true);
|
|
1237
|
+
if (escalated === "identity-mismatch" || escalated === "failed") {
|
|
1238
|
+
restoreRunning();
|
|
1239
|
+
return {
|
|
1240
|
+
ok: false,
|
|
1241
|
+
output: escalated === "identity-mismatch"
|
|
1242
|
+
? `Refused to escalate job "${id}": process identity changed before SIGKILL.`
|
|
1243
|
+
: `Failed to escalate job "${id}" with SIGKILL.`,
|
|
1244
|
+
exitCode: 1,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
stopped = escalated === "gone" || await waitForExit(1_000);
|
|
764
1248
|
}
|
|
765
1249
|
if (!stopped) {
|
|
766
|
-
|
|
767
|
-
if (!job.timeoutAt || Date.parse(job.timeoutAt) > Date.now())
|
|
768
|
-
this.scheduleJobDeadline(job);
|
|
769
|
-
await this.persist();
|
|
1250
|
+
restoreRunning();
|
|
770
1251
|
return { ok: false, output: `Job "${id}" remains alive after ${actualSignal}.`, exitCode: 1 };
|
|
771
1252
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
this.
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
this.
|
|
779
|
-
|
|
780
|
-
|
|
1253
|
+
if (childClose)
|
|
1254
|
+
await childClose;
|
|
1255
|
+
let terminalPersisted = true;
|
|
1256
|
+
const eventFinalization = this.finalizations.get(id);
|
|
1257
|
+
if (eventFinalization)
|
|
1258
|
+
terminalPersisted = await eventFinalization;
|
|
1259
|
+
if (this.isLive(job)) {
|
|
1260
|
+
terminalPersisted = await this.finalizeJob(job, "killed", { signal: actualSignal });
|
|
1261
|
+
}
|
|
1262
|
+
if (!terminalPersisted) {
|
|
1263
|
+
return {
|
|
1264
|
+
ok: false,
|
|
1265
|
+
output: `Job "${id}" stopped, but its terminal state and completion notification could not be persisted.`,
|
|
1266
|
+
exitCode: 1,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
781
1269
|
return { ok: true, output: `Job "${id}" stopped and termination verified (${actualSignal}).` };
|
|
782
1270
|
}
|
|
1271
|
+
async cancelAll(sessionId) {
|
|
1272
|
+
if (!sessionId) {
|
|
1273
|
+
return { ok: false, output: "cancelAll requires a non-empty session id.", exitCode: 1 };
|
|
1274
|
+
}
|
|
1275
|
+
const targets = [...this.jobs.values()].filter((job) => this.isDurable(job) && job.ownerSessionId === sessionId && this.isLive(job));
|
|
1276
|
+
const priorWake = new Map(targets.map((job) => [job.id, job.wakeOnCompletion]));
|
|
1277
|
+
for (const job of targets) {
|
|
1278
|
+
job.wakeOnCompletion = false;
|
|
1279
|
+
const notification = this.notificationForJob(job.id);
|
|
1280
|
+
if (notification)
|
|
1281
|
+
notification.wakeOnCompletion = false;
|
|
1282
|
+
}
|
|
1283
|
+
if (!this.persistSync()) {
|
|
1284
|
+
for (const job of targets)
|
|
1285
|
+
job.wakeOnCompletion = priorWake.get(job.id);
|
|
1286
|
+
return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
output: `Failed to persist wake suppression for session ${sessionId}; no jobs were cancelled.`,
|
|
1289
|
+
exitCode: 1,
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
for (const job of targets)
|
|
1293
|
+
this.emit({ type: "job", jobId: job.id });
|
|
1294
|
+
const outcomes = await Promise.all(targets.map(async (job) => {
|
|
1295
|
+
const current = this.jobs.get(job.id);
|
|
1296
|
+
if (!current || !this.isLive(current))
|
|
1297
|
+
return { id: job.id, ok: true, output: "already terminal" };
|
|
1298
|
+
try {
|
|
1299
|
+
const result = await this.stopJob(job.id, { suppressWake: true });
|
|
1300
|
+
return { id: job.id, ok: result.ok, output: result.output };
|
|
1301
|
+
}
|
|
1302
|
+
catch (error) {
|
|
1303
|
+
return { id: job.id, ok: false, output: error instanceof Error ? error.message : String(error) };
|
|
1304
|
+
}
|
|
1305
|
+
}));
|
|
1306
|
+
const acknowledgedAt = new Date().toISOString();
|
|
1307
|
+
const notificationSnapshots = [];
|
|
1308
|
+
for (const notification of this.notifications.values()) {
|
|
1309
|
+
if (notification.ownerSessionId !== sessionId || notification.acknowledgedAt)
|
|
1310
|
+
continue;
|
|
1311
|
+
notificationSnapshots.push({
|
|
1312
|
+
notification,
|
|
1313
|
+
wakeOnCompletion: notification.wakeOnCompletion,
|
|
1314
|
+
deliveredAt: notification.deliveredAt,
|
|
1315
|
+
acknowledgedAt: notification.acknowledgedAt,
|
|
1316
|
+
});
|
|
1317
|
+
notification.wakeOnCompletion = false;
|
|
1318
|
+
notification.deliveredAt ??= acknowledgedAt;
|
|
1319
|
+
notification.acknowledgedAt = acknowledgedAt;
|
|
1320
|
+
}
|
|
1321
|
+
const acknowledgementsPersisted = this.persistSync();
|
|
1322
|
+
if (!acknowledgementsPersisted) {
|
|
1323
|
+
for (const snapshot of notificationSnapshots) {
|
|
1324
|
+
snapshot.notification.wakeOnCompletion = snapshot.wakeOnCompletion;
|
|
1325
|
+
snapshot.notification.deliveredAt = snapshot.deliveredAt;
|
|
1326
|
+
snapshot.notification.acknowledgedAt = snapshot.acknowledgedAt;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
else {
|
|
1330
|
+
for (const snapshot of notificationSnapshots) {
|
|
1331
|
+
this.emit({
|
|
1332
|
+
type: "notification",
|
|
1333
|
+
jobId: snapshot.notification.jobId,
|
|
1334
|
+
notificationId: snapshot.notification.id,
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
this.pruneTerminalJobs();
|
|
1338
|
+
this.persistSync();
|
|
1339
|
+
}
|
|
1340
|
+
const failures = outcomes.filter((outcome) => !outcome.ok);
|
|
1341
|
+
if (failures.length > 0 || !acknowledgementsPersisted) {
|
|
1342
|
+
const details = failures.map((failure) => `[${failure.id}] ${failure.output}`);
|
|
1343
|
+
if (!acknowledgementsPersisted)
|
|
1344
|
+
details.push("Completion notifications could not be durably acknowledged.");
|
|
1345
|
+
return {
|
|
1346
|
+
ok: false,
|
|
1347
|
+
output: `Cancelled ${outcomes.length - failures.length}/${outcomes.length} background job(s) for session ${sessionId}; ` +
|
|
1348
|
+
`${details.length} failure(s):\n${details.join("\n")}`,
|
|
1349
|
+
exitCode: 1,
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
ok: true,
|
|
1354
|
+
output: targets.length === 0
|
|
1355
|
+
? `No running background jobs for session ${sessionId}; pending notifications were acknowledged.`
|
|
1356
|
+
: `Cancelled ${targets.length} background job(s) for session ${sessionId}; wake notifications were suppressed and acknowledged.`,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
783
1359
|
getRunningJobs(sessionId) {
|
|
784
1360
|
for (const job of this.jobs.values())
|
|
785
1361
|
this.refreshJobLiveness(job);
|
|
@@ -798,6 +1374,13 @@ export class JobManager {
|
|
|
798
1374
|
/** Drop stale terminal durable jobs and all leftover ephemeral rows. */
|
|
799
1375
|
pruneTerminalJobs() {
|
|
800
1376
|
const now = Date.now();
|
|
1377
|
+
for (const [id, notification] of this.notifications) {
|
|
1378
|
+
const job = this.jobs.get(notification.jobId);
|
|
1379
|
+
if (!job ||
|
|
1380
|
+
(notification.acknowledgedAt && job.status !== "lost")) {
|
|
1381
|
+
this.notifications.delete(id);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
801
1384
|
const durableTerminal = [];
|
|
802
1385
|
for (const [id, job] of this.jobs) {
|
|
803
1386
|
if (!this.isDurable(job)) {
|
|
@@ -808,6 +1391,9 @@ export class JobManager {
|
|
|
808
1391
|
}
|
|
809
1392
|
if (this.isLive(job))
|
|
810
1393
|
continue;
|
|
1394
|
+
const notification = this.notificationForJob(job.id);
|
|
1395
|
+
if (notification && !notification.acknowledgedAt)
|
|
1396
|
+
continue;
|
|
811
1397
|
const ended = job.endedAt ? Date.parse(job.endedAt) : Date.parse(job.startedAt);
|
|
812
1398
|
if (Number.isFinite(ended) && now - ended > TERMINAL_JOB_MAX_AGE_MS) {
|
|
813
1399
|
this.jobs.delete(id);
|
|
@@ -824,17 +1410,34 @@ export class JobManager {
|
|
|
824
1410
|
}
|
|
825
1411
|
loadAndReconcile() {
|
|
826
1412
|
try {
|
|
827
|
-
|
|
1413
|
+
let parsed;
|
|
1414
|
+
const sourcePath = existsSync(this.registryPath)
|
|
1415
|
+
? this.registryPath
|
|
1416
|
+
: this.transientV2RegistryPath;
|
|
1417
|
+
if (!existsSync(sourcePath))
|
|
828
1418
|
return;
|
|
829
|
-
|
|
830
|
-
|
|
1419
|
+
try {
|
|
1420
|
+
const candidate = JSON.parse(readFileSync(sourcePath, "utf8"));
|
|
1421
|
+
if ((candidate.schemaVersion === 1 || candidate.schemaVersion === 2) && Array.isArray(candidate.jobs)) {
|
|
1422
|
+
parsed = candidate;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
catch { /* a corrupt current registry must not replay stale v1 state */ }
|
|
1426
|
+
if (!parsed)
|
|
831
1427
|
return;
|
|
1428
|
+
if (parsed.schemaVersion === 2 && Array.isArray(parsed.notifications)) {
|
|
1429
|
+
const notifiedJobs = new Set();
|
|
1430
|
+
for (const notification of parsed.notifications) {
|
|
1431
|
+
if (!notification || typeof notification.id !== "string" || typeof notification.jobId !== "string")
|
|
1432
|
+
continue;
|
|
1433
|
+
if (notifiedJobs.has(notification.jobId))
|
|
1434
|
+
continue;
|
|
1435
|
+
notifiedJobs.add(notification.jobId);
|
|
1436
|
+
this.notifications.set(notification.id, notification);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
832
1439
|
for (const job of parsed.jobs) {
|
|
833
|
-
|
|
834
|
-
// background jobs (command like "fs.list /path"). Drop them on load.
|
|
835
|
-
if (job.kind === "ephemeral")
|
|
836
|
-
continue;
|
|
837
|
-
if (looksLikeEphemeralToolTrack(job))
|
|
1440
|
+
if (job.kind === "ephemeral" || looksLikeEphemeralToolTrack(job))
|
|
838
1441
|
continue;
|
|
839
1442
|
job.kind = "durable";
|
|
840
1443
|
if (["starting", "running", "stopping"].includes(job.status)) {
|
|
@@ -844,39 +1447,58 @@ export class JobManager {
|
|
|
844
1447
|
job.endedAt = new Date().toISOString();
|
|
845
1448
|
}
|
|
846
1449
|
else {
|
|
847
|
-
// The process identity is still verified. Restore any persisted
|
|
848
|
-
// deadline after insertion; an already-expired deadline schedules
|
|
849
|
-
// immediate stopJob(), which verifies termination before marking it.
|
|
850
1450
|
job.status = "running";
|
|
851
1451
|
job.heartbeatAt = new Date().toISOString();
|
|
852
1452
|
}
|
|
853
1453
|
}
|
|
854
1454
|
this.jobs.set(job.id, job);
|
|
1455
|
+
this.ensureCompletionNotification(job);
|
|
855
1456
|
this.scheduleJobDeadline(job);
|
|
856
1457
|
}
|
|
1458
|
+
for (const [id, notification] of this.notifications) {
|
|
1459
|
+
const job = this.jobs.get(notification.jobId);
|
|
1460
|
+
if (!job?.responder)
|
|
1461
|
+
this.notifications.delete(id);
|
|
1462
|
+
}
|
|
857
1463
|
this.pruneTerminalJobs();
|
|
858
1464
|
this.persistSync();
|
|
859
1465
|
}
|
|
860
1466
|
catch { /* Corrupt registries are ignored, never trusted as running. */ }
|
|
861
1467
|
}
|
|
862
|
-
/** Only durable jobs are written to disk. */
|
|
1468
|
+
/** Only durable jobs and responder notifications are written to disk. */
|
|
863
1469
|
registry() {
|
|
864
1470
|
return {
|
|
865
|
-
schemaVersion:
|
|
866
|
-
jobs: [...this.jobs.values()].filter((
|
|
1471
|
+
schemaVersion: 2,
|
|
1472
|
+
jobs: [...this.jobs.values()].filter((job) => this.isDurable(job)),
|
|
1473
|
+
notifications: [...this.notifications.values()],
|
|
867
1474
|
};
|
|
868
1475
|
}
|
|
1476
|
+
scheduleRegistryRetry() {
|
|
1477
|
+
if (this.registryRetryTimer)
|
|
1478
|
+
return;
|
|
1479
|
+
const timer = setTimeout(() => {
|
|
1480
|
+
this.registryRetryTimer = undefined;
|
|
1481
|
+
if (!this.persistSync())
|
|
1482
|
+
this.scheduleRegistryRetry();
|
|
1483
|
+
}, 250);
|
|
1484
|
+
timer.unref?.();
|
|
1485
|
+
this.registryRetryTimer = timer;
|
|
1486
|
+
}
|
|
869
1487
|
persistSync() {
|
|
870
1488
|
try {
|
|
871
1489
|
mkdirSync(this.jobsDir, { recursive: true });
|
|
872
1490
|
const temp = `${this.registryPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
873
1491
|
writeFileSync(temp, `${JSON.stringify(this.registry(), null, 2)}\n`, { mode: 0o600 });
|
|
874
1492
|
renameSync(temp, this.registryPath);
|
|
1493
|
+
return true;
|
|
1494
|
+
}
|
|
1495
|
+
catch {
|
|
1496
|
+
return false;
|
|
875
1497
|
}
|
|
876
|
-
catch { /* best effort during constructor reconciliation */ }
|
|
877
1498
|
}
|
|
878
1499
|
async persist() {
|
|
879
|
-
this.persistSync()
|
|
1500
|
+
if (!this.persistSync())
|
|
1501
|
+
throw new Error("Failed to persist the background-job registry.");
|
|
880
1502
|
}
|
|
881
1503
|
}
|
|
882
1504
|
export const jobManager = new JobManager();
|