[sprites 5-1] Tasks API hold during agent runs#2042
Conversation
Nothing told the platform "work is in progress" while a claude/codex agent
ran in a terminal — with 3-1/3-2's idle-pause fixes landed, a sprite could
now cold-pause mid-run and kill the agent. The sanctioned mechanism is the
Sprites Tasks API: a hold with a short expiry, refreshed on a heartbeat,
deleted on exit (docs.sprites.dev/keeping-sprites-running).
Endpoint shape verified against sprites.dev/api: the Tasks API is served
ONLY by the sprite's own management socket (/.sprite/api.sock, virtual host
`sprite`) — the public REST API documents no tasks endpoints and the pinned
@fly/sprites 0.0.1-rc37 has no tasks primitive. The client therefore issues
the documented REST calls (PUT/DELETE /v1/tasks/:name, {"expire": s}) by
exec'ing `curl --unix-socket` inside the sprite via the SDK's authenticated
exec channel — same auth/config as every other operation, and the exec only
ever runs while the sprite is already awake.
- packages/lib .../sandbox-client/sprite-tasks.ts: pure core (planHold with
missed-heartbeat + 1h max-lifetime re-creation boundaries,
isAgentOutputFlowing, taskHoldName, argv builders that structurally cannot
emit a hold without an expiry, response classifiers, resolveTaskHoldConfig)
+ thin shells (exec client that never throws; serialized hold controller).
- agent-terminal-handler: hold created on connect, refreshed on the 60s/5m
heartbeat while a viewer is attached OR agent output flows, deleted on
detach-with-idle-agent and in the session-teardown funnel.
- Holds always self-expire, so a realtime restart leaks nothing; every
failure is log-and-continue (a lost hold = a possible pause, which the
checkpoint work already survives).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UCWAVunNQfy1Jxn51oUqo
|
Warning Review limit reached
Next review available in: 53 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (10)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2210523d72
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Re-evaluate the platform task hold now that the viewer is gone: with no | ||
| // agent output flowing this DELETES the hold, so the sprite can pause | ||
| // long before the 30-min reap — an agent mid-run (recent output) keeps it. | ||
| session.taskHold?.tick({ attached: false, lastOutputAt: session.lastOutputAt }); |
There was a problem hiding this comment.
Keep the hold after input before first output
If a user submits input and disconnects before the agent emits output (for example a long first API call or build), lastOutputAt is still undefined because the new hold signal is only updated from onOutput; this detach tick therefore makes the controller plan delete, removing the Tasks hold while the PTY is actually doing work. That reintroduces the mid-run pause/cold-kill this change is meant to prevent, so accepted input or another activity/liveness signal needs to keep the hold alive until the run is known idle.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 536a7fd — and you were right that the gap was wider than the literal comment. Three layers now cover it:
- Input counts as activity:
session.lastInputAtis stamped inonInputand at PTY launch (the launch is the session's first activity), andlatestActivityAt()feeds the hold with max(output, input). A typed prompt followed by an immediate detach keeps the hold through the run's silent start. - Agent bash runs count too:
terminal-activity.tsnow stampslastOutputAtwhen it injects a run into a live session's feed, so a detached multi-step agent run keeps its hold between steps. - Blind-detach policy (the deeper version of this bug): while detached the exec socket may be dead (3-2 deliberately never reconnects it), so a frozen activity clock is not evidence of idleness — heartbeat ticks while detached are marked
activityObservable: falseand the controller then refreshes an existing hold instead of deleting it; release comes from PTY exit (if the socket survived) or the bounded 30-min reap. A resumed agent that has never emitted a byte since attach is treated the same way at detach.
Covered by new unit tests: "counts typed input as activity so detach-before-first-output keeps the hold", "counts the PTY launch itself as activity", "heartbeats while detached are marked unobservable", and controller-level "keeps a blind detached hold instead of trusting a frozen activity clock".
…cy, hold hygiene Review round (Codex P2 + CodeQL + 8-angle self-review): - Activity, not just output (Codex P2): typed input and the PTY launch itself now count as activity (session.lastInputAt), so a viewer who types a prompt and detaches before the agent's first byte no longer gets the hold deleted under a run that already started. Agent bash runs injected into the feed (terminal-activity.ts) stamp activity too. - Blind-detach policy: while detached the exec socket may be dead (3-2 never reconnects it), so a frozen activity clock is not evidence of idleness — STALE activity only deletes when activity is observable; an existing hold is otherwise kept refreshed until session end/reap (bounded ~30min). A resumed agent that has never emitted a byte is likewise not paused on our ignorance. Fresh activity always counts (we saw the bytes). - Linear-time taskHoldName (CodeQL js/polynomial-redos): single-pass character map replaces the backtracking-capable regexes. - Hold hygiene: end() now keys its final DELETE on "an upsert was ever attempted" (mayHoldRemotely), not on retry bookkeeping — a PUT that landed but reported failure no longer leaks past session end; re-creates over a possibly-live task (1h max-lifetime boundary) DELETE-then-PUT since the platform lifetime is per CREATION; task names are per-incarnation so a torn-down session's queued DELETE can never destroy a reopened session's fresh hold. - Config safety: env values are digits-only (no '5m'→5s mis-parse), refresh is capped at half the expiry, and the agent-idle window derives from the configured refresh cadence instead of staying pinned at the default. - Reuse: the exec client now runs through sprites.ts's exported runSpawned (regaining the output cap + late-chunk guard); failure logs carry the exec exit code so a missing curl (127) is diagnosable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UCWAVunNQfy1Jxn51oUqo
isAgentOutputFlowing was renamed to isAgentActive in the previous commit (activity is wider than output now — input and PTY launch count too); the module doc comment still referenced the old name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UCWAVunNQfy1Jxn51oUqo
Why
Leaves 3-1 (#2020) and 3-2 (#2029) made idle-pause real: a detached, idle agent terminal now actually lets its sprite pause. That leaves a gap this leaf closes — nothing told the platform "work is in progress", so a sprite could cold-pause mid-run and kill a running agent. The sanctioned mechanism (docs.sprites.dev/keeping-sprites-running) is a Tasks API hold: short expiry, refreshed on a heartbeat, deleted on exit.
Endpoint-shape verification (spec asked for this first)
/v1/sprites/*paths also return 401)./.sprite/api.sock, virtual hostsprite, plain HTTP/JSON —POST/PUT/DELETE http://sprite/v1/tasks[/:name], body{"expire": <seconds|"5m">},PUT /v1/tasks/:nameis an idempotent Refresh/Create upsert. Max task lifetime per creation: 1h.dist/).Adaptation (recorded per spec): the "minimal REST client" issues the documented REST calls by exec'ing
curl --unix-socket /.sprite/api.sock ...inside the sprite through the SDK's authenticated exec channel — the same auth/config as every other SDK operation, no second credential path. Semantically sound: holds are only created/refreshed while work is in progress, i.e. while the sprite is already awake, so the exec never wakes a deliberately-paused sprite.Requirements → how satisfied
startTaskHoldHeartbeatticks immediately on connect and then everytickIntervalMs;planHoldreturnsrefreshonce the refresh interval elapses;resolveTaskHoldConfigmakes expiry/refresh overridable viaSPRITE_TASK_HOLD_EXPIRE_SECONDS/SPRITE_TASK_HOLD_REFRESH_MS(digits-only parse — no5m→5s mis-parse; refresh capped at half the expiry; the agent-idle window derives from the configured cadence).terminal-activity.tsall count — a prompt that kicks off a long silent run keeps its hold from the moment it is typed.taskUpsertExecArgsthrows on missing/zero/over-1h expiry; unit-asserted), and every create/refresh carries it. An orphaned hold frees itself within the expiry. (Flip side, spec-accepted: a restart also orphans a detached mid-run agent's hold, which then expires within 5min — requirement 3 explicitly chose self-expiry over restart persistence; 5-2 checkpoints protect the data.)ok:falsewith the exec exit code, so a missing-curl 127 is diagnosable in logs); the controller resets bookkeeping so the next tick retries as a fresh create, reports viaonError→loggers.realtime.warn, and never propagates into the handler.Design
packages/lib/src/services/sandbox/sandbox-client/sprite-tasks.ts, tested without mocks):planHold(all transitions incl. missed-heartbeat expiry margin and the 1h max-lifetime re-creation boundary),isAgentActive,taskHoldName(single-pass linear sanitizer — no regex over caller-controlled input — plus FNV suffix so sibling terminals on one sprite can't collide), argv builders,parseCurlStatus/isHoldCallOk,resolveTaskHoldConfig.createSpriteTasksClient(via sprites.ts's exportedrunSpawned: hard timeout, SIGKILL, output cap) andcreateTaskHoldController(serialized op queue;end()idempotent).end()keys its final DELETE on "an upsert was ever attempted" (mayHoldRemotely) so a PUT that landed-but-reported-failure can't leak past session end; re-creates over a possibly-live task DELETE-then-PUT (platform max lifetime is per CREATION); task names are per-incarnation so a torn-down session's queued DELETE can never destroy a reopened session's fresh hold.agent-terminal-handler.ts): controller armed once per cold create;viewerAttached/lastOutputAt/lastInputAttracked on the session; immediate ticks on attach/detach; heartbeat + hold torn down in the oneendAgentTerminalSessionfunnel every teardown path already goes through.Accepted tradeoffs (reviewed deliberately, not oversights)
Test evidence (after hardening round 536a7fd)
Out of scope (unchanged)
Services adoption (5-3 spike), billing changes.
🤖 Generated with Claude Code
https://claude.ai/code/session_018UCWAVunNQfy1Jxn51oUqo