Skip to content

Resume eligibility bounds: conversation age, context fullness, chain length #186

Description

@edgehero

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions