mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 05:40:58 +00:00
Merge remote-tracking branch 'origin/main' into feat/session-id-passthrough
# Conflicts: # packages/acp-bridge/src/bridge.ts # packages/cli/src/acp-integration/acpAgent.ts
This commit is contained in:
commit
087d03fb77
197 changed files with 22549 additions and 2368 deletions
264
.github/workflows/qwen-autofix.yml
vendored
264
.github/workflows/qwen-autofix.yml
vendored
|
|
@ -119,6 +119,17 @@ env:
|
|||
# changes, failed checks, and base conflicts may drive code changes;
|
||||
# lower-severity feedback is recorded and left open.
|
||||
CRITICAL_ONLY_AFTER_ROUND: '5'
|
||||
# Per-author tail budget inside Critical-only mode. An account is an
|
||||
# ACCOUNTABILITY unit, not a throttle: a human login can host an automated
|
||||
# reviewer loop with the exact regeneration property the review bot has
|
||||
# (feedback re-generated after every push, at zero marginal cost). So the
|
||||
# brake keys on measured regeneration, not identity: every source gets a
|
||||
# bounded number of untagged feedback batches per counting window once
|
||||
# Critical-only engages — the review bot's budget is zero (all deferred),
|
||||
# a human's is this many CONSUMED batches. Past it, continuing requires
|
||||
# one conscious act (**[Critical]**, a Request changes review, or /retry),
|
||||
# which is precisely what separates intent from automation.
|
||||
CRITICAL_ONLY_HUMAN_BATCHES: '2'
|
||||
# An auth/access model error (401/402/403, "no access"/"does not exist")
|
||||
# never self-heals - only a maintainer can fix the key - and every retry
|
||||
# costs an agent run AND a PR comment. Cap those attempts far below
|
||||
|
|
@ -1689,6 +1700,7 @@ jobs:
|
|||
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
|
||||
FORCED_PR: '${{ needs.route.outputs.pr_number }}'
|
||||
DRY_RUN: '${{ needs.route.outputs.dry_run }}'
|
||||
EVENT_NAME: '${{ github.event_name }}'
|
||||
run: |-
|
||||
# Fleet visibility: every per-PR decision below also records a row so
|
||||
# the run summary shows the WHOLE managed fleet in one table.
|
||||
|
|
@ -2247,43 +2259,85 @@ jobs:
|
|||
if [[ "${ROUND}" -ge "${EFF_MAX_ROUNDS}" ]]; then
|
||||
echo "🚧 #${PR}: hit the round cap (${ROUND}/${EFF_MAX_ROUNDS}) — leaving for a human"
|
||||
fleet_row "${PR}" 'round-capped' "round ${ROUND}/${EFF_MAX_ROUNDS} - needs a human or @qwen-code /retry"
|
||||
# A MANAGED PR pausing at its cap deserves a visible reminder —
|
||||
# maintainers otherwise learn about it only from workflow logs.
|
||||
# Once per counting window: re-arming opens a fresh window and,
|
||||
# if the cap is hit again, a fresh reminder. A failed post
|
||||
# retries naturally on the next scan (marker still absent).
|
||||
if [[ "${HAS_TAKEOVER}" == "true" ]]; then
|
||||
# Dedup boundary = the current window key; with no engage ack
|
||||
# yet (key 'none') fall back to LIFETIME dedup — created_at is
|
||||
# never > 'none' lexically, which would flip this into posting
|
||||
# every scan.
|
||||
NOTICE_RT="${REARM_KEY}"
|
||||
[[ "${NOTICE_RT}" == "none" ]] && NOTICE_RT=''
|
||||
CAP_NOTICED="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg rt "${NOTICE_RT}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- takeover-cap-reached -->"))
|
||||
| select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json")"
|
||||
# A FORCED dispatch refused here answers OUT LOUD. Observed on
|
||||
# #7836: the fleet shepherd detected a merge conflict, posted
|
||||
# "dispatched the autofix loop to resolve it", and the dispatch
|
||||
# died right here with only the log line above — the PR page
|
||||
# showed a promise, the run showed green, and the conflict sat
|
||||
# unhandled for hours. The shepherd also dedups per head SHA,
|
||||
# and a capped PR gets no pushes, so its head never changes:
|
||||
# silence here freezes conflict handling until a human notices
|
||||
# by accident. Gate on workflow_dispatch — that is the explicit
|
||||
# dispatch lever (the shepherd's `gh workflow run` or a human).
|
||||
# FORCED_PR is ALSO set for every trusted pull_request_review
|
||||
# (route emits pr_number for those), which is not an explicit
|
||||
# dispatch: answering each one here spammed 7 refusals on
|
||||
# #7836, so review submissions stay covered by the
|
||||
# once-per-window pause notice below. No dedup on the dispatch
|
||||
# itself: the shepherd sends at most one per head, and a human
|
||||
# asking twice deserves two answers.
|
||||
if [[ -n "${FORCED_PR}" && "${FORCED_PR}" == "${PR}" && "${EVENT_NAME}" == 'workflow_dispatch' ]]; then
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "🧪 DRY-RUN: would post cap-paused notice on #${PR}"
|
||||
elif [[ "${CAP_NOTICED}" == "0" ]]; then
|
||||
# Consent may have moved since PR_META: a takeover label
|
||||
# removed (or skip added) moments ago must not receive a
|
||||
# stale 'paused' notice.
|
||||
LIVE_LABELS="$(gh pr view "${PR}" --repo "${REPO}" --json labels 2> /dev/null | jq -r '[.labels[]?.name] | join(" ")' || echo '')"
|
||||
if [[ " ${LIVE_LABELS} " != *" ${TAKEOVER_LABEL} "* || " ${LIVE_LABELS} " == *" ${SKIP_LABEL} "* ]]; then
|
||||
echo "🧭 cap notice skipped: consent changed since the snapshot (labels: ${LIVE_LABELS:-unreadable})"
|
||||
continue
|
||||
fi
|
||||
# Convention: verify the PAT identity before ANY write. A
|
||||
# rotated PAT would post under a foreign login the dedup
|
||||
# (which counts AUTOFIX_BOT comments only) can never see —
|
||||
# reposting the notice every scan. Memoized per scan run.
|
||||
echo "🧪 DRY-RUN: would post cap-refused notice on #${PR}"
|
||||
else
|
||||
if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then
|
||||
SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')"
|
||||
fi
|
||||
if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then
|
||||
echo "::warning::cap-paused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}"
|
||||
elif ! gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '⏸️ Takeover paused: this PR reached its round cap (%s/%s). Comment `%s` to re-arm a fresh window and continue management, or `%s stop` to release.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ 托管已暂停:本 PR 达到轮次上限(%s/%s)。评论 `%s` 可重新武装、开启新窗口继续托管;或评论 `%s stop` 释放。\n\n</details>\n\n<!-- takeover-cap-reached -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")"; then
|
||||
echo "::warning::cap-refused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}"
|
||||
elif ! gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '⏸️ Dispatch refused: this PR has exhausted its automatic round cap (%s/%s), so the loop will not touch it — whatever triggered this dispatch (a merge conflict, new feedback) stays unhandled. Comment `%s` to re-arm a fresh window, or `%s` for the raised takeover cap; the next scheduled scan then picks it up.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ 已拒绝本次调度:本 PR 的自动轮次上限已用完(%s/%s),循环不会介入——触发本次调度的事项(合并冲突、新反馈)仍未处理。评论 `%s` 可重置计数窗口,或 `%s` 获得更高的接管上限;随后下一次定时扫描会接手。\n\n</details>\n\n<!-- takeover-cap-refused -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"; then
|
||||
echo "::warning::cap-refused notice failed for #${PR}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# A MANAGED PR pausing at its cap deserves a visible reminder —
|
||||
# maintainers otherwise learn about it only from workflow logs.
|
||||
# ALL managed PRs, not just takeover: the takeover-only gate
|
||||
# left standard bot PRs capping in silence (#7836 hit 10/10
|
||||
# with zero PR-visible notice), which is the root of the
|
||||
# frozen-conflict chain above. Once per counting window:
|
||||
# re-arming opens a fresh window and, if the cap is hit again,
|
||||
# a fresh reminder. A failed post retries naturally on the
|
||||
# next scan (marker still absent).
|
||||
# Dedup boundary = the current window key; with no engage ack
|
||||
# or re-arm yet (key 'none') fall back to LIFETIME dedup —
|
||||
# created_at is never > 'none' lexically, which would flip
|
||||
# this into posting every scan.
|
||||
NOTICE_RT="${REARM_KEY}"
|
||||
[[ "${NOTICE_RT}" == "none" ]] && NOTICE_RT=''
|
||||
CAP_NOTICED="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg rt "${NOTICE_RT}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- takeover-cap-reached -->"))
|
||||
| select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json")"
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "🧪 DRY-RUN: would post cap-paused notice on #${PR}"
|
||||
elif [[ "${CAP_NOTICED}" == "0" ]]; then
|
||||
# Consent may have moved since PR_META: skip wins everywhere,
|
||||
# and a takeover notice additionally requires the label to
|
||||
# still be present — a label removed (or skip added) moments
|
||||
# ago must not receive a stale 'paused' notice.
|
||||
LIVE_LABELS="$(gh pr view "${PR}" --repo "${REPO}" --json labels 2> /dev/null | jq -r '[.labels[]?.name] | join(" ")' || echo '')"
|
||||
if [[ " ${LIVE_LABELS} " == *" ${SKIP_LABEL} "* ]] \
|
||||
|| [[ "${HAS_TAKEOVER}" == "true" && " ${LIVE_LABELS} " != *" ${TAKEOVER_LABEL} "* ]]; then
|
||||
echo "🧭 cap notice skipped: consent changed since the snapshot (labels: ${LIVE_LABELS:-unreadable})"
|
||||
continue
|
||||
fi
|
||||
# Convention: verify the PAT identity before ANY write. A
|
||||
# rotated PAT would post under a foreign login the dedup
|
||||
# (which counts AUTOFIX_BOT comments only) can never see —
|
||||
# reposting the notice every scan. Memoized per scan run.
|
||||
if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then
|
||||
SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')"
|
||||
fi
|
||||
if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then
|
||||
echo "::warning::cap-paused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}"
|
||||
else
|
||||
if [[ "${HAS_TAKEOVER}" == "true" ]]; then
|
||||
CAP_BODY="$(printf '⏸️ Takeover paused: this PR reached its round cap (%s/%s). Comment `%s` to re-arm a fresh window and continue management, or `%s stop` to release.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ 托管已暂停:本 PR 达到轮次上限(%s/%s)。评论 `%s` 可重新武装、开启新窗口继续托管;或评论 `%s stop` 释放。\n\n</details>\n\n<!-- takeover-cap-reached -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")"
|
||||
else
|
||||
CAP_BODY="$(printf '⏸️ AutoFix paused: this PR reached its automatic round cap (%s/%s) and the loop will not manage it further — new feedback and base conflicts stay unhandled. Comment `%s` to re-arm a fresh window under the same cap, or `%s` to take it over with the raised cap.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ AutoFix 已暂停:本 PR 达到自动轮次上限(%s/%s),循环不再管理——新反馈与 base 冲突将无人处理。评论 `%s` 可在同一上限下重置计数窗口,或评论 `%s` 以更高上限接管。\n\n</details>\n\n<!-- takeover-cap-reached -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"
|
||||
fi
|
||||
if ! gh pr comment "${PR}" --repo "${REPO}" --body "${CAP_BODY}"; then
|
||||
echo "::warning::cap-paused notice failed for #${PR}; will retry next scan"
|
||||
fi
|
||||
fi
|
||||
|
|
@ -2974,6 +3028,70 @@ jobs:
|
|||
if [[ "${ROUND}" -ge "${CRITICAL_ONLY_AFTER_ROUND}" ]]; then
|
||||
CRITICAL_ONLY='true'
|
||||
fi
|
||||
# Which trusted humans have exhausted their per-window regular
|
||||
# feedback budget (see CRITICAL_ONLY_HUMAN_BATCHES). A batch is
|
||||
# COUNTED only when a Critical-only round actually consumed it:
|
||||
# feedback items are bucketed into the (prev marker ts, marker ts]
|
||||
# span that evaluated them, spans are kept only for markers that
|
||||
# ran in Critical-only territory (acted rounds numbered past the
|
||||
# threshold, no-change rounds at it), and an author needs >= K
|
||||
# distinct consumed spans to land here. Fresh, not-yet-evaluated
|
||||
# feedback never counts against its own author, and everything is
|
||||
# window-scoped so a /retry resets the budget with the window.
|
||||
# Only feedback the deferred renderer below would actually defer is
|
||||
# counted: Critical-tagged items, Request changes / APPROVED reviews,
|
||||
# and inline comments rooted at a Critical comment or attached to a
|
||||
# Request changes review are never deferrable, so they must not burn
|
||||
# an author's budget — the item filter mirrors those predicates.
|
||||
OVER_BUDGET_AUTHORS='[]'
|
||||
if [[ "${CRITICAL_ONLY}" == "true" ]]; then
|
||||
OVER_BUDGET_AUTHORS="$(jq -n \
|
||||
--arg key "${LIVE_REARM_KEY}" --arg ab "${AUTOFIX_BOT}" --arg rb "${REVIEW_BOT}" \
|
||||
--argjson trust "${TRUSTED_ASSOC}" \
|
||||
--argjson r5 "${CRITICAL_ONLY_AFTER_ROUND}" \
|
||||
--argjson k "${CRITICAL_ONLY_HUMAN_BATCHES}" \
|
||||
--slurpfile rv "${WORKDIR}/rv.json" --slurpfile rc "${WORKDIR}/rc.json" --slurpfile ic "${WORKDIR}/ic.json" '
|
||||
([ ($ic | add)[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
|
||||
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))? -->") ] | .[]
|
||||
| {ts: .[0], acted: .[1], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ]
|
||||
| map(select(.win == $key) | select(.ts != "9999-12-31T23:59:59Z"))
|
||||
| sort_by(.at)) as $ms
|
||||
| ([ range(0; ($ms | length)) as $i
|
||||
| ($ms[$i]
|
||||
| select((.acted == "true" and .round > $r5) or (.acted == "false" and .round >= $r5))
|
||||
| {lo: (if $i == 0 then "" else ($ms[$i - 1].ts) end), hi: .ts}) ]) as $spans
|
||||
| ($rv | add) as $reviews
|
||||
| ($rc | add) as $comments
|
||||
| ([ $reviews[]
|
||||
| select((.state // "") == "COMMENTED")
|
||||
| select(((.body // "") | contains("**[Critical]**")) | not)
|
||||
| {at: (.submitted_at // ""), login: (.user.login // ""), assoc: (.author_association // "")} ]
|
||||
+ [ $comments[]
|
||||
| select((
|
||||
((.body // "") | contains("**[Critical]**"))
|
||||
or ((.in_reply_to_id // null) as $root
|
||||
| $root != null
|
||||
and any($comments[]; .id == $root and ((.body // "") | contains("**[Critical]**"))))
|
||||
or ((.pull_request_review_id // null) as $review
|
||||
| $review != null
|
||||
and any($reviews[]; .id == $review and ((.state // "") == "CHANGES_REQUESTED")))
|
||||
) | not)
|
||||
| {at: (.created_at // ""), login: (.user.login // ""), assoc: (.author_association // "")} ]
|
||||
+ [ ($ic | add)[]
|
||||
| select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not)
|
||||
| select((.body // "") | test("^\\s*@qwen-code /") | not)
|
||||
| select(((.body // "") | contains("**[Critical]**")) | not)
|
||||
| {at: (.created_at // ""), login: (.user.login // ""), assoc: (.author_association // "")} ])
|
||||
| map(select(.login != "" and .login != $ab and .login != $rb) | select(.assoc | IN($trust[])))
|
||||
| [ .[] | . as $c
|
||||
| ([ $spans[] | select($c.at > .lo and $c.at <= .hi) ] | .[0] // empty)
|
||||
| {login: $c.login, span: .hi} ]
|
||||
| group_by(.login)
|
||||
| map(select((map(.span) | unique | length) >= $k) | .[0].login)
|
||||
' || echo '[]')"
|
||||
[[ -z "${OVER_BUDGET_AUTHORS}" ]] && OVER_BUDGET_AUTHORS='[]'
|
||||
[[ "${OVER_BUDGET_AUTHORS}" != '[]' ]] && echo "🚦 regular-feedback budget exhausted this window for: $(jq -r 'join(", ")' <<< "${OVER_BUDGET_AUTHORS}")"
|
||||
fi
|
||||
echo "stale=${STALE}" >> "${GITHUB_OUTPUT}"
|
||||
echo "effective_round=${ROUND}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
|
|
@ -2983,27 +3101,27 @@ jobs:
|
|||
{
|
||||
echo '## Deferred non-Critical feedback'
|
||||
echo
|
||||
echo "Critical-only mode is active after ${CRITICAL_ONLY_AFTER_ROUND} change-producing rounds. Any items listed below stay open for human follow-up; do not modify code, resolve threads, or reply on their behalf."
|
||||
echo "Critical-only mode is active after ${CRITICAL_ONLY_AFTER_ROUND} change-producing rounds: the automated reviewer's non-Critical suggestions below are deferred and stay open for human follow-up — do not modify code, resolve threads, or reply on their behalf. Maintainer feedback defers only once its author has already had ${CRITICAL_ONLY_HUMAN_BATCHES} regular feedback batches addressed in this window's Critical-only tail — an account can host an automated reviewer loop, so the brake keys on measured regeneration, not identity; authors at their budget, if any, are named below. (A maintainer can lift the mode itself: \`@qwen-code /retry\` starts a fresh counting window.)"
|
||||
echo
|
||||
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
|
||||
--argjson trust "${TRUSTED_ASSOC}" --arg pr_url "${PR_URL}" '
|
||||
--arg pr_url "${PR_URL}" --argjson over "${OVER_BUDGET_AUTHORS}" '
|
||||
.[]
|
||||
| select((.submitted_at // "") > $wm)
|
||||
| select((.user.login // "") != $ab)
|
||||
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
|
||||
| select(((.user.login // "") == $rb) or ((.user.login // "") | IN($over[])))
|
||||
| select((.state // "") == "COMMENTED")
|
||||
| select(((.body // "") | contains("**[Critical]**")) | not)
|
||||
| "- Review by @\(.user.login): \(.html_url // $pr_url)"' \
|
||||
"${WORKDIR}/rv.json"
|
||||
jq -rs --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
|
||||
--argjson trust "${TRUSTED_ASSOC}" --arg pr_url "${PR_URL}" \
|
||||
--arg pr_url "${PR_URL}" --argjson over "${OVER_BUDGET_AUTHORS}" \
|
||||
--slurpfile reviews "${WORKDIR}/rv.json" '
|
||||
add as $comments
|
||||
| ($reviews | add) as $reviews
|
||||
| $comments[]
|
||||
| select((.created_at // "") > $wm)
|
||||
| select((.user.login // "") != $ab)
|
||||
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
|
||||
| select(((.user.login // "") == $rb) or ((.user.login // "") | IN($over[])))
|
||||
| select((
|
||||
((.body // "") | contains("**[Critical]**"))
|
||||
or ((.in_reply_to_id // null) as $root
|
||||
|
|
@ -3020,21 +3138,25 @@ jobs:
|
|||
| "- Inline rc:\(.id) \(.path // "?"):\(.line // "?"): \(.html_url // $pr_url)"' \
|
||||
"${WORKDIR}/rc.json"
|
||||
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
|
||||
--argjson trust "${TRUSTED_ASSOC}" --arg pr_url "${PR_URL}" '
|
||||
--arg pr_url "${PR_URL}" --argjson over "${OVER_BUDGET_AUTHORS}" '
|
||||
.[]
|
||||
| select((.created_at // "") > $wm)
|
||||
| select((.user.login // "") != $ab)
|
||||
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
|
||||
| select(((.user.login // "") == $rb) or ((.user.login // "") | IN($over[])))
|
||||
| select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not)
|
||||
| select((.body // "") | test("^\\s*@qwen-code /") | not)
|
||||
| select(((.body // "") | contains("**[Critical]**")) | not)
|
||||
| "- PR comment by @\(.user.login): \(.html_url // $pr_url)"' \
|
||||
"${WORKDIR}/ic.json"
|
||||
if [[ "${OVER_BUDGET_AUTHORS}" != '[]' ]]; then
|
||||
echo
|
||||
jq -r '.[] | "- @" + . + " is at this window'"'"'s regular-feedback budget — to continue: tag **[Critical]**, submit a Request changes review, or comment `@qwen-code /retry` for a fresh window. / @" + . + " 本窗口常规反馈预算已用完——继续请标 **[Critical]**、提交 Request changes、或评论 `@qwen-code /retry` 开新窗口。"' <<< "${OVER_BUDGET_AUTHORS}"
|
||||
fi
|
||||
echo
|
||||
echo '<details>'
|
||||
echo '<summary>中文说明</summary>'
|
||||
echo
|
||||
echo "完成 ${CRITICAL_ONLY_AFTER_ROUND} 个产生改动的轮次后,进入仅处理 Critical 的模式。以上内容保持开放,留待人工跟进;不要为其修改代码、解决线程或代为回复。"
|
||||
echo "完成 ${CRITICAL_ONLY_AFTER_ROUND} 个产生改动的轮次后进入仅处理 Critical 的模式:以上为自动评审的非 Critical 建议,予以延后、保持开放并留待人工跟进——不要为其修改代码、解决线程或代为回复。维护者的反馈仅在其本人于本窗口 Critical-only 阶段已被处理 ${CRITICAL_ONLY_HUMAN_BATCHES} 批常规反馈之后才会被延后——账号可能挂着自动评审循环,因此刹车依据实测的再生频度而非身份;达到预算的作者(如有)在下方点名。(如需解除该模式,评论 \`@qwen-code /retry\` 即可开启新的计数窗口。)"
|
||||
echo
|
||||
echo '</details>'
|
||||
} > "${WORKDIR}/deferred-feedback.md"
|
||||
|
|
@ -3051,13 +3173,15 @@ jobs:
|
|||
echo
|
||||
echo "## Reviews"
|
||||
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
|
||||
--argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" '
|
||||
--argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" \
|
||||
--argjson over "${OVER_BUDGET_AUTHORS}" '
|
||||
.[]
|
||||
| select((.submitted_at // "") > $wm)
|
||||
| select((.user.login // "") != $ab)
|
||||
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
|
||||
| select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED"))
|
||||
| select(($critical_only | not)
|
||||
or (((.user.login // "") != $rb) and (((.user.login // "") | IN($over[])) | not))
|
||||
or (.state // "") == "CHANGES_REQUESTED"
|
||||
or ((.body // "") | contains("**[Critical]**")))
|
||||
| "- [\(.state)] @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \
|
||||
|
|
@ -3066,6 +3190,7 @@ jobs:
|
|||
echo "## Inline comments"
|
||||
jq -rs --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
|
||||
--argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" \
|
||||
--argjson over "${OVER_BUDGET_AUTHORS}" \
|
||||
--slurpfile reviews "${WORKDIR}/rv.json" '
|
||||
add as $comments
|
||||
| ($reviews | add) as $reviews
|
||||
|
|
@ -3074,6 +3199,7 @@ jobs:
|
|||
| select((.user.login // "") != $ab)
|
||||
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
|
||||
| select(($critical_only | not)
|
||||
or (((.user.login // "") != $rb) and (((.user.login // "") | IN($over[])) | not))
|
||||
or ((.body // "") | contains("**[Critical]**"))
|
||||
or ((.in_reply_to_id // null) as $root
|
||||
| $root != null
|
||||
|
|
@ -3090,7 +3216,8 @@ jobs:
|
|||
echo
|
||||
echo "## Issue-level comments"
|
||||
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
|
||||
--argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" '
|
||||
--argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" \
|
||||
--argjson over "${OVER_BUDGET_AUTHORS}" '
|
||||
.[]
|
||||
| select((.created_at // "") > $wm)
|
||||
| select((.user.login // "") != $ab)
|
||||
|
|
@ -3098,6 +3225,7 @@ jobs:
|
|||
| select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not)
|
||||
| select((.body // "") | test("^\\s*@qwen-code /") | not)
|
||||
| select(($critical_only | not)
|
||||
or (((.user.login // "") != $rb) and (((.user.login // "") | IN($over[])) | not))
|
||||
or ((.body // "") | contains("**[Critical]**")))
|
||||
| "- @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \
|
||||
"${WORKDIR}/ic.json"
|
||||
|
|
@ -3541,10 +3669,54 @@ jobs:
|
|||
# Push back to the FORK branch via allow-edits (PAT has push
|
||||
# rights on the upstream, which GitHub extends to the fork's
|
||||
# PR branch when the author ticked the box).
|
||||
git push --no-verify "https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git" HEAD:"${BRANCH}"
|
||||
PUSH_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${HEAD_REPO}.git"
|
||||
else
|
||||
git push --no-verify origin "${BRANCH}"
|
||||
PUSH_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${REPO}.git"
|
||||
fi
|
||||
# Salvage a race-lost push instead of discarding the run. The
|
||||
# per-PR head-write concurrency group serialises THIS repo's
|
||||
# workflows, but it cannot stop the PR author (or anything on the
|
||||
# fork side) pushing during the agent's ~50-minute window —
|
||||
# observed twice in one day (#7983, #7985): a one-shot push died
|
||||
# `fetch first` and a full verified agent run was thrown away.
|
||||
# On rejection, fetch the moved head and MERGE it into the local
|
||||
# line (merge, not rebase: the agent's own conflict-resolution
|
||||
# rounds create merge commits, and a rebase would flatten them
|
||||
# and can silently re-introduce the conflicts it resolved). The
|
||||
# merge result descends from the remote head, so the retried push
|
||||
# is a fast-forward. A genuine content conflict aborts and falls
|
||||
# through to the existing failure path — same as today.
|
||||
PUSH_RACE_MERGED='false'
|
||||
for push_attempt in 1 2 3; do
|
||||
if git push --no-verify "${PUSH_URL}" HEAD:"${BRANCH}"; then
|
||||
break
|
||||
fi
|
||||
if [[ "${push_attempt}" == 3 ]]; then
|
||||
echo "::error::push rejected ${push_attempt} times; giving up"
|
||||
exit 1
|
||||
fi
|
||||
echo "⚠️ push rejected (attempt ${push_attempt}) — branch moved during the run; merging the new head and retrying"
|
||||
if ! git fetch "${PUSH_URL}" "refs/heads/${BRANCH}"; then
|
||||
echo "::error::could not fetch the moved head (attempt ${push_attempt}) — cannot salvage this push"
|
||||
exit 1
|
||||
fi
|
||||
# The disclosure flag keys on HEAD actually advancing: a push
|
||||
# can fail transiently (upload timeout, 503) with the branch
|
||||
# unmoved, and the merge then no-ops "Already up to date" —
|
||||
# flagging that would tell the reviewer to re-check mid-run
|
||||
# commits that never existed.
|
||||
PRE_MERGE_HEAD="$(git rev-parse HEAD)"
|
||||
if ! git -c user.name="${AUTOFIX_BOT}" \
|
||||
-c user.email="${AUTOFIX_BOT}@users.noreply.github.com" \
|
||||
merge --no-edit FETCH_HEAD; then
|
||||
git merge --abort || true
|
||||
echo "::error::the commits pushed during the run conflict with this fix — handing off instead of overwriting either side"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(git rev-parse HEAD)" != "${PRE_MERGE_HEAD}" ]]; then
|
||||
PUSH_RACE_MERGED='true'
|
||||
fi
|
||||
done
|
||||
# Resolve the review threads whose findings the agent actually
|
||||
# IMPLEMENTED, so a human re-reviewing sees only what is still open
|
||||
# instead of re-reading every thread to work out what was handled.
|
||||
|
|
@ -3660,6 +3832,10 @@ jobs:
|
|||
fi
|
||||
echo
|
||||
echo "Base-conflict check · 基分支冲突检查: $([[ "${CONFLICT}" == "true" ]] && echo 'conflicted with main — resolved in this push. · 与 main 有冲突——已在本次推送中解决。' || echo 'no conflict with main. · 与 main 无冲突。')"
|
||||
if [[ "${PUSH_RACE_MERGED}" == 'true' ]]; then
|
||||
echo
|
||||
echo "⚠️ The branch received new commits while this round ran; they were merged into this push, but this round's verification predates that merge — re-check anything that landed mid-run. · 本轮运行期间分支收到了新的提交;本次推送已将其合并,但本轮验证在合并之前完成——请复查运行期间落地的改动。"
|
||||
fi
|
||||
echo
|
||||
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
|
||||
|
|
|
|||
|
|
@ -13,9 +13,14 @@ Run the WorkspaceFileSystem, ACP adapter, and HTTP route tests:
|
|||
|
||||
```bash
|
||||
cd packages/cli
|
||||
npx vitest run src/serve/fs/workspace-file-system.test.ts
|
||||
npx vitest run src/serve/bridge-file-system-adapter.test.ts
|
||||
npx vitest run src/serve/routes/workspace-file-read.test.ts
|
||||
npx vitest run src/serve/fs/workspace-file-system.test.ts src/serve/bridge-file-system-adapter.test.ts src/serve/routes/workspace-file-read.test.ts
|
||||
```
|
||||
|
||||
Run the SDK query-serialization tests:
|
||||
|
||||
```bash
|
||||
cd packages/sdk-typescript
|
||||
npx vitest run test/unit/DaemonClient.test.ts test/unit/acpRouteTable.test.ts
|
||||
```
|
||||
|
||||
Then run repository verification:
|
||||
|
|
@ -37,29 +42,37 @@ npm run build
|
|||
returns the requested CSV lines.
|
||||
5. Confirm the agent does not need a shell command such as `head`, `sed`, or
|
||||
`awk` as a fallback.
|
||||
6. Request a later finite line window and confirm it also succeeds without
|
||||
returning more than 256 KiB.
|
||||
6. Follow the returned `nextCursor` until `hasMore` is false. Confirm the
|
||||
rejoined pages equal the original file and no page returns more than
|
||||
256 KiB.
|
||||
7. Append rows after the first page and confirm the outstanding cursor remains
|
||||
valid.
|
||||
|
||||
## Regression checks
|
||||
|
||||
- No-limit, line-only, maxBytes-only, and line-plus-maxBytes requests against
|
||||
the same large file remain `file_too_large`.
|
||||
- A no-window read against the same large file remains `file_too_large`;
|
||||
line-only, maxBytes-only, and line-plus-maxBytes requests are admitted as
|
||||
explicit bounded windows.
|
||||
- A finite line window over a large binary file remains `binary_file`.
|
||||
- A large non-UTF-8 text window remains `file_too_large` with a UTF-8
|
||||
conversion hint.
|
||||
- A large non-UTF-8 text window is `binary_file` with a UTF-8 conversion or
|
||||
`readBytes` hint.
|
||||
- A supported non-UTF-8 file within the full-snapshot cap still obeys
|
||||
`maxBytes` after its content is decoded to UTF-8.
|
||||
- A partial large-file response reports the complete `sizeBytes`, sets
|
||||
`truncated: true`, omits the full-file hash, and exposes
|
||||
`originalLineCount: null` until EOF is known.
|
||||
- Replacing the pathname, appending, truncating, or overwriting the opened file
|
||||
during the read is rejected instead of returning a mixed or stale result.
|
||||
- A deep offset beyond 10 MiB still succeeds when the request has a finite
|
||||
line limit.
|
||||
- Replacing the pathname, truncating, or overwriting the opened file during the
|
||||
read is rejected; append-only growth remains readable.
|
||||
- A deep line offset beyond the 8 MiB scan budget returns `file_too_large` and
|
||||
points the client at cursor paging or `readBytes`.
|
||||
- A malformed cursor or a cursor combined with `line` returns `parse_error`;
|
||||
a cursor for a replaced or truncated file returns `hash_mismatch`.
|
||||
|
||||
## Baseline status
|
||||
|
||||
Before the fix, Core could read the requested range from the 406,892-byte CSV,
|
||||
but Serve rejected the file at its 256 KiB full-snapshot gate before slicing.
|
||||
The focused automated tests cover the corrected Core, WorkspaceFileSystem, ACP,
|
||||
and HTTP paths; the manual ACP/model scenario remains the release smoke test.
|
||||
The first bounded-window implementation then required repeated line scans and
|
||||
could not reach pages beyond the scan budget. The focused automated tests cover
|
||||
the corrected Core, WorkspaceFileSystem, ACP, HTTP, and SDK paths; the manual
|
||||
ACP/model scenario remains the release smoke test.
|
||||
|
|
|
|||
|
|
@ -206,9 +206,14 @@ implement — satisfying a nit is never a reason to bloat the code.
|
|||
`Deferred non-Critical feedback` section, the PR has already completed five
|
||||
suggestion-capable, change-producing rounds. That section is an audit record,
|
||||
not work: do not modify code, resolve threads, or write comment replies for
|
||||
those items. Act only on Critical feedback and formally requested changes
|
||||
rendered in the actionable sections, failed checks, and the requested
|
||||
base-conflict resolution.
|
||||
those items. Everything rendered in the actionable sections IS in scope —
|
||||
the deterministic filter defers the automated reviewer's non-Critical
|
||||
suggestions and, past a small per-window budget of already-addressed
|
||||
batches, a human author's untagged feedback too (an account can host an
|
||||
automated reviewer loop, so the brake keys on measured regeneration, not
|
||||
identity). A maintainer writing "fix X before merge" after round five
|
||||
means exactly that when it reaches you — plus failed checks and the
|
||||
requested base-conflict resolution.
|
||||
- Needs a maintainer's decision: a finding that turns on a judgment that is
|
||||
NOT yours to make — a product or scope tradeoff (is this acceptable for v1?
|
||||
should the PR be split?), two reviewers asking for opposite things, or whether
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
# UserPromptSubmit hook context provenance
|
||||
|
||||
Issue: https://github.com/QwenLM/qwen-code/issues/7940
|
||||
|
||||
## Problem
|
||||
|
||||
`UserPromptSubmit` hooks can return `additionalContext`, which the client
|
||||
appends to the outgoing request as a bare text part. Because
|
||||
`recordUserMessage` persists the augmented request, the injected text lands in
|
||||
the user record's `message.parts` indistinguishable from user-authored text.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **Resume**: the UI projection concatenates all text parts, so resumed
|
||||
sessions display hook-injected context as if the user typed it.
|
||||
- **Offline analysis / downstream consumers**: the JSONL transcript cannot
|
||||
separate user text from injection; consumers resort to fragile custom
|
||||
marker-stripping heuristics.
|
||||
- **Telemetry & auto-memory recall**: both consumed `partToString(request)`
|
||||
after injection, polluting the prompt attribute and the recall query.
|
||||
|
||||
The live TUI is unaffected (it builds its history item from the pre-hook
|
||||
input), which is exactly the asymmetry that made the polluted transcript easy
|
||||
to miss.
|
||||
|
||||
## Design
|
||||
|
||||
Isomorphic to two existing patterns: `SessionStart` context is injected as a
|
||||
tagged block into the system instruction, and mid-turn/notification records
|
||||
separate the model-bound `message` from a `systemPayload.displayText`
|
||||
projection.
|
||||
|
||||
### Write path
|
||||
|
||||
1. **Tagged injection** (`client.ts`): the sanitized `additionalContext` is
|
||||
appended as its own part wrapped in
|
||||
`<qwen:user-prompt-submit-context>...</qwen:user-prompt-submit-context>`.
|
||||
`getAdditionalContext()` escapes `<`/`>` in hook output, so the wrapper
|
||||
cannot be closed or forged from inside. User-authored text is never
|
||||
rewritten or escaped. `promptText` must be declared before the injection
|
||||
assignment that captures it into `preInjectionPromptText` (avoids a TDZ
|
||||
if the surrounding Goal try/catch is later reshuffled).
|
||||
2. **Display provenance** (`chatRecordingService.ts`): `recordUserMessage`
|
||||
accepts an optional `UserPromptRecordPayload { displayText? }` stored as
|
||||
`systemPayload`. `message` keeps the exact model-bound Content — resume
|
||||
must replay what the model actually saw — while `displayText` preserves
|
||||
the pre-injection user projection. Hook-injected text remains in the
|
||||
tagged `message.parts` entry (machine-parseable). The payload is only
|
||||
written when a hook actually injected context.
|
||||
3. **Telemetry & recall** (`client.ts`): `addUserPromptAttributes` and
|
||||
`MemoryManager.recall` use the pre-injection prompt text when injection
|
||||
occurred.
|
||||
|
||||
### Read path (resume projection)
|
||||
|
||||
`resumeHistoryUtils` projects plain user records through a three-shape
|
||||
fallback:
|
||||
|
||||
- (a) new records: prefer `systemPayload.displayText`;
|
||||
- (b) tag-only records (no payload): drop a trailing part that is, in its
|
||||
entirety, a tagged block — whole-part strict match only, so user prose that
|
||||
merely contains the tag is never stripped. A sole part matching the tag
|
||||
shape is also kept (injection always appends after the user's own part(s),
|
||||
so a single-part record can only be user-authored);
|
||||
- (c) legacy bare-injected records: unchanged concatenation.
|
||||
|
||||
The `@`-command resume branch still prefers `AtCommandRecordPayload.userText`
|
||||
when present; only the absent-`userText` fallback goes through
|
||||
`extractUserRecordDisplayText`, so a trailing tagged part does not override
|
||||
the `@`-command display text.
|
||||
|
||||
## Scope notes
|
||||
|
||||
- Focused on the interactive `UserPromptSubmit` path. The ACP session path
|
||||
already records the pre-injection prompt text, so it only needed the same
|
||||
tag wrapping on its model-bound injection (included here). Subagent context
|
||||
injection (`SubagentStart` via `contextState`) needs its own investigation
|
||||
and is a follow-up.
|
||||
- Other transcript consumers (desktop, web UI) can adopt `displayText` in
|
||||
follow-ups; until then they see the tagged shape, which is at least
|
||||
mechanically identifiable.
|
||||
|
||||
ACP/export/daemon consumers that go through `transcript-replay`'s
|
||||
`projectUserRecord` also prefer `displayText` and strip a trailing tagged
|
||||
part for subtype-less user records (same three-shape fallback as the TUI
|
||||
resume path).
|
||||
189
docs/design/2026-07-29-handle-bound-text-range-reads.md
Normal file
189
docs/design/2026-07-29-handle-bound-text-range-reads.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# Handle-Bound Text Range Reads
|
||||
|
||||
## Context
|
||||
|
||||
PR #7947 let the Serve workspace filesystem return bounded line windows from
|
||||
text files above `MAX_READ_BYTES` (256 KiB). To keep those reads pinned to one
|
||||
inode across validation, binary probing, and streaming, it threaded a
|
||||
caller-owned `FileHandle` down into `readTextRange` as an optional field, and
|
||||
added a second optional field, `forceStreaming`, to suppress the buffering fast
|
||||
path that would otherwise defeat the memory bound.
|
||||
|
||||
Two optional fields on one entry point produced four combinations, of which one
|
||||
is meaningful, one is unreachable, and one is unsafe:
|
||||
|
||||
| `fileHandle` | `forceStreaming` | Result |
|
||||
| ------------ | ---------------- | ---------------------------------------------------------------------- |
|
||||
| unset | unset | ordinary path read |
|
||||
| unset | set | streams a small file — used by one test |
|
||||
| set | set | the Serve boundary's read |
|
||||
| set | unset | buffers the whole file through the handle — **no caller can reach it** |
|
||||
|
||||
The unreachable combination carried a dedicated helper, `readFileHandleBuffer`,
|
||||
with no test coverage. Separately, `readFileWithLineAndLimit` accepted the same
|
||||
`fileHandle` but could only honor it on its range branch: an unbounded read fell
|
||||
through to a by-path `readFileWithEncodingInfo`, silently returning bytes from
|
||||
whatever the path resolved to at that moment rather than from the pinned inode.
|
||||
PR #7947's follow-up commit guarded that with a runtime `RangeError`, which
|
||||
documented the trap without removing it.
|
||||
|
||||
Encoding detection had forked for the same reason. `detectFileEncoding` takes a
|
||||
path and opens its own descriptor, so the handle path could not use it; a
|
||||
private `detectFileHandleEncoding` was added alongside, deriving the encoding
|
||||
name from `decodeBufferWithEncodingInfoAsync(...).encoding` instead of from
|
||||
chardet directly. The two disagree when chardet names an encoding `iconv-lite`
|
||||
cannot load: the path variant returns that name, the handle variant returns
|
||||
`'utf-8'` and defers to the streaming decoder's `fatal: true` failure. Both
|
||||
refuse the file, with different messages.
|
||||
|
||||
## Goals
|
||||
|
||||
- One encoding detector, usable from a path or a borrowed descriptor.
|
||||
- No mode flags on the range reader; make the unreachable combination
|
||||
unrepresentable rather than merely unused.
|
||||
- Make the by-path fallthrough structurally impossible instead of guarded.
|
||||
- No observable change at the Serve boundary or in the `read_file` tool.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Collapsing `decodeBufferWithEncodingInfo` (sync) into its async twin. The sync
|
||||
variant is a deliberate public-API compatibility shim
|
||||
([`lazy-first-use-dependencies.md`](./lazy-first-use-dependencies.md)) pinned
|
||||
by a parity test.
|
||||
- Any change to what the Serve boundary returns. This is preparation for
|
||||
byte-cursor paging, not that feature.
|
||||
|
||||
## Design
|
||||
|
||||
### One detector
|
||||
|
||||
`detectFileEncoding(source: string | FileHandle)`. A supplied handle is
|
||||
_borrowed_: reads use explicit positions so the caller's file position is
|
||||
untouched, and the `finally` block closes only a descriptor this function
|
||||
itself opened. `detectFileHandleEncoding` is deleted, and the open-coded
|
||||
BOM-to-name switch is replaced with the existing `bomEncodingToName`.
|
||||
|
||||
This makes the handle path slightly stricter, which is the intended direction:
|
||||
an encoding `iconv-lite` cannot load now raises
|
||||
`LargeNonUtf8TextError(detected)` naming that encoding, rather than reaching the
|
||||
decoder and raising the generic `'invalid-utf8'` variant. The refusal is
|
||||
unchanged; the message improves. The Serve boundary maps both to `binary_file`,
|
||||
so nothing downstream moves.
|
||||
|
||||
A second, smaller delta comes with the merge: `detectFileEncoding` catches all
|
||||
errors and falls back to `'utf-8'`, whereas `detectFileHandleEncoding` had no
|
||||
handler and let an I/O failure propagate. The failure is not lost — a handle bad
|
||||
enough to fail the 8 KiB probe fails the streaming read immediately after, and a
|
||||
file that is not really UTF-8 is still refused by the `fatal: true` decoder — so
|
||||
the error surfaces from a different call rather than disappearing. Accepted for
|
||||
the single fallback policy; noted because it is a real change in which call
|
||||
reports the problem.
|
||||
|
||||
### Two entry points
|
||||
|
||||
```ts
|
||||
readTextRange(request: ReadTextRangeRequest) // path
|
||||
readTextRangeFromHandle(fh, request: ReadTextRangeFromHandleRequest)
|
||||
```
|
||||
|
||||
The handle variant always streams — there is no flag, because a caller reaches
|
||||
for a handle precisely when it needs the read bounded, and the buffering fast
|
||||
path would read the whole file. Its request type has no `path` (nothing for one
|
||||
to disambiguate), retains the numeric `fileSize` captured from the opening
|
||||
`fstat`, and makes both byte bounds required rather than optional.
|
||||
`maxOutputBytes` caps what the read returns, `maxScanBytes` caps what it costs,
|
||||
and `fileSize` prevents an append from widening the descriptor snapshot while
|
||||
the read is in flight. A handle-bound read exists because a security boundary
|
||||
needs all three bounds.
|
||||
|
||||
`maxScanBytes` stays optional on the path variant, where it defaults to
|
||||
`Infinity` so the `read_file` tool is unchanged.
|
||||
|
||||
Both delegate to the same streaming implementation, which now takes
|
||||
`source: string | FileHandle` and selects `createReadStream` or
|
||||
`chunksFromHandle` accordingly. `readFileHandleBuffer` and the branch that
|
||||
called it are deleted.
|
||||
|
||||
### The fallthrough disappears
|
||||
|
||||
`readFileWithLineAndLimit` loses `fileHandle`, `forceStreaming`, and
|
||||
`maxScanBytes` — its single production caller passes none of them.
|
||||
`StandardFileSystemService.readTextFileFromHandle` now calls
|
||||
`readTextRangeFromHandle` directly, and the two read paths share a
|
||||
`toReadTextFileResponse` helper so their metadata shaping cannot drift. With no
|
||||
`fileHandle` parameter left to ignore, the `RangeError` guard is removed: the
|
||||
trap it described can no longer be expressed.
|
||||
|
||||
`readTextFileFromHandle` stays off the `FileSystemService` interface, so
|
||||
`AcpFileSystemService` and the typed fallback mock in `filesystem.test.ts` are
|
||||
untouched.
|
||||
|
||||
## Blast radius
|
||||
|
||||
- `readTextRange` is not exported from `packages/core/src/index.ts`; the three
|
||||
boundary-facing error classes are. The reshaped reader surface is
|
||||
core-internal.
|
||||
- `readTextRange` and `readFileWithLineAndLimit` have exactly one production
|
||||
caller each (`fileUtils.ts`, `fileSystemService.ts`).
|
||||
- `detectFileEncoding` is public via `export * from './utils/fileUtils.js'`.
|
||||
Widening a parameter is source-compatible.
|
||||
- The only cross-package importer of the touched modules is
|
||||
`packages/cli/src/serve/fs/workspace-file-system.ts`. Its only change is
|
||||
dropping two arguments the handle path no longer accepts — see below; the
|
||||
`decodeBufferWithEncodingInfoAsync` import it also carries is untouched.
|
||||
|
||||
### `CoreReadTextFileHandleRequest` becomes standalone
|
||||
|
||||
It was `Omit<CoreReadTextFileRequest, 'limit' | 'stats' | 'maxOutputBytes'> &
|
||||
{...}`, which left two fields the handle path never reads:
|
||||
|
||||
- **`stats`** was documented as required — "must pass the Stats captured from
|
||||
that handle" — and nothing downstream read the object. The final API retains
|
||||
only its numeric `fileSize`: the handle path does not need metadata to choose
|
||||
a strategy, but it does need the opening size to keep reads bounded when the
|
||||
file is appended to concurrently.
|
||||
- **`path`** became dead once `readTextRangeFromHandle` replaced the
|
||||
path-plus-handle call: the read is bound to the descriptor, and errors are
|
||||
labelled with the path by the Serve boundary that owns it.
|
||||
|
||||
Neither was caught by the compiler. The ACP `ReadTextFileRequest` this type
|
||||
derived from permits extra properties, so passing a field the type had removed
|
||||
raised nothing. That is the argument for declaring the type standalone rather
|
||||
than deriving it: the `Omit` chain was stripping four of six inherited fields
|
||||
and quietly re-admitting the rest.
|
||||
|
||||
At the refactor commit, 282 production logic lines changed in `packages/core`;
|
||||
the later cursor follow-up adds behavior and tests on top of that baseline.
|
||||
|
||||
## Testing
|
||||
|
||||
At the refactor commit, the existing suites were the specification: the whole
|
||||
point was that the Serve boundary could not tell. The later cursor follow-up
|
||||
adds boundary behavior and its own tests.
|
||||
|
||||
Three tests in `read-text-range.test.ts` moved to `readTextRangeFromHandle`. Two
|
||||
used `fileHandle` directly. The third used a _path_ with `forceStreaming: true`
|
||||
to force streaming on a file too small to leave the fast path, so that it could
|
||||
exercise the budget-at-EOF boundary; with the flag gone, the handle variant is
|
||||
the only thing that always streams.
|
||||
|
||||
One of the moved tests changed meaning. It previously passed a handle for one
|
||||
file and a path naming a different file, asserting the handle won — a test for
|
||||
the confusion the old signature permitted. The handle variant has no `path`, so
|
||||
that confusion is now unrepresentable and the test would assert nothing. It was
|
||||
rewritten to cover the property that actually motivated the API: open a handle,
|
||||
rename another file over the path, and confirm the read still follows the inode.
|
||||
|
||||
Two tests in `fileSystemService.test.ts` were deleted rather than repaired. They
|
||||
mocked `readFileWithLineAndLimit` and asserted the argument object it received;
|
||||
since `readTextFileFromHandle` no longer calls it, they could only have been
|
||||
kept by re-pointing them at a new mock, which would again assert only that one
|
||||
function passes arguments to another. The behaviour they nominally covered is
|
||||
tested against real files in `read-text-range.test.ts` and at the real boundary
|
||||
in `workspace-file-system.test.ts`. The argument-validation tests beside them
|
||||
are kept — they need no mock.
|
||||
|
||||
## Follow-up
|
||||
|
||||
`chunksFromHandle` gained a `from` parameter as the single seam byte-cursor text
|
||||
paging needed. The follow-up now uses it to resume from a non-zero byte offset.
|
||||
116
docs/design/daemon-session-maintenance-writer-lease.md
Normal file
116
docs/design/daemon-session-maintenance-writer-lease.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Daemon Session Maintenance Writer Lease
|
||||
|
||||
## Problem
|
||||
|
||||
The daemon can delete, archive, or unarchive a persisted transcript after its
|
||||
in-process ACP owner has closed. A different daemon process can still own the
|
||||
same transcript, so the in-process archive coordinator alone does not prevent
|
||||
the daemon from racing an external writer.
|
||||
|
||||
The transcript path and writer-lock path must also be resolved from the same
|
||||
workspace runtime. Falling back to the primary daemon runtime can mutate one
|
||||
workspace while checking a lock in another.
|
||||
|
||||
## Scope
|
||||
|
||||
This change covers daemon-owned maintenance:
|
||||
|
||||
- REST and ACP delete, archive, and unarchive requests
|
||||
- disconnect and orphan cleanup
|
||||
- scheduled-task rollback and keepalive cleanup
|
||||
- daemon shutdown while maintenance is already running
|
||||
|
||||
It does not add lease expiry, heartbeat, hostname-based recovery, automatic
|
||||
steal, force unlock, or a lock-schema migration. Writers that do not participate
|
||||
in the lease protocol still require platform-level single-writer fencing.
|
||||
|
||||
## Runtime storage binding
|
||||
|
||||
Each `WorkspaceRuntime` resolves one absolute session runtime base directory at
|
||||
creation. Resolution keeps the existing priority:
|
||||
|
||||
1. `QWEN_RUNTIME_DIR`
|
||||
2. `advanced.runtimeOutputDir`, resolved relative to the workspace
|
||||
3. the normal Qwen runtime directory
|
||||
|
||||
The resolved directory is stored on the runtime and injected as
|
||||
`QWEN_RUNTIME_DIR` into every managed ACP child. Environment reload may update
|
||||
other values but preserves this pinned value because changing
|
||||
`runtimeOutputDir` requires a runtime restart.
|
||||
|
||||
Daemon parent operations that list, read, export, organize, or maintain
|
||||
sessions run inside the selected runtime's storage context. Runtime resolution
|
||||
failures do not fall back to the primary runtime.
|
||||
|
||||
## Lease API
|
||||
|
||||
`SessionService.acquireSessionWriterLease()` derives both the writer-lock root
|
||||
and the active transcript path from the service's fixed `Storage` instance.
|
||||
Callers provide only the session ID, process kind, version, and reclaim policy.
|
||||
Invalid session IDs are rejected before the lock directory is touched.
|
||||
|
||||
Daemon maintenance always uses `processKind: 'daemon'` and
|
||||
`reclaimPolicy: 'never'`. The existing lock schema, key, owner record, and
|
||||
acquire/release protocol remain unchanged.
|
||||
|
||||
## Maintenance protocol
|
||||
|
||||
Every session is processed independently:
|
||||
|
||||
1. Enter the daemon's per-session exclusive archive coordinator.
|
||||
2. Close the local owner. Archive requires agent close; delete uses the normal
|
||||
fast close. A missing local owner is allowed.
|
||||
3. Classify persisted state and preserve existing not-found and idempotent
|
||||
results without creating a lock.
|
||||
4. Acquire the daemon writer lease.
|
||||
5. Reclassify while holding the lease.
|
||||
6. Verify ownership and the transcript fingerprint, then perform one mutation.
|
||||
7. Release the lease with owner-token verification.
|
||||
|
||||
Batch requests may process independent sessions concurrently, but a worker
|
||||
holds at most one cross-process lease and never waits while holding multiple
|
||||
leases.
|
||||
|
||||
A failed mutation remains the reported error when release succeeds. A release
|
||||
or ownership failure is the externally safe error even if mutation also failed.
|
||||
Logs record the workspace, session, action, error kind, and whether the
|
||||
transcript mutation reached disk; they never include owner tokens or lock
|
||||
paths. Scheduled-task reconciliation follows the actual transcript mutation,
|
||||
not whether lease release subsequently succeeded.
|
||||
|
||||
Orphan cleanup first closes the local owner and respects
|
||||
`requireZeroAttaches`. A newly attached owner therefore prevents deletion.
|
||||
Late-spawn cleanup awaits close before acquiring the lease and deleting the
|
||||
transcript.
|
||||
|
||||
## Shutdown
|
||||
|
||||
`SessionArchiveCoordinator.sealMaintenanceAndWait()` synchronously rejects new
|
||||
exclusive maintenance and waits for exclusive operations already admitted.
|
||||
Shared transcript reads are not included, so a long export does not consume the
|
||||
termination budget. REST returns `503 daemon_draining`; ACP returns a JSON-RPC
|
||||
server error with `data.errorKind = daemon_draining`.
|
||||
|
||||
Daemon shutdown seals maintenance before child/process teardown and completes
|
||||
only after admitted maintenance leases have been released.
|
||||
|
||||
## Compatibility and rollout
|
||||
|
||||
Batch response shapes and existing archive/delete/unarchive idempotency
|
||||
remain unchanged. Pre-check local `session_archiving` conflicts (raised by
|
||||
`assertNotTransitioning` before admission) still surface as a request-level
|
||||
`409`. Conflicts raised inside the admission gate are reported per session in
|
||||
the `200` response body (`errors[]`) for archive, unarchive, and delete
|
||||
alike. Mixed-version writers are unsafe, so deployment and rollback must
|
||||
drain the old daemon and managed ACP processes before starting the new
|
||||
version.
|
||||
|
||||
## Verification
|
||||
|
||||
Tests use real temporary runtime roots for writer contention and root
|
||||
isolation, cover state changes between the initial and locked classifications,
|
||||
and verify close, mutation, release, scheduled-task reconciliation, and
|
||||
shutdown ordering. Unit tests also cover invalid IDs, duplicate IDs,
|
||||
active/archive conflicts, lease release failures, orphan reattachment, and log
|
||||
redaction. Relevant package tests, build, and typecheck are required before
|
||||
merge.
|
||||
|
|
@ -2,11 +2,10 @@
|
|||
|
||||
## Problem
|
||||
|
||||
Fork background agents persist the parent's rendered system instruction and
|
||||
inline tool declarations. Resume sends those launch-time declarations to the
|
||||
model, while execution still uses the current `ToolRegistry`. A removed or
|
||||
changed tool can therefore remain model-visible even though it cannot be
|
||||
executed.
|
||||
Legacy fork background transcripts persisted the parent's rendered system
|
||||
instruction and inline tool declarations. Replaying those launch-time
|
||||
declarations while execution uses the current `ToolRegistry` can leave a
|
||||
removed or changed tool model-visible even though it cannot be executed.
|
||||
|
||||
## Design
|
||||
|
||||
|
|
@ -26,10 +25,16 @@ transcripts for compatibility, but resume no longer treats them as executable
|
|||
authority. New transcripts persist the inherited history and task prompt, not
|
||||
capability snapshots; current runtime state is authoritative.
|
||||
|
||||
Launch-time execution restrictions are different from capability snapshots.
|
||||
When a fork uses `fork_tools`, its `executionAllowedTools` policy is stored in
|
||||
the `AgentMeta` sidecar and reapplied after the live tool surface is rebuilt.
|
||||
An empty persisted list remains deny-all; an absent field remains unrestricted.
|
||||
|
||||
## Consequences
|
||||
|
||||
Removed tools are no longer advertised after resume, and changed tools use
|
||||
their current schemas. A resumed fork can gain a tool that is newly available
|
||||
to its parent, so this favors live consistency over byte-identical replay.
|
||||
Rebinding can also invalidate the old prompt-cache prefix, which is preferable
|
||||
to sending stale capabilities.
|
||||
to its parent only when its persisted execution policy also permits that tool.
|
||||
This favors live consistency over byte-identical replay without weakening an
|
||||
explicit launch restriction. Rebinding can also invalidate the old
|
||||
prompt-cache prefix, which is preferable to sending stale capabilities.
|
||||
|
|
|
|||
97
docs/design/fork-tool-execution-allowlist.md
Normal file
97
docs/design/fork-tool-execution-allowlist.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Fork Tool Execution Allowlist
|
||||
|
||||
## Summary
|
||||
|
||||
Add an optional `fork_tools` parameter to the Agent tool's existing
|
||||
`subagent_type: "fork"` runtime. The parameter narrows which tools a fork can
|
||||
execute without changing the tool declarations sent to the model.
|
||||
|
||||
This is the first phase of #7625. Named profile files, shell argument patterns,
|
||||
overlay filesystems, and `/btw` integration are out of scope. A launch-prompt
|
||||
hint tells the fork which visible tools the allowlist permits.
|
||||
|
||||
## Goals
|
||||
|
||||
- Preserve existing fork behavior when `fork_tools` is omitted.
|
||||
- Treat an empty list as deny-all rather than as the existing `tools: []`
|
||||
wildcard behavior.
|
||||
- Keep the fork's current model-visible declarations unchanged so adding an
|
||||
execution restriction does not alter its prompt-cache prefix.
|
||||
- Reject disallowed calls before tool construction, tool hooks, permission
|
||||
classification, scheduling, or approval.
|
||||
- Preserve the restriction when a background fork is revived from its
|
||||
persisted sidecar.
|
||||
|
||||
## Parameter and Matching
|
||||
|
||||
`fork_tools` is valid only with an explicit `subagent_type: "fork"` and cannot
|
||||
be combined with a named teammate. Every entry must be a non-empty string
|
||||
without surrounding whitespace. Unknown exact names remain in the allowlist
|
||||
and match nothing; they are not filtered away, because turning an invalid
|
||||
non-empty list into an omitted restriction would fail open.
|
||||
|
||||
Built-in tools use exact canonical function names from the model-visible
|
||||
declarations. MCP entries support exact canonical names plus server and
|
||||
trailing-wildcard patterns. Patterns are matched against the registered tool's
|
||||
raw MCP server/tool identity rather than only its provider-sanitized name, so
|
||||
distinct server names that sanitize to the same prefix cannot cross-match.
|
||||
Bare `*` is rejected; omission already represents unrestricted execution.
|
||||
Wildcard entries are limited to `mcp__*` or a trailing MCP tool-prefix pattern
|
||||
such as `mcp__github__read_*`. `mcp__*` deliberately matches all MCP tools
|
||||
without matching built-in tools.
|
||||
|
||||
Shell argument patterns are not part of this phase. Listing
|
||||
`run_shell_command` allows the tool call to continue through the normal
|
||||
permission pipeline but does not pre-approve its command.
|
||||
|
||||
## Runtime Separation
|
||||
|
||||
`ToolConfig.tools` remains the source for `AgentCore.prepareTools()` and the
|
||||
function declarations on every model request. A separate
|
||||
`executionAllowedTools` field is snapshotted when `AgentCore` is created.
|
||||
Exact entries and MCP wildcard entries are precomputed separately so a tool
|
||||
miss does not allocate or rescan unrelated built-in names.
|
||||
|
||||
`processFunctionCalls()` first verifies that a requested name is present in
|
||||
the declaration set. It then applies the optional execution allowlist. A
|
||||
disallowed call produces one synthetic error response with the original call
|
||||
ID and name, while other calls in the same batch continue to the scheduler.
|
||||
Because this check precedes scheduler construction, the rejected call cannot
|
||||
open an approval prompt or execute a pre-tool hook.
|
||||
|
||||
The allowlist only narrows the existing surface. It cannot re-enable tools
|
||||
removed by subagent exclusions, bypass normal permissions for an allowed
|
||||
tool, or add declarations.
|
||||
|
||||
The fork receives a restriction notice in the task prompt after the inherited
|
||||
cacheable prefix. This avoids trial-and-error calls without changing the
|
||||
parent-derived system instruction, history prefix, or tool declarations.
|
||||
|
||||
## Background Revival
|
||||
|
||||
Background forks persist inherited history in the `agent_bootstrap` transcript
|
||||
record and the launch task prompt in a separate record. System instruction and
|
||||
tool declarations are capabilities, so cold revival rebinds them from the
|
||||
current parent runtime and resolves current tool names through the live
|
||||
registry.
|
||||
|
||||
`executionAllowedTools` is launch-time policy instead. Restricted forks store
|
||||
it in the `AgentMeta` sidecar, including an empty deny-all list, and cold
|
||||
revival reapplies it to the live `ToolConfig`. The resulting executable surface
|
||||
is the current parent-derived tool surface narrowed by the persisted policy.
|
||||
|
||||
The field remains optional for compatibility. Older transcripts and forks
|
||||
launched without `fork_tools` restore with no additional execution
|
||||
restriction.
|
||||
|
||||
## Boundary
|
||||
|
||||
`fork_tools` is supplied by the parent model or caller on each Agent tool call.
|
||||
It is therefore a child-capability restriction, not a user- or
|
||||
administrator-enforced security sandbox. A future profile layer can provide a
|
||||
short, project-controlled policy name on top of this execution mechanism.
|
||||
|
||||
The restriction cannot be laundered through another child: fork execution runs
|
||||
inside the fork runtime context, whose authoritative Agent-tool guard rejects
|
||||
all sub-agent spawning. More generally, `fork_tools` cannot make an excluded
|
||||
or undeclared tool executable.
|
||||
112
docs/design/web-shell-context-panels.md
Normal file
112
docs/design/web-shell-context-panels.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Web Shell context panels
|
||||
|
||||
## Goal
|
||||
|
||||
Add a persistent header to active chat sessions and move supported workspace
|
||||
and background-task context into a fixed-width environment panel. Keep the
|
||||
existing artifact panel as an independent right-side surface.
|
||||
|
||||
## Header
|
||||
|
||||
The active chat header is opt-in so existing integrations without header props
|
||||
keep their previous layout. Passing `header` enables the default header, whose
|
||||
content is the current session title. `header.items` controls the title,
|
||||
environment action, and artifact-panel action independently; an empty items
|
||||
array hides the complete header. Passing `renderChatHeader` also enables the
|
||||
header and replaces it completely; the renderer receives the session metadata,
|
||||
enabled items, controlled panel state, and panel open-change callbacks. The
|
||||
compact sidebar toggle remains owned by `sidebar` and renders outside the
|
||||
custom header. While the artifact panel is closed, its toggle is in the chat
|
||||
header. While it is open, that same toggle moves to the right edge of the
|
||||
artifact-panel header, leaving the environment action at the right edge of the
|
||||
chat header adjacent to the panel.
|
||||
|
||||
The artifact-panel action remains available when no tab exists. Opening an
|
||||
The empty panel shows Review and, when session-source metadata is supported,
|
||||
side-task history plus New side task. Once a tab is open, the panel header add
|
||||
menu contains Review and New side task without repeating the side-task history.
|
||||
Review opens the most recent transcript turn containing reviewable file
|
||||
changes, is disabled when no such turn exists, and is hidden from the add menu
|
||||
while a review tab is already open. Closing a populated artifact panel keeps
|
||||
its tabs so the header action can reopen the existing content.
|
||||
|
||||
`rightPanel.items` independently controls whether Review and Side task appear
|
||||
on the empty panel page. Both items are enabled by default.
|
||||
|
||||
## Side tasks
|
||||
|
||||
A side task is a distinct daemon thread session in the same workspace as its
|
||||
parent. It renders the existing interactive chat pane, including the transcript,
|
||||
composer, approval-mode selector, model selector, streaming state, and
|
||||
permission handling. Creation uses the dedicated side-task endpoint to snapshot
|
||||
the main session's complete persisted model context at that moment, then
|
||||
continues independently. The snapshot is serialized against transcript writes,
|
||||
so a side task can be created while the parent is responding without observing
|
||||
a partial JSONL record. Inherited records are not replayed in the side-task
|
||||
transcript; only messages created inside the side task are shown.
|
||||
|
||||
Side-task sessions record `sourceType: side_task` and the parent session id as
|
||||
`sourceId`. The Web Shell session catalog filters this source type, so side
|
||||
tasks do not appear as top-level sessions. With saved side tasks, hovering Side
|
||||
task on the empty right-panel page opens a menu of those sessions and a New
|
||||
action. With no saved task, clicking the row creates one directly. Selecting a
|
||||
saved task restores it as a tab. Closing a tab only detaches its client; the
|
||||
daemon transcript remains available for later conversation.
|
||||
|
||||
`/btw <question>` keeps the lightweight, one-shot BTW interaction.
|
||||
`/btw side <question>` opens a new side-task draft and sends the question as
|
||||
its first prompt when the daemon advertises `session_side_task`. Hosts can
|
||||
trigger the same action through `shellRef.current.createSideTask()`.
|
||||
|
||||
## Environment panel
|
||||
|
||||
The environment panel uses only existing Web Shell capabilities:
|
||||
|
||||
- workspace path;
|
||||
- Git branch and working-tree summary;
|
||||
- working-tree diff and commit history entry points;
|
||||
- configured agents entry point;
|
||||
- background agent, shell, and monitor task summaries.
|
||||
|
||||
The environment action and environment section remain available throughout an
|
||||
active chat session. A clean working tree is shown explicitly; agent and
|
||||
background-task sections appear only when they have content.
|
||||
|
||||
`environmentPanel.items` independently controls the environment, subagent, and
|
||||
background-task sections. All three sections are enabled by default.
|
||||
|
||||
The local `/fork` command refreshes the session task snapshot as soon as its
|
||||
background agent launches. Fork agents have no parent transcript tool call, so
|
||||
their right-panel detail resolves the virtual subagent session by agent task ID
|
||||
instead of `toolUseId`.
|
||||
|
||||
The local `/tasks` command opens the environment panel and refreshes its task
|
||||
snapshot instead of opening the legacy task dialog.
|
||||
|
||||
Side-task, subagent, and fork transcripts expose their own file changes and
|
||||
artifacts through the main right panel. Their source session scopes tab
|
||||
identities and workspace actions, so opening a nested output creates a separate
|
||||
tab without replacing the main session's review or artifact tabs.
|
||||
|
||||
It is a fixed-width, non-resizable layout column styled as a floating card with
|
||||
a border and shadow. At narrower message widths it opens as a dismissible
|
||||
floating popover instead of consuming chat width.
|
||||
|
||||
The environment panel and artifact panel are independent. At desktop widths the
|
||||
two may be visible together. When the viewport cannot fit both, the artifact
|
||||
panel normally takes priority and the environment panel is hidden without
|
||||
losing its open state. Opening a subagent or background task from the
|
||||
environment panel keeps that panel visible beside the resulting detail. A
|
||||
floating environment panel is positioned within the remaining message area and
|
||||
never overlaps the artifact panel.
|
||||
|
||||
## Responsive behavior
|
||||
|
||||
The environment panel is hidden for split/full-page views. When the message
|
||||
area cannot keep at least 800 pixels after docking the panel, the panel closes
|
||||
and can be reopened as a floating popover. An open artifact panel takes
|
||||
priority when both panels cannot fit, but the environment action remains
|
||||
available for explicitly reopening the popover. The existing artifact drawer
|
||||
behavior on narrow screens is unchanged. On desktop, the artifact panel is a
|
||||
top-level layout column beside the chat shell, so it starts at the top of the
|
||||
page and the chat header ends at the panel boundary.
|
||||
88
docs/design/workspace-skills-read-model.md
Normal file
88
docs/design/workspace-skills-read-model.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Workspace skills read model
|
||||
|
||||
## Problem
|
||||
|
||||
`GET /workspace/skills` currently delegates to the ACP child. The child status
|
||||
handler refreshes both the extension and skill caches before returning a
|
||||
response. Web reconnects therefore turn a read-only status query into a full
|
||||
extension scan and skill parse.
|
||||
|
||||
## Design
|
||||
|
||||
The fix is staged:
|
||||
|
||||
1. Make the ACP status handler read only an already committed `SkillManager`
|
||||
cache. A cold cache — or a config with no `SkillManager` at all — returns
|
||||
`initialized: false`; it never scans or parses in response to a status
|
||||
request. Explicit mutation and refresh commands remain the imperative
|
||||
refresh paths.
|
||||
2. Keep the extension half of the snapshot self-healing with a `stat`-only
|
||||
validity check. Skills have a watcher; extensions do not, so a read verifies
|
||||
that the extension directory entries, their manifests, the enablement file,
|
||||
and the store's activation state are where the last refresh left them, and
|
||||
refreshes the extension and skill caches only when they moved. This preserves
|
||||
the pre-change behavior for extension install / enable / disable run outside
|
||||
the daemon, at one `readdir` plus one `stat` per entry plus two, instead of a
|
||||
directory scan and a full manifest and skill parse. Skipped in safe and bare
|
||||
mode, which deliberately never populate the extension cache at all.
|
||||
3. Retain the last initialized child or daemon-local fallback snapshot in the
|
||||
workspace facade. Concurrent cold reads share one request, and a generation
|
||||
guard prevents an invalidated in-flight result from being cached or from
|
||||
extending the freshness window of a snapshot committed after it. The facade
|
||||
revalidates against the child's in-memory snapshot every five seconds so
|
||||
child watcher updates remain visible without request-triggered discovery.
|
||||
4. Split explicit refreshes into settings and content reasons. Settings changes
|
||||
only notify derived consumers; content changes refresh each distinct
|
||||
`SkillManager` once before publishing session command updates.
|
||||
5. Extension reconciliation refreshes the bootstrap extension and skill
|
||||
snapshots as well as session runtimes. Multi-session refreshes nominate one
|
||||
bootstrap refresh per ACP connection, retry through a successful session if
|
||||
the nominated session has disappeared, and use a child-side single-flight
|
||||
to coalesce overlapping requests from older parents.
|
||||
6. Invalidate the daemon snapshot before and after an imperative refresh. The
|
||||
first invalidation prevents a pre-mutation snapshot from being reused; the
|
||||
second prevents a read that raced with the refresh from surviving after the
|
||||
mutation completes.
|
||||
|
||||
The child route remains available for older daemon parents. New child versions
|
||||
serve it from memory, so an old parent is safe even if it continues querying on
|
||||
every reconnect.
|
||||
|
||||
## Invariants
|
||||
|
||||
- A child status read performs no skill parse, no manifest parse, and no
|
||||
settings-file load. It may `readdir` the extensions directory and `stat` a
|
||||
bounded set of paths — one per entry plus two — and refreshes only when that
|
||||
set moved.
|
||||
- Once either the child or the daemon-local fallback has published an
|
||||
initialized snapshot, repeated HTTP status reads perform no filesystem work
|
||||
beyond that bounded check.
|
||||
- Extension state stays eventually consistent with out-of-band mutations
|
||||
without a watcher, because the check is cheap enough to run on a read.
|
||||
- Revalidation can never fail a read: every part of it, including the mode
|
||||
check, is inside the error boundary.
|
||||
- The daemon-local fallback may perform one cold enumeration when no child has
|
||||
ever published a snapshot; this preserves pre-first-prompt autocomplete
|
||||
without reintroducing repeated scans.
|
||||
- Cache refresh publishes a complete replacement; readers never observe a
|
||||
cache being constructed.
|
||||
- A missing committed cache is represented explicitly and does not trigger
|
||||
lazy initialization.
|
||||
- Mutation-triggered refresh is independent from status reads.
|
||||
|
||||
## Known gaps
|
||||
|
||||
Skill enablement is read from the child's in-memory `LoadedSettings` rather than
|
||||
re-loaded per request. `SettingsWatcher` keeps the User and Workspace scopes
|
||||
current, so the common paths — the daemon's own toggle, a `/skills` toggle in a
|
||||
terminal, a hand edit — are covered. Two narrow cases are not: the System and
|
||||
SystemDefaults scopes have no watcher, so a policy change to the locked-skill
|
||||
list is not reflected until an explicit refresh; and an untrusted workspace does
|
||||
not watch the Workspace scope at all. Both were previously self-healing because
|
||||
every read reloaded settings from disk.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The cached read API is additive. Existing callers of `listSkills()` keep its
|
||||
lazy-load behavior. Existing HTTP and ACP response shapes remain compatible;
|
||||
refresh-result fields are additive.
|
||||
|
|
@ -6,7 +6,7 @@ The daemon never lets HTTP routes or ACP-side agent calls touch the host filesys
|
|||
|
||||
- **Path resolution** — canonicalize paths and reject anything escaping the bound workspace, including via symlinks.
|
||||
- **Trust gating** — refuse writes when the workspace is not trusted (`untrusted_workspace`).
|
||||
- **Size & content policy** — full-snapshot/output cap (`MAX_READ_BYTES = 256 KiB`), bounded large-text windows, write cap (`MAX_WRITE_BYTES = 5 MiB`), binary detection.
|
||||
- **Size & content policy** — full-snapshot/output cap (`MAX_READ_BYTES = 256 KiB`), large-text windows bounded in both output and scan cost (`MAX_TEXT_SCAN_BYTES = 8 MiB`), write cap (`MAX_WRITE_BYTES = 5 MiB`), binary detection.
|
||||
- **Atomicity** — write-then-rename with target mode preservation and `0o600` default for new files.
|
||||
- **Audit** — every access / denial emits a structured event for `PermissionAuditRing` / monitoring.
|
||||
- **Typed errors** — closed `FsErrorKind` union mapped to HTTP statuses.
|
||||
|
|
@ -17,7 +17,7 @@ The HTTP file routes (`GET /file`, `GET /file/bytes`, `POST /file/write`, `POST
|
|||
|
||||
- Resolve user-supplied paths into branded `ResolvedPath` values that the rest of the boundary can safely use.
|
||||
- Refuse paths outside the bound workspace (`path_outside_workspace`) and paths whose target is a symlink (`symlink_escape`).
|
||||
- Refuse full-snapshot reads above `MAX_READ_BYTES`, while allowing finite line windows with output capped at `MAX_READ_BYTES`; refuse writes above `MAX_WRITE_BYTES` and binary files (`binary_file`).
|
||||
- Refuse full-snapshot reads above `MAX_READ_BYTES`, while allowing explicit windows with output capped at `MAX_READ_BYTES` and scan cost capped at `MAX_TEXT_SCAN_BYTES`; refuse writes above `MAX_WRITE_BYTES` and binary files (`binary_file`).
|
||||
- Refuse writes/edits when the workspace is untrusted (`untrusted_workspace`) — gated by `assertTrustedForIntent(trusted, intent)`.
|
||||
- Honor `.gitignore` / `.qwenignore` patterns via `shouldIgnore`.
|
||||
- Perform atomic write-then-rename with target mode preservation; default new file mode is `0o600`.
|
||||
|
|
@ -31,7 +31,7 @@ The HTTP file routes (`GET /file`, `GET /file/bytes`, `POST /file/write`, `POST
|
|||
| File | Purpose |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `paths.ts` | `canonicalizeWorkspace`, `resolveWithinWorkspace`, `hasSuspiciousPathPattern`, branded `ResolvedPath`, `Intent` union (`read \| write \| list \| stat \| glob`). |
|
||||
| `policy.ts` | `MAX_READ_BYTES`, `MAX_WRITE_BYTES`, `BINARY_PROBE_BYTES`, `assertTrustedForIntent`, `detectBinary`, `enforceReadBytesSize`, `enforceReadSize`, `enforceWriteSize`, `shouldIgnore`. |
|
||||
| `policy.ts` | `MAX_READ_BYTES`, `MAX_TEXT_SCAN_BYTES`, `MAX_WRITE_BYTES`, `BINARY_PROBE_BYTES`, `assertTrustedForIntent`, `detectBinary`, `enforceReadBytesSize`, `enforceReadSize`, `enforceWriteSize`, `shouldIgnore`. |
|
||||
| `audit.ts` | `FS_ACCESS_EVENT_TYPE`, `FS_DENIED_EVENT_TYPE`, `createAuditPublisher`, audit payload types. |
|
||||
| `errors.ts` | `FsError` class, `isFsError`, `FsErrorKind` union (14 kinds), `FsErrorStatus` union (`400 / 403 / 404 / 409 / 413 / 422 / 500 / 503`). |
|
||||
| `workspace-file-system.ts` | `createWorkspaceFileSystemFactory`, `WorkspaceFileSystem` (the orchestrator that reads/writes/lists), `WriteMode`, `ContentHash`, `FsEntry`, `FsStat`, `ListOptions`, `GlobOptions`, `ReadTextOptions`, `ReadBytesOptions`, `WriteTextAtomicOptions`. |
|
||||
|
|
@ -43,8 +43,8 @@ The HTTP file routes (`GET /file`, `GET /file/bytes`, `POST /file/write`, `POST
|
|||
| `path_outside_workspace` | 400 | Resolved path is outside the bound workspace. |
|
||||
| `symlink_escape` | 400 | Target is a symlink (rejected per the conservative PR 18 + PR 20 posture). |
|
||||
| `path_not_found` | 404 | `ENOENT`. |
|
||||
| `binary_file` | 422 | Content sniffed binary on a text route. |
|
||||
| `file_too_large` | 413 | Unbounded/full-snapshot text above `MAX_READ_BYTES`, unsupported large non-UTF-8 text, or a write above `MAX_WRITE_BYTES`. |
|
||||
| `binary_file` | 422 | Content sniffed binary on a text route, or large text in an encoding the text route cannot decode. |
|
||||
| `file_too_large` | 413 | Windowless/full-snapshot text above `MAX_READ_BYTES`, a line offset beyond `MAX_TEXT_SCAN_BYTES`, or a write above `MAX_WRITE_BYTES`. |
|
||||
| `hash_mismatch` | 409 | Optimistic-concurrency `expectedSha256` failed, or the file changed during a stable read. |
|
||||
| `file_already_exists` | 409 | `mode: 'create'` against an existing file. |
|
||||
| `text_not_found` | 422 | `POST /file/edit`'s search string wasn't in the file. |
|
||||
|
|
@ -139,22 +139,24 @@ sequenceDiagram
|
|||
FS->>FSP: stat(path)
|
||||
FSP-->>FS: stats
|
||||
FS->>FS: reject if not regular file (describeStatKind)
|
||||
alt file <= 256 KiB
|
||||
alt cursor supplied
|
||||
FS->>FSP: open stable FileHandle
|
||||
FS->>FS: validate cursor {dev,ino,size}; seek to the byte offset
|
||||
FS->>FS: return whole lines; emit the next cursor
|
||||
else file <= 256 KiB
|
||||
FS->>FSP: open + read stable full snapshot
|
||||
FSP-->>FS: buffer
|
||||
FS->>POL: detectBinary(buffer)
|
||||
FS->>FS: reject if binary
|
||||
FS->>FS: hash full snapshot; apply line/output limits
|
||||
else file > 256 KiB AND finite limit
|
||||
else file > 256 KiB AND an explicit window arg
|
||||
FS->>FSP: open stable FileHandle
|
||||
FS->>POL: detectBinary(handle sample)
|
||||
FS->>FS: reject if binary
|
||||
FS->>FS: stream requested lines from the same inode
|
||||
FS->>FS: recheck size + mtime + ctime + device/inode
|
||||
FS->>FS: cap output at 256 KiB; omit full-file hash
|
||||
else unbounded large read
|
||||
FS->>FS: cap output at 256 KiB and scan at 8 MiB; omit full-file hash
|
||||
else windowless large read
|
||||
FS-->>R: file_too_large
|
||||
end
|
||||
FS->>POL: detectBinary(sample)
|
||||
POL-->>FS: isBinary?
|
||||
FS->>FS: reject if binary
|
||||
FS->>FS: shouldIgnore? → annotate meta.matchedIgnore
|
||||
FS->>FS: audit fs.access
|
||||
FS-->>R: { content, optional sha256, truncated?, meta }
|
||||
|
|
@ -227,7 +229,8 @@ flowchart LR
|
|||
| Source | Knob | Effect |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `WorkspaceFileSystemFactoryDeps.trusted: boolean` | Constructor input | Whether writes are allowed; defaults to `true` from `runQwenServe`, `false` from `createServeApp` (with warning). |
|
||||
| Constant | `MAX_READ_BYTES = 256 KiB` | Full-snapshot and returned-text cap; larger text requires a finite line limit. |
|
||||
| Constant | `MAX_READ_BYTES = 256 KiB` | Full-snapshot and returned-text cap; larger text requires an explicit window argument. |
|
||||
| Constant | `MAX_TEXT_SCAN_BYTES = 8 MiB` | Bytes a large-text read may scan to locate a line offset; past it, `file_too_large`. |
|
||||
| Constant | `MAX_WRITE_BYTES = 5 MiB` | Write cap; sized below `express.json({ limit: '10mb' })`. |
|
||||
| Constant | `BINARY_PROBE_BYTES = 4096` | Sample size for content-based binary detection. |
|
||||
| Capability tags | `workspace_file_read`, `workspace_file_bytes`, `workspace_file_write` | See [`11-capabilities-versioning.md`](./11-capabilities-versioning.md). |
|
||||
|
|
@ -239,10 +242,12 @@ flowchart LR
|
|||
- **`io_error` vs `permission_denied` are distinct.** Do not conflate them. Monitoring pipelines key on `errorKind` for alerting — folding ENOSPC into permission_denied would page security responders for `df -h` problems.
|
||||
- **New file mode defaults to `0o600`, not umask defaults.** The write syscall's `mode` arg bypasses umask. Agents writing public files should explicitly pass a mode override.
|
||||
- **`createServeApp` default `trusted: false`** silently rejects ACP writes with `untrusted_workspace` for embedders that do not inject a custom `fsFactory` or `bridge`. A one-time stderr warning fires the first time; further callers see no reminder. See [`02-serve-runtime.md`](./02-serve-runtime.md).
|
||||
- **Large text requires a finite line limit.** No-limit reads, line-only reads, and maxBytes-only reads above `MAX_READ_BYTES` remain `file_too_large`. Finite windows stream from an inode-bound handle and never return more than `MAX_READ_BYTES`.
|
||||
- **Streamed windows require a stable file snapshot.** The open handle pins the inode but does not freeze its bytes, so a successful streamed response requires device/inode identity, size, modification time, and change time to remain unchanged through the read. A detected mutation takes precedence over a simultaneous decode failure and returns `hash_mismatch`.
|
||||
- **Large partial reads omit the full-file hash.** They retain the complete `sizeBytes`; `originalLineCount` is omitted when streaming stops before EOF.
|
||||
- **`BridgeFileSystem` adapter MUST preserve both inline-proxy safety properties** (non-regular-file refusal + bounded buffering/streaming). The inline path is fully bypassed when the adapter is injected.
|
||||
- **Large text requires an explicit window argument**, any of `line` / `limit` / `maxBytes`. A read with none of them stays `file_too_large`, because a caller that believes it holds the whole file may write it back truncated. Windows stream from an inode-bound handle and never return more than `MAX_READ_BYTES`.
|
||||
- **`MAX_READ_BYTES` caps what a read returns; `MAX_TEXT_SCAN_BYTES` caps what it costs.** Line offsets are resolved by scanning from byte 0, so `{ line: 900_000_000, limit: 20 }` returns almost nothing and still walks the file. Past 8 MiB of scanning the read is refused with `file_too_large` pointing at `readBytes`, which reaches any offset in O(1).
|
||||
- **Streamed windows tolerate appends, not truncation.** The full-snapshot path can demand byte-for-byte stability because it returns the whole file; a prefix window cannot, or every read of a live log fails. The streamed path asserts inode identity plus "did not shrink", so appends pass and truncation / replacement are still rejected. `sizeBytes` reports the size at `open`, describing the snapshot the window was cut from.
|
||||
- **Large partial reads omit the full-file hash.** `originalLineCount` is omitted when streaming stops before EOF.
|
||||
- **Paging is by byte cursor, not by line.** A read that leaves content behind returns `hasMore` and, where a byte offset is derivable, an opaque `nextCursor`. Resuming from it is O(1); resuming by `line` re-scans from byte 0 and is refused past `MAX_TEXT_SCAN_BYTES`. The cursor carries `{dev, ino, size}`, so a replaced or truncated file yields `hash_mismatch` rather than bytes from the wrong place, while an append leaves it valid. Non-UTF-8 snapshot reads report `hasMore` but no cursor — their decoded text is a UTF-8 re-encoding whose lengths do not map back to file offsets.
|
||||
- **`BridgeFileSystem` adapter MUST replicate both inline-proxy gates** (non-regular-file refusal + bounded buffering/streaming). The inline path is fully bypassed when the adapter is injected.
|
||||
|
||||
## References
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ Extension management: `extension_management_v2` adds the global `/extensions/*`
|
|||
|
||||
Workspace-qualified session reads: `workspace_persisted_transcript`, `workspace_session_export`, `workspace_archived_session_export`. The active and archived export tags are independent from each other and from `session_export` and `workspace_qualified_rest_core`, so clients must pre-flight the exact storage state they intend to export. Persisted transcript paging permits an untrusted secondary under its bounded read policy; both full export paths remain trusted-only.
|
||||
|
||||
Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_acp_preheat`, `workspace_tool_toggle`, **`workspace_settings`** (conditional), `workspace_permissions`, `workspace_init`, `workspace_github_setup`, `workspace_trust`, `workspace_mcp_restart`, `workspace_mcp_manage`, `workspace_file_read`, `workspace_file_bytes`, `workspace_file_write`, **`workspace_reload`** (conditional).
|
||||
Workspace mutation (Wave 4+): `workspace_memory`, `workspace_agents`, `workspace_agent_generate`, `workspace_acp_preheat`, `workspace_tool_toggle`, **`workspace_settings`** (conditional), `workspace_permissions`, `workspace_init`, `workspace_github_setup`, `workspace_trust`, `workspace_mcp_restart`, `workspace_mcp_manage`, `workspace_file_read`, `workspace_file_bytes`, `workspace_file_read_cursor`, `workspace_file_write`, **`workspace_reload`** (conditional).
|
||||
|
||||
MCP guardrails: **`mcp_guardrails`** (`modes: ['warn', 'enforce']`), `mcp_guardrail_events`, `mcp_server_runtime_mutation`, **`mcp_workspace_pool`** (conditional), **`mcp_pool_restart`** (conditional).
|
||||
|
||||
|
|
|
|||
|
|
@ -1293,6 +1293,15 @@ tolerate its absence from older v1 daemons. Skill bodies, hooks, `skillRoot`,
|
|||
and other skill configuration remain excluded. `errors` is omitted when
|
||||
discovery succeeds.
|
||||
|
||||
Repeated reads are served from the last committed workspace snapshot,
|
||||
periodically revalidated against the child's in-memory cache. A read never
|
||||
scans skill directories or reparses `SKILL.md` files. The child does verify
|
||||
that its extension sources are unchanged — one `readdir` of the extensions
|
||||
directory plus a `stat` per entry, the enablement file, and the store's
|
||||
activation state — and refreshes only when they moved, so an extension
|
||||
installed or toggled outside the daemon is still picked up on the next read.
|
||||
Safe and bare mode skip the check, matching their exclusion of extensions.
|
||||
|
||||
### `GET /workspace/providers`
|
||||
|
||||
```json
|
||||
|
|
@ -1578,24 +1587,56 @@ Filesystem errors use this JSON shape:
|
|||
|
||||
#### `GET /file`
|
||||
|
||||
Reads a text file. Query params: `path` (required), `maxBytes`, `line`, and
|
||||
`limit`. The daemon rejects binary files. Files above the 256 KiB full-snapshot
|
||||
cap require a finite `limit`; no-limit, line-only, and maxBytes-only requests
|
||||
remain `file_too_large`. A finite large-file window is streamed and its returned
|
||||
UTF-8 content remains capped at 256 KiB. `maxBytes` always applies to the UTF-8
|
||||
response bytes after decoding, including when the source uses another supported
|
||||
encoding within the full-snapshot cap.
|
||||
Reads a text file. Query params: `path` (required), `maxBytes`, `line`, `limit`,
|
||||
and `cursor`. The daemon rejects binary files. Files above the 256 KiB
|
||||
full-snapshot
|
||||
cap require at least one explicit window argument (`line`, `limit`, or
|
||||
`maxBytes`); a request with none of them remains `file_too_large`. Such a
|
||||
window is streamed, and its returned UTF-8 content stays capped at 256 KiB.
|
||||
`maxBytes` always applies to the UTF-8 response bytes after decoding, including
|
||||
when the source uses another supported encoding within the full-snapshot cap.
|
||||
|
||||
Line offsets are resolved by scanning from the start of the file, so a window
|
||||
is also refused with `file_too_large` when reaching it would read more than
|
||||
8 MiB (`MAX_TEXT_SCAN_BYTES`). Use `GET /file/bytes` to reach a deeper offset
|
||||
directly. Large text in an encoding the route cannot decode returns
|
||||
`binary_file`, not `file_too_large` — retrying with a smaller window cannot
|
||||
help, and `readBytes` is the same remedy that already applies to binary.
|
||||
|
||||
For files within the full-snapshot cap, the response includes `hash`, a SHA-256
|
||||
digest over the raw on-disk bytes for the whole file, even when `line`, `limit`,
|
||||
or `maxBytes` returned a slice. Large partial windows omit `hash`, retain the
|
||||
complete `sizeBytes`, set `truncated: true`, and return
|
||||
`originalLineCount: null` when the stream stops before EOF. A streamed result
|
||||
is returned only when the file remains stable. Concurrent changes detected by
|
||||
the post-read device/inode, size, modification-time, and change-time checks
|
||||
return `hash_mismatch`, including when the same mutation also causes decoding
|
||||
to fail. Stable binary content remains `binary_file`, and path replacement
|
||||
retains the existing `symlink_escape` protection.
|
||||
`originalLineCount: null` when the stream stops before EOF.
|
||||
|
||||
##### Paging with `cursor`
|
||||
|
||||
Requires the `workspace_file_read_cursor` capability. A response that has more
|
||||
to give returns `hasMore: true` and, when a file byte offset is derivable, a
|
||||
`nextCursor` token. Passing it back as `cursor` resumes in O(1), where a deep
|
||||
`line` offset costs a scan from byte 0 and is refused past 8 MiB.
|
||||
|
||||
```
|
||||
GET /file?path=big.log&limit=500 → { content, nextCursor, hasMore: true }
|
||||
GET /file?path=big.log&limit=500&cursor=… → next page
|
||||
```
|
||||
|
||||
`cursor` and `line` are mutually exclusive (`parse_error`) — both name a
|
||||
starting point. A malformed or over-long cursor is `parse_error`; a cursor
|
||||
whose file has been replaced or truncated is `hash_mismatch` (409). Appending
|
||||
does **not** invalidate an outstanding cursor, which is the case the feature
|
||||
exists for.
|
||||
|
||||
`content` omits the terminating newline of its last line, as every other read
|
||||
does, so a client reassembling pages joins them with `\n`. `hasMore` is not a
|
||||
restatement of `nextCursor`: a small non-UTF-8 file read with a `limit` has
|
||||
more content but no derivable byte offset, so it reports `hasMore: true` with
|
||||
`nextCursor: null`. The cursor is also null when the byte cap cuts the current
|
||||
line, because resuming from that offset would return a partial line. For many
|
||||
short lines, lower `limit` until the page ends before the byte cap and returns
|
||||
a cursor. For a single oversized line, request the following line explicitly
|
||||
(for example, `line=2` when starting at line 1), then continue with cursors;
|
||||
use `GET /file/bytes` when the complete oversized line is required.
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ Use `agent` to launch a specialized subagent to handle complex, multi-step tasks
|
|||
- `prompt` (string, required): The detailed task prompt for the subagent to execute. Should contain comprehensive instructions for autonomous execution.
|
||||
- `subagent_type` (string, optional): The type of specialized agent to use for this task. Defaults to `general-purpose` if omitted.
|
||||
- `fork_turns` (string, optional): Only valid with `subagent_type="fork"`. Omit it or use `all` for the full parent conversation, or use a positive integer string such as `"3"` for the most recent three real user turns. Tool responses and pure system reminders do not count as turns.
|
||||
- `fork_tools` (array of strings, optional): Only valid with `subagent_type="fork"`. Restricts execution to exact canonical tool names or MCP server patterns while keeping the fork's current model-visible tool declarations unchanged for prompt-cache sharing. Entries cannot have surrounding whitespace; wildcards are limited to `mcp__*` or a trailing MCP tool-prefix pattern such as `mcp__github__read_*`. Omit it for unrestricted execution; use an empty array to reject every tool call.
|
||||
- `run_in_background` (boolean, optional): Defaults to `true` for top-level regular agents. Set to `false` to wait for a regular agent's result inline. Headless forks always run in the background. Nested agents run in the foreground unless `run_in_background` is explicitly `true`, which is rejected because nested agents cannot receive background completion notifications. Caller-owned `working_dir` launches run in the foreground and reject explicit or configured background execution.
|
||||
- `isolation` (string, optional): Set to `"worktree"` to run an explicitly named, non-fork agent in an isolated git worktree that Qwen Code creates and manages.
|
||||
- `working_dir` (string, optional): Pin an explicitly named, non-fork agent to an existing registered git worktree inside the current repository. The caller owns the worktree lifecycle, so this mode runs in the foreground. If both `working_dir` and `isolation` are provided, `working_dir` takes precedence.
|
||||
|
|
@ -34,6 +35,7 @@ Usage:
|
|||
```
|
||||
agent(description="Brief task description", prompt="Detailed task instructions for the subagent", subagent_type="agent_name")
|
||||
agent(description="Brief task description", prompt="Detailed task instructions for the fork", subagent_type="fork", fork_turns="3")
|
||||
agent(description="Read-only investigation", prompt="Inspect the implementation", subagent_type="fork", fork_tools=["read_file", "grep_search", "mcp__github"])
|
||||
```
|
||||
|
||||
Set `run_in_background=false` when the current turn must use the subagent result before continuing.
|
||||
|
|
@ -144,6 +146,7 @@ Don't use the Agent tool for:
|
|||
## Important Notes
|
||||
|
||||
- **Independent context**: Regular subagents start without parent conversation history. Forks inherit the full conversation by default and accept `fork_turns` when a bounded recent window is sufficient.
|
||||
- **Fork execution restrictions**: `fork_tools` narrows which already-declared tools a fork may execute. Disallowed calls return an error before scheduling or approval; the same declaration list remains model-visible for cache sharing. This is a per-call restriction chosen by the caller, not an administrator-enforced sandbox.
|
||||
- **Completion delivery**: Background results arrive through completion notifications in a later turn. Do not assume a result before the notification arrives.
|
||||
- **Continuation**: Use `list_agents` and `send_message` for related follow-up work instead of launching a duplicate agent. Continuation depends on compatible retained state and may be unavailable.
|
||||
- **Comprehensive prompts**: Your initial prompt should contain all necessary context and instructions for autonomous execution. A regular subagent does not see the parent conversation.
|
||||
|
|
|
|||
|
|
@ -557,6 +557,8 @@ Sequential UserPromptSubmit hooks can append `additionalContext` to `prompt`; `s
|
|||
- `reason`: human-readable explanation for the decision
|
||||
- `hookSpecificOutput.additionalContext`: additional context to append to the prompt (optional)
|
||||
|
||||
When sent to the model, injected `additionalContext` is appended as its own message part wrapped in a reserved `<qwen:user-prompt-submit-context>...</qwen:user-prompt-submit-context>` tag, so it stays distinguishable from user-authored text in model history and session transcripts. Angle brackets in hook output are escaped before wrapping, so hook content cannot close or forge the tag. The session transcript also records the user's original prompt text separately; the interactive TUI and the ACP/export transcript-replay path display that original text rather than the injected context.
|
||||
|
||||
**Note**: Since UserPromptSubmitOutput extends HookOutput, all standard fields are available but only additionalContext in hookSpecificOutput is specifically defined for this event.
|
||||
|
||||
**Example Output**:
|
||||
|
|
|
|||
|
|
@ -25,14 +25,28 @@ Only `subagent_type: "fork"` accepts `fork_turns`:
|
|||
|
||||
Tool responses and pure system reminders do not count as user turns. Regular named subagents and agent-team teammates do not accept `fork_turns`; they keep their separate conversation context.
|
||||
|
||||
## Restricting Fork Tool Execution with `fork_tools`
|
||||
|
||||
Only `subagent_type: "fork"` accepts `fork_tools`. The array may contain exact canonical tool names, such as `read_file` and `grep_search`, or MCP server patterns such as `mcp__github`. The fork still receives the same model-visible tool declarations as an unrestricted fork, preserving its prompt-cache prefix, but its task prompt identifies the restriction and a call not matched by `fork_tools` is rejected before scheduling or approval.
|
||||
|
||||
- Omitting `fork_tools` preserves unrestricted fork execution.
|
||||
- An empty array rejects every tool call.
|
||||
- `*` is not accepted; omit `fork_tools` when unrestricted execution is intended.
|
||||
- Tool names cannot have surrounding whitespace. Wildcards are accepted only as `mcp__*` or as a trailing MCP tool-prefix pattern such as `mcp__github__read_*`.
|
||||
- `mcp__*` intentionally allows every MCP tool while still denying unlisted built-in tools.
|
||||
- Shell command argument patterns are not supported. Listing `run_shell_command` allows that tool to proceed through its normal permission checks but does not pre-approve any command.
|
||||
|
||||
This is a per-invocation restriction supplied by the caller. It narrows a child fork's capabilities but is not an administrator-enforced security sandbox because the caller can omit or expand the list.
|
||||
|
||||
### How Fork Differs from Named Subagents
|
||||
|
||||
| | Named Subagent | Fork Subagent |
|
||||
| ------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Context | Starts fresh with no parent conversation history | Inherits all parent history by default; `fork_turns` can select a bounded recent window |
|
||||
| System prompt | Uses its own configured prompt | Uses parent's exact system prompt (for cache sharing) |
|
||||
| Execution | Background by default; supports an explicit foreground opt-out | Always detached; parent continues immediately |
|
||||
| Use case | Specialized tasks (testing, docs) | Parallel tasks that need the current context |
|
||||
| | Named Subagent | Fork Subagent |
|
||||
| ------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| Context | Starts fresh with no parent conversation history | Inherits all parent history by default; `fork_turns` can select a bounded recent window |
|
||||
| System prompt | Uses its own configured prompt | Uses parent's exact system prompt (for cache sharing) |
|
||||
| Tools | Configured declaration set | Keeps the parent-derived declaration set; `fork_tools` can independently narrow execution without changing that set |
|
||||
| Execution | Background by default; supports an explicit foreground opt-out | Always detached; parent continues immediately |
|
||||
| Use case | Specialized tasks (testing, docs) | Parallel tasks that need the current context |
|
||||
|
||||
### When Fork is Used
|
||||
|
||||
|
|
@ -46,9 +60,9 @@ The AI automatically uses fork when it needs to:
|
|||
|
||||
All forks share the parent's exact API request prefix (system prompt, tools, conversation history), enabling DashScope prompt cache hits. When 3 forks run in parallel, the shared prefix is cached once and reused — saving 80%+ token costs compared to independent subagents.
|
||||
|
||||
### Recursive Fork Prevention
|
||||
### Recursive Delegation Prevention
|
||||
|
||||
Fork children cannot create further forks. This is enforced at runtime — if a fork attempts to spawn another fork, it receives an error instructing it to execute tasks directly.
|
||||
Fork children cannot spawn any further sub-agent. This is enforced at runtime — if a fork calls the Agent tool, it receives an error instructing it to execute tasks directly.
|
||||
|
||||
### Current Limitation
|
||||
|
||||
|
|
|
|||
|
|
@ -310,6 +310,7 @@ describe('qwen serve — capabilities envelope', () => {
|
|||
'session_list',
|
||||
'session_info',
|
||||
'session_source_metadata',
|
||||
'session_side_task',
|
||||
'session_prompt',
|
||||
'session_cancel',
|
||||
'session_events',
|
||||
|
|
@ -357,6 +358,7 @@ describe('qwen serve — capabilities envelope', () => {
|
|||
'mcp_server_runtime_mutation',
|
||||
'workspace_file_read',
|
||||
'workspace_file_bytes',
|
||||
'workspace_file_read_cursor',
|
||||
'workspace_file_write',
|
||||
'session_approval_mode_control',
|
||||
'workspace_tool_toggle',
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
createResultWaiter,
|
||||
} from './test-helper.js';
|
||||
|
||||
const TEST_TIMEOUT = process.env['CI'] ? 60000 : 30000;
|
||||
const TEST_TIMEOUT = 60000;
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
|
||||
/**
|
||||
|
|
@ -359,7 +359,9 @@ describe('Permission Control (E2E)', () => {
|
|||
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) {
|
||||
if (isSDKResultMessage(message)) {
|
||||
// Resolve on result (one per turn), not assistant message
|
||||
// (which may fire multiple times per turn: thinking + text)
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
|
|
@ -367,8 +369,6 @@ describe('Permission Control (E2E)', () => {
|
|||
secondResponseReceived = true;
|
||||
resolvers.second?.();
|
||||
}
|
||||
}
|
||||
if (isSDKResultMessage(message)) {
|
||||
resultWaiter.notifyResult();
|
||||
}
|
||||
}
|
||||
|
|
@ -440,7 +440,9 @@ describe('Permission Control (E2E)', () => {
|
|||
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) {
|
||||
if (isSDKResultMessage(message)) {
|
||||
// Resolve on result (one per turn), not assistant message
|
||||
// (which may fire multiple times per turn: thinking + text)
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
|
|
@ -448,8 +450,6 @@ describe('Permission Control (E2E)', () => {
|
|||
secondResponseReceived = true;
|
||||
resolvers.second?.();
|
||||
}
|
||||
}
|
||||
if (isSDKResultMessage(message)) {
|
||||
resultWaiter.notifyResult();
|
||||
}
|
||||
}
|
||||
|
|
@ -460,7 +460,7 @@ describe('Permission Control (E2E)', () => {
|
|||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for first response')),
|
||||
10000,
|
||||
TEST_TIMEOUT,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
|
@ -476,7 +476,7 @@ describe('Permission Control (E2E)', () => {
|
|||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout waiting for second response')),
|
||||
10000,
|
||||
TEST_TIMEOUT,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
|
@ -521,7 +521,9 @@ describe('Permission Control (E2E)', () => {
|
|||
|
||||
(async () => {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message) || isSDKResultMessage(message)) {
|
||||
if (isSDKResultMessage(message)) {
|
||||
// Resolve on result (one per turn), not assistant message
|
||||
// (which may fire multiple times per turn: thinking + text)
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
|
|
@ -529,8 +531,6 @@ describe('Permission Control (E2E)', () => {
|
|||
secondResponseReceived = true;
|
||||
resolvers.second?.();
|
||||
}
|
||||
}
|
||||
if (isSDKResultMessage(message)) {
|
||||
resultWaiter.notifyResult();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
isSDKAssistantMessage,
|
||||
isSDKSystemMessage,
|
||||
isSDKResultMessage,
|
||||
type SDKUserMessage,
|
||||
|
|
@ -18,7 +17,9 @@ import {
|
|||
} from './test-helper.js';
|
||||
|
||||
const SHARED_TEST_OPTIONS = createSharedTestOptions();
|
||||
const MODEL_RESPONSE_TIMEOUT_MS = process.env['CI'] ? 30000 : 15000;
|
||||
// Per-turn cap. CI model responses can exceed 30s under load, and the
|
||||
// suite budget is 5 minutes, so give each turn more of that headroom.
|
||||
const MODEL_RESPONSE_TIMEOUT_MS = process.env['CI'] ? 60000 : 15000;
|
||||
|
||||
/**
|
||||
* Factory function that creates a streaming input with a control point.
|
||||
|
|
@ -139,8 +140,8 @@ describe('System Control (E2E)', () => {
|
|||
}
|
||||
if (isSDKResultMessage(message)) {
|
||||
resultWaiter.notifyResult();
|
||||
}
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
// Resolve on result (one per turn), not assistant message
|
||||
// (which may fire multiple times per turn: thinking + text)
|
||||
if (!firstResponseReceived) {
|
||||
firstResponseReceived = true;
|
||||
resolvers.first?.();
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ import { TurnBoundaryCompactionEngine } from './compactionEngine.js';
|
|||
import {
|
||||
CHANNEL_STARTUP_PROFILE_META_KEY,
|
||||
CHANNEL_STARTUP_PROFILE_VERSION,
|
||||
WORKTREE_MCP_DEFER_META_KEY,
|
||||
LOAD_REPLAY_HIDE_INHERITED_META_KEY,
|
||||
} from './bridgeTypes.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
|
|
@ -1047,6 +1049,24 @@ describe('createAcpSessionBridge', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('marks worktree session creation to defer MCP discovery', async () => {
|
||||
const handle = makeChannel();
|
||||
const bridge = makeBridge({
|
||||
sessionScope: 'thread',
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
|
||||
await bridge.spawnOrAttach({
|
||||
workspaceCwd: WS_A,
|
||||
worktree: { slug: 'task-a', path: WS_B, branch: 'worktree-task-a' },
|
||||
});
|
||||
|
||||
expect(handle.agent.newSessionCalls[0]?._meta).toMatchObject({
|
||||
[WORKTREE_MCP_DEFER_META_KEY]: true,
|
||||
});
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('does not fail initialization when span enrichment throws', async () => {
|
||||
const handle = makeChannel({
|
||||
initializeImpl: async () => ({
|
||||
|
|
@ -2040,14 +2060,16 @@ describe('createAcpSessionBridge', () => {
|
|||
|
||||
it('refreshes extensions across live sessions and broadcasts merged results', async () => {
|
||||
const handles: ChannelHandle[] = [];
|
||||
let failNextExtensionRefresh = true;
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => {
|
||||
const h = makeChannel({
|
||||
extMethodImpl: (method, params) => {
|
||||
extMethodImpl: (method) => {
|
||||
if (
|
||||
method === 'qwen/control/workspace/extensions/refresh' &&
|
||||
String(params['sessionId']).endsWith('#2')
|
||||
failNextExtensionRefresh
|
||||
) {
|
||||
failNextExtensionRefresh = false;
|
||||
throw new Error('refresh failed');
|
||||
}
|
||||
return {};
|
||||
|
|
@ -2082,6 +2104,13 @@ describe('createAcpSessionBridge', () => {
|
|||
method: 'qwen/control/workspace/extensions/refresh',
|
||||
params: { sessionId: first.sessionId },
|
||||
},
|
||||
{
|
||||
method: 'qwen/control/workspace/extensions/refresh',
|
||||
params: {
|
||||
sessionId: second.sessionId,
|
||||
refreshBootstrap: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'qwen/control/workspace/extensions/refresh',
|
||||
params: { sessionId: second.sessionId },
|
||||
|
|
@ -10849,6 +10878,103 @@ describe('createAcpSessionBridge', () => {
|
|||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('creates a side task with hidden inherited replay', async () => {
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: async (method) => {
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionSideTask) {
|
||||
return { newSessionId: 'side-1', title: 'Side task' };
|
||||
}
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionSource) {
|
||||
return { persisted: true };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
resumeSessionImpl: () => ({}),
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
const parent = await bridge.spawnOrAttach({
|
||||
workspaceCwd: WS_A,
|
||||
sessionScope: 'thread',
|
||||
});
|
||||
|
||||
const sideTask = await bridge.createSideTaskSession(parent.sessionId, {
|
||||
name: 'Side task',
|
||||
});
|
||||
|
||||
expect(sideTask).toMatchObject({
|
||||
sessionId: 'side-1',
|
||||
sourceType: 'side_task',
|
||||
sourceId: parent.sessionId,
|
||||
sourcePersisted: true,
|
||||
parentSessionId: parent.sessionId,
|
||||
});
|
||||
expect(bridge.getSessionSummary(sideTask.sessionId)).toMatchObject({
|
||||
sourceType: 'side_task',
|
||||
sourceId: parent.sessionId,
|
||||
});
|
||||
expect(handle.agent.extMethodCalls).toContainEqual({
|
||||
method: SERVE_CONTROL_EXT_METHODS.sessionSource,
|
||||
params: {
|
||||
sessionId: sideTask.sessionId,
|
||||
sourceType: 'side_task',
|
||||
sourceId: parent.sessionId,
|
||||
},
|
||||
});
|
||||
expect(handle.agent.loadSessionCalls[0]?._meta).toMatchObject({
|
||||
[LOAD_REPLAY_HIDE_INHERITED_META_KEY]: true,
|
||||
});
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('creates a side task while the parent prompt is active', async () => {
|
||||
const promptGate = deferred<void>();
|
||||
const handle = makeChannel({
|
||||
promptImpl: async () => {
|
||||
await promptGate.promise;
|
||||
return { stopReason: 'end_turn' };
|
||||
},
|
||||
extMethodImpl: async (method) => {
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionSideTask) {
|
||||
return { newSessionId: 'side-active', title: 'Side task' };
|
||||
}
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionSource) {
|
||||
return { persisted: true };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
resumeSessionImpl: () => ({}),
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
const parent = await bridge.spawnOrAttach({
|
||||
workspaceCwd: WS_A,
|
||||
sessionScope: 'thread',
|
||||
});
|
||||
const prompt = bridge.sendPrompt(parent.sessionId, {
|
||||
sessionId: parent.sessionId,
|
||||
prompt: [{ type: 'text', text: 'keep working' }],
|
||||
});
|
||||
await vi.waitFor(() => expect(handle.agent.promptCalls).toHaveLength(1));
|
||||
|
||||
await expect(
|
||||
bridge.createSideTaskSession(parent.sessionId, { name: 'Side task' }),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: 'side-active',
|
||||
parentSessionId: parent.sessionId,
|
||||
});
|
||||
expect(bridge.getSessionSummary(parent.sessionId)).toMatchObject({
|
||||
hasActivePrompt: true,
|
||||
});
|
||||
|
||||
promptGate.resolve();
|
||||
await prompt;
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('carries persisted source metadata into ACP session restore', async () => {
|
||||
for (const action of ['load', 'resume'] as const) {
|
||||
const handle = makeChannel();
|
||||
|
|
@ -11541,6 +11667,15 @@ describe('createAcpSessionBridge', () => {
|
|||
aborted: false,
|
||||
});
|
||||
expect(shellSpy).toHaveBeenCalledTimes(1);
|
||||
expect(shellSpy).toHaveBeenCalledWith(
|
||||
'echo hello',
|
||||
WS_A,
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{ terminalWidth: 120, terminalHeight: 40 },
|
||||
{ streamStdout: true },
|
||||
);
|
||||
const it = events[Symbol.asyncIterator]();
|
||||
const first = await it.next();
|
||||
expect(first.value?.type).toBe('user_shell_command');
|
||||
|
|
@ -11550,6 +11685,224 @@ describe('createAcpSessionBridge', () => {
|
|||
await bridge.shutdown();
|
||||
shellSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('executes direct shell in each session effective cwd', async () => {
|
||||
const shellSpy = mockShellExecute();
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: async (method, params) => {
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionCd) {
|
||||
return {
|
||||
previousCwd: WS_A,
|
||||
newCwd: (params as { path: string }).path,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
sessionShellCommandEnabled: true,
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
const firstSession = await bridge.spawnOrAttach({
|
||||
workspaceCwd: WS_A,
|
||||
sessionScope: 'thread',
|
||||
});
|
||||
const secondSession = await bridge.spawnOrAttach({
|
||||
workspaceCwd: WS_A,
|
||||
sessionScope: 'thread',
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
bridge.changeSessionCwd(firstSession.sessionId, { path: WS_A }),
|
||||
bridge.changeSessionCwd(secondSession.sessionId, { path: WS_B }),
|
||||
]);
|
||||
await Promise.all([
|
||||
bridge.executeShellCommand(
|
||||
firstSession.sessionId,
|
||||
'echo first',
|
||||
undefined,
|
||||
{ clientId: firstSession.clientId },
|
||||
),
|
||||
bridge.executeShellCommand(
|
||||
secondSession.sessionId,
|
||||
'echo second',
|
||||
undefined,
|
||||
{ clientId: secondSession.clientId },
|
||||
),
|
||||
]);
|
||||
|
||||
expect(shellSpy).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
shellSpy.mock.calls.map(([command, cwd]) => [command, cwd]),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
['echo first', WS_A],
|
||||
['echo second', WS_B],
|
||||
]),
|
||||
);
|
||||
|
||||
await bridge.shutdown();
|
||||
shellSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('waits for a pending cwd change before executing direct shell', async () => {
|
||||
const shellSpy = mockShellExecute();
|
||||
const cdResult = deferred<{
|
||||
previousCwd: string;
|
||||
newCwd: string;
|
||||
warnings: string[];
|
||||
}>();
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: async (method) => {
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionCd) {
|
||||
return cdResult.promise;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
sessionShellCommandEnabled: true,
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
const cd = bridge.changeSessionCwd(session.sessionId, { path: WS_B });
|
||||
await vi.waitFor(() =>
|
||||
expect(handle.agent.extMethodCalls).toContainEqual({
|
||||
method: SERVE_CONTROL_EXT_METHODS.sessionCd,
|
||||
params: {
|
||||
sessionId: session.sessionId,
|
||||
path: WS_B,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const shell = bridge.executeShellCommand(
|
||||
session.sessionId,
|
||||
'echo after-cd',
|
||||
undefined,
|
||||
{ clientId: session.clientId },
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(shellSpy).not.toHaveBeenCalled();
|
||||
cdResult.resolve({ previousCwd: WS_A, newCwd: WS_B, warnings: [] });
|
||||
await Promise.all([cd, shell]);
|
||||
|
||||
expect(shellSpy.mock.calls[0]?.[1]).toBe(WS_B);
|
||||
|
||||
await bridge.shutdown();
|
||||
shellSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('executes direct shell in previous cwd when a pending cd fails', async () => {
|
||||
const shellSpy = mockShellExecute();
|
||||
const cdResult = deferred<{
|
||||
previousCwd: string;
|
||||
newCwd: string;
|
||||
warnings: string[];
|
||||
}>();
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: async (method) => {
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionCd) {
|
||||
return cdResult.promise;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
sessionShellCommandEnabled: true,
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
const cd = bridge.changeSessionCwd(session.sessionId, { path: WS_B });
|
||||
await vi.waitFor(() =>
|
||||
expect(handle.agent.extMethodCalls).toContainEqual({
|
||||
method: SERVE_CONTROL_EXT_METHODS.sessionCd,
|
||||
params: {
|
||||
sessionId: session.sessionId,
|
||||
path: WS_B,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const shell = bridge.executeShellCommand(
|
||||
session.sessionId,
|
||||
'echo after-failed-cd',
|
||||
undefined,
|
||||
{ clientId: session.clientId },
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(shellSpy).not.toHaveBeenCalled();
|
||||
cdResult.reject(new Error('cd failed'));
|
||||
await expect(cd).rejects.toThrow();
|
||||
await shell;
|
||||
|
||||
expect(shellSpy.mock.calls[0]?.[1]).toBe(WS_A);
|
||||
|
||||
await bridge.shutdown();
|
||||
shellSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns an aborted direct shell without waiting for a hung cwd change', async () => {
|
||||
const shellSpy = mockShellExecute();
|
||||
const cdResult = deferred<{
|
||||
previousCwd: string;
|
||||
newCwd: string;
|
||||
warnings: string[];
|
||||
}>();
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: async (method) => {
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionCd) {
|
||||
return cdResult.promise;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({
|
||||
sessionShellCommandEnabled: true,
|
||||
channelFactory: async () => handle.channel,
|
||||
});
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
const cd = bridge.changeSessionCwd(session.sessionId, { path: WS_B });
|
||||
await vi.waitFor(() =>
|
||||
expect(handle.agent.extMethodCalls).toContainEqual({
|
||||
method: SERVE_CONTROL_EXT_METHODS.sessionCd,
|
||||
params: {
|
||||
sessionId: session.sessionId,
|
||||
path: WS_B,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const abort = new AbortController();
|
||||
const shell = bridge.executeShellCommand(
|
||||
session.sessionId,
|
||||
'echo aborted-cd',
|
||||
abort.signal,
|
||||
{ clientId: session.clientId },
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(shellSpy).not.toHaveBeenCalled();
|
||||
abort.abort();
|
||||
|
||||
// The cd extMethod never settles, yet the aborted command must return
|
||||
// promptly instead of parking on the cwd queue forever.
|
||||
await expect(shell).resolves.toEqual({
|
||||
exitCode: null,
|
||||
output: '',
|
||||
aborted: true,
|
||||
});
|
||||
expect(shellSpy).not.toHaveBeenCalled();
|
||||
|
||||
cdResult.resolve({ previousCwd: WS_A, newCwd: WS_B, warnings: [] });
|
||||
await cd;
|
||||
await bridge.shutdown();
|
||||
shellSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSessionApprovalMode (#4175 Wave 4 PR 17)', () => {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ import {
|
|||
CHANNEL_STARTUP_PROFILE_VERSION,
|
||||
DAEMON_CHANNEL_DELIVERY_META_KEY,
|
||||
LOAD_REPLAY_BULK_MODE,
|
||||
LOAD_REPLAY_HIDE_INHERITED_META_KEY,
|
||||
LOAD_REPLAY_META_KEY,
|
||||
LOAD_REPLAY_MODE_META_KEY,
|
||||
LOAD_REPLAY_PAGE_SIZE_META_KEY,
|
||||
|
|
@ -110,6 +111,7 @@ import {
|
|||
PROMPT_CANCEL_METHOD,
|
||||
REQUESTED_SESSION_ID_META_KEY,
|
||||
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
|
||||
WORKTREE_MCP_DEFER_META_KEY,
|
||||
} from './bridgeTypes.js';
|
||||
import { getChannelStartupProfileAttributes } from './channel-startup-profile.js';
|
||||
import type {
|
||||
|
|
@ -471,6 +473,7 @@ interface ChannelInfo {
|
|||
interface SessionEntry {
|
||||
sessionId: string;
|
||||
workspaceCwd: string;
|
||||
effectiveCwd: string;
|
||||
createdAt: string;
|
||||
displayName?: string;
|
||||
/** Id of the session that spawned this one (via `create_sub_session`).
|
||||
|
|
@ -494,6 +497,8 @@ interface SessionEntry {
|
|||
recordingDegraded: boolean;
|
||||
/** Set synchronously while agent-owned state and its writer lease close. */
|
||||
closing: boolean;
|
||||
/** Tail of cwd changes that direct shell commands must not overtake. */
|
||||
cwdChangeQueue: Promise<void>;
|
||||
/**
|
||||
* Tail of the per-session prompt queue. Each new prompt chains off the
|
||||
* resolved (or rejected) state of this promise so prompts run one at a
|
||||
|
|
@ -1914,7 +1919,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
>();
|
||||
const inFlightExtensionRefreshes = new Map<
|
||||
string,
|
||||
{ connection: ClientSideConnection; promise: Promise<void> }
|
||||
{
|
||||
connection: ClientSideConnection;
|
||||
promise: Promise<void>;
|
||||
refreshBootstrap: boolean;
|
||||
}
|
||||
>();
|
||||
const toSessionSummary = (entry: SessionEntry): BridgeSessionSummary => {
|
||||
let isWaitingForPermission = false;
|
||||
|
|
@ -2032,6 +2041,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
interface InFlightRestore {
|
||||
action: 'load' | 'resume';
|
||||
historyReplay: 'stream' | 'response';
|
||||
hideInheritedHistory: boolean;
|
||||
promise: Promise<BridgeRestoredSession>;
|
||||
/**
|
||||
* Synchronous reservation slot for callers that coalesce onto this
|
||||
|
|
@ -2647,25 +2657,33 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
async () => {
|
||||
// This legacy-named helper sanitizes and injects trace metadata
|
||||
// for any ACP request, not only prompts.
|
||||
const request = telemetry.injectPromptContext({
|
||||
cwd: boundWorkspace,
|
||||
mcpServers: [],
|
||||
...(requestedSessionId || sourceType
|
||||
? {
|
||||
_meta: {
|
||||
...sessionSourceRequestMeta(sourceType, sourceId),
|
||||
...(requestedSessionId
|
||||
? {
|
||||
[REQUESTED_SESSION_ID_META_KEY]: requestedSessionId,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const response = await withTimeout(
|
||||
ci.connection.newSession(
|
||||
telemetry.injectPromptContext({
|
||||
cwd: boundWorkspace,
|
||||
mcpServers: [],
|
||||
...(requestedSessionId || sourceType
|
||||
? {
|
||||
_meta: {
|
||||
...sessionSourceRequestMeta(sourceType, sourceId),
|
||||
...(requestedSessionId
|
||||
? {
|
||||
[REQUESTED_SESSION_ID_META_KEY]:
|
||||
requestedSessionId,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
worktree
|
||||
? {
|
||||
...request,
|
||||
_meta: {
|
||||
...(isRecord(request._meta) ? request._meta : {}),
|
||||
[WORKTREE_MCP_DEFER_META_KEY]: true,
|
||||
},
|
||||
}
|
||||
: request,
|
||||
),
|
||||
initTimeoutMs,
|
||||
'newSession',
|
||||
|
|
@ -3889,6 +3907,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
const entry: SessionEntry = {
|
||||
sessionId,
|
||||
workspaceCwd,
|
||||
effectiveCwd: workspaceCwd,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(options.parentSessionId
|
||||
? { parentSessionId: options.parentSessionId }
|
||||
|
|
@ -3907,6 +3926,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
}),
|
||||
recordingDegraded: false,
|
||||
closing: false,
|
||||
cwdChangeQueue: Promise.resolve(),
|
||||
promptQueue: Promise.resolve(),
|
||||
pendingPromptCount: 0,
|
||||
pendingPromptList: [],
|
||||
|
|
@ -4401,6 +4421,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
}
|
||||
const historyReplay =
|
||||
action === 'load' ? (req.historyReplay ?? 'stream') : 'stream';
|
||||
const hideInheritedHistory =
|
||||
action === 'load' && req.hideInheritedHistory === true;
|
||||
|
||||
const existing = byId.get(req.sessionId);
|
||||
if (existing) {
|
||||
|
|
@ -4462,7 +4484,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
// missing snapshot. Same-action coalescing is unaffected.
|
||||
if (
|
||||
action !== inFlight.action ||
|
||||
historyReplay !== inFlight.historyReplay
|
||||
historyReplay !== inFlight.historyReplay ||
|
||||
hideInheritedHistory !== inFlight.hideInheritedHistory
|
||||
) {
|
||||
throw new RestoreInProgressError(
|
||||
req.sessionId,
|
||||
|
|
@ -4594,7 +4617,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
// intentionally has no `mcpServers` field for the
|
||||
// same reason.
|
||||
mcpServers: [],
|
||||
...(historyReplay === 'response' || req.sourceType
|
||||
...(historyReplay === 'response' ||
|
||||
hideInheritedHistory ||
|
||||
req.sourceType
|
||||
? {
|
||||
_meta: {
|
||||
...sessionSourceRequestMeta(
|
||||
|
|
@ -4613,6 +4638,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(hideInheritedHistory
|
||||
? {
|
||||
[LOAD_REPLAY_HIDE_INHERITED_META_KEY]: true,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
@ -4856,6 +4886,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
inFlightRestores.set(req.sessionId, {
|
||||
action,
|
||||
historyReplay,
|
||||
hideInheritedHistory,
|
||||
promise,
|
||||
coalesceState,
|
||||
});
|
||||
|
|
@ -6237,14 +6268,22 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
|
||||
const entry = byId.get(sessionId);
|
||||
if (!entry) throw new SessionNotFoundError(sessionId);
|
||||
const source = parseSessionSource(req.sourceType, req.sourceId);
|
||||
if ('error' in source) {
|
||||
throw new InvalidSessionMetadataError('sourceType', source.error);
|
||||
}
|
||||
const isSideTask = source.sourceType === 'side_task';
|
||||
|
||||
let originatorClientId: string | undefined;
|
||||
if (context?.clientId !== undefined) {
|
||||
originatorClientId = resolveTrustedClientId(entry, context.clientId);
|
||||
}
|
||||
|
||||
const branchResult = entry.promptQueue.then(async () => {
|
||||
if (entry.promptActive) {
|
||||
const concurrentSideTask = isSideTask && entry.promptActive;
|
||||
const branchResult = (
|
||||
concurrentSideTask ? Promise.resolve() : entry.promptQueue
|
||||
).then(async () => {
|
||||
if (entry.promptActive && !isSideTask) {
|
||||
throw new BranchWhilePromptActiveError(sessionId);
|
||||
}
|
||||
|
||||
|
|
@ -6269,13 +6308,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
try {
|
||||
const ci = await ensureChannel();
|
||||
const result = (await withTimeout(
|
||||
ci.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionBranch, {
|
||||
sessionId,
|
||||
cwd: boundWorkspace,
|
||||
name: req.name,
|
||||
}),
|
||||
ci.connection.extMethod(
|
||||
isSideTask
|
||||
? SERVE_CONTROL_EXT_METHODS.sessionSideTask
|
||||
: SERVE_CONTROL_EXT_METHODS.sessionBranch,
|
||||
{
|
||||
sessionId,
|
||||
cwd: boundWorkspace,
|
||||
name: req.name,
|
||||
},
|
||||
),
|
||||
initTimeoutMs,
|
||||
'branchSession',
|
||||
isSideTask ? 'createSideTaskSession' : 'branchSession',
|
||||
)) as { newSessionId: string; title?: string; displayName?: string };
|
||||
|
||||
if (!result || typeof result.newSessionId !== 'string') {
|
||||
|
|
@ -6291,12 +6335,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
|
||||
let restored;
|
||||
try {
|
||||
const hideInheritedHistory = req.replayInheritedHistory === false;
|
||||
restored = await restoreSession(
|
||||
'load',
|
||||
{
|
||||
sessionId: result.newSessionId,
|
||||
workspaceCwd: boundWorkspace,
|
||||
clientId: context?.clientId,
|
||||
...(hideInheritedHistory
|
||||
? {
|
||||
historyReplay: 'response',
|
||||
hideInheritedHistory: true,
|
||||
}
|
||||
: {}),
|
||||
...source,
|
||||
},
|
||||
{
|
||||
skipFreshSessionAdmission: true,
|
||||
|
|
@ -6322,20 +6374,50 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
|
||||
const newEntry = byId.get(result.newSessionId);
|
||||
if (newEntry) newEntry.displayName = branchDisplayName;
|
||||
let sourcePersisted: boolean | undefined;
|
||||
if (newEntry?.sourceType) {
|
||||
try {
|
||||
const sourceResult = await withTimeout(
|
||||
newEntry.connection.extMethod(
|
||||
SERVE_CONTROL_EXT_METHODS.sessionSource,
|
||||
{
|
||||
sessionId: newEntry.sessionId,
|
||||
sourceType: newEntry.sourceType,
|
||||
...(newEntry.sourceId !== undefined
|
||||
? { sourceId: newEntry.sourceId }
|
||||
: {}),
|
||||
},
|
||||
),
|
||||
initTimeoutMs,
|
||||
'sessionSource',
|
||||
);
|
||||
sourcePersisted =
|
||||
(sourceResult as { persisted?: boolean } | undefined)
|
||||
?.persisted === true;
|
||||
} catch (error) {
|
||||
sourcePersisted = false;
|
||||
writeStderrLine(
|
||||
`qwen serve: source metadata for branched session ${result.newSessionId} was not persisted: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const eventData = {
|
||||
sourceSessionId: sessionId,
|
||||
newSessionId: result.newSessionId,
|
||||
displayName: branchDisplayName,
|
||||
};
|
||||
const branchEnvelope = {
|
||||
type: 'session_branched' as const,
|
||||
data: eventData,
|
||||
...(originatorClientId ? { originatorClientId } : {}),
|
||||
};
|
||||
// The branch announcement belongs to the new session only. Publishing
|
||||
// it on the source session would persist in that session's replay ring.
|
||||
newEntry?.events.publish(branchEnvelope);
|
||||
if (!isSideTask) {
|
||||
const eventData = {
|
||||
sourceSessionId: sessionId,
|
||||
newSessionId: result.newSessionId,
|
||||
displayName: branchDisplayName,
|
||||
};
|
||||
const branchEnvelope = {
|
||||
type: 'session_branched' as const,
|
||||
data: eventData,
|
||||
...(originatorClientId ? { originatorClientId } : {}),
|
||||
};
|
||||
// The branch announcement belongs to the new session only.
|
||||
newEntry?.events.publish(branchEnvelope);
|
||||
}
|
||||
|
||||
return {
|
||||
...restored,
|
||||
|
|
@ -6344,18 +6426,39 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
sessionId,
|
||||
displayName: entry.displayName ?? sessionId.slice(0, 8),
|
||||
},
|
||||
...(sourcePersisted !== undefined ? { sourcePersisted } : {}),
|
||||
};
|
||||
} finally {
|
||||
releaseAdmissionOnce();
|
||||
}
|
||||
});
|
||||
entry.promptQueue = branchResult.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
if (!concurrentSideTask) {
|
||||
entry.promptQueue = branchResult.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
return branchResult;
|
||||
},
|
||||
|
||||
async createSideTaskSession(sessionId, req, context) {
|
||||
const result = await this.branchSession(
|
||||
sessionId,
|
||||
{
|
||||
name: req.name,
|
||||
sourceType: 'side_task',
|
||||
sourceId: sessionId,
|
||||
replayInheritedHistory: false,
|
||||
},
|
||||
context,
|
||||
);
|
||||
const { forkedFrom: _forkedFrom, ...sideTask } = result;
|
||||
return {
|
||||
...sideTask,
|
||||
parentSessionId: sessionId,
|
||||
};
|
||||
},
|
||||
|
||||
async changeSessionCwd(
|
||||
sessionId: string,
|
||||
req: ChangeSessionCwdRequest,
|
||||
|
|
@ -6405,6 +6508,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
|
||||
// State update inside the queue lambda — always executes when
|
||||
// the extMethod settles, regardless of caller timeout.
|
||||
entry.effectiveCwd = extResult.newCwd;
|
||||
if (extResult.previousCwd !== extResult.newCwd) {
|
||||
entry.events.publish({
|
||||
type: 'session_cwd_changed',
|
||||
|
|
@ -6426,6 +6530,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
entry.cwdChangeQueue = cdPromise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
// Timeout is caller-facing only: surfaces a deadline exceeded error
|
||||
// to the HTTP client without advancing the queue prematurely.
|
||||
|
|
@ -7115,52 +7223,100 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
|
||||
async refreshExtensionsForAllSessions(data) {
|
||||
const sessions = Array.from(byId.values());
|
||||
const bootstrapRefreshConnections = new Set<
|
||||
(typeof sessions)[number]['connection']
|
||||
>();
|
||||
const refreshSession = async (
|
||||
entry: (typeof sessions)[number],
|
||||
refreshBootstrap: boolean,
|
||||
) => {
|
||||
let inFlight = inFlightExtensionRefreshes.get(entry.sessionId);
|
||||
if (
|
||||
!inFlight ||
|
||||
inFlight.connection !== entry.connection ||
|
||||
(refreshBootstrap && !inFlight.refreshBootstrap)
|
||||
) {
|
||||
const promise = (async () => {
|
||||
await entry.connection.extMethod(
|
||||
SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh,
|
||||
{
|
||||
sessionId: entry.sessionId,
|
||||
...(refreshBootstrap ? {} : { refreshBootstrap: false }),
|
||||
},
|
||||
);
|
||||
})();
|
||||
inFlight = {
|
||||
connection: entry.connection,
|
||||
promise,
|
||||
refreshBootstrap,
|
||||
};
|
||||
inFlightExtensionRefreshes.set(entry.sessionId, inFlight);
|
||||
const clear = () => {
|
||||
if (inFlightExtensionRefreshes.get(entry.sessionId) === inFlight) {
|
||||
inFlightExtensionRefreshes.delete(entry.sessionId);
|
||||
}
|
||||
};
|
||||
void promise.then(clear, clear);
|
||||
}
|
||||
await Promise.race([
|
||||
withTimeout(
|
||||
inFlight.promise,
|
||||
30_000,
|
||||
SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh,
|
||||
),
|
||||
getTransportClosedReject(entry),
|
||||
]);
|
||||
};
|
||||
|
||||
const results = await Promise.all(
|
||||
sessions.map(async (entry) => {
|
||||
const info = channelInfoForEntry(entry);
|
||||
if (!info || info.isDying) {
|
||||
return { refreshed: 0, failed: 0 };
|
||||
return {
|
||||
refreshed: 0,
|
||||
failed: 0,
|
||||
entry,
|
||||
refreshBootstrap: false,
|
||||
};
|
||||
}
|
||||
const refreshBootstrap = !bootstrapRefreshConnections.has(
|
||||
entry.connection,
|
||||
);
|
||||
bootstrapRefreshConnections.add(entry.connection);
|
||||
try {
|
||||
let inFlight = inFlightExtensionRefreshes.get(entry.sessionId);
|
||||
if (!inFlight || inFlight.connection !== entry.connection) {
|
||||
const promise = (async () => {
|
||||
await entry.connection.extMethod(
|
||||
SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh,
|
||||
{ sessionId: entry.sessionId },
|
||||
);
|
||||
})();
|
||||
inFlight = { connection: entry.connection, promise };
|
||||
inFlightExtensionRefreshes.set(entry.sessionId, inFlight);
|
||||
const clear = () => {
|
||||
if (
|
||||
inFlightExtensionRefreshes.get(entry.sessionId) === inFlight
|
||||
) {
|
||||
inFlightExtensionRefreshes.delete(entry.sessionId);
|
||||
}
|
||||
};
|
||||
void promise.then(clear, clear);
|
||||
}
|
||||
await Promise.race([
|
||||
withTimeout(
|
||||
inFlight.promise,
|
||||
30_000,
|
||||
SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh,
|
||||
),
|
||||
getTransportClosedReject(entry),
|
||||
]);
|
||||
return { refreshed: 1, failed: 0 };
|
||||
await refreshSession(entry, refreshBootstrap);
|
||||
return { refreshed: 1, failed: 0, entry, refreshBootstrap };
|
||||
} catch (err) {
|
||||
writeServeDebugLine(
|
||||
`refreshExtensions: session ${entry.sessionId} failed: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return { refreshed: 0, failed: 1 };
|
||||
return { refreshed: 0, failed: 1, entry, refreshBootstrap };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
results
|
||||
.filter((result) => result.failed > 0 && result.refreshBootstrap)
|
||||
.map(async (failedBootstrap) => {
|
||||
const retry = results.find(
|
||||
(result) =>
|
||||
result.refreshed > 0 &&
|
||||
result.entry.connection === failedBootstrap.entry.connection,
|
||||
);
|
||||
if (!retry) return;
|
||||
try {
|
||||
await refreshSession(retry.entry, true);
|
||||
} catch (err) {
|
||||
writeServeDebugLine(
|
||||
`refreshExtensions: bootstrap retry via session ${retry.entry.sessionId} failed: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const refreshed = results.reduce(
|
||||
(sum, result) => sum + result.refreshed,
|
||||
0,
|
||||
|
|
@ -7784,7 +7940,27 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
return { exitCode: null, output: '', aborted: true };
|
||||
}
|
||||
|
||||
const cwd = entry.workspaceCwd;
|
||||
// Race the cwd queue against the caller's abort signal so a shell
|
||||
// command cannot park forever on a changeSessionCwd extMethod that
|
||||
// never settles (agent crash / deadlock / partitioned ACP channel).
|
||||
let abortResolve: (() => void) | undefined;
|
||||
const onAbort = () => abortResolve?.();
|
||||
try {
|
||||
await Promise.race([
|
||||
entry.cwdChangeQueue,
|
||||
new Promise<void>((resolve) => {
|
||||
abortResolve = resolve;
|
||||
if (signal?.aborted) return resolve();
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return { exitCode: null, output: '', aborted: true };
|
||||
}
|
||||
const cwd = entry.effectiveCwd;
|
||||
|
||||
entry.events.publish({
|
||||
type: 'user_shell_command',
|
||||
|
|
|
|||
|
|
@ -155,6 +155,8 @@ export interface BridgeRestoreSessionRequest {
|
|||
historyReplay?: 'stream' | 'response';
|
||||
/** Optional newest persisted-record page requested for response replay. */
|
||||
historyPageSize?: number;
|
||||
/** Keep inherited fork records as model context without replaying them. */
|
||||
hideInheritedHistory?: boolean;
|
||||
approvalMode?: ApprovalMode;
|
||||
/**
|
||||
* Persisted parent lineage recovered from the transcript by the caller (the
|
||||
|
|
@ -173,6 +175,8 @@ export interface BridgeRestoreSessionRequest {
|
|||
export const LOAD_REPLAY_MODE_META_KEY = 'qwen.session.loadReplayMode';
|
||||
export const LOAD_REPLAY_META_KEY = 'qwen.session.loadReplay';
|
||||
export const LOAD_REPLAY_PAGE_SIZE_META_KEY = 'qwen.session.loadReplayPageSize';
|
||||
export const LOAD_REPLAY_HIDE_INHERITED_META_KEY =
|
||||
'qwen.session.loadReplayHideInherited';
|
||||
export const LOAD_REPLAY_BULK_MODE = 'bulk';
|
||||
export const LOAD_REPLAY_VERSION = 1 as const;
|
||||
|
||||
|
|
@ -181,6 +185,7 @@ export const REQUESTED_SESSION_ID_META_KEY = 'qwen-code/sessionId';
|
|||
export const CHANNEL_STARTUP_PROFILE_META_KEY =
|
||||
'qwen.daemon.channelStartupProfile';
|
||||
export const CHANNEL_STARTUP_PROFILE_VERSION = 1 as const;
|
||||
export const WORKTREE_MCP_DEFER_META_KEY = 'qwen.session.deferMcpDiscovery';
|
||||
|
||||
export interface ChannelStartupProfileV1 {
|
||||
v: typeof CHANNEL_STARTUP_PROFILE_VERSION;
|
||||
|
|
@ -294,6 +299,9 @@ export interface BridgeSessionTranscriptPage {
|
|||
|
||||
export interface BridgeBranchSessionRequest {
|
||||
name?: string;
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
replayInheritedHistory?: boolean;
|
||||
}
|
||||
|
||||
export interface BridgeBranchedSession extends BridgeRestoredSession {
|
||||
|
|
@ -301,6 +309,15 @@ export interface BridgeBranchedSession extends BridgeRestoredSession {
|
|||
forkedFrom: { sessionId: string; displayName: string };
|
||||
}
|
||||
|
||||
export interface BridgeSideTaskSessionRequest {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface BridgeSideTaskSession extends BridgeRestoredSession {
|
||||
displayName: string;
|
||||
parentSessionId: string;
|
||||
}
|
||||
|
||||
export interface BridgeForkAgentResult {
|
||||
sessionId: string;
|
||||
description: string;
|
||||
|
|
@ -885,6 +902,13 @@ export interface AcpSessionBridge {
|
|||
context?: BridgeClientRequestContext,
|
||||
): Promise<BridgeBranchedSession>;
|
||||
|
||||
/** Create a persisted side task with a snapshot of the parent's context. */
|
||||
createSideTaskSession(
|
||||
sessionId: string,
|
||||
req: BridgeSideTaskSessionRequest,
|
||||
context?: BridgeClientRequestContext,
|
||||
): Promise<BridgeSideTaskSession>;
|
||||
|
||||
/**
|
||||
* Change the working directory of a live session. The session must be
|
||||
* idle (no active prompt). Chains onto `entry.promptQueue` and updates
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export const SERVE_CONTROL_EXT_METHODS = {
|
|||
sessionClose: 'qwen/control/session/close',
|
||||
sessionApprovalMode: 'qwen/control/session/approval_mode',
|
||||
sessionBranch: 'qwen/control/session/branch',
|
||||
sessionSideTask: 'qwen/control/session/side_task',
|
||||
sessionForkAgent: 'qwen/control/session/fork_agent',
|
||||
sessionRecap: 'qwen/control/session/recap',
|
||||
sessionGenerationStart: 'qwen/control/session/generation/start',
|
||||
|
|
@ -449,8 +450,13 @@ export interface ServeWorkspaceSkillStatus extends ServeStatusCell {
|
|||
export interface ServeWorkspaceSkillsRefreshResult {
|
||||
sessionsRefreshed: number;
|
||||
sessionsFailed: number;
|
||||
configsRefreshed?: number;
|
||||
configsFailed?: number;
|
||||
reason?: ServeWorkspaceSkillsRefreshReason;
|
||||
}
|
||||
|
||||
export type ServeWorkspaceSkillsRefreshReason = 'settings' | 'content' | 'all';
|
||||
|
||||
export interface ServeWorkspaceSkillsStatus {
|
||||
v: typeof STATUS_SCHEMA_VERSION;
|
||||
workspaceCwd: string;
|
||||
|
|
|
|||
|
|
@ -193,6 +193,135 @@ describe('createTranscriptReplayMachine', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
describe('UserPromptSubmit hook context provenance', () => {
|
||||
const tagged =
|
||||
'<qwen:user-prompt-submit-context>\ninjected hook context\n</qwen:user-prompt-submit-context>';
|
||||
|
||||
it('prefers displayText over the tag-strip fallback and keeps image parts', () => {
|
||||
// Without displayText the tag-strip path would also emit the middle
|
||||
// "expanded extra" text part. displayText must win, and the image
|
||||
// part must survive (the previous early-return path dropped it).
|
||||
const projected = updates(
|
||||
createTranscriptReplayMachine(),
|
||||
record('user-1', 'user', {
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
data: 'abc123',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
},
|
||||
{ text: 'my prompt' },
|
||||
{ text: 'expanded extra' },
|
||||
{ text: tagged },
|
||||
],
|
||||
},
|
||||
systemPayload: {
|
||||
displayText: 'my prompt',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(projected).toMatchObject([
|
||||
{
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: {
|
||||
type: 'image',
|
||||
data: 'abc123',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: 'my prompt' },
|
||||
},
|
||||
]);
|
||||
expect(projected).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('appends displayText after images when the record has no text part to replace', () => {
|
||||
// Exercises the !replaced fallback: after stripping the trailing tagged
|
||||
// block, only the image remains, so displayText is appended.
|
||||
const projected = updates(
|
||||
createTranscriptReplayMachine(),
|
||||
record('user-img-only', 'user', {
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
data: 'abc',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
},
|
||||
{ text: tagged },
|
||||
],
|
||||
},
|
||||
systemPayload: {
|
||||
displayText: 'my image prompt',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(projected).toMatchObject([
|
||||
{
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: {
|
||||
type: 'image',
|
||||
data: 'abc',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: 'my image prompt' },
|
||||
},
|
||||
]);
|
||||
expect(projected).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('strips a trailing whole-part tagged block when displayText is absent', () => {
|
||||
const projected = updates(
|
||||
createTranscriptReplayMachine(),
|
||||
record('user-2', 'user', {
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'my prompt' }, { text: tagged }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(projected).toMatchObject([
|
||||
{
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: 'my prompt' },
|
||||
},
|
||||
]);
|
||||
expect(projected).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps a sole part that matches the tag shape', () => {
|
||||
const projected = updates(
|
||||
createTranscriptReplayMachine(),
|
||||
record('user-3', 'user', {
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [{ text: tagged }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(projected).toMatchObject([
|
||||
{
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: tagged },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('projects ordered message parts with source metadata', () => {
|
||||
const machine = createTranscriptReplayMachine();
|
||||
const projected = updates(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import {
|
|||
projectGoalStateToLegacy,
|
||||
type GoalSnapshotV2,
|
||||
} from '@qwen-code/qwen-code-core/goalWire';
|
||||
// Narrow path — the helper is Node-free. Importing the core package barrel
|
||||
// here would pull the whole Node-bound core graph into the browser
|
||||
// transcript bundle (sdk-typescript daemon/transcript).
|
||||
import { stripTrailingUserPromptSubmitContextPart } from '@qwen-code/qwen-code-core/userPromptSubmitContext';
|
||||
|
||||
export const MISSING_TRANSCRIPT_TOOL_RESULT_MESSAGE =
|
||||
'Tool result missing from saved history; the previous run likely ended ' +
|
||||
|
|
@ -513,10 +517,101 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine {
|
|||
return;
|
||||
}
|
||||
if (record.subtype !== 'mid_turn_user_message') return;
|
||||
} else if (!record.subtype) {
|
||||
// Plain user records — including UserPromptSubmit-augmented ones —
|
||||
// prefer the recorded display projection, then strip a trailing
|
||||
// whole-part tagged hook-context block. Matches resumeHistoryUtils.
|
||||
// Always go through projectMessageParts so multimodal inlineData
|
||||
// (images) survives even when displayText replaces the text parts.
|
||||
const payload = isObjectRecord(record.systemPayload)
|
||||
? record.systemPayload
|
||||
: undefined;
|
||||
const displayText =
|
||||
payload && typeof payload['displayText'] === 'string'
|
||||
? payload['displayText']
|
||||
: undefined;
|
||||
yield* this.projectMessageParts(
|
||||
displayText
|
||||
? this.withUserPromptDisplayText(record, displayText)
|
||||
: this.withoutTrailingUserPromptSubmitContext(record),
|
||||
'user',
|
||||
emit,
|
||||
meta,
|
||||
);
|
||||
return;
|
||||
}
|
||||
yield* this.projectMessageParts(record, 'user', emit, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a trailing message part that is entirely a tagged UserPromptSubmit
|
||||
* context block. Injection always appends after the user's own part(s), so
|
||||
* a sole matching part is treated as user-authored and kept.
|
||||
*/
|
||||
private withoutTrailingUserPromptSubmitContext(
|
||||
record: TranscriptRecordInput,
|
||||
): TranscriptRecordInput {
|
||||
const parts = record.message?.parts;
|
||||
if (!Array.isArray(parts)) {
|
||||
return record;
|
||||
}
|
||||
const nextParts = stripTrailingUserPromptSubmitContextPart(parts);
|
||||
if (nextParts === parts) {
|
||||
return record;
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
message: {
|
||||
...record.message,
|
||||
parts: [...nextParts],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds a plain user record for display: strip trailing tagged hook
|
||||
* context, then replace every text part with a single `displayText` part at
|
||||
* the first text position so images keep their relative order.
|
||||
*/
|
||||
private withUserPromptDisplayText(
|
||||
record: TranscriptRecordInput,
|
||||
displayText: string,
|
||||
): TranscriptRecordInput {
|
||||
const stripped = this.withoutTrailingUserPromptSubmitContext(record);
|
||||
const parts = stripped.message?.parts;
|
||||
if (!Array.isArray(parts) || parts.length === 0) {
|
||||
return {
|
||||
...stripped,
|
||||
message: {
|
||||
...stripped.message,
|
||||
parts: [{ text: displayText }],
|
||||
},
|
||||
};
|
||||
}
|
||||
let replaced = false;
|
||||
const nextParts: unknown[] = [];
|
||||
for (const part of parts) {
|
||||
if (isObjectRecord(part) && typeof part['text'] === 'string') {
|
||||
if (!replaced) {
|
||||
nextParts.push({ text: displayText });
|
||||
replaced = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
nextParts.push(part);
|
||||
}
|
||||
if (!replaced) {
|
||||
nextParts.push({ text: displayText });
|
||||
}
|
||||
return {
|
||||
...stripped,
|
||||
message: {
|
||||
...stripped.message,
|
||||
parts: nextParts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private *projectAssistantRecord(
|
||||
record: TranscriptRecordInput,
|
||||
emit: (update: SessionUpdate) => TranscriptReplayEmission,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ export default defineConfig({
|
|||
__dirname,
|
||||
'../core/src/utils/transcript-records.ts',
|
||||
),
|
||||
'@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve(
|
||||
__dirname,
|
||||
'../core/src/hooks/user-prompt-submit-context.ts',
|
||||
),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
|
|
|||
|
|
@ -799,6 +799,7 @@ import {
|
|||
fetchAllowedGitHub,
|
||||
createWorkspaceMcpBudget,
|
||||
deliverClientMcpMessage,
|
||||
selectVisibleHistoryRecords,
|
||||
} from './acpAgent.js';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -868,6 +869,7 @@ import {
|
|||
CHANNEL_STARTUP_PROFILE_VERSION,
|
||||
PROMPT_CANCEL_METHOD,
|
||||
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
|
||||
WORKTREE_MCP_DEFER_META_KEY,
|
||||
} from '@qwen-code/acp-bridge/bridgeTypes';
|
||||
import {
|
||||
initializeAcpStartupProfiler,
|
||||
|
|
@ -3752,6 +3754,24 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
await agentPromise;
|
||||
});
|
||||
|
||||
it('defers MCP discovery for a worktree session until relocation', async () => {
|
||||
const innerConfig = await setupSessionMocks('worktree-mcp-session');
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
|
||||
await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
_meta: { [WORKTREE_MCP_DEFER_META_KEY]: true },
|
||||
});
|
||||
|
||||
expect(innerConfig.initialize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ skipMcpDiscovery: true }),
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('serializes a working-directory change and hard-suspends Todo Stop Guard', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
const targetDir = await fs.mkdtemp(
|
||||
|
|
@ -3797,6 +3817,45 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
await agentPromise;
|
||||
});
|
||||
|
||||
it('reports an MCP refresh warning after changing the working directory', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
const targetDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-mcp-refresh-cwd-'),
|
||||
);
|
||||
const canonicalTargetDir = await fs.realpath(targetDir);
|
||||
const innerConfig = await setupSessionMocks(sessionId);
|
||||
Object.assign(innerConfig, {
|
||||
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
isRestrictiveSandbox: vi.fn().mockReturnValue(false),
|
||||
relocateWorkingDirectory: vi.fn().mockResolvedValue({
|
||||
mcpRefreshError: new Error('MCP failed'),
|
||||
}),
|
||||
});
|
||||
Object.assign(innerConfig.getGeminiClient(), {
|
||||
addWorkingDirectoryChangedContext: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
const { agent, agentPromise } = await bootAcpAgent();
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionCd, {
|
||||
sessionId,
|
||||
path: targetDir,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
previousCwd: '/tmp',
|
||||
newCwd: canonicalTargetDir,
|
||||
warnings: ['MCP refresh failed: MCP failed'],
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('rechecks a no-op working-directory change after a concurrent relocation', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111';
|
||||
const oldDir = await fs.mkdtemp(
|
||||
|
|
@ -4269,7 +4328,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
? MCPServerStatus.DISCONNECTED
|
||||
: MCPServerStatus.CONNECTED,
|
||||
);
|
||||
const listSkills = vi.fn().mockResolvedValue([
|
||||
const cachedSkills = [
|
||||
{
|
||||
name: 'review',
|
||||
description: 'Review code',
|
||||
|
|
@ -4316,13 +4375,20 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
body: 'display stale body',
|
||||
filePath: '/ext/gsd-core/skills/gsd-display-stale/SKILL.md',
|
||||
},
|
||||
]);
|
||||
];
|
||||
const extensionRefreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
const skillRefreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
const getCachedSkills = vi.fn().mockReturnValue(cachedSkills);
|
||||
const refreshCacheIfSourcesChanged = vi.fn().mockResolvedValue(false);
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
getTargetDir: vi.fn().mockReturnValue('/work/status'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/work/status'),
|
||||
isSafeMode: vi.fn().mockReturnValue(false),
|
||||
getBareMode: vi.fn().mockReturnValue(false),
|
||||
getExtensionManager: vi.fn().mockReturnValue({
|
||||
refreshCache: vi.fn().mockResolvedValue(undefined),
|
||||
refreshCache: extensionRefreshCache,
|
||||
refreshCacheIfSourcesChanged,
|
||||
}),
|
||||
getMcpServers: vi.fn().mockReturnValue({
|
||||
docs: {
|
||||
|
|
@ -4354,8 +4420,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
.fn()
|
||||
.mockReturnValue(new Set(['disabled-skill'])),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
refreshCache: vi.fn().mockResolvedValue(undefined),
|
||||
listSkills,
|
||||
refreshCache: skillRefreshCache,
|
||||
getCachedSkills,
|
||||
}),
|
||||
getExtensions: vi.fn().mockReturnValue([
|
||||
{
|
||||
|
|
@ -4463,6 +4529,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
SERVE_STATUS_EXT_METHODS.workspaceSkills,
|
||||
{},
|
||||
)) as unknown as ServeWorkspaceSkillsStatus;
|
||||
const skillsAgain = (await agent.extMethod(
|
||||
SERVE_STATUS_EXT_METHODS.workspaceSkills,
|
||||
{},
|
||||
)) as unknown as ServeWorkspaceSkillsStatus;
|
||||
const providers = await agent.extMethod(
|
||||
SERVE_STATUS_EXT_METHODS.workspaceProviders,
|
||||
{},
|
||||
|
|
@ -4629,6 +4699,13 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
expect(
|
||||
skills.skills.filter((skill) => skill.name === 'gsd-config-only'),
|
||||
).toHaveLength(1);
|
||||
expect(skillsAgain).toEqual(skills);
|
||||
expect(getCachedSkills).toHaveBeenCalledTimes(2);
|
||||
// Each read validates that the extension sources have not moved, but an
|
||||
// unchanged answer must not cascade into an extension or skill refresh.
|
||||
expect(refreshCacheIfSourcesChanged).toHaveBeenCalledTimes(2);
|
||||
expect(extensionRefreshCache).not.toHaveBeenCalled();
|
||||
expect(skillRefreshCache).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(skills)).not.toContain('secret skill body');
|
||||
expect(JSON.stringify(skills)).not.toContain('manual secret body');
|
||||
expect(JSON.stringify(skills)).not.toContain('disabled secret body');
|
||||
|
|
@ -4670,6 +4747,191 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
await agentPromise;
|
||||
});
|
||||
|
||||
it('returns an uninitialized skills snapshot without warming a cold cache', async () => {
|
||||
const extensionRefreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
const skillRefreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
const listSkills = vi.fn().mockResolvedValue([]);
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
getTargetDir: vi.fn().mockReturnValue('/work/status'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/work/status'),
|
||||
isSafeMode: vi.fn().mockReturnValue(false),
|
||||
getBareMode: vi.fn().mockReturnValue(false),
|
||||
getExtensionManager: vi.fn().mockReturnValue({
|
||||
refreshCache: extensionRefreshCache,
|
||||
refreshCacheIfSourcesChanged: vi.fn().mockResolvedValue(false),
|
||||
}),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
refreshCache: skillRefreshCache,
|
||||
listSkills,
|
||||
getCachedSkills: vi.fn().mockReturnValue(null),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
await expect(
|
||||
agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceSkills, {}),
|
||||
).resolves.toEqual({
|
||||
v: 1,
|
||||
workspaceCwd: '/work/status',
|
||||
initialized: false,
|
||||
skills: [],
|
||||
});
|
||||
expect(extensionRefreshCache).not.toHaveBeenCalled();
|
||||
expect(skillRefreshCache).not.toHaveBeenCalled();
|
||||
expect(listSkills).not.toHaveBeenCalled();
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('reports an uninitialized snapshot when the config has no skill manager', async () => {
|
||||
// The daemon latches any `initialized: true` answer and then prefers it over
|
||||
// its own local enumeration, so a config that can never enumerate must not
|
||||
// claim to be initialized with an empty list.
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
getTargetDir: vi.fn().mockReturnValue('/work/status'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/work/status'),
|
||||
getExtensionManager: vi.fn().mockReturnValue({
|
||||
refreshCache: vi.fn().mockResolvedValue(undefined),
|
||||
refreshCacheIfSourcesChanged: vi.fn().mockResolvedValue(false),
|
||||
}),
|
||||
getSkillManager: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config;
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
await expect(
|
||||
agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceSkills, {}),
|
||||
).resolves.toEqual({
|
||||
v: 1,
|
||||
workspaceCwd: '/work/status',
|
||||
initialized: false,
|
||||
skills: [],
|
||||
});
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('refreshes the skill cache when extension sources moved under a read', async () => {
|
||||
// Extensions have no watcher, so this is the only path by which an
|
||||
// extension installed outside the daemon reaches the snapshot. Extension
|
||||
// skills are derived from the extension set, so the skill cache has to
|
||||
// follow.
|
||||
const skillRefreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
const refreshCacheIfSourcesChanged = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValue(false);
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
getTargetDir: vi.fn().mockReturnValue('/work/status'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/work/status'),
|
||||
isSafeMode: vi.fn().mockReturnValue(false),
|
||||
getBareMode: vi.fn().mockReturnValue(false),
|
||||
getExtensionManager: vi.fn().mockReturnValue({
|
||||
refreshCache: vi.fn().mockResolvedValue(undefined),
|
||||
refreshCacheIfSourcesChanged,
|
||||
}),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
refreshCache: skillRefreshCache,
|
||||
getCachedSkills: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
await agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceSkills, {});
|
||||
expect(skillRefreshCache).toHaveBeenCalledOnce();
|
||||
|
||||
// Sources settled — the second read must not refresh again.
|
||||
await agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceSkills, {});
|
||||
expect(refreshCacheIfSourcesChanged).toHaveBeenCalledTimes(2);
|
||||
expect(skillRefreshCache).toHaveBeenCalledOnce();
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it.each([
|
||||
['safe mode', { isSafeMode: true, getBareMode: false }],
|
||||
['bare mode', { isSafeMode: false, getBareMode: true }],
|
||||
])('does not revalidate extension sources in %s', async (_label, modes) => {
|
||||
// These modes never populate the extension cache, and the snapshot derives
|
||||
// extension skills from getExtensions() — so revalidating here would load
|
||||
// the extensions the mode exists to exclude.
|
||||
const refreshCacheIfSourcesChanged = vi.fn().mockResolvedValue(true);
|
||||
const skillRefreshCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
getTargetDir: vi.fn().mockReturnValue('/work/status'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/work/status'),
|
||||
isSafeMode: vi.fn().mockReturnValue(modes.isSafeMode),
|
||||
getBareMode: vi.fn().mockReturnValue(modes.getBareMode),
|
||||
getExtensionManager: vi.fn().mockReturnValue({
|
||||
refreshCache: vi.fn().mockResolvedValue(undefined),
|
||||
refreshCacheIfSourcesChanged,
|
||||
}),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
refreshCache: skillRefreshCache,
|
||||
getCachedSkills: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
await agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceSkills, {});
|
||||
|
||||
expect(refreshCacheIfSourcesChanged).not.toHaveBeenCalled();
|
||||
expect(skillRefreshCache).not.toHaveBeenCalled();
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('status ext methods return error cells when workspace snapshots fail', async () => {
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
|
|
@ -11904,6 +12166,42 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
await agentPromise;
|
||||
});
|
||||
|
||||
it('keeps ACP stdio MCP cwd implicit so session relocation can rebind it', async () => {
|
||||
await setupSessionMocks('session-stdio-cwd');
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
await agent.newSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [
|
||||
{
|
||||
name: 'local',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: [],
|
||||
} as unknown as McpServer,
|
||||
],
|
||||
});
|
||||
|
||||
const sessionMcpServers = vi.mocked(loadCliConfig).mock.calls[0]?.[6];
|
||||
const localConfig = sessionMcpServers?.['local'] as unknown as {
|
||||
_args: unknown[];
|
||||
};
|
||||
expect(localConfig._args).toEqual(['node', ['server.js'], {}]);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('passes undefined (not []) as the extension override to loadCliConfig', async () => {
|
||||
await setupSessionMocks('session-ext-override');
|
||||
|
||||
|
|
@ -12442,6 +12740,55 @@ describe('QwenAgent extMethod renameSession routing', () => {
|
|||
await agentPromise;
|
||||
});
|
||||
|
||||
it('creates a side task with source metadata and no branch suffix', async () => {
|
||||
const recording = makeRecordingService();
|
||||
const sessionService = {
|
||||
forkSession: vi.fn().mockResolvedValue(undefined),
|
||||
renameSession: vi.fn().mockResolvedValue(true),
|
||||
removeSession: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const innerConfig = makeLiveSessionInnerConfig(recording);
|
||||
innerConfig.getSessionService.mockReturnValue(
|
||||
sessionService as unknown as SessionService,
|
||||
);
|
||||
const { agent, agentPromise } = await bootAgent(innerConfig);
|
||||
|
||||
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
|
||||
const result = await agent.extMethod(
|
||||
SERVE_CONTROL_EXT_METHODS.sessionSideTask,
|
||||
{
|
||||
cwd: '/tmp',
|
||||
sessionId: liveSessionId,
|
||||
name: 'Side task',
|
||||
},
|
||||
);
|
||||
|
||||
expect(sessionService.forkSession).toHaveBeenCalledWith(
|
||||
liveSessionId,
|
||||
expect.any(String),
|
||||
{
|
||||
source: {
|
||||
sourceType: 'side_task',
|
||||
sourceId: liveSessionId,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(recording.runWithWriteBarrier).toHaveBeenCalledOnce();
|
||||
const newSessionId = sessionService.forkSession.mock.calls[0]?.[1];
|
||||
expect(sessionService.renameSession).toHaveBeenCalledWith(
|
||||
newSessionId,
|
||||
'Side task',
|
||||
'manual',
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
title: 'Side task',
|
||||
displayName: 'Side task',
|
||||
});
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('keeps the live session open when strict session close flush fails', async () => {
|
||||
const recording = makeRecordingService();
|
||||
recording.flush.mockRejectedValue(new Error('flush failed'));
|
||||
|
|
@ -15716,6 +16063,11 @@ describe('sessionLanguage multi-session propagation', () => {
|
|||
}),
|
||||
getFileSystemService: vi.fn().mockReturnValue(undefined),
|
||||
setFileSystemService: vi.fn(),
|
||||
getExtensionManager: vi.fn().mockReturnValue({
|
||||
refreshCache: vi.fn().mockResolvedValue(undefined),
|
||||
refreshTools: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
getSkillManager: vi.fn().mockReturnValue(undefined),
|
||||
getHookSystem: vi.fn().mockReturnValue(undefined),
|
||||
getDisableAllHooks: vi.fn().mockReturnValue(true),
|
||||
hasHooksForEvent: vi.fn().mockReturnValue(false),
|
||||
|
|
@ -16180,6 +16532,8 @@ describe('sessionLanguage multi-session propagation', () => {
|
|||
});
|
||||
const refresh1 = vi.fn().mockResolvedValue(undefined);
|
||||
const refresh2 = vi.fn().mockRejectedValue(new Error('client closed'));
|
||||
const reload1 = vi.fn();
|
||||
const reload2 = vi.fn();
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue(bootstrapSettings);
|
||||
vi.mocked(loadCliConfig)
|
||||
|
|
@ -16191,6 +16545,7 @@ describe('sessionLanguage multi-session propagation', () => {
|
|||
getId: vi.fn().mockReturnValue(id),
|
||||
getConfig: vi.fn().mockReturnValue(id === 'skill-1' ? cfg1 : cfg2),
|
||||
isIdle: vi.fn().mockReturnValue(false),
|
||||
reloadSkillSettings: id === 'skill-1' ? reload1 : reload2,
|
||||
refreshSkillsFromSettings: id === 'skill-1' ? refresh1 : refresh2,
|
||||
sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
installRewriter: vi.fn(),
|
||||
|
|
@ -16214,14 +16569,24 @@ describe('sessionLanguage multi-session propagation', () => {
|
|||
await agent.newSession({ cwd: '/skills', mcpServers: [] });
|
||||
await agent.newSession({ cwd: '/skills', mcpServers: [] });
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, {}),
|
||||
).resolves.toEqual({ sessionsRefreshed: 1, sessionsFailed: 1 });
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, {
|
||||
reason: 'settings',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
sessionsRefreshed: 1,
|
||||
sessionsFailed: 1,
|
||||
configsRefreshed: 0,
|
||||
configsFailed: 0,
|
||||
reason: 'settings',
|
||||
});
|
||||
|
||||
expect(bootstrapSettings.reloadScopeFromDisk).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
expect(refresh1).toHaveBeenCalledOnce();
|
||||
expect(refresh2).toHaveBeenCalledOnce();
|
||||
expect(reload1).toHaveBeenCalledOnce();
|
||||
expect(reload2).toHaveBeenCalledOnce();
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||
'Session skill-2 skill refresh failed: Error: client closed',
|
||||
);
|
||||
|
|
@ -16230,7 +16595,109 @@ describe('sessionLanguage multi-session propagation', () => {
|
|||
await agentPromise;
|
||||
});
|
||||
|
||||
it('refreshes extension state without a duplicate direct skill refresh', async () => {
|
||||
it('refreshes skill content once per config before publishing session updates', async () => {
|
||||
const bootstrapSettings = {
|
||||
merged: {},
|
||||
reloadScopeFromDisk: vi.fn(),
|
||||
getUserHooks: vi.fn().mockReturnValue({}),
|
||||
getProjectHooks: vi.fn().mockReturnValue({}),
|
||||
} as unknown as LoadedSettings;
|
||||
const bootstrapRefresh = vi.fn().mockResolvedValue(undefined);
|
||||
const sessionRefresh = vi.fn().mockResolvedValue(undefined);
|
||||
const publishSessionSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const reloadSessionSettings = vi.fn();
|
||||
const bootstrapConfig = makeConfig({
|
||||
getSkillManager: vi
|
||||
.fn()
|
||||
.mockReturnValue({ refreshCache: bootstrapRefresh }),
|
||||
});
|
||||
const sessionConfig = makeConfig({
|
||||
getSessionId: vi.fn().mockReturnValue('skill-content'),
|
||||
getSkillManager: vi
|
||||
.fn()
|
||||
.mockReturnValue({ refreshCache: sessionRefresh }),
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue(bootstrapSettings);
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
sessionConfig as unknown as Config,
|
||||
);
|
||||
vi.mocked(Session).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
getId: vi.fn().mockReturnValue('skill-content'),
|
||||
getConfig: vi.fn().mockReturnValue(sessionConfig),
|
||||
isIdle: vi.fn().mockReturnValue(false),
|
||||
reloadSkillSettings: reloadSessionSettings,
|
||||
refreshSkillsFromSettings: publishSessionSkills,
|
||||
sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
installRewriter: vi.fn(),
|
||||
startCronScheduler: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
}) as unknown as InstanceType<typeof Session>,
|
||||
);
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
bootstrapConfig as unknown as Config,
|
||||
bootstrapSettings,
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: '/skills', mcpServers: [] });
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, {
|
||||
reason: 'content',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
sessionsRefreshed: 1,
|
||||
sessionsFailed: 0,
|
||||
configsRefreshed: 2,
|
||||
configsFailed: 0,
|
||||
reason: 'content',
|
||||
});
|
||||
|
||||
expect(bootstrapRefresh).toHaveBeenCalledOnce();
|
||||
expect(sessionRefresh).toHaveBeenCalledOnce();
|
||||
expect(bootstrapSettings.reloadScopeFromDisk).not.toHaveBeenCalled();
|
||||
expect(publishSessionSkills).toHaveBeenCalledWith({
|
||||
reloadSettings: false,
|
||||
notifyConfigChanged: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh, {}),
|
||||
).resolves.toEqual({
|
||||
sessionsRefreshed: 1,
|
||||
sessionsFailed: 0,
|
||||
configsRefreshed: 2,
|
||||
configsFailed: 0,
|
||||
reason: 'all',
|
||||
});
|
||||
expect(bootstrapRefresh).toHaveBeenCalledTimes(2);
|
||||
expect(sessionRefresh).toHaveBeenCalledTimes(2);
|
||||
expect(bootstrapSettings.reloadScopeFromDisk).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
expect(publishSessionSkills).toHaveBeenLastCalledWith({
|
||||
reloadSettings: false,
|
||||
notifyConfigChanged: false,
|
||||
});
|
||||
expect(reloadSessionSettings).toHaveBeenCalledOnce();
|
||||
expect(reloadSessionSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
sessionRefresh.mock.invocationCallOrder[1]!,
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('coalesces bootstrap extension refreshes without directly refreshing the session skills', async () => {
|
||||
const extensionManager = {
|
||||
refreshCache: vi.fn().mockResolvedValue(undefined),
|
||||
refreshTools: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -16240,6 +16707,20 @@ describe('sessionLanguage multi-session propagation', () => {
|
|||
.fn()
|
||||
.mockRejectedValue(new Error('direct skill refresh should not run')),
|
||||
};
|
||||
let releaseBootstrapRefresh!: () => void;
|
||||
const bootstrapRefreshGate = new Promise<void>((resolve) => {
|
||||
releaseBootstrapRefresh = resolve;
|
||||
});
|
||||
const bootstrapExtensionManager = {
|
||||
refreshCache: vi.fn().mockReturnValue(bootstrapRefreshGate),
|
||||
};
|
||||
const bootstrapSkillRefresh = vi.fn().mockResolvedValue(undefined);
|
||||
const bootstrapConfig = makeConfig({
|
||||
getExtensionManager: vi.fn().mockReturnValue(bootstrapExtensionManager),
|
||||
getSkillManager: vi
|
||||
.fn()
|
||||
.mockReturnValue({ refreshCache: bootstrapSkillRefresh }),
|
||||
});
|
||||
const refreshHierarchicalMemory = vi.fn().mockResolvedValue(undefined);
|
||||
const cfg = makeConfig({
|
||||
getSessionId: vi.fn().mockReturnValue('s-ext'),
|
||||
|
|
@ -16271,7 +16752,7 @@ describe('sessionLanguage multi-session propagation', () => {
|
|||
);
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
makeConfig() as unknown as Config,
|
||||
bootstrapConfig as unknown as Config,
|
||||
{ merged: { mcpServers: {} } } as unknown as LoadedSettings,
|
||||
mockArgv,
|
||||
);
|
||||
|
|
@ -16283,21 +16764,54 @@ describe('sessionLanguage multi-session propagation', () => {
|
|||
});
|
||||
|
||||
await agent.newSession({ cwd: '/ext', mcpServers: [] });
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, {
|
||||
await vi.waitFor(() =>
|
||||
expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce(),
|
||||
);
|
||||
sendAvailableCommandsUpdate.mockClear();
|
||||
const firstRefresh = agent.extMethod(
|
||||
SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh,
|
||||
{
|
||||
sessionId: 's-ext',
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(bootstrapExtensionManager.refreshCache).toHaveBeenCalledOnce(),
|
||||
);
|
||||
const secondRefresh = agent.extMethod(
|
||||
SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh,
|
||||
{
|
||||
sessionId: 's-ext',
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(extensionManager.refreshTools).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
await Promise.resolve();
|
||||
releaseBootstrapRefresh();
|
||||
await expect(Promise.all([firstRefresh, secondRefresh])).resolves.toEqual([
|
||||
{ ok: true },
|
||||
{ ok: true },
|
||||
]);
|
||||
|
||||
expect(extensionManager.refreshCache).toHaveBeenCalledOnce();
|
||||
expect(extensionManager.refreshCache).toHaveBeenCalledTimes(2);
|
||||
expect(skillManager.refreshCache).not.toHaveBeenCalled();
|
||||
expect(extensionManager.refreshTools).toHaveBeenCalledOnce();
|
||||
expect(extensionManager.refreshTools).toHaveBeenCalledTimes(2);
|
||||
expect(bootstrapExtensionManager.refreshCache).toHaveBeenCalledOnce();
|
||||
expect(bootstrapSkillRefresh).toHaveBeenCalledOnce();
|
||||
expect(refreshHierarchicalMemory).not.toHaveBeenCalled();
|
||||
expect(refreshSystemInstruction).toHaveBeenCalledOnce();
|
||||
expect(sendAvailableCommandsUpdate).toHaveBeenCalledOnce();
|
||||
expect(refreshSystemInstruction).toHaveBeenCalledTimes(2);
|
||||
expect(sendAvailableCommandsUpdate).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
extensionManager.refreshTools.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(refreshSystemInstruction.mock.invocationCallOrder[0]!);
|
||||
).toBeLessThan(
|
||||
bootstrapExtensionManager.refreshCache.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(
|
||||
bootstrapExtensionManager.refreshCache.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(bootstrapSkillRefresh.mock.invocationCallOrder[0]!);
|
||||
expect(bootstrapSkillRefresh.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
refreshSystemInstruction.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(refreshSystemInstruction.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
sendAvailableCommandsUpdate.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
|
|
@ -16547,3 +17061,55 @@ describe('deliverClientMcpMessage — reverse tool channel (#5626)', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectVisibleHistoryRecords', () => {
|
||||
function makeRecord(
|
||||
overrides: Partial<{
|
||||
type: string;
|
||||
subtype: string;
|
||||
systemPayload: unknown;
|
||||
forkedFrom: { sessionId: string; messageUuid: string };
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
uuid: `uuid-${Math.random().toString(36).slice(2)}`,
|
||||
parentUuid: null,
|
||||
sessionId: 'test-session',
|
||||
timestamp: '2025-01-01T00:00:00Z',
|
||||
type: 'user',
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
const sourceBoundary = makeRecord({
|
||||
type: 'system',
|
||||
subtype: 'session_source',
|
||||
systemPayload: { sourceType: 'side_task', sourceId: 'parent-1' },
|
||||
});
|
||||
|
||||
it('filters records before a side-task source boundary regardless of hideInheritedHistory', () => {
|
||||
const inherited = makeRecord({
|
||||
forkedFrom: { sessionId: 'parent-1', messageUuid: 'm1' },
|
||||
});
|
||||
const before = makeRecord();
|
||||
const after = makeRecord();
|
||||
const records = [inherited, before, sourceBoundary, after];
|
||||
|
||||
const withHide = selectVisibleHistoryRecords(records, true);
|
||||
const withoutHide = selectVisibleHistoryRecords(records, false);
|
||||
|
||||
expect(withHide).toEqual([sourceBoundary, after]);
|
||||
expect(withoutHide).toEqual([sourceBoundary, after]);
|
||||
});
|
||||
|
||||
it('filters forkedFrom records when hideInheritedHistory is true and no boundary exists', () => {
|
||||
const inherited = makeRecord({
|
||||
forkedFrom: { sessionId: 'parent-1', messageUuid: 'm1' },
|
||||
});
|
||||
const own = makeRecord();
|
||||
const records = [inherited, own];
|
||||
|
||||
expect(selectVisibleHistoryRecords(records, true)).toEqual([own]);
|
||||
expect(selectVisibleHistoryRecords(records, false)).toEqual(records);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -300,6 +300,7 @@ import {
|
|||
CHANNEL_STARTUP_PROFILE_VERSION,
|
||||
CLIENT_MCP_OVER_WS_CONFIG_FLAG,
|
||||
LOAD_REPLAY_BULK_MODE,
|
||||
LOAD_REPLAY_HIDE_INHERITED_META_KEY,
|
||||
LOAD_REPLAY_META_KEY,
|
||||
LOAD_REPLAY_MODE_META_KEY,
|
||||
LOAD_REPLAY_PAGE_SIZE_META_KEY,
|
||||
|
|
@ -307,6 +308,7 @@ import {
|
|||
PROMPT_CANCEL_METHOD,
|
||||
REQUESTED_SESSION_ID_META_KEY,
|
||||
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
|
||||
WORKTREE_MCP_DEFER_META_KEY,
|
||||
type ClientMcpOverWsRuntimeConfig,
|
||||
type BridgeLoadReplayEnvelope,
|
||||
} from '@qwen-code/acp-bridge/bridgeTypes';
|
||||
|
|
@ -627,12 +629,45 @@ function isBulkLoadReplayRequest(params: LoadSessionRequest): boolean {
|
|||
return meta?.[LOAD_REPLAY_MODE_META_KEY] === LOAD_REPLAY_BULK_MODE;
|
||||
}
|
||||
|
||||
function shouldHideInheritedHistory(params: LoadSessionRequest): boolean {
|
||||
const meta = isObjectRecord(params._meta) ? params._meta : undefined;
|
||||
return meta?.[LOAD_REPLAY_HIDE_INHERITED_META_KEY] === true;
|
||||
}
|
||||
|
||||
export function selectVisibleHistoryRecords(
|
||||
records: ChatRecord[],
|
||||
hideInheritedHistory: boolean,
|
||||
): ChatRecord[] {
|
||||
const sourceBoundary = records.findIndex(
|
||||
(record) =>
|
||||
record.type === 'system' &&
|
||||
record.subtype === 'session_source' &&
|
||||
isObjectRecord(record.systemPayload) &&
|
||||
record.systemPayload['sourceType'] === 'side_task',
|
||||
);
|
||||
// A persisted side-task source boundary is authoritative for every replay;
|
||||
// callers cannot opt inherited parent history back into that child session.
|
||||
if (sourceBoundary >= 0) {
|
||||
return records
|
||||
.slice(sourceBoundary)
|
||||
.filter((record) => record.forkedFrom === undefined);
|
||||
}
|
||||
return hideInheritedHistory
|
||||
? records.filter((record) => record.forkedFrom === undefined)
|
||||
: records;
|
||||
}
|
||||
|
||||
function isChannelSessionRequest(params: { _meta?: unknown }): boolean {
|
||||
const meta = isObjectRecord(params._meta) ? params._meta : undefined;
|
||||
const value = meta?.[SESSION_SOURCE_META_KEY];
|
||||
return isObjectRecord(value) && value['sourceType'] === 'channel';
|
||||
}
|
||||
|
||||
function shouldDeferMcpDiscovery(params: { _meta?: unknown }): boolean {
|
||||
const meta = isObjectRecord(params._meta) ? params._meta : undefined;
|
||||
return meta?.[WORKTREE_MCP_DEFER_META_KEY] === true;
|
||||
}
|
||||
|
||||
function getLoadReplayPageSize(params: LoadSessionRequest): number | undefined {
|
||||
const meta = isObjectRecord(params._meta) ? params._meta : undefined;
|
||||
const value = meta?.[LOAD_REPLAY_PAGE_SIZE_META_KEY];
|
||||
|
|
@ -3329,6 +3364,7 @@ class QwenAgent implements Agent {
|
|||
private workspaceMcpDiscoveryConfig: Config | undefined;
|
||||
private workspaceMcpDiscoveryPromise: Promise<void> | undefined;
|
||||
private workspaceMcpDiscoveryError: string | undefined;
|
||||
private workspaceExtensionStatusRefreshPromise: Promise<void> | undefined;
|
||||
private readonly pendingMcpAuthentications = new Map<
|
||||
string,
|
||||
PendingMcpAuthentication
|
||||
|
|
@ -3519,6 +3555,41 @@ class QwenAgent implements Agent {
|
|||
return this.workspaceMcpDiscoveryConfig ?? this.config;
|
||||
}
|
||||
|
||||
private refreshBootstrapExtensionStatus(): Promise<void> {
|
||||
if (this.workspaceExtensionStatusRefreshPromise) {
|
||||
return this.workspaceExtensionStatusRefreshPromise;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
await this.config.getExtensionManager().refreshCache();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
try {
|
||||
await this.config.getSkillManager()?.refreshCache();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
'Bootstrap extension status refresh failed',
|
||||
);
|
||||
}
|
||||
})();
|
||||
this.workspaceExtensionStatusRefreshPromise = promise;
|
||||
const clear = () => {
|
||||
if (this.workspaceExtensionStatusRefreshPromise === promise) {
|
||||
this.workspaceExtensionStatusRefreshPromise = undefined;
|
||||
}
|
||||
};
|
||||
void promise.then(clear, clear);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private getLiveMcpConfigs(serverName: string): Config[] {
|
||||
return [
|
||||
...new Set([
|
||||
|
|
@ -4365,6 +4436,10 @@ class QwenAgent implements Agent {
|
|||
settings,
|
||||
isChannelSession,
|
||||
requestedSessionId,
|
||||
undefined,
|
||||
shouldDeferMcpDiscovery(params)
|
||||
? { skipMcpDiscovery: true }
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
let session: Session;
|
||||
|
|
@ -4420,12 +4495,19 @@ class QwenAgent implements Agent {
|
|||
: {}),
|
||||
} as LoadSessionResponse;
|
||||
const records = sessionData.conversation.messages;
|
||||
if (records.length === 0) return response;
|
||||
const visibleRecords = selectVisibleHistoryRecords(
|
||||
records,
|
||||
shouldHideInheritedHistory(params),
|
||||
);
|
||||
if (visibleRecords.length === 0) return response;
|
||||
|
||||
const bulkReplay = isBulkLoadReplayRequest(params);
|
||||
const replayPage = bulkReplay
|
||||
? selectRecentHistoryRecords(records, getLoadReplayPageSize(params))
|
||||
: { records, hasMore: false };
|
||||
? selectRecentHistoryRecords(
|
||||
visibleRecords,
|
||||
getLoadReplayPageSize(params),
|
||||
)
|
||||
: { records: visibleRecords, hasMore: false };
|
||||
const replay = await collectHistoryReplayUpdates({
|
||||
sessionId: params.sessionId,
|
||||
config,
|
||||
|
|
@ -4505,8 +4587,12 @@ class QwenAgent implements Agent {
|
|||
let replayUpdates: SessionUpdate[] = [];
|
||||
if (records) {
|
||||
createdSession.primeTurnFromHistory(records);
|
||||
const replayPage = selectRecentHistoryRecords(
|
||||
const visibleRecords = selectVisibleHistoryRecords(
|
||||
records,
|
||||
shouldHideInheritedHistory(params),
|
||||
);
|
||||
const replayPage = selectRecentHistoryRecords(
|
||||
visibleRecords,
|
||||
replayPageSize,
|
||||
);
|
||||
const replayUsage = createReplayCumulativeUsage();
|
||||
|
|
@ -4554,7 +4640,6 @@ class QwenAgent implements Agent {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
const modesData = this.buildModesData(config);
|
||||
const availableModels = this.buildAvailableModels(config);
|
||||
const configOptions = this.buildConfigOptions(config);
|
||||
|
|
@ -5843,26 +5928,69 @@ class QwenAgent implements Agent {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the extension-derived half of the skill snapshot self-healing.
|
||||
*
|
||||
* Skills have a watcher (`SkillManager.startWatching`); extensions do not, so
|
||||
* without this the child would never notice an extension installed, removed,
|
||||
* enabled, or disabled outside the daemon — and extension-level skills are
|
||||
* derived from that set, so a skill-watcher tick alone cannot recover it.
|
||||
*
|
||||
* The check is one `readdir` plus a bounded number of `stat`s, and refreshes
|
||||
* only when the sources actually moved, so a steady-state read still parses
|
||||
* no manifest and no `SKILL.md`. Failures are logged and swallowed: a status
|
||||
* read must not fail because revalidation could not run.
|
||||
*
|
||||
* Skipped in safe and bare mode. Those modes deliberately never populate the
|
||||
* extension cache (`Config.initialize` omits the refresh), and the snapshot
|
||||
* derives extension skills from `getExtensions()` — so revalidating here
|
||||
* would load the extensions those modes exist to exclude.
|
||||
*/
|
||||
private async revalidateExtensionSources(config: Config): Promise<void> {
|
||||
// Everything here is inside the boundary, mode check included: this must not
|
||||
// be able to fail a status read no matter which accessor misbehaves.
|
||||
try {
|
||||
if (config.isSafeMode() || config.getBareMode()) return;
|
||||
const changed = await config
|
||||
.getExtensionManager()
|
||||
.refreshCacheIfSourcesChanged();
|
||||
if (!changed) return;
|
||||
await config.getSkillManager()?.refreshCache();
|
||||
} catch (error) {
|
||||
debugLogger.warn('Extension source revalidation failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async buildWorkspaceSkillsStatus(
|
||||
config: Config,
|
||||
): Promise<ServeWorkspaceSkillsStatus> {
|
||||
const skillManager = config.getSkillManager();
|
||||
if (!skillManager) {
|
||||
// No manager means nothing has been enumerated and nothing ever will be
|
||||
// on this config — report that rather than an empty "initialized" list,
|
||||
// which the daemon would latch as a valid snapshot and then keep serving
|
||||
// in preference to its own local enumeration.
|
||||
return {
|
||||
v: STATUS_SCHEMA_VERSION,
|
||||
workspaceCwd: this.workspaceCwd(config),
|
||||
initialized: true,
|
||||
initialized: false,
|
||||
skills: [],
|
||||
};
|
||||
}
|
||||
|
||||
await this.revalidateExtensionSources(config);
|
||||
|
||||
try {
|
||||
const resolved = resolveSkillSettings(
|
||||
loadSettings(this.workspaceCwd(config), {
|
||||
consumeCorruptionEnvVars: false,
|
||||
skipLoadEnvironment: true,
|
||||
}),
|
||||
);
|
||||
const skills = skillManager.getCachedSkills();
|
||||
if (skills === null) {
|
||||
return {
|
||||
v: STATUS_SCHEMA_VERSION,
|
||||
workspaceCwd: this.workspaceCwd(config),
|
||||
initialized: false,
|
||||
skills: [],
|
||||
};
|
||||
}
|
||||
const resolved = resolveSkillSettings(this.settings);
|
||||
const disablements = new Map(
|
||||
Array.from(config.getDisabledSkillNames(), (name) => {
|
||||
const normalizedName = name.trim().toLowerCase();
|
||||
|
|
@ -5873,17 +6001,6 @@ class QwenAgent implements Agent {
|
|||
] as const;
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await config.getExtensionManager().refreshCache();
|
||||
} catch (error) {
|
||||
debugLogger.warn('Extension cache refresh failed:', error);
|
||||
}
|
||||
try {
|
||||
await skillManager.refreshCache();
|
||||
} catch (error) {
|
||||
debugLogger.warn('Skill cache refresh failed:', error);
|
||||
}
|
||||
const skills = await skillManager.listSkills();
|
||||
const inactiveSkillRefs = inactiveExtensionSkillRefs(config);
|
||||
const skillsByKey = new Map(
|
||||
skills.map((skill) => [
|
||||
|
|
@ -8951,6 +9068,15 @@ class QwenAgent implements Agent {
|
|||
}`,
|
||||
);
|
||||
}
|
||||
if (relocation.mcpRefreshError) {
|
||||
warnings.push(
|
||||
`MCP refresh failed: ${
|
||||
relocation.mcpRefreshError instanceof Error
|
||||
? relocation.mcpRefreshError.message
|
||||
: String(relocation.mcpRefreshError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await config
|
||||
|
|
@ -9659,6 +9785,16 @@ class QwenAgent implements Agent {
|
|||
}
|
||||
case SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh: {
|
||||
const sessionId = params['sessionId'] as string;
|
||||
const rawRefreshBootstrap = params['refreshBootstrap'];
|
||||
if (
|
||||
rawRefreshBootstrap !== undefined &&
|
||||
typeof rawRefreshBootstrap !== 'boolean'
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'refreshBootstrap must be a boolean',
|
||||
);
|
||||
}
|
||||
const session = this.sessionOrThrow(sessionId);
|
||||
const config = session.getConfig();
|
||||
const extensionManager = config.getExtensionManager();
|
||||
|
|
@ -9672,6 +9808,12 @@ class QwenAgent implements Agent {
|
|||
};
|
||||
await runRefresh(async () => await extensionManager.refreshCache());
|
||||
await runRefresh(async () => await extensionManager.refreshTools());
|
||||
const bootstrapConfig = this.config;
|
||||
if (rawRefreshBootstrap !== false && bootstrapConfig !== config) {
|
||||
await runRefresh(
|
||||
async () => await this.refreshBootstrapExtensionStatus(),
|
||||
);
|
||||
}
|
||||
const discoveryConfig = this.workspaceMcpDiscoveryConfig;
|
||||
if (discoveryConfig && discoveryConfig !== config) {
|
||||
const discoveryExtensionManager =
|
||||
|
|
@ -10020,7 +10162,9 @@ class QwenAgent implements Agent {
|
|||
apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null,
|
||||
};
|
||||
}
|
||||
case SERVE_CONTROL_EXT_METHODS.sessionBranch: {
|
||||
case SERVE_CONTROL_EXT_METHODS.sessionBranch:
|
||||
case SERVE_CONTROL_EXT_METHODS.sessionSideTask: {
|
||||
const isSideTask = method === SERVE_CONTROL_EXT_METHODS.sessionSideTask;
|
||||
const sessionId = params['sessionId'];
|
||||
if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) {
|
||||
throw RequestError.invalidParams(
|
||||
|
|
@ -10046,7 +10190,20 @@ class QwenAgent implements Agent {
|
|||
|
||||
const newSessionId = randomUUID();
|
||||
const sessionService = sourceConfig.getSessionService();
|
||||
await sessionService.forkSession(sessionId, newSessionId);
|
||||
const fork = () =>
|
||||
isSideTask
|
||||
? sessionService.forkSession(sessionId, newSessionId, {
|
||||
source: {
|
||||
sourceType: 'side_task',
|
||||
sourceId: sessionId,
|
||||
},
|
||||
})
|
||||
: sessionService.forkSession(sessionId, newSessionId);
|
||||
if (isSideTask && recording) {
|
||||
await recording.runWithWriteBarrier(fork);
|
||||
} else {
|
||||
await fork();
|
||||
}
|
||||
|
||||
let title: string;
|
||||
try {
|
||||
|
|
@ -10065,7 +10222,9 @@ class QwenAgent implements Agent {
|
|||
}
|
||||
}
|
||||
|
||||
title = await computeUniqueBranchTitle(baseName, sessionService);
|
||||
title = isSideTask
|
||||
? baseName
|
||||
: await computeUniqueBranchTitle(baseName, sessionService);
|
||||
const renamed = await sessionService.renameSession(
|
||||
newSessionId,
|
||||
title,
|
||||
|
|
@ -10504,10 +10663,62 @@ class QwenAgent implements Agent {
|
|||
};
|
||||
}
|
||||
case SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh: {
|
||||
this.settings.reloadScopeFromDisk(SettingScope.Workspace);
|
||||
const rawReason = params['reason'];
|
||||
if (
|
||||
rawReason !== undefined &&
|
||||
rawReason !== 'settings' &&
|
||||
rawReason !== 'content' &&
|
||||
rawReason !== 'all'
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
'reason must be settings, content, or all',
|
||||
);
|
||||
}
|
||||
const reason = rawReason ?? 'all';
|
||||
const refreshSettings = reason !== 'content';
|
||||
const refreshContent = reason !== 'settings';
|
||||
if (refreshSettings) {
|
||||
this.settings.reloadScopeFromDisk(SettingScope.Workspace);
|
||||
}
|
||||
const sessions = this.getActiveSessions();
|
||||
const settingsReloadResults = refreshSettings
|
||||
? await Promise.allSettled(
|
||||
sessions.map((session) =>
|
||||
Promise.resolve().then(() => session.reloadSkillSettings()),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
let configResults: Array<PromiseSettledResult<void>> = [];
|
||||
if (refreshContent) {
|
||||
const skillManagers = new Set(
|
||||
[this.config, ...sessions.map((session) => session.getConfig())]
|
||||
.map((config) => config.getSkillManager())
|
||||
.filter(
|
||||
(manager): manager is NonNullable<typeof manager> =>
|
||||
manager !== undefined,
|
||||
),
|
||||
);
|
||||
configResults = await Promise.allSettled(
|
||||
[...skillManagers].map((manager) => manager.refreshCache()),
|
||||
);
|
||||
for (const result of configResults) {
|
||||
if (result.status === 'rejected') {
|
||||
debugLogger.warn(`Skill config refresh failed: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
sessions.map((session) => session.refreshSkillsFromSettings()),
|
||||
sessions.map((session, index) => {
|
||||
const settingsReload = settingsReloadResults?.[index];
|
||||
if (settingsReload?.status === 'rejected') {
|
||||
return Promise.reject(settingsReload.reason);
|
||||
}
|
||||
return session.refreshSkillsFromSettings({
|
||||
reloadSettings: false,
|
||||
notifyConfigChanged: !refreshContent,
|
||||
});
|
||||
}),
|
||||
);
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (results[i]!.status === 'rejected') {
|
||||
|
|
@ -10524,6 +10735,13 @@ class QwenAgent implements Agent {
|
|||
sessionsFailed: results.filter(
|
||||
(result) => result.status === 'rejected',
|
||||
).length,
|
||||
configsRefreshed: configResults.filter(
|
||||
(result) => result.status === 'fulfilled',
|
||||
).length,
|
||||
configsFailed: configResults.filter(
|
||||
(result) => result.status === 'rejected',
|
||||
).length,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
default:
|
||||
|
|
@ -10710,7 +10928,6 @@ class QwenAgent implements Agent {
|
|||
stdioServer.command,
|
||||
stdioServer.args,
|
||||
env,
|
||||
cwd,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2794,6 +2794,30 @@ describe('Session', () => {
|
|||
expect(notifyConfigChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('publishes refreshed skill content without reloading settings or notifying twice', async () => {
|
||||
const notifyConfigChanged = vi.fn().mockResolvedValue(undefined);
|
||||
mockConfig.getSkillManager = vi.fn().mockReturnValue({
|
||||
listSkills: vi.fn().mockResolvedValue([]),
|
||||
suppressNextSlashReload: vi.fn(),
|
||||
notifyConfigChanged,
|
||||
});
|
||||
|
||||
await session.refreshSkillsFromSettings({
|
||||
reloadSettings: false,
|
||||
notifyConfigChanged: false,
|
||||
});
|
||||
|
||||
expect(mockSettings.reloadScopeFromDisk).not.toHaveBeenCalled();
|
||||
expect(mockClient.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(notifyConfigChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('notifies SkillManager when the command update fails', async () => {
|
||||
const suppressNextSlashReload = vi.fn();
|
||||
const notifyConfigChanged = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
@ -13749,6 +13773,46 @@ describe('Session', () => {
|
|||
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
|
||||
expect(result.stopReason).toBe('end_turn');
|
||||
});
|
||||
|
||||
it('wraps additionalContext in the reserved tag before sending', async () => {
|
||||
const messageBus = {
|
||||
request: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
additionalContext: 'extra hook context',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
mockConfig.getMessageBus = vi.fn().mockReturnValue(messageBus);
|
||||
mockConfig.getDisableAllHooks = vi.fn().mockReturnValue(false);
|
||||
mockConfig.hasHooksForEvent = vi.fn().mockReturnValue(true);
|
||||
|
||||
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
|
||||
createStreamWithChunks([
|
||||
{
|
||||
type: core.StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'response' }] } }],
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
});
|
||||
|
||||
const sent = firstSentMessage();
|
||||
expect(textParts(sent)[0]).toBe('hello');
|
||||
expect(
|
||||
core.isUserPromptSubmitContextPartText(textParts(sent).at(-1)!),
|
||||
).toBe(true);
|
||||
expect(textParts(sent).at(-1)).toContain('extra hook context');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stop hook', () => {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ import {
|
|||
NotificationType,
|
||||
persistPermissionOutcome,
|
||||
createHookOutput,
|
||||
wrapUserPromptSubmitContext,
|
||||
generateToolUseId,
|
||||
MessageBusType,
|
||||
MessageDisplayDispatcher,
|
||||
|
|
@ -2852,10 +2853,15 @@ export class Session implements SessionContext {
|
|||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
|
||||
// Add additional context from hooks to the request
|
||||
// Add additional context from hooks to the request, wrapped in
|
||||
// the reserved tag so it stays distinguishable from
|
||||
// user-authored text (same shape as the interactive path).
|
||||
const additionalContext = hookOutput?.getAdditionalContext();
|
||||
if (additionalContext) {
|
||||
parts = [...parts, { text: additionalContext }];
|
||||
parts = [
|
||||
...parts,
|
||||
{ text: wrapUserPromptSubmitContext(additionalContext) },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6167,8 +6173,15 @@ export class Session implements SessionContext {
|
|||
}
|
||||
}
|
||||
|
||||
async refreshSkillsFromSettings(): Promise<void> {
|
||||
this.settings.reloadScopeFromDisk(SettingScope.Workspace);
|
||||
async refreshSkillsFromSettings(
|
||||
options: {
|
||||
reloadSettings?: boolean;
|
||||
notifyConfigChanged?: boolean;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
if (options.reloadSettings ?? true) {
|
||||
this.reloadSkillSettings();
|
||||
}
|
||||
const skillManager = this.config.getSkillManager();
|
||||
let updateFailed = false;
|
||||
let updateError: unknown;
|
||||
|
|
@ -6178,7 +6191,7 @@ export class Session implements SessionContext {
|
|||
updateFailed = true;
|
||||
updateError = error;
|
||||
}
|
||||
if (skillManager) {
|
||||
if (skillManager && (options.notifyConfigChanged ?? true)) {
|
||||
try {
|
||||
skillManager.suppressNextSlashReload();
|
||||
await skillManager.notifyConfigChanged();
|
||||
|
|
@ -6193,6 +6206,10 @@ export class Session implements SessionContext {
|
|||
if (updateFailed) throw updateError;
|
||||
}
|
||||
|
||||
reloadSkillSettings(): void {
|
||||
this.settings.reloadScopeFromDisk(SettingScope.Workspace);
|
||||
}
|
||||
|
||||
private async sendAvailableCommandsUpdateOrThrow(): Promise<void> {
|
||||
const { availableCommands, availableSkills, availableSkillDetails } =
|
||||
await buildAvailableCommandsSnapshot(
|
||||
|
|
|
|||
|
|
@ -1574,4 +1574,4 @@ describe('HistoryReplayer', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1864,6 +1864,14 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => {
|
|||
'"${QWEN_CODE_CLI:-qwen}" review test-efficacy /tmp/plan.json',
|
||||
);
|
||||
expect(p).toContain('--base abc123');
|
||||
// All three finding kinds are named, or the agent meets a `mutant-survived`
|
||||
// it was never told how to file — and the skipped/inconclusive mutants must
|
||||
// be fenced off from findings the same way the probes' inconclusive is.
|
||||
expect(p).toContain('`kind: "mutant-survived"`');
|
||||
expect(p).toContain('mutants.skippedForBudget');
|
||||
expect(p).toContain('mutants.skippedForCap');
|
||||
expect(p).toContain('mutants.skippedForBaseline');
|
||||
expect(p).toContain('mutants.note');
|
||||
// No bare executable `qwen` anywhere in this brief. Agent 7 is the one
|
||||
// SUBAGENT that shells out to the review CLI — the one call site neither the
|
||||
// SKILL.md sweep nor check-coverage's stderr hints can reach — and its shell
|
||||
|
|
|
|||
|
|
@ -860,7 +860,9 @@ export function buildRoleBrief(
|
|||
'',
|
||||
'**Then run the test-efficacy probe.** A green suite says the tests pass. It does ' +
|
||||
'not say they would have failed had the change been wrong, and those are ' +
|
||||
'different claims:',
|
||||
'different claims. Give this call `timeout: 600000` too — besides the revert ' +
|
||||
'probe it runs up to 8 single-statement deletion mutants, each a suite run, and ' +
|
||||
'it budgets itself to finish inside that ceiling:',
|
||||
'',
|
||||
'```bash',
|
||||
`"\${QWEN_CODE_CLI:-qwen}" review test-efficacy ${resolve(opts.planPath)} \\`,
|
||||
|
|
@ -872,11 +874,18 @@ export function buildRoleBrief(
|
|||
'Read its `findings[]`. `kind: "unreachable"` is a test the project\'s test command ' +
|
||||
'never collects — it did not run here and it does not run in CI. `kind: "inert"` is ' +
|
||||
'a test that **still passed with the change reverted**: it is green whether or not ' +
|
||||
'the feature exists, so it cannot catch a regression in it. Report each as a ' +
|
||||
'**Suggestion** with `Source: [test]`, saying plainly which behaviour ships ' +
|
||||
'unprotected. **`inconclusive` is not a finding** — reverting the source often ' +
|
||||
"breaks the test's own compile, and that is not the test catching anything. Note it " +
|
||||
'and move on.',
|
||||
'the feature exists, so it cannot catch a regression in it. `kind: "mutant-survived"` ' +
|
||||
'is a single safety statement the diff added (a `.clear()`, an `.abort(…)`, a ' +
|
||||
'reset-to-empty) that was **deleted and every affected test stayed green** — no ' +
|
||||
'test in the diff fails when it is removed, which the whole-file ' +
|
||||
"revert cannot see when the file's other, tested behaviours mask it. Report each as a " +
|
||||
'**Suggestion** with `Source: [test]`, saying plainly which behaviour has no ' +
|
||||
'test in this diff that would catch its removal. **`inconclusive` is not a ' +
|
||||
'finding** — for probes and mutants alike, ' +
|
||||
"reverting or mutating the source often breaks the test's own compile, and that is " +
|
||||
'not the test catching anything. Mutants counted in `mutants.skippedForBudget`, ' +
|
||||
'`mutants.skippedForCap`, or `mutants.skippedForBaseline` never ran — not findings ' +
|
||||
'either. `mutants.note`, when present, explains why no mutants ran at all. Note them and move on.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
// verdict logic is unit-tested in `classifyProbeRun`; what these lock down is
|
||||
// where the probe runs and what it leaves behind.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync,
|
||||
|
|
@ -25,13 +25,14 @@ import {
|
|||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { testEfficacyCommand } from './test-efficacy.js';
|
||||
import { runOneMutant, testEfficacyCommand } from './test-efficacy.js';
|
||||
|
||||
type Handler = (args: {
|
||||
report: string;
|
||||
worktree: string;
|
||||
base: string;
|
||||
out: string;
|
||||
now?: () => number;
|
||||
}) => Promise<void>;
|
||||
const runHandler = testEfficacyCommand.handler as unknown as Handler;
|
||||
|
||||
|
|
@ -97,10 +98,62 @@ function scaffoldModifiedPr(): { wt: string; base: string } {
|
|||
return { wt, base };
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the fake runner for one that reports every test file as FAILED. Used to
|
||||
* drive the unmutated baseline red, so the mutant phase must skip wholesale.
|
||||
*/
|
||||
function installFailingVitest(): void {
|
||||
const bin = join(repo, 'node_modules', '.bin', 'vitest');
|
||||
writeFileSync(
|
||||
bin,
|
||||
`#!/usr/bin/env node
|
||||
const path = require('path');
|
||||
const files = process.argv.slice(2).filter((a) => a.includes('.test.'));
|
||||
process.stdout.write(JSON.stringify({
|
||||
numPassedTests: 0,
|
||||
numFailedTests: files.length,
|
||||
testResults: files.map((f) => ({
|
||||
name: path.resolve(f),
|
||||
assertionResults: [{ status: 'failed' }],
|
||||
})),
|
||||
}));
|
||||
`,
|
||||
);
|
||||
chmodSync(bin, 0o755);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the fake runner for one that reports a file whose path contains "skip"
|
||||
* as all-skipped (collected, but no assertion executed) and every other file as
|
||||
* PASSED. Drives the per-file baseline gate: an unrelated all-skip file is
|
||||
* `inconclusive`, not red, and must not disable the mutant phase.
|
||||
*/
|
||||
function installMixedVitest(): void {
|
||||
const bin = join(repo, 'node_modules', '.bin', 'vitest');
|
||||
writeFileSync(
|
||||
bin,
|
||||
`#!/usr/bin/env node
|
||||
const path = require('path');
|
||||
const files = process.argv.slice(2).filter((a) => a.includes('.test.'));
|
||||
process.stdout.write(JSON.stringify({
|
||||
testResults: files.map((f) => ({
|
||||
name: path.resolve(f),
|
||||
assertionResults: [{ status: f.includes('skip') ? 'skipped' : 'passed' }],
|
||||
})),
|
||||
}));
|
||||
`,
|
||||
);
|
||||
chmodSync(bin, 0o755);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mkdtempSync(join(tmpdir(), 'efficacy-iso-'));
|
||||
outside = mkdtempSync(join(tmpdir(), 'efficacy-outside-'));
|
||||
git(repo, 'init', '-q', '-b', 'main', '.');
|
||||
// Keep the fake vitest out of git: `commitAll` runs `git add -A`, and a
|
||||
// committed bin would be checked out into the probe worktree — the stale
|
||||
// passing copy, not the file `installFailingVitest` overwrites.
|
||||
writeFileSync(join(repo, '.gitignore'), 'node_modules\n');
|
||||
|
||||
// A fake `vitest` on the up-tree bin path so `npx vitest` in the probe tree
|
||||
// resolves locally — fast, deterministic, no network. It echoes each test
|
||||
|
|
@ -225,6 +278,775 @@ describe('test-efficacy probe isolation (#6832)', () => {
|
|||
expect(existsSync(join(repo, 'wt-probe'))).toBe(false);
|
||||
});
|
||||
|
||||
it('runs a deletion mutant end-to-end and reports the survivor', async () => {
|
||||
// The dogfood shape at full scale: the PR adds a reset function whose one
|
||||
// safety statement (`state.clear()`) nothing gates. The fake vitest is
|
||||
// green no matter what, so the baseline run passes, the mutant run passes
|
||||
// — a SURVIVOR — and the revert probe still reads the test as inert. Both
|
||||
// trees end clean: the mutation happened only in the disposable worktree.
|
||||
write('package.json', '{"private":true,"workspaces":["packages/*"]}\n');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export function use(k: string) {\n' +
|
||||
' return state.get(k);\n' +
|
||||
'}\n',
|
||||
);
|
||||
const base = commitAll('base');
|
||||
const prSource =
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export function use(k: string) {\n' +
|
||||
' return state.get(k);\n' +
|
||||
'}\n' +
|
||||
'export function reset() {\n' +
|
||||
' state.clear();\n' +
|
||||
'}\n';
|
||||
write('packages/lib/src/f.ts', prSource);
|
||||
write(
|
||||
'packages/lib/src/f.test.ts',
|
||||
'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n',
|
||||
);
|
||||
commitAll('pr');
|
||||
const wt = join(repo, 'wt');
|
||||
git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD');
|
||||
writeFileSync(
|
||||
join(repo, 'report.json'),
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{ path: 'packages/lib/src/f.ts', kind: 'source' },
|
||||
{ path: 'packages/lib/src/f.test.ts', kind: 'test' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const before = treeState(wt);
|
||||
await runHandler({
|
||||
report: join(repo, 'report.json'),
|
||||
worktree: wt,
|
||||
base,
|
||||
out: join(repo, 'out.json'),
|
||||
});
|
||||
|
||||
const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8'));
|
||||
expect(out.mutants.probed).toEqual([
|
||||
{
|
||||
file: 'packages/lib/src/f.ts',
|
||||
line: 6,
|
||||
statement: 'state.clear();',
|
||||
verdict: 'survived',
|
||||
detail: expect.stringContaining('still PASSED'),
|
||||
},
|
||||
]);
|
||||
expect(out.mutants.survived).toBe(1);
|
||||
expect(out.mutants.skippedForBudget).toBe(0);
|
||||
// The survivor is a finding the orchestrator files; the register matches
|
||||
// the unreachable/inert messages Agent 7's brief already knows how to read.
|
||||
const survivor = (
|
||||
out.findings as Array<{ kind: string; file: string; message: string }>
|
||||
).find((f) => f.kind === 'mutant-survived');
|
||||
expect(survivor?.file).toBe('packages/lib/src/f.ts');
|
||||
expect(survivor?.message).toContain('state.clear();');
|
||||
// The mutation never touched the shared tree, and the probe tree is gone.
|
||||
expect(treeState(wt)).toBe(before);
|
||||
expect(readFileSync(join(wt, 'packages/lib/src/f.ts'), 'utf8')).toBe(
|
||||
prSource,
|
||||
);
|
||||
expect(existsSync(join(repo, 'wt-probe'))).toBe(false);
|
||||
});
|
||||
|
||||
it('kills a mutant the suite catches — the A/B control for the survivor test', async () => {
|
||||
// Same source, same statement, same line as the survivor test above. The
|
||||
// ONLY variable is the fake runner: here it reads the source and fails when
|
||||
// `state.clear()` is gone — a genuinely gating test. The mutant must be
|
||||
// KILLED (no finding), proving the verdict tracks the test, not the harness.
|
||||
write('package.json', '{"private":true,"workspaces":["packages/*"]}\n');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export function use(k: string) {\n' +
|
||||
' return state.get(k);\n' +
|
||||
'}\n',
|
||||
);
|
||||
const base = commitAll('base');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export function use(k: string) {\n' +
|
||||
' return state.get(k);\n' +
|
||||
'}\n' +
|
||||
'export function reset() {\n' +
|
||||
' state.clear();\n' +
|
||||
'}\n',
|
||||
);
|
||||
write(
|
||||
'packages/lib/src/f.test.ts',
|
||||
'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n',
|
||||
);
|
||||
commitAll('pr');
|
||||
const wt = join(repo, 'wt');
|
||||
git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD');
|
||||
writeFileSync(
|
||||
join(repo, 'report.json'),
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{ path: 'packages/lib/src/f.ts', kind: 'source' },
|
||||
{ path: 'packages/lib/src/f.test.ts', kind: 'test' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
// The fake runner reads the source: green when `state.clear()` is present,
|
||||
// red when it is gone. The baseline passes; the mutant (statement deleted)
|
||||
// fails — KILLED.
|
||||
const bin = join(repo, 'node_modules', '.bin', 'vitest');
|
||||
writeFileSync(
|
||||
bin,
|
||||
`#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const files = process.argv.slice(2).filter((a) => a.includes('.test.'));
|
||||
const src = fs.readFileSync(path.join(process.cwd(), 'packages/lib/src/f.ts'), 'utf8');
|
||||
const failed = src.includes('state.clear()') ? 0 : 1;
|
||||
process.stdout.write(JSON.stringify({
|
||||
numPassedTests: failed ? 0 : files.length,
|
||||
numFailedTests: failed ? files.length : 0,
|
||||
testResults: files.map((f) => ({
|
||||
name: path.resolve(f),
|
||||
assertionResults: [{ status: failed ? 'failed' : 'passed' }],
|
||||
})),
|
||||
}));
|
||||
`,
|
||||
);
|
||||
chmodSync(bin, 0o755);
|
||||
|
||||
const before = treeState(wt);
|
||||
await runHandler({
|
||||
report: join(repo, 'report.json'),
|
||||
worktree: wt,
|
||||
base,
|
||||
out: join(repo, 'out.json'),
|
||||
});
|
||||
|
||||
const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8'));
|
||||
expect(out.mutants.probed).toEqual([
|
||||
{
|
||||
file: 'packages/lib/src/f.ts',
|
||||
line: 6,
|
||||
statement: 'state.clear();',
|
||||
verdict: 'killed',
|
||||
detail: expect.stringContaining('suite went red'),
|
||||
},
|
||||
]);
|
||||
expect(out.mutants.killed).toBe(1);
|
||||
expect(out.mutants.survived).toBe(0);
|
||||
// A killed mutant is the GOOD outcome — no finding.
|
||||
expect(
|
||||
(out.findings as Array<{ kind: string }>).some(
|
||||
(f) => f.kind === 'mutant-survived',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(treeState(wt)).toBe(before);
|
||||
expect(existsSync(join(repo, 'wt-probe'))).toBe(false);
|
||||
});
|
||||
|
||||
it('skips the mutants wholesale when the unmutated baseline is not green', async () => {
|
||||
// A mutant is only evidence against a suite that is green WITHOUT it: against
|
||||
// a baseline that already fails, every mutant would be "killed" by failures
|
||||
// it did not cause. So when no probe file is green in the unmutated run, the whole
|
||||
// mutant phase is skipped and the report says so — no probed mutants and no
|
||||
// survivor finding, even though the diff adds an ungated safety statement.
|
||||
write('package.json', '{"private":true,"workspaces":["packages/*"]}\n');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n',
|
||||
);
|
||||
const base = commitAll('base');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export function reset() {\n' +
|
||||
' state.clear();\n' +
|
||||
'}\n',
|
||||
);
|
||||
// The test FAILS, so the suite is not cleanly green under a real runner
|
||||
// too — not only under the fake one installed below. Whichever runner the
|
||||
// probe resolves to, the baseline is red and the mutants must be skipped.
|
||||
write(
|
||||
'packages/lib/src/f.test.ts',
|
||||
'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => { reset(); expect(1).toBe(2); });\n',
|
||||
);
|
||||
commitAll('pr');
|
||||
const wt = join(repo, 'wt');
|
||||
git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD');
|
||||
writeFileSync(
|
||||
join(repo, 'report.json'),
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{ path: 'packages/lib/src/f.ts', kind: 'source' },
|
||||
{ path: 'packages/lib/src/f.test.ts', kind: 'test' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
// The unmutated suite is NOT green: the fake runner reports a failure.
|
||||
installFailingVitest();
|
||||
|
||||
const stdoutChunks: string[] = [];
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation((chunk) => {
|
||||
stdoutChunks.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
await runHandler({
|
||||
report: join(repo, 'report.json'),
|
||||
worktree: wt,
|
||||
base,
|
||||
out: join(repo, 'out.json'),
|
||||
});
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
}
|
||||
|
||||
const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8'));
|
||||
expect(out.mutants.probed).toEqual([]);
|
||||
expect(out.mutants.skippedForBaseline).toBe(1);
|
||||
expect(out.mutants.note).toContain('no probe file was green');
|
||||
expect(
|
||||
(out.findings as Array<{ kind: string }>).some(
|
||||
(f) => f.kind === 'mutant-survived',
|
||||
),
|
||||
).toBe(false);
|
||||
const stdout = stdoutChunks.join('');
|
||||
expect(stdout).toContain(
|
||||
'1 mutant(s) skipped: no probe file was green in the unmutated baseline',
|
||||
);
|
||||
expect(stdout).toContain('mutants not run: no probe file was green');
|
||||
});
|
||||
|
||||
it('still probes when an UNRELATED probe file is all-skipped (per-file gate)', async () => {
|
||||
// Finding 2's shape: a quarantined suite that is entirely `it.skip`
|
||||
// classifies `inconclusive` — not red, not a failure. The old whole-suite
|
||||
// gate read that as "not cleanly green" and took the ENTIRE mutant phase
|
||||
// down with it, losing the survivor finding below. The gate is per file:
|
||||
// the mutant runs against the probe files that ARE green in the baseline,
|
||||
// so an unrelated all-skip file no longer disables it.
|
||||
write('package.json', '{"private":true,"workspaces":["packages/*"]}\n');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n',
|
||||
);
|
||||
const base = commitAll('base');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export function reset() {\n' +
|
||||
' state.clear();\n' +
|
||||
'}\n',
|
||||
);
|
||||
write(
|
||||
'packages/lib/src/f.test.ts',
|
||||
'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n',
|
||||
);
|
||||
// An unrelated suite that collects but runs nothing (all skipped).
|
||||
write(
|
||||
'packages/lib/src/skipped.test.ts',
|
||||
'import { it } from "vitest"; it.skip("quarantined", () => {});\n',
|
||||
);
|
||||
commitAll('pr');
|
||||
const wt = join(repo, 'wt');
|
||||
git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD');
|
||||
writeFileSync(
|
||||
join(repo, 'report.json'),
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{ path: 'packages/lib/src/f.ts', kind: 'source' },
|
||||
{ path: 'packages/lib/src/f.test.ts', kind: 'test' },
|
||||
{ path: 'packages/lib/src/skipped.test.ts', kind: 'test' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
// Baseline: f.test.ts passes (inert), skipped.test.ts collects but runs
|
||||
// nothing (inconclusive). The mutant must still run against the green file.
|
||||
installMixedVitest();
|
||||
|
||||
await runHandler({
|
||||
report: join(repo, 'report.json'),
|
||||
worktree: wt,
|
||||
base,
|
||||
out: join(repo, 'out.json'),
|
||||
});
|
||||
|
||||
const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8'));
|
||||
expect(out.mutants.note).toBeUndefined();
|
||||
expect(out.mutants.survived).toBe(1);
|
||||
expect(out.mutants.probed).toEqual([
|
||||
{
|
||||
file: 'packages/lib/src/f.ts',
|
||||
line: 3,
|
||||
statement: 'state.clear();',
|
||||
verdict: 'survived',
|
||||
detail: expect.stringContaining('still PASSED'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports mutants skipped for budget when time runs out mid-loop', async () => {
|
||||
// Three safety-verb candidates, but the budget expires after one: the
|
||||
// counter, the `skippedForBudget` report field, and the stdout line are
|
||||
// exercised end-to-end. The injected clock advances 100 s per SUITE RUN
|
||||
// (the fake runner logs each run; the real budget is 540 s and a real run
|
||||
// cannot reach it in a test) — a simulated duration, not a count of
|
||||
// `Date.now()` calls, so the implementation is free to consult the clock
|
||||
// as often as it likes. The mutant deadline is 240 s (540 − 300 revert
|
||||
// reservation), the baseline measures 100 s, so `estimatedRunMs` is
|
||||
// 115 s; after the baseline and one mutant the clock reads 200 s and the
|
||||
// remaining 40 s cannot fit another run.
|
||||
write('package.json', '{"private":true,"workspaces":["packages/*"]}\n');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export let items: string[] = ["a"];\n' +
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export const cache = new Set<string>();\n',
|
||||
);
|
||||
const base = commitAll('base');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export let items: string[] = ["a"];\n' +
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export const cache = new Set<string>();\n' +
|
||||
'export function reset() {\n' +
|
||||
' items = [];\n' +
|
||||
' state.clear();\n' +
|
||||
' cache.clear();\n' +
|
||||
'}\n',
|
||||
);
|
||||
write(
|
||||
'packages/lib/src/f.test.ts',
|
||||
'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n',
|
||||
);
|
||||
commitAll('pr');
|
||||
const wt = join(repo, 'wt');
|
||||
git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD');
|
||||
writeFileSync(
|
||||
join(repo, 'report.json'),
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{ path: 'packages/lib/src/f.ts', kind: 'source' },
|
||||
{ path: 'packages/lib/src/f.test.ts', kind: 'test' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// The fake runner appends one line per invocation; the injected clock
|
||||
// reads the log, so it moves only when a suite actually runs.
|
||||
const runsLog = join(repo, 'runs.log');
|
||||
const bin = join(repo, 'node_modules', '.bin', 'vitest');
|
||||
writeFileSync(
|
||||
bin,
|
||||
`#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
fs.appendFileSync(${JSON.stringify(runsLog)}, 'run\\n');
|
||||
const files = process.argv.slice(2).filter((a) => a.includes('.test.'));
|
||||
process.stdout.write(JSON.stringify({
|
||||
numPassedTests: files.length,
|
||||
numFailedTests: 0,
|
||||
testResults: files.map((f) => ({
|
||||
name: path.resolve(f),
|
||||
assertionResults: [{ status: 'passed' }],
|
||||
})),
|
||||
}));
|
||||
`,
|
||||
);
|
||||
chmodSync(bin, 0o755);
|
||||
const suiteRuns = () =>
|
||||
existsSync(runsLog)
|
||||
? readFileSync(runsLog, 'utf8').split('\n').filter(Boolean).length
|
||||
: 0;
|
||||
// The skip must also be DISCLOSED on stdout — a capped run that stays
|
||||
// silent lets `survived: 0` read as "every safety statement is covered".
|
||||
const stdoutChunks: string[] = [];
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation((chunk) => {
|
||||
stdoutChunks.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
await runHandler({
|
||||
report: join(repo, 'report.json'),
|
||||
worktree: wt,
|
||||
base,
|
||||
out: join(repo, 'out.json'),
|
||||
now: () => suiteRuns() * 100_000,
|
||||
});
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
}
|
||||
|
||||
const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8'));
|
||||
expect(out.mutants.probed.length).toBe(1);
|
||||
expect(out.mutants.skippedForBudget).toBe(2);
|
||||
expect(out.mutants.skippedForBaseline).toBe(0);
|
||||
expect(out.mutants.probed.length + out.mutants.skippedForBudget).toBe(3);
|
||||
for (const m of out.mutants.probed) {
|
||||
expect(m.verdict).toBe('survived');
|
||||
}
|
||||
expect(stdoutChunks.join('')).toContain(
|
||||
'2 mutant(s) skipped: the remaining budget cannot fit another suite run',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports mutants skipped for cap when candidates exceed MAX_MUTANTS', async () => {
|
||||
// Nine safety-verb candidates but MAX_MUTANTS is 8: the counter, the
|
||||
// `skippedForCap` report field, and the stdout line are exercised
|
||||
// end-to-end, mirroring the budget-skip test above.
|
||||
write('package.json', '{"private":true,"workspaces":["packages/*"]}\n');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n',
|
||||
);
|
||||
const base = commitAll('base');
|
||||
const stmts = Array.from({ length: 9 }, (_, i) => ` state${i}.clear();`);
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export function reset() {\n' +
|
||||
stmts.join('\n') +
|
||||
'\n}\n',
|
||||
);
|
||||
write(
|
||||
'packages/lib/src/f.test.ts',
|
||||
'import { it, expect } from "vitest"; it("t", () => expect(1).toBe(1));\n',
|
||||
);
|
||||
commitAll('pr');
|
||||
const wt = join(repo, 'wt');
|
||||
git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD');
|
||||
writeFileSync(
|
||||
join(repo, 'report.json'),
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{ path: 'packages/lib/src/f.ts', kind: 'source' },
|
||||
{ path: 'packages/lib/src/f.test.ts', kind: 'test' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const stdoutChunks: string[] = [];
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation((chunk) => {
|
||||
stdoutChunks.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
await runHandler({
|
||||
report: join(repo, 'report.json'),
|
||||
worktree: wt,
|
||||
base,
|
||||
out: join(repo, 'out.json'),
|
||||
});
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
}
|
||||
|
||||
const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8'));
|
||||
expect(out.mutants.probed.length).toBe(8);
|
||||
expect(out.mutants.skippedForCap).toBe(1);
|
||||
expect(out.mutants.skippedForBaseline).toBe(0);
|
||||
expect(out.mutants.probed.length + out.mutants.skippedForCap).toBe(9);
|
||||
expect(stdoutChunks.join('')).toContain(
|
||||
'1 mutant(s) skipped: more candidates than the cap of 8',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks every candidate inconclusive when the runner dies mid-mutation, and still runs the revert probe', async () => {
|
||||
// The mutation-phase catch: a runner killed (or failing to spawn) during a
|
||||
// mutant run is not evidence about any statement. Every candidate that
|
||||
// never got a verdict — the one being run AND the ones never attempted —
|
||||
// must come back `inconclusive` with the reason, the revert probe must
|
||||
// still run, and the report must still be written. The fake runner passes
|
||||
// the baseline (run 1), floods stdout past spawnSync's 64 MiB maxBuffer on
|
||||
// run 2 (the first mutant) so the runner spawn itself errors (ENOBUFS),
|
||||
// and passes the revert probe (run 3).
|
||||
write('package.json', '{"private":true,"workspaces":["packages/*"]}\n');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export const cache = new Set<string>();\n',
|
||||
);
|
||||
const base = commitAll('base');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export const cache = new Set<string>();\n' +
|
||||
'export function reset() {\n' +
|
||||
' state.clear();\n' +
|
||||
' cache.clear();\n' +
|
||||
'}\n',
|
||||
);
|
||||
write(
|
||||
'packages/lib/src/f.test.ts',
|
||||
'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n',
|
||||
);
|
||||
commitAll('pr');
|
||||
const wt = join(repo, 'wt');
|
||||
git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD');
|
||||
writeFileSync(
|
||||
join(repo, 'report.json'),
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{ path: 'packages/lib/src/f.ts', kind: 'source' },
|
||||
{ path: 'packages/lib/src/f.test.ts', kind: 'test' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const callsFile = join(repo, 'calls.txt');
|
||||
const bin = join(repo, 'node_modules', '.bin', 'vitest');
|
||||
writeFileSync(
|
||||
bin,
|
||||
`#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
let n = 0;
|
||||
try { n = parseInt(fs.readFileSync(${JSON.stringify(callsFile)}, 'utf8'), 10) || 0; } catch {}
|
||||
n += 1;
|
||||
fs.writeFileSync(${JSON.stringify(callsFile)}, String(n));
|
||||
if (n === 2) {
|
||||
const big = Buffer.alloc(8 * 1024 * 1024, 97);
|
||||
try { for (let i = 0; i < 10; i++) fs.writeSync(1, big); } catch {}
|
||||
process.exit(0);
|
||||
}
|
||||
const files = process.argv.slice(2).filter((a) => a.includes('.test.'));
|
||||
process.stdout.write(JSON.stringify({
|
||||
numPassedTests: files.length,
|
||||
numFailedTests: 0,
|
||||
testResults: files.map((f) => ({
|
||||
name: path.resolve(f),
|
||||
assertionResults: [{ status: 'passed' }],
|
||||
})),
|
||||
}));
|
||||
`,
|
||||
);
|
||||
chmodSync(bin, 0o755);
|
||||
|
||||
await runHandler({
|
||||
report: join(repo, 'report.json'),
|
||||
worktree: wt,
|
||||
base,
|
||||
out: join(repo, 'out.json'),
|
||||
});
|
||||
|
||||
const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8'));
|
||||
expect(out.mutants.probed).toHaveLength(2);
|
||||
for (const m of out.mutants.probed as Array<{
|
||||
verdict: string;
|
||||
detail: string;
|
||||
}>) {
|
||||
expect(m.verdict).toBe('inconclusive');
|
||||
expect(m.detail).toContain('mutation probe could not run');
|
||||
}
|
||||
expect(out.mutants.probed[0].detail).toContain('ENOBUFS');
|
||||
expect(out.mutants.inconclusive).toBe(2);
|
||||
expect(out.mutants.killed).toBe(0);
|
||||
expect(out.mutants.survived).toBe(0);
|
||||
expect(
|
||||
(out.findings as Array<{ kind: string }>).some(
|
||||
(f) => f.kind === 'mutant-survived',
|
||||
),
|
||||
).toBe(false);
|
||||
// The revert probe still ran: a real verdict from run 3, not a propagated
|
||||
// mutation failure.
|
||||
expect(out.probed).toEqual([
|
||||
expect.objectContaining({
|
||||
file: 'packages/lib/src/f.test.ts',
|
||||
verdict: 'inert',
|
||||
}),
|
||||
]);
|
||||
expect(existsSync(join(repo, 'wt-probe'))).toBe(false);
|
||||
});
|
||||
|
||||
it('still finds the survivor under hostile user git diff config', async () => {
|
||||
// A developer's diff.srcPrefix/dstPrefix reshapes the `+++ b/…` headers
|
||||
// parseAddedLines anchors on, diff.external replaces the unified diff with
|
||||
// an external command's output (here one that dies outright), and
|
||||
// core.quotePath octal-escapes every non-ASCII path — each one alone
|
||||
// would turn selection into a silent zero or a selection failure. The
|
||||
// invocation pins its own prefixes and disables ext-diff/textconv/quoting,
|
||||
// so the survivor must still be found, in a non-ASCII path too.
|
||||
git(repo, 'config', 'diff.srcPrefix', 'left/');
|
||||
git(repo, 'config', 'diff.dstPrefix', 'right/');
|
||||
git(repo, 'config', 'diff.external', 'false');
|
||||
git(repo, 'config', 'core.quotePath', 'true');
|
||||
write('package.json', '{"private":true,"workspaces":["packages/*"]}\n');
|
||||
write(
|
||||
'packages/lib/src/fø.ts',
|
||||
'export const state = new Map<string, string>();\n',
|
||||
);
|
||||
const base = commitAll('base');
|
||||
write(
|
||||
'packages/lib/src/fø.ts',
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export function reset() {\n' +
|
||||
' state.clear();\n' +
|
||||
'}\n',
|
||||
);
|
||||
write(
|
||||
'packages/lib/src/f.test.ts',
|
||||
'import { it, expect } from "vitest"; it("t", () => expect(1).toBe(1));\n',
|
||||
);
|
||||
commitAll('pr');
|
||||
const wt = join(repo, 'wt');
|
||||
git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD');
|
||||
writeFileSync(
|
||||
join(repo, 'report.json'),
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{ path: 'packages/lib/src/fø.ts', kind: 'source' },
|
||||
{ path: 'packages/lib/src/f.test.ts', kind: 'test' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await runHandler({
|
||||
report: join(repo, 'report.json'),
|
||||
worktree: wt,
|
||||
base,
|
||||
out: join(repo, 'out.json'),
|
||||
});
|
||||
|
||||
const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8'));
|
||||
expect(out.mutants.note).toBeUndefined();
|
||||
expect(out.mutants.survived).toBe(1);
|
||||
expect(out.mutants.probed).toEqual([
|
||||
{
|
||||
file: 'packages/lib/src/fø.ts',
|
||||
line: 3,
|
||||
statement: 'state.clear();',
|
||||
verdict: 'survived',
|
||||
detail: expect.stringContaining('still PASSED'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('discloses the dropped candidates when a file derails the literal scan', async () => {
|
||||
// A regex literal holding a backtick flips the whole-file scan into
|
||||
// template state through to EOF, so every candidate in the file — here a
|
||||
// genuinely ungated `state.clear()` — is dropped as untrustworthy. That
|
||||
// zero must be DISCLOSED in `mutants.note`, never silent: a report that
|
||||
// says `survived: 0` without it reads as "every safety statement is
|
||||
// covered". The revert probe does not depend on selection and still runs.
|
||||
write('package.json', '{"private":true,"workspaces":["packages/*"]}\n');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n',
|
||||
);
|
||||
const base = commitAll('base');
|
||||
write(
|
||||
'packages/lib/src/f.ts',
|
||||
'export const state = new Map<string, string>();\n' +
|
||||
'export const TICK_RE = /`/;\n' +
|
||||
'export function reset() {\n' +
|
||||
' state.clear();\n' +
|
||||
'}\n',
|
||||
);
|
||||
write(
|
||||
'packages/lib/src/f.test.ts',
|
||||
'import { reset } from "./f.js"; import { it, expect } from "vitest"; it("t", () => expect(typeof reset).toBe("function"));\n',
|
||||
);
|
||||
commitAll('pr');
|
||||
const wt = join(repo, 'wt');
|
||||
git(repo, 'worktree', 'add', '-q', '--detach', wt, 'HEAD');
|
||||
writeFileSync(
|
||||
join(repo, 'report.json'),
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{ path: 'packages/lib/src/f.ts', kind: 'source' },
|
||||
{ path: 'packages/lib/src/f.test.ts', kind: 'test' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const stdoutChunks: string[] = [];
|
||||
const stdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation((chunk) => {
|
||||
stdoutChunks.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
await runHandler({
|
||||
report: join(repo, 'report.json'),
|
||||
worktree: wt,
|
||||
base,
|
||||
out: join(repo, 'out.json'),
|
||||
});
|
||||
} finally {
|
||||
stdoutSpy.mockRestore();
|
||||
}
|
||||
|
||||
const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8'));
|
||||
expect(out.mutants.probed).toEqual([]);
|
||||
expect(out.mutants.note).toContain('literal scan derailed');
|
||||
expect(out.mutants.note).toContain('packages/lib/src/f.ts');
|
||||
expect(stdoutChunks.join('')).toContain('literal scan derailed');
|
||||
// The revert probe still produced a real verdict.
|
||||
expect(out.probed).toEqual([
|
||||
expect.objectContaining({
|
||||
file: 'packages/lib/src/f.test.ts',
|
||||
verdict: 'inert',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('discloses a selection failure and still runs the revert probe', async () => {
|
||||
// Mutant selection captures the diff with `git diff <base>`, and a base
|
||||
// this repository cannot resolve (a shallow clone's truncated history has
|
||||
// exactly this shape) makes that capture throw. The catch is load-bearing:
|
||||
// without it the whole command crashes and the revert probe — which does
|
||||
// not depend on selection — is lost with it. The failure must be disclosed
|
||||
// as the mutants note, never as a crash and never as silent zero mutants.
|
||||
const { wt } = scaffoldModifiedPr();
|
||||
|
||||
await runHandler({
|
||||
report: join(repo, 'report.json'),
|
||||
worktree: wt,
|
||||
base: 'no-such-base-rev',
|
||||
out: join(repo, 'out.json'),
|
||||
});
|
||||
|
||||
const out = JSON.parse(readFileSync(join(repo, 'out.json'), 'utf8'));
|
||||
expect(out.mutants.note).toContain('mutant selection failed');
|
||||
expect(out.mutants.probed).toEqual([]);
|
||||
// The revert probe still produced a real verdict from the fake runner.
|
||||
expect(out.probed).toEqual([
|
||||
expect.objectContaining({
|
||||
file: 'packages/lib/src/f.test.ts',
|
||||
verdict: 'inert',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('never deletes a line that does not hold the selected statement', () => {
|
||||
// `runOneMutant`'s mismatch guard, pinned directly: selection and the
|
||||
// probe tree both derive from the same commit, so the command cannot reach
|
||||
// this branch — but if the guard were dropped, a stale line number would
|
||||
// delete the WRONG statement and attribute the run's verdict (here the
|
||||
// fake runner's green — `survived`) to a statement that was never removed.
|
||||
write('src/x.ts', 'alpha();\nbeta();\n');
|
||||
const before = readFileSync(join(repo, 'src/x.ts'), 'utf8');
|
||||
|
||||
const got = runOneMutant(
|
||||
repo,
|
||||
{ file: 'src/x.ts', line: 1, statement: 'gone.clear();' },
|
||||
['src/x.test.ts'],
|
||||
);
|
||||
|
||||
expect(got.verdict).toBe('inconclusive');
|
||||
expect(got.detail).toContain('does not match the selected statement');
|
||||
expect(readFileSync(join(repo, 'src/x.ts'), 'utf8')).toBe(before);
|
||||
});
|
||||
|
||||
it('sweeps a stale REGISTERED probe worktree left by a crashed run', async () => {
|
||||
const { wt, base } = scaffoldModifiedPr();
|
||||
// A prior probe crashed after `worktree add` but before its cleanup, leaving
|
||||
|
|
|
|||
|
|
@ -9,9 +9,15 @@ import {
|
|||
isWorkspaceMember,
|
||||
planTestEfficacy,
|
||||
classifyProbeRun,
|
||||
classifyMutantRun,
|
||||
safeRmWithin,
|
||||
selectMutants,
|
||||
parseAddedLines,
|
||||
hasCollocatedNewTest,
|
||||
fitsAnotherMutantRun,
|
||||
probeCreateFailureDetail,
|
||||
probeCleanupFailureDetail,
|
||||
MAX_MUTANTS,
|
||||
} from './test-efficacy.js';
|
||||
import {
|
||||
mkdtempSync,
|
||||
|
|
@ -405,3 +411,740 @@ describe('classifyProbeRun', () => {
|
|||
expect(got.detail).toContain('none executed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAddedLines', () => {
|
||||
it('numbers added lines on the NEW side, per post-change path', () => {
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'index 1111111..2222222 100644',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -10,0 +11,2 @@ ctx',
|
||||
'+first added',
|
||||
'+second added',
|
||||
'@@ -20 +22,0 @@ ctx',
|
||||
'-removed only',
|
||||
'diff --git a/src/gone.ts b/src/gone.ts',
|
||||
'deleted file mode 100644',
|
||||
'--- a/src/gone.ts',
|
||||
'+++ /dev/null',
|
||||
'@@ -1,2 +0,0 @@',
|
||||
'-x',
|
||||
'-y',
|
||||
'diff --git a/src/b.ts b/src/b.ts',
|
||||
'new file mode 100644',
|
||||
'--- /dev/null',
|
||||
'+++ b/src/b.ts',
|
||||
'@@ -0,0 +1 @@',
|
||||
'+only line',
|
||||
'',
|
||||
].join('\n');
|
||||
const got = parseAddedLines(diff);
|
||||
// The `index`/`new file mode` header lines sit between hunks; counting
|
||||
// them as context would shift every number below by the header count.
|
||||
expect(got.get('src/a.ts')).toEqual([11, 12]);
|
||||
expect(got.get('src/b.ts')).toEqual([1]);
|
||||
// A deletion has no new side and must contribute nothing.
|
||||
expect(got.has('src/gone.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('counts context lines, so a default -U3 diff still numbers correctly', () => {
|
||||
const diff = [
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -4,3 +4,4 @@',
|
||||
' ctx one',
|
||||
'+added',
|
||||
' ctx two',
|
||||
' ctx three',
|
||||
'',
|
||||
].join('\n');
|
||||
expect(parseAddedLines(diff).get('src/a.ts')).toEqual([5]);
|
||||
});
|
||||
|
||||
it('does not count a "\\ No newline" marker as a context line', () => {
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -5,0 +6,2 @@',
|
||||
'+added line',
|
||||
'\\ No newline at end of file',
|
||||
'+second added',
|
||||
].join('\n');
|
||||
const got = parseAddedLines(diff);
|
||||
expect(got.get('src/a.ts')).toEqual([6, 7]);
|
||||
});
|
||||
|
||||
it('does not read an added `++ x` line as a file header', () => {
|
||||
// `git diff --unified=0` prefixes each added line with `+`, so a spaced
|
||||
// pre-increment (`++ count;`) renders as `+++ count;`. Matching `+++ `
|
||||
// unconditionally misreads it as a header, drops the line, and attributes
|
||||
// every later added line in the file to a phantom path. The next file's
|
||||
// real header must still be recognised once its `diff --git` leaves the
|
||||
// hunk.
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -1,0 +2,2 @@ ctx',
|
||||
'+++ count;',
|
||||
'+tail.clear();',
|
||||
'diff --git a/src/b.ts b/src/b.ts',
|
||||
'--- a/src/b.ts',
|
||||
'+++ b/src/b.ts',
|
||||
'@@ -0,0 +1 @@',
|
||||
'+only line',
|
||||
'',
|
||||
].join('\n');
|
||||
const got = parseAddedLines(diff);
|
||||
expect(got.get('src/a.ts')).toEqual([2, 3]);
|
||||
expect(got.get('src/b.ts')).toEqual([1]);
|
||||
expect(got.has('count;')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectMutants', () => {
|
||||
const src = (lines: string[]) => lines.join('\n');
|
||||
const all = (n: number) => Array.from({ length: n }, (_, i) => i + 1);
|
||||
|
||||
it('selects the dogfood shape: one safety statement inside a guarded branch', () => {
|
||||
// The finding the revert probe is structurally blind to: the sole
|
||||
// statement of a not-continued branch. Deleting it leaves `{}` — legal —
|
||||
// and the file still carries its other, tested behaviour. The comment
|
||||
// above it must not block the walk back to the `{` that proves the line
|
||||
// stands alone.
|
||||
const content = src([
|
||||
'export function onPrompt(continued: boolean) {',
|
||||
' if (!continued) {',
|
||||
" // an abandoned task's todos must not bleed into a new prompt",
|
||||
' reminders.clear();',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{
|
||||
file: 'src/todo.ts',
|
||||
content,
|
||||
addedLines: [2, 3, 4, 5],
|
||||
hasNewTests: false,
|
||||
},
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/todo.ts', line: 4, statement: 'reminders.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches the whole safety-verb set', () => {
|
||||
const content = src([
|
||||
'cache.delete(key);',
|
||||
'state.reset();',
|
||||
'ctrl.abort();',
|
||||
"emitter.removeListener('tick', onTick);",
|
||||
'timer.unref();',
|
||||
'this.pending = [];',
|
||||
'this.timers = new Map();',
|
||||
'this.subs = new Map<string, Sub>();',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(8), hasNewTests: false },
|
||||
]);
|
||||
expect(got.map((c) => c.line)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
});
|
||||
|
||||
it('matches Set, WeakMap, and WeakSet reassignments', () => {
|
||||
const content = src([
|
||||
'this.set = new Set();',
|
||||
'this.wm = new WeakMap();',
|
||||
'this.ws = new WeakSet();',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(3), hasNewTests: false },
|
||||
]);
|
||||
expect(got.map((c) => c.line)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('skips a modifier-less class field that only looks like an assignment', () => {
|
||||
// `cache = new Map();` in a class body matches the safety-verb set and
|
||||
// balances its delimiters, but it is a field DECLARATION: deleting it breaks
|
||||
// the compile (a wasted run) or, if unused, survives and files a false
|
||||
// finding. A statement inside a method body is enclosed by the method's
|
||||
// brace, not the class's, and must still be selected.
|
||||
const content = src([
|
||||
'class Store {',
|
||||
' cache = new Map();',
|
||||
' reset() {',
|
||||
' this.cache.clear();',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(6), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 4, statement: 'this.cache.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips a class field when the class header spans multiple lines', () => {
|
||||
const content = src([
|
||||
'class Store',
|
||||
' extends Base',
|
||||
'{',
|
||||
' cache = new Map();',
|
||||
' reset() {',
|
||||
' this.cache.clear();',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(8), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 6, statement: 'this.cache.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips a class field when the extends clause has an inline object type', () => {
|
||||
// `extends Base<{ foo: string }>` has balanced braces on its own line.
|
||||
// The backward walk must not break there — only a net-unbalanced brace
|
||||
// (a real block boundary) stops it — or the `class` keyword on the line
|
||||
// above is never reached and the field is admitted.
|
||||
const content = src([
|
||||
'class Store',
|
||||
' extends Base<{ foo: string }>',
|
||||
'{',
|
||||
' cache = new Map();',
|
||||
' reset() {',
|
||||
' this.cache.clear();',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(8), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 6, statement: 'this.cache.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('selects a method-body statement when the method is the first class member', () => {
|
||||
// The backward walk from the method's `{` reaches `class Store {` on the
|
||||
// very first step. The `[;{}]` stop must fire before the `class` match on
|
||||
// that same line, or the walk overshoots into the class header and rejects
|
||||
// a statement that is inside the method body, not the class body.
|
||||
const content = src([
|
||||
'class Store {',
|
||||
' reset() {',
|
||||
' this.cache.clear();',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(5), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 3, statement: 'this.cache.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips what it cannot delete whole — declarations, headers, fragments', () => {
|
||||
// Every line here contains a safety verb; none is a deletable statement.
|
||||
// False negatives are fine, but each false positive wastes a suite run —
|
||||
// or worse, `if (stale)` above a call would silently rebind the NEXT
|
||||
// statement to the `if` when the call is deleted.
|
||||
const content = src([
|
||||
'const fresh = new Map();', // declaration
|
||||
'if (done) pending.delete(id);', // control-flow header on the line
|
||||
'register(', // opener …
|
||||
' bar.clear(),', // … argument, not `;`-terminated
|
||||
');', // … tail
|
||||
'chain', // receiver …
|
||||
' .clear();', // … fluent tail, starts with `.`
|
||||
'const n = base +', // continuation …
|
||||
' offsets.delete(k);', // … its tail
|
||||
'if (stale)', // brace-less if …
|
||||
' cache.clear();', // … its sole statement
|
||||
'this.items = [1];', // not reassignment-to-EMPTY
|
||||
'this.map = new Map(entries);', // not reassignment-to-empty either
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(13), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a multi-statement line even when a safety verb matches', () => {
|
||||
// Two statements on one line: deleting the whole line removes BOTH, and
|
||||
// the extra deletion can MASK a missing test on the safety verb.
|
||||
const content = src([
|
||||
'export function reset() {',
|
||||
" this.cache.clear(); this.emit('reset');",
|
||||
' live.clear();',
|
||||
'}',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: [2, 3], hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 3, statement: 'live.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips safety-verb text inside template literals and comment blocks', () => {
|
||||
// Deleting a line of string or commented-out code changes no behaviour, so
|
||||
// its mutant would ALWAYS survive — a guaranteed false finding.
|
||||
const content = src([
|
||||
'const brief = `',
|
||||
' sessions.clear();',
|
||||
'`;',
|
||||
'/*',
|
||||
'old.clear();',
|
||||
'*/',
|
||||
'live.clear();',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(7), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 7, statement: 'live.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps line accounting across a string that swallows its line end', () => {
|
||||
// A `\`-continued string is legal JS whose literal contains the newline. A
|
||||
// scanner that consumes that newline drops one per-line flag and every
|
||||
// later line reads its NEIGHBOUR's literal-state — here that would admit
|
||||
// line 4, which starts inside a block comment: deleting it removes the
|
||||
// `*/` and comments out the code below, a mutant nobody asked for.
|
||||
const content = src([
|
||||
"const s = 'weird \\",
|
||||
"tail';",
|
||||
'/* block',
|
||||
'note */ cache.clear();',
|
||||
'after.clear();',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(5), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 5, statement: 'after.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps line accounting across a backslash-continued template literal', () => {
|
||||
// The template-state escape skip must not swallow a `\`-continued line's
|
||||
// newline: doing so drops a per-line flag and shifts every later verdict
|
||||
// onto its neighbour — here that would admit line 4, which starts inside a
|
||||
// block comment, so deleting it removes the `*/` and comments out the code
|
||||
// below. Mirrors the single-quote case above for the template branch.
|
||||
const content = src([
|
||||
'const brief = `weird \\',
|
||||
'tail`;',
|
||||
'/* block',
|
||||
'note */ cache.clear();',
|
||||
'after.clear();',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(5), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 5, statement: 'after.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not let a nested template inside ${} close the outer literal', () => {
|
||||
// A backtick inside a `${…}` interpolation opens a NESTED template.
|
||||
// Reading it as the outer close marks the outer literal's remaining lines
|
||||
// as code, and the template TEXT `baz.clear();` becomes a candidate whose
|
||||
// deletion compiles and survives — a false finding filed against string
|
||||
// content. Real code after the outer literal must still be selected.
|
||||
const content = src([
|
||||
'const x = `foo ${`bar;',
|
||||
'baz.clear();',
|
||||
'`} qux`;',
|
||||
'after.clear();',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(4), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 4, statement: 'after.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not let a regex literal in an interpolation swallow later code', () => {
|
||||
// A regex literal is not a string: skipping from the `'` in `/'/g` to a
|
||||
// matching quote runs past the interpolation's `}` (no closing quote on the
|
||||
// line), so the scanner never leaves the template, its end state is not
|
||||
// `code`, and a real safety statement on the next line is silently dropped.
|
||||
// Not skipping quotes inside an interpolation keeps the brace depth honest;
|
||||
// the statement must be selected.
|
||||
const content = src([
|
||||
'const q = `${x.replace(/\'/g, "")}`;',
|
||||
'items.clear();',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(2), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 2, statement: 'items.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not let a } in nested-template text close the outer interpolation', () => {
|
||||
// A `}` in a nested template's TEXT (not its own interpolation) must not
|
||||
// decrement the outer interpDepth. Without the nested-template sub-scan,
|
||||
// the depth drops to 0 and the nested close backtick reads as the outer
|
||||
// close, admitting the outer literal's remaining text as code.
|
||||
const content = src([
|
||||
'const x = `a${x + `b } c`}d',
|
||||
'items.clear();',
|
||||
'`;',
|
||||
'after.clear();',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(4), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 4, statement: 'after.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps outer-template text after a nested template whose text holds a }', () => {
|
||||
// The #8020 trigger. A `}` in the nested template's TEXT must not read as
|
||||
// the end of the outer interpolation: with a depth counter it drained the
|
||||
// depth to zero, the nested close backtick then read as the OUTER close,
|
||||
// and the template text `sessions.clear();` — a non-executable line —
|
||||
// became a deletion mutant whose survival was a guaranteed false finding.
|
||||
const content = src([
|
||||
'const x = `text ${ foo(`nested }`) };',
|
||||
'sessions.clear();',
|
||||
'`;',
|
||||
'after.clear();',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(4), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 4, statement: 'after.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('tracks a nested template inside a nested interpolation (two levels)', () => {
|
||||
// Same trigger one level deeper: the deep template's text `}` must only be
|
||||
// text. A single nesting counter cannot represent this — it mis-assigns
|
||||
// the `}` to the nested interpolation, reads the rest of the line out of
|
||||
// phase, and either admits the template text `sessions.clear();` or ends
|
||||
// the scan derailed and silently drops the REAL candidate on line 4. Only
|
||||
// a stack of template/interpolation frames gets both lines right.
|
||||
const content = src([
|
||||
'const x = `text ${ foo(`nested ${ bar(`deep }`) } tail`) };',
|
||||
'sessions.clear();',
|
||||
'`;',
|
||||
'after.clear();',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(4), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 4, statement: 'after.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a lone ${ left unclosed at EOF as a derailed scan, not code', () => {
|
||||
// An interpolation that never closes leaves every later line's state
|
||||
// unknowable. The scan must end non-`code` so the file's candidates are
|
||||
// dropped (and disclosed), never trusted.
|
||||
const content = src(['const x = `text ${ foo(', 'sessions.clear();', '']);
|
||||
const { selected, derailed } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(2), hasNewTests: false },
|
||||
]);
|
||||
expect(selected).toEqual([]);
|
||||
expect(derailed).toEqual(['src/s.ts']);
|
||||
});
|
||||
|
||||
it('does not read a single-line nested-template interpolation as code', () => {
|
||||
// The same nesting on one line: skipping from the outer backtick to the
|
||||
// NEXT backtick exposes the inner template's content (`key.reset(`) as
|
||||
// code, so a verb that is actually string content matches and a valid
|
||||
// template assignment is selected and deleted — a wasted run and a false
|
||||
// finding.
|
||||
const content = src([
|
||||
'export function summarize(entries: Entry[]) {',
|
||||
" summary = `Results: ${entries.map((e) => `key.reset(${e.id})`).join('; ')};`;",
|
||||
' live.clear();',
|
||||
'}',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: [2, 3], hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 3, statement: 'live.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a class field below a template whose text contains a brace', () => {
|
||||
// The class-body walk reads code lines, not raw text: a multi-line
|
||||
// template whose CONTENT holds an unmatched `{` (agent briefs embed JSON
|
||||
// examples) would otherwise read as an opening brace, stop the walk before
|
||||
// the class header, and admit the field — deleting a declaration, not a
|
||||
// cleanup. The method-body statement below it must still be selected.
|
||||
const content = src([
|
||||
'class Store {',
|
||||
' brief = `',
|
||||
' docs with { brace',
|
||||
' `;',
|
||||
' cache = new Map();',
|
||||
' reset() {',
|
||||
' this.cache.clear();',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: all(9), hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 7, statement: 'this.cache.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sees through a trailing comment on the candidate and its predecessor', () => {
|
||||
// The end-anchored checks run on the code portion only. A trailing comment
|
||||
// must not hide the candidate's `;` (dropping a genuine reset) nor the
|
||||
// predecessor's statement end — `reminders.clear(); // why` is exactly the
|
||||
// dogfood shape this probe was built to catch.
|
||||
const content = src([
|
||||
'export function reset() {',
|
||||
' const x = setup(); // prepare',
|
||||
' reminders.clear(); // why',
|
||||
'}',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: [2, 3], hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 3, statement: 'reminders.clear(); // why' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not select a safety verb that only appears inside a string', () => {
|
||||
// A verb inside a string is not a statement: deleting the line removes a
|
||||
// log call, the suite stays green, and a misleading `mutant-survived`
|
||||
// finding is filed — a false positive that also burns a suite run.
|
||||
const content = src([
|
||||
'export function report() {',
|
||||
' logger.info("sessions.clear() done");',
|
||||
' live.clear();',
|
||||
'}',
|
||||
'',
|
||||
]);
|
||||
const { selected: got } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: [2, 3], hasNewTests: false },
|
||||
]);
|
||||
expect(got).toEqual([
|
||||
{ file: 'src/s.ts', line: 3, statement: 'live.clear();' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('discards ALL candidates from a file whose scan derails, and names the file', () => {
|
||||
// A backtick inside a regex literal flips the scanner into template state
|
||||
// through to EOF. Even the valid candidate before the derailment is
|
||||
// discarded — the scan is untrustworthy past it, and over-rejecting is the
|
||||
// cheap error. The file comes back in `derailed` so the caller can
|
||||
// disclose the dropped candidates instead of reporting a silent zero. A
|
||||
// clean sibling file's candidates are unaffected.
|
||||
const content = src([
|
||||
'state.clear();',
|
||||
'const re = /`/;',
|
||||
'other.clear();',
|
||||
'',
|
||||
]);
|
||||
const { selected, derailed } = selectMutants([
|
||||
{ file: 'src/s.ts', content, addedLines: [1, 2, 3], hasNewTests: false },
|
||||
{
|
||||
file: 'src/clean.ts',
|
||||
content: src(['live.clear();', '']),
|
||||
addedLines: [1],
|
||||
hasNewTests: false,
|
||||
},
|
||||
]);
|
||||
expect(selected).toEqual([
|
||||
{ file: 'src/clean.ts', line: 1, statement: 'live.clear();' },
|
||||
]);
|
||||
expect(derailed).toEqual(['src/s.ts']);
|
||||
});
|
||||
|
||||
it('caps at MAX_MUTANTS, preferring files that also have new tests', () => {
|
||||
const line = (i: number) => `store${i}.clear();`;
|
||||
const content = src([...all(5).map(line), '']);
|
||||
const { selected: got, skippedForCap } = selectMutants([
|
||||
// Diff order says untested first; the preference must still put every
|
||||
// candidate from the tested file ahead of it, and the cap then keeps
|
||||
// the untested file's EARLIEST lines.
|
||||
{
|
||||
file: 'src/untested.ts',
|
||||
content,
|
||||
addedLines: all(5),
|
||||
hasNewTests: false,
|
||||
},
|
||||
{ file: 'src/tested.ts', content, addedLines: all(5), hasNewTests: true },
|
||||
]);
|
||||
expect(MAX_MUTANTS).toBe(8);
|
||||
expect(got).toHaveLength(8);
|
||||
expect(skippedForCap).toBe(2);
|
||||
expect(got.slice(0, 5).map((c) => c.file)).toEqual(
|
||||
Array(5).fill('src/tested.ts'),
|
||||
);
|
||||
expect(got.slice(5).map((c) => [c.file, c.line])).toEqual([
|
||||
['src/untested.ts', 1],
|
||||
['src/untested.ts', 2],
|
||||
['src/untested.ts', 3],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasCollocatedNewTest', () => {
|
||||
it('pairs file.ts with its collocated file.test.ts / file.spec.ts', () => {
|
||||
expect(
|
||||
hasCollocatedNewTest('packages/cli/src/x.ts', [
|
||||
'packages/cli/src/x.test.ts',
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasCollocatedNewTest('packages/cli/src/x.ts', [
|
||||
'packages/cli/src/x.spec.ts',
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasCollocatedNewTest('packages/cli/src/Comp.tsx', [
|
||||
'packages/cli/src/Comp.test.tsx',
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not pair across directories or by basename suffix', () => {
|
||||
expect(
|
||||
hasCollocatedNewTest('packages/cli/src/x.ts', [
|
||||
'packages/core/src/x.test.ts',
|
||||
]),
|
||||
).toBe(false);
|
||||
// `xy.test.ts` must not satisfy `y.ts` — stem equality, not endsWith.
|
||||
expect(
|
||||
hasCollocatedNewTest('packages/cli/src/y.ts', [
|
||||
'packages/cli/src/xy.test.ts',
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyMutantRun', () => {
|
||||
// Verdicts flow through the SAME per-file classifier the revert probe uses,
|
||||
// so these fixtures are the vitest-JSON shapes classifyProbeRun already
|
||||
// understands — what is under test is the mutant-level aggregation.
|
||||
const perFile = (exit: number, json: unknown, probes: string[]) =>
|
||||
classifyProbeRun(exit, JSON.stringify(json), probes);
|
||||
|
||||
it('SURVIVED when every affected test still passes', () => {
|
||||
const got = classifyMutantRun(
|
||||
perFile(
|
||||
0,
|
||||
{
|
||||
testResults: [
|
||||
{ name: '/w/a.test.ts', assertionResults: [{ status: 'passed' }] },
|
||||
],
|
||||
},
|
||||
['a.test.ts'],
|
||||
),
|
||||
);
|
||||
expect(got).toBe('survived');
|
||||
});
|
||||
|
||||
it('KILLED when any assertion fails — the deletion was caught', () => {
|
||||
const got = classifyMutantRun(
|
||||
perFile(
|
||||
1,
|
||||
{
|
||||
testResults: [
|
||||
{ name: '/w/a.test.ts', assertionResults: [{ status: 'passed' }] },
|
||||
{ name: '/w/b.test.ts', assertionResults: [{ status: 'failed' }] },
|
||||
],
|
||||
},
|
||||
['a.test.ts', 'b.test.ts'],
|
||||
),
|
||||
);
|
||||
expect(got).toBe('killed');
|
||||
});
|
||||
|
||||
it('INCONCLUSIVE when the mutant breaks the compile, never killed', () => {
|
||||
// The revert probe's trap, inherited: a run that collected nothing is not
|
||||
// a test catching the deletion.
|
||||
const got = classifyMutantRun(
|
||||
perFile(1, { testResults: [] }, ['a.test.ts']),
|
||||
);
|
||||
expect(got).toBe('inconclusive');
|
||||
});
|
||||
|
||||
it('does not let a green sibling upgrade a non-collected file to SURVIVED', () => {
|
||||
// The file that failed to collect might be the very one that would have
|
||||
// caught the deletion — "survived" requires every file to have run.
|
||||
const got = classifyMutantRun(
|
||||
perFile(
|
||||
0,
|
||||
{
|
||||
testResults: [
|
||||
{ name: '/w/a.test.ts', assertionResults: [{ status: 'passed' }] },
|
||||
],
|
||||
},
|
||||
['a.test.ts', 'b.test.ts'],
|
||||
),
|
||||
);
|
||||
expect(got).toBe('inconclusive');
|
||||
});
|
||||
|
||||
it('a kill outranks an inconclusive sibling — red is red', () => {
|
||||
const got = classifyMutantRun(
|
||||
perFile(
|
||||
1,
|
||||
{
|
||||
testResults: [
|
||||
{ name: '/w/a.test.ts', assertionResults: [{ status: 'failed' }] },
|
||||
],
|
||||
},
|
||||
['a.test.ts', 'b.test.ts'],
|
||||
),
|
||||
);
|
||||
expect(got).toBe('killed');
|
||||
});
|
||||
|
||||
it('an empty run proves nothing', () => {
|
||||
expect(classifyMutantRun([])).toBe('inconclusive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitsAnotherMutantRun', () => {
|
||||
it('requires room for one more mutant run — the revert is reserved by the deadline', () => {
|
||||
expect(fitsAnotherMutantRun(60_000, 60_000)).toBe(true);
|
||||
expect(fitsAnotherMutantRun(59_999, 60_000)).toBe(false);
|
||||
expect(fitsAnotherMutantRun(0, 60_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,6 +33,19 @@
|
|||
// anything, and calling it "gated" would be exactly the false assurance this
|
||||
// command exists to remove. So `gated` requires a real assertion failure, and
|
||||
// everything else that is not a clean pass is `inconclusive`.
|
||||
//
|
||||
// The revert probe is also ALL-OR-NOTHING, and a live dogfood found the gap
|
||||
// that leaves. A PR's file carried six well-tested behaviours and one untested
|
||||
// safety statement; reverting the whole file went red on the six — "gated" —
|
||||
// while deleting just the one statement (a `reminders.clear()` in a
|
||||
// not-continued branch) left the entire 471-test suite green. The PR's headline
|
||||
// invariant had zero coverage and both probes were structurally blind to it. So
|
||||
// a third probe runs statement-level deletion MUTANTS over the diff's added
|
||||
// lines, restricted to a high-precision set of safety verbs. A mutant the suite
|
||||
// never notices — a SURVIVOR — is a finding: the invariant that statement
|
||||
// enforces has no test that would fail without it. The third-outcome discipline
|
||||
// applies here too: a mutant that breaks the compile is `inconclusive`, never
|
||||
// `killed`.
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
|
@ -118,6 +131,518 @@ export function planTestEfficacy(
|
|||
};
|
||||
}
|
||||
|
||||
export type MutantVerdict = 'killed' | 'survived' | 'inconclusive';
|
||||
|
||||
export interface MutantCandidate {
|
||||
file: string;
|
||||
/** 1-based line number in the post-change file. */
|
||||
line: number;
|
||||
/** The statement's text, trimmed — quoted back verbatim in the report. */
|
||||
statement: string;
|
||||
}
|
||||
|
||||
export interface MutantResult extends MutantCandidate {
|
||||
verdict: MutantVerdict;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* At most this many deletion mutants per run. Every mutant is a full vitest run
|
||||
* over the affected test files, so the cap — not the candidate count — is what
|
||||
* keeps this command inside its budget on a diff that clears eight Maps.
|
||||
*/
|
||||
export const MAX_MUTANTS = 8;
|
||||
|
||||
/** Deadline for one vitest run (baseline, mutant, or revert probe alike). */
|
||||
const PROBE_RUN_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Whole-command budget. Agent 7 invokes review commands with the 600s
|
||||
* (600000ms) tool timeout; staying strictly below it means the budget cutoff in
|
||||
* the mutant loop — which reports HOW MANY mutants it skipped — fires before
|
||||
* the harness kills the process and reports nothing at all.
|
||||
*/
|
||||
const TOTAL_BUDGET_MS = 540_000;
|
||||
|
||||
/**
|
||||
* Slack added to the measured baseline duration when pricing a mutant run: a
|
||||
* killed mutant's run is about as long as a green one, but vitest startup and
|
||||
* the restore write jitter, and an estimate that runs hot skips a mutant it
|
||||
* could have fit — cheaper than blowing the deadline on one it could not.
|
||||
*/
|
||||
const RUN_ESTIMATE_MARGIN_MS = 15_000;
|
||||
|
||||
/**
|
||||
* The statements worth mutating: calls that discard, detach or reset state, and
|
||||
* reassignment to an empty collection. Deliberately high-precision — every
|
||||
* selected line costs a full suite run, so this matches the safety-verb shapes
|
||||
* whose deletion is (a) silent at compile time and (b) exactly the kind of
|
||||
* cleanup a test suite forgets to gate. Matched against the TRIMMED line.
|
||||
*/
|
||||
const SAFETY_VERB_RE =
|
||||
/\.(?:clear|delete|reset|abort|removeListener|unref)\(|=\s*\[\]\s*;$|=\s*new\s+(?:Map|Set|WeakMap|WeakSet)(?:<[^=;]*>)?\(\)\s*;$/;
|
||||
|
||||
/**
|
||||
* Files a deletion mutant can run in: TS/JS production source (not `.d.ts` —
|
||||
* declarations never execute). The revert set also carries runtime-loaded prose
|
||||
* and config (an executable SKILL.md, a schema JSON); deleting a line of prose
|
||||
* never breaks anything the runner sees, so every such mutant would "survive"
|
||||
* and file a false finding.
|
||||
*/
|
||||
const MUTANT_SOURCE_RE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
||||
const DECLARATION_FILE_RE = /\.d\.[cm]?ts$/;
|
||||
|
||||
/**
|
||||
* Line starts that are not deletable expression statements: declarations,
|
||||
* control-flow headers, and clause keywords. Class-member modifiers are in the
|
||||
* list because a class field (`private timers = new Map();`) looks exactly like
|
||||
* an assignment statement from one line away.
|
||||
*/
|
||||
const NON_STATEMENT_START_RE =
|
||||
/^(?:const|let|var|function|class|interface|type|enum|import|export|return|throw|yield|if|for|while|switch|do|else|try|catch|finally|case|default|break|continue|async|public|private|protected|readonly|static)\b/;
|
||||
|
||||
/**
|
||||
* Skip a template literal that opens at `line[start]`. Returns the index of
|
||||
* its closing backtick, or -1 when it does not close on this line. Tracks
|
||||
* `${…}` interpolation brace depth so a backtick seen inside an interpolation
|
||||
* opens a NESTED template and is never mistaken for the outer close — without
|
||||
* this, everything after the nested backtick (its string content included)
|
||||
* reads as code. Approximate by construction (a `}` in a nested template's
|
||||
* text, or in a string inside the interpolation, still miscounts), but the
|
||||
* approximation only mis-scans shapes the delimiter check then rejects.
|
||||
*/
|
||||
function skipTemplateOnLine(line: string, start: number): number {
|
||||
let depth = 0;
|
||||
for (let i = start + 1; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '\\') {
|
||||
i++;
|
||||
} else if (depth === 0) {
|
||||
if (ch === '`') return i;
|
||||
if (ch === '$' && line[i + 1] === '{') {
|
||||
depth = 1;
|
||||
i++;
|
||||
}
|
||||
} else if (ch === '{') {
|
||||
depth++;
|
||||
} else if (ch === '}') {
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan one line's code, skipping string literals and comments. Returns `null`
|
||||
* when the line cannot be judged in isolation — an unterminated string or block
|
||||
* comment (it continues on another line), or a closer without an opener (the
|
||||
* line is the tail of a multi-line expression). A regex literal containing a
|
||||
* quote or bracket can confuse this scanner, but only toward rejection or a
|
||||
* mutant that fails to compile (`inconclusive`) — never toward a false finding.
|
||||
*/
|
||||
function scanLineDelimiters(
|
||||
line: string,
|
||||
): { paren: number; bracket: number; brace: number } | null {
|
||||
let paren = 0;
|
||||
let bracket = 0;
|
||||
let brace = 0;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '`') {
|
||||
const close = skipTemplateOnLine(line, i);
|
||||
if (close < 0) return null;
|
||||
i = close;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
i++;
|
||||
while (i < line.length && line[i] !== ch) {
|
||||
if (line[i] === '\\') i++;
|
||||
i++;
|
||||
}
|
||||
if (i >= line.length) return null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && line[i + 1] === '/') break;
|
||||
if (ch === '/' && line[i + 1] === '*') {
|
||||
const close = line.indexOf('*/', i + 2);
|
||||
if (close < 0) return null;
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') paren++;
|
||||
else if (ch === ')') paren--;
|
||||
else if (ch === '[') bracket++;
|
||||
else if (ch === ']') bracket--;
|
||||
else if (ch === '{') brace++;
|
||||
else if (ch === '}') brace--;
|
||||
if (paren < 0 || bracket < 0 || brace < 0) return null;
|
||||
}
|
||||
return { paren, bracket, brace };
|
||||
}
|
||||
|
||||
interface FileScan {
|
||||
/** Per line: does it START inside a template literal or block comment? */
|
||||
inLiteral: boolean[];
|
||||
/** Per line: its code portion — comments stripped, literal contents blanked
|
||||
* (delimiters kept), trimmed. */
|
||||
codeLines: string[];
|
||||
/** The scanner's state at EOF. A non-`code` end means a regex literal or
|
||||
* similar shape derailed the scan — every later line's `inLiteral` is
|
||||
* suspect, so the caller discards the file's candidates. */
|
||||
endState: 'code' | 'template' | 'comment';
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass over the whole file, feeding every text check mutant selection runs.
|
||||
*
|
||||
* `inLiteral`: without it, a safety-verb line inside a multi-line template (an
|
||||
* agent-brief string, a here-doc in a test) or a commented-out block would be
|
||||
* "deleted" without changing any behaviour — a guaranteed false survivor.
|
||||
* Interpolations (`${…}`) are treated as still-template, tracked with a STACK
|
||||
* of frames — one per open template literal: `${` opens an interpolation on
|
||||
* the innermost template, a backtick inside an interpolation opens a NESTED
|
||||
* template, a `}` only closes the interpolation at the top of the stack, and a
|
||||
* backtick in template text only closes the CURRENT template, never an outer
|
||||
* one. A depth counter cannot represent this: a `}` in a nested template's
|
||||
* TEXT drained it to zero, so the nested template's closing backtick read as
|
||||
* the OUTER close and the outer literal's remaining text was admitted as code
|
||||
* — still-template can only skip a candidate, never admit one.
|
||||
* Quotes inside an interpolation are deliberately not skipped: a regex literal
|
||||
* (`/'/g`) is not a string, and skipping to its matching quote runs past the
|
||||
* interpolation's own `}`, derailing the scan and dropping every later candidate
|
||||
* in the file. A `}` in a plain string can still close an interpolation early —
|
||||
* handling that needs regex-literal awareness — but not skipping is what the
|
||||
* corpus shows is safe today.
|
||||
*
|
||||
* `codeLines`: the selection checks are end-anchored — `endsWith(';')`, the
|
||||
* `$` alternatives in {@link SAFETY_VERB_RE}, the predecessor `/[;{}]$/` — so
|
||||
* they must see the real statement end: a trailing comment
|
||||
* (`reminders.clear(); // why`) otherwise hides it, and a verb inside a string
|
||||
* (`log("sessions.clear()")`) fakes it. Whole-file state is what lets a line
|
||||
* that is comment or template CONTENT come out empty — per-line stripping
|
||||
* cannot know that, and its stray `{`/`}` mislead the class-body walk. A line
|
||||
* holding an unterminated single/double-quoted string cannot be judged at all
|
||||
* and is kept verbatim, which only ever preserves the conservative rejection
|
||||
* the checks already apply. The scan stops such a string BEFORE its newline:
|
||||
* consuming the `\n` (a `\`-continued line swallows it) would drop one per-line
|
||||
* entry and shift every later line's verdict onto its neighbour — the template
|
||||
* escape skip below guards its newline for the same reason.
|
||||
*/
|
||||
function scanFileLines(content: string): FileScan {
|
||||
const inLiteral: boolean[] = [];
|
||||
const codeLines: string[] = [];
|
||||
let state: 'code' | 'comment' = 'code';
|
||||
// One entry per open template literal, innermost last: -1 while the scan is
|
||||
// in that template's TEXT, otherwise the brace depth of its open `${…}`
|
||||
// interpolation.
|
||||
const templates: number[] = [];
|
||||
let buf = '';
|
||||
let lineStart = 0;
|
||||
let rawLine = false;
|
||||
const inTemplateOrComment = () => state !== 'code' || templates.length > 0;
|
||||
inLiteral.push(inTemplateOrComment());
|
||||
const endLine = (i: number) => {
|
||||
codeLines.push(rawLine ? content.slice(lineStart, i).trim() : buf.trim());
|
||||
buf = '';
|
||||
rawLine = false;
|
||||
lineStart = i + 1;
|
||||
};
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const ch = content[i];
|
||||
if (ch === '\n') {
|
||||
endLine(i);
|
||||
inLiteral.push(inTemplateOrComment());
|
||||
continue;
|
||||
}
|
||||
if (templates.length > 0) {
|
||||
const top = templates.length - 1;
|
||||
if (ch === '\\' && content[i + 1] !== '\n') {
|
||||
i++;
|
||||
} else if (templates[top] < 0) {
|
||||
// In the innermost template's text.
|
||||
if (ch === '`') {
|
||||
templates.pop();
|
||||
if (templates.length === 0) buf += '`';
|
||||
} else if (ch === '$' && content[i + 1] === '{') {
|
||||
templates[top] = 0;
|
||||
i++;
|
||||
}
|
||||
} else if (ch === '`') {
|
||||
templates.push(-1);
|
||||
} else if (ch === '{') {
|
||||
templates[top]++;
|
||||
} else if (ch === '}') {
|
||||
if (templates[top] === 0) templates[top] = -1;
|
||||
else templates[top]--;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (state === 'comment') {
|
||||
if (ch === '*' && content[i + 1] === '/') {
|
||||
state = 'code';
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '`') {
|
||||
buf += '`';
|
||||
templates.push(-1);
|
||||
} else if (ch === '/' && content[i + 1] === '*') {
|
||||
state = 'comment';
|
||||
i++;
|
||||
} else if (ch === '/' && content[i + 1] === '/') {
|
||||
while (i + 1 < content.length && content[i + 1] !== '\n') i++;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
let k = i + 1;
|
||||
while (k < content.length && content[k] !== ch && content[k] !== '\n') {
|
||||
if (content[k] === '\\' && content[k + 1] !== '\n') k++;
|
||||
k++;
|
||||
}
|
||||
if (k < content.length && content[k] === ch) {
|
||||
buf += ch + ch;
|
||||
i = k;
|
||||
} else {
|
||||
rawLine = true;
|
||||
i = k < content.length && content[k] === '\n' ? k - 1 : k;
|
||||
}
|
||||
} else {
|
||||
buf += ch;
|
||||
}
|
||||
}
|
||||
endLine(content.length);
|
||||
return {
|
||||
inLiteral,
|
||||
codeLines,
|
||||
endState: templates.length > 0 ? 'template' : state,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `lines[idx]` sit directly inside a `class` body? A modifier-less class
|
||||
* field (`cache = new Map();`) reads exactly like a bare assignment statement
|
||||
* from one line away, yet deleting it removes a DECLARATION, not a cleanup — a
|
||||
* compile error (`inconclusive`) or, for an unused field, a false `survived`
|
||||
* that labels a field an added safety statement. Walk backward to the brace
|
||||
* that opens the immediately enclosing block and report whether it belongs to a
|
||||
* `class`. A statement in a method body is enclosed by the method's brace, not
|
||||
* the class's, so it is unaffected. Over-rejecting here is the cheap error.
|
||||
* Walks the {@link scanFileLines} code lines, never the raw text: a `{` in
|
||||
* template or comment CONTENT (an agent brief embedding a JSON example) would
|
||||
* otherwise read as an opening brace, stop the walk early, and admit the field.
|
||||
*/
|
||||
function insideClassBody(codeLines: string[], idx: number): boolean {
|
||||
let depth = 0;
|
||||
for (let j = idx - 1; j >= 0; j--) {
|
||||
const code = codeLines[j];
|
||||
for (let i = code.length - 1; i >= 0; i--) {
|
||||
const ch = code[i];
|
||||
if (ch === '}') depth++;
|
||||
else if (ch === '{') {
|
||||
if (depth === 0) {
|
||||
if (/\bclass\b/.test(code.slice(0, i))) return true;
|
||||
for (let k = j - 1; k >= 0; k--) {
|
||||
const prev = codeLines[k];
|
||||
if (prev.includes(';')) break;
|
||||
const d = scanLineDelimiters(prev);
|
||||
if (!d || d.brace !== 0) break;
|
||||
if (/\bclass\b/.test(prev)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `lines[idx]` deletable as one whole statement? Conservative on purpose: a
|
||||
* false negative costs one unprobed candidate, a false positive costs a full
|
||||
* suite run on a mutant that cannot compile — or worse, one whose deletion is
|
||||
* syntactically fine but rebinds the NEXT statement (the sole statement of a
|
||||
* brace-less `if`). So the line must end in `;`, start like an expression
|
||||
* statement, balance its own delimiters, and follow a line that clearly ENDED
|
||||
* something: `;`, `{`, or `}`. Anything else — a trailing `(`, `,`, `=>`,
|
||||
* `&&`, or the bare `)` that may be an `if (…)` header — is skipped.
|
||||
*/
|
||||
function isRemovableStatement(
|
||||
lines: string[],
|
||||
codeLines: string[],
|
||||
idx: number,
|
||||
): boolean {
|
||||
const t = (lines[idx] ?? '').trim();
|
||||
// End-anchored checks run on the code portion only, so a trailing comment
|
||||
// (`reminders.clear(); // why`) does not hide the statement's real end.
|
||||
if (!(codeLines[idx] ?? '').endsWith(';')) return false;
|
||||
if ((codeLines[idx] ?? '').slice(0, -1).includes(';')) return false;
|
||||
if (!/^(?:await\s+)?[A-Za-z_$]/.test(t)) return false;
|
||||
if (NON_STATEMENT_START_RE.test(t)) return false;
|
||||
if (insideClassBody(codeLines, idx)) return false;
|
||||
const depth = scanLineDelimiters(t);
|
||||
if (!depth || depth.paren !== 0 || depth.bracket !== 0 || depth.brace !== 0) {
|
||||
return false;
|
||||
}
|
||||
// The nearest line that holds any CODE at all — a blank line, a comment
|
||||
// (whether it looks like one or is the content of a block), or template text
|
||||
// decides nothing about where the previous statement ended.
|
||||
let j = idx - 1;
|
||||
while (j >= 0 && codeLines[j] === '') j--;
|
||||
if (j < 0) return true;
|
||||
return /[;{}]$/.test(codeLines[j]);
|
||||
}
|
||||
|
||||
export interface MutantSourceFile {
|
||||
file: string;
|
||||
/** Post-change content at the PR head — what the probe tree checks out. */
|
||||
content: string;
|
||||
/** 1-based new-side line numbers the diff ADDED in this file. */
|
||||
addedLines: number[];
|
||||
/** The diff also adds/changes this file's collocated test. Preference only:
|
||||
* under the cap these candidates go first — a mutant is most informative
|
||||
* exactly where the PR claims its new tests cover the new code. */
|
||||
hasNewTests: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic mutant selection: among the diff's added lines, the complete
|
||||
* single-line safety-verb statements, capped at {@link MAX_MUTANTS} — files
|
||||
* with new tests first, then diff order, then line order. Candidates the cap
|
||||
* cannot fit are counted in `skippedForCap`, not silently lost — a report that
|
||||
* omits them lets a capped `survived: 0` read as "every safety statement is
|
||||
* covered", the same false assurance `skippedForBudget` exists to prevent. A
|
||||
* file whose scan ends outside code state (a regex literal holding a quote or
|
||||
* backtick derails it) has ALL its candidates dropped and is returned in
|
||||
* `derailed` — the caller must disclose that zero for the same reason.
|
||||
*/
|
||||
export function selectMutants(
|
||||
files: MutantSourceFile[],
|
||||
cap: number = MAX_MUTANTS,
|
||||
): { selected: MutantCandidate[]; skippedForCap: number; derailed: string[] } {
|
||||
const preferred: MutantCandidate[] = [];
|
||||
const rest: MutantCandidate[] = [];
|
||||
const derailed: string[] = [];
|
||||
for (const f of files) {
|
||||
const lines = f.content.split('\n');
|
||||
const { inLiteral, codeLines, endState } = scanFileLines(f.content);
|
||||
if (endState !== 'code') {
|
||||
derailed.push(f.file);
|
||||
continue;
|
||||
}
|
||||
for (const n of [...f.addedLines].sort((a, b) => a - b)) {
|
||||
const raw = lines[n - 1];
|
||||
if (raw === undefined) continue;
|
||||
const t = raw.trim();
|
||||
if (!SAFETY_VERB_RE.test(codeLines[n - 1] ?? '')) continue;
|
||||
if (inLiteral[n - 1]) continue;
|
||||
if (!isRemovableStatement(lines, codeLines, n - 1)) continue;
|
||||
(f.hasNewTests ? preferred : rest).push({
|
||||
file: f.file,
|
||||
line: n,
|
||||
statement: t,
|
||||
});
|
||||
}
|
||||
}
|
||||
const eligible = [...preferred, ...rest];
|
||||
return {
|
||||
selected: eligible.slice(0, cap),
|
||||
skippedForCap: Math.max(0, eligible.length - cap),
|
||||
derailed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The new-side line numbers a `--unified=0` diff ADDED, per post-change path.
|
||||
* Zero context is what the caller asks git for, but context lines are counted
|
||||
* anyway so a diff captured with the default `-U3` still numbers correctly.
|
||||
*/
|
||||
export function parseAddedLines(diffText: string): Map<string, number[]> {
|
||||
const added = new Map<string, number[]>();
|
||||
let file: string | null = null;
|
||||
let inHunk = false;
|
||||
let newLine = 0;
|
||||
for (const line of diffText.split('\n')) {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
// A new file's header block follows; leave the previous file's hunk so
|
||||
// its `+++ ` header is recognised rather than read as an added line.
|
||||
inHunk = false;
|
||||
continue;
|
||||
}
|
||||
// `!inHunk`: inside a hunk an added source line that begins with `++ `
|
||||
// (spaced pre-increment) renders as `+++ x` and is not a file header.
|
||||
if (!inHunk && line.startsWith('+++ ')) {
|
||||
const p = line.slice(4).split('\t')[0];
|
||||
file = p === '/dev/null' ? null : p.replace(/^b\//, '');
|
||||
continue;
|
||||
}
|
||||
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
||||
if (m) {
|
||||
newLine = Number(m[1]);
|
||||
inHunk = true;
|
||||
continue;
|
||||
}
|
||||
if (!inHunk || !file) continue;
|
||||
if (line.startsWith('+')) {
|
||||
const list = added.get(file);
|
||||
if (list) list.push(newLine);
|
||||
else added.set(file, [newLine]);
|
||||
newLine++;
|
||||
} else if (!line.startsWith('-') && !line.startsWith('\\')) {
|
||||
newLine++;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the diff add or change a test collocated with this production file?
|
||||
* The repo convention is `file.test.ts` beside `file.ts`. Used only to ORDER
|
||||
* candidates under the cap, so a miss costs priority, not selection.
|
||||
*/
|
||||
export function hasCollocatedNewTest(
|
||||
file: string,
|
||||
testPaths: string[],
|
||||
): boolean {
|
||||
const stem = file.replace(/\.[^./]+$/, '');
|
||||
return testPaths.some((t) => {
|
||||
const tstem = t.replace(/\.[^./]+$/, '');
|
||||
return tstem === `${stem}.test` || tstem === `${stem}.spec`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule on one mutant from the per-file revert-probe verdicts of its run.
|
||||
*
|
||||
* `gated` on any file means an assertion failed with the statement deleted —
|
||||
* the mutant was caught, which is the good outcome and NOT a finding. But
|
||||
* `survived` requires every affected test file to have genuinely run and
|
||||
* passed: a file that collected nothing might be the very one that would have
|
||||
* caught the deletion, so any `inconclusive` without a kill makes the mutant
|
||||
* `inconclusive` — the same never-read-an-error-as-a-verdict asymmetry the
|
||||
* revert probe holds.
|
||||
*/
|
||||
export function classifyMutantRun(
|
||||
perFile: Array<{ verdict: ProbeVerdict }>,
|
||||
): MutantVerdict {
|
||||
if (perFile.some((r) => r.verdict === 'gated')) return 'killed';
|
||||
if (perFile.length === 0 || perFile.some((r) => r.verdict === 'inconclusive'))
|
||||
return 'inconclusive';
|
||||
return 'survived';
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the remaining budget fit one more mutant? The revert probe's slot is
|
||||
* reserved by the deadline passed to {@link runProbeSuite}, so this guard
|
||||
* only prices the mutant's own suite run.
|
||||
*/
|
||||
export function fitsAnotherMutantRun(
|
||||
remainingMs: number,
|
||||
estimatedRunMs: number,
|
||||
): boolean {
|
||||
return remainingMs >= estimatedRunMs;
|
||||
}
|
||||
|
||||
interface VitestAssertion {
|
||||
status?: string;
|
||||
}
|
||||
|
|
@ -235,6 +760,9 @@ interface TestEfficacyArgs {
|
|||
worktree: string;
|
||||
base: string;
|
||||
out: string;
|
||||
/** Injectable clock, for tests only — the budget math cannot be driven to
|
||||
* its cutoff in real time. Defaults to `Date.now`. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function git(cwd: string, ...args: string[]): void {
|
||||
|
|
@ -258,6 +786,25 @@ function gitOut(cwd: string, ...args: string[]): string {
|
|||
return (r.stdout ?? '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run git and return stdout VERBATIM, with a large buffer. Mutant selection
|
||||
* reads blob contents and a whole diff through this: `gitOut`'s trim would
|
||||
* strip a file's leading blank lines and silently shift every line number, and
|
||||
* the 1 MiB default buffer would ENOBUFS on a large PR's diff.
|
||||
*/
|
||||
function gitCapture(cwd: string, ...args: string[]): string {
|
||||
const r = spawnSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (r.error) throw r.error;
|
||||
if (r.status !== 0) {
|
||||
throw new Error(`git ${args.join(' ')} failed: ${r.stderr ?? ''}`);
|
||||
}
|
||||
return r.stdout ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this path exist at the given rev? A non-zero exit is a legitimate "no"
|
||||
* (git prints nothing), but a spawn *failure* (`r.error`, e.g. git missing) is
|
||||
|
|
@ -391,7 +938,117 @@ export function probeCleanupFailureDetail(
|
|||
return `could not remove probe worktree ${probeTree}${why ? `: ${why}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One vitest run over the probe files, classified per file. Shared by the
|
||||
* baseline run, every mutant run, and the revert probe — the same suite, the
|
||||
* same runner, the same classifier. Throws when the run never produced output
|
||||
* to classify (spawn failure, or killed by the deadline).
|
||||
*
|
||||
* `deadlineAt` clamps the per-run timeout so the baseline + mutants + revert
|
||||
* cannot together exceed {@link TOTAL_BUDGET_MS}: the baseline and mutant
|
||||
* runs share a window that reserves the revert probe's full slot, and the
|
||||
* revert probe gets the remainder of the whole budget.
|
||||
*/
|
||||
function runProbeSuite(
|
||||
probeTree: string,
|
||||
probes: string[],
|
||||
deadlineAt?: number,
|
||||
now: () => number = Date.now,
|
||||
): {
|
||||
perFile: Array<{ file: string; verdict: ProbeVerdict; detail: string }>;
|
||||
ms: number;
|
||||
} {
|
||||
const started = now();
|
||||
const timeout =
|
||||
deadlineAt !== undefined
|
||||
? Math.max(1, Math.min(PROBE_RUN_TIMEOUT_MS, deadlineAt - started))
|
||||
: PROBE_RUN_TIMEOUT_MS;
|
||||
const r = spawnSync('npx', ['vitest', 'run', '--reporter=json', ...probes], {
|
||||
cwd: probeTree,
|
||||
encoding: 'utf8',
|
||||
timeout,
|
||||
// Vitest's JSON reporter on a large suite easily exceeds spawnSync's
|
||||
// 1 MiB default stdout buffer, which returns ENOBUFS and turns every
|
||||
// probe `inconclusive`. Match the 64 MiB ceiling the gh wrapper uses.
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
// `r.error` is set — and `r.status` is null — when the process never ran
|
||||
// (npx missing) or was killed (the timeout above fires SIGTERM). Ignoring
|
||||
// it reports those as "the runner produced no parseable JSON", which
|
||||
// blames the runner's output for a run that produced none.
|
||||
if (r.error) throw r.error;
|
||||
if (r.signal) {
|
||||
throw new Error(
|
||||
`runner killed by ${r.signal}${r.signal === 'SIGTERM' ? ` (probe timed out after ${Math.round(timeout / 1000)}s)` : ''}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
perFile: classifyProbeRun(
|
||||
r.status ?? 1,
|
||||
`${r.stdout ?? ''}`,
|
||||
probes,
|
||||
`${r.stderr ?? ''}`,
|
||||
),
|
||||
ms: now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one statement in the probe tree, run the affected tests, put the file
|
||||
* back. The restore is a plain content write, not a git call: the original
|
||||
* bytes are already in hand, and a write cannot be confused by whatever
|
||||
* checkout state a failed run leaves. A restore failure throws — the caller
|
||||
* must not keep mutating a tree it cannot prove clean. (Writing through
|
||||
* `join(probeTree, file)` is symlink-safe here the way `safeRmWithin` has to
|
||||
* enforce for deletes: the candidate resolved as a blob at the head commit, and
|
||||
* one git tree cannot hold both `dir` as a symlink and `dir/file` as a blob, so
|
||||
* in a fresh checkout every ancestor is a real directory.)
|
||||
*
|
||||
* Exported for its tests: the never-delete-a-mismatched-line guard cannot be
|
||||
* reached through the command (selection and the probe tree derive from the
|
||||
* same commit), so the test pins it directly rather than not at all.
|
||||
*/
|
||||
export function runOneMutant(
|
||||
probeTree: string,
|
||||
mutant: MutantCandidate,
|
||||
probes: string[],
|
||||
deadlineAt?: number,
|
||||
now: () => number = Date.now,
|
||||
): MutantResult {
|
||||
const abs = join(probeTree, mutant.file);
|
||||
const original = readFileSync(abs, 'utf8');
|
||||
const lines = original.split('\n');
|
||||
if ((lines[mutant.line - 1] ?? '').trim() !== mutant.statement) {
|
||||
// The tree does not hold the selected statement at that line. Never delete
|
||||
// a line that is not the one selected — a wrong-line mutant's verdict would
|
||||
// be attributed to a statement it never touched.
|
||||
return {
|
||||
...mutant,
|
||||
verdict: 'inconclusive',
|
||||
detail:
|
||||
'the probe tree does not match the selected statement at this line — nothing was mutated',
|
||||
};
|
||||
}
|
||||
lines.splice(mutant.line - 1, 1);
|
||||
try {
|
||||
writeFileSync(abs, lines.join('\n'), 'utf8');
|
||||
const { perFile } = runProbeSuite(probeTree, probes, deadlineAt, now);
|
||||
const verdict = classifyMutantRun(perFile);
|
||||
const detail =
|
||||
verdict === 'killed'
|
||||
? 'the suite went red with this statement deleted — a test catches its removal'
|
||||
: verdict === 'survived'
|
||||
? 'every affected test still PASSED with this statement deleted — no test fails when it is removed'
|
||||
: 'the mutated tree produced no clean verdict (likely a compile or import error) — not evidence either way';
|
||||
return { ...mutant, verdict, detail };
|
||||
} finally {
|
||||
writeFileSync(abs, original, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
async function runTestEfficacy(args: TestEfficacyArgs): Promise<void> {
|
||||
const now = args.now ?? Date.now;
|
||||
const startedAt = now();
|
||||
const { report, worktree, base, out } = args;
|
||||
const plan = JSON.parse(readFileSync(report, 'utf8')) as {
|
||||
files?: FileEntry[];
|
||||
|
|
@ -428,6 +1085,16 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise<void> {
|
|||
detail: string;
|
||||
}> = [];
|
||||
let cleanupFailure: string | undefined;
|
||||
const mutantResults: MutantResult[] = [];
|
||||
let mutantsSkippedForBudget = 0;
|
||||
let mutantsSkippedForCap = 0;
|
||||
let mutantsSkippedForBaseline = 0;
|
||||
let mutantsNote: string | undefined;
|
||||
// Notes can stack (a derailed file AND a red baseline); never clobber one
|
||||
// disclosure with another.
|
||||
const noteMutants = (note: string) => {
|
||||
mutantsNote = mutantsNote ? `${mutantsNote}; ${note}` : note;
|
||||
};
|
||||
|
||||
if (probes.length > 0 && revert.length > 0) {
|
||||
// The probe reverts the PR's source to base and runs the tests against it —
|
||||
|
|
@ -447,6 +1114,63 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise<void> {
|
|||
// repo-root `node_modules` — exactly how the shared review worktree already
|
||||
// runs vitest.
|
||||
const headSha = gitOut(worktree, 'rev-parse', 'HEAD');
|
||||
|
||||
// Mutant selection, from the COMMITTED head: the diff's added lines come
|
||||
// from `base..HEAD` and the contents from the head blobs, so the selection
|
||||
// describes exactly the tree the probe worktree below checks out — never
|
||||
// whatever uncommitted state the shared worktree happens to hold.
|
||||
let candidates: MutantCandidate[] = [];
|
||||
try {
|
||||
const mutantFiles = revert.filter(
|
||||
(p) => MUTANT_SOURCE_RE.test(p) && !DECLARATION_FILE_RE.test(p),
|
||||
);
|
||||
if (mutantFiles.length > 0) {
|
||||
const added = parseAddedLines(
|
||||
gitCapture(
|
||||
worktree,
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'diff',
|
||||
'--unified=0',
|
||||
'--no-color',
|
||||
'--src-prefix=a/',
|
||||
'--dst-prefix=b/',
|
||||
'--no-ext-diff',
|
||||
'--no-textconv',
|
||||
base,
|
||||
headSha,
|
||||
'--',
|
||||
...mutantFiles,
|
||||
),
|
||||
);
|
||||
const selection = selectMutants(
|
||||
mutantFiles
|
||||
.filter((p) => (added.get(p) ?? []).length > 0)
|
||||
.map((p) => ({
|
||||
file: p,
|
||||
content: gitCapture(worktree, 'show', `${headSha}:${p}`),
|
||||
addedLines: added.get(p) ?? [],
|
||||
hasNewTests: hasCollocatedNewTest(p, probes),
|
||||
})),
|
||||
);
|
||||
candidates = selection.selected;
|
||||
mutantsSkippedForCap = selection.skippedForCap;
|
||||
if (selection.derailed.length > 0) {
|
||||
noteMutants(
|
||||
`mutant selection dropped ${selection.derailed.length} file(s) whose literal scan derailed (${selection.derailed.join(', ')}) — a regex literal holding a quote or backtick can do this; their candidates were not probed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Selection is bookkeeping, not evidence: a diff that will not parse or a
|
||||
// blob that will not read says nothing about any test. Disclose and move
|
||||
// on — the probes and the unreachable findings do not depend on it.
|
||||
noteMutants(
|
||||
`mutant selection failed: ${e instanceof Error ? e.message : String(e)} — no mutants were run`,
|
||||
);
|
||||
candidates = [];
|
||||
}
|
||||
|
||||
const probeTree = probeWorktreePath(worktree);
|
||||
let created = false;
|
||||
let sweep: SweepResult | undefined;
|
||||
|
|
@ -464,6 +1188,72 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise<void> {
|
|||
for (const file of probes) {
|
||||
results.push({ file, verdict: 'inconclusive' as const, detail });
|
||||
}
|
||||
for (const c of candidates) {
|
||||
mutantResults.push({ ...c, verdict: 'inconclusive' as const, detail });
|
||||
}
|
||||
}
|
||||
|
||||
if (created && candidates.length > 0) {
|
||||
// The mutation phase runs BEFORE the revert: it needs the probe tree at
|
||||
// the unmodified PR head, and the revert below rewrites that tree to
|
||||
// base. The two cannot contaminate each other — every mutated file is in
|
||||
// the revert set, so the revert's checkout/delete resets it regardless of
|
||||
// what a failed restore left behind.
|
||||
try {
|
||||
// The baseline run does two jobs. A mutant is only evidence against a
|
||||
// suite that is green WITHOUT it — against a base run that already
|
||||
// fails, every mutant would be "killed" by failures it did not cause.
|
||||
// And its measured duration is the unit the budget check prices a
|
||||
// suite run at.
|
||||
// The baseline and mutant runs share a window that ends one
|
||||
// PROBE_RUN_TIMEOUT_MS before the whole budget, reserving the
|
||||
// revert probe's full slot so the pair can never exceed the
|
||||
// 600s tool ceiling (540s budget: at most 240s here + 300s revert).
|
||||
const mutantDeadline =
|
||||
startedAt + TOTAL_BUDGET_MS - PROBE_RUN_TIMEOUT_MS;
|
||||
const baseline = runProbeSuite(probeTree, probes, mutantDeadline, now);
|
||||
// A mutant is only evidence against a probe file that is green WITHOUT
|
||||
// it: against a file already red the mutant is "killed" by failures it
|
||||
// did not cause, and a file that collected nothing proves nothing. Gate
|
||||
// PER FILE, not on the whole suite — one unrelated quarantined (all-skip)
|
||||
// file is `inconclusive`, not red, and must not take the whole probe down.
|
||||
// (`inert` here is the baseline's "all passed" — the same verdict the
|
||||
// revert probe reads as "still passed with the source reverted".)
|
||||
const greenProbes = baseline.perFile
|
||||
.filter((r) => r.verdict === 'inert')
|
||||
.map((r) => r.file);
|
||||
if (greenProbes.length === 0) {
|
||||
mutantsSkippedForBaseline = candidates.length;
|
||||
noteMutants(
|
||||
'mutants not run: no probe file was green in the unmutated baseline (every file was red or collected nothing), so a red mutant run would prove nothing',
|
||||
);
|
||||
} else {
|
||||
const estimatedRunMs = baseline.ms + RUN_ESTIMATE_MARGIN_MS;
|
||||
for (const c of candidates) {
|
||||
const remaining = mutantDeadline - now();
|
||||
if (!fitsAnotherMutantRun(remaining, estimatedRunMs)) {
|
||||
mutantsSkippedForBudget =
|
||||
candidates.length - mutantResults.length;
|
||||
break;
|
||||
}
|
||||
mutantResults.push(
|
||||
runOneMutant(probeTree, c, greenProbes, mutantDeadline, now),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// The baseline, a mutant run, or a restore failed. Not evidence about
|
||||
// any statement — mark whatever never got a verdict and keep going, so
|
||||
// the revert probe below still runs.
|
||||
const detail = `mutation probe could not run: ${e instanceof Error ? e.message : String(e)}`;
|
||||
for (const c of candidates.slice(mutantResults.length)) {
|
||||
mutantResults.push({
|
||||
...c,
|
||||
verdict: 'inconclusive' as const,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (created) {
|
||||
|
|
@ -484,36 +1274,9 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise<void> {
|
|||
}
|
||||
for (const p of added) safeRmWithin(probeTree, p);
|
||||
|
||||
const r = spawnSync(
|
||||
'npx',
|
||||
['vitest', 'run', '--reporter=json', ...probes],
|
||||
{
|
||||
cwd: probeTree,
|
||||
encoding: 'utf8',
|
||||
timeout: 300_000,
|
||||
// Vitest's JSON reporter on a large suite easily exceeds spawnSync's
|
||||
// 1 MiB default stdout buffer, which returns ENOBUFS and turns every
|
||||
// probe `inconclusive`. Match the 64 MiB ceiling the gh wrapper uses.
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
// `r.error` is set — and `r.status` is null — when the process never ran
|
||||
// (npx missing) or was killed (the timeout above fires SIGTERM). Ignoring
|
||||
// it reports those as "the runner produced no parseable JSON", which
|
||||
// blames the runner's output for a run that produced none.
|
||||
if (r.error) throw r.error;
|
||||
if (r.signal) {
|
||||
throw new Error(
|
||||
`runner killed by ${r.signal}${r.signal === 'SIGTERM' ? ' (probe timed out after 300s)' : ''}`,
|
||||
);
|
||||
}
|
||||
results.push(
|
||||
...classifyProbeRun(
|
||||
r.status ?? 1,
|
||||
`${r.stdout ?? ''}`,
|
||||
probes,
|
||||
`${r.stderr ?? ''}`,
|
||||
),
|
||||
...runProbeSuite(probeTree, probes, startedAt + TOTAL_BUDGET_MS, now)
|
||||
.perFile,
|
||||
);
|
||||
} catch (e) {
|
||||
// The probe could not be set up or run. That is not evidence about any
|
||||
|
|
@ -569,23 +1332,60 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise<void> {
|
|||
kind: 'inert' as const,
|
||||
message: `\`${r.file}\`: ${r.detail}. It passes whether or not the change is present, so it cannot catch a regression in it.`,
|
||||
})),
|
||||
...mutantResults
|
||||
.filter((m) => m.verdict === 'survived')
|
||||
.map((m) => ({
|
||||
file: m.file,
|
||||
kind: 'mutant-survived' as const,
|
||||
message: `\`${m.file}:${m.line}\`: deleting the added safety statement \`${m.statement}\` leaves every affected test green. No test in this diff fails when it is removed — confirm an existing test covers it, or add one, so a regression that drops or skips this statement is caught.`,
|
||||
})),
|
||||
];
|
||||
|
||||
const count = (v: MutantVerdict) =>
|
||||
mutantResults.filter((m) => m.verdict === v).length;
|
||||
const result = {
|
||||
unreachable,
|
||||
probed: results,
|
||||
inconclusive: results.filter((r) => r.verdict === 'inconclusive'),
|
||||
mutants: {
|
||||
probed: mutantResults,
|
||||
killed: count('killed'),
|
||||
survived: count('survived'),
|
||||
inconclusive: count('inconclusive'),
|
||||
skippedForBudget: mutantsSkippedForBudget,
|
||||
skippedForCap: mutantsSkippedForCap,
|
||||
skippedForBaseline: mutantsSkippedForBaseline,
|
||||
...(mutantsNote ? { note: mutantsNote } : {}),
|
||||
},
|
||||
findings,
|
||||
cleanupFailure,
|
||||
};
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(out, JSON.stringify(result, null, 2), 'utf8');
|
||||
writeStdoutLine(
|
||||
`Wrote test-efficacy report to ${out} (${unreachable.length} unreachable, ${results.length} probed, ${findings.length} finding(s))`,
|
||||
`Wrote test-efficacy report to ${out} (${unreachable.length} unreachable, ${results.length} probed, ${mutantResults.length} mutant(s), ${findings.length} finding(s))`,
|
||||
);
|
||||
for (const f of findings) {
|
||||
writeStdoutLine(` [test] ${f.kind}: ${f.file}`);
|
||||
}
|
||||
if (mutantsSkippedForCap > 0) {
|
||||
writeStdoutLine(
|
||||
` ${mutantsSkippedForCap} mutant(s) skipped: more candidates than the cap of ${MAX_MUTANTS}`,
|
||||
);
|
||||
}
|
||||
if (mutantsSkippedForBaseline > 0) {
|
||||
writeStdoutLine(
|
||||
` ${mutantsSkippedForBaseline} mutant(s) skipped: no probe file was green in the unmutated baseline`,
|
||||
);
|
||||
}
|
||||
if (mutantsSkippedForBudget > 0) {
|
||||
writeStdoutLine(
|
||||
` ${mutantsSkippedForBudget} mutant(s) skipped: the remaining budget cannot fit another suite run`,
|
||||
);
|
||||
}
|
||||
if (mutantsNote) {
|
||||
writeStdoutLine(` ${mutantsNote}`);
|
||||
}
|
||||
if (cleanupFailure) {
|
||||
// A leftover probe worktree does not corrupt the shared tree — it is swept
|
||||
// at the start of the next run and by cleanup.ts — so this is a warning, not
|
||||
|
|
@ -597,7 +1397,7 @@ async function runTestEfficacy(args: TestEfficacyArgs): Promise<void> {
|
|||
export const testEfficacyCommand: CommandModule = {
|
||||
command: 'test-efficacy <report>',
|
||||
describe:
|
||||
"Check whether the diff's new tests actually gate its new behaviour (unreachable + revert probe)",
|
||||
"Check whether the diff's new tests actually gate its new behaviour (unreachable + revert probe + statement-deletion mutants)",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('report', {
|
||||
|
|
|
|||
21
packages/cli/src/serve/acp-http/dispatch-error.test.ts
Normal file
21
packages/cli/src/serve/acp-http/dispatch-error.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DaemonDrainingError } from '../server/session-archive.js';
|
||||
import { toRpcError } from './dispatch.js';
|
||||
import { RPC } from './json-rpc.js';
|
||||
|
||||
describe('toRpcError', () => {
|
||||
it('maps sealed maintenance to a JSON-RPC server error', () => {
|
||||
expect(toRpcError(new DaemonDrainingError())).toEqual({
|
||||
code: RPC.INTERNAL_ERROR,
|
||||
message:
|
||||
'The daemon is draining and no longer accepts session maintenance.',
|
||||
data: { errorKind: 'daemon_draining' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
BTW_MAX_INPUT_LENGTH,
|
||||
createDebugLogger,
|
||||
GROUP_COLOR_OPTIONS,
|
||||
Storage,
|
||||
SessionService,
|
||||
SessionOrganizationError,
|
||||
SESSION_WRITER_RPC_CODES,
|
||||
|
|
@ -60,6 +61,7 @@ import { writeStderrLine } from '../../utils/stdioHelpers.js';
|
|||
import { MAX_WORKSPACE_PATH_LENGTH } from '../fs/paths.js';
|
||||
import {
|
||||
MAX_READ_BYTES,
|
||||
MAX_TEXT_CURSOR_CHARS,
|
||||
type WorkspaceFileSystemFactory,
|
||||
} from '../fs/index.js';
|
||||
import {
|
||||
|
|
@ -102,7 +104,9 @@ import { createSessionOrganizationService } from '../session-organization-helper
|
|||
import {
|
||||
archiveDaemonSessions,
|
||||
assertSessionLoadable,
|
||||
deleteDaemonSessionIfOrphan,
|
||||
deleteDaemonSessions,
|
||||
DaemonDrainingError,
|
||||
logSessionArchiveWarning,
|
||||
SessionArchiveCoordinator,
|
||||
unarchiveDaemonSessions,
|
||||
|
|
@ -560,11 +564,18 @@ function pickSessionArtifactInput(
|
|||
* the operator-facing message is not a cross-tenant leak), and anything
|
||||
* unrecognized collapses to a generic INTERNAL_ERROR string.
|
||||
*/
|
||||
function toRpcError(err: unknown): {
|
||||
export function toRpcError(err: unknown): {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
} {
|
||||
if (err instanceof DaemonDrainingError) {
|
||||
return {
|
||||
code: RPC.INTERNAL_ERROR,
|
||||
message: err.message,
|
||||
data: { errorKind: 'daemon_draining' },
|
||||
};
|
||||
}
|
||||
const writerError = sessionWriterRpcError(err);
|
||||
if (writerError) return writerError;
|
||||
if (err instanceof AcpParamError || err instanceof InvalidCursorError) {
|
||||
|
|
@ -807,6 +818,7 @@ export class AcpDispatcher {
|
|||
private readonly captureGenerationAssertion: () =>
|
||||
| (() => void)
|
||||
| undefined = () => undefined,
|
||||
private readonly sessionRuntimeBaseDir: string = Storage.getRuntimeBaseDir(),
|
||||
) {
|
||||
this.agentManager = createDaemonSubagentManager(boundWorkspace);
|
||||
}
|
||||
|
|
@ -815,20 +827,21 @@ export class AcpDispatcher {
|
|||
sessionId: string,
|
||||
removePersistedSession = false,
|
||||
): void {
|
||||
void this.bridge
|
||||
.killSession(sessionId, { requireZeroAttaches: true })
|
||||
.then(async (killed) => {
|
||||
if (killed && removePersistedSession) {
|
||||
await new SessionService(this.boundWorkspace).removeSession(
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
writeStderrLine(
|
||||
`qwen serve: /acp orphan killSession(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`,
|
||||
),
|
||||
);
|
||||
const cleanup = removePersistedSession
|
||||
? deleteDaemonSessionIfOrphan({
|
||||
sessionId,
|
||||
service: new SessionService(this.boundWorkspace, {
|
||||
runtimeBaseDir: this.sessionRuntimeBaseDir,
|
||||
}),
|
||||
bridge: this.bridge,
|
||||
coordinator: this.archiveCoordinator,
|
||||
})
|
||||
: this.bridge.killSession(sessionId, { requireZeroAttaches: true });
|
||||
void cleanup.catch((err) =>
|
||||
writeStderrLine(
|
||||
`qwen serve: /acp orphan killSession(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1162,6 +1175,18 @@ export class AcpDispatcher {
|
|||
msg: JsonRpcInbound,
|
||||
sessionHeader?: string,
|
||||
reqLoopback?: boolean,
|
||||
): Promise<void> {
|
||||
return Storage.runWithResolvedRuntimeBaseDir(
|
||||
this.sessionRuntimeBaseDir,
|
||||
() => this.handleInRuntime(conn, msg, sessionHeader, reqLoopback),
|
||||
);
|
||||
}
|
||||
|
||||
private async handleInRuntime(
|
||||
conn: AcpConnection,
|
||||
msg: JsonRpcInbound,
|
||||
sessionHeader?: string,
|
||||
reqLoopback?: boolean,
|
||||
): Promise<void> {
|
||||
// Loopback is evaluated PER REQUEST (the permission-vote POST may arrive
|
||||
// from a different peer than `initialize`), falling back to the
|
||||
|
|
@ -3334,8 +3359,31 @@ export class AcpDispatcher {
|
|||
);
|
||||
return;
|
||||
}
|
||||
const rawCursor = params['cursor'];
|
||||
if (
|
||||
rawCursor !== undefined &&
|
||||
(typeof rawCursor !== 'string' ||
|
||||
rawCursor.length === 0 ||
|
||||
rawCursor.length > MAX_TEXT_CURSOR_CHARS)
|
||||
) {
|
||||
if (id !== undefined)
|
||||
conn.sendConn(
|
||||
error(
|
||||
id,
|
||||
RPC.INVALID_PARAMS,
|
||||
`\`cursor\` must be a non-empty string of at most ${MAX_TEXT_CURSOR_CHARS} characters`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const cursor = rawCursor as string | undefined;
|
||||
const resolved = await fs.resolve(p, 'read');
|
||||
const out = await fs.readText(resolved, { maxBytes, line, limit });
|
||||
const out = await fs.readText(resolved, {
|
||||
maxBytes,
|
||||
line,
|
||||
limit,
|
||||
cursor,
|
||||
});
|
||||
this.replyConn(conn, id, {
|
||||
path: p,
|
||||
content: out.content,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import type { Duplex } from 'node:stream';
|
|||
import type { Application, Request, Response } from 'express';
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes';
|
||||
import { RUNTIME_MCP_IF_ABSENT_CONFIG_FLAG } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
RUNTIME_MCP_IF_ABSENT_CONFIG_FLAG,
|
||||
Storage,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { writeStderrLine } from '../../utils/stdioHelpers.js';
|
||||
import type { DaemonWorkspaceService } from '../workspace-service/types.js';
|
||||
import type { WorkspaceFileSystemFactory } from '../fs/index.js';
|
||||
|
|
@ -792,6 +795,8 @@ export function mountAcpHttp(
|
|||
const guard = opts.workspaceRegistry?.primaryEntry.current?.guard;
|
||||
return guard ? () => guard.assertOpen() : undefined;
|
||||
},
|
||||
opts.workspaceRegistry?.primary.sessionRuntimeBaseDir ??
|
||||
Storage.getRuntimeBaseDir(),
|
||||
);
|
||||
dispatcherRef.current = dispatcher;
|
||||
|
||||
|
|
@ -1271,6 +1276,7 @@ export function mountAcpHttp(
|
|||
const guard = rt.generationGuard;
|
||||
return guard ? () => guard.assertOpen() : undefined;
|
||||
},
|
||||
rt.sessionRuntimeBaseDir,
|
||||
);
|
||||
secondaryDispatcherRef.current = secondaryDispatcher;
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import {
|
|||
} from '../../services/setup-github.js';
|
||||
import {
|
||||
MAX_READ_BYTES,
|
||||
MAX_TEXT_CURSOR_CHARS,
|
||||
type ResolvedPath,
|
||||
type WorkspaceFileSystem,
|
||||
type WorkspaceFileSystemFactory,
|
||||
|
|
@ -173,6 +174,7 @@ class FakeBridge {
|
|||
gate: Promise<void> | undefined;
|
||||
/** `attached` value loadSession returns (false = spawned-from-disk). */
|
||||
loadAttached = true;
|
||||
spawnSessionId = 'sess-1';
|
||||
spawnClientId: string | undefined = 'client-1';
|
||||
loadRequests: Array<{
|
||||
sessionId: string;
|
||||
|
|
@ -190,7 +192,7 @@ class FakeBridge {
|
|||
this.lastSpawnScope = req?.sessionScope;
|
||||
if (this.gate) await this.gate;
|
||||
return {
|
||||
sessionId: 'sess-1',
|
||||
sessionId: this.spawnSessionId,
|
||||
workspaceCwd: '/ws',
|
||||
attached: false,
|
||||
clientId: this.spawnClientId,
|
||||
|
|
@ -838,8 +840,13 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
let base: string;
|
||||
let bridge: FakeBridge;
|
||||
let acpHandle: AcpHttpHandle | undefined;
|
||||
let previousRuntimeDir: string | undefined;
|
||||
let runtimeDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-acp-archive-'));
|
||||
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
|
||||
stdioMocks.writeStderrLine.mockClear();
|
||||
setupGithubMocks.setupGithub.mockReset();
|
||||
setupGithubMocks.setupGithub.mockResolvedValue({
|
||||
|
|
@ -901,6 +908,12 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
// `server.close()` doesn't hang on them.
|
||||
server.closeAllConnections?.();
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
if (previousRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
|
||||
}
|
||||
await fs.rm(runtimeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function restartServer(opts: {
|
||||
|
|
@ -922,6 +935,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
? createSingleWorkspaceRegistry({
|
||||
workspaceId: 'primary',
|
||||
workspaceCwd: boundWorkspace,
|
||||
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
|
||||
primary: true,
|
||||
trusted: opts.primaryTrusted ?? true,
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
|
|
@ -1018,21 +1032,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
async function withRuntimeDir<T>(
|
||||
fn: (runtimeDir: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
const runtimeDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-acp-archive-'),
|
||||
);
|
||||
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
|
||||
try {
|
||||
return await fn(runtimeDir);
|
||||
} finally {
|
||||
if (previousRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
|
||||
}
|
||||
await fs.rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
return fn(runtimeDir);
|
||||
}
|
||||
|
||||
async function writeStoredSession(
|
||||
|
|
@ -3737,13 +3737,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
it.each(['session/load', 'session/resume'])(
|
||||
'%s rejects archived sessions',
|
||||
async (method) => {
|
||||
const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
const runtimeDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-acp-archive-'),
|
||||
);
|
||||
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440123';
|
||||
try {
|
||||
await withRuntimeDir(async () => {
|
||||
const chatsDir = path.join(
|
||||
new Storage('/ws').getProjectDir(),
|
||||
'chats',
|
||||
|
|
@ -3782,14 +3777,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
expect(frame.id).toBe(211);
|
||||
expect(frame.error.code).toBe(-32603);
|
||||
expect(frame.error.data?.errorKind).toBe('session_archived');
|
||||
} finally {
|
||||
if (previousRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
|
||||
}
|
||||
await fs.rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -3860,7 +3848,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('session/load holds archive gate while restore is in flight', async () => {
|
||||
it('session/load reports an archive conflict while restore is in flight', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440124';
|
||||
await writeStoredSession(sessionId);
|
||||
|
|
@ -3903,9 +3891,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
expect(await reader.next()).toMatchObject({
|
||||
id: 213,
|
||||
error: {
|
||||
code: -32603,
|
||||
data: { errorKind: 'session_archiving', sessionId },
|
||||
result: {
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -3973,7 +3966,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('session/prompt holds archive gate while prompt is in flight', async () => {
|
||||
it('session/prompt reports an archive conflict while prompt is in flight', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440127';
|
||||
await writeStoredSession(sessionId);
|
||||
|
|
@ -4023,9 +4016,14 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
expect(await connReader.next()).toMatchObject({
|
||||
id: 219,
|
||||
error: {
|
||||
code: -32603,
|
||||
data: { errorKind: 'session_archiving', sessionId },
|
||||
result: {
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(bridge.closedSessions).toEqual([]);
|
||||
|
|
@ -4880,6 +4878,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
|
||||
it('session/new orphan: DELETE before spawn resolves removes the persisted session', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440126';
|
||||
bridge.spawnSessionId = sessionId;
|
||||
await writeStoredSession(sessionId);
|
||||
const removeSession = vi
|
||||
.spyOn(SessionService.prototype, 'removeSession')
|
||||
.mockResolvedValue(true);
|
||||
|
|
@ -4899,8 +4900,8 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
release(); // spawn resolves AFTER destroy
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
expect(bridge.killed).toContain('sess-1');
|
||||
expect(removeSession).toHaveBeenCalledWith('sess-1');
|
||||
expect(bridge.killed).toContain(sessionId);
|
||||
expect(removeSession).toHaveBeenCalledWith(sessionId);
|
||||
removeSession.mockRestore();
|
||||
});
|
||||
|
||||
|
|
@ -6606,7 +6607,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('_qwen/session/artifacts/add holds the archive gate while mutating', async () => {
|
||||
it('_qwen/session/artifacts/add reports an archive conflict while mutating', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440131';
|
||||
await writeStoredSession(sessionId);
|
||||
|
|
@ -6656,9 +6657,16 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
expect(await reader.next()).toMatchObject({
|
||||
id: 61,
|
||||
error: {
|
||||
code: -32603,
|
||||
data: { errorKind: 'session_archiving', sessionId },
|
||||
result: {
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining(
|
||||
'is being archived or unarchived',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -6671,7 +6679,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('_qwen/session/artifacts/remove holds the archive gate while mutating', async () => {
|
||||
it('_qwen/session/artifacts/remove reports an archive conflict while mutating', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440132';
|
||||
await writeStoredSession(sessionId);
|
||||
|
|
@ -6729,9 +6737,16 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
expect(await reader.next()).toMatchObject({
|
||||
id: 63,
|
||||
error: {
|
||||
code: -32603,
|
||||
data: { errorKind: 'session_archiving', sessionId },
|
||||
result: {
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining(
|
||||
'is being archived or unarchived',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -7553,46 +7568,47 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
it('_qwen/sessions/delete sanitizes stderr remove errors', async () => {
|
||||
const lineSep = '\u2028';
|
||||
const bidiOverride = '\u202e';
|
||||
const sessionId = `sess${lineSep}FAKE\r\x1b[31m`;
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440127';
|
||||
const removeError = `remove\nFAILED\r\x1b[31m${lineSep}${bidiOverride}`;
|
||||
const removeSessionSpy = vi
|
||||
.spyOn(SessionService.prototype, 'removeSession')
|
||||
.mockRejectedValueOnce(new Error(removeError));
|
||||
await withRuntimeDir(async () => {
|
||||
await writeStoredSession(sessionId);
|
||||
const removeSessionSpy = vi
|
||||
.spyOn(SessionService.prototype, 'removeSession')
|
||||
.mockRejectedValueOnce(new Error(removeError));
|
||||
|
||||
try {
|
||||
const connId = await initialize();
|
||||
const streamRes = openStream(connId);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await post(connId, {
|
||||
jsonrpc: '2.0',
|
||||
id: 68,
|
||||
method: '_qwen/sessions/delete',
|
||||
params: { sessionIds: [sessionId] },
|
||||
});
|
||||
const frames = await takeFrames(await streamRes, 1);
|
||||
expect(frames[0]).toMatchObject({
|
||||
result: {
|
||||
removed: [],
|
||||
notFound: [],
|
||||
errors: [{ sessionId, error: removeError }],
|
||||
},
|
||||
});
|
||||
expect(removeSessionSpy).toHaveBeenCalledWith(sessionId);
|
||||
try {
|
||||
const connId = await initialize();
|
||||
const streamRes = openStream(connId);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await post(connId, {
|
||||
jsonrpc: '2.0',
|
||||
id: 68,
|
||||
method: '_qwen/sessions/delete',
|
||||
params: { sessionIds: [sessionId] },
|
||||
});
|
||||
const frames = await takeFrames(await streamRes, 1);
|
||||
expect(frames[0]).toMatchObject({
|
||||
result: {
|
||||
removed: [],
|
||||
notFound: [],
|
||||
errors: [{ sessionId, error: removeError }],
|
||||
},
|
||||
});
|
||||
expect(removeSessionSpy).toHaveBeenCalledWith(sessionId);
|
||||
|
||||
const deleteLog = stdioMocks.writeStderrLine.mock.calls
|
||||
.map(([line]) => line)
|
||||
.find((line) => line.includes('sessions/delete'));
|
||||
expect(deleteLog).toContain(
|
||||
'removeSession(sess FAK) failed: remove FAILED [31m',
|
||||
);
|
||||
expect(deleteLog).not.toContain('\n');
|
||||
expect(deleteLog).not.toContain('\r');
|
||||
expect(deleteLog).not.toContain('\x1b');
|
||||
expect(deleteLog).not.toContain(lineSep);
|
||||
expect(deleteLog).not.toContain(bidiOverride);
|
||||
} finally {
|
||||
removeSessionSpy.mockRestore();
|
||||
}
|
||||
const deleteLog = stdioMocks.writeStderrLine.mock.calls
|
||||
.map(([line]) => line)
|
||||
.find((line) => line.includes('sessions/delete'));
|
||||
expect(deleteLog).toContain('remove FAILED [31m');
|
||||
expect(deleteLog).not.toContain('\n');
|
||||
expect(deleteLog).not.toContain('\r');
|
||||
expect(deleteLog).not.toContain('\x1b');
|
||||
expect(deleteLog).not.toContain(lineSep);
|
||||
expect(deleteLog).not.toContain(bidiOverride);
|
||||
} finally {
|
||||
removeSessionSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('_qwen/sessions/delete deletes available ids when another id is loading', async () => {
|
||||
|
|
@ -7659,7 +7675,7 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('_qwen/sessions/delete does not make missing archive ids wait on live close', async () => {
|
||||
it('_qwen/sessions/archive returns session_archiving while delete owns the gate', async () => {
|
||||
const sessionId = 'delete-archive-race';
|
||||
let firstCloseStarted!: () => void;
|
||||
let releaseFirstClose!: () => void;
|
||||
|
|
@ -7720,7 +7736,12 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
}),
|
||||
expect.objectContaining({
|
||||
id: 70,
|
||||
result: expect.objectContaining({ notFound: [sessionId] }),
|
||||
error: expect.objectContaining({
|
||||
data: {
|
||||
errorKind: 'session_archiving',
|
||||
sessionId,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
|
@ -8141,6 +8162,41 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
maxBytes: undefined,
|
||||
line: undefined,
|
||||
limit: undefined,
|
||||
cursor: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('_qwen/file/read forwards a valid cursor and returns paged content', async () => {
|
||||
const readText = vi.fn(async () => ({
|
||||
content: 'page-two',
|
||||
meta: { truncated: true, nextCursor: 'cursor-2' },
|
||||
}));
|
||||
await restartServer({
|
||||
fsFactory: makeFileFsFactory({ readText }),
|
||||
});
|
||||
const connId = await initialize();
|
||||
const streamRes = openStream(connId);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
await post(connId, {
|
||||
jsonrpc: '2.0',
|
||||
id: 93,
|
||||
method: '_qwen/file/read',
|
||||
params: { path: 'test.txt', cursor: 'cursor-1' },
|
||||
});
|
||||
const frames = await takeFrames(await streamRes, 1);
|
||||
expect(frames[0]).toMatchObject({
|
||||
result: {
|
||||
path: 'test.txt',
|
||||
content: 'page-two',
|
||||
truncated: true,
|
||||
nextCursor: 'cursor-2',
|
||||
},
|
||||
});
|
||||
expect(readText).toHaveBeenCalledWith(resolvedPath('/ws/test.txt'), {
|
||||
maxBytes: undefined,
|
||||
line: undefined,
|
||||
limit: undefined,
|
||||
cursor: 'cursor-1',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -8160,6 +8216,9 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
|
|||
{ limit: 1.5 },
|
||||
{ limit: '1' },
|
||||
{ limit: null },
|
||||
{ cursor: '' },
|
||||
{ cursor: 123 },
|
||||
{ cursor: 'x'.repeat(MAX_TEXT_CURSOR_CHARS + 1) },
|
||||
])('_qwen/file/read rejects invalid window params (%j)', async (params) => {
|
||||
const readText = vi.fn(async () => ({
|
||||
content: 'hello',
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ function makeRuntime(input: {
|
|||
return {
|
||||
workspaceId: input.id,
|
||||
workspaceCwd: input.cwd,
|
||||
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
|
||||
primary: input.primary,
|
||||
trusted: input.trusted,
|
||||
env: input.env ?? PARENT_ENV,
|
||||
|
|
@ -115,24 +116,6 @@ async function writeStoredSession(sessionId: string, cwd: string) {
|
|||
);
|
||||
}
|
||||
|
||||
async function withRuntimeDir<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
const runtimeDir = await fsp.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-workspace-qualified-acp-'),
|
||||
);
|
||||
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (previousRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
|
||||
}
|
||||
await fsp.rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
|
||||
let server: Server;
|
||||
let base: string;
|
||||
|
|
@ -146,8 +129,15 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
|
|||
let workspaceRegistry: ReturnType<typeof createWorkspaceRegistry>;
|
||||
let secondaryRuntime: WorkspaceRuntime;
|
||||
let workspaceVoiceConnection: ReturnType<typeof vi.fn>;
|
||||
let runtimeDir: string;
|
||||
let previousRuntimeDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
runtimeDir = await fsp.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-workspace-qualified-acp-'),
|
||||
);
|
||||
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
|
||||
setupGithubMock.mockReset();
|
||||
setupGithubMock.mockImplementation(async ({ cwd }: { cwd: string }) => ({
|
||||
kind: 'github_setup',
|
||||
|
|
@ -247,6 +237,12 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
|
|||
deviceFlowRegistry?.dispose();
|
||||
server.closeAllConnections?.();
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
if (previousRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
|
||||
}
|
||||
await fsp.rm(runtimeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function postInitialize(pathname: string): Promise<Response> {
|
||||
|
|
@ -571,45 +567,43 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => {
|
|||
});
|
||||
|
||||
it('updates persisted organization in the selected workspace only', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440180';
|
||||
await writeStoredSession(sessionId, '/ws-b');
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440180';
|
||||
await writeStoredSession(sessionId, '/ws-b');
|
||||
|
||||
const response = await sendWsRequest('/workspaces/secondary-id/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: '_qwen/session/update_organization',
|
||||
params: { sessionId, isPinned: true },
|
||||
});
|
||||
|
||||
expect(response['result']).toMatchObject({ sessionId, isPinned: true });
|
||||
const listed = await sendWsRequest('/workspaces/secondary-id/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'session/list',
|
||||
params: { view: 'organized', group: 'pinned' },
|
||||
});
|
||||
expect(listed['result']).toMatchObject({
|
||||
sessions: [expect.objectContaining({ sessionId, isPinned: true })],
|
||||
});
|
||||
|
||||
const legacy = await sendWsRequest('/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 4,
|
||||
method: '_qwen/session/update_organization',
|
||||
params: { sessionId, isPinned: false },
|
||||
});
|
||||
expect(legacy['error']).toMatchObject({ code: -32602 });
|
||||
|
||||
const secondarySnapshot =
|
||||
await createSessionOrganizationService('/ws-b').readSnapshot();
|
||||
const primarySnapshot =
|
||||
await createSessionOrganizationService('/ws').readSnapshot();
|
||||
expect(secondarySnapshot.sessions.get(sessionId)).toMatchObject({
|
||||
isPinned: true,
|
||||
});
|
||||
expect(primarySnapshot.sessions.has(sessionId)).toBe(false);
|
||||
const response = await sendWsRequest('/workspaces/secondary-id/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: '_qwen/session/update_organization',
|
||||
params: { sessionId, isPinned: true },
|
||||
});
|
||||
|
||||
expect(response['result']).toMatchObject({ sessionId, isPinned: true });
|
||||
const listed = await sendWsRequest('/workspaces/secondary-id/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
method: 'session/list',
|
||||
params: { view: 'organized', group: 'pinned' },
|
||||
});
|
||||
expect(listed['result']).toMatchObject({
|
||||
sessions: [expect.objectContaining({ sessionId, isPinned: true })],
|
||||
});
|
||||
|
||||
const legacy = await sendWsRequest('/acp', {
|
||||
jsonrpc: '2.0',
|
||||
id: 4,
|
||||
method: '_qwen/session/update_organization',
|
||||
params: { sessionId, isPinned: false },
|
||||
});
|
||||
expect(legacy['error']).toMatchObject({ code: -32602 });
|
||||
|
||||
const secondarySnapshot =
|
||||
await createSessionOrganizationService('/ws-b').readSnapshot();
|
||||
const primarySnapshot =
|
||||
await createSessionOrganizationService('/ws').readSnapshot();
|
||||
expect(secondarySnapshot.sessions.get(sessionId)).toMatchObject({
|
||||
isPinned: true,
|
||||
});
|
||||
expect(primarySnapshot.sessions.has(sessionId)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an untrusted workspace with 403 untrusted_workspace', async () => {
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ describe('createBridgeFileSystemAdapter', () => {
|
|||
expect(response.content).toBe(lines.slice(2, 22).join('\n'));
|
||||
});
|
||||
|
||||
it('keeps an oversized ACP line-only read behind the snapshot cap', async () => {
|
||||
it('serves an oversized ACP line-only read as a bounded window', async () => {
|
||||
const { MAX_READ_BYTES } = await import('./fs/policy.js');
|
||||
const target = path.join(tmpDir, 'large-line-only.txt');
|
||||
await fsp.writeFile(target, 'x'.repeat(MAX_READ_BYTES + 1), 'utf8');
|
||||
|
|
@ -279,15 +279,13 @@ describe('createBridgeFileSystemAdapter', () => {
|
|||
buildFactory({ trusted: true }),
|
||||
);
|
||||
|
||||
const err = await adapter
|
||||
.readText({
|
||||
path: target,
|
||||
sessionId: 'sess:test',
|
||||
line: 2,
|
||||
})
|
||||
.catch((error: unknown) => error);
|
||||
const response = await adapter.readText({
|
||||
path: target,
|
||||
sessionId: 'sess:test',
|
||||
line: 2,
|
||||
});
|
||||
|
||||
expect((err as { kind?: string }).kind).toBe('file_too_large');
|
||||
expect(response.content).toBe('');
|
||||
});
|
||||
|
||||
it('treats null line/limit as undefined (ACP wire compatibility)', async () => {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
|
|||
// must not be polled in a tight loop.
|
||||
session_info: { since: 'v1' },
|
||||
session_source_metadata: { since: 'v1' },
|
||||
session_side_task: { since: 'v1' },
|
||||
session_prompt: { since: 'v1' },
|
||||
session_cancel: { since: 'v1' },
|
||||
session_events: { since: 'v1' },
|
||||
|
|
@ -135,6 +136,14 @@ export const SERVE_CAPABILITY_REGISTRY = {
|
|||
// advertise the text/list/stat/glob surface without byte-window
|
||||
// support.
|
||||
workspace_file_bytes: { since: 'v1' },
|
||||
// Daemon supports byte-cursor paging on `GET /file`: responses carry
|
||||
// `nextCursor`/`hasMore` and requests accept `cursor`. A separate tag from
|
||||
// `workspace_file_read` because the convention here is that new behavior
|
||||
// gets a new tag — a client that preflighted the old one must not silently
|
||||
// receive a surface it cannot recognise. Same split as
|
||||
// `workspace_file_bytes` from `workspace_file_read`, and
|
||||
// `session_transcript_pagination` from `session_transcript`.
|
||||
workspace_file_read_cursor: { since: 'v1' },
|
||||
// Daemon supports hash-aware text mutation routes
|
||||
// (`POST /file/write`, `POST /file/edit`) behind the strict mutation
|
||||
// gate. Clients should still pre-flight `require_auth` separately for
|
||||
|
|
|
|||
|
|
@ -62,3 +62,4 @@ export {
|
|||
type WriteTextAtomicOptions,
|
||||
type WriteTextAtomicOutcome,
|
||||
} from './workspace-file-system.js';
|
||||
export { MAX_TEXT_CURSOR_CHARS } from './text-cursor.js';
|
||||
|
|
|
|||
|
|
@ -31,6 +31,26 @@ import type { Intent, ResolvedPath } from './paths.js';
|
|||
*/
|
||||
export const MAX_READ_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Upper bound on bytes read off disk to locate a line window above
|
||||
* `MAX_READ_BYTES`.
|
||||
*
|
||||
* `MAX_READ_BYTES` caps what a read *returns*; it says nothing about what a
|
||||
* read *costs*. Line offsets address a byte stream, so `{ line: 900_000_000,
|
||||
* limit: 20 }` returns almost nothing and still walks the file from byte 0.
|
||||
* Without this cap a single query param turns into an uninterruptible
|
||||
* multi-second scan of an arbitrarily large file, and on Windows it holds a
|
||||
* read handle (opened without `FILE_SHARE_DELETE`) for that entire span,
|
||||
* blocking renames and deletes of the target.
|
||||
*
|
||||
* 8 MiB is ~25 ms at the ~300 MB/s this streams at — small enough that the
|
||||
* cost is bounded and the handle-hold window stays negligible, large enough
|
||||
* to cover the head and tail-ish regions agents actually ask for. Requests
|
||||
* past it get `file_too_large` pointing at `readBytes`, which reaches any
|
||||
* offset in O(1).
|
||||
*/
|
||||
export const MAX_TEXT_SCAN_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Maximum bytes accepted by `writeText` / `edit`. Sized below the
|
||||
* `express.json({ limit: '10mb' })` middleware cap so a request
|
||||
|
|
|
|||
128
packages/cli/src/serve/fs/text-cursor.ts
Normal file
128
packages/cli/src/serve/fs/text-cursor.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Opaque resume token for `readText` byte-cursor paging.
|
||||
*
|
||||
* Unsigned `base64url(JSON)`, matching `encodeOrganizedCursor` in
|
||||
* `server/session-list.ts`. Deliberately *not* the HMAC-signed scheme used by
|
||||
* `session-transcript-reader.ts`: that cursor addresses a persisted session
|
||||
* file the caller names only indirectly, whereas here the path is re-resolved
|
||||
* through the workspace boundary on every request. A forged cursor can
|
||||
* therefore only move the byte offset within a file the caller is already
|
||||
* authorised to read — precisely what `GET /file/bytes?offset=` already allows
|
||||
* — so signing would buy a key schedule and no boundary.
|
||||
*
|
||||
* What the payload *is* for is staleness: `{dev, ino}` catches a replaced file
|
||||
* and `size` catches a truncated one, turning a stale cursor into a typed
|
||||
* error instead of bytes from the wrong place.
|
||||
*/
|
||||
|
||||
import { FsError } from './errors.js';
|
||||
|
||||
const CURSOR_VERSION = 1;
|
||||
|
||||
/**
|
||||
* A well-formed cursor is ~120 bytes of base64url. The cap exists so a
|
||||
* hostile client cannot make us parse megabytes before rejecting.
|
||||
*/
|
||||
export const MAX_TEXT_CURSOR_CHARS = 1024;
|
||||
|
||||
export interface TextCursorState {
|
||||
/** Byte offset the next page starts at. */
|
||||
off: number;
|
||||
/** File size when the cursor was minted, for shrink detection. */
|
||||
size: number;
|
||||
/** Device and inode as decimal strings — `Stats` fields may be `bigint`. */
|
||||
dev: string;
|
||||
ino: string;
|
||||
}
|
||||
|
||||
export function encodeTextCursor(state: TextCursorState): string {
|
||||
return Buffer.from(
|
||||
JSON.stringify({ v: CURSOR_VERSION, ...state }),
|
||||
'utf8',
|
||||
).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a client-supplied cursor. Shape problems are the client's fault
|
||||
* (`parse_error`); a cursor that decodes but no longer matches the file is a
|
||||
* concurrency problem (`hash_mismatch`), and that distinction is checked by
|
||||
* {@link assertCursorMatchesFile} once the file has been opened.
|
||||
*/
|
||||
export function decodeTextCursor(cursor: string): TextCursorState {
|
||||
if (cursor.length === 0 || cursor.length > MAX_TEXT_CURSOR_CHARS) {
|
||||
throw new FsError(
|
||||
'parse_error',
|
||||
`cursor must be a non-empty string of at most ${MAX_TEXT_CURSOR_CHARS} characters`,
|
||||
{ hint: 'pass a cursor returned by a previous read' },
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
throw new FsError('parse_error', 'cursor is not a valid read cursor', {
|
||||
hint: 'pass a cursor returned by a previous read',
|
||||
});
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new FsError('parse_error', 'cursor is not a valid read cursor', {
|
||||
hint: 'pass a cursor returned by a previous read',
|
||||
});
|
||||
}
|
||||
const raw = parsed as Record<string, unknown>;
|
||||
const off = raw['off'];
|
||||
const size = raw['size'];
|
||||
const dev = raw['dev'];
|
||||
const ino = raw['ino'];
|
||||
if (
|
||||
raw['v'] !== CURSOR_VERSION ||
|
||||
!Number.isSafeInteger(off) ||
|
||||
(off as number) < 0 ||
|
||||
!Number.isSafeInteger(size) ||
|
||||
(size as number) < 0 ||
|
||||
typeof dev !== 'string' ||
|
||||
typeof ino !== 'string'
|
||||
) {
|
||||
throw new FsError('parse_error', 'cursor is not a valid read cursor', {
|
||||
hint: 'pass a cursor returned by a previous read',
|
||||
});
|
||||
}
|
||||
return { off: off as number, size: size as number, dev, ino };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a cursor known stale through replacement or shrinkage.
|
||||
*
|
||||
* Growth is fine and is the point: appending to a log does not move the lines
|
||||
* an outstanding cursor points at. Shrinking is not — the offset may now land
|
||||
* mid-line or past the end, and the bytes there are not the ones the client
|
||||
* was reading.
|
||||
*
|
||||
* Residual: a same-inode rewrite that keeps or grows the file, or a
|
||||
* delete-and-recreate that reuses the inode, passes both checks. `mtimeMs`
|
||||
* cannot close that gap because both those cases and a valid append advance
|
||||
* it; hashing the prefix would make every page O(n), defeating the cursor.
|
||||
*/
|
||||
export function assertCursorMatchesFile(
|
||||
cursor: TextCursorState,
|
||||
stats: { dev: number | bigint; ino: number | bigint; size: number },
|
||||
path: string,
|
||||
): void {
|
||||
if (
|
||||
String(stats.dev) !== cursor.dev ||
|
||||
String(stats.ino) !== cursor.ino ||
|
||||
stats.size < cursor.size
|
||||
) {
|
||||
throw new FsError(
|
||||
'hash_mismatch',
|
||||
`cursor no longer matches the file it was issued for: ${path}`,
|
||||
{ hint: 're-read the file from the beginning to get a fresh cursor' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import * as os from 'node:os';
|
|||
import * as path from 'node:path';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { Ignore, StandardFileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import { encodeTextCursor } from './text-cursor.js';
|
||||
import {
|
||||
FS_ACCESS_EVENT_TYPE,
|
||||
FS_DENIED_EVENT_TYPE,
|
||||
|
|
@ -199,21 +200,14 @@ describe('WorkspaceFileSystem - readText', () => {
|
|||
expect(expanded.meta.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('throws file_too_large for an oversized read without a finite line limit', async () => {
|
||||
it('throws file_too_large for an oversized read with no window argument', async () => {
|
||||
const big = path.join(h.workspace, 'huge.txt');
|
||||
const bytes = (await import('./policy.js')).MAX_READ_BYTES + 1;
|
||||
await fsp.writeFile(big, 'a'.repeat(bytes));
|
||||
const r = await h.fs.resolve('huge.txt', 'read');
|
||||
for (const opts of [
|
||||
{},
|
||||
{ line: 2 },
|
||||
{ maxBytes: 1024 },
|
||||
{ line: 2, maxBytes: 1024 },
|
||||
]) {
|
||||
const err = await h.fs.readText(r, opts).catch((e: unknown) => e);
|
||||
expect(isFsError(err)).toBe(true);
|
||||
expect((err as { kind: string }).kind).toBe('file_too_large');
|
||||
}
|
||||
const err = await h.fs.readText(r).catch((e: unknown) => e);
|
||||
expect(isFsError(err)).toBe(true);
|
||||
expect((err as { kind: string }).kind).toBe('file_too_large');
|
||||
// Audit was recorded for the denial (P0 silent-failure fix).
|
||||
const denied = h.events.find((e) => e.type === FS_DENIED_EVENT_TYPE);
|
||||
expect(denied).toBeDefined();
|
||||
|
|
@ -222,6 +216,262 @@ describe('WorkspaceFileSystem - readText', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('serves oversized text for any explicit window argument, not just limit', async () => {
|
||||
// `maxBytes` and `line` bound the response just as much as `limit` does;
|
||||
// refusing them while admitting a deep `line` had the cost model backwards.
|
||||
const big = path.join(h.workspace, 'huge-window.txt');
|
||||
const maxReadBytes = (await import('./policy.js')).MAX_READ_BYTES;
|
||||
const line = `${'a'.repeat(99)}\n`;
|
||||
await fsp.writeFile(big, line.repeat(Math.ceil(maxReadBytes / 100) + 10));
|
||||
const r = await h.fs.resolve('huge-window.txt', 'read');
|
||||
|
||||
const capped = await h.fs.readText(r, { maxBytes: 1024 });
|
||||
expect(Buffer.byteLength(capped.content)).toBeLessThanOrEqual(1024);
|
||||
expect(capped.meta.truncated).toBe(true);
|
||||
expect(capped.meta.hasMore).toBe(true);
|
||||
expect(capped.meta.nextCursor).toBeUndefined();
|
||||
expect(capped.meta.hash).toBeUndefined();
|
||||
|
||||
const fromLine = await h.fs.readText(r, { line: 2 });
|
||||
expect(fromLine.content.startsWith('a'.repeat(99))).toBe(true);
|
||||
expect(Buffer.byteLength(fromLine.content)).toBeLessThanOrEqual(
|
||||
maxReadBytes,
|
||||
);
|
||||
expect(fromLine.meta.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a line offset beyond MAX_TEXT_SCAN_BYTES', async () => {
|
||||
const { MAX_TEXT_SCAN_BYTES } = await import('./policy.js');
|
||||
const big = path.join(h.workspace, 'deep-offset.txt');
|
||||
const line = `${'a'.repeat(99)}\n`;
|
||||
const lineCount = Math.ceil((MAX_TEXT_SCAN_BYTES / 100) * 1.5);
|
||||
await fsp.writeFile(big, line.repeat(lineCount));
|
||||
const r = await h.fs.resolve('deep-offset.txt', 'read');
|
||||
|
||||
// A shallow window on the same file is still cheap and still works.
|
||||
const head = await h.fs.readText(r, { limit: 2 });
|
||||
expect(head.content.split('\n')).toHaveLength(2);
|
||||
|
||||
// The deep one is refused rather than silently costing a full scan.
|
||||
const err = await h.fs
|
||||
.readText(r, { line: lineCount - 5, limit: 2 })
|
||||
.catch((e: unknown) => e);
|
||||
expect(isFsError(err)).toBe(true);
|
||||
expect((err as { kind: string }).kind).toBe('file_too_large');
|
||||
expect((err as { hint?: string }).hint).toMatch(/readBytes/);
|
||||
});
|
||||
|
||||
it('pages a large log by cursor and reassembles it exactly', async () => {
|
||||
const target = path.join(h.workspace, 'cursor-page.log');
|
||||
const lines = Array.from(
|
||||
{ length: 6_000 },
|
||||
(_, index) => `row-${index + 1} ${'x'.repeat(60)}`,
|
||||
);
|
||||
const body = lines.join('\n');
|
||||
const maxReadBytes = (await import('./policy.js')).MAX_READ_BYTES;
|
||||
expect(Buffer.byteLength(body)).toBeGreaterThan(maxReadBytes);
|
||||
await fsp.writeFile(target, body);
|
||||
const r = await h.fs.resolve('cursor-page.log', 'read');
|
||||
|
||||
const pages: string[] = [];
|
||||
let out = await h.fs.readText(r, { limit: 500 });
|
||||
pages.push(out.content);
|
||||
expect(out.meta.hasMore).toBe(true);
|
||||
expect(out.meta.nextCursor).toBeDefined();
|
||||
|
||||
let guard = 0;
|
||||
while (out.meta.nextCursor !== undefined) {
|
||||
if (guard++ > 100) throw new Error('paging did not terminate');
|
||||
out = await h.fs.readText(r, {
|
||||
cursor: out.meta.nextCursor,
|
||||
limit: 500,
|
||||
});
|
||||
pages.push(out.content);
|
||||
}
|
||||
expect(out.meta.hasMore).toBe(false);
|
||||
expect(pages.join('\n')).toBe(body);
|
||||
});
|
||||
|
||||
it('serves a cursor read of a file below MAX_READ_BYTES', async () => {
|
||||
// The dispatch must branch on `cursor` before the size check; otherwise a
|
||||
// small file lands on the snapshot path and silently returns line 0.
|
||||
const target = path.join(h.workspace, 'small-cursor.txt');
|
||||
await fsp.writeFile(target, 'one\ntwo\nthree\nfour\n');
|
||||
const r = await h.fs.resolve('small-cursor.txt', 'read');
|
||||
|
||||
const first = await h.fs.readText(r, { limit: 2 });
|
||||
expect(first.content).toBe('one\ntwo');
|
||||
expect(first.meta.nextCursor).toBeDefined();
|
||||
|
||||
const second = await h.fs.readText(r, {
|
||||
cursor: first.meta.nextCursor!,
|
||||
limit: 2,
|
||||
});
|
||||
expect(second.content).toBe('three\nfour');
|
||||
expect(second.meta.hasMore).toBe(false);
|
||||
expect(second.meta.nextCursor).toBeUndefined();
|
||||
|
||||
const completeSnapshot = await h.fs.readText(r, { limit: 4 });
|
||||
expect(completeSnapshot.content).toBe('one\ntwo\nthree\nfour');
|
||||
expect(completeSnapshot.meta.hasMore).toBe(false);
|
||||
expect(completeSnapshot.meta.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports remaining content when a cursor page truncates its final line', async () => {
|
||||
const target = path.join(h.workspace, 'cursor-long-final-line.txt');
|
||||
await fsp.writeFile(target, 'x'.repeat(5_000));
|
||||
const stats = await fsp.stat(target);
|
||||
const r = await h.fs.resolve('cursor-long-final-line.txt', 'read');
|
||||
|
||||
const page = await h.fs.readText(r, {
|
||||
cursor: encodeTextCursor({
|
||||
off: 0,
|
||||
size: stats.size,
|
||||
dev: String(stats.dev),
|
||||
ino: String(stats.ino),
|
||||
}),
|
||||
maxBytes: 100,
|
||||
});
|
||||
expect(page.content).toBe('x'.repeat(100));
|
||||
expect(page.meta.hasMore).toBe(true);
|
||||
expect(page.meta.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps an outstanding cursor valid across an append', async () => {
|
||||
const target = path.join(h.workspace, 'cursor-append.log');
|
||||
await fsp.writeFile(target, 'a\nb\nc\nd\n');
|
||||
const r = await h.fs.resolve('cursor-append.log', 'read');
|
||||
|
||||
const first = await h.fs.readText(r, { limit: 2 });
|
||||
await fsp.appendFile(target, 'e\nf\n');
|
||||
|
||||
const second = await h.fs.readText(r, {
|
||||
cursor: first.meta.nextCursor!,
|
||||
limit: 2,
|
||||
});
|
||||
expect(second.content).toBe('c\nd');
|
||||
});
|
||||
|
||||
it('rejects a cursor after the file is replaced or truncated', async () => {
|
||||
const target = path.join(h.workspace, 'cursor-stale.log');
|
||||
await fsp.writeFile(target, 'a\nb\nc\nd\n');
|
||||
const r = await h.fs.resolve('cursor-stale.log', 'read');
|
||||
const first = await h.fs.readText(r, { limit: 2 });
|
||||
|
||||
// Replace via write-new + rename so the inode genuinely changes.
|
||||
const replacement = path.join(h.workspace, 'cursor-stale.new');
|
||||
await fsp.writeFile(replacement, 'z\ny\nx\nw\n');
|
||||
await fsp.rename(replacement, target);
|
||||
|
||||
const err = await h.fs
|
||||
.readText(r, { cursor: first.meta.nextCursor! })
|
||||
.catch((e: unknown) => e);
|
||||
expect(isFsError(err)).toBe(true);
|
||||
expect((err as { kind: string }).kind).toBe('hash_mismatch');
|
||||
|
||||
// And a shrink on a stable inode is rejected too.
|
||||
await fsp.writeFile(target, 'a\nb\nc\nd\n');
|
||||
const fresh = await h.fs.readText(r, { limit: 2 });
|
||||
await fsp.truncate(target, 2);
|
||||
const shrunk = await h.fs
|
||||
.readText(r, { cursor: fresh.meta.nextCursor! })
|
||||
.catch((e: unknown) => e);
|
||||
expect(isFsError(shrunk)).toBe(true);
|
||||
expect((shrunk as { kind: string }).kind).toBe('hash_mismatch');
|
||||
});
|
||||
|
||||
it('rejects malformed cursors and cursor+line together', async () => {
|
||||
const target = path.join(h.workspace, 'cursor-bad.txt');
|
||||
await fsp.writeFile(target, 'a\nb\n');
|
||||
const r = await h.fs.resolve('cursor-bad.txt', 'read');
|
||||
|
||||
for (const cursor of ['', 'not-base64url!!', 'x'.repeat(2_000)]) {
|
||||
const err = await h.fs.readText(r, { cursor }).catch((e: unknown) => e);
|
||||
expect(isFsError(err)).toBe(true);
|
||||
expect((err as { kind: string }).kind).toBe('parse_error');
|
||||
}
|
||||
|
||||
const good = await h.fs.readText(r, { limit: 1 });
|
||||
const conflict = await h.fs
|
||||
.readText(r, { cursor: good.meta.nextCursor!, line: 2 })
|
||||
.catch((e: unknown) => e);
|
||||
expect(isFsError(conflict)).toBe(true);
|
||||
expect((conflict as { kind: string }).kind).toBe('parse_error');
|
||||
});
|
||||
|
||||
it('maps a cursor that points inside a line to parse_error', async () => {
|
||||
const target = path.join(h.workspace, 'cursor-mid-line.txt');
|
||||
await fsp.writeFile(target, 'alpha');
|
||||
const stats = await fsp.stat(target);
|
||||
const r = await h.fs.resolve('cursor-mid-line.txt', 'read');
|
||||
|
||||
const err = await h.fs
|
||||
.readText(r, {
|
||||
cursor: encodeTextCursor({
|
||||
off: 1,
|
||||
size: stats.size,
|
||||
dev: String(stats.dev),
|
||||
ino: String(stats.ino),
|
||||
}),
|
||||
})
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(isFsError(err)).toBe(true);
|
||||
expect((err as { kind: string }).kind).toBe('parse_error');
|
||||
});
|
||||
|
||||
it('refuses a cursor read of oversized non-UTF-8 text', async () => {
|
||||
const target = path.join(h.workspace, 'cursor-utf16.txt');
|
||||
const body = Buffer.concat([
|
||||
Buffer.from([0xff, 0xfe]),
|
||||
Buffer.from('中文日志行\n'.repeat(30_000), 'utf16le'),
|
||||
]);
|
||||
await fsp.writeFile(target, body);
|
||||
const r = await h.fs.resolve('cursor-utf16.txt', 'read');
|
||||
|
||||
const err = await h.fs
|
||||
.readText(r, {
|
||||
cursor: encodeTextCursor({
|
||||
off: 0,
|
||||
size: body.length,
|
||||
dev: '0',
|
||||
ino: '0',
|
||||
}),
|
||||
})
|
||||
.catch((e: unknown) => e);
|
||||
expect(isFsError(err)).toBe(true);
|
||||
// dev/ino are placeholders, so the staleness gate fires before decoding.
|
||||
expect((err as { kind: string }).kind).toBe('hash_mismatch');
|
||||
});
|
||||
|
||||
it('maps a cursor read of oversized non-UTF-8 text to binary_file', async () => {
|
||||
const target = path.join(h.workspace, 'cursor-utf16-real.txt');
|
||||
const body = Buffer.concat([
|
||||
Buffer.from([0xff, 0xfe]),
|
||||
Buffer.from('中文日志行\n'.repeat(30_000), 'utf16le'),
|
||||
]);
|
||||
await fsp.writeFile(target, body);
|
||||
const stats = await fsp.stat(target);
|
||||
const r = await h.fs.resolve('cursor-utf16-real.txt', 'read');
|
||||
|
||||
const err = await h.fs
|
||||
.readText(r, {
|
||||
cursor: encodeTextCursor({
|
||||
off: 0,
|
||||
size: stats.size,
|
||||
dev: String(stats.dev),
|
||||
ino: String(stats.ino),
|
||||
}),
|
||||
})
|
||||
.catch((e: unknown) => e);
|
||||
expect(isFsError(err)).toBe(true);
|
||||
// Real dev/ino clear the staleness gate, so decoding starts and the
|
||||
// non-UTF-8 content is reclassified — not `file_too_large`, which a
|
||||
// client would retry forever on.
|
||||
expect((err as { kind: string }).kind).toBe('binary_file');
|
||||
expect((err as { hint?: string }).hint).toMatch(/convert.*UTF-8/i);
|
||||
});
|
||||
|
||||
it('streams bounded line windows from text above MAX_READ_BYTES', async () => {
|
||||
const target = path.join(h.workspace, 'large-window.txt');
|
||||
const lines = Array.from(
|
||||
|
|
@ -271,7 +521,7 @@ describe('WorkspaceFileSystem - readText', () => {
|
|||
expect((err as { kind: string }).kind).toBe('binary_file');
|
||||
});
|
||||
|
||||
it('maps oversized non-UTF-8 text windows to file_too_large', async () => {
|
||||
it('maps oversized non-UTF-8 text windows to binary_file', async () => {
|
||||
const target = path.join(h.workspace, 'large-utf16.txt');
|
||||
const body = Buffer.concat([
|
||||
Buffer.from([0xff, 0xfe]),
|
||||
|
|
@ -284,7 +534,10 @@ describe('WorkspaceFileSystem - readText', () => {
|
|||
|
||||
const err = await h.fs.readText(r, { limit: 20 }).catch((e: unknown) => e);
|
||||
expect(isFsError(err)).toBe(true);
|
||||
expect((err as { kind: string }).kind).toBe('file_too_large');
|
||||
// Not `file_too_large`: shrinking the window can never make a GBK file
|
||||
// decodable, so a client retrying on 413 would loop forever. 422 with the
|
||||
// readBytes hint is the same remedy that already works for binary.
|
||||
expect((err as { kind: string }).kind).toBe('binary_file');
|
||||
expect((err as { hint?: string }).hint).toMatch(/convert.*UTF-8/i);
|
||||
});
|
||||
|
||||
|
|
@ -377,7 +630,7 @@ describe('WorkspaceFileSystem - readText', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('rejects in-place changes while a large range is being read', async () => {
|
||||
it('rejects a truncation while a large range is being read', async () => {
|
||||
const target = path.join(h.workspace, 'large-change.txt');
|
||||
const lines = Array.from(
|
||||
{ length: 4_000 },
|
||||
|
|
@ -393,7 +646,7 @@ describe('WorkspaceFileSystem - readText', () => {
|
|||
params,
|
||||
) {
|
||||
const result = await original.call(this, params);
|
||||
await fsp.appendFile(target, '\nchanged');
|
||||
await fsp.truncate(target, 1_000);
|
||||
return result;
|
||||
});
|
||||
|
||||
|
|
@ -408,6 +661,40 @@ describe('WorkspaceFileSystem - readText', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('serves a prefix window from a file being appended to during the read', async () => {
|
||||
// The whole point of the feature: tailing a live log. A prefix window
|
||||
// does not depend on the tail, so an append must not fail the read.
|
||||
const target = path.join(h.workspace, 'large-append.txt');
|
||||
const lines = Array.from(
|
||||
{ length: 4_000 },
|
||||
(_, index) => `line-${index + 1} ${'x'.repeat(80)}`,
|
||||
);
|
||||
await fsp.writeFile(target, lines.join('\n'));
|
||||
const resolved = await h.fs.resolve('large-append.txt', 'read');
|
||||
const original = StandardFileSystemService.prototype.readTextFileFromHandle;
|
||||
const sizeBefore = (await fsp.stat(target)).size;
|
||||
const readSpy = vi
|
||||
.spyOn(StandardFileSystemService.prototype, 'readTextFileFromHandle')
|
||||
.mockImplementation(async function (
|
||||
this: StandardFileSystemService,
|
||||
params,
|
||||
) {
|
||||
const result = await original.call(this, params);
|
||||
await fsp.appendFile(target, `\n${'appended '.repeat(50)}`);
|
||||
return result;
|
||||
});
|
||||
|
||||
try {
|
||||
const out = await h.fs.readText(resolved, { limit: 20 });
|
||||
expect(out.content).toBe(lines.slice(0, 20).join('\n'));
|
||||
// sizeBytes describes the snapshot the window was cut from, not the
|
||||
// file as it stands after the concurrent append.
|
||||
expect(out.meta.sizeBytes).toBe(sizeBefore);
|
||||
} finally {
|
||||
readSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects same-size in-place overwrites during a large range read', async () => {
|
||||
const target = path.join(h.workspace, 'large-overwrite.txt');
|
||||
const lines = Array.from(
|
||||
|
|
@ -447,10 +734,8 @@ describe('WorkspaceFileSystem - readText', () => {
|
|||
} finally {
|
||||
await writer.close();
|
||||
}
|
||||
// Restore mtime to prove ctime still detects a same-size overwrite
|
||||
// that size+mtime checks alone would accept. Pause first so the
|
||||
// change-time lands in a later timestamp quantum than the pre-read
|
||||
// snapshot even on coarse-resolution filesystems.
|
||||
// Restore mtime after ctime has advanced so the stability check
|
||||
// proves that ctime alone detects the same-size overwrite.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await fsp.utimes(target, before.atime, before.mtime);
|
||||
}
|
||||
|
|
@ -515,10 +800,8 @@ describe('WorkspaceFileSystem - readText', () => {
|
|||
} finally {
|
||||
await writer.close();
|
||||
}
|
||||
// Pause so the overwrite's change-time lands in a later timestamp
|
||||
// quantum than the pre-read snapshot even on coarse-resolution
|
||||
// filesystems; detection here relies on ctime since mtime is
|
||||
// restored.
|
||||
// Restore mtime after ctime has advanced so the stability check
|
||||
// proves that ctime alone detects the same-size overwrite.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await fsp.utimes(target, before.atime, before.mtime);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,14 @@ import { glob as globAsync } from 'glob';
|
|||
// don't repeat the regression.
|
||||
|
||||
import {
|
||||
CursorNotAtLineBoundaryError,
|
||||
LargeNonUtf8TextError,
|
||||
StandardFileSystemService,
|
||||
TextScanBudgetExceededError,
|
||||
decodeBufferWithEncodingInfoAsync,
|
||||
detectLineEnding,
|
||||
encodeTextFileContentAsync,
|
||||
isUtf8CompatibleEncoding,
|
||||
loadIgnoreRules,
|
||||
isWithinRoot,
|
||||
type Ignore,
|
||||
|
|
@ -36,6 +39,11 @@ import {
|
|||
createAuditPublisher,
|
||||
} from './audit.js';
|
||||
import { FsError, wrapAsFsError, type FsErrorKind } from './errors.js';
|
||||
import {
|
||||
assertCursorMatchesFile,
|
||||
decodeTextCursor,
|
||||
encodeTextCursor,
|
||||
} from './text-cursor.js';
|
||||
import {
|
||||
canonicalizeWorkspaces,
|
||||
resolveWithinWorkspace,
|
||||
|
|
@ -45,6 +53,7 @@ import {
|
|||
import {
|
||||
BINARY_PROBE_BYTES,
|
||||
MAX_READ_BYTES,
|
||||
MAX_TEXT_SCAN_BYTES,
|
||||
assertTrustedForIntent,
|
||||
enforceReadSize,
|
||||
enforceWriteSize,
|
||||
|
|
@ -83,11 +92,33 @@ export interface ReadMeta {
|
|||
truncated?: boolean;
|
||||
matchedIgnore?: 'file' | 'directory';
|
||||
originalLineCount?: number;
|
||||
/**
|
||||
* Resume token for the next page. Present only when content remains *and* a
|
||||
* file byte offset is derivable — a non-UTF-8 snapshot read has more to give
|
||||
* but cannot be paged by byte, which is why `hasMore` is a separate field
|
||||
* rather than a restatement of this one.
|
||||
*/
|
||||
nextCursor?: string;
|
||||
/** Whether content remains beyond what was returned, for any reason. */
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Above `MAX_READ_BYTES` at least one of these must be set. Any of them is
|
||||
* the caller stating it accepts partial content, which is all the streamed
|
||||
* path returns; with none of them the read is refused rather than silently
|
||||
* handing back a truncated "whole file". Which one is set does not affect
|
||||
* cost — that is bounded by `MAX_TEXT_SCAN_BYTES`.
|
||||
*/
|
||||
export interface ReadTextOptions {
|
||||
/** Returned-byte cap in [1, MAX_READ_BYTES]; defaults to MAX_READ_BYTES. */
|
||||
maxBytes?: number;
|
||||
/**
|
||||
* Opaque resume token from a previous read's `meta.nextCursor`. Mutually
|
||||
* exclusive with `line` — both name a starting point. Reaches any offset in
|
||||
* O(1), where `line` must scan from byte 0.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
* 1-based starting line for partial reads. `1` returns the file
|
||||
* from its first line. The boundary converts to the 0-based slice
|
||||
|
|
@ -473,6 +504,14 @@ class WorkspaceFileSystemImpl implements WorkspaceFileSystem {
|
|||
`limit must be a positive integer, got ${opts.limit}`,
|
||||
);
|
||||
}
|
||||
// Both name a starting point; honouring one and ignoring the other
|
||||
// would silently return the wrong window.
|
||||
if (opts.cursor !== undefined && opts.line !== undefined) {
|
||||
throw new FsError(
|
||||
'parse_error',
|
||||
'cursor and line are mutually exclusive; a cursor already encodes where to resume',
|
||||
);
|
||||
}
|
||||
if (
|
||||
opts.maxBytes !== undefined &&
|
||||
(!Number.isSafeInteger(opts.maxBytes) ||
|
||||
|
|
@ -1375,13 +1414,34 @@ async function readTextFromResolvedFile(
|
|||
throw new FsError('parse_error', `path is not a regular file: ${p}`);
|
||||
}
|
||||
|
||||
if (pre.size > MAX_READ_BYTES && opts.limit !== undefined) {
|
||||
return readLargeTextWindowFromResolvedFile(
|
||||
p,
|
||||
pre,
|
||||
{ ...opts, limit: opts.limit },
|
||||
lowFs,
|
||||
);
|
||||
// Any explicit window argument is the caller stating it accepts partial
|
||||
// content, which is what the large-file path returns. Gating on `limit`
|
||||
// alone got this backwards in both directions: `{ line: 900_000_000,
|
||||
// limit: 20 }` was admitted despite costing a full scan, while
|
||||
// `{ maxBytes: 4096 }` — satisfiable from the first 4 KiB — was refused.
|
||||
// Cost is bounded by MAX_TEXT_SCAN_BYTES, not by which knob was set.
|
||||
//
|
||||
// A read with no window argument at all still fails: an agent that
|
||||
// believes it holds the whole file may write it back truncated. The
|
||||
// omitted `hash` blocks that for `editText`/`writeTextAtomic`, but
|
||||
// `writeTextOverwrite` takes no hash, so `truncated: true` is the only
|
||||
// signal on that path — refusing the unbounded read keeps the caller
|
||||
// from ever being in that position by accident.
|
||||
// Cursor reads branch before the size check, not by widening `wantsWindow`.
|
||||
// Adding `cursor` there would fix only large files: a cursor read of a file
|
||||
// *under* MAX_READ_BYTES would still land on the snapshot path, which knows
|
||||
// only `line`/`limit` and would silently ignore the cursor and return from
|
||||
// line 0 — a wrong answer, worse than the refusal the large case would give.
|
||||
if (opts.cursor !== undefined) {
|
||||
return readTextCursorWindowFromResolvedFile(p, pre, opts, lowFs);
|
||||
}
|
||||
|
||||
const wantsWindow =
|
||||
opts.limit !== undefined ||
|
||||
opts.maxBytes !== undefined ||
|
||||
opts.line !== undefined;
|
||||
if (pre.size > MAX_READ_BYTES && wantsWindow) {
|
||||
return readLargeTextWindowFromResolvedFile(p, pre, opts, lowFs);
|
||||
}
|
||||
return readTextSnapshotFromResolvedFile(p, opts, pre);
|
||||
}
|
||||
|
|
@ -1430,6 +1490,7 @@ async function readTextSnapshotFromResolvedFile(
|
|||
const maxOutputBytes = opts.maxBytes ?? MAX_READ_BYTES;
|
||||
const sizeOutcome = enforceReadSize(raw.length, maxOutputBytes);
|
||||
let content = sliced.content;
|
||||
let byteTruncated = false;
|
||||
const meta: TextSnapshot['meta'] = {
|
||||
encoding: decoded.encoding,
|
||||
bom: decoded.bom,
|
||||
|
|
@ -1444,6 +1505,7 @@ async function readTextSnapshotFromResolvedFile(
|
|||
content = safeUtf8Truncate(output, maxOutputBytes).toString('utf-8');
|
||||
meta.lineEnding = detectLineEnding(content);
|
||||
meta.truncated = true;
|
||||
byteTruncated = true;
|
||||
}
|
||||
if (sizeOutcome.truncated) {
|
||||
meta.truncated = true;
|
||||
|
|
@ -1457,30 +1519,231 @@ async function readTextSnapshotFromResolvedFile(
|
|||
meta.truncated = true;
|
||||
}
|
||||
|
||||
const pageableLineCount =
|
||||
sliced.originalLineCount - (decoded.content.endsWith('\n') ? 1 : 0);
|
||||
meta.hasMore = byteTruncated || sliced.endLine < pageableLineCount;
|
||||
// A byte offset into the file is only derivable when the decoded text and
|
||||
// the file agree byte-for-byte. For GBK, Shift_JIS, or UTF-16 the decoded
|
||||
// string is a UTF-8 re-encoding whose lengths are unrelated to the file's,
|
||||
// so a cursor built from it would point at the wrong byte. Such a read still
|
||||
// reports `hasMore` honestly — it has more to give, it just cannot be paged.
|
||||
// A byte-truncated slice ends mid-line, so there is no line start to resume
|
||||
// from; `hasMore` still says content remains. Every cursor this boundary
|
||||
// mints points at a line start, so a client following cursors never skips
|
||||
// the tail of a line it was only shown part of.
|
||||
const bomBytes = decoded.bom ? 3 : 0;
|
||||
const decodedBytesMatchSource =
|
||||
isUtf8CompatibleEncoding(decoded.encoding) &&
|
||||
Buffer.from(decoded.content, 'utf-8').equals(raw.subarray(bomBytes));
|
||||
if (meta.hasMore && !byteTruncated && decodedBytesMatchSource) {
|
||||
// `decodeBufferWithEncodingInfoAsync` strips the BOM, so decoded offsets
|
||||
// run short by its length. A BOM on a byte-compatible encoding is UTF-8,
|
||||
// whose marker is three bytes.
|
||||
const startByte = bomBytes + sliced.startByteOffset;
|
||||
const contentBytes = Buffer.byteLength(content, 'utf-8');
|
||||
// Whole lines consumed their terminator; a byte-truncated slice stopped
|
||||
// mid-line and resumes at exactly what was returned.
|
||||
const nextOffset = startByte + contentBytes + 1;
|
||||
if (nextOffset < raw.length) {
|
||||
meta.nextCursor = encodeTextCursor({
|
||||
off: nextOffset,
|
||||
size: raw.length,
|
||||
dev: String(pre.dev),
|
||||
ino: String(pre.ino),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { content, meta };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stability check for a streamed *prefix* window.
|
||||
*
|
||||
* The full-snapshot path can demand byte-for-byte stability (`size` and
|
||||
* `mtimeMs` unchanged) because it returns the whole file: any change
|
||||
* invalidates the result. A line window does not return the whole file, so
|
||||
* demanding whole-file stability rejects reads whose returned bytes are
|
||||
* still perfectly valid — and the case it rejects is the one this feature
|
||||
* exists for. Appending to a log does not change lines 1-20, but under an
|
||||
* equality check every read of a live log is a coin flip.
|
||||
*
|
||||
* So the streamed path accepts growth, but rejects shrinkage and same-size
|
||||
* version changes. The latter preserves the stable-read protection against
|
||||
* in-place overwrites while still allowing append-only logs.
|
||||
*
|
||||
* The residual gap is a writer that changes existing bytes and grows past the
|
||||
* original size inside one read window while keeping the same inode. Metadata
|
||||
* cannot distinguish that from a pure append; hashing the prefix would make
|
||||
* every page O(n), defeating the cursor.
|
||||
*/
|
||||
function assertStreamWindowStable(
|
||||
before: {
|
||||
size: number | bigint;
|
||||
mtimeMs: number | bigint;
|
||||
ctimeMs: number | bigint;
|
||||
},
|
||||
after: {
|
||||
size: number | bigint;
|
||||
mtimeMs: number | bigint;
|
||||
ctimeMs: number | bigint;
|
||||
},
|
||||
p: ResolvedPath,
|
||||
reason: string,
|
||||
): void {
|
||||
const beforeSize = toBigInt(before.size);
|
||||
const afterSize = toBigInt(after.size);
|
||||
if (
|
||||
afterSize < beforeSize ||
|
||||
(afterSize === beforeSize &&
|
||||
(after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs))
|
||||
) {
|
||||
throw new FsError('hash_mismatch', `${reason}: ${p}`, {
|
||||
hint: 'retry after re-reading the latest file',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte-cursor page. Reaches any offset in O(1), so `MAX_TEXT_SCAN_BYTES` does
|
||||
* not apply here — that budget exists only because line offsets must be
|
||||
* resolved by scanning.
|
||||
*
|
||||
* The fd-bound TOCTOU discipline is lifted verbatim from
|
||||
* `readLargeTextWindowFromResolvedFile`. It is deliberately *not* copied from
|
||||
* `readBytesWindow`, which sits next door and looks like the closer model but
|
||||
* still demands `size`/`mtimeMs` equality after the read — the check `e784e6d`
|
||||
* relaxed precisely because it fails every page of an actively-written log.
|
||||
*/
|
||||
async function readTextCursorWindowFromResolvedFile(
|
||||
p: ResolvedPath,
|
||||
pre: Awaited<ReturnType<typeof fsp.lstat>>,
|
||||
opts: ReadTextOptions,
|
||||
lowFs: StandardFileSystemService,
|
||||
): Promise<TextReadOutcome> {
|
||||
const cursor = decodeTextCursor(opts.cursor as string);
|
||||
const fh = await fsp.open(p as string, 'r');
|
||||
let opened: Awaited<ReturnType<typeof fh.stat>> | undefined;
|
||||
let afterRead: Awaited<ReturnType<typeof fh.stat>> | undefined;
|
||||
let window:
|
||||
| Awaited<ReturnType<StandardFileSystemService['readTextCursorFromHandle']>>
|
||||
| undefined;
|
||||
let primaryError: unknown;
|
||||
let hasPrimaryError = false;
|
||||
try {
|
||||
opened = await fh.stat();
|
||||
assertSameFile(pre, opened, p as string, 'read');
|
||||
assertStreamWindowStable(pre, opened, p, 'file changed before read');
|
||||
assertCursorMatchesFile(cursor, opened, p as string);
|
||||
|
||||
try {
|
||||
const probe = Buffer.alloc(Math.min(BINARY_PROBE_BYTES, opened.size));
|
||||
if (probe.length > 0) {
|
||||
const { bytesRead } = await fh.read(probe, 0, probe.length, 0);
|
||||
if (looksBinary(probe.subarray(0, bytesRead))) {
|
||||
throw new FsError('binary_file', `binary file: ${p}`, {
|
||||
hint: 'use readBytes for binary content',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window = await lowFs.readTextCursorFromHandle({
|
||||
fileHandle: fh,
|
||||
startOffset: cursor.off,
|
||||
fileSize: opened.size,
|
||||
maxOutputBytes: opts.maxBytes ?? MAX_READ_BYTES,
|
||||
maxSnapBytes: MAX_TEXT_SCAN_BYTES,
|
||||
...(opts.limit !== undefined ? { limit: opts.limit } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
hasPrimaryError = true;
|
||||
primaryError = err;
|
||||
}
|
||||
|
||||
afterRead = await fh.stat();
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
|
||||
if (opened === undefined || afterRead === undefined) {
|
||||
throw new FsError('internal_error', `failed to stat opened file: ${p}`);
|
||||
}
|
||||
const post = await fsp.lstat(p as string);
|
||||
if (post.isSymbolicLink()) {
|
||||
throw new FsError(
|
||||
'symlink_escape',
|
||||
`path was replaced with a symlink during read: ${p}`,
|
||||
{ hint: 'TOCTOU swap detected via post-read lstat' },
|
||||
);
|
||||
}
|
||||
assertSameFile(opened, afterRead, p as string, 'read');
|
||||
assertStreamWindowStable(opened, afterRead, p, 'file changed during read');
|
||||
assertSameFile(opened, post, p as string, 'read');
|
||||
assertStreamWindowStable(opened, post, p, 'file changed during read');
|
||||
|
||||
if (hasPrimaryError) {
|
||||
if (primaryError instanceof LargeNonUtf8TextError) {
|
||||
throw new FsError('binary_file', primaryError.message, {
|
||||
cause: primaryError,
|
||||
hint: 'convert the file to UTF-8, or use readBytes for the raw bytes',
|
||||
});
|
||||
}
|
||||
// The offset is malformed, not the file oversized — a cursor this daemon
|
||||
// issued always lands on a line start.
|
||||
if (primaryError instanceof CursorNotAtLineBoundaryError) {
|
||||
throw new FsError('parse_error', primaryError.message, {
|
||||
cause: primaryError,
|
||||
hint: 'pass a cursor returned by a previous read',
|
||||
});
|
||||
}
|
||||
throw primaryError;
|
||||
}
|
||||
if (window === undefined) {
|
||||
throw new FsError(
|
||||
'internal_error',
|
||||
`cursor text read returned no result: ${p}`,
|
||||
);
|
||||
}
|
||||
|
||||
const meta: TextReadOutcome['meta'] = {
|
||||
encoding: window.encoding,
|
||||
bom: window.bom,
|
||||
lineEnding: window.lineEnding,
|
||||
sizeBytes: opened.size,
|
||||
truncated: true,
|
||||
hasMore:
|
||||
window.nextOffset !== undefined || window.truncatedByBytes === true,
|
||||
};
|
||||
if (window.nextOffset !== undefined) {
|
||||
meta.nextCursor = encodeTextCursor({
|
||||
off: window.nextOffset,
|
||||
size: opened.size,
|
||||
dev: String(opened.dev),
|
||||
ino: String(opened.ino),
|
||||
});
|
||||
}
|
||||
return { content: window.content, meta };
|
||||
}
|
||||
|
||||
async function readLargeTextWindowFromResolvedFile(
|
||||
p: ResolvedPath,
|
||||
pre: Awaited<ReturnType<typeof fsp.lstat>>,
|
||||
opts: ReadTextOptions & { limit: number },
|
||||
opts: ReadTextOptions,
|
||||
lowFs: StandardFileSystemService,
|
||||
): Promise<TextReadOutcome> {
|
||||
const fh = await fsp.open(p as string, 'r');
|
||||
let opened: Awaited<ReturnType<typeof fh.stat>> | undefined;
|
||||
let afterRead: Awaited<ReturnType<typeof fh.stat>> | undefined;
|
||||
let result:
|
||||
| Awaited<ReturnType<StandardFileSystemService['readTextFileFromHandle']>>
|
||||
| undefined;
|
||||
let primaryError: unknown;
|
||||
let hasPrimaryError = false;
|
||||
try {
|
||||
const opened = await fh.stat();
|
||||
opened = await fh.stat();
|
||||
assertSameFile(pre, opened, p as string, 'read');
|
||||
if (didFileVersionChange(pre, opened)) {
|
||||
throw new FsError('hash_mismatch', `file changed before read: ${p}`, {
|
||||
hint: 'retry after re-reading the latest file',
|
||||
});
|
||||
}
|
||||
assertStreamWindowStable(pre, opened, p, 'file changed before read');
|
||||
|
||||
let result:
|
||||
| Awaited<ReturnType<StandardFileSystemService['readTextFileFromHandle']>>
|
||||
| undefined;
|
||||
let primaryError: unknown;
|
||||
let hasPrimaryError = false;
|
||||
try {
|
||||
const probe = Buffer.alloc(Math.min(BINARY_PROBE_BYTES, opened.size));
|
||||
if (probe.length > 0) {
|
||||
|
|
@ -1493,91 +1756,94 @@ async function readLargeTextWindowFromResolvedFile(
|
|||
}
|
||||
|
||||
result = await lowFs.readTextFileFromHandle({
|
||||
path: p as string,
|
||||
fileHandle: fh,
|
||||
stats: opened,
|
||||
limit: opts.limit,
|
||||
fileSize: opened.size,
|
||||
limit: opts.limit ?? Number.POSITIVE_INFINITY,
|
||||
line: opts.line !== undefined ? opts.line - 1 : 0,
|
||||
maxOutputBytes: opts.maxBytes ?? MAX_READ_BYTES,
|
||||
maxScanBytes: MAX_TEXT_SCAN_BYTES,
|
||||
});
|
||||
} catch (err) {
|
||||
hasPrimaryError = true;
|
||||
primaryError = err;
|
||||
}
|
||||
|
||||
const afterRead = await fh.stat();
|
||||
const post = await fsp.lstat(p as string);
|
||||
if (post.isSymbolicLink()) {
|
||||
throw new FsError(
|
||||
'symlink_escape',
|
||||
`path was replaced with a symlink during read: ${p}`,
|
||||
{ hint: 'TOCTOU swap detected via post-read lstat' },
|
||||
);
|
||||
}
|
||||
assertSameFile(opened, afterRead, p as string, 'read');
|
||||
assertSameFile(opened, post, p as string, 'read');
|
||||
if (
|
||||
didFileVersionChange(opened, afterRead) ||
|
||||
didFileVersionChange(opened, post)
|
||||
) {
|
||||
throw new FsError('hash_mismatch', `file changed during read: ${p}`, {
|
||||
hint: 'retry after re-reading the latest file',
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPrimaryError) {
|
||||
if (primaryError instanceof LargeNonUtf8TextError) {
|
||||
throw new FsError('file_too_large', primaryError.message, {
|
||||
cause: primaryError,
|
||||
hint: 'convert the file to UTF-8 before requesting a large line window',
|
||||
});
|
||||
}
|
||||
throw primaryError;
|
||||
}
|
||||
|
||||
if (result === undefined) {
|
||||
throw new FsError(
|
||||
'internal_error',
|
||||
`large text range read returned no result: ${p}`,
|
||||
);
|
||||
}
|
||||
|
||||
const meta: TextReadOutcome['meta'] = {
|
||||
encoding: result._meta?.encoding,
|
||||
bom: result._meta?.bom,
|
||||
lineEnding: detectLineEnding(result.content),
|
||||
sizeBytes: opened.size,
|
||||
truncated: true,
|
||||
};
|
||||
if (
|
||||
result._meta?.originalLineCountExact === true &&
|
||||
result._meta.originalLineCount !== undefined
|
||||
) {
|
||||
meta.originalLineCount = result._meta.originalLineCount;
|
||||
}
|
||||
return { content: result.content, meta };
|
||||
afterRead = await fh.stat();
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
function didFileVersionChange(
|
||||
before: {
|
||||
size: number | bigint;
|
||||
mtimeMs: number | bigint;
|
||||
ctimeMs: number | bigint;
|
||||
},
|
||||
after: {
|
||||
size: number | bigint;
|
||||
mtimeMs: number | bigint;
|
||||
ctimeMs: number | bigint;
|
||||
},
|
||||
): boolean {
|
||||
return (
|
||||
after.size !== before.size ||
|
||||
after.mtimeMs !== before.mtimeMs ||
|
||||
after.ctimeMs !== before.ctimeMs
|
||||
);
|
||||
if (opened === undefined || afterRead === undefined) {
|
||||
throw new FsError('internal_error', `failed to stat opened file: ${p}`);
|
||||
}
|
||||
const post = await fsp.lstat(p as string);
|
||||
if (post.isSymbolicLink()) {
|
||||
throw new FsError(
|
||||
'symlink_escape',
|
||||
`path was replaced with a symlink during read: ${p}`,
|
||||
{ hint: 'TOCTOU swap detected via post-read lstat' },
|
||||
);
|
||||
}
|
||||
assertSameFile(opened, afterRead, p as string, 'read');
|
||||
assertStreamWindowStable(opened, afterRead, p, 'file changed during read');
|
||||
assertSameFile(opened, post, p as string, 'read');
|
||||
assertStreamWindowStable(opened, post, p, 'file changed during read');
|
||||
|
||||
if (hasPrimaryError) {
|
||||
// An encoding the text route can't represent is the same class of refusal
|
||||
// as sniffed-binary content, and `binary_file` already tells clients to
|
||||
// fall back to `readBytes`.
|
||||
if (primaryError instanceof LargeNonUtf8TextError) {
|
||||
throw new FsError('binary_file', primaryError.message, {
|
||||
cause: primaryError,
|
||||
hint: 'convert the file to UTF-8, or use readBytes for the raw bytes',
|
||||
});
|
||||
}
|
||||
if (primaryError instanceof TextScanBudgetExceededError) {
|
||||
throw new FsError('file_too_large', primaryError.message, {
|
||||
cause: primaryError,
|
||||
hint: `line offsets are resolved by scanning from byte 0 and stop after ${MAX_TEXT_SCAN_BYTES} bytes; page with the cursor from a shallower read to reach this offset in O(1), or use readBytes for raw bytes`,
|
||||
});
|
||||
}
|
||||
throw primaryError;
|
||||
}
|
||||
if (result === undefined) {
|
||||
throw new FsError(
|
||||
'internal_error',
|
||||
`large text range read returned no result: ${p}`,
|
||||
);
|
||||
}
|
||||
const content = result.content;
|
||||
const readMeta = result._meta;
|
||||
|
||||
const meta: TextReadOutcome['meta'] = {
|
||||
encoding: readMeta?.encoding,
|
||||
bom: readMeta?.bom,
|
||||
lineEnding: readMeta?.lineEnding ?? detectLineEnding(content),
|
||||
// Size as of `open`, not as of now: it describes the snapshot the
|
||||
// returned window was cut from. A file that grew during the read
|
||||
// reports the smaller, consistent number.
|
||||
sizeBytes: opened.size,
|
||||
truncated: true,
|
||||
hasMore:
|
||||
readMeta?.nextByteOffset !== undefined ||
|
||||
readMeta?.truncatedByBytes === true,
|
||||
};
|
||||
if (readMeta?.nextByteOffset !== undefined) {
|
||||
meta.nextCursor = encodeTextCursor({
|
||||
off: readMeta.nextByteOffset,
|
||||
size: opened.size,
|
||||
dev: String(opened.dev),
|
||||
ino: String(opened.ino),
|
||||
});
|
||||
}
|
||||
if (
|
||||
readMeta?.originalLineCountExact === true &&
|
||||
readMeta?.originalLineCount !== undefined
|
||||
) {
|
||||
meta.originalLineCount = readMeta.originalLineCount;
|
||||
}
|
||||
return { content, meta };
|
||||
}
|
||||
|
||||
async function readStableRegularFileBuffer(
|
||||
|
|
@ -1632,14 +1898,27 @@ function sliceDecodedText(
|
|||
content: string,
|
||||
startLine: number,
|
||||
limit: number,
|
||||
): { content: string; originalLineCount: number } {
|
||||
): {
|
||||
content: string;
|
||||
originalLineCount: number;
|
||||
/** Byte offset of `startLine` within the decoded text (BOM excluded). */
|
||||
startByteOffset: number;
|
||||
/** Index just past the last returned line. */
|
||||
endLine: number;
|
||||
} {
|
||||
const lines = content.split('\n');
|
||||
const originalLineCount = lines.length;
|
||||
const endLine = Math.min(startLine + limit, originalLineCount);
|
||||
const actualStartLine = Math.min(startLine, originalLineCount);
|
||||
let startByteOffset = 0;
|
||||
for (let i = 0; i < actualStartLine; i++) {
|
||||
startByteOffset += Buffer.byteLength(lines[i]!, 'utf-8') + 1;
|
||||
}
|
||||
return {
|
||||
content: lines.slice(actualStartLine, endLine).join('\n'),
|
||||
originalLineCount,
|
||||
startByteOffset,
|
||||
endLine,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ interface FakeBridge extends AcpSessionBridge {
|
|||
context?: BridgeClientRequestContext;
|
||||
}>;
|
||||
readonly primaryOnlyMutationCalls: Array<{
|
||||
route: 'branch' | 'fork' | 'cd';
|
||||
route: 'branch' | 'side-task' | 'fork' | 'cd';
|
||||
sessionId: string;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -622,6 +622,10 @@ function makeBridge(
|
|||
primaryOnlyMutationCalls.push({ route: 'branch', sessionId });
|
||||
throw new Error('Unexpected branchSession call');
|
||||
},
|
||||
async createSideTaskSession(sessionId: string) {
|
||||
primaryOnlyMutationCalls.push({ route: 'side-task', sessionId });
|
||||
throw new Error('Unexpected createSideTaskSession call');
|
||||
},
|
||||
async launchSessionForkAgent(sessionId: string) {
|
||||
primaryOnlyMutationCalls.push({ route: 'fork', sessionId });
|
||||
throw new Error('Unexpected launchSessionForkAgent call');
|
||||
|
|
@ -699,9 +703,12 @@ function makeRuntime(input: {
|
|||
primary: boolean;
|
||||
trusted: boolean;
|
||||
bridge: AcpSessionBridge;
|
||||
sessionRuntimeBaseDir?: string;
|
||||
}): WorkspaceRuntime {
|
||||
return {
|
||||
...input,
|
||||
sessionRuntimeBaseDir:
|
||||
input.sessionRuntimeBaseDir ?? Storage.getRuntimeBaseDir(),
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
workspaceService: {} as DaemonWorkspaceService,
|
||||
routeFileSystemFactory: {
|
||||
|
|
@ -743,6 +750,8 @@ function makeHarness(opts?: {
|
|||
secondaryRewindImpl?: AcpSessionBridge['rewindSession'];
|
||||
secondaryShellImpl?: AcpSessionBridge['executeShellCommand'];
|
||||
serveOptions?: Partial<ServeOptions>;
|
||||
primaryRuntimeBaseDir?: string;
|
||||
secondaryRuntimeBaseDir?: string;
|
||||
}) {
|
||||
const primaryBridge = makeBridge(
|
||||
PRIMARY_CWD,
|
||||
|
|
@ -771,6 +780,9 @@ function makeHarness(opts?: {
|
|||
primary: true,
|
||||
trusted: opts?.primaryTrusted ?? true,
|
||||
bridge: primaryBridge,
|
||||
...(opts?.primaryRuntimeBaseDir
|
||||
? { sessionRuntimeBaseDir: opts.primaryRuntimeBaseDir }
|
||||
: {}),
|
||||
}),
|
||||
makeRuntime({
|
||||
workspaceId: 'secondary-id',
|
||||
|
|
@ -779,6 +791,9 @@ function makeHarness(opts?: {
|
|||
primary: false,
|
||||
trusted: opts?.secondaryTrusted ?? true,
|
||||
bridge: secondaryBridge,
|
||||
...(opts?.secondaryRuntimeBaseDir
|
||||
? { sessionRuntimeBaseDir: opts.secondaryRuntimeBaseDir }
|
||||
: {}),
|
||||
}),
|
||||
]);
|
||||
const app = createServeApp(
|
||||
|
|
@ -3627,7 +3642,7 @@ describe('multi-workspace session dispatch', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps archive and delete blocked while a workspace export is in flight', async () => {
|
||||
it('reports archive and delete conflicts while a workspace export is in flight', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440283';
|
||||
await writeStoredSession({
|
||||
|
|
@ -3668,10 +3683,15 @@ describe('multi-workspace session dispatch', () => {
|
|||
.post('/workspaces/secondary-id/sessions/archive')
|
||||
.set('Host', host())
|
||||
.send({ sessionIds: [sessionId] });
|
||||
expect(archive.status).toBe(409);
|
||||
expect(archive.status).toBe(200);
|
||||
expect(archive.body).toMatchObject({
|
||||
code: 'session_archiving',
|
||||
sessionId,
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const remove = await request(app)
|
||||
|
|
@ -3880,7 +3900,7 @@ describe('multi-workspace session dispatch', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps unarchive and delete blocked while archived export is in flight', async () => {
|
||||
it('reports unarchive and delete conflicts while archived export is in flight', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440289';
|
||||
await writeStoredSession({
|
||||
|
|
@ -3922,8 +3942,16 @@ describe('multi-workspace session dispatch', () => {
|
|||
.post('/workspaces/secondary-id/sessions/unarchive')
|
||||
.set('Host', host())
|
||||
.send({ sessionIds: [sessionId] });
|
||||
expect(unarchive.status).toBe(409);
|
||||
expect(unarchive.body.code).toBe('session_archiving');
|
||||
expect(unarchive.status).toBe(200);
|
||||
expect(unarchive.body).toMatchObject({
|
||||
unarchived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const remove = await request(app)
|
||||
.post('/workspaces/secondary-id/sessions/delete')
|
||||
|
|
@ -4600,6 +4628,75 @@ describe('multi-workspace session dispatch', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps secondary maintenance inside its fixed runtime root', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440123';
|
||||
const runtimeRoot = Storage.getRuntimeBaseDir();
|
||||
const primaryRuntimeBaseDir = path.join(runtimeRoot, 'primary-runtime');
|
||||
const secondaryRuntimeBaseDir = path.join(
|
||||
runtimeRoot,
|
||||
'secondary-runtime',
|
||||
);
|
||||
await Storage.runWithResolvedRuntimeBaseDir(primaryRuntimeBaseDir, () =>
|
||||
writeStoredSession({
|
||||
sessionId,
|
||||
cwd: PRIMARY_CWD,
|
||||
timestamp: '2026-07-08T00:14:00.000Z',
|
||||
prompt: 'primary fixed-root target',
|
||||
mtime: new Date('2026-07-08T00:14:00.000Z'),
|
||||
}),
|
||||
);
|
||||
await Storage.runWithResolvedRuntimeBaseDir(secondaryRuntimeBaseDir, () =>
|
||||
writeStoredSession({
|
||||
sessionId,
|
||||
cwd: SECONDARY_CWD,
|
||||
timestamp: '2026-07-08T00:15:00.000Z',
|
||||
prompt: 'secondary fixed-root target',
|
||||
mtime: new Date('2026-07-08T00:15:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const primaryService = new SessionService(PRIMARY_CWD, {
|
||||
runtimeBaseDir: primaryRuntimeBaseDir,
|
||||
});
|
||||
const primaryLease = await primaryService.acquireSessionWriterLease(
|
||||
sessionId,
|
||||
{
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const { app } = makeHarness({
|
||||
primaryRuntimeBaseDir,
|
||||
secondaryRuntimeBaseDir,
|
||||
primarySummaries: [],
|
||||
secondarySummaries: [],
|
||||
});
|
||||
const archived = await request(app)
|
||||
.post('/workspaces/secondary-id/sessions/archive')
|
||||
.set('Host', host())
|
||||
.send({ sessionIds: [sessionId] })
|
||||
.expect(200);
|
||||
|
||||
expect(archived.body).toMatchObject({
|
||||
archived: [sessionId],
|
||||
errors: [],
|
||||
});
|
||||
await expect(
|
||||
primaryService.getSessionLocation(sessionId),
|
||||
).resolves.toBe('active');
|
||||
await expect(
|
||||
new SessionService(SECONDARY_CWD, {
|
||||
runtimeBaseDir: secondaryRuntimeBaseDir,
|
||||
}).getSessionLocation(sessionId),
|
||||
).resolves.toBe('archived');
|
||||
} finally {
|
||||
await primaryLease.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('routes plural session group CRUD to the selected workspace', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const { app } = makeHarness();
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ interface Harness {
|
|||
scratch: string;
|
||||
workspace: string;
|
||||
bridge: StubBridge;
|
||||
cleanupSession: ReturnType<typeof vi.fn>;
|
||||
channelDeliveryAuthorizations: ChannelDeliveryAuthorizationStore;
|
||||
}
|
||||
|
||||
|
|
@ -119,11 +120,20 @@ async function makeHarness(
|
|||
({
|
||||
workspaceId: 'primary',
|
||||
workspaceCwd: workspace,
|
||||
sessionRuntimeBaseDir: scratch,
|
||||
primary: true,
|
||||
trusted: runtimeTrusted,
|
||||
bridge,
|
||||
generationGuard,
|
||||
}) as unknown as WorkspaceRuntime;
|
||||
const cleanupSession = vi.fn(
|
||||
async (_runtime: WorkspaceRuntime, sessionId: string) => {
|
||||
await bridge.closeSession(sessionId);
|
||||
await new SessionService(workspace, {
|
||||
runtimeBaseDir: scratch,
|
||||
}).removeSession(sessionId);
|
||||
},
|
||||
);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
registerScheduledTasksRoutes(app, {
|
||||
|
|
@ -133,13 +143,14 @@ async function makeHarness(
|
|||
safeBody,
|
||||
bridge,
|
||||
channelDeliveryAuthorizations,
|
||||
...(getRuntime ? { getRuntime } : {}),
|
||||
...(getRuntime ? { getRuntime, cleanupSession } : {}),
|
||||
});
|
||||
return {
|
||||
app,
|
||||
scratch,
|
||||
workspace,
|
||||
bridge,
|
||||
cleanupSession,
|
||||
channelDeliveryAuthorizations,
|
||||
};
|
||||
}
|
||||
|
|
@ -226,6 +237,10 @@ describe('scheduled-tasks routes', () => {
|
|||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.code).toBe('workspace_runtime_unavailable');
|
||||
expect(h.cleanupSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspaceCwd: h.workspace }),
|
||||
'sess-1',
|
||||
);
|
||||
expect(h.bridge.closed).toEqual(['sess-1']);
|
||||
await expect(
|
||||
fsp.readFile(getCronFilePath(h.workspace), 'utf8'),
|
||||
|
|
@ -1778,6 +1793,7 @@ describe('scheduledTaskSessionName', () => {
|
|||
interface QualifiedRuntime {
|
||||
workspaceId: string;
|
||||
workspaceCwd: string;
|
||||
sessionRuntimeBaseDir: string;
|
||||
trusted: boolean;
|
||||
bridge: StubBridge;
|
||||
}
|
||||
|
|
@ -1846,6 +1862,7 @@ async function makeQualifiedHarness(): Promise<QualifiedHarness> {
|
|||
return {
|
||||
workspaceId: `id-${name}`,
|
||||
workspaceCwd,
|
||||
sessionRuntimeBaseDir: path.join(scratch, `runtime-${name}`),
|
||||
trusted,
|
||||
bridge: makeStubBridge(),
|
||||
};
|
||||
|
|
@ -1866,6 +1883,7 @@ async function makeQualifiedHarness(): Promise<QualifiedHarness> {
|
|||
mutate: () => (_req, _res, next) => next(),
|
||||
safeBody,
|
||||
bridge: primary.bridge,
|
||||
getRuntime: () => primary as unknown as WorkspaceRuntime,
|
||||
});
|
||||
registerWorkspaceQualifiedScheduledTasksRoutes(app, {
|
||||
workspaceRegistry: makeStubRegistry(runtimes),
|
||||
|
|
@ -1888,6 +1906,10 @@ describe('workspace-qualified scheduled-tasks routes', () => {
|
|||
});
|
||||
|
||||
const qualified = (id: string) => `/workspaces/${id}/scheduled-tasks`;
|
||||
const cronFilePath = (runtime: QualifiedRuntime) =>
|
||||
Storage.runWithResolvedRuntimeBaseDir(runtime.sessionRuntimeBaseDir, () =>
|
||||
getCronFilePath(runtime.workspaceCwd),
|
||||
);
|
||||
|
||||
it('creates a task in the targeted workspace, isolated from the primary', async () => {
|
||||
const res = await request(h.app)
|
||||
|
|
@ -1913,12 +1935,15 @@ describe('workspace-qualified scheduled-tasks routes', () => {
|
|||
.post(qualified(h.secondary.workspaceId))
|
||||
.send({ cron: '0 9 * * *', prompt: 'p' });
|
||||
const onDisk = JSON.parse(
|
||||
await fsp.readFile(getCronFilePath(h.secondary.workspaceCwd), 'utf-8'),
|
||||
await fsp.readFile(cronFilePath(h.secondary), 'utf-8'),
|
||||
);
|
||||
expect(onDisk).toHaveLength(1);
|
||||
// The primary's file was never created.
|
||||
// Neither the primary runtime nor the process-global fallback was touched.
|
||||
await expect(
|
||||
fsp.readFile(getCronFilePath(h.primary.workspaceCwd), 'utf-8'),
|
||||
fsp.readFile(cronFilePath(h.primary), 'utf-8'),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
fsp.readFile(getCronFilePath(h.secondary.workspaceCwd), 'utf-8'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
nextFireTime,
|
||||
nextDurableFireMs,
|
||||
SessionService,
|
||||
Storage,
|
||||
stripTerminalControlSequences,
|
||||
MAX_JOBS,
|
||||
type CronTaskDelivery,
|
||||
|
|
@ -136,7 +137,9 @@ export function scheduledTaskSessionName(label: string): string {
|
|||
*/
|
||||
interface ScheduledTaskTarget {
|
||||
workspaceCwd: string;
|
||||
runtimeBaseDir?: string;
|
||||
bridge?: ScheduledTasksSessionBridge;
|
||||
cleanupSession?: (sessionId: string) => Promise<unknown>;
|
||||
assertGenerationOpen?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -154,14 +157,16 @@ function requireOpenGeneration(
|
|||
}
|
||||
|
||||
async function rollbackCronMutation(
|
||||
workspaceCwd: string,
|
||||
target: ScheduledTaskTarget,
|
||||
before: DurableCronTask[] | undefined,
|
||||
after: DurableCronTask[] | undefined,
|
||||
route: string,
|
||||
): Promise<void> {
|
||||
if (!before || !after) return;
|
||||
await updateCronTasks(workspaceCwd, (tasks) =>
|
||||
isDeepStrictEqual(tasks, after) ? before : tasks,
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
updateCronTasks(target.workspaceCwd, (tasks) =>
|
||||
isDeepStrictEqual(tasks, after) ? before : tasks,
|
||||
),
|
||||
).catch((error) => {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${route} failed to roll back a stale task mutation: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
@ -169,6 +174,22 @@ async function rollbackCronMutation(
|
|||
});
|
||||
}
|
||||
|
||||
async function teardownBoundSession(
|
||||
target: ScheduledTaskTarget,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (target.cleanupSession) {
|
||||
await target.cleanupSession(sessionId).catch(() => {});
|
||||
} else if (target.bridge) {
|
||||
await target.bridge.closeSession(sessionId).catch(() => {});
|
||||
await new SessionService(target.workspaceCwd, {
|
||||
runtimeBaseDir: target.runtimeBaseDir,
|
||||
})
|
||||
.removeSession(sessionId)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the target workspace for one request. Returns null when it can't be
|
||||
* resolved (unknown or untrusted `:workspace`), in which case the resolver has
|
||||
|
|
@ -201,6 +222,10 @@ interface RegisterScheduledTasksRoutesDeps {
|
|||
bridge?: ScheduledTasksSessionBridge;
|
||||
channelDeliveryAuthorizations?: ChannelDeliveryAuthorizationStore;
|
||||
getRuntime?: () => WorkspaceRuntime | undefined;
|
||||
cleanupSession?: (
|
||||
runtime: WorkspaceRuntime,
|
||||
sessionId: string,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface RegisterWorkspaceQualifiedScheduledTasksRoutesDeps {
|
||||
|
|
@ -217,6 +242,20 @@ interface RegisterWorkspaceQualifiedScheduledTasksRoutesDeps {
|
|||
* revives. Off → tasks are created unbound (shared-owner firing).
|
||||
*/
|
||||
manageScheduledTaskSessions: boolean;
|
||||
cleanupSession?: (
|
||||
runtime: WorkspaceRuntime,
|
||||
sessionId: string,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function runWithScheduledTaskTarget<T>(
|
||||
target: ScheduledTaskTarget,
|
||||
fn: () => T,
|
||||
): T {
|
||||
if (target.runtimeBaseDir === undefined) {
|
||||
return fn();
|
||||
}
|
||||
return Storage.runWithResolvedRuntimeBaseDir(target.runtimeBaseDir, fn);
|
||||
}
|
||||
|
||||
/** On-the-wire task shape — normalizes the optional on-disk fields so the
|
||||
|
|
@ -340,7 +379,9 @@ function registerScheduledTaskCrudRoutes(
|
|||
if (!target) return;
|
||||
if (!requireOpenGeneration(target, res)) return;
|
||||
try {
|
||||
const tasks = await readCronTasks(target.workspaceCwd);
|
||||
const tasks = await runWithScheduledTaskTarget(target, () =>
|
||||
readCronTasks(target.workspaceCwd),
|
||||
);
|
||||
if (!requireOpenGeneration(target, res)) return;
|
||||
res.status(200).json({ v: 1, tasks: tasks.map(toView) });
|
||||
} catch (err) {
|
||||
|
|
@ -465,7 +506,13 @@ function registerScheduledTaskCrudRoutes(
|
|||
// an orphan with no owning task. Best-effort — the write-lock cap check
|
||||
// below stays authoritative for the concurrent-create race.
|
||||
try {
|
||||
if ((await readCronTasks(workspaceCwd)).length >= MAX_SCHEDULED_TASKS) {
|
||||
if (
|
||||
(
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
readCronTasks(workspaceCwd),
|
||||
)
|
||||
).length >= MAX_SCHEDULED_TASKS
|
||||
) {
|
||||
res.status(409).json({
|
||||
error: `Maximum number of scheduled tasks (${MAX_SCHEDULED_TASKS}) reached`,
|
||||
code: 'max_tasks_reached',
|
||||
|
|
@ -485,10 +532,7 @@ function registerScheduledTaskCrudRoutes(
|
|||
});
|
||||
boundSessionId = session.sessionId;
|
||||
if (!requireOpenGeneration(target, res)) {
|
||||
await bridge.closeSession(boundSessionId).catch(() => {});
|
||||
await new SessionService(workspaceCwd)
|
||||
.removeSession(boundSessionId)
|
||||
.catch(() => {});
|
||||
await teardownBoundSession(target, boundSessionId);
|
||||
return;
|
||||
}
|
||||
// Name the session after the task so it's recognizable in the session
|
||||
|
|
@ -536,11 +580,8 @@ function registerScheduledTaskCrudRoutes(
|
|||
// which passes the pre-check but loses the authoritative write) would leave
|
||||
// a named "⏰ …" session in the list with no owning task.
|
||||
const rollbackSession = async () => {
|
||||
if (boundSessionId !== undefined && bridge) {
|
||||
await bridge.closeSession(boundSessionId).catch(() => {});
|
||||
await new SessionService(workspaceCwd)
|
||||
.removeSession(boundSessionId)
|
||||
.catch(() => {});
|
||||
if (boundSessionId !== undefined) {
|
||||
await teardownBoundSession(target, boundSessionId);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -548,21 +589,23 @@ function registerScheduledTaskCrudRoutes(
|
|||
let rollbackBefore: DurableCronTask[] | undefined;
|
||||
let rollbackAfter: DurableCronTask[] | undefined;
|
||||
try {
|
||||
await updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
// Cap check under the write lock so two concurrent creates can't both
|
||||
// slip past a stale count. Returning the input unchanged is a no-op
|
||||
// (no write), which the flag below turns into a 409.
|
||||
if (tasks.length >= MAX_SCHEDULED_TASKS) {
|
||||
overCap = true;
|
||||
return tasks;
|
||||
}
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = [...tasks, task];
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
// Cap check under the write lock so two concurrent creates can't both
|
||||
// slip past a stale count. Returning the input unchanged is a no-op
|
||||
// (no write), which the flag below turns into a 409.
|
||||
if (tasks.length >= MAX_SCHEDULED_TASKS) {
|
||||
overCap = true;
|
||||
return tasks;
|
||||
}
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = [...tasks, task];
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
await rollbackSession();
|
||||
|
|
@ -581,7 +624,7 @@ function registerScheduledTaskCrudRoutes(
|
|||
target.assertGenerationOpen?.();
|
||||
} catch (error) {
|
||||
await rollbackCronMutation(
|
||||
workspaceCwd,
|
||||
target,
|
||||
rollbackBefore,
|
||||
rollbackAfter,
|
||||
`POST ${base}`,
|
||||
|
|
@ -727,90 +770,92 @@ function registerScheduledTaskCrudRoutes(
|
|||
let rollbackBefore: DurableCronTask[] | undefined;
|
||||
let rollbackAfter: DurableCronTask[] | undefined;
|
||||
try {
|
||||
await updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
found = true;
|
||||
const current = tasks[idx]!;
|
||||
// A legacy guarded task (isolated + precondition, both removed) can't be
|
||||
// enabled: `toView` reports it disabled, so the only PATCH the Web Shell
|
||||
// sends for it is the Enable toggle — which would 200 here and then read
|
||||
// back disabled again, an Enable control that can never succeed with no
|
||||
// error explaining why. Reject the enable with the recreate remediation
|
||||
// instead of acknowledging an update that changes nothing runnable.
|
||||
if (patch.enabled === true && taskHasLegacyCondition(current)) {
|
||||
blockedLegacy = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
// A task disabled BY archiving its session (`disabledByArchive`) can't
|
||||
// be re-enabled through this generic PATCH: its bound session is still
|
||||
// archived and can't fire, so flipping `enabled: true` here would show
|
||||
// an enabled task with a countdown that never runs. The task/session
|
||||
// lifecycle must stay coupled — the caller has to unarchive the session
|
||||
// (which clears the marker and reloads it). Reject and leave the file
|
||||
// untouched.
|
||||
if (patch.enabled === true && current.disabledByArchive === true) {
|
||||
blockedByArchive = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
const next: DurableCronTask = { ...current, ...patch };
|
||||
// `name: null/""` clears the field rather than storing an empty name,
|
||||
// so toView reports it as unnamed and isValidTask never sees a "".
|
||||
if (clearName) delete next.name;
|
||||
if (clearDelivery) delete next.delivery;
|
||||
// Re-seat the task's schedule anchor to "now" whenever an edit would
|
||||
// otherwise let the scheduler retroactively fire an already-past slot.
|
||||
const justReEnabled =
|
||||
current.enabled === false && patch.enabled === true;
|
||||
// Compare the EFFECTIVE schedule, not the raw string: a cosmetic edit
|
||||
// (`0 9 * * *` → `00 9 * * *`, whitespace) must not re-seat the anchor
|
||||
// and drop a legitimately-pending catch-up fire.
|
||||
const cronChanged =
|
||||
patch.cron !== undefined &&
|
||||
canonicalCron(patch.cron) !== canonicalCron(current.cron);
|
||||
const becameRecurring =
|
||||
patch.recurring === true && current.recurring !== true;
|
||||
const becameOneShot =
|
||||
patch.recurring === false && current.recurring !== false;
|
||||
// Re-seated REGARDLESS of enabled: a schedule edit made while the task
|
||||
// is paused must not leave a stale anchor that fires retroactively when
|
||||
// it's later re-enabled in a SEPARATE request (the re-enable patch has no
|
||||
// schedule change of its own to trigger the re-seat). Re-seating a paused
|
||||
// task's anchor is harmless — it doesn't fire until enabled.
|
||||
{
|
||||
const now = Date.now();
|
||||
const minute = now - (now % 60_000);
|
||||
if (
|
||||
next.recurring &&
|
||||
(justReEnabled || cronChanged || becameRecurring)
|
||||
) {
|
||||
// A recurring task's anchor is lastFiredAt: resume from now so a
|
||||
// re-enable / cron edit / one-shot→recurring flip doesn't retroactively
|
||||
// fire a past slot (matters most for a bound task, whose catch-up runs
|
||||
// on every file-watch reload).
|
||||
next.lastFiredAt = minute;
|
||||
} else if (
|
||||
!next.recurring &&
|
||||
(justReEnabled || cronChanged || becameOneShot)
|
||||
) {
|
||||
// A one-shot's anchor is createdAt. Re-seat it on a schedule change
|
||||
// (cron edit, or recurring→one-shot) OR a re-enable so the task fires
|
||||
// at its NEXT occurrence — otherwise the scheduler reads its original
|
||||
// long-past slot as a MISSED one-shot and fires + permanently deletes
|
||||
// it. A one-shot disabled past its slot then re-enabled would
|
||||
// otherwise be silently destroyed on the next reload.
|
||||
next.createdAt = now;
|
||||
next.lastFiredAt = minute;
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
found = true;
|
||||
const current = tasks[idx]!;
|
||||
// A legacy guarded task (isolated + precondition, both removed) can't be
|
||||
// enabled: `toView` reports it disabled, so the only PATCH the Web Shell
|
||||
// sends for it is the Enable toggle — which would 200 here and then read
|
||||
// back disabled again, an Enable control that can never succeed with no
|
||||
// error explaining why. Reject the enable with the recreate remediation
|
||||
// instead of acknowledging an update that changes nothing runnable.
|
||||
if (patch.enabled === true && taskHasLegacyCondition(current)) {
|
||||
blockedLegacy = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
}
|
||||
updated = next;
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = tasks.map((t, i) => (i === idx ? next : t));
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
// A task disabled BY archiving its session (`disabledByArchive`) can't
|
||||
// be re-enabled through this generic PATCH: its bound session is still
|
||||
// archived and can't fire, so flipping `enabled: true` here would show
|
||||
// an enabled task with a countdown that never runs. The task/session
|
||||
// lifecycle must stay coupled — the caller has to unarchive the session
|
||||
// (which clears the marker and reloads it). Reject and leave the file
|
||||
// untouched.
|
||||
if (patch.enabled === true && current.disabledByArchive === true) {
|
||||
blockedByArchive = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
const next: DurableCronTask = { ...current, ...patch };
|
||||
// `name: null/""` clears the field rather than storing an empty name,
|
||||
// so toView reports it as unnamed and isValidTask never sees a "".
|
||||
if (clearName) delete next.name;
|
||||
if (clearDelivery) delete next.delivery;
|
||||
// Re-seat the task's schedule anchor to "now" whenever an edit would
|
||||
// otherwise let the scheduler retroactively fire an already-past slot.
|
||||
const justReEnabled =
|
||||
current.enabled === false && patch.enabled === true;
|
||||
// Compare the EFFECTIVE schedule, not the raw string: a cosmetic edit
|
||||
// (`0 9 * * *` → `00 9 * * *`, whitespace) must not re-seat the anchor
|
||||
// and drop a legitimately-pending catch-up fire.
|
||||
const cronChanged =
|
||||
patch.cron !== undefined &&
|
||||
canonicalCron(patch.cron) !== canonicalCron(current.cron);
|
||||
const becameRecurring =
|
||||
patch.recurring === true && current.recurring !== true;
|
||||
const becameOneShot =
|
||||
patch.recurring === false && current.recurring !== false;
|
||||
// Re-seated REGARDLESS of enabled: a schedule edit made while the task
|
||||
// is paused must not leave a stale anchor that fires retroactively when
|
||||
// it's later re-enabled in a SEPARATE request (the re-enable patch has no
|
||||
// schedule change of its own to trigger the re-seat). Re-seating a paused
|
||||
// task's anchor is harmless — it doesn't fire until enabled.
|
||||
{
|
||||
const now = Date.now();
|
||||
const minute = now - (now % 60_000);
|
||||
if (
|
||||
next.recurring &&
|
||||
(justReEnabled || cronChanged || becameRecurring)
|
||||
) {
|
||||
// A recurring task's anchor is lastFiredAt: resume from now so a
|
||||
// re-enable / cron edit / one-shot→recurring flip doesn't retroactively
|
||||
// fire a past slot (matters most for a bound task, whose catch-up runs
|
||||
// on every file-watch reload).
|
||||
next.lastFiredAt = minute;
|
||||
} else if (
|
||||
!next.recurring &&
|
||||
(justReEnabled || cronChanged || becameOneShot)
|
||||
) {
|
||||
// A one-shot's anchor is createdAt. Re-seat it on a schedule change
|
||||
// (cron edit, or recurring→one-shot) OR a re-enable so the task fires
|
||||
// at its NEXT occurrence — otherwise the scheduler reads its original
|
||||
// long-past slot as a MISSED one-shot and fires + permanently deletes
|
||||
// it. A one-shot disabled past its slot then re-enabled would
|
||||
// otherwise be silently destroyed on the next reload.
|
||||
next.createdAt = now;
|
||||
next.lastFiredAt = minute;
|
||||
}
|
||||
}
|
||||
updated = next;
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = tasks.map((t, i) => (i === idx ? next : t));
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (sendGenerationClosedError(res, err)) return;
|
||||
|
|
@ -828,7 +873,7 @@ function registerScheduledTaskCrudRoutes(
|
|||
target.assertGenerationOpen?.();
|
||||
} catch (error) {
|
||||
await rollbackCronMutation(
|
||||
workspaceCwd,
|
||||
target,
|
||||
rollbackBefore,
|
||||
rollbackAfter,
|
||||
`PATCH ${base}/${id}`,
|
||||
|
|
@ -917,21 +962,23 @@ function registerScheduledTaskCrudRoutes(
|
|||
let rollbackBefore: DurableCronTask[] | undefined;
|
||||
let rollbackAfter: DurableCronTask[] | undefined;
|
||||
try {
|
||||
await updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
const match = tasks[idx]!.sessionId;
|
||||
if (typeof match === 'string' && match.length > 0) {
|
||||
boundSessionId = match;
|
||||
}
|
||||
removed = true;
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = tasks.filter((_, i) => i !== idx);
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
const match = tasks[idx]!.sessionId;
|
||||
if (typeof match === 'string' && match.length > 0) {
|
||||
boundSessionId = match;
|
||||
}
|
||||
removed = true;
|
||||
rollbackBefore = tasks;
|
||||
rollbackAfter = tasks.filter((_, i) => i !== idx);
|
||||
return rollbackAfter;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (sendGenerationClosedError(res, err)) return;
|
||||
|
|
@ -949,7 +996,7 @@ function registerScheduledTaskCrudRoutes(
|
|||
target.assertGenerationOpen?.();
|
||||
} catch (error) {
|
||||
await rollbackCronMutation(
|
||||
workspaceCwd,
|
||||
target,
|
||||
rollbackBefore,
|
||||
rollbackAfter,
|
||||
`DELETE ${base}/${id}`,
|
||||
|
|
@ -1007,54 +1054,56 @@ function registerScheduledTaskCrudRoutes(
|
|||
let rollbackBefore: DurableCronTask[] | undefined;
|
||||
let rollbackAfter: DurableCronTask[] | undefined;
|
||||
try {
|
||||
await updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
found = true;
|
||||
const current = tasks[idx]!;
|
||||
// A legacy guarded task (isolated + precondition, both removed) must not
|
||||
// run from ANY path. The scheduler already skips it and the list view
|
||||
// reports it disabled; reject a direct `/run` too — its on-disk
|
||||
// `enabled` may still be true, so the disabled check below is not enough.
|
||||
// Executing it here would run the prompt with its safety gate ignored,
|
||||
// which is exactly what the removal must never allow.
|
||||
if (taskHasLegacyCondition(current)) {
|
||||
blockedLegacy = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
// A disabled task must not record a manual run: it's paused (and if it
|
||||
// was disabled by archiving its session, that session can't even fire),
|
||||
// so stamping lastFiredAt + a 'manual' entry would write a phantom "ran"
|
||||
// record. Mirrors the PATCH route's refusal to re-enable such tasks and
|
||||
// the UI, where onRunPrompt already rejects before recording.
|
||||
if (current.enabled === false) {
|
||||
blockedDisabled = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
const next: DurableCronTask = {
|
||||
...current,
|
||||
lastFiredAt: now,
|
||||
runs: appendCronRun(current.runs, {
|
||||
at: now,
|
||||
kind: 'manual',
|
||||
...(current.sessionId ? { sessionId: current.sessionId } : {}),
|
||||
}),
|
||||
};
|
||||
updated = next;
|
||||
// A one-shot's manual run IS its single fire — remove it from the store
|
||||
// so the scheduler doesn't ALSO fire it at its original scheduled time
|
||||
// (its slot is still in the future, so stamping lastFiredAt=now wouldn't
|
||||
// stop that fire). The response still returns the recorded run.
|
||||
rollbackBefore = tasks;
|
||||
const nextTasks = !current.recurring
|
||||
? tasks.filter((_, i) => i !== idx)
|
||||
: tasks.map((t, i) => (i === idx ? next : t));
|
||||
rollbackAfter = nextTasks;
|
||||
return nextTasks;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
await runWithScheduledTaskTarget(target, () =>
|
||||
updateCronTasks(
|
||||
workspaceCwd,
|
||||
(tasks) => {
|
||||
const idx = tasks.findIndex((t) => t.id === id);
|
||||
if (idx === -1) return tasks; // not found → no write
|
||||
found = true;
|
||||
const current = tasks[idx]!;
|
||||
// A legacy guarded task (isolated + precondition, both removed) must not
|
||||
// run from ANY path. The scheduler already skips it and the list view
|
||||
// reports it disabled; reject a direct `/run` too — its on-disk
|
||||
// `enabled` may still be true, so the disabled check below is not enough.
|
||||
// Executing it here would run the prompt with its safety gate ignored,
|
||||
// which is exactly what the removal must never allow.
|
||||
if (taskHasLegacyCondition(current)) {
|
||||
blockedLegacy = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
// A disabled task must not record a manual run: it's paused (and if it
|
||||
// was disabled by archiving its session, that session can't even fire),
|
||||
// so stamping lastFiredAt + a 'manual' entry would write a phantom "ran"
|
||||
// record. Mirrors the PATCH route's refusal to re-enable such tasks and
|
||||
// the UI, where onRunPrompt already rejects before recording.
|
||||
if (current.enabled === false) {
|
||||
blockedDisabled = true;
|
||||
return tasks; // no write
|
||||
}
|
||||
const next: DurableCronTask = {
|
||||
...current,
|
||||
lastFiredAt: now,
|
||||
runs: appendCronRun(current.runs, {
|
||||
at: now,
|
||||
kind: 'manual',
|
||||
...(current.sessionId ? { sessionId: current.sessionId } : {}),
|
||||
}),
|
||||
};
|
||||
updated = next;
|
||||
// A one-shot's manual run IS its single fire — remove it from the store
|
||||
// so the scheduler doesn't ALSO fire it at its original scheduled time
|
||||
// (its slot is still in the future, so stamping lastFiredAt=now wouldn't
|
||||
// stop that fire). The response still returns the recorded run.
|
||||
rollbackBefore = tasks;
|
||||
const nextTasks = !current.recurring
|
||||
? tasks.filter((_, i) => i !== idx)
|
||||
: tasks.map((t, i) => (i === idx ? next : t));
|
||||
rollbackAfter = nextTasks;
|
||||
return nextTasks;
|
||||
},
|
||||
{ assertCanCommit: target.assertGenerationOpen },
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (sendGenerationClosedError(res, err)) return;
|
||||
|
|
@ -1072,7 +1121,7 @@ function registerScheduledTaskCrudRoutes(
|
|||
target.assertGenerationOpen?.();
|
||||
} catch (error) {
|
||||
await rollbackCronMutation(
|
||||
workspaceCwd,
|
||||
target,
|
||||
rollbackBefore,
|
||||
rollbackAfter,
|
||||
`POST ${base}/${id}/run`,
|
||||
|
|
@ -1141,6 +1190,17 @@ export function registerScheduledTasksRoutes(
|
|||
if (runtime && !requireTrustedWorkspaceRuntime(runtime, res)) return null;
|
||||
return {
|
||||
workspaceCwd: boundWorkspace,
|
||||
...(runtime
|
||||
? {
|
||||
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
|
||||
...(deps.cleanupSession
|
||||
? {
|
||||
cleanupSession: (sessionId: string) =>
|
||||
deps.cleanupSession!(runtime, sessionId),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
bridge: runtime?.bridge ?? bridge,
|
||||
...(runtime?.generationGuard
|
||||
? {
|
||||
|
|
@ -1173,6 +1233,7 @@ export function registerWorkspaceQualifiedScheduledTasksRoutes(
|
|||
safeBody,
|
||||
manageScheduledTaskSessions,
|
||||
channelDeliveryAuthorizations,
|
||||
cleanupSession,
|
||||
} = deps;
|
||||
registerScheduledTaskCrudRoutes(app, {
|
||||
prefix: '/workspaces/:workspace',
|
||||
|
|
@ -1186,6 +1247,13 @@ export function registerWorkspaceQualifiedScheduledTasksRoutes(
|
|||
if (!requireTrustedWorkspaceRuntime(runtime, res)) return null;
|
||||
return {
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
|
||||
...(cleanupSession
|
||||
? {
|
||||
cleanupSession: (sessionId: string) =>
|
||||
cleanupSession(runtime, sessionId),
|
||||
}
|
||||
: {}),
|
||||
// Mirror the primary surface: only bind a session when management is on,
|
||||
// so a bound task always has something to keep it resident + rehydrate it.
|
||||
bridge: manageScheduledTaskSessions ? runtime.bridge : undefined,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ function runtime(opts: {
|
|||
}): WorkspaceRuntime {
|
||||
return {
|
||||
...opts,
|
||||
sessionRuntimeBaseDir: path.join(opts.workspaceCwd, '.runtime'),
|
||||
trusted: opts.trusted !== false,
|
||||
} as WorkspaceRuntime;
|
||||
}
|
||||
|
|
@ -220,6 +221,7 @@ describe('special session resolver telemetry publication', () => {
|
|||
expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith(
|
||||
secondaryCwd,
|
||||
'secondary-session',
|
||||
path.join(secondaryCwd, '.runtime'),
|
||||
);
|
||||
expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1);
|
||||
expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith(
|
||||
|
|
@ -230,8 +232,15 @@ describe('special session resolver telemetry publication', () => {
|
|||
|
||||
it('publishes the sole active transcript runtime after storage lookup', async () => {
|
||||
archiveMocks.assertSessionLoadable.mockImplementation(
|
||||
async (workspaceCwd: string) =>
|
||||
workspaceCwd === secondaryCwd ? 'active' : undefined,
|
||||
async (
|
||||
workspaceCwd: string,
|
||||
_sessionId: string,
|
||||
runtimeBaseDir: string,
|
||||
) =>
|
||||
runtimeBaseDir === path.join(secondaryCwd, '.runtime') &&
|
||||
workspaceCwd === secondaryCwd
|
||||
? 'active'
|
||||
: undefined,
|
||||
);
|
||||
const primary = runtime({
|
||||
workspaceId: 'primary',
|
||||
|
|
@ -255,10 +264,12 @@ describe('special session resolver telemetry publication', () => {
|
|||
expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith(
|
||||
primaryCwd,
|
||||
'stored-secondary',
|
||||
path.join(primaryCwd, '.runtime'),
|
||||
);
|
||||
expect(archiveMocks.assertSessionLoadable).toHaveBeenCalledWith(
|
||||
secondaryCwd,
|
||||
'stored-secondary',
|
||||
path.join(secondaryCwd, '.runtime'),
|
||||
);
|
||||
expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledTimes(1);
|
||||
expect(telemetryMocks.setDaemonTelemetryWorkspace).toHaveBeenCalledWith(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -683,14 +683,18 @@ export function createExtensionsController(
|
|||
const startedAt = Date.now();
|
||||
try {
|
||||
runtime.workspaceService.invalidateWorkspaceSkillsStatus();
|
||||
return {
|
||||
status: 'fulfilled' as const,
|
||||
result:
|
||||
await runtime.bridge.refreshExtensionsForAllSessions(
|
||||
bridgeMutationEvent(event),
|
||||
),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
try {
|
||||
return {
|
||||
status: 'fulfilled' as const,
|
||||
result:
|
||||
await runtime.bridge.refreshExtensionsForAllSessions(
|
||||
bridgeMutationEvent(event),
|
||||
),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
} finally {
|
||||
runtime.workspaceService.invalidateWorkspaceSkillsStatus();
|
||||
}
|
||||
} catch (reason) {
|
||||
return {
|
||||
status: 'rejected' as const,
|
||||
|
|
@ -771,10 +775,14 @@ export function createExtensionsController(
|
|||
const { result, elapsedMs } = await runReconciliation(async () => {
|
||||
workspace.invalidateWorkspaceSkillsStatus();
|
||||
const startedAt = Date.now();
|
||||
const result = await bridge.refreshExtensionsForAllSessions(
|
||||
bridgeMutationEvent(event),
|
||||
);
|
||||
return { result, elapsedMs: Date.now() - startedAt };
|
||||
try {
|
||||
const result = await bridge.refreshExtensionsForAllSessions(
|
||||
bridgeMutationEvent(event),
|
||||
);
|
||||
return { result, elapsedMs: Date.now() - startedAt };
|
||||
} finally {
|
||||
workspace.invalidateWorkspaceSkillsStatus();
|
||||
}
|
||||
});
|
||||
const warnings: NonNullable<ExtensionOperationStatus['warnings']> =
|
||||
[...commitWarnings];
|
||||
|
|
|
|||
|
|
@ -449,12 +449,16 @@ export function registerWorkspaceExtensionRoutes(
|
|||
await Promise.allSettled(
|
||||
runtimes.map(async (runtime) => {
|
||||
runtime.workspaceService.invalidateWorkspaceSkillsStatus();
|
||||
const result =
|
||||
await runtime.bridge.refreshExtensionsForAllSessions();
|
||||
if (result.failed > 0) {
|
||||
throw new Error(
|
||||
`${result.failed} extension session refresh(es) failed`,
|
||||
);
|
||||
try {
|
||||
const result =
|
||||
await runtime.bridge.refreshExtensionsForAllSessions();
|
||||
if (result.failed > 0) {
|
||||
throw new Error(
|
||||
`${result.failed} extension session refresh(es) failed`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
runtime.workspaceService.invalidateWorkspaceSkillsStatus();
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -147,6 +147,61 @@ describe('GET /file', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('pages a large file over HTTP with nextCursor', async () => {
|
||||
const lines = Array.from(
|
||||
{ length: 4_000 },
|
||||
(_, index) => `line-${index + 1} ${'x'.repeat(80)}`,
|
||||
);
|
||||
const body = lines.join('\n');
|
||||
await fsp.writeFile(path.join(h.workspace, 'paged.log'), body);
|
||||
|
||||
const first = await request(h.app)
|
||||
.get('/file?path=paged.log&limit=500')
|
||||
.set('Host', loopbackHost());
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.hasMore).toBe(true);
|
||||
expect(typeof first.body.nextCursor).toBe('string');
|
||||
|
||||
const pages: string[] = [first.body.content];
|
||||
let cursor: string | null = first.body.nextCursor;
|
||||
let guard = 0;
|
||||
while (cursor) {
|
||||
if (guard++ > 50) throw new Error('paging did not terminate');
|
||||
const next = await request(h.app)
|
||||
.get(
|
||||
`/file?path=paged.log&limit=500&cursor=${encodeURIComponent(cursor)}`,
|
||||
)
|
||||
.set('Host', loopbackHost());
|
||||
expect(next.status).toBe(200);
|
||||
pages.push(next.body.content);
|
||||
cursor = next.body.nextCursor;
|
||||
}
|
||||
expect(pages.join('\n')).toBe(body);
|
||||
});
|
||||
|
||||
it('rejects a malformed cursor with 400', async () => {
|
||||
await fsp.writeFile(path.join(h.workspace, 'c.txt'), 'a\nb\n');
|
||||
const res = await request(h.app)
|
||||
.get('/file?path=c.txt&cursor=not-a-cursor')
|
||||
.set('Host', loopbackHost());
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.errorKind).toBe('parse_error');
|
||||
});
|
||||
|
||||
it('rejects cursor combined with line', async () => {
|
||||
await fsp.writeFile(path.join(h.workspace, 'cl.txt'), 'a\nb\nc\n');
|
||||
const first = await request(h.app)
|
||||
.get('/file?path=cl.txt&limit=1')
|
||||
.set('Host', loopbackHost());
|
||||
const res = await request(h.app)
|
||||
.get(
|
||||
`/file?path=cl.txt&line=2&cursor=${encodeURIComponent(first.body.nextCursor)}`,
|
||||
)
|
||||
.set('Host', loopbackHost());
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.errorKind).toBe('parse_error');
|
||||
});
|
||||
|
||||
it('returns a bounded line window for text above MAX_READ_BYTES', async () => {
|
||||
const { MAX_READ_BYTES } = await import('../fs/policy.js');
|
||||
const lines = Array.from(
|
||||
|
|
@ -173,23 +228,20 @@ describe('GET /file', () => {
|
|||
expect(res.body.hash).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['', '&line=2', '&maxBytes=1024', '&line=2&maxBytes=1024'])(
|
||||
'keeps oversized reads without a finite limit behind the snapshot cap (%s)',
|
||||
async (query) => {
|
||||
const { MAX_READ_BYTES } = await import('../fs/policy.js');
|
||||
await fsp.writeFile(
|
||||
path.join(h.workspace, 'large-no-limit.txt'),
|
||||
'x'.repeat(MAX_READ_BYTES + 1),
|
||||
);
|
||||
it('keeps an oversized read without a window behind the snapshot cap', async () => {
|
||||
const { MAX_READ_BYTES } = await import('../fs/policy.js');
|
||||
await fsp.writeFile(
|
||||
path.join(h.workspace, 'large-no-window.txt'),
|
||||
'x'.repeat(MAX_READ_BYTES + 1),
|
||||
);
|
||||
|
||||
const res = await request(h.app)
|
||||
.get(`/file?path=large-no-limit.txt${query}`)
|
||||
.set('Host', loopbackHost());
|
||||
const res = await request(h.app)
|
||||
.get('/file?path=large-no-window.txt')
|
||||
.set('Host', loopbackHost());
|
||||
|
||||
expect(res.status).toBe(413);
|
||||
expect(res.body.errorKind).toBe('file_too_large');
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(413);
|
||||
expect(res.body.errorKind).toBe('file_too_large');
|
||||
});
|
||||
|
||||
it('attaches Cache-Control: no-store and X-Content-Type-Options: nosniff', async () => {
|
||||
await fsp.writeFile(path.join(h.workspace, 'a.txt'), 'x');
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { writeStderrLine } from '../../utils/stdioHelpers.js';
|
|||
import {
|
||||
FsError,
|
||||
MAX_READ_BYTES,
|
||||
MAX_TEXT_CURSOR_CHARS,
|
||||
canonicalizeWorkspace,
|
||||
isFsError,
|
||||
type WorkspaceFileSystemFactory,
|
||||
|
|
@ -256,13 +257,34 @@ async function handleGetFile(
|
|||
});
|
||||
return;
|
||||
}
|
||||
const rawCursor = req.query['cursor'];
|
||||
if (
|
||||
rawCursor !== undefined &&
|
||||
(typeof rawCursor !== 'string' ||
|
||||
rawCursor.length === 0 ||
|
||||
rawCursor.length > MAX_TEXT_CURSOR_CHARS)
|
||||
) {
|
||||
applyReadHeaders(res);
|
||||
res.status(400).json({
|
||||
errorKind: 'parse_error',
|
||||
error: `\`cursor\` must be a non-empty string of at most ${MAX_TEXT_CURSOR_CHARS} characters`,
|
||||
status: 400,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cursor = rawCursor as string | undefined;
|
||||
const fs = factory.forRequest({
|
||||
originatorClientId: clientId ?? undefined,
|
||||
route: ROUTE,
|
||||
});
|
||||
try {
|
||||
const resolved = await fs.resolve(queryPath, 'read');
|
||||
const out = await fs.readText(resolved, { maxBytes, line, limit });
|
||||
const out = await fs.readText(resolved, {
|
||||
maxBytes,
|
||||
line,
|
||||
limit,
|
||||
cursor,
|
||||
});
|
||||
const returnedBytes = Buffer.byteLength(out.content, 'utf-8');
|
||||
applyReadHeaders(res);
|
||||
res.status(200).json({
|
||||
|
|
@ -278,6 +300,8 @@ async function handleGetFile(
|
|||
hash: out.meta.hash,
|
||||
matchedIgnore: out.meta.matchedIgnore ?? null,
|
||||
originalLineCount: out.meta.originalLineCount ?? null,
|
||||
nextCursor: out.meta.nextCursor ?? null,
|
||||
hasMore: out.meta.hasMore === true,
|
||||
});
|
||||
} catch (err) {
|
||||
sendFsError(res, err, ROUTE);
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ function makeRuntime(
|
|||
return {
|
||||
workspaceId: opts.workspaceId,
|
||||
workspaceCwd,
|
||||
sessionRuntimeBaseDir: path.join(workspaceCwd, '.runtime'),
|
||||
primary: opts.primary,
|
||||
trusted: opts.trusted,
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
|
|
|
|||
|
|
@ -3060,6 +3060,8 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-env-reload-')),
|
||||
);
|
||||
const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
const originalBase = process.env['QWEN_TEST_BOOT_BASE'];
|
||||
const originalLeak = process.env['QWEN_TEST_RELOAD_LEAK'];
|
||||
const originalRemoved = process.env['QWEN_TEST_REMOVED_FROM_DOTENV'];
|
||||
|
|
@ -3076,6 +3078,11 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
() =>
|
||||
({
|
||||
merged: {
|
||||
advanced: {
|
||||
runtimeOutputDir: runtimeMounted
|
||||
? '.runtime-reloaded'
|
||||
: '.runtime-boot',
|
||||
},
|
||||
env: {
|
||||
QWEN_TEST_RUNTIME_VALUE: runtimeMounted ? 'reloaded' : 'boot',
|
||||
},
|
||||
|
|
@ -3110,11 +3117,15 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
effectiveEnv?: NodeJS.ProcessEnv;
|
||||
}
|
||||
| undefined;
|
||||
let primaryRuntime:
|
||||
| import('./workspace-registry.js').WorkspaceRuntime
|
||||
| undefined;
|
||||
vi.spyOn(serverModule, 'createServeApp').mockImplementation(
|
||||
(_opts, _getPort, deps) => {
|
||||
runtimeMounted = true;
|
||||
workspace = deps?.workspace as typeof workspace;
|
||||
primaryRuntimeEnv = deps?.primaryRuntimeEnv as typeof primaryRuntimeEnv;
|
||||
primaryRuntime = deps?.workspaceRegistry?.primary;
|
||||
return express();
|
||||
},
|
||||
);
|
||||
|
|
@ -3142,6 +3153,9 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
expect(primaryRuntimeEnv?.effectiveEnv).toBeDefined();
|
||||
const capturedRuntimeEnv = primaryRuntimeEnv!.effectiveEnv!;
|
||||
expect(capturedRuntimeEnv['QWEN_TEST_RUNTIME_VALUE']).toBe('boot');
|
||||
const pinnedRuntimeBaseDir = path.join(tmpDir, '.runtime-boot');
|
||||
expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir);
|
||||
expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
|
||||
|
||||
await workspace!.reload({
|
||||
route: 'POST /workspace/reload',
|
||||
|
|
@ -3156,6 +3170,8 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
expect(capturedRuntimeEnv['QWEN_TEST_RUNTIME_VALUE']).toBe('reloaded');
|
||||
expect(capturedRuntimeEnv['QWEN_TEST_REMOVED_FROM_DOTENV']).toBe('stale');
|
||||
expect(capturedRuntimeEnv['QWEN_TEST_RELOAD_LEAK']).toBeUndefined();
|
||||
expect(primaryRuntime?.sessionRuntimeBaseDir).toBe(pinnedRuntimeBaseDir);
|
||||
expect(capturedRuntimeEnv['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
|
||||
} finally {
|
||||
if (originalBase === undefined) {
|
||||
delete process.env['QWEN_TEST_BOOT_BASE'];
|
||||
|
|
@ -3172,6 +3188,11 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
} else {
|
||||
process.env['QWEN_TEST_REMOVED_FROM_DOTENV'] = originalRemoved;
|
||||
}
|
||||
if (originalRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir;
|
||||
}
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -3305,6 +3326,8 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
);
|
||||
const primary = path.join(tmpDir, 'primary');
|
||||
const secondary = path.join(tmpDir, 'secondary');
|
||||
const originalRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
fs.mkdirSync(primary);
|
||||
fs.mkdirSync(secondary);
|
||||
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
|
||||
|
|
@ -3318,6 +3341,13 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
const isSecondary = workspace === secondary;
|
||||
return {
|
||||
merged: {
|
||||
advanced: {
|
||||
runtimeOutputDir: isSecondary
|
||||
? runtimeMounted
|
||||
? '.secondary-runtime-reloaded'
|
||||
: '.secondary-runtime-boot'
|
||||
: '.primary-runtime',
|
||||
},
|
||||
env: {
|
||||
[isSecondary
|
||||
? 'QWEN_TEST_SECONDARY_ENV'
|
||||
|
|
@ -3381,6 +3411,14 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
const envFilePaths = env.envFilePaths;
|
||||
const envFileReadFailures = env.envFileReadFailures;
|
||||
expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe('boot');
|
||||
const pinnedRuntimeBaseDir = path.join(
|
||||
secondary,
|
||||
'.secondary-runtime-boot',
|
||||
);
|
||||
expect(secondaryRuntime!.sessionRuntimeBaseDir).toBe(
|
||||
pinnedRuntimeBaseDir,
|
||||
);
|
||||
expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
|
||||
|
||||
await secondaryRuntime!.workspaceService.reload({
|
||||
route: 'POST /workspace/reload',
|
||||
|
|
@ -3391,8 +3429,17 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
expect(env.envFilePaths).toBe(envFilePaths);
|
||||
expect(env.envFileReadFailures).toBe(envFileReadFailures);
|
||||
expect(env.effectiveEnv?.['QWEN_TEST_SECONDARY_ENV']).toBe('reloaded');
|
||||
expect(secondaryRuntime!.sessionRuntimeBaseDir).toBe(
|
||||
pinnedRuntimeBaseDir,
|
||||
);
|
||||
expect(env.effectiveEnv?.['QWEN_RUNTIME_DIR']).toBe(pinnedRuntimeBaseDir);
|
||||
} finally {
|
||||
await handle.close();
|
||||
if (originalRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -5296,6 +5343,54 @@ describe('runQwenServe runtime startup failures', () => {
|
|||
).toBeLessThan(vi.mocked(bridge.shutdown).mock.invocationCallOrder[0]!);
|
||||
});
|
||||
|
||||
it('seals and drains admitted session maintenance before bridge shutdown', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-maintenance-drain-')),
|
||||
);
|
||||
vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({
|
||||
enabled: false,
|
||||
sensitiveSpanAttributeMaxLength: 1024 * 1024,
|
||||
});
|
||||
const bridge = makeRuntimeBridge();
|
||||
vi.spyOn(acpBridge, 'createAcpSessionBridge').mockReturnValue(
|
||||
bridge as ReturnType<typeof acpBridge.createAcpSessionBridge>,
|
||||
);
|
||||
let finishMaintenance!: () => void;
|
||||
const maintenanceGate = new Promise<void>((resolve) => {
|
||||
finishMaintenance = resolve;
|
||||
});
|
||||
const sealMaintenanceAndWait = vi.fn(() => maintenanceGate);
|
||||
vi.spyOn(serverModule, 'createServeApp').mockImplementation(() => {
|
||||
const runtimeApp = express();
|
||||
runtimeApp.locals['sessionArchiveCoordinator'] = {
|
||||
sealMaintenanceAndWait,
|
||||
};
|
||||
return runtimeApp;
|
||||
});
|
||||
|
||||
const handle = await runQwenServe(
|
||||
{
|
||||
port: 0,
|
||||
hostname: '127.0.0.1',
|
||||
mode: 'http-bridge',
|
||||
workspace: tmpDir,
|
||||
maxSessions: 1,
|
||||
serveWebShell: false,
|
||||
},
|
||||
{ resolveOnListen: true },
|
||||
);
|
||||
await handle.runtimeReady;
|
||||
|
||||
const close = handle.close();
|
||||
expect(sealMaintenanceAndWait).toHaveBeenCalledOnce();
|
||||
await Promise.resolve();
|
||||
expect(bridge.shutdown).not.toHaveBeenCalled();
|
||||
|
||||
finishMaintenance();
|
||||
await close;
|
||||
expect(bridge.shutdown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not cancel deferred runtime once startup is already running', async () => {
|
||||
tmpDir = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qws-health-close-running-')),
|
||||
|
|
|
|||
|
|
@ -3108,6 +3108,47 @@ async function runQwenServeImpl(
|
|||
envFileReadFailed: false,
|
||||
envFileReadFailures: Object.freeze([]),
|
||||
};
|
||||
const resolveSessionRuntimeBaseDir = (
|
||||
workspace: string,
|
||||
settings: ReturnType<SettingsRuntime['loadSettings']> | undefined,
|
||||
effectiveEnv: Readonly<NodeJS.ProcessEnv>,
|
||||
): string => {
|
||||
const resolveConfiguredPath = (
|
||||
configuredPath: string,
|
||||
relativeTo: string,
|
||||
): string => {
|
||||
const expanded =
|
||||
configuredPath === '~'
|
||||
? os.homedir()
|
||||
: configuredPath.startsWith('~/') ||
|
||||
configuredPath.startsWith('~\\')
|
||||
? path.join(
|
||||
os.homedir(),
|
||||
...configuredPath
|
||||
.slice(2)
|
||||
.split(/[/\\]+/)
|
||||
.filter(Boolean),
|
||||
)
|
||||
: configuredPath;
|
||||
return path.resolve(relativeTo, expanded);
|
||||
};
|
||||
const runtimeDir = effectiveEnv['QWEN_RUNTIME_DIR'];
|
||||
if (runtimeDir) {
|
||||
return resolveConfiguredPath(runtimeDir, process.cwd());
|
||||
}
|
||||
const settingsDir = settings?.merged.advanced?.runtimeOutputDir;
|
||||
if (settingsDir) {
|
||||
return resolveConfiguredPath(settingsDir, workspace);
|
||||
}
|
||||
const qwenHome = effectiveEnv['QWEN_HOME'];
|
||||
if (qwenHome) {
|
||||
return resolveConfiguredPath(qwenHome, process.cwd());
|
||||
}
|
||||
const homeDir = os.homedir();
|
||||
return homeDir
|
||||
? path.join(homeDir, '.qwen')
|
||||
: path.join(os.tmpdir(), '.qwen');
|
||||
};
|
||||
const logRuntimeEnvFileReadFailures = (
|
||||
workspace: string,
|
||||
snapshot: {
|
||||
|
|
@ -3126,8 +3167,14 @@ async function runQwenServeImpl(
|
|||
});
|
||||
};
|
||||
logRuntimeEnvFileReadFailures(boundWorkspace, runtimeEnvSnapshot);
|
||||
const primarySessionRuntimeBaseDir = resolveSessionRuntimeBaseDir(
|
||||
boundWorkspace,
|
||||
runtimeBootSettings,
|
||||
runtimeEnvSnapshot.effectiveEnv,
|
||||
);
|
||||
const runtimeEffectiveEnv: NodeJS.ProcessEnv = {
|
||||
...runtimeEnvSnapshot.effectiveEnv,
|
||||
QWEN_RUNTIME_DIR: primarySessionRuntimeBaseDir,
|
||||
};
|
||||
const replaceRuntimeEffectiveEnv = (
|
||||
nextEnv: Readonly<NodeJS.ProcessEnv>,
|
||||
|
|
@ -3136,6 +3183,7 @@ async function runQwenServeImpl(
|
|||
delete runtimeEffectiveEnv[key];
|
||||
}
|
||||
Object.assign(runtimeEffectiveEnv, nextEnv);
|
||||
runtimeEffectiveEnv['QWEN_RUNTIME_DIR'] = primarySessionRuntimeBaseDir;
|
||||
};
|
||||
const primaryRuntimeEnv: {
|
||||
mode: 'runtime-overlay';
|
||||
|
|
@ -3814,6 +3862,7 @@ async function runQwenServeImpl(
|
|||
{
|
||||
workspaceId: daemonWorkspaceHash,
|
||||
workspaceCwd: boundWorkspace,
|
||||
sessionRuntimeBaseDir: primarySessionRuntimeBaseDir,
|
||||
...(workspaceInputs[0]?.displayName
|
||||
? { displayName: workspaceInputs[0].displayName }
|
||||
: {}),
|
||||
|
|
@ -3846,6 +3895,7 @@ async function runQwenServeImpl(
|
|||
fallbackReason?: string;
|
||||
};
|
||||
effectiveEnv: NodeJS.ProcessEnv;
|
||||
sessionRuntimeBaseDir: string;
|
||||
replace: (nextEnv: Readonly<NodeJS.ProcessEnv>) => void;
|
||||
} => {
|
||||
const snapshot = settings
|
||||
|
|
@ -3863,7 +3913,15 @@ async function runQwenServeImpl(
|
|||
envFileReadFailures: Object.freeze([]),
|
||||
};
|
||||
logRuntimeEnvFileReadFailures(workspace, snapshot);
|
||||
const effectiveEnv: NodeJS.ProcessEnv = { ...snapshot.effectiveEnv };
|
||||
const sessionRuntimeBaseDir = resolveSessionRuntimeBaseDir(
|
||||
workspace,
|
||||
settings,
|
||||
snapshot.effectiveEnv,
|
||||
);
|
||||
const effectiveEnv: NodeJS.ProcessEnv = {
|
||||
...snapshot.effectiveEnv,
|
||||
QWEN_RUNTIME_DIR: sessionRuntimeBaseDir,
|
||||
};
|
||||
const metadata: {
|
||||
mode: 'runtime-overlay';
|
||||
overlayKeys: string[];
|
||||
|
|
@ -3883,11 +3941,13 @@ async function runQwenServeImpl(
|
|||
return {
|
||||
metadata,
|
||||
effectiveEnv,
|
||||
sessionRuntimeBaseDir,
|
||||
replace(nextEnv) {
|
||||
for (const key of Object.keys(effectiveEnv)) {
|
||||
delete effectiveEnv[key];
|
||||
}
|
||||
Object.assign(effectiveEnv, nextEnv);
|
||||
effectiveEnv['QWEN_RUNTIME_DIR'] = sessionRuntimeBaseDir;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -4177,6 +4237,7 @@ async function runQwenServeImpl(
|
|||
const secondaryRuntime: WorkspaceRuntime = {
|
||||
workspaceId: secondaryWorkspaceHash,
|
||||
workspaceCwd: workspaceInput.cwd,
|
||||
sessionRuntimeBaseDir: secondaryEnv.sessionRuntimeBaseDir,
|
||||
...(workspaceInput.displayName
|
||||
? { displayName: workspaceInput.displayName }
|
||||
: {}),
|
||||
|
|
@ -4711,6 +4772,7 @@ async function runQwenServeImpl(
|
|||
const wsRuntime: WorkspaceRuntime = {
|
||||
workspaceId: wsHash,
|
||||
workspaceCwd: cwd,
|
||||
sessionRuntimeBaseDir: wsEnv.sessionRuntimeBaseDir,
|
||||
...(buildOptions?.displayName !== undefined
|
||||
? { displayName: buildOptions.displayName }
|
||||
: {}),
|
||||
|
|
@ -6345,11 +6407,17 @@ async function runQwenServeImpl(
|
|||
const initiallyMountedManagement = initiallyMountedApp?.locals?.[
|
||||
'workspaceManagementHandle'
|
||||
] as { sealAndWait?: () => Promise<void> } | undefined;
|
||||
const initiallyMountedSessionMaintenance = initiallyMountedApp
|
||||
?.locals?.['sessionArchiveCoordinator'] as
|
||||
| { sealMaintenanceAndWait?: () => Promise<void> }
|
||||
| undefined;
|
||||
// Calling an async function runs through its first await
|
||||
// synchronously. Seal an already-mounted runtime before close()
|
||||
// yields so no management request can enter the shutdown window.
|
||||
const initialManagementWait =
|
||||
initiallyMountedManagement?.sealAndWait?.();
|
||||
const initialSessionMaintenanceWait =
|
||||
initiallyMountedSessionMaintenance?.sealMaintenanceAndWait?.();
|
||||
let processRegistryShutdown: Promise<Error | undefined> | undefined;
|
||||
const startProcessRegistryShutdown = () => {
|
||||
processRegistryShutdown ??= managedProcessRegistry
|
||||
|
|
@ -6492,10 +6560,19 @@ async function runQwenServeImpl(
|
|||
const workspaceManagementHandle = appForCleanup?.locals?.[
|
||||
'workspaceManagementHandle'
|
||||
] as { sealAndWait?: () => Promise<void> } | undefined;
|
||||
const sessionMaintenance = appForCleanup?.locals?.[
|
||||
'sessionArchiveCoordinator'
|
||||
] as
|
||||
| { sealMaintenanceAndWait?: () => Promise<void> }
|
||||
| undefined;
|
||||
await initialManagementWait;
|
||||
if (workspaceManagementHandle !== initiallyMountedManagement) {
|
||||
await workspaceManagementHandle?.sealAndWait?.();
|
||||
}
|
||||
await initialSessionMaintenanceWait;
|
||||
if (sessionMaintenance !== initiallyMountedSessionMaintenance) {
|
||||
await sessionMaintenance?.sealMaintenanceAndWait?.();
|
||||
}
|
||||
stopTrustPolicyMonitor(appForCleanup);
|
||||
const waitForTrustPolicyIdle = appForCleanup?.locals?.[
|
||||
'waitForTrustPolicyIdle'
|
||||
|
|
|
|||
|
|
@ -777,6 +777,46 @@ describe('scheduled-task keepalive', () => {
|
|||
releaseSpawn?.();
|
||||
});
|
||||
|
||||
it('waits for close before deleting a late spawned transcript', async () => {
|
||||
await updateCronTasks(workspace, () => [
|
||||
task({ id: 'hung', prompt: 'will resolve late' }),
|
||||
]);
|
||||
let resolveSpawn!: (value: { sessionId: string }) => void;
|
||||
let finishClose!: () => void;
|
||||
const closeGate = new Promise<void>((resolve) => {
|
||||
finishClose = resolve;
|
||||
});
|
||||
const closeSession = vi.fn(() => closeGate);
|
||||
const removeSpy = vi
|
||||
.spyOn(SessionService.prototype, 'removeSession')
|
||||
.mockResolvedValue(true);
|
||||
const ka = startScheduledTaskKeepalive({
|
||||
bridge: {
|
||||
...bridge,
|
||||
spawnOrAttach: () =>
|
||||
new Promise<{ sessionId: string }>((resolve) => {
|
||||
resolveSpawn = resolve;
|
||||
}),
|
||||
closeSession,
|
||||
},
|
||||
boundWorkspace: workspace,
|
||||
intervalMs: 50,
|
||||
spawnTimeoutMs: 5,
|
||||
});
|
||||
|
||||
await ka.tick();
|
||||
resolveSpawn({ sessionId: 'late-sess' });
|
||||
await vi.waitFor(() =>
|
||||
expect(closeSession).toHaveBeenCalledWith('late-sess'),
|
||||
);
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
|
||||
finishClose();
|
||||
await vi.waitFor(() => expect(removeSpy).toHaveBeenCalledWith('late-sess'));
|
||||
ka.stop();
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rehydration onTasksRead populates the authorization store for delivery-enabled tasks', async () => {
|
||||
const authorizations = new ChannelDeliveryAuthorizationStore();
|
||||
await updateCronTasks(workspace, () => [
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
getCronFilePath,
|
||||
createDebugLogger,
|
||||
SessionService,
|
||||
Storage,
|
||||
taskHasLegacyCondition,
|
||||
type DurableCronTask,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -126,6 +127,7 @@ async function bindAndNameSessions(
|
|||
renamed: Set<string>,
|
||||
spawnTimeoutMs: number,
|
||||
binding: Set<string>,
|
||||
cleanupSession: (sessionId: string) => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
const unbound = tasks.filter(
|
||||
(t) =>
|
||||
|
|
@ -158,17 +160,14 @@ async function bindAndNameSessions(
|
|||
// binding guard on TRUE settlement so retries are possible.
|
||||
let timedOut = false;
|
||||
rawSpawn
|
||||
.then(({ sessionId }) => {
|
||||
.then(async ({ sessionId }) => {
|
||||
if (timedOut) {
|
||||
log.debug(
|
||||
'keepalive: late spawn resolved, cleaning up',
|
||||
task.id,
|
||||
sessionId,
|
||||
);
|
||||
bridge.closeSession(sessionId).catch(() => {});
|
||||
new SessionService(boundWorkspace)
|
||||
.removeSession(sessionId)
|
||||
.catch(() => {});
|
||||
await cleanupSession(sessionId).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
|
|
@ -226,10 +225,7 @@ async function bindAndNameSessions(
|
|||
} catch (err) {
|
||||
log.debug('keepalive: failed to bind task', task.id, err);
|
||||
if (spawnedSessionId !== undefined) {
|
||||
await bridge.closeSession(spawnedSessionId).catch(() => {});
|
||||
await new SessionService(boundWorkspace)
|
||||
.removeSession(spawnedSessionId)
|
||||
.catch(() => {});
|
||||
await cleanupSession(spawnedSessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -257,6 +253,8 @@ export interface ScheduledTaskKeepalive {
|
|||
export interface StartScheduledTaskKeepaliveOptions {
|
||||
bridge: KeepaliveBridge;
|
||||
boundWorkspace: string;
|
||||
runtimeBaseDir?: string;
|
||||
cleanupSession?: (sessionId: string) => Promise<unknown>;
|
||||
/** How often to heartbeat; must be comfortably under the reaper timeout. */
|
||||
intervalMs: number;
|
||||
/** Per-session revive timeout; defaults to KEEPALIVE_REVIVE_TIMEOUT_MS. */
|
||||
|
|
@ -272,6 +270,14 @@ export function startScheduledTaskKeepalive(
|
|||
const { bridge, boundWorkspace, intervalMs } = opts;
|
||||
const reviveTimeoutMs = opts.reviveTimeoutMs ?? KEEPALIVE_REVIVE_TIMEOUT_MS;
|
||||
const spawnTimeoutMs = opts.spawnTimeoutMs ?? KEEPALIVE_SPAWN_TIMEOUT_MS;
|
||||
const cleanupSession =
|
||||
opts.cleanupSession ??
|
||||
(async (sessionId: string) => {
|
||||
await bridge.closeSession(sessionId);
|
||||
await new SessionService(boundWorkspace, {
|
||||
runtimeBaseDir: opts.runtimeBaseDir,
|
||||
}).removeSession(sessionId);
|
||||
});
|
||||
|
||||
// Per-session revive state: `nextAttemptAt` gates retries after failures so a
|
||||
// permanently-gone session isn't reloaded every interval; cleared on success.
|
||||
|
|
@ -294,7 +300,7 @@ export function startScheduledTaskKeepalive(
|
|||
// so updateSessionMetadata isn't called every tick.
|
||||
const renamed = new Set<string>();
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
const tickInRuntime = async (): Promise<void> => {
|
||||
let tasks;
|
||||
try {
|
||||
tasks = await readCronTasks(boundWorkspace);
|
||||
|
|
@ -388,8 +394,16 @@ export function startScheduledTaskKeepalive(
|
|||
renamed,
|
||||
spawnTimeoutMs,
|
||||
binding,
|
||||
cleanupSession,
|
||||
);
|
||||
};
|
||||
const tick = (): Promise<void> =>
|
||||
opts.runtimeBaseDir === undefined
|
||||
? tickInRuntime()
|
||||
: Storage.runWithResolvedRuntimeBaseDir(
|
||||
opts.runtimeBaseDir,
|
||||
tickInRuntime,
|
||||
);
|
||||
|
||||
// In-flight guard: a pass can outlast the interval (each revive awaits up to
|
||||
// the revive timeout), so skip a tick while the previous is still running —
|
||||
|
|
@ -410,7 +424,12 @@ export function startScheduledTaskKeepalive(
|
|||
// dedicated session immediately, not after the next interval. Same
|
||||
// directory-watch + debounce pattern the scheduler uses.
|
||||
let bindDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||
const cronFilePath = getCronFilePath(boundWorkspace);
|
||||
const cronFilePath =
|
||||
opts.runtimeBaseDir === undefined
|
||||
? getCronFilePath(boundWorkspace)
|
||||
: Storage.runWithResolvedRuntimeBaseDir(opts.runtimeBaseDir, () =>
|
||||
getCronFilePath(boundWorkspace),
|
||||
);
|
||||
const cronDir = path.dirname(cronFilePath);
|
||||
const cronFileName = path.basename(cronFilePath);
|
||||
let fileWatcher: ReturnType<typeof fsSync.watch> | undefined;
|
||||
|
|
|
|||
|
|
@ -334,6 +334,7 @@ const EXPECTED_STAGE1_FEATURES = [
|
|||
'session_list',
|
||||
'session_info',
|
||||
'session_source_metadata',
|
||||
'session_side_task',
|
||||
'session_prompt',
|
||||
'session_cancel',
|
||||
'session_events',
|
||||
|
|
@ -393,6 +394,7 @@ const EXPECTED_STAGE1_FEATURES = [
|
|||
// Issue #4175 PR 20. Always-on. Daemon exposes raw byte windows and
|
||||
// hash-aware text mutation routes behind the strict mutation gate.
|
||||
'workspace_file_bytes',
|
||||
'workspace_file_read_cursor',
|
||||
'workspace_file_write',
|
||||
// Mutation control routes (approval mode, workspace tool/skill toggles,
|
||||
// init scaffold, and MCP server restart).
|
||||
|
|
@ -2178,12 +2180,15 @@ function makeWorkspaceRuntimeForTest(input: {
|
|||
workspaceCwd: string;
|
||||
primary: boolean;
|
||||
bridge: AcpSessionBridge;
|
||||
sessionRuntimeBaseDir?: string;
|
||||
trusted?: boolean;
|
||||
generationGuard?: WorkspaceGenerationGuard;
|
||||
}): WorkspaceRuntime {
|
||||
return {
|
||||
workspaceId: input.workspaceId,
|
||||
workspaceCwd: input.workspaceCwd,
|
||||
sessionRuntimeBaseDir:
|
||||
input.sessionRuntimeBaseDir ?? Storage.getRuntimeBaseDir(),
|
||||
primary: input.primary,
|
||||
trusted: input.trusted ?? true,
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
|
|
@ -11935,6 +11940,55 @@ describe('createServeApp', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('rejects singular session-group mutations when the selected runtime is unavailable', async () => {
|
||||
const runtime = makeWorkspaceRuntimeForTest({
|
||||
workspaceId: 'primary-id',
|
||||
workspaceCwd: WS_BOUND,
|
||||
primary: true,
|
||||
bridge: fakeBridge(),
|
||||
});
|
||||
const workspaceRegistry = createWorkspaceRegistry([runtime]);
|
||||
const app = createServeApp(baseOpts, undefined, {
|
||||
workspaceRegistry,
|
||||
});
|
||||
workspaceRegistry.beginReplacement(
|
||||
workspaceRegistry.primaryEntry,
|
||||
'policy-2',
|
||||
);
|
||||
workspaceRegistry.blockReplacement(
|
||||
workspaceRegistry.primaryEntry,
|
||||
'runtime build failed',
|
||||
);
|
||||
|
||||
const responses = await Promise.all([
|
||||
request(app)
|
||||
.post(`/workspace/${encodeURIComponent(WS_BOUND)}/session-groups`)
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({ name: 'Frontend', color: 'blue' }),
|
||||
request(app)
|
||||
.patch(
|
||||
`/workspace/${encodeURIComponent(
|
||||
WS_BOUND,
|
||||
)}/session-groups/missing-group`,
|
||||
)
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({ name: 'Frontend' }),
|
||||
request(app)
|
||||
.delete(
|
||||
`/workspace/${encodeURIComponent(
|
||||
WS_BOUND,
|
||||
)}/session-groups/missing-group`,
|
||||
)
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`),
|
||||
]);
|
||||
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers['retry-after']).toBe('1');
|
||||
expect(response.body.code).toBe('workspace_runtime_unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns session organization errors for invalid REST inputs', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
await writeStoredSession({
|
||||
|
|
@ -14346,12 +14400,34 @@ describe('createServeApp', () => {
|
|||
])(
|
||||
'%s the persisted branch when generation cleanup kills=%s',
|
||||
async (_label, killed, expectedRemovals) => {
|
||||
const runtimeDir = await fsp.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-branch-cleanup-'),
|
||||
);
|
||||
const staleBranchId = '550e8400-e29b-41d4-a716-446655440125';
|
||||
const chatsDir = path.join(
|
||||
new Storage(WS_BOUND, runtimeDir).getProjectDir(),
|
||||
'chats',
|
||||
);
|
||||
await fsp.mkdir(chatsDir, { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(chatsDir, `${staleBranchId}.jsonl`),
|
||||
`${JSON.stringify({
|
||||
uuid: `${staleBranchId}-user-1`,
|
||||
parentUuid: null,
|
||||
sessionId: staleBranchId,
|
||||
timestamp: '2026-07-29T00:00:00.000Z',
|
||||
type: 'user',
|
||||
message: { role: 'user', parts: [{ text: 'hello' }] },
|
||||
cwd: WS_BOUND,
|
||||
})}\n`,
|
||||
'utf8',
|
||||
);
|
||||
const generationGuard = createWorkspaceGenerationGuard();
|
||||
const bridge = fakeBridge();
|
||||
bridge.branchSession = vi.fn(async (sessionId) => {
|
||||
generationGuard.close();
|
||||
return {
|
||||
sessionId: 'stale-branch',
|
||||
sessionId: staleBranchId,
|
||||
workspaceCwd: WS_BOUND,
|
||||
attached: false,
|
||||
clientId: 'stale-client',
|
||||
|
|
@ -14369,6 +14445,7 @@ describe('createServeApp', () => {
|
|||
const runtime = makeWorkspaceRuntimeForTest({
|
||||
workspaceId: 'branch-primary',
|
||||
workspaceCwd: WS_BOUND,
|
||||
sessionRuntimeBaseDir: runtimeDir,
|
||||
primary: true,
|
||||
bridge,
|
||||
generationGuard,
|
||||
|
|
@ -14387,16 +14464,17 @@ describe('createServeApp', () => {
|
|||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.code).toBe('workspace_runtime_unavailable');
|
||||
expect(killSpy).toHaveBeenCalledWith('stale-branch', {
|
||||
expect(killSpy).toHaveBeenCalledWith(staleBranchId, {
|
||||
requireZeroAttaches: true,
|
||||
});
|
||||
expect(removeSpy).toHaveBeenCalledTimes(expectedRemovals);
|
||||
if (killed) {
|
||||
expect(removeSpy).toHaveBeenCalledWith('stale-branch');
|
||||
expect(removeSpy).toHaveBeenCalledWith(staleBranchId);
|
||||
}
|
||||
} finally {
|
||||
killSpy.mockRestore();
|
||||
removeSpy.mockRestore();
|
||||
await fsp.rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -17825,7 +17903,7 @@ describe('createServeApp', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps archive blocked while a legacy export is in flight', async () => {
|
||||
it('reports an archive conflict while a legacy export is in flight', async () => {
|
||||
const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeef';
|
||||
await writeExportSession(sid);
|
||||
let loadStarted!: () => void;
|
||||
|
|
@ -17859,10 +17937,15 @@ describe('createServeApp', () => {
|
|||
.post('/sessions/archive')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({ sessionIds: [sid] });
|
||||
expect(archive.status).toBe(409);
|
||||
expect(archive.status).toBe(200);
|
||||
expect(archive.body).toMatchObject({
|
||||
code: 'session_archiving',
|
||||
sessionId: sid,
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId: sid,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
releaseLoad();
|
||||
|
|
@ -18973,6 +19056,8 @@ describe('createServeApp', () => {
|
|||
});
|
||||
|
||||
it('returns per-id errors when removeSession throws unexpectedly', async () => {
|
||||
const sessionId = 'aaaa0000-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
await writeSession(sessionId);
|
||||
const spy = vi
|
||||
.spyOn(SessionService.prototype, 'removeSession')
|
||||
.mockRejectedValueOnce(new Error('disk on fire'));
|
||||
|
|
@ -18984,11 +19069,11 @@ describe('createServeApp', () => {
|
|||
const res = await request(app)
|
||||
.post('/sessions/delete')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({ sessionIds: ['aaaa0000-bbbb-cccc-dddd-eeeeeeeeeeee'] });
|
||||
.send({ sessionIds: [sessionId] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.errors).toEqual([
|
||||
{
|
||||
sessionId: 'aaaa0000-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
sessionId,
|
||||
error: 'disk on fire',
|
||||
},
|
||||
]);
|
||||
|
|
@ -19174,7 +19259,7 @@ describe('createServeApp', () => {
|
|||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not close a live session when no active JSONL exists', async () => {
|
||||
it('returns notFound after closing a live session with no active JSONL', async () => {
|
||||
const sid = '22222222-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
const bridge = fakeBridge();
|
||||
const app = createArchiveApp(bridge);
|
||||
|
|
@ -19191,7 +19276,13 @@ describe('createServeApp', () => {
|
|||
notFound: [sid],
|
||||
errors: [],
|
||||
});
|
||||
expect(bridge.closeCalls).toHaveLength(0);
|
||||
expect(bridge.closeCalls).toEqual([
|
||||
{
|
||||
sessionId: sid,
|
||||
clientId: undefined,
|
||||
closeOpts: { requireAgentClose: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('unarchives by moving JSONL back into active chats', async () => {
|
||||
|
|
@ -19383,7 +19474,7 @@ describe('createServeApp', () => {
|
|||
expect(archiveRes.body.archived).toEqual([sid]);
|
||||
});
|
||||
|
||||
it('returns session_archiving for archive while load is in flight', async () => {
|
||||
it('reports an archive conflict while load is in flight', async () => {
|
||||
const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
await writeSession(sid);
|
||||
let loadStarted!: () => void;
|
||||
|
|
@ -19421,12 +19512,16 @@ describe('createServeApp', () => {
|
|||
.post('/sessions/archive')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({ sessionIds: [sid] });
|
||||
expect(archiveRes.status).toBe(409);
|
||||
expect(archiveRes.status).toBe(200);
|
||||
expect(archiveRes.body).toMatchObject({
|
||||
code: 'session_archiving',
|
||||
sessionId: sid,
|
||||
archived: [],
|
||||
errors: [
|
||||
{
|
||||
sessionId: sid,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(archiveRes.body.error).toContain('being archived or unarchived');
|
||||
|
||||
releaseLoad();
|
||||
const loadRes = await loadPromise;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { Application } from 'express';
|
|||
import type { DaemonStatusProvider } from '@qwen-code/acp-bridge';
|
||||
import {
|
||||
hashDaemonWorkspace,
|
||||
Storage,
|
||||
type DurableCronTask,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { DaemonLogger } from './daemon-logger.js';
|
||||
|
|
@ -171,7 +172,10 @@ import {
|
|||
} from './server/error-handlers.js';
|
||||
import { installRateLimiter } from './server/rate-limiter-setup.js';
|
||||
import { createServeFeatures } from './server/serve-features.js';
|
||||
import { SessionArchiveCoordinator } from './server/session-archive.js';
|
||||
import {
|
||||
deleteDaemonSessionIfOrphan,
|
||||
SessionArchiveCoordinator,
|
||||
} from './server/session-archive.js';
|
||||
import { installSelfOriginStripMiddleware } from './server/self-origin.js';
|
||||
import {
|
||||
createSingleWorkspaceRegistry,
|
||||
|
|
@ -181,6 +185,10 @@ import {
|
|||
type WorkspaceRuntime,
|
||||
type WorkspaceRuntimeEnvMetadata,
|
||||
} from './workspace-registry.js';
|
||||
import {
|
||||
createWorkspaceRuntimeSessionService,
|
||||
runWithWorkspaceRuntimeStorage,
|
||||
} from './workspace-runtime-storage.js';
|
||||
import {
|
||||
isScratchRootCompatible,
|
||||
type ManagedScratchRoot,
|
||||
|
|
@ -935,6 +943,21 @@ export function createServeApp(
|
|||
defaultBridgeForAdmission = bridge;
|
||||
}
|
||||
const archiveCoordinator = new SessionArchiveCoordinator();
|
||||
(
|
||||
app.locals as {
|
||||
sessionArchiveCoordinator?: SessionArchiveCoordinator;
|
||||
}
|
||||
).sessionArchiveCoordinator = archiveCoordinator;
|
||||
|
||||
const cleanupSession = (runtime: WorkspaceRuntime, sessionId: string) =>
|
||||
runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId,
|
||||
service: createWorkspaceRuntimeSessionService(runtime),
|
||||
bridge: runtime.bridge,
|
||||
coordinator: archiveCoordinator,
|
||||
}),
|
||||
);
|
||||
|
||||
installSelfOriginStripMiddleware(app, getPort);
|
||||
|
||||
|
|
@ -1038,6 +1061,7 @@ export function createServeApp(
|
|||
{
|
||||
workspaceId: hashDaemonWorkspace(boundWorkspace),
|
||||
workspaceCwd: boundWorkspace,
|
||||
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
|
||||
primary: true,
|
||||
trusted: deps.primaryWorkspaceTrusted ?? false,
|
||||
env: primaryRuntimeEnvMetadata ?? {
|
||||
|
|
@ -1852,6 +1876,7 @@ export function createServeApp(
|
|||
workspaceRegistry.primaryEntry.state === 'active'
|
||||
? workspaceRegistry.primaryEntry.current?.runtime
|
||||
: undefined,
|
||||
cleanupSession,
|
||||
channelDeliveryAuthorizations: deps.channelDeliveryAuthorizations,
|
||||
});
|
||||
|
||||
|
|
@ -1875,6 +1900,7 @@ export function createServeApp(
|
|||
safeBody,
|
||||
manageScheduledTaskSessions: deps.manageScheduledTaskSessions === true,
|
||||
channelDeliveryAuthorizations: deps.channelDeliveryAuthorizations,
|
||||
cleanupSession,
|
||||
});
|
||||
|
||||
// Read-only token-usage dashboard (Daemon Status "统计" tab). Aggregate local
|
||||
|
|
@ -1918,28 +1944,27 @@ export function createServeApp(
|
|||
// restart (a bound task fires only in its own session, which nothing else
|
||||
// reloads). Fire-and-forget so it never delays the server coming up; a
|
||||
// no-op when there are no bound tasks. Deliberately not awaited.
|
||||
const rehydrateWorkspace = (
|
||||
taskBridge: AcpSessionBridge,
|
||||
workspaceCwd: string,
|
||||
) => {
|
||||
void rehydrateScheduledTaskSessions({
|
||||
bridge: taskBridge,
|
||||
boundWorkspace: workspaceCwd,
|
||||
onTasksRead: (tasks) =>
|
||||
registerScheduledTaskAuthorizations(workspaceCwd, tasks),
|
||||
onError: (sessionId, err) => {
|
||||
process.stderr.write(
|
||||
`qwen serve: failed to rehydrate scheduled-task session ${sessionId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}\n`,
|
||||
);
|
||||
},
|
||||
// Outer catch is defense-in-depth: rehydrateScheduledTaskSessions already
|
||||
// catches readCronTasks failures and per-session load errors internally
|
||||
// (returning { loaded, failed }), so this only guards an unexpected throw
|
||||
// from the function entry itself. Log rather than swallow it — a silent
|
||||
// failure here leaves every bound task dormant with no diagnostic.
|
||||
}).catch((err) => {
|
||||
const rehydrateWorkspace = (runtime: WorkspaceRuntime) => {
|
||||
void runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
rehydrateScheduledTaskSessions({
|
||||
bridge: runtime.bridge,
|
||||
boundWorkspace: runtime.workspaceCwd,
|
||||
onTasksRead: (tasks) =>
|
||||
registerScheduledTaskAuthorizations(runtime.workspaceCwd, tasks),
|
||||
onError: (sessionId, err) => {
|
||||
process.stderr.write(
|
||||
`qwen serve: failed to rehydrate scheduled-task session ${sessionId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}\n`,
|
||||
);
|
||||
},
|
||||
// Outer catch is defense-in-depth: rehydrateScheduledTaskSessions already
|
||||
// catches readCronTasks failures and per-session load errors internally
|
||||
// (returning { loaded, failed }), so this only guards an unexpected throw
|
||||
// from the function entry itself. Log rather than swallow it — a silent
|
||||
// failure here leaves every bound task dormant with no diagnostic.
|
||||
}),
|
||||
).catch((err) => {
|
||||
process.stderr.write(
|
||||
`qwen serve: unexpected scheduled-task rehydration failure: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
|
|
@ -1961,10 +1986,12 @@ export function createServeApp(
|
|||
bridge: runtime.bridge,
|
||||
boundWorkspace: runtime.workspaceCwd,
|
||||
intervalMs: keepaliveIntervalMs,
|
||||
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
|
||||
cleanupSession: (sessionId) => cleanupSession(runtime, sessionId),
|
||||
onTasksRead: (tasks) =>
|
||||
registerScheduledTaskAuthorizations(runtime.workspaceCwd, tasks),
|
||||
});
|
||||
rehydrateWorkspace(runtime.bridge, runtime.workspaceCwd);
|
||||
rehydrateWorkspace(runtime);
|
||||
keepaliveStops.set(runtime.workspaceCwd, keepalive.stop);
|
||||
};
|
||||
for (const runtime of workspaceRegistry.list()) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
SessionWriterUnavailableError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { sendBridgeError } from './error-response.js';
|
||||
import { DaemonDrainingError } from './session-archive.js';
|
||||
|
||||
function responseMock(): {
|
||||
response: Response;
|
||||
|
|
@ -28,6 +29,20 @@ function responseMock(): {
|
|||
}
|
||||
|
||||
describe('sendBridgeError session writer errors', () => {
|
||||
it('maps sealed session maintenance to daemon_draining', () => {
|
||||
const { response, status, json } = responseMock();
|
||||
|
||||
sendBridgeError(response, new DaemonDrainingError());
|
||||
|
||||
expect(status).toHaveBeenCalledWith(503);
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
error:
|
||||
'The daemon is draining and no longer accepts session maintenance.',
|
||||
code: 'daemon_draining',
|
||||
errorKind: 'daemon_draining',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
error: new SessionWriterConflictError(),
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import {
|
|||
WorkspaceSkillNotToggleableError,
|
||||
} from '../workspace-service/types.js';
|
||||
import { sendGenerationClosedError } from '../workspace-route-runtime.js';
|
||||
import { DaemonDrainingError } from './session-archive.js';
|
||||
|
||||
export type BridgeErrorContext = {
|
||||
route?: string;
|
||||
|
|
@ -169,6 +170,14 @@ export function sendBridgeError(
|
|||
ctx?: BridgeErrorContext,
|
||||
daemonLog?: DaemonLogger,
|
||||
): void {
|
||||
if (err instanceof DaemonDrainingError) {
|
||||
res.status(503).json({
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
errorKind: err.code,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (sendGenerationClosedError(res, err)) return;
|
||||
if (err instanceof SessionWriterError) {
|
||||
res.status(err.httpStatus).json({
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import path from 'node:path';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
SessionService,
|
||||
SessionWriterConflictError,
|
||||
SessionWriterLostError,
|
||||
type SessionWriterLease,
|
||||
Storage,
|
||||
getCronFilePath,
|
||||
readCronTasks,
|
||||
updateCronTasks,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -25,9 +29,11 @@ import {
|
|||
archiveDaemonSessions,
|
||||
assertSessionArchived,
|
||||
assertSessionLoadable,
|
||||
deleteDaemonSessionIfOrphan,
|
||||
deleteDaemonSessions,
|
||||
SessionArchiveCoordinator,
|
||||
unarchiveDaemonSessions,
|
||||
DaemonDrainingError,
|
||||
} from './session-archive.js';
|
||||
|
||||
describe('assertSessionLoadable', () => {
|
||||
|
|
@ -188,6 +194,47 @@ describe('SessionArchiveCoordinator', () => {
|
|||
coordinator.runExclusiveMany([sessionId], async () => 'ok'),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('seals new maintenance and waits only for admitted exclusive work', async () => {
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
let finish!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const maintenance = coordinator.runExclusiveMany(['session-a'], () => gate);
|
||||
const drain = coordinator.sealMaintenanceAndWait();
|
||||
|
||||
await expect(
|
||||
coordinator.runExclusiveMany(['session-b'], async () => undefined),
|
||||
).rejects.toMatchObject({ code: 'daemon_draining' });
|
||||
let drained = false;
|
||||
void drain.then(() => {
|
||||
drained = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(drained).toBe(false);
|
||||
|
||||
finish();
|
||||
await maintenance;
|
||||
await drain;
|
||||
expect(drained).toBe(true);
|
||||
});
|
||||
|
||||
it('does not wait for shared transcript reads when sealed', async () => {
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
let finish!: () => void;
|
||||
const shared = coordinator.runSharedMany(
|
||||
['session-a'],
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(coordinator.sealMaintenanceAndWait()).resolves.toBeUndefined();
|
||||
finish();
|
||||
await shared;
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveDaemonSessions', () => {
|
||||
|
|
@ -273,30 +320,354 @@ describe('archiveDaemonSessions', () => {
|
|||
expect(byId['other']!.enabled).toBeUndefined(); // unrelated — untouched
|
||||
});
|
||||
|
||||
it('does not lock ids that are already archived or missing', async () => {
|
||||
it('does not acquire writer leases for ids already archived or missing', async () => {
|
||||
const archivedId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
const missingId = '550e8400-e29b-41d4-a716-446655440004';
|
||||
writeSessionFile(workspaceDir, archivedId, 'archived');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const closeSession = vi.fn().mockResolvedValue(undefined);
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
|
||||
|
||||
await coordinator.runSharedMany([archivedId, missingId], async () => {
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [archivedId, missingId],
|
||||
service,
|
||||
bridge: { closeSession },
|
||||
coordinator,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
archived: [],
|
||||
alreadyArchived: [archivedId],
|
||||
notFound: [missingId],
|
||||
errors: [],
|
||||
});
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [archivedId, missingId],
|
||||
service,
|
||||
bridge: { closeSession },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(closeSession).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({
|
||||
archived: [],
|
||||
alreadyArchived: [archivedId],
|
||||
notFound: [missingId],
|
||||
errors: [],
|
||||
});
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect(closeSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not archive while another writer holds the lease', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440005';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const lease = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
|
||||
const blocked = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(blocked.archived).toEqual([]);
|
||||
expect(blocked.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await lease.release();
|
||||
const retried = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(retried.archived).toEqual([sessionId]);
|
||||
});
|
||||
|
||||
it('keeps independent batch sessions moving when one writer conflicts', async () => {
|
||||
const blockedId = '550e8400-e29b-41d4-a716-446655440008';
|
||||
const availableId = '550e8400-e29b-41d4-a716-446655440009';
|
||||
writeSessionFile(workspaceDir, blockedId, 'active');
|
||||
writeSessionFile(workspaceDir, availableId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const lease = await service.acquireSessionWriterLease(blockedId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [blockedId, availableId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([availableId]);
|
||||
expect(result.errors[0]?.sessionId).toBe(blockedId);
|
||||
expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError);
|
||||
await lease.release();
|
||||
});
|
||||
|
||||
it('reports a gate race per session after another batch item was archived', async () => {
|
||||
const archivedId = '550e8400-e29b-41d4-a716-446655440023';
|
||||
const blockedId = '550e8400-e29b-41d4-a716-446655440024';
|
||||
writeSessionFile(workspaceDir, archivedId, 'active');
|
||||
writeSessionFile(workspaceDir, blockedId, 'active');
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
let releaseBlocked!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
releaseBlocked = resolve;
|
||||
});
|
||||
let competingMaintenance: Promise<void> | undefined;
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [archivedId, blockedId],
|
||||
service: new SessionService(workspaceDir),
|
||||
bridge: {
|
||||
closeSession: vi.fn(async (sessionId) => {
|
||||
if (sessionId === archivedId) {
|
||||
competingMaintenance = coordinator.runExclusiveMany(
|
||||
[blockedId],
|
||||
() => blocked,
|
||||
);
|
||||
}
|
||||
}),
|
||||
},
|
||||
coordinator,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(result.archived).toEqual([archivedId]);
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
sessionId: blockedId,
|
||||
error: expect.any(SessionArchivingError),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
releaseBlocked();
|
||||
await competingMaintenance;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps independent batch sessions moving when one classification fails', async () => {
|
||||
const failedId = '550e8400-e29b-41d4-a716-446655440019';
|
||||
const availableId = '550e8400-e29b-41d4-a716-446655440020';
|
||||
writeSessionFile(workspaceDir, availableId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const getLocation = service.getSessionLocation.bind(service);
|
||||
const failure = new Error('classification failed');
|
||||
vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) =>
|
||||
sessionId === failedId ? Promise.reject(failure) : getLocation(sessionId),
|
||||
);
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [failedId, availableId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([availableId]);
|
||||
expect(result.errors).toEqual([{ sessionId: failedId, error: failure }]);
|
||||
});
|
||||
|
||||
it('does not acquire a lease or mutate when closing the owner fails', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440017';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
|
||||
const closeError = new Error('agent flush failed');
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockRejectedValue(closeError) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([]);
|
||||
expect(result.errors).toEqual([{ sessionId, error: closeError }]);
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the classification made after acquiring the lease', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440010';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const originalGetLocation = service.getSessionLocation.bind(service);
|
||||
let classifications = 0;
|
||||
vi.spyOn(service, 'getSessionLocation').mockImplementation(async (id) => {
|
||||
classifications++;
|
||||
if (classifications === 2) {
|
||||
fs.mkdirSync(path.dirname(sessionPath(workspaceDir, id, 'archived')), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.renameSync(
|
||||
sessionPath(workspaceDir, id, 'active'),
|
||||
sessionPath(workspaceDir, id, 'archived'),
|
||||
);
|
||||
}
|
||||
return originalGetLocation(id);
|
||||
});
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
archived: [],
|
||||
alreadyArchived: [sessionId],
|
||||
notFound: [],
|
||||
errors: [],
|
||||
});
|
||||
const reacquired = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
await reacquired.release();
|
||||
});
|
||||
|
||||
it('does not lock an active/archive conflict', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440016';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
writeSessionFile(workspaceDir, sessionId, 'archived');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([]);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not report success after release fails but reconciles the task to the applied archive', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440006';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
await updateCronTasks(workspaceDir, () => [
|
||||
{
|
||||
id: 'bound',
|
||||
cron: '0 9 * * *',
|
||||
prompt: 'p',
|
||||
recurring: true,
|
||||
createdAt: 1_700_000_000_000,
|
||||
lastFiredAt: null,
|
||||
sessionId,
|
||||
},
|
||||
]);
|
||||
const service = new SessionService(workspaceDir);
|
||||
const release = vi.fn(async () => {
|
||||
expect((await readCronTasks(workspaceDir))[0]?.enabled).toBe(false);
|
||||
throw new SessionWriterLostError();
|
||||
});
|
||||
vi.spyOn(service, 'acquireSessionWriterLease').mockResolvedValue({
|
||||
assertOwnedAndUnchanged: vi.fn().mockResolvedValue(undefined),
|
||||
release,
|
||||
} as unknown as SessionWriterLease);
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([]);
|
||||
expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterLostError);
|
||||
expect(
|
||||
fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')),
|
||||
).toBe(true);
|
||||
expect((await readCronTasks(workspaceDir))[0]?.enabled).toBe(false);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('releases the lease when scheduled-task reconciliation fails', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440018';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
fs.mkdirSync(getCronFilePath(workspaceDir), { recursive: true });
|
||||
const service = new SessionService(workspaceDir);
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([sessionId]);
|
||||
const reacquired = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
await reacquired.release();
|
||||
});
|
||||
|
||||
it('checks only the selected runtime root for transcripts and locks', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440007';
|
||||
const primaryRuntime = path.join(runtimeDir, 'primary');
|
||||
const secondaryRuntime = path.join(runtimeDir, 'secondary');
|
||||
writeSessionFile(
|
||||
workspaceDir,
|
||||
sessionId,
|
||||
'active',
|
||||
workspaceDir,
|
||||
secondaryRuntime,
|
||||
);
|
||||
const primaryService = new SessionService(workspaceDir, {
|
||||
runtimeBaseDir: primaryRuntime,
|
||||
});
|
||||
const primaryLease = await primaryService.acquireSessionWriterLease(
|
||||
sessionId,
|
||||
{
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
},
|
||||
);
|
||||
|
||||
const result = await archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service: new SessionService(workspaceDir, {
|
||||
runtimeBaseDir: secondaryRuntime,
|
||||
}),
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.archived).toEqual([sessionId]);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
sessionPath(workspaceDir, sessionId, 'archived', secondaryRuntime),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
sessionPath(workspaceDir, sessionId, 'active', primaryRuntime),
|
||||
),
|
||||
).toBe(false);
|
||||
await primaryLease.release();
|
||||
});
|
||||
|
||||
it('rejects with DaemonDrainingError after the coordinator is sealed', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440080';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
await coordinator.sealMaintenanceAndWait();
|
||||
|
||||
await expect(
|
||||
archiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service: new SessionService(workspaceDir),
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator,
|
||||
}),
|
||||
).rejects.toThrow(DaemonDrainingError);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -324,22 +695,19 @@ describe('unarchiveDaemonSessions', () => {
|
|||
writeSessionFile(workspaceDir, archivedId, 'archived');
|
||||
writeSessionFile(workspaceDir, activeId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
|
||||
await coordinator.runSharedMany([activeId, missingId], async () => {
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: [archivedId, activeId, missingId, archivedId],
|
||||
service,
|
||||
coordinator,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
unarchived: [archivedId],
|
||||
alreadyActive: [activeId],
|
||||
notFound: [missingId],
|
||||
errors: [],
|
||||
});
|
||||
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: [archivedId, activeId, missingId, archivedId],
|
||||
service,
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
unarchived: [archivedId],
|
||||
alreadyActive: [activeId],
|
||||
notFound: [missingId],
|
||||
errors: [],
|
||||
});
|
||||
expect(acquire).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, archivedId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
|
@ -348,6 +716,29 @@ describe('unarchiveDaemonSessions', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not unarchive while another writer holds the lease', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440015';
|
||||
writeSessionFile(workspaceDir, sessionId, 'archived');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const lease = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(result.unarchived).toEqual([]);
|
||||
expect(result.errors[0]?.error).toBeInstanceOf(SessionWriterConflictError);
|
||||
expect(
|
||||
fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')),
|
||||
).toBe(true);
|
||||
|
||||
await lease.release();
|
||||
});
|
||||
|
||||
it('reports a single error per archived id when unarchive batch fails', async () => {
|
||||
const archivedId = '550e8400-e29b-41d4-a716-446655440014';
|
||||
writeSessionFile(workspaceDir, archivedId, 'archived');
|
||||
|
|
@ -367,6 +758,75 @@ describe('unarchiveDaemonSessions', () => {
|
|||
notFound: [],
|
||||
errors: [{ sessionId: archivedId, error: failure }],
|
||||
});
|
||||
const reacquired = await service.acquireSessionWriterLease(archivedId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
await reacquired.release();
|
||||
});
|
||||
|
||||
it('keeps independent unarchive sessions moving when one classification fails', async () => {
|
||||
const failedId = '550e8400-e29b-41d4-a716-446655440021';
|
||||
const availableId = '550e8400-e29b-41d4-a716-446655440022';
|
||||
writeSessionFile(workspaceDir, availableId, 'archived');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const getLocation = service.getSessionLocation.bind(service);
|
||||
const failure = new Error('classification failed');
|
||||
vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) =>
|
||||
sessionId === failedId ? Promise.reject(failure) : getLocation(sessionId),
|
||||
);
|
||||
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: [failedId, availableId],
|
||||
service,
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
|
||||
expect(result.unarchived).toEqual([availableId]);
|
||||
expect(result.errors).toEqual([{ sessionId: failedId, error: failure }]);
|
||||
});
|
||||
|
||||
it('reports a gate race per session after another batch item was unarchived', async () => {
|
||||
const unarchivedId = '550e8400-e29b-41d4-a716-446655440025';
|
||||
const blockedId = '550e8400-e29b-41d4-a716-446655440026';
|
||||
writeSessionFile(workspaceDir, unarchivedId, 'archived');
|
||||
writeSessionFile(workspaceDir, blockedId, 'archived');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const getLocation = service.getSessionLocation.bind(service);
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
let releaseBlocked!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
releaseBlocked = resolve;
|
||||
});
|
||||
let competingMaintenance: Promise<void> | undefined;
|
||||
vi.spyOn(service, 'getSessionLocation').mockImplementation((sessionId) => {
|
||||
if (sessionId === unarchivedId && !competingMaintenance) {
|
||||
competingMaintenance = coordinator.runExclusiveMany(
|
||||
[blockedId],
|
||||
() => blocked,
|
||||
);
|
||||
}
|
||||
return getLocation(sessionId);
|
||||
});
|
||||
|
||||
const result = await unarchiveDaemonSessions({
|
||||
sessionIds: [unarchivedId, blockedId],
|
||||
service,
|
||||
coordinator,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(result.unarchived).toEqual([unarchivedId]);
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
sessionId: blockedId,
|
||||
error: expect.any(SessionArchivingError),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
releaseBlocked();
|
||||
await competingMaintenance;
|
||||
}
|
||||
});
|
||||
|
||||
it('re-enables an archive-disabled task bound to the unarchived session', async () => {
|
||||
|
|
@ -434,6 +894,24 @@ describe('unarchiveDaemonSessions', () => {
|
|||
expect(stranded!.enabled).toBe(true); // recovered
|
||||
expect(stranded!.disabledByArchive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects with DaemonDrainingError after the coordinator is sealed', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440081';
|
||||
writeSessionFile(workspaceDir, sessionId, 'archived');
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
await coordinator.sealMaintenanceAndWait();
|
||||
|
||||
await expect(
|
||||
unarchiveDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service: new SessionService(workspaceDir),
|
||||
coordinator,
|
||||
}),
|
||||
).rejects.toThrow(DaemonDrainingError);
|
||||
expect(
|
||||
fs.existsSync(sessionPath(workspaceDir, sessionId, 'archived')),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDaemonSessions', () => {
|
||||
|
|
@ -487,6 +965,186 @@ describe('deleteDaemonSessions', () => {
|
|||
const ids = (await readCronTasks(workspaceDir)).map((t) => t.id).sort();
|
||||
expect(ids).toEqual(['other']); // bound task deleted, unbound survives
|
||||
});
|
||||
|
||||
it('does not delete while another writer holds the lease', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440071';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const lease = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
|
||||
const result = await deleteDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service,
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
});
|
||||
expect(result.removed).toEqual([]);
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
sessionId,
|
||||
error: 'This session is already open in another Qwen process.',
|
||||
},
|
||||
]);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await lease.release();
|
||||
});
|
||||
|
||||
it('reports a gate race per session after another batch item was deleted', async () => {
|
||||
const removedId = '550e8400-e29b-41d4-a716-446655440073';
|
||||
const blockedId = '550e8400-e29b-41d4-a716-446655440074';
|
||||
writeSessionFile(workspaceDir, removedId, 'active');
|
||||
writeSessionFile(workspaceDir, blockedId, 'active');
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
let releaseBlocked!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
releaseBlocked = resolve;
|
||||
});
|
||||
let competingMaintenance: Promise<void> | undefined;
|
||||
|
||||
try {
|
||||
const result = await deleteDaemonSessions({
|
||||
sessionIds: [removedId, blockedId],
|
||||
service: new SessionService(workspaceDir),
|
||||
bridge: {
|
||||
closeSession: vi.fn(async (sessionId) => {
|
||||
if (sessionId === removedId) {
|
||||
competingMaintenance = coordinator.runExclusiveMany(
|
||||
[blockedId],
|
||||
() => blocked,
|
||||
);
|
||||
}
|
||||
}),
|
||||
},
|
||||
coordinator,
|
||||
});
|
||||
|
||||
expect(result.removed).toEqual([removedId]);
|
||||
expect(result.errors).toEqual([
|
||||
{
|
||||
sessionId: blockedId,
|
||||
error: expect.stringContaining('is being archived or unarchived'),
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
fs.existsSync(sessionPath(workspaceDir, removedId, 'active')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
fs.existsSync(sessionPath(workspaceDir, blockedId, 'active')),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
releaseBlocked();
|
||||
await competingMaintenance;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips orphan deletion when a new owner attached', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440072';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const acquire = vi.spyOn(service, 'acquireSessionWriterLease');
|
||||
|
||||
await expect(
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId,
|
||||
service,
|
||||
bridge: { killSession: vi.fn().mockResolvedValue(false) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects with DaemonDrainingError after the coordinator is sealed', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440082';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const coordinator = new SessionArchiveCoordinator();
|
||||
await coordinator.sealMaintenanceAndWait();
|
||||
|
||||
await expect(
|
||||
deleteDaemonSessions({
|
||||
sessionIds: [sessionId],
|
||||
service: new SessionService(workspaceDir),
|
||||
bridge: { closeSession: vi.fn().mockResolvedValue(undefined) },
|
||||
coordinator,
|
||||
}),
|
||||
).rejects.toThrow(DaemonDrainingError);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the transcript when killSession resolves true', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440083';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
|
||||
await expect(
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId,
|
||||
service,
|
||||
bridge: { killSession: vi.fn().mockResolvedValue(true) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the transcript when killSession throws SessionNotFoundError', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440084';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
|
||||
await expect(
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId,
|
||||
service,
|
||||
bridge: {
|
||||
killSession: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new SessionNotFoundError(sessionId)),
|
||||
},
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the lease is held by another writer', async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440085';
|
||||
writeSessionFile(workspaceDir, sessionId, 'active');
|
||||
const service = new SessionService(workspaceDir);
|
||||
const lease = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteDaemonSessionIfOrphan({
|
||||
sessionId,
|
||||
service,
|
||||
bridge: { killSession: vi.fn().mockResolvedValue(true) },
|
||||
coordinator: new SessionArchiveCoordinator(),
|
||||
}),
|
||||
).rejects.toThrow(SessionWriterConflictError);
|
||||
expect(fs.existsSync(sessionPath(workspaceDir, sessionId, 'active'))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await lease.release();
|
||||
});
|
||||
});
|
||||
|
||||
function writeSessionFile(
|
||||
|
|
@ -494,9 +1152,10 @@ function writeSessionFile(
|
|||
sessionId: string,
|
||||
state: 'active' | 'archived',
|
||||
recordCwd = workspaceDir,
|
||||
runtimeBaseDir?: string,
|
||||
): void {
|
||||
const chatsDir = path.join(
|
||||
new Storage(workspaceDir).getProjectDir(),
|
||||
new Storage(workspaceDir, runtimeBaseDir).getProjectDir(),
|
||||
'chats',
|
||||
);
|
||||
const targetDir =
|
||||
|
|
@ -521,9 +1180,10 @@ function sessionPath(
|
|||
workspaceDir: string,
|
||||
sessionId: string,
|
||||
state: 'active' | 'archived',
|
||||
runtimeBaseDir?: string,
|
||||
): string {
|
||||
const chatsDir = path.join(
|
||||
new Storage(workspaceDir).getProjectDir(),
|
||||
new Storage(workspaceDir, runtimeBaseDir).getProjectDir(),
|
||||
'chats',
|
||||
);
|
||||
return path.join(
|
||||
|
|
|
|||
|
|
@ -46,9 +46,23 @@ export interface DaemonDeleteSessionsResult {
|
|||
|
||||
export type DaemonDeleteErrorPhase = 'close' | 'remove' | 'delete';
|
||||
|
||||
export class DaemonDrainingError extends Error {
|
||||
override readonly name = 'DaemonDrainingError';
|
||||
readonly code = 'daemon_draining';
|
||||
|
||||
constructor() {
|
||||
super('The daemon is draining and no longer accepts session maintenance.');
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionArchiveCoordinator {
|
||||
private readonly exclusive = new Set<string>();
|
||||
private readonly shared = new Map<string, number>();
|
||||
private maintenanceSealed = false;
|
||||
private activeMaintenance = 0;
|
||||
private maintenanceDrain:
|
||||
| { promise: Promise<void>; resolve: () => void }
|
||||
| undefined;
|
||||
|
||||
assertNotTransitioning(sessionId: string): void {
|
||||
if (this.exclusive.has(sessionId)) {
|
||||
|
|
@ -60,6 +74,9 @@ export class SessionArchiveCoordinator {
|
|||
sessionIds: string[],
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (this.maintenanceSealed) {
|
||||
throw new DaemonDrainingError();
|
||||
}
|
||||
const uniqueSessionIds = [...new Set(sessionIds)];
|
||||
for (const sessionId of uniqueSessionIds) {
|
||||
this.assertNotTransitioning(sessionId);
|
||||
|
|
@ -70,15 +87,36 @@ export class SessionArchiveCoordinator {
|
|||
for (const sessionId of uniqueSessionIds) {
|
||||
this.exclusive.add(sessionId);
|
||||
}
|
||||
this.activeMaintenance++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (const sessionId of uniqueSessionIds) {
|
||||
this.exclusive.delete(sessionId);
|
||||
}
|
||||
this.activeMaintenance--;
|
||||
if (this.activeMaintenance === 0) {
|
||||
this.maintenanceDrain?.resolve();
|
||||
this.maintenanceDrain = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealMaintenanceAndWait(): Promise<void> {
|
||||
this.maintenanceSealed = true;
|
||||
if (this.activeMaintenance === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!this.maintenanceDrain) {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
this.maintenanceDrain = { promise, resolve };
|
||||
}
|
||||
return this.maintenanceDrain.promise;
|
||||
}
|
||||
|
||||
async runSharedMany<T>(
|
||||
sessionIds: string[],
|
||||
fn: () => Promise<T>,
|
||||
|
|
@ -105,6 +143,225 @@ export class SessionArchiveCoordinator {
|
|||
}
|
||||
}
|
||||
|
||||
type DaemonMaintenanceAction = 'delete' | 'archive' | 'unarchive';
|
||||
|
||||
interface LeaseMutationResult<T> {
|
||||
value?: T;
|
||||
mutationApplied: boolean;
|
||||
error?: unknown;
|
||||
maintenanceError?: unknown;
|
||||
}
|
||||
|
||||
async function runWithDaemonWriterLease<T>(params: {
|
||||
action: DaemonMaintenanceAction;
|
||||
sessionId: string;
|
||||
service: SessionService;
|
||||
mutate: (
|
||||
assertOwnedAndUnchanged: () => Promise<void>,
|
||||
) => Promise<{ value: T; mutationApplied: boolean }>;
|
||||
mutationAppliedAfterError: () => Promise<boolean>;
|
||||
afterMutationApplied: () => Promise<void>;
|
||||
}): Promise<LeaseMutationResult<T>> {
|
||||
const {
|
||||
action,
|
||||
sessionId,
|
||||
service,
|
||||
mutate,
|
||||
mutationAppliedAfterError,
|
||||
afterMutationApplied,
|
||||
} = params;
|
||||
let lease;
|
||||
try {
|
||||
lease = await service.acquireSessionWriterLease(sessionId, {
|
||||
processKind: 'daemon',
|
||||
reclaimPolicy: 'never',
|
||||
});
|
||||
} catch (error) {
|
||||
return { mutationApplied: false, error };
|
||||
}
|
||||
|
||||
let value: T | undefined;
|
||||
let mutationApplied = false;
|
||||
let mutationError: unknown;
|
||||
try {
|
||||
const mutation = await mutate(() => lease.assertOwnedAndUnchanged());
|
||||
value = mutation.value;
|
||||
mutationApplied = mutation.mutationApplied;
|
||||
} catch (error) {
|
||||
mutationError = error;
|
||||
try {
|
||||
mutationApplied = await mutationAppliedAfterError();
|
||||
} catch {
|
||||
mutationApplied = false;
|
||||
}
|
||||
}
|
||||
|
||||
let maintenanceError: unknown;
|
||||
if (mutationApplied) {
|
||||
try {
|
||||
await afterMutationApplied();
|
||||
} catch (error) {
|
||||
maintenanceError = error;
|
||||
logSessionArchiveWarning(
|
||||
`scheduled task lifecycle update failed action=${action} workspace=${safeLogValue(
|
||||
service.getProjectRoot(),
|
||||
)} session=${safeLogValue(sessionId)} error=${safeLogValue(
|
||||
errorMessage(error),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let releaseError: unknown;
|
||||
try {
|
||||
await lease.release();
|
||||
} catch (error) {
|
||||
releaseError = error;
|
||||
}
|
||||
|
||||
if (releaseError !== undefined) {
|
||||
logMaintenanceLeaseReleaseFailure({
|
||||
action,
|
||||
workspace: service.getProjectRoot(),
|
||||
sessionId,
|
||||
error: releaseError,
|
||||
mutationApplied,
|
||||
});
|
||||
if (mutationError !== undefined) {
|
||||
logSessionArchiveWarning(
|
||||
`session maintenance mutation also failed action=${action} workspace=${safeLogValue(
|
||||
service.getProjectRoot(),
|
||||
)} session=${safeLogValue(sessionId)} error=${safeLogValue(
|
||||
errorMessage(mutationError),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return { mutationApplied, error: releaseError, maintenanceError };
|
||||
}
|
||||
if (mutationError !== undefined) {
|
||||
return { mutationApplied, error: mutationError, maintenanceError };
|
||||
}
|
||||
return { value, mutationApplied, maintenanceError };
|
||||
}
|
||||
|
||||
function logMaintenanceLeaseReleaseFailure(params: {
|
||||
action: DaemonMaintenanceAction;
|
||||
workspace: string;
|
||||
sessionId: string;
|
||||
error: unknown;
|
||||
mutationApplied: boolean;
|
||||
}): void {
|
||||
const errorKind =
|
||||
typeof params.error === 'object' &&
|
||||
params.error !== null &&
|
||||
typeof (params.error as { errorKind?: unknown }).errorKind === 'string'
|
||||
? (params.error as { errorKind: string }).errorKind
|
||||
: 'unknown';
|
||||
logSessionArchiveWarning(
|
||||
`session maintenance lease release failed action=${params.action} workspace=${safeLogValue(
|
||||
params.workspace,
|
||||
)} session=${safeLogValue(params.sessionId)} errorKind=${safeLogValue(
|
||||
errorKind,
|
||||
)} mutationApplied=${params.mutationApplied}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function classifySessionLocation(
|
||||
service: SessionService,
|
||||
sessionId: string,
|
||||
): Promise<SessionLocation> {
|
||||
return service.getSessionLocation(sessionId);
|
||||
}
|
||||
|
||||
function sessionLocationError(sessionId: string): Error {
|
||||
return new Error(`Session archive conflict: ${sessionId}`);
|
||||
}
|
||||
|
||||
function updateScheduledTaskForMaintenance(
|
||||
service: SessionService,
|
||||
sessionId: string,
|
||||
action: DaemonMaintenanceAction,
|
||||
): Promise<void> {
|
||||
if (action === 'archive') {
|
||||
return disableTasksForSessions(service.getProjectRoot(), [sessionId]);
|
||||
}
|
||||
if (action === 'unarchive') {
|
||||
return enableTasksForSessions(service.getProjectRoot(), [sessionId]);
|
||||
}
|
||||
return removeTasksForSessions(service.getProjectRoot(), [sessionId]);
|
||||
}
|
||||
|
||||
type DeleteOneResult =
|
||||
| {
|
||||
kind: 'removed';
|
||||
mutationApplied: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'notFound';
|
||||
mutationApplied: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
error: unknown;
|
||||
mutationApplied: boolean;
|
||||
};
|
||||
|
||||
async function deletePersistedSessionWithLease(
|
||||
service: SessionService,
|
||||
sessionId: string,
|
||||
): Promise<DeleteOneResult> {
|
||||
const initialLocation = await classifySessionLocation(service, sessionId);
|
||||
if (initialLocation === undefined) {
|
||||
return { kind: 'notFound', mutationApplied: false };
|
||||
}
|
||||
if (initialLocation === 'conflict') {
|
||||
return {
|
||||
kind: 'error',
|
||||
error: sessionLocationError(sessionId),
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
|
||||
const mutation = await runWithDaemonWriterLease({
|
||||
action: 'delete',
|
||||
sessionId,
|
||||
service,
|
||||
mutate: async (assertOwnedAndUnchanged) => {
|
||||
const lockedLocation = await classifySessionLocation(service, sessionId);
|
||||
if (lockedLocation === undefined) {
|
||||
return {
|
||||
value: 'notFound' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (lockedLocation === 'conflict') {
|
||||
throw sessionLocationError(sessionId);
|
||||
}
|
||||
await assertOwnedAndUnchanged();
|
||||
const removed = await service.removeSession(sessionId);
|
||||
return {
|
||||
value: removed ? ('removed' as const) : ('notFound' as const),
|
||||
mutationApplied: removed,
|
||||
};
|
||||
},
|
||||
mutationAppliedAfterError: async () =>
|
||||
(await classifySessionLocation(service, sessionId)) === undefined,
|
||||
afterMutationApplied: () =>
|
||||
updateScheduledTaskForMaintenance(service, sessionId, 'delete'),
|
||||
});
|
||||
if (mutation.error !== undefined) {
|
||||
return {
|
||||
kind: 'error',
|
||||
error: mutation.error,
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: mutation.value ?? 'notFound',
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteDaemonSessions(params: {
|
||||
sessionIds: string[];
|
||||
service: SessionService;
|
||||
|
|
@ -118,98 +375,131 @@ export async function deleteDaemonSessions(params: {
|
|||
}): Promise<DaemonDeleteSessionsResult> {
|
||||
const { sessionIds, service, bridge, coordinator, onError } = params;
|
||||
const uniqueSessionIds = [...new Set(sessionIds)];
|
||||
const closeErrors: Array<{ sessionId: string; error: string }> = [];
|
||||
const removed: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
const removeErrors: Array<{ sessionId: string; error: string }> = [];
|
||||
|
||||
for (const sessionId of uniqueSessionIds) {
|
||||
coordinator.assertNotTransitioning(sessionId);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
const results = await Promise.all(
|
||||
uniqueSessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
// Keep close+remove under one gate so load/resume cannot recreate the
|
||||
// same live session between bridge close and transcript deletion.
|
||||
await coordinator.runExclusiveMany([sessionId], async () => {
|
||||
let shouldRemove = false;
|
||||
return await coordinator.runExclusiveMany([sessionId], async () => {
|
||||
try {
|
||||
// Intentional: batch delete bypasses per-tab ownership.
|
||||
await bridge.closeSession(sessionId);
|
||||
shouldRemove = true;
|
||||
} catch (closeErr) {
|
||||
if (
|
||||
closeErr instanceof SessionNotFoundError ||
|
||||
(closeErr instanceof Error &&
|
||||
closeErr.name === 'SessionNotFoundError')
|
||||
) {
|
||||
shouldRemove = true;
|
||||
} else {
|
||||
const message =
|
||||
closeErr instanceof Error ? closeErr.message : String(closeErr);
|
||||
onError?.({ phase: 'close', sessionId, error: message });
|
||||
closeErrors.push({ sessionId, error: message });
|
||||
} catch (error) {
|
||||
if (isSessionNotFoundError(error)) {
|
||||
const result = await deletePersistedSessionWithLease(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (result.kind === 'error') {
|
||||
onError?.({
|
||||
phase: 'remove',
|
||||
sessionId,
|
||||
error: errorMessage(result.error),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
onError?.({
|
||||
phase: 'close',
|
||||
sessionId,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldRemove) return;
|
||||
|
||||
try {
|
||||
if (await service.removeSession(sessionId)) {
|
||||
removed.push(sessionId);
|
||||
} else {
|
||||
notFound.push(sessionId);
|
||||
}
|
||||
} catch (removeErr) {
|
||||
const message =
|
||||
removeErr instanceof Error
|
||||
? removeErr.message
|
||||
: String(removeErr);
|
||||
onError?.({ phase: 'remove', sessionId, error: message });
|
||||
removeErrors.push({ sessionId, error: message });
|
||||
const result = await deletePersistedSessionWithLease(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (result.kind === 'error') {
|
||||
onError?.({
|
||||
phase: 'remove',
|
||||
sessionId,
|
||||
error: errorMessage(result.error),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof SessionArchivingError &&
|
||||
err.lockKind === 'exclusive'
|
||||
) {
|
||||
throw err;
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonDrainingError) {
|
||||
throw error;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
onError?.({ phase: 'delete', sessionId, error: message });
|
||||
closeErrors.push({ sessionId, error: message });
|
||||
onError?.({
|
||||
phase: 'delete',
|
||||
sessionId,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Deleting a session permanently removes any scheduled task bound to it —
|
||||
// the task existed only to run in that session. Best-effort: a failure here
|
||||
// must not turn a successful session delete into an error, but LOG it (like
|
||||
// the archive/unarchive paths) — the session is already gone, so a swallowed
|
||||
// write failure leaves the still-enabled bound task a permanent ghost the
|
||||
// keepalive retries a doomed revive on every tick.
|
||||
await removeTasksForSessions(service.getProjectRoot(), removed).catch(
|
||||
(err: unknown) => {
|
||||
logSessionArchiveWarning(
|
||||
`removeTasksForSessions failed for [${removed.join(', ')}]: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
const removed: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
const errors: Array<{ sessionId: string; error: unknown }> = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const sessionId = uniqueSessionIds[i]!;
|
||||
const result = results[i]!;
|
||||
if (result.kind === 'removed') {
|
||||
removed.push(sessionId);
|
||||
} else if (result.kind === 'notFound') {
|
||||
notFound.push(sessionId);
|
||||
} else {
|
||||
errors.push({ sessionId, error: errorMessage(result.error) });
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, notFound, errors: [...closeErrors, ...removeErrors] };
|
||||
return { removed, notFound, errors };
|
||||
}
|
||||
|
||||
export async function deleteDaemonSessionIfOrphan(params: {
|
||||
sessionId: string;
|
||||
service: SessionService;
|
||||
bridge: Pick<AcpSessionBridge, 'killSession'>;
|
||||
coordinator: SessionArchiveCoordinator;
|
||||
}): Promise<boolean> {
|
||||
const { sessionId, service, bridge, coordinator } = params;
|
||||
coordinator.assertNotTransitioning(sessionId);
|
||||
const result = await coordinator.runExclusiveMany([sessionId], async () => {
|
||||
let killed = false;
|
||||
try {
|
||||
killed = await bridge.killSession(sessionId, {
|
||||
requireZeroAttaches: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isSessionNotFoundError(error)) throw error;
|
||||
killed = true;
|
||||
}
|
||||
if (!killed) {
|
||||
return undefined;
|
||||
}
|
||||
return deletePersistedSessionWithLease(service, sessionId);
|
||||
});
|
||||
if (result === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (result.kind === 'error') {
|
||||
throw result.error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function assertSessionLoadable(
|
||||
workspaceCwd: string,
|
||||
sessionId: string,
|
||||
runtimeBaseDir?: string,
|
||||
): Promise<SessionLocation> {
|
||||
const location = await new SessionService(workspaceCwd).getSessionLocation(
|
||||
sessionId,
|
||||
);
|
||||
const location = await new SessionService(workspaceCwd, {
|
||||
runtimeBaseDir,
|
||||
}).getSessionLocation(sessionId);
|
||||
if (location === 'archived') {
|
||||
throw new SessionArchivedError(sessionId);
|
||||
}
|
||||
|
|
@ -222,10 +512,11 @@ export async function assertSessionLoadable(
|
|||
export async function assertSessionArchived(
|
||||
workspaceCwd: string,
|
||||
sessionId: string,
|
||||
runtimeBaseDir?: string,
|
||||
): Promise<void> {
|
||||
const location = await new SessionService(workspaceCwd).getSessionLocation(
|
||||
sessionId,
|
||||
);
|
||||
const location = await new SessionService(workspaceCwd, {
|
||||
runtimeBaseDir,
|
||||
}).getSessionLocation(sessionId);
|
||||
if (location === 'active') {
|
||||
throw new SessionNotArchivedError(sessionId);
|
||||
}
|
||||
|
|
@ -244,53 +535,6 @@ function isSessionNotFoundError(err: unknown): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
interface SessionLocationBuckets {
|
||||
active: string[];
|
||||
archived: string[];
|
||||
notFound: string[];
|
||||
errors: Array<{ sessionId: string; error: unknown }>;
|
||||
}
|
||||
|
||||
async function classifySessionLocations(
|
||||
service: SessionService,
|
||||
sessionIds: string[],
|
||||
): Promise<SessionLocationBuckets> {
|
||||
const result: SessionLocationBuckets = {
|
||||
active: [],
|
||||
archived: [],
|
||||
notFound: [],
|
||||
errors: [],
|
||||
};
|
||||
const locationResults = await Promise.allSettled(
|
||||
sessionIds.map(async (sessionId) => ({
|
||||
sessionId,
|
||||
location: await service.getSessionLocation(sessionId),
|
||||
})),
|
||||
);
|
||||
for (let i = 0; i < locationResults.length; i++) {
|
||||
const sessionId = sessionIds[i]!;
|
||||
const locationResult = locationResults[i]!;
|
||||
if (locationResult.status === 'rejected') {
|
||||
result.errors.push({ sessionId, error: locationResult.reason });
|
||||
continue;
|
||||
}
|
||||
const location = locationResult.value.location;
|
||||
if (location === undefined) {
|
||||
result.notFound.push(sessionId);
|
||||
} else if (location === 'archived') {
|
||||
result.archived.push(sessionId);
|
||||
} else if (location === 'conflict') {
|
||||
result.errors.push({
|
||||
sessionId,
|
||||
error: new Error(`Session archive conflict: ${sessionId}`),
|
||||
});
|
||||
} else {
|
||||
result.active.push(sessionId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function logSessionArchiveResult(
|
||||
action: 'archive' | 'unarchive',
|
||||
result: {
|
||||
|
|
@ -355,66 +599,135 @@ export async function archiveDaemonSessions(params: {
|
|||
}): Promise<DaemonArchiveSessionsResult> {
|
||||
const { sessionIds, service, bridge, coordinator } = params;
|
||||
const uniqueSessionIds = [...new Set(sessionIds)];
|
||||
const archived: string[] = [];
|
||||
const alreadyArchived: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
const errors: Array<{ sessionId: string; error: unknown }> = [];
|
||||
|
||||
const initial = await classifySessionLocations(service, uniqueSessionIds);
|
||||
const activeIds = initial.active;
|
||||
alreadyArchived.push(...initial.archived);
|
||||
notFound.push(...initial.notFound);
|
||||
errors.push(...initial.errors);
|
||||
|
||||
if (activeIds.length > 0) {
|
||||
await coordinator.runExclusiveMany(activeIds, async () => {
|
||||
const locked = await classifySessionLocations(service, activeIds);
|
||||
const closableIds = locked.active;
|
||||
alreadyArchived.push(...locked.archived);
|
||||
notFound.push(...locked.notFound);
|
||||
errors.push(...locked.errors);
|
||||
|
||||
// Close+flush before moving JSONL: live writers keep the active path.
|
||||
// If the later move fails, the active JSONL remains and a retry treats
|
||||
// SessionNotFound as the recoverable "already closed" state.
|
||||
const closeResults = await Promise.allSettled(
|
||||
closableIds.map(async (sessionId) => {
|
||||
for (const sessionId of uniqueSessionIds) {
|
||||
coordinator.assertNotTransitioning(sessionId);
|
||||
}
|
||||
const results = await Promise.all(
|
||||
uniqueSessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
return await coordinator.runExclusiveMany([sessionId], async () => {
|
||||
try {
|
||||
await bridge.closeSession(sessionId, undefined, {
|
||||
requireAgentClose: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isSessionNotFoundError(err)) {
|
||||
throw err;
|
||||
} catch (error) {
|
||||
if (!isSessionNotFoundError(error)) {
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
const archiveIds: string[] = [];
|
||||
for (let i = 0; i < closeResults.length; i++) {
|
||||
const sessionId = closableIds[i]!;
|
||||
const result = closeResults[i]!;
|
||||
if (result.status === 'fulfilled') {
|
||||
archiveIds.push(sessionId);
|
||||
} else {
|
||||
errors.push({ sessionId, error: result.reason });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const archiveResult = await service.archiveSessions(archiveIds, {
|
||||
knownLocation: 'active',
|
||||
const initialLocation = await classifySessionLocation(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (initialLocation === undefined) {
|
||||
return { kind: 'notFound' as const, mutationApplied: false };
|
||||
}
|
||||
if (initialLocation === 'archived') {
|
||||
return {
|
||||
kind: 'alreadyArchived' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (initialLocation === 'conflict') {
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error: sessionLocationError(sessionId),
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
|
||||
const mutation = await runWithDaemonWriterLease({
|
||||
action: 'archive',
|
||||
sessionId,
|
||||
service,
|
||||
mutate: async (assertOwnedAndUnchanged) => {
|
||||
const lockedLocation = await classifySessionLocation(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (lockedLocation === undefined) {
|
||||
return {
|
||||
value: 'notFound' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (lockedLocation === 'archived') {
|
||||
return {
|
||||
value: 'alreadyArchived' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (lockedLocation === 'conflict') {
|
||||
throw sessionLocationError(sessionId);
|
||||
}
|
||||
await assertOwnedAndUnchanged();
|
||||
const result = await service.archiveSessions([sessionId], {
|
||||
knownLocation: 'active',
|
||||
});
|
||||
if (result.errors[0]) throw result.errors[0].error;
|
||||
if (result.archived.length > 0) {
|
||||
return {
|
||||
value: 'archived' as const,
|
||||
mutationApplied: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value:
|
||||
result.alreadyArchived.length > 0
|
||||
? ('alreadyArchived' as const)
|
||||
: ('notFound' as const),
|
||||
mutationApplied: false,
|
||||
};
|
||||
},
|
||||
mutationAppliedAfterError: async () =>
|
||||
(await classifySessionLocation(service, sessionId)) ===
|
||||
'archived',
|
||||
afterMutationApplied: () =>
|
||||
updateScheduledTaskForMaintenance(service, sessionId, 'archive'),
|
||||
});
|
||||
if (mutation.error !== undefined) {
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error: mutation.error,
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: mutation.value ?? 'notFound',
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
};
|
||||
});
|
||||
archived.push(...archiveResult.archived);
|
||||
alreadyArchived.push(...archiveResult.alreadyArchived);
|
||||
notFound.push(...archiveResult.notFound);
|
||||
errors.push(...archiveResult.errors);
|
||||
} catch (err) {
|
||||
for (const sessionId of archiveIds) {
|
||||
errors.push({ sessionId, error: err });
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonDrainingError) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error,
|
||||
mutationApplied: false,
|
||||
maintenanceError: undefined,
|
||||
};
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const archived: string[] = [];
|
||||
const alreadyArchived: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
const errors: Array<{ sessionId: string; error: unknown }> = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const sessionId = uniqueSessionIds[i]!;
|
||||
const result = results[i]!;
|
||||
if (result.kind === 'archived') archived.push(sessionId);
|
||||
else if (result.kind === 'alreadyArchived') {
|
||||
alreadyArchived.push(sessionId);
|
||||
} else if (result.kind === 'notFound') notFound.push(sessionId);
|
||||
else errors.push({ sessionId, error: result.error });
|
||||
}
|
||||
|
||||
logSessionArchiveResult('archive', {
|
||||
|
|
@ -425,22 +738,6 @@ export async function archiveDaemonSessions(params: {
|
|||
errors,
|
||||
});
|
||||
|
||||
// Archiving a session pauses any scheduled task bound to it (kept on disk,
|
||||
// recoverable on unarchive). Best-effort — never fail the archive over it, but
|
||||
// LOG a write failure: if the task's `enabled` flag isn't flipped, the
|
||||
// keepalive still sees it enabled + bound and will revive the just-archived
|
||||
// session so the task keeps firing. Logging makes that broken coupling
|
||||
// diagnosable rather than silent.
|
||||
await disableTasksForSessions(service.getProjectRoot(), archived).catch(
|
||||
(err: unknown) => {
|
||||
logSessionArchiveWarning(
|
||||
`disableTasksForSessions failed for [${archived.join(', ')}]: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
} — bound tasks may keep firing until reconciled`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return { archived, alreadyArchived, notFound, errors };
|
||||
}
|
||||
|
||||
|
|
@ -451,43 +748,145 @@ export async function unarchiveDaemonSessions(params: {
|
|||
}): Promise<DaemonUnarchiveSessionsResult> {
|
||||
const { sessionIds, service, coordinator } = params;
|
||||
const uniqueSessionIds = [...new Set(sessionIds)];
|
||||
for (const sessionId of uniqueSessionIds) {
|
||||
coordinator.assertNotTransitioning(sessionId);
|
||||
}
|
||||
const results = await Promise.all(
|
||||
uniqueSessionIds.map(async (sessionId) => {
|
||||
try {
|
||||
return await coordinator.runExclusiveMany([sessionId], async () => {
|
||||
const initialLocation = await classifySessionLocation(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (initialLocation === undefined) {
|
||||
return { kind: 'notFound' as const, mutationApplied: false };
|
||||
}
|
||||
if (initialLocation === 'active') {
|
||||
let maintenanceError: unknown;
|
||||
try {
|
||||
await updateScheduledTaskForMaintenance(
|
||||
service,
|
||||
sessionId,
|
||||
'unarchive',
|
||||
);
|
||||
} catch (error) {
|
||||
maintenanceError = error;
|
||||
logSessionArchiveWarning(
|
||||
`scheduled task lifecycle update failed action=unarchive workspace=${safeLogValue(
|
||||
service.getProjectRoot(),
|
||||
)} session=${safeLogValue(sessionId)} error=${safeLogValue(
|
||||
errorMessage(error),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: 'alreadyActive' as const,
|
||||
mutationApplied: false,
|
||||
maintenanceError,
|
||||
};
|
||||
}
|
||||
if (initialLocation === 'conflict') {
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error: sessionLocationError(sessionId),
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
|
||||
const mutation = await runWithDaemonWriterLease({
|
||||
action: 'unarchive',
|
||||
sessionId,
|
||||
service,
|
||||
mutate: async (assertOwnedAndUnchanged) => {
|
||||
const lockedLocation = await classifySessionLocation(
|
||||
service,
|
||||
sessionId,
|
||||
);
|
||||
if (lockedLocation === undefined) {
|
||||
return {
|
||||
value: 'notFound' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (lockedLocation === 'active') {
|
||||
return {
|
||||
value: 'alreadyActive' as const,
|
||||
mutationApplied: false,
|
||||
};
|
||||
}
|
||||
if (lockedLocation === 'conflict') {
|
||||
throw sessionLocationError(sessionId);
|
||||
}
|
||||
await assertOwnedAndUnchanged();
|
||||
const result = await service.unarchiveSessions([sessionId], {
|
||||
knownLocation: 'archived',
|
||||
});
|
||||
if (result.errors[0]) throw result.errors[0].error;
|
||||
if (result.unarchived.length > 0) {
|
||||
return {
|
||||
value: 'unarchived' as const,
|
||||
mutationApplied: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value:
|
||||
result.alreadyActive.length > 0
|
||||
? ('alreadyActive' as const)
|
||||
: ('notFound' as const),
|
||||
mutationApplied: false,
|
||||
};
|
||||
},
|
||||
mutationAppliedAfterError: async () =>
|
||||
(await classifySessionLocation(service, sessionId)) === 'active',
|
||||
afterMutationApplied: () =>
|
||||
updateScheduledTaskForMaintenance(
|
||||
service,
|
||||
sessionId,
|
||||
'unarchive',
|
||||
),
|
||||
});
|
||||
if (mutation.error !== undefined) {
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error: mutation.error,
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: mutation.value ?? 'notFound',
|
||||
mutationApplied: mutation.mutationApplied,
|
||||
maintenanceError: mutation.maintenanceError,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonDrainingError) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
kind: 'error' as const,
|
||||
error,
|
||||
mutationApplied: false,
|
||||
maintenanceError: undefined,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const unarchived: string[] = [];
|
||||
const alreadyActive: string[] = [];
|
||||
const notFound: string[] = [];
|
||||
const errors: Array<{ sessionId: string; error: unknown }> = [];
|
||||
|
||||
const initial = await classifySessionLocations(service, uniqueSessionIds);
|
||||
const archivedIds = initial.archived;
|
||||
alreadyActive.push(...initial.active);
|
||||
notFound.push(...initial.notFound);
|
||||
errors.push(...initial.errors);
|
||||
|
||||
if (archivedIds.length > 0) {
|
||||
await coordinator.runExclusiveMany(archivedIds, async () => {
|
||||
const locked = await classifySessionLocations(service, archivedIds);
|
||||
const unarchiveIds = locked.archived;
|
||||
alreadyActive.push(...locked.active);
|
||||
notFound.push(...locked.notFound);
|
||||
errors.push(...locked.errors);
|
||||
|
||||
if (unarchiveIds.length > 0) {
|
||||
try {
|
||||
const result = await service.unarchiveSessions(unarchiveIds, {
|
||||
knownLocation: 'archived',
|
||||
});
|
||||
unarchived.push(...result.unarchived);
|
||||
alreadyActive.push(...result.alreadyActive);
|
||||
notFound.push(...result.notFound);
|
||||
errors.push(...result.errors);
|
||||
} catch (err) {
|
||||
// The service reports normal per-session failures in `result.errors`.
|
||||
// Reaching this catch means the batch could not produce a result at all.
|
||||
for (const sessionId of unarchiveIds) {
|
||||
errors.push({ sessionId, error: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const sessionId = uniqueSessionIds[i]!;
|
||||
const result = results[i]!;
|
||||
if (result.kind === 'unarchived') unarchived.push(sessionId);
|
||||
else if (result.kind === 'alreadyActive') alreadyActive.push(sessionId);
|
||||
else if (result.kind === 'notFound') notFound.push(sessionId);
|
||||
else errors.push({ sessionId, error: result.error });
|
||||
if (result.maintenanceError !== undefined) {
|
||||
errors.push({ sessionId, error: result.maintenanceError });
|
||||
}
|
||||
}
|
||||
|
||||
logSessionArchiveResult('unarchive', {
|
||||
|
|
@ -498,29 +897,5 @@ export async function unarchiveDaemonSessions(params: {
|
|||
errors,
|
||||
});
|
||||
|
||||
// Unarchiving a session resumes any scheduled task bound to it (re-enabled,
|
||||
// anchor reset to now). Also run it for sessions that were ALREADY active:
|
||||
// enableTasksForSessions is idempotent (it only re-enables archive-disabled
|
||||
// tasks), so re-unarchiving a session whose task was stranded
|
||||
// (`disabledByArchive: true`) by a PRIOR failed enable recovers it — otherwise
|
||||
// that task is unrecoverable (PATCH-enable 409s on the stale flag, keepalive
|
||||
// skips it). Surface a write failure in `errors` (and log it) instead of
|
||||
// swallowing, so a stranded task isn't left silent.
|
||||
const resumeSessionIds = [...new Set([...unarchived, ...alreadyActive])];
|
||||
try {
|
||||
await enableTasksForSessions(service.getProjectRoot(), resumeSessionIds);
|
||||
} catch (err) {
|
||||
logSessionArchiveWarning(
|
||||
`enableTasksForSessions failed for [${resumeSessionIds.join(', ')}]: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
// Report against the full resume set: a failed already-active recovery must
|
||||
// surface too, or its stranded task stays silently unrecoverable.
|
||||
for (const sessionId of resumeSessionIds) {
|
||||
errors.push({ sessionId, error: err });
|
||||
}
|
||||
}
|
||||
|
||||
return { unarchived, alreadyActive, notFound, errors };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => {
|
|||
.map(({ method, path }) => `${method} ${path}`)
|
||||
.sort();
|
||||
|
||||
expect(registered).toHaveLength(50);
|
||||
expect(registered).toHaveLength(51);
|
||||
expect(registered).toEqual(catalog);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -794,17 +794,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => {
|
|||
});
|
||||
|
||||
describe('legacy session telemetry route catalog', () => {
|
||||
it('contains 50 unique routes with the audited 43/7 attribution split', () => {
|
||||
it('contains 51 unique routes with the audited 44/7 attribution split', () => {
|
||||
const keys = legacySessionTelemetryRoutes.map(
|
||||
({ method, path }) => `${method} ${path}`,
|
||||
);
|
||||
expect(keys).toHaveLength(50);
|
||||
expect(new Set(keys).size).toBe(50);
|
||||
expect(keys).toHaveLength(51);
|
||||
expect(new Set(keys).size).toBe(51);
|
||||
expect(
|
||||
legacySessionTelemetryRoutes.filter(
|
||||
({ attribution }) => attribution === 'handler_resolved',
|
||||
),
|
||||
).toHaveLength(43);
|
||||
).toHaveLength(44);
|
||||
expect(
|
||||
legacySessionTelemetryRoutes.filter(
|
||||
({ attribution }) => attribution === 'pre_resolved',
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ export const legacySessionTelemetryRoutes = [
|
|||
attribution: 'handler_resolved',
|
||||
route: 'POST /session/:id/fork',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/session/:id/side-task',
|
||||
attribution: 'handler_resolved',
|
||||
route: 'POST /session/:id/side-task',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/session/:id/cd',
|
||||
|
|
|
|||
|
|
@ -88,6 +88,46 @@ describe('VirtualSubagentSessions', () => {
|
|||
).toThrow('valid id parts');
|
||||
});
|
||||
|
||||
it('resolves an out-of-band fork by agent task id', async () => {
|
||||
const runtime = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceCwd: '/workspace',
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
bridge: {
|
||||
getSessionTasksStatus: async () => ({
|
||||
v: 1 as const,
|
||||
sessionId: 'parent-session',
|
||||
now: Date.now(),
|
||||
tasks: [
|
||||
{
|
||||
kind: 'agent' as const,
|
||||
id: 'fork-agent-1',
|
||||
label: 'Review current changes',
|
||||
description: 'Review current changes',
|
||||
status: 'running' as const,
|
||||
startTime: Date.now(),
|
||||
runtimeMs: 1,
|
||||
outputFile: '/tmp/fork-agent-1.jsonl',
|
||||
isBackgrounded: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
} as unknown as WorkspaceRuntime;
|
||||
|
||||
const resolved = await new VirtualSubagentSessions().resolve(
|
||||
runtime,
|
||||
'parent-session',
|
||||
'fork-agent-1',
|
||||
);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
taskId: 'fork-agent-1',
|
||||
title: 'Review current changes',
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves, fully loads, and independently streams an agent transcript', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-subagent-'));
|
||||
tempDirs.push(dir);
|
||||
|
|
@ -125,6 +165,7 @@ describe('VirtualSubagentSessions', () => {
|
|||
const runtime = {
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
bridge: {
|
||||
getSessionTasksStatus: async () => ({
|
||||
|
|
@ -248,6 +289,7 @@ describe('VirtualSubagentSessions', () => {
|
|||
const runtime = {
|
||||
workspaceId: 'workspace-refresh-error',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
bridge: {
|
||||
getSessionTasksStatus: async () => ({
|
||||
|
|
@ -306,6 +348,7 @@ describe('VirtualSubagentSessions', () => {
|
|||
return {
|
||||
workspaceId,
|
||||
workspaceCwd: `/workspace/${workspaceId}`,
|
||||
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
bridge: {
|
||||
getSessionTasksStatus: async () => ({
|
||||
|
|
@ -373,6 +416,7 @@ describe('VirtualSubagentSessions', () => {
|
|||
const runtime = {
|
||||
workspaceId: 'workspace-batch',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
bridge: {
|
||||
getSessionTasksStatus: async () => ({
|
||||
|
|
@ -441,6 +485,7 @@ describe('VirtualSubagentSessions', () => {
|
|||
const runtime = {
|
||||
workspaceId: 'workspace-reload',
|
||||
workspaceCwd: '/workspace',
|
||||
sessionRuntimeBaseDir: Storage.getRuntimeBaseDir(),
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
bridge: {
|
||||
getSessionTasksStatus: async () => ({
|
||||
|
|
@ -552,6 +597,7 @@ describe('VirtualSubagentSessions', () => {
|
|||
const runtime = {
|
||||
workspaceId: 'running-workspace',
|
||||
workspaceCwd,
|
||||
sessionRuntimeBaseDir: runtimeDir,
|
||||
env: {
|
||||
mode: 'runtime-overlay',
|
||||
overlayKeys: ['QWEN_RUNTIME_DIR'],
|
||||
|
|
@ -728,6 +774,7 @@ describe('VirtualSubagentSessions', () => {
|
|||
const runtime = {
|
||||
workspaceId: 'legacy-workspace',
|
||||
workspaceCwd,
|
||||
sessionRuntimeBaseDir: runtimeDir,
|
||||
env: {
|
||||
mode: 'runtime-overlay',
|
||||
overlayKeys: ['QWEN_RUNTIME_DIR'],
|
||||
|
|
|
|||
|
|
@ -735,10 +735,8 @@ export class VirtualSubagentSessions {
|
|||
};
|
||||
}
|
||||
|
||||
const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR'];
|
||||
const projectDir = Storage.runWithRuntimeBaseDir(
|
||||
runtimeDir,
|
||||
runtime.workspaceCwd,
|
||||
const projectDir = Storage.runWithResolvedRuntimeBaseDir(
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
() => new Storage(runtime.workspaceCwd).getProjectDir(),
|
||||
);
|
||||
const sessionDir = getSubagentSessionDir(projectDir, parentSessionId);
|
||||
|
|
@ -785,10 +783,8 @@ export class VirtualSubagentSessions {
|
|||
): Promise<ResolvedAgentTask | undefined> {
|
||||
// Pre-toolUseId transcripts cannot be linked exactly. This score is only a
|
||||
// best-effort compatibility path and identical parallel launches may tie.
|
||||
const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR'];
|
||||
const projectDir = Storage.runWithRuntimeBaseDir(
|
||||
runtimeDir,
|
||||
runtime.workspaceCwd,
|
||||
const projectDir = Storage.runWithResolvedRuntimeBaseDir(
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
() => new Storage(runtime.workspaceCwd).getProjectDir(),
|
||||
);
|
||||
const parentRecords = await readJsonl<ChatRecord>(
|
||||
|
|
@ -882,10 +878,8 @@ export class VirtualSubagentSessions {
|
|||
parentSessionId: string,
|
||||
toolCallId: string,
|
||||
): Promise<ToolCallMetrics> {
|
||||
const runtimeDir = runtime.env.effectiveEnv?.['QWEN_RUNTIME_DIR'];
|
||||
const projectDir = Storage.runWithRuntimeBaseDir(
|
||||
runtimeDir,
|
||||
runtime.workspaceCwd,
|
||||
const projectDir = Storage.runWithResolvedRuntimeBaseDir(
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
() => new Storage(runtime.workspaceCwd).getProjectDir(),
|
||||
);
|
||||
const records = await readJsonl<ChatRecord>(
|
||||
|
|
@ -903,6 +897,9 @@ export class VirtualSubagentSessions {
|
|||
runtime,
|
||||
parentSessionId,
|
||||
(candidate) =>
|
||||
// /fork has no parent transcript tool call, so its task ID is the
|
||||
// stable reference used by Web Shell.
|
||||
candidate.id === toolCallId ||
|
||||
candidate.toolUseId === toolCallId ||
|
||||
candidate.id.endsWith(`-${toolCallId}`),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ async function makeHarness(opts?: {
|
|||
const primary: WorkspaceRuntime = {
|
||||
workspaceId: 'same-as-path',
|
||||
workspaceCwd: primaryCwd,
|
||||
sessionRuntimeBaseDir: path.join(primaryCwd, '.runtime'),
|
||||
primary: true,
|
||||
trusted: true,
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
|
|
@ -232,6 +233,7 @@ async function makeHarness(opts?: {
|
|||
const secondary: WorkspaceRuntime = {
|
||||
workspaceId: hashDaemonWorkspace(secondaryCwd),
|
||||
workspaceCwd: secondaryCwd,
|
||||
sessionRuntimeBaseDir: path.join(secondaryCwd, '.runtime'),
|
||||
primary: false,
|
||||
trusted: opts?.secondaryTrusted ?? true,
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
|
|
@ -288,6 +290,7 @@ async function makeWindowsSelectorHarness() {
|
|||
const primary: WorkspaceRuntime = {
|
||||
workspaceId: 'primary-id',
|
||||
workspaceCwd: primaryCwd,
|
||||
sessionRuntimeBaseDir: path.join(primaryCwd, '.runtime'),
|
||||
primary: true,
|
||||
trusted: true,
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
|
|
@ -299,6 +302,7 @@ async function makeWindowsSelectorHarness() {
|
|||
const windowsRuntime: WorkspaceRuntime = {
|
||||
workspaceId: 'windows-id',
|
||||
workspaceCwd: windowsCwd,
|
||||
sessionRuntimeBaseDir: '/runtime/windows',
|
||||
primary: false,
|
||||
trusted: true,
|
||||
env: { mode: 'parent-process', overlayKeys: [] },
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export interface WorkspaceRuntimeEnvMetadata {
|
|||
export interface WorkspaceRuntime {
|
||||
readonly workspaceId: string;
|
||||
readonly workspaceCwd: string;
|
||||
readonly sessionRuntimeBaseDir: string;
|
||||
/** Optional presentation-only name. Workspace identity remains id/cwd. */
|
||||
displayName?: string;
|
||||
readonly primary: boolean;
|
||||
|
|
|
|||
32
packages/cli/src/serve/workspace-runtime-storage.ts
Normal file
32
packages/cli/src/serve/workspace-runtime-storage.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
SessionService,
|
||||
Storage,
|
||||
type SessionServiceOptions,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { WorkspaceRuntime } from './workspace-registry.js';
|
||||
|
||||
export function runWithWorkspaceRuntimeStorage<T>(
|
||||
runtime: WorkspaceRuntime,
|
||||
fn: () => T,
|
||||
): T {
|
||||
return Storage.runWithResolvedRuntimeBaseDir(
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
export function createWorkspaceRuntimeSessionService(
|
||||
runtime: WorkspaceRuntime,
|
||||
options: Omit<SessionServiceOptions, 'runtimeBaseDir'> = {},
|
||||
): SessionService {
|
||||
return new SessionService(runtime.workspaceCwd, {
|
||||
...options,
|
||||
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
|
||||
});
|
||||
}
|
||||
|
|
@ -93,7 +93,10 @@ vi.mock('../../../utils/stdioHelpers.js', () => ({
|
|||
|
||||
const { createDaemonWorkspaceService } = await import('../index.js');
|
||||
import { SessionNotFoundError } from '@qwen-code/acp-bridge/bridgeErrors';
|
||||
import { BridgeChannelClosedError } from '@qwen-code/acp-bridge/status';
|
||||
import {
|
||||
BridgeChannelClosedError,
|
||||
type ServeWorkspaceSkillsStatus,
|
||||
} from '@qwen-code/acp-bridge/status';
|
||||
import {
|
||||
resetHomeEnvBootstrapForTesting,
|
||||
SettingScope,
|
||||
|
|
@ -112,6 +115,8 @@ import {
|
|||
} from '../types.js';
|
||||
import type {
|
||||
DaemonWorkspaceServiceDeps,
|
||||
InvokeWorkspaceCommandFn,
|
||||
QueryWorkspaceStatusFn,
|
||||
WorkspaceRequestContext,
|
||||
} from '../types.js';
|
||||
|
||||
|
|
@ -793,7 +798,7 @@ describe('createDaemonWorkspaceService', () => {
|
|||
expect(second.skills.map((s) => s.name)).toEqual(['review']);
|
||||
});
|
||||
|
||||
it('getWorkspaceSkillsStatus refreshes the cached status on a newer live answer', async () => {
|
||||
it('getWorkspaceSkillsStatus reuses the snapshot until it is invalidated', async () => {
|
||||
const statuses = [
|
||||
{
|
||||
v: 1,
|
||||
|
|
@ -820,8 +825,227 @@ describe('createDaemonWorkspaceService', () => {
|
|||
);
|
||||
|
||||
await svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
const cached = await svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
expect(cached.skills.map((s) => s.name)).toEqual(['review']);
|
||||
expect(queryWorkspaceStatus).toHaveBeenCalledOnce();
|
||||
|
||||
svc.invalidateWorkspaceSkillsStatus();
|
||||
const refreshed = await svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
expect(refreshed.skills.map((s) => s.name)).toEqual(['review', 'plan']);
|
||||
expect(queryWorkspaceStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('revalidates the workspace skills snapshot after its freshness window', async () => {
|
||||
let now = 10_000;
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const queryWorkspaceStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
v: 1,
|
||||
workspaceCwd: '/ws',
|
||||
initialized: true,
|
||||
skills: [
|
||||
{
|
||||
kind: 'skill',
|
||||
status: 'ok',
|
||||
name: 'review',
|
||||
description: 'Review code',
|
||||
level: 'bundled',
|
||||
modelInvocable: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
v: 1,
|
||||
workspaceCwd: '/ws',
|
||||
initialized: true,
|
||||
skills: [
|
||||
{
|
||||
kind: 'skill',
|
||||
status: 'ok',
|
||||
name: 'plan',
|
||||
description: 'Plan changes',
|
||||
level: 'bundled',
|
||||
modelInvocable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const svc = createDaemonWorkspaceService(
|
||||
makeDeps({ queryWorkspaceStatus, boundWorkspace: '/ws' }),
|
||||
);
|
||||
|
||||
try {
|
||||
const initial = await svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
now += 4_999;
|
||||
const cached = await svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
now += 1;
|
||||
const refreshed = await svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
|
||||
expect(initial.skills.map((skill) => skill.name)).toEqual(['review']);
|
||||
expect(cached).toEqual(initial);
|
||||
expect(refreshed.skills.map((skill) => skill.name)).toEqual(['plan']);
|
||||
expect(queryWorkspaceStatus).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a superseded read extend the freshness window', async () => {
|
||||
// A read that started before an invalidation still serves the snapshot a
|
||||
// later read committed, but must not push that snapshot's TTL out —
|
||||
// otherwise a post-mutation snapshot goes unrevalidated for longer than
|
||||
// the window.
|
||||
let now = 10_000;
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const stale = deferred<ServeWorkspaceSkillsStatus>();
|
||||
const skill = (name: string) =>
|
||||
({
|
||||
kind: 'skill',
|
||||
status: 'ok',
|
||||
name,
|
||||
description: name,
|
||||
level: 'bundled',
|
||||
modelInvocable: true,
|
||||
}) as ServeWorkspaceSkillsStatus['skills'][number];
|
||||
const fresh: ServeWorkspaceSkillsStatus = {
|
||||
v: 1,
|
||||
workspaceCwd: '/ws',
|
||||
initialized: true,
|
||||
skills: [skill('review')],
|
||||
};
|
||||
const later: ServeWorkspaceSkillsStatus = {
|
||||
v: 1,
|
||||
workspaceCwd: '/ws',
|
||||
initialized: true,
|
||||
skills: [skill('plan')],
|
||||
};
|
||||
const query = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => stale.promise)
|
||||
.mockResolvedValueOnce(fresh)
|
||||
.mockResolvedValueOnce(later);
|
||||
const queryWorkspaceStatus: QueryWorkspaceStatusFn = async <T>() =>
|
||||
(await query()) as T;
|
||||
const svc = createDaemonWorkspaceService(
|
||||
makeDeps({ queryWorkspaceStatus, boundWorkspace: '/ws' }),
|
||||
);
|
||||
|
||||
try {
|
||||
const supersededRead = svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
svc.invalidateWorkspaceSkillsStatus();
|
||||
await expect(svc.getWorkspaceSkillsStatus(makeCtx())).resolves.toEqual(
|
||||
fresh,
|
||||
);
|
||||
|
||||
now += 4_000;
|
||||
// The superseded read answers late and uninitialized, so it falls back
|
||||
// to the committed snapshot.
|
||||
stale.resolve({
|
||||
v: 1,
|
||||
workspaceCwd: '/ws',
|
||||
initialized: false,
|
||||
skills: [],
|
||||
});
|
||||
await expect(supersededRead).resolves.toEqual(fresh);
|
||||
|
||||
// 5_001ms after `fresh` was committed: the window is over regardless of
|
||||
// when the superseded read happened to finish.
|
||||
now += 1_001;
|
||||
await expect(svc.getWorkspaceSkillsStatus(makeCtx())).resolves.toEqual(
|
||||
later,
|
||||
);
|
||||
expect(query).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('shares one workspace skills query between concurrent readers', async () => {
|
||||
const pending = deferred<ServeWorkspaceSkillsStatus>();
|
||||
const query = vi.fn(() => pending.promise);
|
||||
const queryWorkspaceStatus: QueryWorkspaceStatusFn = async <T>() =>
|
||||
(await query()) as T;
|
||||
const svc = createDaemonWorkspaceService(
|
||||
makeDeps({ queryWorkspaceStatus, boundWorkspace: '/ws' }),
|
||||
);
|
||||
|
||||
const first = svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
const second = svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
pending.resolve({
|
||||
v: 1,
|
||||
workspaceCwd: '/ws',
|
||||
initialized: true,
|
||||
skills: [
|
||||
{
|
||||
kind: 'skill',
|
||||
status: 'ok',
|
||||
name: 'review',
|
||||
description: 'Review code',
|
||||
level: 'bundled',
|
||||
modelInvocable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
expect.objectContaining({ initialized: true }),
|
||||
expect.objectContaining({ initialized: true }),
|
||||
]);
|
||||
expect(query).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not cache a workspace skills query invalidated while in flight', async () => {
|
||||
const stale = deferred<ServeWorkspaceSkillsStatus>();
|
||||
const freshStatus: ServeWorkspaceSkillsStatus = {
|
||||
v: 1,
|
||||
workspaceCwd: '/ws',
|
||||
initialized: true,
|
||||
skills: [
|
||||
{
|
||||
kind: 'skill',
|
||||
status: 'ok',
|
||||
name: 'plan',
|
||||
description: 'Plan changes',
|
||||
level: 'bundled',
|
||||
modelInvocable: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const query = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => stale.promise)
|
||||
.mockResolvedValueOnce(freshStatus);
|
||||
const queryWorkspaceStatus: QueryWorkspaceStatusFn = async <T>() =>
|
||||
(await query()) as T;
|
||||
const svc = createDaemonWorkspaceService(
|
||||
makeDeps({ queryWorkspaceStatus, boundWorkspace: '/ws' }),
|
||||
);
|
||||
|
||||
const staleRead = svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
svc.invalidateWorkspaceSkillsStatus();
|
||||
const freshRead = svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
|
||||
await expect(freshRead).resolves.toEqual(freshStatus);
|
||||
stale.resolve({
|
||||
v: 1,
|
||||
workspaceCwd: '/ws',
|
||||
initialized: true,
|
||||
skills: [
|
||||
{
|
||||
kind: 'skill',
|
||||
status: 'ok',
|
||||
name: 'review',
|
||||
description: 'Review code',
|
||||
level: 'bundled',
|
||||
modelInvocable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(staleRead).resolves.toEqual(freshStatus);
|
||||
await expect(svc.getWorkspaceSkillsStatus(makeCtx())).resolves.toEqual(
|
||||
freshStatus,
|
||||
);
|
||||
expect(query).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('invalidateWorkspaceSkillsStatus drops the cached child skills answer', async () => {
|
||||
|
|
@ -903,10 +1127,13 @@ describe('createDaemonWorkspaceService', () => {
|
|||
);
|
||||
|
||||
const result = await svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
const cached = await svc.getWorkspaceSkillsStatus(makeCtx());
|
||||
|
||||
expect(workspaceSkillsStatusProvider).toHaveBeenCalledWith('/ws');
|
||||
expect(workspaceSkillsStatusProvider).toHaveBeenCalledOnce();
|
||||
expect(result.initialized).toBe(true);
|
||||
expect(result.skills.map((s) => s.name)).toEqual(['review']);
|
||||
expect(cached).toEqual(result);
|
||||
});
|
||||
|
||||
it('getWorkspaceSkillsStatus prefers the cached child answer over the daemon-local provider', async () => {
|
||||
|
|
@ -1405,7 +1632,7 @@ describe('createDaemonWorkspaceService', () => {
|
|||
expect(invalidate).toHaveBeenCalledWith('/workspace');
|
||||
expect(invokeWorkspaceCommand).toHaveBeenCalledWith(
|
||||
'qwen/control/workspace/skills/refresh',
|
||||
{ cwd: '/workspace' },
|
||||
{ cwd: '/workspace', reason: 'settings' },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
skillName: 'review',
|
||||
|
|
@ -1426,6 +1653,72 @@ describe('createDaemonWorkspaceService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not retain a status snapshot read while a settings refresh is in flight', async () => {
|
||||
const refresh = deferred<{
|
||||
sessionsRefreshed: number;
|
||||
sessionsFailed: number;
|
||||
}>();
|
||||
const oldSkill: ServeWorkspaceSkillsStatus['skills'][number] = {
|
||||
kind: 'skill',
|
||||
status: 'ok',
|
||||
name: 'review',
|
||||
description: 'Review changed code',
|
||||
level: 'bundled',
|
||||
modelInvocable: true,
|
||||
};
|
||||
const oldStatus: ServeWorkspaceSkillsStatus = {
|
||||
v: 1,
|
||||
workspaceCwd: '/workspace',
|
||||
initialized: true,
|
||||
skills: [oldSkill],
|
||||
};
|
||||
const newStatus: ServeWorkspaceSkillsStatus = {
|
||||
...oldStatus,
|
||||
skills: [
|
||||
{
|
||||
...oldSkill,
|
||||
status: 'disabled',
|
||||
disabledReason: 'hard',
|
||||
},
|
||||
],
|
||||
};
|
||||
const queryWorkspaceStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(oldStatus)
|
||||
.mockResolvedValueOnce(oldStatus)
|
||||
.mockResolvedValueOnce(newStatus);
|
||||
const invokeWorkspaceCommand = vi.fn(
|
||||
() => refresh.promise,
|
||||
) as unknown as InvokeWorkspaceCommandFn;
|
||||
const svc = createDaemonWorkspaceService(
|
||||
makeDeps({
|
||||
queryWorkspaceStatus,
|
||||
persistDisabledSkills: vi.fn().mockResolvedValue({
|
||||
changed: true,
|
||||
disabled: ['review'],
|
||||
}),
|
||||
invokeWorkspaceCommand,
|
||||
isChannelLive: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
const toggle = svc.setWorkspaceSkillEnabled(makeCtx(), 'review', false);
|
||||
await vi.waitFor(() =>
|
||||
expect(invokeWorkspaceCommand).toHaveBeenCalledOnce(),
|
||||
);
|
||||
|
||||
await expect(svc.getWorkspaceSkillsStatus(makeCtx())).resolves.toEqual(
|
||||
oldStatus,
|
||||
);
|
||||
refresh.resolve({ sessionsRefreshed: 1, sessionsFailed: 0 });
|
||||
await toggle;
|
||||
|
||||
await expect(svc.getWorkspaceSkillsStatus(makeCtx())).resolves.toEqual(
|
||||
newStatus,
|
||||
);
|
||||
expect(queryWorkspaceStatus).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('publishes an explicit enabled override for a default-disabled skill', async () => {
|
||||
const publishWorkspaceEvent = vi.fn();
|
||||
const svc = createDaemonWorkspaceService(
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ export {
|
|||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WORKSPACE_SKILLS_SNAPSHOT_TTL_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Walk up from `inputPath` until we find an ancestor that exists on disk,
|
||||
* then `realpath` it. Used by `initWorkspace` to canonicalize the parent
|
||||
|
|
@ -245,61 +247,129 @@ export function createDaemonWorkspaceService(
|
|||
// skill-backed slash commands (e.g. `/review`) keep autocompleting after
|
||||
// the child channel has been reaped. See `getWorkspaceSkillsStatus`.
|
||||
let lastWorkspaceSkillsStatus: ServeWorkspaceSkillsStatus | undefined;
|
||||
let lastWorkspaceSkillsStatusAt = 0;
|
||||
let workspaceSkillsGeneration = 0;
|
||||
let inFlightWorkspaceSkillsStatus:
|
||||
| {
|
||||
generation: number;
|
||||
promise: Promise<ServeWorkspaceSkillsStatus>;
|
||||
}
|
||||
| undefined;
|
||||
let inFlightAcpPreheat: Promise<void> | undefined;
|
||||
|
||||
const getWorkspaceSkillsStatus =
|
||||
async (): Promise<ServeWorkspaceSkillsStatus> => {
|
||||
let status: ServeWorkspaceSkillsStatus;
|
||||
try {
|
||||
status = await queryWorkspaceStatus(
|
||||
SERVE_STATUS_EXT_METHODS.workspaceSkills,
|
||||
() => createIdleWorkspaceSkillsStatus(boundWorkspace),
|
||||
);
|
||||
} catch (err) {
|
||||
// The channel can die mid-RPC (`liveChannelInfo()` was valid at the
|
||||
// check but the child exited before the call completed). Treat that
|
||||
// like "no live child" and fall back to the cache / daemon-local
|
||||
// enumeration below instead of failing the request — matching
|
||||
// getWorkspaceEnvStatus / getWorkspacePreflightStatus.
|
||||
writeStderrLine(
|
||||
`qwen serve: getWorkspaceSkillsStatus query failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
status = createIdleWorkspaceSkillsStatus(boundWorkspace);
|
||||
}
|
||||
if (status.initialized) {
|
||||
lastWorkspaceSkillsStatus = status;
|
||||
return status;
|
||||
}
|
||||
// Live child unavailable. Prefer the last answer it produced (keeps the
|
||||
// full, extension-aware list available across a reap)...
|
||||
if (lastWorkspaceSkillsStatus) return lastWorkspaceSkillsStatus;
|
||||
// ...then fall back to daemon-local enumeration, so a child that has not
|
||||
// answered even once (e.g. a preheat that times out under `npm run dev`)
|
||||
// still yields the on-disk skills — `/review` included. The provider
|
||||
// handles its own errors, but it is injected, so guard the call too and
|
||||
// degrade to the idle placeholder rather than failing the request —
|
||||
// matching getWorkspaceEnvStatus / getWorkspacePreflightStatus.
|
||||
if (workspaceSkillsStatusProvider) {
|
||||
try {
|
||||
return await workspaceSkillsStatusProvider(boundWorkspace);
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: getWorkspaceSkillsStatus local provider failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const invalidateWorkspaceSkillsSnapshot = () => {
|
||||
workspaceSkillsGeneration += 1;
|
||||
lastWorkspaceSkillsStatus = undefined;
|
||||
lastWorkspaceSkillsStatusAt = 0;
|
||||
workspaceSkillsStatusProvider?.invalidate?.(boundWorkspace);
|
||||
};
|
||||
|
||||
const readWorkspaceSkillsStatus = async (
|
||||
generation: number,
|
||||
): Promise<ServeWorkspaceSkillsStatus> => {
|
||||
let status: ServeWorkspaceSkillsStatus;
|
||||
try {
|
||||
status = await queryWorkspaceStatus(
|
||||
SERVE_STATUS_EXT_METHODS.workspaceSkills,
|
||||
() => createIdleWorkspaceSkillsStatus(boundWorkspace),
|
||||
);
|
||||
} catch (err) {
|
||||
// The channel can die mid-RPC (`liveChannelInfo()` was valid at the
|
||||
// check but the child exited before the call completed). Treat that
|
||||
// like "no live child" and fall back to the cache / daemon-local
|
||||
// enumeration below instead of failing the request — matching
|
||||
// getWorkspaceEnvStatus / getWorkspacePreflightStatus.
|
||||
writeStderrLine(
|
||||
`qwen serve: getWorkspaceSkillsStatus query failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
status = createIdleWorkspaceSkillsStatus(boundWorkspace);
|
||||
}
|
||||
if (status.initialized && generation === workspaceSkillsGeneration) {
|
||||
lastWorkspaceSkillsStatus = status;
|
||||
lastWorkspaceSkillsStatusAt = Date.now();
|
||||
return status;
|
||||
}
|
||||
// Live child unavailable. Prefer the last answer it produced (keeps the
|
||||
// full, extension-aware list available across a reap)...
|
||||
if (lastWorkspaceSkillsStatus) {
|
||||
// Only extend the freshness window when this read still owns the current
|
||||
// generation. A read that started before an invalidation must not push out
|
||||
// the TTL of the snapshot some later read committed — that would let a
|
||||
// post-mutation snapshot go unrevalidated for longer than the window.
|
||||
if (generation === workspaceSkillsGeneration) {
|
||||
lastWorkspaceSkillsStatusAt = Date.now();
|
||||
}
|
||||
return lastWorkspaceSkillsStatus;
|
||||
}
|
||||
// ...then fall back to daemon-local enumeration, so a child that has not
|
||||
// answered even once (e.g. a preheat that times out under `npm run dev`)
|
||||
// still yields the on-disk skills — `/review` included. The provider
|
||||
// handles its own errors, but it is injected, so guard the call too and
|
||||
// degrade to the idle placeholder rather than failing the request —
|
||||
// matching getWorkspaceEnvStatus / getWorkspacePreflightStatus.
|
||||
if (workspaceSkillsStatusProvider) {
|
||||
try {
|
||||
const localStatus = await workspaceSkillsStatusProvider(boundWorkspace);
|
||||
if (
|
||||
localStatus.initialized &&
|
||||
generation === workspaceSkillsGeneration
|
||||
) {
|
||||
lastWorkspaceSkillsStatus = localStatus;
|
||||
lastWorkspaceSkillsStatusAt = Date.now();
|
||||
}
|
||||
return localStatus;
|
||||
} catch (err) {
|
||||
writeStderrLine(
|
||||
`qwen serve: getWorkspaceSkillsStatus local provider failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
const getWorkspaceSkillsStatus = (): Promise<ServeWorkspaceSkillsStatus> => {
|
||||
const cacheAgeMs = Date.now() - lastWorkspaceSkillsStatusAt;
|
||||
if (
|
||||
lastWorkspaceSkillsStatus &&
|
||||
cacheAgeMs >= 0 &&
|
||||
cacheAgeMs < WORKSPACE_SKILLS_SNAPSHOT_TTL_MS
|
||||
) {
|
||||
return Promise.resolve(lastWorkspaceSkillsStatus);
|
||||
}
|
||||
|
||||
const generation = workspaceSkillsGeneration;
|
||||
if (inFlightWorkspaceSkillsStatus?.generation === generation) {
|
||||
return inFlightWorkspaceSkillsStatus.promise;
|
||||
}
|
||||
|
||||
const promise = readWorkspaceSkillsStatus(generation);
|
||||
inFlightWorkspaceSkillsStatus = { generation, promise };
|
||||
const clearInFlight = () => {
|
||||
if (inFlightWorkspaceSkillsStatus?.promise === promise) {
|
||||
inFlightWorkspaceSkillsStatus = undefined;
|
||||
}
|
||||
};
|
||||
void promise.then(clearInFlight, clearInFlight);
|
||||
return promise;
|
||||
};
|
||||
|
||||
const refreshWorkspaceSkillsAfterMutation = async (): Promise<void> => {
|
||||
lastWorkspaceSkillsStatus = undefined;
|
||||
workspaceSkillsStatusProvider?.invalidate?.(boundWorkspace);
|
||||
invalidateWorkspaceSkillsSnapshot();
|
||||
if (!(isChannelLive?.() ?? false)) return;
|
||||
try {
|
||||
await invokeWorkspaceCommand<ServeWorkspaceSkillsRefreshResult>(
|
||||
SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh,
|
||||
{ cwd: boundWorkspace },
|
||||
);
|
||||
const refreshed =
|
||||
await invokeWorkspaceCommand<ServeWorkspaceSkillsRefreshResult>(
|
||||
SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh,
|
||||
{ cwd: boundWorkspace, reason: 'content' },
|
||||
);
|
||||
// `content` is the only reason that refreshes skill caches, so this is
|
||||
// the one path where a non-zero count is meaningful. The mutation itself
|
||||
// still succeeded; surface the partial refresh rather than dropping it.
|
||||
if ((refreshed.configsFailed ?? 0) > 0) {
|
||||
writeStderrLine(
|
||||
`qwen serve: ${refreshed.configsFailed} skill cache refresh(es) failed after mutation`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (
|
||||
!(err instanceof SessionNotFoundError) &&
|
||||
|
|
@ -309,6 +379,8 @@ export function createDaemonWorkspaceService(
|
|||
`qwen serve: workspace skill refresh after mutation failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
invalidateWorkspaceSkillsSnapshot();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -786,17 +858,19 @@ export function createDaemonWorkspaceService(
|
|||
let sessionsFailed = 0;
|
||||
|
||||
if (persisted.changed) {
|
||||
lastWorkspaceSkillsStatus = undefined;
|
||||
workspaceSkillsStatusProvider?.invalidate?.(boundWorkspace);
|
||||
invalidateWorkspaceSkillsSnapshot();
|
||||
if (channelLive) {
|
||||
try {
|
||||
const refreshed =
|
||||
await invokeWorkspaceCommand<ServeWorkspaceSkillsRefreshResult>(
|
||||
SERVE_CONTROL_EXT_METHODS.workspaceSkillsRefresh,
|
||||
{ cwd: boundWorkspace },
|
||||
{ cwd: boundWorkspace, reason: 'settings' },
|
||||
);
|
||||
assertActiveGeneration();
|
||||
sessionsRefreshed = refreshed.sessionsRefreshed;
|
||||
// `reason: 'settings'` never touches skill caches, so
|
||||
// `configsFailed` is structurally 0 here — folding it in would only
|
||||
// conflate two different failures behind one count.
|
||||
sessionsFailed = refreshed.sessionsFailed;
|
||||
if (sessionsFailed > 0) activation = 'partial';
|
||||
} catch (err) {
|
||||
|
|
@ -813,6 +887,7 @@ export function createDaemonWorkspaceService(
|
|||
);
|
||||
}
|
||||
}
|
||||
invalidateWorkspaceSkillsSnapshot();
|
||||
}
|
||||
|
||||
assertActiveGeneration();
|
||||
|
|
@ -1247,7 +1322,7 @@ export function createDaemonWorkspaceService(
|
|||
},
|
||||
|
||||
invalidateWorkspaceSkillsStatus() {
|
||||
lastWorkspaceSkillsStatus = undefined;
|
||||
invalidateWorkspaceSkillsSnapshot();
|
||||
},
|
||||
|
||||
async refreshExtensionsForAllSessions() {
|
||||
|
|
@ -1263,7 +1338,7 @@ export function createDaemonWorkspaceService(
|
|||
);
|
||||
return { refreshed: 0, failed: 1 };
|
||||
} finally {
|
||||
lastWorkspaceSkillsStatus = undefined;
|
||||
invalidateWorkspaceSkillsSnapshot();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -339,6 +339,24 @@ describe('cdCommand', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('reports a successful move when MCP refresh fails afterward', async () => {
|
||||
relocateWorkingDirectory.mockResolvedValue({
|
||||
mcpRefreshError: new Error('MCP failed'),
|
||||
});
|
||||
|
||||
const result = (await cdCommand.action?.(
|
||||
context,
|
||||
'../next',
|
||||
)) as MessageActionReturn;
|
||||
const realNextDir = await realpath(nextDir);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'warning',
|
||||
content: `Moved to ${realNextDir}. MCP refresh failed: MCP failed`,
|
||||
});
|
||||
});
|
||||
|
||||
it('asks for confirmation before moving to an untrusted directory', async () => {
|
||||
context = createMockCommandContext({
|
||||
invocation: {
|
||||
|
|
|
|||
|
|
@ -179,6 +179,15 @@ export const cdCommand: SlashCommand = {
|
|||
}`,
|
||||
);
|
||||
}
|
||||
if (relocation.mcpRefreshError) {
|
||||
warnings.push(
|
||||
`MCP refresh failed: ${
|
||||
relocation.mcpRefreshError instanceof Error
|
||||
? relocation.mcpRefreshError.message
|
||||
: String(relocation.mcpRefreshError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
|
|
|
|||
|
|
@ -118,6 +118,147 @@ describe('resumeHistoryUtils', () => {
|
|||
expect(userItem.text).toBe('post-gap message');
|
||||
});
|
||||
|
||||
describe('UserPromptSubmit hook context provenance', () => {
|
||||
const tagged =
|
||||
'<qwen:user-prompt-submit-context>\ninjected hook context\n</qwen:user-prompt-submit-context>';
|
||||
|
||||
const buildUserItems = (record: Record<string, unknown>) => {
|
||||
const conversation = {
|
||||
messages: [record],
|
||||
} as unknown as ConversationRecord;
|
||||
const session: ResumedSessionData = {
|
||||
conversation,
|
||||
} as ResumedSessionData;
|
||||
return buildResumedHistoryItems(session, makeConfig({}), 1_000);
|
||||
};
|
||||
|
||||
it('prefers recorded displayText over the augmented parts', () => {
|
||||
const items = buildUserItems({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: 'my prompt' }, { text: tagged }] },
|
||||
systemPayload: {
|
||||
displayText: 'my prompt',
|
||||
},
|
||||
});
|
||||
expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]);
|
||||
});
|
||||
|
||||
it('prefers displayText over the tag-strip fallback', () => {
|
||||
// Fixture where the two branches disagree: without displayText the
|
||||
// tag-strip path would expose the middle "expanded extra" part.
|
||||
const items = buildUserItems({
|
||||
type: 'user',
|
||||
message: {
|
||||
parts: [
|
||||
{ text: 'my prompt' },
|
||||
{ text: 'expanded extra' },
|
||||
{ text: tagged },
|
||||
],
|
||||
},
|
||||
systemPayload: {
|
||||
displayText: 'my prompt',
|
||||
},
|
||||
});
|
||||
expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]);
|
||||
});
|
||||
|
||||
it('strips a trailing whole-part tagged block when no displayText is recorded', () => {
|
||||
const items = buildUserItems({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: 'my prompt' }, { text: tagged }] },
|
||||
});
|
||||
expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]);
|
||||
});
|
||||
|
||||
it('keeps user-authored text that merely contains the tag', () => {
|
||||
const items = buildUserItems({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: `quote: ${tagged} end` }] },
|
||||
});
|
||||
expect(items).toEqual([
|
||||
{ id: 1_001, type: 'user', text: `quote: ${tagged} end` },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a sole part that matches the tag shape (user-authored)', () => {
|
||||
const items = buildUserItems({
|
||||
type: 'user',
|
||||
message: { parts: [{ text: tagged }] },
|
||||
});
|
||||
expect(items).toEqual([{ id: 1_001, type: 'user', text: tagged }]);
|
||||
});
|
||||
|
||||
it('falls back to raw concatenation for legacy bare-injected records', () => {
|
||||
const items = buildUserItems({
|
||||
type: 'user',
|
||||
message: {
|
||||
parts: [{ text: 'my prompt' }, { text: 'bare injected context' }],
|
||||
},
|
||||
});
|
||||
expect(items).toEqual([
|
||||
{ id: 1_001, type: 'user', text: 'my prompt\nbare injected context' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers at_command userText even when the paired user record has a trailing tagged part', () => {
|
||||
const conversation = {
|
||||
messages: [
|
||||
{
|
||||
type: 'system',
|
||||
subtype: 'at_command',
|
||||
systemPayload: {
|
||||
userText: '@file.ts summarize this',
|
||||
filesRead: ['/tmp/file.ts'],
|
||||
status: 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
parts: [{ text: 'expanded model prompt' }, { text: tagged }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as ConversationRecord;
|
||||
const items = buildResumedHistoryItems(
|
||||
{ conversation } as ResumedSessionData,
|
||||
makeConfig({}),
|
||||
1_000,
|
||||
);
|
||||
const userItem = items.find((i) => i.type === 'user') as { text: string };
|
||||
expect(userItem.text).toBe('@file.ts summarize this');
|
||||
expect(userItem.text).not.toContain('qwen:user-prompt-submit-context');
|
||||
});
|
||||
|
||||
it('strips a trailing tagged part when at_command userText is absent', () => {
|
||||
const conversation = {
|
||||
messages: [
|
||||
{
|
||||
type: 'system',
|
||||
subtype: 'at_command',
|
||||
systemPayload: {
|
||||
filesRead: ['/tmp/file.ts'],
|
||||
status: 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
message: {
|
||||
parts: [{ text: 'my prompt' }, { text: tagged }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as ConversationRecord;
|
||||
const items = buildResumedHistoryItems(
|
||||
{ conversation } as ResumedSessionData,
|
||||
makeConfig({}),
|
||||
1_000,
|
||||
);
|
||||
const userItem = items.find((i) => i.type === 'user') as { text: string };
|
||||
expect(userItem.text).toBe('my prompt');
|
||||
});
|
||||
});
|
||||
|
||||
it('converts conversation into history items with incremental ids', () => {
|
||||
const conversation = {
|
||||
messages: [
|
||||
|
|
|
|||
|
|
@ -15,8 +15,12 @@ import type {
|
|||
SlashCommandRecordPayload,
|
||||
AtCommandRecordPayload,
|
||||
HistoryGap,
|
||||
UserPromptRecordPayload,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
getToolResponseDisplayText,
|
||||
stripTrailingUserPromptSubmitContextPart,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { getToolResponseDisplayText } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemInfo,
|
||||
|
|
@ -31,6 +35,28 @@ import {
|
|||
indexGapsByChild,
|
||||
} from './history-gap-notice.js';
|
||||
|
||||
/**
|
||||
* Projects a plain user record to its display text.
|
||||
*
|
||||
* Prefers the `displayText` recorded when a UserPromptSubmit hook augmented
|
||||
* the model-bound parts. For records that carry the reserved tag but no
|
||||
* payload (written by other/newer writers), drops a trailing part that is
|
||||
* entirely a tagged hook-context block. Legacy records with bare injected
|
||||
* text fall back to the raw part concatenation.
|
||||
*/
|
||||
function extractUserRecordDisplayText(
|
||||
record: ConversationRecord['messages'][number],
|
||||
): string {
|
||||
const payload = record.systemPayload as UserPromptRecordPayload | undefined;
|
||||
if (payload?.displayText) {
|
||||
return payload.displayText;
|
||||
}
|
||||
const parts = (record.message?.parts as Part[] | undefined) ?? [];
|
||||
return extractTextFromParts([
|
||||
...stripTrailingUserPromptSubmitContextPart(parts),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from a Content object's parts (excluding thought parts).
|
||||
*/
|
||||
|
|
@ -356,9 +382,7 @@ function convertToHistoryItems(
|
|||
}
|
||||
|
||||
const payload = pendingAtCommands.shift()!;
|
||||
const text =
|
||||
payload.userText ||
|
||||
extractTextFromParts(record.message?.parts as Part[]);
|
||||
const text = payload.userText || extractUserRecordDisplayText(record);
|
||||
if (text) {
|
||||
items.push({ type: 'user', text });
|
||||
}
|
||||
|
|
@ -381,7 +405,7 @@ function convertToHistoryItems(
|
|||
currentToolGroup = [];
|
||||
}
|
||||
|
||||
const text = extractTextFromParts(record.message?.parts as Part[]);
|
||||
const text = extractUserRecordDisplayText(record);
|
||||
if (text) {
|
||||
items.push({ type: 'user', text });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ export default defineConfig({
|
|||
__dirname,
|
||||
'../core/src/utils/transcript-records.ts',
|
||||
),
|
||||
'@qwen-code/qwen-code-core/userPromptSubmitContext': path.resolve(
|
||||
__dirname,
|
||||
'../core/src/hooks/user-prompt-submit-context.ts',
|
||||
),
|
||||
'@qwen-code/qwen-code-core': path.resolve(__dirname, '../core/index.ts'),
|
||||
// cli's daemon-status-provider.test.ts imports `FakeAgent` /
|
||||
// `makeChannel` from acp-bridge's package-private
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@
|
|||
"types": "./dist/src/goals/goal-wire.d.ts",
|
||||
"import": "./dist/src/goals/goal-wire.js"
|
||||
},
|
||||
"./userPromptSubmitContext": {
|
||||
"types": "./dist/src/hooks/user-prompt-submit-context.d.ts",
|
||||
"import": "./dist/src/hooks/user-prompt-submit-context.js"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./dist/*": "./dist/*",
|
||||
"./src/*": "./src/*"
|
||||
|
|
|
|||
|
|
@ -138,12 +138,14 @@ describe('agent-transcript', () => {
|
|||
status: 'running',
|
||||
subagentName: 'explore',
|
||||
resolvedApprovalMode: 'auto-edit',
|
||||
executionAllowedTools: [],
|
||||
});
|
||||
|
||||
expect(readAgentMeta(metaPath)).toMatchObject({
|
||||
agentId: 'a',
|
||||
status: 'running',
|
||||
subagentName: 'explore',
|
||||
executionAllowedTools: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -273,6 +275,9 @@ describe('agent-transcript', () => {
|
|||
kind: 'fork',
|
||||
history: [],
|
||||
});
|
||||
expect(records[0]?.systemPayload).not.toHaveProperty(
|
||||
'executionAllowedTools',
|
||||
);
|
||||
});
|
||||
|
||||
it('writes a ROUND_TEXT event as an assistant record with text part', () => {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,11 @@ export interface AgentMeta {
|
|||
lastUpdatedAt?: string;
|
||||
/** Resolved approval mode used when the agent was launched. */
|
||||
resolvedApprovalMode?: string;
|
||||
/**
|
||||
* Immutable launch-time execution policy for a restricted fork.
|
||||
* Absence preserves unrestricted execution; an empty list means deny-all.
|
||||
*/
|
||||
executionAllowedTools?: string[];
|
||||
/** Launch-time CLI/runtime flags that should survive process restart. */
|
||||
persistedCliFlags?: AgentPersistedCliFlags;
|
||||
/** Canonical subagent config name used to recreate this agent. */
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ describe('BackgroundAgentResumeService', () => {
|
|||
copyDiscoveredToolsFrom: vi.fn(),
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
getAllToolNames: vi.fn().mockReturnValue([]),
|
||||
getTool: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
warmAll: vi.fn().mockResolvedValue(undefined),
|
||||
getDeferredToolSummary: vi
|
||||
|
|
@ -2028,11 +2029,16 @@ describe('BackgroundAgentResumeService', () => {
|
|||
},
|
||||
tools: [{ name: 'Bash' }, { name: 'mcp__removed__search' }],
|
||||
},
|
||||
executionAllowedTools: ['Read'] as string[] | undefined,
|
||||
},
|
||||
{
|
||||
format: 'history-only bootstrap',
|
||||
legacyCapabilities: {},
|
||||
executionAllowedTools: undefined as string[] | undefined,
|
||||
},
|
||||
{ format: 'history-only bootstrap', legacyCapabilities: {} },
|
||||
])(
|
||||
'resumes fork agents with the current parent prompt and live tool registry ($format)',
|
||||
async ({ legacyCapabilities }) => {
|
||||
async ({ legacyCapabilities, executionAllowedTools }) => {
|
||||
const sessionId = 'session-fork-resume';
|
||||
const agentId = 'agent-fork-resume';
|
||||
const metaPath = getAgentMetaPath(tempDir, sessionId, agentId);
|
||||
|
|
@ -2049,6 +2055,9 @@ describe('BackgroundAgentResumeService', () => {
|
|||
status: 'running',
|
||||
subagentName: FORK_SUBAGENT_TYPE,
|
||||
resolvedApprovalMode: 'default',
|
||||
...(executionAllowedTools !== undefined
|
||||
? { executionAllowedTools }
|
||||
: {}),
|
||||
});
|
||||
fs.writeFileSync(
|
||||
outputFile,
|
||||
|
|
@ -2113,39 +2122,61 @@ describe('BackgroundAgentResumeService', () => {
|
|||
isBackgrounded: true,
|
||||
});
|
||||
|
||||
const execute = vi.fn(async (_context: unknown) => undefined);
|
||||
const subagent = {
|
||||
execute,
|
||||
setExternalMessageProvider: vi.fn(),
|
||||
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
|
||||
getExecutionSummary: () => ({
|
||||
totalTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalDurationMs: 0,
|
||||
}),
|
||||
getTerminateMode: () => AgentTerminateMode.GOAL,
|
||||
getFinalText: () => 'done',
|
||||
};
|
||||
|
||||
const originalCreate = AgentHeadless.create;
|
||||
let executeContext: unknown;
|
||||
let deniedError: unknown;
|
||||
const createSpy = vi
|
||||
.spyOn(AgentHeadless, 'create')
|
||||
.mockResolvedValue(subagent as unknown as AgentHeadless);
|
||||
.mockImplementation(async (...args) => {
|
||||
const subagent = await originalCreate(...args);
|
||||
vi.spyOn(subagent, 'execute').mockImplementation(async (context) => {
|
||||
executeContext = context;
|
||||
if (executionAllowedTools === undefined) {
|
||||
return;
|
||||
}
|
||||
const denial = await subagent
|
||||
.getCore()
|
||||
.processFunctionCalls(
|
||||
[{ id: 'call-edit', name: 'Edit', args: {} }],
|
||||
new AbortController(),
|
||||
'resume-policy-test',
|
||||
1,
|
||||
[{ name: 'Read' }, { name: 'Edit' }],
|
||||
);
|
||||
deniedError =
|
||||
denial.messages[0]?.parts?.[0]?.functionResponse?.response?.[
|
||||
'error'
|
||||
];
|
||||
});
|
||||
vi.spyOn(subagent, 'getTerminateMode').mockReturnValue(
|
||||
AgentTerminateMode.GOAL,
|
||||
);
|
||||
vi.spyOn(subagent, 'getFinalText').mockReturnValue('done');
|
||||
return subagent;
|
||||
});
|
||||
const currentSystemInstruction: Content = {
|
||||
role: 'system',
|
||||
parts: [{ text: 'current parent system instruction' }],
|
||||
};
|
||||
const { service, subagentManager } = createService({
|
||||
const { service, subagentManager, stubToolRegistry } = createService({
|
||||
currentForkRuntime: {
|
||||
systemInstruction: currentSystemInstruction,
|
||||
advertisedTools: [
|
||||
{ name: 'Read', description: 'advertised current schema' },
|
||||
{ name: 'Edit', description: 'advertised edit schema' },
|
||||
{ name: 'mcp__removed__search' },
|
||||
],
|
||||
registeredTools: [
|
||||
{ name: 'Read', description: 'registered current schema' },
|
||||
{ name: 'Edit', description: 'registered edit schema' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const deniedBuild = vi.fn();
|
||||
stubToolRegistry.getTool.mockReturnValue({
|
||||
name: 'Edit',
|
||||
build: deniedBuild,
|
||||
});
|
||||
const resumed = await service.resumeBackgroundAgent(agentId, 'continue');
|
||||
|
||||
expect(resumed).toBeDefined();
|
||||
|
|
@ -2166,12 +2197,13 @@ describe('BackgroundAgentResumeService', () => {
|
|||
max_turns: FORK_DEFAULT_MAX_TURNS,
|
||||
});
|
||||
expect(createArgs?.[5]).toEqual({
|
||||
tools: ['Read'],
|
||||
tools: ['Read', 'Edit'],
|
||||
...(executionAllowedTools !== undefined
|
||||
? { executionAllowedTools }
|
||||
: {}),
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
const executeCall = execute.mock.calls[0];
|
||||
expect(executeCall).toBeDefined();
|
||||
const contextArg = executeCall?.[0] as
|
||||
expect(executeContext).toBeDefined();
|
||||
const contextArg = executeContext as
|
||||
| { get(key: string): unknown }
|
||||
| undefined;
|
||||
expect(contextArg).toBeDefined();
|
||||
|
|
@ -2182,6 +2214,14 @@ describe('BackgroundAgentResumeService', () => {
|
|||
'Earlier capability listings in the conversation history are obsolete',
|
||||
);
|
||||
expect(contextArg.get('task_prompt')).toContain('continue');
|
||||
if (executionAllowedTools !== undefined) {
|
||||
expect(deniedError).toContain('execution allowlist');
|
||||
expect(deniedError).not.toContain('not found');
|
||||
expect(stubToolRegistry.getTool).not.toHaveBeenCalled();
|
||||
expect(deniedBuild).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(deniedError).toBeUndefined();
|
||||
}
|
||||
createSpy.mockRestore();
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -980,6 +980,7 @@ export class BackgroundAgentResumeService {
|
|||
bgEventEmitter,
|
||||
resumeHistory ?? [],
|
||||
currentForkRuntime!,
|
||||
meta.executionAllowedTools,
|
||||
);
|
||||
} else {
|
||||
const resumeSubagentConfig =
|
||||
|
|
@ -1659,6 +1660,7 @@ export class BackgroundAgentResumeService {
|
|||
eventEmitter: AgentEventEmitter,
|
||||
initialMessages: Content[],
|
||||
runtime: CurrentForkRuntime,
|
||||
executionAllowedTools?: string[],
|
||||
): Promise<AgentHeadless> {
|
||||
const promptConfig: PromptConfig = {
|
||||
renderedSystemPrompt: structuredClone(runtime.systemInstruction),
|
||||
|
|
@ -1666,6 +1668,9 @@ export class BackgroundAgentResumeService {
|
|||
};
|
||||
const toolConfig: ToolConfig = {
|
||||
tools: [...runtime.toolNames],
|
||||
...(executionAllowedTools !== undefined
|
||||
? { executionAllowedTools: structuredClone(executionAllowedTools) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return AgentHeadless.create(
|
||||
|
|
|
|||
|
|
@ -115,6 +115,34 @@ import {
|
|||
SUBAGENT_PLAN_LIFECYCLE_TOOLS,
|
||||
} from './subagent-plan-tool-policy.js';
|
||||
|
||||
const EXECUTION_ALLOWLIST_ERROR_MAX_ITEMS = 8;
|
||||
const EXECUTION_ALLOWLIST_ERROR_MAX_CHARS = 240;
|
||||
|
||||
function summarizeExecutionAllowlist(
|
||||
executionAllowedTools: readonly string[],
|
||||
): string | undefined {
|
||||
if (executionAllowedTools.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const visibleTools = executionAllowedTools.slice(
|
||||
0,
|
||||
EXECUTION_ALLOWLIST_ERROR_MAX_ITEMS,
|
||||
);
|
||||
let summary = visibleTools
|
||||
.map((toolName) => JSON.stringify(toolName))
|
||||
.join(', ');
|
||||
const wasClipped = summary.length > EXECUTION_ALLOWLIST_ERROR_MAX_CHARS;
|
||||
if (wasClipped) {
|
||||
summary = `${summary.slice(0, EXECUTION_ALLOWLIST_ERROR_MAX_CHARS - 3)}...`;
|
||||
}
|
||||
const omittedCount = executionAllowedTools.length - visibleTools.length;
|
||||
if (omittedCount > 0) {
|
||||
return `${summary} (+${omittedCount} more)`;
|
||||
}
|
||||
return wasClipped ? `${summary} (truncated)` : summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a single reasoning loop invocation.
|
||||
*/
|
||||
|
|
@ -342,6 +370,10 @@ export class AgentCore {
|
|||
readonly modelConfig: ModelConfig;
|
||||
readonly runConfig: RunConfig;
|
||||
readonly toolConfig?: ToolConfig;
|
||||
private readonly executionAllowedTools?: readonly string[];
|
||||
private readonly executionAllowedExactTools?: ReadonlySet<string>;
|
||||
private readonly executionAllowedMcpPatterns?: readonly string[];
|
||||
private readonly executionAllowlistErrorSummary?: string;
|
||||
/**
|
||||
* Event emitter for this agent. Always present — if the caller doesn't
|
||||
* pass one, AgentCore allocates its own so the observable state below
|
||||
|
|
@ -421,6 +453,22 @@ export class AgentCore {
|
|||
this.modelConfig = modelConfig;
|
||||
this.runConfig = runConfig;
|
||||
this.toolConfig = toolConfig;
|
||||
if (toolConfig?.executionAllowedTools !== undefined) {
|
||||
this.executionAllowedTools = Object.freeze([
|
||||
...toolConfig.executionAllowedTools,
|
||||
]);
|
||||
this.executionAllowedExactTools = new Set(
|
||||
this.executionAllowedTools.filter(
|
||||
(toolName) => !toolName.includes('*'),
|
||||
),
|
||||
);
|
||||
this.executionAllowedMcpPatterns = Object.freeze(
|
||||
this.executionAllowedTools.filter((toolName) => toolName.includes('*')),
|
||||
);
|
||||
this.executionAllowlistErrorSummary = summarizeExecutionAllowlist(
|
||||
this.executionAllowedTools,
|
||||
);
|
||||
}
|
||||
this.eventEmitter = eventEmitter ?? new AgentEventEmitter();
|
||||
this.hooks = hooks;
|
||||
this.runtimeView = runtimeView;
|
||||
|
|
@ -1388,6 +1436,62 @@ export class AgentCore {
|
|||
);
|
||||
}
|
||||
|
||||
private isToolExecutionAllowed(toolName: string): boolean {
|
||||
if (this.executionAllowedTools === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (this.executionAllowedExactTools?.has(toolName)) {
|
||||
return true;
|
||||
}
|
||||
if (!toolName.startsWith('mcp__')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match MCP patterns against the registry's raw server/tool identity.
|
||||
// Comparing provider-sanitized prefixes can merge distinct server names
|
||||
// such as "repo.bad" and "repo/bad", so it is unsafe for an allowlist.
|
||||
const registeredTool = this.runtimeContext
|
||||
.getToolRegistry()
|
||||
.getTool(toolName) as
|
||||
| { serverName?: unknown; serverToolName?: unknown }
|
||||
| undefined;
|
||||
if (
|
||||
typeof registeredTool?.serverName !== 'string' ||
|
||||
typeof registeredTool.serverToolName !== 'string'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const serverName = registeredTool.serverName;
|
||||
const serverToolName = registeredTool.serverToolName;
|
||||
const serverPattern = `mcp__${serverName}`;
|
||||
const rawToolName = `${serverPattern}__${serverToolName}`;
|
||||
if (
|
||||
this.executionAllowedExactTools?.has(serverPattern) ||
|
||||
this.executionAllowedExactTools?.has(rawToolName)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.executionAllowedMcpPatterns!.some((pattern) => {
|
||||
if (pattern === 'mcp__*') {
|
||||
return true;
|
||||
}
|
||||
if (!pattern.startsWith('mcp__')) {
|
||||
return false;
|
||||
}
|
||||
if (!pattern.endsWith('*')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const toolPatternPrefix = `${serverPattern}__`;
|
||||
return (
|
||||
pattern.startsWith(toolPatternPrefix) &&
|
||||
serverToolName.startsWith(pattern.slice(toolPatternPrefix.length, -1))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a list of function calls via CoreToolScheduler.
|
||||
*
|
||||
|
|
@ -1429,8 +1533,10 @@ export class AgentCore {
|
|||
]),
|
||||
);
|
||||
|
||||
// Build allowed tool names set for filtering
|
||||
const allowedToolNames = new Set(toolsList.map((t) => t.name));
|
||||
// The model-visible declarations and the execution allowlist are separate:
|
||||
// forks keep the parent's declaration prefix for cache sharing while
|
||||
// optionally narrowing which declared tools may actually run.
|
||||
const declaredToolNames = new Set(toolsList.map((t) => t.name));
|
||||
const repeatedDuplicateCall = findRepeatedDuplicateProviderToolCall(
|
||||
uniqueFunctionCalls,
|
||||
(fc) => getProviderToolCallId(fc) ?? fc.id,
|
||||
|
|
@ -1461,12 +1567,21 @@ export class AgentCore {
|
|||
const toolName = String(fc.name);
|
||||
const args = (fc.args ?? {}) as Record<string, unknown>;
|
||||
|
||||
if (!allowedToolNames.has(fc.name)) {
|
||||
const errorMessage = isPlanLifecycleToolUnavailableInSubagent(toolName)
|
||||
let errorMessage: string | undefined;
|
||||
if (!declaredToolNames.has(fc.name)) {
|
||||
errorMessage = isPlanLifecycleToolUnavailableInSubagent(toolName)
|
||||
? getSubagentPlanToolUnavailableMessage(toolName)
|
||||
: isLeaderOnlyToolUnavailableInSubagent(toolName)
|
||||
? getLeaderOnlyToolUnavailableMessage(toolName)
|
||||
: `Tool "${toolName}" not found. Tools must use the exact names provided.`;
|
||||
} else if (!this.isToolExecutionAllowed(toolName)) {
|
||||
errorMessage =
|
||||
this.executionAllowlistErrorSummary !== undefined
|
||||
? `Tool "${toolName}" is not allowed by this agent's execution allowlist. Allowed entries: ${this.executionAllowlistErrorSummary}.`
|
||||
: `Tool "${toolName}" is not allowed by this agent's execution allowlist. No tools are allowed.`;
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
const functionResponsePart = {
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ import type {
|
|||
} from './agent-types.js';
|
||||
import { AgentTerminateMode } from './agent-types.js';
|
||||
import { WriteFileTool } from '../../tools/write-file.js';
|
||||
import { ToolNames } from '../../tools/tool-names.js';
|
||||
import { normalizeToolNameForProvider } from '../../utils/tool-name-utils.js';
|
||||
|
||||
vi.mock('../../core/geminiChat.js');
|
||||
vi.mock('../../core/contentGenerator.js', async (importOriginal) => {
|
||||
|
|
@ -1392,9 +1394,549 @@ describe('subagent.ts', () => {
|
|||
'file1.txt\nfile2.ts',
|
||||
);
|
||||
|
||||
expect(listFilesInvocation.execute).toHaveBeenCalledTimes(1);
|
||||
expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL);
|
||||
});
|
||||
|
||||
it('keeps declarations unchanged while enforcing the execution allowlist', async () => {
|
||||
const readFileToolDef: FunctionDeclaration = {
|
||||
name: ToolNames.READ_FILE,
|
||||
description: 'Reads a file',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
const editFileToolDef: FunctionDeclaration = {
|
||||
name: ToolNames.EDIT,
|
||||
description: 'Edits a file',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
const { config } = await createMockConfig();
|
||||
|
||||
const readFileInvocation = {
|
||||
params: { path: 'README.md' },
|
||||
getDescription: vi.fn().mockReturnValue('Read README.md'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: 'file contents',
|
||||
returnDisplay: 'file contents',
|
||||
}),
|
||||
};
|
||||
const editFileInvocation = {
|
||||
params: { path: 'README.md' },
|
||||
getDescription: vi.fn().mockReturnValue('Edit README.md'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
|
||||
execute: vi.fn(),
|
||||
};
|
||||
const readFileTool = {
|
||||
name: ToolNames.READ_FILE,
|
||||
displayName: 'Read File',
|
||||
description: 'Reads a file',
|
||||
kind: 'READ' as const,
|
||||
schema: readFileToolDef,
|
||||
build: vi.fn().mockReturnValue(readFileInvocation),
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: true,
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const editFileTool = {
|
||||
name: ToolNames.EDIT,
|
||||
displayName: 'Edit File',
|
||||
description: 'Edits a file',
|
||||
kind: 'EDIT' as const,
|
||||
schema: editFileToolDef,
|
||||
build: vi.fn().mockReturnValue(editFileInvocation),
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: true,
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
vi.mocked(config.getToolRegistry().getTool).mockImplementation(
|
||||
(name: string) =>
|
||||
name === ToolNames.READ_FILE
|
||||
? readFileTool
|
||||
: name === ToolNames.EDIT
|
||||
? editFileTool
|
||||
: undefined,
|
||||
);
|
||||
|
||||
mockSendMessageStream.mockImplementation(
|
||||
createMockStream([
|
||||
[
|
||||
{
|
||||
id: 'call_read',
|
||||
name: ToolNames.READ_FILE,
|
||||
args: { path: 'README.md' },
|
||||
},
|
||||
{
|
||||
id: 'call_edit',
|
||||
name: ToolNames.EDIT,
|
||||
args: { path: 'README.md', old_string: 'a', new_string: 'b' },
|
||||
},
|
||||
],
|
||||
'stop',
|
||||
]),
|
||||
);
|
||||
|
||||
const toolCallEvents: AgentToolCallEvent[] = [];
|
||||
const toolResultEvents: AgentToolResultEvent[] = [];
|
||||
const approvalEvents: unknown[] = [];
|
||||
const eventEmitter = new AgentEventEmitter();
|
||||
eventEmitter.on(AgentEventType.TOOL_CALL, (event: unknown) => {
|
||||
toolCallEvents.push(event as AgentToolCallEvent);
|
||||
});
|
||||
eventEmitter.on(AgentEventType.TOOL_RESULT, (event: unknown) => {
|
||||
toolResultEvents.push(event as AgentToolResultEvent);
|
||||
});
|
||||
eventEmitter.on(
|
||||
AgentEventType.TOOL_WAITING_APPROVAL,
|
||||
(event: unknown) => {
|
||||
approvalEvents.push(event);
|
||||
},
|
||||
);
|
||||
|
||||
const executionAllowedTools: string[] = [ToolNames.READ_FILE];
|
||||
const scope = await AgentHeadless.create(
|
||||
'fork',
|
||||
config,
|
||||
{ systemPrompt: 'Test prompt' },
|
||||
defaultModelConfig,
|
||||
defaultRunConfig,
|
||||
{
|
||||
tools: [readFileToolDef, editFileToolDef],
|
||||
executionAllowedTools,
|
||||
},
|
||||
eventEmitter,
|
||||
);
|
||||
executionAllowedTools.push(ToolNames.EDIT);
|
||||
await scope.execute(new ContextState());
|
||||
|
||||
const sentDeclarations =
|
||||
mockSendMessageStream.mock.calls[0][1].config.tools[0]
|
||||
.functionDeclarations;
|
||||
expect(sentDeclarations).toStrictEqual([
|
||||
readFileToolDef,
|
||||
editFileToolDef,
|
||||
]);
|
||||
expect(JSON.stringify(sentDeclarations)).toBe(
|
||||
JSON.stringify([readFileToolDef, editFileToolDef]),
|
||||
);
|
||||
expect(readFileTool.build).toHaveBeenCalled();
|
||||
expect(readFileInvocation.execute).toHaveBeenCalledTimes(1);
|
||||
expect(editFileTool.build).not.toHaveBeenCalled();
|
||||
expect(editFileInvocation.execute).not.toHaveBeenCalled();
|
||||
expect(approvalEvents).toHaveLength(0);
|
||||
|
||||
const secondRoundParts = mockSendMessageStream.mock.calls[1][1]
|
||||
.message as Part[];
|
||||
expect(
|
||||
secondRoundParts.map((part) => part.functionResponse?.id),
|
||||
).toEqual(['call_read', 'call_edit']);
|
||||
const deniedResponse = secondRoundParts.find(
|
||||
(part) => part.functionResponse?.id === 'call_edit',
|
||||
)?.functionResponse;
|
||||
expect(deniedResponse?.name).toBe(ToolNames.EDIT);
|
||||
expect(deniedResponse?.response?.['error']).toContain(
|
||||
'execution allowlist',
|
||||
);
|
||||
expect(deniedResponse?.response?.['error']).not.toContain('fork_tools');
|
||||
expect(deniedResponse?.response?.['error']).not.toContain('not found');
|
||||
expect(toolCallEvents.map((event) => event.callId).sort()).toEqual([
|
||||
'call_edit',
|
||||
'call_read',
|
||||
]);
|
||||
expect(
|
||||
toolResultEvents
|
||||
.map((event) => ({
|
||||
callId: event.callId,
|
||||
success: event.success,
|
||||
}))
|
||||
.sort((left, right) => left.callId.localeCompare(right.callId)),
|
||||
).toEqual([
|
||||
{ callId: 'call_edit', success: false },
|
||||
{ callId: 'call_read', success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats an empty execution allowlist as deny-all', async () => {
|
||||
const toolDef: FunctionDeclaration = {
|
||||
name: ToolNames.READ_FILE,
|
||||
description: 'Reads a file',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
const tool = {
|
||||
name: ToolNames.READ_FILE,
|
||||
schema: toolDef,
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const { config } = await createMockConfig({
|
||||
getTool: vi.fn().mockReturnValue(tool),
|
||||
});
|
||||
mockSendMessageStream.mockImplementation(
|
||||
createMockStream([
|
||||
[
|
||||
{
|
||||
id: 'call_read',
|
||||
name: ToolNames.READ_FILE,
|
||||
args: { path: 'README.md' },
|
||||
},
|
||||
],
|
||||
'stop',
|
||||
]),
|
||||
);
|
||||
|
||||
const scope = await AgentHeadless.create(
|
||||
'fork',
|
||||
config,
|
||||
{ systemPrompt: 'Test prompt' },
|
||||
defaultModelConfig,
|
||||
defaultRunConfig,
|
||||
{ tools: [toolDef], executionAllowedTools: [] },
|
||||
);
|
||||
await scope.execute(new ContextState());
|
||||
|
||||
expect(tool.build).not.toHaveBeenCalled();
|
||||
const response = (
|
||||
mockSendMessageStream.mock.calls[1][1].message as Part[]
|
||||
)[0]?.functionResponse;
|
||||
expect(response?.id).toBe('call_read');
|
||||
expect(response?.response?.['error']).toContain('No tools are allowed');
|
||||
});
|
||||
|
||||
it('caps and decouples the execution allowlist denial message', async () => {
|
||||
const toolDef: FunctionDeclaration = {
|
||||
name: ToolNames.READ_FILE,
|
||||
description: 'Reads a file',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
const tool = {
|
||||
name: ToolNames.READ_FILE,
|
||||
schema: toolDef,
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const { config } = await createMockConfig({
|
||||
getTool: vi.fn().mockReturnValue(tool),
|
||||
});
|
||||
mockSendMessageStream.mockImplementation(
|
||||
createMockStream([
|
||||
[
|
||||
{
|
||||
id: 'call_read',
|
||||
name: ToolNames.READ_FILE,
|
||||
args: { path: 'README.md' },
|
||||
},
|
||||
],
|
||||
'stop',
|
||||
]),
|
||||
);
|
||||
const executionAllowedTools = Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) => `tool_${index}_${'x'.repeat(50)}`,
|
||||
);
|
||||
|
||||
const scope = await AgentHeadless.create(
|
||||
'fork',
|
||||
config,
|
||||
{ systemPrompt: 'Test prompt' },
|
||||
defaultModelConfig,
|
||||
defaultRunConfig,
|
||||
{ tools: [toolDef], executionAllowedTools },
|
||||
);
|
||||
await scope.execute(new ContextState());
|
||||
|
||||
const error = (
|
||||
mockSendMessageStream.mock.calls[1][1].message as Part[]
|
||||
)[0]?.functionResponse?.response?.['error'];
|
||||
expect(error).toContain('execution allowlist');
|
||||
expect(error).toContain('(+4 more)');
|
||||
expect(error).not.toContain('fork_tools');
|
||||
expect(String(error).length).toBeLessThan(400);
|
||||
expect(tool.build).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches an exact MCP server allowlist entry without crossing server boundaries', async () => {
|
||||
const githubName = normalizeToolNameForProvider('mcp__github__search');
|
||||
const enterpriseName = normalizeToolNameForProvider(
|
||||
'mcp__github-enterprise__search',
|
||||
);
|
||||
const githubDef: FunctionDeclaration = {
|
||||
name: githubName,
|
||||
description: 'Search GitHub',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
const enterpriseDef: FunctionDeclaration = {
|
||||
name: enterpriseName,
|
||||
description: 'Search GitHub Enterprise',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
const githubInvocation = {
|
||||
params: {},
|
||||
getDescription: vi.fn().mockReturnValue('Search GitHub'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: 'github result',
|
||||
returnDisplay: 'github result',
|
||||
}),
|
||||
};
|
||||
const githubTool = {
|
||||
name: githubName,
|
||||
serverName: 'github',
|
||||
serverToolName: 'search',
|
||||
schema: githubDef,
|
||||
build: vi.fn().mockReturnValue(githubInvocation),
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: true,
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const enterpriseTool = {
|
||||
name: enterpriseName,
|
||||
serverName: 'github-enterprise',
|
||||
serverToolName: 'search',
|
||||
schema: enterpriseDef,
|
||||
build: vi.fn(),
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: true,
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const { config } = await createMockConfig({
|
||||
getTool: vi.fn((name: string) =>
|
||||
name === githubName
|
||||
? githubTool
|
||||
: name === enterpriseName
|
||||
? enterpriseTool
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
mockSendMessageStream.mockImplementation(
|
||||
createMockStream([
|
||||
[
|
||||
{ id: 'call_github', name: githubName, args: {} },
|
||||
{ id: 'call_enterprise', name: enterpriseName, args: {} },
|
||||
],
|
||||
'stop',
|
||||
]),
|
||||
);
|
||||
|
||||
const scope = await AgentHeadless.create(
|
||||
'fork',
|
||||
config,
|
||||
{ systemPrompt: 'Test prompt' },
|
||||
defaultModelConfig,
|
||||
defaultRunConfig,
|
||||
{
|
||||
tools: [githubDef, enterpriseDef],
|
||||
executionAllowedTools: ['mcp__github'],
|
||||
},
|
||||
);
|
||||
await scope.execute(new ContextState());
|
||||
|
||||
expect(githubInvocation.execute).toHaveBeenCalledTimes(1);
|
||||
expect(enterpriseTool.build).not.toHaveBeenCalled();
|
||||
const responses = mockSendMessageStream.mock.calls[1][1]
|
||||
.message as Part[];
|
||||
expect(
|
||||
responses.find(
|
||||
(part) => part.functionResponse?.id === 'call_enterprise',
|
||||
)?.functionResponse?.response?.['error'],
|
||||
).toContain('execution allowlist');
|
||||
});
|
||||
|
||||
it('lets mcp__* match MCP tools without matching built-in tools', async () => {
|
||||
const mcpName = normalizeToolNameForProvider('mcp__github__search');
|
||||
const mcpDef: FunctionDeclaration = {
|
||||
name: mcpName,
|
||||
description: 'Search GitHub',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
const builtinDef: FunctionDeclaration = {
|
||||
name: ToolNames.READ_FILE,
|
||||
description: 'Read a file',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
const mcpInvocation = {
|
||||
params: {},
|
||||
getDescription: vi.fn().mockReturnValue('Search GitHub'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: 'github result',
|
||||
returnDisplay: 'github result',
|
||||
}),
|
||||
};
|
||||
const mcpTool = {
|
||||
name: mcpName,
|
||||
serverName: 'github',
|
||||
serverToolName: 'search',
|
||||
schema: mcpDef,
|
||||
build: vi.fn().mockReturnValue(mcpInvocation),
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: true,
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const builtinTool = {
|
||||
name: ToolNames.READ_FILE,
|
||||
schema: builtinDef,
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const { config } = await createMockConfig({
|
||||
getTool: vi.fn((name: string) =>
|
||||
name === mcpName
|
||||
? mcpTool
|
||||
: name === ToolNames.READ_FILE
|
||||
? builtinTool
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
mockSendMessageStream.mockImplementation(
|
||||
createMockStream([
|
||||
[
|
||||
{ id: 'call_mcp', name: mcpName, args: {} },
|
||||
{
|
||||
id: 'call_builtin',
|
||||
name: ToolNames.READ_FILE,
|
||||
args: { path: 'README.md' },
|
||||
},
|
||||
],
|
||||
'stop',
|
||||
]),
|
||||
);
|
||||
|
||||
const scope = await AgentHeadless.create(
|
||||
'fork',
|
||||
config,
|
||||
{ systemPrompt: 'Test prompt' },
|
||||
defaultModelConfig,
|
||||
defaultRunConfig,
|
||||
{
|
||||
tools: [mcpDef, builtinDef],
|
||||
executionAllowedTools: ['mcp__*'],
|
||||
},
|
||||
);
|
||||
await scope.execute(new ContextState());
|
||||
|
||||
expect(mcpInvocation.execute).toHaveBeenCalledTimes(1);
|
||||
expect(builtinTool.build).not.toHaveBeenCalled();
|
||||
const responses = mockSendMessageStream.mock.calls[1][1]
|
||||
.message as Part[];
|
||||
expect(
|
||||
responses.find((part) => part.functionResponse?.id === 'call_builtin')
|
||||
?.functionResponse?.response?.['error'],
|
||||
).toContain('execution allowlist');
|
||||
});
|
||||
|
||||
it('matches long MCP wildcard patterns by raw server identity and boundary', async () => {
|
||||
const serverSuffix = 'a'.repeat(80);
|
||||
const allowedServer = `repo.${serverSuffix}`;
|
||||
const deniedServer = `repo/${serverSuffix}`;
|
||||
const boundaryDeniedServer = `${allowedServer}__evil`;
|
||||
const allowedName = normalizeToolNameForProvider(
|
||||
`mcp__${allowedServer}__read`,
|
||||
);
|
||||
const deniedName = normalizeToolNameForProvider(
|
||||
`mcp__${deniedServer}__read`,
|
||||
);
|
||||
const boundaryDeniedName = normalizeToolNameForProvider(
|
||||
`mcp__${boundaryDeniedServer}__read`,
|
||||
);
|
||||
const allowedDef: FunctionDeclaration = {
|
||||
name: allowedName,
|
||||
description: 'Reads from repo.bad',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
const deniedDef: FunctionDeclaration = {
|
||||
name: deniedName,
|
||||
description: 'Reads from repo/bad',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
const boundaryDeniedDef: FunctionDeclaration = {
|
||||
name: boundaryDeniedName,
|
||||
description: 'Reads from a server with a shared raw prefix',
|
||||
parameters: { type: Type.OBJECT, properties: {} },
|
||||
};
|
||||
const allowedInvocation = {
|
||||
params: {},
|
||||
getDescription: vi.fn().mockReturnValue('Read from repo.bad'),
|
||||
toolLocations: vi.fn().mockReturnValue([]),
|
||||
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: 'repo result',
|
||||
returnDisplay: 'repo result',
|
||||
}),
|
||||
};
|
||||
const allowedTool = {
|
||||
name: allowedName,
|
||||
serverName: allowedServer,
|
||||
serverToolName: 'read',
|
||||
schema: allowedDef,
|
||||
build: vi.fn().mockReturnValue(allowedInvocation),
|
||||
canUpdateOutput: false,
|
||||
isOutputMarkdown: true,
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const deniedTool = {
|
||||
name: deniedName,
|
||||
serverName: deniedServer,
|
||||
serverToolName: 'read',
|
||||
schema: deniedDef,
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const boundaryDeniedTool = {
|
||||
name: boundaryDeniedName,
|
||||
serverName: boundaryDeniedServer,
|
||||
serverToolName: 'read',
|
||||
schema: boundaryDeniedDef,
|
||||
build: vi.fn(),
|
||||
} as unknown as AnyDeclarativeTool;
|
||||
const { config } = await createMockConfig({
|
||||
getTool: vi.fn((name: string) =>
|
||||
name === allowedName
|
||||
? allowedTool
|
||||
: name === deniedName
|
||||
? deniedTool
|
||||
: name === boundaryDeniedName
|
||||
? boundaryDeniedTool
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
mockSendMessageStream.mockImplementation(
|
||||
createMockStream([
|
||||
[
|
||||
{ id: 'call_repo', name: allowedName, args: {} },
|
||||
{ id: 'call_repo2', name: deniedName, args: {} },
|
||||
{
|
||||
id: 'call_boundary',
|
||||
name: boundaryDeniedName,
|
||||
args: {},
|
||||
},
|
||||
],
|
||||
'stop',
|
||||
]),
|
||||
);
|
||||
|
||||
const scope = await AgentHeadless.create(
|
||||
'fork',
|
||||
config,
|
||||
{ systemPrompt: 'Test prompt' },
|
||||
defaultModelConfig,
|
||||
defaultRunConfig,
|
||||
{
|
||||
tools: [allowedDef, deniedDef, boundaryDeniedDef],
|
||||
executionAllowedTools: [`mcp__${allowedServer}__*`],
|
||||
},
|
||||
);
|
||||
await scope.execute(new ContextState());
|
||||
|
||||
expect(allowedName).not.toBe(deniedName);
|
||||
expect(allowedInvocation.execute).toHaveBeenCalledTimes(1);
|
||||
expect(deniedTool.build).not.toHaveBeenCalled();
|
||||
expect(boundaryDeniedTool.build).not.toHaveBeenCalled();
|
||||
const responses = mockSendMessageStream.mock.calls[1][1]
|
||||
.message as Part[];
|
||||
expect(
|
||||
responses.find((part) => part.functionResponse?.id === 'call_repo2')
|
||||
?.functionResponse?.response?.['error'],
|
||||
).toContain('execution allowlist');
|
||||
expect(
|
||||
responses.find(
|
||||
(part) => part.functionResponse?.id === 'call_boundary',
|
||||
)?.functionResponse?.response?.['error'],
|
||||
).toContain('execution allowlist');
|
||||
});
|
||||
|
||||
it('should ignore duplicate provider tool-call ids across rounds', async () => {
|
||||
const listFilesToolDef: FunctionDeclaration = {
|
||||
name: 'list_files',
|
||||
|
|
|
|||
|
|
@ -82,10 +82,17 @@ export type AgentExternalInput =
|
|||
export interface ToolConfig {
|
||||
/**
|
||||
* A list of tool names (from the tool registry) or full function declarations
|
||||
* that the agent is permitted to use.
|
||||
* exposed to the model.
|
||||
*/
|
||||
tools: Array<string | FunctionDeclaration>;
|
||||
|
||||
/**
|
||||
* Optional execution-layer allowlist. Tool declarations remain unchanged,
|
||||
* but calls outside this list are rejected before scheduling or approval.
|
||||
* Supports exact tool names and MCP server-level patterns.
|
||||
*/
|
||||
executionAllowedTools?: string[];
|
||||
|
||||
/**
|
||||
* Optional list of tool names to exclude from the agent's tool pool.
|
||||
* Applied after the allowlist and MCP bypass. Supports MCP server-level
|
||||
|
|
|
|||
|
|
@ -324,7 +324,10 @@ describe('Config safe mode', () => {
|
|||
topTierMcpServers: { probe: { command: 'probe', args: [] } },
|
||||
});
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
probe: { command: 'probe', args: [] },
|
||||
probe: {
|
||||
command: 'probe',
|
||||
args: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -343,7 +346,10 @@ describe('Config safe mode', () => {
|
|||
},
|
||||
});
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
probe: { command: 'probe', args: [] },
|
||||
probe: {
|
||||
command: 'probe',
|
||||
args: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1874,6 +1874,28 @@ describe('Server Config (config.ts)', () => {
|
|||
expect(Object.keys(result!)).not.toContain('playwright');
|
||||
});
|
||||
|
||||
it('getMcpServers does not stamp cwd — cwd binding happens in populateMcpServerCommand', () => {
|
||||
const explicitCwd = path.resolve('/explicit/mcp');
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
targetDir: path.resolve('/session/worktree'),
|
||||
mcpServers: {
|
||||
implicit: { command: 'node', args: ['server.js'] },
|
||||
explicit: { command: 'node', cwd: explicitCwd },
|
||||
remote: { httpUrl: 'https://example.test/mcp' },
|
||||
sdk: { type: 'sdk', command: 'placeholder' },
|
||||
tcpWithCommand: { tcp: 'tcp://example.test:9000', command: 'node' },
|
||||
},
|
||||
});
|
||||
|
||||
const servers = config.getMcpServers()!;
|
||||
expect(servers['implicit']?.cwd).toBeUndefined();
|
||||
expect(servers['explicit']?.cwd).toBe(explicitCwd);
|
||||
expect(servers['remote']?.cwd).toBeUndefined();
|
||||
expect(servers['sdk']?.cwd).toBeUndefined();
|
||||
expect(servers['tcpWithCommand']?.cwd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('isMcpServerDisabled supports glob patterns in excludedMcpServers', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
|
|
@ -3099,6 +3121,38 @@ describe('Server Config (config.ts)', () => {
|
|||
expect(stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('aborts active workflows during shutdown', async () => {
|
||||
const config = new Config(baseParams);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const internal = config as unknown as {
|
||||
initializeInternal: () => Promise<void>;
|
||||
toolRegistry: ToolRegistry;
|
||||
};
|
||||
vi.spyOn(internal, 'initializeInternal').mockImplementation(async () => {
|
||||
internal.toolRegistry = { stop } as unknown as ToolRegistry;
|
||||
});
|
||||
await config.initialize();
|
||||
const abortController = new AbortController();
|
||||
const registry = config.getWorkflowRunRegistry();
|
||||
registry.register({
|
||||
runId: 'wf_1234',
|
||||
meta: null,
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
outputFile: '/tmp/wf_1234.jsonl',
|
||||
abortController,
|
||||
});
|
||||
|
||||
await config.shutdown({
|
||||
shutdownTelemetry: false,
|
||||
skipSessionWriter: true,
|
||||
strictResourceCleanup: true,
|
||||
});
|
||||
|
||||
expect(abortController.signal.aborted).toBe(true);
|
||||
expect(registry.get('wf_1234')?.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('allows a later shutdown to retry incomplete resource cleanup', async () => {
|
||||
const config = new Config(baseParams);
|
||||
const stop = vi
|
||||
|
|
@ -5182,6 +5236,64 @@ describe('Server Config (config.ts)', () => {
|
|||
cwdSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('relocateWorkingDirectory should reconcile MCP servers with the new session cwd', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
mcpServers: { local: { command: 'node', args: ['server.js'] } },
|
||||
});
|
||||
await config.initialize();
|
||||
const manager = (
|
||||
config.getToolRegistry() as unknown as {
|
||||
__mcpManagerMock: { discoverAllMcpToolsIncremental: Mock };
|
||||
}
|
||||
).__mcpManagerMock;
|
||||
await config.waitForMcpReady();
|
||||
manager.discoverAllMcpToolsIncremental.mockClear();
|
||||
const newDir = path.resolve('/path/to/other');
|
||||
const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => {
|
||||
// Keep the test process in its original directory.
|
||||
});
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(newDir);
|
||||
|
||||
await expect(config.relocateWorkingDirectory(newDir)).resolves.toEqual({});
|
||||
|
||||
expect(manager.discoverAllMcpToolsIncremental).toHaveBeenCalledOnce();
|
||||
expect(manager.discoverAllMcpToolsIncremental).toHaveBeenCalledWith(config);
|
||||
|
||||
chdirSpy.mockRestore();
|
||||
cwdSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('relocateWorkingDirectory should report MCP reconcile failures after moving', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
mcpServers: { local: { command: 'node' } },
|
||||
});
|
||||
await config.initialize();
|
||||
const manager = (
|
||||
config.getToolRegistry() as unknown as {
|
||||
__mcpManagerMock: { discoverAllMcpToolsIncremental: Mock };
|
||||
}
|
||||
).__mcpManagerMock;
|
||||
await config.waitForMcpReady();
|
||||
manager.discoverAllMcpToolsIncremental.mockRejectedValueOnce(
|
||||
new Error('MCP failed'),
|
||||
);
|
||||
const newDir = path.resolve('/path/to/other');
|
||||
const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => {
|
||||
// Keep the test process in its original directory.
|
||||
});
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(newDir);
|
||||
|
||||
const result = await config.relocateWorkingDirectory(newDir);
|
||||
|
||||
expect(config.getTargetDir()).toBe(newDir);
|
||||
expect(result.mcpRefreshError).toEqual(new Error('MCP failed'));
|
||||
|
||||
chdirSpy.mockRestore();
|
||||
cwdSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('relocateWorkingDirectory should continue after recording flush fails', async () => {
|
||||
const config = new Config(baseParams);
|
||||
const newDir = path.resolve('/path/to/other');
|
||||
|
|
@ -5513,6 +5625,40 @@ describe('Server Config (config.ts)', () => {
|
|||
cwdSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('relocateWorkingDirectory should report both memory and MCP refresh failures after moving', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
mcpServers: { local: { command: 'node' } },
|
||||
});
|
||||
await config.initialize();
|
||||
const manager = (
|
||||
config.getToolRegistry() as unknown as {
|
||||
__mcpManagerMock: { discoverAllMcpToolsIncremental: Mock };
|
||||
}
|
||||
).__mcpManagerMock;
|
||||
await config.waitForMcpReady();
|
||||
manager.discoverAllMcpToolsIncremental.mockRejectedValueOnce(
|
||||
new Error('MCP failed'),
|
||||
);
|
||||
vi.mocked(loadServerHierarchicalMemory).mockRejectedValueOnce(
|
||||
new Error('memory failed'),
|
||||
);
|
||||
const newDir = path.resolve('/path/to/other');
|
||||
const chdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => {
|
||||
// Keep the test process in its original directory.
|
||||
});
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(newDir);
|
||||
|
||||
const result = await config.relocateWorkingDirectory(newDir);
|
||||
|
||||
expect(config.getTargetDir()).toBe(newDir);
|
||||
expect(result.memoryRefreshError).toEqual(new Error('memory failed'));
|
||||
expect(result.mcpRefreshError).toEqual(new Error('MCP failed'));
|
||||
|
||||
chdirSpy.mockRestore();
|
||||
cwdSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('refreshHierarchicalMemory should include empty memory prompt when no managed auto-memory index exists', async () => {
|
||||
const config = new Config(baseParams);
|
||||
|
||||
|
|
|
|||
|
|
@ -4565,7 +4565,10 @@ export class Config {
|
|||
newDir: string,
|
||||
expectedCanonicalDir?: string,
|
||||
opts?: { skipProcessChdir?: boolean; skipArtifactMigration?: boolean },
|
||||
): Promise<{ memoryRefreshError?: unknown }> {
|
||||
): Promise<{
|
||||
memoryRefreshError?: unknown;
|
||||
mcpRefreshError?: unknown;
|
||||
}> {
|
||||
if (
|
||||
!opts?.skipArtifactMigration &&
|
||||
this.chatRecordingService?.hasWriteOwnership()
|
||||
|
|
@ -4630,12 +4633,25 @@ export class Config {
|
|||
this.fileHistoryService = undefined;
|
||||
this.getFileReadCache().clear();
|
||||
|
||||
let memoryRefreshError: unknown;
|
||||
try {
|
||||
await this.refreshHierarchicalMemory();
|
||||
return {};
|
||||
} catch (error) {
|
||||
return { memoryRefreshError: error };
|
||||
memoryRefreshError = error;
|
||||
}
|
||||
|
||||
let mcpRefreshError: unknown;
|
||||
try {
|
||||
await this.waitForMcpReady();
|
||||
await this.refreshMcpServers();
|
||||
} catch (error) {
|
||||
mcpRefreshError = error;
|
||||
}
|
||||
|
||||
return {
|
||||
...(memoryRefreshError !== undefined && { memoryRefreshError }),
|
||||
...(mcpRefreshError !== undefined && { mcpRefreshError }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -4816,6 +4832,7 @@ export class Config {
|
|||
this.backgroundTaskRegistry.abortAll();
|
||||
this.monitorRegistry.abortAll({ notify: false });
|
||||
this.backgroundShellRegistry.abortAll();
|
||||
this.workflowRunRegistry.abortAll();
|
||||
|
||||
await this.cleanupArenaRuntime();
|
||||
await this.cleanupTeamRuntime();
|
||||
|
|
@ -5332,6 +5349,10 @@ export class Config {
|
|||
this.recentlyRemovedMcpServers.add(name);
|
||||
}
|
||||
}
|
||||
await this.refreshMcpServers();
|
||||
}
|
||||
|
||||
private async refreshMcpServers(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
// No tool registry yet — boot-time discovery will pick up the new map.
|
||||
this.debugLogger.debug(
|
||||
|
|
@ -5339,13 +5360,12 @@ export class Config {
|
|||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mcpReconcileInProgress) {
|
||||
// Coalesce: a pass is already running. Mark that the desired state
|
||||
// advanced so its drain loop runs again with the latest config, and
|
||||
// await that in-flight pass — NOT a resolved promise — so this caller
|
||||
// does not proceed (e.g. the hot-reload listener emitting approval events
|
||||
// and logging "complete") before its coalesced change is actually
|
||||
// reconciled, and so it observes a shared reconcile failure.
|
||||
// does not proceed before its coalesced change is actually reconciled.
|
||||
this.mcpReconcilePending = true;
|
||||
this.debugLogger.debug(
|
||||
'[mcp-hot-reload] reconcile already in flight — coalescing into a follow-up pass',
|
||||
|
|
@ -5354,8 +5374,7 @@ export class Config {
|
|||
}
|
||||
this.mcpReconcileInProgress = true;
|
||||
const registry = this.getToolRegistry();
|
||||
// Run pass 1 + its drain loop as a single promise, assigned BEFORE the
|
||||
// first await so a coalesced caller arriving mid-flight can await it.
|
||||
// Assign before the first await so a coalesced caller can await this pass.
|
||||
const runReconcile = (async () => {
|
||||
try {
|
||||
this.debugLogger.debug(
|
||||
|
|
@ -5364,9 +5383,8 @@ export class Config {
|
|||
await registry
|
||||
.getMcpClientManager()
|
||||
.discoverAllMcpToolsIncremental(this);
|
||||
// Drain any change that arrived while this pass was in flight. The pool
|
||||
// path returns the in-flight promise rather than queuing, so awaiting
|
||||
// is not enough — re-run once more to pick up the latest config.
|
||||
// The pool path returns an in-flight promise, so re-run after any
|
||||
// coalesced change to ensure the latest effective config is applied.
|
||||
let pass = 1;
|
||||
while (this.mcpReconcilePending) {
|
||||
this.mcpReconcilePending = false;
|
||||
|
|
@ -5390,17 +5408,13 @@ export class Config {
|
|||
throw err;
|
||||
} finally {
|
||||
this.mcpReconcileInProgress = false;
|
||||
// Clear the coalesce flag too: if a pass threw, a pending follow-up
|
||||
// would otherwise stay stuck `true` and make the next (unrelated)
|
||||
// reconcile run an extra no-op drain pass. The next real settings
|
||||
// change re-triggers reconcile anyway.
|
||||
// A failed pass must not leak a pending drain into the next reconcile.
|
||||
this.mcpReconcilePending = false;
|
||||
this.mcpReconcilePromise = undefined;
|
||||
}
|
||||
})();
|
||||
this.mcpReconcilePromise = runReconcile;
|
||||
// Propagate failure to this caller (and, via the shared promise, to any
|
||||
// coalesced callers). Existing callers rely on the throw.
|
||||
// Propagate failure to this caller and every coalesced caller.
|
||||
await runReconcile;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -642,6 +642,35 @@ describe('Storage – runtime base dir async context isolation', () => {
|
|||
expect(b).toBe(path.join(cwdB, '.qwen-b'));
|
||||
});
|
||||
|
||||
it('lets a resolved runtime pin override later process env changes', async () => {
|
||||
const pinned = path.resolve('workspace', 'pinned-runtime');
|
||||
process.env['QWEN_RUNTIME_DIR'] = path.resolve(
|
||||
'workspace',
|
||||
'ambient-runtime',
|
||||
);
|
||||
|
||||
await Storage.runWithResolvedRuntimeBaseDir(pinned, async () => {
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(pinned);
|
||||
await Promise.resolve();
|
||||
expect(new Storage('/workspace').getRuntimeBaseDir()).toBe(pinned);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a resolved runtime pin across nested configurable contexts', () => {
|
||||
const pinned = path.resolve('workspace', 'pinned-runtime');
|
||||
|
||||
Storage.runWithResolvedRuntimeBaseDir(pinned, () => {
|
||||
Storage.runWithRuntimeBaseDir(
|
||||
path.resolve('workspace', 'nested-runtime'),
|
||||
undefined,
|
||||
() => {
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(pinned);
|
||||
expect(new Storage('/workspace').getRuntimeBaseDir()).toBe(pinned);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('pins an instance to the runtime dir where it was created', () => {
|
||||
const cwd = path.resolve('workspace', 'pinned');
|
||||
const runtimeDir = path.join(cwd, '.qwen-a');
|
||||
|
|
|
|||
|
|
@ -42,9 +42,10 @@ export class Storage {
|
|||
* When null, falls back to getGlobalQwenDir().
|
||||
*/
|
||||
private static runtimeBaseDir: string | null = null;
|
||||
private static readonly runtimeBaseDirContext = new AsyncLocalStorage<
|
||||
string | null
|
||||
>();
|
||||
private static readonly runtimeBaseDirContext = new AsyncLocalStorage<{
|
||||
dir: string | null;
|
||||
pinned: boolean;
|
||||
}>();
|
||||
|
||||
constructor(
|
||||
targetDir: string,
|
||||
|
|
@ -127,8 +128,24 @@ export class Storage {
|
|||
cwd: string | undefined,
|
||||
fn: () => T,
|
||||
): T {
|
||||
if (Storage.runtimeBaseDirContext.getStore()?.pinned) {
|
||||
return fn();
|
||||
}
|
||||
const resolved = Storage.resolveRuntimeBaseDir(dir, cwd);
|
||||
return Storage.runtimeBaseDirContext.run(resolved, fn);
|
||||
return Storage.runtimeBaseDirContext.run(
|
||||
{ dir: resolved, pinned: false },
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
static runWithResolvedRuntimeBaseDir<T>(dir: string, fn: () => T): T {
|
||||
// A managed workspace runtime owns this root for its full lifetime.
|
||||
// Unlike the configurable context above, later process-env reloads must
|
||||
// not redirect storage created inside this context.
|
||||
return Storage.runtimeBaseDirContext.run(
|
||||
{ dir: path.resolve(dir), pinned: true },
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
static hasRuntimeBaseDirContext(): boolean {
|
||||
|
|
@ -139,10 +156,14 @@ export class Storage {
|
|||
* Returns the base directory for all runtime output (temp files, debug logs,
|
||||
* session data, todos, insights, etc.).
|
||||
*
|
||||
* Priority: QWEN_RUNTIME_DIR env var > setRuntimeBaseDir() value > getGlobalQwenDir()
|
||||
* Priority: pinned runtime context > QWEN_RUNTIME_DIR env var > configurable context > setRuntimeBaseDir() value > getGlobalQwenDir()
|
||||
* @returns Absolute path to the runtime output base directory
|
||||
*/
|
||||
static getRuntimeBaseDir(): string {
|
||||
const contextualDir = Storage.runtimeBaseDirContext.getStore();
|
||||
if (contextualDir?.pinned) {
|
||||
return contextualDir.dir ?? Storage.getGlobalQwenDir();
|
||||
}
|
||||
const envDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
if (envDir) {
|
||||
return (
|
||||
|
|
@ -150,9 +171,8 @@ export class Storage {
|
|||
);
|
||||
}
|
||||
|
||||
const contextualDir = Storage.runtimeBaseDirContext.getStore();
|
||||
if (contextualDir !== undefined) {
|
||||
return contextualDir ?? Storage.getGlobalQwenDir();
|
||||
return contextualDir.dir ?? Storage.getGlobalQwenDir();
|
||||
}
|
||||
if (Storage.runtimeBaseDir) {
|
||||
return Storage.runtimeBaseDir;
|
||||
|
|
|
|||
|
|
@ -348,6 +348,8 @@ vi.mock('../telemetry/loggers.js', () => ({
|
|||
logLoopDetectionDisabled: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as telemetryIndex from '../telemetry/index.js';
|
||||
|
||||
const { mockClientDebugLogger } = vi.hoisted(() => ({
|
||||
mockClientDebugLogger: {
|
||||
isEnabled: vi.fn().mockReturnValue(false),
|
||||
|
|
@ -9657,6 +9659,166 @@ Other open files:
|
|||
});
|
||||
});
|
||||
|
||||
it('wraps injected additionalContext in the reserved tag and records display provenance', async () => {
|
||||
const mockMessageBus = {
|
||||
request: vi.fn().mockResolvedValue({
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
additionalContext: 'extra hook context',
|
||||
},
|
||||
},
|
||||
}),
|
||||
response: vi.fn(),
|
||||
};
|
||||
vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false);
|
||||
vi.mocked(mockConfig.getMessageBus).mockReturnValue(
|
||||
mockMessageBus as unknown as ReturnType<Config['getMessageBus']>,
|
||||
);
|
||||
vi.mocked(mockConfig.hasHooksForEvent).mockImplementation(
|
||||
(event: string) => event === 'UserPromptSubmit',
|
||||
);
|
||||
const recordUserMessage = vi.fn();
|
||||
vi.mocked(mockConfig.getChatRecordingService).mockReturnValue({
|
||||
recordUserMessage,
|
||||
recordCronPrompt: vi.fn(),
|
||||
recordAttributionSnapshot: vi.fn(),
|
||||
} as unknown as ReturnType<Config['getChatRecordingService']>);
|
||||
mockTurnRunFn.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'ok' };
|
||||
})(),
|
||||
);
|
||||
|
||||
await fromAsync(
|
||||
client.sendMessageStream(
|
||||
[{ text: 'my prompt' }],
|
||||
new AbortController().signal,
|
||||
'prompt-hook-context-tag',
|
||||
),
|
||||
);
|
||||
|
||||
const taggedContext =
|
||||
'<qwen:user-prompt-submit-context>\nextra hook context\n</qwen:user-prompt-submit-context>';
|
||||
|
||||
// The model-bound request keeps the user prompt intact and carries
|
||||
// the injected context inside the reserved tag.
|
||||
const requestText = getLastTurnRequestText();
|
||||
expect(requestText).toContain('my prompt');
|
||||
expect(requestText).toContain(taggedContext);
|
||||
|
||||
// The recorded message is the exact model-bound request, with the
|
||||
// user-authored projection preserved separately.
|
||||
expect(recordUserMessage).toHaveBeenCalledWith(
|
||||
[{ text: 'my prompt' }, { text: taggedContext }],
|
||||
undefined,
|
||||
{ displayText: 'my prompt' },
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the pre-injection prompt for managed auto-memory recall', async () => {
|
||||
const mockMessageBus = {
|
||||
request: vi.fn().mockResolvedValue({
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
additionalContext: 'extra hook context',
|
||||
},
|
||||
},
|
||||
}),
|
||||
response: vi.fn(),
|
||||
};
|
||||
vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false);
|
||||
vi.mocked(mockConfig.getMessageBus).mockReturnValue(
|
||||
mockMessageBus as unknown as ReturnType<Config['getMessageBus']>,
|
||||
);
|
||||
vi.mocked(mockConfig.hasHooksForEvent).mockImplementation(
|
||||
(event: string) => event === 'UserPromptSubmit',
|
||||
);
|
||||
mockTurnRunFn.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'ok' };
|
||||
})(),
|
||||
);
|
||||
|
||||
await fromAsync(
|
||||
client.sendMessageStream(
|
||||
[{ text: 'my prompt' }],
|
||||
new AbortController().signal,
|
||||
'prompt-hook-context-recall',
|
||||
),
|
||||
);
|
||||
|
||||
expect(mockMemoryManager.recall).toHaveBeenCalledWith(
|
||||
'/test/project/root',
|
||||
'my prompt',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the pre-injection prompt for telemetry user-prompt attributes', async () => {
|
||||
const mockMessageBus = {
|
||||
request: vi.fn().mockResolvedValue({
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
additionalContext: 'extra hook context',
|
||||
},
|
||||
},
|
||||
}),
|
||||
response: vi.fn(),
|
||||
};
|
||||
vi.mocked(mockConfig.getDisableAllHooks).mockReturnValue(false);
|
||||
vi.mocked(mockConfig.getMessageBus).mockReturnValue(
|
||||
mockMessageBus as unknown as ReturnType<Config['getMessageBus']>,
|
||||
);
|
||||
vi.mocked(mockConfig.hasHooksForEvent).mockImplementation(
|
||||
(event: string) => event === 'UserPromptSubmit',
|
||||
);
|
||||
Object.assign(mockConfig, {
|
||||
getTelemetryIncludeSensitiveSpanAttributes: vi
|
||||
.fn()
|
||||
.mockReturnValue(true),
|
||||
});
|
||||
const startSpy = vi
|
||||
.spyOn(telemetryIndex, 'startInteractionSpan')
|
||||
.mockImplementation(() => {});
|
||||
const spanSpy = vi
|
||||
.spyOn(telemetryIndex, 'getActiveInteractionSpan')
|
||||
.mockReturnValue({} as never);
|
||||
const addSpy = vi
|
||||
.spyOn(telemetryIndex, 'addUserPromptAttributes')
|
||||
.mockImplementation(() => {});
|
||||
mockTurnRunFn.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'ok' };
|
||||
})(),
|
||||
);
|
||||
|
||||
try {
|
||||
await fromAsync(
|
||||
client.sendMessageStream(
|
||||
[{ text: 'my prompt' }],
|
||||
new AbortController().signal,
|
||||
'prompt-hook-context-telemetry',
|
||||
),
|
||||
);
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.anything(),
|
||||
'my prompt',
|
||||
);
|
||||
const promptArg = addSpy.mock.calls[0]?.[2] as string;
|
||||
expect(promptArg).not.toContain('extra hook context');
|
||||
expect(promptArg).not.toContain('qwen:user-prompt-submit-context');
|
||||
} finally {
|
||||
startSpy.mockRestore();
|
||||
spanSpy.mockRestore();
|
||||
addSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'empty UserQuery value',
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
} from '../goals/goalHook.js';
|
||||
import { formatStopHookBlockingCapWarning } from '../hooks/stopHookCap.js';
|
||||
import { buildContextUsage } from '../hooks/context-usage.js';
|
||||
import { wrapUserPromptSubmitContext } from '../hooks/user-prompt-submit-context.js';
|
||||
import { DEFAULT_TOKEN_LIMIT, tokenLimit } from './tokenLimits.js';
|
||||
import { createSessionStartProfiler } from './session-start-profiler.js';
|
||||
|
||||
|
|
@ -2270,6 +2271,11 @@ export class GeminiClient {
|
|||
// content's own pairing.
|
||||
}
|
||||
|
||||
// Set when the UserPromptSubmit hook injects additional context: the
|
||||
// pre-injection prompt projection. Telemetry, memory recall, and chat
|
||||
// recording must see the user's own text, not the augmented request.
|
||||
let preInjectionPromptText: string | undefined;
|
||||
|
||||
// Fire UserPromptSubmit hook through MessageBus (only if hooks are enabled)
|
||||
let hooksEnabled: boolean;
|
||||
let messageBus: ReturnType<Config['getMessageBus']>;
|
||||
|
|
@ -2351,11 +2357,21 @@ export class GeminiClient {
|
|||
return new Turn(this.getChat(), prompt_id);
|
||||
}
|
||||
|
||||
// Add additional context from hooks to the request
|
||||
// Add additional context from hooks to the request. The context is
|
||||
// appended as its own part, wrapped in a reserved tag so it stays
|
||||
// distinguishable from user-authored text in model history, resume,
|
||||
// and offline transcript analysis. `getAdditionalContext()` escapes
|
||||
// `<`/`>`, so hook output cannot forge the closing tag.
|
||||
// `promptText` is declared above this block so assignment here cannot
|
||||
// hit a TDZ if the surrounding Goal try/catch is later reshuffled.
|
||||
const additionalContext = hookOutput?.getAdditionalContext();
|
||||
if (additionalContext) {
|
||||
const requestArray = Array.isArray(request) ? request : [request];
|
||||
request = [...requestArray, { text: additionalContext }];
|
||||
request = [
|
||||
...requestArray,
|
||||
{ text: wrapUserPromptSubmitContext(additionalContext) },
|
||||
];
|
||||
preInjectionPromptText = promptText;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -2502,7 +2518,7 @@ export class GeminiClient {
|
|||
addUserPromptAttributes(
|
||||
this.config,
|
||||
interactionSpan,
|
||||
partToString(request),
|
||||
preInjectionPromptText ?? partToString(request),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2557,12 +2573,16 @@ export class GeminiClient {
|
|||
}
|
||||
const promise = this.config
|
||||
.getMemoryManager()
|
||||
.recall(this.config.getProjectRoot(), partToString(request), {
|
||||
config: this.config,
|
||||
excludedFilePaths: this.surfacedRelevantAutoMemoryPaths,
|
||||
recentTools: [...this.recentCompletedToolNames],
|
||||
abortSignal: controller.signal,
|
||||
})
|
||||
.recall(
|
||||
this.config.getProjectRoot(),
|
||||
preInjectionPromptText ?? partToString(request),
|
||||
{
|
||||
config: this.config,
|
||||
excludedFilePaths: this.surfacedRelevantAutoMemoryPaths,
|
||||
recentTools: [...this.recentCompletedToolNames],
|
||||
abortSignal: controller.signal,
|
||||
},
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
// Abort sources are now numerous (caller signal, new UserQuery,
|
||||
// cleanup paths, safety-net timeout). Keep a debug trace so
|
||||
|
|
@ -2627,9 +2647,17 @@ export class GeminiClient {
|
|||
goalPermit,
|
||||
);
|
||||
} else {
|
||||
this.config
|
||||
.getChatRecordingService()
|
||||
?.recordUserMessage(request, goalPermit);
|
||||
// Only pass the payload when a hook actually injected; omitting
|
||||
// the third argument keeps existing two-arg spies/call sites
|
||||
// exact (passing `undefined` would still count as a third arg).
|
||||
const recordingService = this.config.getChatRecordingService();
|
||||
if (recordingService && preInjectionPromptText !== undefined) {
|
||||
recordingService.recordUserMessage(request, goalPermit, {
|
||||
displayText: preInjectionPromptText,
|
||||
});
|
||||
} else if (recordingService) {
|
||||
recordingService.recordUserMessage(request, goalPermit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1059,6 +1059,150 @@ describe('extension tests', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('refreshCacheIfSourcesChanged', () => {
|
||||
// Extension sources have no watcher, so read-only consumers rely on this to
|
||||
// stay eventually consistent with mutations made outside the process
|
||||
// (`qwen extensions install` in a terminal) without scanning on every read.
|
||||
// See docs/design/workspace-skills-read-model.md.
|
||||
it('does not refresh while the sources are unchanged', async () => {
|
||||
createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' });
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
expect(manager.getLoadedExtensions()).toHaveLength(1);
|
||||
|
||||
const refreshSpy = vi.spyOn(manager, 'refreshCache');
|
||||
for (let i = 0; i < 20; i++) {
|
||||
expect(await manager.refreshCacheIfSourcesChanged()).toBe(false);
|
||||
}
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
expect(manager.getLoadedExtensions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refreshes once a new extension appears on disk', async () => {
|
||||
createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' });
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
expect(manager.getLoadedExtensions()).toHaveLength(1);
|
||||
|
||||
createExtension({ extensionsDir: userExtensionsDir, name: 'ext-b' });
|
||||
|
||||
expect(await manager.refreshCacheIfSourcesChanged()).toBe(true);
|
||||
expect(
|
||||
manager
|
||||
.getLoadedExtensions()
|
||||
.map((e) => e.name)
|
||||
.sort(),
|
||||
).toEqual(['ext-a', 'ext-b']);
|
||||
// The refresh commits a new baseline, so the next call is a no-op again.
|
||||
expect(await manager.refreshCacheIfSourcesChanged()).toBe(false);
|
||||
});
|
||||
|
||||
it('refreshes after an extension is removed', async () => {
|
||||
createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' });
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
|
||||
fs.rmSync(path.join(userExtensionsDir, 'ext-a'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(await manager.refreshCacheIfSourcesChanged()).toBe(true);
|
||||
expect(manager.getLoadedExtensions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refreshes after an in-place manifest edit', async () => {
|
||||
createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' });
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
expect(manager.getLoadedExtensions()[0]?.version).toBe('1.0.0');
|
||||
|
||||
// Rewriting the manifest changes neither the extensions dir nor the
|
||||
// extension dir mtime on every platform, which is why the fingerprint
|
||||
// covers each manifest itself. The new version is a different length so
|
||||
// the size differs too — otherwise this would depend on the filesystem's
|
||||
// mtime granularity.
|
||||
fs.writeFileSync(
|
||||
path.join(userExtensionsDir, 'ext-a', EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: 'ext-a', version: '10.0.0', mcpServers: {} }),
|
||||
);
|
||||
|
||||
expect(await manager.refreshCacheIfSourcesChanged()).toBe(true);
|
||||
expect(manager.getLoadedExtensions()[0]?.version).toBe('10.0.0');
|
||||
});
|
||||
|
||||
it('shares one refresh between concurrent callers', async () => {
|
||||
createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' });
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
|
||||
createExtension({ extensionsDir: userExtensionsDir, name: 'ext-b' });
|
||||
const refreshSpy = vi.spyOn(manager, 'refreshCache');
|
||||
|
||||
const results = await Promise.all([
|
||||
manager.refreshCacheIfSourcesChanged(),
|
||||
manager.refreshCacheIfSourcesChanged(),
|
||||
manager.refreshCacheIfSourcesChanged(),
|
||||
]);
|
||||
|
||||
expect(results).toEqual([true, true, true]);
|
||||
expect(refreshSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not mask a change that lands while a refresh is running', async () => {
|
||||
// The committed baseline is captured before the load, so a write that
|
||||
// races the refresh leaves the fingerprint stale and is still seen next
|
||||
// time. Stamping after the load would swallow it until something else
|
||||
// moved on disk.
|
||||
createExtension({ extensionsDir: userExtensionsDir, name: 'ext-a' });
|
||||
const manager = createExtensionManager();
|
||||
await manager.refreshCache();
|
||||
expect(manager.getLoadedExtensions()).toHaveLength(1);
|
||||
|
||||
const realLoad = manager['loadExtensionsFromExtensionsDir'].bind(manager);
|
||||
let raced = false;
|
||||
vi.spyOn(
|
||||
manager as unknown as {
|
||||
loadExtensionsFromExtensionsDir: (
|
||||
...args: unknown[]
|
||||
) => Promise<unknown>;
|
||||
},
|
||||
'loadExtensionsFromExtensionsDir',
|
||||
).mockImplementation(async (...args: unknown[]) => {
|
||||
const loaded = await (
|
||||
realLoad as (...a: unknown[]) => Promise<unknown>
|
||||
)(...args);
|
||||
if (!raced) {
|
||||
raced = true;
|
||||
// Lands after this refresh has already read the directory.
|
||||
createExtension({ extensionsDir: userExtensionsDir, name: 'ext-b' });
|
||||
}
|
||||
return loaded;
|
||||
});
|
||||
|
||||
// Triggered by the enablement file moving, so the first refresh does not
|
||||
// observe ext-b.
|
||||
fs.writeFileSync(
|
||||
path.join(userExtensionsDir, 'extension-enablement.json'),
|
||||
JSON.stringify({ touched: true }),
|
||||
);
|
||||
expect(await manager.refreshCacheIfSourcesChanged()).toBe(true);
|
||||
expect(manager.getLoadedExtensions()).toHaveLength(1);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// The racing install is still visible to the next check.
|
||||
expect(await manager.refreshCacheIfSourcesChanged()).toBe(true);
|
||||
expect(
|
||||
manager
|
||||
.getLoadedExtensions()
|
||||
.map((e) => e.name)
|
||||
.sort(),
|
||||
).toEqual(['ext-a', 'ext-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadExtension', () => {
|
||||
it('uses the injected extension store root for discovery', async () => {
|
||||
const customExtensionsDir = path.join(tempHomeDir, 'custom-extensions');
|
||||
|
|
|
|||
|
|
@ -436,6 +436,9 @@ export class ExtensionManager {
|
|||
private readonly networkPolicy?: ExtensionInstallMetadata['networkPolicy'];
|
||||
private readonly preparedMutations = new WeakSet<PreparedExtensionMutation>();
|
||||
private discoverCache: DiscoveredPlugin[] | null = null;
|
||||
/** See `sourceFingerprint`. `undefined` until the first refresh commits. */
|
||||
private lastSourceFingerprint: string | undefined;
|
||||
private inFlightSourceRevalidation: Promise<boolean> | undefined;
|
||||
|
||||
private withNetworkPolicy(
|
||||
installMetadata: ExtensionInstallMetadata | undefined,
|
||||
|
|
@ -1085,6 +1088,11 @@ export class ExtensionManager {
|
|||
names?: string[];
|
||||
}): Promise<ExtensionStoreSnapshot> {
|
||||
const requestedNames = options?.names?.filter(Boolean) ?? [];
|
||||
// Captured before the load, not after: an install landing mid-refresh must
|
||||
// leave the committed fingerprint stale so the next check still sees it.
|
||||
// Stamping post-load would mask that change until something else moved.
|
||||
const dirFingerprintBeforeLoad =
|
||||
requestedNames.length === 0 ? this.extensionDirFingerprint() : undefined;
|
||||
const { value: extensions, snapshot } =
|
||||
await this.extensionStore.readConsistent(async () => {
|
||||
let loaded: Extension[];
|
||||
|
|
@ -1115,9 +1123,129 @@ export class ExtensionManager {
|
|||
});
|
||||
this.extensionCache = nextCache;
|
||||
this.applyStoreActivation(snapshot);
|
||||
// Only a full refresh establishes a baseline. A name-filtered refresh leaves
|
||||
// the cache partial, so claiming the whole directory is up to date would let
|
||||
// `refreshCacheIfSourcesChanged` report "unchanged" over a partial set.
|
||||
if (dirFingerprintBeforeLoad !== undefined) {
|
||||
this.lastSourceFingerprint = this.sourceFingerprint(
|
||||
dirFingerprintBeforeLoad,
|
||||
);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static stampPath(target: string): string {
|
||||
try {
|
||||
const stats = fs.statSync(target);
|
||||
return `${stats.mtimeMs}:${stats.size}`;
|
||||
} catch {
|
||||
// Absent is a real state and must not collide with any present one —
|
||||
// otherwise deleting the last extension would look unchanged.
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprints which extension directories exist (install / uninstall) and
|
||||
* each manifest's mtime and size (in-place edits).
|
||||
*
|
||||
* A pure function of on-disk state, deliberately independent of the current
|
||||
* cache, so the same disk yields the same value before and after a refresh.
|
||||
* A refresh never writes these paths, which is what makes it safe to commit
|
||||
* the pre-load value — see `refreshCacheWithSnapshot`.
|
||||
*
|
||||
* Deliberately cheap: one `readdir` plus one `stat` per entry, where
|
||||
* `refreshCache()` parses every manifest and re-lists every extension skill
|
||||
* directory. That difference is what lets a status read stay self-healing
|
||||
* without becoming a directory scan.
|
||||
*
|
||||
* mtime-and-size is the usual stat-based approximation, so an edit that
|
||||
* preserves both is not detected. That is acceptable here: this is only the
|
||||
* out-of-band safety net — mutations made through the daemon invalidate
|
||||
* explicitly and never rely on it.
|
||||
*/
|
||||
private extensionDirFingerprint(): string {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(this.configDir);
|
||||
} catch {
|
||||
return 'dir:-';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const stamp = ExtensionManager.stampPath(
|
||||
path.join(this.configDir, entry, EXTENSIONS_CONFIG_FILENAME),
|
||||
);
|
||||
// Entries with no manifest are not extensions — notably the enablement
|
||||
// file, which lives in this directory and is created lazily by the store.
|
||||
// Counting them would make the store's own bookkeeping look like an
|
||||
// install and cost one spurious refresh.
|
||||
if (stamp === '-') continue;
|
||||
parts.push(`ext:${entry}:${stamp}`);
|
||||
}
|
||||
// Sorted so directory iteration order cannot make an unchanged set look
|
||||
// moved.
|
||||
return parts.sort().join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprints the enablement file and the store's activation state — where
|
||||
* `enable` / `disable` land.
|
||||
*
|
||||
* Unlike the directory part this is stamped *after* a refresh, because a
|
||||
* refresh writes the store itself. That is safe: store mutations hold the
|
||||
* store lock, so no external write can interleave with the refresh and be
|
||||
* masked by the post-load stamp.
|
||||
*/
|
||||
private extensionStoreFingerprint(): string {
|
||||
return [
|
||||
`enablement:${ExtensionManager.stampPath(this.configFilePath)}`,
|
||||
`state:${ExtensionManager.stampPath(
|
||||
path.join(this.extensionStore.storeDir, 'state.json'),
|
||||
)}`,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
private sourceFingerprint(dirFingerprint: string): string {
|
||||
return `${dirFingerprint}||${this.extensionStoreFingerprint()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the cache only when the on-disk extension sources moved since the
|
||||
* last refresh. Returns whether a refresh actually ran.
|
||||
*
|
||||
* Extension sources have no watcher (skills do — see
|
||||
* `SkillManager.startWatching`), so read-only consumers that must not scan on
|
||||
* every call use this to stay eventually consistent with `qwen extensions
|
||||
* install` / `enable` / `disable` run outside the process.
|
||||
*
|
||||
* Concurrent callers share one refresh, so a caller can join a refresh that
|
||||
* started just before the change it cares about. That is bounded rather than
|
||||
* lost: the committed baseline is the pre-load fingerprint, so the change is
|
||||
* still visible to the next call.
|
||||
*/
|
||||
async refreshCacheIfSourcesChanged(): Promise<boolean> {
|
||||
const inFlight = this.inFlightSourceRevalidation;
|
||||
if (inFlight) return await inFlight;
|
||||
const current = this.sourceFingerprint(this.extensionDirFingerprint());
|
||||
if (this.lastSourceFingerprint === current) return false;
|
||||
const revalidation = (async () => {
|
||||
// `refreshCache` commits the new baseline itself, from its pre-load
|
||||
// fingerprint. A throw leaves the old baseline in place so the next call
|
||||
// retries rather than assuming the refresh landed.
|
||||
await this.refreshCache();
|
||||
return true;
|
||||
})();
|
||||
this.inFlightSourceRevalidation = revalidation;
|
||||
const clear = () => {
|
||||
if (this.inFlightSourceRevalidation === revalidation) {
|
||||
this.inFlightSourceRevalidation = undefined;
|
||||
}
|
||||
};
|
||||
void revalidation.then(clear, clear);
|
||||
return await revalidation;
|
||||
}
|
||||
|
||||
getLoadedExtensions(): Extension[] {
|
||||
if (!this.extensionCache) {
|
||||
return [];
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue