Skip to content

[sprites 5-1] Tasks API hold during agent runs#2042

Merged
2witstudios merged 3 commits into
masterfrom
pu/sprites-5-1-tasks-api-hold
Jul 13, 2026
Merged

[sprites 5-1] Tasks API hold during agent runs#2042
2witstudios merged 3 commits into
masterfrom
pu/sprites-5-1-tasks-api-hold

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 13, 2026

Copy link
Copy Markdown
Owner

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)

  • The public REST API has no tasks endpoints. https://sprites.dev/api documents sprites/exec/services/filesystem/checkpoints/policies/proxy only; an unauthenticated probe can't discriminate further (auth is checked before routing — unknown /v1/sprites/* paths also return 401).
  • The docs serve the Tasks API ONLY via the sprite's own management socket: /.sprite/api.sock, virtual host sprite, plain HTTP/JSON — POST/PUT/DELETE http://sprite/v1/tasks[/:name], body {"expire": <seconds|"5m">}, PUT /v1/tasks/:name is an idempotent Refresh/Create upsert. Max task lifetime per creation: 1h.
  • The pinned @fly/sprites 0.0.1-rc37 has no tasks primitive (verified in 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

  • Attached terminal with agent running → hold maintained on the documented cadence (5m/60s, configurable): startTaskHoldHeartbeat ticks immediately on connect and then every tickIntervalMs; planHold returns refresh once the refresh interval elapses; resolveTaskHoldConfig makes expiry/refresh overridable via SPRITE_TASK_HOLD_EXPIRE_SECONDS / SPRITE_TASK_HOLD_REFRESH_MS (digits-only parse — no 5m→5s mis-parse; refresh capped at half the expiry; the agent-idle window derives from the configured cadence).
  • Viewer detach with no active agent output → hold deleted: the detach tick runs with staleness trusted (the socket was live until that instant, so silence was real) and deletes an idle session's hold immediately. "Activity" is wider than output: typed input, the PTY launch, and agent bash runs injected via terminal-activity.ts all count — a prompt that kicks off a long silent run keeps its hold from the moment it is typed.
  • Realtime restart → only self-expiring holds: the argv builder structurally cannot emit a hold without an expiry (taskUpsertExecArgs throws 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.)
  • Hold API failures → degrade gracefully: the exec client never throws (bad name / transport error / timeout / non-2xx all resolve ok:false with 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 via onErrorloggers.realtime.warn, and never propagates into the handler.

Design

  • Pure core (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.
  • Thin shells: createSpriteTasksClient (via sprites.ts's exported runSpawned: hard timeout, SIGKILL, output cap) and createTaskHoldController (serialized op queue; end() idempotent).
  • 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; blind, an existing hold is kept refreshed until PTY exit or the bounded 30-min reap. Fresh activity always counts. A resumed agent that has never emitted a byte is not paused on our ignorance.
  • Hold hygiene: 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.
  • Wiring (agent-terminal-handler.ts): controller armed once per cold create; viewerAttached/lastOutputAt/lastInputAt tracked on the session; immediate ticks on attach/detach; heartbeat + hold torn down in the one endAgentTerminalSession funnel every teardown path already goes through.

Accepted tradeoffs (reviewed deliberately, not oversights)

  • Per-session (not per-sprite) holds: platform semantics only need ≥1 live task, but per-session names are what make "delete on MY session's end" safe next to a sibling's live work; session-per-sprite counts are small (tier-capped) and one 100-byte exec/min/busy-session is comparable to the keep-alive churn this epic removes. A long-lived in-sprite refresher loop was rejected because its own open exec connection would itself keep the sprite awake — defeating release-on-idle.
  • Scrollback replay stamps activity: a brief peek at an idle terminal can hold its sprite ≤1 idle window (~2min) past detach. Self-correcting and far cheaper than plumbing replay-vs-live discrimination out of sprites-shell.
  • Detached hold lives until reap when work was in progress at detach: while blind we cannot distinguish "finished" from "working silently"; killing a run is the worse error. Bounded by the existing 30-min reap; PTY exit still releases promptly when the socket survived. Finer granularity is the 5-3 Services spike's territory.

Test evidence (after hardening round 536a7fd)

packages/lib  sprite-tasks.test.ts            40 passed (40)
apps/realtime terminal suite                  12 files, 429 passed (429)
turbo typecheck @pagespace/lib + realtime     clean
eslint (touched files)                        clean

Out of scope (unchanged)

Services adoption (5-3 spike), billing changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_018UCWAVunNQfy1Jxn51oUqo

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
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 89ad223d-72be-4652-8517-a1d9b631bd4c

📥 Commits

Reviewing files that changed from the base of the PR and between d6a4cde and d0919f9.

📒 Files selected for processing (10)
  • apps/realtime/src/index.ts
  • apps/realtime/src/terminal/__tests__/agent-terminal-task-hold.test.ts
  • apps/realtime/src/terminal/__tests__/terminal-session-map.test.ts
  • apps/realtime/src/terminal/agent-terminal-handler.ts
  • apps/realtime/src/terminal/terminal-activity.ts
  • apps/realtime/src/terminal/terminal-session-map.ts
  • packages/lib/package.json
  • packages/lib/src/services/sandbox/sandbox-client/__tests__/sprite-tasks.test.ts
  • packages/lib/src/services/sandbox/sandbox-client/sprite-tasks.ts
  • packages/lib/src/services/sandbox/sandbox-client/sprites.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/sprites-5-1-tasks-api-hold

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 536a7fd — and you were right that the gap was wider than the literal comment. Three layers now cover it:

  1. Input counts as activity: session.lastInputAt is stamped in onInput and at PTY launch (the launch is the session's first activity), and latestActivityAt() feeds the hold with max(output, input). A typed prompt followed by an immediate detach keeps the hold through the run's silent start.
  2. Agent bash runs count too: terminal-activity.ts now stamps lastOutputAt when it injects a run into a live session's feed, so a detached multi-step agent run keeps its hold between steps.
  3. 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: false and 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".

Comment thread packages/lib/src/services/sandbox/sandbox-client/sprite-tasks.ts Fixed
2witstudios and others added 2 commits July 12, 2026 23:29
…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
@2witstudios 2witstudios merged commit a8007c5 into master Jul 13, 2026
10 checks passed
@2witstudios 2witstudios deleted the pu/sprites-5-1-tasks-api-hold branch July 13, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants