-
Notifications
You must be signed in to change notification settings - Fork 1
feat(dispatcher): human-in-the-loop and review-driven resume flow #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
134b97d
9a6958e
7b3cf3d
94f27ae
3804fbf
c6184b6
c3c86a3
87e209c
0e48630
fb2e47e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| -- 002_waitfor_fired.sql | ||
| -- The GitHub poller (Phase 5) fires a workflow's resume signal once per park. | ||
| -- `fired_at` records when a signal was fired so a subsequent poll pass does not | ||
| -- re-fire the same wait before the workflow has resumed and consumed the row. | ||
| -- A fresh park (next review round) deletes-and-reinserts the row, clearing it. | ||
|
|
||
| ALTER TABLE waitfor_signals ADD COLUMN fired_at INTEGER; | ||
|
|
||
| INSERT OR IGNORE INTO schema_version VALUES (2); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,8 +13,11 @@ import { Engine } from "bunqueue/workflow"; | |
| import { openAndMigrate } from "./db.ts"; | ||
| import { HookServer } from "./hook-server.ts"; | ||
| import { DbHookStore } from "./hook-store.ts"; | ||
| import { ghPollGateway } from "./poller-gateway.ts"; | ||
| import { startPoller } from "./poller-cron.ts"; | ||
| import { killSession, status } from "./tmux.ts"; | ||
| import { startWatchdog } from "./watchdog-cron.ts"; | ||
| import { RESUME_EVENT } from "./workflows/implementation.ts"; | ||
|
|
||
| /** Phase 2 adapter registry — only `claude` is implemented. */ | ||
| function getAdapter(name: string): AgentAdapter { | ||
|
|
@@ -44,6 +47,18 @@ async function main(): Promise<void> { | |
| getAdapter, | ||
| }); | ||
|
|
||
| // GitHub poller: every 60s, for each parked workflow with an armed wait, fire | ||
| // its resume signal when the unblocking event appears (a human reply, or a PR | ||
| // review verdict). `fireSignal` delivers it to the engine that hosts the | ||
| // parked execution. NOTE: routing dispatches through this long-lived engine | ||
| // (so parked executions live here to be resumed) is the Phase 8 auto-dispatch | ||
| // integration; the poller + signal seam are in place ahead of it. | ||
| const stopPoller = await startPoller({ | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Poller + signal seam wired; cross-process resume hosting is Phase 8. |
||
| db, | ||
| github: ghPollGateway, | ||
| fireSignal: (workflowId, payload) => engine.signal(workflowId, RESUME_EVENT, payload), | ||
| }); | ||
|
|
||
| console.log( | ||
| `middle dispatcher up — hooks on :${hookServer.port}, db ${config.global.dbPath}`, | ||
| ); | ||
|
|
@@ -59,6 +74,11 @@ async function main(): Promise<void> { | |
| } catch (error) { | ||
| console.error(`shutdown: stopWatchdog failed — ${(error as Error).message}`); | ||
| } | ||
| try { | ||
| await stopPoller(); | ||
| } catch (error) { | ||
| console.error(`shutdown: stopPoller failed — ${(error as Error).message}`); | ||
| } | ||
| try { | ||
| hookServer.stop(); | ||
| } catch (error) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { Bunqueue } from "bunqueue/client"; | ||
| import { runPoller, type PollerDeps } from "./poller.ts"; | ||
|
|
||
| /** | ||
| * How often the poller checks GitHub for resume triggers. Slower than the | ||
| * watchdog (30s) — a human reply or a review verdict is not latency-sensitive, | ||
| * and a gentler cadence is kinder to GitHub rate limits. | ||
| */ | ||
| export const POLLER_INTERVAL_MS = 60_000; | ||
|
|
||
| /** | ||
| * Stand up the GitHub poller as a bunqueue cron: every {@link POLLER_INTERVAL_MS} | ||
| * it runs one {@link runPoller} pass over parked workflows with an armed wait, | ||
| * firing the resume signal when the unblocking event appears. Returns a stop | ||
| * function that tears the cron down. The pass is resilient on its own (per- | ||
| * workflow failures are isolated); this wrapper guards the whole pass too so a | ||
| * thrown pass never crashes the cron worker. | ||
| */ | ||
| export async function startPoller(deps: PollerDeps): Promise<() => Promise<void>> { | ||
| const queue = new Bunqueue("middle-poller", { | ||
| embedded: true, | ||
| processor: async () => { | ||
| try { | ||
| await runPoller(deps); | ||
| } catch (error) { | ||
| console.error(`[poller] pass failed: ${(error as Error).message}`); | ||
| } | ||
| }, | ||
| }); | ||
| await queue.every("poller-tick", POLLER_INTERVAL_MS); | ||
| return async () => { | ||
| await queue.close(true); | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import type { GitHubPollGateway, IssueComment, PrReview, PrSnapshot } from "./poller.ts"; | ||
|
|
||
| /** | ||
| * The production {@link GitHubPollGateway} — reads issue comments and PR review | ||
| * state through the `gh` CLI. The poller's logic is unit-tested against an | ||
| * injected stub gateway; this is the thin subprocess glue that backs it in the | ||
| * dispatcher. Read-only: the poller never writes to GitHub. | ||
| */ | ||
|
|
||
| async function gh(argv: string[]): Promise<string> { | ||
| const proc = Bun.spawn(["gh", ...argv], { stdout: "pipe", stderr: "pipe", stdin: "ignore" }); | ||
| const [stdout, stderr] = await Promise.all([ | ||
| new Response(proc.stdout).text(), | ||
| new Response(proc.stderr).text(), | ||
| ]); | ||
| if ((await proc.exited) !== 0) { | ||
| throw new Error(`gh ${argv.join(" ")} failed: ${stderr.trim()}`); | ||
| } | ||
| return stdout; | ||
| } | ||
|
|
||
| function isBotLogin(login: string, type: string | undefined): boolean { | ||
| return type === "Bot" || login.endsWith("[bot]"); | ||
| } | ||
|
|
||
| export const ghPollGateway: GitHubPollGateway = { | ||
| async listIssueComments(repo: string, issueNumber: number): Promise<IssueComment[]> { | ||
| // `--slurp` wraps the per-page arrays into one outer array; `gh` without it | ||
| // emits one JSON array *per page*, which `JSON.parse` chokes on past page 1. | ||
| const out = await gh([ | ||
| "api", | ||
| "--paginate", | ||
| "--slurp", | ||
| `repos/${repo}/issues/${issueNumber}/comments`, | ||
| ]); | ||
| const rows = ( | ||
| JSON.parse(out) as Array< | ||
| Array<{ | ||
| id: number; | ||
| body: string; | ||
| created_at: string; | ||
| user: { login: string; type?: string } | null; | ||
| }> | ||
| > | ||
| ).flat(); | ||
| return rows.map((r) => ({ | ||
| id: r.id, | ||
| body: r.body ?? "", | ||
| createdAt: Date.parse(r.created_at), | ||
| authorLogin: r.user?.login ?? "", | ||
| authorIsBot: isBotLogin(r.user?.login ?? "", r.user?.type), | ||
| })); | ||
| }, | ||
|
|
||
| async findPrForEpic(repo: string, epicNumber: number): Promise<PrSnapshot | null> { | ||
| // The Epic's one PR closes the Epic — find the open PR referencing it. | ||
| const listOut = await gh([ | ||
| "pr", | ||
| "list", | ||
| "--repo", | ||
| repo, | ||
| "--state", | ||
| "open", | ||
| "--search", | ||
| `in:body Closes #${epicNumber}`, | ||
| "--json", | ||
| "number", | ||
| ]); | ||
| const prs = JSON.parse(listOut) as Array<{ number: number }>; | ||
| const prNumber = prs[0]?.number; | ||
| if (prNumber === undefined) return null; | ||
|
|
||
| const viewOut = await gh([ | ||
| "pr", | ||
| "view", | ||
| String(prNumber), | ||
| "--repo", | ||
| repo, | ||
| "--json", | ||
| "reviewDecision,labels", | ||
| ]); | ||
| const view = JSON.parse(viewOut) as { | ||
| reviewDecision: string | null; | ||
| labels: Array<{ name: string }>; | ||
| }; | ||
|
|
||
| const reviewsOut = await gh([ | ||
| "api", | ||
| "--paginate", | ||
| "--slurp", | ||
| `repos/${repo}/pulls/${prNumber}/reviews`, | ||
| ]); | ||
| const reviewRows = ( | ||
| JSON.parse(reviewsOut) as Array< | ||
| Array<{ | ||
| id: number; | ||
| state: string; | ||
| body: string; | ||
| submitted_at: string | null; | ||
| user: { login: string } | null; | ||
| }> | ||
| > | ||
| ).flat(); | ||
| const reviews: PrReview[] = reviewRows.map((r) => ({ | ||
| id: r.id, | ||
| state: r.state, | ||
| body: r.body ?? "", | ||
| submittedAt: r.submitted_at ? Date.parse(r.submitted_at) : 0, | ||
| authorLogin: r.user?.login ?? "", | ||
| })); | ||
|
|
||
| return { | ||
| number: prNumber, | ||
| reviewDecision: view.reviewDecision ?? null, | ||
| reviews, | ||
| labels: view.labels.map((l) => l.name), | ||
| }; | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Poller idempotency via
fired_at. The poller is a pure detect-and-fire pass; it marks a wait fired so a later pass won't re-fire before the workflow resumes and consumes the row. A fresh park (next round) deletes-and-reinserts the row, clearingfired_at. Note the row is keyed bysignal_name(epic-scoped) — rounds of the same Epic never overlap because each round consumes its wait before the next round arms (sequential handoff), so the shared key doesn't collide.