Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,14 @@ only after that action and its validation succeed.
Terminal updates cannot resurrect a job; a same-terminal-status retry does not rewrite the result.
Results are limited to 20,000 characters. Check newer jobs before finalizing or acting on old approval.

A terminal outcome updates only the job's captured card version by default. If you published your own
replacement while doing that job, pass its exact returned `version` as `expectedIdeaVersion` when
finishing. A newer job, an intervening card revision or Skip prevents that outcome changing the card;
the completed job and its result remain recorded. `ideaStatus: null` means no card state was changed.
A competing terminal update returns 409 if its job-status comparison lost. Do not blindly retry it.
For legacy jobs without a captured version, automatic reconciliation requires an unrevised card or
a job created strictly after that card revision. Same-second ordering after a replacement is ambiguous.

If work is complete, record the result without inventing a new decision. If the premise changed or
work is blocked, replace the misleading card with the current facts and any meaningful next option.
Never add an Acknowledge/Keep blocked no-op. Keep actual in-flight checks Working until they finish.
Expand Down
22 changes: 17 additions & 5 deletions app/api/agent-jobs/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { env } from "cloudflare:workers";
import { ensureDatabase } from "../../../db";
import { canUpdateJob, ideaStatusForOutcome, jobLeaseWindow, MAX_CONCURRENT_JOBS, resolveTicketOutcome, type StoredJobStatus, type TicketOutcome } from "../../../lib/job-lifecycle";
import { canUpdateJob, ideaStatusForOutcome, JOB_MATCHES_IDEA_SQL, jobLeaseWindow, MAX_CONCURRENT_JOBS, resolveTicketOutcome, type StoredJobStatus, type TicketOutcome } from "../../../lib/job-lifecycle";

function canUseQueue(request: Request) {
const origin = request.headers.get("origin");
Expand Down Expand Up @@ -63,11 +63,12 @@ export async function GET(request: Request) {

export async function POST(request: Request) {
if (!canUseQueue(request)) return Response.json({ error: "Missing agent key" }, { status: 401 });
const payload = (await request.json()) as { id?: number; status?: "running" | "done" | "failed"; result?: string; ticketOutcome?: TicketOutcome };
const payload = (await request.json()) as { id?: number; status?: "running" | "done" | "failed"; result?: string; ticketOutcome?: TicketOutcome; expectedIdeaVersion?: number };
if (!payload.id || !["running", "done", "failed"].includes(payload.status ?? "")) return Response.json({ error: "Invalid job update" }, { status: 400 });
if (payload.ticketOutcome && !["completed", "review", "blocked"].includes(payload.ticketOutcome)) return Response.json({ error: "Invalid ticket outcome" }, { status: 400 });
if (payload.status === "done" && payload.ticketOutcome === "blocked") return Response.json({ error: "A done job cannot be blocked" }, { status: 400 });
if (payload.status === "failed" && payload.ticketOutcome && payload.ticketOutcome !== "blocked") return Response.json({ error: "A failed job must be blocked" }, { status: 400 });
if (payload.expectedIdeaVersion !== undefined && (!Number.isInteger(payload.expectedIdeaVersion) || payload.expectedIdeaVersion < 1)) return Response.json({ error: "Invalid expected idea version" }, { status: 400 });
const db = await ensureDatabase();
const job = await db.prepare("SELECT idea_id AS ideaId, action, status FROM agent_jobs WHERE id = ?").bind(payload.id).first<{ ideaId: number; action: string; status: StoredJobStatus }>();
if (!job) return Response.json({ error: "Job not found" }, { status: 404 });
Expand All @@ -84,15 +85,26 @@ export async function POST(request: Request) {
];
if ((payload.status === "done" || payload.status === "failed") && job.action !== "no") {
const ideaStatus = ideaStatusForOutcome(ticketOutcome);
// In this atomic batch, changes() is the preceding job CAS, not another request.
updates.push(db.prepare(`
UPDATE ideas SET status = ?
WHERE id = ? AND status IN ('new', 'working')
AND changes() = 1
AND NOT EXISTS (
SELECT 1 FROM agent_jobs newer
WHERE newer.idea_id = ? AND newer.id > ?
)
`).bind(ideaStatus, job.ideaId, job.ideaId, payload.id));
AND EXISTS (
SELECT 1 FROM agent_jobs job
WHERE job.id = ? AND job.status = ? AND job.ticket_outcome = ?
AND CASE WHEN ? IS NOT NULL THEN ideas.version = ?
ELSE (${JOB_MATCHES_IDEA_SQL}) END
)
`).bind(ideaStatus, job.ideaId, job.ideaId, payload.id, payload.id, payload.status, ticketOutcome, payload.expectedIdeaVersion ?? null, payload.expectedIdeaVersion ?? null));
}
const [updatedJob, updatedIdea] = await db.batch(updates);
if (updatedJob.meta.changes !== 1) {
return Response.json({ error: "Job changed while completing it" }, { status: 409 });
}
await db.batch(updates);
return Response.json({ ok: true, ticketOutcome, ideaStatus: ideaStatusForOutcome(ticketOutcome) });
return Response.json({ ok: true, ticketOutcome, ideaStatus: updatedIdea?.meta.changes ? ideaStatusForOutcome(ticketOutcome) : null });
}
2 changes: 1 addition & 1 deletion app/api/tasks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export async function POST(request: Request) {
source: "Agency New Task",
boundary: "Complete safe private work first. Stop before an external, destructive, paid, merge, or deploy action unless the task explicitly and exactly approves it.",
});
const idea = await db.prepare("INSERT INTO ideas (project, category, headline, why_matters, impact, finished_work, primary_action, secondary_action, external_action, card_html, agent_context, score, rise_reach, rise_impact, rise_strategic_fit, rise_ease, source_label, source_url, agent_name, preview_kind, preview_title, preview_body, preview_asset, dedupe_key, status) VALUES ('Agency', 'Quick task', ?, '', '', '', '', '', '', ?, ?, 100, 25, 25, 25, 25, 'User-created task', '', 'Agency · Task Runner', 'html', '', '', '', ?, 'working') RETURNING id, project, category, headline, card_html AS cardHtml, agent_context AS agentContext, source_label AS sourceLabel, source_url AS sourceUrl, dedupe_key AS dedupeKey")
const idea = await db.prepare("INSERT INTO ideas (project, category, headline, why_matters, impact, finished_work, primary_action, secondary_action, external_action, card_html, agent_context, score, rise_reach, rise_impact, rise_strategic_fit, rise_ease, source_label, source_url, agent_name, preview_kind, preview_title, preview_body, preview_asset, dedupe_key, status) VALUES ('Agency', 'Quick task', ?, '', '', '', '', '', '', ?, ?, 100, 25, 25, 25, 25, 'User-created task', '', 'Agency · Task Runner', 'html', '', '', '', ?, 'working') RETURNING id, version, project, category, headline, card_html AS cardHtml, agent_context AS agentContext, source_label AS sourceLabel, source_url AS sourceUrl, dedupe_key AS dedupeKey")
.bind(headline, taskCard(task), agentContext, taskKey).first();
if (!idea?.id) return Response.json({ error: "Task card could not be created." }, { status: 500 });

Expand Down
33 changes: 14 additions & 19 deletions db/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { env } from "cloudflare:workers";
import { JOB_MATCHES_IDEA_SQL } from "../lib/job-lifecycle";

export function getD1() {
if (!env.DB) throw new Error("The local Agency database is unavailable.");
Expand Down Expand Up @@ -103,32 +104,26 @@ async function runMaintenance(db: ReturnType<typeof getD1>) {
if (!jobNames.has("ticket_outcome")) {
await db.prepare("ALTER TABLE agent_jobs ADD COLUMN ticket_outcome TEXT").run();
}
// A replacement card can set an idea back to New before the agent posts its
// terminal outcome. Reconcile from the latest explicit marker so completed,
// review, and blocked remain card states instead of agent-run states. Never
// revive a card the user already dismissed.
// Only the latest terminal job for this card version may reconcile its state.
// A fresh reply must not inherit completion from an older card version.
await db.prepare(`
UPDATE ideas
SET status = CASE (
SELECT latest.ticket_outcome
FROM agent_jobs latest
WHERE latest.idea_id = ideas.id
ORDER BY latest.id DESC
LIMIT 1
)
SET status = CASE job.ticket_outcome
WHEN 'completed' THEN 'done'
WHEN 'review' THEN 'new'
WHEN 'blocked' THEN 'new'
ELSE status
ELSE ideas.status
END
WHERE status IN ('new', 'working', 'done')
FROM agent_jobs job
WHERE job.idea_id = ideas.id
AND job.id = (SELECT MAX(latest.id) FROM agent_jobs latest WHERE latest.idea_id = ideas.id)
AND ideas.status IN ('new', 'working', 'done')
AND job.action <> 'no'
AND (
SELECT latest.ticket_outcome
FROM agent_jobs latest
WHERE latest.idea_id = ideas.id
ORDER BY latest.id DESC
LIMIT 1
) IS NOT NULL
(job.status = 'done' AND job.ticket_outcome IN ('completed', 'review'))
OR (job.status = 'failed' AND job.ticket_outcome = 'blocked')
)
AND (${JOB_MATCHES_IDEA_SQL})
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
`).run();
// Early Agency audits used the same decision fields as real user clicks. Mark
// those known maintenance labels once so decision timing measures the user.
Expand Down
17 changes: 17 additions & 0 deletions lib/job-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,20 @@ export function ideaStatusForOutcome(outcome: TicketOutcome | null) {
if (outcome === "review" || outcome === "blocked") return "new";
return "working";
}

const legacyJobMatchesIdea = "(ideas.version = 1 OR job.created_at > ideas.created_at)";

// A captured version wins over second-resolution timestamps. Legacy same-second
// ordering is ambiguous after a replacement, so only an unrevised card is safe.
export const JOB_MATCHES_IDEA_SQL = `
CASE WHEN json_valid(job.card_context) THEN
CASE
WHEN json_type(job.card_context, '$.idea.version') = 'integer'
THEN json_extract(job.card_context, '$.idea.version') = ideas.version
WHEN json_type(job.card_context, '$.idea.version') IS NULL
THEN ${legacyJobMatchesIdea}
ELSE 0
END
ELSE ${legacyJobMatchesIdea}
END
`;
Loading