feat(registry): workspace hibernation — auto-pause idle workspaces - #724
Conversation
) Implements automatic workspace hibernation for workspaces that have been idle longer than their configured hibernation_idle_minutes threshold. Changes: - migrations/029: Add hibernation_idle_minutes INT DEFAULT NULL column + partial index on workspaces table - registry/hibernation.go: New StartHibernationMonitor goroutine that ticks every 2 min and calls hibernateIdleWorkspaces via the HibernateHandler callback (same import-cycle-prevention pattern as OfflineHandler) - registry/hibernation_test.go: 5 unit tests covering handler calls, no-rows, DB error, tick behaviour, and context-cancel shutdown - handlers/workspace_restart.go: New Hibernate() HTTP handler (POST /workspaces/:id/hibernate) + HibernateWorkspace(ctx, id) method — stops container, sets status='hibernated', clears Redis keys, broadcasts event - handlers/a2a_proxy.go: Auto-wake in resolveAgentURL — when status='hibernated' and URL is empty, triggers async RestartByID and returns 503 + Retry-After: 15 so callers can retry transparently - registry/liveness.go: Exclude 'hibernated' workspaces from offline detection - router.go: Register POST /workspaces/:id/hibernate under wsAuth group - cmd/server/main.go: Wire hibernation monitor via supervised.RunWithRecover Closes #711 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR #724 Security Review — REQUEST CHANGES 🔴Draft status is appropriate — three blockers, one critical regression against a previously approved security fix, and the core feature has a broken auto-wake path. Access control and SQL safety are clean. Details below. Access control ✅
SQL safety ✅Every query in the new code uses parameterized placeholders. The hibernation monitor query uses no user-controlled input at all (reads Blocker 1 — CRITICAL regression:
|
| Check | Result |
|---|---|
| Access control (cross-workspace hibernate) | ✅ Clean — wsAuth enforces workspace binding |
| SQL parameterization | ✅ Clean throughout |
/metrics AdminAuth |
❌ Removed — #683 regression |
Auto-wake via RestartByID |
❌ Broken — 'hibernated' excluded, workspace never wakes |
delivery_confirmed fix (#689) |
❌ Removed — branch cut pre-#696 |
active_tasks guard on manual hibernate |
❌ Missing — task data loss |
| Migration slot (029) | ✅ Clear |
| Token validity on resume | ✅ Clean |
Fix the three blockers (restore /metrics AdminAuth, fix RestartByID exclusion, rebase for delivery_confirmed), address the active_tasks guard, and I'll re-review.
…nation commit (#689) The hibernation PR (7f5f74d) accidentally removed the delivery_confirmed fix that was introduced for issue #689. When io.ReadAll fails after the target has already responded with headers (200-399), the message WAS delivered — stripping delivery_confirmed from the error response caused callers to treat a successful send as a hard failure. Restore the full original body-read error block: - deliveryConfirmed flag (true when status 200-399) - log line with status/bytes_read context - logA2ASuccess call when deliveryConfirmed (audit trail accuracy) - proxyA2AError.Response includes "delivery_confirmed" field so callers can distinguish "not delivered" from "delivered, body lost" The hibernation auto-wake feature (resolveAgentURL status='hibernated' check) is orthogonal and untouched. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
APPROVED — Both issues resolved cleanly.
-
delivery_confirmedrestored (a2a_proxy.go):deliveryConfirmed,log.Printfwith status/bytes, andlogA2ASuccesscall are all back. The hibernation auto-wake block is correctly placed inresolveAgentURL— fully decoupled from the body-read path. Fix #689 behavior preserved. -
Hibernation auto-wake (resolveAgentURL):
status == 'hibernated'check firesgo h.RestartByIDasync and returns 503 +Retry-After: 15— clean and correct.
Commits 11b2c05 (PR #723) and 2452700 (PR #724) both verified on their branches. Ready to merge.
Cover the full hibernation feature (PR #724) + scheduler interaction (#722): handlers/hibernation_test.go (new, 6 tests): - HibernateWorkspace_OnlineWorkspace_Success — container stop called (nil provisioner guard), DB status set to 'hibernated', Redis keys cleared (ws:{id}, ws:{id}:url, ws:{id}:internal_url), WORKSPACE_HIBERNATED broadcast - HibernateWorkspace_NotEligible_NoOp — ErrNoRows → early return, no UPDATE, Redis keys untouched - HibernateWorkspace_DBUpdateFails_NoCrash — UPDATE error → no panic, no broadcast - HibernateHandler_Online_Returns200 — HTTP POST, online workspace → 200 {"status":"hibernated"} - HibernateHandler_NotActive_Returns404 — not online/degraded → 404 - HibernateHandler_DBError_Returns500 — DB error → 500 a2a_proxy_test.go (2 new tests): - ResolveAgentURL_HibernatedWorkspace_Returns503WithWaking — empty Redis + DB returns status=hibernated/url="" → 503 + Retry-After:15 + {waking:true,retry_after:15} - ResolveAgentURL_HibernatedWorkspace_NullURLVariant — same with SQL NULL url scheduler_test.go (1 new test): - RepairNullNextRunAt_HibernatedWorkspace_ScheduleRepaired — repair query has no workspace status filter; hibernated workspace's schedule still gets next_run_at repaired so it fires on wake Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ntegration test(hibernation): integration tests for workspace hibernation (#711)
#782) Adds two missing env vars to .env.example + docker-compose.yml platform block: 1. HIBERNATION_IDLE_MINUTES (default 60) Source: issue #724 / workspace hibernation feature. Note: currently configured per-workspace via the hibernation_idle_minutes DB column. This placeholder documents the planned global-default env var; the platform does not yet read it. Per-workspace DB column is active now. 2. PLUGIN_ALLOW_UNPINNED (empty = false) Source: issue #768 / PR #775 (supply chain hardening, not yet merged). Pre-emptive documentation — takes effect when PR #775 lands. ADMIN_TOKEN (item 3): already present with clear generation instructions (openssl rand -base64 32) and NEVER-commit reminder. No changes needed. docker-compose.yml cross-check — vars present in .env.example but absent from the platform service env block (flagged, not fixed in this PR — all have safe compiled-in defaults and are optional): SECRETS_ENCRYPTION_KEY, AWARENESS_URL, MOLECULE_ENV, MOLECULE_IN_DOCKER, MOLECULE_ENABLE_TEST_TOKENS, MOLECULE_ORG_ID, CP_PROVISION_URL, ACTIVITY_RETENTION_DAYS, ACTIVITY_CLEANUP_INTERVAL_HOURS, REMOTE_LIVENESS_STALE_AFTER, PLUGIN_INSTALL_{BODY_MAX_BYTES,FETCH_TIMEOUT, MAX_DIR_BYTES}, TIER{2,3,4}_{MEMORY_MB,CPU_SHARES}, WORKSPACE_DIR. These are not forwarded by docker-compose because they either auto-detect or have safe defaults — operators override them via .env on the host. Adding all of them to docker-compose would be noisy; a separate cleanup issue tracks this. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cover the full hibernation feature (PR #724) + scheduler interaction (#722): handlers/hibernation_test.go (new, 6 tests): - HibernateWorkspace_OnlineWorkspace_Success — container stop called (nil provisioner guard), DB status set to 'hibernated', Redis keys cleared (ws:{id}, ws:{id}:url, ws:{id}:internal_url), WORKSPACE_HIBERNATED broadcast - HibernateWorkspace_NotEligible_NoOp — ErrNoRows → early return, no UPDATE, Redis keys untouched - HibernateWorkspace_DBUpdateFails_NoCrash — UPDATE error → no panic, no broadcast - HibernateHandler_Online_Returns200 — HTTP POST, online workspace → 200 {"status":"hibernated"} - HibernateHandler_NotActive_Returns404 — not online/degraded → 404 - HibernateHandler_DBError_Returns500 — DB error → 500 a2a_proxy_test.go (2 new tests): - ResolveAgentURL_HibernatedWorkspace_Returns503WithWaking — empty Redis + DB returns status=hibernated/url="" → 503 + Retry-After:15 + {waking:true,retry_after:15} - ResolveAgentURL_HibernatedWorkspace_NullURLVariant — same with SQL NULL url scheduler_test.go (1 new test): - RepairNullNextRunAt_HibernatedWorkspace_ScheduleRepaired — repair query has no workspace status filter; hibernated workspace's schedule still gets next_run_at repaired so it fires on wake Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ernation feat(registry): workspace hibernation — auto-pause idle workspaces
#782) Adds two missing env vars to .env.example + docker-compose.yml platform block: 1. HIBERNATION_IDLE_MINUTES (default 60) Source: issue #724 / workspace hibernation feature. Note: currently configured per-workspace via the hibernation_idle_minutes DB column. This placeholder documents the planned global-default env var; the platform does not yet read it. Per-workspace DB column is active now. 2. PLUGIN_ALLOW_UNPINNED (empty = false) Source: issue #768 / PR #775 (supply chain hardening, not yet merged). Pre-emptive documentation — takes effect when PR #775 lands. ADMIN_TOKEN (item 3): already present with clear generation instructions (openssl rand -base64 32) and NEVER-commit reminder. No changes needed. docker-compose.yml cross-check — vars present in .env.example but absent from the platform service env block (flagged, not fixed in this PR — all have safe compiled-in defaults and are optional): SECRETS_ENCRYPTION_KEY, AWARENESS_URL, MOLECULE_ENV, MOLECULE_IN_DOCKER, MOLECULE_ENABLE_TEST_TOKENS, MOLECULE_ORG_ID, CP_PROVISION_URL, ACTIVITY_RETENTION_DAYS, ACTIVITY_CLEANUP_INTERVAL_HOURS, REMOTE_LIVENESS_STALE_AFTER, PLUGIN_INSTALL_{BODY_MAX_BYTES,FETCH_TIMEOUT, MAX_DIR_BYTES}, TIER{2,3,4}_{MEMORY_MB,CPU_SHARES}, WORKSPACE_DIR. These are not forwarded by docker-compose because they either auto-detect or have safe defaults — operators override them via .env on the host. Adding all of them to docker-compose would be noisy; a separate cleanup issue tracks this. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Closes #711
Implements automatic workspace hibernation: workspaces with
hibernation_idle_minutesconfigured are automatically paused when they've been idle (no active tasks, no recent heartbeat) for longer than their threshold. They auto-wake transparently when a new A2A message arrives.hibernatedworkspace status — distinct frompaused(automatic vs. manual, auto-wake vs. manual resume)hibernation_idle_minutescolumn —INT DEFAULT NULL;NULL= disabled (opt-in, no change to existing workspaces)POST /workspaces/:id/hibernate— explicit API endpoint underWorkspaceAuthmiddlewareresolveAgentURLdetectsstatus='hibernated', triggers asyncRestartByID, returns503 + Retry-After: 15with{"waking": true}body so callers can retry cleanlysupervised.Heartbeathibernatedworkspaces excluded from offline detection (same guard aspaused)Files changed
migrations/029_workspace_hibernation.up.sqlhibernation_idle_minutescolumn + partial indexmigrations/029_workspace_hibernation.down.sqlinternal/registry/hibernation.goHibernateHandlertype,StartHibernationMonitor,hibernateIdleWorkspacesinternal/registry/hibernation_test.gointernal/handlers/workspace_restart.goHibernate()HTTP handler +HibernateWorkspace()methodinternal/handlers/a2a_proxy.goresolveAgentURLinternal/registry/liveness.go'hibernated'from offline detectioninternal/router/router.goPOST /workspaces/:id/hibernatecmd/server/main.gosupervised.RunWithRecoverTest plan
go build ./...— cleango test ./... -count=1— all packages pass (including newregistryhibernation tests)hibernation_idle_minutes=1, wait for monitor tick, verifystatus='hibernated'+ container stoppedwaking:true, then retry after ~15s and workspace respondsPOST /workspaces/:id/hibernatereturns{"status":"hibernated"}for an online workspace🤖 Generated with Claude Code