agent-tempo 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +213 -0
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/icon-dark-32.png +0 -0
- package/assets/icon-dark-64.png +0 -0
- package/assets/icon-dark.svg +9 -0
- package/assets/icon.svg +9 -0
- package/assets/logo-dark.svg +11 -0
- package/assets/logo-light.svg +11 -0
- package/dashboard/README.md +91 -0
- package/dashboard/dist/assets/index-CB78ToNE.css +2 -0
- package/dashboard/dist/assets/index-_5jV0Znu.js +62 -0
- package/dashboard/dist/assets/index-_5jV0Znu.js.map +1 -0
- package/dashboard/dist/index.html +21 -0
- package/dashboard/package.json +47 -0
- package/dist/activities/hard-terminate.d.ts +32 -0
- package/dist/activities/hard-terminate.js +460 -0
- package/dist/activities/maestro.d.ts +72 -0
- package/dist/activities/maestro.js +254 -0
- package/dist/activities/outbox.d.ts +188 -0
- package/dist/activities/outbox.js +849 -0
- package/dist/activities/resolve.d.ts +64 -0
- package/dist/activities/resolve.js +129 -0
- package/dist/activities/schedule-fire.d.ts +36 -0
- package/dist/activities/schedule-fire.js +147 -0
- package/dist/adapters/base.d.ts +426 -0
- package/dist/adapters/base.js +1270 -0
- package/dist/adapters/claude-api/adapter.d.ts +168 -0
- package/dist/adapters/claude-api/adapter.js +797 -0
- package/dist/adapters/claude-api/api-error.d.ts +96 -0
- package/dist/adapters/claude-api/api-error.js +191 -0
- package/dist/adapters/claude-api/index.d.ts +16 -0
- package/dist/adapters/claude-api/index.js +21 -0
- package/dist/adapters/claude-api/mcp-bridge.d.ts +50 -0
- package/dist/adapters/claude-api/mcp-bridge.js +157 -0
- package/dist/adapters/claude-code/adapter.d.ts +133 -0
- package/dist/adapters/claude-code/adapter.js +274 -0
- package/dist/adapters/claude-code/index.d.ts +15 -0
- package/dist/adapters/claude-code/index.js +20 -0
- package/dist/adapters/claude-code-headless/adapter.d.ts +131 -0
- package/dist/adapters/claude-code-headless/adapter.js +710 -0
- package/dist/adapters/claude-code-headless/error-mapper.d.ts +107 -0
- package/dist/adapters/claude-code-headless/error-mapper.js +281 -0
- package/dist/adapters/claude-code-headless/index.d.ts +17 -0
- package/dist/adapters/claude-code-headless/index.js +26 -0
- package/dist/adapters/claude-code-headless/pre-flight.d.ts +51 -0
- package/dist/adapters/claude-code-headless/pre-flight.js +207 -0
- package/dist/adapters/claude-code-headless/prompt.d.ts +93 -0
- package/dist/adapters/claude-code-headless/prompt.js +79 -0
- package/dist/adapters/claude-code-headless/stream-json.d.ts +242 -0
- package/dist/adapters/claude-code-headless/stream-json.js +208 -0
- package/dist/adapters/claude-code-headless/types.d.ts +28 -0
- package/dist/adapters/claude-code-headless/types.js +36 -0
- package/dist/adapters/copilot/adapter.d.ts +100 -0
- package/dist/adapters/copilot/adapter.js +730 -0
- package/dist/adapters/copilot/index.d.ts +15 -0
- package/dist/adapters/copilot/index.js +20 -0
- package/dist/adapters/index.d.ts +42 -0
- package/dist/adapters/index.js +115 -0
- package/dist/adapters/opencode/adapter.d.ts +82 -0
- package/dist/adapters/opencode/adapter.js +710 -0
- package/dist/adapters/opencode/config.d.ts +90 -0
- package/dist/adapters/opencode/config.js +137 -0
- package/dist/adapters/opencode/helpers.d.ts +40 -0
- package/dist/adapters/opencode/helpers.js +144 -0
- package/dist/adapters/opencode/index.d.ts +12 -0
- package/dist/adapters/opencode/index.js +17 -0
- package/dist/adapters/opencode/server-bridge.d.ts +124 -0
- package/dist/adapters/opencode/server-bridge.js +216 -0
- package/dist/adapters/sdk/base.d.ts +95 -0
- package/dist/adapters/sdk/base.js +134 -0
- package/dist/adapters/sdk/system-prompt.d.ts +64 -0
- package/dist/adapters/sdk/system-prompt.js +78 -0
- package/dist/adapters/terminal-error.d.ts +27 -0
- package/dist/adapters/terminal-error.js +39 -0
- package/dist/channel.d.ts +3 -0
- package/dist/channel.js +48 -0
- package/dist/cli/commands.d.ts +245 -0
- package/dist/cli/commands.js +2438 -0
- package/dist/cli/config-command.d.ts +8 -0
- package/dist/cli/config-command.js +254 -0
- package/dist/cli/daemon-command.d.ts +57 -0
- package/dist/cli/daemon-command.js +493 -0
- package/dist/cli/daemon.d.ts +217 -0
- package/dist/cli/daemon.js +632 -0
- package/dist/cli/dashboard-command.d.ts +20 -0
- package/dist/cli/dashboard-command.js +241 -0
- package/dist/cli/dev-banner.d.ts +107 -0
- package/dist/cli/dev-banner.js +190 -0
- package/dist/cli/dev-mode-bootstrap.d.ts +29 -0
- package/dist/cli/dev-mode-bootstrap.js +36 -0
- package/dist/cli/dev-verbs.d.ts +43 -0
- package/dist/cli/dev-verbs.js +254 -0
- package/dist/cli/help-text.d.ts +1 -0
- package/dist/cli/help-text.js +158 -0
- package/dist/cli/legacy-migration.d.ts +35 -0
- package/dist/cli/legacy-migration.js +335 -0
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +63 -0
- package/dist/cli/output.d.ts +12 -0
- package/dist/cli/output.js +37 -0
- package/dist/cli/preflight.d.ts +9 -0
- package/dist/cli/preflight.js +96 -0
- package/dist/cli/removed-verbs.d.ts +9 -0
- package/dist/cli/removed-verbs.js +78 -0
- package/dist/cli/sa-preflight.d.ts +99 -0
- package/dist/cli/sa-preflight.js +183 -0
- package/dist/cli/scenarios-command.d.ts +6 -0
- package/dist/cli/scenarios-command.js +167 -0
- package/dist/cli/startup.d.ts +112 -0
- package/dist/cli/startup.js +641 -0
- package/dist/cli/upgrade-command.d.ts +5 -0
- package/dist/cli/upgrade-command.js +240 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +680 -0
- package/dist/client/core.d.ts +33 -0
- package/dist/client/core.js +1260 -0
- package/dist/client/ensure-conductor-spawned.d.ts +35 -0
- package/dist/client/ensure-conductor-spawned.js +48 -0
- package/dist/client/index.d.ts +32 -0
- package/dist/client/index.js +22 -0
- package/dist/client/interface.d.ts +461 -0
- package/dist/client/interface.js +2 -0
- package/dist/client/subscribe.d.ts +108 -0
- package/dist/client/subscribe.js +598 -0
- package/dist/client/with-spawn.d.ts +27 -0
- package/dist/client/with-spawn.js +87 -0
- package/dist/config.d.ts +323 -0
- package/dist/config.js +593 -0
- package/dist/connection.d.ts +7 -0
- package/dist/connection.js +46 -0
- package/dist/constants.d.ts +50 -0
- package/dist/constants.js +74 -0
- package/dist/copilot-bridge.d.ts +22 -0
- package/dist/copilot-bridge.js +565 -0
- package/dist/daemon-adapter-versions.d.ts +52 -0
- package/dist/daemon-adapter-versions.js +170 -0
- package/dist/daemon.d.ts +275 -0
- package/dist/daemon.js +989 -0
- package/dist/ensemble/agent-types.d.ts +23 -0
- package/dist/ensemble/agent-types.js +132 -0
- package/dist/ensemble/loader.d.ts +14 -0
- package/dist/ensemble/loader.js +140 -0
- package/dist/ensemble/saver.d.ts +49 -0
- package/dist/ensemble/saver.js +201 -0
- package/dist/ensemble/schema.d.ts +71 -0
- package/dist/ensemble/schema.js +3 -0
- package/dist/git-info.d.ts +4 -0
- package/dist/git-info.js +29 -0
- package/dist/http/aggregate.d.ts +319 -0
- package/dist/http/aggregate.js +684 -0
- package/dist/http/auth.d.ts +67 -0
- package/dist/http/auth.js +177 -0
- package/dist/http/body.d.ts +71 -0
- package/dist/http/body.js +121 -0
- package/dist/http/catalog.d.ts +67 -0
- package/dist/http/catalog.js +209 -0
- package/dist/http/cors.d.ts +42 -0
- package/dist/http/cors.js +111 -0
- package/dist/http/dashboard-pair.d.ts +94 -0
- package/dist/http/dashboard-pair.js +148 -0
- package/dist/http/dashboard.d.ts +20 -0
- package/dist/http/dashboard.js +160 -0
- package/dist/http/event-bus.d.ts +217 -0
- package/dist/http/event-bus.js +365 -0
- package/dist/http/event-id.d.ts +77 -0
- package/dist/http/event-id.js +117 -0
- package/dist/http/event-types.d.ts +348 -0
- package/dist/http/event-types.js +36 -0
- package/dist/http/fixtures/chat-stress.d.ts +8 -0
- package/dist/http/fixtures/chat-stress.js +63 -0
- package/dist/http/fixtures/conductor-leaving.d.ts +8 -0
- package/dist/http/fixtures/conductor-leaving.js +80 -0
- package/dist/http/fixtures/constants.d.ts +10 -0
- package/dist/http/fixtures/constants.js +13 -0
- package/dist/http/fixtures/eight-player-broadcast.d.ts +10 -0
- package/dist/http/fixtures/eight-player-broadcast.js +81 -0
- package/dist/http/fixtures/empty-ensemble.d.ts +6 -0
- package/dist/http/fixtures/empty-ensemble.js +26 -0
- package/dist/http/fixtures/index.d.ts +55 -0
- package/dist/http/fixtures/index.js +110 -0
- package/dist/http/fixtures/single-conductor.d.ts +7 -0
- package/dist/http/fixtures/single-conductor.js +46 -0
- package/dist/http/fixtures/sse-reconnect.d.ts +8 -0
- package/dist/http/fixtures/sse-reconnect.js +77 -0
- package/dist/http/index.d.ts +21 -0
- package/dist/http/index.js +61 -0
- package/dist/http/port-file.d.ts +22 -0
- package/dist/http/port-file.js +132 -0
- package/dist/http/responses.d.ts +27 -0
- package/dist/http/responses.js +40 -0
- package/dist/http/ring-buffer.d.ts +41 -0
- package/dist/http/ring-buffer.js +80 -0
- package/dist/http/server.d.ts +122 -0
- package/dist/http/server.js +459 -0
- package/dist/http/snapshot.d.ts +85 -0
- package/dist/http/snapshot.js +180 -0
- package/dist/http/sse-handler.d.ts +87 -0
- package/dist/http/sse-handler.js +294 -0
- package/dist/http/writes.d.ts +55 -0
- package/dist/http/writes.js +240 -0
- package/dist/palette/index.d.ts +138 -0
- package/dist/palette/index.js +221 -0
- package/dist/reconcile/orphans.d.ts +255 -0
- package/dist/reconcile/orphans.js +340 -0
- package/dist/scripts/258-spotcheck.js +303 -0
- package/dist/scripts/check-components-css-sync.js +199 -0
- package/dist/scripts/run-shard.js +121 -0
- package/dist/scripts/verify-daemon-isolation-guard.js +128 -0
- package/dist/server-tools.d.ts +87 -0
- package/dist/server-tools.js +146 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +366 -0
- package/dist/spawn.d.ts +296 -0
- package/dist/spawn.js +747 -0
- package/dist/tools/agent-types.d.ts +2 -0
- package/dist/tools/agent-types.js +21 -0
- package/dist/tools/attachment-info.d.ts +4 -0
- package/dist/tools/attachment-info.js +48 -0
- package/dist/tools/broadcast.d.ts +4 -0
- package/dist/tools/broadcast.js +76 -0
- package/dist/tools/cancel-stage.d.ts +3 -0
- package/dist/tools/cancel-stage.js +20 -0
- package/dist/tools/clear-state.d.ts +3 -0
- package/dist/tools/clear-state.js +37 -0
- package/dist/tools/coat-check-evict.d.ts +4 -0
- package/dist/tools/coat-check-evict.js +43 -0
- package/dist/tools/coat-check-get.d.ts +4 -0
- package/dist/tools/coat-check-get.js +56 -0
- package/dist/tools/coat-check-list.d.ts +4 -0
- package/dist/tools/coat-check-list.js +60 -0
- package/dist/tools/coat-check-put.d.ts +4 -0
- package/dist/tools/coat-check-put.js +53 -0
- package/dist/tools/cue.d.ts +44 -0
- package/dist/tools/cue.js +201 -0
- package/dist/tools/destroy.d.ts +4 -0
- package/dist/tools/destroy.js +188 -0
- package/dist/tools/detach.d.ts +4 -0
- package/dist/tools/detach.js +45 -0
- package/dist/tools/encore.d.ts +4 -0
- package/dist/tools/encore.js +31 -0
- package/dist/tools/ensemble.d.ts +32 -0
- package/dist/tools/ensemble.js +198 -0
- package/dist/tools/evaluate-gate.d.ts +3 -0
- package/dist/tools/evaluate-gate.js +32 -0
- package/dist/tools/fetch-state.d.ts +13 -0
- package/dist/tools/fetch-state.js +78 -0
- package/dist/tools/gates.d.ts +3 -0
- package/dist/tools/gates.js +41 -0
- package/dist/tools/helpers.d.ts +21 -0
- package/dist/tools/helpers.js +25 -0
- package/dist/tools/hosts.d.ts +4 -0
- package/dist/tools/hosts.js +40 -0
- package/dist/tools/listen.d.ts +3 -0
- package/dist/tools/listen.js +22 -0
- package/dist/tools/load-lineup.d.ts +5 -0
- package/dist/tools/load-lineup.js +381 -0
- package/dist/tools/migrate.d.ts +4 -0
- package/dist/tools/migrate.js +60 -0
- package/dist/tools/pause-ensemble.d.ts +4 -0
- package/dist/tools/pause-ensemble.js +58 -0
- package/dist/tools/pause.d.ts +4 -0
- package/dist/tools/pause.js +36 -0
- package/dist/tools/play.d.ts +4 -0
- package/dist/tools/play.js +57 -0
- package/dist/tools/quality-gate.d.ts +3 -0
- package/dist/tools/quality-gate.js +26 -0
- package/dist/tools/recall.d.ts +3 -0
- package/dist/tools/recall.js +32 -0
- package/dist/tools/recruit.d.ts +38 -0
- package/dist/tools/recruit.js +447 -0
- package/dist/tools/release.d.ts +4 -0
- package/dist/tools/release.js +98 -0
- package/dist/tools/report.d.ts +3 -0
- package/dist/tools/report.js +29 -0
- package/dist/tools/resolve.d.ts +1 -0
- package/dist/tools/resolve.js +7 -0
- package/dist/tools/restart.d.ts +35 -0
- package/dist/tools/restart.js +131 -0
- package/dist/tools/restore.d.ts +4 -0
- package/dist/tools/restore.js +107 -0
- package/dist/tools/resume-ensemble.d.ts +4 -0
- package/dist/tools/resume-ensemble.js +79 -0
- package/dist/tools/save-lineup.d.ts +4 -0
- package/dist/tools/save-lineup.js +36 -0
- package/dist/tools/save-state.d.ts +3 -0
- package/dist/tools/save-state.js +57 -0
- package/dist/tools/schedule.d.ts +4 -0
- package/dist/tools/schedule.js +152 -0
- package/dist/tools/schedules.d.ts +4 -0
- package/dist/tools/schedules.js +54 -0
- package/dist/tools/set-ensemble-description.d.ts +4 -0
- package/dist/tools/set-ensemble-description.js +37 -0
- package/dist/tools/set-name.d.ts +4 -0
- package/dist/tools/set-name.js +45 -0
- package/dist/tools/set-part.d.ts +3 -0
- package/dist/tools/set-part.js +20 -0
- package/dist/tools/shutdown.d.ts +4 -0
- package/dist/tools/shutdown.js +54 -0
- package/dist/tools/stage.d.ts +3 -0
- package/dist/tools/stage.js +28 -0
- package/dist/tools/stages.d.ts +3 -0
- package/dist/tools/stages.js +35 -0
- package/dist/tools/stop.d.ts +4 -0
- package/dist/tools/stop.js +29 -0
- package/dist/tools/unschedule.d.ts +4 -0
- package/dist/tools/unschedule.js +35 -0
- package/dist/tools/who-am-i.d.ts +3 -0
- package/dist/tools/who-am-i.js +34 -0
- package/dist/tools/worktree.d.ts +4 -0
- package/dist/tools/worktree.js +181 -0
- package/dist/tui/App.d.ts +85 -0
- package/dist/tui/App.js +1791 -0
- package/dist/tui/bootstrap-types.d.ts +46 -0
- package/dist/tui/bootstrap-types.js +7 -0
- package/dist/tui/client.d.ts +6 -0
- package/dist/tui/client.js +9 -0
- package/dist/tui/commands.d.ts +71 -0
- package/dist/tui/commands.js +1375 -0
- package/dist/tui/components/ActivityLog.d.ts +16 -0
- package/dist/tui/components/ActivityLog.js +36 -0
- package/dist/tui/components/ChatView.d.ts +35 -0
- package/dist/tui/components/ChatView.js +54 -0
- package/dist/tui/components/CommandOverlay.d.ts +15 -0
- package/dist/tui/components/CommandOverlay.js +34 -0
- package/dist/tui/components/CommandPalette.d.ts +21 -0
- package/dist/tui/components/CommandPalette.js +67 -0
- package/dist/tui/components/ConductorChat.d.ts +16 -0
- package/dist/tui/components/ConductorChat.js +32 -0
- package/dist/tui/components/ConversationStream.d.ts +114 -0
- package/dist/tui/components/ConversationStream.js +307 -0
- package/dist/tui/components/CreateEnsembleWizard.d.ts +19 -0
- package/dist/tui/components/CreateEnsembleWizard.js +223 -0
- package/dist/tui/components/DestroyConfirmModal.d.ts +17 -0
- package/dist/tui/components/DestroyConfirmModal.js +62 -0
- package/dist/tui/components/EnsembleListView.d.ts +14 -0
- package/dist/tui/components/EnsembleListView.js +32 -0
- package/dist/tui/components/EnsemblePanel.d.ts +12 -0
- package/dist/tui/components/EnsemblePanel.js +40 -0
- package/dist/tui/components/ErrorView.d.ts +31 -0
- package/dist/tui/components/ErrorView.js +129 -0
- package/dist/tui/components/HomeView.d.ts +54 -0
- package/dist/tui/components/HomeView.js +306 -0
- package/dist/tui/components/InputBar.d.ts +13 -0
- package/dist/tui/components/InputBar.js +58 -0
- package/dist/tui/components/LoadLineupModal.d.ts +18 -0
- package/dist/tui/components/LoadLineupModal.js +79 -0
- package/dist/tui/components/MainView.d.ts +21 -0
- package/dist/tui/components/MainView.js +107 -0
- package/dist/tui/components/NewEnsembleModal.d.ts +9 -0
- package/dist/tui/components/NewEnsembleModal.js +73 -0
- package/dist/tui/components/Picker.d.ts +23 -0
- package/dist/tui/components/Picker.js +70 -0
- package/dist/tui/components/PlayerDetailView.d.ts +26 -0
- package/dist/tui/components/PlayerDetailView.js +118 -0
- package/dist/tui/components/PromptArea.d.ts +50 -0
- package/dist/tui/components/PromptArea.js +303 -0
- package/dist/tui/components/RecruitWizard.d.ts +17 -0
- package/dist/tui/components/RecruitWizard.js +221 -0
- package/dist/tui/components/RestoreConfirmModal.d.ts +18 -0
- package/dist/tui/components/RestoreConfirmModal.js +71 -0
- package/dist/tui/components/ScheduleOverlay.d.ts +13 -0
- package/dist/tui/components/ScheduleOverlay.js +113 -0
- package/dist/tui/components/ScheduleWizard.d.ts +19 -0
- package/dist/tui/components/ScheduleWizard.js +259 -0
- package/dist/tui/components/Splash.d.ts +23 -0
- package/dist/tui/components/Splash.js +221 -0
- package/dist/tui/components/StatusBar.d.ts +48 -0
- package/dist/tui/components/StatusBar.js +128 -0
- package/dist/tui/components/StatusOverlay.d.ts +15 -0
- package/dist/tui/components/StatusOverlay.js +76 -0
- package/dist/tui/components/TitleBar.d.ts +10 -0
- package/dist/tui/components/TitleBar.js +21 -0
- package/dist/tui/components/TopBar.d.ts +12 -0
- package/dist/tui/components/TopBar.js +15 -0
- package/dist/tui/core-api.d.ts +26 -0
- package/dist/tui/core-api.js +67 -0
- package/dist/tui/hooks/useEnsembleDiscovery.d.ts +3 -0
- package/dist/tui/hooks/useEnsembleDiscovery.js +30 -0
- package/dist/tui/hooks/useMaestroPoller.d.ts +3 -0
- package/dist/tui/hooks/useMaestroPoller.js +36 -0
- package/dist/tui/hooks/useSendCommand.d.ts +7 -0
- package/dist/tui/hooks/useSendCommand.js +29 -0
- package/dist/tui/index.d.ts +15 -0
- package/dist/tui/index.js +156 -0
- package/dist/tui/ink-context.d.ts +18 -0
- package/dist/tui/ink-context.js +59 -0
- package/dist/tui/ink-loader.d.ts +26 -0
- package/dist/tui/ink-loader.js +42 -0
- package/dist/tui/removed-commands.d.ts +9 -0
- package/dist/tui/removed-commands.js +22 -0
- package/dist/tui/sse-handler.d.ts +52 -0
- package/dist/tui/sse-handler.js +157 -0
- package/dist/tui/store.d.ts +598 -0
- package/dist/tui/store.js +753 -0
- package/dist/tui/utils/format.d.ts +56 -0
- package/dist/tui/utils/format.js +155 -0
- package/dist/tui/utils/fullscreen.d.ts +23 -0
- package/dist/tui/utils/fullscreen.js +71 -0
- package/dist/tui/utils/history.d.ts +10 -0
- package/dist/tui/utils/history.js +85 -0
- package/dist/tui/utils/platform.d.ts +45 -0
- package/dist/tui/utils/platform.js +258 -0
- package/dist/tui/utils/theme.d.ts +21 -0
- package/dist/tui/utils/theme.js +24 -0
- package/dist/types.d.ts +1020 -0
- package/dist/types.js +39 -0
- package/dist/utils/attachment-format.d.ts +22 -0
- package/dist/utils/attachment-format.js +32 -0
- package/dist/utils/default-part.d.ts +43 -0
- package/dist/utils/default-part.js +104 -0
- package/dist/utils/duration.d.ts +30 -0
- package/dist/utils/duration.js +69 -0
- package/dist/utils/ensemble-ops.d.ts +61 -0
- package/dist/utils/ensemble-ops.js +77 -0
- package/dist/utils/format-hosts.d.ts +21 -0
- package/dist/utils/format-hosts.js +73 -0
- package/dist/utils/hosts.d.ts +113 -0
- package/dist/utils/hosts.js +265 -0
- package/dist/utils/parent-death-watchdog.d.ts +1 -0
- package/dist/utils/parent-death-watchdog.js +47 -0
- package/dist/utils/query-timeout.d.ts +103 -0
- package/dist/utils/query-timeout.js +113 -0
- package/dist/utils/recall-format.d.ts +78 -0
- package/dist/utils/recall-format.js +105 -0
- package/dist/utils/restore-format.d.ts +49 -0
- package/dist/utils/restore-format.js +91 -0
- package/dist/utils/safe-path.d.ts +10 -0
- package/dist/utils/safe-path.js +43 -0
- package/dist/utils/sdk-probe.d.ts +9 -0
- package/dist/utils/sdk-probe.js +45 -0
- package/dist/utils/search-attributes.d.ts +76 -0
- package/dist/utils/search-attributes.js +86 -0
- package/dist/utils/validation.d.ts +113 -0
- package/dist/utils/validation.js +163 -0
- package/dist/utils/visibility-deadline.d.ts +186 -0
- package/dist/utils/visibility-deadline.js +158 -0
- package/dist/utils/worktree.d.ts +103 -0
- package/dist/utils/worktree.js +327 -0
- package/dist/worker.d.ts +14 -0
- package/dist/worker.js +146 -0
- package/dist/workflows/attachment-math.d.ts +56 -0
- package/dist/workflows/attachment-math.js +47 -0
- package/dist/workflows/index.d.ts +3 -0
- package/dist/workflows/index.js +11 -0
- package/dist/workflows/maestro-signals.d.ts +217 -0
- package/dist/workflows/maestro-signals.js +155 -0
- package/dist/workflows/maestro.d.ts +3 -0
- package/dist/workflows/maestro.js +812 -0
- package/dist/workflows/scheduler-signals.d.ts +10 -0
- package/dist/workflows/scheduler-signals.js +14 -0
- package/dist/workflows/scheduler.d.ts +17 -0
- package/dist/workflows/scheduler.js +143 -0
- package/dist/workflows/session.d.ts +2 -0
- package/dist/workflows/session.js +1638 -0
- package/dist/workflows/signals.d.ts +297 -0
- package/dist/workflows/signals.js +239 -0
- package/examples/agents/tempo-composer.md +56 -0
- package/examples/agents/tempo-conductor.md +117 -0
- package/examples/agents/tempo-critic.md +73 -0
- package/examples/agents/tempo-improv.md +74 -0
- package/examples/agents/tempo-liner.md +75 -0
- package/examples/agents/tempo-roadie.md +61 -0
- package/examples/agents/tempo-soloist.md +71 -0
- package/examples/agents/tempo-tuner.md +94 -0
- package/examples/ensembles/tempo-big-band.yaml +146 -0
- package/examples/ensembles/tempo-dev-team.yaml +58 -0
- package/examples/ensembles/tempo-headless-jam.yaml +77 -0
- package/examples/ensembles/tempo-jam-session.yaml +41 -0
- package/examples/ensembles/tempo-mock-jam.yaml +79 -0
- package/examples/ensembles/tempo-review-squad.yaml +32 -0
- package/package.json +172 -0
- package/packaging/launchd/com.agent.tempo.plist +46 -0
- package/packaging/systemd/agent-tempo.service +32 -0
- package/packaging/windows/install-task.ps1 +71 -0
- package/scenarios/conductor-recruit-mock.yaml +33 -0
- package/scenarios/echo-roundtrip.yaml +15 -0
- package/scenarios/multi-player-handoff.yaml +38 -0
- package/scenarios/recruit-cascade.yaml +38 -0
- package/scenarios/two-player-conversation.yaml +33 -0
- package/workflow-bundle.js +14146 -0
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AdapterRegistry = exports.BaseAttachment = void 0;
|
|
4
|
+
exports.buildProcessTerminatingFrame = buildProcessTerminatingFrame;
|
|
5
|
+
exports.installProcessLifecycleTelemetry = installProcessLifecycleTelemetry;
|
|
6
|
+
exports._resetProcessLifecycleTelemetryForTest = _resetProcessLifecycleTelemetryForTest;
|
|
7
|
+
exports._liveAdaptersForTest = _liveAdaptersForTest;
|
|
8
|
+
const signals_1 = require("../workflows/signals");
|
|
9
|
+
const terminal_error_1 = require("./terminal-error");
|
|
10
|
+
const log = (...args) => console.error('[agent-tempo:adapter]', ...args);
|
|
11
|
+
// ── Hypothesis A telemetry (#258 follow-up) ─────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// The structured `terminal fire` log shipped in #258 made the next
|
|
14
|
+
// adapter-silence incident self-describing — but only for cases where
|
|
15
|
+
// `fireTerminal` actually fires. Hypothesis A (process death — crash, OOM,
|
|
16
|
+
// Windows sleep, terminal close, SIGKILL) wouldn't produce that log
|
|
17
|
+
// because the process never reached the code path. The handlers below
|
|
18
|
+
// close that gap: a future #258 recurrence with no `fireTerminal` log AND
|
|
19
|
+
// no `adapter-process-terminating` log narrows to a distinct hypothesis
|
|
20
|
+
// (likely SIGKILL / abrupt OS termination — file separately).
|
|
21
|
+
//
|
|
22
|
+
// Design tenets:
|
|
23
|
+
// - **Idempotent registration**: a module-level boolean ensures multiple
|
|
24
|
+
// adapter instances spawning in the same process never double-register
|
|
25
|
+
// handlers. Repeated `installProcessLifecycleTelemetry()` calls no-op.
|
|
26
|
+
// - **Additive only**: every `process.on(...)` call appends; nothing
|
|
27
|
+
// calls `removeAllListeners`. Coexists with the test-cleanup chain in
|
|
28
|
+
// `test/helpers.ts` (#312) and the daemon's own SIGTERM/SIGINT
|
|
29
|
+
// shutdown function.
|
|
30
|
+
// - **Synchronous logging on terminal signals**: process termination
|
|
31
|
+
// doesn't await async log flushes. `console.error` to stderr is
|
|
32
|
+
// synchronous on POSIX + Windows, which is enough.
|
|
33
|
+
// - **No behavior change on uncaughtException**: we register
|
|
34
|
+
// `uncaughtExceptionMonitor` (Node 13.7+) to telemeter without
|
|
35
|
+
// suppressing Node's default crash. If the runtime predates that
|
|
36
|
+
// event, we fall back to `uncaughtException` + `process.exit(1)`
|
|
37
|
+
// which preserves "don't swallow."
|
|
38
|
+
// - **Test gating**: mocha defines `it` globally (vitest with
|
|
39
|
+
// `globals: false` does not). Skip auto-install whenever the test
|
|
40
|
+
// framework signal is present so we don't fight the existing zombie
|
|
41
|
+
// reap in `test/helpers.ts`. The unit tests for these handlers spawn
|
|
42
|
+
// a dedicated child Node process where the gate doesn't fire.
|
|
43
|
+
/**
|
|
44
|
+
* Live adapters in this process. Populated by `startV2Lifecycle` after a
|
|
45
|
+
* successful claim; emptied on `stopV2Lifecycle` and `fireTerminal`.
|
|
46
|
+
* Each lifecycle handler iterates this set to build the per-adapter
|
|
47
|
+
* snapshot in the structured log.
|
|
48
|
+
*/
|
|
49
|
+
const liveAdapters = new Set();
|
|
50
|
+
let processLifecycleTelemetryInstalled = false;
|
|
51
|
+
let processLifecycleHandlerRefs = [];
|
|
52
|
+
/**
|
|
53
|
+
* Should `installProcessLifecycleTelemetry()` actually wire up handlers?
|
|
54
|
+
*
|
|
55
|
+
* - Forced on by `AGENT_TEMPO_LIFECYCLE_TELEMETRY=1` (used by the
|
|
56
|
+
* child-process tests for these handlers — see
|
|
57
|
+
* `test/adapter-process-lifecycle-telemetry.test.ts`).
|
|
58
|
+
* - Forced off by `AGENT_TEMPO_LIFECYCLE_TELEMETRY=0`.
|
|
59
|
+
* - Off when running under mocha (detected via `globalThis.it` —
|
|
60
|
+
* mocha defines this; vitest with `globals: false` does not).
|
|
61
|
+
* - Off when `NODE_ENV === 'test'` — belt and suspenders.
|
|
62
|
+
* - Otherwise on.
|
|
63
|
+
*/
|
|
64
|
+
function shouldInstallLifecycleTelemetry(force) {
|
|
65
|
+
if (force)
|
|
66
|
+
return true;
|
|
67
|
+
const flag = process.env.AGENT_TEMPO_LIFECYCLE_TELEMETRY;
|
|
68
|
+
if (flag === '1' || flag === 'true')
|
|
69
|
+
return true;
|
|
70
|
+
if (flag === '0' || flag === 'false')
|
|
71
|
+
return false;
|
|
72
|
+
// Mocha exposes BDD globals (`it`, `describe`, …) on the global object;
|
|
73
|
+
// our vitest config opts out of globals so it doesn't trigger this gate.
|
|
74
|
+
if (typeof globalThis.it === 'function')
|
|
75
|
+
return false;
|
|
76
|
+
if (process.env.NODE_ENV === 'test')
|
|
77
|
+
return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
function snapshotLiveAdapters() {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const adapter of liveAdapters) {
|
|
83
|
+
out.push(adapter._captureTelemetrySnapshot());
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build the structured frame emitted by every lifecycle handler. Pure
|
|
89
|
+
* function — exposed for unit tests that don't want to spawn a child
|
|
90
|
+
* process.
|
|
91
|
+
*/
|
|
92
|
+
function buildProcessTerminatingFrame(signal, errorMessage, snapshot = snapshotLiveAdapters()) {
|
|
93
|
+
return JSON.stringify({
|
|
94
|
+
event: 'adapter-process-terminating',
|
|
95
|
+
signal,
|
|
96
|
+
...(errorMessage !== undefined ? { errorMessage } : {}),
|
|
97
|
+
adapterCount: snapshot.length,
|
|
98
|
+
adapters: snapshot,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function emitTerminatingLog(signal, errorMessage) {
|
|
102
|
+
// `console.error` synchronously writes to stderr on POSIX + Windows.
|
|
103
|
+
// The `[agent-tempo:adapter]` prefix matches the rest of the adapter
|
|
104
|
+
// logs so a single grep surfaces both the existing `terminal fire`
|
|
105
|
+
// line and these new lifecycle lines for the same incident.
|
|
106
|
+
log(`adapter-process-terminating: ${buildProcessTerminatingFrame(signal, errorMessage)}`);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Install the process-lifecycle telemetry handlers. Idempotent. Skipped
|
|
110
|
+
* by default in test environments (see {@link shouldInstallLifecycleTelemetry}).
|
|
111
|
+
*
|
|
112
|
+
* Production callers (and the first `startV2Lifecycle()` call on any
|
|
113
|
+
* adapter) invoke without arguments. Unit tests pass `{ force: true }`
|
|
114
|
+
* to bypass the env gate.
|
|
115
|
+
*/
|
|
116
|
+
function installProcessLifecycleTelemetry(opts = {}) {
|
|
117
|
+
if (processLifecycleTelemetryInstalled)
|
|
118
|
+
return;
|
|
119
|
+
if (!shouldInstallLifecycleTelemetry(opts.force === true))
|
|
120
|
+
return;
|
|
121
|
+
processLifecycleTelemetryInstalled = true;
|
|
122
|
+
const handlers = [];
|
|
123
|
+
// `exit` — synchronous, last chance. Don't do async work; just log.
|
|
124
|
+
const onExit = () => emitTerminatingLog('exit');
|
|
125
|
+
process.on('exit', onExit);
|
|
126
|
+
handlers.push({ event: 'exit', handler: onExit });
|
|
127
|
+
// `beforeExit` — event loop is empty but Node hasn't exited yet.
|
|
128
|
+
const onBeforeExit = () => emitTerminatingLog('beforeExit');
|
|
129
|
+
process.on('beforeExit', onBeforeExit);
|
|
130
|
+
handlers.push({ event: 'beforeExit', handler: onBeforeExit });
|
|
131
|
+
// SIGTERM — graceful termination request (kill, supervisord, systemd).
|
|
132
|
+
const onSigterm = () => emitTerminatingLog('SIGTERM');
|
|
133
|
+
process.on('SIGTERM', onSigterm);
|
|
134
|
+
handlers.push({ event: 'SIGTERM', handler: onSigterm });
|
|
135
|
+
// SIGINT — Ctrl+C from a controlling terminal.
|
|
136
|
+
const onSigint = () => emitTerminatingLog('SIGINT');
|
|
137
|
+
process.on('SIGINT', onSigint);
|
|
138
|
+
handlers.push({ event: 'SIGINT', handler: onSigint });
|
|
139
|
+
// `uncaughtExceptionMonitor` lets us telemeter without suppressing
|
|
140
|
+
// Node's default crash behavior. The default action runs unchanged:
|
|
141
|
+
// print stack, exit non-zero. The codebase's `engines` requirement
|
|
142
|
+
// (Node 20+) guarantees this event is available — Node 13.7+.
|
|
143
|
+
const onUncaughtMonitor = (err) => {
|
|
144
|
+
emitTerminatingLog('uncaughtException', err instanceof Error ? err.message : String(err));
|
|
145
|
+
};
|
|
146
|
+
process.on('uncaughtExceptionMonitor', onUncaughtMonitor);
|
|
147
|
+
handlers.push({ event: 'uncaughtExceptionMonitor', handler: onUncaughtMonitor });
|
|
148
|
+
// `unhandledRejection` — log only. Adding a listener prevents Node's
|
|
149
|
+
// default crash on unhandled promise rejections (Node 15+); that's
|
|
150
|
+
// intentional per the brief ("log + don't crash").
|
|
151
|
+
const onUnhandled = (reason) => {
|
|
152
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
153
|
+
emitTerminatingLog('unhandledRejection', msg);
|
|
154
|
+
};
|
|
155
|
+
process.on('unhandledRejection', onUnhandled);
|
|
156
|
+
handlers.push({ event: 'unhandledRejection', handler: onUnhandled });
|
|
157
|
+
processLifecycleHandlerRefs = handlers;
|
|
158
|
+
}
|
|
159
|
+
/** Test-only — uninstall handlers + reset state. */
|
|
160
|
+
function _resetProcessLifecycleTelemetryForTest() {
|
|
161
|
+
for (const { event, handler } of processLifecycleHandlerRefs) {
|
|
162
|
+
process.off(event, handler);
|
|
163
|
+
}
|
|
164
|
+
processLifecycleHandlerRefs = [];
|
|
165
|
+
processLifecycleTelemetryInstalled = false;
|
|
166
|
+
liveAdapters.clear();
|
|
167
|
+
}
|
|
168
|
+
/** Test-only — direct access to the live-adapter set. */
|
|
169
|
+
function _liveAdaptersForTest() {
|
|
170
|
+
return liveAdapters;
|
|
171
|
+
}
|
|
172
|
+
/** Backoff tuning for the heartbeat + phase-watcher loops on transient errors. */
|
|
173
|
+
const LOOP_BACKOFF_FACTOR = 1.5;
|
|
174
|
+
const LOOP_BACKOFF_MAX_MS = 30_000;
|
|
175
|
+
/**
|
|
176
|
+
* Emit a periodic `heartbeats-delivered=N` / `phase-ticks=N` summary every N
|
|
177
|
+
* successful ticks (#249). Chosen so a live claude-code adapter (60s cadence)
|
|
178
|
+
* logs roughly once every 10 minutes and an SDK adapter (30s cadence) once
|
|
179
|
+
* every 5 — rare enough to stay quiet, frequent enough that a 2-hour incident
|
|
180
|
+
* window always contains at least one breadcrumb.
|
|
181
|
+
*/
|
|
182
|
+
const LOOP_SUMMARY_EVERY = 10;
|
|
183
|
+
/**
|
|
184
|
+
* Reconnect tuning (#201). The loop retries `claimAttachment` with exponential backoff
|
|
185
|
+
* bounded by a total elapsed-time budget. Elapsed-time bounds beat retry-count bounds
|
|
186
|
+
* because they map cleanly to user-facing mental models ("waits ~15 min then gives up")
|
|
187
|
+
* and don't drift when the backoff curve changes. 15 min catches the long tail of
|
|
188
|
+
* laptop-sleep events without leaving zombie pollers running forever.
|
|
189
|
+
*/
|
|
190
|
+
const RECONNECT_TOTAL_BUDGET_MS = 15 * 60_000;
|
|
191
|
+
const RECONNECT_BASE_MS = 10_000;
|
|
192
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
193
|
+
const RECONNECT_BACKOFF_FACTOR = 1.5;
|
|
194
|
+
/**
|
|
195
|
+
* #258: tiebreaker timeout for the `describe()` confirmation that gates
|
|
196
|
+
* `fireTerminal('destroy')` from the reconnect-loop pre-check. The Temporal
|
|
197
|
+
* SDK's per-call default is conservative (10s+); we'd rather conclude
|
|
198
|
+
* "describe is hung, treat as terminal" in 3s than freeze the reconnect
|
|
199
|
+
* loop on a slow visibility-API call.
|
|
200
|
+
*/
|
|
201
|
+
const DESCRIBE_TIMEOUT_MS = 3_000;
|
|
202
|
+
/**
|
|
203
|
+
* Workflow execution statuses that are unambiguously terminal — used by
|
|
204
|
+
* the #258 `describe()` tiebreaker to decide whether a transient
|
|
205
|
+
* pre-check error reflects a genuinely-gone workflow (fire destroy) or a
|
|
206
|
+
* transient blip (continue the loop). Anything not in this set, including
|
|
207
|
+
* `RUNNING`, `PAUSED`, `UNSPECIFIED`, and `UNKNOWN`, is treated as
|
|
208
|
+
* non-terminal — conservatively keeps the loop alive when classification
|
|
209
|
+
* is ambiguous.
|
|
210
|
+
*/
|
|
211
|
+
const TERMINAL_WORKFLOW_STATUSES = new Set([
|
|
212
|
+
'COMPLETED',
|
|
213
|
+
'FAILED',
|
|
214
|
+
'CANCELLED',
|
|
215
|
+
'TERMINATED',
|
|
216
|
+
'CONTINUED_AS_NEW',
|
|
217
|
+
'TIMED_OUT',
|
|
218
|
+
]);
|
|
219
|
+
/**
|
|
220
|
+
* Abstract base class for session adapters.
|
|
221
|
+
*
|
|
222
|
+
* Concrete adapters (`InteractiveAttachment`, `CopilotSdkAttachment`) own
|
|
223
|
+
* their own top-level delivery loop. The base class owns the V2 attachment
|
|
224
|
+
* lifecycle: claim, heartbeat at `descriptor.heartbeatMs`, phase-watcher
|
|
225
|
+
* loop, `WorkflowGone` classifier, graceful `adapterExited` on teardown.
|
|
226
|
+
* Subclasses must call `startV2Lifecycle()` before their delivery loop and
|
|
227
|
+
* `stopV2Lifecycle()` on shutdown.
|
|
228
|
+
*
|
|
229
|
+
* PR-H (#132): the `AGENT_TEMPO_LIFECYCLE_V2` flag and the legacy V1 poll-
|
|
230
|
+
* only path it gated have been removed. The V2 attachment-lease path is
|
|
231
|
+
* now the only path.
|
|
232
|
+
*/
|
|
233
|
+
class BaseAttachment {
|
|
234
|
+
/** Populated at construction for InteractiveAttachment; lazily via `configureV2()` for subprocess adapters (Copilot bridge). */
|
|
235
|
+
client;
|
|
236
|
+
host;
|
|
237
|
+
/** V2 state — populated by `startV2Lifecycle()`, null on legacy path. */
|
|
238
|
+
token = null;
|
|
239
|
+
/** Handle pinned to the runId returned by `claimAttachment`. Never resolve by ID alone (§6.3). */
|
|
240
|
+
pinnedHandle = null;
|
|
241
|
+
heartbeatTimer = null;
|
|
242
|
+
phaseWatcherTimer = null;
|
|
243
|
+
heartbeatBackoff = 0;
|
|
244
|
+
phaseBackoff = 0;
|
|
245
|
+
stopped = false;
|
|
246
|
+
terminalFired = false;
|
|
247
|
+
knownPhase = null;
|
|
248
|
+
/**
|
|
249
|
+
* `true` once a heartbeat has successfully landed on the current attachment (or rebind).
|
|
250
|
+
* Cleared on `startV2Lifecycle`, reconnect-loop success, and CAN rebind so each freshly
|
|
251
|
+
* live attachment emits its own `heartbeat#1 delivered` diagnostic. Added in #249 to
|
|
252
|
+
* distinguish "claim OK but heartbeat loop died" from "adapter just hasn't ticked yet."
|
|
253
|
+
*/
|
|
254
|
+
firstHeartbeatLogged = false;
|
|
255
|
+
/**
|
|
256
|
+
* Monotonic heartbeat counter for the current attachment cycle. Reset on
|
|
257
|
+
* claim/reconnect/CAN-rebind. Emitted periodically (every {@link LOOP_SUMMARY_EVERY}
|
|
258
|
+
* ticks) so a long-running session leaves breadcrumbs in the log proving the loop is
|
|
259
|
+
* alive — operators can `grep 'heartbeats-delivered='` to confirm health without
|
|
260
|
+
* parsing Temporal history. Added in #249.
|
|
261
|
+
*/
|
|
262
|
+
heartbeatsSent = 0;
|
|
263
|
+
/**
|
|
264
|
+
* Mirror of {@link heartbeatsSent} for the phase-watcher loop. Same emission cadence,
|
|
265
|
+
* same rationale — the watcher is the only self-heal surface when the heartbeat loop
|
|
266
|
+
* dies silently, so a summary log line proves it's still live too.
|
|
267
|
+
*/
|
|
268
|
+
phaseTicksDone = 0;
|
|
269
|
+
phaseChangeListeners = [];
|
|
270
|
+
leaseRevokedListeners = [];
|
|
271
|
+
terminalListeners = [];
|
|
272
|
+
/**
|
|
273
|
+
* Pending `abortableSleep` cancellers (#201). `stopV2Lifecycle` iterates and invokes
|
|
274
|
+
* each so any in-flight reconnect backoff rejects immediately and the loop unwinds
|
|
275
|
+
* instead of stalling teardown by up to `RECONNECT_MAX_MS`.
|
|
276
|
+
*/
|
|
277
|
+
sleepAborters = new Set();
|
|
278
|
+
/**
|
|
279
|
+
* `true` while `runReconnectLoop` is active. Prevents concurrent reconnect attempts
|
|
280
|
+
* (e.g. if both the heartbeat and phase-watcher loops observe the same lease expiry
|
|
281
|
+
* at nearly the same time) and gates heartbeat/watcher ticks from firing new terminals
|
|
282
|
+
* while the reconnect pre-check is still deciding.
|
|
283
|
+
*/
|
|
284
|
+
reconnecting = false;
|
|
285
|
+
/** Reconnect loop timing — production constants unless overridden for tests. */
|
|
286
|
+
reconnectBaseMs;
|
|
287
|
+
reconnectMaxMs;
|
|
288
|
+
reconnectBudgetMs;
|
|
289
|
+
reconnectBackoffFactor;
|
|
290
|
+
constructor(options = {}) {
|
|
291
|
+
this.client = options.client;
|
|
292
|
+
this.host = options.host;
|
|
293
|
+
const t = options.reconnectTiming ?? {};
|
|
294
|
+
this.reconnectBaseMs = t.baseMs ?? RECONNECT_BASE_MS;
|
|
295
|
+
this.reconnectMaxMs = t.maxMs ?? RECONNECT_MAX_MS;
|
|
296
|
+
this.reconnectBudgetMs = t.budgetMs ?? RECONNECT_TOTAL_BUDGET_MS;
|
|
297
|
+
this.reconnectBackoffFactor = t.backoffFactor ?? RECONNECT_BACKOFF_FACTOR;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Lazily populate the V2-path dependencies (Temporal client, host). Used by
|
|
301
|
+
* adapters whose subprocess constructs the client inside `run()` rather
|
|
302
|
+
* than receiving it from the outer process (Copilot bridge). Must be called
|
|
303
|
+
* BEFORE `startV2Lifecycle()`.
|
|
304
|
+
*
|
|
305
|
+
* C3 (PR-C dual-QA follow-up): rejects late reconfiguration — once a claim
|
|
306
|
+
* token has been issued, swapping the client out silently would leave the
|
|
307
|
+
* pinned handle pointing at the previous connection. Future adapters that
|
|
308
|
+
* mis-order the calls fail loudly instead of drifting.
|
|
309
|
+
*/
|
|
310
|
+
configureV2(client, host) {
|
|
311
|
+
if (this.token) {
|
|
312
|
+
throw new Error('configureV2() called after startV2Lifecycle; configuration must happen before claim');
|
|
313
|
+
}
|
|
314
|
+
this.client = client;
|
|
315
|
+
this.host = host;
|
|
316
|
+
}
|
|
317
|
+
/** Subscribe to `attachmentInfo.phase` changes observed by the watcher. */
|
|
318
|
+
onPhaseChange(listener) {
|
|
319
|
+
this.phaseChangeListeners.push(listener);
|
|
320
|
+
return () => {
|
|
321
|
+
const i = this.phaseChangeListeners.indexOf(listener);
|
|
322
|
+
if (i >= 0)
|
|
323
|
+
this.phaseChangeListeners.splice(i, 1);
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/** Subscribe to lease-revocation events (§9.3 split-brain resolution). */
|
|
327
|
+
onLeaseRevoked(listener) {
|
|
328
|
+
this.leaseRevokedListeners.push(listener);
|
|
329
|
+
return () => {
|
|
330
|
+
const i = this.leaseRevokedListeners.indexOf(listener);
|
|
331
|
+
if (i >= 0)
|
|
332
|
+
this.leaseRevokedListeners.splice(i, 1);
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Hypothesis A telemetry — capture the adapter state included in
|
|
337
|
+
* process-lifecycle log frames. Public so the module-level
|
|
338
|
+
* `snapshotLiveAdapters()` helper can read private fields without an
|
|
339
|
+
* `any` cast; consumers other than the telemetry path should not call it.
|
|
340
|
+
*/
|
|
341
|
+
_captureTelemetrySnapshot() {
|
|
342
|
+
return {
|
|
343
|
+
attachmentId: this.token?.attachmentId ?? null,
|
|
344
|
+
workflowId: this.pinnedHandle?.workflowId ?? null,
|
|
345
|
+
runId: this.token?.runId ?? null,
|
|
346
|
+
heartbeatsSent: this.heartbeatsSent,
|
|
347
|
+
phaseTicksDone: this.phaseTicksDone,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Subscribe to terminal events — `WorkflowNotFound` (§9.4) and phase `gone`.
|
|
352
|
+
* Terminal fires at most once per instance. Subclasses stop delivery + exit.
|
|
353
|
+
*/
|
|
354
|
+
onTerminal(listener) {
|
|
355
|
+
this.terminalListeners.push(listener);
|
|
356
|
+
return () => {
|
|
357
|
+
const i = this.terminalListeners.indexOf(listener);
|
|
358
|
+
if (i >= 0)
|
|
359
|
+
this.terminalListeners.splice(i, 1);
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* V2 lifecycle entry point. Claims (or renews) the attachment, pins the handle by runId,
|
|
364
|
+
* and starts the heartbeat + phase watcher loops.
|
|
365
|
+
*
|
|
366
|
+
* @param workflowId Target session workflow id.
|
|
367
|
+
* @param expectedAttachmentId
|
|
368
|
+
* PR-D renewal path. When present, the adapter was spawned by `restart` or `migrate`
|
|
369
|
+
* — the workflow has already created an `Attachment` with this id and is
|
|
370
|
+
* expecting the new adapter to take over. Passing it through to `claimAttachment`
|
|
371
|
+
* selects the renewal branch in §9.2 (refresh lease in place, idempotent on retry)
|
|
372
|
+
* instead of the fresh-claim branch. Fresh spawn (first recruit) omits this arg.
|
|
373
|
+
* @returns Pinned `WorkflowHandle` — subclass delivery loop MUST use this for every
|
|
374
|
+
* subsequent query/signal (never resolve by id alone).
|
|
375
|
+
* @throws Re-throws `claimAttachment` rejections (`AttachmentConflict`, `WorkflowGone`).
|
|
376
|
+
*/
|
|
377
|
+
async startV2Lifecycle(workflowId, expectedAttachmentId) {
|
|
378
|
+
if (!this.client) {
|
|
379
|
+
throw new Error('BaseAttachment V2 path requires a Temporal client — pass via constructor options');
|
|
380
|
+
}
|
|
381
|
+
if (!this.host) {
|
|
382
|
+
throw new Error('BaseAttachment V2 path requires a host — pass via constructor options');
|
|
383
|
+
}
|
|
384
|
+
const unpinned = this.client.workflow.getHandle(workflowId);
|
|
385
|
+
this.token = await unpinned.executeUpdate(signals_1.claimAttachmentUpdate, {
|
|
386
|
+
args: [{
|
|
387
|
+
host: this.host,
|
|
388
|
+
adapterId: this.descriptor.adapterId,
|
|
389
|
+
adapterClass: this.descriptor.adapterClass,
|
|
390
|
+
leaseMs: 3 * this.descriptor.heartbeatMs,
|
|
391
|
+
...(expectedAttachmentId ? { expectedAttachmentId } : {}),
|
|
392
|
+
}],
|
|
393
|
+
});
|
|
394
|
+
this.pinnedHandle = this.client.workflow.getHandle(workflowId, this.token.runId);
|
|
395
|
+
// Hypothesis A telemetry — register this adapter so a future process-
|
|
396
|
+
// lifecycle handler (exit / SIGTERM / uncaughtException / …) can
|
|
397
|
+
// include its state in the structured log. `installProcessLifecycleTelemetry`
|
|
398
|
+
// is idempotent + env-gated; first call wires the handlers, subsequent
|
|
399
|
+
// calls no-op.
|
|
400
|
+
liveAdapters.add(this);
|
|
401
|
+
installProcessLifecycleTelemetry();
|
|
402
|
+
// #249: reset the per-attachment diagnostic counters so the next tick emits
|
|
403
|
+
// `heartbeat#1 delivered` on the freshly live lease. Without this reset a
|
|
404
|
+
// renewal path (e.g. restart → renewed claim) would never re-log first-heartbeat.
|
|
405
|
+
this.firstHeartbeatLogged = false;
|
|
406
|
+
this.heartbeatsSent = 0;
|
|
407
|
+
this.phaseTicksDone = 0;
|
|
408
|
+
log(`${expectedAttachmentId ? 'renewed' : 'attached to'} ${workflowId} ` +
|
|
409
|
+
`(attachmentId=${this.token.attachmentId}, runId=${this.token.runId}); ` +
|
|
410
|
+
`first heartbeat scheduled in ${this.descriptor.heartbeatMs}ms`);
|
|
411
|
+
this.scheduleHeartbeat();
|
|
412
|
+
this.schedulePhaseWatcher();
|
|
413
|
+
return this.pinnedHandle;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Tear down V2 machinery. Idempotent. Called by subclass on stop, on terminal
|
|
417
|
+
* events, and on graceful detach completion.
|
|
418
|
+
*
|
|
419
|
+
* When `graceful=true` (detach owner) we fire `adapterExited` so the workflow
|
|
420
|
+
* collapses `draining → detached` immediately per §11.1.
|
|
421
|
+
*/
|
|
422
|
+
async stopV2Lifecycle(reason = 'user-stop', graceful = false) {
|
|
423
|
+
if (this.stopped)
|
|
424
|
+
return;
|
|
425
|
+
this.stopped = true;
|
|
426
|
+
// Hypothesis A telemetry — keep `liveAdapters` accurate so a subsequent
|
|
427
|
+
// process-lifecycle handler firing after stop doesn't include a
|
|
428
|
+
// already-torn-down adapter in its frame.
|
|
429
|
+
liveAdapters.delete(this);
|
|
430
|
+
if (this.heartbeatTimer) {
|
|
431
|
+
clearTimeout(this.heartbeatTimer);
|
|
432
|
+
this.heartbeatTimer = null;
|
|
433
|
+
}
|
|
434
|
+
if (this.phaseWatcherTimer) {
|
|
435
|
+
clearTimeout(this.phaseWatcherTimer);
|
|
436
|
+
this.phaseWatcherTimer = null;
|
|
437
|
+
}
|
|
438
|
+
// #201: a user-initiated stop must abort any in-flight reconnect backoff
|
|
439
|
+
// BEFORE awaiting `adapterExited`, otherwise teardown stalls up to
|
|
440
|
+
// `RECONNECT_MAX_MS` while the sleep timer runs out naturally.
|
|
441
|
+
this.abortSleepers();
|
|
442
|
+
if (graceful && this.pinnedHandle && this.token) {
|
|
443
|
+
try {
|
|
444
|
+
await this.pinnedHandle.signal(signals_1.adapterExitedSignal, {
|
|
445
|
+
attachmentId: this.token.attachmentId,
|
|
446
|
+
reason,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
// Best-effort — workflow may already have reaped us. Don't fail shutdown.
|
|
451
|
+
log(`adapterExited signal suppressed error: ${err?.message ?? err}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
scheduleHeartbeat() {
|
|
456
|
+
const delay = this.heartbeatBackoff || this.descriptor.heartbeatMs;
|
|
457
|
+
this.heartbeatTimer = setTimeout(() => { void this.tickHeartbeat(); }, delay);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Emit a loud diagnostic when a tick early-returns via one of its guard paths (#249).
|
|
461
|
+
* Pre-#249 these returns were silent — the only observable effect was "heartbeats stop
|
|
462
|
+
* arriving." Now operators can grep `adapter.*guard tripped` to confirm or rule out
|
|
463
|
+
* tick-orphan as a failure mode without needing workflow history.
|
|
464
|
+
*
|
|
465
|
+
* `terminalFired=true` / `stopped=true` guards are load-bearing on the terminal path
|
|
466
|
+
* (don't want to re-enter terminal) so they're expected during teardown; we still log
|
|
467
|
+
* them but at the same level — operators can correlate timestamps against the preceding
|
|
468
|
+
* `terminal (...) — stopping delivery poll permanently` line.
|
|
469
|
+
*/
|
|
470
|
+
logGuardTrip(loop) {
|
|
471
|
+
log(`${loop} guard tripped:`, JSON.stringify({
|
|
472
|
+
stopped: this.stopped,
|
|
473
|
+
reconnecting: this.reconnecting,
|
|
474
|
+
hasHandle: this.pinnedHandle !== null,
|
|
475
|
+
hasToken: this.token !== null,
|
|
476
|
+
terminalFired: this.terminalFired,
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Single tick of the heartbeat loop. Try/finally scaffolding (#249) guarantees
|
|
481
|
+
* reschedule in every path except genuinely terminal state (`stopped`,
|
|
482
|
+
* `terminalFired`) or when the reconnect loop has taken ownership of scheduling
|
|
483
|
+
* (`reconnecting`). Pre-#249 the three early-return paths at the top + the
|
|
484
|
+
* handled-terminal-error path silently orphaned the timer forever; a transient
|
|
485
|
+
* `reconnecting=true` window or a null-handle race was enough to kill the loop
|
|
486
|
+
* with no log and no teardown.
|
|
487
|
+
*
|
|
488
|
+
* Handled terminals (CAN rebind, destroy) still short-circuit via `return` —
|
|
489
|
+
* the `finally` block re-checks `reconnecting` / `terminalFired` before
|
|
490
|
+
* rescheduling, so the reconnect/terminal machinery keeps ownership of
|
|
491
|
+
* whatever comes next.
|
|
492
|
+
*/
|
|
493
|
+
async tickHeartbeat() {
|
|
494
|
+
try {
|
|
495
|
+
if (this.stopped || this.terminalFired) {
|
|
496
|
+
this.logGuardTrip('heartbeat');
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (this.reconnecting) {
|
|
500
|
+
// Reconnect loop owns reschedule; this tick was queued before the guard
|
|
501
|
+
// flipped. Dropping it is correct — the reconnect path will rearm.
|
|
502
|
+
this.logGuardTrip('heartbeat');
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (!this.pinnedHandle || !this.token) {
|
|
506
|
+
// Should be unreachable after `startV2Lifecycle` success — surface loudly
|
|
507
|
+
// if we ever hit it instead of silently orphaning (the pre-#249 behavior).
|
|
508
|
+
this.logGuardTrip('heartbeat');
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
await this.pinnedHandle.signal(signals_1.heartbeatSignal, {
|
|
513
|
+
attachmentId: this.token.attachmentId,
|
|
514
|
+
at: new Date().toISOString(),
|
|
515
|
+
});
|
|
516
|
+
this.heartbeatBackoff = 0;
|
|
517
|
+
this.heartbeatsSent++;
|
|
518
|
+
if (!this.firstHeartbeatLogged) {
|
|
519
|
+
this.firstHeartbeatLogged = true;
|
|
520
|
+
log(`heartbeat#1 delivered (attachmentId=${this.token.attachmentId}, runId=${this.token.runId})`);
|
|
521
|
+
}
|
|
522
|
+
else if (this.heartbeatsSent % LOOP_SUMMARY_EVERY === 0) {
|
|
523
|
+
log(`heartbeats-delivered=${this.heartbeatsSent} (attachmentId=${this.token.attachmentId})`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
if (await this.handleRunEndError(err))
|
|
528
|
+
return;
|
|
529
|
+
this.heartbeatBackoff = Math.min(this.heartbeatBackoff ? this.heartbeatBackoff * LOOP_BACKOFF_FACTOR : this.descriptor.heartbeatMs, LOOP_BACKOFF_MAX_MS);
|
|
530
|
+
log(`heartbeat transient error (retry in ${Math.round(this.heartbeatBackoff)}ms):`, err?.message ?? err);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
if (!this.stopped && !this.reconnecting && !this.terminalFired) {
|
|
535
|
+
this.scheduleHeartbeat();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
schedulePhaseWatcher() {
|
|
540
|
+
// §3.2 item 6: relaxed poll — once per 5 heartbeat intervals.
|
|
541
|
+
const base = this.descriptor.heartbeatMs * 5;
|
|
542
|
+
const delay = this.phaseBackoff || base;
|
|
543
|
+
this.phaseWatcherTimer = setTimeout(() => { void this.tickPhaseWatcher(); }, delay);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Single tick of the phase-watcher loop. Same orphan-resistance scaffolding as
|
|
547
|
+
* {@link tickHeartbeat} (#249): try/finally reschedule, unconditional unless
|
|
548
|
+
* `stopped` / `terminalFired` / `reconnecting`. When the heartbeat loop dies
|
|
549
|
+
* silently, the watcher is the only remaining self-heal surface — losing it
|
|
550
|
+
* too meant the adapter had no path back to a healthy state short of process
|
|
551
|
+
* restart.
|
|
552
|
+
*/
|
|
553
|
+
async tickPhaseWatcher() {
|
|
554
|
+
try {
|
|
555
|
+
if (this.stopped || this.terminalFired) {
|
|
556
|
+
this.logGuardTrip('phase-watcher');
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (this.reconnecting) {
|
|
560
|
+
this.logGuardTrip('phase-watcher');
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (!this.pinnedHandle || !this.token) {
|
|
564
|
+
this.logGuardTrip('phase-watcher');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const info = await this.pinnedHandle.query(signals_1.attachmentInfoQuery);
|
|
569
|
+
this.phaseBackoff = 0;
|
|
570
|
+
this.phaseTicksDone++;
|
|
571
|
+
if (this.phaseTicksDone % LOOP_SUMMARY_EVERY === 0) {
|
|
572
|
+
log(`phase-ticks=${this.phaseTicksDone} (phase=${info.phase}, attachmentId=${this.token.attachmentId})`);
|
|
573
|
+
}
|
|
574
|
+
// #249: if the workflow-side attachment record shows our last heartbeat landed
|
|
575
|
+
// more than 2 * heartbeatMs ago, the heartbeat loop is drifting (or has
|
|
576
|
+
// silently died) even though the lease hasn't yet expired. Loud warning so
|
|
577
|
+
// operators can catch degradation before the reaper fires. Baseline is
|
|
578
|
+
// `claimedAt` on cycles before the first post-claim heartbeat lands.
|
|
579
|
+
if (info.currentAttachment && info.currentAttachment.attachmentId === this.token.attachmentId) {
|
|
580
|
+
const lastBeatMs = new Date(info.currentAttachment.lastHeartbeatAt || info.currentAttachment.claimedAt).getTime();
|
|
581
|
+
const ageMs = Date.now() - lastBeatMs;
|
|
582
|
+
if (ageMs > 2 * this.descriptor.heartbeatMs) {
|
|
583
|
+
log(`WARNING: heartbeat staleness — lastHeartbeatAt=${info.currentAttachment.lastHeartbeatAt} ` +
|
|
584
|
+
`age=${ageMs}ms exceeds 2× heartbeatMs (${2 * this.descriptor.heartbeatMs}ms); ` +
|
|
585
|
+
`lease may be about to reap (expiresAt=${info.currentAttachment.expiresAt})`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (this.knownPhase !== info.phase) {
|
|
589
|
+
this.knownPhase = info.phase;
|
|
590
|
+
for (const l of this.phaseChangeListeners) {
|
|
591
|
+
try {
|
|
592
|
+
l(info.phase);
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
log('phase listener threw:', err);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// Lease revocation (§9.3) — another claimant took over.
|
|
600
|
+
if (info.currentAttachment &&
|
|
601
|
+
info.currentAttachment.attachmentId !== this.token.attachmentId) {
|
|
602
|
+
log(`lease revoked: attachmentId ${info.currentAttachment.attachmentId} does not match ours ${this.token.attachmentId}`);
|
|
603
|
+
for (const l of this.leaseRevokedListeners) {
|
|
604
|
+
try {
|
|
605
|
+
l('superseded');
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
log('leaseRevoked listener threw:', err);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
this.fireTerminalOrReconnect('superseded');
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
// #201: the workflow side reaped our lease (main-loop §9.5.a) without anyone
|
|
615
|
+
// else claiming. This is the laptop-sleep failure mode — `phase=detached` with
|
|
616
|
+
// `currentAttachment=undefined`. Before #201 this branch was silent and the
|
|
617
|
+
// poller kept querying a workflow that had already evicted us, so no cues were
|
|
618
|
+
// delivered until manual `restart`. Now we surface it as a recoverable terminal
|
|
619
|
+
// that the subclass can choose to reconnect through.
|
|
620
|
+
if (info.phase === 'detached' && !info.currentAttachment) {
|
|
621
|
+
log(`lease reaped workflow-side (phase=detached, no current attachment)`);
|
|
622
|
+
for (const l of this.leaseRevokedListeners) {
|
|
623
|
+
try {
|
|
624
|
+
l('heartbeat-timeout');
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
log('leaseRevoked listener threw:', err);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
this.fireTerminalOrReconnect('heartbeat-timeout');
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
// Phase `gone` is terminal — workflow destroyed. Never recoverable.
|
|
634
|
+
if (info.phase === 'gone') {
|
|
635
|
+
this.fireTerminal('destroy', 'tickPhaseWatcher:phase-gone');
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
catch (err) {
|
|
640
|
+
if (await this.handleRunEndError(err))
|
|
641
|
+
return;
|
|
642
|
+
this.phaseBackoff = Math.min(this.phaseBackoff ? this.phaseBackoff * LOOP_BACKOFF_FACTOR : this.descriptor.heartbeatMs, LOOP_BACKOFF_MAX_MS);
|
|
643
|
+
log(`phase watcher transient error (retry in ${Math.round(this.phaseBackoff)}ms):`, err?.message ?? err);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
finally {
|
|
647
|
+
if (!this.stopped && !this.reconnecting && !this.terminalFired) {
|
|
648
|
+
this.schedulePhaseWatcher();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Shared error-classification path for the heartbeat + phase-watcher ticks (#226).
|
|
654
|
+
*
|
|
655
|
+
* Returns `true` if the error was a terminal-class (handled inline: CAN rebind
|
|
656
|
+
* kicked off, or destroy fired). Returns `false` when the caller should treat
|
|
657
|
+
* the error as transient and continue its backoff.
|
|
658
|
+
*
|
|
659
|
+
* Always consults `fetchHistory` on any terminal-class error, because the
|
|
660
|
+
* Temporal SDK can't distinguish CAN-close from true-complete at the error
|
|
661
|
+
* level — see {@link isTerminalWorkflowError}. The history lookup is cheap
|
|
662
|
+
* (only runs on terminal, so at most once per adapter lifetime per terminal)
|
|
663
|
+
* and safer than re-querying by workflow id (which could race a fresh session
|
|
664
|
+
* reusing the id).
|
|
665
|
+
*/
|
|
666
|
+
async handleRunEndError(err) {
|
|
667
|
+
if (!(0, terminal_error_1.isTerminalWorkflowError)(err))
|
|
668
|
+
return false;
|
|
669
|
+
// Always try to find a CAN successor — the Temporal SDK's error shape is
|
|
670
|
+
// ambiguous between CAN and true-destroy, so history is the only reliable
|
|
671
|
+
// disambiguator (option 1 from the #226 design brief).
|
|
672
|
+
const successorRunId = await this.findCanSuccessorRunId();
|
|
673
|
+
if (successorRunId) {
|
|
674
|
+
this.fireTerminalOrReconnect('continued-as-new', successorRunId);
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
// No CAN event in the closed run's history → truly terminal (COMPLETED /
|
|
678
|
+
// TERMINATED / FAILED / workflow-id GC'd).
|
|
679
|
+
this.fireTerminal('destroy', 'handleRunEndError:no-can-successor');
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Fetch the closed pinned run's history and return the runId of a CAN successor
|
|
684
|
+
* if present, else `null`. Scoped to the pinned (old) run via `this.pinnedHandle`,
|
|
685
|
+
* so it can't be fooled by a fresh session that happens to reuse the workflow id.
|
|
686
|
+
*
|
|
687
|
+
* Called only on the terminal path from {@link handleRunEndError}, so the cost
|
|
688
|
+
* of `fetchHistory` (a full event stream for the closed run) is paid at most
|
|
689
|
+
* once per terminal — not on every tick.
|
|
690
|
+
*/
|
|
691
|
+
async findCanSuccessorRunId() {
|
|
692
|
+
if (!this.pinnedHandle)
|
|
693
|
+
return null;
|
|
694
|
+
try {
|
|
695
|
+
const history = await this.pinnedHandle.fetchHistory();
|
|
696
|
+
const events = history?.events ?? [];
|
|
697
|
+
for (const ev of events) {
|
|
698
|
+
const attrs = ev.workflowExecutionContinuedAsNewEventAttributes;
|
|
699
|
+
const newRunId = attrs?.newExecutionRunId;
|
|
700
|
+
if (newRunId)
|
|
701
|
+
return newRunId;
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
log('findCanSuccessorRunId: fetchHistory failed:', err?.message ?? err);
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Fire the terminal hook — the adapter is going dark and won't recover.
|
|
712
|
+
*
|
|
713
|
+
* #258: emits a structured log line on every fire so the next post-CAN
|
|
714
|
+
* silence incident is unambiguous in logs. Pre-#258, a `fireTerminal`
|
|
715
|
+
* from an unexpected source (the root cause was a silent destroy from
|
|
716
|
+
* the reconnect-loop pre-check on a transient terminal-class error) was
|
|
717
|
+
* indistinguishable from process death in workflow history — both produced
|
|
718
|
+
* "no further heartbeats." The structured log includes:
|
|
719
|
+
*
|
|
720
|
+
* - `reason` — the existing DetachReason
|
|
721
|
+
* - `callsite` — the calling function or rationale (passed by every
|
|
722
|
+
* callsite so the source is grep-able without parsing stack traces)
|
|
723
|
+
* - `attachmentId` / `workflowId` / `runId` — for cross-referencing
|
|
724
|
+
* against workflow history when bisecting an incident
|
|
725
|
+
* - `heartbeatsSent` / `phaseTicksDone` — the existing #249 counters
|
|
726
|
+
* so an operator can correlate "loop alive at N heartbeats, then
|
|
727
|
+
* terminal fired at this callsite" without external context
|
|
728
|
+
*
|
|
729
|
+
* Idempotent — repeat calls (e.g. reconnect-exhausted re-fires after
|
|
730
|
+
* destroy) early-return without re-logging. The first fire wins.
|
|
731
|
+
*/
|
|
732
|
+
fireTerminal(reason, callsite = 'unspecified') {
|
|
733
|
+
if (this.terminalFired)
|
|
734
|
+
return;
|
|
735
|
+
this.terminalFired = true;
|
|
736
|
+
this.stopped = true;
|
|
737
|
+
// Hypothesis A telemetry — same reasoning as `stopV2Lifecycle`.
|
|
738
|
+
liveAdapters.delete(this);
|
|
739
|
+
log(`terminal fire:`, JSON.stringify({
|
|
740
|
+
reason,
|
|
741
|
+
callsite,
|
|
742
|
+
attachmentId: this.token?.attachmentId ?? null,
|
|
743
|
+
workflowId: this.pinnedHandle?.workflowId ?? null,
|
|
744
|
+
runId: this.token?.runId ?? null,
|
|
745
|
+
heartbeatsSent: this.heartbeatsSent,
|
|
746
|
+
phaseTicksDone: this.phaseTicksDone,
|
|
747
|
+
}));
|
|
748
|
+
if (this.heartbeatTimer) {
|
|
749
|
+
clearTimeout(this.heartbeatTimer);
|
|
750
|
+
this.heartbeatTimer = null;
|
|
751
|
+
}
|
|
752
|
+
if (this.phaseWatcherTimer) {
|
|
753
|
+
clearTimeout(this.phaseWatcherTimer);
|
|
754
|
+
this.phaseWatcherTimer = null;
|
|
755
|
+
}
|
|
756
|
+
this.abortSleepers();
|
|
757
|
+
for (const l of this.terminalListeners) {
|
|
758
|
+
try {
|
|
759
|
+
l(reason);
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
log('terminal listener threw:', err);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* #258 tiebreaker: confirm whether a workflow is genuinely terminal after
|
|
768
|
+
* the reconnect-loop pre-check threw a terminal-class error. Used to
|
|
769
|
+
* distinguish a real workflow-gone state from a transient gRPC /
|
|
770
|
+
* visibility-API blip that classified as terminal.
|
|
771
|
+
*
|
|
772
|
+
* Returns:
|
|
773
|
+
* - `{ kind: 'running', statusName }` — workflow is alive (any
|
|
774
|
+
* non-terminal status). Caller should treat the original error as
|
|
775
|
+
* transient and continue the reconnect loop.
|
|
776
|
+
* - `{ kind: 'terminal', statusName }` — workflow is in a terminal
|
|
777
|
+
* status (`COMPLETED` / `FAILED` / `CANCELLED` / `TERMINATED` /
|
|
778
|
+
* `CONTINUED_AS_NEW` / `TIMED_OUT`). Caller should fire destroy.
|
|
779
|
+
* - `{ kind: 'describe-threw' }` — `describe()` itself failed. Treat
|
|
780
|
+
* as terminal (fire destroy) — consistent with pre-#258 semantics
|
|
781
|
+
* when classification is ambiguous, and avoids spinning forever on
|
|
782
|
+
* a workflow we can't reach.
|
|
783
|
+
* - `{ kind: 'timed-out' }` — `describe()` exceeded
|
|
784
|
+
* {@link DESCRIBE_TIMEOUT_MS}. Treat as terminal (fire destroy) —
|
|
785
|
+
* same rationale: prefer clean shutdown to a hung loop.
|
|
786
|
+
*
|
|
787
|
+
* The unpinned handle follows any CAN chain to the latest run, so
|
|
788
|
+
* `desc.status.name === 'CONTINUED_AS_NEW'` here means the workflow
|
|
789
|
+
* id itself is closed (no successor) — genuinely terminal.
|
|
790
|
+
*/
|
|
791
|
+
async confirmWorkflowTerminal(unpinned) {
|
|
792
|
+
let timer = null;
|
|
793
|
+
try {
|
|
794
|
+
const desc = await Promise.race([
|
|
795
|
+
unpinned.describe(),
|
|
796
|
+
new Promise((resolve) => {
|
|
797
|
+
timer = setTimeout(() => resolve('timeout'), DESCRIBE_TIMEOUT_MS);
|
|
798
|
+
}),
|
|
799
|
+
]);
|
|
800
|
+
if (desc === 'timeout')
|
|
801
|
+
return { kind: 'timed-out' };
|
|
802
|
+
const statusName = desc.status?.name ?? 'UNKNOWN';
|
|
803
|
+
if (TERMINAL_WORKFLOW_STATUSES.has(statusName)) {
|
|
804
|
+
return { kind: 'terminal', statusName };
|
|
805
|
+
}
|
|
806
|
+
return { kind: 'running', statusName };
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
return { kind: 'describe-threw' };
|
|
810
|
+
}
|
|
811
|
+
finally {
|
|
812
|
+
if (timer)
|
|
813
|
+
clearTimeout(timer);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
817
|
+
// #201 reconnect machinery. Subclasses opt in by overriding `shouldReconnect`.
|
|
818
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
819
|
+
/**
|
|
820
|
+
* Opt-in reconnect policy. Default: return `false` — the base class behaves
|
|
821
|
+
* exactly as it did before #201 (fire terminal, tear down). Subclasses that
|
|
822
|
+
* can safely replay delivery on a fresh lease should override and return
|
|
823
|
+
* `true` for recoverable reasons (typically `heartbeat-timeout` and
|
|
824
|
+
* `superseded`; never `destroy`).
|
|
825
|
+
*
|
|
826
|
+
* Why opt-in: SDK adapters (e.g. Copilot bridge) have their own subprocess
|
|
827
|
+
* restart logic; double-reconnecting would race their native poller and
|
|
828
|
+
* produce duplicate `pendingMessages` queries. Keep them on the old
|
|
829
|
+
* behavior until we've proven reconnect is safe there.
|
|
830
|
+
*/
|
|
831
|
+
shouldReconnect(_reason) {
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Called once, just before the reconnect loop enters its first backoff sleep.
|
|
836
|
+
* Subclasses should tear down any delivery loops that are still polling the
|
|
837
|
+
* stale pinned handle (it may succeed but `markDelivered` will be ignored by
|
|
838
|
+
* the workflow because our `attachmentId` is no longer current). The default
|
|
839
|
+
* is a no-op.
|
|
840
|
+
*/
|
|
841
|
+
async onReconnectStart(_reason) {
|
|
842
|
+
// Default: nothing to tear down.
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Called once on a successful re-claim, with the freshly pinned handle.
|
|
846
|
+
* Subclasses should restart their delivery loop against `handle`. Runs
|
|
847
|
+
* before the base class reschedules its own heartbeat + phase-watcher
|
|
848
|
+
* loops, so the subclass sees a quiescent state.
|
|
849
|
+
*
|
|
850
|
+
* Note: the runId returned by `claimAttachment` may differ from the previous
|
|
851
|
+
* pinned handle's runId (the workflow may have `continueAsNew`'d during the
|
|
852
|
+
* outage), so subclasses MUST use the `handle` argument — never cache a
|
|
853
|
+
* handle from before the reconnect.
|
|
854
|
+
*/
|
|
855
|
+
async onReconnected(_handle) {
|
|
856
|
+
// Default: nothing to restart.
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Sleep `ms` milliseconds, resolving cleanly on timer and rejecting with
|
|
860
|
+
* `aborted:stopped` if `stopV2Lifecycle` or `fireTerminal` fires mid-wait.
|
|
861
|
+
* The canonical pattern for any blocking wait inside an adapter loop —
|
|
862
|
+
* never use bare `setTimeout` + `Promise` in loop code, or teardown stalls.
|
|
863
|
+
*/
|
|
864
|
+
async abortableSleep(ms) {
|
|
865
|
+
if (this.stopped)
|
|
866
|
+
throw new Error('aborted:stopped');
|
|
867
|
+
await new Promise((resolve, reject) => {
|
|
868
|
+
let aborter = null;
|
|
869
|
+
const timer = setTimeout(() => {
|
|
870
|
+
if (aborter)
|
|
871
|
+
this.sleepAborters.delete(aborter);
|
|
872
|
+
resolve();
|
|
873
|
+
}, ms);
|
|
874
|
+
aborter = () => {
|
|
875
|
+
clearTimeout(timer);
|
|
876
|
+
if (aborter)
|
|
877
|
+
this.sleepAborters.delete(aborter);
|
|
878
|
+
reject(new Error('aborted:stopped'));
|
|
879
|
+
};
|
|
880
|
+
this.sleepAborters.add(aborter);
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
/** Reject every in-flight `abortableSleep`. Called on stop + terminal. */
|
|
884
|
+
abortSleepers() {
|
|
885
|
+
// Snapshot then clear — each aborter mutates the set during iteration.
|
|
886
|
+
const aborters = [...this.sleepAborters];
|
|
887
|
+
this.sleepAborters.clear();
|
|
888
|
+
for (const abort of aborters) {
|
|
889
|
+
try {
|
|
890
|
+
abort();
|
|
891
|
+
}
|
|
892
|
+
catch (err) {
|
|
893
|
+
log('sleep aborter threw:', err);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Consult {@link shouldReconnect}; if true, kick off the reconnect loop in
|
|
899
|
+
* the background (fire-and-forget), otherwise fire terminal synchronously.
|
|
900
|
+
* Called by the heartbeat / phase-watcher ticks instead of `fireTerminal`
|
|
901
|
+
* when the reason is potentially recoverable.
|
|
902
|
+
*/
|
|
903
|
+
fireTerminalOrReconnect(reason, canSuccessorRunId) {
|
|
904
|
+
if (this.stopped || this.terminalFired || this.reconnecting)
|
|
905
|
+
return;
|
|
906
|
+
if (!this.shouldReconnect(reason)) {
|
|
907
|
+
this.fireTerminal(reason, 'fireTerminalOrReconnect:not-recoverable');
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
// Pause the heartbeat + watcher loops for the duration of the reconnect.
|
|
911
|
+
this.reconnecting = true;
|
|
912
|
+
if (this.heartbeatTimer) {
|
|
913
|
+
clearTimeout(this.heartbeatTimer);
|
|
914
|
+
this.heartbeatTimer = null;
|
|
915
|
+
}
|
|
916
|
+
if (this.phaseWatcherTimer) {
|
|
917
|
+
clearTimeout(this.phaseWatcherTimer);
|
|
918
|
+
this.phaseWatcherTimer = null;
|
|
919
|
+
}
|
|
920
|
+
log(`reconnect requested (reason=${reason})`);
|
|
921
|
+
// #226: CAN takes the short-circuit rebind path (no backoff, no re-claim —
|
|
922
|
+
// the workflow's §2.3 CAN-boundary lease extension keeps the lease alive
|
|
923
|
+
// across the transition). Every other recoverable reason goes through the
|
|
924
|
+
// full #201 budget-bounded re-claim loop.
|
|
925
|
+
if (reason === 'continued-as-new' && canSuccessorRunId) {
|
|
926
|
+
void this.runCanRebind(canSuccessorRunId).catch((err) => {
|
|
927
|
+
log(`CAN rebind crashed:`, err?.message ?? err);
|
|
928
|
+
this.reconnecting = false;
|
|
929
|
+
this.fireTerminal('reconnect-exhausted', 'runCanRebind:crashed');
|
|
930
|
+
});
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
void this.runReconnectLoop(reason).catch((err) => {
|
|
934
|
+
log(`reconnect loop crashed:`, err?.message ?? err);
|
|
935
|
+
this.reconnecting = false;
|
|
936
|
+
this.fireTerminal('reconnect-exhausted', 'runReconnectLoop:crashed');
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* #226 CAN rebind. Transparently repoints `pinnedHandle` at the successor run,
|
|
941
|
+
* keeps the existing `attachmentId` / `leaseMs` (the workflow extended the lease
|
|
942
|
+
* by one heartbeat interval during the CAN transition per §2.3, so the lease is
|
|
943
|
+
* still live on the new run), notifies the subclass to restart its delivery
|
|
944
|
+
* loop, and resumes heartbeat + phase-watcher.
|
|
945
|
+
*
|
|
946
|
+
* Why this is safe without re-claiming:
|
|
947
|
+
* - The new run carries forward `currentAttachment` verbatim from the old run.
|
|
948
|
+
* - The adapter's `attachmentId` still matches, so the next `heartbeat` /
|
|
949
|
+
* `markDelivered` / `adapterExited` signal on the new pinned handle will be
|
|
950
|
+
* accepted unchanged by the workflow's handlers.
|
|
951
|
+
* - If the lease actually did expire before we got here (e.g. adapter was
|
|
952
|
+
* offline through multiple CAN cycles), the next phase-watcher tick on the
|
|
953
|
+
* new pinned handle will see `phase=detached` + no current attachment and
|
|
954
|
+
* fall through to the existing #201 reclaim path — belt-and-suspenders.
|
|
955
|
+
*/
|
|
956
|
+
async runCanRebind(newRunId) {
|
|
957
|
+
try {
|
|
958
|
+
if (!this.client || !this.pinnedHandle || !this.token) {
|
|
959
|
+
log('runCanRebind: missing client/handle/token — firing terminal');
|
|
960
|
+
this.fireTerminal('reconnect-exhausted', 'runCanRebind:missing-state');
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const workflowId = this.pinnedHandle.workflowId;
|
|
964
|
+
const oldRunId = this.token.runId;
|
|
965
|
+
try {
|
|
966
|
+
// Tear down any subclass-owned stream against the stale pinned handle
|
|
967
|
+
// before repointing, so the subclass doesn't race itself on the rebuild.
|
|
968
|
+
await this.onReconnectStart('continued-as-new');
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
log('onReconnectStart threw:', err?.message ?? err);
|
|
972
|
+
}
|
|
973
|
+
const newHandle = this.client.workflow.getHandle(workflowId, newRunId);
|
|
974
|
+
this.pinnedHandle = newHandle;
|
|
975
|
+
// Keep attachmentId + leaseMs (lease carried across CAN); refresh runId so
|
|
976
|
+
// diagnostic logging and any token-based debug output reflect the live run.
|
|
977
|
+
this.token = { ...this.token, runId: newRunId };
|
|
978
|
+
this.knownPhase = null; // force next phase-watcher tick to re-emit phaseChange
|
|
979
|
+
this.heartbeatBackoff = 0;
|
|
980
|
+
this.phaseBackoff = 0;
|
|
981
|
+
// #249: reset per-attachment diagnostic counters so the first post-rebind
|
|
982
|
+
// heartbeat re-logs `heartbeat#1 delivered`. Without this a rebind could
|
|
983
|
+
// mask a dead loop on the successor run — we'd never see the confirmation
|
|
984
|
+
// that heartbeats resumed.
|
|
985
|
+
this.firstHeartbeatLogged = false;
|
|
986
|
+
this.heartbeatsSent = 0;
|
|
987
|
+
this.phaseTicksDone = 0;
|
|
988
|
+
log(`rebound ${workflowId} to CAN successor ` +
|
|
989
|
+
`(attachmentId=${this.token.attachmentId}, oldRunId=${oldRunId}, newRunId=${newRunId})`);
|
|
990
|
+
try {
|
|
991
|
+
await this.onReconnected(newHandle);
|
|
992
|
+
}
|
|
993
|
+
catch (err) {
|
|
994
|
+
log('onReconnected threw:', err?.message ?? err);
|
|
995
|
+
}
|
|
996
|
+
// Clear reconnecting BEFORE rescheduling so the first tick after rebind
|
|
997
|
+
// doesn't short-circuit on its own reconnecting-guard. Mirrors the pattern
|
|
998
|
+
// in `runReconnectLoop`'s success path (#206).
|
|
999
|
+
this.reconnecting = false;
|
|
1000
|
+
if (!this.stopped) {
|
|
1001
|
+
this.scheduleHeartbeat();
|
|
1002
|
+
this.schedulePhaseWatcher();
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
finally {
|
|
1006
|
+
this.reconnecting = false;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Budget-bounded reconnect loop.
|
|
1011
|
+
*
|
|
1012
|
+
* Strategy:
|
|
1013
|
+
* 1. Sleep (abortable) with exponential backoff from {@link RECONNECT_BASE_MS}
|
|
1014
|
+
* up to {@link RECONNECT_MAX_MS}, capped by an elapsed-time budget of
|
|
1015
|
+
* {@link RECONNECT_TOTAL_BUDGET_MS}.
|
|
1016
|
+
* 2. Query `attachmentInfo` via a fresh unpinned handle:
|
|
1017
|
+
* • workflow gone → fire `destroy`, exit.
|
|
1018
|
+
* • phase `gone` → fire `destroy`, exit.
|
|
1019
|
+
* • someone else holds the lease → fire `superseded`, exit (architect §1).
|
|
1020
|
+
* • phase `draining` → wait another tick (lease about to reap).
|
|
1021
|
+
* • otherwise → attempt fresh `claimAttachment`.
|
|
1022
|
+
* 3. On successful claim: rebuild `this.pinnedHandle` from the **new** token's
|
|
1023
|
+
* `runId` (workflow may have `continueAsNew`'d during outage), reset loop
|
|
1024
|
+
* state, call subclass hooks, restart heartbeat + watcher.
|
|
1025
|
+
*
|
|
1026
|
+
* Fires `reconnect-exhausted` on budget exhaustion. Exits silently (without
|
|
1027
|
+
* firing terminal) on abort — `stopV2Lifecycle` owns teardown messaging.
|
|
1028
|
+
*/
|
|
1029
|
+
async runReconnectLoop(initialReason) {
|
|
1030
|
+
// Single try/finally so `reconnecting` always resets no matter how we exit
|
|
1031
|
+
// — success path, any fireTerminal, abort-during-sleep, or an unexpected
|
|
1032
|
+
// throw. #206 fixed the prior abort-catch path that leaked `reconnecting=true`
|
|
1033
|
+
// if `stopV2Lifecycle` aborted the backoff sleep.
|
|
1034
|
+
try {
|
|
1035
|
+
if (!this.client || !this.host || !this.token || !this.pinnedHandle) {
|
|
1036
|
+
log('runReconnectLoop: missing client/host/token/handle — aborting');
|
|
1037
|
+
this.fireTerminal('reconnect-exhausted', 'runReconnectLoop:missing-state');
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const workflowId = this.pinnedHandle.workflowId;
|
|
1041
|
+
const oldAttachmentId = this.token.attachmentId;
|
|
1042
|
+
try {
|
|
1043
|
+
await this.onReconnectStart(initialReason);
|
|
1044
|
+
}
|
|
1045
|
+
catch (err) {
|
|
1046
|
+
log('onReconnectStart threw:', err?.message ?? err);
|
|
1047
|
+
}
|
|
1048
|
+
const deadline = Date.now() + this.reconnectBudgetMs;
|
|
1049
|
+
let backoff = this.reconnectBaseMs;
|
|
1050
|
+
let attempt = 0;
|
|
1051
|
+
while (!this.stopped && Date.now() < deadline) {
|
|
1052
|
+
attempt++;
|
|
1053
|
+
log(`reconnect attempt ${attempt} (sleep ${Math.round(backoff)}ms)`);
|
|
1054
|
+
try {
|
|
1055
|
+
await this.abortableSleep(backoff);
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
// User-initiated stop during sleep — teardown already owns the rest.
|
|
1059
|
+
// The finally block still resets `reconnecting` so a subsequent
|
|
1060
|
+
// reclaim attempt (hypothetical — stop normally ends the adapter) would
|
|
1061
|
+
// find clean state. #206.
|
|
1062
|
+
log('reconnect aborted by stop during backoff');
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (this.stopped)
|
|
1066
|
+
return;
|
|
1067
|
+
// §Pre-check (architect §1): query attachmentInfo via a fresh unpinned handle.
|
|
1068
|
+
// The old pinned handle's runId may be stale after a continueAsNew.
|
|
1069
|
+
const unpinned = this.client.workflow.getHandle(workflowId);
|
|
1070
|
+
let info;
|
|
1071
|
+
try {
|
|
1072
|
+
info = await unpinned.query(signals_1.attachmentInfoQuery);
|
|
1073
|
+
}
|
|
1074
|
+
catch (err) {
|
|
1075
|
+
if ((0, terminal_error_1.isTerminalWorkflowError)(err)) {
|
|
1076
|
+
// #258: ONE terminal-class pre-check error is not enough evidence
|
|
1077
|
+
// to destroy the adapter. The classifier matches phrasings
|
|
1078
|
+
// (`WorkflowNotFound`, `NOT_FOUND`, "workflow execution already
|
|
1079
|
+
// completed") that can ALSO surface from transient gRPC blips and
|
|
1080
|
+
// momentary visibility-API hiccups. Pre-#258, this branch fired
|
|
1081
|
+
// `fireTerminal('destroy')` immediately — a single transient
|
|
1082
|
+
// error orphaned the adapter for the rest of the session
|
|
1083
|
+
// (heartbeat + watcher dead via `terminalFired`, poller torn
|
|
1084
|
+
// down by `onReconnectStart` + `onTerminal` listener).
|
|
1085
|
+
//
|
|
1086
|
+
// Tiebreaker: confirm with `describe()` against the same unpinned
|
|
1087
|
+
// handle. If the workflow is genuinely gone, `describe()` will
|
|
1088
|
+
// either return a closed status (COMPLETED/TERMINATED/...) or
|
|
1089
|
+
// itself throw — fire destroy with confidence. If it returns
|
|
1090
|
+
// RUNNING (or any non-terminal status), the original error was
|
|
1091
|
+
// transient — log and continue the loop. Bounded by
|
|
1092
|
+
// `DESCRIBE_TIMEOUT_MS` so a slow visibility-API call can't hang
|
|
1093
|
+
// the reconnect path indefinitely.
|
|
1094
|
+
const errClass = err?.name ?? 'unknown';
|
|
1095
|
+
const errMsg = err?.message ?? String(err);
|
|
1096
|
+
const tiebreak = await this.confirmWorkflowTerminal(unpinned);
|
|
1097
|
+
if (tiebreak.kind === 'running') {
|
|
1098
|
+
log(`reconnect: pre-check threw ${errClass} but describe() shows ` +
|
|
1099
|
+
`${tiebreak.statusName} — treating as transient, continuing loop ` +
|
|
1100
|
+
`(originalError="${errMsg}")`);
|
|
1101
|
+
backoff = Math.min(backoff * this.reconnectBackoffFactor, this.reconnectMaxMs);
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
const confirmDesc = tiebreak.kind === 'terminal'
|
|
1105
|
+
? `describe() confirmed ${tiebreak.statusName}`
|
|
1106
|
+
: `describe() ${tiebreak.kind === 'describe-threw' ? 'threw' : 'timed out'}`;
|
|
1107
|
+
log(`reconnect: pre-check terminal (${errClass}) and ${confirmDesc} — firing destroy ` +
|
|
1108
|
+
`(originalError="${errMsg}")`);
|
|
1109
|
+
this.fireTerminal('destroy', 'runReconnectLoop:precheck-terminal-confirmed');
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
backoff = Math.min(backoff * this.reconnectBackoffFactor, this.reconnectMaxMs);
|
|
1113
|
+
log(`reconnect pre-check transient error (next backoff ${Math.round(backoff)}ms):`, err?.message ?? err);
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
if (info.phase === 'gone') {
|
|
1117
|
+
log('reconnect: phase=gone — giving up');
|
|
1118
|
+
this.fireTerminal('destroy', 'runReconnectLoop:phase-gone');
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (info.currentAttachment && info.currentAttachment.attachmentId !== oldAttachmentId) {
|
|
1122
|
+
log(`reconnect: another adapter holds the lease (${info.currentAttachment.attachmentId}) — bailing`);
|
|
1123
|
+
this.fireTerminal('superseded', 'runReconnectLoop:other-holder');
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (info.phase === 'draining') {
|
|
1127
|
+
// About to reap — give the workflow one more tick to finish collapsing.
|
|
1128
|
+
backoff = Math.min(backoff * this.reconnectBackoffFactor, this.reconnectMaxMs);
|
|
1129
|
+
log(`reconnect: phase=draining, waiting (next backoff ${Math.round(backoff)}ms)`);
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
// §Claim: attempt a fresh `claimAttachment` (no expectedAttachmentId — our
|
|
1133
|
+
// previous lease is revoked, this is a fresh claim from the workflow's POV).
|
|
1134
|
+
try {
|
|
1135
|
+
const newToken = await unpinned.executeUpdate(signals_1.claimAttachmentUpdate, {
|
|
1136
|
+
args: [{
|
|
1137
|
+
host: this.host,
|
|
1138
|
+
adapterId: this.descriptor.adapterId,
|
|
1139
|
+
adapterClass: this.descriptor.adapterClass,
|
|
1140
|
+
leaseMs: 3 * this.descriptor.heartbeatMs,
|
|
1141
|
+
}],
|
|
1142
|
+
});
|
|
1143
|
+
// Success — rebuild pinned handle from the NEW runId and hand it to the subclass.
|
|
1144
|
+
this.token = newToken;
|
|
1145
|
+
this.pinnedHandle = this.client.workflow.getHandle(workflowId, newToken.runId);
|
|
1146
|
+
this.knownPhase = null; // force the next phase-watcher tick to re-emit phaseChange
|
|
1147
|
+
this.heartbeatBackoff = 0;
|
|
1148
|
+
this.phaseBackoff = 0;
|
|
1149
|
+
// #249: reset per-attachment diagnostic counters so the first post-reconnect
|
|
1150
|
+
// heartbeat re-logs `heartbeat#1 delivered`. Parity with CAN rebind path.
|
|
1151
|
+
this.firstHeartbeatLogged = false;
|
|
1152
|
+
this.heartbeatsSent = 0;
|
|
1153
|
+
this.phaseTicksDone = 0;
|
|
1154
|
+
log(`reconnected to ${workflowId} after ${attempt} attempt(s) ` +
|
|
1155
|
+
`(new attachmentId=${newToken.attachmentId}, runId=${newToken.runId})`);
|
|
1156
|
+
try {
|
|
1157
|
+
await this.onReconnected(this.pinnedHandle);
|
|
1158
|
+
}
|
|
1159
|
+
catch (err) {
|
|
1160
|
+
log('onReconnected threw:', err?.message ?? err);
|
|
1161
|
+
}
|
|
1162
|
+
// Clear the reconnecting flag BEFORE rescheduling so the first
|
|
1163
|
+
// heartbeat/watcher tick after reconnect doesn't short-circuit on
|
|
1164
|
+
// its own `this.reconnecting` guard. The finally block reasserts
|
|
1165
|
+
// `reconnecting=false` after return; this early assignment is the
|
|
1166
|
+
// one that matters for loop wiring.
|
|
1167
|
+
this.reconnecting = false;
|
|
1168
|
+
if (!this.stopped) {
|
|
1169
|
+
this.scheduleHeartbeat();
|
|
1170
|
+
this.schedulePhaseWatcher();
|
|
1171
|
+
}
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
catch (err) {
|
|
1175
|
+
if ((0, terminal_error_1.isTerminalWorkflowError)(err)) {
|
|
1176
|
+
log('reconnect: workflow gone during claim');
|
|
1177
|
+
this.fireTerminal('destroy', 'runReconnectLoop:claim-terminal');
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
backoff = Math.min(backoff * this.reconnectBackoffFactor, this.reconnectMaxMs);
|
|
1181
|
+
log(`reconnect claim failed (next backoff ${Math.round(backoff)}ms):`, err?.message ?? err);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
// Budget exhausted — give up cleanly.
|
|
1185
|
+
log(`reconnect budget exhausted after ${attempt} attempt(s)`);
|
|
1186
|
+
this.fireTerminal('reconnect-exhausted', 'runReconnectLoop:budget-exhausted');
|
|
1187
|
+
}
|
|
1188
|
+
finally {
|
|
1189
|
+
// Guarantee state reset regardless of which path we exited on. Safe to
|
|
1190
|
+
// assign unconditionally — a successful reconnect also ends up here after
|
|
1191
|
+
// the early assignment inside the success path (the early one is needed
|
|
1192
|
+
// so tick reschedulers see `reconnecting=false`; this one is belt-and-
|
|
1193
|
+
// suspenders for the abort/throw/terminal paths).
|
|
1194
|
+
this.reconnecting = false;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
exports.BaseAttachment = BaseAttachment;
|
|
1199
|
+
/**
|
|
1200
|
+
* Registry of adapter descriptors keyed by `adapterId`.
|
|
1201
|
+
*
|
|
1202
|
+
* Look up the descriptor for a given session by `SessionMetadata.adapterId` (or
|
|
1203
|
+
* fall back to `'claude-code'` for pre-v0.25 sessions that have no adapterId set).
|
|
1204
|
+
* `src/adapters/index.ts` creates the singleton `registry` and registers all
|
|
1205
|
+
* shipped adapters at import time.
|
|
1206
|
+
*/
|
|
1207
|
+
class AdapterRegistry {
|
|
1208
|
+
byId = new Map();
|
|
1209
|
+
/** Register an adapter descriptor. Replaces any existing entry with the same id. */
|
|
1210
|
+
register(desc) {
|
|
1211
|
+
this.byId.set(desc.adapterId, desc);
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Fetch the descriptor for `adapterId`. Throws if unregistered.
|
|
1215
|
+
*
|
|
1216
|
+
* Callers resolving from possibly-undefined metadata should coalesce first:
|
|
1217
|
+
* `registry.get(metadata.adapterId ?? 'claude-code')`.
|
|
1218
|
+
*/
|
|
1219
|
+
get(adapterId) {
|
|
1220
|
+
const desc = this.byId.get(adapterId);
|
|
1221
|
+
if (!desc) {
|
|
1222
|
+
const known = [...this.byId.keys()].join(', ') || '(none registered)';
|
|
1223
|
+
throw new Error(`Unknown adapter "${adapterId}". Registered: ${known}`);
|
|
1224
|
+
}
|
|
1225
|
+
return desc;
|
|
1226
|
+
}
|
|
1227
|
+
/** `true` if `adapterId` is registered. */
|
|
1228
|
+
has(adapterId) {
|
|
1229
|
+
return this.byId.has(adapterId);
|
|
1230
|
+
}
|
|
1231
|
+
/** Snapshot of all registered descriptors. */
|
|
1232
|
+
all() {
|
|
1233
|
+
return [...this.byId.values()];
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Resolve an `adapterId` from the legacy `agent` field on {@link SessionMetadata}.
|
|
1237
|
+
* Maps `'claude'` → `'claude-code'`, `'copilot'` → `'copilot'`.
|
|
1238
|
+
*
|
|
1239
|
+
* Used as a fallback when `adapterId` is not yet populated on the session metadata
|
|
1240
|
+
* (e.g. sessions started before PR-B landed). PR-D removes this mapping when the
|
|
1241
|
+
* legacy `AgentType` enum is retired.
|
|
1242
|
+
*/
|
|
1243
|
+
resolveFromAgentType(agent) {
|
|
1244
|
+
if (agent === 'copilot')
|
|
1245
|
+
return 'copilot';
|
|
1246
|
+
// ADR 0014 §4.1 — mock adapter resolves to its own descriptor. The
|
|
1247
|
+
// descriptor is only registered when `isDevMode()` (gate 2); a stale
|
|
1248
|
+
// metadata.agentType='mock' in a production build would land here and
|
|
1249
|
+
// then `registry.get('mock')` would throw the documented "Unknown
|
|
1250
|
+
// adapter" error — which is the correct safety behaviour.
|
|
1251
|
+
if (agent === 'mock')
|
|
1252
|
+
return 'mock';
|
|
1253
|
+
// #131 Phase C — headless Claude API adapter. Descriptor lives in
|
|
1254
|
+
// src/adapters/claude-api; opt-in via `recruit({ agent: 'claude-api' })`.
|
|
1255
|
+
if (agent === 'claude-api')
|
|
1256
|
+
return 'claude-api';
|
|
1257
|
+
// #449 Phase C — headless multi-provider OpenCode adapter. Descriptor
|
|
1258
|
+
// lives in src/adapters/opencode; opt-in via `recruit({ agent: 'opencode' })`.
|
|
1259
|
+
if (agent === 'opencode')
|
|
1260
|
+
return 'opencode';
|
|
1261
|
+
// #520 — headless Claude Code adapter (per-turn `claude -p` subprocess).
|
|
1262
|
+
// Descriptor lives in src/adapters/claude-code-headless; opt-in via
|
|
1263
|
+
// `recruit({ agent: 'claude-code-headless' })`. Uses the host's existing
|
|
1264
|
+
// Claude Code OAuth login so turns bill against subscription extra-usage.
|
|
1265
|
+
if (agent === 'claude-code-headless')
|
|
1266
|
+
return 'claude-code-headless';
|
|
1267
|
+
return 'claude-code';
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
exports.AdapterRegistry = AdapterRegistry;
|