From 1e3e9f60644378f287cf09cce4b45ce6c40197e8 Mon Sep 17 00:00:00 2001 From: Raj Dholakia Date: Wed, 14 Oct 2026 11:24:38 -0400 Subject: [PATCH 1/2] chore(coil): retire parked and absorbed fork features Upstream now ships usage-limit recovery, thread notifications and startup run recovery, so the fork's parked auto-resume, needs-input notifier and crash-recovery reconciler go. Loops, browser Web Push and the web outbox go with them by decision: the parked code no longer compiled against v2. Their design docs and evidence go too; cc969b360c is the last main that has them. Also removes the wiring only parked code used: the desktop notification bridge, the notifyOnNeedsInput setting and the web-push dependency. Retires upstream's hourly mobile-android-apk workflow, which needs a Blacksmith runner and only ever queued here. The seam ledger drops from 65 files (+1106) to 62 (+957). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/mobile-android-apk.yml | 59 - .../src/electron/ElectronNotification.test.ts | 266 - .../src/electron/ElectronNotification.ts | 113 - apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 - apps/desktop/src/ipc/channels.ts | 2 - apps/desktop/src/ipc/methods/coilUpdate.ts | 2 +- .../src/ipc/methods/notifications.test.ts | 59 - apps/desktop/src/ipc/methods/notifications.ts | 26 - apps/desktop/src/main.ts | 4 - apps/desktop/src/preload.ts | 12 - .../settings/DesktopClientSettings.test.ts | 1 - apps/server/package.json | 2 - coil-parked/README.md | 29 - .../src/coil/autoResume/Reactor.test.ts | 722 - .../server/src/coil/autoResume/Reactor.ts | 443 - .../coil/autoResume/classifyRateLimit.test.ts | 79 - .../src/coil/autoResume/classifyRateLimit.ts | 79 - .../server/src/coil/autoResume/config.test.ts | 81 - .../apps/server/src/coil/autoResume/config.ts | 113 - .../server/src/coil/autoResume/decide.test.ts | 107 - .../apps/server/src/coil/autoResume/decide.ts | 87 - .../server/src/coil/autoResume/guards.test.ts | 385 - .../apps/server/src/coil/autoResume/guards.ts | 302 - .../server/src/coil/autoResume/http.test.ts | 147 - .../apps/server/src/coil/autoResume/http.ts | 122 - .../server/src/coil/autoResume/receipts.ts | 118 - .../autoResume/replay/adapterReplay.test.ts | 96 - .../coil/autoResume/replay/adapterReplay.ts | 262 - .../autoResume/replay/capturedEpisode.test.ts | 143 - .../src/coil/autoResume/replay/episode.ts | 101 - .../autoResume/replay/fixtures/.gitignore | 12 - .../replay/fixtures/macos-hard-block-a.json | 3521 ---- .../replay/incidentDoomedTurn.test.ts | 261 - .../autoResume/replay/providerLog.test.ts | 131 - .../src/coil/autoResume/replay/providerLog.ts | 141 - .../coil/autoResume/replay/reactorHarness.ts | 361 - .../src/coil/autoResume/replay/record.ts | 304 - .../src/coil/autoResume/replay/redact.test.ts | 170 - .../src/coil/autoResume/replay/redact.ts | 187 - .../autoResume/replay/spendLimit118.test.ts | 160 - .../autoResume/replay/subagentFanout.test.ts | 246 - .../src/coil/autoResume/sharing.test.ts | 85 - .../server/src/coil/autoResume/state.test.ts | 249 - .../apps/server/src/coil/autoResume/state.ts | 252 - .../apps/server/src/coil/http/auth.test.ts | 115 - coil-parked/apps/server/src/coil/http/auth.ts | 74 - .../apps/server/src/coil/http/testAuth.ts | 148 - coil-parked/apps/server/src/coil/index.ts | 125 - .../apps/server/src/coil/loop/Reactor.test.ts | 1086 -- .../apps/server/src/coil/loop/Reactor.ts | 831 - .../apps/server/src/coil/loop/config.test.ts | 384 - .../apps/server/src/coil/loop/config.ts | 291 - .../server/src/coil/loop/cron/parse.test.ts | 228 - .../apps/server/src/coil/loop/cron/parse.ts | 368 - .../apps/server/src/coil/loop/crons.test.ts | 561 - .../apps/server/src/coil/loop/crons.ts | 297 - .../apps/server/src/coil/loop/decide.test.ts | 664 - .../apps/server/src/coil/loop/decide.ts | 285 - .../apps/server/src/coil/loop/guards.test.ts | 671 - .../apps/server/src/coil/loop/guards.ts | 390 - .../server/src/coil/loop/hooksRegistry.ts | 50 - .../apps/server/src/coil/loop/http.test.ts | 1226 -- coil-parked/apps/server/src/coil/loop/http.ts | 761 - .../server/src/coil/loop/integration.test.ts | 613 - .../apps/server/src/coil/loop/layer.ts | 36 - .../src/coil/loop/reactorHarness.test.ts | 149 - .../server/src/coil/loop/reactorHarness.ts | 564 - .../apps/server/src/coil/loop/receipts.ts | 138 - .../server/src/coil/loop/sentinel.test.ts | 448 - .../apps/server/src/coil/loop/sentinel.ts | 147 - .../apps/server/src/coil/loop/sharing.test.ts | 100 - .../apps/server/src/coil/loop/state.test.ts | 895 - .../apps/server/src/coil/loop/state.ts | 842 - .../apps/server/src/coil/loop/status.test.ts | 200 - .../apps/server/src/coil/loop/status.ts | 131 - .../apps/server/src/coil/loop/types.ts | 222 - .../server/src/coil/loop/userInputs.test.ts | 285 - .../apps/server/src/coil/loop/userInputs.ts | 131 - .../apps/server/src/coil/webPush/Reactor.ts | 151 - .../server/src/coil/webPush/attention.test.ts | 113 - .../apps/server/src/coil/webPush/attention.ts | 99 - .../apps/server/src/coil/webPush/config.ts | 44 - .../apps/server/src/coil/webPush/http.test.ts | 152 - .../apps/server/src/coil/webPush/http.ts | 122 - .../apps/server/src/coil/webPush/send.ts | 67 - .../apps/server/src/coil/webPush/state.ts | 127 - .../apps/server/src/coil/webPush/vapid.ts | 91 - .../src/mcp/toolkits/loop/handlers.test.ts | 621 - .../server/src/mcp/toolkits/loop/handlers.ts | 250 - .../src/mcp/toolkits/loop/tools.test.ts | 110 - .../server/src/mcp/toolkits/loop/tools.ts | 157 - .../Layers/CrashRecoveryReconciler.test.ts | 339 - .../Layers/CrashRecoveryReconciler.ts | 150 - coil-parked/apps/web/public/sw.js | 90 - .../web/src/coil/AutoResumeOverlay.test.tsx | 86 - .../apps/web/src/coil/AutoResumeOverlay.tsx | 326 - .../apps/web/src/coil/ThreadCoilOverlay.tsx | 34 - .../web/src/coil/autoResumeAnchor.test.ts | 193 - .../apps/web/src/coil/autoResumeAnchor.ts | 115 - .../web/src/coil/autoResumeClient.test.ts | 75 - .../apps/web/src/coil/autoResumeClient.ts | 130 - .../web/src/coil/autoResumeController.test.ts | 438 - .../apps/web/src/coil/autoResumeController.ts | 267 - .../src/coil/autoResumePresentation.test.ts | 111 - .../web/src/coil/autoResumePresentation.ts | 80 - .../apps/web/src/coil/composerAnchor.ts | 108 - .../apps/web/src/coil/loop/LoopArmForm.tsx | 173 - .../apps/web/src/coil/loop/LoopConsole.tsx | 365 - .../apps/web/src/coil/loop/LoopQuestions.tsx | 330 - .../apps/web/src/coil/loop/loopClient.ts | 469 - .../src/coil/loop/loopPresentation.test.ts | 541 - .../web/src/coil/loop/loopPresentation.ts | 525 - .../apps/web/src/coil/loop/useLoopPolling.ts | 98 - .../components/NotificationCoordinator.tsx | 143 - .../components/PushSubscriptionManager.tsx | 66 - .../components/chat/ThreadOutboxQueueList.tsx | 333 - .../web/src/components/coil/LoopsSettings.tsx | 338 - .../notifications/coordinator.logic.test.ts | 100 - .../src/notifications/coordinator.logic.ts | 69 - .../needsAttention.logic.test.ts | 339 - .../src/notifications/needsAttention.logic.ts | 127 - .../src/notifications/notifier.logic.test.ts | 28 - .../web/src/notifications/notifier.logic.ts | 17 - .../web/src/notifications/notifier.test.ts | 88 - .../apps/web/src/notifications/notifier.ts | 100 - .../permissionPrompt.logic.test.ts | 42 - .../notifications/permissionPrompt.logic.ts | 28 - .../web/src/notifications/push.logic.test.ts | 62 - .../apps/web/src/notifications/push.logic.ts | 45 - .../apps/web/src/notifications/push.ts | 175 - .../web/src/notifications/serviceWorker.ts | 31 - .../outbox/composerSendLabel.logic.test.ts | 19 - .../web/src/outbox/composerSendLabel.logic.ts | 28 - .../apps/web/src/outbox/outboxDiagnostics.ts | 67 - .../web/src/outbox/threadOutbox.logic.test.ts | 680 - .../apps/web/src/outbox/threadOutbox.logic.ts | 361 - .../apps/web/src/outbox/threadOutbox.ts | 139 - .../web/src/outbox/threadOutboxManager.ts | 273 - .../web/src/outbox/threadOutboxStorage.ts | 136 - .../web/src/outbox/useThreadOutboxDrain.ts | 418 - .../apps/web/src/routes/settings.loops.tsx | 11 - coil-parked/docs/user/loops.md | 126 - docs/coil/SEAMS.md | 152 +- docs/coil/agents/domain.md | 5 +- .../2026-08-10-capsule-anchor/README.md | 39 - .../2026-08-10-capsule-anchor/after.mp4 | Bin 110968 -> 0 bytes .../2026-08-10-capsule-anchor/before.mp4 | Bin 107293 -> 0 bytes .../side-by-side.gif | Bin 191211 -> 0 bytes .../side-by-side.mp4 | Bin 111778 -> 0 bytes .../still-after-both.png | Bin 302666 -> 0 bytes .../still-after-drawer.png | Bin 280933 -> 0 bytes .../still-before-drawer.png | Bin 280725 -> 0 bytes .../still-before-right-panel.png | Bin 297603 -> 0 bytes .../still-side-by-side-both-open.png | Bin 154680 -> 0 bytes docs/coil/loop/DESIGN.md | 571 - docs/coil/loop/OPTIONS.md | 751 - docs/coil/loop/RESEARCH.md | 916 - docs/coil/loop/SUBAGENTS.md | 152 - .../captures/subagent-backgrounded.ndjson | 81 - .../loop/captures/subagent-foreground.ndjson | 35 - docs/coil/loop/report.html | 3010 --- docs/coil/loops-v2/BACKEND.md | 1056 -- docs/coil/loops-v2/FINDINGS.md | 456 - docs/coil/loops-v2/PLAN.md | 782 - docs/coil/loops-v2/TESTS.md | 505 - docs/coil/loops-v2/UPSTREAM-DELTA.md | 432 - docs/coil/loops-v2/build-report.mjs | 114 - docs/coil/loops-v2/prototypes/_shell.css | 985 - docs/coil/loops-v2/prototypes/p1-console.html | 933 - .../loops-v2/prototypes/p2-directions.html | 611 - .../loops-v2/prototypes/p3-workspace.html | 651 - .../coil/loops-v2/prototypes/p4-settings.html | 647 - .../loops-v2/prototypes/p5-lifecycle.html | 686 - .../loops-v2/prototypes/p6-maintainer.html | 405 - .../prototypes/p7-console-shapes.html | 593 - docs/coil/loops-v2/prototypes/p8-mobile.html | 672 - docs/coil/loops-v2/report.html | 15506 ---------------- docs/coil/loops-v2/report.src.html | 2265 --- docs/coil/sync-agent-runbook.md | 4 +- .../2026-07-23-fork-upstream-sync-design.md | 2 +- ...26-07-23-usage-limit-auto-resume-design.md | 358 - .../2026-07-24-t3x-autoresume-ui-design.md | 103 - ...t3x-autoresume-overlay-variant-d-design.md | 190 - .../contracts/src/coil/settings.coil.test.ts | 34 - packages/contracts/src/ipc.ts | 34 +- packages/contracts/src/settings.ts | 5 - pnpm-lock.yaml | 83 - scripts/coil/sync-upstream.sh | 1 + third-party-licenses.config.json | 8 - vite.config.ts | 2 - 190 files changed, 90 insertions(+), 68781 deletions(-) delete mode 100644 .github/workflows/mobile-android-apk.yml delete mode 100644 apps/desktop/src/electron/ElectronNotification.test.ts delete mode 100644 apps/desktop/src/electron/ElectronNotification.ts delete mode 100644 apps/desktop/src/ipc/methods/notifications.test.ts delete mode 100644 apps/desktop/src/ipc/methods/notifications.ts delete mode 100644 coil-parked/README.md delete mode 100644 coil-parked/apps/server/src/coil/autoResume/Reactor.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/Reactor.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/classifyRateLimit.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/classifyRateLimit.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/config.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/config.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/decide.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/decide.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/guards.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/guards.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/http.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/http.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/receipts.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/adapterReplay.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/adapterReplay.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/capturedEpisode.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/episode.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/fixtures/.gitignore delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/fixtures/macos-hard-block-a.json delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/incidentDoomedTurn.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/providerLog.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/providerLog.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/reactorHarness.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/record.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/redact.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/redact.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/spendLimit118.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/replay/subagentFanout.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/sharing.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/state.test.ts delete mode 100644 coil-parked/apps/server/src/coil/autoResume/state.ts delete mode 100644 coil-parked/apps/server/src/coil/http/auth.test.ts delete mode 100644 coil-parked/apps/server/src/coil/http/auth.ts delete mode 100644 coil-parked/apps/server/src/coil/http/testAuth.ts delete mode 100644 coil-parked/apps/server/src/coil/index.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/Reactor.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/Reactor.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/config.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/config.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/cron/parse.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/cron/parse.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/crons.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/crons.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/decide.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/decide.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/guards.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/guards.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/hooksRegistry.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/http.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/http.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/integration.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/layer.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/reactorHarness.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/reactorHarness.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/receipts.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/sentinel.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/sentinel.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/sharing.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/state.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/state.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/status.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/status.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/types.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/userInputs.test.ts delete mode 100644 coil-parked/apps/server/src/coil/loop/userInputs.ts delete mode 100644 coil-parked/apps/server/src/coil/webPush/Reactor.ts delete mode 100644 coil-parked/apps/server/src/coil/webPush/attention.test.ts delete mode 100644 coil-parked/apps/server/src/coil/webPush/attention.ts delete mode 100644 coil-parked/apps/server/src/coil/webPush/config.ts delete mode 100644 coil-parked/apps/server/src/coil/webPush/http.test.ts delete mode 100644 coil-parked/apps/server/src/coil/webPush/http.ts delete mode 100644 coil-parked/apps/server/src/coil/webPush/send.ts delete mode 100644 coil-parked/apps/server/src/coil/webPush/state.ts delete mode 100644 coil-parked/apps/server/src/coil/webPush/vapid.ts delete mode 100644 coil-parked/apps/server/src/mcp/toolkits/loop/handlers.test.ts delete mode 100644 coil-parked/apps/server/src/mcp/toolkits/loop/handlers.ts delete mode 100644 coil-parked/apps/server/src/mcp/toolkits/loop/tools.test.ts delete mode 100644 coil-parked/apps/server/src/mcp/toolkits/loop/tools.ts delete mode 100644 coil-parked/apps/server/src/orchestration/Layers/CrashRecoveryReconciler.test.ts delete mode 100644 coil-parked/apps/server/src/orchestration/Layers/CrashRecoveryReconciler.ts delete mode 100644 coil-parked/apps/web/public/sw.js delete mode 100644 coil-parked/apps/web/src/coil/AutoResumeOverlay.test.tsx delete mode 100644 coil-parked/apps/web/src/coil/AutoResumeOverlay.tsx delete mode 100644 coil-parked/apps/web/src/coil/ThreadCoilOverlay.tsx delete mode 100644 coil-parked/apps/web/src/coil/autoResumeAnchor.test.ts delete mode 100644 coil-parked/apps/web/src/coil/autoResumeAnchor.ts delete mode 100644 coil-parked/apps/web/src/coil/autoResumeClient.test.ts delete mode 100644 coil-parked/apps/web/src/coil/autoResumeClient.ts delete mode 100644 coil-parked/apps/web/src/coil/autoResumeController.test.ts delete mode 100644 coil-parked/apps/web/src/coil/autoResumeController.ts delete mode 100644 coil-parked/apps/web/src/coil/autoResumePresentation.test.ts delete mode 100644 coil-parked/apps/web/src/coil/autoResumePresentation.ts delete mode 100644 coil-parked/apps/web/src/coil/composerAnchor.ts delete mode 100644 coil-parked/apps/web/src/coil/loop/LoopArmForm.tsx delete mode 100644 coil-parked/apps/web/src/coil/loop/LoopConsole.tsx delete mode 100644 coil-parked/apps/web/src/coil/loop/LoopQuestions.tsx delete mode 100644 coil-parked/apps/web/src/coil/loop/loopClient.ts delete mode 100644 coil-parked/apps/web/src/coil/loop/loopPresentation.test.ts delete mode 100644 coil-parked/apps/web/src/coil/loop/loopPresentation.ts delete mode 100644 coil-parked/apps/web/src/coil/loop/useLoopPolling.ts delete mode 100644 coil-parked/apps/web/src/components/NotificationCoordinator.tsx delete mode 100644 coil-parked/apps/web/src/components/PushSubscriptionManager.tsx delete mode 100644 coil-parked/apps/web/src/components/chat/ThreadOutboxQueueList.tsx delete mode 100644 coil-parked/apps/web/src/components/coil/LoopsSettings.tsx delete mode 100644 coil-parked/apps/web/src/notifications/coordinator.logic.test.ts delete mode 100644 coil-parked/apps/web/src/notifications/coordinator.logic.ts delete mode 100644 coil-parked/apps/web/src/notifications/needsAttention.logic.test.ts delete mode 100644 coil-parked/apps/web/src/notifications/needsAttention.logic.ts delete mode 100644 coil-parked/apps/web/src/notifications/notifier.logic.test.ts delete mode 100644 coil-parked/apps/web/src/notifications/notifier.logic.ts delete mode 100644 coil-parked/apps/web/src/notifications/notifier.test.ts delete mode 100644 coil-parked/apps/web/src/notifications/notifier.ts delete mode 100644 coil-parked/apps/web/src/notifications/permissionPrompt.logic.test.ts delete mode 100644 coil-parked/apps/web/src/notifications/permissionPrompt.logic.ts delete mode 100644 coil-parked/apps/web/src/notifications/push.logic.test.ts delete mode 100644 coil-parked/apps/web/src/notifications/push.logic.ts delete mode 100644 coil-parked/apps/web/src/notifications/push.ts delete mode 100644 coil-parked/apps/web/src/notifications/serviceWorker.ts delete mode 100644 coil-parked/apps/web/src/outbox/composerSendLabel.logic.test.ts delete mode 100644 coil-parked/apps/web/src/outbox/composerSendLabel.logic.ts delete mode 100644 coil-parked/apps/web/src/outbox/outboxDiagnostics.ts delete mode 100644 coil-parked/apps/web/src/outbox/threadOutbox.logic.test.ts delete mode 100644 coil-parked/apps/web/src/outbox/threadOutbox.logic.ts delete mode 100644 coil-parked/apps/web/src/outbox/threadOutbox.ts delete mode 100644 coil-parked/apps/web/src/outbox/threadOutboxManager.ts delete mode 100644 coil-parked/apps/web/src/outbox/threadOutboxStorage.ts delete mode 100644 coil-parked/apps/web/src/outbox/useThreadOutboxDrain.ts delete mode 100644 coil-parked/apps/web/src/routes/settings.loops.tsx delete mode 100644 coil-parked/docs/user/loops.md delete mode 100644 docs/coil/evidence/2026-08-10-capsule-anchor/README.md delete mode 100644 docs/coil/evidence/2026-08-10-capsule-anchor/after.mp4 delete mode 100644 docs/coil/evidence/2026-08-10-capsule-anchor/before.mp4 delete mode 100644 docs/coil/evidence/2026-08-10-capsule-anchor/side-by-side.gif delete mode 100644 docs/coil/evidence/2026-08-10-capsule-anchor/side-by-side.mp4 delete mode 100644 docs/coil/evidence/2026-08-10-capsule-anchor/still-after-both.png delete mode 100644 docs/coil/evidence/2026-08-10-capsule-anchor/still-after-drawer.png delete mode 100644 docs/coil/evidence/2026-08-10-capsule-anchor/still-before-drawer.png delete mode 100644 docs/coil/evidence/2026-08-10-capsule-anchor/still-before-right-panel.png delete mode 100644 docs/coil/evidence/2026-08-10-capsule-anchor/still-side-by-side-both-open.png delete mode 100644 docs/coil/loop/DESIGN.md delete mode 100644 docs/coil/loop/OPTIONS.md delete mode 100644 docs/coil/loop/RESEARCH.md delete mode 100644 docs/coil/loop/SUBAGENTS.md delete mode 100644 docs/coil/loop/captures/subagent-backgrounded.ndjson delete mode 100644 docs/coil/loop/captures/subagent-foreground.ndjson delete mode 100644 docs/coil/loop/report.html delete mode 100644 docs/coil/loops-v2/BACKEND.md delete mode 100644 docs/coil/loops-v2/FINDINGS.md delete mode 100644 docs/coil/loops-v2/PLAN.md delete mode 100644 docs/coil/loops-v2/TESTS.md delete mode 100644 docs/coil/loops-v2/UPSTREAM-DELTA.md delete mode 100755 docs/coil/loops-v2/build-report.mjs delete mode 100644 docs/coil/loops-v2/prototypes/_shell.css delete mode 100644 docs/coil/loops-v2/prototypes/p1-console.html delete mode 100644 docs/coil/loops-v2/prototypes/p2-directions.html delete mode 100644 docs/coil/loops-v2/prototypes/p3-workspace.html delete mode 100644 docs/coil/loops-v2/prototypes/p4-settings.html delete mode 100644 docs/coil/loops-v2/prototypes/p5-lifecycle.html delete mode 100644 docs/coil/loops-v2/prototypes/p6-maintainer.html delete mode 100644 docs/coil/loops-v2/prototypes/p7-console-shapes.html delete mode 100644 docs/coil/loops-v2/prototypes/p8-mobile.html delete mode 100644 docs/coil/loops-v2/report.html delete mode 100644 docs/coil/loops-v2/report.src.html delete mode 100644 docs/superpowers/specs/2026-07-23-usage-limit-auto-resume-design.md delete mode 100644 docs/superpowers/specs/2026-07-24-t3x-autoresume-ui-design.md delete mode 100644 docs/superpowers/specs/2026-08-08-t3x-autoresume-overlay-variant-d-design.md delete mode 100644 packages/contracts/src/coil/settings.coil.test.ts diff --git a/.github/workflows/mobile-android-apk.yml b/.github/workflows/mobile-android-apk.yml deleted file mode 100644 index 9a81b25c6d5b..000000000000 --- a/.github/workflows/mobile-android-apk.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Mobile Android APK - -# Publishes the newest nightly-compatible Android build as a sideloadable APK -# on the rolling "android-nightly" prerelease, for nightly users who can't join -# the Play testing group. Play re-signs uploaded bundles with the app signing -# key, so its universal APK matches the Play Store install and needs no signing -# secrets here. EAS submits asynchronously (--no-wait), so this polls Play -# instead of chaining off mobile-eas-production.yml; runs with nothing new exit -# early. -on: - schedule: - # Avoid minute zero, when GitHub scheduled jobs are busiest. - - cron: "23 * * * *" - workflow_dispatch: - -concurrency: - group: mobile-android-apk - cancel-in-progress: false - -jobs: - mirror: - name: Mirror Play APK - runs-on: blacksmith-2vcpu-ubuntu-2404 - timeout-minutes: 10 - permissions: - contents: write - env: - PACKAGE_NAME: com.t3tools.t3code - # eas.json submits v2-preview builds to this closed testing track; they - # speak the orchestration protocol nightly servers use, which the Play - # Store release can't. - PLAY_TRACK: v2 - RELEASE_TAG: android-nightly - steps: - - id: credentials - name: Check for Play credentials - env: - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} - run: | - if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is not available; skipping APK mirror." - fi - - - name: Checkout - if: steps.credentials.outputs.present == 'true' - uses: actions/checkout@v6 - with: - sparse-checkout: .github/scripts - - - name: Mirror the newest APK - if: steps.credentials.outputs.present == 'true' - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} - run: node .github/scripts/mirror-play-apk.cjs diff --git a/apps/desktop/src/electron/ElectronNotification.test.ts b/apps/desktop/src/electron/ElectronNotification.test.ts deleted file mode 100644 index 7ab125c20b39..000000000000 --- a/apps/desktop/src/electron/ElectronNotification.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import type * as Electron from "electron"; -import { beforeEach, vi } from "vite-plus/test"; - -interface FakeNotificationEntry { - readonly options: { readonly title?: string; readonly body?: string }; - readonly listeners: Map void>; - shown: number; - closed: number; -} - -const { notificationState, FakeNotification } = vi.hoisted(() => { - const notificationState = { - supported: true, - constructorThrows: null as Error | null, - instances: [] as FakeNotificationEntry[], - }; - - class FakeNotification { - static isSupported(): boolean { - return notificationState.supported; - } - - readonly entry: FakeNotificationEntry; - - constructor(options: { title?: string; body?: string }) { - if (notificationState.constructorThrows !== null) { - throw notificationState.constructorThrows; - } - this.entry = { options, listeners: new Map(), shown: 0, closed: 0 }; - notificationState.instances.push(this.entry); - } - - on(event: string, listener: (...args: readonly unknown[]) => void): this { - this.entry.listeners.set(event, listener); - return this; - } - - show(): void { - this.entry.shown += 1; - } - - // Electron dismisses the notification and then emits `close`; the fake - // mirrors that so the service's bookkeeping is exercised. - close(): void { - this.entry.closed += 1; - this.entry.listeners.get("close")?.(); - } - } - - return { notificationState, FakeNotification }; -}); - -vi.mock("electron", () => ({ Notification: FakeNotification })); - -import { NOTIFICATION_ACTIVATED_CHANNEL } from "../ipc/channels.ts"; -import * as ElectronNotification from "./ElectronNotification.ts"; -import * as ElectronWindow from "./ElectronWindow.ts"; - -const request = { - id: "thread-1", - title: "T3 Code needs your input", - body: "Claude is waiting on your answer.", - environmentId: "local", - threadId: "thread-1", -} as const; - -const clickNotification = (index: number) => { - const entry = notificationState.instances[index]; - assert.isDefined(entry); - const click = entry.listeners.get("click"); - if (click === undefined) { - throw new Error("Expected the notification to register a click listener."); - } - click(); -}; - -const clickFirstNotification = () => clickNotification(0); - -const closeNotification = (index: number) => { - const entry = notificationState.instances[index]; - assert.isDefined(entry); - const close = entry.listeners.get("close"); - if (close === undefined) { - throw new Error("Expected the notification to register a close listener."); - } - close(); -}; - -const silentWindowLayer = Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.none()), - sendAll: () => Effect.void, -}); - -describe("ElectronNotification", () => { - beforeEach(() => { - notificationState.supported = true; - notificationState.constructorThrows = null; - notificationState.instances = []; - }); - - it.effect("shows a native notification carrying the request title and body", () => - Effect.gen(function* () { - const notifications = yield* ElectronNotification.ElectronNotification; - yield* notifications.show(request); - - assert.equal(notificationState.instances.length, 1); - assert.deepEqual(notificationState.instances[0]?.options, { - title: "T3 Code needs your input", - body: "Claude is waiting on your answer.", - }); - assert.equal(notificationState.instances[0]?.shown, 1); - }).pipe( - Effect.provide( - ElectronNotification.layer.pipe( - Layer.provide( - Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.none()), - }), - ), - ), - ), - ), - ); - - it.effect("reveals the target window and broadcasts the activation on click", () => - Effect.gen(function* () { - const window = { id: 7 } as Electron.BrowserWindow; - const revealedRef = yield* Ref.make>(Option.none()); - const sent = yield* Deferred.make(); - const windowLayer = Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.some(window)), - reveal: (target) => Ref.set(revealedRef, Option.some(target)), - sendAll: (channel, ...args) => - Deferred.succeed(sent, [channel, ...args]).pipe(Effect.asVoid), - }); - - yield* Effect.gen(function* () { - const notifications = yield* ElectronNotification.ElectronNotification; - yield* notifications.show(request); - - clickFirstNotification(); - - const broadcast = yield* Deferred.await(sent); - assert.deepEqual(broadcast, [ - NOTIFICATION_ACTIVATED_CHANNEL, - { environmentId: "local", threadId: "thread-1" }, - ]); - assert.strictEqual(Option.getOrNull(yield* Ref.get(revealedRef)), window); - }).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(windowLayer)))); - }), - ); - - it.effect("broadcasts the activation even when there is no window to reveal", () => - Effect.gen(function* () { - const sent = yield* Deferred.make(); - const windowLayer = Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.none()), - sendAll: (channel, ...args) => - Deferred.succeed(sent, [channel, ...args]).pipe(Effect.asVoid), - }); - - yield* Effect.gen(function* () { - const notifications = yield* ElectronNotification.ElectronNotification; - yield* notifications.show(request); - - clickFirstNotification(); - - assert.deepEqual(yield* Deferred.await(sent), [ - NOTIFICATION_ACTIVATED_CHANNEL, - { environmentId: "local", threadId: "thread-1" }, - ]); - }).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(windowLayer)))); - }), - ); - - it.effect("no-ops when the platform does not support native notifications", () => - Effect.gen(function* () { - notificationState.supported = false; - - const notifications = yield* ElectronNotification.ElectronNotification; - yield* notifications.show(request); - - assert.equal(notificationState.instances.length, 0); - }).pipe( - Effect.provide( - ElectronNotification.layer.pipe( - Layer.provide( - Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.none()), - }), - ), - ), - ), - ), - ); - - it.effect("closes the previous notification when the same id is shown again", () => - Effect.gen(function* () { - const notifications = yield* ElectronNotification.ElectronNotification; - yield* notifications.show(request); - yield* notifications.show(request); - yield* notifications.show({ ...request, id: "thread-2", threadId: "thread-2" }); - - assert.equal(notificationState.instances.length, 3); - // Only the first is superseded: the third carries a different id. - assert.equal(notificationState.instances[0]?.closed, 1); - assert.equal(notificationState.instances[1]?.closed, 0); - assert.equal(notificationState.instances[2]?.closed, 0); - }).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(silentWindowLayer)))), - ); - - it.effect("forgets a dismissed notification so it is never closed twice", () => - Effect.gen(function* () { - const notifications = yield* ElectronNotification.ElectronNotification; - yield* notifications.show(request); - - // The OS dismissal emits `close` without routing through `close()`. - closeNotification(0); - yield* notifications.show(request); - - assert.equal(notificationState.instances.length, 2); - assert.equal(notificationState.instances[0]?.closed, 0); - }).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(silentWindowLayer)))), - ); - - it.effect("forgets a clicked notification so it is never closed afterwards", () => - Effect.gen(function* () { - const notifications = yield* ElectronNotification.ElectronNotification; - yield* notifications.show(request); - - clickFirstNotification(); - yield* notifications.show(request); - - assert.equal(notificationState.instances.length, 2); - assert.equal(notificationState.instances[0]?.closed, 0); - }).pipe(Effect.provide(ElectronNotification.layer.pipe(Layer.provide(silentWindowLayer)))), - ); - - it.effect("swallows Electron notification failures instead of failing the caller", () => - Effect.gen(function* () { - notificationState.constructorThrows = new Error("notification center unavailable"); - - const notifications = yield* ElectronNotification.ElectronNotification; - const exit = yield* Effect.exit(notifications.show(request)); - - assert.equal(exit._tag, "Success"); - assert.equal(notificationState.instances.length, 0); - }).pipe( - Effect.provide( - ElectronNotification.layer.pipe( - Layer.provide( - Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.none()), - }), - ), - ), - ), - ), - ); -}); diff --git a/apps/desktop/src/electron/ElectronNotification.ts b/apps/desktop/src/electron/ElectronNotification.ts deleted file mode 100644 index 1b5fbbf6a936..000000000000 --- a/apps/desktop/src/electron/ElectronNotification.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { DesktopNotificationActivation, DesktopNotificationRequest } from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -import * as Electron from "electron"; - -import { NOTIFICATION_ACTIVATED_CHANNEL } from "../ipc/channels.ts"; -import * as ElectronWindow from "./ElectronWindow.ts"; - -export class ElectronNotificationShowError extends Schema.TaggedError()( - "ElectronNotificationShowError", - { - notificationId: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to show the Electron notification ${JSON.stringify(this.notificationId)}.`; - } -} - -export class ElectronNotification extends Context.Service< - ElectronNotification, - { - /** - * Raises a native OS notification. Resolves once the notification has been - * handed to the platform, and no-ops when the platform has no notification - * support. Clicking the notification reveals the current window and - * broadcasts a `DesktopNotificationActivation` to every renderer. - */ - readonly show: (request: DesktopNotificationRequest) => Effect.Effect; - } ->()("@t3tools/desktop/electron/ElectronNotification") {} - -export const make = Effect.gen(function* () { - const electronWindow = yield* ElectronWindow.ElectronWindow; - // The click listener is a bare Electron callback, so the activation effect - // has to be run against the captured context (keeps logging/tracing intact). - const context = yield* Effect.context(); - const runFork = Effect.runForkWith(context); - - // Live notifications keyed by `DesktopNotificationRequest.id`, which the - // contract defines as a coalescing key: showing again for the same id - // replaces the previous entry instead of stacking a second one in - // Notification Center (the web path gets this from the notification `tag`). - // Holding the JS reference also keeps the notification from being collected - // while it is on screen, which is what makes Electron's `click` reliable. - const liveNotifications = new Map(); - - const activate = Effect.fn("desktop.electron.notification.activate")(function* ( - request: DesktopNotificationRequest, - ) { - const window = yield* electronWindow.focusedMainOrFirst; - if (Option.isSome(window)) { - yield* electronWindow.reveal(window.value); - } - - const activation: DesktopNotificationActivation = { - environmentId: request.environmentId, - threadId: request.threadId, - }; - // Broadcast rather than target the revealed window: any renderer may own - // the thread, and the activation is idempotent for the ones that don't. - yield* electronWindow.sendAll(NOTIFICATION_ACTIVATED_CHANNEL, activation); - }); - - const show = Effect.fn("desktop.electron.notification.show")(function* ( - request: DesktopNotificationRequest, - ) { - const supported = yield* Effect.try({ - try: () => Electron.Notification.isSupported(), - catch: (cause) => new ElectronNotificationShowError({ notificationId: request.id, cause }), - }); - if (!supported) { - return; - } - - yield* Effect.try({ - try: () => { - const notification = new Electron.Notification({ - title: request.title, - body: request.body, - }); - const forget = () => { - if (liveNotifications.get(request.id) === notification) { - liveNotifications.delete(request.id); - } - }; - notification.on("click", () => { - forget(); - runFork(activate(request)); - }); - notification.on("close", forget); - - liveNotifications.get(request.id)?.close(); - liveNotifications.set(request.id, notification); - notification.show(); - }, - catch: (cause) => new ElectronNotificationShowError({ notificationId: request.id, cause }), - }); - }); - - return ElectronNotification.of({ - // Notifications are advisory: failing to raise one must never fail the - // renderer's invoke, so failures are logged and swallowed here. - show: (request) => show(request).pipe(Effect.catch((error) => Effect.logWarning(error))), - }); -}); - -export const layer = Layer.effect(ElectronNotification, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index edc6c4d2c940..432a4641c83f 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -30,7 +30,6 @@ import { resolveSshHost, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; -import { showNotification } from "./methods/notifications.ts"; // coil: fork-owned update delivery. import { dismissCoilUpdate, getCoilUpdateState, restartIntoUpdate } from "./methods/coilUpdate.ts"; import { @@ -154,7 +153,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(checkSystemPermission); yield* ipc.handle(pasteAsText); yield* ipc.handle(probeRemoteEditors); - yield* ipc.handle(showNotification); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 01bd30842fba..e92930bdb28d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -14,8 +14,6 @@ export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end"; -export const SHOW_NOTIFICATION_CHANNEL = "desktop:show-notification"; -export const NOTIFICATION_ACTIVATED_CHANNEL = "desktop:notification-activated"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; diff --git a/apps/desktop/src/ipc/methods/coilUpdate.ts b/apps/desktop/src/ipc/methods/coilUpdate.ts index 836e3782fada..0be83f70d202 100644 --- a/apps/desktop/src/ipc/methods/coilUpdate.ts +++ b/apps/desktop/src/ipc/methods/coilUpdate.ts @@ -1,7 +1,7 @@ /** * IPC surface for the fork's update delivery. * - * A fork-owned file under an upstream directory, mirroring `notifications.ts`. The only upstream + * A fork-owned file under an upstream directory. The only upstream * edits it needs are the four channel constants and the three `ipc.handle` registrations — see * `docs/coil/SEAMS.md`. */ diff --git a/apps/desktop/src/ipc/methods/notifications.test.ts b/apps/desktop/src/ipc/methods/notifications.test.ts deleted file mode 100644 index 2da1374bcfae..000000000000 --- a/apps/desktop/src/ipc/methods/notifications.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import type { DesktopNotificationRequest } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; - -import * as ElectronNotification from "../../electron/ElectronNotification.ts"; -import { SHOW_NOTIFICATION_CHANNEL } from "../channels.ts"; -import { showNotification } from "./notifications.ts"; - -const request = { - id: "thread-1", - title: "T3 Code needs your input", - body: "Claude is waiting on your answer.", - environmentId: "local", - threadId: "thread-1", -} satisfies DesktopNotificationRequest; - -describe("showNotification", () => { - it("is registered on the renderer-facing notification channel", () => { - assert.equal(showNotification.channel, SHOW_NOTIFICATION_CHANNEL); - assert.equal(SHOW_NOTIFICATION_CHANNEL, "desktop:show-notification"); - }); - - it.effect("forwards the decoded request to the native notification service", () => - Effect.gen(function* () { - const shownRef = yield* Ref.make([]); - - yield* showNotification.handler({ ...request }).pipe( - Effect.provide( - Layer.mock(ElectronNotification.ElectronNotification)({ - show: (shown) => Ref.update(shownRef, (previous) => [...previous, shown]), - }), - ), - ); - - assert.deepEqual(yield* Ref.get(shownRef), [request]); - }), - ); - - it.effect("rejects a malformed request instead of raising a notification", () => - Effect.gen(function* () { - const shownRef = yield* Ref.make([]); - - const exit = yield* Effect.exit( - showNotification.handler({ id: "thread-1" }).pipe( - Effect.provide( - Layer.mock(ElectronNotification.ElectronNotification)({ - show: (shown) => Ref.update(shownRef, (previous) => [...previous, shown]), - }), - ), - ), - ); - - assert.equal(exit._tag, "Failure"); - assert.deepEqual(yield* Ref.get(shownRef), []); - }), - ); -}); diff --git a/apps/desktop/src/ipc/methods/notifications.ts b/apps/desktop/src/ipc/methods/notifications.ts deleted file mode 100644 index cf9c066e4f10..000000000000 --- a/apps/desktop/src/ipc/methods/notifications.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -import * as ElectronNotification from "../../electron/ElectronNotification.ts"; -import * as IpcChannels from "../channels.ts"; -import * as DesktopIpc from "../DesktopIpc.ts"; - -// Mirrors DesktopNotificationRequest from @t3tools/contracts; the handler's -// call into ElectronNotification.show keeps the two structurally checked. -const NotificationRequestInput = Schema.Struct({ - id: Schema.String, - title: Schema.String, - body: Schema.String, - environmentId: Schema.String, - threadId: Schema.String, -}); - -export const showNotification = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.SHOW_NOTIFICATION_CHANNEL, - payload: NotificationRequestInput, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.notifications.showNotification")(function* (request) { - const notifications = yield* ElectronNotification.ElectronNotification; - yield* notifications.show(request); - }), -}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 34ced0ce8975..c0aab3b3791b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -27,7 +27,6 @@ import * as ElectronApp from "./electron/ElectronApp.ts"; import * as CoilUpdateDelivery from "./coil/updateDelivery/UpdateDelivery.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; -import * as ElectronNotification from "./electron/ElectronNotification.ts"; import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "./electron/ElectronSafeStorage.ts"; @@ -141,9 +140,6 @@ const layerElectron = Layer.mergeAll( ElectronTheme.layer, ElectronUpdater.layer, ElectronWindow.layer, - // Notifications reveal/broadcast through ElectronWindow on click, so the - // window layer feeds them here (and stays exported by the merge above). - ElectronNotification.layer.pipe(Layer.provide(ElectronWindow.layer)), DesktopIpc.layer(Electron.ipcMain), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3b8c20a54dbf..dd29217a2f18 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -250,18 +250,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); }; }, - showNotification: (request) => ipcRenderer.invoke(IpcChannels.SHOW_NOTIFICATION_CHANNEL, request), - onNotificationActivated: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, activation: unknown) => { - if (typeof activation !== "object" || activation === null) return; - listener(activation as Parameters[0]); - }; - - ipcRenderer.on(IpcChannels.NOTIFICATION_ACTIVATED_CHANNEL, wrappedListener); - return () => { - ipcRenderer.removeListener(IpcChannels.NOTIFICATION_ACTIVATED_CHANNEL, wrappedListener); - }; - }, // coil: fork-owned update delivery. coilUpdate: { getState: () => ipcRenderer.invoke(IpcChannels.COIL_UPDATE_GET_STATE_CHANNEL), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 9ad49158bf9c..75ff38f32260 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -51,7 +51,6 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, - notifyOnNeedsInput: true, onboardingCompletedAt: null, panelAnimationDurationMs: 0, planModeEnabled: false, diff --git a/apps/server/package.json b/apps/server/package.json index f3a5761e0488..715fb3097986 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -59,7 +59,6 @@ "proper-lockfile": "4.1.2", "stream-chain": "^4.2.5", "stream-json": "3.6.0", - "web-push": "^3.6.7", "yaml": "catalog:", "yauzl": "^3.4.0" }, @@ -75,7 +74,6 @@ "@t3tools/web": "workspace:*", "@types/node": "catalog:", "@types/proper-lockfile": "^4.1.4", - "@types/web-push": "^3.6.4", "@types/yauzl": "^3.4.0", "effect-acp": "workspace:*", "effect-codex-app-server": "workspace:*", diff --git a/coil-parked/README.md b/coil-parked/README.md deleted file mode 100644 index e10611a053f5..000000000000 --- a/coil-parked/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Parked fork features - -Upstream #2829 replaced the orchestration engine (orchestration v2). The fork features below -were built on the deleted v1 engine (`OrchestrationEngine`, `ProjectionSnapshotQuery`, -`ProviderService`, `contracts/orchestration.ts`, the v1 Claude adapter). The 2026-10-08 sync -merged upstream `cdd331b6c3` and parked them here instead of porting them in the same change. - -Files keep their original paths under `coil-parked/`, so restoring one is -`git mv coil-parked/ `. Nothing here is typechecked, tested, or linted. Treat it as -reference material for a port to v2, not as code that still works. Fork edits to upstream-owned -files were dropped in the merge; recover them from the last pre-sync `main`, -`git show 6ffa75a398:`. - -| Feature | Parked here | Upstream-file edits dropped (see `6ffa75a398`) | What upstream ships now | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| Auto-resume after rate limits | `apps/server/src/coil/autoResume`, `apps/web/src/coil` (capsule) | `server.ts` coil seam, `ThreadRouteView.tsx` overlay mount | Usage-limit recovery: `orchestration-v2/UsageLimitRecoveryWorker.ts`, **Auto-resume limited threads** in **Settings → General** | -| Loops | `apps/server/src/coil/loop`, `apps/server/src/mcp/toolkits/loop`, `apps/web/src/coil/loop`, `LoopsSettings.tsx`, `routes/settings.loops.tsx`, `docs/user/loops.md` | `ClaudeAdapter.ts` hooks (file deleted upstream; now `orchestration-v2/Adapters/ClaudeAdapterV2.ts`), `McpHttpServer.ts`, settings nav/search | Scheduled tasks (`scheduling/Scheduler.ts`, **Settings → Scheduled Tasks**) overlap in part | -| Web push | `apps/server/src/coil/webPush`, `notifications/push*.ts`, `notifications/serviceWorker.ts`, `PushSubscriptionManager.tsx`, `public/sw.js` | `__root.tsx` mount | Nothing equivalent | -| Needs-input notifications | `apps/web/src/notifications`, `NotificationCoordinator.tsx` | `__root.tsx` mount, `SettingsPanels.tsx` toggle | Opt-in **Thread notifications** (#11481) in **Settings → General** | -| Offline outbox and Queue button | `apps/web/src/outbox`, `ThreadOutboxQueueList.tsx` | `ChatView.tsx`, `ChatComposer.tsx`, `ComposerPrimaryActions.tsx`, `connection/platform.ts`, `__root.tsx` | v2 queues follow-ups on the server while a run is active; nothing holds sends while disconnected | -| Crash-recovery reconciler | `apps/server/src/orchestration/Layers/CrashRecoveryReconciler*.ts` | `serverRuntimeStartup.ts` (+ test) | `orchestration-v2/ProviderRuntimeRecoveryService.ts` reconciles runs at startup | -| Slow-backend tolerance and heartbeat (#21, client half) | Not parked; the edits lived only in upstream files | `packages/client-runtime/src/connection/supervisor.ts` (+ test) | #14897 rewrote reconnect backoff (jittered, keeps healthy sockets); there is no background heartbeat | - -Still wired but now unused: the `notifyOnNeedsInput` setting in `packages/contracts/src/settings.ts`, -the desktop `showNotification` / `onNotificationActivated` bridge (`ipc.ts`, `preload.ts`, -`ElectronNotification.ts`), and the server's `web-push` dependency. - -Before porting a row, check what upstream already covers. Retire the fork version when upstream's -covers the need. diff --git a/coil-parked/apps/server/src/coil/autoResume/Reactor.test.ts b/coil-parked/apps/server/src/coil/autoResume/Reactor.test.ts deleted file mode 100644 index c72076948c1c..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/Reactor.test.ts +++ /dev/null @@ -1,722 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -// @effect-diagnostics globalErrorInEffectFailure:off -- the dispatch stub raises a bare -// Error on purpose, mirroring an arbitrary driver throw the reactor must survive. -import * as NodePath from "node:path"; - -import type { - OrchestrationCommand, - OrchestrationReadModel, - ProviderRuntimeEvent, -} from "@t3tools/contracts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as PubSub from "effect/PubSub"; -import * as Ref from "effect/Ref"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; - -import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { ProviderService } from "../../provider/Services/ProviderService.ts"; -import { AutoResumeReactorLive } from "./Reactor.ts"; -import { - type AutoResumeReactorReceipt, - AutoResumeReactorReceipts, - AutoResumeReactorReceiptsLive, -} from "./receipts.ts"; -import { AutoResumeStore, makeAutoResumeStore } from "./state.ts"; - -// Defaults: safetyMargin 60s, pollMs 30s. With resetsAt=100s the resume is due at -// 100_000 + 60_000 = 160_000ms, so advancing past that (with 30s wake ticks) fires it. - -// A Claude-thread read model, one row per id (one row unless a scenario asks for more). -// Cast because building every branded field is noise for this test — the reactor only reads -// the fields set here. -const readModel = (o: { - /** Ids to build rows for, in order. Every row is otherwise identical. */ - threadIds?: ReadonlyArray; - messages?: Array<{ id: string; role: string }>; - status?: string; - latestTurnId?: string; - /** Explicit latestTurn override; pass null to model an idle thread whose - * projection row has no latest_turn_id (see radroid/t3code#6). `completedAt` is when - * the turn ended — the guard uses it to tell a turn that outlived the closed window - * from one that died inside it. TestClock starts at 0, so script it in test-clock ms. */ - latestTurn?: { turnId: string; state: string; completedAt?: string } | null; -}): OrchestrationReadModel => - ({ - snapshotSequence: 1, - updatedAt: "2026-01-01T00:00:00.000Z", - projects: [{ id: "project-1", workspaceRoot: "/tmp/coil-nonexistent-workspace" }], - threads: (o.threadIds ?? ["thread-1"]).map((id) => ({ - id, - projectId: "project-1", - runtimeMode: "full-access", - interactionMode: "default", - worktreePath: null, - deletedAt: null, - archivedAt: null, - settledOverride: null, - messages: o.messages ?? [{ id: "u1", role: "user" }], - activities: [], - latestTurn: - o.latestTurn !== undefined - ? o.latestTurn - : { turnId: o.latestTurnId ?? "turn-1", state: "completed" }, - session: { status: o.status ?? "ready", providerName: "claudeAgent" }, - })), - }) as unknown as OrchestrationReadModel; - -const rejectedEvent = ( - resetsAtSeconds: number, - threadId = "thread-1", - eventId = "evt-1", -): ProviderRuntimeEvent => - ({ - type: "runtime.warning", - eventId, - provider: "claudeAgent", - threadId, - createdAt: "2026-01-01T00:00:00.000Z", - payload: { - message: "Claude usage limit reached", - detail: { - status: "rejected", - rateLimitType: "five_hour", - resetsAt: resetsAtSeconds, - }, - }, - }) as unknown as ProviderRuntimeEvent; - -// Real AutoResumeStore + real Reactor; stubbed engine/provider/snapshot; TestClock. -// Events are pre-loaded into the provider stream (emit-then-block) so delivery is -// deterministic and does not depend on publish/subscribe timing. -const harness = (initialModel: OrchestrationReadModel, events: ProviderRuntimeEvent[]) => - Effect.gen(function* () { - const dispatched = yield* Ref.make([]); - const modelRef = yield* Ref.make(initialModel); - const failTurnStart = yield* Ref.make(false); - - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "coil-reactor-" }); - const statePath = NodePath.join(root, "state.json"); - - const EngineStub = Layer.succeed(OrchestrationEngineService, { - dispatch: (command: OrchestrationCommand) => - Effect.gen(function* () { - if (command.type === "thread.turn.start" && (yield* Ref.get(failTurnStart))) { - return yield* Effect.fail(new Error("simulated dispatch failure")); - } - yield* Ref.update(dispatched, (a) => [...a, command]); - return { sequence: 0 }; - }), - streamDomainEvents: Stream.empty, - readEvents: () => Stream.empty, - latestSequence: Effect.succeed(0), - } as unknown as typeof OrchestrationEngineService.Service); - - const snapshotCalls = yield* Ref.make(0); - // One-shot: the NEXT snapshot read fails and the flag clears itself. `getSnapshot` has a - // typed `ProjectionRepositoryError` channel in production; the shape of the error does - // not matter here, only that the read fails where the reactor expects it can. - const failNextSnapshot = yield* Ref.make(false); - const SnapshotStub = Layer.succeed(ProjectionSnapshotQuery, { - getSnapshot: () => - Effect.gen(function* () { - yield* Ref.update(snapshotCalls, (n) => n + 1); - if (yield* Ref.getAndSet(failNextSnapshot, false)) { - return yield* Effect.fail(new Error("simulated snapshot read failure")); - } - return yield* Ref.get(modelRef); - }), - } as unknown as typeof ProjectionSnapshotQuery.Service); - - const ProviderStub = Layer.succeed(ProviderService, { - get streamEvents() { - return Stream.concat(Stream.fromIterable(events), Stream.never); - }, - } as unknown as typeof ProviderService.Service); - - const store = yield* makeAutoResumeStore(statePath); - const StoreLive = Layer.succeed(AutoResumeStore, store); - - // Crypto.make derives randomUUIDv4 from randomBytes; a counter keeps bytes distinct - // so generated command/message ids differ across calls. - let seed = 1; - const CryptoStub = Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => { - const bytes = new Uint8Array(size); - for (let i = 0; i < size; i++) bytes[i] = (seed + i) & 0xff; - seed += size; - return bytes; - }, - digest: (_algorithm, data) => Effect.succeed(data), - }), - ); - - const deps = Layer.mergeAll( - EngineStub, - SnapshotStub, - ProviderStub, - CryptoStub, - StoreLive, - // Test-only, and the whole reason the waits below are exact. Production never provides - // it, so the reactor's emitter resolves to a no-op there. See `receipts.ts`. - AutoResumeReactorReceiptsLive, - ); - return { dispatched, modelRef, deps, store, snapshotCalls, failTurnStart, failNextSnapshot }; - }); - -const types = (commands: ReadonlyArray) => commands.map((c) => c.type); - -// --- waiting ----------------------------------------------------------------------- -// -// Every wait below is an await on a receipt the reactor published (`receipts.ts`). Nothing -// here counts scheduler turns, and nothing here moves the clock except the polls a scenario -// asks for. -// -// It used to count turns: `advancePastResume` was eight clock steps each chased by a fixed -// ten-pump spin, a budget rather than a signal. The store persists through -// `writeFileStringAtomically` — real filesystem I/O whose completion fires on the Node event -// loop and NOT on TestClock — so on a loaded runner a turn buys less real progress and a -// budget that is generous on a laptop runs out. That is exactly what radroid/t3code#134 was: -// `pending must be cleared: expected 1 to equal +0`, an assertion that looked before the -// cancellation had landed, reported as a defect in the product. - -/** The reactor's poll cadence (`config.pollMs`), and the size of one clock step here. */ -const POLL_MS = 30_000; - -/** A real event-loop tick. */ -const realTick = Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); - -type ReceiptMatcher = (receipt: AutoResumeReactorReceipt) => boolean; - -/** An arm was scheduled — optionally, the one due at `resumeAtMs`. */ -const scheduled = - (resumeAtMs?: number): ReceiptMatcher => - (receipt) => - receipt.type === "resume.scheduled" && - (resumeAtMs === undefined || receipt.resumeAtMs === resumeAtMs); - -/** A resume attempt is over. Published after the dispatch, which may itself have failed. */ -const fired: ReceiptMatcher = (receipt) => receipt.type === "resume.fired"; - -const firedFor = - (threadId: string): ReceiptMatcher => - (receipt) => - receipt.type === "resume.fired" && receipt.threadId === threadId; - -const scheduledFor = - (threadId: string): ReceiptMatcher => - (receipt) => - receipt.type === "resume.scheduled" && receipt.threadId === threadId; - -const cancelledFor = - (reason: string): ReceiptMatcher => - (receipt) => - receipt.type === "resume.cancelled" && receipt.reason === reason; - -const skippedFor = - (reason: string): ReceiptMatcher => - (receipt) => - receipt.type === "resume.skipped" && receipt.reason === reason; - -/** The next receipt matching `matches`. Unbounded on purpose: the test timeout is the bound. */ -const untilReceipt = (matches: ReceiptMatcher) => - Effect.gen(function* () { - const { log } = yield* AutoResumeReactorReceipts; - while (true) { - const receipt = yield* PubSub.take(log); - if (matches(receipt)) return receipt; - } - }); - -/** - * Advance one poll and wait out the wake pass it triggers, returning what that pass published. - * - * `tick.completed` is emitted after every due arm has been decided and every write is durable, - * so when this returns the world is exactly one whole pass further on — no more and no less, - * on any machine. - * - * The `realTick` is a handoff, not a budget: a pass publishes its receipt just before it - * re-arms its own `Effect.sleep`, so this gives it the turn it needs to register that sleep - * before the clock steps over it. - */ -const advanceOneTick = (log: PubSub.Subscription) => - Effect.gen(function* () { - yield* realTick; - yield* TestClock.adjust(Duration.millis(POLL_MS)); - const seen: AutoResumeReactorReceipt[] = []; - while (true) { - const receipt = yield* PubSub.take(log); - if (receipt.type === "tick.completed") return seen; - seen.push(receipt); - } - }); - -/** - * Advance by whole polls, waiting out each wake pass. - * - * This is how a scenario asserts that something does NOT happen: N passes demonstrably ran - * and none of them did it, rather than N clock steps and a hope that the reactor kept up. - */ -const advanceTicks = (ticks: number) => - Effect.gen(function* () { - const { log } = yield* AutoResumeReactorReceipts; - for (let i = 0; i < ticks; i++) yield* advanceOneTick(log); - }); - -/** - * Advance in poll-sized steps until a wake pass publishes a matching receipt. - * - * `maxPolls` bounds SIMULATED time — how long the scenario is willing to wait — and says - * nothing about the machine, so it can never expire early under load. - */ -const advanceUntilReceipt = (matches: ReceiptMatcher, description: string, maxPolls = 60) => - Effect.gen(function* () { - const { log } = yield* AutoResumeReactorReceipts; - for (let poll = 0; poll < maxPolls; poll++) { - const hit = (yield* advanceOneTick(log)).find(matches); - if (hit !== undefined) return hit; - } - return yield* Effect.die( - new Error(`timed out waiting for ${description} after ${maxPolls} polls`), - ); - }); - -describe("AutoResumeReactor (integration)", () => { - it.effect("schedules on a rejected event and resumes once the window reopens", () => - Effect.gen(function* () { - const { dispatched, deps, store, snapshotCalls } = yield* harness(readModel({}), [ - rejectedEvent(100), - ]); - - yield* Effect.gen(function* () { - yield* untilReceipt(scheduled()); - - const calls = yield* Ref.get(snapshotCalls); - assert.isAbove( - calls, - 0, - "detection should have reached getSnapshot (past classify/plan/gate)", - ); - const pending = yield* store.listPending; - assert.strictEqual(pending.length, 1, "detection should have scheduled a pending resume"); - - const afterSchedule = yield* Ref.get(dispatched); - assert.include(types(afterSchedule), "thread.activity.append"); - assert.notInclude(types(afterSchedule), "thread.turn.start"); - - yield* advanceUntilReceipt(fired, "the resume turn"); - - const afterWake = yield* Ref.get(dispatched); - const turnStarts = afterWake.filter((c) => c.type === "thread.turn.start"); - assert.strictEqual(turnStarts.length, 1); - const turn = turnStarts[0] as Extract; - assert.strictEqual(turn.message.text, "continue"); - assert.strictEqual(turn.threadId, "thread-1"); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - // Regression for radroid/t3code#6 — the incident shape observed in production: - // the limit lands while the turn is RUNNING (baseline captures its id); by wake - // time the turn has settled and the projection row has no latest_turn_id, so the - // snapshot reports latestTurn: null. That must NOT read as "thread-advanced". - it.effect("resumes when the limited turn has settled away by wake time (latestTurn null)", () => - Effect.gen(function* () { - const { dispatched, modelRef, deps } = yield* harness( - readModel({ status: "running", latestTurn: { turnId: "turn-1", state: "running" } }), - [rejectedEvent(100)], - ); - - yield* Effect.gen(function* () { - yield* untilReceipt(scheduled()); // baseline.latestTurnId === "turn-1" - - // The limited turn settles and the session stops during the wait — the - // projection's latest_turn_id empties out, so the snapshot's latestTurn is null. - yield* Ref.set(modelRef, readModel({ status: "stopped", latestTurn: null })); - - yield* advanceUntilReceipt(fired, "the settled-away thread to resume"); - - const commands = yield* Ref.get(dispatched); - const turnStarts = commands.filter((c) => c.type === "thread.turn.start"); - assert.strictEqual(turnStarts.length, 1, "the settled thread must resume, not cancel"); - const summaries = commands - .filter((c) => c.type === "thread.activity.append") - .map((c) => (c as unknown as { activity: { summary: string } }).activity.summary); - assert.isFalse( - summaries.some((s) => s.includes("thread-advanced")), - "no thread-advanced cancellation may be posted", - ); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - // --- the 2026-08-18 doomed-turn rule, end to end ------------------------------------ - // These two are a matched pair, and the pair is the point: the ONLY difference between - // them is which side of the reopen the new turn's `completedAt` falls on. That proves - // the window comparison is load-bearing rather than dead code, and — unlike any unit - // test of `cancelReason` — that the reactor really hands the guard `pending.resumeAtMs`. - // - // Timing: resetsAt 100s + 60s margin ⇒ the arm is due at 160_000 on the TestClock. - - it.effect("resumes when the new turn died inside the still-closed window", () => - Effect.gen(function* () { - const { dispatched, modelRef, deps } = yield* harness( - readModel({ latestTurn: { turnId: "turn-old", state: "completed" } }), - [rejectedEvent(100)], - ); - - yield* Effect.gen(function* () { - yield* untilReceipt(scheduled()); // baseline: "turn-old" - - // A turn is requested and dies half a second later, at t=150s — ten seconds - // before the window reopens. It produced nothing; it only became the latest turn. - yield* Ref.set( - modelRef, - readModel({ - latestTurn: { - turnId: "turn-doomed", - state: "completed", - completedAt: "1970-01-01T00:02:30.000Z", // 150_000ms < 160_000ms - }, - }), - ); - - yield* advanceUntilReceipt(fired, "the doomed turn to be ignored and the resume to fire"); - - const commands = yield* Ref.get(dispatched); - assert.strictEqual( - commands.filter((c) => c.type === "thread.turn.start").length, - 1, - "a turn rejected by the same limit is the blockage, not advancement", - ); - const summaries = commands - .filter((c) => c.type === "thread.activity.append") - .map((c) => (c as unknown as { activity: { summary: string } }).activity.summary); - assert.isFalse( - summaries.some((s) => s.includes("thread-advanced")), - "no thread-advanced cancellation may be posted", - ); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - it.effect("still cancels when the new turn outlived the window (genuine advancement)", () => - Effect.gen(function* () { - const { dispatched, modelRef, deps } = yield* harness( - readModel({ latestTurn: { turnId: "turn-old", state: "completed" } }), - [rejectedEvent(100)], - ); - - yield* Effect.gen(function* () { - yield* untilReceipt(scheduled()); // baseline: "turn-old" - - // Same fixture, one field moved across the boundary: this turn finished at t=170s, - // after the window reopened at 160s. The thread really did move on. - yield* Ref.set( - modelRef, - readModel({ - latestTurn: { - turnId: "turn-real", - state: "completed", - completedAt: "1970-01-01T00:02:50.000Z", // 170_000ms >= 160_000ms - }, - }), - ); - - // The guard names its own verdict, so the wait is that verdict rather than an - // inference from the arm having gone away. - yield* advanceUntilReceipt( - cancelledFor("thread-advanced"), - "the wake pass to reach a verdict on the arm", - ); - - const commands = yield* Ref.get(dispatched); - assert.notInclude(types(commands), "thread.turn.start"); - const activities = commands - .filter((c) => c.type === "thread.activity.append") - .map( - (c) => - ( - c as unknown as { - activity: { summary: string; kind: string; payload: Record }; - } - ).activity, - ); - assert.include( - activities.map((a) => a.summary), - "Auto-resume cancelled: thread-advanced.", - ); - - // The cancel must also be machine-readable. Every silent failure in this feature - // so far was diagnosed by hand from SQL because the reason lived only in prose. - const cancelled = activities.find((a) => a.kind === "coil.auto-resume.cancelled"); - assert.isDefined(cancelled, "a cancellation activity must be posted"); - assert.deepStrictEqual(cancelled?.payload, { - reason: "thread-advanced", - resumeAtMs: 160_000, - baselineTurnId: "turn-old", - observedTurnId: "turn-real", - observedTurnCompletedAt: "1970-01-01T00:02:50.000Z", - }); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - // The wake loop sleeps until the earliest arm is due (capped at pollMs) instead of on a - // fixed pollMs cadence, so fire time tracks armed time. Measured on 2026-08-18: arms due - // at 19:51:00 fired at 19:51:42 and 19:51:47, and up to 30s of that was pure poll - // misalignment. 160_000 is deliberately NOT a multiple of the 30s poll: under the old - // fixed cadence the ticks landed on 150_000 then 180_000, so nothing fired at 160_000. - it.effect("fires at the armed time, not at the next poll boundary", () => - Effect.gen(function* () { - const { dispatched, deps } = yield* harness(readModel({}), [rejectedEvent(100)]); - - yield* Effect.gen(function* () { - yield* untilReceipt(scheduled(160_000)); - - // t = 150_000: five whole wake passes, still ten seconds short of the reopening. - yield* advanceTicks(5); - assert.notInclude( - types(yield* Ref.get(dispatched)), - "thread.turn.start", - "must not fire before the window reopens", - ); - - // t = 160_000 exactly: the armed moment, and a moment the old fixed cadence - // never visited. - yield* TestClock.adjust(Duration.millis(10_000)); - yield* untilReceipt(fired); - assert.include( - types(yield* Ref.get(dispatched)), - "thread.turn.start", - "the resume must fire at the armed time rather than the next poll", - ); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - // Regression for radroid/t3code#39 — the reported shape: the user types "keep going" - // while the resume is pending, that message is itself rejected by a limit so it starts - // nothing, and the arm used to be destroyed as `user-took-over`. It must survive. - it.effect("still resumes when the user posted a message while the resume was pending", () => - Effect.gen(function* () { - const { dispatched, modelRef, deps } = yield* harness(readModel({}), [rejectedEvent(100)]); - - yield* Effect.gen(function* () { - yield* untilReceipt(scheduled()); - - // A new user message lands, and goes nowhere: the thread is still idle at wake time. - yield* Ref.set( - modelRef, - readModel({ - messages: [ - { id: "u1", role: "user" }, - { id: "u2", role: "user" }, - ], - }), - ); - - yield* advanceUntilReceipt(fired, "the resume turn"); - - const commands = yield* Ref.get(dispatched); - assert.strictEqual( - commands.filter((c) => c.type === "thread.turn.start").length, - 1, - "the resume must fire despite the newer user message", - ); - const summaries = commands - .filter((c) => c.type === "thread.activity.append") - .map((c) => (c as unknown as { activity: { summary: string } }).activity.summary); - assert.isFalse( - summaries.some((s) => s.includes("cancelled")), - "no cancellation may be posted", - ); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - // The other half of #39: a second, longer limit arriving while a resume is armed used - // to be dropped as `already-pending`, so the arm fired into a window still shut. - it.effect("moves a pending resume out when a longer limit supersedes it", () => - Effect.gen(function* () { - const { dispatched, deps, store } = yield* harness(readModel({}), [ - rejectedEvent(100), // due at 100_000 + 60_000 margin - rejectedEvent(1000), // due at 1_000_000 + 60_000 margin - ]); - - yield* Effect.gen(function* () { - yield* untilReceipt(scheduled(1_060_000)); - - assert.strictEqual( - (yield* store.listPending).length, - 1, - "superseding replaces the arm, it does not add a second one", - ); - - const kinds = (yield* Ref.get(dispatched)) - .filter((c) => c.type === "thread.activity.append") - .map((c) => (c as unknown as { activity: { kind: string } }).activity.kind); - assert.deepStrictEqual(kinds, [ - "coil.auto-resume.scheduled", - "coil.auto-resume.rescheduled", - ]); - - // The original 160_000 due time passes without firing: that window is still shut. - yield* advanceTicks(8); // 8 x 30s = 240_000ms - assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start"); - - yield* advanceUntilReceipt(fired, "the superseded resume to fire at its new time"); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - it.effect("reserves the attempt before dispatch: a failed dispatch does not tight-loop", () => - Effect.gen(function* () { - const { dispatched, deps, store, failTurnStart } = yield* harness(readModel({}), [ - rejectedEvent(100), - ]); - yield* Ref.set(failTurnStart, true); // make the resume's turn.start dispatch fail - - yield* Effect.gen(function* () { - yield* untilReceipt(scheduled()); - // Fires once and the dispatch fails; the attempt is reserved either way, and the - // receipt is published after the failed dispatch for exactly that reason. - yield* advanceUntilReceipt(fired, "the attempt to be reserved"); - - // The attempt was reserved (pending cleared, one fire recorded) despite the failure. - assert.strictEqual((yield* store.listPending).length, 0); - assert.strictEqual(yield* store.countFiredSince("thread-1", 0), 1); - - // Subsequent ticks must NOT re-dispatch — re-arming requires a fresh rejection. - const turnStartsBefore = (yield* Ref.get(dispatched)).filter( - (c) => c.type === "thread.turn.start", - ).length; - yield* advanceTicks(8); - const turnStartsAfter = (yield* Ref.get(dispatched)).filter( - (c) => c.type === "thread.turn.start", - ).length; - assert.strictEqual(turnStartsAfter, turnStartsBefore); - assert.strictEqual(yield* store.countFiredSince("thread-1", 0), 1); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - it.effect("does NOT schedule for a thread whose auto-resume is switched off", () => - Effect.gen(function* () { - const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]); - yield* store.setEnabled("thread-1", false); - - yield* Effect.gen(function* () { - // Detection announces the gate it hit, so even "nothing was scheduled" is an exact - // await: the assertions below run after detection has demonstrably finished with the - // pre-loaded rejection, not after a spin that hoped it had. - yield* untilReceipt(skippedFor("disabled")); - - assert.strictEqual( - (yield* store.listPending).length, - 0, - "a disabled thread must never schedule a resume", - ); - // Disabling is a deliberate user action, so it must not post timeline noise either. - assert.notInclude(types(yield* Ref.get(dispatched)), "thread.activity.append"); - - yield* advanceTicks(8); - assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start"); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - it.effect("cancels an already-scheduled resume when the thread is switched off mid-wait", () => - Effect.gen(function* () { - const { dispatched, deps, store } = yield* harness(readModel({}), [rejectedEvent(100)]); - - yield* Effect.gen(function* () { - yield* untilReceipt(scheduled()); - assert.strictEqual((yield* store.listPending).length, 1, "precondition: it scheduled"); - - // The switch is flipped off *after* scheduling but *before* the window reopens. - // fireOne must re-read the record rather than trust the scheduling-time value. - yield* store.setEnabled("thread-1", false); - - // The wait that made this test radroid/t3code#134: it was eight clock steps and a - // turn budget, and under load the cancellation had not landed when the assertion ran. - yield* advanceUntilReceipt(cancelledFor("disabled"), "the arm to be cancelled at wake"); - - assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start"); - assert.strictEqual((yield* store.listPending).length, 0, "pending must be cleared"); - assert.strictEqual( - yield* store.countFiredSince("thread-1", 0), - 0, - "a cancellation must not burn one of the 24h attempts", - ); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - // `fireOne` reads a fresh snapshot per arm and `getSnapshot` has a typed - // `ProjectionRepositoryError` channel, so one arm failing mid-pass is an expected event, - // not a defect. Two things must survive it: the rest of the batch, and the end-of-pass - // receipt — a pass that dies before `tick.completed` leaves anything awaiting that receipt - // waiting for one that will never come. - it.effect("one failed fire neither aborts the batch nor suppresses the completed pass", () => - Effect.gen(function* () { - const { dispatched, deps, store, failNextSnapshot } = yield* harness( - readModel({ threadIds: ["thread-1", "thread-2"] }), - [rejectedEvent(100, "thread-1"), rejectedEvent(100, "thread-2", "evt-2")], - ); - - yield* Effect.gen(function* () { - yield* untilReceipt(scheduledFor("thread-1")); - yield* untilReceipt(scheduledFor("thread-2")); - // Both due at 160_000, and in this order: the pass walks `listPending`, so the - // failure below lands on thread-1 and thread-2 is the rest of the batch. - assert.deepStrictEqual( - (yield* store.listPending).map((p) => p.threadId), - ["thread-1", "thread-2"], - ); - - // The next snapshot read — the first `fireOne` of the coming pass — fails. - yield* Ref.set(failNextSnapshot, true); - - // Both assertions at once: thread-2 fired despite thread-1 failing, and the pass - // reached `tick.completed` at all (without it `advanceUntilReceipt` could not return, - // because it drains to the tick receipt on every step). - yield* advanceUntilReceipt(firedFor("thread-2"), "the rest of the batch to fire anyway"); - - // Existing behaviour, pinned rather than changed: the failed fire reserved nothing and - // cleared nothing, so the arm is untouched and the next pass tries it again. - assert.deepStrictEqual( - (yield* store.listPending).map((p) => p.threadId), - ["thread-1"], - "a failed fire must leave the arm alone, not drop it", - ); - assert.strictEqual( - yield* store.countFiredSince("thread-1", 0), - 0, - "a failed fire must not burn one of the 24h attempts", - ); - - yield* advanceUntilReceipt(firedFor("thread-1"), "the failed arm to be retried"); - - const turnStarts = (yield* Ref.get(dispatched)).filter( - (c) => c.type === "thread.turn.start", - ); - assert.deepStrictEqual( - turnStarts.map((c) => c.threadId), - ["thread-2", "thread-1"], - "both threads resume: the healthy one first, the failed one on the next pass", - ); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); -}); diff --git a/coil-parked/apps/server/src/coil/autoResume/Reactor.ts b/coil-parked/apps/server/src/coil/autoResume/Reactor.ts deleted file mode 100644 index 2e3d5312a02f..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/Reactor.ts +++ /dev/null @@ -1,443 +0,0 @@ -/** - * AutoResumeReactor — usage-limit auto-resume supervisor. - * - * Self-starts two scoped fibers at layer construction (no external `.start()`, so the - * only upstream seam stays the 2 lines in server.ts): - * - * 1. Detection: subscribes once to `providerService.streamEvents` and, on the Claude - * `runtime.warning` whose `detail` is a rate-limit info with `status:"rejected"`, - * schedules a resume at the structured `resetsAt` (+ margin), or on a backoff ladder - * when absent. - * 2. Wake: sleeps until the earliest armed resume comes due (capped at `pollMs`) and - * fires it — re-reading a fresh snapshot to re-check every guard immediately before - * dispatch (closing the wake race). - * - * Detection reads the structured rate-limit signal, NOT projection error state: a usage - * limit does not produce a failed turn (the SDK has no rate-limit result subtype). - * - * @module coil/autoResume/Reactor - */ - -import type { - OrchestrationThread, - OrchestrationThreadActivityTone, - ProviderRuntimeEvent, - ThreadId, -} from "@t3tools/contracts"; -import { CommandId, EventId, MessageId } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; - -import { ProviderService } from "../../provider/Services/ProviderService.ts"; -import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { classifyRateLimit } from "./classifyRateLimit.ts"; -import { resolveConfig, resolveResumePrompt } from "./config.ts"; -import { planSchedule } from "./decide.ts"; -import { - CLAUDE_DRIVER_KIND, - captureBaseline, - cancelReason, - isClaudeThread, - threadIsGone, -} from "./guards.ts"; -import { receiptEmitter } from "./receipts.ts"; -import { AutoResumeStore, type PendingResume } from "./state.ts"; - -const HOUR_MS = 60 * 60_000; -const BACKOFF_LOOKBACK_MS = 6 * HOUR_MS; -const CAP_WINDOW_MS = 24 * HOUR_MS; -/** Floor on the wake sleep, so an arm due in a millisecond cannot spin the fiber. */ -const MIN_WAKE_MS = 250; - -const makeSupervisor = Effect.gen(function* () { - const engine = yield* OrchestrationEngineService; - const providerService = yield* ProviderService; - const snapshotQuery = yield* ProjectionSnapshotQuery; - const store = yield* AutoResumeStore; - const crypto = yield* Crypto.Crypto; - const config = resolveConfig(); - // Optional, and absent in every production graph. See `receipts.ts`. - const receipts = yield* receiptEmitter; - - const isoNow = DateTime.now.pipe(Effect.map(DateTime.formatIso)); - - /** - * `payload` is free-form on the contract and is the only machine-readable trace this - * feature leaves. All three silent failures (#6, #39, 2026-08-18) had to be diagnosed - * by hand from SQL because the reason lived only in prose inside `summary`; anything - * that wants to aggregate or alert on cancellations needs it as a field. - */ - const appendActivity = ( - threadId: ThreadId, - tone: OrchestrationThreadActivityTone, - kind: string, - summary: string, - payload: Record = {}, - ) => - Effect.gen(function* () { - const commandUuid = yield* crypto.randomUUIDv4; - const eventUuid = yield* crypto.randomUUIDv4; - const createdAt = yield* isoNow; - yield* engine.dispatch({ - type: "thread.activity.append", - commandId: CommandId.make(`coil-auto-resume-activity:${commandUuid}`), - threadId, - activity: { - id: EventId.make(`coil-auto-resume:${eventUuid}`), - tone, - kind, - summary, - payload, - turnId: null, - createdAt, - }, - createdAt, - }); - }).pipe( - // Timeline note is best-effort; never let it fail a resume. - Effect.catchCause((cause) => - Effect.logDebug("coil auto-resume: activity append failed", { - threadId, - kind, - cause: Cause.pretty(cause), - }), - ), - ); - - const dispatchResume = (thread: OrchestrationThread, text: string) => - Effect.gen(function* () { - const commandUuid = yield* crypto.randomUUIDv4; - const messageUuid = yield* crypto.randomUUIDv4; - const createdAt = yield* isoNow; - // A plain user turn — byte-for-byte the path a keystroke produces. The decider - // uses the thread's own runtimeMode/interactionMode and places no session-status - // guard on turn starts (decider.ts:591-684). Provider respawn + `--resume` are - // already handled by ClaudeAdapter, so no provider work is needed here. - yield* engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make(`coil-auto-resume:${commandUuid}`), - threadId: thread.id, - message: { - messageId: MessageId.make(`coil-auto-resume:${messageUuid}`), - role: "user", - text, - attachments: [], - }, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - createdAt, - }); - }); - - // --- detection ------------------------------------------------------------ - const onRuntimeEvent = (event: ProviderRuntimeEvent) => - Effect.gen(function* () { - // Upstream #9507 normalises `account.rate-limits.updated` into utilisation windows - // with no `status`, and drops the event entirely when the SDK omits `utilization`. - // The raw `SDKRateLimitInfo` now travels as the `detail` of the `runtime.warning` - // the adapter raises for a rejected window that overage does not cover — which is - // exactly the case this tap acts on. `adapterReplay.test.ts` pins that route. - if (event.type !== "runtime.warning") return; - if (event.provider !== CLAUDE_DRIVER_KIND) return; - - const verdict = classifyRateLimit(event.payload.detail); - if (!verdict || !verdict.rejected) return; - - const threadId = event.threadId; - const record = yield* store.getThread(threadId); - // Per-thread switch. A thread the user turned off never schedules, and posts no - // timeline note: they disabled it deliberately, so an activity row would be noise. - if (!record.enabled) { - yield* receipts.emit({ type: "resume.skipped", threadId, reason: "disabled" }); - return; - } - const nowMs = yield* Clock.currentTimeMillis; - const firedRecently = yield* store.countFiredSince(threadId, nowMs - BACKOFF_LOOKBACK_MS); - const firedInCapWindow = yield* store.countFiredSince(threadId, nowMs - CAP_WINDOW_MS); - - const plan = planSchedule({ - verdict, - pendingResumeAtMs: record.pending?.resumeAtMs ?? null, - nowMs, - firedRecently, - firedInCapWindow, - config, - }); - // A capped-out thread stops scheduling here (and stops posting "scheduled" notes) - // until its fires age out of the 24h window; all other skips are dedupe/no-op. - if (plan.kind === "skip") { - yield* receipts.emit({ type: "resume.skipped", threadId, reason: plan.reason }); - return; - } - - const snapshot = yield* snapshotQuery.getSnapshot(); - const thread = snapshot.threads.find((t) => t.id === threadId); - if (!thread || threadIsGone(thread) || !isClaudeThread(thread)) { - yield* receipts.emit({ type: "resume.skipped", threadId, reason: "thread-ineligible" }); - return; - } - - // Replacing an existing arm (a later window superseded it — see decide.ts). The - // fresh `captureBaseline` below is what re-baselines the resume onto whatever the - // thread looks like now, so a supersede is also the re-arm path for #39. - const superseded = record.pending !== null; - - yield* store.schedule({ - threadId, - resumeAtMs: plan.resumeAtMs, - reason: verdict.rateLimitType ?? "usage limit", - scheduledAtMs: nowMs, - baseline: captureBaseline(thread), - }); - - const waitMinutes = Math.max(0, Math.round((plan.resumeAtMs - nowMs) / 60_000)); - const limitType = verdict.rateLimitType ?? "window"; - yield* appendActivity( - threadId, - "info", - superseded ? "coil.auto-resume.rescheduled" : "coil.auto-resume.scheduled", - superseded - ? `Usage limit window pushed back (${limitType}). Auto-resume rescheduled to ~${waitMinutes} min from now.` - : `Usage limit reached (${limitType}). Auto-resume scheduled in ~${waitMinutes} min.`, - ); - yield* receipts.emit({ - type: "resume.scheduled", - threadId, - resumeAtMs: plan.resumeAtMs, - superseded, - }); - }); - - // --- wake ----------------------------------------------------------------- - const fireOne = (pending: PendingResume, nowMs: number) => - Effect.gen(function* () { - // Re-read a FRESH snapshot per item, immediately before the guard check + dispatch, - // so every due thread is guarded against current state (not state captured before - // earlier dispatches in the same tick ran). - const snapshot = yield* snapshotQuery.getSnapshot(); - const thread = snapshot.threads.find((t) => t.id === pending.threadId); - if (!thread) { - yield* store.clearPending(pending.threadId); - yield* receipts.emit({ - type: "resume.cancelled", - threadId: pending.threadId, - reason: "thread-missing", - }); - return; - } - - // The user can switch a thread off *after* its resume was scheduled, so re-read the - // record here rather than trusting the value seen at scheduling time, and treat a - // disabled thread as a cancellation (drop the pending resume, say so once). - const record = yield* store.getThread(pending.threadId); - if (!record.enabled) { - yield* store.clearPending(pending.threadId); - yield* appendActivity( - thread.id, - "info", - "coil.auto-resume.cancelled", - "Auto-resume cancelled: turned off for this thread.", - ); - yield* receipts.emit({ - type: "resume.cancelled", - threadId: pending.threadId, - reason: "disabled", - }); - return; - } - - // `pending` structurally satisfies `ResumeArm` (baseline + resumeAtMs). The guard - // needs to know WHICH window it was waiting on: a turn that ended before that - // window reopened was rejected by the same limit and is not advancement. - const reason = cancelReason(thread, pending); - if (reason) { - yield* store.clearPending(pending.threadId); - yield* appendActivity( - thread.id, - "info", - "coil.auto-resume.cancelled", - `Auto-resume cancelled: ${reason}.`, - // Enough to re-diagnose a lost arm without opening the projection: which rule - // fired, which window it was waiting on, and which turn it was comparing. - { - reason, - resumeAtMs: pending.resumeAtMs, - baselineTurnId: pending.baseline.latestTurnId, - observedTurnId: thread.latestTurn?.turnId ?? null, - observedTurnCompletedAt: thread.latestTurn?.completedAt ?? null, - }, - ); - yield* receipts.emit({ type: "resume.cancelled", threadId: pending.threadId, reason }); - return; - } - - const firedIn24h = yield* store.countFiredSince(pending.threadId, nowMs - CAP_WINDOW_MS); - if (firedIn24h >= config.maxResumesPer24h) { - yield* store.clearPending(pending.threadId); - yield* appendActivity( - thread.id, - "error", - "coil.auto-resume.capped", - `Auto-resume stopped after ${config.maxResumesPer24h} attempts in 24h.`, - ); - yield* receipts.emit({ - type: "resume.cancelled", - threadId: pending.threadId, - reason: "capped", - }); - return; - } - - // Reserve the attempt (clears pending + records the fire) BEFORE dispatch so a - // dispatch failure cannot tight-loop retry; a genuine re-limit re-arms via a new - // rejection event, spaced out on the backoff ladder. - yield* store.recordFired(pending.threadId, nowMs); - - const project = snapshot.projects.find((pr) => pr.id === thread.projectId); - const workspaceRoot = project?.workspaceRoot ?? thread.worktreePath ?? null; - const overridePrompt = (yield* store.getThread(pending.threadId)).overridePrompt; - const text = yield* resolveResumePrompt({ workspaceRoot, threadOverride: overridePrompt }); - - yield* appendActivity( - thread.id, - "info", - "coil.auto-resume.resumed", - `Resuming now (attempt ${firedIn24h + 1} of ${config.maxResumesPer24h}).`, - ); - yield* dispatchResume(thread, text).pipe( - Effect.catchCause((cause) => - Effect.logWarning("coil auto-resume: resume dispatch failed", { - threadId: pending.threadId, - cause: Cause.pretty(cause), - }), - ), - ); - // After the dispatch attempt, not before: a failed dispatch still burned the attempt, - // and the test that asserts exactly that needs to be told when the attempt is over. - yield* receipts.emit({ - type: "resume.fired", - threadId: pending.threadId, - attempt: firedIn24h + 1, - }); - }); - - const processDue = Effect.gen(function* () { - const nowMs = yield* Clock.currentTimeMillis; - const pending = yield* store.listPending; - const due = pending.filter((p) => p.resumeAtMs <= nowMs); - yield* Effect.forEach( - due, - (p) => - fireOne(p, nowMs).pipe( - // Per arm, so one bad record cannot abort the rest of the batch — and cannot skip - // the end-of-pass receipt below. `fireOne` reads a fresh snapshot per item and - // `getSnapshot` has a typed `ProjectionRepositoryError` channel, so this is an - // expected failure, not just a defect. The arm is left as it was: nothing was - // reserved and nothing was cleared, so the next pass tries it again. - Effect.catchCause((cause) => - Effect.logWarning("coil auto-resume: fire failed", { - threadId: p.threadId, - cause: Cause.pretty(cause), - }), - ), - ), - { discard: true }, - ); - // The end-of-pass receipt, published on the empty path too: it is what makes "advance one - // poll and let the reactor finish" an exact await instead of a budget of scheduler turns. - // Assembled only when someone is listening — this runs every poll of every install. - if (receipts.enabled) { - yield* receipts.emit({ type: "tick.completed", dueCount: due.length, nowMs }); - } - }); - - /** - * How long to sleep before looking again: until the earliest armed resume comes due, - * capped at `pollMs` so a resume armed *during* the sleep is still picked up within one - * poll, and floored so a near-instant due time cannot spin the fiber. - * - * A fixed `pollMs` cadence is unaligned to the due time, which cost the observed 0-30s - * of lateness (an arm due at 19:51:00 fired at 19:51:42). Sleeping to the due time makes - * fire time track armed time instead. This changes only WHEN the loop looks, never what - * it checks once awake — `fireOne` still takes its own fresh snapshot per item, which is - * what closes the wake race. - * - * Arms that are already due are excluded (`delta > 0`), so a due-but-unfired arm cannot - * compute a zero sleep and busy-loop a full `getSnapshot()` per iteration. That is the - * reachable failure mode here: if `processDue` dies (a snapshot read that throws), the - * arm stays pending and stays due, and the caller above only logs. - */ - const nextWakeMs = Effect.gen(function* () { - const nowMs = yield* Clock.currentTimeMillis; - const upcoming = (yield* store.listPending) - .map((p) => p.resumeAtMs - nowMs) - .filter((delta) => delta > 0); - if (upcoming.length === 0) return config.pollMs; - return Math.max(MIN_WAKE_MS, Math.min(config.pollMs, ...upcoming)); - }); - - if (!config.enabled) { - yield* Effect.logInfo("coil auto-resume: disabled via T3X_AUTO_RESUME_ENABLED"); - return; - } - - yield* Effect.forkScoped( - Stream.runForEach(providerService.streamEvents, (event) => - onRuntimeEvent(event).pipe( - Effect.catchCause((cause) => - Effect.logWarning("coil auto-resume: detection failed", { - eventType: event.type, - cause: Cause.pretty(cause), - }), - ), - ), - ), - ); - - // Process THEN sleep, deliberately: a server that restarts holding an overdue arm fires - // it immediately rather than up to one poll interval later. - yield* Effect.forkScoped( - Effect.gen(function* () { - // Boot grace, BEFORE the first pass. - // - // This fiber is forked during layer construction, but crashed turns are settled later, - // in the `reconcile.interrupted-turns` startup phase (serverRuntimeStartup.ts:359). - // A server killed mid-turn therefore comes back with the projection still showing that - // turn as running. If the wake fiber looks first, an arm that fell due while the machine - // was down reads as `progressing` and `fireOne` CLEARS it — permanently, with no retry. - // That is the same silent-arm-loss this module exists to prevent. - // - // The previous `processDue.pipe(delay(pollMs), forever)` shape got this for free, because - // `Effect.delay` ran before every pass. Sleeping until the earliest due time is a strict - // improvement afterwards, so the grace is stated explicitly rather than re-derived from - // the poll cadence. - yield* Effect.sleep(Duration.millis(config.pollMs)); - return yield* Effect.gen(function* () { - yield* processDue; - // Sleeping until the earliest due arm (rather than a fixed cadence) is what makes the - // fire time track the armed time; `nextWakeMs` is inside the catch so a defect here - // cannot kill the only wake fiber and silently retire the feature. - yield* Effect.sleep(Duration.millis(yield* nextWakeMs)); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("coil auto-resume: wake tick failed", { cause: Cause.pretty(cause) }), - ), - Effect.forever, - ); - }), - ); - - yield* Effect.logInfo("coil auto-resume: supervisor started", { - pollMs: config.pollMs, - maxResumesPer24h: config.maxResumesPer24h, - }); -}); - -export const AutoResumeReactorLive = Layer.effectDiscard(makeSupervisor); diff --git a/coil-parked/apps/server/src/coil/autoResume/classifyRateLimit.test.ts b/coil-parked/apps/server/src/coil/autoResume/classifyRateLimit.test.ts deleted file mode 100644 index 48666c4b890d..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/classifyRateLimit.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { classifyRateLimit, normalizeResetsAtMs } from "./classifyRateLimit.ts"; - -// Shapes mirror @anthropic-ai/claude-agent-sdk SDKRateLimitEvent / SDKRateLimitInfo. -const event = (info: Record) => ({ - type: "rate_limit_event", - rate_limit_info: info, - uuid: "u", - session_id: "s", -}); - -describe("classifyRateLimit", () => { - it("classifies a rejected five_hour window with a reset time", () => { - const resetSeconds = 1_800_000_000; - const verdict = classifyRateLimit( - event({ status: "rejected", rateLimitType: "five_hour", resetsAt: resetSeconds }), - ); - expect(verdict).toEqual({ - rejected: true, - resetsAtMs: resetSeconds * 1000, - rateLimitType: "five_hour", - status: "rejected", - }); - }); - - it("classifies allowed and allowed_warning as not rejected", () => { - expect(classifyRateLimit(event({ status: "allowed" }))?.rejected).toBe(false); - expect(classifyRateLimit(event({ status: "allowed_warning" }))?.rejected).toBe(false); - }); - - it("returns resetsAtMs null when the SDK omits resetsAt (backoff path)", () => { - const verdict = classifyRateLimit(event({ status: "rejected", rateLimitType: "seven_day" })); - expect(verdict).toEqual({ - rejected: true, - resetsAtMs: null, - rateLimitType: "seven_day", - status: "rejected", - }); - }); - - it("reads the info directly when there is no nested rate_limit_info wrapper", () => { - const verdict = classifyRateLimit({ status: "rejected", resetsAt: 2_000_000_000 }); - expect(verdict?.rejected).toBe(true); - expect(verdict?.resetsAtMs).toBe(2_000_000_000 * 1000); - }); - - it("returns undefined for unrecognized / malformed blobs", () => { - expect(classifyRateLimit(undefined)).toBeUndefined(); - expect(classifyRateLimit(null)).toBeUndefined(); - expect(classifyRateLimit("nope")).toBeUndefined(); - expect(classifyRateLimit({})).toBeUndefined(); - expect(classifyRateLimit(event({ status: "unknown_status" }))).toBeUndefined(); - expect(classifyRateLimit(event({ rateLimitType: "five_hour" }))).toBeUndefined(); - }); - - it("tolerates a non-string rateLimitType", () => { - const verdict = classifyRateLimit(event({ status: "rejected", rateLimitType: 5 })); - expect(verdict?.rateLimitType).toBeNull(); - }); -}); - -describe("normalizeResetsAtMs", () => { - it("scales epoch seconds up to milliseconds", () => { - expect(normalizeResetsAtMs(1_700_000_000)).toBe(1_700_000_000_000); - }); - - it("passes epoch milliseconds through unchanged", () => { - expect(normalizeResetsAtMs(1_700_000_000_000)).toBe(1_700_000_000_000); - }); - - it("returns null for absent / non-positive / non-finite input", () => { - expect(normalizeResetsAtMs(undefined)).toBeNull(); - expect(normalizeResetsAtMs(0)).toBeNull(); - expect(normalizeResetsAtMs(-5)).toBeNull(); - expect(normalizeResetsAtMs(Number.NaN)).toBeNull(); - expect(normalizeResetsAtMs("123")).toBeNull(); - }); -}); diff --git a/coil-parked/apps/server/src/coil/autoResume/classifyRateLimit.ts b/coil-parked/apps/server/src/coil/autoResume/classifyRateLimit.ts deleted file mode 100644 index 6ce8b72fa2a2..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/classifyRateLimit.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * classifyRateLimit — pure, structured decode of a Claude rate-limit payload. - * - * The single place that knows the vendor's rate-limit shape, isolated so it is the one - * thing to update if the SDK changes. Structured (not string matching): reads the - * `SDKRateLimitInfo` carried as the `detail` of a `runtime.warning` runtime event. - * - * Ground truth (@anthropic-ai/claude-agent-sdk): - * SDKRateLimitEvent = { type: "rate_limit_event"; rate_limit_info: SDKRateLimitInfo; ... } - * SDKRateLimitInfo = { - * status: "allowed" | "allowed_warning" | "rejected"; - * resetsAt?: number; // epoch (seconds); exact reset time - * rateLimitType?: "five_hour" | "seven_day" | "seven_day_opus" - * | "seven_day_sonnet" | "overage"; - * ... - * } - * - * ClaudeAdapter forwards the `SDKRateLimitInfo` of a rejected window as the `detail` of a - * `runtime.warning` event (upstream #9507; before that the whole `SDKRateLimitEvent` rode on - * `account.rate-limits.updated`, which is now a normalised, status-less window list). Both - * shapes decode here. This function is provider-agnostic in its parsing but is only ever fed - * Claude payloads by the auto-resume reactor (which gates on `provider === "claude"`). - * - * @module coil/autoResume/classifyRateLimit - */ - -const KNOWN_STATUSES = new Set(["allowed", "allowed_warning", "rejected"]); - -export interface RateLimitVerdict { - readonly rejected: boolean; - /** Epoch milliseconds of the window reset, or null when the SDK omitted it. */ - readonly resetsAtMs: number | null; - readonly rateLimitType: string | null; - readonly status: "allowed" | "allowed_warning" | "rejected"; -} - -function asRecord(value: unknown): Record | null { - return typeof value === "object" && value !== null ? (value as Record) : null; -} - -/** - * Normalize an SDK `resetsAt` to epoch milliseconds. The SDK documents epoch, and in - * practice emits seconds; anything that looks like seconds (i.e. too small to be a - * plausible future ms timestamp) is scaled up. Returns null for absent/invalid input. - */ -export function normalizeResetsAtMs(resetsAt: unknown): number | null { - if (typeof resetsAt !== "number" || !Number.isFinite(resetsAt) || resetsAt <= 0) { - return null; - } - // 1e12 ms ≈ 2001-09; any epoch below that is seconds, not milliseconds. - return resetsAt < 1e12 ? Math.round(resetsAt * 1000) : Math.round(resetsAt); -} - -/** - * Decode a rate-limit payload into a verdict, or undefined when the blob is not a - * recognizable rate-limit info object (so the caller ignores it). - */ -export function classifyRateLimit(rateLimits: unknown): RateLimitVerdict | undefined { - const outer = asRecord(rateLimits); - if (outer === null) return undefined; - - // Prefer the nested `rate_limit_info`; fall back to the outer object so a future - // adapter that forwards the info directly still classifies. - const info = asRecord(outer.rate_limit_info) ?? outer; - - const status = info.status; - if (typeof status !== "string" || !KNOWN_STATUSES.has(status)) { - return undefined; - } - - const rateLimitType = typeof info.rateLimitType === "string" ? info.rateLimitType : null; - - return { - rejected: status === "rejected", - resetsAtMs: normalizeResetsAtMs(info.resetsAt), - rateLimitType, - status: status as RateLimitVerdict["status"], - }; -} diff --git a/coil-parked/apps/server/src/coil/autoResume/config.test.ts b/coil-parked/apps/server/src/coil/autoResume/config.test.ts deleted file mode 100644 index 2209e7392b04..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/config.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as NodePath from "node:path"; - -import { - DEFAULT_BACKOFF_LADDER_MS, - DEFAULT_RESUME_PROMPT, - RESUME_PROMPT_RELATIVE_PATH, - backoffDelayMs, - resolveConfig, - resolveResumePrompt, -} from "./config.ts"; - -describe("resolveConfig", () => { - it("uses safe defaults for an empty env", () => { - const c = resolveConfig({}); - assert.strictEqual(c.enabled, true); - assert.strictEqual(c.pollMs, 30_000); - assert.strictEqual(c.maxResumesPer24h, 10); - assert.strictEqual(c.safetyMarginMs, 60_000); - assert.deepStrictEqual([...c.backoffLadderMs], [...DEFAULT_BACKOFF_LADDER_MS]); - }); - - it("parses overrides and ignores invalid values", () => { - const c = resolveConfig({ - T3X_AUTO_RESUME_ENABLED: "false", - T3X_AUTO_RESUME_POLL_MS: "5000", - T3X_AUTO_RESUME_MAX_PER_24H: "-3", // invalid -> default - T3X_AUTO_RESUME_SAFETY_MARGIN_MS: "0", // invalid -> default - T3X_AUTO_RESUME_BACKOFF_MS: "1000, 2000 ,x", - }); - assert.strictEqual(c.enabled, false); - assert.strictEqual(c.pollMs, 5000); - assert.strictEqual(c.maxResumesPer24h, 10); - assert.strictEqual(c.safetyMarginMs, 60_000); - assert.deepStrictEqual([...c.backoffLadderMs], [1000, 2000]); - }); -}); - -describe("backoffDelayMs", () => { - it("clamps the attempt index into the ladder", () => { - const ladder = [10, 20, 30]; - assert.strictEqual(backoffDelayMs(ladder, 0), 10); - assert.strictEqual(backoffDelayMs(ladder, 1), 20); - assert.strictEqual(backoffDelayMs(ladder, 99), 30); - assert.strictEqual(backoffDelayMs(ladder, -1), 10); - }); -}); - -describe("resolveResumePrompt", () => { - it("prefers a per-thread override", () => - Effect.gen(function* () { - const result = yield* resolveResumePrompt({ - workspaceRoot: null, - threadOverride: " do the thing ", - }); - assert.strictEqual(result, "do the thing"); - }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise)); - - it("reads the project resume-prompt file when present", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "coil-prompt-" }); - const promptPath = NodePath.join(root, RESUME_PROMPT_RELATIVE_PATH); - yield* fs.makeDirectory(NodePath.dirname(promptPath), { recursive: true }); - yield* fs.writeFileString(promptPath, "read the latest iter log and continue\n"); - const result = yield* resolveResumePrompt({ workspaceRoot: root }); - assert.strictEqual(result, "read the latest iter log and continue"); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise)); - - it("falls back to the default when no override and no file", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "coil-prompt-" }); - const result = yield* resolveResumePrompt({ workspaceRoot: root }); - assert.strictEqual(result, DEFAULT_RESUME_PROMPT); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise)); -}); diff --git a/coil-parked/apps/server/src/coil/autoResume/config.ts b/coil-parked/apps/server/src/coil/autoResume/config.ts deleted file mode 100644 index a79006e54fc6..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/config.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Auto-resume configuration and resume-prompt resolution. - * - * Config is read from env with safe defaults, so the feature works with zero setup. - * `resolveConfig` is pure (env passed in) for testability. - * - * @module coil/autoResume/config - */ - -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; - -export interface AutoResumeConfig { - readonly enabled: boolean; - readonly pollMs: number; - readonly maxResumesPer24h: number; - readonly safetyMarginMs: number; - /** Backoff ladder (ms) used when the SDK omits `resetsAt`; the last value is the cap. */ - readonly backoffLadderMs: ReadonlyArray; -} - -export const DEFAULT_BACKOFF_LADDER_MS: ReadonlyArray = [ - 15 * 60_000, - 30 * 60_000, - 60 * 60_000, -]; - -const DEFAULTS: AutoResumeConfig = { - enabled: true, - pollMs: 30_000, - maxResumesPer24h: 10, - safetyMarginMs: 60_000, - backoffLadderMs: DEFAULT_BACKOFF_LADDER_MS, -}; - -function parseBool(value: string | undefined, fallback: boolean): boolean { - if (value === undefined) return fallback; - const v = value.trim().toLowerCase(); - if (v === "false" || v === "0" || v === "no" || v === "off") return false; - if (v === "true" || v === "1" || v === "yes" || v === "on") return true; - return fallback; -} - -function parsePositiveInt(value: string | undefined, fallback: number): number { - if (value === undefined) return fallback; - const n = Number.parseInt(value.trim(), 10); - return Number.isFinite(n) && n > 0 ? n : fallback; -} - -function parseLadder( - value: string | undefined, - fallback: ReadonlyArray, -): ReadonlyArray { - if (value === undefined) return fallback; - const parts = value - .split(",") - .map((p) => Number.parseInt(p.trim(), 10)) - .filter((n) => Number.isFinite(n) && n > 0); - return parts.length > 0 ? parts : fallback; -} - -export function resolveConfig( - env: Record = process.env, -): AutoResumeConfig { - return { - enabled: parseBool(env.T3X_AUTO_RESUME_ENABLED, DEFAULTS.enabled), - pollMs: parsePositiveInt(env.T3X_AUTO_RESUME_POLL_MS, DEFAULTS.pollMs), - maxResumesPer24h: parsePositiveInt(env.T3X_AUTO_RESUME_MAX_PER_24H, DEFAULTS.maxResumesPer24h), - safetyMarginMs: parsePositiveInt(env.T3X_AUTO_RESUME_SAFETY_MARGIN_MS, DEFAULTS.safetyMarginMs), - backoffLadderMs: parseLadder(env.T3X_AUTO_RESUME_BACKOFF_MS, DEFAULTS.backoffLadderMs), - }; -} - -/** The backoff delay for the Nth (0-based) attempt when no `resetsAt` is known. */ -export function backoffDelayMs(ladder: ReadonlyArray, attemptIndex: number): number { - if (ladder.length === 0) return DEFAULT_BACKOFF_LADDER_MS[DEFAULT_BACKOFF_LADDER_MS.length - 1]!; - const clamped = Math.min(Math.max(attemptIndex, 0), ladder.length - 1); - return ladder[clamped]!; -} - -export const DEFAULT_RESUME_PROMPT = "continue"; -export const RESUME_PROMPT_RELATIVE_PATH = ".t3x/resume-prompt.md"; - -/** - * Resolve the text to send on resume. First match wins: - * 1. an explicit per-thread override (passed in from durable state), - * 2. `/.t3x/resume-prompt.md` committed in the thread's repo, - * 3. the literal "continue". - * Never fails: any file error falls through to the default. - */ -export function resolveResumePrompt(input: { - readonly workspaceRoot: string | null; - readonly threadOverride?: string | null; -}): Effect.Effect { - return Effect.gen(function* () { - const override = input.threadOverride?.trim(); - if (override) return override; - - if (input.workspaceRoot) { - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const promptPath = path.join(input.workspaceRoot, RESUME_PROMPT_RELATIVE_PATH); - const fromFile = yield* fs.readFileString(promptPath).pipe( - Effect.map((text) => text.trim()), - Effect.orElseSucceed(() => ""), - ); - if (fromFile) return fromFile; - } - - return DEFAULT_RESUME_PROMPT; - }); -} diff --git a/coil-parked/apps/server/src/coil/autoResume/decide.test.ts b/coil-parked/apps/server/src/coil/autoResume/decide.test.ts deleted file mode 100644 index b7d0d24af67e..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/decide.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveConfig } from "./config.ts"; -import { planSchedule, type PlanScheduleInput } from "./decide.ts"; -import type { RateLimitVerdict } from "./classifyRateLimit.ts"; - -const config = resolveConfig({}); // defaults: margin 60s, backoff 15/30/60m, cap 10/24h -const rejected = (o: Partial = {}): RateLimitVerdict => ({ - rejected: true, - resetsAtMs: 1_000_000, - rateLimitType: "five_hour", - status: "rejected", - ...o, -}); - -// Base input = a fresh rejection, nothing pending, no prior fires, not capped. -// With the default verdict (resetsAtMs 1_000_000) and nowMs 0 the computed resume is -// 1_000_000 + 60_000 margin = 1_060_000; the pending-comparison tests are anchored on that. -const plan = (o: Partial = {}) => - planSchedule({ - verdict: rejected(), - pendingResumeAtMs: null, - nowMs: 0, - firedRecently: 0, - firedInCapWindow: 0, - config, - ...o, - }); - -describe("planSchedule", () => { - it("schedules at resetsAt + safety margin when the window opens in the future", () => { - const p = plan({ verdict: rejected({ resetsAtMs: 1_000_000 }), nowMs: 500_000 }); - expect(p.kind).toBe("schedule"); - if (p.kind !== "schedule") return; - expect(p.resumeAtMs).toBe(1_000_000 + config.safetyMarginMs); - }); - - it("skips a non-rejected verdict", () => { - expect(plan({ verdict: rejected({ rejected: false }) })).toEqual({ - kind: "skip", - reason: "not-rejected", - }); - }); - - it("skips when a resume is already pending (dedupes telemetry re-emits)", () => { - // Same window re-emitted: computed 1_060_000 is not later than the arm, so nothing moves. - expect(plan({ pendingResumeAtMs: 1_060_000 })).toEqual({ - kind: "skip", - reason: "already-pending", - }); - }); - - // radroid/t3code#39: a second, longer limit landing on top of an armed shorter one used - // to be dropped, so the arm fired into a window that was still shut. - it("re-schedules when a concrete later reset window supersedes the pending arm", () => { - const p = plan({ pendingResumeAtMs: 500_000 }); - expect(p.kind).toBe("schedule"); - if (p.kind !== "schedule") return; - expect(p.resumeAtMs).toBe(1_000_000 + config.safetyMarginMs); - }); - - it("keeps the existing arm when the new window opens earlier", () => { - expect(plan({ pendingResumeAtMs: 5_000_000 })).toEqual({ - kind: "skip", - reason: "already-pending", - }); - }); - - // The churn guard. A ladder-derived time is `nowMs + delay`, so it is later on every - // re-emit; if those superseded, a persistent limit would push the arm out forever and - // post a reschedule note each time. - it("never lets a backoff-ladder re-emit push out a pending arm", () => { - expect( - plan({ verdict: rejected({ resetsAtMs: null }), nowMs: 100_000, pendingResumeAtMs: 50_000 }), - ).toEqual({ kind: "skip", reason: "already-pending" }); - }); - - it("skips when the thread has hit the 24h cap (stops re-scheduling + misleading notes)", () => { - expect(plan({ firedInCapWindow: config.maxResumesPer24h })).toEqual({ - kind: "skip", - reason: "capped", - }); - // One below the cap still schedules. - expect(plan({ firedInCapWindow: config.maxResumesPer24h - 1 }).kind).toBe("schedule"); - }); - - it("uses backoff from now when resetsAt is absent (offset applied to nowMs)", () => { - const nowMs = 1_000_000; // non-zero so the `nowMs +` term is actually exercised - const p = plan({ verdict: rejected({ resetsAtMs: null }), nowMs, firedRecently: 1 }); - if (p.kind !== "schedule") throw new Error("expected schedule"); - expect(p.resumeAtMs).toBe(nowMs + config.backoffLadderMs[1]!); // attempt 1 -> 30m - }); - - it("uses backoff from now when the reset time is already in the past (stale/persistent limit)", () => { - const nowMs = 2_000_000; - const p = plan({ verdict: rejected({ resetsAtMs: 1_000_000 }), nowMs, firedRecently: 2 }); - if (p.kind !== "schedule") throw new Error("expected schedule"); - // Does NOT reuse the stale resetsAt; climbs the ladder (attempt 2 -> 60m cap) from now. - expect(p.resumeAtMs).toBe(nowMs + config.backoffLadderMs[2]!); - }); - - it("caps the backoff ladder at its last rung", () => { - const p = plan({ verdict: rejected({ resetsAtMs: null }), nowMs: 5_000, firedRecently: 99 }); - if (p.kind !== "schedule") throw new Error("expected schedule"); - expect(p.resumeAtMs).toBe(5_000 + config.backoffLadderMs[config.backoffLadderMs.length - 1]!); - }); -}); diff --git a/coil-parked/apps/server/src/coil/autoResume/decide.ts b/coil-parked/apps/server/src/coil/autoResume/decide.ts deleted file mode 100644 index 1d82efe88952..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/decide.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Pure scheduling decision for auto-resume. - * - * Separated from the reactor's IO so the tricky parts — dedupe and reset-vs-backoff - * timing — are exhaustively unit-testable. - * - * @module coil/autoResume/decide - */ - -import type { AutoResumeConfig } from "./config.ts"; -import { backoffDelayMs } from "./config.ts"; -import type { RateLimitVerdict } from "./classifyRateLimit.ts"; - -export interface SchedulePlan { - readonly kind: "schedule"; - readonly resumeAtMs: number; -} - -export interface SkipPlan { - readonly kind: "skip"; - readonly reason: "already-pending" | "not-rejected" | "capped"; -} - -export interface PlanScheduleInput { - readonly verdict: RateLimitVerdict; - /** - * `resumeAtMs` of this thread's pending resume, or null when nothing is armed. - * Still one-pending-per-thread — a new plan replaces the arm rather than adding one. - */ - readonly pendingResumeAtMs: number | null; - readonly nowMs: number; - /** Fires for this thread within the recent backoff window — drives the ladder. */ - readonly firedRecently: number; - /** Fires for this thread within the rolling 24h cap window. */ - readonly firedInCapWindow: number; - readonly config: AutoResumeConfig; -} - -/** - * Decide whether/when to schedule a resume for a rejection. - * - * Dedup is "one pending per thread": while a resume is pending we skip every telemetry - * re-emit (no churn). Once a resume fires, its pending is cleared, so the next rejection - * re-arms naturally — and because `firedRecently` has incremented, its resume is spaced - * out on the backoff ladder rather than tight-looping. - * - * One narrow exception (radroid/t3code#39): a rejection that names a CONCRETE reset time - * LATER than the pending one supersedes it. A `seven_day` limit landing on top of an - * armed `five_hour` used to be dropped outright, so the arm fired into a window that was - * still shut and burned an attempt. The exception is deliberately restricted to - * `windowOpensInFuture` — a ladder-derived time is `nowMs + delay`, which grows with - * every re-emit, so allowing those to supersede would push the arm out forever and flood - * the timeline with reschedule notes. An earlier reset time never supersedes either: the - * existing arm is already the conservative choice. - * - * The 24h cap is checked HERE (at schedule time) as well as at fire time. Checking it at - * schedule time stops a capped-out thread from re-scheduling — and re-posting a misleading - * "auto-resume scheduled" timeline note — on every subsequent rejection until the fires - * age out of the window. The fire-time check remains as defence-in-depth. - * - * Timing: - * - window opens in the future (`resetsAt > now`): wait until `resetsAt + margin`. - * - window already open (or absent) but still rejected: the reset time is stale / - * the limit persists, so retry on the backoff ladder from `now`. - * - * This deliberately uses NO persistent per-signature dedup: a signature keyed on a - * volatile fired-count or a fixed reset time either collides across episodes (permanent - * skip) or blocks re-arming a persistent limit. One-pending + backoff avoids both. - */ -export function planSchedule(input: PlanScheduleInput): SchedulePlan | SkipPlan { - const { verdict, pendingResumeAtMs, nowMs, firedRecently, firedInCapWindow, config } = input; - - if (!verdict.rejected) return { kind: "skip", reason: "not-rejected" }; - - const windowOpensInFuture = verdict.resetsAtMs !== null && verdict.resetsAtMs > nowMs; - const resumeAtMs = windowOpensInFuture - ? verdict.resetsAtMs! + config.safetyMarginMs - : nowMs + backoffDelayMs(config.backoffLadderMs, firedRecently); - - if (pendingResumeAtMs !== null) { - const supersedes = windowOpensInFuture && resumeAtMs > pendingResumeAtMs; - if (!supersedes) return { kind: "skip", reason: "already-pending" }; - } - if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" }; - - return { kind: "schedule", resumeAtMs }; -} diff --git a/coil-parked/apps/server/src/coil/autoResume/guards.test.ts b/coil-parked/apps/server/src/coil/autoResume/guards.test.ts deleted file mode 100644 index d356f543affe..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/guards.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { - type OrchestrationThread, - type OrchestrationThreadShell, - ProjectId, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; - -import { - cancelReason, - captureBaseline, - hasOpenBlockingRequest, - isClaudeThread, - newestUserMessageId, - threadIsGone, - threadIsProgressing, -} from "./guards.ts"; - -type Msg = { id: string; role: string }; -type Act = { kind: string; payload: unknown }; - -// Minimal thread shape covering only the fields the guards read. -const makeThread = (o: { - messages?: Msg[]; - activities?: Act[]; - status?: string; - providerName?: string | null; - latestTurnState?: string | null; - latestTurnId?: string | null; - /** ISO time the latest turn ended; drives the "did it outlive the closed window?" rule. */ - latestTurnCompletedAt?: string | null; - deletedAt?: string | null; - archivedAt?: string | null; - settledOverride?: string | null; -}): OrchestrationThread => - ({ - id: "thread-1", - projectId: "project-1", - messages: o.messages ?? [], - activities: o.activities ?? [], - deletedAt: o.deletedAt ?? null, - archivedAt: o.archivedAt ?? null, - settledOverride: o.settledOverride ?? null, - latestTurn: - o.latestTurnId || o.latestTurnState - ? { - turnId: o.latestTurnId ?? "turn-1", - state: o.latestTurnState ?? "completed", - completedAt: o.latestTurnCompletedAt ?? null, - } - : null, - session: { - status: o.status ?? "ready", - providerName: o.providerName === undefined ? "claudeAgent" : o.providerName, - }, - }) as unknown as OrchestrationThread; - -describe("newestUserMessageId", () => { - it("returns the last user message id, ignoring assistant messages", () => { - const thread = makeThread({ - messages: [ - { id: "m1", role: "user" }, - { id: "m2", role: "assistant" }, - { id: "m3", role: "user" }, - { id: "m4", role: "assistant" }, - ], - }); - expect(newestUserMessageId(thread)).toBe("m3"); - }); - - it("returns null with no user messages", () => { - expect( - newestUserMessageId(makeThread({ messages: [{ id: "m1", role: "assistant" }] })), - ).toBeNull(); - }); -}); - -describe("hasOpenBlockingRequest", () => { - it("is true for an unresolved approval request", () => { - expect( - hasOpenBlockingRequest([{ kind: "approval.requested", payload: { requestId: "r1" } }]), - ).toBe(true); - }); - - it("is false once the request is resolved", () => { - expect( - hasOpenBlockingRequest([ - { kind: "approval.requested", payload: { requestId: "r1" } }, - { kind: "approval.resolved", payload: { requestId: "r1" } }, - ]), - ).toBe(false); - }); - - it("clears on a stale respond-failed detail", () => { - expect( - hasOpenBlockingRequest([ - { kind: "user-input.requested", payload: { requestId: "r1" } }, - { - kind: "provider.user-input.respond.failed", - payload: { requestId: "r1", detail: "Unknown pending user-input request" }, - }, - ]), - ).toBe(false); - }); - - it("stays open on a non-stale respond-failed detail", () => { - expect( - hasOpenBlockingRequest([ - { kind: "user-input.requested", payload: { requestId: "r1" } }, - { - kind: "provider.user-input.respond.failed", - payload: { requestId: "r1", detail: "boom" }, - }, - ]), - ).toBe(true); - }); - - it("ignores activities without a requestId", () => { - expect(hasOpenBlockingRequest([{ kind: "approval.requested", payload: {} }])).toBe(false); - }); -}); - -describe("simple predicates", () => { - it("isClaudeThread matches the claudeAgent driver, case-insensitively", () => { - expect(isClaudeThread(makeThread({ providerName: "claudeAgent" }))).toBe(true); - expect(isClaudeThread(makeThread({ providerName: "ClaudeAgent" }))).toBe(true); - expect(isClaudeThread(makeThread({ providerName: "codex" }))).toBe(false); - expect(isClaudeThread(makeThread({ providerName: null }))).toBe(false); - }); - - // A shell carries `session` with the same `OrchestrationSession | null` type the full - // thread does, so it satisfies the guard's `Pick<…, "session">` argument. - // - // Built with NO cast, deliberately: the cast-free literal is the only thing pinning the - // widening. Revert `isClaudeThread` to `(thread: OrchestrationThread)` and this file stops - // type-checking, which is the failure a shell-only caller (the loop supervisor reads the - // shell stream) would otherwise hit at build time instead of here. - it("isClaudeThread accepts a thread shell, not just a full thread", () => { - const shell: OrchestrationThreadShell = { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("claude"), model: "opus" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: "feature", - worktreePath: "/repo", - latestTurn: null, - createdAt: "2026-09-01T00:00:00.000Z", - updatedAt: "2026-09-02T00:00:00.000Z", - archivedAt: null, - settledOverride: null, - settledAt: null, - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: "2026-09-02T00:00:00.000Z", - }, - latestUserMessageAt: "2026-09-02T00:00:00.000Z", - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - pullRequests: [], - }; - - expect(isClaudeThread(shell)).toBe(true); - expect(isClaudeThread({ ...shell, session: null })).toBe(false); - }); - - it("threadIsGone covers deleted / archived", () => { - expect(threadIsGone(makeThread({ deletedAt: "2026-01-01" }))).toBe(true); - expect(threadIsGone(makeThread({ archivedAt: "2026-01-01" }))).toBe(true); - expect(threadIsGone(makeThread({}))).toBe(false); - }); - - it("a settled thread is not gone — a server timer settles it, not the user", () => { - // Upstream #8600's ThreadSettlementReactor auto-settles an idle thread after - // 3 days by default, writing the same settledOverride an explicit settle - // writes. Reading it as "gone" cancelled every resume armed more than three - // days out — the seven_day window could never survive to fire. - expect(threadIsGone(makeThread({ settledOverride: "settled" }))).toBe(false); - }); - - it("threadIsProgressing covers running/starting session and running turn", () => { - expect(threadIsProgressing(makeThread({ status: "running" }))).toBe(true); - expect(threadIsProgressing(makeThread({ status: "starting" }))).toBe(true); - expect(threadIsProgressing(makeThread({ status: "ready", latestTurnState: "running" }))).toBe( - true, - ); - expect(threadIsProgressing(makeThread({ status: "ready", latestTurnState: "completed" }))).toBe( - false, - ); - }); - - // Crash-recovery reconciliation settles leftover in-flight turns to a resumable - // `interrupted` state with a `stopped` session (see CrashRecoveryReconciler). Such a - // thread must NOT read as progressing, otherwise auto-resume would treat the crashed - // turn as still-working and never resume it. - it("threadIsProgressing is false for a reconciled interrupted/stopped thread", () => { - expect( - threadIsProgressing(makeThread({ status: "stopped", latestTurnState: "interrupted" })), - ).toBe(false); - }); -}); - -describe("cancelReason", () => { - const base = () => - makeThread({ - messages: [{ id: "u1", role: "user" }], - latestTurnId: "turn-1", - latestTurnState: "completed", - status: "ready", - }); - const baseline = () => captureBaseline(base()); - - // The arm the guard re-checks: the baseline PLUS the window it is waiting on. Written - // at test-clock scale (epoch ms, not wall-clock 2026 dates) on purpose — `resumeAtMs` - // comes from `Clock.currentTimeMillis`, which is 0-based under TestClock, so a fixture - // that mixes real ISO turn times with a test-clock arm reads every turn as post-window. - const REOPENS_AT_MS = 1_000; - const AFTER_REOPEN_ISO = "1970-01-01T00:00:06.000Z"; // 6_000ms — after the window opened - const BEFORE_REOPEN_ISO = "1970-01-01T00:00:00.500Z"; // 500ms — inside the shut window - const arm = () => ({ baseline: baseline(), resumeAtMs: REOPENS_AT_MS }); - - it("does not cancel a resume the server's idle sweep settled", () => { - // The end-to-end shape of the threadIsGone change: without it, upstream's - // 3-day auto-settle cancelled every arm waiting on the seven_day window. - const thread = makeThread({ settledOverride: "settled" }); - expect(cancelReason(thread, arm())).toBeNull(); - }); - - it("returns null when nothing changed and thread is idle Claude", () => { - expect(cancelReason(base(), arm())).toBeNull(); - }); - - // Regression for radroid/t3code#39. The removed `user-took-over` branch cancelled on - // any newer user message. The message that trips it is typically "keep going" typed at - // the usage-limit banner — and is itself rejected by the same limit, so it starts - // nothing and the thread is left with no pending resume at all. - it("does NOT cancel when a new user message arrived while the resume was pending", () => { - const thread = makeThread({ - messages: [ - { id: "u1", role: "user" }, - { id: "u2", role: "user" }, - ], - latestTurnId: "turn-1", - }); - expect(cancelReason(thread, arm())).toBeNull(); - }); - - // The baseline still records it — it is persisted with the pending resume and is what - // makes a stranded arm diagnosable from the state file. - it("still captures the newest user message id in the baseline", () => { - expect(baseline().newestUserMessageId).toBe("u1"); - }); - - // What the removed branch was actually reaching for: a user who is driving right now. - // That is `progressing`, and it still cancels. - it("cancels a new user message that is actually being worked on", () => { - const thread = makeThread({ - messages: [ - { id: "u1", role: "user" }, - { id: "u2", role: "user" }, - ], - status: "running", - }); - expect(cancelReason(thread, arm())).toBe("progressing"); - }); - - // Still advancement — but it now needs the turn to have OUTLIVED the closed window. - // A turn that lived entirely inside it is the doomed-turn case, covered below. - it("detects a new turn since scheduling", () => { - const thread = makeThread({ - messages: [{ id: "u1", role: "user" }], - latestTurnId: "turn-2", - latestTurnCompletedAt: AFTER_REOPEN_ISO, - }); - expect(cancelReason(thread, arm())).toBe("thread-advanced"); - }); - - // Regression for radroid/t3code#6: a limit captured mid-turn pins the running turn's id - // in the baseline, and the snapshot can report `latestTurn: null` at fire time when the - // join finds no retained turn row. Null is "no turn to report", not advancement — - // cancelling here killed every real-world resume. - it("does NOT treat a settled-away turn (latestTurn null at fire) as advancement", () => { - const thread = makeThread({ - messages: [{ id: "u1", role: "user" }], - status: "stopped", - }); - expect(thread.latestTurn).toBeNull(); - expect(cancelReason(thread, arm())).toBeNull(); - }); - - it("still detects advancement when a turn exists but the baseline had none", () => { - const noTurnArm = { - baseline: captureBaseline(makeThread({ messages: [{ id: "u1", role: "user" }] })), - resumeAtMs: REOPENS_AT_MS, - }; - const thread = makeThread({ - messages: [{ id: "u1", role: "user" }], - latestTurnId: "turn-9", - latestTurnCompletedAt: AFTER_REOPEN_ISO, - }); - expect(cancelReason(thread, noTurnArm)).toBe("thread-advanced"); - }); - - // --- the 2026-08-18 doomed-turn rule ------------------------------------------------- - // These three drive the real timestamp arithmetic. They are not optional garnish: the - // locked replay fixture leaves `completedAt` unset, so it reaches the right verdict via - // the no-evidence fallback and would stay green even if this comparison were inverted. - // In production `completed_at` is always present (0 of 764 settled turns are null), so - // the comparison below is the only thing standing between us and a fourth incident. - - it("does NOT treat a turn that lived inside the shut window as advancement", () => { - const thread = makeThread({ - messages: [{ id: "u1", role: "user" }], - latestTurnId: "turn-doomed", - latestTurnCompletedAt: BEFORE_REOPEN_ISO, - }); - expect(cancelReason(thread, arm())).toBeNull(); - }); - - // The incident's own arithmetic, at wall-clock scale: turn 64f7c4b7 ran 19:34:44.276 -> - // 19:34:44.784 against a five_hour window whose arm was due at 19:51:00. The sibling - // threads that DID resume started their next turns at 19:51:42 — after the reopen. - it("separates the incident's doomed turn from the siblings' real ones", () => { - const incidentArm = { - baseline: captureBaseline( - makeThread({ messages: [{ id: "u1", role: "user" }], latestTurnId: "turn-f0d77468" }), - ), - resumeAtMs: Date.parse("2026-08-18T19:51:00.000Z"), - }; - const doomed = makeThread({ - messages: [{ id: "u1", role: "user" }], - latestTurnId: "turn-64f7c4b7", - latestTurnCompletedAt: "2026-08-18T19:34:44.784Z", - }); - expect(cancelReason(doomed, incidentArm)).toBeNull(); - - const genuine = makeThread({ - messages: [{ id: "u1", role: "user" }], - latestTurnId: "turn-after-reopen", - latestTurnCompletedAt: "2026-08-18T19:52:10.000Z", - }); - expect(cancelReason(genuine, incidentArm)).toBe("thread-advanced"); - }); - - // Absent timing evidence is NOT evidence of advancement — cancelling is the destructive - // move, so "no proof" must never cancel. Stated as an invariant rather than left as an - // accident of a null check. - it("does NOT cancel for a turn it cannot place in time", () => { - const noTimestamp = makeThread({ - messages: [{ id: "u1", role: "user" }], - latestTurnId: "turn-unknown", - }); - expect(cancelReason(noTimestamp, arm())).toBeNull(); - - const unparseable = makeThread({ - messages: [{ id: "u1", role: "user" }], - latestTurnId: "turn-unknown", - latestTurnCompletedAt: "not-a-timestamp", - }); - expect(cancelReason(unparseable, arm())).toBeNull(); - }); - - it("blocks when awaiting input", () => { - const thread = makeThread({ - messages: [{ id: "u1", role: "user" }], - latestTurnId: "turn-1", - activities: [{ kind: "approval.requested", payload: { requestId: "r1" } }], - }); - expect(cancelReason(thread, arm())).toBe("awaiting-input"); - }); - - it("prioritizes thread-gone and non-claude and progressing", () => { - expect(cancelReason(makeThread({ deletedAt: "x" }), arm())).toBe("thread-gone"); - expect(cancelReason(makeThread({ providerName: "codex" }), arm())).toBe("not-claude"); - expect(cancelReason(makeThread({ status: "running" }), arm())).toBe("progressing"); - }); -}); diff --git a/coil-parked/apps/server/src/coil/autoResume/guards.ts b/coil-parked/apps/server/src/coil/autoResume/guards.ts deleted file mode 100644 index 3bcaecf382ed..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/guards.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * Pure guard helpers for auto-resume. - * - * Predicates operate on the authoritative `OrchestrationThread` read-model snapshot (the - * full-thread shape, which carries `messages` and `activities` — NOT the shell-only derived - * flags). The one exception is `isClaudeThread`, which asks for only the field it reads so a - * caller holding a thread shell can reuse it. Keeping them pure makes every guard trivially - * unit-testable. - * - * @module coil/autoResume/guards - */ - -import type { OrchestrationThread } from "@t3tools/contracts"; - -/** - * Baseline captured when a resume is scheduled, re-checked immediately before dispatch - * to detect that the thread moved on. - * - * `newestUserMessageId` is recorded but is deliberately NOT a cancel condition — see the - * block in `cancelReason` (radroid/t3code#39). It stays in the shape because it is part - * of the persisted pending-resume record (`state.ts`), it is re-captured on every - * (re)schedule, and it is what makes a stranded arm diagnosable from the state file. - */ -export interface GuardBaseline { - readonly newestUserMessageId: string | null; - readonly latestTurnId: string | null; -} - -/** Id of the newest `role:"user"` message, or null. Messages are in append order. */ -export function newestUserMessageId(thread: OrchestrationThread): string | null { - for (let i = thread.messages.length - 1; i >= 0; i--) { - const message = thread.messages[i]; - if (message && message.role === "user") return message.id; - } - return null; -} - -export function captureBaseline(thread: OrchestrationThread): GuardBaseline { - return { - newestUserMessageId: newestUserMessageId(thread), - latestTurnId: thread.latestTurn?.turnId ?? null, - }; -} - -/** - * Blocked-on-you work derived from retained activities: an approval or user-input - * request with no later resolution for the same requestId. - * - * LOGIC MIRROR of the private `hasOpenBlockingRequest` in - * `apps/server/src/orchestration/decider.ts` (see docs/coil/SEAMS.md). Kept faithful so - * auto-resume never fires into a pending prompt. If upstream adds a new blocking-request - * activity kind, this must be updated in lockstep. - */ -export function hasOpenBlockingRequest( - activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>, -): boolean { - const openRequestIds = new Set(); - for (const activity of activities) { - const payload = - typeof activity.payload === "object" && activity.payload !== null - ? (activity.payload as Record) - : null; - const requestId = typeof payload?.requestId === "string" ? payload.requestId : null; - if (requestId === null) continue; - if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") { - openRequestIds.add(requestId); - } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") { - openRequestIds.delete(requestId); - } else if ( - (activity.kind === "provider.approval.respond.failed" || - activity.kind === "provider.user-input.respond.failed") && - isStaleRequestFailureDetail(payload) - ) { - openRequestIds.delete(requestId); - } - } - return openRequestIds.size > 0; -} - -function isStaleRequestFailureDetail(payload: Record | null): boolean { - const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; - if (detail === null) return false; - return ( - detail.includes("stale pending approval request") || - detail.includes("unknown pending approval request") || - detail.includes("unknown pending permission request") || - detail.includes("stale pending user-input request") || - detail.includes("unknown pending user-input request") || - detail.includes("unknown pending user input request") || - detail.includes("unknown pending codex user input request") - ); -} - -/** - * The thread's session is backed by the Claude driver. The driver slug is - * `"claudeAgent"`; `session.providerName` is set from `event.provider` in - * `ProviderRuntimeIngestion`. - */ -export const CLAUDE_DRIVER_KIND = "claudeAgent"; - -/** - * Takes the narrowest shape it reads rather than a whole `OrchestrationThread`, so a caller - * holding only an `OrchestrationThreadShell` can ask too — the shell declares `session` with - * the identical `OrchestrationSession | null` type, so it is structurally assignable and no - * second copy of this predicate has to exist. - * - * Every other guard in this module still takes the full thread, because they read `messages` - * and `activities`, which the shell does not carry. - */ -export function isClaudeThread(thread: Pick): boolean { - const name = thread.session?.providerName; - return typeof name === "string" && name.toLowerCase() === CLAUDE_DRIVER_KIND.toLowerCase(); -} - -/** - * The thread is deleted or archived — destinations a resume could not reach. - * - * `settledOverride === "settled"` used to belong here, back when only an - * explicit `thread.settle` command could write it. Upstream #8600 moved - * settlement server-side: `ThreadSettlementReactor` sweeps every minute and - * dispatches `thread.auto-settle`, which shares `thread.settle`'s decider case - * and emits the same `thread.settled` event with no provenance marker. Auto - * settle is on by default (3 days idle, or as soon as the thread's PR merges), - * so an armed week-long resume was reliably destroyed on day 3 by a timer, - * reported as `coil.auto-resume.cancelled reason: "thread-gone"` on a thread - * nobody had settled. - * - * That makes settledness the same ambiguous fact as the three incidents below: - * it no longer carries the "the human is done here" meaning the guard read into - * it, and there is no discriminator to recover it from. The user's actual - * opt-out is the per-thread auto-resume switch, honoured in `Reactor.fireOne`. - * Settling is also not a one-way door for a resume — sending a turn to a - * settled thread clears the override on upstream's own path. - */ -export function threadIsGone(thread: OrchestrationThread): boolean { - return thread.deletedAt !== null || thread.archivedAt !== null; -} - -/** The thread is actively working (or spinning up) — resuming would double up. */ -export function threadIsProgressing(thread: OrchestrationThread): boolean { - const status = thread.session?.status; - if (status === "running" || status === "starting") return true; - return thread.latestTurn?.state === "running"; -} - -/** - * The armed resume being re-checked: the baseline, plus the window it is waiting on. - * - * `PendingResume` (state.ts) already satisfies this structurally, so `Reactor.fireOne` - * passes its pending record straight through. Nothing new is persisted, so the on-disk - * state schema — and the whole-file-decode hazard documented on `ThreadRecord.enabled` — - * is untouched. - * - * Required, not optional. An optional window would leave a second, weaker meaning of - * "advancement" alive for a caller that does not exist (the reactor is the only - * production caller), and this guard has now been wrong three times precisely because - * one ambiguous fact was allowed to stand in for another. - */ -export interface ResumeArm { - readonly baseline: GuardBaseline; - /** - * When the arm is due, i.e. when the blocked window reopens: `resetsAt` + the safety - * margin (decide.ts), or `now + backoff` when the provider named no reset time. - */ - readonly resumeAtMs: number; -} - -/** Epoch ms for an ISO timestamp, or null when it is absent or unparseable. */ -function isoMs(value: string | null | undefined): number | null { - if (typeof value !== "string") return null; - const ms = Date.parse(value); - return Number.isFinite(ms) ? ms : null; -} - -/** - * Positive evidence that a turn did work the closed window could not have blocked: it - * ended at or after the moment that window reopened. - * - * A turn with no usable `completedAt` returns false — "no proof", not "advancement". - * Cancelling is the destructive move, so absent evidence must never cancel; that is the - * same rule #6 and #39 bought, applied to timing. In production that branch is - * unreachable anyway: a turn that has not completed is `running`, which `threadIsProgressing` - * catches before this is ever called, and 0 of 764 settled turns in the live projection - * have a null `completed_at`. - * - * CLOCK PROVENANCE: `resumeAtMs` comes from `Clock.currentTimeMillis` and the turn - * timestamps from the projection, which the same process wrote — consistent in - * production. NOT consistent under `TestClock`, which starts at epoch 0 while a realistic - * ISO timestamp is ~1.79e12, so a fixture that scripts real-world turn times against a - * test-clock arm reads every turn as post-window and silently restores the incident. - * Script turn times relative to the test clock, or leave them unset. - */ -function turnOutlivedClosedWindow( - turn: { readonly completedAt?: string | null }, - reopensAtMs: number, -): boolean { - const completedAtMs = isoMs(turn.completedAt); - if (completedAtMs === null) return false; - return completedAtMs >= reopensAtMs; -} - -export type CancelReason = - | "thread-gone" - | "not-claude" - | "progressing" - | "awaiting-input" - | "thread-advanced"; - -/** - * Re-checked immediately before dispatch against a fresh snapshot. Returns a reason to - * cancel the pending resume, or null when it is still safe to resume. - */ -export function cancelReason(thread: OrchestrationThread, arm: ResumeArm): CancelReason | null { - if (threadIsGone(thread)) return "thread-gone"; - if (!isClaudeThread(thread)) return "not-claude"; - if (threadIsProgressing(thread)) return "progressing"; - if (hasOpenBlockingRequest(thread.activities)) return "awaiting-input"; - // A new user message does NOT cancel (radroid/t3code#39). This branch used to read - // `newestUserMessageId(thread) !== baseline.newestUserMessageId` and return - // "user-took-over", which is the same negative-evidence mistake #6 fixed one line - // below: "a message exists that wasn't there when we armed" is not evidence that the - // human took the wheel. In practice it is the opposite — the message that trips it is - // typed the moment the usage-limit banner appears, which is exactly when someone is - // stepping away ("keep going through the night"). That message is then usually rejected - // by the same limit, so it starts nothing, and the wake tick destroys the only pending - // resume. Measured on this install: 4 of 17 armed resumes (~24%) lost this way. - // - // Everything the branch was reaching for is still covered: - // * the user is actively driving right now -> `progressing` - // * the thread is blocked on a prompt -> `awaiting-input` - // * a different turn is live at fire time -> `thread-advanced` - // * the user wants no resume at all -> the per-thread switch, honoured - // in `Reactor.fireOne`. - // - // Advancement needs POSITIVE evidence that the thread moved on. Two things look like - // that evidence and are not. Each cost an incident, and all three incidents in this - // feature are the same bug: one ambiguous fact read as "the human took the wheel". - // - // 1. `latestTurn: null` (radroid/t3code#6). Null means the join found no retained turn - // row — "no turn to report" — not "the thread moved on". Cancelling on it killed - // every real resume, so null still short-circuits first and never cancels. - // - // CORRECTION, verified against the live projection on 2026-08-18: the reason this - // comment used to give — that `latest_turn_id` is populated only while a turn is - // active — is FALSE. `ProjectionPipeline` writes `activeTurnId ?? existingRow.latestTurnId` - // ("a terminal session must not erase history") and only recomputes it on - // `thread.reverted`, so a COMPLETED turn stays latest forever. All 63 threads that - // have a `latest_turn_id` point at a turn in state `completed`, including the - // incident thread. #6's rule stands; only its explanation was wrong. - // - // 2. A different, non-null turn id (2026-08-18). While the window is shut every - // provider request is rejected by construction, so a turn that ended before the - // window reopened cannot be the thread moving on — it IS the blockage, observed - // again. The incident's turn 64f7c4b7 was not even the user's: no user message drove - // it (`pending_message_id` NULL), it ran 508ms (19:34:44.276 -> .784) against a - // five_hour window that did not reopen until 19:50, and its only output was the - // assistant line "You've hit your session limit · resets 3:50pm". Comparing ids - // alone read that as a takeover and destroyed the one arm that would have restarted - // the thread — a whole night's work lost. - // - // NEGATIVE RESULTS, so nobody rebuilds a broken signal that still passes the replay - // test: the doomed turn reached `state: "completed"`, DID carry an - // `assistantMessageId`, and DID take a checkpoint (turn_count 2, status ready). Turn - // state, "produced no output", "took no checkpoint", turn counts and message counts - // are all disproven as discriminators. - // - // This also subsumes #39's follow-on shape. The "keep going" typed at the banner is - // rejected by the same limit and settles in half a second; today #39's fix survives - // only because that turn usually does not become `latest_turn_id`. Requiring the turn - // to outlive the window kills both shapes with one predicate, so #39 stops depending - // on luck. - // - // KNOWN APPROXIMATION. The discriminator that actually names the class is - // `projection_turns.pending_message_id IS NULL` ("the user did not start this turn") — - // provider-synthesized turns are not rare here, 180 of 764, and 9 of 63 threads - // currently point `latest_turn_id` at one. It is unreachable from `OrchestrationThread` - // (`OrchestrationLatestTurn` has no `pendingMessageId`, and user messages are stored - // with `turn_id = NULL`, so the read model cannot join one either); exposing it means - // editing `packages/contracts` + `ProjectionSnapshotQuery`, i.e. permanent rebase tax - // for this fork. Timing is the best in-fork proxy, not the right long-term model — - // price the contract change before inventing a fourth proxy. - // - // TWO ACCEPTED COSTS, both chosen rather than overlooked: - // * `resumeAtMs` is `resetsAt + safetyMarginMs`, not the true reopen, and for a - // backoff-derived arm it is `now + ladder`, a guess rather than a known-shut - // interval. So a turn genuinely done in that margin reads as doomed. It errs toward - // firing on an idle thread, which is the cheap direction (#39 already priced a - // redundant nudge against a lost night). Persisting the raw `resetsAtMs` would make - // it exact and is not worth a `PendingResume` schema field. - // * `thread-advanced` now fires only for a post-window turn, so on a `seven_day` arm - // days of genuine user work can pass and we still inject "continue". The remaining - // brakes are `progressing`, `awaiting-input`, the per-thread switch, and the 24h cap. - const currentTurn = thread.latestTurn ?? null; - if ( - currentTurn !== null && - currentTurn.turnId !== arm.baseline.latestTurnId && - turnOutlivedClosedWindow(currentTurn, arm.resumeAtMs) - ) { - return "thread-advanced"; - } - return null; -} diff --git a/coil-parked/apps/server/src/coil/autoResume/http.test.ts b/coil-parked/apps/server/src/coil/autoResume/http.test.ts deleted file mode 100644 index d9902f438abc..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/http.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -/** - * Route-level tests for `/api/coil/auto-resume`. - * - * Serves ONLY the fork's route layer (not the whole `makeRoutesLayer`) over a real HTTP - * server on an ephemeral port, with `EnvironmentAuth` mocked (see `coil/http/testAuth.ts`). - * - * Scope note: because auth is mocked, these tests do NOT prove that the shared - * `coil/http/auth.ts` helper faithfully mirrors upstream's private - * `authenticateRawRouteWithScope` — that remains a logic mirror tracked in SEAMS.md. - * What they DO prove is that a rejected credential and a missing scope each render through - * the route's `catchTags` rather than escaping as a 500 or an unhandled defect, and that the - * GET/POST contract round-trips through a real store. The helper's own bodies are pinned in - * `coil/http/auth.test.ts`. - */ -import { assert, describe, it } from "@effect/vitest"; -import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import type { HttpClient, HttpServer } from "effect/unstable/http"; -import * as NodePath from "node:path"; - -import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts"; -import { authFails, authOk, getJson, postJson, runServed, serveRoutes } from "../http/testAuth.ts"; -import { autoResumeRouteLayer } from "./http.ts"; -import { AutoResumeStore, makeAutoResumeStore } from "./state.ts"; - -const PATH = "/api/coil/auto-resume"; - -const authRejects = authFails(new EnvironmentAuth.ServerAuthInvalidCredentialError({})); - -/** Serves the route over an ephemeral port with the given auth layer and a real store. */ -const withRoute = ( - auth: Layer.Layer, - body: Effect.Effect, -) => - runServed( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "coil-http-" }); - const storeLayer = Layer.effect( - AutoResumeStore, - makeAutoResumeStore(NodePath.join(root, "state.json")), - ); - return yield* serveRoutes({ - routes: autoResumeRouteLayer, - deps: Layer.merge(storeLayer, auth), - body, - }); - }), - ); - -describe("/api/coil/auto-resume", () => { - it("rejects a bad credential with a 401 instead of leaking a 500", () => - withRoute( - authRejects, - Effect.gen(function* () { - const res = yield* getJson(`${PATH}?threadId=thread-a`); - assert.strictEqual(res.status, 401); - }), - )); - - it("rejects a session lacking the operate scope with a 403", () => - withRoute( - authOk([]), - Effect.gen(function* () { - const res = yield* getJson(`${PATH}?threadId=thread-a`); - assert.strictEqual(res.status, 403); - }), - )); - - it("GET defaults a never-seen thread to enabled", () => - withRoute( - authOk([AuthOrchestrationOperateScope]), - Effect.gen(function* () { - const res = yield* getJson(`${PATH}?threadId=fresh`); - assert.strictEqual(res.status, 200); - const body = (yield* res.json) as Record; - assert.strictEqual(body.enabled, true); - assert.strictEqual(body.overridePrompt, null); - assert.strictEqual(body.pending, null); - }), - )); - - it("GET without a threadId is a 400, not a crash", () => - withRoute( - authOk([AuthOrchestrationOperateScope]), - Effect.gen(function* () { - const res = yield* getJson(PATH); - assert.strictEqual(res.status, 400); - }), - )); - - it("POST patches one field without clobbering the other", () => - withRoute( - authOk([AuthOrchestrationOperateScope]), - Effect.gen(function* () { - const off = yield* postJson(PATH, { threadId: "t1", enabled: false }); - assert.strictEqual(off.status, 200); - assert.strictEqual(((yield* off.json) as Record).enabled, false); - - // Writing only overridePrompt must not resurrect `enabled`. - const text = yield* postJson(PATH, { threadId: "t1", overridePrompt: "keep going" }); - const textBody = (yield* text.json) as Record; - assert.strictEqual(textBody.overridePrompt, "keep going"); - assert.strictEqual(textBody.enabled, false, "a prompt write must not clobber enabled"); - - // …and writing only `enabled` must not wipe the prompt. - const on = yield* postJson(PATH, { threadId: "t1", enabled: true }); - const onBody = (yield* on.json) as Record; - assert.strictEqual(onBody.enabled, true); - assert.strictEqual(onBody.overridePrompt, "keep going", "toggle must not clobber prompt"); - }), - )); - - // This route is what makes threadId caller-controlled; before it, ids only ever came - // from provider events. A prototype-chain id used to resolve on Object.prototype, which - // 500'd on read and persisted a malformed record that wiped the whole state file on the - // next boot. Pinned here at the HTTP boundary, not just in the store. - it("handles a prototype-chain threadId as an ordinary unknown thread", () => - withRoute( - authOk([AuthOrchestrationOperateScope]), - Effect.gen(function* () { - for (const hostile of ["constructor", "__proto__", "toString"]) { - const res = yield* getJson(`${PATH}?threadId=${encodeURIComponent(hostile)}`); - assert.strictEqual(res.status, 200, `GET threadId=${hostile} must not 500`); - const body = (yield* res.json) as Record; - assert.strictEqual(body.enabled, true); - assert.strictEqual(body.pending, null); - } - - const wrote = yield* postJson(PATH, { threadId: "__proto__", enabled: false }); - assert.strictEqual(wrote.status, 200, "POST threadId=__proto__ must not 500"); - assert.strictEqual(((yield* wrote.json) as Record).enabled, false); - }), - )); - - it("POST with a malformed body is a 400, not a 500", () => - withRoute( - authOk([AuthOrchestrationOperateScope]), - Effect.gen(function* () { - const res = yield* postJson(PATH, { nope: true }); - assert.strictEqual(res.status, 400); - }), - )); -}); diff --git a/coil-parked/apps/server/src/coil/autoResume/http.ts b/coil-parked/apps/server/src/coil/autoResume/http.ts deleted file mode 100644 index 6ff818f74b6f..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/http.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Fork-owned raw HTTP route backing the per-thread auto-resume control. - * - * GET /api/coil/auto-resume?threadId=… -> { enabled, overridePrompt, pending } - * POST /api/coil/auto-resume -> same shape, after applying the write - * - * Deliberately a *raw* route rather than a WS-RPC method: adding an RPC would force edits - * to `@t3tools/contracts` (`WsRpcGroup`, `WS_METHODS`) plus `apps/server/src/ws.ts` and its - * scope map — several hot upstream files. A raw route costs exactly one additive line in - * `server.ts`'s route list instead. See docs/coil/SEAMS.md. - * - * @module coil/autoResume/http - */ - -import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; - -import { authenticateWithScope, routeAuthErrorTags } from "../http/auth.ts"; -import { AutoResumeStore, type AutoResumeStoreShape } from "./state.ts"; - -export const AUTO_RESUME_ROUTE_PATH = "/api/coil/auto-resume"; - -/** Operate (not read) scope: these endpoints mutate scheduling behaviour. */ -const authenticateWithOperateScope = authenticateWithScope(AuthOrchestrationOperateScope); - -const WriteBody = Schema.Struct({ - threadId: Schema.String, - enabled: Schema.optionalKey(Schema.Boolean), - overridePrompt: Schema.optionalKey(Schema.NullOr(Schema.String)), -}); - -const decodeWriteBody = Schema.decodeUnknownEffect(WriteBody); - -/** - * The wire shape shared by GET and POST responses. - * - * Takes the store as an argument instead of `yield*`-ing it from context. That is what - * keeps `AutoResumeStore` out of the *handler's* requirements — see `autoResumeRouteLayer`. - */ -const readThreadState = (store: AutoResumeStoreShape, threadId: string) => - Effect.gen(function* () { - const record = yield* store.getThread(threadId); - return { - enabled: record.enabled, - overridePrompt: record.overridePrompt, - pending: - record.pending === null - ? null - : { resumeAtMs: record.pending.resumeAtMs, reason: record.pending.reason }, - }; - }); - -const makeGetRoute = (store: AutoResumeStoreShape) => - HttpRouter.add( - "GET", - AUTO_RESUME_ROUTE_PATH, - Effect.gen(function* () { - yield* authenticateWithOperateScope; - const request = yield* HttpServerRequest.HttpServerRequest; - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return HttpServerResponse.text("Bad Request", { status: 400 }); - } - const threadId = url.value.searchParams.get("threadId"); - if (threadId === null || threadId === "") { - return HttpServerResponse.text("Missing threadId", { status: 400 }); - } - return HttpServerResponse.jsonUnsafe(yield* readThreadState(store, threadId)); - }).pipe(Effect.catchTags(routeAuthErrorTags)), - ); - -const makePostRoute = (store: AutoResumeStoreShape) => - HttpRouter.add( - "POST", - AUTO_RESUME_ROUTE_PATH, - Effect.gen(function* () { - yield* authenticateWithOperateScope; - const request = yield* HttpServerRequest.HttpServerRequest; - - const body = yield* decodeWriteBody(yield* request.json).pipe( - Effect.map((decoded): typeof WriteBody.Type | null => decoded), - // A malformed body is a client error, not a 500. - Effect.orElseSucceed(() => null), - ); - if (body === null || body.threadId === "") { - return HttpServerResponse.text("Invalid body", { status: 400 }); - } - - // Both fields are optional so the UI can PATCH one without clobbering the other: - // the toggle must not wipe a resume message the user is mid-way through typing. - if (body.enabled !== undefined) { - yield* store.setEnabled(body.threadId, body.enabled); - } - if (body.overridePrompt !== undefined) { - yield* store.setOverridePrompt(body.threadId, body.overridePrompt); - } - - return HttpServerResponse.jsonUnsafe(yield* readThreadState(store, body.threadId)); - }).pipe(Effect.catchTags(routeAuthErrorTags)), - ); - -/** - * Mounted from `server.ts`'s route list — the fork's only route seam. - * - * `Layer.unwrap` resolves `AutoResumeStore` once, at *layer construction*, and the handlers - * close over the resulting value. This is load-bearing: if the handlers `yield*`-ed the store - * from context instead, that requirement would propagate out through `HttpRouter` into the - * type of upstream's `makeRoutesLayer`, and every existing `server.test.ts` / `bin.test.ts` - * case that builds it would stop type-checking. A fork change must never widen an upstream - * signature. Here the requirement lands on the *layer* instead, where `coil/index.ts` - * discharges it with `Layer.provide(AutoResumeStoreLive)`. - */ -export const autoResumeRouteLayer = Layer.unwrap( - Effect.gen(function* () { - const store = yield* AutoResumeStore; - return Layer.merge(makeGetRoute(store), makePostRoute(store)); - }), -); diff --git a/coil-parked/apps/server/src/coil/autoResume/receipts.ts b/coil-parked/apps/server/src/coil/autoResume/receipts.ts deleted file mode 100644 index cc207b7e64c9..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/receipts.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * The auto-resume supervisor's receipts. - * - * Every milestone this reactor reaches is asynchronous and most end in real filesystem I/O, - * so a test that wants to assert about one has two choices: infer it from a store read after - * spinning the scheduler, or be told. Inference is what this file replaces — `Reactor.test.ts` - * went red in CI (radroid/t3code#134) on `pending must be cleared: expected 1 to equal +0`, - * which was not a defect in the product but a wait that ran out of scheduler turns on a - * loaded runner. AGENTS.md is explicit about the remedy: wait on receipts and worker drains, - * never on sleeps or polling. - * - * So the reactor announces, exactly as the loop supervisor does (`coil/loop/receipts.ts`), - * and the tests await the announcement instead of guessing how many turns it should take. - * - * ## Nothing is paid for in production - * - * The service is **optional**. `receiptEmitter` resolves it with `Effect.serviceOption`, and - * when nobody has provided it — which is every production layer graph, since `coil/index.ts` - * does not mention this module — `emit` is a constant `Effect.void` and `enabled` is false. - * Callers use `enabled` to skip even assembling a receipt. - * - * The buffer is bounded and **dropping**: a subscriber that stops draining loses receipts - * rather than blocking the wake fiber. A supervisor that stalls because a test stopped - * listening would be a worse bug than the flake this replaces. - * - * @module coil/autoResume/receipts - */ - -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as PubSub from "effect/PubSub"; -import * as Scope from "effect/Scope"; - -/** - * One announced milestone. - * - * Every variant names something a test used to infer from the store or from the recorded - * command list. `tick.completed` is the important one: it is published at the **end** of - * every wake pass, including the one where nothing is due, so "advance one poll and let the - * reactor finish" is a single exact await rather than a budget of scheduler turns. - */ -export type AutoResumeReactorReceipt = - /** A whole wake pass is over: every due arm decided, every write durable. */ - | { readonly type: "tick.completed"; readonly dueCount: number; readonly nowMs: number } - /** Detection armed a resume. `superseded` is true when it replaced an existing arm. */ - | { - readonly type: "resume.scheduled"; - readonly threadId: string; - readonly resumeAtMs: number; - readonly superseded: boolean; - } - /** Detection saw a rejection and armed nothing; `reason` is the gate that stopped it. */ - | { readonly type: "resume.skipped"; readonly threadId: string; readonly reason: string } - /** An armed resume was dropped at wake time; `reason` is the guard that dropped it. */ - | { readonly type: "resume.cancelled"; readonly threadId: string; readonly reason: string } - /** The resume turn went out (or was attempted — a failed dispatch still burns the attempt). */ - | { readonly type: "resume.fired"; readonly threadId: string; readonly attempt: number }; - -export interface AutoResumeReactorReceiptsShape { - readonly publish: (receipt: AutoResumeReactorReceipt) => Effect.Effect; - /** - * Everything published for the life of the service, in order. - * - * Subscribed at construction rather than handed out per wait, because the service is built - * before the reactor is: detection can publish while the test body is still being - * assembled, and a subscription opened later would miss it and wait forever. - */ - readonly log: PubSub.Subscription; -} - -export class AutoResumeReactorReceipts extends Context.Service< - AutoResumeReactorReceipts, - AutoResumeReactorReceiptsShape ->()("t3/coil/autoResume/receipts/AutoResumeReactorReceipts") {} - -/** - * Room for every receipt a scenario can produce without draining. - * - * Scenarios run at most a few hundred simulated polls and drain the tick receipt on each one, - * so this is slack rather than a working limit — but it is a limit, because the alternative - * is a queue that grows with a stuck subscriber. - */ -const RECEIPT_BUFFER = 4096; - -export const makeAutoResumeReactorReceipts: Effect.Effect< - AutoResumeReactorReceiptsShape, - never, - Scope.Scope -> = Effect.gen(function* () { - const pubsub = yield* PubSub.dropping(RECEIPT_BUFFER); - const log = yield* PubSub.subscribe(pubsub); - return { - publish: (receipt) => PubSub.publish(pubsub, receipt).pipe(Effect.asVoid), - log, - }; -}); - -export const AutoResumeReactorReceiptsLive = Layer.effect( - AutoResumeReactorReceipts, - makeAutoResumeReactorReceipts, -); - -const noEmit = (_receipt: AutoResumeReactorReceipt): Effect.Effect => Effect.void; - -export interface AutoResumeReceiptEmitter { - /** Whether anyone is listening. Lets a caller skip assembling a receipt, not just sending. */ - readonly enabled: boolean; - readonly emit: (receipt: AutoResumeReactorReceipt) => Effect.Effect; -} - -/** Resolve the optional service into something the reactor can call unconditionally. */ -export const receiptEmitter: Effect.Effect = Effect.gen(function* () { - const service = yield* Effect.serviceOption(AutoResumeReactorReceipts); - if (Option.isNone(service)) return { enabled: false, emit: noEmit }; - return { enabled: true, emit: service.value.publish }; -}); diff --git a/coil-parked/apps/server/src/coil/autoResume/replay/adapterReplay.test.ts b/coil-parked/apps/server/src/coil/autoResume/replay/adapterReplay.test.ts deleted file mode 100644 index 7d8b2e157a32..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/replay/adapterReplay.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; - -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; - -import { classifyRateLimit } from "../classifyRateLimit.ts"; -import { eventsOfType, missingRecordedEventTypes, replayThroughAdapter } from "./adapterReplay.ts"; -import type { Episode } from "./episode.ts"; - -function loadFixture(name: string): Episode { - const file = NodePath.join(import.meta.dirname, "fixtures", `${name}.json`); - return JSON.parse(NodeFS.readFileSync(file, "utf8")) as Episode; -} - -describe("replayThroughAdapter — captured macOS hard block", () => { - it.live("forwards the rejected rate-limit info through the real adapter intact", () => - Effect.gen(function* () { - const episode = loadFixture("macos-hard-block-a"); - const result = yield* replayThroughAdapter(episode); - - // Upstream #9507 turned `account.rate-limits.updated` into a normalised window list - // with no `status`; the raw `SDKRateLimitInfo` of a rejected window now rides on the - // `runtime.warning` the adapter raises for it. The adapter must not reshape, filter - // or summarise that detail: auto-resume's whole detection path reads these fields, - // and #118 turned on whether an overage-carrying payload survived the trip. - const rateLimitWarnings = eventsOfType(result, "runtime.warning").filter( - (event) => - classifyRateLimit((event as { payload: { detail?: unknown } }).payload.detail) !== - undefined, - ); - expect(rateLimitWarnings.length).toBeGreaterThan(0); - - const payload = (rateLimitWarnings[0] as { payload: { detail: unknown } }).payload; - expect(payload.detail).toMatchObject({ - status: "rejected", - overageStatus: "rejected", - overageDisabledReason: "org_level_disabled", - rateLimitType: "five_hour", - isUsingOverage: false, - }); - }), - ); - - it.live("produces a payload the real classifier reads as a rejection", () => - Effect.gen(function* () { - // The end-to-end claim hypothesis 2 disputes: captured bytes -> real adapter -> - // real classifier -> `rejected`. Asserting it here means a future SDK field rename - // (or upstream moving the info off `runtime.warning`) fails this test instead of - // silently disarming auto-resume in production. - const episode = loadFixture("macos-hard-block-a"); - const result = yield* replayThroughAdapter(episode); - const verdicts = eventsOfType(result, "runtime.warning") - .map((event) => - classifyRateLimit((event as { payload: { detail?: unknown } }).payload.detail), - ) - .filter((verdict) => verdict !== undefined); - - expect(verdicts.length).toBeGreaterThan(0); - expect(verdicts[0]?.rejected).toBe(true); - expect(verdicts[0]?.rateLimitType).toBe("five_hour"); - expect(verdicts[0]?.resetsAtMs).toBeGreaterThan(0); - }), - ); - - it.live("reproduces every message-derived canonical event the capture recorded", () => - Effect.gen(function* () { - // Differential check: the capture holds both halves of the adapter boundary, so the - // adapter is compared against its own past behaviour. Compared as a type set — ids - // and timestamps are regenerated per run, and transient types were never persisted - // (see providerLog.ts). - const episode = loadFixture("macos-hard-block-a"); - const result = yield* replayThroughAdapter(episode); - - expect(missingRecordedEventTypes(episode, result)).toEqual([]); - }), - ); - - it.live("cannot reproduce callback-derived events, and says so", () => - Effect.gen(function* () { - // Guards the exclusion above against becoming a silent catch-all. This capture - // really does contain a user-input interaction, and replaying its messages really - // cannot produce one — so the harness's blind spot is asserted, not assumed. If a - // future adapter emits these from a message instead, this test fails and the - // exclusion list should shrink. - const episode = loadFixture("macos-hard-block-a"); - const recorded = new Set(episode.canonical.map((entry) => entry.type)); - expect(recorded.has("user-input.resolved")).toBe(true); - - const result = yield* replayThroughAdapter(episode); - const produced = new Set(result.events.map((event) => event.type)); - expect(produced.has("user-input.resolved")).toBe(false); - }), - ); -}); diff --git a/coil-parked/apps/server/src/coil/autoResume/replay/adapterReplay.ts b/coil-parked/apps/server/src/coil/autoResume/replay/adapterReplay.ts deleted file mode 100644 index 56fce6ec1b1c..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/replay/adapterReplay.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Stage 1 of replay: an episode's native SDK messages → the **real** `ClaudeAdapter`. - * - * Answers one question precisely: given exactly what the provider sent, what canonical - * runtime events does the adapter produce today? That is the half of the auto-resume chain - * no existing test covers — `Reactor.test.ts` hands the reactor hand-written - * `ProviderRuntimeEvent`s, so a defect in how the adapter reads a payload (a new overage - * field, a renamed status) is invisible to it. Feeding captured bytes through the adapter - * closes that gap. - * - * Replay is deliberately split in two clock domains, because they cannot share one: - * - * - **Stage 1 (here, real clock).** Draining an `AsyncIterable` requires the JS event - * loop to turn. Under Effect's `TestClock` nothing advances unless the test advances - * it, so an adapter waiting on a promise and a test waiting on the adapter deadlock. - * Stage 1 involves no waiting — it just pushes messages — so a real clock is correct - * and fast. - * - **Stage 2 (`reactorReplay.ts`, TestClock).** The reactor's job is *waiting* — hours, - * for a window to reopen. It consumes the events stage 1 produced, so the seam between - * them is honest: stage 2 is driven by what the adapter really emitted, not by - * hand-written events. - * - * @module coil/autoResume/replay/adapterReplay - */ - -import type { - Options as ClaudeQueryOptions, - PermissionMode, - SDKControlGetContextUsageResponse, - SDKMessage, - SDKUserMessage, -} from "@anthropic-ai/claude-agent-sdk"; -import { - ClaudeSettings, - ProviderDriverKind, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; -import type { ProviderRuntimeEvent } from "@t3tools/contracts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; - -import { ServerConfig } from "../../../config.ts"; -import { ServerSettingsService } from "../../../serverSettings.ts"; -import { makeClaudeAdapter } from "../../../provider/Layers/ClaudeAdapter.ts"; -import type { Episode } from "./episode.ts"; - -const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); - -/** - * A push-driven `ClaudeQueryRuntime` double. - * - * Mirrors `FakeClaudeQuery` in ClaudeAdapter.test.ts (:48-155) — kept as its own copy - * rather than exported from that file, because a test file exporting helpers into - * production-adjacent modules is a merge-conflict magnet on a fork that rebases onto - * upstream weekly, and this copy is ~40 lines. - */ -class ReplayQuery implements AsyncIterable { - private readonly queue: Array = []; - private readonly waiters: Array<(result: IteratorResult) => void> = []; - private done = false; - - public closeCalls = 0; - - emit(message: SDKMessage): void { - if (this.done) return; - const waiter = this.waiters.shift(); - if (waiter) { - waiter({ done: false, value: message }); - return; - } - this.queue.push(message); - } - - finish(): void { - if (this.done) return; - this.done = true; - for (const waiter of this.waiters.splice(0)) waiter({ done: true, value: undefined }); - } - - readonly interrupt = async (): Promise => {}; - readonly stopTask = async (): Promise => {}; - readonly setModel = async (): Promise => {}; - readonly setPermissionMode = async (_mode: PermissionMode): Promise => {}; - readonly setMaxThinkingTokens = async (): Promise => {}; - readonly getContextUsage = async (): Promise => - ({}) as SDKControlGetContextUsageResponse; - readonly close = (): void => { - this.closeCalls += 1; - }; - - [Symbol.asyncIterator](): AsyncIterator { - return { - next: () => { - const queued = this.queue.shift(); - if (queued !== undefined) return Promise.resolve({ done: false, value: queued }); - if (this.done) return Promise.resolve({ done: true, value: undefined }); - return new Promise((resolve) => this.waiters.push(resolve)); - }, - }; - } -} - -export interface AdapterReplayOptions { - /** - * How long to let the adapter drain after the last message, in ms. - * - * The adapter consumes the iterable on its own fiber, so "everything has been emitted" - * is not "everything has been processed". Rather than sleep blindly, the driver polls - * for quiescence and only uses this as a ceiling. - */ - readonly settleMs?: number; - /** Poll interval while waiting for the event count to stop growing. */ - readonly pollMs?: number; -} - -export interface AdapterReplayResult { - /** Canonical runtime events the real adapter produced from the episode's natives. */ - readonly events: ReadonlyArray; - /** Native messages fed in. */ - readonly emitted: number; -} - -const REPLAY_THREAD_ID = ThreadId.make("11111111-1111-4111-8111-111111111111"); - -/** - * Feed an episode's native messages through a real `ClaudeAdapter` and collect what it - * emits. Runs on the real clock; the episode's recorded offsets are NOT slept through — - * ordering is what matters here, and stage 2 owns time. - */ -export const replayThroughAdapter = ( - episode: Episode, - options?: AdapterReplayOptions, -): Effect.Effect => - Effect.gen(function* () { - const settleMs = options?.settleMs ?? 2_000; - const pollMs = options?.pollMs ?? 25; - - const query = new ReplayQuery(); - const adapter = yield* makeClaudeAdapter(decodeClaudeSettings({}), { - createQuery: (_input: { - readonly prompt: AsyncIterable; - readonly options: ClaudeQueryOptions; - }) => query, - }); - - const collected = yield* Ref.make>([]); - const collector = yield* Stream.runForEach(adapter.streamEvents, (event) => - Ref.update(collected, (events) => [...events, event]), - ).pipe(Effect.forkChild); - - const session = yield* adapter.startSession({ - threadId: REPLAY_THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-5", - }, - runtimeMode: "full-access", - }); - - // A turn must be open for the adapter to attribute item/turn events; the episode's own - // messages then drive everything that follows. - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "replay", - attachments: [], - }); - - for (const step of episode.native) { - query.emit(step.message as SDKMessage); - } - query.finish(); - - // Quiesce: wait until the collected count stops growing, capped at settleMs. - let stable = 0; - let previous = -1; - const deadline = Math.ceil(settleMs / pollMs); - for (let tick = 0; tick < deadline && stable < 3; tick++) { - yield* Effect.sleep(Duration.millis(pollMs)); - const current = (yield* Ref.get(collected)).length; - stable = current === previous ? stable + 1 : 0; - previous = current; - } - - yield* Fiber.interrupt(collector); - const events = yield* Ref.get(collected); - return { events, emitted: episode.native.length }; - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll( - ServerConfig.layerTest("/tmp/coil-autoresume-replay", "/tmp"), - ServerSettingsService.layerTest(), - ).pipe(Layer.provideMerge(NodeServices.layer)), - ), - Effect.orDie, - ); - -/** Canonical events of a given type, for assertions. */ -export function eventsOfType( - result: AdapterReplayResult, - type: string, -): ReadonlyArray { - return result.events.filter((event) => event.type === type); -} - -/** - * Event types a native-message replay can never reproduce, by construction. - * - * The adapter has **two** input channels, and a capture only records one. Besides the - * SDK message stream, the SDK calls back into the adapter for tool permission - * (`canUseTool`), and that path emits its own runtime events while blocking on a - * `Deferred` the host resolves (ClaudeAdapter.ts:3892-3912). No message in the stream - * triggers them, so replaying messages alone cannot produce them. - * - * This matters directly for auto-resume: the `awaiting-input` guard reads exactly these - * interactions (`guards.ts:52-76`). A replay must therefore *script* a blocked thread - * rather than expect the fixture to generate one — expecting otherwise would quietly - * test nothing. - */ -export const CALLBACK_DERIVED_EVENT_TYPES: ReadonlySet = new Set([ - "user-input.requested", - "user-input.resolved", -]); - -/** - * Canonical event types the adapter deliberately stopped deriving from the recorded messages, - * each with the upstream change that retired it. Listed here so the drift check below stays a - * drift check: a type in this set is a known, reviewed change, not silent decay. - * - * - `account.rate-limits.updated`: upstream #9507 emits it only when the SDK reports a - * `utilization`, which the capture's rejected window did not carry. The rejection now - * reaches the reactor as the `runtime.warning` asserted in `adapterReplay.test.ts`. - */ -export const RETIRED_RECORDED_EVENT_TYPES: ReadonlySet = new Set([ - "account.rate-limits.updated", -]); - -/** - * Canonical event types the capture recorded that this replay did not reproduce, - * excluding the ones no replay could and the ones upstream knowingly retired. - * - * A non-empty result means the adapter's behaviour has drifted since the capture: it used - * to turn these messages into an event and no longer does. - */ -export function missingRecordedEventTypes( - episode: Episode, - result: AdapterReplayResult, -): ReadonlyArray { - const produced = new Set(result.events.map((event) => event.type)); - return [...new Set(episode.canonical.map((entry) => entry.type))] - .filter((type) => !CALLBACK_DERIVED_EVENT_TYPES.has(type)) - .filter((type) => !RETIRED_RECORDED_EVENT_TYPES.has(type)) - .filter((type) => !produced.has(type)); -} diff --git a/coil-parked/apps/server/src/coil/autoResume/replay/capturedEpisode.test.ts b/coil-parked/apps/server/src/coil/autoResume/replay/capturedEpisode.test.ts deleted file mode 100644 index 05a9857a9c4b..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/replay/capturedEpisode.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; - -import type { ProviderRuntimeEvent } from "@t3tools/contracts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as TestClock from "effect/testing/TestClock"; - -import { AutoResumeReactorLive } from "../Reactor.ts"; -import type { Episode } from "./episode.ts"; -import { - appendedActivityKinds, - dispatchedTypes, - makeReplayHarness, - readModel, - rebaseRateLimitEvents, - REPLAY_THREAD_ID, - settleQuiet, - settleUntil, - threadRow, -} from "./reactorHarness.ts"; - -function loadFixture(name: string): Episode { - const file = NodePath.join(import.meta.dirname, "fixtures", `${name}.json`); - return JSON.parse(NodeFS.readFileSync(file, "utf8")) as Episode; -} - -const episode = loadFixture("macos-hard-block-a"); - -/** - * The reactor's input, taken from the episode's RECORDED canonical events. - * - * A capture stores both halves of the adapter boundary, so these are real events the real - * adapter really emitted during a real usage-limit episode — not hand-written stand-ins. - * That the adapter still reproduces them from the native messages is asserted separately, - * in `adapterReplay.test.ts`; splitting it that way keeps each file in one clock domain. - * Re-running the adapter here would need the live clock to drain an AsyncIterable while - * these scenarios need TestClock to skip four hours, and the two cannot coexist in one test. - */ -const rateLimitEvents: ReadonlyArray = rebaseRateLimitEvents( - episode.canonical - // The capture predates upstream #9507; `rebaseRateLimitEvents` converts its recorded - // `account.rate-limits.updated` rows into the `runtime.warning` the reactor taps now. - .filter((entry) => entry.type === "account.rate-limits.updated") - .map((entry) => entry.event as ProviderRuntimeEvent), - Date.parse(episode.provenance.firstObservedAt), - // Retarget at the harness's thread id; the capture's own is a real uuid. -).map((event) => ({ ...event, threadId: REPLAY_THREAD_ID }) as unknown as ProviderRuntimeEvent); - -/** - * Advance in poll-sized steps until `condition` holds. - * - * A single large `TestClock.adjust` does not reliably drive the wake fiber's - * `delay + forever` loop, so time moves one poll interval at a time. The captured window is - * ~4 hours, which at a 30s poll is several hundred steps — each costs only scheduler turns. - */ -const advanceUntil = (condition: Effect.Effect, description: string, maxSteps = 700) => - Effect.gen(function* () { - for (let step = 0; step < maxSteps; step++) { - if (yield* condition) return; - yield* TestClock.adjust(Duration.millis(30_000)); - yield* settleQuiet; - } - if (yield* condition) return; - return yield* Effect.die(new Error(`timed out waiting for ${description}`)); - }); - -const pendingCountIs = ( - store: { readonly listPending: Effect.Effect> }, - expected: number, -) => store.listPending.pipe(Effect.map((pending) => pending.length === expected)); - -describe("captured macOS hard block → real reactor", () => { - it.effect("arms a resume from a real captured rejection", () => - Effect.gen(function* () { - expect(rateLimitEvents.length).toBeGreaterThan(0); - - const harness = yield* makeReplayHarness({ - events: rateLimitEvents, - initialModel: readModel([threadRow()]), - }); - - yield* Effect.gen(function* () { - yield* settleUntil( - pendingCountIs(harness.store, 1), - "a resume to be armed from the captured rejection", - ); - - const pending = yield* harness.store.listPending; - expect(pending[0]?.threadId).toBe(REPLAY_THREAD_ID); - // The arm must name the limit the capture carries — that string reaches the user - // in the timeline note. - expect(pending[0]?.reason).toBe("five_hour"); - - const commands = yield* Ref.get(harness.dispatched); - expect(appendedActivityKinds(commands)).toContain("coil.auto-resume.scheduled"); - expect(dispatchedTypes(commands)).not.toContain("thread.turn.start"); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(harness.deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - it.effect("fires the armed resume once the captured window reopens", () => - Effect.gen(function* () { - const harness = yield* makeReplayHarness({ - events: rateLimitEvents, - initialModel: readModel([threadRow()]), - }); - - yield* Effect.gen(function* () { - yield* settleUntil(pendingCountIs(harness.store, 1), "the resume to arm"); - - // The armed time is derived from the captured payload, not from a constant: reset - // plus the configured 60s safety margin. Asserting it here is what preserves the - // capture's fidelity even though the clock below is rebased. - const pending = yield* harness.store.listPending; - const resumeAtMs = pending[0]!.resumeAtMs; - expect(resumeAtMs).toBeGreaterThan(60_000); - - yield* settleQuiet; - expect(dispatchedTypes(yield* Ref.get(harness.dispatched))).not.toContain( - "thread.turn.start", - ); - - yield* advanceUntil( - Ref.get(harness.dispatched).pipe( - Effect.map((commands) => dispatchedTypes(commands).includes("thread.turn.start")), - ), - "the resume to fire after the captured window reopened", - ); - - const commands = yield* Ref.get(harness.dispatched); - expect(appendedActivityKinds(commands)).toContain("coil.auto-resume.resumed"); - expect(yield* harness.store.listPending).toHaveLength(0); - expect(yield* harness.store.countFiredSince(REPLAY_THREAD_ID, 0)).toBe(1); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(harness.deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); -}); diff --git a/coil-parked/apps/server/src/coil/autoResume/replay/episode.ts b/coil-parked/apps/server/src/coil/autoResume/replay/episode.ts deleted file mode 100644 index 3c0abd4bf148..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/replay/episode.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * The replay fixture format. - * - * An *episode* is a redacted slice of one thread's provider log covering a usage-limit - * event: the native SDK messages the adapter consumed, and the canonical runtime events it - * produced at capture time. Replaying the first through the real adapter and diffing - * against the second turns a one-off incident into a permanent regression test. - * - * Why both halves are stored: auto-resume has now failed three times (#6, #39, #118) with - * the same symptom — "it silently didn't fire" — and no way to tell *which* layer dropped - * the signal. With both halves captured, a replay localises the failure by itself: if the - * adapter reproduces the recorded canonical events, the adapter is exonerated and the - * defect is in the reactor; if it does not, the defect is in the adapter or the SDK. - * - * Fixtures are committed, so everything here has been through `redact.ts` first. - * - * @module coil/autoResume/replay/episode - */ - -export const EPISODE_FORMAT_VERSION = 1; - -/** A native SDK message to feed back into a `FakeClaudeQuery`. */ -export interface EpisodeNativeStep { - /** Milliseconds after the episode's first entry — drives the TestClock during replay. */ - readonly offsetMs: number; - readonly observedAt: string; - /** 1-based line in the source log, so a failure can be traced back to the capture. */ - readonly sourceLine: number; - readonly message: unknown; -} - -/** A canonical runtime event the adapter produced at capture time. */ -export interface EpisodeCanonicalEvent { - readonly offsetMs: number; - readonly observedAt: string; - readonly sourceLine: number; - readonly type: string; - readonly event: unknown; -} - -export interface EpisodeRedaction { - readonly policy: string; - readonly redactedStrings: number; - readonly keptVendorStrings: number; -} - -export interface EpisodeProvenance { - /** Log file basename only — never a full path, which would carry a username. */ - readonly sourceFile: string; - readonly firstObservedAt: string; - readonly lastObservedAt: string; - readonly firstSourceLine: number; - readonly lastSourceLine: number; - /** Lines the parser could not read, if any — an honest record of capture fidelity. */ - readonly malformedLines: number; -} - -export interface Episode { - readonly formatVersion: typeof EPISODE_FORMAT_VERSION; - readonly id: string; - readonly title: string; - /** - * `capture` — lifted from a real provider log, faithful to what the provider sent. - * `reconstruction` — hand-built from an incident report. Kept explicit so nobody - * mistakes an inferred payload for observed ground truth. - */ - readonly origin: "capture" | "reconstruction"; - readonly notes: string; - readonly threadId: string | null; - readonly provenance: EpisodeProvenance; - readonly redaction: EpisodeRedaction; - readonly native: ReadonlyArray; - readonly canonical: ReadonlyArray; -} - -/** Total wall-clock the episode spans, in ms. */ -export function episodeDurationMs(episode: Episode): number { - const last = episode.native[episode.native.length - 1]; - return last?.offsetMs ?? 0; -} - -/** Native steps whose message is of the given SDK type. */ -export function nativeStepsOfType( - episode: Episode, - type: string, -): ReadonlyArray { - return episode.native.filter( - (step) => - typeof step.message === "object" && - step.message !== null && - (step.message as { type?: unknown }).type === type, - ); -} - -/** Canonical events of the given runtime-event type. */ -export function canonicalEventsOfType( - episode: Episode, - type: string, -): ReadonlyArray { - return episode.canonical.filter((entry) => entry.type === type); -} diff --git a/coil-parked/apps/server/src/coil/autoResume/replay/fixtures/.gitignore b/coil-parked/apps/server/src/coil/autoResume/replay/fixtures/.gitignore deleted file mode 100644 index f7ac639ce0c0..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/replay/fixtures/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -# Captured episodes are committed ONLY when a test replays them. -# -# A capture is a redacted slice of a real provider log, and a busy session runs to hundreds -# of KiB — macos-hard-block-b.json alone was 14,172 lines, 69% of the pull request that -# introduced this harness, and no test referenced it. An unused fixture is not free: it is -# reviewed by nobody, diffed by everybody, and grows the clone for everyone forever. -# -# Keep local captures here (the recorder writes them here by default) and commit one only -# when it is load-bearing. `record.ts` can regenerate a fixture from a provider log at any -# time — but note the source logs rotate, so a capture worth keeping should be kept locally -# until the test that uses it exists. -macos-hard-block-b.json diff --git a/coil-parked/apps/server/src/coil/autoResume/replay/fixtures/macos-hard-block-a.json b/coil-parked/apps/server/src/coil/autoResume/replay/fixtures/macos-hard-block-a.json deleted file mode 100644 index 88d23bd56296..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/replay/fixtures/macos-hard-block-a.json +++ /dev/null @@ -1,3521 +0,0 @@ -{ - "formatVersion": 1, - "id": "macos-hard-block-a", - "title": "macOS hard block: five_hour rejected, overage disabled (org_level_disabled)", - "origin": "capture", - "notes": "Captured on macOS 2026-08-17. Same rate_limit_info class as radroid/t3code#118's 06:34:49 terminal event: status rejected AND overageStatus rejected, isUsingOverage false. On this platform auto-resume recovered, so this is the CONTRAST fixture for the Windows failure.", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "provenance": { - "sourceFile": "events.43059392-62af-40b4-b341-aeaa35b257d9.log", - "firstObservedAt": "2026-08-17T14:06:58.214Z", - "lastObservedAt": "2026-08-17T14:11:06.171Z", - "firstSourceLine": 1272, - "lastSourceLine": 1401, - "malformedLines": 0 - }, - "redaction": { - "policy": "allowlist-v1", - "redactedStrings": 294, - "keptVendorStrings": 25 - }, - "native": [ - { - "offsetMs": 44, - "observedAt": "2026-08-17T14:06:58.258Z", - "sourceLine": 1273, - "message": { - "type": "user", - "message": { - "role": "user", - "content": [ - { - "type": "tool_result", - "content": "«redacted»", - "tool_use_id": "toolu_015WkJMkqMnb96fdBG7DRzQX" - } - ] - }, - "parent_tool_use_id": null, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "8e9690e3-b6ae-4c73-aebf-d4498d4a392e", - "timestamp": "2026-08-17T14:06:58.251Z", - "tool_use_result": { - "questions": [ - { - "question": "«redacted»", - "header": "«redacted»", - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ], - "multiSelect": false - }, - { - "question": "«redacted»", - "header": "«redacted»", - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ], - "multiSelect": false - }, - { - "question": "«redacted»", - "header": "«redacted»", - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ], - "multiSelect": false - }, - { - "question": "«redacted»", - "header": "«redacted»", - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ], - "multiSelect": false - } - ], - "answers": { - "Where should the new capture/replay harness live?": "«redacted»", - "How deep should the replay go?": "«redacted»", - "Should I build a recorder that turns future real limit episodes into fixtures automatically?": "«redacted»", - "Can you get the actual incident log off the Windows machine? (events.57e2846c-9cb1-40d4-8776-e5f7db3fe7b9.log, plus t3x-auto-resume.json)": "«redacted»" - } - } - } - }, - { - "offsetMs": 52, - "observedAt": "2026-08-17T14:06:58.266Z", - "sourceLine": 1275, - "message": { - "type": "system", - "subtype": "status", - "status": "requesting", - "uuid": "3725d6ed-f0e9-45b1-9718-dc200ce73893", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 1823, - "observedAt": "2026-08-17T14:07:00.037Z", - "sourceLine": 1277, - "message": { - "type": "stream_event", - "event": { - "type": "message_start", - "message": { - "model": "claude-opus-5", - "id": "msg_011Ce8T4z9DhKRg9F2DLDnbt", - "type": "message", - "role": "assistant", - "content": [], - "stop_reason": null, - "stop_sequence": null, - "stop_details": null, - "usage": { - "input_tokens": 2, - "cache_creation_input_tokens": 4299, - "cache_read_input_tokens": 114840, - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 4299 - }, - "output_tokens": 3, - "service_tier": "standard", - "inference_geo": "not_available" - }, - "diagnostics": null - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "0c206de1-916b-4bec-994c-fc1ec7a3b84d", - "ttft_ms": 1754 - } - }, - { - "offsetMs": 2139, - "observedAt": "2026-08-17T14:07:00.353Z", - "sourceLine": 1278, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 0, - "content_block": { - "type": "thinking", - "thinking": "", - "signature": "" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "19f40e4d-dc6b-49ab-ad22-8de58f5b50ae" - } - }, - { - "offsetMs": 2253, - "observedAt": "2026-08-17T14:07:00.467Z", - "sourceLine": 1279, - "message": { - "type": "system", - "subtype": "thinking_tokens", - "estimated_tokens": 50, - "estimated_tokens_delta": 50, - "uuid": "336293b2-f5ce-4d7f-aa3f-233f7a7ea064", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 2254, - "observedAt": "2026-08-17T14:07:00.468Z", - "sourceLine": 1280, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "thinking_delta", - "thinking": "", - "estimated_tokens": 50 - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "12554526-f01c-43ea-ab94-21cd67cc67e4" - } - }, - { - "offsetMs": 9235, - "observedAt": "2026-08-17T14:07:07.449Z", - "sourceLine": 1281, - "message": { - "type": "system", - "subtype": "thinking_tokens", - "estimated_tokens": 150, - "estimated_tokens_delta": 100, - "uuid": "a7784f53-6b49-4baa-8e6c-1916e26c191d", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 9235, - "observedAt": "2026-08-17T14:07:07.449Z", - "sourceLine": 1282, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "thinking_delta", - "thinking": "", - "estimated_tokens": 100 - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "a1b67291-dffe-4679-a80d-7cfae34ff562" - } - }, - { - "offsetMs": 9235, - "observedAt": "2026-08-17T14:07:07.449Z", - "sourceLine": 1283, - "message": { - "type": "system", - "subtype": "thinking_tokens", - "estimated_tokens": 250, - "estimated_tokens_delta": 100, - "uuid": "519596a6-c878-473d-aff3-b38fa3e29374", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 9235, - "observedAt": "2026-08-17T14:07:07.449Z", - "sourceLine": 1284, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "thinking_delta", - "thinking": "", - "estimated_tokens": 100 - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "cca0ed20-69aa-4321-b331-f381277f7558" - } - }, - { - "offsetMs": 9236, - "observedAt": "2026-08-17T14:07:07.450Z", - "sourceLine": 1285, - "message": { - "type": "system", - "subtype": "thinking_tokens", - "estimated_tokens": 350, - "estimated_tokens_delta": 100, - "uuid": "22db94c7-2730-45a7-abcf-65492ecd1827", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 9236, - "observedAt": "2026-08-17T14:07:07.450Z", - "sourceLine": 1286, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "thinking_delta", - "thinking": "", - "estimated_tokens": 100 - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "252d99ef-72a8-4eab-9594-21a99ad05ad0" - } - }, - { - "offsetMs": 9236, - "observedAt": "2026-08-17T14:07:07.450Z", - "sourceLine": 1287, - "message": { - "type": "system", - "subtype": "thinking_tokens", - "estimated_tokens": 450, - "estimated_tokens_delta": 100, - "uuid": "33e1ee20-6d21-4c51-9a21-5dc5b758663a", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 9236, - "observedAt": "2026-08-17T14:07:07.450Z", - "sourceLine": 1288, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "thinking_delta", - "thinking": "", - "estimated_tokens": 100 - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "c8e72217-ab17-48af-8fea-467357657003" - } - }, - { - "offsetMs": 10601, - "observedAt": "2026-08-17T14:07:08.815Z", - "sourceLine": 1289, - "message": { - "type": "system", - "subtype": "thinking_tokens", - "estimated_tokens": 550, - "estimated_tokens_delta": 100, - "uuid": "46749115-4093-49cf-be19-6238a49d13d1", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 10601, - "observedAt": "2026-08-17T14:07:08.815Z", - "sourceLine": 1290, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "thinking_delta", - "thinking": "", - "estimated_tokens": 100 - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "cf84654e-8109-40c6-addc-42369165b638" - } - }, - { - "offsetMs": 12004, - "observedAt": "2026-08-17T14:07:10.218Z", - "sourceLine": 1291, - "message": { - "type": "system", - "subtype": "thinking_tokens", - "estimated_tokens": 650, - "estimated_tokens_delta": 100, - "uuid": "eec53c20-2ea6-4d3e-82f3-589220c28809", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 12004, - "observedAt": "2026-08-17T14:07:10.218Z", - "sourceLine": 1292, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "thinking_delta", - "thinking": "", - "estimated_tokens": 100 - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "9a56c0d2-2748-4e05-a86a-723fb2fa9973" - } - }, - { - "offsetMs": 13352, - "observedAt": "2026-08-17T14:07:11.566Z", - "sourceLine": 1293, - "message": { - "type": "system", - "subtype": "thinking_tokens", - "estimated_tokens": 750, - "estimated_tokens_delta": 100, - "uuid": "0341253d-f6af-4c1d-a393-81291d8b366b", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 13352, - "observedAt": "2026-08-17T14:07:11.566Z", - "sourceLine": 1294, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "thinking_delta", - "thinking": "", - "estimated_tokens": 100 - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "7b705e4b-4d1c-4a05-a3f2-b1deb34731f9" - } - }, - { - "offsetMs": 14766, - "observedAt": "2026-08-17T14:07:12.980Z", - "sourceLine": 1295, - "message": { - "type": "system", - "subtype": "thinking_tokens", - "estimated_tokens": 850, - "estimated_tokens_delta": 100, - "uuid": "e0a1ffe1-c9d0-4b80-9264-d7a0daf8dc82", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 14766, - "observedAt": "2026-08-17T14:07:12.980Z", - "sourceLine": 1296, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "thinking_delta", - "thinking": "", - "estimated_tokens": 100 - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "802c7647-fa16-4581-80f3-c848a10b6b1b" - } - }, - { - "offsetMs": 15476, - "observedAt": "2026-08-17T14:07:13.690Z", - "sourceLine": 1297, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "thinking_delta", - "thinking": "", - "estimated_tokens": null - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "509f66f0-6806-4d62-921b-4dc184a131c2" - } - }, - { - "offsetMs": 15476, - "observedAt": "2026-08-17T14:07:13.690Z", - "sourceLine": 1298, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 0, - "delta": { - "type": "signature_delta", - "signature": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "7ead4160-8a47-44aa-ab63-74720e6c6788" - } - }, - { - "offsetMs": 15476, - "observedAt": "2026-08-17T14:07:13.690Z", - "sourceLine": 1299, - "message": { - "type": "assistant", - "message": { - "model": "claude-opus-5", - "id": "msg_011Ce8T4z9DhKRg9F2DLDnbt", - "type": "message", - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "", - "signature": "«redacted»" - } - ], - "stop_reason": null, - "stop_sequence": null, - "stop_details": null, - "usage": { - "input_tokens": 2, - "cache_creation_input_tokens": 4299, - "cache_read_input_tokens": 114840, - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 4299 - }, - "output_tokens": 3, - "service_tier": "standard", - "inference_geo": "not_available" - }, - "diagnostics": null, - "context_management": null - }, - "parent_tool_use_id": null, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "0c319527-f80a-4fb6-8d04-a964defd7ac5", - "timestamp": "2026-08-17T14:07:13.690Z", - "request_id": "«redacted»" - } - }, - { - "offsetMs": 15477, - "observedAt": "2026-08-17T14:07:13.691Z", - "sourceLine": 1300, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_stop", - "index": 0 - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "6df50bed-6b21-4248-ab80-803bc059ce88" - } - }, - { - "offsetMs": 15477, - "observedAt": "2026-08-17T14:07:13.691Z", - "sourceLine": 1301, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 1, - "content_block": { - "type": "text", - "text": "" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "cf4e0ee0-8779-4cbf-93a9-710b96ca7b95" - } - }, - { - "offsetMs": 15477, - "observedAt": "2026-08-17T14:07:13.691Z", - "sourceLine": 1302, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 1, - "delta": { - "type": "text_delta", - "text": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "c9b707e2-44c7-4f88-94ed-99e7288a5224" - } - }, - { - "offsetMs": 16198, - "observedAt": "2026-08-17T14:07:14.412Z", - "sourceLine": 1303, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 1, - "delta": { - "type": "text_delta", - "text": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "c1de61e0-fe42-40a1-8b1b-c6fe781ed82d" - } - }, - { - "offsetMs": 16969, - "observedAt": "2026-08-17T14:07:15.183Z", - "sourceLine": 1304, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 1, - "delta": { - "type": "text_delta", - "text": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "1e08934f-30e8-4f4a-b6c0-6f03c98cc6c1" - } - }, - { - "offsetMs": 16970, - "observedAt": "2026-08-17T14:07:15.184Z", - "sourceLine": 1305, - "message": { - "type": "assistant", - "message": { - "model": "claude-opus-5", - "id": "msg_011Ce8T4z9DhKRg9F2DLDnbt", - "type": "message", - "role": "assistant", - "content": [ - { - "type": "text", - "text": "«redacted»" - } - ], - "stop_reason": null, - "stop_sequence": null, - "stop_details": null, - "usage": { - "input_tokens": 2, - "cache_creation_input_tokens": 4299, - "cache_read_input_tokens": 114840, - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 4299 - }, - "output_tokens": 3, - "service_tier": "standard", - "inference_geo": "not_available" - }, - "diagnostics": null, - "context_management": null - }, - "parent_tool_use_id": null, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "392b2565-3327-4977-8ba6-41599ba3c60a", - "timestamp": "2026-08-17T14:07:15.182Z", - "request_id": "«redacted»" - } - }, - { - "offsetMs": 16971, - "observedAt": "2026-08-17T14:07:15.185Z", - "sourceLine": 1306, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_stop", - "index": 1 - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "4e8b4347-87b7-4b95-ba35-fa0ba47e81f2" - } - }, - { - "offsetMs": 16972, - "observedAt": "2026-08-17T14:07:15.186Z", - "sourceLine": 1307, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 2, - "content_block": { - "type": "tool_use", - "id": "toolu_01BQBws6G8AmR761w8Vqem38", - "name": "Agent", - "input": {}, - "caller": { - "type": "direct" - } - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "e70c998f-057f-4eb8-b877-657e5d1cc20a" - } - }, - { - "offsetMs": 16972, - "observedAt": "2026-08-17T14:07:15.186Z", - "sourceLine": 1308, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "47daff97-0071-4529-8f26-053e4ac7566c" - } - }, - { - "offsetMs": 16973, - "observedAt": "2026-08-17T14:07:15.187Z", - "sourceLine": 1309, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "7bab1856-4510-4bf8-8d4d-6fa23346f78d" - } - }, - { - "offsetMs": 16973, - "observedAt": "2026-08-17T14:07:15.187Z", - "sourceLine": 1310, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "1648be73-d6a8-4bd1-9538-804e470f03e7" - } - }, - { - "offsetMs": 17592, - "observedAt": "2026-08-17T14:07:15.806Z", - "sourceLine": 1313, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "1ab16675-9263-486f-8964-c0206b82b6f3" - } - }, - { - "offsetMs": 17592, - "observedAt": "2026-08-17T14:07:15.806Z", - "sourceLine": 1314, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "7081043d-dc3a-47db-8069-de50a7be5f52" - } - }, - { - "offsetMs": 18302, - "observedAt": "2026-08-17T14:07:16.516Z", - "sourceLine": 1315, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "711db3be-782c-4b63-acf2-f8e9c71326f6" - } - }, - { - "offsetMs": 19015, - "observedAt": "2026-08-17T14:07:17.229Z", - "sourceLine": 1316, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "001d7707-1ac0-433a-8fd6-a362ca60b1a1" - } - }, - { - "offsetMs": 19707, - "observedAt": "2026-08-17T14:07:17.921Z", - "sourceLine": 1317, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "40d6cc53-7b30-40b0-a09f-902e0fe232ed" - } - }, - { - "offsetMs": 20413, - "observedAt": "2026-08-17T14:07:18.627Z", - "sourceLine": 1318, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "f9ecd25e-df05-4e5c-b518-de3df775421f" - } - }, - { - "offsetMs": 21120, - "observedAt": "2026-08-17T14:07:19.334Z", - "sourceLine": 1319, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "291227ab-1255-454d-b702-4d650da1962b" - } - }, - { - "offsetMs": 21986, - "observedAt": "2026-08-17T14:07:20.200Z", - "sourceLine": 1320, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "f2b34cfb-8917-48da-a281-96c563112cca" - } - }, - { - "offsetMs": 22545, - "observedAt": "2026-08-17T14:07:20.759Z", - "sourceLine": 1321, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "b1d1912e-ff31-40fc-8206-314acfbf4ec7" - } - }, - { - "offsetMs": 23240, - "observedAt": "2026-08-17T14:07:21.454Z", - "sourceLine": 1322, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "df1d6ec0-2ebf-48c8-a178-e9f4ceb16ae1" - } - }, - { - "offsetMs": 24136, - "observedAt": "2026-08-17T14:07:22.350Z", - "sourceLine": 1323, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "2cd6309c-38aa-4c91-b460-18e5cf284e18" - } - }, - { - "offsetMs": 24670, - "observedAt": "2026-08-17T14:07:22.884Z", - "sourceLine": 1324, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "09f8d197-1eb0-4fdb-8b13-446fd5f0ed41" - } - }, - { - "offsetMs": 25360, - "observedAt": "2026-08-17T14:07:23.574Z", - "sourceLine": 1325, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "c606a7ce-5da7-4f97-b0ac-106adc449f01" - } - }, - { - "offsetMs": 26171, - "observedAt": "2026-08-17T14:07:24.385Z", - "sourceLine": 1326, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "becd726e-90b9-4458-ae18-62eb86837811" - } - }, - { - "offsetMs": 26786, - "observedAt": "2026-08-17T14:07:25.000Z", - "sourceLine": 1327, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "1714fc55-ef0c-495c-a861-8ef80b3267ec" - } - }, - { - "offsetMs": 27480, - "observedAt": "2026-08-17T14:07:25.694Z", - "sourceLine": 1328, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "9a1f2d14-f036-47dd-9ce6-72974970ad9b" - } - }, - { - "offsetMs": 28258, - "observedAt": "2026-08-17T14:07:26.472Z", - "sourceLine": 1329, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "2a167890-7356-4d5b-aec4-9db8d767110b" - } - }, - { - "offsetMs": 28895, - "observedAt": "2026-08-17T14:07:27.109Z", - "sourceLine": 1330, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "7c8b5dbf-2729-4422-a01b-d43ff63ddea0" - } - }, - { - "offsetMs": 29625, - "observedAt": "2026-08-17T14:07:27.839Z", - "sourceLine": 1331, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "a9f67bcd-919b-45ac-90e4-f893a1065bc1" - } - }, - { - "offsetMs": 30355, - "observedAt": "2026-08-17T14:07:28.569Z", - "sourceLine": 1332, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "9888f461-b303-46f5-b3a4-6811b551bc5e" - } - }, - { - "offsetMs": 31012, - "observedAt": "2026-08-17T14:07:29.226Z", - "sourceLine": 1333, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "b4776ed8-f57f-4a7a-a63f-211546732d62" - } - }, - { - "offsetMs": 31713, - "observedAt": "2026-08-17T14:07:29.927Z", - "sourceLine": 1334, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "6a7ee1fc-f925-46bc-8530-aba9269fc9d7" - } - }, - { - "offsetMs": 32451, - "observedAt": "2026-08-17T14:07:30.665Z", - "sourceLine": 1335, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "7f056ebe-9fa1-4eb4-9007-ed79532bcedc" - } - }, - { - "offsetMs": 33125, - "observedAt": "2026-08-17T14:07:31.339Z", - "sourceLine": 1336, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "393ddda4-b707-4633-955a-14c5699717c2" - } - }, - { - "offsetMs": 33830, - "observedAt": "2026-08-17T14:07:32.044Z", - "sourceLine": 1337, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "87d6887b-a9b3-4672-a7c8-adf63f9afd30" - } - }, - { - "offsetMs": 34549, - "observedAt": "2026-08-17T14:07:32.763Z", - "sourceLine": 1338, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "095256e0-d1e8-469d-a222-ac693b6d1a4a" - } - }, - { - "offsetMs": 35241, - "observedAt": "2026-08-17T14:07:33.455Z", - "sourceLine": 1339, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "bfd4c1e4-a13f-4d06-a2c5-5f8bfcb3289d" - } - }, - { - "offsetMs": 35949, - "observedAt": "2026-08-17T14:07:34.163Z", - "sourceLine": 1340, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "d213113b-abf5-42a5-aee8-3cccb317162b" - } - }, - { - "offsetMs": 36652, - "observedAt": "2026-08-17T14:07:34.866Z", - "sourceLine": 1341, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 2, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "7064f735-fac2-4702-9101-d629783fc34b" - } - }, - { - "offsetMs": 36672, - "observedAt": "2026-08-17T14:07:34.886Z", - "sourceLine": 1342, - "message": { - "type": "assistant", - "message": { - "model": "claude-opus-5", - "id": "msg_011Ce8T4z9DhKRg9F2DLDnbt", - "type": "message", - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_01BQBws6G8AmR761w8Vqem38", - "name": "Agent", - "input": { - "description": "«redacted»", - "subagent_type": "«redacted»", - "run_in_background": false, - "prompt": "«redacted»" - }, - "caller": { - "type": "direct" - } - } - ], - "stop_reason": null, - "stop_sequence": null, - "stop_details": null, - "usage": { - "input_tokens": 2, - "cache_creation_input_tokens": 4299, - "cache_read_input_tokens": 114840, - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 4299 - }, - "output_tokens": 3, - "service_tier": "standard", - "inference_geo": "not_available" - }, - "diagnostics": null, - "context_management": null - }, - "parent_tool_use_id": null, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "53088c3f-9721-4dec-bbf5-bfa6d2f6403b", - "timestamp": "2026-08-17T14:07:34.867Z", - "request_id": "«redacted»" - } - }, - { - "offsetMs": 36687, - "observedAt": "2026-08-17T14:07:34.901Z", - "sourceLine": 1343, - "message": { - "type": "system", - "subtype": "task_started", - "task_id": "«redacted»", - "tool_use_id": "toolu_01BQBws6G8AmR761w8Vqem38", - "description": "«redacted»", - "subagent_type": "«redacted»", - "task_type": "«redacted»", - "prompt": "«redacted»", - "uuid": "020c9337-eae9-4f1b-a26d-ba20ae021c71", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 36698, - "observedAt": "2026-08-17T14:07:34.912Z", - "sourceLine": 1345, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_stop", - "index": 2 - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "576a54f5-236a-42f0-b374-3a6b34eb17e5" - } - }, - { - "offsetMs": 36705, - "observedAt": "2026-08-17T14:07:34.919Z", - "sourceLine": 1346, - "message": { - "type": "user", - "message": { - "role": "user", - "content": [ - { - "type": "text", - "text": "«redacted»" - } - ] - }, - "parent_tool_use_id": "toolu_01BQBws6G8AmR761w8Vqem38", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "204319ce-b2b0-41d8-aee8-fb528fbcb2db", - "timestamp": "2026-08-17T14:07:34.890Z", - "subagent_type": "«redacted»", - "task_description": "«redacted»" - } - }, - { - "offsetMs": 36707, - "observedAt": "2026-08-17T14:07:34.921Z", - "sourceLine": 1347, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 3, - "content_block": { - "type": "tool_use", - "id": "toolu_0147fBoLLMNLZ2yJuDyZpGvC", - "name": "Agent", - "input": {}, - "caller": { - "type": "direct" - } - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "fea11a14-1699-4019-a694-f6e77a5ac52c" - } - }, - { - "offsetMs": 36707, - "observedAt": "2026-08-17T14:07:34.921Z", - "sourceLine": 1349, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "e9aa8044-1b82-4138-9700-50a4086f7179" - } - }, - { - "offsetMs": 36708, - "observedAt": "2026-08-17T14:07:34.922Z", - "sourceLine": 1350, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "3d95effc-fbac-4a9c-8523-cf58f3e653ae" - } - }, - { - "offsetMs": 36708, - "observedAt": "2026-08-17T14:07:34.922Z", - "sourceLine": 1351, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "3c321395-3a25-48d8-ac3d-0703e864a0cd" - } - }, - { - "offsetMs": 36708, - "observedAt": "2026-08-17T14:07:34.922Z", - "sourceLine": 1352, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "1558c219-1fb7-42ca-b1bf-23c0f93b7674" - } - }, - { - "offsetMs": 37359, - "observedAt": "2026-08-17T14:07:35.573Z", - "sourceLine": 1353, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "bebf84df-7bf5-4590-be05-d18d587f6d28" - } - }, - { - "offsetMs": 37904, - "observedAt": "2026-08-17T14:07:36.118Z", - "sourceLine": 1354, - "message": { - "type": "rate_limit_event", - "rate_limit_info": { - "status": "rejected", - "resetsAt": 1786989000, - "rateLimitType": "five_hour", - "overageStatus": "rejected", - "overageDisabledReason": "org_level_disabled", - "isUsingOverage": false - }, - "uuid": "e06d8627-1bc5-49b4-8ea4-6a8d90324404", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 38064, - "observedAt": "2026-08-17T14:07:36.278Z", - "sourceLine": 1356, - "message": { - "type": "system", - "subtype": "task_updated", - "task_id": "«redacted»", - "patch": { - "status": "failed", - "end_time": 1786975656125, - "error": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)" - }, - "uuid": "e9e6f55d-99a4-497c-b748-7bbbd068b006", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 38064, - "observedAt": "2026-08-17T14:07:36.278Z", - "sourceLine": 1357, - "message": { - "type": "system", - "subtype": "task_notification", - "task_id": "«redacted»", - "tool_use_id": "toolu_01BQBws6G8AmR761w8Vqem38", - "status": "failed", - "output_file": "«redacted»", - "summary": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "uuid": "42429493-851c-4673-8874-4a9465872f41", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 38064, - "observedAt": "2026-08-17T14:07:36.278Z", - "sourceLine": 1358, - "message": { - "type": "user", - "message": { - "role": "user", - "content": [ - { - "type": "tool_result", - "content": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "is_error": true, - "tool_use_id": "toolu_01BQBws6G8AmR761w8Vqem38" - } - ] - }, - "parent_tool_use_id": null, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "521e3db4-b159-44e5-9acc-fa11d5f82501", - "timestamp": "2026-08-17T14:07:36.130Z", - "tool_use_result": "Error: Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)" - } - }, - { - "offsetMs": 38067, - "observedAt": "2026-08-17T14:07:36.281Z", - "sourceLine": 1362, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "ca073424-119f-4081-ae19-161e6cf59c2e" - } - }, - { - "offsetMs": 41429, - "observedAt": "2026-08-17T14:07:39.643Z", - "sourceLine": 1363, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "207c9fb7-461a-4ba3-86de-d89a791799fb" - } - }, - { - "offsetMs": 41430, - "observedAt": "2026-08-17T14:07:39.644Z", - "sourceLine": 1364, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "8cd02baa-9f2b-4f0d-bdc7-f81f95a0066f" - } - }, - { - "offsetMs": 41430, - "observedAt": "2026-08-17T14:07:39.644Z", - "sourceLine": 1365, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "59c0c254-b9b5-4cd3-a004-97af597a977e" - } - }, - { - "offsetMs": 41430, - "observedAt": "2026-08-17T14:07:39.644Z", - "sourceLine": 1366, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "8f956807-97a7-415c-b8b6-817f8f82ee21" - } - }, - { - "offsetMs": 41617, - "observedAt": "2026-08-17T14:07:39.831Z", - "sourceLine": 1367, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "01bd963a-4be9-4009-a243-5bcbde9d8476" - } - }, - { - "offsetMs": 42302, - "observedAt": "2026-08-17T14:07:40.516Z", - "sourceLine": 1368, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "be62ebff-e52d-4c22-b098-774366dceb36" - } - }, - { - "offsetMs": 43184, - "observedAt": "2026-08-17T14:07:41.398Z", - "sourceLine": 1369, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "0e14596b-f921-4aff-8a2e-5f0fe29a56f5" - } - }, - { - "offsetMs": 43713, - "observedAt": "2026-08-17T14:07:41.927Z", - "sourceLine": 1370, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "a11b0f97-cd64-4fb3-bd87-81dd285e57bb" - } - }, - { - "offsetMs": 44520, - "observedAt": "2026-08-17T14:07:42.734Z", - "sourceLine": 1371, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "475a6b42-8c6d-4619-9482-25b8b88f0d15" - } - }, - { - "offsetMs": 45125, - "observedAt": "2026-08-17T14:07:43.339Z", - "sourceLine": 1372, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "fb178f39-dc03-4e9b-9ed3-01ca97743dab" - } - }, - { - "offsetMs": 45830, - "observedAt": "2026-08-17T14:07:44.044Z", - "sourceLine": 1373, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "328c3aa6-fb62-4e87-83c4-cd0a36e69822" - } - }, - { - "offsetMs": 46611, - "observedAt": "2026-08-17T14:07:44.825Z", - "sourceLine": 1374, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "0a7597eb-c800-4e87-96dd-2bbb4fbbbe5b" - } - }, - { - "offsetMs": 47246, - "observedAt": "2026-08-17T14:07:45.460Z", - "sourceLine": 1375, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "4f03d487-1278-4b8c-bb17-2985ad9014d4" - } - }, - { - "offsetMs": 47961, - "observedAt": "2026-08-17T14:07:46.175Z", - "sourceLine": 1376, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "9ccbc3dd-04c9-49ae-87b4-88a65e03ab60" - } - }, - { - "offsetMs": 48711, - "observedAt": "2026-08-17T14:07:46.925Z", - "sourceLine": 1377, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "19c7d67b-4dd9-45cf-91fc-103b8b0c939f" - } - }, - { - "offsetMs": 49361, - "observedAt": "2026-08-17T14:07:47.575Z", - "sourceLine": 1378, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "f34a6466-1910-484c-baf7-1bb69563e828" - } - }, - { - "offsetMs": 49984, - "observedAt": "2026-08-17T14:07:48.198Z", - "sourceLine": 1379, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "6e73bb20-07ee-45d8-9880-365fbb545ca1" - } - }, - { - "offsetMs": 49985, - "observedAt": "2026-08-17T14:07:48.199Z", - "sourceLine": 1380, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_delta", - "index": 3, - "delta": { - "type": "input_json_delta", - "partial_json": "«redacted»" - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "1eb38e39-7cfe-4703-8780-9a4935482982" - } - }, - { - "offsetMs": 49986, - "observedAt": "2026-08-17T14:07:48.200Z", - "sourceLine": 1381, - "message": { - "type": "assistant", - "message": { - "model": "claude-opus-5", - "id": "msg_011Ce8T4z9DhKRg9F2DLDnbt", - "type": "message", - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_0147fBoLLMNLZ2yJuDyZpGvC", - "name": "Agent", - "input": { - "description": "«redacted»", - "subagent_type": "«redacted»", - "run_in_background": false, - "prompt": "«redacted»" - }, - "caller": { - "type": "direct" - } - } - ], - "stop_reason": null, - "stop_sequence": null, - "stop_details": null, - "usage": { - "input_tokens": 2, - "cache_creation_input_tokens": 4299, - "cache_read_input_tokens": 114840, - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 4299 - }, - "output_tokens": 3, - "service_tier": "standard", - "inference_geo": "not_available" - }, - "diagnostics": null, - "context_management": null - }, - "parent_tool_use_id": null, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "9918eab0-6fd2-4743-87e1-49d77c262b3e", - "timestamp": "2026-08-17T14:07:48.199Z", - "request_id": "«redacted»" - } - }, - { - "offsetMs": 49996, - "observedAt": "2026-08-17T14:07:48.210Z", - "sourceLine": 1382, - "message": { - "type": "system", - "subtype": "task_started", - "task_id": "«redacted»", - "tool_use_id": "toolu_0147fBoLLMNLZ2yJuDyZpGvC", - "description": "«redacted»", - "subagent_type": "«redacted»", - "task_type": "«redacted»", - "prompt": "«redacted»", - "uuid": "59743139-0214-4eec-8b25-c51dbd36c4ba", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 49996, - "observedAt": "2026-08-17T14:07:48.210Z", - "sourceLine": 1383, - "message": { - "type": "stream_event", - "event": { - "type": "content_block_stop", - "index": 3 - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "8dfb8dc2-db1c-4d65-821d-eb9a1ce1c0f2" - } - }, - { - "offsetMs": 49998, - "observedAt": "2026-08-17T14:07:48.212Z", - "sourceLine": 1385, - "message": { - "type": "user", - "message": { - "role": "user", - "content": [ - { - "type": "text", - "text": "«redacted»" - } - ] - }, - "parent_tool_use_id": "toolu_0147fBoLLMNLZ2yJuDyZpGvC", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "f629daa4-d11b-45dd-a9b5-412eddacf0e8", - "timestamp": "2026-08-17T14:07:48.201Z", - "subagent_type": "«redacted»", - "task_description": "«redacted»" - } - }, - { - "offsetMs": 50009, - "observedAt": "2026-08-17T14:07:48.223Z", - "sourceLine": 1386, - "message": { - "type": "stream_event", - "event": { - "type": "message_delta", - "delta": { - "stop_reason": "tool_use", - "stop_sequence": null, - "stop_details": null - }, - "usage": { - "input_tokens": 2, - "cache_creation_input_tokens": 4299, - "cache_read_input_tokens": 114840, - "output_tokens": 3787, - "output_tokens_details": { - "thinking_tokens": 909 - }, - "iterations": [ - { - "input_tokens": 2, - "output_tokens": 3787, - "cache_read_input_tokens": 114840, - "cache_creation_input_tokens": 4299, - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 4299 - }, - "type": "message" - } - ] - }, - "context_management": { - "applied_edits": [] - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "0e2a9afd-3679-4daf-b341-604f6eb44075" - } - }, - { - "offsetMs": 50009, - "observedAt": "2026-08-17T14:07:48.223Z", - "sourceLine": 1387, - "message": { - "type": "stream_event", - "event": { - "type": "message_stop" - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "0a7ffb99-503c-4215-9525-bf6638d4384b" - } - }, - { - "offsetMs": 50343, - "observedAt": "2026-08-17T14:07:48.557Z", - "sourceLine": 1389, - "message": { - "type": "system", - "subtype": "task_updated", - "task_id": "«redacted»", - "patch": { - "status": "failed", - "end_time": 1786975668556, - "error": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)" - }, - "uuid": "7a3c52a8-2828-4f55-95c7-3aa0523c7591", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 50343, - "observedAt": "2026-08-17T14:07:48.557Z", - "sourceLine": 1391, - "message": { - "type": "system", - "subtype": "task_notification", - "task_id": "«redacted»", - "tool_use_id": "toolu_0147fBoLLMNLZ2yJuDyZpGvC", - "status": "failed", - "output_file": "«redacted»", - "summary": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "uuid": "7842db2e-ac45-4609-8594-87fc1c0b4d7c", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 50344, - "observedAt": "2026-08-17T14:07:48.558Z", - "sourceLine": 1393, - "message": { - "type": "user", - "message": { - "role": "user", - "content": [ - { - "type": "tool_result", - "content": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "is_error": true, - "tool_use_id": "toolu_0147fBoLLMNLZ2yJuDyZpGvC" - } - ] - }, - "parent_tool_use_id": null, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "010def0a-564c-4fe9-80b5-695827fce218", - "timestamp": "2026-08-17T14:07:48.557Z", - "tool_use_result": "Error: Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)" - } - }, - { - "offsetMs": 50350, - "observedAt": "2026-08-17T14:07:48.564Z", - "sourceLine": 1395, - "message": { - "type": "system", - "subtype": "status", - "status": "requesting", - "uuid": "d4a7fef5-d52d-4971-84de-eeeadf1f6bd1", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - { - "offsetMs": 50653, - "observedAt": "2026-08-17T14:07:48.867Z", - "sourceLine": 1397, - "message": { - "type": "assistant", - "message": { - "diagnostics": null, - "id": "472af1e9-cab1-4824-9f98-22f66e215d5e", - "container": null, - "model": "", - "role": "assistant", - "stop_details": null, - "stop_reason": "stop_sequence", - "stop_sequence": "", - "type": "message", - "usage": { - "output_tokens_details": null, - "input_tokens": 0, - "output_tokens": 0, - "cache_creation_input_tokens": 0, - "cache_read_input_tokens": 0, - "server_tool_use": { - "web_search_requests": 0, - "web_fetch_requests": 0 - }, - "service_tier": null, - "cache_creation": { - "ephemeral_1h_input_tokens": 0, - "ephemeral_5m_input_tokens": 0 - }, - "inference_geo": null, - "iterations": null, - "speed": null - }, - "content": [ - { - "type": "text", - "text": "You've hit your session limit · resets 1:50pm (America/Toronto)" - } - ], - "context_management": null - }, - "parent_tool_use_id": null, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "401447f6-139d-4c34-a9ec-c096a756c157", - "timestamp": "2026-08-17T14:07:48.866Z", - "error": "«redacted»", - "request_id": "«redacted»", - "is_api_error_message": true - } - }, - { - "offsetMs": 50654, - "observedAt": "2026-08-17T14:07:48.868Z", - "sourceLine": 1398, - "message": { - "is_error": true, - "duration_api_ms": 125307, - "num_turns": 7, - "stop_reason": "stop_sequence", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "total_cost_usd": 0.5377985000000001, - "usage": { - "input_tokens": 8, - "cache_creation_input_tokens": 8338, - "cache_read_input_tokens": 450157, - "output_tokens": 9172, - "output_tokens_details": { - "thinking_tokens": 4034 - }, - "server_tool_use": { - "web_search_requests": 0, - "web_fetch_requests": 0 - }, - "service_tier": "standard", - "cache_creation": { - "ephemeral_1h_input_tokens": 8338, - "ephemeral_5m_input_tokens": 0 - }, - "inference_geo": "not_available", - "iterations": [ - { - "input_tokens": 2, - "output_tokens": 3787, - "cache_read_input_tokens": 114840, - "cache_creation_input_tokens": 4299, - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 4299 - }, - "type": "message" - } - ], - "speed": "standard" - }, - "modelUsage": { - "claude-opus-5[1m]": { - "inputTokens": 8, - "outputTokens": 9172, - "cacheReadInputTokens": 450157, - "cacheCreationInputTokens": 8338, - "webSearchRequests": 0, - "costUSD": 0.5377985000000001, - "contextWindow": 1000000, - "maxOutputTokens": 64000, - "canonicalModel": "«redacted»", - "provider": "firstParty" - } - }, - "permission_denials": [], - "terminal_reason": "«redacted»", - "fast_mode_state": "«redacted»", - "fast_mode_disabled_reason": "«redacted»", - "subtype": "success", - "api_error_status": 429, - "result": "You've hit your session limit · resets 1:50pm (America/Toronto)", - "type": "result", - "duration_ms": 2264386, - "uuid": "c4c977a9-b5ad-400a-9e90-fbc931bb93c4" - } - } - ], - "canonical": [ - { - "offsetMs": 0, - "observedAt": "2026-08-17T14:06:58.214Z", - "sourceLine": 1272, - "type": "user-input.resolved", - "event": { - "type": "user-input.resolved", - "eventId": "ed8ddb2a-3202-4de1-88f9-89a90d6b0b51", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:06:58.214Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "requestId": "a093a911-f171-420b-860a-1fe9cad0df29", - "payload": { - "answers": { - "Where should the new capture/replay harness live?": "«redacted»", - "How deep should the replay go?": "«redacted»", - "Should I build a recorder that turns future real limit episodes into fixtures automatically?": "«redacted»", - "Can you get the actual incident log off the Windows machine? (events.57e2846c-9cb1-40d4-8776-e5f7db3fe7b9.log, plus t3x-auto-resume.json)": "«redacted»" - } - }, - "providerRefs": { - "providerItemId": "«redacted»" - }, - "raw": { - "source": "claude.sdk.permission", - "method": "canUseTool/AskUserQuestion/resolved", - "payload": { - "answers": { - "Where should the new capture/replay harness live?": "«redacted»", - "How deep should the replay go?": "«redacted»", - "Should I build a recorder that turns future real limit episodes into fixtures automatically?": "«redacted»", - "Can you get the actual incident log off the Windows machine? (events.57e2846c-9cb1-40d4-8776-e5f7db3fe7b9.log, plus t3x-auto-resume.json)": "«redacted»" - } - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 44, - "observedAt": "2026-08-17T14:06:58.258Z", - "sourceLine": 1274, - "type": "item.completed", - "event": { - "type": "item.completed", - "eventId": "28d52bc5-25ee-4b3c-b7e3-39e95b0e71c6", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:06:58.258Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "itemId": "toolu_015WkJMkqMnb96fdBG7DRzQX", - "payload": { - "itemType": "«redacted»", - "status": "completed", - "title": "«redacted»", - "detail": "«redacted»", - "data": { - "toolName": "«redacted»", - "input": { - "questions": [ - { - "question": "«redacted»", - "header": "«redacted»", - "multiSelect": false, - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ] - }, - { - "question": "«redacted»", - "header": "«redacted»", - "multiSelect": false, - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ] - }, - { - "question": "«redacted»", - "header": "«redacted»", - "multiSelect": false, - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ] - }, - { - "question": "«redacted»", - "header": "«redacted»", - "multiSelect": false, - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ] - } - ] - }, - "result": { - "type": "tool_result", - "content": "«redacted»", - "tool_use_id": "toolu_015WkJMkqMnb96fdBG7DRzQX" - } - } - }, - "providerRefs": { - "providerItemId": "«redacted»" - }, - "raw": { - "source": "claude.sdk.message", - "method": "claude/user", - "payload": { - "type": "user", - "message": { - "role": "user", - "content": [ - { - "type": "tool_result", - "content": "«redacted»", - "tool_use_id": "toolu_015WkJMkqMnb96fdBG7DRzQX" - } - ] - }, - "parent_tool_use_id": null, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "8e9690e3-b6ae-4c73-aebf-d4498d4a392e", - "timestamp": "2026-08-17T14:06:58.251Z", - "tool_use_result": { - "questions": [ - { - "question": "«redacted»", - "header": "«redacted»", - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ], - "multiSelect": false - }, - { - "question": "«redacted»", - "header": "«redacted»", - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ], - "multiSelect": false - }, - { - "question": "«redacted»", - "header": "«redacted»", - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ], - "multiSelect": false - }, - { - "question": "«redacted»", - "header": "«redacted»", - "options": [ - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - }, - { - "label": "«redacted»", - "description": "«redacted»" - } - ], - "multiSelect": false - } - ], - "answers": { - "Where should the new capture/replay harness live?": "«redacted»", - "How deep should the replay go?": "«redacted»", - "Should I build a recorder that turns future real limit episodes into fixtures automatically?": "«redacted»", - "Can you get the actual incident log off the Windows machine? (events.57e2846c-9cb1-40d4-8776-e5f7db3fe7b9.log, plus t3x-auto-resume.json)": "«redacted»" - } - } - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 58, - "observedAt": "2026-08-17T14:06:58.272Z", - "sourceLine": 1276, - "type": "session.state.changed", - "event": { - "eventId": "3fc97059-8f18-460d-b5f7-4f2dd97b238f", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:06:58.267Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/system/status", - "messageType": "system:status", - "payload": { - "type": "system", - "subtype": "status", - "status": "requesting", - "uuid": "3725d6ed-f0e9-45b1-9718-dc200ce73893", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "type": "session.state.changed", - "payload": { - "state": "«redacted»", - "reason": "status:requesting", - "detail": { - "type": "system", - "subtype": "status", - "status": "requesting", - "uuid": "3725d6ed-f0e9-45b1-9718-dc200ce73893", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 16973, - "observedAt": "2026-08-17T14:07:15.187Z", - "sourceLine": 1311, - "type": "item.completed", - "event": { - "type": "item.completed", - "eventId": "350664d1-bf11-4785-bf76-546104e720ab", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:15.186Z", - "itemId": "e1e930b7-f6eb-4e4c-87da-9f87ad2f48d5", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "payload": { - "itemType": "«redacted»", - "status": "completed", - "title": "«redacted»" - }, - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/stream_event/content_block_stop", - "payload": { - "type": "stream_event", - "event": { - "type": "content_block_stop", - "index": 1 - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "4e8b4347-87b7-4b95-ba35-fa0ba47e81f2" - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 16973, - "observedAt": "2026-08-17T14:07:15.187Z", - "sourceLine": 1312, - "type": "item.started", - "event": { - "type": "item.started", - "eventId": "d2009cab-908e-4ee9-a372-a1fa8b35c8c0", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:15.186Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "itemId": "toolu_01BQBws6G8AmR761w8Vqem38", - "payload": { - "itemType": "«redacted»", - "status": "inProgress", - "title": "«redacted»", - "detail": "«redacted»", - "data": { - "toolName": "«redacted»", - "input": {} - } - }, - "providerRefs": { - "providerItemId": "«redacted»" - }, - "raw": { - "source": "claude.sdk.message", - "method": "claude/stream_event/content_block_start", - "payload": { - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 2, - "content_block": { - "type": "tool_use", - "id": "toolu_01BQBws6G8AmR761w8Vqem38", - "name": "Agent", - "input": {}, - "caller": { - "type": "direct" - } - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "e70c998f-057f-4eb8-b877-657e5d1cc20a" - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 36688, - "observedAt": "2026-08-17T14:07:34.902Z", - "sourceLine": 1344, - "type": "task.started", - "event": { - "eventId": "cdae4b1a-6fd5-4e82-9abf-e1ccae7dc6ab", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:34.902Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/system/task_started", - "messageType": "system:task_started", - "payload": { - "type": "system", - "subtype": "task_started", - "task_id": "«redacted»", - "tool_use_id": "toolu_01BQBws6G8AmR761w8Vqem38", - "description": "«redacted»", - "subagent_type": "«redacted»", - "task_type": "«redacted»", - "prompt": "«redacted»", - "uuid": "020c9337-eae9-4f1b-a26d-ba20ae021c71", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "type": "task.started", - "payload": { - "taskId": "«redacted»", - "description": "«redacted»", - "taskType": "«redacted»", - "title": "«redacted»", - "role": "general-purpose", - "model": "claude-opus-5", - "effort": "«redacted»", - "toolUseId": "«redacted»" - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 36707, - "observedAt": "2026-08-17T14:07:34.921Z", - "sourceLine": 1348, - "type": "item.started", - "event": { - "type": "item.started", - "eventId": "926132cd-c57b-41bf-9d10-db387c732fb6", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:34.921Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "itemId": "toolu_0147fBoLLMNLZ2yJuDyZpGvC", - "payload": { - "itemType": "«redacted»", - "status": "inProgress", - "title": "«redacted»", - "detail": "«redacted»", - "data": { - "toolName": "«redacted»", - "input": {} - } - }, - "providerRefs": { - "providerItemId": "«redacted»" - }, - "raw": { - "source": "claude.sdk.message", - "method": "claude/stream_event/content_block_start", - "payload": { - "type": "stream_event", - "event": { - "type": "content_block_start", - "index": 3, - "content_block": { - "type": "tool_use", - "id": "toolu_0147fBoLLMNLZ2yJuDyZpGvC", - "name": "Agent", - "input": {}, - "caller": { - "type": "direct" - } - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "fea11a14-1699-4019-a694-f6e77a5ac52c" - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 37904, - "observedAt": "2026-08-17T14:07:36.118Z", - "sourceLine": 1355, - "type": "account.rate-limits.updated", - "event": { - "eventId": "5dbf93db-3ea9-4fa9-ab39-22cc90a8f511", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:36.118Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/rate_limit_event", - "messageType": "rate_limit_event", - "payload": { - "type": "rate_limit_event", - "rate_limit_info": { - "status": "rejected", - "resetsAt": 1786989000, - "rateLimitType": "five_hour", - "overageStatus": "rejected", - "overageDisabledReason": "org_level_disabled", - "isUsingOverage": false - }, - "uuid": "e06d8627-1bc5-49b4-8ea4-6a8d90324404", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "type": "account.rate-limits.updated", - "payload": { - "rateLimits": { - "type": "rate_limit_event", - "rate_limit_info": { - "status": "rejected", - "resetsAt": 1786989000, - "rateLimitType": "five_hour", - "overageStatus": "rejected", - "overageDisabledReason": "org_level_disabled", - "isUsingOverage": false - }, - "uuid": "e06d8627-1bc5-49b4-8ea4-6a8d90324404", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 38067, - "observedAt": "2026-08-17T14:07:36.281Z", - "sourceLine": 1359, - "type": "task.updated", - "event": { - "eventId": "e77408a0-ec88-4599-9569-dd89af0333e9", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:36.278Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/system/task_updated", - "messageType": "system:task_updated", - "payload": { - "type": "system", - "subtype": "task_updated", - "task_id": "«redacted»", - "patch": { - "status": "failed", - "end_time": 1786975656125, - "error": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)" - }, - "uuid": "e9e6f55d-99a4-497c-b748-7bbbd068b006", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "type": "task.updated", - "payload": { - "taskId": "«redacted»", - "status": "failed", - "error": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "endedAt": "2026-08-17T14:07:36.125Z", - "taskType": "«redacted»", - "title": "«redacted»", - "role": "general-purpose", - "model": "claude-opus-5", - "effort": "«redacted»", - "toolUseId": "«redacted»" - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 38067, - "observedAt": "2026-08-17T14:07:36.281Z", - "sourceLine": 1360, - "type": "task.completed", - "event": { - "eventId": "e6cde432-d2d2-4760-9961-e2963a05d068", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:36.278Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/system/task_notification", - "messageType": "system:task_notification", - "payload": { - "type": "system", - "subtype": "task_notification", - "task_id": "«redacted»", - "tool_use_id": "toolu_01BQBws6G8AmR761w8Vqem38", - "status": "failed", - "output_file": "«redacted»", - "summary": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "uuid": "42429493-851c-4673-8874-4a9465872f41", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "type": "task.completed", - "payload": { - "taskId": "«redacted»", - "status": "failed", - "summary": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "outputFile": "«redacted»", - "taskType": "«redacted»", - "title": "«redacted»", - "role": "general-purpose", - "model": "claude-opus-5", - "effort": "«redacted»", - "toolUseId": "«redacted»" - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 38067, - "observedAt": "2026-08-17T14:07:36.281Z", - "sourceLine": 1361, - "type": "item.completed", - "event": { - "type": "item.completed", - "eventId": "fc4f3db9-f1b3-48fe-ba07-d132a9c2d48e", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:36.278Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "itemId": "toolu_01BQBws6G8AmR761w8Vqem38", - "payload": { - "itemType": "«redacted»", - "status": "failed", - "title": "«redacted»", - "detail": "«redacted»", - "data": { - "toolName": "«redacted»", - "input": { - "description": "«redacted»", - "subagent_type": "«redacted»", - "run_in_background": false, - "prompt": "«redacted»" - }, - "result": { - "type": "tool_result", - "content": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "is_error": true, - "tool_use_id": "toolu_01BQBws6G8AmR761w8Vqem38" - } - } - }, - "providerRefs": { - "providerItemId": "«redacted»" - }, - "raw": { - "source": "claude.sdk.message", - "method": "claude/user", - "payload": { - "type": "user", - "message": { - "role": "user", - "content": [ - { - "type": "tool_result", - "content": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "is_error": true, - "tool_use_id": "toolu_01BQBws6G8AmR761w8Vqem38" - } - ] - }, - "parent_tool_use_id": null, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "521e3db4-b159-44e5-9acc-fa11d5f82501", - "timestamp": "2026-08-17T14:07:36.130Z", - "tool_use_result": "Error: Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)" - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 49998, - "observedAt": "2026-08-17T14:07:48.212Z", - "sourceLine": 1384, - "type": "task.started", - "event": { - "eventId": "0f96547f-522c-49e0-930f-69c89c433e54", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:48.210Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/system/task_started", - "messageType": "system:task_started", - "payload": { - "type": "system", - "subtype": "task_started", - "task_id": "«redacted»", - "tool_use_id": "toolu_0147fBoLLMNLZ2yJuDyZpGvC", - "description": "«redacted»", - "subagent_type": "«redacted»", - "task_type": "«redacted»", - "prompt": "«redacted»", - "uuid": "59743139-0214-4eec-8b25-c51dbd36c4ba", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "type": "task.started", - "payload": { - "taskId": "«redacted»", - "description": "«redacted»", - "taskType": "«redacted»", - "title": "«redacted»", - "role": "general-purpose", - "model": "claude-opus-5", - "effort": "«redacted»", - "toolUseId": "«redacted»" - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 50011, - "observedAt": "2026-08-17T14:07:48.225Z", - "sourceLine": 1388, - "type": "thread.token-usage.updated", - "event": { - "type": "thread.token-usage.updated", - "eventId": "22563a83-80f8-4905-98ca-0622c13326a7", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:48.223Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "payload": { - "usage": { - "usedTokens": 122928, - "lastUsedTokens": 122928, - "inputTokens": 119141, - "outputTokens": 3787, - "maxTokens": 500000 - } - }, - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/stream_event/message_delta", - "payload": { - "type": "stream_event", - "event": { - "type": "message_delta", - "delta": { - "stop_reason": "tool_use", - "stop_sequence": null, - "stop_details": null - }, - "usage": { - "input_tokens": 2, - "cache_creation_input_tokens": 4299, - "cache_read_input_tokens": 114840, - "output_tokens": 3787, - "output_tokens_details": { - "thinking_tokens": 909 - }, - "iterations": [ - { - "input_tokens": 2, - "output_tokens": 3787, - "cache_read_input_tokens": 114840, - "cache_creation_input_tokens": 4299, - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 4299 - }, - "type": "message" - } - ] - }, - "context_management": { - "applied_edits": [] - } - }, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "parent_tool_use_id": null, - "uuid": "0e2a9afd-3679-4daf-b341-604f6eb44075" - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 50343, - "observedAt": "2026-08-17T14:07:48.557Z", - "sourceLine": 1390, - "type": "task.updated", - "event": { - "eventId": "c63c6887-c0ca-4d23-b2ce-022d5a6f4cee", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:48.557Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/system/task_updated", - "messageType": "system:task_updated", - "payload": { - "type": "system", - "subtype": "task_updated", - "task_id": "«redacted»", - "patch": { - "status": "failed", - "end_time": 1786975668556, - "error": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)" - }, - "uuid": "7a3c52a8-2828-4f55-95c7-3aa0523c7591", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "type": "task.updated", - "payload": { - "taskId": "«redacted»", - "status": "failed", - "error": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "endedAt": "2026-08-17T14:07:48.556Z", - "taskType": "«redacted»", - "title": "«redacted»", - "role": "general-purpose", - "model": "claude-opus-5", - "effort": "«redacted»", - "toolUseId": "«redacted»" - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 50344, - "observedAt": "2026-08-17T14:07:48.558Z", - "sourceLine": 1392, - "type": "task.completed", - "event": { - "eventId": "9991d222-6716-4a53-8b88-4165c71af783", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:48.557Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/system/task_notification", - "messageType": "system:task_notification", - "payload": { - "type": "system", - "subtype": "task_notification", - "task_id": "«redacted»", - "tool_use_id": "toolu_0147fBoLLMNLZ2yJuDyZpGvC", - "status": "failed", - "output_file": "«redacted»", - "summary": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "uuid": "7842db2e-ac45-4609-8594-87fc1c0b4d7c", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "type": "task.completed", - "payload": { - "taskId": "«redacted»", - "status": "failed", - "summary": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "outputFile": "«redacted»", - "taskType": "«redacted»", - "title": "«redacted»", - "role": "general-purpose", - "model": "claude-opus-5", - "effort": "«redacted»", - "toolUseId": "«redacted»" - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 50350, - "observedAt": "2026-08-17T14:07:48.564Z", - "sourceLine": 1394, - "type": "item.completed", - "event": { - "type": "item.completed", - "eventId": "867dccd5-add4-4a8a-9ae9-9e9e8bbee337", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:48.558Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "itemId": "toolu_0147fBoLLMNLZ2yJuDyZpGvC", - "payload": { - "itemType": "«redacted»", - "status": "failed", - "title": "«redacted»", - "detail": "«redacted»", - "data": { - "toolName": "«redacted»", - "input": { - "description": "«redacted»", - "subagent_type": "«redacted»", - "run_in_background": false, - "prompt": "«redacted»" - }, - "result": { - "type": "tool_result", - "content": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "is_error": true, - "tool_use_id": "toolu_0147fBoLLMNLZ2yJuDyZpGvC" - } - } - }, - "providerRefs": { - "providerItemId": "«redacted»" - }, - "raw": { - "source": "claude.sdk.message", - "method": "claude/user", - "payload": { - "type": "user", - "message": { - "role": "user", - "content": [ - { - "type": "tool_result", - "content": "Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)", - "is_error": true, - "tool_use_id": "toolu_0147fBoLLMNLZ2yJuDyZpGvC" - } - ] - }, - "parent_tool_use_id": null, - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "uuid": "010def0a-564c-4fe9-80b5-695827fce218", - "timestamp": "2026-08-17T14:07:48.557Z", - "tool_use_result": "Error: Agent terminated early due to an API error: You've hit your session limit · resets 1:50pm (America/Toronto)" - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 50351, - "observedAt": "2026-08-17T14:07:48.565Z", - "sourceLine": 1396, - "type": "session.state.changed", - "event": { - "eventId": "8feb0813-26b3-40fe-b9be-941b97e9c38a", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:48.564Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/system/status", - "messageType": "system:status", - "payload": { - "type": "system", - "subtype": "status", - "status": "requesting", - "uuid": "d4a7fef5-d52d-4971-84de-eeeadf1f6bd1", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "type": "session.state.changed", - "payload": { - "state": "«redacted»", - "reason": "status:requesting", - "detail": { - "type": "system", - "subtype": "status", - "status": "requesting", - "uuid": "d4a7fef5-d52d-4971-84de-eeeadf1f6bd1", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c" - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 51605, - "observedAt": "2026-08-17T14:07:49.819Z", - "sourceLine": 1399, - "type": "thread.token-usage.updated", - "event": { - "type": "thread.token-usage.updated", - "eventId": "bae5eca6-7b7f-433b-a2b2-ab1974ca1568", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:49.819Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "payload": { - "usage": { - "usedTokens": 119141, - "lastUsedTokens": 119141, - "totalProcessedTokens": 467675, - "maxTokens": 500000, - "compactsAutomatically": true - } - }, - "providerRefs": {}, - "raw": { - "source": "claude.sdk.message", - "method": "claude/result", - "payload": { - "is_error": true, - "duration_api_ms": 125307, - "num_turns": 7, - "stop_reason": "stop_sequence", - "session_id": "cd13e5bc-382e-4440-bec6-4a64fad7506c", - "total_cost_usd": 0.5377985000000001, - "usage": { - "input_tokens": 8, - "cache_creation_input_tokens": 8338, - "cache_read_input_tokens": 450157, - "output_tokens": 9172, - "output_tokens_details": { - "thinking_tokens": 4034 - }, - "server_tool_use": { - "web_search_requests": 0, - "web_fetch_requests": 0 - }, - "service_tier": "standard", - "cache_creation": { - "ephemeral_1h_input_tokens": 8338, - "ephemeral_5m_input_tokens": 0 - }, - "inference_geo": "not_available", - "iterations": [ - { - "input_tokens": 2, - "output_tokens": 3787, - "cache_read_input_tokens": 114840, - "cache_creation_input_tokens": 4299, - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 4299 - }, - "type": "message" - } - ], - "speed": "standard" - }, - "modelUsage": { - "claude-opus-5[1m]": { - "inputTokens": 8, - "outputTokens": 9172, - "cacheReadInputTokens": 450157, - "cacheCreationInputTokens": 8338, - "webSearchRequests": 0, - "costUSD": 0.5377985000000001, - "contextWindow": 1000000, - "maxOutputTokens": 64000, - "canonicalModel": "«redacted»", - "provider": "firstParty" - } - }, - "permission_denials": [], - "terminal_reason": "«redacted»", - "fast_mode_state": "«redacted»", - "fast_mode_disabled_reason": "«redacted»", - "subtype": "success", - "api_error_status": 429, - "result": "You've hit your session limit · resets 1:50pm (America/Toronto)", - "type": "result", - "duration_ms": 2264386, - "uuid": "c4c977a9-b5ad-400a-9e90-fbc931bb93c4" - } - }, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 51606, - "observedAt": "2026-08-17T14:07:49.820Z", - "sourceLine": 1400, - "type": "turn.completed", - "event": { - "type": "turn.completed", - "eventId": "bc70da4c-c9fe-45f1-b595-d5dced409d67", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:07:49.819Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "turnId": "bba86e8d-29df-4e63-a74b-9df18861c238", - "payload": { - "state": "«redacted»", - "stopReason": "«redacted»", - "usage": { - "input_tokens": 8, - "cache_creation_input_tokens": 8338, - "cache_read_input_tokens": 450157, - "output_tokens": 9172, - "output_tokens_details": { - "thinking_tokens": 4034 - }, - "server_tool_use": { - "web_search_requests": 0, - "web_fetch_requests": 0 - }, - "service_tier": "standard", - "cache_creation": { - "ephemeral_1h_input_tokens": 8338, - "ephemeral_5m_input_tokens": 0 - }, - "inference_geo": "not_available", - "iterations": [ - { - "input_tokens": 2, - "output_tokens": 3787, - "cache_read_input_tokens": 114840, - "cache_creation_input_tokens": 4299, - "cache_creation": { - "ephemeral_5m_input_tokens": 0, - "ephemeral_1h_input_tokens": 4299 - }, - "type": "message" - } - ], - "speed": "standard" - }, - "modelUsage": { - "claude-opus-5[1m]": { - "inputTokens": 8, - "outputTokens": 9172, - "cacheReadInputTokens": 450157, - "cacheCreationInputTokens": 8338, - "webSearchRequests": 0, - "costUSD": 0.5377985000000001, - "contextWindow": 1000000, - "maxOutputTokens": 64000, - "canonicalModel": "«redacted»", - "provider": "firstParty" - } - }, - "totalCostUsd": 0.5377985000000001 - }, - "providerRefs": {}, - "providerInstanceId": "claudeAgent" - } - }, - { - "offsetMs": 247957, - "observedAt": "2026-08-17T14:11:06.171Z", - "sourceLine": 1401, - "type": "session.exited", - "event": { - "type": "session.exited", - "eventId": "40378382-32a7-43fe-8dc9-be1eb8c2ab75", - "provider": "claudeAgent", - "createdAt": "2026-08-17T14:11:06.167Z", - "threadId": "43059392-62af-40b4-b341-aeaa35b257d9", - "payload": { - "reason": "Session stopped", - "exitKind": "graceful" - }, - "providerRefs": {}, - "providerInstanceId": "claudeAgent" - } - } - ] -} diff --git a/coil-parked/apps/server/src/coil/autoResume/replay/incidentDoomedTurn.test.ts b/coil-parked/apps/server/src/coil/autoResume/replay/incidentDoomedTurn.test.ts deleted file mode 100644 index 7e90a9213b19..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/replay/incidentDoomedTurn.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Regression: a doomed turn must not destroy a pending resume. - * - * Reconstructed from a real local incident on 2026-08-18 (thread - * 1a2d462e-2f4f-468f-ad37-285ef5095f3c), where three threads were rejected by the same - * five_hour window within 32 seconds of each other and only two came back. - * - * Ground truth from the projection and the reactor's own timeline notes: - * - * 19:24:13 previous turn f0d77468 completes; thread goes idle - * 19:33:00 five_hour REJECTED, resetsAt 19:50:00 - * 19:33:07 coil.auto-resume.scheduled — "Auto-resume scheduled in ~18 min" - * 19:34:44 turn 64f7c4b7 requested AND completed, 508ms apart — a turn burned into - * the still-closed window, producing nothing - * 19:51:34 coil.auto-resume.cancelled — "thread-advanced" <-- the defect - * (never resumes; firedAtMs stays empty) - * - * The two sibling threads were mid-turn when the limit landed, so their baseline pinned a - * turn that was still the latest at fire time. Nothing advanced, and both resumed at - * 19:51:42 and 19:51:47. The only difference in the failing thread is the doomed turn. - * - * Why the guard misreads it: `cancelReason` treats "a different, non-null latest turn id" - * as positive evidence the thread moved on (guards.ts:161-164). A turn that ran for half a - * second against a closed limit and produced nothing is not the thread moving on — it is - * the same blockage, observed again. This is the third instance of that class after #6 and - * #39: a guard meant to detect a human taking the wheel, firing on ordinary machine - * activity. - * - * NOTE FOR ANYONE FIXING THIS: do not edit this file or `reactorHarness.ts`. The fix - * belongs in the feature (guards.ts / Reactor.ts / state.ts). This test encodes observed - * production behaviour; changing it to match the code would erase the evidence. - */ - -import type { ProviderRuntimeEvent } from "@t3tools/contracts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as TestClock from "effect/testing/TestClock"; - -import { AutoResumeReactorLive } from "../Reactor.ts"; -import { - appendedActivityKinds, - appendedActivitySummaries, - dispatchedTypes, - makeReplayHarness, - msToIso, - readModel, - REPLAY_THREAD_ID, - settleQuiet, - settleUntil, - threadRow, -} from "./reactorHarness.ts"; - -/** The rejection shape the incident carried, verbatim from the provider log. */ -const rejection = (resetsAtSeconds: number): ProviderRuntimeEvent => - ({ - type: "runtime.warning", - eventId: "evt-five-hour-rejected", - provider: "claudeAgent", - threadId: REPLAY_THREAD_ID, - createdAt: "2026-08-18T19:33:00.698Z", - payload: { - message: "Claude usage limit reached", - detail: { - status: "rejected", - resetsAt: resetsAtSeconds, - rateLimitType: "five_hour", - overageStatus: "rejected", - overageDisabledReason: "org_level_disabled", - isUsingOverage: false, - }, - }, - }) as unknown as ProviderRuntimeEvent; - -// Reset at 100s ⇒ resume due at 160s (60s safety margin). The incident's real interval was -// 17 minutes; only the ordering matters here. -const RESET_SECONDS = 100; - -/** - * The thread as it looked when the limit landed: idle, with the id of the turn that - * finished nine minutes earlier still sitting in `latest_turn_id`. - * - * That the projection keeps a COMPLETED turn as `latest_turn_id` is itself observed from - * the incident: turn 64f7c4b7 completed at 19:34:44 and is still the row's latest_turn_id. - */ -const idleAfterCompletedTurn = () => - threadRow({ sessionStatus: "ready", latestTurn: { turnId: "turn-old", state: "completed" } }); - -// Reset 100s ⇒ resume due at 160s. The incident's doomed turn completed 16 minutes BEFORE -// its arm was due (19:34:44 vs 19:51:00); scaled here to 104s, inside the shut window. -const DOOMED_COMPLETED_AT_MS = 104_000; -// A turn finished after the window reopened — a real human takeover, which must still cancel. -const TAKEOVER_COMPLETED_AT_MS = 200_000; - -/** - * The same thread after a turn burned into the closed window and died in half a second. - * - * `completedAt` is load-bearing: the guard compares it against the arm's `resumeAtMs`. - * Omitting it would exercise only the no-evidence fallback and the scenario would pass no - * matter which way the comparison ran — verified by mutation probe. - */ -const afterDoomedTurn = () => - threadRow({ - sessionStatus: "ready", - latestTurn: { - turnId: "turn-doomed", - state: "completed", - completedAt: msToIso(DOOMED_COMPLETED_AT_MS), - }, - }); - -const advanceSteps = (steps: number) => - Effect.gen(function* () { - for (let step = 0; step < steps; step++) { - yield* TestClock.adjust(Duration.millis(30_000)); - yield* settleQuiet; - } - }); - -describe("incident 2026-08-18: doomed turn destroys the pending resume", () => { - it.effect("resumes even though a doomed turn advanced the latest turn id", () => - Effect.gen(function* () { - const harness = yield* makeReplayHarness({ - events: [rejection(RESET_SECONDS)], - initialModel: readModel([idleAfterCompletedTurn()]), - }); - - yield* Effect.gen(function* () { - yield* settleUntil( - harness.store.listPending.pipe(Effect.map((pending) => pending.length === 1)), - "the rejection to arm a resume", - ); - - // 19:34:44 — a turn is requested and completes 508ms later, against a window that - // does not reopen for another 15 minutes. It produced nothing; all it did was - // become the latest turn. - yield* Ref.set(harness.modelRef, readModel([afterDoomedTurn()])); - - // 19:51:00 — the window has reopened and the resume is due. - yield* advanceSteps(10); - // The clock is where it needs to be; now wait for the verdict rather than assuming - // ten quiet spins were enough for it to land (see `settleUntil`). - yield* settleUntil( - Ref.get(harness.dispatched).pipe( - Effect.map((commands) => - appendedActivityKinds(commands).includes("coil.auto-resume.resumed"), - ), - ), - "the reopened window to fire the resume", - ); - - const commands = yield* Ref.get(harness.dispatched); - - // What production did: cancelled as "thread-advanced", pending cleared, no resume. - expect(appendedActivitySummaries(commands)).not.toContain( - "Auto-resume cancelled: thread-advanced.", - ); - expect(appendedActivityKinds(commands)).not.toContain("coil.auto-resume.cancelled"); - - // What it must do instead: fire. The thread is idle, the window is open, and - // nothing about a 508ms failed turn says a human took over. - expect(dispatchedTypes(commands)).toContain("thread.turn.start"); - expect(appendedActivityKinds(commands)).toContain("coil.auto-resume.resumed"); - expect(yield* harness.store.countFiredSince(REPLAY_THREAD_ID, 0)).toBe(1); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(harness.deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - it.effect("CONTROL: a turn completed AFTER the window reopened still cancels as takeover", () => - Effect.gen(function* () { - // The other side of the rule, and the reason it is a comparison rather than a blanket - // exemption. If the arm fires here it would steamroll work a human really did after - // the limit lifted. Without this case the corpus cannot tell a correct comparison - // from an inverted one. - const harness = yield* makeReplayHarness({ - events: [rejection(RESET_SECONDS)], - initialModel: readModel([idleAfterCompletedTurn()]), - }); - - yield* Effect.gen(function* () { - yield* settleUntil( - harness.store.listPending.pipe(Effect.map((pending) => pending.length === 1)), - "the rejection to arm a resume", - ); - - yield* Ref.set( - harness.modelRef, - readModel([ - threadRow({ - sessionStatus: "ready", - latestTurn: { - turnId: "turn-human", - state: "completed", - completedAt: msToIso(TAKEOVER_COMPLETED_AT_MS), - }, - }), - ]), - ); - yield* advanceSteps(10); - // Same rule as above: the cancellation is something that must APPEAR, so wait for it. - // A fixed spin looked too early on a loaded runner (PR #143's CI run 34734274701). - yield* settleUntil( - Ref.get(harness.dispatched).pipe( - Effect.map((commands) => - appendedActivityKinds(commands).includes("coil.auto-resume.cancelled"), - ), - ), - "the takeover to cancel the pending resume", - ); - - const commands = yield* Ref.get(harness.dispatched); - expect(appendedActivitySummaries(commands)).toContain( - "Auto-resume cancelled: thread-advanced.", - ); - expect(dispatchedTypes(commands)).not.toContain("thread.turn.start"); - expect(yield* harness.store.countFiredSince(REPLAY_THREAD_ID, 0)).toBe(0); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(harness.deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); - - it.effect("CONTROL: the sibling threads' shape still resumes (mid-turn when limited)", () => - Effect.gen(function* () { - // 74b246f8 and 7245dfbe: rejected while a turn was running, that turn stayed latest, - // both resumed. This must keep passing after any fix — it is the case that works. - const harness = yield* makeReplayHarness({ - events: [rejection(RESET_SECONDS)], - initialModel: readModel([ - threadRow({ - sessionStatus: "running", - latestTurn: { turnId: "turn-live", state: "running" }, - }), - ]), - }); - - yield* Effect.gen(function* () { - yield* settleUntil( - harness.store.listPending.pipe(Effect.map((pending) => pending.length === 1)), - "the rejection to arm a resume", - ); - - // The limited turn settles; its id remains the latest. - yield* Ref.set( - harness.modelRef, - readModel([ - threadRow({ - sessionStatus: "ready", - latestTurn: { turnId: "turn-live", state: "completed" }, - }), - ]), - ); - yield* advanceSteps(10); - - expect(dispatchedTypes(yield* Ref.get(harness.dispatched))).toContain("thread.turn.start"); - expect(yield* harness.store.countFiredSince(REPLAY_THREAD_ID, 0)).toBe(1); - }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(harness.deps)))); - }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), - ); -}); diff --git a/coil-parked/apps/server/src/coil/autoResume/replay/providerLog.test.ts b/coil-parked/apps/server/src/coil/autoResume/replay/providerLog.test.ts deleted file mode 100644 index e5e659add217..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/replay/providerLog.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - bodyType, - canonicalRuntimeEvent, - nativeSdkMessage, - parseProviderLog, - parseProviderLogLine, - TRANSIENT_CANONICAL_EVENT_TYPES, -} from "./providerLog.ts"; - -// Both fixtures are byte-faithful reductions of lines observed in a real log on this -// machine (~/.t3/userdata/logs/provider/events.16cca1b0-…log). Field order, nesting and -// the `NTIVE`/`CANON` prefixes are copied from the file, not invented — the parser exists -// to read that exact format, so drifting from it would make these tests vacuous. -const NATIVE_LINE = - '[2026-08-10T22:35:41.614Z] NTIVE: {"observedAt":"2026-08-10T22:35:41.614Z","event":' + - '{"id":"25e7bbc0-42be-468a-b96a-9c276e968a60","kind":"notification","provider":"claudeAgent",' + - '"createdAt":"2026-08-10T22:35:41.614Z","method":"claude/rate_limit_event",' + - '"providerThreadId":"e3bfdbb3-2abf-4f89-8ddd-164bc1b23b05",' + - '"turnId":"00cc6485-0247-4c0e-9b0b-fb6b88ba88eb","payload":{"type":"rate_limit_event",' + - '"rate_limit_info":{"status":"allowed","resetsAt":1786402800,"rateLimitType":"five_hour",' + - '"overageStatus":"rejected","overageDisabledReason":"org_level_disabled_until",' + - '"isUsingOverage":false},"uuid":"25e7bbc0-42be-468a-b96a-9c276e968a60",' + - '"session_id":"e3bfdbb3-2abf-4f89-8ddd-164bc1b23b05"}}}'; - -const CANONICAL_LINE = - '[2026-08-10T23:12:41.841Z] CANON: {"type":"session.exited",' + - '"eventId":"5fc601a3-ef7e-4efa-afd4-da4657aa1e3a","provider":"claudeAgent",' + - '"createdAt":"2026-08-10T23:12:41.839Z","threadId":"16cca1b0-8132-4649-8021-ff46779aa5d7",' + - '"payload":{"reason":"Session stopped","exitKind":"graceful"},"providerRefs":{},' + - '"providerInstanceId":"claudeAgent"}'; - -describe("parseProviderLogLine", () => { - it("reads a native line and exposes the raw SDK message at event.payload", () => { - const entry = parseProviderLogLine(NATIVE_LINE, 1); - expect(entry).toMatchObject({ - lineNumber: 1, - observedAt: "2026-08-10T22:35:41.614Z", - stream: "native", - }); - - const message = nativeSdkMessage(entry as never); - expect(bodyType(message)).toBe("rate_limit_event"); - // The whole point of replaying natives: the overage fields survive intact. - expect(message).toMatchObject({ - rate_limit_info: { - status: "allowed", - overageStatus: "rejected", - overageDisabledReason: "org_level_disabled_until", - rateLimitType: "five_hour", - resetsAt: 1_786_402_800, - }, - }); - }); - - it("reads a canonical line as a ProviderRuntimeEvent with no envelope", () => { - const entry = parseProviderLogLine(CANONICAL_LINE, 7); - expect(entry).toMatchObject({ lineNumber: 7, stream: "canonical" }); - - const event = canonicalRuntimeEvent(entry as never); - expect(bodyType(event)).toBe("session.exited"); - expect(event).toMatchObject({ - threadId: "16cca1b0-8132-4649-8021-ff46779aa5d7", - payload: { exitKind: "graceful", reason: "Session stopped" }, - }); - }); - - it("recognizes the orchestration label", () => { - const entry = parseProviderLogLine('[2026-08-10T22:35:41.614Z] ORCH: {"type":"x"}', 1); - expect(entry).toMatchObject({ stream: "orchestration" }); - }); - - it("reports rather than throws on the ways a rotated log breaks", () => { - // A crash or rotation can sever a line mid-JSON; a stray line can carry no prefix at - // all. Either must degrade to a report, because a capture tool that throws on one bad - // line cannot read the log from the incident that motivated it. - expect(parseProviderLogLine('[2026-08-10T22:35:41.614Z] NTIVE: {"trunc', 3)).toEqual({ - lineNumber: 3, - reason: "bad-json", - }); - expect(parseProviderLogLine("not a log line at all", 4)).toEqual({ - lineNumber: 4, - reason: "no-prefix", - }); - expect(parseProviderLogLine("[2026-08-10T22:35:41.614Z] WAT: {}", 5)).toEqual({ - lineNumber: 5, - reason: "unknown-label", - }); - }); -}); - -describe("parseProviderLog", () => { - it("keeps good entries, reports bad ones, and numbers lines as the file does", () => { - const contents = [NATIVE_LINE, "", "garbage", CANONICAL_LINE].join("\n"); - const result = parseProviderLog(contents); - - expect(result.entries.map((e) => e.stream)).toEqual(["native", "canonical"]); - // Blank lines are skipped without consuming a number: garbage is line 3, CANON line 4. - expect(result.malformed).toEqual([{ lineNumber: 3, reason: "no-prefix" }]); - expect(result.entries[1]?.lineNumber).toBe(4); - }); - - it("tolerates a file with no trailing newline and an empty file", () => { - expect(parseProviderLog(NATIVE_LINE).entries).toHaveLength(1); - expect(parseProviderLog("")).toEqual({ entries: [], malformed: [] }); - }); -}); - -describe("TRANSIENT_CANONICAL_EVENT_TYPES", () => { - it("mirrors the upstream drop list so a differential replay excludes the right types", () => { - // Kept in lockstep with transientCanonicalEventTypes (EventNdjsonLogger.ts:39-47). If - // upstream adds a type here, a replay diff would otherwise report it as "the adapter - // produced an event the capture lacks" — a false positive on every capture. - expect([...TRANSIENT_CANONICAL_EVENT_TYPES].sort()).toEqual([ - "content.delta", - "hook.progress", - "item.updated", - "task.progress", - "thread.realtime.audio.delta", - "tool.progress", - "turn.proposed.delta", - ]); - }); - - it("does not drop any event auto-resume depends on", () => { - for (const type of ["runtime.warning", "session.exited", "turn.completed"]) { - expect(TRANSIENT_CANONICAL_EVENT_TYPES.has(type)).toBe(false); - } - }); -}); diff --git a/coil-parked/apps/server/src/coil/autoResume/replay/providerLog.ts b/coil-parked/apps/server/src/coil/autoResume/replay/providerLog.ts deleted file mode 100644 index b40481322c04..000000000000 --- a/coil-parked/apps/server/src/coil/autoResume/replay/providerLog.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Reader for the on-disk provider event log. - * - * `EventNdjsonLogger` writes one line per event as - * - * `[]