brl-subagent 2.4.0 → 2.5.1

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.
@@ -1,9 +1,9 @@
1
1
  // Purpose: Background execution: SDK sessions (`createAgentSession`), agent records, timeouts, steering, and settle paths.
2
2
  import { randomUUID } from 'crypto';
3
- import { join } from 'path';
4
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
5
- import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats, ErrorCategory } from './types';
6
- import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, classifyTerminalOutcome, isProviderError, coherentFailureReason, SUBAGENT_ABORTED_MESSAGE } from './types';
3
+ import { basename, dirname, join, resolve } from 'path';
4
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'fs';
5
+ import type { BackgroundAgent, AgentStatus, GitMode, RecoveryMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats, ErrorCategory } from './types';
6
+ import { EMPTY_USAGE, classifyError, classifyTerminalOutcome, isProviderError, coherentFailureReason, SUBAGENT_ABORTED_MESSAGE } from './types';
7
7
  import { accumulateUsage } from './runner';
8
8
  import * as eventBus from './event-bus';
9
9
  import * as transcript from './transcript';
@@ -11,8 +11,11 @@ import { createEvent } from './event-bus';
11
11
  import { assertSafeAgentId, sanitizeErrorMessage } from './sanitize';
12
12
  import { wrapTask } from './prompt';
13
13
  import { createLogger } from './logging';
14
- import { getCurrentBranch, createWorkBranch, captureDiff, switchToBranch, deleteBranch, hasUncommittedChanges, getRepoRoot, commitAll, captureWorkingDiff } from './git';
14
+ import { getCurrentBranch, getHeadState, restoreHead, createWorkBranch, captureDiff, deleteBranch, hasUncommittedChanges, getRepoRoot, commitAll, captureWorkingDiff } from './git';
15
+ import type { HeadState } from './git';
15
16
  import { normalizeTimeout, DEFAULT_BACKGROUND_DEADLINE_MS } from './validate';
17
+ import { currentProcessOwner } from './recovery';
18
+ import { persistRunRecord } from './run-registry';
16
19
 
17
20
  const log = createLogger('brl-subagent');
18
21
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
@@ -75,6 +78,90 @@ function ensureStorageDir(): void {
75
78
  mkdirSync(STORAGE_DIR, { recursive: true, mode: 0o700 });
76
79
  }
77
80
 
81
+ // ---------------------------------------------------------------------------
82
+ // Option B U2 (D1/D2): file-backed background sessions + retention.
83
+ //
84
+ // Background runs are persisted by the SDK's SessionManager-created files, one
85
+ // per-run directory BESIDE the agent records (`.pi/subagents/sessions/<id>/`).
86
+ // The directory NAME is the run→file mapping — no new record field is needed,
87
+ // and no resume/re-drive/UI logic lives here (that is U3/U4). The per-run dir is
88
+ // removed when the run reaches a TERMINAL state; a run marked interrupted (U1's
89
+ // boot scan, D3) KEEPS its file until it is resolved by a later unit.
90
+ //
91
+ // Boundaries (stated once, referenced by the PR): no resume (U4); no UI/notice/
92
+ // abandon (U3); background is in-process only; a session file has a single
93
+ // writer (the settled run's own process).
94
+ //
95
+ // D5 measurement (one week of use):
96
+ // du -sh .pi/subagents/sessions
97
+ // find .pi/subagents/sessions -name '*.jsonl' | wc -l
98
+ // The expected steady state is EMPTY between runs (terminal runs prune their
99
+ // dir) plus one dir per currently-interrupted run — a non-empty count of
100
+ // settled runs means a prune path regressed. There is no runtime metric.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /** Subdirectory of the agent-record store that holds per-run session dirs. */
104
+ const SESSIONS_SUBDIR = 'sessions';
105
+
106
+ /**
107
+ * The per-run session directory for `id`: `<STORAGE_DIR>/sessions/<id>`.
108
+ * The id is validated first — this path is fed to SessionManager.create() and
109
+ * to rmSync(), so a traversal id must never reach either.
110
+ */
111
+ export function backgroundSessionDir(id: string): string {
112
+ assertSafeAgentId(id);
113
+ return join(STORAGE_DIR, SESSIONS_SUBDIR, id);
114
+ }
115
+
116
+ /**
117
+ * Remove ONE per-run session directory. Defensive by construction: the target
118
+ * must look like `<...>/sessions/<id>` (basename === id, parent basename ===
119
+ * 'sessions') before rmSync runs, so a caller can never delete a path outside
120
+ * the sessions tree. Idempotent (force: true); best-effort (never throws).
121
+ */
122
+ function pruneSessionDir(id: string, dir: string): void {
123
+ try {
124
+ assertSafeAgentId(id);
125
+ } catch {
126
+ log.error(`Refusing to prune session dir for invalid id: ${id}`);
127
+ return;
128
+ }
129
+ const target = resolve(dir);
130
+ if (basename(target) !== id || basename(dirname(target)) !== SESSIONS_SUBDIR) {
131
+ log.error(`Refusing to prune session dir outside ${SESSIONS_SUBDIR}/: ${target}`);
132
+ return;
133
+ }
134
+ try {
135
+ rmSync(target, { recursive: true, force: true });
136
+ } catch (err) {
137
+ // Pruning is hygiene — it must never break execution or a settle path.
138
+ log.warn(`Failed to prune session dir for ${id}`, {
139
+ error: err instanceof Error ? err.message : String(err),
140
+ });
141
+ }
142
+ }
143
+
144
+ /**
145
+ * U2 (D2): remove the persisted session for a finalized/abandoned background
146
+ * run. Validated (throws on an unsafe id) and idempotent — a missing directory
147
+ * is a success. Exposed for U3's abandon action to reuse; the internal settle
148
+ * path prunes the CAPTURED directory instead so a mid-run STORAGE_DIR change
149
+ * (tests swap it per case) can never mis-target.
150
+ *
151
+ * U3 (D5) documented boundary — abandon after a STORAGE_DIR change: this
152
+ * PUBLIC helper recomputes the root from the CURRENT STORAGE_DIR, so if the
153
+ * root moved between the spawn and the abandon it prunes the new root (a
154
+ * no-op when nothing lives there) and leaves the old-root session dir behind
155
+ * as debris. That is prod-safe today (only tests swap the root; production
156
+ * uses one root per process), so U3 does NOT try to track the captured dir
157
+ * across sessions — the run's `_sessionFile` is live-only process state and is
158
+ * not persisted (D4). Debris, if any, is bounded and inspectable under the old
159
+ * root.
160
+ */
161
+ export function pruneBackgroundSession(id: string): void {
162
+ pruneSessionDir(id, backgroundSessionDir(id));
163
+ }
164
+
78
165
  /**
79
166
  * Persist agent record to disk
80
167
  */
@@ -85,15 +172,17 @@ function persistAgent(agent: BackgroundAgent): void {
85
172
  try {
86
173
  assertSafeAgentId(agent.id);
87
174
  } catch {
88
- console.error(`[brl-subagent] Refusing to persist agent with invalid id: ${agent.id}`);
175
+ log.error(`Refusing to persist agent with invalid id: ${agent.id}`);
89
176
  return;
90
177
  }
91
178
  ensureStorageDir();
92
179
  const filePath = join(STORAGE_DIR, `${agent.id}.json`);
93
180
  // Strip live/non-serializable fields before stringify — `_sessionRef` holds the
94
181
  // ENTIRE live pi session graph (1.4MB+ for an empty session, and uninitialized
95
- // getters like Theme can make JSON.stringify throw).
96
- const { _sessionRef, ...persistable } = agent;
182
+ // getters like Theme can make JSON.stringify throw). `_sessionFile` (U2) is the
183
+ // on-disk session path — live-only bookkeeping for precise pruning, never a
184
+ // persisted record field (D4).
185
+ const { _sessionRef, _sessionFile, ...persistable } = agent;
97
186
  try {
98
187
  // F6 (issue #29): records contain task, error, result.messages (full
99
188
  // conversation) and finalOutput — write owner-only (0o600) so other local
@@ -102,7 +191,7 @@ function persistAgent(agent: BackgroundAgent): void {
102
191
  writeFileSync(filePath, JSON.stringify(persistable, null, 2), { encoding: 'utf-8', mode: 0o600 });
103
192
  } catch (err) {
104
193
  // Log but never throw — persistence must not break execution
105
- console.error(`[brl-subagent] Failed to persist agent ${agent.id}:`, err);
194
+ log.error(`Failed to persist agent ${agent.id}`, { error: err instanceof Error ? err.message : String(err) });
106
195
  }
107
196
  }
108
197
 
@@ -136,6 +225,54 @@ export function getAgent(id: string): BackgroundAgent | null {
136
225
  return agents.get(id) || loadAgent(id);
137
226
  }
138
227
 
228
+ /**
229
+ * Option B U1: every agent record persisted on disk (cross-session — the
230
+ * `.pi/subagents/` store survives a process death; the conductor's session run
231
+ * entries do not). Boot recovery does NOT use this: it sources candidates from
232
+ * the durable run registry and reads background records ONE at a time by id
233
+ * (`recoverInflightRuns`'s `readAgentRecord`), precisely to avoid parsing the
234
+ * whole store; production has no caller for this sweep. Unreadable/corrupt
235
+ * files and unsafe ids are skipped, never thrown.
236
+ */
237
+ export function listPersistedAgents(): BackgroundAgent[] {
238
+ let files: string[];
239
+ try {
240
+ if (!existsSync(STORAGE_DIR)) return [];
241
+ files = readdirSync(STORAGE_DIR);
242
+ } catch {
243
+ return [];
244
+ }
245
+ const records: BackgroundAgent[] = [];
246
+ for (const file of files) {
247
+ if (!file.endsWith('.json')) continue;
248
+ const id = file.slice(0, -'.json'.length);
249
+ try {
250
+ assertSafeAgentId(id);
251
+ } catch {
252
+ continue;
253
+ }
254
+ const record = loadAgent(id);
255
+ if (record) records.push(record);
256
+ }
257
+ return records;
258
+ }
259
+
260
+ /**
261
+ * Option B U1 (D3): read-modify-write the interrupted mark onto a persisted
262
+ * agent record. Idempotent: an already-marked record is returned unchanged.
263
+ * Returns null when the record does not exist.
264
+ */
265
+ export function markAgentInterrupted(id: string, interruptedAt: string): BackgroundAgent | null {
266
+ const agent = getAgent(id);
267
+ if (!agent) return null;
268
+ if (!agent.interruptedAt) {
269
+ agent.interruptedAt = interruptedAt;
270
+ agents.set(id, agent);
271
+ persistAgent(agent);
272
+ }
273
+ return agent;
274
+ }
275
+
139
276
  /**
140
277
  * Update agent status
141
278
  */
@@ -320,15 +457,13 @@ export async function steerAgent(id: string, message: string): Promise<Backgroun
320
457
  }
321
458
 
322
459
  /**
323
- * Get transcript path for an agent
460
+ * Get transcript path for an agent.
461
+ *
462
+ * Issue #276: the format lives in `transcript-path.ts` (the single source).
463
+ * This re-export preserves the historical `session-manager.getTranscriptPath`
464
+ * surface for `index.ts`'s dynamic import and the existing tests.
324
465
  */
325
- export function getTranscriptPath(id: string): string {
326
- // F24: reachable with LLM-controlled ids (get_agent_result). Throw on invalid
327
- // ids — the tool caller surfaces it as an error. (Defense in depth: getAgent
328
- // already validated before this is reached with a live agent.)
329
- assertSafeAgentId(id);
330
- return join('.pi', 'output', `agent-${id}.jsonl`);
331
- }
466
+ export { transcriptPath as getTranscriptPath } from './transcript-path';
332
467
 
333
468
  /**
334
469
  * Spawn a background session using pi's session API
@@ -359,6 +494,15 @@ export async function spawnBackgroundSession(
359
494
  /** Issue #114: per-unit priority — recorded on the run entry + agent record
360
495
  * so the drill-in header and history carry it (absent → no segment). */
361
496
  priority?: string;
497
+ /** Option B U1: durability identity. A retry inherits the original's
498
+ * dispatchId and records resumeOf/attempt; a fresh dispatch omits them. */
499
+ dispatchId?: string;
500
+ resumeOf?: string;
501
+ attempt?: number;
502
+ /** Option B U3 (D4): recovery posture at dispatch — snapshotted onto the
503
+ * agent record + run entry so the boot notice reads the posture the run
504
+ * was actually dispatched under. Absent reads as "offer". */
505
+ recoveryPosture?: RecoveryMode;
362
506
  }
363
507
  ): Promise<BackgroundAgent> {
364
508
  // Serialize access to pi API to prevent concurrent import races
@@ -388,8 +532,16 @@ export async function spawnBackgroundSession(
388
532
  }
389
533
  }
390
534
 
391
- // Create session manager for this background agent
392
- const sessionManager = SessionManager.inMemory(effectiveCwd);
535
+ // Create the session manager for this background agent. U2 (D1): an explicit
536
+ // per-run sessionDir is REQUIRED — `inMemory` persists nothing (no post-hoc
537
+ // inspection, no U4 resume) and the SDK default (`~/.pi/agent/sessions/<cwd-key>/`,
538
+ // passed as `undefined`) would scatter our runs into the global store. The
539
+ // directory lives beside the agent record: `.pi/subagents/sessions/<id>/`.
540
+ const sessionManager = SessionManager.create(effectiveCwd, backgroundSessionDir(id));
541
+ // Capture the exact path the SDK will write. The settle path derives the
542
+ // prune target from THIS (not from a freshly-computed STORAGE_DIR path) so a
543
+ // change of STORAGE_DIR between spawn and settle cannot mis-target the delete.
544
+ const sessionFile: string | undefined = sessionManager.getSessionFile();
393
545
  const settingsManager = SettingsManager.create(effectiveCwd);
394
546
 
395
547
  // E20: Inject the subagent prompt into the session's system prompt.
@@ -469,6 +621,13 @@ export async function spawnBackgroundSession(
469
621
  // Set session name
470
622
  session.setSessionName(`background-${id.slice(0, 8)}`);
471
623
 
624
+ // Option B U1: durability identity. Background runs have no child process
625
+ // (the session is in-process), so there is no childMarker — only the owner
626
+ // identity that a boot scan uses to tell a live conductor from a dead one.
627
+ const dispatchId = params.dispatchId ?? generateUUID();
628
+ const attempt = params.attempt ?? 1;
629
+ const owner = currentProcessOwner();
630
+
472
631
  // Create agent record
473
632
  const agent: BackgroundAgent = {
474
633
  id,
@@ -481,6 +640,15 @@ export async function spawnBackgroundSession(
481
640
  model: params.model || 'unknown',
482
641
  thinkingLevel: params.thinkingLevel || 'medium',
483
642
  priority: params.priority,
643
+ dispatchId,
644
+ resumeOf: params.resumeOf,
645
+ attempt,
646
+ owner,
647
+ // Option B U3 (D4): per-run recovery posture snapshot. Additive — absent
648
+ // reads as "offer" via `runRecoveryPosture`/the notice collector.
649
+ recoveryPosture: params.recoveryPosture,
650
+ // U2: live-only pointer to this run's session file (stripped before persist).
651
+ _sessionFile: sessionFile,
484
652
  };
485
653
 
486
654
  agents.set(id, agent);
@@ -506,13 +674,27 @@ export async function spawnBackgroundSession(
506
674
  priority: params.priority,
507
675
  startedAt: new Date().toISOString(),
508
676
  originalParams: params.originalParams,
677
+ dispatchId,
678
+ resumeOf: params.resumeOf,
679
+ attempt,
680
+ owner,
681
+ recoveryPosture: params.recoveryPosture,
509
682
  };
510
- pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
683
+ persistRunRecord(pi, run, "background");
511
684
 
512
685
  // Issue #98: keep the session run entry in lockstep with the agent record —
513
686
  // every terminal branch that flips agent.status also finalizes the run entry
514
687
  // ("done"/"failed"). The guard keeps the entry single-finalized even when a
515
688
  // settle handler throws after finalizing (markTerminalBestEffort catch-all).
689
+ //
690
+ // U2 (D2) retention: this is the SINGLE terminal choke point for a background
691
+ // run — git-setup refusal, a synchronous prompt throw, the .then completed /
692
+ // provider-failed / aborted branches, the .catch stopped / failed branches and
693
+ // the markTerminalBestEffort catch-all ALL land here exactly once (the
694
+ // runFinalized guard). Pruning here therefore covers every settle path. The
695
+ // index.ts poller crash notices are NOT settlements (they flip only the live
696
+ // UI state and leave the run/agent record to this settle); an interrupted run
697
+ // (U1 boot-scan mark) never reaches this function and KEEPS its file (D3).
516
698
  let runFinalized = false;
517
699
  const finalizeRunEntry = (
518
700
  status: "done" | "failed",
@@ -521,6 +703,15 @@ export async function spawnBackgroundSession(
521
703
  ): void => {
522
704
  if (runFinalized) return;
523
705
  runFinalized = true;
706
+ // U2 (D2): drop the persisted session now that the run is terminal. Done
707
+ // FIRST (before the run-entry write below) so a persist throw cannot leave
708
+ // a terminal run's session file behind. Interrupted runs keep theirs.
709
+ if (!agent.interruptedAt) {
710
+ pruneSessionDir(
711
+ id,
712
+ agent._sessionFile ? dirname(agent._sessionFile) : backgroundSessionDir(id),
713
+ );
714
+ }
524
715
  const usage = agent.result?.usage ?? EMPTY_USAGE;
525
716
  const finalOutput = agent.finalOutput ?? "";
526
717
  // Issue #179 (D1/D2): the classifier's explicit category wins (it knows the
@@ -576,7 +767,7 @@ export async function spawnBackgroundSession(
576
767
  ? { ...(run.originalParams ?? {}), errorCategory }
577
768
  : run.originalParams,
578
769
  };
579
- pi.appendEntry(CUSTOM_ENTRY_TYPES.run, entry);
770
+ persistRunRecord(pi, entry);
580
771
  // Issue #179 (D6): the explicit settle line — the log's job is to record the
581
772
  // CLASSIFIED OUTCOME, which cwd alone can never convey.
582
773
  log.info("Background run settled", {
@@ -661,9 +852,14 @@ export async function spawnBackgroundSession(
661
852
  // hard failure — never spawn an unisolated background agent that was asked
662
853
  // for isolation (fail-loud, same principle as foreground's fallback but
663
854
  // background cannot warn-and-continue safely).
664
- let originalBranch: string | undefined;
855
+ let headState: HeadState | undefined;
665
856
  let workBranchName: string | undefined;
666
857
  let releaseGitLock: (() => void) | undefined;
858
+ // Idempotency guard for cleanupWorkBranch: it must be safe to call from both
859
+ // the normal settle path and the catch-all that runs when a settle step
860
+ // throws FIRST (before its own cleanup call). Without this the second call
861
+ // would re-restore/re-delete and log spurious warnings.
862
+ let gitCleanupDone = false;
667
863
  const gitCwd = params.cwd ?? ctx.cwd;
668
864
  if (params.gitMode === 'branch') {
669
865
  // C2: serialize the FULL branch lifecycle per repo. The spawnQueue only
@@ -696,13 +892,13 @@ export async function spawnBackgroundSession(
696
892
  'requesting background gitMode=branch isolation'
697
893
  );
698
894
  }
699
- originalBranch = getCurrentBranch(gitCwd);
700
- const branchResult = createWorkBranch(gitCwd, originalBranch);
895
+ headState = getHeadState(gitCwd);
896
+ const branchResult = createWorkBranch(gitCwd, headState.sha);
701
897
  if (branchResult.ok) {
702
898
  workBranchName = branchResult.branch;
703
899
  log.info('Created work branch for background agent', {
704
900
  branch: workBranchName,
705
- base: originalBranch,
901
+ base: headState.branch,
706
902
  agentId: id,
707
903
  });
708
904
  } else {
@@ -740,7 +936,8 @@ export async function spawnBackgroundSession(
740
936
  // uncommitted edits would leak into the base working tree on switch —
741
937
  // commitAll first makes the diff real and the switch clean.
742
938
  const cleanupWorkBranch = (): { gitBranch?: string; gitDiff?: string } => {
743
- if (!workBranchName || !originalBranch) return {};
939
+ if (gitCleanupDone || !workBranchName || !headState) return {};
940
+ gitCleanupDone = true;
744
941
  const info: { gitBranch?: string; gitDiff?: string } = { gitBranch: workBranchName };
745
942
  // The lock MUST be released on every exit path — early returns included.
746
943
  try {
@@ -778,7 +975,7 @@ export async function spawnBackgroundSession(
778
975
  if (workingDiff) info.gitDiff = workingDiff;
779
976
  }
780
977
  }
781
- const diffResult = captureDiff(gitCwd, originalBranch);
978
+ const diffResult = captureDiff(gitCwd, headState.sha);
782
979
  if (diffResult.ok && diffResult.diff.trim()) {
783
980
  // Merge committed + working diffs — either can exist alone.
784
981
  info.gitDiff = info.gitDiff
@@ -789,9 +986,12 @@ export async function spawnBackgroundSession(
789
986
  log.warn(`captureDiff failed for background agent ${id}`, { error: (err as Error).message });
790
987
  }
791
988
  try {
792
- switchToBranch(gitCwd, originalBranch);
989
+ const restoreResult = restoreHead(gitCwd, headState);
990
+ if (!restoreResult.ok) {
991
+ log.warn(`restoreHead failed for background agent ${id}`, { error: restoreResult.error });
992
+ }
793
993
  } catch (err) {
794
- log.warn(`switchToBranch failed for background agent ${id}`, { error: (err as Error).message });
994
+ log.warn(`restoreHead failed for background agent ${id}`, { error: (err as Error).message });
795
995
  }
796
996
  try {
797
997
  deleteBranch(gitCwd, workBranchName);
@@ -812,6 +1012,11 @@ export async function spawnBackgroundSession(
812
1012
  // terminal so no zombie 'running' record survives; never rethrow.
813
1013
  const markTerminalBestEffort = (fallback: AgentStatus): void => {
814
1014
  try {
1015
+ // Issue #53 / review C: this catch-all fires when a settle handler step
1016
+ // threw — possibly BEFORE the branch's own cleanupWorkBranch() call. Run
1017
+ // it here too (idempotent) so a throw can never bypass branch teardown
1018
+ // and strand the work branch in the repo working tree.
1019
+ cleanupWorkBranch();
815
1020
  // Issue #31 (PR #76 review): this catch-all fires when a settle handler
816
1021
  // threw — either BEFORE the branch's own capture (ref still live: capture
817
1022
  // now, it is the last chance to record the session's output) or AFTER it
package/src/state.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  ThinkingLevel,
18
18
  ApprovalMode,
19
19
  CompletionNotifyMode,
20
+ RecoveryMode,
20
21
  CircuitBreakerState,
21
22
  Priority,
22
23
  } from "./types";
@@ -24,17 +25,21 @@ import type { TranscriptMessage } from "./transcript-tail";
24
25
  import {
25
26
  isSubagentStateShape,
26
27
  isSubagentRunShape,
28
+ isInterruptedRun,
27
29
  CUSTOM_ENTRY_TYPES,
28
30
  MAX_RUN_HISTORY_ENTRIES,
29
31
  DEFAULT_MAX_SUBAGENT_DEPTH,
30
32
  DEFAULT_SESSION_COST_LIMIT,
31
33
  DEFAULT_COMPLETION_NOTIFY,
32
34
  COMPLETION_NOTIFY_MODES,
35
+ DEFAULT_RECOVERY,
36
+ RECOVERY_MODES,
33
37
  MAX_CONSECUTIVE_FAILURES,
34
38
  CIRCUIT_BREAKER_RESET_MS,
35
39
  CIRCUIT_DEGRADED_THINKING,
36
40
  } from "./types";
37
41
  import { cleanupRuns } from "./history";
42
+ import { persistRunRecord, type InflightRunKind } from "./run-registry";
38
43
  import type { Logger } from "./logging";
39
44
 
40
45
  // ---------------------------------------------------------------------------
@@ -69,14 +74,21 @@ export const STALE_FINALIZE_GRACE_MS = POLLER_TICK_MS + 1000;
69
74
 
70
75
  /**
71
76
  * Terminal-preferring run-entry lookup — the ONE implementation of the
72
- * preference rule.
77
+ * preference rule. Recovery's `dedupeRunEntriesById` delegates here, so the
78
+ * boot scan and every display/retry lookup agree by construction.
73
79
  *
74
80
  * Each run writes TWO custom entries sharing its id, in append order: a spawn
75
81
  * entry (status "running", carrying `originalParams`) FIRST, then the final
76
82
  * entry (status "done"/"failed", stamped with fullOutput/cost/duration) at
77
- * settle. Prefer the terminal entry; when none is terminal yet, fall back to
78
- * the first match (the pre-finalize spawn shape). Returns undefined when no
79
- * entry matches.
83
+ * settle. Preference, in order:
84
+ *
85
+ * 1. a TERMINAL entry (status !== "running") — the run is resolved;
86
+ * 2. among non-terminal entries, one carrying the D3 `interruptedAt` mark —
87
+ * append-only stores keep the original spawn entry forever, so the marked
88
+ * clone is the authoritative RECOVERED state U3's shared lookup reads;
89
+ * 3. otherwise the first match (the pre-finalize spawn shape).
90
+ *
91
+ * Returns undefined when no entry matches.
80
92
  */
81
93
  export function resolveTerminalRunEntry(
82
94
  entries: SubagentRun[],
@@ -84,7 +96,57 @@ export function resolveTerminalRunEntry(
84
96
  ): SubagentRun | undefined {
85
97
  const matching = entries.filter((r) => r.id === id);
86
98
  if (matching.length === 0) return undefined;
87
- return matching.find((r) => r.status !== "running") ?? matching[0];
99
+ return (
100
+ matching.find((r) => r.status !== "running") ??
101
+ matching.find((r) => r.interruptedAt) ??
102
+ matching[0]
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Run-history display projection (issue #259): ONE record per SETTLED run.
108
+ *
109
+ * Each run appends two entries sharing its id — a spawn entry (status
110
+ * "running") first, then the terminal entry at settle — so a raw entry list
111
+ * renders every run twice and the spawn row shows a stale "running" forever.
112
+ * This collapses each id to its terminal-preferred entry via the ONE shared
113
+ * rule (`resolveTerminalRunEntry`). Runs still in flight (spawn entry, no
114
+ * terminal entry yet) are omitted: live state belongs to the monitor. U3 (D1)
115
+ * carves ONE exception — an INTERRUPTED run (running + `interruptedAt`, per
116
+ * `isInterruptedRun`) is surfaced, because the human must see it to recover or
117
+ * abandon it.
118
+ *
119
+ * Ordering is preserved from `entries`; callers pass `getRunEntries` output
120
+ * (newest-first via `cleanupRuns`), which stays the single ordering authority —
121
+ * no re-sort is performed here.
122
+ */
123
+ export function collapseRunsForHistory(entries: SubagentRun[]): SubagentRun[] {
124
+ const display: SubagentRun[] = [];
125
+ const seen = new Set<string>();
126
+ for (const entry of entries) {
127
+ if (seen.has(entry.id)) continue;
128
+ seen.add(entry.id);
129
+ const resolved = resolveTerminalRunEntry(entries, entry.id);
130
+ if (resolved && (resolved.status !== "running" || isInterruptedRun(resolved))) {
131
+ display.push(resolved);
132
+ }
133
+ }
134
+ return display;
135
+ }
136
+
137
+ /**
138
+ * U3 (D3): the retry surface's candidate projection — settled FAILED runs plus
139
+ * unresolved INTERRUPTED runs, one row per id. Built on the SAME
140
+ * terminal-preferred collapse the history list uses, so the two surfaces can
141
+ * never disagree about which record represents a run. The retry offer reuses
142
+ * the existing `retryRunId` machinery (recorded params restored by
143
+ * `resolveRetryParams`), so an interrupted run is recoverable without any new
144
+ * delegate_task parameter.
145
+ */
146
+ export function retryableRuns(entries: SubagentRun[]): SubagentRun[] {
147
+ return collapseRunsForHistory(entries).filter(
148
+ (r) => r.status === "failed" || isInterruptedRun(r),
149
+ );
88
150
  }
89
151
 
90
152
  // ---------------------------------------------------------------------------
@@ -158,6 +220,7 @@ export class SessionState {
158
220
  gitMode: "none",
159
221
  approvalMode: "writes",
160
222
  completionNotify: DEFAULT_COMPLETION_NOTIFY,
223
+ recovery: DEFAULT_RECOVERY,
161
224
  maxHistoryEntries: MAX_RUN_HISTORY_ENTRIES,
162
225
  sessionCostLimit: DEFAULT_SESSION_COST_LIMIT,
163
226
  perTaskCostEstimate: 0,
@@ -183,6 +246,7 @@ export class SessionState {
183
246
  gitMode: this.config.gitMode,
184
247
  approvalMode: this.config.approvalMode,
185
248
  completionNotify: this.config.completionNotify,
249
+ recovery: this.config.recovery,
186
250
  maxHistoryEntries: this.config.maxHistoryEntries,
187
251
  sessionCostLimit: this.config.sessionCostLimit,
188
252
  perTaskCostEstimate: this.config.perTaskCostEstimate,
@@ -194,8 +258,14 @@ export class SessionState {
194
258
  });
195
259
  }
196
260
 
197
- persistRun(pi: ExtensionAPI, run: SubagentRun): void {
198
- pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
261
+ /**
262
+ * Persist a run record through the ONE choke point (`persistRunRecord`),
263
+ * which appends the session entry AND mirrors the record into the durable
264
+ * in-flight registry. `kind` is supplied at dispatch; terminal writes clear
265
+ * the registry entry.
266
+ */
267
+ persistRun(pi: ExtensionAPI, run: SubagentRun, kind?: InflightRunKind): void {
268
+ persistRunRecord(pi, run, kind);
199
269
  }
200
270
 
201
271
  // -------------------------------------------------------------------
@@ -244,6 +314,9 @@ export class SessionState {
244
314
  COMPLETION_NOTIFY_MODES.includes(data.completionNotify as CompletionNotifyMode)
245
315
  ? data.completionNotify
246
316
  : DEFAULT_COMPLETION_NOTIFY;
317
+ this.config.recovery = RECOVERY_MODES.includes(data.recovery as RecoveryMode)
318
+ ? data.recovery
319
+ : DEFAULT_RECOVERY;
247
320
  if (data.maxHistoryEntries !== undefined) this.config.maxHistoryEntries = data.maxHistoryEntries;
248
321
  if (data.sessionCostLimit !== undefined) this.config.sessionCostLimit = data.sessionCostLimit;
249
322
  if (data.perTaskCostEstimate !== undefined) this.config.perTaskCostEstimate = data.perTaskCostEstimate;
@@ -533,6 +606,7 @@ export class SessionState {
533
606
  this.config.gitMode = "none";
534
607
  this.config.approvalMode = "writes";
535
608
  this.config.completionNotify = DEFAULT_COMPLETION_NOTIFY;
609
+ this.config.recovery = DEFAULT_RECOVERY;
536
610
  this.config.maxHistoryEntries = MAX_RUN_HISTORY_ENTRIES;
537
611
  this.config.sessionCostLimit = DEFAULT_SESSION_COST_LIMIT;
538
612
  this.config.perTaskCostEstimate = 0;
@@ -0,0 +1,41 @@
1
+ // Purpose: The single declaration of the transcript path format — filesystem (`join`) and display (POSIX) forms.
2
+ import { join } from "node:path";
3
+ import { assertSafeAgentId } from "./sanitize";
4
+
5
+ /**
6
+ * The transcript directory, relative to the project root. This is the ONE
7
+ * declaration of the format; every other module derives its path from here
8
+ * rather than re-inlining `.pi/output` in a template string (issue #276).
9
+ */
10
+ export const TRANSCRIPT_DIR = ".pi/output";
11
+
12
+ /**
13
+ * Filesystem path to an agent's transcript: `<dir>/agent-<id>.jsonl`.
14
+ *
15
+ * SECURITY (F24): the id is validated through `assertSafeAgentId` BEFORE it
16
+ * reaches `path.join` — join does NOT sanitize `../` or absolute segments, so
17
+ * a traversal id would otherwise escape `dir`. Keep this chokepoint for every
18
+ * filesystem caller (`transcript.ts`, the `get_agent_result` re-export).
19
+ *
20
+ * `dir` is a parameter so `transcript.ts` can forward its mutable test-only
21
+ * `OUTPUT_DIR`; production callers take the `TRANSCRIPT_DIR` default.
22
+ */
23
+ export function transcriptPath(agentId: string, dir: string = TRANSCRIPT_DIR): string {
24
+ assertSafeAgentId(agentId);
25
+ return join(dir, `agent-${agentId}.jsonl`);
26
+ }
27
+
28
+ /**
29
+ * Display form of the transcript path: `<TRANSCRIPT_DIR>/agent-<id>.jsonl`,
30
+ * always with forward slashes so the rendered panel is identical on every
31
+ * platform.
32
+ *
33
+ * This is deliberately format-only: it does NOT call `assertSafeAgentId` and
34
+ * must never throw. It is called during TUI rendering (`tui.ts` →
35
+ * `buildOutputHonestyLines`) from a stored run record, where a throw would
36
+ * break the panel. Display text is POSIX while filesystem access uses `join` —
37
+ * the two forms intentionally differ (settled in issue #276).
38
+ */
39
+ export function transcriptDisplayPath(agentId: string): string {
40
+ return `${TRANSCRIPT_DIR}/agent-${agentId}.jsonl`;
41
+ }
package/src/transcript.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  // Purpose: JSONL transcript recording for every agent run (`.pi/output/agent-<id>.jsonl`).
2
- import { join } from 'path';
3
2
  import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'fs';
4
3
  import type { TranscriptEntry, TranscriptEntryType } from './types';
5
- import { assertSafeAgentId } from './sanitize';
4
+ import { transcriptPath, TRANSCRIPT_DIR } from './transcript-path';
5
+ import { createLogger } from './logging';
6
+
7
+ // Issue #265: route the settle-path warning through the logger (file-only by
8
+ // default) instead of a raw console.warn that corrupts the pi TUI.
9
+ const log = createLogger('brl-subagent');
6
10
 
7
11
  // Output directory — overridable for tests (issue #52): unit tests must NOT
8
- // write into the real repo .pi/ dir; the setter is test-only.
9
- let OUTPUT_DIR = '.pi/output';
12
+ // write into the real repo .pi/ dir; the setter is test-only. Defaults to the
13
+ // single-sourced TRANSCRIPT_DIR (issue #276).
14
+ let OUTPUT_DIR = TRANSCRIPT_DIR;
10
15
 
11
16
  /**
12
17
  * TEST-ONLY: point the transcript output at an alternate directory.
@@ -30,13 +35,9 @@ function ensureOutputDir(): void {
30
35
  * Get transcript file path for an agent
31
36
  */
32
37
  export function getTranscriptPath(agentId: string): string {
33
- // F24: single chokepoint for every transcript file path. All internal callers
34
- // (startTranscript/appendEntry/getTranscript/completeTranscript) pass
35
- // generateUUID() ids, but get_agent_result feeds LLM-controlled ids in via
36
- // getTranscript. path.join does not sanitize "../" or absolute segments, so
37
- // throw on anything that is not a UUID before it reaches the filesystem.
38
- assertSafeAgentId(agentId);
39
- return join(OUTPUT_DIR, `agent-${agentId}.jsonl`);
38
+ // Issue #276: the format and the F24 `assertSafeAgentId` chokepoint live in
39
+ // transcript-path.ts; here we only forward the mutable test-only OUTPUT_DIR.
40
+ return transcriptPath(agentId, OUTPUT_DIR);
40
41
  }
41
42
 
42
43
  /**
@@ -118,7 +119,7 @@ export function completeTranscript(agentId: string, status: string): void {
118
119
  // appending — that invariant is load-bearing for caller validation).
119
120
  const path = getTranscriptPath(agentId);
120
121
  if (!existsSync(path)) {
121
- console.warn(`[brl-subagent] completeTranscript: transcript missing for agent ${agentId} — skipping completion entry`);
122
+ log.warn(`completeTranscript: transcript missing for agent ${agentId} — skipping completion entry`);
122
123
  return;
123
124
  }
124
125
  appendEntry(agentId, 'system', `Transcript completed: ${status}`);