From 97a73a3047b1a90be270e24d62d1e1175588a422 Mon Sep 17 00:00:00 2001 From: Mengxiong Han Date: Fri, 13 Nov 2026 11:43:11 +0800 Subject: [PATCH 1/3] Review daily QA backlog and follow unresolved work --- docs/MAUSBOT-VALIDATION.md | 15 +++++ scripts/qa/backlog.mjs | 103 +++++++++++++++++++++++++++++++ scripts/qa/backlog.test.ts | 123 +++++++++++++++++++++++++++++++++++++ scripts/qa/queue.mjs | 66 +++++++++++++++++--- 4 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 scripts/qa/backlog.mjs create mode 100644 scripts/qa/backlog.test.ts diff --git a/docs/MAUSBOT-VALIDATION.md b/docs/MAUSBOT-VALIDATION.md index 37e81b8b1..5a9d3efb7 100644 --- a/docs/MAUSBOT-VALIDATION.md +++ b/docs/MAUSBOT-VALIDATION.md @@ -268,6 +268,21 @@ retried. Preflight outages before any prompt attempt retry on the next tick. The schedule requires this Mac and Herdr to be available; CI's coverage fallback does not depend on them. +At the first available hourly tick of each Asia/Shanghai day, the same queue +reads the complete open issue inventory and hands Lead a bounded review. It +separates QA, process and other requests, prioritizes verified closure and stale +defects, and includes paused or product requests only for an explicit disposition. +Unresolved QA and process work can return on a six-hour cadence without a new +issue or changed action key. Each handoff states its current owner, next action, +recorded evidence or blocker, and existing repair PR when present. The durable +queue state records inventory, delivery, assignment, fixing, review, verification +and closure evidence separately. A prompt receipt proves only delivery; Lead +records actual work and exact accepted completion on the original issue or PR. +For a closed issue, `QA-Disposition-Evidence: ` or +`QA-Closure-Evidence: ` in its body binds the queue's +closed record; missing closure evidence remains pending. The queue never resumes paused work, +reassigns an active repair or closes an issue automatically. + Lead targets triage within 30 minutes and sets `qa:triaged`, removes `qa:untriaged`, chooses `qa:p0`, `qa:p1` or `qa:p2`, selects a `qa:kind:*` and `qa:scope:*`, and records: diff --git a/scripts/qa/backlog.mjs b/scripts/qa/backlog.mjs new file mode 100644 index 000000000..45a004f05 --- /dev/null +++ b/scripts/qa/backlog.mjs @@ -0,0 +1,103 @@ +import { issueLabels, triageOf } from "./contracts.mjs"; + +const DAY_MS = 24 * 60 * 60_000; +const CADENCE_MS = 6 * 60 * 60_000; + +export function beijingDay(now) { + return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }) + .format(new Date(now)); +} + +export function backlogSlot(now) { + return Math.floor((now + 8 * 60 * 60_000) / CADENCE_MS); +} + +function safe(value, limit = 120) { + return [...String(value ?? "")].map((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127 ? " " : char) + .join("").replace(/\s+/g, " ").trim().slice(0, limit); +} + +function field(body, name) { + return body?.match(new RegExp(`^${name}:\\s*(.+)$`, "m"))?.[1]; +} + +function linkedRepair(issue, prs) { + return prs.find((pr) => new RegExp(`^QA-Repair:\\s*#${issue.number}\\s*$`, "m").test(pr.body ?? "")); +} + +function observe(issue, prs, previous, now) { + const labels = issueLabels(issue); + const qa = labels.includes("qa") && !labels.includes("qa:process"); + const process = labels.includes("qa:process"); + const paused = labels.some((label) => /(?:^|:)paused?$|^status:on-hold$/.test(label)); + const triage = qa ? triageOf(issue) : {}; + const owner = triage.owner ?? field(issue.body, "QA-Owner") ?? "unassigned"; + const next = field(issue.body, "QA-Next"); + const blocker = field(issue.body, "QA-Blocker"); + const evidence = field(issue.body, "QA-Evidence"); + const repair = qa ? linkedRepair(issue, prs) : undefined; + const verified = labels.includes("qa:verified"); + const review = repair && !repair.draft && repair.body?.match(/^QA-Handoff:\s*([a-f0-9]{40})\s*$/m)?.[1] === repair.head?.sha; + const stage = !qa && !process || paused ? "disposition_needed" + : verified ? "verification" : review ? "review" : repair ? "fixing" : triage.complete ? "assigned" : "unassigned"; + const signature = JSON.stringify([stage, owner, next, blocker, evidence, repair?.number, repair?.head?.sha, verified]); + const firstSeenAt = previous?.firstSeenAt ?? issue.created_at ?? new Date(now).toISOString(); + const lastProgressAt = previous?.signature === signature ? previous.lastProgressAt + : previous ? new Date(now).toISOString() : firstSeenAt; + const age = now - Date.parse(issue.created_at ?? firstSeenAt); + const progressAge = now - Date.parse(lastProgressAt); + const stale = Number.isFinite(age) && age >= 48 * 60 * 60_000 + && (previous ? progressAge >= DAY_MS : true); + const priority = verified ? 0 : qa && !triage.complete ? 1 : qa && stale ? 2 : process ? 3 : qa ? 4 : 5; + const action = stage === "disposition_needed" ? "request explicit disposition; do not resume paused or product work" + : stage === "verification" ? "review exact integrated evidence for closure" + : stage === "review" ? "finish existing review and verification" + : stage === "fixing" ? "follow up with existing repair owner" + : stage === "unassigned" ? "triage and assign an existing owner" : "check evidence and next action with existing owner"; + return { + number: issue.number, url: issue.html_url, qa, process, paused, verified, stale, priority, stage, + firstSeenAt, lastProgressAt, signature, lastNotifiedAt: previous?.lastNotifiedAt, + owner: safe(owner), next: safe(next), blocker: safe(blocker), evidence: safe(evidence), + repair: repair && { number: repair.number, head: repair.head?.sha, draft: repair.draft }, + action, + }; +} + +function order(a, b) { + return a.priority - b.priority || (a.lastNotifiedAt ? Date.parse(a.lastNotifiedAt) : 0) - (b.lastNotifiedAt ? Date.parse(b.lastNotifiedAt) : 0) + || Date.parse(a.firstSeenAt) - Date.parse(b.firstSeenAt) || a.number - b.number; +} + +function line(item) { + const proof = item.evidence || item.blocker || "no evidence/blocker recorded"; + return `#${item.number} ${safe(item.url)}: ${item.action}; stage=${item.stage}; owner=${item.owner}; QA-Next=${item.next || "unrecorded"}; evidence/blocker=${proof}${item.repair ? `; existing PR #${item.repair.number}` : ""}`; +} + +export function planBacklog(issues, prs, records = {}, now = Date.now(), { daily = true, followup = true } = {}) { + const open = issues.filter((issue) => !issue.pull_request && issue.state !== "closed"); + const observed = open.map((issue) => observe(issue, prs, records[issue.number], now)); + const inventory = { + at: new Date(now).toISOString(), day: beijingDay(now), total: observed.length, + qa: observed.filter((item) => item.qa).length, process: observed.filter((item) => item.process).length, + other: observed.filter((item) => !item.qa && !item.process).length, + verified: observed.filter((item) => item.verified).length, + paused: observed.filter((item) => item.paused).length, + }; + const eligible = observed.filter((item) => item.stage !== "closed").sort(order); + const qa = eligible.filter((item) => item.qa); + const process = eligible.filter((item) => item.process); + const other = eligible.filter((item) => !item.qa && !item.process); + const reservedProcess = process.slice(0, 2); + const reserved = [...reservedProcess, ...other.slice(0, 3 - reservedProcess.length)]; + const selectedDaily = daily ? [...qa.slice(0, 5), ...reserved, + ...eligible.filter((item) => !qa.slice(0, 5).includes(item) && !reserved.includes(item))].slice(0, 8) : []; + const dailyIds = new Set(selectedDaily.map((item) => item.number)); + const selectedFollowup = followup ? eligible.filter((item) => !dailyIds.has(item.number) + && (item.qa || item.process) && (item.verified || item.stage === "unassigned" || item.stale)).slice(0, 6) : []; + const items = []; + if (daily) items.push({ key: `backlog:daily:${inventory.day}`, kind: "daily", selected: selectedDaily, + text: `Beijing daily open backlog ${inventory.day}: ${inventory.total} open (${inventory.qa} QA, ${inventory.process} process, ${inventory.other} other; ${inventory.verified} verified, ${inventory.paused} paused). Review all-open inventory; selected ${selectedDaily.length} for bounded action, ${inventory.total - selectedDaily.length} deferred. Preserve current owners and existing repairs.\n${selectedDaily.map(line).join("\n")}` }); + if (followup && selectedFollowup.length) items.push({ key: `backlog:followup:${backlogSlot(now)}`, kind: "followup", selected: selectedFollowup, + text: `Unresolved work follow-up, six-hour cadence: ${selectedFollowup.length} selected. Prompt delivery is not completion; record assignment, repair, review/verification or evidence-backed disposition on each original item.\n${selectedFollowup.map(line).join("\n")}` }); + return { inventory, observed, items }; +} diff --git a/scripts/qa/backlog.test.ts b/scripts/qa/backlog.test.ts new file mode 100644 index 000000000..49f33d102 --- /dev/null +++ b/scripts/qa/backlog.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { backlogSlot, beijingDay, planBacklog } from "./backlog.mjs"; +import { drainQueue, queueItems } from "./queue.mjs"; + +const start = Date.parse("2026-11-13T00:00:00Z"); +const old = "2026-11-10T00:00:00Z"; + +function issue(number: number, labels = ["qa", "qa:triaged", "qa:p2", "qa:kind:infrastructure", "qa:scope:global", "owner:upstream"], body = `QA-Owner: mausbot-upstream\nQA-Next: inspect saved evidence\nQA-Due: 2026-11-15T00:00:00Z`) { + return { number, state: "open", created_at: old, updated_at: old, labels, body, + html_url: `https://github.com/example/mausbot/issues/${number}` }; +} + +function harness(initial = [issue(1)]) { + let open = initial; + const closed = new Map(); + const api = vi.fn(async (path: string) => { + if (path.startsWith("/issues?state=open&labels=qa")) return open.filter((item) => item.labels.includes("qa")); + if (path.startsWith("/issues?state=open")) return open; + if (path.startsWith("/pulls?state=open")) return []; + const id = Number(path.match(/^\/issues\/(\d+)$/)?.[1]); + if (id && closed.has(id)) return closed.get(id); + throw new Error(`Unexpected API read: ${path}`); + }); + const send = vi.fn(() => ({ submittedAt: new Date(start).toISOString() })); + const tick = (state: Record, now: number, sender = send) => drainQueue(state, { + api, now, send: sender, recover: async () => ({ healthy: true }), + scan: async () => ({ handoffs: [] }), save: () => {}, + }); + return { tick, send, api, close: (number: number, body: string) => { + open = open.filter((item) => item.number !== number); + closed.set(number, { number, state: "closed", closed_at: new Date(start).toISOString(), body }); + } }; +} + +describe("hourly QA backlog", () => { + it("runs one Beijing daily inventory, resurfaces unchanged stale work by cadence and survives state reload", async () => { + expect(beijingDay(Date.parse("2026-11-12T15:59:59Z"))).toBe("2026-11-12"); + expect(beijingDay(Date.parse("2026-11-12T16:00:00Z"))).toBe("2026-11-13"); + const h = harness(), state: Record = {}; + expect((await h.tick(state, start)).sent).toBe(true); + expect(h.send.mock.calls[0][0]).toContain("Beijing daily open backlog 2026-11-13"); + expect(state.backlog.records[1]).toMatchObject({ stage: "assigned", delivery: { status: "notification_sent" } }); + expect(state.backlog.records[1]).not.toHaveProperty("completedAt"); + expect((await h.tick(state, start + 60_000)).sent).toBe(false); + const resumed = JSON.parse(JSON.stringify(state)); + expect((await h.tick(resumed, start + 6 * 60 * 60_000)).sent).toBe(true); + expect(h.send.mock.calls[1][0]).toContain("Unresolved work follow-up"); + expect(resumed.backlog.lastFollowupSlot).toBe(backlogSlot(start + 6 * 60 * 60_000)); + expect((await h.tick(resumed, start + 6 * 60 * 60_000 + 60_000)).sent).toBe(false); + expect((await h.tick(resumed, start + 24 * 60 * 60_000)).sent).toBe(true); + expect(h.send.mock.calls[2][0]).toContain("Beijing daily open backlog 2026-11-14"); + expect(h.send).toHaveBeenCalledTimes(3); + }); + + it("records a verified closure separately from notification and excludes closed work", async () => { + const h = harness(), state: Record = {}; + await h.tick(state, start); + h.close(1, "QA-Disposition-Evidence: accepted review and exact integrated report"); + await h.tick(state, start + 24 * 60 * 60_000); + expect(state.backlog.records[1]).toMatchObject({ stage: "closed", closureEvidence: "accepted review and exact integrated report" }); + expect(state.backlog.lastInventory).toMatchObject({ total: 0, qa: 0 }); + expect(h.send.mock.calls[1][0]).not.toContain("#1 https://"); + expect(h.api.mock.calls.every((call) => call.length === 1)).toBe(true); + }); + + it("requires evidence for a closed disposition", async () => { + const h = harness(), state: Record = {}; + await h.tick(state, start); + h.close(1, "Closed without a verification record"); + await h.tick(state, start + 24 * 60 * 60_000); + expect(state.backlog.records[1]).toMatchObject({ stage: "closed_evidence_pending", closureEvidence: null }); + }); + + it("keeps ambiguous submission pending and retries only a known preflight rejection", async () => { + const h = harness(), uncertain: Record = {}; + const unknown = vi.fn(() => { throw new Error("possible submission"); }); + await expect(h.tick(uncertain, start, unknown)).rejects.toThrow("possible submission"); + expect(uncertain.pending.status).toBe("uncertain"); + expect(uncertain.backlog).not.toHaveProperty("lastDailyDay"); + expect((await h.tick(uncertain, start + 60_000, unknown)).uncertain).toBe(true); + expect(unknown).toHaveBeenCalledTimes(1); + const known: Record = {}; + const preflight = vi.fn(() => { throw Object.assign(new Error("unavailable before prompt"), { notSubmitted: true }); }); + await expect(h.tick(known, start, preflight)).rejects.toMatchObject({ notSubmitted: true }); + expect(known).not.toHaveProperty("pending"); + expect(known.backlog).not.toHaveProperty("lastDailyDay"); + expect((await h.tick(known, start + 60_000)).sent).toBe(true); + }); + + it("bounds the all-open batch, keeps paused requests disposition-only and retains active owners", () => { + const verified = issue(1, ["qa", "qa:verified", "qa:triaged", "qa:p2", "qa:kind:infrastructure", "qa:scope:global", "owner:upstream"]); + const qa = Array.from({ length: 19 }, (_, i) => issue(i + 2)); + const other = Array.from({ length: 10 }, (_, i) => issue(i + 30, ["feature", "status:paused"], "Request held for product decision")); + const pr = { number: 44, draft: true, head: { sha: "a".repeat(40) }, body: "QA-Repair: #2" }; + const plan = planBacklog([verified, ...qa, ...other], [pr], {}, start); + expect(plan.inventory).toMatchObject({ total: 30, qa: 20, other: 10, verified: 1, paused: 10 }); + expect(plan.items[0].selected).toHaveLength(8); + expect(plan.items[1].selected).toHaveLength(6); + expect(plan.items[0].selected[0]).toMatchObject({ number: 1, stage: "verification", owner: "mausbot-upstream" }); + expect(plan.observed.find((item) => item.number === 2)).toMatchObject({ stage: "fixing", repair: { number: 44 } }); + expect(plan.items[0].selected.filter((item) => item.stage === "disposition_needed")).toHaveLength(3); + expect(plan.items[0].text).toContain("do not resume paused or product work"); + expect(plan.items[0].text).toContain("existing PR #44"); + }); + + it("observes an existing repair through fixing, review and verification without declaring it complete", () => { + const source = issue(2); + const draft = { number: 44, draft: true, head: { sha: "a".repeat(40) }, body: "QA-Repair: #2" }; + const fixing = planBacklog([source], [draft], {}, start).observed[0]; + const ready = { ...draft, draft: false, body: `${draft.body}\nQA-Handoff: ${draft.head.sha}` }; + const review = planBacklog([source], [ready], { 2: fixing }, start + 60_000).observed[0]; + const verified = planBacklog([{ ...source, labels: [...source.labels, "qa:verified"] }], [], { 2: review }, start + 120_000).observed[0]; + expect([fixing.stage, review.stage, verified.stage]).toEqual(["fixing", "review", "verification"]); + expect([fixing, review, verified].every((item) => !Object.hasOwn(item, "completedAt"))).toBe(true); + }); + + it("does not wake immediate QA again for cosmetic due or comment changes", () => { + const first = issue(1, ["qa", "qa:triaged", "qa:p2", "qa:kind:infrastructure", "qa:scope:global", "owner:upstream"], + "QA-Owner: mausbot-upstream\nQA-Next: inspect saved evidence\nQA-Due: 2026-11-12T00:00:00Z"); + const renewed = { ...first, body: first.body.replace("2026-11-12T00:00:00Z", "2026-11-12T12:00:00Z") + "\nCheckpoint comment" }; + expect(queueItems([first], [], start)[0].key).toBe(queueItems([renewed], [], start)[0].key); + }); +}); diff --git a/scripts/qa/queue.mjs b/scripts/qa/queue.mjs index 3fdfc6358..09cf05013 100644 --- a/scripts/qa/queue.mjs +++ b/scripts/qa/queue.mjs @@ -7,6 +7,7 @@ import { artifactFiles, fullRunEvidence, github, pages, runJobs, runPlan } from import { eligibleRun, fullRunPlan, issueLabels, triageOf } from "./contracts.mjs"; import { FULL_QA_FRESHNESS_MS, qaHealth } from "./health.mjs"; import { scanProcesses } from "./scan.mjs"; +import { backlogSlot, beijingDay, planBacklog } from "./backlog.mjs"; export function queueItems(issues, prs, now = Date.now()) { const items = []; @@ -14,11 +15,13 @@ export function queueItems(issues, prs, now = Date.now()) { const labels = issueLabels(issue); const epoch = issue.body?.match(/"triageEpoch":"([^"]+)"/)?.[1] ?? issue.created_at; const { due, owner, complete: triaged } = triageOf(issue); - let action; - if (labels.includes("qa:verified")) action = "verify closure with Reviewer and integrated fix evidence"; - else if (!triaged) action = now - Date.parse(epoch) > 30 * 60_000 ? "overdue triage" : "triage within 30 minutes"; - else if (Date.parse(due) < now) action = `overdue repair owned by ${owner}, due ${due}`; - if (action) items.push({ key: `issue:${issue.number}:${epoch}:${action}`, text: `${issue.html_url}: ${action}` }); + let action, kind; + if (labels.includes("qa:verified")) { action = "verify closure with Reviewer and integrated fix evidence"; kind = "verify"; } + else if (!triaged) { + kind = now - Date.parse(epoch) > 30 * 60_000 ? "overdue-triage" : "triage"; + action = kind === "overdue-triage" ? "overdue triage" : "triage within 30 minutes"; + } else if (Date.parse(due) < now) { action = `overdue repair owned by ${owner}, due ${due}`; kind = "overdue-repair"; } + if (action) items.push({ key: `issue:${issue.number}:${epoch}:${kind}`, text: `${issue.html_url}: ${action}` }); } for (const pr of prs.filter((item) => !item.draft && item.body?.match(/^QA-Handoff:\s*([a-f0-9]{40})\s*$/m)?.[1] === item.head.sha)) { items.push({ key: `pr:${pr.number}:${pr.head.sha}`, text: `${pr.html_url}: review/CI handoff for ${pr.head.sha}` }); @@ -132,20 +135,65 @@ export async function drainQueue(state, { api = github, recover = recoverQa, sca state.lastProcessScan = { at: new Date(now).toISOString(), outcome: "incomplete", error: error.message }; } save(state); - const items = [...queueItems(issues, prs, now), ...processItems].filter((item) => !state.seen?.[item.key]); + if (state.pending) return { sent: false, uncertain: true, state }; + const day = beijingDay(now), slot = backlogSlot(now); + const daily = state.backlog?.lastDailyDay !== day; + const followup = state.backlog?.lastFollowupSlot !== slot; + let backlogItems = []; + if (daily || followup) { + const allOpen = await pages("/issues?state=open", null, api); + state.backlog ??= { records: {} }; + state.backlog.records ??= {}; + const plan = planBacklog(allOpen, prs, state.backlog.records, now, { daily, followup }); + for (const item of plan.observed) state.backlog.records[item.number] = { ...state.backlog.records[item.number], ...item }; + if (daily) { + const openIds = new Set(plan.observed.map((item) => item.number)); + const missing = Object.values(state.backlog.records).filter((item) => !openIds.has(item.number) + && !["closed", "closed_evidence_pending"].includes(item.stage)).slice(0, 4); + for (const item of missing) { + const closed = await api(`/issues/${item.number}`); + if (closed.state !== "closed" || !closed.closed_at) continue; + const evidence = closed.body?.match(/^QA-(?:Closure|Disposition)-Evidence:\s*(.+)$/m)?.[1]; + state.backlog.records[item.number] = { ...item, stage: evidence ? "closed" : "closed_evidence_pending", + closedAt: closed.closed_at, closureEvidence: evidence ?? null }; + } + const needsEvidence = Object.values(state.backlog.records).filter((item) => item.stage === "closed_evidence_pending").slice(0, 4); + if (needsEvidence.length) plan.items[0].text += `\nClosed items awaiting exact disposition evidence: ${needsEvidence.map((item) => `#${item.number}`).join(", ")}. Verify on the original issue; do not infer completion.`; + } + state.backlog.lastInventory = plan.inventory; + if (daily) plan.items[0].text += `\nComplete compact inventory: ${process.env.MAUSBOT_QA_STATE || "the existing queue state file"} -> backlog.records. Lead reviews deferred entries there before selecting later bounded work.`; + backlogItems = plan.items; + save(state); + } + const selectedIds = new Set(backlogItems.flatMap((entry) => entry.selected.map((item) => item.number))); + const qaItems = queueItems(issues, prs, now); + const ordinary = [...qaItems, ...processItems]; + const covered = ordinary.filter((item) => selectedIds.has(Number(item.key.match(/^issue:(\d+):/)?.[1] + ?? item.subject?.match(/^issue:(\d+)$/)?.[1]))); + const items = [...qaItems.filter((item) => !state.seen?.[item.key] && !covered.includes(item)).slice(0, 6), + ...processItems.filter((item) => !state.seen?.[item.key] && !covered.includes(item)).slice(0, 4), ...backlogItems]; if (!qa.healthy) { const key = `qa-unavailable:${qa.report?.runId ?? "no-report"}:${new Date(now).toISOString().slice(0, 10)}`; if (!state.seen?.[key]) items.push({ key, text: `QA coverage reduction disabled: ${qa.reason}. Check the shared recovery/report delivery; do not start a full matrix for each PR.` }); } if (!items.length) return { sent: false, state }; const fingerprint = createHash("sha256").update(items.map((item) => item.key).join("\n")).digest("hex").slice(0, 16); - if (state.pending) return { sent: false, uncertain: true, state }; - state.pending = { fingerprint, items: items.map((item) => item.key), status: "submitting", at: new Date(now).toISOString() }; + state.pending = { fingerprint, items: items.map((item) => item.key), covered: covered.map((item) => item.key), + status: "submitting", at: new Date(now).toISOString() }; save(state); const message = `MausBot QA queue ${fingerprint}. Preserve your active task; handle this handoff at the next safe boundary.\n${items.map((item) => `- ${item.text}`).join("\n")}\nFollow docs/MAUSBOT-VALIDATION.md. Triage with priority, classification, scope, QA-Owner, QA-Next and QA-Due; acknowledge in the issue/PR. Assign repairs to an existing developer, dispatch independent review, and close only with verification. This is a role handoff authorized by 大雄, not permission to change the live App.`; try { state.lastDelivery = await send(message); - state.seen = { ...state.seen, ...Object.fromEntries(items.map((item) => [item.key, new Date(now).toISOString()])) }; + state.seen = { ...state.seen, ...Object.fromEntries([...items, ...covered].map((item) => [item.key, new Date(now).toISOString()])) }; + if (daily || followup) { + if (daily) state.backlog.lastDailyDay = day; + if (followup) state.backlog.lastFollowupSlot = slot; + for (const item of backlogItems.flatMap((entry) => entry.selected)) { + const record = state.backlog.records[item.number]; + record.lastNotifiedAt = new Date(now).toISOString(); + record.delivery = { status: "notification_sent", at: record.lastNotifiedAt, fingerprint }; + } + } delete state.pending; save(state); return { sent: true, fingerprint, state }; From 3bec9e6d8d993cad44c8086ab28c3aae704c2a1e Mon Sep 17 00:00:00 2001 From: Mengxiong Han Date: Fri, 13 Nov 2026 11:44:50 +0800 Subject: [PATCH 2/3] Recognize accepted Lead closure records in backlog state --- docs/MAUSBOT-VALIDATION.md | 3 ++- scripts/qa/backlog.test.ts | 8 ++++++++ scripts/qa/queue.mjs | 3 ++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/MAUSBOT-VALIDATION.md b/docs/MAUSBOT-VALIDATION.md index 5a9d3efb7..6911463e2 100644 --- a/docs/MAUSBOT-VALIDATION.md +++ b/docs/MAUSBOT-VALIDATION.md @@ -279,7 +279,8 @@ queue state records inventory, delivery, assignment, fixing, review, verificatio and closure evidence separately. A prompt receipt proves only delivery; Lead records actual work and exact accepted completion on the original issue or PR. For a closed issue, `QA-Disposition-Evidence: ` or -`QA-Closure-Evidence: ` in its body binds the queue's +`QA-Closure-Evidence: ` or an existing Lead closure +marker in its body binds the queue's closed record; missing closure evidence remains pending. The queue never resumes paused work, reassigns an active repair or closes an issue automatically. diff --git a/scripts/qa/backlog.test.ts b/scripts/qa/backlog.test.ts index 49f33d102..080698278 100644 --- a/scripts/qa/backlog.test.ts +++ b/scripts/qa/backlog.test.ts @@ -71,6 +71,14 @@ describe("hourly QA backlog", () => { expect(state.backlog.records[1]).toMatchObject({ stage: "closed_evidence_pending", closureEvidence: null }); }); + it("recognizes the existing Lead closure marker on an exact closed issue", async () => { + const h = harness(), state: Record = {}; + await h.tick(state, start); + h.close(1, "\nOriginal review and exact integrated report are retained below."); + await h.tick(state, start + 24 * 60 * 60_000); + expect(state.backlog.records[1]).toMatchObject({ stage: "closed", closureEvidence: "" }); + }); + it("keeps ambiguous submission pending and retries only a known preflight rejection", async () => { const h = harness(), uncertain: Record = {}; const unknown = vi.fn(() => { throw new Error("possible submission"); }); diff --git a/scripts/qa/queue.mjs b/scripts/qa/queue.mjs index 09cf05013..d243da0f9 100644 --- a/scripts/qa/queue.mjs +++ b/scripts/qa/queue.mjs @@ -153,7 +153,8 @@ export async function drainQueue(state, { api = github, recover = recoverQa, sca for (const item of missing) { const closed = await api(`/issues/${item.number}`); if (closed.state !== "closed" || !closed.closed_at) continue; - const evidence = closed.body?.match(/^QA-(?:Closure|Disposition)-Evidence:\s*(.+)$/m)?.[1]; + const evidence = closed.body?.match(/^QA-(?:Closure|Disposition)-Evidence:\s*(.+)$/m)?.[1] + ?? closed.body?.match(//)?.[0]; state.backlog.records[item.number] = { ...item, stage: evidence ? "closed" : "closed_evidence_pending", closedAt: closed.closed_at, closureEvidence: evidence ?? null }; } From 0279dc34df201cec06a3431ff6e4b1222fcfcb4b Mon Sep 17 00:00:00 2001 From: Mengxiong Han Date: Fri, 13 Nov 2026 12:01:55 +0800 Subject: [PATCH 3/3] Fix fair backlog closure reads and process ownership --- scripts/qa/backlog.mjs | 23 +++++++++------ scripts/qa/backlog.test.ts | 58 +++++++++++++++++++++++++++++++++++++- scripts/qa/queue.mjs | 14 +++++---- 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/scripts/qa/backlog.mjs b/scripts/qa/backlog.mjs index 45a004f05..754b39e57 100644 --- a/scripts/qa/backlog.mjs +++ b/scripts/qa/backlog.mjs @@ -31,15 +31,17 @@ function observe(issue, prs, previous, now) { const process = labels.includes("qa:process"); const paused = labels.some((label) => /(?:^|:)paused?$|^status:on-hold$/.test(label)); const triage = qa ? triageOf(issue) : {}; - const owner = triage.owner ?? field(issue.body, "QA-Owner") ?? "unassigned"; - const next = field(issue.body, "QA-Next"); - const blocker = field(issue.body, "QA-Blocker"); - const evidence = field(issue.body, "QA-Evidence"); + const prefix = process ? "Process" : "QA"; + const owner = triage.owner ?? field(issue.body, `${prefix}-Owner`) ?? "unassigned"; + const next = field(issue.body, `${prefix}-Next`); + const blocker = field(issue.body, `${prefix}-Blocker`); + const evidence = field(issue.body, `${prefix}-Evidence`); const repair = qa ? linkedRepair(issue, prs) : undefined; const verified = labels.includes("qa:verified"); const review = repair && !repair.draft && repair.body?.match(/^QA-Handoff:\s*([a-f0-9]{40})\s*$/m)?.[1] === repair.head?.sha; const stage = !qa && !process || paused ? "disposition_needed" - : verified ? "verification" : review ? "review" : repair ? "fixing" : triage.complete ? "assigned" : "unassigned"; + : verified ? "verification" : review ? "review" : repair ? "fixing" + : triage.complete || process && safe(owner) !== "" && safe(owner) !== "unassigned" ? "assigned" : "unassigned"; const signature = JSON.stringify([stage, owner, next, blocker, evidence, repair?.number, repair?.head?.sha, verified]); const firstSeenAt = previous?.firstSeenAt ?? issue.created_at ?? new Date(now).toISOString(); const lastProgressAt = previous?.signature === signature ? previous.lastProgressAt @@ -54,12 +56,17 @@ function observe(issue, prs, previous, now) { : stage === "review" ? "finish existing review and verification" : stage === "fixing" ? "follow up with existing repair owner" : stage === "unassigned" ? "triage and assign an existing owner" : "check evidence and next action with existing owner"; + const epoch = issue.body?.match(/"triageEpoch":"([^"]+)"/)?.[1] ?? issue.created_at; + const overdue = qa && !["disposition_needed", "verification"].includes(stage) + ? !triage.complete && now - Date.parse(epoch) > 30 * 60_000 ? "overdue triage; " + : triage.complete && Date.parse(triage.due) < now ? `overdue repair owned by ${safe(owner)}, due ${triage.due}; ` : "" + : ""; return { number: issue.number, url: issue.html_url, qa, process, paused, verified, stale, priority, stage, firstSeenAt, lastProgressAt, signature, lastNotifiedAt: previous?.lastNotifiedAt, owner: safe(owner), next: safe(next), blocker: safe(blocker), evidence: safe(evidence), repair: repair && { number: repair.number, head: repair.head?.sha, draft: repair.draft }, - action, + action: overdue + action, }; } @@ -70,7 +77,7 @@ function order(a, b) { function line(item) { const proof = item.evidence || item.blocker || "no evidence/blocker recorded"; - return `#${item.number} ${safe(item.url)}: ${item.action}; stage=${item.stage}; owner=${item.owner}; QA-Next=${item.next || "unrecorded"}; evidence/blocker=${proof}${item.repair ? `; existing PR #${item.repair.number}` : ""}`; + return `#${item.number} ${safe(item.url)}: ${item.action}; stage=${item.stage}; owner=${item.owner}; ${item.process ? "Process" : "QA"}-Next=${item.next || "unrecorded"}; evidence/blocker=${proof}${item.repair ? `; existing PR #${item.repair.number}` : ""}`; } export function planBacklog(issues, prs, records = {}, now = Date.now(), { daily = true, followup = true } = {}) { @@ -93,7 +100,7 @@ export function planBacklog(issues, prs, records = {}, now = Date.now(), { daily ...eligible.filter((item) => !qa.slice(0, 5).includes(item) && !reserved.includes(item))].slice(0, 8) : []; const dailyIds = new Set(selectedDaily.map((item) => item.number)); const selectedFollowup = followup ? eligible.filter((item) => !dailyIds.has(item.number) - && (item.qa || item.process) && (item.verified || item.stage === "unassigned" || item.stale)).slice(0, 6) : []; + && (item.process || item.qa && (item.verified || item.stage === "unassigned" || item.stale))).slice(0, 6) : []; const items = []; if (daily) items.push({ key: `backlog:daily:${inventory.day}`, kind: "daily", selected: selectedDaily, text: `Beijing daily open backlog ${inventory.day}: ${inventory.total} open (${inventory.qa} QA, ${inventory.process} process, ${inventory.other} other; ${inventory.verified} verified, ${inventory.paused} paused). Review all-open inventory; selected ${selectedDaily.length} for bounded action, ${inventory.total - selectedDaily.length} deferred. Preserve current owners and existing repairs.\n${selectedDaily.map(line).join("\n")}` }); diff --git a/scripts/qa/backlog.test.ts b/scripts/qa/backlog.test.ts index 080698278..35f672d0e 100644 --- a/scripts/qa/backlog.test.ts +++ b/scripts/qa/backlog.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { backlogSlot, beijingDay, planBacklog } from "./backlog.mjs"; import { drainQueue, queueItems } from "./queue.mjs"; +import { recordCases } from "./scan.mjs"; const start = Date.parse("2026-11-13T00:00:00Z"); const old = "2026-11-10T00:00:00Z"; @@ -26,7 +27,9 @@ function harness(initial = [issue(1)]) { api, now, send: sender, recover: async () => ({ healthy: true }), scan: async () => ({ handoffs: [] }), save: () => {}, }); - return { tick, send, api, close: (number: number, body: string) => { + return { tick, send, api, update: (number: number, body: string) => { + open = open.map((item) => item.number === number ? { ...item, body } : item); + }, close: (number: number, body: string) => { open = open.filter((item) => item.number !== number); closed.set(number, { number, state: "closed", closed_at: new Date(start).toISOString(), body }); } }; @@ -71,6 +74,59 @@ describe("hourly QA backlog", () => { expect(state.backlog.records[1]).toMatchObject({ stage: "closed_evidence_pending", closureEvidence: null }); }); + it("fairly rereads more than four closed records and settles only later evidence after restart", async () => { + const h = harness(Array.from({ length: 6 }, (_, index) => issue(index + 1))); + const state: Record = {}; + const detailReads = () => h.api.mock.calls.filter(([path]) => /^\/issues\/\d+$/.test(path)).map(([path]) => path); + await h.tick(state, start); + for (let number = 1; number <= 6; number++) h.close(number, "Closed without evidence"); + await h.tick(state, start + 24 * 60 * 60_000); + expect(detailReads()).toEqual(["/issues/1", "/issues/2", "/issues/3", "/issues/4"]); + expect([1, 2, 3, 4].map((number) => state.backlog.records[number].stage)).toEqual(Array(4).fill("closed_evidence_pending")); + const resumed = JSON.parse(JSON.stringify(state)); + await h.tick(resumed, start + 48 * 60 * 60_000); + expect(detailReads().slice(4)).toEqual(["/issues/5", "/issues/6", "/issues/1", "/issues/2"]); + expect([1, 2, 3, 4, 5, 6].map((number) => resumed.backlog.records[number].stage)).toEqual(Array(6).fill("closed_evidence_pending")); + expect(h.send.mock.calls[2][0]).toContain("Closed items awaiting exact disposition evidence: #5, #6, #1, #2"); + h.close(3, "QA-Disposition-Evidence: accepted review and exact integrated report"); + await h.tick(resumed, start + 72 * 60 * 60_000); + expect(detailReads().slice(8)).toEqual(["/issues/3", "/issues/4", "/issues/1", "/issues/2"]); + expect(resumed.backlog.records[3]).toMatchObject({ stage: "closed", closureEvidence: "accepted review and exact integrated report" }); + expect(resumed.backlog.records[4]).toMatchObject({ stage: "closed_evidence_pending", closureEvidence: null }); + expect(h.send.mock.calls[3][0]).toContain("Closed items awaiting exact disposition evidence: #4"); + expect(h.send.mock.calls[3][0]).not.toContain("Closed items awaiting exact disposition evidence: #3"); + }); + + it("uses the native process record through assignment and six-hour follow-up", async () => { + let generated: ReturnType | undefined; + await recordCases([{ key: "a".repeat(16), rule: "ci-latency", subject: "run:123:1", text: "Inspect the measured CI phase." }], { + now: start, + api: async (path: string, options?: { method?: string; body?: { body: string; labels: string[] } }) => { + if (path.startsWith("/issues?state=all&labels=qa:process")) return []; + if (path === "/issues" && options?.method === "POST") { + generated = { ...issue(9, options.body!.labels, options.body!.body), created_at: new Date(start).toISOString() }; + return generated; + } + throw new Error(`Unexpected process API call: ${path}`); + }, + }); + const h = harness([generated!]), state: Record = {}; + await h.tick(state, start); + expect(state.backlog.records[9]).toMatchObject({ process: true, stage: "assigned", owner: "mausbot-lead", + next: expect.stringContaining("assign an existing owner") }); + const resumed = JSON.parse(JSON.stringify(state)); + h.update(9, generated!.body.replace(/^Process-Owner: .+$/m, "Process-Owner: mausbot-upstream") + .replace(/^Process-Next: .+$/m, "Process-Next: inspect the saved phase trace") + + "\nProcess-Evidence: bounded trace accepted"); + await h.tick(resumed, start + 6 * 60 * 60_000); + expect(resumed.backlog.records[9]).toMatchObject({ stage: "assigned", owner: "mausbot-upstream", + next: "inspect the saved phase trace", evidence: "bounded trace accepted", lastProgressAt: new Date(start + 6 * 60 * 60_000).toISOString() }); + expect(resumed.backlog.records[9]).not.toHaveProperty("completedAt"); + expect(h.send.mock.calls[1][0]).toContain("Unresolved work follow-up"); + expect(h.send.mock.calls[1][0]).toContain("Process-Next=inspect the saved phase trace"); + expect(h.send.mock.calls[1][0]).toContain("keep Process-Owner and Process-Next on process cases"); + }); + it("recognizes the existing Lead closure marker on an exact closed issue", async () => { const h = harness(), state: Record = {}; await h.tick(state, start); diff --git a/scripts/qa/queue.mjs b/scripts/qa/queue.mjs index d243da0f9..368c6ab57 100644 --- a/scripts/qa/queue.mjs +++ b/scripts/qa/queue.mjs @@ -149,17 +149,21 @@ export async function drainQueue(state, { api = github, recover = recoverQa, sca if (daily) { const openIds = new Set(plan.observed.map((item) => item.number)); const missing = Object.values(state.backlog.records).filter((item) => !openIds.has(item.number) - && !["closed", "closed_evidence_pending"].includes(item.stage)).slice(0, 4); + && item.stage !== "closed") + .sort((a, b) => (a.lastClosureCheckedAt ?? "").localeCompare(b.lastClosureCheckedAt ?? "") || a.number - b.number) + .slice(0, 4); + const needsEvidence = []; for (const item of missing) { const closed = await api(`/issues/${item.number}`); + state.backlog.records[item.number] = { ...item, lastClosureCheckedAt: new Date(now).toISOString() }; if (closed.state !== "closed" || !closed.closed_at) continue; const evidence = closed.body?.match(/^QA-(?:Closure|Disposition)-Evidence:\s*(.+)$/m)?.[1] ?? closed.body?.match(//)?.[0]; - state.backlog.records[item.number] = { ...item, stage: evidence ? "closed" : "closed_evidence_pending", + state.backlog.records[item.number] = { ...state.backlog.records[item.number], stage: evidence ? "closed" : "closed_evidence_pending", closedAt: closed.closed_at, closureEvidence: evidence ?? null }; + if (!evidence) needsEvidence.push(item.number); } - const needsEvidence = Object.values(state.backlog.records).filter((item) => item.stage === "closed_evidence_pending").slice(0, 4); - if (needsEvidence.length) plan.items[0].text += `\nClosed items awaiting exact disposition evidence: ${needsEvidence.map((item) => `#${item.number}`).join(", ")}. Verify on the original issue; do not infer completion.`; + if (needsEvidence.length) plan.items[0].text += `\nClosed items awaiting exact disposition evidence: ${needsEvidence.map((number) => `#${number}`).join(", ")}. Verify on the original issue; do not infer completion.`; } state.backlog.lastInventory = plan.inventory; if (daily) plan.items[0].text += `\nComplete compact inventory: ${process.env.MAUSBOT_QA_STATE || "the existing queue state file"} -> backlog.records. Lead reviews deferred entries there before selecting later bounded work.`; @@ -182,7 +186,7 @@ export async function drainQueue(state, { api = github, recover = recoverQa, sca state.pending = { fingerprint, items: items.map((item) => item.key), covered: covered.map((item) => item.key), status: "submitting", at: new Date(now).toISOString() }; save(state); - const message = `MausBot QA queue ${fingerprint}. Preserve your active task; handle this handoff at the next safe boundary.\n${items.map((item) => `- ${item.text}`).join("\n")}\nFollow docs/MAUSBOT-VALIDATION.md. Triage with priority, classification, scope, QA-Owner, QA-Next and QA-Due; acknowledge in the issue/PR. Assign repairs to an existing developer, dispatch independent review, and close only with verification. This is a role handoff authorized by 大雄, not permission to change the live App.`; + const message = `MausBot QA queue ${fingerprint}. Preserve your active task; handle this handoff at the next safe boundary.\n${items.map((item) => `- ${item.text}`).join("\n")}\nFollow docs/MAUSBOT-VALIDATION.md. Triage QA incidents with priority, classification, scope, QA-Owner, QA-Next and QA-Due; keep Process-Owner and Process-Next on process cases. Acknowledge in the original issue/PR. Assign repairs to an existing developer, dispatch independent review, and close only with verification. This is a role handoff authorized by 大雄, not permission to change the live App.`; try { state.lastDelivery = await send(message); state.seen = { ...state.seen, ...Object.fromEntries([...items, ...covered].map((item) => [item.key, new Date(now).toISOString()])) };