Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3149,7 +3149,9 @@ paths:
summary: Delete Checkpoint
description: |-
**Experimental, owner-only.** Delete a checkpoint, scoped to the owner's own session. One
that is already gone is not an error. See docs/specs/warm-state.md.
that is already gone is not an error. One a fork still pins answers 409, naming the
pinning fork session when it is known, rather than reporting success for a checkpoint that
still exists (issue #311). See docs/specs/warm-state.md.
operationId: deleteCheckpoint
parameters:
- name: checkpoint_id
Expand Down
36 changes: 36 additions & 0 deletions docs/specs/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,42 @@ Existing sessions are not migrated, and there is no legacy workspace enrollment
conversion. The task-session checks have fake runtime and PostgreSQL evidence only;
there is no live native-agent, Kubernetes or retained-source handoff qualification in this slice.

## Forked sessions (issue #311)

A session forked with `POST /checkpoints/{id}/fork` ([warm-state](warm-state.md#checkpoint-and-fork-api-implemented-experimental))
always gets a Mainloop session row with `parent_session_id` set. If its source session is itself a
workspace (a git checkout, not every session has one — a plain `/agents` session or the main
thread does not), the fork also gets a workspace row, so it is a first-class workspace session
like any other: its manifest (repository, ref, branch, depth, agent kind, dev settings and
development environment) is copied from the source, and `GET /workspaces/{fork}` works, as do
suspend, resume, idle suspend and delete, through the same code paths as any workspace (see
[workspaces](workspaces.md#forked-workspaces-issue-311)). Which checkpoint and source workspace a
fork came from is not duplicated onto the new row: it is the existing `checkpoint_forks` row (keyed
by the fork's request id) together with the forked binding's `parent_session_id`, which already
name the checkpoint and the source session (and, since a workspace's row shares its session's id,
the source workspace). A fork of a non-workspace session gets no workspace row; there is no
manifest to copy and this change does not invent one.

A fork's agent binding keeps `mcp_grant_kind` `none` and is issued no MCP credential, the same as
before this change: kagent's `ForkSession` carries no credential reference for Mainloop to attach
one to (`checkpoints.proto`'s `ForkSessionRequest`, and `sessions.proto`'s `Session` comment that
forks do not inherit credential bindings; see warm-state.md's measurement). This is why the fork's
MCP calls are still rejected (401 `AUTH_HEADER_REJECTED`) where the source session's succeeded, and
it is deliberate, not an oversight left over from the fork having no workspace row: giving the fork
a credential anyway, by some path other than the one every other session's credential is issued
through, would be a workaround this change does not take. The grant staying `none` is how that
state is recorded; it is the same field an ordinary ungranted `/agents` session already carries,
read here as "this session has no Mainloop credential yet" rather than "this session never asked
for one". A kagent-side change (`oldsj/kagent`, branch `mainloop-substrate-0-4`) adds credential
references to `ForkSessionRequest`; a follow-up Mainloop task vendors it and issues per-fork
credentials once it lands and the vendored pin is bumped.

Tested against the fake kagent only (a fork of a workspace gets the workspace row with the copied
manifest; a fork of a plain session does not); not measured against a live kagent. See
[workspaces](workspaces.md#forked-workspaces-issue-311) for the delete behavior and
[checkpoints](warm-state.md#checkpoint-and-fork-api-implemented-experimental) for
`DELETE /checkpoints/{id}` now answering 409, not 200, while a fork still pins the checkpoint.

## React status

Labels: **implemented** means built in `web/` and covered by component or route tests against a fake backend. Nothing here is live-proven.
Expand Down
37 changes: 26 additions & 11 deletions docs/specs/warm-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,18 @@ measurement says otherwise.
source session, and `parent_session_id` set to it. The response carries `requested_at`,
`accepted_at` and `ready_at` (each set as the fork reaches that point; repeating the idempotent
request_id reads them as they fill in), so time-to-running is `ready_at` minus `requested_at` once
both are set.
- `DELETE /checkpoints/{checkpoint_id}` deletes one; already-gone is not an error. Checkpoint
snapshot retention itself is tracked separately, in
[#302](https://github.com/oldsj/mainloop/issues/302).
both are set. If the source session is itself a workspace, the fork also gets its own workspace
row with the source's manifest copied, so it is managed like any other workspace; see
[sessions](sessions.md#forked-sessions-issue-311) and
[workspaces](workspaces.md#forked-workspaces-issue-311).
- `DELETE /checkpoints/{checkpoint_id}` deletes one; already-gone is not an error. kagent's own
delete skips a checkpoint a fork still pins and answers NotFound exactly as it does for one that
is genuinely gone ([#311](https://github.com/oldsj/mainloop/issues/311)), so after that answer
this route re-reads the checkpoint before telling the caller it is gone: if it is still there, the
response is 409, naming the pinning fork session when Mainloop's own fork records know it
(`checkpoint is pinned by fork session <id>`), or `checkpoint is pinned by a fork` otherwise. It
never answers 200 for a checkpoint that still exists. Checkpoint snapshot retention itself is
tracked separately, in [#302](https://github.com/oldsj/mainloop/issues/302).

Follows the reconciler rule (AGENTS.md): both create-checkpoint and fork write a durable intent row
(`session_checkpoints`, `checkpoint_forks`) keyed on the caller's `request_id` before calling kagent.
Expand Down Expand Up @@ -406,10 +414,16 @@ version `c97e3803`.
files, into `/data` with `cp -a` ran for about 30 min without finishing.
- **Not measured:** `/data` small-file write throughput, and the restore time of a larger `/data`
snapshot.
- Open, #311: the fork has no Mainloop workspace row. Its MCP calls were rejected (401
`AUTH_HEADER_REJECTED`, where the source's succeeded). It can't be deleted through Mainloop.
`DELETE /checkpoints/{id}` answers 200 while kagent keeps the checkpoint, because the fork still
pins it.
- [#311](https://github.com/oldsj/mainloop/issues/311), fixed except for the credential: a fork
of a workspace now gets its own workspace row (manifest copied from the source), deleting or
archiving it deletes the forked kagent Session, and `DELETE /checkpoints/{id}` answers 409
instead of 200 while a fork still pins the checkpoint. See
[sessions](sessions.md#forked-sessions-issue-311) and
[workspaces](workspaces.md#forked-workspaces-issue-311). Its MCP calls are still rejected (401
`AUTH_HEADER_REJECTED`, where the source's succeeded): kagent's `ForkSession` carries no
credential reference to attach one to, so this is left as an explicit, documented gap rather
than a workaround. A kagent-side change (`oldsj/kagent`, branch `mainloop-substrate-0-4`) adds
one; a follow-up Mainloop task vendors it and issues per-fork credentials once it lands.
- Related, Open: #309. A session whose stuck turn is stopped can't take another turn (its
workspace preparation stays stale). Any fork or checkpoint run that needs a stopped turn hits
it.
Expand All @@ -430,9 +444,10 @@ The dev data so far adds no evidence for seed fork, and the ≈ 52 s dev session
to measure dev's time to running separately once workspaces prepare. The dev data now points the
other way for builds: with caches under `/data`, a fork carries a warm build cache (18.9 s first
build against 1 m 49.7 s cold), as well as the checkout and conversation. That makes per-project
seed forks a candidate to replace the cache image, pending #311 (fork binding and cleanup), #299
(the 15–30 min checkpoint window), production harnesses snapshotting `Data` instead of `Full`,
and a measurement of `/data` I/O and snapshot restore at realistic sizes.
seed forks a candidate to replace the cache image, pending #311's remaining credential gap (the
fork binding and cleanup are now fixed; see above), #299 (the 15–30 min checkpoint window),
production harnesses snapshotting `Data` instead of `Full`, and a measurement of `/data` I/O and
snapshot restore at realistic sizes.

Two Mainloop bugs surfaced by this measurement, filed as
[#299](https://github.com/oldsj/mainloop/issues/299): the stored-409 replay for kagent's retryable
Expand Down
30 changes: 30 additions & 0 deletions docs/specs/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -679,3 +679,33 @@ The guard denies new preview admissions after draining; it does not terminate al
connections. Write-enabled handoff remains unqualified until retained-source isolation and preview
fencing are proven by the handoff/runtime slices. Superseded history cannot use the ordinary
immediate-delete archive path; its retention is a separate slice.

## Forked workspaces (issue #311)

A checkpoint fork ([warm-state](warm-state.md#checkpoint-and-fork-api-implemented-experimental),
`POST /checkpoints/{id}/fork`) of a session that is itself a workspace gets its own workspace row
in the same transaction as its session and agent binding
(`server/internal/sessions/checkpoints.go`'s `commitFork`): the manifest (repository, ref, branch,
depth, agent kind, dev settings and development environment) is copied from the source workspace's
row, and the Agent is the one the forked kagent Session actually reports, falling back to the
source's resolved Agent if kagent names none. Unlike an ordinary workspace, whose binding gets its
kagent Session id only once its first `CreateSession` reply lands, the forked binding already has
it (kagent's `ForkSession` returned it) at the moment the workspace row is written, so this
workspace is created with no `workspace_ops` create operation: there is nothing to retry and
nothing to replay. See [sessions](sessions.md#forked-sessions-issue-311) for what is deliberately
not copied (an MCP credential) and why.

Because the row is ordinary once written — a `workspaces` row joined to an `agent_bindings` row
that already names a kagent Session — every lifecycle path above applies unchanged: `GET
/workspaces/{id}`, suspend, resume, idle suspend, and delete. In particular, deleting or archiving
a fork's workspace goes through the same reconciler-rule delete op as any other workspace (a
durable `workspace_ops` intent row, written before any kagent call): `doDelete` finds the Session
already bound and calls kagent's `DeleteSession` directly, with no create to replay first, which
releases kagent's `pinned_checkpoint_id` on the checkpoint the fork came from. A fork of a session
that is not a workspace (a plain `/agents` session, or the main thread) gets no workspace row:
there is no manifest to copy, and nothing in this change invents one.

Tested against the fake kagent only: a fork of a workspace gets the workspace row with the copied
manifest and no create operation, and deleting it calls kagent's `DeleteSession` and removes the
rows. Not measured against a live kagent; the owner verifies this on `mainloop-dev` separately
(see warm-state.md's live fork measurement).
Loading
Loading