fix(web): serialize generation and reconcile uncertain posts before retry

Refs #6556 Refs #6559. Completes source repairs for W02-02 and W02-05.
Persist post-attempt target/text identity before network dispatch through the
existing draft claim Durable Object and KV. Durable attempts survive lease
expiry. Unavailable or malformed receipt reads/writes fail closed; changed
ambiguous retry text/target is refused. Reconcile at most 10 GitHub pages
with one 30-second deadline and 2 MiB per-page cap, refusing incomplete reads.
Definite rejection and completed bookkeeping clear the receipt under its claim.

The existing durable claim authority also serializes each bounded community
cron batch before source reads/model calls. Missing bindings start no provider
call; a 45-minute crash lease and two-minute completed hold cover overlap and
KV propagation. No new runtime/store authority or external action.

Validation: focused 6 files, Tests 78 passed (78), Test Files 6 passed (6).
TypeScript noEmit and scoped ESLint passed. Fixes-off source at integration
6352eb2754: Tests 7 failed | 44 skipped (51); all seven new regressions
fail on the prior behavior. Fixed source restored byte-identically (4/4);
restored regression run: Tests 7 passed | 44 skipped (51).
git diff --check passed. No test/proof is inferred from a zero-match run.
Root npm test && npm run check:web, actual deployed DO behavior and final
candidate/provider evidence remain separate; no deploy/provider spend here.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-09-30 01:38:51 -07:00
1 parent 6352eb2754
commit 639d8003fd
8 files changed
+272 -71

No files matched your search

+10 -2
View File
@@ -26,8 +26,16 @@ suffix over the finding's full identity, max 80 chars — so unchanged findings
dedup and changed findings land as new drafts. Semantic-drift model output is
validated and capped (10 drafts/run) before any KV writes.
Before sending a GitHub post, the action records its target/text identity in the
existing durable claim object and KV. Read or write failures post nothing.
The durable receipt survives lease expiry; retries with changed text or target
are refused while unresolved. Reconciliation searches up to ten pages under
one 30-second deadline and a per-page byte cap; an incomplete search posts
nothing. The KV fallback has eventual-consistency limits; deployment of the
existing durable binding still needs its own receipt.
A post whose GitHub outcome was unknown (network error, 5xx, 408, 429) leaves
draft-post-unknown:<type>:<id> (30 days; the next post of that
draft-post-unknown:<type>:<id> (until resolved; the next post of that
draft first looks on GitHub for
the earlier attempt's post)
@@ -61,7 +69,7 @@ All drafts follow these rules:
- Each cron invocation caps at ~30k input tokens and ~2k output tokens.
- Issue/PR bodies are truncated to 1000–4000 chars before sending to the model.
- Deduplication: `hasFreshDraft` checks if a draft already exists that's newer than the item's `updated_at`. Skips if so.
- Deduplication: the existing `DRAFT_CLAIM_LOCK` authority serializes each cron task before source reads or model calls (45-minute crash lease, then a two-minute propagation hold). Missing KV or lock bindings skip generation before spending. Inside the claim, `hasFreshDraft` skips drafts newer than the item's `updated_at`.
- Token usage is logged as one `usage:<YYYY-MM-DD>:…` KV record per model call
(retained 90 days); list the day's prefix and sum `calls`/`inputTokens`/
`outputTokens`. Records are append-only because KV has no atomic increment.
+51 -24
View File
@@ -243,21 +243,43 @@ export async function POST(req: Request) {
// GitHub has no idempotency key. If an earlier attempt's outcome was
// unknown, the post it may have created is looked for (from shortly
// before that attempt) instead of posting blind a second time.
const unknownAt = await getPostOutcomeUnknown(env.CURATED_KV, parsedKey.type, parsedKey.id).catch(() => null);
const identity = await reviewedBodyHash(JSON.stringify({
repo: env.GITHUB_REPO ?? "Hmbown/CodeWhale", type: draft.type,
target: draft.targetNumber, body: commentBody,
}));
let unknownAt: string | null;
try {
unknownAt = await getPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, identity);
} catch (error) {
await clearDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id).catch(() => undefined);
await releaseDraftClaim(env.CURATED_KV, heldClaim).catch(() => undefined);
const changed = error instanceof Error && error.message === "unresolved post has different text or target";
return NextResponse.json({ error: changed
? "an earlier post with different text or target is unresolved; check GitHub before retrying; nothing was posted"
: "could not read the earlier post receipt; nothing was posted" }, { status: changed ? 409 : 503 });
}
const lookSince = unknownAt ? new Date(Date.parse(unknownAt) - 10 * 60 * 1000).toISOString() : null;
const githubList = async (url: string, authScheme: "token" | "Bearer"): Promise<Record<string, unknown>[]> => {
const res = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `${authScheme} ${env.MAINTAINER_GITHUB_PAT}`,
"X-GitHub-Api-Version": "2022-11-28",
},
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`GitHub ${res.status}`);
const list: unknown = await res.json();
if (!Array.isArray(list)) throw new Error("GitHub returned no list");
return list.filter((item): item is Record<string, unknown> => !!item && typeof item === "object");
const githubFind = async (url: string, authScheme: "token" | "Bearer", matches: (item: Record<string, unknown>) => boolean): Promise<Record<string, unknown> | undefined> => {
const signal = AbortSignal.timeout(30_000);
for (let page = 1; page <= 10; page++) {
const pageUrl = new URL(url);
pageUrl.searchParams.set("page", String(page));
const res = await fetch(pageUrl.toString(), {
headers: {
Accept: "application/vnd.github+json",
Authorization: `${authScheme} ${env.MAINTAINER_GITHUB_PAT}`,
"X-GitHub-Api-Version": "2022-11-28",
}, signal,
});
if (!res.ok) throw new Error(`GitHub ${res.status}`);
const list: unknown = JSON.parse(new TextDecoder().decode(await readBoundedBody(res, 2 * 1024 * 1024)));
if (!Array.isArray(list) || list.some(item => !item || typeof item !== "object")) throw new Error("GitHub returned no valid list");
const found = list.find(matches);
if (found) return found;
if (list.length < 100) return undefined;
}
// A capped search is unknown, never proof that GitHub lacks the post.
throw new Error("GitHub reconciliation page limit exceeded");
};
// This request posted nothing, so the draft is free again.
const lookupFailed = async () => {
@@ -279,9 +301,9 @@ export async function POST(req: Request) {
await markDraftResolved(env.CURATED_KV, parsedKey.type, parsedKey.id, "posted");
// The marker now carries the decision, so a later claim sees it; a
// reopened draft (new activity clears the marker) is postable again.
await releaseDraftClaim(env.CURATED_KV, heldClaim, { recorded: true }).catch(() => undefined);
await clearPostOutcomeUnknown(env.CURATED_KV, parsedKey.type, parsedKey.id).catch(() => undefined);
await env.CURATED_KV?.put(draftKey, JSON.stringify(draft), { expirationTtl: 60 * 60 * 24 * 7 });
await clearPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, heldClaim);
await releaseDraftClaim(env.CURATED_KV, heldClaim, { recorded: true }).catch(() => undefined);
return undefined;
} catch (e) {
return `Posted to GitHub, but saving the draft state failed (${String(e)}). Do not post it again; it may reappear as pending.`;
@@ -289,6 +311,7 @@ export async function POST(req: Request) {
};
const postToGitHub = async (url: string, payload: unknown, authScheme: "token" | "Bearer") => {
await markPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, heldClaim, identity);
try {
return await fetch(url, {
method: "POST",
@@ -298,6 +321,7 @@ export async function POST(req: Request) {
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(30_000),
body: JSON.stringify(payload),
});
} catch {
@@ -308,7 +332,7 @@ export async function POST(req: Request) {
};
const unknownOutcome = async (detail: string) => {
// Remembered past the claim, so a later retry looks before posting.
await markPostOutcomeUnknown(env.CURATED_KV, parsedKey.type, parsedKey.id).catch(() => undefined);
// The durable receipt was persisted before the outbound POST.
return NextResponse.json(
{ error: `${detail}; check GitHub before retrying (retry unlocks in 15 minutes)` },
{ status: 502 }
@@ -321,9 +345,10 @@ export async function POST(req: Request) {
return unknownOutcome(`GitHub ${res.status}: ${text}`);
}
try {
await clearPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, heldClaim);
await clearDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id);
await releaseDraftClaim(env.CURATED_KV, heldClaim);
} catch { /* the claim expires on its own */ }
} catch { /* receipt remains; the next retry must reconcile */ }
return NextResponse.json({ error: `GitHub ${res.status}: ${text}` }, { status: 502 });
};
@@ -339,9 +364,7 @@ export async function POST(req: Request) {
if (lookSince) {
try {
const listUrl = `${issuesUrl}?labels=digest&state=all&since=${encodeURIComponent(lookSince)}&per_page=100`;
const found = (await githubList(listUrl, "token")).find(
(item) => !item.pull_request && item.title === title && item.body === digestBody
);
const found = await githubFind(listUrl, "token", (item) => !item.pull_request && item.title === title && item.body === digestBody);
if (found) {
issue = {
number: typeof found.number === "number" ? found.number : undefined,
@@ -354,7 +377,9 @@ export async function POST(req: Request) {
}
const reconciled = issue !== undefined;
if (!issue) {
const digestRes = await postToGitHub(issuesUrl, { title, body: digestBody, labels: ["digest"] }, "token");
let digestRes: Response | null;
try { digestRes = await postToGitHub(issuesUrl, { title, body: digestBody, labels: ["digest"] }, "token"); }
catch { return lookupFailed(); }
if (!digestRes) return unknownOutcome("GitHub request failed");
if (!digestRes.ok) return githubFailed(digestRes);
issue = await digestRes.json().catch(() => ({})) as { number?: number; html_url?: string };
@@ -405,13 +430,15 @@ export async function POST(req: Request) {
if (lookSince) {
try {
const listUrl = `${commentUrl}?since=${encodeURIComponent(lookSince)}&per_page=100`;
reconciled = (await githubList(listUrl, "Bearer")).some((comment) => comment.body === commentBody);
reconciled = (await githubFind(listUrl, "Bearer", (comment) => comment.body === commentBody)) !== undefined;
} catch {
return lookupFailed();
}
}
if (!reconciled) {
const ghRes = await postToGitHub(commentUrl, { body: commentBody }, "Bearer");
let ghRes: Response | null;
try { ghRes = await postToGitHub(commentUrl, { body: commentBody }, "Bearer"); }
catch { return lookupFailed(); }
if (!ghRes) return unknownOutcome("GitHub request failed");
if (!ghRes.ok) return githubFailed(ghRes);
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { FakeDraftClaimLock } from "./draft-claim-lock.fake";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchBoundedText } from "./bounded-body";
import { runDigest, runDupes, runPrReview, runStale, runTriage } from "./community-agent-tasks";
@@ -34,7 +35,7 @@ describe("bounded outbound reads", () => {
await runLinkCheck({ CURATED_KV: { get: async () => null, put: async () => undefined } } as never);
const kv = { get: async () => null, put: async () => undefined, delete: async () => undefined, list: async () => ({ keys: [], list_complete: true }) };
for (const task of [runTriage, runPrReview, runStale, runDupes, runDigest]) {
await task({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" } as never).catch(() => undefined);
await task({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" } as never).catch(() => undefined);
}
expect(seen.mock.calls.length).toBeGreaterThan(1);
const unbounded = seen.mock.calls
+107 -15
View File
@@ -213,7 +213,7 @@ describe("weekly digest publication requires maintainer approval", () => {
usage: { input: 1, output: 1 },
});
await expect(runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: true });
await expect(runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: true });
const recordKey = onlyKey(kv, "digest:weekly-");
const staged: unknown = JSON.parse(kv.values.get(recordKey)!);
expect(staged).toMatchObject({ approved: false });
@@ -235,7 +235,7 @@ describe("weekly digest publication requires maintainer approval", () => {
const kv = new FakeKv();
stubDigestSources();
mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" });
await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
stubAdminEnv(kv);
stubGitHub();
@@ -249,7 +249,7 @@ describe("weekly digest publication requires maintainer approval", () => {
const kv = new FakeKv();
stubDigestSources();
mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" });
await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
stubAdminEnv(kv);
stubGitHub();
@@ -265,7 +265,7 @@ describe("weekly digest publication requires maintainer approval", () => {
const kv = new FakeKv();
stubDigestSources();
mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" });
await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
stubAdminEnv(kv);
stubGitHub();
@@ -284,7 +284,7 @@ describe("weekly digest publication requires maintainer approval", () => {
const kv = new FakeKv();
stubDigestSources();
mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" });
await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
stubAdminEnv(kv);
const draftKey = onlyKey(kv, "draft:digest:");
@@ -293,7 +293,7 @@ describe("weekly digest publication requires maintainer approval", () => {
expect([...kv.values.keys()].filter((k) => k.startsWith("digest:weekly-"))).toEqual([]);
mocks.agentChat.mockClear();
await expect(runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ skipped: true });
await expect(runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ skipped: true });
expect(mocks.agentChat).not.toHaveBeenCalled();
expect(kv.values.has(draftKey)).toBe(false);
});
@@ -302,7 +302,7 @@ describe("weekly digest publication requires maintainer approval", () => {
const kv = new FakeKv();
stubDigestSources();
mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" });
await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
stubAdminEnv(kv);
stubGitHub(422);
@@ -319,7 +319,7 @@ describe("weekly digest publication requires maintainer approval", () => {
const kv = new FakeKv();
stubDigestSources();
mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" });
await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
stubAdminEnv(kv);
const draftKey = onlyKey(kv, "draft:digest:");
stubGitHub(504);
@@ -339,7 +339,7 @@ describe("weekly digest publication requires maintainer approval", () => {
const kv = new FakeKv();
stubDigestSources();
mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" });
await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
const recordKey = onlyKey(kv, "digest:weekly-");
const recordA = kv.values.get(recordKey)!;
@@ -349,7 +349,7 @@ describe("weekly digest publication requires maintainer approval", () => {
usage: { input: 1, output: 1 },
});
kv.failPutsMatching = /^digest:weekly-/;
await expect(runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: false });
await expect(runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: false });
kv.failPutsMatching = null;
const draftKey = onlyKey(kv, "draft:digest:");
expect(JSON.parse(kv.values.get(draftKey)!).bodyEn).toContain("Digest B");
@@ -377,7 +377,7 @@ describe("weekly digest publication requires maintainer approval", () => {
const kv = new FakeKv();
stubDigestSources();
mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } });
await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" });
await runDigest({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
const recordKey = onlyKey(kv, "digest:weekly-");
kv.values.set(recordKey, JSON.stringify({ ...JSON.parse(kv.values.get(recordKey)!), extra: "<script>" }));
@@ -435,7 +435,7 @@ describe("resolved drafts are not resurrected by the cron", () => {
expect(res.status).toBe(200);
stubIssues("2020-01-01T00:00:00.000Z");
await expect(runTriage({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ processed: 0, skipped: 1 });
await expect(runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ processed: 0, skipped: 1 });
expect(mocks.agentChat).not.toHaveBeenCalled();
expect(kv.values.has("draft:triage:42")).toBe(false);
});
@@ -450,7 +450,7 @@ describe("resolved drafts are not resurrected by the cron", () => {
// Our own comment bumps updated_at past the draft's generatedAt.
stubIssues(new Date().toISOString());
await runTriage({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" });
await runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" });
expect(mocks.agentChat).not.toHaveBeenCalled();
expect(JSON.parse(kv.values.get("draft:triage:42")!)).toMatchObject({ posted: true });
});
@@ -464,7 +464,7 @@ describe("resolved drafts are not resurrected by the cron", () => {
// New commits or a reply a day later.
stubIssues(new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString());
await expect(runTriage({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ processed: 1 });
await expect(runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ processed: 1 });
expect(JSON.parse(kv.values.get("draft:triage:42")!)).toMatchObject({ posted: false, bodyEn: "review" });
// The fresh draft can be posted.
@@ -484,7 +484,7 @@ describe("resolved drafts are not resurrected by the cron", () => {
usage: { input: 1, output: 1 },
});
await expect(runDupes({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: true, processed: 0 });
await expect(runDupes({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: true, processed: 0 });
expect(JSON.parse(kv.values.get("draft:dupes:7")!)).toMatchObject({ posted: true });
});
});
@@ -994,3 +994,95 @@ describe("admin draft queue", () => {
await expect(listDrafts(kv)).resolves.toHaveLength(200);
});
});
describe("post retry durability and generation admission", () => {
const KEY = "draft:triage:42";
async function prepared() {
const kv = new FakeKv();
await saveDraft(kv, draft());
const lock = new FakeDraftClaimLock();
stubAdminEnv(kv, lock);
return { kv, lock };
}
it("posts nothing when an earlier receipt cannot be read", async () => {
const { kv } = await prepared();
const originalGet = kv.get.bind(kv);
kv.get = async (key) => { if (key.startsWith("draft-post-unknown:")) throw new Error("unavailable"); return originalGet(key); };
const posts = stubGitHub();
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(503);
expect(posts).toEqual([]);
});
it("writes the attempt before dispatch and refuses an unavailable receipt write", async () => {
const { kv } = await prepared();
kv.failPutsMatching = /^draft-post-unknown:/;
const posts = stubGitHub();
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
expect(posts).toEqual([]);
});
it("finds an earlier post past page one even when KV lost the receipt", async () => {
const { kv, lock } = await prepared();
stubGitHub(504);
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
lock.now += 15 * 60 * 1000;
kv.values.delete("draft-resolved:triage:42");
kv.values.delete("draft-post-unknown:triage:42");
const posts: string[] = [], pages: string[] = [];
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = inputUrl(input);
if (init?.method === "POST") { posts.push(url); return jsonResponse({ id: 2 }, 201); }
const page = new URL(url).searchParams.get("page"); pages.push(page!);
return jsonResponse(page === "1" ? Array.from({ length: 100 }, () => ({ body: "another body" })) : [{ id: 7, body: "English body" }]);
}));
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(200);
expect(pages).toEqual(["1", "2"]);
expect(posts).toEqual([]);
});
it("a bounded incomplete reconciliation never licenses another post", async () => {
const { kv, lock } = await prepared();
stubGitHub(504);
await act(kv, { action: "post", draftKey: KEY });
lock.now += 15 * 60 * 1000;
kv.values.delete("draft-resolved:triage:42");
const posts: string[] = [];
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
if (init?.method === "POST") posts.push(inputUrl(input));
return jsonResponse(Array.from({ length: 100 }, () => ({ body: "another body" })));
}));
expect((await act(kv, { action: "post", draftKey: KEY })).status).toBe(502);
expect(posts).toEqual([]);
});
it("refuses changed retry text while the earlier attempt is unresolved", async () => {
const { kv, lock } = await prepared();
stubGitHub(504);
await act(kv, { action: "post", draftKey: KEY });
lock.now += 15 * 60 * 1000;
kv.values.delete("draft-resolved:triage:42");
const posts = stubGitHub();
expect((await act(kv, { action: "post", draftKey: KEY, editedBody: "different body" })).status).toBe(409);
expect(posts).toEqual([]);
});
it("admits one overlapping generation batch and holds completed KV propagation", async () => {
const kv = new FakeKv(), lock = new FakeDraftClaimLock();
const env = { CURATED_KV: kv, DRAFT_CLAIM_LOCK: lock, DEEPSEEK_API_KEY: "k" };
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse([{ number: 42, title: "issue", body: "body", updated_at: "2020-01-01T00:00:00.000Z", html_url: "https://github.com/Hmbown/CodeWhale/issues/42", labels: [] }])));
let finish!: (value: unknown) => void;
mocks.agentChat.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue({ content: JSON.stringify({ bodyEn: "other", bodyZh: "另一个" }), usage: { input: 1, output: 1 } });
const first = runTriage(env);
await vi.waitFor(() => expect(mocks.agentChat).toHaveBeenCalledOnce());
const second = await runTriage(env);
expect(second).toMatchObject({ processed: 0, skipped: 1 });
finish({ content: JSON.stringify({ bodyEn: "generated", bodyZh: "生成" }), usage: { input: 1, output: 1 } });
expect(await first).toMatchObject({ processed: 1 });
kv.values.delete(KEY); // another location's stale KV must not spend again.
expect(await runTriage(env)).toMatchObject({ processed: 0, skipped: 1 });
expect(mocks.agentChat).toHaveBeenCalledOnce();
});
it("missing or failed durable generation admission starts no provider call", async () => {
const kv = new FakeKv();
const reads = vi.fn(); vi.stubGlobal("fetch", reads);
expect(await runTriage({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).toMatchObject({ skipped: true });
const broken = { idFromName: () => ({ toString: () => "x" }), get: () => ({ act: async () => { throw new Error("down"); } }) };
expect(await runTriage({ CURATED_KV: kv, DRAFT_CLAIM_LOCK: broken, DEEPSEEK_API_KEY: "k" })).toMatchObject({ skipped: true });
expect(reads).not.toHaveBeenCalled(); expect(mocks.agentChat).not.toHaveBeenCalled();
});
});
+48 -6
View File
@@ -1,3 +1,4 @@
import type { DraftClaimLockNamespace, DraftClaimLockStub } from "./draft-claim-lock";
import { OUTBOUND_TIMEOUT_MS } from "@/lib/bounded-body";
import { fetchFeed, fetchRepoStats } from "@/lib/github";
import { curate } from "@/lib/deepseek";
@@ -21,6 +22,7 @@ import {
} from "@/lib/community-agent";
export interface AgentEnv {
DRAFT_CLAIM_LOCK?: DraftClaimLockNamespace;
CURATED_KV?: {
get(k: string): Promise<string | null>;
put(k: string, v: string, o?: { expirationTtl?: number }): Promise<void>;
@@ -46,7 +48,7 @@ function dsEnv(env: AgentEnv): DeepSeekEnv {
};
}
export async function runCurate(env: AgentEnv): Promise<Record<string, unknown>> {
async function generateCurate(env: AgentEnv): Promise<Record<string, unknown>> {
if (!env.DEEPSEEK_API_KEY) {
return { skipped: true, reason: "DEEPSEEK_API_KEY not set" };
}
@@ -82,7 +84,7 @@ export async function runCurate(env: AgentEnv): Promise<Record<string, unknown>>
}
}
export async function runTriage(env: AgentEnv): Promise<Record<string, unknown>> {
async function generateTriage(env: AgentEnv): Promise<Record<string, unknown>> {
const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
try {
const res = await fetch(
@@ -149,7 +151,7 @@ export async function runTriage(env: AgentEnv): Promise<Record<string, unknown>>
}
}
export async function runPrReview(env: AgentEnv): Promise<Record<string, unknown>> {
async function generatePrReview(env: AgentEnv): Promise<Record<string, unknown>> {
const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
try {
const res = await fetch(
@@ -234,7 +236,7 @@ export async function runPrReview(env: AgentEnv): Promise<Record<string, unknown
}
}
export async function runStale(env: AgentEnv): Promise<Record<string, unknown>> {
async function generateStale(env: AgentEnv): Promise<Record<string, unknown>> {
const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
try {
@@ -302,7 +304,7 @@ export async function runStale(env: AgentEnv): Promise<Record<string, unknown>>
}
}
export async function runDupes(env: AgentEnv): Promise<Record<string, unknown>> {
async function generateDupes(env: AgentEnv): Promise<Record<string, unknown>> {
const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
try {
const res = await fetch(
@@ -363,7 +365,7 @@ export async function runDupes(env: AgentEnv): Promise<Record<string, unknown>>
}
}
export async function runDigest(env: AgentEnv): Promise<Record<string, unknown>> {
async function generateDigest(env: AgentEnv): Promise<Record<string, unknown>> {
const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
@@ -473,3 +475,43 @@ export async function runDigest(env: AgentEnv): Promise<Record<string, unknown>>
return { ok: false, error: String(e) };
}
}
/** The existing durable claim authority also serializes each bounded cron batch.
* Missing bindings stop generation before provider spend; KV is never a lock.
* The 45-minute lease covers at most ten 180-second model calls and reads.
* A completed batch retains a short hold for its KV drafts to propagate.
*/
async function withGenerationClaim(env: AgentEnv, task: string, generate: () => Promise<Record<string, unknown>>): Promise<Record<string, unknown>> {
if (!env.DEEPSEEK_API_KEY) return { skipped: true, reason: "DEEPSEEK_API_KEY not set" };
if (!env.DRAFT_CLAIM_LOCK || !env.CURATED_KV) return { skipped: true, reason: "durable generation claim unavailable; no provider call started" };
let lock: DraftClaimLockStub;
const token = `generate:${crypto.randomUUID()}`;
try {
lock = env.DRAFT_CLAIM_LOCK.get(env.DRAFT_CLAIM_LOCK.idFromName(`draft-generation:${task}`));
const held = await lock.act({ op: "claim", token, action: "generate", leaseMs: 45 * 60 * 1000 });
if (!held.ok) return { ok: true, processed: 0, skipped: 1, reason: "generation already in progress or recently completed" };
} catch {
return { skipped: true, reason: "durable generation claim unavailable; no provider call started" };
}
let completed = false;
try {
const result = await generate();
completed = result.ok === true;
return result;
} finally {
// A release failure leaves the durable lease in place until its expiry.
await lock.act({ op: "release", token, holdMs: completed ? 120_000 : 0 }).catch(() => undefined);
}
}
export const runCurate = (env: AgentEnv) => withGenerationClaim(env, "curate", () => generateCurate(env));
export const runTriage = (env: AgentEnv) => withGenerationClaim(env, "triage", () => generateTriage(env));
export const runPrReview = (env: AgentEnv) => withGenerationClaim(env, "prreview", () => generatePrReview(env));
export const runStale = (env: AgentEnv) => withGenerationClaim(env, "stale", () => generateStale(env));
export const runDupes = (env: AgentEnv) => withGenerationClaim(env, "dupes", () => generateDupes(env));
export const runDigest = (env: AgentEnv) => withGenerationClaim(env, "digest", () => generateDigest(env));
+29 -19
View File
@@ -566,35 +566,45 @@ export async function clearDraftResolution(
// next attempt looks for the post the earlier one may have created before
// posting again, however late the retry comes.
const POST_UNKNOWN_PREFIX = "draft-post-unknown:";
const POST_UNKNOWN_TTL_SEC = 60 * 60 * 24 * 30;
function postUnknownKey(type: AgentDraftType, id: string): string {
return POST_UNKNOWN_PREFIX + draftKey(type, id).slice("draft:".length);
}
export async function markPostOutcomeUnknown(kv: KVNamespace | undefined, type: AgentDraftType, id: string): Promise<void> {
if (!kv) return;
await kv.put(postUnknownKey(type, id), JSON.stringify({ at: new Date().toISOString() }), {
expirationTtl: POST_UNKNOWN_TTL_SEC,
});
export async function markPostOutcomeUnknown(stores: DraftClaimStores, type: AgentDraftType, id: string, claim: DraftClaim, identity: string): Promise<void> {
const attempt = { at: new Date().toISOString(), identity };
// Receipt before dispatch: a crash or an unavailable KV write must never
// allow a blind resend. The existing claim object is the strong authority.
if (claim.lock) await claim.lock.act({ op: "remember-post", token: claim.token, attempt });
if (!stores.CURATED_KV) throw new Error("post receipt storage unavailable");
const previous = await stores.CURATED_KV.get(postUnknownKey(type, id));
await stores.CURATED_KV.put(postUnknownKey(type, id), previous ?? JSON.stringify(attempt));
}
/** When the earliest unresolved unknown-outcome attempt happened, if any. */
export async function getPostOutcomeUnknown(kv: KVNamespace | undefined, type: AgentDraftType, id: string): Promise<string | null> {
if (!kv) return null;
const raw = await kv.get(postUnknownKey(type, id));
/** Read failures propagate. Absence is never inferred from unavailable storage. */
export async function getPostOutcomeUnknown(stores: DraftClaimStores, type: AgentDraftType, id: string, identity: string): Promise<string | null> {
if (stores.DRAFT_CLAIM_LOCK) {
const lock = stores.DRAFT_CLAIM_LOCK.get(stores.DRAFT_CLAIM_LOCK.idFromName(claimKey(type, id)));
const result = await lock.act({ op: "post-status" });
if (!result.ok) throw new Error("post receipt unavailable");
if (result.attempt) {
if (typeof result.attempt.at !== "string" || !Number.isFinite(Date.parse(result.attempt.at))) throw new Error("invalid durable post receipt");
if (result.attempt.identity !== identity) throw new Error("unresolved post has different text or target");
return result.attempt.at;
}
}
if (!stores.CURATED_KV) throw new Error("post receipt storage unavailable");
const raw = await stores.CURATED_KV.get(postUnknownKey(type, id));
if (!raw) return null;
try {
const at = (JSON.parse(raw) as { at?: unknown }).at;
if (typeof at === "string" && Number.isFinite(Date.parse(at))) return at;
} catch { /* fall through */ }
// An unreadable marker still means "look first"; look back its full life.
return new Date(Date.now() - POST_UNKNOWN_TTL_SEC * 1000).toISOString();
const attempt = JSON.parse(raw) as { at?: unknown; identity?: unknown };
if (attempt.identity !== undefined && attempt.identity !== identity) throw new Error("unresolved post has different text or target");
if (typeof attempt.at !== "string" || !Number.isFinite(Date.parse(attempt.at))) throw new Error("invalid post receipt");
return attempt.at;
}
export async function clearPostOutcomeUnknown(kv: KVNamespace | undefined, type: AgentDraftType, id: string): Promise<void> {
if (!kv) return;
await kv.delete(postUnknownKey(type, id));
export async function clearPostOutcomeUnknown(stores: DraftClaimStores, type: AgentDraftType, id: string, claim: DraftClaim): Promise<void> {
await stores.CURATED_KV?.delete(postUnknownKey(type, id));
if (claim.lock) await claim.lock.act({ op: "forget-post", token: claim.token });
}
// --- Public weekly digest records ---
+22 -2
View File
@@ -12,9 +12,14 @@
* the Next.js build; `worker.ts` holds the thin class around it.
*/
export type DraftLockAction = "post" | "discard";
export type DraftLockAction = "post" | "discard" | "generate";
export interface PostAttempt { at: string; identity: string }
export type DraftLockRequest =
| { op: "post-status" }
| { op: "remember-post"; token: string; attempt: PostAttempt }
| { op: "forget-post"; token: string }
| { op: "claim"; token: string; action: DraftLockAction; leaseMs: number }
/**
* `holdMs` > 0 keeps refusing other claims for that long after an action
@@ -24,7 +29,7 @@ export type DraftLockRequest =
| { op: "release"; token: string; holdMs: number };
export type DraftLockResponse =
| { ok: true }
| { ok: true; attempt?: PostAttempt }
| { ok: false; holder: DraftLockAction };
/** The subset of `DurableObjectStorage` the lock uses. */
@@ -48,9 +53,24 @@ export async function applyDraftLock(
now: number,
req: DraftLockRequest
): Promise<DraftLockResponse> {
if (req.op === "post-status") {
return { ok: true, attempt: await storage.get<PostAttempt>("post-attempt") };
}
const lease = await storage.get<Lease>(LEASE_KEY);
const live = lease && lease.expiresAt > now ? lease : undefined;
if (req.op === "remember-post" || req.op === "forget-post") {
if (!live || live.token !== req.token) throw new Error("post claim expired");
if (req.op === "remember-post") {
if (!/^[0-9a-f]{64}$/.test(req.attempt.identity) || !Number.isFinite(Date.parse(req.attempt.at))) throw new Error("invalid post receipt");
const previous = await storage.get<PostAttempt>("post-attempt");
if (previous && previous.identity !== req.attempt.identity) throw new Error("unresolved post has different text or target");
await storage.put("post-attempt", previous ?? req.attempt);
} else {
await storage.delete("post-attempt");
}
return { ok: true };
}
if (req.op === "claim") {
if (live && live.token !== req.token) return { ok: false, holder: live.action };
await storage.put<Lease>(LEASE_KEY, {
+3 -2
View File
@@ -1,3 +1,4 @@
import { FakeDraftClaimLock } from "./draft-claim-lock.fake";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const securityMocks = vi.hoisted(() => ({
@@ -145,7 +146,7 @@ describe("public security boundaries", () => {
});
vi.stubGlobal("fetch", triageFetch);
const triageEnv = { CURATED_KV: triageKv, DEEPSEEK_API_KEY: "test-key" };
const triageEnv = { CURATED_KV: triageKv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "test-key" };
await expect(runTriage(triageEnv)).resolves.toMatchObject({ processed: 1, skipped: 0 });
expect(securityMocks.agentChat).toHaveBeenCalledOnce();
securityMocks.agentChat.mockClear();
@@ -171,7 +172,7 @@ describe("public security boundaries", () => {
});
vi.stubGlobal("fetch", prFetch);
const prEnv = { CURATED_KV: prKv, DEEPSEEK_API_KEY: "test-key" };
const prEnv = { CURATED_KV: prKv, DRAFT_CLAIM_LOCK: new FakeDraftClaimLock(), DEEPSEEK_API_KEY: "test-key" };
await expect(runPrReview(prEnv)).resolves.toMatchObject({ processed: 1, skipped: 0 });
expect(securityMocks.agentChat).toHaveBeenCalledOnce();
securityMocks.agentChat.mockClear();