Skip to content

Latest commit

 

History

History
225 lines (181 loc) · 19 KB

File metadata and controls

225 lines (181 loc) · 19 KB

Resumable sessions

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.

{ "on": { "type": "comment", "phrase": "@pi" },
  "run": { "kind": "github", "flow": "fix", "resume": true } }

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.

Setup

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/sessions
PI_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 full

pi-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.

Read this before you enable it

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=1

It 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.

What actually gets resumed

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).

When it silently doesn't resume

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.

When a promotion doesn't happen

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.

Cost

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.

What is stored

<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.