@dsdenes/devboard 0.1.560 → 0.1.562
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/AGENTS.md +59 -0
- package/README.md +2497 -26
- package/bin/devboard.mjs +352 -118
- package/client/App.svelte +523 -0
- package/client/FeedbackOverlay.svelte +1125 -0
- package/client/FlowMode.svelte +1015 -0
- package/client/app.css +3922 -0
- package/client/env.d.ts +23 -0
- package/client/lib/commands.ts +836 -0
- package/client/lib/components/ActivityBar.svelte +295 -0
- package/client/lib/components/AdrTable.svelte +241 -0
- package/client/lib/components/CommandPalette.svelte +198 -0
- package/client/lib/components/ConceptKind.svelte +66 -0
- package/client/lib/components/ConfirmDialog.svelte +62 -0
- package/client/lib/components/DataTable.svelte +243 -0
- package/client/lib/components/DetailSplit.svelte +68 -0
- package/client/lib/components/DiscoverOverview.svelte +898 -0
- package/client/lib/components/DiscoverWorkspace.svelte +437 -0
- package/client/lib/components/DraftsPage.svelte +186 -0
- package/client/lib/components/GoalWorkspace.svelte +1042 -0
- package/client/lib/components/IssuesAgentFocus.svelte +117 -0
- package/client/lib/components/IssuesAgentLog.svelte +50 -0
- package/client/lib/components/IssuesQuestion.svelte +95 -0
- package/client/lib/components/IssuesWorkspace.svelte +577 -0
- package/client/lib/components/MermaidDiagram.svelte +89 -0
- package/client/lib/components/MonacoEditor.svelte +190 -0
- package/client/lib/components/NotificationRegion.svelte +195 -0
- package/client/lib/components/PaneHandle.svelte +25 -0
- package/client/lib/components/ScoutIssues.svelte +215 -0
- package/client/lib/components/ScoutWorkspace.svelte +180 -0
- package/client/lib/components/Sidebar.svelte +622 -0
- package/client/lib/components/StatusBar.svelte +118 -0
- package/client/lib/components/Terminal.svelte +193 -0
- package/client/lib/components/WatchdogWorkspace.svelte +537 -0
- package/client/lib/components/ui/button.svelte +56 -0
- package/client/lib/components/ui/icon-button.svelte +56 -0
- package/client/lib/components/ui/input.svelte +19 -0
- package/client/lib/components/ui/page-header.svelte +120 -0
- package/client/lib/components/ui/stage-track.svelte +141 -0
- package/client/lib/components/watchdog-indicator.svelte +189 -0
- package/client/lib/connection.svelte.ts +210 -0
- package/client/lib/dependency-diagram.test.ts +90 -0
- package/client/lib/dependency-diagram.ts +75 -0
- package/client/lib/discover-format.test.ts +265 -0
- package/client/lib/discover-format.ts +394 -0
- package/client/lib/discover.svelte.ts +412 -0
- package/client/lib/draft-format.test.ts +48 -0
- package/client/lib/draft-format.ts +13 -0
- package/client/lib/flow-save.test.ts +148 -0
- package/client/lib/flow.svelte.ts +445 -0
- package/client/lib/goal-reconnect.test.ts +282 -0
- package/client/lib/goal.svelte.ts +652 -0
- package/client/lib/issues.svelte.ts +439 -0
- package/client/lib/layout.svelte.ts +234 -0
- package/client/lib/notification-issue.test.ts +53 -0
- package/client/lib/notification-issue.ts +27 -0
- package/client/lib/notifications.svelte.ts +62 -0
- package/client/lib/poll-status.test.ts +141 -0
- package/client/lib/poll-status.ts +88 -0
- package/client/lib/record-status.test.ts +19 -0
- package/client/lib/record-status.ts +25 -0
- package/client/lib/scout.svelte.ts +164 -0
- package/client/lib/skill-document.test.ts +77 -0
- package/client/lib/skill-document.ts +57 -0
- package/client/lib/status-tone.test.ts +40 -0
- package/client/lib/status-tone.ts +33 -0
- package/client/lib/types.ts +578 -0
- package/client/lib/utils.ts +6 -0
- package/client/lib/viewport.svelte.ts +20 -0
- package/client/lib/watchdog.svelte.ts +69 -0
- package/client/main.ts +7 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/abnfDiagram-VCTEODGH-CmBSMArZ.js +1 -0
- package/dist/assets/arc-BZgCSX2H.js +1 -0
- package/dist/assets/architecture-7GRP2DOG-CF9MHlfo.js +1 -0
- package/dist/assets/architectureDiagram-5GKGNRK7-DzWB6xiQ.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-I7D4REHJ-BtmGJ0hn.js +129 -0
- package/dist/assets/c4Diagram-7LVT6UL2-D5CHrrRX.js +38 -0
- package/dist/assets/channel-CgM4lcUW.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4HAMMTFA-DeIufjFl.js +62 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-75Z2AOVW-DbtzyKUg.js +2 -0
- package/dist/assets/chunk-DU6HZSFF-C0fdbx3K.js +127 -0
- package/dist/assets/chunk-F27PBJKO-HObL2IvF.js +1 -0
- package/dist/assets/chunk-FOHPRMQF-DD4dKubD.js +161 -0
- package/dist/assets/chunk-GMAD6QVW-fg8wvtPY.js +72 -0
- package/dist/assets/chunk-GVQU2GXP-C0TaHOrD.js +1 -0
- package/dist/assets/chunk-IMKFNOWR-BVoIdx20.js +231 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-L3NEJ4N5-B_kmqfk5.js +1 -0
- package/dist/assets/chunk-OSK3NFVY-DO0vhxHH.js +10 -0
- package/dist/assets/chunk-P2QGCYS3-CjljLvFj.js +1 -0
- package/dist/assets/chunk-POPQ4Y6H-BU0C7tw6.js +1 -0
- package/dist/assets/chunk-PWAF6VOD-Dn0OjBt0.js +1 -0
- package/dist/assets/chunk-SHT3W25Y-CMCZE2xx.js +168 -0
- package/dist/assets/chunk-SVP7TREG-BIRmRy_W.js +88 -0
- package/dist/assets/chunk-TICWLB2K-C1bc2CDE.js +206 -0
- package/dist/assets/chunk-XXDRQBXY-Cc38QUmR.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/classDiagram-ZZMXUADV-BDh5w6Nc.js +1 -0
- package/dist/assets/classDiagram-v2-VYDZK3BY-BDh5w6Nc.js +1 -0
- package/dist/assets/cose-bilkent-JH36ORCC-B7pU7qhM.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-76dswSqS.js +1 -0
- package/dist/assets/cynefinDiagram-5FMLGOSQ-2kS4q01d.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-D7JM2Yhv.js +1 -0
- package/dist/assets/dagre-GXQ25YYZ-B5Y21TQM.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-S7CK7UJ4-CY6td7Et.js +30 -0
- package/dist/assets/diagram-UQ7AKVKN-DmDqnGZf.js +41 -0
- package/dist/assets/diagram-VSXAHHWV-KGLWOOcE.js +3 -0
- package/dist/assets/diagram-VX7I27RA-D5syUraJ.js +24 -0
- package/dist/assets/diagram-Z3DM3KII-Dtrbs1Om.js +24 -0
- package/dist/assets/dist-Dce-2YbA.js +1 -0
- package/dist/assets/ebnfDiagram-PWID7BFC-CQpJLtU3.js +1 -0
- package/dist/assets/editor.worker-BmdWdrHZ.js +33 -0
- package/dist/assets/erDiagram-RLTQ6QDP-cMTzKLu_.js +99 -0
- package/dist/assets/eventmodeling-NTZA5JFV-CMzLdNaL.js +1 -0
- package/dist/assets/flowDiagram-HODETNUW-BMUajB-G.js +1 -0
- package/dist/assets/ganttDiagram-EL5Y4UJY-DseBS5Fs.js +292 -0
- package/dist/assets/gitGraph-4MIJSDKK-CUQZRpxJ.js +1 -0
- package/dist/assets/gitGraphDiagram-WWUBYQGX-CfMfsJ3b.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-ByQ-XX38.css +1 -0
- package/dist/assets/index-CIf5TpfY.js +214 -0
- package/dist/assets/info-A6RAGUB7-D3nAKZ8M.js +1 -0
- package/dist/assets/infoDiagram-27XIBGKW-zu1O0WOy.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-5VMMS53U-DKC7BO9p.js +70 -0
- package/dist/assets/journeyDiagram-3NMN7TZE-Ld5mXse7.js +139 -0
- package/dist/assets/kanban-definition-UXKFOSKX-CH7_QlHL.js +89 -0
- package/dist/assets/katex-DJhrJSbw.js +257 -0
- package/dist/assets/line-N9XiCREw.js +1 -0
- package/dist/assets/linear-C9hnqmJv.js +1 -0
- package/dist/assets/mermaid-parser.core-DgPwV5Bh.js +7 -0
- package/dist/assets/mermaid.core-BKqynXLR.js +44 -0
- package/dist/assets/mindmap-definition-YA3MSWOX-DkiL-wiW.js +96 -0
- package/dist/assets/monaco-DjpsE8to.css +1 -0
- package/dist/assets/monaco-DqzJaNIe.js +908 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-AYTQ26CC-DuMTvpEJ.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-XKGWAZYB-DvMl_xSe.js +1 -0
- package/dist/assets/pie-WAS4IAKB-Da0xPaD8.js +1 -0
- package/dist/assets/pieDiagram-E7YTZNPT-amANNRJY.js +39 -0
- package/dist/assets/quadrantDiagram-AXDQQJYC-BrgvDMnk.js +7 -0
- package/dist/assets/radar-RG4KPBEZ-Dz3Ih5-N.js +1 -0
- package/dist/assets/railroad-74A4TZTK-CKMW-IV2.js +1 -0
- package/dist/assets/railroad-abnf-HS5TGJTU-ChqmSWSF.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-V3R4fFdb.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-B-RuhUQW.js +1 -0
- package/dist/assets/railroadDiagram-O6MQD6OU-FjG-6OLu.js +1 -0
- package/dist/assets/requirementDiagram-BXWQKSXE-Cnj8RfE4.js +84 -0
- package/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-P5KCCOFB-DMu1fnEh.js +40 -0
- package/dist/assets/sequenceDiagram-WJ2MYXX4-CtCtaRpW.js +162 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-CGp3bfio.js +1 -0
- package/dist/assets/stateDiagram-D77RDMKH-xcJbLNhz.js +1 -0
- package/dist/assets/stateDiagram-v2-MP3YSRHH-DYHrQmJJ.js +1 -0
- package/dist/assets/swimlanes-42K2YHIH-BwLln_ga.js +1 -0
- package/dist/assets/swimlanesDiagram-VR7AAH4N-BcDfUQ0A.js +8 -0
- package/dist/assets/timeline-definition-24CTP7MA-ucNGsUUz.js +120 -0
- package/dist/assets/treeView-Q6P3EWNA-3uOomRue.js +1 -0
- package/dist/assets/treemap-WGGIJYW6-iGK7vwID.js +1 -0
- package/dist/assets/vennDiagram-4TSXK5OY-B9LgGe16.js +34 -0
- package/dist/assets/wardley-WFR3VGLG-FupMOf6k.js +1 -0
- package/dist/assets/wardleyDiagram-VM6X3IG4-CGZdEXM2.js +78 -0
- package/dist/assets/xterm-BrP-ENHg.css +1 -0
- package/dist/assets/xterm-C8zLTNGk.js +16 -0
- package/dist/assets/xychartDiagram-S5SC5T6Z-DafZrSBJ.js +7 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +6 -0
- package/dist/index.html +34 -0
- package/extensions/implement-goal-issue.ts +368 -0
- package/extensions/implement-next-epic.ts +835 -0
- package/index.html +28 -0
- package/install.sh +111 -0
- package/issues/control.ts +91 -0
- package/issues/guards.ts +12 -0
- package/issues/ledger.ts +122 -0
- package/issues/legacy.ts +68 -0
- package/issues/lock.ts +305 -0
- package/issues/paths.ts +76 -0
- package/issues/preflight.ts +189 -0
- package/issues/repository.ts +203 -0
- package/issues/scout/actions.ts +255 -0
- package/issues/scout/control.ts +80 -0
- package/issues/scout/github.ts +358 -0
- package/issues/scout/paths.ts +129 -0
- package/issues/scout/preflight.ts +277 -0
- package/issues/scout/recommendation.ts +400 -0
- package/issues/scout/scan.ts +143 -0
- package/issues/scout/state.ts +448 -0
- package/issues/scout/worker.ts +1282 -0
- package/issues/state.ts +584 -0
- package/issues/tracker.ts +271 -0
- package/issues/worker.ts +1065 -0
- package/lib/dependencies.ts +61 -0
- package/lib/forge-auth.ts +953 -0
- package/lib/forge.ts +141 -0
- package/lib/gitea.ts +709 -0
- package/lib/github-release.ts +318 -0
- package/lib/inventory.test.ts +79 -0
- package/lib/inventory.ts +53 -0
- package/lib/release.test.ts +269 -0
- package/lib/release.ts +801 -0
- package/omp/commands/adr-resolve.md +8 -0
- package/omp/commands/discover.md +6 -0
- package/omp/commands/epic-next.md +8 -0
- package/omp/commands/flow-change.md +8 -0
- package/omp/commands/flow-goal.md +8 -0
- package/omp/commands/flow-implement.md +8 -0
- package/omp/commands/flow-widget.md +8 -0
- package/omp/commands/goal-draft.md +8 -0
- package/omp/commands/goal-outcome.md +8 -0
- package/omp/commands/implement-issue.md +8 -0
- package/omp/commands/prepare-skills.md +6 -0
- package/omp/commands/scout-recommendations.md +8 -0
- package/omp/commands/setup-devboard.md +24 -0
- package/omp/config.yml +12 -0
- package/omp/extensions/implement-goal-issue.ts +2 -0
- package/omp/extensions/implement-next-epic.ts +2 -0
- package/omp/mcp.json +9 -0
- package/package.json +86 -7
- package/prompts/adr-resolve.md +130 -0
- package/prompts/discover.md +541 -0
- package/prompts/epic-next.md +533 -0
- package/prompts/flow-change.md +76 -0
- package/prompts/flow-goal.md +126 -0
- package/prompts/flow-implement.md +103 -0
- package/prompts/flow-widget.md +54 -0
- package/prompts/goal-draft.md +147 -0
- package/prompts/goal-outcome.md +87 -0
- package/prompts/implement-issue.md +66 -0
- package/prompts/prepare-skills.md +142 -0
- package/prompts/scout-recommendations.md +143 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/server/acp.ts +540 -0
- package/server/activity.test.ts +96 -0
- package/server/activity.ts +48 -0
- package/server/app.test.ts +882 -0
- package/server/app.ts +6179 -0
- package/server/ask.test.ts +543 -0
- package/server/ask.ts +589 -0
- package/server/dependencies.test.ts +118 -0
- package/server/discover-remote.test.ts +1602 -0
- package/server/discover-sheet.test.ts +176 -0
- package/server/discover.test.ts +1510 -0
- package/server/discover.ts +3198 -0
- package/server/feedback.test.ts +458 -0
- package/server/feedback.ts +369 -0
- package/server/fixtures/adr-source.ts +71 -0
- package/server/fixtures/hang-download.ts +14 -0
- package/server/fixtures/stub-agent.ts +1870 -0
- package/server/fixtures/stub-forge.ts +141 -0
- package/server/fixtures/stub-gh.ts +57 -0
- package/server/flow-pr.test.ts +976 -0
- package/server/flow.test.ts +1632 -0
- package/server/flow.ts +2655 -0
- package/server/forge-pr.ts +387 -0
- package/server/goal-creation.test.ts +957 -0
- package/server/goal-draft.test.ts +348 -0
- package/server/goal-drafts.ts +449 -0
- package/server/goal-proposal.test.ts +479 -0
- package/server/goal-proposal.ts +232 -0
- package/server/goal-revision.test.ts +613 -0
- package/server/goal-slug.test.ts +56 -0
- package/server/goal-slug.ts +96 -0
- package/server/goal.ts +20 -0
- package/server/index.ts +147 -0
- package/server/issue-close.test.ts +100 -0
- package/server/issue-close.ts +178 -0
- package/server/issues.test.ts +167 -0
- package/server/issues.ts +479 -0
- package/server/journal.test.ts +350 -0
- package/server/journal.ts +329 -0
- package/server/lifecycle-pause.test.ts +1795 -0
- package/server/lifecycle.test.ts +1879 -0
- package/server/parallel-lifecycle.test.ts +1029 -0
- package/server/persistence-restart.test.ts +723 -0
- package/server/persistence.test.ts +1935 -0
- package/server/records.test.ts +180 -0
- package/server/records.ts +99 -0
- package/server/reset-implementation.test.ts +997 -0
- package/server/run.test.ts +611 -0
- package/server/scout.test.ts +877 -0
- package/server/scout.ts +459 -0
- package/server/skills-preparation.test.ts +1073 -0
- package/server/state.ts +249 -0
- package/server/tracker.ts +369 -0
- package/server/updates.test.ts +444 -0
- package/server/updates.ts +198 -0
- package/server/version.test.ts +140 -0
- package/server/version.ts +151 -0
- package/server/watchdog.ts +389 -0
- package/svelte.config.js +5 -0
- package/templates/host-AGENTS.md +235 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +21 -0
- package/watchdog/cleanup-main.ts +98 -0
- package/watchdog/cleanup.ts +301 -0
- package/watchdog/collect.ts +311 -0
- package/watchdog/coordinator.ts +397 -0
- package/watchdog/delivery.ts +107 -0
- package/watchdog/discovery.ts +209 -0
- package/watchdog/findings.ts +211 -0
- package/watchdog/github.ts +189 -0
- package/watchdog/history.ts +138 -0
- package/watchdog/install.ts +103 -0
- package/watchdog/ledger.ts +188 -0
- package/watchdog/lock.ts +63 -0
- package/watchdog/main.ts +106 -0
- package/watchdog/status.ts +71 -0
- package/watchdog/systemd.ts +96 -0
- package/watchdog/uninstall.ts +30 -0
- package/watchdog/units/devboard-watchdog.service +9 -0
- package/watchdog/units/devboard-watchdog.timer +10 -0
|
@@ -0,0 +1,1879 @@
|
|
|
1
|
+
import { fixtureRecord, frontmatterStatus, pendingRecord } from "./fixtures/adr-source";
|
|
2
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { Server } from "bun";
|
|
8
|
+
import { createApp } from "./app";
|
|
9
|
+
import type { EpicLookup, OpenEpic } from "./tracker";
|
|
10
|
+
|
|
11
|
+
const temporaryDirectories: string[] = [];
|
|
12
|
+
const STUB_AGENT = join(import.meta.dir, "fixtures", "stub-agent.ts");
|
|
13
|
+
const MANAGED_SKILL = [
|
|
14
|
+
"---",
|
|
15
|
+
"name: behavior-first-testing",
|
|
16
|
+
"description: Choose tests by observable behavior.",
|
|
17
|
+
"metadata:",
|
|
18
|
+
' devboard-managed: "true"',
|
|
19
|
+
"---",
|
|
20
|
+
"",
|
|
21
|
+
"# Behavior-first testing",
|
|
22
|
+
"",
|
|
23
|
+
"- Prefer observable behavior over implementation details.",
|
|
24
|
+
"",
|
|
25
|
+
].join("\n");
|
|
26
|
+
const UNMANAGED_SKILL = [
|
|
27
|
+
"---",
|
|
28
|
+
"name: house-style",
|
|
29
|
+
"description: Follow the house style.",
|
|
30
|
+
"---",
|
|
31
|
+
"",
|
|
32
|
+
"# House style",
|
|
33
|
+
"",
|
|
34
|
+
"- Prefer short functions.",
|
|
35
|
+
"",
|
|
36
|
+
].join("\n");
|
|
37
|
+
|
|
38
|
+
interface TestServer {
|
|
39
|
+
server: Server<unknown>;
|
|
40
|
+
root: string;
|
|
41
|
+
adrDir: string;
|
|
42
|
+
goalFile: string;
|
|
43
|
+
logPath: string;
|
|
44
|
+
url: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function temporaryDirectory() {
|
|
48
|
+
const directory = await mkdtemp(join(tmpdir(), "devboard-lifecycle-"));
|
|
49
|
+
temporaryDirectories.push(directory);
|
|
50
|
+
return directory;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function startServer(
|
|
54
|
+
files: Record<string, string>,
|
|
55
|
+
options: {
|
|
56
|
+
agentArguments?: string;
|
|
57
|
+
tracker?: EpicLookup;
|
|
58
|
+
goalText?: string | null;
|
|
59
|
+
trackerSettle?: { attempts: number; delayMs: number };
|
|
60
|
+
laneBackoffMs?: number;
|
|
61
|
+
mainPollMs?: number;
|
|
62
|
+
installedSkills?: "managed" | "unmanaged" | "none";
|
|
63
|
+
} = {},
|
|
64
|
+
): Promise<TestServer> {
|
|
65
|
+
const root = await temporaryDirectory();
|
|
66
|
+
const adrDir = join(root, "adrs");
|
|
67
|
+
const staticDir = join(root, "static");
|
|
68
|
+
const goalFile = join(root, "GOAL.md");
|
|
69
|
+
await mkdir(adrDir, { recursive: true });
|
|
70
|
+
await mkdir(staticDir, { recursive: true });
|
|
71
|
+
await writeFile(join(staticDir, "index.html"), "<!doctype html><title>ADR overview</title>");
|
|
72
|
+
if (options.goalText === undefined) {
|
|
73
|
+
await writeFile(goalFile, "# Goal\n\nShip the lifecycle.\n");
|
|
74
|
+
} else if (options.goalText !== null) {
|
|
75
|
+
await writeFile(goalFile, options.goalText);
|
|
76
|
+
}
|
|
77
|
+
const installedSkills = options.installedSkills ?? "managed";
|
|
78
|
+
if (installedSkills !== "none") {
|
|
79
|
+
const name = installedSkills === "managed" ? "behavior-first-testing" : "house-style";
|
|
80
|
+
const source = installedSkills === "managed" ? MANAGED_SKILL : UNMANAGED_SKILL;
|
|
81
|
+
await mkdir(join(root, ".omp", "skills", name), { recursive: true });
|
|
82
|
+
await writeFile(join(root, ".omp", "skills", name, "SKILL.md"), source);
|
|
83
|
+
}
|
|
84
|
+
for (const [file, source] of Object.entries(files)) {
|
|
85
|
+
await writeFile(join(adrDir, file), source);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const logPath = join(root, "agent.log");
|
|
89
|
+
const server = createApp({
|
|
90
|
+
adrDir,
|
|
91
|
+
staticDir,
|
|
92
|
+
hostname: "127.0.0.1",
|
|
93
|
+
port: 0,
|
|
94
|
+
repoDir: root,
|
|
95
|
+
stateDir: join(root, "state"),
|
|
96
|
+
goalFile,
|
|
97
|
+
agentCommand: `bun ${STUB_AGENT} --log=${logPath} ${options.agentArguments ?? ""}`.trim(),
|
|
98
|
+
tracker: options.tracker ?? (async () => undefined),
|
|
99
|
+
trackerSettle: options.trackerSettle ?? { attempts: 1 },
|
|
100
|
+
laneBackoffMs: options.laneBackoffMs,
|
|
101
|
+
mainPollMs: options.mainPollMs,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return { server, root, adrDir, goalFile, logPath, url: `127.0.0.1:${server.port}` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface TestClient {
|
|
108
|
+
socket: WebSocket;
|
|
109
|
+
events: Record<string, unknown>[];
|
|
110
|
+
send(command: unknown): void;
|
|
111
|
+
waitFor(predicate: (message: Record<string, unknown>) => boolean): Promise<Record<string, unknown>>;
|
|
112
|
+
close(): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function connect(url: string): Promise<TestClient> {
|
|
116
|
+
const { promise, resolve, reject } = Promise.withResolvers<TestClient>();
|
|
117
|
+
const socket = new WebSocket(`ws://${url}/ws`);
|
|
118
|
+
const events: Record<string, unknown>[] = [];
|
|
119
|
+
const buffered: Record<string, unknown>[] = [];
|
|
120
|
+
const waiters: {
|
|
121
|
+
predicate: (message: Record<string, unknown>) => boolean;
|
|
122
|
+
resolve: (message: Record<string, unknown>) => void;
|
|
123
|
+
}[] = [];
|
|
124
|
+
|
|
125
|
+
socket.onopen = () => {
|
|
126
|
+
resolve({
|
|
127
|
+
socket,
|
|
128
|
+
events,
|
|
129
|
+
send(command) {
|
|
130
|
+
socket.send(JSON.stringify(command));
|
|
131
|
+
},
|
|
132
|
+
waitFor(predicate) {
|
|
133
|
+
const index = buffered.findIndex(predicate);
|
|
134
|
+
if (index >= 0) {
|
|
135
|
+
const message = buffered.splice(index, 1)[0];
|
|
136
|
+
if (message !== undefined) return Promise.resolve(message);
|
|
137
|
+
}
|
|
138
|
+
const { promise: waiting, resolve: settle } =
|
|
139
|
+
Promise.withResolvers<Record<string, unknown>>();
|
|
140
|
+
waiters.push({ predicate, resolve: settle });
|
|
141
|
+
return waiting;
|
|
142
|
+
},
|
|
143
|
+
close() {
|
|
144
|
+
socket.close();
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
socket.onmessage = (event) => {
|
|
150
|
+
let message: Record<string, unknown>;
|
|
151
|
+
try {
|
|
152
|
+
message = JSON.parse(String(event.data)) as Record<string, unknown>;
|
|
153
|
+
} catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
events.push(message);
|
|
157
|
+
const index = waiters.findIndex((waiter) => waiter.predicate(message));
|
|
158
|
+
if (index >= 0) {
|
|
159
|
+
const waiter = waiters.splice(index, 1)[0];
|
|
160
|
+
if (waiter !== undefined) waiter.resolve(message);
|
|
161
|
+
} else {
|
|
162
|
+
buffered.push(message);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
socket.onerror = () => reject(new Error("websocket failed"));
|
|
167
|
+
|
|
168
|
+
return promise;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function spawnCount(logPath: string): number {
|
|
172
|
+
if (!existsSync(logPath)) return 0;
|
|
173
|
+
return readFileSync(logPath, "utf8")
|
|
174
|
+
.split("\n")
|
|
175
|
+
.filter((line) => line.startsWith("spawn ")).length;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function prompts(logPath: string): string[] {
|
|
179
|
+
if (!existsSync(logPath)) return [];
|
|
180
|
+
return readFileSync(logPath, "utf8")
|
|
181
|
+
.split("\n")
|
|
182
|
+
.filter((line) => line.startsWith("prompt "))
|
|
183
|
+
.map((line) => line.slice("prompt ".length));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function lifecycleStates(client: TestClient): Record<string, unknown>[] {
|
|
187
|
+
return client.events.filter((message) => message.type === "lifecycleState");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function waitUntil(
|
|
191
|
+
predicate: () => boolean,
|
|
192
|
+
message: string,
|
|
193
|
+
timeout = 5000,
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
const deadline = Date.now() + timeout;
|
|
196
|
+
while (!predicate() && Date.now() < deadline) await Bun.sleep(25);
|
|
197
|
+
if (!predicate()) throw new Error(message);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function waitForAt(
|
|
201
|
+
client: TestClient,
|
|
202
|
+
start: number,
|
|
203
|
+
predicate: (message: Record<string, unknown>) => boolean,
|
|
204
|
+
message: string,
|
|
205
|
+
timeout = 5000,
|
|
206
|
+
): Promise<Record<string, unknown>> {
|
|
207
|
+
const deadline = Date.now() + timeout;
|
|
208
|
+
while (Date.now() < deadline) {
|
|
209
|
+
const index = client.events.slice(start).findIndex(predicate);
|
|
210
|
+
if (index >= 0) return client.events[start + index] as Record<string, unknown>;
|
|
211
|
+
await Bun.sleep(25);
|
|
212
|
+
}
|
|
213
|
+
throw new Error(message);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
afterAll(async () => {
|
|
217
|
+
await Promise.all(
|
|
218
|
+
temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const PENDING_ONE = pendingRecord("0001-first.md", "First decision", "First.", "Choose the first.");
|
|
223
|
+
const PENDING_TWO = pendingRecord("0002-second.md", "Second decision", "Second.", "Choose the second.");
|
|
224
|
+
const PENDING_THREE = pendingRecord("0003-third.md", "Third decision", "Third.", "Choose the third.");
|
|
225
|
+
|
|
226
|
+
describe("lifecycle runs", () => {
|
|
227
|
+
test(
|
|
228
|
+
"stops a running lifecycle and closes its in-flight lanes",
|
|
229
|
+
async () => {
|
|
230
|
+
const epic: OpenEpic = {
|
|
231
|
+
number: 55,
|
|
232
|
+
title: "Stoppable",
|
|
233
|
+
url: "https://example.test/issues/55",
|
|
234
|
+
};
|
|
235
|
+
const { server, logPath, url } = await startServer(
|
|
236
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
237
|
+
{
|
|
238
|
+
agentArguments: "--delay-ms=5000",
|
|
239
|
+
tracker: async () => epic,
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
const client = await connect(url);
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
client.send({ type: "startLifecycle" });
|
|
246
|
+
expect(
|
|
247
|
+
await client.waitFor(
|
|
248
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
249
|
+
),
|
|
250
|
+
).toEqual({
|
|
251
|
+
type: "lifecycleState",
|
|
252
|
+
stage: "implementation",
|
|
253
|
+
pass: 1,
|
|
254
|
+
work: { kind: "epic", id: "55", url: "https://example.test/issues/55" },
|
|
255
|
+
paused: false,
|
|
256
|
+
});
|
|
257
|
+
expect(await client.waitFor((message) => message.type === "runStarted")).toMatchObject({
|
|
258
|
+
kind: "implementation",
|
|
259
|
+
file: "",
|
|
260
|
+
title: "Stoppable",
|
|
261
|
+
action: "implementing",
|
|
262
|
+
});
|
|
263
|
+
await waitUntil(() => spawnCount(logPath) === 1, "the lifecycle peer did not spawn");
|
|
264
|
+
await waitUntil(() => prompts(logPath).length === 1, "the epic peer did not prompt");
|
|
265
|
+
|
|
266
|
+
const stopIndex = client.events.length;
|
|
267
|
+
client.send({ type: "stopLifecycle" });
|
|
268
|
+
expect(
|
|
269
|
+
await waitForAt(
|
|
270
|
+
client,
|
|
271
|
+
stopIndex,
|
|
272
|
+
(message) => message.type === "lifecycleState",
|
|
273
|
+
"the stopped lifecycle did not report an idle state",
|
|
274
|
+
),
|
|
275
|
+
).toEqual({
|
|
276
|
+
type: "lifecycleState",
|
|
277
|
+
stage: "goal",
|
|
278
|
+
pass: 1,
|
|
279
|
+
work: null,
|
|
280
|
+
paused: false,
|
|
281
|
+
});
|
|
282
|
+
const laneSnapshot = await waitForAt(
|
|
283
|
+
client,
|
|
284
|
+
stopIndex,
|
|
285
|
+
(message) =>
|
|
286
|
+
message.type === "laneState" &&
|
|
287
|
+
message.runningLanes === 0 &&
|
|
288
|
+
Array.isArray(message.lanes) &&
|
|
289
|
+
message.lanes.some(
|
|
290
|
+
(lane) =>
|
|
291
|
+
typeof lane === "object" &&
|
|
292
|
+
lane !== null &&
|
|
293
|
+
"status" in lane &&
|
|
294
|
+
lane.status === "failed",
|
|
295
|
+
),
|
|
296
|
+
"the stopped lanes were not reported",
|
|
297
|
+
);
|
|
298
|
+
expect(laneSnapshot.openEpics).toBe(1);
|
|
299
|
+
|
|
300
|
+
const idleIndex = client.events.length;
|
|
301
|
+
client.send({ type: "stopLifecycle" });
|
|
302
|
+
expect(
|
|
303
|
+
await waitForAt(
|
|
304
|
+
client,
|
|
305
|
+
idleIndex,
|
|
306
|
+
(message) => message.type === "error",
|
|
307
|
+
"a second stop was not refused",
|
|
308
|
+
),
|
|
309
|
+
).toEqual({ type: "error", message: "No lifecycle run is active." });
|
|
310
|
+
|
|
311
|
+
// The interface controls the lifecycle with one button, so a stopped
|
|
312
|
+
// run must be startable again without waiting for the old loop.
|
|
313
|
+
client.send({ type: "startLifecycle" });
|
|
314
|
+
expect(
|
|
315
|
+
await waitForAt(
|
|
316
|
+
client,
|
|
317
|
+
idleIndex,
|
|
318
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
319
|
+
"the lifecycle did not start again after a stop",
|
|
320
|
+
),
|
|
321
|
+
).toMatchObject({ stage: "implementation", pass: 1 });
|
|
322
|
+
} finally {
|
|
323
|
+
client.close();
|
|
324
|
+
server.stop(true);
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
20000,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
test(
|
|
331
|
+
"starts one epic-generation peer and reports the initial state first",
|
|
332
|
+
async () => {
|
|
333
|
+
const { server, logPath, url } = await startServer({
|
|
334
|
+
"0001-done.md": fixtureRecord("0001-done.md", "Done"),
|
|
335
|
+
});
|
|
336
|
+
const client = await connect(url);
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
client.send({ type: "startLifecycle" });
|
|
340
|
+
|
|
341
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toMatchObject({
|
|
342
|
+
type: "lifecycleState",
|
|
343
|
+
stage: "epic",
|
|
344
|
+
pass: 1,
|
|
345
|
+
work: null,
|
|
346
|
+
paused: false,
|
|
347
|
+
});
|
|
348
|
+
expect(lifecycleStates(client)[0]).toEqual({
|
|
349
|
+
type: "lifecycleState",
|
|
350
|
+
stage: "epic",
|
|
351
|
+
pass: 1,
|
|
352
|
+
work: null,
|
|
353
|
+
paused: false,
|
|
354
|
+
});
|
|
355
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
356
|
+
type: "lifecycleState",
|
|
357
|
+
stage: "finished",
|
|
358
|
+
pass: 1,
|
|
359
|
+
work: null,
|
|
360
|
+
paused: false,
|
|
361
|
+
});
|
|
362
|
+
expect(lifecycleStates(client)).toEqual([
|
|
363
|
+
{ type: "lifecycleState", stage: "epic", pass: 1, work: null, paused: false },
|
|
364
|
+
{ type: "lifecycleState", stage: "finished", pass: 1, work: null, paused: false },
|
|
365
|
+
]);
|
|
366
|
+
expect(client.events.filter((message) => message.type === "error")).toEqual([]);
|
|
367
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
368
|
+
expect(prompts(logPath)).toEqual(["/epic-next"]);
|
|
369
|
+
} finally {
|
|
370
|
+
client.close();
|
|
371
|
+
server.stop(true);
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
20000,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
test(
|
|
378
|
+
"reports the running stage before the tracker lookup returns",
|
|
379
|
+
async () => {
|
|
380
|
+
const lookup = Promise.withResolvers<void>();
|
|
381
|
+
let lookups = 0;
|
|
382
|
+
const { server, logPath, url } = await startServer(
|
|
383
|
+
{},
|
|
384
|
+
{
|
|
385
|
+
tracker: async () => {
|
|
386
|
+
lookups += 1;
|
|
387
|
+
if (lookups === 1) await lookup.promise;
|
|
388
|
+
return undefined;
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
const client = await connect(url);
|
|
393
|
+
const other = await connect(url);
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const initial = {
|
|
397
|
+
type: "lifecycleState",
|
|
398
|
+
stage: "epic",
|
|
399
|
+
pass: 1,
|
|
400
|
+
work: null,
|
|
401
|
+
paused: false,
|
|
402
|
+
};
|
|
403
|
+
client.send({ type: "startLifecycle" });
|
|
404
|
+
|
|
405
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual(
|
|
406
|
+
initial,
|
|
407
|
+
);
|
|
408
|
+
expect(await other.waitFor((message) => message.type === "lifecycleState")).toEqual(
|
|
409
|
+
initial,
|
|
410
|
+
);
|
|
411
|
+
expect(spawnCount(logPath)).toBe(0);
|
|
412
|
+
expect(client.events.filter((message) => message.type === "runStarted")).toEqual([]);
|
|
413
|
+
|
|
414
|
+
const late = await connect(url);
|
|
415
|
+
try {
|
|
416
|
+
expect(await late.waitFor((message) => message.type === "lifecycleState")).toEqual(
|
|
417
|
+
initial,
|
|
418
|
+
);
|
|
419
|
+
expect(late.events[0]).toEqual(initial);
|
|
420
|
+
} finally {
|
|
421
|
+
late.close();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
lookup.resolve();
|
|
425
|
+
expect(
|
|
426
|
+
await client.waitFor(
|
|
427
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
428
|
+
),
|
|
429
|
+
).toEqual({
|
|
430
|
+
type: "lifecycleState",
|
|
431
|
+
stage: "finished",
|
|
432
|
+
pass: 1,
|
|
433
|
+
work: null,
|
|
434
|
+
paused: false,
|
|
435
|
+
});
|
|
436
|
+
expect(lifecycleStates(client)).toEqual([
|
|
437
|
+
initial,
|
|
438
|
+
{ type: "lifecycleState", stage: "finished", pass: 1, work: null, paused: false },
|
|
439
|
+
]);
|
|
440
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
441
|
+
} finally {
|
|
442
|
+
client.close();
|
|
443
|
+
other.close();
|
|
444
|
+
server.stop(true);
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
20000,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
test(
|
|
451
|
+
"finishes when epic generation yields no epic and no pending ADR",
|
|
452
|
+
async () => {
|
|
453
|
+
let lookups = 0;
|
|
454
|
+
const { server, logPath, url } = await startServer(
|
|
455
|
+
{},
|
|
456
|
+
{
|
|
457
|
+
tracker: async () => {
|
|
458
|
+
lookups += 1;
|
|
459
|
+
return undefined;
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
);
|
|
463
|
+
const client = await connect(url);
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
client.send({ type: "startLifecycle" });
|
|
467
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
468
|
+
type: "lifecycleState",
|
|
469
|
+
stage: "epic",
|
|
470
|
+
pass: 1,
|
|
471
|
+
work: null,
|
|
472
|
+
paused: false,
|
|
473
|
+
});
|
|
474
|
+
const finished = await client.waitFor(
|
|
475
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
476
|
+
);
|
|
477
|
+
expect(finished).toEqual({
|
|
478
|
+
type: "lifecycleState",
|
|
479
|
+
stage: "finished",
|
|
480
|
+
pass: 1,
|
|
481
|
+
work: null,
|
|
482
|
+
paused: false,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const lookupsAtFinish = lookups;
|
|
486
|
+
await Bun.sleep(500);
|
|
487
|
+
const index = client.events.indexOf(finished);
|
|
488
|
+
expect(index).toBeGreaterThanOrEqual(0);
|
|
489
|
+
expect(client.events.slice(index + 1)).toEqual([]);
|
|
490
|
+
expect(
|
|
491
|
+
client.events.filter(
|
|
492
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
493
|
+
),
|
|
494
|
+
).toHaveLength(1);
|
|
495
|
+
expect(
|
|
496
|
+
client.events.filter((message) => message.type === "error"),
|
|
497
|
+
).toEqual([]);
|
|
498
|
+
expect(lookups).toBe(lookupsAtFinish);
|
|
499
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
500
|
+
expect(prompts(logPath)).toEqual(["/epic-next"]);
|
|
501
|
+
|
|
502
|
+
client.send({ type: "startLifecycle" });
|
|
503
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
504
|
+
type: "lifecycleState",
|
|
505
|
+
stage: "epic",
|
|
506
|
+
pass: 1,
|
|
507
|
+
work: null,
|
|
508
|
+
paused: false,
|
|
509
|
+
});
|
|
510
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
511
|
+
type: "lifecycleState",
|
|
512
|
+
stage: "finished",
|
|
513
|
+
pass: 1,
|
|
514
|
+
work: null,
|
|
515
|
+
paused: false,
|
|
516
|
+
});
|
|
517
|
+
expect(
|
|
518
|
+
client.events.filter((message) => message.type === "error"),
|
|
519
|
+
).toEqual([]);
|
|
520
|
+
expect(lookups).toBe(lookupsAtFinish * 2);
|
|
521
|
+
expect(spawnCount(logPath)).toBe(2);
|
|
522
|
+
expect(prompts(logPath)).toEqual(["/epic-next", "/epic-next"]);
|
|
523
|
+
} finally {
|
|
524
|
+
client.close();
|
|
525
|
+
server.stop(true);
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
20000,
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
test(
|
|
532
|
+
"refuses startLifecycle, an empty startRun, and saveGoal while the epic peer runs",
|
|
533
|
+
async () => {
|
|
534
|
+
const { server, goalFile, logPath, url } = await startServer(
|
|
535
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
536
|
+
{ agentArguments: "--delay-ms=800" },
|
|
537
|
+
);
|
|
538
|
+
const client = await connect(url);
|
|
539
|
+
const originalGoal = await readFile(goalFile, "utf8");
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
client.send({ type: "startLifecycle" });
|
|
543
|
+
await client.waitFor((message) => message.type === "lifecycleState");
|
|
544
|
+
|
|
545
|
+
const spawned = Date.now() + 5000;
|
|
546
|
+
while (spawnCount(logPath) < 1 && Date.now() < spawned) await Bun.sleep(25);
|
|
547
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
548
|
+
|
|
549
|
+
client.send({ type: "startLifecycle" });
|
|
550
|
+
client.send({ type: "startRun" });
|
|
551
|
+
client.send({ type: "saveGoal", text: "changed" });
|
|
552
|
+
|
|
553
|
+
for (const expected of [
|
|
554
|
+
"A lifecycle run is already active.",
|
|
555
|
+
"No pending ADRs to start.",
|
|
556
|
+
"A lifecycle run is active.",
|
|
557
|
+
]) {
|
|
558
|
+
await client.waitFor(
|
|
559
|
+
(message) => message.type === "error" && message.message === expected,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
expect(
|
|
564
|
+
client.events
|
|
565
|
+
.filter((message) => message.type === "error")
|
|
566
|
+
.map((message) => message.message)
|
|
567
|
+
.sort(),
|
|
568
|
+
).toEqual(
|
|
569
|
+
[
|
|
570
|
+
"A lifecycle run is already active.",
|
|
571
|
+
"No pending ADRs to start.",
|
|
572
|
+
"A lifecycle run is active.",
|
|
573
|
+
].sort(),
|
|
574
|
+
);
|
|
575
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
576
|
+
expect(await readFile(goalFile, "utf8")).toBe(originalGoal);
|
|
577
|
+
|
|
578
|
+
await client.waitFor(
|
|
579
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
580
|
+
);
|
|
581
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
582
|
+
} finally {
|
|
583
|
+
client.close();
|
|
584
|
+
server.stop(true);
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
20000,
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
test(
|
|
591
|
+
"resolves every pending ADR in file-name order, then returns to epic generation",
|
|
592
|
+
async () => {
|
|
593
|
+
const { server, adrDir, logPath, url } = await startServer(
|
|
594
|
+
{
|
|
595
|
+
"0003-third.md": PENDING_THREE,
|
|
596
|
+
"0001-first.md": PENDING_ONE,
|
|
597
|
+
"0002-second.md": PENDING_TWO,
|
|
598
|
+
},
|
|
599
|
+
{ agentArguments: "--questions=0" },
|
|
600
|
+
);
|
|
601
|
+
const client = await connect(url);
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
client.send({ type: "startLifecycle" });
|
|
605
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toMatchObject({
|
|
606
|
+
stage: "epic",
|
|
607
|
+
pass: 1,
|
|
608
|
+
work: null,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toMatchObject({
|
|
612
|
+
stage: "adr",
|
|
613
|
+
pass: 1,
|
|
614
|
+
work: { kind: "adr", id: "0001-first.md" },
|
|
615
|
+
});
|
|
616
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toMatchObject({
|
|
617
|
+
stage: "adr",
|
|
618
|
+
pass: 1,
|
|
619
|
+
work: { kind: "adr", id: "0002-second.md" },
|
|
620
|
+
});
|
|
621
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toMatchObject({
|
|
622
|
+
stage: "adr",
|
|
623
|
+
pass: 1,
|
|
624
|
+
work: { kind: "adr", id: "0003-third.md" },
|
|
625
|
+
});
|
|
626
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toMatchObject({
|
|
627
|
+
stage: "epic",
|
|
628
|
+
pass: 1,
|
|
629
|
+
work: null,
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
expect(lifecycleStates(client).map((message) => [message.stage, message.work])).toEqual([
|
|
633
|
+
["epic", null],
|
|
634
|
+
["adr", { kind: "adr", id: "0001-first.md" }],
|
|
635
|
+
["adr", { kind: "adr", id: "0002-second.md" }],
|
|
636
|
+
["adr", { kind: "adr", id: "0003-third.md" }],
|
|
637
|
+
["epic", null],
|
|
638
|
+
]);
|
|
639
|
+
|
|
640
|
+
for (const file of ["0001-first.md", "0002-second.md", "0003-third.md"]) {
|
|
641
|
+
expect(frontmatterStatus(await readFile(join(adrDir, file), "utf8"))).toBe("accepted");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const deadline = Date.now() + 5000;
|
|
645
|
+
while (spawnCount(logPath) < 5 && Date.now() < deadline) await Bun.sleep(50);
|
|
646
|
+
|
|
647
|
+
const recorded = prompts(logPath);
|
|
648
|
+
expect(recorded[0]).toBe("/epic-next");
|
|
649
|
+
expect(recorded.slice(1, 4).map((prompt) => prompt.startsWith("/adr-resolve "))).toEqual([
|
|
650
|
+
true,
|
|
651
|
+
true,
|
|
652
|
+
true,
|
|
653
|
+
]);
|
|
654
|
+
expect(recorded[1]).toContain("0001-first.md");
|
|
655
|
+
expect(recorded[2]).toContain("0002-second.md");
|
|
656
|
+
expect(recorded[3]).toContain("0003-third.md");
|
|
657
|
+
expect(recorded[4]).toBe("/epic-next");
|
|
658
|
+
} finally {
|
|
659
|
+
client.close();
|
|
660
|
+
server.stop(true);
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
30000,
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
test(
|
|
667
|
+
"implements an existing open epic before generating another",
|
|
668
|
+
async () => {
|
|
669
|
+
const epic: OpenEpic = {
|
|
670
|
+
number: 42,
|
|
671
|
+
title: "An existing epic",
|
|
672
|
+
url: "https://example.test/issues/42",
|
|
673
|
+
};
|
|
674
|
+
let lookups = 0;
|
|
675
|
+
const { server, logPath, url } = await startServer(
|
|
676
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
677
|
+
{
|
|
678
|
+
agentArguments: "--delay-ms=1200",
|
|
679
|
+
tracker: async () => {
|
|
680
|
+
lookups += 1;
|
|
681
|
+
return lookups === 1 ? epic : undefined;
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
);
|
|
685
|
+
const client = await connect(url);
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
client.send({ type: "startLifecycle" });
|
|
689
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
690
|
+
type: "lifecycleState",
|
|
691
|
+
stage: "epic",
|
|
692
|
+
pass: 1,
|
|
693
|
+
work: null,
|
|
694
|
+
paused: false,
|
|
695
|
+
});
|
|
696
|
+
expect(spawnCount(logPath)).toBe(0);
|
|
697
|
+
expect(
|
|
698
|
+
await client.waitFor(
|
|
699
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
700
|
+
),
|
|
701
|
+
).toEqual({
|
|
702
|
+
type: "lifecycleState",
|
|
703
|
+
stage: "implementation",
|
|
704
|
+
pass: 1,
|
|
705
|
+
work: { kind: "epic", id: "42", url: "https://example.test/issues/42" },
|
|
706
|
+
paused: false,
|
|
707
|
+
});
|
|
708
|
+
expect(await client.waitFor((message) => message.type === "runStarted")).toMatchObject({
|
|
709
|
+
file: "",
|
|
710
|
+
title: "An existing epic",
|
|
711
|
+
action: "implementing",
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
const spawned = Date.now() + 5000;
|
|
715
|
+
while (prompts(logPath).length < 1 && Date.now() < spawned) await Bun.sleep(25);
|
|
716
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
717
|
+
expect(prompts(logPath)).toEqual(["/implement-next-epic 42"]);
|
|
718
|
+
expect(lifecycleStates(client)).toEqual([
|
|
719
|
+
{
|
|
720
|
+
type: "lifecycleState",
|
|
721
|
+
stage: "epic",
|
|
722
|
+
pass: 1,
|
|
723
|
+
work: null,
|
|
724
|
+
paused: false,
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
type: "lifecycleState",
|
|
728
|
+
stage: "implementation",
|
|
729
|
+
pass: 1,
|
|
730
|
+
work: { kind: "epic", id: "42", url: "https://example.test/issues/42" },
|
|
731
|
+
paused: false,
|
|
732
|
+
},
|
|
733
|
+
]);
|
|
734
|
+
} finally {
|
|
735
|
+
client.close();
|
|
736
|
+
server.stop(true);
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
20000,
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
test(
|
|
743
|
+
"returns to epic generation on the next pass after implementation",
|
|
744
|
+
async () => {
|
|
745
|
+
const epic: OpenEpic = {
|
|
746
|
+
number: 7,
|
|
747
|
+
title: "Existing epic",
|
|
748
|
+
url: "https://example.test/issues/7",
|
|
749
|
+
};
|
|
750
|
+
let lookups = 0;
|
|
751
|
+
const { server, logPath, url } = await startServer(
|
|
752
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
753
|
+
{
|
|
754
|
+
tracker: async () => {
|
|
755
|
+
lookups += 1;
|
|
756
|
+
return lookups === 1 ? epic : undefined;
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
);
|
|
760
|
+
const client = await connect(url);
|
|
761
|
+
|
|
762
|
+
try {
|
|
763
|
+
client.send({ type: "startLifecycle" });
|
|
764
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
765
|
+
type: "lifecycleState",
|
|
766
|
+
stage: "epic",
|
|
767
|
+
pass: 1,
|
|
768
|
+
work: null,
|
|
769
|
+
paused: false,
|
|
770
|
+
});
|
|
771
|
+
expect(
|
|
772
|
+
await client.waitFor(
|
|
773
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
774
|
+
),
|
|
775
|
+
).toMatchObject({
|
|
776
|
+
stage: "implementation",
|
|
777
|
+
pass: 1,
|
|
778
|
+
work: { kind: "epic", id: "7", url: "https://example.test/issues/7" },
|
|
779
|
+
});
|
|
780
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
781
|
+
type: "lifecycleState",
|
|
782
|
+
stage: "epic",
|
|
783
|
+
pass: 2,
|
|
784
|
+
work: null,
|
|
785
|
+
paused: false,
|
|
786
|
+
});
|
|
787
|
+
expect(
|
|
788
|
+
await client.waitFor(
|
|
789
|
+
(message) => message.type === "runStarted" && message.title === "Epic generation",
|
|
790
|
+
),
|
|
791
|
+
).toMatchObject({ file: "", title: "Epic generation", action: "generating" });
|
|
792
|
+
|
|
793
|
+
const started = Date.now() + 5000;
|
|
794
|
+
while (prompts(logPath).length < 2 && Date.now() < started) await Bun.sleep(25);
|
|
795
|
+
expect(spawnCount(logPath)).toBe(2);
|
|
796
|
+
expect(prompts(logPath)).toEqual(["/implement-next-epic 7", "/epic-next"]);
|
|
797
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
798
|
+
type: "lifecycleState",
|
|
799
|
+
stage: "finished",
|
|
800
|
+
pass: 2,
|
|
801
|
+
work: null,
|
|
802
|
+
paused: false,
|
|
803
|
+
});
|
|
804
|
+
expect(lifecycleStates(client)).toEqual([
|
|
805
|
+
{ type: "lifecycleState", stage: "epic", pass: 1, work: null, paused: false },
|
|
806
|
+
{
|
|
807
|
+
type: "lifecycleState",
|
|
808
|
+
stage: "implementation",
|
|
809
|
+
pass: 1,
|
|
810
|
+
work: { kind: "epic", id: "7", url: "https://example.test/issues/7" },
|
|
811
|
+
paused: false,
|
|
812
|
+
},
|
|
813
|
+
{ type: "lifecycleState", stage: "epic", pass: 2, work: null, paused: false },
|
|
814
|
+
{ type: "lifecycleState", stage: "finished", pass: 2, work: null, paused: false },
|
|
815
|
+
]);
|
|
816
|
+
} finally {
|
|
817
|
+
client.close();
|
|
818
|
+
server.stop(true);
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
20000,
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
test(
|
|
825
|
+
"waits for a just-created epic the tracker reports only after a delay",
|
|
826
|
+
async () => {
|
|
827
|
+
const epic: OpenEpic = {
|
|
828
|
+
number: 31,
|
|
829
|
+
title: "Just created",
|
|
830
|
+
url: "https://example.test/issues/31",
|
|
831
|
+
};
|
|
832
|
+
let lookups = 0;
|
|
833
|
+
const { server, logPath, url } = await startServer(
|
|
834
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
835
|
+
{
|
|
836
|
+
trackerSettle: { attempts: 4, delayMs: 10 },
|
|
837
|
+
tracker: async () => {
|
|
838
|
+
lookups += 1;
|
|
839
|
+
return lookups === 3 || lookups === 4 ? epic : undefined;
|
|
840
|
+
},
|
|
841
|
+
},
|
|
842
|
+
);
|
|
843
|
+
const client = await connect(url);
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
client.send({ type: "startLifecycle" });
|
|
847
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
848
|
+
type: "lifecycleState",
|
|
849
|
+
stage: "epic",
|
|
850
|
+
pass: 1,
|
|
851
|
+
work: null,
|
|
852
|
+
paused: false,
|
|
853
|
+
});
|
|
854
|
+
expect(
|
|
855
|
+
await client.waitFor(
|
|
856
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
857
|
+
),
|
|
858
|
+
).toEqual({
|
|
859
|
+
type: "lifecycleState",
|
|
860
|
+
stage: "implementation",
|
|
861
|
+
pass: 1,
|
|
862
|
+
work: { kind: "epic", id: "31", url: "https://example.test/issues/31" },
|
|
863
|
+
paused: false,
|
|
864
|
+
});
|
|
865
|
+
expect(
|
|
866
|
+
await client.waitFor(
|
|
867
|
+
(message) => message.type === "runStarted" && message.title === "Just created",
|
|
868
|
+
),
|
|
869
|
+
).toMatchObject({ file: "", title: "Just created", action: "implementing" });
|
|
870
|
+
|
|
871
|
+
const started = Date.now() + 5000;
|
|
872
|
+
while (prompts(logPath).length < 2 && Date.now() < started) await Bun.sleep(25);
|
|
873
|
+
const lines = prompts(logPath);
|
|
874
|
+
expect(lines[0]).toBe("/epic-next");
|
|
875
|
+
expect(lines[1]).toBe("/implement-next-epic 31");
|
|
876
|
+
} finally {
|
|
877
|
+
client.close();
|
|
878
|
+
server.stop(true);
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
20000,
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
test(
|
|
885
|
+
"waits out a just-closed epic instead of aborting the pass",
|
|
886
|
+
async () => {
|
|
887
|
+
const epic: OpenEpic = {
|
|
888
|
+
number: 32,
|
|
889
|
+
title: "Just closed",
|
|
890
|
+
url: "https://example.test/issues/32",
|
|
891
|
+
};
|
|
892
|
+
let lookups = 0;
|
|
893
|
+
const { server, logPath, url } = await startServer(
|
|
894
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
895
|
+
{
|
|
896
|
+
trackerSettle: { attempts: 4, delayMs: 10 },
|
|
897
|
+
tracker: async () => {
|
|
898
|
+
lookups += 1;
|
|
899
|
+
return lookups <= 2 ? epic : undefined;
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
);
|
|
903
|
+
const client = await connect(url);
|
|
904
|
+
|
|
905
|
+
try {
|
|
906
|
+
client.send({ type: "startLifecycle" });
|
|
907
|
+
await client.waitFor(
|
|
908
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
909
|
+
);
|
|
910
|
+
expect(
|
|
911
|
+
await client.waitFor(
|
|
912
|
+
(message) =>
|
|
913
|
+
message.type === "lifecycleState" &&
|
|
914
|
+
message.stage === "epic" &&
|
|
915
|
+
message.pass === 2,
|
|
916
|
+
),
|
|
917
|
+
).toEqual({
|
|
918
|
+
type: "lifecycleState",
|
|
919
|
+
stage: "epic",
|
|
920
|
+
pass: 2,
|
|
921
|
+
work: null,
|
|
922
|
+
paused: false,
|
|
923
|
+
});
|
|
924
|
+
expect(
|
|
925
|
+
client.events.filter(
|
|
926
|
+
(message) =>
|
|
927
|
+
message.type === "error" &&
|
|
928
|
+
message.message === "Implementation ended but epic #32 is still open.",
|
|
929
|
+
),
|
|
930
|
+
).toEqual([]);
|
|
931
|
+
|
|
932
|
+
const started = Date.now() + 5000;
|
|
933
|
+
while (prompts(logPath).length < 2 && Date.now() < started) await Bun.sleep(25);
|
|
934
|
+
expect(prompts(logPath)).toEqual(["/implement-next-epic 32", "/epic-next"]);
|
|
935
|
+
} finally {
|
|
936
|
+
client.close();
|
|
937
|
+
server.stop(true);
|
|
938
|
+
}
|
|
939
|
+
},
|
|
940
|
+
20000,
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
test(
|
|
944
|
+
"gives up waiting for a lagging tracker and finishes",
|
|
945
|
+
async () => {
|
|
946
|
+
let lookups = 0;
|
|
947
|
+
const { server, url } = await startServer(
|
|
948
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
949
|
+
{
|
|
950
|
+
trackerSettle: { attempts: 3, delayMs: 10 },
|
|
951
|
+
tracker: async () => {
|
|
952
|
+
lookups += 1;
|
|
953
|
+
return undefined;
|
|
954
|
+
},
|
|
955
|
+
},
|
|
956
|
+
);
|
|
957
|
+
const client = await connect(url);
|
|
958
|
+
|
|
959
|
+
try {
|
|
960
|
+
client.send({ type: "startLifecycle" });
|
|
961
|
+
await client.waitFor(
|
|
962
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
963
|
+
);
|
|
964
|
+
expect(lookups).toBe(4);
|
|
965
|
+
} finally {
|
|
966
|
+
client.close();
|
|
967
|
+
server.stop(true);
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
20000,
|
|
971
|
+
);
|
|
972
|
+
|
|
973
|
+
test(
|
|
974
|
+
"refuses starts and goal saving during implementation",
|
|
975
|
+
async () => {
|
|
976
|
+
const epic: OpenEpic = {
|
|
977
|
+
number: 9,
|
|
978
|
+
title: "Open epic",
|
|
979
|
+
url: "https://example.test/issues/9",
|
|
980
|
+
};
|
|
981
|
+
const { server, goalFile, logPath, url } = await startServer(
|
|
982
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
983
|
+
{
|
|
984
|
+
agentArguments: "--delay-ms=1200",
|
|
985
|
+
trackerSettle: { attempts: 1, delayMs: 0 },
|
|
986
|
+
tracker: async () => epic,
|
|
987
|
+
laneBackoffMs: 60_000,
|
|
988
|
+
mainPollMs: 60_000,
|
|
989
|
+
},
|
|
990
|
+
);
|
|
991
|
+
const client = await connect(url);
|
|
992
|
+
const originalGoal = await readFile(goalFile, "utf8");
|
|
993
|
+
|
|
994
|
+
try {
|
|
995
|
+
client.send({ type: "startLifecycle" });
|
|
996
|
+
await client.waitFor(
|
|
997
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
const spawned = Date.now() + 5000;
|
|
1001
|
+
while (spawnCount(logPath) < 1 && Date.now() < spawned) await Bun.sleep(25);
|
|
1002
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
1003
|
+
|
|
1004
|
+
client.send({ type: "startLifecycle" });
|
|
1005
|
+
client.send({ type: "startRun" });
|
|
1006
|
+
client.send({ type: "saveGoal", text: "changed" });
|
|
1007
|
+
|
|
1008
|
+
for (const expected of [
|
|
1009
|
+
"A lifecycle run is already active.",
|
|
1010
|
+
"No pending ADRs to start.",
|
|
1011
|
+
"A lifecycle run is active.",
|
|
1012
|
+
]) {
|
|
1013
|
+
await client.waitFor(
|
|
1014
|
+
(message) => message.type === "error" && message.message === expected,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
expect(
|
|
1019
|
+
client.events
|
|
1020
|
+
.filter((message) => message.type === "error")
|
|
1021
|
+
.map((message) => message.message)
|
|
1022
|
+
.sort(),
|
|
1023
|
+
).toEqual(
|
|
1024
|
+
[
|
|
1025
|
+
"A lifecycle run is already active.",
|
|
1026
|
+
"No pending ADRs to start.",
|
|
1027
|
+
"A lifecycle run is active.",
|
|
1028
|
+
].sort(),
|
|
1029
|
+
);
|
|
1030
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
1031
|
+
expect(await readFile(goalFile, "utf8")).toBe(originalGoal);
|
|
1032
|
+
|
|
1033
|
+
await client.waitFor(
|
|
1034
|
+
(message) =>
|
|
1035
|
+
message.type === "error" &&
|
|
1036
|
+
message.message === "Implementation ended but epic #9 is still open.",
|
|
1037
|
+
);
|
|
1038
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
1039
|
+
const lines = prompts(logPath);
|
|
1040
|
+
expect(lines[0]).toBe("/implement-next-epic 9");
|
|
1041
|
+
expect(lines.length).toBe(1);
|
|
1042
|
+
} finally {
|
|
1043
|
+
client.close();
|
|
1044
|
+
server.stop(true);
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
20000,
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
test(
|
|
1051
|
+
"replays implementation state and activity to a reconnecting client",
|
|
1052
|
+
async () => {
|
|
1053
|
+
const epic: OpenEpic = {
|
|
1054
|
+
number: 5,
|
|
1055
|
+
title: "Replay the epic",
|
|
1056
|
+
url: "https://example.test/issues/5",
|
|
1057
|
+
};
|
|
1058
|
+
let lookups = 0;
|
|
1059
|
+
const { server, url } = await startServer(
|
|
1060
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
1061
|
+
{
|
|
1062
|
+
agentArguments: "--delay-ms=1500",
|
|
1063
|
+
tracker: async () => {
|
|
1064
|
+
lookups += 1;
|
|
1065
|
+
return lookups === 1 ? epic : undefined;
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
);
|
|
1069
|
+
const client = await connect(url);
|
|
1070
|
+
|
|
1071
|
+
try {
|
|
1072
|
+
client.send({ type: "startLifecycle" });
|
|
1073
|
+
await client.waitFor(
|
|
1074
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
1075
|
+
);
|
|
1076
|
+
await client.waitFor(
|
|
1077
|
+
(message) => message.type === "activity" && message.text === "Working on epic 5",
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
const late = await connect(url);
|
|
1081
|
+
try {
|
|
1082
|
+
const received = Date.now() + 5000;
|
|
1083
|
+
while (late.events.length < 2 && Date.now() < received) await Bun.sleep(25);
|
|
1084
|
+
expect(late.events[0]).toEqual({
|
|
1085
|
+
type: "lifecycleState",
|
|
1086
|
+
stage: "implementation",
|
|
1087
|
+
pass: 1,
|
|
1088
|
+
work: { kind: "epic", id: "5", url: "https://example.test/issues/5" },
|
|
1089
|
+
paused: false,
|
|
1090
|
+
});
|
|
1091
|
+
const snapshot = late.events.find((message) => message.type === "laneState");
|
|
1092
|
+
expect(snapshot).toMatchObject({
|
|
1093
|
+
openEpics: 1,
|
|
1094
|
+
runningLanes: 1,
|
|
1095
|
+
lanes: [
|
|
1096
|
+
{
|
|
1097
|
+
kind: "implementation",
|
|
1098
|
+
status: "running",
|
|
1099
|
+
epic: { number: 5, title: "Replay the epic" },
|
|
1100
|
+
activity: ["Working on epic 5"],
|
|
1101
|
+
},
|
|
1102
|
+
],
|
|
1103
|
+
});
|
|
1104
|
+
} finally {
|
|
1105
|
+
late.close();
|
|
1106
|
+
}
|
|
1107
|
+
} finally {
|
|
1108
|
+
client.close();
|
|
1109
|
+
server.stop(true);
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
20000,
|
|
1113
|
+
);
|
|
1114
|
+
|
|
1115
|
+
test(
|
|
1116
|
+
"marks a lane retrying without stopping the run when its epic stays open",
|
|
1117
|
+
async () => {
|
|
1118
|
+
const epic: OpenEpic = {
|
|
1119
|
+
number: 11,
|
|
1120
|
+
title: "Still open",
|
|
1121
|
+
url: "https://example.test/issues/11",
|
|
1122
|
+
};
|
|
1123
|
+
const { server, logPath, url } = await startServer(
|
|
1124
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
1125
|
+
{
|
|
1126
|
+
trackerSettle: { attempts: 1, delayMs: 0 },
|
|
1127
|
+
tracker: async () => epic,
|
|
1128
|
+
laneBackoffMs: 60_000,
|
|
1129
|
+
mainPollMs: 60_000,
|
|
1130
|
+
},
|
|
1131
|
+
);
|
|
1132
|
+
const client = await connect(url);
|
|
1133
|
+
|
|
1134
|
+
try {
|
|
1135
|
+
client.send({ type: "startLifecycle" });
|
|
1136
|
+
await client.waitFor(
|
|
1137
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
1138
|
+
);
|
|
1139
|
+
expect(await client.waitFor((message) => message.type === "error")).toMatchObject({
|
|
1140
|
+
message: "Implementation ended but epic #11 is still open.",
|
|
1141
|
+
});
|
|
1142
|
+
expect(
|
|
1143
|
+
await client.waitFor(
|
|
1144
|
+
(message) =>
|
|
1145
|
+
message.type === "laneState" &&
|
|
1146
|
+
Array.isArray(message.lanes) &&
|
|
1147
|
+
message.lanes.some(
|
|
1148
|
+
(lane) =>
|
|
1149
|
+
typeof lane === "object" &&
|
|
1150
|
+
lane !== null &&
|
|
1151
|
+
"status" in lane &&
|
|
1152
|
+
lane.status === "retrying",
|
|
1153
|
+
),
|
|
1154
|
+
),
|
|
1155
|
+
).toMatchObject({ openEpics: 1, runningLanes: 0 });
|
|
1156
|
+
|
|
1157
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
1158
|
+
const lines = prompts(logPath);
|
|
1159
|
+
expect(lines).toEqual(["/implement-next-epic 11"]);
|
|
1160
|
+
expect(
|
|
1161
|
+
lifecycleStates(client).every((message) => message.stage !== "goal"),
|
|
1162
|
+
).toBe(true);
|
|
1163
|
+
} finally {
|
|
1164
|
+
client.close();
|
|
1165
|
+
server.stop(true);
|
|
1166
|
+
}
|
|
1167
|
+
},
|
|
1168
|
+
20000,
|
|
1169
|
+
);
|
|
1170
|
+
|
|
1171
|
+
test(
|
|
1172
|
+
"retries an epic that stayed open in a new lane and finishes when it closes",
|
|
1173
|
+
async () => {
|
|
1174
|
+
const epic: OpenEpic = {
|
|
1175
|
+
number: 33,
|
|
1176
|
+
title: "Still open after a yield",
|
|
1177
|
+
url: "https://example.test/issues/33",
|
|
1178
|
+
};
|
|
1179
|
+
let lookups = 0;
|
|
1180
|
+
const { server, logPath, url } = await startServer(
|
|
1181
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
1182
|
+
{
|
|
1183
|
+
trackerSettle: { attempts: 1, delayMs: 0 },
|
|
1184
|
+
tracker: async () => {
|
|
1185
|
+
lookups += 1;
|
|
1186
|
+
return lookups < 3 ? epic : undefined;
|
|
1187
|
+
},
|
|
1188
|
+
laneBackoffMs: 20,
|
|
1189
|
+
mainPollMs: 60_000,
|
|
1190
|
+
},
|
|
1191
|
+
);
|
|
1192
|
+
const client = await connect(url);
|
|
1193
|
+
|
|
1194
|
+
try {
|
|
1195
|
+
client.send({ type: "startLifecycle" });
|
|
1196
|
+
await client.waitFor(
|
|
1197
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
1198
|
+
);
|
|
1199
|
+
await client.waitFor(
|
|
1200
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
1201
|
+
);
|
|
1202
|
+
|
|
1203
|
+
const lines = prompts(logPath);
|
|
1204
|
+
expect(lines.slice(0, 2)).toEqual([
|
|
1205
|
+
"/implement-next-epic 33",
|
|
1206
|
+
"/implement-next-epic 33",
|
|
1207
|
+
]);
|
|
1208
|
+
expect(lines[2]).toBe("/epic-next");
|
|
1209
|
+
const laneSnapshots = client.events.filter(
|
|
1210
|
+
(message) => message.type === "laneState" && Array.isArray(message.lanes),
|
|
1211
|
+
);
|
|
1212
|
+
const attempts = laneSnapshots.flatMap((message) =>
|
|
1213
|
+
Array.isArray(message.lanes)
|
|
1214
|
+
? message.lanes
|
|
1215
|
+
.filter(
|
|
1216
|
+
(lane): lane is Record<string, unknown> =>
|
|
1217
|
+
typeof lane === "object" && lane !== null && "attempt" in lane,
|
|
1218
|
+
)
|
|
1219
|
+
.map((lane) => lane.attempt)
|
|
1220
|
+
: [],
|
|
1221
|
+
);
|
|
1222
|
+
expect(attempts).toContain(2);
|
|
1223
|
+
} finally {
|
|
1224
|
+
client.close();
|
|
1225
|
+
server.stop(true);
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
20000,
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
test(
|
|
1232
|
+
"retries a lane after the backoff without a second concurrent lane",
|
|
1233
|
+
async () => {
|
|
1234
|
+
const epic: OpenEpic = {
|
|
1235
|
+
number: 34,
|
|
1236
|
+
title: "Retry peer",
|
|
1237
|
+
url: "https://example.test/issues/34",
|
|
1238
|
+
};
|
|
1239
|
+
let lookups = 0;
|
|
1240
|
+
const { server, logPath, url } = await startServer(
|
|
1241
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
1242
|
+
{
|
|
1243
|
+
trackerSettle: { attempts: 1, delayMs: 0 },
|
|
1244
|
+
tracker: async () => {
|
|
1245
|
+
lookups += 1;
|
|
1246
|
+
return lookups < 3 ? epic : undefined;
|
|
1247
|
+
},
|
|
1248
|
+
laneBackoffMs: 20,
|
|
1249
|
+
mainPollMs: 60_000,
|
|
1250
|
+
},
|
|
1251
|
+
);
|
|
1252
|
+
const client = await connect(url);
|
|
1253
|
+
|
|
1254
|
+
try {
|
|
1255
|
+
client.send({ type: "startLifecycle" });
|
|
1256
|
+
await client.waitFor(
|
|
1257
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
1258
|
+
);
|
|
1259
|
+
await client.waitFor(
|
|
1260
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
const lines = prompts(logPath);
|
|
1264
|
+
expect(lines).toEqual([
|
|
1265
|
+
"/implement-next-epic 34",
|
|
1266
|
+
"/implement-next-epic 34",
|
|
1267
|
+
"/epic-next",
|
|
1268
|
+
]);
|
|
1269
|
+
} finally {
|
|
1270
|
+
client.close();
|
|
1271
|
+
server.stop(true);
|
|
1272
|
+
}
|
|
1273
|
+
},
|
|
1274
|
+
20000,
|
|
1275
|
+
);
|
|
1276
|
+
|
|
1277
|
+
test(
|
|
1278
|
+
"returns an aborted run to an editable idle goal state for reconnecting clients",
|
|
1279
|
+
async () => {
|
|
1280
|
+
const epic: OpenEpic = {
|
|
1281
|
+
number: 21,
|
|
1282
|
+
title: "Aborted epic",
|
|
1283
|
+
url: "https://example.test/issues/21",
|
|
1284
|
+
};
|
|
1285
|
+
let epicOpen = true;
|
|
1286
|
+
const { server, goalFile, logPath, url } = await startServer(
|
|
1287
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
1288
|
+
{
|
|
1289
|
+
agentArguments: "--blocker-epic=21",
|
|
1290
|
+
trackerSettle: { attempts: 1, delayMs: 0 },
|
|
1291
|
+
tracker: async () => (epicOpen ? epic : undefined),
|
|
1292
|
+
},
|
|
1293
|
+
);
|
|
1294
|
+
const client = await connect(url);
|
|
1295
|
+
const idle = {
|
|
1296
|
+
type: "lifecycleState",
|
|
1297
|
+
stage: "goal",
|
|
1298
|
+
pass: 1,
|
|
1299
|
+
work: null,
|
|
1300
|
+
paused: false,
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
try {
|
|
1304
|
+
client.send({ type: "startLifecycle" });
|
|
1305
|
+
await client.waitFor(
|
|
1306
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
1307
|
+
);
|
|
1308
|
+
await client.waitFor(
|
|
1309
|
+
(message) =>
|
|
1310
|
+
message.type === "error" &&
|
|
1311
|
+
message.message === "Lifecycle stopped with parked epics: #21.",
|
|
1312
|
+
);
|
|
1313
|
+
await client.waitFor(
|
|
1314
|
+
(message) => message.type === "lifecycleState" && message.stage === "goal",
|
|
1315
|
+
);
|
|
1316
|
+
|
|
1317
|
+
const late = await connect(url);
|
|
1318
|
+
try {
|
|
1319
|
+
expect(await late.waitFor(() => true)).toEqual(idle);
|
|
1320
|
+
expect(late.events[0]).toEqual(idle);
|
|
1321
|
+
expect(lifecycleStates(late)).toEqual([idle]);
|
|
1322
|
+
} finally {
|
|
1323
|
+
late.close();
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const revised = "# Goal\n\nRevised after the abort.\n";
|
|
1327
|
+
client.send({ type: "saveGoal", text: revised });
|
|
1328
|
+
expect(await client.waitFor((message) => message.type === "goal")).toEqual({
|
|
1329
|
+
type: "goal",
|
|
1330
|
+
text: revised,
|
|
1331
|
+
});
|
|
1332
|
+
expect(await readFile(goalFile, "utf8")).toBe(revised);
|
|
1333
|
+
|
|
1334
|
+
epicOpen = false;
|
|
1335
|
+
client.send({ type: "startLifecycle" });
|
|
1336
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
1337
|
+
type: "lifecycleState",
|
|
1338
|
+
stage: "epic",
|
|
1339
|
+
pass: 1,
|
|
1340
|
+
work: null,
|
|
1341
|
+
paused: false,
|
|
1342
|
+
});
|
|
1343
|
+
await client.waitFor(
|
|
1344
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
1345
|
+
);
|
|
1346
|
+
expect(
|
|
1347
|
+
client.events.filter(
|
|
1348
|
+
(message) =>
|
|
1349
|
+
message.type === "error" &&
|
|
1350
|
+
message.message === "A lifecycle run is already active.",
|
|
1351
|
+
),
|
|
1352
|
+
).toEqual([]);
|
|
1353
|
+
const lines = prompts(logPath);
|
|
1354
|
+
expect(lines[0]).toBe("/implement-next-epic 21");
|
|
1355
|
+
expect(lines.at(-1)).toBe("/epic-next");
|
|
1356
|
+
} finally {
|
|
1357
|
+
client.close();
|
|
1358
|
+
server.stop(true);
|
|
1359
|
+
}
|
|
1360
|
+
},
|
|
1361
|
+
20000,
|
|
1362
|
+
);
|
|
1363
|
+
|
|
1364
|
+
test(
|
|
1365
|
+
"returns to the idle goal state when epic generation leaves the same ADRs pending",
|
|
1366
|
+
async () => {
|
|
1367
|
+
const { server, logPath, url } = await startServer(
|
|
1368
|
+
{ "0001-first.md": PENDING_ONE },
|
|
1369
|
+
{ agentArguments: "--no-resolve --questions=0" },
|
|
1370
|
+
);
|
|
1371
|
+
const client = await connect(url);
|
|
1372
|
+
|
|
1373
|
+
try {
|
|
1374
|
+
client.send({ type: "startLifecycle" });
|
|
1375
|
+
await client.waitFor(
|
|
1376
|
+
(message) => message.type === "lifecycleState" && message.stage === "epic",
|
|
1377
|
+
);
|
|
1378
|
+
await client.waitFor(
|
|
1379
|
+
(message) =>
|
|
1380
|
+
message.type === "error" &&
|
|
1381
|
+
message.message ===
|
|
1382
|
+
"Epic generation left the same ADRs pending without opening an epic.",
|
|
1383
|
+
);
|
|
1384
|
+
expect(
|
|
1385
|
+
await client.waitFor(
|
|
1386
|
+
(message) => message.type === "lifecycleState" && message.stage === "goal",
|
|
1387
|
+
),
|
|
1388
|
+
).toEqual({
|
|
1389
|
+
type: "lifecycleState",
|
|
1390
|
+
stage: "goal",
|
|
1391
|
+
pass: 1,
|
|
1392
|
+
work: null,
|
|
1393
|
+
paused: false,
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
await Bun.sleep(300);
|
|
1397
|
+
expect(spawnCount(logPath)).toBe(3);
|
|
1398
|
+
expect(prompts(logPath)).toEqual([
|
|
1399
|
+
"/epic-next",
|
|
1400
|
+
expect.stringContaining("/adr-resolve"),
|
|
1401
|
+
"/epic-next",
|
|
1402
|
+
]);
|
|
1403
|
+
} finally {
|
|
1404
|
+
client.close();
|
|
1405
|
+
server.stop(true);
|
|
1406
|
+
}
|
|
1407
|
+
},
|
|
1408
|
+
20000,
|
|
1409
|
+
);
|
|
1410
|
+
|
|
1411
|
+
test(
|
|
1412
|
+
"returns to the idle goal state when a tracker query throws",
|
|
1413
|
+
async () => {
|
|
1414
|
+
const { server, logPath, url } = await startServer(
|
|
1415
|
+
{},
|
|
1416
|
+
{
|
|
1417
|
+
tracker: async () => {
|
|
1418
|
+
throw new Error("tracker unavailable");
|
|
1419
|
+
},
|
|
1420
|
+
},
|
|
1421
|
+
);
|
|
1422
|
+
const client = await connect(url);
|
|
1423
|
+
|
|
1424
|
+
try {
|
|
1425
|
+
client.send({ type: "startLifecycle" });
|
|
1426
|
+
expect(await client.waitFor((message) => message.type === "error")).toEqual({
|
|
1427
|
+
type: "error",
|
|
1428
|
+
message: "tracker unavailable",
|
|
1429
|
+
});
|
|
1430
|
+
expect(
|
|
1431
|
+
await client.waitFor(
|
|
1432
|
+
(message) => message.type === "lifecycleState" && message.stage === "goal",
|
|
1433
|
+
),
|
|
1434
|
+
).toEqual({
|
|
1435
|
+
type: "lifecycleState",
|
|
1436
|
+
stage: "goal",
|
|
1437
|
+
pass: 1,
|
|
1438
|
+
work: null,
|
|
1439
|
+
paused: false,
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
await Bun.sleep(300);
|
|
1443
|
+
expect(spawnCount(logPath)).toBe(0);
|
|
1444
|
+
expect(lifecycleStates(client)).toEqual([
|
|
1445
|
+
{ type: "lifecycleState", stage: "epic", pass: 1, work: null, paused: false },
|
|
1446
|
+
{
|
|
1447
|
+
type: "lifecycleState",
|
|
1448
|
+
stage: "goal",
|
|
1449
|
+
pass: 1,
|
|
1450
|
+
work: null,
|
|
1451
|
+
paused: false,
|
|
1452
|
+
},
|
|
1453
|
+
]);
|
|
1454
|
+
} finally {
|
|
1455
|
+
client.close();
|
|
1456
|
+
server.stop(true);
|
|
1457
|
+
}
|
|
1458
|
+
},
|
|
1459
|
+
20000,
|
|
1460
|
+
);
|
|
1461
|
+
|
|
1462
|
+
test(
|
|
1463
|
+
"implements the epic the generation peer creates and retries when it stays open",
|
|
1464
|
+
async () => {
|
|
1465
|
+
let lookups = 0;
|
|
1466
|
+
const epic: OpenEpic = {
|
|
1467
|
+
number: 7,
|
|
1468
|
+
title: "Generated epic",
|
|
1469
|
+
url: "https://example.test/issues/7",
|
|
1470
|
+
};
|
|
1471
|
+
const { server, logPath, url } = await startServer(
|
|
1472
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
1473
|
+
{
|
|
1474
|
+
trackerSettle: { attempts: 1, delayMs: 0 },
|
|
1475
|
+
tracker: async () => {
|
|
1476
|
+
lookups += 1;
|
|
1477
|
+
return lookups === 1 ? undefined : epic;
|
|
1478
|
+
},
|
|
1479
|
+
laneBackoffMs: 60_000,
|
|
1480
|
+
mainPollMs: 60_000,
|
|
1481
|
+
},
|
|
1482
|
+
);
|
|
1483
|
+
const client = await connect(url);
|
|
1484
|
+
|
|
1485
|
+
try {
|
|
1486
|
+
client.send({ type: "startLifecycle" });
|
|
1487
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toMatchObject({
|
|
1488
|
+
stage: "epic",
|
|
1489
|
+
pass: 1,
|
|
1490
|
+
work: null,
|
|
1491
|
+
});
|
|
1492
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toMatchObject({
|
|
1493
|
+
stage: "implementation",
|
|
1494
|
+
pass: 1,
|
|
1495
|
+
work: { kind: "epic", id: "7", url: "https://example.test/issues/7" },
|
|
1496
|
+
});
|
|
1497
|
+
expect(await client.waitFor((message) => message.type === "error")).toMatchObject({
|
|
1498
|
+
message: "Implementation ended but epic #7 is still open.",
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
const started = Date.now() + 5000;
|
|
1502
|
+
while (spawnCount(logPath) < 2 && Date.now() < started) await Bun.sleep(25);
|
|
1503
|
+
expect(spawnCount(logPath)).toBe(2);
|
|
1504
|
+
const lines = prompts(logPath);
|
|
1505
|
+
expect(lines[0]).toBe("/epic-next");
|
|
1506
|
+
expect(lines[1]).toBe("/implement-next-epic 7");
|
|
1507
|
+
expect(lines).toHaveLength(2);
|
|
1508
|
+
} finally {
|
|
1509
|
+
client.close();
|
|
1510
|
+
server.stop(true);
|
|
1511
|
+
}
|
|
1512
|
+
},
|
|
1513
|
+
20000,
|
|
1514
|
+
);
|
|
1515
|
+
|
|
1516
|
+
test(
|
|
1517
|
+
"replays the current lifecycle state first to a client that connects during a stage",
|
|
1518
|
+
async () => {
|
|
1519
|
+
const { server, logPath, url } = await startServer(
|
|
1520
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
1521
|
+
{ agentArguments: "--delay-ms=1200" },
|
|
1522
|
+
);
|
|
1523
|
+
const client = await connect(url);
|
|
1524
|
+
|
|
1525
|
+
try {
|
|
1526
|
+
client.send({ type: "startLifecycle" });
|
|
1527
|
+
await client.waitFor((message) => message.type === "lifecycleState");
|
|
1528
|
+
|
|
1529
|
+
const late = await connect(url);
|
|
1530
|
+
try {
|
|
1531
|
+
expect(await late.waitFor(() => true)).toMatchObject({
|
|
1532
|
+
type: "lifecycleState",
|
|
1533
|
+
stage: "epic",
|
|
1534
|
+
pass: 1,
|
|
1535
|
+
work: null,
|
|
1536
|
+
});
|
|
1537
|
+
expect(late.events[0]).toMatchObject({
|
|
1538
|
+
type: "lifecycleState",
|
|
1539
|
+
stage: "epic",
|
|
1540
|
+
pass: 1,
|
|
1541
|
+
work: null,
|
|
1542
|
+
});
|
|
1543
|
+
} finally {
|
|
1544
|
+
late.close();
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
await client.waitFor(
|
|
1548
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
1549
|
+
);
|
|
1550
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
1551
|
+
} finally {
|
|
1552
|
+
client.close();
|
|
1553
|
+
server.stop(true);
|
|
1554
|
+
}
|
|
1555
|
+
},
|
|
1556
|
+
20000,
|
|
1557
|
+
);
|
|
1558
|
+
|
|
1559
|
+
test(
|
|
1560
|
+
"replays the finished state first to a client that connects after the run ends",
|
|
1561
|
+
async () => {
|
|
1562
|
+
const { server, url } = await startServer({});
|
|
1563
|
+
const client = await connect(url);
|
|
1564
|
+
|
|
1565
|
+
try {
|
|
1566
|
+
client.send({ type: "startLifecycle" });
|
|
1567
|
+
const finished = await client.waitFor(
|
|
1568
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
1569
|
+
);
|
|
1570
|
+
expect(finished).toEqual({
|
|
1571
|
+
type: "lifecycleState",
|
|
1572
|
+
stage: "finished",
|
|
1573
|
+
pass: 1,
|
|
1574
|
+
work: null,
|
|
1575
|
+
paused: false,
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
const late = await connect(url);
|
|
1579
|
+
try {
|
|
1580
|
+
late.send({ type: "listRecords" });
|
|
1581
|
+
late.send({ type: "getGoal" });
|
|
1582
|
+
await late.waitFor((message) => message.type === "records");
|
|
1583
|
+
await late.waitFor((message) => message.type === "goal");
|
|
1584
|
+
|
|
1585
|
+
expect(late.events[0]).toEqual({
|
|
1586
|
+
type: "lifecycleState",
|
|
1587
|
+
stage: "finished",
|
|
1588
|
+
pass: 1,
|
|
1589
|
+
work: null,
|
|
1590
|
+
paused: false,
|
|
1591
|
+
});
|
|
1592
|
+
expect(lifecycleStates(late)).toHaveLength(1);
|
|
1593
|
+
} finally {
|
|
1594
|
+
late.close();
|
|
1595
|
+
}
|
|
1596
|
+
} finally {
|
|
1597
|
+
client.close();
|
|
1598
|
+
server.stop(true);
|
|
1599
|
+
}
|
|
1600
|
+
},
|
|
1601
|
+
20000,
|
|
1602
|
+
);
|
|
1603
|
+
|
|
1604
|
+
test(
|
|
1605
|
+
"sends no lifecycle state to a client when no lifecycle run has ever started",
|
|
1606
|
+
async () => {
|
|
1607
|
+
const { server, url } = await startServer({});
|
|
1608
|
+
const client = await connect(url);
|
|
1609
|
+
|
|
1610
|
+
try {
|
|
1611
|
+
client.send({ type: "listRecords" });
|
|
1612
|
+
client.send({ type: "getGoal" });
|
|
1613
|
+
await client.waitFor((message) => message.type === "records");
|
|
1614
|
+
await client.waitFor((message) => message.type === "goal");
|
|
1615
|
+
|
|
1616
|
+
await Bun.sleep(200);
|
|
1617
|
+
expect(lifecycleStates(client)).toEqual([]);
|
|
1618
|
+
} finally {
|
|
1619
|
+
client.close();
|
|
1620
|
+
server.stop(true);
|
|
1621
|
+
}
|
|
1622
|
+
},
|
|
1623
|
+
20000,
|
|
1624
|
+
);
|
|
1625
|
+
|
|
1626
|
+
test(
|
|
1627
|
+
"replays the new run's stage, not the finished one, to a client that connects later",
|
|
1628
|
+
async () => {
|
|
1629
|
+
const { server, url } = await startServer({}, { agentArguments: "--delay-ms=1200" });
|
|
1630
|
+
const client = await connect(url);
|
|
1631
|
+
|
|
1632
|
+
try {
|
|
1633
|
+
client.send({ type: "startLifecycle" });
|
|
1634
|
+
await client.waitFor(
|
|
1635
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
1636
|
+
);
|
|
1637
|
+
|
|
1638
|
+
const reported = lifecycleStates(client).length;
|
|
1639
|
+
client.send({ type: "startLifecycle" });
|
|
1640
|
+
for (let attempt = 0; lifecycleStates(client).length === reported; attempt += 1) {
|
|
1641
|
+
if (attempt > 500) throw new Error("The second lifecycle run reported no state.");
|
|
1642
|
+
await Bun.sleep(10);
|
|
1643
|
+
}
|
|
1644
|
+
expect(lifecycleStates(client)[reported]).toEqual({
|
|
1645
|
+
type: "lifecycleState",
|
|
1646
|
+
stage: "epic",
|
|
1647
|
+
pass: 1,
|
|
1648
|
+
work: null,
|
|
1649
|
+
paused: false,
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
const late = await connect(url);
|
|
1653
|
+
try {
|
|
1654
|
+
expect(await late.waitFor(() => true)).toEqual({
|
|
1655
|
+
type: "lifecycleState",
|
|
1656
|
+
stage: "epic",
|
|
1657
|
+
pass: 1,
|
|
1658
|
+
work: null,
|
|
1659
|
+
paused: false,
|
|
1660
|
+
});
|
|
1661
|
+
expect(late.events[0]).not.toMatchObject({ stage: "finished" });
|
|
1662
|
+
} finally {
|
|
1663
|
+
late.close();
|
|
1664
|
+
}
|
|
1665
|
+
} finally {
|
|
1666
|
+
client.close();
|
|
1667
|
+
server.stop(true);
|
|
1668
|
+
}
|
|
1669
|
+
},
|
|
1670
|
+
20000,
|
|
1671
|
+
);
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
describe("lifecycle goal precondition", () => {
|
|
1675
|
+
test(
|
|
1676
|
+
"refuses startLifecycle when the goal document is absent",
|
|
1677
|
+
async () => {
|
|
1678
|
+
let lookups = 0;
|
|
1679
|
+
const { server, logPath, url } = await startServer(
|
|
1680
|
+
{},
|
|
1681
|
+
{
|
|
1682
|
+
goalText: null,
|
|
1683
|
+
tracker: async () => {
|
|
1684
|
+
lookups += 1;
|
|
1685
|
+
return undefined;
|
|
1686
|
+
},
|
|
1687
|
+
},
|
|
1688
|
+
);
|
|
1689
|
+
const client = await connect(url);
|
|
1690
|
+
|
|
1691
|
+
try {
|
|
1692
|
+
client.send({ type: "startLifecycle" });
|
|
1693
|
+
const error = await client.waitFor((message) => message.type === "error");
|
|
1694
|
+
expect(String(error.message)).toMatch(/goal/i);
|
|
1695
|
+
|
|
1696
|
+
await Bun.sleep(300);
|
|
1697
|
+
expect(client.events.filter((message) => message.type === "error")).toHaveLength(1);
|
|
1698
|
+
expect(lifecycleStates(client)).toEqual([]);
|
|
1699
|
+
expect(spawnCount(logPath)).toBe(0);
|
|
1700
|
+
expect(lookups).toBe(0);
|
|
1701
|
+
} finally {
|
|
1702
|
+
client.close();
|
|
1703
|
+
server.stop(true);
|
|
1704
|
+
}
|
|
1705
|
+
},
|
|
1706
|
+
20000,
|
|
1707
|
+
);
|
|
1708
|
+
|
|
1709
|
+
test(
|
|
1710
|
+
"refuses startLifecycle when the goal document is only whitespace",
|
|
1711
|
+
async () => {
|
|
1712
|
+
const { server, logPath, url } = await startServer({}, { goalText: " \n\t\n" });
|
|
1713
|
+
const client = await connect(url);
|
|
1714
|
+
|
|
1715
|
+
try {
|
|
1716
|
+
client.send({ type: "startLifecycle" });
|
|
1717
|
+
const error = await client.waitFor((message) => message.type === "error");
|
|
1718
|
+
expect(String(error.message)).toMatch(/goal/i);
|
|
1719
|
+
|
|
1720
|
+
await Bun.sleep(300);
|
|
1721
|
+
expect(client.events.filter((message) => message.type === "error")).toHaveLength(1);
|
|
1722
|
+
expect(lifecycleStates(client)).toEqual([]);
|
|
1723
|
+
expect(spawnCount(logPath)).toBe(0);
|
|
1724
|
+
} finally {
|
|
1725
|
+
client.close();
|
|
1726
|
+
server.stop(true);
|
|
1727
|
+
}
|
|
1728
|
+
},
|
|
1729
|
+
20000,
|
|
1730
|
+
);
|
|
1731
|
+
|
|
1732
|
+
test(
|
|
1733
|
+
"refuses a start after a blank goal is saved and accepts a non-blank replacement",
|
|
1734
|
+
async () => {
|
|
1735
|
+
const { server, logPath, url } = await startServer({});
|
|
1736
|
+
const client = await connect(url);
|
|
1737
|
+
|
|
1738
|
+
try {
|
|
1739
|
+
client.send({ type: "saveGoal", text: "" });
|
|
1740
|
+
await client.waitFor((message) => message.type === "goal" && message.text === "");
|
|
1741
|
+
|
|
1742
|
+
client.send({ type: "startLifecycle" });
|
|
1743
|
+
const error = await client.waitFor((message) => message.type === "error");
|
|
1744
|
+
expect(String(error.message)).toMatch(/goal/i);
|
|
1745
|
+
await Bun.sleep(300);
|
|
1746
|
+
expect(lifecycleStates(client)).toEqual([]);
|
|
1747
|
+
expect(spawnCount(logPath)).toBe(0);
|
|
1748
|
+
|
|
1749
|
+
client.send({ type: "saveGoal", text: "# Goal\n\nShip the interface.\n" });
|
|
1750
|
+
await client.waitFor(
|
|
1751
|
+
(message) =>
|
|
1752
|
+
message.type === "goal" && String(message.text).includes("Ship the interface."),
|
|
1753
|
+
);
|
|
1754
|
+
client.send({ type: "startLifecycle" });
|
|
1755
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
1756
|
+
type: "lifecycleState",
|
|
1757
|
+
stage: "epic",
|
|
1758
|
+
pass: 1,
|
|
1759
|
+
work: null,
|
|
1760
|
+
paused: false,
|
|
1761
|
+
});
|
|
1762
|
+
await client.waitFor(
|
|
1763
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
1764
|
+
);
|
|
1765
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
1766
|
+
} finally {
|
|
1767
|
+
client.close();
|
|
1768
|
+
server.stop(true);
|
|
1769
|
+
}
|
|
1770
|
+
},
|
|
1771
|
+
20000,
|
|
1772
|
+
);
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
describe("lifecycle skills precondition", () => {
|
|
1776
|
+
test(
|
|
1777
|
+
"refuses startLifecycle when no skills are installed",
|
|
1778
|
+
async () => {
|
|
1779
|
+
let lookups = 0;
|
|
1780
|
+
const { server, logPath, url } = await startServer(
|
|
1781
|
+
{},
|
|
1782
|
+
{
|
|
1783
|
+
installedSkills: "none",
|
|
1784
|
+
tracker: async () => {
|
|
1785
|
+
lookups += 1;
|
|
1786
|
+
return undefined;
|
|
1787
|
+
},
|
|
1788
|
+
},
|
|
1789
|
+
);
|
|
1790
|
+
const client = await connect(url);
|
|
1791
|
+
|
|
1792
|
+
try {
|
|
1793
|
+
client.send({ type: "startLifecycle" });
|
|
1794
|
+
const error = await client.waitFor((message) => message.type === "error");
|
|
1795
|
+
expect(String(error.message)).toMatch(/skills/i);
|
|
1796
|
+
|
|
1797
|
+
await Bun.sleep(300);
|
|
1798
|
+
expect(client.events.filter((message) => message.type === "error")).toHaveLength(1);
|
|
1799
|
+
expect(lifecycleStates(client)).toEqual([]);
|
|
1800
|
+
expect(spawnCount(logPath)).toBe(0);
|
|
1801
|
+
expect(lookups).toBe(0);
|
|
1802
|
+
} finally {
|
|
1803
|
+
client.close();
|
|
1804
|
+
server.stop(true);
|
|
1805
|
+
}
|
|
1806
|
+
},
|
|
1807
|
+
20000,
|
|
1808
|
+
);
|
|
1809
|
+
|
|
1810
|
+
test(
|
|
1811
|
+
"refuses startLifecycle when only an unmanaged skill is installed",
|
|
1812
|
+
async () => {
|
|
1813
|
+
const { server, logPath, url } = await startServer(
|
|
1814
|
+
{},
|
|
1815
|
+
{ installedSkills: "unmanaged" },
|
|
1816
|
+
);
|
|
1817
|
+
const client = await connect(url);
|
|
1818
|
+
|
|
1819
|
+
try {
|
|
1820
|
+
client.send({ type: "startLifecycle" });
|
|
1821
|
+
const error = await client.waitFor((message) => message.type === "error");
|
|
1822
|
+
expect(String(error.message)).toMatch(/skills/i);
|
|
1823
|
+
|
|
1824
|
+
await Bun.sleep(300);
|
|
1825
|
+
expect(client.events.filter((message) => message.type === "error")).toHaveLength(1);
|
|
1826
|
+
expect(lifecycleStates(client)).toEqual([]);
|
|
1827
|
+
expect(spawnCount(logPath)).toBe(0);
|
|
1828
|
+
} finally {
|
|
1829
|
+
client.close();
|
|
1830
|
+
server.stop(true);
|
|
1831
|
+
}
|
|
1832
|
+
},
|
|
1833
|
+
20000,
|
|
1834
|
+
);
|
|
1835
|
+
|
|
1836
|
+
test(
|
|
1837
|
+
"accepts startLifecycle once a managed skill is installed",
|
|
1838
|
+
async () => {
|
|
1839
|
+
const { server, root, logPath, url } = await startServer(
|
|
1840
|
+
{},
|
|
1841
|
+
{ installedSkills: "none" },
|
|
1842
|
+
);
|
|
1843
|
+
const client = await connect(url);
|
|
1844
|
+
|
|
1845
|
+
try {
|
|
1846
|
+
client.send({ type: "startLifecycle" });
|
|
1847
|
+
const error = await client.waitFor((message) => message.type === "error");
|
|
1848
|
+
expect(String(error.message)).toMatch(/skills/i);
|
|
1849
|
+
expect(spawnCount(logPath)).toBe(0);
|
|
1850
|
+
|
|
1851
|
+
await mkdir(join(root, ".omp", "skills", "behavior-first-testing"), {
|
|
1852
|
+
recursive: true,
|
|
1853
|
+
});
|
|
1854
|
+
await writeFile(
|
|
1855
|
+
join(root, ".omp", "skills", "behavior-first-testing", "SKILL.md"),
|
|
1856
|
+
MANAGED_SKILL,
|
|
1857
|
+
);
|
|
1858
|
+
|
|
1859
|
+
client.send({ type: "startLifecycle" });
|
|
1860
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
1861
|
+
type: "lifecycleState",
|
|
1862
|
+
stage: "epic",
|
|
1863
|
+
pass: 1,
|
|
1864
|
+
work: null,
|
|
1865
|
+
paused: false,
|
|
1866
|
+
});
|
|
1867
|
+
await client.waitFor(
|
|
1868
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
1869
|
+
);
|
|
1870
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
1871
|
+
expect(prompts(logPath)).toEqual(["/epic-next"]);
|
|
1872
|
+
} finally {
|
|
1873
|
+
client.close();
|
|
1874
|
+
server.stop(true);
|
|
1875
|
+
}
|
|
1876
|
+
},
|
|
1877
|
+
20000,
|
|
1878
|
+
);
|
|
1879
|
+
});
|