@edgehero/pi-dispatch 1.1.0 → 1.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.
@@ -44,6 +44,54 @@ import { sessionKeyFor } from "./session-key.mjs";
44
44
  export const SESSION_FILE_NAME = "current.jsonl";
45
45
  const PI_VERSION_FILE = "pi-version";
46
46
  const LOCK_FILE = "lock";
47
+ /**
48
+ * How many times in a row this key's transcript has been HANDED TO A CONTAINER. A counter rather than a
49
+ * derivation, because there is nothing to derive it from: the run record deliberately carries no session
50
+ * key (DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED), so counting past runs would need the key->record index
51
+ * that entry refuses. One integer beside the transcript it describes is not that index; it is keyed state
52
+ * written where the key already is, and it answers exactly one question rather than being a query
53
+ * surface. Maintained even when no bound is set, deliberately -- see the write in promoteSession.
54
+ *
55
+ * IT COUNTS THE HOST'S DELIVERIES, NOT PI'S CONTINUATIONS, and that is the whole security of this bound.
56
+ * It counted pi's `resumed` first, on the reasoning that a transcript pi declined to continue extended
57
+ * nothing. That reasoning is wrong here, because the agent owns /session and therefore chooses what pi
58
+ * makes of the file: a transcript carrying a valid header and payload on lines pi's parser DROPS is
59
+ * delivered by the host every run while pi reports zero messages, so the counter reset every run and the
60
+ * chain bound never fired -- measured, not theorised. The host's own decision to hand the file over is
61
+ * the one fact in this exchange that nothing inside the container can influence.
62
+ */
63
+ const RESUME_CHAIN_FILE = "resume-chain";
64
+ /**
65
+ * How full the context was when the run that wrote this transcript ended, as `<tokens> <window>`. Both
66
+ * numbers, not a precomputed percentage: the denominator is what makes the numerator readable later, and
67
+ * an operator looking at a refusal should be able to see what it was judged against.
68
+ *
69
+ * Reported BY THE CONTAINER, which is the only place the number exists: pi computes it from the session
70
+ * it is holding. That puts it at the same trust level as `turns` and `tokens`, and the residual is
71
+ * recorded in OQ-003 rather than papered over -- there is no host-side alternative that is not equally
72
+ * agent-influenced, since the transcript itself is agent-written.
73
+ */
74
+ const CONTEXT_FILE = "context";
75
+ /** Both sidecar formats are a handful of bytes. Generous, and still nowhere near a job's wall clock. */
76
+ const SIDECAR_MAX_BYTES = 4096;
77
+ /**
78
+ * The host-effective provider and model as one token, or null when the job names neither.
79
+ *
80
+ * CONSERVATIVE BY CONSTRUCTION: the sidecar is whitespace-delimited, so a value carrying a space would
81
+ * split the record and be read back as a different field. Rather than escape, refuse: anything outside
82
+ * the charset the run record already validates model ids against is no identity, and no identity means
83
+ * the reading stays usable rather than being thrown away.
84
+ */
85
+ function modelIdentity(job) {
86
+ const provider = typeof job?.provider === "string" ? job.provider : "";
87
+ const model = typeof job?.model === "string" ? job.model : "";
88
+ if (provider === "" || model === "") return null;
89
+ // Lowercased first, the same normalisation the run record's own model ids get, so a trigger written
90
+ // `Claude-Sonnet` and one written `claude-sonnet` are one model rather than two -- and so that a
91
+ // perfectly ordinary id does not fall out of the charset below and silently stop stamping.
92
+ const id = `${provider}/${model}`.toLowerCase();
93
+ return /^[a-z0-9][a-z0-9._:/-]{0,127}$/.test(id) ? id : null;
94
+ }
47
95
 
48
96
  /**
49
97
  * Read-path outcomes. Every one is a named cold start rather than a bare `false`: a feature that fails
@@ -56,6 +104,9 @@ export function makeSessionStore({
56
104
  sessionsDir,
57
105
  ttlDays,
58
106
  maxBytes,
107
+ maxAgeDays = 0,
108
+ maxResumeChain = 0,
109
+ maxContextPct = null,
59
110
  log = () => {},
60
111
  now = () => Date.now(),
61
112
  fs = { copyFileSync, lstatSync, mkdirSync, openSync, closeSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync },
@@ -83,11 +134,16 @@ export function makeSessionStore({
83
134
  // CLI run, an unresolvable head ref), so it gets no mount and no transcript on disk.
84
135
  if (key === null) return null;
85
136
 
137
+ // The model this job will actually run, for the context bound. A key is (kind, repo, ref) and
138
+ // carries NO model, so two triggers on one issue can name different ones, and the same token
139
+ // count is 78% of a 32k window and 2.5% of a 1M one. Carried on the session object rather than
140
+ // read again at promote time, so the reading is stamped with the model that produced it.
141
+ const modelId = modelIdentity(job);
86
142
  const hostDir = join(jobDir, "session");
87
143
  const staged = join(hostDir, SESSION_FILE_NAME);
88
144
  fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 });
89
145
 
90
- const verdict = readCanonical(key, piVersion);
146
+ const verdict = readCanonical(key, piVersion, modelId);
91
147
  if (verdict.resume) {
92
148
  fs.copyFileSync(canonicalFile(key), staged);
93
149
  } else {
@@ -98,7 +154,7 @@ export function makeSessionStore({
98
154
  fs.writeFileSync(staged, "");
99
155
  }
100
156
  log("session_resolved", { key, resume: verdict.resume, reason: verdict.reason });
101
- return { hostDir, key, ...verdict };
157
+ return { hostDir, key, modelId, ...verdict };
102
158
  } catch (err) {
103
159
  // A history fault must never fail the prepare that asked.
104
160
  log("session_store_failed", { phase: "resolve", reason: err?.message });
@@ -114,7 +170,7 @@ export function makeSessionStore({
114
170
  * one key is a real shape (REQ-QUEUE-BURST-NO-DROP), and last-write-wins there would interleave two
115
171
  * agents' turns into one transcript.
116
172
  */
117
- function promoteSession(session, { piVersion = null } = {}) {
173
+ function promoteSession(session, { piVersion = null, context = null } = {}) {
118
174
  // The second DI-seam backstop, and unreachable for the same reason as the `!sessionsDir` return
119
175
  // above: sessionKeyFor is total and binary (null, or 32 hex chars), so resolveSession returns null
120
176
  // rather than a keyless session, and processor.mjs only calls this when prepare handed it one. Kept
@@ -137,16 +193,42 @@ export function makeSessionStore({
137
193
  let fd;
138
194
  try {
139
195
  fd = fs.openSync(lock, "wx"); // exclusive create IS the lock; no daemon, no lease
140
- } catch {
196
+ } catch (err) {
197
+ // EEXIST is the only failure that MEANS locked. A read-only directory, a full disk or a
198
+ // vanished store all failed to create the lock too, and reporting those as `locked` sends an
199
+ // operator looking for a stuck lock file that does not exist. Anything else falls through to
200
+ // the outer catch and reports `promote-failed`, which is what actually happened.
201
+ if (err?.code !== "EEXIST") throw err;
141
202
  log("session_promote_skipped", { key: session.key, reason: "locked" });
142
203
  return { promoted: false, reason: "locked" };
143
204
  }
144
205
  try {
145
206
  // Atomic swap: a reader either sees the old file or the new one, never a half-written one.
146
207
  const tmp = `${canonicalFile(session.key)}.incoming`;
208
+ try {
209
+ // `copyFileSync` follows a link at the DESTINATION, so a link planted at this name would
210
+ // receive the whole transcript and leave the canonical path pointing at it. The key
211
+ // directory's name is derived rather than random, so the path is precomputable by anyone
212
+ // who knows the repository and the branch; unlinking removes the link, never its target.
213
+ fs.unlinkSync(tmp);
214
+ } catch {
215
+ // Absent is the desired state.
216
+ }
147
217
  fs.copyFileSync(staged, tmp);
148
218
  fs.renameSync(tmp, canonicalFile(session.key));
149
219
  fs.writeFileSync(join(dir, PI_VERSION_FILE), String(piVersion ?? ""));
220
+ // The two sidecars, immediately after the swap and under the same lock. NOT part of the swap
221
+ // itself, which is one rename and cannot be widened: what the lock buys them is that no
222
+ // other job can interleave, and what the ordering buys them is that they never describe a
223
+ // transcript older than the one now in place.
224
+ //
225
+ // EACH IS CAUGHT SEPARATELY, and that is not defensiveness for its own sake. These writes
226
+ // run AFTER the transcript is already promoted, so letting one throw would return
227
+ // `promote-failed` for a promotion that demonstrably happened -- a record that says the next
228
+ // run will cold start when it will in fact resume, which is worse than the bookkeeping loss
229
+ // it is reporting.
230
+ writeSidecar(dir, RESUME_CHAIN_FILE, session.key, chainValue(session));
231
+ writeContextSidecar(dir, session, context);
150
232
  } finally {
151
233
  fs.closeSync(fd);
152
234
  try {
@@ -164,6 +246,79 @@ export function makeSessionStore({
164
246
  }
165
247
  }
166
248
 
249
+ /**
250
+ * The counter's next value. `session.resume` is the HOST's own decision to hand this key's transcript
251
+ * to a container, which is the only half of the exchange the container cannot influence; `resumed` (the
252
+ * container's verdict) is deliberately ignored for the counter and kept in the signature only because
253
+ * the record's own merge still wants it. A cold start resets, so a lineage always gets a fresh start
254
+ * from its next COMPLETED run -- a run that never completes promotes nothing and resets nothing, which
255
+ * is the safe direction: the key simply keeps cold-starting.
256
+ */
257
+ function chainValue(session) {
258
+ return String(session.resume === true ? readResumeChain(session.key) + 1 : 0);
259
+ }
260
+
261
+ /**
262
+ * One sidecar write. Two properties, both deliberate.
263
+ *
264
+ * **It cannot write THROUGH a link.** `writeFileSync` follows one, which would turn a planted symlink
265
+ * in a key directory into a truncating write of any worker-writable file, with the container's own
266
+ * integers as the payload. Writing a temp and renaming over the name replaces whatever is there --
267
+ * link included -- with a regular file, and never opens the link's target. The temp is unlinked first
268
+ * for the same reason, since a planted link at THAT name would be the same hole one step along. The
269
+ * read side's `lstat` guard is the other half of this; neither is sufficient alone.
270
+ *
271
+ * **It is never fatal.** This runs AFTER the transcript is already promoted, so throwing would return
272
+ * `promote-failed` for a promotion that demonstrably happened, telling an operator the next run will
273
+ * cold start when it will in fact resume. The bookkeeping loss is logged and the truth is kept.
274
+ */
275
+ function writeSidecar(dir, name, key, value) {
276
+ const file = join(dir, name);
277
+ const tmp = `${file}.incoming`;
278
+ try {
279
+ try {
280
+ fs.unlinkSync(tmp);
281
+ } catch {
282
+ // Absent is the desired state.
283
+ }
284
+ fs.writeFileSync(tmp, value);
285
+ fs.renameSync(tmp, file);
286
+ } catch (err) {
287
+ log("session_sidecar_failed", { key, file: name, reason: err?.message });
288
+ }
289
+ }
290
+
291
+ /**
292
+ * The context sidecar, whose three cases are all different.
293
+ *
294
+ * A run that RESUMED and measured nothing keeps the previous reading: the transcript it promoted is
295
+ * the old one extended, so the last real measurement is the closest true statement available, and a
296
+ * zero would read as "the context emptied", which cannot have happened.
297
+ *
298
+ * A COLD START, though, promoted a transcript that shares nothing with the one the old reading
299
+ * described, so the reading must GO. Keeping it is what turned a single high measurement into a key
300
+ * that refused itself forever: the gate read a stale number, cold-started, and the cold start left the
301
+ * same number behind for the next run to read. That loop had no exit that did not involve deleting the
302
+ * store by hand.
303
+ */
304
+ function writeContextSidecar(dir, session, context) {
305
+ const file = join(dir, CONTEXT_FILE);
306
+ if (session.resume !== true) {
307
+ try {
308
+ fs.unlinkSync(file);
309
+ } catch {
310
+ // Absent is the desired state, so failing to remove what is not there is success.
311
+ }
312
+ return;
313
+ }
314
+ if (!context) return;
315
+ // The model rides along because the ratio is meaningless without it: a key is (kind, repo, ref) and
316
+ // carries no model, so two triggers on one issue can run different ones, and 25k tokens is 78% of a
317
+ // 32k window and 2.5% of a 1M one. A reading from another model is not a reading about this one.
318
+ const stamp = session.modelId ? ` ${session.modelId}` : "";
319
+ writeSidecar(dir, CONTEXT_FILE, session.key, `${context.tokens} ${context.window}${stamp}`);
320
+ }
321
+
167
322
  function keyDir(key) {
168
323
  return join(sessionsDir, key);
169
324
  }
@@ -172,7 +327,7 @@ export function makeSessionStore({
172
327
  }
173
328
 
174
329
  /** The read path, gate by gate. The FIRST miss wins and names itself. */
175
- function readCanonical(key, piVersion) {
330
+ function readCanonical(key, piVersion, modelId) {
176
331
  const file = canonicalFile(key);
177
332
  const check = inspectFile(file);
178
333
  if (!check.ok) return COLD(check.reason);
@@ -184,26 +339,150 @@ export function makeSessionStore({
184
339
  // repair that mid-run, so a version change is a cold start rather than a mid-run failure. An
185
340
  // image that declares no version never resumes -- the safe direction, never "assume it matches".
186
341
  if (piVersion === null) return COLD("pi-version-changed");
187
- let stamped = null;
188
- try {
189
- stamped = String(fs.readFileSync(join(keyDir(key), PI_VERSION_FILE), "utf8")).trim();
190
- } catch {
191
- return COLD("pi-version-changed");
342
+ // Through the same guarded read as the two sidecars below it. This one predates them and was the
343
+ // one unguarded read left in the key directory; a symlink here would have decided a gate on the
344
+ // contents of some other file entirely.
345
+ const stamped = readSidecar(key, PI_VERSION_FILE);
346
+ if (stamped === null || stamped !== piVersion) return COLD("pi-version-changed");
347
+
348
+ // How many times in a row this key has already been resumed. Placed HERE, ahead of the header read,
349
+ // for two reasons. It is a small sidecar read exactly like the pi-version arm above it, so refusing
350
+ // on it skips pulling a transcript that may be megabytes; and unlike every other arm it asks about
351
+ // the LINEAGE rather than the file, so it needs nothing the file could tell it.
352
+ //
353
+ // The cost of that placement, stated rather than left to be discovered: a transcript that is both
354
+ // chain-exhausted AND corrupt reports the chain. That is the intentional refusal of the two, and the
355
+ // corruption is not hidden, only deferred -- this cold start's own promotion resets the counter, so
356
+ // the very next run reads the file and reports `unparseable`.
357
+ //
358
+ // FAILS OPEN on absence, which is the opposite of the age gate one arm down and deliberate. Every
359
+ // key that existed before this counter did has no file, and reading that as "already exhausted"
360
+ // would cold-start an operator's entire store the day they set the bound.
361
+ if (maxResumeChain > 0 && readResumeChain(key) >= maxResumeChain) return COLD("resume-chain-too-long");
362
+
363
+ // How full the context already is, against a ceiling the HOST owns. Not a duplicate of pi's own
364
+ // compaction threshold and deliberately not read from it: pi's is settable in a serviced repo's
365
+ // .pi/settings.json, so it is a line the repository can move, and this one cannot be. Past that
366
+ // threshold what a resumed job replays is not the transcript but a model-written summary of it,
367
+ // produced while that model was reading attacker-authored text (OQ-003), so this is a safety bound
368
+ // before it is an economic one.
369
+ //
370
+ // FAILS OPEN and INVENTS NO DENOMINATOR. No sidecar (every key promoted before this shipped, and
371
+ // every key under an image whose runner predates it), a compaction that left pi's own count
372
+ // unknown, or a window of zero all mean the gate has nothing to act on, and a gate with nothing to
373
+ // act on passes. A bytes-against-window guess was rejected rather than used as a fallback: the
374
+ // transcript is the whole branch INCLUDING what compaction folded away, so it over-reads exactly
375
+ // past the threshold this exists to catch, and there is no bytes-to-tokens calibration here to
376
+ // make it mean anything.
377
+ if (maxContextPct !== null) {
378
+ const seen = readContext(key);
379
+ // A reading STAMPED WITH ANOTHER MODEL is not a reading about this one, and using it is wrong in
380
+ // both directions: it refuses a job whose window is far larger than the one that was measured,
381
+ // and it passes one whose window is far smaller. Unknown on either side stays usable, so a
382
+ // deployment that names no model per trigger keeps the bound it had.
383
+ const foreign = seen !== null && seen.modelId !== null && modelId !== null && seen.modelId !== modelId;
384
+ if (seen !== null && !foreign && (seen.tokens * 100) / seen.window >= maxContextPct) return COLD("context-too-full");
192
385
  }
193
- if (stamped !== piVersion) return COLD("pi-version-changed");
194
386
 
195
- // Cheapest real shape check, and the last one: the first line must be a pi session header. Anything
196
- // else the runner would throw on, so refusing here keeps the container's degrade path for genuine
197
- // surprises rather than for a file we could already tell was wrong.
387
+ // Cheapest real shape check, and the last one before the header's own contents are used: the first
388
+ // line must be a pi session header. Anything else the runner would throw on, so refusing here keeps
389
+ // the container's degrade path for genuine surprises rather than for a file we could already tell
390
+ // was wrong.
391
+ let header = null;
198
392
  try {
199
393
  const head = String(fs.readFileSync(file, "utf8")).split("\n", 1)[0];
200
- if (JSON.parse(head)?.type !== "session") return COLD("unparseable");
394
+ header = JSON.parse(head);
395
+ if (header?.type !== "session") return COLD("unparseable");
201
396
  } catch {
202
397
  return COLD("unparseable");
203
398
  }
399
+
400
+ // The CONVERSATION's age, and it is a DIFFERENT CLOCK from `expired` above rather than a finer
401
+ // setting of it. The TTL reads the transcript's mtime, which the PROMOTE rename refreshes -- and only
402
+ // that: `copyFileSync` stamps its destination, never its source, so the resolve half leaves the
403
+ // canonical file's mtime alone (measured, because the obvious reading of the two call sites says
404
+ // otherwise). So `expired` is time since the last COMPLETED run on this key, and a lineage whose runs
405
+ // keep completing never expires however old its first turn is. pi's header carries the instant the
406
+ // session was created, so this
407
+ // costs no new persisted state -- the line is already read and parsed one gate up, and until now
408
+ // only its `type` was looked at.
409
+ //
410
+ // The arm is LAST because the earlier gates are cheaper and because a corrupt file is corrupt rather
411
+ // than old: `unparseable` must keep winning over this, or a damaged transcript would be reported as
412
+ // a lineage that aged out.
413
+ //
414
+ // UNREADABLE FAILS CLOSED, on the pi-version gate's precedent one arm up: a header with no usable
415
+ // timestamp cannot be shown to be young enough, and "assume it matches" is the direction that
416
+ // silently keeps resuming. Like `pi-version-changed`, one token covers all three causes (absent,
417
+ // wrong type, unparseable).
418
+ //
419
+ // A timestamp in the FUTURE passes, deliberately. It buys nothing to refuse one: the agent owns
420
+ // /session, so anything able to write a future timestamp is equally able to write the current one,
421
+ // and refusing would convert ordinary clock skew between a container and its host into a cold start
422
+ // for every key on the deployment.
423
+ if (maxAgeDays > 0) {
424
+ const started = Date.parse(typeof header.timestamp === "string" ? header.timestamp : "");
425
+ if (!Number.isFinite(started)) return COLD("conversation-too-old");
426
+ if (now() - started > maxAgeDays * 86400000) return COLD("conversation-too-old");
427
+ }
204
428
  return { resume: true, reason: "resumed", bytes: check.bytes };
205
429
  }
206
430
 
431
+ /**
432
+ * Every sidecar read goes through here, and it is the same load-bearing check `inspectFile` makes on
433
+ * the transcript: **`lstat`, regular files only.** The canonical store is host-only and never mounted,
434
+ * so nothing in a container can plant a link here -- but the directory NAME is derived rather than
435
+ * random (`sha256(kind, repo, ref)`), so anyone who knows the repository and the branch can compute it
436
+ * and pre-create the path. `readFileSync` and `writeFileSync` both follow links, which would turn a
437
+ * planted symlink into a read of any worker-readable file on the gate's path, and a promotion into a
438
+ * truncating write of any worker-writable one. The transcript has been guarded against exactly this
439
+ * since the feature shipped; these files inherit it rather than being the exception.
440
+ *
441
+ * SIZE-BOUNDED for the same reason the transcript is. Both formats are a handful of bytes, `maxBytes`
442
+ * does not cover them, and reading a 2.5 GiB file on the job's own path costs half a minute of wall
443
+ * clock before any container starts.
444
+ */
445
+ function readSidecar(key, name) {
446
+ try {
447
+ const file = join(keyDir(key), name);
448
+ const st = fs.lstatSync(file);
449
+ if (!st.isFile() || st.size === 0 || st.size > SIDECAR_MAX_BYTES) return null;
450
+ return String(fs.readFileSync(file, "utf8")).trim();
451
+ } catch {
452
+ return null;
453
+ }
454
+ }
455
+
456
+ /**
457
+ * The consecutive-delivery counter for a key, or 0 when there is not a readable one. Never throws and
458
+ * never guesses: a missing, empty, corrupt or negative counter is 0, so the only way to be refused by
459
+ * the chain bound is for this store to have written a number that reaches it.
460
+ */
461
+ function readResumeChain(key) {
462
+ const raw = readSidecar(key, RESUME_CHAIN_FILE);
463
+ if (raw === null) return 0;
464
+ const n = Number.parseInt(raw, 10);
465
+ // `String(n) === raw` is the same anti-truncation guard config.mjs applies to every integer knob,
466
+ // and it is what keeps a corrupt "3.5" from being read as a chain of three.
467
+ return Number.isInteger(n) && n > 0 && String(n) === raw ? n : 0;
468
+ }
469
+
470
+ /**
471
+ * The stored context occupancy for a key, or `null` when there is no measurement. Never throws, never
472
+ * guesses, and never returns a partial: anything it cannot read as two positive integers is no
473
+ * measurement at all, which the caller treats as "pass" rather than as zero.
474
+ */
475
+ function readContext(key) {
476
+ const raw = readSidecar(key, CONTEXT_FILE);
477
+ if (raw === null) return null;
478
+ const [rawTokens, rawWindow, rawModel] = raw.split(/\s+/);
479
+ const tokens = Number.parseInt(rawTokens, 10);
480
+ const window = Number.parseInt(rawWindow, 10);
481
+ if (!Number.isInteger(tokens) || !Number.isInteger(window) || tokens < 0 || window <= 0) return null;
482
+ if (String(tokens) !== rawTokens || String(window) !== rawWindow) return null;
483
+ return { tokens, window, modelId: rawModel ?? null };
484
+ }
485
+
207
486
  /**
208
487
  * lstat, REGULAR FILES ONLY -- and this is the one line in the file that is load-bearing security
209
488
  * rather than hygiene.
package/src/start.mjs CHANGED
@@ -25,6 +25,7 @@ import { makeSessionStore } from "./session-store.mjs";
25
25
  import { loadPauseWindows, pauseUntilMs } from "./pause-windows.mjs";
26
26
  import { makeQueue } from "./queue.mjs";
27
27
  import { makeRunContainer } from "./run-container.mjs";
28
+ import { makeSecretsResolver } from "./secrets.mjs";
28
29
  import { buildRecord, makeFindPreviousRun, makeLogReaper, makeLogSink, makeRecordWriter } from "./run-history.mjs";
29
30
  import { effectiveSettings, readOverlay } from "./runtime-settings.mjs";
30
31
  import { loadSchedules } from "./schedules.mjs";
@@ -162,6 +163,7 @@ export async function startWorker(
162
163
  makeLogReaper: makeLogReaperFn = makeLogReaper,
163
164
  makeSandboxReaper: makeSandboxReaperFn = makeSandboxReaper,
164
165
  makeRunContainer: makeRunContainerFn = makeRunContainer,
166
+ makeSecretsResolver: makeSecretsResolverFn = makeSecretsResolver,
165
167
  makeImagePreflight: makeImagePreflightFn = makeImagePreflight,
166
168
  makeEgressPreflight: makeEgressPreflightFn = makeEgressPreflight,
167
169
  makeGitLabAuth: makeGitLabAuthFn = makeGitLabAuth,
@@ -298,6 +300,9 @@ export async function startWorker(
298
300
  sessionsDir: config.sessionsDir,
299
301
  ttlDays: config.sessionsTtlDays,
300
302
  maxBytes: config.sessionMaxBytes,
303
+ maxAgeDays: config.sessionMaxAgeDays,
304
+ maxResumeChain: config.sessionMaxResumeChain,
305
+ maxContextPct: config.sessionMaxContextPct,
301
306
  log,
302
307
  });
303
308
  // Boot sweep, beside the log reaper and for the same reason it is beside rather than inside it: these
@@ -323,7 +328,12 @@ export async function startWorker(
323
328
  log("settings_overlay_invalid", { reason: res.invalid, settingsFile });
324
329
  return { invalid: res.invalid };
325
330
  }
326
- return effectiveSettings(config, res.overlay);
331
+ // REQ-TRIGGER-SECRETS rides ALONGSIDE the ten tunables rather than inside them. `effectiveSettings`
332
+ // resolves `overlay > env` over a fixed ten-key literal, and its own tests pin that key set and
333
+ // assert an empty overlay returns the config verbatim -- so an eleventh key there would break both,
334
+ // and would also claim a precedence this key deliberately does not have (a name declared in both
335
+ // sources is refused per delivery, not silently won by either).
336
+ return { ...effectiveSettings(config, res.overlay), secretProfiles: res.overlay?.secretProfiles ?? {} };
327
337
  };
328
338
 
329
339
  // Resolve the Worker constructor's slot count once from the overlay: a present overlay may raise or lower
@@ -418,6 +428,14 @@ export async function startWorker(
418
428
  // real path; the difference shows under an injected env, where the store would be built from the
419
429
  // synthetic value while the gate read the process one.
420
430
  sessionsDir: config.sessionsDir,
431
+ // REQ-TRIGGER-SECRETS. Built here for the image and egress preflights' reason: one deployment
432
+ // value, one place, so the gate that refuses an unknown profile and the spawn that runs it cannot
433
+ // disagree about which resolvers exist. The env-declared table is parsed once at boot (it is env,
434
+ // and a change to it is a restart), while the OVERLAY half arrives per job through index.mjs,
435
+ // because the settings file is read at each job start and an operator who declares a profile in
436
+ // the panel should not have to restart the worker to use it. A deployment that declares nothing
437
+ // spawns nothing at all: the gate only calls this when a trigger is armed.
438
+ resolveSecrets: makeSecretsResolverFn({ envProfiles: config.secretProfiles, roots: config.secretResolverRoots, timeoutMs: config.secretResolveTimeoutMs, forwardEnv: config.forwardEnv, log }),
421
439
  runContainer: makeRunContainerFn({
422
440
  image: config.jobImage,
423
441
  hostEnv: env,