@dsdenes/devboard 0.1.559 → 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,1795 @@
|
|
|
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 QUESTION_OPTIONS = [
|
|
27
|
+
{ label: "Alpha", description: "Alpha leads to the first outcome.", recommended: true },
|
|
28
|
+
{ label: "Beta", description: "Beta leads to the second outcome.", recommended: false },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
interface TestServer {
|
|
32
|
+
server: Server<unknown>;
|
|
33
|
+
root: string;
|
|
34
|
+
adrDir: string;
|
|
35
|
+
goalFile: string;
|
|
36
|
+
logPath: string;
|
|
37
|
+
url: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function temporaryDirectory() {
|
|
41
|
+
const directory = await mkdtemp(join(tmpdir(), "devboard-lifecycle-pause-"));
|
|
42
|
+
temporaryDirectories.push(directory);
|
|
43
|
+
return directory;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function startServer(
|
|
47
|
+
files: Record<string, string>,
|
|
48
|
+
options: {
|
|
49
|
+
agentArguments?: string;
|
|
50
|
+
tracker?: EpicLookup;
|
|
51
|
+
reviseGoal?: boolean;
|
|
52
|
+
root?: string;
|
|
53
|
+
} = {},
|
|
54
|
+
): Promise<TestServer> {
|
|
55
|
+
const root = options.root ?? (await temporaryDirectory());
|
|
56
|
+
const adrDir = join(root, "adrs");
|
|
57
|
+
const staticDir = join(root, "static");
|
|
58
|
+
const goalFile = join(root, "GOAL.md");
|
|
59
|
+
await mkdir(adrDir, { recursive: true });
|
|
60
|
+
await mkdir(staticDir, { recursive: true });
|
|
61
|
+
await writeFile(join(staticDir, "index.html"), "<!doctype html><title>ADR overview</title>");
|
|
62
|
+
await writeFile(goalFile, "# Goal\n\nShip the lifecycle.\n");
|
|
63
|
+
await mkdir(join(root, ".omp", "skills", "behavior-first-testing"), { recursive: true });
|
|
64
|
+
await writeFile(
|
|
65
|
+
join(root, ".omp", "skills", "behavior-first-testing", "SKILL.md"),
|
|
66
|
+
MANAGED_SKILL,
|
|
67
|
+
);
|
|
68
|
+
for (const [file, source] of Object.entries(files)) {
|
|
69
|
+
await writeFile(join(adrDir, file), source);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const logPath = join(root, "agent.log");
|
|
73
|
+
const command = [
|
|
74
|
+
`bun ${STUB_AGENT}`,
|
|
75
|
+
`--log=${logPath}`,
|
|
76
|
+
options.reviseGoal === true ? `--revise-goal --goal=${goalFile}` : "",
|
|
77
|
+
options.agentArguments ?? "",
|
|
78
|
+
]
|
|
79
|
+
.filter((part) => part.length > 0)
|
|
80
|
+
.join(" ");
|
|
81
|
+
const server = createApp({
|
|
82
|
+
adrDir,
|
|
83
|
+
staticDir,
|
|
84
|
+
hostname: "127.0.0.1",
|
|
85
|
+
port: 0,
|
|
86
|
+
repoDir: root,
|
|
87
|
+
stateDir: join(root, "state"),
|
|
88
|
+
goalFile,
|
|
89
|
+
agentCommand: command,
|
|
90
|
+
tracker: options.tracker ?? (async () => undefined),
|
|
91
|
+
trackerSettle: { attempts: 1 },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return { server, root, adrDir, goalFile, logPath, url: `127.0.0.1:${server.port}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface TestClient {
|
|
98
|
+
socket: WebSocket;
|
|
99
|
+
events: Record<string, unknown>[];
|
|
100
|
+
send(command: unknown): void;
|
|
101
|
+
waitFor(predicate: (message: Record<string, unknown>) => boolean): Promise<Record<string, unknown>>;
|
|
102
|
+
close(): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function connect(url: string): Promise<TestClient> {
|
|
106
|
+
const { promise, resolve, reject } = Promise.withResolvers<TestClient>();
|
|
107
|
+
const socket = new WebSocket(`ws://${url}/ws`);
|
|
108
|
+
const events: Record<string, unknown>[] = [];
|
|
109
|
+
const buffered: Record<string, unknown>[] = [];
|
|
110
|
+
const waiters: {
|
|
111
|
+
predicate: (message: Record<string, unknown>) => boolean;
|
|
112
|
+
resolve: (message: Record<string, unknown>) => void;
|
|
113
|
+
}[] = [];
|
|
114
|
+
|
|
115
|
+
socket.onopen = () => {
|
|
116
|
+
resolve({
|
|
117
|
+
socket,
|
|
118
|
+
events,
|
|
119
|
+
send(command) {
|
|
120
|
+
socket.send(JSON.stringify(command));
|
|
121
|
+
},
|
|
122
|
+
waitFor(predicate) {
|
|
123
|
+
const index = buffered.findIndex(predicate);
|
|
124
|
+
if (index >= 0) {
|
|
125
|
+
const message = buffered.splice(index, 1)[0];
|
|
126
|
+
if (message !== undefined) return Promise.resolve(message);
|
|
127
|
+
}
|
|
128
|
+
const { promise: waiting, resolve: settle } =
|
|
129
|
+
Promise.withResolvers<Record<string, unknown>>();
|
|
130
|
+
waiters.push({ predicate, resolve: settle });
|
|
131
|
+
return waiting;
|
|
132
|
+
},
|
|
133
|
+
close() {
|
|
134
|
+
socket.close();
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
socket.onmessage = (event) => {
|
|
140
|
+
let message: Record<string, unknown>;
|
|
141
|
+
try {
|
|
142
|
+
message = JSON.parse(String(event.data)) as Record<string, unknown>;
|
|
143
|
+
} catch {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
events.push(message);
|
|
147
|
+
const index = waiters.findIndex((waiter) => waiter.predicate(message));
|
|
148
|
+
if (index >= 0) {
|
|
149
|
+
const waiter = waiters.splice(index, 1)[0];
|
|
150
|
+
if (waiter !== undefined) waiter.resolve(message);
|
|
151
|
+
} else {
|
|
152
|
+
buffered.push(message);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
socket.onerror = () => reject(new Error("websocket failed"));
|
|
157
|
+
|
|
158
|
+
return promise;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function logLines(logPath: string): string[] {
|
|
162
|
+
if (!existsSync(logPath)) return [];
|
|
163
|
+
return readFileSync(logPath, "utf8").split("\n").filter((line) => line.length > 0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function spawnCount(logPath: string): number {
|
|
167
|
+
return logLines(logPath).filter((line) => line.startsWith("spawn ")).length;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function prompts(logPath: string): string[] {
|
|
171
|
+
return logLines(logPath)
|
|
172
|
+
.filter((line) => line.startsWith("prompt "))
|
|
173
|
+
.map((line) => line.slice("prompt ".length));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function lifecycleStates(client: TestClient): Record<string, unknown>[] {
|
|
177
|
+
return client.events.filter((message) => message.type === "lifecycleState");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function waitUntil(
|
|
181
|
+
predicate: () => boolean,
|
|
182
|
+
message: string,
|
|
183
|
+
timeout = 5000,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const deadline = Date.now() + timeout;
|
|
186
|
+
while (!predicate() && Date.now() < deadline) await Bun.sleep(25);
|
|
187
|
+
if (!predicate()) throw new Error(message);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function indexAfter(
|
|
191
|
+
events: Record<string, unknown>[],
|
|
192
|
+
start: number,
|
|
193
|
+
predicate: (message: Record<string, unknown>) => boolean,
|
|
194
|
+
): number {
|
|
195
|
+
const offset = events.slice(start).findIndex(predicate);
|
|
196
|
+
return offset < 0 ? -1 : start + offset;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function waitForIndex(
|
|
200
|
+
client: TestClient,
|
|
201
|
+
start: number,
|
|
202
|
+
predicate: (message: Record<string, unknown>) => boolean,
|
|
203
|
+
message: string,
|
|
204
|
+
timeout = 5000,
|
|
205
|
+
): Promise<number> {
|
|
206
|
+
let index = -1;
|
|
207
|
+
await waitUntil(() => (index = indexAfter(client.events, start, predicate)) >= 0, message, timeout);
|
|
208
|
+
return index;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function waitForAt(
|
|
212
|
+
client: TestClient,
|
|
213
|
+
start: number,
|
|
214
|
+
predicate: (message: Record<string, unknown>) => boolean,
|
|
215
|
+
message: string,
|
|
216
|
+
timeout = 5000,
|
|
217
|
+
): Promise<Record<string, unknown>> {
|
|
218
|
+
const index = await waitForIndex(client, start, predicate, message, timeout);
|
|
219
|
+
return client.events[index] as Record<string, unknown>;
|
|
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
|
+
|
|
225
|
+
/** Covers the revision start/stop and replay, then resumes the suspended peer. */
|
|
226
|
+
async function exercisePausedRevision(request: string | undefined) {
|
|
227
|
+
const epic: OpenEpic = {
|
|
228
|
+
number: 21,
|
|
229
|
+
title: "Revision while paused",
|
|
230
|
+
url: "https://example.test/issues/21",
|
|
231
|
+
};
|
|
232
|
+
let lookups = 0;
|
|
233
|
+
const { server, goalFile, logPath, url } = await startServer(
|
|
234
|
+
{},
|
|
235
|
+
{
|
|
236
|
+
agentArguments: "--delay-ms=1000 --questions=1",
|
|
237
|
+
reviseGoal: true,
|
|
238
|
+
tracker: async () => {
|
|
239
|
+
lookups += 1;
|
|
240
|
+
return lookups === 1 ? epic : undefined;
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
const client = await connect(url);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
client.send({ type: "startLifecycle" });
|
|
248
|
+
expect(
|
|
249
|
+
await client.waitFor((message) => message.type === "lifecycleState"),
|
|
250
|
+
).toEqual({
|
|
251
|
+
type: "lifecycleState",
|
|
252
|
+
stage: "epic",
|
|
253
|
+
pass: 1,
|
|
254
|
+
work: null,
|
|
255
|
+
paused: false,
|
|
256
|
+
});
|
|
257
|
+
expect(
|
|
258
|
+
await client.waitFor(
|
|
259
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
260
|
+
),
|
|
261
|
+
).toEqual({
|
|
262
|
+
type: "lifecycleState",
|
|
263
|
+
stage: "implementation",
|
|
264
|
+
pass: 1,
|
|
265
|
+
work: { kind: "epic", id: "21", url: "https://example.test/issues/21" },
|
|
266
|
+
paused: false,
|
|
267
|
+
});
|
|
268
|
+
await waitUntil(() => spawnCount(logPath) === 1, "the lifecycle peer did not spawn");
|
|
269
|
+
expect(prompts(logPath)).toEqual(["/implement-next-epic 21"]);
|
|
270
|
+
|
|
271
|
+
client.send({ type: "pauseLifecycle" });
|
|
272
|
+
await client.waitFor(
|
|
273
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
274
|
+
);
|
|
275
|
+
const pauseIndex = client.events.length;
|
|
276
|
+
const lifecycleActivity = client.events
|
|
277
|
+
.filter((message) => message.type === "activity" && message.lane !== undefined)
|
|
278
|
+
.map((message) => message.text);
|
|
279
|
+
expect(lifecycleActivity).toEqual(["Working on epic 21"]);
|
|
280
|
+
|
|
281
|
+
client.send(
|
|
282
|
+
request === undefined
|
|
283
|
+
? { type: "reviseGoal" }
|
|
284
|
+
: { type: "reviseGoal", request },
|
|
285
|
+
);
|
|
286
|
+
const startedIndex = await waitForIndex(
|
|
287
|
+
client,
|
|
288
|
+
pauseIndex,
|
|
289
|
+
(message) => message.type === "goalRevisionStarted",
|
|
290
|
+
"the revision did not start while paused",
|
|
291
|
+
);
|
|
292
|
+
await waitForIndex(
|
|
293
|
+
client,
|
|
294
|
+
startedIndex,
|
|
295
|
+
(message) => message.type === "activity" && message.text === "Revising the goal",
|
|
296
|
+
"the revision activity was not relayed",
|
|
297
|
+
);
|
|
298
|
+
const revisionQuestion = await waitForAt(
|
|
299
|
+
client,
|
|
300
|
+
startedIndex,
|
|
301
|
+
(message) => message.type === "question",
|
|
302
|
+
"the revision question was not relayed",
|
|
303
|
+
);
|
|
304
|
+
expect(revisionQuestion).toMatchObject({
|
|
305
|
+
id: 1,
|
|
306
|
+
message: "Choose an answer",
|
|
307
|
+
options: QUESTION_OPTIONS,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(spawnCount(logPath)).toBe(2);
|
|
311
|
+
expect(prompts(logPath)[1]?.startsWith("Revise the goal in ")).toBe(true);
|
|
312
|
+
const agentLog = logLines(logPath).join("\n");
|
|
313
|
+
if (request === undefined) {
|
|
314
|
+
expect(agentLog).not.toContain("Guidance:");
|
|
315
|
+
} else {
|
|
316
|
+
expect(agentLog).toContain(`Guidance: ${request}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const resumeAttempt = client.events.length;
|
|
320
|
+
client.send({ type: "resumeLifecycle" });
|
|
321
|
+
expect(
|
|
322
|
+
await waitForAt(
|
|
323
|
+
client,
|
|
324
|
+
resumeAttempt,
|
|
325
|
+
(message) => message.type === "error",
|
|
326
|
+
"resuming during the revision was not refused",
|
|
327
|
+
),
|
|
328
|
+
).toEqual({ type: "error", message: "A goal revision is active." });
|
|
329
|
+
|
|
330
|
+
client.send({ type: "answer", id: revisionQuestion.id, value: "Alpha" });
|
|
331
|
+
const stopped = await client.waitFor(
|
|
332
|
+
(message) => message.type === "goalRevisionStopped",
|
|
333
|
+
);
|
|
334
|
+
expect(stopped).toEqual({
|
|
335
|
+
type: "goalRevisionStopped",
|
|
336
|
+
completed: true,
|
|
337
|
+
text: "# Revised goal\n\nThe agent rewrote this.",
|
|
338
|
+
});
|
|
339
|
+
const stoppedIndex = client.events.lastIndexOf(stopped);
|
|
340
|
+
expect(await readFile(goalFile, "utf8")).toBe(String(stopped.text));
|
|
341
|
+
|
|
342
|
+
expect(
|
|
343
|
+
client.events
|
|
344
|
+
.slice(pauseIndex, stoppedIndex)
|
|
345
|
+
.filter((message) => message.type === "lifecycleState" && message.paused === false),
|
|
346
|
+
).toEqual([]);
|
|
347
|
+
expect(spawnCount(logPath)).toBe(2);
|
|
348
|
+
expect(logLines(logPath).filter((line) => line.startsWith("revised "))).toHaveLength(1);
|
|
349
|
+
|
|
350
|
+
expect(client.events.slice(stoppedIndex + 1).filter(
|
|
351
|
+
(message) => message.type === "runStarted",
|
|
352
|
+
)).toEqual([]);
|
|
353
|
+
const pausedSnapshot = [...client.events]
|
|
354
|
+
.reverse()
|
|
355
|
+
.find((message) => message.type === "laneState");
|
|
356
|
+
expect(pausedSnapshot).toMatchObject({
|
|
357
|
+
lanes: [
|
|
358
|
+
{
|
|
359
|
+
kind: "implementation",
|
|
360
|
+
status: "paused",
|
|
361
|
+
activity: ["Working on epic 21"],
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const resumeIndex = client.events.length;
|
|
367
|
+
client.send({ type: "resumeLifecycle" });
|
|
368
|
+
expect(
|
|
369
|
+
await waitForAt(
|
|
370
|
+
client,
|
|
371
|
+
resumeIndex,
|
|
372
|
+
(message) => message.type === "lifecycleState" && message.paused === false,
|
|
373
|
+
"the paused lifecycle did not resume",
|
|
374
|
+
),
|
|
375
|
+
).toEqual({
|
|
376
|
+
type: "lifecycleState",
|
|
377
|
+
stage: "implementation",
|
|
378
|
+
pass: 1,
|
|
379
|
+
work: { kind: "epic", id: "21", url: "https://example.test/issues/21" },
|
|
380
|
+
paused: false,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const resumedQuestion = await waitForAt(
|
|
384
|
+
client,
|
|
385
|
+
resumeIndex,
|
|
386
|
+
(message) => message.type === "question",
|
|
387
|
+
"the original lifecycle peer did not continue",
|
|
388
|
+
10000,
|
|
389
|
+
);
|
|
390
|
+
expect(resumedQuestion).toMatchObject({
|
|
391
|
+
id: 1,
|
|
392
|
+
message: "Choose an answer",
|
|
393
|
+
options: QUESTION_OPTIONS,
|
|
394
|
+
});
|
|
395
|
+
client.send({ type: "answer", id: resumedQuestion.id, value: "Beta" });
|
|
396
|
+
|
|
397
|
+
expect(
|
|
398
|
+
await waitForAt(
|
|
399
|
+
client,
|
|
400
|
+
resumeIndex,
|
|
401
|
+
(message) =>
|
|
402
|
+
message.type === "lifecycleState" && message.stage === "epic" && message.pass === 2,
|
|
403
|
+
"the lifecycle did not advance after the resumed stage",
|
|
404
|
+
10000,
|
|
405
|
+
),
|
|
406
|
+
).toEqual({
|
|
407
|
+
type: "lifecycleState",
|
|
408
|
+
stage: "epic",
|
|
409
|
+
pass: 2,
|
|
410
|
+
work: null,
|
|
411
|
+
paused: false,
|
|
412
|
+
});
|
|
413
|
+
expect(
|
|
414
|
+
prompts(logPath).filter((prompt) => prompt === "/implement-next-epic 21"),
|
|
415
|
+
).toHaveLength(1);
|
|
416
|
+
expect(logLines(logPath).filter((line) => line.startsWith("revised "))).toHaveLength(1);
|
|
417
|
+
expect(logLines(logPath).filter((line) => line === "answer Beta")).toHaveLength(1);
|
|
418
|
+
} finally {
|
|
419
|
+
client.close();
|
|
420
|
+
server.stop(true);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
afterAll(async () => {
|
|
425
|
+
await Promise.all(
|
|
426
|
+
temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe("lifecycle pause and resume", () => {
|
|
431
|
+
test(
|
|
432
|
+
"pauses during implementation and resumes the same peer",
|
|
433
|
+
async () => {
|
|
434
|
+
let lookups = 0;
|
|
435
|
+
const epic: OpenEpic = {
|
|
436
|
+
number: 5,
|
|
437
|
+
title: "Pause the epic",
|
|
438
|
+
url: "https://example.test/issues/5",
|
|
439
|
+
};
|
|
440
|
+
const { server, logPath, url } = await startServer(
|
|
441
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
442
|
+
{
|
|
443
|
+
agentArguments: "--delay-ms=6000",
|
|
444
|
+
tracker: async () => {
|
|
445
|
+
lookups += 1;
|
|
446
|
+
return lookups === 1 ? epic : undefined;
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
);
|
|
450
|
+
const client = await connect(url);
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
client.send({ type: "startLifecycle" });
|
|
454
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
455
|
+
type: "lifecycleState",
|
|
456
|
+
stage: "epic",
|
|
457
|
+
pass: 1,
|
|
458
|
+
work: null,
|
|
459
|
+
paused: false,
|
|
460
|
+
});
|
|
461
|
+
expect(
|
|
462
|
+
await client.waitFor(
|
|
463
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
464
|
+
),
|
|
465
|
+
).toEqual({
|
|
466
|
+
type: "lifecycleState",
|
|
467
|
+
stage: "implementation",
|
|
468
|
+
pass: 1,
|
|
469
|
+
work: { kind: "epic", id: "5", url: "https://example.test/issues/5" },
|
|
470
|
+
paused: false,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const spawned = Date.now() + 5000;
|
|
474
|
+
while (spawnCount(logPath) < 1 && Date.now() < spawned) await Bun.sleep(25);
|
|
475
|
+
while (prompts(logPath).length < 1 && Date.now() < spawned) await Bun.sleep(25);
|
|
476
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
477
|
+
expect(prompts(logPath)).toEqual(["/implement-next-epic 5"]);
|
|
478
|
+
|
|
479
|
+
client.send({ type: "pauseLifecycle" });
|
|
480
|
+
const paused = await client.waitFor(
|
|
481
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
482
|
+
);
|
|
483
|
+
expect(paused).toEqual({
|
|
484
|
+
type: "lifecycleState",
|
|
485
|
+
stage: "implementation",
|
|
486
|
+
pass: 1,
|
|
487
|
+
work: { kind: "epic", id: "5", url: "https://example.test/issues/5" },
|
|
488
|
+
paused: true,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const activityAtPause = client.events.filter((message) => message.type === "activity").length;
|
|
492
|
+
const statesAtPause = lifecycleStates(client).length;
|
|
493
|
+
|
|
494
|
+
await Bun.sleep(2200);
|
|
495
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
496
|
+
expect(prompts(logPath)).toEqual(["/implement-next-epic 5"]);
|
|
497
|
+
expect(lifecycleStates(client)).toHaveLength(statesAtPause);
|
|
498
|
+
expect(client.events.filter((message) => message.type === "activity")).toHaveLength(
|
|
499
|
+
activityAtPause,
|
|
500
|
+
);
|
|
501
|
+
expect(client.events.filter((message) => message.type === "runStopped")).toEqual([]);
|
|
502
|
+
|
|
503
|
+
client.send({ type: "resumeLifecycle" });
|
|
504
|
+
expect(
|
|
505
|
+
await client.waitFor(
|
|
506
|
+
(message) => message.type === "lifecycleState" && message.paused === false,
|
|
507
|
+
),
|
|
508
|
+
).toEqual({
|
|
509
|
+
type: "lifecycleState",
|
|
510
|
+
stage: "implementation",
|
|
511
|
+
pass: 1,
|
|
512
|
+
work: { kind: "epic", id: "5", url: "https://example.test/issues/5" },
|
|
513
|
+
paused: false,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
expect(
|
|
517
|
+
await client.waitFor(
|
|
518
|
+
(message) =>
|
|
519
|
+
message.type === "lifecycleState" &&
|
|
520
|
+
message.stage === "epic" &&
|
|
521
|
+
message.pass === 2,
|
|
522
|
+
),
|
|
523
|
+
).toEqual({
|
|
524
|
+
type: "lifecycleState",
|
|
525
|
+
stage: "epic",
|
|
526
|
+
pass: 2,
|
|
527
|
+
work: null,
|
|
528
|
+
paused: false,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
await waitUntil(
|
|
532
|
+
() => spawnCount(logPath) === 2,
|
|
533
|
+
"the lifecycle did not start the next stage",
|
|
534
|
+
);
|
|
535
|
+
expect(prompts(logPath)).toEqual(["/implement-next-epic 5", "/epic-next"]);
|
|
536
|
+
expect(
|
|
537
|
+
prompts(logPath).filter((prompt) => prompt === "/implement-next-epic 5"),
|
|
538
|
+
).toHaveLength(1);
|
|
539
|
+
} finally {
|
|
540
|
+
client.close();
|
|
541
|
+
server.stop(true);
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
30000,
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
test(
|
|
548
|
+
"pauses with a pending interview question and carries its answer to the same peer",
|
|
549
|
+
async () => {
|
|
550
|
+
const { server, adrDir, logPath, url } = await startServer({
|
|
551
|
+
"0001-first.md": PENDING_ONE,
|
|
552
|
+
});
|
|
553
|
+
const client = await connect(url);
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
client.send({ type: "startLifecycle" });
|
|
557
|
+
expect(
|
|
558
|
+
await client.waitFor(
|
|
559
|
+
(message) =>
|
|
560
|
+
message.type === "lifecycleState" &&
|
|
561
|
+
message.stage === "adr" &&
|
|
562
|
+
message.work !== null,
|
|
563
|
+
),
|
|
564
|
+
).toEqual({
|
|
565
|
+
type: "lifecycleState",
|
|
566
|
+
stage: "adr",
|
|
567
|
+
pass: 1,
|
|
568
|
+
work: { kind: "adr", id: "0001-first.md" },
|
|
569
|
+
paused: false,
|
|
570
|
+
});
|
|
571
|
+
const question = await client.waitFor((message) => message.type === "question");
|
|
572
|
+
expect(question).toMatchObject({ id: 1, message: "Choose an answer" });
|
|
573
|
+
|
|
574
|
+
client.send({ type: "pauseLifecycle" });
|
|
575
|
+
const paused = await client.waitFor(
|
|
576
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
577
|
+
);
|
|
578
|
+
expect(paused).toEqual({
|
|
579
|
+
type: "lifecycleState",
|
|
580
|
+
stage: "adr",
|
|
581
|
+
pass: 1,
|
|
582
|
+
work: { kind: "adr", id: "0001-first.md" },
|
|
583
|
+
paused: true,
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const promptsAtPause = prompts(logPath);
|
|
587
|
+
const spawnsAtPause = spawnCount(logPath);
|
|
588
|
+
expect(promptsAtPause).toHaveLength(2);
|
|
589
|
+
expect(promptsAtPause[1]?.startsWith("/adr-resolve ")).toBe(true);
|
|
590
|
+
expect(spawnsAtPause).toBe(2);
|
|
591
|
+
|
|
592
|
+
client.send({ type: "answer", id: 1, value: "Alpha" });
|
|
593
|
+
await Bun.sleep(500);
|
|
594
|
+
expect(client.events.filter((message) => message.type === "error")).toEqual([]);
|
|
595
|
+
expect(logLines(logPath).filter((line) => line.startsWith("answer "))).toEqual([]);
|
|
596
|
+
expect(prompts(logPath)).toEqual(promptsAtPause);
|
|
597
|
+
expect(spawnCount(logPath)).toBe(spawnsAtPause);
|
|
598
|
+
|
|
599
|
+
client.send({ type: "resumeLifecycle" });
|
|
600
|
+
expect(
|
|
601
|
+
await client.waitFor(
|
|
602
|
+
(message) =>
|
|
603
|
+
message.type === "lifecycleState" &&
|
|
604
|
+
message.stage === "adr" &&
|
|
605
|
+
message.paused === false,
|
|
606
|
+
),
|
|
607
|
+
).toEqual({
|
|
608
|
+
type: "lifecycleState",
|
|
609
|
+
stage: "adr",
|
|
610
|
+
pass: 1,
|
|
611
|
+
work: { kind: "adr", id: "0001-first.md" },
|
|
612
|
+
paused: false,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
await waitUntil(
|
|
616
|
+
() => logLines(logPath).includes("answer Alpha"),
|
|
617
|
+
"the suspended peer did not receive the answer",
|
|
618
|
+
);
|
|
619
|
+
await waitUntil(
|
|
620
|
+
() => frontmatterStatus(readFileSync(join(adrDir, "0001-first.md"), "utf8")) === "accepted",
|
|
621
|
+
"the suspended peer did not finish the ADR",
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState" &&
|
|
625
|
+
message.stage === "finished")).toEqual({
|
|
626
|
+
type: "lifecycleState",
|
|
627
|
+
stage: "finished",
|
|
628
|
+
pass: 1,
|
|
629
|
+
work: null,
|
|
630
|
+
paused: false,
|
|
631
|
+
});
|
|
632
|
+
expect(spawnCount(logPath)).toBe(3);
|
|
633
|
+
expect(prompts(logPath)[2]).toBe("/epic-next");
|
|
634
|
+
} finally {
|
|
635
|
+
client.close();
|
|
636
|
+
server.stop(true);
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
30000,
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
test(
|
|
643
|
+
"pauses between stages before the next tracker query result is used",
|
|
644
|
+
async () => {
|
|
645
|
+
const epic: OpenEpic = {
|
|
646
|
+
number: 8,
|
|
647
|
+
title: "Between stages",
|
|
648
|
+
url: "https://example.test/issues/8",
|
|
649
|
+
};
|
|
650
|
+
const secondQuery = Promise.withResolvers<void>();
|
|
651
|
+
const releaseSecondQuery = Promise.withResolvers<void>();
|
|
652
|
+
let lookups = 0;
|
|
653
|
+
const { server, logPath, url } = await startServer(
|
|
654
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
655
|
+
{
|
|
656
|
+
tracker: async () => {
|
|
657
|
+
lookups += 1;
|
|
658
|
+
if (lookups === 1) return epic;
|
|
659
|
+
if (lookups === 2) {
|
|
660
|
+
secondQuery.resolve();
|
|
661
|
+
await releaseSecondQuery.promise;
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
664
|
+
return undefined;
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
);
|
|
668
|
+
const client = await connect(url);
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
client.send({ type: "startLifecycle" });
|
|
672
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
673
|
+
type: "lifecycleState",
|
|
674
|
+
stage: "epic",
|
|
675
|
+
pass: 1,
|
|
676
|
+
work: null,
|
|
677
|
+
paused: false,
|
|
678
|
+
});
|
|
679
|
+
expect(
|
|
680
|
+
await client.waitFor(
|
|
681
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
682
|
+
),
|
|
683
|
+
).toEqual({
|
|
684
|
+
type: "lifecycleState",
|
|
685
|
+
stage: "implementation",
|
|
686
|
+
pass: 1,
|
|
687
|
+
work: { kind: "epic", id: "8", url: "https://example.test/issues/8" },
|
|
688
|
+
paused: false,
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
await secondQuery.promise;
|
|
692
|
+
client.send({ type: "pauseLifecycle" });
|
|
693
|
+
expect(
|
|
694
|
+
await client.waitFor(
|
|
695
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
696
|
+
),
|
|
697
|
+
).toEqual({
|
|
698
|
+
type: "lifecycleState",
|
|
699
|
+
stage: "implementation",
|
|
700
|
+
pass: 1,
|
|
701
|
+
work: { kind: "epic", id: "8", url: "https://example.test/issues/8" },
|
|
702
|
+
paused: true,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
await Bun.sleep(600);
|
|
706
|
+
expect(
|
|
707
|
+
lifecycleStates(client).filter((message) => message.paused === true),
|
|
708
|
+
).toHaveLength(1);
|
|
709
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
710
|
+
|
|
711
|
+
releaseSecondQuery.resolve();
|
|
712
|
+
// Integration timing: the tracker result is available, but the paused
|
|
713
|
+
// gate must hold the stage transition until the explicit resume.
|
|
714
|
+
await Bun.sleep(300);
|
|
715
|
+
expect(
|
|
716
|
+
lifecycleStates(client).filter((message) => message.stage === "epic" && message.pass === 2),
|
|
717
|
+
).toEqual([]);
|
|
718
|
+
|
|
719
|
+
client.send({ type: "resumeLifecycle" });
|
|
720
|
+
expect(
|
|
721
|
+
await client.waitFor(
|
|
722
|
+
(message) =>
|
|
723
|
+
message.type === "lifecycleState" &&
|
|
724
|
+
message.stage === "epic" &&
|
|
725
|
+
message.pass === 2,
|
|
726
|
+
),
|
|
727
|
+
).toEqual({
|
|
728
|
+
type: "lifecycleState",
|
|
729
|
+
stage: "epic",
|
|
730
|
+
pass: 2,
|
|
731
|
+
work: null,
|
|
732
|
+
paused: false,
|
|
733
|
+
});
|
|
734
|
+
} finally {
|
|
735
|
+
client.close();
|
|
736
|
+
server.stop(true);
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
20000,
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
test(
|
|
743
|
+
"pauses the initial tracker lookup and resumes into its result",
|
|
744
|
+
async () => {
|
|
745
|
+
const lookup = Promise.withResolvers<void>();
|
|
746
|
+
let lookups = 0;
|
|
747
|
+
const epic: OpenEpic = {
|
|
748
|
+
number: 15,
|
|
749
|
+
title: "Lookup epic",
|
|
750
|
+
url: "https://example.test/issues/15",
|
|
751
|
+
};
|
|
752
|
+
const { server, logPath, url } = await startServer(
|
|
753
|
+
{},
|
|
754
|
+
{
|
|
755
|
+
tracker: async () => {
|
|
756
|
+
lookups += 1;
|
|
757
|
+
if (lookups === 1) {
|
|
758
|
+
await lookup.promise;
|
|
759
|
+
return epic;
|
|
760
|
+
}
|
|
761
|
+
return undefined;
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
);
|
|
765
|
+
const client = await connect(url);
|
|
766
|
+
|
|
767
|
+
try {
|
|
768
|
+
const initial = {
|
|
769
|
+
type: "lifecycleState",
|
|
770
|
+
stage: "epic",
|
|
771
|
+
pass: 1,
|
|
772
|
+
work: null,
|
|
773
|
+
paused: false,
|
|
774
|
+
};
|
|
775
|
+
client.send({ type: "startLifecycle" });
|
|
776
|
+
expect(await client.waitFor((message) => message.type === "lifecycleState")).toEqual(
|
|
777
|
+
initial,
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
client.send({ type: "pauseLifecycle" });
|
|
781
|
+
expect(
|
|
782
|
+
await client.waitFor(
|
|
783
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
784
|
+
),
|
|
785
|
+
).toEqual({
|
|
786
|
+
type: "lifecycleState",
|
|
787
|
+
stage: "epic",
|
|
788
|
+
pass: 1,
|
|
789
|
+
work: null,
|
|
790
|
+
paused: true,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
await Bun.sleep(200);
|
|
794
|
+
expect(spawnCount(logPath)).toBe(0);
|
|
795
|
+
|
|
796
|
+
lookup.resolve();
|
|
797
|
+
await Bun.sleep(400);
|
|
798
|
+
expect(spawnCount(logPath)).toBe(0);
|
|
799
|
+
expect(lifecycleStates(client)).toEqual([
|
|
800
|
+
initial,
|
|
801
|
+
{
|
|
802
|
+
type: "lifecycleState",
|
|
803
|
+
stage: "epic",
|
|
804
|
+
pass: 1,
|
|
805
|
+
work: null,
|
|
806
|
+
paused: true,
|
|
807
|
+
},
|
|
808
|
+
]);
|
|
809
|
+
expect(client.events.filter((message) => message.type === "runStarted")).toEqual([]);
|
|
810
|
+
|
|
811
|
+
client.send({ type: "resumeLifecycle" });
|
|
812
|
+
expect(
|
|
813
|
+
await client.waitFor(
|
|
814
|
+
(message) =>
|
|
815
|
+
message.type === "lifecycleState" && message.stage === "implementation",
|
|
816
|
+
),
|
|
817
|
+
).toEqual({
|
|
818
|
+
type: "lifecycleState",
|
|
819
|
+
stage: "implementation",
|
|
820
|
+
pass: 1,
|
|
821
|
+
work: { kind: "epic", id: "15", url: "https://example.test/issues/15" },
|
|
822
|
+
paused: false,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
await waitUntil(
|
|
826
|
+
() => prompts(logPath).includes("/implement-next-epic 15"),
|
|
827
|
+
"the lifecycle peer did not spawn",
|
|
828
|
+
);
|
|
829
|
+
expect(prompts(logPath)[0]).toBe("/implement-next-epic 15");
|
|
830
|
+
} finally {
|
|
831
|
+
client.close();
|
|
832
|
+
server.stop(true);
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
20000,
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
test(
|
|
839
|
+
"replays paused state and pending question to a client that connects while paused",
|
|
840
|
+
async () => {
|
|
841
|
+
const { server, logPath, url } = await startServer({
|
|
842
|
+
"0001-first.md": PENDING_ONE,
|
|
843
|
+
});
|
|
844
|
+
const client = await connect(url);
|
|
845
|
+
|
|
846
|
+
try {
|
|
847
|
+
client.send({ type: "startLifecycle" });
|
|
848
|
+
await client.waitFor(
|
|
849
|
+
(message) =>
|
|
850
|
+
message.type === "lifecycleState" &&
|
|
851
|
+
message.stage === "adr" &&
|
|
852
|
+
message.work !== null,
|
|
853
|
+
);
|
|
854
|
+
await client.waitFor((message) => message.type === "question");
|
|
855
|
+
|
|
856
|
+
client.send({ type: "pauseLifecycle" });
|
|
857
|
+
await client.waitFor(
|
|
858
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
const late = await connect(url);
|
|
862
|
+
try {
|
|
863
|
+
expect(await late.waitFor(() => true)).toEqual({
|
|
864
|
+
type: "lifecycleState",
|
|
865
|
+
stage: "adr",
|
|
866
|
+
pass: 1,
|
|
867
|
+
work: { kind: "adr", id: "0001-first.md" },
|
|
868
|
+
paused: true,
|
|
869
|
+
});
|
|
870
|
+
expect(await late.waitFor(() => true)).toMatchObject({
|
|
871
|
+
type: "laneState",
|
|
872
|
+
lanes: [
|
|
873
|
+
{ kind: "generation", status: "closed" },
|
|
874
|
+
{
|
|
875
|
+
kind: "adr",
|
|
876
|
+
record: { file: "0001-first.md", title: "First decision" },
|
|
877
|
+
status: "paused",
|
|
878
|
+
activity: ["Inspecting 0001-first.md"],
|
|
879
|
+
question: {
|
|
880
|
+
id: 1,
|
|
881
|
+
message: "Choose an answer",
|
|
882
|
+
options: QUESTION_OPTIONS,
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
],
|
|
886
|
+
openEpics: 0,
|
|
887
|
+
runningLanes: 0,
|
|
888
|
+
});
|
|
889
|
+
} finally {
|
|
890
|
+
late.close();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
await Bun.sleep(500);
|
|
894
|
+
expect(spawnCount(logPath)).toBe(2);
|
|
895
|
+
} finally {
|
|
896
|
+
client.close();
|
|
897
|
+
server.stop(true);
|
|
898
|
+
}
|
|
899
|
+
},
|
|
900
|
+
20000,
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
test(
|
|
904
|
+
"allows goal saving while paused and starts no run without pending ADRs",
|
|
905
|
+
async () => {
|
|
906
|
+
const epic: OpenEpic = {
|
|
907
|
+
number: 4,
|
|
908
|
+
title: "Goal while paused",
|
|
909
|
+
url: "https://example.test/issues/4",
|
|
910
|
+
};
|
|
911
|
+
const { server, goalFile, logPath, url } = await startServer(
|
|
912
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
913
|
+
{ agentArguments: "--delay-ms=1500", tracker: async () => epic },
|
|
914
|
+
);
|
|
915
|
+
const client = await connect(url);
|
|
916
|
+
|
|
917
|
+
try {
|
|
918
|
+
client.send({ type: "startLifecycle" });
|
|
919
|
+
await client.waitFor(
|
|
920
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
921
|
+
);
|
|
922
|
+
const spawned = Date.now() + 5000;
|
|
923
|
+
while (spawnCount(logPath) < 1 && Date.now() < spawned) await Bun.sleep(25);
|
|
924
|
+
|
|
925
|
+
client.send({ type: "pauseLifecycle" });
|
|
926
|
+
await client.waitFor(
|
|
927
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
client.send({ type: "saveGoal", text: "# Paused goal\n\nEdited while paused.\n" });
|
|
931
|
+
expect(
|
|
932
|
+
await client.waitFor(
|
|
933
|
+
(message) => message.type === "goal" && message.text ===
|
|
934
|
+
"# Paused goal\n\nEdited while paused.\n",
|
|
935
|
+
),
|
|
936
|
+
).toEqual({ type: "goal", text: "# Paused goal\n\nEdited while paused.\n" });
|
|
937
|
+
expect(await readFile(goalFile, "utf8")).toBe("# Paused goal\n\nEdited while paused.\n");
|
|
938
|
+
|
|
939
|
+
client.send({ type: "getGoal" });
|
|
940
|
+
expect(
|
|
941
|
+
await client.waitFor(
|
|
942
|
+
(message) => message.type === "goal" && message.text ===
|
|
943
|
+
"# Paused goal\n\nEdited while paused.\n",
|
|
944
|
+
),
|
|
945
|
+
).toEqual({ type: "goal", text: "# Paused goal\n\nEdited while paused.\n" });
|
|
946
|
+
|
|
947
|
+
client.send({ type: "startLifecycle" });
|
|
948
|
+
client.send({ type: "startRun" });
|
|
949
|
+
|
|
950
|
+
for (const expected of [
|
|
951
|
+
"A lifecycle run is already active.",
|
|
952
|
+
"No pending ADRs to start.",
|
|
953
|
+
]) {
|
|
954
|
+
expect(
|
|
955
|
+
await client.waitFor(
|
|
956
|
+
(message) => message.type === "error" && message.message === expected,
|
|
957
|
+
),
|
|
958
|
+
).toEqual({ type: "error", message: expected });
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
expect(spawnCount(logPath)).toBe(1);
|
|
962
|
+
expect(prompts(logPath)).toEqual(["/implement-next-epic 4"]);
|
|
963
|
+
expect(
|
|
964
|
+
lifecycleStates(client).filter((message) => message.paused === false),
|
|
965
|
+
).toEqual([
|
|
966
|
+
{
|
|
967
|
+
type: "lifecycleState",
|
|
968
|
+
stage: "epic",
|
|
969
|
+
pass: 1,
|
|
970
|
+
work: null,
|
|
971
|
+
paused: false,
|
|
972
|
+
},
|
|
973
|
+
{
|
|
974
|
+
type: "lifecycleState",
|
|
975
|
+
stage: "implementation",
|
|
976
|
+
pass: 1,
|
|
977
|
+
work: { kind: "epic", id: "4", url: "https://example.test/issues/4" },
|
|
978
|
+
paused: false,
|
|
979
|
+
},
|
|
980
|
+
]);
|
|
981
|
+
} finally {
|
|
982
|
+
client.close();
|
|
983
|
+
server.stop(true);
|
|
984
|
+
}
|
|
985
|
+
},
|
|
986
|
+
30000,
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
test(
|
|
990
|
+
"resolves pending ADRs while paused and restores the lifecycle conversation",
|
|
991
|
+
async () => {
|
|
992
|
+
const epic: OpenEpic = {
|
|
993
|
+
number: 31,
|
|
994
|
+
title: "Manual while paused",
|
|
995
|
+
url: "https://example.test/issues/31",
|
|
996
|
+
};
|
|
997
|
+
let lookups = 0;
|
|
998
|
+
const { server, adrDir, logPath, url } = await startServer(
|
|
999
|
+
{ "0001-first.md": PENDING_ONE, "0002-second.md": PENDING_TWO },
|
|
1000
|
+
{
|
|
1001
|
+
agentArguments: "--delay-ms=900 --questions=0",
|
|
1002
|
+
tracker: async () => {
|
|
1003
|
+
lookups += 1;
|
|
1004
|
+
return lookups === 1 ? epic : undefined;
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
);
|
|
1008
|
+
const client = await connect(url);
|
|
1009
|
+
|
|
1010
|
+
try {
|
|
1011
|
+
client.send({ type: "startLifecycle" });
|
|
1012
|
+
await client.waitFor(
|
|
1013
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
1014
|
+
);
|
|
1015
|
+
await waitUntil(() => spawnCount(logPath) === 1, "the lifecycle peer did not spawn");
|
|
1016
|
+
|
|
1017
|
+
client.send({ type: "pauseLifecycle" });
|
|
1018
|
+
await client.waitFor(
|
|
1019
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
1020
|
+
);
|
|
1021
|
+
const pausedIndex = client.events.length;
|
|
1022
|
+
|
|
1023
|
+
client.send({ type: "startRun", file: "0001-first.md" });
|
|
1024
|
+
expect(
|
|
1025
|
+
await waitForAt(
|
|
1026
|
+
client,
|
|
1027
|
+
pausedIndex,
|
|
1028
|
+
(message) => message.type === "runStarted" && message.file === "0001-first.md",
|
|
1029
|
+
"the manual run did not start while paused",
|
|
1030
|
+
),
|
|
1031
|
+
).toEqual({
|
|
1032
|
+
type: "runStarted",
|
|
1033
|
+
file: "0001-first.md",
|
|
1034
|
+
title: "First decision",
|
|
1035
|
+
action: "resolving",
|
|
1036
|
+
manual: true,
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
const resumeIndex = client.events.length;
|
|
1040
|
+
client.send({ type: "resumeLifecycle" });
|
|
1041
|
+
expect(
|
|
1042
|
+
await waitForAt(
|
|
1043
|
+
client,
|
|
1044
|
+
resumeIndex,
|
|
1045
|
+
(message) => message.type === "error",
|
|
1046
|
+
"resuming during the manual run was not refused",
|
|
1047
|
+
),
|
|
1048
|
+
).toEqual({ type: "error", message: "A manual ADR run is active." });
|
|
1049
|
+
|
|
1050
|
+
await waitForAt(
|
|
1051
|
+
client,
|
|
1052
|
+
pausedIndex,
|
|
1053
|
+
(message) =>
|
|
1054
|
+
message.type === "runStopped" &&
|
|
1055
|
+
message.file === "0001-first.md" &&
|
|
1056
|
+
message.resolved === true,
|
|
1057
|
+
"the first manual record did not resolve",
|
|
1058
|
+
10000,
|
|
1059
|
+
);
|
|
1060
|
+
await waitForAt(
|
|
1061
|
+
client,
|
|
1062
|
+
pausedIndex,
|
|
1063
|
+
(message) => message.type === "runStarted" && message.file === "0002-second.md",
|
|
1064
|
+
"the manual sequence did not continue",
|
|
1065
|
+
10000,
|
|
1066
|
+
);
|
|
1067
|
+
await waitForAt(
|
|
1068
|
+
client,
|
|
1069
|
+
pausedIndex,
|
|
1070
|
+
(message) =>
|
|
1071
|
+
message.type === "runStopped" &&
|
|
1072
|
+
message.file === "0002-second.md" &&
|
|
1073
|
+
message.resolved === true,
|
|
1074
|
+
"the second manual record did not resolve",
|
|
1075
|
+
10000,
|
|
1076
|
+
);
|
|
1077
|
+
|
|
1078
|
+
expect(frontmatterStatus(await readFile(join(adrDir, "0001-first.md"), "utf8"))).toBe("accepted");
|
|
1079
|
+
expect(frontmatterStatus(await readFile(join(adrDir, "0002-second.md"), "utf8"))).toBe("accepted");
|
|
1080
|
+
expect(
|
|
1081
|
+
await client.waitFor((message) => message.type === "records" && message.next === null),
|
|
1082
|
+
).toMatchObject({ next: null });
|
|
1083
|
+
|
|
1084
|
+
const pausedSnapshot = [...client.events]
|
|
1085
|
+
.reverse()
|
|
1086
|
+
.find((message) => message.type === "laneState");
|
|
1087
|
+
expect(pausedSnapshot).toMatchObject({
|
|
1088
|
+
lanes: [
|
|
1089
|
+
{
|
|
1090
|
+
kind: "implementation",
|
|
1091
|
+
status: "paused",
|
|
1092
|
+
epic: { number: 31, title: "Manual while paused" },
|
|
1093
|
+
},
|
|
1094
|
+
],
|
|
1095
|
+
});
|
|
1096
|
+
expect(
|
|
1097
|
+
client.events
|
|
1098
|
+
.slice(pausedIndex)
|
|
1099
|
+
.filter((message) => message.type === "lifecycleState" && message.paused === false),
|
|
1100
|
+
).toEqual([]);
|
|
1101
|
+
expect(
|
|
1102
|
+
client.events
|
|
1103
|
+
.slice(pausedIndex)
|
|
1104
|
+
.filter(
|
|
1105
|
+
(message) =>
|
|
1106
|
+
message.type === "runStarted" &&
|
|
1107
|
+
message.lane === undefined &&
|
|
1108
|
+
message.manual !== true,
|
|
1109
|
+
),
|
|
1110
|
+
).toEqual([]);
|
|
1111
|
+
expect(spawnCount(logPath)).toBe(3);
|
|
1112
|
+
expect(prompts(logPath)[0]).toBe("/implement-next-epic 31");
|
|
1113
|
+
expect(prompts(logPath)[1]?.startsWith("/adr-resolve ")).toBe(true);
|
|
1114
|
+
expect(prompts(logPath)[2]?.startsWith("/adr-resolve ")).toBe(true);
|
|
1115
|
+
const resumedIndex = client.events.length;
|
|
1116
|
+
client.send({ type: "resumeLifecycle" });
|
|
1117
|
+
expect(
|
|
1118
|
+
await waitForAt(
|
|
1119
|
+
client,
|
|
1120
|
+
resumedIndex,
|
|
1121
|
+
(message) => message.type === "lifecycleState" && message.paused === false,
|
|
1122
|
+
"the paused lifecycle did not resume",
|
|
1123
|
+
),
|
|
1124
|
+
).toMatchObject({
|
|
1125
|
+
stage: "implementation",
|
|
1126
|
+
pass: 1,
|
|
1127
|
+
work: { kind: "epic", id: "31", url: "https://example.test/issues/31" },
|
|
1128
|
+
});
|
|
1129
|
+
expect(
|
|
1130
|
+
await waitForAt(
|
|
1131
|
+
client,
|
|
1132
|
+
resumedIndex,
|
|
1133
|
+
(message) =>
|
|
1134
|
+
message.type === "lifecycleState" && message.stage === "epic" && message.pass === 2,
|
|
1135
|
+
"the resumed peer did not advance the lifecycle",
|
|
1136
|
+
15000,
|
|
1137
|
+
),
|
|
1138
|
+
).toEqual({
|
|
1139
|
+
type: "lifecycleState",
|
|
1140
|
+
stage: "epic",
|
|
1141
|
+
pass: 2,
|
|
1142
|
+
work: null,
|
|
1143
|
+
paused: false,
|
|
1144
|
+
});
|
|
1145
|
+
} finally {
|
|
1146
|
+
client.close();
|
|
1147
|
+
server.stop(true);
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
30000,
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
test(
|
|
1154
|
+
"resumes the lifecycle after a manual ADR run that interrupted it",
|
|
1155
|
+
async () => {
|
|
1156
|
+
const epic: OpenEpic = {
|
|
1157
|
+
number: 32,
|
|
1158
|
+
title: "Paused while advancing",
|
|
1159
|
+
url: "https://example.test/issues/32",
|
|
1160
|
+
};
|
|
1161
|
+
let lookups = 0;
|
|
1162
|
+
const { server, adrDir, logPath, url } = await startServer(
|
|
1163
|
+
{ "0001-first.md": PENDING_ONE },
|
|
1164
|
+
{
|
|
1165
|
+
agentArguments: "--delay-ms=1500 --questions=0",
|
|
1166
|
+
tracker: async () => {
|
|
1167
|
+
lookups += 1;
|
|
1168
|
+
return lookups === 1 ? epic : undefined;
|
|
1169
|
+
},
|
|
1170
|
+
},
|
|
1171
|
+
);
|
|
1172
|
+
const client = await connect(url);
|
|
1173
|
+
|
|
1174
|
+
try {
|
|
1175
|
+
client.send({ type: "startLifecycle" });
|
|
1176
|
+
await client.waitFor(
|
|
1177
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
1178
|
+
);
|
|
1179
|
+
await waitUntil(() => spawnCount(logPath) === 1, "the lifecycle peer did not spawn");
|
|
1180
|
+
|
|
1181
|
+
const beforeStart = client.events.length;
|
|
1182
|
+
client.send({ type: "startRun", file: "0001-first.md" });
|
|
1183
|
+
const paused = await waitForAt(
|
|
1184
|
+
client,
|
|
1185
|
+
beforeStart,
|
|
1186
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
1187
|
+
"the advancing lifecycle was not paused",
|
|
1188
|
+
);
|
|
1189
|
+
expect(paused).toMatchObject({
|
|
1190
|
+
stage: "implementation",
|
|
1191
|
+
pass: 1,
|
|
1192
|
+
work: { kind: "epic", id: "32", url: "https://example.test/issues/32" },
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
const pausedIndex = client.events.indexOf(paused);
|
|
1196
|
+
expect(
|
|
1197
|
+
await waitForAt(
|
|
1198
|
+
client,
|
|
1199
|
+
pausedIndex,
|
|
1200
|
+
(message) => message.type === "runStarted" && message.file === "0001-first.md",
|
|
1201
|
+
"the manual run did not start after the pause",
|
|
1202
|
+
),
|
|
1203
|
+
).toMatchObject({ manual: true });
|
|
1204
|
+
await waitForAt(
|
|
1205
|
+
client,
|
|
1206
|
+
pausedIndex,
|
|
1207
|
+
(message) =>
|
|
1208
|
+
message.type === "runStopped" &&
|
|
1209
|
+
message.file === "0001-first.md" &&
|
|
1210
|
+
message.resolved === true,
|
|
1211
|
+
"the manual record did not resolve",
|
|
1212
|
+
10000,
|
|
1213
|
+
);
|
|
1214
|
+
|
|
1215
|
+
expect(frontmatterStatus(await readFile(join(adrDir, "0001-first.md"), "utf8"))).toBe("accepted");
|
|
1216
|
+
expect(spawnCount(logPath)).toBeGreaterThanOrEqual(2);
|
|
1217
|
+
expect(prompts(logPath)[0]).toBe("/implement-next-epic 32");
|
|
1218
|
+
expect(prompts(logPath)[1]?.startsWith("/adr-resolve ")).toBe(true);
|
|
1219
|
+
expect(
|
|
1220
|
+
client.events
|
|
1221
|
+
.slice(pausedIndex)
|
|
1222
|
+
.filter(
|
|
1223
|
+
(message) =>
|
|
1224
|
+
message.type === "runStarted" &&
|
|
1225
|
+
message.lane === undefined &&
|
|
1226
|
+
message.manual !== true,
|
|
1227
|
+
),
|
|
1228
|
+
).toEqual([]);
|
|
1229
|
+
expect(
|
|
1230
|
+
client.events.some(
|
|
1231
|
+
(message) =>
|
|
1232
|
+
message.type === "laneState" &&
|
|
1233
|
+
Array.isArray(message.lanes) &&
|
|
1234
|
+
message.lanes.some(
|
|
1235
|
+
(lane) =>
|
|
1236
|
+
typeof lane === "object" &&
|
|
1237
|
+
lane !== null &&
|
|
1238
|
+
"kind" in lane &&
|
|
1239
|
+
lane.kind === "implementation" &&
|
|
1240
|
+
"status" in lane &&
|
|
1241
|
+
lane.status === "paused",
|
|
1242
|
+
),
|
|
1243
|
+
),
|
|
1244
|
+
).toBe(true);
|
|
1245
|
+
|
|
1246
|
+
expect(
|
|
1247
|
+
await waitForAt(
|
|
1248
|
+
client,
|
|
1249
|
+
pausedIndex,
|
|
1250
|
+
(message) =>
|
|
1251
|
+
message.type === "lifecycleState" &&
|
|
1252
|
+
message.paused === false &&
|
|
1253
|
+
message.stage === "implementation",
|
|
1254
|
+
"the manual sequence did not resume the lifecycle",
|
|
1255
|
+
10000,
|
|
1256
|
+
),
|
|
1257
|
+
).toMatchObject({
|
|
1258
|
+
stage: "implementation",
|
|
1259
|
+
pass: 1,
|
|
1260
|
+
work: { kind: "epic", id: "32", url: "https://example.test/issues/32" },
|
|
1261
|
+
});
|
|
1262
|
+
} finally {
|
|
1263
|
+
client.close();
|
|
1264
|
+
server.stop(true);
|
|
1265
|
+
}
|
|
1266
|
+
},
|
|
1267
|
+
30000,
|
|
1268
|
+
);
|
|
1269
|
+
|
|
1270
|
+
test(
|
|
1271
|
+
"refuses a manual start for the record the lifecycle is resolving",
|
|
1272
|
+
async () => {
|
|
1273
|
+
const { server, url } = await startServer(
|
|
1274
|
+
{ "0001-first.md": PENDING_ONE, "0002-second.md": PENDING_TWO },
|
|
1275
|
+
{ agentArguments: "--delay-ms=2000 --questions=0" },
|
|
1276
|
+
);
|
|
1277
|
+
const client = await connect(url);
|
|
1278
|
+
|
|
1279
|
+
try {
|
|
1280
|
+
client.send({ type: "startLifecycle" });
|
|
1281
|
+
await client.waitFor(
|
|
1282
|
+
(message) => message.type === "lifecycleState" && message.stage === "adr",
|
|
1283
|
+
);
|
|
1284
|
+
client.send({ type: "pauseLifecycle" });
|
|
1285
|
+
await client.waitFor(
|
|
1286
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
1287
|
+
);
|
|
1288
|
+
|
|
1289
|
+
const beforeStart = client.events.length;
|
|
1290
|
+
client.send({ type: "startRun", file: "0001-first.md" });
|
|
1291
|
+
expect(
|
|
1292
|
+
await waitForAt(
|
|
1293
|
+
client,
|
|
1294
|
+
beforeStart,
|
|
1295
|
+
(message) => message.type === "error",
|
|
1296
|
+
"the manual start was not refused",
|
|
1297
|
+
),
|
|
1298
|
+
).toEqual({ type: "error", message: "The lifecycle is already resolving that ADR." });
|
|
1299
|
+
expect(
|
|
1300
|
+
client.events.filter(
|
|
1301
|
+
(message) => message.type === "runStarted" && message.manual === true,
|
|
1302
|
+
),
|
|
1303
|
+
).toEqual([]);
|
|
1304
|
+
} finally {
|
|
1305
|
+
client.close();
|
|
1306
|
+
server.stop(true);
|
|
1307
|
+
}
|
|
1308
|
+
},
|
|
1309
|
+
30000,
|
|
1310
|
+
);
|
|
1311
|
+
|
|
1312
|
+
test(
|
|
1313
|
+
"stops a paused lifecycle for good instead of leaving it suspended",
|
|
1314
|
+
async () => {
|
|
1315
|
+
const epic: OpenEpic = {
|
|
1316
|
+
number: 41,
|
|
1317
|
+
title: "Stopped while paused",
|
|
1318
|
+
url: "https://example.test/issues/41",
|
|
1319
|
+
};
|
|
1320
|
+
const { server, logPath, url } = await startServer(
|
|
1321
|
+
{ "0001-done.md": fixtureRecord("0001-done.md", "Done") },
|
|
1322
|
+
{
|
|
1323
|
+
agentArguments: "--delay-ms=5000",
|
|
1324
|
+
tracker: async () => epic,
|
|
1325
|
+
},
|
|
1326
|
+
);
|
|
1327
|
+
const client = await connect(url);
|
|
1328
|
+
|
|
1329
|
+
try {
|
|
1330
|
+
client.send({ type: "startLifecycle" });
|
|
1331
|
+
await client.waitFor(
|
|
1332
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
1333
|
+
);
|
|
1334
|
+
await waitUntil(() => spawnCount(logPath) === 1, "the lifecycle peer did not spawn");
|
|
1335
|
+
await waitUntil(() => prompts(logPath).length >= 1, "the epic peer did not prompt");
|
|
1336
|
+
|
|
1337
|
+
client.send({ type: "pauseLifecycle" });
|
|
1338
|
+
await client.waitFor(
|
|
1339
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
1340
|
+
);
|
|
1341
|
+
|
|
1342
|
+
const stopIndex = client.events.length;
|
|
1343
|
+
client.send({ type: "stopLifecycle" });
|
|
1344
|
+
expect(
|
|
1345
|
+
await waitForAt(
|
|
1346
|
+
client,
|
|
1347
|
+
stopIndex,
|
|
1348
|
+
(message) => message.type === "lifecycleState" && message.stage === "goal",
|
|
1349
|
+
"the paused lifecycle did not stop",
|
|
1350
|
+
),
|
|
1351
|
+
).toEqual({
|
|
1352
|
+
type: "lifecycleState",
|
|
1353
|
+
stage: "goal",
|
|
1354
|
+
pass: 1,
|
|
1355
|
+
work: null,
|
|
1356
|
+
paused: false,
|
|
1357
|
+
});
|
|
1358
|
+
expect(
|
|
1359
|
+
await waitForAt(
|
|
1360
|
+
client,
|
|
1361
|
+
stopIndex,
|
|
1362
|
+
(message) =>
|
|
1363
|
+
message.type === "laneState" &&
|
|
1364
|
+
message.runningLanes === 0 &&
|
|
1365
|
+
Array.isArray(message.lanes) &&
|
|
1366
|
+
message.lanes.some(
|
|
1367
|
+
(lane) =>
|
|
1368
|
+
typeof lane === "object" &&
|
|
1369
|
+
lane !== null &&
|
|
1370
|
+
"status" in lane &&
|
|
1371
|
+
lane.status === "failed",
|
|
1372
|
+
),
|
|
1373
|
+
"the suspended lane was not closed",
|
|
1374
|
+
),
|
|
1375
|
+
).toBeDefined();
|
|
1376
|
+
|
|
1377
|
+
const afterStop = client.events.length;
|
|
1378
|
+
client.send({ type: "resumeLifecycle" });
|
|
1379
|
+
expect(
|
|
1380
|
+
await waitForAt(
|
|
1381
|
+
client,
|
|
1382
|
+
afterStop,
|
|
1383
|
+
(message) => message.type === "error",
|
|
1384
|
+
"resuming a stopped lifecycle was not refused",
|
|
1385
|
+
),
|
|
1386
|
+
).toEqual({ type: "error", message: "No lifecycle run is active." });
|
|
1387
|
+
} finally {
|
|
1388
|
+
client.close();
|
|
1389
|
+
server.stop(true);
|
|
1390
|
+
}
|
|
1391
|
+
},
|
|
1392
|
+
20000,
|
|
1393
|
+
);
|
|
1394
|
+
|
|
1395
|
+
test(
|
|
1396
|
+
"refuses invalid pause and resume without changing state",
|
|
1397
|
+
async () => {
|
|
1398
|
+
const idle = await startServer({});
|
|
1399
|
+
const idleClient = await connect(idle.url);
|
|
1400
|
+
try {
|
|
1401
|
+
idleClient.send({ type: "pauseLifecycle" });
|
|
1402
|
+
idleClient.send({ type: "resumeLifecycle" });
|
|
1403
|
+
expect(
|
|
1404
|
+
await idleClient.waitFor(
|
|
1405
|
+
(message) =>
|
|
1406
|
+
message.type === "error" && message.message === "No lifecycle run is active.",
|
|
1407
|
+
),
|
|
1408
|
+
).toEqual({ type: "error", message: "No lifecycle run is active." });
|
|
1409
|
+
expect(
|
|
1410
|
+
await idleClient.waitFor(
|
|
1411
|
+
(message) =>
|
|
1412
|
+
message.type === "error" && message.message === "No lifecycle run is active.",
|
|
1413
|
+
),
|
|
1414
|
+
).toEqual({ type: "error", message: "No lifecycle run is active." });
|
|
1415
|
+
expect(lifecycleStates(idleClient)).toEqual([]);
|
|
1416
|
+
} finally {
|
|
1417
|
+
idleClient.close();
|
|
1418
|
+
idle.server.stop(true);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const finished = await startServer({});
|
|
1422
|
+
const finishedClient = await connect(finished.url);
|
|
1423
|
+
try {
|
|
1424
|
+
finishedClient.send({ type: "startLifecycle" });
|
|
1425
|
+
await finishedClient.waitFor(
|
|
1426
|
+
(message) => message.type === "lifecycleState" && message.stage === "finished",
|
|
1427
|
+
);
|
|
1428
|
+
const before = lifecycleStates(finishedClient).length;
|
|
1429
|
+
|
|
1430
|
+
finishedClient.send({ type: "pauseLifecycle" });
|
|
1431
|
+
finishedClient.send({ type: "resumeLifecycle" });
|
|
1432
|
+
expect(
|
|
1433
|
+
await finishedClient.waitFor(
|
|
1434
|
+
(message) =>
|
|
1435
|
+
message.type === "error" && message.message === "No lifecycle run is active.",
|
|
1436
|
+
),
|
|
1437
|
+
).toEqual({ type: "error", message: "No lifecycle run is active." });
|
|
1438
|
+
expect(
|
|
1439
|
+
await finishedClient.waitFor(
|
|
1440
|
+
(message) =>
|
|
1441
|
+
message.type === "error" && message.message === "No lifecycle run is active.",
|
|
1442
|
+
),
|
|
1443
|
+
).toEqual({ type: "error", message: "No lifecycle run is active." });
|
|
1444
|
+
expect(lifecycleStates(finishedClient)).toHaveLength(before);
|
|
1445
|
+
} finally {
|
|
1446
|
+
finishedClient.close();
|
|
1447
|
+
finished.server.stop(true);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const epic: OpenEpic = {
|
|
1451
|
+
number: 12,
|
|
1452
|
+
title: "Still open",
|
|
1453
|
+
url: "https://example.test/issues/12",
|
|
1454
|
+
};
|
|
1455
|
+
const failed = await startServer(
|
|
1456
|
+
{},
|
|
1457
|
+
{ agentArguments: "--blocker-epic=12", tracker: async () => epic },
|
|
1458
|
+
);
|
|
1459
|
+
const failedClient = await connect(failed.url);
|
|
1460
|
+
try {
|
|
1461
|
+
failedClient.send({ type: "startLifecycle" });
|
|
1462
|
+
await failedClient.waitFor(
|
|
1463
|
+
(message) =>
|
|
1464
|
+
message.type === "error" &&
|
|
1465
|
+
message.message === "Lifecycle stopped with parked epics: #12.",
|
|
1466
|
+
);
|
|
1467
|
+
await failedClient.waitFor(
|
|
1468
|
+
(message) => message.type === "lifecycleState" && message.stage === "goal",
|
|
1469
|
+
);
|
|
1470
|
+
const before = lifecycleStates(failedClient).length;
|
|
1471
|
+
|
|
1472
|
+
failedClient.send({ type: "pauseLifecycle" });
|
|
1473
|
+
failedClient.send({ type: "resumeLifecycle" });
|
|
1474
|
+
expect(
|
|
1475
|
+
await failedClient.waitFor(
|
|
1476
|
+
(message) =>
|
|
1477
|
+
message.type === "error" && message.message === "No lifecycle run is active.",
|
|
1478
|
+
),
|
|
1479
|
+
).toEqual({ type: "error", message: "No lifecycle run is active." });
|
|
1480
|
+
expect(
|
|
1481
|
+
await failedClient.waitFor(
|
|
1482
|
+
(message) =>
|
|
1483
|
+
message.type === "error" && message.message === "No lifecycle run is active.",
|
|
1484
|
+
),
|
|
1485
|
+
).toEqual({ type: "error", message: "No lifecycle run is active." });
|
|
1486
|
+
expect(lifecycleStates(failedClient)).toHaveLength(before);
|
|
1487
|
+
} finally {
|
|
1488
|
+
failedClient.close();
|
|
1489
|
+
failed.server.stop(true);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const activeEpic: OpenEpic = {
|
|
1493
|
+
number: 13,
|
|
1494
|
+
title: "Paused once",
|
|
1495
|
+
url: "https://example.test/issues/13",
|
|
1496
|
+
};
|
|
1497
|
+
let lookups = 0;
|
|
1498
|
+
const active = await startServer(
|
|
1499
|
+
{},
|
|
1500
|
+
{
|
|
1501
|
+
agentArguments: "--delay-ms=6000",
|
|
1502
|
+
tracker: async () => {
|
|
1503
|
+
lookups += 1;
|
|
1504
|
+
return lookups === 1 ? activeEpic : undefined;
|
|
1505
|
+
},
|
|
1506
|
+
},
|
|
1507
|
+
);
|
|
1508
|
+
const activeClient = await connect(active.url);
|
|
1509
|
+
try {
|
|
1510
|
+
activeClient.send({ type: "startLifecycle" });
|
|
1511
|
+
await activeClient.waitFor(
|
|
1512
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
1513
|
+
);
|
|
1514
|
+
activeClient.send({ type: "pauseLifecycle" });
|
|
1515
|
+
await activeClient.waitFor(
|
|
1516
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
1517
|
+
);
|
|
1518
|
+
const statesPaused = lifecycleStates(activeClient).length;
|
|
1519
|
+
|
|
1520
|
+
activeClient.send({ type: "pauseLifecycle" });
|
|
1521
|
+
expect(
|
|
1522
|
+
await activeClient.waitFor(
|
|
1523
|
+
(message) =>
|
|
1524
|
+
message.type === "error" &&
|
|
1525
|
+
message.message === "The lifecycle run is already paused.",
|
|
1526
|
+
),
|
|
1527
|
+
).toEqual({ type: "error", message: "The lifecycle run is already paused." });
|
|
1528
|
+
expect(lifecycleStates(activeClient)).toHaveLength(statesPaused);
|
|
1529
|
+
|
|
1530
|
+
activeClient.send({ type: "resumeLifecycle" });
|
|
1531
|
+
await activeClient.waitFor(
|
|
1532
|
+
(message) =>
|
|
1533
|
+
message.type === "lifecycleState" &&
|
|
1534
|
+
message.paused === false &&
|
|
1535
|
+
message.stage === "implementation",
|
|
1536
|
+
);
|
|
1537
|
+
const statesResumed = lifecycleStates(activeClient).length;
|
|
1538
|
+
|
|
1539
|
+
activeClient.send({ type: "resumeLifecycle" });
|
|
1540
|
+
expect(
|
|
1541
|
+
await activeClient.waitFor(
|
|
1542
|
+
(message) =>
|
|
1543
|
+
message.type === "error" &&
|
|
1544
|
+
message.message === "The lifecycle run is not paused.",
|
|
1545
|
+
),
|
|
1546
|
+
).toEqual({ type: "error", message: "The lifecycle run is not paused." });
|
|
1547
|
+
expect(lifecycleStates(activeClient)).toHaveLength(statesResumed);
|
|
1548
|
+
} finally {
|
|
1549
|
+
activeClient.close();
|
|
1550
|
+
active.server.stop(true);
|
|
1551
|
+
}
|
|
1552
|
+
},
|
|
1553
|
+
40000,
|
|
1554
|
+
);
|
|
1555
|
+
|
|
1556
|
+
test(
|
|
1557
|
+
"revises the goal with guidance while paused and resumes the interrupted peer",
|
|
1558
|
+
async () => {
|
|
1559
|
+
await exercisePausedRevision("Tighten the goal");
|
|
1560
|
+
},
|
|
1561
|
+
40000,
|
|
1562
|
+
);
|
|
1563
|
+
|
|
1564
|
+
test(
|
|
1565
|
+
"revises the goal without guidance while paused and resumes the interrupted peer",
|
|
1566
|
+
async () => {
|
|
1567
|
+
await exercisePausedRevision(undefined);
|
|
1568
|
+
},
|
|
1569
|
+
40000,
|
|
1570
|
+
);
|
|
1571
|
+
|
|
1572
|
+
test(
|
|
1573
|
+
"replays the pending lifecycle question to every client after a paused revision",
|
|
1574
|
+
async () => {
|
|
1575
|
+
const epic: OpenEpic = {
|
|
1576
|
+
number: 31,
|
|
1577
|
+
title: "Question across a revision",
|
|
1578
|
+
url: "https://example.test/issues/31",
|
|
1579
|
+
};
|
|
1580
|
+
let lookups = 0;
|
|
1581
|
+
const { server, logPath, url } = await startServer(
|
|
1582
|
+
{},
|
|
1583
|
+
{
|
|
1584
|
+
reviseGoal: true,
|
|
1585
|
+
agentArguments: "--questions=1",
|
|
1586
|
+
tracker: async () => {
|
|
1587
|
+
lookups += 1;
|
|
1588
|
+
return lookups === 1 ? epic : undefined;
|
|
1589
|
+
},
|
|
1590
|
+
},
|
|
1591
|
+
);
|
|
1592
|
+
const client = await connect(url);
|
|
1593
|
+
const late = await connect(url);
|
|
1594
|
+
|
|
1595
|
+
try {
|
|
1596
|
+
client.send({ type: "startLifecycle" });
|
|
1597
|
+
await client.waitFor(
|
|
1598
|
+
(message) => message.type === "lifecycleState" && message.stage === "implementation",
|
|
1599
|
+
);
|
|
1600
|
+
const lifecycleQuestion = await client.waitFor(
|
|
1601
|
+
(message) => message.type === "question",
|
|
1602
|
+
);
|
|
1603
|
+
expect(lifecycleQuestion).toMatchObject({
|
|
1604
|
+
id: 1,
|
|
1605
|
+
message: "Choose an answer",
|
|
1606
|
+
options: QUESTION_OPTIONS,
|
|
1607
|
+
});
|
|
1608
|
+
const lifecycleActivity = client.events
|
|
1609
|
+
.filter((message) => message.type === "activity")
|
|
1610
|
+
.map((message) => message.text);
|
|
1611
|
+
expect(lifecycleActivity).toEqual(["Working on epic 31"]);
|
|
1612
|
+
|
|
1613
|
+
client.send({ type: "pauseLifecycle" });
|
|
1614
|
+
await client.waitFor(
|
|
1615
|
+
(message) => message.type === "lifecycleState" && message.paused === true,
|
|
1616
|
+
);
|
|
1617
|
+
await waitUntil(
|
|
1618
|
+
() => late.events.some((message) => message.type === "question"),
|
|
1619
|
+
"the late client did not receive the paused conversation",
|
|
1620
|
+
);
|
|
1621
|
+
|
|
1622
|
+
const pauseIndex = client.events.length;
|
|
1623
|
+
client.send({ type: "reviseGoal", request: "Clarify the goal" });
|
|
1624
|
+
const started = await waitForIndex(
|
|
1625
|
+
client,
|
|
1626
|
+
pauseIndex,
|
|
1627
|
+
(message) => message.type === "goalRevisionStarted",
|
|
1628
|
+
"the revision did not start",
|
|
1629
|
+
);
|
|
1630
|
+
const startedLate = await waitForIndex(
|
|
1631
|
+
late,
|
|
1632
|
+
0,
|
|
1633
|
+
(message) => message.type === "goalRevisionStarted",
|
|
1634
|
+
"the late client did not see the revision start",
|
|
1635
|
+
);
|
|
1636
|
+
const revisionQuestion = await waitForAt(
|
|
1637
|
+
client,
|
|
1638
|
+
started,
|
|
1639
|
+
(message) => message.type === "question",
|
|
1640
|
+
"the revision question was not relayed",
|
|
1641
|
+
);
|
|
1642
|
+
await waitForAt(
|
|
1643
|
+
late,
|
|
1644
|
+
startedLate,
|
|
1645
|
+
(message) => message.type === "question",
|
|
1646
|
+
"the late client did not see the revision question",
|
|
1647
|
+
);
|
|
1648
|
+
|
|
1649
|
+
client.send({ type: "answer", id: revisionQuestion.id, value: "Alpha" });
|
|
1650
|
+
const stopped = await waitForIndex(
|
|
1651
|
+
client,
|
|
1652
|
+
started,
|
|
1653
|
+
(message) => message.type === "goalRevisionStopped",
|
|
1654
|
+
"the revision did not stop",
|
|
1655
|
+
);
|
|
1656
|
+
const stoppedLate = await waitForIndex(
|
|
1657
|
+
late,
|
|
1658
|
+
startedLate,
|
|
1659
|
+
(message) => message.type === "goalRevisionStopped",
|
|
1660
|
+
"the late client did not see the revision stop",
|
|
1661
|
+
);
|
|
1662
|
+
|
|
1663
|
+
for (const target of [client, late]) {
|
|
1664
|
+
expect(
|
|
1665
|
+
[...target.events].reverse().find((message) => message.type === "laneState"),
|
|
1666
|
+
).toMatchObject({
|
|
1667
|
+
lanes: [
|
|
1668
|
+
{
|
|
1669
|
+
kind: "implementation",
|
|
1670
|
+
status: "paused",
|
|
1671
|
+
activity: ["Working on epic 31"],
|
|
1672
|
+
question: {
|
|
1673
|
+
id: lifecycleQuestion.id,
|
|
1674
|
+
message: lifecycleQuestion.message,
|
|
1675
|
+
options: lifecycleQuestion.options,
|
|
1676
|
+
},
|
|
1677
|
+
},
|
|
1678
|
+
],
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
expect(
|
|
1682
|
+
client.events
|
|
1683
|
+
.slice(stopped + 1)
|
|
1684
|
+
.filter((message) => message.type === "runStarted" && message.lane === undefined),
|
|
1685
|
+
).toEqual([]);
|
|
1686
|
+
expect(
|
|
1687
|
+
late.events
|
|
1688
|
+
.slice(stoppedLate + 1)
|
|
1689
|
+
.filter((message) => message.type === "runStarted" && message.lane === undefined),
|
|
1690
|
+
).toEqual([]);
|
|
1691
|
+
|
|
1692
|
+
client.send({ type: "answer", id: lifecycleQuestion.id, value: "Beta" });
|
|
1693
|
+
|
|
1694
|
+
const resumeIndex = client.events.length;
|
|
1695
|
+
client.send({ type: "resumeLifecycle" });
|
|
1696
|
+
expect(
|
|
1697
|
+
await waitForAt(
|
|
1698
|
+
client,
|
|
1699
|
+
resumeIndex,
|
|
1700
|
+
(message) => message.type === "lifecycleState" && message.paused === false,
|
|
1701
|
+
"the lifecycle did not resume after the revision",
|
|
1702
|
+
),
|
|
1703
|
+
).toEqual({
|
|
1704
|
+
type: "lifecycleState",
|
|
1705
|
+
stage: "implementation",
|
|
1706
|
+
pass: 1,
|
|
1707
|
+
work: { kind: "epic", id: "31", url: "https://example.test/issues/31" },
|
|
1708
|
+
paused: false,
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
await waitUntil(
|
|
1712
|
+
() => logLines(logPath).filter((line) => line === "answer Beta").length === 1,
|
|
1713
|
+
"the replayed question did not reach the suspended peer",
|
|
1714
|
+
10000,
|
|
1715
|
+
);
|
|
1716
|
+
expect(client.events.filter((message) => message.type === "error")).toEqual([]);
|
|
1717
|
+
expect(
|
|
1718
|
+
await waitForAt(
|
|
1719
|
+
client,
|
|
1720
|
+
resumeIndex,
|
|
1721
|
+
(message) =>
|
|
1722
|
+
message.type === "lifecycleState" &&
|
|
1723
|
+
message.stage === "epic" &&
|
|
1724
|
+
message.pass === 2,
|
|
1725
|
+
"the lifecycle did not advance after the resumed stage",
|
|
1726
|
+
10000,
|
|
1727
|
+
),
|
|
1728
|
+
).toEqual({
|
|
1729
|
+
type: "lifecycleState",
|
|
1730
|
+
stage: "epic",
|
|
1731
|
+
pass: 2,
|
|
1732
|
+
work: null,
|
|
1733
|
+
paused: false,
|
|
1734
|
+
});
|
|
1735
|
+
} finally {
|
|
1736
|
+
client.close();
|
|
1737
|
+
late.close();
|
|
1738
|
+
server.stop(true);
|
|
1739
|
+
}
|
|
1740
|
+
},
|
|
1741
|
+
40000,
|
|
1742
|
+
);
|
|
1743
|
+
|
|
1744
|
+
test(
|
|
1745
|
+
"retains the lifecycle pass across a server restart",
|
|
1746
|
+
async () => {
|
|
1747
|
+
const epic: OpenEpic = {
|
|
1748
|
+
number: 77,
|
|
1749
|
+
title: "Restart the pass",
|
|
1750
|
+
url: "https://example.test/issues/77",
|
|
1751
|
+
};
|
|
1752
|
+
let lookups = 0;
|
|
1753
|
+
const first = await startServer(
|
|
1754
|
+
{},
|
|
1755
|
+
{
|
|
1756
|
+
agentArguments: "--delay-ms=100 --questions=0",
|
|
1757
|
+
tracker: async () => {
|
|
1758
|
+
lookups += 1;
|
|
1759
|
+
return lookups === 1 ? epic : undefined;
|
|
1760
|
+
},
|
|
1761
|
+
},
|
|
1762
|
+
);
|
|
1763
|
+
const client = await connect(first.url);
|
|
1764
|
+
|
|
1765
|
+
try {
|
|
1766
|
+
client.send({ type: "startLifecycle" });
|
|
1767
|
+
expect(
|
|
1768
|
+
await client.waitFor(
|
|
1769
|
+
(message) => message.type === "lifecycleState" && message.pass === 2,
|
|
1770
|
+
),
|
|
1771
|
+
).toMatchObject({ stage: "epic", pass: 2 });
|
|
1772
|
+
} finally {
|
|
1773
|
+
client.close();
|
|
1774
|
+
first.server.stop(true);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
const second = await startServer({}, { root: first.root, tracker: async () => undefined });
|
|
1778
|
+
const resumed = await connect(second.url);
|
|
1779
|
+
|
|
1780
|
+
try {
|
|
1781
|
+
expect(await resumed.waitFor((message) => message.type === "lifecycleState")).toEqual({
|
|
1782
|
+
type: "lifecycleState",
|
|
1783
|
+
stage: "goal",
|
|
1784
|
+
pass: 2,
|
|
1785
|
+
work: null,
|
|
1786
|
+
paused: false,
|
|
1787
|
+
});
|
|
1788
|
+
} finally {
|
|
1789
|
+
resumed.close();
|
|
1790
|
+
second.server.stop(true);
|
|
1791
|
+
}
|
|
1792
|
+
},
|
|
1793
|
+
30000,
|
|
1794
|
+
);
|
|
1795
|
+
});
|