@ag-eco/agentplate-cli 0.13.0
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/LICENSE +21 -0
- package/README.md +462 -0
- package/agents/ap-co-creation.md +90 -0
- package/agents/builder.md +144 -0
- package/agents/coordinator.md +377 -0
- package/agents/lead.md +435 -0
- package/agents/merger.md +164 -0
- package/agents/monitor.md +214 -0
- package/agents/orchestrator.md +239 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +125 -0
- package/agents/supervisor.md +427 -0
- package/package.json +66 -0
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/agents/guard-rules.ts +97 -0
- package/src/agents/headless-mail-injector.test.ts +709 -0
- package/src/agents/headless-mail-injector.ts +377 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +3119 -0
- package/src/agents/hooks-deployer.ts +804 -0
- package/src/agents/identity.test.ts +604 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/manifest.test.ts +1026 -0
- package/src/agents/manifest.ts +376 -0
- package/src/agents/overlay.test.ts +1058 -0
- package/src/agents/overlay.ts +490 -0
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +230 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +328 -0
- package/src/commands/agents.ts +299 -0
- package/src/commands/clean.test.ts +797 -0
- package/src/commands/clean.ts +791 -0
- package/src/commands/completions.test.ts +348 -0
- package/src/commands/completions.ts +981 -0
- package/src/commands/coordinator.test.ts +2975 -0
- package/src/commands/coordinator.ts +1841 -0
- package/src/commands/costs.test.ts +1183 -0
- package/src/commands/costs.ts +599 -0
- package/src/commands/dashboard.test.ts +954 -0
- package/src/commands/dashboard.ts +1212 -0
- package/src/commands/discover.test.ts +288 -0
- package/src/commands/discover.ts +202 -0
- package/src/commands/doctor.test.ts +303 -0
- package/src/commands/doctor.ts +311 -0
- package/src/commands/ecosystem.test.ts +226 -0
- package/src/commands/ecosystem.ts +248 -0
- package/src/commands/errors.test.ts +654 -0
- package/src/commands/errors.ts +197 -0
- package/src/commands/feed.test.ts +709 -0
- package/src/commands/feed.ts +260 -0
- package/src/commands/group.test.ts +475 -0
- package/src/commands/group.ts +546 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +263 -0
- package/src/commands/init.test.ts +1011 -0
- package/src/commands/init.ts +967 -0
- package/src/commands/inspect.test.ts +1239 -0
- package/src/commands/inspect.ts +648 -0
- package/src/commands/log.test.ts +1913 -0
- package/src/commands/log.ts +958 -0
- package/src/commands/logs.test.ts +801 -0
- package/src/commands/logs.ts +483 -0
- package/src/commands/mail.test.ts +1501 -0
- package/src/commands/mail.ts +848 -0
- package/src/commands/merge.test.ts +864 -0
- package/src/commands/merge.ts +381 -0
- package/src/commands/metrics.test.ts +458 -0
- package/src/commands/metrics.ts +129 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +409 -0
- package/src/commands/nudge.test.ts +579 -0
- package/src/commands/nudge.ts +646 -0
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +612 -0
- package/src/commands/prime.ts +359 -0
- package/src/commands/replay.test.ts +757 -0
- package/src/commands/replay.ts +231 -0
- package/src/commands/run.test.ts +469 -0
- package/src/commands/run.ts +353 -0
- package/src/commands/serve/agent-actions.test.ts +210 -0
- package/src/commands/serve/agent-actions.ts +192 -0
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +410 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1680 -0
- package/src/commands/serve/rest.ts +1130 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +654 -0
- package/src/commands/sling.test.ts +1583 -0
- package/src/commands/sling.ts +1351 -0
- package/src/commands/spec.test.ts +179 -0
- package/src/commands/spec.ts +105 -0
- package/src/commands/status.test.ts +614 -0
- package/src/commands/status.ts +403 -0
- package/src/commands/stop.test.ts +964 -0
- package/src/commands/stop.ts +319 -0
- package/src/commands/supervisor.test.ts +185 -0
- package/src/commands/supervisor.ts +537 -0
- package/src/commands/trace.test.ts +762 -0
- package/src/commands/trace.ts +205 -0
- package/src/commands/update.test.ts +466 -0
- package/src/commands/update.ts +263 -0
- package/src/commands/upgrade.test.ts +48 -0
- package/src/commands/upgrade.ts +240 -0
- package/src/commands/watch.test.ts +257 -0
- package/src/commands/watch.ts +308 -0
- package/src/commands/worktree.test.ts +1297 -0
- package/src/commands/worktree.ts +451 -0
- package/src/config.test.ts +1535 -0
- package/src/config.ts +1064 -0
- package/src/doctor/agents.test.ts +523 -0
- package/src/doctor/agents.ts +399 -0
- package/src/doctor/config-check.test.ts +191 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +807 -0
- package/src/doctor/consistency.ts +347 -0
- package/src/doctor/databases.test.ts +350 -0
- package/src/doctor/databases.ts +243 -0
- package/src/doctor/dependencies.test.ts +296 -0
- package/src/doctor/dependencies.ts +272 -0
- package/src/doctor/ecosystem.test.ts +308 -0
- package/src/doctor/ecosystem.ts +156 -0
- package/src/doctor/logs.test.ts +253 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +315 -0
- package/src/doctor/merge-queue.ts +167 -0
- package/src/doctor/providers.test.ts +409 -0
- package/src/doctor/providers.ts +250 -0
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/structure.test.ts +423 -0
- package/src/doctor/structure.ts +285 -0
- package/src/doctor/types.ts +43 -0
- package/src/doctor/version.test.ts +241 -0
- package/src/doctor/version.ts +132 -0
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +214 -0
- package/src/e2e/init-sling-lifecycle.test.ts +283 -0
- package/src/errors.test.ts +350 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tailer.test.ts +719 -0
- package/src/events/tailer.ts +332 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +533 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.test.ts +72 -0
- package/src/json.ts +53 -0
- package/src/loam/client.test.ts +752 -0
- package/src/loam/client.ts +664 -0
- package/src/logging/color.test.ts +252 -0
- package/src/logging/color.ts +105 -0
- package/src/logging/format.test.ts +110 -0
- package/src/logging/format.ts +255 -0
- package/src/logging/logger.test.ts +814 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +110 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/logging/theme.ts +140 -0
- package/src/mail/broadcast.test.ts +204 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +774 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +898 -0
- package/src/mail/store.ts +425 -0
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/queue.test.ts +426 -0
- package/src/merge/queue.ts +246 -0
- package/src/merge/resolver.test.ts +1993 -0
- package/src/merge/resolver.ts +926 -0
- package/src/metrics/pricing.test.ts +258 -0
- package/src/metrics/pricing.ts +135 -0
- package/src/metrics/store.test.ts +978 -0
- package/src/metrics/store.ts +501 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +483 -0
- package/src/metrics/transcript.ts +114 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +1474 -0
- package/src/runtimes/claude.ts +579 -0
- package/src/runtimes/codex.test.ts +805 -0
- package/src/runtimes/codex.ts +273 -0
- package/src/runtimes/connections.test.ts +214 -0
- package/src/runtimes/connections.ts +103 -0
- package/src/runtimes/copilot.test.ts +707 -0
- package/src/runtimes/copilot.ts +316 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.test.ts +537 -0
- package/src/runtimes/gemini.ts +243 -0
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/opencode.test.ts +325 -0
- package/src/runtimes/opencode.ts +188 -0
- package/src/runtimes/pi-guards.test.ts +486 -0
- package/src/runtimes/pi-guards.ts +367 -0
- package/src/runtimes/pi.test.ts +789 -0
- package/src/runtimes/pi.ts +305 -0
- package/src/runtimes/registry.test.ts +196 -0
- package/src/runtimes/registry.ts +99 -0
- package/src/runtimes/sapling.test.ts +1267 -0
- package/src/runtimes/sapling.ts +710 -0
- package/src/runtimes/types.ts +266 -0
- package/src/schema-consistency.test.ts +246 -0
- package/src/sessions/compat.test.ts +281 -0
- package/src/sessions/compat.ts +105 -0
- package/src/sessions/store.test.ts +1748 -0
- package/src/sessions/store.ts +858 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +145 -0
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/tools/loam/api.ts +368 -0
- package/src/tools/loam/cli.ts +278 -0
- package/src/tools/loam/commands/add.ts +52 -0
- package/src/tools/loam/commands/archive.ts +214 -0
- package/src/tools/loam/commands/audit.ts +276 -0
- package/src/tools/loam/commands/compact.ts +1062 -0
- package/src/tools/loam/commands/completions.ts +79 -0
- package/src/tools/loam/commands/config.ts +381 -0
- package/src/tools/loam/commands/delete-domain.ts +121 -0
- package/src/tools/loam/commands/delete.ts +316 -0
- package/src/tools/loam/commands/diff.ts +200 -0
- package/src/tools/loam/commands/doctor.ts +1113 -0
- package/src/tools/loam/commands/edit.ts +226 -0
- package/src/tools/loam/commands/init.ts +31 -0
- package/src/tools/loam/commands/learn.ts +179 -0
- package/src/tools/loam/commands/move.ts +323 -0
- package/src/tools/loam/commands/onboard.ts +374 -0
- package/src/tools/loam/commands/outcome.ts +185 -0
- package/src/tools/loam/commands/prime.ts +688 -0
- package/src/tools/loam/commands/prune.ts +614 -0
- package/src/tools/loam/commands/query.ts +218 -0
- package/src/tools/loam/commands/rank.ts +180 -0
- package/src/tools/loam/commands/ready.ts +189 -0
- package/src/tools/loam/commands/record.ts +1210 -0
- package/src/tools/loam/commands/restore.ts +166 -0
- package/src/tools/loam/commands/search.ts +327 -0
- package/src/tools/loam/commands/setup.ts +887 -0
- package/src/tools/loam/commands/status.ts +103 -0
- package/src/tools/loam/commands/sync.ts +298 -0
- package/src/tools/loam/commands/update.ts +19 -0
- package/src/tools/loam/commands/upgrade.ts +93 -0
- package/src/tools/loam/commands/validate.ts +190 -0
- package/src/tools/loam/index.ts +62 -0
- package/src/tools/loam/log.ts +127 -0
- package/src/tools/loam/registry/builtins.ts +409 -0
- package/src/tools/loam/registry/custom.ts +431 -0
- package/src/tools/loam/registry/init.ts +55 -0
- package/src/tools/loam/registry/template.ts +40 -0
- package/src/tools/loam/registry/type-registry.ts +113 -0
- package/src/tools/loam/schemas/config-schema.ts +489 -0
- package/src/tools/loam/schemas/config.ts +245 -0
- package/src/tools/loam/schemas/index.ts +18 -0
- package/src/tools/loam/schemas/record-schema.ts +191 -0
- package/src/tools/loam/schemas/record.ts +115 -0
- package/src/tools/loam/utils/active-work.ts +205 -0
- package/src/tools/loam/utils/anchor-validity.ts +80 -0
- package/src/tools/loam/utils/archive.ts +146 -0
- package/src/tools/loam/utils/audit.ts +667 -0
- package/src/tools/loam/utils/bm25.ts +238 -0
- package/src/tools/loam/utils/budget.ts +142 -0
- package/src/tools/loam/utils/config.ts +344 -0
- package/src/tools/loam/utils/dir-anchors.ts +62 -0
- package/src/tools/loam/utils/domain-rules.ts +114 -0
- package/src/tools/loam/utils/expertise.ts +393 -0
- package/src/tools/loam/utils/format-helpers.ts +96 -0
- package/src/tools/loam/utils/format.ts +1234 -0
- package/src/tools/loam/utils/git-context.ts +50 -0
- package/src/tools/loam/utils/git.ts +183 -0
- package/src/tools/loam/utils/hooks.ts +299 -0
- package/src/tools/loam/utils/index.ts +52 -0
- package/src/tools/loam/utils/json-output.ts +13 -0
- package/src/tools/loam/utils/lock.ts +76 -0
- package/src/tools/loam/utils/markers.ts +48 -0
- package/src/tools/loam/utils/numeric-flags.ts +20 -0
- package/src/tools/loam/utils/palette.ts +44 -0
- package/src/tools/loam/utils/prime-ranking.ts +135 -0
- package/src/tools/loam/utils/recipe-discovery.ts +195 -0
- package/src/tools/loam/utils/runtime-flags.ts +28 -0
- package/src/tools/loam/utils/scoring.ts +94 -0
- package/src/tools/loam/utils/version.ts +116 -0
- package/src/tools/sprout/commands/block.ts +64 -0
- package/src/tools/sprout/commands/blocked.ts +86 -0
- package/src/tools/sprout/commands/close.ts +129 -0
- package/src/tools/sprout/commands/completions.ts +198 -0
- package/src/tools/sprout/commands/config.ts +238 -0
- package/src/tools/sprout/commands/create.ts +164 -0
- package/src/tools/sprout/commands/dep.ts +148 -0
- package/src/tools/sprout/commands/doctor.ts +979 -0
- package/src/tools/sprout/commands/init.ts +83 -0
- package/src/tools/sprout/commands/label.ts +178 -0
- package/src/tools/sprout/commands/list.ts +210 -0
- package/src/tools/sprout/commands/migrate.ts +133 -0
- package/src/tools/sprout/commands/onboard.ts +207 -0
- package/src/tools/sprout/commands/plan-show.ts +278 -0
- package/src/tools/sprout/commands/plan.ts +2526 -0
- package/src/tools/sprout/commands/prime.ts +399 -0
- package/src/tools/sprout/commands/ready.ts +245 -0
- package/src/tools/sprout/commands/search.ts +221 -0
- package/src/tools/sprout/commands/show.ts +277 -0
- package/src/tools/sprout/commands/stats.ts +146 -0
- package/src/tools/sprout/commands/sync.ts +134 -0
- package/src/tools/sprout/commands/tpl.ts +364 -0
- package/src/tools/sprout/commands/unblock.ts +115 -0
- package/src/tools/sprout/commands/update.ts +257 -0
- package/src/tools/sprout/commands/upgrade.ts +91 -0
- package/src/tools/sprout/config-schema.ts +152 -0
- package/src/tools/sprout/config.ts +355 -0
- package/src/tools/sprout/filter.ts +107 -0
- package/src/tools/sprout/format.ts +43 -0
- package/src/tools/sprout/id.ts +22 -0
- package/src/tools/sprout/index.ts +204 -0
- package/src/tools/sprout/log.ts +76 -0
- package/src/tools/sprout/markers.ts +22 -0
- package/src/tools/sprout/output.ts +121 -0
- package/src/tools/sprout/plan-backref.ts +93 -0
- package/src/tools/sprout/plan-context.ts +81 -0
- package/src/tools/sprout/plan-domain.ts +139 -0
- package/src/tools/sprout/plan-lifecycle.ts +65 -0
- package/src/tools/sprout/plan-loam.ts +207 -0
- package/src/tools/sprout/plan-schema.ts +209 -0
- package/src/tools/sprout/sort.ts +31 -0
- package/src/tools/sprout/store.ts +172 -0
- package/src/tools/sprout/types.ts +118 -0
- package/src/tools/sprout/validation.ts +119 -0
- package/src/tools/sprout/version.ts +1 -0
- package/src/tools/sprout/yaml.ts +387 -0
- package/src/tools/trellis/commands/archive.ts +87 -0
- package/src/tools/trellis/commands/completions.ts +610 -0
- package/src/tools/trellis/commands/config.ts +382 -0
- package/src/tools/trellis/commands/create.ts +252 -0
- package/src/tools/trellis/commands/diff.ts +150 -0
- package/src/tools/trellis/commands/doctor.ts +771 -0
- package/src/tools/trellis/commands/emit.ts +365 -0
- package/src/tools/trellis/commands/history.ts +83 -0
- package/src/tools/trellis/commands/import.ts +198 -0
- package/src/tools/trellis/commands/init.ts +81 -0
- package/src/tools/trellis/commands/list.ts +103 -0
- package/src/tools/trellis/commands/onboard.ts +156 -0
- package/src/tools/trellis/commands/pin.ts +172 -0
- package/src/tools/trellis/commands/prime.ts +193 -0
- package/src/tools/trellis/commands/render.ts +122 -0
- package/src/tools/trellis/commands/schema.ts +353 -0
- package/src/tools/trellis/commands/show.ts +115 -0
- package/src/tools/trellis/commands/stats.ts +65 -0
- package/src/tools/trellis/commands/sync.ts +112 -0
- package/src/tools/trellis/commands/tree.ts +123 -0
- package/src/tools/trellis/commands/update.ts +330 -0
- package/src/tools/trellis/commands/upgrade.ts +95 -0
- package/src/tools/trellis/commands/validate.ts +166 -0
- package/src/tools/trellis/config-schema.ts +81 -0
- package/src/tools/trellis/config.ts +108 -0
- package/src/tools/trellis/frontmatter.ts +348 -0
- package/src/tools/trellis/id.ts +24 -0
- package/src/tools/trellis/index.ts +209 -0
- package/src/tools/trellis/markers.ts +28 -0
- package/src/tools/trellis/output.ts +84 -0
- package/src/tools/trellis/render.ts +212 -0
- package/src/tools/trellis/store.ts +144 -0
- package/src/tools/trellis/types.ts +82 -0
- package/src/tools/trellis/validate.ts +199 -0
- package/src/tools/trellis/yaml.ts +309 -0
- package/src/tracker/beads.test.ts +454 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +90 -0
- package/src/tracker/factory.ts +65 -0
- package/src/tracker/sprout.test.ts +461 -0
- package/src/tracker/sprout.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/trellis/client.test.ts +107 -0
- package/src/trellis/client.ts +179 -0
- package/src/types.ts +970 -0
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/browser.test.ts +49 -0
- package/src/utils/browser.ts +48 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +152 -0
- package/src/utils/pid.ts +130 -0
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/version.ts +5 -0
- package/src/watchdog/daemon.test.ts +3721 -0
- package/src/watchdog/daemon.ts +1257 -0
- package/src/watchdog/health.test.ts +830 -0
- package/src/watchdog/health.ts +434 -0
- package/src/watchdog/triage.test.ts +205 -0
- package/src/watchdog/triage.ts +205 -0
- package/src/worktree/manager.test.ts +720 -0
- package/src/worktree/manager.ts +405 -0
- package/src/worktree/process.test.ts +172 -0
- package/src/worktree/process.ts +131 -0
- package/src/worktree/tmux.test.ts +1616 -0
- package/src/worktree/tmux.ts +721 -0
- package/templates/CLAUDE.md.tmpl +100 -0
- package/templates/copilot-hooks.json.tmpl +13 -0
- package/templates/hooks.json.tmpl +109 -0
- package/templates/overlay.md.tmpl +88 -0
- package/ui/dist/apple-touch-icon-bdy6teep.png +0 -0
- package/ui/dist/chunk-8s31f05k.css +1 -0
- package/ui/dist/chunk-vm5rz679.js +300 -0
- package/ui/dist/favicon-nzb39vza.svg +4 -0
- package/ui/dist/index.html +17 -0
|
@@ -0,0 +1,2975 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for agentplate coordinator command.
|
|
3
|
+
*
|
|
4
|
+
* Uses real temp directories and real git repos for file I/O and config loading.
|
|
5
|
+
* Tmux is injected via the CoordinatorDeps DI interface instead of
|
|
6
|
+
* mock.module() to avoid the process-global mock leak issue
|
|
7
|
+
* (see loam record mx-56558b).
|
|
8
|
+
*
|
|
9
|
+
* WHY DI instead of mock.module: mock.module() in bun:test is process-global
|
|
10
|
+
* and leaks across test files. The DI approach (same pattern as daemon.ts
|
|
11
|
+
* _tmux/_triage/_nudge) ensures mocks are scoped to each test invocation.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { mkdir, realpath } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { AgentError, ValidationError } from "../errors.ts";
|
|
18
|
+
import { createMailStore } from "../mail/store.ts";
|
|
19
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
20
|
+
import { createRunStore, createSessionStore } from "../sessions/store.ts";
|
|
21
|
+
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
22
|
+
import type { AgentSession } from "../types.ts";
|
|
23
|
+
import {
|
|
24
|
+
askCoordinator,
|
|
25
|
+
buildCoordinatorBeacon,
|
|
26
|
+
type CoordinatorDeps,
|
|
27
|
+
checkComplete,
|
|
28
|
+
coordinatorCommand,
|
|
29
|
+
createCoordinatorCommand,
|
|
30
|
+
resolveAttach,
|
|
31
|
+
startCoordinatorSession,
|
|
32
|
+
} from "./coordinator.ts";
|
|
33
|
+
import {
|
|
34
|
+
buildOrchestratorBeacon,
|
|
35
|
+
createOrchestratorCommand,
|
|
36
|
+
orchestratorCommand,
|
|
37
|
+
} from "./orchestrator.ts";
|
|
38
|
+
|
|
39
|
+
// --- Fake Tmux ---
|
|
40
|
+
|
|
41
|
+
/** Track calls to fake tmux for assertions. */
|
|
42
|
+
interface TmuxCallTracker {
|
|
43
|
+
createSession: Array<{
|
|
44
|
+
name: string;
|
|
45
|
+
cwd: string;
|
|
46
|
+
command: string;
|
|
47
|
+
env?: Record<string, string>;
|
|
48
|
+
}>;
|
|
49
|
+
isSessionAlive: Array<{ name: string; result: boolean }>;
|
|
50
|
+
checkSessionState: Array<{ name: string; result: "alive" | "dead" | "no_server" }>;
|
|
51
|
+
killSession: Array<{ name: string }>;
|
|
52
|
+
sendKeys: Array<{ name: string; keys: string }>;
|
|
53
|
+
waitForTuiReady: Array<{ name: string }>;
|
|
54
|
+
ensureTmuxAvailable: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Fake Watchdog ---
|
|
58
|
+
|
|
59
|
+
/** Track calls to fake watchdog for assertions. */
|
|
60
|
+
interface WatchdogCallTracker {
|
|
61
|
+
start: number;
|
|
62
|
+
stop: number;
|
|
63
|
+
isRunning: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Fake Monitor ---
|
|
67
|
+
|
|
68
|
+
/** Track calls to fake monitor for assertions. */
|
|
69
|
+
interface MonitorCallTracker {
|
|
70
|
+
start: number;
|
|
71
|
+
stop: number;
|
|
72
|
+
isRunning: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build a fake tmux DI object with configurable session liveness. */
|
|
76
|
+
function makeFakeTmux(
|
|
77
|
+
sessionAliveMap: Record<string, boolean> = {},
|
|
78
|
+
options: {
|
|
79
|
+
waitForTuiReadyResult?: boolean;
|
|
80
|
+
ensureTmuxAvailableError?: Error;
|
|
81
|
+
checkSessionStateMap?: Record<string, "alive" | "dead" | "no_server">;
|
|
82
|
+
} = {},
|
|
83
|
+
): {
|
|
84
|
+
tmux: NonNullable<CoordinatorDeps["_tmux"]>;
|
|
85
|
+
calls: TmuxCallTracker;
|
|
86
|
+
} {
|
|
87
|
+
const calls: TmuxCallTracker = {
|
|
88
|
+
createSession: [],
|
|
89
|
+
isSessionAlive: [],
|
|
90
|
+
checkSessionState: [],
|
|
91
|
+
killSession: [],
|
|
92
|
+
sendKeys: [],
|
|
93
|
+
waitForTuiReady: [],
|
|
94
|
+
ensureTmuxAvailable: 0,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const tmux: NonNullable<CoordinatorDeps["_tmux"]> = {
|
|
98
|
+
createSession: async (
|
|
99
|
+
name: string,
|
|
100
|
+
cwd: string,
|
|
101
|
+
command: string,
|
|
102
|
+
env?: Record<string, string>,
|
|
103
|
+
): Promise<number> => {
|
|
104
|
+
calls.createSession.push({ name, cwd, command, env });
|
|
105
|
+
return 99999; // Fake PID
|
|
106
|
+
},
|
|
107
|
+
isSessionAlive: async (name: string): Promise<boolean> => {
|
|
108
|
+
const alive = sessionAliveMap[name] ?? false;
|
|
109
|
+
calls.isSessionAlive.push({ name, result: alive });
|
|
110
|
+
return alive;
|
|
111
|
+
},
|
|
112
|
+
checkSessionState: async (name: string): Promise<"alive" | "dead" | "no_server"> => {
|
|
113
|
+
const stateMap = options.checkSessionStateMap ?? {};
|
|
114
|
+
// Default: derive from sessionAliveMap for backwards compat
|
|
115
|
+
const state = stateMap[name] ?? (sessionAliveMap[name] ? "alive" : "dead");
|
|
116
|
+
calls.checkSessionState.push({ name, result: state });
|
|
117
|
+
return state;
|
|
118
|
+
},
|
|
119
|
+
killSession: async (name: string): Promise<void> => {
|
|
120
|
+
calls.killSession.push({ name });
|
|
121
|
+
},
|
|
122
|
+
sendKeys: async (name: string, keys: string): Promise<void> => {
|
|
123
|
+
calls.sendKeys.push({ name, keys });
|
|
124
|
+
},
|
|
125
|
+
waitForTuiReady: async (name: string): Promise<boolean> => {
|
|
126
|
+
calls.waitForTuiReady.push({ name });
|
|
127
|
+
return options.waitForTuiReadyResult ?? true;
|
|
128
|
+
},
|
|
129
|
+
ensureTmuxAvailable: async (): Promise<void> => {
|
|
130
|
+
calls.ensureTmuxAvailable++;
|
|
131
|
+
if (options.ensureTmuxAvailableError) {
|
|
132
|
+
throw options.ensureTmuxAvailableError;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return { tmux, calls };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build a fake watchdog DI object with configurable behavior.
|
|
142
|
+
* @param running - Whether the watchdog should report as running
|
|
143
|
+
* @param startSuccess - Whether start() should succeed (return a PID)
|
|
144
|
+
* @param stopSuccess - Whether stop() should succeed (return true)
|
|
145
|
+
*/
|
|
146
|
+
function makeFakeWatchdog(
|
|
147
|
+
running = false,
|
|
148
|
+
startSuccess = true,
|
|
149
|
+
stopSuccess = true,
|
|
150
|
+
): {
|
|
151
|
+
watchdog: NonNullable<CoordinatorDeps["_watchdog"]>;
|
|
152
|
+
calls: WatchdogCallTracker;
|
|
153
|
+
} {
|
|
154
|
+
const calls: WatchdogCallTracker = {
|
|
155
|
+
start: 0,
|
|
156
|
+
stop: 0,
|
|
157
|
+
isRunning: 0,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const watchdog: NonNullable<CoordinatorDeps["_watchdog"]> = {
|
|
161
|
+
async start(): Promise<{ pid: number } | null> {
|
|
162
|
+
calls.start++;
|
|
163
|
+
return startSuccess ? { pid: 88888 } : null;
|
|
164
|
+
},
|
|
165
|
+
async stop(): Promise<boolean> {
|
|
166
|
+
calls.stop++;
|
|
167
|
+
return stopSuccess;
|
|
168
|
+
},
|
|
169
|
+
async isRunning(): Promise<boolean> {
|
|
170
|
+
calls.isRunning++;
|
|
171
|
+
return running;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return { watchdog, calls };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build a fake monitor DI object with configurable behavior.
|
|
180
|
+
* @param running - Whether the monitor should report as running
|
|
181
|
+
* @param startSuccess - Whether start() should succeed (return a PID)
|
|
182
|
+
* @param stopSuccess - Whether stop() should succeed (return true)
|
|
183
|
+
*/
|
|
184
|
+
function makeFakeMonitor(
|
|
185
|
+
running = false,
|
|
186
|
+
startSuccess = true,
|
|
187
|
+
stopSuccess = true,
|
|
188
|
+
): {
|
|
189
|
+
monitor: NonNullable<CoordinatorDeps["_monitor"]>;
|
|
190
|
+
calls: MonitorCallTracker;
|
|
191
|
+
} {
|
|
192
|
+
const calls: MonitorCallTracker = {
|
|
193
|
+
start: 0,
|
|
194
|
+
stop: 0,
|
|
195
|
+
isRunning: 0,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const monitor: NonNullable<CoordinatorDeps["_monitor"]> = {
|
|
199
|
+
async start(): Promise<{ pid: number } | null> {
|
|
200
|
+
calls.start++;
|
|
201
|
+
return startSuccess ? { pid: 77777 } : null;
|
|
202
|
+
},
|
|
203
|
+
async stop(): Promise<boolean> {
|
|
204
|
+
calls.stop++;
|
|
205
|
+
return stopSuccess;
|
|
206
|
+
},
|
|
207
|
+
async isRunning(): Promise<boolean> {
|
|
208
|
+
calls.isRunning++;
|
|
209
|
+
return running;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return { monitor, calls };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Test Setup ---
|
|
217
|
+
|
|
218
|
+
let tempDir: string;
|
|
219
|
+
let agentplateDir: string;
|
|
220
|
+
const originalCwd = process.cwd();
|
|
221
|
+
|
|
222
|
+
/** Save sessions to the SessionStore (sessions.db) for test setup. */
|
|
223
|
+
function saveSessionsToDb(sessions: AgentSession[]): void {
|
|
224
|
+
const { store } = openSessionStore(agentplateDir);
|
|
225
|
+
try {
|
|
226
|
+
for (const session of sessions) {
|
|
227
|
+
store.upsert(session);
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
store.close();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Load all sessions from the SessionStore (sessions.db). */
|
|
235
|
+
function loadSessionsFromDb(): AgentSession[] {
|
|
236
|
+
const { store } = openSessionStore(agentplateDir);
|
|
237
|
+
try {
|
|
238
|
+
return store.getAll();
|
|
239
|
+
} finally {
|
|
240
|
+
store.close();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
beforeEach(async () => {
|
|
245
|
+
// Restore cwd FIRST so createTempGitRepo's git operations don't fail
|
|
246
|
+
// if a prior test's tempDir was already cleaned up.
|
|
247
|
+
process.chdir(originalCwd);
|
|
248
|
+
|
|
249
|
+
tempDir = await realpath(await createTempGitRepo());
|
|
250
|
+
agentplateDir = join(tempDir, ".agentplate");
|
|
251
|
+
await mkdir(agentplateDir, { recursive: true });
|
|
252
|
+
|
|
253
|
+
// Write a minimal config.yaml so loadConfig succeeds
|
|
254
|
+
// tier2Enabled: true so existing --monitor tests pass (new skipped tests override inline)
|
|
255
|
+
await Bun.write(
|
|
256
|
+
join(agentplateDir, "config.yaml"),
|
|
257
|
+
[
|
|
258
|
+
"project:",
|
|
259
|
+
" name: test-project",
|
|
260
|
+
` root: ${tempDir}`,
|
|
261
|
+
" canonicalBranch: main",
|
|
262
|
+
"watchdog:",
|
|
263
|
+
" tier2Enabled: true",
|
|
264
|
+
].join("\n"),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Write agent-manifest.json and stub agent-def .md files so manifest loading succeeds
|
|
268
|
+
const agentDefsDir = join(agentplateDir, "agent-defs");
|
|
269
|
+
await mkdir(agentDefsDir, { recursive: true });
|
|
270
|
+
const manifest = {
|
|
271
|
+
version: "1.0",
|
|
272
|
+
agents: {
|
|
273
|
+
coordinator: {
|
|
274
|
+
file: "coordinator.md",
|
|
275
|
+
model: "opus",
|
|
276
|
+
tools: ["Read", "Bash"],
|
|
277
|
+
capabilities: ["coordinate"],
|
|
278
|
+
canSpawn: true,
|
|
279
|
+
constraints: [],
|
|
280
|
+
},
|
|
281
|
+
orchestrator: {
|
|
282
|
+
file: "orchestrator.md",
|
|
283
|
+
model: "opus",
|
|
284
|
+
tools: ["Read", "Bash"],
|
|
285
|
+
capabilities: ["orchestrate", "coordinate"],
|
|
286
|
+
canSpawn: true,
|
|
287
|
+
constraints: [],
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
capabilityIndex: {
|
|
291
|
+
coordinate: ["coordinator", "orchestrator"],
|
|
292
|
+
orchestrate: ["orchestrator"],
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
await Bun.write(
|
|
296
|
+
join(agentplateDir, "agent-manifest.json"),
|
|
297
|
+
`${JSON.stringify(manifest, null, "\t")}\n`,
|
|
298
|
+
);
|
|
299
|
+
await Bun.write(join(agentDefsDir, "coordinator.md"), "# Coordinator\n");
|
|
300
|
+
await Bun.write(join(agentDefsDir, "orchestrator.md"), "# Orchestrator\n");
|
|
301
|
+
|
|
302
|
+
// Override cwd so coordinator commands find our temp project
|
|
303
|
+
process.chdir(tempDir);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
afterEach(async () => {
|
|
307
|
+
process.chdir(originalCwd);
|
|
308
|
+
await cleanupTempDir(tempDir);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// --- Helpers ---
|
|
312
|
+
|
|
313
|
+
function makeCoordinatorSession(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
314
|
+
return {
|
|
315
|
+
id: `session-${Date.now()}-coordinator`,
|
|
316
|
+
agentName: "coordinator",
|
|
317
|
+
capability: "coordinator",
|
|
318
|
+
worktreePath: tempDir,
|
|
319
|
+
branchName: "main",
|
|
320
|
+
taskId: "",
|
|
321
|
+
tmuxSession: "agentplate-test-project-coordinator",
|
|
322
|
+
state: "working",
|
|
323
|
+
pid: 99999,
|
|
324
|
+
parentAgent: null,
|
|
325
|
+
depth: 0,
|
|
326
|
+
runId: null,
|
|
327
|
+
startedAt: new Date().toISOString(),
|
|
328
|
+
lastActivity: new Date().toISOString(),
|
|
329
|
+
escalationLevel: 0,
|
|
330
|
+
stalledSince: null,
|
|
331
|
+
transcriptPath: null,
|
|
332
|
+
...overrides,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Capture stdout.write output during a function call. */
|
|
337
|
+
async function captureStdout(fn: () => Promise<void>): Promise<string> {
|
|
338
|
+
const chunks: string[] = [];
|
|
339
|
+
const originalWrite = process.stdout.write;
|
|
340
|
+
process.stdout.write = ((chunk: string) => {
|
|
341
|
+
chunks.push(chunk);
|
|
342
|
+
return true;
|
|
343
|
+
}) as typeof process.stdout.write;
|
|
344
|
+
try {
|
|
345
|
+
await fn();
|
|
346
|
+
} finally {
|
|
347
|
+
process.stdout.write = originalWrite;
|
|
348
|
+
}
|
|
349
|
+
return chunks.join("");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Build default CoordinatorDeps with fake tmux, watchdog, and monitor.
|
|
353
|
+
* Always injects fakes for all three to prevent real Bun.spawn(["agentplate", ...])
|
|
354
|
+
* calls in tests (agentplate CLI is not available in CI). */
|
|
355
|
+
function makeDeps(
|
|
356
|
+
sessionAliveMap: Record<string, boolean> = {},
|
|
357
|
+
watchdogConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
|
|
358
|
+
monitorConfig?: { running?: boolean; startSuccess?: boolean; stopSuccess?: boolean },
|
|
359
|
+
tmuxOptions?: {
|
|
360
|
+
waitForTuiReadyResult?: boolean;
|
|
361
|
+
ensureTmuxAvailableError?: Error;
|
|
362
|
+
checkSessionStateMap?: Record<string, "alive" | "dead" | "no_server">;
|
|
363
|
+
},
|
|
364
|
+
): {
|
|
365
|
+
deps: CoordinatorDeps;
|
|
366
|
+
calls: TmuxCallTracker;
|
|
367
|
+
watchdogCalls: WatchdogCallTracker;
|
|
368
|
+
monitorCalls: MonitorCallTracker;
|
|
369
|
+
} {
|
|
370
|
+
const { tmux, calls } = makeFakeTmux(sessionAliveMap, tmuxOptions);
|
|
371
|
+
const { watchdog, calls: watchdogCalls } = makeFakeWatchdog(
|
|
372
|
+
watchdogConfig?.running,
|
|
373
|
+
watchdogConfig?.startSuccess,
|
|
374
|
+
watchdogConfig?.stopSuccess,
|
|
375
|
+
);
|
|
376
|
+
const { monitor, calls: monitorCalls } = makeFakeMonitor(
|
|
377
|
+
monitorConfig?.running,
|
|
378
|
+
monitorConfig?.startSuccess,
|
|
379
|
+
monitorConfig?.stopSuccess,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const deps: CoordinatorDeps = {
|
|
383
|
+
_tmux: tmux,
|
|
384
|
+
_watchdog: watchdog,
|
|
385
|
+
_monitor: monitor,
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
deps,
|
|
390
|
+
calls,
|
|
391
|
+
watchdogCalls,
|
|
392
|
+
monitorCalls,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// --- Tests ---
|
|
397
|
+
|
|
398
|
+
describe("coordinatorCommand help", () => {
|
|
399
|
+
test("--help outputs help text", async () => {
|
|
400
|
+
const output = await captureStdout(() => coordinatorCommand(["--help"]));
|
|
401
|
+
expect(output).toContain("coordinator");
|
|
402
|
+
expect(output).toContain("start");
|
|
403
|
+
expect(output).toContain("stop");
|
|
404
|
+
expect(output).toContain("status");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("start --help includes --attach and --no-attach flags", async () => {
|
|
408
|
+
const cmd = createCoordinatorCommand({});
|
|
409
|
+
for (const sub of cmd.commands) {
|
|
410
|
+
sub.exitOverride();
|
|
411
|
+
}
|
|
412
|
+
const output = await captureStdout(async () => {
|
|
413
|
+
await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
|
|
414
|
+
});
|
|
415
|
+
expect(output).toContain("--attach");
|
|
416
|
+
expect(output).toContain("--no-attach");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("-h outputs help text", async () => {
|
|
420
|
+
const output = await captureStdout(() => coordinatorCommand(["-h"]));
|
|
421
|
+
expect(output).toContain("coordinator");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("empty args outputs help text", async () => {
|
|
425
|
+
const output = await captureStdout(() => coordinatorCommand([]));
|
|
426
|
+
expect(output).toContain("coordinator");
|
|
427
|
+
expect(output).toContain("Commands:");
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe("coordinatorCommand unknown subcommand", () => {
|
|
432
|
+
test("throws ValidationError for unknown subcommand", async () => {
|
|
433
|
+
await expect(coordinatorCommand(["frobnicate"])).rejects.toThrow(ValidationError);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("error message includes the bad subcommand name", async () => {
|
|
437
|
+
try {
|
|
438
|
+
await coordinatorCommand(["frobnicate"]);
|
|
439
|
+
expect.unreachable("should have thrown");
|
|
440
|
+
} catch (err) {
|
|
441
|
+
expect(err).toBeInstanceOf(ValidationError);
|
|
442
|
+
const ve = err as ValidationError;
|
|
443
|
+
expect(ve.message).toContain("frobnicate");
|
|
444
|
+
expect(ve.field).toBe("subcommand");
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe("startCoordinator", () => {
|
|
450
|
+
test("writes session to sessions.json with correct fields", async () => {
|
|
451
|
+
const { deps, calls } = makeDeps();
|
|
452
|
+
|
|
453
|
+
// Override Bun.sleep to skip the 3s and 0.5s waits
|
|
454
|
+
const originalSleep = Bun.sleep;
|
|
455
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
await captureStdout(() => coordinatorCommand(["start"], deps));
|
|
459
|
+
} finally {
|
|
460
|
+
Bun.sleep = originalSleep;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Verify sessions.json was written
|
|
464
|
+
const sessions = loadSessionsFromDb();
|
|
465
|
+
expect(sessions).toHaveLength(1);
|
|
466
|
+
|
|
467
|
+
const session = sessions[0];
|
|
468
|
+
expect(session).toBeDefined();
|
|
469
|
+
expect(session?.agentName).toBe("coordinator");
|
|
470
|
+
expect(session?.capability).toBe("coordinator");
|
|
471
|
+
expect(session?.tmuxSession).toBe("agentplate-test-project-coordinator");
|
|
472
|
+
expect(session?.state).toBe("booting");
|
|
473
|
+
expect(session?.pid).toBe(99999);
|
|
474
|
+
expect(session?.parentAgent).toBeNull();
|
|
475
|
+
expect(session?.depth).toBe(0);
|
|
476
|
+
expect(session?.taskId).toBe("");
|
|
477
|
+
expect(session?.branchName).toBe("main");
|
|
478
|
+
expect(session?.worktreePath).toBe(tempDir);
|
|
479
|
+
expect(session?.id).toMatch(/^session-\d+-coordinator$/);
|
|
480
|
+
|
|
481
|
+
// Verify the session has a runId set (not null)
|
|
482
|
+
expect(session?.runId).not.toBeNull();
|
|
483
|
+
expect(session?.runId).toMatch(/^run-/);
|
|
484
|
+
|
|
485
|
+
// Verify tmux createSession was called
|
|
486
|
+
expect(calls.createSession).toHaveLength(1);
|
|
487
|
+
expect(calls.createSession[0]?.name).toBe("agentplate-test-project-coordinator");
|
|
488
|
+
expect(calls.createSession[0]?.cwd).toBe(tempDir);
|
|
489
|
+
|
|
490
|
+
// Verify sendKeys was called (beacon + follow-up Enter)
|
|
491
|
+
expect(calls.sendKeys.length).toBeGreaterThanOrEqual(1);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("creates a run record with coordinatorName set", async () => {
|
|
495
|
+
const { deps } = makeDeps();
|
|
496
|
+
const originalSleep = Bun.sleep;
|
|
497
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
|
|
501
|
+
} finally {
|
|
502
|
+
Bun.sleep = originalSleep;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const runStore = createRunStore(join(agentplateDir, "sessions.db"));
|
|
506
|
+
try {
|
|
507
|
+
const run = runStore.getActiveRunForCoordinator("coordinator");
|
|
508
|
+
expect(run).not.toBeNull();
|
|
509
|
+
expect(run?.coordinatorName).toBe("coordinator");
|
|
510
|
+
expect(run?.status).toBe("active");
|
|
511
|
+
expect(run?.coordinatorSessionId).toMatch(/^session-\d+-coordinator$/);
|
|
512
|
+
} finally {
|
|
513
|
+
runStore.close();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("writes current-run.txt for backward compatibility", async () => {
|
|
518
|
+
const { deps } = makeDeps();
|
|
519
|
+
const originalSleep = Bun.sleep;
|
|
520
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
|
|
524
|
+
} finally {
|
|
525
|
+
Bun.sleep = originalSleep;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const currentRunFile = Bun.file(join(agentplateDir, "current-run.txt"));
|
|
529
|
+
expect(await currentRunFile.exists()).toBe(true);
|
|
530
|
+
const runId = (await currentRunFile.text()).trim();
|
|
531
|
+
expect(runId).toMatch(/^run-/);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("run ID in current-run.txt matches session runId", async () => {
|
|
535
|
+
const { deps } = makeDeps();
|
|
536
|
+
const originalSleep = Bun.sleep;
|
|
537
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
|
|
541
|
+
} finally {
|
|
542
|
+
Bun.sleep = originalSleep;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const sessions = loadSessionsFromDb();
|
|
546
|
+
const session = sessions[0];
|
|
547
|
+
expect(session?.runId).toBeDefined();
|
|
548
|
+
|
|
549
|
+
const currentRunFile = Bun.file(join(agentplateDir, "current-run.txt"));
|
|
550
|
+
const fileRunId = (await currentRunFile.text()).trim();
|
|
551
|
+
|
|
552
|
+
expect(session?.runId).toBe(fileRunId);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("deploys hooks to project root .claude/settings.local.json", async () => {
|
|
556
|
+
const { deps } = makeDeps();
|
|
557
|
+
const originalSleep = Bun.sleep;
|
|
558
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
559
|
+
|
|
560
|
+
try {
|
|
561
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
|
|
562
|
+
} finally {
|
|
563
|
+
Bun.sleep = originalSleep;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Verify .claude/settings.local.json was created at the project root
|
|
567
|
+
const settingsPath = join(tempDir, ".claude", "settings.local.json");
|
|
568
|
+
const settingsFile = Bun.file(settingsPath);
|
|
569
|
+
expect(await settingsFile.exists()).toBe(true);
|
|
570
|
+
|
|
571
|
+
const content = await settingsFile.text();
|
|
572
|
+
const config = JSON.parse(content) as {
|
|
573
|
+
hooks: Record<string, unknown[]>;
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// Verify hook categories exist
|
|
577
|
+
expect(config.hooks).toBeDefined();
|
|
578
|
+
expect(config.hooks.SessionStart).toBeDefined();
|
|
579
|
+
expect(config.hooks.UserPromptSubmit).toBeDefined();
|
|
580
|
+
expect(config.hooks.PreToolUse).toBeDefined();
|
|
581
|
+
expect(config.hooks.PostToolUse).toBeDefined();
|
|
582
|
+
expect(config.hooks.Stop).toBeDefined();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("hooks use coordinator agent name for event logging", async () => {
|
|
586
|
+
const { deps } = makeDeps();
|
|
587
|
+
const originalSleep = Bun.sleep;
|
|
588
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
|
|
592
|
+
} finally {
|
|
593
|
+
Bun.sleep = originalSleep;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const settingsPath = join(tempDir, ".claude", "settings.local.json");
|
|
597
|
+
const content = await Bun.file(settingsPath).text();
|
|
598
|
+
|
|
599
|
+
// The hooks should reference the coordinator agent name
|
|
600
|
+
expect(content).toContain("--agent coordinator");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("hooks include ENV_GUARD to avoid affecting user's Claude Code session", async () => {
|
|
604
|
+
const { deps } = makeDeps();
|
|
605
|
+
const originalSleep = Bun.sleep;
|
|
606
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach"], deps));
|
|
610
|
+
} finally {
|
|
611
|
+
Bun.sleep = originalSleep;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const settingsPath = join(tempDir, ".claude", "settings.local.json");
|
|
615
|
+
const content = await Bun.file(settingsPath).text();
|
|
616
|
+
|
|
617
|
+
// PreToolUse guards should include the ENV_GUARD prefix
|
|
618
|
+
expect(content).toContain("AGENTPLATE_AGENT_NAME");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test("injects agent definition via --append-system-prompt when agent-defs/coordinator.md exists", async () => {
|
|
622
|
+
// Deploy a coordinator agent definition
|
|
623
|
+
const agentDefsDir = join(agentplateDir, "agent-defs");
|
|
624
|
+
await mkdir(agentDefsDir, { recursive: true });
|
|
625
|
+
await Bun.write(
|
|
626
|
+
join(agentDefsDir, "coordinator.md"),
|
|
627
|
+
"# Coordinator Agent\n\nYou are the coordinator.\n",
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
const { deps, calls } = makeDeps();
|
|
631
|
+
const originalSleep = Bun.sleep;
|
|
632
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
633
|
+
|
|
634
|
+
try {
|
|
635
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach", "--json"], deps));
|
|
636
|
+
} finally {
|
|
637
|
+
Bun.sleep = originalSleep;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
expect(calls.createSession).toHaveLength(1);
|
|
641
|
+
const cmd = calls.createSession[0]?.command ?? "";
|
|
642
|
+
expect(cmd).toContain("--append-system-prompt");
|
|
643
|
+
// File path is passed via $(cat ...) instead of inlining content (agentplate#45)
|
|
644
|
+
expect(cmd).toContain("$(cat '");
|
|
645
|
+
expect(cmd).toContain("agent-defs/coordinator.md");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("reads model from manifest instead of hardcoding", async () => {
|
|
649
|
+
// Override the manifest to use sonnet instead of default opus
|
|
650
|
+
const manifest = {
|
|
651
|
+
version: "1.0",
|
|
652
|
+
agents: {
|
|
653
|
+
coordinator: {
|
|
654
|
+
file: "coordinator.md",
|
|
655
|
+
model: "sonnet",
|
|
656
|
+
tools: ["Read", "Bash"],
|
|
657
|
+
capabilities: ["coordinate"],
|
|
658
|
+
canSpawn: true,
|
|
659
|
+
constraints: [],
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
capabilityIndex: { coordinate: ["coordinator"] },
|
|
663
|
+
};
|
|
664
|
+
await Bun.write(
|
|
665
|
+
join(agentplateDir, "agent-manifest.json"),
|
|
666
|
+
`${JSON.stringify(manifest, null, "\t")}\n`,
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
const { deps, calls } = makeDeps();
|
|
670
|
+
const originalSleep = Bun.sleep;
|
|
671
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
await captureStdout(() => coordinatorCommand(["start", "--no-attach", "--json"], deps));
|
|
675
|
+
} finally {
|
|
676
|
+
Bun.sleep = originalSleep;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
expect(calls.createSession).toHaveLength(1);
|
|
680
|
+
const cmd = calls.createSession[0]?.command ?? "";
|
|
681
|
+
expect(cmd).toContain("--model sonnet");
|
|
682
|
+
expect(cmd).not.toContain("--model opus");
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("--json outputs JSON with expected fields", async () => {
|
|
686
|
+
const { deps } = makeDeps();
|
|
687
|
+
const originalSleep = Bun.sleep;
|
|
688
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
689
|
+
|
|
690
|
+
let output: string;
|
|
691
|
+
try {
|
|
692
|
+
output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
693
|
+
} finally {
|
|
694
|
+
Bun.sleep = originalSleep;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
698
|
+
expect(parsed.success).toBe(true);
|
|
699
|
+
expect(parsed.command).toBe("coordinator start");
|
|
700
|
+
expect(parsed.agentName).toBe("coordinator");
|
|
701
|
+
expect(parsed.capability).toBe("coordinator");
|
|
702
|
+
expect(parsed.tmuxSession).toBe("agentplate-test-project-coordinator");
|
|
703
|
+
expect(parsed.pid).toBe(99999);
|
|
704
|
+
expect(parsed.projectRoot).toBe(tempDir);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("rejects duplicate when coordinator is already running", async () => {
|
|
708
|
+
// Write an existing active coordinator session
|
|
709
|
+
const existing = makeCoordinatorSession({ state: "working", pid: process.pid });
|
|
710
|
+
saveSessionsToDb([existing]);
|
|
711
|
+
|
|
712
|
+
// Mock tmux as alive for the existing session
|
|
713
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
714
|
+
|
|
715
|
+
await expect(coordinatorCommand(["start"], deps)).rejects.toThrow(AgentError);
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
await coordinatorCommand(["start"], deps);
|
|
719
|
+
} catch (err) {
|
|
720
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
721
|
+
const ae = err as AgentError;
|
|
722
|
+
expect(ae.message).toContain("already running");
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("rejects duplicate when pid is null but tmux session is alive", async () => {
|
|
727
|
+
// Session has null pid (e.g. migrated from older schema) but tmux is alive.
|
|
728
|
+
// Cannot prove it's a zombie without a pid, so treat as active.
|
|
729
|
+
const existing = makeCoordinatorSession({ state: "working", pid: null });
|
|
730
|
+
saveSessionsToDb([existing]);
|
|
731
|
+
|
|
732
|
+
const { deps } = makeDeps(
|
|
733
|
+
{ "agentplate-test-project-coordinator": true },
|
|
734
|
+
undefined,
|
|
735
|
+
undefined,
|
|
736
|
+
{ checkSessionStateMap: { "agentplate-test-project-coordinator": "alive" } },
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
try {
|
|
740
|
+
await coordinatorCommand(["start"], deps);
|
|
741
|
+
expect(true).toBe(false); // Should have thrown
|
|
742
|
+
} catch (err) {
|
|
743
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
744
|
+
const ae = err as AgentError;
|
|
745
|
+
expect(ae.message).toContain("already running");
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("cleans up dead session and starts new one", async () => {
|
|
750
|
+
// Write an existing session that claims to be working
|
|
751
|
+
const deadSession = makeCoordinatorSession({
|
|
752
|
+
id: "session-dead-coordinator",
|
|
753
|
+
state: "working",
|
|
754
|
+
});
|
|
755
|
+
saveSessionsToDb([deadSession]);
|
|
756
|
+
|
|
757
|
+
// Mock tmux as NOT alive for the existing session
|
|
758
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
|
|
759
|
+
|
|
760
|
+
const originalSleep = Bun.sleep;
|
|
761
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
await captureStdout(() => coordinatorCommand(["start"], deps));
|
|
765
|
+
} finally {
|
|
766
|
+
Bun.sleep = originalSleep;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// SessionStore uses UNIQUE(agent_name), so the new session replaces the old one.
|
|
770
|
+
// Verify the new session is in booting state with the coordinator name.
|
|
771
|
+
const sessions = loadSessionsFromDb();
|
|
772
|
+
expect(sessions).toHaveLength(1);
|
|
773
|
+
|
|
774
|
+
const newSession = sessions[0];
|
|
775
|
+
expect(newSession).toBeDefined();
|
|
776
|
+
expect(newSession?.state).toBe("booting");
|
|
777
|
+
expect(newSession?.agentName).toBe("coordinator");
|
|
778
|
+
// The new session should have a different ID than the dead one
|
|
779
|
+
expect(newSession?.id).not.toBe("session-dead-coordinator");
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("cleans up zombie session when tmux alive but PID dead", async () => {
|
|
783
|
+
// Session is "working" in DB, tmux session exists, but the PID is dead
|
|
784
|
+
const zombieSession = makeCoordinatorSession({
|
|
785
|
+
id: "session-zombie-coordinator",
|
|
786
|
+
state: "working",
|
|
787
|
+
pid: 999999, // Non-existent PID
|
|
788
|
+
});
|
|
789
|
+
saveSessionsToDb([zombieSession]);
|
|
790
|
+
|
|
791
|
+
// Tmux session is alive (pane exists) but PID 999999 is not running
|
|
792
|
+
const { deps } = makeDeps(
|
|
793
|
+
{ "agentplate-test-project-coordinator": true },
|
|
794
|
+
undefined,
|
|
795
|
+
undefined,
|
|
796
|
+
{ checkSessionStateMap: { "agentplate-test-project-coordinator": "alive" } },
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
const originalSleep = Bun.sleep;
|
|
800
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
await captureStdout(() => coordinatorCommand(["start"], deps));
|
|
804
|
+
} finally {
|
|
805
|
+
Bun.sleep = originalSleep;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Zombie session should be cleaned up and new one created
|
|
809
|
+
const sessions = loadSessionsFromDb();
|
|
810
|
+
expect(sessions).toHaveLength(1);
|
|
811
|
+
const newSession = sessions[0];
|
|
812
|
+
expect(newSession?.state).toBe("booting");
|
|
813
|
+
expect(newSession?.id).not.toBe("session-zombie-coordinator");
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("cleans up stale session when tmux server is not running", async () => {
|
|
817
|
+
// Session is "booting" in DB but tmux server crashed
|
|
818
|
+
const staleSession = makeCoordinatorSession({
|
|
819
|
+
id: "session-stale-coordinator",
|
|
820
|
+
state: "booting",
|
|
821
|
+
});
|
|
822
|
+
saveSessionsToDb([staleSession]);
|
|
823
|
+
|
|
824
|
+
// checkSessionState returns no_server
|
|
825
|
+
const { deps } = makeDeps(
|
|
826
|
+
{ "agentplate-test-project-coordinator": false },
|
|
827
|
+
undefined,
|
|
828
|
+
undefined,
|
|
829
|
+
{ checkSessionStateMap: { "agentplate-test-project-coordinator": "no_server" } },
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
const originalSleep = Bun.sleep;
|
|
833
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
834
|
+
|
|
835
|
+
try {
|
|
836
|
+
await captureStdout(() => coordinatorCommand(["start"], deps));
|
|
837
|
+
} finally {
|
|
838
|
+
Bun.sleep = originalSleep;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Stale session cleaned up, new one created
|
|
842
|
+
const sessions = loadSessionsFromDb();
|
|
843
|
+
expect(sessions).toHaveLength(1);
|
|
844
|
+
const newSession = sessions[0];
|
|
845
|
+
expect(newSession?.state).toBe("booting");
|
|
846
|
+
expect(newSession?.id).not.toBe("session-stale-coordinator");
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test("respects shellInitDelayMs config before polling TUI readiness", async () => {
|
|
850
|
+
// Append shellInitDelayMs to existing config (preserve tier2Enabled etc.)
|
|
851
|
+
const configPath = join(tempDir, ".agentplate", "config.yaml");
|
|
852
|
+
const existing = await Bun.file(configPath).text();
|
|
853
|
+
await Bun.write(configPath, `${existing}\nruntime:\n shellInitDelayMs: 500\n`);
|
|
854
|
+
|
|
855
|
+
const { deps } = makeDeps();
|
|
856
|
+
|
|
857
|
+
const sleepCalls: number[] = [];
|
|
858
|
+
const originalSleep = Bun.sleep;
|
|
859
|
+
Bun.sleep = ((ms: number | Date) => {
|
|
860
|
+
if (typeof ms === "number") sleepCalls.push(ms);
|
|
861
|
+
return Promise.resolve();
|
|
862
|
+
}) as typeof Bun.sleep;
|
|
863
|
+
|
|
864
|
+
try {
|
|
865
|
+
await captureStdout(() => coordinatorCommand(["start"], deps));
|
|
866
|
+
} finally {
|
|
867
|
+
Bun.sleep = originalSleep;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// The 500ms shell init delay should appear in the sleep calls
|
|
871
|
+
expect(sleepCalls).toContain(500);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
test("throws AgentError when tmux is not available", async () => {
|
|
875
|
+
const { deps } = makeDeps({}, undefined, undefined, {
|
|
876
|
+
ensureTmuxAvailableError: new AgentError(
|
|
877
|
+
"tmux is not installed or not on PATH. Install tmux to use agentplate agent orchestration.",
|
|
878
|
+
),
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
await expect(coordinatorCommand(["start"], deps)).rejects.toThrow(AgentError);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test("AgentError message mentions tmux not installed when tmux unavailable", async () => {
|
|
885
|
+
const { deps } = makeDeps({}, undefined, undefined, {
|
|
886
|
+
ensureTmuxAvailableError: new AgentError(
|
|
887
|
+
"tmux is not installed or not on PATH. Install tmux to use agentplate agent orchestration.",
|
|
888
|
+
),
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
try {
|
|
892
|
+
await coordinatorCommand(["start"], deps);
|
|
893
|
+
expect(true).toBe(false); // Should have thrown
|
|
894
|
+
} catch (err: unknown) {
|
|
895
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
896
|
+
const agentErr = err as AgentError;
|
|
897
|
+
expect(agentErr.message).toContain("tmux is not installed");
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test("throws AgentError when session dies during startup", async () => {
|
|
902
|
+
// waitForTuiReady returns false AND isSessionAlive returns false — session died
|
|
903
|
+
const { deps } = makeDeps(
|
|
904
|
+
{ "agentplate-test-project-coordinator": false },
|
|
905
|
+
undefined,
|
|
906
|
+
undefined,
|
|
907
|
+
{ waitForTuiReadyResult: false },
|
|
908
|
+
);
|
|
909
|
+
|
|
910
|
+
await expect(coordinatorCommand(["start"], deps)).rejects.toThrow(AgentError);
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
test("AgentError message mentions session dying when session dies during startup", async () => {
|
|
914
|
+
const { deps } = makeDeps(
|
|
915
|
+
{ "agentplate-test-project-coordinator": false },
|
|
916
|
+
undefined,
|
|
917
|
+
undefined,
|
|
918
|
+
{ waitForTuiReadyResult: false },
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
await coordinatorCommand(["start"], deps);
|
|
923
|
+
expect(true).toBe(false); // Should have thrown
|
|
924
|
+
} catch (err: unknown) {
|
|
925
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
926
|
+
const agentErr = err as AgentError;
|
|
927
|
+
expect(agentErr.message).toContain("died during startup");
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("kills the coordinator and throws when waitForTuiReady times out but session is still alive", async () => {
|
|
932
|
+
// waitForTuiReady returns false (timeout) and the session is still alive,
|
|
933
|
+
// so startup should fail explicitly instead of sending the beacon blindly.
|
|
934
|
+
const { deps, calls } = makeDeps(
|
|
935
|
+
{ "agentplate-test-project-coordinator": true },
|
|
936
|
+
undefined,
|
|
937
|
+
undefined,
|
|
938
|
+
{ waitForTuiReadyResult: false },
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
const originalSleep = Bun.sleep;
|
|
942
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
943
|
+
|
|
944
|
+
let thrownError: unknown;
|
|
945
|
+
try {
|
|
946
|
+
await captureStdout(() => coordinatorCommand(["start"], deps));
|
|
947
|
+
} catch (err: unknown) {
|
|
948
|
+
thrownError = err;
|
|
949
|
+
} finally {
|
|
950
|
+
Bun.sleep = originalSleep;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
expect(thrownError).toBeInstanceOf(AgentError);
|
|
954
|
+
const agentErr = thrownError as AgentError;
|
|
955
|
+
expect(agentErr.message).toContain("did not become ready during startup");
|
|
956
|
+
expect(calls.killSession).toHaveLength(1);
|
|
957
|
+
expect(calls.killSession[0]?.name).toBe("agentplate-test-project-coordinator");
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
describe("stopCoordinator", () => {
|
|
962
|
+
test("marks session as completed after stopping", async () => {
|
|
963
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
964
|
+
saveSessionsToDb([session]);
|
|
965
|
+
|
|
966
|
+
// Tmux is alive so killSession will be called
|
|
967
|
+
const { deps, calls } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
968
|
+
|
|
969
|
+
await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
970
|
+
|
|
971
|
+
// Verify session is now completed
|
|
972
|
+
const sessions = loadSessionsFromDb();
|
|
973
|
+
expect(sessions).toHaveLength(1);
|
|
974
|
+
expect(sessions[0]?.state).toBe("completed");
|
|
975
|
+
|
|
976
|
+
// Verify killSession was called
|
|
977
|
+
expect(calls.killSession).toHaveLength(1);
|
|
978
|
+
expect(calls.killSession[0]?.name).toBe("agentplate-test-project-coordinator");
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test("--json outputs JSON with stopped flag", async () => {
|
|
982
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
983
|
+
saveSessionsToDb([session]);
|
|
984
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
985
|
+
|
|
986
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
987
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
988
|
+
expect(parsed.success).toBe(true);
|
|
989
|
+
expect(parsed.command).toBe("coordinator stop");
|
|
990
|
+
expect(parsed.stopped).toBe(true);
|
|
991
|
+
expect(parsed.sessionId).toBe(session.id);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test("handles already-dead tmux session gracefully", async () => {
|
|
995
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
996
|
+
saveSessionsToDb([session]);
|
|
997
|
+
|
|
998
|
+
// Tmux is NOT alive — should skip killSession
|
|
999
|
+
const { deps, calls } = makeDeps({ "agentplate-test-project-coordinator": false });
|
|
1000
|
+
|
|
1001
|
+
await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1002
|
+
|
|
1003
|
+
// Verify session is completed
|
|
1004
|
+
const sessions = loadSessionsFromDb();
|
|
1005
|
+
expect(sessions[0]?.state).toBe("completed");
|
|
1006
|
+
|
|
1007
|
+
// killSession should NOT have been called since session was already dead
|
|
1008
|
+
expect(calls.killSession).toHaveLength(0);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
test("throws AgentError when no coordinator session exists", async () => {
|
|
1012
|
+
const { deps } = makeDeps();
|
|
1013
|
+
|
|
1014
|
+
// No sessions.json at all
|
|
1015
|
+
await expect(coordinatorCommand(["stop"], deps)).rejects.toThrow(AgentError);
|
|
1016
|
+
|
|
1017
|
+
try {
|
|
1018
|
+
await coordinatorCommand(["stop"], deps);
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
1021
|
+
const ae = err as AgentError;
|
|
1022
|
+
expect(ae.message).toContain("No active coordinator session");
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test("throws AgentError when only completed sessions exist", async () => {
|
|
1027
|
+
const completed = makeCoordinatorSession({ state: "completed" });
|
|
1028
|
+
saveSessionsToDb([completed]);
|
|
1029
|
+
const { deps } = makeDeps();
|
|
1030
|
+
|
|
1031
|
+
await expect(coordinatorCommand(["stop"], deps)).rejects.toThrow(AgentError);
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
describe("stopCoordinator run completion", () => {
|
|
1036
|
+
test("coordinator stop auto-completes the active run", async () => {
|
|
1037
|
+
// Create a coordinator session
|
|
1038
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1039
|
+
saveSessionsToDb([session]);
|
|
1040
|
+
|
|
1041
|
+
// Create a run in RunStore
|
|
1042
|
+
const dbPath = join(agentplateDir, "sessions.db");
|
|
1043
|
+
const runStore = createRunStore(dbPath);
|
|
1044
|
+
runStore.createRun({
|
|
1045
|
+
id: "run-test-123",
|
|
1046
|
+
startedAt: new Date().toISOString(),
|
|
1047
|
+
coordinatorSessionId: null,
|
|
1048
|
+
status: "active",
|
|
1049
|
+
});
|
|
1050
|
+
runStore.close();
|
|
1051
|
+
|
|
1052
|
+
// Write current-run.txt
|
|
1053
|
+
await Bun.write(join(agentplateDir, "current-run.txt"), "run-test-123");
|
|
1054
|
+
|
|
1055
|
+
// Stop coordinator
|
|
1056
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
1057
|
+
await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1058
|
+
|
|
1059
|
+
// Verify run status is "completed"
|
|
1060
|
+
const runStoreCheck = createRunStore(dbPath);
|
|
1061
|
+
const run = runStoreCheck.getRun("run-test-123");
|
|
1062
|
+
runStoreCheck.close();
|
|
1063
|
+
expect(run?.status).toBe("completed");
|
|
1064
|
+
|
|
1065
|
+
// Verify current-run.txt is deleted
|
|
1066
|
+
const currentRunFile = Bun.file(join(agentplateDir, "current-run.txt"));
|
|
1067
|
+
expect(await currentRunFile.exists()).toBe(false);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test("coordinator stop succeeds when no active run exists", async () => {
|
|
1071
|
+
// Create a coordinator session
|
|
1072
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1073
|
+
saveSessionsToDb([session]);
|
|
1074
|
+
|
|
1075
|
+
// No current-run.txt
|
|
1076
|
+
|
|
1077
|
+
// Stop coordinator (should succeed without errors)
|
|
1078
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
1079
|
+
await expect(captureStdout(() => coordinatorCommand(["stop"], deps))).resolves.toBeDefined();
|
|
1080
|
+
|
|
1081
|
+
// Verify session is completed
|
|
1082
|
+
const sessions = loadSessionsFromDb();
|
|
1083
|
+
expect(sessions[0]?.state).toBe("completed");
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test("coordinator stop succeeds when current-run.txt is empty", async () => {
|
|
1087
|
+
// Create a coordinator session
|
|
1088
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1089
|
+
saveSessionsToDb([session]);
|
|
1090
|
+
|
|
1091
|
+
// Write empty current-run.txt
|
|
1092
|
+
await Bun.write(join(agentplateDir, "current-run.txt"), "");
|
|
1093
|
+
|
|
1094
|
+
// Stop coordinator (should succeed without errors)
|
|
1095
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
1096
|
+
await expect(captureStdout(() => coordinatorCommand(["stop"], deps))).resolves.toBeDefined();
|
|
1097
|
+
|
|
1098
|
+
// Verify session is completed
|
|
1099
|
+
const sessions = loadSessionsFromDb();
|
|
1100
|
+
expect(sessions[0]?.state).toBe("completed");
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
test("--json output includes runCompleted field", async () => {
|
|
1104
|
+
// Create a coordinator session
|
|
1105
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1106
|
+
saveSessionsToDb([session]);
|
|
1107
|
+
|
|
1108
|
+
// Create a run in RunStore
|
|
1109
|
+
const dbPath = join(agentplateDir, "sessions.db");
|
|
1110
|
+
const runStore = createRunStore(dbPath);
|
|
1111
|
+
runStore.createRun({
|
|
1112
|
+
id: "run-test-456",
|
|
1113
|
+
startedAt: new Date().toISOString(),
|
|
1114
|
+
coordinatorSessionId: null,
|
|
1115
|
+
status: "active",
|
|
1116
|
+
});
|
|
1117
|
+
runStore.close();
|
|
1118
|
+
|
|
1119
|
+
// Write current-run.txt
|
|
1120
|
+
await Bun.write(join(agentplateDir, "current-run.txt"), "run-test-456");
|
|
1121
|
+
|
|
1122
|
+
// Stop coordinator with --json
|
|
1123
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
1124
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
1125
|
+
|
|
1126
|
+
// Verify output includes runCompleted: true
|
|
1127
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1128
|
+
expect(parsed.runCompleted).toBe(true);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
test("--json output includes runCompleted:false when no run", async () => {
|
|
1132
|
+
// Create a coordinator session
|
|
1133
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1134
|
+
saveSessionsToDb([session]);
|
|
1135
|
+
|
|
1136
|
+
// No current-run.txt
|
|
1137
|
+
|
|
1138
|
+
// Stop coordinator with --json
|
|
1139
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
1140
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
1141
|
+
|
|
1142
|
+
// Verify output includes runCompleted: false
|
|
1143
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1144
|
+
expect(parsed.runCompleted).toBe(false);
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
describe("statusCoordinator", () => {
|
|
1149
|
+
test("shows 'not running' when no session exists", async () => {
|
|
1150
|
+
const { deps } = makeDeps();
|
|
1151
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
1152
|
+
expect(output).toContain("not running");
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
test("--json shows running:false when no session exists", async () => {
|
|
1156
|
+
const { deps } = makeDeps();
|
|
1157
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1158
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1159
|
+
expect(parsed.success).toBe(true);
|
|
1160
|
+
expect(parsed.command).toBe("coordinator status");
|
|
1161
|
+
expect(parsed.running).toBe(false);
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
test("shows running state when coordinator is alive", async () => {
|
|
1165
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1166
|
+
saveSessionsToDb([session]);
|
|
1167
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
1168
|
+
|
|
1169
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
1170
|
+
expect(output).toContain("running");
|
|
1171
|
+
expect(output).toContain(session.id);
|
|
1172
|
+
expect(output).toContain("agentplate-test-project-coordinator");
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
test("--json shows correct fields when running", async () => {
|
|
1176
|
+
const session = makeCoordinatorSession({ state: "working", pid: 99999 });
|
|
1177
|
+
saveSessionsToDb([session]);
|
|
1178
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
1179
|
+
|
|
1180
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1181
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1182
|
+
expect(parsed.success).toBe(true);
|
|
1183
|
+
expect(parsed.command).toBe("coordinator status");
|
|
1184
|
+
expect(parsed.running).toBe(true);
|
|
1185
|
+
expect(parsed.sessionId).toBe(session.id);
|
|
1186
|
+
expect(parsed.state).toBe("working");
|
|
1187
|
+
expect(parsed.tmuxSession).toBe("agentplate-test-project-coordinator");
|
|
1188
|
+
expect(parsed.pid).toBe(99999);
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
test("reconciles zombie: updates state when tmux is dead but session says working", async () => {
|
|
1192
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1193
|
+
saveSessionsToDb([session]);
|
|
1194
|
+
|
|
1195
|
+
// Tmux is NOT alive — triggers zombie reconciliation
|
|
1196
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
|
|
1197
|
+
|
|
1198
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1199
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1200
|
+
expect(parsed.running).toBe(false);
|
|
1201
|
+
expect(parsed.state).toBe("zombie");
|
|
1202
|
+
|
|
1203
|
+
// Verify sessions.json was updated
|
|
1204
|
+
const sessions = loadSessionsFromDb();
|
|
1205
|
+
expect(sessions[0]?.state).toBe("zombie");
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
test("reconciles zombie for booting state too", async () => {
|
|
1209
|
+
const session = makeCoordinatorSession({ state: "booting" });
|
|
1210
|
+
saveSessionsToDb([session]);
|
|
1211
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
|
|
1212
|
+
|
|
1213
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1214
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1215
|
+
expect(parsed.state).toBe("zombie");
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
test("headless: reports running when pid is alive even though tmuxSession is empty (agentplate-34a6)", async () => {
|
|
1219
|
+
// Headless coordinator: tmuxSession === "", liveness comes from PID check.
|
|
1220
|
+
// Use process.pid (test runner's own PID) as a guaranteed-alive process.
|
|
1221
|
+
const session = makeCoordinatorSession({
|
|
1222
|
+
state: "working",
|
|
1223
|
+
tmuxSession: "",
|
|
1224
|
+
pid: process.pid,
|
|
1225
|
+
});
|
|
1226
|
+
saveSessionsToDb([session]);
|
|
1227
|
+
// sessionAliveMap is empty — tmux.isSessionAlive should NOT be consulted
|
|
1228
|
+
// for headless sessions. If the regression returns, fakeTmux returns false
|
|
1229
|
+
// for an unknown name and the status flips to zombie.
|
|
1230
|
+
const { deps, calls } = makeDeps();
|
|
1231
|
+
|
|
1232
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1233
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1234
|
+
|
|
1235
|
+
expect(parsed.running).toBe(true);
|
|
1236
|
+
expect(parsed.state).toBe("working");
|
|
1237
|
+
expect(parsed.tmuxSession).toBe("");
|
|
1238
|
+
// No tmux liveness check should have been made for the headless session.
|
|
1239
|
+
expect(calls.isSessionAlive).toEqual([]);
|
|
1240
|
+
|
|
1241
|
+
// SessionStore must NOT have been flipped to zombie.
|
|
1242
|
+
const sessions = loadSessionsFromDb();
|
|
1243
|
+
expect(sessions[0]?.state).toBe("working");
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
test("headless: flips to zombie when pid is dead (sentinel non-existent PID)", async () => {
|
|
1247
|
+
// PID 2147483647 (INT32_MAX) is reserved/invalid and never alive.
|
|
1248
|
+
const session = makeCoordinatorSession({
|
|
1249
|
+
state: "working",
|
|
1250
|
+
tmuxSession: "",
|
|
1251
|
+
pid: 2147483647,
|
|
1252
|
+
});
|
|
1253
|
+
saveSessionsToDb([session]);
|
|
1254
|
+
const { deps } = makeDeps();
|
|
1255
|
+
|
|
1256
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1257
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1258
|
+
|
|
1259
|
+
expect(parsed.running).toBe(false);
|
|
1260
|
+
expect(parsed.state).toBe("zombie");
|
|
1261
|
+
|
|
1262
|
+
const sessions = loadSessionsFromDb();
|
|
1263
|
+
expect(sessions[0]?.state).toBe("zombie");
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
test("does not show completed sessions as active", async () => {
|
|
1267
|
+
const completed = makeCoordinatorSession({ state: "completed" });
|
|
1268
|
+
saveSessionsToDb([completed]);
|
|
1269
|
+
const { deps } = makeDeps();
|
|
1270
|
+
|
|
1271
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1272
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1273
|
+
expect(parsed.running).toBe(false);
|
|
1274
|
+
});
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
describe("buildCoordinatorBeacon", () => {
|
|
1278
|
+
test("is a single line (no newlines)", () => {
|
|
1279
|
+
const beacon = buildCoordinatorBeacon();
|
|
1280
|
+
expect(beacon).not.toContain("\n");
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
test("includes coordinator identity in header", () => {
|
|
1284
|
+
const beacon = buildCoordinatorBeacon();
|
|
1285
|
+
expect(beacon).toContain("[AGENTPLATE] coordinator (coordinator)");
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
test("includes ISO timestamp", () => {
|
|
1289
|
+
const beacon = buildCoordinatorBeacon();
|
|
1290
|
+
expect(beacon).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
test("includes depth and parent info", () => {
|
|
1294
|
+
const beacon = buildCoordinatorBeacon();
|
|
1295
|
+
expect(beacon).toContain("Depth: 0 | Parent: none");
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test("includes persistent orchestrator role", () => {
|
|
1299
|
+
const beacon = buildCoordinatorBeacon();
|
|
1300
|
+
expect(beacon).toContain("Role: persistent orchestrator");
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
test("includes startup instructions", () => {
|
|
1304
|
+
const beacon = buildCoordinatorBeacon();
|
|
1305
|
+
expect(beacon).toContain("loam prime");
|
|
1306
|
+
expect(beacon).toContain("ap mail check --agent coordinator");
|
|
1307
|
+
expect(beacon).toContain("bd ready");
|
|
1308
|
+
expect(beacon).toContain("ap group status");
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
test("defaults to bd ready when no cliName provided", () => {
|
|
1312
|
+
const beacon = buildCoordinatorBeacon();
|
|
1313
|
+
expect(beacon).toContain("bd ready");
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
test("uses sr ready when cliName is sr", () => {
|
|
1317
|
+
const beacon = buildCoordinatorBeacon("sr");
|
|
1318
|
+
expect(beacon).toContain("sr ready");
|
|
1319
|
+
expect(beacon).not.toContain("bd ready");
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
test("includes hierarchy enforcement instruction", () => {
|
|
1323
|
+
const beacon = buildCoordinatorBeacon();
|
|
1324
|
+
expect(beacon).toContain("Default to leads");
|
|
1325
|
+
expect(beacon).toContain("spawn scout/builder directly");
|
|
1326
|
+
expect(beacon).toContain("NEVER spawn reviewer or merger directly");
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
test("includes delegation instruction", () => {
|
|
1330
|
+
const beacon = buildCoordinatorBeacon();
|
|
1331
|
+
expect(beacon).toContain("DELEGATION");
|
|
1332
|
+
expect(beacon).toContain("spawn a lead who will handle scouts/builders/reviewers");
|
|
1333
|
+
expect(beacon).toContain("--dispatch-max-agents 1/2");
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
test("parts are joined with em-dash separator", () => {
|
|
1337
|
+
const beacon = buildCoordinatorBeacon();
|
|
1338
|
+
// Should have exactly 4 " — " separators (5 parts)
|
|
1339
|
+
const dashes = beacon.split(" — ");
|
|
1340
|
+
expect(dashes).toHaveLength(5);
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
describe("orchestratorCommand", () => {
|
|
1345
|
+
test("help shows orchestrator command name", async () => {
|
|
1346
|
+
const output = await captureStdout(() => orchestratorCommand(["--help"]));
|
|
1347
|
+
expect(output).toContain("orchestrator");
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test("start creates orchestrator session with orchestrator capability", async () => {
|
|
1351
|
+
const { deps, calls } = makeDeps({ "agentplate-test-project-orchestrator": true });
|
|
1352
|
+
const originalSleep = Bun.sleep;
|
|
1353
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1354
|
+
|
|
1355
|
+
try {
|
|
1356
|
+
const output = await captureStdout(() =>
|
|
1357
|
+
orchestratorCommand(["start", "--no-attach", "--json"], deps),
|
|
1358
|
+
);
|
|
1359
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1360
|
+
|
|
1361
|
+
expect(parsed.agentName).toBe("orchestrator");
|
|
1362
|
+
expect(parsed.capability).toBe("orchestrator");
|
|
1363
|
+
expect(parsed.tmuxSession).toBe("agentplate-test-project-orchestrator");
|
|
1364
|
+
expect(calls.createSession[0]?.name).toBe("agentplate-test-project-orchestrator");
|
|
1365
|
+
expect(calls.createSession[0]?.command).toContain("orchestrator.md");
|
|
1366
|
+
|
|
1367
|
+
const session = loadSessionsFromDb().find((entry) => entry.agentName === "orchestrator");
|
|
1368
|
+
expect(session?.capability).toBe("orchestrator");
|
|
1369
|
+
} finally {
|
|
1370
|
+
Bun.sleep = originalSleep;
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
test("command registration includes orchestrator start/stop/status", () => {
|
|
1375
|
+
const cmd = createOrchestratorCommand({});
|
|
1376
|
+
const subcommandNames = cmd.commands.map((c) => c.name());
|
|
1377
|
+
expect(subcommandNames).toContain("start");
|
|
1378
|
+
expect(subcommandNames).toContain("stop");
|
|
1379
|
+
expect(subcommandNames).toContain("status");
|
|
1380
|
+
expect(subcommandNames).not.toContain("check-complete");
|
|
1381
|
+
});
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
describe("buildOrchestratorBeacon", () => {
|
|
1385
|
+
test("includes orchestrator identity in header", () => {
|
|
1386
|
+
const beacon = buildOrchestratorBeacon();
|
|
1387
|
+
expect(beacon).toContain("[AGENTPLATE] orchestrator (orchestrator)");
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
test("includes ecosystem startup instructions", () => {
|
|
1391
|
+
const beacon = buildOrchestratorBeacon("sr");
|
|
1392
|
+
expect(beacon).toContain("ap mail check --agent orchestrator");
|
|
1393
|
+
expect(beacon).toContain("sr ready");
|
|
1394
|
+
expect(beacon).toContain("inspect ecosystem status");
|
|
1395
|
+
});
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
describe("resolveAttach", () => {
|
|
1399
|
+
test("--attach flag forces attach regardless of TTY", () => {
|
|
1400
|
+
expect(resolveAttach(["--attach"], false)).toBe(true);
|
|
1401
|
+
expect(resolveAttach(["--attach"], true)).toBe(true);
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
test("--no-attach flag forces no attach regardless of TTY", () => {
|
|
1405
|
+
expect(resolveAttach(["--no-attach"], false)).toBe(false);
|
|
1406
|
+
expect(resolveAttach(["--no-attach"], true)).toBe(false);
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
test("--attach takes precedence when both flags are present", () => {
|
|
1410
|
+
expect(resolveAttach(["--attach", "--no-attach"], false)).toBe(true);
|
|
1411
|
+
expect(resolveAttach(["--attach", "--no-attach"], true)).toBe(true);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
test("defaults to TTY state when no flag is set", () => {
|
|
1415
|
+
expect(resolveAttach([], true)).toBe(true);
|
|
1416
|
+
expect(resolveAttach([], false)).toBe(false);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
test("works with other flags present", () => {
|
|
1420
|
+
expect(resolveAttach(["--json", "--attach"], false)).toBe(true);
|
|
1421
|
+
expect(resolveAttach(["--json", "--no-attach"], true)).toBe(false);
|
|
1422
|
+
expect(resolveAttach(["--json"], true)).toBe(true);
|
|
1423
|
+
});
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
describe("watchdog integration", () => {
|
|
1427
|
+
describe("startCoordinator with --watchdog", () => {
|
|
1428
|
+
test("calls watchdog.start() when --watchdog flag is present", async () => {
|
|
1429
|
+
const { deps, watchdogCalls } = makeDeps({}, { startSuccess: true });
|
|
1430
|
+
const originalSleep = Bun.sleep;
|
|
1431
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1432
|
+
|
|
1433
|
+
try {
|
|
1434
|
+
await captureStdout(() => coordinatorCommand(["start", "--watchdog", "--json"], deps));
|
|
1435
|
+
} finally {
|
|
1436
|
+
Bun.sleep = originalSleep;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
expect(watchdogCalls?.start).toBe(1);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
test("does NOT call watchdog.start() when --watchdog flag is absent", async () => {
|
|
1443
|
+
const { deps, watchdogCalls } = makeDeps({}, { startSuccess: true });
|
|
1444
|
+
const originalSleep = Bun.sleep;
|
|
1445
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1446
|
+
|
|
1447
|
+
try {
|
|
1448
|
+
await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
1449
|
+
} finally {
|
|
1450
|
+
Bun.sleep = originalSleep;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
expect(watchdogCalls?.start).toBe(0);
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
test("--json output includes watchdog field when --watchdog is present and succeeds", async () => {
|
|
1457
|
+
const { deps } = makeDeps({}, { startSuccess: true });
|
|
1458
|
+
const originalSleep = Bun.sleep;
|
|
1459
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1460
|
+
|
|
1461
|
+
let output: string;
|
|
1462
|
+
try {
|
|
1463
|
+
output = await captureStdout(() =>
|
|
1464
|
+
coordinatorCommand(["start", "--watchdog", "--json"], deps),
|
|
1465
|
+
);
|
|
1466
|
+
} finally {
|
|
1467
|
+
Bun.sleep = originalSleep;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1471
|
+
expect(parsed.watchdog).toBe(true);
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
test("--json output includes watchdog:false when --watchdog is present but start fails", async () => {
|
|
1475
|
+
const { deps } = makeDeps({}, { startSuccess: false });
|
|
1476
|
+
const originalSleep = Bun.sleep;
|
|
1477
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1478
|
+
|
|
1479
|
+
let output: string;
|
|
1480
|
+
try {
|
|
1481
|
+
output = await captureStdout(() =>
|
|
1482
|
+
coordinatorCommand(["start", "--watchdog", "--json"], deps),
|
|
1483
|
+
);
|
|
1484
|
+
} finally {
|
|
1485
|
+
Bun.sleep = originalSleep;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1489
|
+
expect(parsed.watchdog).toBe(false);
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
test("--json output includes watchdog:false when --watchdog is absent", async () => {
|
|
1493
|
+
const { deps } = makeDeps({}, { startSuccess: true });
|
|
1494
|
+
const originalSleep = Bun.sleep;
|
|
1495
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1496
|
+
|
|
1497
|
+
let output: string;
|
|
1498
|
+
try {
|
|
1499
|
+
output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
1500
|
+
} finally {
|
|
1501
|
+
Bun.sleep = originalSleep;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1505
|
+
expect(parsed.watchdog).toBe(false);
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
test("text output includes watchdog PID when --watchdog succeeds", async () => {
|
|
1509
|
+
const { deps } = makeDeps({}, { startSuccess: true });
|
|
1510
|
+
const originalSleep = Bun.sleep;
|
|
1511
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1512
|
+
|
|
1513
|
+
let output: string;
|
|
1514
|
+
try {
|
|
1515
|
+
output = await captureStdout(() =>
|
|
1516
|
+
coordinatorCommand(["start", "--watchdog", "--no-attach"], deps),
|
|
1517
|
+
);
|
|
1518
|
+
} finally {
|
|
1519
|
+
Bun.sleep = originalSleep;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
expect(output).toContain("Watchdog started");
|
|
1523
|
+
});
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
describe("stopCoordinator watchdog cleanup", () => {
|
|
1527
|
+
test("always calls watchdog.stop() when stopping coordinator", async () => {
|
|
1528
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1529
|
+
saveSessionsToDb([session]);
|
|
1530
|
+
const { deps, watchdogCalls } = makeDeps(
|
|
1531
|
+
{ "agentplate-test-project-coordinator": true },
|
|
1532
|
+
{ stopSuccess: true },
|
|
1533
|
+
);
|
|
1534
|
+
|
|
1535
|
+
await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1536
|
+
|
|
1537
|
+
expect(watchdogCalls?.stop).toBe(1);
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
test("--json output includes watchdogStopped:true when watchdog was running", async () => {
|
|
1541
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1542
|
+
saveSessionsToDb([session]);
|
|
1543
|
+
const { deps } = makeDeps(
|
|
1544
|
+
{ "agentplate-test-project-coordinator": true },
|
|
1545
|
+
{ stopSuccess: true },
|
|
1546
|
+
);
|
|
1547
|
+
|
|
1548
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
1549
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1550
|
+
expect(parsed.watchdogStopped).toBe(true);
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
test("--json output includes watchdogStopped:false when no watchdog was running", async () => {
|
|
1554
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1555
|
+
saveSessionsToDb([session]);
|
|
1556
|
+
const { deps } = makeDeps(
|
|
1557
|
+
{ "agentplate-test-project-coordinator": true },
|
|
1558
|
+
{ stopSuccess: false },
|
|
1559
|
+
);
|
|
1560
|
+
|
|
1561
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
1562
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1563
|
+
expect(parsed.watchdogStopped).toBe(false);
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
test("text output shows 'Watchdog stopped' when watchdog was running", async () => {
|
|
1567
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1568
|
+
saveSessionsToDb([session]);
|
|
1569
|
+
const { deps } = makeDeps(
|
|
1570
|
+
{ "agentplate-test-project-coordinator": true },
|
|
1571
|
+
{ stopSuccess: true },
|
|
1572
|
+
);
|
|
1573
|
+
|
|
1574
|
+
const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1575
|
+
expect(output).toContain("Watchdog stopped");
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
test("text output shows 'No watchdog running' when no watchdog was running", async () => {
|
|
1579
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1580
|
+
saveSessionsToDb([session]);
|
|
1581
|
+
const { deps } = makeDeps(
|
|
1582
|
+
{ "agentplate-test-project-coordinator": true },
|
|
1583
|
+
{ stopSuccess: false },
|
|
1584
|
+
);
|
|
1585
|
+
|
|
1586
|
+
const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1587
|
+
expect(output).toContain("No watchdog running");
|
|
1588
|
+
});
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
describe("statusCoordinator watchdog state", () => {
|
|
1592
|
+
test("includes watchdogRunning in JSON output when coordinator is running", async () => {
|
|
1593
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1594
|
+
saveSessionsToDb([session]);
|
|
1595
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, { running: true });
|
|
1596
|
+
|
|
1597
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1598
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1599
|
+
expect(parsed.watchdogRunning).toBe(true);
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
test("includes watchdogRunning:false in JSON output when watchdog is not running", async () => {
|
|
1603
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1604
|
+
saveSessionsToDb([session]);
|
|
1605
|
+
const { deps } = makeDeps(
|
|
1606
|
+
{ "agentplate-test-project-coordinator": true },
|
|
1607
|
+
{ running: false },
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1611
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1612
|
+
expect(parsed.watchdogRunning).toBe(false);
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
test("text output shows watchdog status when coordinator is running", async () => {
|
|
1616
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1617
|
+
saveSessionsToDb([session]);
|
|
1618
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, { running: true });
|
|
1619
|
+
|
|
1620
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
1621
|
+
expect(output).toContain("Watchdog: running");
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
test("text output shows 'not running' when watchdog is not running", async () => {
|
|
1625
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1626
|
+
saveSessionsToDb([session]);
|
|
1627
|
+
const { deps } = makeDeps(
|
|
1628
|
+
{ "agentplate-test-project-coordinator": true },
|
|
1629
|
+
{ running: false },
|
|
1630
|
+
);
|
|
1631
|
+
|
|
1632
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
1633
|
+
expect(output).toContain("Watchdog: not running");
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
test("includes watchdogRunning in JSON output when coordinator is not running", async () => {
|
|
1637
|
+
const { deps } = makeDeps({}, { running: true });
|
|
1638
|
+
|
|
1639
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
1640
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1641
|
+
expect(parsed.running).toBe(false);
|
|
1642
|
+
expect(parsed.watchdogRunning).toBe(true);
|
|
1643
|
+
});
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
describe("COORDINATOR_HELP", () => {
|
|
1647
|
+
test("start help text includes --watchdog flag", async () => {
|
|
1648
|
+
const cmd = createCoordinatorCommand({});
|
|
1649
|
+
for (const sub of cmd.commands) {
|
|
1650
|
+
sub.exitOverride();
|
|
1651
|
+
}
|
|
1652
|
+
const output = await captureStdout(async () => {
|
|
1653
|
+
await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
|
|
1654
|
+
});
|
|
1655
|
+
expect(output).toContain("--watchdog");
|
|
1656
|
+
expect(output).toContain("watchdog");
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
test("start help text includes --accept-existing-watchdog flag", async () => {
|
|
1660
|
+
const cmd = createCoordinatorCommand({});
|
|
1661
|
+
for (const sub of cmd.commands) {
|
|
1662
|
+
sub.exitOverride();
|
|
1663
|
+
}
|
|
1664
|
+
const output = await captureStdout(async () => {
|
|
1665
|
+
await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
|
|
1666
|
+
});
|
|
1667
|
+
expect(output).toContain("--accept-existing-watchdog");
|
|
1668
|
+
});
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
// agentplate-3f0c: detect leftover watchdog from a previous session before
|
|
1672
|
+
// spawning, so operators do not get unexpected watchdog supervision.
|
|
1673
|
+
describe("orphan watchdog detection (agentplate-3f0c)", () => {
|
|
1674
|
+
// (a) start (no --watchdog) + isRunning=true -> throws AgentError with PID
|
|
1675
|
+
// and mention of --accept-existing-watchdog in the message
|
|
1676
|
+
test("rejects start with AgentError when no flag passed and watchdog already running", async () => {
|
|
1677
|
+
const { deps, watchdogCalls } = makeDeps({}, { running: true, startSuccess: true });
|
|
1678
|
+
const originalSleep = Bun.sleep;
|
|
1679
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1680
|
+
|
|
1681
|
+
try {
|
|
1682
|
+
await coordinatorCommand(["start", "--json"], deps);
|
|
1683
|
+
expect.unreachable("should have thrown AgentError");
|
|
1684
|
+
} catch (err) {
|
|
1685
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
1686
|
+
const ae = err as AgentError;
|
|
1687
|
+
expect(ae.message).toContain("Watchdog daemon");
|
|
1688
|
+
// PID is unavailable from the fake watchdog (no PID file written),
|
|
1689
|
+
// so the message reports "unknown PID" — but it must reference the
|
|
1690
|
+
// concept and the suppress flag explicitly.
|
|
1691
|
+
expect(ae.message).toMatch(/PID/);
|
|
1692
|
+
expect(ae.message).toContain("--accept-existing-watchdog");
|
|
1693
|
+
expect(ae.message).toContain("--watchdog");
|
|
1694
|
+
expect(ae.message).toContain("ap watch --kill-others");
|
|
1695
|
+
} finally {
|
|
1696
|
+
Bun.sleep = originalSleep;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// Detection ran but auto-start did NOT — the throw fired first.
|
|
1700
|
+
expect(watchdogCalls?.isRunning).toBeGreaterThanOrEqual(1);
|
|
1701
|
+
expect(watchdogCalls?.start).toBe(0);
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
// (b) start --watchdog + isRunning=true -> does NOT throw;
|
|
1705
|
+
// watchdog.start() is still called once
|
|
1706
|
+
test("--watchdog with already-running daemon does NOT throw and still calls start()", async () => {
|
|
1707
|
+
const { deps, watchdogCalls } = makeDeps(
|
|
1708
|
+
{},
|
|
1709
|
+
{ running: true, startSuccess: false }, // startSuccess:false simulates the no-op-when-already-running return
|
|
1710
|
+
);
|
|
1711
|
+
const originalSleep = Bun.sleep;
|
|
1712
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1713
|
+
|
|
1714
|
+
let output: string;
|
|
1715
|
+
try {
|
|
1716
|
+
output = await captureStdout(() =>
|
|
1717
|
+
coordinatorCommand(["start", "--watchdog", "--json"], deps),
|
|
1718
|
+
);
|
|
1719
|
+
} finally {
|
|
1720
|
+
Bun.sleep = originalSleep;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
expect(watchdogCalls?.start).toBe(1);
|
|
1724
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1725
|
+
// reused-daemon sentinel keeps watchdog truthy in the JSON output
|
|
1726
|
+
expect(parsed.watchdog).toBe(true);
|
|
1727
|
+
expect(parsed.watchdogPreexisting).toBe(true);
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
// (c) start --accept-existing-watchdog + isRunning=true -> does NOT throw;
|
|
1731
|
+
// coordinator starts normally; watchdog.start() is NOT called
|
|
1732
|
+
test("--accept-existing-watchdog allows start without calling watchdog.start()", async () => {
|
|
1733
|
+
const { deps, watchdogCalls } = makeDeps({}, { running: true, startSuccess: true });
|
|
1734
|
+
const originalSleep = Bun.sleep;
|
|
1735
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1736
|
+
|
|
1737
|
+
let output: string;
|
|
1738
|
+
try {
|
|
1739
|
+
output = await captureStdout(() =>
|
|
1740
|
+
coordinatorCommand(["start", "--accept-existing-watchdog", "--json"], deps),
|
|
1741
|
+
);
|
|
1742
|
+
} finally {
|
|
1743
|
+
Bun.sleep = originalSleep;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
expect(watchdogCalls?.start).toBe(0);
|
|
1747
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1748
|
+
expect(parsed.watchdog).toBe(true);
|
|
1749
|
+
expect(parsed.watchdogPreexisting).toBe(true);
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
// (d) start (no --watchdog) + isRunning=false -> no error, no start
|
|
1753
|
+
// (regression — preserves the original "no flag, no daemon activity" path)
|
|
1754
|
+
test("no flag + watchdog not running: starts normally without calling start()", async () => {
|
|
1755
|
+
const { deps, watchdogCalls } = makeDeps({}, { running: false, startSuccess: true });
|
|
1756
|
+
const originalSleep = Bun.sleep;
|
|
1757
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1758
|
+
|
|
1759
|
+
let output: string;
|
|
1760
|
+
try {
|
|
1761
|
+
output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
1762
|
+
} finally {
|
|
1763
|
+
Bun.sleep = originalSleep;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
expect(watchdogCalls?.start).toBe(0);
|
|
1767
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1768
|
+
expect(parsed.watchdog).toBe(false);
|
|
1769
|
+
expect(parsed.watchdogPreexisting).toBe(false);
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
test("orchestrator inherits the same orphan-watchdog detection", async () => {
|
|
1773
|
+
const { deps, watchdogCalls } = makeDeps({}, { running: true });
|
|
1774
|
+
const originalSleep = Bun.sleep;
|
|
1775
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1776
|
+
|
|
1777
|
+
try {
|
|
1778
|
+
await expect(orchestratorCommand(["start", "--json"], deps)).rejects.toThrow(AgentError);
|
|
1779
|
+
} finally {
|
|
1780
|
+
Bun.sleep = originalSleep;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
expect(watchdogCalls?.start).toBe(0);
|
|
1784
|
+
});
|
|
1785
|
+
});
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
describe("monitor integration", () => {
|
|
1789
|
+
describe("startCoordinator with --monitor", () => {
|
|
1790
|
+
test("calls monitor.start() when --monitor flag is present", async () => {
|
|
1791
|
+
const { deps, monitorCalls } = makeDeps({}, undefined, { startSuccess: true });
|
|
1792
|
+
const originalSleep = Bun.sleep;
|
|
1793
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1794
|
+
|
|
1795
|
+
try {
|
|
1796
|
+
await captureStdout(() => coordinatorCommand(["start", "--monitor", "--json"], deps));
|
|
1797
|
+
} finally {
|
|
1798
|
+
Bun.sleep = originalSleep;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
expect(monitorCalls?.start).toBe(1);
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
test("does NOT call monitor.start() when --monitor flag is absent", async () => {
|
|
1805
|
+
const { deps, monitorCalls } = makeDeps({}, undefined, { startSuccess: true });
|
|
1806
|
+
const originalSleep = Bun.sleep;
|
|
1807
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1808
|
+
|
|
1809
|
+
try {
|
|
1810
|
+
await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
1811
|
+
} finally {
|
|
1812
|
+
Bun.sleep = originalSleep;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
expect(monitorCalls?.start).toBe(0);
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
test("--json output includes monitor field when --monitor is present and succeeds", async () => {
|
|
1819
|
+
const { deps } = makeDeps({}, undefined, { startSuccess: true });
|
|
1820
|
+
const originalSleep = Bun.sleep;
|
|
1821
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1822
|
+
|
|
1823
|
+
let output: string;
|
|
1824
|
+
try {
|
|
1825
|
+
output = await captureStdout(() =>
|
|
1826
|
+
coordinatorCommand(["start", "--monitor", "--json"], deps),
|
|
1827
|
+
);
|
|
1828
|
+
} finally {
|
|
1829
|
+
Bun.sleep = originalSleep;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1833
|
+
expect(parsed.monitor).toBe(true);
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
test("--json output includes monitor:false when --monitor is present but start fails", async () => {
|
|
1837
|
+
const { deps } = makeDeps({}, undefined, { startSuccess: false });
|
|
1838
|
+
const originalSleep = Bun.sleep;
|
|
1839
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1840
|
+
|
|
1841
|
+
let output: string;
|
|
1842
|
+
try {
|
|
1843
|
+
output = await captureStdout(() =>
|
|
1844
|
+
coordinatorCommand(["start", "--monitor", "--json"], deps),
|
|
1845
|
+
);
|
|
1846
|
+
} finally {
|
|
1847
|
+
Bun.sleep = originalSleep;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1851
|
+
expect(parsed.monitor).toBe(false);
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
test("--json output includes monitor:false when --monitor is absent", async () => {
|
|
1855
|
+
const { deps } = makeDeps({}, undefined, { startSuccess: true });
|
|
1856
|
+
const originalSleep = Bun.sleep;
|
|
1857
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1858
|
+
|
|
1859
|
+
let output: string;
|
|
1860
|
+
try {
|
|
1861
|
+
output = await captureStdout(() => coordinatorCommand(["start", "--json"], deps));
|
|
1862
|
+
} finally {
|
|
1863
|
+
Bun.sleep = originalSleep;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1867
|
+
expect(parsed.monitor).toBe(false);
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
test("text output includes monitor PID when --monitor succeeds", async () => {
|
|
1871
|
+
const { deps } = makeDeps({}, undefined, { startSuccess: true });
|
|
1872
|
+
const originalSleep = Bun.sleep;
|
|
1873
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1874
|
+
|
|
1875
|
+
let output: string;
|
|
1876
|
+
try {
|
|
1877
|
+
output = await captureStdout(() =>
|
|
1878
|
+
coordinatorCommand(["start", "--monitor", "--no-attach"], deps),
|
|
1879
|
+
);
|
|
1880
|
+
} finally {
|
|
1881
|
+
Bun.sleep = originalSleep;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
expect(output).toContain("Monitor started");
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
test("does NOT call monitor.start() when tier2Enabled is false", async () => {
|
|
1888
|
+
// Override config with tier2Enabled: false
|
|
1889
|
+
await Bun.write(
|
|
1890
|
+
join(agentplateDir, "config.yaml"),
|
|
1891
|
+
[
|
|
1892
|
+
"project:",
|
|
1893
|
+
" name: test-project",
|
|
1894
|
+
` root: ${tempDir}`,
|
|
1895
|
+
" canonicalBranch: main",
|
|
1896
|
+
"watchdog:",
|
|
1897
|
+
" tier2Enabled: false",
|
|
1898
|
+
].join("\n"),
|
|
1899
|
+
);
|
|
1900
|
+
const { deps, monitorCalls } = makeDeps({}, undefined, { startSuccess: true });
|
|
1901
|
+
const originalSleep = Bun.sleep;
|
|
1902
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1903
|
+
|
|
1904
|
+
try {
|
|
1905
|
+
await captureStdout(() => coordinatorCommand(["start", "--monitor", "--json"], deps));
|
|
1906
|
+
} finally {
|
|
1907
|
+
Bun.sleep = originalSleep;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
expect(monitorCalls?.start).toBe(0);
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
test("text output shows skipped message when tier2Enabled is false", async () => {
|
|
1914
|
+
// Override config with tier2Enabled: false
|
|
1915
|
+
await Bun.write(
|
|
1916
|
+
join(agentplateDir, "config.yaml"),
|
|
1917
|
+
[
|
|
1918
|
+
"project:",
|
|
1919
|
+
" name: test-project",
|
|
1920
|
+
` root: ${tempDir}`,
|
|
1921
|
+
" canonicalBranch: main",
|
|
1922
|
+
"watchdog:",
|
|
1923
|
+
" tier2Enabled: false",
|
|
1924
|
+
].join("\n"),
|
|
1925
|
+
);
|
|
1926
|
+
const { deps } = makeDeps({}, undefined, { startSuccess: true });
|
|
1927
|
+
const originalSleep = Bun.sleep;
|
|
1928
|
+
Bun.sleep = (() => Promise.resolve()) as typeof Bun.sleep;
|
|
1929
|
+
|
|
1930
|
+
let output: string;
|
|
1931
|
+
try {
|
|
1932
|
+
output = await captureStdout(() =>
|
|
1933
|
+
coordinatorCommand(["start", "--monitor", "--no-attach"], deps),
|
|
1934
|
+
);
|
|
1935
|
+
} finally {
|
|
1936
|
+
Bun.sleep = originalSleep;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
expect(output).toContain("skipped");
|
|
1940
|
+
});
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
describe("stopCoordinator monitor cleanup", () => {
|
|
1944
|
+
test("always calls monitor.stop() when stopping coordinator", async () => {
|
|
1945
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1946
|
+
saveSessionsToDb([session]);
|
|
1947
|
+
const { deps, monitorCalls } = makeDeps(
|
|
1948
|
+
{ "agentplate-test-project-coordinator": true },
|
|
1949
|
+
undefined,
|
|
1950
|
+
{ stopSuccess: true },
|
|
1951
|
+
);
|
|
1952
|
+
|
|
1953
|
+
await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1954
|
+
|
|
1955
|
+
expect(monitorCalls?.stop).toBe(1);
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
test("--json output includes monitorStopped:true when monitor was running", async () => {
|
|
1959
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1960
|
+
saveSessionsToDb([session]);
|
|
1961
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
|
|
1962
|
+
stopSuccess: true,
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
1966
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1967
|
+
expect(parsed.monitorStopped).toBe(true);
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
test("--json output includes monitorStopped:false when no monitor was running", async () => {
|
|
1971
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1972
|
+
saveSessionsToDb([session]);
|
|
1973
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
|
|
1974
|
+
stopSuccess: false,
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
const output = await captureStdout(() => coordinatorCommand(["stop", "--json"], deps));
|
|
1978
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1979
|
+
expect(parsed.monitorStopped).toBe(false);
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
test("text output shows 'Monitor stopped' when monitor was running", async () => {
|
|
1983
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1984
|
+
saveSessionsToDb([session]);
|
|
1985
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
|
|
1986
|
+
stopSuccess: true,
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
1990
|
+
expect(output).toContain("Monitor stopped");
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1993
|
+
test("text output shows 'No monitor running' when no monitor was running", async () => {
|
|
1994
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1995
|
+
saveSessionsToDb([session]);
|
|
1996
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
|
|
1997
|
+
stopSuccess: false,
|
|
1998
|
+
});
|
|
1999
|
+
|
|
2000
|
+
const output = await captureStdout(() => coordinatorCommand(["stop"], deps));
|
|
2001
|
+
expect(output).toContain("No monitor running");
|
|
2002
|
+
});
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
describe("statusCoordinator monitor state", () => {
|
|
2006
|
+
test("includes monitorRunning in JSON output when coordinator is running", async () => {
|
|
2007
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2008
|
+
saveSessionsToDb([session]);
|
|
2009
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
|
|
2010
|
+
running: true,
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
2014
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
2015
|
+
expect(parsed.monitorRunning).toBe(true);
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
test("includes monitorRunning:false in JSON output when monitor is not running", async () => {
|
|
2019
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2020
|
+
saveSessionsToDb([session]);
|
|
2021
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
|
|
2022
|
+
running: false,
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
2026
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
2027
|
+
expect(parsed.monitorRunning).toBe(false);
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
test("text output shows monitor status when coordinator is running", async () => {
|
|
2031
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2032
|
+
saveSessionsToDb([session]);
|
|
2033
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
|
|
2034
|
+
running: true,
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
2038
|
+
expect(output).toContain("Monitor: running");
|
|
2039
|
+
});
|
|
2040
|
+
|
|
2041
|
+
test("text output shows 'not running' when monitor is not running", async () => {
|
|
2042
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2043
|
+
saveSessionsToDb([session]);
|
|
2044
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true }, undefined, {
|
|
2045
|
+
running: false,
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
const output = await captureStdout(() => coordinatorCommand(["status"], deps));
|
|
2049
|
+
expect(output).toContain("Monitor: not running");
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
test("includes monitorRunning in JSON output when coordinator is not running", async () => {
|
|
2053
|
+
const { deps } = makeDeps({}, undefined, { running: true });
|
|
2054
|
+
|
|
2055
|
+
const output = await captureStdout(() => coordinatorCommand(["status", "--json"], deps));
|
|
2056
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
2057
|
+
expect(parsed.running).toBe(false);
|
|
2058
|
+
expect(parsed.monitorRunning).toBe(true);
|
|
2059
|
+
});
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
describe("COORDINATOR_HELP", () => {
|
|
2063
|
+
test("start help text includes --monitor flag", async () => {
|
|
2064
|
+
const cmd = createCoordinatorCommand({});
|
|
2065
|
+
for (const sub of cmd.commands) {
|
|
2066
|
+
sub.exitOverride();
|
|
2067
|
+
}
|
|
2068
|
+
const output = await captureStdout(async () => {
|
|
2069
|
+
await cmd.parseAsync(["start", "--help"], { from: "user" }).catch(() => {});
|
|
2070
|
+
});
|
|
2071
|
+
expect(output).toContain("--monitor");
|
|
2072
|
+
expect(output).toContain("monitor");
|
|
2073
|
+
});
|
|
2074
|
+
});
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
describe("SessionStore round-trip", () => {
|
|
2078
|
+
test("returns empty array when no sessions exist", () => {
|
|
2079
|
+
const sessions = loadSessionsFromDb();
|
|
2080
|
+
expect(sessions).toEqual([]);
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
test("save then load round-trips correctly", () => {
|
|
2084
|
+
const original = [makeCoordinatorSession()];
|
|
2085
|
+
saveSessionsToDb(original);
|
|
2086
|
+
const loaded = loadSessionsFromDb();
|
|
2087
|
+
|
|
2088
|
+
expect(loaded).toHaveLength(1);
|
|
2089
|
+
expect(loaded[0]?.agentName).toBe("coordinator");
|
|
2090
|
+
expect(loaded[0]?.capability).toBe("coordinator");
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
test("sessions.db is created after save", () => {
|
|
2094
|
+
saveSessionsToDb([makeCoordinatorSession()]);
|
|
2095
|
+
const dbPath = join(agentplateDir, "sessions.db");
|
|
2096
|
+
const exists = Bun.file(dbPath).size > 0;
|
|
2097
|
+
expect(exists).toBe(true);
|
|
2098
|
+
});
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
// --- Helpers for send/output tests ---
|
|
2102
|
+
|
|
2103
|
+
/** Read all messages from the mail store at mail.db for assertions. */
|
|
2104
|
+
function loadMailMessages() {
|
|
2105
|
+
const mailDbPath = join(agentplateDir, "mail.db");
|
|
2106
|
+
const mailStore = createMailStore(mailDbPath);
|
|
2107
|
+
try {
|
|
2108
|
+
return mailStore.getAll();
|
|
2109
|
+
} finally {
|
|
2110
|
+
mailStore.close();
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
describe("sendCoordinator", () => {
|
|
2115
|
+
test("send succeeds with running coordinator — mail is in DB", async () => {
|
|
2116
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2117
|
+
saveSessionsToDb([session]);
|
|
2118
|
+
|
|
2119
|
+
let nudgeCalled = false;
|
|
2120
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
2121
|
+
deps._nudge = async () => {
|
|
2122
|
+
nudgeCalled = true;
|
|
2123
|
+
return { delivered: true };
|
|
2124
|
+
};
|
|
2125
|
+
|
|
2126
|
+
await captureStdout(() => coordinatorCommand(["send", "--body", "hello world"], deps));
|
|
2127
|
+
|
|
2128
|
+
const messages = loadMailMessages();
|
|
2129
|
+
expect(messages).toHaveLength(1);
|
|
2130
|
+
expect(messages[0]?.from).toBe("operator");
|
|
2131
|
+
expect(messages[0]?.to).toBe("coordinator");
|
|
2132
|
+
expect(messages[0]?.body).toBe("hello world");
|
|
2133
|
+
expect(messages[0]?.type).toBe("dispatch");
|
|
2134
|
+
expect(nudgeCalled).toBe(true);
|
|
2135
|
+
});
|
|
2136
|
+
|
|
2137
|
+
test("send fails when no coordinator running", async () => {
|
|
2138
|
+
const { deps } = makeDeps();
|
|
2139
|
+
|
|
2140
|
+
await expect(coordinatorCommand(["send", "--body", "hello"], deps)).rejects.toThrow(AgentError);
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
test("send fails when coordinator tmux is dead — state updated to zombie", async () => {
|
|
2144
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2145
|
+
saveSessionsToDb([session]);
|
|
2146
|
+
|
|
2147
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
|
|
2148
|
+
|
|
2149
|
+
await expect(coordinatorCommand(["send", "--body", "hello"], deps)).rejects.toThrow(AgentError);
|
|
2150
|
+
|
|
2151
|
+
const sessions = loadSessionsFromDb();
|
|
2152
|
+
expect(sessions[0]?.state).toBe("zombie");
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
test("send --json outputs JSON with id and nudged fields", async () => {
|
|
2156
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2157
|
+
saveSessionsToDb([session]);
|
|
2158
|
+
|
|
2159
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
2160
|
+
deps._nudge = async () => ({ delivered: true });
|
|
2161
|
+
|
|
2162
|
+
const output = await captureStdout(() =>
|
|
2163
|
+
coordinatorCommand(["send", "--body", "hello", "--json"], deps),
|
|
2164
|
+
);
|
|
2165
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
2166
|
+
expect(typeof parsed.id).toBe("string");
|
|
2167
|
+
expect(parsed.nudged).toBe(true);
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
test("send with custom --subject uses subject in mail", async () => {
|
|
2171
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2172
|
+
saveSessionsToDb([session]);
|
|
2173
|
+
|
|
2174
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
2175
|
+
deps._nudge = async () => ({ delivered: false });
|
|
2176
|
+
|
|
2177
|
+
await captureStdout(() =>
|
|
2178
|
+
coordinatorCommand(
|
|
2179
|
+
["send", "--body", "build feature X", "--subject", "Deploy feature X"],
|
|
2180
|
+
deps,
|
|
2181
|
+
),
|
|
2182
|
+
);
|
|
2183
|
+
|
|
2184
|
+
const messages = loadMailMessages();
|
|
2185
|
+
expect(messages[0]?.subject).toBe("Deploy feature X");
|
|
2186
|
+
});
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
describe("outputCoordinator", () => {
|
|
2190
|
+
test("output shows pane content", async () => {
|
|
2191
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2192
|
+
saveSessionsToDb([session]);
|
|
2193
|
+
|
|
2194
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
2195
|
+
deps._capturePaneContent = async () => "Hello from coordinator pane\n";
|
|
2196
|
+
|
|
2197
|
+
const output = await captureStdout(() => coordinatorCommand(["output"], deps));
|
|
2198
|
+
expect(output).toContain("Hello from coordinator pane");
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
test("output fails when no coordinator running", async () => {
|
|
2202
|
+
const { deps } = makeDeps();
|
|
2203
|
+
|
|
2204
|
+
await expect(coordinatorCommand(["output"], deps)).rejects.toThrow(AgentError);
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
test("output fails when coordinator tmux is dead — state updated to zombie", async () => {
|
|
2208
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2209
|
+
saveSessionsToDb([session]);
|
|
2210
|
+
|
|
2211
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
|
|
2212
|
+
|
|
2213
|
+
await expect(coordinatorCommand(["output"], deps)).rejects.toThrow(AgentError);
|
|
2214
|
+
|
|
2215
|
+
const sessions = loadSessionsFromDb();
|
|
2216
|
+
expect(sessions[0]?.state).toBe("zombie");
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
test("output --json wraps content in JSON", async () => {
|
|
2220
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2221
|
+
saveSessionsToDb([session]);
|
|
2222
|
+
|
|
2223
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
2224
|
+
deps._capturePaneContent = async () => "some output";
|
|
2225
|
+
|
|
2226
|
+
const output = await captureStdout(() => coordinatorCommand(["output", "--json"], deps));
|
|
2227
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
2228
|
+
expect(parsed.content).toBe("some output");
|
|
2229
|
+
expect(typeof parsed.lines).toBe("number");
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
test("output --lines passes lines parameter to capturePaneContent", async () => {
|
|
2233
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2234
|
+
saveSessionsToDb([session]);
|
|
2235
|
+
|
|
2236
|
+
let capturedLines: number | undefined;
|
|
2237
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
2238
|
+
deps._capturePaneContent = async (_name: string, lines?: number) => {
|
|
2239
|
+
capturedLines = lines;
|
|
2240
|
+
return "output";
|
|
2241
|
+
};
|
|
2242
|
+
|
|
2243
|
+
await captureStdout(() => coordinatorCommand(["output", "--lines", "100"], deps));
|
|
2244
|
+
expect(capturedLines).toBe(100);
|
|
2245
|
+
});
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
describe("askCoordinator", () => {
|
|
2249
|
+
test("sends mail and returns reply body on stdout", async () => {
|
|
2250
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2251
|
+
saveSessionsToDb([session]);
|
|
2252
|
+
|
|
2253
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
2254
|
+
deps._nudge = async () => ({ delivered: true });
|
|
2255
|
+
deps._pollIntervalMs = 50; // Fast polling for test
|
|
2256
|
+
|
|
2257
|
+
const mailDbPath = join(agentplateDir, "mail.db");
|
|
2258
|
+
const outputChunks: string[] = [];
|
|
2259
|
+
const originalWrite = process.stdout.write;
|
|
2260
|
+
process.stdout.write = ((chunk: string) => {
|
|
2261
|
+
outputChunks.push(chunk);
|
|
2262
|
+
return true;
|
|
2263
|
+
}) as typeof process.stdout.write;
|
|
2264
|
+
|
|
2265
|
+
try {
|
|
2266
|
+
// Start ask without awaiting — lets us insert the reply concurrently
|
|
2267
|
+
const askPromise = askCoordinator(
|
|
2268
|
+
"what is the status",
|
|
2269
|
+
{ subject: "status check", timeout: 10, json: false },
|
|
2270
|
+
deps,
|
|
2271
|
+
);
|
|
2272
|
+
|
|
2273
|
+
// Wait for the ask to complete setup and send mail, then insert a reply
|
|
2274
|
+
await Bun.sleep(300);
|
|
2275
|
+
const replyStore = createMailStore(mailDbPath);
|
|
2276
|
+
try {
|
|
2277
|
+
const messages = replyStore.getAll({ from: "operator", to: "coordinator" });
|
|
2278
|
+
const sent = messages[0];
|
|
2279
|
+
if (sent) {
|
|
2280
|
+
replyStore.insert({
|
|
2281
|
+
id: "",
|
|
2282
|
+
from: "coordinator",
|
|
2283
|
+
to: "operator",
|
|
2284
|
+
subject: `Re: ${sent.subject}`,
|
|
2285
|
+
body: "Here is your answer",
|
|
2286
|
+
type: "status",
|
|
2287
|
+
priority: "normal",
|
|
2288
|
+
threadId: sent.id,
|
|
2289
|
+
payload: JSON.stringify({
|
|
2290
|
+
correlationId: JSON.parse(sent.payload ?? "{}").correlationId,
|
|
2291
|
+
}),
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
} finally {
|
|
2295
|
+
replyStore.close();
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
await askPromise;
|
|
2299
|
+
} finally {
|
|
2300
|
+
process.stdout.write = originalWrite;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
expect(outputChunks.join("")).toBe("Here is your answer\n");
|
|
2304
|
+
});
|
|
2305
|
+
|
|
2306
|
+
test("times out when no reply arrives", async () => {
|
|
2307
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2308
|
+
saveSessionsToDb([session]);
|
|
2309
|
+
|
|
2310
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
2311
|
+
deps._nudge = async () => ({ delivered: false });
|
|
2312
|
+
deps._pollIntervalMs = 50; // Fast polling so the 1s timeout exhausts quickly
|
|
2313
|
+
|
|
2314
|
+
let caughtError: unknown;
|
|
2315
|
+
try {
|
|
2316
|
+
await askCoordinator(
|
|
2317
|
+
"will you answer?",
|
|
2318
|
+
{ subject: "timeout test", timeout: 1, json: false },
|
|
2319
|
+
deps,
|
|
2320
|
+
);
|
|
2321
|
+
} catch (err) {
|
|
2322
|
+
caughtError = err;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
expect(caughtError).toBeInstanceOf(AgentError);
|
|
2326
|
+
const ae = caughtError as AgentError;
|
|
2327
|
+
expect(ae.message).toContain("Timed out");
|
|
2328
|
+
});
|
|
2329
|
+
|
|
2330
|
+
test("throws when coordinator is not running", async () => {
|
|
2331
|
+
// No session in DB
|
|
2332
|
+
const { deps } = makeDeps();
|
|
2333
|
+
|
|
2334
|
+
let caughtError: unknown;
|
|
2335
|
+
try {
|
|
2336
|
+
await askCoordinator("hello", { subject: "test", timeout: 5, json: false }, deps);
|
|
2337
|
+
} catch (err) {
|
|
2338
|
+
caughtError = err;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
expect(caughtError).toBeInstanceOf(AgentError);
|
|
2342
|
+
const ae = caughtError as AgentError;
|
|
2343
|
+
expect(ae.message).toContain("No active coordinator");
|
|
2344
|
+
});
|
|
2345
|
+
|
|
2346
|
+
test("throws when coordinator tmux session is dead", async () => {
|
|
2347
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2348
|
+
saveSessionsToDb([session]);
|
|
2349
|
+
|
|
2350
|
+
// Tmux reports session as dead
|
|
2351
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": false });
|
|
2352
|
+
|
|
2353
|
+
let caughtError: unknown;
|
|
2354
|
+
try {
|
|
2355
|
+
await askCoordinator("hello", { subject: "test", timeout: 5, json: false }, deps);
|
|
2356
|
+
} catch (err) {
|
|
2357
|
+
caughtError = err;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
expect(caughtError).toBeInstanceOf(AgentError);
|
|
2361
|
+
const ae = caughtError as AgentError;
|
|
2362
|
+
expect(ae.message).toContain("not alive");
|
|
2363
|
+
|
|
2364
|
+
// Session state should be updated to zombie
|
|
2365
|
+
const sessions = loadSessionsFromDb();
|
|
2366
|
+
expect(sessions[0]?.state).toBe("zombie");
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2369
|
+
test("JSON output includes correlationId and reply details", async () => {
|
|
2370
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2371
|
+
saveSessionsToDb([session]);
|
|
2372
|
+
|
|
2373
|
+
const { deps } = makeDeps({ "agentplate-test-project-coordinator": true });
|
|
2374
|
+
deps._nudge = async () => ({ delivered: true });
|
|
2375
|
+
deps._pollIntervalMs = 50;
|
|
2376
|
+
|
|
2377
|
+
const mailDbPath = join(agentplateDir, "mail.db");
|
|
2378
|
+
let output = "";
|
|
2379
|
+
|
|
2380
|
+
const askPromise = captureStdout(async () => {
|
|
2381
|
+
const innerAskPromise = askCoordinator(
|
|
2382
|
+
"report status",
|
|
2383
|
+
{ subject: "status", timeout: 10, json: true },
|
|
2384
|
+
deps,
|
|
2385
|
+
);
|
|
2386
|
+
|
|
2387
|
+
// Insert reply while ask is polling
|
|
2388
|
+
await Bun.sleep(300);
|
|
2389
|
+
const replyStore = createMailStore(mailDbPath);
|
|
2390
|
+
try {
|
|
2391
|
+
const messages = replyStore.getAll({ from: "operator", to: "coordinator" });
|
|
2392
|
+
const sent = messages[0];
|
|
2393
|
+
if (sent) {
|
|
2394
|
+
replyStore.insert({
|
|
2395
|
+
id: "",
|
|
2396
|
+
from: "coordinator",
|
|
2397
|
+
to: "operator",
|
|
2398
|
+
subject: `Re: ${sent.subject}`,
|
|
2399
|
+
body: "Status: all good",
|
|
2400
|
+
type: "status",
|
|
2401
|
+
priority: "normal",
|
|
2402
|
+
threadId: sent.id,
|
|
2403
|
+
payload: JSON.stringify({
|
|
2404
|
+
correlationId: JSON.parse(sent.payload ?? "{}").correlationId,
|
|
2405
|
+
}),
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
} finally {
|
|
2409
|
+
replyStore.close();
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
await innerAskPromise;
|
|
2413
|
+
});
|
|
2414
|
+
|
|
2415
|
+
output = await askPromise;
|
|
2416
|
+
|
|
2417
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
2418
|
+
expect(parsed.success).toBe(true);
|
|
2419
|
+
expect(parsed.command).toBe("coordinator ask");
|
|
2420
|
+
expect(typeof parsed.correlationId).toBe("string");
|
|
2421
|
+
expect(typeof parsed.sentId).toBe("string");
|
|
2422
|
+
expect(typeof parsed.replyId).toBe("string");
|
|
2423
|
+
expect(parsed.body).toBe("Status: all good");
|
|
2424
|
+
});
|
|
2425
|
+
|
|
2426
|
+
test("command registration — createCoordinatorCommand has ask subcommand", () => {
|
|
2427
|
+
const cmd = createCoordinatorCommand({});
|
|
2428
|
+
const subcommandNames = cmd.commands.map((c) => c.name());
|
|
2429
|
+
expect(subcommandNames).toContain("ask");
|
|
2430
|
+
});
|
|
2431
|
+
});
|
|
2432
|
+
|
|
2433
|
+
// ─── checkComplete ─────────────────────────────────────────────────────────
|
|
2434
|
+
|
|
2435
|
+
describe("checkComplete", () => {
|
|
2436
|
+
test("all triggers disabled → complete: false", async () => {
|
|
2437
|
+
// Default config has no coordinator section → all triggers default to false
|
|
2438
|
+
const result = await checkComplete({ json: false });
|
|
2439
|
+
expect(result.complete).toBe(false);
|
|
2440
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(false);
|
|
2441
|
+
expect(result.triggers.taskTrackerEmpty.enabled).toBe(false);
|
|
2442
|
+
expect(result.triggers.onShutdownSignal.enabled).toBe(false);
|
|
2443
|
+
});
|
|
2444
|
+
|
|
2445
|
+
test("allAgentsDone met when all non-coordinator agents completed", async () => {
|
|
2446
|
+
// Enable allAgentsDone in config
|
|
2447
|
+
await Bun.write(
|
|
2448
|
+
join(agentplateDir, "config.yaml"),
|
|
2449
|
+
[
|
|
2450
|
+
"project:",
|
|
2451
|
+
" name: test-project",
|
|
2452
|
+
` root: ${tempDir}`,
|
|
2453
|
+
" canonicalBranch: main",
|
|
2454
|
+
"coordinator:",
|
|
2455
|
+
" exitTriggers:",
|
|
2456
|
+
" allAgentsDone: true",
|
|
2457
|
+
" taskTrackerEmpty: false",
|
|
2458
|
+
" onShutdownSignal: false",
|
|
2459
|
+
].join("\n"),
|
|
2460
|
+
);
|
|
2461
|
+
|
|
2462
|
+
// Write current-run.txt
|
|
2463
|
+
const runId = `run-${Date.now()}`;
|
|
2464
|
+
await Bun.write(join(agentplateDir, "current-run.txt"), runId);
|
|
2465
|
+
|
|
2466
|
+
// Create sessions.db with two completed agents
|
|
2467
|
+
const store = createSessionStore(join(agentplateDir, "sessions.db"));
|
|
2468
|
+
try {
|
|
2469
|
+
const base: AgentSession = {
|
|
2470
|
+
id: "s1",
|
|
2471
|
+
agentName: "builder-1",
|
|
2472
|
+
capability: "builder",
|
|
2473
|
+
worktreePath: tempDir,
|
|
2474
|
+
branchName: "feat/x",
|
|
2475
|
+
taskId: "t1",
|
|
2476
|
+
tmuxSession: "tmux-1",
|
|
2477
|
+
state: "completed",
|
|
2478
|
+
pid: null,
|
|
2479
|
+
parentAgent: "coordinator",
|
|
2480
|
+
depth: 1,
|
|
2481
|
+
runId,
|
|
2482
|
+
startedAt: new Date().toISOString(),
|
|
2483
|
+
lastActivity: new Date().toISOString(),
|
|
2484
|
+
escalationLevel: 0,
|
|
2485
|
+
stalledSince: null,
|
|
2486
|
+
transcriptPath: null,
|
|
2487
|
+
};
|
|
2488
|
+
store.upsert(base);
|
|
2489
|
+
store.upsert({ ...base, id: "s2", agentName: "builder-2" });
|
|
2490
|
+
} finally {
|
|
2491
|
+
store.close();
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
const result = await checkComplete({ json: false });
|
|
2495
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2496
|
+
expect(result.triggers.allAgentsDone.met).toBe(true);
|
|
2497
|
+
expect(result.complete).toBe(true);
|
|
2498
|
+
});
|
|
2499
|
+
|
|
2500
|
+
test("allAgentsDone not met when agents still working", async () => {
|
|
2501
|
+
await Bun.write(
|
|
2502
|
+
join(agentplateDir, "config.yaml"),
|
|
2503
|
+
[
|
|
2504
|
+
"project:",
|
|
2505
|
+
" name: test-project",
|
|
2506
|
+
` root: ${tempDir}`,
|
|
2507
|
+
" canonicalBranch: main",
|
|
2508
|
+
"coordinator:",
|
|
2509
|
+
" exitTriggers:",
|
|
2510
|
+
" allAgentsDone: true",
|
|
2511
|
+
" taskTrackerEmpty: false",
|
|
2512
|
+
" onShutdownSignal: false",
|
|
2513
|
+
].join("\n"),
|
|
2514
|
+
);
|
|
2515
|
+
|
|
2516
|
+
const runId = `run-${Date.now()}`;
|
|
2517
|
+
await Bun.write(join(agentplateDir, "current-run.txt"), runId);
|
|
2518
|
+
|
|
2519
|
+
const store = createSessionStore(join(agentplateDir, "sessions.db"));
|
|
2520
|
+
try {
|
|
2521
|
+
const session: AgentSession = {
|
|
2522
|
+
id: "s1",
|
|
2523
|
+
agentName: "builder-1",
|
|
2524
|
+
capability: "builder",
|
|
2525
|
+
worktreePath: tempDir,
|
|
2526
|
+
branchName: "feat/x",
|
|
2527
|
+
taskId: "t1",
|
|
2528
|
+
tmuxSession: "tmux-1",
|
|
2529
|
+
state: "working",
|
|
2530
|
+
pid: null,
|
|
2531
|
+
parentAgent: "coordinator",
|
|
2532
|
+
depth: 1,
|
|
2533
|
+
runId,
|
|
2534
|
+
startedAt: new Date().toISOString(),
|
|
2535
|
+
lastActivity: new Date().toISOString(),
|
|
2536
|
+
escalationLevel: 0,
|
|
2537
|
+
stalledSince: null,
|
|
2538
|
+
transcriptPath: null,
|
|
2539
|
+
};
|
|
2540
|
+
store.upsert(session);
|
|
2541
|
+
} finally {
|
|
2542
|
+
store.close();
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
const result = await checkComplete({ json: false });
|
|
2546
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2547
|
+
expect(result.triggers.allAgentsDone.met).toBe(false);
|
|
2548
|
+
expect(result.complete).toBe(false);
|
|
2549
|
+
});
|
|
2550
|
+
|
|
2551
|
+
test("allAgentsDone filters out coordinator session", async () => {
|
|
2552
|
+
await Bun.write(
|
|
2553
|
+
join(agentplateDir, "config.yaml"),
|
|
2554
|
+
[
|
|
2555
|
+
"project:",
|
|
2556
|
+
" name: test-project",
|
|
2557
|
+
` root: ${tempDir}`,
|
|
2558
|
+
" canonicalBranch: main",
|
|
2559
|
+
"coordinator:",
|
|
2560
|
+
" exitTriggers:",
|
|
2561
|
+
" allAgentsDone: true",
|
|
2562
|
+
" taskTrackerEmpty: false",
|
|
2563
|
+
" onShutdownSignal: false",
|
|
2564
|
+
].join("\n"),
|
|
2565
|
+
);
|
|
2566
|
+
|
|
2567
|
+
const runId = `run-${Date.now()}`;
|
|
2568
|
+
await Bun.write(join(agentplateDir, "current-run.txt"), runId);
|
|
2569
|
+
|
|
2570
|
+
const store = createSessionStore(join(agentplateDir, "sessions.db"));
|
|
2571
|
+
try {
|
|
2572
|
+
// coordinator session (should be excluded)
|
|
2573
|
+
store.upsert({
|
|
2574
|
+
id: "coord",
|
|
2575
|
+
agentName: "coordinator",
|
|
2576
|
+
capability: "coordinator",
|
|
2577
|
+
worktreePath: tempDir,
|
|
2578
|
+
branchName: "main",
|
|
2579
|
+
taskId: "",
|
|
2580
|
+
tmuxSession: "tmux-coord",
|
|
2581
|
+
state: "working",
|
|
2582
|
+
pid: null,
|
|
2583
|
+
parentAgent: null,
|
|
2584
|
+
depth: 0,
|
|
2585
|
+
runId,
|
|
2586
|
+
startedAt: new Date().toISOString(),
|
|
2587
|
+
lastActivity: new Date().toISOString(),
|
|
2588
|
+
escalationLevel: 0,
|
|
2589
|
+
stalledSince: null,
|
|
2590
|
+
transcriptPath: null,
|
|
2591
|
+
});
|
|
2592
|
+
// worker session that is completed
|
|
2593
|
+
store.upsert({
|
|
2594
|
+
id: "worker",
|
|
2595
|
+
agentName: "builder-1",
|
|
2596
|
+
capability: "builder",
|
|
2597
|
+
worktreePath: tempDir,
|
|
2598
|
+
branchName: "feat/x",
|
|
2599
|
+
taskId: "t1",
|
|
2600
|
+
tmuxSession: "tmux-w",
|
|
2601
|
+
state: "completed",
|
|
2602
|
+
pid: null,
|
|
2603
|
+
parentAgent: "coordinator",
|
|
2604
|
+
depth: 1,
|
|
2605
|
+
runId,
|
|
2606
|
+
startedAt: new Date().toISOString(),
|
|
2607
|
+
lastActivity: new Date().toISOString(),
|
|
2608
|
+
escalationLevel: 0,
|
|
2609
|
+
stalledSince: null,
|
|
2610
|
+
transcriptPath: null,
|
|
2611
|
+
});
|
|
2612
|
+
} finally {
|
|
2613
|
+
store.close();
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
const result = await checkComplete({ json: false });
|
|
2617
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2618
|
+
// coordinator is filtered out; only the builder counts → all done
|
|
2619
|
+
expect(result.triggers.allAgentsDone.met).toBe(true);
|
|
2620
|
+
expect(result.complete).toBe(true);
|
|
2621
|
+
});
|
|
2622
|
+
|
|
2623
|
+
test("onShutdownSignal met when shutdown mail exists", async () => {
|
|
2624
|
+
await Bun.write(
|
|
2625
|
+
join(agentplateDir, "config.yaml"),
|
|
2626
|
+
[
|
|
2627
|
+
"project:",
|
|
2628
|
+
" name: test-project",
|
|
2629
|
+
` root: ${tempDir}`,
|
|
2630
|
+
" canonicalBranch: main",
|
|
2631
|
+
"coordinator:",
|
|
2632
|
+
" exitTriggers:",
|
|
2633
|
+
" allAgentsDone: false",
|
|
2634
|
+
" taskTrackerEmpty: false",
|
|
2635
|
+
" onShutdownSignal: true",
|
|
2636
|
+
].join("\n"),
|
|
2637
|
+
);
|
|
2638
|
+
|
|
2639
|
+
// Insert a shutdown message into mail.db
|
|
2640
|
+
const mailStore = createMailStore(join(agentplateDir, "mail.db"));
|
|
2641
|
+
try {
|
|
2642
|
+
mailStore.insert({
|
|
2643
|
+
id: "",
|
|
2644
|
+
from: "greenhouse",
|
|
2645
|
+
to: "coordinator",
|
|
2646
|
+
subject: "shutdown",
|
|
2647
|
+
body: "All work done, please shutdown",
|
|
2648
|
+
type: "status",
|
|
2649
|
+
priority: "normal",
|
|
2650
|
+
threadId: null,
|
|
2651
|
+
payload: null,
|
|
2652
|
+
});
|
|
2653
|
+
} finally {
|
|
2654
|
+
mailStore.close();
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
const result = await checkComplete({ json: false });
|
|
2658
|
+
expect(result.triggers.onShutdownSignal.enabled).toBe(true);
|
|
2659
|
+
expect(result.triggers.onShutdownSignal.met).toBe(true);
|
|
2660
|
+
expect(result.complete).toBe(true);
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
test("overall complete false when only one of two enabled triggers is met", async () => {
|
|
2664
|
+
// Enable allAgentsDone + onShutdownSignal; satisfy only onShutdownSignal
|
|
2665
|
+
await Bun.write(
|
|
2666
|
+
join(agentplateDir, "config.yaml"),
|
|
2667
|
+
[
|
|
2668
|
+
"project:",
|
|
2669
|
+
" name: test-project",
|
|
2670
|
+
` root: ${tempDir}`,
|
|
2671
|
+
" canonicalBranch: main",
|
|
2672
|
+
"coordinator:",
|
|
2673
|
+
" exitTriggers:",
|
|
2674
|
+
" allAgentsDone: true",
|
|
2675
|
+
" taskTrackerEmpty: false",
|
|
2676
|
+
" onShutdownSignal: true",
|
|
2677
|
+
].join("\n"),
|
|
2678
|
+
);
|
|
2679
|
+
|
|
2680
|
+
// Write current-run.txt but no sessions → allAgentsDone not met (empty run)
|
|
2681
|
+
const runId = `run-${Date.now()}`;
|
|
2682
|
+
await Bun.write(join(agentplateDir, "current-run.txt"), runId);
|
|
2683
|
+
// Sessions DB will be created empty — no agents → allAgentsDone.met = false (length === 0)
|
|
2684
|
+
|
|
2685
|
+
// Insert shutdown mail so onShutdownSignal is met
|
|
2686
|
+
const mailStore = createMailStore(join(agentplateDir, "mail.db"));
|
|
2687
|
+
try {
|
|
2688
|
+
mailStore.insert({
|
|
2689
|
+
id: "",
|
|
2690
|
+
from: "operator",
|
|
2691
|
+
to: "coordinator",
|
|
2692
|
+
subject: "shutdown now",
|
|
2693
|
+
body: "Please shutdown",
|
|
2694
|
+
type: "status",
|
|
2695
|
+
priority: "normal",
|
|
2696
|
+
threadId: null,
|
|
2697
|
+
payload: null,
|
|
2698
|
+
});
|
|
2699
|
+
} finally {
|
|
2700
|
+
mailStore.close();
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
const result = await checkComplete({ json: false });
|
|
2704
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2705
|
+
expect(result.triggers.allAgentsDone.met).toBe(false);
|
|
2706
|
+
expect(result.triggers.onShutdownSignal.enabled).toBe(true);
|
|
2707
|
+
expect(result.triggers.onShutdownSignal.met).toBe(true);
|
|
2708
|
+
// Both must be met → false
|
|
2709
|
+
expect(result.complete).toBe(false);
|
|
2710
|
+
});
|
|
2711
|
+
|
|
2712
|
+
test("allAgentsDone false when merge queue has pending branches", async () => {
|
|
2713
|
+
await Bun.write(
|
|
2714
|
+
join(agentplateDir, "config.yaml"),
|
|
2715
|
+
[
|
|
2716
|
+
"project:",
|
|
2717
|
+
" name: test-project",
|
|
2718
|
+
` root: ${tempDir}`,
|
|
2719
|
+
" canonicalBranch: main",
|
|
2720
|
+
"coordinator:",
|
|
2721
|
+
" exitTriggers:",
|
|
2722
|
+
" allAgentsDone: true",
|
|
2723
|
+
" taskTrackerEmpty: false",
|
|
2724
|
+
" onShutdownSignal: false",
|
|
2725
|
+
].join("\n"),
|
|
2726
|
+
);
|
|
2727
|
+
|
|
2728
|
+
const runId = `run-${Date.now()}`;
|
|
2729
|
+
await Bun.write(join(agentplateDir, "current-run.txt"), runId);
|
|
2730
|
+
|
|
2731
|
+
// All agent sessions completed
|
|
2732
|
+
const store = createSessionStore(join(agentplateDir, "sessions.db"));
|
|
2733
|
+
try {
|
|
2734
|
+
store.upsert({
|
|
2735
|
+
id: "s1",
|
|
2736
|
+
agentName: "lead-1",
|
|
2737
|
+
capability: "lead",
|
|
2738
|
+
worktreePath: tempDir,
|
|
2739
|
+
branchName: "agentplate/lead-1/task-1",
|
|
2740
|
+
taskId: "task-1",
|
|
2741
|
+
tmuxSession: "tmux-1",
|
|
2742
|
+
state: "completed",
|
|
2743
|
+
pid: null,
|
|
2744
|
+
parentAgent: "coordinator",
|
|
2745
|
+
depth: 1,
|
|
2746
|
+
runId,
|
|
2747
|
+
startedAt: new Date().toISOString(),
|
|
2748
|
+
lastActivity: new Date().toISOString(),
|
|
2749
|
+
escalationLevel: 0,
|
|
2750
|
+
stalledSince: null,
|
|
2751
|
+
transcriptPath: null,
|
|
2752
|
+
});
|
|
2753
|
+
} finally {
|
|
2754
|
+
store.close();
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// Merge queue has a pending entry — lead branch not yet merged
|
|
2758
|
+
const { createMergeQueue } = await import("../merge/queue.ts");
|
|
2759
|
+
const queue = createMergeQueue(join(agentplateDir, "merge-queue.db"));
|
|
2760
|
+
try {
|
|
2761
|
+
queue.enqueue({
|
|
2762
|
+
branchName: "agentplate/lead-1/task-1",
|
|
2763
|
+
taskId: "task-1",
|
|
2764
|
+
agentName: "lead-1",
|
|
2765
|
+
filesModified: ["src/foo.ts"],
|
|
2766
|
+
});
|
|
2767
|
+
} finally {
|
|
2768
|
+
queue.close();
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
const result = await checkComplete({ json: false });
|
|
2772
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2773
|
+
expect(result.triggers.allAgentsDone.met).toBe(false);
|
|
2774
|
+
expect(result.triggers.allAgentsDone.detail).toInclude("pending merge");
|
|
2775
|
+
expect(result.triggers.allAgentsDone.detail).toInclude("agentplate/lead-1/task-1");
|
|
2776
|
+
expect(result.complete).toBe(false);
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
test("allAgentsDone true when all agents completed and merge queue is empty", async () => {
|
|
2780
|
+
await Bun.write(
|
|
2781
|
+
join(agentplateDir, "config.yaml"),
|
|
2782
|
+
[
|
|
2783
|
+
"project:",
|
|
2784
|
+
" name: test-project",
|
|
2785
|
+
` root: ${tempDir}`,
|
|
2786
|
+
" canonicalBranch: main",
|
|
2787
|
+
"coordinator:",
|
|
2788
|
+
" exitTriggers:",
|
|
2789
|
+
" allAgentsDone: true",
|
|
2790
|
+
" taskTrackerEmpty: false",
|
|
2791
|
+
" onShutdownSignal: false",
|
|
2792
|
+
].join("\n"),
|
|
2793
|
+
);
|
|
2794
|
+
|
|
2795
|
+
const runId = `run-${Date.now()}`;
|
|
2796
|
+
await Bun.write(join(agentplateDir, "current-run.txt"), runId);
|
|
2797
|
+
|
|
2798
|
+
const store = createSessionStore(join(agentplateDir, "sessions.db"));
|
|
2799
|
+
try {
|
|
2800
|
+
store.upsert({
|
|
2801
|
+
id: "s1",
|
|
2802
|
+
agentName: "lead-1",
|
|
2803
|
+
capability: "lead",
|
|
2804
|
+
worktreePath: tempDir,
|
|
2805
|
+
branchName: "agentplate/lead-1/task-1",
|
|
2806
|
+
taskId: "task-1",
|
|
2807
|
+
tmuxSession: "tmux-1",
|
|
2808
|
+
state: "completed",
|
|
2809
|
+
pid: null,
|
|
2810
|
+
parentAgent: "coordinator",
|
|
2811
|
+
depth: 1,
|
|
2812
|
+
runId,
|
|
2813
|
+
startedAt: new Date().toISOString(),
|
|
2814
|
+
lastActivity: new Date().toISOString(),
|
|
2815
|
+
escalationLevel: 0,
|
|
2816
|
+
stalledSince: null,
|
|
2817
|
+
transcriptPath: null,
|
|
2818
|
+
});
|
|
2819
|
+
} finally {
|
|
2820
|
+
store.close();
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
// Merge queue exists but all entries are already merged (no pending)
|
|
2824
|
+
const { createMergeQueue } = await import("../merge/queue.ts");
|
|
2825
|
+
const queue = createMergeQueue(join(agentplateDir, "merge-queue.db"));
|
|
2826
|
+
try {
|
|
2827
|
+
const entry = queue.enqueue({
|
|
2828
|
+
branchName: "agentplate/lead-1/task-1",
|
|
2829
|
+
taskId: "task-1",
|
|
2830
|
+
agentName: "lead-1",
|
|
2831
|
+
filesModified: ["src/foo.ts"],
|
|
2832
|
+
});
|
|
2833
|
+
queue.updateStatus(entry.branchName, "merged", "clean-merge");
|
|
2834
|
+
} finally {
|
|
2835
|
+
queue.close();
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
const result = await checkComplete({ json: false });
|
|
2839
|
+
expect(result.triggers.allAgentsDone.enabled).toBe(true);
|
|
2840
|
+
expect(result.triggers.allAgentsDone.met).toBe(true);
|
|
2841
|
+
expect(result.complete).toBe(true);
|
|
2842
|
+
});
|
|
2843
|
+
|
|
2844
|
+
test("command registration — createCoordinatorCommand has check-complete subcommand", () => {
|
|
2845
|
+
const cmd = createCoordinatorCommand({});
|
|
2846
|
+
const subcommandNames = cmd.commands.map((c) => c.name());
|
|
2847
|
+
expect(subcommandNames).toContain("check-complete");
|
|
2848
|
+
});
|
|
2849
|
+
});
|
|
2850
|
+
|
|
2851
|
+
describe("startCoordinatorSession headless", () => {
|
|
2852
|
+
test("with headless: true, calls spawnHeadlessAgent and skips tmux", async () => {
|
|
2853
|
+
const { tmux, calls: tmuxCalls } = makeFakeTmux();
|
|
2854
|
+
const { watchdog } = makeFakeWatchdog();
|
|
2855
|
+
const { monitor } = makeFakeMonitor();
|
|
2856
|
+
|
|
2857
|
+
const spawnCalls: Array<{
|
|
2858
|
+
argv: string[];
|
|
2859
|
+
cwd: string;
|
|
2860
|
+
agentName?: string;
|
|
2861
|
+
}> = [];
|
|
2862
|
+
const writes: string[] = [];
|
|
2863
|
+
|
|
2864
|
+
const fakeSpawn = async (
|
|
2865
|
+
argv: string[],
|
|
2866
|
+
opts: { cwd: string; env: Record<string, string>; agentName?: string },
|
|
2867
|
+
): Promise<{
|
|
2868
|
+
pid: number;
|
|
2869
|
+
stdin: { write(data: string | Uint8Array): number | Promise<number> };
|
|
2870
|
+
stdout: ReadableStream<Uint8Array> | null;
|
|
2871
|
+
}> => {
|
|
2872
|
+
spawnCalls.push({ argv, cwd: opts.cwd, agentName: opts.agentName });
|
|
2873
|
+
return {
|
|
2874
|
+
pid: 55555,
|
|
2875
|
+
stdin: {
|
|
2876
|
+
write(data: string | Uint8Array): number {
|
|
2877
|
+
writes.push(typeof data === "string" ? data : new TextDecoder().decode(data));
|
|
2878
|
+
return 0;
|
|
2879
|
+
},
|
|
2880
|
+
},
|
|
2881
|
+
stdout: null,
|
|
2882
|
+
};
|
|
2883
|
+
};
|
|
2884
|
+
|
|
2885
|
+
const deps: CoordinatorDeps = {
|
|
2886
|
+
_tmux: tmux,
|
|
2887
|
+
_watchdog: watchdog,
|
|
2888
|
+
_monitor: monitor,
|
|
2889
|
+
_spawnHeadless: fakeSpawn,
|
|
2890
|
+
};
|
|
2891
|
+
|
|
2892
|
+
await captureStdout(async () => {
|
|
2893
|
+
await startCoordinatorSession(
|
|
2894
|
+
{
|
|
2895
|
+
json: true,
|
|
2896
|
+
attach: false,
|
|
2897
|
+
watchdog: false,
|
|
2898
|
+
monitor: false,
|
|
2899
|
+
headless: true,
|
|
2900
|
+
},
|
|
2901
|
+
deps,
|
|
2902
|
+
);
|
|
2903
|
+
});
|
|
2904
|
+
|
|
2905
|
+
// spawnHeadlessAgent was called exactly once with agentName: "coordinator"
|
|
2906
|
+
expect(spawnCalls.length).toBe(1);
|
|
2907
|
+
expect(spawnCalls[0]?.agentName).toBe("coordinator");
|
|
2908
|
+
expect(spawnCalls[0]?.cwd).toBe(tempDir);
|
|
2909
|
+
|
|
2910
|
+
// initial stdin prompt was written
|
|
2911
|
+
expect(writes.length).toBeGreaterThanOrEqual(1);
|
|
2912
|
+
|
|
2913
|
+
// tmux helpers were never called for the headless path
|
|
2914
|
+
expect(tmuxCalls.createSession.length).toBe(0);
|
|
2915
|
+
expect(tmuxCalls.sendKeys.length).toBe(0);
|
|
2916
|
+
expect(tmuxCalls.waitForTuiReady.length).toBe(0);
|
|
2917
|
+
expect(tmuxCalls.ensureTmuxAvailable).toBe(0);
|
|
2918
|
+
|
|
2919
|
+
// Session row records empty tmuxSession + the headless spawn pid
|
|
2920
|
+
const sessions = loadSessionsFromDb();
|
|
2921
|
+
expect(sessions.length).toBe(1);
|
|
2922
|
+
expect(sessions[0]?.agentName).toBe("coordinator");
|
|
2923
|
+
expect(sessions[0]?.tmuxSession).toBe("");
|
|
2924
|
+
expect(sessions[0]?.pid).toBe(55555);
|
|
2925
|
+
expect(sessions[0]?.state).toBe("booting");
|
|
2926
|
+
|
|
2927
|
+
// current-run.txt was written for downstream consumers
|
|
2928
|
+
const runFile = Bun.file(join(agentplateDir, "current-run.txt"));
|
|
2929
|
+
expect(await runFile.exists()).toBe(true);
|
|
2930
|
+
});
|
|
2931
|
+
|
|
2932
|
+
test("rejects when runtime has no buildDirectSpawn", async () => {
|
|
2933
|
+
// Override config to route the coordinator capability to a runtime that
|
|
2934
|
+
// lacks buildDirectSpawn (e.g. cursor). The headless path must reject.
|
|
2935
|
+
await Bun.write(
|
|
2936
|
+
join(agentplateDir, "config.yaml"),
|
|
2937
|
+
[
|
|
2938
|
+
"project:",
|
|
2939
|
+
" name: test-project",
|
|
2940
|
+
` root: ${tempDir}`,
|
|
2941
|
+
" canonicalBranch: main",
|
|
2942
|
+
"watchdog:",
|
|
2943
|
+
" tier2Enabled: true",
|
|
2944
|
+
"runtime:",
|
|
2945
|
+
" capabilities:",
|
|
2946
|
+
" coordinator: cursor",
|
|
2947
|
+
].join("\n"),
|
|
2948
|
+
);
|
|
2949
|
+
|
|
2950
|
+
const { tmux } = makeFakeTmux();
|
|
2951
|
+
const { watchdog } = makeFakeWatchdog();
|
|
2952
|
+
const { monitor } = makeFakeMonitor();
|
|
2953
|
+
const deps: CoordinatorDeps = {
|
|
2954
|
+
_tmux: tmux,
|
|
2955
|
+
_watchdog: watchdog,
|
|
2956
|
+
_monitor: monitor,
|
|
2957
|
+
_spawnHeadless: async () => {
|
|
2958
|
+
throw new Error("should not be called");
|
|
2959
|
+
},
|
|
2960
|
+
};
|
|
2961
|
+
|
|
2962
|
+
await expect(
|
|
2963
|
+
startCoordinatorSession(
|
|
2964
|
+
{
|
|
2965
|
+
json: true,
|
|
2966
|
+
attach: false,
|
|
2967
|
+
watchdog: false,
|
|
2968
|
+
monitor: false,
|
|
2969
|
+
headless: true,
|
|
2970
|
+
},
|
|
2971
|
+
deps,
|
|
2972
|
+
),
|
|
2973
|
+
).rejects.toThrow(ValidationError);
|
|
2974
|
+
});
|
|
2975
|
+
});
|