qwen-code/packages/cli/vitest.config.ts
ChiGao 02d303f849
feat(serve): persist prompt terminal ledger for cold-load reconciliation (#9426)
* 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>
2026-08-20 07:27:49 +00:00

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/],
},
},
},
});