@cruxy/cli 1.10.0 → 1.11.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.
@@ -0,0 +1,505 @@
1
+ import { appendFileSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { APP_VERSION } from "../constants.js";
5
+ import { SESSION_FILE_EXT, reservedDir } from "../session/paths.js";
6
+ /**
7
+ * Persisted background-job logs (#172 item 1), under the `subagents/` subtree
8
+ * `paths.ts` reserved for exactly this:
9
+ *
10
+ * ```
11
+ * ~/.cruxy/projects/<project>/
12
+ * <session-id>.jsonl ← the conversation (P2)
13
+ * subagents/
14
+ * <session-id>.<job-id>.jsonl ← one background job's output (here)
15
+ * ```
16
+ *
17
+ * ## What this is, and what it is emphatically not
18
+ *
19
+ * It is a POST-MORTEM. It is not "your job survives a restart", and the
20
+ * difference is not pedantry — it is the first bug someone would otherwise
21
+ * file. A job is an in-process {@link ../agent/loop.js} call, `run.ts` calls
22
+ * `cancelAll("session exit")` on the way out, and `orphan.test.ts` pins that
23
+ * this kill-tree's the whole process group. So a job never outlives the session
24
+ * that dispatched it. What this store lets you read afterwards is what the job
25
+ * SAID before it stopped, which is the one thing that used to die with the
26
+ * process — its file mutations already survived as a checkpoint set
27
+ * (`rollback.ts` finds it by `runId === job.id`) and its spend already survived
28
+ * as a usage record keyed by the same id.
29
+ *
30
+ * ## Streamed, never flushed at exit
31
+ *
32
+ * Every line is written when it happens, through the single sink
33
+ * `JobManager.log` already funnels all of them through. The alternative —
34
+ * serialising the ring buffer once on the way out — was rejected twice over:
35
+ *
36
+ * - it would live in the same `finally` that `process.exit` skips. The
37
+ * SIGINT/SIGTERM/SIGHUP handlers in `tui/restore.ts` and
38
+ * `utils/child-tree.ts` both exit the process directly, so a closed terminal
39
+ * window, a dropped ssh session or a plain `kill` runs no `finally` at all.
40
+ * A log that only persists on the orderly path persists exactly the runs
41
+ * that did not need persisting and loses every one that did.
42
+ * - the whole point of the issue is to remove the ring buffer's
43
+ * drop-on-overflow. Writing the BUFFER at exit would persist the truncated
44
+ * tail, i.e. ship the data loss to disk verbatim.
45
+ *
46
+ * ## Durability: the same rules as `session/log.ts`, unchanged
47
+ *
48
+ * One `appendFileSync` of one newline-terminated JSON line, `O_APPEND` so
49
+ * interleaving writers cannot corrupt each other, `0600` from the first line,
50
+ * and a `broken` latch so an unwritable disk goes inert after ONE warning
51
+ * rather than warning per line. A torn final line is skipped by the reader
52
+ * instead of prevented by the writer. Nothing here is temp-then-rename: that
53
+ * makes a whole-file REWRITE atomic, which is the wrong tool for a file that is
54
+ * only ever appended to.
55
+ */
56
+ /** Extension of a persisted job log. Matches the session log's, deliberately. */
57
+ export const JOB_LOG_EXT = ".jsonl";
58
+ /** Schema version of the `job` header record. */
59
+ export const JOB_LOG_VERSION = 1;
60
+ /**
61
+ * How much of a file's tail is read to find its terminal record.
62
+ *
63
+ * The sweep needs one bit per file — did this job finish, and how — and reading
64
+ * a capped-out 20k-line log in full to learn it would put megabytes of read on
65
+ * the startup path that #255 spent its time removing. The terminal record is
66
+ * the last line by construction (nothing logs into a job after its `finally`,
67
+ * and both `cancelAll` and `abortSiblings` skip jobs that are already
68
+ * terminal), so a few KB always contains it. The tail is SCANNED for the last
69
+ * `end` record rather than assuming the final line is one, so a future writer
70
+ * that appends something after it does not silently turn every finished job
71
+ * into an interrupted one.
72
+ */
73
+ const TAIL_BYTES = 8192;
74
+ // ── the record shapes ───────────────────────────────────────────────────────
75
+ //
76
+ // `.passthrough()` throughout, matching `session/types.ts`: a newer cruxy's
77
+ // extra fields must never make an older one discard a log it can otherwise
78
+ // read.
79
+ const JobHeaderSchema = z
80
+ .object({
81
+ kind: z.literal("job"),
82
+ version: z.number().int().positive(),
83
+ jobId: z.string(),
84
+ sessionId: z.string(),
85
+ label: z.string(),
86
+ cwd: z.string(),
87
+ startedAt: z.string(),
88
+ cliVersion: z.string().optional(),
89
+ })
90
+ .passthrough();
91
+ const JobLineSchema = z
92
+ .object({
93
+ kind: z.literal("line"),
94
+ atMs: z.number(),
95
+ stream: z.enum(["out", "err"]),
96
+ text: z.string(),
97
+ })
98
+ .passthrough();
99
+ const JobTruncatedSchema = z
100
+ .object({
101
+ kind: z.literal("truncated"),
102
+ atMs: z.number(),
103
+ cap: z.number().int().nonnegative(),
104
+ })
105
+ .passthrough();
106
+ const JobEndSchema = z
107
+ .object({
108
+ kind: z.literal("end"),
109
+ at: z.string(),
110
+ status: z.string(),
111
+ iterations: z.number().int().nonnegative().optional(),
112
+ error: z.string().optional(),
113
+ })
114
+ .passthrough();
115
+ /**
116
+ * A reader's verdict on a job that has no terminal record.
117
+ *
118
+ * NOT a {@link JobStatus}: no job is ever IN this state, and adding it to the
119
+ * lifecycle enum would put a case into every `switch` over a job's real states
120
+ * for a value the manager can never produce. It is what a FILE looks like, not
121
+ * what a job was.
122
+ */
123
+ export const INTERRUPTED = "interrupted";
124
+ // ── paths ───────────────────────────────────────────────────────────────────
125
+ /** `~/.cruxy/projects/<project>/subagents` */
126
+ export function jobLogsDir(cwd) {
127
+ return reservedDir(cwd, "subagents");
128
+ }
129
+ /**
130
+ * Filename-safe form of an id. Everything outside `[A-Za-z0-9_-]` collapses to
131
+ * `-`, so a name can never contain a path separator or the `.` that separates
132
+ * the two ids.
133
+ *
134
+ * Like {@link ../session/paths.js projectKey}, this is an INDEX and is not
135
+ * reversible. The true ids are recorded in the header record, which is what a
136
+ * reader reports. Real ids (a UUID session, `job-<n>-<rand>`) are already safe;
137
+ * this exists so a test seam's `idFactory` cannot write outside the subtree.
138
+ */
139
+ export function idKey(id) {
140
+ const key = id.replace(/[^A-Za-z0-9_-]+/g, "-");
141
+ return key === "" ? "unknown" : key;
142
+ }
143
+ /** `~/.cruxy/projects/<project>/subagents/<session-id>.<job-id>.jsonl` */
144
+ export function jobLogPath(cwd, sessionId, jobId) {
145
+ return path.join(jobLogsDir(cwd), `${idKey(sessionId)}.${idKey(jobId)}${JOB_LOG_EXT}`);
146
+ }
147
+ /**
148
+ * One background job's on-disk log.
149
+ *
150
+ * ## The per-job cap, and why one is needed at all
151
+ *
152
+ * Retention (#257) bounds how many session-shaped things the tree KEEPS. It
153
+ * cannot bound how big ONE of them gets, and a background job is the first
154
+ * writer here that can produce unbounded output on its own: it runs a full
155
+ * agent loop off screen, and a job stuck in a tool-call loop emits lines for as
156
+ * long as its budget lasts with nobody watching. Streaming it straight to disk
157
+ * with no ceiling is precisely the second unbounded writer under `~/.cruxy`
158
+ * that #257 was filed to prevent — the ring buffer's bound was doing this job
159
+ * implicitly, and taking the buffer off the critical path takes that bound away
160
+ * with it.
161
+ *
162
+ * So the file gets its own: `jobs.logFileLines`, default 20000, floored at
163
+ * `jobs.logBufferLines` because a file that kept LESS than memory would make
164
+ * persisting strictly worse than not persisting.
165
+ *
166
+ * ## The truncation marker is written at the cap, not at the end
167
+ *
168
+ * When the cap is reached a `truncated` record goes down immediately and
169
+ * further lines are dropped. It is not deferred to the terminal record and it
170
+ * does not carry a running count of what came after, for the same reason the
171
+ * rest of this file streams: a marker that only lands on the orderly path is
172
+ * absent from every log that was killed, which is where the truncation is most
173
+ * likely to matter. An exact count of unrecorded lines is not knowable after a
174
+ * SIGKILL either, so promising one would be the lie the marker exists to
175
+ * prevent.
176
+ *
177
+ * The terminal record is written EVEN PAST THE CAP. Suppressing it there would
178
+ * make every capped-out job read as interrupted, which is the one thing the
179
+ * reader's rule must never get wrong.
180
+ */
181
+ export class JobLogWriter {
182
+ file;
183
+ cap;
184
+ logger;
185
+ /** Set once a write fails: the writer goes inert rather than warning per line. */
186
+ broken = false;
187
+ written = 0;
188
+ truncated = false;
189
+ closed = false;
190
+ /**
191
+ * The header, held until the job records something — the same lazy-meta rule
192
+ * `session/log.ts` adopted in #269, for the same reason: a writer that is
193
+ * constructed and never used should leave nothing on disk. In practice
194
+ * `dispatch` logs immediately, so this flushes on the job's first breath.
195
+ */
196
+ pendingHeader;
197
+ constructor(opts) {
198
+ this.file = opts.file ?? jobLogPath(opts.cwd, opts.sessionId, opts.jobId);
199
+ this.cap = Math.max(1, Math.floor(opts.cap));
200
+ this.logger = opts.logger;
201
+ this.pendingHeader = {
202
+ kind: "job",
203
+ version: JOB_LOG_VERSION,
204
+ jobId: opts.jobId,
205
+ sessionId: opts.sessionId,
206
+ label: opts.label,
207
+ cwd: opts.cwd,
208
+ startedAt: new Date().toISOString(),
209
+ cliVersion: APP_VERSION,
210
+ };
211
+ }
212
+ /** Append one log line, honouring the cap. */
213
+ line(line) {
214
+ if (this.broken || this.closed)
215
+ return;
216
+ if (this.written >= this.cap) {
217
+ if (this.truncated)
218
+ return;
219
+ this.truncated = true;
220
+ this.writeRecord({
221
+ kind: "truncated",
222
+ atMs: line.atMs,
223
+ cap: this.cap,
224
+ });
225
+ return;
226
+ }
227
+ if (this.writeRecord({ kind: "line", ...line }))
228
+ this.written++;
229
+ }
230
+ /**
231
+ * Write the terminal record. Called from the one `finally` that runs on every
232
+ * path a job can actually terminate on — so the presence of this record is
233
+ * exactly the fact {@link readJobLog} reads back. Idempotent.
234
+ */
235
+ end(status, iterations, error) {
236
+ if (this.broken || this.closed)
237
+ return;
238
+ this.closed = true;
239
+ this.writeRecord({
240
+ kind: "end",
241
+ at: new Date().toISOString(),
242
+ status,
243
+ iterations,
244
+ ...(error !== undefined ? { error } : {}),
245
+ });
246
+ }
247
+ writeRecord(record) {
248
+ if (this.broken)
249
+ return false;
250
+ const header = this.pendingHeader;
251
+ if (header !== null) {
252
+ this.pendingHeader = null;
253
+ if (!this.writeLine(header))
254
+ return false;
255
+ }
256
+ return this.writeLine(record);
257
+ }
258
+ writeLine(record) {
259
+ try {
260
+ mkdirSync(path.dirname(this.file), { recursive: true });
261
+ // `mode` applies only at creation — 0600 from the first line, matching
262
+ // the session log, credentials, usage and memory.
263
+ appendFileSync(this.file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
264
+ return true;
265
+ }
266
+ catch (err) {
267
+ this.broken = true;
268
+ this.logger?.warn(`background job log not being saved: ${err.message} ` +
269
+ `(the job continues, and \`/logs\` still reads it from memory)`);
270
+ return false;
271
+ }
272
+ }
273
+ }
274
+ /**
275
+ * Persisted job logs for `cwd`'s project, most-recently-modified first.
276
+ *
277
+ * `readdir` plus one `stat` each — no file is opened, exactly as
278
+ * {@link ../session/list.js sessionFilesByRecency} does for sessions, and for
279
+ * the same reason: recency is knowable from the directory alone, so the
280
+ * per-file read only happens for the rows a caller actually shows.
281
+ *
282
+ * The `.jsonl` filter and the `isFile` test are load-bearing, not tidiness:
283
+ * everything returned here is something the sweep will consider deleting, and
284
+ * `statSync` succeeds on a directory. A name that does not split into exactly
285
+ * two ids is skipped rather than guessed at — it was not written by this
286
+ * writer, and the sweep must not delete files it does not own.
287
+ */
288
+ export function jobLogFilesByRecency(cwd) {
289
+ const dir = jobLogsDir(cwd);
290
+ let names;
291
+ try {
292
+ names = readdirSync(dir);
293
+ }
294
+ catch {
295
+ return []; // no jobs have ever run here — normal, not an error
296
+ }
297
+ const refs = [];
298
+ for (const name of names) {
299
+ if (!name.endsWith(JOB_LOG_EXT))
300
+ continue;
301
+ const stem = name.slice(0, -JOB_LOG_EXT.length);
302
+ const dot = stem.indexOf(".");
303
+ if (dot <= 0 || dot === stem.length - 1)
304
+ continue;
305
+ const sessionKey = stem.slice(0, dot);
306
+ const jobKey = stem.slice(dot + 1);
307
+ if (jobKey.includes("."))
308
+ continue;
309
+ const file = path.join(dir, name);
310
+ try {
311
+ const stat = statSync(file);
312
+ if (!stat.isFile())
313
+ continue;
314
+ refs.push({
315
+ file,
316
+ sessionKey,
317
+ jobKey,
318
+ mtimeMs: stat.mtimeMs,
319
+ size: stat.size,
320
+ });
321
+ }
322
+ catch {
323
+ continue; // deleted mid-listing
324
+ }
325
+ }
326
+ refs.sort((a, b) => b.mtimeMs - a.mtimeMs || a.file.localeCompare(b.file));
327
+ return refs;
328
+ }
329
+ /** Refs whose job id equals `id` or begins with it (the `--resume` rule). */
330
+ export function matchJobLogs(refs, id) {
331
+ const exact = refs.filter((r) => r.jobKey === id);
332
+ if (exact.length > 0)
333
+ return exact;
334
+ return refs.filter((r) => r.jobKey.startsWith(id));
335
+ }
336
+ /**
337
+ * Read one persisted log back.
338
+ *
339
+ * ## RECONCILIATION BY ABSENCE — the whole rule, and there is no other
340
+ *
341
+ * A job is {@link INTERRUPTED} if and only if its file carries no `end`
342
+ * record. `JobManager.execute`'s `finally` writes one on every path that runs
343
+ * at all; the paths that do not run are precisely the ones that killed the
344
+ * process without warning. So "no terminal record" means "the process died
345
+ * mid-job", every time, with nothing to maintain.
346
+ *
347
+ * WHAT THIS REPLACES, and why neither alternative works here:
348
+ *
349
+ * - A PID + start-time liveness marker is category-wrong. A job is not a
350
+ * process — it is an in-process `runAgent` call — so the pid on disk would
351
+ * be the CLI's, and "that pid is alive" only ever tells you the SESSION is
352
+ * alive, which every caller already knows more cheaply. It also adds
353
+ * writable state that a crash can leave stale, which is the disease and not
354
+ * the cure.
355
+ * - A STARTUP SWEEP that rewrites non-terminal logs cannot be the load-bearing
356
+ * rule, because a reader can open a log before any restart happens — `cruxy
357
+ * logs` from a second terminal while the writing session is still running.
358
+ * It would see a `running` job and have nothing to distinguish that from a
359
+ * stale one. Deriving from absence answers the live case and the dead case
360
+ * with the same sentence.
361
+ *
362
+ * Tolerant throughout, matching `replay.ts`: a torn or unknown line is skipped
363
+ * rather than failing the read, because a log that was killed mid-write is the
364
+ * exact case this function exists to serve. Returns null only when the file has
365
+ * no readable header — that is not a log this writer produced.
366
+ */
367
+ export function readJobLog(file) {
368
+ let raw;
369
+ try {
370
+ raw = readFileSync(file, "utf8");
371
+ }
372
+ catch {
373
+ return null;
374
+ }
375
+ let header = null;
376
+ const lines = [];
377
+ let end = null;
378
+ let truncatedAt;
379
+ for (const text of raw.split("\n")) {
380
+ if (text.trim() === "")
381
+ continue;
382
+ let json;
383
+ try {
384
+ json = JSON.parse(text);
385
+ }
386
+ catch {
387
+ continue; // torn line — skip, same tolerance as replay
388
+ }
389
+ if (header === null) {
390
+ const h = JobHeaderSchema.safeParse(json);
391
+ if (h.success)
392
+ header = h.data;
393
+ continue; // nothing before the header is ours
394
+ }
395
+ const line = JobLineSchema.safeParse(json);
396
+ if (line.success) {
397
+ lines.push({
398
+ atMs: line.data.atMs,
399
+ stream: line.data.stream,
400
+ text: line.data.text,
401
+ });
402
+ continue;
403
+ }
404
+ const trunc = JobTruncatedSchema.safeParse(json);
405
+ if (trunc.success) {
406
+ truncatedAt = trunc.data.cap;
407
+ continue;
408
+ }
409
+ const e = JobEndSchema.safeParse(json);
410
+ if (e.success)
411
+ end = e.data;
412
+ }
413
+ if (header === null)
414
+ return null;
415
+ return {
416
+ jobId: header.jobId,
417
+ sessionId: header.sessionId,
418
+ label: header.label,
419
+ cwd: header.cwd,
420
+ startedAt: header.startedAt,
421
+ status: terminalStatus(end),
422
+ ...(end?.at !== undefined ? { endedAt: end.at } : {}),
423
+ ...(end?.iterations !== undefined ? { iterations: end.iterations } : {}),
424
+ ...(end?.error !== undefined ? { error: end.error } : {}),
425
+ lines,
426
+ ...(truncatedAt !== undefined ? { truncatedAt } : {}),
427
+ };
428
+ }
429
+ /**
430
+ * The terminal status of a log WITHOUT reading it in full — the tail only.
431
+ *
432
+ * This is what the sweep asks of every file it considers, so it must not cost a
433
+ * full read: see {@link TAIL_BYTES}. Returns {@link INTERRUPTED} when no `end`
434
+ * record is in the tail, which for a file this writer produced means there is
435
+ * none at all.
436
+ */
437
+ export function readJobLogTerminal(file) {
438
+ let tail;
439
+ try {
440
+ tail = readTail(file, TAIL_BYTES);
441
+ }
442
+ catch {
443
+ return INTERRUPTED;
444
+ }
445
+ let end = null;
446
+ for (const text of tail.split("\n")) {
447
+ if (text.trim() === "")
448
+ continue;
449
+ let json;
450
+ try {
451
+ json = JSON.parse(text);
452
+ }
453
+ catch {
454
+ continue; // the first line of a tail read is torn by definition
455
+ }
456
+ const e = JobEndSchema.safeParse(json);
457
+ if (e.success)
458
+ end = e.data;
459
+ }
460
+ return terminalStatus(end);
461
+ }
462
+ /** The `end` record's status, narrowed — or {@link INTERRUPTED} when absent. */
463
+ function terminalStatus(end) {
464
+ if (end === null)
465
+ return INTERRUPTED;
466
+ // The status is read back as a plain string and narrowed here rather than
467
+ // parsed as an enum, so a log written by a build that knows a status this one
468
+ // does not is reported as written instead of being discarded.
469
+ return end.status;
470
+ }
471
+ /** The last `bytes` of a file, decoded as UTF-8. Short files read whole. */
472
+ function readTail(file, bytes) {
473
+ const fd = openSync(file, "r");
474
+ try {
475
+ const size = fstatSync(fd).size;
476
+ const length = Math.min(size, bytes);
477
+ const buf = Buffer.alloc(length);
478
+ readSync(fd, buf, 0, length, size - length);
479
+ return buf.toString("utf8");
480
+ }
481
+ finally {
482
+ closeSync(fd);
483
+ }
484
+ }
485
+ /**
486
+ * The {@link idKey}s of every session file currently in `cwd`'s project.
487
+ *
488
+ * KEYS AND NOT IDS, because that is the only comparison that is safe in both
489
+ * directions. A log's filename carries a sanitized session id, so comparing it
490
+ * against a raw session id would mismatch for any id `idKey` altered — and a
491
+ * mismatch here does not spare a file, it DELETES one, by concluding the owning
492
+ * session is gone. Sanitizing both sides makes the two agree by construction.
493
+ * (For a real session id — a UUID — `idKey` is the identity function; this
494
+ * matters only for a test seam or a future id format.)
495
+ *
496
+ * Derived from the same scan `prune.ts` already ran, so the sweep adds no
497
+ * second directory walk over the session files.
498
+ */
499
+ export function sessionKeysPresent(files) {
500
+ const keys = new Set();
501
+ for (const { file } of files) {
502
+ keys.add(idKey(path.basename(file, SESSION_FILE_EXT)));
503
+ }
504
+ return keys;
505
+ }
@@ -10,6 +10,7 @@ import { Budget, resolveBudget } from "../agent/budget.js";
10
10
  import { scopeRegistry } from "../subagent/index.js";
11
11
  import { Workspace } from "../workspace/index.js";
12
12
  import { LogBuffer } from "./log-buffer.js";
13
+ import { JobLogWriter } from "./log-store.js";
13
14
  import { JobLogRenderer } from "./log-renderer.js";
14
15
  import { isTerminal } from "./types.js";
15
16
  /** Longest task excerpt kept as a job label (display, not record). */
@@ -126,6 +127,7 @@ export class JobManager {
126
127
  usage: { input_tokens: 0, output_tokens: 0 },
127
128
  summary: "",
128
129
  logs: new LogBuffer(config.jobs.logBufferLines),
130
+ writer: this.makeWriter(id, taskLabel(spec.task)),
129
131
  controller: new AbortController(),
130
132
  checkpoints,
131
133
  scope,
@@ -331,6 +333,15 @@ export class JobManager {
331
333
  if (record.entries.length > 0)
332
334
  this.deps.onRunUsage?.(record);
333
335
  this.log(job, "out", `job ${job.status}`);
336
+ // THE TERMINAL RECORD, and the entire reconciliation rule for a persisted
337
+ // log (#172 item 1): a job is `interrupted` if and only if its file has
338
+ // none. This `finally` runs on every path a job can actually terminate
339
+ // on — completed, failed, cancelled, refused by the pool — so its
340
+ // presence means "the job ended" and its absence means "the process died
341
+ // mid-job", with no marker to maintain and nothing to reconcile at
342
+ // startup. See `readJobLog` for why neither a pid liveness marker nor a
343
+ // startup sweep can carry that weight instead.
344
+ job.writer?.end(job.status, job.iterations, job.error);
334
345
  }
335
346
  }
336
347
  /**
@@ -592,8 +603,65 @@ export class JobManager {
592
603
  throw jobNotFound(id);
593
604
  return job;
594
605
  }
606
+ /**
607
+ * THE ONE SINK every job log line goes through — the in-memory ring buffer
608
+ * for the live readers (`/logs`, the Tasks view), and the file for afterwards
609
+ * (#172 item 1).
610
+ *
611
+ * Both, on the same call, because the file is written by STREAMING and not by
612
+ * serialising the buffer on the way out. A flush-at-exit design would live in
613
+ * `execute`'s `finally`, and that `finally` is exactly what does not run when
614
+ * the process is felled: `tui/restore.ts` and `utils/child-tree.ts` both
615
+ * install SIGINT/SIGTERM/SIGHUP handlers that call `process.exit` directly,
616
+ * so a closed terminal window, a dropped ssh session or a plain `kill`
617
+ * abandons every pending `finally` in the process. A log that only persists
618
+ * on the orderly path persists the runs nobody needed persisted and loses
619
+ * every one they did.
620
+ *
621
+ * Streaming also happens to be the only version that delivers what the issue
622
+ * asked for: writing the BUFFER at exit would persist its truncated tail, so
623
+ * the drop-on-overflow this was meant to remove would simply move to disk.
624
+ */
595
625
  log(job, stream, text) {
596
- job.logs.append({ atMs: this.now(), stream, text });
626
+ const line = { atMs: this.now(), stream, text };
627
+ job.logs.append(line);
628
+ job.writer?.line(line);
629
+ }
630
+ /**
631
+ * Build a job's on-disk log, or decline to.
632
+ *
633
+ * DECLINES WHEN `sessions.enabled` IS FALSE, and this is load-bearing rather
634
+ * than tidy. The sweep that bounds this subtree
635
+ * ({@link ../session/prune.js sweepOrphanedJobLogs}) is reached only through
636
+ * `pruneSessions`, which returns early on that same flag — so a writer that
637
+ * ignored it would produce files nothing ever collects, which is precisely
638
+ * the second unbounded writer under `~/.cruxy` that #257 was filed to
639
+ * prevent. "Do not record my conversations to disk" also plainly covers what
640
+ * the agent said while working in the background.
641
+ *
642
+ * DECLINES WITH NO SESSION ID for the same ownership reason: a job log is
643
+ * content owned by its session, keyed on the session, and one that cannot
644
+ * name its owner can be neither swept with it nor listed beside it. The id is
645
+ * read lazily (see {@link JobManagerDeps.sessionId}) and is present by the
646
+ * time any job is dispatched — a dispatch happens inside a turn, and the
647
+ * session exists before its first turn. Returning undefined is the honest
648
+ * answer to the case that should not arise, rather than a fabricated key.
649
+ */
650
+ makeWriter(id, label) {
651
+ const { deps } = this;
652
+ if (!deps.config.sessions.enabled)
653
+ return undefined;
654
+ const sessionId = deps.sessionId?.();
655
+ if (sessionId === undefined)
656
+ return undefined;
657
+ return new JobLogWriter({
658
+ cwd: deps.cwd,
659
+ sessionId,
660
+ jobId: id,
661
+ label,
662
+ cap: deps.config.jobs.logFileLines,
663
+ logger: deps.logger,
664
+ });
597
665
  }
598
666
  view(job) {
599
667
  const pending = this.deps.approvalQueue.pendingFor(job.id);
@@ -1,5 +1,5 @@
1
1
  export * from "./types.js";
2
- export { ConfigRouter, DEFAULT_TIER, routerForConfig, resolveTaskModel, } from "./router.js";
2
+ export { ConfigRouter, routerForConfig, resolveTaskModel } from "./router.js";
3
3
  export { MODEL_CHOICES, SessionModel, describeModelChoice, parseModelChoice, } from "./session-model.js";
4
4
  // `resolve.ts` (tier → wire model-id) is deliberately NOT re-exported: the
5
5
  // mapping is internal to routing, so it can never be reached from a user-facing