pi-better-background-tasks 0.2.20 → 0.3.0
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 +5 -2
- package/package.json +1 -1
- package/src/failures.ts +25 -4
- package/src/logs.ts +51 -7
- package/src/navigator-provider.ts +2 -2
- package/src/output.ts +753 -0
- package/src/process.ts +95 -8
- package/src/registry.ts +84 -7
- package/src/runtime.ts +55 -16
- package/src/shared-callback-batcher.ts +327 -29
- package/src/shared-failure-observations.ts +501 -22
- package/src/shared-log-utils.ts +1025 -2
- package/src/tools.ts +105 -135
- package/src/types.ts +16 -0
package/src/process.ts
CHANGED
|
@@ -183,14 +183,15 @@ export function runCommandOnce(
|
|
|
183
183
|
validateCommandSpec(spec);
|
|
184
184
|
const startedAt = Date.now();
|
|
185
185
|
const child = spawnArgs(spec, true, ["ignore", "pipe", "pipe"]);
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
const cap = Math.max(1, Math.floor(maxBufferBytes));
|
|
187
|
+
const stdoutCapture = createCaptureBuffer();
|
|
188
|
+
const stderrCapture = createCaptureBuffer();
|
|
188
189
|
let timedOut = false;
|
|
189
|
-
child.stdout?.on("data", (chunk: Buffer) => {
|
|
190
|
-
|
|
190
|
+
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
191
|
+
captureChunk(stdoutCapture, chunk, cap);
|
|
191
192
|
});
|
|
192
|
-
child.stderr?.on("data", (chunk: Buffer) => {
|
|
193
|
-
|
|
193
|
+
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
194
|
+
captureChunk(stderrCapture, chunk, cap);
|
|
194
195
|
});
|
|
195
196
|
return new Promise((resolve, reject) => {
|
|
196
197
|
// The group outlives the leader as long as any member is alive, so the
|
|
@@ -239,19 +240,105 @@ export function runCommandOnce(
|
|
|
239
240
|
});
|
|
240
241
|
child.on("close", (exitCode, signal) => {
|
|
241
242
|
settle();
|
|
243
|
+
const stdout = finishCapture(stdoutCapture);
|
|
244
|
+
const stderr = finishCapture(stderrCapture);
|
|
245
|
+
const captureTruncated = stdout.discardedBytes > 0 || stderr.discardedBytes > 0;
|
|
242
246
|
resolve({
|
|
243
247
|
exitCode,
|
|
244
248
|
signal,
|
|
245
|
-
stdout,
|
|
246
|
-
stderr,
|
|
249
|
+
stdout: stdout.text,
|
|
250
|
+
stderr: stderr.text,
|
|
247
251
|
startedAt,
|
|
248
252
|
endedAt: Date.now(),
|
|
249
253
|
...(timedOut ? { timedOut: true } : {}),
|
|
254
|
+
...(stdout.discardedBytes > 0 ? { stdoutDiscardedBytes: stdout.discardedBytes } : {}),
|
|
255
|
+
...(stderr.discardedBytes > 0 ? { stderrDiscardedBytes: stderr.discardedBytes } : {}),
|
|
256
|
+
...(captureTruncated ? { captureTruncated: true } : {}),
|
|
250
257
|
});
|
|
251
258
|
});
|
|
252
259
|
});
|
|
253
260
|
}
|
|
254
261
|
|
|
262
|
+
interface CaptureBuffer {
|
|
263
|
+
chunks: Buffer[];
|
|
264
|
+
storedBytes: number;
|
|
265
|
+
discardedBytes: number;
|
|
266
|
+
truncated: boolean;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function createCaptureBuffer(): CaptureBuffer {
|
|
270
|
+
return { chunks: [], storedBytes: 0, discardedBytes: 0, truncated: false };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function asBuffer(chunk: Buffer | string): Buffer {
|
|
274
|
+
return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Largest UTF-8 prefix of `buffer` that fits in `maxBytes`. */
|
|
278
|
+
export function utf8PrefixLength(buffer: Buffer, maxBytes: number): number {
|
|
279
|
+
if (maxBytes <= 0) return 0;
|
|
280
|
+
if (buffer.length <= maxBytes) return buffer.length;
|
|
281
|
+
let end = maxBytes;
|
|
282
|
+
while (end > 0 && (buffer[end]! & 0xc0) === 0x80) end -= 1;
|
|
283
|
+
if (end === 0 && (buffer[0]! & 0xc0) === 0x80) return 0;
|
|
284
|
+
const lead = buffer[end]!;
|
|
285
|
+
const need = lead <= 0x7f ? 1
|
|
286
|
+
: (lead & 0xe0) === 0xc0 ? 2
|
|
287
|
+
: (lead & 0xf0) === 0xe0 ? 3
|
|
288
|
+
: (lead & 0xf8) === 0xf0 ? 4
|
|
289
|
+
: 1;
|
|
290
|
+
return end + need <= maxBytes ? end + need : end;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function captureChunk(target: CaptureBuffer, chunk: Buffer | string, maxBytes: number): void {
|
|
294
|
+
const buffer = asBuffer(chunk);
|
|
295
|
+
if (target.truncated) {
|
|
296
|
+
target.discardedBytes += buffer.length;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const room = maxBytes - target.storedBytes;
|
|
300
|
+
if (buffer.length <= room) {
|
|
301
|
+
target.chunks.push(buffer);
|
|
302
|
+
target.storedBytes += buffer.length;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const take = utf8PrefixLength(buffer, room);
|
|
306
|
+
if (take > 0) {
|
|
307
|
+
target.chunks.push(buffer.subarray(0, take));
|
|
308
|
+
target.storedBytes += take;
|
|
309
|
+
}
|
|
310
|
+
target.discardedBytes += buffer.length - take;
|
|
311
|
+
target.truncated = true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Length of `buffer` without a trailing UTF-8 sequence that the cap cut short.
|
|
316
|
+
* Chunk boundaries can split a code point; once capture overflows, the bytes
|
|
317
|
+
* that would have completed it were discarded, so the dangling lead bytes are
|
|
318
|
+
* discarded too instead of decoding to U+FFFD.
|
|
319
|
+
*/
|
|
320
|
+
export function completeUtf8Length(buffer: Buffer): number {
|
|
321
|
+
let start = buffer.length - 1;
|
|
322
|
+
while (start >= 0 && buffer.length - start < 4 && (buffer[start]! & 0xc0) === 0x80) start -= 1;
|
|
323
|
+
if (start < 0) return buffer.length;
|
|
324
|
+
const lead = buffer[start]!;
|
|
325
|
+
const need = lead <= 0x7f ? 1
|
|
326
|
+
: (lead & 0xe0) === 0xc0 ? 2
|
|
327
|
+
: (lead & 0xf0) === 0xe0 ? 3
|
|
328
|
+
: (lead & 0xf8) === 0xf0 ? 4
|
|
329
|
+
: 1;
|
|
330
|
+
return start + need > buffer.length ? start : buffer.length;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function finishCapture(target: CaptureBuffer): { text: string; discardedBytes: number } {
|
|
334
|
+
const stored = Buffer.concat(target.chunks, target.storedBytes);
|
|
335
|
+
const keep = target.truncated ? completeUtf8Length(stored) : stored.length;
|
|
336
|
+
return {
|
|
337
|
+
text: stored.subarray(0, keep).toString("utf8"),
|
|
338
|
+
discardedBytes: target.discardedBytes + (stored.length - keep),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
255
342
|
/**
|
|
256
343
|
* Stop a task's process tree.
|
|
257
344
|
*
|
package/src/registry.ts
CHANGED
|
@@ -92,15 +92,92 @@ export function onMetaChanged(listener: () => void): () => void {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
export function readMeta(id: string): BackgroundTaskMeta | undefined {
|
|
95
|
+
return inspectMeta(id).meta;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface MetaInspection {
|
|
99
|
+
id: string;
|
|
100
|
+
meta?: BackgroundTaskMeta;
|
|
101
|
+
found: boolean;
|
|
102
|
+
readable: boolean;
|
|
103
|
+
error?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function errorText(error: unknown): string {
|
|
107
|
+
return error instanceof Error ? error.message : String(error);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Parsed JSON that lacks the identifying fields is corrupt, not a task. */
|
|
111
|
+
function metaShapeProblem(meta: unknown, id: string): string | undefined {
|
|
112
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return "not an object";
|
|
113
|
+
const value = meta as Partial<BackgroundTaskMeta>;
|
|
114
|
+
if (value.id !== id) return `id ${JSON.stringify(value.id ?? null)} does not match ${JSON.stringify(id)}`;
|
|
115
|
+
if (typeof value.status !== "string" || !value.status) return "missing status";
|
|
116
|
+
if (typeof value.logPath !== "string" || !value.logPath) return "missing logPath";
|
|
117
|
+
if (typeof value.startedAt !== "number" || !Number.isFinite(value.startedAt)) return "missing startedAt";
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Distinguish missing, unreadable, and healthy metadata. Callers must not treat
|
|
123
|
+
* an unreadable file as proof that a task does not exist.
|
|
124
|
+
*/
|
|
125
|
+
export function inspectMeta(id: string): MetaInspection {
|
|
126
|
+
const path = metaPathFor(id);
|
|
95
127
|
try {
|
|
96
128
|
registryIo.metadataFileReads += 1;
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
const raw = readFileSync(path, "utf8");
|
|
130
|
+
try {
|
|
131
|
+
const meta = JSON.parse(raw) as BackgroundTaskMeta;
|
|
132
|
+
const invalid = metaShapeProblem(meta, id);
|
|
133
|
+
if (invalid) {
|
|
134
|
+
metaCache.delete(id);
|
|
135
|
+
return { id, found: true, readable: false, error: `invalid metadata: ${invalid}` };
|
|
136
|
+
}
|
|
137
|
+
metaCache.set(id, meta);
|
|
138
|
+
return { id, meta, found: true, readable: true };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
metaCache.delete(id);
|
|
141
|
+
return { id, found: true, readable: false, error: `invalid JSON: ${errorText(error)}` };
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
101
144
|
metaCache.delete(id);
|
|
102
|
-
|
|
145
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
146
|
+
if (code === "ENOENT") {
|
|
147
|
+
try {
|
|
148
|
+
statSync(taskDir(id));
|
|
149
|
+
return { id, found: true, readable: false, error: "metadata file is missing" };
|
|
150
|
+
} catch {
|
|
151
|
+
return { id, found: false, readable: false };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { id, found: true, readable: false, error: errorText(error) };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface TaskIndex {
|
|
159
|
+
records: MetaInspection[];
|
|
160
|
+
indexError?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** List every task directory, including unreadable metadata. */
|
|
164
|
+
export function listTaskRecords(): TaskIndex {
|
|
165
|
+
let ids: string[];
|
|
166
|
+
try {
|
|
167
|
+
registryIo.fullDirectoryReads += 1;
|
|
168
|
+
ids = readdirSync(tasksDir());
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
171
|
+
if (code === "ENOENT") return { records: [] };
|
|
172
|
+
return { records: [], indexError: errorText(error) };
|
|
103
173
|
}
|
|
174
|
+
return {
|
|
175
|
+
records: ids.map((id) => inspectMeta(id)).sort((a, b) => {
|
|
176
|
+
const aTime = a.meta?.startedAt ?? 0;
|
|
177
|
+
const bTime = b.meta?.startedAt ?? 0;
|
|
178
|
+
return bTime - aTime;
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
104
181
|
}
|
|
105
182
|
|
|
106
183
|
export function removeMeta(meta: BackgroundTaskMeta): boolean {
|
|
@@ -164,11 +241,11 @@ function readOwnedMeta(id: string): BackgroundTaskMeta | undefined {
|
|
|
164
241
|
return metaCache.get(id) ?? readMeta(id);
|
|
165
242
|
}
|
|
166
243
|
|
|
167
|
-
function originOf(meta: BackgroundTaskMeta): BackgroundTaskCallbackOrigin {
|
|
244
|
+
export function originOf(meta: BackgroundTaskMeta): BackgroundTaskCallbackOrigin {
|
|
168
245
|
return meta.callbackOrigin ?? { cwd: meta.cwd };
|
|
169
246
|
}
|
|
170
247
|
|
|
171
|
-
function belongsToOrigin(meta: BackgroundTaskMeta, origin: BackgroundTaskCallbackOrigin): boolean {
|
|
248
|
+
export function belongsToOrigin(meta: BackgroundTaskMeta, origin: BackgroundTaskCallbackOrigin): boolean {
|
|
172
249
|
const candidate = originOf(meta);
|
|
173
250
|
if (candidate.cwd !== origin.cwd) return false;
|
|
174
251
|
if (candidate.sessionId || origin.sessionId) return candidate.sessionId === origin.sessionId;
|
package/src/runtime.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { confineCommandSpec, resolveForegroundSandboxPlan } from "./sandbox.js";
|
|
|
11
11
|
import { failurePath, recordFailure, recoverFailure, scheduleFailureAttention, stopFailureAttention, terminalFailureAttention } from "./failures.js";
|
|
12
12
|
import { markFailureAttentionDelivered } from "./shared-failure-observations.js";
|
|
13
13
|
import { getCallbackBatcher } from "./shared-callback-batcher.js";
|
|
14
|
+
import { formatCallbackFacts } from "./output.js";
|
|
14
15
|
import type {
|
|
15
16
|
BackgroundTaskCallbackOrigin,
|
|
16
17
|
BackgroundTaskMeta,
|
|
@@ -487,9 +488,7 @@ export async function stopTask(
|
|
|
487
488
|
if (remoteSessionMayExist) {
|
|
488
489
|
const remoteTask = activeRemoteTasks.get(id);
|
|
489
490
|
if (!remoteTask) {
|
|
490
|
-
meta.
|
|
491
|
-
meta.error = `Cannot stop remote tmux session ${remote.sessionName}: its active SSH controller is unavailable.`;
|
|
492
|
-
writeMeta(meta);
|
|
491
|
+
recordStopError(meta, `Cannot stop remote tmux session ${remote.sessionName}: its active SSH controller is unavailable.`);
|
|
493
492
|
scheduleRemoteSessionPoll(pi, id, REMOTE_SESSION_POLL_MS, getActiveSession);
|
|
494
493
|
return meta;
|
|
495
494
|
}
|
|
@@ -497,18 +496,14 @@ export async function stopTask(
|
|
|
497
496
|
const stopped = await remoteTask.killTmuxSession();
|
|
498
497
|
if (stopped.exitCode !== 0) {
|
|
499
498
|
const detail = stopped.stderr.trim() || stopped.stdout.trim() || "remote tmux returned no diagnostic";
|
|
500
|
-
meta.
|
|
501
|
-
meta.error = `Could not kill remote tmux session ${remote.sessionName} on ${meta.ssh?.target} (exit ${stopped.exitCode ?? "unknown"}): ${detail}`;
|
|
502
|
-
writeMeta(meta);
|
|
499
|
+
recordStopError(meta, `Could not kill remote tmux session ${remote.sessionName} on ${meta.ssh?.target} (exit ${stopped.exitCode ?? "unknown"}): ${detail}`);
|
|
503
500
|
scheduleRemoteSessionPoll(pi, id, REMOTE_SESSION_POLL_MS, getActiveSession);
|
|
504
501
|
return meta;
|
|
505
502
|
}
|
|
506
503
|
remote.stopMessage = `Killed remote tmux session ${remote.sessionName} on ${meta.ssh?.target}.`;
|
|
507
504
|
appendLine(meta.logPath, `--- ${remote.stopMessage} ---`);
|
|
508
505
|
} catch (error) {
|
|
509
|
-
meta.
|
|
510
|
-
meta.error = error instanceof Error ? error.message : String(error);
|
|
511
|
-
writeMeta(meta);
|
|
506
|
+
recordStopError(meta, error instanceof Error ? error.message : String(error));
|
|
512
507
|
scheduleRemoteSessionPoll(pi, id, REMOTE_SESSION_POLL_MS, getActiveSession);
|
|
513
508
|
return meta;
|
|
514
509
|
}
|
|
@@ -516,9 +511,7 @@ export async function stopTask(
|
|
|
516
511
|
try {
|
|
517
512
|
stopProcessGroup(meta.pid, meta.pgid);
|
|
518
513
|
} catch (error) {
|
|
519
|
-
meta.
|
|
520
|
-
meta.error = error instanceof Error ? error.message : String(error);
|
|
521
|
-
writeMeta(meta);
|
|
514
|
+
recordStopError(meta, error instanceof Error ? error.message : String(error));
|
|
522
515
|
if (meta.deadlineAt && meta.deadlineAt > Date.now()) {
|
|
523
516
|
scheduleProcessTimeout(pi, id, meta.deadlineAt, getActiveSession);
|
|
524
517
|
}
|
|
@@ -528,6 +521,7 @@ export async function stopTask(
|
|
|
528
521
|
|
|
529
522
|
stopFailureAttention(id);
|
|
530
523
|
meta.status = "cancelled";
|
|
524
|
+
meta.stopError = undefined;
|
|
531
525
|
meta.endedAt = Date.now();
|
|
532
526
|
meta.result = {
|
|
533
527
|
reason: meta.remote?.session === "direct"
|
|
@@ -541,6 +535,13 @@ export async function stopTask(
|
|
|
541
535
|
return meta;
|
|
542
536
|
}
|
|
543
537
|
|
|
538
|
+
function recordStopError(meta: BackgroundTaskMeta, message: string): void {
|
|
539
|
+
meta.stopRequestedAt = undefined;
|
|
540
|
+
meta.stopError = message;
|
|
541
|
+
meta.error = message;
|
|
542
|
+
writeMeta(meta);
|
|
543
|
+
}
|
|
544
|
+
|
|
544
545
|
function scheduleWatch(
|
|
545
546
|
pi: ExtensionAPI,
|
|
546
547
|
id: string,
|
|
@@ -583,6 +584,7 @@ async function pollWatch(
|
|
|
583
584
|
appendWatchResult(meta.logPath, result);
|
|
584
585
|
const latest = readMeta(id);
|
|
585
586
|
if (!latest || latest.status !== "running") return;
|
|
587
|
+
applyCaptureOverflow(latest, result);
|
|
586
588
|
enforceLogRetention(latest);
|
|
587
589
|
latest.lastCheckedAt = Date.now();
|
|
588
590
|
latest.lastProgressAt = latest.lastCheckedAt;
|
|
@@ -632,7 +634,13 @@ async function pollWatch(
|
|
|
632
634
|
if (conditionErrors.length) latest.error = conditionErrors.join("; ");
|
|
633
635
|
if (failure?.matched) {
|
|
634
636
|
recordFailure(latest, "failure_when", "failure condition matched", pollKey, { category: "condition", at: result.endedAt });
|
|
635
|
-
finalize(latest, {
|
|
637
|
+
finalize(latest, {
|
|
638
|
+
status: "failed",
|
|
639
|
+
reason: "failure condition matched",
|
|
640
|
+
matchedCondition: latest.failureWhen,
|
|
641
|
+
matchedValue: failure.value,
|
|
642
|
+
commandResult: result,
|
|
643
|
+
}, pi, getActiveSession);
|
|
636
644
|
return;
|
|
637
645
|
}
|
|
638
646
|
if (transportFailure) {
|
|
@@ -647,7 +655,13 @@ async function pollWatch(
|
|
|
647
655
|
return;
|
|
648
656
|
}
|
|
649
657
|
if (success?.matched) {
|
|
650
|
-
finalize(latest, {
|
|
658
|
+
finalize(latest, {
|
|
659
|
+
status: "succeeded",
|
|
660
|
+
reason: "success condition matched",
|
|
661
|
+
matchedCondition: latest.successWhen,
|
|
662
|
+
matchedValue: success.value,
|
|
663
|
+
commandResult: result,
|
|
664
|
+
}, pi, getActiveSession);
|
|
651
665
|
return;
|
|
652
666
|
}
|
|
653
667
|
writeMeta(latest);
|
|
@@ -683,10 +697,12 @@ function finalize(
|
|
|
683
697
|
meta.result = {
|
|
684
698
|
reason: terminal.reason,
|
|
685
699
|
matchedCondition: terminal.matchedCondition,
|
|
700
|
+
matchedValue: terminal.matchedValue,
|
|
686
701
|
exitCode: terminal.commandResult?.exitCode,
|
|
687
702
|
signal: terminal.commandResult?.signal,
|
|
688
703
|
};
|
|
689
704
|
if (terminal.commandResult) {
|
|
705
|
+
applyCaptureOverflow(meta, terminal.commandResult);
|
|
690
706
|
meta.lastExitCode = terminal.commandResult.exitCode;
|
|
691
707
|
meta.lastSignal = terminal.commandResult.signal;
|
|
692
708
|
meta.lastCheckedAt = terminal.commandResult.endedAt;
|
|
@@ -799,8 +815,8 @@ async function finalizeProcessTimeout(
|
|
|
799
815
|
stopProcessGroup(meta.pid, meta.pgid);
|
|
800
816
|
} catch (error) {
|
|
801
817
|
reason = `timeout; could not terminate local process tree: ${readableError(error)}`;
|
|
802
|
-
|
|
803
|
-
|
|
818
|
+
// The task is still running: this is a stop failure, reported as such.
|
|
819
|
+
recordStopError(meta, reason);
|
|
804
820
|
return;
|
|
805
821
|
}
|
|
806
822
|
}
|
|
@@ -837,12 +853,17 @@ async function notifyTerminal(
|
|
|
837
853
|
}
|
|
838
854
|
const pending = terminalFailureAttention(latest.id);
|
|
839
855
|
const label = latest.name ? `${latest.name} (${latest.id})` : latest.id;
|
|
856
|
+
const facts = formatCallbackFacts(latest);
|
|
840
857
|
getCallbackBatcher(pi).enqueue({
|
|
841
858
|
source: "background-task",
|
|
842
859
|
id: latest.id,
|
|
843
860
|
label,
|
|
844
861
|
status: pending ? `${latest.status}: ${pending.summary}` : latest.status,
|
|
845
862
|
detailTool: "bg_task_status",
|
|
863
|
+
outcome: facts.outcome,
|
|
864
|
+
failureRows: facts.failureRows,
|
|
865
|
+
decision: facts.decision,
|
|
866
|
+
incidentCount: facts.incidentCount,
|
|
846
867
|
callback: true,
|
|
847
868
|
isDelivered: () => {
|
|
848
869
|
const current = readMeta(latest.id);
|
|
@@ -950,9 +971,27 @@ function enforceLogRetention(meta: BackgroundTaskMeta): void {
|
|
|
950
971
|
if (!compacted) return;
|
|
951
972
|
meta.logDiscardedBytes = (meta.logDiscardedBytes ?? 0) + compacted.discardedBytes;
|
|
952
973
|
meta.logRetentionEvents = (meta.logRetentionEvents ?? 0) + 1;
|
|
974
|
+
meta.logGeneration = (meta.logGeneration ?? 0) + 1;
|
|
953
975
|
writeMeta(meta);
|
|
954
976
|
}
|
|
955
977
|
|
|
978
|
+
/** Results whose capture loss is already counted; one poll result is counted once. */
|
|
979
|
+
const countedCaptureResults = new WeakSet<CommandResult>();
|
|
980
|
+
|
|
981
|
+
function applyCaptureOverflow(meta: BackgroundTaskMeta, result: CommandResult): void {
|
|
982
|
+
const stdout = result.stdoutDiscardedBytes ?? 0;
|
|
983
|
+
const stderr = result.stderrDiscardedBytes ?? 0;
|
|
984
|
+
if (!result.captureTruncated && stdout === 0 && stderr === 0) return;
|
|
985
|
+
// pollWatch counts a result before evaluating conditions, and finalize sees
|
|
986
|
+
// the same result again when that poll ends the watch.
|
|
987
|
+
if (countedCaptureResults.has(result)) return;
|
|
988
|
+
countedCaptureResults.add(result);
|
|
989
|
+
meta.stdoutDiscardedBytes = (meta.stdoutDiscardedBytes ?? 0) + stdout;
|
|
990
|
+
meta.stderrDiscardedBytes = (meta.stderrDiscardedBytes ?? 0) + stderr;
|
|
991
|
+
meta.captureDiscardedBytes = (meta.captureDiscardedBytes ?? 0) + stdout + stderr;
|
|
992
|
+
meta.captureOverflowEvents = (meta.captureOverflowEvents ?? 0) + 1;
|
|
993
|
+
}
|
|
994
|
+
|
|
956
995
|
function extractLastState(result: { stdout: string }): unknown {
|
|
957
996
|
try {
|
|
958
997
|
return JSON.parse(result.stdout);
|