mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(daemon): keep daemon as sole writer of run events on stale message PUT The web client saves whole-message snapshots through PUT /messages/:mid. A stale snapshot — captured in memory before a reconnect or project switch, then PUT after the daemon appended more events — overwrote `events_json` and wiped early run events, including the `status:model` event the conversation page renders its Model module from (#6396). Make the daemon the single writer of the daemon-owned fields for daemon-backed assistant messages (stored role=assistant with a run_id): the PUT route now keeps the stored events / content / last-run-event id / run status instead of the client snapshot's values. Client-owned metadata (feedback, comment attachments, telemetry) still flows through the same PUT. Adds getMessage (single-message getter) and a concurrency regression test: a real run persists its early status event, then a stale snapshot PUT must not wipe it — red before the fix, green after. * fix(daemon): scope stale-snapshot guard to event-list regression The #6396 guard previously preserved stored events/content/run-status for every daemon-backed assistant message, which broke mock-agent flows (UI P0 app-restoration): e2e suites mock the run SSE end-to-end, so the daemon never persists events and the web client is the legitimate writer of the assistant message's content/run-status. Run events are append-only, so a stale snapshot can only shrink the stored list. Narrow the guard to a no-regression rule: preserve the stored events/content/last-run-event-id/run-status only when the incoming snapshot would drop already-persisted events. A web write that carries at least as many events still flows through, keeping mock-agent flows and client metadata writes working. Adds a regression test for the mock-agent path (web writes events and a terminal runStatus when the daemon never persisted any). * fix(daemon): preserve daemon ownership marker across repeated stale PUTs The stale-snapshot guard preserved events/content/last-run-event-id/run status but spread the incoming snapshot's runId/role unchanged. A snapshot captured before `/api/runs` assigned a run id omits `runId`, so upsertMessage wrote `run_id = NULL` — dropping the message back out of the daemon-backed path on the NEXT stale PUT, which could then wipe the protected events/content again (looper review on #6418). Preserve the stored role + runId alongside the other daemon-owned fields when the guard fires, and extend the regression test to replay two stale PUTs that both omit runId, asserting the run id, events, content, and terminal status survive both. * fix(daemon): latch terminal run status against equal-length stale snapshots The length-based freshness check only caught snapshots that SHRANK the stored event list. But the daemon writes the terminal run_status in a separate UPDATE (no event appended), so a web snapshot captured after the final event but before that write has the SAME event count while still carrying a non-terminal status — it previously flowed through and regressed the stored status back to 'running', sticking conversations in a nonterminal state (looper review on #6418). Make terminal run status a daemon-owned latch: once the stored status is terminal (succeeded/failed/canceled), a client PUT that carries any other status is rejected for the daemon-owned fields. Combined with the existing event-shrink rule, and preserving role/runId on both paths. Adds a regression test: run completes (terminal status + events), then an equal-length snapshot with 'running' is replayed and must not regress the terminal status or ownership fields. * fix(daemon): preserve run lifecycle timestamps across stale message PUTs The stale-snapshot guard protected events/content/run-status/ownership but a snapshot that omits startedAt/endedAt still nulled the daemon-written run timestamps via upsertMessage — and saved.endedAt feeds persisted-run telemetry, so a terminal message lost its timing data (looper review on #6418). Preserve stored startedAt/endedAt alongside the other daemon-owned fields in the stale-write branch, and assert in the regression test that the timestamps survive repeated stale PUTs. * fix(daemon): let metadata updates write fresh timestamps on stale-snapshot path Preserving startedAt/endedAt unconditionally in the stale-snapshot branch broke the retry flow: a metadata update that carries a NEW endedAt (empty events, matching terminal status) was treated as a stale full snapshot, so the web's fresh completion timestamp was discarded and the daemon's was kept (e2e retry-after-stop). Preserve the lifecycle timestamps only when the snapshot OMITS them (the #6396 stale case, where upsertMessage would null started_at/ended_at) and let a snapshot that genuinely provides them write through. Events, content, terminal status, and ownership fields keep the no-regression protection. Adds a regression test for the metadata-update path: a fresh endedAt lands while daemon-owned events and terminal status survive. * fix(daemon): scope stale-snapshot lookup to the authorized conversation Two data-integrity gaps from the #6418 guard (looper review): 1. getMessage selected globally by id, so a message id from another conversation could be read (data disclosure in the response) and rewritten through the wrong project/conversation endpoint. Scope the lookup by conversation_id and reject a PUT whose message id belongs to a different conversation. 2. The timestamp exception let a stale snapshot carrying an older endedAt overwrite the daemon's newer value. Make lifecycle timestamps monotonic watermarks: startedAt keeps the earliest (the daemon's first start), endedAt only advances — while a metadata update that genuinely advances endedAt (retry flow) still lands. Adds regression tests: a cross-conversation message id is rejected and the other conversation's row is untouched; monotonic timestamps preserved. * fix(daemon): never regress daemon-written startedAt on stale message PUTs The stale-write branch accepted an earlier startedAt from the incoming snapshot ("earliest wins"), so a captured snapshot carrying an older start time inflated persisted duration/telemetry even though events/content/ status were protected (looper review on #6418). startedAt is the daemon's first start (COALESCE keeps it), so keep the stored value unconditionally in the stale branch; endedAt stays an advance-only watermark so the retry flow's fresh completion timestamp still lands. Extend the stale-snapshot regression with an explicitly older startedAt that must not replace the stored value. * fix(daemon): keep pinned runId on stale pre-run message PUTs A daemon-backed row pinned by /api/runs (runId set, runStatus queued, no events yet) followed by a stale web snapshot that omits runId used to have its run_id nulled: neither shrinksEvents nor regressesTerminalStatus fires on an empty event list with a non-terminal status, so the early return spread the runId-less snapshot unchanged. The row then dropped out of the protected path and the next stale PUT could wipe events once they landed. The no-regression early return now preserves the daemon-ownership markers (role + runId) while still letting the client's non-terminal status and metadata flow. Regression test drives the real PUT route (#6418 review). * fix(daemon): let same-message retries with a new runId replace the failed attempt The stale-write guard added for #6418 correctly preserved role+runId on the no-regression early return, but it also caught legitimate same-message retries: side-chat retries reuse the failed assistant's id, and pinAssistantMessageOnRunCreate assigns a NEW run id while keeping the old terminal status/events/content until the retry's final PUT. The guard then routed that snapshot into the stale branch, keeping the retry stuck on the previous attempt's failure. A snapshot carrying a different runId is the retry (not a stale copy of the old run), so it now flows through untouched. Regression test drives the real PUT route (nettee review on #6418). * fix(daemon): reset run generation at pin time so retries replace the failed attempt The same-message retry path stays stuck on the previous failure: after pinAssistantMessageOnRunCreate rebinds a terminal assistant row to a new run id, the retry's final PUT shares that runId, so the no-regression guard classifies it as a stale shrink/status regression and restores the old failure. A runId carve-out in the guard cannot fix this (the ids already match post-pin) and would open a stale-write hole for old-run snapshots. Make pin the generation boundary instead: when a run creation rebinds an existing assistant message, reset the run-owned fields to this run's start — run_status to the (non-terminal) run status, events/content cleared, ended_at nulled, started_at set to the run's createdAt. The retry's final PUT then flows through the guard normally. Also preserve pin-written startedAt on the eventless early-return path. Tests model the post-pin shape (same runId, fewer events, fresh terminal status) and assert the pin-written start time survives a stale pre-run snapshot (mrcfps review on #6418). * fix(daemon): scope run pin to the conversation and clear last_run_event_id on rebind Addresses the four findings on #6418 (mrcfps + nettee): - pinAssistantMessageOnRunCreate now scopes both the SELECT and the UPDATE to the run's own conversation (and skips a no-row pin whose message id belongs to another conversation), so a mis-scoped run can no longer wipe another conversation's assistant message (nettee P1). - A rebind to a NEW run id now also clears last_run_event_id, so a reattach never resumes from a superseded run's event cursor (mrcfps P1). - A same-run re-pin (AMR recharge-resume) only refreshes status/context and keeps the persisted transcript (mrcfps P2). - mergeMessageWriteForDaemonBacked discards a delayed PUT from a superseded run generation (incoming.runId differs from stored.runId), keeping the current generation's run fields while letting metadata land (nettee P2). Tests: new pin unit suite (rebind reset / resume keep / cross-conversation skip) and a stale-message e2e case for the old-generation discard. * fix(daemon): enforce assistant-message ownership and resume lifecycle timestamps Addresses the three data-integrity blockers on #6418 (nettee 8/8): - Ownership end-to-end: POST /api/runs and /api/chat now reject an assistantMessageId that references a user message (409 INVALID_ASSISTANT_MESSAGE) or an assistant message in another conversation (409 IDEMPOTENCY_CONFLICT), and pinAssistantMessageOnRunCreate scopes its lookup by role too - a mis-scoped run can no longer pin/append/finalize a row it does not own. - Recharge resume: the same-run pin branch clears ended_at so the resumed completion records a fresh terminal timestamp instead of the prior failure's end. - Stale terminal snapshot: the PUT guard discards a terminal failed snapshot when the stored row is non-terminal (i.e. after a same-run resume), so the resumed run cannot relatch the old failure; the current run's failure is finalized by the daemon, never by a web PUT. Tests: route ownership rejections (foreign conversation + user row), pin unit ended_at clear, and a stale-message e2e case for the resume relatch. * fix(daemon): reject unverifiable assistantMessageId and active-run rebinds Addresses the two additional ownership/lifecycle hazards on #6418 (nettee 8/8): - POST /api/runs and /api/chat now reject a supplied assistantMessageId when no conversation can be resolved (400) — without a conversation there is nothing to validate ownership against, and the run would mutate a foreign row via the id-only writers. - Both routes also reject an assistantMessageId whose existing row is owned by a still-active (queued/running) run (409 RUN_IN_PROGRESS), so two concurrent runs sharing the id cannot reset each other's in-flight generation. pinAssistantMessageOnRunCreate adds the same skip as defense in depth. Tests: route regressions for no-conversation (400) and active-run rebind (409), plus a pin unit case asserting an active row's transcript is never generation-reset. * fix(daemon): allow runId-less web placeholders through the active-run guard The active-run rebind guard broke the normal pre-run flow: the web persists an assistant placeholder with runStatus set but no run bound yet, then submits the run with that assistantMessageId. The guard read runStatus only and rejected it as RUN_IN_PROGRESS, failing the e2e dialog suites. Scope the guard to rows bound to a REAL run (run_id non-empty): only those can be owned by a still-active concurrent run. A runId-less placeholder stays rebindable. Same condition applied to the pin-side defense-in-depth skip. Tests: pin unit case for a runId-less placeholder binding to the new run; existing active-run rebind rejection (with a real runId) unchanged. * fix(daemon): keep web-persisted placeholder startedAt on first pin The generation-reset rebind overwrote started_at with the run's createdAt for a runId-less web placeholder, clobbering the turn's own start time the UI had already persisted (retry-after-stop e2e asserted the stopped attempt keeps its startedAt). Only a retry that rebinds an OLD-run row resets started_at to the new run's createdAt; a runId-less placeholder keeps its web-persisted start via COALESCE. Pin unit test updated to assert the placeholder start time survives. * fix(daemon): make rebind concurrency guard daemon-aware; drop staleResumeFailure The route-level active-run guard was rejecting the normal UI flow: the web persists a runId-less assistant placeholder, submits the run, and after a failure clicks retry — the guard read the message row's own runStatus and rejected both as RUN_IN_PROGRESS, failing the dialog/workspace-restoration e2e suites. The message row's runStatus is not authoritative for concurrency. - The active-run reject now consults design.runs.get(runId): only when the daemon STILL has that run active is the rebind rejected (409 RUN_IN_PROGRESS). A runId-less placeholder, or a run the daemon has already finished (normal retry), stays rebindable. - Drop the staleResumeFailure heuristic: it treated every non-terminal stored row + incoming failed as a stale post-resume snapshot, which also swallowed a genuine run failure (stored queued -> incoming failed) and left the message stuck non-terminal, breaking the retry chain. Genuine failures now persist again; post-resume stale-snapshot relatch is left to a future daemon-side generation boundary rather than an unreliable heuristic. Tests: retry/placeholder rebind now asserts the daemon-no-longer-owns case succeeds (no RUN_IN_PROGRESS); the staleResumeFailure-only test is removed. * fix(daemon): exempt idempotent retries and confirm failures via daemon state Addresses the nettee re-review on b4c33bf: - The active-run rebind guard now exempts a same-clientRequestId idempotent retry: when the daemon run's clientRequestId matches the request, the daemon reuses the original run and must not be rejected as RUN_IN_PROGRESS (run-request-idempotency suite). - The post-resume stale-snapshot guard is now daemon-aware: a terminal failed snapshot against a non-terminal stored row is discarded only when the daemon does NOT report the run as failed (stale pre-resume copy). A failure the daemon confirms (written via reconcileAssistantMessageOnRunEnd) still lands, so genuine run failures persist and the retry chain works. run-request-idempotency (6) + stale-message + pin + cross-project (20) pass; daemon typecheck clean. * fix(daemon): make assistant-message ownership atomic at run creation The core concurrency hole (PerishCode): two runs sharing an assistantMessageId could both create and write through the same message row, because the pin was a void best-effort — on detecting an active owner it skipped, and the run started anyway. Every route-level heuristic to close it mis-fired on the normal UI flow (placeholder, retry, idempotent retry). Make ownership atomic: pinAssistantMessageOnRunCreate is now an atomic claim inside db.transaction().immediate(), executing a single conditional UPDATE (WHERE run_id IS NULL OR run_id = <this run> OR run_status IN (succeeded,failed,canceled)) that returns changes>0. A claim failure drops the just-created run (design.runs.drop) and rejects with 409 RUN_IN_PROGRESS, so no run ever starts without owning its assistant message. The racy design.runs.get pre-checks are removed; placeholder/retry/idempotent semantics fall out of the claim's WHERE conditions (same-run refresh, terminal rebind generation reset, placeholder startedAt preservation). Tests: run-atomic-ownership.test.ts — two concurrent POSTs with the same assistantMessageId resolve to exactly [202, 409], only the winner spawns a child, and a web-persisted placeholder startedAt survives the first claim. Existing pin/stale-message/cross-project/idempotency suites (69 total) pass. * fix(daemon): arbitrate terminal writes, claim before seed, never drop resumed runs Addresses nettee's 8/10 review on the atomic-ownership head: - Terminal-write arbitration (conversations.ts): the guard now keys on ALL client terminal statuses against what the daemon positively knows. A daemon it does not know (mock/client-owned row) accepts the client terminal write; a daemon-owned run still active preserves every daemon field (no premature succeeded/failed/canceled latch, no reopen for a competing claim); a daemon terminal that disagrees is authoritative. This stops both discarded mock-path failures and premature-terminal rebinds. - Claim before seed (runs.ts POST /api/runs): the atomic ownership claim runs BEFORE user-message seeding, so a rejected concurrent run never leaves an orphan user turn. - Resume safety (runs.ts + chat-run-messages.ts): a resumed run's claim runs before prepareRestart and only freshly-created losers are dropped — a resumable run that loses a claim stays terminal + resumable instead of being deleted. pinAssistantMessageOnRunCreate gains a status override so a resume claim writes the post-restart queued intent. Tests: status-override pin unit; concurrent orphan-user-turn route case. 71 tests + typecheck pass. * fix(daemon): key terminal arbitration on daemon state, not stored status presence nettee 8/10 blocking: a stale whole-message PUT that omits runStatus used to null the daemon-owned status column, which reopened the terminal gate — a later premature terminal PUT could then latch succeeded/failed onto an active daemon run and make the row re-claimable. - Terminal arbitration now keys on the daemon lookup for ANY non-terminal (or status-less) stored row, not only when stored.runStatus is defined. - The accepted (no-regression) merge preserves the stored run status when the snapshot omits it, and applies the same monotonic endedAt watermark. - New integration regression: status-less PUT -> premature terminal PUT against an active run -> competing claim is still rejected with 409. 72 tests + typecheck pass. * fix(daemon): return ok from the workspace-gate claim stub The atomic claim contract changed from void to { ok, reason }; the run-create-workspace-gate suite's injected stub still returned void, so every created run was treated as an unclaimed loser (31 failures). Return { ok: true } from the stub — the suite is back to its 3 pre-existing BYOK failures. * fix(daemon): preserve role on the terminal-arbitration preserve path The arbitration preserve return was missing role: stored.role (sibling preserve paths already restore it), which could let a stale snapshot carry a different role. Restore it for consistency with the daemon-ownership marker. * fix(daemon): preserve run seed message order * fix(daemon): reject aliased run message ids * fix(daemon): preserve stale write event cursor * fix(daemon): preserve placeholder content on run claim * fix(daemon): validate chat assistant conversation claims * fix(daemon): harden run message ownership edge cases * fix(daemon): preserve content on equal-length stale snapshots * fix(daemon): protect partial equal-length content snapshots * fix(daemon): preserve daemon content on event growth * fix(daemon): atomically seed claimed run user turn
466 lines
17 KiB
TypeScript
466 lines
17 KiB
TypeScript
// #6418 acceptance gate: the run creation must atomically claim the assistant
|
|
// message. Two concurrent runs sharing an assistantMessageId can never both
|
|
// claim it — exactly one succeeds, the other is rejected with RUN_IN_PROGRESS
|
|
// and its run is dropped (no child process spawned).
|
|
|
|
import type { Server } from 'node:http';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { startServer } from '../src/server.js';
|
|
|
|
type StartedServer = { url: string; server: Server; shutdown?: () => Promise<void> | void };
|
|
|
|
describe('run creation atomic assistant-message ownership (#6418)', () => {
|
|
let started: StartedServer | null = null;
|
|
let binDir: string | null = null;
|
|
|
|
afterEach(async () => {
|
|
await Promise.resolve(started?.shutdown?.());
|
|
if (started?.server) {
|
|
await new Promise<void>((resolve) => started?.server.close(() => resolve()));
|
|
}
|
|
started = null;
|
|
if (binDir) await rm(binDir, { recursive: true, force: true });
|
|
binDir = null;
|
|
});
|
|
|
|
async function startWithHangingClaude() {
|
|
delete process.env.POSTHOG_KEY;
|
|
delete process.env.POSTHOG_HOST;
|
|
delete process.env.LANGFUSE_PUBLIC_KEY;
|
|
delete process.env.LANGFUSE_SECRET_KEY;
|
|
delete process.env.LANGFUSE_BASE_URL;
|
|
delete process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL;
|
|
|
|
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-atomic-bin-'));
|
|
const bin = path.join(binDir, 'claude');
|
|
const invocationPath = path.join(binDir, 'invocations.jsonl');
|
|
await writeFile(
|
|
bin,
|
|
`#!/usr/bin/env node
|
|
const fs = require('node:fs');
|
|
const invocationPath = ${JSON.stringify(invocationPath)};
|
|
if (process.argv.includes('--version')) { console.log('claude-code 1.0.0'); process.exit(0); }
|
|
if (process.argv.includes('--help')) { console.log('Usage: claude -p'); process.exit(0); }
|
|
fs.appendFileSync(invocationPath, 'x\\n');
|
|
setInterval(() => {}, 1000);
|
|
`,
|
|
'utf8',
|
|
);
|
|
await chmod(bin, 0o755);
|
|
|
|
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
|
|
const response = await fetch(`${started.url}/api/app-config`, {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'claude',
|
|
agentCliEnv: { claude: { CLAUDE_BIN: bin } },
|
|
telemetry: { metrics: false, content: false, artifactManifest: false },
|
|
privacyDecisionAt: Date.now(),
|
|
}),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
return { url: started.url, invocationPath };
|
|
}
|
|
|
|
async function createProject(url: string) {
|
|
const projectId = `atomic_${randomUUID()}`;
|
|
const project = await fetch(`${url}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Atomic smoke',
|
|
metadata: { kind: 'prototype' },
|
|
skipDiscoveryBrief: true,
|
|
}),
|
|
});
|
|
expect(project.status).toBe(200);
|
|
const body = (await project.json()) as { conversationId: string };
|
|
return { projectId, conversationId: body.conversationId };
|
|
}
|
|
|
|
async function postRun(url: string, body: Record<string, unknown>) {
|
|
return fetch(`${url}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function fetchAssistantMessage(
|
|
url: string,
|
|
projectId: string,
|
|
conversationId: string,
|
|
messageId: string,
|
|
) {
|
|
const response = await fetch(
|
|
`${url}/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages`,
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { messages: Array<Record<string, unknown>> };
|
|
return body.messages.find((m) => m.id === messageId);
|
|
}
|
|
|
|
async function listMessages(url: string, projectId: string, conversationId: string) {
|
|
const response = await fetch(
|
|
`${url}/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages`,
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { messages: Array<{ id: string; role: string }> };
|
|
return body.messages;
|
|
}
|
|
|
|
async function waitForInvocation(invocationPath: string): Promise<string> {
|
|
const deadline = Date.now() + 5_000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const raw = await readFile(invocationPath, 'utf8');
|
|
if (raw.trim()) return raw;
|
|
} catch {
|
|
// file not written yet — the daemon spawns the child asynchronously
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
throw new Error('winning run never spawned the agent CLI');
|
|
}
|
|
|
|
it('atomically lets only one of two concurrent runs claim the assistant message', async () => {
|
|
const { url, invocationPath } = await startWithHangingClaude();
|
|
const { projectId, conversationId } = await createProject(url);
|
|
|
|
const assistantMessageId = `assistant_concurrent_${randomUUID()}`;
|
|
const base = {
|
|
projectId,
|
|
conversationId,
|
|
assistantMessageId,
|
|
agentId: 'claude',
|
|
message: 'M',
|
|
currentPrompt: 'M',
|
|
};
|
|
const body1 = { ...base, clientRequestId: `c1_${randomUUID()}` };
|
|
const body2 = { ...base, clientRequestId: `c2_${randomUUID()}` };
|
|
|
|
const [r1, r2] = await Promise.all([
|
|
postRun(url, body1),
|
|
postRun(url, body2),
|
|
]);
|
|
|
|
const statuses = [r1.status, r2.status].sort();
|
|
expect(statuses).toEqual([202, 409]);
|
|
const loser = r1.status === 409 ? r1 : r2;
|
|
expect((await loser.json())).toMatchObject({ error: { code: 'RUN_IN_PROGRESS' } });
|
|
|
|
// Only the winning run spawned a child process.
|
|
const raw = await waitForInvocation(invocationPath);
|
|
expect(raw.trim().split('\n').filter(Boolean)).toHaveLength(1);
|
|
});
|
|
|
|
it('a status-less stale snapshot cannot null the status or reopen a claim', async () => {
|
|
// nettee 8/10 on #6418: a whole-message PUT that omits runStatus must not
|
|
// null the daemon-owned status column (which would open the terminal gate),
|
|
// and a premature terminal PUT against an active daemon run must be
|
|
// discarded so the row cannot be re-claimed while the run is still writing.
|
|
const { url } = await startWithHangingClaude();
|
|
const { projectId, conversationId } = await createProject(url);
|
|
const assistantMessageId = `assistant_statusless_${randomUUID()}`;
|
|
|
|
// run #1 claims the message (daemon keeps it active).
|
|
const r1 = await postRun(url, {
|
|
projectId,
|
|
conversationId,
|
|
assistantMessageId,
|
|
agentId: 'claude',
|
|
message: 'M',
|
|
currentPrompt: 'M',
|
|
clientRequestId: `s1_${randomUUID()}`,
|
|
});
|
|
expect(r1.status).toBe(202);
|
|
|
|
// A stale whole-message PUT omits runStatus — must not null the column.
|
|
const statusless = await fetch(
|
|
`${url}/api/projects/${projectId}/conversations/${conversationId}/messages/${assistantMessageId}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ id: assistantMessageId, role: 'assistant', content: 'stale', events: [] }),
|
|
},
|
|
);
|
|
expect(statusless.status).toBe(200);
|
|
const afterStatusless = await fetchAssistantMessage(url, projectId, conversationId, assistantMessageId);
|
|
expect(afterStatusless?.runStatus).toBeDefined();
|
|
|
|
// A premature terminal PUT must be discarded (daemon still active).
|
|
const premature = await fetch(
|
|
`${url}/api/projects/${projectId}/conversations/${conversationId}/messages/${assistantMessageId}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ id: assistantMessageId, role: 'assistant', content: 'done', runStatus: 'succeeded', events: [] }),
|
|
},
|
|
);
|
|
expect(premature.status).toBe(200);
|
|
const afterPremature = await fetchAssistantMessage(url, projectId, conversationId, assistantMessageId);
|
|
expect(afterPremature?.runStatus).not.toBe('succeeded');
|
|
|
|
// A second run with the same assistantMessageId must still be rejected.
|
|
const r2 = await postRun(url, {
|
|
projectId,
|
|
conversationId,
|
|
assistantMessageId,
|
|
agentId: 'claude',
|
|
message: 'M',
|
|
currentPrompt: 'M',
|
|
clientRequestId: `s2_${randomUUID()}`,
|
|
});
|
|
expect(r2.status).toBe(409);
|
|
});
|
|
|
|
it('does not move a daemon-owned running message backward to queued', async () => {
|
|
// nettee on #6418: onRunCreated(queued) and onRunStatus(running) can arrive
|
|
// as separate whole-message PUTs. A delayed queued snapshot must not regress
|
|
// a daemon-known row that is already running.
|
|
const { url, invocationPath } = await startWithHangingClaude();
|
|
const { projectId, conversationId } = await createProject(url);
|
|
const assistantMessageId = `assistant_status_order_${randomUUID()}`;
|
|
|
|
const created = await postRun(url, {
|
|
projectId,
|
|
conversationId,
|
|
assistantMessageId,
|
|
agentId: 'claude',
|
|
message: 'M',
|
|
currentPrompt: 'M',
|
|
clientRequestId: `order_${randomUUID()}`,
|
|
});
|
|
expect(created.status).toBe(202);
|
|
await waitForInvocation(invocationPath);
|
|
const claimed = await fetchAssistantMessage(url, projectId, conversationId, assistantMessageId);
|
|
expect(claimed?.runId).toBeTypeOf('string');
|
|
|
|
const runningPut = await fetch(
|
|
`${url}/api/projects/${projectId}/conversations/${conversationId}/messages/${assistantMessageId}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: assistantMessageId,
|
|
role: 'assistant',
|
|
content: claimed?.content ?? '',
|
|
runId: claimed?.runId,
|
|
runStatus: 'running',
|
|
events: claimed?.events ?? [],
|
|
}),
|
|
},
|
|
);
|
|
expect(runningPut.status).toBe(200);
|
|
const running = await fetchAssistantMessage(url, projectId, conversationId, assistantMessageId);
|
|
expect(running?.runStatus).toBe('running');
|
|
|
|
const staleQueued = await fetch(
|
|
`${url}/api/projects/${projectId}/conversations/${conversationId}/messages/${assistantMessageId}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: assistantMessageId,
|
|
role: 'assistant',
|
|
content: running?.content ?? '',
|
|
runId: running?.runId,
|
|
runStatus: 'queued',
|
|
events: running?.events ?? [],
|
|
}),
|
|
},
|
|
);
|
|
expect(staleQueued.status).toBe(200);
|
|
|
|
const after = await fetchAssistantMessage(url, projectId, conversationId, assistantMessageId);
|
|
expect(after?.runStatus).toBe('running');
|
|
});
|
|
|
|
it('a rejected concurrent run leaves no orphan user turn', async () => {
|
|
// nettee 8/10 on #6418: the claim must precede user-message seeding, so a
|
|
// rejected (loser) concurrent run never leaves its user row behind.
|
|
const { url } = await startWithHangingClaude();
|
|
const { projectId, conversationId } = await createProject(url);
|
|
|
|
const assistantMessageId = `assistant_orphan_${randomUUID()}`;
|
|
const body1 = {
|
|
projectId,
|
|
conversationId,
|
|
assistantMessageId,
|
|
userMessageId: `u1_${randomUUID()}`,
|
|
agentId: 'claude',
|
|
message: 'M',
|
|
currentPrompt: 'M',
|
|
clientRequestId: `o1_${randomUUID()}`,
|
|
};
|
|
const body2 = {
|
|
...body1,
|
|
userMessageId: `u2_${randomUUID()}`,
|
|
clientRequestId: `o2_${randomUUID()}`,
|
|
};
|
|
|
|
const [r1, r2] = await Promise.all([postRun(url, body1), postRun(url, body2)]);
|
|
const statuses = [r1.status, r2.status].sort();
|
|
expect(statuses).toEqual([202, 409]);
|
|
const winner = r1.status === 202 ? body1 : body2;
|
|
const loser = r1.status === 409 ? body1 : body2;
|
|
|
|
const messages = await listMessages(url, projectId, conversationId);
|
|
const userMessageIds = messages.filter((m) => m.role === 'user').map((m) => m.id);
|
|
expect(userMessageIds).toContain(winner.userMessageId);
|
|
expect(userMessageIds).not.toContain(loser.userMessageId);
|
|
});
|
|
|
|
it('rejects equal user and assistant message ids before claiming the run', async () => {
|
|
// PerishCode on #6418: a malformed API/MCP request must not let the
|
|
// fresh-claim ordering path seed a user row and then overwrite it with the
|
|
// assistant row under the same primary key.
|
|
const { url, invocationPath } = await startWithHangingClaude();
|
|
const { projectId, conversationId } = await createProject(url);
|
|
const sharedMessageId = `shared_${randomUUID()}`;
|
|
|
|
const response = await postRun(url, {
|
|
projectId,
|
|
conversationId,
|
|
userMessageId: sharedMessageId,
|
|
assistantMessageId: sharedMessageId,
|
|
agentId: 'claude',
|
|
message: 'M',
|
|
currentPrompt: 'M',
|
|
clientRequestId: `same_${randomUUID()}`,
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(await response.json()).toMatchObject({
|
|
error: {
|
|
code: 'BAD_REQUEST',
|
|
message: 'userMessageId and assistantMessageId must be distinct',
|
|
},
|
|
});
|
|
|
|
const messages = await listMessages(url, projectId, conversationId);
|
|
expect(messages).toEqual([]);
|
|
await expect(readFile(invocationPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('rejects an existing non-user userMessageId before seeding', async () => {
|
|
// nettee on #6418: seedRunUserMessage upserts by id. If a malformed
|
|
// API/MCP request points userMessageId at an existing assistant row, the
|
|
// seed would otherwise convert and overwrite that assistant transcript
|
|
// before the fresh assistant row is inserted.
|
|
const { url, invocationPath } = await startWithHangingClaude();
|
|
const { projectId, conversationId } = await createProject(url);
|
|
const existingAssistantId = `assistant_as_user_${randomUUID()}`;
|
|
const newAssistantId = `assistant_fresh_${randomUUID()}`;
|
|
|
|
const seed = await fetch(
|
|
`${url}/api/projects/${projectId}/conversations/${conversationId}/messages/${existingAssistantId}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: existingAssistantId,
|
|
role: 'assistant',
|
|
content: 'old assistant transcript',
|
|
runId: 'old-run',
|
|
runStatus: 'succeeded',
|
|
events: [{ kind: 'text', text: 'old assistant transcript' }],
|
|
}),
|
|
},
|
|
);
|
|
expect(seed.status).toBe(200);
|
|
|
|
const response = await postRun(url, {
|
|
projectId,
|
|
conversationId,
|
|
userMessageId: existingAssistantId,
|
|
assistantMessageId: newAssistantId,
|
|
agentId: 'claude',
|
|
message: 'new prompt',
|
|
currentPrompt: 'new prompt',
|
|
clientRequestId: `role_${randomUUID()}`,
|
|
});
|
|
|
|
expect(response.status).toBe(409);
|
|
expect(await response.json()).toMatchObject({
|
|
error: { code: 'INVALID_USER_MESSAGE' },
|
|
});
|
|
|
|
const oldMessage = await fetchAssistantMessage(
|
|
url,
|
|
projectId,
|
|
conversationId,
|
|
existingAssistantId,
|
|
);
|
|
expect(oldMessage).toMatchObject({
|
|
role: 'assistant',
|
|
content: 'old assistant transcript',
|
|
runId: 'old-run',
|
|
runStatus: 'succeeded',
|
|
});
|
|
expect(await fetchAssistantMessage(url, projectId, conversationId, newAssistantId))
|
|
.toBeUndefined();
|
|
await expect(readFile(invocationPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('keeps a web-persisted placeholder startedAt through the first claim', async () => {
|
|
const { url } = await startWithHangingClaude();
|
|
const { projectId, conversationId } = await createProject(url);
|
|
|
|
const assistantMessageId = `assistant_placeholder_${randomUUID()}`;
|
|
const startedAt = Date.now();
|
|
|
|
// Web persists a runId-less assistant placeholder with its own startedAt.
|
|
const seed = await fetch(
|
|
`${url}/api/projects/${projectId}/conversations/${conversationId}/messages/${assistantMessageId}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: assistantMessageId,
|
|
role: 'assistant',
|
|
content: '',
|
|
runStatus: 'running',
|
|
startedAt,
|
|
}),
|
|
},
|
|
);
|
|
expect(seed.status).toBe(200);
|
|
|
|
// First run claims the placeholder (202); its startedAt survives.
|
|
const r1 = await postRun(url, {
|
|
projectId,
|
|
conversationId,
|
|
assistantMessageId,
|
|
agentId: 'claude',
|
|
message: 'M',
|
|
currentPrompt: 'M',
|
|
clientRequestId: `p1_${randomUUID()}`,
|
|
});
|
|
expect(r1.status).toBe(202);
|
|
|
|
const msg = await fetchAssistantMessage(url, projectId, conversationId, assistantMessageId);
|
|
expect(msg?.startedAt).toBe(startedAt);
|
|
|
|
// A second concurrent run with the same assistantMessageId is rejected.
|
|
const r2 = await postRun(url, {
|
|
projectId,
|
|
conversationId,
|
|
assistantMessageId,
|
|
agentId: 'claude',
|
|
message: 'M',
|
|
currentPrompt: 'M',
|
|
clientRequestId: `p2_${randomUUID()}`,
|
|
});
|
|
expect(r2.status).toBe(409);
|
|
expect((await r2.json())).toMatchObject({ error: { code: 'RUN_IN_PROGRESS' } });
|
|
});
|
|
});
|