When a pull request a pi-dispatch job opened gets feedback, the follow-up job is a cold start: new container, fresh clone, empty transcript. The agent re-explores the repository and re-derives the decisions it made an hour ago before it can act on a two-line comment.
A trigger can opt into continuing that conversation instead.
Off by default. A deployment that sets nothing writes nothing to disk and its containers are launched with exactly the arguments they were before this feature existed.
PI_SESSIONS_DIR has no default, deliberately. PI_LOGS_DIR and PI_SETTINGS_FILE fall back to
~/.pi-dispatch; a transcript does not, because unset here means the feature is unavailable and a trigger
that armed run.resume refuses pre-spend rather than running unpersisted and looking like it worked.
A default would turn that refusal into a silent success on a path you never chose.
mkdir -p ~/.pi-dispatch/sessions && chmod 700 ~/.pi-dispatch/sessionsPI_SESSIONS_DIR=/home/you/.pi-dispatch/sessions
PI_SESSIONS_TTL_DAYS=14 # default 14; 0 = keep forever
PI_SESSION_MAX_BYTES=8388608 # default 8 MiB; 0 = no cap
PI_SESSION_MAX_AGE_DAYS= # unset/0 = no bound; how old the conversation itself may be
PI_SESSION_MAX_RESUME_CHAIN= # unset/0 = no bound; how many times in a row one key may be resumed
PI_SESSION_MAX_CONTEXT_PCT= # unset = no bound; 1-100, e.g. 80: refuse a resume into a context already this fullpi-dispatch doctor reports the store whenever a trigger arms the flag, and fails that check when
PI_SESSIONS_DIR is unset. It also prints which of the four bounds are on, because a knob that is silent
when unset gives you no way to tell a deliberate "no bound" from a forgotten one, and it warns when
PI_SESSION_MAX_CONTEXT_PCT is set, since that one needs a job image whose runner reports the
measurement and does nothing at all on an older one. On an image that does report it, readings are kept
whether or not the bound is set, so switching the bound on applies from the next job rather than from the
next run of each key. One case fails closed: a trigger that sets "resume": true with no store
configured is refused before it costs anything, as a policy refusal that reserves no budget slot and
starts no container, rather than running unpersisted and looking like it worked. That is the whole reason
the refusal exists: a green run is exactly how an operator comes to believe a feature is on while it is
off. It lands in the run record as "reason": "sessions-dir-unset" and comments on the issue naming the
two ways out, set the variable or drop the flag. The refusal is per delivery rather than at load, because
whether a store exists is a property of the deployment and not of the triggers file; doctor is what
tells you before the first delivery arrives.
A transcript is the most sensitive thing this system stores. It holds the issue text, the contents of
every file the agent read, its tool output, and its own reasoning. That is strictly more than
logs/<jobId>.log holds — and that one is opt-in and off by default for exactly this reason. Unlike the
raw log, a transcript has to exist for the feature to work at all.
Put the store outside every git repository. The shipped .gitignore covers the conventional layout
(a sessions/ directory) and cannot cover a path it has never seen.
Who can be handed one. Sessions are keyed by (forge, repository, branch), and a branch name is chosen
by anyone who can push to your repository. It is tempting to reason that pi/issue-7 names issue 7's
work forever — it does not. That branch name is something the agent was asked to use; nothing verifies
it, and branches, unlike issue numbers, can be deleted and re-created by someone else. So the population
that can receive a transcript is your repository's push-access population.
That is one step wider than the population pi-dispatch already trusts to put code in a job container, and what they gain is short: the model's own reasoning, and anything a credential-bearing command echoed. It is worth being concrete about how small that step is: pi-dispatch already lets anyone who can land a commit on your default branch run code inside a job container, with the job token and open network egress. This lets a slightly wider group read a transcript. Wider population, much narrower capability.
So: do not enable this if you service repositories whose push access you do not control. If your
deployment is your own repos, or your team's, the people who could be handed a transcript are people who
can already push code the agent will run — and refusing them a transcript would be a lock on the wrong
door. If you run pi-dispatch for repositories belonging to others, run.resume is not for you, in the
same way context discovery is not (SECURITY.md). Nothing enforces this: no code here can tell the two
situations apart, which is exactly why it is written down in three places. specs/open-questions.md
records the full reasoning as OQ-014.
A fork pull request never resumes. No key is resolved, no mount is created, and the job is identical
to one run before this feature. That is what stops a stranger forking your repo, naming a branch
pi/issue-7, and being handed issue 7's history.
A run.resume job refuses to start under GITHUB_AUTH_SOURCE=gh. That source is your whole gh
login: full-scope and non-expiring. An env var dies with the container; a transcript is a file, and any
command that echoed an auth header put your token in it, permanently. The refusal happens at mint time, so
it costs no budget slot.
Use GITHUB_AUTH_SOURCE=app or a short-expiry fine-grained PAT, so the exposure is bounded by an expiry
rather than by whether an agent ever ran a verbose curl. If you want the trade anyway, take it explicitly:
PI_SESSIONS_ALLOW_GH_SOURCE=1It is a refusal rather than a warning because the asymmetry decides it: a warning is read once at setup, and the disclosure is permanent and silent.
| trigger | key |
|---|---|
| issue label / comment on an issue | the pi/issue-<n> branch the job is told to push to |
| comment / activity on a pull request | the PR's head branch, read from the forge API |
pi-dispatch run, chained /outbox jobs |
nothing — these never resume |
The issue and pull-request cases converge because they are the same branch: issue #7's job opens PR #8 on
pi/issue-7, and a later comment on PR #8 resolves that same branch. That join is why a branch is the key
and not a number.
Cron resume is refused at load, not yet covered. The session store is handed only to the forge
preparers: a kind: "local" job returns from prepareWorkspace before that point, so nothing would ever
resolve a key for it. Rather than accept a flag that does nothing, run.resume on a cron trigger is
refused fail-loud when the triggers file loads, the same way run.replicas is. A key for it exists in
principle (the trigger's own on.id, operator-authored and stable across fires), so this is a gap to
close rather than a permanent limit, and the refusal says so.
Command triggers refuse resume the same way, on every kind. A command job's whole prompt is the
dispatch line /name args, and what a resumed session should do with a re-dispatched command is
undesigned — so run.resume beside run.command is refused fail-loud at load, another gap to close
rather than a permanent limit (workflows.md).
Every one of these is a cold start, never a failed job, and every one is named in the run record's
session.reason so you can tell them apart. That last part was not true until recently: a refused read
stages an empty transcript, the container opens it and reports absent, and that used to overwrite the
host's answer, so expired and pi-version-changed never reached a record at all. A gate that refused
now keeps its own name on the record. What the container found is still what you see whenever the
container is the one that found it: unparseable below is reported by both, and a run whose transcript
the host resolved and the container could not use reads absent. Be careful with that last one, because
in the record it looks exactly like an ordinary first-ever cold start: the record carries the container's
verdict, not the host's intent. The worker's own session_resolved log line is where the two halves can
be told apart.
| reason | meaning |
|---|---|
absent |
first run for this key, or the previous one produced nothing |
expired |
older than PI_SESSIONS_TTL_DAYS, measured from the last completed run on the key (the promotion is what refreshes the file) |
conversation-too-old |
the conversation itself started more than PI_SESSION_MAX_AGE_DAYS ago. A different clock from expired: that one reads the transcript's mtime, which every completed run refreshes, so a lineage that keeps finishing work never ages out however old its first turn is. A header whose timestamp is missing or unreadable lands here too, because a conversation that cannot say how old it is has not been shown to be young enough. The timestamp is written by the agent's own session, so this bounds accumulation, not an adversary |
too-large |
over PI_SESSION_MAX_BYTES |
unparseable |
the first line is not a pi session header. Nothing is quarantined: the canonical file stays where it is and is re-read and re-rejected on every run, until the TTL reaper sweeps the key or a completed run promotes a replacement over it |
not-a-regular-file |
the transcript's own name is not a regular file. The check is an lstat, so a symlink is never followed. Planted in the store, this job runs cold; planted by the agent in /session, the promotion is refused instead, so the store keeps whatever it already had |
key-not-a-directory |
the key's own directory in the store is not a directory: a symlink, a regular file, a dangling link. Both edges refuse it and the entry is left alone, so this key stays cold on every run until you remove what is standing there. The store itself may be a symlink; only the key's own name is checked |
venue-changed |
the job runs in a different backend than wrote the transcript (see backends.md). A transcript from before venues were recorded counts as local, whatever your default is now. It also covers a stamp this store cannot read, and a promotion that was interrupted, or was still landing from another job on the same key, when this job read it: all three cold-start rather than risk handing one venue's conversation to another |
transcript-replaced |
the transcript this job judged, or the key directory holding it, changed while this job was staging it: usually another completed job on this key promoting a new one. The host copies the transcript outside the promotion lock, then re-checks that the file it copied is still the one its gates judged; when it is not, this run cold-starts rather than resuming a conversation no gate has seen. It is the read-side half of the same event locked reports from the write side, so the two appearing together on one key means two jobs overlapping on one pull request |
pi-version-changed |
the job image ships a different pi than wrote the transcript |
context-too-full |
the saved session's context was already at or above PI_SESSION_MAX_CONTEXT_PCT of its model's window when it was last written. The measurement comes from the job image's runner, so on an image that does not report one this bound does nothing at all; where there is no measurement the gate passes rather than guessing, and it never estimates one from the transcript's size. The reading is stamped with the model that produced it and ignored by a job running a different one, since the same token count is most of a small window and almost none of a large one. A cold start clears it, so a key cannot be refused forever on a number describing a conversation it no longer holds |
resume-chain-too-long |
the host has already handed this key's transcript to a container PI_SESSION_MAX_RESUME_CHAIN times in a row. It counts deliveries rather than what pi made of them, so an agent cannot reset it by arranging for pi to find nothing usable in a file it still receives. The count is kept for every key whether or not the bound is set, so setting it takes effect on the next job rather than that many jobs later, and the cold start it causes resets the count once that run completes: a lineage whose runs keep failing keeps cold-starting, which is the safe direction |
pi-version-changed is the one that surprises people. A transcript can outlive the pi that wrote it, and
an older session's stored tool-call arguments may not match a newer pi's tool schema. Rather than fail
mid-run, the resume is refused. Upgrading the job image costs every key one cold start, by design:
nothing is deleted, each key simply cold-starts the first time its stamped version fails to match, and its
next completed run rewrites both the transcript and the stamp.
Moving a trigger to another backend costs each of its keys one cold start in the same way, and for a sharper reason: a transcript written in one venue is never staged into another venue's container, so the next job there starts fresh and its first completed run stamps the new venue. Moving back costs one more.
Four more reasons reach session.reason from promoteSession, describing the write back to the store
rather than the read that started the job. Three of them, locked, promote-failed and
transcript-diverted, come from nowhere else and so appear only on a completed run. key-not-a-directory is one of several tokens both edges produce, since the same checks guard both, and it
is in the read table above for that reason: the same shape refuses the read that starts a job and the write
that ends it. absent, too-large and not-a-regular-file are the others. A refused promotion
outranks everything else on the line, because it says why the NEXT run for this key will cold start:
| reason | meaning |
|---|---|
key-not-a-directory |
the key's own directory in the store is not a directory, or is no longer the one this promotion prepared (see the table above). Caught before the lock, nothing is written at all; caught after the swap, the transcript and the venue sentinel have already been written to whatever the name pointed at, and this is what stops the record claiming otherwise |
transcript-diverted |
the promotion could not establish that the transcript ended up at the key, although the key looks right now. This is the reverted swap: something replaced the key directory for the length of the write and put the real one back afterwards, so the shape check saw nothing wrong while the transcript landed elsewhere. The promotion refuses and the key stays as it was; the bytes that were written cannot be recalled, and this is what stops the record claiming they landed. It needs write access to your sessions directory, which is the precondition the whole store assumes. It also appears, fail-closed, if the store's own identity reads fail mid-promotion (a filesystem going read-only or refusing stats), where the transcript may in fact have landed and the promotion still refuses rather than claim something it could not check |
locked |
the key was already held by another job's exclusive promotion lock. A lock left behind by a killed promotion is taken over by the next promotion once it is older than an hour, so this almost always means a live writer rather than a file somebody has to delete. The exception is a lock whose timestamp is in the FUTURE, from a clock skew on a shared store: that one never ages, and with PI_SESSIONS_TTL_DAYS=0 nothing else clears it either |
promote-failed |
the write failed before the transcript landed: a full disk, or a permissions change under the store mid-promotion. A sidecar that fails AFTER the swap is logged instead, never reported here, because the transcript did land |
locked is the one with a design behind it. That run discards its own copy rather than clobbering the
other's, and the reason is recorded to explain why the next run for the key will not see this run's work.
Two jobs on one pull request inside one runtime is a real shape (REQ-QUEUE-BURST-NO-DROP), and
last-write-wins there would interleave two agents' turns into one transcript. promote-failed is the
disk telling you something: the run itself succeeded and its result is already on the forge, but its
transcript did not persist, so the next run for that key cold-starts.
A refused promotion wins over whatever the read path said, because on a completed run the more useful reason is the one that explains the next run's cold start rather than this one's.
Two values are not cold starts at all and round out the enum: resumed, the transcript loaded and pi
continued it, and disabled, which every job that did not arm run.resume records.
A resumed job starts with the whole prior conversation in its context, so its per-turn token cost is
higher even though it should need fewer turns. PI_MAX_TOKENS counts that replayed prefill on the first
call, so a long-running key can breach a per-job budget before doing any work.
The run record's tokens field already measures this per job. Measure it on your own repository before
assuming resume is cheaper — it is a real trade, not a free win, and PI_SESSIONS_TTL_DAYS is the knob
that bounds how long a conversation accumulates.
<PI_SESSIONS_DIR>/<hash>/current.jsonl the transcript
<PI_SESSIONS_DIR>/<hash>/pi-version which pi wrote it
<PI_SESSIONS_DIR>/<hash>/venue which backend wrote it; absent on keys from before venues were recorded
<PI_SESSIONS_DIR>/<hash>/resume-chain how many times in a row it has been resumed
<PI_SESSIONS_DIR>/<hash>/context how full the context was when it was last written
<PI_SESSIONS_DIR>/<hash>/lock the one-writer promotion lock; absent when free, and taken
over by the next promotion once it is older than an hour
The directory name is a hash, not a readable path, so a branch name never becomes a filesystem path and a
listing of the store names none of your repositories. The hash is derived rather than random, so it is
computable by anyone who knows the repository and the branch: each of those directories must therefore BE a
directory, and the worker refuses a key whose name is a symlink, a regular file or a dangling link rather
than following it. PI_SESSIONS_DIR itself may be a symlink, which is how you move the store elsewhere;
only the key's own name is held to that rule.
The store itself is never mounted into a container. Each job gets its own copy, and only a job that completed successfully has its copy promoted back — so a failed or retried job leaves the stored transcript exactly as it was.
Deleting the whole directory is always safe: every key degrades to a cold start and nothing else breaks.
{ "on": { "type": "comment", "phrase": "@pi" }, "run": { "kind": "github", "flow": "fix", "resume": true } }