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,1638 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.agentSessionWorkflow = agentSessionWorkflow;
|
|
4
|
+
const workflow_1 = require("@temporalio/workflow");
|
|
5
|
+
const common_1 = require("@temporalio/common");
|
|
6
|
+
/**
|
|
7
|
+
* Workflow-deterministic clock. The Temporal TS SDK intercepts `new Date()` at the
|
|
8
|
+
* sandbox level to return replay-consistent time, so this wrapper is safe — the
|
|
9
|
+
* name aligns with the project convention (CLAUDE.md: "no `Date.now()` in workflow
|
|
10
|
+
* code, use `workflow.now()` instead") while using the SDK-intercepted constructor.
|
|
11
|
+
*/
|
|
12
|
+
function workflowNow() {
|
|
13
|
+
return new Date();
|
|
14
|
+
}
|
|
15
|
+
const attachment_math_1 = require("./attachment-math");
|
|
16
|
+
const signals_1 = require("./signals");
|
|
17
|
+
const validation_1 = require("../utils/validation");
|
|
18
|
+
// ── Outbox Activity Proxies ──
|
|
19
|
+
const { deliverCue, deliverReport, terminateSession, startRecruitedSession, releasePlayer, deliverDetach, deliverDestroy, deliverRestart } = (0, workflow_1.proxyActivities)({
|
|
20
|
+
startToCloseTimeout: '30 seconds',
|
|
21
|
+
retry: { maximumAttempts: 3 },
|
|
22
|
+
});
|
|
23
|
+
function getSpawnProxy(hostname) {
|
|
24
|
+
return (0, workflow_1.proxyActivities)({
|
|
25
|
+
taskQueue: `agent-tempo-${hostname}`,
|
|
26
|
+
startToCloseTimeout: '2 minutes',
|
|
27
|
+
retry: { maximumAttempts: 2 },
|
|
28
|
+
}).spawnProcess;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Host-routed proxy for the #159 Gap 2 hard-terminate activity. Runs on the target's
|
|
32
|
+
* `agent-tempo-{hostname}` task queue so the kill happens where the child process
|
|
33
|
+
* actually lives. Short timeout + low retry — this is a best-effort cleanup and the
|
|
34
|
+
* workflow must not wedge if the host worker is down.
|
|
35
|
+
*/
|
|
36
|
+
function getHardTerminateProxy(hostname) {
|
|
37
|
+
return (0, workflow_1.proxyActivities)({
|
|
38
|
+
taskQueue: `agent-tempo-${hostname}`,
|
|
39
|
+
startToCloseTimeout: '10 seconds',
|
|
40
|
+
scheduleToCloseTimeout: '20 seconds',
|
|
41
|
+
retry: { maximumAttempts: 1 },
|
|
42
|
+
}).hardTerminateAttachment;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Shorter-timeout proxy for destroyUpdate. Destroy is terminal/best-effort
|
|
46
|
+
* (§2.5) — if the host worker is offline we don't want to block the workflow's
|
|
47
|
+
* terminal transition for 20s waiting on a schedule-to-close timeout. Test
|
|
48
|
+
* environments without a host worker fail fast in ~5s instead.
|
|
49
|
+
*/
|
|
50
|
+
function getHardTerminateProxyForDestroy(hostname) {
|
|
51
|
+
return (0, workflow_1.proxyActivities)({
|
|
52
|
+
taskQueue: `agent-tempo-${hostname}`,
|
|
53
|
+
startToCloseTimeout: '5 seconds',
|
|
54
|
+
scheduleToCloseTimeout: '5 seconds',
|
|
55
|
+
retry: { maximumAttempts: 1 },
|
|
56
|
+
}).hardTerminateAttachment;
|
|
57
|
+
}
|
|
58
|
+
async function agentSessionWorkflow(input) {
|
|
59
|
+
// ── v0.25 Attachment Lifecycle Timers (design §2.3, §9.5) ──
|
|
60
|
+
// PR-C commit 6 (#119a): each attachment carries its own `leaseMs` (negotiated at
|
|
61
|
+
// claim time). No workflow-side default constant — heartbeats extend `expiresAt`
|
|
62
|
+
// by `currentAttachment.leaseMs`.
|
|
63
|
+
/**
|
|
64
|
+
* Legacy CAN-extension constant. Retained solely so sessions that ran on a
|
|
65
|
+
* pre-#249 workflow bundle replay deterministically: the `patched()` branch at
|
|
66
|
+
* the CAN-boundary extension site selects this constant on the non-patched
|
|
67
|
+
* side, matching the exact arg sequence those histories recorded. New runs
|
|
68
|
+
* (and all post-#249 CAN transitions) use `currentAttachment.leaseMs` instead
|
|
69
|
+
* — see the call site below.
|
|
70
|
+
*/
|
|
71
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
72
|
+
/**
|
|
73
|
+
* Default grace period for `draining → detached` transition after requestDetach. Used when a
|
|
74
|
+
* `requestDetach` signal omits `deadlineMs`. Per-signal overrides are honored via the
|
|
75
|
+
* `drainingDeadlineMs` state variable below (fix for #159 Gap 1a).
|
|
76
|
+
*/
|
|
77
|
+
const DEFAULT_DRAINING_DEADLINE_MS = 5_000;
|
|
78
|
+
/** Max duration a messageId can stay in-flight before the safety timer ejects it. */
|
|
79
|
+
const PROCESSING_DEADLINE_MS = 15 * 60 * 1000;
|
|
80
|
+
// Version marker for v0.10 — records a patch marker in workflow history.
|
|
81
|
+
// Future workflow changes that alter the command sequence should use
|
|
82
|
+
// patched('v0.10-<change-name>') to protect in-flight sessions from
|
|
83
|
+
// non-determinism errors during rolling deploys.
|
|
84
|
+
(0, workflow_1.patched)('v0.10-initial');
|
|
85
|
+
(0, workflow_1.patched)('v0.11-check-and-set-status');
|
|
86
|
+
(0, workflow_1.patched)('v0.13-quality-gates');
|
|
87
|
+
(0, workflow_1.patched)('v0.14-worktrees');
|
|
88
|
+
(0, workflow_1.patched)('v0.15-blocked-detection');
|
|
89
|
+
(0, workflow_1.patched)('v0.18-stages');
|
|
90
|
+
(0, workflow_1.patched)('v0.23-hold-release');
|
|
91
|
+
(0, workflow_1.patched)('v0.25-attachment-lifecycle');
|
|
92
|
+
// Issue #172: kept as a replay marker for workflows that predate the
|
|
93
|
+
// simpler hold-on-startup design (v0.26). The state field + interceptor
|
|
94
|
+
// were removed in favor of baking the banner/directive into
|
|
95
|
+
// `SessionInput.messages` at workflow creation, but leaving the patched
|
|
96
|
+
// marker ensures existing replay histories that recorded this command
|
|
97
|
+
// still deserialize cleanly. Safe no-op today.
|
|
98
|
+
(0, workflow_1.patched)('v0.26-pending-startup-context');
|
|
99
|
+
// Ensure search attributes are always current — critical when reconnecting
|
|
100
|
+
// via WorkflowIdConflictPolicy.USE_EXISTING, which skips the attributes
|
|
101
|
+
// passed to client.workflow.start().
|
|
102
|
+
(0, workflow_1.upsertSearchAttributes)({
|
|
103
|
+
AgentTempoEnsemble: [input.metadata.ensemble],
|
|
104
|
+
AgentTempoPlayerId: [input.metadata.playerId],
|
|
105
|
+
AgentTempoHostname: [input.metadata.hostname],
|
|
106
|
+
...(input.metadata.gitRoot ? { AgentTempoGitRoot: [input.metadata.gitRoot] } : {}),
|
|
107
|
+
...(input.metadata.playerType ? { AgentTempoPlayerType: [input.metadata.playerType] } : {}),
|
|
108
|
+
AgentTempoIsConductor: [input.metadata.isConductor === true],
|
|
109
|
+
// v0.25 attachment search attributes — initial values for a fresh/restored workflow.
|
|
110
|
+
// Updated on every phase transition below.
|
|
111
|
+
AgentTempoAttachedHost: [input.currentAttachment?.hostname ?? ''],
|
|
112
|
+
AgentTempoAttachmentState: [input.phase ?? 'booting'],
|
|
113
|
+
AgentTempoAttachmentId: [input.currentAttachment?.attachmentId ?? ''],
|
|
114
|
+
});
|
|
115
|
+
// ── State (carried across continue-as-new) ──
|
|
116
|
+
let part = input.part ?? input.autoSummary ?? 'No description set';
|
|
117
|
+
const messages = input.messages ?? [];
|
|
118
|
+
const sentMessages = input.sentMessages ?? [];
|
|
119
|
+
const outbox = input.outbox ?? [];
|
|
120
|
+
let lastActivityTime = workflowNow().getTime();
|
|
121
|
+
let lastOutboundTime = input.lastOutboundTime ?? workflowNow().getTime();
|
|
122
|
+
let lastInboundRRTime = input.lastInboundRRTime ?? 0;
|
|
123
|
+
// ── #399 W2 — wire-extension counters (carried across continueAsNew) ──
|
|
124
|
+
// `activityCount` mirrors the ~20 `lastActivityTime` mutation sites;
|
|
125
|
+
// `receivedCount` / `sentCount` track inbound cues + outbox submissions.
|
|
126
|
+
// All three feed dashboard surfaces via the new `getActivityStateQuery`
|
|
127
|
+
// and `getMessagingStateQuery` queries.
|
|
128
|
+
let activityCount = input.activityCount ?? 0;
|
|
129
|
+
let receivedCount = input.receivedCount ?? 0;
|
|
130
|
+
let sentCount = input.sentCount ?? 0;
|
|
131
|
+
// ── Warm Hold + Pause State ──
|
|
132
|
+
let outboxLocked = input.outboxLocked ?? false;
|
|
133
|
+
let heldMessage = input.heldMessage;
|
|
134
|
+
let paused = input.paused ?? false;
|
|
135
|
+
// ── Player Saveable State (#334 PR-1; ADR 0011) ──
|
|
136
|
+
// Per-key opaque-string artifacts the player itself curates via `save_state`.
|
|
137
|
+
// Carried via continueAsNew (only when populated). Sized at validation:
|
|
138
|
+
// up to PLAYER_STATE_SLOTS_MAX × PLAYER_STATE_CONTENT_MAX.
|
|
139
|
+
const playerState = { ...(input.playerState ?? {}) };
|
|
140
|
+
// ── v0.25 Attachment Lifecycle State (design §2.2) ──
|
|
141
|
+
/** Current attachment lease, or null when detached. */
|
|
142
|
+
let currentAttachment = input.currentAttachment ?? null;
|
|
143
|
+
/** Current phase — authoritative source of lifecycle truth after #175. */
|
|
144
|
+
let phase = input.phase ?? (currentAttachment ? 'attached' : 'booting');
|
|
145
|
+
/** Preferred host for daemon reconcile-on-boot auto-restore. */
|
|
146
|
+
let preferredHost = input.preferredHost ?? currentAttachment?.hostname ?? input.metadata.hostname;
|
|
147
|
+
/** ISO timestamp of when the current `draining` phase started. */
|
|
148
|
+
let drainingSince = input.drainingSince ?? null;
|
|
149
|
+
/**
|
|
150
|
+
* Grace window (ms) for the current `draining` phase, if a `requestDetach` signal supplied one.
|
|
151
|
+
* Fix for #159 Gap 1a: the pre-fix handler discarded `deadlineMs` and always used the 5s default,
|
|
152
|
+
* so callers requesting a longer/shorter window were silently ignored. Reset to `null` whenever
|
|
153
|
+
* `drainingSince` clears so it can't leak into the next detach cycle.
|
|
154
|
+
*/
|
|
155
|
+
let drainingDeadlineMs = input.drainingDeadlineMs ?? null;
|
|
156
|
+
/**
|
|
157
|
+
* Monotonic counter bumped by signal/update handlers that *shorten* `nextDeadlineMs()` output
|
|
158
|
+
* (e.g. `requestDetach` creates a new, sooner draining deadline; `forceDetach` nullifies the
|
|
159
|
+
* current attachment expiry). The main-loop `condition(...)` includes `wakeEpoch` in its
|
|
160
|
+
* predicate so state changes outside the existing wake conditions still punch through the
|
|
161
|
+
* already-scheduled timeout — fix for #159 Gap 1b. Signal handlers that *extend* deadlines
|
|
162
|
+
* (e.g. heartbeat) don't need to bump since the pre-existing, earlier deadline firing and
|
|
163
|
+
* being re-checked is harmless.
|
|
164
|
+
*/
|
|
165
|
+
let wakeEpoch = 0;
|
|
166
|
+
/** Reason recorded when the last attachment detached (for orphanSummary query). */
|
|
167
|
+
let lastDetachReason;
|
|
168
|
+
/** Metadata about the last-known adapter (for orphanSummary query). */
|
|
169
|
+
let lastAdapterMeta = currentAttachment
|
|
170
|
+
? { hostname: currentAttachment.hostname, adapterId: currentAttachment.adapterId }
|
|
171
|
+
: undefined;
|
|
172
|
+
/** ISO timestamp of when the workflow most recently entered `detached`. */
|
|
173
|
+
let detachedSince = null;
|
|
174
|
+
// ── Processing Lifecycle State (fixes #99) ──
|
|
175
|
+
// Tracks messages currently being processed by a blocking adapter. While non-empty,
|
|
176
|
+
// stale detection is suppressed AND the phase refines to `processing`.
|
|
177
|
+
const inFlightMessages = new Set(input.inFlightMessageIds ?? []);
|
|
178
|
+
// processingSince carried as ISO string in v0.25; normalize numeric legacy values.
|
|
179
|
+
const _inputProcessingSince = input.processingSince;
|
|
180
|
+
let processingSince = typeof _inputProcessingSince === 'string'
|
|
181
|
+
? _inputProcessingSince
|
|
182
|
+
: typeof _inputProcessingSince === 'number'
|
|
183
|
+
? new Date(_inputProcessingSince).toISOString()
|
|
184
|
+
: (inFlightMessages.size > 0 ? workflowNow().toISOString() : null);
|
|
185
|
+
// ── Destroy State (fixes #102; §8.5 immediate-COMPLETE) ──
|
|
186
|
+
// Once set, the workflow COMPLETES per §2.5 (abandon in-flight, no drain).
|
|
187
|
+
// Adapter recovery code reads `isDestroyed` and exits.
|
|
188
|
+
let destroyed = input.destroyed ?? false;
|
|
189
|
+
let destroyRequested = destroyed;
|
|
190
|
+
/** IDs of outbox entries abandoned by the last `destroy` — written to history event. */
|
|
191
|
+
let destroyAbandonedIds = [];
|
|
192
|
+
// PR-H (#132): the v0.25.1 `updateMetadata({ status: 'terminated' })` shim
|
|
193
|
+
// path is gone. `destroyRequested` is set only by the `destroyUpdate` handler
|
|
194
|
+
// below. Operator-initiated termination flows through the `destroy` verb +
|
|
195
|
+
// its outbox path; adapter graceful exit fires `adapterExited`; MCP-server
|
|
196
|
+
// SIGINT detaches without destroying. See
|
|
197
|
+
// docs/design/session-lifecycle-rebuild-v2.md §2.5, §11.1.
|
|
198
|
+
// ── Helpers ──
|
|
199
|
+
/**
|
|
200
|
+
* Reduce the outbox state list to a short status string for the
|
|
201
|
+
* dashboard's `Messages` KV row (Q5.5 of #399 W2). Returns:
|
|
202
|
+
*
|
|
203
|
+
* - `"empty"` — no pending entries
|
|
204
|
+
* - `"N pending"` — pending entries, oldest within `STALE_MS`
|
|
205
|
+
* - `"N pending (oldest 2m)"` — pending entries, oldest beyond
|
|
206
|
+
* the stale threshold; the magnitude (m / s) is human-rounded so
|
|
207
|
+
* the dashboard reads cleanly without further parsing.
|
|
208
|
+
*
|
|
209
|
+
* `STALE_MS = 30_000` per the brief — anything older than 30s pending
|
|
210
|
+
* is the "outbox is backing up" signal we want to surface.
|
|
211
|
+
*/
|
|
212
|
+
function outboxStatus() {
|
|
213
|
+
const STALE_MS = 30_000;
|
|
214
|
+
const nowMs = workflowNow().getTime();
|
|
215
|
+
let count = 0;
|
|
216
|
+
let oldestAge = 0;
|
|
217
|
+
for (const e of outbox) {
|
|
218
|
+
if (e.status !== 'pending')
|
|
219
|
+
continue;
|
|
220
|
+
count++;
|
|
221
|
+
const age = nowMs - Date.parse(e.createdAt);
|
|
222
|
+
if (age > oldestAge)
|
|
223
|
+
oldestAge = age;
|
|
224
|
+
}
|
|
225
|
+
if (count === 0)
|
|
226
|
+
return 'empty';
|
|
227
|
+
if (oldestAge < STALE_MS)
|
|
228
|
+
return `${count} pending`;
|
|
229
|
+
const minutes = Math.floor(oldestAge / 60_000);
|
|
230
|
+
const ageLabel = minutes >= 1 ? `${minutes}m` : `${Math.floor(oldestAge / 1000)}s`;
|
|
231
|
+
return `${count} pending (oldest ${ageLabel})`;
|
|
232
|
+
}
|
|
233
|
+
/** Transition to a new phase, syncing the attachment search attribute. */
|
|
234
|
+
function setPhase(next) {
|
|
235
|
+
if (phase === next)
|
|
236
|
+
return;
|
|
237
|
+
phase = next;
|
|
238
|
+
(0, workflow_1.upsertSearchAttributes)({ AgentTempoAttachmentState: [next] });
|
|
239
|
+
lastActivityTime = workflowNow().getTime();
|
|
240
|
+
activityCount++;
|
|
241
|
+
}
|
|
242
|
+
/** Build the token returned from `claimAttachment`. `leaseMs` is the value the caller
|
|
243
|
+
* supplied (or the default if they didn't), so the adapter knows when to heartbeat. */
|
|
244
|
+
function attachmentTokenFrom(a, leaseMs) {
|
|
245
|
+
return {
|
|
246
|
+
attachmentId: a.attachmentId,
|
|
247
|
+
runId: a.runId,
|
|
248
|
+
expiresAt: a.expiresAt,
|
|
249
|
+
leaseMs,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** Compute next time-based deadline for the main loop. Returns +Infinity when no deadline applies. */
|
|
253
|
+
function nextDeadlineMs() {
|
|
254
|
+
const nowMs = workflowNow().getTime();
|
|
255
|
+
const candidates = [];
|
|
256
|
+
if (currentAttachment) {
|
|
257
|
+
candidates.push(new Date(currentAttachment.expiresAt).getTime());
|
|
258
|
+
}
|
|
259
|
+
if (processingSince) {
|
|
260
|
+
candidates.push(new Date(processingSince).getTime() + PROCESSING_DEADLINE_MS);
|
|
261
|
+
}
|
|
262
|
+
if (phase === 'draining' && drainingSince) {
|
|
263
|
+
const window = drainingDeadlineMs ?? DEFAULT_DRAINING_DEADLINE_MS;
|
|
264
|
+
candidates.push(new Date(drainingSince).getTime() + window);
|
|
265
|
+
}
|
|
266
|
+
if (candidates.length === 0)
|
|
267
|
+
return Number.POSITIVE_INFINITY;
|
|
268
|
+
return Math.max(0, Math.min(...candidates) - nowMs);
|
|
269
|
+
}
|
|
270
|
+
// ── Outbox Update + Query Handlers ──
|
|
271
|
+
(0, workflow_1.setHandler)(signals_1.submitOutboxUpdate, (entryInput) => {
|
|
272
|
+
const entry = {
|
|
273
|
+
...entryInput,
|
|
274
|
+
id: (0, workflow_1.uuid4)(),
|
|
275
|
+
createdAt: workflowNow().toISOString(),
|
|
276
|
+
status: 'pending',
|
|
277
|
+
};
|
|
278
|
+
outbox.push(entry);
|
|
279
|
+
// #399 W2 — every outbox submission counts as outbound traffic.
|
|
280
|
+
sentCount++;
|
|
281
|
+
// Record in sentMessages for history continuity
|
|
282
|
+
if (entry.type === 'cue') {
|
|
283
|
+
// #357: forward broadcastId so the sender's view reflects the same
|
|
284
|
+
// grouping the receiver sees.
|
|
285
|
+
sentMessages.push({
|
|
286
|
+
id: entry.id,
|
|
287
|
+
to: entry.targetPlayerId,
|
|
288
|
+
text: entry.message,
|
|
289
|
+
timestamp: entry.createdAt,
|
|
290
|
+
...(entry.broadcastId !== undefined ? { broadcastId: entry.broadcastId } : {}),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
else if (entry.type === 'report') {
|
|
294
|
+
sentMessages.push({ id: entry.id, to: 'conductor', text: `[${entry.reportType}] ${entry.text}`, timestamp: entry.createdAt });
|
|
295
|
+
}
|
|
296
|
+
else if (entry.type === 'stop') {
|
|
297
|
+
sentMessages.push({ id: entry.id, to: entry.targetPlayerId, text: '[stop requested]', timestamp: entry.createdAt });
|
|
298
|
+
}
|
|
299
|
+
else if (entry.type === 'detach') {
|
|
300
|
+
sentMessages.push({ id: entry.id, to: entry.targetPlayerId, text: '[detach requested]', timestamp: entry.createdAt });
|
|
301
|
+
}
|
|
302
|
+
else if (entry.type === 'destroy') {
|
|
303
|
+
sentMessages.push({ id: entry.id, to: entry.targetPlayerId, text: '[destroy requested]', timestamp: entry.createdAt });
|
|
304
|
+
}
|
|
305
|
+
else if (entry.type === 'restart') {
|
|
306
|
+
sentMessages.push({ id: entry.id, to: entry.targetPlayerId, text: '[restart requested]', timestamp: entry.createdAt });
|
|
307
|
+
}
|
|
308
|
+
else if (entry.type === 'release') {
|
|
309
|
+
sentMessages.push({ id: entry.id, to: entry.targetPlayerId, text: '[release requested]', timestamp: entry.createdAt });
|
|
310
|
+
}
|
|
311
|
+
lastActivityTime = workflowNow().getTime();
|
|
312
|
+
activityCount++;
|
|
313
|
+
lastOutboundTime = workflowNow().getTime();
|
|
314
|
+
return entry.id;
|
|
315
|
+
}, {
|
|
316
|
+
validator: (entry) => {
|
|
317
|
+
if (!entry.type)
|
|
318
|
+
throw new common_1.ApplicationFailure('Outbox entry must have a type', 'InvalidOutboxEntry', true);
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
(0, workflow_1.setHandler)(signals_1.outboxQuery, () => outbox);
|
|
322
|
+
// ── Player Signal Handlers ──
|
|
323
|
+
(0, workflow_1.setHandler)(signals_1.receiveMessageSignal, (msg) => {
|
|
324
|
+
messages.push({
|
|
325
|
+
id: (0, workflow_1.uuid4)(),
|
|
326
|
+
from: msg.from,
|
|
327
|
+
text: msg.text,
|
|
328
|
+
timestamp: workflowNow().toISOString(),
|
|
329
|
+
delivered: false,
|
|
330
|
+
isMaestro: msg.isMaestro,
|
|
331
|
+
// #357: thread the broadcast id (if any) onto the stored Message
|
|
332
|
+
// so subsequent `allMessages`/`fetchEnsembleChat` queries surface
|
|
333
|
+
// it for TUI grouping.
|
|
334
|
+
...(msg.broadcastId !== undefined ? { broadcastId: msg.broadcastId } : {}),
|
|
335
|
+
// #318: thread the coat-check ticket (if any) onto the stored
|
|
336
|
+
// Message so `recall` / `fetchPlayerMessages` surface it and the
|
|
337
|
+
// recipient knows to fetch via `coat_check_get`.
|
|
338
|
+
...(msg.attachmentTicket !== undefined ? { attachmentTicket: msg.attachmentTicket } : {}),
|
|
339
|
+
});
|
|
340
|
+
lastActivityTime = workflowNow().getTime();
|
|
341
|
+
activityCount++;
|
|
342
|
+
// #399 W2 — every inbound cue counts as received traffic.
|
|
343
|
+
receivedCount++;
|
|
344
|
+
// Track inbound messages that expect a response (default: true for backward compat)
|
|
345
|
+
if ((0, workflow_1.patched)('v0.20-response-requested-blocked') && msg.responseRequested !== false) {
|
|
346
|
+
lastInboundRRTime = workflowNow().getTime();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
(0, workflow_1.setHandler)(signals_1.setPartSignal, (newPart) => {
|
|
350
|
+
part = newPart;
|
|
351
|
+
lastActivityTime = workflowNow().getTime();
|
|
352
|
+
activityCount++;
|
|
353
|
+
lastOutboundTime = workflowNow().getTime();
|
|
354
|
+
});
|
|
355
|
+
(0, workflow_1.setHandler)(signals_1.setNameSignal, (newName) => {
|
|
356
|
+
input.metadata.playerId = newName;
|
|
357
|
+
(0, workflow_1.upsertSearchAttributes)({ AgentTempoPlayerId: [newName] });
|
|
358
|
+
lastActivityTime = workflowNow().getTime();
|
|
359
|
+
activityCount++;
|
|
360
|
+
});
|
|
361
|
+
(0, workflow_1.setHandler)(signals_1.markDeliveredSignal, (ids) => {
|
|
362
|
+
for (const msg of messages) {
|
|
363
|
+
if (ids.includes(msg.id)) {
|
|
364
|
+
msg.delivered = true;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Any delivery proves the session is alive
|
|
368
|
+
lastActivityTime = workflowNow().getTime();
|
|
369
|
+
activityCount++;
|
|
370
|
+
});
|
|
371
|
+
(0, workflow_1.setHandler)(signals_1.updateMetadataSignal, (update) => {
|
|
372
|
+
if (update.hostname != null)
|
|
373
|
+
input.metadata.hostname = update.hostname;
|
|
374
|
+
if (update.gitBranch != null)
|
|
375
|
+
input.metadata.gitBranch = update.gitBranch;
|
|
376
|
+
if (update.gitRoot != null)
|
|
377
|
+
input.metadata.gitRoot = update.gitRoot;
|
|
378
|
+
if (update.playerType != null)
|
|
379
|
+
input.metadata.playerType = update.playerType;
|
|
380
|
+
if (update.playerTypeDescription != null)
|
|
381
|
+
input.metadata.playerTypeDescription = update.playerTypeDescription;
|
|
382
|
+
if (update.worktreePath != null)
|
|
383
|
+
input.metadata.worktreePath = update.worktreePath;
|
|
384
|
+
if (update.sessionId != null || update.claudeSessionId != null) {
|
|
385
|
+
input.metadata.sessionId = update.sessionId ?? update.claudeSessionId;
|
|
386
|
+
}
|
|
387
|
+
// `update.enableStaleDetection` is silently dropped — attachment phase
|
|
388
|
+
// (driven by the V2 wire surface: claimAttachment / adapterExited /
|
|
389
|
+
// forceDetach / destroy) is authoritative for lifecycle state.
|
|
390
|
+
(0, workflow_1.upsertSearchAttributes)({
|
|
391
|
+
AgentTempoEnsemble: [input.metadata.ensemble],
|
|
392
|
+
AgentTempoPlayerId: [input.metadata.playerId],
|
|
393
|
+
AgentTempoHostname: [input.metadata.hostname],
|
|
394
|
+
...(input.metadata.gitRoot ? { AgentTempoGitRoot: [input.metadata.gitRoot] } : {}),
|
|
395
|
+
...(input.metadata.playerType ? { AgentTempoPlayerType: [input.metadata.playerType] } : {}),
|
|
396
|
+
AgentTempoIsConductor: [input.metadata.isConductor === true],
|
|
397
|
+
});
|
|
398
|
+
lastActivityTime = workflowNow().getTime();
|
|
399
|
+
activityCount++;
|
|
400
|
+
});
|
|
401
|
+
(0, workflow_1.setHandler)(signals_1.recordSentMessageSignal, (msg) => {
|
|
402
|
+
sentMessages.push({
|
|
403
|
+
id: (0, workflow_1.uuid4)(),
|
|
404
|
+
to: msg.to,
|
|
405
|
+
text: msg.text,
|
|
406
|
+
timestamp: workflowNow().toISOString(),
|
|
407
|
+
// #357: mirror Message.broadcastId on the sender side so the
|
|
408
|
+
// TUI's local-side projection sees the same fold key.
|
|
409
|
+
...(msg.broadcastId !== undefined ? { broadcastId: msg.broadcastId } : {}),
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
// ── Player Query Handlers ──
|
|
413
|
+
(0, workflow_1.setHandler)(signals_1.getPartQuery, () => part);
|
|
414
|
+
(0, workflow_1.setHandler)(signals_1.getMetadataQuery, () => input.metadata);
|
|
415
|
+
(0, workflow_1.setHandler)(signals_1.pendingMessagesQuery, () => messages.filter((m) => !m.delivered));
|
|
416
|
+
(0, workflow_1.setHandler)(signals_1.allMessagesQuery, () => messages);
|
|
417
|
+
(0, workflow_1.setHandler)(signals_1.allSentMessagesQuery, () => sentMessages);
|
|
418
|
+
// ── #399 W2 — Wire extension queries (Q5.2 / Q5.5 / Q5.6 / Q5.7) ──
|
|
419
|
+
(0, workflow_1.setHandler)(signals_1.getRunIdQuery, () => (0, workflow_1.workflowInfo)().runId);
|
|
420
|
+
(0, workflow_1.setHandler)(signals_1.getMessagingStateQuery, () => ({
|
|
421
|
+
received: receivedCount,
|
|
422
|
+
sent: sentCount,
|
|
423
|
+
outbox: outboxStatus(),
|
|
424
|
+
}));
|
|
425
|
+
(0, workflow_1.setHandler)(signals_1.getActivityStateQuery, () => ({
|
|
426
|
+
activityCount,
|
|
427
|
+
lastActivityAt: new Date(lastActivityTime).toISOString(),
|
|
428
|
+
}));
|
|
429
|
+
(0, workflow_1.setHandler)(signals_1.getLeaseStateQuery, () => {
|
|
430
|
+
if (!currentAttachment)
|
|
431
|
+
return { expiresAt: null, leaseMs: null };
|
|
432
|
+
return {
|
|
433
|
+
expiresAt: Date.parse(currentAttachment.expiresAt),
|
|
434
|
+
leaseMs: currentAttachment.leaseMs,
|
|
435
|
+
};
|
|
436
|
+
});
|
|
437
|
+
// ── Hold / Release Handlers ──
|
|
438
|
+
(0, workflow_1.setHandler)(signals_1.releaseHeldSignal, () => {
|
|
439
|
+
if (heldMessage) {
|
|
440
|
+
// Deliver the stored initial message now that the hold is released
|
|
441
|
+
messages.push({
|
|
442
|
+
id: (0, workflow_1.uuid4)(),
|
|
443
|
+
from: input.metadata.recruitedBy || 'system',
|
|
444
|
+
text: heldMessage,
|
|
445
|
+
timestamp: workflowNow().toISOString(),
|
|
446
|
+
delivered: false,
|
|
447
|
+
});
|
|
448
|
+
heldMessage = undefined;
|
|
449
|
+
}
|
|
450
|
+
outboxLocked = false;
|
|
451
|
+
});
|
|
452
|
+
(0, workflow_1.setHandler)(signals_1.outboxLockedQuery, () => outboxLocked);
|
|
453
|
+
// ── Pause / Resume Handlers ──
|
|
454
|
+
(0, workflow_1.setHandler)(signals_1.setPausedSignal, (value) => {
|
|
455
|
+
paused = value;
|
|
456
|
+
});
|
|
457
|
+
(0, workflow_1.setHandler)(signals_1.pausedQuery, () => paused);
|
|
458
|
+
// ── Processing Lifecycle Handlers (fixes #99; v0.25 phase-aware) ──
|
|
459
|
+
(0, workflow_1.setHandler)(signals_1.processingStartUpdate, ({ messageId, expectedAttachmentId }) => {
|
|
460
|
+
// `expectedAttachmentId` is optional for shim compatibility; when provided, only operate
|
|
461
|
+
// if it matches the current attachment (prevents late updates from a superseded adapter).
|
|
462
|
+
if (expectedAttachmentId && currentAttachment?.attachmentId !== expectedAttachmentId) {
|
|
463
|
+
throw common_1.ApplicationFailure.nonRetryable(`Attachment ${expectedAttachmentId} does not match current ${currentAttachment?.attachmentId ?? 'none'}`, 'AttachmentMismatch');
|
|
464
|
+
}
|
|
465
|
+
const wasEmpty = inFlightMessages.size === 0;
|
|
466
|
+
inFlightMessages.add(messageId);
|
|
467
|
+
if (wasEmpty) {
|
|
468
|
+
processingSince = workflowNow().toISOString();
|
|
469
|
+
// Phase refinement: if we're attached (or awaiting), move to `processing`.
|
|
470
|
+
if (phase === 'attached' || phase === 'awaiting')
|
|
471
|
+
setPhase('processing');
|
|
472
|
+
}
|
|
473
|
+
lastActivityTime = workflowNow().getTime();
|
|
474
|
+
activityCount++;
|
|
475
|
+
return { inFlightCount: inFlightMessages.size };
|
|
476
|
+
}, {
|
|
477
|
+
validator: ({ messageId }) => {
|
|
478
|
+
if (!messageId || typeof messageId !== 'string') {
|
|
479
|
+
throw common_1.ApplicationFailure.nonRetryable('processingStart requires a non-empty messageId', 'InvalidMessageId');
|
|
480
|
+
}
|
|
481
|
+
if (phase === 'gone') {
|
|
482
|
+
throw common_1.ApplicationFailure.nonRetryable('Cannot start processing on destroyed session', 'WorkflowGone');
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
(0, workflow_1.setHandler)(signals_1.processingEndUpdate, ({ messageId, expectedAttachmentId }) => {
|
|
487
|
+
if (expectedAttachmentId && currentAttachment?.attachmentId !== expectedAttachmentId) {
|
|
488
|
+
throw common_1.ApplicationFailure.nonRetryable(`Attachment ${expectedAttachmentId} does not match current ${currentAttachment?.attachmentId ?? 'none'}`, 'AttachmentMismatch');
|
|
489
|
+
}
|
|
490
|
+
inFlightMessages.delete(messageId);
|
|
491
|
+
if (inFlightMessages.size === 0) {
|
|
492
|
+
processingSince = null;
|
|
493
|
+
// Phase refinement (§2.2, §2.4; fixes #117): when in-flight drops to 0, move
|
|
494
|
+
// back out of `processing`. If the outbox has nothing left to dispatch, land
|
|
495
|
+
// directly in `awaiting` (idle refinement of attached). Otherwise `attached`,
|
|
496
|
+
// and the main-loop outbox-dispatch drain will refine to `awaiting` once the
|
|
497
|
+
// outbox clears.
|
|
498
|
+
if (phase === 'processing') {
|
|
499
|
+
const outboxIdle = !outbox.some((e) => e.status === 'pending' || e.status === 'processing');
|
|
500
|
+
setPhase(outboxIdle ? 'awaiting' : 'attached');
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
lastActivityTime = workflowNow().getTime();
|
|
504
|
+
activityCount++;
|
|
505
|
+
return { inFlightCount: inFlightMessages.size };
|
|
506
|
+
}, {
|
|
507
|
+
validator: ({ messageId }) => {
|
|
508
|
+
if (!messageId || typeof messageId !== 'string') {
|
|
509
|
+
throw common_1.ApplicationFailure.nonRetryable('processingEnd requires a non-empty messageId', 'InvalidMessageId');
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
(0, workflow_1.setHandler)(signals_1.inFlightMessagesQuery, () => [...inFlightMessages]);
|
|
514
|
+
// ── Destroy Handler (fixes #102; design §8.5) ──
|
|
515
|
+
// Terminal: set phase = gone, revoke attachment, emit audit event with abandoned outbox
|
|
516
|
+
// IDs, return from main loop → workflow COMPLETES. Per §2.5: abandon in-flight outbox
|
|
517
|
+
// (no drain wait) — destroy is an explicit operator action; delivery is best-effort.
|
|
518
|
+
//
|
|
519
|
+
// #164: the handler is `async` because it also fires `hardTerminateAttachment` on the
|
|
520
|
+
// host's per-host task queue before the state flip, to prevent an orphaned claude.exe
|
|
521
|
+
// when destroy is invoked while an attachment is live. Unlike `forceDetachUpdate` this
|
|
522
|
+
// is wrapped best-effort — a failure there MUST NOT wedge the workflow, because destroy
|
|
523
|
+
// is terminal by contract. See issue #164 for the orphan repro.
|
|
524
|
+
(0, workflow_1.setHandler)(signals_1.destroyUpdate, async ({ reason, terminatedBy }) => {
|
|
525
|
+
if (phase === 'gone')
|
|
526
|
+
return; // idempotent
|
|
527
|
+
// Record abandoned outbox entries for the history/audit event.
|
|
528
|
+
destroyAbandonedIds = outbox
|
|
529
|
+
.filter((e) => e.status === 'pending' || e.status === 'processing')
|
|
530
|
+
.map((e) => e.id);
|
|
531
|
+
if (destroyAbandonedIds.length > 0) {
|
|
532
|
+
workflow_1.log.warn(`destroy abandoning ${destroyAbandonedIds.length} outbox entr${destroyAbandonedIds.length === 1 ? 'y' : 'ies'}: ${destroyAbandonedIds.join(', ')}` +
|
|
533
|
+
`${reason ? ` (reason: ${reason})` : ''}`);
|
|
534
|
+
}
|
|
535
|
+
else if (reason) {
|
|
536
|
+
workflow_1.log.info(`destroy requested (reason: ${reason})`);
|
|
537
|
+
}
|
|
538
|
+
// #164: await hardTerminate BEFORE flipping `destroyRequested` / `phase`. Must
|
|
539
|
+
// await (fire-and-forget is dropped because the workflow's main loop exits as
|
|
540
|
+
// soon as destroyRequested=true, before the activity has a chance to dispatch).
|
|
541
|
+
// Best-effort: 5s timeout, log-and-continue on failure. `destroyRequested` flips
|
|
542
|
+
// AFTER the activity so the main loop stays alive to dispatch it.
|
|
543
|
+
//
|
|
544
|
+
// #227: the original (#164) guard was `if (currentAttachment)` — correct for the
|
|
545
|
+
// `phase=attached` case but silently skipped the kill when `phase=detached`, leaking
|
|
546
|
+
// `claude.exe` + terminal tab when a destroy ran on a session whose lease had been
|
|
547
|
+
// reaped. The ensemble cascade exposed by #226/#201 made this a reliable orphan
|
|
548
|
+
// generator: destroy → workflow COMPLETES, process survives. Fix: pick the best
|
|
549
|
+
// host we have from workflow state and fire the kill whenever *any* host is known.
|
|
550
|
+
//
|
|
551
|
+
// `hardTerminateAttachment` is already safe to run speculatively — it does image-
|
|
552
|
+
// name PID-reuse guards + command-line matching on `-n <playerName>` AND
|
|
553
|
+
// `--remote-control-session-name-prefix <ensemble>`, so a stale state that no
|
|
554
|
+
// longer corresponds to a live process returns `strategy: 'none'` with a clean
|
|
555
|
+
// log line. No new PID / attach-time wire-protocol fields needed.
|
|
556
|
+
//
|
|
557
|
+
// Host-pick priority (aligns with the reap path's provenance tracking):
|
|
558
|
+
// 1. `currentAttachment.hostname` — `phase=attached` / `processing` / `awaiting`
|
|
559
|
+
// 2. `lastAdapterMeta.hostname` — `phase=detached` (set by forceDetach / reap)
|
|
560
|
+
// 3. `preferredHost` — post-CAN restore before any adapter landed
|
|
561
|
+
// 4. `input.metadata.hostname` — recruit-time fallback (booting path)
|
|
562
|
+
const killHost = currentAttachment?.hostname ??
|
|
563
|
+
lastAdapterMeta?.hostname ??
|
|
564
|
+
preferredHost ??
|
|
565
|
+
input.metadata.hostname;
|
|
566
|
+
if (killHost) {
|
|
567
|
+
try {
|
|
568
|
+
const killResult = await getHardTerminateProxyForDestroy(killHost)({
|
|
569
|
+
ensemble: input.metadata.ensemble,
|
|
570
|
+
playerName: input.metadata.playerId,
|
|
571
|
+
agent: (input.metadata.agentType ?? 'claude'),
|
|
572
|
+
workDir: input.metadata.workDir,
|
|
573
|
+
});
|
|
574
|
+
workflow_1.log.info(`destroy hard-terminate on ${killHost} (phase=${phase}): strategy=${killResult.strategy}, ` +
|
|
575
|
+
`killedPids=[${killResult.killedPids.join(',')}]`);
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
workflow_1.log.warn(`destroy hard-terminate failed on ${killHost} (continuing, best-effort): ` +
|
|
579
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// Flip destroyRequested AFTER the kill so the main loop stays alive for activity
|
|
583
|
+
// dispatch. Any concurrent claimAttachment / processingStart that arrives during
|
|
584
|
+
// the 5s kill window will still hit the phase!=='gone' guard until we setPhase
|
|
585
|
+
// below; on rare kill-in-progress races, the new work is abandoned by setPhase('gone').
|
|
586
|
+
destroyRequested = true;
|
|
587
|
+
// Revoke attachment (if any) — record metadata for orphanSummary/audit.
|
|
588
|
+
if (currentAttachment) {
|
|
589
|
+
lastAdapterMeta = { hostname: currentAttachment.hostname, adapterId: currentAttachment.adapterId };
|
|
590
|
+
lastDetachReason = 'destroy';
|
|
591
|
+
currentAttachment = null;
|
|
592
|
+
}
|
|
593
|
+
(0, workflow_1.upsertSearchAttributes)({
|
|
594
|
+
AgentTempoAttachedHost: [''],
|
|
595
|
+
AgentTempoAttachmentId: [''],
|
|
596
|
+
});
|
|
597
|
+
setPhase('gone');
|
|
598
|
+
// Inject a final audit message so the old adapter-completion path has something to show.
|
|
599
|
+
messages.push({
|
|
600
|
+
id: (0, workflow_1.uuid4)(),
|
|
601
|
+
from: terminatedBy || 'system',
|
|
602
|
+
text: `Session destroyed${reason ? `: ${reason}` : ''}.`,
|
|
603
|
+
timestamp: workflowNow().toISOString(),
|
|
604
|
+
delivered: false,
|
|
605
|
+
});
|
|
606
|
+
lastActivityTime = workflowNow().getTime();
|
|
607
|
+
activityCount++;
|
|
608
|
+
});
|
|
609
|
+
(0, workflow_1.setHandler)(signals_1.isDestroyedQuery, () => destroyed || destroyRequested);
|
|
610
|
+
// ── Test-only CAN trigger (#226) ──
|
|
611
|
+
// Force the next main-loop iteration into the `continueAsNew` branch without
|
|
612
|
+
// waiting for the server's history-size threshold. Production code never sends
|
|
613
|
+
// this; the adapter reconnect test uses it to exercise the CAN-boundary path
|
|
614
|
+
// in <1s instead of emitting ~10k filler events. One-shot: the flag is cleared
|
|
615
|
+
// when the main loop acts on it, so repeated signals require repeated sends.
|
|
616
|
+
//
|
|
617
|
+
// The `wakeEpoch` bump is essential — the main loop's `condition()` predicate
|
|
618
|
+
// (see §9.5 loop body below) only wakes on outbox activity, phase changes,
|
|
619
|
+
// destroy, or `wakeEpoch` drift. Without the bump, an idle session would sit
|
|
620
|
+
// asleep until its lease-expiry deadline and the test would time out.
|
|
621
|
+
let forceContinueAsNew = false;
|
|
622
|
+
(0, workflow_1.setHandler)(signals_1.testForceContinueAsNewSignal, () => {
|
|
623
|
+
forceContinueAsNew = true;
|
|
624
|
+
wakeEpoch++;
|
|
625
|
+
lastActivityTime = workflowNow().getTime();
|
|
626
|
+
activityCount++;
|
|
627
|
+
});
|
|
628
|
+
// ── v0.25 Attachment Lifecycle Handlers (design §§8, §9.2, §9.5) ──
|
|
629
|
+
/**
|
|
630
|
+
* `claimAttachment` — transactional claim / renewal of the attachment lease.
|
|
631
|
+
* Pseudocode and behavior per design §9.2.
|
|
632
|
+
*/
|
|
633
|
+
(0, workflow_1.setHandler)(signals_1.claimAttachmentUpdate, ({ host, adapterId, adapterClass, leaseMs, expectedAttachmentId }) => {
|
|
634
|
+
if (phase === 'gone') {
|
|
635
|
+
throw common_1.ApplicationFailure.nonRetryable(`Cannot attach to ${(0, workflow_1.workflowInfo)().workflowId}: workflow is terminated`, 'WorkflowGone');
|
|
636
|
+
}
|
|
637
|
+
const now = workflowNow();
|
|
638
|
+
const nowMs = now.getTime();
|
|
639
|
+
// Renewal path: caller provides a valid expectedAttachmentId matching the current
|
|
640
|
+
// attachment, and the lease hasn't expired yet.
|
|
641
|
+
if (currentAttachment &&
|
|
642
|
+
currentAttachment.attachmentId === expectedAttachmentId &&
|
|
643
|
+
new Date(currentAttachment.expiresAt).getTime() > nowMs) {
|
|
644
|
+
currentAttachment.lastHeartbeatAt = now.toISOString();
|
|
645
|
+
currentAttachment.expiresAt = new Date(nowMs + leaseMs).toISOString();
|
|
646
|
+
// Honour the caller's renewal-time leaseMs so subsequent heartbeats extend
|
|
647
|
+
// the lease by the current negotiated value (not the claim-time value).
|
|
648
|
+
currentAttachment.leaseMs = leaseMs;
|
|
649
|
+
lastActivityTime = nowMs;
|
|
650
|
+
activityCount++;
|
|
651
|
+
return attachmentTokenFrom(currentAttachment, leaseMs);
|
|
652
|
+
}
|
|
653
|
+
// Conflict: active lease held by someone else.
|
|
654
|
+
if (currentAttachment && new Date(currentAttachment.expiresAt).getTime() > nowMs) {
|
|
655
|
+
throw common_1.ApplicationFailure.nonRetryable(`Attached on ${currentAttachment.hostname} until ${currentAttachment.expiresAt}`, 'AttachmentConflict');
|
|
656
|
+
}
|
|
657
|
+
// Free or expired — claim fresh.
|
|
658
|
+
const newAttachment = {
|
|
659
|
+
attachmentId: (0, workflow_1.uuid4)(),
|
|
660
|
+
hostname: host,
|
|
661
|
+
adapterId,
|
|
662
|
+
adapterClass,
|
|
663
|
+
claimedAt: now.toISOString(),
|
|
664
|
+
lastHeartbeatAt: now.toISOString(),
|
|
665
|
+
expiresAt: new Date(nowMs + leaseMs).toISOString(),
|
|
666
|
+
leaseMs,
|
|
667
|
+
runId: (0, workflow_1.workflowInfo)().runId,
|
|
668
|
+
};
|
|
669
|
+
currentAttachment = newAttachment;
|
|
670
|
+
lastAdapterMeta = { hostname: newAttachment.hostname, adapterId: newAttachment.adapterId };
|
|
671
|
+
preferredHost = host;
|
|
672
|
+
// Fresh claim abandons any residual in-flight messageIds from the previous adapter.
|
|
673
|
+
inFlightMessages.clear();
|
|
674
|
+
processingSince = null;
|
|
675
|
+
// A fresh claim always supersedes an in-flight drain; clear its window so a later
|
|
676
|
+
// `requestDetach` starts from the default and doesn't inherit a stale value.
|
|
677
|
+
drainingSince = null;
|
|
678
|
+
drainingDeadlineMs = null;
|
|
679
|
+
detachedSince = null;
|
|
680
|
+
setPhase('attached');
|
|
681
|
+
(0, workflow_1.upsertSearchAttributes)({
|
|
682
|
+
AgentTempoAttachedHost: [host],
|
|
683
|
+
AgentTempoAttachmentId: [newAttachment.attachmentId],
|
|
684
|
+
});
|
|
685
|
+
lastActivityTime = nowMs;
|
|
686
|
+
activityCount++;
|
|
687
|
+
return attachmentTokenFrom(newAttachment, leaseMs);
|
|
688
|
+
}, {
|
|
689
|
+
validator: ({ leaseMs }) => {
|
|
690
|
+
if (!Number.isInteger(leaseMs) || leaseMs < 1_000 || leaseMs > 600_000) {
|
|
691
|
+
throw common_1.ApplicationFailure.nonRetryable(`leaseMs must be between 1000 and 600000, got ${leaseMs}`, 'InvalidLease');
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
/**
|
|
696
|
+
* `forceDetach` — revoke the current attachment. `expectedAttachmentId` guards against TOCTOU.
|
|
697
|
+
* `gracePeriodMs` is reserved for future use (§8.3); v0.25 PR-A ignores it and detaches
|
|
698
|
+
* immediately — PR-D's `restart` flow passes `gracePeriodMs: 0`.
|
|
699
|
+
*
|
|
700
|
+
* #159 Gap 2: this handler is the canonical "kill then flip" point. Before we null out
|
|
701
|
+
* the attachment and transition to `detached`, we invoke `hardTerminateAttachment` on the
|
|
702
|
+
* reaped host's per-host task queue so the child process tree is actually torn down at
|
|
703
|
+
* the OS level. If the activity throws, the update itself throws and the caller
|
|
704
|
+
* (`deliverRestart`, operator tooling) retries — we DON'T silently flip state while the
|
|
705
|
+
* orphan is still alive, because that produces exactly the bug reported in #159.
|
|
706
|
+
*/
|
|
707
|
+
(0, workflow_1.setHandler)(signals_1.forceDetachUpdate, async ({ reason, expectedAttachmentId }) => {
|
|
708
|
+
if (phase === 'gone') {
|
|
709
|
+
throw common_1.ApplicationFailure.nonRetryable('Workflow is terminated', 'WorkflowGone');
|
|
710
|
+
}
|
|
711
|
+
if (!currentAttachment) {
|
|
712
|
+
return { reaped: false };
|
|
713
|
+
}
|
|
714
|
+
if (expectedAttachmentId && currentAttachment.attachmentId !== expectedAttachmentId) {
|
|
715
|
+
// TOCTOU — the expected attachment is already gone; don't reap a fresh one.
|
|
716
|
+
return { reaped: false };
|
|
717
|
+
}
|
|
718
|
+
const reaped = currentAttachment;
|
|
719
|
+
const previousId = reaped.attachmentId;
|
|
720
|
+
// Kill OS process tree on the host where the adapter is actually running. In
|
|
721
|
+
// production `reaped.hostname === input.metadata.hostname` — both are the machine
|
|
722
|
+
// that spawned the child — but `metadata.hostname` is the more stable routing key
|
|
723
|
+
// (attachments can come and go during cross-host restart flows, and test harnesses
|
|
724
|
+
// sometimes set the two fields independently). Failure aborts the update so the
|
|
725
|
+
// workflow state stays in sync with what actually happened on the host.
|
|
726
|
+
const killHost = input.metadata.hostname;
|
|
727
|
+
try {
|
|
728
|
+
const killResult = await getHardTerminateProxy(killHost)({
|
|
729
|
+
ensemble: input.metadata.ensemble,
|
|
730
|
+
playerName: input.metadata.playerId,
|
|
731
|
+
agent: (input.metadata.agentType ?? 'claude'),
|
|
732
|
+
workDir: input.metadata.workDir,
|
|
733
|
+
});
|
|
734
|
+
workflow_1.log.info(`forceDetach hard-terminate on ${killHost}: strategy=${killResult.strategy}, ` +
|
|
735
|
+
`killedPids=[${killResult.killedPids.join(',')}]`);
|
|
736
|
+
}
|
|
737
|
+
catch (err) {
|
|
738
|
+
// Turn into an ApplicationFailure so the caller sees a clean retry signal rather
|
|
739
|
+
// than a raw activity timeout / cancelation.
|
|
740
|
+
throw common_1.ApplicationFailure.nonRetryable(`forceDetach hard-terminate failed on ${killHost}: ` +
|
|
741
|
+
`${err instanceof Error ? err.message : String(err)}. ` +
|
|
742
|
+
`Refusing to flip phase to detached while the OS process may still be live.`, 'HardTerminateFailed');
|
|
743
|
+
}
|
|
744
|
+
lastAdapterMeta = { hostname: reaped.hostname, adapterId: reaped.adapterId };
|
|
745
|
+
lastDetachReason = reason;
|
|
746
|
+
currentAttachment = null;
|
|
747
|
+
inFlightMessages.clear();
|
|
748
|
+
processingSince = null;
|
|
749
|
+
drainingSince = null;
|
|
750
|
+
drainingDeadlineMs = null;
|
|
751
|
+
detachedSince = workflowNow().toISOString();
|
|
752
|
+
setPhase('detached');
|
|
753
|
+
(0, workflow_1.upsertSearchAttributes)({
|
|
754
|
+
AgentTempoAttachedHost: [''],
|
|
755
|
+
AgentTempoAttachmentId: [''],
|
|
756
|
+
});
|
|
757
|
+
// #159 Gap 1b: wake the main loop — `phase === 'detached'` isn't in the predicate
|
|
758
|
+
// and the condition would otherwise sleep on the now-stale lease-expiry deadline.
|
|
759
|
+
wakeEpoch++;
|
|
760
|
+
return { reaped: true, previousAttachmentId: previousId };
|
|
761
|
+
});
|
|
762
|
+
/**
|
|
763
|
+
* Enqueue a spawn outbox entry carrying the claim token. PR-C commit 6 (#118)
|
|
764
|
+
* replaced the double-cast `type: 'recruit'` workaround with a dedicated
|
|
765
|
+
* `SpawnOutboxEntry` discriminated-union variant. The dispatch branch
|
|
766
|
+
* (`case 'spawn':` below) routes through `startRecruitedSession` today and
|
|
767
|
+
* will be extended by PR-D to forward `attachmentId`/`runId`/`resume`/
|
|
768
|
+
* `sessionId`/`adapterId` into the activity signature so the adapter boots
|
|
769
|
+
* into the pre-claimed attachment.
|
|
770
|
+
*/
|
|
771
|
+
(0, workflow_1.setHandler)(signals_1.enqueueSpawnUpdate, ({ host, attachmentId, runId, resume, sessionId, adapterId, agentDefinition, agentDefinitionPath, nativeResolvable, model }) => {
|
|
772
|
+
const spawnEntryId = (0, workflow_1.uuid4)();
|
|
773
|
+
const entry = {
|
|
774
|
+
id: spawnEntryId,
|
|
775
|
+
type: 'spawn',
|
|
776
|
+
targetName: input.metadata.playerId,
|
|
777
|
+
workDir: input.metadata.workDir,
|
|
778
|
+
isConductor: input.metadata.isConductor,
|
|
779
|
+
agent: (input.metadata.agentType ?? 'claude'),
|
|
780
|
+
targetHostname: host,
|
|
781
|
+
attachmentId,
|
|
782
|
+
attachmentRunId: runId,
|
|
783
|
+
resumeAttachment: resume,
|
|
784
|
+
sessionId,
|
|
785
|
+
adapterId,
|
|
786
|
+
agentDefinition,
|
|
787
|
+
agentDefinitionPath,
|
|
788
|
+
nativeResolvable,
|
|
789
|
+
// #131 Phase C — claude-api model id carried across restart.
|
|
790
|
+
...(model !== undefined ? { model } : {}),
|
|
791
|
+
createdAt: workflowNow().toISOString(),
|
|
792
|
+
status: 'pending',
|
|
793
|
+
};
|
|
794
|
+
outbox.push(entry);
|
|
795
|
+
lastActivityTime = workflowNow().getTime();
|
|
796
|
+
activityCount++;
|
|
797
|
+
lastOutboundTime = workflowNow().getTime();
|
|
798
|
+
return { spawnEntryId };
|
|
799
|
+
});
|
|
800
|
+
/** Record a preferred host. Used by `reconcileOnBoot` (§10) in later PRs. */
|
|
801
|
+
(0, workflow_1.setHandler)(signals_1.setPreferredHostUpdate, ({ host }) => {
|
|
802
|
+
preferredHost = host;
|
|
803
|
+
lastActivityTime = workflowNow().getTime();
|
|
804
|
+
activityCount++;
|
|
805
|
+
});
|
|
806
|
+
/**
|
|
807
|
+
* `heartbeat` signal — extend the lease. Last-write-wins via the `attachmentId` guard;
|
|
808
|
+
* heartbeats for superseded attachments are ignored.
|
|
809
|
+
*/
|
|
810
|
+
(0, workflow_1.setHandler)(signals_1.heartbeatSignal, ({ attachmentId }) => {
|
|
811
|
+
if (!currentAttachment || currentAttachment.attachmentId !== attachmentId)
|
|
812
|
+
return;
|
|
813
|
+
const now = workflowNow();
|
|
814
|
+
currentAttachment.lastHeartbeatAt = now.toISOString();
|
|
815
|
+
// #119a: extend by the caller-negotiated `leaseMs` stored on the attachment at
|
|
816
|
+
// claim time, not a workflow-side default. Adapters with non-default lease windows
|
|
817
|
+
// (e.g. test harnesses running accelerated clocks) get the lease duration they asked for.
|
|
818
|
+
currentAttachment.expiresAt = new Date(now.getTime() + currentAttachment.leaseMs).toISOString();
|
|
819
|
+
lastActivityTime = now.getTime();
|
|
820
|
+
activityCount++;
|
|
821
|
+
});
|
|
822
|
+
/**
|
|
823
|
+
* `requestDetach` signal — adapter-initiated graceful detach. Transitions to `draining`;
|
|
824
|
+
* main loop reaps to `detached` when outbox is drained OR `drainingDeadline` elapses.
|
|
825
|
+
*
|
|
826
|
+
* Fix for #159 Gap 1a: previously this handler destructured only `reason` and threw away
|
|
827
|
+
* `deadlineMs`, so the workflow always used `DEFAULT_DRAINING_DEADLINE_MS` regardless of
|
|
828
|
+
* what the caller requested. We now store the caller's window in `drainingDeadlineMs` so
|
|
829
|
+
* `nextDeadlineMs()` and the §9.5.c reap block honor it.
|
|
830
|
+
*
|
|
831
|
+
* Fix for #159 Gap 1b: bumping `wakeEpoch` causes the main-loop `condition(...)` predicate
|
|
832
|
+
* to flip, waking it from its pre-existing (longer) timer so it re-computes `nextDeadlineMs()`
|
|
833
|
+
* with the fresh, sooner drainingDeadline. Without this, the signal lands while the loop is
|
|
834
|
+
* asleep on a lease-expiry timer and the phase stays in `draining` until that far-away
|
|
835
|
+
* timer fires — exactly the smoke-test repro in #159.
|
|
836
|
+
*/
|
|
837
|
+
(0, workflow_1.setHandler)(signals_1.requestDetachSignal, ({ reason, deadlineMs }) => {
|
|
838
|
+
if (!currentAttachment || phase === 'gone')
|
|
839
|
+
return;
|
|
840
|
+
if (phase === 'draining' || phase === 'detached')
|
|
841
|
+
return; // idempotent
|
|
842
|
+
drainingSince = workflowNow().toISOString();
|
|
843
|
+
drainingDeadlineMs =
|
|
844
|
+
typeof deadlineMs === 'number' && Number.isFinite(deadlineMs) && deadlineMs >= 0
|
|
845
|
+
? deadlineMs
|
|
846
|
+
: DEFAULT_DRAINING_DEADLINE_MS;
|
|
847
|
+
lastDetachReason = reason;
|
|
848
|
+
setPhase('draining');
|
|
849
|
+
lastActivityTime = workflowNow().getTime();
|
|
850
|
+
activityCount++;
|
|
851
|
+
wakeEpoch++;
|
|
852
|
+
});
|
|
853
|
+
/**
|
|
854
|
+
* `adapterExited` signal — collapses `draining → detached` immediately if `attachmentId` matches.
|
|
855
|
+
*/
|
|
856
|
+
(0, workflow_1.setHandler)(signals_1.adapterExitedSignal, ({ attachmentId, reason }) => {
|
|
857
|
+
if (phase === 'detached' || phase === 'gone')
|
|
858
|
+
return;
|
|
859
|
+
if (!currentAttachment || currentAttachment.attachmentId !== attachmentId)
|
|
860
|
+
return;
|
|
861
|
+
lastAdapterMeta = { hostname: currentAttachment.hostname, adapterId: currentAttachment.adapterId };
|
|
862
|
+
lastDetachReason = reason;
|
|
863
|
+
currentAttachment = null;
|
|
864
|
+
inFlightMessages.clear();
|
|
865
|
+
processingSince = null;
|
|
866
|
+
drainingSince = null;
|
|
867
|
+
drainingDeadlineMs = null;
|
|
868
|
+
detachedSince = workflowNow().toISOString();
|
|
869
|
+
setPhase('detached');
|
|
870
|
+
(0, workflow_1.upsertSearchAttributes)({
|
|
871
|
+
AgentTempoAttachedHost: [''],
|
|
872
|
+
AgentTempoAttachmentId: [''],
|
|
873
|
+
});
|
|
874
|
+
lastActivityTime = workflowNow().getTime();
|
|
875
|
+
activityCount++;
|
|
876
|
+
// Wake main loop; the pre-existing condition timer was sized for the old lease
|
|
877
|
+
// window which no longer applies.
|
|
878
|
+
wakeEpoch++;
|
|
879
|
+
});
|
|
880
|
+
/** `attachmentInfo` query — current phase + attachment state snapshot. */
|
|
881
|
+
(0, workflow_1.setHandler)(signals_1.attachmentInfoQuery, () => ({
|
|
882
|
+
phase,
|
|
883
|
+
...(currentAttachment ? { currentAttachment } : {}),
|
|
884
|
+
...(preferredHost ? { preferredHost } : {}),
|
|
885
|
+
inFlightCount: inFlightMessages.size,
|
|
886
|
+
...(processingSince ? { processingSince } : {}),
|
|
887
|
+
}));
|
|
888
|
+
/** `orphanSummary` query — daemon/CLI restore metadata when phase === 'detached'. */
|
|
889
|
+
(0, workflow_1.setHandler)(signals_1.orphanSummaryQuery, () => ({
|
|
890
|
+
ensemble: input.metadata.ensemble,
|
|
891
|
+
playerId: input.metadata.playerId,
|
|
892
|
+
...(detachedSince ? { detachedSince } : {}),
|
|
893
|
+
...(lastDetachReason ? { reason: lastDetachReason } : {}),
|
|
894
|
+
...(preferredHost ? { preferredHost } : {}),
|
|
895
|
+
...(lastAdapterMeta ? { lastAdapter: lastAdapterMeta } : {}),
|
|
896
|
+
}));
|
|
897
|
+
// ── Player Saveable State Handlers (#334 PR-1, ADR 0011) ──
|
|
898
|
+
//
|
|
899
|
+
// Validators run pre-handler so size/key/slot-cap rejections never commit
|
|
900
|
+
// history events. Handler bodies trust their inputs and stay trivially
|
|
901
|
+
// deterministic. `workflow.now()` is SDK-intercepted so `savedAt` is
|
|
902
|
+
// replay-deterministic.
|
|
903
|
+
const assertValidPlayerStateKey = (key) => {
|
|
904
|
+
if (typeof key !== 'string' || !validation_1.PLAYER_STATE_KEY_REGEX.test(key) || key.length > validation_1.PLAYER_STATE_KEY_MAX) {
|
|
905
|
+
throw common_1.ApplicationFailure.nonRetryable(`Invalid playerState key "${key}" — must match ${validation_1.PLAYER_STATE_KEY_REGEX} and be ≤ ${validation_1.PLAYER_STATE_KEY_MAX} chars`, 'PlayerStateInvalidKey');
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
(0, workflow_1.setHandler)(signals_1.savePlayerStateUpdate, ({ key, content, savedBy }) => {
|
|
909
|
+
playerState[key] = {
|
|
910
|
+
content,
|
|
911
|
+
savedAt: workflowNow().toISOString(),
|
|
912
|
+
savedBy,
|
|
913
|
+
};
|
|
914
|
+
lastActivityTime = workflowNow().getTime();
|
|
915
|
+
activityCount++;
|
|
916
|
+
return { saved: true, savedAt: playerState[key].savedAt };
|
|
917
|
+
}, {
|
|
918
|
+
validator: ({ key, content }) => {
|
|
919
|
+
assertValidPlayerStateKey(key);
|
|
920
|
+
if (typeof content !== 'string') {
|
|
921
|
+
throw common_1.ApplicationFailure.nonRetryable('playerState content must be a string', 'PlayerStateInvalidContent');
|
|
922
|
+
}
|
|
923
|
+
// `TextEncoder` is replay-safe (pure string→bytes); `Buffer` is Node-only
|
|
924
|
+
// and not available in the workflow sandbox.
|
|
925
|
+
if (new TextEncoder().encode(content).length > validation_1.PLAYER_STATE_CONTENT_MAX) {
|
|
926
|
+
throw common_1.ApplicationFailure.nonRetryable(`playerState content exceeds ${validation_1.PLAYER_STATE_CONTENT_MAX} bytes`, 'PlayerStateContentTooLarge');
|
|
927
|
+
}
|
|
928
|
+
if (!(key in playerState) && Object.keys(playerState).length >= validation_1.PLAYER_STATE_SLOTS_MAX) {
|
|
929
|
+
const existingKeys = Object.keys(playerState).sort().join(', ');
|
|
930
|
+
throw common_1.ApplicationFailure.nonRetryable(`playerState slots full (${validation_1.PLAYER_STATE_SLOTS_MAX}). Clear one before saving "${key}". Existing slots: ${existingKeys}`, 'PlayerStateSlotsFull');
|
|
931
|
+
}
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
(0, workflow_1.setHandler)(signals_1.clearPlayerStateUpdate, ({ key }) => {
|
|
935
|
+
if (!(key in playerState))
|
|
936
|
+
return { cleared: false };
|
|
937
|
+
delete playerState[key];
|
|
938
|
+
lastActivityTime = workflowNow().getTime();
|
|
939
|
+
activityCount++;
|
|
940
|
+
return { cleared: true };
|
|
941
|
+
}, {
|
|
942
|
+
validator: ({ key }) => assertValidPlayerStateKey(key),
|
|
943
|
+
});
|
|
944
|
+
(0, workflow_1.setHandler)(signals_1.playerStateQuery, ({ key } = {}) => {
|
|
945
|
+
const k = key ?? validation_1.PLAYER_STATE_DEFAULT_KEY;
|
|
946
|
+
return playerState[k] ?? null;
|
|
947
|
+
});
|
|
948
|
+
(0, workflow_1.setHandler)(signals_1.playerStateKeysQuery, () => Object.keys(playerState).sort());
|
|
949
|
+
// ── Conductor State ──
|
|
950
|
+
const commandHistory = input.commandHistory ?? [];
|
|
951
|
+
const reportHistory = input.reportHistory ?? [];
|
|
952
|
+
const qualityGates = input.qualityGates ?? [];
|
|
953
|
+
const worktrees = input.worktrees ?? [];
|
|
954
|
+
const stages = input.stages ?? [];
|
|
955
|
+
// ── Conductor-specific Handlers ──
|
|
956
|
+
if (input.metadata.isConductor) {
|
|
957
|
+
(0, workflow_1.setHandler)(signals_1.commandSignal, (cmd) => {
|
|
958
|
+
commandHistory.push({
|
|
959
|
+
...cmd,
|
|
960
|
+
timestamp: workflowNow().toISOString(),
|
|
961
|
+
});
|
|
962
|
+
// Deliver command as a message to self so the conductor's Claude session sees it
|
|
963
|
+
messages.push({
|
|
964
|
+
id: (0, workflow_1.uuid4)(),
|
|
965
|
+
from: cmd.source,
|
|
966
|
+
text: cmd.text,
|
|
967
|
+
timestamp: workflowNow().toISOString(),
|
|
968
|
+
delivered: false,
|
|
969
|
+
});
|
|
970
|
+
// Command processing counts as implicit outbound for blocked detection
|
|
971
|
+
lastActivityTime = workflowNow().getTime();
|
|
972
|
+
activityCount++;
|
|
973
|
+
lastOutboundTime = workflowNow().getTime();
|
|
974
|
+
});
|
|
975
|
+
(0, workflow_1.setHandler)(signals_1.playerReportSignal, (report) => {
|
|
976
|
+
reportHistory.push({
|
|
977
|
+
...report,
|
|
978
|
+
timestamp: workflowNow().toISOString(),
|
|
979
|
+
});
|
|
980
|
+
// Deliver report as a message to self
|
|
981
|
+
messages.push({
|
|
982
|
+
id: (0, workflow_1.uuid4)(),
|
|
983
|
+
from: report.playerId,
|
|
984
|
+
text: `[${report.type}] ${report.text}`,
|
|
985
|
+
timestamp: workflowNow().toISOString(),
|
|
986
|
+
delivered: false,
|
|
987
|
+
});
|
|
988
|
+
// ── Stage tracking: update player status in any active stage ──
|
|
989
|
+
for (const stage of stages) {
|
|
990
|
+
if (stage.status !== 'active')
|
|
991
|
+
continue;
|
|
992
|
+
const playerEntry = stage.players.find((p) => p.playerId === report.playerId);
|
|
993
|
+
if (!playerEntry || playerEntry.status !== 'waiting')
|
|
994
|
+
continue;
|
|
995
|
+
const now = workflowNow().toISOString();
|
|
996
|
+
if (report.type === 'result') {
|
|
997
|
+
playerEntry.status = 'reported';
|
|
998
|
+
playerEntry.reportType = 'result';
|
|
999
|
+
playerEntry.reportText = report.text;
|
|
1000
|
+
playerEntry.reportedAt = now;
|
|
1001
|
+
}
|
|
1002
|
+
else if (report.type === 'blocker') {
|
|
1003
|
+
playerEntry.status = 'blocked';
|
|
1004
|
+
playerEntry.reportType = 'blocker';
|
|
1005
|
+
playerEntry.reportText = report.text;
|
|
1006
|
+
playerEntry.reportedAt = now;
|
|
1007
|
+
// Halt policy: fail stage immediately on any blocker
|
|
1008
|
+
if (stage.failurePolicy === 'halt') {
|
|
1009
|
+
stage.status = 'failed';
|
|
1010
|
+
stage.completedAt = now;
|
|
1011
|
+
messages.push({
|
|
1012
|
+
id: (0, workflow_1.uuid4)(),
|
|
1013
|
+
from: '_stage',
|
|
1014
|
+
text: `[stage failed] "${stage.name}" halted — ${report.playerId} reported blocker: ${report.text}`,
|
|
1015
|
+
timestamp: now,
|
|
1016
|
+
delivered: false,
|
|
1017
|
+
});
|
|
1018
|
+
continue; // Don't check completion for a failed stage
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
else {
|
|
1022
|
+
// 'question' or 'update' — no stage effect, player is still working
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
// Check if all players in the stage are done (reported or blocked)
|
|
1026
|
+
const allDone = stage.players.every((p) => p.status !== 'waiting');
|
|
1027
|
+
if (allDone) {
|
|
1028
|
+
const blocked = stage.players.filter((p) => p.status === 'blocked');
|
|
1029
|
+
if (blocked.length > 0) {
|
|
1030
|
+
// Some players blocked (continue policy — didn't halt above)
|
|
1031
|
+
stage.status = 'failed';
|
|
1032
|
+
stage.completedAt = now;
|
|
1033
|
+
const blockerNames = blocked.map((p) => p.playerId).join(', ');
|
|
1034
|
+
messages.push({
|
|
1035
|
+
id: (0, workflow_1.uuid4)(),
|
|
1036
|
+
from: '_stage',
|
|
1037
|
+
text: `[stage failed] "${stage.name}" completed with ${blocked.length} blocker(s): ${blockerNames}`,
|
|
1038
|
+
timestamp: now,
|
|
1039
|
+
delivered: false,
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
// All players reported successfully
|
|
1044
|
+
stage.status = 'complete';
|
|
1045
|
+
stage.completedAt = now;
|
|
1046
|
+
messages.push({
|
|
1047
|
+
id: (0, workflow_1.uuid4)(),
|
|
1048
|
+
from: '_stage',
|
|
1049
|
+
text: `[stage complete] "${stage.name}" — all ${stage.players.length} players reported successfully.`,
|
|
1050
|
+
timestamp: now,
|
|
1051
|
+
delivered: false,
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
(0, workflow_1.setHandler)(signals_1.historyQuery, () => {
|
|
1058
|
+
const entries = [
|
|
1059
|
+
...commandHistory.map((c) => ({
|
|
1060
|
+
type: 'command',
|
|
1061
|
+
timestamp: c.timestamp,
|
|
1062
|
+
data: c,
|
|
1063
|
+
})),
|
|
1064
|
+
...reportHistory.map((r) => ({
|
|
1065
|
+
type: 'report',
|
|
1066
|
+
timestamp: r.timestamp,
|
|
1067
|
+
data: r,
|
|
1068
|
+
})),
|
|
1069
|
+
];
|
|
1070
|
+
return entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
1071
|
+
});
|
|
1072
|
+
// ── Quality Gate Handlers ──
|
|
1073
|
+
/** Derive aggregate gate status from individual criteria. */
|
|
1074
|
+
function deriveGateStatus(gate) {
|
|
1075
|
+
if (gate.criteria.length === 0)
|
|
1076
|
+
return 'open';
|
|
1077
|
+
if (gate.criteria.some((c) => c.status === 'failed'))
|
|
1078
|
+
return 'failed';
|
|
1079
|
+
if (gate.criteria.every((c) => c.status === 'passed'))
|
|
1080
|
+
return 'passed';
|
|
1081
|
+
return 'open';
|
|
1082
|
+
}
|
|
1083
|
+
(0, workflow_1.setHandler)(signals_1.setQualityGateSignal, ({ task, criteria, createdBy }) => {
|
|
1084
|
+
const existing = qualityGates.findIndex((g) => g.task === task);
|
|
1085
|
+
const gate = {
|
|
1086
|
+
task,
|
|
1087
|
+
criteria: criteria.map((text) => ({ text, status: 'pending' })),
|
|
1088
|
+
createdBy,
|
|
1089
|
+
createdAt: workflowNow().toISOString(),
|
|
1090
|
+
status: 'open',
|
|
1091
|
+
};
|
|
1092
|
+
if (existing >= 0) {
|
|
1093
|
+
qualityGates[existing] = gate;
|
|
1094
|
+
}
|
|
1095
|
+
else {
|
|
1096
|
+
qualityGates.push(gate);
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
(0, workflow_1.setHandler)(signals_1.evaluateGateCriteriaSignal, ({ task, evaluations, evaluatedBy }) => {
|
|
1100
|
+
const gate = qualityGates.find((g) => g.task === task);
|
|
1101
|
+
if (!gate)
|
|
1102
|
+
return;
|
|
1103
|
+
const now = workflowNow().toISOString();
|
|
1104
|
+
for (const ev of evaluations) {
|
|
1105
|
+
if (ev.index >= 0 && ev.index < gate.criteria.length) {
|
|
1106
|
+
gate.criteria[ev.index].status = ev.status;
|
|
1107
|
+
gate.criteria[ev.index].evaluatedBy = evaluatedBy;
|
|
1108
|
+
gate.criteria[ev.index].evaluatedAt = now;
|
|
1109
|
+
if (ev.notes)
|
|
1110
|
+
gate.criteria[ev.index].notes = ev.notes;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
gate.status = deriveGateStatus(gate);
|
|
1114
|
+
});
|
|
1115
|
+
(0, workflow_1.setHandler)(signals_1.qualityGatesQuery, () => qualityGates);
|
|
1116
|
+
// ── Worktree Handlers ──
|
|
1117
|
+
(0, workflow_1.setHandler)(signals_1.setWorktreeSignal, (entry) => {
|
|
1118
|
+
const existing = worktrees.findIndex((w) => w.player === entry.player);
|
|
1119
|
+
if (existing >= 0) {
|
|
1120
|
+
worktrees[existing] = entry;
|
|
1121
|
+
}
|
|
1122
|
+
else {
|
|
1123
|
+
worktrees.push(entry);
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
(0, workflow_1.setHandler)(signals_1.removeWorktreeSignal, (playerName) => {
|
|
1127
|
+
const idx = worktrees.findIndex((w) => w.player === playerName);
|
|
1128
|
+
if (idx >= 0) {
|
|
1129
|
+
worktrees.splice(idx, 1);
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
(0, workflow_1.setHandler)(signals_1.worktreesQuery, () => worktrees);
|
|
1133
|
+
// ── Stage Handlers ──
|
|
1134
|
+
(0, workflow_1.setHandler)(signals_1.setStageSignal, ({ name, players, failurePolicy, createdBy }) => {
|
|
1135
|
+
const entry = {
|
|
1136
|
+
name,
|
|
1137
|
+
players: players.map((playerId) => ({
|
|
1138
|
+
playerId,
|
|
1139
|
+
status: 'waiting',
|
|
1140
|
+
})),
|
|
1141
|
+
status: 'active',
|
|
1142
|
+
failurePolicy: failurePolicy || 'halt',
|
|
1143
|
+
createdAt: workflowNow().toISOString(),
|
|
1144
|
+
createdBy,
|
|
1145
|
+
};
|
|
1146
|
+
const existing = stages.findIndex((s) => s.name === name);
|
|
1147
|
+
if (existing >= 0) {
|
|
1148
|
+
stages[existing] = entry;
|
|
1149
|
+
}
|
|
1150
|
+
else {
|
|
1151
|
+
stages.push(entry);
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
(0, workflow_1.setHandler)(signals_1.cancelStageSignal, (name) => {
|
|
1155
|
+
const stage = stages.find((s) => s.name === name);
|
|
1156
|
+
if (stage && stage.status === 'active') {
|
|
1157
|
+
stage.status = 'cancelled';
|
|
1158
|
+
stage.completedAt = workflowNow().toISOString();
|
|
1159
|
+
// Notify conductor
|
|
1160
|
+
messages.push({
|
|
1161
|
+
id: (0, workflow_1.uuid4)(),
|
|
1162
|
+
from: '_stage',
|
|
1163
|
+
text: `[stage cancelled] "${name}" was cancelled.`,
|
|
1164
|
+
timestamp: workflowNow().toISOString(),
|
|
1165
|
+
delivered: false,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
(0, workflow_1.setHandler)(signals_1.stagesQuery, () => stages);
|
|
1170
|
+
}
|
|
1171
|
+
// ── Main Loop ──
|
|
1172
|
+
//
|
|
1173
|
+
// v0.25 design §9.5: the loop is a deadline-race. On each iteration we wait for
|
|
1174
|
+
// - an outbox dispatch opportunity, OR
|
|
1175
|
+
// - a phase transition condition wake, OR
|
|
1176
|
+
// - the nearest time-based deadline (lease expiry, processingDeadline, drainingDeadline).
|
|
1177
|
+
// On wake, we handle time-based deadlines first (§9.5.a–c), then dispatch outbox entries,
|
|
1178
|
+
// then run the legacy stale/blocked heuristics (shim until PR-C), then check continueAsNew.
|
|
1179
|
+
//
|
|
1180
|
+
// The only exit from this loop is `destroyRequested === true` — the workflow never
|
|
1181
|
+
// COMPLETEs implicitly per design §2.2 invariant 2. `destroyRequested` is set
|
|
1182
|
+
// exclusively by the `destroyUpdate` handler (PR-H removed the
|
|
1183
|
+
// `updateMetadata({ status: 'terminated' })` compat shim that previously also
|
|
1184
|
+
// routed onto this flag).
|
|
1185
|
+
const hasPendingOutbox = () => outbox.some((e) => e.status === 'pending');
|
|
1186
|
+
/** Stop entries bypass pause — they must always be dispatched. */
|
|
1187
|
+
const hasPendingStop = () => outbox.some((e) => e.status === 'pending' && e.type === 'stop');
|
|
1188
|
+
const canDispatch = () => !outboxLocked && !paused && hasPendingOutbox();
|
|
1189
|
+
while (!destroyRequested) {
|
|
1190
|
+
// Deadline race: wake on outbox, phase change, destroy, or the nearest time deadline.
|
|
1191
|
+
//
|
|
1192
|
+
// #159 Gap 1b: `wakeEpoch` is captured here and included in the predicate so any handler
|
|
1193
|
+
// that mutates the deadline landscape (e.g. `requestDetach` setting a draining window)
|
|
1194
|
+
// can force re-entry to this loop *before* the pre-scheduled timeout fires. Without
|
|
1195
|
+
// this, a detach signal landing while the loop is asleep on a far-away lease-expiry
|
|
1196
|
+
// timer would leave the workflow in `draining` until that old timer fired.
|
|
1197
|
+
const epochAtWait = wakeEpoch;
|
|
1198
|
+
const deadlineMs = nextDeadlineMs();
|
|
1199
|
+
// NOTE: This 5-min fallback wake is LOAD-BEARING despite an old "PR-C shim"
|
|
1200
|
+
// framing that surfaced in researcher's tier-2 cleanup audit (2026-04-26).
|
|
1201
|
+
// While #175 removed the legacy stale/blocked detection block this originally
|
|
1202
|
+
// fed (see ~§1527 below), the wake itself remains essential as the loop's
|
|
1203
|
+
// periodic re-evaluation tick for state changes from handlers that mutate
|
|
1204
|
+
// `nextDeadlineMs()` inputs WITHOUT bumping `wakeEpoch`:
|
|
1205
|
+
//
|
|
1206
|
+
// - `claimAttachmentUpdate` (renewal + fresh paths) — sets
|
|
1207
|
+
// `currentAttachment.expiresAt` → new lease-expiry deadline
|
|
1208
|
+
// - `processingStartUpdate` — sets `processingSince` → new
|
|
1209
|
+
// `PROCESSING_DEADLINE_MS` deadline
|
|
1210
|
+
// - `processingEndUpdate` — clears `processingSince` → cancels processing
|
|
1211
|
+
// deadline
|
|
1212
|
+
// - `destroyUpdate` (async hard-terminate-then-flip path)
|
|
1213
|
+
//
|
|
1214
|
+
// Without the fallback wake, a workflow waiting in `condition(predicate)` on
|
|
1215
|
+
// an `Infinity` deadline (booting / detached, no draining, no processing)
|
|
1216
|
+
// never re-evaluates `nextDeadlineMs()` after one of these handlers fires —
|
|
1217
|
+
// the freshly-set lease-expiry timer is never picked up, lease expiry is
|
|
1218
|
+
// never reaped, and the workflow stalls until external state forces the
|
|
1219
|
+
// predicate true. Smoking-gun test that fails without the fallback:
|
|
1220
|
+
// `test/session-phase-processing.test.ts:54` "attached -> processing ->
|
|
1221
|
+
// awaiting via processingStart/End (#117 fix)" — times out at 10s because
|
|
1222
|
+
// the loop never makes progress after `processingStart` lands on a fresh
|
|
1223
|
+
// claim.
|
|
1224
|
+
//
|
|
1225
|
+
// Removing this fallback safely is a "main-loop wake-discipline cleanup"
|
|
1226
|
+
// separate from the audit's framing — adds `wakeEpoch++` to each affected
|
|
1227
|
+
// handler, gates with `patched()` markers for replay-determinism (live
|
|
1228
|
+
// workflow histories already recorded the existing `Timer 5min` events),
|
|
1229
|
+
// and adds a regression test covering the handler-induced-deadline pickup.
|
|
1230
|
+
// Estimated 4–6 handler edits + tests, separate dedicated PR. See the
|
|
1231
|
+
// 2026-04-26 forensics for the full mechanism walkthrough — link from this
|
|
1232
|
+
// file's PR history.
|
|
1233
|
+
//
|
|
1234
|
+
// Until that cleanup happens, DO NOT remove this fallback. The
|
|
1235
|
+
// `Math.min(deadlineMs, 5 * 60 * 1000)` cap is part of the same mechanism:
|
|
1236
|
+
// it ensures every deadline (even hour-long lease-expiry timers) is
|
|
1237
|
+
// re-evaluated at least every 5 min so handler-induced deadline shortenings
|
|
1238
|
+
// can't be missed.
|
|
1239
|
+
const conditionPromise = (0, workflow_1.condition)(() => destroyRequested ||
|
|
1240
|
+
canDispatch() ||
|
|
1241
|
+
hasPendingStop() ||
|
|
1242
|
+
phase === 'gone' ||
|
|
1243
|
+
wakeEpoch !== epochAtWait, deadlineMs === Number.POSITIVE_INFINITY ? '5 minutes' : Math.min(deadlineMs, 5 * 60 * 1000));
|
|
1244
|
+
await conditionPromise;
|
|
1245
|
+
if (destroyRequested)
|
|
1246
|
+
break;
|
|
1247
|
+
// ── §9.5.a: Lease expiry — reap attachment and transition to `detached`. ──
|
|
1248
|
+
if (currentAttachment && new Date(currentAttachment.expiresAt).getTime() <= workflowNow().getTime()) {
|
|
1249
|
+
const reaped = currentAttachment;
|
|
1250
|
+
lastAdapterMeta = { hostname: reaped.hostname, adapterId: reaped.adapterId };
|
|
1251
|
+
lastDetachReason = 'heartbeat-timeout';
|
|
1252
|
+
currentAttachment = null;
|
|
1253
|
+
inFlightMessages.clear();
|
|
1254
|
+
processingSince = null;
|
|
1255
|
+
drainingSince = null;
|
|
1256
|
+
drainingDeadlineMs = null;
|
|
1257
|
+
detachedSince = workflowNow().toISOString();
|
|
1258
|
+
setPhase('detached');
|
|
1259
|
+
(0, workflow_1.upsertSearchAttributes)({
|
|
1260
|
+
AgentTempoAttachedHost: [''],
|
|
1261
|
+
AgentTempoAttachmentId: [''],
|
|
1262
|
+
});
|
|
1263
|
+
workflow_1.log.warn(`lease expired for attachment ${reaped.attachmentId} (host=${reaped.hostname})`);
|
|
1264
|
+
}
|
|
1265
|
+
// ── §9.5.b: processingDeadline — force exit from `processing` if a messageId is wedged. ──
|
|
1266
|
+
if (processingSince !== null &&
|
|
1267
|
+
workflowNow().getTime() - new Date(processingSince).getTime() > PROCESSING_DEADLINE_MS) {
|
|
1268
|
+
const abandoned = [...inFlightMessages];
|
|
1269
|
+
workflow_1.log.warn(`processingDeadline exceeded (${Math.round(PROCESSING_DEADLINE_MS / 1000)}s); ` +
|
|
1270
|
+
`ejecting ${abandoned.length} in-flight message(s): ${abandoned.join(', ')}`);
|
|
1271
|
+
inFlightMessages.clear();
|
|
1272
|
+
processingSince = null;
|
|
1273
|
+
if (phase === 'processing')
|
|
1274
|
+
setPhase('attached');
|
|
1275
|
+
}
|
|
1276
|
+
// ── §9.5.c: drainingDeadline — force exit from `draining` to `detached`. ──
|
|
1277
|
+
// #159 Gap 1a: use the caller-supplied `drainingDeadlineMs` when present; fall back to
|
|
1278
|
+
// `DEFAULT_DRAINING_DEADLINE_MS` otherwise. `nextDeadlineMs()` uses the same value, so
|
|
1279
|
+
// the condition wake timing and the reap threshold stay in sync.
|
|
1280
|
+
//
|
|
1281
|
+
// #159 Gap 2: before flipping to `detached`, kill the OS child process on the host
|
|
1282
|
+
// where the adapter was running. If we skipped this step the workflow would happily
|
|
1283
|
+
// report `phase=detached` while an orphaned `claude.exe` kept holding the session
|
|
1284
|
+
// lock — and the next `recruit`/`restart` would collide with its own past self.
|
|
1285
|
+
// Best-effort: errors from the activity are logged but don't block the state flip
|
|
1286
|
+
// (the alternative is a workflow wedged in `draining` forever when the host worker
|
|
1287
|
+
// is down, which is worse than a lingering process that operators can clean up).
|
|
1288
|
+
if (phase === 'draining' &&
|
|
1289
|
+
drainingSince !== null &&
|
|
1290
|
+
workflowNow().getTime() - new Date(drainingSince).getTime() >
|
|
1291
|
+
(drainingDeadlineMs ?? DEFAULT_DRAINING_DEADLINE_MS)) {
|
|
1292
|
+
const window = drainingDeadlineMs ?? DEFAULT_DRAINING_DEADLINE_MS;
|
|
1293
|
+
const reaped = currentAttachment;
|
|
1294
|
+
if (reaped) {
|
|
1295
|
+
// Same routing consideration as in `forceDetachUpdate`: use `metadata.hostname`
|
|
1296
|
+
// as the stable key. Best-effort only — a failure here (e.g. host worker down)
|
|
1297
|
+
// would otherwise wedge the workflow in `draining` forever, which is worse than
|
|
1298
|
+
// a lingering OS process that operators can clean up by hand.
|
|
1299
|
+
const killHost = input.metadata.hostname;
|
|
1300
|
+
try {
|
|
1301
|
+
const killResult = await getHardTerminateProxy(killHost)({
|
|
1302
|
+
ensemble: input.metadata.ensemble,
|
|
1303
|
+
playerName: input.metadata.playerId,
|
|
1304
|
+
agent: (input.metadata.agentType ?? 'claude'),
|
|
1305
|
+
workDir: input.metadata.workDir,
|
|
1306
|
+
});
|
|
1307
|
+
workflow_1.log.info(`drainingDeadline hard-terminate on ${killHost}: strategy=${killResult.strategy}, ` +
|
|
1308
|
+
`killedPids=[${killResult.killedPids.join(',')}]`);
|
|
1309
|
+
}
|
|
1310
|
+
catch (err) {
|
|
1311
|
+
workflow_1.log.warn(`drainingDeadline hard-terminate failed for ${killHost} ` +
|
|
1312
|
+
`(continuing with state flip): ${err instanceof Error ? err.message : String(err)}`);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
lastDetachReason = lastDetachReason ?? 'force';
|
|
1316
|
+
currentAttachment = null;
|
|
1317
|
+
inFlightMessages.clear();
|
|
1318
|
+
processingSince = null;
|
|
1319
|
+
drainingSince = null;
|
|
1320
|
+
drainingDeadlineMs = null;
|
|
1321
|
+
detachedSince = workflowNow().toISOString();
|
|
1322
|
+
setPhase('detached');
|
|
1323
|
+
(0, workflow_1.upsertSearchAttributes)({
|
|
1324
|
+
AgentTempoAttachedHost: [''],
|
|
1325
|
+
AgentTempoAttachmentId: [''],
|
|
1326
|
+
});
|
|
1327
|
+
if (reaped) {
|
|
1328
|
+
workflow_1.log.info(`drainingDeadline exceeded (${Math.round(window / 1000)}s); ` +
|
|
1329
|
+
`reaping attachment ${reaped.attachmentId}`);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
// ── Outbox Dispatch ──
|
|
1333
|
+
while (hasPendingOutbox() && !destroyRequested) {
|
|
1334
|
+
// When paused or locked, only dispatch stop entries (bypass)
|
|
1335
|
+
const nextEntry = (canDispatch())
|
|
1336
|
+
? outbox.find((e) => e.status === 'pending')
|
|
1337
|
+
: outbox.find((e) => e.status === 'pending' && e.type === 'stop') ?? null;
|
|
1338
|
+
if (!nextEntry)
|
|
1339
|
+
break;
|
|
1340
|
+
const entry = nextEntry;
|
|
1341
|
+
entry.status = 'processing';
|
|
1342
|
+
try {
|
|
1343
|
+
switch (entry.type) {
|
|
1344
|
+
case 'cue':
|
|
1345
|
+
await deliverCue({
|
|
1346
|
+
ensemble: input.metadata.ensemble,
|
|
1347
|
+
fromPlayerId: input.metadata.playerId,
|
|
1348
|
+
targetPlayerId: entry.targetPlayerId,
|
|
1349
|
+
message: entry.message,
|
|
1350
|
+
// #357: thread broadcast id so the target's `receiveMessage`
|
|
1351
|
+
// signal carries it onto the stored Message.
|
|
1352
|
+
...(entry.broadcastId !== undefined ? { broadcastId: entry.broadcastId } : {}),
|
|
1353
|
+
// #318: thread coat-check ticket so the target can pull the
|
|
1354
|
+
// full content body via `coat_check_get`.
|
|
1355
|
+
...(entry.attachmentTicket !== undefined ? { attachmentTicket: entry.attachmentTicket } : {}),
|
|
1356
|
+
});
|
|
1357
|
+
break;
|
|
1358
|
+
case 'report':
|
|
1359
|
+
await deliverReport({
|
|
1360
|
+
ensemble: input.metadata.ensemble,
|
|
1361
|
+
fromPlayerId: input.metadata.playerId,
|
|
1362
|
+
text: entry.text,
|
|
1363
|
+
reportType: entry.reportType,
|
|
1364
|
+
});
|
|
1365
|
+
break;
|
|
1366
|
+
case 'stop':
|
|
1367
|
+
await terminateSession({
|
|
1368
|
+
ensemble: input.metadata.ensemble,
|
|
1369
|
+
targetPlayerId: entry.targetPlayerId,
|
|
1370
|
+
terminatedBy: input.metadata.playerId,
|
|
1371
|
+
});
|
|
1372
|
+
break;
|
|
1373
|
+
case 'recruit': {
|
|
1374
|
+
const tc = input.temporalConfig;
|
|
1375
|
+
const recruitResult = await startRecruitedSession({
|
|
1376
|
+
ensemble: input.metadata.ensemble,
|
|
1377
|
+
targetName: entry.targetName,
|
|
1378
|
+
workDir: entry.workDir,
|
|
1379
|
+
isConductor: entry.isConductor,
|
|
1380
|
+
initialMessage: entry.initialMessage,
|
|
1381
|
+
fromPlayerId: input.metadata.playerId,
|
|
1382
|
+
agent: entry.agent,
|
|
1383
|
+
systemPrompt: entry.systemPrompt,
|
|
1384
|
+
taskQueue: tc?.taskQueue || 'agent-tempo',
|
|
1385
|
+
agentDefinition: entry.agentDefinition,
|
|
1386
|
+
agentDefinitionDescription: entry.agentDefinitionDescription,
|
|
1387
|
+
allowedTools: entry.allowedTools,
|
|
1388
|
+
claudeBin: entry.claudeBin,
|
|
1389
|
+
held: entry.held,
|
|
1390
|
+
// #131 Phase C — claude-api model id; activity persists it onto
|
|
1391
|
+
// SessionMetadata.model so restart/encore/migrate can recover it.
|
|
1392
|
+
...(entry.model !== undefined ? { model: entry.model } : {}),
|
|
1393
|
+
});
|
|
1394
|
+
// Warm hold: process always spawns. When held, the workflow's outbox
|
|
1395
|
+
// is locked and the initial message is deferred until release.
|
|
1396
|
+
const targetHost = entry.targetHostname || input.metadata.hostname;
|
|
1397
|
+
const spawnFn = getSpawnProxy(targetHost);
|
|
1398
|
+
await spawnFn({
|
|
1399
|
+
targetName: entry.targetName,
|
|
1400
|
+
workDir: entry.workDir,
|
|
1401
|
+
isConductor: entry.isConductor,
|
|
1402
|
+
agent: entry.agent,
|
|
1403
|
+
systemPrompt: entry.systemPrompt,
|
|
1404
|
+
ensemble: input.metadata.ensemble,
|
|
1405
|
+
temporalAddress: tc?.temporalAddress || 'localhost:7233',
|
|
1406
|
+
temporalNamespace: tc?.temporalNamespace || 'default',
|
|
1407
|
+
agentDefinition: entry.agentDefinition,
|
|
1408
|
+
agentDefinitionPath: entry.agentDefinitionPath,
|
|
1409
|
+
nativeResolvable: entry.nativeResolvable,
|
|
1410
|
+
sessionId: recruitResult.sessionId,
|
|
1411
|
+
allowedTools: entry.allowedTools,
|
|
1412
|
+
claudeBin: entry.claudeBin,
|
|
1413
|
+
mockMode: entry.mockMode,
|
|
1414
|
+
mockScenario: entry.mockScenario,
|
|
1415
|
+
// #131 Phase C — forward to spawnProcess so spawnClaudeApiAdapter
|
|
1416
|
+
// can plumb it into the subprocess env (AGENT_TEMPO_API_MODEL).
|
|
1417
|
+
...(entry.model !== undefined ? { model: entry.model } : {}),
|
|
1418
|
+
});
|
|
1419
|
+
break;
|
|
1420
|
+
}
|
|
1421
|
+
case 'release': {
|
|
1422
|
+
// Warm hold release — signal the target to unlock outbox and deliver held message.
|
|
1423
|
+
// No spawning needed — the process is already running.
|
|
1424
|
+
await releasePlayer({
|
|
1425
|
+
ensemble: input.metadata.ensemble,
|
|
1426
|
+
targetPlayerId: entry.targetPlayerId,
|
|
1427
|
+
});
|
|
1428
|
+
break;
|
|
1429
|
+
}
|
|
1430
|
+
case 'detach': {
|
|
1431
|
+
// PR-D: route the `detach` verb through the outbox (QA B1). The
|
|
1432
|
+
// activity resolves the target and signals `requestDetachSignal`.
|
|
1433
|
+
await deliverDetach({
|
|
1434
|
+
ensemble: input.metadata.ensemble,
|
|
1435
|
+
targetPlayerId: entry.targetPlayerId,
|
|
1436
|
+
...(entry.reason !== undefined ? { reason: entry.reason } : {}),
|
|
1437
|
+
...(entry.deadlineMs !== undefined ? { deadlineMs: entry.deadlineMs } : {}),
|
|
1438
|
+
});
|
|
1439
|
+
break;
|
|
1440
|
+
}
|
|
1441
|
+
case 'destroy': {
|
|
1442
|
+
// PR-D: route the `destroy` verb through the outbox (QA B2).
|
|
1443
|
+
await deliverDestroy({
|
|
1444
|
+
ensemble: input.metadata.ensemble,
|
|
1445
|
+
targetPlayerId: entry.targetPlayerId,
|
|
1446
|
+
terminatedBy: input.metadata.playerId,
|
|
1447
|
+
...(entry.reason !== undefined ? { reason: entry.reason } : {}),
|
|
1448
|
+
...(entry.notifyConductor !== undefined ? { notifyConductor: entry.notifyConductor } : {}),
|
|
1449
|
+
});
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
case 'restart': {
|
|
1453
|
+
// PR-D: route the `restart`/`migrate` verbs through the outbox
|
|
1454
|
+
// (QA B3). The activity owns the §8.2 algorithm: graceful detach
|
|
1455
|
+
// → optional force → claim → context replay → enqueueSpawn.
|
|
1456
|
+
// #334 PR-2: forward `loadFromState` + `transcript` so the
|
|
1457
|
+
// activity can seed the restarted session from a saved-state slot.
|
|
1458
|
+
await deliverRestart({
|
|
1459
|
+
ensemble: input.metadata.ensemble,
|
|
1460
|
+
targetPlayerId: entry.targetPlayerId,
|
|
1461
|
+
invokerPlayerId: entry.invokerPlayerId ?? input.metadata.playerId,
|
|
1462
|
+
...(entry.force !== undefined ? { force: entry.force } : {}),
|
|
1463
|
+
...(entry.host !== undefined ? { host: entry.host } : {}),
|
|
1464
|
+
...(entry.fresh !== undefined ? { fresh: entry.fresh } : {}),
|
|
1465
|
+
...(entry.contextMessages !== undefined ? { contextMessages: entry.contextMessages } : {}),
|
|
1466
|
+
...(entry.loadFromState !== undefined ? { loadFromState: entry.loadFromState } : {}),
|
|
1467
|
+
...(entry.transcript !== undefined ? { transcript: entry.transcript } : {}),
|
|
1468
|
+
});
|
|
1469
|
+
break;
|
|
1470
|
+
}
|
|
1471
|
+
case 'spawn': {
|
|
1472
|
+
// PR-D: forward the pre-claimed attachment token + pinned runId +
|
|
1473
|
+
// resolved adapterId to the spawn activity. The child process picks
|
|
1474
|
+
// these up from env in `BaseAttachment.startV2Lifecycle(workflowId,
|
|
1475
|
+
// expectedAttachmentId)` and renews the lease rather than claiming
|
|
1476
|
+
// fresh. Design §8.2 step 5.
|
|
1477
|
+
const spawnTc = input.temporalConfig;
|
|
1478
|
+
const spawnHost = entry.targetHostname;
|
|
1479
|
+
const spawnFn = getSpawnProxy(spawnHost);
|
|
1480
|
+
await spawnFn({
|
|
1481
|
+
targetName: entry.targetName,
|
|
1482
|
+
workDir: entry.workDir,
|
|
1483
|
+
isConductor: entry.isConductor,
|
|
1484
|
+
agent: entry.agent,
|
|
1485
|
+
ensemble: input.metadata.ensemble,
|
|
1486
|
+
temporalAddress: spawnTc?.temporalAddress || 'localhost:7233',
|
|
1487
|
+
temporalNamespace: spawnTc?.temporalNamespace || 'default',
|
|
1488
|
+
sessionId: entry.sessionId,
|
|
1489
|
+
resume: entry.resumeAttachment,
|
|
1490
|
+
attachmentId: entry.attachmentId,
|
|
1491
|
+
attachmentRunId: entry.attachmentRunId,
|
|
1492
|
+
adapterId: entry.adapterId,
|
|
1493
|
+
agentDefinition: entry.agentDefinition,
|
|
1494
|
+
agentDefinitionPath: entry.agentDefinitionPath,
|
|
1495
|
+
nativeResolvable: entry.nativeResolvable,
|
|
1496
|
+
// #131 Phase C — claude-api model carried across restart via the
|
|
1497
|
+
// spawn outbox entry (sourced from durable SessionMetadata.model
|
|
1498
|
+
// by deliverRestart).
|
|
1499
|
+
...(entry.model !== undefined ? { model: entry.model } : {}),
|
|
1500
|
+
});
|
|
1501
|
+
break;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
entry.status = 'delivered';
|
|
1505
|
+
entry.deliveredAt = workflowNow().toISOString();
|
|
1506
|
+
}
|
|
1507
|
+
catch (err) {
|
|
1508
|
+
entry.status = 'failed';
|
|
1509
|
+
entry.error = String(err);
|
|
1510
|
+
// PR-D §8.4: spawn-entry failure rollback. When `restart` or `migrate`
|
|
1511
|
+
// creates an attachment + enqueues a spawn, a subsequent spawn
|
|
1512
|
+
// activity failure leaves the session `attached` with no adapter — the
|
|
1513
|
+
// worst steady state. Force-detach the just-created attachment so the
|
|
1514
|
+
// session lands in `detached` and `restart` can be retried. Guard with
|
|
1515
|
+
// `expectedAttachmentId` (TOCTOU: another claim may have superseded).
|
|
1516
|
+
if (entry.type === 'spawn' &&
|
|
1517
|
+
entry.attachmentId &&
|
|
1518
|
+
currentAttachment?.attachmentId === entry.attachmentId) {
|
|
1519
|
+
lastAdapterMeta = {
|
|
1520
|
+
hostname: currentAttachment.hostname,
|
|
1521
|
+
adapterId: currentAttachment.adapterId,
|
|
1522
|
+
};
|
|
1523
|
+
lastDetachReason = 'spawn-failed';
|
|
1524
|
+
currentAttachment = null;
|
|
1525
|
+
inFlightMessages.clear();
|
|
1526
|
+
processingSince = null;
|
|
1527
|
+
drainingSince = null;
|
|
1528
|
+
drainingDeadlineMs = null;
|
|
1529
|
+
detachedSince = workflowNow().toISOString();
|
|
1530
|
+
setPhase('detached');
|
|
1531
|
+
(0, workflow_1.upsertSearchAttributes)({
|
|
1532
|
+
AgentTempoAttachedHost: [''],
|
|
1533
|
+
AgentTempoAttachmentId: [''],
|
|
1534
|
+
});
|
|
1535
|
+
workflow_1.log.warn(`spawn failed for "${entry.targetName}"; rolled back attachment ${entry.attachmentId} → detached`);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
// ── §2.2 phase refinement: attached → awaiting when idle ──
|
|
1540
|
+
// Issue #117: after outbox drain completes, if the attachment is still held,
|
|
1541
|
+
// no messages are in flight, and no outbox entries are pending/processing,
|
|
1542
|
+
// the session is in its idle steady state. Transition to `awaiting` so
|
|
1543
|
+
// external observers (AgentTempoAttachmentState search attribute, TUI,
|
|
1544
|
+
// monitoring) see the correct phase. `processingStart` (line 502) already
|
|
1545
|
+
// guards for `awaiting`, so the next inbound message lifts us to `processing`.
|
|
1546
|
+
if (phase === 'attached' && inFlightMessages.size === 0) {
|
|
1547
|
+
const outboxIdle = !outbox.some((e) => e.status === 'pending' || e.status === 'processing');
|
|
1548
|
+
if (outboxIdle)
|
|
1549
|
+
setPhase('awaiting');
|
|
1550
|
+
}
|
|
1551
|
+
// Legacy stale/blocked detection + `_heartbeat`/`_ping` probe removed in #175.
|
|
1552
|
+
// The phase machine (lease expiry, `processingDeadline`, `adapterExited`) is now
|
|
1553
|
+
// the single source of liveness truth; see §§9.5.a/b above.
|
|
1554
|
+
// Prevent unbounded history growth — let the SDK decide when. The
|
|
1555
|
+
// `forceContinueAsNew` flag (#226 test-only) piggybacks on this branch so
|
|
1556
|
+
// the test fixture exercises the exact production CAN path, including the
|
|
1557
|
+
// §2.3 lease extension below.
|
|
1558
|
+
const info = (0, workflow_1.workflowInfo)();
|
|
1559
|
+
if (info.continueAsNewSuggested || forceContinueAsNew) {
|
|
1560
|
+
forceContinueAsNew = false;
|
|
1561
|
+
await (0, workflow_1.condition)(workflow_1.allHandlersFinished);
|
|
1562
|
+
// ── CAN-boundary lease extension (design §2.3) ──
|
|
1563
|
+
// The CAN transition is not instantaneous. If we write the old expiresAt into the
|
|
1564
|
+
// new execution and the transition takes ~100–500ms, the new execution's first main
|
|
1565
|
+
// loop check could reap a healthy attachment as expired. Extend the lease so a
|
|
1566
|
+
// normally-beating adapter has room to land its next heartbeat.
|
|
1567
|
+
//
|
|
1568
|
+
// #249 Bug 3: pre-fix this used a hardcoded 30s constant, but the claude-code
|
|
1569
|
+
// adapter's `heartbeatMs` is 60s → CAN would grant 30s of runway when the adapter
|
|
1570
|
+
// needed 60s minimum, so the first post-CAN main-loop tick reaped the healthy
|
|
1571
|
+
// attachment before its next heartbeat could land. Post-fix we use
|
|
1572
|
+
// `currentAttachment.leaseMs` (= 3 × heartbeatMs, negotiated at claim time) which
|
|
1573
|
+
// matches what the adapter signed up for and covers at least one full heartbeat
|
|
1574
|
+
// interval for every adapter class.
|
|
1575
|
+
//
|
|
1576
|
+
// The `patched()` gate keeps replay of pre-#249 workflow runs deterministic:
|
|
1577
|
+
// histories that CAN'd on the old bundle recorded `extendAttachmentForCAN(…, 30_000, …)`,
|
|
1578
|
+
// so replaying those runs must pick the legacy constant. New runs (and in-flight
|
|
1579
|
+
// runs that CAN *after* the deploy) take the patched branch.
|
|
1580
|
+
//
|
|
1581
|
+
// Math lives in `./attachment-math.ts` for direct unit testability (#127).
|
|
1582
|
+
//
|
|
1583
|
+
// #255 cleanup: the `patched()` call stays at the eager/unconditional
|
|
1584
|
+
// position it was introduced in — relocating it inside the
|
|
1585
|
+
// `currentAttachment ?` branch would skip marker recording on histories
|
|
1586
|
+
// that hit the CAN site with a null attachment, risking replay
|
|
1587
|
+
// non-determinism against those recordings. The dead-code cleanup is
|
|
1588
|
+
// strictly the removal of the `?? HEARTBEAT_INTERVAL_MS` fallback that
|
|
1589
|
+
// used to sit inside the ternary: on the patched branch it never fires
|
|
1590
|
+
// (Attachment.leaseMs is required), and on the pre-patched branch the
|
|
1591
|
+
// fallback is replaced by the bare constant — same value either way.
|
|
1592
|
+
const usePatchedLease = (0, workflow_1.patched)('v0.26-can-lease-from-attachment');
|
|
1593
|
+
const extendedAttachment = currentAttachment
|
|
1594
|
+
? (0, attachment_math_1.extendAttachmentForCAN)(currentAttachment, usePatchedLease ? currentAttachment.leaseMs : HEARTBEAT_INTERVAL_MS, workflowNow().getTime())
|
|
1595
|
+
: undefined;
|
|
1596
|
+
await (0, workflow_1.continueAsNew)({
|
|
1597
|
+
...input,
|
|
1598
|
+
part,
|
|
1599
|
+
messages: messages.filter((m) => !m.delivered),
|
|
1600
|
+
sentMessages: sentMessages.slice(-50),
|
|
1601
|
+
outbox: outbox.filter((e) => e.status === 'pending' || e.status === 'processing'),
|
|
1602
|
+
lastInboundRRTime,
|
|
1603
|
+
lastOutboundTime,
|
|
1604
|
+
// #399 W2 — counters carried across continueAsNew so the
|
|
1605
|
+
// dashboard's "Messages" + "tempo" surfaces stay monotonic.
|
|
1606
|
+
receivedCount,
|
|
1607
|
+
sentCount,
|
|
1608
|
+
activityCount,
|
|
1609
|
+
outboxLocked,
|
|
1610
|
+
heldMessage,
|
|
1611
|
+
paused,
|
|
1612
|
+
inFlightMessageIds: [...inFlightMessages],
|
|
1613
|
+
processingSince: processingSince ?? undefined,
|
|
1614
|
+
destroyed: destroyed || destroyRequested,
|
|
1615
|
+
// v0.25 attachment state — each carried forward with the lease extension applied.
|
|
1616
|
+
...(extendedAttachment ? { currentAttachment: extendedAttachment } : {}),
|
|
1617
|
+
...(preferredHost ? { preferredHost } : {}),
|
|
1618
|
+
phase,
|
|
1619
|
+
...(drainingSince ? { drainingSince } : {}),
|
|
1620
|
+
...(drainingDeadlineMs !== null ? { drainingDeadlineMs } : {}),
|
|
1621
|
+
// #334 PR-1 — carry player saveable state only when populated.
|
|
1622
|
+
// Empty maps are omitted from the CAN payload to keep the wire
|
|
1623
|
+
// small for the common no-state case (same idiom as currentAttachment).
|
|
1624
|
+
...(Object.keys(playerState).length > 0 ? { playerState } : {}),
|
|
1625
|
+
...(input.metadata.isConductor ? { commandHistory, reportHistory, qualityGates, worktrees, stages } : {}),
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
// ── Exit path ──
|
|
1630
|
+
// Single terminal state: `destroyRequested` (from the `destroy` update OR from the
|
|
1631
|
+
// quarantined `updateMetadata({ status: 'terminated' })` test-compat shim — both
|
|
1632
|
+
// route through §2.5 abandon-in-flight semantics). PR-C commit 4 retired the
|
|
1633
|
+
// v0.24 legacy 2-min drain-wait branch; callers expecting drain semantics should
|
|
1634
|
+
// request it before destroy (e.g. via `requestDetach` + wait for phase=detached).
|
|
1635
|
+
await (0, workflow_1.condition)(workflow_1.allHandlersFinished);
|
|
1636
|
+
// Finalize `destroyed = true` so `isDestroyed` queries against the completed run return true.
|
|
1637
|
+
destroyed = true;
|
|
1638
|
+
}
|