chore(emdash-bot): migrate to Flue 2 agents#2101
Conversation
The state-machine work from the closed feat/bot-state-machine branch is being rebuilt on Cloudflare per .opencode/plans/0003-bot-on-cloudflare.md. Phase 0 is the integration spike: stand up a Cloudflare-target Flue app with the container-backed Sandbox integration and Workers AI binding, prove the build succeeds, then validate end-to-end locally. This commit lands the scaffolding: - package.json / tsconfig.json mirroring infra/flue-review. - wrangler.jsonc: AI binding, Sandbox + FlueRegistry DOs, container declaration, R2 workspace bucket. No Orchestrator DO yet (Phase 3). - Dockerfile: minimal base for the spike (Node + pnpm + git + gh). The full toolchain (Chromium, agent-browser) lands in Phase 2 when we port the repro skills. - .flue/cloudflare.ts: re-exports Sandbox DO, narrows Env.Sandbox type. - .flue/app.ts: Hono root with Flue's standard /workflows routing. - .flue/lib/classifier.ts: the shared classifier agent, defaulting to qwen3-30b-a3b-fp8 via the cloudflare/* binding-native provider id (no API key in scope). - .flue/workflows/classify-command.ts: state-aware free-text classifier, ported from the previous attempt with cf-wai/* model strings replaced by cloudflare/@cf/* binding strings. - .flue/workflows/investigate.ts: minimal stub agent that runs inside the container-backed Sandbox via cloudflareSandbox(getSandbox(...)). Returns structured classification only -- real five-stage pipeline lands in Phase 2. Verified: `pnpm typecheck` and `pnpm build` clean. Both workflows discovered. Sandbox container declaration parses. The Flue beta.5 + @cloudflare/sandbox 0.12.1 + agents 0.14.5 + hono 4.12.23 dependency set installs and resolves cleanly under the workspace's release-age policy.
Both halves work locally:
classify-command (qwen3-30b via env.AI binding):
POST /workflows/classify-command?wait=result
-> {event:'retry', tokens:{input:1741,output:292}, model:'cloudflare/@cf/qwen/qwen3-30b-a3b-fp8'}
investigate (glm-5.2 inside Cloudflare Sandbox container):
POST /workflows/investigate?wait=result
-> {kind:'bug', area:'admin', requiresBrowser:true, tokens:{input:1624,output:60},
model:'cloudflare/@cf/zai-org/glm-5.2'}
The structural credential boundary works as designed: no API key is in the
agent's process address space at any point. The whole class of bugs the
previous attempt's eight review cycles patched is gone.
Fixes since first scaffold:
- Dropped process.env references from the agent definitions (Workers have
no process.env; Flue rejected the AgentDefinition shape at runtime).
- Dockerfile: switched to a multi-stage build that COPIES Node + npm + pnpm
from the official node:22.21.0-bookworm-slim image rather than downloading
inside the sandbox base. The Cloudflare Sandbox base image has a broken
CA bundle that fails curl -fsSL against nodejs.org with 'self-signed
certificate in certificate chain'; copying across avoids any TLS in the
final image. Symlinks for npm/pnpm CLIs get rebuilt because Docker COPY
flattens slim's symlinks; chmod +x on the .cjs targets because they lose
their executable bit during COPY.
- wrangler.jsonc: added Flue's workflow DO classes (FlueClassifyCommand-
Workflow, FlueInvestigateWorkflow) plus FlueRegistry to new_sqlite_classes.
Flue auto-adds the durable_objects.bindings in its merged .flue-vite.
wrangler.jsonc but does not add the migrations; the user has to declare
them sqlite-enabled. Same pattern as flue-review's wrangler.jsonc.
Phase 0 decision gate: PASS. Browser support (Phase 2) is the remaining
load-bearing piece -- the spike only proves the agent runs in a container
with Workers AI, not that it can drive Chromium for repro-admin/public
skills. That risk gates Phase 2, not the architecture choice.
Phase 0 stretch validation. Extended the Dockerfile with Chromium's runtime dependencies (libnss3, libatk, fonts-liberation, etc.) and copied bgproc + agent-browser from the node-source stage. Added a throwaway sandbox-probe workflow that runs a sequence of shell commands inside the container to verify each piece. Confirmed inside the Sandbox container: - Node 22.21.0, pnpm 11.1.3, git 2.34.1, bgproc, agent-browser 0.30.1. - 'agent-browser install' downloads Chrome 150 (~179 MB) in ~50s. - Chromium launches and loads pages; agent-browser open/read works. Two cleanup items for Phase 1 (not blockers): - Chromium reinstalls every run because each Sandbox DO call gets a fresh $HOME. Phase 1 should either bake Chromium into the Dockerfile (~200 MB image growth) or persist /root/.agent-browser/browsers across runs via a volume / R2 cache. - Locally, Cloudflare WARP intercepts TLS with a corp Zero Trust cert the sandbox base doesn't trust, so chromium shows cert warnings on page loads. This is dev-machine only and won't happen on real CF edges; safe to defer. Decision: Phase 0 done. Browser-in-Sandbox works. Phase 1 (orchestrator DO + webhook ingress + real investigate pipeline port) can proceed.
Ports machine.ts and router.cjs from the closed feat/bot-state-machine branch into infra/emdash-bot/.flue/lib/. router.ts re-exports findTransition from machine.ts (single source of truth) and uses TypeScript-native exports instead of the CommonJS wrapper. Sets up vitest in tests/unit/ with the full 32-case router suite ported from node:test. All pass in 4ms. tsconfig includes tests/ for typecheck. These are the substrate-agnostic bits of the bot logic; the OrchestratorDO will wrap them with per-issue serialization in the next commit.
Adds the per-issue Orchestrator Durable Object as the source of truth for the bot lifecycle. The DO holds state, kind, currentRunId, prNumber, and a bounded event log; webhook events route through event() and the intrinsic per-instance serialization eliminates the PR-comment race that plagued the Actions-based predecessor (PR #1606 cycle 4). This commit is the SKELETON: resolve + persist + delivery dedupe + stale- run discard + inert-state guard. GitHub side effects (label flip, comment, PR ops) and workflow invocation land in the next commit alongside the webhook handler, since they share the App-token-issuing helper. Test infrastructure also lands here. Pure router tests stay in tests/unit/ (plain vitest, ~4ms). DO tests live in tests/integration/ and run under @cloudflare/vitest-pool-workers against a real workerd isolate, with a separate wrangler.test.jsonc + test entry that mirrors prod bindings but skips Flue-generated workflow DOs (not exported by the test entry). Both suites run via pnpm test; 32 unit + 7 integration tests pass.
POST /webhook/github verifies the X-Hub-Signature-256 HMAC against the raw body, normalizes the GitHub payload into a NormalizedEvent, and dispatches into the per-anchor Orchestrator DO via getByName. Handles issues, issue_comment, pull_request, pull_request_review, and pull_request_review_comment events. The classifier hand-off lives in the DO; the webhook resolves bare verbs deterministically (parseCommand) and flags everything else as needsClassify. Bot-author detection for the in_review default-comment-event is intentionally deferred (defaults to false, routes through classifier) until we have a known bot login binding. Tests: - 25 unit tests for actor classification + payload normalization (pure) - 11 integration tests under @cloudflare/vitest-pool-workers: signature verify (the workerd-only timingSafeEqual), full SELF.fetch -> DO round trips for the deterministic verb path, free-text classify-pending path, duplicate-delivery dedupe, and signature/JSON rejection. The core routes (health + webhook) live in routes.ts so the workers-pool test entry can mount them without pulling in Flue's workflow routes (those need workflow DOs the test wrangler does not declare). Production app.ts still mounts both.
Ports readAppCreds + mintInstallationToken + JWT helpers from infra/flue-review, adds bot-specific API helpers (addLabels, removeLabel, postIssueComment, getIssueLabels), and wires them into the orchestrator's event() path. The transition path now: resolve -> applySideEffects (label flip + comment) -> persistDecision. Side effects run BEFORE persist so a label-flip failure leaves DO state unchanged and the next event retries; comment failures are non-fatal and logged. Tokens are cached in DO storage with a 55-minute window. Dev mode (no GITHUB_APP_PRIVATE_KEY) cleanly skips all GitHub calls with a log line -- no exceptions, DO state still advances locally. Adds GITHUB_OWNER + GITHUB_REPO as wrangler vars for the single managed repo. Multi-repo support deferred; would need owner/repo plumbed through NormalizedEvent and the DO signature. The webhook normalizer now stamps anchorNumber onto every NormalizedEvent so the DO can address the GitHub side. Tests stay green via empty PEM binding -> readAppCreds returns null -> side effects skip.
Free-text comments now invoke the classify-command workflow synchronously via ctx.exports.default (the loopback service binding to the Worker's default fetch handler, no service-binding config needed). The DO blocks ~1-2s on the classifier turn, then re-enters event() with the resolved verb. Removes the classify-pending placeholder outcome -- the DO now always returns a concrete decision (transition / readonly / noop), where classifier failures degrade to noop with a logged error. Updates tests to the cloudflare:workers exports surface (cloudflare:test is deprecated as of vitest-pool-workers v0.13).
Adds runAction() to the orchestrator: on a transition with decision.action (investigate.repro / investigate.implement / investigate.revise), fetches the issue context via the GitHub App token, generates a runId, persists it as currentRunId, and admits the investigate workflow via Flue's invoke(). Extends the investigate workflow to accept the orchestrator's input shape (runId, mode, arg, issueTitle, issueBody) and call back into the right OrchestratorDO via env.Orchestrator.getByName(...).applyAgentResult() on completion. Phase 1 body is still a single LLM call; the 5-stage pipeline is Phase 2. Wires DO and Worker exports through .flue/wrangler-main.ts so wrangler types can infer the class generics on Cloudflare.Env's DurableObjectName- space bindings and the Cloudflare.Exports loopback service shape. The file is type-only -- flue dev generates its own runtime entry and the default fetch handler here just returns 500 if hit. Drops the previous ad-hoc Env augmentation in cloudflare.ts. No tests needed updating: the existing integration suite already exercises the new action path (with creds-null degrading to a logged skip).
Status / help mentions used to be no-ops on GitHub. The orchestrator now posts a short reply (current state + offered commands footer) when a readonly event lands. No-creds dev mode still skips silently.
Adds self-arming alarms on the OrchestratorDO. The tick recovers stale runs (currentRunId older than 30 minutes -> drop, allowing a retry) and reconciles label drift between DO state and the live GitHub labels (the DO is the source of truth; manual edits are healed). The alarm self-rearms (every 60 minutes), bootstrapped from the first event() call. applyAgentResult now clears currentRunId on completion so stale-run detection doesn't false-positive on a finished run.
Adds createPullRequest and closePullRequest helpers to github.ts, and splits the orchestrator's runAction into runInvestigate / runOpenPr / runClosePr. PR number is persisted to DO storage on open so close knows what to target. openPr will fail in Phase 1 (no fix branch exists yet -- the investigate workflow's git push step is Phase 2). The runError surfaces in the EventOutcome; DO state still advances.
The orchestrator was passing input.labels straight through to resolve(), even when DO storage held a newer state from a prior transition. A follow-up event (status check, retry, etc.) carried stale labels from the webhook's snapshot and the router decided against them. Now: if DO storage has persisted state, project it to labels via projectLabels() and resolve against those. Falls back to input.labels for first-time mentions where DO storage is empty.
The workflow now runs in two trusted phases around the agent: 1. setupSandbox(): before the agent starts, mint an installation token, write it to /root/.git-credentials (persistent credential store), put it in /etc/environment as GITHUB_TOKEN, configure git identity, and clone the repo into /workspace/repo. For revise mode, check out the existing bot/fix-N branch. 2. detectPush(): after the agent reports done, query the GitHub API for the bot/fix-N branch. If it exists, set pushed:true in the callback. The router's outcomeFromResult uses pushed to gate agent.fix_ready (fixed:true without a real branch demotes to agent.failed). The agent now defaults to kimi-k2.7-code (the code-focused model) and is told via instructions that the repo and creds are ready. No-creds dev mode skips clone and push detection silently; useful for sandbox toolchain smoke tests.
Switches the credential path from 'agent sees the token' to 'agent never sees the token'. The Sandbox subclass declares outboundByHost for github.com / api.github.com / codeload.github.com pointing at authenticatedGithub, which mints a fresh installation token in the Worker runtime, injects Basic auth (x-access-token format that works for both git smart HTTP and the REST API), and forwards upstream. allowedHosts denies everything except github + the registry hosts the toolchain may need (npm + githubusercontent CDNs). enableInternet=false makes that the only path out. The agent now pushes directly: git clone/fetch/push to github.com is transparent and the sandbox holds no tokens. The SKILL.md says so explicitly to discourage poking at /etc/environment etc. Wired in the investigate skill via the with-skill import attribute. The orchestrator's investigate workflow import is lazy so the workers-pool test pipeline (which has no Flue build plugin) doesn't try to parse the markdown.
…ed repo
Three bugs together prevented the sandbox setup from working:
1. interceptHttps defaults to false in @cloudflare/containers 0.3.x even
though the April 2026 changelog says HTTPS interception is on by
default. Override to true on our Sandbox subclass so outboundByHost
handlers actually see github.com traffic (otherwise the proxy was
blind to git clone over HTTPS).
2. harness.shell inherits the agent's cwd (/workspace/repo), which does
not exist before the clone. Pass cwd: "/" so setup steps run from a
directory that exists.
3. The setup script was a single .join(" && ") string containing an
if/then/else/fi block, which is a bash syntax error ("if ... && else"
never parses). Split into a typed steps[] array and run each via its
own harness.shell call. Each step's exitCode is checked explicitly
and stderr surfaces in the worker log on failure.
Adds x-emdash-dry-run: 1 header support: routes.ts forwards the flag
into NormalizedEvent.dryRun, and the orchestrator skips applySideEffects
(label flip + status comment) when dry-run. The workflow still runs in
full (LLM call, sandbox setup, push attempt). Lets local smoke tests
iterate without spamming labels/comments on a real GitHub issue.
Verified: dry-run repro against issue #1042. Setup steps all exit 0,
the agent's first command finds a populated /workspace/repo, the agent
runs real grep/find investigations against the cloned source.
Adds a final pnpm-install step to setupSandbox so the agent doesn't burn turns rediscovering that it needs deps and figuring out which install command works. --frozen-lockfile matches the lockfile in the checked-out tree, which may differ between main and a revise branch. The step is non-fatal: pnpm's post-install hooks can exit 1 in the sandbox env (e.g. on missing build tools for native modules) while still leaving node_modules populated enough to run tests. Failure is logged with stdout/stderr tails but doesn't abort setup. Critical steps (clone, checkout) remain fatal. Bumps the install timeout to 10 min; clone gets 5 min. Updates SKILL.md so the agent knows deps are pre-installed.
EmDash's pnpm-lock pins @lunariajs/core from pkg.pr.new; add it to the Sandbox's allowedHosts so pnpm install can fetch it. Without this, the install fails with ERR_PNPM_FETCH_520 (ContainerProxy returns 'Origin is disallowed' status 520 for any host not in allowedHosts). Add build-essential, python3, and python-is-python3 to the Dockerfile so node-gyp can rebuild native modules (better-sqlite3, etc.). The previous image only had ca-certificates + browser deps, which left node-gyp unable to compile. Also bumped the setup step failure log slice from 800 to 4000 chars so node-gyp's actual error message is visible; the trailing summary alone isn't enough to diagnose.
Replaces the bot-jargon transition comments ('Moved to working on retry
(investigate.repro).') with messages a reporter actually wants to read.
agent.fix_ready now includes a pkg.pr.new install URL (the existing
preview-releases.yml workflow auto-publishes for every push to
bot/fix-*), so reporters can pnpm-add the preview directly rather than
having to clone a branch. PR stays gated on .
Readonly status replies are state-specific now too instead of dumping
the verb list, and unmapped events get no comment at all (skip-post on
empty body).
Production sandbox network is slower than local docker; the 10 min limit killed the install on a real run against #1623 before postinstall hooks (better-sqlite3, sharp, workerd binaries) could finish. 15 min covers the cold-cache case based on dev timings (~7 min) plus headroom.
- instance_type: standard-3 (2 vCPU, 8 GiB, 16 GB). The default lite (256 MiB / 1/16 vCPU) was killing pnpm install on cold cache. - NODE_OPTIONS=--max-old-space-size=6144 so node/pnpm processes can use the increased memory ceiling (mirrors what we bumped for emdash builds). - Drop FlueSandboxProbeWorkflow. It was a Phase 0 toolchain probe that hasn't been invoked since; v2 migration deletes the DO class.
ripgrep, jq, tree, less, file, unzip, sqlite3. The agent fell back to grep -rnH and similar in the first prod run because rg wasn't installed. sqlite3 is useful when poking at D1/SQLite-backed tests.
A workflow can be evicted mid-run (DO hibernation, deploy rotation, infra eviction) without producing a callback. The hourly tick already dropped the orphaned currentRunId, but left DO state stuck on "working" forever, with no way out except a manual reset. Synthesize an agent.failed event after dropping the run so the state machine progresses to `failed` and the reporter gets the failed-state comment.
Subscribes once at app startup to the Flue event stream and logs a compact one-liner per run/turn/tool event. wrangler tail now shows what the agent is thinking, not just the raw sandbox.exec commands -- much easier to spot when the agent is stalled vs. mid-LLM-call.
The agent.reproduced event maps to working -> blocked; we already had text for the other agent.* events but missed this one. The state flipped silently on #1589, leaving the reporter with just a label change and no explanation.
…maries The outbound proxy now signs api.github.com requests scoped to the current anchor issue/PR. The agent can curl GETs anywhere on api.github.com, POST comments and reactions on its own issue, and push to bot/fix-<n>; writes to other issues, PRs, or repos are denied 403. Anchor + repo are passed via ctx.params, installed per-run by the workflow before the agent session starts. Replaces the boilerplate bot-jargon comments with the agent's own summary. User-driven transitions (repro / implement / confirm / decline / reopen / reset / take_over / hand_back) no longer post -- the user just typed the verb, echoing it is noise. Agent transitions post the summary verbatim, with structural footers (pkg.pr.new install URL for fix_ready; "reply with steps" prompt for not_reproduced; etc.). SKILL.md tells the agent the summary IS the comment, and to write it to the reporter.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 0dea3f8 | Jul 19 2026, 11:27 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | 0dea3f8 | Jul 19 2026, 11:27 AM |
Scope checkThis PR changes 7,468 lines across 38 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
docs | 4084d68 | Jul 17 2026, 09:14 PM |
There was a problem hiding this comment.
Pull request overview
Migrates infra/emdash-bot from Flue 1 workflow-style orchestration to Flue 2 agents, making the Orchestrator Durable Object the canonical source of bot state and adding a Workers-pool integration test harness for DO + webhook ingress.
Changes:
- Adds an OrchestratorDO implementation with durable webhook admission, inbox processing, dispatch recovery, and stale-run recovery.
- Moves classification and investigation to Flue 2 agents and introduces a repository-scoped authenticated GitHub outbound proxy for the sandbox.
- Adds Worker/Vite/Wrangler/Vitest configuration and a container image for the investigate sandbox.
Reviewed changes
Copilot reviewed 36 out of 38 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-workspace.yaml | Pins Flue’s transitive hono version to match the bot’s direct dependency. |
| infra/emdash-bot/wrangler.test.jsonc | Adds a test-only Wrangler config mirroring production bindings for workers-pool tests. |
| infra/emdash-bot/wrangler.jsonc | Adds production Wrangler config including DO migrations, AI, R2, and required secrets. |
| infra/emdash-bot/vitest.workers.config.ts | Configures Vitest to run integration tests in a real workerd isolate via workers-pool. |
| infra/emdash-bot/vitest.config.ts | Adds a unit-test-only Vitest config for pure logic tests. |
| infra/emdash-bot/vite.config.ts | Adds Vite config wiring Flue + Cloudflare Vite plugin for Worker builds. |
| infra/emdash-bot/tsconfig.json | Adds TypeScript config for the bot package (workers-pool types, strict settings). |
| infra/emdash-bot/tests/unit/webhook.test.ts | Adds unit coverage for webhook normalization and actor classification logic. |
| infra/emdash-bot/tests/unit/sandbox-deadline.test.ts | Adds unit coverage for sandbox RPC deadline bounding helpers. |
| infra/emdash-bot/tests/unit/router.test.ts | Adds unit coverage for the state-machine router logic and parsing rules. |
| infra/emdash-bot/tests/unit/github-proxy.test.ts | Adds unit coverage for the sandbox GitHub proxy request gate. |
| infra/emdash-bot/tests/unit/classifier-client.test.ts | Adds unit coverage for classifier result validation/mapping. |
| infra/emdash-bot/tests/integration/webhook.test.ts | Adds end-to-end tests for webhook ingress + signature validation + DO admission in workerd. |
| infra/emdash-bot/tests/integration/orchestrator.test.ts | Adds workerd integration tests for OrchestratorDO persistence, dedupe, recovery, and tick behavior. |
| infra/emdash-bot/tests/integration/_entry.ts | Adds a workers-pool-only Worker entry that mounts core routes and exports DO classes. |
| infra/emdash-bot/package.json | Introduces the bot package with Flue runtime, Hono, workers testing deps, and scripts. |
| infra/emdash-bot/entrypoint.sh | Adds container entrypoint to trust Cloudflare’s outbound interception CA at runtime. |
| infra/emdash-bot/Dockerfile | Adds a sandbox container image (Node/pnpm/git/etc.) for the investigate agent. |
| infra/emdash-bot/.gitignore | Ignores build outputs and local Wrangler/env artifacts for the bot package. |
| infra/emdash-bot/.flue/wrangler-main.ts | Adds a type-only main stub for wrangler types to generate correct bindings. |
| infra/emdash-bot/.flue/skills/investigate/instructions.md | Adds the investigate agent’s operating instructions embedded into the skill. |
| infra/emdash-bot/.flue/routes.ts | Adds core routes (health + GitHub webhook) separated for test entry mounting. |
| infra/emdash-bot/.flue/raw.d.ts | Adds typing for ?raw imports used by skill documents. |
| infra/emdash-bot/.flue/lib/webhook.ts | Adds webhook signature verification + payload normalization into durable DO events. |
| infra/emdash-bot/.flue/lib/sandbox-deadline.ts | Adds deadline wrappers to bound sandbox RPC operations. |
| infra/emdash-bot/.flue/lib/router.ts | Adds pure routing logic from machine state/labels + comment parsing and resolution. |
| infra/emdash-bot/.flue/lib/orchestrator.ts | Adds the OrchestratorDO implementation: inbox, dedupe, side effects, dispatch + recovery. |
| infra/emdash-bot/.flue/lib/observer.ts | Adds Flue event observer logging for more actionable production logs. |
| infra/emdash-bot/.flue/lib/machine.ts | Adds the single-source-of-truth bot state machine (states/events/transitions/validation). |
| infra/emdash-bot/.flue/lib/github.ts | Adds GitHub App helpers for token minting and repo-side effects (labels/comments/PR ops). |
| infra/emdash-bot/.flue/lib/github-proxy.ts | Adds sandbox proxy gate to restrict GitHub/api access (repo-scoped, read-only API, bot-branch-only push). |
| infra/emdash-bot/.flue/lib/classifier-client.ts | Adds classifier dispatch + validation/mapping of structured classifier outputs. |
| infra/emdash-bot/.flue/cloudflare.ts | Adds Cloudflare-target DO exports and authenticated outbound GitHub proxy for the sandbox. |
| infra/emdash-bot/.flue/app.ts | Adds Worker entry wiring core routes + authenticated Flue agent control routes. |
| infra/emdash-bot/.flue/agents/investigate.ts | Adds Flue 2 investigate agent integrating sandbox setup + result reporting to the DO. |
| infra/emdash-bot/.flue/agents/classify-command.ts | Adds Flue 2 classifier agent for choosing a safe command from a bounded set. |
| infra/emdash-bot/.env.example | Adds local dev secret template for webhook secret and app private key. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // `@emdashbot decline\nplease don't` would otherwise be treated as a bare | ||
| // `decline` (an `$` end-of-line anchor with the `m` flag matches after the | ||
| // verb word), bypassing the destructive-event guard. | ||
| const MENTION_RE = /^[ \t]*@emdashbot[ \t]+([\s\S]*)/m; |
| let machineEvent: NormalizedEvent["event"]; | ||
| switch (action) { | ||
| case "opened": | ||
| case "reopened": | ||
| machineEvent = "pr.opened"; | ||
| break; | ||
| case "closed": | ||
| machineEvent = "pr.merged"; // refined to merged-vs-closed via pr.merged below | ||
| break; | ||
| default: | ||
| return { kind: "skip", reason: `pull_request.${action} not handled` }; | ||
| } |
| - `curl https://api.github.com/...` GET anything (read issues, PRs, files, blobs). | ||
| - `curl -X POST https://api.github.com/repos/emdash-cms/emdash/issues/<your-issue>/comments -d '{"body":"..."}'` to post a comment on the issue you're working on. | ||
| - Same for `/reactions` to add an emoji reaction. | ||
| - Writes outside the configured repository are denied (403). Don't try. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
This is the right change for the stated goal: moving emdashbot orchestration from Flue 1 workflows into Flue 2 agents and making the per-issue Durable Object the canonical source of state. The scope is well-contained to infra/emdash-bot/, the state machine/router separation is clean, and the test matrix (unit router/proxy tests + workers-pool DO/integration tests) covers the most failure-prone seams.
I reviewed the diff statically against the checked-out PR head. I did not run the test/lint/build tooling (no shell in this environment), so test results are taken as unverified claims from the PR description.
Headline conclusion: the architecture is sound, but there are a few concrete issues that should be fixed before merging:
- Sandbox GitHub proxy auth scheme is wrong for API calls. The proxy sends
Basic x-access-token:<token>to every host. That works for git HTTPS traffic ongithub.com, but the GitHub REST API expectsAuthorization: Bearer <installation_token>; Basic will 401 for API reads the agent may perform from inside the container. - Readonly status replies do not respect
x-emdash-dry-run: 1. The transition path correctly suppresses side effects whendryRunis set, but thereadonlybranch still queues a GitHub comment, defeating the smoke-test mode. - Type generation is missing for a clean checkout.
tsconfig.jsonincludesworker-configuration.d.ts, which is gitignored and not regenerated by any package script, sopnpm typecheckwill fail on a fresh clone until someone manually runswrangler types. pull_request.closedis collapsed intopr.merged. Closing a bot PR without merging will still move the anchor issue todone. There is a comment acknowledging this, but it is a real state-machine regression that should be handled before the rewrite goes live.pkg.pr.newpreview URL in the fix-ready comment looks incorrect. It uses the unqualifiedemdash@<branch>form; for this repo the canonical URL should include the fullowner/repo@<branch>path.
Addressing (1) and (2) are the most important; (3) is a contributor-experience issue; (4)-(5) are follow-up polish.
| return new Response("token mint failed", { status: 502 }); | ||
| } | ||
| const authed = new Request(request); | ||
| authed.headers.set("authorization", `Basic ${btoa(`x-access-token:${token}`)}`); |
There was a problem hiding this comment.
[needs fixing] The outbound GitHub proxy uses Basic x-access-token:<token> for every host. This scheme is correct for git HTTPS operations on github.com, but the GitHub REST API requires Authorization: Bearer <installation_token>; Basic auth on api.github.com will be rejected. Any API read the agent performs via curl inside the sandbox will fail.
| authed.headers.set("authorization", `Basic ${btoa(`x-access-token:${token}`)}`); | |
| const isApi = url.host === "api.github.com"; | |
| authed.headers.set( | |
| "authorization", | |
| isApi ? `Bearer ${token}` : `Basic ${btoa(`x-access-token:${token}`)}`, | |
| ); |
| return { kind: "noop", reason: decision.reason }; | ||
| } | ||
| if (decision.kind === "readonly") { | ||
| await this.postReadonlyReply(decision, input); |
There was a problem hiding this comment.
[needs fixing] The readonly/status reply branch enqueues a GitHub comment without checking input.dryRun. The transition path already suppresses label/comment side effects when dryRun is true, so a dry-run @emdashbot status still mutates the issue. Make the readonly path consistent.
| await this.postReadonlyReply(decision, input); | |
| if (decision.kind === "readonly") { | |
| if (!input.dryRun) await this.postReadonlyReply(decision, input); | |
| if (input.deliveryId) await this.recordDelivery(input.deliveryId); | |
| return { kind: "readonly", state: decision.state, event: decision.event }; | |
| } |
| "skipLibCheck": true, | ||
| "noEmit": true | ||
| }, | ||
| "include": [".flue/**/*.ts", "tests/**/*.ts", "worker-configuration.d.ts"] |
There was a problem hiding this comment.
[suggestion] tsconfig.json depends on worker-configuration.d.ts, but that file is gitignored and there is no pretypecheck script (or equivalent) that runs wrangler types. A fresh clone will hit a missing-file error on pnpm typecheck. Add a generation step or commit a generated stub.
| case "reopened": | ||
| machineEvent = "pr.opened"; | ||
| break; | ||
| case "closed": |
There was a problem hiding this comment.
[suggestion] pull_request.closed always emits pr.merged, so a bot PR closed without merging will still move the anchor issue to done. The code already notes this as a follow-up, but it is a real state-machine regression in the new rewrite. Read payload.pull_request.merged and emit pr.closed when the PR was not merged.
| "Try it:", | ||
| "", | ||
| "```sh", | ||
| `pnpm add https://pkg.pr.new/emdash@bot/fix-${anchorNumber}`, |
There was a problem hiding this comment.
[suggestion] The fix-ready comment uses https://pkg.pr.new/emdash@bot/fix-${anchorNumber}, which omits the owner and is unlikely to be the canonical preview URL for this repo. Use https://pkg.pr.new/emdash-cms/emdash@bot/fix-${anchorNumber} so the generated link actually resolves.
There was a problem hiding this comment.
This remains the right change: moving emdashbot orchestration from Flue 1 workflows into Flue 2 agents, with the per-issue Durable Object as the canonical state source. The scope is well-contained to infra/emdash-bot/, the separation between pure router logic and the stateful DO is clean, and the test matrix covers the main failure modes.
All five issues from the previous review have been addressed in the current head:
- GitHub proxy auth scheme now sends
Bearerforapi.github.comand Basicx-access-tokenfor git hosts. x-emdash-dry-run: 1now suppresses readonly status replies viashouldPostReadonlyReply.- Type generation on a clean checkout is covered by the new
pretypecheckscript. pull_request.closedwithout merging now routes toblocked, notdone.pkg.pr.newURL uses the canonicalemdash-cms/emdash@…form.
I reviewed the diff statically against the checked-out PR head and did not re-run the test/lint/build tooling (no shell in this environment), so those test-result claims are taken as unverified.
One real logic bug remains: agent runs that were dispatched in implement or revise mode can never reach agent.fix_ready because outcomeFromResult gates every successful outcome behind reproduced === true. The skill instructions explicitly say reproduced is only meaningful in repro mode, and the result schema makes it optional, so @emdashbot implement <directive> runs that successfully push a fix currently land in blocked instead of awaiting_feedback. This should be fixed before the rewrite goes live, ideally by threading the run mode into outcomeFromResult so the reproduced check only applies to bug-reproduction runs. Coverage for the implement/revise success path is also missing.
A smaller suggestion: wrangler.test.jsonc intentionally warns against drift with production, but its compatibility_date lags wrangler.jsonc (2026-04-01 vs 2026-07-17), so the workers-pool tests may not exercise the same runtime surface as production.
| export function outcomeFromResult({ | ||
| ok, | ||
| result, | ||
| pushed, | ||
| }: { | ||
| ok: boolean; | ||
| result?: AgentResult | null; | ||
| pushed?: boolean; | ||
| }): EventId { | ||
| if (!ok || !result || typeof result !== "object") return "agent.failed"; | ||
| if (result.skipped === true) return "agent.skipped"; | ||
| if (result.verdict === "intended-behavior") return "agent.by_design"; | ||
| if (result.reproduced !== true) return "agent.not_reproduced"; |
There was a problem hiding this comment.
[needs fixing] outcomeFromResult requires reproduced === true before it will ever emit agent.fix_ready, which is wrong for implement and revise runs.
- In
investigate.tsthe skill instructions sayreproducedis "only meaningful inrepromode". - The result schema makes
reproducedoptional. - But this function returns
agent.not_reproducedwheneverreproducedis nottrue, even iffixedistrueand the branch was pushed.
So a successful implement or revise run can never advance the issue to awaiting_feedback; it will get stuck in blocked. The clean fix is to thread the run mode through to this function and only gate on reproduced for mode === "repro".
export function outcomeFromResult({
ok,
result,
pushed,
mode, // <-- plumbed from the DO's current run
}: {
ok: boolean;
result?: AgentResult | null;
pushed?: boolean;
mode?: "repro" | "implement" | "revise";
}): EventId {
if (!ok || !result || typeof result !== "object") return "agent.failed";
if (result.skipped === true) return "agent.skipped";
if (result.verdict === "intended-behavior") return "agent.by_design";
if (mode === "repro" && result.reproduced !== true) return "agent.not_reproduced";
if (result.fixed === true) return pushed === true ? "agent.fix_ready" : "agent.failed";
return mode === "repro" ? "agent.reproduced" : "agent.failed";
}Store currentRunMode in the DO when dispatching (prepareInvestigation already knows the mode), pass it into applyAgentResult, and add unit/router tests for implement/revise outcomes and an integration test where such a run yields fix_ready.
| "$schema": "node_modules/wrangler/config-schema.json", | ||
| "name": "emdash-bot-test", | ||
| "account_id": "1f74638c495bc9f0330ce5c8e64c1b6b", | ||
| "compatibility_date": "2026-04-01", |
There was a problem hiding this comment.
[suggestion] The file explicitly warns against drift with production, but the test worker is pinned to 2026-04-01 while wrangler.jsonc uses 2026-07-17. Aligning the compatibility_date (and any new flags) keeps the workers-pool tests from passing on a different runtime surface than production.
| "compatibility_date": "2026-04-01", | |
| "compatibility_date": "2026-07-17", |
What does this PR do?
Migrates emdashbot from Flue 1 workflows to Flue 2 agents and makes the Durable Object the canonical source of orchestration state.
Closes: N/A, internal emdashbot infrastructure migration.
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
No visual changes. Verified with the full workspace build and package typecheck, full type-aware lint, formatting checks, 70 bot unit tests, and 26 Workers integration tests.
Try this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
feat/emdash-bot-worker. Updated automatically when the playground redeploys.