Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

FEAT: persist symphony loops on reboot - #64

Merged
aponamarev merged 26 commits into
mainfrom
feat/persist-loops-on-reboot
Apr 1, 2026
Merged

aponamarev merged 26 commits into
mainfrom
feat/persist-loops-on-reboot

Conversation

@aponamarev

@aponamarev aponamarev commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Symphony loops now survive an Electron restart in a useful way: if a loop’s Claude child process is still running, the desktop app re-registers it with the gateway, resumes NDJSON output tailing from the last persisted offset, and watches the PID so completion still triggers API finalization. Loops that died while the app was down (or never had a final status posted) are finalized on the next boot when API credentials are available, using artifacts and logs read from disk so the cloud loop record can close cleanly.

Key Changes

Core Feature:

  • Added BootRecoveryService wired from DesktopApplication after reconcileJobStore(): finalizes unfinalized dead jobs (finalStatusPersistedAt), reattaches live PIDs via registerRecoveredLoop, restarts startOutputTailer with lastObservedJsonlOffset, and finalizes when the watcher sees the process exit.
  • Introduced loop-finalizer.ts to post loop completion events to ${apiBaseUrl}/loops/${loopId}/events with command-specific artifact payloads (plan, judges, execution results, PRD content, etc.), log/tail diagnostics, token usage parsing, and job-store updates including finalStatusPersistedAt.
  • Extended LocalJob / JobStore and refactored symphony-loop.ts to share recovery and finalization paths with the main loop lifecycle.

Supporting Changes:

  • New unit tests for boot recovery and loop finalizer; test isolation hardening for Claude-related desktop tests; minor output-tailer / gateway test adjustments; desktop package.json patch version bump.

- isolate plugin-discovery tests from the developer HOME so they cannot spawn real cached wrappers
- lock setupStubClaude to the fake PATH so evaluate tests cannot resolve the real Claude CLI from a login shell
- tighten SSRF loop tests to use isolated allowlists and non-existent repos for deterministic early exits

Testing
- pnpm exec tsx --test test/symphony-loop-evaluate-code.test.ts test/symphony-loop-evaluate-plan.test.ts test/symphony-loop-ssrf.test.ts test/gateway-server.test.ts
- pnpm exec tsx --test test/*.test.ts

Risks
- plugin-discovery tests now intentionally assert the no-plugin-cache path and rely on separate fake-plugin tests for spawn coverage

Made-with: Cursor
- Add BootRecoveryService to finalize dead RUNNING jobs after reconcile
- Extend LocalJob with offsets, apiBaseUrl, and finalization timestamps
- Extract loop-finalizer, diagnostics-helpers, and token-usage for reuse
- Route normal completion through finalizeLoopFromRuntime when JobStore exists
- Persist runtime.json and execution-result.json for recovery replay
- Output tailer optional onOffset for lastObservedJsonlOffset updates
- Add job.recovery.finalize_replayed telemetry and TelemetryEmitter type
- Add boot-recovery and loop-finalizer unit tests

Testing: not run in this session; run `just desktop-test` before merge

Risks: Duplicate completed events if API lacks idempotency; mitigated by persisted finalization markers
Made-with: Cursor
@aponamarev
aponamarev requested a review from a team March 31, 2026 14:49
@aponamarev
aponamarev marked this pull request as draft March 31, 2026 16:17
- persist runner tokens for live loops so recovered loops can resume posting output after restart
- backfill recovered job paths and add recovery logging so output tailers and finalization are observable
- stop silent output tailer auth failures and cover recovery auth behavior with tests

Testing:
- just test
- just desktop-typecheck

Risks:
- runner token refresh still depends on a cloud endpoint that currently returns 404, so recovery falls back to the persisted token

Made-with: Cursor
- Remove refreshRunnerToken and the POST to /loops/:id/runner-token (404 on prod)
- Resolve loop auth synchronously from persisted file with API key fallback
- Update boot-recovery tests: persist token for dead-job case, expect loop-token

Testing: tsx --test test/boot-recovery.test.ts; just desktop-typecheck

Risks: None identified; reattach still uses on-disk runner JWT until it expires
Made-with: Cursor
- Route jobStore loop completion through finalizeLoopFromRuntime only for
  artifact upload and completed events; legacy no-jobStore path unchanged
- Remove unused assertPathAllowed from LoopFinalizerDeps and call sites
- Drop runtime.json write and pidStartedAt from LocalJob / spawn upsert
- Update loop-finalizer tests for trimmed finalizer deps

Testing: pnpm exec tsc -p apps/desktop/tsconfig.json --noEmit; node --test
  boot-recovery.test.ts loop-finalizer.test.ts; symphony-loop-execute,
  symphony-loop-plan, symphony-loop-cloud-failures

Risks: Finalizer-owned uploads use slimmer metadata than the old pre-finalizer
  path; parity with API expectations should be monitored
Made-with: Cursor
- Add LoopTokenStore (electron-store + safeStorage) keyed by loopId
- Wire Desktop app, gateway server, boot recovery, live symphony path
- Remove token from store after successful upload and completed event
- Legacy workdir token file still read when store miss; tests use stub crypto

Testing: pnpm exec tsc -p tsconfig.json --noEmit && pnpm test (apps/desktop)

Risks: Token persistence requires Electron safeStorage in production; partial
finalization paths intentionally retain token for retry

Made-with: Cursor
- branch recovery finalization so only successful statuses upload artifacts and emit completed events
- preserve FAILED/CANCELLED/STOPPED/UNKNOWN states during replay and emit status-appropriate error telemetry/events
- expand boot-recovery and loop-finalizer coverage for non-success recovery outcomes

Testing:
- pnpm -C apps/desktop exec tsx --test test/loop-finalizer.test.ts test/boot-recovery.test.ts

Risks:
- recovery now posts error events for STOPPED/UNKNOWN outcomes, which may affect downstream loop state handling expectations

Made-with: Cursor
- Fail closed during boot recovery by requiring per-loop tokens from LoopTokenStore.
- Remove legacy workdir token persistence and add coverage for skipping dead/live loops when tokens are missing.

Testing:
- pnpm test -- test/boot-recovery.test.ts
- pnpm typecheck

Risks:
- Loops without a persisted token now skip recovery network calls and require operator visibility via warnings.

Made-with: Cursor
- Dropped backfillJobPaths; reattach uses job paths set at spawn
- Removed legacy test for jobs without persisted jsonlPath

Testing: apps/desktop pnpm typecheck, lint, and test

Risks: Legacy JobStore rows missing jsonlPath no longer get derived paths on reattach; tailer stays off for those rows
Made-with: Cursor
@aponamarev
aponamarev marked this pull request as ready for review March 31, 2026 19:58
- Map CANCEL_PENDING to CANCELLED in finalizeLoopFromRuntime after live-PID guard
- Persist CANCEL_PENDING as CANCELLED in persistFinalJobStatus when not success
- Add loop-finalizer and boot-recovery regression tests

Testing: pnpm -C apps/desktop test test/loop-finalizer.test.ts test/boot-recovery.test.ts
Risks: Low; aligns persisted status with JobStore TERMINAL_STATUSES
Made-with: Cursor
- Advance onOffset only after newline-framed bytes and 2xx output POST when
  required; skip advance for partial lines and 401/403
- Retry framed output on throttle or non-2xx POST instead of dropping bytes
- Document lastObservedJsonlOffset as replay-safe in job-store and tailer calls
- Add tests for partial JSONL, 403 output, and boot recovery incomplete tail

Testing: apps/desktop: tsx --test symphony-loop-output-events boot-recovery;
  pnpm run typecheck; pnpm run lint

Risks: Persisted offset can lag file EOF until a frame is committed
Made-with: Cursor
@aponamarev
aponamarev changed the base branch from main to fix/setupStubClaude-for-tests March 31, 2026 20:18
@aponamarev
aponamarev changed the base branch from fix/setupStubClaude-for-tests to main March 31, 2026 20:19
- Split BootRecoveryService into reattachLiveJobs and
  startDeadJobFinalization; keep live reattach before gateway bind
- Run dead-loop finalization after server.start with logged failures
- Track background tasks; quiesce briefly on shutdown after dispose

Testing: just desktop-test; just desktop-typecheck

Risks: Brief window after bind before dead jobs finalized; shutdown may
leave one in-flight finalization running up to quiesce timeout

Made-with: Cursor
…ry that

  never ran (terminal COMPLETED jobs are not reconciled as active)
- Always call deleteLoopToken after persistFinalJobStatus when loopTokenStore
  is set; early returns unchanged
- Add regression test for failed artifact upload
- Bump desktop to 0.9.14

Testing: pnpm test (apps/desktop)

Risks: Failed artifact or completed-event POST no longer leaves a token for
  a hypothetical retry; uploads/events are not auto-retried on next boot
- Read CLOSEDLOOP_WATCHER_POLL_MS at setInterval creation (default 3000)
- Tests: fast poll in beforeEach; shorter wait vs sleep(3400); restore env
- Document variable in apps/desktop/README.md

Testing: not re-run in this commit
Risks: Low; production default unchanged
Made-with: Cursor
@aponamarev
aponamarev changed the base branch from main to fix/setupStubClaude-for-tests April 1, 2026 13:27
@aponamarev
aponamarev changed the base branch from fix/setupStubClaude-for-tests to main April 1, 2026 13:28
Comment thread apps/desktop/src/main/boot-recovery.ts
Comment thread apps/desktop/src/main/boot-recovery.ts
Comment thread apps/desktop/src/server/operations/output-tailer.ts Outdated
Comment thread apps/desktop/src/server/operations/symphony-loop.ts
Comment thread apps/desktop/src/main/loop-finalizer.ts Outdated
Comment thread apps/desktop/src/main/loop-finalizer.ts Outdated
- Add cloudFinalizedAt, recoveryAttempts, lastRecoveryError on LocalJob
- Refactor finalizeLoopFromRuntime outcomes; gate token delete on retry policy
- Boot recovery merges dead + terminal candidates; cap recovery at 3 attempts
- Preserve live-exit warning merge ordering; extend boot/loop-finalizer tests

Testing: pnpm test -- test/boot-recovery.test.ts test/loop-finalizer.test.ts; pnpm typecheck in apps/desktop

Risks: Token retained longer on retryable cloud failures until success or cap
Made-with: Cursor
- Replace permanent authFailed stop with bounded exponential backoff for 401/403, 5xx, and network errors (env-tunable base/max delay and max attempts)
- Keep replay-safe offsets; reset retry state on successful 2xx output POST
- Let flush() bypass backoff for a final delivery attempt
- Extend event test server with per-request output status sequences; add tests for retry recovery and flush bypass

Testing: pnpm exec tsx --test test/symphony-loop-output-events.test.ts (apps/desktop)

Risks: After max retries the tailer stops posting output until process restart; backoff increases API load briefly during outages
Made-with: Cursor
- Delete persisted loop tokens from handleProcessCompletion on non-zero exit,
  cancellation early-returns, NO_WORK_PRODUCED, and legacy completion path
- Clear tokens in handleLoopRequest finally when spawn never succeeds
- Add LoopTokenStore.listLoopIds and BootRecoveryService.sweepOrphanedTokens
  after finalizeDeadJobs/reattachLiveJobs
- Add unit and integration tests; bump desktop to 0.10.3

Testing: just desktop-typecheck, just desktop-lint, tsx --test on affected
test files

Risks: sweep removes tokens for unknown loop IDs; recovery still needs token
for jobs not yet cloud-finalized (sweep keeps those)

Made-with: Cursor
- Add sessionId from session-id.txt and branchName fallback from worktree git
- Mirror fields in artifact upload metadata for parity with legacy desktop path
- Add loop-finalizer regression tests (session, branch, EXECUTE precedence, upload)
- Set desktop package version to 0.10.0

Testing: pnpm test -- test/loop-finalizer.test.ts

Risks: None identified; cloud consumers receive prior correlation fields again
Made-with: Cursor
@aponamarev
aponamarev merged commit 39bff0c into main Apr 1, 2026
2 checks passed
@aponamarev
aponamarev deleted the feat/persist-loops-on-reboot branch April 1, 2026 18:38
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants