Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
44 changes: 44 additions & 0 deletions docs/design/daemon-turn-status-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Daemon turn-status endpoint

## Goal

Let clients that do not keep the session SSE stream open poll the state and raw final main answer of an admitted daemon prompt by `promptId`.

The feature is advertised by the always-on `session_turn_status` capability. It has no setting, flag, or environment variable.

## Scope and ownership

The read-only routes are Session-scoped:

- `GET /session/:id/turns/:promptId`
- `GET /session/:id/turns/current`

They resolve the live runtime that owns `sessionId`, apply the same client-id authorization as `/prompt`, and never scan another workspace or fall back to the primary runtime. The Session must already be live; polling does not load or resume an offline Session.

`current` returns the running prompt, otherwise the FIFO queued head, otherwise the newest settled result, otherwise `idle`. The exact route returns `404 prompt_not_found` when the live queue, the bounded bridge overlay, and the bounded active-transcript scan contain no matching result. This does not prove that the prompt never existed.

## Result semantics

States are `idle`, `queued`, `running`, `completed`, `cancelled`, and `error`. `queuedAt` is admission time. `startedAt` is present only after actual FIFO dispatch into Session/model execution. `endedAt` is terminal time.
Comment thread
BenGuanRan marked this conversation as resolved.
Outdated

`resultText` is the raw canonical final main answer: top-level, non-thought text from the last primary-model response block that does not contain a tool call. Text emitted before a tool call is discarded. Tool output, thought text, subagent stream updates, diagnostics, background messages, slash-command output, and future output from a sent sub-session are excluded. Optional message rewriting is downstream presentation and does not change this field. A completed turn can therefore have no `resultText` when the parent model produced no final text.

`promptText` and `resultText` are limited to 32,768 UTF-16 code units. A truncated result has `resultTruncated: true` and `resultCode: "RESULT_TEXT_TRUNCATED"`. Error messages and codes are normalized without invoking unsafe getters and are limited to 4,096 and 256 code units respectively.
Comment thread
BenGuanRan marked this conversation as resolved.
Outdated

## Live and persisted sources

The bridge owns live FIFO state plus a fixed 64-entry terminal overlay. Formal terminal publication is first-writer-wins. Pending entries that are removed or already terminal are never projected as queued/running. Polling re-reads the overlay after an awaited child read, including when that read fails, so a concurrent terminal cannot regress to queued or not-found. When overlay and transcript contain the same prompt, the overlay outcome remains authoritative and the transcript can enrich it with `resultText`.

Session is the only transcript writer. A daemon prompt that reaches `Session.prompt()` appends one best-effort `turn_result` system record through `ChatRecordingService`. Recording failure never changes the prompt lifecycle. Reads best-effort flush the recorder and walk at most 10 backward pages of 500 active records, with the existing 4 MiB page and snapshot limits. Invalid cursor, unavailable snapshot, oversized snapshot, and oversized page errors remain structured errors rather than becoming not-found.

Normal restart lookup therefore requires recording to be enabled, the append to have succeeded, the result to remain on the active branch and within the bounded scan window, and the Session to be loaded live again. Deleting the JSONL, disabling recording, a failed append, or leaving the bounded window removes that guarantee.
Comment thread
BenGuanRan marked this conversation as resolved.

Prompts accepted only by the bridge but never dispatched into Session, including queued removal, queued deadline, close/kill cancellation, or forward failure, are available from the in-process overlay only. Unexpected process crashes and daemon shutdown do not trigger transcript backfill.

## History operations

A failed rewind keeps the overlay. A successful rewind clears it; the child reader's active transcript branch then decides which results remain queryable. Forking excludes `turn_result` records so a new Session cannot inherit source prompt identities.

## Non-goals

This is not an exactly-once or permanent result store. It adds no strict teardown persistence, close/kill write barrier, crash recovery journal, daemon transcript writer, offline workspace scan, promptId index, rewind coordinate map, or message-rewrite refactor.
2 changes: 2 additions & 0 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,8 @@ Both events live in the per-session SSE replay ring (they carry an `id`) so a cl

## Routes

Clients can feature-detect `session_turn_status` and poll `GET /session/:id/turns/current` or `GET /session/:id/turns/:promptId`. These routes require the live owning Session and never load or scan another workspace. Settled results are best-effort transcript records read from the active branch with a bounded scan; `prompt_not_found` means no result was found in the live queue, 64-entry terminal overlay, or bounded active window. `resultText` is the raw final parent-model answer after the last tool boundary, before optional message rewriting, and may be absent. Results over 32,768 UTF-16 code units include `resultTruncated: true` and `resultCode: "RESULT_TEXT_TRUNCATED"`.

### `GET /health`

Liveness probe. Default form returns `200 {"status":"ok"}` if the listener is up — cheap, no bridge access, suitable for high-frequency k8s/Compose liveness probes.
Expand Down
1 change: 1 addition & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_source_metadata',
'session_side_task',
'session_prompt',
'session_turn_status',
Comment thread
BenGuanRan marked this conversation as resolved.
'session_mid_turn_message_mutation',
'session_mid_turn_message_query',
'session_cancel',
Expand Down
108 changes: 107 additions & 1 deletion integration-tests/cli/qwen-serve-streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { isPathWithinRoot } from '@qwen-code/qwen-code-core';
import {
isPathWithinRoot,
TURN_RESULT_TEXT_MAX_CHARS,
} from '@qwen-code/qwen-code-core';
import { DaemonClient, parseSseStream } from '@qwen-code/sdk';
import type { DaemonEvent, DaemonSessionSummary } from '@qwen-code/sdk';
import {
Expand Down Expand Up @@ -194,6 +197,32 @@ beforeAll(async () => {
return { content: 'The test Todo remains unfinished.' };
}

if (messages.includes('turn-final-answer-boundary-e2e')) {
const toolCallId = 'call_turn_final_answer_boundary';
if (!messages.includes(toolCallId)) {
return {
content: 'I will inspect the fixture first. ',
toolCalls: [
fakeToolCall(
'read_file',
{
file_path: path.join(
workspaceDir,
'turn-final-answer-boundary.txt',
),
},
toolCallId,
),
],
};
}
return { content: 'The strict final answer is 42.' };
}

if (messages.includes('turn-result-truncation-e2e')) {
return { content: 'z'.repeat(TURN_RESULT_TEXT_MAX_CHARS + 100) };
}

if (pendingWritePath && messages.includes('fan-out') && !hasToolResult) {
return {
toolCalls: [
Expand Down Expand Up @@ -272,6 +301,10 @@ beforeAll(async () => {
}),
);
workspaceDir = mkdtempSync(path.join(tmpdir(), 'qwen-serve-streaming-ws-'));
writeFileSync(
path.join(workspaceDir, 'turn-final-answer-boundary.txt'),
'42',
);
daemon = spawn(
process.execPath,
[
Expand Down Expand Up @@ -389,6 +422,79 @@ async function* sseFrames(
yield* parseSseStream(res.body!, opts.signal);
}

async function turnStatus(
sessionId: string,
promptId: string,
): Promise<Record<string, unknown>> {
const response = await fetch(
`${base}/session/${sessionId}/turns/${promptId}`,
{ headers: { Authorization: `Bearer ${TOKEN}` } },
);
if (!response.ok) return { status: response.status };
return (await response.json()) as Record<string, unknown>;
}

describePOSIX('qwen serve — pollable turn results', () => {
Comment thread
BenGuanRan marked this conversation as resolved.
it('returns only the final parent answer after a tool boundary', async () => {
Comment thread
BenGuanRan marked this conversation as resolved.
const session = await client.createOrAttachSession({
workspaceCwd: workspaceDir,
sessionScope: 'thread',
});
await client.setSessionApprovalMode(session.sessionId, 'yolo');
try {
const accepted = asAccepted(
await client.promptNonBlocking(session.sessionId, {
prompt: [{ type: 'text', text: 'turn-final-answer-boundary-e2e' }],
}),
);
expect(accepted).toBeDefined();
if (!accepted) return;

await expect
.poll(() => turnStatus(session.sessionId, accepted.promptId), {
timeout: 30_000,
})
.toMatchObject({
state: 'completed',
stopReason: 'end_turn',
resultText: 'The strict final answer is 42.',
});
} finally {
await client.closeSession(session.sessionId).catch(() => undefined);
}
}, 60_000);

it('reports truncation through the stable result code', async () => {
const session = await client.createOrAttachSession({
workspaceCwd: workspaceDir,
sessionScope: 'thread',
});
try {
const accepted = asAccepted(
await client.promptNonBlocking(session.sessionId, {
prompt: [{ type: 'text', text: 'turn-result-truncation-e2e' }],
}),
);
expect(accepted).toBeDefined();
if (!accepted) return;

await expect
.poll(() => turnStatus(session.sessionId, accepted.promptId), {
timeout: 30_000,
})
.toMatchObject({
state: 'completed',
resultTruncated: true,
resultCode: 'RESULT_TEXT_TRUNCATED',
});
const status = await turnStatus(session.sessionId, accepted.promptId);
expect(status['resultText']).toHaveLength(TURN_RESULT_TEXT_MAX_CHARS);
} finally {
await client.closeSession(session.sessionId).catch(() => undefined);
}
}, 60_000);
});

describePOSIX('qwen serve — child-crash recovery (real SIGKILL)', () => {
it('publishes session_died after the qwen --acp child is SIGKILL-ed', async () => {
const session = await client.createOrAttachSession({
Expand Down
Loading
Loading