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/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
- let stdout = "";
187
- let stderr = "";
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
- if (Buffer.byteLength(stdout) < maxBufferBytes) stdout += chunk.toString("utf8");
190
+ child.stdout?.on("data", (chunk: Buffer | string) => {
191
+ captureChunk(stdoutCapture, chunk, cap);
191
192
  });
192
- child.stderr?.on("data", (chunk: Buffer) => {
193
- if (Buffer.byteLength(stderr) < maxBufferBytes) stderr += chunk.toString("utf8");
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 meta = JSON.parse(readFileSync(metaPathFor(id), "utf8")) as BackgroundTaskMeta;
98
- metaCache.set(id, meta);
99
- return meta;
100
- } catch {
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
- return undefined;
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.stopRequestedAt = undefined;
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.stopRequestedAt = undefined;
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.stopRequestedAt = undefined;
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.stopRequestedAt = undefined;
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, { status: "failed", reason: "failure condition matched", matchedCondition: latest.failureWhen, commandResult: result }, pi, getActiveSession);
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, { status: "succeeded", reason: "success condition matched", matchedCondition: latest.successWhen, commandResult: result }, pi, getActiveSession);
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
- meta.error = reason;
803
- writeMeta(meta);
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);