mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-26 17:13:32 +00:00
* feat(serve): persist prompt terminal ledger for cold-load reconciliation Turn terminal events (turn_complete / turn_error) were synthesized by the ACP bridge and published over SSE only, so a prompt that was in flight when the daemon died could never be resolved after a restart: the cold load replay emits transcript chunks and carries no terminal evidence, leaving promptId-keyed orchestrators stuck on "unknown". Each session now owns an append-only sidecar ledger next to its transcript. The bridge appends one in_flight record at prompt admission and one terminal record at the single publishPromptTerminal exit (covering the close/kill/channel-crash/daemon-shutdown flushes) through an injected synchronous sink. Ledger writes are best-effort and never block prompt execution or teardown, and records carry only ids, states, and timestamps — no prompt text, user content, or paths. On a cold session load the serve layer reconciles prompts left dangling by a dead daemon: it classifies the transcript tail with the existing turn-interruption detector and appends a completed (stop reason reconstructed_from_transcript) or interrupted (code daemon_lost) verdict, guarded by an attribution check so an unattributable tail stays unknown (fail-closed). The load response gains an optional promptTerminals field with the trailing 64 terminal records, omitted entirely when the ledger holds no terminal evidence, and archive/unarchive move the sidecar alongside the transcript so evidence survives storage lifecycle. Design: docs/design/2026-08-19-prompt-terminal-ledger-design.md * fix(serve): tighten ledger reconciliation fail-closed semantics and complete sidecar lifecycle Address review findings on the prompt terminal ledger: - reconcile: fail closed on multiple dangling prompts (no synthesized terminal for the newest either); attribute the oldest dangling prompt only when the attribution guard skips settled admissions (fixes the [A if, B if, B cancelled] misattribution veto), the transcript's last write postdates the admission (temporal evidence), and a clean verdict is upgraded to interrupted when the model tail holds any functionCall part, id or not (id-less tool-call guard covering the detectTurnInterruption wire-pairing blind spot) - lifecycle: removeSessionFiles deletes the ledger in both archive states; archive/unarchive move it through a single getPromptLedgerPathForState helper with merge semantics when the destination already exists (append-and-unlink instead of a permanent split); move warnings carry full source and destination paths in both directions - scans: DataProcessor.scanChatFiles and usageHistoryService.rebuildFromSessionJsonl exclude .ledger.jsonl sidecars (the ledger is not a transcript) - writer: appendPromptLedgerRecord seals a torn tail before appending so a torn fragment cannot fuse with (and destroy) the next record - tests: pin the new behavior across multi-dangling fail-closed, settled-then-queued attribution, valid interleave migration, temporal veto, id-less tool-call guard, sidecar lifecycle (move/merge/warn-only delete), torn-tail sealing, queued-admission flush on shutdown, active-prompt and resume load contracts, and ledger exclusion from insight scans - docs: sync the design doc's reconciliation algorithm, lifecycle, and fail-closed invariants * perf(serve): read only the ledger tail for load-response promptTerminals readRecentPromptTerminals ran on every POST /session/:id/load (including attached hot loads) and synchronously read and JSON-parsed the entire ledger — a multi-megabyte event-loop stall for long sessions on the per-request hot path. Add a tailBytes option to readPromptLedgerRecords that reads a trailing byte window (the first window line is always dropped: the window start can tear a line in half). The load path now reads a 256 KiB window, which holds hundreds of ~150-byte records against the 64-terminal response cap; sessions whose ledger outgrows the window return a best-effort trailing subset, which the response contract already allows. * fix(serve): close wrong-terminal attribution classes in cold-load reconciliation Strengthen the reconcile attribution evidence per review round 2: measure the temporal evidence on the same api-history projection the verdict uses, fail closed on a compression checkpoint written after the target's admission, and require the visible tail to postdate every other prompt's settled terminal (FIFO evidence). Also fix a TS18048 narrowing gap in the window test, make the seal test assert the raw file layout, and restructure the window test so the call-site tailBytes wiring is actually observable. * fix(serve): keep ChatRecord import inline so lint-staged cannot merge it into a type-only import * fix(serve): fail-closed reconciliation on millisecond clock equality and deadline-overlapped turns * fix(serve): TOCTOU fence before ledger append and documented residual attribution risk * test(serve): pin the ledger race fixture on the transcript timeline * feat(serve): bind cold-load evidence to the admission via a dispatch marker * test(core): pin the ledger sidecar exclusion in usage rebuild * fix(serve): create ledger sidecar owner-only and fence marker-era compression by position Round-7 review Criticals: - appendPromptLedgerRecord created the sidecar with umask-default permissions (0o644) while the adjacent transcript is owner-only; the ledger now follows the 0o600 convention at creation time. - Marker-bearing admissions fence post-admission compression by marker position instead of wall clock, so a backward clock step cannot hide a compression reset that voids the evidence chain. - Design doc: the residual-risk claim is corrected — the dispatch marker binds ordering, not ownership; the two ownership classes that survive it (recordless predecessor with continued writes, ledger-less cross-client writer) are documented, pending writer identity on transcript records (#9483). --------- Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
198 lines
6.6 KiB
TypeScript
198 lines
6.6 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/// <reference types="vitest" />
|
|
import { defineConfig } from 'vitest/config';
|
|
import path from 'node:path';
|
|
|
|
export default defineConfig({
|
|
resolve: {
|
|
alias: {
|
|
'@qwen-code/qwen-code-core/subSessionConstants': path.resolve(
|
|
__dirname,
|
|
'../core/src/tools/sub-session-constants.ts',
|
|
),
|
|
'@qwen-code/qwen-code-core/goalWire': path.resolve(
|
|
__dirname,
|
|
'../core/src/goals/goal-wire.ts',
|
|
),
|
|
'@qwen-code/qwen-code-core/transcriptRecords': path.resolve(
|
|
__dirname,
|
|
'../core/src/utils/transcript-records.ts',
|
|
),
|
|
'@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve(
|
|
__dirname,
|
|
'../core/src/hooks/user-prompt-submit-context.ts',
|
|
),
|
|
'@qwen-code/qwen-code-core/memoryScopes': path.resolve(
|
|
__dirname,
|
|
'../core/src/memory/scopes.ts',
|
|
),
|
|
'@qwen-code/qwen-code-core/toolWriteOrigin': path.resolve(
|
|
__dirname,
|
|
'../core/src/services/tool-write-origin.ts',
|
|
),
|
|
'@qwen-code/qwen-code-core': path.resolve(__dirname, '../core/index.ts'),
|
|
// cli's daemon-status-provider.test.ts imports `FakeAgent` /
|
|
// `makeChannel` from acp-bridge's package-private
|
|
// `internal/testUtils` module. This alias overrides the runtime
|
|
// resolution so vitest reads the .ts source directly instead of
|
|
// the build-then-stale `dist/` copy.
|
|
'@qwen-code/acp-bridge/internal/testUtils': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/internal/testUtils.ts',
|
|
),
|
|
// Same rationale as above: bridgeErrors and status subpaths
|
|
// resolve to dist/ via package.json exports, but tests in the
|
|
// monorepo worktree need the live source (dist may be stale or
|
|
// absent during development).
|
|
'@qwen-code/acp-bridge/bridgeErrors': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/bridgeErrors.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/status': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/status.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/bridge': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/bridge.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/spawnChannel': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/spawnChannel.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/processRegistry': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/process-registry.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/daemonMemoryBudget': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/daemon-memory-budget.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/ndJsonStream': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/ndJsonStream.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/logRedaction': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/logRedaction.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/bridgeClient': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/bridgeClient.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/bridgeOptions': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/bridgeOptions.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/promptLedger': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/prompt-ledger.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/bridgeTypes': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/bridgeTypes.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/bridgeFileSystem': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/bridgeFileSystem.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/sessionArtifacts': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/sessionArtifacts.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/eventBus': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/eventBus.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/replayWindowLimits': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/replayWindowLimits.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/transcriptReplay': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/transcript-replay.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/workspacePaths': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/workspacePaths.ts',
|
|
),
|
|
'@qwen-code/acp-bridge/externalToolGuard': path.resolve(
|
|
__dirname,
|
|
'../acp-bridge/src/externalToolGuard.ts',
|
|
),
|
|
'@qwen-code/audio-capture': path.resolve(
|
|
__dirname,
|
|
'../audio-capture/src/index.ts',
|
|
),
|
|
'@qwen-code/sdk/daemon/transcript': path.resolve(
|
|
__dirname,
|
|
'../sdk-typescript/src/daemon/transcript.ts',
|
|
),
|
|
'@qwen-code/sdk/daemon/ui/transcript': path.resolve(
|
|
__dirname,
|
|
'../sdk-typescript/src/daemon/ui/transcript.ts',
|
|
),
|
|
'@qwen-code/sdk/daemon/types': path.resolve(
|
|
__dirname,
|
|
'../sdk-typescript/src/daemon/types.ts',
|
|
),
|
|
'@qwen-code/sdk/daemon': path.resolve(
|
|
__dirname,
|
|
'../sdk-typescript/src/daemon/index.ts',
|
|
),
|
|
},
|
|
},
|
|
test: {
|
|
// See packages/core/vitest.config.ts: raise the per-test ceiling above
|
|
// vitest's 5s default so I/O-bound tests (e.g. the workspace registration
|
|
// store's tempdir round-trip) don't blow it purely under CI contention.
|
|
testTimeout: 15000,
|
|
// ECS hosts run several jobs at once; leave capacity for neighboring jobs.
|
|
maxWorkers: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-')
|
|
? '25%'
|
|
: undefined,
|
|
include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', 'config.test.ts'],
|
|
exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**'],
|
|
environment: 'jsdom',
|
|
globals: true,
|
|
reporters: ['default', 'junit'],
|
|
silent: true,
|
|
outputFile: {
|
|
junit: 'junit.xml',
|
|
},
|
|
setupFiles: ['./test-setup.ts'],
|
|
// Fail fast with an actionable message when workspace dist/ output or
|
|
// generated files are missing (fresh clone, new worktree, deep clean).
|
|
// See scripts/vitest-global-setup.js and issue #9149.
|
|
// Resolved against this config file (not vitest's root/cwd) so the guard
|
|
// also loads when vitest is launched from elsewhere with --config.
|
|
globalSetup: path.resolve(
|
|
__dirname,
|
|
'../../scripts/vitest-global-setup.js',
|
|
),
|
|
coverage: {
|
|
enabled: true,
|
|
provider: 'v8',
|
|
reportsDirectory: './coverage',
|
|
include: ['src/**/*'],
|
|
reporter: [
|
|
['text', { file: 'full-text-summary.txt' }],
|
|
'html',
|
|
'json',
|
|
'lcov',
|
|
'cobertura',
|
|
['json-summary', { outputFile: 'coverage-summary.json' }],
|
|
],
|
|
},
|
|
server: {
|
|
deps: {
|
|
inline: [/@qwen-code\/qwen-code-core/],
|
|
},
|
|
},
|
|
},
|
|
});
|