What
run.resume decides eligibility with six read-path gates today (existence, regular-file, byte size, TTL-by-mtime, pi-version, header shape) — none of which measures what an operator actually worries about when a lineage gets long: how old the conversation is, how full the context window is, and how many consecutive times the key has already been resumed. Add three eligibility bounds, each opt-in, each producing a cold start with a named session.reason — never a dropped job:
- age — resume only when the conversation is younger than a bound (the existing TTL gate measures the wrong clock; gap 1)
- context — resume only when the saved session's context occupancy is below a threshold, e.g. 80% of the model's window (gap 2)
- chain — resume only when the key has been resumed fewer than N consecutive times, e.g. 3 (gap 3)
The degrade direction is already settled by the spec, not open for debate: every read-path miss is a cold start, never a failure (requirements.md:1123-1127); refusing the job outright is reserved for sessions-dir-unset alone (processor.mjs:170-203).
What already works (don't redo this)
- The read-gate ladder in
readCanonical (session-store.mjs:175-205), first miss wins: absent → not-a-regular-file → too-large (PI_SESSION_MAX_BYTES) → expired (PI_SESSIONS_TTL_DAYS, default 14, mtime) → pi-version-changed → unparseable. New bounds are new arms in this ladder using the same COLD helper (:53) — "a feature that fails open is otherwise indistinguishable from a feature nobody switched on".
- The age gate already runs at OPEN as well as at boot reap — OQ-007's session-store answer: a stale transcript is a live input to a future job, not debris (
open-questions.md:809, session-store.mjs:234-237).
- The
session.reason plumbing end to end: three producers with merge precedence (interfaces.md:1797-1808), the record's {resumed, reason, bytes} block (interfaces.md:1784-1786), the operator table (docs/sessions.md:116-122).
- The token discipline:
sessions-dir-unset's precedent — the token greps to the spec text that mandates it (interfaces.md:2257) — and the collision rule: the nested and terminal enums must never share a token (interfaces.md:2253).
Gap 1 — the TTL measures the wrong clock
mtime is refreshed by both the resolve copy (session-store.mjs:92) and the promote rename (:148), so expired measures time since the last completed run on this key, not conversation age — a key touched daily never expires, however old its first turn is. The alternative clock is already on the one line the store parses: pi's session header carries timestamp (SessionHeader, pinned pi's session-manager.d.ts:5-12), and readCanonical reads that line today but checks only type (:198-203). A conversation-age bound is therefore readable with zero new persisted state; its token must not collide with expired.
Gap 2 — context fullness (the 80% bound)
Nothing measures occupancy today, and there are exactly two candidate measurements:
- pi's own number:
ContextUsage.percent via getContextUsage()/getSessionStats() (pinned pi's agent-session.d.ts:594, extensions/types.d.ts:192-198) — exactly the quantity wanted, but it exists in the container at the END of a run, and tokens is explicitly nullable right after compaction. Using it means the runner emits it on the exit line (run-job.mjs:271) and the promote path persists it beside the transcript — the pi-version sidecar (session-store.mjs:45,149) is the per-key sidecar precedent, written under the same lock. Both symbols verified against the pinned artifact, not docs (CONST-PI-VERSION-PINNED; session.mjs:21-27 records pi's JSDoc being wrong once already).
- host-side proxy:
bytes (already in hand) against the model's window — getPricedModel(provider, id) already returns the whole pi-ai Model, so contextWindow is readable with no new dependency (pricing.mjs:51-58); but bytes→tokens has no calibration in this repo.
Why this is a safety bound, not just economics — OQ-003 (open-questions.md:45-65): the "jobs are short-lived so compaction never triggers" mitigation was STRUCK the day run.resume landed; past pi's compaction threshold a resumed job replays a model-generated summary written while that model was reading attacker-authored text. And pi's own threshold is repo-movable via .pi/settings.json, so a host-side bound is a distinct, host-owned line — not a duplicate of pi's.
Gap 3 — the resume-chain bound
No counter exists anywhere: the record's session block is three fields, and there is deliberately no key→record index (DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED, design.md:1746-1783 — an index is a query surface the flat-file doctrine refuses). A chain bound therefore needs one small piece of new persisted state: a counter file beside current.jsonl in the key dir, on the pi-version sidecar precedent — incremented under the promote lock when the promoted run resumed, reset by any cold start so a lineage can begin again. INT-SESSION-STORE-CONTRACT's closed file enumeration (interfaces.md:1970-1978) grows by one line.
Where it lives
worker/src/session-store.mjs (the ladder, the sidecar), config knobs beside sessionsTtlDays/sessionMaxBytes (worker/src/config.mjs:197-200), mergeSession untouched in shape (processor.mjs:403-414), and only if the exit-line route wins gap 2: image/runner/src/session.mjs + run-job.mjs:271. Specs in the same PR: REQ-RESUMABLE-SESSION, INT-SESSION-STORE-CONTRACT (the read-path order is itself an enumerated contract, interfaces.md:1979-1983), INT-RUN-HISTORY-FILE-CONTRACT (enum row, producer table, merge precedence), docs/sessions.md:116-122.
Explicitly NOT proposed
- Refusing the job. Every bound here is cold-start-with-reason;
sessions-dir-unset stays the only fail-closed case (requirements.md:1128-1131).
- A key→record index or
SessionManager.continueRecent — both already on the design's rejected list (design.md:1764-1781).
- String-shaped record fields: new facts ride as integers and fixed enum tokens only, the admitted classes (
interfaces.md:1849-1851).
- Duplicating pi's compaction thresholds: pi keeps its line, the host draws its own, and neither reads the other's config.
Acceptance
- A session whose header timestamp is older than the configured age bound resolves cold with its own reason token, distinct from
expired; a daily-touched mtime cannot keep it eligible.
- With the context bound set: occupancy at or above the threshold resolves cold with its token; below it, resumes; when no measurement exists (nullable, or the sidecar route not yet written), the gate acts on nothing — it passes and invents no denominator.
- With the chain bound set to 3: three consecutive resumed completions on one key make the fourth resolve cold with its token, and the reset lets the lineage start over.
- Unset bounds change nothing byte-for-byte; every new token lands in the enum row, the three-producer table and the merge precedence, collides with neither enum, and gets its row in
docs/sessions.md; specs and code move in the same PR with revision rows.
What
run.resumedecides eligibility with six read-path gates today (existence, regular-file, byte size, TTL-by-mtime, pi-version, header shape) — none of which measures what an operator actually worries about when a lineage gets long: how old the conversation is, how full the context window is, and how many consecutive times the key has already been resumed. Add three eligibility bounds, each opt-in, each producing a cold start with a namedsession.reason— never a dropped job:The degrade direction is already settled by the spec, not open for debate: every read-path miss is a cold start, never a failure (
requirements.md:1123-1127); refusing the job outright is reserved forsessions-dir-unsetalone (processor.mjs:170-203).What already works (don't redo this)
readCanonical(session-store.mjs:175-205), first miss wins: absent → not-a-regular-file → too-large (PI_SESSION_MAX_BYTES) → expired (PI_SESSIONS_TTL_DAYS, default 14, mtime) → pi-version-changed → unparseable. New bounds are new arms in this ladder using the sameCOLDhelper (:53) — "a feature that fails open is otherwise indistinguishable from a feature nobody switched on".open-questions.md:809,session-store.mjs:234-237).session.reasonplumbing end to end: three producers with merge precedence (interfaces.md:1797-1808), the record's{resumed, reason, bytes}block (interfaces.md:1784-1786), the operator table (docs/sessions.md:116-122).sessions-dir-unset's precedent — the token greps to the spec text that mandates it (interfaces.md:2257) — and the collision rule: the nested and terminal enums must never share a token (interfaces.md:2253).Gap 1 — the TTL measures the wrong clock
mtime is refreshed by both the resolve copy (
session-store.mjs:92) and the promote rename (:148), soexpiredmeasures time since the last completed run on this key, not conversation age — a key touched daily never expires, however old its first turn is. The alternative clock is already on the one line the store parses: pi's session header carriestimestamp(SessionHeader, pinned pi'ssession-manager.d.ts:5-12), andreadCanonicalreads that line today but checks onlytype(:198-203). A conversation-age bound is therefore readable with zero new persisted state; its token must not collide withexpired.Gap 2 — context fullness (the 80% bound)
Nothing measures occupancy today, and there are exactly two candidate measurements:
ContextUsage.percentviagetContextUsage()/getSessionStats()(pinned pi'sagent-session.d.ts:594,extensions/types.d.ts:192-198) — exactly the quantity wanted, but it exists in the container at the END of a run, andtokensis explicitly nullable right after compaction. Using it means the runner emits it on the exit line (run-job.mjs:271) and the promote path persists it beside the transcript — thepi-versionsidecar (session-store.mjs:45,149) is the per-key sidecar precedent, written under the same lock. Both symbols verified against the pinned artifact, not docs (CONST-PI-VERSION-PINNED;session.mjs:21-27records pi's JSDoc being wrong once already).bytes(already in hand) against the model's window —getPricedModel(provider, id)already returns the whole pi-aiModel, socontextWindowis readable with no new dependency (pricing.mjs:51-58); but bytes→tokens has no calibration in this repo.Why this is a safety bound, not just economics — OQ-003 (
open-questions.md:45-65): the "jobs are short-lived so compaction never triggers" mitigation was STRUCK the dayrun.resumelanded; past pi's compaction threshold a resumed job replays a model-generated summary written while that model was reading attacker-authored text. And pi's own threshold is repo-movable via.pi/settings.json, so a host-side bound is a distinct, host-owned line — not a duplicate of pi's.Gap 3 — the resume-chain bound
No counter exists anywhere: the record's session block is three fields, and there is deliberately no key→record index (
DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED,design.md:1746-1783— an index is a query surface the flat-file doctrine refuses). A chain bound therefore needs one small piece of new persisted state: a counter file besidecurrent.jsonlin the key dir, on thepi-versionsidecar precedent — incremented under the promote lock when the promoted run resumed, reset by any cold start so a lineage can begin again.INT-SESSION-STORE-CONTRACT's closed file enumeration (interfaces.md:1970-1978) grows by one line.Where it lives
worker/src/session-store.mjs(the ladder, the sidecar), config knobs besidesessionsTtlDays/sessionMaxBytes(worker/src/config.mjs:197-200),mergeSessionuntouched in shape (processor.mjs:403-414), and only if the exit-line route wins gap 2:image/runner/src/session.mjs+run-job.mjs:271. Specs in the same PR: REQ-RESUMABLE-SESSION, INT-SESSION-STORE-CONTRACT (the read-path order is itself an enumerated contract,interfaces.md:1979-1983), INT-RUN-HISTORY-FILE-CONTRACT (enum row, producer table, merge precedence),docs/sessions.md:116-122.Explicitly NOT proposed
sessions-dir-unsetstays the only fail-closed case (requirements.md:1128-1131).SessionManager.continueRecent— both already on the design's rejected list (design.md:1764-1781).interfaces.md:1849-1851).Acceptance
expired; a daily-touched mtime cannot keep it eligible.docs/sessions.md; specs and code move in the same PR with revision rows.