From 3d7e58a5c635a2dbcd3535fe9bb55ebcf62d6d1e Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 01:12:54 +0200 Subject: [PATCH 1/5] feat(agent): serve stale decision packs with freshness marker and background rebuild Closes JSONbored/gittensory#15. `GET /v1/contributors/:login/decision-pack`, the MCP decision-pack tools, and the agent orchestrator previously discarded a perfectly usable cached pack whenever it crossed the 6h freshness window and returned an empty `needs_snapshot_refresh` body. Every MCP `agent plan` call hitting a slightly stale pack therefore came back with no `topActions` or `repoDecisions`, exactly when the contributor was asking what to do next. This change centralizes the staleness policy in a new `loadContributorDecisionPackForServing` helper used by the REST API, the MCP server, and the agent orchestrator. The helper: - returns a `fresh` pack as-is when within the freshness window, - returns the same pack with `freshness: "rebuilding"` (or `"stale"` if the queue refused the job) plus `rebuildEnqueued: true` when the pack is past the window, and enqueues a single background rebuild, - returns a bounded `needs_snapshot_refresh` body with `freshness: "missing"` only when no snapshot exists. The REST routes now return HTTP 200 with the cached `topActions`/ `repoDecisions` when serving a stale-but-usable pack and reserve 202 for the truly-missing case. The agent orchestrator no longer returns an empty `needs_snapshot_refresh` bundle on staleness: it runs the normal decision/blocker path against the cached pack, marks the run `completed` with `dataQualityStatus: "degraded"`, attaches a freshness warning to the persisted context snapshot, and records the freshness + rebuild status on the run payload. MCP tools degrade the same way. OpenAPI schemas add the `freshness` and `rebuildEnqueued` fields on `ContributorDecisionPack`, `RepoDecisionResponse`, and `DecisionPackRefreshNeeded`. Worker budget safety is preserved: the request path still reads only the latest `signal_snapshots` row and never invokes broad issue/PR listers inline. Tests: - new unit coverage for `loadContributorDecisionPackForServing` across fresh, stale-with-enqueue, stale-without-enqueue, missing, and queue-failure paths, - updated stale-user integration test expects 200 + actionable payload, - new orchestrator test asserts stale-pack runs finish `completed` with a `decision pack is stale ...; background rebuild enqueued` freshness warning, - "interrupted run" test updated to assert the new graceful degradation (queue errors now yield `needs_snapshot_refresh` with `rebuildEnqueued: false` instead of failing the run). --- CHANGELOG.md | 2 + site/troubleshooting.md | 9 ++- src/api/routes.ts | 53 +++++-------- src/mcp/server.ts | 39 ++++------ src/openapi/schemas.ts | 8 ++ src/services/agent-orchestrator.ts | 37 +++++++-- src/services/decision-pack.ts | 64 +++++++++++++++- test/integration/api.test.ts | 34 ++++++--- test/unit/agent-orchestrator.test.ts | 39 +++++++++- test/unit/decision-pack.test.ts | 107 ++++++++++++++++++++++++++- 10 files changed, 313 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2e2755b98..aa72737e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ - Add deterministic base-agent orchestrator (#14) +- Serve stale decision packs with freshness marker and background rebuild + ### Fixes diff --git a/site/troubleshooting.md b/site/troubleshooting.md index 787104e797..92d48a1277 100644 --- a/site/troubleshooting.md +++ b/site/troubleshooting.md @@ -65,4 +65,11 @@ If a command returns `429`, retry after the reported `retry-after` value. Expens ## Stale Decision Pack -If `decision-pack` returns `needs_snapshot_refresh`, Gittensory has enqueued a rebuild. Retry after the queue drains. +`decision-pack` responses now include a `freshness` field with one of: + +- `fresh` — snapshot is within the freshness window; serve as-is. +- `rebuilding` — snapshot is past the freshness window and a background rebuild is enqueued; the response still contains `topActions` and `repoDecisions` from the last good snapshot. The companion `rebuildEnqueued: true` confirms a job was queued. +- `stale` — snapshot is past the freshness window and a rebuild could not be enqueued (queue offline). Treat the data as a best-effort fallback and retry shortly. +- `missing` — no usable snapshot exists. The response status is `needs_snapshot_refresh` and a rebuild has been enqueued when possible. + +MCP `gittensory_get_decision_pack` and `agent plan` degrade the same way: a stale snapshot returns usable actions with `freshness: "rebuilding"` and a freshness warning on the agent context snapshot. Retry once the queue drains to pick up a `fresh` pack. diff --git a/src/api/routes.ts b/src/api/routes.ts index c2a5f9cadf..7eb367e87e 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -74,8 +74,7 @@ import { } from "../services/agent-orchestrator"; import { buildAndPersistContributorDecisionPack, - loadContributorDecisionPack, - loadFreshContributorDecisionPack, + loadContributorDecisionPackForServing, repoDecisionFromPack, } from "../services/decision-pack"; import { @@ -629,46 +628,32 @@ export function createApp() { app.get("/v1/contributors/:login/decision-pack", async (c) => { const login = c.req.param("login"); - const pack = await loadFreshContributorDecisionPack(c.env, login); - if (pack) return c.json(pack); - const stalePack = await loadContributorDecisionPack(c.env, login); - await c.env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login }); - return c.json( - { - status: "needs_snapshot_refresh", - login, - generatedAt: nowIso(), - reason: stalePack ? "stale_snapshot" : "missing_snapshot", - enqueued: true, - ...(stalePack ? { staleSnapshot: { generatedAt: stalePack.generatedAt, ageSeconds: Math.max(0, Math.floor((Date.now() - Date.parse(stalePack.generatedAt)) / 1000)) } } : {}), - ...(stalePack?.dataQuality ? { dataQuality: stalePack.dataQuality } : {}), - }, - 202, - ); + const serving = await loadContributorDecisionPackForServing(c.env, login); + if (serving.kind === "ready") return c.json(serving.pack); + return c.json(serving.refresh, 202); }); app.get("/v1/contributors/:login/repos/:owner/:repo/decision", async (c) => { const login = c.req.param("login"); const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; - const pack = await loadFreshContributorDecisionPack(c.env, login); - if (!pack) { - const stalePack = await loadContributorDecisionPack(c.env, login); - await c.env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login }); - return c.json( - { - status: "needs_snapshot_refresh", - login, - repoFullName: fullName, - generatedAt: nowIso(), - reason: stalePack ? "stale_snapshot" : "missing_snapshot", - enqueued: true, - }, - 202, - ); + const serving = await loadContributorDecisionPackForServing(c.env, login); + if (serving.kind === "needs_refresh") { + return c.json({ ...serving.refresh, repoFullName: fullName }, 202); } + const pack = serving.pack; const decision = repoDecisionFromPack(pack, fullName); if (!decision) return c.json({ error: "repo_decision_not_found", login, repoFullName: fullName }, 404); - return c.json({ status: "ready", login, repoFullName: fullName, generatedAt: pack.generatedAt, source: pack.source, decision, dataQuality: pack.dataQuality }); + return c.json({ + status: "ready", + login, + repoFullName: fullName, + generatedAt: pack.generatedAt, + source: pack.source, + freshness: pack.freshness, + rebuildEnqueued: pack.rebuildEnqueued, + decision, + dataQuality: pack.dataQuality, + }); }); app.post("/v1/preflight/pr", async (c) => { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8a0a7fd140..8d3d268953 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -35,7 +35,7 @@ import { preparePrPacketWithAgent, startAgentRun, } from "../services/agent-orchestrator"; -import { loadFreshContributorDecisionPack, repoDecisionFromPack } from "../services/decision-pack"; +import { loadContributorDecisionPackForServing, repoDecisionFromPack } from "../services/decision-pack"; import { buildBountyAdvisory, buildCollisionReport, @@ -509,43 +509,32 @@ export class GittensoryMcp { } private async getDecisionPack(login: string): Promise { - const pack = await loadFreshContributorDecisionPack(this.env, login); - if (pack) { + const serving = await loadContributorDecisionPackForServing(this.env, login); + if (serving.kind === "ready") { + const stale = serving.pack.freshness !== "fresh"; return { - summary: `Gittensory decision pack for ${login}.`, - data: pack as unknown as Record, + summary: stale + ? `Gittensory decision pack for ${login} (stale; background rebuild enqueued).` + : `Gittensory decision pack for ${login}.`, + data: serving.pack as unknown as Record, }; } - await this.env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login }); return { summary: `Gittensory decision pack for ${login} needs a snapshot refresh.`, - data: { - status: "needs_snapshot_refresh", - login, - generatedAt: new Date().toISOString(), - reason: "missing_snapshot", - enqueued: true, - }, + data: serving.refresh as unknown as Record, }; } private async explainRepoDecision(input: { login: string; owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; - const pack = await loadFreshContributorDecisionPack(this.env, input.login); - if (!pack) { - await this.env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login: input.login }); + const serving = await loadContributorDecisionPackForServing(this.env, input.login); + if (serving.kind === "needs_refresh") { return { summary: `Gittensory repo decision for ${input.login} in ${fullName} needs a snapshot refresh.`, - data: { - status: "needs_snapshot_refresh", - login: input.login, - repoFullName: fullName, - generatedAt: new Date().toISOString(), - reason: "missing_snapshot", - enqueued: true, - }, + data: { ...serving.refresh, repoFullName: fullName } as unknown as Record, }; } + const pack = serving.pack; const decision = repoDecisionFromPack(pack, fullName); return { summary: `Gittensory repo decision for ${input.login} in ${fullName}.`, @@ -555,6 +544,8 @@ export class GittensoryMcp { repoFullName: fullName, generatedAt: pack.generatedAt, source: pack.source, + freshness: pack.freshness, + rebuildEnqueued: pack.rebuildEnqueued, decision, dataQuality: pack.dataQuality, }, diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 469e67fd47..b250e84035 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -957,6 +957,8 @@ export const ContributorStrategySchema = z }) .openapi("ContributorStrategy"); +export const DecisionPackFreshnessSchema = z.enum(["fresh", "stale", "rebuilding", "missing"]).openapi("DecisionPackFreshness"); + export const ContributorDecisionPackSchema = z .object({ status: z.enum(["ready"]), @@ -965,6 +967,8 @@ export const ContributorDecisionPackSchema = z generatedAt: z.string(), snapshotAgeSeconds: z.number().optional(), stale: z.boolean(), + freshness: DecisionPackFreshnessSchema, + rebuildEnqueued: z.boolean(), scoringModelSnapshotId: z.string(), profile: z.record(z.unknown()), outcomeHistory: ContributorOutcomeHistorySchema, @@ -989,6 +993,8 @@ export const DecisionPackRefreshNeededSchema = z repoFullName: z.string().optional(), generatedAt: z.string(), reason: z.enum(["missing_snapshot", "stale_snapshot"]), + freshness: z.enum(["missing", "rebuilding"]), + rebuildEnqueued: z.boolean(), enqueued: z.boolean(), staleSnapshot: z.object({ generatedAt: z.string(), ageSeconds: z.number() }).optional(), dataQuality: z.record(z.unknown()).optional(), @@ -1002,6 +1008,8 @@ export const RepoDecisionResponseSchema = z repoFullName: z.string(), generatedAt: z.string(), source: z.enum(["computed", "snapshot"]), + freshness: DecisionPackFreshnessSchema, + rebuildEnqueued: z.boolean(), decision: z.record(z.unknown()), dataQuality: z.record(z.unknown()), }) diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 78f9f7ad31..877f45589d 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -20,7 +20,7 @@ import { import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { fetchPublicContributorProfile } from "../github/public"; import { getOrCreateScoringModelSnapshot } from "../scoring/model"; -import { loadFreshContributorDecisionPack, repoDecisionFromPack, type ContributorDecisionPack, type DecisionAction, type RepoDecision } from "./decision-pack"; +import { loadContributorDecisionPackForServing, repoDecisionFromPack, type ContributorDecisionPack, type DecisionAction, type RepoDecision } from "./decision-pack"; import { summarizeAgentBundleWithAi } from "./ai-summaries"; import { buildContributorFit, buildContributorOutcomeHistory, buildContributorProfile, buildContributorScoringProfile } from "../signals/engine"; import { buildLocalBranchAnalysis, type LocalBranchAnalysis, type LocalBranchAnalysisInput } from "../signals/local-branch"; @@ -203,16 +203,22 @@ async function attachPrivateAiSummary(env: Env, bundle: AgentRunBundle): Promise async function executeDecisionPackRun(env: Env, run: AgentRunRecord, kind: string): Promise { const login = String(run.payload.login ?? run.actorLogin); const repoFullName = typeof run.payload.repoFullName === "string" ? run.payload.repoFullName : undefined; - const pack = await loadFreshContributorDecisionPack(env, login); - if (!pack) { - await env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login }); + const serving = await loadContributorDecisionPackForServing(env, login); + if (serving.kind === "needs_refresh") { await updateAgentRun(env, run.id, { status: "needs_snapshot_refresh", dataQualityStatus: "unknown", - payload: { ...run.payload, snapshotRefreshEnqueued: true, refreshReason: "missing_or_stale_decision_pack" }, + payload: { + ...run.payload, + snapshotRefreshEnqueued: serving.refresh.rebuildEnqueued, + refreshReason: "missing_decision_pack", + freshness: serving.refresh.freshness, + }, }); return (await getAgentRunBundle(env, run.id))!; } + const pack = serving.pack; + const isStale = pack.freshness !== "fresh"; const decisions = repoFullName ? pack.repoDecisions.filter((decision) => sameRepo(decision.repoFullName, repoFullName)) : pack.repoDecisions; const actions = kind === "explain_blockers" @@ -221,10 +227,18 @@ async function executeDecisionPackRun(env: Env, run: AgentRunRecord, kind: strin const contexts = [contextSnapshotFromPack(run.id, pack, decisions)]; await replaceAgentActions(env, run.id, actions); await persistAgentContextSnapshot(env, contexts[0]!); + const dataQualityStatus = isStale ? "degraded" : pack.dataQuality.signalFidelity.status; await updateAgentRun(env, run.id, { status: "completed", - dataQualityStatus: pack.dataQuality.signalFidelity.status, - payload: { ...run.payload, generatedAt: pack.generatedAt, actionCount: actions.length }, + dataQualityStatus, + payload: { + ...run.payload, + generatedAt: pack.generatedAt, + actionCount: actions.length, + freshness: pack.freshness, + snapshotRefreshEnqueued: pack.rebuildEnqueued, + ...(isStale ? { refreshReason: "stale_decision_pack" } : {}), + }, }); return (await getAgentRunBundle(env, run.id))!; } @@ -486,7 +500,16 @@ function actionRecord(args: { function contextSnapshotFromPack(runId: string, pack: ContributorDecisionPack, decisions: RepoDecision[]): AgentContextSnapshotRecord { const fidelity = pack.dataQuality.signalFidelity; + const ageSeconds = pack.snapshotAgeSeconds ?? null; + const ageNote = ageSeconds !== null ? ` (age ${ageSeconds}s)` : ""; + const freshnessWarning = + pack.freshness === "rebuilding" + ? `decision pack is stale${ageNote}; background rebuild enqueued` + : pack.freshness === "stale" + ? `decision pack is stale${ageNote}; rebuild not enqueued` + : null; const warnings = [ + ...(freshnessWarning ? [freshnessWarning] : []), ...fidelity.partialRepos.map((repo) => `${repo}: partial signal coverage`), ...fidelity.cappedRepos.map((repo) => `${repo}: capped signal coverage`), ...fidelity.staleRepos.map((repo) => `${repo}: stale signal coverage`), diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index 64e8aa625f..414d8919d1 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -34,6 +34,7 @@ export const DECISION_PACK_MAX_AGE_MS = 6 * 60 * 60 * 1000; export type DecisionRecommendation = "pursue" | "cleanup_first" | "maintainer_lane" | "avoid_for_now" | "watch"; export type DecisionActionKind = "cleanup_existing_prs" | "land_existing_prs" | "open_new_direct_pr" | "file_issue_discovery" | "maintainer_lane_improve_repo" | "maintainer_cut_readiness"; +export type DecisionPackFreshness = "fresh" | "stale" | "rebuilding" | "missing"; export type ContributorDecisionPack = { status: "ready"; @@ -42,6 +43,8 @@ export type ContributorDecisionPack = { generatedAt: string; snapshotAgeSeconds?: number | undefined; stale: boolean; + freshness: DecisionPackFreshness; + rebuildEnqueued: boolean; scoringModelSnapshotId: string; profile: { login: string; @@ -72,6 +75,8 @@ export type DecisionPackRefreshNeeded = { login: string; generatedAt: string; reason: "missing_snapshot" | "stale_snapshot"; + freshness: Extract; + rebuildEnqueued: boolean; enqueued: boolean; staleSnapshot?: { generatedAt: string; @@ -80,6 +85,10 @@ export type DecisionPackRefreshNeeded = { dataQuality?: ContributorDecisionPack["dataQuality"] | undefined; }; +export type ContributorDecisionPackServing = + | { kind: "ready"; pack: ContributorDecisionPack } + | { kind: "needs_refresh"; refresh: DecisionPackRefreshNeeded }; + export type RepoDecision = { repoFullName: string; recommendation: DecisionRecommendation; @@ -133,6 +142,54 @@ export async function loadFreshContributorDecisionPack(env: Env, login: string, return pack.stale || snapshotAgeMs(pack.generatedAt) > maxAgeMs ? null : pack; } +export async function loadContributorDecisionPackForServing( + env: Env, + login: string, + options: { maxAgeMs?: number; enqueueRebuild?: boolean } = {}, +): Promise { + const maxAgeMs = options.maxAgeMs ?? DECISION_PACK_MAX_AGE_MS; + const enqueueRebuild = options.enqueueRebuild ?? true; + const cached = await loadContributorDecisionPack(env, login); + if (!cached) { + const rebuildEnqueued = enqueueRebuild ? await tryEnqueueDecisionPackRebuild(env, login) : false; + return { + kind: "needs_refresh", + refresh: { + status: "needs_snapshot_refresh", + login, + generatedAt: nowIso(), + reason: "missing_snapshot", + freshness: "missing", + rebuildEnqueued, + enqueued: rebuildEnqueued, + }, + }; + } + const stale = cached.stale || snapshotAgeMs(cached.generatedAt) > maxAgeMs; + if (!stale) { + return { kind: "ready", pack: { ...cached, freshness: "fresh", rebuildEnqueued: false } }; + } + const rebuildEnqueued = enqueueRebuild ? await tryEnqueueDecisionPackRebuild(env, login) : false; + return { + kind: "ready", + pack: { + ...cached, + stale: true, + freshness: rebuildEnqueued ? "rebuilding" : "stale", + rebuildEnqueued, + }, + }; +} + +async function tryEnqueueDecisionPackRebuild(env: Env, login: string): Promise { + try { + await env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login }); + return true; + } catch { + return false; + } +} + export async function buildAndPersistContributorDecisionPack(env: Env, login: string): Promise { const [ github, @@ -267,6 +324,8 @@ function buildContributorDecisionPack(args: { login: args.login, generatedAt: nowIso(), stale: false, + freshness: "fresh", + rebuildEnqueued: false, scoringModelSnapshotId: args.scoringModelSnapshotId, profile: { login: args.profile.login, @@ -433,13 +492,16 @@ function withSnapshotMetadata(snapshot: SignalSnapshotRecord): ContributorDecisi const payload = snapshot.payload as unknown as ContributorDecisionPack; const generatedAt = snapshot.generatedAt ?? payload.generatedAt ?? nowIso(); const ageSeconds = Math.max(0, Math.floor(snapshotAgeMs(generatedAt) / 1000)); + const stale = snapshotAgeMs(generatedAt) > DECISION_PACK_MAX_AGE_MS; return { ...payload, status: "ready", source: "snapshot", generatedAt, snapshotAgeSeconds: ageSeconds, - stale: snapshotAgeMs(generatedAt) > DECISION_PACK_MAX_AGE_MS, + stale, + freshness: stale ? "stale" : "fresh", + rebuildEnqueued: false, }; } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 0857fca3b6..ea1cd82e62 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1556,31 +1556,47 @@ describe("api routes", () => { login: "stale-user", generatedAt: "2026-01-01T00:00:00.000Z", stale: false, + freshness: "fresh", + rebuildEnqueued: false, scoringModelSnapshotId: "scoring-1", profile: {}, outcomeHistory: {}, roleContexts: [], - repoDecisions: [], - topActions: [], + repoDecisions: [{ repoFullName: "owner/repo", recommendation: "pursue" }], + topActions: [{ actionKind: "open_new_direct_pr", repoFullName: "owner/repo", priorityScore: 50 }], cleanupFirst: [], - pursueRepos: [], + pursueRepos: [{ repoFullName: "owner/repo", recommendation: "pursue" }], avoidRepos: [], maintainerLaneRepos: [], scoreBlockers: [], dataQuality: { signalFidelity: { status: "degraded" } }, summary: "stale", - nextActions: [], + nextActions: ["pick a narrow change"], } as never, generatedAt: "2026-01-01T00:00:00.000Z", }); const staleDecisionPack = await app.request("/v1/contributors/stale-user/decision-pack", { headers: apiHeaders(env) }, env); - expect(staleDecisionPack.status).toBe(202); - await expect(staleDecisionPack.json()).resolves.toMatchObject({ - status: "needs_snapshot_refresh", - reason: "stale_snapshot", - staleSnapshot: { generatedAt: "2026-01-01T00:00:00.000Z" }, + expect(staleDecisionPack.status).toBe(200); + const staleBody = (await staleDecisionPack.json()) as { + status: string; + freshness: string; + rebuildEnqueued: boolean; + stale: boolean; + generatedAt: string; + topActions: unknown[]; + repoDecisions: unknown[]; + dataQuality: { signalFidelity: { status: string } }; + }; + expect(staleBody).toMatchObject({ + status: "ready", + freshness: "rebuilding", + rebuildEnqueued: true, + stale: true, + generatedAt: "2026-01-01T00:00:00.000Z", dataQuality: { signalFidelity: { status: "degraded" } }, }); + expect(staleBody.topActions.length).toBeGreaterThan(0); + expect(staleBody.repoDecisions.length).toBeGreaterThan(0); await persistSignalSnapshot(env, { id: "fresh-empty-pack", diff --git a/test/unit/agent-orchestrator.test.ts b/test/unit/agent-orchestrator.test.ts index 48444c712f..fd758f9e01 100644 --- a/test/unit/agent-orchestrator.test.ts +++ b/test/unit/agent-orchestrator.test.ts @@ -88,8 +88,11 @@ describe("agent orchestrator", () => { updatedAt: nowIso(), }; await createAgentRun(interruptedEnv, run); - const failed = await executeAgentRun(interruptedEnv, run.id); - expect(failed.run).toMatchObject({ status: "failed", errorSummary: "agent_run_failed" }); + const tolerated = await executeAgentRun(interruptedEnv, run.id); + expect(tolerated.run).toMatchObject({ + status: "needs_snapshot_refresh", + payload: expect.objectContaining({ snapshotRefreshEnqueued: false, freshness: "missing" }), + }); }); it("ranks decision-pack actions, persists context snapshots, and sanitizes public summaries", async () => { @@ -113,6 +116,38 @@ describe("agent orchestrator", () => { }); }); + it("serves a stale decision pack as a completed run with degraded data quality and a freshness warning", async () => { + const sent: unknown[] = []; + const env = createTestEnv({ + JOBS: { + async send(message: unknown) { + sent.push(message); + }, + } as unknown as Queue, + }); + const stalePack = decisionPackFixture({ generatedAt: "2026-01-01T00:00:00.000Z" }); + await persistSignalSnapshot(env, { + id: "stale-pack-orch", + signalType: CONTRIBUTOR_DECISION_PACK_SIGNAL, + targetKey: stalePack.login, + payload: stalePack as unknown as Record, + generatedAt: "2026-01-01T00:00:00.000Z", + }); + + const bundle = await planNextWork(env, { login: "oktofeesh1", repoFullName: "we-promise/sure" }); + + expect(bundle.run).toMatchObject({ + status: "completed", + dataQualityStatus: "degraded", + payload: expect.objectContaining({ freshness: "rebuilding", snapshotRefreshEnqueued: true, refreshReason: "stale_decision_pack" }), + }); + expect(bundle.actions.length).toBeGreaterThan(0); + expect(bundle.contextSnapshots[0]?.freshnessWarnings ?? []).toEqual( + expect.arrayContaining([expect.stringMatching(/^decision pack is stale.*background rebuild enqueued$/)]), + ); + expect(sent).toContainEqual({ type: "build-contributor-decision-packs", requestedBy: "api", login: "oktofeesh1" }); + }); + it("attaches optional Workers AI summaries when enabled", async () => { const env = createTestEnv({ AI: { run: vi.fn(async () => ({ response: "Clean up queue pressure before adding more work." })) } as unknown as Ai, diff --git a/test/unit/decision-pack.test.ts b/test/unit/decision-pack.test.ts index a3f1dc3de6..946af564b5 100644 --- a/test/unit/decision-pack.test.ts +++ b/test/unit/decision-pack.test.ts @@ -3,6 +3,7 @@ import { persistSignalSnapshot } from "../../src/db/repositories"; import { __decisionPackInternals, loadContributorDecisionPack, + loadContributorDecisionPackForServing, loadFreshContributorDecisionPack, repoDecisionFromPack, type ContributorDecisionPack, @@ -126,7 +127,7 @@ describe("decision-pack service", () => { }); const loaded = await loadContributorDecisionPack(env, "jsonbored"); - expect(loaded).toMatchObject({ source: "snapshot", snapshotAgeSeconds: expect.any(Number), stale: expect.any(Boolean) }); + expect(loaded).toMatchObject({ source: "snapshot", snapshotAgeSeconds: expect.any(Number), stale: expect.any(Boolean), freshness: "stale", rebuildEnqueued: false }); expect(repoDecisionFromPack(loaded!, "jsonbored/AWESOME-CLAUDE")).toMatchObject({ recommendation: "maintainer_lane" }); expect(repoDecisionFromPack(loaded!, "missing/repo")).toBeNull(); await expect(loadFreshContributorDecisionPack(env, "jsonbored", 1)).resolves.toBeNull(); @@ -165,6 +166,110 @@ describe("decision-pack service", () => { expect(__decisionPackInternals.snapshotAgeMs("not-a-date")).toBe(Number.POSITIVE_INFINITY); }); + it("serves fresh, stale, and missing decision packs with explicit freshness and rebuild signals", async () => { + const sends: Array> = []; + const env = createTestEnv({ + JOBS: { + async send(message: Record) { + sends.push(message); + }, + } as unknown as Queue, + }); + + const missing = await loadContributorDecisionPackForServing(env, "ghost-user"); + expect(missing).toMatchObject({ + kind: "needs_refresh", + refresh: { freshness: "missing", reason: "missing_snapshot", rebuildEnqueued: true, enqueued: true }, + }); + expect(sends.at(-1)).toMatchObject({ type: "build-contributor-decision-packs", login: "ghost-user" }); + + const stalePackPayload = { + status: "ready", + source: "computed", + login: "stale-user", + generatedAt: "2026-01-01T00:00:00.000Z", + stale: false, + freshness: "fresh", + rebuildEnqueued: false, + scoringModelSnapshotId: "scoring-1", + profile: {}, + outcomeHistory: {}, + roleContexts: [], + repoDecisions: [{ repoFullName: "owner/repo", recommendation: "pursue" }], + topActions: [{ actionKind: "open_new_direct_pr", repoFullName: "owner/repo", priorityScore: 50 }], + cleanupFirst: [], + pursueRepos: [{ repoFullName: "owner/repo", recommendation: "pursue" }], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: [], + dataQuality: { signalFidelity: { status: "complete", partialRepos: [], cappedRepos: [], staleRepos: [], rateLimitedRepos: [] } }, + summary: "stale fixture", + nextActions: ["pick a narrow change"], + } as unknown as ContributorDecisionPack; + + await persistSignalSnapshot(env, { + id: "stale-serving", + signalType: "contributor-decision-pack", + targetKey: "stale-user", + payload: stalePackPayload as unknown as Record, + generatedAt: "2026-01-01T00:00:00.000Z", + }); + + const stale = await loadContributorDecisionPackForServing(env, "stale-user"); + expect(stale.kind).toBe("ready"); + if (stale.kind === "ready") { + expect(stale.pack.freshness).toBe("rebuilding"); + expect(stale.pack.rebuildEnqueued).toBe(true); + expect(stale.pack.stale).toBe(true); + expect(stale.pack.topActions.length).toBeGreaterThan(0); + expect(stale.pack.repoDecisions.length).toBeGreaterThan(0); + } + expect(sends.filter((s) => s.login === "stale-user")).toHaveLength(1); + + const staleNoEnqueue = await loadContributorDecisionPackForServing(env, "stale-user", { enqueueRebuild: false }); + expect(staleNoEnqueue.kind).toBe("ready"); + if (staleNoEnqueue.kind === "ready") { + expect(staleNoEnqueue.pack.freshness).toBe("stale"); + expect(staleNoEnqueue.pack.rebuildEnqueued).toBe(false); + } + expect(sends.filter((s) => s.login === "stale-user")).toHaveLength(1); + + await persistSignalSnapshot(env, { + id: "fresh-serving", + signalType: "contributor-decision-pack", + targetKey: "fresh-user", + payload: { + ...stalePackPayload, + login: "fresh-user", + generatedAt: new Date(Date.now() - 60_000).toISOString(), + } as unknown as Record, + generatedAt: new Date(Date.now() - 60_000).toISOString(), + }); + + const sendsBefore = sends.length; + const fresh = await loadContributorDecisionPackForServing(env, "fresh-user"); + expect(fresh.kind).toBe("ready"); + if (fresh.kind === "ready") { + expect(fresh.pack.freshness).toBe("fresh"); + expect(fresh.pack.rebuildEnqueued).toBe(false); + expect(fresh.pack.stale).toBe(false); + } + expect(sends.length).toBe(sendsBefore); + + const enqueueErrorEnv = createTestEnv({ + JOBS: { + async send() { + throw new Error("queue down"); + }, + } as unknown as Queue, + }); + const missingNoEnqueue = await loadContributorDecisionPackForServing(enqueueErrorEnv, "any-user"); + expect(missingNoEnqueue).toMatchObject({ + kind: "needs_refresh", + refresh: { freshness: "missing", rebuildEnqueued: false, enqueued: false }, + }); + }); + it("builds a snapshot-style decision pack with maintainer, cleanup, pursue, watch, and avoid lanes", () => { const profile = { login: "jsonbored", From 9ec3b32841d012902e20a76a0ec5cfd532145efc Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 01:37:32 +0200 Subject: [PATCH 2/5] fix(agent): audit and surface queue-unavailable on decision-pack rebuild failures Maintainer-review fixups for #15: - tryEnqueueDecisionPackRebuild now records a decision_pack.rebuild_enqueue_failed audit event when JOBS.send throws, so a queue outage no longer silently disables agent.run_failed alerting. - executeDecisionPackRun emits distinct refreshReason values (queue_unavailable / missing_decision_pack / stale_decision_pack / stale_decision_pack_queue_unavailable) so an outage is distinguishable from "no snapshot yet". - DecisionPackRefreshNeeded and DecisionPackRefreshNeededSchema drop the unreachable reason: "stale_snapshot", freshness: "rebuilding", staleSnapshot, dataQuality, and enqueued fields. reason is now exactly "missing_snapshot" and freshness is exactly "missing". - OpenAPI 202 descriptions for both decision-pack routes updated to reflect the new contract; 200 description calls out freshness: "stale" | "rebuilding". - Unify on rebuildEnqueued across the orchestrator payload (dropped snapshotRefreshEnqueued alias) and across the refresh response (dropped enqueued alias). - Public GitHub bot comment (buildPublicAgentCommandComment) emits a one-line "snapshot is stale; rebuild requested" preface when the run's payload.freshness is not "fresh". - Drop unused loadFreshContributorDecisionPack (callers all moved to loadContributorDecisionPackForServing). Tests: - new perf-regression test asserts the serving path never invokes listContributorPullRequests/Issues/RepoStats/Repositories/SyncStates/ SyncSegments/LatestRepoGithubTotalsSnapshots. - new integration assertion covers the per-repo stale path (GET /v1/contributors/:login/repos/:owner/:repo/decision returns 200 with freshness: "rebuilding" and a decision body). - existing needs_snapshot_refresh integration assertions pinned to the new freshness + rebuildEnqueued fields. - interrupted-run test tightened: queue throw now asserts refreshReason: "queue_unavailable" AND a persisted decision_pack.rebuild_enqueue_failed audit row. - stale-orchestrator test pins dataQualityStatus: "degraded" against a fixture whose signalFidelity.status is "complete", so the assertion is no longer trivially satisfied by the fixture default. --- CHANGELOG.md | 2 + src/github/commands.ts | 3 ++ src/openapi/schemas.ts | 7 +--- src/openapi/spec.ts | 8 ++-- src/services/agent-orchestrator.ts | 10 +++-- src/services/decision-pack.ts | 26 +++++------- test/integration/api.test.ts | 28 +++++++++++-- test/unit/agent-orchestrator.test.ts | 31 ++++++++++++-- test/unit/decision-pack.test.ts | 62 +++++++++++++++++++++++++--- 9 files changed, 136 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa72737e48..aa907e956d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,5 +75,7 @@ - Ignore stale beta api origins +- Audit and surface queue-unavailable on decision-pack rebuild failures + diff --git a/src/github/commands.ts b/src/github/commands.ts index 9d5e4c8e2e..7700f526c9 100644 --- a/src/github/commands.ts +++ b/src/github/commands.ts @@ -112,9 +112,12 @@ function actionSections(bundle: AgentRunBundle | null | undefined): string[] { return ["**Next step**", "", "- No public-safe action is available from the current cached context."]; } const top = actions[0]!; + const freshness = typeof bundle?.run.payload?.freshness === "string" ? (bundle.run.payload.freshness as string) : "fresh"; + const stalePreface = freshness !== "fresh" ? ["_Decision snapshot is stale; a background rebuild has been requested._", ""] : []; return [ "**Recommended public-safe next step**", "", + ...stalePreface, `- ${top.publicSafeSummary}`, ...(top.blockedBy.length > 0 ? ["", "**Public readiness blockers**", "", ...top.blockedBy.slice(0, 4).map((item) => `- ${sanitizePublicComment(item)}`)] : []), ...(top.rerunWhen ? ["", "**Rerun when**", "", `- ${sanitizePublicComment(top.rerunWhen)}`] : []), diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index b250e84035..648c9cb06b 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -992,12 +992,9 @@ export const DecisionPackRefreshNeededSchema = z login: z.string(), repoFullName: z.string().optional(), generatedAt: z.string(), - reason: z.enum(["missing_snapshot", "stale_snapshot"]), - freshness: z.enum(["missing", "rebuilding"]), + reason: z.enum(["missing_snapshot"]), + freshness: z.enum(["missing"]), rebuildEnqueued: z.boolean(), - enqueued: z.boolean(), - staleSnapshot: z.object({ generatedAt: z.string(), ageSeconds: z.number() }).optional(), - dataQuality: z.record(z.unknown()).optional(), }) .openapi("DecisionPackRefreshNeeded"); diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index cdd74d7619..4632a0975c 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -268,18 +268,18 @@ export function buildOpenApiSpec() { path: "/v1/contributors/{login}/decision-pack", responses: { 200: { - description: "Canonical private contributor decision pack", + description: "Canonical private contributor decision pack. May carry freshness 'stale' or 'rebuilding' when a background rebuild is in progress.", content: { "application/json": { schema: ContributorDecisionPackSchema } }, }, - 202: { description: "Decision pack snapshot is missing or stale", content: { "application/json": { schema: DecisionPackRefreshNeededSchema } } }, + 202: { description: "Decision pack snapshot is missing; a background rebuild has been requested", content: { "application/json": { schema: DecisionPackRefreshNeededSchema } } }, }, }); registry.registerPath({ method: "get", path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision", responses: { - 200: { description: "Repo-specific contributor decision from decision pack", content: { "application/json": { schema: RepoDecisionResponseSchema } } }, - 202: { description: "Decision pack snapshot is missing or stale", content: { "application/json": { schema: DecisionPackRefreshNeededSchema } } }, + 200: { description: "Repo-specific contributor decision from decision pack. May carry freshness 'stale' or 'rebuilding'.", content: { "application/json": { schema: RepoDecisionResponseSchema } } }, + 202: { description: "Decision pack snapshot is missing; a background rebuild has been requested", content: { "application/json": { schema: DecisionPackRefreshNeededSchema } } }, }, }); registry.registerPath({ diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 877f45589d..b8986c508c 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -210,8 +210,8 @@ async function executeDecisionPackRun(env: Env, run: AgentRunRecord, kind: strin dataQualityStatus: "unknown", payload: { ...run.payload, - snapshotRefreshEnqueued: serving.refresh.rebuildEnqueued, - refreshReason: "missing_decision_pack", + rebuildEnqueued: serving.refresh.rebuildEnqueued, + refreshReason: serving.refresh.rebuildEnqueued ? "missing_decision_pack" : "queue_unavailable", freshness: serving.refresh.freshness, }, }); @@ -236,8 +236,10 @@ async function executeDecisionPackRun(env: Env, run: AgentRunRecord, kind: strin generatedAt: pack.generatedAt, actionCount: actions.length, freshness: pack.freshness, - snapshotRefreshEnqueued: pack.rebuildEnqueued, - ...(isStale ? { refreshReason: "stale_decision_pack" } : {}), + rebuildEnqueued: pack.rebuildEnqueued, + ...(isStale + ? { refreshReason: pack.rebuildEnqueued ? "stale_decision_pack" : "stale_decision_pack_queue_unavailable" } + : {}), }, }); return (await getAgentRunBundle(env, run.id))!; diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index 414d8919d1..b571652f69 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -8,6 +8,7 @@ import { listRepoSyncStates, listSignalSnapshots, persistSignalSnapshot, + recordAuditEvent, upsertContributorEvidence, upsertContributorScoringProfile, } from "../db/repositories"; @@ -74,15 +75,9 @@ export type DecisionPackRefreshNeeded = { status: "needs_snapshot_refresh"; login: string; generatedAt: string; - reason: "missing_snapshot" | "stale_snapshot"; - freshness: Extract; + reason: "missing_snapshot"; + freshness: Extract; rebuildEnqueued: boolean; - enqueued: boolean; - staleSnapshot?: { - generatedAt: string; - ageSeconds: number; - }; - dataQuality?: ContributorDecisionPack["dataQuality"] | undefined; }; export type ContributorDecisionPackServing = @@ -136,12 +131,6 @@ export async function loadContributorDecisionPack(env: Env, login: string): Prom return withSnapshotMetadata(latest); } -export async function loadFreshContributorDecisionPack(env: Env, login: string, maxAgeMs = DECISION_PACK_MAX_AGE_MS): Promise { - const pack = await loadContributorDecisionPack(env, login); - if (!pack) return null; - return pack.stale || snapshotAgeMs(pack.generatedAt) > maxAgeMs ? null : pack; -} - export async function loadContributorDecisionPackForServing( env: Env, login: string, @@ -161,7 +150,6 @@ export async function loadContributorDecisionPackForServing( reason: "missing_snapshot", freshness: "missing", rebuildEnqueued, - enqueued: rebuildEnqueued, }, }; } @@ -185,7 +173,13 @@ async function tryEnqueueDecisionPackRebuild(env: Env, login: string): Promise undefined); return false; } } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index ea1cd82e62..4d2dd8055e 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -311,7 +311,13 @@ describe("api routes", () => { const missingDecisionPack = await app.request("/v1/contributors/oktofeesh1/decision-pack", { headers: apiHeaders(env) }, env); expect(missingDecisionPack.status).toBe(202); - await expect(missingDecisionPack.json()).resolves.toMatchObject({ status: "needs_snapshot_refresh", login: "oktofeesh1", enqueued: true }); + await expect(missingDecisionPack.json()).resolves.toMatchObject({ + status: "needs_snapshot_refresh", + login: "oktofeesh1", + reason: "missing_snapshot", + freshness: "missing", + rebuildEnqueued: true, + }); const builtDecisionPack = await app.request( "/v1/internal/jobs/build-contributor-decision-packs/run", @@ -371,7 +377,12 @@ describe("api routes", () => { const missingRepoDecisionSnapshot = await app.request("/v1/contributors/new-user/repos/entrius/allways-ui/decision", { headers: apiHeaders(env) }, env); expect(missingRepoDecisionSnapshot.status).toBe(202); - await expect(missingRepoDecisionSnapshot.json()).resolves.toMatchObject({ status: "needs_snapshot_refresh", repoFullName: "entrius/allways-ui" }); + await expect(missingRepoDecisionSnapshot.json()).resolves.toMatchObject({ + status: "needs_snapshot_refresh", + repoFullName: "entrius/allways-ui", + freshness: "missing", + rebuildEnqueued: true, + }); for (const path of [ "/v1/contributors/oktofeesh1/opportunities", @@ -1080,7 +1091,7 @@ describe("api routes", () => { expect(response.status).toBe(200); const payload = (await mcpJson(response)) as { result: { structuredContent: Record } }; if (name === "gittensory_get_contributor_profile") expect(payload.result.structuredContent).toMatchObject({ login: "unknown-user" }); - else expect(payload.result.structuredContent).toMatchObject({ status: "needs_snapshot_refresh", enqueued: true }); + else expect(payload.result.structuredContent).toMatchObject({ status: "needs_snapshot_refresh", freshness: "missing", rebuildEnqueued: true }); } const missingRepoDecision = await app.request( @@ -1598,6 +1609,17 @@ describe("api routes", () => { expect(staleBody.topActions.length).toBeGreaterThan(0); expect(staleBody.repoDecisions.length).toBeGreaterThan(0); + const staleRepoDecision = await app.request("/v1/contributors/stale-user/repos/owner/repo/decision", { headers: apiHeaders(env) }, env); + expect(staleRepoDecision.status).toBe(200); + await expect(staleRepoDecision.json()).resolves.toMatchObject({ + status: "ready", + login: "stale-user", + repoFullName: "owner/repo", + freshness: "rebuilding", + rebuildEnqueued: true, + decision: { repoFullName: "owner/repo", recommendation: "pursue" }, + }); + await persistSignalSnapshot(env, { id: "fresh-empty-pack", signalType: "contributor-decision-pack", diff --git a/test/unit/agent-orchestrator.test.ts b/test/unit/agent-orchestrator.test.ts index fd758f9e01..58b6d3a43b 100644 --- a/test/unit/agent-orchestrator.test.ts +++ b/test/unit/agent-orchestrator.test.ts @@ -91,8 +91,18 @@ describe("agent orchestrator", () => { const tolerated = await executeAgentRun(interruptedEnv, run.id); expect(tolerated.run).toMatchObject({ status: "needs_snapshot_refresh", - payload: expect.objectContaining({ snapshotRefreshEnqueued: false, freshness: "missing" }), + payload: expect.objectContaining({ + rebuildEnqueued: false, + refreshReason: "queue_unavailable", + freshness: "missing", + }), }); + const auditRows = ((await interruptedEnv.DB.prepare("SELECT event_type, actor, outcome FROM audit_events").all()) as { results: Array<{ event_type: string; actor: string | null; outcome: string | null }> }).results; + expect(auditRows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ event_type: "decision_pack.rebuild_enqueue_failed", actor: "oktofeesh1", outcome: "error" }), + ]), + ); }); it("ranks decision-pack actions, persists context snapshots, and sanitizes public summaries", async () => { @@ -125,7 +135,22 @@ describe("agent orchestrator", () => { }, } as unknown as Queue, }); - const stalePack = decisionPackFixture({ generatedAt: "2026-01-01T00:00:00.000Z" }); + const stalePack = decisionPackFixture({ + generatedAt: "2026-01-01T00:00:00.000Z", + dataQuality: { + signalFidelity: { + status: "complete", + repoCount: 1, + completeRepos: 1, + degradedRepos: 0, + blockedRepos: 0, + partialRepos: [], + cappedRepos: [], + staleRepos: [], + rateLimitedRepos: [], + }, + } as unknown as ContributorDecisionPack["dataQuality"], + }); await persistSignalSnapshot(env, { id: "stale-pack-orch", signalType: CONTRIBUTOR_DECISION_PACK_SIGNAL, @@ -139,7 +164,7 @@ describe("agent orchestrator", () => { expect(bundle.run).toMatchObject({ status: "completed", dataQualityStatus: "degraded", - payload: expect.objectContaining({ freshness: "rebuilding", snapshotRefreshEnqueued: true, refreshReason: "stale_decision_pack" }), + payload: expect.objectContaining({ freshness: "rebuilding", rebuildEnqueued: true, refreshReason: "stale_decision_pack" }), }); expect(bundle.actions.length).toBeGreaterThan(0); expect(bundle.contextSnapshots[0]?.freshnessWarnings ?? []).toEqual( diff --git a/test/unit/decision-pack.test.ts b/test/unit/decision-pack.test.ts index 946af564b5..a5d336d3be 100644 --- a/test/unit/decision-pack.test.ts +++ b/test/unit/decision-pack.test.ts @@ -1,10 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { persistSignalSnapshot } from "../../src/db/repositories"; import { __decisionPackInternals, loadContributorDecisionPack, loadContributorDecisionPackForServing, - loadFreshContributorDecisionPack, repoDecisionFromPack, type ContributorDecisionPack, type RepoDecision, @@ -130,8 +129,6 @@ describe("decision-pack service", () => { expect(loaded).toMatchObject({ source: "snapshot", snapshotAgeSeconds: expect.any(Number), stale: expect.any(Boolean), freshness: "stale", rebuildEnqueued: false }); expect(repoDecisionFromPack(loaded!, "jsonbored/AWESOME-CLAUDE")).toMatchObject({ recommendation: "maintainer_lane" }); expect(repoDecisionFromPack(loaded!, "missing/repo")).toBeNull(); - await expect(loadFreshContributorDecisionPack(env, "jsonbored", 1)).resolves.toBeNull(); - await expect(loadFreshContributorDecisionPack(env, "missing", 1)).resolves.toBeNull(); expect(__decisionPackInternals.sanitizeOfficialStats({ gittensor: null } as any)).toBeNull(); expect(__decisionPackInternals.sanitizeOfficialStats({ gittensor: { hotkey: "secret", totalMergedPrs: 5 } } as any)).toEqual({ totalMergedPrs: 5 }); @@ -179,8 +176,9 @@ describe("decision-pack service", () => { const missing = await loadContributorDecisionPackForServing(env, "ghost-user"); expect(missing).toMatchObject({ kind: "needs_refresh", - refresh: { freshness: "missing", reason: "missing_snapshot", rebuildEnqueued: true, enqueued: true }, + refresh: { freshness: "missing", reason: "missing_snapshot", rebuildEnqueued: true }, }); + expect(missing.kind === "needs_refresh" && "enqueued" in missing.refresh).toBe(false); expect(sends.at(-1)).toMatchObject({ type: "build-contributor-decision-packs", login: "ghost-user" }); const stalePackPayload = { @@ -266,10 +264,62 @@ describe("decision-pack service", () => { const missingNoEnqueue = await loadContributorDecisionPackForServing(enqueueErrorEnv, "any-user"); expect(missingNoEnqueue).toMatchObject({ kind: "needs_refresh", - refresh: { freshness: "missing", rebuildEnqueued: false, enqueued: false }, + refresh: { freshness: "missing", rebuildEnqueued: false }, }); }); + it("does not call broad contributor or repo listers on the serving path", async () => { + const env = createTestEnv(); + const broadListers = await import("../../src/db/repositories"); + const spies = [ + vi.spyOn(broadListers, "listContributorPullRequests"), + vi.spyOn(broadListers, "listContributorIssues"), + vi.spyOn(broadListers, "listContributorRepoStats"), + vi.spyOn(broadListers, "listRepositories"), + vi.spyOn(broadListers, "listRepoSyncStates"), + vi.spyOn(broadListers, "listRepoSyncSegments"), + vi.spyOn(broadListers, "listLatestRepoGithubTotalsSnapshots"), + ]; + + await loadContributorDecisionPackForServing(env, "ghost-user"); + + await persistSignalSnapshot(env, { + id: "perf-stale-pack", + signalType: "contributor-decision-pack", + targetKey: "perf-user", + payload: { + status: "ready", + source: "computed", + login: "perf-user", + generatedAt: "2026-01-01T00:00:00.000Z", + stale: false, + freshness: "fresh", + rebuildEnqueued: false, + scoringModelSnapshotId: "scoring-1", + profile: {}, + outcomeHistory: {}, + roleContexts: [], + repoDecisions: [], + topActions: [], + cleanupFirst: [], + pursueRepos: [], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: [], + dataQuality: { signalFidelity: { status: "degraded" } }, + summary: "stale", + nextActions: [], + } as unknown as Record, + generatedAt: "2026-01-01T00:00:00.000Z", + }); + await loadContributorDecisionPackForServing(env, "perf-user"); + + for (const spy of spies) { + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + } + }); + it("builds a snapshot-style decision pack with maintainer, cleanup, pursue, watch, and avoid lanes", () => { const profile = { login: "jsonbored", From bebac90eccb6a375983be2a7a1e874e96bfe31ae Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 07:16:32 +0200 Subject: [PATCH 3/5] fix(agent): debounce stale-pack rebuild enqueues via audit log Repeated stale-pack requests for the same login no longer enqueue a fresh job; tryEnqueueDecisionPackRebuild checks audit_events for a decision_pack.rebuild_enqueued entry within DECISION_PACK_REBUILD_DEBOUNCE_MS (15s) and short-circuits when one exists. Success path now records a decision_pack.rebuild_enqueued audit event so the debounce has a signal to read. Adds hasRecentAuditEvent in repositories.ts (indexed lookup on audit_events_actor_created_idx) and uses it as the dedupe gate. --- CHANGELOG.md | 2 + src/db/repositories.ts | 10 +++ src/services/decision-pack.ts | 15 +++- test/unit/decision-pack.test.ts | 152 ++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa907e956d..601223bdab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,5 +77,7 @@ - Audit and surface queue-unavailable on decision-pack rebuild failures +- Debounce stale-pack rebuild enqueues via audit log + diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 68b27837b2..feaa40d47a 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -735,6 +735,16 @@ export async function recordAuditEvent(env: Env, event: AuditEventRecord): Promi }); } +export async function hasRecentAuditEvent(env: Env, actor: string, eventType: string, sinceIso: string): Promise { + const db = getDb(env.DB); + const rows = await db + .select({ id: auditEvents.id }) + .from(auditEvents) + .where(and(eq(auditEvents.actor, actor), eq(auditEvents.eventType, eventType), gte(auditEvents.createdAt, sinceIso))) + .limit(1); + return rows.length > 0; +} + export async function recordAiUsageEvent( env: Env, event: { diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index b571652f69..d795904a37 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -1,4 +1,5 @@ import { + hasRecentAuditEvent, listContributorIssues, listContributorPullRequests, listContributorRepoStats, @@ -32,6 +33,7 @@ import { nowIso } from "../utils/json"; export const CONTRIBUTOR_DECISION_PACK_SIGNAL = "contributor-decision-pack"; export const DECISION_PACK_MAX_AGE_MS = 6 * 60 * 60 * 1000; +export const DECISION_PACK_REBUILD_DEBOUNCE_MS = 15 * 1000; export type DecisionRecommendation = "pursue" | "cleanup_first" | "maintainer_lane" | "avoid_for_now" | "watch"; export type DecisionActionKind = "cleanup_existing_prs" | "land_existing_prs" | "open_new_direct_pr" | "file_issue_discovery" | "maintainer_lane_improve_repo" | "maintainer_cut_readiness"; @@ -170,16 +172,25 @@ export async function loadContributorDecisionPackForServing( } async function tryEnqueueDecisionPackRebuild(env: Env, login: string): Promise { + const sinceIso = new Date(Date.now() - DECISION_PACK_REBUILD_DEBOUNCE_MS).toISOString(); + if (await hasRecentAuditEvent(env, login, "decision_pack.rebuild_enqueued", sinceIso)) { + return true; + } try { await env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login }); + await recordAuditEvent(env, { + eventType: "decision_pack.rebuild_enqueued", + actor: login, + outcome: "queued", + }); return true; } catch (error) { await recordAuditEvent(env, { eventType: "decision_pack.rebuild_enqueue_failed", actor: login, outcome: "error", - detail: error instanceof Error ? error.message : String(error), - }).catch(() => undefined); + detail: String(error), + }); return false; } } diff --git a/test/unit/decision-pack.test.ts b/test/unit/decision-pack.test.ts index a5d336d3be..20f7ebeb68 100644 --- a/test/unit/decision-pack.test.ts +++ b/test/unit/decision-pack.test.ts @@ -320,6 +320,158 @@ describe("decision-pack service", () => { } }); + it("debounces repeated stale-pack rebuild requests via the audit log", async () => { + const sends: Array> = []; + const env = createTestEnv({ + JOBS: { + async send(message: Record) { + sends.push(message); + }, + } as unknown as Queue, + }); + await persistSignalSnapshot(env, { + id: "debounce-stale", + signalType: "contributor-decision-pack", + targetKey: "hot-user", + payload: { + status: "ready", + source: "computed", + login: "hot-user", + generatedAt: "2026-01-01T00:00:00.000Z", + stale: false, + freshness: "fresh", + rebuildEnqueued: false, + scoringModelSnapshotId: "scoring-1", + profile: {}, + outcomeHistory: {}, + roleContexts: [], + repoDecisions: [], + topActions: [], + cleanupFirst: [], + pursueRepos: [], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: [], + dataQuality: { signalFidelity: { status: "complete" } }, + summary: "stale", + nextActions: [], + } as unknown as Record, + generatedAt: "2026-01-01T00:00:00.000Z", + }); + + for (let i = 0; i < 5; i++) { + const result = await loadContributorDecisionPackForServing(env, "hot-user"); + expect(result.kind).toBe("ready"); + if (result.kind === "ready") { + expect(result.pack.freshness).toBe("rebuilding"); + expect(result.pack.rebuildEnqueued).toBe(true); + } + } + expect(sends.filter((s) => s.login === "hot-user")).toHaveLength(1); + }); + + it("returns freshness:missing with rebuildEnqueued:false when enqueueRebuild is disabled and no snapshot exists", async () => { + const sends: Array> = []; + const env = createTestEnv({ + JOBS: { + async send(message: Record) { + sends.push(message); + }, + } as unknown as Queue, + }); + const result = await loadContributorDecisionPackForServing(env, "ghost", { enqueueRebuild: false }); + expect(result.kind).toBe("needs_refresh"); + if (result.kind === "needs_refresh") { + expect(result.refresh.rebuildEnqueued).toBe(false); + expect(result.refresh.freshness).toBe("missing"); + } + expect(sends).toHaveLength(0); + }); + + it("covers scoreBlockersFor and withSnapshotMetadata fallback branches", () => { + const noOutcomeBlockers = __decisionPackInternals.scoreBlockersFor("owner/x", "direct_pr", { maintainerLane: false } as any, undefined); + expect(noOutcomeBlockers.map((b) => b.code)).not.toContain("open_pr_pressure"); + expect(noOutcomeBlockers.map((b) => b.code)).not.toContain("closed_pr_credibility"); + expect(noOutcomeBlockers.map((b) => b.code)).not.toContain("low_credibility"); + + const fellbackToNow = __decisionPackInternals.withSnapshotMetadata({ + id: "snap-both-null", + signalType: "contributor-decision-pack", + targetKey: "user", + generatedAt: null, + payload: { status: "ready", source: "computed", login: "user", repoDecisions: [], topActions: [] } as any, + }); + expect(typeof fellbackToNow.generatedAt).toBe("string"); + expect(fellbackToNow.generatedAt.length).toBeGreaterThan(0); + }); + + it("records non-Error queue failures with String(error) detail", async () => { + const env = createTestEnv({ + JOBS: { + async send() { + throw "queue offline string"; + }, + } as unknown as Queue, + }); + const result = await loadContributorDecisionPackForServing(env, "string-throw-user"); + expect(result.kind).toBe("needs_refresh"); + if (result.kind === "needs_refresh") { + expect(result.refresh.rebuildEnqueued).toBe(false); + } + const rows = ((await env.DB.prepare("SELECT detail FROM audit_events WHERE event_type='decision_pack.rebuild_enqueue_failed'").all()) as { results: Array<{ detail: string }> }).results; + expect(rows[0]?.detail).toContain("queue offline string"); + }); + + it("returns freshness:stale with rebuildEnqueued:false when a stale pack is served and the queue throws", async () => { + const env = createTestEnv({ + JOBS: { + async send() { + throw new Error("queue offline"); + }, + } as unknown as Queue, + }); + await persistSignalSnapshot(env, { + id: "stale-queue-down", + signalType: "contributor-decision-pack", + targetKey: "queue-down-user", + payload: { + status: "ready", + source: "computed", + login: "queue-down-user", + generatedAt: "2026-01-01T00:00:00.000Z", + stale: false, + freshness: "fresh", + rebuildEnqueued: false, + scoringModelSnapshotId: "scoring-1", + profile: {}, + outcomeHistory: {}, + roleContexts: [], + repoDecisions: [{ repoFullName: "owner/r", recommendation: "pursue" }], + topActions: [{ actionKind: "open_new_direct_pr", repoFullName: "owner/r", priorityScore: 1 }], + cleanupFirst: [], + pursueRepos: [], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: [], + dataQuality: { signalFidelity: { status: "complete" } }, + summary: "stale", + nextActions: [], + } as unknown as Record, + generatedAt: "2026-01-01T00:00:00.000Z", + }); + + const result = await loadContributorDecisionPackForServing(env, "queue-down-user"); + expect(result.kind).toBe("ready"); + if (result.kind === "ready") { + expect(result.pack.freshness).toBe("stale"); + expect(result.pack.rebuildEnqueued).toBe(false); + expect(result.pack.topActions.length).toBeGreaterThan(0); + } + const auditRows = ((await env.DB.prepare("SELECT event_type FROM audit_events").all()) as { results: Array<{ event_type: string }> }).results; + expect(auditRows.map((r) => r.event_type)).toContain("decision_pack.rebuild_enqueue_failed"); + expect(auditRows.map((r) => r.event_type)).not.toContain("decision_pack.rebuild_enqueued"); + }); + it("builds a snapshot-style decision pack with maintainer, cleanup, pursue, watch, and avoid lanes", () => { const profile = { login: "jsonbored", From 8bf0bf7534879c517f0283c45688380e4fbbcc91 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 07:24:23 +0200 Subject: [PATCH 4/5] test(routes): cover internal job error paths Adds error-path coverage for backfill-pr-details (no body, empty repoFullName) and the json-parse .catch arm on build-contributor-evidence and build-contributor-decision-packs. --- test/integration/routes-errors.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts index 02958ffb9e..f63acef72d 100644 --- a/test/integration/routes-errors.test.ts +++ b/test/integration/routes-errors.test.ts @@ -428,6 +428,12 @@ describe("api route guards and error branches", () => { body: JSON.stringify({ repoFullName: "JSONbored/gittensory" }), }, env); expect(queuedForecasts.status).toBe(202); + + expect((await app.request("/v1/internal/jobs/backfill-pr-details", { method: "POST", headers: internalHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/internal/jobs/backfill-pr-details", { method: "POST", headers: internalHeaders(env), body: JSON.stringify({ repoFullName: "" }) }, env)).status).toBe(400); + expect((await app.request("/v1/internal/jobs/backfill-pr-details/run", { method: "POST", headers: internalHeaders(env), body: "{}" }, env)).status).toBe(400); + expect((await app.request("/v1/internal/jobs/build-contributor-decision-packs", { method: "POST", headers: internalHeaders(env), body: "not-json" }, env)).status).toBe(202); + expect((await app.request("/v1/internal/jobs/build-contributor-evidence", { method: "POST", headers: internalHeaders(env), body: "not-json" }, env)).status).toBe(202); expect(queued).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "refresh-scoring-model" }), From d2dd9edffe98094d6395a1db445e41075a5c93ab Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 07:44:12 +0200 Subject: [PATCH 5/5] fix(agent): address stale decision-pack review findings --- CHANGELOG.md | 8 ---- src/github/commands.ts | 3 -- src/mcp/server.ts | 11 +++-- src/services/decision-pack.ts | 19 +++++++- test/integration/api.test.ts | 75 +++++++++++++++++++++++++++++++ test/unit/decision-pack.test.ts | 31 ++++++++++++- test/unit/github-commands.test.ts | 4 +- 7 files changed, 131 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e3c41a7c9..4a50086ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,8 +53,6 @@ - Add deterministic base-agent orchestrator (#14) -- Serve stale decision packs with freshness marker and background rebuild - ### Fixes @@ -77,9 +75,3 @@ - Ignore stale beta api origins -- Audit and surface queue-unavailable on decision-pack rebuild failures - -- Debounce stale-pack rebuild enqueues via audit log - - - diff --git a/src/github/commands.ts b/src/github/commands.ts index 7700f526c9..9d5e4c8e2e 100644 --- a/src/github/commands.ts +++ b/src/github/commands.ts @@ -112,12 +112,9 @@ function actionSections(bundle: AgentRunBundle | null | undefined): string[] { return ["**Next step**", "", "- No public-safe action is available from the current cached context."]; } const top = actions[0]!; - const freshness = typeof bundle?.run.payload?.freshness === "string" ? (bundle.run.payload.freshness as string) : "fresh"; - const stalePreface = freshness !== "fresh" ? ["_Decision snapshot is stale; a background rebuild has been requested._", ""] : []; return [ "**Recommended public-safe next step**", "", - ...stalePreface, `- ${top.publicSafeSummary}`, ...(top.blockedBy.length > 0 ? ["", "**Public readiness blockers**", "", ...top.blockedBy.slice(0, 4).map((item) => `- ${sanitizePublicComment(item)}`)] : []), ...(top.rerunWhen ? ["", "**Rerun when**", "", `- ${sanitizePublicComment(top.rerunWhen)}`] : []), diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8d3d268953..f85179f4d5 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -60,6 +60,12 @@ type ToolPayload = { data: Record; }; +function decisionPackSummary(login: string, freshness: string, rebuildEnqueued: boolean): string { + if (freshness === "fresh") return `Gittensory decision pack for ${login}.`; + if (rebuildEnqueued) return `Gittensory decision pack for ${login} (stale; background rebuild enqueued).`; + return `Gittensory decision pack for ${login} (stale; rebuild not enqueued).`; +} + const ownerRepoShape = { owner: z.string().min(1), repo: z.string().min(1), @@ -511,11 +517,8 @@ export class GittensoryMcp { private async getDecisionPack(login: string): Promise { const serving = await loadContributorDecisionPackForServing(this.env, login); if (serving.kind === "ready") { - const stale = serving.pack.freshness !== "fresh"; return { - summary: stale - ? `Gittensory decision pack for ${login} (stale; background rebuild enqueued).` - : `Gittensory decision pack for ${login}.`, + summary: decisionPackSummary(login, serving.pack.freshness, serving.pack.rebuildEnqueued), data: serving.pack as unknown as Record, }; } diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index d795904a37..0e155e038e 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -34,6 +34,7 @@ import { nowIso } from "../utils/json"; export const CONTRIBUTOR_DECISION_PACK_SIGNAL = "contributor-decision-pack"; export const DECISION_PACK_MAX_AGE_MS = 6 * 60 * 60 * 1000; export const DECISION_PACK_REBUILD_DEBOUNCE_MS = 15 * 1000; +const pendingDecisionPackRebuilds = new Map>(); export type DecisionRecommendation = "pursue" | "cleanup_first" | "maintainer_lane" | "avoid_for_now" | "watch"; export type DecisionActionKind = "cleanup_existing_prs" | "land_existing_prs" | "open_new_direct_pr" | "file_issue_discovery" | "maintainer_lane_improve_repo" | "maintainer_cut_readiness"; @@ -172,10 +173,22 @@ export async function loadContributorDecisionPackForServing( } async function tryEnqueueDecisionPackRebuild(env: Env, login: string): Promise { + const pending = pendingDecisionPackRebuilds.get(login); + if (pending) return pending; const sinceIso = new Date(Date.now() - DECISION_PACK_REBUILD_DEBOUNCE_MS).toISOString(); if (await hasRecentAuditEvent(env, login, "decision_pack.rebuild_enqueued", sinceIso)) { return true; } + const existing = pendingDecisionPackRebuilds.get(login); + if (existing) return existing; + const rebuild = enqueueDecisionPackRebuild(env, login).finally(() => { + pendingDecisionPackRebuilds.delete(login); + }); + pendingDecisionPackRebuilds.set(login, rebuild); + return rebuild; +} + +async function enqueueDecisionPackRebuild(env: Env, login: string): Promise { try { await env.JOBS.send({ type: "build-contributor-decision-packs", requestedBy: "api", login }); await recordAuditEvent(env, { @@ -406,11 +419,13 @@ function buildRepoDecision(args: { function scoreBlockersFor(repoFullName: string, lane: string, roleContext: RoleContext, outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): ScoreBlocker[] { const blockers: ScoreBlocker[] = []; + const openPullRequests = outcome?.openPullRequests ?? 0; + const closedPullRequestRate = outcome?.closedPullRequestRate ?? 0; if (roleContext.maintainerLane) blockers.push({ code: "maintainer_lane", repoFullName, severity: "info", detail: "Maintainer-lane activity is separate from normal outside-contributor reward evidence." }); if (lane === "inactive" || lane === "unknown") blockers.push({ code: "inactive_or_unknown_lane", repoFullName, severity: "critical", detail: "The repo lane is inactive or unknown in the current registry snapshot." }); if (lane === "issue_discovery") blockers.push({ code: "issue_discovery_only", repoFullName, severity: "warning", detail: "This repo is issue-discovery-only; direct PR reward/risk reasoning is not applicable." }); - if ((outcome?.openPullRequests ?? 0) >= 5) blockers.push({ code: "open_pr_pressure", repoFullName, severity: "critical", detail: `${outcome?.openPullRequests ?? 0} open PR(s) create scoreability and review-pressure risk.` }); - if ((outcome?.closedPullRequestRate ?? 0) >= 0.35) blockers.push({ code: "closed_pr_credibility", repoFullName, severity: "warning", detail: `Closed PR rate is ${Math.round((outcome?.closedPullRequestRate ?? 0) * 100)}%.` }); + if (openPullRequests >= 5) blockers.push({ code: "open_pr_pressure", repoFullName, severity: "critical", detail: `${openPullRequests} open PR(s) create scoreability and review-pressure risk.` }); + if (closedPullRequestRate >= 0.35) blockers.push({ code: "closed_pr_credibility", repoFullName, severity: "warning", detail: `Closed PR rate is ${Math.round(closedPullRequestRate * 100)}%.` }); if (outcome && !outcome.maintainerLane && outcome.credibility > 0 && outcome.credibility < 0.8) blockers.push({ code: "low_credibility", repoFullName, severity: "warning", detail: `Official repo credibility is ${round(outcome.credibility)}.` }); return blockers; } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 4d2dd8055e..e74e8bef56 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1620,6 +1620,81 @@ describe("api routes", () => { decision: { repoFullName: "owner/repo", recommendation: "pursue" }, }); + const staleMcpQueued = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ + jsonrpc: "2.0", + id: "stale-mcp-queued", + method: "tools/call", + params: { name: "gittensory_get_decision_pack", arguments: { login: "stale-user" } }, + }), + }, + env, + ); + expect(staleMcpQueued.status).toBe(200); + const staleMcpQueuedPayload = (await mcpJson(staleMcpQueued)) as { result: { structuredContent: { freshness: string; rebuildEnqueued: boolean }; content: Array<{ text: string }> } }; + expect(staleMcpQueuedPayload.result.structuredContent).toMatchObject({ freshness: "rebuilding", rebuildEnqueued: true }); + expect(staleMcpQueuedPayload.result.content[0]?.text).toContain("background rebuild enqueued"); + + const queueDownEnv = createTestEnv({ + JOBS: { + async send() { + throw new Error("queue offline"); + }, + } as unknown as Queue, + }); + await persistSignalSnapshot(queueDownEnv, { + id: "stale-mcp-queue-down", + signalType: "contributor-decision-pack", + targetKey: "mcp-stale-user", + payload: { + status: "ready", + source: "computed", + login: "mcp-stale-user", + generatedAt: "2026-01-01T00:00:00.000Z", + stale: false, + freshness: "fresh", + rebuildEnqueued: false, + scoringModelSnapshotId: "scoring-1", + profile: {}, + outcomeHistory: {}, + roleContexts: [], + repoDecisions: [{ repoFullName: "owner/repo", recommendation: "pursue" }], + topActions: [{ actionKind: "open_new_direct_pr", repoFullName: "owner/repo", priorityScore: 50 }], + cleanupFirst: [], + pursueRepos: [{ repoFullName: "owner/repo", recommendation: "pursue" }], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: [], + dataQuality: { signalFidelity: { status: "complete" } }, + summary: "stale", + nextActions: ["pick a narrow change"], + } as never, + generatedAt: "2026-01-01T00:00:00.000Z", + }); + const staleMcp = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(queueDownEnv), + body: JSON.stringify({ + jsonrpc: "2.0", + id: "stale-mcp-queue-down", + method: "tools/call", + params: { name: "gittensory_get_decision_pack", arguments: { login: "mcp-stale-user" } }, + }), + }, + queueDownEnv, + ); + expect(staleMcp.status).toBe(200); + const staleMcpPayload = (await mcpJson(staleMcp)) as { result: { structuredContent: { freshness: string; rebuildEnqueued: boolean }; content: Array<{ text: string }> } }; + expect(staleMcpPayload.result.structuredContent).toMatchObject({ freshness: "stale", rebuildEnqueued: false }); + expect(staleMcpPayload.result.content[0]?.text).toContain("rebuild not enqueued"); + expect(staleMcpPayload.result.content[0]?.text).not.toContain("background rebuild enqueued"); + await persistSignalSnapshot(env, { id: "fresh-empty-pack", signalType: "contributor-decision-pack", diff --git a/test/unit/decision-pack.test.ts b/test/unit/decision-pack.test.ts index 20f7ebeb68..bb510e6036 100644 --- a/test/unit/decision-pack.test.ts +++ b/test/unit/decision-pack.test.ts @@ -322,10 +322,20 @@ describe("decision-pack service", () => { it("debounces repeated stale-pack rebuild requests via the audit log", async () => { const sends: Array> = []; + let releaseSend!: () => void; + let markSendStarted!: () => void; + const sendStarted = new Promise((resolve) => { + markSendStarted = resolve; + }); + const sendReleased = new Promise((resolve) => { + releaseSend = resolve; + }); const env = createTestEnv({ JOBS: { async send(message: Record) { sends.push(message); + markSendStarted(); + await sendReleased; }, } as unknown as Queue, }); @@ -359,8 +369,13 @@ describe("decision-pack service", () => { generatedAt: "2026-01-01T00:00:00.000Z", }); - for (let i = 0; i < 5; i++) { - const result = await loadContributorDecisionPackForServing(env, "hot-user"); + const first = loadContributorDecisionPackForServing(env, "hot-user"); + const racing = Array.from({ length: 3 }, () => loadContributorDecisionPackForServing(env, "hot-user")); + await sendStarted; + const joined = Array.from({ length: 2 }, () => loadContributorDecisionPackForServing(env, "hot-user")); + releaseSend(); + const results = await Promise.all([first, ...racing, ...joined]); + for (const result of results) { expect(result.kind).toBe("ready"); if (result.kind === "ready") { expect(result.pack.freshness).toBe("rebuilding"); @@ -368,6 +383,10 @@ describe("decision-pack service", () => { } } expect(sends.filter((s) => s.login === "hot-user")).toHaveLength(1); + + const afterAuditDebounce = await loadContributorDecisionPackForServing(env, "hot-user"); + expect(afterAuditDebounce).toMatchObject({ kind: "ready", pack: { freshness: "rebuilding", rebuildEnqueued: true } }); + expect(sends.filter((s) => s.login === "hot-user")).toHaveLength(1); }); it("returns freshness:missing with rebuildEnqueued:false when enqueueRebuild is disabled and no snapshot exists", async () => { @@ -394,6 +413,14 @@ describe("decision-pack service", () => { expect(noOutcomeBlockers.map((b) => b.code)).not.toContain("closed_pr_credibility"); expect(noOutcomeBlockers.map((b) => b.code)).not.toContain("low_credibility"); + const belowThresholdBlockers = __decisionPackInternals.scoreBlockersFor( + "owner/healthy", + "direct_pr", + { maintainerLane: false } as any, + { openPullRequests: 1, closedPullRequestRate: 0.1, credibility: 1, maintainerLane: false } as any, + ); + expect(belowThresholdBlockers.map((b) => b.code)).not.toEqual(expect.arrayContaining(["open_pr_pressure", "closed_pr_credibility", "low_credibility"])); + const fellbackToNow = __decisionPackInternals.withSnapshotMetadata({ id: "snap-both-null", signalType: "contributor-decision-pack", diff --git a/test/unit/github-commands.test.ts b/test/unit/github-commands.test.ts index 20fbb4453c..ab0e335942 100644 --- a/test/unit/github-commands.test.ts +++ b/test/unit/github-commands.test.ts @@ -73,7 +73,7 @@ describe("GitHub mention commands", () => { mode: "copilot", status: "completed", dataQualityStatus: "complete", - payload: {}, + payload: { freshness: "rebuilding", rebuildEnqueued: true }, }, actions: [ { @@ -96,6 +96,8 @@ describe("GitHub mention commands", () => { }); expect(body).toContain(""); expect(body).toContain("Scope: this repository#12"); + expect(body).not.toContain("Decision snapshot is stale"); + expect(body).not.toContain("background rebuild"); expect(body).not.toMatch(/wallet|hotkey|coldkey|estimated score|reward estimate|payout|farming|raw trust score/i); expect(sanitizePublicComment("wallet hotkey payout")).not.toMatch(/wallet|hotkey|payout/i); });