mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-06 15:25:34 +00:00
Merge branch 'main' into fix/completion-tab-switch-8069
This commit is contained in:
commit
e55ebe74b4
244 changed files with 29464 additions and 2774 deletions
37
.github/workflows/comment-attachment-guard.yml
vendored
37
.github/workflows/comment-attachment-guard.yml
vendored
|
|
@ -19,11 +19,44 @@ permissions:
|
|||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
concurrency:
|
||||
# One scan per comment, keyed on whichever id this event carries. The scan
|
||||
# reads the comment's CURRENT body, so when an edit lands while an earlier
|
||||
# scan is queued the earlier result is already stale — cancelling it loses
|
||||
# nothing. (Contrast the verify lane, where cancel-in-progress is false
|
||||
# because a cancelled run destroys evidence.) Falling back to run_id keeps
|
||||
# unexpected payloads on their own group instead of serialising them all
|
||||
# into one.
|
||||
group: >-
|
||||
attachment-guard-${{
|
||||
github.event.comment.id || github.event.review.id || github.run_id
|
||||
}}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
remove-suspicious-attachments:
|
||||
timeout-minutes: 2
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
# The trust check used to live inside the script, which means a runner was
|
||||
# queued, allocated and started before the job could decide it had nothing
|
||||
# to do. Measured over the 200 most recent comments on this repo: 184 from
|
||||
# trusted associations and 9 from bots — 96.5% of runs existed only to
|
||||
# print "Trusted author; skipping". On a saturated hosted pool those waited
|
||||
# up to 629s each for 2-5s of work.
|
||||
#
|
||||
# Hoisted here because GitHub evaluates `if:` BEFORE allocating a runner.
|
||||
# The script keeps its own copy of these checks: this is an optimisation,
|
||||
# not the control, and the two must be able to disagree without becoming
|
||||
# unsafe. Every ambiguity therefore resolves toward RUNNING the scan — an
|
||||
# unknown payload yields an empty association, which is not in the trusted
|
||||
# list, so the job runs.
|
||||
if: >-
|
||||
github.repository == 'QwenLM/qwen-code' &&
|
||||
github.event.sender.type != 'Bot' &&
|
||||
!contains(
|
||||
fromJSON('["OWNER","MEMBER","COLLABORATOR"]'),
|
||||
github.event.comment.author_association ||
|
||||
github.event.review.author_association
|
||||
)
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Remove suspicious attachment comments'
|
||||
|
|
|
|||
616
.github/workflows/qwen-autofix.yml
vendored
616
.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
|
||||
|
|
@ -190,6 +201,16 @@ env:
|
|||
# unbroken run of failures. Observed on #6723: 7 straight failed rounds (3
|
||||
# timeouts, 4 gate rejections) over 8 hours, heading for 100.
|
||||
CONSECUTIVE_FAILURE_CAP: '5'
|
||||
# Cumulative agent-timeout sub-cap, the sibling of the consecutive cap for
|
||||
# the failure shape it cannot see: timeouts INTERLEAVED with successful
|
||||
# rounds. A success resets the consecutive streak, but it does not make the
|
||||
# next timeout any cheaper — each one burns a full agent budget (~50m of
|
||||
# runner time) and pushes nothing. Observed on #7929: three timeouts with
|
||||
# pushed rounds in between, so the consecutive cap never fired and the PR
|
||||
# kept walking into the same wall; #7846 the same, twice. Counted over the
|
||||
# current counting window (window-scoped like every other census), so a
|
||||
# re-arm clears it along with the round counter.
|
||||
TIMEOUT_WINDOW_CAP: '3'
|
||||
# Do not claim more issues when too many existing autofix PRs are still open.
|
||||
MAX_OPEN_AUTOFIX_PRS: '5'
|
||||
|
||||
|
|
@ -404,8 +425,8 @@ jobs:
|
|||
# applies TAKEOVER_LABEL, 'TAKEOVER_COMMAND stop' removes it —
|
||||
# nothing else. The label stays the single source of truth:
|
||||
# engagement and release happen ONLY via the label events
|
||||
# below, so the command's whole blast radius is one label
|
||||
# toggle. Exact match on the trimmed body (constants, never
|
||||
# below; the command also posts acks directly in both
|
||||
# directions (#7999, #8002). Exact match on the trimmed body (constants, never
|
||||
# user-input parsing); allowed senders: the PR author (who may
|
||||
# lack label access) or a write+ collaborator. This immediately
|
||||
# narrows a previously fully-closed surface reopened under
|
||||
|
|
@ -518,7 +539,19 @@ jobs:
|
|||
else
|
||||
DO_REVIEW=true
|
||||
ROUTE_PR="$(sanitize_number "${PR_NUMBER_EVENT}")"
|
||||
TAKEOVER_ACK='engaged'
|
||||
if [[ "${SENDER_LOGIN}" == "${AUTOFIX_BOT}" ]]; then
|
||||
# The bot only applies this label from takeover-command,
|
||||
# which posts the engage ack ITSELF: the labeled event
|
||||
# has been observed to simply not fire (#7999 — the
|
||||
# author read the silence as failure and removed the
|
||||
# label; #8002), so the user-visible ack must not
|
||||
# depend on this round-trip. Suppress only the ack —
|
||||
# the immediate scan is this event's real work and
|
||||
# still routes.
|
||||
echo "🧭 engage ack skipped: label applied by ${AUTOFIX_BOT} — the command path already acked"
|
||||
else
|
||||
TAKEOVER_ACK='engaged'
|
||||
fi
|
||||
echo "🧭 ${TAKEOVER_LABEL} applied by ${SENDER_LOGIN} on PR #${PR_NUMBER_EVENT} → review phase (takeover)"
|
||||
fi
|
||||
elif [[ "${EVENT_ACTION}" == 'unlabeled' ]]; then
|
||||
|
|
@ -533,6 +566,12 @@ jobs:
|
|||
# fail its identity check — a red run for a label that
|
||||
# never engaged anything. Log and stop.
|
||||
echo "🧭 takeover release ignored: PR is a fork (${PR_HEAD_REPO} != ${REPO})"
|
||||
elif [[ "${SENDER_LOGIN}" == "${AUTOFIX_BOT}" ]]; then
|
||||
# Mirror of the labeled-path suppression: the bot only
|
||||
# removes this label from takeover-command, which posts
|
||||
# the release ack itself — acking here too would
|
||||
# double-post on every command-driven stop.
|
||||
echo "🧭 release ack skipped: label removed by ${AUTOFIX_BOT} — the command path already acked"
|
||||
else
|
||||
TAKEOVER_ACK='released'
|
||||
echo "🧭 ${TAKEOVER_LABEL} removed from PR #${PR_NUMBER_EVENT} by ${SENDER_LOGIN} → released"
|
||||
|
|
@ -1387,10 +1426,27 @@ jobs:
|
|||
# State/base can change while this job sits in its per-PR queue —
|
||||
# re-verify what the route checked so a stale command cannot label
|
||||
# a closed or non-main PR.
|
||||
if [[ "$(jq -r '.state // ""' <<< "${PR_INFO}")" != "OPEN" || "$(jq -r '.baseRefName // ""' <<< "${PR_INFO}")" != "main" ]]; then
|
||||
echo "🧭 takeover command dropped: PR #${PR} is no longer an open main-targeting PR"
|
||||
if [[ "$(jq -r '.state // ""' <<< "${PR_INFO}")" != "OPEN" ]]; then
|
||||
echo "🧭 takeover command dropped: PR #${PR} is no longer an open PR"
|
||||
exit 0
|
||||
fi
|
||||
CMD_BASE_REF="$(jq -r '.baseRefName // ""' <<< "${PR_INFO}")"
|
||||
if [[ "${CMD_BASE_REF}" != "main" ]]; then
|
||||
if [[ "${CMD}" == 'add' ]]; then
|
||||
# Refuse OUT LOUD — the silent drop made a /takeover on a
|
||||
# stacked PR indistinguishable from a lost event. Mirrors the
|
||||
# label path's base-refused ack, except no label was applied
|
||||
# here, so the ask is to re-run the command after retargeting
|
||||
# (not "the label is left in place").
|
||||
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🚫 Takeover not engaged: the loop only manages PRs that target `main`, and this one targets `%s`. A stacked PR moves whenever its base branch does, so "new feedback since the last round" and base-conflict resolution are not well defined until the base lands. Retarget this PR to `main` once the base PR merges and re-run `%s` — or take over the base PR instead.\n\n<details>\n<summary>中文说明</summary>\n\n🚫 未接管:循环只管理以 `main` 为 base 的 PR,而本 PR 的 base 是 `%s`。堆叠 PR 会随 base 分支移动,因此“自上一轮以来的新反馈”与 base 冲突处理都无法良定义。待 base 的 PR 合入后将本 PR 改为面向 `main` 并重新执行 `%s`;或改为接管 base 那个 PR。\n\n</details>\n\n<!-- takeover-ack base-refused -->' "${CMD_BASE_REF}" "${TAKEOVER_COMMAND}" "${CMD_BASE_REF}" "${TAKEOVER_COMMAND}")"
|
||||
echo "🧭 takeover command refused: PR #${PR} targets '${CMD_BASE_REF}' not 'main'"
|
||||
exit 0
|
||||
fi
|
||||
# 'stop' proceeds: removing the label from a non-main PR is
|
||||
# harmless and matches the latest intent — dropping it here left
|
||||
# a manually-applied label stuck with no command able to remove
|
||||
# it (the label path's release ack ignores non-main PRs too).
|
||||
fi
|
||||
# Skip wins over takeover EVERYWHERE — including here: engaging or
|
||||
# re-arming a skip-labeled PR would post an 'engaged' window anchor
|
||||
# for management that the scans deliberately refuse to perform.
|
||||
|
|
@ -1443,6 +1499,25 @@ jobs:
|
|||
else
|
||||
gh pr edit "${PR}" --repo "${REPO}" --add-label "${TAKEOVER_LABEL}"
|
||||
echo "🏷️ applied ${TAKEOVER_LABEL} to #${PR}"
|
||||
# Ack HERE, not via the pull_request:labeled round-trip: that
|
||||
# event has been observed to simply not fire (#7999 — the
|
||||
# author read the silence as failure and removed the label;
|
||||
# #8002 — no ack for hours), and fork label events could never
|
||||
# ack at all (they carry no secrets). Every admission gate
|
||||
# above has already passed, so 'engaged' is truthful for both
|
||||
# in-repo and fork PRs. The route side suppresses the
|
||||
# label-path ack when the label sender is the bot, and the
|
||||
# scan's first-pickup ack dedups against this comment — and
|
||||
# heals it on the next scan if this post fails, which is why
|
||||
# a failure here only warns.
|
||||
FORK_NOTE=''
|
||||
FORK_NOTE_ZH=''
|
||||
if [[ "$(jq -r 'if has("isCrossRepository") then .isCrossRepository else true end' <<< "${PR_INFO}")" != "false" ]]; then
|
||||
FORK_NOTE=' This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes).'
|
||||
FORK_NOTE_ZH='本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。'
|
||||
fi
|
||||
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached.%s Remove the `%s` label (or comment `%s stop`) to release.\n\n<details>\n<summary>中文说明</summary>\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。%s移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n</details>\n\n<!-- takeover-ack engaged -->' "${FORK_NOTE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FORK_NOTE_ZH}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")" \
|
||||
|| echo "::warning::engage ack comment failed on #${PR}; the scan's first-pickup ack heals it"
|
||||
fi
|
||||
else
|
||||
if [[ "${HAS}" != 'true' ]]; then
|
||||
|
|
@ -1450,13 +1525,38 @@ jobs:
|
|||
else
|
||||
gh pr edit "${PR}" --repo "${REPO}" --remove-label "${TAKEOVER_LABEL}"
|
||||
echo "🏷️ removed ${TAKEOVER_LABEL} from #${PR}"
|
||||
# Release ack, direct from the command — the exact mirror of
|
||||
# the engage side above, for the same reason: the unlabeled
|
||||
# round-trip is the thing we no longer trust, fork unlabeled
|
||||
# events can never ack (no secrets), and a non-main release
|
||||
# never even reaches the ack job. A loud add next to a mute
|
||||
# stop would re-create the "did it work or did the event get
|
||||
# lost?" ambiguity on the release side. Variant selection
|
||||
# mirrors the ack job verbatim (live author + skip label from
|
||||
# the same PR_INFO the gates used); the route side suppresses
|
||||
# the unlabeled-path ack when the label sender is the bot.
|
||||
REL_AUTHOR="$(jq -r '.author.login // ""' <<< "${PR_INFO}")"
|
||||
REL_HAS_SKIP="$(jq -r --arg t "${SKIP_LABEL}" '[.labels[].name] | index($t) != null' <<< "${PR_INFO}")"
|
||||
if [[ "${REL_AUTHOR}" == "${AUTOFIX_BOT}" && "${REL_HAS_SKIP}" == "true" ]]; then
|
||||
REL_BODY="$(printf '👋 Takeover mode ended. This bot-authored PR also carries `%s`, which opts it out of standard bot management entirely — nothing will engage it until that label is removed.\n\n<details>\n<summary>中文说明</summary>\n\n👋 接管模式结束。本 bot 创建的 PR 同时带有 `%s`,已完全退出常规 bot 管理 —— 移除该标签前不会有任何介入。\n\n</details>\n\n<!-- takeover-ack released -->' "${SKIP_LABEL}" "${SKIP_LABEL}")"
|
||||
elif [[ "${REL_AUTHOR}" == "${AUTOFIX_BOT}" ]]; then
|
||||
REL_BODY="$(printf '👋 Takeover mode ended: the raised round cap no longer applies. This is a bot-authored PR, so STANDARD bot management continues under the strict cap (apply `%s` to opt it out entirely). Re-apply `%s` (or comment `%s`) for the raised cap again.\n\n<details>\n<summary>中文说明</summary>\n\n👋 接管模式结束:提升的轮次上限不再适用。这是 bot 创建的 PR,常规 bot 管理仍将继续(严格上限;如需完全退出请打 `%s`)。重新打上 `%s` 标签(或评论 `%s`)可恢复提升上限。\n\n</details>\n\n<!-- takeover-ack released -->' "${SKIP_LABEL}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${SKIP_LABEL}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")"
|
||||
else
|
||||
REL_BODY="$(printf '👋 Takeover released: the autofix loop will no longer engage this PR (an in-flight round, if any, completes its bounded work). Re-apply `%s` (or comment `%s`) to re-engage.\n\n<details>\n<summary>中文说明</summary>\n\n👋 已释放:autofix 循环不再介入此 PR(在飞的一轮如有,将完成其有界工作)。重新打上 `%s` 标签(或评论 `%s`)即可再次接管。\n\n</details>\n\n<!-- takeover-ack released -->' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")"
|
||||
fi
|
||||
gh pr comment "${PR}" --repo "${REPO}" --body "${REL_BODY}" \
|
||||
|| echo "::warning::release ack comment failed on #${PR}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ===========================================================================
|
||||
# TAKEOVER ACK — visible confirmation when a maintainer engages or releases
|
||||
# a PR via the takeover label. Label events are explicit user actions, so
|
||||
# every toggle acks (no dedup wanted). In-repo PRs only reach this job.
|
||||
# a PR via the takeover label. Manual label toggles are explicit user
|
||||
# actions, so every one acks (no dedup wanted). Command-driven toggles are
|
||||
# acked by takeover-command itself in BOTH directions — the label event has
|
||||
# been observed to not fire at all (#7999, #8002), so those acks cannot
|
||||
# depend on this round-trip — and the route suppresses this job for them
|
||||
# (label sender is the bot). In-repo PRs only reach this job.
|
||||
# ===========================================================================
|
||||
# Re-arm a stranded PR without deleting anything. Recovery previously meant
|
||||
# `gh api -X DELETE` on the bot's own autofix-eval marker comment: raw API
|
||||
|
|
@ -1600,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.
|
||||
|
|
@ -1669,10 +1770,10 @@ jobs:
|
|||
else
|
||||
gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
|
||||
--base main \
|
||||
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify > "${WORKDIR}/bot-prs.json"
|
||||
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify,updatedAt > "${WORKDIR}/bot-prs.json"
|
||||
gh pr list --repo "${REPO}" --state open --label "${TAKEOVER_LABEL}" \
|
||||
--base main \
|
||||
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify > "${WORKDIR}/takeover-prs.json"
|
||||
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify,updatedAt > "${WORKDIR}/takeover-prs.json"
|
||||
# Skip-labeled PRs are excluded HERE, not only at the address
|
||||
# gate: that gate discards without writing a marker, so the
|
||||
# watermark never advances and an unfiltered scan would re-emit
|
||||
|
|
@ -1815,6 +1916,44 @@ jobs:
|
|||
)
|
||||
[[ "${BUSY_PRS}" != ' ' ]] && echo "🚧 address in flight/queued for PR(s):${BUSY_PRS}"
|
||||
|
||||
# Idle backoff, from the list's own updatedAt (no API call): a
|
||||
# candidate with no activity for >24h is inspected on about one
|
||||
# scan in four instead of every one. The pool doubled in two
|
||||
# days (28 takeover PRs, 8 of them idle in "nothing new" state
|
||||
# for 10+ hours), and every idle inspection costs a unit of the
|
||||
# SHARED MAX_CANDIDATE_INSPECTIONS budget plus a slice of the
|
||||
# serial API walk over the candidate list. The win is small: a
|
||||
# few fewer gh round-trips per scan (~2-3 of the pool) and less
|
||||
# rate-limit pressure. It does NOT recover the job's queue or
|
||||
# startup latency, which dwarfed the walk in the #8002
|
||||
# measurement that motivated this. Idle PRs never reach the
|
||||
# 10-target budget (the "nothing new" branch continues before
|
||||
# the TARGETS append), so that cap is NOT what this relieves.
|
||||
# Safe because comments, reviews, labels, and pushes all bump
|
||||
# updatedAt or route in real time; the two scan-only signals
|
||||
# that do NOT bump it — a base conflict appearing when main
|
||||
# moves, and still-red checks awaiting the redcheck marker —
|
||||
# wait out the backoff on a PR nobody touched in a day, then
|
||||
# self-correct (the eventual address run comments/pushes). The
|
||||
# slot is keyed by PR number mod 4 against a 600s time quantum
|
||||
# (same quantum as ROT_OFF), so each scan is an independent
|
||||
# ~25% draw per idle PR — about one scan in four. This is NOT a
|
||||
# bounded gap: the scheduled scan lands every ~40-70 min on
|
||||
# this repo (not the */10 the cron implies), so the wait is
|
||||
# geometric — measured median ~2h, p90 ~6h across 100 real
|
||||
# scans. The forced-dispatch path never builds the list files,
|
||||
# so a forced PR is always inspected (fail-open, like a PR
|
||||
# missing from the set).
|
||||
IDLE_PRS=' '
|
||||
if [[ -f "${WORKDIR}/bot-prs.json" && -f "${WORKDIR}/takeover-prs.json" ]]; then
|
||||
IDLE_CUTOFF="$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
IDLE_PRS=" $(jq -rs --arg cut "${IDLE_CUTOFF}" 'add | unique_by(.number)
|
||||
| map(select((.updatedAt // "") != "" and .updatedAt < $cut) | .number | tostring)
|
||||
| join(" ")' \
|
||||
"${WORKDIR}/bot-prs.json" "${WORKDIR}/takeover-prs.json" 2> /dev/null || echo '') "
|
||||
fi
|
||||
IDLE_SLOT_NOW="$(( ($(date -u +%s) / 600) % 4 ))"
|
||||
|
||||
TARGETS='[]'
|
||||
INSPECTED=0
|
||||
for PR in ${CANDIDATES}; do
|
||||
|
|
@ -1825,6 +1964,14 @@ jobs:
|
|||
fleet_row "${PR}" 'busy' 'address run in flight'
|
||||
continue
|
||||
fi
|
||||
# The idle-backoff skip is free too (a bash substring test on
|
||||
# the precomputed set, same idiom as the busy skip) — it must
|
||||
# not consume inspection budget either.
|
||||
if [[ "${IDLE_PRS}" == *" ${PR} "* && "$(( PR % 4 ))" != "${IDLE_SLOT_NOW}" ]]; then
|
||||
echo "😴 #${PR}: idle >24h — deferring to its rotation slot (slot $(( PR % 4 )), current ${IDLE_SLOT_NOW}; inspected ~1 scan in 4)"
|
||||
fleet_row "${PR}" 'idle-backoff' 'idle >24h; inspected ~1 scan in 4 (median ~2h, p90 ~6h)'
|
||||
continue
|
||||
fi
|
||||
INSPECTED=$(( INSPECTED + 1 ))
|
||||
if [[ "${INSPECTED}" -gt "${MAX_CANDIDATE_INSPECTIONS}" ]]; then
|
||||
echo "🧮 candidate-inspection budget (${MAX_CANDIDATE_INSPECTIONS}) reached — deferring the rest to the next scan"
|
||||
|
|
@ -1984,13 +2131,29 @@ jobs:
|
|||
| .created_at] | sort | last // ""' "${WORKDIR}/pr-events.json")"
|
||||
if [[ -z "${LAST_ENGAGE_ACK_TS}" ]]; then
|
||||
NEED_ENGAGE_ACK='true'
|
||||
# In-repo label events have a DEDICATED ack job — the scan is
|
||||
# only its healer. Within a short grace after the label lands,
|
||||
# defer: a concurrent ack job must not be double-posted (that
|
||||
# shifts the window anchor). A failed ack job is healed by the
|
||||
# next scan, which is past the grace. Forks have no ack job,
|
||||
# so no grace applies there.
|
||||
if [[ "$(jq -r '.isCrossRepository // false' <<< "${PR_META}")" != "true" ]] \
|
||||
# Grace windows keyed by WHO owns the missing ack, read from
|
||||
# the label event's actor (pr-events.json is already here).
|
||||
# A bot-applied label came from takeover-command, which posts
|
||||
# the ack itself within seconds — fork or in-repo alike — so
|
||||
# a SHORT grace covers the write's own latency and an
|
||||
# ic.json snapshot taken between the label write and the ack
|
||||
# landing; past it, the command's post failed and the next
|
||||
# scheduled scan heals it (≤10 min), instead of waiting on
|
||||
# a label event that may never arrive. A human-applied
|
||||
# in-repo label is owned by the
|
||||
# DEDICATED ack job, which needs job-spin-up time — the
|
||||
# longer grace stands. A human-labeled fork has no other
|
||||
# owner, so no grace: the scan posts right here.
|
||||
LAST_LABELED_BY="$(jq -rs --arg lb "${TAKEOVER_LABEL}" '
|
||||
add | [.[] | select(.event == "labeled")
|
||||
| select((.label.name // "") == $lb)]
|
||||
| sort_by(.created_at) | last | .actor.login // ""' "${WORKDIR}/pr-events.json")"
|
||||
if [[ "${LAST_LABELED_BY}" == "${AUTOFIX_BOT}" ]]; then
|
||||
if [[ -n "${LAST_LABELED_TS}" && "${LAST_LABELED_TS}" > "$(date -u -d '45 seconds ago' +%Y-%m-%dT%H:%M:%SZ)" ]]; then
|
||||
echo "🧭 engage ack deferred for #${PR}: command-applied label <45s ago — the command's own ack is in flight"
|
||||
NEED_ENGAGE_ACK='false'
|
||||
fi
|
||||
elif [[ "$(jq -r '.isCrossRepository // false' <<< "${PR_META}")" != "true" ]] \
|
||||
&& [[ -n "${LAST_LABELED_TS}" && "${LAST_LABELED_TS}" > "$(date -u -d '3 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" ]]; then
|
||||
echo "🧭 engage ack deferred for #${PR}: in-repo label applied <3m ago — the ack job owns it"
|
||||
NEED_ENGAGE_ACK='false'
|
||||
|
|
@ -2096,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
|
||||
|
|
@ -2281,7 +2486,7 @@ jobs:
|
|||
# bot's own eval markers, and known non-actionable bot comments
|
||||
# (triage stages, coverage reports, legacy suggestion summaries,
|
||||
# force-push reminders).
|
||||
BOT_COMMENT_FILTER='<!-- (autofix-eval|autofix-rearm|autofix-base-updated|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) '
|
||||
BOT_COMMENT_FILTER='<!-- (autofix-eval|autofix-rearm|autofix-base-updated|autofix-milestone|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) '
|
||||
# Command-style comments (@qwen-code /takeover, /triage, /review …)
|
||||
# are INSTRUCTIONS to tooling, not review feedback on the diff:
|
||||
# counting them as actionable burns a full agent cycle to post a
|
||||
|
|
@ -2823,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}"
|
||||
|
||||
|
|
@ -2832,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
|
||||
|
|
@ -2869,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"
|
||||
|
|
@ -2900,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"; ""))"' \
|
||||
|
|
@ -2915,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
|
||||
|
|
@ -2923,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
|
||||
|
|
@ -2939,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)
|
||||
|
|
@ -2947,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"
|
||||
|
|
@ -2998,6 +3277,42 @@ jobs:
|
|||
echo
|
||||
printf '%s\n' "${LAST_REJECTION}"
|
||||
fi
|
||||
# Time-budget exhaustions SINCE THE LAST SUCCESSFUL ROUND mean
|
||||
# the standard address-everything prompt is not converging at
|
||||
# this budget: re-running it unchanged just walks into the same
|
||||
# wall (#7929 burned three 50-minute timeouts that way, #7846
|
||||
# two — each a full agent run with nothing pushed). From the
|
||||
# second attempt on, tell the agent to narrow. Counted since
|
||||
# the last pushed/no-change round, NOT cumulatively: a push
|
||||
# falsifies "not converging" and resets the count, so a recovered
|
||||
# PR stops seeing the warning; until a round pushes or no-ops it
|
||||
# fires on every failing round (gate rejections included) —
|
||||
# correctly, since nothing has converged yet. (The
|
||||
# BREAKER in the report step stays cumulative — a push does not
|
||||
# make the next timeout cheaper in budget terms.) Window-scoped
|
||||
# like every other census (LIVE_REARM_KEY is the live window),
|
||||
# so a re-arm clears it. The needle matches the emitted
|
||||
# headline verbatim: first lines can embed provider error text
|
||||
# (API_ERROR_DETAIL), so a loose phrase could count a model
|
||||
# error message as a timeout.
|
||||
PRIOR_TIMEOUTS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" '
|
||||
[ .[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- autofix-eval "))
|
||||
| select(((.body // "") | contains("win=" + $key + " -->"))
|
||||
or ($key == "none" and (((.body // "") | contains("win=")) | not)))
|
||||
] | sort_by(.created_at)
|
||||
| map((.body | gsub("\r"; "") | split("\n")[0]))
|
||||
| (map(test("Addressed the latest review feedback|no changes needed")) | rindex(true) // -1) as $lastok
|
||||
| [ .[($lastok + 1):][] | select(contains("AutoFix ran out of time before finishing")) ] | length' "${WORKDIR}/ic.json" 2> /dev/null || true)"
|
||||
if [[ "${PRIOR_TIMEOUTS}" -ge 1 ]]; then
|
||||
echo
|
||||
echo '## Budget warning: previous round(s) ran out of time'
|
||||
echo
|
||||
echo "${PRIOR_TIMEOUTS} round(s) since the last successful round exhausted the agent time budget before finishing anything. Do NOT attempt everything at once this round:"
|
||||
echo '- Address the smallest set of blocking (Critical) findings first, and commit as soon as that subset is done.'
|
||||
echo '- Prefer minimal, focused diffs; decline refactors and nice-to-haves with a one-line reason rather than implementing them.'
|
||||
echo '- If the remaining feedback cannot fit the budget, defer it the normal way: leave those findings out of resolved-comments.txt and record each deferral in comment-replies.json so every open thread gets its reason — never let a deferral live only in the summary.'
|
||||
fi
|
||||
} > "${WORKDIR}/feedback.md"
|
||||
echo '--- feedback.md ---'
|
||||
cat "${WORKDIR}/feedback.md"
|
||||
|
|
@ -3222,6 +3537,7 @@ jobs:
|
|||
"${WORKDIR}/failure.md" \
|
||||
"${WORKDIR}/handoff.md" \
|
||||
"${WORKDIR}/gate-output.log" \
|
||||
"${WORKDIR}/gate-rejection.md" \
|
||||
"${WORKDIR}/agent-api-error" \
|
||||
"${WORKDIR}/agent-api-error-kind" \
|
||||
"${WORKDIR}/agent-timeout"
|
||||
|
|
@ -3353,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.
|
||||
|
|
@ -3472,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
|
||||
|
|
@ -3507,6 +3871,89 @@ jobs:
|
|||
|
||||
gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md"
|
||||
|
||||
# Takeover milestone digest — roughly every 10 rounds. The takeover
|
||||
# cap (100) bounds runaway but says nothing about when a human
|
||||
# should step in: #7469 ground to round 12 over 7 days with the
|
||||
# only "this is burning budget" signal buried in Actions logs.
|
||||
# Once 10+ rounds accumulate since the last digest, surface a
|
||||
# window-scoped census on the PR so the maintainer who engaged it
|
||||
# can decide: keep going, split the PR, or release. A SEPARATE
|
||||
# comment with its OWN marker and WITHOUT the autofix-eval marker:
|
||||
# every census (round, consec, watermark) selects on autofix-eval,
|
||||
# so this comment is invisible to all of them, and the feedback
|
||||
# filters drop bot comments, so the agent never sees it either.
|
||||
# Best-effort: a digest failure must never fail a good push.
|
||||
if [[ "${OUTCOME}" == "fixed" && "${MAX_ROUNDS}" == "${TAKEOVER_MAX_ROUNDS}" ]] \
|
||||
&& [[ "${NEXT_ROUND}" -ge 10 && -f "${WORKDIR}/ic.json" ]]; then
|
||||
# Crossing trigger, not an equality test: failure rounds also
|
||||
# advance the round counter, so `push@9, crash@10, push@11`
|
||||
# would skip an exact %10 check forever — and a failure-heavy
|
||||
# PR is the very PR the digest exists for. Post on the first
|
||||
# PUSHED round once 10+ rounds have accumulated since the last
|
||||
# digest in THIS window (or since the window opened).
|
||||
MS_LAST="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" '
|
||||
[ .[] | select((.user.login // "") == $ab) | (.body // "")
|
||||
| [ scan("<!-- autofix-milestone round=([0-9]+) win=([^ ]+) -->") ] | .[]
|
||||
| select(.[1] == $win) | (.[0] | tonumber) ]
|
||||
| max // 0' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)"
|
||||
if [[ "$(( NEXT_ROUND - MS_LAST ))" -ge 10 ]]; then
|
||||
WIN_HEADS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" '
|
||||
[.[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- autofix-eval "))
|
||||
| select(
|
||||
((.body // "") | contains("win=" + $win + " -->"))
|
||||
or ($win == "none" and (((.body // "") | contains("win=")) | not)))]
|
||||
| sort_by(.created_at) | .[]
|
||||
| (.body | gsub("\r"; "") | split("\n")[0])' "${WORKDIR}/ic.json" 2> /dev/null || true)"
|
||||
if [[ -z "${WIN_HEADS}" ]]; then
|
||||
# Reaching round 10+ with zero window markers means the
|
||||
# parse failed (prior markers must exist to be here) — a
|
||||
# fabricated all-zero census is worse than no digest.
|
||||
echo "::warning::milestone census found no window markers on #${PR}; skipping the digest"
|
||||
else
|
||||
N_PUSHED="$(grep -c 'Addressed the latest review feedback' <<< "${WIN_HEADS}" || true)"
|
||||
# This round's own marker was posted just above but ic.json
|
||||
# predates it — count it in by hand.
|
||||
N_PUSHED=$(( N_PUSHED + 1 ))
|
||||
N_NOOP="$(grep -c 'no changes needed' <<< "${WIN_HEADS}" || true)"
|
||||
# Needle matches the emitted headline verbatim — first
|
||||
# lines can embed provider error text.
|
||||
N_TIMEOUT="$(grep -c 'AutoFix ran out of time before finishing' <<< "${WIN_HEADS}" || true)"
|
||||
# Both wordings of the gate-rejection handoff, past and
|
||||
# present — the census must not silently zero when the
|
||||
# headline is reworded.
|
||||
N_REJECTED="$(grep -cE 'Could not (address the latest feedback|produce a passing fix)' <<< "${WIN_HEADS}" || true)"
|
||||
# Every other outcome (crash, model error, gate error,
|
||||
# infra) lands in a residual bucket: a window that burned
|
||||
# 80% of its budget on crashes must be the LOUDEST line in
|
||||
# the digest, not four zeros quieter than a healthy one.
|
||||
N_TOTAL=$(( $(grep -c . <<< "${WIN_HEADS}" || true) + 1 ))
|
||||
N_OTHER=$(( N_TOTAL - N_PUSHED - N_NOOP - N_TIMEOUT - N_REJECTED ))
|
||||
(( N_OTHER < 0 )) && N_OTHER=0
|
||||
# Base updates carry their own marker with no win= field;
|
||||
# their window is recovered by timestamp (the window key IS
|
||||
# the engage ack's created_at — 'none' means count all,
|
||||
# and the header says so).
|
||||
N_BASE="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" '
|
||||
[.[] | select((.user.login // "") == $ab)
|
||||
| select((.body // "") | contains("<!-- autofix-base-updated -->"))
|
||||
| select($win == "none" or ((.created_at // "") > $win))]
|
||||
| length' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)"
|
||||
WIN_DESC='in the current window'
|
||||
WIN_DESC_ZH='当前窗口'
|
||||
if [[ "${WINDOW:-none}" == 'none' ]]; then
|
||||
WIN_DESC='since the PR opened (no counting window yet)'
|
||||
WIN_DESC_ZH='自 PR 创建以来(尚无计数窗口)'
|
||||
fi
|
||||
if gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '📊 Takeover milestone — round %s/%s, %s. Census: %s pushed fix(es), %s no-change review(s), %s timeout(s), %s rejected attempt(s), %s other round(s) (crash / model error / gate error / infra), %s base update(s).\n\nThis many rounds deserves a human look. Options: keep going (fine — nothing changes), split or reduce the PR if rounds keep accumulating, or release takeover (remove the `%s` label or comment `%s stop`). Management continues unchanged unless you act.\n\n<details>\n<summary>中文说明</summary>\n\n📊 接管里程碑 —— 第 %s/%s 轮(%s)。统计:推送修复 %s 次、审阅无需改动 %s 次、超时 %s 次、验证拒绝 %s 次、其他轮次(崩溃/模型错误/门错误/infra)%s 次、base 更新 %s 次。\n\n轮次到这个量值得人工看一眼。可选:继续(无需操作);若轮次持续累积,考虑拆分或缩减 PR;或释放接管(移除 `%s` 标签或评论 `%s stop`)。不操作则托管照常继续。\n\n</details>\n\n<!-- autofix-milestone round=%s win=%s -->' "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC_ZH}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${WINDOW:-none}")"; then
|
||||
echo "📊 milestone digest posted on #${PR} (round ${NEXT_ROUND})"
|
||||
else
|
||||
echo "::warning::milestone digest failed to post on PR #${PR}; the round report above already landed"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
ISSUE_REF=""
|
||||
[[ "${ISSUE}" != "${PR}" ]] && ISSUE_REF=" (issue #${ISSUE})"
|
||||
|
|
@ -3783,7 +4230,22 @@ jobs:
|
|||
MARK_TS='9999-12-31T23:59:59Z'
|
||||
HEADLINE="🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind \`${DEFAULT_BRANCH:-main}\`, so it merged current main in via update-branch and will retry on the next scan. A stale base (a dependency or symbol main already changed) can fail the build without being the fix's fault; if it still fails once current, it hands off to a human."
|
||||
else
|
||||
HEADLINE="🤖 Could not address the latest feedback automatically (round ${MARK_ROUND}/${MAX_ROUNDS}). A human should take over this PR."
|
||||
# Say what actually happens next. The old "A human should
|
||||
# take over this PR" read as a full release, but the loop
|
||||
# is NOT done with the PR: this feedback's watermark
|
||||
# advances (no automatic retry of THIS item), while
|
||||
# management continues for new feedback and base conflicts
|
||||
# — #7929 posted the old wording and then kept pushing
|
||||
# rounds, which read as a contradiction.
|
||||
# Name the gate ONLY when it actually ran: this branch is
|
||||
# reached for every outcome=failed verdict, but reject_fix
|
||||
# is the sole writer of gate-rejection.md — the failure.md /
|
||||
# dirty-tree / unchanged-branch / missing-summary paths made
|
||||
# no gate decision, so a blanket clause would repeat the very
|
||||
# wording-doesn't-match-behaviour bug this PR fixes.
|
||||
GATE_CLAUSE=''
|
||||
[[ -s "${WORKDIR}/gate-rejection.md" ]] && GATE_CLAUSE=' — the verification gate rejected the attempt'
|
||||
HEADLINE="🤖 Could not produce a passing fix for this feedback (round ${MARK_ROUND}/${MAX_ROUNDS})${GATE_CLAUSE}. This item now needs a human; the loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own."
|
||||
fi
|
||||
fi
|
||||
elif [[ "${PREPARE_OUTCOME}" != 'success' && "${PREPARE_OUTCOME}" != 'failure' ]]; then
|
||||
|
|
@ -3884,6 +4346,36 @@ jobs:
|
|||
MARK_ROUND="${MAX_ROUNDS}"
|
||||
HEADLINE="🤖 AutoFix stopped after ${CONSEC_FAIL} consecutive rounds that failed to push anything (timeouts and/or gate rejections). Retrying at the same per-round budget is not converging — this usually means the PR is too large or conflicts with a fast-moving \`main\`. A human should rebase, split, or reduce it, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR."
|
||||
fi
|
||||
# CUMULATIVE timeout breaker — the sibling of the consecutive
|
||||
# one above, for the failure shape it cannot see: timeouts
|
||||
# interleaved with pushed rounds. A push resets CONSEC_FAIL,
|
||||
# but it does not make the next timeout cheaper — each burns a
|
||||
# full agent budget with nothing to show (observed on #7929:
|
||||
# three timeouts with successes in between; #7846 twice). The
|
||||
# census reuses PRIOR_HEADS, so it is window-scoped exactly
|
||||
# like the consecutive one and a re-arm clears it. Only
|
||||
# overrides a would-be RETRY: a round already terminal keeps
|
||||
# its own headline (the consecutive breaker included).
|
||||
if [[ "${MARK_ROUND}" != "${MAX_ROUNDS}" ]]; then
|
||||
# Needle matches the emitted headline verbatim — first lines
|
||||
# can embed provider error text (API_ERROR_DETAIL puts up to
|
||||
# 200 bytes of it on the same line), so a loose phrase could
|
||||
# count a model error message as a timeout.
|
||||
TIMEOUT_N="$(grep -c 'AutoFix ran out of time before finishing' <<< "${PRIOR_HEADS}" || true)"
|
||||
if [[ -n "${AGENT_TIMEOUT:-}" ]]; then
|
||||
TIMEOUT_N=$(( TIMEOUT_N + 1 ))
|
||||
fi
|
||||
if [[ "${TIMEOUT_N}" -ge "${TIMEOUT_WINDOW_CAP}" ]]; then
|
||||
MARK_ROUND="${MAX_ROUNDS}"
|
||||
# The headline states what the census MEASURED — the
|
||||
# window's cumulative count — not "stopped after N
|
||||
# timeouts": the round that trips this can itself have
|
||||
# failed differently (a gate rejection landing on a window
|
||||
# that already carries the cap — the exact rollout state
|
||||
# of #7929/#7846).
|
||||
HEADLINE="🤖 AutoFix stopped: this counting window now contains ${TIMEOUT_N} time-budget exhaustions (pushed rounds in between included; this round itself may have failed differently). That is ${TIMEOUT_N} full agent runs that pushed nothing. A human should split or reduce the PR (or raise the agent time budget), then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
{
|
||||
echo "${HEADLINE}"
|
||||
|
|
|
|||
135
.github/workflows/qwen-triage.yml
vendored
135
.github/workflows/qwen-triage.yml
vendored
|
|
@ -1850,12 +1850,24 @@ jobs:
|
|||
format('{0}-verify-run-{1}', github.workflow, github.run_id)
|
||||
}}
|
||||
cancel-in-progress: false
|
||||
# 60, not 45: the agent's own 25m timeout is the graceful budget (it
|
||||
# ships a partial report); the job limit only guards infra hangs. At 45
|
||||
# a slow npm ci + build (15m+ on this monorepo) could let the JOB
|
||||
# timeout kill the container mid-run, bypassing the agent's
|
||||
# The agent's own 120m timeout is the GRACEFUL budget — it ships a
|
||||
# partial report on expiry. This job limit only guards infra hangs, so it
|
||||
# must stay comfortably ABOVE `agent budget + everything before it`, or
|
||||
# the JOB timeout kills the container mid-run and bypasses the
|
||||
# ship-what-ran path entirely.
|
||||
timeout-minutes: 60
|
||||
#
|
||||
# agent budget 120m
|
||||
# install + build ~6m measured (run 30284341325: npm ci
|
||||
# 3m00 + build 2m40), budget 15m for a
|
||||
# cold cache or a heavier dependency tree
|
||||
# resolver/tools, checkout,
|
||||
# pin, upload, cleanup ~5m
|
||||
# ------------------------------------
|
||||
# worst case ~140m ⇒ 150 leaves 10m of headroom.
|
||||
#
|
||||
# Cost of the raise, stated so it is a decision and not a surprise: a
|
||||
# verify run now occupies one ECS slot for up to 2.5h instead of 1h.
|
||||
timeout-minutes: 150
|
||||
runs-on: ['self-hosted', 'linux', 'x64', 'ecs-qwen']
|
||||
# The job checks out and executes PR code. Run the steps in a container so
|
||||
# package scripts/builds cannot persist changes in the self-hosted runner's
|
||||
|
|
@ -2235,6 +2247,25 @@ jobs:
|
|||
(cd "${RUNNER_TEMP:?}" && npm install -g --registry=https://registry.npmjs.org '@qwen-code/qwen-code@latest')
|
||||
qwen --version
|
||||
|
||||
# Chromium system dependencies (apt packages) for evidence
|
||||
# screenshots. install-deps is unpinned so the apt list tracks
|
||||
# current Playwright (a superset of the lockfile binary's needs);
|
||||
# the version-sensitive BINARY is downloaded after checkout by the
|
||||
# "Install evidence browser" step, using the checkout's own
|
||||
# Playwright so it always matches the lockfile. Best-effort: a
|
||||
# failure here must not fail the job.
|
||||
# Record success in a marker the "Install evidence browser" step
|
||||
# gates on: apt and the Playwright CDN are independent servers with
|
||||
# no shared success signal, so a binary download alone must not
|
||||
# promise chromium to the agent. Rewritten or removed every run so
|
||||
# a stale success on the persistent pool cannot leak through.
|
||||
if (cd "${RUNNER_TEMP:?}" && npx --yes playwright install-deps chromium); then
|
||||
printf 'ok' > "${RUNNER_TEMP:?}/verify-chromium-deps-ok"
|
||||
else
|
||||
rm -f "${RUNNER_TEMP:?}/verify-chromium-deps-ok"
|
||||
echo "::warning::Chromium system deps install failed; the verification agent will produce a text-only report."
|
||||
fi
|
||||
|
||||
# This container mounts the persistent runner workspace, and a previous
|
||||
# run EXECUTED PR code as the node user with write access to .git —
|
||||
# hooks or config planted then would fire during the checkout below (as
|
||||
|
|
@ -2432,8 +2463,11 @@ jobs:
|
|||
unset ACTIONS_RUNTIME_TOKEN ACTIONS_RUNTIME_URL ACTIONS_CACHE_URL
|
||||
# rm first: on the persistent pool a stale verify-results from an
|
||||
# EARLIER PR's run would otherwise ride along into this run's
|
||||
# artifact upload and report selection.
|
||||
# artifact upload and report selection. The chromium marker is
|
||||
# cleared too, so a previous run's success cannot promise this run
|
||||
# a browser it never installed.
|
||||
rm -rf "$RUNNER_TEMP/verify-results"
|
||||
rm -f "$RUNNER_TEMP/verify-chromium-path"
|
||||
mkdir -p "$RUNNER_TEMP/verify-results"
|
||||
chown -R node:node "$GITHUB_WORKSPACE"
|
||||
prepare_log="$RUNNER_TEMP/verify-results/prepare.log"
|
||||
|
|
@ -2524,6 +2558,46 @@ jobs:
|
|||
fi
|
||||
echo "Install/build completed before verification." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: 'Install evidence browser'
|
||||
if: "steps.pr.outputs.decision == 'run' && steps.prepare.outputs.verdict == ''"
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
GH_TOKEN: ''
|
||||
run: |-
|
||||
set -uo pipefail
|
||||
# Download the browser binary for the Playwright package the
|
||||
# capture harness actually imports. This lockfile has TWO
|
||||
# Playwright trees: terminal-capture.ts imports `playwright`, but
|
||||
# node_modules/.bin/playwright is @playwright/test's CLI, which
|
||||
# pins a different chromium revision — so `npx playwright install`
|
||||
# would download a browser the harness cannot launch. Resolve the
|
||||
# CLI from the harness's own directory: require.resolve runs the
|
||||
# same algorithm as its `from 'playwright'` import, so the binary
|
||||
# matches the lockfile even if npm's hoist layout changes (nothing
|
||||
# pins playwright to the root node_modules). cli.js is not in the
|
||||
# package's exports map, so resolve the exported package.json and
|
||||
# join. Runs as node so the tree is agent-readable without chmod.
|
||||
# Best-effort: failure degrades to a text-only report.
|
||||
PW_PATH="${RUNNER_TEMP:?}/pw-browsers"
|
||||
mkdir -p "$PW_PATH"
|
||||
chown node:node "$PW_PATH"
|
||||
PW_CLI="$(runuser -u node -- node -p "require('path').join(require('path').dirname(require.resolve('playwright/package.json', { paths: ['./integration-tests/terminal-capture'] })), 'cli.js')")"
|
||||
# The marker gates QWEN_VERIFY_CHROMIUM in the agent step, so it
|
||||
# requires BOTH halves: the system deps installed as root in the
|
||||
# tools step (which writes verify-chromium-deps-ok) AND the binary
|
||||
# download below. A binary-only success would tell the agent
|
||||
# chromium is ready when every launch still dies on a missing .so.
|
||||
if [ -f "${RUNNER_TEMP:?}/verify-chromium-deps-ok" ] &&
|
||||
runuser -u node -- env -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \
|
||||
-u ACTIONS_RUNTIME_TOKEN -u ACTIONS_RUNTIME_URL -u ACTIONS_CACHE_URL \
|
||||
PLAYWRIGHT_BROWSERS_PATH="$PW_PATH" \
|
||||
node "$PW_CLI" install chromium; then
|
||||
printf '%s' "$PW_PATH" > "${RUNNER_TEMP:?}/verify-chromium-path"
|
||||
echo "Chromium available for evidence screenshots." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "::warning::Chromium unavailable (system deps or browser download failed); the verification agent will produce a text-only report."
|
||||
fi
|
||||
|
||||
- name: 'Run verification agent'
|
||||
if: "steps.pr.outputs.decision == 'run' && steps.prepare.outputs.verdict == ''"
|
||||
id: 'run'
|
||||
|
|
@ -2958,16 +3032,32 @@ jobs:
|
|||
"QWEN_CI_REAL_GH=${QWEN_CI_REAL_GH:-}"
|
||||
"QWEN_CI_REAL_GIT=${QWEN_CI_REAL_GIT:-}"
|
||||
)
|
||||
# Evidence screenshots: the "Install evidence browser" step
|
||||
# downloaded chromium into a shared path using the checkout's
|
||||
# Playwright. Both variables are set only when that install
|
||||
# actually succeeded, so their ABSENCE is the agent's signal to
|
||||
# skip captures rather than burn budget on a download it cannot
|
||||
# complete.
|
||||
if CHROMIUM_PATH="$(cat "${RUNNER_TEMP:?}/verify-chromium-path" 2>/dev/null)" &&
|
||||
[ -n "$CHROMIUM_PATH" ]; then
|
||||
QWEN_ENV+=(
|
||||
"PLAYWRIGHT_BROWSERS_PATH=$CHROMIUM_PATH"
|
||||
"QWEN_VERIFY_CHROMIUM=1"
|
||||
)
|
||||
fi
|
||||
if [ -n "${OPENAI_MODEL:-}" ]; then
|
||||
QWEN_ENV+=("OPENAI_MODEL=$OPENAI_MODEL")
|
||||
fi
|
||||
|
||||
# The agent's graceful kill budget in minutes. The watchdog
|
||||
# threshold below derives from it, so the two cannot drift apart.
|
||||
AGENT_BUDGET_M=120
|
||||
# Elapsed time of the WATCHDOG CHILD only: $SECONDS includes proxy
|
||||
# and network setup, so an OOM kill late in the step could cross a
|
||||
# global threshold and be mislabeled a configured timeout.
|
||||
AGENT_START=$SECONDS
|
||||
set +e
|
||||
timeout --kill-after=10s 25m runuser -u node -- env -i "${QWEN_ENV[@]}" "${QWEN_CMD[@]}" \
|
||||
timeout --kill-after=10s "${AGENT_BUDGET_M}m" runuser -u node -- env -i "${QWEN_ENV[@]}" "${QWEN_CMD[@]}" \
|
||||
--prompt "/verify-pr ${PR_NUMBER} --repo ${REPOSITORY}" \
|
||||
--output-format stream-json \
|
||||
| tee "$RUNNER_TEMP/verify-results/output.jsonl"
|
||||
|
|
@ -2983,6 +3073,7 @@ jobs:
|
|||
TEE_STATUS=${PIPE_STATUS[1]:-0}
|
||||
EXIT_CODE=$AGENT_STATUS
|
||||
set -e
|
||||
AGENT_ELAPSED=$((SECONDS - AGENT_START))
|
||||
|
||||
# Collect the skill's artifacts (report.md, verdict.txt,
|
||||
# assertions.json, harness scripts, raw logs) into the upload dir.
|
||||
|
|
@ -2994,10 +3085,12 @@ jobs:
|
|||
|
||||
# 137 is ambiguous: the watchdog escalating past --kill-after looks
|
||||
# identical to an OOM kill. Use the elapsed budget to tell them
|
||||
# apart instead of labelling every 137 a crash.
|
||||
AGENT_ELAPSED=$((SECONDS - AGENT_START))
|
||||
# apart instead of labelling every 137 a crash. The threshold is
|
||||
# derived from AGENT_BUDGET_M so it cannot drift from the budget:
|
||||
# a 137 at or after the full budget is the watchdog, an earlier
|
||||
# 137 is an OOM.
|
||||
WATCHDOG_FIRED=false
|
||||
if [ "$EXIT_CODE" -eq 137 ] && [ "$AGENT_ELAPSED" -ge 1500 ]; then
|
||||
if [ "$EXIT_CODE" -eq 137 ] && [ "$AGENT_ELAPSED" -ge $((AGENT_BUDGET_M * 60)) ]; then
|
||||
WATCHDOG_FIRED=true
|
||||
fi
|
||||
if [ "$EXIT_CODE" -eq 124 ] || [ "$WATCHDOG_FIRED" = true ]; then
|
||||
|
|
@ -3233,8 +3326,8 @@ jobs:
|
|||
printf '</code></pre>\n\n</details>\n\n'
|
||||
}
|
||||
|
||||
# Host the agent's evidence images (if any) on the pr-assets branch
|
||||
# — the same convention hand-run verification rounds use — and build
|
||||
# Host the agent's evidence images (if any) on a per-PR branch
|
||||
# (pr-assets/<N>-verify, matching hand-run convention) and build
|
||||
# a markdown section referencing them. Image bytes come from a run
|
||||
# that executed PR code: inert but untrusted, so filenames pass a
|
||||
# strict allowlist, count/size are capped (8 files, <2 MB each; the
|
||||
|
|
@ -3245,6 +3338,7 @@ jobs:
|
|||
collect_and_host_evidence() {
|
||||
local imgs=() f base safe seen=' ' hosted=0 total=0 skipped=0
|
||||
local dest_dir="verify/pr${PR_NUMBER}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
local assets_branch="pr-assets/${PR_NUMBER}-verify"
|
||||
local clone_dir="${RUNNER_TEMP:-/tmp}/pr-assets"
|
||||
local remote="${VERIFY_ASSETS_REMOTE:-https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git}"
|
||||
# `|| true` on every find: the artifact download is
|
||||
|
|
@ -3264,9 +3358,12 @@ jobs:
|
|||
return 0
|
||||
fi
|
||||
rm -rf "$clone_dir"
|
||||
if ! git clone -q --depth 1 --branch pr-assets "$remote" "$clone_dir" 2>/dev/null; then
|
||||
echo "::warning::pr-assets branch unavailable; posting a text-only report." >&2
|
||||
return 0
|
||||
if ! git clone -q --depth 1 --branch "$assets_branch" "$remote" "$clone_dir" 2>/dev/null; then
|
||||
rm -rf "$clone_dir"
|
||||
mkdir -p "$clone_dir"
|
||||
git -C "$clone_dir" init -q
|
||||
git -C "$clone_dir" checkout -q --orphan "$assets_branch"
|
||||
git -C "$clone_dir" remote add origin "$remote"
|
||||
fi
|
||||
# Rebase in the racing-push retry needs a committer identity, so
|
||||
# set it once on the clone instead of per-command -c flags.
|
||||
|
|
@ -3302,11 +3399,11 @@ jobs:
|
|||
git add "$dest_dir" &&
|
||||
git commit -q -m "verify evidence for PR #${PR_NUMBER} (run ${GITHUB_RUN_ID})" &&
|
||||
{
|
||||
git push -q origin HEAD:pr-assets 2>/dev/null ||
|
||||
git push -q origin "HEAD:$assets_branch" 2>/dev/null ||
|
||||
{
|
||||
# One retry after a racing push from another assets job.
|
||||
git pull -q --rebase origin pr-assets 2>/dev/null &&
|
||||
git push -q origin HEAD:pr-assets 2>/dev/null
|
||||
git pull -q --rebase origin "$assets_branch" 2>/dev/null &&
|
||||
git push -q origin "HEAD:$assets_branch" 2>/dev/null
|
||||
}
|
||||
}
|
||||
); then
|
||||
|
|
@ -3314,7 +3411,7 @@ jobs:
|
|||
EVIDENCE_SECTION=''
|
||||
return 0
|
||||
fi
|
||||
local raw_base="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/pr-assets/${dest_dir}"
|
||||
local raw_base="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${assets_branch}/${dest_dir}"
|
||||
EVIDENCE_SECTION=$'### Evidence images\n\n'
|
||||
for f in "$clone_dir/$dest_dir"/*.png; do
|
||||
[ -f "$f" ] || continue
|
||||
|
|
|
|||
44
.github/workflows/web-shell-visuals-cleanup.yml
vendored
44
.github/workflows/web-shell-visuals-cleanup.yml
vendored
|
|
@ -1,9 +1,13 @@
|
|||
name: 'Web-shell Visuals Cleanup'
|
||||
name: 'PR Asset Branch Cleanup'
|
||||
|
||||
# When a PR closes, delete its per-PR visuals asset branch so the `pr-assets/*`
|
||||
# refs (one per PR that ever produced a preview) don't accumulate without bound
|
||||
# in the base repository. Runs in the base context (pull_request_target) but
|
||||
# never checks out or runs PR code — it only deletes one ref by name.
|
||||
# When a PR closes, delete its per-PR asset branches so the `pr-assets/*`
|
||||
# refs (one per PR that ever produced a preview or a verification report)
|
||||
# don't accumulate without bound in the base repository. Runs in the base
|
||||
# context (pull_request_target) but never checks out or runs PR code — it
|
||||
# only deletes refs by name.
|
||||
#
|
||||
# Both producers are covered, and every new `pr-assets/*` producer must be
|
||||
# added here: a branch nothing deletes is permanent.
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
|
|
@ -18,17 +22,29 @@ jobs:
|
|||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: 'Delete the PR asset branch'
|
||||
- name: 'Delete the PR asset branches'
|
||||
env:
|
||||
# Deleting a ref needs contents:write, which the CI_BOT_PAT carries.
|
||||
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
PR_NUMBER: '${{ github.event.pull_request.number }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
branch="pr-assets/web-shell-visuals-${PR_NUMBER}"
|
||||
if gh api "repos/${GITHUB_REPOSITORY}/git/refs/heads/${branch}" >/dev/null 2>&1; then
|
||||
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/heads/${branch}"
|
||||
echo "Deleted ${branch}."
|
||||
else
|
||||
echo "No asset branch ${branch}; nothing to delete."
|
||||
fi
|
||||
set -uo pipefail
|
||||
# Not `set -e`: one branch missing, or one delete failing, must not
|
||||
# stop the others. Each is independent and absence is normal — most
|
||||
# PRs produce neither.
|
||||
status=0
|
||||
for branch in \
|
||||
"pr-assets/web-shell-visuals-${PR_NUMBER}" \
|
||||
"pr-assets/${PR_NUMBER}-verify"; do
|
||||
if ! gh api "repos/${GITHUB_REPOSITORY}/git/refs/heads/${branch}" >/dev/null 2>&1; then
|
||||
echo "No asset branch ${branch}; nothing to delete."
|
||||
continue
|
||||
fi
|
||||
if gh api -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/heads/${branch}"; then
|
||||
echo "Deleted ${branch}."
|
||||
else
|
||||
echo "::warning::Failed to delete ${branch}; it will need removing by hand."
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
exit "$status"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,9 +17,14 @@ Ensure the following dependencies are installed before running:
|
|||
|
||||
```bash
|
||||
npm install # Install project dependencies.
|
||||
npx playwright install chromium # Install Playwright browser
|
||||
npx playwright install chromium # Install Playwright browser (skip in CI: see note below)
|
||||
```
|
||||
|
||||
> **CI / verify context:** when `QWEN_VERIFY_CHROMIUM=1` is set, the browser
|
||||
> is already installed and `PLAYWRIGHT_BROWSERS_PATH` points at it. Do **not**
|
||||
> run `playwright install` — it downloads ~170 MB and fails on system deps
|
||||
> the agent user cannot install.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
|
@ -227,7 +232,8 @@ This tool is commonly used for visual verification during PR reviews.
|
|||
|
||||
- Playwright error `browser not found`
|
||||
Cause: browser not installed.
|
||||
Solution: `npx playwright install chromium`.
|
||||
Solution: `npx playwright install chromium` (local dev only — in CI verify
|
||||
runs, this means the pre-install step failed; report it, do not install).
|
||||
- Blank screenshot
|
||||
Cause: process starts slowly or build failed.
|
||||
Solution: check build success and the spawn command.
|
||||
|
|
|
|||
|
|
@ -30,9 +30,19 @@ The workflow (`qwen-triage.yml` `verify` job) guarantees:
|
|||
- **You may execute PR code freely.** This job is the designated sandbox
|
||||
(container, no credentials) — the opposite of the `/triage` rules. Builds,
|
||||
node processes, loopback servers, and scratch `git worktree`s are all fine.
|
||||
- **Time budget ≈ 20 minutes** of agent time (hard 25-minute kill; install
|
||||
- **Time budget ≈ 110 minutes** of agent time (hard 120-minute kill; install
|
||||
and build happen before your clock starts and do not eat it). Pick scope
|
||||
first (below); when time runs out, ship the report with what ran.
|
||||
This budget is large on purpose. It is enough to bisect a threshold
|
||||
through the real code path, compile an intermediate build to separate the
|
||||
halves of a bundled fix, run a mutation matrix and adjudicate its
|
||||
survivors, or drive a real daemon end to end — the things a maintainer's
|
||||
local round does and a 20-minute round had to skip. Spending it on more
|
||||
breadth instead is the one way to waste it: the rule that one proven
|
||||
load-bearing claim beats ten unverified observations does not relax
|
||||
because the clock did. It is a ceiling, not a target: once the central
|
||||
claim is proven and the report is written, ship. There is no credit for
|
||||
using the clock.
|
||||
- If the directory holding `$QWEN_VERIFY_CONTEXT` contains
|
||||
`previous-report.md`, this is a **follow-up round**. The workflow snapshots
|
||||
the newest _substantive_ report — never a "running"/cancelled/infra
|
||||
|
|
@ -94,6 +104,13 @@ secondary claims. Budget by value:
|
|||
1. **A/B load-bearing proof of the central claim** (always, ~half the budget).
|
||||
2. **One or two wire-oracle harnesses** on the changed surface.
|
||||
3. **Targeted gates**: tests/typecheck of the affected workspace(s) only.
|
||||
4. **Capture the A/B and the matrix as they print** (~5 minutes, whenever
|
||||
`QWEN_VERIFY_CHROMIUM=1`). This is a budget line, not an afterthought:
|
||||
two live runs with the browser installed and working produced **zero**
|
||||
images, because the instruction lived in the artifact contract while the
|
||||
plan the agent follows is this list. Decide here how many captures the
|
||||
round needs — normally two, at most a handful — and reserve the time.
|
||||
See the artifact contract for the mechanics and the naming rule.
|
||||
|
||||
Everything else is explicitly out of scope — and is **listed as not covered**
|
||||
in the report. Never let breadth eat the A/B: one proven load-bearing claim
|
||||
|
|
@ -168,11 +185,105 @@ differs only by the change under test; the verdict is the pair of counts.
|
|||
change — and every residual delta gets accounted for ("the closure is
|
||||
1.3 KB larger: that is the new guards themselves"). An unexplained
|
||||
residue is a finding, not noise.
|
||||
- **Isolate the slice the mechanism can actually affect, then show what
|
||||
fraction of the total it is.** A speedup claim is really two claims: the
|
||||
mechanism works, and the thing it speeds up matters. Add an arm that
|
||||
strips everything the mechanism cannot touch — measured example: an npm
|
||||
download cache was claimed to cut `npm ci` by ~75%; running with
|
||||
`--ignore-scripts` isolated pure download+extract at 36 s cold of a 226 s
|
||||
install, and warming just that slice removed 20 s of it (36 s → 16 s) —
|
||||
the cache's ceiling. End-to-end the install went 226 s → 193 s, a 15%
|
||||
saving rather than the claimed 75%, the rest of the cost being the repo's
|
||||
own `postinstall`/`tsc`/bundler work. Then check that saving against the
|
||||
**whole job budget**: 33 s off a 14 m 37 s job is not the headline the
|
||||
description claimed. A perf PR whose mechanism works but targets 15% of the
|
||||
cost is a finding about the premise, not the code.
|
||||
- **A mechanism that persists something has a cost, not only a benefit —
|
||||
price it.** Caches, artifacts and generated entries consume a shared,
|
||||
bounded resource. Measure what it adds (219 MB per lockfile hash), what
|
||||
the pool holds (9.98 GB of a 10 GB cap), and the churn rate (39 distinct
|
||||
lockfile states in 30 days) — because at the cap every new entry evicts
|
||||
by LRU, including entries other jobs depend on, and possibly its own,
|
||||
degrading the very hit rate the saving assumes.
|
||||
- **Test the scarier consequences and report which ones do NOT hold.** Having
|
||||
found a real problem, the temptation is to report the worst reading of it.
|
||||
Bound it instead: in the cache case the write-path finding was real
|
||||
(a post-step uploads the directory that untrusted code can write), but
|
||||
code injection was **disproved** — tampering with a cached tarball made
|
||||
npm reject it against the lockfile hash and refetch under the flag CI
|
||||
uses, and all 2262 lockfile entries carry an `integrity` hash, so nothing
|
||||
installs unhashed — and privilege escalation was **disproved** —
|
||||
`chown -R` does not follow symlinks. What survived was content and quota
|
||||
abuse. A finding that names what it is _not_ is far harder to wave away
|
||||
than one that implies everything.
|
||||
- When the PR adds a defensive guard or shape check, its unit tests usually
|
||||
mock the reject path — so verify the **accept path against the real
|
||||
artifacts it will see in production** (the shipped chunks, the real
|
||||
module namespaces, the actual wire payloads). A guard that is too strict
|
||||
fails in production on a path no mocked test covers.
|
||||
- **When one fix bundles two changes, build the intermediate variants.** An
|
||||
A/B against base proves the pair works; it says nothing about what each
|
||||
half does or whether both are needed. Compile a third build with one half
|
||||
reverted and put all three in one table. Worked example, on a first-poll
|
||||
drain fix that both replaced `Math.max(...spread)` with `reduce()` and
|
||||
moved `initialized = true` after the fallible work:
|
||||
|
||||
| build | RangeError | prompts dispatched | cursor saved |
|
||||
| ----------------------------- | ---------- | ---------------------- | ------------ |
|
||||
| base (`Math.max`, flag first) | yes | **2,999 and climbing** | none |
|
||||
| flag moved only | yes | 0 | none |
|
||||
| both (head) | no | 0 | saved |
|
||||
|
||||
The ordering change is what converts a backlog flood into a fail-safe
|
||||
retry; `reduce()` is what restores liveness. Either alone leaves a channel
|
||||
that floods or wedges — a conclusion the two-cell A/B cannot reach.
|
||||
|
||||
- **A limit measured in isolation does not transfer to the real call site.**
|
||||
Argument-count caps, stack depth, buffer sizes and timeouts all move with
|
||||
context: the same `Math.max` spread threw between 110k and 130k elements
|
||||
inside a deep async stack, well below what a standalone micro-benchmark
|
||||
suggests. Bisect the threshold **through the real code path**, and quote
|
||||
the harness you bisected with — a limit quoted from documentation or from
|
||||
a toy loop is a guess about the system under test.
|
||||
- **When the same predicate is checked in two places, verify they see the
|
||||
same state.** A guard duplicated across a process boundary — a route and
|
||||
the child it spawns, a parent and a worker, a cache and its source — is
|
||||
two implementations of one question, and they diverge whenever their
|
||||
_inputs_ differ rather than their logic. Find the configuration that makes
|
||||
them disagree and drive it: one measured case had the route ask
|
||||
`sessionExistsInAnyState()` with an unpinned runtime dir while the child
|
||||
asked it with a pinned one, so a single settings key flipped a clean 409
|
||||
into a 500 plus a `process.exit(1)` that killed every session on the
|
||||
channel. Two related questions expose most of this class: does one side
|
||||
observe state the other cannot, and **is the state observable yet at all**
|
||||
— lazily-created backing files (`ensureConversationFile()` writes nothing
|
||||
until the first prompt) leave a window in which a just-created entity is
|
||||
invisible to any existence check that looks on disk.
|
||||
- **Measure the blast radius on bystanders, not just on the caller.** When a
|
||||
failure path can take down shared infrastructure, the interesting number
|
||||
is what happened to everything else: an unrelated session going
|
||||
`200 → 404`, a workspace list going `2 → 0`. Assert on a third party you
|
||||
set up beforehand — the caller's own error code understates a shared-state
|
||||
failure every time.
|
||||
- **Run every control on BOTH arms, not just the arm that needs it.** A
|
||||
control usually exists to validate the probe on one side — "the empty list
|
||||
on base is a real absence, so let the model call the API explicitly and
|
||||
watch an entry appear". Run that same step on head anyway. The single
|
||||
highest-value finding of a real round came from exactly this: the
|
||||
base-side positive control, executed identically on head, showed the
|
||||
curated title being silently discarded. The control was not looking for a
|
||||
bug; running it symmetrically is what found one.
|
||||
- **A new writer into a shared store is an ordering change, not just an
|
||||
addition.** When the PR makes some new path write into a store that
|
||||
already has writers — an artifact list, a cache, a registry, a settings
|
||||
merge — the bug is rarely in the new writer. It is in the _collision_:
|
||||
the store's existing merge policy (first-writer-wins, last-writer-wins,
|
||||
shallow merge) was chosen when only one writer existed, and the PR
|
||||
changes who arrives first. Enumerate the other writers, exercise the
|
||||
collision **in both orders**, and check what the loser is told — a silent
|
||||
no-op that reports success is a finding even when the merge policy itself
|
||||
is pre-existing and correct. Name the pre-existing cause and the PR's
|
||||
contribution separately, so the author is not blamed for the policy.
|
||||
|
||||
### Vacuity check on new/changed tests
|
||||
|
||||
|
|
@ -196,6 +307,67 @@ If deleting the new guard leaves its own new test green, that test is pinned
|
|||
by something else (an earlier early-return, a different branch) and asserts
|
||||
nothing about the change. Name what actually pins it.
|
||||
|
||||
**And the failure one level earlier: the scenario never reached the code
|
||||
under test.** A vacuity check asks whether the assertion can fail; this asks
|
||||
whether the code ever ran. Instrument the seam and count — requests the fake
|
||||
peer actually received, invocations of the function under test, frames
|
||||
rendered — then assert that count is non-zero. Worked example: four abort
|
||||
cases in an E2E suite fired their aborts during **CLI process startup**, so
|
||||
`modelRequestsSeenByFakeServer` was `0` and `messages` empty; a suite named
|
||||
for aborting mid-stream never streamed. Every assertion passed. Fixing the
|
||||
race also restored the coverage the tests were named for
|
||||
(`modelRequestsInFlightAtAbort=1`), which is the tell that the original
|
||||
green meant nothing.
|
||||
|
||||
The mirror of it: **count at the destination, not at the component
|
||||
boundary.** What a component emits and what survives to the end of the
|
||||
pipeline are different numbers, and the gates live in between — "envelopes
|
||||
the adapter emitted" versus "prompts that actually reached the agent" differ
|
||||
by every filter on the path. Assert the number a user would experience; a
|
||||
count taken at the seam can be right while the feature is silently dropped
|
||||
downstream.
|
||||
|
||||
**Timing-triggered assertions have a threshold — measure it, do not sample
|
||||
it.** When an assertion's outcome depends on a wall-clock timer racing an
|
||||
operation whose duration you do not control (`setTimeout(() => abort(), 1000)`
|
||||
against a query bounded by process startup, not by the server), the test
|
||||
encodes a margin nobody has measured. Measure the operation's natural
|
||||
duration directly — run the scenario with the trigger disabled — and compare
|
||||
it to the timer. If the distribution crosses the threshold, the test fails on
|
||||
every machine on the fast side of it. A green run proves only that _this_ box
|
||||
was slow enough.
|
||||
|
||||
This matters most because **a speed-correlated failure is not flake, and a
|
||||
retry budget does not absorb it.** Ordinary flake is random, so `retry: 2`
|
||||
converts it to a pass; a failure driven by machine speed is fully correlated
|
||||
across attempts — measured on a real PR as 5/5 runs failing all three
|
||||
attempts. Before writing off an intermittent failure as flake, establish
|
||||
which kind it is: in local mode, repeat under load and idle, and report the
|
||||
natural durations alongside the outcomes. The two get opposite verdicts —
|
||||
flake is a note, a speed-correlated failure is blocking. Make that blocking
|
||||
verdict expressible in the contract by encoding the margin as a scripted
|
||||
assertion: measure the natural duration N times and assert it stays on the
|
||||
side the test needs (here `min(duration) > timer`, because the test fails on
|
||||
the fast side). A distribution that crosses the threshold then lands in
|
||||
`fail`, and the existing rule (nonzero `fail` ⇒ not `merge-ready`) carries
|
||||
the verdict without a special case.
|
||||
|
||||
Note the CI verify job runs on a **shared, loaded** runner, which is the
|
||||
regime where such a test passes. You cannot reproduce a fast-machine failure
|
||||
here by repetition; you can only compute the margin and say what it implies.
|
||||
|
||||
**Before calling a survivor vacuous, escalate to a finer mutation.** A
|
||||
whole-file revert is a blunt instrument: it can remove the _precondition_ a
|
||||
test depends on, so a perfectly good test goes green because its scenario no
|
||||
longer occurs — indistinguishable, from the outside, from a test that asserts
|
||||
nothing. Worked example: a `finally`-cleanup test survived reverting all four
|
||||
production files, which read as vacuity; deleting the single line
|
||||
(`inFlightSessionIds.delete(...)`) killed it cleanly. It was doing exactly the
|
||||
job it was added for. Coarse mutation survived, fine mutation killed ⇒ the
|
||||
test is fine and the mutation was wrong. Report the finer result, not the
|
||||
coarse one — a false "your test is vacuous" costs the author more than a
|
||||
missed survivor.
|
||||
|
||||
And do not generalize from one dead guard to its siblings. A clause that is
|
||||
unreachable in one call path may be the only thing protecting another —
|
||||
check each on its own evidence and report the contrast, so "this guard is
|
||||
|
|
@ -235,6 +407,24 @@ inconclusive.
|
|||
- Assert **both sides of the wire** where a protocol is involved: what the
|
||||
peer actually received (method, path, headers, exact body, request count)
|
||||
and what the caller observed — plus that stderr stayed clean.
|
||||
- **When the oracle is an instrument, corroborate it with a mechanism that
|
||||
does not use that instrument.** A tool's _report_ about the system is not
|
||||
the system: a cursor query, a profiler number, a coverage percentage can
|
||||
each be wrong in ways your assertion cannot see. Find a second effect of
|
||||
the same physical fact whose failure mode is independent. Worked example:
|
||||
the hardware cursor row was read with
|
||||
`tmux display-message -p '#{cursor_y}'`, then confirmed by letting the TUI
|
||||
exit and printing a marker — anything printed after exit lands wherever the
|
||||
cursor actually was, so the marker's row corroborates the query without
|
||||
trusting it. Two agreeing instruments turn a measurement into evidence.
|
||||
- **To exercise real production data safely, interpose a refusing proxy on
|
||||
the write path.** Read-only claims about a live system are best tested
|
||||
against that system, and the objection is always side effects. Remove it
|
||||
mechanically: wrap the client so every mutating call hard-fails, then run
|
||||
the shipped script verbatim. A workflow verified this way returned real
|
||||
counts (1085 unminimized comments, `rateLimit.cost = 2`) with a guarantee
|
||||
no write could occur — stronger evidence than a fixture and safer than a
|
||||
careful hand. Say in the report which wrapper enforced it.
|
||||
- Every assertion is a scripted comparison that can fail. Keep harnesses as
|
||||
`.mjs` files inside the artifact dir so a maintainer can rerun them.
|
||||
|
||||
|
|
@ -278,6 +468,23 @@ since the merge-base, say so and re-measure there.
|
|||
whether it is a coverage gap or a real defect, and prove which
|
||||
independently rather than by reading the code. Confirm the unmutated
|
||||
control is green, or the kills mean nothing.
|
||||
- **Third-party actions and dependencies**: verify what they do from **their
|
||||
own manifest**, never from the PR's description of them. A change asserted
|
||||
that a cache directory was "ephemeral, discarded after the job"; reading
|
||||
`action.yml` showed `post: 'dist/save/index.js'` with `post-if: success()`
|
||||
— a post-step uploads that directory as root with the Actions credentials
|
||||
intact, which is the opposite of the claim and the whole finding. Also
|
||||
confirm a pinned SHA dereferences to the tag the PR says it does.
|
||||
- **Committed generated artifacts** (a `patch-package` patch, a lockfile, a
|
||||
generated schema or `.d.ts`, a checked-in snapshot): the description
|
||||
usually says it was regenerated with the tool. **Re-run the generator and
|
||||
diff its output against what was committed.** A byte-difference proves the
|
||||
file was hand-edited rather than generated, which is a maintenance hazard
|
||||
even when the content is functionally identical and applies cleanly — the
|
||||
next regeneration will produce a confusing diff. Worked example: re-running
|
||||
`npx patch-package ink` produced hunk headers carrying the function-context
|
||||
suffix that the committed `.d.ts` hunks lacked. Report it at the severity
|
||||
it deserves (usually a nit), and say plainly that the content matched.
|
||||
- **Multi-commit PRs**: verify each commit's claim separately when the
|
||||
commits are reachable. In CI they usually are **not** — the checkout is
|
||||
depth 2, giving only the merge commit, the base tip (`HEAD^1`), and the PR
|
||||
|
|
@ -325,17 +532,33 @@ workflow globs). It must contain:
|
|||
- `assertions.json` — `{"pass": <int>, "fail": <int>, "total": <int>}`,
|
||||
counting **only scripted assertions that actually executed**.
|
||||
- Harness scripts and raw logs (per-cell stdout/stderr, build logs).
|
||||
- Optionally `evidence/*.png` — rendered image evidence. The publish job
|
||||
hosts these on the `pr-assets` branch and appends them below the report,
|
||||
capped at **8 images, 2 MB each**; anything beyond stays in the run
|
||||
artifacts only. Use them when text cannot carry the oracle: TUI rendering
|
||||
(`terminal-capture` skill: node-pty → xterm → Playwright PNG;
|
||||
`npx playwright install chromium` on demand) or a one-image harness
|
||||
summary. Name each file as a kebab-case caption that binds image to claim
|
||||
(`01-bundle-ab-base-vs-head.png`, `02-repaint-after-sigcont.png`) — the
|
||||
filename becomes the published caption — and reference it from report.md
|
||||
prose by that name. Before/after pairs beat single "after" shots; a
|
||||
screenshot that does not name what to look at proves nothing.
|
||||
- `evidence/*.png` — image evidence. **Produce these whenever you ran a
|
||||
harness**, not only for TUI work. A table in the report is your _claim_
|
||||
about what happened; a capture of the run is a _witness_ that the numbers
|
||||
came from a real execution, and it is the part a reviewer cannot get any
|
||||
other way. The highest-value shots, in order: the A/B cells side by side,
|
||||
the mutation matrix as it printed, and the raw harness output behind a
|
||||
headline number. One capture of the terminal showing `2999 → 0` is worth
|
||||
more than the sentence asserting it.
|
||||
|
||||
**Chromium is pre-installed for you** when `QWEN_VERIFY_CHROMIUM=1` is set;
|
||||
`PLAYWRIGHT_BROWSERS_PATH` already points at it. Do **not** run
|
||||
`playwright install` — you run as `node` with a fresh `HOME` and no apt
|
||||
rights, so it downloads ~170 MB and then fails on system deps. If
|
||||
`QWEN_VERIFY_CHROMIUM` is unset the capability is unavailable in this run:
|
||||
ship the text-only report and note it under _Not covered_ in one line, do
|
||||
not spend budget working around it.
|
||||
|
||||
Route: `terminal-capture` skill (node-pty → xterm.js → Playwright PNG).
|
||||
The publish job hosts what you produce on a per-PR branch
|
||||
(`pr-assets/<N>-verify`) and appends it below the report, capped at
|
||||
**8 images, 2 MB each**; anything
|
||||
beyond stays in the run artifacts. Name each file as a kebab-case caption
|
||||
that binds image to claim (`01-bundle-ab-base-vs-head.png`,
|
||||
`02-repaint-after-sigcont.png`) — the filename becomes the published
|
||||
caption — and reference it from report.md prose by that name. Before/after
|
||||
pairs beat single "after" shots; a screenshot that does not name what to
|
||||
look at proves nothing.
|
||||
|
||||
`verdict.txt` meanings: `merge-ready` = every executed assertion passed and no
|
||||
new blocking finding; `findings` = evidence produced concrete problems worth a
|
||||
|
|
@ -356,6 +579,8 @@ central claim from being tested — say why.
|
|||
them. Cite the tables below by name instead of restating their numbers in
|
||||
prose: a number written twice is a number that can disagree with itself.
|
||||
3. **Central claim + A/B table** (cells, oracles, head vs control counts).
|
||||
Reference the capture of those cells here by its filename — a table with
|
||||
no witness beside it is the shape every report has had so far.
|
||||
4. **Corrections**, when an earlier review round or bot comment described
|
||||
the code inaccurately (a wrong ARIA role, a wrong mechanism, a
|
||||
misattributed cause). State the correct fact with its evidence and label
|
||||
|
|
@ -369,7 +594,13 @@ central claim from being tested — say why.
|
|||
collapsed minimal suggested fix that preserves the original commit's
|
||||
intent.
|
||||
6. **Not covered** — every claim, surface, or gate you skipped. A silent cap
|
||||
reads as "covered everything"; never allow that.
|
||||
reads as "covered everything"; never allow that. When something failed to
|
||||
run rather than being skipped by choice, **prove it was environmental
|
||||
before saying so**: boot the identical thing on base and on head and show
|
||||
both fail the same way (an A/A control). "The dev harness renders blank —
|
||||
base and head both blank, so this is my sandbox, not a regression" is a
|
||||
claim a reader can check; "seems environmental" is not, and the two look
|
||||
identical in a report.
|
||||
7. **Methodology** — one paragraph: environment, how each harness drove the
|
||||
code, where the raw logs live.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Web Shell AskUserQuestion Submit Retry
|
||||
|
||||
## Problem
|
||||
|
||||
`AskUserQuestion` locks immediately after a decision is clicked, but its
|
||||
callback does not expose the asynchronous permission result. A failed request
|
||||
therefore leaves an apparently enabled panel that silently ignores retries.
|
||||
The submit path also silently returns when the permission payload has no
|
||||
`allow_once` option.
|
||||
|
||||
## Design
|
||||
|
||||
- Give `AskUserQuestion` a promise-returning confirmation callback and an error
|
||||
reporter supplied by its owning chat surface.
|
||||
- While the request is in flight, disable the actions and show a submitting
|
||||
indicator.
|
||||
- Keep a successfully accepted decision locked while the permission event
|
||||
removes the panel. This also covers consensus votes that were recorded but
|
||||
are not final yet.
|
||||
- On rejection or a `false` result, report the error and unlock the actions so
|
||||
the user can retry.
|
||||
- Report a missing `allow_once` option immediately instead of returning
|
||||
silently.
|
||||
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.
|
||||
35
docs/design/web-shell-prompt-send-failure-retry.md
Normal file
35
docs/design/web-shell-prompt-send-failure-retry.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Web Shell Prompt Send Failure Retry
|
||||
|
||||
## Goal
|
||||
|
||||
Make a prompt transport failure visible on the user message that failed to
|
||||
send, and let the user retry it without creating a duplicate message.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Only failures before daemon admission are shown as send failures. Errors
|
||||
after admission continue to use the existing turn-error retry UI.
|
||||
- The failed user message shows an inline "Failed to send" label and a retry
|
||||
icon directly below its bubble.
|
||||
- Retrying sends the original text, images, and input annotations without
|
||||
appending another optimistic user message.
|
||||
- The failure indicator is removed as soon as the retry starts.
|
||||
- The global processing indicator stays hidden until the daemon admits the
|
||||
retry, then starts timing from the retry attempt rather than the original
|
||||
failed message.
|
||||
- If an admitted retry later becomes a turn error, retrying from the existing
|
||||
turn-error UI also starts a new timer for that retry attempt.
|
||||
- If the retry also fails before admission, the indicator returns.
|
||||
- As soon as a newer user message is sent, the previous message no longer
|
||||
offers retry.
|
||||
- Session changes or transcript reconciliation remove failure state whose
|
||||
message is no longer present.
|
||||
- The first prompt in a lazily created session uses the allocated session ID
|
||||
even before the connection render catches up.
|
||||
|
||||
## Scope
|
||||
|
||||
The state remains local to the Web Shell UI and is keyed by both the owning
|
||||
session and the exact optimistic user message ID captured when it is appended.
|
||||
Failures settling after a session change are ignored. The daemon transcript
|
||||
schema and SDK store remain unchanged.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ Add the channel to `~/.qwen/settings.json`:
|
|||
"allowedUsers": ["operator-github-username"],
|
||||
"sessionScope": "chat_thread",
|
||||
"cwd": "/path/to/your/project",
|
||||
"blockStreaming": "off",
|
||||
"groupPolicy": "open",
|
||||
"groups": {
|
||||
"*": { "requireMention": true }
|
||||
|
|
@ -66,15 +67,16 @@ For GitHub Enterprise Server, set `baseUrl`:
|
|||
|
||||
## Configuration Options
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------------- | ------------------------ | -------------------------------------------------------------------------------- |
|
||||
| `token` | (required) | Classic PAT with `notifications` scope |
|
||||
| `pollInterval` | `60000` | Poll interval in ms |
|
||||
| `baseUrl` | `https://api.github.com` | API base URL (for GHE) |
|
||||
| `groupPolicy` | `"disabled"` | Must be `"open"` for notifications to flow |
|
||||
| `senderPolicy` | `"allowlist"` | Who can trigger the bot |
|
||||
| `groups.*.requireMention` | `true` | Require @mentions for ordinary comments; directed notification reasons still run |
|
||||
| `reasonFilter` | unset | Optional allowlist of GitHub notification reasons to process |
|
||||
| Option | Default | Description |
|
||||
| ------------------------- | ------------------------ | --------------------------------------------------------------------------------------------- |
|
||||
| `token` | (required) | Classic PAT with `notifications` scope |
|
||||
| `pollInterval` | `60000` | Poll interval in ms |
|
||||
| `baseUrl` | `https://api.github.com` | API base URL (for GHE) |
|
||||
| `groupPolicy` | `"disabled"` | Must be `"open"` for notifications to flow |
|
||||
| `senderPolicy` | `"allowlist"` | Who can trigger the bot |
|
||||
| `groups.*.requireMention` | `true` | Require @mentions for ordinary comments; directed notification reasons still run |
|
||||
| `blockStreaming` | `"off"` | Always forced to `"off"`; intermediate model chunks aren't published; `"on"` is not supported |
|
||||
| `reasonFilter` | unset | Optional allowlist of GitHub notification reasons to process |
|
||||
|
||||
Use `reasonFilter` to drop noisy notification classes such as `ci_activity` or `state_change`. Do not use `reasonFilter: ["mention"]` as a replacement for `groups.*.requireMention`: GitHub's `mention` reason is sticky at the thread level, so real new @mentions can arrive later under `comment`, `subscribed`, `author`, or other reasons and would be skipped.
|
||||
|
||||
|
|
@ -108,6 +110,20 @@ The comment window is `(previousCursor, currentMaxUpdatedAt]` — comments alrea
|
|||
|
||||
Non-comment activity (push, label changes) bumps the notification's `updated_at` but produces zero new comments in the window, so re-fetched threads are skipped without triggering the agent.
|
||||
|
||||
## Response Feedback
|
||||
|
||||
For an accepted issue or pull-request comment, the channel adds GitHub's `👀` reaction while the agent is working, then removes it when the run completes, fails, or is cancelled. Both operations are best-effort: a reaction API or permission failure is logged and never prevents the final response.
|
||||
|
||||
### Final-only output
|
||||
|
||||
The GitHub channel always forces final-only delivery. The adapter sets `blockStreaming` to `"off"`, so intermediate model chunks are never published as separate comments and `blockStreaming: "on"` is not supported.
|
||||
|
||||
```json
|
||||
{
|
||||
"blockStreaming": "off"
|
||||
}
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **First start skips existing unread notifications.** The cursor initializes to "now" on first launch. Notifications created before the bot starts are not processed unless the thread receives new activity afterwards.
|
||||
|
|
|
|||
|
|
@ -277,6 +277,26 @@ The deterministic halves of the pipeline — argument parsing (`qwen review pars
|
|||
|
||||
Every run ends with one machine-readable line (`Review complete: <target> — <disposition>`), so scripts and CI wrappers can detect completion and outcome with a single `^Review complete: ` match.
|
||||
|
||||
## Headless runs (`qwen review run`)
|
||||
|
||||
`/review` is interactive. When a script or CI job needs to run a review and act on its outcome, use the headless wrapper:
|
||||
|
||||
```bash
|
||||
qwen review run [target] [--json] [--fail-on request-changes] [--comment] [--quiet]
|
||||
```
|
||||
|
||||
`target` is a PR number, a PR URL, or a file path; omit it to review the local working tree. The command runs this build's own CLI non-interactively (with stdin closed, so slash-command detection survives), streams the child's progress to **stderr**, and prints the verdict to **stdout** — or, with `--json`, the full result object. The verdict is read from the artifact `compose-review` writes (the same JSON the skill treats as the verdict authority), never parsed from the model's prose.
|
||||
|
||||
The exit code is the contract a gate should read:
|
||||
|
||||
| Exit | Meaning |
|
||||
| ---- | ------------------------------------------------------------------------------------------------- |
|
||||
| `0` | The review completed (whatever it decided) |
|
||||
| `1` | It never reached a verdict — the child failed, timed out, or left no composed artifact |
|
||||
| `3` | It completed with `REQUEST_CHANGES` **and** `--fail-on request-changes` was set (opt-in blocking) |
|
||||
|
||||
`3` (not `2`) lets a gate distinguish "the review is blocking" from "the tool broke" — yargs already uses `1` for usage errors — without parsing any output. `--timeout-minutes` (default 120, floored at 1) terminates a hung review and exits `1`, and cancelling the command (Ctrl+C / SIGTERM) terminates the review's process group rather than orphaning it.
|
||||
|
||||
## Cross-file Impact Analysis
|
||||
|
||||
A dedicated cross-file tracer (Agent 1c) owns this walk end-to-end. When code changes modify exported functions, classes, or interfaces, it searches for all callers and checks compatibility:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -5,16 +5,33 @@
|
|||
*/
|
||||
|
||||
import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, type, printDebugInfo } from '../test-helper.js';
|
||||
import {
|
||||
startFakeOpenAIServer,
|
||||
fakeToolCall,
|
||||
type FakeOpenAIServer,
|
||||
} from '../fake-openai-server.js';
|
||||
import {
|
||||
TestRig,
|
||||
type,
|
||||
printDebugInfo,
|
||||
applyContainerSandboxNoProxy,
|
||||
fakeServerHostOptions,
|
||||
} from '../test-helper.js';
|
||||
|
||||
describe('Interactive file system', () => {
|
||||
let rig: TestRig;
|
||||
let fakeServer: FakeOpenAIServer | undefined;
|
||||
let restoreNoProxy: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
restoreNoProxy = applyContainerSandboxNoProxy();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fakeServer?.close();
|
||||
fakeServer = undefined;
|
||||
restoreNoProxy();
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
|
|
@ -22,8 +39,46 @@ describe('Interactive file system', () => {
|
|||
'should perform a read-then-write sequence in interactive mode',
|
||||
async () => {
|
||||
const fileName = 'version.txt';
|
||||
|
||||
// Drive the conversation with a deterministic fake model instead of a
|
||||
// live LLM. A real model made this multi-turn test flaky: the cited CI
|
||||
// failure was the second turn stalling mid-stream until the poll timed
|
||||
// out, and a live model can also pick a different tool or phrase the
|
||||
// read result without the literal version. The fake model scripts the
|
||||
// exact read-then-write turn sequence so the interactive mechanics
|
||||
// (typed input, tool execution, file mutation) are what get tested.
|
||||
let filePath = '';
|
||||
fakeServer = await startFakeOpenAIServer(({ requestIndex }) => {
|
||||
if (requestIndex === 0) {
|
||||
return {
|
||||
toolCalls: [fakeToolCall('read_file', { file_path: filePath })],
|
||||
};
|
||||
}
|
||||
if (requestIndex === 1) {
|
||||
return { content: 'The current version is 1.0.0.' };
|
||||
}
|
||||
if (requestIndex === 2) {
|
||||
return {
|
||||
toolCalls: [
|
||||
fakeToolCall('write_file', {
|
||||
file_path: filePath,
|
||||
content: '1.0.1',
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: 'Done. The version is now 1.0.1.' };
|
||||
}, fakeServerHostOptions());
|
||||
|
||||
await rig.setup('interactive-read-then-write', {
|
||||
settings: {
|
||||
memory: {
|
||||
enableManagedAutoMemory: false,
|
||||
enableManagedAutoDream: false,
|
||||
},
|
||||
ui: {
|
||||
enableFollowupSuggestions: false,
|
||||
},
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: 'openai',
|
||||
|
|
@ -31,62 +86,95 @@ describe('Interactive file system', () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
rig.createFile(fileName, '1.0.0');
|
||||
filePath = rig.createFile(fileName, '1.0.0');
|
||||
|
||||
const { ptyProcess } = rig.runInteractive();
|
||||
const { ptyProcess, promise } = rig.runInteractive(
|
||||
'--auth-type',
|
||||
'openai',
|
||||
'--openai-api-key',
|
||||
'fake-key',
|
||||
'--openai-base-url',
|
||||
fakeServer.baseUrl,
|
||||
'--model',
|
||||
'fake-model',
|
||||
);
|
||||
|
||||
// Wait for the app to be ready
|
||||
const isReady = await rig.waitForText('Type your message');
|
||||
expect(
|
||||
isReady,
|
||||
'CLI did not start up in interactive mode correctly',
|
||||
).toBe(true);
|
||||
try {
|
||||
// Wait for the app to be ready
|
||||
const isReady = await rig.waitForText('Type your message');
|
||||
expect(
|
||||
isReady,
|
||||
'CLI did not start up in interactive mode correctly',
|
||||
).toBe(true);
|
||||
|
||||
// Step 1: Read the file
|
||||
const readPrompt = `Read the version from ${fileName}`;
|
||||
await type(ptyProcess, readPrompt);
|
||||
await type(ptyProcess, '\r');
|
||||
// Step 1: Read the file
|
||||
const readPrompt = `Read the version from ${fileName}`;
|
||||
await type(ptyProcess, readPrompt);
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
const readCall = await rig.waitForToolCall('read_file');
|
||||
expect(readCall, 'Expected to find a read_file tool call').toBe(true);
|
||||
const readCall = await rig.waitForToolCall('read_file');
|
||||
if (!readCall) {
|
||||
printDebugInfo(rig, rig._interactiveOutput, { readCall });
|
||||
}
|
||||
expect(readCall, 'Expected to find a read_file tool call').toBe(true);
|
||||
|
||||
const containsExpectedVersion = await rig.waitForText('1.0.0');
|
||||
expect(
|
||||
containsExpectedVersion,
|
||||
'Expected to see version "1.0.0" in output',
|
||||
).toBe(true);
|
||||
// The interactive UI renders a successful read_file as a one-line
|
||||
// summary, not its content, so the rendered '1.0.0' matches the fake
|
||||
// model's scripted echo (requestIndex 1), not the read result. Assert
|
||||
// the read result directly via the tool result the CLI sent back in
|
||||
// the next request, so this still observes the real file content; the
|
||||
// write-side poll below grounds the test in the real filesystem.
|
||||
const containsExpectedVersion = await rig.waitForText('1.0.0');
|
||||
if (!containsExpectedVersion) {
|
||||
printDebugInfo(rig, rig._interactiveOutput, {
|
||||
containsExpectedVersion,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
containsExpectedVersion,
|
||||
'Expected to see version "1.0.0" in output',
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(fakeServer.requests[1]!.body)).toContain('1.0.0');
|
||||
|
||||
// Step 2: Write the file
|
||||
const writePrompt = `now change the version to 1.0.1 in the file`;
|
||||
await type(ptyProcess, writePrompt);
|
||||
await type(ptyProcess, '\r');
|
||||
// Step 2: Write the file
|
||||
const writePrompt = `now change the version to 1.0.1 in the file`;
|
||||
await type(ptyProcess, writePrompt);
|
||||
await type(ptyProcess, '\r');
|
||||
|
||||
const toolCall = await rig.waitForAnyToolCall(['write_file', 'edit']);
|
||||
const toolCall = await rig.waitForAnyToolCall(['write_file', 'edit']);
|
||||
|
||||
if (!toolCall) {
|
||||
printDebugInfo(rig, rig._interactiveOutput, {
|
||||
if (!toolCall) {
|
||||
printDebugInfo(rig, rig._interactiveOutput, {
|
||||
toolCall,
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
toolCall,
|
||||
});
|
||||
}
|
||||
'Expected to find a write_file or edit tool call',
|
||||
).toBe(true);
|
||||
|
||||
expect(toolCall, 'Expected to find a write_file or edit tool call').toBe(
|
||||
true,
|
||||
);
|
||||
const updated = await rig.poll(
|
||||
() => rig.readFile(fileName).includes('1.0.1'),
|
||||
rig.getDefaultTimeout(),
|
||||
200,
|
||||
);
|
||||
if (!updated) {
|
||||
printDebugInfo(rig, rig._interactiveOutput, { toolCall });
|
||||
}
|
||||
expect(updated, 'Expected file content to contain 1.0.1').toBe(true);
|
||||
|
||||
// The tool call is logged once the model issues it, but the turn may
|
||||
// still be settling (a failed edit can be retried) and the model may
|
||||
// write more than just '1.0.1'. Poll the file until it contains the new
|
||||
// version, matching the lenient assertion used by the non-interactive
|
||||
// sibling test (file-system.test.ts uses .toContain('1.0.1')).
|
||||
const updated = await rig.poll(
|
||||
() => rig.readFile(fileName).includes('1.0.1'),
|
||||
rig.getDefaultTimeout(),
|
||||
200,
|
||||
);
|
||||
if (!updated) {
|
||||
printDebugInfo(rig, rig._interactiveOutput, { toolCall });
|
||||
// The file is mutated before the final model turn (requestIndex 3)
|
||||
// completes, so wait for that turn's rendered text before counting
|
||||
// requests. The exact count catches a spurious extra request that
|
||||
// would otherwise silently shift the requestIndex-based dispatch.
|
||||
const done = await rig.waitForText('Done. The version is now 1.0.1.');
|
||||
expect(done, 'Expected final assistant message to render').toBe(true);
|
||||
expect(fakeServer.requests).toHaveLength(4);
|
||||
} finally {
|
||||
ptyProcess.kill();
|
||||
await promise;
|
||||
}
|
||||
expect(updated, 'Expected file content to contain 1.0.1').toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ describe('Subagents (E2E)', () => {
|
|||
name: 'file-reader',
|
||||
description: 'Reads a requested file and reports its exact contents.',
|
||||
systemPrompt:
|
||||
'Use read_file to read the requested file, then report its exact contents.',
|
||||
'Use the read_file tool to read the requested file, then report its exact contents. Never answer from memory.',
|
||||
level: 'session',
|
||||
tools: ['read_file'],
|
||||
};
|
||||
|
|
@ -258,6 +258,7 @@ describe('Subagents (E2E)', () => {
|
|||
const testFile = helper.getPath('test.txt');
|
||||
const q = query({
|
||||
prompt:
|
||||
`Do not read the file yourself; you must delegate this task. ` +
|
||||
`Use the agent tool to ask the file-reader subagent to read ${testFile}. ` +
|
||||
`Return the file contents reported by the subagent.`,
|
||||
options: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -1046,6 +1048,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 () => ({
|
||||
|
|
@ -2018,14 +2038,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 {};
|
||||
|
|
@ -2060,6 +2082,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 },
|
||||
|
|
@ -10827,6 +10856,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();
|
||||
|
|
@ -11519,6 +11645,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');
|
||||
|
|
@ -11528,6 +11663,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,12 +103,14 @@ 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,
|
||||
LOAD_REPLAY_VERSION,
|
||||
PROMPT_CANCEL_METHOD,
|
||||
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
|
||||
WORKTREE_MCP_DEFER_META_KEY,
|
||||
} from './bridgeTypes.js';
|
||||
import { getChannelStartupProfileAttributes } from './channel-startup-profile.js';
|
||||
import type {
|
||||
|
|
@ -470,6 +472,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`).
|
||||
|
|
@ -493,6 +496,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
|
||||
|
|
@ -1913,7 +1918,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;
|
||||
|
|
@ -2031,6 +2040,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
|
||||
|
|
@ -2645,17 +2655,24 @@ 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: [],
|
||||
...(sourceType
|
||||
? { _meta: sessionSourceRequestMeta(sourceType, sourceId) }
|
||||
: {}),
|
||||
});
|
||||
const response = await withTimeout(
|
||||
ci.connection.newSession(
|
||||
telemetry.injectPromptContext({
|
||||
cwd: boundWorkspace,
|
||||
mcpServers: [],
|
||||
...(sourceType
|
||||
? {
|
||||
_meta: sessionSourceRequestMeta(sourceType, sourceId),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
worktree
|
||||
? {
|
||||
...request,
|
||||
_meta: {
|
||||
...(isRecord(request._meta) ? request._meta : {}),
|
||||
[WORKTREE_MCP_DEFER_META_KEY]: true,
|
||||
},
|
||||
}
|
||||
: request,
|
||||
),
|
||||
initTimeoutMs,
|
||||
'newSession',
|
||||
|
|
@ -3879,6 +3896,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
const entry: SessionEntry = {
|
||||
sessionId,
|
||||
workspaceCwd,
|
||||
effectiveCwd: workspaceCwd,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(options.parentSessionId
|
||||
? { parentSessionId: options.parentSessionId }
|
||||
|
|
@ -3897,6 +3915,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
}),
|
||||
recordingDegraded: false,
|
||||
closing: false,
|
||||
cwdChangeQueue: Promise.resolve(),
|
||||
promptQueue: Promise.resolve(),
|
||||
pendingPromptCount: 0,
|
||||
pendingPromptList: [],
|
||||
|
|
@ -4391,6 +4410,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) {
|
||||
|
|
@ -4452,7 +4473,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,
|
||||
|
|
@ -4584,7 +4606,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(
|
||||
|
|
@ -4603,6 +4627,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(hideInheritedHistory
|
||||
? {
|
||||
[LOAD_REPLAY_HIDE_INHERITED_META_KEY]: true,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
@ -4846,6 +4875,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
|
|||
inFlightRestores.set(req.sessionId, {
|
||||
action,
|
||||
historyReplay,
|
||||
hideInheritedHistory,
|
||||
promise,
|
||||
coalesceState,
|
||||
});
|
||||
|
|
@ -6226,14 +6256,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);
|
||||
}
|
||||
|
||||
|
|
@ -6258,13 +6296,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') {
|
||||
|
|
@ -6280,12 +6323,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,
|
||||
|
|
@ -6311,20 +6362,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,
|
||||
|
|
@ -6333,18 +6414,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,
|
||||
|
|
@ -6394,6 +6496,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',
|
||||
|
|
@ -6415,6 +6518,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.
|
||||
|
|
@ -7104,52 +7211,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,
|
||||
|
|
@ -7773,7 +7928,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',
|
||||
|
|
|
|||
|
|
@ -147,6 +147,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
|
||||
|
|
@ -165,12 +167,15 @@ 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;
|
||||
|
||||
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;
|
||||
|
|
@ -284,6 +289,9 @@ export interface BridgeSessionTranscriptPage {
|
|||
|
||||
export interface BridgeBranchSessionRequest {
|
||||
name?: string;
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
replayInheritedHistory?: boolean;
|
||||
}
|
||||
|
||||
export interface BridgeBranchedSession extends BridgeRestoredSession {
|
||||
|
|
@ -291,6 +299,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;
|
||||
|
|
@ -875,6 +892,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: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -25,6 +33,10 @@ vi.mock('@octokit/rest', () => {
|
|||
createComment: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
reactions: {
|
||||
createForIssueComment: vi.fn(),
|
||||
deleteForIssueComment: vi.fn(),
|
||||
},
|
||||
pulls: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
|
|
@ -66,6 +78,10 @@ const mockOctokit = (
|
|||
createComment: ReturnType<typeof vi.fn>;
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
reactions: {
|
||||
createForIssueComment: Mock;
|
||||
deleteForIssueComment: Mock;
|
||||
};
|
||||
pulls: {
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
|
@ -182,6 +198,28 @@ class TestableGithubChannel extends GithubChannel {
|
|||
}
|
||||
}
|
||||
|
||||
class LiveGithubChannel extends GithubChannel {
|
||||
setCursorForTest(lastProcessedAt: string): void {
|
||||
this.cursor = { lastProcessedAt };
|
||||
}
|
||||
|
||||
async pollForTest(): Promise<void> {
|
||||
await this.pollOnce();
|
||||
}
|
||||
|
||||
startPromptForTest(
|
||||
chatId: string,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
): void {
|
||||
this.onPromptStart(chatId, sessionId, messageId);
|
||||
}
|
||||
|
||||
endPromptForTest(chatId: string, sessionId: string, messageId: string): void {
|
||||
this.onPromptEnd(chatId, sessionId, messageId);
|
||||
}
|
||||
}
|
||||
|
||||
describe('GithubChannel', () => {
|
||||
let channel: TestableGithubChannel;
|
||||
let savedQwenHome: string | undefined;
|
||||
|
|
@ -200,6 +238,10 @@ describe('GithubChannel', () => {
|
|||
});
|
||||
mockOctokit.rest.activity.markNotificationsAsRead.mockResolvedValue({});
|
||||
mockOctokit.rest.issues.createComment.mockResolvedValue({});
|
||||
mockOctokit.rest.reactions.createForIssueComment.mockResolvedValue({
|
||||
data: { id: 9000 },
|
||||
});
|
||||
mockOctokit.rest.reactions.deleteForIssueComment.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -770,7 +812,7 @@ describe('GithubChannel', () => {
|
|||
|
||||
expect(
|
||||
channel.inboundEnvelopes.map((envelope) => envelope.messageId),
|
||||
).toEqual(['2001', '2002']);
|
||||
).toEqual(['event-2001', 'event-2002']);
|
||||
expect(channel.cursor.dispatchedEvents).toEqual(['E_2001', 'E_2002']);
|
||||
});
|
||||
|
||||
|
|
@ -1937,6 +1979,216 @@ describe('GithubChannel', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('working reaction', () => {
|
||||
it('acknowledges an accepted comment with an eyes reaction', async () => {
|
||||
const liveChannel = new LiveGithubChannel(
|
||||
'test-github',
|
||||
makeConfig(),
|
||||
makeBridge(),
|
||||
);
|
||||
mockOctokit.paginate.mockResolvedValueOnce([]);
|
||||
await liveChannel.connect();
|
||||
liveChannel.disconnect();
|
||||
liveChannel.setCursorForTest('2026-07-01T00:00:00.000Z');
|
||||
mockOctokit.paginate
|
||||
.mockResolvedValueOnce([makeNotification()])
|
||||
.mockResolvedValueOnce([makeComment()]);
|
||||
|
||||
await liveChannel.pollForTest();
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.reactions.createForIssueComment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
comment_id: 1001,
|
||||
content: 'eyes',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not wait for the acknowledgment before replying', async () => {
|
||||
const { promise: reactionPending, resolve: resolveReaction } =
|
||||
Promise.withResolvers<{ data: { id: number } }>();
|
||||
mockOctokit.rest.reactions.createForIssueComment.mockReturnValue(
|
||||
reactionPending,
|
||||
);
|
||||
const liveChannel = new LiveGithubChannel(
|
||||
'test-github',
|
||||
makeConfig(),
|
||||
makeBridge(),
|
||||
);
|
||||
mockOctokit.paginate.mockResolvedValueOnce([]);
|
||||
await liveChannel.connect();
|
||||
liveChannel.disconnect();
|
||||
liveChannel.setCursorForTest('2026-07-01T00:00:00.000Z');
|
||||
mockOctokit.paginate
|
||||
.mockResolvedValueOnce([makeNotification()])
|
||||
.mockResolvedValueOnce([makeComment()]);
|
||||
|
||||
await liveChannel.pollForTest();
|
||||
|
||||
expect(mockOctokit.rest.issues.createComment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: 'response' }),
|
||||
);
|
||||
resolveReaction({ data: { id: 9000 } });
|
||||
await reactionPending;
|
||||
});
|
||||
|
||||
it('does not create a duplicate reaction while one is pending', async () => {
|
||||
const { promise: reactionPending, resolve: resolveReaction } =
|
||||
Promise.withResolvers<{ data: { id: number } }>();
|
||||
mockOctokit.rest.reactions.createForIssueComment.mockReturnValue(
|
||||
reactionPending,
|
||||
);
|
||||
const liveChannel = new LiveGithubChannel(
|
||||
'test-github',
|
||||
makeConfig(),
|
||||
makeBridge(),
|
||||
);
|
||||
await liveChannel.connect();
|
||||
liveChannel.disconnect();
|
||||
|
||||
liveChannel.startPromptForTest('owner/repo', 'session-1', '1001');
|
||||
liveChannel.startPromptForTest('owner/repo', 'session-2', '1001');
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.reactions.createForIssueComment,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
resolveReaction({ data: { id: 9000 } });
|
||||
await reactionPending;
|
||||
});
|
||||
|
||||
it('removes the working reaction when the prompt finishes', async () => {
|
||||
const { promise: reactionPending, resolve: resolveReaction } =
|
||||
Promise.withResolvers<{ data: { id: number } }>();
|
||||
mockOctokit.rest.reactions.createForIssueComment.mockReturnValue(
|
||||
reactionPending,
|
||||
);
|
||||
const liveChannel = new LiveGithubChannel(
|
||||
'test-github',
|
||||
makeConfig(),
|
||||
makeBridge(),
|
||||
);
|
||||
await liveChannel.connect();
|
||||
liveChannel.disconnect();
|
||||
|
||||
liveChannel.startPromptForTest('owner/repo', 'session-1', '1001');
|
||||
liveChannel.endPromptForTest('owner/repo', 'session-1', '1001');
|
||||
expect(
|
||||
mockOctokit.rest.reactions.deleteForIssueComment,
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
resolveReaction({ data: { id: 9001 } });
|
||||
await reactionPending;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.reactions.deleteForIssueComment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
comment_id: 1001,
|
||||
reaction_id: 9001,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles direct working reaction removal failures', async () => {
|
||||
mockOctokit.rest.reactions.deleteForIssueComment.mockRejectedValue(
|
||||
new Error('403'),
|
||||
);
|
||||
const liveChannel = new LiveGithubChannel(
|
||||
'test-github',
|
||||
makeConfig(),
|
||||
makeBridge(),
|
||||
);
|
||||
await liveChannel.connect();
|
||||
liveChannel.disconnect();
|
||||
liveChannel.startPromptForTest('owner/repo', 'session-1', '1001');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
liveChannel.endPromptForTest('owner/repo', 'session-1', '1001');
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
mockOctokit.rest.reactions.deleteForIssueComment,
|
||||
).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
expect(
|
||||
mockOctokit.rest.reactions.deleteForIssueComment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
comment_id: 1001,
|
||||
reaction_id: 9000,
|
||||
});
|
||||
});
|
||||
|
||||
it('retries acknowledgement after a create failure', async () => {
|
||||
const error = new Error('403');
|
||||
mockOctokit.rest.reactions.createForIssueComment
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValue({ data: { id: 9002 } });
|
||||
const liveChannel = new LiveGithubChannel(
|
||||
'test-github',
|
||||
makeConfig(),
|
||||
makeBridge(),
|
||||
);
|
||||
await liveChannel.connect();
|
||||
liveChannel.disconnect();
|
||||
liveChannel.startPromptForTest('owner/repo', 'session-1', '1001');
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
mockOctokit.rest.reactions.createForIssueComment,
|
||||
).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
liveChannel.startPromptForTest('owner/repo', 'session-2', '1001');
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
mockOctokit.rest.reactions.createForIssueComment,
|
||||
).toHaveBeenCalledTimes(4),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not react to a synthetic direct review-request trigger', async () => {
|
||||
const liveChannel = new LiveGithubChannel(
|
||||
'test-github',
|
||||
makeConfig(),
|
||||
makeBridge(),
|
||||
);
|
||||
mockOctokit.paginate.mockResolvedValueOnce([]);
|
||||
await liveChannel.connect();
|
||||
liveChannel.disconnect();
|
||||
liveChannel.setCursorForTest('2026-07-01T00:00:00.000Z');
|
||||
mockOctokit.paginate
|
||||
.mockResolvedValueOnce([
|
||||
makeNotification({
|
||||
reason: 'review_requested',
|
||||
subject: {
|
||||
title: 'Review me',
|
||||
url: 'https://api.github.com/repos/owner/repo/pulls/42',
|
||||
type: 'PullRequest',
|
||||
},
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([makeIssueEvent()])
|
||||
.mockResolvedValueOnce([]);
|
||||
mockOctokit.rest.pulls.get.mockResolvedValue({
|
||||
data: { title: 'Review me', user: { login: 'alice' } },
|
||||
});
|
||||
|
||||
await liveChannel.pollForTest();
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.reactions.createForIssueComment,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendThreadMessage', () => {
|
||||
it('posts comment on the correct issue', async () => {
|
||||
mockOctokit.paginate.mockResolvedValue([]);
|
||||
|
|
|
|||
|
|
@ -103,6 +103,13 @@ interface NotificationContext {
|
|||
reason: string;
|
||||
}
|
||||
|
||||
interface WorkingReaction {
|
||||
owner: string;
|
||||
repo: string;
|
||||
commentId: number;
|
||||
reactionId?: number;
|
||||
}
|
||||
|
||||
function normalizeReasonFilter(
|
||||
config: GithubConfig,
|
||||
channelName: string,
|
||||
|
|
@ -204,6 +211,8 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
|
|||
private octokit!: Octokit;
|
||||
private botUsername: string | null = null;
|
||||
private webOrigin = 'https://github.com';
|
||||
private readonly activeReactions = new Map<string, WorkingReaction>();
|
||||
private readonly reactionsPendingRemoval = new Set<string>();
|
||||
private reasonFilter: Set<string> | null = null;
|
||||
|
||||
constructor(
|
||||
|
|
@ -438,6 +447,89 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds GitHub's eyes reaction to accepted comment prompts, then removes it
|
||||
* when the prompt ends. Both operations are best-effort and never block the
|
||||
* agent response.
|
||||
*/
|
||||
protected override onPromptStart(
|
||||
chatId: string,
|
||||
_sessionId: string,
|
||||
messageId?: string,
|
||||
): void {
|
||||
if (!messageId || !/^\d+$/.test(messageId)) return;
|
||||
const [owner, repo] = chatId.split('/');
|
||||
if (!owner || !repo) return;
|
||||
const commentId = Number(messageId);
|
||||
const key = this.reactionKey(chatId, commentId);
|
||||
if (this.activeReactions.has(key)) return;
|
||||
const reaction: WorkingReaction = { owner, repo, commentId };
|
||||
this.activeReactions.set(key, reaction);
|
||||
void this.githubApi(
|
||||
() =>
|
||||
this.octokit.rest.reactions.createForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
content: 'eyes',
|
||||
}),
|
||||
`acknowledgeComment(${messageId})`,
|
||||
)
|
||||
.then(({ data }) => {
|
||||
reaction.reactionId = data.id;
|
||||
if (this.reactionsPendingRemoval.delete(key)) {
|
||||
this.removeReaction(key, reaction);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
this.activeReactions.delete(key);
|
||||
this.reactionsPendingRemoval.delete(key);
|
||||
process.stderr.write(
|
||||
`[Channel:${this.name}] failed to acknowledge comment ${messageId}: ${err}\n`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
protected override onPromptEnd(
|
||||
chatId: string,
|
||||
_sessionId: string,
|
||||
messageId?: string,
|
||||
): void {
|
||||
if (!messageId || !/^\d+$/.test(messageId)) return;
|
||||
const key = this.reactionKey(chatId, Number(messageId));
|
||||
const reaction = this.activeReactions.get(key);
|
||||
if (!reaction) return;
|
||||
if (reaction.reactionId === undefined) {
|
||||
this.reactionsPendingRemoval.add(key);
|
||||
return;
|
||||
}
|
||||
this.removeReaction(key, reaction);
|
||||
}
|
||||
|
||||
private reactionKey(chatId: string, commentId: number): string {
|
||||
return `${chatId}:${commentId}`;
|
||||
}
|
||||
|
||||
private removeReaction(key: string, reaction: WorkingReaction): void {
|
||||
const { reactionId } = reaction;
|
||||
if (reactionId === undefined) return;
|
||||
this.activeReactions.delete(key);
|
||||
void this.githubApi(
|
||||
() =>
|
||||
this.octokit.rest.reactions.deleteForIssueComment({
|
||||
owner: reaction.owner,
|
||||
repo: reaction.repo,
|
||||
comment_id: reaction.commentId,
|
||||
reaction_id: reactionId,
|
||||
}),
|
||||
`removeAcknowledgement(${reaction.commentId})`,
|
||||
).catch((err) => {
|
||||
process.stderr.write(
|
||||
`[Channel:${this.name}] failed to remove acknowledgement from comment ${reaction.commentId}: ${err}\n`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
protected async pollOnce(): Promise<void> {
|
||||
this.cursor.metaFloor ??= this.cursor.lastProcessedAt;
|
||||
const since = new Date(
|
||||
|
|
@ -610,7 +702,9 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
|
|||
senderName: trigger.actor,
|
||||
chatId: ctx.chatId,
|
||||
threadId: ctx.threadId,
|
||||
messageId: String(trigger.id),
|
||||
// GitHub issue-event IDs are not comment IDs. Prefix them so lifecycle
|
||||
// acknowledgements only target real comment messages.
|
||||
messageId: `event-${trigger.id}`,
|
||||
text:
|
||||
reason === 'review_requested'
|
||||
? 'Return a formal review summary with verified actionable findings, or a concise no-blocker result.'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -3733,6 +3735,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(
|
||||
|
|
@ -3778,6 +3798,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(
|
||||
|
|
@ -4250,7 +4309,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
? MCPServerStatus.DISCONNECTED
|
||||
: MCPServerStatus.CONNECTED,
|
||||
);
|
||||
const listSkills = vi.fn().mockResolvedValue([
|
||||
const cachedSkills = [
|
||||
{
|
||||
name: 'review',
|
||||
description: 'Review code',
|
||||
|
|
@ -4297,13 +4356,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: {
|
||||
|
|
@ -4335,8 +4401,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([
|
||||
{
|
||||
|
|
@ -4444,6 +4510,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,
|
||||
{},
|
||||
|
|
@ -4610,6 +4680,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');
|
||||
|
|
@ -4651,6 +4728,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,
|
||||
|
|
@ -11885,6 +12147,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');
|
||||
|
||||
|
|
@ -12423,6 +12721,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'));
|
||||
|
|
@ -15697,6 +16044,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),
|
||||
|
|
@ -16161,6 +16513,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)
|
||||
|
|
@ -16172,6 +16526,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(),
|
||||
|
|
@ -16195,14 +16550,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',
|
||||
);
|
||||
|
|
@ -16211,7 +16576,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),
|
||||
|
|
@ -16221,6 +16688,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'),
|
||||
|
|
@ -16252,7 +16733,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,
|
||||
);
|
||||
|
|
@ -16264,21 +16745,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]!,
|
||||
);
|
||||
|
|
@ -16528,3 +17042,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,12 +300,14 @@ 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,
|
||||
LOAD_REPLAY_VERSION,
|
||||
PROMPT_CANCEL_METHOD,
|
||||
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
|
||||
WORKTREE_MCP_DEFER_META_KEY,
|
||||
type ClientMcpOverWsRuntimeConfig,
|
||||
type BridgeLoadReplayEnvelope,
|
||||
} from '@qwen-code/acp-bridge/bridgeTypes';
|
||||
|
|
@ -626,12 +628,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];
|
||||
|
|
@ -3328,6 +3363,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
|
||||
|
|
@ -3518,6 +3554,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([
|
||||
|
|
@ -4354,7 +4425,17 @@ class QwenAgent implements Agent {
|
|||
);
|
||||
this.settings = settings;
|
||||
const config = await profiler.time('config_setup', () =>
|
||||
this.newSessionConfig(cwd, mcpServers, settings, isChannelSession),
|
||||
this.newSessionConfig(
|
||||
cwd,
|
||||
mcpServers,
|
||||
settings,
|
||||
isChannelSession,
|
||||
undefined,
|
||||
undefined,
|
||||
shouldDeferMcpDiscovery(params)
|
||||
? { skipMcpDiscovery: true }
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
let session: Session;
|
||||
try {
|
||||
|
|
@ -4409,12 +4490,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,
|
||||
|
|
@ -4494,8 +4582,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();
|
||||
|
|
@ -4543,7 +4635,6 @@ class QwenAgent implements Agent {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
const modesData = this.buildModesData(config);
|
||||
const availableModels = this.buildAvailableModels(config);
|
||||
const configOptions = this.buildConfigOptions(config);
|
||||
|
|
@ -5832,26 +5923,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();
|
||||
|
|
@ -5862,17 +5996,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) => [
|
||||
|
|
@ -8940,6 +9063,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
|
||||
|
|
@ -9648,6 +9780,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();
|
||||
|
|
@ -9661,6 +9803,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 =
|
||||
|
|
@ -10009,7 +10157,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(
|
||||
|
|
@ -10035,7 +10185,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 {
|
||||
|
|
@ -10054,7 +10217,9 @@ class QwenAgent implements Agent {
|
|||
}
|
||||
}
|
||||
|
||||
title = await computeUniqueBranchTitle(baseName, sessionService);
|
||||
title = isSideTask
|
||||
? baseName
|
||||
: await computeUniqueBranchTitle(baseName, sessionService);
|
||||
const renamed = await sessionService.renameSession(
|
||||
newSessionId,
|
||||
title,
|
||||
|
|
@ -10493,10 +10658,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') {
|
||||
|
|
@ -10513,6 +10730,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:
|
||||
|
|
@ -10699,7 +10923,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', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
resolveBootstrapRoute,
|
||||
runCliEntry,
|
||||
runCliEntryPoint,
|
||||
stampCliEntryEnv,
|
||||
} from './cli.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
|
|
@ -358,6 +359,110 @@ describe('runCliEntry', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('stampCliEntryEnv', () => {
|
||||
// Isolated because the CLI exports QWEN_CODE_CLI to every shell it spawns —
|
||||
// a test run started from inside a qwen session inherits it.
|
||||
let originalCli: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCli = process.env['QWEN_CODE_CLI'];
|
||||
delete process.env['QWEN_CODE_CLI'];
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-entry-stamp-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCli !== undefined) {
|
||||
process.env['QWEN_CODE_CLI'] = originalCli;
|
||||
} else {
|
||||
delete process.env['QWEN_CODE_CLI'];
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('stamps the built bin entry so skill shell-outs reach THIS build', () => {
|
||||
// A direct workspace launch (`node dist/index.js`) never passes through
|
||||
// scripts/cli-entry.js, so without this stamp every
|
||||
// `"${QWEN_CODE_CLI:-qwen}"` resolved a global install off PATH.
|
||||
const entry = path.join(tempDir, 'index.js');
|
||||
writeFileSync(entry, '#!/usr/bin/env node\nconsole.log("hi");\n');
|
||||
|
||||
stampCliEntryEnv(entry);
|
||||
|
||||
expect(process.env['QWEN_CODE_CLI']).toBe(entry);
|
||||
});
|
||||
|
||||
it("never overwrites an outer launcher's stamp", () => {
|
||||
// cli-entry.js may have selected a standalone shim, and the desktop app
|
||||
// stamps its vendored bundle — both know launch details this module
|
||||
// cannot see, and both run before runCliEntryPoint in the same process.
|
||||
const entry = path.join(tempDir, 'index.js');
|
||||
writeFileSync(entry, '#!/usr/bin/env node\n');
|
||||
process.env['QWEN_CODE_CLI'] = '/outer/launcher/qwen';
|
||||
|
||||
stampCliEntryEnv(entry);
|
||||
|
||||
expect(process.env['QWEN_CODE_CLI']).toBe('/outer/launcher/qwen');
|
||||
});
|
||||
|
||||
it('treats an inherited empty string as unset', () => {
|
||||
// A parent session's spawn filter writes '' for an entry its shell could
|
||||
// not exec. That verdict is about the parent's entry — this build must
|
||||
// still stamp its own.
|
||||
const entry = path.join(tempDir, 'index.js');
|
||||
writeFileSync(entry, '#!/usr/bin/env node\n');
|
||||
process.env['QWEN_CODE_CLI'] = '';
|
||||
|
||||
stampCliEntryEnv(entry);
|
||||
|
||||
expect(process.env['QWEN_CODE_CLI']).toBe(entry);
|
||||
});
|
||||
|
||||
it('grants the execute bit tsc never emits, so the spawn filter passes the stamp', () => {
|
||||
// tsc writes dist/index.js as 0644 and only npm's bin-link chmods it; the
|
||||
// spawn-time filter in core blanks a shebang-bearing entry without X_OK,
|
||||
// which would turn this stamp into a no-op on every plain-build checkout.
|
||||
const entry = path.join(tempDir, 'index.js');
|
||||
writeFileSync(entry, '#!/usr/bin/env node\n', { mode: 0o644 });
|
||||
|
||||
stampCliEntryEnv(entry);
|
||||
|
||||
expect(process.env['QWEN_CODE_CLI']).toBe(entry);
|
||||
expect(statSync(entry).mode & 0o111).not.toBe(0);
|
||||
});
|
||||
|
||||
it('leaves the slot unset when the derived entry does not exist', () => {
|
||||
stampCliEntryEnv(path.join(tempDir, 'no', 'such', 'index.js'));
|
||||
|
||||
expect(process.env['QWEN_CODE_CLI']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives the bin entry one level up from the compiled module', () => {
|
||||
// cli.ts emits to dist/src/cli.js and the shebang bin is dist/index.js —
|
||||
// one level up, not two. Two lands on the unbuilt packages/cli/index.js,
|
||||
// which fails the existence check and silently never stamps, and no other
|
||||
// test can catch that: the derivation is only reachable under a built
|
||||
// layout, where vitest never runs.
|
||||
const source = readFileSync('src/cli.ts', 'utf8');
|
||||
expect(source).toContain("new URL('../index.js', import.meta.url)");
|
||||
expect(
|
||||
new URL('../index.js', 'file:///repo/packages/cli/dist/src/cli.js')
|
||||
.pathname,
|
||||
).toBe('/repo/packages/cli/dist/index.js');
|
||||
});
|
||||
|
||||
it('default derivation never throws and never stamps outside a built layout', () => {
|
||||
// Under vitest Vite rewrites new URL(…, import.meta.url) to a non-file
|
||||
// URL, and in dev runs the derived ../index.js is the unbuilt
|
||||
// packages/cli/index.js. Both must keep the bare-`qwen` fallback — a
|
||||
// failed derivation taking the CLI down would be worse than the version
|
||||
// skew this stamp exists to fix.
|
||||
stampCliEntryEnv();
|
||||
|
||||
expect(process.env['QWEN_CODE_CLI']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootstrap import boundaries', () => {
|
||||
it('keeps fast-path-only dependencies out of static imports', () => {
|
||||
const source = readFileSync('src/cli.ts', 'utf8');
|
||||
|
|
@ -1091,4 +1196,18 @@ describe('bootstrap error handling', () => {
|
|||
expect(output).toContain('Error handler failed:');
|
||||
expect(output).toContain('handler failed');
|
||||
});
|
||||
|
||||
it('wires stampCliEntryEnv into the entry point', () => {
|
||||
const source = readFileSync('src/cli.ts', 'utf8');
|
||||
const entryPoint = source.slice(
|
||||
source.indexOf('export async function runCliEntryPoint'),
|
||||
);
|
||||
expect(entryPoint).toContain('stampCliEntryEnv()');
|
||||
// "First thing in runCliEntryPoint" is the property the doc relies on: the
|
||||
// stamp must land before the CLI runs, not merely somewhere in the body —
|
||||
// a stamp moved below `await run()` would still pass a contains() check.
|
||||
expect(entryPoint.indexOf('stampCliEntryEnv()')).toBeLessThan(
|
||||
entryPoint.indexOf('await run()'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,14 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
accessSync,
|
||||
chmodSync,
|
||||
constants,
|
||||
existsSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import type { ArgumentsCamelCase, Argv, Options } from 'yargs';
|
||||
import { normalizeServeFastPathArgv } from './serve/fast-path-argv.js';
|
||||
import { initStartupProfiler } from './utils/startupProfiler.js';
|
||||
|
|
@ -25,7 +32,7 @@ export const TOP_LEVEL_COMMANDS = [
|
|||
['mcp', 'Manage MCP servers'],
|
||||
[
|
||||
'review <command>',
|
||||
'Internal helpers used by the /review skill (PR worktree setup, context fetch, rules loading, presubmit checks, cleanup)',
|
||||
'Run a review non-interactively (`run`), plus the internal helpers used by the /review skill (PR worktree setup, context fetch, rules loading, presubmit checks, cleanup)',
|
||||
],
|
||||
[
|
||||
'serve',
|
||||
|
|
@ -449,10 +456,89 @@ function writeStderrLine(line: string): void {
|
|||
process.stderr.write(line.endsWith('\n') ? line : `${line}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry a subprocess should call to reach THIS build, consumed by shell
|
||||
* children as `"${QWEN_CODE_CLI:-qwen}"` (see getShellContextEnvVars in core).
|
||||
* The npm bin wrapper (scripts/cli-entry.js) stamps installed launches, but a
|
||||
* workspace launch — a direct `node dist/index.js` — never passes through
|
||||
* it (the npm `start` and `dev` scripts stamp QWEN_CODE_CLI in their own
|
||||
* launchers), so every skill shell-out resolved `qwen` off PATH: a different
|
||||
* install, silently.
|
||||
*
|
||||
* Stamps the bin entry (dist/index.js), not this module: cli.ts compiles to
|
||||
* dist/src/cli.js, which carries no shebang, and the spawn-time filter blanks
|
||||
* an entry a shell cannot exec. Skipped when the derived path does not exist
|
||||
* (dev runs execute .ts sources with no built entry; the bare-`qwen` fallback
|
||||
* is the pre-existing behavior there) and when the module was not loaded from
|
||||
* the filesystem at all — under test runners, Vite statically rewrites the
|
||||
* new URL(…, import.meta.url) expression to a non-file URL, and the stamp
|
||||
* must never take the CLI down.
|
||||
*
|
||||
* The execute bit is granted here when missing, best-effort: the stamped file
|
||||
* must be shell-execable, but tsc emits dist/index.js as 0644 and only npm's
|
||||
* bin-link ever chmods it — on a plain `npm run build` checkout the spawn
|
||||
* filter would blank the stamp and the version skew this exists to fix would
|
||||
* survive. A failed chmod keeps the old fallback: the filter writes '' and
|
||||
* subprocesses run `qwen`.
|
||||
*
|
||||
* First writer wins, unlike the wrapper's unconditional assignment: an
|
||||
* already-set value may come from an outer launcher in THIS process —
|
||||
* cli-entry.js selecting a standalone shim, or the desktop app's vendored
|
||||
* bundle — which knows launch details this module cannot see and must not be
|
||||
* overwritten. The cost is that a value inherited from a PARENT qwen session
|
||||
* also survives, since the two cases are indistinguishable here; the primary
|
||||
* skew scenario — a workspace launch from a plain terminal — has the slot
|
||||
* unset either way. Empty counts as unset: a parent session's spawn filter
|
||||
* writes '' for an entry its shell could not exec, and that verdict is about
|
||||
* the parent's entry, not this build's.
|
||||
*
|
||||
* scripts/dev.js and scripts/start.js assign QWEN_CODE_CLI unconditionally —
|
||||
* the opposite policy on purpose, not an oversight: those files ARE the outer
|
||||
* launcher (they spawn the CLI as a child and must re-point an inherited value
|
||||
* at this build), whereas this module runs in-process AFTER an outer launcher
|
||||
* may already have stamped, so it yields. The bundled `node dist/cli.js` launch
|
||||
* (the desktop error message's instruction) is not stamped either — cli.js sits
|
||||
* at the package root, so the derived ../index.js does not exist and the
|
||||
* existence check skips it, consistent with this PR's workspace-entry scope.
|
||||
*/
|
||||
export function stampCliEntryEnv(entryPath?: string): void {
|
||||
if (process.env['QWEN_CODE_CLI']) {
|
||||
return;
|
||||
}
|
||||
let entry = entryPath;
|
||||
if (entry === undefined) {
|
||||
// dist/src/cli.js → dist/index.js. In dev (src/cli.ts) this lands on the
|
||||
// unbuilt packages/cli/index.js and the existence check below skips it.
|
||||
const entryUrl = new URL('../index.js', import.meta.url);
|
||||
if (entryUrl.protocol !== 'file:') {
|
||||
return;
|
||||
}
|
||||
entry = fileURLToPath(entryUrl);
|
||||
}
|
||||
if (existsSync(entry)) {
|
||||
try {
|
||||
accessSync(entry, constants.X_OK);
|
||||
} catch {
|
||||
try {
|
||||
// Add exec bits to whatever mode the build/umask chose, rather than
|
||||
// setting 0o755 — a deliberately-private 0o600 checkout becomes
|
||||
// execable without also becoming world-readable.
|
||||
chmodSync(entry, statSync(entry).mode | 0o111);
|
||||
} catch {
|
||||
// Not chmoddable (read-only checkout): the spawn filter blanks the
|
||||
// stamp and subprocesses fall back to `qwen`, as before this stamp.
|
||||
}
|
||||
}
|
||||
process.env['QWEN_CODE_CLI'] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCliEntryPoint(
|
||||
run: () => Promise<void> = runCliEntry,
|
||||
handleError: (error: unknown) => Promise<void> = handleCriticalError,
|
||||
): Promise<void> {
|
||||
stampCliEntryEnv();
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
if (isExpectedPtyRaceError(error)) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ describe('reviewCommand', () => {
|
|||
|
||||
it('registers exactly the expected internal helper subcommands', () => {
|
||||
expect(registeredSubcommands()).toEqual([
|
||||
'run',
|
||||
'parse-args',
|
||||
'fetch-pr',
|
||||
'capture-local',
|
||||
|
|
|
|||
|
|
@ -26,13 +26,15 @@ import { scriptLintCommand } from './review/script-lint.js';
|
|||
import { submitCommand } from './review/submit.js';
|
||||
import { testEfficacyCommand } from './review/test-efficacy.js';
|
||||
import { cleanupCommand } from './review/cleanup.js';
|
||||
import { runCommand } from './review/run.js';
|
||||
|
||||
export const reviewCommand: CommandModule = {
|
||||
command: 'review',
|
||||
describe:
|
||||
'Internal helpers used by the /review skill (PR worktree setup, context fetch, rules loading, presubmit checks, cleanup)',
|
||||
'Run a review non-interactively (`run`), plus the internal helpers used by the /review skill (PR worktree setup, context fetch, rules loading, presubmit checks, cleanup)',
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.command(runCommand)
|
||||
.command(parseArgsCommand)
|
||||
.command(fetchPrCommand)
|
||||
.command(captureLocalCommand)
|
||||
|
|
@ -52,7 +54,7 @@ export const reviewCommand: CommandModule = {
|
|||
.command(cleanupCommand)
|
||||
.demandCommand(
|
||||
1,
|
||||
'Specify a subcommand: parse-args, fetch-pr, capture-local, plan-diff, pr-context, comment-status, load-rules, agent-prompt, build-test, script-lint, resolve-anchors, check-coverage, presubmit, test-efficacy, compose-review, submit, or cleanup.',
|
||||
'Specify a subcommand: run, parse-args, fetch-pr, capture-local, plan-diff, pr-context, comment-status, load-rules, agent-prompt, build-test, script-lint, resolve-anchors, check-coverage, presubmit, test-efficacy, compose-review, submit, or cleanup.',
|
||||
)
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
616
packages/cli/src/commands/review/run.test.ts
Normal file
616
packages/cli/src/commands/review/run.test.ts
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// `review run` is a contract around the headless review: build the right
|
||||
// /review invocation, republish the verdict compose-review wrote (never the
|
||||
// model's prose), and map outcomes onto exit codes a CI gate can trust. The
|
||||
// child CLI itself is tested elsewhere; these tests pin the contract — prompt
|
||||
// assembly, artifact discovery (this run's verdict, not a stale one), the
|
||||
// completed/failed/blocking exit split, and the spawn wiring.
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
utimesSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
const execFileSyncMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual, spawn: spawnMock, execFileSync: execFileSyncMock },
|
||||
spawn: spawnMock,
|
||||
execFileSync: execFileSyncMock,
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
buildReviewPrompt,
|
||||
newestArtifactSince,
|
||||
exitCodeFor,
|
||||
killProcessGroup,
|
||||
runCommand,
|
||||
} = await import('./run.js');
|
||||
const { REVIEW_TMP_DIR, REVIEWS_DIR } = await import('./lib/paths.js');
|
||||
// The real cleanup, not a mock: the regression test below must prove the parent
|
||||
// captures the verdict before Step 9's actual sweep deletes it.
|
||||
const { runCleanup } = await import('./cleanup.js');
|
||||
|
||||
describe('buildReviewPrompt', () => {
|
||||
it('reviews the local tree when no target is given', () => {
|
||||
expect(buildReviewPrompt({})).toBe('/review');
|
||||
});
|
||||
|
||||
it('threads target, effort, and --comment through verbatim', () => {
|
||||
expect(
|
||||
buildReviewPrompt({ target: '7724', effort: 'high', comment: true }),
|
||||
).toBe('/review 7724 --effort high --comment');
|
||||
});
|
||||
|
||||
it('omits what was not asked for', () => {
|
||||
expect(buildReviewPrompt({ effort: 'medium' })).toBe(
|
||||
'/review --effort medium',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a target that would re-tokenize into extra args', () => {
|
||||
// `123 --comment` would split into a target plus a flag the child
|
||||
// honours, silently authorising a post the run never asked for.
|
||||
expect(() => buildReviewPrompt({ target: '123 --comment' })).toThrow(
|
||||
/Invalid review target/,
|
||||
);
|
||||
expect(() => buildReviewPrompt({ target: '--comment' })).toThrow(
|
||||
/Invalid review target/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a target carrying quote characters', () => {
|
||||
// tokenizeArgs strips quotes, so `src/it's-a-file.ts` would re-tokenize
|
||||
// to `src/its-a-file.ts` — silently re-targeting a file never named.
|
||||
expect(() => buildReviewPrompt({ target: "src/it's-a-file.ts" })).toThrow(
|
||||
/Invalid review target/,
|
||||
);
|
||||
expect(() => buildReviewPrompt({ target: 'src/"quoted".ts' })).toThrow(
|
||||
/Invalid review target/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('newestArtifactSince', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'run-artifacts-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function file(name: string, mtimeMs: number): string {
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, '{}', 'utf8');
|
||||
utimesSync(path, mtimeMs / 1000, mtimeMs / 1000);
|
||||
return path;
|
||||
}
|
||||
|
||||
it('ignores artifacts older than the run', () => {
|
||||
// A stale composed JSON is the LAST review's verdict — republishing it
|
||||
// would report an outcome this run never produced.
|
||||
const start = Date.now();
|
||||
file('qwen-review-local-composed.json', start - 60_000);
|
||||
|
||||
expect(
|
||||
newestArtifactSince(dir, /^qwen-review-.*composed\.json$/, start),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the newest matching artifact from this run', () => {
|
||||
const start = Date.now() - 10_000;
|
||||
file('qwen-review-local-composed.json', start + 1_000);
|
||||
const newer = file('qwen-review-pr-9-composed.json', start + 5_000);
|
||||
file('unrelated.json', start + 9_000);
|
||||
|
||||
expect(
|
||||
newestArtifactSince(dir, /^qwen-review-.*composed\.json$/, start),
|
||||
).toBe(newer);
|
||||
});
|
||||
|
||||
it('returns null when the directory does not exist', () => {
|
||||
expect(
|
||||
newestArtifactSince(join(dir, 'absent'), /composed/, Date.now()),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exitCodeFor', () => {
|
||||
it('splits completed / no-verdict / blocking into 0 / 1 / 3', () => {
|
||||
expect(exitCodeFor(true, 'APPROVE', 'none')).toBe(0);
|
||||
expect(exitCodeFor(true, 'REQUEST_CHANGES', 'none')).toBe(0);
|
||||
expect(exitCodeFor(false, null, 'none')).toBe(1);
|
||||
expect(exitCodeFor(true, 'REQUEST_CHANGES', 'request-changes')).toBe(3);
|
||||
expect(exitCodeFor(true, 'COMMENT', 'request-changes')).toBe(0);
|
||||
// An incomplete run is 1 even under --fail-on: "the tool broke" must never
|
||||
// read as "the review blocked".
|
||||
expect(exitCodeFor(false, 'REQUEST_CHANGES', 'request-changes')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('killProcessGroup', () => {
|
||||
let processKill: MockInstance<typeof process.kill>;
|
||||
|
||||
beforeEach(() => {
|
||||
processKill = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
execFileSyncMock.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('kills the POSIX process group with a negative pid', () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux');
|
||||
killProcessGroup(12345, 'SIGTERM');
|
||||
expect(processKill).toHaveBeenCalledWith(-12345, 'SIGTERM');
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('kills the process tree via taskkill on Windows', () => {
|
||||
// A negative pid is not a process group on win32; the group kill must fall
|
||||
// back to a tree kill or the timeout/cancel termination silently no-ops.
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
|
||||
killProcessGroup(12345, 'SIGTERM');
|
||||
expect(execFileSyncMock).toHaveBeenCalledWith(
|
||||
'taskkill',
|
||||
['/pid', '12345', '/T', '/F'],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
expect(processKill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('review run (handler)', () => {
|
||||
let dir: string;
|
||||
let cwd: string;
|
||||
let outs: string[];
|
||||
let errs: string[];
|
||||
let exitCode: number | undefined;
|
||||
let processKill: MockInstance<typeof process.kill>;
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
pid = 12345;
|
||||
stdout = Object.assign(new EventEmitter(), { resume: () => {} });
|
||||
stderr = Object.assign(new EventEmitter(), { resume: () => {} });
|
||||
kill = vi.fn();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'run-handler-'));
|
||||
cwd = process.cwd();
|
||||
process.chdir(dir);
|
||||
outs = [];
|
||||
errs = [];
|
||||
exitCode = process.exitCode as number | undefined;
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
outs.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
||||
errs.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
processKill = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = exitCode;
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
process.chdir(cwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runHandler(over: Record<string, unknown> = {}): Promise<void> {
|
||||
return (runCommand.handler as (a: unknown) => Promise<void>)({
|
||||
comment: false,
|
||||
json: true,
|
||||
'fail-on': 'none',
|
||||
'timeout-minutes': 120,
|
||||
'approval-mode': 'yolo',
|
||||
quiet: true,
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
/** Child that "completes", writing (or not) a composed verdict first. */
|
||||
function armChild(exit: number, composed?: Record<string, unknown>): void {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = new FakeChild();
|
||||
setImmediate(() => {
|
||||
if (composed) {
|
||||
mkdirSync(REVIEW_TMP_DIR, { recursive: true });
|
||||
mkdirSync(REVIEWS_DIR, { recursive: true });
|
||||
writeFileSync(
|
||||
join(REVIEW_TMP_DIR, 'qwen-review-local-composed.json'),
|
||||
JSON.stringify(composed),
|
||||
'utf8',
|
||||
);
|
||||
writeFileSync(join(REVIEWS_DIR, 'review.md'), '# report', 'utf8');
|
||||
}
|
||||
child.emit('close', exit);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
it('republishes the composed verdict and exits 0', async () => {
|
||||
// Non-default values for every republished field, so a dropped or
|
||||
// hard-coded `composed?.X ?? default` mapping cannot pass.
|
||||
armChild(0, {
|
||||
event: 'COMMENT',
|
||||
verdictLine: 'Verdict: Comment',
|
||||
baseEvent: 'REQUEST_CHANGES',
|
||||
cappedBy: ['unreviewed-dimension'],
|
||||
downgraded: true,
|
||||
downgradedFrom: 'Request changes',
|
||||
remediation: ['do x'],
|
||||
});
|
||||
await runHandler();
|
||||
|
||||
const result = JSON.parse(outs.join(''));
|
||||
expect(result.completed).toBe(true);
|
||||
expect(result.event).toBe('COMMENT');
|
||||
expect(result.verdictLine).toBe('Verdict: Comment');
|
||||
expect(result.baseEvent).toBe('REQUEST_CHANGES');
|
||||
expect(result.cappedBy).toEqual(['unreviewed-dimension']);
|
||||
expect(result.downgraded).toBe(true);
|
||||
expect(result.downgradedFrom).toBe('Request changes');
|
||||
expect(result.remediation).toEqual(['do x']);
|
||||
expect(result.reportPath).toContain('review.md');
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('exits 3 on a blocking verdict only when --fail-on asks for it', async () => {
|
||||
armChild(0, {
|
||||
event: 'REQUEST_CHANGES',
|
||||
verdictLine: 'Verdict: Request changes',
|
||||
});
|
||||
await runHandler({ 'fail-on': 'request-changes' });
|
||||
|
||||
expect(process.exitCode).toBe(3);
|
||||
});
|
||||
|
||||
it('treats a clean child exit without a composed verdict as failure', async () => {
|
||||
// The model can wander off and exit 0 without ever reaching Step 7. That is
|
||||
// "no verdict", never "approve".
|
||||
armChild(0);
|
||||
await runHandler();
|
||||
|
||||
const result = JSON.parse(outs.join(''));
|
||||
expect(result.completed).toBe(false);
|
||||
expect(result.event).toBeNull();
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('captures the verdict before Step 9 cleanup sweeps it', async () => {
|
||||
// The regression this command shipped with: the child runs the bundled
|
||||
// skill through Step 9, whose `cleanup` deletes the composed verdict before
|
||||
// the child exits. A parent that reads only after `close` sees nothing and
|
||||
// reports a completed review as a failure. The capture poll must snapshot
|
||||
// the verdict while the child still runs.
|
||||
vi.useFakeTimers();
|
||||
let child!: FakeChild;
|
||||
spawnMock.mockImplementation(() => {
|
||||
// Step 6: compose-review writes the composed verdict.
|
||||
mkdirSync(REVIEW_TMP_DIR, { recursive: true });
|
||||
writeFileSync(
|
||||
join(REVIEW_TMP_DIR, 'qwen-review-local-composed.json'),
|
||||
JSON.stringify({ event: 'APPROVE', verdictLine: 'Verdict: Approve' }),
|
||||
'utf8',
|
||||
);
|
||||
child = new FakeChild();
|
||||
return child;
|
||||
});
|
||||
|
||||
const done = runHandler();
|
||||
// The capture poll snapshots the verdict while the child still runs...
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
// ...then Step 9 runs the REAL cleanup, which sweeps the verdict...
|
||||
runCleanup('local');
|
||||
outs.length = 0; // drop cleanup's "Removed temp file" stdout noise
|
||||
// ...and only then does the child exit.
|
||||
child.emit('close', 0);
|
||||
await done;
|
||||
|
||||
const result = JSON.parse(outs.join(''));
|
||||
expect(result.completed).toBe(true);
|
||||
expect(result.event).toBe('APPROVE');
|
||||
expect(result.composedPath).toContain('qwen-review-local-composed.json');
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('closes the child stdin so piped input cannot defeat slash detection', async () => {
|
||||
armChild(0, { event: 'APPROVE', verdictLine: 'Verdict: Approve' });
|
||||
await runHandler();
|
||||
|
||||
const [, argvUsed, opts] = spawnMock.mock.calls[0] as [
|
||||
string,
|
||||
string[],
|
||||
{ stdio: unknown[]; detached: boolean },
|
||||
];
|
||||
expect(opts.stdio[0]).toBe('ignore');
|
||||
expect(opts.detached).toBe(true);
|
||||
// --expose-gc must lead the argv: spawning argv[1] directly would drop the
|
||||
// flag the memory-pressure monitor's critical tier needs (cli-entry.js
|
||||
// passes it for exactly this relaunch path).
|
||||
expect(argvUsed[0]).toBe('--expose-gc');
|
||||
expect(argvUsed).toContain('--prompt');
|
||||
expect(argvUsed).toContain('/review');
|
||||
});
|
||||
|
||||
it('passes the approval mode through to the child CLI', async () => {
|
||||
armChild(0, { event: 'APPROVE', verdictLine: 'Verdict: Approve' });
|
||||
await runHandler({ 'approval-mode': 'default' });
|
||||
|
||||
const [, argvUsed] = spawnMock.mock.calls[0] as [string, string[]];
|
||||
const i = argvUsed.indexOf('--approval-mode');
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
expect(argvUsed[i + 1]).toBe('default');
|
||||
});
|
||||
|
||||
it('treats a composed verdict without a string event as no verdict', async () => {
|
||||
// readComposed must refuse a file whose `event` is not a string, or a
|
||||
// corrupt verdict would read as completed with event null and exit 0.
|
||||
armChild(0, { event: 123, verdictLine: 'Verdict: Approve' });
|
||||
await runHandler();
|
||||
|
||||
const result = JSON.parse(outs.join(''));
|
||||
expect(result.completed).toBe(false);
|
||||
expect(result.event).toBeNull();
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('reports a launch failure when the child emits an error', async () => {
|
||||
// A missing CLI binary or an OS that cannot fork emits `error`, not
|
||||
// `close`; the handler must still settle and report "no verdict".
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = new FakeChild();
|
||||
setImmediate(() => child.emit('error', new Error('spawn ENOENT')));
|
||||
return child;
|
||||
});
|
||||
await runHandler();
|
||||
|
||||
const result = JSON.parse(outs.join(''));
|
||||
expect(result.completed).toBe(false);
|
||||
expect(result.childExitCode).toBeNull();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errs.join('')).toContain('failed to launch the CLI');
|
||||
});
|
||||
|
||||
it('streams child progress to stderr, never stdout, when not quiet', async () => {
|
||||
// The contract: stdout carries only the result. If progress leaked to
|
||||
// stdout it would interleave with the JSON a CI consumer parses.
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = new FakeChild();
|
||||
setImmediate(() => {
|
||||
child.stdout.emit('data', Buffer.from('progress noise'));
|
||||
child.emit('close', 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
await runHandler({ quiet: false });
|
||||
|
||||
expect(errs.join('')).toContain('progress noise');
|
||||
expect(outs.join('')).not.toContain('progress noise');
|
||||
});
|
||||
|
||||
it('reports a timed-out run as incomplete and kills the process group', async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeChild();
|
||||
spawnMock.mockImplementation(() => child);
|
||||
|
||||
const done = runHandler({ 'timeout-minutes': 1 });
|
||||
await vi.advanceTimersByTimeAsync(60_000); // fire the timeout
|
||||
expect(processKill).toHaveBeenCalledWith(-12345, 'SIGTERM');
|
||||
// A child that ignores SIGTERM is escalated to SIGKILL after 10 s.
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(processKill).toHaveBeenCalledWith(-12345, 'SIGKILL');
|
||||
child.emit('close', null, 'SIGTERM'); // the kill takes effect
|
||||
await done;
|
||||
|
||||
const result = JSON.parse(outs.join(''));
|
||||
expect(result.completed).toBe(false);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.childExitCode).toBeNull();
|
||||
expect(result.childSignal).toBe('SIGTERM');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a captured verdict when the timeout fires after compose-review', async () => {
|
||||
// The race the contract must survive: compose-review writes the verdict
|
||||
// (Step 6) and the capture poll snapshots it, but --timeout-minutes fires
|
||||
// before the child exits (Steps 7–9). The flag terminates a run "without a
|
||||
// verdict", so a captured verdict still counts as completed — the kill must
|
||||
// not flip exit 0 to 1 or suppress the verdict, and `timedOut` alone still
|
||||
// records that the timer fired.
|
||||
vi.useFakeTimers();
|
||||
let child!: FakeChild;
|
||||
spawnMock.mockImplementation(() => {
|
||||
mkdirSync(REVIEW_TMP_DIR, { recursive: true });
|
||||
writeFileSync(
|
||||
join(REVIEW_TMP_DIR, 'qwen-review-local-composed.json'),
|
||||
JSON.stringify({ event: 'APPROVE', verdictLine: 'Verdict: Approve' }),
|
||||
'utf8',
|
||||
);
|
||||
child = new FakeChild();
|
||||
return child;
|
||||
});
|
||||
|
||||
const done = runHandler({ 'timeout-minutes': 1 });
|
||||
// The capture poll snapshots the verdict while the child still runs...
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
// ...then the timeout fires and kills the group before the child exits.
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(processKill).toHaveBeenCalledWith(-12345, 'SIGTERM');
|
||||
child.emit('close', null, 'SIGTERM');
|
||||
await done;
|
||||
|
||||
const result = JSON.parse(outs.join(''));
|
||||
expect(result.completed).toBe(true);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.event).toBe('APPROVE');
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('forwards a parent signal to the child group and exits 128+signum', async () => {
|
||||
// The detached child sits outside the foreground group a terminal's
|
||||
// Ctrl+C signals, and a cancelled CI job sends the parent SIGTERM.
|
||||
// Without forwarding, the parent dies and the review is reparented to
|
||||
// PID 1, burning API calls for the full timeout. Pin the registration
|
||||
// and the 128+signum mapping so a refactor cannot silently drop them.
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeChild();
|
||||
spawnMock.mockImplementation(() => child);
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
const onSpy = vi.spyOn(process, 'on');
|
||||
|
||||
const done = runHandler({ 'timeout-minutes': 1 });
|
||||
|
||||
// All three signals must be registered.
|
||||
const registered = onSpy.mock.calls
|
||||
.map(([sig]) => sig)
|
||||
.filter((sig) => ['SIGHUP', 'SIGINT', 'SIGTERM'].includes(sig as string));
|
||||
expect(registered).toEqual(
|
||||
expect.arrayContaining(['SIGHUP', 'SIGINT', 'SIGTERM']),
|
||||
);
|
||||
|
||||
const handler = onSpy.mock.calls.find(
|
||||
([sig]) => sig === 'SIGTERM',
|
||||
)?.[1] as (signal: NodeJS.Signals) => void;
|
||||
handler('SIGHUP');
|
||||
handler('SIGINT');
|
||||
handler('SIGTERM');
|
||||
|
||||
// The group is killed and each signal maps onto 128+signum.
|
||||
expect(processKill).toHaveBeenCalledWith(-12345, 'SIGTERM');
|
||||
expect(exitSpy).toHaveBeenNthCalledWith(1, 129);
|
||||
expect(exitSpy).toHaveBeenNthCalledWith(2, 130);
|
||||
expect(exitSpy).toHaveBeenNthCalledWith(3, 143);
|
||||
|
||||
// The handler cleared the timeout timer: crossing it must not fire the
|
||||
// timeout path (which would write its own stderr notice).
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(errs.join('')).not.toContain('timeout after');
|
||||
|
||||
child.emit('close', null, 'SIGTERM');
|
||||
await done;
|
||||
});
|
||||
|
||||
it('prints the verdict line and report path in human-readable mode', async () => {
|
||||
armChild(0, { event: 'APPROVE', verdictLine: 'Verdict: Approve' });
|
||||
await runHandler({ json: false });
|
||||
|
||||
const output = outs.join('');
|
||||
expect(output).toContain('Verdict: Approve');
|
||||
expect(output).toContain('Report: ');
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('distinguishes a corrupt composed artifact from a missing one', async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = new FakeChild();
|
||||
setImmediate(() => {
|
||||
mkdirSync(REVIEW_TMP_DIR, { recursive: true });
|
||||
writeFileSync(
|
||||
join(REVIEW_TMP_DIR, 'qwen-review-local-composed.json'),
|
||||
'{truncated',
|
||||
'utf8',
|
||||
);
|
||||
child.emit('close', 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
await runHandler({ json: false });
|
||||
|
||||
const output = outs.join('');
|
||||
expect(output).toContain('could not be parsed');
|
||||
expect(output).not.toContain('no composed verdict was produced');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves the exit code when writing the result to stdout throws', async () => {
|
||||
// The pipe reader can go away (EPIPE) mid-write. The exit code is the
|
||||
// contract a CI gate reads, so it must be set before — and survive — the
|
||||
// write, not downgraded to yargs' generic exit 1 by the throw.
|
||||
armChild(0, {
|
||||
event: 'REQUEST_CHANGES',
|
||||
verdictLine: 'Verdict: Request changes',
|
||||
});
|
||||
vi.spyOn(process.stdout, 'write').mockImplementation(() => {
|
||||
throw new Error('EPIPE');
|
||||
});
|
||||
|
||||
await runHandler({ 'fail-on': 'request-changes' });
|
||||
|
||||
expect(process.exitCode).toBe(3);
|
||||
});
|
||||
|
||||
it('clamps a negative timeout to the 1-minute floor', async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeChild();
|
||||
spawnMock.mockImplementation(() => child);
|
||||
|
||||
const done = runHandler({ 'timeout-minutes': -5 });
|
||||
// 59 s is under the 1-minute floor — must not fire.
|
||||
await vi.advanceTimersByTimeAsync(59_000);
|
||||
expect(processKill).not.toHaveBeenCalled();
|
||||
// Crossing the floor fires the timeout.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
child.emit('close', null, 'SIGTERM');
|
||||
await done;
|
||||
|
||||
const result = JSON.parse(outs.join(''));
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('floors a zero timeout to 1 minute rather than the 120-minute default', async () => {
|
||||
// `|| 120` treats 0 as falsy and would silently substitute the default;
|
||||
// an explicit 0 must still reach the Math.max(1, …) floor.
|
||||
vi.useFakeTimers();
|
||||
const child = new FakeChild();
|
||||
spawnMock.mockImplementation(() => child);
|
||||
|
||||
const done = runHandler({ 'timeout-minutes': 0 });
|
||||
await vi.advanceTimersByTimeAsync(59_000);
|
||||
expect(processKill).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(processKill).toHaveBeenCalledWith(-12345, 'SIGTERM');
|
||||
child.emit('close', null, 'SIGTERM');
|
||||
await done;
|
||||
|
||||
const result = JSON.parse(outs.join(''));
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
495
packages/cli/src/commands/review/run.ts
Normal file
495
packages/cli/src/commands/review/run.ts
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// `qwen review run`: execute a full /review non-interactively and report the
|
||||
// verdict in a machine-readable way.
|
||||
//
|
||||
// The review pipeline already runs headless — `qwen --prompt "/review …"` expands
|
||||
// the bundled skill, launches the dimension agents, and honors the approval mode.
|
||||
// What that path does NOT give a caller is a contract: the verdict lives in the
|
||||
// model's prose and in files whose names the caller would have to know, the exit
|
||||
// code says nothing about the review's outcome, and a piped stdin silently
|
||||
// defeats slash-command detection (the runner prepends piped input, and
|
||||
// `isSlashCommand` requires the FIRST character to be `/`). Every consumer that
|
||||
// wants "run a review, tell me what it decided" has been re-deriving those facts
|
||||
// by scraping a terminal.
|
||||
//
|
||||
// This command is that contract, and nothing more: it assembles the /review
|
||||
// invocation, runs the CLI's own non-interactive path in a child process with
|
||||
// stdin closed, and then reads the verdict from the artifact `compose-review`
|
||||
// wrote — the same JSON the skill treats as the verdict authority — rather than
|
||||
// from anything the model said. Progress streams to stderr; stdout carries only
|
||||
// the result; the exit code distinguishes "review completed" from "review never
|
||||
// reached a verdict" from "blocking verdict" (opt-in via --fail-on).
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
writeStdoutLine,
|
||||
writeStderrLineSafe,
|
||||
} from '../../utils/stdioHelpers.js';
|
||||
import { REVIEW_TMP_DIR, REVIEWS_DIR } from './lib/paths.js';
|
||||
import { EFFORT_LEVELS } from './parse-args.js';
|
||||
|
||||
export interface RunReviewArgs {
|
||||
target?: string;
|
||||
effort?: string;
|
||||
comment: boolean;
|
||||
json: boolean;
|
||||
failOn: 'none' | 'request-changes';
|
||||
timeoutMinutes: number;
|
||||
approvalMode: string;
|
||||
quiet: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composed-verdict fields this command republishes (see compose-review).
|
||||
* `findings`, `model`, and `disclosures` (named by #7981) are deliberately
|
||||
* absent: compose-review does not emit them as discrete fields, so there is
|
||||
* nothing to republish until the composed artifact grows them.
|
||||
*/
|
||||
interface ComposedVerdict {
|
||||
event?: string;
|
||||
verdictLine?: string;
|
||||
baseEvent?: string;
|
||||
cappedBy?: string[];
|
||||
downgraded?: boolean;
|
||||
downgradedFrom?: string | null;
|
||||
remediation?: string[];
|
||||
}
|
||||
|
||||
export interface RunReviewResult {
|
||||
completed: boolean;
|
||||
event: string | null;
|
||||
verdictLine: string | null;
|
||||
baseEvent: string | null;
|
||||
cappedBy: string[];
|
||||
downgraded: boolean;
|
||||
downgradedFrom: string | null;
|
||||
remediation: string[];
|
||||
composedPath: string | null;
|
||||
reportPath: string | null;
|
||||
childExitCode: number | null;
|
||||
childSignal: string | null;
|
||||
timedOut: boolean;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/** The composed verdict `compose-review` writes and Step 9 cleanup sweeps. */
|
||||
const COMPOSED_PATTERN = /^qwen-review-.*composed\.json$/;
|
||||
|
||||
// How often to poll for the composed verdict while the child runs. The verdict
|
||||
// sits on disk from Step 6 (compose-review) until Step 9 (cleanup) — a window
|
||||
// spanning the model's between-step narration and the report write, i.e.
|
||||
// seconds — so a quarter-second poll catches it with a wide margin.
|
||||
const COMPOSED_POLL_MS = 250;
|
||||
|
||||
// Conventional exit codes for a run cancelled by a signal (128 + signum).
|
||||
const SIGNAL_EXIT_CODES: Record<string, number> = {
|
||||
SIGHUP: 129,
|
||||
SIGINT: 130,
|
||||
SIGTERM: 143,
|
||||
};
|
||||
const PARENT_SIGNALS = Object.keys(SIGNAL_EXIT_CODES) as NodeJS.Signals[];
|
||||
|
||||
/** The /review invocation the child runs — built from flags, never hand-typed. */
|
||||
export function buildReviewPrompt(args: {
|
||||
target?: string;
|
||||
effort?: string;
|
||||
comment?: boolean;
|
||||
}): string {
|
||||
const parts = ['/review'];
|
||||
if (args.target) {
|
||||
// The child re-tokenizes this string; a target carrying whitespace or a
|
||||
// leading dash would split into extra tokens (`123 --comment` would
|
||||
// silently authorise posting), and a quote is stripped by the tokenizer
|
||||
// (`src/it's.ts` would re-target to `src/its.ts`) — refuse anything but a
|
||||
// single clean token.
|
||||
if (
|
||||
/\s/.test(args.target) ||
|
||||
args.target.startsWith('-') ||
|
||||
/['"]/.test(args.target)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid review target ${JSON.stringify(args.target)}: expected a single PR number, PR URL, or file path`,
|
||||
);
|
||||
}
|
||||
parts.push(args.target);
|
||||
}
|
||||
if (args.effort) parts.push(`--effort ${args.effort}`);
|
||||
if (args.comment) parts.push('--comment');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest file under `dir` matching `pattern` whose mtime is at or after
|
||||
* `startMs`, or null. Pre-existing artifacts from earlier reviews in the same
|
||||
* repo must not be mistaken for this run's verdict — a stale composed JSON says
|
||||
* whatever the LAST review decided, which is exactly the wrong thing to
|
||||
* republish — so anything older than the run is invisible here.
|
||||
*/
|
||||
export function newestArtifactSince(
|
||||
dir: string,
|
||||
pattern: RegExp,
|
||||
startMs: number,
|
||||
): string | null {
|
||||
let best: { path: string; mtime: number } | null = null;
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
} catch {
|
||||
return null; // no directory — the review never got far enough to create it
|
||||
}
|
||||
for (const name of names) {
|
||||
if (!pattern.test(name)) continue;
|
||||
const path = join(dir, name);
|
||||
let mtime: number;
|
||||
try {
|
||||
mtime = statSync(path).mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (mtime < startMs) continue;
|
||||
if (!best || mtime > best.mtime) best = { path, mtime };
|
||||
}
|
||||
return best ? best.path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit code contract: 0 = the review completed (whatever it decided); 1 = it
|
||||
* never reached a verdict (child failed, timed out with no verdict captured,
|
||||
* or left no composed artifact); 3 = it completed AND the caller asked
|
||||
* --fail-on request-changes AND the event is REQUEST_CHANGES. 3, not 2 — yargs
|
||||
* exits 1 on usage errors and some shells reserve 2, so a CI gate can tell
|
||||
* "review is blocking" from "the tool broke" without parsing anything.
|
||||
*/
|
||||
export function exitCodeFor(
|
||||
completed: boolean,
|
||||
event: string | null,
|
||||
failOn: 'none' | 'request-changes',
|
||||
): number {
|
||||
if (!completed) return 1;
|
||||
if (failOn === 'request-changes' && event === 'REQUEST_CHANGES') return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function readComposed(path: string): ComposedVerdict | null {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8')) as ComposedVerdict;
|
||||
// The one field everything downstream keys on. A file without it is not a
|
||||
// composed verdict, whatever its name says.
|
||||
return typeof parsed.event === 'string' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the child's process group — the detached relaunch wrapper AND the
|
||||
* real review it spawned. On POSIX a negative pid names the group; on Windows
|
||||
* there are no POSIX process groups and a negative pid is meaningless, so fall
|
||||
* back to `taskkill /T`, which walks the tree the detached child spawned. Both
|
||||
* are best-effort: killing a group that is already gone throws, and that is
|
||||
* fine.
|
||||
*/
|
||||
export function killProcessGroup(pid: number, signal: NodeJS.Signals): void {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
} catch {
|
||||
// Already dead, or taskkill unavailable.
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
} catch {
|
||||
// Already dead.
|
||||
}
|
||||
}
|
||||
|
||||
async function runReview(args: RunReviewArgs): Promise<void> {
|
||||
const startMs = Date.now();
|
||||
const prompt = buildReviewPrompt(args);
|
||||
|
||||
// The verdict cutoff carries slack: a coarse filesystem clock can stamp a
|
||||
// file a moment BEFORE the Date.now() captured at run start, and a review's
|
||||
// own verdict must not be discarded over clock granularity. Artifacts from a
|
||||
// previous review are minutes old, far outside any slack.
|
||||
const cutoffMs = startMs - 2_000;
|
||||
|
||||
// Re-enter THIS build's CLI, not whatever `qwen` PATH resolves to — the same
|
||||
// version-skew rule the skill's own subprocesses follow via QWEN_CODE_CLI.
|
||||
// process.argv[1] is the entry that is already running this command.
|
||||
// --expose-gc comes first, exactly as the relaunch wrapper passes it
|
||||
// (cli-entry.js): a full review is the longest, most memory-hungry session
|
||||
// the CLI runs, and spawning argv[1] directly would silently drop the flag
|
||||
// the memory-pressure monitor's critical tier needs to call global.gc().
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--expose-gc',
|
||||
process.argv[1],
|
||||
'--prompt',
|
||||
prompt,
|
||||
'--approval-mode',
|
||||
args.approvalMode,
|
||||
],
|
||||
{
|
||||
// stdin CLOSED, not inherited: piped input would be prepended to the
|
||||
// prompt and the leading `/` would no longer be the first character —
|
||||
// the slash command would reach the model as plain text.
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// The CLI relaunches itself in a child (for --max-old-space-size), so
|
||||
// the pid we spawn is a wrapper whose grandchild is the real review.
|
||||
// A new process group lets the timeout kill reach both.
|
||||
detached: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (!args.quiet) {
|
||||
// Progress belongs on stderr; stdout is reserved for the result. A throw
|
||||
// here (EPIPE once the pipe reader exits) would crash the parent and orphan
|
||||
// the child review, so the write stays incidental.
|
||||
const writeProgress = (chunk: Buffer): void => {
|
||||
try {
|
||||
process.stderr.write(chunk);
|
||||
} catch {
|
||||
// stderr is gone; the verdict, not the progress, is what matters.
|
||||
}
|
||||
};
|
||||
child.stdout?.on('data', writeProgress);
|
||||
child.stderr?.on('data', writeProgress);
|
||||
} else {
|
||||
child.stdout?.resume();
|
||||
child.stderr?.resume();
|
||||
}
|
||||
|
||||
// The composed verdict is transient: the child's Step 9 `cleanup` sweeps
|
||||
// every `.qwen/tmp/qwen-review-<target>-*` file — including it — before the
|
||||
// child exits. Reading it only AFTER `close` therefore sees nothing and
|
||||
// reports a review that completed as one that failed. Snapshot it the moment
|
||||
// compose-review writes it: the first verdict newer than the run start is
|
||||
// this run's, and caching it in memory survives the sweep.
|
||||
let capturedPath: string | null = null;
|
||||
let capturedVerdict: ComposedVerdict | null = null;
|
||||
const captureTimer = setInterval(() => {
|
||||
if (capturedVerdict !== null) return;
|
||||
const path = newestArtifactSince(
|
||||
REVIEW_TMP_DIR,
|
||||
COMPOSED_PATTERN,
|
||||
cutoffMs,
|
||||
);
|
||||
if (path === null) return;
|
||||
// A half-written file fails to parse; the next tick retries it.
|
||||
const verdict = readComposed(path);
|
||||
if (verdict !== null) {
|
||||
capturedPath = path;
|
||||
capturedVerdict = verdict;
|
||||
}
|
||||
}, COMPOSED_POLL_MS);
|
||||
|
||||
let timedOut = false;
|
||||
const timeoutMs = args.timeoutMinutes * 60_000;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
// Safe write: a throw on EPIPE would skip the kill below and leave the
|
||||
// child review running on, burning compute and model API calls.
|
||||
writeStderrLineSafe(
|
||||
`review run: timeout after ${args.timeoutMinutes} minutes — terminating the review`,
|
||||
);
|
||||
// Kill the process group, not just the wrapper: child.kill() would only
|
||||
// reach the relaunch wrapper, leaving the real review reparented to PID 1
|
||||
// and still burning API calls.
|
||||
const pid = child.pid;
|
||||
if (pid !== undefined) {
|
||||
killProcessGroup(pid, 'SIGTERM');
|
||||
setTimeout(() => killProcessGroup(pid, 'SIGKILL'), 10_000).unref();
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// The child is detached (its own process group) so the timeout kill can reach
|
||||
// the relaunch wrapper's grandchild — but that also puts it outside the
|
||||
// foreground group a terminal's Ctrl+C signals, and a cancelled CI job sends
|
||||
// the parent SIGTERM. Without forwarding, the parent dies and the review is
|
||||
// reparented to PID 1, burning model API calls for up to the full timeout
|
||||
// (and, with --comment, can still post after the job that spawned it is
|
||||
// gone). Terminate the group on the way out, mirroring the timeout path.
|
||||
const onParentSignal = (signal: NodeJS.Signals): void => {
|
||||
clearTimeout(timer);
|
||||
clearInterval(captureTimer);
|
||||
const pid = child.pid;
|
||||
if (pid !== undefined) {
|
||||
// SIGTERM's default action terminates the node group; the parent exits
|
||||
// immediately, so there is no later moment to escalate to SIGKILL.
|
||||
killProcessGroup(pid, 'SIGTERM');
|
||||
}
|
||||
process.exit(SIGNAL_EXIT_CODES[signal] ?? 1);
|
||||
};
|
||||
for (const signal of PARENT_SIGNALS) process.on(signal, onParentSignal);
|
||||
|
||||
const childOutcome = await new Promise<{
|
||||
code: number | null;
|
||||
signal: string | null;
|
||||
}>((resolvePromise) => {
|
||||
child.on('close', (code, signal) => resolvePromise({ code, signal }));
|
||||
child.on('error', (err) => {
|
||||
writeStderrLineSafe(
|
||||
`review run: failed to launch the CLI: ${err.message}`,
|
||||
);
|
||||
resolvePromise({ code: null, signal: null });
|
||||
});
|
||||
});
|
||||
const childExitCode = childOutcome.code;
|
||||
const childSignal = childOutcome.signal;
|
||||
clearTimeout(timer);
|
||||
clearInterval(captureTimer);
|
||||
for (const signal of PARENT_SIGNALS) process.off(signal, onParentSignal);
|
||||
|
||||
// The verdict is what compose-review wrote, not what the child printed. A
|
||||
// clean child exit without a composed artifact means the run wandered off
|
||||
// before Step 7 — that is "no verdict", not "approve". Prefer the verdict
|
||||
// captured during the run (Step 9 cleanup has usually swept the file by now);
|
||||
// fall back to a disk scan for a child that died before cleanup ran.
|
||||
// Annotated, not inferred: capturedPath/capturedVerdict are mutated only
|
||||
// inside the poll closure, so control-flow analysis would narrow them to
|
||||
// their `null` initializer and reject the fallback reassignment below.
|
||||
let composedPath: string | null = capturedPath;
|
||||
let composed: ComposedVerdict | null = capturedVerdict;
|
||||
if (composed === null) {
|
||||
composedPath = newestArtifactSince(
|
||||
REVIEW_TMP_DIR,
|
||||
COMPOSED_PATTERN,
|
||||
cutoffMs,
|
||||
);
|
||||
composed = composedPath ? readComposed(composedPath) : null;
|
||||
}
|
||||
const reportPath = newestArtifactSince(REVIEWS_DIR, /\.md$/, cutoffMs);
|
||||
|
||||
const completed = composed !== null;
|
||||
const result: RunReviewResult = {
|
||||
completed,
|
||||
event: composed?.event ?? null,
|
||||
verdictLine: composed?.verdictLine ?? null,
|
||||
baseEvent: composed?.baseEvent ?? null,
|
||||
cappedBy: composed?.cappedBy ?? [],
|
||||
downgraded: composed?.downgraded ?? false,
|
||||
downgradedFrom: composed?.downgradedFrom ?? null,
|
||||
remediation: composed?.remediation ?? [],
|
||||
composedPath: composedPath ? resolve(composedPath) : null,
|
||||
reportPath: reportPath ? resolve(reportPath) : null,
|
||||
childExitCode,
|
||||
childSignal,
|
||||
timedOut,
|
||||
durationMs: Date.now() - startMs,
|
||||
};
|
||||
|
||||
// Assign the exit code BEFORE writing the result: a stdout write can throw
|
||||
// (EPIPE once the pipe reader exits), and the exit code — not the prose — is
|
||||
// the contract a CI gate reads. A throw must not downgrade a blocking verdict
|
||||
// (exit 3) to yargs' generic failure (exit 1).
|
||||
process.exitCode = exitCodeFor(completed, result.event, args.failOn);
|
||||
|
||||
try {
|
||||
if (args.json) {
|
||||
writeStdoutLine(JSON.stringify(result, null, 2));
|
||||
} else if (completed) {
|
||||
writeStdoutLine(result.verdictLine ?? `Event: ${result.event}`);
|
||||
if (result.reportPath) writeStdoutLine(`Report: ${result.reportPath}`);
|
||||
} else {
|
||||
const detail =
|
||||
composedPath !== null
|
||||
? `a composed verdict was found at ${resolve(composedPath)} but could not be parsed`
|
||||
: 'no composed verdict was produced';
|
||||
writeStdoutLine(
|
||||
timedOut
|
||||
? 'Review did not complete: timed out.'
|
||||
: `Review did not complete: ${detail}` +
|
||||
`${childExitCode !== null ? ` (CLI exit ${childExitCode})` : ''}` +
|
||||
`${childSignal !== null ? ` (killed by ${childSignal})` : ''}.`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// stdout is gone; the exit code above is the contract, not this prose.
|
||||
}
|
||||
}
|
||||
|
||||
export const runCommand: CommandModule = {
|
||||
command: 'run [target]',
|
||||
describe:
|
||||
'Run a full /review non-interactively and print the verdict (machine-readable with --json)',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional('target', {
|
||||
type: 'string',
|
||||
describe:
|
||||
'What to review: a PR number, a PR URL, or a file path; omit to review the local working tree',
|
||||
})
|
||||
.option('effort', {
|
||||
type: 'string',
|
||||
choices: [...EFFORT_LEVELS],
|
||||
describe:
|
||||
'The review effort. Defaults to the skill default for the target (high for a PR, medium locally).',
|
||||
})
|
||||
.option('comment', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
describe:
|
||||
'Authorise posting the review to GitHub (PR targets only) — same meaning as `/review <pr> --comment`',
|
||||
})
|
||||
.option('json', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
describe: 'Print the full result as JSON on stdout',
|
||||
})
|
||||
.option('fail-on', {
|
||||
type: 'string',
|
||||
choices: ['none', 'request-changes'],
|
||||
default: 'none',
|
||||
describe:
|
||||
'Exit 3 when the review completes with this outcome — lets CI gate on the verdict without parsing output',
|
||||
})
|
||||
.option('timeout-minutes', {
|
||||
type: 'number',
|
||||
default: 120,
|
||||
describe:
|
||||
'Terminate the review after this long without a verdict (exit 1)',
|
||||
})
|
||||
.option('approval-mode', {
|
||||
type: 'string',
|
||||
default: 'yolo',
|
||||
choices: ['plan', 'default', 'auto-edit', 'auto', 'yolo'],
|
||||
describe:
|
||||
'Approval mode for the child CLI. The default is yolo: headless runs cannot answer ' +
|
||||
'confirmation prompts, and anything still unapproved would be auto-denied mid-review.',
|
||||
})
|
||||
.option('quiet', {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
describe: 'Suppress the child CLI progress stream on stderr',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await runReview({
|
||||
target: argv['target'] as string | undefined,
|
||||
effort: argv['effort'] as string | undefined,
|
||||
comment: Boolean(argv['comment']),
|
||||
json: Boolean(argv['json']),
|
||||
failOn: (argv['fail-on'] as 'none' | 'request-changes') ?? 'none',
|
||||
// `|| 120` would treat an explicit `--timeout-minutes 0` as falsy and
|
||||
// silently substitute the default; decide default-vs-value by finiteness
|
||||
// so 0 still reaches the 1-minute floor.
|
||||
timeoutMinutes: Number.isFinite(Number(argv['timeout-minutes']))
|
||||
? Math.max(1, Number(argv['timeout-minutes']))
|
||||
: 120,
|
||||
approvalMode: String(argv['approval-mode'] ?? 'yolo'),
|
||||
quiet: Boolean(argv['quiet']),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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: [] },
|
||||
|
|
@ -11730,6 +11735,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({
|
||||
|
|
@ -14141,12 +14195,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',
|
||||
|
|
@ -14164,6 +14240,7 @@ describe('createServeApp', () => {
|
|||
const runtime = makeWorkspaceRuntimeForTest({
|
||||
workspaceId: 'branch-primary',
|
||||
workspaceCwd: WS_BOUND,
|
||||
sessionRuntimeBaseDir: runtimeDir,
|
||||
primary: true,
|
||||
bridge,
|
||||
generationGuard,
|
||||
|
|
@ -14182,16 +14259,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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -17620,7 +17698,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;
|
||||
|
|
@ -17654,10 +17732,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();
|
||||
|
|
@ -18768,6 +18851,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'));
|
||||
|
|
@ -18779,11 +18864,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',
|
||||
},
|
||||
]);
|
||||
|
|
@ -18969,7 +19054,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);
|
||||
|
|
@ -18986,7 +19071,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 () => {
|
||||
|
|
@ -19178,7 +19269,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;
|
||||
|
|
@ -19216,12 +19307,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: [
|
||||
|
|
|
|||
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