Files
open-design/apps/daemon/tests/run-atomic-ownership.test.ts
YOMXXXandGitHub dd1c0f6c61 fix(daemon): keep daemon as sole writer of run events on stale message PUT (#6418)
* 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
2026-08-12 06:36:05 +00:00

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' } });
});
});