Skip to content
Merged
3 changes: 0 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,3 @@
- Tighten and extend decision-pack regression coverage

- Cover review-requested branches and tier sanitization



9 changes: 8 additions & 1 deletion site/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
53 changes: 19 additions & 34 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@ import {
} from "../services/agent-orchestrator";
import {
buildAndPersistContributorDecisionPack,
loadContributorDecisionPack,
loadFreshContributorDecisionPack,
loadContributorDecisionPackForServing,
repoDecisionFromPack,
} from "../services/decision-pack";
import {
Expand Down Expand Up @@ -681,46 +680,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) => {
Expand Down
10 changes: 10 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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: {
Expand Down
42 changes: 18 additions & 24 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -60,6 +60,12 @@ type ToolPayload = {
data: Record<string, unknown>;
};

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),
Expand Down Expand Up @@ -509,43 +515,29 @@ export class GittensoryMcp {
}

private async getDecisionPack(login: string): Promise<ToolPayload> {
const pack = await loadFreshContributorDecisionPack(this.env, login);
if (pack) {
const serving = await loadContributorDecisionPackForServing(this.env, login);
if (serving.kind === "ready") {
return {
summary: `Gittensory decision pack for ${login}.`,
data: pack as unknown as Record<string, unknown>,
summary: decisionPackSummary(login, serving.pack.freshness, serving.pack.rebuildEnqueued),
data: serving.pack as unknown as Record<string, unknown>,
};
}
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<string, unknown>,
};
}

private async explainRepoDecision(input: { login: string; owner: string; repo: string }): Promise<ToolPayload> {
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<string, unknown>,
};
}
const pack = serving.pack;
const decision = repoDecisionFromPack(pack, fullName);
return {
summary: `Gittensory repo decision for ${input.login} in ${fullName}.`,
Expand All @@ -555,6 +547,8 @@ export class GittensoryMcp {
repoFullName: fullName,
generatedAt: pack.generatedAt,
source: pack.source,
freshness: pack.freshness,
rebuildEnqueued: pack.rebuildEnqueued,
decision,
dataQuality: pack.dataQuality,
},
Expand Down
13 changes: 9 additions & 4 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,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"]),
Expand All @@ -1030,6 +1032,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,
Expand All @@ -1053,10 +1057,9 @@ export const DecisionPackRefreshNeededSchema = z
login: z.string(),
repoFullName: z.string().optional(),
generatedAt: z.string(),
reason: z.enum(["missing_snapshot", "stale_snapshot"]),
enqueued: z.boolean(),
staleSnapshot: z.object({ generatedAt: z.string(), ageSeconds: z.number() }).optional(),
dataQuality: z.record(z.unknown()).optional(),
reason: z.enum(["missing_snapshot"]),
freshness: z.enum(["missing"]),
rebuildEnqueued: z.boolean(),
})
.openapi("DecisionPackRefreshNeeded");

Expand All @@ -1067,6 +1070,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()),
})
Expand Down
8 changes: 4 additions & 4 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,18 +278,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({
Expand Down
39 changes: 32 additions & 7 deletions src/services/agent-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -203,16 +203,22 @@ async function attachPrivateAiSummary(env: Env, bundle: AgentRunBundle): Promise
async function executeDecisionPackRun(env: Env, run: AgentRunRecord, kind: string): Promise<AgentRunBundle> {
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,
rebuildEnqueued: serving.refresh.rebuildEnqueued,
refreshReason: serving.refresh.rebuildEnqueued ? "missing_decision_pack" : "queue_unavailable",
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"
Expand All @@ -221,10 +227,20 @@ 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,
rebuildEnqueued: pack.rebuildEnqueued,
...(isStale
? { refreshReason: pack.rebuildEnqueued ? "stale_decision_pack" : "stale_decision_pack_queue_unavailable" }
: {}),
},
});
return (await getAgentRunBundle(env, run.id))!;
}
Expand Down Expand Up @@ -486,7 +502,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`),
Expand Down
Loading