@cat-factory/executor-harness 1.47.0 → 1.50.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 CHANGED
@@ -73,6 +73,8 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
73
73
  | `src/bootstrap.ts` | The `/bootstrap` handler (clone-or-empty → adapt → reinit + force-push). |
74
74
  | `src/blueprint.ts` | The `/blueprint` handler (decompose → render `blueprints/` → commit on branch). |
75
75
  | `src/embed.ts` | Bundled assets/templates written into the workspace. |
76
+ | `src/agent-runner.ts` | The subscription-harness runners (`runClaudeCode` / `runCodex`) — talk direct to the vendor with a leased OAuth token, lift per-turn usage/telemetry off the CLI event stream. |
77
+ | `src/transcript-retention.ts` | Lifts the CLI session transcripts (`projects/` / `sessions/`) out of the isolated, credential-bearing config home before it is deleted, and prunes them on a TTL (debugging artifact retention). |
76
78
  | `src/logger.ts` | Structured logging. |
77
79
 
78
80
  ## Runner lifecycle knobs
@@ -85,6 +87,8 @@ runner):
85
87
  | `PORT` | `8080` | HTTP port the harness listens on. |
86
88
  | `JOB_MAX_DURATION_MS` | `3600000` (60m) | Hard ceiling on a job's wall-clock time; force-fails after. |
87
89
  | `JOB_INACTIVITY_MS` | `600000` (10m) | Kills a hung agent that produces no output for this long. |
90
+ | `HARNESS_TRANSCRIPT_TTL_MS` | `259200000` (3d) | How long lifted subscription-CLI session transcripts are kept before the retention sweep prunes them. |
91
+ | `HARNESS_TRANSCRIPT_ROOT` | `<tmpdir>/cf-agent-transcripts` | Where retained session transcripts are moved to (one dir per run). Meaningful only on a reused (warm-pool) container; a per-run container is torn down with the job. The TTL sweep deletes only dirs it created (each carries a `.cf-retained` marker), so pointing this at a shared directory never touches unrelated content — though a dedicated dir is still recommended. An override on a different filesystem than the config home falls back to copy-then-remove. |
88
92
 
89
93
  ## Build / test
90
94
 
@@ -4,6 +4,7 @@ import { homedir, tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { killChildProcess, spawnDetached } from './process.js';
6
6
  import { redact, secretsToRedact } from './redact.js';
7
+ import { retainSessionTranscripts } from './transcript-retention.js';
7
8
  function isObject(value) {
8
9
  return typeof value === 'object' && value !== null;
9
10
  }
@@ -296,9 +297,17 @@ export async function runClaudeCode(opts) {
296
297
  };
297
298
  }
298
299
  finally {
299
- // Never leave the config dir (and any cached credential) on disk past the run.
300
- if (configHome)
300
+ if (configHome) {
301
+ // Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
302
+ // home is deleted — the credential lives at the home root, never in `projects/`, so this
303
+ // keeps the debugging artifact without leaking the token. Best-effort; never throws.
304
+ await retainSessionTranscripts(configHome, ['projects'], {
305
+ label: 'claude-code',
306
+ ...(opts.log ? { log: opts.log } : {}),
307
+ });
308
+ // Never leave the config dir (and any cached credential) on disk past the run.
301
309
  await rm(configHome, { recursive: true, force: true }).catch(() => { });
310
+ }
302
311
  }
303
312
  }
304
313
  /** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
@@ -498,9 +507,17 @@ export async function runCodex(opts) {
498
507
  };
499
508
  }
500
509
  finally {
501
- // Never leave the decrypted credential on disk past the run.
502
- if (codexHome)
510
+ if (codexHome) {
511
+ // Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
512
+ // home is deleted — the credential (`auth.json`) lives at the home root, never in
513
+ // `sessions/`, so this keeps the debugging artifact without leaking it. Best-effort.
514
+ await retainSessionTranscripts(codexHome, ['sessions'], {
515
+ label: 'codex',
516
+ ...(opts.log ? { log: opts.log } : {}),
517
+ });
518
+ // Never leave the decrypted credential on disk past the run.
503
519
  await rm(codexHome, { recursive: true, force: true }).catch(() => { });
520
+ }
504
521
  }
505
522
  }
506
523
  /**
@@ -146,6 +146,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
146
146
  signal: opts.signal,
147
147
  onActivity: opts.onActivity,
148
148
  onProgress: opts.onProgress,
149
+ ...(opts.log ? { log: opts.log } : {}),
149
150
  });
150
151
  }
151
152
  if (!spec.proxyBaseUrl || !spec.sessionToken) {
@@ -0,0 +1,139 @@
1
+ import { cp, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, join } from 'node:path';
4
+ // Session-transcript retention for the subscription harnesses (Claude Code / Codex).
5
+ //
6
+ // Both runners create an ISOLATED, per-run config home so the leased OAuth credential never
7
+ // lands in the cloned checkout, then delete that home in `finally`. The CLIs also write their
8
+ // per-turn session transcripts INSIDE that home (`$CLAUDE_CONFIG_DIR/projects/…` for Claude
9
+ // Code, `$CODEX_HOME/sessions/…` for Codex), so deleting the home also erases the exact
10
+ // per-call detail needed to debug a finished run.
11
+ //
12
+ // This module lifts ONLY the transcript subtree out of the home BEFORE it is deleted, into a
13
+ // retention root, and prunes retained transcripts on a short TTL. The credential lives at the
14
+ // home ROOT (`.claude.json` / `auth.json`), never inside `projects/` / `sessions/`, so moving
15
+ // just those subdirs keeps the debugging artifact while the existing `rm(home)` still removes
16
+ // the credential — the credential-safety property is preserved.
17
+ //
18
+ // It is meaningful only where the container filesystem outlives the job (the reused local
19
+ // warm-pool container, whose next run's sweep honours the TTL); on a per-run cloud container
20
+ // torn down with the job it is a harmless no-op. Best-effort throughout: a retention failure
21
+ // must NEVER fail an otherwise-successful run.
22
+ /** Default retention window: 3 days. Overridable via `HARNESS_TRANSCRIPT_TTL_MS`. */
23
+ const DEFAULT_TTL_MS = 3 * 24 * 60 * 60 * 1000;
24
+ /**
25
+ * A marker file dropped into every retention dir THIS module creates. The pruner deletes ONLY
26
+ * dirs carrying it, so pointing `HARNESS_TRANSCRIPT_ROOT` at a shared (non-dedicated) directory
27
+ * can never `rm -rf` unrelated sibling content — we only ever sweep our own retained transcripts.
28
+ */
29
+ export const RETENTION_MARKER = '.cf-retained';
30
+ /** The retention root (one dir per retained home underneath it). Overridable for operators. */
31
+ function retentionRoot() {
32
+ const override = process.env.HARNESS_TRANSCRIPT_ROOT?.trim();
33
+ return override && override.length > 0 ? override : join(tmpdir(), 'cf-agent-transcripts');
34
+ }
35
+ /** The retention TTL in ms, from the env override when it's a positive finite number. */
36
+ function ttlMs() {
37
+ const raw = Number(process.env.HARNESS_TRANSCRIPT_TTL_MS);
38
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TTL_MS;
39
+ }
40
+ /**
41
+ * Move the named transcript `subdirs` out of the credential-bearing config `home` into the
42
+ * retention root (so the caller's subsequent `rm(home)` can't take them), then prune retained
43
+ * transcripts older than the TTL. Both steps are best-effort: any failure is swallowed (logged
44
+ * at debug) so this can never fail an otherwise-successful run. Returns the destination dir when
45
+ * something was retained (for logging/tests), else `undefined`.
46
+ */
47
+ export async function retainSessionTranscripts(home, subdirs, options = {}) {
48
+ const { label, log } = options;
49
+ const root = retentionRoot();
50
+ // A filesystem-safe, sortable per-home dir name: an ISO stamp (colons/dots → dashes) + the
51
+ // home's basename (already unique — `mkdtemp` seeded).
52
+ const dest = join(root, `${new Date().toISOString().replace(/[:.]/g, '-')}-${basename(home)}`);
53
+ let moved = 0;
54
+ try {
55
+ for (const sub of subdirs) {
56
+ const from = join(home, sub);
57
+ try {
58
+ await stat(from);
59
+ }
60
+ catch {
61
+ continue; // the CLI never wrote this subdir this run — nothing to retain
62
+ }
63
+ if (moved === 0)
64
+ await ensureRetentionDir(dest);
65
+ await moveDir(from, join(dest, sub));
66
+ moved += 1;
67
+ }
68
+ if (moved > 0)
69
+ log?.info('retained session transcripts', { label, dest, subdirs: moved });
70
+ }
71
+ catch (err) {
72
+ log?.debug('failed to retain session transcripts', {
73
+ label,
74
+ err: err instanceof Error ? err.message : String(err),
75
+ });
76
+ }
77
+ // Prune regardless of whether this run retained anything — a run that moved nothing still gets
78
+ // to sweep the backlog left by earlier runs on a reused container.
79
+ await pruneRetentionRoot(root, ttlMs(), log);
80
+ return moved > 0 ? dest : undefined;
81
+ }
82
+ /** Create a retention dir and stamp it with the ownership marker (see {@link RETENTION_MARKER}). */
83
+ async function ensureRetentionDir(dest) {
84
+ await mkdir(dest, { recursive: true });
85
+ await writeFile(join(dest, RETENTION_MARKER), '');
86
+ }
87
+ /**
88
+ * Move `from` → `to`, preferring a cheap same-filesystem `rename`. When the retention root is on
89
+ * a DIFFERENT device than the config home (an operator override of `HARNESS_TRANSCRIPT_ROOT`),
90
+ * `rename` fails with `EXDEV` — fall back to a recursive copy + remove so the transcript is still
91
+ * lifted out before the caller deletes the home, rather than being silently lost.
92
+ */
93
+ async function moveDir(from, to) {
94
+ try {
95
+ await rename(from, to);
96
+ }
97
+ catch (err) {
98
+ if (err?.code !== 'EXDEV')
99
+ throw err;
100
+ await cp(from, to, { recursive: true });
101
+ await rm(from, { recursive: true, force: true });
102
+ }
103
+ }
104
+ /**
105
+ * Delete retained-transcript dirs whose mtime is older than `maxAgeMs`. Only dirs carrying the
106
+ * retention marker are candidates — foreign content under a shared retention root is never
107
+ * touched. Best-effort per entry (a concurrent sweep or a vanished dir must not abort the loop).
108
+ */
109
+ async function pruneRetentionRoot(root, maxAgeMs, log) {
110
+ const cutoff = Date.now() - maxAgeMs;
111
+ let entries;
112
+ try {
113
+ entries = await readdir(root);
114
+ }
115
+ catch {
116
+ return; // the root doesn't exist yet (nothing ever retained) — nothing to prune
117
+ }
118
+ for (const name of entries) {
119
+ const path = join(root, name);
120
+ try {
121
+ const info = await stat(path);
122
+ if (!info.isDirectory() || info.mtimeMs >= cutoff)
123
+ continue;
124
+ // Ownership gate: only sweep dirs WE created (they carry the marker). This makes a
125
+ // shared/non-dedicated `HARNESS_TRANSCRIPT_ROOT` safe — unrelated dirs are left alone.
126
+ try {
127
+ await stat(join(path, RETENTION_MARKER));
128
+ }
129
+ catch {
130
+ continue; // not one of ours — never delete it
131
+ }
132
+ await rm(path, { recursive: true, force: true });
133
+ log?.debug('pruned expired session transcripts', { path });
134
+ }
135
+ catch {
136
+ // best-effort per entry — a concurrent sweep or a vanished dir must not abort the loop
137
+ }
138
+ }
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.47.0",
3
+ "version": "1.50.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,13 +21,13 @@
21
21
  "access": "public"
22
22
  },
23
23
  "devDependencies": {
24
- "@hono/node-server": "^2.0.8",
24
+ "@hono/node-server": "^2.0.10",
25
25
  "@types/node": "^26.1.1",
26
- "hono": "^4.12.29",
26
+ "hono": "^4.12.30",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.129.0",
30
- "@cat-factory/spend": "0.12.41"
29
+ "@cat-factory/server": "0.138.7",
30
+ "@cat-factory/spend": "0.12.62"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
@@ -2,9 +2,11 @@ import { spawn } from 'node:child_process'
2
2
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
3
  import { homedir, tmpdir } from 'node:os'
4
4
  import { dirname, join } from 'node:path'
5
+ import type { Logger } from './logger.js'
5
6
  import type { HarnessCallMetric, PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
6
7
  import { killChildProcess, spawnDetached } from './process.js'
7
8
  import { redact, secretsToRedact } from './redact.js'
9
+ import { retainSessionTranscripts } from './transcript-retention.js'
8
10
 
9
11
  // The alternate (subscription) harness runners. The Pi harness reaches models
10
12
  // through the LLM proxy with a model-locked session token; the Claude Code and
@@ -70,6 +72,11 @@ export interface SubscriptionRunOptions {
70
72
  onActivity?: () => void
71
73
  /** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
72
74
  onProgress?: (progress: TodoProgress) => void
75
+ /**
76
+ * The per-job child logger (jobId/repo/branch correlation). Threaded so the retained
77
+ * session-transcript path is logged for the run when the isolated config home is torn down.
78
+ */
79
+ log?: Logger
73
80
  }
74
81
 
75
82
  function isObject(value: unknown): value is Record<string, unknown> {
@@ -400,8 +407,17 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
400
407
  ...(calls.length ? { callMetrics: calls } : {}),
401
408
  }
402
409
  } finally {
403
- // Never leave the config dir (and any cached credential) on disk past the run.
404
- if (configHome) await rm(configHome, { recursive: true, force: true }).catch(() => {})
410
+ if (configHome) {
411
+ // Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
412
+ // home is deleted — the credential lives at the home root, never in `projects/`, so this
413
+ // keeps the debugging artifact without leaking the token. Best-effort; never throws.
414
+ await retainSessionTranscripts(configHome, ['projects'], {
415
+ label: 'claude-code',
416
+ ...(opts.log ? { log: opts.log } : {}),
417
+ })
418
+ // Never leave the config dir (and any cached credential) on disk past the run.
419
+ await rm(configHome, { recursive: true, force: true }).catch(() => {})
420
+ }
405
421
  }
406
422
  }
407
423
 
@@ -620,8 +636,17 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
620
636
  ...(calls.length ? { callMetrics: calls } : {}),
621
637
  }
622
638
  } finally {
623
- // Never leave the decrypted credential on disk past the run.
624
- if (codexHome) await rm(codexHome, { recursive: true, force: true }).catch(() => {})
639
+ if (codexHome) {
640
+ // Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
641
+ // home is deleted — the credential (`auth.json`) lives at the home root, never in
642
+ // `sessions/`, so this keeps the debugging artifact without leaking it. Best-effort.
643
+ await retainSessionTranscripts(codexHome, ['sessions'], {
644
+ label: 'codex',
645
+ ...(opts.log ? { log: opts.log } : {}),
646
+ })
647
+ // Never leave the decrypted credential on disk past the run.
648
+ await rm(codexHome, { recursive: true, force: true }).catch(() => {})
649
+ }
625
650
  }
626
651
  }
627
652
 
@@ -270,6 +270,7 @@ export async function runAgentInWorkspace(
270
270
  signal: opts.signal,
271
271
  onActivity: opts.onActivity,
272
272
  onProgress: opts.onProgress,
273
+ ...(opts.log ? { log: opts.log } : {}),
273
274
  })
274
275
  }
275
276
  if (!spec.proxyBaseUrl || !spec.sessionToken) {
@@ -0,0 +1,150 @@
1
+ import { cp, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { basename, join } from 'node:path'
4
+ import type { Logger } from './logger.js'
5
+
6
+ // Session-transcript retention for the subscription harnesses (Claude Code / Codex).
7
+ //
8
+ // Both runners create an ISOLATED, per-run config home so the leased OAuth credential never
9
+ // lands in the cloned checkout, then delete that home in `finally`. The CLIs also write their
10
+ // per-turn session transcripts INSIDE that home (`$CLAUDE_CONFIG_DIR/projects/…` for Claude
11
+ // Code, `$CODEX_HOME/sessions/…` for Codex), so deleting the home also erases the exact
12
+ // per-call detail needed to debug a finished run.
13
+ //
14
+ // This module lifts ONLY the transcript subtree out of the home BEFORE it is deleted, into a
15
+ // retention root, and prunes retained transcripts on a short TTL. The credential lives at the
16
+ // home ROOT (`.claude.json` / `auth.json`), never inside `projects/` / `sessions/`, so moving
17
+ // just those subdirs keeps the debugging artifact while the existing `rm(home)` still removes
18
+ // the credential — the credential-safety property is preserved.
19
+ //
20
+ // It is meaningful only where the container filesystem outlives the job (the reused local
21
+ // warm-pool container, whose next run's sweep honours the TTL); on a per-run cloud container
22
+ // torn down with the job it is a harmless no-op. Best-effort throughout: a retention failure
23
+ // must NEVER fail an otherwise-successful run.
24
+
25
+ /** Default retention window: 3 days. Overridable via `HARNESS_TRANSCRIPT_TTL_MS`. */
26
+ const DEFAULT_TTL_MS = 3 * 24 * 60 * 60 * 1000
27
+
28
+ /**
29
+ * A marker file dropped into every retention dir THIS module creates. The pruner deletes ONLY
30
+ * dirs carrying it, so pointing `HARNESS_TRANSCRIPT_ROOT` at a shared (non-dedicated) directory
31
+ * can never `rm -rf` unrelated sibling content — we only ever sweep our own retained transcripts.
32
+ */
33
+ export const RETENTION_MARKER = '.cf-retained'
34
+
35
+ /** The retention root (one dir per retained home underneath it). Overridable for operators. */
36
+ function retentionRoot(): string {
37
+ const override = process.env.HARNESS_TRANSCRIPT_ROOT?.trim()
38
+ return override && override.length > 0 ? override : join(tmpdir(), 'cf-agent-transcripts')
39
+ }
40
+
41
+ /** The retention TTL in ms, from the env override when it's a positive finite number. */
42
+ function ttlMs(): number {
43
+ const raw = Number(process.env.HARNESS_TRANSCRIPT_TTL_MS)
44
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TTL_MS
45
+ }
46
+
47
+ export interface RetainOptions {
48
+ /** A short label for the run/harness, folded into the retention log line. */
49
+ label?: string
50
+ /** The per-job child logger, so the retained path is logged with the run's correlation fields. */
51
+ log?: Logger
52
+ }
53
+
54
+ /**
55
+ * Move the named transcript `subdirs` out of the credential-bearing config `home` into the
56
+ * retention root (so the caller's subsequent `rm(home)` can't take them), then prune retained
57
+ * transcripts older than the TTL. Both steps are best-effort: any failure is swallowed (logged
58
+ * at debug) so this can never fail an otherwise-successful run. Returns the destination dir when
59
+ * something was retained (for logging/tests), else `undefined`.
60
+ */
61
+ export async function retainSessionTranscripts(
62
+ home: string,
63
+ subdirs: string[],
64
+ options: RetainOptions = {},
65
+ ): Promise<string | undefined> {
66
+ const { label, log } = options
67
+ const root = retentionRoot()
68
+ // A filesystem-safe, sortable per-home dir name: an ISO stamp (colons/dots → dashes) + the
69
+ // home's basename (already unique — `mkdtemp` seeded).
70
+ const dest = join(root, `${new Date().toISOString().replace(/[:.]/g, '-')}-${basename(home)}`)
71
+ let moved = 0
72
+ try {
73
+ for (const sub of subdirs) {
74
+ const from = join(home, sub)
75
+ try {
76
+ await stat(from)
77
+ } catch {
78
+ continue // the CLI never wrote this subdir this run — nothing to retain
79
+ }
80
+ if (moved === 0) await ensureRetentionDir(dest)
81
+ await moveDir(from, join(dest, sub))
82
+ moved += 1
83
+ }
84
+ if (moved > 0) log?.info('retained session transcripts', { label, dest, subdirs: moved })
85
+ } catch (err) {
86
+ log?.debug('failed to retain session transcripts', {
87
+ label,
88
+ err: err instanceof Error ? err.message : String(err),
89
+ })
90
+ }
91
+ // Prune regardless of whether this run retained anything — a run that moved nothing still gets
92
+ // to sweep the backlog left by earlier runs on a reused container.
93
+ await pruneRetentionRoot(root, ttlMs(), log)
94
+ return moved > 0 ? dest : undefined
95
+ }
96
+
97
+ /** Create a retention dir and stamp it with the ownership marker (see {@link RETENTION_MARKER}). */
98
+ async function ensureRetentionDir(dest: string): Promise<void> {
99
+ await mkdir(dest, { recursive: true })
100
+ await writeFile(join(dest, RETENTION_MARKER), '')
101
+ }
102
+
103
+ /**
104
+ * Move `from` → `to`, preferring a cheap same-filesystem `rename`. When the retention root is on
105
+ * a DIFFERENT device than the config home (an operator override of `HARNESS_TRANSCRIPT_ROOT`),
106
+ * `rename` fails with `EXDEV` — fall back to a recursive copy + remove so the transcript is still
107
+ * lifted out before the caller deletes the home, rather than being silently lost.
108
+ */
109
+ async function moveDir(from: string, to: string): Promise<void> {
110
+ try {
111
+ await rename(from, to)
112
+ } catch (err) {
113
+ if ((err as NodeJS.ErrnoException)?.code !== 'EXDEV') throw err
114
+ await cp(from, to, { recursive: true })
115
+ await rm(from, { recursive: true, force: true })
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Delete retained-transcript dirs whose mtime is older than `maxAgeMs`. Only dirs carrying the
121
+ * retention marker are candidates — foreign content under a shared retention root is never
122
+ * touched. Best-effort per entry (a concurrent sweep or a vanished dir must not abort the loop).
123
+ */
124
+ async function pruneRetentionRoot(root: string, maxAgeMs: number, log?: Logger): Promise<void> {
125
+ const cutoff = Date.now() - maxAgeMs
126
+ let entries: string[]
127
+ try {
128
+ entries = await readdir(root)
129
+ } catch {
130
+ return // the root doesn't exist yet (nothing ever retained) — nothing to prune
131
+ }
132
+ for (const name of entries) {
133
+ const path = join(root, name)
134
+ try {
135
+ const info = await stat(path)
136
+ if (!info.isDirectory() || info.mtimeMs >= cutoff) continue
137
+ // Ownership gate: only sweep dirs WE created (they carry the marker). This makes a
138
+ // shared/non-dedicated `HARNESS_TRANSCRIPT_ROOT` safe — unrelated dirs are left alone.
139
+ try {
140
+ await stat(join(path, RETENTION_MARKER))
141
+ } catch {
142
+ continue // not one of ours — never delete it
143
+ }
144
+ await rm(path, { recursive: true, force: true })
145
+ log?.debug('pruned expired session transcripts', { path })
146
+ } catch {
147
+ // best-effort per entry — a concurrent sweep or a vanished dir must not abort the loop
148
+ }
149
+ }
150
+ }