diff --git a/.oxfmtrc.json b/.oxfmtrc.json index c126a58b59..72401a3482 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -9,6 +9,8 @@ "**/emdash-env.d.ts", "**/worker-configuration.d.ts", "packages/registry-lexicons/src/generated/**", - "packages/plugin-cli/schemas/**" + "packages/plugin-cli/schemas/**", + "infra/emdash-bot/.flue/lib/machine.json", + "infra/emdash-bot/BOT_STATE_MACHINE.md" ] } diff --git a/infra/emdash-bot/.flue/agents/investigate.ts b/infra/emdash-bot/.flue/agents/investigate.ts index c05d6d7526..e28ac1cb62 100644 --- a/infra/emdash-bot/.flue/agents/investigate.ts +++ b/infra/emdash-bot/.flue/agents/investigate.ts @@ -37,7 +37,7 @@ const SANDBOX_EXEC_GRACE_MS = 30_000; const initialDataSchema = v.object({ runId: v.pipe(v.string(), v.minLength(1)), issueNumber: v.number(), - mode: v.picklist(["repro", "implement", "revise"]), + mode: v.picklist(["repro", "implement", "revise", "diagnose", "fix"]), arg: v.optional(v.nullable(v.string())), issueTitle: v.pipe(v.string(), v.minLength(1)), issueBody: v.string(), @@ -173,12 +173,17 @@ async function setupSandbox( if (!repo) throw new Error("repository context is not configured"); const cloneUrl = `https://github.com/${repo.owner}/${repo.repo}.git`; const branch = input.mode === "revise" ? `bot/fix-${input.issueNumber}` : "main"; - const pushCapability = await createPushCapability( - workerEnv.GITHUB_WEBHOOK_SECRET, - repo.owner, - repo.repo, - input.issueNumber, - ); + // Diagnose mode is investigation-only: no push capability enters the + // sandbox, so a fix push is impossible rather than merely instructed against. + const pushCapability = + input.mode === "diagnose" + ? null + : await createPushCapability( + workerEnv.GITHUB_WEBHOOK_SECRET, + repo.owner, + repo.repo, + input.issueNumber, + ); const steps: Array<{ name: string; command: string; timeoutMs?: number; nonFatal?: boolean }> = [ { name: "git-identity-email", @@ -200,10 +205,14 @@ async function setupSandbox( name: "checkout-main", command: `cd ${REPO_DIR} && git checkout main && git reset --hard origin/main`, }, - { - name: "git-push-capability", - command: `cd ${REPO_DIR} && git config http.https://github.com/.extraHeader '${PUSH_CAPABILITY_HEADER}: ${pushCapability}'`, - }, + ...(pushCapability + ? [ + { + name: "git-push-capability", + command: `cd ${REPO_DIR} && git config http.https://github.com/.extraHeader '${PUSH_CAPABILITY_HEADER}: ${pushCapability}'`, + }, + ] + : []), { name: "pnpm-install", command: `cd ${REPO_DIR} && pnpm install --frozen-lockfile --prefer-offline`, @@ -268,6 +277,22 @@ async function detectPush(issueNumber: number, previousBranchSha: string | null) function buildPrompt(input: InvestigateData): string { const argSection = input.arg ? ["", "## Directive", "", input.arg, ""].join("\n") : ""; + const diagnose = input.mode === "diagnose"; + const method = diagnose + ? [ + "- Read AGENTS.md, find the relevant code, and attempt to reproduce the bug.", + "- Diagnose the root cause. Do NOT write or push a fix -- this is investigation only.", + "- Report `reproduced` and put the diagnosis in `summary`. Use verdict `unclear` only when you are blocked on information that only the reporter can supply.", + ] + : [ + "- Read AGENTS.md, find the relevant code, attempt to reproduce, build, or revise.", + "- Write tests where they make sense.", + "- Touch only files relevant to the issue. Do not bulk-format or modify .github/workflows.", + `- When done, commit and push: \`git checkout -B bot/fix-${input.issueNumber} && git add && git commit -m '' && git push -u origin HEAD --force-with-lease\`.`, + ]; + const closing = diagnose + ? "Call report_result exactly once when finished. Do not set fixed; report reproduced and your verdict with the diagnosis in summary." + : "Call report_result exactly once when finished. fixed may only be true if a fix and test passed and the branch was pushed."; return [ `Investigate issue #${input.issueNumber} in mode: ${input.mode}.`, "", @@ -279,11 +304,8 @@ function buildPrompt(input: InvestigateData): string { argSection, "## Method", "", - "- Read AGENTS.md, find the relevant code, attempt to reproduce, build, or revise.", - "- Write tests where they make sense.", - "- Touch only files relevant to the issue. Do not bulk-format or modify .github/workflows.", - `- When done, commit and push: \`git checkout -B bot/fix-${input.issueNumber} && git add && git commit -m '' && git push -u origin HEAD --force-with-lease\`.`, + ...method, "", - "Call report_result exactly once when finished. fixed may only be true if a fix and test passed and the branch was pushed.", + closing, ].join("\n"); } diff --git a/infra/emdash-bot/.flue/lib/comments.ts b/infra/emdash-bot/.flue/lib/comments.ts index 239ef981ad..148b521a1d 100644 --- a/infra/emdash-bot/.flue/lib/comments.ts +++ b/infra/emdash-bot/.flue/lib/comments.ts @@ -27,6 +27,20 @@ export function renderReadonlyReply(state: StateId | null): string { return "I declined this. Reopen with `@emdashbot reopen` if circumstances change."; case "failed": return "My last attempt failed. A maintainer can `@emdashbot retry` or take it over."; + case "investigating": + return "Investigating now (reproduce + diagnose). I'll report a verdict with evidence."; + case "reproduced": + return "Reproduced it -- diagnosis in my last comment. A maintainer can `@emdashbot fix` to try a fix, or `@emdashbot decline`."; + case "not_reproduced": + return "I couldn't reproduce this; transcript above. Reply with steps that fail for you, or a maintainer can `@emdashbot decline`."; + case "needs_info": + return "I need more to go on -- see my last comment for what's missing."; + case "fixing": + return "Building a candidate fix."; + case "preview_building": + return "Building a preview so you can try the fix."; + case "awaiting_reporter": + return "Try the preview from my last comment. Reply `@emdashbot confirm` if it's fixed, or describe what's still wrong."; default: { const _exhaustive: never = state; return `State: \`${String(_exhaustive)}\`.`; @@ -52,6 +66,11 @@ export function renderAgentComment( switch (decision.event) { case "agent.fix_ready": + // The fix loop routes fix_ready into preview_building, where the preview + // pipeline posts a deployed-preview link on preview.ready; a pkg.pr.new + // install line is the legacy awaiting_feedback lane only. + if (decision.to === "preview_building") + return `${summary}\n\nBuilding a preview so you can try the change before I open a PR.`; return [ summary, "", @@ -64,9 +83,13 @@ export function renderAgentComment( "Reply `@emdashbot confirm` if it works and I'll open the PR, or `@emdashbot revise ` to push changes.", ].join("\n"); case "agent.reproduced": + if (decision.to === "reproduced") + return `${summary}\n\nA maintainer can \`@emdashbot fix\` to try a fix, or \`@emdashbot decline\`.`; return `${summary}\n\nReply \`@emdashbot implement \` if you want me to take another swing with guidance.`; case "agent.not_reproduced": return `${summary}\n\nReply with steps that fail for you, or close if it's no longer relevant.`; + case "agent.needs_info": + return `${summary}\n\nReply with the details above; a maintainer can \`@emdashbot investigate\` again once they arrive.`; default: return summary; } diff --git a/infra/emdash-bot/.flue/lib/github.ts b/infra/emdash-bot/.flue/lib/github.ts index dfff605c52..4371851a3c 100644 --- a/infra/emdash-bot/.flue/lib/github.ts +++ b/infra/emdash-bot/.flue/lib/github.ts @@ -183,6 +183,16 @@ export async function getBranchSha( return json.commit?.sha ?? null; } +/** Deletes a branch ref. A 404/422 means it is already gone, which is fine. */ +export async function deleteBranch(token: string, ctx: RepoContext, branch: string): Promise { + const res = await githubFetch( + `${GITHUB_API}/repos/${ctx.owner}/${ctx.repo}/git/refs/heads/${encodeURIComponent(branch)}`, + { method: "DELETE", headers: authHeaders(token) }, + ); + if (res.status === 404 || res.status === 422) return; + if (!res.ok) throw new Error(`deleteBranch(${branch}) failed: ${res.status} ${await res.text()}`); +} + export async function addLabels( token: string, ctx: RepoContext, @@ -252,7 +262,7 @@ export async function getOpenPullRequest( export async function createPullRequest( token: string, ctx: RepoContext, - args: { headBranch: string; baseBranch: string; title: string; body: string }, + args: { headBranch: string; baseBranch: string; title: string; body: string; draft?: boolean }, ): Promise { const res = await githubFetch(`${GITHUB_API}/repos/${ctx.owner}/${ctx.repo}/pulls`, { method: "POST", @@ -262,6 +272,7 @@ export async function createPullRequest( base: args.baseBranch, title: args.title, body: args.body, + draft: args.draft === true, }), }); if (!res.ok) { diff --git a/infra/emdash-bot/.flue/lib/machine.json b/infra/emdash-bot/.flue/lib/machine.json new file mode 100644 index 0000000000..e1029bcff2 --- /dev/null +++ b/infra/emdash-bot/.flue/lib/machine.json @@ -0,0 +1,908 @@ +{ + "kinds": [ + "bug", + "enhancement", + "task" + ], + "entryState": "unmanaged", + "states": { + "unmanaged": { + "label": "", + "boardColumn": "(none)", + "description": "No bot labels yet. An issue nobody has handed to the bot. Entry commands work directly.", + "terminal": false, + "offeredCommands": [ + "investigate", + "repro", + "implement", + "decline" + ] + }, + "triage": { + "label": "bot:triage", + "boardColumn": "Triage", + "description": "Filed and awaiting a decision on whether/how the bot should act.", + "terminal": false, + "offeredCommands": [ + "investigate", + "repro", + "implement", + "decline" + ] + }, + "working": { + "label": "bot:working", + "boardColumn": "Working", + "description": "An agent run is in flight (reproduce / diagnose / verify / fix / implement).", + "terminal": false, + "transient": true, + "offeredCommands": [ + "status" + ] + }, + "blocked": { + "label": "bot:blocked", + "boardColumn": "Blocked", + "description": "The bot stopped and needs a human decision. Covers the old skipped / not-reproduced / reproduced-no-fix / by-design outcomes; the reason is in the bot's comment.", + "terminal": false, + "offeredCommands": [ + "investigate", + "implement", + "repro", + "retry", + "decline", + "take_over" + ] + }, + "awaiting_feedback": { + "label": "bot:awaiting-feedback", + "boardColumn": "Awaiting feedback", + "description": "A fix is staged on bot/fix-; waiting for the reporter or a maintainer to confirm or reject.", + "terminal": false, + "offeredCommands": [ + "confirm", + "reject", + "retry", + "take_over" + ] + }, + "in_review": { + "label": "bot:in-review", + "boardColumn": "In review", + "description": "A PR is open. The review/* sub-states live on the PR and roll up here. On a bot PR, a plain `@emdashbot` comment is feedback; explicit verbs still win.", + "terminal": false, + "offeredCommands": [ + "revise", + "decline", + "take_over" + ], + "defaultCommentEvent": "revise" + }, + "human_owned": { + "label": "bot:human-owned", + "boardColumn": "Human owned", + "description": "A maintainer took it over; the bot stays disengaged but the item stays on the board.", + "terminal": false, + "offeredCommands": [ + "hand_back" + ] + }, + "done": { + "label": "bot:done", + "boardColumn": "Done", + "description": "Shipped (PR merged) or confirmed resolved.", + "terminal": true, + "offeredCommands": [ + "reopen" + ] + }, + "declined": { + "label": "bot:declined", + "boardColumn": "Declined", + "description": "Won't be actioned (by design, out of scope, or a maintainer call).", + "terminal": true, + "offeredCommands": [ + "reopen" + ] + }, + "failed": { + "label": "bot:failed", + "boardColumn": "Failed", + "description": "An agent run errored or produced no usable result. Retryable -- not a dead end.", + "terminal": false, + "offeredCommands": [ + "retry", + "implement", + "repro", + "investigate", + "decline" + ] + }, + "investigating": { + "label": "bot:investigating", + "boardColumn": "Investigating", + "description": "A maintainer-triggered investigation is in flight: reproduce + diagnose, no fix. Emits an evidence-carrying verdict.", + "terminal": false, + "transient": true, + "offeredCommands": [ + "status" + ] + }, + "reproduced": { + "label": "bot:reproduced", + "boardColumn": "Reproduced", + "description": "Verdict: reproduced with a diagnosis attached. Resting until a maintainer triggers the fix loop or disposes of it.", + "terminal": false, + "offeredCommands": [ + "fix", + "investigate", + "decline", + "take_over" + ] + }, + "not_reproduced": { + "label": "bot:not-reproduced", + "boardColumn": "Not reproduced", + "description": "Verdict: could not reproduce, transcript attached. A first-class outcome, not a failure. Reporter can add steps; a maintainer can re-investigate.", + "terminal": false, + "offeredCommands": [ + "investigate", + "decline", + "take_over" + ] + }, + "needs_info": { + "label": "bot:needs-info", + "boardColumn": "Needs info", + "description": "Verdict: the investigation needs information only the reporter has. Evidence records what was tried and what is missing.", + "terminal": false, + "offeredCommands": [ + "investigate", + "decline", + "take_over" + ] + }, + "fixing": { + "label": "bot:fixing", + "boardColumn": "Fixing", + "description": "A maintainer-triggered fix run is in flight: build a candidate change on bot/fix-. No PR yet.", + "terminal": false, + "transient": true, + "offeredCommands": [ + "status" + ] + }, + "preview_building": { + "label": "bot:preview-building", + "boardColumn": "Building preview", + "description": "The candidate fix is pushed; a preview deploy is building so the reporter can try the change before a PR exists.", + "terminal": false, + "transient": true, + "offeredCommands": [ + "status" + ] + }, + "awaiting_reporter": { + "label": "bot:awaiting-reporter", + "boardColumn": "Awaiting reporter", + "description": "Preview link posted; waiting for the reporter to confirm the fix. On confirm a draft PR opens; on denial or 14-day silence the branch is reaped.", + "terminal": false, + "offeredCommands": [ + "confirm", + "reject", + "decline", + "take_over" + ] + } + }, + "events": { + "repro": { + "description": "Reproduce the issue as a bug and attempt a fix.", + "actors": [ + "maintainer" + ], + "labelTriggers": [ + "bot:repro" + ], + "defaultKind": "bug" + }, + "investigate": { + "description": "Reproduce and diagnose the issue as a bug, with evidence. Does not attempt a fix.", + "actors": [ + "maintainer" + ], + "labelTriggers": [ + "bot:investigate" + ], + "arg": "directive", + "defaultKind": "bug" + }, + "implement": { + "description": "Build the described change (feature or directed fix), skipping the bug-repro gate.", + "actors": [ + "maintainer" + ], + "labelTriggers": [ + "bot:implement" + ], + "arg": "directive", + "defaultKind": "enhancement" + }, + "fix": { + "description": "Build a candidate fix on a bot branch and post a preview for the reporter to try.", + "actors": [ + "maintainer" + ], + "labelTriggers": [ + "bot:fix" + ], + "arg": "directive" + }, + "retry": { + "description": "Re-run the bug reproduction pipeline.", + "actors": [ + "maintainer" + ] + }, + "revise": { + "description": "Send review feedback back into the agent to update the open PR branch.", + "actors": [ + "maintainer" + ], + "arg": "feedback" + }, + "confirm": { + "description": "Confirm the staged fix works; open a PR.", + "actors": [ + "reporter", + "maintainer" + ] + }, + "reject": { + "description": "The staged fix does not work; retry with feedback.", + "actors": [ + "reporter", + "maintainer" + ], + "arg": "feedback" + }, + "decline": { + "description": "Won't be actioned; move to declined.", + "actors": [ + "maintainer" + ], + "destructive": true, + "defaultKind": "task" + }, + "reopen": { + "description": "Bring a terminal item back into triage.", + "actors": [ + "maintainer" + ] + }, + "take_over": { + "description": "A maintainer takes the item; the bot disengages but stays on the board.", + "actors": [ + "maintainer" + ], + "destructive": true + }, + "hand_back": { + "description": "Return a human-owned item to the bot.", + "actors": [ + "maintainer" + ] + }, + "reset": { + "description": "Force-reset to triage. Maintainer recovery for conflicting state labels.", + "actors": [ + "maintainer" + ], + "destructive": true + }, + "status": { + "description": "Render the item's current state and available commands.", + "actors": [ + "reporter", + "maintainer" + ], + "readOnly": true + }, + "help": { + "description": "Show the command grammar.", + "actors": [ + "reporter", + "maintainer" + ], + "readOnly": true + }, + "agent.skipped": { + "description": "Agent skipped (non-bug kind, or repro needs external/prod-only conditions).", + "actors": [ + "system" + ] + }, + "agent.not_reproduced": { + "description": "Agent could not reproduce the issue.", + "actors": [ + "system" + ] + }, + "agent.by_design": { + "description": "Agent verified the behaviour as intended.", + "actors": [ + "system" + ] + }, + "agent.reproduced": { + "description": "Reproduced, but the fix needs a human decision.", + "actors": [ + "system" + ] + }, + "agent.fix_ready": { + "description": "Reproduced and fixed; a verified change is staged on bot/fix-.", + "actors": [ + "system" + ] + }, + "agent.needs_info": { + "description": "Investigation is blocked on information only the reporter can supply.", + "actors": [ + "system" + ] + }, + "agent.failed": { + "description": "Agent run errored or produced no usable result.", + "actors": [ + "system" + ] + }, + "pr.opened": { + "description": "A bot PR was opened for this item.", + "actors": [ + "system" + ] + }, + "pr.merged": { + "description": "The bot PR was merged.", + "actors": [ + "system" + ] + }, + "pr.closed": { + "description": "The bot PR was closed without merging.", + "actors": [ + "system" + ] + }, + "pr.changes_requested": { + "description": "A reviewer requested changes (review sub-state).", + "actors": [ + "system" + ] + }, + "pr.approved": { + "description": "A reviewer approved the PR (review sub-state).", + "actors": [ + "system" + ] + }, + "preview.ready": { + "description": "The preview deploy for the candidate fix is live; link ready to post.", + "actors": [ + "system" + ] + }, + "preview.failed": { + "description": "The preview deploy failed to build.", + "actors": [ + "system" + ] + }, + "expire": { + "description": "The reporter-confirmation window elapsed without a reply.", + "actors": [ + "system" + ] + } + }, + "transitions": [ + { + "from": "unmanaged", + "event": "repro", + "to": "working", + "action": "investigate.repro" + }, + { + "from": "unmanaged", + "event": "implement", + "to": "working", + "action": "investigate.implement", + "note": "implement works straight from an untriaged issue" + }, + { + "from": "unmanaged", + "event": "decline", + "to": "declined" + }, + { + "from": "triage", + "event": "repro", + "to": "working", + "action": "investigate.repro" + }, + { + "from": "triage", + "event": "implement", + "to": "working", + "action": "investigate.implement", + "note": "enhancement/feature lane -- no repro gate" + }, + { + "from": "triage", + "event": "decline", + "to": "declined" + }, + { + "from": "working", + "event": "agent.skipped", + "to": "blocked", + "note": "reason: skipped (was a sink)" + }, + { + "from": "working", + "event": "agent.not_reproduced", + "to": "blocked", + "note": "reason: not-reproduced (was a sink)" + }, + { + "from": "working", + "event": "agent.by_design", + "to": "blocked", + "note": "reason: by-design" + }, + { + "from": "working", + "event": "agent.reproduced", + "to": "blocked", + "note": "reason: fix needs a decision" + }, + { + "from": "working", + "event": "agent.fix_ready", + "to": "awaiting_feedback", + "note": "executor pushes bot/fix-; orchestrator asks the reporter to confirm. PR opens on confirm, not here." + }, + { + "from": "working", + "event": "agent.failed", + "to": "failed" + }, + { + "from": "blocked", + "event": "implement", + "to": "working", + "action": "investigate.implement" + }, + { + "from": "blocked", + "event": "repro", + "to": "working", + "action": "investigate.repro" + }, + { + "from": "blocked", + "event": "retry", + "to": "working", + "action": "investigate.repro" + }, + { + "from": "blocked", + "event": "decline", + "to": "declined" + }, + { + "from": "blocked", + "event": "take_over", + "to": "human_owned" + }, + { + "from": "awaiting_feedback", + "event": "confirm", + "to": "in_review", + "action": "openPr" + }, + { + "from": "awaiting_feedback", + "event": "reject", + "to": "working", + "action": "investigate.revise", + "note": "retry with reporter feedback" + }, + { + "from": "awaiting_feedback", + "event": "retry", + "to": "working", + "action": "investigate.repro" + }, + { + "from": "awaiting_feedback", + "event": "take_over", + "to": "human_owned" + }, + { + "from": "in_review", + "event": "pr.opened", + "to": "in_review", + "note": "idempotent; sets review sub-state" + }, + { + "from": "in_review", + "event": "pr.approved", + "to": "in_review", + "note": "review sub-state only" + }, + { + "from": "in_review", + "event": "pr.changes_requested", + "to": "in_review", + "note": "review sub-state only" + }, + { + "from": "in_review", + "event": "revise", + "to": "working", + "action": "investigate.revise", + "note": "PR feedback -> agent (was impossible)" + }, + { + "from": "in_review", + "event": "pr.merged", + "to": "done" + }, + { + "from": "working", + "event": "pr.merged", + "to": "done", + "note": "merged mid-revise" + }, + { + "from": "awaiting_feedback", + "event": "pr.merged", + "to": "done", + "note": "merged before confirm" + }, + { + "from": "in_review", + "event": "pr.closed", + "to": "blocked", + "note": "PR closed without merge" + }, + { + "from": "working", + "event": "pr.closed", + "to": "blocked", + "note": "PR closed mid-revise" + }, + { + "from": "awaiting_feedback", + "event": "pr.closed", + "to": "blocked", + "note": "PR closed while awaiting confirm" + }, + { + "from": "unmanaged", + "event": "reset", + "to": "triage" + }, + { + "from": "triage", + "event": "reset", + "to": "triage" + }, + { + "from": "working", + "event": "reset", + "to": "triage" + }, + { + "from": "blocked", + "event": "reset", + "to": "triage" + }, + { + "from": "awaiting_feedback", + "event": "reset", + "to": "triage" + }, + { + "from": "in_review", + "event": "reset", + "to": "triage" + }, + { + "from": "human_owned", + "event": "reset", + "to": "triage" + }, + { + "from": "done", + "event": "reset", + "to": "triage" + }, + { + "from": "declined", + "event": "reset", + "to": "triage" + }, + { + "from": "failed", + "event": "reset", + "to": "triage" + }, + { + "from": "in_review", + "event": "decline", + "to": "declined", + "action": "closePr" + }, + { + "from": "in_review", + "event": "take_over", + "to": "human_owned" + }, + { + "from": "human_owned", + "event": "hand_back", + "to": "triage" + }, + { + "from": "done", + "event": "reopen", + "to": "triage" + }, + { + "from": "declined", + "event": "reopen", + "to": "triage" + }, + { + "from": "failed", + "event": "retry", + "to": "working", + "action": "investigate.repro" + }, + { + "from": "failed", + "event": "implement", + "to": "working", + "action": "investigate.implement" + }, + { + "from": "failed", + "event": "repro", + "to": "working", + "action": "investigate.repro" + }, + { + "from": "failed", + "event": "decline", + "to": "declined" + }, + { + "from": "unmanaged", + "event": "investigate", + "to": "investigating", + "action": "investigate.diagnose", + "note": "cold trigger; applies bot:bug" + }, + { + "from": "triage", + "event": "investigate", + "to": "investigating", + "action": "investigate.diagnose" + }, + { + "from": "blocked", + "event": "investigate", + "to": "investigating", + "action": "investigate.diagnose" + }, + { + "from": "failed", + "event": "investigate", + "to": "investigating", + "action": "investigate.diagnose" + }, + { + "from": "not_reproduced", + "event": "investigate", + "to": "investigating", + "action": "investigate.diagnose", + "note": "re-run after the reporter adds steps" + }, + { + "from": "needs_info", + "event": "investigate", + "to": "investigating", + "action": "investigate.diagnose", + "note": "re-run once the missing info arrives" + }, + { + "from": "reproduced", + "event": "investigate", + "to": "investigating", + "action": "investigate.diagnose", + "note": "re-diagnose" + }, + { + "from": "investigating", + "event": "agent.reproduced", + "to": "reproduced" + }, + { + "from": "investigating", + "event": "agent.not_reproduced", + "to": "not_reproduced" + }, + { + "from": "investigating", + "event": "agent.needs_info", + "to": "needs_info" + }, + { + "from": "investigating", + "event": "agent.by_design", + "to": "blocked", + "note": "by-design verdict rests in blocked for a maintainer to decline" + }, + { + "from": "investigating", + "event": "agent.skipped", + "to": "blocked", + "note": "repro needs external/prod-only conditions" + }, + { + "from": "investigating", + "event": "agent.failed", + "to": "failed" + }, + { + "from": "reproduced", + "event": "fix", + "to": "fixing", + "action": "investigate.fix" + }, + { + "from": "reproduced", + "event": "decline", + "to": "declined" + }, + { + "from": "reproduced", + "event": "take_over", + "to": "human_owned" + }, + { + "from": "not_reproduced", + "event": "decline", + "to": "declined" + }, + { + "from": "not_reproduced", + "event": "take_over", + "to": "human_owned" + }, + { + "from": "needs_info", + "event": "decline", + "to": "declined" + }, + { + "from": "needs_info", + "event": "take_over", + "to": "human_owned" + }, + { + "from": "fixing", + "event": "agent.fix_ready", + "to": "preview_building" + }, + { + "from": "fixing", + "event": "agent.failed", + "to": "failed" + }, + { + "from": "fixing", + "event": "agent.by_design", + "to": "blocked", + "note": "fix run concluded the behaviour is intended" + }, + { + "from": "fixing", + "event": "agent.skipped", + "to": "blocked", + "note": "fix run skipped rather than building a candidate; rest in blocked for a maintainer" + }, + { + "from": "preview_building", + "event": "preview.ready", + "to": "awaiting_reporter" + }, + { + "from": "preview_building", + "event": "preview.failed", + "to": "reproduced", + "note": "candidate branch retained; the diagnosis still holds, so fall back to the reproduced verdict" + }, + { + "from": "awaiting_reporter", + "event": "confirm", + "to": "in_review", + "action": "openDraftPr" + }, + { + "from": "awaiting_reporter", + "event": "reject", + "to": "reproduced", + "action": "reapBranch", + "note": "denial reaps the unvalidated branch; feedback is recorded for the next fix" + }, + { + "from": "awaiting_reporter", + "event": "expire", + "to": "reproduced", + "action": "reapBranch", + "note": "14-day silence reaps the branch; the reproduced verdict survives" + }, + { + "from": "awaiting_reporter", + "event": "take_over", + "to": "human_owned" + }, + { + "from": "awaiting_reporter", + "event": "decline", + "to": "declined", + "action": "reapBranch", + "note": "maintainer disposal reaps the branch" + }, + { + "from": "investigating", + "event": "reset", + "to": "triage" + }, + { + "from": "reproduced", + "event": "reset", + "to": "triage" + }, + { + "from": "not_reproduced", + "event": "reset", + "to": "triage" + }, + { + "from": "needs_info", + "event": "reset", + "to": "triage" + }, + { + "from": "fixing", + "event": "reset", + "to": "triage" + }, + { + "from": "preview_building", + "event": "reset", + "to": "triage" + }, + { + "from": "awaiting_reporter", + "event": "reset", + "to": "triage" + } + ] +} diff --git a/infra/emdash-bot/.flue/lib/machine.ts b/infra/emdash-bot/.flue/lib/machine.ts index 6f9bfefe9c..d462290934 100644 --- a/infra/emdash-bot/.flue/lib/machine.ts +++ b/infra/emdash-bot/.flue/lib/machine.ts @@ -43,7 +43,16 @@ export type StateId = | "human_owned" | "done" | "declined" - | "failed"; + | "failed" + // --- next-generation: investigation lifecycle (maintainer-triggered) --- + | "investigating" + | "reproduced" + | "not_reproduced" + | "needs_info" + // --- next-generation: fix loop (maintainer-triggered) --- + | "fixing" + | "preview_building" + | "awaiting_reporter"; export interface StateMeta { /** GitHub label that encodes this state. One per item, always. */ @@ -76,20 +85,21 @@ export const STATES: Record = { // The implicit starting point: an issue the bot has never touched. It // carries no state label, so it is not provisioned and never appears on // the board until a command moves it in. Entry commands (repro / - // implement / decline) work here directly -- triage is not a prerequisite. + // investigate / implement / decline) work here directly -- triage is not + // a prerequisite. label: "", boardColumn: "(none)", description: "No bot labels yet. An issue nobody has handed to the bot. Entry commands work directly.", terminal: false, - offeredCommands: ["repro", "implement", "decline"], + offeredCommands: ["investigate", "repro", "implement", "decline"], }, triage: { label: "bot:triage", boardColumn: "Triage", description: "Filed and awaiting a decision on whether/how the bot should act.", terminal: false, - offeredCommands: ["repro", "implement", "decline"], + offeredCommands: ["investigate", "repro", "implement", "decline"], }, working: { label: "bot:working", @@ -105,7 +115,7 @@ export const STATES: Record = { description: "The bot stopped and needs a human decision. Covers the old skipped / not-reproduced / reproduced-no-fix / by-design outcomes; the reason is in the bot's comment.", terminal: false, - offeredCommands: ["implement", "repro", "retry", "decline", "take_over"], + offeredCommands: ["investigate", "implement", "repro", "retry", "decline", "take_over"], }, awaiting_feedback: { label: "bot:awaiting-feedback", @@ -151,7 +161,73 @@ export const STATES: Record = { boardColumn: "Failed", description: "An agent run errored or produced no usable result. Retryable -- not a dead end.", terminal: false, - offeredCommands: ["retry", "implement", "repro", "decline"], + offeredCommands: ["retry", "implement", "repro", "investigate", "decline"], + }, + + // ----------------------------------------------------------------------- + // Next-generation states. Investigation is repro+diagnose in one run with + // no auto-fix; the fix loop is a separate maintainer-gated trigger. Both + // are expensive (kimi k2.7-code) so their entry events are maintainer-only. + // ----------------------------------------------------------------------- + + investigating: { + label: "bot:investigating", + boardColumn: "Investigating", + description: + "A maintainer-triggered investigation is in flight: reproduce + diagnose, no fix. Emits an evidence-carrying verdict.", + terminal: false, + transient: true, + offeredCommands: ["status"], + }, + reproduced: { + label: "bot:reproduced", + boardColumn: "Reproduced", + description: + "Verdict: reproduced with a diagnosis attached. Resting until a maintainer triggers the fix loop or disposes of it.", + terminal: false, + offeredCommands: ["fix", "investigate", "decline", "take_over"], + }, + not_reproduced: { + label: "bot:not-reproduced", + boardColumn: "Not reproduced", + description: + "Verdict: could not reproduce, transcript attached. A first-class outcome, not a failure. Reporter can add steps; a maintainer can re-investigate.", + terminal: false, + offeredCommands: ["investigate", "decline", "take_over"], + }, + needs_info: { + label: "bot:needs-info", + boardColumn: "Needs info", + description: + "Verdict: the investigation needs information only the reporter has. Evidence records what was tried and what is missing.", + terminal: false, + offeredCommands: ["investigate", "decline", "take_over"], + }, + fixing: { + label: "bot:fixing", + boardColumn: "Fixing", + description: + "A maintainer-triggered fix run is in flight: build a candidate change on bot/fix-. No PR yet.", + terminal: false, + transient: true, + offeredCommands: ["status"], + }, + preview_building: { + label: "bot:preview-building", + boardColumn: "Building preview", + description: + "The candidate fix is pushed; a preview deploy is building so the reporter can try the change before a PR exists.", + terminal: false, + transient: true, + offeredCommands: ["status"], + }, + awaiting_reporter: { + label: "bot:awaiting-reporter", + boardColumn: "Awaiting reporter", + description: + "Preview link posted; waiting for the reporter to confirm the fix. On confirm a draft PR opens; on denial or 14-day silence the branch is reaped.", + terminal: false, + offeredCommands: ["confirm", "reject", "decline", "take_over"], }, }; @@ -186,7 +262,10 @@ export type CommandVerb = | "hand_back" | "reset" | "status" - | "help"; + | "help" + // --- next-generation maintainer triggers --- + | "investigate" + | "fix"; // Events the agent action workflow emits after a run, derived from the flat // gating fields in the Flue result (skipped / reproduced / fixed / verdict). @@ -197,6 +276,7 @@ export type AgentEvent = | "agent.by_design" // verdict === "intended-behavior" | "agent.reproduced" // reproduced && !fixed | "agent.fix_ready" // reproduced && fixed + | "agent.needs_info" // reproduced/unclear but blocked on reporter-only info | "agent.failed"; // nonzero exit / no result file // GitHub PR lifecycle events that propagate onto the anchoring issue. @@ -207,7 +287,15 @@ export type PrEvent = | "pr.changes_requested" | "pr.approved"; -export type EventId = CommandVerb | AgentEvent | PrEvent; +// Preview-deploy lifecycle events, emitted by the preview-build pipeline. +export type PreviewEvent = + | "preview.ready" // deploy succeeded; link ready to post + | "preview.failed"; // deploy errored; no link + +// Timer/alarm events, emitted by the DO cleanup alarm. +export type TimerEvent = "expire"; // reporter silence window elapsed + +export type EventId = CommandVerb | AgentEvent | PrEvent | PreviewEvent | TimerEvent; export interface EventMeta { description: string; @@ -240,6 +328,16 @@ export const EVENTS: Record = { labelTriggers: ["bot:repro"], defaultKind: "bug", }, + // Next-generation split of `repro`: reproduce + diagnose only, no auto-fix. + // The fix loop is a separate `fix` trigger. Maintainer-only (expensive run). + investigate: { + description: + "Reproduce and diagnose the issue as a bug, with evidence. Does not attempt a fix.", + actors: ["maintainer"], + labelTriggers: ["bot:investigate"], + arg: "directive", + defaultKind: "bug", + }, implement: { description: "Build the described change (feature or directed fix), skipping the bug-repro gate.", @@ -248,6 +346,15 @@ export const EVENTS: Record = { arg: "directive", defaultKind: "enhancement", }, + // Next-generation: start the gated fix loop on a reproduced+diagnosed issue. + // Produces a candidate branch and a preview build, not a PR. Maintainer-only. + fix: { + description: + "Build a candidate fix on a bot branch and post a preview for the reporter to try.", + actors: ["maintainer"], + labelTriggers: ["bot:fix"], + arg: "directive", + }, // NB: `retry` is always wired to `investigate.repro` in the transition // table (we don't persist the previous run's mode), so the user-facing // description has to say what it actually does. After `implement`/`revise`, @@ -326,6 +433,13 @@ export const EVENTS: Record = { description: "Reproduced and fixed; a verified change is staged on bot/fix-.", actors: ["system"], }, + // Next-generation: the investigation ran but is blocked on reporter-only + // information (missing repro details it could not infer). Carries evidence + // of what was attempted and what is still needed. + "agent.needs_info": { + description: "Investigation is blocked on information only the reporter can supply.", + actors: ["system"], + }, "agent.failed": { description: "Agent run errored or produced no usable result.", actors: ["system"], @@ -345,6 +459,20 @@ export const EVENTS: Record = { description: "A reviewer approved the PR (review sub-state).", actors: ["system"], }, + // --- preview-deploy lifecycle (next-generation fix loop) --- + "preview.ready": { + description: "The preview deploy for the candidate fix is live; link ready to post.", + actors: ["system"], + }, + "preview.failed": { + description: "The preview deploy failed to build.", + actors: ["system"], + }, + // --- timers (next-generation cleanup alarm) --- + expire: { + description: "The reporter-confirmation window elapsed without a reply.", + actors: ["system"], + }, }; // --------------------------------------------------------------------------- @@ -359,7 +487,12 @@ export type ActionId = | "investigate.implement" // directed build/fix (sets maintainerDirective) | "investigate.revise" // re-run against existing bot/fix- with PR feedback | "openPr" // push branch (already done) + gh pr create - | "closePr"; // close the bot PR + | "closePr" // close the bot PR + // --- next-generation actions --- + | "investigate.diagnose" // bug repro -> diagnose only, no fix; emits a verdict + | "investigate.fix" // build candidate fix on bot/fix-, push, kick preview + | "openDraftPr" // open a DRAFT PR from the reporter-confirmed fix branch + | "reapBranch"; // delete the unvalidated bot/fix- branch // --------------------------------------------------------------------------- // Transitions @@ -466,7 +599,7 @@ export const TRANSITIONS: Transition[] = [ // from every state where the PR may still be open: bot:working (during a // revise run) and bot:awaiting-feedback (right after a revise produced a // new fix and is awaiting reporter confirmation). Late agent results that - // arrive after the merge no-op via investigate-run.yml's terminal guard. + // arrive after the merge no-op via the orchestrator's inert-state guard. { from: "working", event: "pr.merged", to: "done", note: "merged mid-revise" }, { from: "awaiting_feedback", event: "pr.merged", to: "done", note: "merged before confirm" }, @@ -509,6 +642,137 @@ export const TRANSITIONS: Transition[] = [ { from: "failed", event: "implement", to: "working", action: "investigate.implement" }, { from: "failed", event: "repro", to: "working", action: "investigate.repro" }, { from: "failed", event: "decline", to: "declined" }, + + // ======================================================================= + // Next-generation: investigation lifecycle (maintainer-triggered). + // `investigate` = reproduce + diagnose, no fix. It enters from every place + // a maintainer might trigger it: cold issues, the triage rest state, the + // generic blocked/failed buckets, and its own verdict states (re-run). + // ======================================================================= + { + from: "unmanaged", + event: "investigate", + to: "investigating", + action: "investigate.diagnose", + note: "cold trigger; applies bot:bug", + }, + { + from: "triage", + event: "investigate", + to: "investigating", + action: "investigate.diagnose", + }, + { from: "blocked", event: "investigate", to: "investigating", action: "investigate.diagnose" }, + { from: "failed", event: "investigate", to: "investigating", action: "investigate.diagnose" }, + { + from: "not_reproduced", + event: "investigate", + to: "investigating", + action: "investigate.diagnose", + note: "re-run after the reporter adds steps", + }, + { + from: "needs_info", + event: "investigate", + to: "investigating", + action: "investigate.diagnose", + note: "re-run once the missing info arrives", + }, + { + from: "reproduced", + event: "investigate", + to: "investigating", + action: "investigate.diagnose", + note: "re-diagnose", + }, + + // --- investigation outcomes (from investigating) --- + { from: "investigating", event: "agent.reproduced", to: "reproduced" }, + { from: "investigating", event: "agent.not_reproduced", to: "not_reproduced" }, + { from: "investigating", event: "agent.needs_info", to: "needs_info" }, + { + from: "investigating", + event: "agent.by_design", + to: "blocked", + note: "by-design verdict rests in blocked for a maintainer to decline", + }, + { + from: "investigating", + event: "agent.skipped", + to: "blocked", + note: "repro needs external/prod-only conditions", + }, + { from: "investigating", event: "agent.failed", to: "failed" }, + + // --- verdict disposal edges (maintainer disposes; humans dispose) --- + { from: "reproduced", event: "fix", to: "fixing", action: "investigate.fix" }, + { from: "reproduced", event: "decline", to: "declined" }, + { from: "reproduced", event: "take_over", to: "human_owned" }, + { from: "not_reproduced", event: "decline", to: "declined" }, + { from: "not_reproduced", event: "take_over", to: "human_owned" }, + { from: "needs_info", event: "decline", to: "declined" }, + { from: "needs_info", event: "take_over", to: "human_owned" }, + + // ======================================================================= + // Next-generation: fix loop (maintainer-triggered). + // candidate fix (fixing) -> preview build (preview_building) -> reporter + // confirmation (awaiting_reporter) -> draft PR (in_review) or reap. + // ======================================================================= + { from: "fixing", event: "agent.fix_ready", to: "preview_building" }, + { from: "fixing", event: "agent.failed", to: "failed" }, + { + from: "fixing", + event: "agent.by_design", + to: "blocked", + note: "fix run concluded the behaviour is intended", + }, + { + from: "fixing", + event: "agent.skipped", + to: "blocked", + note: "fix run skipped rather than building a candidate; rest in blocked for a maintainer", + }, + + { from: "preview_building", event: "preview.ready", to: "awaiting_reporter" }, + { + from: "preview_building", + event: "preview.failed", + to: "reproduced", + note: "candidate branch retained; the diagnosis still holds, so fall back to the reproduced verdict", + }, + + { from: "awaiting_reporter", event: "confirm", to: "in_review", action: "openDraftPr" }, + { + from: "awaiting_reporter", + event: "reject", + to: "reproduced", + action: "reapBranch", + note: "denial reaps the unvalidated branch; feedback is recorded for the next fix", + }, + { + from: "awaiting_reporter", + event: "expire", + to: "reproduced", + action: "reapBranch", + note: "14-day silence reaps the branch; the reproduced verdict survives", + }, + { from: "awaiting_reporter", event: "take_over", to: "human_owned" }, + { + from: "awaiting_reporter", + event: "decline", + to: "declined", + action: "reapBranch", + note: "maintainer disposal reaps the branch", + }, + + // --- reset: maintainer recovery from every next-generation state --- + { from: "investigating", event: "reset", to: "triage" }, + { from: "reproduced", event: "reset", to: "triage" }, + { from: "not_reproduced", event: "reset", to: "triage" }, + { from: "needs_info", event: "reset", to: "triage" }, + { from: "fixing", event: "reset", to: "triage" }, + { from: "preview_building", event: "reset", to: "triage" }, + { from: "awaiting_reporter", event: "reset", to: "triage" }, ]; // --------------------------------------------------------------------------- diff --git a/infra/emdash-bot/.flue/lib/orchestrator.ts b/infra/emdash-bot/.flue/lib/orchestrator.ts index db635c0762..8016450598 100644 --- a/infra/emdash-bot/.flue/lib/orchestrator.ts +++ b/infra/emdash-bot/.flue/lib/orchestrator.ts @@ -14,6 +14,7 @@ import { addLabels, closePullRequest, createPullRequest, + deleteBranch, getBranchSha, getIssue, getIssueLabels, @@ -38,8 +39,7 @@ import { DeadlineExceededError, withDeadline } from "./sandbox-deadline.js"; /** * Inert states cannot be advanced by a late-arriving agent result. If a run * lands here, the issue was reset, declined, or hand-taken since it started; - * discard the result rather than re-animate a dead lifecycle. Mirrors the - * cycle 6/7 fix from PR #1606, but operating on DO state instead of labels. + * discard the result rather than re-animate a dead lifecycle. */ const INERT_STATES: ReadonlySet = new Set([ "unmanaged", @@ -169,9 +169,13 @@ const STORAGE = { inbox: "o:inbox", pendingDispatch: "o:pendingDispatch", pendingSideEffects: "o:pendingSideEffects", + awaitingReporterSince: "o:awaitingReporterSince", } as const; const TICK_INTERVAL_MS = 60 * 60 * 1000; +/** Reporter-confirmation window for the fix loop. After this, the alarm fires + * `expire`, which reaps the candidate branch and falls back to `reproduced`. */ +const REPORTER_SILENCE_WINDOW_MS = 14 * 24 * 60 * 60 * 1000; const STALE_RUN_THRESHOLD_MS = 30 * 60 * 1000; const DISPATCH_TIMEOUT_MS = 30_000; const INBOX_RETRY_MS = 60_000; @@ -189,7 +193,7 @@ interface PreparedInvestigation { runId: string; agentId: string; issueNumber: number; - mode: "repro" | "implement" | "revise"; + mode: InvestigationMode; arg: string | null; issueTitle: string; issueBody: string; @@ -246,12 +250,7 @@ export class OrchestratorDO extends DurableObject { /** * Entry point from the webhook handler. Single-threaded per DO instance, - * so concurrent events for the same issue queue here -- the PR-comment / - * issue-comment race from PR #1606 cycle 4 cannot occur. - * - * This skeleton version resolves the decision and persists state. The full - * version also runs side effects (label flip, comment, PR ops) and - * invokes the investigate workflow for transitions with `action`. + * so concurrent events for the same issue queue here without racing. */ event(input: NormalizedEvent): Promise { return this.runExclusive(() => this.processEvent(input)); @@ -357,11 +356,10 @@ export class OrchestratorDO extends DurableObject { } /** - * Map an investigate workflow's result to a follow-up machine event. + * Map an investigate run's result to a follow-up machine event. * Late-result discard: if the run id no longer matches the current * in-flight run, the issue was advanced or reset since the run started; - * drop the result silently. Mirrors PR #1606's cycle 6/7 fix but operates - * on DO state, not labels. + * drop the result silently. */ applyAgentResult(input: { runId: string; @@ -454,6 +452,14 @@ export class OrchestratorDO extends DurableObject { recoveryError = error instanceof Error ? error.message : String(error); console.error("[orchestrator] stale-run recovery failed", { error: recoveryError }); } + let expiredReporterWait = false; + try { + expiredReporterWait = await this.reapExpiredReporterWait(now); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + recoveryError ??= message; + console.error("[orchestrator] reporter-wait expiry failed", { error: message }); + } const labelDrift = await this.reconcileLabels(); return { @@ -463,9 +469,33 @@ export class OrchestratorDO extends DurableObject { droppedStaleRun, recoveryError, labelDrift, + expiredReporterWait, }; } + /** + * Fire the fix loop's `expire` timer when the reporter has been silent past + * the confirmation window. The transition reaps the candidate branch and + * falls back to the `reproduced` verdict. + */ + private async reapExpiredReporterWait(now: number): Promise { + const [state, since] = await Promise.all([ + this.ctx.storage.get(STORAGE.state), + this.ctx.storage.get(STORAGE.awaitingReporterSince), + ]); + if (state !== "awaiting_reporter" || since === undefined) return false; + if (now - since < REPORTER_SILENCE_WINDOW_MS) return false; + const labels = await this.projectLabels(); + await this.processEvent({ + event: "expire", + arg: null, + actor: "system", + labels, + needsClassify: false, + }); + return true; + } + private async processInboxHead(): Promise { const inbox = (await this.ctx.storage.get(STORAGE.inbox)) ?? []; const entry = inbox[0]; @@ -726,9 +756,15 @@ export class OrchestratorDO extends DurableObject { if (decision.action === "openPr") { return this.runOpenPr(creds, repo, anchorNumber); } + if (decision.action === "openDraftPr") { + return this.runOpenPr(creds, repo, anchorNumber, true); + } if (decision.action === "closePr") { return this.runClosePr(creds, repo); } + if (decision.action === "reapBranch") { + return this.runReapBranch(creds, repo, anchorNumber); + } return `unknown action "${decision.action}"`; } @@ -915,15 +951,13 @@ export class OrchestratorDO extends DurableObject { } /** - * Open the bot PR from the pushed fix branch (`bot/fix-`). Phase 1 - * sees no branches yet -- the investigate workflow's push step is - * Phase 2 -- so this returns an error string that surfaces as runError - * in the EventOutcome. The DO still advances state. + * Open (or reuse) the bot PR from the pushed fix branch `bot/fix-`. */ private async runOpenPr( creds: Parameters[0], repo: Parameters[1], anchorNumber: number, + draft = false, ): Promise { const token = await this.getInstallationToken(creds); const headBranch = `bot/fix-${anchorNumber}`; @@ -935,6 +969,7 @@ export class OrchestratorDO extends DurableObject { baseBranch: "main", title: `Fix #${anchorNumber}`, body: `Fixes #${anchorNumber}.\n\nAutomated PR opened by emdashbot.`, + draft, })); await this.ctx.storage.put(STORAGE.prNumber, created.number); return null; @@ -943,6 +978,20 @@ export class OrchestratorDO extends DurableObject { } } + private async runReapBranch( + creds: Parameters[0], + repo: Parameters[1], + anchorNumber: number, + ): Promise { + try { + const token = await this.getInstallationToken(creds); + await deleteBranch(token, repo, `bot/fix-${anchorNumber}`); + return null; + } catch (err) { + return `reapBranch failed: ${errorMessage(err)}`; + } + } + private async runClosePr( creds: Parameters[0], repo: Parameters[1], @@ -1066,6 +1115,9 @@ export class OrchestratorDO extends DurableObject { const puts: Promise[] = [ transaction.put(STORAGE.state, decision.to), transaction.put(STORAGE.eventLog, eventLog), + decision.to === "awaiting_reporter" + ? transaction.put(STORAGE.awaitingReporterSince, Date.now()) + : transaction.delete(STORAGE.awaitingReporterSince), ]; const kindLabel = decision.addLabels.find( (label) => label.startsWith("bot:") && label !== decision.addLabel, @@ -1382,6 +1434,11 @@ export class OrchestratorDO extends DurableObject { ]); } + /** Test-only: backdate the reporter-confirmation window to force expiry. */ + async debugBackdateReporterWait(since: number): Promise { + await this.ctx.storage.put(STORAGE.awaitingReporterSince, since); + } + /** Test-only: inject dispatch recovery state without invoking Flue. */ async debugSetPendingDispatch(input: { runId: string; @@ -1442,6 +1499,7 @@ export interface TickOutcome { droppedStaleRun: boolean; recoveryError: string | null; labelDrift: { added: number; removed: number } | null; + expiredReporterWait: boolean; } class ClassifierProcessingError extends Error { @@ -1455,8 +1513,15 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function parseInvestigateMode(value: string): "repro" | "implement" | "revise" | null { - if (value === "repro" || value === "implement" || value === "revise") return value; +function parseInvestigateMode(value: string): InvestigationMode | null { + if ( + value === "repro" || + value === "implement" || + value === "revise" || + value === "diagnose" || + value === "fix" + ) + return value; return null; } diff --git a/infra/emdash-bot/.flue/lib/router.ts b/infra/emdash-bot/.flue/lib/router.ts index 10a55fcebd..c00e6759b6 100644 --- a/infra/emdash-bot/.flue/lib/router.ts +++ b/infra/emdash-bot/.flue/lib/router.ts @@ -328,7 +328,7 @@ export interface AgentResult { [key: string]: unknown; } -export type InvestigationMode = "repro" | "implement" | "revise"; +export type InvestigationMode = "repro" | "implement" | "revise" | "diagnose" | "fix"; /** * Map the investigate agent's flat result to a machine event. Deterministic @@ -337,6 +337,9 @@ export type InvestigationMode = "repro" | "implement" | "revise"; * - `ok` is false when the run errored or produced no parseable result. * - `pushed` is the trusted push step's report. A model claim of `fixed: true` * is only "fix_ready" when a branch actually exists. + * - `diagnose` (investigation) lands on a verdict and never a fix; `unclear` + * becomes `needs_info`. `fix` (the fix loop) only advances when a candidate + * was built AND pushed. */ export function outcomeFromResult({ ok, @@ -353,6 +356,13 @@ export function outcomeFromResult({ if (result.skipped === true) return "agent.skipped"; if (result.verdict === "intended-behavior") return "agent.by_design"; const effectiveMode = mode ?? "repro"; + if (effectiveMode === "diagnose") { + if (result.verdict === "unclear") return "agent.needs_info"; + return result.reproduced === true ? "agent.reproduced" : "agent.not_reproduced"; + } + if (effectiveMode === "fix") { + return result.fixed === true && pushed === true ? "agent.fix_ready" : "agent.failed"; + } if (effectiveMode === "repro" && result.reproduced !== true) return "agent.not_reproduced"; if (result.fixed === true) return pushed === true ? "agent.fix_ready" : "agent.failed"; return effectiveMode === "repro" ? "agent.reproduced" : "agent.failed"; diff --git a/infra/emdash-bot/BOT_STATE_MACHINE.md b/infra/emdash-bot/BOT_STATE_MACHINE.md new file mode 100644 index 0000000000..5ced16b275 --- /dev/null +++ b/infra/emdash-bot/BOT_STATE_MACHINE.md @@ -0,0 +1,250 @@ +# emdashbot state machine + + + +Entry state: `unmanaged`. Kinds: `bug`, `enhancement`, `task`. + +## States + +| State | Label | Board column | Terminal | Transient | Offered commands | +| --- | --- | --- | --- | --- | --- | +| `unmanaged` | — | (none) | no | no | `investigate`, `repro`, `implement`, `decline` | +| `triage` | `bot:triage` | Triage | no | no | `investigate`, `repro`, `implement`, `decline` | +| `working` | `bot:working` | Working | no | yes | `status` | +| `blocked` | `bot:blocked` | Blocked | no | no | `investigate`, `implement`, `repro`, `retry`, `decline`, `take_over` | +| `awaiting_feedback` | `bot:awaiting-feedback` | Awaiting feedback | no | no | `confirm`, `reject`, `retry`, `take_over` | +| `in_review` | `bot:in-review` | In review | no | no | `revise`, `decline`, `take_over` | +| `human_owned` | `bot:human-owned` | Human owned | no | no | `hand_back` | +| `done` | `bot:done` | Done | yes | no | `reopen` | +| `declined` | `bot:declined` | Declined | yes | no | `reopen` | +| `failed` | `bot:failed` | Failed | no | no | `retry`, `implement`, `repro`, `investigate`, `decline` | +| `investigating` | `bot:investigating` | Investigating | no | yes | `status` | +| `reproduced` | `bot:reproduced` | Reproduced | no | no | `fix`, `investigate`, `decline`, `take_over` | +| `not_reproduced` | `bot:not-reproduced` | Not reproduced | no | no | `investigate`, `decline`, `take_over` | +| `needs_info` | `bot:needs-info` | Needs info | no | no | `investigate`, `decline`, `take_over` | +| `fixing` | `bot:fixing` | Fixing | no | yes | `status` | +| `preview_building` | `bot:preview-building` | Building preview | no | yes | `status` | +| `awaiting_reporter` | `bot:awaiting-reporter` | Awaiting reporter | no | no | `confirm`, `reject`, `decline`, `take_over` | + +## Events + +| Event | Category | Actors | Arg | Description | +| --- | --- | --- | --- | --- | +| `repro` | command | maintainer | — | Reproduce the issue as a bug and attempt a fix. | +| `investigate` | command | maintainer | `directive` | Reproduce and diagnose the issue as a bug, with evidence. Does not attempt a fix. | +| `implement` | command | maintainer | `directive` | Build the described change (feature or directed fix), skipping the bug-repro gate. | +| `fix` | command | maintainer | `directive` | Build a candidate fix on a bot branch and post a preview for the reporter to try. | +| `retry` | command | maintainer | — | Re-run the bug reproduction pipeline. | +| `revise` | command | maintainer | `feedback` | Send review feedback back into the agent to update the open PR branch. | +| `confirm` | command | reporter, maintainer | — | Confirm the staged fix works; open a PR. | +| `reject` | command | reporter, maintainer | `feedback` | The staged fix does not work; retry with feedback. | +| `decline` | command | maintainer | — | Won't be actioned; move to declined. | +| `reopen` | command | maintainer | — | Bring a terminal item back into triage. | +| `take_over` | command | maintainer | — | A maintainer takes the item; the bot disengages but stays on the board. | +| `hand_back` | command | maintainer | — | Return a human-owned item to the bot. | +| `reset` | command | maintainer | — | Force-reset to triage. Maintainer recovery for conflicting state labels. | +| `status` | command | reporter, maintainer | — | Render the item's current state and available commands. | +| `help` | command | reporter, maintainer | — | Show the command grammar. | +| `agent.skipped` | agent result | system | — | Agent skipped (non-bug kind, or repro needs external/prod-only conditions). | +| `agent.not_reproduced` | agent result | system | — | Agent could not reproduce the issue. | +| `agent.by_design` | agent result | system | — | Agent verified the behaviour as intended. | +| `agent.reproduced` | agent result | system | — | Reproduced, but the fix needs a human decision. | +| `agent.fix_ready` | agent result | system | — | Reproduced and fixed; a verified change is staged on bot/fix-. | +| `agent.needs_info` | agent result | system | — | Investigation is blocked on information only the reporter can supply. | +| `agent.failed` | agent result | system | — | Agent run errored or produced no usable result. | +| `pr.opened` | pr lifecycle | system | — | A bot PR was opened for this item. | +| `pr.merged` | pr lifecycle | system | — | The bot PR was merged. | +| `pr.closed` | pr lifecycle | system | — | The bot PR was closed without merging. | +| `pr.changes_requested` | pr lifecycle | system | — | A reviewer requested changes (review sub-state). | +| `pr.approved` | pr lifecycle | system | — | A reviewer approved the PR (review sub-state). | +| `preview.ready` | preview | system | — | The preview deploy for the candidate fix is live; link ready to post. | +| `preview.failed` | preview | system | — | The preview deploy failed to build. | +| `expire` | timer | system | — | The reporter-confirmation window elapsed without a reply. | + +## Transitions + +| From | Event | To | Action | +| --- | --- | --- | --- | +| `unmanaged` | `repro` | `working` | `investigate.repro` | +| `unmanaged` | `implement` | `working` | `investigate.implement` | +| `unmanaged` | `decline` | `declined` | — | +| `triage` | `repro` | `working` | `investigate.repro` | +| `triage` | `implement` | `working` | `investigate.implement` | +| `triage` | `decline` | `declined` | — | +| `working` | `agent.skipped` | `blocked` | — | +| `working` | `agent.not_reproduced` | `blocked` | — | +| `working` | `agent.by_design` | `blocked` | — | +| `working` | `agent.reproduced` | `blocked` | — | +| `working` | `agent.fix_ready` | `awaiting_feedback` | — | +| `working` | `agent.failed` | `failed` | — | +| `blocked` | `implement` | `working` | `investigate.implement` | +| `blocked` | `repro` | `working` | `investigate.repro` | +| `blocked` | `retry` | `working` | `investigate.repro` | +| `blocked` | `decline` | `declined` | — | +| `blocked` | `take_over` | `human_owned` | — | +| `awaiting_feedback` | `confirm` | `in_review` | `openPr` | +| `awaiting_feedback` | `reject` | `working` | `investigate.revise` | +| `awaiting_feedback` | `retry` | `working` | `investigate.repro` | +| `awaiting_feedback` | `take_over` | `human_owned` | — | +| `in_review` | `pr.opened` | `in_review` | — | +| `in_review` | `pr.approved` | `in_review` | — | +| `in_review` | `pr.changes_requested` | `in_review` | — | +| `in_review` | `revise` | `working` | `investigate.revise` | +| `in_review` | `pr.merged` | `done` | — | +| `working` | `pr.merged` | `done` | — | +| `awaiting_feedback` | `pr.merged` | `done` | — | +| `in_review` | `pr.closed` | `blocked` | — | +| `working` | `pr.closed` | `blocked` | — | +| `awaiting_feedback` | `pr.closed` | `blocked` | — | +| `unmanaged` | `reset` | `triage` | — | +| `triage` | `reset` | `triage` | — | +| `working` | `reset` | `triage` | — | +| `blocked` | `reset` | `triage` | — | +| `awaiting_feedback` | `reset` | `triage` | — | +| `in_review` | `reset` | `triage` | — | +| `human_owned` | `reset` | `triage` | — | +| `done` | `reset` | `triage` | — | +| `declined` | `reset` | `triage` | — | +| `failed` | `reset` | `triage` | — | +| `in_review` | `decline` | `declined` | `closePr` | +| `in_review` | `take_over` | `human_owned` | — | +| `human_owned` | `hand_back` | `triage` | — | +| `done` | `reopen` | `triage` | — | +| `declined` | `reopen` | `triage` | — | +| `failed` | `retry` | `working` | `investigate.repro` | +| `failed` | `implement` | `working` | `investigate.implement` | +| `failed` | `repro` | `working` | `investigate.repro` | +| `failed` | `decline` | `declined` | — | +| `unmanaged` | `investigate` | `investigating` | `investigate.diagnose` | +| `triage` | `investigate` | `investigating` | `investigate.diagnose` | +| `blocked` | `investigate` | `investigating` | `investigate.diagnose` | +| `failed` | `investigate` | `investigating` | `investigate.diagnose` | +| `not_reproduced` | `investigate` | `investigating` | `investigate.diagnose` | +| `needs_info` | `investigate` | `investigating` | `investigate.diagnose` | +| `reproduced` | `investigate` | `investigating` | `investigate.diagnose` | +| `investigating` | `agent.reproduced` | `reproduced` | — | +| `investigating` | `agent.not_reproduced` | `not_reproduced` | — | +| `investigating` | `agent.needs_info` | `needs_info` | — | +| `investigating` | `agent.by_design` | `blocked` | — | +| `investigating` | `agent.skipped` | `blocked` | — | +| `investigating` | `agent.failed` | `failed` | — | +| `reproduced` | `fix` | `fixing` | `investigate.fix` | +| `reproduced` | `decline` | `declined` | — | +| `reproduced` | `take_over` | `human_owned` | — | +| `not_reproduced` | `decline` | `declined` | — | +| `not_reproduced` | `take_over` | `human_owned` | — | +| `needs_info` | `decline` | `declined` | — | +| `needs_info` | `take_over` | `human_owned` | — | +| `fixing` | `agent.fix_ready` | `preview_building` | — | +| `fixing` | `agent.failed` | `failed` | — | +| `fixing` | `agent.by_design` | `blocked` | — | +| `fixing` | `agent.skipped` | `blocked` | — | +| `preview_building` | `preview.ready` | `awaiting_reporter` | — | +| `preview_building` | `preview.failed` | `reproduced` | — | +| `awaiting_reporter` | `confirm` | `in_review` | `openDraftPr` | +| `awaiting_reporter` | `reject` | `reproduced` | `reapBranch` | +| `awaiting_reporter` | `expire` | `reproduced` | `reapBranch` | +| `awaiting_reporter` | `take_over` | `human_owned` | — | +| `awaiting_reporter` | `decline` | `declined` | `reapBranch` | +| `investigating` | `reset` | `triage` | — | +| `reproduced` | `reset` | `triage` | — | +| `not_reproduced` | `reset` | `triage` | — | +| `needs_info` | `reset` | `triage` | — | +| `fixing` | `reset` | `triage` | — | +| `preview_building` | `reset` | `triage` | — | +| `awaiting_reporter` | `reset` | `triage` | — | + +## Diagram + +```mermaid +stateDiagram-v2 + [*] --> unmanaged + unmanaged --> working: repro / investigate.repro + unmanaged --> working: implement / investigate.implement + unmanaged --> declined: decline + triage --> working: repro / investigate.repro + triage --> working: implement / investigate.implement + triage --> declined: decline + working --> blocked: agent.skipped + working --> blocked: agent.not_reproduced + working --> blocked: agent.by_design + working --> blocked: agent.reproduced + working --> awaiting_feedback: agent.fix_ready + working --> failed: agent.failed + blocked --> working: implement / investigate.implement + blocked --> working: repro / investigate.repro + blocked --> working: retry / investigate.repro + blocked --> declined: decline + blocked --> human_owned: take_over + awaiting_feedback --> in_review: confirm / openPr + awaiting_feedback --> working: reject / investigate.revise + awaiting_feedback --> working: retry / investigate.repro + awaiting_feedback --> human_owned: take_over + in_review --> in_review: pr.opened + in_review --> in_review: pr.approved + in_review --> in_review: pr.changes_requested + in_review --> working: revise / investigate.revise + in_review --> done: pr.merged + working --> done: pr.merged + awaiting_feedback --> done: pr.merged + in_review --> blocked: pr.closed + working --> blocked: pr.closed + awaiting_feedback --> blocked: pr.closed + unmanaged --> triage: reset + triage --> triage: reset + working --> triage: reset + blocked --> triage: reset + awaiting_feedback --> triage: reset + in_review --> triage: reset + human_owned --> triage: reset + done --> triage: reset + declined --> triage: reset + failed --> triage: reset + in_review --> declined: decline / closePr + in_review --> human_owned: take_over + human_owned --> triage: hand_back + done --> triage: reopen + declined --> triage: reopen + failed --> working: retry / investigate.repro + failed --> working: implement / investigate.implement + failed --> working: repro / investigate.repro + failed --> declined: decline + unmanaged --> investigating: investigate / investigate.diagnose + triage --> investigating: investigate / investigate.diagnose + blocked --> investigating: investigate / investigate.diagnose + failed --> investigating: investigate / investigate.diagnose + not_reproduced --> investigating: investigate / investigate.diagnose + needs_info --> investigating: investigate / investigate.diagnose + reproduced --> investigating: investigate / investigate.diagnose + investigating --> reproduced: agent.reproduced + investigating --> not_reproduced: agent.not_reproduced + investigating --> needs_info: agent.needs_info + investigating --> blocked: agent.by_design + investigating --> blocked: agent.skipped + investigating --> failed: agent.failed + reproduced --> fixing: fix / investigate.fix + reproduced --> declined: decline + reproduced --> human_owned: take_over + not_reproduced --> declined: decline + not_reproduced --> human_owned: take_over + needs_info --> declined: decline + needs_info --> human_owned: take_over + fixing --> preview_building: agent.fix_ready + fixing --> failed: agent.failed + fixing --> blocked: agent.by_design + fixing --> blocked: agent.skipped + preview_building --> awaiting_reporter: preview.ready + preview_building --> reproduced: preview.failed + awaiting_reporter --> in_review: confirm / openDraftPr + awaiting_reporter --> reproduced: reject / reapBranch + awaiting_reporter --> reproduced: expire / reapBranch + awaiting_reporter --> human_owned: take_over + awaiting_reporter --> declined: decline / reapBranch + investigating --> triage: reset + reproduced --> triage: reset + not_reproduced --> triage: reset + needs_info --> triage: reset + fixing --> triage: reset + preview_building --> triage: reset + awaiting_reporter --> triage: reset +``` diff --git a/infra/emdash-bot/package.json b/infra/emdash-bot/package.json index 14d8ed5fd6..d3c2446426 100644 --- a/infra/emdash-bot/package.json +++ b/infra/emdash-bot/package.json @@ -9,6 +9,7 @@ "build": "vite build", "deploy": "vite build && wrangler deploy", "cf-typegen": "wrangler types", + "bot:generate": "node --experimental-strip-types scripts/generate-machine.ts", "pretypecheck": "wrangler types", "typecheck": "tsc --noEmit", "test": "pnpm test:unit && pnpm test:workers", diff --git a/infra/emdash-bot/scripts/generate-machine.ts b/infra/emdash-bot/scripts/generate-machine.ts new file mode 100644 index 0000000000..a709d677e5 --- /dev/null +++ b/infra/emdash-bot/scripts/generate-machine.ts @@ -0,0 +1,23 @@ +// Writes the generated machine artifacts to disk. Run with `pnpm bot:generate` +// after editing `.flue/lib/machine.ts`. The rendering lives in +// `machine-artifacts.ts`; this file only handles filesystem I/O so the drift +// test can import the renderers without pulling in node:fs. + +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { validateMachine } from "../.flue/lib/machine.ts"; +import { renderMachineDoc, renderMachineJson } from "./machine-artifacts.ts"; + +const problems = validateMachine(); +if (problems.length > 0) { + for (const problem of problems) console.error(`machine.ts: ${problem.message}`); + process.exit(1); +} + +const botRoot = fileURLToPath(new URL("..", import.meta.url).href); + +writeFileSync(`${botRoot}.flue/lib/machine.json`, renderMachineJson()); +writeFileSync(`${botRoot}BOT_STATE_MACHINE.md`, renderMachineDoc()); + +console.log("Wrote .flue/lib/machine.json and BOT_STATE_MACHINE.md"); diff --git a/infra/emdash-bot/scripts/machine-artifacts.ts b/infra/emdash-bot/scripts/machine-artifacts.ts new file mode 100644 index 0000000000..3d473e0075 --- /dev/null +++ b/infra/emdash-bot/scripts/machine-artifacts.ts @@ -0,0 +1,119 @@ +// Deterministic renderers for the generated machine artifacts. The state +// machine in `.flue/lib/machine.ts` is the single source of truth; these +// functions project it into the committed `machine.json` runtime artifact and +// the `BOT_STATE_MACHINE.md` docs. `scripts/generate-machine.ts` writes them to +// disk and `tests/unit/machine-artifacts.test.ts` fails if the committed files +// drift from what these produce. +// +// Imports use the explicit `.ts` extension so `node scripts/generate-machine.ts` +// can run the chain without a bundler; `allowImportingTsExtensions` keeps tsc +// happy when the drift test pulls this module into the program. + +import { + ENTRY_STATE, + EVENTS, + KINDS, + machineSnapshot, + STATES, + TRANSITIONS, +} from "../.flue/lib/machine.ts"; + +export function renderMachineJson(): string { + return `${JSON.stringify(machineSnapshot(), null, "\t")}\n`; +} + +function code(value: string): string { + return `\`${value}\``; +} + +function commandList(commands: readonly string[]): string { + return commands.length === 0 ? "—" : commands.map(code).join(", "); +} + +function eventCategory(id: string): string { + if (id.startsWith("agent.")) return "agent result"; + if (id.startsWith("pr.")) return "pr lifecycle"; + if (id.startsWith("preview.")) return "preview"; + if (id === "expire") return "timer"; + return "command"; +} + +function statesTable(): string { + const rows = Object.entries(STATES).map( + ([id, meta]) => + `| ${code(id)} | ${meta.label ? code(meta.label) : "—"} | ${meta.boardColumn} | ${ + meta.terminal ? "yes" : "no" + } | ${meta.transient ? "yes" : "no"} | ${commandList(meta.offeredCommands)} |`, + ); + return [ + "## States", + "", + "| State | Label | Board column | Terminal | Transient | Offered commands |", + "| --- | --- | --- | --- | --- | --- |", + ...rows, + ].join("\n"); +} + +function eventsTable(): string { + const rows = Object.entries(EVENTS).map( + ([id, meta]) => + `| ${code(id)} | ${eventCategory(id)} | ${meta.actors.join(", ")} | ${ + meta.arg ? code(meta.arg) : "—" + } | ${meta.description} |`, + ); + return [ + "## Events", + "", + "| Event | Category | Actors | Arg | Description |", + "| --- | --- | --- | --- | --- |", + ...rows, + ].join("\n"); +} + +function transitionsTable(): string { + const rows = TRANSITIONS.map( + (t) => + `| ${code(t.from)} | ${code(t.event)} | ${code(t.to)} | ${t.action ? code(t.action) : "—"} |`, + ); + return [ + "## Transitions", + "", + "| From | Event | To | Action |", + "| --- | --- | --- | --- |", + ...rows, + ].join("\n"); +} + +function diagram(): string { + const edges = TRANSITIONS.map( + (t) => ` ${t.from} --> ${t.to}: ${t.event}${t.action ? ` / ${t.action}` : ""}`, + ); + return [ + "## Diagram", + "", + "```mermaid", + "stateDiagram-v2", + ` [*] --> ${ENTRY_STATE}`, + ...edges, + "```", + ].join("\n"); +} + +export function renderMachineDoc(): string { + const kinds = KINDS.map(code).join(", "); + return `${[ + "# emdashbot state machine", + "", + "", + "", + `Entry state: ${code(ENTRY_STATE)}. Kinds: ${kinds}.`, + "", + statesTable(), + "", + eventsTable(), + "", + transitionsTable(), + "", + diagram(), + ].join("\n")}\n`; +} diff --git a/infra/emdash-bot/tests/integration/orchestrator.test.ts b/infra/emdash-bot/tests/integration/orchestrator.test.ts index 506febe08b..690394fa93 100644 --- a/infra/emdash-bot/tests/integration/orchestrator.test.ts +++ b/infra/emdash-bot/tests/integration/orchestrator.test.ts @@ -327,6 +327,164 @@ describe("OrchestratorDO (workers-pool)", () => { expect(await stub.getPendingSideEffectCount()).toBe(0); }); + test("investigate is rejected for a non-maintainer actor", async () => { + const stub = testEnv.Orchestrator.getByName(uniqueIssueName()); + const outcome = await stub.event( + makeEvent({ + event: "investigate", + arg: "look at the loader", + actor: "reporter", + anchorNumber: 42, + }), + ); + expect(outcome.kind).toBe("noop"); + expect((await stub.getPersistedState()).state).toBe(null); + }); + + test("a diagnose run blocked on reporter info lands on needs_info", async () => { + const stub = testEnv.Orchestrator.getByName(uniqueIssueName()); + await stub.event(makeEvent({ event: "investigate", arg: "diagnose it", anchorNumber: 42 })); + expect((await stub.getPersistedState()).state).toBe("investigating"); + + await stub.debugSetStaleRun("diag-run", Date.now(), "investigate-42-diag-run", "diagnose"); + const outcome = await stub.applyAgentResult({ + runId: "diag-run", + result: { verdict: "unclear", summary: "I need the exact steps that fail for you." }, + pushed: false, + ok: true, + }); + expect(outcome.kind).toBe("transition"); + expect((await stub.getPersistedState()).state).toBe("needs_info"); + }); + + test("the fix loop runs from a diagnosis to a confirmed draft PR", async () => { + const stub = testEnv.Orchestrator.getByName(uniqueIssueName()); + await stub.event(makeEvent({ event: "investigate", arg: "diagnose it", anchorNumber: 42 })); + + await stub.debugSetStaleRun("diag-run", Date.now(), "investigate-42-diag-run", "diagnose"); + await stub.applyAgentResult({ + runId: "diag-run", + result: { reproduced: true, summary: "Reproduced: the loader drops the locale." }, + pushed: false, + ok: true, + }); + expect((await stub.getPersistedState()).state).toBe("reproduced"); + + await stub.event(makeEvent({ event: "fix", arg: "fix the loader", anchorNumber: 42 })); + expect((await stub.getPersistedState()).state).toBe("fixing"); + + await stub.debugSetStaleRun("fix-run", Date.now(), "investigate-42-fix-run", "fix"); + await stub.applyAgentResult({ + runId: "fix-run", + result: { fixed: true, summary: "Fixed the loader; added a test." }, + pushed: true, + ok: true, + }); + expect((await stub.getPersistedState()).state).toBe("preview_building"); + + await stub.event( + makeEvent({ event: "preview.ready", arg: null, actor: "system", anchorNumber: 42 }), + ); + expect((await stub.getPersistedState()).state).toBe("awaiting_reporter"); + + const confirm = await stub.event( + makeEvent({ event: "confirm", arg: null, actor: "reporter", anchorNumber: 42 }), + ); + expect(confirm.kind).toBe("transition"); + if (confirm.kind === "transition") expect(confirm.decision.action).toBe("openDraftPr"); + expect((await stub.getPersistedState()).state).toBe("in_review"); + }); + + test("a reporter rejection reaps the branch back to the reproduced verdict", async () => { + const stub = testEnv.Orchestrator.getByName(uniqueIssueName()); + await stub.event(makeEvent({ event: "investigate", arg: "diagnose it", anchorNumber: 42 })); + await stub.debugSetStaleRun("diag-run", Date.now(), "investigate-42-diag-run", "diagnose"); + await stub.applyAgentResult({ + runId: "diag-run", + result: { reproduced: true, summary: "Reproduced it." }, + pushed: false, + ok: true, + }); + await stub.event(makeEvent({ event: "fix", arg: "fix it", anchorNumber: 42 })); + await stub.debugSetStaleRun("fix-run", Date.now(), "investigate-42-fix-run", "fix"); + await stub.applyAgentResult({ + runId: "fix-run", + result: { fixed: true, summary: "Built a candidate." }, + pushed: true, + ok: true, + }); + await stub.event( + makeEvent({ event: "preview.ready", arg: null, actor: "system", anchorNumber: 42 }), + ); + expect((await stub.getPersistedState()).state).toBe("awaiting_reporter"); + + const reject = await stub.event( + makeEvent({ + event: "reject", + arg: "still broken on my end", + actor: "reporter", + anchorNumber: 42, + }), + ); + expect(reject.kind).toBe("transition"); + if (reject.kind === "transition") expect(reject.decision.action).toBe("reapBranch"); + expect((await stub.getPersistedState()).state).toBe("reproduced"); + }); + + test("a fix run that reports skipped rests in blocked, not wedged in fixing", async () => { + const stub = testEnv.Orchestrator.getByName(uniqueIssueName()); + await stub.event(makeEvent({ event: "investigate", arg: "diagnose it", anchorNumber: 42 })); + await stub.debugSetStaleRun("diag-run", Date.now(), "investigate-42-diag-run", "diagnose"); + await stub.applyAgentResult({ + runId: "diag-run", + result: { reproduced: true, summary: "Reproduced it." }, + pushed: false, + ok: true, + }); + await stub.event(makeEvent({ event: "fix", arg: "fix it", anchorNumber: 42 })); + expect((await stub.getPersistedState()).state).toBe("fixing"); + + await stub.debugSetStaleRun("fix-run", Date.now(), "investigate-42-fix-run", "fix"); + const outcome = await stub.applyAgentResult({ + runId: "fix-run", + result: { skipped: true, summary: "This needs a product decision, not a code fix." }, + pushed: false, + ok: true, + }); + expect(outcome.kind).toBe("transition"); + expect((await stub.getPersistedState()).state).toBe("blocked"); + }); + + test("reporter silence past the window expires the wait and reaps the branch", async () => { + const stub = testEnv.Orchestrator.getByName(uniqueIssueName()); + await stub.event(makeEvent({ event: "investigate", arg: "diagnose it", anchorNumber: 42 })); + await stub.debugSetStaleRun("diag-run", Date.now(), "investigate-42-diag-run", "diagnose"); + await stub.applyAgentResult({ + runId: "diag-run", + result: { reproduced: true, summary: "Reproduced it." }, + pushed: false, + ok: true, + }); + await stub.event(makeEvent({ event: "fix", arg: "fix it", anchorNumber: 42 })); + await stub.debugSetStaleRun("fix-run", Date.now(), "investigate-42-fix-run", "fix"); + await stub.applyAgentResult({ + runId: "fix-run", + result: { fixed: true, summary: "Built a candidate." }, + pushed: true, + ok: true, + }); + await stub.event( + makeEvent({ event: "preview.ready", arg: null, actor: "system", anchorNumber: 42 }), + ); + expect((await stub.getPersistedState()).state).toBe("awaiting_reporter"); + + // Backdate the confirmation window past 14 days, then run the alarm. + await stub.debugBackdateReporterWait(Date.now() - 15 * 24 * 60 * 60 * 1000); + const tick = await stub.tick(); + expect(tick.expiredReporterWait).toBe(true); + expect((await stub.getPersistedState()).state).toBe("reproduced"); + }); + test("concurrent events on the same DO yield a deterministic end state", async () => { // workerd single-threads DO message processing; this test pins that // two events fired in parallel observe each other's effects rather diff --git a/infra/emdash-bot/tests/unit/machine-artifacts.test.ts b/infra/emdash-bot/tests/unit/machine-artifacts.test.ts new file mode 100644 index 0000000000..bd7826fc2a --- /dev/null +++ b/infra/emdash-bot/tests/unit/machine-artifacts.test.ts @@ -0,0 +1,25 @@ +// Drift check for the generated machine artifacts. `.flue/lib/machine.ts` is the +// single source of truth; `machine.json` and `BOT_STATE_MACHINE.md` are +// regenerated from it by `pnpm bot:generate`. These tests fail if the committed +// artifacts fall out of sync, the same contract as the query-count snapshots. + +import { describe, expect, test } from "vitest"; + +import committedMachineJson from "../../.flue/lib/machine.json?raw"; +import { validateMachine } from "../../.flue/lib/machine.ts"; +import committedMachineDoc from "../../BOT_STATE_MACHINE.md?raw"; +import { renderMachineDoc, renderMachineJson } from "../../scripts/machine-artifacts.ts"; + +describe("machine artifacts", () => { + test("machine.ts passes structural validation", () => { + expect(validateMachine()).toEqual([]); + }); + + test("machine.json matches machine.ts (run `pnpm bot:generate`)", () => { + expect(committedMachineJson).toBe(renderMachineJson()); + }); + + test("BOT_STATE_MACHINE.md matches machine.ts (run `pnpm bot:generate`)", () => { + expect(committedMachineDoc).toBe(renderMachineDoc()); + }); +}); diff --git a/infra/emdash-bot/tests/unit/router.test.ts b/infra/emdash-bot/tests/unit/router.test.ts index 36036ce7f7..41f5e92a96 100644 --- a/infra/emdash-bot/tests/unit/router.test.ts +++ b/infra/emdash-bot/tests/unit/router.test.ts @@ -436,3 +436,160 @@ describe("router", () => { expect(footer).toMatch(FOOTER_DECLINE_RE); }); }); + +describe("router: investigation + fix loop", () => { + test("investigate and fix are maintainer-only", () => { + assertNoop(resolve({ labels: [], event: "investigate", arg: "look at X", actor: "reporter" })); + assertNoop( + resolve({ + labels: ["bot:bug", "bot:reproduced"], + event: "fix", + actor: "reporter", + }), + ); + }); + + test("investigate from a cold issue enters investigating and applies the bug kind", () => { + const d = resolve({ + labels: [], + event: "investigate", + arg: "repro steps", + actor: "maintainer", + }); + assertTransition(d); + expect(d.to).toBe("investigating"); + expect(d.action).toBe("investigate.diagnose"); + expect(d.addLabels).toContain("bot:investigating"); + expect(d.addLabels).toContain("bot:bug"); + }); + + test("investigation verdicts branch on the agent event", () => { + const cases = [ + ["agent.reproduced", "reproduced"], + ["agent.not_reproduced", "not_reproduced"], + ["agent.needs_info", "needs_info"], + ["agent.by_design", "blocked"], + ] as const; + for (const [event, to] of cases) { + const d = resolve({ + labels: ["bot:bug", "bot:investigating"], + event, + actor: "system", + }); + assertTransition(d); + expect(d.to).toBe(to); + expect(d.action).toBe(null); + } + }); + + test("fix starts the fix loop from a reproduced verdict", () => { + const d = resolve({ + labels: ["bot:bug", "bot:reproduced"], + event: "fix", + arg: "try the loader path", + actor: "maintainer", + }); + assertTransition(d); + expect(d.to).toBe("fixing"); + expect(d.action).toBe("investigate.fix"); + }); + + test("the fix loop advances through preview to the reporter wait", () => { + const fixReady = resolve({ + labels: ["bot:bug", "bot:fixing"], + event: "agent.fix_ready", + actor: "system", + }); + assertTransition(fixReady); + expect(fixReady.to).toBe("preview_building"); + + const previewReady = resolve({ + labels: ["bot:bug", "bot:preview-building"], + event: "preview.ready", + actor: "system", + }); + assertTransition(previewReady); + expect(previewReady.to).toBe("awaiting_reporter"); + }); + + test("a fix run that skips rests in blocked rather than wedging in fixing", () => { + const d = resolve({ + labels: ["bot:bug", "bot:fixing"], + event: "agent.skipped", + actor: "system", + }); + assertTransition(d); + expect(d.to).toBe("blocked"); + }); + + test("a preview failure falls back to the reproduced verdict", () => { + const d = resolve({ + labels: ["bot:bug", "bot:preview-building"], + event: "preview.failed", + actor: "system", + }); + assertTransition(d); + expect(d.to).toBe("reproduced"); + }); + + test("confirm opens a draft PR; reject and expire reap the branch", () => { + const confirm = resolve({ + labels: ["bot:bug", "bot:awaiting-reporter"], + event: "confirm", + actor: "reporter", + }); + assertTransition(confirm); + expect(confirm.to).toBe("in_review"); + expect(confirm.action).toBe("openDraftPr"); + + const reject = resolve({ + labels: ["bot:bug", "bot:awaiting-reporter"], + event: "reject", + actor: "reporter", + }); + assertTransition(reject); + expect(reject.to).toBe("reproduced"); + expect(reject.action).toBe("reapBranch"); + + const expire = resolve({ + labels: ["bot:bug", "bot:awaiting-reporter"], + event: "expire", + actor: "system", + }); + assertTransition(expire); + expect(expire.to).toBe("reproduced"); + expect(expire.action).toBe("reapBranch"); + }); + + test("fix is offered to the classifier from a reproduced verdict", () => { + const events = new Set(classifierCommands("reproduced").map((c) => c.event)); + expect(events.has("fix")).toBe(true); + expect(events.has("investigate")).toBe(true); + expect(events.has("decline")).toBe(false); // destructive + expect(events.has("take_over")).toBe(false); // destructive + }); + + test("outcomeFromResult diagnose mode lands on a verdict without a fix", () => { + expect(outcomeFromResult({ ok: true, result: { reproduced: true }, mode: "diagnose" })).toBe( + "agent.reproduced", + ); + expect(outcomeFromResult({ ok: true, result: { reproduced: false }, mode: "diagnose" })).toBe( + "agent.not_reproduced", + ); + expect(outcomeFromResult({ ok: true, result: { verdict: "unclear" }, mode: "diagnose" })).toBe( + "agent.needs_info", + ); + }); + + test("outcomeFromResult fix mode only advances when the branch was pushed", () => { + expect( + outcomeFromResult({ ok: true, result: { fixed: true }, pushed: true, mode: "fix" }), + ).toBe("agent.fix_ready"); + expect( + outcomeFromResult({ ok: true, result: { fixed: true }, pushed: false, mode: "fix" }), + ).toBe("agent.failed"); + expect(outcomeFromResult({ ok: true, result: { fixed: false }, mode: "fix" })).toBe( + "agent.failed", + ); + }); +}); diff --git a/infra/emdash-bot/tsconfig.json b/infra/emdash-bot/tsconfig.json index 24c18206e3..eaeaa3dffa 100644 --- a/infra/emdash-bot/tsconfig.json +++ b/infra/emdash-bot/tsconfig.json @@ -10,8 +10,9 @@ "noImplicitOverride": true, "verbatimModuleSyntax": true, "resolveJsonModule": true, + "allowImportingTsExtensions": true, "skipLibCheck": true, "noEmit": true }, - "include": [".flue/**/*.ts", "tests/**/*.ts", "worker-configuration.d.ts"] + "include": [".flue/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts", "worker-configuration.d.ts"] }