mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-09 04:20:32 -10:00
Merge origin/main into feat/agent-view-first-consumer
Resolve docs/users/features/commands.md by union: keep this branch's widened `qwen sessions ps` description and the `managed` field in the interactive JSON record, and take main's new `qwen sessions controllers` row plus the `ipcPath` field. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-conflict/jmtrcr08gpe
This commit is contained in:
commit
7c3e095850
332 files changed
+34480
-3020
No files matched your search
@@ -1,5 +1,5 @@
|
||||
name: 'Configure Windows self-hosted runner'
|
||||
description: 'Tunes a self-hosted Windows runner for the test gates: exports the Linux gates'' C.UTF-8 locale env (inert on Windows, where Node collates through ICU), redirects TEMP/TMP to RUNNER_TEMP, and puts Git Bash on PATH for bash-shell steps. Runs after actions/checkout because repository-local actions resolve from the workspace; callers turn off autocrlf before the checkout itself. Shared by the Windows merge-queue gate and the runner smoke workflow so both always run an identical configuration.'
|
||||
description: "Tunes a self-hosted Windows runner for the test gates: exports the Linux gates' C.UTF-8 locale env (inert on Windows, where Node collates through ICU), redirects TEMP/TMP to RUNNER_TEMP, and puts Git Bash on PATH for bash-shell steps. Runs after actions/checkout because repository-local actions resolve from the workspace; callers turn off autocrlf before the checkout itself. Shared by the Windows merge-queue gate and the runner smoke workflow so both always run an identical configuration."
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
|
||||
@@ -77,6 +77,28 @@ REPORT_HEAD="${CHECKED_OUT_HEAD}"
|
||||
# would double-write that round's marker.
|
||||
ROUND="${EFFECTIVE_ROUND:-${ROUND}}"
|
||||
MODEL_DISPLAY="${MODEL:-default}"
|
||||
# agent-model: run-agent.mjs's copy of the session's RESOLVED model + CLI
|
||||
# version (from its stream-json init event). Preferred over MODEL, which names
|
||||
# only what was REQUESTED and reads 'default' when the repo variable is unset.
|
||||
# WORKDIR is agent-writable, so this read is defensive in BOTH directions.
|
||||
# Fails safe: a planted directory or FIFO fails the regular-file test, an
|
||||
# unreadable or malformed file yields empty values, and every fallback ends
|
||||
# at the configured MODEL — none of them may abort this PAT-bearing step
|
||||
# under -eo pipefail (which would lose the push and the round report).
|
||||
# Publishes nothing an agent chose: no symlink (head would follow one to an
|
||||
# arbitrary host file and print its head into a public comment), bounded
|
||||
# head -c prefixes (sed buffers a whole line before cut could bound it), and
|
||||
# a character allowlist + length cap per value — markup, backticks and
|
||||
# comment tokens cannot survive. The version allowlist is the review footer's
|
||||
# FOOTER_VERSION_RE shape.
|
||||
AGENT_MODEL=''
|
||||
AGENT_CLI_VERSION=''
|
||||
if [[ -f "${WORKDIR}/agent-model" && ! -L "${WORKDIR}/agent-model" ]]; then
|
||||
AGENT_MODEL="$(head -c 400 "${WORKDIR}/agent-model" 2>/dev/null | sed -n '1p' | tr -cd 'A-Za-z0-9._:/+-' | cut -c1-100 || true)"
|
||||
AGENT_CLI_VERSION="$(head -c 1000 "${WORKDIR}/agent-model" 2>/dev/null | sed -n '2p' | tr -cd 'A-Za-z0-9._+-' | cut -c1-40 || true)"
|
||||
fi
|
||||
[[ -n "${AGENT_MODEL}" ]] && MODEL_DISPLAY="${AGENT_MODEL}"
|
||||
CLI_DISPLAY="${AGENT_CLI_VERSION:+ · CLI \`${AGENT_CLI_VERSION}\`}"
|
||||
# Growth-audit trail (+ re-arm on sound): audit rounds record the
|
||||
# verdict under the key the baseline was READ under — same rule as
|
||||
# the growth markers, same dead-key hazard (a supersede-exempt
|
||||
@@ -625,7 +647,7 @@ if [[ "${OUTCOME}" == "fixed" ]]; then
|
||||
echo "Re-review when you have a moment. After round ${MAX_ROUNDS} this bot stops and leaves the PR for a human. · 有空请复审;第 ${MAX_ROUNDS} 轮后本 bot 停止并将 PR 交给人工。"
|
||||
echo
|
||||
echo "---"
|
||||
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`"
|
||||
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}"
|
||||
echo
|
||||
echo "<!-- autofix-eval ts=${NEWEST} acted=true round=${NEXT_ROUND} win=${WINDOW:-none} -->"
|
||||
echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->"
|
||||
@@ -668,7 +690,7 @@ else
|
||||
fi
|
||||
echo
|
||||
echo "---"
|
||||
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`"
|
||||
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}"
|
||||
echo
|
||||
echo "<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} win=${WINDOW:-none} -->"
|
||||
echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->"
|
||||
|
||||
@@ -30,7 +30,10 @@ test('MDX is executable content, never docs_only', () => {
|
||||
assert.equal(classifyChangedFiles(['docs/guide.mdx']), 'full');
|
||||
assert.equal(classifyChangedFiles(['docs/guide.MDX']), 'full');
|
||||
assert.equal(classifyChangedFiles(['README.mdx']), 'full');
|
||||
assert.equal(classifyChangedFiles(['docs/usage.md', 'docs/guide.mdx']), 'full');
|
||||
assert.equal(
|
||||
classifyChangedFiles(['docs/usage.md', 'docs/guide.mdx']),
|
||||
'full',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to full for root docs names used as directories', () => {
|
||||
@@ -56,7 +59,9 @@ test('keeps yaml-dependent helper suites on the full profile', () => {
|
||||
// dependency-free github_ci_only lane can never execute it; downgrading a
|
||||
// PR that only edits that suite would green-light it without running it.
|
||||
assert.equal(
|
||||
classifyChangedFiles(['.github/scripts/web-shell-visuals-publish.test.mjs']),
|
||||
classifyChangedFiles([
|
||||
'.github/scripts/web-shell-visuals-publish.test.mjs',
|
||||
]),
|
||||
'full',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -191,9 +191,8 @@ test('the body stays bounded on a total-suite failure', () => {
|
||||
`body is ${body.length} chars, must stay under GitHub's 65,536 limit`,
|
||||
);
|
||||
assert.ok(body.includes(`- …and ${400 - MAX_BODY_TESTS} more`));
|
||||
const markerCount = (
|
||||
body.match(new RegExp(TEST_MARKER_PREFIX, 'g')) ?? []
|
||||
).length;
|
||||
const markerCount = (body.match(new RegExp(TEST_MARKER_PREFIX, 'g')) ?? [])
|
||||
.length;
|
||||
assert.ok(
|
||||
markerCount <= MAX_SEARCH_MARKERS,
|
||||
`body carries ${markerCount} markers, at most ${MAX_SEARCH_MARKERS}`,
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('release note classification', () => {
|
||||
// labels — its projectCards lookup fails on affected gh builds).
|
||||
"if (args[0] === 'api' && args[1] === '-X' && (args[2] === 'POST' || args[2] === 'DELETE') && /\\/issues\\/\\d+\\/labels/.test(args[3])) {",
|
||||
" const action = args[2] === 'DELETE' ? 'remove' : 'add';",
|
||||
" const number = args[3].match(/\\/issues\\/(\\d+)\\/labels/)[1];",
|
||||
' const number = args[3].match(/\\/issues\\/(\\d+)\\/labels/)[1];',
|
||||
` process.getBuiltinModule('node:fs').appendFileSync(${JSON.stringify(updates)}, number + ' ' + action + '\\n');`,
|
||||
' process.exit(0);',
|
||||
'}',
|
||||
|
||||
@@ -7,12 +7,7 @@ import path from 'node:path';
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
const assets = fs.readdirSync(options.assets).sort();
|
||||
const patterns = {
|
||||
macos: [
|
||||
/-arm64\.zip$/i,
|
||||
/-x64\.zip$/i,
|
||||
/-arm64\.dmg$/i,
|
||||
/-x64\.dmg$/i,
|
||||
],
|
||||
macos: [/-arm64\.zip$/i, /-x64\.zip$/i, /-arm64\.dmg$/i, /-x64\.dmg$/i],
|
||||
windows: [/-setup\.exe$/i],
|
||||
linux: [/\.AppImage$/i],
|
||||
};
|
||||
@@ -20,7 +15,9 @@ const selectedPatterns = patterns[options.platform];
|
||||
if (!selectedPatterns) {
|
||||
throw new Error(`Invalid --platform: ${options.platform}`);
|
||||
}
|
||||
const names = selectedPatterns.map((pattern) => selectArtifact(assets, pattern));
|
||||
const names = selectedPatterns.map((pattern) =>
|
||||
selectArtifact(assets, pattern),
|
||||
);
|
||||
const artifacts = names.map((name) => readArtifact(assets, name));
|
||||
const primary = artifacts[0];
|
||||
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsw-tb-manifest-'));
|
||||
const script = join(dirname(fileURLToPath(import.meta.url)), 'make-terminal-bench-manifest.py');
|
||||
const script = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'make-terminal-bench-manifest.py',
|
||||
);
|
||||
let archive;
|
||||
|
||||
before(() => {
|
||||
@@ -23,18 +32,30 @@ before(() => {
|
||||
writeFileSync(join(task, 'instruction.md'), 'test\n');
|
||||
}
|
||||
archive = join(root, 'tasks.tar.gz');
|
||||
const result = spawnSync('tar', ['-czf', archive, '-C', root, 'tasks'], { encoding: 'utf8' });
|
||||
const result = spawnSync('tar', ['-czf', archive, '-C', root, 'tasks'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const run = (...args) => spawnSync('python3', [script, '--archive', archive, ...args], { encoding: 'utf8' });
|
||||
const run = (...args) =>
|
||||
spawnSync('python3', [script, '--archive', archive, ...args], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
describe('make-terminal-bench-manifest', () => {
|
||||
it('selects one exact task for an end-to-end smoke', () => {
|
||||
const output = join(root, 'one.json');
|
||||
const result = run('--limit', '1', '--instance-id', 'task-42', '--output', output);
|
||||
const result = run(
|
||||
'--limit',
|
||||
'1',
|
||||
'--instance-id',
|
||||
'task-42',
|
||||
'--output',
|
||||
output,
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const manifest = JSON.parse(readFileSync(output, 'utf8'));
|
||||
assert.equal(manifest.expected_instances, 1);
|
||||
@@ -51,7 +72,14 @@ describe('make-terminal-bench-manifest', () => {
|
||||
});
|
||||
|
||||
it('rejects an unknown exact task', () => {
|
||||
const result = run('--limit', '1', '--instance-id', 'missing', '--output', join(root, 'bad.json'));
|
||||
const result = run(
|
||||
'--limit',
|
||||
'1',
|
||||
'--instance-id',
|
||||
'missing',
|
||||
'--output',
|
||||
join(root, 'bad.json'),
|
||||
);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Unknown Terminal-Bench/);
|
||||
});
|
||||
|
||||
@@ -61,9 +61,9 @@ const updateJobName =
|
||||
workflow.match(/\n {2}update:\n {4}name: '([^']*)'/)?.[1] ?? '';
|
||||
const poolPrefix = updateJobName.replace(/\$\{\{.*\}\}$/, '');
|
||||
|
||||
// Both layouts, because CI runs `prettier --write .` before this suite and
|
||||
// prettier reflows the inline matrix array onto its own lines. A parser that
|
||||
// reads only the checked-in layout passes locally and finds zero pools there.
|
||||
// Both layouts, because the checked-in workflow carries the reflowed form
|
||||
// prettier produces for an inline matrix array, and older branches still
|
||||
// carry the single-line one. A parser that reads only one finds zero pools.
|
||||
function parsePools(text) {
|
||||
return (text.match(/\n\s+runner:\s*\[([^\]]*)\]/)?.[1] ?? '')
|
||||
.split(',')
|
||||
|
||||
@@ -203,11 +203,15 @@ while (( lookup_page <= LOOKUP_MAX_PAGES )); do
|
||||
# fallback. The marker lives on the one surface maintainers are invited to
|
||||
# edit, so an edit that drops it would orphan the issue and the next round
|
||||
# would open a duplicate; the title is derived, never authored.
|
||||
# The title fallback accepts both the bare form and the creation-time
|
||||
# enriched form "base: <PR title>", guarded by the colon so PR #5's base
|
||||
# never prefix-matches PR #50's issue.
|
||||
HIT="$(jq -r --arg m "${MARKER}" --arg t "${TITLE}" '
|
||||
(map(select((.pull_request | not)
|
||||
and ((.body // "") | contains($m)))) | .[0].number)
|
||||
// (map(select((.pull_request | not)
|
||||
and ((.title // "") == $t))) | .[0].number)
|
||||
and (((.title // "") == $t)
|
||||
or ((.title // "") | startswith($t + ":"))))) | .[0].number)
|
||||
// "" | tostring' \
|
||||
<<< "${PAGE_JSON}" 2> /dev/null)" || HIT=''
|
||||
if [[ -n "${HIT}" && "${HIT}" != 'null' ]]; then
|
||||
@@ -287,7 +291,8 @@ fi
|
||||
# @ @) get their `&` escaped — both measured inert against the
|
||||
# real renderer; `\@` and bare entity-escaping are NOT. Paths are already
|
||||
# reduced to a safe charset (no `@` survives).
|
||||
if ! NEW_LINES="$(jq -r --rawfile known "${KNOWN_FILE}" --rawfile resolved "${RESOLVED_FILE}" '
|
||||
if ! NEW_LINES="$(jq -r --rawfile known "${KNOWN_FILE}" --rawfile resolved "${RESOLVED_FILE}" \
|
||||
--arg repo "${REPO}" --arg pr "${PR}" '
|
||||
# Identity for the multi-finding sources. LOSSLESS on content: only case
|
||||
# and PUNCTUATION are normalized, so the tolerance for rewording survives
|
||||
# while every letter of every script does too. The earlier form stripped
|
||||
@@ -312,7 +317,7 @@ if ! NEW_LINES="$(jq -r --rawfile known "${KNOWN_FILE}" --rawfile resolved "${RE
|
||||
raw: ((.path // "?") + " " + .reason),
|
||||
# The path charset filter already excludes `<`, so the comment opener
|
||||
# cannot survive there; the reason is escaped explicitly below.
|
||||
line: "- \($pfx):\($id) `\(.path // "?" | gsub("[^A-Za-z0-9._/ -]"; "?") | .[0:200])`: \(.reason
|
||||
line: ("- \($pfx):\($id) `\(.path // "?" | gsub("[^A-Za-z0-9._/ -]"; "?") | .[0:200])`: \(.reason
|
||||
| gsub("[\r\n]+"; " ")
|
||||
| gsub("&(?<ent>#0*(?:64|[xX]0*40);|commat;)"; "&\(.ent)")
|
||||
| gsub("@"; "@\u200b")
|
||||
@@ -321,7 +326,17 @@ if ! NEW_LINES="$(jq -r --rawfile known "${KNOWN_FILE}" --rawfile resolved "${RE
|
||||
# raw rendering against the escaped stored form never matches and
|
||||
# re-publishes the finding every round.
|
||||
| gsub("<!--"; "<!\\-\\-")
|
||||
| .[0:500])",
|
||||
| .[0:500])"
|
||||
# rc gets a deep link back to the original review comment — safe on
|
||||
# identity because rc dedup is anchored to the id (the line-start
|
||||
# `anchor` prefix and unique_by [src,id]), so trailing text can
|
||||
# neither duplicate nor suppress. rv/ic deliberately DO NOT get one:
|
||||
# their cross-round identity IS the rendered line, and a suffix here
|
||||
# would render every already-persisted rv/ic item as new — a
|
||||
# one-time duplicate wave over the existing tracking issues.
|
||||
+ (if $src == "review_comment"
|
||||
then " — [comment](https://github.com/\($repo)/pull/\($pr)#discussion_r\($id))"
|
||||
else "" end)),
|
||||
anchor: ("^- " + $pfx + ":" + ($id | tostring) + " ")}
|
||||
# Identity key for the multi-finding sources. NOT the exact line: a
|
||||
# reworded re-emission (the repair flow re-runs the agent, so this is
|
||||
@@ -376,11 +391,135 @@ else
|
||||
fi
|
||||
|
||||
if [[ -z "${ISSUE_NUM}" || "${ISSUE_NUM}" == 'null' ]]; then
|
||||
BODY="${MARKER}"$'\n\n'"Verified review findings from PR #${PR} whose fixes lie outside that PR's footprint, deferred by the autofix loop for follow-up. A maintainer can turn any item into its own issue/PR (or apply the ready-for-agent flow) — nothing here is scheduled automatically."$'\n\n'"${NEW_LINES}"
|
||||
# Creation-only context, best-effort: the PR title and author make the
|
||||
# tracking issue self-describing at a glance (which PR it came from, what
|
||||
# that PR was about) and assign the follow-up back to that author. Any
|
||||
# failure here degrades to the bare title / no assignee — metadata must
|
||||
# never lose findings.
|
||||
gh_err_reset
|
||||
if NUM="$(gh api "repos/${REPO}/issues" \
|
||||
-f title="${TITLE}" \
|
||||
-f body="${BODY}" --jq '.number' 2> "${GH_ERR:-/dev/null}")"; then
|
||||
PR_FETCH_OK=1
|
||||
PR_JSON="$(gh api "repos/${REPO}/pulls/${PR}" 2> "${GH_ERR:-/dev/null}")" ||
|
||||
PR_FETCH_OK=0
|
||||
# The title is published on ONE surface: the issue TITLE, which GitHub
|
||||
# stores and renders as plain text — no markdown pass, no mention filter —
|
||||
# so it needs no escaping, and escaping it would only corrupt the one
|
||||
# human-facing string this change exists to make readable (an invisible
|
||||
# ZWSP inside `@types`, visible `\-\-` backslashes). Flatten and cap only.
|
||||
#
|
||||
# It deliberately never reaches the markdown-rendered issue BODY, because a
|
||||
# PR title is fully contributor-controlled text and NO enumerated escape
|
||||
# chain closes that surface: `[URGENT](https://evil.example/phish)` and
|
||||
# `` sail through mention/entity/
|
||||
# comment-opener neutralization byte-identical and render as a live link or
|
||||
# an attacker-chosen image (camo-proxied, so not a per-reader beacon)
|
||||
# inside bot-authored text maintainers read as trusted automation output.
|
||||
# Worse, an unclosed `<details>` makes a real HTML5 parser nest the
|
||||
# findings `<ul>` inside it — the whole human-facing surface collapses
|
||||
# behind GitHub's generic `Details` fold while the round still prints its
|
||||
# clean success line, and because the dedupe corpus reads the RAW body the
|
||||
# hidden items then count as already tracked and are never re-published.
|
||||
# The body copy bought nothing: `PR #${PR}` below auto-links on its own, and
|
||||
# the enriched issue title already says what the PR was about. Do NOT
|
||||
# re-add it; if a title ever has to appear in the body, put it in a code
|
||||
# span, never in prose.
|
||||
#
|
||||
# The slice happens in jq (codepoint-safe): a bash byte slice could cut a
|
||||
# UTF-8 sequence on CJK titles under a C locale and fail the create call.
|
||||
PR_TITLE_RAW="$(jq -r '(.title // "")
|
||||
| gsub("[\r\n\t]+"; " ")
|
||||
| .[0:80]' <<< "${PR_JSON}" 2> /dev/null || true)"
|
||||
PR_AUTHOR="$(jq -r '.user.login // ""' <<< "${PR_JSON}" 2> /dev/null || true)"
|
||||
# GitHub login charset; anything else (or a failed fetch) reads as absent.
|
||||
[[ "${PR_AUTHOR}" =~ ^[A-Za-z0-9-]{1,39}$ ]] || PR_AUTHOR=''
|
||||
# The degradation must be SAID, like every other gh failure path here: the
|
||||
# reset before the create call below wipes this call's captured reason, so
|
||||
# without this warning a systematic pulls-endpoint failure — a fine-grained
|
||||
# PAT rotated without pull-requests:read, a rate limit, a persistent 404 —
|
||||
# reverts every new tracking issue to the bare title / no assignee / no cc
|
||||
# while the success line still prints, and a fully degraded round is
|
||||
# indistinguishable from a healthy one in the log. Non-blocking (persistence
|
||||
# must never fail a round) and read BEFORE that reset, so the create-failure
|
||||
# warning below still reports its own reason.
|
||||
#
|
||||
# The gate is "the call failed OR it yielded nothing usable", not the call
|
||||
# status alone. This fetch passes no --jq, and gh copies a non-JSON body raw
|
||||
# with serverError set only above status 299, so a transparent proxy answering
|
||||
# `200 text/html` on the self-hosted pool exits 0 while both derivations
|
||||
# above come back empty through their `|| true` — the bare title, no cc, no
|
||||
# assignee and a clean success line, precisely the degradation this warning
|
||||
# exists to name. Keying on BOTH derived strings being empty cannot
|
||||
# false-positive on a real PR object (a whitespace-only title still flattens
|
||||
# to a non-empty " ", a deleted author leaves the title intact), where a
|
||||
# `.number`-presence gate would warn on every healthy round — the stub PR
|
||||
# object the suite passes carries no `.number`.
|
||||
if [[ "${PR_FETCH_OK}" != 1 || ( -z "${PR_TITLE_RAW}" && -z "${PR_AUTHOR}" ) ]]; then
|
||||
PR_CTX_REASON="$(gh_reason)"
|
||||
# The call itself succeeded, so there is no stderr to name — say what the
|
||||
# second half of the gate observed instead of "no stderr captured".
|
||||
[[ "${PR_FETCH_OK}" == 1 ]] &&
|
||||
PR_CTX_REASON='the call exited 0 but returned no usable PR object'
|
||||
echo "::warning::could not fetch PR #${PR} context (${PR_CTX_REASON}); creating the deferred-findings issue with the bare title, no assignee and no cc — the findings themselves are still persisted"
|
||||
fi
|
||||
# The ": " separator is consumed 220 lines away by the marker-less title
|
||||
# fallback in the lookup above (startswith($t + ":")) — restyle it in both
|
||||
# places, or marker-stripped issues stop being adopted.
|
||||
CREATE_TITLE="${TITLE}"
|
||||
[[ -n "${PR_TITLE_RAW}" ]] && CREATE_TITLE="${TITLE}: ${PR_TITLE_RAW}"
|
||||
# Neither half of this sentence carries contributor-chosen markup: `PR #${PR}`
|
||||
# auto-links in the rendered body on its own, and PR_AUTHOR is charset-
|
||||
# validated above. The title stays out — see the block above the fetch.
|
||||
CONTEXT="PR #${PR}"
|
||||
[[ -n "${PR_AUTHOR}" ]] && CONTEXT="${CONTEXT} by ${PR_AUTHOR}"
|
||||
# The assignment below notifies the author only when GitHub accepts it;
|
||||
# external contributors are NOT assignable, so also cc them in the body —
|
||||
# the mention is what actually reaches them. The login is charset-validated
|
||||
# above, so this deliberate mention cannot inject anything else. Evaluated
|
||||
# ONCE and reused below: a second copy of the condition could drift, cc'ing
|
||||
# an author in the body that the assignment then declines.
|
||||
CC=''
|
||||
ASSIGNABLE=0
|
||||
if [[ -n "${PR_AUTHOR}" && "${PR_AUTHOR}" != "${AUTOFIX_BOT}" ]]; then
|
||||
CC=" cc @${PR_AUTHOR}."
|
||||
ASSIGNABLE=1
|
||||
fi
|
||||
# The ready-for-agent flow is pointed at the PER-ITEM issue, not at this
|
||||
# tracking one. Once the assign call below lands, this issue carries an
|
||||
# assignee, and the scheduled ready-for-agent scan filters `no:assignee`
|
||||
# (AUTOFIX_ISSUE_EXCLUDES in qwen-autofix.yml) — so labelling it here would
|
||||
# run without the scan that retries a label-event run cancelled by the
|
||||
# per-issue concurrency group. A human-filed per-item issue is unassigned
|
||||
# and keeps that backstop. Do not widen this back to "or apply the
|
||||
# ready-for-agent flow" without re-checking that filter.
|
||||
BODY="${MARKER}"$'\n\n'"Verified review findings from ${CONTEXT} whose fixes lie outside that PR's footprint, deferred by the autofix loop for follow-up.${CC} Each rc: item links back to its original review comment. A maintainer or the PR author can turn any item into its own issue/PR and apply the ready-for-agent flow to that issue — nothing here is scheduled automatically."$'\n\n'"${NEW_LINES}"
|
||||
gh_err_reset
|
||||
# ONE create call, never retried. POST /repos/{owner}/{repo}/issues is not
|
||||
# idempotent, and the failures that reach a retry are the ambiguous ones —
|
||||
# a connection reset, a gateway 502, a read timeout after the server already
|
||||
# committed — so re-POSTing can mint a second tracking issue carrying the
|
||||
# same marker and title. The next round's newest-first lookup adopts the
|
||||
# newer one and the first is orphaned forever, publicly duplicating every
|
||||
# finding while this round logs clean success. Same rule as the lookup cap
|
||||
# above: creating a duplicate is worse than deferring persistence one round,
|
||||
# so the failure path here is the loud LOST warning below, not another POST.
|
||||
NUM="$(gh api "repos/${REPO}/issues" \
|
||||
-f title="${CREATE_TITLE}" \
|
||||
-f body="${BODY}" --jq '.number' 2> "${GH_ERR:-/dev/null}")" || NUM=''
|
||||
if [[ -n "${NUM}" && "${ASSIGNABLE}" == 1 ]]; then
|
||||
# Assignment is a separate metadata call: it can never mint a second
|
||||
# issue, and it cannot take the create down with it. Assignees passed
|
||||
# on the create call are REJECTED for users without push access — a
|
||||
# 422 that fails the whole create — which for the external
|
||||
# contributors this branch exists for would lose the findings
|
||||
# entirely. A genuine failure here only warns (persistence already
|
||||
# succeeded); a non-assignable author accepts this call yet stays
|
||||
# unassigned (exit 0, empty assignees), so the body's cc mention is
|
||||
# what actually reaches them.
|
||||
gh_err_reset
|
||||
gh api "repos/${REPO}/issues/${NUM}/assignees" -f "assignees[]=${PR_AUTHOR}" \
|
||||
> /dev/null 2> "${GH_ERR:-/dev/null}" \
|
||||
|| echo "::warning::could not assign deferred-findings issue #${NUM} to ${PR_AUTHOR} ($(gh_reason)); the body's cc mention still notifies them"
|
||||
fi
|
||||
if [[ -n "${NUM}" ]]; then
|
||||
echo "🗂 deferred findings tracked in new issue #${NUM} (${KEPT} of ${TOTAL_NEW} new)"
|
||||
else
|
||||
# Not "this round": the eval watermark filters this round's feedback out
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
6495 pr-self-report-label.yml
|
||||
9646 qwen-autofix-fork-bridge.yml
|
||||
5942 qwen-autofix-fork-signal.yml
|
||||
460801 qwen-autofix.yml
|
||||
463090 qwen-autofix.yml
|
||||
7061 qwen-ci-flaky-rerun.yml
|
||||
261105 qwen-code-pr-review.yml
|
||||
79041 qwen-fleet-shepherd.yml
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
runner: 'windows-2022'
|
||||
arch: 'x64'
|
||||
steps:
|
||||
- uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
- uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
- uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
run: 'echo "push_image=${PUSH_IMAGE}" >> "$GITHUB_OUTPUT"'
|
||||
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ github.ref }}'
|
||||
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ on:
|
||||
version:
|
||||
description: 'Version to release (without v prefix)'
|
||||
required: true
|
||||
default: '0.20.3'
|
||||
default: '0.20.4'
|
||||
notarize:
|
||||
description:
|
||||
'Codesign + notarize the macOS artifacts (false to skip during
|
||||
|
||||
@@ -1286,7 +1286,15 @@ jobs:
|
||||
needs.test.outputs.ci_profile == 'full'
|
||||
}}
|
||||
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
|
||||
timeout-minutes: 20
|
||||
# Routed like test's and lint's, so it needs their allowance too — it was
|
||||
# the last job on the pool still priced flat, and one commit lost it twice:
|
||||
# on hk3-9 `npm ci` alone ran 19m46s of the 20 and never finished, and on
|
||||
# hk4-23 install took 10m33s against 4m53s on hk5-2, leaving the browser
|
||||
# smoke 8m15s before the job died against 2m29s warm. Both phases 2-3x is
|
||||
# the pool, not the diff: the same job passed on the previous commit, which
|
||||
# touches no web-shell file. Hosted runners keep 20 for the reason lint's
|
||||
# copy gives — a genuine hang there should not burn the ECS allowance.
|
||||
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'', ''ecs-qwen'') && ''40'' || ''20'') }}'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
steps:
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
|
||||
- name: 'Initialize CodeQL'
|
||||
uses: 'github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/init@v3
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
|
||||
- name: 'Setup Pages'
|
||||
uses: 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d' # ratchet:actions/configure-pages@v6
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
id: 'gate'
|
||||
env:
|
||||
EVENT_NAME: '${{ github.event_name }}'
|
||||
RELEASE_TAG: '${{ github.event.release.tag_name || '''' }}'
|
||||
RELEASE_TAG: "${{ github.event.release.tag_name || '' }}"
|
||||
RELEASE_PRERELEASE: '${{ github.event.release.prerelease || false }}'
|
||||
INPUT_EXECUTION_BACKEND: '${{ inputs.execution_backend }}'
|
||||
INPUT_INSTANCE_LIMIT: '${{ inputs.instance_limit }}'
|
||||
@@ -136,12 +136,12 @@ jobs:
|
||||
benchmark:
|
||||
name: 'Dispatch SWE-bench and Terminal-Bench to DSW'
|
||||
needs: 'release_gate'
|
||||
if: '${{ needs.release_gate.outputs.should_run == ''true'' }}'
|
||||
if: "${{ needs.release_gate.outputs.should_run == 'true' }}"
|
||||
runs-on: ['self-hosted', 'Linux', 'X64', 'qwen-benchmark-dsw-hk-eas']
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
RELEASE_TAG: '${{ github.event_name == ''release'' && github.event.release.tag_name || inputs.release_tag }}'
|
||||
QWEN_REF: '${{ github.event_name == ''release'' && github.event.release.tag_name || inputs.qwen_release_tag || inputs.release_tag }}'
|
||||
RELEASE_TAG: "${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}"
|
||||
QWEN_REF: "${{ github.event_name == 'release' && github.event.release.tag_name || inputs.qwen_release_tag || inputs.release_tag }}"
|
||||
INSTANCE_LIMIT: '${{ needs.release_gate.outputs.instance_limit }}'
|
||||
BENCHMARK_INSTANCE_ID: '${{ needs.release_gate.outputs.instance_id }}'
|
||||
TERMINAL_BENCH_LIMIT: '${{ needs.release_gate.outputs.terminal_bench_limit }}'
|
||||
|
||||
@@ -117,6 +117,8 @@ jobs:
|
||||
# and show misleading red status. Same-repo PRs run normally.
|
||||
if: |-
|
||||
${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
env:
|
||||
E2E_CONTAINER_OWNER: '${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard }}'
|
||||
strategy:
|
||||
# One failing shard must not cancel the others: a partial matrix hides the
|
||||
# rest of the suite and reports an incomplete failure set.
|
||||
@@ -125,13 +127,10 @@ jobs:
|
||||
sandbox:
|
||||
- 'sandbox:none'
|
||||
- 'sandbox:docker'
|
||||
# The suite is ~16min of wall clock on one runner, dominated by a long
|
||||
# tail of sdk-typescript files. vitest assigns files to shards by path
|
||||
# hash, so those spread out instead of clustering in one shard.
|
||||
# Keep three-way test concurrency inside one runner so each sandbox
|
||||
# shares setup work instead of occupying three pool runners.
|
||||
shard:
|
||||
- '1/3'
|
||||
- '2/3'
|
||||
- '3/3'
|
||||
- '1/1'
|
||||
node-version:
|
||||
- '22.x'
|
||||
steps:
|
||||
@@ -256,9 +255,24 @@ jobs:
|
||||
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
|
||||
QWEN_SANDBOX: "${{ matrix.sandbox == 'sandbox:docker' && 'docker' || 'false' }}"
|
||||
BUILD_SANDBOX_FLAGS: '--label org.qwen-code.ci.sandbox=true'
|
||||
SANDBOX_FLAGS: '--label org.qwen-code.ci.owner=${E2E_CONTAINER_OWNER}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
|
||||
cleanup_e2e_job() {
|
||||
if [ '${{ matrix.sandbox }}' = 'sandbox:docker' ]; then
|
||||
container_ids="$(timeout 30 docker ps -aq --filter "label=org.qwen-code.ci.owner=${E2E_CONTAINER_OWNER}" 2>/dev/null)" || container_ids=''
|
||||
if [ -n "$container_ids" ]; then
|
||||
printf '%s\n' "$container_ids" | xargs -r timeout 60 docker rm -f > /dev/null 2>&1 || echo "::warning::failed to remove E2E containers for ${E2E_CONTAINER_OWNER}"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${QWEN_CI_TMPDIR:-}" ]; then
|
||||
rm -rf "$QWEN_CI_TMPDIR" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup_e2e_job EXIT
|
||||
trap 'exit 1' INT TERM
|
||||
|
||||
if [ '${{ matrix.sandbox }}' = 'sandbox:docker' ]; then
|
||||
sandbox_image="$(node -p "require('./packages/cli/package.json').config.sandboxImageUri")-e2e-${GITHUB_SHA}"
|
||||
|
||||
@@ -337,7 +351,6 @@ jobs:
|
||||
if [ -n "$QWEN_CI_TMPDIR" ]; then
|
||||
TMPDIR="$QWEN_CI_TMPDIR"
|
||||
export TMPDIR
|
||||
trap 'rm -rf "$TMPDIR" 2>/dev/null || true' EXIT
|
||||
fi
|
||||
fi
|
||||
# QWEN_E2E_RENDERER=ink pins the baseline leg of the renderer
|
||||
@@ -346,10 +359,10 @@ jobs:
|
||||
# test:integration:sandbox:docker: that script would rebuild the image
|
||||
# the step above just built.
|
||||
if [[ "${{ matrix.sandbox }}" == "sandbox:docker" ]]; then
|
||||
npx cross-env QWEN_E2E_RENDERER=ink QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/interactive/cron-interactive.test.ts' --exclude '**/channel-plugin.test.ts' --exclude '**/chat-transcript-document.test.ts' --shard='${{ matrix.shard }}' 9>&-
|
||||
npx cross-env QWEN_E2E_RENDERER=ink QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/interactive/cron-interactive.test.ts' --exclude '**/channel-plugin.test.ts' --exclude '**/chat-transcript-document.test.ts' --poolOptions.forks.maxForks=3 --shard='${{ matrix.shard }}' 9>&-
|
||||
else
|
||||
run_shard() {
|
||||
QWEN_E2E_RENDERER=ink npm run test:integration:sandbox:none -- --exclude '**/interactive/cron-interactive.test.ts' --exclude '**/channel-plugin.test.ts' --exclude '**/chat-transcript-document.test.ts' --shard='${{ matrix.shard }}'
|
||||
QWEN_E2E_RENDERER=ink npm run test:integration:sandbox:none -- --exclude '**/interactive/cron-interactive.test.ts' --exclude '**/channel-plugin.test.ts' --exclude '**/chat-transcript-document.test.ts' --poolOptions.forks.maxForks=3 --shard='${{ matrix.shard }}'
|
||||
}
|
||||
# One bounded retry: pool runners' sandbox:none shards die under
|
||||
# shared-host pressure with every test green and no vitest FAIL
|
||||
@@ -380,6 +393,21 @@ jobs:
|
||||
}
|
||||
fi
|
||||
|
||||
- name: 'Remove job-owned E2E containers'
|
||||
if: |-
|
||||
${{ always() && matrix.sandbox == 'sandbox:docker' && runner.environment == 'self-hosted' }}
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
container_ids="$(timeout 30 docker ps -aq --filter "label=org.qwen-code.ci.owner=${E2E_CONTAINER_OWNER}")"
|
||||
if [ -n "$container_ids" ]; then
|
||||
printf '%s\n' "$container_ids" | xargs -r timeout 60 docker rm -f > /dev/null || true
|
||||
fi
|
||||
remaining="$(timeout 30 docker ps -aq --filter "label=org.qwen-code.ci.owner=${E2E_CONTAINER_OWNER}")"
|
||||
if [ -n "$remaining" ]; then
|
||||
echo "::error::E2E containers remain for ${E2E_CONTAINER_OWNER}: ${remaining//$'\n'/,}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Commit-qualified CI images are pruned under the exclusive daemon lock
|
||||
# once they are older than a day. The cleanup is non-blocking so a shard
|
||||
# finishing early cannot queue a writer ahead of another shard's reader.
|
||||
|
||||
@@ -1489,6 +1489,19 @@ jobs:
|
||||
# unpredictable path a watcher cannot pre-seed.
|
||||
export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"
|
||||
MODEL_DISPLAY="${MODEL:-default}"
|
||||
# Same agent-model preference + defensive reads as 'Push and
|
||||
# report' (each step is its own shell, so the block does not carry
|
||||
# over): regular file only, never a symlink, bounded head -c
|
||||
# prefixes, allowlist + cap, || true — a planted path degrades to
|
||||
# the configured MODEL and must not abort this PAT-bearing step.
|
||||
AGENT_MODEL=''
|
||||
AGENT_CLI_VERSION=''
|
||||
if [[ -f "${WORKDIR}/agent-model" && ! -L "${WORKDIR}/agent-model" ]]; then
|
||||
AGENT_MODEL="$(head -c 400 "${WORKDIR}/agent-model" 2>/dev/null | sed -n '1p' | tr -cd 'A-Za-z0-9._:/+-' | cut -c1-100 || true)"
|
||||
AGENT_CLI_VERSION="$(head -c 1000 "${WORKDIR}/agent-model" 2>/dev/null | sed -n '2p' | tr -cd 'A-Za-z0-9._+-' | cut -c1-40 || true)"
|
||||
fi
|
||||
[[ -n "${AGENT_MODEL}" ]] && MODEL_DISPLAY="${AGENT_MODEL}"
|
||||
CLI_DISPLAY="${AGENT_CLI_VERSION:+ · CLI \`${AGENT_CLI_VERSION}\`}"
|
||||
if [[ -z "${GITHUB_TOKEN}" ]]; then
|
||||
echo '::error::CI_DEV_BOT_PAT is required to publish the PR as the autofix bot.'
|
||||
exit 1
|
||||
@@ -1549,7 +1562,7 @@ jobs:
|
||||
{
|
||||
echo
|
||||
echo "---"
|
||||
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`"
|
||||
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}"
|
||||
} >> "${WORKDIR}/e2e-report.md"
|
||||
gh pr comment "${PR_URL}" --body-file "${WORKDIR}/e2e-report.md"
|
||||
|
||||
@@ -5568,8 +5581,13 @@ jobs:
|
||||
"${WORKDIR}/agent-api-error" \
|
||||
"${WORKDIR}/agent-api-error-kind" \
|
||||
"${WORKDIR}/agent-timeout" \
|
||||
"${WORKDIR}/agent-model" \
|
||||
"${WORKDIR}/resolved-comments.txt" \
|
||||
"${WORKDIR}/comment-replies.json"
|
||||
"${WORKDIR}/comment-replies.json" || true
|
||||
# || true: rm -f exits non-zero on a directory planted at a
|
||||
# sentinel path; aborting HERE would lose the repair
|
||||
# (attempted=true is already written) while every report read
|
||||
# tolerates a leftover and run 2's writers overwrite the rest.
|
||||
# NOT deleted with its siblings: run 2 writes its own
|
||||
# deferred-findings.json, and a deferral dropped here is gone for
|
||||
# good (the eval watermark filters this round's feedback out of
|
||||
@@ -5827,7 +5845,7 @@ jobs:
|
||||
if git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then
|
||||
git diff "origin/main...${BRANCH}" > "${WORKDIR}/pr.diff" || true
|
||||
fi
|
||||
for f in feedback.md address-summary.md no-action.md failure.md failure.zh.md handoff.md gate-rejection.md gate-advisories.md growth-audit.json agent-api-error agent-api-error-kind agent-timeout resolved-comments.txt comment-replies.json deferred-findings.json deferred-findings.carry.json deferred-findings.unmerged.json pr.diff heartbeat.log; do
|
||||
for f in feedback.md address-summary.md no-action.md failure.md failure.zh.md handoff.md gate-rejection.md gate-advisories.md growth-audit.json agent-api-error agent-api-error-kind agent-timeout agent-model resolved-comments.txt comment-replies.json deferred-findings.json deferred-findings.carry.json deferred-findings.unmerged.json pr.diff heartbeat.log; do
|
||||
if [[ -f "${WORKDIR}/${f}" ]]; then
|
||||
echo "=============== ${f} ==============="
|
||||
# Agent/reviewer-written content: both workflow-command syntaxes
|
||||
@@ -5978,6 +5996,19 @@ jobs:
|
||||
REPORT_HEAD="${CHECKED_OUT_HEAD}"
|
||||
ROUND="${EFFECTIVE_ROUND:-${ROUND}}"
|
||||
MODEL_DISPLAY="${MODEL:-default}"
|
||||
# Same agent-model preference + defensive reads as 'Push and
|
||||
# report' (each step is its own shell, so the block does not carry
|
||||
# over): regular file only, never a symlink, bounded head -c
|
||||
# prefixes, allowlist + cap, || true — a planted path degrades to
|
||||
# the configured MODEL and must not abort this PAT-bearing step.
|
||||
AGENT_MODEL=''
|
||||
AGENT_CLI_VERSION=''
|
||||
if [[ -f "${WORKDIR}/agent-model" && ! -L "${WORKDIR}/agent-model" ]]; then
|
||||
AGENT_MODEL="$(head -c 400 "${WORKDIR}/agent-model" 2>/dev/null | sed -n '1p' | tr -cd 'A-Za-z0-9._:/+-' | cut -c1-100 || true)"
|
||||
AGENT_CLI_VERSION="$(head -c 1000 "${WORKDIR}/agent-model" 2>/dev/null | sed -n '2p' | tr -cd 'A-Za-z0-9._+-' | cut -c1-40 || true)"
|
||||
fi
|
||||
[[ -n "${AGENT_MODEL}" ]] && MODEL_DISPLAY="${AGENT_MODEL}"
|
||||
CLI_DISPLAY="${AGENT_CLI_VERSION:+ · CLI \`${AGENT_CLI_VERSION}\`}"
|
||||
# Same helper as 'Push and report' (each step is its own shell, so
|
||||
# the definition does not carry over). The verdict is the one the
|
||||
# verification GATE validated (AUDIT_VERDICT step output), never a
|
||||
@@ -6584,7 +6615,7 @@ jobs:
|
||||
echo "Run log: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo
|
||||
echo "---"
|
||||
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`"
|
||||
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}"
|
||||
echo
|
||||
echo "<!-- autofix-eval ts=${MARK_TS} acted=false round=${MARK_ROUND} win=${WINDOW:-none} -->"
|
||||
# Per-round growth history the census counts — same marker
|
||||
|
||||
@@ -3809,7 +3809,6 @@ jobs:
|
||||
gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "${WORKDIR}/report.md" ||
|
||||
echo "::warning::Resolve was skipped, but posting the skip-reason comment failed."
|
||||
|
||||
|
||||
# Publishes what the agent job produced — verification, push and the result
|
||||
# comment — on a runner that never executed the agent. It starts from a
|
||||
# fresh checkout, fetches the base and head refs from GitHub itself, and
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
decision: '${{ steps.assess.outputs.decision }}'
|
||||
steps:
|
||||
- name: 'Checkout trusted precheck script'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ github.event.repository.default_branch }}'
|
||||
sparse-checkout: '.github/scripts/pr-safety-precheck.mjs'
|
||||
|
||||
@@ -34,7 +34,7 @@ on:
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.event.inputs.create_nightly_release == ''true'' && ''nightly'' || github.event.inputs.create_preview_release == ''true'' && ''preview'' || ''stable'' }}'
|
||||
group: "${{ github.workflow }}-${{ github.event.inputs.create_nightly_release == 'true' && 'nightly' || github.event.inputs.create_preview_release == 'true' && 'preview' || 'stable' }}"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
echo "sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
MANUAL_VERSION: '${{ inputs.version }}'
|
||||
|
||||
- name: 'Setup Python'
|
||||
uses: 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405' # v6.2.0
|
||||
uses: 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405' # v6.2.0
|
||||
with:
|
||||
# Keep in sync with packages/sdk-python/pyproject.toml [project] requires-python.
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ github.event.release.tag_name || github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ github.event.release.tag_name || github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ github.event.release.tag_name || github.event.inputs.ref || github.sha }}'
|
||||
|
||||
|
||||
@@ -421,10 +421,6 @@ jobs:
|
||||
npm run postinstall
|
||||
npm run generate
|
||||
|
||||
- name: 'Format Project'
|
||||
run: |-
|
||||
npm run format
|
||||
|
||||
- name: 'Run Lint'
|
||||
run: |-
|
||||
npm run lint:ci
|
||||
|
||||
@@ -77,10 +77,10 @@ jobs:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
|
||||
- name: 'Set up Python'
|
||||
uses: 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405' # v6.2.0
|
||||
uses: 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405' # v6.2.0
|
||||
with:
|
||||
python-version: '${{ matrix.python-version }}'
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
group: '${{ github.workflow }}-stale'
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: 'actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899' # v10.3.0
|
||||
- uses: 'actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899' # v10.3.0
|
||||
with:
|
||||
repo-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
# Issues are intentionally disabled here; a separate policy will
|
||||
|
||||
@@ -75,7 +75,14 @@ jobs:
|
||||
if: "${{ github.repository == 'QwenLM/qwen-code' }}"
|
||||
strategy:
|
||||
matrix:
|
||||
runner: ['ecs-update-hk-1', 'ecs-update-hk-2', 'ecs-update-hk-3', 'ecs-update-hk-4', 'ecs-update-hk-5']
|
||||
runner:
|
||||
[
|
||||
'ecs-update-hk-1',
|
||||
'ecs-update-hk-2',
|
||||
'ecs-update-hk-3',
|
||||
'ecs-update-hk-4',
|
||||
'ecs-update-hk-5',
|
||||
]
|
||||
fail-fast: false
|
||||
runs-on: ['self-hosted', 'linux', 'x64', '${{ matrix.runner }}']
|
||||
concurrency:
|
||||
@@ -150,7 +157,7 @@ jobs:
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -23,3 +23,15 @@ Thumbs.db
|
||||
packages/vscode-ide-companion/schemas/settings.schema.json
|
||||
packages/cli/src/services/insight/templates/insightTemplate.ts
|
||||
packages/cua-driver/
|
||||
# The rest of eslint.config.js's global ignores, for the same reasons it
|
||||
# gives. scripts/codemod/fixtures is the load-bearing one: those files are
|
||||
# "intentionally non-idiomatic ink input/output", so normalising them to
|
||||
# house style would corrupt the very data the codemod tests assert on.
|
||||
scripts/codemod/fixtures/
|
||||
packages/live-host/
|
||||
packages/desktop-shell/runtime/
|
||||
# Vendored from mobile-next/mobile-mcp and carries its own eslint config, so
|
||||
# reformatting it to house style only widens the diff against upstream on the
|
||||
# next sync. eslint.config.js already ignores packages/mobile-mcp/** for the
|
||||
# same reason; .prettierignore had cua-driver but not its sibling.
|
||||
packages/mobile-mcp/
|
||||
@@ -0,0 +1,25 @@
|
||||
# Web Shell sidebarless live prompt state
|
||||
|
||||
## Baseline
|
||||
|
||||
The global CLI cannot reproduce a host-only `sidebar={false}` configuration.
|
||||
A focused Web Shell reproduction at `f6f237233b8c` mounted the prompt bridge
|
||||
without the sidebar or Sessions panel: the catalog fallback ran once, the
|
||||
live-state endpoint was never called, and the bridge remained non-authoritative.
|
||||
|
||||
## Manual check
|
||||
|
||||
1. Open the VS Code companion on a trusted workspace and start a prompt whose
|
||||
tool call stays silent for longer than three seconds.
|
||||
2. Keep the Sessions panel closed.
|
||||
3. Verify `GET /workspaces/:cwd/sessions/live-state` continues polling and the
|
||||
conversation indicator remains active until the daemon reports the prompt
|
||||
finished.
|
||||
4. Repeat with an untrusted workspace and verify no qualified live-state request
|
||||
is sent.
|
||||
|
||||
## Automated coverage
|
||||
|
||||
`App.test.tsx` verifies that a sidebarless host enables live-state polling for
|
||||
the connected trusted workspace. Existing provider tests verify that daemon
|
||||
authority keeps a silent turn active and settles it when the authority clears.
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
constants,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
@@ -221,6 +222,11 @@ function runQwen(options, prompt) {
|
||||
let stdoutCarry = '';
|
||||
let discardingOversizedStdoutLine = false;
|
||||
let terminalResult;
|
||||
// The stream's init event carries the RESOLVED model and the CLI version —
|
||||
// what actually ran, versus the configured OPENAI_MODEL the workflow knows.
|
||||
// finish() writes them to agent-model for the report footers.
|
||||
let initModel = '';
|
||||
let initVersion = '';
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let idleTimedOut = false;
|
||||
@@ -262,6 +268,24 @@ function runQwen(options, prompt) {
|
||||
const event = JSON.parse(line);
|
||||
lastOutputAt = Date.now();
|
||||
if (event?.type === 'result') terminalResult = event;
|
||||
// First init event wins; newlines are flattened so the sentinel file
|
||||
// stays two lines (the read sites allowlist further). The caps EQUAL
|
||||
// the read sites' published bounds (cut -c1-100 / -c1-40), so a
|
||||
// legitimate value is never written long and silently truncated on
|
||||
// its way into the footer — a contract test pins the two pairs
|
||||
// together.
|
||||
if (
|
||||
!initModel &&
|
||||
event?.type === 'system' &&
|
||||
event?.subtype === 'init' &&
|
||||
typeof event.model === 'string'
|
||||
) {
|
||||
initModel = event.model.split('\n')[0].slice(0, 100);
|
||||
initVersion =
|
||||
typeof event.qwen_code_version === 'string'
|
||||
? event.qwen_code_version.split('\n')[0].slice(0, 40)
|
||||
: '';
|
||||
}
|
||||
if (event?.type !== 'stream_event') {
|
||||
process.stdout.write(`${line}${terminated ? '\n' : ''}`);
|
||||
}
|
||||
@@ -329,6 +353,37 @@ function runQwen(options, prompt) {
|
||||
apiErrorKind: apiErrorInfo.kind,
|
||||
sandboxRemoval,
|
||||
};
|
||||
// Written on EVERY settle path — a crashed or timed-out round is exactly
|
||||
// when the diagnosis footer needs to name the model that died. The open
|
||||
// is non-following and non-blocking (af-053's rule for agent-writable
|
||||
// paths, the shape run-ledger.ts's noFollow writes use): WORKDIR is
|
||||
// bind-mounted rw into the sandbox as this same uid, so the round that
|
||||
// just ran can leave a FIFO here — a plain O_WRONLY open would block
|
||||
// forever at this point, after finish() has already disarmed every
|
||||
// watchdog — or a symlink, which O_TRUNC would follow to truncate a
|
||||
// host file while the round reports success. O_NOFOLLOW turns the
|
||||
// symlink into ELOOP and O_NONBLOCK the readerless FIFO into ENXIO
|
||||
// (undefined O_NOFOLLOW on Windows folds to 0; neither shape exists
|
||||
// there), both landing in the best-effort catch.
|
||||
if (initModel || initVersion) {
|
||||
try {
|
||||
writeFileSync(
|
||||
file(options.workdir, 'agent-model'),
|
||||
`${initModel}\n${initVersion}\n`,
|
||||
{
|
||||
flag:
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_TRUNC |
|
||||
(constants.O_NOFOLLOW ?? 0) |
|
||||
constants.O_NONBLOCK,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// Best-effort: a write failure must not change the run outcome; the
|
||||
// read sites fall back to the configured model.
|
||||
}
|
||||
}
|
||||
if (log.destroyed) {
|
||||
resolve(payload);
|
||||
} else {
|
||||
|
||||
@@ -78,26 +78,26 @@ path this repo ships or loads. A path named by more than one row takes the
|
||||
most restrictive outcome: Never-a-target beats Report-only, which beats
|
||||
Landable.
|
||||
|
||||
| Territory | Outcome |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/cli/src` — the whole package (`generated/` stays under the Never-a-target row below; `**/*.sb` stays under the Report-only row below; `i18n/locales/**` and `commands/extensions/examples/**` stay under the Report-only row below; `**/*.test.ts(x)`, `**/*.spec.ts(x)`, `**/__snapshots__/**` are never targets, always searched as consumers) | Landable |
|
||||
| `scripts/`, `esbuild.config.js`, `eslint.legacy-filenames.mjs`, root manifests | Landable |
|
||||
| Territory | Outcome |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/cli/src` — the whole package (`generated/` stays under the Never-a-target row below; `**/*.sb` stays under the Report-only row below; `i18n/locales/**` and `commands/extensions/examples/**` stay under the Report-only row below; `**/*.test.ts(x)`, `**/*.spec.ts(x)`, `**/__snapshots__/**` are never targets, always searched as consumers) | Landable |
|
||||
| `scripts/`, `esbuild.config.js`, `eslint.legacy-filenames.mjs`, root manifests | Landable |
|
||||
| Whole files or directories nothing consumes by any mechanism named above — no import and no runtime read, loader, manifest, or tool config — anywhere outside `packages/core/src`, `packages/audio-capture`, `packages/channels`, `packages/sdk-*`, `packages/acp-bridge`, `packages/vscode-ide-companion`, `packages/chrome-extension`, `packages/zed-extension`, `packages/web-shell`, `packages/core/vendor`, `.github`, and the Never-a-target row below | Landable |
|
||||
| `docs/users/**`, `docs/developers/**`, `docs/index.md`, `docs/_meta.ts`, `packages/cli/src/i18n/locales/**`, `packages/cli/src/commands/extensions/examples/**` — as whole files or directories | **Report-only** — copied into the published tarball (`scripts/prepare-package.js` copies the locales and extension examples; `scripts/copy_bundle_assets.js` copies `docs/users/` for qc-helper) and consumed by the published docs site (`docs-site/scripts/link-public-docs.mjs` symlinks `docs/users/` and `docs/developers/` into the Nextra build per `PUBLIC_DOC_ROOTS` and copies `docs/index.md` and `docs/_meta.ts`; the site discovers pages by walking that tree); consumers are runtime reads — qc-helper's doc paths, the i18n loader's segment-assembled `import()`, `/extensions new` scaffolds — never imports. Individual orphan locale keys stay class-4 candidates: their proof greps the literal key, naming its mechanism |
|
||||
| `docs-site/` | **Report-only** — standalone published-site app, not a workspace member; route files are consumed by Next.js filesystem routing and an out-of-repo deploy, never imports, and no in-repo CI builds it |
|
||||
| Tracked `.qwen/skills/**`, `.qwen/agents/**`, `.qwen/e2e-tests/**`, `docs/design/**`, `docs/plans/**` | **Report-only** — consumed by the skill loader, agent definitions, and process readers (including `AGENTS.md` itself), never imports |
|
||||
| `AGENTS.md`, `CLAUDE.md`, `SECURITY.md`, `CONTRIBUTING.md`, `.prettierrc.json`, `.prettierignore`, `.editorconfig`, `.nvmrc`, `.npmrc`, `.yamllint.yml` | **Report-only** — consumed by external tooling through filename convention (agent harnesses, GitHub's security-policy UI, prettier and yamllint config auto-discovery, nvm, editors); never imports, and an in-repo grep for them measures only prose |
|
||||
| Anything under `packages/core/src` | **Report-only** — published |
|
||||
| `packages/audio-capture`, `packages/channels` | **Report-only** — npm-published (`--access public`) |
|
||||
| `packages/core/vendor/**`, `packages/web-shell` | **Report-only** — shipped inside the published `@qwen-code/qwen-code` tarball / served to browsers by `qwen serve`; consumers are bundled or browser-side, never imports |
|
||||
| `packages/cli/src/utils/**/*.sb` | **Report-only** — copied into the published bundle by extension glob (`scripts/copy_bundle_assets.js` copies `packages/**/*.sb`, `scripts/prepare-package.js` lists `'*.sb'`) and read at runtime through a segment-assembled path (`resolveSeatbeltProfileFile()` builds `sandbox-macos-${profile}.sb`); consumers are never imports, and a basename grep measures zero |
|
||||
| Any key in `packages/cli/src/config/settingsSchema.ts` | **Report-only** — see below |
|
||||
| `packages/sdk-*`, `packages/acp-bridge`, protocol/wire shapes | **Report-only** — out-of-repo consumers |
|
||||
| `packages/vscode-ide-companion`, `packages/chrome-extension`, `packages/zed-extension` | **Report-only** — shipped as store manifests; the store is the consumer |
|
||||
| `.github/` — workflows, actions, CODEOWNERS | **Report-only** — consumed GitHub-side: triggers, required checks, cross-repo `uses:` |
|
||||
| `package.json` dependencies | **Report-only** — bundlers and postinstall hide consumers |
|
||||
| Comments, JSDoc, commented-out code | **Out of scope** — `AGENTS.md` says do not delete existing comments as cleanup |
|
||||
| `packages/desktop-shell`, `packages/cua-driver`, `packages/mobile-mcp`, `**/generated/**`, `**/*.test.ts(x)`, `**/*.spec.ts(x)`, `**/__snapshots__/**` | Never a target; always searched as consumers — vitest discovers tests by filename glob, nothing imports them, so an import-based orphan detector matches every live test vacuously |
|
||||
| `docs/users/**`, `docs/developers/**`, `docs/index.md`, `docs/_meta.ts`, `packages/cli/src/i18n/locales/**`, `packages/cli/src/commands/extensions/examples/**` — as whole files or directories | **Report-only** — copied into the published tarball (`scripts/prepare-package.js` copies the locales and extension examples; `scripts/copy_bundle_assets.js` copies `docs/users/` for qc-helper) and consumed by the published docs site (`docs-site/scripts/link-public-docs.mjs` symlinks `docs/users/` and `docs/developers/` into the Nextra build per `PUBLIC_DOC_ROOTS` and copies `docs/index.md` and `docs/_meta.ts`; the site discovers pages by walking that tree); consumers are runtime reads — qc-helper's doc paths, the i18n loader's segment-assembled `import()`, `/extensions new` scaffolds — never imports. Individual orphan locale keys stay class-4 candidates: their proof greps the literal key, naming its mechanism |
|
||||
| `docs-site/` | **Report-only** — standalone published-site app, not a workspace member; route files are consumed by Next.js filesystem routing and an out-of-repo deploy, never imports, and no in-repo CI builds it |
|
||||
| Tracked `.qwen/skills/**`, `.qwen/agents/**`, `.qwen/e2e-tests/**`, `docs/design/**`, `docs/plans/**` | **Report-only** — consumed by the skill loader, agent definitions, and process readers (including `AGENTS.md` itself), never imports |
|
||||
| `AGENTS.md`, `CLAUDE.md`, `SECURITY.md`, `CONTRIBUTING.md`, `.prettierrc.json`, `.prettierignore`, `.editorconfig`, `.nvmrc`, `.npmrc`, `.yamllint.yml` | **Report-only** — consumed by external tooling through filename convention (agent harnesses, GitHub's security-policy UI, prettier and yamllint config auto-discovery, nvm, editors); never imports, and an in-repo grep for them measures only prose |
|
||||
| Anything under `packages/core/src` | **Report-only** — published |
|
||||
| `packages/audio-capture`, `packages/channels` | **Report-only** — npm-published (`--access public`) |
|
||||
| `packages/core/vendor/**`, `packages/web-shell` | **Report-only** — shipped inside the published `@qwen-code/qwen-code` tarball / served to browsers by `qwen serve`; consumers are bundled or browser-side, never imports |
|
||||
| `packages/cli/src/utils/**/*.sb` | **Report-only** — copied into the published bundle by extension glob (`scripts/copy_bundle_assets.js` copies `packages/**/*.sb`, `scripts/prepare-package.js` lists `'*.sb'`) and read at runtime through a segment-assembled path (`resolveSeatbeltProfileFile()` builds `sandbox-macos-${profile}.sb`); consumers are never imports, and a basename grep measures zero |
|
||||
| Any key in `packages/cli/src/config/settingsSchema.ts` | **Report-only** — see below |
|
||||
| `packages/sdk-*`, `packages/acp-bridge`, protocol/wire shapes | **Report-only** — out-of-repo consumers |
|
||||
| `packages/vscode-ide-companion`, `packages/chrome-extension`, `packages/zed-extension` | **Report-only** — shipped as store manifests; the store is the consumer |
|
||||
| `.github/` — workflows, actions, CODEOWNERS | **Report-only** — consumed GitHub-side: triggers, required checks, cross-repo `uses:` |
|
||||
| `package.json` dependencies | **Report-only** — bundlers and postinstall hide consumers |
|
||||
| Comments, JSDoc, commented-out code | **Out of scope** — `AGENTS.md` says do not delete existing comments as cleanup |
|
||||
| `packages/desktop-shell`, `packages/cua-driver`, `packages/mobile-mcp`, `**/generated/**`, `**/*.test.ts(x)`, `**/*.spec.ts(x)`, `**/__snapshots__/**` | Never a target; always searched as consumers — vitest discovers tests by filename glob, nothing imports them, so an import-based orphan detector matches every live test vacuously |
|
||||
|
||||
A settings key with zero read sites is still not cleanup. The key is likely
|
||||
documented under `docs/users/` and completed from the generated schema;
|
||||
@@ -332,9 +332,9 @@ easy surface is gone — the remaining work is design, not sweeping).
|
||||
delete a colleague's. Removing the doc comment attached to a symbol you are
|
||||
deleting in the same commit is not comment deletion; anything else is out of
|
||||
scope.
|
||||
- No formatting sweeps, no dependency bumps, no drive-by renames. CI's
|
||||
Prettier step runs `--write`, not `--check`, so a formatting diff carries
|
||||
zero signal.
|
||||
- No formatting sweeps, no dependency bumps, no drive-by renames. But CI's
|
||||
Prettier step runs `--check` and exits non-zero, so any deletion that
|
||||
changes line shape must be accompanied by `npm run format`.
|
||||
- Never file a candidate whose evidence is a line count, a complexity score,
|
||||
or "this looks complex." It names no consumer and proves no deletion.
|
||||
|
||||
|
||||
@@ -139,9 +139,8 @@ Green CI is weaker evidence than it looks for a deletion:
|
||||
skips most steps. Deleting docs alongside code can quietly buy you less CI
|
||||
than you think.
|
||||
- The Prettier CI step (the `Run Prettier` step in `ci.yml` → `runPrettier()`
|
||||
in `scripts/lint.js`) runs `prettier --write .`, not `--check`, so
|
||||
formatting never fails a build — and a formatting-only diff carries zero
|
||||
signal.
|
||||
in `scripts/lint.js`) runs `prettier --check .`, so unformatted code fails
|
||||
the build. Run `npm run format` before pushing.
|
||||
- The pre-commit hook runs formatters over staged files. Re-read the diff
|
||||
after committing; if the hook reformatted lines you did not otherwise touch,
|
||||
restore them in a follow-up commit (history is additive here — never amend).
|
||||
|
||||
@@ -743,10 +743,10 @@ since the merge-base, say so and re-measure there.
|
||||
`bash -n` and `shellcheck` on extracted `run:` blocks always work; the
|
||||
repo's wrapper only lints when the pinned binaries are present, so
|
||||
install them with `node scripts/lint.js --setup` and then invoke the
|
||||
individual non-mutating checks (`--actionlint`, `--yamllint`, `--eslint`).
|
||||
**Never run `node scripts/lint.js` with no arguments** — the no-arg form
|
||||
also runs `prettier --write .`, which rewrites the PR working tree
|
||||
underneath your A/B and replay harnesses. If the tools cannot be installed
|
||||
individual non-mutating checks (`--actionlint`, `--yamllint`, `--eslint`,
|
||||
`--prettier`). **Never run `node scripts/lint.js` with no arguments** — the
|
||||
no-arg form calls `setupLinters()`, which wipes the linter temp dir and
|
||||
re-downloads three pinned binaries before running anything. If the tools cannot be installed
|
||||
in-container, say which gate you could not run rather than implying it
|
||||
passed. For a new automated trigger, do the day-one cost math
|
||||
— arrival rate against the job's drain rate. Event history needs the API,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
`GET /health?deep=1` gains three fields: `activeWork`, `activeWorkReporting`, and `activeWorkStaleMs`.
|
||||
|
||||
`activeWork` is true while any managed workspace has an accepted-but-unsettled prompt, a running background Agent, an Agent terminal notification that is queued, awaiting acceptance, or being processed by its parent continuation, or Session-managed background shell work. Shell work covers a running registry entry and the terminal notification until its parent continuation settles. It deliberately does **not** cover Monitors, workflows, cron, or external processes the shell registry can no longer track.
|
||||
`activeWork` is true while any managed workspace has an accepted-but-unsettled prompt, a running background Agent, an Agent terminal notification that is queued, awaiting acceptance, or being processed by its parent continuation, Session-managed background shell or workflow work, or a child-owned Session turn. The aggregate `session` hold covers goal and cron processing, history mutation, and queued or running Monitor continuations. Running Monitors, follow-up suggestions, and external processes the shell registry can no longer track remain outside the field.
|
||||
|
||||
It is also **Session-scoped, not channel-scoped**. Channel-level work with no Session attached yet — a spawn in flight, a pending restore, MCP discovery or authentication — is not counted, so `activeWork` can read false while the daemon's own `hasNoChannelWork` is simultaneously refusing to reclaim that channel. The two answer different questions and are allowed to disagree: this field describes work owned by Sessions, and widening it to cover channel setup would change what the boolean means for every existing reader. A controller that needs "is this daemon reclaimable" must combine the three-term rule below with a graceful-shutdown handshake, not read more into this one field than it claims.
|
||||
|
||||
@@ -16,7 +16,7 @@ Restart policy stays with the external controller. The daemon publishes facts; i
|
||||
|
||||
## Why holds, and why full snapshots
|
||||
|
||||
Each Session reports a set of named **holds**, each carrying a category (`agent`, `notification`, or `shell`). Two properties follow, and both are the point:
|
||||
Each Session reports a set of named **holds**, each carrying a category (`agent`, `notification`, `shell`, `session`, or `workflow`). Two properties follow, and both are the point:
|
||||
|
||||
**Holds are derived, never maintained.** `Session.collectActiveWorkHolds()` reads the owners of the work — the background-task registry's unfinalized set, the background-shell registry's running entries, the notification queue, and the in-flight acceptance and continuation state — on every call. There is no acquire/release ledger kept alongside the work, because a ledger can miss a release, and a leaked hold would pin its Session forever while every snapshot faithfully republished the leak.
|
||||
|
||||
@@ -24,6 +24,8 @@ The agent category uses `BackgroundTaskRegistry.hasUnfinalizedTasks()`'s predica
|
||||
|
||||
Shells use one aggregate hold, `{ "category": "shell", "id": "background-shells" }`, regardless of the number of running shells. The task registry and `/tasks` surface remain the detailed roster; active-work only needs the bounded retention fact. The aggregate also prevents an unbounded shell roster from exceeding the protocol's per-Session hold limit.
|
||||
|
||||
Child-owned turns that are not foreground prompts use one aggregate hold, `{ "category": "session", "id": "session:active-turn" }`. This keeps the conditional-close predicate aligned with the work its drain waits for without exposing feature-specific protocol categories.
|
||||
|
||||
**Reports are complete snapshots at channel scope, not per-Session transitions.** One message per ACP channel carries every Session the child owns and every hold it holds:
|
||||
|
||||
```json
|
||||
@@ -55,7 +57,7 @@ Per Session the daemon holds one of:
|
||||
|
||||
Never-reported and gone-quiet are the same state on purpose. A snapshot older than the grading window (`intervalMs × 3`) is not a report that the Session is idle, it is the absence of one — a background Agent could have started at any point since — so it stops counting as evidence.
|
||||
|
||||
**Unknown is a reason to ask, not a reason to skip.** The two consumers read it differently, and they have to: the health surface reports unknown as busy (a controller must never mistake "nobody told me" for "nothing is running"), while automatic cleanup treats it as a candidate and goes on to the conditional close below. Only _known_ work — daemon-owned, or a fresh report of held work — blocks the attempt outright. Skipping on unknown instead would look safe and in fact be the worse failure: nothing would ever resolve it, so a Session on a channel that went quiet would be retained forever with no path out. Asking costs one bounded round trip and still retains on any non-answer, and the child can answer authoritatively under its close gate whether or not its snapshots are arriving. Incomplete coverage is different and does skip: a negotiated child that omits `shell` can truthfully answer according to its older predicate while missing a running shell, so its answer cannot authorize automatic destruction.
|
||||
**Unknown is a reason to ask, not a reason to skip.** The two consumers read it differently, and they have to: the health surface reports unknown as busy (a controller must never mistake "nobody told me" for "nothing is running"), while automatic cleanup treats it as a candidate and goes on to the conditional close below. Only _known_ work — daemon-owned, or a fresh report of held work — blocks the attempt outright. Skipping on unknown instead would look safe and in fact be the worse failure: nothing would ever resolve it, so a Session on a channel that went quiet would be retained forever with no path out. Asking costs one bounded round trip and still retains on any non-answer, and the child can answer authoritatively under its close gate whether or not its snapshots are arriving. Incomplete coverage is different and does skip: a negotiated child that omits `shell` or `session` can truthfully answer according to its older predicate while missing work in that category, so its answer cannot authorize automatic destruction.
|
||||
|
||||
Reclaiming a channel that has stopped answering entirely is still not this mechanism's job; see below.
|
||||
|
||||
@@ -66,7 +68,7 @@ qwen/control/session/close { sessionId, onlyIfUnheld: true }
|
||||
→ { sessionId, closed: true } | { sessionId, closed: false, holds: [...] }
|
||||
```
|
||||
|
||||
The child evaluates it under its own close gate, before anything destructive runs. It rejects known holds immediately, drains any turn that was already active when the gate closed, then evaluates the unfiltered collector again. The second read matters because an already-running out-of-scope turn such as cron can register a background shell while it drains. With the gate still held no new turn can start after that final read, so a hold cannot appear between final authorization and teardown **on the child side**. If either read finds holds, the gate is released and they are handed back. The daemon adopts the returned hold set only when it stays within the same 1,024-hold per-Session bound as snapshots; an oversized refusal still retains the Session but does not replace the last valid cache.
|
||||
The child evaluates it under its own close gate, before anything destructive runs. It rejects known holds immediately, drains any turn that was already active when the gate closed, then evaluates the unfiltered collector again. With the gate still held no new turn can start after that final read, so a hold cannot appear between final authorization and teardown **on the child side**. If either read finds holds, the gate is released and they are handed back. The daemon adopts the returned hold set only when it stays within the same 1,024-hold per-Session bound as snapshots; an oversized refusal still retains the Session but does not replace the last valid cache.
|
||||
|
||||
The daemon side needs its own cover, because the round trip is an await of up to ten seconds. A Session with a conditional close outstanding is marked in-flight, and every admission path — attach, prompt, rewind — refuses it exactly as it refuses one that is already closing. Without that, a prompt accepted during the round trip is lost when the teardown it raced completes; the previous synchronous guard-then-teardown sequence got this for free, and splitting it is what created the need to say so explicitly.
|
||||
|
||||
|
||||
@@ -18,12 +18,12 @@ The Session collector remains an unfiltered statement of local truth. Category n
|
||||
|
||||
## Negotiation and compatibility
|
||||
|
||||
The protocol version remains v1. The daemon initialize request advertises `agent`, `notification`, and `shell`; the child answers with the intersection it supports. A request with no `categories` is the pre-negotiation v1 baseline, `agent` and `notification`.
|
||||
The protocol version remains v1. The daemon initialize request advertises every current category; the child answers with the intersection it supports. A request with no `categories` is the pre-negotiation v1 baseline, `agent` and `notification`.
|
||||
|
||||
| Peers | Reporting result | Ordinary automatic cleanup |
|
||||
| ------------------------------------------ | ---------------------------------------- | ----------------------------------------------------- |
|
||||
| new daemon + new child | `full`; shell hold crosses the wire | existing conditional-close flow |
|
||||
| new daemon + old v1 child | `partial`; `shell` is missing | disabled for that Session |
|
||||
| new daemon + new child | `full`; current holds cross the wire | existing conditional-close flow |
|
||||
| new daemon + old v1 child | `partial`; newer categories are missing | disabled for that Session |
|
||||
| old v1 daemon + new child | wire contains only the legacy categories | local conditional close still rejects a running shell |
|
||||
| daemon + child with no active-work support | `none` | historical legacy cleanup |
|
||||
|
||||
@@ -37,13 +37,13 @@ At shell completion, the registry invokes the notification callback before publi
|
||||
|
||||
`Session.isIdle()` consumes the same unfiltered collector. Workspace reload therefore skips a Session while a background shell or its terminal continuation is active.
|
||||
|
||||
Conditional close reads the unfiltered collector once before disturbing active turns and again after those turns drain, while the Session close gate remains held. The final read closes the window where an already-running, otherwise out-of-scope cron or automatic turn registers a shell during drain; the new shell refuses ordinary teardown without adding cron itself to `activeWork`.
|
||||
Conditional close reads the unfiltered collector once before disturbing active turns and again after those turns drain, while the Session close gate remains held. Child-owned cron, goal, history-mutation, and Monitor-continuation work is now represented by the aggregate `session` category, so it refuses ordinary teardown before the drain begins.
|
||||
|
||||
## Boundaries
|
||||
|
||||
This change tracks the logical lifecycle owned by `BackgroundShellRegistry`; it does not use PID probes or sidecars to reconstruct process liveness. `task_stop` follows the registry's terminal status and does not promise an additional OS-level exit confirmation. A promoted or externally detached process that the registry no longer tracks is outside the signal.
|
||||
|
||||
Long-running development servers consequently keep `activeWork: true`. This is the intended retention fact, not shell-stall detection or a restart lease. Monitor, workflow, cron, and follow-up work remain out of scope, and the public health shape, persistence formats, shell admission policy, heartbeat behavior, and watchdog behavior do not change.
|
||||
Long-running development servers consequently keep `activeWork: true`. This is the intended retention fact, not shell-stall detection or a restart lease. Running Monitors, follow-up work, and external processes remain out of scope, and the public health shape, persistence formats, shell admission policy, heartbeat behavior, and watchdog behavior do not change.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -90,7 +90,7 @@ A response reports:
|
||||
- stable-element support;
|
||||
- a closed resynchronization reason when full output is required.
|
||||
|
||||
The caller, not cua-driver, selects the base revision. Missing, expired, foreign, or incompatible bases produce a full response. The driver never guesses whether a revision reached a model.
|
||||
The immediate native caller, not cua-driver, selects the base revision. The high-level `ComputerUse` facade is that caller: it keeps one cursor per exact window and never exposes cursor management to model code. Missing, expired, foreign, or incompatible bases produce a full response.
|
||||
|
||||
## Stable identity and diff correctness
|
||||
|
||||
@@ -122,7 +122,7 @@ Provider invalidation, truncation, subtree read failure, target ambiguity, or fa
|
||||
|
||||
The JavaScript wrapper exposes a small Computer Use API while directly using the generated TypeScript cua-driver SDK. It hides raw low-level constructors and arbitrary tool dispatch from its public surface, but it does not depend on Qwen Code.
|
||||
|
||||
The observation API returns the revision ID and accepts an explicit base revision ID on the next call. The caller owns that state. Actions consume opaque element tokens returned by the driver. The wrapper does not compute a second semantic diff or invent element identity.
|
||||
The observation API automatically uses the last successful revision for the same PID and window. The first observation, a new driver generation, an unsupported lineage, or an explicit one-shot `disableDiff` request returns a full tree. Incomplete captures receive one bounded default retry and remain observation-only if they cannot recover. Revision and lineage identifiers stay inside the wrapper; callers receive the observation mode, optional resync reason, current AX payload, screenshot, and a nested diagnostics object without cursor values. Actions consume opaque element tokens returned by the driver. The wrapper does not compute a second semantic diff or invent element identity.
|
||||
|
||||
The wrapper must run in a standalone Node.js integration test before Stage 3 begins.
|
||||
|
||||
|
||||
@@ -23,19 +23,20 @@ iteration must check its diff against this list before continuing.
|
||||
- B1: Node REPL MCP instructions contain only the minimal persistent-kernel
|
||||
contract: cross-cell scope, `globalThis` versus lexical bindings, dynamic
|
||||
import, selective output, and cell/reset lifecycle.
|
||||
- B2: the Computer Use skill retains the Codex Computer Use principles while
|
||||
using the actual CUA SDK types and action-result fields.
|
||||
- B3: state-changing operations use an action-result plus fresh, stable
|
||||
verification workflow. Completion requires current evidence.
|
||||
- B2: the Computer Use skill directly migrates Codex's `API surface` and
|
||||
`Workflow` sections onto the typed CUA SDK. Section structure, prose order,
|
||||
action-batch example structure, and decision semantics stay aligned; only
|
||||
SDK-required mappings change.
|
||||
- B3: after one or more UI actions, call `observeWindow(...)` before deciding
|
||||
what to do next. There is no per-action observation rule.
|
||||
- B4: the full Computer Use skill is not embedded in Node REPL MCP
|
||||
instructions. The two texts have one source and one owner each.
|
||||
- B5: both texts are minimum viable guidance. They contain no benchmark rules,
|
||||
scores, evaluators, historical case names, failure counts, application
|
||||
special cases, or duplicated examples. The final outbound model request is
|
||||
the delivery boundary.
|
||||
- B6: the default observation example maintains a per-surface revision cursor.
|
||||
`forceFull` is a one-shot resync path and is never a persistent helper
|
||||
default.
|
||||
- B6: the SDK maintains one revision cursor per exact surface. `disableDiff` is
|
||||
a one-shot complete-tree request and is never a persistent helper default.
|
||||
|
||||
## Non-goals
|
||||
|
||||
@@ -46,7 +47,8 @@ iteration must check its diff against this list before continuing.
|
||||
- Input across the UAC secure desktop or login desktop.
|
||||
- Changes to JavaScript lexical semantics, automatic block wrapping, binding
|
||||
deletion, or code rewriting to hide redeclarations.
|
||||
- A new CUA wrapper/DSL, implicit revision API, or benchmark-specific policy.
|
||||
- A new native revision protocol, model-managed revision helper, or
|
||||
benchmark-specific policy.
|
||||
|
||||
## Implementation order
|
||||
|
||||
@@ -59,8 +61,10 @@ iteration must check its diff against this list before continuing.
|
||||
metadata, partial accessibility coverage, and degraded diagnostics.
|
||||
4. Activate and route the existing packaged UIAccess worker for A4. Keep the
|
||||
public SDK and MCP endpoint unchanged.
|
||||
5. Rewrite the Node REPL instruction and Computer Use skill for B1-B6. Record a
|
||||
semantic coverage map from the canonical Codex principles to the new skill.
|
||||
5. Rewrite the Node REPL instruction and Computer Use skill for B1-B6. Record
|
||||
the mechanical API mapping from the canonical Codex workflow to the typed
|
||||
SDK, while keeping native revision details and state-verification helpers
|
||||
out of model guidance.
|
||||
6. Run focused tests, build/typecheck, protocol E2E, Windows interactive tests,
|
||||
AP-container-equivalent bootstrap, and representative prior-failure jobs.
|
||||
|
||||
@@ -77,8 +81,11 @@ After each fix:
|
||||
|
||||
## Verification record
|
||||
|
||||
The following evidence is complete for the current implementation. It does not
|
||||
replace the release or benchmark gates below.
|
||||
The following evidence was recorded for the preceding Round 2 implementation.
|
||||
This Linux/Qwen Code SDK and Skill update does not rerun or supersede its
|
||||
Windows and model-request results. These historical results do not replace the
|
||||
release or benchmark gates below and must not be read as verification of the
|
||||
revised Skill.
|
||||
|
||||
- Node REPL: 152 local tests pass. A clean-packed MCP session on Windows
|
||||
cancelled an active CUA cell without changing the kernel PID or the
|
||||
@@ -115,31 +122,29 @@ replace the release or benchmark gates below.
|
||||
verify that progressively discovered MCP instructions are queued for the
|
||||
next user turn instead of being lost after the startup prelude is built.
|
||||
|
||||
## Computer Use guidance coverage
|
||||
## Computer Use API migration
|
||||
|
||||
The Computer Use skill keeps the original operating principles but maps them
|
||||
to the public CUA SDK instead of copying interfaces from another runtime.
|
||||
The Computer Use skill keeps Codex's section structure and workflow. Only the
|
||||
runtime-specific surface changes:
|
||||
|
||||
| Principle | Round 2 owner and expression |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Prefer a connector or API | Computer Use skill, before any UI action |
|
||||
| Keep one persistent session | Node REPL MCP owns kernel lifecycle; the skill creates one `ComputerUse` client |
|
||||
| Select the real app and surface | `listApps`, then `listWindows`; no guessed PID, window, token, or coordinate |
|
||||
| Observe before acting | `observeWindow` on the exact current surface |
|
||||
| Prefer semantic targets | Current element tokens before screenshot coordinates |
|
||||
| Consume incremental state | Per-surface `baseRevisionId`; one-shot `forceFull` only for an explicit lineage failure |
|
||||
| Inspect only useful visuals | Request screenshots when accessibility is incomplete or visual evidence is necessary |
|
||||
| Use only advertised secondary actions | `performSecondaryAction` only from the current element action list |
|
||||
| Re-observe after an unexpected transition | Refresh surfaces and state instead of repeating an action blindly |
|
||||
| Do not assume text replacement | Inspect current state and select existing text when replacement is required |
|
||||
| Verify every mutation | Read the typed action result, then require fresh stable state with `actAndVerify`, `verifyState`, or a fresh visual observation |
|
||||
| Stop when the requested state is proven | No extra cleanup actions after the stable postcondition |
|
||||
| Codex `@oai/sky` | Qwen CUA SDK mapping |
|
||||
| --------------------------------- | ---------------------------------------------------------- |
|
||||
| bundled `sky` singleton | `ComputerUse.create()` and `close()` |
|
||||
| app name target | exact PID and window ID from `listApps` + `listWindows` |
|
||||
| `get_app_state` | `observeWindow` |
|
||||
| `element_index` | `elementToken` |
|
||||
| `disableDiff: true` | `disableDiff: true` |
|
||||
| screenshot `file://` URL | `{ mimeType, dataBase64 }` images |
|
||||
| snake-case actions | typed camel-case SDK actions |
|
||||
| `press_key` combination syntax | `pressKey` modifiers or `hotkey` keys |
|
||||
| `paste` and `select_text` | no public high-level SDK equivalent; omitted, not emulated |
|
||||
| one or more actions, then observe | unchanged |
|
||||
|
||||
The Node REPL MCP instruction exclusively owns JavaScript cell scope,
|
||||
redeclaration, cancellation, output, and reset behavior. The Computer Use
|
||||
skill exclusively owns UI observation, action, result interpretation, and
|
||||
verification. Neither text contains benchmark policy, evaluator criteria,
|
||||
historical failures, or application-specific examples.
|
||||
skill exclusively owns the migrated UI discovery, observation, action, and
|
||||
screenshot guidance. Neither text contains benchmark policy, evaluator
|
||||
criteria, historical failures, or application-specific examples.
|
||||
|
||||
## Release gate
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Automatic Computer Use observation diffs
|
||||
|
||||
## Decision
|
||||
|
||||
The root goal is a direct migration of Codex's Computer Use skill onto the
|
||||
public CUA SDK. The Qwen skill preserves Codex's `API surface` and `Workflow`
|
||||
structure, prose order, action-batch example, and decision semantics. It changes
|
||||
only the package import, typed API names and parameters, exact-window targeting,
|
||||
automatic diff option, screenshot representation, and required client cleanup.
|
||||
Automatic cursors and consistent full/diff/no-change behavior exist to support
|
||||
that migration; they do not define a new model workflow.
|
||||
|
||||
`@qwen-code/cua-sdk/computer-use` owns observation revision cursors. Model and
|
||||
application callers provide only an exact PID and window ID. The native
|
||||
`accessibility.observation_revision.v1` request remains unchanged and receives
|
||||
the last successful revision for that surface from the facade.
|
||||
|
||||
## State and concurrency
|
||||
|
||||
Each `ComputerUse` instance keeps a cursor keyed by PID and window ID. A cursor
|
||||
is valid only for the driver generation that produced it. A successful full,
|
||||
diff, or no-change response with stable element identity advances the cursor;
|
||||
unsupported or unretained observations clear it.
|
||||
|
||||
Observations for one surface execute in call order so an older response cannot
|
||||
replace a newer cursor. Different surfaces are not serialized together. A
|
||||
driver generation change clears every cursor, and an observation retried after
|
||||
reconnection explicitly requests a full tree.
|
||||
|
||||
`disableDiff: true` omits the saved base for one call and maps to the native
|
||||
force-full flag. A successful full result becomes the next automatic base. The
|
||||
legacy `forceFull` spelling remains a compatibility alias but is not part of
|
||||
model guidance.
|
||||
|
||||
An incomplete capture clears the surface cursor and receives one bounded retry
|
||||
without disabling diffs. If the retry remains incomplete, the facade returns
|
||||
an explicit observation-only state with no actionable elements and tells the
|
||||
caller to observe again normally after the UI settles or use the screenshot.
|
||||
|
||||
## Public guidance
|
||||
|
||||
The public observation options reject every cursor or native revision request.
|
||||
Revision and lineage identifiers stay private to the facade, and raw native
|
||||
responses are not returned. The normal result exposes text, elements,
|
||||
screenshot, mode, and resync reason; protocol metrics are grouped under a
|
||||
cursor-free diagnostics object.
|
||||
|
||||
The bundled skill preserves Codex's loop: initialize the target, perform one or
|
||||
more actions, then observe before deciding what to do next. The SDK owns the
|
||||
cursor, so the model never stores or sends revision identifiers. The skill
|
||||
prefers the default diff output and uses `disableDiff: true` only when it needs a
|
||||
fresh complete tree; it adds no per-action observation rule.
|
||||
|
||||
The skill requests screenshots only when AX text is incomplete, visual layout
|
||||
matters, or observed behavior conflicts with AX state. Returned images use the
|
||||
typed `{ mimeType, dataBase64 }` shape.
|
||||
|
||||
## Verification
|
||||
|
||||
Hermetic tests cover the initial full response, automatic diff/no-change,
|
||||
one-shot diff disabling, incomplete-capture recovery, surface isolation,
|
||||
invalid-base resync, unsupported capability, generation changes, and
|
||||
same-surface concurrency. Type tests reject manual revision inputs and public
|
||||
access to revision IDs or raw responses. A gated daemon integration test
|
||||
exercises two observations without passing revision state.
|
||||
@@ -0,0 +1,62 @@
|
||||
# CUA SDK Default Delivery Mode
|
||||
|
||||
## Problem
|
||||
|
||||
The high-level `@qwen-code/cua-sdk/computer-use` facade currently relies on the
|
||||
native default whenever an action omits `deliveryMode`. That default is
|
||||
`background`, which is appropriate for normal desktop automation but not for
|
||||
isolated benchmark desktops where preserving another foreground window has no
|
||||
value and background input may be rejected by the application framework.
|
||||
|
||||
The facade also exposes camelCase `deliveryMode`, while native refusal text can
|
||||
recommend snake_case `delivery_mode`. JavaScript silently accepts that unknown
|
||||
property, so a caller can believe it selected foreground delivery while the
|
||||
facade still dispatches the native default.
|
||||
|
||||
## Design
|
||||
|
||||
Add `QWEN_CUA_SDK_DEFAULT_DELIVERY_MODE`, accepting exactly `background` or
|
||||
`foreground` case-insensitively. `ComputerUse.create()` and
|
||||
`ComputerUse.connect()` capture it when the facade instance is created.
|
||||
|
||||
Delivery precedence is:
|
||||
|
||||
1. the action's explicit `deliveryMode`;
|
||||
2. `QWEN_CUA_SDK_DEFAULT_DELIVERY_MODE`;
|
||||
3. `background`.
|
||||
|
||||
Only actions that already accept `deliveryMode` are affected. Accessibility-only
|
||||
actions such as `setValue` and `performSecondaryAction` remain unchanged. An
|
||||
invalid environment value fails during facade creation rather than silently
|
||||
falling back. The facade passes the resolved value to every supported typed
|
||||
action, including an explicit `background` when neither higher-precedence source
|
||||
is present, so the high-level contract does not depend on a native implicit
|
||||
default.
|
||||
|
||||
The facade rejects the unsupported JavaScript property `delivery_mode` with a
|
||||
camelCase correction before dispatch, and rewrites native delivery-mode guidance
|
||||
in surfaced error text to the public camelCase name.
|
||||
|
||||
## Skill migration
|
||||
|
||||
Keep the Codex-aligned API-surface and workflow structure. Add only the real
|
||||
typed `deliveryMode` option to action signatures. In the screenshot section,
|
||||
state that `includeScreenshot: true` requests the image and that `disableDiff`
|
||||
controls only accessibility full/diff output.
|
||||
|
||||
## Process-wide configuration
|
||||
|
||||
An isolated automation process that should consistently use foreground delivery
|
||||
sets:
|
||||
|
||||
```text
|
||||
QWEN_CUA_SDK_DEFAULT_DELIVERY_MODE=foreground
|
||||
```
|
||||
|
||||
This is process configuration, not model prompting. Ordinary SDK consumers
|
||||
remain background by default.
|
||||
|
||||
## Scope
|
||||
|
||||
No native delivery-mode default changes, persistent window activation, automatic
|
||||
background-to-foreground retry, or benchmark policy is added to the Skill.
|
||||
@@ -0,0 +1,197 @@
|
||||
# Mem0 External Context Auto Recall
|
||||
|
||||
**Status:** Implemented
|
||||
|
||||
**Date:** 2026-09-04
|
||||
|
||||
## Decision
|
||||
|
||||
Add an administrator-installed `UserPromptSubmit` command Hook to the public
|
||||
Mem0 External Context package. The Hook reuses the administrator-owned
|
||||
`DialectV1`, bounded request engine, and untrusted result envelope without
|
||||
changing Qwen Core or the default Extension manifest.
|
||||
|
||||
The retrieval profiles are mutually exclusive:
|
||||
|
||||
- **On-demand:** `InstanceConfigV2`, the default Extension manifest, and the
|
||||
`context_search({ query })` MCP tool.
|
||||
- **Auto recall:** `InstanceConfigV3`, an administrator-installed Hook, and no
|
||||
Mem0 External Context MCP server.
|
||||
|
||||
The default manifest does not register the Hook. Installing or upgrading the
|
||||
Extension therefore cannot cause automatic prompt forwarding or add a process
|
||||
spawn to existing user turns. Auto recall requires both a v3 configuration and
|
||||
an explicit Hook registration in an administrator-controlled `QWEN_HOME`.
|
||||
|
||||
## Scope
|
||||
|
||||
### Goals
|
||||
|
||||
- Perform at most one provider search for an eligible user submission.
|
||||
- Preserve the v2 MCP configuration and `context_search` contract.
|
||||
- Keep the endpoint, credential, scope, dialect, and repository binding outside
|
||||
model control.
|
||||
- Use only `submitted_prompt` provenance captured before model-bound expansion.
|
||||
- Reduce accidental credential forwarding before a query leaves the host.
|
||||
- Inject only bounded, structured, untrusted user-layer context.
|
||||
- Fail open with bounded latency and no integration-generated request logs.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Memory creation, update, deletion, ingestion, or automatic memory extraction.
|
||||
- Built-in provider presets or provider-specific dialects.
|
||||
- Qwen Core changes or conditional Extension-manifest features.
|
||||
- DLP, user authentication, tenant authorization, or compliance audit.
|
||||
- Input paths that do not provide `submitted_prompt`.
|
||||
- Retry, redirect, cache, protocol probing, or a persistent Hook process.
|
||||
- Preventing indirect prompt injection from retrieved content.
|
||||
|
||||
## Configuration
|
||||
|
||||
`InstanceConfigV2` remains the exact on-demand schema. Auto recall uses a
|
||||
separate canonical schema and `schemaVersion: 3`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"autoRecall": {
|
||||
"repositoryRoot": "/absolute/path/to/repository"
|
||||
},
|
||||
"dialectPath": "/etc/qwen/external-context/memory.dialect.json",
|
||||
"endpoint": {
|
||||
"origin": "https://memory.example.com",
|
||||
"basePath": "",
|
||||
"allowInsecureHttp": false
|
||||
},
|
||||
"credentialEnv": "MEMORY_API_KEY",
|
||||
"scope": {
|
||||
"userId": "repository-memory"
|
||||
},
|
||||
"timeoutMs": 1500
|
||||
}
|
||||
```
|
||||
|
||||
The Hook and MCP entry points share
|
||||
`QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG`, but accept different configuration
|
||||
versions. The MCP loader accepts only v2 and the Hook loader accepts only v3.
|
||||
This rejects accidental cross-mode use while preserving existing deployments.
|
||||
The auto-recall timeout must be from 100 through 5000 milliseconds.
|
||||
|
||||
`repositoryRoot` must be an existing absolute directory and cannot be a
|
||||
filesystem root. The loader resolves it through `realpath`. Each event `cwd` is
|
||||
also resolved through `realpath`; retrieval runs only for the configured root
|
||||
or a descendant. The repository check prevents accidental corpus reuse after a
|
||||
directory change. Provider-side credentials and authorization remain the
|
||||
actual security boundary.
|
||||
|
||||
`DialectV1` is unchanged. It remains a closed administrator-owned grammar and
|
||||
cannot define arbitrary headers, templates, transformations, or write methods.
|
||||
|
||||
## Runtime flow
|
||||
|
||||
Each eligible invocation starts a new Node process:
|
||||
|
||||
1. Read at most 1 MiB of Hook JSON from stdin.
|
||||
2. Require `hook_event_name: "UserPromptSubmit"`, a non-empty
|
||||
`submitted_prompt`, and a string `cwd`.
|
||||
3. Load and validate `InstanceConfigV3`, `DialectV1`, the canonical repository
|
||||
root, and the named credential.
|
||||
4. Resolve `cwd` and skip retrieval when it is outside the configured root.
|
||||
5. Remove fenced code, the exact configured credential, and common secret
|
||||
shapes; collapse whitespace and keep at most 512 Unicode code points.
|
||||
6. Call the existing request engine once with the configured timeout.
|
||||
7. Return at most five results through `UserPromptSubmit.additionalContext` as
|
||||
the existing `untrusted_external_context` envelope.
|
||||
8. Emit `{}` for missing provenance, mismatched paths, empty results, invalid
|
||||
configuration, timeouts, transport failures, or invalid provider responses.
|
||||
|
||||
The Hook never reads or falls back to the legacy `prompt` field. Eligibility
|
||||
depends on `submitted_prompt`, not the input transport: supported TUI
|
||||
submissions and headless CLI user turns supply it, including `qwen -p` and
|
||||
stream-json user messages from SDK clients. Events without this field, such as
|
||||
tool-result continuations, skip retrieval. Do not infer a TUI-only origin or
|
||||
exclude a transport merely from its name.
|
||||
|
||||
Configuration and dialect paths must resolve to regular files and are bounded
|
||||
to 64 KiB. Nonblocking open and descriptor validation reject FIFOs before a
|
||||
filesystem worker can block waiting for a writer. The long-running MCP
|
||||
process reads them once at startup. The command Hook reads them once per
|
||||
eligible invocation, so administrator file changes apply to the next eligible
|
||||
submission; changing its environment or Hook registration requires restarting
|
||||
Qwen.
|
||||
|
||||
## Bounds and failure semantics
|
||||
|
||||
- Sanitizer input: 4096 Unicode code points.
|
||||
- Provider query: 512 Unicode code points.
|
||||
- Provider timeout: 100-5000 ms.
|
||||
- Internal Hook wall-clock budget: 6500 ms.
|
||||
- Qwen command-Hook timeout: 8000 ms.
|
||||
- Provider response: 1 MiB before JSON parsing.
|
||||
- Output: five items, 1000 Unicode code points per content field, and 4000
|
||||
JavaScript code units for the serialized envelope.
|
||||
|
||||
There is no retry, redirect, or cache. The Hook writes exactly one JSON object
|
||||
to stdout and emits no integration-generated stderr. Once the pinned Node
|
||||
entry point starts, handled failures return `{}` with exit code zero. The
|
||||
executable flushes stdout and explicitly exits so abandoned connections do not
|
||||
keep the event loop alive after the result is ready. Secret-assignment
|
||||
matching starts at identifier boundaries and checks the keyword separately from
|
||||
the assignment suffix, avoiding overlapping scans on repeated-keyword inputs.
|
||||
A launcher failure before Node starts or an outer Qwen timeout follows the
|
||||
command-Hook runner's own error policy; ordinary runner timeouts are nonfatal
|
||||
but delay the turn. Administrators must validate the fixed binary and bundle
|
||||
paths before rollout.
|
||||
|
||||
Sanitization is a best-effort reduction, not DLP. The external provider may log
|
||||
the sanitized query. Retrieved content is sent to the model provider and may be
|
||||
persisted in the session transcript. The untrusted envelope and Qwen's reserved
|
||||
Hook-context wrapper preserve provenance but do not prevent the model from
|
||||
following malicious retrieved instructions.
|
||||
|
||||
## Deployment
|
||||
|
||||
The package ships `dist/auto-recall.js`, the v3 schema, and unbranded POSIX and
|
||||
Windows Hook examples. It ships no provider preset or provider dialect.
|
||||
|
||||
The administrator installs a pinned package version at a stable absolute path,
|
||||
creates the v3 instance and dialect files outside ordinary workspaces, injects
|
||||
the configuration path and credential through the managed process environment,
|
||||
and copies the applicable Hook definition into an administrator-controlled
|
||||
`QWEN_HOME/settings.json`.
|
||||
|
||||
This registration opts in every eligible input handled by that launcher,
|
||||
including headless and stream-json user turns. Automation can disable all Hooks
|
||||
with `--bare`, `--safe-mode`, or `disableAllHooks: true` in managed settings
|
||||
before startup. The two flags also change which customizations are loaded.
|
||||
When automation needs other Hooks, use a separate controlled `QWEN_HOME`
|
||||
without this Hook and omit its configuration and credential from the
|
||||
automation environment. A protocol-level TUI-only filter would require a
|
||||
separate Core/CLI provenance change and is outside this package-only design.
|
||||
|
||||
The auto-recall process must not enable the package's default Extension
|
||||
manifest or configure another on-demand Mem0 MCP server. Otherwise one turn
|
||||
could produce both a deterministic Hook request and a model-selected MCP
|
||||
request. A separate pinned installation path is preferred for the Hook-only
|
||||
profile.
|
||||
|
||||
Rollback removes the Hook registration and credential from the managed
|
||||
launcher and restarts Qwen. It does not delete provider records or access logs.
|
||||
|
||||
## Verification
|
||||
|
||||
Unit tests cover strict v2/v3 parsing, canonical roots, containment, missing
|
||||
provenance, legacy-prompt isolation, input limits, credential patterns, Unicode
|
||||
bounds, one-request behavior, fail-open output, timeouts, and final context
|
||||
bounds. Package test commands build the shipped bundles before running tests.
|
||||
Local subprocess tests execute the Hook bundle with a fake provider and cover
|
||||
configuration, `DialectV1`, exact outbound requests, flushed Hook stdout, and
|
||||
successful process exit during a stalled TLS handshake and rejection of instance
|
||||
or dialect FIFOs. Repeated secret-keyword
|
||||
near misses are tested in bundle-importing subprocesses with a real deadline.
|
||||
|
||||
Package verification builds both entry points and inspects `npm pack --dry-run`
|
||||
to confirm that the tarball contains the runtime, schemas, manifest,
|
||||
documentation, and unbranded Hook examples, with no provider-specific data.
|
||||
The default manifest test continues to require exactly `context_search` and no
|
||||
Hook registration.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Administrator-configured Mem0 External Context Extension
|
||||
|
||||
**Status:** PR2 implementation
|
||||
**Status:** Implemented base integration; optional Auto Recall follow-up implemented
|
||||
|
||||
**Date:** 2026-08-28
|
||||
|
||||
@@ -23,12 +23,20 @@ absolute dialect path. The dialect file describes request and response
|
||||
differences within the existing closed `DialectV1` grammar. Its `id` is an
|
||||
administrator-managed audit label and has no registry or file-name semantics.
|
||||
|
||||
External Context MCP Profile v1 remains the only public Qwen interoperability
|
||||
boundary. Qwen Core does not gain a provider registry, public provider SDK,
|
||||
External Context MCP Profile v1 remains the on-demand interoperability
|
||||
boundary. The optional automatic mode uses Qwen's existing
|
||||
`UserPromptSubmit` Hook contract; it does not add a provider-specific Qwen
|
||||
interface. Qwen Core does not gain a provider registry, public provider SDK,
|
||||
dynamic module loading, or new third-party cases in its private
|
||||
`ProviderConfig` union. The existing direct integration remains available for
|
||||
compatibility and is not modified by this design.
|
||||
|
||||
The default Extension manifest remains MCP-only. Administrators may separately
|
||||
install the package's opt-in `UserPromptSubmit` command Hook to retrieve context
|
||||
before a prompt. That deployment mode is specified in
|
||||
[Opt-in Auto Recall for Administrator-configured Mem0](./external-context-mem0-auto-recall.md)
|
||||
and is intentionally not enabled by installing the Extension.
|
||||
|
||||
## Goals
|
||||
|
||||
- Let administrators connect retrieval-only compatible services without a
|
||||
@@ -37,6 +45,8 @@ compatibility and is not modified by this design.
|
||||
endpoint, credential, scope, timeout, and dialect remain administrator-owned.
|
||||
- Preserve the bounded request engine, response normalization, and failure
|
||||
behavior already implemented by the Extension.
|
||||
- Preserve zero automatic retrieval for the default Extension installation
|
||||
while allowing an administrator-owned Hook profile to opt in explicitly.
|
||||
- Give protocols outside the closed grammar a clear path to a separate local
|
||||
or remote MCP Extension.
|
||||
|
||||
@@ -45,10 +55,11 @@ compatibility and is not modified by this design.
|
||||
- Publish provider presets, provider-specific contract fixtures, or live
|
||||
service credentials.
|
||||
- Add ordinary Qwen settings or Extension settings for the configuration path.
|
||||
- Define arbitrary request templates, JSONPath, scripting, custom headers, or
|
||||
executable hooks.
|
||||
- Define arbitrary request templates, JSONPath, scripting, or custom headers.
|
||||
- Probe or fall back between upstream protocol versions.
|
||||
- Add memory creation, update, deletion, Auto Recall, redirects, or retries.
|
||||
- Enable Auto Recall by default or combine the on-demand MCP and automatic Hook
|
||||
surfaces in one installation profile.
|
||||
- Add memory creation, update, deletion, redirects, or retries.
|
||||
- Migrate or remove the existing direct External Context integration.
|
||||
|
||||
## Architecture
|
||||
@@ -61,12 +72,15 @@ flowchart LR
|
||||
D["Administrator-owned DialectV1"] --> E
|
||||
E --> R["Bounded request engine"]
|
||||
R --> S["Compatible HTTP service"]
|
||||
H["Optional administrator-owned UserPromptSubmit Hook"] --> R
|
||||
A["Administrator-owned InstanceConfigV3"] --> H
|
||||
```
|
||||
|
||||
The local Extension owns configuration loading and HTTP translation. Qwen sees
|
||||
only the MCP profile. A service that cannot fit the bounded dialect grammar
|
||||
owns a separate MCP implementation instead of expanding the grammar or Qwen
|
||||
Core.
|
||||
The local package owns configuration loading and HTTP translation. On-demand
|
||||
retrieval reaches it through the MCP profile; the optional automatic mode
|
||||
reaches it through the existing command-Hook contract. A service that cannot
|
||||
fit the bounded dialect grammar owns a separate MCP implementation instead of
|
||||
expanding the grammar or Qwen Core.
|
||||
|
||||
## Version model
|
||||
|
||||
@@ -74,8 +88,9 @@ Four independent version axes remain explicit:
|
||||
|
||||
1. **MCP Profile version** defines the Qwen-to-Extension tool contract. This
|
||||
Extension implements External Context MCP Profile v1.
|
||||
2. **Instance schema version** defines administrator binding. This design uses
|
||||
`schemaVersion: 2`.
|
||||
2. **Instance schema version** defines administrator binding. The MCP runtime
|
||||
uses `schemaVersion: 2`; the separate Auto Recall Hook profile uses
|
||||
`schemaVersion: 3` so each entry point rejects the other's configuration.
|
||||
3. **Dialect version** defines interpretation of the closed request and
|
||||
response grammar. This design keeps `dialectVersion: 1` unchanged.
|
||||
4. **Upstream API version** belongs to the service and appears only in the
|
||||
@@ -175,9 +190,9 @@ The grammar stays deliberately closed:
|
||||
- `threshold` and `rerank` are typed fields, not request fragments.
|
||||
|
||||
The dialect cannot define arbitrary headers, body interpolation, JSONPath,
|
||||
code, environment-variable expansion, redirects, or response transformations.
|
||||
Its `id` does not have to match its file name or any value in the instance
|
||||
file. Administrators own its naming and versioning policy.
|
||||
code, environment-variable expansion, redirects, response transformations, or
|
||||
trigger behavior. Its `id` does not have to match its file name or any value in
|
||||
the instance file. Administrators own its naming and versioning policy.
|
||||
|
||||
## Startup and failure behavior
|
||||
|
||||
@@ -217,16 +232,20 @@ query, credential, or upstream response.
|
||||
|
||||
## Retrieval-only boundary
|
||||
|
||||
The manifest exposes exactly `context_search`. A dialect cannot enable memory
|
||||
creation, update, deletion, or Auto Recall. Write protocols require a separate
|
||||
future profile or Extension because their idempotency, duplication, timeout,
|
||||
and authorization semantics do not fit the retrieval grammar.
|
||||
The default manifest exposes exactly `context_search` and contains no Hooks. A
|
||||
dialect cannot enable memory creation, update, deletion, or Auto Recall. The
|
||||
optional administrator-installed Hook changes only when the same bounded
|
||||
retrieval runs; it does not add write operations or expand the dialect grammar.
|
||||
Write protocols require a separate future profile or Extension because their
|
||||
idempotency, duplication, timeout, and authorization semantics do not fit the
|
||||
retrieval grammar.
|
||||
|
||||
## Packaging and service ownership
|
||||
|
||||
The npm package publishes only the bundled runtime, canonical schemas,
|
||||
Extension manifest, and README. It contains no administrator dialect, provider
|
||||
preset, provider identifier, or provider-specific contract fixture.
|
||||
The npm package publishes only the bundled MCP and Auto Recall entry points,
|
||||
canonical schemas, Extension manifest, unbranded Hook configuration examples,
|
||||
and README. It contains no administrator dialect, provider preset, provider
|
||||
identifier, or provider-specific contract fixture.
|
||||
|
||||
The public package name is `@qwen-code/external-context-mem0`. Its package and
|
||||
Extension manifest versions follow the Qwen Code release version and are
|
||||
@@ -265,16 +284,20 @@ built-in provider rollout for either case.
|
||||
and profile boundary.
|
||||
4. **Distribution follow-up:** Publish the self-contained Extension through the
|
||||
normal Qwen Code npm release without adding provider data or Core wiring.
|
||||
5. Design any portable write capability separately.
|
||||
5. **Auto Recall follow-up:** Add a separately installed, administrator-owned
|
||||
Hook profile while leaving the default manifest MCP-only.
|
||||
6. Design any portable write capability separately.
|
||||
|
||||
There is no Qwen-maintained provider-preset PR3. Administrators own compatible
|
||||
dialect data; incompatible protocols use their own MCP Extension.
|
||||
|
||||
## Verification
|
||||
|
||||
Verification covers both canonical schemas; 64 KiB file limits; unavailable,
|
||||
Verification covers all canonical schemas; 64 KiB file limits; unavailable,
|
||||
malformed, unsupported, relative, and semantically invalid configurations;
|
||||
credential ordering; synthetic GET and POST request contracts; response
|
||||
normalization; the MCP tool surface; real stdio MCP startup against a local
|
||||
synthetic HTTP service; restart-only reload behavior; package contents; build,
|
||||
typecheck, lint, and tests. No verification contacts a live provider service.
|
||||
normalization; the MCP tool surface; Hook provenance, repository containment,
|
||||
sanitization, fail-open behavior, and wall-clock bounds; real stdio MCP and Hook
|
||||
startup against local synthetic HTTP services; reload behavior; package
|
||||
contents; build, typecheck, lint, and tests. No verification contacts a live
|
||||
provider service.
|
||||
@@ -133,7 +133,7 @@ The entry-point behavior is fixed:
|
||||
> project navigation rather than a global entry point, so it inherits the
|
||||
> current explicit context: a workspace chat stays in its workspace and a cold
|
||||
> draft lands on the primary workspace. Standalone creation is an option of the
|
||||
> composer's workspace picker ("No workspace (standalone)"), offered while the
|
||||
> composer's workspace picker ("No workspace"), offered while the
|
||||
> daemon advertises the capability, no workspace is locked and no projectless
|
||||
> session is attached; the picker stays enabled for projectless drafts so the
|
||||
> target can be changed before the first prompt. The workspace navigation tree
|
||||
@@ -141,11 +141,14 @@ The entry-point behavior is fixed:
|
||||
> to the chat surface and to project-only controls, and dropping the tree left
|
||||
> a standalone chat with no way back to a workspace.
|
||||
|
||||
Standalone sessions appear in a top-level **Recents** group separate from Live
|
||||
and project groups. Their chat surface hides workspace selection, Git status,
|
||||
branch and worktree controls, project files, project settings, pin/group
|
||||
controls, and attachments/uploads. Normal model, approval, tool, permission,
|
||||
transcript, and supported session metadata controls remain available.
|
||||
Standalone sessions appear under **No workspace sessions** inside the Projects
|
||||
tree, with archived entries in the shared Archived section. Their chat surface
|
||||
hides workspace selection, Git status, branch and worktree controls, project
|
||||
files, and pin/group controls. Project navigation and management stay available
|
||||
against the registered primary workspace. Attachments are available only when
|
||||
the standalone session advertises `session_attachments`. Normal model,
|
||||
approval, tool, permission, transcript, and supported session metadata controls
|
||||
remain available.
|
||||
Approval-mode changes are session-local: the generic `persist: true` form is
|
||||
rejected because it would write the shared Conversations root as a workspace
|
||||
setting and affect unrelated standalone and Live sessions. User-global settings
|
||||
@@ -190,7 +193,8 @@ reclassified.
|
||||
|
||||
`create_sub_session` invoked by a standalone session explicitly persists
|
||||
`sourceType: "standalone"` together with `parentSessionId`. Children remain
|
||||
loadable by identity but are excluded from top-level Recents. Parent and child
|
||||
loadable by identity but are excluded from the No workspace sessions
|
||||
group. Parent and child
|
||||
archive or deletion operations do not cascade; each transcript and private
|
||||
directory has an independent lifecycle.
|
||||
|
||||
@@ -1300,14 +1304,16 @@ Suggested title: `feat(web-shell): Add standalone chats`
|
||||
failure preserves standalone intent and displays the error.
|
||||
- Store explicit pending context for deferred creation; undefined cwd is never
|
||||
standalone semantics.
|
||||
- Add top-level Recents with rename, export, archive, unarchive, and delete.
|
||||
- Hide project-only selectors, browsers, controls, settings, and uploads.
|
||||
- Add No workspace sessions under Projects with rename, export, archive,
|
||||
unarchive, and delete.
|
||||
- Hide session-bound project selectors, browsers, and controls; keep registered
|
||||
project management available and capability-gate standalone attachments.
|
||||
- Resolve deep links only after standalone/Live/workspace catalogs are ready and
|
||||
use exact lookup; never guess primary.
|
||||
- Surface directory recovery/compromise, outcome-unknown, and deferred-cleanup
|
||||
state.
|
||||
- Retain second delete confirmation and remove the session from Recents once the
|
||||
transcript is deleted, even if cleanup is pending.
|
||||
- Retain second delete confirmation and remove the session from No workspace
|
||||
sessions once the transcript is deleted, even if cleanup is pending.
|
||||
|
||||
Verification covers every entry point, old/capable daemons, capable failure,
|
||||
deferred creation, deep links and restart, context switching, directory states,
|
||||
@@ -1317,7 +1323,7 @@ coexistence, and platform differences.
|
||||
Estimated size: 450-800 production lines and 800-1,400 test lines.
|
||||
|
||||
Exit criterion: the end-to-end product matches this contract and keeps
|
||||
project-only controls and uploads out of standalone chats.
|
||||
session-bound project controls out of standalone chats.
|
||||
|
||||
### Dependencies and merge order
|
||||
|
||||
@@ -1360,8 +1366,8 @@ the daemon contract.
|
||||
downgrade to primary.
|
||||
- Workspace selectors and project controls never display or target the internal
|
||||
Conversations runtime.
|
||||
- Attachments/uploads and other project-only controls are unavailable in the
|
||||
standalone MVP.
|
||||
- Attachments/uploads require the standalone session attachment capability;
|
||||
session-bound project controls remain unavailable.
|
||||
|
||||
### Runtime and source
|
||||
|
||||
@@ -1404,7 +1410,7 @@ the daemon contract.
|
||||
- Explicit standalone, compatible legacy, Live, unrelated source, top-level, and
|
||||
child classification are covered.
|
||||
- Standalone children persist source, remain independently loadable, and stay
|
||||
out of top-level Recents.
|
||||
out of the No workspace sessions group.
|
||||
- Standalone cannot load or create durable cron tasks from the Conversations
|
||||
root.
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Configurable standalone update downloads
|
||||
|
||||
Issue: #11149
|
||||
|
||||
## Problem and current behavior
|
||||
|
||||
The standalone updater uses fixed release download sources. The installer has its own base URL option, but it selects a final version directory; reusing that variable in the updater would introduce different path semantics. All standalone update entrypoints share performStandaloneUpdate, including qwen update, /update, background updates, and update-and-relaunch.
|
||||
|
||||
## Proposed behavior
|
||||
|
||||
Introduce QWEN_UPDATE_BASE_URL as an optional absolute HTTPS release root. The updater appends the normalized v-prefixed version and platform archive name. The same root supplies SHA256SUMS and its optional signature. For example, https://downloads.example.com/qwen-code resolves version 0.23.0 beneath /qwen-code/v0.23.0/.
|
||||
|
||||
Resolve and validate the value once per update before creating directories or a lock. Treat an empty or whitespace-only value as unset and normalize trailing slashes. Reject non-HTTPS URLs, credentials, query strings, and fragments without echoing the rejected value.
|
||||
|
||||
With no override, preserve existing source order and fallback behavior. With an override, use only the configured source and report download failure without trying the built-in sources. Keep TLS verification, checksum checking, signature policy, extraction checks, smoke testing, rollback, and cleanup unchanged.
|
||||
|
||||
## Configuration trust
|
||||
|
||||
The update source controls executable downloads. Add the variable to the existing project-environment hardcoded exclusions. The launching shell and initial user-level dotenv loading may supply it; project dotenv files and the top-level settings.env section at every scope must not set or override it. User-level dotenv changes require a restart. Reuse the shared filter so initial loading, reload, and explicit runtime environments agree.
|
||||
|
||||
## Affected areas
|
||||
|
||||
- Shared standalone updater: one-time source resolution and download propagation.
|
||||
- Existing environment exclusion list: protect the new variable from project configuration.
|
||||
- Collocated updater and environment tests: custom/default source behavior, failures, integrity, and trust boundaries.
|
||||
- User configuration documentation: variable syntax, path rules, fallback, trusted scopes, and separate npm registry lookup.
|
||||
|
||||
## Validation
|
||||
|
||||
Use temporary standalone fixtures, controlled HTTPS download responses, and isolated CLI configuration. Check the global CLI baseline without modifying its installation, then exercise the built local CLI and shared downloader. Cover full archive/verification URL selection, absent optional signatures, required-signature/checksum failures, invalid configuration before mutation, no fallback for a custom source, existing defaults, and environment filtering. No model call or live deployment is required.
|
||||
|
||||
## Scope and open decisions
|
||||
|
||||
This does not change installer semantics, npm version discovery, authentication, update scheduling, or verification policy. The configuration name and custom-source fallback choice remain reviewable proposals for maintainers; this PR chooses a distinct updater variable and no implicit fallback when it is set.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Web Shell artifact icons
|
||||
|
||||
Artifact cards and artifact panel tabs use one shared icon renderer. Built-in
|
||||
SVGs are selected from the artifact file extension first, then its MIME type or
|
||||
kind. Existing Lucide icons cover artifact kinds without bundled artwork, with
|
||||
the generic file icon as the final fallback.
|
||||
|
||||
Hosts can provide `artifact.renderImage`. It receives the complete artifact
|
||||
record and may return a replacement React node; a nullish or false result keeps
|
||||
the built-in icon. The artifact namespace leaves room for title and description
|
||||
renderers without adding them before they are needed.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Web Shell command explanation
|
||||
|
||||
Shell approval cards expose an **Explain** action when the connected session
|
||||
supports session generation. Opening it sends the displayed command to the
|
||||
existing session-generation endpoint and renders the streamed explanation in
|
||||
the same popover pattern used for thinking translations.
|
||||
|
||||
The action is limited to executable permission requests with an extracted
|
||||
command. Other approvals and sessions without generation support remain
|
||||
unchanged.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Web Shell sidebar session scope
|
||||
|
||||
## Goal
|
||||
|
||||
Keep the sidebar daemon-scoped while the composer selects the target for the
|
||||
next session. Present standalone sessions as the "No workspace" peer of
|
||||
registered workspaces, and preserve that ownership in the archive.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Selecting "No workspace" changes only the pending creation context. A
|
||||
standalone URL is written only after a standalone session has an id.
|
||||
- The project header owns Search, Add workspace, and Manage workspaces.
|
||||
- Active standalone sessions render under "No workspace sessions" in Projects.
|
||||
- Archived sessions render under workspace headings, including "No workspace".
|
||||
- A locked workspace hides every standalone surface and global workspace
|
||||
management action.
|
||||
- Live conversations are hidden for embedded hosts by default; the standalone
|
||||
development entry point opts in with `showLive: true`.
|
||||
- Standalone attachments use the session's private runtime directory rather
|
||||
than a registered workspace.
|
||||
- Project Git actions, Session Overview, Split View, and other sidebar project
|
||||
navigation remain available while the composer targets a standalone session.
|
||||
Session-local workspace controls in the chat surface remain unavailable.
|
||||
|
||||
## Implementation
|
||||
|
||||
Reuse `pendingSessionContext` for composer intent and keep sidebar availability
|
||||
based on workspace capabilities. Split `StandaloneRecents` presentation into
|
||||
active and archived slots. A shared refresh key invalidates both slots after a
|
||||
mutation; refreshing reloads the already-visible pagination depth so removed
|
||||
rows cannot survive as stale client state. An interrupted pagination request
|
||||
releases its loading state when the refresh starts.
|
||||
@@ -176,6 +176,7 @@ interface WebShellSidebarOptions {
|
||||
defaultCollapsed?: boolean; // initial collapsed state (persisted in localStorage)
|
||||
showCompactToggle?: boolean; // show the collapse button in the chat area (default: true)
|
||||
showSessionSourceSwitch?: boolean; // show the Tasks/Channels switch (default: true)
|
||||
showLive?: boolean; // show daemon-owned Live conversations (default: false)
|
||||
branding?: false | WebShellSidebarBranding;
|
||||
primaryNav?: WebShellSidebarPrimaryNavOptions;
|
||||
hideProjectHeader?: boolean; // hide "Projects" header row (default: false = shown)
|
||||
@@ -199,6 +200,20 @@ This removes the Tasks/Channels switch and fixes every active, archived, primary
|
||||
and secondary session query to `sourceType: "default"`. Omitting the option keeps
|
||||
the current switch and channel-session access unchanged.
|
||||
|
||||
### Live conversations — `showLive`
|
||||
|
||||
Live conversations are hidden from embedded hosts by default. Opt in when the
|
||||
host should expose the daemon-owned Live group:
|
||||
|
||||
Previous releases displayed this group without an explicit option, so hosts
|
||||
that rely on it must set `showLive: true` when upgrading.
|
||||
|
||||
```tsx
|
||||
sidebar={{
|
||||
showLive: true,
|
||||
}}
|
||||
```
|
||||
|
||||
### ③ Project Header — `hideProjectHeader`
|
||||
|
||||
Controls visibility of the "Projects" header row (the row with the collapse
|
||||
@@ -320,11 +335,11 @@ desktop collapse preference.
|
||||
|
||||
## Source locations
|
||||
|
||||
| Component | File |
|
||||
| ------------------- | ------------------------------------------------------------------------- |
|
||||
| WebShellSidebar | `packages/web-shell/client/components/sidebar/WebShellSidebar.tsx` |
|
||||
| SessionGroupSection | `packages/web-shell/client/components/sidebar/SessionGroupSection.tsx` |
|
||||
| WorkspaceSection | `packages/web-shell/client/components/sidebar/WorkspaceSection.tsx` |
|
||||
| Sidebar styles | `packages/web-shell/client/components/sidebar/WebShellSidebar.module.css` |
|
||||
| App integration | `packages/web-shell/client/App.tsx` (search `WebShellSidebar`) |
|
||||
| Entry point (dev) | `packages/web-shell/client/main.tsx` (`sidebar: true`) |
|
||||
| Component | File |
|
||||
| ------------------- | ----------------------------------------------------------------------------------- |
|
||||
| WebShellSidebar | `packages/web-shell/client/components/sidebar/WebShellSidebar.tsx` |
|
||||
| SessionGroupSection | `packages/web-shell/client/components/sidebar/SessionGroupSection.tsx` |
|
||||
| WorkspaceSection | `packages/web-shell/client/components/sidebar/WorkspaceSection.tsx` |
|
||||
| Sidebar styles | `packages/web-shell/client/components/sidebar/WebShellSidebar.module.css` |
|
||||
| App integration | `packages/web-shell/client/App.tsx` (search `WebShellSidebar`) |
|
||||
| Entry point (dev) | `packages/web-shell/client/main.tsx` (`sidebar: { enabled: true, showLive: true }`) |
|
||||
@@ -126,7 +126,7 @@ Use these anchors when moving from the docs into the latest `main` code:
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| HTTP routes | The route catalog lives in `qwen-serve-protocol.md`; this daemon set only references it and explains implementation ownership. | [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md), [`20`](./20-quickstart-operations.md) |
|
||||
| Event schema | `EVENT_SCHEMA_VERSION = 1`; 53 known event types; id-less subscriber synthetic frames; `_meta.serverTimestamp` stamped by `EventBus.publish()` (with `formatSseFrame()` fallback for synthetic frames). | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) |
|
||||
| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 157 registered tags; 45 conditional tags. | [`11`](./11-capabilities-versioning.md) |
|
||||
| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 158 registered tags; 45 conditional tags. | [`11`](./11-capabilities-versioning.md) |
|
||||
| Session shell | `POST /session/:id/shell` exists behind `--enable-session-shell`, bearer or trusted-loopback authority, and session-bound `X-Qwen-Client-Id`; capability tag is conditional. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md), [`20`](./20-quickstart-operations.md) |
|
||||
| Rate limiting | Optional per-tier HTTP rate limit is exposed by CLI flags/env and conditional capability tag. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md) |
|
||||
|
||||
|
||||
@@ -110,7 +110,9 @@ Both:
|
||||
1. Use a per-session `pendingRestoreIds` set on the channel so concurrent restore calls coalesce (`RestoreInProgressError`).
|
||||
2. Cache `restoreState` on the entry so a late attacher gets the same payload the original restorer did.
|
||||
|
||||
For a persisted Part 4A worktree session, restore is an integrity-gated extension of this lifecycle. The sidecar identifies the requested workspace root explicitly, the checkout must be canonically contained below the corresponding `.qwen/worktrees/` directory, and its marker must be a single-link regular file containing the exact restored session ID. The daemon relocates an idle restored child only after those checks; an active child is accepted only when its reported cwd already equals the worktree. Those responses return canonical worktree metadata with `worktreeState: "persisted-v1"`. A cold restore that reports an active prompt without a current cwd returns unverified worktree metadata without that attestation, so isolation-aware clients reject it and may retry after the prompt settles. Invalid Part 4A state detaches an existing attachment or kills a cold restore with `requireZeroAttaches`; a missing sidecar likewise yields no attestation. Whenever the effective restore source is Channel-owned, the route suppresses the ACP agent's best-effort cleanup for Part 4A or unclassifiable sidecar state, so validation failure preserves the uncertain checkout evidence. Persisted source metadata takes precedence; when it is absent, the load/resume request supplies the effective source. A structurally valid legacy sidecar without `workspaceCwd` instead retains the existing best-effort agent restore: it must identify either the requested workspace root or its Git repository top-level, is containment-checked without marker attestation, may be cleaned up by the agent, and may return `worktree` without `worktreeState`. Apart from that explicit legacy compatibility case, only sessions whose effective restore source is not Channel-owned retain the existing best-effort cleanup before route validation.
|
||||
For a persisted Part 4A worktree session, restore is an integrity-gated extension of this lifecycle. The sidecar identifies the requested workspace root explicitly, the checkout must be canonically contained below the corresponding `.qwen/worktrees/` directory, and its marker must be a single-link regular file containing the exact restored session ID. The daemon relocates an idle restored child only after those checks; an active child is accepted only when its reported cwd already equals the worktree, and an active child whose reported cwd is absent or elsewhere fails closed instead of being relocated under its prompt — except for a cold restore that could not defer its restore-prompt (`suppressWorktreeContextRestore` off, so the bridge fired the re-hung question instead of parking it): that shape keeps the pre-4B outcome, returning canonical worktree metadata without `worktreeState`, unrelocated, with the session surviving. The relocated and accepted responses return canonical worktree metadata with `worktreeState: "persisted-v1"`. A sidecar carrying `supersededBy` never restores: the route returns `409 worktree_session_superseded` with the replacement session id, a classification decided from that link alone before any marker read, so callers redirect and heal their bookkeeping only once a load of that id has succeeded — a pre-commit interrupted transfer names a replacement that is not the marker owner, cannot itself be restored, and is reaped by the retried reset; a restored replacement whose `supersedes` link agrees with the old sidecar while the marker never moved — or is absent — returns `409 worktree_reset_interrupted`, whose repair is retrying the reset against the superseded session; and a missing marker without that agreeing link pair returns `409 worktree_marker_missing`, whose repair is resetting the task rather than retrying the restore, because no restore path recreates a marker. The interrupted classification is checked first. Invalid Part 4A state detaches an existing attachment or kills a cold restore with `requireZeroAttaches`; a missing sidecar likewise yields no attestation. Whenever the effective restore source is Channel-owned, the route suppresses the ACP agent's best-effort cleanup for Part 4A or unclassifiable sidecar state, so validation failure preserves the uncertain checkout evidence. Persisted source metadata takes precedence; when it is absent, the load/resume request supplies the effective source. A structurally valid legacy sidecar without `workspaceCwd` instead retains the existing best-effort agent restore: it must identify either the requested workspace root or its Git repository top-level, is containment-checked without marker attestation, may be cleaned up by the agent, and may return `worktree` without `worktreeState`. Apart from that explicit legacy compatibility case, only sessions whose effective restore source is not Channel-owned retain the existing best-effort cleanup before route validation.
|
||||
|
||||
Worktree ownership transfer (`POST /session/:id/worktree-reset`, advertised by `session_worktree_reset_v1`) extends this lifecycle for Channel task resets: the daemon spawns a fresh thread-scoped replacement in the root workspace, relocates it into the verified checkout, links the sidecar pair (`supersededBy` on the old session first, then `supersedes` on the replacement), flips the marker to the replacement under a per-checkout route lock and an admission barrier that fences prompt admission plus the seven other writers that start work in the checkout or move the session cwd (rewind, cwd change, branch, fork, shell, goal control, workflow-task action), while the release and stop paths stay unfenced by design, and then severs the superseded session's client registrations and in-memory worktree association. The sever reports whether the superseded session is actually gone: a survivor whose child still holds background work keeps the barrier armed, is logged, and is reported to the caller as `supersededSessionLive: true` rather than being papered over by the success response. A fenced writer admitted on the superseded session mid-transfer is refused with `409 worktree_reset_active`; the full failure taxonomy, including which crash windows a retry rolls back and which fail closed for operator repair, is documented with the route in `qwen-serve-protocol.md`.
|
||||
|
||||
### Heartbeat
|
||||
|
||||
|
||||
@@ -636,7 +636,7 @@ Pass `?deep=1` (also accepts `?deep=true` or bare `?deep`) for a daemon-wide pro
|
||||
}
|
||||
```
|
||||
|
||||
`sessions`, `pendingPermissions`, and `activePrompts` are sums. `activeWork` is true when any runtime has an accepted but unsettled prompt (including a FIFO-waiting prompt), a running background Agent, a queued/in-progress Agent terminal notification, or Session-managed background shell work. Shell work remains active while the shell registry reports a running entry and while its terminal notification is queued or driving the parent continuation; any number of shells contributes one bounded aggregate hold. Monitors, workflows, cron jobs, follow-up suggestions, and external processes the shell registry can no longer track remain outside the field. It is session-scoped: channel-level work with no session attached yet — a spawn in flight, a pending restore, MCP discovery or authentication — is not counted, so `activeWork` may read false while the daemon still declines to reclaim that channel. Do not read this field as "the daemon is reclaimable"; it describes session-owned work only. `activeWorkReporting` says how much of that boolean is actually vouched for: `full` when every live session is covered by a fresh report from a child that reports all required categories, `none` when no session negotiated reporting, and `partial` for anything between — including a stale snapshot or a negotiated child that omits a required category. A snapshot older than three report intervals stops counting as coverage: it is not a report that the session is idle, so the session goes back to reading as retained, exactly as if the child had never reported. Ordinary automatic cleanup is also disabled for a negotiated-but-incomplete child; a child that does not understand `shell` cannot safely authorize conditional close according to the complete current predicate. Completely unsupported historical children retain legacy cleanup behavior, and explicit close, kill, shutdown, and channel exit remain force operations. `activeWorkStaleMs` is the age of the oldest snapshot the boolean rests on **among the covered sessions**, and is `0` when no session is covered; it is diagnostic, because freshness is already graded into `activeWorkReporting` by the daemon (only the daemon knows each channel's negotiated cadence). The grade is computed once over every managed runtime rather than per runtime and then combined — a runtime with no sessions is vacuously complete, and treating that as evidence would let an empty workspace vouch for another workspace's unreported sessions. `lastActivityAt` is the latest non-null workspace activity time and `idleSinceMs` is derived from that same snapshot. `channelAlive` means at least one managed workspace channel is live; it does not mean every workspace is healthy. `connectedClients` and the optional `rateLimitHits` remain daemon-wide counters rather than per-workspace sums.
|
||||
`sessions`, `pendingPermissions`, and `activePrompts` are sums. `activeWork` is true when any runtime has an accepted but unsettled prompt (including a FIFO-waiting prompt), a running background Agent, a queued/in-progress Agent terminal notification, Session-managed background shell or workflow work, or a child-owned Session turn. The aggregate `session` hold covers goal and cron processing, history mutation, and queued or running Monitor continuations; foreground prompts remain daemon-owned. Running Monitors, follow-up suggestions, and external processes the shell registry can no longer track remain outside the field. It is session-scoped: channel-level work with no session attached yet — a spawn in flight, a pending restore, MCP discovery or authentication — is not counted, so `activeWork` may read false while the daemon still declines to reclaim that channel. Do not read this field as "the daemon is reclaimable"; it describes session-owned work only. `activeWorkReporting` says how much of that boolean is actually vouched for: `full` when every live session is covered by a fresh report from a child that reports all required categories, `none` when no session negotiated reporting, and `partial` for anything between — including a stale snapshot or a negotiated child that omits a required category. A snapshot older than three report intervals stops counting as coverage: it is not a report that the session is idle, so the session goes back to reading as retained, exactly as if the child had never reported. Ordinary automatic cleanup is also disabled for a negotiated-but-incomplete child; a child that does not understand `shell` or `session` cannot safely authorize conditional close according to the complete current predicate. Completely unsupported historical children retain legacy cleanup behavior, and explicit close, kill, shutdown, and channel exit remain force operations. `activeWorkStaleMs` is the age of the oldest snapshot the boolean rests on **among the covered sessions**, and is `0` when no session is covered; it is diagnostic, because freshness is already graded into `activeWorkReporting` by the daemon (only the daemon knows each channel's negotiated cadence). The grade is computed once over every managed runtime rather than per runtime and then combined — a runtime with no sessions is vacuously complete, and treating that as evidence would let an empty workspace vouch for another workspace's unreported sessions. `lastActivityAt` is the latest non-null workspace activity time and `idleSinceMs` is derived from that same snapshot. `channelAlive` means at least one managed workspace channel is live; it does not mean every workspace is healthy. `connectedClients` and the optional `rateLimitHits` remain daemon-wide counters rather than per-workspace sums.
|
||||
|
||||
Restart controllers should treat the daemon as busy when:
|
||||
|
||||
@@ -1133,7 +1133,9 @@ Stable contract: when `v` increments the frame layout has changed in a backwards
|
||||
|
||||
> **`workspaces[]`** lists every registered runtime. Newer single-workspace daemons include the primary runtime even when `multi_workspace_sessions` is absent so clients can discover the stable id required by workspace-qualified routes; older daemons may omit the array. Each entry is `{ id, cwd, displayName?, primary, trusted, removable? }`. `displayName` is presentation-only and omitted when unset. The first/primary workspace remains mirrored by `workspaceCwd`; new clients choose a non-primary runtime by passing that entry's `cwd` to `POST /session`. Untrusted workspaces are advertised for diagnostics but reject fresh session creation with `403 untrusted_workspace` until trust changes. `removable` is present on daemons that support runtime removal and is true only for process-dynamic or persistence-restored secondary runtimes.
|
||||
|
||||
> **`session_worktree_persistence_v1`** means the daemon can persist and verify Part 4A worktree ownership. Successful worktree creation responses, and restore responses whose child is either relocated while idle or already reports the verified worktree cwd, carry `worktree` metadata plus `worktreeState: "persisted-v1"`. A legacy best-effort restore or a cold active-prompt restore with no reported current cwd may return `worktree` without that attestation. Clients requesting isolation must pre-flight this tag and verify each response; the `worktree` object alone is not durable-ownership proof.
|
||||
> **`session_worktree_persistence_v1`** means the daemon can persist and verify Part 4A worktree ownership. Successful worktree creation responses, and restore responses whose child is either relocated while idle or already reports the verified worktree cwd, carry `worktree` metadata plus `worktreeState: "persisted-v1"`. A legacy best-effort restore, or a cold restore whose restore-prompt was fired rather than parked (`suppressWorktreeContextRestore` off, so the route asked the bridge for no deferral) and which therefore reports an active prompt with no current cwd, may still return `worktree` without that attestation. Clients requesting isolation must pre-flight this tag and verify each response; the `worktree` object alone is not durable-ownership proof.
|
||||
|
||||
> **`session_worktree_reset_v1`** means the daemon supports worktree ownership transfer: `POST /session/:id/worktree-reset` moves a persisted worktree session's checkout ownership to a fresh replacement session. Restore responses gain three typed 409 classifications alongside it: `worktree_session_superseded` (the session's sidecar carries a `supersededBy` link — the classification is decided from that link alone, before any marker read, and the link is written before the marker flips, so the body's `replacementSessionId` is a redirect to verify rather than proof of ownership: in a pre-commit interrupted state it names a session that is not the marker owner, cannot itself be restored, and is reaped by the retried reset), `worktree_marker_missing` (the checkout marker is absent; reset the task to recreate it — a restore retry cannot, because no restore path writes a marker), and `worktree_reset_interrupted` (a previous transfer crashed mid-flight with its sidecar links agreeing; retry the reset). The interrupted classification is checked first: a missing marker whose `supersedes`/`supersededBy` links agree surfaces as `worktree_reset_interrupted`, never as `worktree_marker_missing`. See the route section below for the transfer protocol and failure taxonomy.
|
||||
|
||||
The workspace feature tags and `workspaces[]` are dynamic. Clients that add a workspace must fetch `/capabilities` again after the mutation completes; the daemon does not broadcast capability changes to clients that cached an earlier response. Forgetting persistence does not unload an active runtime, so that runtime remains advertised until restart.
|
||||
|
||||
@@ -2229,7 +2231,7 @@ Response:
|
||||
|
||||
`attached: true` means a session for that workspace already existed and you're now sharing it.
|
||||
|
||||
`worktreeState` is present only for an attested worktree session. Legacy best-effort restore may return `worktree` without `worktreeState`. A cold Part 4A restore that still reports an active prompt but no current cwd also returns unverified `worktree` metadata until the prompt settles. Neither response is durable execution-root proof. A client that requested worktree isolation must require `worktreeState: "persisted-v1"` and the expected canonical path before routing a prompt; an initial load may be retried after the active prompt settles. On SDK reattach, a missing attestation or changed path is terminal for that client: the new attachment is detached and the prompt is not retried.
|
||||
`worktreeState` is present only for an attested worktree session. Legacy best-effort restore may return `worktree` without `worktreeState`. A cold Part 4A restore whose restore-prompt was fired rather than parked (`suppressWorktreeContextRestore` off, so the route asked the bridge for no deferral) also returns unverified `worktree` metadata until the prompt settles: the child reads active with no reported cwd, relocation is impossible under a live prompt, and the route keeps the session rather than killing the one the caller just recovered. Neither response is durable execution-root proof. A client that requested worktree isolation must require `worktreeState: "persisted-v1"` and the expected canonical path before routing a prompt; an initial load may be retried after the active prompt settles. On SDK reattach the outcome splits on whether the response still carries `worktree` metadata: a response that carries it with a missing attestation or a changed path is terminal for that client — the new attachment is detached and the prompt is not retried — while a response with no `worktree` object at all heals: the client drops its cached worktree claim, keeps the new attachment, and retries the prompt in the same call. The heal does not gate that retry, so one turn can still execute in a directory the client can no longer attest (an `exit_worktree` the client never saw is the ordinary cause, and the client cannot distinguish it from a cleared in-memory association). An isolation-aware caller must therefore re-verify the worktree attestation itself before dispatching a prompt; the dropped claim only makes that caller fail closed at its own identity gate on its next load or selection.
|
||||
|
||||
Caller-supplied IDs are unique across all currently registered workspace runtimes and every still-live bridge generation, including draining replacements. A live, pending, active, archived, or worktree-backed duplicate returns `409 session_id_conflict`. Invalid values return `400 invalid_session_id`; an unavailable live-owner or persisted-state check returns retryable `503 session_id_admission_unavailable`. Retry with bounded backoff after bridge or storage health changes; `retryable` means another attempt is safe, not that an immediate retry will succeed. If the downstream agent returns a different ID, the daemon removes that orphan and returns `500 session_id_not_honored`. After an ambiguous response, load or resume the known ID instead of retrying create as an attach.
|
||||
|
||||
@@ -2312,7 +2314,7 @@ Response:
|
||||
|
||||
`attached: true` means the session was already live (either from a prior `session/load`/`session/resume`, or because a coalesced concurrent caller raced just ahead).
|
||||
|
||||
When the persisted session owns a Part 4A worktree, load/resume validates its sidecar, canonical containment, and exact checkout marker. An idle child is relocated before the response; an active child is attested only when its reported cwd already equals the worktree. Those responses return `worktree` plus `worktreeState: "persisted-v1"`. A cold restore that reports an active prompt without a current cwd instead returns unverified `worktree` metadata without `worktreeState`; isolation-aware clients reject that response and may retry after the prompt settles. Whenever the effective restore source is Channel-owned, the route suppresses the agent's best-effort cleanup for Part 4A or unclassifiable sidecar state, so stale, foreign, ambiguous, or ownership-mismatched persisted state is preserved and fails the request. Persisted source metadata takes precedence; when it is absent, the load/resume request supplies the effective source. A structurally valid legacy sidecar without the Part 4A `workspaceCwd` field retains the existing best-effort agent restore, may be cleaned up by that path, and may return `worktree` without `worktreeState`; clients must not infer isolation from that compatibility metadata. Apart from that explicit legacy compatibility case, only sessions whose effective restore source is not Channel-owned retain the existing best-effort cleanup before route validation. A missing sidecar returns no worktree attestation; isolation-aware clients must reject that response rather than rebinding the session to the shared workspace.
|
||||
When the persisted session owns a Part 4A worktree, load/resume validates its sidecar, canonical containment, and exact checkout marker. An idle child is relocated before the response; an active child is attested only when its reported cwd already equals the worktree, and an active child whose reported cwd is absent or elsewhere fails closed instead of being moved under its prompt — except for a cold restore that could not defer its restore-prompt (`suppressWorktreeContextRestore` off — any restore whose effective source is not Channel-owned — or a bridge without the deferral API): that shape still returns `worktree` without `worktreeState`, unrelocated, and the session survives. The relocated and attested responses return `worktree` plus `worktreeState: "persisted-v1"`. Restore-side typed 409 classifications (advertised by `session_worktree_reset_v1`): `worktree_session_superseded` when the session's sidecar carries a `supersededBy` link — the classification reads that link alone, under the per-checkout ownership lock and before any marker read, so a transfer that rolls back while the load waits cannot redirect the caller to a session the same daemon is deleting; the link is written before the marker flips, though, so the body's `replacementSessionId` is a redirect to verify rather than proof that ownership moved: after a committed transfer callers should load that id instead and update their bookkeeping, but in a pre-commit interrupted state it names a replacement that is not the marker owner, whose own restore returns `worktree_reset_interrupted`, and that the retried reset then reaps — so `replacementSessionId` must not be persisted as the task's session id until a load of it succeeds; `worktree_reset_interrupted` when the restored session's `supersedes` link and the named session's `supersededBy` link agree but the marker never moved or is absent — a previous reset crashed mid-transfer and a retried reset is the repair; and `worktree_marker_missing` when the checkout marker is absent without that agreeing link pair — resetting the task is the repair, because no restore path recreates a marker and a restore retry returns the same 409. The interrupted classification is checked first, so an agreeing link pair never surfaces as `worktree_marker_missing`. Whenever the effective restore source is Channel-owned, the route suppresses the agent's best-effort cleanup for Part 4A or unclassifiable sidecar state, so stale, foreign, ambiguous, or ownership-mismatched persisted state is preserved and fails the request. Persisted source metadata takes precedence; when it is absent, the load/resume request supplies the effective source. A structurally valid legacy sidecar without the Part 4A `workspaceCwd` field retains the existing best-effort agent restore, may be cleaned up by that path, and may return `worktree` without `worktreeState`; clients must not infer isolation from that compatibility metadata. Apart from that explicit legacy compatibility case, only sessions whose effective restore source is not Channel-owned retain the existing best-effort cleanup before route validation. A missing sidecar returns no worktree attestation; isolation-aware clients must reject that response rather than rebinding the session to the shared workspace.
|
||||
|
||||
**History replay over SSE.** While `loadSession` is in flight on the agent side, the agent may emit `session_update` notifications for persisted turns, or return bulk replay updates in the response metadata. The daemon seeds those events into the session's bounded replay snapshot window before the route response returns. For live sessions, `POST /session/:id/load` only promises that bounded window (`compactedReplay`, `liveJournal`, `lastEventId`), not the full transcript. The window is byte-capped by `--compacted-replay-max-bytes` (default 4 MiB, maximum 256 MiB); if older replay entries were dropped, `compactedReplay[0]` is an id-less `history_truncated` marker. The in-flight `liveJournal` is separately capped by `--max-journal-events` (default 10 000 replay entries) and `--max-journal-bytes` (default 8 MiB of serialized source events). These are per-session **baseline** caps. When an in-flight turn outgrows them, the daemon first tries adaptive growth: it raises that session's caps toward double (up to a per-session hard cap of 256 MiB, entries scaled proportionally, limited by the remaining pool headroom) while the growth granted across every live session fits in one daemon-wide growth pool sized at 5% of the daemon's effective memory budget — the `--memory-budget-mb` value when passed, capped at resolved available memory, otherwise 50% of auto-detected memory — capped at `1024` MB. Accounting is daemon-wide — a multi-workspace daemon runs one bridge per workspace and all of them share the single pool. Growth is on demand and only as far as the pool allows; an operator-pinned `--max-journal-events` or `--max-journal-bytes` disables it, as does a host whose effective budget falls below the 1024 MB minimum (`insufficientMemory`): the pool is 0 and adaptive growth is disabled outright. Consecutive compatible `agent_message_chunk` or `agent_thought_chunk` source events share a replay entry, up to 256 source events per entry, while tool, attribution, provenance, and discrete-message boundaries remain intact. When the journal still exceeds its (possibly grown) caps after the growth the pool allows — including when no headroom is granted or a grant covers only part of the overshoot — the oldest entries are dropped whole (so the retained tail can be much smaller than the byte cap) and a `history_truncated` marker with `scope: 'live_journal'` is prepended; its `truncatedEvents` and `retainedEvents` fields count source events, not replay entries, and its `maxBytes` / `maxEvents` reflect the caps in force (which may already have grown). Clients should render that marker as status and continue applying retained events. Full persisted transcript access is exposed separately through `GET /session/:id/transcript`.
|
||||
|
||||
@@ -2424,6 +2426,38 @@ Use `/load` when the client has no history rendered (cold reconnect, picker →
|
||||
|
||||
> ⚠️ **Why is `unstable_session_resume` still advertised?** The daemon's HTTP route and `session_resume` capability are stable for v1, but the bridge still calls ACP's `connection.unstable_resumeSession`. The old tag remains only so SDKs that shipped before `session_resume` can keep working.
|
||||
|
||||
### `POST /session/:id/worktree-reset`
|
||||
|
||||
Transfer a persisted Part 4A worktree session's checkout ownership to a fresh replacement session. Pre-flight `session_worktree_reset_v1` — older daemons return `404` for this route. The route is how Channel named-task `/clear` resets a worktree task without discarding its files: the replacement gets a brand-new conversation, and the superseded session keeps its transcript in the catalog but can never be restored again.
|
||||
|
||||
Request body (all fields optional):
|
||||
|
||||
```json
|
||||
{
|
||||
"cwd": "/canonical/path",
|
||||
"modelServiceId": "service-id",
|
||||
"approvalMode": "plan",
|
||||
"sourceType": "channel",
|
||||
"sourceId": "dingtalk-main"
|
||||
}
|
||||
```
|
||||
|
||||
`cwd` follows the same resolution as `POST /session/:id/load` and may be omitted for the daemon's primary workspace. The metadata fields stamp the replacement session with the same thread-scope and source conventions as a worktree creation.
|
||||
|
||||
On success the route returns `200` with the replacement session's create-shape response — the new `sessionId`, `workspaceCwd`, `currentCwd` equal to the verified canonical worktree path, `worktree` metadata, and `worktreeState: "persisted-v1"`. Callers discard their handle on the old id and use the replacement id from then on; the old id's later restore returns `worktree_session_superseded` with `replacementSessionId`, which clients may also use to self-heal stale bookkeeping — but only once a load of that id has succeeded. The classification is decided from the sidecar link alone, and that link is written before the marker flips, so an interrupted pre-commit transfer hands back a replacement that is not the marker owner, whose own restore returns `worktree_reset_interrupted`, and that the retried reset then reaps.
|
||||
|
||||
This route never reads `X-Qwen-Client-Id`, so the replacement spawns unattached — but the success response is not registration-free. A fresh transfer mints an owner-style `clientId` for that spawn, registers it on the replacement, and reports it in the body; an idempotent resume of a committed transfer reports none. The minted id is not an attachment (the replacement's attach count is unchanged, so `killSession` with `requireZeroAttaches` still sees it as unattached), but a live client registration is exactly what holds the daemon's idle cleanup off: detach an id you do not keep using, or the replacement stays live indefinitely — and a replacement that is still live with no marker is the shape a later reset refuses to dismantle. Treat the response as the replacement's identity, and attach it through the normal `POST /session/:id/load` or `/resume` surface when a registered client is required.
|
||||
|
||||
The transfer is serialized per checkout against worktree restores and other resets, refuses to run while either session is busy (prompt in flight or a pending interaction), and arms an admission barrier on the superseded session: while it is armed, exactly eight writers are refused with `worktree_reset_active` at admission — `POST /session/:id/prompt`, `/rewind`, `/cd`, `/branch`, `/fork`, `/shell`, `/goal`, and `/tasks/:taskId/workflow-action`. The other seven are fenced because each moves the session cwd or starts work in it without passing prompt admission: a shell command runs in the session's effective cwd, which for a relocated worktree session is the checkout itself; a fork agent runs its tools in that cwd; a rewind restores files relative to it; a cd moves it, including into a subdir of the checkout; a branch mutates the superseded session's persisted history and spawns a derivative session while ownership is in flux; a goal `resume` promotes a queued turn and queues a continuation; and a workflow action runs a saved workflow, or restarts a live run, through the session's own tool registry. `POST /session/:id/continue` is not a ninth gate: it drives an accepted continuation through the fenced prompt admission and is refused there. That list is the whole fence, and the fence is not everything that reaches the child — the release and stop paths stay usable mid-transfer by design (cancelling a task, clearing a goal, detaching a client, killing the session), and the daemon-internal background-notification enqueue — a sub-session's completion acknowledgement to its parent, which no HTTP route calls — is unfenced as well, because it enqueues a notification rather than starting a turn. The transfer's last step severs the superseded session's client registrations, clears its in-memory worktree association, and reports whether the superseded session is actually gone. A survivor — a child that still holds background work, so the idle close the last detach triggers is refused and deferred — is surfaced rather than papered over: the daemon logs it, the barrier stays armed on that entry as the only fence left, and the `200` body carries `supersededSessionLive: true` so the caller knows the superseded id is still live and re-attachable inside the checkout whose ownership just moved. Crash safety is per window, and the write order defines the windows: the old sidecar's `supersededBy` link is written first, then the replacement's sidecar carrying `supersedes`, and the marker flips last. A crash before the flip therefore leaves the old session authoritative in one of three shapes a retry tells apart — no links at all (the replacement is an ordinary orphan and a retry simply starts a fresh transfer), the backward link alone with the replacement's sidecar missing (a retry fails closed with `worktree_reset_invalid_state` and leaves the interrupted state untouched for operator repair), or both links present (a retry rolls the partial transfer back and completes a fresh one — unless the interrupted replacement cannot be removed because a client is still attached to it, in which case the rollback fails closed with `worktree_reset_invalid_state` and keeps both links, so a later retry converges once that client is gone). A crash after the flip leaves the replacement authoritative and a retry resumes as a no-op. The old session is never deleted and the worktree checkout is never removed by reset.
|
||||
|
||||
**Errors:**
|
||||
|
||||
- `404` — `session_not_found` when the session record does not exist.
|
||||
- `409` — `worktree_reset_unsupported` when the session has no valid Part 4A sidecar (not a worktree session).
|
||||
- `409` — `worktree_reset_active` when a session involved in the transfer is busy; retry once it settles. Admission of any writer the barrier covers — a prompt, rewind, cwd change, branch, fork, shell command, goal control, or workflow-task action — on a session mid-transfer fails with the same code.
|
||||
- `409` — `worktree_reset_invalid_state` for corrupt sidecar, invalid marker, containment failure, or an inconsistent supersede link pair. Non-destructive for what this request started: any partial transfer this request began is rolled back before the caller sees it, while the fail-closed resume branches — an invalid marker, a supersede link pair that does not agree (including a backward link whose replacement sidecar is missing), an ambiguous marker owner, and a replacement that is still live on this daemon while the marker is absent — leave the pre-existing interrupted state untouched for operator repair, so that shape stays visible to a later reset or restore. A retry against one of those states re-reads it and returns the same 409: it does not converge, so the state needs operator repair rather than a retry loop. The specific reason is written to the daemon log rather than the wire.
|
||||
- `500` — fail-closed internal errors (for example a bridge without reset support, or a relocation rejection after rollback); not retryable as-is. One `500` differs in kind: a marker transfer whose own post-commit tail failed _after_ the flip durably committed reports that ownership moved, never runs the destructive rollback, and is repaired by retrying the reset, which resumes the committed transfer idempotently.
|
||||
|
||||
### `GET /workspace/:id/session-info` and `GET /workspaces/:workspace/session-info`
|
||||
|
||||
Return aggregate persisted session counts for the selected workspace without changing the paginated session-list path:
|
||||
|
||||
@@ -407,13 +407,13 @@ See [Memory](../features/memory) for details on how auto-memory works and how to
|
||||
|
||||
#### agents
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| ------------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `agents.builtin.exploreModel` | string | Model selector for the built-in Explore subagent. Use `inherit` for the main session model, `fast` for `fastModel`, a model ID, or an `authType:model-id` selector. A custom same-name Explore agent keeps its own model configuration. Requires restart. | `inherit` |
|
||||
| `agents.modelGrades` | object | Maps semantic grade names exposed to the Agent tool to model selectors. Requires restart. | `undefined` |
|
||||
| `agents.allowedGrades` | array of strings | Optional whitelist of configured model grades the Agent tool may use. Requires restart. | `undefined` |
|
||||
| `agents.crossSessionMessaging` | boolean | Experimental. Let Qwen Code sessions on this machine send each other messages over a per-session local socket. Turning it on opens this session to peer messages, makes it discoverable to others, and lets its model address them from `send_message`. Requires restart. A workspace may set this to `false` only; a workspace `true` is ignored, with a warning only when it would loosen the operator-set or default value. | `false` |
|
||||
| `agents.crossSessionInbound` | enum | What happens to messages other sessions send this one: `accept` delivers them, `hold` parks them for `/peers` review without letting the model act, `refuse` opts this session out. Unset means review-class parity (see [Messaging another running session](../features/commands.md#6-messaging-another-running-session)). A workspace may only tighten this (`hold` or `refuse`, when stricter than the operator-set value or the unset default); an effective unrecognized value holds every message. | `undefined` |
|
||||
| Setting | Type | Description | Default |
|
||||
| ------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| `agents.builtin.exploreModel` | string | Model selector for the built-in Explore subagent. Use `inherit` for the main session model, `fast` for `fastModel`, a model ID, or an `authType:model-id` selector. A custom same-name Explore agent keeps its own model configuration. Requires restart. | `inherit` |
|
||||
| `agents.modelGrades` | object | Maps semantic grade names exposed to the Agent tool to model selectors. Requires restart. | `undefined` |
|
||||
| `agents.allowedGrades` | array of strings | Optional whitelist of configured model grades the Agent tool may use. Requires restart. | `undefined` |
|
||||
| `agents.crossSessionMessaging` | boolean | Experimental. Let Qwen Code sessions on this machine send each other messages over a per-session local socket. Turning it on opens this session to peer messages, makes it discoverable to others, and lets its model address them from `send_message`. Requires restart. A workspace may set this to `false` only; a workspace `true` is ignored, with a warning only when it would loosen the operator-set or default value. | `false` |
|
||||
| `agents.crossSessionInbound` | enum | What happens to inbound cross-session messages: `accept` delivers them, `hold` parks them for `/peers` review without letting the model act, and `refuse` opts this session out. Unset means [user-minted controllers](../features/commands.md#trusted-controllers) and this session's own child processes auto-deliver, while other sessions use [review-class parity](../features/commands.md#6-messaging-another-running-session); other messages are held for review. A workspace may only tighten this (`hold` or `refuse`, when stricter than the operator-set value or the unset default); an effective unrecognized value holds every message. | `undefined` |
|
||||
|
||||
#### permissions
|
||||
|
||||
@@ -791,6 +791,23 @@ When both user-level `.env` files define the same variable, the Qwen-specific
|
||||
file wins: `<QWEN_HOME>/.env` (or `~/.qwen/.env` when `QWEN_HOME` is unset) is
|
||||
loaded before `~/.env`, and existing environment values are not overwritten.
|
||||
|
||||
### Standalone update download source
|
||||
|
||||
Set `QWEN_UPDATE_BASE_URL` to use a custom HTTPS release root for standalone updates:
|
||||
|
||||
```bash
|
||||
export QWEN_UPDATE_BASE_URL="https://downloads.example.com/qwen-code"
|
||||
qwen update
|
||||
```
|
||||
|
||||
For version `0.23.0`, the updater downloads the platform archive, `SHA256SUMS`, and `SHA256SUMS.sig` from `<base-url>/v0.23.0/`. Host these files using the same names and directory layout as an official release. Existing checksum and signature checks still apply, including `QWEN_REQUIRE_SIGNATURE=1` when a signature is required.
|
||||
|
||||
The URL must use HTTPS and cannot contain credentials, a query string, or a fragment. Surrounding whitespace and trailing slashes are removed. An empty or whitespace-only value preserves the built-in download sources and their fallback order. When a custom root is set, a failed download does not fall back to the built-in sources.
|
||||
|
||||
Configure this variable in the launching shell or a user-level `.env` file. It is rejected from project `.env` and `.qwen/.env` files and from the top-level `settings.json` `env` section at every scope. A user-level `.env` value is loaded at startup; restart Qwen Code after changing it.
|
||||
|
||||
This setting applies to `qwen update`, `/update`, and automatic standalone updates. It does not change npm registry version discovery. It is separate from the installer's `QWEN_INSTALL_BASE_URL`, which points directly to a version-specific directory.
|
||||
|
||||
## Command-Line Arguments
|
||||
|
||||
Arguments passed directly when running the CLI can override other configurations for that specific session.
|
||||
|
||||
@@ -105,6 +105,32 @@ you edit other fields.
|
||||
|
||||
Set `"useConnectionManager": false` to disable Qwen Code's connection manager and fall back to the SDK's keepalive and automatic reconnect behavior.
|
||||
|
||||
### Background Agent Responses
|
||||
|
||||
Background Agent output is sent as soon as each response segment is available.
|
||||
Every message is labeled with the Agent name so concurrent work remains
|
||||
attributable.
|
||||
|
||||
To buffer each Agent's notification turn and send it as one labeled message,
|
||||
enable aggregation for the DingTalk channel in `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"my-dingtalk": {
|
||||
"type": "dingtalk",
|
||||
"clientId": "$DINGTALK_CLIENT_ID",
|
||||
"clientSecret": "$DINGTALK_CLIENT_SECRET",
|
||||
"aggregateBackgroundAgentResponses": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Aggregation is disabled by default. A partial labeled message is sent if the
|
||||
Agent turn is interrupted, fails before producing a final response, or does
|
||||
not finish within ten minutes.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
|
||||
@@ -118,7 +118,7 @@ The catalog is private to the exact channel, chat, and sender. Task names use 1
|
||||
|
||||
Named results identify their originating task: direct chats use `[task]`, while group chats use `[sender · task]`. Named text permission prompts also show the exact request ID and the corresponding `/approve <id>`, `/approve-always <id>`, and `/deny <id>` commands. The label is presentation-only and is not stored in the model transcript.
|
||||
|
||||
One task remains selected to receive the next normal message, but other named tasks may keep running concurrently. `/session new <name>` shares the configured workspace, while `/session new <name> --worktree` creates an isolated checkout for that task under the daemon workspace's `.qwen/worktrees/` directory. The daemon verifies the persisted worktree owner before reopening the task after a restart; a missing, changed, or foreign ownership record fails closed instead of silently moving the task into the shared workspace. Creating or selecting another task does not cancel or retarget earlier work, and late results retain their originating task label. A busy task cannot be closed, but its active prompt can be cancelled with `/session cancel [<name>]` through the existing Channel cancellation behavior. Independently queued turns are not cancelled, but in `collect` dispatch mode any follow-ups buffered behind the cancelled prompt are discarded by that existing behavior. Media preparation is not targeted. Bare permission commands apply only to the selected task, while an explicit request ID can answer an owned inactive task. `/clear`, `/new`, and `/reset` do not replace a selected worktree task; select or create a shared task first. Channel memory remains scoped to the chat rather than to a named task.
|
||||
One task remains selected to receive the next normal message, but other named tasks may keep running concurrently. `/session new <name>` shares the configured workspace, while `/session new <name> --worktree` creates an isolated checkout for that task under the daemon workspace's `.qwen/worktrees/` directory. The daemon verifies the persisted worktree owner before reopening the task after a restart; a missing, changed, or foreign ownership record fails closed instead of silently moving the task into the shared workspace. Creating or selecting another task does not cancel or retarget earlier work, and late results retain their originating task label. A busy task cannot be closed, but its active prompt can be cancelled with `/session cancel [<name>]` through the existing Channel cancellation behavior. Independently queued turns are not cancelled, but in `collect` dispatch mode any follow-ups buffered behind the cancelled prompt are discarded by that existing behavior. Media preparation is not targeted. Bare permission commands apply only to the selected task, while an explicit request ID can answer an owned inactive task. `/clear`, `/new`, and `/reset` also work on a selected worktree task: the task gets a fresh conversation while its worktree and files are kept. A busy worktree task refuses the reset until its prompt finishes, and a task whose worktree record was damaged reports the failure without touching files. Channel memory remains scoped to the chat rather than to a named task.
|
||||
|
||||
This mode is unavailable in standalone `qwen channel start`, with webhooks, with non-zero channel or group `groupHistoryLimit`, or with Channel loops. If an enabled loop already exists for that channel, the daemon worker refuses to start until the loop is disabled.
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ Commands for managing AI tools and models.
|
||||
| → `auto-edit` | Auto-approve edits (trusted environment) | `/approval-mode auto-edit` |
|
||||
| → `auto` | Classifier-evaluated approval (autonomous) | `/approval-mode auto` |
|
||||
| → `yolo` | Auto-approve everything (quick prototyping) | `/approval-mode yolo` |
|
||||
| `/peers` | Review messages held from other Qwen Code sessions on this machine | `/peers`, `/peers accept <id>`, `/peers deny all` |
|
||||
| `/peers` | Review held peer messages; manage trusted controllers | `/peers`, `/peers accept <id>`, `/peers deny all`, `/peers controllers`, `/peers revoke <id>` |
|
||||
| `/model` | Switch model used in current session | `/model`, `/model <model-id>` (switch immediately) |
|
||||
| `/model --fast` | Set a lighter model for prompt suggestions | `/model --fast qwen3-coder-flash` |
|
||||
| `/model --voice` | Set the model used for voice transcription | `/model --voice <model-id>` |
|
||||
@@ -711,10 +711,11 @@ These commands are run from the shell as `qwen <subcommand>` before starting an
|
||||
|
||||
### Session Management
|
||||
|
||||
| Command | Description | Usage Examples |
|
||||
| -------------------- | ----------------------------------- | ------------------------------------------------------------ |
|
||||
| `qwen sessions list` | List recent conversation sessions | `qwen sessions list`, `qwen sessions list --json --limit 50` |
|
||||
| `qwen sessions ps` | List the sessions running right now | `qwen sessions ps`, `qwen sessions ps --json` |
|
||||
| Command | Description | Usage Examples |
|
||||
| --------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `qwen sessions list` | List recent conversation sessions | `qwen sessions list`, `qwen sessions list --json --limit 50` |
|
||||
| `qwen sessions ps` | List the sessions running right now | `qwen sessions ps`, `qwen sessions ps --json` |
|
||||
| `qwen sessions controllers` | Manage trusted controller tokens | `qwen sessions controllers add --label <name>`, `qwen sessions controllers list` |
|
||||
|
||||
#### `qwen sessions list`
|
||||
|
||||
@@ -798,7 +799,7 @@ An interactive session is emitted as its whole registry record, plus
|
||||
|
||||
```
|
||||
schemaVersion, pid, procStart, pidNs, sessionId, cwd, name, startedAt,
|
||||
qwenVersion, managed
|
||||
qwenVersion, ipcPath (when peer messaging is available), managed
|
||||
```
|
||||
|
||||
A managed session is emitted in the managed row shape, even when the same
|
||||
@@ -952,3 +953,67 @@ anything else. The model sees it as
|
||||
`<cross_session_message from="own process" origin="own-process">` with a
|
||||
notice that it came from a script or hook the session ran, not from the
|
||||
user.
|
||||
|
||||
### Trusted controllers
|
||||
|
||||
The rule above holds a message from any sender that does not say which
|
||||
review class it is in, and a program that is not a Qwen Code session has
|
||||
none to say. That is the right default for a stranger, but not for a
|
||||
program you chose: a voice front-end, a dictation bridge, an automation
|
||||
daemon relaying your own instructions would have every message parked,
|
||||
and approving each one by hand defeats the point.
|
||||
|
||||
You grant such a program delivery by minting it a token:
|
||||
|
||||
```bash
|
||||
qwen sessions controllers add --label voice-bridge
|
||||
```
|
||||
|
||||
The token is printed once and is not stored anywhere: the file under
|
||||
your Qwen home keeps only its SHA-256 hash, so nothing that later reads
|
||||
that file can present the token. Put it in the controller's own
|
||||
configuration when the command prints it.
|
||||
|
||||
A controller presents the token the way any other sender does — as the
|
||||
first line of the connection — and takes the socket path from the
|
||||
session registry (`qwen sessions ps --json` prints one record per live
|
||||
session, `ipcPath` being the address):
|
||||
|
||||
```bash
|
||||
{ printf '%s\n' \
|
||||
'{"msgV":1,"type":"auth","token":"'"$QWEN_CONTROLLER_TOKEN"'"}' \
|
||||
'{"msgV":1,"msgId":"'"$(uuidgen)"'","type":"user","priority":"next","message":{"role":"user","content":"open the failing test"}}'; \
|
||||
} | socat - UNIX-CONNECT:"$SESSION_IPC_PATH"
|
||||
```
|
||||
|
||||
A message that arrives on a granted token is delivered without
|
||||
per-message review, whatever review class either side is in — but it
|
||||
still yields to an explicit setting: an `agents.crossSessionInbound` of
|
||||
`hold` parks it like anything else, and `refuse` turns it away. Grants
|
||||
belong to your Qwen home rather than to one session, so a controller
|
||||
reaches whichever sessions you are running, and sessions re-read the
|
||||
file on every connection: minting or revoking one takes effect on the
|
||||
next connection, with nothing to restart.
|
||||
|
||||
```bash
|
||||
qwen sessions controllers list # ids, labels, when they were added
|
||||
qwen sessions controllers remove c_1a2b # revoke one
|
||||
```
|
||||
|
||||
`/peers controllers` and `/peers revoke <id>` do the same from inside a
|
||||
session. A message that came through a grant is shown as
|
||||
`Message from a trusted controller (voice-bridge)`, and appears in
|
||||
`/peers` as `[controller] voice-bridge` if a `hold` setting parked it.
|
||||
|
||||
The model sees such a message as
|
||||
`<cross_session_message from="controller" origin="controller" controller="voice-bridge">`,
|
||||
with a notice that it relays your own instructions — and the same two
|
||||
prohibitions that apply to every other origin: it may not edit
|
||||
permission settings, QWEN.md, or config because the message asked, and
|
||||
it may not treat the message as you approving a pending confirmation
|
||||
prompt. A controller can say what to do next; it cannot answer a prompt
|
||||
on your behalf.
|
||||
|
||||
Anyone who holds the token can send as that controller, so treat it like
|
||||
any other credential: give it to one program, keep it out of shared
|
||||
config, and revoke it when that program is done.
|
||||
@@ -25,8 +25,8 @@ Node.js 22 or later and npm are required.
|
||||
When first used, the skill runs these commands itself:
|
||||
|
||||
```bash
|
||||
qwen mcp add --scope user node-repl npx -y @qwen-code/node-repl-mcp@0.1.2
|
||||
npm install --no-save --package-lock=false @qwen-code/cua-sdk@0.20.3
|
||||
qwen mcp add --scope user node-repl npx -y @qwen-code/node-repl-mcp@0.1.3
|
||||
npm install --no-save --package-lock=false @qwen-code/cua-sdk@0.20.4
|
||||
```
|
||||
|
||||
Restart Qwen Code after the MCP server is first added. The skill then resumes
|
||||
@@ -45,11 +45,13 @@ Ask Qwen Code to use `$computer-use` for the desktop task. After bootstrap, it
|
||||
follows the standard Computer Use workflow:
|
||||
|
||||
1. discovers the exact application and window;
|
||||
2. observes full accessibility state;
|
||||
3. acts through current semantic element tokens when possible;
|
||||
4. fetches fresh state after every mutation;
|
||||
5. verifies the requested result; and
|
||||
6. closes the SDK client and resets the REPL.
|
||||
2. observes full accessibility state, then accumulates automatic incremental
|
||||
updates into the current state;
|
||||
3. performs one or more actions through current semantic element tokens,
|
||||
including unchanged tokens retained across compatible diffs;
|
||||
4. fetches the latest state before deciding what to do next; and
|
||||
5. closes the SDK client and resets the REPL only when no other persistent
|
||||
state is needed.
|
||||
|
||||
The driver is the only component that computes observation diffs. Model code
|
||||
uses the typed SDK methods and does not dispatch arbitrary driver tool names.
|
||||
|
||||
@@ -16,6 +16,8 @@ A Goal keeps Qwen Code working across turns until a stated condition is met. Set
|
||||
|
||||
Creating, editing, or resuming a Goal requires a trusted workspace (`/trust`). Headless usage is covered in [Headless Mode](./headless.md#run-a-persistent-goal).
|
||||
|
||||
Once a Goal has billed a turn, the footer pill and every status card show what it has spent against the window it is allowed, as `1.2k/30.0m`. The figure counts the model calls the Goal makes in its own turns; subagents and the verifier's own checks are not included. The window is set by [`model.goalTokenBudget`](../configuration/settings.md); resuming a Goal that has spent its window grants another one on top of what it has already spent, so the figure reads `30.0m/60.0m` rather than starting over. A Goal with no budget shows only what it has spent. A Goal that has not billed a turn yet shows no figures at all.
|
||||
|
||||
## Interrupting a Goal
|
||||
|
||||
Cancelling a Goal turn pauses the Goal. Press Esc while the model is answering or while its tools are still running, and the turn stops, the Goal moves to `paused`, and the card and `/goal` both say why it stopped. Nothing continues until you run `/goal resume`.
|
||||
|
||||
@@ -190,6 +190,46 @@ disable-model-invocation: true
|
||||
|
||||
You can combine both fields, but then the Skill is not reachable through the normal user or model invocation paths.
|
||||
|
||||
### Optional: enforce a rule deterministically (`hooks:`)
|
||||
|
||||
Everything in a `SKILL.md` body is an instruction to the model: it is prompt text, so following it depends on the model. When a rule must hold no matter what the model decides — refuse to run unless a required value was injected, never touch a protected path — declare a [hook](hooks.md) in the frontmatter instead. Hooks run as code, so they do not depend on the model's cooperation:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: gated-skill
|
||||
description: Calls the downstream CLI using a runtime-injected session ID
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: run_shell_command
|
||||
hooks:
|
||||
- type: command
|
||||
command: '"$QWEN_SKILL_ROOT/scripts/gate-session-id.sh"'
|
||||
---
|
||||
```
|
||||
|
||||
`$QWEN_SKILL_ROOT` is set to the Skill's own directory, so hook commands can reference files shipped alongside `SKILL.md`. The command string is handed to a shell, so **keep the inner quotes**: unquoted, a project path containing a space splits into two words and the gate never runs. **Make the script executable** (`chmod +x`) too. Both mistakes fail open in the same way: the tool call proceeds, and nothing appears in the transcript or the log to say the gate did not run. A `PreToolUse` hook blocks the tool call when it exits with code `2` (stderr is fed back to the model as the reason), or when it prints `hookSpecificOutput.permissionDecision: "deny"`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
if [ -z "${DOWNSTREAM_SESSION_ID:-}" ]; then
|
||||
echo "Required input DOWNSTREAM_SESSION_ID is not available. Cannot proceed." >&2
|
||||
exit 2
|
||||
fi
|
||||
exit 0
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Hooks are registered when the Skill is invoked and last for the rest of the session. This is true on both invocation paths — whether the model calls the Skill or you type `/<skill-name>`.
|
||||
- Session hooks live only in memory, so resuming a session with `--continue` / `--resume` does **not** restore them, on either invocation path. The Skill's instructions can come back with the replayed conversation while the hooks meant to enforce them are gone — re-run the Skill after resuming to re-arm its gate.
|
||||
- Registration is idempotent: re-invoking a Skill does not stack duplicate hooks.
|
||||
- Always give a tool event an explicit `matcher:`. An omitted one is stored as the empty pattern, which is compiled to `^$` and matches no tool name — the hook registers and then never fires, with nothing to say so. Use `*` if you mean every tool.
|
||||
- The `command:` runs through the platform shell: `bash` on macOS and Linux, and on Windows Git Bash when it is detected (`MSYSTEM`/`TERM`), otherwise `cmd.exe` or PowerShell. The example above is POSIX shell — under `cmd.exe` `$QWEN_SKILL_ROOT` is not expanded and a `.sh` script is not executable, so the gate fails open there. A hook may set `shell: bash` to force bash, but that resolves to whatever `bash` is on `PATH`, so on Windows outside Git Bash write the gate for the shell you actually have.
|
||||
- Sessions that disable hooks register none of them — `disableAllHooks`, safe mode, and an ACP client's `skipHooks`. The Skill's body and its `allowedTools` still apply in those sessions, but its gate does not, so a rule you rely on a hook to enforce is not enforced there. Bare mode goes further: no Skills are discovered at all, so there is no body and no `allowedTools` either.
|
||||
- A **project** Skill's hooks run repo-supplied commands, so they are registered only in a trusted folder, and trust is re-read every time a hook fires and every time a permission is decided. With an IDE companion connected that value is live: revoking trust silences an already-registered gate — and suspends the Skill's `allowedTools` — at the next tool call, without a restart. Without an IDE connection the value is fixed when the CLI starts, so a change made through the CLI's own trust dialog takes effect on restart. Granting trust never retro-registers: invoke the Skill again.
|
||||
- `hooks:` is read for project, user, and bundled Skills. Extension-provided Skills do not support it; use the extension's own manifest-level hooks instead.
|
||||
- See [Hooks](hooks.md) for the full event list, matcher syntax, and output format.
|
||||
|
||||
## Add supporting files
|
||||
|
||||
Create additional files alongside `SKILL.md`:
|
||||
|
||||
@@ -4,14 +4,21 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
runForcedToolCallScenario,
|
||||
TestRig,
|
||||
printDebugInfo,
|
||||
validateModelOutput,
|
||||
} from '../test-helper.js';
|
||||
import { fakeToolCall } from '../fake-openai-server.js';
|
||||
|
||||
describe('file-system', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should be able to read a file', async () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should be able to read a file');
|
||||
@@ -95,15 +102,19 @@ describe('file-system', () => {
|
||||
const rig = new TestRig();
|
||||
await rig.setup('should correctly handle file paths with spaces');
|
||||
const fileName = 'my test file.txt';
|
||||
const filePath = join(rig.testDir!, fileName);
|
||||
|
||||
const result = await rig.run(
|
||||
`Use write_file to write exactly "hello" to "${fileName}".`,
|
||||
);
|
||||
await runForcedToolCallScenario({
|
||||
rig,
|
||||
toolCall: fakeToolCall('write_file', {
|
||||
file_path: filePath,
|
||||
content: 'hello',
|
||||
}),
|
||||
prompt: `Use write_file to write exactly "hello" to "${fileName}".`,
|
||||
finalResponse: 'Done.',
|
||||
});
|
||||
|
||||
const foundToolCall = await rig.waitForToolCall('write_file');
|
||||
if (!foundToolCall) {
|
||||
printDebugInfo(rig, result);
|
||||
}
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a write_file tool call',
|
||||
|
||||
@@ -474,6 +474,7 @@ describe('qwen serve — capabilities envelope', () => {
|
||||
'workspace_session_live_state',
|
||||
'workspace_session_metadata',
|
||||
'session_worktree_persistence_v1',
|
||||
'session_worktree_reset_v1',
|
||||
'voice_transcribe',
|
||||
'web_terminal',
|
||||
]);
|
||||
|
||||
@@ -4,10 +4,19 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { TestRig } from '../test-helper.js';
|
||||
import { runForcedToolCallScenario, TestRig } from '../test-helper.js';
|
||||
import { fakeToolCall } from '../fake-openai-server.js';
|
||||
|
||||
// Windows skip (Option A: avoid infra scope)
|
||||
const d = process.platform === 'win32' ? describe.skip : describe;
|
||||
@@ -64,15 +73,25 @@ d('BOM end-to-end integration', () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
async function runAndAssert(
|
||||
filename: string,
|
||||
content: Buffer,
|
||||
expectedText: string | null,
|
||||
) {
|
||||
writeFileSync(join(dir, filename), content);
|
||||
const filePath = join(dir, filename);
|
||||
writeFileSync(filePath, content);
|
||||
const prompt = `read the file ${filename} and output its exact contents`;
|
||||
const output = await rig.run(prompt);
|
||||
await rig.waitForToolCall('read_file');
|
||||
const requests = await runForcedToolCallScenario({
|
||||
rig,
|
||||
toolCall: fakeToolCall('read_file', { file_path: filePath }),
|
||||
prompt,
|
||||
finalResponse: 'Done.',
|
||||
});
|
||||
const output = JSON.stringify(requests.at(-1)?.['messages'] ?? '');
|
||||
const lower = output.toLowerCase();
|
||||
if (expectedText === null) {
|
||||
expect(
|
||||
|
||||
@@ -34,11 +34,14 @@ describe('Interactive Mode', () => {
|
||||
});
|
||||
|
||||
async function runInteractiveWithFakeModel() {
|
||||
// The seed must fit the 80x30 pty viewport: under the OpenTUI renderer
|
||||
// off-screen rows never reach the pty stream waitForText polls, so a
|
||||
// seed longer than the viewport hides its own end marker (#11191).
|
||||
fakeServer = await startFakeOpenAIServer(
|
||||
({ requestIndex }) => ({
|
||||
content:
|
||||
requestIndex === 0
|
||||
? `${'history '.repeat(1000)} Einstein`
|
||||
? `${'history '.repeat(100)} Einstein`
|
||||
: '<state_snapshot>Compressed history focused on Einstein.</state_snapshot>',
|
||||
}),
|
||||
fakeServerHostOptions(),
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* A project skill's frontmatter `hooks:` must fire regardless of who invoked
|
||||
* the skill. Regression for #11067, where the `/<skill-name>` slash-command
|
||||
* path injected the skill body and granted its allowedTools but never
|
||||
* registered its hooks — so a `PreToolUse` gate silently failed open exactly
|
||||
* when the user started the skill by hand.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
applyContainerSandboxNoProxy,
|
||||
fakeServerHostOptions,
|
||||
TestRig,
|
||||
} from './test-helper.js';
|
||||
import { fakeToolCall, startFakeOpenAIServer } from './fake-openai-server.js';
|
||||
import type { FakeOpenAIServer } from './fake-openai-server.js';
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
|
||||
|
||||
const GATE_MARKER = 'GATE_BLOCKED_DOWNSTREAM_SESSION_ID_MISSING';
|
||||
const EXECUTED_FLAG = 'executed.flag';
|
||||
const SKILL_DESCRIPTION =
|
||||
'Calls the downstream CLI using a runtime-injected session ID';
|
||||
// Only a loaded skill command can render its own description in the
|
||||
// completion menu, which is what the user path polls for. Match on a prefix
|
||||
// short enough to survive a narrow terminal truncating the rest.
|
||||
const SKILL_DESCRIPTION_PREFIX = 'Calls the downstream CLI';
|
||||
|
||||
function installGatedSkill(testDir: string) {
|
||||
const skillDir = join(testDir, '.qwen', 'skills', 'gated-skill');
|
||||
mkdirSync(join(skillDir, 'scripts'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(skillDir, 'SKILL.md'),
|
||||
`---
|
||||
name: gated-skill
|
||||
description: ${SKILL_DESCRIPTION}
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- matcher: Shell
|
||||
hooks:
|
||||
- type: command
|
||||
command: "$QWEN_SKILL_ROOT/scripts/gate-session-id.sh"
|
||||
---
|
||||
|
||||
Only use the exact runtime-injected ID (\`DOWNSTREAM_SESSION_ID\`).
|
||||
Never fabricate a fallback; stop if it is missing.
|
||||
`,
|
||||
);
|
||||
|
||||
const gate = join(skillDir, 'scripts', 'gate-session-id.sh');
|
||||
writeFileSync(
|
||||
gate,
|
||||
`#!/usr/bin/env bash
|
||||
if [ -z "\${DOWNSTREAM_SESSION_ID:-}" ]; then
|
||||
echo "${GATE_MARKER}" >&2
|
||||
exit 2
|
||||
fi
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
chmodSync(gate, 0o755);
|
||||
}
|
||||
|
||||
describe('skill hooks fire on both invocation paths', () => {
|
||||
let rig: TestRig;
|
||||
let fakeServer: FakeOpenAIServer;
|
||||
let restoreNoProxy: (() => void) | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
restoreNoProxy?.();
|
||||
restoreNoProxy = undefined;
|
||||
await fakeServer?.close();
|
||||
await rig?.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* Drives one interactive session. `modelInvokesSkill` controls whether the
|
||||
* model calls the Skill tool itself (control) or the user typed the slash
|
||||
* command first (repro).
|
||||
*/
|
||||
async function driveSession(options: {
|
||||
testName: string;
|
||||
modelInvokesSkill: boolean;
|
||||
}): Promise<{ output: string; executed: boolean }> {
|
||||
rig = new TestRig();
|
||||
await rig.setup(options.testName);
|
||||
installGatedSkill(rig.testDir!);
|
||||
|
||||
let streamingRequestIndex = 0;
|
||||
fakeServer = await startFakeOpenAIServer(({ body }) => {
|
||||
if (body['stream'] !== true) {
|
||||
return { content: '{"selected_memories":[]}' };
|
||||
}
|
||||
const i = streamingRequestIndex++;
|
||||
if (options.modelInvokesSkill && i === 0) {
|
||||
return {
|
||||
toolCalls: [
|
||||
fakeToolCall('skill', { skill: 'gated-skill' }, 'call-skill'),
|
||||
],
|
||||
};
|
||||
}
|
||||
const shellTurn = options.modelInvokesSkill ? 1 : 0;
|
||||
if (i === shellTurn) {
|
||||
return {
|
||||
toolCalls: [
|
||||
fakeToolCall(
|
||||
'run_shell_command',
|
||||
{ command: `touch ${EXECUTED_FLAG}` },
|
||||
'call-shell',
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: 'done' };
|
||||
}, fakeServerHostOptions());
|
||||
|
||||
vi.stubEnv('OPENAI_API_KEY', 'fake-key');
|
||||
vi.stubEnv('OPENAI_BASE_URL', fakeServer.baseUrl);
|
||||
vi.stubEnv('OPENAI_MODEL', 'fake-model');
|
||||
vi.stubEnv('QWEN_MODEL', 'fake-model');
|
||||
vi.stubEnv('QWEN_HOME', join(rig.testDir!, '.qwen-home'));
|
||||
vi.stubEnv('QWEN_RUNTIME_DIR', join(rig.testDir!, '.qwen-home'));
|
||||
// Under the docker/podman sandbox legs the CLI is containerized, so the
|
||||
// fake server must be reachable as host.docker.internal and excluded from
|
||||
// the proxy. Both helpers are no-ops outside a container sandbox.
|
||||
restoreNoProxy = applyContainerSandboxNoProxy();
|
||||
// The gate's required value is deliberately absent.
|
||||
vi.stubEnv('DOWNSTREAM_SESSION_ID', '');
|
||||
|
||||
const { ptyProcess } = rig.runInteractive(
|
||||
'--auth-type',
|
||||
'openai',
|
||||
'--model',
|
||||
'fake-model',
|
||||
'--openai-base-url',
|
||||
fakeServer.baseUrl,
|
||||
'--openai-api-key',
|
||||
'fake-key',
|
||||
);
|
||||
|
||||
let output = '';
|
||||
ptyProcess.onData((d) => {
|
||||
output += d;
|
||||
});
|
||||
|
||||
const ready = await rig.waitForText('Type your message', 30000);
|
||||
expect(ready, `CLI did not start. Output:\n${output}`).toBe(true);
|
||||
|
||||
// Every wait below polls for the condition it is actually waiting on, so
|
||||
// the test neither burns a fixed budget on a fast runner nor gives up
|
||||
// early on a slow one.
|
||||
const waitFor = async (label: string, done: () => boolean) => {
|
||||
const ok = await rig.poll(done, 30000, 100);
|
||||
expect(ok, `timed out waiting for ${label}. Output:\n${output}`).toBe(
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
if (!options.modelInvokesSkill) {
|
||||
// USER path: start the skill by hand. Wait for the command to exist,
|
||||
// not merely for the typed text to come back: the echo lands long
|
||||
// before the skill command registry does, and submitting into that gap
|
||||
// gets `Unknown command: /gated-skill` instead of the skill. That
|
||||
// failure is undetectable downstream — the error text contains
|
||||
// `/gated-skill`, so any inclusion check on the typed command matches
|
||||
// it too. The completion menu rendering the skill's own description is
|
||||
// a signal only a registered command can produce.
|
||||
ptyProcess.write('/gated-skill');
|
||||
await waitFor('the skill command to be registered', () =>
|
||||
output.includes(SKILL_DESCRIPTION_PREFIX),
|
||||
);
|
||||
// The body is submitted as a prompt rather than echoed, so the
|
||||
// observable signal is the model call it produces.
|
||||
const before = fakeServer.requests.length;
|
||||
ptyProcess.write('\r');
|
||||
await waitFor(
|
||||
'the skill body to reach the model',
|
||||
() => fakeServer.requests.length > before,
|
||||
);
|
||||
// Guards the wait above rather than the CLI: the command was never
|
||||
// submitted before it existed, so this string must never appear.
|
||||
expect(
|
||||
output,
|
||||
'the slash command was submitted before it was registered',
|
||||
).not.toContain('Unknown command: /gated-skill');
|
||||
}
|
||||
|
||||
const prompt = 'run the downstream command';
|
||||
ptyProcess.write(prompt);
|
||||
await waitFor('the prompt to echo', () => output.includes(prompt));
|
||||
ptyProcess.write('\r');
|
||||
|
||||
const flagPath = join(rig.testDir!, EXECUTED_FLAG);
|
||||
await waitFor(
|
||||
'the shell call to be gated or to run',
|
||||
() => existsSync(flagPath) || output.includes(GATE_MARKER),
|
||||
);
|
||||
|
||||
// Ctrl+C twice to exit; the second only registers once the first has been
|
||||
// acknowledged.
|
||||
ptyProcess.write('\x03');
|
||||
await waitFor('the exit confirmation', () =>
|
||||
output.includes('Ctrl+C again to exit'),
|
||||
);
|
||||
ptyProcess.write('\x03');
|
||||
|
||||
return { output, executed: existsSync(flagPath) };
|
||||
}
|
||||
|
||||
it('model-invoked skill: the gate fires and blocks the shell call', async () => {
|
||||
const { output, executed } = await driveSession({
|
||||
testName: 'skill hooks model invoked',
|
||||
modelInvokesSkill: true,
|
||||
});
|
||||
expect(
|
||||
output,
|
||||
`gate did not fire. Output tail:\n${output.slice(-1500)}`,
|
||||
).toContain(GATE_MARKER);
|
||||
expect(executed).toBe(false);
|
||||
}, 120000);
|
||||
|
||||
it('user-invoked skill (/<skill-name>): the gate fires and blocks the shell call', async () => {
|
||||
const { output, executed } = await driveSession({
|
||||
testName: 'skill hooks slash invoked',
|
||||
modelInvokesSkill: false,
|
||||
});
|
||||
expect(
|
||||
output,
|
||||
`gate did not fire. Output tail:\n${output.slice(-1500)}`,
|
||||
).toContain(GATE_MARKER);
|
||||
expect(executed).toBe(false);
|
||||
}, 120000);
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
# Mem0 External Context Extension
|
||||
|
||||
This package provides a retrieval-only External Context MCP Profile v1 server
|
||||
for administrator-configured Mem0-compatible HTTP services. It validates a
|
||||
closed dialect grammar and uses a bounded HTTP request engine; it does not ship
|
||||
This package provides retrieval-only on-demand and Auto Recall profiles for
|
||||
administrator-configured Mem0-compatible HTTP services. It validates a closed
|
||||
dialect grammar and uses a bounded HTTP request engine; it does not ship
|
||||
provider presets or provider-specific configuration.
|
||||
|
||||
The default Extension exposes exactly one tool, `context_search({ query })`.
|
||||
Administrators can instead install the packaged `UserPromptSubmit` Hook for
|
||||
automatic retrieval. The model cannot select the endpoint, credential, scope,
|
||||
timeout, result limit, or dialect in either profile.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the published Extension with Qwen Code:
|
||||
@@ -21,39 +26,29 @@ Use an explicit package version when the deployment must remain pinned:
|
||||
qwen extensions install @qwen-code/external-context-mem0@x.y.z
|
||||
```
|
||||
|
||||
The Extension version follows the Qwen Code release version. Installing the
|
||||
package does not configure a memory service or install provider data; an
|
||||
administrator must supply the two files and credential environment described
|
||||
below.
|
||||
The Extension version follows the Qwen Code release version. Installation does
|
||||
not configure a memory service or install provider data. An administrator must
|
||||
supply an instance file, a dialect file, and the credential environment
|
||||
variable described below.
|
||||
|
||||
## Configuration
|
||||
## Administrator deployment
|
||||
|
||||
Set `QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG` in the Extension process environment to
|
||||
the absolute path of an administrator-owned instance JSON file. The instance
|
||||
file must conform to
|
||||
[`schemas/instance-config.schema.json`](./schemas/instance-config.schema.json)
|
||||
and reference a separate dialect JSON file by absolute `dialectPath`:
|
||||
### 1. Confirm that the upstream API fits the bounded dialect
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"dialectPath": "/etc/qwen/external-context/memory.dialect.json",
|
||||
"endpoint": {
|
||||
"origin": "https://memory.example.com",
|
||||
"basePath": "",
|
||||
"allowInsecureHttp": false
|
||||
},
|
||||
"credentialEnv": "MEMORY_API_KEY",
|
||||
"scope": {
|
||||
"userId": "repository-memory"
|
||||
},
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
```
|
||||
The upstream search API must use a static `GET` or `POST` path, one supported
|
||||
authentication header, simple query and scope placement, and a JSON response
|
||||
that matches the supported result fields. The dialect cannot define arbitrary
|
||||
headers, request templates, JSONPath, executable transformations, redirects,
|
||||
retries, or write operations.
|
||||
|
||||
The dialect file must conform to
|
||||
[`schemas/dialect.schema.json`](./schemas/dialect.schema.json). This synthetic
|
||||
example describes one supported shape without identifying a provider:
|
||||
If the upstream API cannot fit this grammar, implement a separate MCP Extension
|
||||
instead of weakening the boundary.
|
||||
|
||||
### 2. Create the dialect file
|
||||
|
||||
Store the file outside ordinary workspaces. The following unbranded template
|
||||
describes a `POST` search endpoint that receives the query and fixed user scope
|
||||
in JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -81,21 +76,265 @@ example describes one supported shape without identifying a provider:
|
||||
}
|
||||
```
|
||||
|
||||
The administrator controls both files and the process environment. Production
|
||||
deployments should keep them outside ordinary workspaces and supply the
|
||||
instance path through a system environment, controlled launcher, or pinned MCP
|
||||
configuration. `dialectPath` is never resolved relative to the instance file,
|
||||
expanded from environment variables, or loaded from a URL.
|
||||
Save it, for example, as
|
||||
`/etc/qwen/external-context/memory.dialect.json`. It must conform to
|
||||
[`schemas/dialect.schema.json`](./schemas/dialect.schema.json).
|
||||
|
||||
The credential value must exist only in the environment variable named by
|
||||
`credentialEnv`; neither JSON file may contain it. Both files are limited to
|
||||
64 KiB and are read once when the Extension starts. Restart the Extension after
|
||||
changing either file.
|
||||
Use the dialect fields as follows:
|
||||
|
||||
The schema intentionally supports only the bounded request and response
|
||||
grammar recorded in the design. Protocols that require arbitrary templates,
|
||||
headers, JSONPath, redirects, executable transformations, or write operations
|
||||
must use a separate MCP Extension.
|
||||
| Upstream contract | Dialect value |
|
||||
| ------------------------------------ | -------------------------------------------- |
|
||||
| `Authorization: Token <credential>` | `auth: "authorization-token"` |
|
||||
| `Authorization: Bearer <credential>` | `auth: "authorization-bearer"` |
|
||||
| `X-API-Key: <credential>` | `auth: "x-api-key"` |
|
||||
| Query string search | `method: "GET"` and `queryLocation: "query"` |
|
||||
| JSON request search | `method: "POST"` and `queryLocation: "json"` |
|
||||
| Scope at the JSON root | `json` |
|
||||
| Scope under `filters` | `json.filters` |
|
||||
| Scope in the query string | `query` |
|
||||
| Scope not sent upstream | `omit` |
|
||||
| Upstream result limit | `limitField: "limit"` or `"top_k"` |
|
||||
| No upstream result-limit field | `limitField: "omit"` |
|
||||
|
||||
`GET` dialects cannot use JSON locations. The `response` object selects only
|
||||
the supported collection and field names; it does not contain paths or
|
||||
transformations. The dialect `id` is an administrator-owned audit label and
|
||||
does not participate in lookup or file-name matching.
|
||||
|
||||
Optional `search.threshold` values from 0 through 1 and boolean
|
||||
`search.rerank` values are sent as query parameters for `GET` or JSON fields
|
||||
for `POST`. The complete response-field allowlists are defined by the dialect
|
||||
schema.
|
||||
|
||||
### 3. Create the instance file
|
||||
|
||||
The instance file binds the dialect to one endpoint, credential variable,
|
||||
fixed scope, and timeout:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"dialectPath": "/etc/qwen/external-context/memory.dialect.json",
|
||||
"endpoint": {
|
||||
"origin": "https://memory.example.com",
|
||||
"basePath": "",
|
||||
"allowInsecureHttp": false
|
||||
},
|
||||
"credentialEnv": "MEMORY_API_KEY",
|
||||
"scope": {
|
||||
"userId": "repository-memory"
|
||||
},
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
```
|
||||
|
||||
Save it, for example, as
|
||||
`/etc/qwen/external-context/memory.instance.json`. It must conform to
|
||||
[`schemas/instance-config.schema.json`](./schemas/instance-config.schema.json).
|
||||
|
||||
`dialectPath` must be an absolute local path. It is not resolved relative to
|
||||
the instance file, expanded from environment variables, or loaded from a URL.
|
||||
The old `schemaVersion: 1` and `preset` shape is intentionally unsupported.
|
||||
|
||||
Every non-`omit` dialect scope location requires the corresponding `userId`,
|
||||
`agentId`, or `appId` in the instance file. Every `omit` location requires that
|
||||
scope value to be absent. Scope values are fixed routing inputs, not an
|
||||
authorization boundary. Use separate administrator bindings when deployments
|
||||
must access different fixed corpora.
|
||||
|
||||
`endpoint.origin` contains only the scheme and authority. Put a shared static
|
||||
path prefix in `basePath` and the operation path in the dialect. HTTPS is
|
||||
required by default. Set `allowInsecureHttp` to `true` only for a trusted
|
||||
private-network HTTP endpoint; never send a credential over public HTTP in a
|
||||
production deployment.
|
||||
|
||||
### 4. Protect the files and inject the environment
|
||||
|
||||
Both JSON files are administrator-controlled configuration, even though they
|
||||
contain no credential. Restrict their ownership and permissions according to
|
||||
the host policy. For example:
|
||||
|
||||
```bash
|
||||
install -d -m 700 /etc/qwen/external-context
|
||||
chmod 600 /etc/qwen/external-context/memory.*.json
|
||||
```
|
||||
|
||||
These are Unix examples. On Windows, use an administrator-controlled absolute
|
||||
directory and equivalent ACLs; escape backslashes when writing the absolute
|
||||
path in JSON.
|
||||
|
||||
Set `QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG` to the absolute instance-file path in
|
||||
the Qwen process environment. Separately inject the environment variable named
|
||||
by `credentialEnv` through the deployment's secret manager or service manager:
|
||||
|
||||
```bash
|
||||
export QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG=/etc/qwen/external-context/memory.instance.json
|
||||
# The secret manager must populate and export MEMORY_API_KEY before this point.
|
||||
test -n "${MEMORY_API_KEY:-}" || exit 1
|
||||
qwen
|
||||
```
|
||||
|
||||
The Extension inherits these values from the Qwen process. Do not put the
|
||||
credential in either JSON file, Qwen settings, a repository `.env` file, a
|
||||
launcher script, or a command-line argument. The configuration path is not an
|
||||
ordinary Qwen setting.
|
||||
|
||||
Both files are limited to 64 KiB. The on-demand MCP process reads them once at
|
||||
startup. Restart Qwen Code after changing either file or environment value in
|
||||
that profile.
|
||||
|
||||
### 5. Verify the deployment
|
||||
|
||||
1. Start Qwen Code from the configured process environment.
|
||||
2. Open `/mcp` and verify that `external-context-mem0` is connected.
|
||||
3. Verify that the server exposes only `context_search`.
|
||||
4. Search for a known, non-sensitive record in the fixed scope and confirm the
|
||||
expected result.
|
||||
5. If available, correlate the request with the upstream service access log
|
||||
without recording the credential or response body.
|
||||
|
||||
The Extension is retrieval-only. Provision a disposable test record through an
|
||||
administrator-approved upstream path if a known record is not already
|
||||
available; the Extension itself cannot create or delete memories.
|
||||
|
||||
## Auto Recall profile
|
||||
|
||||
Auto Recall retrieves external context before each eligible ordinary user
|
||||
submission. It is not enabled by the default Extension manifest. Installing or
|
||||
upgrading the Extension therefore does not automatically forward prompts or
|
||||
add a Hook process to existing turns.
|
||||
|
||||
Use a separate `schemaVersion: 3` instance file for Auto Recall:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 3,
|
||||
"autoRecall": {
|
||||
"repositoryRoot": "/workspace/my-repository"
|
||||
},
|
||||
"dialectPath": "/etc/qwen/external-context/memory.dialect.json",
|
||||
"endpoint": {
|
||||
"origin": "https://memory.example.com",
|
||||
"basePath": "",
|
||||
"allowInsecureHttp": false
|
||||
},
|
||||
"credentialEnv": "MEMORY_API_KEY",
|
||||
"scope": {
|
||||
"userId": "repository-memory"
|
||||
},
|
||||
"timeoutMs": 1500
|
||||
}
|
||||
```
|
||||
|
||||
The file must conform to
|
||||
[`schemas/auto-recall-instance-config.schema.json`](./schemas/auto-recall-instance-config.schema.json).
|
||||
`repositoryRoot` must be an existing absolute directory and cannot be a
|
||||
filesystem root. Auto Recall runs only when the Hook event working directory
|
||||
is that canonical directory or one of its descendants. The provider timeout
|
||||
must be from 100 through 5000 milliseconds.
|
||||
|
||||
Install a pinned package version at a stable administrator-controlled path,
|
||||
for example:
|
||||
|
||||
```bash
|
||||
npm install --prefix /opt/qwen/external-context-mem0-auto-recall \
|
||||
@qwen-code/external-context-mem0@x.y.z
|
||||
```
|
||||
|
||||
The corresponding bundle is
|
||||
`/opt/qwen/external-context-mem0-auto-recall/node_modules/@qwen-code/external-context-mem0/dist/auto-recall.js`.
|
||||
Copy the applicable unbranded Hook definition from
|
||||
[`examples/managed-auto-recall-user-settings-posix.json`](./examples/managed-auto-recall-user-settings-posix.json)
|
||||
or
|
||||
[`examples/managed-auto-recall-user-settings-windows.json`](./examples/managed-auto-recall-user-settings-windows.json)
|
||||
into the `settings.json` of an administrator-controlled `QWEN_HOME`. Replace
|
||||
both command placeholders with fixed absolute Node and `dist/auto-recall.js`
|
||||
paths. The Mem0 instance and dialect remain independent JSON files; Qwen
|
||||
settings contain only the Hook registration.
|
||||
|
||||
The Auto Recall profile must not enable this package's default Extension
|
||||
manifest or configure another on-demand Mem0 MCP server. Running both profiles
|
||||
could send two searches for one turn. Use the v2 Extension profile for
|
||||
on-demand `context_search`, or the v3 Hook-only profile for Auto Recall, never
|
||||
both in one Qwen process.
|
||||
|
||||
The Hook requires a non-empty `submitted_prompt` captured before prompt
|
||||
expansion. This includes supported interactive TUI submissions and headless
|
||||
CLI user turns (`qwen -p` and stream-json input, including SDK clients using
|
||||
that path). The field establishes prompt provenance, not a TUI-only origin.
|
||||
The Hook never falls back to the expanded `prompt`; events without
|
||||
`submitted_prompt` do not trigger retrieval.
|
||||
|
||||
Register this profile only in launchers where automatic retrieval is intended
|
||||
for all eligible inputs. To disable every Hook for an automation run, use
|
||||
`qwen --bare -p '…'`, `qwen --safe-mode -p '…'`, or set `disableAllHooks: true`
|
||||
in its managed settings before starting Qwen. These options disable all Hooks;
|
||||
`--bare` and `--safe-mode` also change which customizations are loaded. If
|
||||
automation needs other Hooks, give it a separate administrator-controlled
|
||||
`QWEN_HOME` without this Hook and omit the Auto Recall configuration and
|
||||
credential from that launcher's environment. The Hook itself does not
|
||||
distinguish TUI, SDK, or other transports supplying the field.
|
||||
|
||||
Instance and dialect paths must resolve to regular files. FIFOs and other
|
||||
special files are rejected before reading configuration.
|
||||
|
||||
Missing provenance, invalid configuration, a cwd outside the repository, an
|
||||
empty result, timeout, or provider failure returns `{}`. The executable flushes
|
||||
stdout and exits zero even if an aborted request retains open handles, allowing
|
||||
the user turn to continue after the pinned Node entry point starts.
|
||||
|
||||
Before sending the query, the Hook removes fenced code, the configured
|
||||
credential, and common secret shapes, then keeps at most 512 Unicode code
|
||||
points. This is best-effort reduction, not DLP. Each eligible invocation is a
|
||||
new process and reads the v3 instance and dialect once, so administrator file
|
||||
changes apply to the next eligible prompt. Environment and Hook registration
|
||||
changes require restarting Qwen Code.
|
||||
|
||||
## Runtime guarantees
|
||||
|
||||
- The on-demand model supplies only a normalized query of at most 2,000
|
||||
Unicode code points; Auto Recall derives at most 512 Unicode code points from
|
||||
`submitted_prompt`.
|
||||
- Requests have the configured timeout, do not retry, and do not follow
|
||||
redirects.
|
||||
- Responses are limited to 1 MiB before JSON parsing.
|
||||
- Results preserve upstream order and are capped at five entries, 1,000 Unicode
|
||||
characters per content field, and 4,000 UTF-16 code units in the serialized
|
||||
tool output.
|
||||
- Provider output is returned as `untrusted_external_context`; it is reference
|
||||
data, not trusted instructions.
|
||||
- Startup and request errors are redacted and do not expose paths, endpoints,
|
||||
queries, credentials, or upstream response bodies.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | What to check |
|
||||
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `configuration path must be absolute` | `QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG` must contain an absolute local path. |
|
||||
| `instance configuration is unavailable` | The instance path or named credential environment variable is missing, blank, unresolved, or unreadable. |
|
||||
| `instance configuration is invalid` | Validate the on-demand JSON, `schemaVersion: 2`, required fields, file size, and rejection of extra fields. |
|
||||
| Auto Recall returns `{}` | Validate the v3 schema, Hook paths, `submitted_prompt`, repository containment, credential, network, timeout, and provider response. Failures are intentionally silent. |
|
||||
| `dialect path must be absolute` | `dialectPath` must contain an absolute local path. |
|
||||
| `dialect configuration is unavailable` | Confirm that the dialect file exists and is readable by the Qwen process user. |
|
||||
| `dialect configuration is invalid` | Validate JSON, `dialectVersion: 1`, enum values, required fields, file size, and rejection of extra fields. |
|
||||
| `endpoint`, `path`, `scope`, or `dialect` is invalid | Check HTTPS or explicit private HTTP opt-in, static path rules, GET/JSON compatibility, and exact scope/`omit` consistency. |
|
||||
| MCP server connects but search returns an error | Check network routing, DNS, firewall or allowlist rules, upstream authentication, timeout, status code, and response shape. The Extension intentionally redacts upstream details. |
|
||||
|
||||
## Administrator acceptance checklist
|
||||
|
||||
- The endpoint is reachable from the Qwen host and its network allowlist is
|
||||
intentionally scoped.
|
||||
- Production traffic uses HTTPS; private HTTP is an explicit exception.
|
||||
- Both configuration paths are absolute, outside ordinary workspaces, and
|
||||
readable only by the intended administrator and process user.
|
||||
- No credential appears in configuration files, settings, repositories,
|
||||
scripts, logs, or shell history.
|
||||
- Every non-`omit` dialect scope has exactly one matching fixed instance value.
|
||||
- The deployment enables exactly one retrieval profile: v2 MCP or v3 Hook.
|
||||
- `/mcp` shows only `context_search` in the on-demand profile and no Mem0 MCP
|
||||
server in the Auto Recall profile.
|
||||
- A known-record search succeeds. On-demand file changes take effect after
|
||||
restart; Auto Recall file changes take effect on the next eligible prompt.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "exec '/absolute/path/to/node' '/administrator/path/to/external-context-mem0/dist/auto-recall.js'",
|
||||
"timeout": 8000,
|
||||
"name": "external-context-mem0-auto-recall",
|
||||
"statusMessage": "Retrieving external context"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"$version": 4
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "& 'C:\\Program Files\\nodejs\\node.exe' 'C:\\administrator\\external-context-mem0\\dist\\auto-recall.js'",
|
||||
"shell": "powershell",
|
||||
"timeout": 8000,
|
||||
"name": "external-context-mem0-auto-recall",
|
||||
"statusMessage": "Retrieving external context"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"$version": 4
|
||||
}
|
||||
@@ -12,16 +12,18 @@
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run clean && esbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --outfile=dist/main.js",
|
||||
"build": "npm run clean && esbuild src/main.ts src/auto-recall.ts --bundle --platform=node --target=node22 --format=esm --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --outdir=dist",
|
||||
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"test:ci": "vitest run --config vitest.config.ts",
|
||||
"test": "npm run build && vitest run --config vitest.config.ts",
|
||||
"test:ci": "npm run build && vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
"dist/main.js",
|
||||
"dist/auto-recall.js",
|
||||
"schemas",
|
||||
"examples",
|
||||
"qwen-extension.json",
|
||||
"README.md"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Mem0 External Context auto-recall instance configuration v3",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"autoRecall",
|
||||
"dialectPath",
|
||||
"endpoint",
|
||||
"credentialEnv",
|
||||
"scope",
|
||||
"timeoutMs"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": 3
|
||||
},
|
||||
"autoRecall": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["repositoryRoot"],
|
||||
"properties": {
|
||||
"repositoryRoot": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialectPath": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["origin"],
|
||||
"properties": {
|
||||
"origin": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2048
|
||||
},
|
||||
"basePath": {
|
||||
"type": "string",
|
||||
"maxLength": 512,
|
||||
"default": ""
|
||||
},
|
||||
"allowInsecureHttp": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"credentialEnv": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Z_][A-Z0-9_]*$"
|
||||
},
|
||||
"scope": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"userId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
},
|
||||
"agentId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
},
|
||||
"appId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"timeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, parse } from 'node:path';
|
||||
import { Ajv, type AnySchema } from 'ajv';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isWithinRepository,
|
||||
loadAutoRecallRuntimeConfiguration,
|
||||
loadRuntimeConfiguration,
|
||||
} from './config.js';
|
||||
import { parseAutoRecallInstanceConfig } from './schemas.js';
|
||||
import type { DialectV1, InstanceConfigV2 } from './types.js';
|
||||
|
||||
const MAX_CONFIG_BYTES = 64 * 1024;
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('Mem0 Auto Recall configuration', () => {
|
||||
it('accepts the canonical v3 schema and resolves the repository root', async () => {
|
||||
const fixture = await readFixture();
|
||||
const directory = await makeTemporaryDirectory();
|
||||
const instance = autoRecallInstance(fixture.instance, directory);
|
||||
const schema = await readJson(
|
||||
'../schemas/auto-recall-instance-config.schema.json',
|
||||
);
|
||||
const validate = new Ajv({ allErrors: true, strict: true }).compile(
|
||||
schema as AnySchema,
|
||||
);
|
||||
|
||||
expect(validate(instance)).toBe(true);
|
||||
expect(parseAutoRecallInstanceConfig(instance).schemaVersion).toBe(3);
|
||||
const { configPath } = await writeRuntimeConfiguration(
|
||||
instance,
|
||||
fixture.dialect,
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadAutoRecallRuntimeConfiguration({
|
||||
env: runtimeEnvironment(configPath),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
instance: {
|
||||
schemaVersion: 3,
|
||||
autoRecall: { repositoryRoot: await realpath(directory) },
|
||||
},
|
||||
dialect: { id: fixture.dialect.id },
|
||||
credential: 'runtime-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects on-demand, unknown, and extended configuration shapes', async () => {
|
||||
const fixture = await readFixture();
|
||||
const directory = await makeTemporaryDirectory();
|
||||
const instance = autoRecallInstance(fixture.instance, directory);
|
||||
|
||||
expect(() => parseAutoRecallInstanceConfig(fixture.instance)).toThrow(
|
||||
'auto-recall configuration is invalid',
|
||||
);
|
||||
expect(() =>
|
||||
parseAutoRecallInstanceConfig({ ...instance, schemaVersion: 4 }),
|
||||
).toThrow('auto-recall configuration is invalid');
|
||||
expect(() =>
|
||||
parseAutoRecallInstanceConfig({
|
||||
...instance,
|
||||
autoRecall: {
|
||||
...instance.autoRecall,
|
||||
dynamicScope: true,
|
||||
},
|
||||
}),
|
||||
).toThrow('auto-recall configuration is invalid');
|
||||
|
||||
const autoRecall = await writeRuntimeConfiguration(
|
||||
instance,
|
||||
fixture.dialect,
|
||||
);
|
||||
await expect(
|
||||
loadRuntimeConfiguration({
|
||||
env: runtimeEnvironment(autoRecall.configPath),
|
||||
}),
|
||||
).rejects.toThrow('instance configuration is invalid');
|
||||
|
||||
const onDemand = await writeRuntimeConfiguration(
|
||||
fixture.instance,
|
||||
fixture.dialect,
|
||||
);
|
||||
await expect(
|
||||
loadAutoRecallRuntimeConfiguration({
|
||||
env: runtimeEnvironment(onDemand.configPath),
|
||||
}),
|
||||
).rejects.toThrow('auto-recall configuration is invalid');
|
||||
});
|
||||
|
||||
it('requires an Auto Recall timeout from 100 through 5000 milliseconds', async () => {
|
||||
const fixture = await readFixture();
|
||||
const repositoryRoot = await makeTemporaryDirectory();
|
||||
const instance = autoRecallInstance(fixture.instance, repositoryRoot);
|
||||
|
||||
for (const timeoutMs of [100, 5000]) {
|
||||
expect(
|
||||
parseAutoRecallInstanceConfig({ ...instance, timeoutMs }).timeoutMs,
|
||||
).toBe(timeoutMs);
|
||||
}
|
||||
for (const timeoutMs of [99, 5001]) {
|
||||
expect(() =>
|
||||
parseAutoRecallInstanceConfig({ ...instance, timeoutMs }),
|
||||
).toThrow('auto-recall configuration is invalid');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects relative, missing, and filesystem-root repositories', async () => {
|
||||
const fixture = await readFixture();
|
||||
const directory = await makeTemporaryDirectory();
|
||||
for (const repositoryRoot of [
|
||||
'relative/repository',
|
||||
join(directory, 'missing'),
|
||||
parse(process.cwd()).root,
|
||||
]) {
|
||||
const instance = autoRecallInstance(fixture.instance, repositoryRoot);
|
||||
const { configPath } = await writeRuntimeConfiguration(
|
||||
instance,
|
||||
fixture.dialect,
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadAutoRecallRuntimeConfiguration({
|
||||
env: runtimeEnvironment(configPath),
|
||||
}),
|
||||
).rejects.toThrow('repository root is invalid');
|
||||
}
|
||||
});
|
||||
|
||||
it('allows canonical descendants and rejects paths outside the repository', async () => {
|
||||
const repositoryRoot = await makeTemporaryDirectory();
|
||||
const canonicalRoot = await realpath(repositoryRoot);
|
||||
const child = join(repositoryRoot, 'child');
|
||||
const outside = await makeTemporaryDirectory();
|
||||
await mkdir(child);
|
||||
|
||||
await expect(isWithinRepository(canonicalRoot, child)).resolves.toBe(true);
|
||||
await expect(isWithinRepository(canonicalRoot, outside)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(
|
||||
isWithinRepository(canonicalRoot, 'relative/repository'),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects a descendant path that resolves outside through a symbolic link',
|
||||
async () => {
|
||||
const repositoryRoot = await makeTemporaryDirectory();
|
||||
const canonicalRoot = await realpath(repositoryRoot);
|
||||
const outside = await makeTemporaryDirectory();
|
||||
const escaped = join(repositoryRoot, 'escaped');
|
||||
await symlink(outside, escaped, 'dir');
|
||||
|
||||
await expect(isWithinRepository(canonicalRoot, escaped)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the v3 instance file bounded', async () => {
|
||||
const fixture = await readFixture();
|
||||
const repositoryRoot = await makeTemporaryDirectory();
|
||||
const instance = autoRecallInstance(fixture.instance, repositoryRoot);
|
||||
const exact = await writeRuntimeConfiguration(instance, fixture.dialect, {
|
||||
instanceSource: (configured) =>
|
||||
JSON.stringify(configured).padEnd(MAX_CONFIG_BYTES, ' '),
|
||||
});
|
||||
await expect(
|
||||
loadAutoRecallRuntimeConfiguration({
|
||||
env: runtimeEnvironment(exact.configPath),
|
||||
}),
|
||||
).resolves.toMatchObject({ instance: { schemaVersion: 3 } });
|
||||
|
||||
const oversized = await writeRuntimeConfiguration(
|
||||
instance,
|
||||
fixture.dialect,
|
||||
{
|
||||
instanceSource: (configured) =>
|
||||
JSON.stringify(configured).padEnd(MAX_CONFIG_BYTES + 1, ' '),
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
loadAutoRecallRuntimeConfiguration({
|
||||
env: runtimeEnvironment(oversized.configPath),
|
||||
}),
|
||||
).rejects.toThrow('instance configuration is invalid');
|
||||
});
|
||||
});
|
||||
|
||||
interface SyntheticFixture {
|
||||
instance: InstanceConfigV2;
|
||||
dialect: DialectV1;
|
||||
}
|
||||
|
||||
interface RuntimeWriteOptions {
|
||||
instanceSource?: (instance: unknown) => string;
|
||||
}
|
||||
|
||||
function autoRecallInstance(
|
||||
instance: InstanceConfigV2,
|
||||
repositoryRoot: string,
|
||||
) {
|
||||
return {
|
||||
...structuredClone(instance),
|
||||
schemaVersion: 3 as const,
|
||||
autoRecall: { repositoryRoot },
|
||||
timeoutMs: 1500,
|
||||
};
|
||||
}
|
||||
|
||||
async function readFixture(): Promise<SyntheticFixture> {
|
||||
return (await readJson(
|
||||
'../test/fixtures/synthetic-filtered-post-v1.json',
|
||||
)) as SyntheticFixture;
|
||||
}
|
||||
|
||||
async function readJson(relativePath: string): Promise<unknown> {
|
||||
return JSON.parse(
|
||||
await readFile(new URL(relativePath, import.meta.url), 'utf8'),
|
||||
) as unknown;
|
||||
}
|
||||
|
||||
async function writeRuntimeConfiguration(
|
||||
instance: unknown,
|
||||
dialect: unknown,
|
||||
options: RuntimeWriteOptions = {},
|
||||
): Promise<{ configPath: string }> {
|
||||
const directory = await makeTemporaryDirectory();
|
||||
const configPath = join(directory, 'instance.json');
|
||||
const dialectPath = join(directory, 'dialect.json');
|
||||
const configuredInstance = {
|
||||
...(instance as Record<string, unknown>),
|
||||
dialectPath,
|
||||
};
|
||||
await writeFile(dialectPath, JSON.stringify(dialect));
|
||||
await writeFile(
|
||||
configPath,
|
||||
options.instanceSource?.(configuredInstance) ??
|
||||
JSON.stringify(configuredInstance),
|
||||
);
|
||||
return { configPath };
|
||||
}
|
||||
|
||||
async function makeTemporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'qwen-mem0-auto-config-'));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function runtimeEnvironment(configPath: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG: configPath,
|
||||
SYNTHETIC_MEMORY_TOKEN: 'runtime-token',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
createServer as createTcpServer,
|
||||
type AddressInfo,
|
||||
type Server,
|
||||
type Socket,
|
||||
} from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const servers: Server[] = [];
|
||||
const sockets: Socket[] = [];
|
||||
const hookBundle = new URL('../dist/auto-recall.js', import.meta.url);
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of sockets.splice(0)) socket.destroy();
|
||||
await Promise.all(
|
||||
servers
|
||||
.splice(0)
|
||||
.map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
),
|
||||
);
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe('Mem0 Auto Recall local provider', () => {
|
||||
it('runs the real entry point and fails open without stderr', async () => {
|
||||
await expect(runAutoRecallProcess('{')).resolves.toEqual({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
stdout: '{}',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
runAutoRecallProcess(
|
||||
JSON.stringify({
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
submitted_prompt: 'question',
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
{
|
||||
QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG:
|
||||
'/missing/administrator/instance.json',
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
stdout: '{}',
|
||||
stderr: '',
|
||||
});
|
||||
}, 20000);
|
||||
|
||||
it('executes the v3 configuration, dialect, request engine, and Hook envelope', async () => {
|
||||
const requests: Array<{
|
||||
authorization: string | undefined;
|
||||
body: unknown;
|
||||
path: string | undefined;
|
||||
}> = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
requests.push({
|
||||
authorization: request.headers.authorization,
|
||||
body: JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown,
|
||||
path: request.url,
|
||||
});
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{ id: 'memory-1', memory: 'Use the bounded request engine.' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const { directory, env } = await makeRuntime(`http://127.0.0.1:${port}`);
|
||||
|
||||
const result = await runAutoRecallProcess(
|
||||
JSON.stringify({
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'expanded prompt must not leave the process',
|
||||
submitted_prompt: 'deployment policy API_KEY=remove-me',
|
||||
cwd: directory,
|
||||
}),
|
||||
env,
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.stderr).toBe('');
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
authorization: 'Token runtime-token',
|
||||
path: '/v2/memories/search/',
|
||||
body: {
|
||||
query: 'deployment policy',
|
||||
filters: { user_id: 'repository-memory' },
|
||||
limit: 5,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(requests)).not.toContain(
|
||||
'expanded prompt must not leave the process',
|
||||
);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
additionalContext: expect.stringContaining(
|
||||
'Use the bounded request engine.',
|
||||
),
|
||||
},
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
it('exits successfully after a provider timeout during a stalled TLS handshake', async () => {
|
||||
let connected = false;
|
||||
const server = createTcpServer((socket) => {
|
||||
connected = true;
|
||||
sockets.push(socket);
|
||||
});
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const { directory, env } = await makeRuntime(`https://127.0.0.1:${port}`);
|
||||
|
||||
const startedAt = performance.now();
|
||||
const result = await runAutoRecallProcess(
|
||||
JSON.stringify({
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
submitted_prompt: 'deployment policy',
|
||||
cwd: directory,
|
||||
}),
|
||||
env,
|
||||
);
|
||||
|
||||
expect(connected).toBe(true);
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
stdout: '{}',
|
||||
stderr: '',
|
||||
});
|
||||
expect(performance.now() - startedAt).toBeLessThan(5000);
|
||||
}, 10000);
|
||||
|
||||
it.each(['token', 'api_key', 'password', 'secret'])(
|
||||
'bounds repeated %s near misses in the bundle with a process deadline',
|
||||
(keyword) => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--input-type=module',
|
||||
'--eval',
|
||||
`
|
||||
import { createAutoRecallQuery } from ${JSON.stringify(hookBundle.href)};
|
||||
const startedAt = performance.now();
|
||||
const query = createAutoRecallQuery(${JSON.stringify(keyword)}.repeat(Math.floor(4096 / ${keyword.length})), '');
|
||||
process.stdout.write(JSON.stringify({ query: query ?? null, elapsedMs: performance.now() - startedAt }));
|
||||
`,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 8000, killSignal: 'SIGKILL' },
|
||||
);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
const output = JSON.parse(result.stdout) as {
|
||||
query: string | null;
|
||||
elapsedMs: number;
|
||||
};
|
||||
expect(output.query).toBeNull();
|
||||
expect(output.elapsedMs).toBeLessThan(2000);
|
||||
expect(result.stderr).toBe('');
|
||||
},
|
||||
10000,
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32').each(['instance', 'dialect'])(
|
||||
'rejects a stalled %s FIFO and exits successfully',
|
||||
async (kind) => {
|
||||
const { directory, env } = await makeRuntime(
|
||||
'https://memory.example.com',
|
||||
);
|
||||
const fifo = join(directory, 'blocked.json');
|
||||
expect(spawnSync('mkfifo', [fifo]).status).toBe(0);
|
||||
if (kind === 'instance') {
|
||||
env.QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG = fifo;
|
||||
} else {
|
||||
const configPath = env.QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG;
|
||||
const config = JSON.parse(await readFile(configPath, 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ ...config, dialectPath: fifo }),
|
||||
);
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
const result = await runAutoRecallProcess(
|
||||
JSON.stringify({
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
submitted_prompt: 'deployment policy',
|
||||
cwd: directory,
|
||||
}),
|
||||
env,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
stdout: '{}',
|
||||
stderr: '',
|
||||
});
|
||||
expect(performance.now() - startedAt).toBeLessThan(5000);
|
||||
},
|
||||
10000,
|
||||
);
|
||||
});
|
||||
|
||||
async function runAutoRecallProcess(
|
||||
input: string,
|
||||
envOverrides: NodeJS.ProcessEnv = {},
|
||||
): Promise<{
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const env = { ...process.env };
|
||||
delete env['QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG'];
|
||||
const child = spawn(process.execPath, [fileURLToPath(hookBundle)], {
|
||||
env: { ...env, ...envOverrides, NODE_NO_WARNINGS: '1' },
|
||||
killSignal: 'SIGKILL',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 8000,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.stdin.end(input);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('close', (exitCode, signal) => {
|
||||
resolve({ exitCode, signal, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTemporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'qwen-mem0-auto-e2e-'));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function makeRuntime(origin: string) {
|
||||
const directory = await makeTemporaryDirectory();
|
||||
const dialectPath = join(directory, 'dialect.json');
|
||||
const configPath = join(directory, 'instance.json');
|
||||
await writeFile(
|
||||
dialectPath,
|
||||
JSON.stringify({
|
||||
dialectVersion: 1,
|
||||
id: 'synthetic-auto-recall-v1',
|
||||
auth: 'authorization-token',
|
||||
search: {
|
||||
method: 'POST',
|
||||
path: '/v2/memories/search/',
|
||||
queryLocation: 'json',
|
||||
userIdLocation: 'json.filters',
|
||||
agentIdLocation: 'omit',
|
||||
appIdLocation: 'omit',
|
||||
limitField: 'limit',
|
||||
},
|
||||
response: {
|
||||
collection: 'results',
|
||||
idField: 'id',
|
||||
contentField: 'memory',
|
||||
titleField: 'omit',
|
||||
uriField: 'omit',
|
||||
scoreField: 'omit',
|
||||
updatedAtField: 'omit',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
autoRecall: { repositoryRoot: directory },
|
||||
dialectPath,
|
||||
endpoint: {
|
||||
origin,
|
||||
basePath: '',
|
||||
allowInsecureHttp: origin.startsWith('http:'),
|
||||
},
|
||||
credentialEnv: 'SYNTHETIC_MEMORY_TOKEN',
|
||||
scope: { userId: 'repository-memory' },
|
||||
timeoutMs: 1500,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
directory,
|
||||
env: {
|
||||
QWEN_EXTERNAL_CONTEXT_MEM0_CONFIG: configPath,
|
||||
SYNTHETIC_MEMORY_TOKEN: 'runtime-token',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { PassThrough, Readable } from 'node:stream';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const loadAutoRecallRuntimeConfiguration = vi.hoisted(() => vi.fn());
|
||||
const isWithinRepository = vi.hoisted(() => vi.fn());
|
||||
const search = vi.hoisted(() => vi.fn());
|
||||
const createRequestEngine = vi.hoisted(() => vi.fn(() => search));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
loadAutoRecallRuntimeConfiguration,
|
||||
isWithinRepository,
|
||||
}));
|
||||
vi.mock('./request-engine.js', () => ({ createRequestEngine }));
|
||||
|
||||
beforeEach(() => {
|
||||
loadAutoRecallRuntimeConfiguration.mockReset().mockResolvedValue(runtime());
|
||||
isWithinRepository.mockReset().mockResolvedValue(true);
|
||||
search.mockReset();
|
||||
createRequestEngine.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('createAutoRecallQuery', () => {
|
||||
it('removes code and common credential shapes', async () => {
|
||||
const { createAutoRecallQuery } = await import('./auto-recall.js');
|
||||
const query = createAutoRecallQuery(
|
||||
[
|
||||
'How should deployment work?',
|
||||
'```sh',
|
||||
'curl -H "Authorization: Bearer code-secret"',
|
||||
'```',
|
||||
'API_KEY=assignment-secret',
|
||||
'Bearer bearer-secret',
|
||||
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIn0.signature123',
|
||||
'provider-secret-value',
|
||||
].join('\n'),
|
||||
'provider-secret-value',
|
||||
);
|
||||
|
||||
expect(query).toBe('How should deployment work?');
|
||||
});
|
||||
|
||||
it('keeps benign prose and limits output to 512 Unicode code points', async () => {
|
||||
const { createAutoRecallQuery } = await import('./auto-recall.js');
|
||||
|
||||
expect(
|
||||
createAutoRecallQuery(
|
||||
'readme: token refresh flow, where is it documented?',
|
||||
'',
|
||||
),
|
||||
).toBe('readme: token refresh flow, where is it documented?');
|
||||
expect(createAutoRecallQuery('🙂'.repeat(513), '')).toBe('🙂'.repeat(512));
|
||||
expect(
|
||||
createAutoRecallQuery('```text\nonly code\n```', ''),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'my_api_key_suffix=remove-me',
|
||||
'"service.token": "remove me"',
|
||||
"'db.password' = 'remove me'",
|
||||
'client-secret: remove-me',
|
||||
'config: token=remove-me',
|
||||
])('removes secret assignments in %s', async (assignment) => {
|
||||
const { createAutoRecallQuery } = await import('./auto-recall.js');
|
||||
|
||||
const query = createAutoRecallQuery(`deployment ${assignment}`, '');
|
||||
|
||||
expect(query).not.toContain('remove');
|
||||
expect(query).toContain('deployment');
|
||||
});
|
||||
|
||||
it('bounds sanitizer work before applying credential patterns', async () => {
|
||||
const { createAutoRecallQuery } = await import('./auto-recall.js');
|
||||
const startedAt = Date.now();
|
||||
const query = createAutoRecallQuery('a-'.repeat(25_000), 'secret');
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(2000);
|
||||
expect(query ?? '').not.toContain('a-a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAutoRecall', () => {
|
||||
it('retrieves once from submitted prompt provenance', async () => {
|
||||
search.mockResolvedValue([
|
||||
{ id: '<one>', content: '<policy>repository</policy>' },
|
||||
]);
|
||||
const { runAutoRecall } = await import('./auto-recall.js');
|
||||
|
||||
const output = await runAutoRecall({
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'expanded prompt that must be ignored',
|
||||
submitted_prompt: ' deployment\npolicy ',
|
||||
cwd: '/repository/child',
|
||||
});
|
||||
|
||||
expect(loadAutoRecallRuntimeConfiguration).toHaveBeenCalledOnce();
|
||||
expect(isWithinRepository).toHaveBeenCalledWith(
|
||||
'/repository',
|
||||
'/repository/child',
|
||||
);
|
||||
expect(createRequestEngine).toHaveBeenCalledOnce();
|
||||
expect(search).toHaveBeenCalledOnce();
|
||||
expect(search).toHaveBeenCalledWith({
|
||||
query: 'deployment policy',
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(output).toMatchObject({
|
||||
hookSpecificOutput: { hookEventName: 'UserPromptSubmit' },
|
||||
});
|
||||
const context =
|
||||
'hookSpecificOutput' in output
|
||||
? output.hookSpecificOutput.additionalContext
|
||||
: '';
|
||||
expect(context).not.toContain('<');
|
||||
expect(JSON.parse(context)).toMatchObject({
|
||||
untrusted_external_context: {
|
||||
items: [{ id: '<one>', content: '<policy>repository</policy>' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inspect the expanded prompt field', async () => {
|
||||
search.mockResolvedValue([]);
|
||||
const { runAutoRecall } = await import('./auto-recall.js');
|
||||
const input = {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
submitted_prompt: 'question',
|
||||
cwd: '/repository',
|
||||
};
|
||||
Object.defineProperty(input, 'prompt', {
|
||||
get() {
|
||||
throw new Error('expanded prompt was read');
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runAutoRecall(input)).resolves.toEqual({});
|
||||
expect(search).toHaveBeenCalledWith({
|
||||
query: 'question',
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{ hook_event_name: 'ToolResult', submitted_prompt: 'question', cwd: '/' },
|
||||
{ hook_event_name: 'UserPromptSubmit', prompt: 'expanded', cwd: '/' },
|
||||
{
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
submitted_prompt: ' ',
|
||||
cwd: '/',
|
||||
},
|
||||
])('skips unsupported input before loading configuration', async (input) => {
|
||||
const { runAutoRecall } = await import('./auto-recall.js');
|
||||
|
||||
await expect(runAutoRecall(input)).resolves.toEqual({});
|
||||
expect(loadAutoRecallRuntimeConfiguration).not.toHaveBeenCalled();
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips a working directory outside the configured repository', async () => {
|
||||
isWithinRepository.mockResolvedValue(false);
|
||||
const { runAutoRecall } = await import('./auto-recall.js');
|
||||
|
||||
await expect(runAutoRecall(validInput())).resolves.toEqual({});
|
||||
expect(createRequestEngine).not.toHaveBeenCalled();
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns no context for an empty provider result', async () => {
|
||||
search.mockResolvedValue([]);
|
||||
const { runAutoRecall } = await import('./auto-recall.js');
|
||||
|
||||
await expect(runAutoRecall(validInput())).resolves.toEqual({});
|
||||
expect(search).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('aborts an in-flight request at the configured provider timeout', async () => {
|
||||
loadAutoRecallRuntimeConfiguration.mockResolvedValue({
|
||||
...runtime(),
|
||||
instance: { ...runtime().instance, timeoutMs: 100 },
|
||||
});
|
||||
let providerSignal: AbortSignal | undefined;
|
||||
search.mockImplementation(
|
||||
({ signal }: { signal: AbortSignal }) =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
providerSignal = signal;
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const { runAutoRecallCli } = await import('./auto-recall.js');
|
||||
|
||||
await expect(
|
||||
captureCli(runAutoRecallCli, JSON.stringify(validInput())),
|
||||
).resolves.toBe('{}');
|
||||
expect(providerSignal?.aborted).toBe(true);
|
||||
expect(search).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAutoRecallCli', () => {
|
||||
it('fails open for malformed input and configuration failures', async () => {
|
||||
const { runAutoRecallCli } = await import('./auto-recall.js');
|
||||
const malformed = await captureCli(runAutoRecallCli, '{');
|
||||
expect(malformed).toBe('{}');
|
||||
expect(loadAutoRecallRuntimeConfiguration).not.toHaveBeenCalled();
|
||||
|
||||
loadAutoRecallRuntimeConfiguration.mockRejectedValue(
|
||||
new Error('/secret/path token=secret'),
|
||||
);
|
||||
const failed = await captureCli(
|
||||
runAutoRecallCli,
|
||||
JSON.stringify(validInput()),
|
||||
);
|
||||
expect(failed).toBe('{}');
|
||||
});
|
||||
|
||||
it('accepts exactly 1 MiB and rejects one additional byte before configuration', async () => {
|
||||
const { runAutoRecallCli } = await import('./auto-recall.js');
|
||||
search.mockResolvedValue([]);
|
||||
const baseInput = JSON.stringify({ ...validInput(), padding: '' });
|
||||
const exactInput = JSON.stringify({
|
||||
...validInput(),
|
||||
padding: 'x'.repeat(1024 * 1024 - Buffer.byteLength(baseInput)),
|
||||
});
|
||||
|
||||
expect(Buffer.byteLength(exactInput)).toBe(1024 * 1024);
|
||||
await expect(captureCli(runAutoRecallCli, exactInput)).resolves.toBe('{}');
|
||||
expect(loadAutoRecallRuntimeConfiguration).toHaveBeenCalledOnce();
|
||||
loadAutoRecallRuntimeConfiguration.mockClear();
|
||||
|
||||
const oversized = `${exactInput} `;
|
||||
expect(Buffer.byteLength(oversized)).toBe(1024 * 1024 + 1);
|
||||
await expect(captureCli(runAutoRecallCli, oversized)).resolves.toBe('{}');
|
||||
|
||||
expect(loadAutoRecallRuntimeConfiguration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns within the internal wall-clock budget', async () => {
|
||||
vi.useFakeTimers();
|
||||
let providerSignal: AbortSignal | undefined;
|
||||
search.mockImplementation(
|
||||
({ signal }: { signal: AbortSignal }) =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
providerSignal = signal;
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const { runAutoRecallCli } = await import('./auto-recall.js');
|
||||
const output = { value: '' };
|
||||
const work = runAutoRecallCli(
|
||||
Readable.from([JSON.stringify(validInput())]),
|
||||
{ write: (value) => (output.value += value) },
|
||||
{},
|
||||
);
|
||||
await vi.waitFor(() => expect(search).toHaveBeenCalledOnce());
|
||||
await vi.advanceTimersByTimeAsync(6500);
|
||||
await work;
|
||||
|
||||
expect(output.value).toBe('{}');
|
||||
expect(providerSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('closes stalled input at the internal wall-clock budget', async () => {
|
||||
vi.useFakeTimers();
|
||||
const input = new PassThrough();
|
||||
const output = { value: '' };
|
||||
const { runAutoRecallCli } = await import('./auto-recall.js');
|
||||
const work = runAutoRecallCli(input, {
|
||||
write: (value) => (output.value += value),
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6500);
|
||||
await work;
|
||||
|
||||
expect(input.destroyed).toBe(true);
|
||||
expect(output.value).toBe('{}');
|
||||
expect(loadAutoRecallRuntimeConfiguration).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function runtime() {
|
||||
return {
|
||||
instance: {
|
||||
schemaVersion: 3,
|
||||
autoRecall: { repositoryRoot: '/repository' },
|
||||
dialectPath: '/dialect.json',
|
||||
endpoint: {
|
||||
origin: 'https://memory.example.com',
|
||||
basePath: '',
|
||||
allowInsecureHttp: false,
|
||||
},
|
||||
credentialEnv: 'MEMORY_TOKEN',
|
||||
scope: { userId: 'repository-memory' },
|
||||
timeoutMs: 1500,
|
||||
},
|
||||
dialect: {},
|
||||
credential: 'provider-secret-value',
|
||||
};
|
||||
}
|
||||
|
||||
function validInput() {
|
||||
return {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
submitted_prompt: 'question',
|
||||
cwd: '/repository',
|
||||
};
|
||||
}
|
||||
|
||||
async function captureCli(
|
||||
run: (
|
||||
input: Readable,
|
||||
output: { write(value: string): unknown },
|
||||
env: NodeJS.ProcessEnv,
|
||||
) => Promise<void>,
|
||||
input: string,
|
||||
): Promise<string> {
|
||||
let output = '';
|
||||
await run(
|
||||
Readable.from([input]),
|
||||
{ write: (value) => (output += value) },
|
||||
{},
|
||||
);
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
isWithinRepository,
|
||||
loadAutoRecallRuntimeConfiguration,
|
||||
} from './config.js';
|
||||
import { renderResult } from './profile.js';
|
||||
import { createRequestEngine } from './request-engine.js';
|
||||
|
||||
const MAX_HOOK_INPUT_BYTES = 1024 * 1024;
|
||||
const MAX_AUTO_QUERY_CHARACTERS = 512;
|
||||
const MAX_SANITIZER_INPUT_CHARACTERS = 4096;
|
||||
const HOOK_WALL_CLOCK_TIMEOUT_MS = 6500;
|
||||
// Check each identifier once, without overlapping scans around the keyword.
|
||||
const SECRET_ASSIGNMENT_PATTERN =
|
||||
/(?<![A-Za-z0-9_.-])["']?(?=[A-Za-z0-9_.-]*(?:api[_-]?key|token|password|secret))[A-Za-z0-9_.-]+["']?[^\S\r\n]*[:=][^\S\r\n]*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)?/gi;
|
||||
|
||||
interface HookInput {
|
||||
submittedPrompt: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
type HookOutput =
|
||||
| Record<string, never>
|
||||
| {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'UserPromptSubmit';
|
||||
additionalContext: string;
|
||||
};
|
||||
};
|
||||
|
||||
type HookInputStream = AsyncIterable<string | Uint8Array> & {
|
||||
destroy?(): void;
|
||||
};
|
||||
|
||||
interface HookOutputStream {
|
||||
write(value: string): unknown;
|
||||
}
|
||||
|
||||
export async function runAutoRecall(
|
||||
value: unknown,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
signal: AbortSignal = new AbortController().signal,
|
||||
): Promise<HookOutput> {
|
||||
const input = parseHookInput(value);
|
||||
if (!input) return {};
|
||||
|
||||
const runtime = await loadAutoRecallRuntimeConfiguration({ env });
|
||||
if (
|
||||
!(await isWithinRepository(
|
||||
runtime.instance.autoRecall.repositoryRoot,
|
||||
input.cwd,
|
||||
))
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const query = createAutoRecallQuery(
|
||||
input.submittedPrompt,
|
||||
runtime.credential,
|
||||
);
|
||||
if (!query) return {};
|
||||
|
||||
const items = await createRequestEngine(runtime)({
|
||||
query,
|
||||
signal: AbortSignal.any([
|
||||
signal,
|
||||
AbortSignal.timeout(runtime.instance.timeoutMs),
|
||||
]),
|
||||
});
|
||||
if (items.length === 0) return {};
|
||||
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
additionalContext: renderResult(items).text,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createAutoRecallQuery(
|
||||
submittedPrompt: string,
|
||||
credential: string,
|
||||
): string | undefined {
|
||||
let query = submittedPrompt;
|
||||
if (query.length > MAX_SANITIZER_INPUT_CHARACTERS) {
|
||||
query = Array.from(query).slice(0, MAX_SANITIZER_INPUT_CHARACTERS).join('');
|
||||
}
|
||||
query = query
|
||||
.replace(/(```|~~~)[\s\S]*?\1/g, ' ')
|
||||
.replace(/(?:```|~~~)[\s\S]*$/g, ' ');
|
||||
if (credential) query = query.replaceAll(credential, ' ');
|
||||
query = query
|
||||
.replace(SECRET_ASSIGNMENT_PATTERN, ' ')
|
||||
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, ' ')
|
||||
.replace(
|
||||
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
|
||||
' ',
|
||||
)
|
||||
.replace(/\b[A-Za-z0-9_-]{32,}\b/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!query) return undefined;
|
||||
return Array.from(query).slice(0, MAX_AUTO_QUERY_CHARACTERS).join('');
|
||||
}
|
||||
|
||||
export async function runAutoRecallCli(
|
||||
inputStream: HookInputStream = process.stdin,
|
||||
outputStream: HookOutputStream = process.stdout,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = new Promise<HookOutput>((resolveTimeout) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
inputStream.destroy?.();
|
||||
resolveTimeout({});
|
||||
}, HOOK_WALL_CLOCK_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
const work = (async (): Promise<HookOutput> => {
|
||||
const input = await readHookInput(inputStream);
|
||||
return input === undefined
|
||||
? {}
|
||||
: runAutoRecall(input, env, controller.signal);
|
||||
})().catch(() => ({}));
|
||||
|
||||
const output = await Promise.race([work, timedOut]);
|
||||
controller.abort();
|
||||
if (timeout !== undefined) clearTimeout(timeout);
|
||||
outputStream.write(JSON.stringify(output));
|
||||
}
|
||||
|
||||
async function readHookInput(
|
||||
inputStream: HookInputStream,
|
||||
): Promise<unknown | undefined> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for await (const source of inputStream) {
|
||||
const chunk = Buffer.from(source);
|
||||
total += chunk.byteLength;
|
||||
if (total > MAX_HOOK_INPUT_BYTES) return undefined;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHookInput(value: unknown): HookInput | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const submittedPrompt = value['submitted_prompt'];
|
||||
const cwd = value['cwd'];
|
||||
if (
|
||||
value['hook_event_name'] !== 'UserPromptSubmit' ||
|
||||
typeof submittedPrompt !== 'string' ||
|
||||
submittedPrompt.trim().length === 0 ||
|
||||
typeof cwd !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { submittedPrompt, cwd };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function isDirectEntryPoint(): Promise<boolean> {
|
||||
const entryPoint = process.argv[1];
|
||||
if (entryPoint === undefined) return false;
|
||||
try {
|
||||
return (
|
||||
(await realpath(fileURLToPath(import.meta.url))) ===
|
||||
(await realpath(resolve(entryPoint)))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (await isDirectEntryPoint()) {
|
||||
await runAutoRecallCli().catch(() => undefined);
|
||||
// Aborted fetches or filesystem reads can retain handles after output is ready.
|
||||
process.stdout.end(() => process.exit(0));
|
||||
}
|
||||
@@ -4,16 +4,20 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { open, type FileHandle } from 'node:fs/promises';
|
||||
import { isAbsolute, parse, relative, sep } from 'node:path';
|
||||
import { constants } from 'node:fs';
|
||||
import { open, realpath, stat, type FileHandle } from 'node:fs/promises';
|
||||
import {
|
||||
ConfigurationError,
|
||||
parseAutoRecallInstanceConfig,
|
||||
parseDialect,
|
||||
parseInstanceConfig,
|
||||
} from './schemas.js';
|
||||
import type {
|
||||
AutoRecallRuntimeConfiguration,
|
||||
DialectV1,
|
||||
InstanceConfigV2,
|
||||
InstanceConfigV3,
|
||||
RuntimeConfiguration,
|
||||
ScopeLocation,
|
||||
} from './types.js';
|
||||
@@ -25,6 +29,30 @@ export async function loadRuntimeConfiguration(
|
||||
options: { env?: NodeJS.ProcessEnv } = {},
|
||||
): Promise<RuntimeConfiguration> {
|
||||
const env = options.env ?? process.env;
|
||||
return loadConfiguration(env, parseInstanceConfig);
|
||||
}
|
||||
|
||||
export async function loadAutoRecallRuntimeConfiguration(
|
||||
options: { env?: NodeJS.ProcessEnv } = {},
|
||||
): Promise<AutoRecallRuntimeConfiguration> {
|
||||
const env = options.env ?? process.env;
|
||||
const runtime = await loadConfiguration(env, parseAutoRecallInstanceConfig);
|
||||
const repositoryRoot = await resolveRepositoryRoot(
|
||||
runtime.instance.autoRecall.repositoryRoot,
|
||||
);
|
||||
return {
|
||||
...runtime,
|
||||
instance: {
|
||||
...runtime.instance,
|
||||
autoRecall: { repositoryRoot },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadConfiguration<T extends InstanceConfigV2 | InstanceConfigV3>(
|
||||
env: NodeJS.ProcessEnv,
|
||||
parseInstance: (value: unknown) => T,
|
||||
): Promise<{ instance: T; dialect: DialectV1; credential: string }> {
|
||||
const configPath = readRequiredEnvironment(env, CONFIG_ENV);
|
||||
if (!isAbsolute(configPath)) {
|
||||
throw new ConfigurationError(
|
||||
@@ -32,9 +60,7 @@ export async function loadRuntimeConfiguration(
|
||||
);
|
||||
}
|
||||
|
||||
const instance = parseInstanceConfig(
|
||||
await readConfigFile(configPath, 'instance'),
|
||||
);
|
||||
const instance = parseInstance(await readConfigFile(configPath, 'instance'));
|
||||
if (!isAbsolute(instance.dialectPath)) {
|
||||
throw new ConfigurationError(
|
||||
'Mem0 extension dialect path must be absolute.',
|
||||
@@ -59,7 +85,9 @@ async function readConfigFile(
|
||||
let source: Buffer;
|
||||
let file: FileHandle | undefined;
|
||||
try {
|
||||
file = await open(path, 'r');
|
||||
// A blocked FIFO open can outlive even the Hook's explicit process exit.
|
||||
file = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
||||
if (!(await file.stat()).isFile()) throw new Error('Not a regular file.');
|
||||
source = Buffer.allocUnsafe(MAX_CONFIG_BYTES + 1);
|
||||
let offset = 0;
|
||||
while (offset < source.byteLength) {
|
||||
@@ -95,7 +123,7 @@ async function readConfigFile(
|
||||
}
|
||||
|
||||
function validateInstance(
|
||||
instance: InstanceConfigV2,
|
||||
instance: InstanceConfigV2 | InstanceConfigV3,
|
||||
dialect: DialectV1,
|
||||
): void {
|
||||
validateEndpoint(instance);
|
||||
@@ -105,7 +133,7 @@ function validateInstance(
|
||||
validateScope(instance, dialect);
|
||||
}
|
||||
|
||||
function validateEndpoint(instance: InstanceConfigV2): void {
|
||||
function validateEndpoint(instance: InstanceConfigV2 | InstanceConfigV3): void {
|
||||
let origin: URL;
|
||||
try {
|
||||
origin = new URL(instance.endpoint.origin);
|
||||
@@ -169,12 +197,56 @@ function validateDialectSemantics(dialect: DialectV1): void {
|
||||
}
|
||||
}
|
||||
|
||||
function validateScope(instance: InstanceConfigV2, dialect: DialectV1): void {
|
||||
function validateScope(
|
||||
instance: InstanceConfigV2 | InstanceConfigV3,
|
||||
dialect: DialectV1,
|
||||
): void {
|
||||
requireScopeValue(instance.scope.userId, dialect.search.userIdLocation);
|
||||
requireScopeValue(instance.scope.agentId, dialect.search.agentIdLocation);
|
||||
requireScopeValue(instance.scope.appId, dialect.search.appIdLocation);
|
||||
}
|
||||
|
||||
async function resolveRepositoryRoot(value: string): Promise<string> {
|
||||
if (!isAbsolute(value)) {
|
||||
throw new ConfigurationError('Mem0 extension repository root is invalid.');
|
||||
}
|
||||
try {
|
||||
const resolved = await realpath(value);
|
||||
if (
|
||||
!(await stat(resolved)).isDirectory() ||
|
||||
parse(resolved).root === resolved
|
||||
) {
|
||||
throw new ConfigurationError(
|
||||
'Mem0 extension repository root is invalid.',
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigurationError) throw error;
|
||||
throw new ConfigurationError('Mem0 extension repository root is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function isWithinRepository(
|
||||
repositoryRoot: string,
|
||||
candidate: string,
|
||||
): Promise<boolean> {
|
||||
if (!isAbsolute(candidate)) return false;
|
||||
try {
|
||||
const resolved = await realpath(candidate);
|
||||
if (!(await stat(resolved)).isDirectory()) return false;
|
||||
const pathFromRoot = relative(repositoryRoot, resolved);
|
||||
return (
|
||||
pathFromRoot === '' ||
|
||||
(!pathFromRoot.startsWith(`..${sep}`) &&
|
||||
pathFromRoot !== '..' &&
|
||||
!isAbsolute(pathFromRoot))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requireScopeValue(
|
||||
value: string | undefined,
|
||||
location: ScopeLocation,
|
||||
@@ -195,7 +267,7 @@ function readRequiredEnvironment(env: NodeJS.ProcessEnv, name: string): string {
|
||||
}
|
||||
|
||||
export function buildSearchUrl(
|
||||
instance: InstanceConfigV2,
|
||||
instance: InstanceConfigV2 | InstanceConfigV3,
|
||||
dialect: DialectV1,
|
||||
): URL {
|
||||
const url = new URL(instance.endpoint.origin);
|
||||
|
||||
@@ -23,10 +23,12 @@ describe('Mem0 Extension package', () => {
|
||||
includeTools: ['context_search'],
|
||||
});
|
||||
expect(manifest.settings).toBeUndefined();
|
||||
expect(manifest.hooks).toBeUndefined();
|
||||
expect(server?.['env']).toBeUndefined();
|
||||
expect(server?.['trust']).toBeUndefined();
|
||||
expect(packageJson.scripts?.['build']).toContain('--bundle');
|
||||
expect(packageJson.files).toContain('dist/main.js');
|
||||
expect(packageJson.files).toContain('dist/auto-recall.js');
|
||||
expect(packageJson.dependencies).toBeUndefined();
|
||||
expect(packageJson.private).not.toBe(true);
|
||||
expect(packageJson.name).toBe('@qwen-code/external-context-mem0');
|
||||
@@ -38,20 +40,71 @@ describe('Mem0 Extension package', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('ships only the runtime, schemas, manifest, and documentation', async () => {
|
||||
it('ships only the runtime, schemas, examples, manifest, and documentation', async () => {
|
||||
const packageJson = await readJson('../package.json');
|
||||
|
||||
expect(packageJson.files).toEqual([
|
||||
'dist/main.js',
|
||||
'dist/auto-recall.js',
|
||||
'schemas',
|
||||
'examples',
|
||||
'qwen-extension.json',
|
||||
'README.md',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
platform: 'posix',
|
||||
command:
|
||||
"exec '/absolute/path/to/node' '/administrator/path/to/external-context-mem0/dist/auto-recall.js'",
|
||||
shell: undefined,
|
||||
},
|
||||
{
|
||||
platform: 'windows',
|
||||
command:
|
||||
"& 'C:\\Program Files\\nodejs\\node.exe' 'C:\\administrator\\external-context-mem0\\dist\\auto-recall.js'",
|
||||
shell: 'powershell',
|
||||
},
|
||||
])(
|
||||
'keeps the managed Auto Recall $platform profile Hook-only',
|
||||
async ({ platform, command, shell }) => {
|
||||
const settings = await readJson(
|
||||
`../examples/managed-auto-recall-user-settings-${platform}.json`,
|
||||
);
|
||||
const groups = settings.hooks?.['UserPromptSubmit'] ?? [];
|
||||
const group = groups[0];
|
||||
const hooks = group?.hooks ?? [];
|
||||
|
||||
expect(settings.$version).toBe(4);
|
||||
expect(settings.mcpServers).toBeUndefined();
|
||||
expect(Object.keys(settings.hooks ?? {})).toEqual(['UserPromptSubmit']);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(group?.matcher).toBe('*');
|
||||
expect(hooks).toEqual([
|
||||
{
|
||||
type: 'command',
|
||||
command,
|
||||
...(shell === undefined ? {} : { shell }),
|
||||
timeout: 8000,
|
||||
name: 'external-context-mem0-auto-recall',
|
||||
statusMessage: 'Retrieving external context',
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
interface Manifest {
|
||||
$version?: number;
|
||||
version?: string;
|
||||
hooks?: Record<
|
||||
string,
|
||||
Array<{
|
||||
matcher?: string;
|
||||
hooks?: Array<Record<string, unknown>>;
|
||||
}>
|
||||
>;
|
||||
mcpServers?: Record<string, Record<string, unknown>>;
|
||||
settings?: unknown;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { buildSearchUrl } from './config.js';
|
||||
import type {
|
||||
DialectV1,
|
||||
ExternalContextItem,
|
||||
RuntimeConfiguration,
|
||||
SearchRuntimeConfiguration,
|
||||
ScopeLocation,
|
||||
SearchProvider,
|
||||
} from './types.js';
|
||||
@@ -22,7 +22,7 @@ export type FetchLike = (
|
||||
) => Promise<Response>;
|
||||
|
||||
export function createRequestEngine(
|
||||
runtime: RuntimeConfiguration,
|
||||
runtime: SearchRuntimeConfiguration,
|
||||
fetcher: FetchLike = fetch,
|
||||
): SearchProvider {
|
||||
return async ({ query, signal }) => {
|
||||
@@ -56,7 +56,7 @@ export function createRequestEngine(
|
||||
|
||||
function applyAuthentication(
|
||||
headers: Headers,
|
||||
runtime: RuntimeConfiguration,
|
||||
runtime: SearchRuntimeConfiguration,
|
||||
): void {
|
||||
switch (runtime.dialect.auth) {
|
||||
case 'authorization-token':
|
||||
@@ -75,7 +75,7 @@ function applyAuthentication(
|
||||
|
||||
function buildRequest(
|
||||
url: URL,
|
||||
runtime: RuntimeConfiguration,
|
||||
runtime: SearchRuntimeConfiguration,
|
||||
query: string,
|
||||
): string | undefined {
|
||||
const body: Record<string, unknown> = {};
|
||||
|
||||
@@ -6,40 +6,36 @@
|
||||
|
||||
import { Ajv, type ValidateFunction } from 'ajv';
|
||||
// eslint-disable-next-line import/no-internal-modules -- bundle the canonical package schema
|
||||
import autoRecallInstanceConfigSchema from '../schemas/auto-recall-instance-config.schema.json' with { type: 'json' };
|
||||
// eslint-disable-next-line import/no-internal-modules -- bundle the canonical package schema
|
||||
import dialectSchema from '../schemas/dialect.schema.json' with { type: 'json' };
|
||||
// eslint-disable-next-line import/no-internal-modules -- bundle the canonical package schema
|
||||
import instanceConfigSchema from '../schemas/instance-config.schema.json' with { type: 'json' };
|
||||
import type { DialectV1, InstanceConfigV2 } from './types.js';
|
||||
import type { DialectV1, InstanceConfigV2, InstanceConfigV3 } from './types.js';
|
||||
|
||||
const ajv = new Ajv({ allErrors: true, strict: true });
|
||||
const validateInstance = ajv.compile(instanceConfigSchema);
|
||||
const validateAutoRecallInstance = ajv.compile(autoRecallInstanceConfigSchema);
|
||||
const validateDialect = ajv.compile(dialectSchema);
|
||||
|
||||
export class ConfigurationError extends Error {}
|
||||
|
||||
export function parseInstanceConfig(value: unknown): InstanceConfigV2 {
|
||||
requireValid(
|
||||
return parseInstance(
|
||||
validateInstance,
|
||||
value,
|
||||
'Mem0 extension instance configuration is invalid.',
|
||||
);
|
||||
const parsed = value as Omit<InstanceConfigV2, 'endpoint'> & {
|
||||
endpoint: Omit<
|
||||
InstanceConfigV2['endpoint'],
|
||||
'basePath' | 'allowInsecureHttp'
|
||||
> &
|
||||
Partial<
|
||||
Pick<InstanceConfigV2['endpoint'], 'basePath' | 'allowInsecureHttp'>
|
||||
>;
|
||||
};
|
||||
return {
|
||||
...parsed,
|
||||
endpoint: {
|
||||
...parsed.endpoint,
|
||||
basePath: parsed.endpoint.basePath ?? '',
|
||||
allowInsecureHttp: parsed.endpoint.allowInsecureHttp ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAutoRecallInstanceConfig(
|
||||
value: unknown,
|
||||
): InstanceConfigV3 {
|
||||
return parseInstance(
|
||||
validateAutoRecallInstance,
|
||||
value,
|
||||
'Mem0 extension auto-recall configuration is invalid.',
|
||||
);
|
||||
}
|
||||
|
||||
export function parseDialect(value: unknown): DialectV1 {
|
||||
@@ -60,3 +56,20 @@ function requireValid(
|
||||
throw new ConfigurationError(message);
|
||||
}
|
||||
}
|
||||
|
||||
function parseInstance<T extends InstanceConfigV2 | InstanceConfigV3>(
|
||||
validate: ValidateFunction,
|
||||
value: unknown,
|
||||
message: string,
|
||||
): T {
|
||||
requireValid(validate, value, message);
|
||||
const parsed = value as T;
|
||||
return {
|
||||
...parsed,
|
||||
endpoint: {
|
||||
...parsed.endpoint,
|
||||
basePath: parsed.endpoint.basePath ?? '',
|
||||
allowInsecureHttp: parsed.endpoint.allowInsecureHttp ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -11,8 +11,7 @@ export type AuthenticationKind =
|
||||
|
||||
export type ScopeLocation = 'json' | 'json.filters' | 'query' | 'omit';
|
||||
|
||||
export interface InstanceConfigV2 {
|
||||
schemaVersion: 2;
|
||||
interface InstanceConfigBase {
|
||||
dialectPath: string;
|
||||
endpoint: {
|
||||
origin: string;
|
||||
@@ -28,6 +27,17 @@ export interface InstanceConfigV2 {
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface InstanceConfigV2 extends InstanceConfigBase {
|
||||
schemaVersion: 2;
|
||||
}
|
||||
|
||||
export interface InstanceConfigV3 extends InstanceConfigBase {
|
||||
schemaVersion: 3;
|
||||
autoRecall: {
|
||||
repositoryRoot: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DialectV1 {
|
||||
dialectVersion: 1;
|
||||
id: string;
|
||||
@@ -60,6 +70,16 @@ export interface RuntimeConfiguration {
|
||||
credential: string;
|
||||
}
|
||||
|
||||
export interface AutoRecallRuntimeConfiguration {
|
||||
instance: InstanceConfigV3;
|
||||
dialect: DialectV1;
|
||||
credential: string;
|
||||
}
|
||||
|
||||
export type SearchRuntimeConfiguration =
|
||||
| RuntimeConfiguration
|
||||
| AutoRecallRuntimeConfiguration;
|
||||
|
||||
export interface ExternalContextItem {
|
||||
id: string;
|
||||
content: string;
|
||||
|
||||
@@ -11,5 +11,12 @@ export default defineConfig({
|
||||
include: ['src/**/*.test.ts'],
|
||||
// RPC-timeout exemption; see scripts/tests/unit-vitest-configs.test.ts.
|
||||
dangerouslyIgnoreUnhandledErrors: process.platform !== 'linux',
|
||||
// Shared-pool ceiling; see scripts/tests/unit-vitest-configs.test.ts.
|
||||
// Raised only on the ECS pool, where the same suite runs ~5x slower
|
||||
// depending on which host it lands on (#10490); off the pool this stays
|
||||
// `undefined` so vitest's 5s default keeps catching a genuine hang fast.
|
||||
testTimeout: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-')
|
||||
? 60_000
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
@@ -11,5 +11,12 @@ export default defineConfig({
|
||||
include: ['src/**/*.test.ts'],
|
||||
// RPC-timeout exemption; see scripts/tests/unit-vitest-configs.test.ts.
|
||||
dangerouslyIgnoreUnhandledErrors: process.platform !== 'linux',
|
||||
// Shared-pool ceiling; see scripts/tests/unit-vitest-configs.test.ts.
|
||||
// Raised only on the ECS pool, where the same suite runs ~5x slower
|
||||
// depending on which host it lands on (#10490); off the pool this stays
|
||||
// `undefined` so vitest's 5s default keeps catching a genuine hang fast.
|
||||
testTimeout: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-')
|
||||
? 60_000
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
Generated
+1
-1
@@ -28920,7 +28920,7 @@
|
||||
},
|
||||
"packages/node-repl": {
|
||||
"name": "@qwen-code/node-repl-mcp",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@
|
||||
"test:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./chat-transcript-contract.test.ts ./qwen-live-m4-acp-call.test.ts ./qwen-live-m4-acp-permission.test.ts ./qwen-live-m4-acp-steering.test.ts ./qwen-live-m4-acp-multibackend.test.ts ./qwen-live-m1-call.test.ts ./qwen-live-m2-inject.test.ts ./qwen-live-m2-permission.test.ts ./qwen-live-m2-steering.test.ts ./cli/_prompt-latency-policy.test.ts ./cli/daemon-invocation-context.test.ts ./cli/list_directory.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-streaming.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts",
|
||||
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./chat-transcript-contract.test.ts ./skill-hooks-invocation-parity.test.ts ./qwen-live-m4-acp-call.test.ts ./qwen-live-m4-acp-permission.test.ts ./qwen-live-m4-acp-steering.test.ts ./qwen-live-m4-acp-multibackend.test.ts ./qwen-live-m1-call.test.ts ./qwen-live-m2-inject.test.ts ./qwen-live-m2-permission.test.ts ./qwen-live-m2-steering.test.ts ./cli/_prompt-latency-policy.test.ts ./cli/daemon-invocation-context.test.ts ./cli/list_directory.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-streaming.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts",
|
||||
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 sdk-typescript",
|
||||
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 sdk-typescript",
|
||||
"test:sdk:python": "python3 -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q",
|
||||
@@ -190,7 +190,7 @@
|
||||
"prettier --write",
|
||||
"eslint --fix --max-warnings 0 --no-warn-ignored"
|
||||
],
|
||||
"*.{json,md}": [
|
||||
"*.{mjs,cjs,json,md,yml,yaml,css,html}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
|
||||
import type { InitializeResponse } from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
makeBridge,
|
||||
makeChannel,
|
||||
WS_A,
|
||||
WS_B,
|
||||
type ChannelHandle,
|
||||
} from './internal/testUtils.js';
|
||||
import {
|
||||
SessionNotFoundError,
|
||||
SessionResetPendingError,
|
||||
} from './bridgeErrors.js';
|
||||
import { SERVE_CONTROL_EXT_METHODS } from './status.js';
|
||||
import {
|
||||
ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM,
|
||||
ACTIVE_WORK_HEARTBEAT_INTERVAL_MS,
|
||||
ACTIVE_WORK_HEARTBEAT_META_KEY,
|
||||
ACTIVE_WORK_HEARTBEAT_VERSION,
|
||||
ACTIVE_WORK_HOLD_CATEGORIES,
|
||||
ACTIVE_WORK_NOTIFICATION_METHOD,
|
||||
DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY,
|
||||
} from './bridgeTypes.js';
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
} {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** A child that negotiated active-work reporting (mirrors bridge.test.ts). */
|
||||
function activeWorkInitializeResponse(): InitializeResponse {
|
||||
return {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentInfo: { name: 'active-work-agent', version: '0' },
|
||||
authMethods: [],
|
||||
agentCapabilities: {},
|
||||
_meta: {
|
||||
[ACTIVE_WORK_HEARTBEAT_META_KEY]: {
|
||||
v: ACTIVE_WORK_HEARTBEAT_VERSION,
|
||||
intervalMs: ACTIVE_WORK_HEARTBEAT_INTERVAL_MS,
|
||||
categories: [...ACTIVE_WORK_HOLD_CATEGORIES],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function sendActiveWorkSnapshot(
|
||||
handle: ChannelHandle,
|
||||
seq: number,
|
||||
sessions: Array<{
|
||||
sessionId: string;
|
||||
holds: Array<{ category: string; id: string }>;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
await handle.agentConnection.extNotification(
|
||||
ACTIVE_WORK_NOTIFICATION_METHOD,
|
||||
{ v: ACTIVE_WORK_HEARTBEAT_VERSION, seq, sessions },
|
||||
);
|
||||
}
|
||||
|
||||
describe('worktree reset session transfer', () => {
|
||||
describe('parked deferred restore prompt activity', () => {
|
||||
it('reports a parked deferred restore prompt as active in getSessionSummary', async () => {
|
||||
const handle = makeChannel({
|
||||
promptImpl: () => ({ stopReason: 'end_turn' }),
|
||||
loadSessionImpl: () => ({
|
||||
configOptions: [],
|
||||
_meta: { [DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY]: true },
|
||||
}),
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
restoreAskUserQuestion: true,
|
||||
});
|
||||
try {
|
||||
const restored = await bridge.loadSession({
|
||||
sessionId: 'reset-deferred-auq',
|
||||
workspaceCwd: WS_A,
|
||||
clientId: 'client-1',
|
||||
deferRestoreAskUserQuestionPrompt: true,
|
||||
});
|
||||
|
||||
// The owner-side restore response deliberately keeps its pre-transfer
|
||||
// computation (restorePromptAdmitted || promptActive || goalTurnActive)
|
||||
// so the restore route can tell a parked prompt from a running one.
|
||||
expect(restored.hasActivePrompt).toBe(false);
|
||||
expect(handle.agent.promptCalls).toHaveLength(0);
|
||||
// The summary is the surface that must count the parked prompt as
|
||||
// in-flight: quiescence readers (reset preconditions, the Channel
|
||||
// busy probe) would otherwise relocate the session under it.
|
||||
expect(
|
||||
bridge.getSessionSummary(restored.sessionId).hasActivePrompt,
|
||||
).toBe(true);
|
||||
expect(
|
||||
bridge
|
||||
.listWorkspaceSessions(WS_A)
|
||||
.find((s) => s.sessionId === restored.sessionId)?.hasActivePrompt,
|
||||
).toBe(true);
|
||||
// The daemon status surface is what automation reads session activity
|
||||
// from; no other field on it reveals the parked prompt, so it must not
|
||||
// report the session as idle while the summary reports it active.
|
||||
const statusSnapshotHasActivePrompt = () =>
|
||||
bridge
|
||||
.getDaemonStatusSnapshot()
|
||||
.sessions.find((s) => s.sessionId === restored.sessionId)
|
||||
?.hasActivePrompt;
|
||||
expect(statusSnapshotHasActivePrompt()).toBe(true);
|
||||
|
||||
// Firing the parked prompt and letting it settle returns the summary
|
||||
// to quiescent — the parked state, not a sticky flag, drove the report.
|
||||
expect(
|
||||
bridge.fireDeferredRestoreAskUserQuestionPrompt?.(
|
||||
restored.sessionId,
|
||||
restored.clientId,
|
||||
),
|
||||
).toBe(true);
|
||||
await vi.waitFor(() => {
|
||||
expect(handle.agent.promptCalls).toHaveLength(1);
|
||||
expect(
|
||||
bridge.getSessionSummary(restored.sessionId).hasActivePrompt,
|
||||
).toBe(false);
|
||||
expect(statusSnapshotHasActivePrompt()).toBe(false);
|
||||
});
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a parked deferred restore prompt as active to a coalesced restore waiter', async () => {
|
||||
const loadGate = deferred<void>();
|
||||
const handle = makeChannel({
|
||||
loadSessionImpl: async () => {
|
||||
await loadGate.promise;
|
||||
return {
|
||||
configOptions: [],
|
||||
_meta: { [DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY]: true },
|
||||
};
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
restoreAskUserQuestion: true,
|
||||
});
|
||||
try {
|
||||
const owner = bridge.loadSession({
|
||||
sessionId: 'reset-deferred-coalesced',
|
||||
workspaceCwd: WS_A,
|
||||
clientId: 'client-1',
|
||||
deferRestoreAskUserQuestionPrompt: true,
|
||||
});
|
||||
// Same-shape restores coalesce onto the in-flight owner; the waiter
|
||||
// has no restorePromptAdmitted term of its own, so without the
|
||||
// parked-prompt term it would read the session as idle.
|
||||
const waiter = bridge.loadSession({
|
||||
sessionId: 'reset-deferred-coalesced',
|
||||
workspaceCwd: WS_A,
|
||||
clientId: 'client-2',
|
||||
});
|
||||
loadGate.resolve();
|
||||
|
||||
const [ownerRestored, waiterRestored] = await Promise.all([
|
||||
owner,
|
||||
waiter,
|
||||
]);
|
||||
expect(ownerRestored.hasActivePrompt).toBe(false);
|
||||
expect(waiterRestored.hasActivePrompt).toBe(true);
|
||||
expect(handle.agent.promptCalls).toHaveLength(0);
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset-pending prompt barrier', () => {
|
||||
it('throws SessionResetPendingError synchronously from sendPrompt while armed and re-admits after clear', async () => {
|
||||
const handle = makeChannel();
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
try {
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
// The return value reports whether a live entry exists for the id.
|
||||
expect(bridge.setSessionResetPending?.(session.sessionId)).toBe(true);
|
||||
|
||||
// Admission fails closed synchronously — before the route's 202
|
||||
// contract, same as PromptQueueFullError — and nothing reaches the
|
||||
// child.
|
||||
expect(() =>
|
||||
bridge.sendPrompt(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'blocked during reset' }],
|
||||
}),
|
||||
).toThrow(SessionResetPendingError);
|
||||
expect(handle.agent.promptCalls).toHaveLength(0);
|
||||
|
||||
bridge.clearSessionResetPending?.(session.sessionId);
|
||||
await expect(
|
||||
bridge.sendPrompt(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'admitted after reset completed' }],
|
||||
}),
|
||||
).resolves.toEqual({ stopReason: 'end_turn' });
|
||||
expect(handle.agent.promptCalls).toHaveLength(1);
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks the trusted continueSession prompt source while armed', async () => {
|
||||
const handle = makeChannel({
|
||||
promptImpl: () => ({ stopReason: 'end_turn' }),
|
||||
extMethodImpl: (method) => {
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionContinue) {
|
||||
return { accepted: true, interruption: 'interrupted_prompt' };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
try {
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
bridge.setSessionResetPending?.(session.sessionId);
|
||||
|
||||
// continueSession admits through the same sendPrompt admission path:
|
||||
// the child-side accept/decline pre-check runs, then the barrier
|
||||
// refuses admission and no continuation turn is ever dispatched.
|
||||
await expect(
|
||||
bridge.continueSession(session.sessionId, {
|
||||
clientId: session.clientId,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SessionResetPendingError);
|
||||
expect(handle.agent.extMethodCalls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
method: SERVE_CONTROL_EXT_METHODS.sessionContinue,
|
||||
}),
|
||||
);
|
||||
expect(handle.agent.promptCalls).toHaveLength(0);
|
||||
|
||||
bridge.clearSessionResetPending?.(session.sessionId);
|
||||
await expect(
|
||||
bridge.continueSession(session.sessionId, {
|
||||
clientId: session.clientId,
|
||||
}),
|
||||
).resolves.toMatchObject({ accepted: true });
|
||||
await vi.waitFor(() => {
|
||||
expect(handle.agent.promptCalls).toHaveLength(1);
|
||||
});
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('stays armed for an id whose entry registers after the barrier was set', async () => {
|
||||
const handle = makeChannel();
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
try {
|
||||
// Arming an id with no live entry returns false (no entry) but still
|
||||
// fences the id: the transfer's replacement restore registers the
|
||||
// entry later, and a prompt admitted in between would write to a
|
||||
// checkout whose ownership just moved.
|
||||
expect(bridge.setSessionResetPending?.('reset-pending-restore')).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const restored = await bridge.loadSession({
|
||||
sessionId: 'reset-pending-restore',
|
||||
workspaceCwd: WS_A,
|
||||
});
|
||||
expect(() =>
|
||||
bridge.sendPrompt(restored.sessionId, {
|
||||
sessionId: restored.sessionId,
|
||||
prompt: [{ type: 'text', text: 'blocked after restore' }],
|
||||
}),
|
||||
).toThrow(SessionResetPendingError);
|
||||
expect(handle.agent.promptCalls).toHaveLength(0);
|
||||
|
||||
// Clearing is idempotent and re-admits.
|
||||
bridge.clearSessionResetPending?.(restored.sessionId);
|
||||
bridge.clearSessionResetPending?.(restored.sessionId);
|
||||
await expect(
|
||||
bridge.sendPrompt(restored.sessionId, {
|
||||
sessionId: restored.sessionId,
|
||||
prompt: [{ type: 'text', text: 'admitted after clear' }],
|
||||
}),
|
||||
).resolves.toEqual({ stopReason: 'end_turn' });
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset-pending writer barrier', () => {
|
||||
it('fences the non-prompt writers that reach the checkout or the cwd', async () => {
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: (method) => {
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionRewind) {
|
||||
return { targetTurnIndex: 0, filesChanged: [], filesFailed: [] };
|
||||
}
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionCd) {
|
||||
return { previousCwd: WS_A, newCwd: WS_B, warnings: [] };
|
||||
}
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionForkAgent) {
|
||||
return { launched: true };
|
||||
}
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionBranch) {
|
||||
return { newSessionId: 'branch-1', title: 'Branch 1' };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
sessionShellCommandEnabled: true,
|
||||
});
|
||||
try {
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const context = { clientId: session.clientId };
|
||||
bridge.setSessionResetPending?.(session.sessionId);
|
||||
|
||||
// A rewind restores files under the session cwd, a fork agent runs its
|
||||
// tools there, a shell command executes in `effectiveCwd`, a branch
|
||||
// mutates the session's persisted history and a cd moves the session —
|
||||
// all admitted precisely in the idle state the transfer requires, so
|
||||
// all must fail closed on the barrier `sendPrompt` uses.
|
||||
await expect(
|
||||
bridge.rewindSession(
|
||||
session.sessionId,
|
||||
{ promptId: 'prompt-1' },
|
||||
context,
|
||||
),
|
||||
).rejects.toBeInstanceOf(SessionResetPendingError);
|
||||
await expect(
|
||||
bridge.launchSessionForkAgent(
|
||||
session.sessionId,
|
||||
'fork the checkout writer',
|
||||
context,
|
||||
),
|
||||
).rejects.toBeInstanceOf(SessionResetPendingError);
|
||||
await expect(
|
||||
bridge.branchSession(
|
||||
session.sessionId,
|
||||
{ atRecordId: '11111111-1111-4111-8111-111111111111' },
|
||||
context,
|
||||
),
|
||||
).rejects.toBeInstanceOf(SessionResetPendingError);
|
||||
await expect(
|
||||
bridge.changeSessionCwd(session.sessionId, { path: WS_B }, context),
|
||||
).rejects.toBeInstanceOf(SessionResetPendingError);
|
||||
await expect(
|
||||
bridge.executeShellCommand(
|
||||
session.sessionId,
|
||||
'touch written-during-transfer',
|
||||
undefined,
|
||||
context,
|
||||
),
|
||||
).rejects.toBeInstanceOf(SessionResetPendingError);
|
||||
|
||||
// Nothing reached the child: the refusal is at admission, not a
|
||||
// dispatched-then-failed mutation.
|
||||
const dispatched = handle.agent.extMethodCalls.map(
|
||||
(call) => call.method,
|
||||
);
|
||||
for (const method of [
|
||||
SERVE_CONTROL_EXT_METHODS.sessionRewind,
|
||||
SERVE_CONTROL_EXT_METHODS.sessionForkAgent,
|
||||
SERVE_CONTROL_EXT_METHODS.sessionBranch,
|
||||
SERVE_CONTROL_EXT_METHODS.sessionCd,
|
||||
]) {
|
||||
expect(dispatched).not.toContain(method);
|
||||
}
|
||||
|
||||
// The barrier is the only thing refusing them, and the route clears it
|
||||
// in its `finally`, so a refused writer cannot wedge past the
|
||||
// transfer: the same rewind is admitted and dispatched afterwards.
|
||||
bridge.clearSessionResetPending?.(session.sessionId);
|
||||
await expect(
|
||||
bridge.rewindSession(
|
||||
session.sessionId,
|
||||
{ promptId: 'prompt-1' },
|
||||
context,
|
||||
),
|
||||
).resolves.toMatchObject({ targetTurnIndex: 0 });
|
||||
expect(handle.agent.extMethodCalls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
method: SERVE_CONTROL_EXT_METHODS.sessionRewind,
|
||||
params: expect.objectContaining({
|
||||
sessionId: session.sessionId,
|
||||
rewindFiles: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('fences the control writers that start work in the session cwd', async () => {
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: (method) => {
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction) {
|
||||
return { changed: true, status: 'running', taskId: 'run-1' };
|
||||
}
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionGoalControl) {
|
||||
return { snapshot: { v: 2, activity: 'running', goal: null } };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
try {
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
bridge.setSessionResetPending?.(session.sessionId);
|
||||
|
||||
// No client registration is needed to reach either writer: an absent
|
||||
// client-id header makes the route pass no context at all, and
|
||||
// `resolveTrustedClientId(entry, undefined)` returns without checking
|
||||
// registration. The barrier must still refuse — and for every action on
|
||||
// the method, not only the one that starts a saved workflow, because
|
||||
// `retry`/`rerun`/`resume` restart a live run through the same tool
|
||||
// registry and a goal `resume` starts a turn that never passes
|
||||
// `sendPrompt`.
|
||||
await expect(
|
||||
bridge.controlSessionWorkflowTask(
|
||||
session.sessionId,
|
||||
'wf-1',
|
||||
'run-saved',
|
||||
),
|
||||
).rejects.toBeInstanceOf(SessionResetPendingError);
|
||||
await expect(
|
||||
bridge.controlSessionWorkflowTask(session.sessionId, 'wf-1', 'rerun'),
|
||||
).rejects.toBeInstanceOf(SessionResetPendingError);
|
||||
await expect(
|
||||
bridge.controlSessionGoal(session.sessionId, {
|
||||
action: 'resume',
|
||||
expectedGoalId: 'goal-1',
|
||||
expectedRevision: 1,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SessionResetPendingError);
|
||||
|
||||
// Nothing reached the child: the refusal is at admission, not a
|
||||
// dispatched-then-failed mutation.
|
||||
const dispatched = handle.agent.extMethodCalls.map(
|
||||
(call) => call.method,
|
||||
);
|
||||
expect(dispatched).not.toContain(
|
||||
SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction,
|
||||
);
|
||||
expect(dispatched).not.toContain(
|
||||
SERVE_CONTROL_EXT_METHODS.sessionGoalControl,
|
||||
);
|
||||
|
||||
// The barrier is the only thing refusing them, and the route clears it
|
||||
// in its `finally`, so a refused control writer cannot wedge past the
|
||||
// transfer: both are admitted and dispatched afterwards.
|
||||
const context = { clientId: session.clientId };
|
||||
bridge.clearSessionResetPending?.(session.sessionId);
|
||||
await expect(
|
||||
bridge.controlSessionWorkflowTask(
|
||||
session.sessionId,
|
||||
'wf-1',
|
||||
'run-saved',
|
||||
context,
|
||||
),
|
||||
).resolves.toMatchObject({ changed: true, taskId: 'run-1' });
|
||||
await expect(
|
||||
bridge.controlSessionGoal(
|
||||
session.sessionId,
|
||||
{ action: 'resume', expectedGoalId: 'goal-1', expectedRevision: 1 },
|
||||
context,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
snapshot: expect.objectContaining({ v: 2 }),
|
||||
});
|
||||
expect(handle.agent.extMethodCalls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
method: SERVE_CONTROL_EXT_METHODS.sessionWorkflowTaskAction,
|
||||
params: expect.objectContaining({
|
||||
sessionId: session.sessionId,
|
||||
taskId: 'wf-1',
|
||||
action: 'run-saved',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(handle.agent.extMethodCalls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
method: SERVE_CONTROL_EXT_METHODS.sessionGoalControl,
|
||||
params: expect.objectContaining({
|
||||
sessionId: session.sessionId,
|
||||
request: expect.objectContaining({ action: 'resume' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSessionWorktree', () => {
|
||||
it('removes the worktree association from the session summary and bumps the catalog once', async () => {
|
||||
const handle = makeChannel();
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
try {
|
||||
const worktree = { slug: 'wt', path: '/tmp/wt', branch: 'wt-branch' };
|
||||
const session = await bridge.spawnOrAttach({
|
||||
workspaceCwd: WS_A,
|
||||
worktree,
|
||||
});
|
||||
expect(bridge.getSessionSummary(session.sessionId).worktree).toEqual(
|
||||
worktree,
|
||||
);
|
||||
const v0 = bridge.getSessionCatalogVersion().revision;
|
||||
|
||||
bridge.clearSessionWorktree?.(session.sessionId);
|
||||
expect(
|
||||
bridge.getSessionSummary(session.sessionId).worktree,
|
||||
).toBeUndefined();
|
||||
expect(bridge.getSessionCatalogVersion().revision).toBe(v0 + 1);
|
||||
|
||||
// No association left and unknown id: both no-ops, no further bumps.
|
||||
bridge.clearSessionWorktree?.(session.sessionId);
|
||||
bridge.clearSessionWorktree?.('missing-session');
|
||||
expect(bridge.getSessionCatalogVersion().revision).toBe(v0 + 1);
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('severSessionClients', () => {
|
||||
it('detaches every registered client and lets the idle-close path remove the session', async () => {
|
||||
const handle = makeChannel();
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
try {
|
||||
const owner = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
const attacher = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
expect(attacher.sessionId).toBe(owner.sessionId);
|
||||
expect(bridge.getSessionSummary(owner.sessionId).clientCount).toBe(2);
|
||||
|
||||
const severed = await bridge.severSessionClients?.(owner.sessionId);
|
||||
|
||||
// The last detach runs the natural idle-close path: the child gets
|
||||
// the ordinary sessionClose round trip, the entry leaves the
|
||||
// catalog, and the now-empty channel follows the idle policy
|
||||
// (zero-configured timeout kills it immediately in tests).
|
||||
expect(severed).toBe(true);
|
||||
expect(handle.agent.extMethodCalls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
method: SERVE_CONTROL_EXT_METHODS.sessionClose,
|
||||
params: expect.objectContaining({ sessionId: owner.sessionId }),
|
||||
}),
|
||||
);
|
||||
expect(bridge.sessionCount).toBe(0);
|
||||
expect(() => bridge.getSessionSummary(owner.sessionId)).toThrow(
|
||||
SessionNotFoundError,
|
||||
);
|
||||
expect(handle.killed).toBe(true);
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('detaches a clientId registered twice so the superseded session cannot survive', async () => {
|
||||
const handle = makeChannel();
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
try {
|
||||
const owner = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
// Reconnect shape: the client echoes the minted id, so the second
|
||||
// attach refcounts the existing registration instead of adding a key.
|
||||
// No count-based surface reveals it — clientCount counts keys, and the
|
||||
// attach ledger holds the one recorded attach (a spawn owner seeds 0).
|
||||
const reconnect = await bridge.spawnOrAttach({
|
||||
workspaceCwd: WS_A,
|
||||
clientId: owner.clientId,
|
||||
});
|
||||
expect(reconnect.clientId).toBe(owner.clientId);
|
||||
expect(bridge.getSessionSummary(owner.sessionId).clientCount).toBe(1);
|
||||
expect(
|
||||
bridge
|
||||
.getDaemonStatusSnapshot()
|
||||
.sessions.find((s) => s.sessionId === owner.sessionId)?.attachCount,
|
||||
).toBe(1);
|
||||
|
||||
await bridge.severSessionClients?.(owner.sessionId);
|
||||
|
||||
// One detach per remaining registration: the surviving refcount must
|
||||
// not hold the idle close off, or the superseded session stays live
|
||||
// and promptable after the reset route clears the barrier.
|
||||
expect(bridge.sessionCount).toBe(0);
|
||||
expect(() => bridge.getSessionSummary(owner.sessionId)).toThrow(
|
||||
SessionNotFoundError,
|
||||
);
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op for an unknown session id', async () => {
|
||||
const handle = makeChannel();
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
try {
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
// Nothing is registered for the id, so the reported end state (entry
|
||||
// gone) holds trivially.
|
||||
const severed = await bridge.severSessionClients?.('missing-session');
|
||||
|
||||
expect(severed).toBe(true);
|
||||
expect(bridge.getSessionSummary(session.sessionId).clientCount).toBe(1);
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a survivor when the child refuses the conditional idle close', async () => {
|
||||
const handle = makeChannel({
|
||||
initializeImpl: () => activeWorkInitializeResponse(),
|
||||
// The user is running a dev server inside the worktree: the child
|
||||
// holds a background shell and refuses the conditional close.
|
||||
extMethodImpl: (method) =>
|
||||
method === SERVE_CONTROL_EXT_METHODS.sessionClose
|
||||
? { closed: false, holds: [{ category: 'shell', id: 'sh1' }] }
|
||||
: {},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
try {
|
||||
const owner = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
// A fresh "no holds" snapshot so the detach path treats the session as
|
||||
// an auto-close candidate and actually asks the child: the refusal, not
|
||||
// the cache, is what defers the close.
|
||||
await sendActiveWorkSnapshot(handle, 1, [
|
||||
{ sessionId: owner.sessionId, holds: [] },
|
||||
]);
|
||||
|
||||
const severed = await bridge.severSessionClients?.(owner.sessionId);
|
||||
|
||||
// The documented end state is not reached, and the caller must be able
|
||||
// to see that: the entry is still registered, so the superseded
|
||||
// session is alive, cwd'd inside the transferred checkout,
|
||||
// re-attachable by id and — once the route clears the barrier —
|
||||
// promptable.
|
||||
expect(severed).toBe(false);
|
||||
expect(handle.agent.extMethodCalls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
method: SERVE_CONTROL_EXT_METHODS.sessionClose,
|
||||
params: expect.objectContaining({
|
||||
sessionId: owner.sessionId,
|
||||
[ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM]: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
expect(bridge.getSessionSummary(owner.sessionId).clientCount).toBe(0);
|
||||
} finally {
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,9 @@ import {
|
||||
PROMPT_CANCEL_METHOD,
|
||||
TODO_STOP_GUARD_CONTINUATION_CLAIM_METHOD,
|
||||
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
|
||||
ACTIVE_WORK_CLOSE_RETRY_BASE_MS,
|
||||
ACTIVE_WORK_CLOSE_RETRY_CEILING_MS,
|
||||
activeWorkCloseRetryDelayMs,
|
||||
sessionCloseDrainBudgetMs,
|
||||
} from './bridgeTypes.js';
|
||||
|
||||
@@ -177,6 +180,37 @@ describe('sessionCloseDrainBudgetMs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeWorkCloseRetryDelayMs', () => {
|
||||
it('keeps the first retry immediate, then backs off to a ceiling', () => {
|
||||
// Literal pin: one unanswered probe must stay immediately retryable,
|
||||
// because that single deferral is the documented recovery for a lost
|
||||
// close response — `recovers a lost close response once nothing local
|
||||
// holds the session` depends on the retry running on the very next
|
||||
// snapshot. A grace of 0 would break that recovery.
|
||||
expect(activeWorkCloseRetryDelayMs(0)).toBeNull();
|
||||
expect(activeWorkCloseRetryDelayMs(1)).toBeNull();
|
||||
expect(activeWorkCloseRetryDelayMs(2)).toBe(
|
||||
ACTIVE_WORK_CLOSE_RETRY_BASE_MS,
|
||||
);
|
||||
expect(activeWorkCloseRetryDelayMs(3)).toBe(
|
||||
ACTIVE_WORK_CLOSE_RETRY_BASE_MS * 2,
|
||||
);
|
||||
// Geometric growth must saturate at the ceiling, not overflow past it.
|
||||
for (const failures of [12, 40, 1_000]) {
|
||||
expect(activeWorkCloseRetryDelayMs(failures)).toBe(
|
||||
ACTIVE_WORK_CLOSE_RETRY_CEILING_MS,
|
||||
);
|
||||
}
|
||||
// A longer run of failures never shortens the deferral.
|
||||
let previous = 0;
|
||||
for (let failures = 0; failures <= 30; failures++) {
|
||||
const delay = activeWorkCloseRetryDelayMs(failures) ?? 0;
|
||||
expect(delay).toBeGreaterThanOrEqual(previous);
|
||||
previous = delay;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Test-local fake of the daemon-wide growth aggregator `runQwenServe` wires
|
||||
* every bridge to: one shared provider set plus the register/read pair.
|
||||
@@ -845,6 +879,692 @@ describe('createAcpSessionBridge', () => {
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('logs the child error detail when a conditional close fails', async () => {
|
||||
const handle = makeChannel({
|
||||
initializeImpl: () => activeWorkInitializeResponse(),
|
||||
extMethodImpl: async (method) => {
|
||||
if (method !== SERVE_CONTROL_EXT_METHODS.sessionClose) return {};
|
||||
// Mirrors the child's drain deadline: a bare Error, which the ACP
|
||||
// SDK turns into a JSON-RPC `-32603` carrying the text in
|
||||
// `data.details` — a plain object on this side, not an Error.
|
||||
throw new Error('Session close timed out after 8000ms');
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
sessionReapIntervalMs: 0,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
try {
|
||||
await sendActiveWorkSnapshot(handle, 1, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await bridge.detachClient(session.sessionId, session.clientId);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'did not resolve (Session close timed out after 8000ms)',
|
||||
),
|
||||
);
|
||||
});
|
||||
// One failure is inside the grace window, so the line must take the
|
||||
// immediate-retry arm. The error detail alone is shared by both arms,
|
||||
// so asserting only that would let them be swapped — telling oncall a
|
||||
// deferral was armed when the retry is in fact immediate — while the
|
||||
// suite stayed green.
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'leaving it in place for the next snapshot to settle',
|
||||
),
|
||||
);
|
||||
for (const call of stderrSpy.mock.calls) {
|
||||
expect(String(call[0])).not.toContain('deferring the next probe by');
|
||||
}
|
||||
// A non-answer still never authorizes teardown.
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('defers further probes after a run of unanswered conditional closes', async () => {
|
||||
let closeAttempts = 0;
|
||||
const handle = makeChannel({
|
||||
initializeImpl: () => activeWorkInitializeResponse(),
|
||||
extMethodImpl: async (method, params) => {
|
||||
if (method !== SERVE_CONTROL_EXT_METHODS.sessionClose) return {};
|
||||
if (params?.[ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM] !== true) {
|
||||
return { closed: true, holds: [] };
|
||||
}
|
||||
closeAttempts++;
|
||||
throw new Error('Session close timed out after 8000ms');
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
sessionReapIntervalMs: 0,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
try {
|
||||
await sendActiveWorkSnapshot(handle, 1, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await bridge.detachClient(session.sessionId, session.clientId);
|
||||
// The detach probes once; the next snapshot probes again, because one
|
||||
// deferral is the documented recovery for a lost close response.
|
||||
await sendActiveWorkSnapshot(handle, 2, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
// Wait on the daemon's own log rather than `closeAttempts`: the counter
|
||||
// moves when the child is asked, while the backoff is armed only once
|
||||
// the failure has been recorded on this side.
|
||||
await vi.waitFor(() => {
|
||||
const failures = stderrSpy.mock.calls.filter((call) =>
|
||||
String(call[0]).includes('did not resolve'),
|
||||
).length;
|
||||
expect(failures).toBe(2);
|
||||
});
|
||||
expect(closeAttempts).toBe(2);
|
||||
// The second failure is past the grace window, so the line must name
|
||||
// the deferral it actually armed — the rung and the run length — and
|
||||
// not the immediate-retry wording.
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`deferring the next probe by ${ACTIVE_WORK_CLOSE_RETRY_BASE_MS}ms after 2 consecutive failures`,
|
||||
),
|
||||
);
|
||||
|
||||
for (const seq of [3, 4, 5]) {
|
||||
await sendActiveWorkSnapshot(handle, seq, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
}
|
||||
// The deferral is far longer than this settle window, so reaching the
|
||||
// child again here would mean the backoff is not applied at all.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(closeAttempts).toBe(2);
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('probes again once the deferral expires', async () => {
|
||||
// The gate compares a timestamp, so the deferral has to end. Reducing it
|
||||
// to "a non-null deferral suppresses the probe" would turn the bounded
|
||||
// backoff into a permanent per-session give-up, and nothing else in the
|
||||
// suite would notice: the suppression test passes either way, and the
|
||||
// reset test clears the field through a different branch.
|
||||
let closeAttempts = 0;
|
||||
const handle = makeChannel({
|
||||
initializeImpl: () => activeWorkInitializeResponse(),
|
||||
extMethodImpl: async (method, params) => {
|
||||
if (method !== SERVE_CONTROL_EXT_METHODS.sessionClose) return {};
|
||||
if (params?.[ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM] !== true) {
|
||||
return { closed: true, holds: [] };
|
||||
}
|
||||
closeAttempts++;
|
||||
throw new Error('Session close timed out after 8000ms');
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
sessionReapIntervalMs: 0,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
try {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await sendActiveWorkSnapshot(handle, 1, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
const detached = bridge.detachClient(
|
||||
session.sessionId,
|
||||
session.clientId,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000,
|
||||
);
|
||||
await detached;
|
||||
expect(closeAttempts).toBe(1);
|
||||
|
||||
// A second consecutive failure arms the first rung.
|
||||
await sendActiveWorkSnapshot(handle, 2, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000,
|
||||
);
|
||||
expect(closeAttempts).toBe(2);
|
||||
|
||||
// Inside the rung the probe is suppressed.
|
||||
await sendActiveWorkSnapshot(handle, 3, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(closeAttempts).toBe(2);
|
||||
|
||||
// Past the rung it is not: the backoff defers, it never gives up.
|
||||
await vi.advanceTimersByTimeAsync(ACTIVE_WORK_CLOSE_RETRY_BASE_MS);
|
||||
await sendActiveWorkSnapshot(handle, 4, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000,
|
||||
);
|
||||
expect(closeAttempts).toBe(3);
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
// Rung 2, not rung 1 again: the ladder has to grow at bridge level,
|
||||
// not only in the pure unit test, which feeds
|
||||
// `activeWorkCloseRetryDelayMs` its own inputs. Capping the failure
|
||||
// counter at two, or flattening the ladder call to a constant, keeps
|
||||
// the whole package green while a Session the child can never settle
|
||||
// is re-probed every 60s for the daemon's lifetime instead of backing
|
||||
// off toward the ceiling — the bound this PR exists to add.
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`deferring the next probe by ${
|
||||
ACTIVE_WORK_CLOSE_RETRY_BASE_MS * 2
|
||||
}ms after 3 consecutive failures`,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('reconciles a dropped session at once rather than waiting out the deferral', async () => {
|
||||
// The probe is how the daemon reconciles a Session the child has already
|
||||
// destroyed, and the deferral suppresses the probe. A child that finishes
|
||||
// the close one beat late would therefore leave a ghost entry retained
|
||||
// for the whole rung: one `maxSessions` slot, a channel that cannot
|
||||
// retire, and a Session `spawnOrAttach`'s single-scope fast path hands to
|
||||
// the next client because that path checks only daemon-side liveness.
|
||||
//
|
||||
// Absence from the snapshot is the one recovery signal that cannot be
|
||||
// misread, and acting on it is free — the child answers an unknown id
|
||||
// from `closeStoredSession`'s early return without entering the drain. A
|
||||
// wedged child still *lists* the Session with no holds, so the deferral
|
||||
// the wedge needs is untouched, as the suppressed snapshot below shows.
|
||||
let closeAttempts = 0;
|
||||
let wedged = true;
|
||||
const handle = makeChannel({
|
||||
initializeImpl: () => activeWorkInitializeResponse(),
|
||||
extMethodImpl: async (method, params) => {
|
||||
if (method !== SERVE_CONTROL_EXT_METHODS.sessionClose) return {};
|
||||
if (params?.[ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM] !== true) {
|
||||
return { closed: true, holds: [] };
|
||||
}
|
||||
closeAttempts++;
|
||||
if (wedged) throw new Error('Session close timed out after 8000ms');
|
||||
return { closed: true, holds: [] };
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
sessionReapIntervalMs: 0,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
try {
|
||||
await sendActiveWorkSnapshot(handle, 1, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await bridge.detachClient(session.sessionId, session.clientId);
|
||||
await sendActiveWorkSnapshot(handle, 2, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.waitFor(() => {
|
||||
const failures = stderrSpy.mock.calls.filter((call) =>
|
||||
String(call[0]).includes('did not resolve'),
|
||||
).length;
|
||||
expect(failures).toBe(2);
|
||||
});
|
||||
expect(closeAttempts).toBe(2);
|
||||
|
||||
// Still listed, still deferred: the backoff is armed and biting.
|
||||
await sendActiveWorkSnapshot(handle, 3, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(closeAttempts).toBe(2);
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
|
||||
// The wedge clears and the child destroys the Session, so the next
|
||||
// snapshot omits it. That reconciles now, not when the rung expires.
|
||||
wedged = false;
|
||||
await sendActiveWorkSnapshot(handle, 4, []);
|
||||
await vi.waitFor(() => {
|
||||
expect(bridge.sessionCount).toBe(0);
|
||||
});
|
||||
expect(closeAttempts).toBe(3);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('keeps the deferred-close detail readable and the failure run reset', async () => {
|
||||
// A close the child grants can still fail its own local teardown:
|
||||
// `closeSessionImpl` asks the agent with `throwOnFailure`, a definitive
|
||||
// refusal restores `closing = false` and rethrows, and `closeIfChildUnheld`
|
||||
// swallows it — so the entry stays registered and usable. Two things have
|
||||
// to survive that, and neither had a witness:
|
||||
//
|
||||
// - the log line naming why the teardown failed must carry the child's
|
||||
// detail. The inner notifier logs the same error as `[object Object]`
|
||||
// (#11123), so this asserts on the deferred-close line specifically.
|
||||
// - the child answered, so the run of *unanswered* probes is over. The
|
||||
// reset has to happen before the `closed === true` early return, or a
|
||||
// stale count makes the next single failure skip a rung and defer by
|
||||
// 120s instead of retrying at once.
|
||||
let grantConditional = false;
|
||||
const handle = makeChannel({
|
||||
initializeImpl: () => activeWorkInitializeResponse(),
|
||||
extMethodImpl: async (method, params) => {
|
||||
if (method !== SERVE_CONTROL_EXT_METHODS.sessionClose) return {};
|
||||
const conditional =
|
||||
params?.[ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM] === true;
|
||||
if (conditional && grantConditional)
|
||||
return { closed: true, holds: [] };
|
||||
if (conditional) {
|
||||
throw new Error('Session close timed out after 8000ms');
|
||||
}
|
||||
throw new Error('agent close exploded');
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
sessionReapIntervalMs: 0,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
try {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// Two unanswered probes arm the first rung.
|
||||
await sendActiveWorkSnapshot(handle, 1, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
const detached = bridge.detachClient(
|
||||
session.sessionId,
|
||||
session.clientId,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000,
|
||||
);
|
||||
await detached;
|
||||
await sendActiveWorkSnapshot(handle, 2, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000,
|
||||
);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('after 2 consecutive failures'),
|
||||
);
|
||||
|
||||
// Past the rung the child grants, then the local teardown fails.
|
||||
grantConditional = true;
|
||||
await vi.advanceTimersByTimeAsync(ACTIVE_WORK_CLOSE_RETRY_BASE_MS);
|
||||
await sendActiveWorkSnapshot(handle, 3, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(ACTIVE_WORK_CLOSE_TIMEOUT_MS);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/deferred close \([^)]*\) failed for .*agent close exploded/s,
|
||||
),
|
||||
);
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
|
||||
// The answer ended the run, so one more unanswered probe must retry
|
||||
// at once rather than reporting a third consecutive failure.
|
||||
grantConditional = false;
|
||||
await sendActiveWorkSnapshot(handle, 4, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000,
|
||||
);
|
||||
expect(stderrSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('after 3 consecutive failures'),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('quiets the reaper while a deferral is armed', async () => {
|
||||
// The gate sits in `entryIsAutoCloseCandidate`, ahead of every automatic
|
||||
// trigger, rather than inside the probe. Moving it into
|
||||
// `closeIfChildUnheld` would suppress the round trip just the same and
|
||||
// keep every snapshot-driven test green, but the reaper would keep
|
||||
// finding the entry, keep logging `reaping idle session`, and keep
|
||||
// scheduling a teardown it then declines — exactly the noise the
|
||||
// deferral exists to stop. Only a reaper-enabled run can see that.
|
||||
let closeAttempts = 0;
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const handle = makeChannel({
|
||||
initializeImpl: () => activeWorkInitializeResponse(),
|
||||
extMethodImpl: async (method, params) => {
|
||||
if (method !== SERVE_CONTROL_EXT_METHODS.sessionClose) return {};
|
||||
if (params?.[ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM] !== true) {
|
||||
return { closed: true, holds: [] };
|
||||
}
|
||||
closeAttempts++;
|
||||
throw new Error('Session close timed out after 8000ms');
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
sessionReapIntervalMs: 1_000,
|
||||
sessionIdleTimeoutMs: 1,
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
const reapLines = () =>
|
||||
stderrSpy.mock.calls.filter((call) =>
|
||||
String(call[0]).includes('reaping idle session'),
|
||||
).length;
|
||||
try {
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
await sendActiveWorkSnapshot(handle, 1, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
// The detach triggers the first probe and the reaper's next tick the
|
||||
// second, which arms the rung. The child answers with a throw rather
|
||||
// than by stalling, so both failures land inside the first ticks and
|
||||
// the rung is what bounds the rest of the timeline.
|
||||
const detached = bridge.detachClient(
|
||||
session.sessionId,
|
||||
session.clientId,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await detached;
|
||||
expect(closeAttempts).toBe(2);
|
||||
expect(reapLines()).toBe(1);
|
||||
|
||||
// Thirty reaper ticks inside the rung: no probe and no reap line.
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(closeAttempts).toBe(2);
|
||||
expect(reapLines()).toBe(1);
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
|
||||
// Past the rung the reaper picks the session up again — the
|
||||
// deferral bounds the retries, it does not retire the session.
|
||||
await vi.advanceTimersByTimeAsync(ACTIVE_WORK_CLOSE_RETRY_BASE_MS);
|
||||
expect(closeAttempts).toBe(3);
|
||||
expect(reapLines()).toBe(2);
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
await bridge.shutdown();
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('probes again at once once the child reports held work', async () => {
|
||||
let closeAttempts = 0;
|
||||
const handle = makeChannel({
|
||||
initializeImpl: () => activeWorkInitializeResponse(),
|
||||
extMethodImpl: async (method, params) => {
|
||||
if (method !== SERVE_CONTROL_EXT_METHODS.sessionClose) return {};
|
||||
if (params?.[ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM] !== true) {
|
||||
return { closed: true, holds: [] };
|
||||
}
|
||||
closeAttempts++;
|
||||
throw new Error('Session close timed out after 8000ms');
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
sessionReapIntervalMs: 0,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
try {
|
||||
await sendActiveWorkSnapshot(handle, 1, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await bridge.detachClient(session.sessionId, session.clientId);
|
||||
await sendActiveWorkSnapshot(handle, 2, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.waitFor(() => {
|
||||
const failures = stderrSpy.mock.calls.filter((call) =>
|
||||
String(call[0]).includes('did not resolve'),
|
||||
).length;
|
||||
expect(failures).toBe(2);
|
||||
});
|
||||
|
||||
// A report of held work is an answer, so the run of failures is over.
|
||||
await sendActiveWorkSnapshot(handle, 3, [
|
||||
{ sessionId: session.sessionId, holds: [agentHold('a1')] },
|
||||
]);
|
||||
expect(bridge.activeWork).toBe(true);
|
||||
|
||||
// Going idle again must probe on the very next snapshot rather than
|
||||
// waiting out a deferral earned against a different state of the
|
||||
// world — a transient wedge is never stranded.
|
||||
await sendActiveWorkSnapshot(handle, 4, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.waitFor(() => expect(closeAttempts).toBe(3));
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('clears the failure run on a refusal answer, not only on a grant', async () => {
|
||||
// The hoisted reset is documented as covering an answer "granted or
|
||||
// refused", but its other witness drives the granted side only — a close
|
||||
// the child grants whose local teardown then fails. Narrowing the reset
|
||||
// back inside the `closed === true` branch keeps the whole package green,
|
||||
// and a Session whose wedge demonstrably cleared then carries a stale
|
||||
// count: its next single failure skips a rung and defers by 120s instead
|
||||
// of retrying at once. `probes again at once once the child reports held
|
||||
// work` does not cover this, because it clears the run through the
|
||||
// snapshot hold-report branch in `applyActiveWorkSnapshot` and never
|
||||
// through a refusal answer to the probe itself.
|
||||
let closeAttempts = 0;
|
||||
let refuseWithHold = false;
|
||||
const handle = makeChannel({
|
||||
initializeImpl: () => activeWorkInitializeResponse(),
|
||||
extMethodImpl: async (method, params) => {
|
||||
if (method !== SERVE_CONTROL_EXT_METHODS.sessionClose) return {};
|
||||
if (params?.[ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM] !== true) {
|
||||
return { closed: true, holds: [] };
|
||||
}
|
||||
closeAttempts++;
|
||||
if (refuseWithHold) {
|
||||
return { closed: false, holds: [agentHold('a1')] };
|
||||
}
|
||||
throw new Error('Session close timed out after 8000ms');
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
sessionReapIntervalMs: 0,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
try {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// Two unanswered probes arm the first rung.
|
||||
await sendActiveWorkSnapshot(handle, 1, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
const detached = bridge.detachClient(
|
||||
session.sessionId,
|
||||
session.clientId,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000,
|
||||
);
|
||||
await detached;
|
||||
await sendActiveWorkSnapshot(handle, 2, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000,
|
||||
);
|
||||
expect(closeAttempts).toBe(2);
|
||||
|
||||
// Past the rung the child answers with a definitive refusal carrying
|
||||
// a hold. That is an answer, so the run of unanswered probes is over.
|
||||
refuseWithHold = true;
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
ACTIVE_WORK_CLOSE_RETRY_BASE_MS + 1_000,
|
||||
);
|
||||
await sendActiveWorkSnapshot(handle, 3, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000,
|
||||
);
|
||||
expect(closeAttempts).toBe(3);
|
||||
|
||||
// The hold then clears on a snapshot that still names the Session, so
|
||||
// it takes the `child_idle` path, which deliberately does not reset.
|
||||
// The wedge is back and the next failure must be the first of a new
|
||||
// run, not the third of the old one.
|
||||
refuseWithHold = false;
|
||||
await sendActiveWorkSnapshot(handle, 4, [
|
||||
{ sessionId: session.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000,
|
||||
);
|
||||
expect(closeAttempts).toBe(4);
|
||||
expect(stderrSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('after 3 consecutive failures'),
|
||||
);
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
await bridge.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('still reclaims through a condemned channel while a deferral is armed', async () => {
|
||||
// The deferral backs off a child that cannot answer; it is not a reason
|
||||
// to keep holding a Session in a channel the daemon has already given up
|
||||
// on. `confirmChildUnheld` skips the round trip entirely for a condemned
|
||||
// channel because that teardown is the only thing able to release a
|
||||
// request nobody will answer — so deferring here would leave the Session
|
||||
// registered and the channel unable to drain.
|
||||
vi.useFakeTimers();
|
||||
const lateRestore = deferred<LoadSessionResponse>();
|
||||
const handle = makeChannel({
|
||||
initializeImpl: () => activeWorkInitializeResponse(),
|
||||
loadSessionImpl: () => lateRestore.promise,
|
||||
// The wedged child answers no close at all.
|
||||
extMethodImpl: (method) =>
|
||||
method === SERVE_CONTROL_EXT_METHODS.sessionClose
|
||||
? new Promise<Record<string, unknown>>(() => {})
|
||||
: Promise.resolve({}),
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
sessionScope: 'thread',
|
||||
maxSessions: 5,
|
||||
sessionRestoreTimeoutMs: 20,
|
||||
channelIdleTimeoutMs: 60_000,
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
try {
|
||||
const wedged = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
await sendActiveWorkSnapshot(handle, 1, [
|
||||
{ sessionId: wedged.sessionId, holds: [] },
|
||||
]);
|
||||
|
||||
// Two unanswered probes arm the deferral; the first retry is immediate
|
||||
// by design, so it takes two failures to reach a non-null delay. The
|
||||
// detach cannot be awaited directly: it waits on a close this child
|
||||
// never answers, so the timers have to move first.
|
||||
const detached = bridge.detachClient(wedged.sessionId, wedged.clientId);
|
||||
await vi.advanceTimersByTimeAsync(ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000);
|
||||
await detached;
|
||||
await sendActiveWorkSnapshot(handle, 2, [
|
||||
{ sessionId: wedged.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(ACTIVE_WORK_CLOSE_TIMEOUT_MS + 1_000);
|
||||
expect(handle.killed).toBe(false);
|
||||
expect(bridge.sessionCount).toBe(1);
|
||||
|
||||
// Condemn the channel through the abandoned-restore bound.
|
||||
const timedOut = bridge
|
||||
.loadSession({ sessionId: 'wedged-restore', workspaceCwd: WS_A })
|
||||
.catch((error: unknown) => error);
|
||||
await advanceRestoreDeadline(20);
|
||||
expect(await timedOut).toBeInstanceOf(SessionRestoreTimeoutError);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
|
||||
// The still-armed deferral must not suppress the condemned path: the
|
||||
// next trigger closes locally, and the bounded agent close expiring
|
||||
// kills the channel — the drain this bound exists to force.
|
||||
await sendActiveWorkSnapshot(handle, 3, [
|
||||
{ sessionId: wedged.sessionId, holds: [] },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(ACTIVE_WORK_CLOSE_TIMEOUT_MS);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(handle.killed).toBe(true);
|
||||
} finally {
|
||||
lateRestore.reject(new Error('channel torn down'));
|
||||
await bridge.shutdown();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
count: ACTIVE_WORK_MAX_SESSION_HOLDS,
|
||||
@@ -35037,6 +35757,7 @@ describe('createAcpSessionBridge — background notifications', () => {
|
||||
taskId: 'worker-1',
|
||||
status: 'completed',
|
||||
kind: 'agent',
|
||||
label: 'research worker',
|
||||
}),
|
||||
).resolves.toEqual({ sessionId: session.sessionId, accepted: true });
|
||||
|
||||
@@ -35049,6 +35770,7 @@ describe('createAcpSessionBridge — background notifications', () => {
|
||||
taskId: 'worker-1',
|
||||
status: 'completed',
|
||||
kind: 'agent',
|
||||
label: 'research worker',
|
||||
},
|
||||
});
|
||||
await bridge.shutdown();
|
||||
|
||||
@@ -123,6 +123,7 @@ import {
|
||||
PromptDeadlineExceededError,
|
||||
BridgeChannelQuarantinedError,
|
||||
McpAuthenticationInProgressError,
|
||||
SessionResetPendingError,
|
||||
StandaloneSessionSpawnError,
|
||||
} from './bridgeErrors.js';
|
||||
import type { BridgeChannelUnavailableReason } from './bridgeErrors.js';
|
||||
@@ -181,6 +182,7 @@ import {
|
||||
SESSION_INITIALIZATION_TIMEOUT_ERROR_KIND,
|
||||
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
|
||||
WORKTREE_MCP_DEFER_META_KEY,
|
||||
activeWorkCloseRetryDelayMs,
|
||||
isValidTrustedModelPrompt,
|
||||
sessionCloseDrainBudgetMs,
|
||||
} from './bridgeTypes.js';
|
||||
@@ -413,6 +415,35 @@ function getChannelPromptDisplayText(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session has prompt work in flight, counting a parked deferred
|
||||
* restore prompt (`deferredRestoreAskUserQuestionPrompts`) as active. The
|
||||
* parked prompt owns no promptActive/goalTurnActive flag and no
|
||||
* pendingInteractions entry, so without this term a session with a deferred
|
||||
* restore question reads quiescent to `getSessionSummary` consumers
|
||||
* (quiescence preconditions, the Channel busy probe), to the coalesced-restore
|
||||
* waiter response, and to the daemon status snapshot's per-session table.
|
||||
*
|
||||
* Deliberately NOT used for the restore RESPONSE's `hasActivePrompt`
|
||||
* (computed as `restorePromptAdmitted || promptActive || goalTurnActive`):
|
||||
* the restore route reads that value to decide between relocating the
|
||||
* restored session and re-firing the deferred prompt, and counting the
|
||||
* parked prompt there would push a working deferred restore into the
|
||||
* fail-closed active-session branch.
|
||||
*/
|
||||
function hasInFlightPromptActivity(
|
||||
entry: Pick<
|
||||
SessionEntry,
|
||||
'promptActive' | 'goalTurnActive' | 'deferredRestoreAskUserQuestionPrompts'
|
||||
>,
|
||||
): boolean {
|
||||
return (
|
||||
entry.promptActive ||
|
||||
entry.goalTurnActive === true ||
|
||||
(entry.deferredRestoreAskUserQuestionPrompts?.size ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function isDefinitiveAcpRequestError(error: unknown): boolean {
|
||||
if (error instanceof RequestError) return true;
|
||||
if (!isRecord(error)) return false;
|
||||
@@ -1162,6 +1193,25 @@ interface SessionEntry {
|
||||
* snapshot settle the question.
|
||||
*/
|
||||
activeWorkCloseInFlight: boolean;
|
||||
/**
|
||||
* Consecutive conditional-close probes that produced no answer. A run
|
||||
* counter, not a lifetime total. Cleared whenever the daemon learns the
|
||||
* world moved on — the child answering a probe either way, a snapshot
|
||||
* reporting held work, or a snapshot omitting the Session because the child
|
||||
* has let go of it — so a Session that recovers visibly is probed again on
|
||||
* the next snapshot with no memory of the earlier failures. One that
|
||||
* recovers silently produces none of those and is probed again when the
|
||||
* rung expires instead; see `activeWorkCloseRetryAt`.
|
||||
*/
|
||||
activeWorkCloseFailures: number;
|
||||
/**
|
||||
* Epoch ms before which `entryIsAutoCloseCandidate` suppresses a probe.
|
||||
* Derived from `activeWorkCloseFailures` via `activeWorkCloseRetryDelayMs`;
|
||||
* `null` while probing stays immediate. Gated at the candidacy check rather
|
||||
* than inside the probe so the reaper's own log line stops firing too —
|
||||
* a suppressed probe that still announced itself would read as progress.
|
||||
*/
|
||||
activeWorkCloseRetryAt: number | null;
|
||||
/**
|
||||
* Detailed list of prompts accepted into the FIFO queue. Each entry
|
||||
* carries its `promptId`, summary, and an `abortController` so the
|
||||
@@ -3156,13 +3206,35 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
const capability = owner?.activeWork;
|
||||
if (
|
||||
capability &&
|
||||
!channelIsCondemned(owner) &&
|
||||
childCloseNeedsRoundTrip(owner) &&
|
||||
ACTIVE_WORK_HOLD_CATEGORIES.some(
|
||||
(category) => !capability.categories.includes(category),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// A probe the child could not answer is retried on the next snapshot, but
|
||||
// not on every snapshot forever: a Session the child can never settle
|
||||
// would otherwise be re-probed at the report cadence for the lifetime of
|
||||
// the daemon, each probe spending a full drain budget and each one holding
|
||||
// this Session closed to admission while it runs. The delay is derived
|
||||
// from a run of consecutive failures and cleared whenever the daemon
|
||||
// learns the Session moved on — an answer, a hold report, or the child
|
||||
// dropping it from a snapshot — so a wedge that resolves visibly costs one
|
||||
// deferral rather than being stranded. One that resolves silently is
|
||||
// re-probed when the delay expires, bounded by the ladder's ceiling.
|
||||
//
|
||||
// A channel that needs no round trip is exempt: there is no probe to back
|
||||
// off from, and deferring would leave the escape hatch unreachable while
|
||||
// the retained Session keeps the channel non-empty, so a channel already
|
||||
// given up on could never drain.
|
||||
if (
|
||||
childCloseNeedsRoundTrip(owner) &&
|
||||
entry.activeWorkCloseRetryAt !== null &&
|
||||
Date.now() < entry.activeWorkCloseRetryAt
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// DAEMON-005: hold the session open during the prompt-settled grace
|
||||
// window so a poll-based client can reconnect without forcing a
|
||||
// session-rebuild epoch_reset resync.
|
||||
@@ -3370,7 +3442,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
}).catch((err) => {
|
||||
writeStderrLine(
|
||||
`qwen serve: deferred close (${opts.trigger}) failed for ` +
|
||||
`${JSON.stringify(entry.sessionId)}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
|
||||
`${JSON.stringify(entry.sessionId)}: ${err instanceof Error ? (err.stack ?? err.message) : extractErrorMessage(err)}`,
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
@@ -3427,6 +3499,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
ACTIVE_WORK_CLOSE_TIMEOUT_MS,
|
||||
SERVE_CONTROL_EXT_METHODS.sessionClose,
|
||||
);
|
||||
// The child answered, so the run of unanswered probes is over — granted
|
||||
// or refused. Hoisted above the `closed` branch because a granted close
|
||||
// can still fail its local teardown and leave the entry registered and
|
||||
// usable, and it must not carry a stale count into the next probe.
|
||||
entry.activeWorkCloseFailures = 0;
|
||||
entry.activeWorkCloseRetryAt = null;
|
||||
if (response['closed'] === true) {
|
||||
// The child is done with it; only local teardown remains.
|
||||
return true;
|
||||
@@ -3459,10 +3537,19 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
entry.activeWorkCloseFailures++;
|
||||
const delayMs = activeWorkCloseRetryDelayMs(
|
||||
entry.activeWorkCloseFailures,
|
||||
);
|
||||
entry.activeWorkCloseRetryAt =
|
||||
delayMs === null ? null : Date.now() + delayMs;
|
||||
writeStderrLine(
|
||||
`qwen serve: close-if-unheld for session ${JSON.stringify(entry.sessionId)} ` +
|
||||
`did not resolve (${err instanceof Error ? err.message : String(err)}); ` +
|
||||
`leaving it in place for the next snapshot to settle`,
|
||||
`did not resolve (${extractErrorMessage(err)}); ` +
|
||||
(delayMs === null
|
||||
? `leaving it in place for the next snapshot to settle`
|
||||
: `leaving it in place and deferring the next probe by ${delayMs}ms ` +
|
||||
`after ${entry.activeWorkCloseFailures} consecutive failures`),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -3516,10 +3603,32 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
touchActivity();
|
||||
}
|
||||
if (holds.size === 0) {
|
||||
// Absence is the one recovery signal that cannot be misread: the child
|
||||
// has let go of the Session entirely, so a probe can only answer
|
||||
// `closed`, and it answers an unknown id from `closeStoredSession`'s
|
||||
// early return without ever entering the drain. A backoff earned
|
||||
// against a Session the child was still holding no longer applies, and
|
||||
// keeping it would suppress exactly the reconciliation described above
|
||||
// and strand a ghost entry the child has already destroyed.
|
||||
//
|
||||
// Deliberately not extended to `child_idle`: named with no holds is the
|
||||
// wedge the backoff exists for, and clearing there pins the run at a
|
||||
// single failure forever.
|
||||
if (!reported.has(sessionId)) {
|
||||
entry.activeWorkCloseFailures = 0;
|
||||
entry.activeWorkCloseRetryAt = null;
|
||||
}
|
||||
void maybeCloseIdleSession(
|
||||
entry,
|
||||
reported.has(sessionId) ? 'child_idle' : 'child_dropped',
|
||||
);
|
||||
} else {
|
||||
// Reporting held work is an answer, so a run of unanswered close
|
||||
// probes is over: once this Session goes idle again the daemon may
|
||||
// probe it on the next snapshot instead of waiting out a backoff that
|
||||
// was earned against a different state of the world.
|
||||
entry.activeWorkCloseFailures = 0;
|
||||
entry.activeWorkCloseRetryAt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3778,6 +3887,31 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a conditional close on this channel has to ask the child at all.
|
||||
*
|
||||
* The mirror of `confirmChildUnheld`'s two authorize-locally short-circuits:
|
||||
* a channel that never negotiated active-work, and one the session lifecycle
|
||||
* has condemned, are both closed locally with no round trip. Guards that back
|
||||
* off a *probe* must consult this rather than re-derive the pair by hand, or
|
||||
* they keep deferring an entry whose teardown needs nobody's permission — and
|
||||
* for a condemned channel that teardown is the only thing that can release a
|
||||
* request nobody is going to answer, while the retained Session is what keeps
|
||||
* the channel non-empty and its drain from ever completing.
|
||||
*
|
||||
* `confirmChildUnheld`'s third non-round-trip exit, `isDying`, deliberately
|
||||
* does not belong here: it retains the Session rather than authorizing its
|
||||
* close, and it sits ahead of the condemned check, so folding it in would
|
||||
* flip a dying-and-condemned channel from retain to authorize.
|
||||
*/
|
||||
function childCloseNeedsRoundTrip(owner: ChannelInfo | undefined): boolean {
|
||||
return (
|
||||
owner !== undefined &&
|
||||
owner.activeWork !== undefined &&
|
||||
!channelIsCondemned(owner)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A restore that blew its public deadline is left running because the ACP
|
||||
* request cannot be cancelled — but "cannot cancel" must not mean "wait
|
||||
@@ -4099,7 +4233,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
...(entry.sourceType ? { sourceType: entry.sourceType } : {}),
|
||||
...(entry.sourceId !== undefined ? { sourceId: entry.sourceId } : {}),
|
||||
clientCount: entry.clientIds.size,
|
||||
hasActivePrompt: entry.promptActive || entry.goalTurnActive === true,
|
||||
hasActivePrompt: hasInFlightPromptActivity(entry),
|
||||
isWaitingForPermission,
|
||||
isWaitingForUserQuestion,
|
||||
pendingInteractionCount: entry.pendingInteractions.size,
|
||||
@@ -4229,6 +4363,32 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
// duplicate history frames or race two entries into `byId`.
|
||||
const inFlightRestores = new Map<string, InFlightRestore>();
|
||||
|
||||
// Sessions whose worktree ownership is being transferred to a replacement
|
||||
// session (worktree reset). While an id is present, every writer that could
|
||||
// reach the superseded session's checkout or cwd refuses admission
|
||||
// synchronously. Keyed by id (not the SessionEntry) so the barrier survives
|
||||
// entry replacement and dies only with the process. The reset route clears
|
||||
// it on every outcome up to the marker flip; past the flip the release
|
||||
// belongs to a completed severance, so a post-commit failure leaves the
|
||||
// entry fenced for the retry that finishes the transfer.
|
||||
const resetPendingSessions = new Set<string>();
|
||||
|
||||
/**
|
||||
* Barrier check for the writers other than `sendPrompt`: a rewind restores
|
||||
* files under the session cwd, a fork agent runs tools there, a shell
|
||||
* command executes in `effectiveCwd`, a branch mutates the session's
|
||||
* persisted history, a cwd change moves the session inside the checkout, a
|
||||
* workflow action runs through the session's tool registry in that cwd, and
|
||||
* a goal control starts a turn that never passes `sendPrompt`. Each is
|
||||
* admitted precisely in the idle state the transfer requires, so each fails
|
||||
* closed on the same id-keyed barrier.
|
||||
*/
|
||||
function assertSessionResetNotPending(sessionId: string): void {
|
||||
if (resetPendingSessions.has(sessionId)) {
|
||||
throw new SessionResetPendingError(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async function settleReleasedRuntimeWork(
|
||||
context: string,
|
||||
armIdleTimer = true,
|
||||
@@ -6927,6 +7087,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
childHolds: null,
|
||||
childHoldsAt: null,
|
||||
activeWorkCloseInFlight: false,
|
||||
activeWorkCloseFailures: 0,
|
||||
activeWorkCloseRetryAt: null,
|
||||
retryAllowed: false,
|
||||
promptSettledAt: null,
|
||||
promptSettledCloseTimer: undefined,
|
||||
@@ -7992,7 +8154,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
attached: true,
|
||||
clientId,
|
||||
createdAt: entry.createdAt,
|
||||
hasActivePrompt: entry.promptActive || entry.goalTurnActive === true,
|
||||
// Unlike the owner-side restore responses, the waiter has no
|
||||
// `restorePromptAdmitted` term; a prompt the owner parked while the
|
||||
// restore was in flight must still read as active here so the
|
||||
// waiter's caller never relocates or resets under a deferred prompt.
|
||||
hasActivePrompt: hasInFlightPromptActivity(entry),
|
||||
...(entry.sourceType ? { sourceType: entry.sourceType } : {}),
|
||||
...(entry.sourceId !== undefined ? { sourceId: entry.sourceId } : {}),
|
||||
...(sourcePersisted !== undefined ? { sourcePersisted } : {}),
|
||||
@@ -9208,8 +9374,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
attachCount: entry.attachCount,
|
||||
pendingPromptCount: entry.pendingPromptCount,
|
||||
pendingPermissionCount: entry.pendingPermissionIds.size,
|
||||
hasActivePrompt:
|
||||
entry.promptActive || entry.goalTurnActive === true,
|
||||
hasActivePrompt: hasInFlightPromptActivity(entry),
|
||||
lastEventId: entry.events.lastEventId,
|
||||
...(entry.sessionLastSeenAt !== undefined
|
||||
? { lastSeenAt: entry.sessionLastSeenAt }
|
||||
@@ -9853,6 +10018,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
),
|
||||
);
|
||||
}
|
||||
// Worktree-reset barrier: the transfer owns this session's worktree
|
||||
// from barrier-arm to outcome, and a prompt admitted in between would
|
||||
// write to a checkout whose ownership just moved. Synchronous throw so
|
||||
// the prompt route rejects before returning 202 (same admission
|
||||
// contract as PromptQueueFullError).
|
||||
if (resetPendingSessions.has(sessionId)) {
|
||||
throw new SessionResetPendingError(sessionId);
|
||||
}
|
||||
if (
|
||||
isReservedStandaloneSessionSourceType(entry.sourceType) &&
|
||||
entry.managedConversationBinding?.released !== true
|
||||
@@ -10889,6 +11062,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
resolveTrustedClientId(entry, context.clientId);
|
||||
}
|
||||
|
||||
// A branch mutates the superseded session's persisted history and spawns
|
||||
// a derivative session while the checkout's ownership is in flux.
|
||||
assertSessionResetNotPending(sessionId);
|
||||
|
||||
const concurrentSideTask = isSideTask && entry.promptActive;
|
||||
// Admission-time check: pendingPromptCount changes synchronously when a
|
||||
// prompt is accepted, before its queue callback sets promptActive. A
|
||||
@@ -11199,6 +11376,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
context?.clientId,
|
||||
);
|
||||
|
||||
// Relocation moves the session's cwd — including into a subdir of the
|
||||
// checkout the transfer is moving. The route relocates the *replacement*
|
||||
// (never armed), so the superseded id fails closed here.
|
||||
assertSessionResetNotPending(sessionId);
|
||||
|
||||
// Chain onto promptQueue and update tail — ensures:
|
||||
// 1. cd waits for any in-flight prompt to complete
|
||||
// 2. Subsequent prompts wait for cd to complete (prevents stale config.cwd)
|
||||
@@ -11440,6 +11622,65 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
}
|
||||
},
|
||||
|
||||
clearSessionWorktree(sessionId) {
|
||||
const entry = byId.get(sessionId);
|
||||
if (entry?.worktree !== undefined) {
|
||||
delete entry.worktree;
|
||||
markSessionCatalogChanged();
|
||||
}
|
||||
},
|
||||
|
||||
setSessionResetPending(sessionId) {
|
||||
// The barrier is id-keyed and survives entry replacement; arming a
|
||||
// session with no live entry still counts (a dormant session is
|
||||
// quiescent, but a restore racing the transfer must not re-admit it —
|
||||
// the reset route holds the worktree-keyed restore lock for that).
|
||||
resetPendingSessions.add(sessionId);
|
||||
return byId.has(sessionId);
|
||||
},
|
||||
|
||||
clearSessionResetPending(sessionId) {
|
||||
resetPendingSessions.delete(sessionId);
|
||||
},
|
||||
|
||||
async severSessionClients(sessionId) {
|
||||
// Detach every registered client. The last detach runs the natural
|
||||
// idle-close path (transcript and persisted record survive), which is
|
||||
// the desired end state for a superseded session: no client may keep
|
||||
// writing through it once its worktree moved to the replacement.
|
||||
//
|
||||
// Registrations are refcounted and one `detachClient` drops exactly one,
|
||||
// so a clientId registered twice (an SDK reconnect whose first detach
|
||||
// never arrived) survives a single pass over the key snapshot at count 1
|
||||
// — and a non-empty `clientIds` is precisely what holds the idle close
|
||||
// off. Re-snapshot the keys each pass, once per remaining registration.
|
||||
const entry = byId.get(sessionId);
|
||||
if (!entry) return true;
|
||||
const registrations = (): number => {
|
||||
let total = 0;
|
||||
for (const count of entry.clientIds.values()) total += count;
|
||||
return total;
|
||||
};
|
||||
let outstanding = registrations();
|
||||
while (outstanding > 0 && byId.get(sessionId) === entry) {
|
||||
for (const clientId of [...entry.clientIds.keys()]) {
|
||||
await bridgeApi.detachClient(sessionId, clientId);
|
||||
}
|
||||
// Bail on a pass that made no net progress: a client reconnecting as
|
||||
// fast as we detach would otherwise spin this request forever, and the
|
||||
// reset route holds the worktree ownership lock while it runs.
|
||||
const remaining = registrations();
|
||||
if (remaining >= outstanding) break;
|
||||
outstanding = remaining;
|
||||
}
|
||||
// The idle close is conditional: a child that holds work (a background
|
||||
// shell inside the worktree) refuses it and the close is deferred, so
|
||||
// the documented end state above is not reached and the superseded
|
||||
// session stays live and re-attachable. Report which happened rather
|
||||
// than leaving the caller to assume the entry is gone.
|
||||
return !byId.has(sessionId);
|
||||
},
|
||||
|
||||
fireDeferredRestoreAskUserQuestionPrompt(sessionId, clientId) {
|
||||
if (clientId === undefined) return false;
|
||||
const entry = byId.get(sessionId);
|
||||
@@ -12257,6 +12498,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
const entry = byId.get(sessionId);
|
||||
if (!entry) throw new SessionNotFoundError(sessionId);
|
||||
resolveTrustedClientId(entry, context?.clientId);
|
||||
|
||||
// A workflow action runs a saved workflow, or restarts a live run,
|
||||
// through this session's own tool registry in its cwd — the checkout the
|
||||
// transfer is moving — and sets none of the busy flags the barrier or the
|
||||
// route's quiescence re-check reads.
|
||||
assertSessionResetNotPending(sessionId);
|
||||
return requestSessionStatus<{
|
||||
changed: boolean;
|
||||
status?: ServeSessionWorkflowTaskStatus['status'];
|
||||
@@ -12273,6 +12520,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
const info = channelInfoForEntry(entry);
|
||||
if (!info || info.isDying) throw new SessionNotFoundError(sessionId);
|
||||
resolveTrustedClientId(entry, context?.clientId);
|
||||
|
||||
// A goal `resume` promotes a queued user turn and queues a continuation,
|
||||
// so it starts work in this session's cwd without ever passing the
|
||||
// fenced `sendPrompt` admission.
|
||||
assertSessionResetNotPending(sessionId);
|
||||
return requestSessionStatus(
|
||||
sessionId,
|
||||
SERVE_CONTROL_EXT_METHODS.sessionGoalControl,
|
||||
@@ -13484,6 +13736,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
if (!trimmed) {
|
||||
throw new Error('Fork directive is required');
|
||||
}
|
||||
// The fork agent runs its tools in this session's cwd — the checkout the
|
||||
// transfer is moving — and it chains onto the same queue a prompt would.
|
||||
assertSessionResetNotPending(sessionId);
|
||||
if (entry.pendingPromptCount > 0 || entry.promptActive) {
|
||||
throw new SessionBusyError(
|
||||
sessionId,
|
||||
@@ -13571,6 +13826,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
context.clientId,
|
||||
);
|
||||
|
||||
// A shell command runs in `entry.effectiveCwd`, which for a worktree
|
||||
// session is the checkout itself — the strongest writer vector on the
|
||||
// superseded session, not a workspace-cwd one.
|
||||
assertSessionResetNotPending(sessionId);
|
||||
|
||||
if (signal?.aborted) {
|
||||
return { exitCode: null, output: '', aborted: true };
|
||||
}
|
||||
@@ -13739,6 +13999,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
||||
context?.clientId,
|
||||
);
|
||||
|
||||
// A rewind restores files relative to the session cwd, so it writes into
|
||||
// the checkout the transfer is moving — and it is admitted precisely in
|
||||
// the idle state the transfer requires, setting none of the flags the
|
||||
// barrier or the route's re-check reads.
|
||||
assertSessionResetNotPending(sessionId);
|
||||
|
||||
// Admission-time check: a rewind queued behind an active prompt runs
|
||||
// after the prompt's `finally` clears the busy flags, and client-side
|
||||
// timeouts cannot cancel queued work — the rewind would truncate
|
||||
|
||||
@@ -705,6 +705,25 @@ export class CdWhilePromptActiveError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admission refusal for a session whose worktree ownership is being
|
||||
* transferred to a replacement session (worktree reset). The daemon arms the
|
||||
* barrier before the transfer's first side effect, so every prompt source and
|
||||
* every other writer that could reach the session's checkout or cwd fails
|
||||
* closed for its duration; the session id in the message is the superseded
|
||||
* one.
|
||||
*/
|
||||
export class SessionResetPendingError extends Error {
|
||||
readonly sessionId: string;
|
||||
constructor(sessionId: string) {
|
||||
super(
|
||||
`Session ${sessionId} is mid worktree reset; prompt admission is closed until the reset completes`,
|
||||
);
|
||||
this.name = 'SessionResetPendingError';
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
export class McpAuthenticationInProgressError extends Error {
|
||||
constructor() {
|
||||
super('Another MCP authentication is already in progress');
|
||||
|
||||
@@ -326,6 +326,46 @@ export const ACTIVE_WORK_CLOSE_TIMEOUT_MS = 10_000;
|
||||
export function sessionCloseDrainBudgetMs(outerWaitMs: number): number {
|
||||
return Math.max(1, Math.floor(outerWaitMs * 0.8));
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff for a conditional close that keeps failing.
|
||||
*
|
||||
* One deferral is the documented recovery for a lost close response: the next
|
||||
* snapshot asks again, and a child that already closed answers `closed` for a
|
||||
* Session it no longer has. That first retry therefore stays immediate. What
|
||||
* is not recoverable is the same probe failing on every snapshot forever — a
|
||||
* Session the child can never settle would otherwise be re-probed at the
|
||||
* report cadence for the lifetime of the daemon, spending a full drain budget
|
||||
* each time. Past `GRACE` consecutive failures the next probe is deferred
|
||||
* geometrically up to `CEILING`.
|
||||
*
|
||||
* The count resets on any evidence that the world moved on — the child
|
||||
* answering a probe either way, a snapshot reporting held work, or a snapshot
|
||||
* omitting the Session because the child has let go of it — so a wedge that
|
||||
* resolves visibly is never stranded, and goes back to being probed on the
|
||||
* next snapshot exactly as it was before the run of failures began. A wedge
|
||||
* that resolves silently is not: work of a kind the child cannot report as a
|
||||
* hold (see #11118) produces none of those signals, so such a Session is
|
||||
* probed again when the rung expires instead, and `CEILING` is what bounds
|
||||
* that rather than any reset.
|
||||
*/
|
||||
export const ACTIVE_WORK_CLOSE_RETRY_GRACE = 1;
|
||||
export const ACTIVE_WORK_CLOSE_RETRY_BASE_MS = 60_000;
|
||||
export const ACTIVE_WORK_CLOSE_RETRY_CEILING_MS = 3_600_000;
|
||||
|
||||
/**
|
||||
* How long to defer the next conditional-close probe after `failures`
|
||||
* consecutive unanswered probes; `null` while probing stays immediate.
|
||||
*/
|
||||
export function activeWorkCloseRetryDelayMs(failures: number): number | null {
|
||||
if (failures <= ACTIVE_WORK_CLOSE_RETRY_GRACE) return null;
|
||||
const exponent = failures - ACTIVE_WORK_CLOSE_RETRY_GRACE - 1;
|
||||
return Math.min(
|
||||
ACTIVE_WORK_CLOSE_RETRY_BASE_MS * 2 ** exponent,
|
||||
ACTIVE_WORK_CLOSE_RETRY_CEILING_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Bounds on a single snapshot. Generous next to any real deployment — it
|
||||
* exists so a version-skewed or buggy child cannot make the daemon walk an
|
||||
* unbounded Session list per report. An oversized packet is discarded whole. */
|
||||
@@ -336,14 +376,14 @@ export const ACTIVE_WORK_MAX_SESSION_HOLDS = 1024;
|
||||
export const WORKTREE_MCP_DEFER_META_KEY = 'qwen.session.deferMcpDiscovery';
|
||||
|
||||
/**
|
||||
* Work categories a child reports holds for. Monitors and cron remain outside
|
||||
* `activeWork`'s declared scope. The category travels on every
|
||||
* Work categories a child reports holds for. The category travels on every
|
||||
* hold so peers can negotiate coverage explicitly when the scope widens.
|
||||
*/
|
||||
export type ActiveWorkHoldCategory =
|
||||
| 'agent'
|
||||
| 'notification'
|
||||
| 'shell'
|
||||
| 'session'
|
||||
| 'workflow';
|
||||
|
||||
/** Categories understood by active-work v1 before category negotiation was
|
||||
@@ -355,6 +395,7 @@ export const ACTIVE_WORK_HOLD_CATEGORIES: readonly ActiveWorkHoldCategory[] = [
|
||||
'agent',
|
||||
'notification',
|
||||
'shell',
|
||||
'session',
|
||||
'workflow',
|
||||
];
|
||||
|
||||
@@ -1368,6 +1409,7 @@ export interface BridgeBackgroundNotification {
|
||||
status: 'completed' | 'failed' | 'cancelled';
|
||||
kind: 'agent';
|
||||
toolUseId?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type RuntimeMcpServerAddResult =
|
||||
@@ -1530,6 +1572,49 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
|
||||
worktree: { slug: string; path: string; branch: string },
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Clear the in-memory worktree association of a live session. Used by the
|
||||
* worktree-reset transfer after the marker moved to the replacement
|
||||
* session, so the superseded session's runtime view matches the disk
|
||||
* state. No-op when the session is not live or carries no worktree.
|
||||
*/
|
||||
clearSessionWorktree?(sessionId: string): void;
|
||||
|
||||
/**
|
||||
* Arm the worktree-reset admission barrier for a session id: while armed,
|
||||
* `sendPrompt` and the other writers that reach the session's checkout or
|
||||
* cwd (`rewindSession`, `launchSessionForkAgent`, `branchSession`,
|
||||
* `changeSessionCwd`, `executeShellCommand`, `controlSessionWorkflowTask`,
|
||||
* `controlSessionGoal`) throw `SessionResetPendingError` synchronously at
|
||||
* admission. Returns whether a live entry currently exists for the id — a
|
||||
* dormant session counts as quiescent but is still fenced against
|
||||
* re-admission. Optional so lightweight fakes may omit it.
|
||||
*/
|
||||
setSessionResetPending?(sessionId: string): boolean;
|
||||
|
||||
/**
|
||||
* Disarm the worktree-reset admission barrier. Idempotent; the reset route
|
||||
* calls it on every transfer outcome up to the marker flip. Past the flip
|
||||
* only a completed severance clears it, so a post-commit failure leaves the
|
||||
* barrier armed for the retry that finishes the transfer.
|
||||
*/
|
||||
clearSessionResetPending?(sessionId: string): void;
|
||||
|
||||
/**
|
||||
* Detach every client registered on a live session. The last detach runs
|
||||
* the normal idle-close path (transcript and persisted record survive).
|
||||
* No-op for unknown ids. Used by the worktree-reset transfer to sever the
|
||||
* superseded session's residual attaches after the ownership flip.
|
||||
*
|
||||
* Resolves whether the session entry is gone from the registry once the
|
||||
* detach drain finishes. `false` means the child refused the conditional
|
||||
* idle close because it holds work (a background shell inside the worktree,
|
||||
* for example), so the superseded session is still live, re-attachable and
|
||||
* — once the barrier is cleared — promptable. Callers must not read a
|
||||
* resolved call as "severed" without checking this.
|
||||
*/
|
||||
severSessionClients?(sessionId: string): Promise<boolean>;
|
||||
|
||||
/** Admit a restore question deferred by the daemon's integrity gate. */
|
||||
fireDeferredRestoreAskUserQuestionPrompt?(
|
||||
sessionId: string,
|
||||
@@ -2426,11 +2511,7 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
|
||||
/** Number of sessions with an active prompt. */
|
||||
readonly activePromptCount: number;
|
||||
|
||||
/**
|
||||
* Whether an accepted prompt, a running background Agent, an Agent terminal
|
||||
* notification, or Session-managed background shell work is unsettled.
|
||||
* Monitors, workflows, and cron are deliberately outside this.
|
||||
*/
|
||||
/** Whether daemon-owned or child-reported Session work is unsettled. */
|
||||
readonly activeWork: boolean;
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,5 +12,12 @@ export default defineConfig({
|
||||
include: ['src/**/*.test.ts'],
|
||||
// RPC-timeout exemption; see scripts/tests/unit-vitest-configs.test.ts.
|
||||
dangerouslyIgnoreUnhandledErrors: process.platform !== 'linux',
|
||||
// Shared-pool ceiling; see scripts/tests/unit-vitest-configs.test.ts.
|
||||
// Raised only on the ECS pool, where the same suite runs ~5x slower
|
||||
// depending on which host it lands on (#10490); off the pool this stays
|
||||
// `undefined` so vitest's 5s default keeps catching a genuine hang fast.
|
||||
testTimeout: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-')
|
||||
? 60_000
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
@@ -905,9 +905,9 @@ describe('AcpBridge', () => {
|
||||
cliEntryPath: '/tmp/qwen',
|
||||
cwd: '/tmp',
|
||||
}) as unknown as TestableAcpBridge;
|
||||
const backgroundResponses: Array<[string, string]> = [];
|
||||
bridge.on('backgroundResponse', (sessionId, text) => {
|
||||
backgroundResponses.push([sessionId, text]);
|
||||
const backgroundResponses: unknown[][] = [];
|
||||
bridge.on('backgroundResponse', (sessionId, text, context) => {
|
||||
backgroundResponses.push([sessionId, text, context]);
|
||||
});
|
||||
const textChunks: Array<[string, string]> = [];
|
||||
bridge.on('textChunk', (sessionId, text) => {
|
||||
@@ -922,11 +922,82 @@ describe('AcpBridge', () => {
|
||||
_meta: {
|
||||
source: 'background_notification_response',
|
||||
qwenDiscreteMessage: true,
|
||||
backgroundTask: {
|
||||
taskId: 'agent-1',
|
||||
status: 'completed',
|
||||
kind: 'agent',
|
||||
label: 'dependency check',
|
||||
turnId: 'notification-turn-1',
|
||||
turnComplete: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
bridge.handleSessionUpdate({
|
||||
sessionId: 's-1',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
source: 'background_notification_response',
|
||||
qwenDiscreteMessage: true,
|
||||
backgroundTask: {
|
||||
taskId: 'agent-1',
|
||||
status: 'completed',
|
||||
kind: 'agent',
|
||||
label: 'dependency check',
|
||||
turnComplete: true,
|
||||
partial: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const backgroundTask of [
|
||||
undefined,
|
||||
{ taskId: '', status: 'completed', kind: 'agent' },
|
||||
]) {
|
||||
bridge.handleSessionUpdate({
|
||||
sessionId: 's-1',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Legacy background answer.' },
|
||||
_meta: {
|
||||
source: 'background_notification_response',
|
||||
qwenDiscreteMessage: true,
|
||||
...(backgroundTask ? { backgroundTask } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
expect(backgroundResponses).toEqual([['s-1', 'Background final answer.']]);
|
||||
expect(backgroundResponses).toEqual([
|
||||
[
|
||||
's-1',
|
||||
'Background final answer.',
|
||||
{
|
||||
taskId: 'agent-1',
|
||||
status: 'completed',
|
||||
kind: 'agent',
|
||||
label: 'dependency check',
|
||||
turnId: 'notification-turn-1',
|
||||
turnComplete: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
's-1',
|
||||
'',
|
||||
{
|
||||
taskId: 'agent-1',
|
||||
status: 'completed',
|
||||
kind: 'agent',
|
||||
label: 'dependency check',
|
||||
turnComplete: true,
|
||||
partial: true,
|
||||
},
|
||||
],
|
||||
['s-1', 'Legacy background answer.', undefined],
|
||||
['s-1', 'Legacy background answer.', undefined],
|
||||
]);
|
||||
expect(textChunks).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CHANNEL_BTW_METHOD,
|
||||
CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY,
|
||||
CHANNEL_PROMPT_META_KEY,
|
||||
parseBackgroundResponseContext,
|
||||
resolvePromptImages,
|
||||
type AvailableCommand,
|
||||
type ChannelAgentBridge,
|
||||
@@ -457,11 +458,22 @@ export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
|
||||
if (meta?.['qwenDiscreteMessage'] === true) {
|
||||
if (
|
||||
meta['source'] === 'background_notification_response' &&
|
||||
meta['rewritten'] !== true &&
|
||||
content?.type === 'text' &&
|
||||
content.text
|
||||
meta['rewritten'] !== true
|
||||
) {
|
||||
this.emit('backgroundResponse', sessionId, content.text);
|
||||
const context = parseBackgroundResponseContext(
|
||||
meta['backgroundTask'],
|
||||
);
|
||||
if (
|
||||
content?.type === 'text' &&
|
||||
(content.text || context?.turnComplete)
|
||||
) {
|
||||
this.emit(
|
||||
'backgroundResponse',
|
||||
sessionId,
|
||||
content.text ?? '',
|
||||
context,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
meta['source'] === 'vision_bridge_notice' &&
|
||||
content?.type === 'text' &&
|
||||
|
||||
@@ -81,10 +81,64 @@ export interface PermissionResolvedEvent {
|
||||
outcome?: RequestPermissionResponse['outcome'];
|
||||
}
|
||||
|
||||
export interface BackgroundResponseContext {
|
||||
taskId: string;
|
||||
status: string;
|
||||
kind: 'agent' | 'monitor' | 'shell' | 'workflow';
|
||||
toolUseId?: string;
|
||||
label?: string;
|
||||
turnId?: string;
|
||||
turnComplete?: boolean;
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
export function parseBackgroundResponseContext(
|
||||
value: unknown,
|
||||
): BackgroundResponseContext | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const taskId = record['taskId'];
|
||||
const status = record['status'];
|
||||
const kind = record['kind'];
|
||||
if (
|
||||
typeof taskId !== 'string' ||
|
||||
!taskId ||
|
||||
typeof status !== 'string' ||
|
||||
!status ||
|
||||
(kind !== 'agent' &&
|
||||
kind !== 'monitor' &&
|
||||
kind !== 'shell' &&
|
||||
kind !== 'workflow')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const context: BackgroundResponseContext = { taskId, status, kind };
|
||||
for (const field of ['toolUseId', 'label', 'turnId'] as const) {
|
||||
const fieldValue = record[field];
|
||||
if (typeof fieldValue === 'string' && fieldValue) {
|
||||
context[field] = fieldValue;
|
||||
}
|
||||
}
|
||||
if (typeof record['turnComplete'] === 'boolean') {
|
||||
context.turnComplete = record['turnComplete'];
|
||||
}
|
||||
if (typeof record['partial'] === 'boolean') {
|
||||
context.partial = record['partial'];
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ChannelAgentBridgeEventMap {
|
||||
sessionDied: [SessionDiedEvent];
|
||||
textChunk: [sessionId: string, chunk: string];
|
||||
backgroundResponse: [sessionId: string, text: string];
|
||||
backgroundResponse: [
|
||||
sessionId: string,
|
||||
text: string,
|
||||
context?: BackgroundResponseContext,
|
||||
];
|
||||
responseBoundary: [sessionId: string];
|
||||
toolCall: [ToolCallEvent];
|
||||
permissionRequest: [PermissionRequestEvent];
|
||||
@@ -192,6 +246,17 @@ export interface ChannelAgentBridge {
|
||||
options?: ChannelAgentBridgeSessionOptions,
|
||||
bindingToken?: object,
|
||||
): Promise<string>;
|
||||
/**
|
||||
* Transfer a worktree session's checkout ownership to a fresh replacement
|
||||
* session and return the replacement's id. Bridges without daemon-side
|
||||
* worktree reset support omit it; callers fail closed.
|
||||
*/
|
||||
resetWorktreeSession?(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
options?: ChannelAgentBridgeSessionOptions,
|
||||
bindingToken?: object,
|
||||
): Promise<string>;
|
||||
prompt(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
|
||||
@@ -92,6 +92,7 @@ class TestChannel extends ChannelBase {
|
||||
sessionId: string;
|
||||
segment?: unknown;
|
||||
}> = [];
|
||||
retiringSessions: string[] = [];
|
||||
/** When set, onPromptEnd throws AFTER recording — to exercise the finally guard. */
|
||||
throwOnPromptEnd = false;
|
||||
responseCompleteGate?: Promise<void>;
|
||||
@@ -230,6 +231,10 @@ class TestChannel extends ChannelBase {
|
||||
this.promptStarts.push({ chatId, sessionId, messageId });
|
||||
}
|
||||
|
||||
protected override onSessionRetiring(sessionId: string): void {
|
||||
this.retiringSessions.push(sessionId);
|
||||
}
|
||||
|
||||
protected override onPromptEnd(
|
||||
chatId: string,
|
||||
sessionId: string,
|
||||
@@ -400,6 +405,32 @@ function createBridge(): ChannelAgentBridge {
|
||||
);
|
||||
return sessionId;
|
||||
}),
|
||||
resetWorktreeSession: vi
|
||||
.fn()
|
||||
.mockImplementation(async (sessionId: string, workspaceCwd: string) => {
|
||||
// The daemon transfers the checkout: the replacement session owns the
|
||||
// SAME worktree path, and the superseded session leaves the map.
|
||||
const previous = sessions.get(sessionId);
|
||||
const replacementId = `s-${++sessionCounter}`;
|
||||
sessions.set(
|
||||
replacementId,
|
||||
previous?.worktree
|
||||
? {
|
||||
sessionId: replacementId,
|
||||
workspaceCwd,
|
||||
hasActivePrompt: false,
|
||||
worktree: previous.worktree,
|
||||
worktreeState: 'persisted-v1' as const,
|
||||
}
|
||||
: {
|
||||
sessionId: replacementId,
|
||||
workspaceCwd,
|
||||
hasActivePrompt: false,
|
||||
},
|
||||
);
|
||||
sessions.delete(sessionId);
|
||||
return replacementId;
|
||||
}),
|
||||
prompt: vi.fn().mockResolvedValue('agent response'),
|
||||
btw: vi.fn().mockResolvedValue({
|
||||
sessionId: 's-1',
|
||||
@@ -453,6 +484,17 @@ function envelope(overrides: Partial<Envelope> = {}): Envelope {
|
||||
};
|
||||
}
|
||||
|
||||
/** A daemon conflict error by shape (channels/base keeps no SDK dependency). */
|
||||
function daemonError(
|
||||
code: string,
|
||||
extraBody: Record<string, unknown> = {},
|
||||
): Error {
|
||||
const error = new Error(`daemon rejected with ${code}`);
|
||||
error.name = 'DaemonHttpError';
|
||||
Object.assign(error, { status: 409, body: { code, ...extraBody } });
|
||||
return error;
|
||||
}
|
||||
|
||||
function pairingCodeOf(result: CreatePairingRequestResult): string {
|
||||
if ('code' in result) return result.code;
|
||||
throw new Error(
|
||||
@@ -4387,12 +4429,30 @@ describe('ChannelBase', () => {
|
||||
'Usage: /session current | /session new <name> [--worktree]',
|
||||
);
|
||||
expect(bridge.newSession).not.toHaveBeenCalled();
|
||||
|
||||
// The flag alone is missing the task name: it must return the usage
|
||||
// line, not fall through to a confusing name-validation error.
|
||||
await ch.handleInbound(envelope({ text: '/session new --worktree' }));
|
||||
expect(ch.sent.at(-1)?.text).toContain(
|
||||
'Usage: /session current | /session new <name> [--worktree]',
|
||||
);
|
||||
expect(bridge.newSession).not.toHaveBeenCalled();
|
||||
|
||||
// Any leading-flag token is missing the task name, not just
|
||||
// --worktree: the guard covers the class, and no legal task name
|
||||
// starts with '-' (TASK_NAME_PATTERN requires a leading
|
||||
// alphanumeric).
|
||||
await ch.handleInbound(envelope({ text: '/session new --force' }));
|
||||
expect(ch.sent.at(-1)?.text).toContain(
|
||||
'Usage: /session current | /session new <name> [--worktree]',
|
||||
);
|
||||
expect(bridge.newSession).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects clearing a selected worktree task before clear side effects', async () => {
|
||||
it('resets a selected worktree task and keeps its worktree', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
const ch = createChannel({ multiSession: true }, { stateDir });
|
||||
try {
|
||||
@@ -4400,15 +4460,225 @@ describe('ChannelBase', () => {
|
||||
envelope({ text: '/session new feature --worktree' }),
|
||||
);
|
||||
vi.mocked(bridge.newSession).mockClear();
|
||||
vi.mocked(bridge.cancelSession).mockClear();
|
||||
vi.mocked(bridge.discardSession!).mockClear();
|
||||
|
||||
await ch.handleInbound(envelope({ text: '/clear' }));
|
||||
|
||||
expect(ch.sent.at(-1)?.text).toContain('cannot be cleared or reset');
|
||||
expect(ch.sent.at(-1)?.text).toBe(
|
||||
'Task "feature" reset with a fresh conversation; its worktree and files were kept.',
|
||||
);
|
||||
expect(bridge.resetWorktreeSession).toHaveBeenCalledWith(
|
||||
's-1',
|
||||
'/tmp',
|
||||
{ sourceId: 'test-chan' },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(bridge.newSession).not.toHaveBeenCalled();
|
||||
expect(bridge.cancelSession).not.toHaveBeenCalled();
|
||||
|
||||
// The next turn binds to the replacement session in the kept worktree.
|
||||
await ch.handleInbound(envelope({ text: 'continue the task' }));
|
||||
expect(bridge.prompt).toHaveBeenLastCalledWith(
|
||||
's-2',
|
||||
expect.stringContaining('continue the task'),
|
||||
expect.anything(),
|
||||
);
|
||||
} finally {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the plain reset message for a shared task', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
const ch = createChannel({ multiSession: true }, { stateDir });
|
||||
try {
|
||||
await ch.handleInbound(envelope({ text: '/session new review' }));
|
||||
|
||||
await ch.handleInbound(envelope({ text: '/clear' }));
|
||||
|
||||
expect(ch.sent.at(-1)?.text).toBe(
|
||||
'Task "review" reset with a fresh conversation.',
|
||||
);
|
||||
} finally {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects clearing a busy worktree task before any reset call', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
let finishPrompt!: (response: string) => void;
|
||||
vi.mocked(bridge.prompt).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
finishPrompt = resolve;
|
||||
}),
|
||||
);
|
||||
const ch = createChannel({ multiSession: true }, { stateDir });
|
||||
try {
|
||||
await ch.handleInbound(
|
||||
envelope({ text: '/session new feature --worktree' }),
|
||||
);
|
||||
const running = ch.handleInbound(envelope({ text: 'long task' }));
|
||||
await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1));
|
||||
|
||||
await ch.handleInbound(envelope({ text: '/clear' }));
|
||||
|
||||
expect(ch.sent.at(-1)?.text).toBe(
|
||||
'Task "feature" is busy. Wait for the running prompt to finish (or cancel it), then try again.',
|
||||
);
|
||||
expect(bridge.resetWorktreeSession).not.toHaveBeenCalled();
|
||||
expect(bridge.discardSession).not.toHaveBeenCalled();
|
||||
|
||||
finishPrompt('done');
|
||||
await running;
|
||||
await ch.handleInbound(envelope({ text: '/session current' }));
|
||||
expect(ch.sent.at(-1)?.text).toContain('Current task: feature');
|
||||
} finally {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a daemon-rejected worktree reset as a busy task', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
const ch = createChannel({ multiSession: true }, { stateDir });
|
||||
try {
|
||||
await ch.handleInbound(
|
||||
envelope({ text: '/session new feature --worktree' }),
|
||||
);
|
||||
vi.mocked(bridge.resetWorktreeSession!).mockRejectedValueOnce(
|
||||
daemonError('worktree_reset_active'),
|
||||
);
|
||||
|
||||
await ch.handleInbound(envelope({ text: '/clear' }));
|
||||
|
||||
expect(ch.sent.at(-1)?.text).toBe(
|
||||
'Task is busy. Wait for the running prompt to finish (or cancel it), then try again.',
|
||||
);
|
||||
// The daemon busy signal is not retried.
|
||||
expect(bridge.resetWorktreeSession).toHaveBeenCalledTimes(1);
|
||||
await ch.handleInbound(envelope({ text: '/session current' }));
|
||||
expect(ch.sent.at(-1)?.text).toContain('Current task: feature');
|
||||
} finally {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an interrupted transfer when the task is selected or messaged', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
const recoveredBridge = createBridge();
|
||||
// The daemon's restore route reports the interrupted transfer, so it
|
||||
// surfaces when the task is loaded — never from the reset itself.
|
||||
vi.mocked(recoveredBridge.loadSession).mockRejectedValue(
|
||||
daemonError('worktree_reset_interrupted'),
|
||||
);
|
||||
const ch = createChannel({ multiSession: true }, { stateDir });
|
||||
const interrupted =
|
||||
'Task "feature" was interrupted while being reset. Its files were not changed. Clear the task again to finish the reset.';
|
||||
try {
|
||||
await ch.handleInbound(
|
||||
envelope({ text: '/session new feature --worktree' }),
|
||||
);
|
||||
ch.setBridge(recoveredBridge);
|
||||
|
||||
await ch.handleInbound(envelope({ text: '/session use feature' }));
|
||||
expect(ch.sent.at(-1)?.text).toBe(interrupted);
|
||||
|
||||
await ch.handleInbound(envelope({ text: 'continue the task' }));
|
||||
expect(ch.sent.at(-1)?.text).toBe(interrupted);
|
||||
|
||||
expect(recoveredBridge.prompt).not.toHaveBeenCalled();
|
||||
expect(recoveredBridge.resetWorktreeSession).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('names the broken task instead of pointing a clear at the selected one', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
const ch = createChannel({ multiSession: true }, { stateDir });
|
||||
try {
|
||||
await ch.handleInbound(
|
||||
envelope({ text: '/session new feature --worktree' }),
|
||||
);
|
||||
await ch.handleInbound(
|
||||
envelope({ text: '/session new review --worktree' }),
|
||||
);
|
||||
// Only feature reports the interrupted transfer; review, the task a
|
||||
// clear would act on, is healthy.
|
||||
vi.mocked(bridge.loadSession).mockImplementation(
|
||||
async (sessionId: string) => {
|
||||
if (sessionId === 's-1') {
|
||||
throw daemonError('worktree_reset_interrupted');
|
||||
}
|
||||
return sessionId;
|
||||
},
|
||||
);
|
||||
ch.setBridge(bridge);
|
||||
|
||||
await ch.handleInbound(envelope({ text: '/session use feature' }));
|
||||
|
||||
const reply = ch.sent.at(-1)?.text ?? '';
|
||||
expect(reply).toContain('feature');
|
||||
expect(reply).toContain('/session close feature');
|
||||
// Telling the user to clear again would run a full ownership transfer
|
||||
// against review and destroy a conversation that is not broken.
|
||||
expect(reply).not.toContain('Clear the task again');
|
||||
await ch.handleInbound(envelope({ text: '/session current' }));
|
||||
expect(ch.sent.at(-1)?.text).toContain('Current task: review');
|
||||
|
||||
await ch.handleInbound(envelope({ text: '/clear' }));
|
||||
expect(bridge.resetWorktreeSession).toHaveBeenCalledWith(
|
||||
's-2',
|
||||
'/tmp',
|
||||
{ sourceId: 'test-chan' },
|
||||
expect.anything(),
|
||||
);
|
||||
} finally {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a missing worktree marker when the task is selected', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
const recoveredBridge = createBridge();
|
||||
vi.mocked(recoveredBridge.loadSession).mockRejectedValue(
|
||||
daemonError('worktree_marker_missing'),
|
||||
);
|
||||
const ch = createChannel({ multiSession: true }, { stateDir });
|
||||
try {
|
||||
await ch.handleInbound(
|
||||
envelope({ text: '/session new feature --worktree' }),
|
||||
);
|
||||
ch.setBridge(recoveredBridge);
|
||||
|
||||
await ch.handleInbound(envelope({ text: '/session use feature' }));
|
||||
|
||||
expect(ch.sent.at(-1)?.text).toBe(
|
||||
'Task "feature" cannot verify its worktree because its ownership marker is missing. Its files were not changed. Clear the task to restart it in the same worktree, or close it.',
|
||||
);
|
||||
expect(recoveredBridge.resetWorktreeSession).not.toHaveBeenCalled();
|
||||
await ch.handleInbound(envelope({ text: '/session current' }));
|
||||
expect(ch.sent.at(-1)?.text).toContain('Current task: feature');
|
||||
} finally {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the generic named-session error for other reset failures', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
const ch = createChannel({ multiSession: true }, { stateDir });
|
||||
try {
|
||||
await ch.handleInbound(
|
||||
envelope({ text: '/session new feature --worktree' }),
|
||||
);
|
||||
vi.mocked(bridge.resetWorktreeSession!).mockRejectedValueOnce(
|
||||
daemonError('session_not_found'),
|
||||
);
|
||||
|
||||
await ch.handleInbound(envelope({ text: '/clear' }));
|
||||
|
||||
expect(ch.sent.at(-1)?.text).toBe('Could not reset task "feature".');
|
||||
expect(ch.sent.at(-1)?.text).not.toContain('s-1');
|
||||
await ch.handleInbound(envelope({ text: '/session current' }));
|
||||
expect(ch.sent.at(-1)?.text).toContain('Current task: feature');
|
||||
} finally {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -4822,6 +5092,23 @@ describe('ChannelBase', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('retires a closed named task so buffered output is drained', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
const ch = createChannel({ multiSession: true }, { stateDir });
|
||||
try {
|
||||
await ch.handleInbound(envelope({ text: '/session new review' }));
|
||||
ch.retiringSessions = [];
|
||||
|
||||
await ch.handleInbound(envelope({ text: '/session close review' }));
|
||||
|
||||
expect(ch.sent.at(-1)!.text).toContain('Closed task "review"');
|
||||
expect(bridge.discardSession).toHaveBeenCalledWith('s-1');
|
||||
expect(ch.retiringSessions).toEqual(['s-1']);
|
||||
} finally {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a named task busy for the full shell command', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
let finishShell!: (result: {
|
||||
@@ -4857,6 +5144,9 @@ describe('ChannelBase', () => {
|
||||
expect(ch.sent.at(-1)!.text).toContain(
|
||||
'still running or waiting for permission',
|
||||
);
|
||||
// A refused close must not retire the task: draining a live task's
|
||||
// buffer would flush output the turn has not finished producing.
|
||||
expect(ch.retiringSessions).toEqual([]);
|
||||
|
||||
finishShell({ exitCode: 0, output: 'ok', aborted: false });
|
||||
await running;
|
||||
@@ -4986,6 +5276,171 @@ describe('ChannelBase', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('dispatches a collected turn onto the session its reload healed to', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
let finishFirst!: (response: string) => void;
|
||||
vi.mocked(bridge.prompt).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
}),
|
||||
);
|
||||
// While the second message waits in the collect buffer, an external
|
||||
// client resets the task: the daemon supersedes s-1 and its restore
|
||||
// reports the replacement that now owns the same checkout.
|
||||
const healedBridge = createBridge();
|
||||
vi.mocked(healedBridge.loadSession).mockImplementation(
|
||||
async (sessionId: string) => {
|
||||
if (sessionId === 's-1') {
|
||||
throw daemonError('worktree_session_superseded', {
|
||||
replacementSessionId: 's-2',
|
||||
});
|
||||
}
|
||||
return sessionId;
|
||||
},
|
||||
);
|
||||
vi.mocked(healedBridge.listSessions!).mockReturnValue([
|
||||
{
|
||||
sessionId: 's-2',
|
||||
workspaceCwd: '/tmp',
|
||||
hasActivePrompt: false,
|
||||
worktree: { slug: 's-1', path: '/worktrees/s-1', branch: 's-1' },
|
||||
worktreeState: 'persisted-v1',
|
||||
},
|
||||
]);
|
||||
const ch = createChannel(
|
||||
{ multiSession: true, dispatchMode: 'collect' },
|
||||
{ stateDir },
|
||||
);
|
||||
const namedSessions = (
|
||||
ch as unknown as {
|
||||
namedSessions: {
|
||||
resumeReserved: (
|
||||
input: unknown,
|
||||
sessionId: string,
|
||||
) => Promise<string | undefined>;
|
||||
};
|
||||
}
|
||||
).namedSessions;
|
||||
try {
|
||||
await ch.handleInbound(
|
||||
envelope({ text: '/session new feature --worktree' }),
|
||||
);
|
||||
|
||||
const first = ch.handleInbound(envelope({ text: 'build feature' }));
|
||||
await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1));
|
||||
// Buffered behind the running prompt, so the turn is bound to s-1
|
||||
// before the transfer becomes visible to this worker.
|
||||
await ch.handleInbound(envelope({ text: 'update feature' }));
|
||||
|
||||
ch.setBridge(healedBridge);
|
||||
const resumeSpy = vi.spyOn(namedSessions, 'resumeReserved');
|
||||
finishFirst('done');
|
||||
await first;
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(healedBridge.prompt).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
// The drained turn reloads the session it was bound to and must then
|
||||
// dispatch on the healed replacement.
|
||||
expect(resumeSpy).toHaveBeenCalledWith(expect.anything(), 's-1');
|
||||
expect(healedBridge.prompt).toHaveBeenCalledWith(
|
||||
's-2',
|
||||
expect.stringContaining('update feature'),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(ch.sent.map((message) => message.text)).not.toContain(
|
||||
'Could not identify the selected task. Use /sessions, select it again, and retry.',
|
||||
);
|
||||
// The queue reservation moved with the binding: the superseded id is
|
||||
// not left busy forever.
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
(ch as unknown as { queuedTurns: Map<string, number> }).queuedTurns
|
||||
.size,
|
||||
).toBe(0),
|
||||
);
|
||||
} finally {
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('dispatches a collected turn whose id another turn already healed', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
let finishFirst!: (response: string) => void;
|
||||
vi.mocked(bridge.prompt).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
}),
|
||||
);
|
||||
const healedBridge = createBridge();
|
||||
vi.mocked(healedBridge.loadSession).mockImplementation(
|
||||
async (sessionId: string) => {
|
||||
if (sessionId === 's-1') {
|
||||
throw daemonError('worktree_session_superseded', {
|
||||
replacementSessionId: 's-2',
|
||||
});
|
||||
}
|
||||
return sessionId;
|
||||
},
|
||||
);
|
||||
vi.mocked(healedBridge.listSessions!).mockReturnValue([
|
||||
{
|
||||
sessionId: 's-2',
|
||||
workspaceCwd: '/tmp',
|
||||
hasActivePrompt: false,
|
||||
worktree: { slug: 's-1', path: '/worktrees/s-1', branch: 's-1' },
|
||||
worktreeState: 'persisted-v1',
|
||||
},
|
||||
]);
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
const ch = createChannel(
|
||||
{ multiSession: true, dispatchMode: 'collect' },
|
||||
{ stateDir },
|
||||
);
|
||||
try {
|
||||
await ch.handleInbound(
|
||||
envelope({ text: '/session new feature --worktree' }),
|
||||
);
|
||||
|
||||
const first = ch.handleInbound(envelope({ text: 'build feature' }));
|
||||
await vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalledTimes(1));
|
||||
// Buffered behind the running prompt, so this turn is bound to s-1 and
|
||||
// keeps that binding until it drains.
|
||||
await ch.handleInbound(envelope({ text: 'update feature' }));
|
||||
|
||||
// A different turn heals the task onto the replacement first, so the
|
||||
// buffered turn is left holding an id no task names any more.
|
||||
ch.setBridge(healedBridge);
|
||||
await ch.handleInbound(envelope({ text: '/session use feature' }));
|
||||
|
||||
finishFirst('done');
|
||||
await first;
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(healedBridge.prompt).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(healedBridge.prompt).toHaveBeenCalledWith(
|
||||
's-2',
|
||||
expect.stringContaining('update feature'),
|
||||
expect.anything(),
|
||||
);
|
||||
// The user-visible half: the buffered turn is answered instead of
|
||||
// vanishing behind an operator-only log line.
|
||||
expect(
|
||||
writeSpy.mock.calls.some(([message]) =>
|
||||
String(message).includes('dropped collected turn'),
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels the exact named owner when legacy route keys collide', async () => {
|
||||
const stateDir = mkdtempSync(join(tmpdir(), 'qwen-channel-named-'));
|
||||
const finishPrompts = new Map<string, (response: string) => void>();
|
||||
@@ -5046,9 +5501,13 @@ describe('ChannelBase', () => {
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
ch.retiringSessions = [];
|
||||
await ch.handleInbound(envelope({ text: '/clear' }));
|
||||
expect(ch.sent.at(-1)!.text).toContain('Task "review" reset');
|
||||
expect(bridge.discardSession).toHaveBeenCalledWith('s-1');
|
||||
// /clear of a named task retires it through the removedIds loop, the
|
||||
// only path that lets an adapter drain what the task had buffered.
|
||||
expect(ch.retiringSessions).toEqual(['s-1']);
|
||||
|
||||
await ch.handleInbound(envelope({ text: '/session use feature' }));
|
||||
await ch.handleInbound(envelope({ text: '/session use review' }));
|
||||
@@ -8882,6 +9341,7 @@ describe('ChannelBase', () => {
|
||||
expect(ch.sent).toHaveLength(1);
|
||||
expect(ch.sent[0]!.text).toContain('Session cleared');
|
||||
expect(bridge.discardSession).toHaveBeenCalledWith('s-1');
|
||||
expect(ch.retiringSessions).toEqual(['s-1']);
|
||||
});
|
||||
|
||||
it('/clear purges the session from every per-session map (no leak)', async () => {
|
||||
@@ -10611,6 +11071,55 @@ describe('ChannelBase', () => {
|
||||
registerBridgeEvents: true,
|
||||
} as unknown as ChannelBaseOptions);
|
||||
ch.proactiveSupported = true;
|
||||
const dispatch = vi.spyOn(ch, 'dispatchBackgroundResponse');
|
||||
const context = {
|
||||
taskId: 'agent-1',
|
||||
status: 'completed',
|
||||
kind: 'agent' as const,
|
||||
turnComplete: true,
|
||||
};
|
||||
|
||||
(bridge as unknown as EventEmitter).emit(
|
||||
'backgroundResponse',
|
||||
's-1',
|
||||
'Background final answer.',
|
||||
context,
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
's-1',
|
||||
'Background final answer.',
|
||||
context,
|
||||
);
|
||||
expect(ch.proactive).toEqual([
|
||||
{ chatId: 'chat1', text: 'Background final answer.' },
|
||||
]);
|
||||
});
|
||||
expect(ch.proactiveTargets).toEqual([target]);
|
||||
expect(ch.sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a background response whose route disappeared during resolution', async () => {
|
||||
const target: SessionTarget = {
|
||||
channelName: 'test-chan',
|
||||
senderId: 'user1',
|
||||
chatId: 'chat1',
|
||||
isGroup: true,
|
||||
};
|
||||
const router = {
|
||||
getTarget: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(target)
|
||||
.mockReturnValue(undefined),
|
||||
handleSessionDied: vi.fn(),
|
||||
setBridge: vi.fn(),
|
||||
};
|
||||
const ch = createChannel({}, {
|
||||
router,
|
||||
registerBridgeEvents: true,
|
||||
} as unknown as ChannelBaseOptions);
|
||||
ch.proactiveSupported = true;
|
||||
|
||||
(bridge as unknown as EventEmitter).emit(
|
||||
'backgroundResponse',
|
||||
@@ -10618,12 +11127,10 @@ describe('ChannelBase', () => {
|
||||
'Background final answer.',
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(ch.proactive).toEqual([
|
||||
{ chatId: 'chat1', text: 'Background final answer.' },
|
||||
]);
|
||||
});
|
||||
expect(ch.proactiveTargets).toEqual([target]);
|
||||
await vi.waitFor(() =>
|
||||
expect(router.getTarget.mock.calls.length).toBeGreaterThanOrEqual(2),
|
||||
);
|
||||
expect(ch.proactive).toEqual([]);
|
||||
expect(ch.sent).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -21260,6 +21767,7 @@ describe('ChannelBase', () => {
|
||||
});
|
||||
expect(bridge.cancelSession).toHaveBeenCalledWith('s-1');
|
||||
expect(bridge.discardSession).toHaveBeenCalledWith('s-1');
|
||||
expect(ch.retiringSessions).toEqual(['s-1']);
|
||||
expect(btwSignal?.aborted).toBe(true);
|
||||
expect(
|
||||
(
|
||||
|
||||
@@ -39,9 +39,10 @@ import type { GroupHistoryEntry } from './group-history-store.js';
|
||||
import { SenderGate } from './SenderGate.js';
|
||||
import { PairingStore } from './PairingStore.js';
|
||||
import type { CreatePairingRequestResult } from './PairingStore.js';
|
||||
import { SessionRouter } from './SessionRouter.js';
|
||||
import { SessionRouter, readDaemonHttpErrorCode } from './SessionRouter.js';
|
||||
import {
|
||||
NamedSessionManager,
|
||||
NamedSessionTaskError,
|
||||
type NamedSessionOwnerInput,
|
||||
type NamedSessionSelection,
|
||||
type NamedSessionTaskReference,
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
} from './sanitize.js';
|
||||
import type {
|
||||
AvailableCommand,
|
||||
BackgroundResponseContext,
|
||||
ChannelAgentBridge,
|
||||
ChannelPromptImage,
|
||||
ChannelLoopToolCreateInput,
|
||||
@@ -92,6 +94,11 @@ import {
|
||||
type ChannelMemoryRecallIndex,
|
||||
} from './channel-memory-recall.js';
|
||||
|
||||
interface BackgroundResponseDeliveryTarget {
|
||||
target: SessionTarget;
|
||||
sourceLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Max time /clear waits for a cancelled in-flight turn to wind down before
|
||||
* purging anyway. A wedged ACP child (stuck tool call, not reading stdin, or
|
||||
@@ -473,8 +480,9 @@ export abstract class ChannelBase {
|
||||
private readonly bridgeBackgroundResponseListener = (
|
||||
sessionId: string,
|
||||
text: string,
|
||||
context?: BackgroundResponseContext,
|
||||
): void => {
|
||||
void this.dispatchBackgroundResponse(sessionId, text).catch(
|
||||
void this.dispatchBackgroundResponse(sessionId, text, context).catch(
|
||||
(err: unknown) => {
|
||||
process.stderr.write(
|
||||
`[${this.name}] background response delivery failed for session ${sanitizeLogText(sessionId, 128)}: ${this.lifecycleError(err)}\n`,
|
||||
@@ -542,15 +550,21 @@ export abstract class ChannelBase {
|
||||
async dispatchBackgroundResponse(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
_context?: BackgroundResponseContext,
|
||||
): Promise<void> {
|
||||
let target = this.router.getTarget(sessionId);
|
||||
if (
|
||||
!target ||
|
||||
target.channelName !== this.name ||
|
||||
text.trim().length === 0
|
||||
) {
|
||||
if (text.trim().length === 0) return;
|
||||
const delivery = await this.resolveBackgroundResponseDelivery(sessionId);
|
||||
if (!delivery || this.router.getTarget(sessionId) !== delivery.target) {
|
||||
return;
|
||||
}
|
||||
await this.deliverBackgroundResponseToTarget(sessionId, text, delivery);
|
||||
}
|
||||
|
||||
protected async resolveBackgroundResponseDelivery(
|
||||
sessionId: string,
|
||||
): Promise<BackgroundResponseDeliveryTarget | undefined> {
|
||||
let target = this.router.getTarget(sessionId);
|
||||
if (!target || target.channelName !== this.name) return undefined;
|
||||
let sourceLabel: string | undefined;
|
||||
if (this.namedSessions) {
|
||||
const presentation =
|
||||
@@ -573,6 +587,15 @@ export abstract class ChannelBase {
|
||||
target = currentTarget;
|
||||
sourceLabel = this.createSourceLabel(presentation, target);
|
||||
}
|
||||
return { target, sourceLabel };
|
||||
}
|
||||
|
||||
protected async deliverBackgroundResponseToTarget(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
delivery: BackgroundResponseDeliveryTarget,
|
||||
): Promise<void> {
|
||||
const { target, sourceLabel } = delivery;
|
||||
if (this.supportsProactiveSend() && this.supportsProactiveTarget(target)) {
|
||||
if (sourceLabel) {
|
||||
await this.pushProactive(target, text, sourceLabel);
|
||||
@@ -2488,6 +2511,7 @@ export abstract class ChannelBase {
|
||||
]);
|
||||
if (!cancelled) {
|
||||
this.cancelBtw(sessionId);
|
||||
this.onSessionRetiring(sessionId);
|
||||
this.router.removeSessionId(sessionId);
|
||||
this.instructedSessions.delete(sessionId);
|
||||
this.unattendedMemorySessions.delete(sessionId);
|
||||
@@ -2673,6 +2697,8 @@ export abstract class ChannelBase {
|
||||
this.removePendingPermissionsForSession(sessionId);
|
||||
}
|
||||
|
||||
protected onSessionRetiring(_sessionId: string): void {}
|
||||
|
||||
private attachBridgeEvents(bridge: ChannelAgentBridge): void {
|
||||
bridge.on('toolCall', this.bridgeToolCallListener);
|
||||
bridge.on('backgroundResponse', this.bridgeBackgroundResponseListener);
|
||||
@@ -3449,6 +3475,25 @@ export abstract class ChannelBase {
|
||||
this.releaseQueuedTurn(binding.sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a task onto the session its reload healed to (a superseded
|
||||
* redirect). The queue reservation and the staleness generation are keyed
|
||||
* by session id, so both must move with the binding: leaving them behind
|
||||
* keeps the stale id "busy" forever and drops the turn on a generation the
|
||||
* replacement never had.
|
||||
*/
|
||||
private moveNamedTurnBinding(
|
||||
binding: NamedTurnBinding,
|
||||
sessionId: string,
|
||||
): void {
|
||||
if (binding.sessionId !== null) {
|
||||
this.releaseQueuedTurn(binding.sessionId);
|
||||
}
|
||||
binding.sessionId = sessionId;
|
||||
binding.generation = this.sessionGenerations.get(sessionId) ?? 0;
|
||||
this.reserveQueuedTurn(sessionId);
|
||||
}
|
||||
|
||||
private finishNamedTurnBinding(envelope: Envelope): void {
|
||||
const binding = this.namedTurnBindings.get(envelope);
|
||||
if (!binding) return;
|
||||
@@ -3514,6 +3559,74 @@ export abstract class ChannelBase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The actionable recovery message for a worktree task whose ownership state
|
||||
* the daemon refused to restore. Both codes come from the load/resume route
|
||||
* — the reset route resumes an interrupted transfer itself and reports a
|
||||
* broken marker as invalid state — so they surface on selection and on a
|
||||
* message, wrapped in the manager's generic load failure.
|
||||
*
|
||||
* Clearing always acts on the *selected* task, so pointing the user at a
|
||||
* clear is only safe when the task that failed is the selected one. Aimed at
|
||||
* any other task it would run a full ownership transfer against a healthy
|
||||
* one and destroy that conversation, so the alternative points at the close
|
||||
* that works on the broken task without loading it. Both name that task.
|
||||
*/
|
||||
private async worktreeRecoveryMessage(
|
||||
envelope: Envelope,
|
||||
error: unknown,
|
||||
): Promise<string | undefined> {
|
||||
const code = readDaemonHttpErrorCode(error);
|
||||
if (
|
||||
code !== 'worktree_reset_interrupted' &&
|
||||
code !== 'worktree_marker_missing'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const interrupted = code === 'worktree_reset_interrupted';
|
||||
const failedTaskName =
|
||||
error instanceof NamedSessionTaskError ? error.taskName : undefined;
|
||||
// This message bypasses the wrapper's sanitizeDisplayText, so bound the one
|
||||
// piece of interpolated text it adds.
|
||||
const safeTaskName =
|
||||
failedTaskName === undefined
|
||||
? undefined
|
||||
: sanitizeDisplayText(failedTaskName, 32);
|
||||
const subject =
|
||||
safeTaskName === undefined ? 'The task' : `Task "${safeTaskName}"`;
|
||||
const problem = interrupted
|
||||
? 'was interrupted while being reset'
|
||||
: 'cannot verify its worktree because its ownership marker is missing';
|
||||
if (
|
||||
safeTaskName !== undefined &&
|
||||
failedTaskName === (await this.selectedTaskName(envelope))
|
||||
) {
|
||||
return `${subject} ${problem}. Its files were not changed. ${
|
||||
interrupted
|
||||
? 'Clear the task again to finish the reset.'
|
||||
: 'Clear the task to restart it in the same worktree, or close it.'
|
||||
}`;
|
||||
}
|
||||
const remedy =
|
||||
safeTaskName === undefined
|
||||
? 'select this task first or close it'
|
||||
: `select this task first or close it with ${this.prefixedCommand(`/session close ${safeTaskName}`)}`;
|
||||
return `${subject} ${problem}. Its files were not changed. Clearing now would reset the selected task instead, so ${remedy}.`;
|
||||
}
|
||||
|
||||
private async selectedTaskName(
|
||||
envelope: Envelope,
|
||||
): Promise<string | undefined> {
|
||||
const namedSessions = this.namedSessions;
|
||||
if (!namedSessions) return undefined;
|
||||
try {
|
||||
return (await namedSessions.current(this.namedSessionOwner(envelope)))
|
||||
?.name;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendNamedSessionError(
|
||||
envelope: Envelope,
|
||||
error: unknown,
|
||||
@@ -3524,9 +3637,10 @@ export abstract class ChannelBase {
|
||||
);
|
||||
}
|
||||
const message =
|
||||
error instanceof Error
|
||||
(await this.worktreeRecoveryMessage(envelope, error)) ??
|
||||
(error instanceof Error
|
||||
? sanitizeDisplayText(error.message, 500)
|
||||
: 'Named-session operation failed.';
|
||||
: 'Named-session operation failed.');
|
||||
await this.sendThreadMessage(
|
||||
envelope.chatId,
|
||||
envelope.threadId,
|
||||
@@ -3609,7 +3723,8 @@ export abstract class ChannelBase {
|
||||
: 'shared';
|
||||
if (
|
||||
(isolation === 'shared' && parts.length !== 1) ||
|
||||
(isolation === 'worktree' && parts.length !== 2)
|
||||
(isolation === 'worktree' && parts.length !== 2) ||
|
||||
parts[0]?.startsWith('-')
|
||||
) {
|
||||
break;
|
||||
}
|
||||
@@ -3639,7 +3754,10 @@ export abstract class ChannelBase {
|
||||
if (parts.length !== 1) break;
|
||||
const closing = await namedSessions.lookup(owner, parts[0]!);
|
||||
const result = await namedSessions.close(owner, parts[0]!);
|
||||
if (closing) this.cancelBtw(closing.sessionId);
|
||||
if (closing) {
|
||||
this.cancelBtw(closing.sessionId);
|
||||
this.onSessionRetiring(closing.sessionId);
|
||||
}
|
||||
await this.sendThreadMessage(
|
||||
envelope.chatId,
|
||||
envelope.threadId,
|
||||
@@ -3713,15 +3831,42 @@ export abstract class ChannelBase {
|
||||
private registerSharedCommands(): void {
|
||||
const doClear = async (envelope: Envelope): Promise<void> => {
|
||||
let resetTaskName: string | undefined;
|
||||
let resetWorktreeKept = false;
|
||||
let removedIds: string[];
|
||||
const retiringSessionId = this.namedSessions
|
||||
? undefined
|
||||
: this.router.getSession(
|
||||
this.name,
|
||||
envelope.senderId,
|
||||
envelope.chatId,
|
||||
envelope.threadId,
|
||||
);
|
||||
if (retiringSessionId) this.onSessionRetiring(retiringSessionId);
|
||||
if (this.namedSessions) {
|
||||
try {
|
||||
const reset = await this.namedSessions.reset(
|
||||
this.namedSessionOwner(envelope),
|
||||
);
|
||||
resetTaskName = reset?.name;
|
||||
resetWorktreeKept = reset?.worktreeKept ?? false;
|
||||
removedIds = reset ? [reset.previousSessionId] : [];
|
||||
} catch (error) {
|
||||
// The reset route's busy signal has its own wording; every other
|
||||
// typed failure goes through the shared named-session surface.
|
||||
const code = readDaemonHttpErrorCode(error);
|
||||
if (code !== undefined) {
|
||||
process.stderr.write(
|
||||
`[${sanitizeLogText(this.name, 64)}] worktree reset failed (${sanitizeLogText(code, 64)}): ${this.lifecycleError(error)}\n`,
|
||||
);
|
||||
}
|
||||
if (code === 'worktree_reset_active') {
|
||||
await this.sendThreadMessage(
|
||||
envelope.chatId,
|
||||
envelope.threadId,
|
||||
'Task is busy. Wait for the running prompt to finish (or cancel it), then try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.sendNamedSessionError(envelope, error);
|
||||
return;
|
||||
}
|
||||
@@ -3736,6 +3881,7 @@ export abstract class ChannelBase {
|
||||
this.clearPendingGroupHistory(envelope);
|
||||
if (removedIds.length > 0) {
|
||||
for (const id of removedIds) {
|
||||
if (id !== retiringSessionId) this.onSessionRetiring(id);
|
||||
this.cancelBtw(id);
|
||||
// Audit: clearing a SHARED session wipes the conversation for every
|
||||
// participant, so record who triggered it (sanitized display name +
|
||||
@@ -3844,7 +3990,9 @@ export abstract class ChannelBase {
|
||||
envelope.chatId,
|
||||
envelope.threadId,
|
||||
resetTaskName
|
||||
? `Task "${resetTaskName}" reset with a fresh conversation.`
|
||||
? resetWorktreeKept
|
||||
? `Task "${resetTaskName}" reset with a fresh conversation; its worktree and files were kept.`
|
||||
: `Task "${resetTaskName}" reset with a fresh conversation.`
|
||||
: 'Session cleared. The next message starts a fresh conversation.',
|
||||
);
|
||||
} else {
|
||||
@@ -6325,11 +6473,13 @@ export abstract class ChannelBase {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resumed = await this.namedSessions.resumeReserved(
|
||||
sessionId = await this.namedSessions.resumeReserved(
|
||||
this.namedSessionOwner(envelope),
|
||||
namedTurn.sessionId,
|
||||
);
|
||||
sessionId = resumed ? namedTurn.sessionId : undefined;
|
||||
if (sessionId && sessionId !== namedTurn.sessionId) {
|
||||
this.moveNamedTurnBinding(namedTurn, sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
await this.sendNamedSessionError(envelope, error);
|
||||
return;
|
||||
|
||||
@@ -226,6 +226,76 @@ describe('DaemonChannelBridge', () => {
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('rejects worktree reset before calling an unsupported daemon factory', async () => {
|
||||
const factory = vi.fn();
|
||||
const bridge = new DaemonChannelBridge({
|
||||
cwd: '/repo',
|
||||
sessionFactory: factory,
|
||||
sessionWorktreeReset: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridge.resetWorktreeSession('session-1', '/repo'),
|
||||
).rejects.toThrow('does not support worktree reset');
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('transfers a worktree session through the factory and registers loop tools for the replacement', async () => {
|
||||
const events = new EventQueue();
|
||||
const session = {
|
||||
...createFakeSession(events, 'session-2'),
|
||||
worktree: { slug: 'task', path: '/repo-wt', branch: 'task' },
|
||||
worktreeState: 'persisted-v1' as const,
|
||||
};
|
||||
const factory = vi.fn().mockResolvedValue(session);
|
||||
const host: DaemonChannelLoopMcpHost = {
|
||||
register: vi.fn().mockResolvedValue(undefined),
|
||||
unregister: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const bridge = new DaemonChannelBridge({
|
||||
cwd: '/repo',
|
||||
sessionFactory: factory,
|
||||
sessionWorktreeReset: true,
|
||||
channelLoopMcpHost: host,
|
||||
});
|
||||
bridge.registerChannelLoopToolHandler({
|
||||
create: vi.fn(async () => ({ text: 'created' })),
|
||||
list: vi.fn(async () => ({ text: 'listed' })),
|
||||
cancel: vi.fn(async () => ({ text: 'cancelled' })),
|
||||
});
|
||||
|
||||
await bridge.start();
|
||||
const replacementId = await bridge.resetWorktreeSession(
|
||||
'session-1',
|
||||
'/repo',
|
||||
{ sourceId: 'feishu-main' },
|
||||
);
|
||||
|
||||
expect(replacementId).toBe('session-2');
|
||||
expect(factory).toHaveBeenCalledWith({
|
||||
workspaceCwd: '/repo',
|
||||
modelServiceId: undefined,
|
||||
sessionScope: 'thread',
|
||||
sourceId: 'feishu-main',
|
||||
worktreeReset: { sessionId: 'session-1' },
|
||||
});
|
||||
expect(bridge.listSessions()[0]).toMatchObject({
|
||||
sessionId: 'session-2',
|
||||
worktree: session.worktree,
|
||||
worktreeState: 'persisted-v1',
|
||||
});
|
||||
// The replacement must get the same loop-tool registration a loaded
|
||||
// session gets, or channel loops silently stop working after a reset.
|
||||
await waitFor(() =>
|
||||
expect(host.register).toHaveBeenCalledWith(
|
||||
'session-2',
|
||||
expect.any(Function),
|
||||
),
|
||||
);
|
||||
events.close();
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('deletes an internal session through its owning workspace', async () => {
|
||||
const events = new EventQueue();
|
||||
const session = createFakeSession(events);
|
||||
@@ -1235,9 +1305,9 @@ describe('DaemonChannelBridge', () => {
|
||||
cwd: '/repo',
|
||||
sessionFactory: vi.fn().mockResolvedValue(session),
|
||||
});
|
||||
const backgroundResponses: Array<[string, string]> = [];
|
||||
bridge.on('backgroundResponse', (sessionId, text) => {
|
||||
backgroundResponses.push([sessionId, text]);
|
||||
const backgroundResponses: unknown[][] = [];
|
||||
bridge.on('backgroundResponse', (sessionId, text, context) => {
|
||||
backgroundResponses.push([sessionId, text, context]);
|
||||
});
|
||||
|
||||
await bridge.start();
|
||||
@@ -1258,14 +1328,89 @@ describe('DaemonChannelBridge', () => {
|
||||
_meta: {
|
||||
source: 'background_notification_response',
|
||||
qwenDiscreteMessage: true,
|
||||
backgroundTask: {
|
||||
taskId: 'agent-1',
|
||||
status: 'completed',
|
||||
kind: 'agent',
|
||||
label: 'dependency check',
|
||||
turnComplete: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
events.push({
|
||||
id: 3,
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: {
|
||||
sessionId: 'session-1',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
source: 'background_notification_response',
|
||||
qwenDiscreteMessage: true,
|
||||
backgroundTask: {
|
||||
taskId: 'agent-1',
|
||||
status: 'completed',
|
||||
kind: 'agent',
|
||||
label: 'dependency check',
|
||||
turnComplete: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const [id, backgroundTask] of [
|
||||
[4, undefined],
|
||||
[5, { taskId: '', status: 'completed', kind: 'agent' }],
|
||||
] as const) {
|
||||
events.push({
|
||||
id,
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: {
|
||||
sessionId: 'session-1',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Legacy background answer.' },
|
||||
_meta: {
|
||||
source: 'background_notification_response',
|
||||
qwenDiscreteMessage: true,
|
||||
...(backgroundTask ? { backgroundTask } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(backgroundResponses).toEqual([
|
||||
['session-1', 'Background final answer.'],
|
||||
[
|
||||
'session-1',
|
||||
'Background final answer.',
|
||||
{
|
||||
taskId: 'agent-1',
|
||||
status: 'completed',
|
||||
kind: 'agent',
|
||||
label: 'dependency check',
|
||||
turnComplete: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'session-1',
|
||||
'',
|
||||
{
|
||||
taskId: 'agent-1',
|
||||
status: 'completed',
|
||||
kind: 'agent',
|
||||
label: 'dependency check',
|
||||
turnComplete: true,
|
||||
},
|
||||
],
|
||||
['session-1', 'Legacy background answer.', undefined],
|
||||
['session-1', 'Legacy background answer.', undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CHANNEL_PROMPT_AUTHORIZATION_META_KEY,
|
||||
CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY,
|
||||
CHANNEL_PROMPT_META_KEY,
|
||||
parseBackgroundResponseContext,
|
||||
resolvePromptImages,
|
||||
type AvailableCommand,
|
||||
type BridgeSessionInfo,
|
||||
@@ -90,6 +91,11 @@ export interface DaemonChannelSessionFactoryRequest {
|
||||
/** Channel instance name stamped as daemon `sourceId`. */
|
||||
sourceId?: string;
|
||||
worktree?: Record<string, never>;
|
||||
/**
|
||||
* Worktree ownership transfer: replace this session with a fresh session
|
||||
* that takes over its worktree (daemon `session_worktree_reset_v1`).
|
||||
*/
|
||||
worktreeReset?: { sessionId: string };
|
||||
}
|
||||
|
||||
export type DaemonChannelSessionFactory = (
|
||||
@@ -129,6 +135,8 @@ export interface DaemonChannelBridgeOptions {
|
||||
sessionPermissionVote?: boolean;
|
||||
/** Daemon guarantees durable worktree create/restore attestation. */
|
||||
sessionWorktreePersistence?: boolean;
|
||||
/** Daemon supports worktree ownership transfer (`session_worktree_reset_v1`). */
|
||||
sessionWorktreeReset?: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonPermissionRequestEvent {
|
||||
@@ -472,6 +480,46 @@ export class DaemonChannelBridge
|
||||
return session.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer a worktree session's checkout ownership to a fresh replacement
|
||||
* session (daemon `session_worktree_reset_v1`). The returned id is the
|
||||
* replacement's; the superseded session's clients stay bound to it (and
|
||||
* are forgotten by the caller). Gated on the capability flag so a daemon
|
||||
* without reset support fails before any session is created.
|
||||
*/
|
||||
async resetWorktreeSession(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
options?: ChannelAgentBridgeSessionOptions,
|
||||
bindingToken?: object,
|
||||
): Promise<string> {
|
||||
if (!this.options.sessionWorktreeReset) {
|
||||
throw new Error(
|
||||
'The daemon does not support worktree reset for Channel tasks.',
|
||||
);
|
||||
}
|
||||
const lifecycleGeneration = this.lifecycleGeneration;
|
||||
const session = await this.options.sessionFactory({
|
||||
workspaceCwd: cwd || this.options.cwd,
|
||||
modelServiceId: this.options.modelServiceId,
|
||||
sessionScope: this.options.sessionScope ?? 'thread',
|
||||
...(options?.approvalMode ? { approvalMode: options.approvalMode } : {}),
|
||||
...(options?.sourceId ? { sourceId: options.sourceId } : {}),
|
||||
worktreeReset: { sessionId },
|
||||
});
|
||||
if (lifecycleGeneration !== this.lifecycleGeneration) {
|
||||
await this.rejectStaleSession(session);
|
||||
}
|
||||
this.attachSession(session, bindingToken);
|
||||
if (options?.enableChannelLoops === false) {
|
||||
this.channelLoopDisabledSessions.add(session.sessionId);
|
||||
void this.reconcileChannelLoopMcpForSession(session.sessionId);
|
||||
} else {
|
||||
await this.reconcileChannelLoopMcpForSession(session.sessionId);
|
||||
}
|
||||
return session.sessionId;
|
||||
}
|
||||
|
||||
registerChannelLoopToolHandler(handler: ChannelLoopToolHandler): void {
|
||||
if (!this.channelLoopToolHandlers.includes(handler)) {
|
||||
this.channelLoopToolHandlers.push(handler);
|
||||
@@ -1012,10 +1060,14 @@ export class DaemonChannelBridge
|
||||
if (meta?.['qwenDiscreteMessage'] === true) {
|
||||
if (
|
||||
meta['source'] === 'background_notification_response' &&
|
||||
meta['rewritten'] !== true &&
|
||||
text
|
||||
meta['rewritten'] !== true
|
||||
) {
|
||||
this.emit('backgroundResponse', sessionId, text);
|
||||
const context = parseBackgroundResponseContext(
|
||||
meta['backgroundTask'],
|
||||
);
|
||||
if (text || context?.turnComplete) {
|
||||
this.emit('backgroundResponse', sessionId, text ?? '', context);
|
||||
}
|
||||
} else if (meta['source'] === 'vision_bridge_notice' && text) {
|
||||
this.emit('textChunk', sessionId, text);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,20 @@ import {
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SessionRouter } from './SessionRouter.js';
|
||||
import {
|
||||
readDaemonHttpErrorCode,
|
||||
readSupersededReplacementId,
|
||||
SessionRouter,
|
||||
} from './SessionRouter.js';
|
||||
import {
|
||||
DaemonChannelBridge,
|
||||
type DaemonChannelSessionFactoryRequest,
|
||||
type DaemonChannelSessionClient,
|
||||
} from './DaemonChannelBridge.js';
|
||||
import type { ChannelAgentBridge } from './ChannelAgentBridge.js';
|
||||
import type {
|
||||
ChannelAgentBridge,
|
||||
SessionDiedEvent,
|
||||
} from './ChannelAgentBridge.js';
|
||||
|
||||
const mockRenameSync = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -61,6 +69,17 @@ function writePersistedSession(persistPath: string, key = 'key1'): void {
|
||||
);
|
||||
}
|
||||
|
||||
/** A `DaemonHttpError` by shape — channels/base keeps no SDK dependency. */
|
||||
function daemonHttpError(
|
||||
code: string,
|
||||
extraBody: Record<string, unknown> = {},
|
||||
): Error {
|
||||
const error = new Error(`daemon rejected with ${code}`);
|
||||
error.name = 'DaemonHttpError';
|
||||
Object.assign(error, { status: 409, body: { code, ...extraBody } });
|
||||
return error;
|
||||
}
|
||||
|
||||
function invalidationMetadataSize(router: SessionRouter): number {
|
||||
const state = router as unknown as {
|
||||
routeGenerations?: Map<string, unknown>;
|
||||
@@ -893,11 +912,11 @@ describe('SessionRouter', () => {
|
||||
router.activateManagedSession(first, firstTarget, '/tmp');
|
||||
await expect(
|
||||
router.loadManagedSession(second, secondTarget, '/tmp'),
|
||||
).resolves.toEqual({ loaded: false });
|
||||
).resolves.toEqual({ loaded: false, sessionId: second });
|
||||
router.activateManagedSession(second, secondTarget, '/tmp');
|
||||
await expect(
|
||||
router.loadManagedSession(first, firstTarget, '/tmp'),
|
||||
).resolves.toEqual({ loaded: false });
|
||||
).resolves.toEqual({ loaded: false, sessionId: first });
|
||||
router.activateManagedSession(first, firstTarget, '/tmp');
|
||||
|
||||
expect(bridge.loadSession).not.toHaveBeenCalled();
|
||||
@@ -946,7 +965,7 @@ describe('SessionRouter', () => {
|
||||
worktreePath,
|
||||
'worktree',
|
||||
),
|
||||
).resolves.toEqual({ loaded: false });
|
||||
).resolves.toEqual({ loaded: false, sessionId: 'worktree-session' });
|
||||
|
||||
expect(managedBridge.loadSession).not.toHaveBeenCalled();
|
||||
expect(router.getSessionCwd('worktree-session')).toBe(worktreePath);
|
||||
@@ -1033,7 +1052,7 @@ describe('SessionRouter', () => {
|
||||
router.setBridge(replacementBridge);
|
||||
await expect(
|
||||
router.loadManagedSession(second, target, '/tmp'),
|
||||
).resolves.toEqual({ loaded: true });
|
||||
).resolves.toEqual({ loaded: true, sessionId: second });
|
||||
|
||||
expect(replacementBridge.loadSession).toHaveBeenCalledWith(
|
||||
second,
|
||||
@@ -1066,7 +1085,7 @@ describe('SessionRouter', () => {
|
||||
});
|
||||
await expect(
|
||||
router.loadManagedSession(sessionId, target, '/tmp'),
|
||||
).resolves.toEqual({ loaded: false });
|
||||
).resolves.toEqual({ loaded: false, sessionId });
|
||||
|
||||
expect(replacementBridge.loadSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -1126,6 +1145,751 @@ describe('SessionRouter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceManagedWorktreeSession', () => {
|
||||
const target = {
|
||||
channelName: 'ch',
|
||||
senderId: 'alice',
|
||||
chatId: 'chat1',
|
||||
};
|
||||
|
||||
function worktreeResetBridge(
|
||||
overrides: Partial<ChannelAgentBridge> & {
|
||||
replacementWorktreePath?: string;
|
||||
} = {},
|
||||
): ChannelAgentBridge {
|
||||
const { replacementWorktreePath, ...rest } = overrides;
|
||||
return {
|
||||
...mockBridge(),
|
||||
listSessions: vi.fn().mockReturnValue([
|
||||
{
|
||||
sessionId: 'replacement-session',
|
||||
workspaceCwd: '/tmp',
|
||||
hasActivePrompt: false,
|
||||
worktree: {
|
||||
slug: 'task',
|
||||
path: replacementWorktreePath ?? '/tmp/worktree-task',
|
||||
branch: 'task',
|
||||
},
|
||||
worktreeState: 'persisted-v1' as const,
|
||||
},
|
||||
]),
|
||||
resetWorktreeSession: vi.fn().mockResolvedValue('replacement-session'),
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
/** An attestation for a session owning the task's worktree checkout. */
|
||||
function worktreeSessionInfo(sessionId: string) {
|
||||
return {
|
||||
sessionId,
|
||||
workspaceCwd: '/tmp',
|
||||
hasActivePrompt: false,
|
||||
worktree: { slug: 'task', path: '/tmp/worktree-task', branch: 'task' },
|
||||
worktreeState: 'persisted-v1' as const,
|
||||
};
|
||||
}
|
||||
|
||||
it('fails closed when the bridge does not support worktree reset', async () => {
|
||||
const router = new SessionRouter(bridge, '/tmp', 'user', undefined, {
|
||||
recoveryMode: 'lazy',
|
||||
});
|
||||
|
||||
await expect(
|
||||
router.replaceManagedWorktreeSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
),
|
||||
).rejects.toThrow('Worktree reset is not supported by this bridge');
|
||||
expect(router.getTarget('old-session')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('routes the validated replacement and forgets the superseded session', async () => {
|
||||
const managedBridge = worktreeResetBridge();
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
router.activateManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp/worktree-task',
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.replaceManagedWorktreeSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
),
|
||||
).resolves.toBe('replacement-session');
|
||||
|
||||
expect(managedBridge.resetWorktreeSession).toHaveBeenCalledWith(
|
||||
'old-session',
|
||||
'/tmp',
|
||||
{ sourceId: 'ch' },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(router.getTarget('replacement-session')).toEqual(target);
|
||||
expect(router.getSessionCwd('replacement-session')).toBe(
|
||||
'/tmp/worktree-task',
|
||||
);
|
||||
expect(router.isSessionLive('replacement-session')).toBe(true);
|
||||
// The superseded session's route and bookkeeping are gone; the manager
|
||||
// re-activates a route for the replacement itself.
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBeUndefined();
|
||||
expect(router.getTarget('old-session')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('discards the replacement and keeps the old session when attestation fails', async () => {
|
||||
const discardSession = vi.fn().mockResolvedValue(undefined);
|
||||
const managedBridge = worktreeResetBridge({
|
||||
replacementWorktreePath: '/tmp/other-worktree',
|
||||
discardSession,
|
||||
});
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
router.activateManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp/worktree-task',
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.replaceManagedWorktreeSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
),
|
||||
).rejects.toThrow('did not attest');
|
||||
|
||||
expect(discardSession).toHaveBeenCalledWith(
|
||||
'replacement-session',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(router.getTarget('replacement-session')).toBeUndefined();
|
||||
expect(router.getTarget('old-session')).toEqual(target);
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session');
|
||||
});
|
||||
|
||||
it('keeps the old session when the daemon reset fails', async () => {
|
||||
const discardSession = vi.fn().mockResolvedValue(undefined);
|
||||
const managedBridge = worktreeResetBridge({
|
||||
resetWorktreeSession: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('daemon unavailable')),
|
||||
discardSession,
|
||||
});
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
router.activateManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp/worktree-task',
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.replaceManagedWorktreeSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
),
|
||||
).rejects.toThrow('daemon unavailable');
|
||||
|
||||
expect(discardSession).not.toHaveBeenCalled();
|
||||
expect(router.getTarget('old-session')).toEqual(target);
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session');
|
||||
});
|
||||
|
||||
it('still releases the old session on detach after a failed reset', async () => {
|
||||
const discardSession = vi.fn().mockResolvedValue(undefined);
|
||||
const managedBridge = worktreeResetBridge({
|
||||
listSessions: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
worktreeSessionInfo('old-session'),
|
||||
worktreeSessionInfo('replacement-session'),
|
||||
]),
|
||||
resetWorktreeSession: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('daemon unavailable')),
|
||||
discardSession,
|
||||
});
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
await expect(
|
||||
router.loadManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
'worktree',
|
||||
),
|
||||
).resolves.toEqual({ loaded: true, sessionId: 'old-session' });
|
||||
|
||||
await expect(
|
||||
router.replaceManagedWorktreeSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
),
|
||||
).rejects.toThrow('daemon unavailable');
|
||||
expect(discardSession).not.toHaveBeenCalled();
|
||||
|
||||
// The failed reset dropped the live flag so the next load consults the
|
||||
// daemon again, but the bridge still holds the client and its event
|
||||
// pump: closing the task must release it rather than leak it until the
|
||||
// daemon's idle reaper runs.
|
||||
await router.detachManagedSession('old-session');
|
||||
|
||||
expect(discardSession).toHaveBeenCalledWith('old-session');
|
||||
expect(router.getTarget('old-session')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('discards a replacement that dies before the reset completes', async () => {
|
||||
const state: { router?: SessionRouter } = {};
|
||||
const discardSession = vi.fn().mockResolvedValue(undefined);
|
||||
const managedBridge = worktreeResetBridge({
|
||||
resetWorktreeSession: vi.fn(async () => {
|
||||
state.router?.removeSessionId('replacement-session');
|
||||
return 'replacement-session';
|
||||
}),
|
||||
discardSession,
|
||||
});
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
state.router = router;
|
||||
|
||||
await expect(
|
||||
router.replaceManagedWorktreeSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
),
|
||||
).rejects.toThrow('died before reset completed');
|
||||
|
||||
expect(discardSession).toHaveBeenCalledWith(
|
||||
'replacement-session',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(router.getTarget('replacement-session')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('consults the daemon for the old session after a failed reset', async () => {
|
||||
// The daemon commits the flip and the client side fails afterwards, so
|
||||
// only the next load of the old id reports the superseded redirect.
|
||||
let flipCommitted = false;
|
||||
const loadSession = vi.fn(async (sessionId: string) => {
|
||||
if (flipCommitted && sessionId === 'old-session') {
|
||||
throw daemonHttpError('worktree_session_superseded', {
|
||||
replacementSessionId: 'replacement-session',
|
||||
});
|
||||
}
|
||||
return sessionId;
|
||||
});
|
||||
const managedBridge = worktreeResetBridge({
|
||||
loadSession,
|
||||
listSessions: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
worktreeSessionInfo('old-session'),
|
||||
worktreeSessionInfo('replacement-session'),
|
||||
]),
|
||||
resetWorktreeSession: vi.fn(async () => {
|
||||
flipCommitted = true;
|
||||
throw new Error('timed out');
|
||||
}),
|
||||
});
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
router.activateManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp/worktree-task',
|
||||
);
|
||||
await router.loadManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
'worktree',
|
||||
);
|
||||
expect(router.isSessionLive('old-session')).toBe(true);
|
||||
vi.mocked(loadSession).mockClear();
|
||||
|
||||
await expect(
|
||||
router.replaceManagedWorktreeSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
),
|
||||
).rejects.toThrow('timed out');
|
||||
|
||||
// The failed reset drops only the live flag: the route survives and the
|
||||
// old id is no longer served from memory.
|
||||
expect(router.isSessionLive('old-session')).toBe(false);
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session');
|
||||
|
||||
await expect(
|
||||
router.loadManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
'worktree',
|
||||
),
|
||||
).resolves.toEqual({
|
||||
loaded: true,
|
||||
sessionId: 'replacement-session',
|
||||
redirectedFrom: 'old-session',
|
||||
});
|
||||
expect(loadSession.mock.calls.map((call) => call[0])).toEqual([
|
||||
'old-session',
|
||||
'replacement-session',
|
||||
]);
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBe(
|
||||
'replacement-session',
|
||||
);
|
||||
});
|
||||
|
||||
it('re-loads the same session when a failed reset never committed', async () => {
|
||||
const detaches: Array<ReturnType<typeof vi.fn>> = [];
|
||||
const sessionFactory = vi.fn(
|
||||
async (
|
||||
request: DaemonChannelSessionFactoryRequest,
|
||||
): Promise<DaemonChannelSessionClient> => {
|
||||
if (request.worktreeReset) {
|
||||
throw new Error('daemon unavailable');
|
||||
}
|
||||
const detach = vi.fn().mockResolvedValue(undefined);
|
||||
detaches.push(detach);
|
||||
return {
|
||||
...daemonSession(request.sessionId ?? '', detach),
|
||||
worktree: {
|
||||
slug: 'task',
|
||||
path: '/tmp/worktree-task',
|
||||
branch: 'task',
|
||||
},
|
||||
worktreeState: 'persisted-v1',
|
||||
};
|
||||
},
|
||||
);
|
||||
const daemonBridge = new DaemonChannelBridge({
|
||||
cwd: '/tmp',
|
||||
sessionFactory,
|
||||
sessionWorktreeReset: true,
|
||||
});
|
||||
await daemonBridge.start();
|
||||
const router = new SessionRouter(
|
||||
daemonBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
const sessionDied = vi.fn();
|
||||
daemonBridge.on('sessionDied', (event: SessionDiedEvent) => {
|
||||
sessionDied(event);
|
||||
router.handleSessionDied(event.sessionId);
|
||||
});
|
||||
router.activateManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp/worktree-task',
|
||||
);
|
||||
await router.loadManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
'worktree',
|
||||
);
|
||||
expect(daemonBridge.listSessions()).toHaveLength(1);
|
||||
|
||||
await expect(
|
||||
router.replaceManagedWorktreeSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
),
|
||||
).rejects.toThrow('daemon unavailable');
|
||||
|
||||
// A pre-flip failure keeps the same session as the worktree owner: only
|
||||
// the live flag goes, and the bridge keeps holding its client.
|
||||
expect(router.isSessionLive('old-session')).toBe(false);
|
||||
expect(daemonBridge.listSessions()).toHaveLength(1);
|
||||
|
||||
await expect(
|
||||
router.loadManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
'worktree',
|
||||
),
|
||||
).resolves.toEqual({ loaded: true, sessionId: 'old-session' });
|
||||
|
||||
expect(sessionFactory).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sessionId: 'old-session', sourceId: 'ch' }),
|
||||
);
|
||||
// Attaching over the surviving binding reports the session as replaced,
|
||||
// and that notification would abort the very load recovering it.
|
||||
expect(sessionDied).not.toHaveBeenCalled();
|
||||
expect(detaches).toHaveLength(2);
|
||||
expect(detaches[0]).toHaveBeenCalledOnce();
|
||||
expect(detaches[1]).not.toHaveBeenCalled();
|
||||
expect(daemonBridge.listSessions()).toEqual([
|
||||
{
|
||||
sessionId: 'old-session',
|
||||
workspaceCwd: '/tmp',
|
||||
hasActivePrompt: false,
|
||||
worktree: {
|
||||
slug: 'task',
|
||||
path: '/tmp/worktree-task',
|
||||
branch: 'task',
|
||||
},
|
||||
worktreeState: 'persisted-v1',
|
||||
},
|
||||
]);
|
||||
expect(router.getSessionCwd('old-session')).toBe('/tmp/worktree-task');
|
||||
expect(router.isSessionLive('old-session')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('superseded redirects', () => {
|
||||
const target = {
|
||||
channelName: 'ch',
|
||||
senderId: 'alice',
|
||||
chatId: 'chat1',
|
||||
};
|
||||
|
||||
function supersededError(replacementSessionId: string): Error {
|
||||
return daemonHttpError('worktree_session_superseded', {
|
||||
replacementSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
it('redirects a superseded load to the replacement and heals the route', async () => {
|
||||
const loadSession = vi.fn(async (sessionId: string) => {
|
||||
if (sessionId === 'old-session') {
|
||||
throw supersededError('replacement-session');
|
||||
}
|
||||
return sessionId;
|
||||
});
|
||||
const managedBridge = {
|
||||
...mockBridge(),
|
||||
loadSession,
|
||||
} satisfies ChannelAgentBridge;
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
router.activateManagedSession('old-session', target, '/tmp');
|
||||
|
||||
await expect(
|
||||
router.loadManagedSession('old-session', target, '/tmp'),
|
||||
).resolves.toEqual({
|
||||
loaded: true,
|
||||
sessionId: 'replacement-session',
|
||||
redirectedFrom: 'old-session',
|
||||
});
|
||||
|
||||
expect(loadSession.mock.calls.map((call) => call[0])).toEqual([
|
||||
'old-session',
|
||||
'replacement-session',
|
||||
]);
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBe(
|
||||
'replacement-session',
|
||||
);
|
||||
expect(router.getTarget('old-session')).toBeUndefined();
|
||||
expect(router.getTarget('replacement-session')).toEqual(target);
|
||||
expect(router.isSessionLive('replacement-session')).toBe(true);
|
||||
});
|
||||
|
||||
it('revalidates the replacement attestation for a worktree redirect', async () => {
|
||||
const managedBridge = {
|
||||
...mockBridge(),
|
||||
listSessions: vi.fn().mockReturnValue([
|
||||
{
|
||||
sessionId: 'replacement-session',
|
||||
workspaceCwd: '/tmp',
|
||||
hasActivePrompt: false,
|
||||
worktree: {
|
||||
slug: 'task',
|
||||
path: '/tmp/worktree-task',
|
||||
branch: 'task',
|
||||
},
|
||||
worktreeState: 'persisted-v1' as const,
|
||||
},
|
||||
]),
|
||||
loadSession: vi.fn(async (sessionId: string) => {
|
||||
if (sessionId === 'old-session') {
|
||||
throw supersededError('replacement-session');
|
||||
}
|
||||
return sessionId;
|
||||
}),
|
||||
} satisfies ChannelAgentBridge;
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
router.activateManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp/worktree-task',
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.loadManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
'worktree',
|
||||
),
|
||||
).resolves.toEqual({
|
||||
loaded: true,
|
||||
sessionId: 'replacement-session',
|
||||
redirectedFrom: 'old-session',
|
||||
});
|
||||
|
||||
expect(router.getSessionCwd('replacement-session')).toBe(
|
||||
'/tmp/worktree-task',
|
||||
);
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBe(
|
||||
'replacement-session',
|
||||
);
|
||||
expect(router.getTarget('old-session')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('discards a redirected replacement whose attestation does not match', async () => {
|
||||
const discardSession = vi.fn().mockResolvedValue(undefined);
|
||||
const managedBridge = {
|
||||
...mockBridge(),
|
||||
listSessions: vi.fn().mockReturnValue([
|
||||
{
|
||||
sessionId: 'replacement-session',
|
||||
workspaceCwd: '/tmp',
|
||||
hasActivePrompt: false,
|
||||
worktree: {
|
||||
slug: 'task',
|
||||
path: '/tmp/other-worktree',
|
||||
branch: 'task',
|
||||
},
|
||||
worktreeState: 'persisted-v1' as const,
|
||||
},
|
||||
]),
|
||||
loadSession: vi.fn(async (sessionId: string) => {
|
||||
if (sessionId === 'old-session') {
|
||||
throw supersededError('replacement-session');
|
||||
}
|
||||
return sessionId;
|
||||
}),
|
||||
discardSession,
|
||||
} satisfies ChannelAgentBridge;
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
router.activateManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp/worktree-task',
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.loadManagedSession(
|
||||
'old-session',
|
||||
target,
|
||||
'/tmp',
|
||||
'/tmp/worktree-task',
|
||||
'worktree',
|
||||
),
|
||||
).rejects.toThrow('did not attest');
|
||||
|
||||
expect(discardSession).toHaveBeenCalledWith(
|
||||
'replacement-session',
|
||||
expect.anything(),
|
||||
);
|
||||
// The stale route is kept: no heal happened for a failed redirect.
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session');
|
||||
expect(router.getTarget('replacement-session')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails closed when the redirect points at the session itself', async () => {
|
||||
const selfRedirect = supersededError('old-session');
|
||||
const loadSession = vi.fn(async (sessionId: string) => {
|
||||
if (sessionId === 'old-session') {
|
||||
throw selfRedirect;
|
||||
}
|
||||
return sessionId;
|
||||
});
|
||||
const managedBridge = {
|
||||
...mockBridge(),
|
||||
loadSession,
|
||||
} satisfies ChannelAgentBridge;
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
router.activateManagedSession('old-session', target, '/tmp');
|
||||
|
||||
await expect(
|
||||
router.loadManagedSession('old-session', target, '/tmp'),
|
||||
).rejects.toBe(selfRedirect);
|
||||
expect(loadSession).toHaveBeenCalledTimes(1);
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session');
|
||||
});
|
||||
|
||||
it('fails closed when the replacement is itself superseded', async () => {
|
||||
const firstRedirect = supersededError('replacement-1');
|
||||
const secondRedirect = supersededError('replacement-2');
|
||||
const loadSession = vi.fn(async (sessionId: string) => {
|
||||
if (sessionId === 'old-session') {
|
||||
throw firstRedirect;
|
||||
}
|
||||
if (sessionId === 'replacement-1') {
|
||||
throw secondRedirect;
|
||||
}
|
||||
return sessionId;
|
||||
});
|
||||
const managedBridge = {
|
||||
...mockBridge(),
|
||||
loadSession,
|
||||
} satisfies ChannelAgentBridge;
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
router.activateManagedSession('old-session', target, '/tmp');
|
||||
|
||||
await expect(
|
||||
router.loadManagedSession('old-session', target, '/tmp'),
|
||||
).rejects.toBe(secondRedirect);
|
||||
|
||||
// A redirect chain is never followed past the first hop.
|
||||
expect(loadSession.mock.calls.map((call) => call[0])).toEqual([
|
||||
'old-session',
|
||||
'replacement-1',
|
||||
]);
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session');
|
||||
});
|
||||
|
||||
it('reads the superseded signal through a wrapped cause', async () => {
|
||||
const wrapped = new Error('load failed', {
|
||||
cause: supersededError('replacement-session'),
|
||||
});
|
||||
const loadSession = vi.fn(async (sessionId: string) => {
|
||||
if (sessionId === 'old-session') {
|
||||
throw wrapped;
|
||||
}
|
||||
return sessionId;
|
||||
});
|
||||
const managedBridge = {
|
||||
...mockBridge(),
|
||||
loadSession,
|
||||
} satisfies ChannelAgentBridge;
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
router.activateManagedSession('old-session', target, '/tmp');
|
||||
|
||||
await expect(
|
||||
router.loadManagedSession('old-session', target, '/tmp'),
|
||||
).resolves.toEqual({
|
||||
loaded: true,
|
||||
sessionId: 'replacement-session',
|
||||
redirectedFrom: 'old-session',
|
||||
});
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBe(
|
||||
'replacement-session',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not redirect other daemon conflicts', async () => {
|
||||
const conflict = daemonHttpError('worktree_marker_missing');
|
||||
const loadSession = vi.fn(async (sessionId: string) => {
|
||||
if (sessionId === 'old-session') {
|
||||
throw conflict;
|
||||
}
|
||||
return sessionId;
|
||||
});
|
||||
const managedBridge = {
|
||||
...mockBridge(),
|
||||
loadSession,
|
||||
} satisfies ChannelAgentBridge;
|
||||
const router = new SessionRouter(
|
||||
managedBridge,
|
||||
'/tmp',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
router.activateManagedSession('old-session', target, '/tmp');
|
||||
|
||||
await expect(
|
||||
router.loadManagedSession('old-session', target, '/tmp'),
|
||||
).rejects.toBe(conflict);
|
||||
expect(loadSession).toHaveBeenCalledTimes(1);
|
||||
expect(router.getSession('ch', 'alice', 'chat1')).toBe('old-session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSession', () => {
|
||||
it('removes session by key and returns session IDs', async () => {
|
||||
const router = new SessionRouter(bridge, '/tmp');
|
||||
@@ -2487,3 +3251,116 @@ describe('SessionRouter', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('daemon error helpers', () => {
|
||||
describe('readDaemonHttpErrorCode', () => {
|
||||
it('reads the code from a DaemonHttpError-shaped error', () => {
|
||||
expect(
|
||||
readDaemonHttpErrorCode(daemonHttpError('worktree_reset_active')),
|
||||
).toBe('worktree_reset_active');
|
||||
});
|
||||
|
||||
it('reads the code through a cause chain', () => {
|
||||
const error = new Error('outer', {
|
||||
cause: new Error('middle', {
|
||||
cause: daemonHttpError('worktree_reset_interrupted'),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(readDaemonHttpErrorCode(error)).toBe('worktree_reset_interrupted');
|
||||
});
|
||||
|
||||
it('returns undefined for non-matching shapes', () => {
|
||||
expect(readDaemonHttpErrorCode(undefined)).toBeUndefined();
|
||||
expect(readDaemonHttpErrorCode(null)).toBeUndefined();
|
||||
expect(readDaemonHttpErrorCode('worktree_reset_active')).toBeUndefined();
|
||||
expect(readDaemonHttpErrorCode(new Error('plain'))).toBeUndefined();
|
||||
// Wrong name or status, and non-object or empty codes, do not match.
|
||||
expect(
|
||||
readDaemonHttpErrorCode(
|
||||
Object.assign(new Error('x'), {
|
||||
status: 409,
|
||||
body: { code: 'worktree_reset_active' },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readDaemonHttpErrorCode(
|
||||
Object.assign(new Error('x'), {
|
||||
name: 'DaemonHttpError',
|
||||
status: 500,
|
||||
body: { code: 'worktree_reset_active' },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readDaemonHttpErrorCode(
|
||||
Object.assign(new Error('x'), {
|
||||
name: 'DaemonHttpError',
|
||||
status: 409,
|
||||
body: { code: '' },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readDaemonHttpErrorCode(
|
||||
Object.assign(new Error('x'), {
|
||||
name: 'DaemonHttpError',
|
||||
status: 409,
|
||||
body: ['worktree_reset_active'],
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('bounds the cause-chain walk', () => {
|
||||
let error: Error = daemonHttpError('worktree_reset_active');
|
||||
for (let depth = 0; depth < 8; depth++) {
|
||||
error = new Error(`wrap-${depth}`, { cause: error });
|
||||
}
|
||||
|
||||
expect(readDaemonHttpErrorCode(error)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readSupersededReplacementId', () => {
|
||||
it('reads the replacement id from a superseded conflict', () => {
|
||||
expect(
|
||||
readSupersededReplacementId(
|
||||
daemonHttpError('worktree_session_superseded', {
|
||||
replacementSessionId: 'replacement-session',
|
||||
}),
|
||||
),
|
||||
).toBe('replacement-session');
|
||||
});
|
||||
|
||||
it('reads the replacement id through a cause chain', () => {
|
||||
const error = new Error('outer', {
|
||||
cause: daemonHttpError('worktree_session_superseded', {
|
||||
replacementSessionId: 'replacement-session',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(readSupersededReplacementId(error)).toBe('replacement-session');
|
||||
});
|
||||
|
||||
it('returns undefined for other codes or a missing replacement id', () => {
|
||||
expect(
|
||||
readSupersededReplacementId(daemonHttpError('worktree_reset_active')),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readSupersededReplacementId(
|
||||
daemonHttpError('worktree_session_superseded'),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
readSupersededReplacementId(
|
||||
daemonHttpError('worktree_session_superseded', {
|
||||
replacementSessionId: '',
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(readSupersededReplacementId(new Error('plain'))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,67 @@ interface ResolveOptions {
|
||||
export type SessionRecoveryMode = 'eager' | 'lazy';
|
||||
export type ManagedSessionIsolation = 'shared' | 'worktree';
|
||||
|
||||
/**
|
||||
* Read the daemon's typed error code off a `DaemonHttpError` (matched by
|
||||
* shape: channels/base keeps no SDK dependency), walking `cause` chains so
|
||||
* callers that wrap errors (the named-session manager) stay transparent.
|
||||
*/
|
||||
export function readDaemonHttpErrorCode(error: unknown): string | undefined {
|
||||
let current: unknown = error;
|
||||
for (let depth = 0; depth < 5; depth++) {
|
||||
if (
|
||||
(typeof current !== 'object' && typeof current !== 'function') ||
|
||||
current === null
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
if (record['name'] === 'DaemonHttpError' && record['status'] === 409) {
|
||||
const body = record['body'];
|
||||
if (typeof body === 'object' && body !== null && !Array.isArray(body)) {
|
||||
const code = (body as Record<string, unknown>)['code'];
|
||||
if (typeof code === 'string' && code.length > 0) return code;
|
||||
}
|
||||
}
|
||||
current = record['cause'];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The replacement session id from a `409 worktree_session_superseded`
|
||||
* daemon response, when the error carries one.
|
||||
*/
|
||||
export function readSupersededReplacementId(
|
||||
error: unknown,
|
||||
): string | undefined {
|
||||
let current: unknown = error;
|
||||
for (let depth = 0; depth < 5; depth++) {
|
||||
if (
|
||||
(typeof current !== 'object' && typeof current !== 'function') ||
|
||||
current === null
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
if (record['name'] === 'DaemonHttpError' && record['status'] === 409) {
|
||||
const body = record['body'];
|
||||
if (typeof body === 'object' && body !== null && !Array.isArray(body)) {
|
||||
const typedBody = body as Record<string, unknown>;
|
||||
if (typedBody['code'] !== 'worktree_session_superseded') {
|
||||
return undefined;
|
||||
}
|
||||
const replacement = typedBody['replacementSessionId'];
|
||||
return typeof replacement === 'string' && replacement.length > 0
|
||||
? replacement
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
current = record['cause'];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface SessionRouterOptions {
|
||||
recoveryMode?: SessionRecoveryMode;
|
||||
}
|
||||
@@ -57,6 +118,7 @@ export class SessionRouter {
|
||||
private creatingSessions: Map<string, SessionOperation> = new Map();
|
||||
private sessionLoadWindows: Set<SessionLoadWindow> = new Set();
|
||||
private readonly liveSessionIds = new Set<string>();
|
||||
private readonly staleBridgeBindings = new Set<string>();
|
||||
private readonly routeTokens = new Map<string, object>();
|
||||
private lifecycleGeneration = 0;
|
||||
|
||||
@@ -87,6 +149,7 @@ export class SessionRouter {
|
||||
setBridge(bridge: ChannelAgentBridge): void {
|
||||
this.bridge = bridge;
|
||||
this.liveSessionIds.clear();
|
||||
this.staleBridgeBindings.clear();
|
||||
}
|
||||
|
||||
/** Set scope override for a specific channel. */
|
||||
@@ -501,7 +564,12 @@ export class SessionRouter {
|
||||
workspaceCwd: string,
|
||||
expectedCwd: string = workspaceCwd,
|
||||
isolation: ManagedSessionIsolation = 'shared',
|
||||
): Promise<{ loaded: boolean }> {
|
||||
allowSupersededRedirect = true,
|
||||
): Promise<{
|
||||
loaded: boolean;
|
||||
sessionId: string;
|
||||
redirectedFrom?: string;
|
||||
}> {
|
||||
if (this.liveSessionIds.has(sessionId)) {
|
||||
const actualCwd = this.validateManagedSessionIdentity(
|
||||
this.bridge,
|
||||
@@ -512,7 +580,7 @@ export class SessionRouter {
|
||||
);
|
||||
this.toTarget.set(sessionId, target);
|
||||
this.toCwd.set(sessionId, actualCwd);
|
||||
return { loaded: false };
|
||||
return { loaded: false, sessionId };
|
||||
}
|
||||
|
||||
const loadWindow = this.beginSessionLoad();
|
||||
@@ -521,6 +589,13 @@ export class SessionRouter {
|
||||
const bindingToken = {};
|
||||
let loadedSessionId: string | undefined;
|
||||
try {
|
||||
if (this.staleBridgeBindings.delete(sessionId)) {
|
||||
// A failed worktree reset left the bridge holding a client for this id.
|
||||
// Attaching over that binding reports the session as replaced, which
|
||||
// aborts this very load, so release it first. Tokenless: the surviving
|
||||
// binding carries the previous load's token.
|
||||
await bridge.discardSession?.(sessionId).catch(() => undefined);
|
||||
}
|
||||
loadedSessionId = await bridge.loadSession(
|
||||
sessionId,
|
||||
workspaceCwd,
|
||||
@@ -558,13 +633,145 @@ export class SessionRouter {
|
||||
this.toTarget.set(sessionId, target);
|
||||
this.toCwd.set(sessionId, actualCwd);
|
||||
this.liveSessionIds.add(sessionId);
|
||||
return { loaded: true };
|
||||
return { loaded: true, sessionId };
|
||||
} catch (error) {
|
||||
if (loadedSessionId) {
|
||||
await bridge
|
||||
.discardSession?.(loadedSessionId, bindingToken)
|
||||
.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
// Superseded redirect: a worktree reset moved this session's checkout
|
||||
// ownership to a replacement. Redirect the load once, then heal the
|
||||
// route mappings so the stale id is never consulted again. A redirect
|
||||
// chain (or a self-redirect) fails closed.
|
||||
const replacementId = readSupersededReplacementId(error);
|
||||
if (
|
||||
allowSupersededRedirect &&
|
||||
replacementId !== undefined &&
|
||||
replacementId !== sessionId
|
||||
) {
|
||||
const redirected = await this.loadManagedSession(
|
||||
replacementId,
|
||||
target,
|
||||
workspaceCwd,
|
||||
expectedCwd,
|
||||
isolation,
|
||||
false,
|
||||
);
|
||||
this.healSupersededRoute(sessionId, redirected.sessionId);
|
||||
return {
|
||||
loaded: redirected.loaded,
|
||||
sessionId: redirected.sessionId,
|
||||
redirectedFrom: sessionId,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this.endSessionLoad(loadWindow);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap every route that still points at a superseded session to its
|
||||
* replacement, and drop the superseded id's bookkeeping. The manager's
|
||||
* own task registry heals separately (it owns the task-name mapping).
|
||||
*/
|
||||
private healSupersededRoute(
|
||||
oldSessionId: string,
|
||||
newSessionId: string,
|
||||
): void {
|
||||
let changed = false;
|
||||
for (const [key, mappedSessionId] of this.toSession) {
|
||||
if (mappedSessionId !== oldSessionId) continue;
|
||||
this.invalidateRouteOperation(key);
|
||||
this.toSession.set(key, newSessionId);
|
||||
changed = true;
|
||||
}
|
||||
changed = this.toTarget.delete(oldSessionId) || changed;
|
||||
changed = this.toCwd.delete(oldSessionId) || changed;
|
||||
this.liveSessionIds.delete(oldSessionId);
|
||||
this.staleBridgeBindings.delete(oldSessionId);
|
||||
if (changed) this.persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Worktree reset: transfer the task session's checkout ownership to a
|
||||
* fresh replacement session on the daemon, validate the replacement's
|
||||
* attestation, and route it. The task registry itself is swapped by the
|
||||
* named-session manager (it owns the task-name mapping); this call only
|
||||
* wires the router's session bookkeeping.
|
||||
*
|
||||
* A failure past the daemon's marker flip rolls nothing back: the
|
||||
* registry keeps pointing at the old id, and the next selection's load
|
||||
* heals it through the superseded redirect — the failed reset drops the
|
||||
* old id from the live set so that load consults the daemon again.
|
||||
*/
|
||||
async replaceManagedWorktreeSession(
|
||||
sessionId: string,
|
||||
target: SessionTarget,
|
||||
workspaceCwd: string,
|
||||
expectedCwd: string,
|
||||
): Promise<string> {
|
||||
const bridge = this.bridge;
|
||||
if (!bridge.resetWorktreeSession) {
|
||||
throw new Error('Worktree reset is not supported by this bridge');
|
||||
}
|
||||
const loadWindow = this.beginSessionLoad();
|
||||
const lifecycleGeneration = this.lifecycleGeneration;
|
||||
const bindingToken = {};
|
||||
let replacementId: string | undefined;
|
||||
try {
|
||||
replacementId = await bridge.resetWorktreeSession(
|
||||
sessionId,
|
||||
workspaceCwd,
|
||||
this.sessionOptions(target.channelName),
|
||||
bindingToken,
|
||||
);
|
||||
if (
|
||||
lifecycleGeneration !== this.lifecycleGeneration ||
|
||||
bridge !== this.bridge
|
||||
) {
|
||||
this.scheduleManagedDiscard(bridge, replacementId, bindingToken);
|
||||
throw new Error('Managed session reset was invalidated');
|
||||
}
|
||||
if (typeof replacementId !== 'string' || replacementId.length === 0) {
|
||||
throw new Error('Invalid session ID from bridge');
|
||||
}
|
||||
if (loadWindow.delete(replacementId)) {
|
||||
await bridge.discardSession?.(replacementId, bindingToken);
|
||||
throw new Error(
|
||||
`Managed session ${replacementId} died before reset completed`,
|
||||
);
|
||||
}
|
||||
const actualCwd = this.validateManagedSessionIdentity(
|
||||
bridge,
|
||||
replacementId,
|
||||
workspaceCwd,
|
||||
expectedCwd,
|
||||
'worktree',
|
||||
);
|
||||
this.toTarget.set(replacementId, target);
|
||||
this.toCwd.set(replacementId, actualCwd);
|
||||
this.liveSessionIds.add(replacementId);
|
||||
this.forgetManagedSession(sessionId);
|
||||
return replacementId;
|
||||
} catch (error) {
|
||||
if (replacementId !== undefined) {
|
||||
await bridge
|
||||
.discardSession?.(replacementId, bindingToken)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
// A failure past the daemon's marker flip leaves this session superseded
|
||||
// server-side with no eviction event to tell us. While the id stays live
|
||||
// the next load short-circuits and never consults the daemon, so the
|
||||
// superseded redirect can never heal it. Drop only the live flag: the
|
||||
// route mappings stay, so a pre-flip failure re-loads this id. The bridge
|
||||
// keeps holding its client either way, so record the id — the re-load has
|
||||
// to release that binding before attaching, and attaching over it would
|
||||
// report the session as replaced and abort the load that recovers it.
|
||||
this.staleBridgeBindings.add(sessionId);
|
||||
this.liveSessionIds.delete(sessionId);
|
||||
throw error;
|
||||
} finally {
|
||||
this.endSessionLoad(loadWindow);
|
||||
@@ -636,16 +843,20 @@ export class SessionRouter {
|
||||
changed = this.toTarget.delete(sessionId) || changed;
|
||||
changed = this.toCwd.delete(sessionId) || changed;
|
||||
changed = this.liveSessionIds.delete(sessionId) || changed;
|
||||
this.staleBridgeBindings.delete(sessionId);
|
||||
if (changed) this.persist();
|
||||
}
|
||||
|
||||
async detachManagedSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
if (this.liveSessionIds.has(sessionId)) {
|
||||
if (!this.bridge.discardSession) {
|
||||
throw new Error('Managed session detach is not supported');
|
||||
}
|
||||
if (this.bridge.discardSession) {
|
||||
// Release regardless of the live flag: a failed worktree reset drops it
|
||||
// so the superseded redirect can heal the id, while the bridge keeps
|
||||
// holding the client and its event pump. Discarding an id the bridge
|
||||
// has no binding for is a no-op.
|
||||
await this.bridge.discardSession(sessionId);
|
||||
} else if (this.liveSessionIds.has(sessionId)) {
|
||||
throw new Error('Managed session detach is not supported');
|
||||
}
|
||||
} finally {
|
||||
this.forgetManagedSession(sessionId);
|
||||
@@ -910,6 +1121,7 @@ export class SessionRouter {
|
||||
this.creatingSessions.clear();
|
||||
this.sessionLoadWindows.clear();
|
||||
this.liveSessionIds.clear();
|
||||
this.staleBridgeBindings.clear();
|
||||
this.routeTokens.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
} from './ChannelAgentBridge.js';
|
||||
export type {
|
||||
AvailableCommand,
|
||||
BackgroundResponseContext,
|
||||
BridgeSessionInfo,
|
||||
ChannelBtwResult,
|
||||
ChannelAgentBridge,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ChannelAgentBridge } from './ChannelAgentBridge.js';
|
||||
import { NamedSessionManager } from './named-session-manager.js';
|
||||
import { canonicalizeWorkspacePath } from './paths.js';
|
||||
import { SessionRouter } from './SessionRouter.js';
|
||||
import { readDaemonHttpErrorCode, SessionRouter } from './SessionRouter.js';
|
||||
|
||||
function createBridge(): ChannelAgentBridge {
|
||||
let nextId = 0;
|
||||
@@ -54,6 +54,25 @@ function createBridge(): ChannelAgentBridge {
|
||||
);
|
||||
return sessionId;
|
||||
}),
|
||||
resetWorktreeSession: vi.fn(
|
||||
async (sessionId: string, workspaceCwd: string) => {
|
||||
// The daemon transfers the checkout: the replacement session owns the
|
||||
// SAME worktree path, and the superseded session leaves the live set.
|
||||
const previous = known.get(sessionId);
|
||||
const replacementId = `session-${++nextId}`;
|
||||
const info = previous?.worktree
|
||||
? {
|
||||
workspaceCwd,
|
||||
worktree: { ...previous.worktree },
|
||||
worktreeState: 'persisted-v1' as const,
|
||||
}
|
||||
: { workspaceCwd };
|
||||
live.set(replacementId, info);
|
||||
known.set(replacementId, info);
|
||||
live.delete(sessionId);
|
||||
return replacementId;
|
||||
},
|
||||
),
|
||||
prompt: vi.fn().mockResolvedValue(''),
|
||||
cancelSession: vi.fn().mockResolvedValue(undefined),
|
||||
discardSession: vi.fn(async (sessionId: string) => {
|
||||
@@ -69,6 +88,17 @@ function createBridge(): ChannelAgentBridge {
|
||||
};
|
||||
}
|
||||
|
||||
/** A daemon conflict error by shape (channels/base keeps no SDK dependency). */
|
||||
function daemonError(
|
||||
code: string,
|
||||
extraBody: Record<string, unknown> = {},
|
||||
): Error {
|
||||
const error = new Error(`daemon rejected with ${code}`);
|
||||
error.name = 'DaemonHttpError';
|
||||
Object.assign(error, { status: 409, body: { code, ...extraBody } });
|
||||
return error;
|
||||
}
|
||||
|
||||
const alice = {
|
||||
senderId: 'alice',
|
||||
chatId: 'group-1',
|
||||
@@ -104,6 +134,58 @@ describe('NamedSessionManager', () => {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart harness for superseded-redirect tests: a fresh bridge whose daemon
|
||||
* "moved" the checkout of `oldSessionId` to session-2 (loading the old id
|
||||
* fails with the typed conflict), plus a fresh router and manager on the
|
||||
* same registry file so nothing is live yet.
|
||||
*/
|
||||
function supersededRestart(filePath: string, oldSessionId: string) {
|
||||
const restartedBridge = createBridge();
|
||||
vi.mocked(restartedBridge.loadSession).mockImplementation(
|
||||
async (sessionId: string) => {
|
||||
if (sessionId === oldSessionId) {
|
||||
throw daemonError('worktree_session_superseded', {
|
||||
replacementSessionId: 'session-2',
|
||||
});
|
||||
}
|
||||
return sessionId;
|
||||
},
|
||||
);
|
||||
vi.mocked(restartedBridge.listSessions).mockReturnValue([
|
||||
{
|
||||
sessionId: 'session-2',
|
||||
workspaceCwd: '/workspace',
|
||||
hasActivePrompt: false,
|
||||
worktree: {
|
||||
slug: 'feature',
|
||||
path: `/worktrees/${oldSessionId}`,
|
||||
branch: 'feature',
|
||||
},
|
||||
worktreeState: 'persisted-v1',
|
||||
},
|
||||
]);
|
||||
const restartedRouter = new SessionRouter(
|
||||
restartedBridge,
|
||||
'/workspace',
|
||||
'user',
|
||||
undefined,
|
||||
{ recoveryMode: 'lazy' },
|
||||
);
|
||||
const restartedManager = new NamedSessionManager({
|
||||
channelName: 'channel-a',
|
||||
cwd: '/workspace',
|
||||
filePath,
|
||||
router: restartedRouter,
|
||||
isBusy: () => false,
|
||||
});
|
||||
return {
|
||||
bridge: restartedBridge,
|
||||
router: restartedRouter,
|
||||
manager: restartedManager,
|
||||
};
|
||||
}
|
||||
|
||||
it('adopts the existing route as default without changing its session', async () => {
|
||||
const legacyId = await router.resolve(
|
||||
'channel-a',
|
||||
@@ -377,7 +459,7 @@ describe('NamedSessionManager', () => {
|
||||
vi.mocked(bridge.loadSession).mockClear();
|
||||
|
||||
await expect(named.resumeReserved(alice, feature.sessionId)).resolves.toBe(
|
||||
true,
|
||||
feature.sessionId,
|
||||
);
|
||||
|
||||
await expect(named.current(alice)).resolves.toEqual(
|
||||
@@ -398,11 +480,11 @@ describe('NamedSessionManager', () => {
|
||||
|
||||
await expect(
|
||||
named.resumeReserved({ ...alice, senderId: 'bob' }, review.sessionId),
|
||||
).resolves.toBe(false);
|
||||
).resolves.toBeUndefined();
|
||||
await expect(named.close(alice, 'review')).resolves.toBeDefined();
|
||||
await expect(named.resumeReserved(alice, review.sessionId)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(
|
||||
named.resumeReserved(alice, review.sessionId),
|
||||
).resolves.toBeUndefined();
|
||||
expect(bridge.loadSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -743,15 +825,302 @@ describe('NamedSessionManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects resetting a worktree task before creating a replacement', async () => {
|
||||
it('resets a worktree task onto a fresh session that keeps the worktree', async () => {
|
||||
const named = manager();
|
||||
await named.create(alice, 'feature', 'worktree');
|
||||
const created = await named.create(alice, 'feature', 'worktree');
|
||||
const worktreeCwd = canonicalizeWorkspacePath(
|
||||
`/worktrees/${created.sessionId}`,
|
||||
);
|
||||
const before = JSON.parse(
|
||||
readFileSync(join(dir, 'named-sessions.json'), 'utf8'),
|
||||
) as { owners: Array<{ tasks: Array<{ createdAt: number }> }> };
|
||||
const createdAt = before.owners[0]?.tasks[0]?.createdAt;
|
||||
vi.mocked(bridge.newSession).mockClear();
|
||||
|
||||
const reset = await named.reset(alice);
|
||||
|
||||
expect(reset).toEqual({
|
||||
name: 'feature',
|
||||
previousSessionId: created.sessionId,
|
||||
sessionId: 'session-2',
|
||||
worktreeKept: true,
|
||||
});
|
||||
expect(bridge.resetWorktreeSession).toHaveBeenCalledWith(
|
||||
created.sessionId,
|
||||
'/workspace',
|
||||
{ sourceId: 'channel-a' },
|
||||
expect.anything(),
|
||||
);
|
||||
// The reset is an ownership transfer: no fresh conversation session is
|
||||
// created and nothing is discarded.
|
||||
expect(bridge.newSession).not.toHaveBeenCalled();
|
||||
expect(bridge.discardSession).not.toHaveBeenCalled();
|
||||
// The replacement keeps the same worktree checkout and takes over routing.
|
||||
expect(router.getSessionCwd('session-2')).toBe(worktreeCwd);
|
||||
expect(router.getSession('channel-a', 'alice', 'group-1')).toBe(
|
||||
'session-2',
|
||||
);
|
||||
expect(named.presentation(created.sessionId)).toBeUndefined();
|
||||
expect(named.presentation('session-2')).toEqual(
|
||||
expect.objectContaining({ taskName: 'feature', status: 'open' }),
|
||||
);
|
||||
await expect(named.current(alice)).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'feature', sessionId: 'session-2' }),
|
||||
);
|
||||
|
||||
// The registry swap preserves the task's identity and creation timestamp.
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(dir, 'named-sessions.json'), 'utf8'),
|
||||
) as {
|
||||
owners: Array<{
|
||||
tasks: Array<{ sessionId: string; cwd: string; createdAt: number }>;
|
||||
}>;
|
||||
};
|
||||
expect(persisted.owners[0]?.tasks[0]).toMatchObject({
|
||||
sessionId: 'session-2',
|
||||
cwd: worktreeCwd,
|
||||
createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects resetting a busy worktree task before any daemon call', async () => {
|
||||
const named = manager(vi.fn().mockReturnValue(true));
|
||||
const created = await named.create(alice, 'feature', 'worktree');
|
||||
vi.mocked(bridge.newSession).mockClear();
|
||||
|
||||
await expect(named.reset(alice)).rejects.toThrow(
|
||||
'cannot be cleared or reset yet',
|
||||
'Task "feature" is busy. Wait for the running prompt to finish (or cancel it), then try again.',
|
||||
);
|
||||
|
||||
expect(bridge.resetWorktreeSession).not.toHaveBeenCalled();
|
||||
expect(bridge.newSession).not.toHaveBeenCalled();
|
||||
await expect(named.current(alice)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'feature',
|
||||
sessionId: created.sessionId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('attempts a worktree reset once and keeps the daemon cause', async () => {
|
||||
const named = manager();
|
||||
const created = await named.create(alice, 'feature', 'worktree');
|
||||
vi.mocked(bridge.resetWorktreeSession).mockRejectedValue(
|
||||
daemonError('worktree_reset_invalid_state'),
|
||||
);
|
||||
|
||||
const error: unknown = await named
|
||||
.reset(alice)
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('Could not reset task "feature".');
|
||||
expect(readDaemonHttpErrorCode(error)).toBe('worktree_reset_invalid_state');
|
||||
// The daemon resumes an interrupted transfer itself, so the manager never
|
||||
// retries a rejected reset.
|
||||
expect(bridge.resetWorktreeSession).toHaveBeenCalledTimes(1);
|
||||
await expect(named.current(alice)).resolves.toEqual(
|
||||
expect.objectContaining({ sessionId: created.sessionId }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the typed daemon code on a load failure for the channel', async () => {
|
||||
const named = manager();
|
||||
const created = await named.create(alice, 'feature', 'worktree');
|
||||
router.handleSessionDied(created.sessionId);
|
||||
vi.mocked(bridge.loadSession).mockRejectedValue(
|
||||
daemonError('worktree_reset_interrupted'),
|
||||
);
|
||||
|
||||
const error: unknown = await named
|
||||
.use(alice, 'feature')
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect((error as Error).message).toBe(
|
||||
'Could not load task "feature". The current task was not changed.',
|
||||
);
|
||||
expect(readDaemonHttpErrorCode(error)).toBe('worktree_reset_interrupted');
|
||||
await expect(named.current(alice)).resolves.toEqual(
|
||||
expect.objectContaining({ sessionId: created.sessionId }),
|
||||
);
|
||||
});
|
||||
|
||||
it('detaches the replacement when the registry commit fails after a worktree reset', async () => {
|
||||
const registryDir = join(dir, 'registry');
|
||||
const named = new NamedSessionManager({
|
||||
channelName: 'channel-a',
|
||||
cwd: '/workspace',
|
||||
filePath: join(registryDir, 'named-sessions.json'),
|
||||
router,
|
||||
isBusy: () => false,
|
||||
now: () => 1_000,
|
||||
});
|
||||
const created = await named.create(alice, 'feature', 'worktree');
|
||||
rmSync(registryDir, { recursive: true, force: true });
|
||||
writeFileSync(registryDir, 'block');
|
||||
|
||||
await expect(named.reset(alice)).rejects.toThrow('Failed to persist');
|
||||
|
||||
// The freshly created replacement is released again; the registry keeps
|
||||
// pointing at the superseded id, which the next load heals via the
|
||||
// superseded redirect.
|
||||
expect(bridge.discardSession).toHaveBeenCalledWith('session-2');
|
||||
expect(router.getTarget('session-2')).toBeUndefined();
|
||||
expect(router.getTarget(created.sessionId)).toBeUndefined();
|
||||
expect(router.getSession('channel-a', 'alice', 'group-1')).toBeUndefined();
|
||||
await expect(named.current(alice)).resolves.toEqual(
|
||||
expect.objectContaining({ sessionId: created.sessionId }),
|
||||
);
|
||||
});
|
||||
|
||||
it('heals the registry when the daemon reports the task session superseded', async () => {
|
||||
const filePath = join(dir, 'named-sessions.json');
|
||||
const firstManager = manager();
|
||||
const created = await firstManager.create(alice, 'feature', 'worktree');
|
||||
const worktreeCwd = canonicalizeWorkspacePath(
|
||||
`/worktrees/${created.sessionId}`,
|
||||
);
|
||||
const restarted = supersededRestart(filePath, created.sessionId);
|
||||
|
||||
const selected = await restarted.manager.use(alice, 'feature');
|
||||
|
||||
// The selection, the registry, and the router all name the replacement.
|
||||
expect(selected.sessionId).toBe('session-2');
|
||||
await expect(restarted.manager.current(alice)).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'feature', sessionId: 'session-2' }),
|
||||
);
|
||||
expect(restarted.manager.presentation(created.sessionId)).toBeUndefined();
|
||||
expect(restarted.manager.presentation('session-2')).toEqual(
|
||||
expect.objectContaining({ taskName: 'feature', status: 'open' }),
|
||||
);
|
||||
expect(restarted.router.getSession('channel-a', 'alice', 'group-1')).toBe(
|
||||
'session-2',
|
||||
);
|
||||
expect(restarted.router.getSessionCwd('session-2')).toBe(worktreeCwd);
|
||||
const persisted = JSON.parse(readFileSync(filePath, 'utf8')) as {
|
||||
owners: Array<{ tasks: Array<{ sessionId: string }> }>;
|
||||
};
|
||||
expect(persisted.owners[0]?.tasks[0]?.sessionId).toBe('session-2');
|
||||
});
|
||||
|
||||
it('moves the resolve reservation to the replacement session', async () => {
|
||||
const filePath = join(dir, 'named-sessions.json');
|
||||
const firstManager = manager();
|
||||
const created = await firstManager.create(alice, 'feature', 'worktree');
|
||||
const restarted = supersededRestart(filePath, created.sessionId);
|
||||
const reserved: string[] = [];
|
||||
const released: string[] = [];
|
||||
|
||||
const sessionId = await restarted.manager.resolve(alice, (id) => {
|
||||
reserved.push(id);
|
||||
return () => {
|
||||
released.push(id);
|
||||
};
|
||||
});
|
||||
|
||||
expect(sessionId).toBe('session-2');
|
||||
expect(reserved).toEqual([created.sessionId, 'session-2']);
|
||||
expect(released).toEqual([created.sessionId]);
|
||||
});
|
||||
|
||||
it('reports the healed replacement session from a reserved reload', async () => {
|
||||
const filePath = join(dir, 'named-sessions.json');
|
||||
const firstManager = manager();
|
||||
const created = await firstManager.create(alice, 'feature', 'worktree');
|
||||
const restarted = supersededRestart(filePath, created.sessionId);
|
||||
|
||||
// A queued turn is bound to the superseded id; the reload must hand back
|
||||
// the id the caller dispatches on, not just a success flag.
|
||||
await expect(
|
||||
restarted.manager.resumeReserved(alice, created.sessionId),
|
||||
).resolves.toBe('session-2');
|
||||
// The heal rewrote the registry, but the *other* queued turns of this chat
|
||||
// are still bound to the superseded id. They must follow the task onto the
|
||||
// replacement instead of resolving to nothing and being dropped in
|
||||
// silence.
|
||||
await expect(
|
||||
restarted.manager.resumeReserved(alice, created.sessionId),
|
||||
).resolves.toBe('session-2');
|
||||
await expect(restarted.manager.current(alice)).resolves.toEqual(
|
||||
expect.objectContaining({ name: 'feature', sessionId: 'session-2' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a healed load when the registry cannot persist the heal', async () => {
|
||||
const registryDir = join(dir, 'registry');
|
||||
const filePath = join(registryDir, 'named-sessions.json');
|
||||
const firstManager = new NamedSessionManager({
|
||||
channelName: 'channel-a',
|
||||
cwd: '/workspace',
|
||||
filePath,
|
||||
router,
|
||||
isBusy: () => false,
|
||||
now: () => 1_000,
|
||||
});
|
||||
const created = await firstManager.create(alice, 'feature', 'worktree');
|
||||
const restarted = supersededRestart(filePath, created.sessionId);
|
||||
rmSync(registryDir, { recursive: true, force: true });
|
||||
writeFileSync(registryDir, 'block');
|
||||
const reserved: string[] = [];
|
||||
const released: string[] = [];
|
||||
|
||||
// The redirect loaded, validated and routed the replacement, so an
|
||||
// unwritable registry must not be reported as a load that created none.
|
||||
await expect(
|
||||
restarted.manager.resolve(alice, (id) => {
|
||||
reserved.push(id);
|
||||
return () => {
|
||||
released.push(id);
|
||||
};
|
||||
}),
|
||||
).resolves.toBe('session-2');
|
||||
expect(reserved).toEqual([created.sessionId, 'session-2']);
|
||||
expect(released).toEqual([created.sessionId]);
|
||||
// The replacement keeps its route: detaching it would drop a live session
|
||||
// the caller is about to dispatch on.
|
||||
expect(restarted.bridge.discardSession).not.toHaveBeenCalledWith(
|
||||
'session-2',
|
||||
);
|
||||
expect(restarted.router.getSession('channel-a', 'alice', 'group-1')).toBe(
|
||||
'session-2',
|
||||
);
|
||||
|
||||
// The registry still names the superseded id — the state the router
|
||||
// tolerates — and the next load heals it once writing succeeds again.
|
||||
rmSync(registryDir, { force: true });
|
||||
mkdirSync(registryDir, { recursive: true });
|
||||
await expect(restarted.manager.resolve(alice)).resolves.toBe('session-2');
|
||||
const persisted = JSON.parse(readFileSync(filePath, 'utf8')) as {
|
||||
owners: Array<{ tasks: Array<{ sessionId: string }> }>;
|
||||
};
|
||||
expect(persisted.owners[0]?.tasks[0]?.sessionId).toBe('session-2');
|
||||
});
|
||||
|
||||
it('still detaches the replacement when a selection cannot persist a healed load', async () => {
|
||||
const registryDir = join(dir, 'registry');
|
||||
const filePath = join(registryDir, 'named-sessions.json');
|
||||
const firstManager = new NamedSessionManager({
|
||||
channelName: 'channel-a',
|
||||
cwd: '/workspace',
|
||||
filePath,
|
||||
router,
|
||||
isBusy: () => false,
|
||||
now: () => 1_000,
|
||||
});
|
||||
const created = await firstManager.create(alice, 'feature', 'worktree');
|
||||
const restarted = supersededRestart(filePath, created.sessionId);
|
||||
rmSync(registryDir, { recursive: true, force: true });
|
||||
writeFileSync(registryDir, 'block');
|
||||
|
||||
// A selection change commits itself, so it still fails closed and releases
|
||||
// the replacement it loaded; the best-effort heal must not swallow that.
|
||||
await expect(restarted.manager.use(alice, 'feature')).rejects.toThrow(
|
||||
'Failed to persist',
|
||||
);
|
||||
expect(restarted.bridge.discardSession).toHaveBeenCalledWith('session-2');
|
||||
await expect(restarted.manager.current(alice)).resolves.toEqual(
|
||||
expect.objectContaining({ sessionId: created.sessionId }),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads a legacy shared-only v1 registry and writes its workspace root next', async () => {
|
||||
@@ -795,6 +1164,83 @@ describe('NamedSessionManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a fallback session healed mid-close when detachment rolls back', async () => {
|
||||
const filePath = join(dir, 'named-sessions.json');
|
||||
const named = manager();
|
||||
const fallback = await named.create(alice, 'fallback', 'worktree');
|
||||
const closing = await named.create(alice, 'feature', 'worktree');
|
||||
const healedId = 'session-1b';
|
||||
// The fallback's session was superseded out of band, so loading it during
|
||||
// the close heals the registry onto the replacement before the detach of
|
||||
// the closing task fails and the snapshot is restored.
|
||||
router.handleSessionDied(fallback.sessionId);
|
||||
vi.mocked(bridge.loadSession).mockImplementation(
|
||||
async (sessionId: string) => {
|
||||
if (sessionId === fallback.sessionId) {
|
||||
throw daemonError('worktree_session_superseded', {
|
||||
replacementSessionId: healedId,
|
||||
});
|
||||
}
|
||||
return sessionId;
|
||||
},
|
||||
);
|
||||
vi.mocked(bridge.listSessions).mockReturnValue([
|
||||
{
|
||||
sessionId: healedId,
|
||||
workspaceCwd: '/workspace',
|
||||
hasActivePrompt: false,
|
||||
worktree: {
|
||||
slug: 'fallback',
|
||||
path: `/worktrees/${fallback.sessionId}`,
|
||||
branch: 'fallback',
|
||||
},
|
||||
worktreeState: 'persisted-v1',
|
||||
},
|
||||
{
|
||||
sessionId: closing.sessionId,
|
||||
workspaceCwd: '/workspace',
|
||||
hasActivePrompt: false,
|
||||
worktree: {
|
||||
slug: 'feature',
|
||||
path: `/worktrees/${closing.sessionId}`,
|
||||
branch: 'feature',
|
||||
},
|
||||
worktreeState: 'persisted-v1',
|
||||
},
|
||||
]);
|
||||
vi.mocked(bridge.discardSession).mockRejectedValueOnce(
|
||||
new Error('detach failed'),
|
||||
);
|
||||
|
||||
await expect(named.close(alice, 'feature')).rejects.toThrow(
|
||||
'Failed to close',
|
||||
);
|
||||
|
||||
const persisted = JSON.parse(readFileSync(filePath, 'utf8')) as {
|
||||
owners: Array<{
|
||||
activeTaskName: string | null;
|
||||
tasks: Array<{ name: string; sessionId: string; status: string }>;
|
||||
}>;
|
||||
};
|
||||
const tasks = persisted.owners[0]?.tasks ?? [];
|
||||
// The committed heal survives the rollback ...
|
||||
expect(tasks.find((task) => task.name === 'fallback')?.sessionId).toBe(
|
||||
healedId,
|
||||
);
|
||||
// ... and the task that failed closed is restored under its original id.
|
||||
expect(tasks.find((task) => task.name === 'feature')).toMatchObject({
|
||||
sessionId: closing.sessionId,
|
||||
status: 'open',
|
||||
});
|
||||
expect(persisted.owners[0]?.activeTaskName).toBe('feature');
|
||||
expect(named.presentation(closing.sessionId)).toEqual(
|
||||
expect.objectContaining({ taskName: 'feature', status: 'open' }),
|
||||
);
|
||||
expect(named.presentation(healedId)).toEqual(
|
||||
expect.objectContaining({ taskName: 'fallback', status: 'open' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces the indexed session ID when resetting a task', async () => {
|
||||
const named = manager();
|
||||
const review = await named.create(alice, 'review');
|
||||
@@ -802,6 +1248,7 @@ describe('NamedSessionManager', () => {
|
||||
const reset = await named.reset(alice);
|
||||
|
||||
expect(reset?.previousSessionId).toBe(review.sessionId);
|
||||
expect(reset?.worktreeKept).toBe(false);
|
||||
expect(named.presentation(review.sessionId)).toBeUndefined();
|
||||
expect(named.presentation(reset!.sessionId)).toEqual(
|
||||
expect.objectContaining({ taskName: 'review', status: 'open' }),
|
||||
|
||||
@@ -79,6 +79,24 @@ interface StoredRegistry {
|
||||
owners: StoredOwner[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A named-session failure that carries the task it happened to. The recovery
|
||||
* advice the channel derives from it depends on which task broke, and that is
|
||||
* not recoverable from the message text.
|
||||
*/
|
||||
export class NamedSessionTaskError extends Error {
|
||||
readonly taskName: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
taskName: string,
|
||||
options?: { cause?: unknown },
|
||||
) {
|
||||
super(message, options);
|
||||
this.taskName = taskName;
|
||||
}
|
||||
}
|
||||
|
||||
export class NamedSessionManager {
|
||||
private readonly channelName: string;
|
||||
private readonly cwd: string;
|
||||
@@ -90,6 +108,12 @@ export class NamedSessionManager {
|
||||
private registry: StoredRegistry;
|
||||
private taskBySessionId: Map<string, NamedSessionTaskReference>;
|
||||
private readonly ownerOperations = new Map<string, Promise<void>>();
|
||||
/**
|
||||
* Superseded session id → the replacement its task moved onto. A queued turn
|
||||
* stays bound to the id it reserved, so the reservation lookup has to follow
|
||||
* the task or every turn after the first one is stranded on the old id.
|
||||
*/
|
||||
private readonly supersededSessionIds = new Map<string, string>();
|
||||
|
||||
constructor(options: NamedSessionManagerOptions) {
|
||||
this.channelName = options.channelName;
|
||||
@@ -233,22 +257,32 @@ export class NamedSessionManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload a task a queued turn is already bound to. Resolves to the session
|
||||
* id the caller must dispatch on — the healed replacement when a superseded
|
||||
* redirect fired — or undefined when the task is no longer reservable.
|
||||
*/
|
||||
resumeReserved(
|
||||
input: NamedSessionOwnerInput,
|
||||
sessionId: string,
|
||||
): Promise<boolean> {
|
||||
): Promise<string | undefined> {
|
||||
return this.withOwnerLock(input, async () => {
|
||||
const owner = await this.ensureOwner(input, false);
|
||||
// The registry may already have healed this task onto its replacement;
|
||||
// the caller's binding still names the superseded id.
|
||||
const reservedSessionId =
|
||||
this.supersededSessionIds.get(sessionId) ?? sessionId;
|
||||
const task = owner?.tasks.find(
|
||||
(candidate) =>
|
||||
candidate.sessionId === sessionId && candidate.status === 'open',
|
||||
candidate.sessionId === reservedSessionId &&
|
||||
candidate.status === 'open',
|
||||
);
|
||||
if (!task) return false;
|
||||
await this.loadTask(
|
||||
if (!task) return undefined;
|
||||
const result = await this.loadTask(
|
||||
task,
|
||||
`Could not reload reserved task "${task.name}".`,
|
||||
);
|
||||
return true;
|
||||
return result.sessionId;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -332,12 +366,13 @@ export class NamedSessionManager {
|
||||
}
|
||||
const timestamp = this.nextTimestamp(owner);
|
||||
|
||||
const loaded = await this.loadTask(
|
||||
const loadResult = await this.loadTask(
|
||||
task,
|
||||
`Could not load task "${task.name}". The current task was not changed.`,
|
||||
);
|
||||
const updatedTask: StoredTask = {
|
||||
...task,
|
||||
sessionId: loadResult.sessionId,
|
||||
status: 'open',
|
||||
updatedAt: timestamp,
|
||||
lastSelectedAt: timestamp,
|
||||
@@ -346,14 +381,18 @@ export class NamedSessionManager {
|
||||
try {
|
||||
this.commitOwner(nextOwner);
|
||||
} catch (error) {
|
||||
if (loaded) {
|
||||
if (loadResult.loaded) {
|
||||
await this.router
|
||||
.detachManagedSession(task.sessionId)
|
||||
.detachManagedSession(loadResult.sessionId)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.router.activateManagedSession(task.sessionId, task.target, task.cwd);
|
||||
this.router.activateManagedSession(
|
||||
loadResult.sessionId,
|
||||
updatedTask.target,
|
||||
updatedTask.cwd,
|
||||
);
|
||||
return this.selection(nextOwner, updatedTask);
|
||||
});
|
||||
}
|
||||
@@ -388,11 +427,14 @@ export class NamedSessionManager {
|
||||
.sort((a, b) => b.lastSelectedAt - a.lastSelectedAt)[0]
|
||||
: undefined;
|
||||
let replacementLoaded = false;
|
||||
let replacementSessionId: string | undefined;
|
||||
if (replacement) {
|
||||
replacementLoaded = await this.loadTask(
|
||||
const replacementLoad = await this.loadTask(
|
||||
replacement,
|
||||
`Could not load fallback task "${replacement.name}". Task "${task.name}" was not closed.`,
|
||||
);
|
||||
replacementLoaded = replacementLoad.loaded;
|
||||
replacementSessionId = replacementLoad.sessionId;
|
||||
}
|
||||
|
||||
const closedTask: StoredTask = {
|
||||
@@ -408,6 +450,7 @@ export class NamedSessionManager {
|
||||
const selectedReplacement = replacement
|
||||
? {
|
||||
...replacement,
|
||||
sessionId: replacementSessionId ?? replacement.sessionId,
|
||||
updatedAt: timestamp,
|
||||
lastSelectedAt: timestamp,
|
||||
}
|
||||
@@ -424,7 +467,7 @@ export class NamedSessionManager {
|
||||
} catch (error) {
|
||||
if (replacement && replacementLoaded) {
|
||||
await this.router
|
||||
.detachManagedSession(replacement.sessionId)
|
||||
.detachManagedSession(replacementSessionId ?? replacement.sessionId)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
@@ -432,7 +475,7 @@ export class NamedSessionManager {
|
||||
|
||||
if (replacement) {
|
||||
this.router.activateManagedSession(
|
||||
replacement.sessionId,
|
||||
replacementSessionId ?? replacement.sessionId,
|
||||
replacement.target,
|
||||
replacement.cwd,
|
||||
);
|
||||
@@ -440,15 +483,27 @@ export class NamedSessionManager {
|
||||
try {
|
||||
await this.router.detachManagedSession(task.sessionId);
|
||||
} catch (error) {
|
||||
this.commitOwner(owner);
|
||||
// The fallback's load may have healed its own superseded redirect
|
||||
// mid-close; restoring the raw snapshot would undo that heal.
|
||||
const healedReplacement =
|
||||
replacement !== undefined &&
|
||||
replacementSessionId !== undefined &&
|
||||
replacementSessionId !== replacement.sessionId
|
||||
? { ...replacement, sessionId: replacementSessionId }
|
||||
: undefined;
|
||||
this.commitOwner(
|
||||
healedReplacement
|
||||
? this.replaceTask(owner, healedReplacement, owner.activeTaskName)
|
||||
: owner,
|
||||
);
|
||||
this.router.forgetManagedSession(task.sessionId);
|
||||
await this.loadTask(
|
||||
const restored = await this.loadTask(
|
||||
task,
|
||||
`Task "${task.name}" could not be restored after close failed.`,
|
||||
);
|
||||
if (wasActive) {
|
||||
this.router.activateManagedSession(
|
||||
task.sessionId,
|
||||
restored.sessionId,
|
||||
task.target,
|
||||
task.cwd,
|
||||
);
|
||||
@@ -467,23 +522,64 @@ export class NamedSessionManager {
|
||||
});
|
||||
}
|
||||
|
||||
reset(
|
||||
input: NamedSessionOwnerInput,
|
||||
): Promise<
|
||||
{ name: string; previousSessionId: string; sessionId: string } | undefined
|
||||
reset(input: NamedSessionOwnerInput): Promise<
|
||||
| {
|
||||
name: string;
|
||||
previousSessionId: string;
|
||||
sessionId: string;
|
||||
worktreeKept: boolean;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
return this.withOwnerLock(input, async () => {
|
||||
const owner = await this.ensureOwner(input, false);
|
||||
if (!owner?.activeTaskName) return undefined;
|
||||
const task = this.findTask(owner, owner.activeTaskName);
|
||||
if (!task || task.status !== 'open') return undefined;
|
||||
if (task.isolation === 'worktree') {
|
||||
throw new Error(
|
||||
`Task "${task.name}" uses a worktree and cannot be cleared or reset yet. Continue using the task or close it. Its files were not changed.`,
|
||||
);
|
||||
}
|
||||
const timestamp = this.nextTimestamp(owner);
|
||||
|
||||
if (task.isolation === 'worktree') {
|
||||
// Worktree reset is an ownership transfer, not a new conversation:
|
||||
// the daemon moves the checkout to a fresh session and supersedes
|
||||
// this one. Busy tasks fail before any daemon call, and the daemon
|
||||
// resumes an interrupted transfer itself, so one attempt is enough.
|
||||
if (this.isBusy(task.sessionId)) {
|
||||
throw new Error(
|
||||
`Task "${task.name}" is busy. Wait for the running prompt to finish (or cancel it), then try again.`,
|
||||
);
|
||||
}
|
||||
const sessionId = await this.resetWorktreeSession(task);
|
||||
const updatedTask: StoredTask = {
|
||||
...task,
|
||||
sessionId,
|
||||
updatedAt: timestamp,
|
||||
lastSelectedAt: timestamp,
|
||||
};
|
||||
const nextOwner = this.replaceTask(owner, updatedTask, task.name);
|
||||
try {
|
||||
this.commitOwner(nextOwner);
|
||||
} catch (error) {
|
||||
await this.router
|
||||
.detachManagedSession(sessionId)
|
||||
.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
this.router.activateManagedSession(
|
||||
sessionId,
|
||||
updatedTask.target,
|
||||
updatedTask.cwd,
|
||||
);
|
||||
this.router.forgetManagedSession(task.sessionId);
|
||||
this.repointSupersededSessionIds(task.sessionId, sessionId);
|
||||
this.supersededSessionIds.delete(task.sessionId);
|
||||
return {
|
||||
name: task.name,
|
||||
previousSessionId: task.sessionId,
|
||||
sessionId,
|
||||
worktreeKept: true,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionId = await this.createSession(
|
||||
task.target,
|
||||
task.isolation,
|
||||
@@ -510,10 +606,13 @@ export class NamedSessionManager {
|
||||
updatedTask.cwd,
|
||||
);
|
||||
this.router.forgetManagedSession(task.sessionId);
|
||||
this.repointSupersededSessionIds(task.sessionId, sessionId);
|
||||
this.supersededSessionIds.delete(task.sessionId);
|
||||
return {
|
||||
name: task.name,
|
||||
previousSessionId: task.sessionId,
|
||||
sessionId,
|
||||
worktreeKept: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -615,15 +714,26 @@ export class NamedSessionManager {
|
||||
throw new Error('The selected Channel task is unavailable.');
|
||||
}
|
||||
const release = reserve?.(task.sessionId);
|
||||
let activeRelease = release;
|
||||
try {
|
||||
await this.loadTask(
|
||||
const result = await this.loadTask(
|
||||
task,
|
||||
`Could not load task "${task.name}". No replacement session was created.`,
|
||||
);
|
||||
this.router.activateManagedSession(task.sessionId, task.target, task.cwd);
|
||||
return task.sessionId;
|
||||
if (result.sessionId !== task.sessionId && reserve) {
|
||||
// The task moved to its replacement session; the reservation must
|
||||
// follow the id the caller will dispatch on.
|
||||
release?.();
|
||||
activeRelease = reserve(result.sessionId);
|
||||
}
|
||||
this.router.activateManagedSession(
|
||||
result.sessionId,
|
||||
task.target,
|
||||
task.cwd,
|
||||
);
|
||||
return result.sessionId;
|
||||
} catch (error) {
|
||||
release?.();
|
||||
activeRelease?.();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -665,19 +775,93 @@ export class NamedSessionManager {
|
||||
private async loadTask(
|
||||
task: StoredTask,
|
||||
failureMessage: string,
|
||||
): Promise<boolean> {
|
||||
): Promise<{ loaded: boolean; sessionId: string }> {
|
||||
try {
|
||||
return (
|
||||
await this.router.loadManagedSession(
|
||||
task.sessionId,
|
||||
task.target,
|
||||
this.cwd,
|
||||
task.cwd,
|
||||
task.isolation,
|
||||
)
|
||||
).loaded;
|
||||
const result = await this.router.loadManagedSession(
|
||||
task.sessionId,
|
||||
task.target,
|
||||
this.cwd,
|
||||
task.cwd,
|
||||
task.isolation,
|
||||
);
|
||||
if (
|
||||
result.redirectedFrom !== undefined &&
|
||||
result.sessionId !== task.sessionId
|
||||
) {
|
||||
try {
|
||||
this.healSupersededTask(task, result.sessionId);
|
||||
} catch {
|
||||
// The load succeeded and the router already routes the replacement;
|
||||
// a registry still naming the superseded id is the state a failed
|
||||
// reset leaves behind, so the next load re-heals it. Callers that
|
||||
// commit a selection still fail closed on their own write.
|
||||
}
|
||||
}
|
||||
// Callers must use the returned id for everything after this point:
|
||||
// when a superseded redirect fired, the caller's `task` snapshot still
|
||||
// names the old session, and building registry updates from it would
|
||||
// overwrite the heal.
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new Error(failureMessage, { cause: error });
|
||||
throw new NamedSessionTaskError(failureMessage, task.name, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a task at its replacement session after a superseded redirect
|
||||
* healed the route. No-op when the registry entry already moved on (a
|
||||
* concurrent reset under the same owner lock cannot interleave, but a
|
||||
* stale in-memory snapshot could).
|
||||
*/
|
||||
private healSupersededTask(task: StoredTask, sessionId: string): void {
|
||||
const owner = this.getOwner(task.target);
|
||||
if (!owner) return;
|
||||
const current = this.findTask(owner, task.name);
|
||||
if (!current || current.sessionId !== task.sessionId) return;
|
||||
const timestamp = this.nextTimestamp(owner);
|
||||
this.commitOwner(
|
||||
this.replaceTask(
|
||||
owner,
|
||||
{ ...current, sessionId, updatedAt: timestamp },
|
||||
owner.activeTaskName,
|
||||
),
|
||||
);
|
||||
this.repointSupersededSessionIds(task.sessionId, sessionId);
|
||||
this.supersededSessionIds.set(task.sessionId, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow every id that still resolves to `oldSessionId` onto its
|
||||
* replacement, so a task superseded more than once stays reachable from the
|
||||
* oldest id a turn is bound to and the map does not grow once per reset.
|
||||
*/
|
||||
private repointSupersededSessionIds(
|
||||
oldSessionId: string,
|
||||
newSessionId: string,
|
||||
): void {
|
||||
for (const boundSessionId of [...this.supersededSessionIds.keys()]) {
|
||||
if (this.supersededSessionIds.get(boundSessionId) === oldSessionId) {
|
||||
this.supersededSessionIds.set(boundSessionId, newSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async resetWorktreeSession(task: StoredTask): Promise<string> {
|
||||
try {
|
||||
return await this.router.replaceManagedWorktreeSession(
|
||||
task.sessionId,
|
||||
task.target,
|
||||
this.cwd,
|
||||
task.cwd,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new NamedSessionTaskError(
|
||||
`Could not reset task "${task.name}".`,
|
||||
task.name,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,5 +6,12 @@ export default defineConfig({
|
||||
globals: true,
|
||||
// RPC-timeout exemption; see scripts/tests/unit-vitest-configs.test.ts.
|
||||
dangerouslyIgnoreUnhandledErrors: process.platform !== 'linux',
|
||||
// Shared-pool ceiling; see scripts/tests/unit-vitest-configs.test.ts.
|
||||
// Raised only on the ECS pool, where the same suite runs ~5x slower
|
||||
// depending on which host it lands on (#10490); off the pool this stays
|
||||
// `undefined` so vitest's 5s default keeps catching a genuine hang fast.
|
||||
testTimeout: process.env['RUNNER_NAME']?.startsWith('ecs-qwen-')
|
||||
? 60_000
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
Loaded 100 of 332 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user