@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
package/issues/worker.ts
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readlinkSync, rmSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { AcpConnection, type AcpActivity, type AcpQuestion } from "../server/acp";
|
|
4
|
+
import { recordedChildAlive } from "../server/state";
|
|
5
|
+
import { readHarnessVersion } from "../server/version";
|
|
6
|
+
import { takeCommands, type IssuesCommand } from "./control";
|
|
7
|
+
import {
|
|
8
|
+
emptyLedger,
|
|
9
|
+
loadLedger,
|
|
10
|
+
pruneLedger,
|
|
11
|
+
recordHandled,
|
|
12
|
+
writeLedger,
|
|
13
|
+
type Ledger,
|
|
14
|
+
} from "./ledger";
|
|
15
|
+
import { migrateLegacyLedger } from "./legacy";
|
|
16
|
+
import { acquireLock, liveLockOwner } from "./lock";
|
|
17
|
+
import { originForge, originRepository } from "./repository";
|
|
18
|
+
import { giteaBaseUrl } from "../lib/forge";
|
|
19
|
+
import { controlDirectory, lockPath, runsDirectory, statePath } from "./paths";
|
|
20
|
+
import { pollOpenIssues } from "./tracker";
|
|
21
|
+
import {
|
|
22
|
+
appendActivityLine,
|
|
23
|
+
emptyState,
|
|
24
|
+
listRunDirectories,
|
|
25
|
+
loadState,
|
|
26
|
+
parseRunFile,
|
|
27
|
+
readActivity,
|
|
28
|
+
runFilePath,
|
|
29
|
+
runSummary,
|
|
30
|
+
writeActivity,
|
|
31
|
+
writeRunFile,
|
|
32
|
+
writeState,
|
|
33
|
+
type IssuesAcpChild,
|
|
34
|
+
type IssuesAgent,
|
|
35
|
+
type IssuesIssue,
|
|
36
|
+
type IssuesQuestion,
|
|
37
|
+
type IssuesRunStatus,
|
|
38
|
+
type IssuesRunSummary,
|
|
39
|
+
type IssuesState,
|
|
40
|
+
type QueuedIssue,
|
|
41
|
+
type RunFile,
|
|
42
|
+
} from "./state";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The detached worker for exactly one repository. It owns the poll loop, the
|
|
46
|
+
* ACP peers, the ledger, the repository lock, and the activity, so it outlives
|
|
47
|
+
* any dashboard process. Every open, unhandled issue gets its own peer in the
|
|
48
|
+
* same poll, with no concurrency cap, and all of them run at the same time.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const DEFAULT_INTERVAL_MS = 10_000;
|
|
52
|
+
const COOLDOWN_MS = 5 * 60_000;
|
|
53
|
+
const TICK_MS = 100;
|
|
54
|
+
const IMPLEMENT_ISSUE_COMMAND = "/implement-issue";
|
|
55
|
+
const IMPLEMENT_GOAL_COMMAND = "/implement-goal-issue";
|
|
56
|
+
const GOAL_LABEL = "goal";
|
|
57
|
+
const DEFAULT_AGENT_COMMAND = "omp acp";
|
|
58
|
+
const FIRST_LINE_MAX = 400;
|
|
59
|
+
const RUN_HISTORY_LIMIT = 50;
|
|
60
|
+
|
|
61
|
+
export interface WorkerOptions {
|
|
62
|
+
repoDir: string;
|
|
63
|
+
stateDir: string;
|
|
64
|
+
agentCommand?: string;
|
|
65
|
+
environment?: Record<string, string | undefined>;
|
|
66
|
+
intervalMs?: number;
|
|
67
|
+
now?: () => Date;
|
|
68
|
+
/** Injected for tests; defaults to the conditional `gh` tracker. */
|
|
69
|
+
listIssues?: (options: { etag: string | null }) => Promise<
|
|
70
|
+
| { kind: "issues"; issues: IssuesIssue[]; etag: string | null }
|
|
71
|
+
| { kind: "not-modified"; etag: string | null }
|
|
72
|
+
| { kind: "rate-limited"; until: string; detail: string }
|
|
73
|
+
| { kind: "error"; detail: string }
|
|
74
|
+
>;
|
|
75
|
+
/** Injected for tests; defaults to the real ACP client. */
|
|
76
|
+
connect?: (options: IssuesPeerOptions) => AcpPeer;
|
|
77
|
+
harnessVersion?: string | null;
|
|
78
|
+
log?: (line: string) => void;
|
|
79
|
+
/** Runs after Stop has written its state and released the lock. */
|
|
80
|
+
onStopped?: () => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AcpPeer {
|
|
84
|
+
readonly pid: number | undefined;
|
|
85
|
+
readonly argv: string[];
|
|
86
|
+
run(prompt: string): Promise<unknown>;
|
|
87
|
+
/** Sends another prompt on the session `run` opened. */
|
|
88
|
+
prompt(prompt: string): Promise<unknown>;
|
|
89
|
+
answer(id: number | string, value: string): boolean;
|
|
90
|
+
close(): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface IssuesPeerOptions {
|
|
94
|
+
command: string;
|
|
95
|
+
cwd: string;
|
|
96
|
+
onActivity: (activity: AcpActivity) => void;
|
|
97
|
+
onQuestion: (question: AcpQuestion) => void;
|
|
98
|
+
onStderr: (text: string) => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class FirstLine {
|
|
102
|
+
private buffer = "";
|
|
103
|
+
private found = false;
|
|
104
|
+
value: string | null = null;
|
|
105
|
+
|
|
106
|
+
push(chunk: string): void {
|
|
107
|
+
if (this.found) return;
|
|
108
|
+
this.buffer += chunk;
|
|
109
|
+
const line = this.buffer
|
|
110
|
+
.split("\n")
|
|
111
|
+
.map((entry) => entry.replace(/\s+$/, ""))
|
|
112
|
+
.find((entry) => entry.trim().length > 0);
|
|
113
|
+
if (line !== undefined) {
|
|
114
|
+
this.found = true;
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
this.value =
|
|
117
|
+
trimmed.length <= FIRST_LINE_MAX ? trimmed : `${trimmed.slice(0, FIRST_LINE_MAX - 1)}…`;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (this.buffer.length > FIRST_LINE_MAX * 8) this.buffer = this.buffer.slice(-FIRST_LINE_MAX);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
finish(): void {
|
|
124
|
+
if (this.found) return;
|
|
125
|
+
const trimmed = this.buffer.trim();
|
|
126
|
+
if (trimmed.length === 0) return;
|
|
127
|
+
this.value =
|
|
128
|
+
trimmed.length <= FIRST_LINE_MAX ? trimmed : `${trimmed.slice(0, FIRST_LINE_MAX - 1)}…`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface ActiveRun {
|
|
133
|
+
runId: string;
|
|
134
|
+
directory: string;
|
|
135
|
+
startedAt: string;
|
|
136
|
+
issue: IssuesIssue;
|
|
137
|
+
captured: IssuesIssue[];
|
|
138
|
+
activity: string[];
|
|
139
|
+
peer: AcpPeer;
|
|
140
|
+
firstLine: FirstLine;
|
|
141
|
+
finished: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The child as the process table sees it, for a later proven termination. */
|
|
145
|
+
function observedChild(
|
|
146
|
+
pid: number | undefined,
|
|
147
|
+
cwd: string,
|
|
148
|
+
): { pid: number; argv: string[]; cwd: string } | null {
|
|
149
|
+
if (pid === undefined) return null;
|
|
150
|
+
try {
|
|
151
|
+
const argv = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\u0000").filter(Boolean);
|
|
152
|
+
const actual = readlinkSync(`/proc/${pid}/cwd`);
|
|
153
|
+
return argv.length === 0 ? null : { pid, argv, cwd: actual === cwd ? cwd : actual };
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function uniqueRunDirectory(base: string, runId: string): { directory: string; runId: string } {
|
|
160
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
161
|
+
const candidate = attempt === 0 ? runId : `${runId}-${attempt}`;
|
|
162
|
+
try {
|
|
163
|
+
mkdirSync(join(base, candidate));
|
|
164
|
+
return { directory: join(base, candidate), runId: candidate };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`could not create a run directory under ${base}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function runIdentifier(now: Date): string {
|
|
173
|
+
return now.toISOString().replace(/[:.]/g, "-");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Loads every run record, newest first: every running run plus the newest
|
|
178
|
+
* `limit` finished ones. A running agent never loses its directory.
|
|
179
|
+
*/
|
|
180
|
+
export function loadRuns(
|
|
181
|
+
stateDir: string,
|
|
182
|
+
repoDir: string,
|
|
183
|
+
limit = RUN_HISTORY_LIMIT,
|
|
184
|
+
): Array<{ run: RunFile; directory: string; activity: number }> {
|
|
185
|
+
const found: Array<{ run: RunFile; directory: string; activity: number }> = [];
|
|
186
|
+
let finished = 0;
|
|
187
|
+
for (const name of listRunDirectories(stateDir, repoDir)) {
|
|
188
|
+
const directory = join(runsDirectory(stateDir, repoDir), name);
|
|
189
|
+
try {
|
|
190
|
+
const run = parseRunFile(readFileSync(runFilePath(directory), "utf8"));
|
|
191
|
+
if (run === undefined) continue;
|
|
192
|
+
if (run.status === "running") {
|
|
193
|
+
found.push({ run, directory, activity: readActivity(directory).length });
|
|
194
|
+
} else if (finished < limit) {
|
|
195
|
+
found.push({ run, directory, activity: readActivity(directory).length });
|
|
196
|
+
finished += 1;
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// A run file that vanished mid-scan is already gone.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return found;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function isGoalIssue(issue: IssuesIssue): boolean {
|
|
206
|
+
return (issue.labels ?? []).includes(GOAL_LABEL);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The exact prompt for one issue: `/implement-issue` or `/implement-goal-issue`. */
|
|
210
|
+
export function promptFor(issue: IssuesIssue): string {
|
|
211
|
+
const command = isGoalIssue(issue) ? IMPLEMENT_GOAL_COMMAND : IMPLEMENT_ISSUE_COMMAND;
|
|
212
|
+
return `${command} ${issue.number}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Reads the previous state file's recorded ACP children and run ids verbatim. */
|
|
216
|
+
function previousRecords(path: string): {
|
|
217
|
+
children: IssuesAcpChild[];
|
|
218
|
+
runIds: string[];
|
|
219
|
+
} {
|
|
220
|
+
let parsed: unknown;
|
|
221
|
+
try {
|
|
222
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
223
|
+
} catch {
|
|
224
|
+
return { children: [], runIds: [] };
|
|
225
|
+
}
|
|
226
|
+
if (typeof parsed !== "object" || parsed === null) return { children: [], runIds: [] };
|
|
227
|
+
const record = parsed as Record<string, unknown>;
|
|
228
|
+
const children: IssuesAcpChild[] = [];
|
|
229
|
+
const runIds: string[] = [];
|
|
230
|
+
const collect = (value: unknown): void => {
|
|
231
|
+
if (typeof value !== "object" || value === null) return;
|
|
232
|
+
const candidate = value as Record<string, unknown>;
|
|
233
|
+
if (
|
|
234
|
+
typeof candidate.pid === "number" &&
|
|
235
|
+
Number.isInteger(candidate.pid) &&
|
|
236
|
+
candidate.pid > 0 &&
|
|
237
|
+
Array.isArray(candidate.argv) &&
|
|
238
|
+
candidate.argv.every((entry) => typeof entry === "string") &&
|
|
239
|
+
typeof candidate.cwd === "string"
|
|
240
|
+
) {
|
|
241
|
+
children.push({
|
|
242
|
+
pid: candidate.pid,
|
|
243
|
+
argv: candidate.argv as string[],
|
|
244
|
+
cwd: candidate.cwd,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
const agents = Array.isArray(record.agents) ? record.agents : [];
|
|
249
|
+
for (const agent of agents) {
|
|
250
|
+
if (typeof agent !== "object" || agent === null) continue;
|
|
251
|
+
const entry = agent as Record<string, unknown>;
|
|
252
|
+
if (typeof entry.runId === "string") runIds.push(entry.runId);
|
|
253
|
+
collect(entry.acp);
|
|
254
|
+
}
|
|
255
|
+
collect(record.acp);
|
|
256
|
+
if (typeof record.activeRun === "object" && record.activeRun !== null) {
|
|
257
|
+
const active = record.activeRun as Record<string, unknown>;
|
|
258
|
+
if (typeof active.runId === "string") runIds.push(active.runId);
|
|
259
|
+
}
|
|
260
|
+
return { children, runIds };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export class IssuesWorker {
|
|
264
|
+
readonly repoDir: string;
|
|
265
|
+
readonly stateDir: string;
|
|
266
|
+
readonly intervalMs: number;
|
|
267
|
+
private readonly agentCommand: string;
|
|
268
|
+
private readonly environment: Record<string, string | undefined>;
|
|
269
|
+
private readonly clock: () => Date;
|
|
270
|
+
private readonly listIssues: NonNullable<WorkerOptions["listIssues"]>;
|
|
271
|
+
private readonly connect: NonNullable<WorkerOptions["connect"]>;
|
|
272
|
+
private readonly harnessVersion: string | null;
|
|
273
|
+
private readonly log: (line: string) => void;
|
|
274
|
+
|
|
275
|
+
private state: IssuesState;
|
|
276
|
+
private ledger: Ledger;
|
|
277
|
+
private readonly active = new Map<number, ActiveRun>();
|
|
278
|
+
private runs: Array<{ run: RunFile; directory: string }> = [];
|
|
279
|
+
private stopping = false;
|
|
280
|
+
private polling = false;
|
|
281
|
+
private nextPollDue = 0;
|
|
282
|
+
private ticker: Timer | undefined;
|
|
283
|
+
private lock: { release: () => void } | undefined;
|
|
284
|
+
private lastSaved = "";
|
|
285
|
+
private readonly onStopped: (() => void) | undefined;
|
|
286
|
+
|
|
287
|
+
constructor(options: WorkerOptions) {
|
|
288
|
+
this.repoDir = resolve(options.repoDir);
|
|
289
|
+
this.stateDir = resolve(options.stateDir);
|
|
290
|
+
this.intervalMs = Math.max(100, options.intervalMs ?? DEFAULT_INTERVAL_MS);
|
|
291
|
+
this.agentCommand =
|
|
292
|
+
options.agentCommand ??
|
|
293
|
+
options.environment?.AGENT_COMMAND ??
|
|
294
|
+
process.env.AGENT_COMMAND ??
|
|
295
|
+
DEFAULT_AGENT_COMMAND;
|
|
296
|
+
this.environment = options.environment ?? process.env;
|
|
297
|
+
this.clock = options.now ?? (() => new Date());
|
|
298
|
+
this.listIssues =
|
|
299
|
+
options.listIssues ??
|
|
300
|
+
((request) => {
|
|
301
|
+
const remote = originForge(this.repoDir, this.environment);
|
|
302
|
+
return pollOpenIssues({
|
|
303
|
+
repository: this.state.repo,
|
|
304
|
+
etag: request.etag,
|
|
305
|
+
environment: this.environment,
|
|
306
|
+
...(remote === undefined ? {} : { forge: remote.forge }),
|
|
307
|
+
...(remote?.forge === "gitea"
|
|
308
|
+
? { baseUrl: giteaBaseUrl(remote, this.environment) }
|
|
309
|
+
: {}),
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
this.connect =
|
|
313
|
+
options.connect ?? ((peerOptions) => new AcpConnection(peerOptions) as unknown as AcpPeer);
|
|
314
|
+
this.harnessVersion = options.harnessVersion ?? readHarnessVersion(resolve(import.meta.dir, ".."));
|
|
315
|
+
this.log = options.log ?? (() => undefined);
|
|
316
|
+
this.onStopped = options.onStopped;
|
|
317
|
+
this.state = emptyState({
|
|
318
|
+
repo: "",
|
|
319
|
+
root: this.repoDir,
|
|
320
|
+
intervalMs: this.intervalMs,
|
|
321
|
+
now: this.clock(),
|
|
322
|
+
});
|
|
323
|
+
this.ledger = emptyLedger();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
get currentState(): IssuesState {
|
|
327
|
+
return this.state;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
get running(): boolean {
|
|
331
|
+
return this.active.size > 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Takes the repository lock and publishes the fresh `idle` cycle. */
|
|
335
|
+
start(repository: string): boolean {
|
|
336
|
+
const lockResult = acquireLock(lockPath(this.repoDir), {
|
|
337
|
+
repo: this.repoDir,
|
|
338
|
+
stateDir: this.stateDir,
|
|
339
|
+
harnessVersion: this.harnessVersion,
|
|
340
|
+
argv: [...process.argv],
|
|
341
|
+
});
|
|
342
|
+
if (!lockResult.acquired) {
|
|
343
|
+
this.log(`already running for ${this.repoDir}`);
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
this.lock = lockResult;
|
|
347
|
+
|
|
348
|
+
const path = statePath(this.stateDir, this.repoDir);
|
|
349
|
+
const previous = loadState(path);
|
|
350
|
+
const records = previousRecords(path);
|
|
351
|
+
this.ledger = loadLedger(this.stateDir, this.repoDir);
|
|
352
|
+
let migrationError: string | null = null;
|
|
353
|
+
const migrated = migrateLegacyLedger({
|
|
354
|
+
stateDir: this.stateDir,
|
|
355
|
+
repoDir: this.repoDir,
|
|
356
|
+
now: this.clock(),
|
|
357
|
+
});
|
|
358
|
+
if (migrated !== undefined && migrated.length > 0) {
|
|
359
|
+
const handled = { ...this.ledger.handled };
|
|
360
|
+
for (const entry of migrated) {
|
|
361
|
+
handled[String(entry.number)] = entry;
|
|
362
|
+
}
|
|
363
|
+
this.ledger = { version: 1, handled };
|
|
364
|
+
migrationError = this.persistLedger();
|
|
365
|
+
this.log(`migrated ${migrated.length} handled issue(s) from the legacy orchestrator`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
this.runs = loadRuns(this.stateDir, this.repoDir).map((entry) => ({
|
|
369
|
+
run: entry.run,
|
|
370
|
+
directory: entry.directory,
|
|
371
|
+
}));
|
|
372
|
+
// A worker that died mid-run leaves recorded children and `running`
|
|
373
|
+
// records. Only a process the table proves is that child is signalled.
|
|
374
|
+
for (const orphan of records.children) {
|
|
375
|
+
if (!recordedChildAlive(orphan)) continue;
|
|
376
|
+
try {
|
|
377
|
+
process.kill(orphan.pid, "SIGKILL");
|
|
378
|
+
this.log(`terminated orphaned ACP child ${orphan.pid}`);
|
|
379
|
+
} catch {
|
|
380
|
+
// It exited between the proof and the signal.
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const interrupted = new Set([
|
|
384
|
+
...records.runIds,
|
|
385
|
+
...this.runs.filter((entry) => entry.run.status === "running").map((entry) => entry.run.runId),
|
|
386
|
+
]);
|
|
387
|
+
for (const runId of interrupted) this.closeInterruptedRun(runId);
|
|
388
|
+
this.active.clear();
|
|
389
|
+
|
|
390
|
+
const now = this.clock();
|
|
391
|
+
this.state = {
|
|
392
|
+
...emptyState({
|
|
393
|
+
repo: repository,
|
|
394
|
+
root: this.repoDir,
|
|
395
|
+
intervalMs: this.intervalMs,
|
|
396
|
+
now,
|
|
397
|
+
}),
|
|
398
|
+
lifecycle: "idle",
|
|
399
|
+
enabled: true,
|
|
400
|
+
worker: {
|
|
401
|
+
pid: process.pid,
|
|
402
|
+
startedAt: now.toISOString(),
|
|
403
|
+
stateDir: this.stateDir,
|
|
404
|
+
harnessVersion: this.harnessVersion,
|
|
405
|
+
argv: [...process.argv],
|
|
406
|
+
},
|
|
407
|
+
runs: this.summaries(),
|
|
408
|
+
updatedAt: now.toISOString(),
|
|
409
|
+
};
|
|
410
|
+
if (migrationError !== null) this.state.lastError = migrationError;
|
|
411
|
+
void previous;
|
|
412
|
+
this.nextPollDue = now.getTime();
|
|
413
|
+
this.publish();
|
|
414
|
+
this.startTicker();
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private closeInterruptedRun(runId: string): void {
|
|
419
|
+
const entry = this.runs.find((candidate) => candidate.run.runId === runId);
|
|
420
|
+
if (entry === undefined) return;
|
|
421
|
+
const run: RunFile = {
|
|
422
|
+
...entry.run,
|
|
423
|
+
status: "stopped",
|
|
424
|
+
finishedAt: this.clock().toISOString(),
|
|
425
|
+
exitCode: null,
|
|
426
|
+
};
|
|
427
|
+
try {
|
|
428
|
+
writeRunFile(entry.directory, run);
|
|
429
|
+
entry.run = run;
|
|
430
|
+
} catch {
|
|
431
|
+
// The record stays as it was; the next successful run rewrites history.
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private summaries(): IssuesRunSummary[] {
|
|
436
|
+
return this.runs.map((entry) => runSummary(entry.run, readActivity(entry.directory).length));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private publish(): void {
|
|
440
|
+
this.state.updatedAt = this.clock().toISOString();
|
|
441
|
+
const text = JSON.stringify(this.state);
|
|
442
|
+
if (text === this.lastSaved) return;
|
|
443
|
+
try {
|
|
444
|
+
writeState(statePath(this.stateDir, this.repoDir), this.state);
|
|
445
|
+
this.lastSaved = text;
|
|
446
|
+
} catch (error) {
|
|
447
|
+
this.log(`could not write state: ${error instanceof Error ? error.message : String(error)}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private startTicker(): void {
|
|
452
|
+
if (this.ticker !== undefined) return;
|
|
453
|
+
this.ticker = setInterval(() => {
|
|
454
|
+
void this.tick();
|
|
455
|
+
}, TICK_MS);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private async tick(): Promise<void> {
|
|
459
|
+
if (this.stopping) return;
|
|
460
|
+
await this.guarded("consume control commands", () => this.consumeCommands());
|
|
461
|
+
if (this.stopping) return;
|
|
462
|
+
if (!this.state.enabled) return;
|
|
463
|
+
if (this.polling) return;
|
|
464
|
+
if (this.clock().getTime() < this.nextPollDue) return;
|
|
465
|
+
await this.guarded("poll the tracker", () => this.poll());
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* One tick stage. A thrown stage is recorded and never rejects the ticker:
|
|
470
|
+
* the interval keeps running and the next tick retries.
|
|
471
|
+
*/
|
|
472
|
+
private async guarded(stage: string, action: () => Promise<void>): Promise<void> {
|
|
473
|
+
try {
|
|
474
|
+
await action();
|
|
475
|
+
} catch (error) {
|
|
476
|
+
this.recordFailure(stage, error);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private recordFailure(stage: string, error: unknown): void {
|
|
481
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
482
|
+
this.state.lastError = `the issues worker could not ${stage}: ${detail}`;
|
|
483
|
+
this.log(this.state.lastError);
|
|
484
|
+
this.publish();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Persists the ledger; an I/O failure is recorded, never thrown. */
|
|
488
|
+
private persistLedger(): string | null {
|
|
489
|
+
try {
|
|
490
|
+
writeLedger(this.stateDir, this.repoDir, this.ledger);
|
|
491
|
+
return null;
|
|
492
|
+
} catch (error) {
|
|
493
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
494
|
+
this.log(`could not write the ledger: ${detail}`);
|
|
495
|
+
return `could not write the ledger: ${detail}`;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private async consumeCommands(): Promise<void> {
|
|
500
|
+
const commands = takeCommands(controlDirectory(this.stateDir, this.repoDir));
|
|
501
|
+
for (const command of commands) {
|
|
502
|
+
if (this.stopping) return;
|
|
503
|
+
await this.apply(command);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private findActive(runId: string): ActiveRun | undefined {
|
|
508
|
+
for (const run of this.active.values()) {
|
|
509
|
+
if (run.runId === runId) return run;
|
|
510
|
+
}
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async apply(command: IssuesCommand): Promise<void> {
|
|
515
|
+
switch (command.type) {
|
|
516
|
+
case "pause": {
|
|
517
|
+
this.state.paused = true;
|
|
518
|
+
this.state.notice = null;
|
|
519
|
+
this.state.lifecycle = this.running ? "pausing" : "paused";
|
|
520
|
+
this.publish();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
case "resume": {
|
|
524
|
+
this.state.paused = false;
|
|
525
|
+
this.state.notice = null;
|
|
526
|
+
this.state.lifecycle = this.settledLifecycle();
|
|
527
|
+
this.nextPollDue = this.clock().getTime();
|
|
528
|
+
this.publish();
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
case "check": {
|
|
532
|
+
if (this.state.paused) {
|
|
533
|
+
this.state.notice = "Check now is unavailable while paused.";
|
|
534
|
+
this.publish();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (!this.state.enabled) {
|
|
538
|
+
this.state.notice = "Start the issues worker before checking now.";
|
|
539
|
+
this.publish();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
this.state.cooldowns = {};
|
|
543
|
+
this.state.notice = null;
|
|
544
|
+
if (this.state.lifecycle === "failed") this.state.lifecycle = "idle";
|
|
545
|
+
this.nextPollDue = this.clock().getTime();
|
|
546
|
+
this.publish();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
case "answer": {
|
|
550
|
+
const runId = command.runId;
|
|
551
|
+
if (typeof runId !== "string" || runId.length === 0) {
|
|
552
|
+
this.state.notice = "An answer needs its run id.";
|
|
553
|
+
this.publish();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const run = this.findActive(runId);
|
|
557
|
+
const question = run === undefined ? null : this.questionFor(run);
|
|
558
|
+
if (run === undefined || question === null || String(question.id) !== String(command.id)) {
|
|
559
|
+
this.state.notice = "That question is no longer waiting for an answer.";
|
|
560
|
+
this.publish();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const answered = run.peer.answer(command.id, command.value);
|
|
564
|
+
if (!answered) {
|
|
565
|
+
this.state.notice = "That question is no longer waiting for an answer.";
|
|
566
|
+
this.publish();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
this.state.agents = this.state.agents.map((agent) =>
|
|
570
|
+
agent.runId === run.runId ? { ...agent, question: null } : agent,
|
|
571
|
+
);
|
|
572
|
+
this.state.notice = null;
|
|
573
|
+
this.publish();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
case "stop": {
|
|
577
|
+
await this.stop();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private questionFor(run: ActiveRun): IssuesQuestion | null {
|
|
584
|
+
return this.state.agents.find((agent) => agent.runId === run.runId)?.question ?? null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private cooldownRemaining(number: number): boolean {
|
|
588
|
+
const until = this.state.cooldowns[String(number)];
|
|
589
|
+
if (until === undefined) return false;
|
|
590
|
+
const at = Date.parse(until);
|
|
591
|
+
return Number.isFinite(at) && this.clock().getTime() < at;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private anyCooldownRemaining(): boolean {
|
|
595
|
+
return Object.values(this.state.cooldowns).some((until) => {
|
|
596
|
+
const at = Date.parse(until);
|
|
597
|
+
return Number.isFinite(at) && this.clock().getTime() < at;
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private settledLifecycle(): IssuesState["lifecycle"] {
|
|
602
|
+
if (this.stopping) return "stopped";
|
|
603
|
+
if (this.state.paused) return this.running ? "pausing" : "paused";
|
|
604
|
+
if (this.running) return "running";
|
|
605
|
+
if (!this.state.enabled) return "stopped";
|
|
606
|
+
const unhandled = this.state.queue.filter((issue) => !issue.handled);
|
|
607
|
+
if (unhandled.length === 0) return "idle";
|
|
608
|
+
return "failed";
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** One conditional poll, then the trigger decision. */
|
|
612
|
+
async poll(): Promise<void> {
|
|
613
|
+
if (this.polling || this.stopping) return;
|
|
614
|
+
this.polling = true;
|
|
615
|
+
const startedAt = this.clock();
|
|
616
|
+
try {
|
|
617
|
+
const result = await this.listIssues({ etag: this.state.poll.etag });
|
|
618
|
+
if (this.stopping) return;
|
|
619
|
+
this.state.poll.lastPollAt = startedAt.toISOString();
|
|
620
|
+
switch (result.kind) {
|
|
621
|
+
case "not-modified": {
|
|
622
|
+
this.state.poll.etag = result.etag;
|
|
623
|
+
this.state.poll.notModified = true;
|
|
624
|
+
this.state.poll.error = null;
|
|
625
|
+
this.state.poll.rateLimitedUntil = null;
|
|
626
|
+
this.state.poll.nextPollAt = new Date(startedAt.getTime() + this.intervalMs).toISOString();
|
|
627
|
+
this.nextPollDue = Date.parse(this.state.poll.nextPollAt);
|
|
628
|
+
this.refreshQueue();
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
case "issues": {
|
|
632
|
+
this.state.poll.etag = result.etag;
|
|
633
|
+
this.state.poll.notModified = false;
|
|
634
|
+
this.state.poll.error = null;
|
|
635
|
+
this.state.poll.rateLimitedUntil = null;
|
|
636
|
+
this.state.poll.nextPollAt = new Date(startedAt.getTime() + this.intervalMs).toISOString();
|
|
637
|
+
this.nextPollDue = Date.parse(this.state.poll.nextPollAt);
|
|
638
|
+
this.applyIssues(result.issues);
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
case "rate-limited": {
|
|
642
|
+
this.state.poll.error = result.detail;
|
|
643
|
+
this.state.poll.rateLimitedUntil = result.until;
|
|
644
|
+
this.state.poll.nextPollAt = result.until;
|
|
645
|
+
this.nextPollDue = Date.parse(result.until);
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
case "error": {
|
|
649
|
+
this.state.poll.error = result.detail;
|
|
650
|
+
this.state.poll.nextPollAt = new Date(startedAt.getTime() + this.intervalMs).toISOString();
|
|
651
|
+
this.nextPollDue = Date.parse(this.state.poll.nextPollAt);
|
|
652
|
+
this.state.lastError = result.detail;
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
this.publish();
|
|
657
|
+
this.maybeTrigger();
|
|
658
|
+
} finally {
|
|
659
|
+
this.polling = false;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private applyIssues(issues: IssuesIssue[]): void {
|
|
664
|
+
const open = new Set(issues.map((issue) => issue.number));
|
|
665
|
+
const pruned = pruneLedger(this.ledger, open);
|
|
666
|
+
if (pruned !== this.ledger) {
|
|
667
|
+
this.ledger = pruned;
|
|
668
|
+
const ledgerError = this.persistLedger();
|
|
669
|
+
if (ledgerError !== null) this.state.lastError = ledgerError;
|
|
670
|
+
}
|
|
671
|
+
const cooldowns = Object.fromEntries(
|
|
672
|
+
Object.entries(this.state.cooldowns).filter(([key]) => open.has(Number(key))),
|
|
673
|
+
);
|
|
674
|
+
if (Object.keys(cooldowns).length !== Object.keys(this.state.cooldowns).length) {
|
|
675
|
+
this.state.cooldowns = cooldowns;
|
|
676
|
+
}
|
|
677
|
+
this.state.queue = issues.map((issue) => this.queued(issue));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private queued(issue: IssuesIssue): QueuedIssue {
|
|
681
|
+
const handled = this.ledger.handled[String(issue.number)];
|
|
682
|
+
return {
|
|
683
|
+
...issue,
|
|
684
|
+
handled: handled !== undefined,
|
|
685
|
+
runId: handled?.runId ?? null,
|
|
686
|
+
agentRunId: this.active.get(issue.number)?.runId ?? null,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private refreshQueue(): void {
|
|
691
|
+
this.state.queue = this.state.queue.map((issue) => this.queued(issue));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private maybeTrigger(): void {
|
|
695
|
+
if (this.stopping || this.state.paused || !this.state.enabled) return;
|
|
696
|
+
for (const issue of this.state.queue) {
|
|
697
|
+
if (issue.handled) continue;
|
|
698
|
+
if (this.active.has(issue.number)) continue;
|
|
699
|
+
if (this.cooldownRemaining(issue.number)) continue;
|
|
700
|
+
this.begin(issue);
|
|
701
|
+
}
|
|
702
|
+
this.state.lifecycle = this.settledLifecycle();
|
|
703
|
+
this.publish();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private acpChild(active: ActiveRun): IssuesAcpChild | null {
|
|
707
|
+
const observed = observedChild(active.peer.pid, this.repoDir);
|
|
708
|
+
if (observed !== null) return observed;
|
|
709
|
+
if (active.peer.pid === undefined || active.peer.pid <= 0) return null;
|
|
710
|
+
return { pid: active.peer.pid, argv: [...active.peer.argv], cwd: this.repoDir };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private openRun(
|
|
714
|
+
issue: IssuesIssue,
|
|
715
|
+
): { active: ActiveRun } | { runId: string; directory: string; error: string } {
|
|
716
|
+
const now = this.clock();
|
|
717
|
+
const base = runsDirectory(this.stateDir, this.repoDir);
|
|
718
|
+
mkdirSync(base, { recursive: true });
|
|
719
|
+
const created = uniqueRunDirectory(base, runIdentifier(now));
|
|
720
|
+
const activity: string[] = [];
|
|
721
|
+
writeActivity(created.directory, activity);
|
|
722
|
+
const run: RunFile = {
|
|
723
|
+
version: 1,
|
|
724
|
+
runId: created.runId,
|
|
725
|
+
repo: this.state.repo,
|
|
726
|
+
status: "running",
|
|
727
|
+
argv: this.agentCommand.trim().split(/\s+/).filter(Boolean),
|
|
728
|
+
cwd: this.repoDir,
|
|
729
|
+
startedAt: now.toISOString(),
|
|
730
|
+
finishedAt: null,
|
|
731
|
+
exitCode: null,
|
|
732
|
+
issues: [issue],
|
|
733
|
+
firstStderrLine: null,
|
|
734
|
+
workerPid: process.pid,
|
|
735
|
+
harnessVersion: this.harnessVersion,
|
|
736
|
+
};
|
|
737
|
+
writeRunFile(created.directory, run);
|
|
738
|
+
this.runs.unshift({ run, directory: created.directory });
|
|
739
|
+
this.pruneHistory();
|
|
740
|
+
|
|
741
|
+
const firstLine = new FirstLine();
|
|
742
|
+
const active: ActiveRun = {
|
|
743
|
+
runId: created.runId,
|
|
744
|
+
directory: created.directory,
|
|
745
|
+
startedAt: run.startedAt,
|
|
746
|
+
issue,
|
|
747
|
+
captured: [issue],
|
|
748
|
+
activity,
|
|
749
|
+
peer: undefined as unknown as AcpPeer,
|
|
750
|
+
firstLine,
|
|
751
|
+
finished: false,
|
|
752
|
+
};
|
|
753
|
+
let peer: AcpPeer;
|
|
754
|
+
try {
|
|
755
|
+
peer = this.connect({
|
|
756
|
+
command: this.agentCommand,
|
|
757
|
+
cwd: this.repoDir,
|
|
758
|
+
onActivity: (entry) => this.recordActivity(active, entry),
|
|
759
|
+
onQuestion: (question) => this.recordQuestion(active, question),
|
|
760
|
+
onStderr: (text) => firstLine.push(text),
|
|
761
|
+
});
|
|
762
|
+
} catch (error) {
|
|
763
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
764
|
+
return { runId: created.runId, directory: created.directory, error: detail };
|
|
765
|
+
}
|
|
766
|
+
active.peer = peer;
|
|
767
|
+
return { active };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Starts one peer for one issue and records its run and agent entry. */
|
|
771
|
+
private begin(issue: IssuesIssue): void {
|
|
772
|
+
const opened = this.openRun(issue);
|
|
773
|
+
if ("error" in opened) {
|
|
774
|
+
this.failWithoutPeer(issue, opened.runId, opened.directory, opened.error);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const active = opened.active;
|
|
778
|
+
this.active.set(issue.number, active);
|
|
779
|
+
const agent: IssuesAgent = {
|
|
780
|
+
runId: active.runId,
|
|
781
|
+
issueNumber: issue.number,
|
|
782
|
+
startedAt: active.startedAt,
|
|
783
|
+
question: null,
|
|
784
|
+
acp: this.acpChild(active),
|
|
785
|
+
};
|
|
786
|
+
this.state.agents = [...this.state.agents, agent];
|
|
787
|
+
this.refreshQueue();
|
|
788
|
+
this.state.lifecycle = "running";
|
|
789
|
+
this.state.runs = this.summaries();
|
|
790
|
+
this.publish();
|
|
791
|
+
|
|
792
|
+
void active.peer
|
|
793
|
+
.run(promptFor(issue))
|
|
794
|
+
.then(() => this.finishSucceeded(active))
|
|
795
|
+
.catch((error: unknown) => {
|
|
796
|
+
const detail =
|
|
797
|
+
error instanceof Error && error.message.length > 0
|
|
798
|
+
? error.message
|
|
799
|
+
: "the agent ended its turn with an error";
|
|
800
|
+
this.finishFailed(active, detail);
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private recordActivity(active: ActiveRun, entry: AcpActivity): void {
|
|
805
|
+
if (active.finished) return;
|
|
806
|
+
appendActivityLine(active.activity, entry.text, entry.append);
|
|
807
|
+
try {
|
|
808
|
+
writeActivity(active.directory, active.activity);
|
|
809
|
+
} catch {
|
|
810
|
+
// A write failure must not end the run; the next update retries.
|
|
811
|
+
}
|
|
812
|
+
const summary = this.state.runs.find((candidate) => candidate.runId === active.runId);
|
|
813
|
+
if (summary !== undefined) summary.activity = active.activity.length;
|
|
814
|
+
this.publish();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private recordQuestion(active: ActiveRun, question: AcpQuestion): void {
|
|
818
|
+
if (active.finished) return;
|
|
819
|
+
const stored: IssuesQuestion = {
|
|
820
|
+
id: question.id,
|
|
821
|
+
message: question.message,
|
|
822
|
+
options: question.options.map((option) => ({
|
|
823
|
+
label: option.label,
|
|
824
|
+
...(option.description === undefined ? {} : { description: option.description }),
|
|
825
|
+
recommended: option.recommended,
|
|
826
|
+
})),
|
|
827
|
+
};
|
|
828
|
+
this.state.agents = this.state.agents.map((agent) =>
|
|
829
|
+
agent.runId === active.runId ? { ...agent, question: stored } : agent,
|
|
830
|
+
);
|
|
831
|
+
this.publish();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private updateRun(
|
|
835
|
+
runId: string,
|
|
836
|
+
directory: string,
|
|
837
|
+
status: IssuesRunStatus,
|
|
838
|
+
finishedAt: string,
|
|
839
|
+
exitCode: number | null,
|
|
840
|
+
firstStderrLine: string | null,
|
|
841
|
+
): void {
|
|
842
|
+
const entry = this.runs.find((candidate) => candidate.run.runId === runId);
|
|
843
|
+
if (entry === undefined) return;
|
|
844
|
+
const run: RunFile = {
|
|
845
|
+
...entry.run,
|
|
846
|
+
status,
|
|
847
|
+
finishedAt,
|
|
848
|
+
exitCode,
|
|
849
|
+
firstStderrLine,
|
|
850
|
+
issues: entry.run.issues,
|
|
851
|
+
};
|
|
852
|
+
entry.run = run;
|
|
853
|
+
try {
|
|
854
|
+
writeRunFile(directory, run);
|
|
855
|
+
} catch {
|
|
856
|
+
// In-memory history still reflects the outcome.
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private forget(active: ActiveRun): void {
|
|
861
|
+
this.active.delete(active.issue.number);
|
|
862
|
+
this.state.agents = this.state.agents.filter((agent) => agent.runId !== active.runId);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
private settle(exitCode: number | null, error: string | null, finishedAt: string): void {
|
|
866
|
+
this.state.runs = this.summaries();
|
|
867
|
+
this.state.lastExitCode = exitCode;
|
|
868
|
+
if (error !== null) this.state.lastError = error;
|
|
869
|
+
this.state.finishedAt = finishedAt;
|
|
870
|
+
const lifecycle = this.settledLifecycle();
|
|
871
|
+
this.state.lifecycle = lifecycle;
|
|
872
|
+
if (lifecycle === "idle") {
|
|
873
|
+
this.state.lastError = null;
|
|
874
|
+
this.state.lastExitCode = null;
|
|
875
|
+
this.state.finishedAt = null;
|
|
876
|
+
}
|
|
877
|
+
this.publish();
|
|
878
|
+
if (!this.stopping && this.state.enabled && !this.state.paused) {
|
|
879
|
+
this.nextPollDue = Math.min(this.nextPollDue, this.clock().getTime());
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private failWithoutPeer(
|
|
884
|
+
issue: IssuesIssue,
|
|
885
|
+
runId: string,
|
|
886
|
+
directory: string,
|
|
887
|
+
detail: string,
|
|
888
|
+
): void {
|
|
889
|
+
const finishedAt = this.clock().toISOString();
|
|
890
|
+
this.updateRun(runId, directory, "failed", finishedAt, 1, detail);
|
|
891
|
+
this.state.cooldowns[String(issue.number)] = new Date(
|
|
892
|
+
this.clock().getTime() + COOLDOWN_MS,
|
|
893
|
+
).toISOString();
|
|
894
|
+
this.settle(1, detail, finishedAt);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private finishSucceeded(active: ActiveRun): void {
|
|
898
|
+
if (active.finished || this.stopping) return;
|
|
899
|
+
active.finished = true;
|
|
900
|
+
this.closePeer(active);
|
|
901
|
+
const finishedAt = this.clock().toISOString();
|
|
902
|
+
this.updateRun(active.runId, active.directory, "succeeded", finishedAt, 0, null);
|
|
903
|
+
this.ledger = recordHandled(this.ledger, active.captured, active.runId, this.clock());
|
|
904
|
+
// Settle before persisting: a ledger I/O error must never strand the run.
|
|
905
|
+
this.forget(active);
|
|
906
|
+
this.refreshQueue();
|
|
907
|
+
this.settle(0, null, finishedAt);
|
|
908
|
+
const ledgerError = this.persistLedger();
|
|
909
|
+
if (ledgerError !== null) {
|
|
910
|
+
this.state.lastError = ledgerError;
|
|
911
|
+
this.publish();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private finishFailed(active: ActiveRun, detail: string): void {
|
|
916
|
+
if (active.finished || this.stopping) return;
|
|
917
|
+
active.finished = true;
|
|
918
|
+
this.closePeer(active);
|
|
919
|
+
active.firstLine.finish();
|
|
920
|
+
const error = active.firstLine.value ?? detail;
|
|
921
|
+
const finishedAt = this.clock().toISOString();
|
|
922
|
+
this.updateRun(active.runId, active.directory, "failed", finishedAt, 1, error);
|
|
923
|
+
this.state.cooldowns[String(active.issue.number)] = new Date(
|
|
924
|
+
this.clock().getTime() + COOLDOWN_MS,
|
|
925
|
+
).toISOString();
|
|
926
|
+
this.forget(active);
|
|
927
|
+
this.refreshQueue();
|
|
928
|
+
this.settle(1, error, finishedAt);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private closePeer(active: ActiveRun): void {
|
|
932
|
+
try {
|
|
933
|
+
active.peer.close();
|
|
934
|
+
} catch {
|
|
935
|
+
// The peer already exited.
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Keeps every running run and the newest 50 finished ones. */
|
|
940
|
+
private pruneHistory(): void {
|
|
941
|
+
const base = runsDirectory(this.stateDir, this.repoDir);
|
|
942
|
+
const kept = new Set<string>();
|
|
943
|
+
let finished = 0;
|
|
944
|
+
for (const entry of this.runs) {
|
|
945
|
+
if (entry.run.status === "running") {
|
|
946
|
+
kept.add(entry.directory);
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (finished < RUN_HISTORY_LIMIT) {
|
|
950
|
+
kept.add(entry.directory);
|
|
951
|
+
finished += 1;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
for (const entry of this.runs) {
|
|
955
|
+
if (kept.has(entry.directory)) continue;
|
|
956
|
+
try {
|
|
957
|
+
rmSync(entry.directory, { recursive: true, force: true });
|
|
958
|
+
} catch {
|
|
959
|
+
// A stale directory is left for the watchdog.
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
this.runs = this.runs.filter((entry) => kept.has(entry.directory));
|
|
963
|
+
void base;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Stop: end every peer, record `stopped`, leave captured issues unhandled. */
|
|
967
|
+
async stop(): Promise<void> {
|
|
968
|
+
if (this.stopping) return;
|
|
969
|
+
this.stopping = true;
|
|
970
|
+
this.state.enabled = false;
|
|
971
|
+
this.state.paused = false;
|
|
972
|
+
this.state.cooldowns = {};
|
|
973
|
+
for (const active of this.active.values()) {
|
|
974
|
+
if (active.finished) continue;
|
|
975
|
+
active.finished = true;
|
|
976
|
+
this.closePeer(active);
|
|
977
|
+
this.updateRun(active.runId, active.directory, "stopped", this.clock().toISOString(), null, null);
|
|
978
|
+
}
|
|
979
|
+
this.active.clear();
|
|
980
|
+
this.state.agents = [];
|
|
981
|
+
for (const issue of this.state.queue) issue.agentRunId = null;
|
|
982
|
+
this.state.runs = this.summaries();
|
|
983
|
+
this.state.lastExitCode = null;
|
|
984
|
+
this.state.lastError = null;
|
|
985
|
+
this.state.lifecycle = "stopped";
|
|
986
|
+
this.state.notice = null;
|
|
987
|
+
this.publish();
|
|
988
|
+
if (this.ticker !== undefined) {
|
|
989
|
+
clearInterval(this.ticker);
|
|
990
|
+
this.ticker = undefined;
|
|
991
|
+
}
|
|
992
|
+
this.lock?.release();
|
|
993
|
+
this.lock = undefined;
|
|
994
|
+
this.onStopped?.();
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async shutdown(): Promise<void> {
|
|
998
|
+
await this.stop();
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function parseArguments(
|
|
1003
|
+
args: string[],
|
|
1004
|
+
): { repoDir: string; stateDir: string; intervalMs?: number } | undefined {
|
|
1005
|
+
let repoDir: string | undefined;
|
|
1006
|
+
let stateDir: string | undefined;
|
|
1007
|
+
let intervalMs: number | undefined;
|
|
1008
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1009
|
+
const argument = args[index];
|
|
1010
|
+
if (argument === "--repo" || argument === "--state-dir" || argument === "--interval") {
|
|
1011
|
+
const value = args[index + 1];
|
|
1012
|
+
index += 1;
|
|
1013
|
+
if (value === undefined) return undefined;
|
|
1014
|
+
if (argument === "--repo") repoDir = value;
|
|
1015
|
+
else if (argument === "--state-dir") stateDir = value;
|
|
1016
|
+
else {
|
|
1017
|
+
const parsed = Number(value);
|
|
1018
|
+
if (Number.isFinite(parsed) && parsed > 0) intervalMs = parsed;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (repoDir === undefined || stateDir === undefined) return undefined;
|
|
1023
|
+
return { repoDir, stateDir, ...(intervalMs === undefined ? {} : { intervalMs }) };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function main(): Promise<void> {
|
|
1027
|
+
const parsed = parseArguments(process.argv.slice(2));
|
|
1028
|
+
if (parsed === undefined) {
|
|
1029
|
+
console.error("usage: worker.ts --repo <checkout> --state-dir <dir> [--interval <ms>]");
|
|
1030
|
+
process.exit(2);
|
|
1031
|
+
}
|
|
1032
|
+
const repoDir = resolve(parsed.repoDir);
|
|
1033
|
+
const stateDir = resolve(parsed.stateDir);
|
|
1034
|
+
const repository = originRepository(repoDir) ?? "";
|
|
1035
|
+
if (repository.length === 0) {
|
|
1036
|
+
console.error("issues worker: the checkout's origin does not name a repository");
|
|
1037
|
+
process.exit(2);
|
|
1038
|
+
}
|
|
1039
|
+
if (liveLockOwner(lockPath(repoDir)) !== undefined) {
|
|
1040
|
+
console.error("issues worker: this checkout already has a live worker");
|
|
1041
|
+
process.exit(0);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const worker = new IssuesWorker({
|
|
1045
|
+
repoDir,
|
|
1046
|
+
stateDir,
|
|
1047
|
+
...(parsed.intervalMs === undefined ? {} : { intervalMs: parsed.intervalMs }),
|
|
1048
|
+
log: (line) => console.error(`issues: ${line}`),
|
|
1049
|
+
onStopped: () => process.exit(0),
|
|
1050
|
+
});
|
|
1051
|
+
if (!worker.start(repository)) {
|
|
1052
|
+
process.exit(0);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const shutdown = (signal: NodeJS.Signals): void => {
|
|
1056
|
+
console.error(`issues: received ${signal}`);
|
|
1057
|
+
void worker.shutdown().finally(() => process.exit(0));
|
|
1058
|
+
};
|
|
1059
|
+
process.on("SIGTERM", shutdown);
|
|
1060
|
+
process.on("SIGINT", shutdown);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (import.meta.main) {
|
|
1064
|
+
await main();
|
|
1065
|
+
}
|