Merge branch 'main' into fix/ci-flaky-patrol-ecs-cold-start

Lint & Static red on fa9e3ca963 was the lint gate freshness check, not this
PR: it named .github/workflows/ci.yml as changed on main by #16513
(708ba347d3) after this branch last incorporated it, and this branch does
not touch that file. Merging main is the fix; no code change.

The merge auto-resolved both overlapping files (the size manifest and the
shared runner-routing gate) with this branch's entries intact. Re-verified on
the merged tree: 503 passed | 1 skipped across seven suites, 134/134 shared
node gates, size ratchet green at 46,266 bytes.
This commit is contained in:
wenshao committed 2026-11-08 00:09:39 +08:00
commit 669d4bda1d
69 files changed
+5937 -327

No files matched your search

+111 -10
View File
@@ -8,7 +8,7 @@
// scarce pool (2026-10-31 decision, same accepted risk class as lint).
// The association routing for DOWNSTREAM lanes lives in the `pick_runner`
// shell step, which publishes `ubuntu_runner` (association-gated) and
// `lint_runner` (every pull request reaches the fleet). The matrix below
// `lint_runner` (untrusted forks default to hosted). The matrix below
// pins the gate's unconditional routing AND that ubuntu_runner keeps the
// association gate for the test/integration lanes.
//
@@ -123,6 +123,8 @@ function runPickRunnerOutputs({
eventName,
dispatch,
fleet,
ephemeralEnabled,
ephemeralArch,
}) {
const tmp = mkdtempSync(join(tmpdir(), 'pick-runner-'));
const outputFile = join(tmp, 'github_output');
@@ -133,6 +135,8 @@ function runPickRunnerOutputs({
ECS_DISABLED: ecsDisabled ? 'true' : '',
EVENT_NAME: eventName,
LINUX_RUNNER_FLEET: fleet ?? '',
EPHEMERAL_ENABLED: ephemeralEnabled ?? '',
EPHEMERAL_ARCH: ephemeralArch ?? '',
DISPATCH_LINUX_RUNNER: dispatch ?? '',
GITHUB_OUTPUT: outputFile,
},
@@ -161,7 +165,15 @@ function runPickRunnerOutputs({
outputs.split('\n').includes(`lint_runner=${lintRunner}`),
`lint_runner not published: ${outputs}`,
);
return { ubuntuRunner, lintRunner };
const prTest = outputs
.split('\n')
.find((line) => line.startsWith('pr_test_runner='));
assert.ok(prTest, 'pr_test_runner not published');
return {
ubuntuRunner,
lintRunner,
prTestRunner: prTest.slice('pr_test_runner='.length),
};
}
function runPickRunner(inputs) {
@@ -302,11 +314,7 @@ describe('ci.yml lint_runner routing', () => {
const ARM = '["self-hosted", "linux", "arm64"]';
const lintRunner = (inputs) => runPickRunnerOutputs(inputs).lintRunner;
it('routes every pull request to the fleet, untrusted forks included', () => {
// The maintainer decision this pins: Lint & Static executes PR code on
// the shared pool for ALL authors. If this matrix ever flips back to
// hosted for the negative associations without a deliberate decision,
// the lane silently regrows its hosted quota spend.
it('keeps untrusted fork lint hosted when the disposable switch is unset', () => {
for (const sameRepo of [true, false]) {
for (const assoc of ASSOCIATIONS) {
assert.equal(
@@ -316,8 +324,8 @@ describe('ci.yml lint_runner routing', () => {
assoc,
eventName: 'pull_request',
}),
ECS,
`lint sameRepo=${sameRepo} assoc='${assoc}' must route to ECS`,
sameRepo || TRUSTED.includes(assoc) ? ECS : HOSTED,
`lint sameRepo=${sameRepo} assoc='${assoc}'`,
);
}
}
@@ -354,7 +362,7 @@ describe('ci.yml lint_runner routing', () => {
lintRunner({
ecsDisabled: false,
sameRepo: false,
assoc: 'NONE',
assoc: 'MEMBER',
eventName: 'pull_request',
fleet: 'arm64',
}),
@@ -442,6 +450,99 @@ describe('ci.yml lint_runner routing', () => {
});
});
describe('ci.yml disposable runner routing', () => {
it('keeps all selected fork jobs hosted unless the switch is exactly true', () => {
for (const ephemeralEnabled of [undefined, '', 'false', 'invalid']) {
const out = runPickRunnerOutputs({
eventName: 'pull_request',
sameRepo: false,
assoc: 'NONE',
ephemeralEnabled,
});
assert.equal(out.prTestRunner, HOSTED);
assert.equal(out.lintRunner, HOSTED);
}
});
it('selects ARM64 only when explicitly configured', () => {
const inputs = {
eventName: 'pull_request',
sameRepo: false,
assoc: 'NONE',
ephemeralEnabled: 'true',
};
const arm = '["self-hosted", "linux", "arm64", "ecs-qwen-ephemeral"]';
assert.equal(
runPickRunnerOutputs({ ...inputs, ephemeralArch: 'arm64' }).prTestRunner,
arm,
);
assert.equal(
runPickRunnerOutputs({ ...inputs, ephemeralArch: 'arm64' }).lintRunner,
arm,
);
assert.equal(
runPickRunnerOutputs({ ...inputs, ephemeralArch: 'invalid' })
.prTestRunner,
'["self-hosted", "linux", "x64", "ecs-qwen-ephemeral"]',
);
});
it('routes only untrusted fork jobs to the disposable pool when enabled', () => {
const ephemeral = '["self-hosted", "linux", "x64", "ecs-qwen-ephemeral"]';
for (const eventName of [
'pull_request',
'push',
'merge_group',
'workflow_dispatch',
]) {
for (const sameRepo of [true, false]) {
for (const assoc of ASSOCIATIONS) {
for (const ecsDisabled of [true, false]) {
for (const fleet of ['', 'hosted', 'arm64']) {
const inputs = { eventName, sameRepo, assoc, ecsDisabled, fleet };
const baseline = runPickRunnerOutputs(inputs);
const enabled = runPickRunnerOutputs({
...inputs,
ephemeralEnabled: 'true',
});
const selected =
eventName === 'pull_request' &&
!sameRepo &&
!TRUSTED.includes(assoc) &&
!ecsDisabled &&
fleet !== 'hosted';
assert.equal(
enabled.prTestRunner,
selected ? ephemeral : baseline.ubuntuRunner,
JSON.stringify(inputs),
);
assert.equal(
enabled.lintRunner,
selected ? ephemeral : baseline.lintRunner,
JSON.stringify(inputs),
);
assert.equal(enabled.ubuntuRunner, baseline.ubuntuRunner);
}
}
}
}
}
});
it('routes only the intended jobs through the new output', () => {
assert.equal(
ciDoc.jobs.classify_pr.outputs.pr_test_runner,
'${{ steps.pick_runner.outputs.pr_test_runner }}',
);
for (const name of ['test_build', 'test'])
assert.match(ciDoc.jobs[name]['runs-on'], /pr_test_runner/);
for (const name of ['lint_and_static', 'integration_no_ak'])
assert.match(ciDoc.jobs[name]['runs-on'], /lint_runner/);
for (const [name, job] of Object.entries(ciDoc.jobs)) {
if (!['classify_pr', 'test_build', 'test'].includes(name))
assert.doesNotMatch(String(job['runs-on']), /pr_test_runner/, name);
}
});
});
describe('ci.yml lint HOME isolation', () => {
// This guard lives in the routing battery (HELPER_TESTS) because it gates
// every full-profile PR; the scripts/tests vitest suites only run after the
+1
View File
@@ -27,6 +27,7 @@
2038 docs-page-action.yml
10005 dsw-swe-verified-release.yml
40188 e2e.yml
651 ephemeral-runner-smoke.yml
13135 finalize-release.yml
16647 live-host-release.yml
5950 live-host.yml
+21 -32
View File
@@ -101,8 +101,8 @@ env:
# re-runs every suite after `npm ci`, and a push to main always classifies
# `full` (classify_pr computes a profile only for pull_request), so what
# the fast lane skips is still re-checked post-merge.
HELPER_TESTS_DEP_FREE: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/check-test-growth.test.mjs .github/scripts/check-ci-budget.test.mjs .github/scripts/ecs-runner/check-font-baseline.test.mjs'
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/assign-pr-owner.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/ci-disk-pressure.test.mjs .github/scripts/e2e-build.test.mjs integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/ecs-runner/review-scratch-cleanup.test.mjs .github/scripts/check-test-growth.test.mjs .github/scripts/check-ci-budget.test.mjs .github/scripts/ecs-runner/check-font-baseline.test.mjs'
HELPER_TESTS_DEP_FREE: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/check-test-growth.test.mjs .github/scripts/check-ci-budget.test.mjs .github/scripts/ecs-runner/check-font-baseline.test.mjs scripts/ci/ephemeral-runner/manager.test.mjs'
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/assign-pr-owner.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/ci-disk-pressure.test.mjs .github/scripts/e2e-build.test.mjs integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/ecs-runner/review-scratch-cleanup.test.mjs .github/scripts/check-test-growth.test.mjs .github/scripts/check-ci-budget.test.mjs .github/scripts/ecs-runner/check-font-baseline.test.mjs scripts/ci/ephemeral-runner/manager.test.mjs'
# The growth ratchet and its vitest mirror compare each workflow against
# the PR's base commit to tell "this PR grew the file" apart from "the
# baseline went stale on main" (#9904). Wired once here so every lane
@@ -143,11 +143,8 @@ jobs:
outputs:
skip_ci: '${{ steps.release_sync.outputs.skip_ci }}'
ubuntu_runner: '${{ steps.pick_runner.outputs.ubuntu_runner }}'
# Lint & Static's own routing: every pull request reaches the fleet,
# including untrusted forks (maintainer decision 2026-10-31 — the lane
# executes PR code on the shared pool and that risk is accepted). The
# association gate in ubuntu_runner still governs every other Linux
# lane; do not "simplify" the two outputs into one.
pr_test_runner: '${{ steps.pick_runner.outputs.pr_test_runner }}'
# Untrusted forks use hosted runners unless the disposable pool is enabled.
lint_runner: '${{ steps.pick_runner.outputs.lint_runner }}'
ci_profile: '${{ steps.ci_profile.outputs.ci_profile }}'
steps:
@@ -196,17 +193,16 @@ jobs:
# Keep push routing aligned with the classifier above so a hosted queue
# does not delay the downstream ECS jobs.
#
# lint_runner is the one deliberate divergence: Lint & Static routes
# EVERY pull request to the fleet, untrusted forks included (maintainer
# decision 2026-10-31, risk accepted — see the classify_pr outputs
# comment). The kill-switch and LINUX_RUNNER_FLEET=hosted still force
# hosted, and non-PR events inherit ubuntu_runner unchanged.
# Untrusted forks opt into the disposable pool; otherwise their build,
# test, lint and no-AK integration jobs stay hosted.
- name: 'Select Linux runner'
id: 'pick_runner'
env:
SAME_REPO: '${{ github.event.pull_request.head.repo.full_name == github.repository }}'
AUTHOR_ASSOCIATION: '${{ github.event.pull_request.author_association }}'
ECS_DISABLED: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED }}'
EPHEMERAL_ENABLED: '${{ vars.EXTERNAL_PR_EPHEMERAL_RUNNER_ENABLED }}'
EPHEMERAL_ARCH: '${{ vars.EXTERNAL_PR_EPHEMERAL_ARCH }}'
# Optional fleet selector: 'hosted' forces github.com-hosted runners,
# 'arm64' routes to the experimental ARM64 ECS fleet, anything else
# keeps the x64 ecs-qwen fleet. Empty/unset keeps current behavior.
@@ -242,12 +238,16 @@ jobs:
fi
echo "ubuntu_runner=${ubuntu_runner}" >> "${GITHUB_OUTPUT}"
echo "Selected Linux runner: ${ubuntu_runner}"
# Lint & Static routes every pull request to the fleet (maintainer
# decision 2026-10-31); non-PR events inherit ubuntu_runner unchanged.
pr_test_runner="${ubuntu_runner}"
lint_runner="${ubuntu_runner}"
if [[ "${EVENT_NAME}" == "pull_request" && "${ECS_DISABLED}" != "true" && "${LINUX_RUNNER_FLEET}" != "hosted" ]]; then
lint_runner="${fleet_runner}"
if [[ "${EPHEMERAL_ENABLED}" == "true" && "${ECS_DISABLED}" != "true" && "${LINUX_RUNNER_FLEET}" != "hosted" && "${EVENT_NAME}" == "pull_request" && "${SAME_REPO}" == "false" && "${trusted_author}" == "false" ]]; then
pr_test_runner='["self-hosted", "linux", "x64", "ecs-qwen-ephemeral"]'
if [[ "${EPHEMERAL_ARCH}" == "arm64" ]]; then
pr_test_runner='["self-hosted", "linux", "arm64", "ecs-qwen-ephemeral"]'
fi
lint_runner="${pr_test_runner}"
fi
echo "pr_test_runner=${pr_test_runner}" >> "${GITHUB_OUTPUT}"
echo "lint_runner=${lint_runner}" >> "${GITHUB_OUTPUT}"
echo "Selected lint runner: ${lint_runner}"
@@ -423,7 +423,7 @@ jobs:
name: 'Build unit-test tree (ubuntu-latest, Node 22.x)'
needs: 'classify_pr'
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
runs-on: '${{ fromJSON(needs.classify_pr.outputs.pr_test_runner || ''["ubuntu-latest"]'') }}'
timeout-minutes: 45
permissions:
contents: 'read'
@@ -890,7 +890,7 @@ jobs:
# have. So a push run is lint, static analysis and unit tests —
# lint/static in `lint_and_static`, unit tests here.
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
runs-on: '${{ fromJSON(needs.classify_pr.outputs.pr_test_runner || ''["ubuntu-latest"]'') }}'
# Every Vitest run takes one eighth of its suite's files (`--shard`): the
# unsharded lane ran 45-59 minutes, almost all of it the CLI and core
# suites one after the other. A shard keeps the old ceiling's headroom
@@ -1559,12 +1559,7 @@ jobs:
# same release-sync convention: stay running on skip_ci PRs so a required
# context still reports, with the per-step guards making it a no-op pass.
if: "${{ !cancelled() && github.event_name != 'schedule' }}"
# Reads lint_runner, NOT ubuntu_runner: this lane routes every pull
# request to the fleet, untrusted forks included (maintainer decision
# 2026-10-31 — the lane executes PR code and the shared-pool risk is
# accepted). test and the integration lanes keep the association-gated
# ubuntu_runner. The kill-switch and hosted-fleet overrides still apply,
# inside pick_runner.
# Shares the external-PR hosted/disposable switch with test and integration_no_ak.
runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'') }}'
# Pool-scoped like test's, not a bare constant: this lane was beheaded twice
# running honestly on cold-cache hk3-pool runners — at 30 minutes flat
@@ -2982,14 +2977,8 @@ jobs:
# A draft pull request skips it (and the browser smoke): it cannot merge,
# and `ready_for_review` starts the full run that gates the merge.
if: "${{ !cancelled() && needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') && !github.event.pull_request.draft }}"
# Reads lint_runner, NOT ubuntu_runner: like lint_and_static, this lane
# executes PR code on the pool for every pull request — accepted under the
# same 2026-10-31 decision, and it carries NO secrets by construction
# (every model key is emptied at the integration step; the job's only
# token is the read-only checkout grant). The lane publishes a run-unique
# HOME from its first step (lint's shape) and empties every model key at
# the gate, so the pool exposure is the class already accepted for lint.
# test and integration_cli keep the association-gated ubuntu_runner.
# Shares lint's hosted/disposable routing for untrusted forks. Model keys
# are empty; the job receives only the read-only checkout token.
runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner || ''["ubuntu-latest"]'') }}'
strategy:
fail-fast: false
@@ -0,0 +1,23 @@
name: 'Ephemeral runner smoke'
on:
workflow_dispatch:
permissions:
contents: 'read'
jobs:
smoke:
runs-on: ['ecs-qwen-ephemeral-smoke']
timeout-minutes: 10
steps:
- uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
- name: 'Check tools and workspace'
run: |
node --version
git --version
printf 'Runner: %s\n' "$RUNNER_NAME" > runner-smoke.txt
- uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'ephemeral-runner-smoke'
path: 'runner-smoke.txt'
@@ -0,0 +1,163 @@
# Ephemeral container runners for external PRs
[English](2026-11-07-ephemeral-container-runners.md) | [简体中文](2026-11-07-ephemeral-container-runners.zh-CN.md)
Date: 2026-11-07. Status: implemented; a five-slot systemd trial is deployed on
hz4. Production external-PR routing remains disabled.
Ten slots registering and running ten keyless test jobs of a private repository
at the same time on the Hangzhou shared test machine; containers, networks and
registrations were all reclaimed. A subsequent one-slot trial uses a real PAT
and two sequential GitHub Actions jobs to verify registration, container
replacement, workspace separation and absence of the PAT in jobs. A forced
manager exit verified systemd restart and cleanup before replacement. These
trials found and fixed compatibility with gh 2.46 pagination and Docker's quiet
mode overriding formatted slot output. Full PR CI and production isolation
remain unverified, so the shared host is still only a controlled trial host.
Follow-up model access is described in
[the runner and model-proxy combination](2026-11-07-ephemeral-runner-model-proxy.md);
that proposal is neither implemented nor enabled.
## Goals and boundaries
Put the whole GitHub Actions runner into a disposable Docker container. The
container runs one job and is then deleted; the next job gets a new container,
a new runner identity and a new working directory from the same clean image.
Users do not want to maintain a per-command permission allowlist for CI.
The disposable unit is a job, not a workflow, a step or a PR. Multiple jobs
exchange files through GitHub artifacts and never share a writable working
directory.
This design addresses run-environment residue and cross-task file and process
contamination. Plain Docker shares the host kernel and gives no virtual-machine
isolation from malicious code; any secret passed into a job can still be read
by that job. The first phase accepts only Linux CI work without long-lived
secrets: no model gateway is built here, and release, autofix, real-model E2E
and tasks that need a Docker daemon are not migrated.
## Architecture
```text
External-CI dedicated ECS
├── systemd-managed manager (long-running, trusted)
│ ├── requests JIT runner configuration with the host-only PAT
│ ├── starts, monitors and deletes containers and their isolated networks
│ └── keeps management state, exports logs, recovers interrupted tasks
├── slot 1: container A → one job → delete → container C
└── slot 2: container B → one job → delete → container D
Each container
├── one JIT runner that connects to GitHub on its own
├── Node.js 22, Git and the CI dependencies
└── its own HOME, working directory, temporary directory and npm cache
```
The manager keeps a configured number of slots, at most one container per slot.
The Hangzhou trial ran ten slots at once; the hz4 deployment now maintains five slots. It takes no webhook, schedules no PR
itself, and introduces neither Kubernetes nor a database. GitHub assigns work by
runner label. Each ECS runs its own manager against its local Docker socket,
with a unique `poolId` and a per-host `slots` count. Hosts share the scheduling
labels; no central manager is needed. New names use `ecs-qwen-` for compatibility
with CI worker caps and timeouts; recovery still accepts old `qwen-` identities.
systemd starts the manager at boot when enabled and restarts unexpected exits
after ten seconds (`Restart=always`). An explicit service stop remains stopped.
This supervises process exits; it does not detect a live but stuck manager.
Images are produced by a trusted build and pinned by digest. The manager and the
image may only be deployed from versions maintained under maintainer control,
never updated from a job checkout or from PR artifacts.
Authentication uses a dedicated PAT in the root-only systemd EnvironmentFile,
`/etc/qwen-ephemeral-runner/credentials.env`, as `GH_TOKEN`. A fine-grained token
should select only the target repository and Administration: write, the GitHub
API's required permission. It is not restricted to runner operations alone.
Fine-grained access can be unavailable for outside collaborators or under
organization policy; a broader classic token requires an explicit decision.
The PAT stays on the host, never in the config JSON or a job container. Host
root and Docker administrators remain trusted. Rotate the credential file and
restart the service before expiry; no GitHub App or organization runner
permission is needed for repository-scoped runners.
## Ownership and lifecycle
| Object | Owner | Lifetime |
| ------------------------------------- | ----------------------- | ---------------------------------------------------- |
| ECS, Docker daemon, manager | Ops / maintainers | Long term |
| Host-only runner-management PAT | Manager | Never enters a job container |
| Clean image | Trusted image build | Versioned, not writable from a job |
| JIT configuration, runner identity | One container instance | Used for a single job |
| Container writable layer and job data | One container instance | Deleted on destruction |
| Docker bridge network | One container instance | Deleted on destruction |
| Diagnostic logs | Exported by the manager | Size and retention bounded, unreadable by later jobs |
Normal path: request JIT → create the isolated network and container → runner
comes online → run one job → runner exits → export logs → delete the container
and network → clean up the leftover registration → create the next one.
JIT provides single-job registration semantics under the normal protocol; the
host manager is responsible for actually destroying the container. The design
must not rely on a malicious job exiting on its own, finishing a final cleanup
step, or keeping the runner intact. A watchdog outside the container enforces a
maximum lifetime, and a manager restart reconciles old resources first.
If deleting a container fails, that slot stops; if GitHub registration cleanup
fails, the failure is recorded and retried instead of endlessly requesting new
identities. Host failures cannot clean up immediately, so recovery must reclaim
before accepting new work.
## Minimal but necessary deployment boundary
- Use an ECS dedicated to external CI; do not share a host with existing runners
that hold long-lived secrets.
- Mount no host home, workspace, management state, credential directory or
Docker socket; use neither privileged mode, host PID nor host networking. No
per-command allowlist is needed inside the container.
- Images declare no persistent VOLUME and the first phase creates no job volume;
all job writes go to the container writable layer.
- Network policy outside the host and unmodifiable from the container blocks the
management interface, sensitive intranets, instance credentials and metadata;
configure only the GitHub, npm and image-service egress actually required.
- Access granted to the old trusted runners must be verified on the GitHub side.
Neither labels nor PR-modifiable YAML are an authorization boundary; an
external PR must not route back into a sensitive pool by editing runs-on.
- Do not open fork secrets because a self-hosted runner is enabled, and do not
introduce a privileged pull_request_target flow that executes PR code.
## Initial routing decision
Keep the existing `ubuntu_runner` output and all of its former semantics, and add
a `pr_test_runner` output used by `test_build` and `test`. For an untrusted fork
PR, `EXTERNAL_PR_EPHEMERAL_RUNNER_ENABLED=true` selects the disposable pool;
unset, `false`, or any other value selects `ubuntu-latest`. `lint_runner` follows
the same switch for `lint_and_static` and `integration_no_ak`, replacing the old
shared-ECS fallback for these fork jobs. Trusted and same-repository PR routing
stays unchanged. The global kill-switch and hosted setting take precedence.
The switch selects a destination when a job is evaluated, without querying
runner availability. It cannot move an already queued job; cancel queued work
and start a new workflow run to apply a rollback. Verify unset/false/invalid,
true for both architectures, and the global overrides against the actual shell
selector before rollout.
`classify_pr`, `web_shell_e2e_smoke`, `integration_cli` and the other workflows
keep their current routing. This avoids migrating browser system dependencies
that the new image has not verified, and leaves the routing image relationship
of the existing classifier job and the other workflows unchanged.
The new pool label is `ecs-qwen-ephemeral`. Default routing uses x64; setting
`EXTERNAL_PR_EPHEMERAL_ARCH=arm64` selects ARM64 instead. The toolchain of full
PR CI on the new image still has to be verified.
## References
- [GitHub ephemeral runner semantics and cleanup responsibility](https://docs.github.com/en/actions/reference/runners/self-hosted-runners#ephemeral-runners-for-autoscaling)
- [GitHub repository-level JIT API](https://docs.github.com/en/rest/actions/self-hosted-runners#create-configuration-for-a-just-in-time-runner-for-a-repository)
- [Docker daemon and kernel security boundary](https://docs.docker.com/engine/security/)
The facts this design relies on were checked against the official documentation
during this discussion; the actual runner version, GitHub account capabilities,
API permissions and ECS network configuration must be confirmed again at
deployment time.
@@ -0,0 +1,133 @@
# 外部 PR 的一次性容器 runner
[English](2026-11-07-ephemeral-container-runners.md) | [简体中文](2026-11-07-ephemeral-container-runners.zh-CN.md)
日期:2026-11-07。状态:已实现并在 hz4 部署五槽位 systemd 试运行服务;
外部 PR 的生产路由仍未启用。
杭州共享测试机上已验证 10 个槽位同时注册,并执行同一个私有测试仓库的 10 个无密钥
测试 job,容器、网络和注册均已回收。后续单槽位试运行使用真实 PAT,
通过两个串行 GitHub Actions job 验证注册、容器替换、工作区隔离及 job 内
没有 PAT;强制结束 manager 后也验证了 systemd 重启及补位前清理。
试运行发现并修复了 gh 2.46 分页兼容性及 Docker quiet 模式覆盖格式化
槽位输出的问题。完整 PR CI 和生产隔离仍未验证,共享主机只用于受控试验。
后续模型访问见 [runner 与模型代理结合方案](2026-11-07-ephemeral-runner-model-proxy.zh-CN.md);
该方案尚未实现或启用。
## 目标和边界
将整个 GitHub Actions runner 放进一次性 Docker 容器。容器执行一个 job,
随后删除;下一次从同一个干净镜像创建新容器、新 runner 身份和新工作目录。
用户不希望为 CI 中的每条命令维护权限白名单。
一次性单位是 job,不是 workflow、step 或 PR。多个 job 通过 GitHub artifacts
交换文件,不共享可写工作目录。
本设计解决运行环境残留和跨任务文件、进程污染。普通 Docker 共享宿主机内核,
不提供虚拟机级恶意代码隔离;传入 job 的任何密钥仍可能被该 job 读取。
首期只接无长期密钥的 Linux CI,不建设模型网关,不迁移发布、自动修复、
真实模型 E2E 或需要 Docker daemon 的任务。
## 架构
```text
外部 CI 专用 ECS
├── systemd 托管的管理程序(长期运行,可信)
│ ├── 使用宿主机 PAT 申请 JIT runner 配置
│ ├── 启动、监控、删除容器及其独立网络
│ └── 保存管理状态、导出日志、恢复异常任务
├── 槽位 1:容器 A → 一个 job → 删除 → 容器 C
└── 槽位 2:容器 B → 一个 job → 删除 → 容器 D
每个容器
├── 一个 JIT runner,主动连接 GitHub
├── Node.js 22、Git 和 CI 依赖
└── 独立的 HOME、工作目录、临时目录、npm 缓存
```
管理程序按配置维护固定数量的槽位,每个槽位最多一个容器;杭州十槽位试验同时使用
十个槽位,hz4 部署目前维持五个槽位。不接 webhook、不自行调度 PR、不引入 Kubernetes 或数据库。
GitHub 根据 runner 标签分配任务。每台 ECS 各运行一个管理程序,只连接本机
Docker socket,使用独立的 `poolId` 和按主机配置的 `slots`。各主机共用调度
标签,无需中央管理器。新名称使用 `ecs-qwen-` 前缀以兼容 CI 并发上限和超时;
恢复时仍接受旧的 `qwen-` 身份。
启用 systemd 服务后开机启动,意外退出时十秒后重启(`Restart=always`);
显式停止服务后保持停止。它监督进程退出,不检测进程存活但卡住的情况。
镜像由可信构建生成并固定 digest。管理程序和镜像只能从经过维护者控制的版本
部署,不从任务 checkout 或 PR artifacts 更新。
认证使用专用 PAT,以 `GH_TOKEN` 写入仅 root 可读的 systemd EnvironmentFile:
`/etc/qwen-ephemeral-runner/credentials.env`。fine-grained token 应只选择目标
仓库及 GitHub API 要求的 Administration: write;它不只限于 runner 操作。
外部协作者身份或组织策略可能使 fine-grained token 不可用,改用权限更宽的
classic token 必须明确决定。PAT 留在宿主机,不进入配置 JSON 或任务容器。
宿主机 root 和 Docker 管理员仍属于可信边界。到期前替换凭证文件并重启服务;
仓库级 runner 不需要 GitHub App 或组织级 runner 管理权限。
## 所有权和生命周期
| 对象 | 所有者 | 生命周期 |
| ---------------------------- | ------------ | ----------------------------------- |
| ECS、Docker daemon、管理程序 | 运维/维护者 | 长期 |
| 宿主机 runner 管理 PAT | 管理程序 | 不进入任务容器 |
| 干净镜像 | 可信镜像构建 | 版本化、不可由 job 回写 |
| JIT 配置、runner 身份 | 单个容器实例 | 只用于一个 job |
| 容器可写层和任务数据 | 单个容器实例 | 销毁时删除 |
| Docker bridge 网络 | 单个容器实例 | 销毁时删除 |
| 诊断日志 | 管理程序导出 | 有大小和保留期限,不被后续 job 读取 |
正常路径:申请 JIT → 创建独立网络和容器 → runner 上线 → 执行一个 job →
runner 退出 → 导出日志 → 删除容器与网络 → 清理残留注册 → 创建下一份。
JIT 提供正常协议下的单任务注册语义;宿主机管理程序负责实际销毁。
不能依赖恶意任务自行退出、完成最后一个 cleanup step 或保持 runner 完整。
容器外 watchdog 强制限制最长存活时间,管理程序重启时先对账旧资源。
容器删除失败时停止对应槽位;GitHub 注册清理失败时记录并重试,不继续无限
申请新身份。宿主机故障期间无法即时清理,恢复后必须先回收再接新任务。
## 最少但必要的部署边界
- 使用外部 CI 专用 ECS;不与持有长期密钥的现有 runner 共用主机。
- 不挂载宿主机 home、工作区、管理状态、凭证目录、Docker socket;不使用
privileged、host PID 或 host network。容器内无需逐命令白名单。
- 镜像不声明持久 VOLUME,首期不创建任务卷;全部任务写入容器可写层。
- 宿主机外/容器无法修改的网络策略阻止访问管理接口、敏感内网、实例凭证和
元数据;配置实际需要的 GitHub、npm、镜像服务出口。
- 旧可信 runner 的访问权必须在 GitHub 侧检查。标签和 PR 可修改的 YAML 都
不是授权边界;外部 PR 不得通过修改 runs-on 绕回敏感池。
- 不因启用自托管 runner 而开放 fork secrets,也不引入执行 PR 代码的
pull_request_target 特权流程。
## 首期路由决策
保留现有 `ubuntu_runner` 输出及其所有旧语义,新增 `pr_test_runner` 输出,
用于 `test_build` 和 `test`。对于非可信作者的外部 fork PR,变量
`EXTERNAL_PR_EPHEMERAL_RUNNER_ENABLED=true` 选择一次性池;未设置、`false`
或其他值选择 `ubuntu-latest`。`lint_runner` 在 `lint_and_static` 和
`integration_no_ak` 中遵循同一个开关,替代这些 fork 任务原有的共享 ECS
回退行为。可信作者和同仓库 PR 路由不变。全局 kill-switch 和 hosted 设置优先。
开关在 job 求值时选择目标,不查询 runner 是否空闲,也不能迁移已排队任务;
回滚时取消已排队任务并启动新的 workflow run。上线前用实际 shell 选择器
验证未设置/false/非法值、开启时的两种架构,以及全局覆盖开关。
`classify_pr`、`web_shell_e2e_smoke`、`integration_cli`、其他 workflow
保持当前路由。这样不把新镜像尚未验证的浏览器系统依赖一起迁入,也不改变
现有分类 job 与其他 workflow 的路由镜像关系。
新池标签为 `ecs-qwen-ephemeral`。默认路由使用 x64,设置变量
`EXTERNAL_PR_EPHEMERAL_ARCH=arm64` 可改用 ARM64;完整 PR CI 在新镜像上的
工具链仍须验证。
## 依据
- [GitHub ephemeral runner 语义与清理责任](https://docs.github.com/en/actions/reference/runners/self-hosted-runners#ephemeral-runners-for-autoscaling)
- [GitHub 仓库级 JIT API](https://docs.github.com/en/rest/actions/self-hosted-runners#create-configuration-for-a-just-in-time-runner-for-a-repository)
- [Docker 的 daemon 与内核安全边界](https://docs.docker.com/engine/security/)
本设计采用的事实已在本次讨论中核对官方文档;部署时再次确认实际 runner
版本、GitHub 账户能力、API 权限和 ECS 网络配置。
@@ -0,0 +1,165 @@
# Combining ephemeral runners with a model proxy
[English](2026-11-07-ephemeral-runner-model-proxy.md) | [简体中文](2026-11-07-ephemeral-runner-model-proxy.zh-CN.md)
Date: 2026-11-07. Status: proposal and partial verification complete; the
combined service is neither implemented, deployed nor enabled.
## Goals and scope
When CI for an external PR needs a real model, every ephemeral runner uses its
own short-lived, restricted proxy credential. The real model AK goes only to the
trusted model proxy and never into a runner container. After the runner finishes
one job, the management service reclaims the container and revokes the
credential. Existing keyless jobs need no change.
The existing triage/verify execution model is unchanged, no external PR gains
unrestricted model access by default, and Docker is not claimed to be
virtual-machine isolation from malicious code. This proposal provides neither
release capability, model-output trustworthiness, nor prompt-injection
protection.
## Current code and verification
- `scripts/ci/ephemeral-runner/manager.mjs` creates a single-job JIT runner for
each fixed slot, gives every container its own bridge, and replenishes after
reclamation. Model-proxy integration does not exist yet.
- `triage`, `tmux-testing` and `verify` in `.github/workflows/qwen-triage.yml` each embed
a model proxy: it listens on host loopback, validates requests with a random
bearer, substitutes the real AK, forwards only `POST .../chat/completions`,
and supports streaming responses and an idle timeout. These are per-workflow
temporary processes, not standalone gateways.
- Using the proxy extracted from the existing `verify` workflow plus a fake
upstream, we verified that a wrong token returns 401, other paths return 403,
a correct token is forwarded with the upstream receiving only the fake real
AK, and streamed data is returned. Two correct requests both succeed: the
current implementation has no per-request revocation.
- Five selected existing triage tests pass, covering authentication and the
streaming idle timeout for the `tmux-testing` and `verify` lanes; the
`triage` lane embeds the same proxy pattern and is in the extraction scope
below.
- On Hangzhou hz4, with a temporary Docker bridge and a single restricted test
container, we verified that `127.0.0.1` inside the container cannot reach a
loopback service on the host. Test resources were cleaned up. This does **not
prove** that a container cannot reach the host bridge address or other host
services.
- No real model AK was used; an actual proxy deployment, credential issuance
and revocation, quotas, model jobs for
external PRs, and production network isolation were not verified.
## Trust boundary
```text
GitHub job queue
↓ dispatched to a labelled ephemeral runner
External-CI dedicated ECS Trusted proxy service environment
runner management service ──protected issue/revoke──→ model proxy admin API
│ │
└─ runner container ──short-lived token──────→ model endpoint ──real AK──→ upstream
```
The model proxy is preferably deployed on a separate trusted host or service;
the real AK does not live on the runner ECS. The host management service holds
the runner-management PAT and the admin credential for the proxy admin API;
neither is mounted, injected into, or exposed to runner containers. The admin
API and the model endpoint are authorized separately and restricted by TLS and
network policy; containers cannot reach the issue/revoke endpoints, the
management service, the Docker socket, sensitive intranets, or ECS metadata.
Runners are allowed to reach only the model endpoint and the CI egress they
need.
Containers have sudo, so isolating the real AK from the container's root/node
users cannot be relied on. Plain Docker shares the host kernel; untrusted code
still needs a dedicated host, with virtual-machine isolation considered
according to risk. Network policy is enforced by the host or external network
devices that the container cannot modify, and is verified in practice.
## Credential and slot lifecycle
The first version issues credentials per **runner instance**, rather than
pretending to issue per PR or per job: the management service does not yet know
which job GitHub will assign when it creates a runner. Model capability needs
its own dedicated runner label and runner group, used only by explicitly
permitted model-test workflows; model tokens must not be injected into the
default keyless runner pool. Runner group permissions on the GitHub side must be
verified: labels or workflow YAML in a PR are not an authorization boundary.
State machine for each instance:
1. Generate a non-reusable instance ID. Request a random token through the
protected admin API, setting model, request size, concurrency, call rate,
total volume and an absolute expiry. Store on the proxy only what validation
needs; persistent state and logs never record the plaintext token.
2. Deliver the proxy address and token to the corresponding new container; the
management service itself must not hand management credentials or the
upstream AK to a container. Supply them as `OPENAI_BASE_URL` and
`OPENAI_API_KEY` for compatible tests. The token is considered public to that
job; log redaction is not treated as a security boundary.
3. An idle runner still consumes its credential's lifetime. In the first
version the credential lifetime covers the instance's maximum lifetime, and
an instance left idle too long is reclaimed together with its runner; no
fixed remaining time at job start can be promised.
4. Normal exit, failure, cancellation, timeout and restart recovery all revoke
the token, delete the container and network, and clean up the GitHub
registration. A new instance gets a new token; old tokens are rejected.
5. When issuance is uncertain, do not create a container that can reach the
model; when revocation fails, stop replenishing that slot and retry, with the
absolute expiry as the final backstop. After a management-service or proxy
restart, reconcile first: the restart must not lose the revocation state or
reset budgets for already-issued tokens.
Any script in the container can use or leak the token and may incur cost before
revocation; environment variables, container metadata, logs and artifacts are
all untrusted for that job. Credential capability must therefore be limited,
and the whole model pool or proxy endpoint must be switchable off immediately.
## Proxy features and budget
Extract a testable shared implementation from the existing forwarding logic and
preserve the behaviour of the three triage lanes; the new standalone service adds
credential issuance, revocation, persistent state, statistics and limits
separately, without changing the permissions or triggers of the existing
workflows. Allow only configured upstream targets, models and exact API paths;
requests must not control the upstream URL or pass an upstream authorization
header. Bound request body size, timeout and concurrency, keep streamed
disconnect handling, and prevent a client from triggering arbitrary address
access or unbounded resource use.
Set per-instance request counts, rates, concurrency and input/output limits, plus
a shared budget across all instances and a hard quota or alert on the upstream
account. Concurrent debits must be atomic and over-limit requests rejected.
Request-count and output-parameter caps are not an exact cost cap; a streamed
response may still incur cost after disconnect. An exact cost cap depends on the
model provider's metering and quota capability and must be verified once the
upstream is chosen. Do not log plaintext credentials, complete prompts or
responses unless a concrete later requirement and data-handling policy exist.
## Implementation and acceptance
1. Determine which model tests actually need external-PR execution, and which
upstream model and budget they use; by default open exactly one controlled
test workflow and do not change the existing `integration_no_ak` or other CI
routing.
2. Extract and test the HTTP/streaming core of the existing proxy, preserving
both triage lanes. Implement the standalone proxy's admin API, separate
credentials, permissions, persistence, expiry, revocation and budget.
3. Add optional model-pool lifecycle to the management service; fail closed when
issuance or cleanup is uncertain. Cover token injection, log redaction,
abnormal recovery and old-token invalidation in tests.
4. In an isolated test environment with a fake upstream, one slot and a
controlled private repository, verify two consecutive jobs: the two tokens
differ; normal and streamed calls succeed; wrong, old, revoked and expired
tokens are rejected; over-limit requests, concurrency races, upstream
failures, and management-service/proxy restarts do not bypass the limits.
5. From inside a job, verify that the real AK, the runner-management PAT and the proxy
admin credential are unreadable, and that metadata, the Docker socket, other
containers and host management services are unreachable; verify that the
proxy can reach only the permitted upstream. Both container-cleanup and
credential-revocation failures must stop slot replenishment.
6. Prepare isolated dedicated ECS capacity and proxy services, and measure the
network and GitHub authorization, then run a one-slot trial with a low-budget
real model credential. Only after it passes does a maintainer decide whether
to enable the corresponding model-test routing.
The existing runner design is described in
[the ephemeral runner design](2026-11-07-ephemeral-container-runners.md).
@@ -0,0 +1,116 @@
# 一次性 runner 与模型代理的结合方案
[English](2026-11-07-ephemeral-runner-model-proxy.md) | [简体中文](2026-11-07-ephemeral-runner-model-proxy.zh-CN.md)
日期:2026-11-07。状态:方案与局部验证完成;尚未实现组合服务、部署或启用。
## 目标与范围
外部 PR 的 CI 需要调用真实模型时,每个一次性 runner 使用独立、短期、受限的
代理凭证。真实模型 AK 只交给可信模型代理,不进入 runner 容器。runner 执行
一个 job 后,管理服务回收容器并撤销该凭证。原有无 AK 的 job 不需要改变。
不改变现有 triage/verify 的运行方式,不让所有外部 PR 默认获得无限制模型
访问,不承诺 Docker 是对恶意代码的虚拟机级隔离。本方案不提供发布、模型输出
可信性或 prompt 注入防护。
## 当前代码与验证
- `scripts/ci/ephemeral-runner/manager.mjs` 为固定槽位创建单 job JIT runner,
每个容器使用单独的 bridge,回收完毕后再补充。当前没有模型代理集成。
- `.github/workflows/qwen-triage.yml` 的 `triage`、`tmux-testing` 与 `verify` 各有一份
内嵌的模型代理:监听本机 loopback,以随机 bearer 校验请求,替换为真实 AK,
只转发 `POST .../chat/completions`,支持流式响应与空闲超时。它们是各自
workflow 的临时进程,不是独立网关。
- 使用从现有 `verify` workflow 提取的代理和假上游,验证了错误 token 返回
401、其他路径返回 403、正确 token 转发且上游只收到假真实 AK,以及流式
数据返回。两个正确请求都能成功:现有实现没有按请求数撤销 token 的行为。
- 现有 triage 测试中选中的 5 项通过,包含认证,以及 `tmux-testing` 与 `verify`
两条 lane 的流式空闲超时;`triage` lane 使用同一套代理形态,也纳入下述提取范围。
- 在杭州 hz4 通过一个临时 Docker bridge 和单个受限测试容器验证:容器内
`127.0.0.1` 不能访问宿主机的 loopback 服务。测试资源已清理。这**不证明**
容器不能访问宿主机 bridge 地址或其他宿主机服务。
- 未使用真实模型 AK;未验证实际代理部署、凭证签发与
撤销、配额、外部 PR 的模型 job、或生产网络隔离。
## 信任边界
```text
GitHub job 队列
↓ 分派到带标签的一次性 runner
外部 CI 专用 ECS 可信代理服务环境
runner 管理服务 ──受保护的签发/撤销──→ 模型代理管理接口
│ │
└─ runner 容器 ──短期 token────→ 模型调用接口 ──真实 AK──→ 上游
```
模型代理优先部署在独立可信主机或服务,真实 AK 不放在 runner ECS。宿主机
管理服务保存 runner 管理 PAT 和调用代理管理接口的管理凭证;这些都不挂载、
注入或暴露给 runner 容器。管理接口与模型调用接口分开授权,并通过 TLS 和
网络策略限制来源;容器不能访问签发、撤销接口、管理服务、Docker socket、
敏感内网和 ECS 元数据。runner 仅获准访问模型调用接口及必要 CI 出口。
容器有 sudo,不能依赖容器内 root/node 用户隔离真实 AK。普通 Docker 共享
宿主机内核;涉及不可信代码时仍需专用主机,按风险考虑虚拟机级隔离。网络
策略由容器不能修改的宿主机或外部网络设备执行并实际验证。
## 凭证和槽位生命周期
第一版按 **runner 实例**签发,而不是假装按 PR 或 job 签发:管理服务创建
runner 时还不知道 GitHub 将分配哪个 job。模型能力需使用独立的专用 runner
标签与 runner group,只供经明确允许的模型测试 workflow 使用;不要把模型
token 注入默认无 AK 的 runner 池。GitHub 侧 runner group 权限必须经过核验,
标签或 PR 中的 workflow YAML 不是授权边界。
每个实例的状态机:
1. 生成不可复用的实例 ID。通过受保护的管理接口申请随机 token,设置模型、
请求大小、并发、调用速率、总量及绝对过期时间。只在代理端保存验证所需
信息;持久状态和日志不记录明文 token。
2. 将 proxy 地址和 token 交付给对应新容器;管理服务自身不能将管理凭证或
上游 AK 交给容器。以 `OPENAI_BASE_URL`、`OPENAI_API_KEY` 供兼容的测试
使用,token 对这个 job 视为已公开,不依赖日志脱敏作为安全边界。
3. runner 空闲时也占用凭证有效期。第一版凭证有效期覆盖实例最长生命周期,
空闲太久后连 runner 一起回收;不能承诺任务开始时仍有固定剩余时间。
4. 正常退出、失败、取消、超时或重启恢复均撤销 token、删除容器和网络、
清理 GitHub 注册。新实例获得新 token;旧 token 被拒绝。
5. 签发结果不确定时,不创建可访问模型的容器;撤销失败时停止对应槽位补充
并重试,凭证绝对过期作为最终兜底。管理服务/代理重启后应先对账,不能
因重启丢失已签发 token 的撤销状态或重置预算。
容器中任何脚本都能使用或泄露 token,且撤销前可能产生费用;环境变量、
容器元数据、日志和 artifact 都不能被视作对该 job 保密。必须限制凭证能力,
并允许立即整体关闭模型池或代理调用接口。
## 代理功能与预算
从现有转发逻辑提取可测试的共享实现,保持已有三条 triage lane 行为;
新的独立服务另加凭证签发、吊销、持久状态、统计和限额,不直接修改既有
workflow 的权限或触发方式。只允许配置好的上游目标、模型和确切 API 路径,
不得让请求控制上游 URL 或传递上游授权头;限制请求体大小、超时和并发,
保留流式断连处理,避免客户端触发任意地址访问或无限资源消耗。
按实例设置请求数、速率、并发和输入/输出上限,并设置所有实例共享的总预算
及上游账户侧的硬额度或告警。并发扣额需要原子处理,拒绝超额请求。请求数
和输出参数上限并不等于精确费用上限;流式响应可能在断连后仍产生费用。
精确成本上限依赖模型供应方的计量/限额能力,应在选定上游后验证。不要记录
明文凭证、完整 prompt 或回复,除非后续有明确需求和数据处理策略。
## 实施和验收
1. 确定哪些模型测试确实需要外部 PR 执行、上游模型及预算;默认只开放一条
受控测试 workflow,先不要改变现有 `integration_no_ak` 或其他 CI 路由。
2. 提取并测试现有 proxy 的 HTTP/流式核心,保留三个 triage lane 行为。实现
独立代理的管理接口、独立凭证、权限、持久化、过期、撤销和预算。
3. 为管理服务增加可选的模型池生命周期;签发或清理不确定时 fail closed。
明确 token 注入、日志脱敏、异常恢复及旧 token 失效的测试。
4. 在独立测试环境使用假上游、1 个槽位及受控私有仓库,验证两次连续 job:
两个 token 不同;正常和流式调用通过;错误、旧、撤销和过期 token 被拒;
超额、并发竞争、上游故障、管理服务/代理重启不绕过限制。
5. 从 job 内验证不能读到真实 AK、runner 管理 PAT、代理管理凭证,也不能访问元数据、
Docker socket、其他容器和宿主机管理服务;验证代理只能访问允许的上游。
容器清理和凭证撤销失败均阻止槽位补充。
6. 准备隔离的专用 ECS 和代理服务、实测网络与 GitHub 授权,再用低预算真实
模型凭证进行一槽位试运行。通过后由维护者决定是否启用相应模型测试路由。
现有 runner 方案见 [一次性 runner 设计](2026-11-07-ephemeral-container-runners.zh-CN.md)。
+23 -3
View File
@@ -97,6 +97,14 @@ falls back to the workspace daemon client when it does not. That fallback is
required because the banner is most useful on the empty new-session screen,
where no session client has been created yet.
The dialog cannot reuse the sidebar's default source filter: that filter
excludes `sourceType: "channel"`, which is exactly the population that can fill
the cap. It also cannot read persisted history page by page, because closed
channel sessions can fill the first page and hide the live rows that still hold
slots. The recovery list therefore asks the workspace for its live bridge
sessions (`liveOnly`), the established sessions consuming the cap, and keeps
the default, channel, and qwen-live rows.
The banner is cleared when the session or workspace changes, or when the user
dismisses it. This path is a fallback for the remaining 503 cases (for example,
every slot is held by active work); it does not replace automatic reclamation.
@@ -132,9 +140,19 @@ every slot is held by active work); it does not replace automatic reclamation.
release from the empty new-session screen uses the workspace daemon client.
- `packages/web-shell/client/components/SessionLimitBanner.test.tsx`: the
banner exposes release and dismiss actions.
- `packages/web-shell/client/hooks/useScopedSessions.test.tsx`: the release
query can request live-only sessions without a source filter; the sidebar
default still applies the filter.
- `packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx`:
channel sessions are listed, qwen-live keeps its existing place, and agent
sessions stay out.
- `packages/cli/src/serve/multi-workspace-sessions.test.ts`: a live session
stays reachable when twenty newer closed channel sessions fill the persisted
page.
- Manual browser run on the built Web Shell against a daemon with
`--max-sessions 1`: submit a prompt, confirm the banner, open the release
dialog, release the idle session, and create a session successfully.
`--max-sessions 1`, filled by a real `sourceType: "channel"` session: submit
a prompt, confirm the banner, release the channel session, and create a
session successfully.
## 9. Acceptance Criteria
@@ -142,7 +160,9 @@ every slot is held by active work); it does not replace automatic reclamation.
channel event pump is subscribed.
2. A non-channel session with a live subscriber is not closed by the reaper.
3. A create rejected by the workspace cap produces a visible release action.
4. Releasing an idle session frees a slot and the next create succeeds.
4. Releasing an idle session, including a workspace filled only by channel
sessions, frees a slot and the next create succeeds even when newer closed
persisted history fills the first catalog page.
5. Existing reaper tests and web-shell type checks pass.
## 10. Follow-ups
@@ -88,6 +88,12 @@ workspace 创建失败时,`dispatchActionError` 现在把 `session_limit_excee
客户端。这个回退是必要的:横幅最常出现在空白的新会话页,此时还没有创建任何
会话客户端。
对话框不能复用侧栏默认的 source 过滤:该过滤会排除 `sourceType: "channel"`,
而这正是能占满名额的会话群体。它也不能按持久历史逐页读取:已关闭的渠道会话可能
占满首屏,把仍占用名额的活跃行挤出页面。因此恢复列表改为向工作区请求 live
bridge 会话(`liveOnly`)——这些是占用名额的已建立会话——再保留 default、
channel、qwen-live 三类行。
会话或工作区变化、或用户关闭横幅时,横幅清除。这条路径只作为剩余 503 场景的
兜底(例如所有名额都被活跃工作占用),不替代自动回收。
@@ -122,15 +128,23 @@ workspace 创建失败时,`dispatchActionError` 现在把 `session_limit_excee
释放时走工作区 daemon 客户端。
- `packages/web-shell/client/components/SessionLimitBanner.test.tsx`:横幅暴露
释放和关闭两个动作。
- 在构建版 Web Shell 上对 `--max-sessions 1` 的 daemon 做真实浏览器验证:
提交 prompt 出现横幅、打开释放对话框、释放空闲会话、再次创建成功。
- `packages/web-shell/client/hooks/useScopedSessions.test.tsx`:释放查询可以在
不带 source 过滤的情况下请求 live-only 会话;侧栏默认仍应用过滤。
- `packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx`:
渠道会话会被列出,qwen-live 保留原有位置,agent 会话被排除。
- `packages/cli/src/serve/multi-workspace-sessions.test.ts`:20 条更新的已关闭
渠道会话占满持久页时,活跃会话仍然可被列出。
- 在构建版 Web Shell 上对 `--max-sessions 1` 的 daemon 做真实浏览器验证:用
真实 `sourceType: "channel"` 会话占满名额,提交 prompt 出现横幅、释放该
渠道会话、再次创建成功。
## 9. 验收标准
1. 渠道会话空闲超过 `sessionIdleTimeoutMs` 后,即使渠道事件泵仍订阅,也会被关闭。
2. 带 live 订阅者的非渠道会话不会被 reaper 关闭。
3. 因工作区上限被拒绝的创建会显示可见的释放入口。
4. 释放一条空闲会话后名额释放,下一次创建成功。
4. 释放一条空闲会话(包括工作区只被渠道会话占满、且更新的已关闭持久历史占满
目录首屏的情况)后名额释放,下一次创建成功。
5. 现有 reaper 测试和 web-shell 类型检查通过。
## 10. 后续工作
+3
View File
@@ -3479,6 +3479,8 @@ curl http://127.0.0.1:4170/workspaces/<workspace-id>/sessions
When `workspace_qualified_rest_core` is advertised, workspace-scoped session batch operations, group CRUD, and session organization mutation are available under `/workspaces/:workspace/sessions/{delete,archive,unarchive}`, `/workspaces/:workspace/session-groups`, and `/workspaces/:workspace/session/:id/organization`. For an untrusted secondary, group GET remains available; every group, session, and organization mutation remains trust-gated. Workspace-less batch and organization mutation routes remain primary-workspace-only for compatibility.
The `liveOnly=1` list reads the workspace bridge instead of `chats/`, so closed persisted history cannot crowd live sessions out of the first page. It requires a trusted workspace and does not accept the persisted-only filters: combining it with `view`, `archiveState=archived`, `parentSessionId`, `sourceType`, or `sourceId` returns `400 { code: "invalid_session_live_only_filter" }`. Any value other than `1` returns `400 { code: "invalid_session_live_only" }`.
Query parameters:
| Field | Required | Notes |
@@ -3488,6 +3490,7 @@ Query parameters:
| `size` | no | Page size. Invalid values return `400 { code: "invalid_cursor" }` or the existing page-size validation. |
| `view` | no | Omit for the legacy recent list. `organized` opts into server-side pinned/group ordering and adds optional organization fields. Any other value returns `400 { code: "invalid_session_view" }`. |
| `group` | no | Only meaningful with `view=organized`. `all` (default), `pinned`, `ungrouped`, or a custom group id. Unknown group ids return `404 { code: "group_not_found" }`. |
| `liveOnly` | no | `1` lists only sessions currently live in the workspace bridge, ignoring persisted history. Requires a trusted workspace and rejects the incompatibilities described above. |
Response:
+4
View File
@@ -100,6 +100,10 @@ Everything saved is plain markdown — you can open, edit, or delete any file at
When rebuilding omits entries, the saved warning reports the dropped count and up to three names for each limit: the topic-file scan cap and the index-body budget. The counts stay separate because one grouped team entry can represent several files. The warning remains visible when the index is loaded into a prompt while it is the file's last paragraph. Insert new index pointers above the trailing warning to preserve it. Omission from the index does not delete the linked topic files. For index-body pressure, merge topic files or remove obsolete ones, or shorten their frontmatter names and descriptions. An overlong link already has no description: shorten its topic-file path or frontmatter name instead. Editing only the generated index does not survive the next rebuild. For the file-count scan cap, merge topic files or remove obsolete ones; editing only the generated index does not reduce the number of files scanned.
The warning for a partially loaded index distinguishes the line and character budgets. Shortening descriptions can help with the total character budget even when no entry exceeds the ordinary line length. For entries matching the generated format, that advice accounts for names and links, including grouped links; prune or merge entries when those links alone consume the budget. Hand-edited formats or grouped suffixes that could instead be description text receive possible remedies without a promise that shortening will fit. If the entry-count limit is exceeded, entries must still be pruned or merged: shortening a description does not reduce the number of entries. When both budgets are exceeded, reducing the index to 200 entries may still require shorter descriptions or fewer entries to meet the character budget; the warning reports the actual candidate count. Shortening to 150 units per entry is not always enough to meet the 25,000-unit total.
A bounded, single-paragraph trailing writer notice is excluded from prompt budgeting. When all saved entries fit, Qwen preserves that notice without claiming that more entries were omitted while loading. Regenerating a saturated index can refill space freed by shorter descriptions, so the size of the saved file need not shrink immediately. For project, user, and team memory, edit the topic files' `description` frontmatter and check the regenerated index. An empty description can fall back to the memory type, so shortening frontmatter does not guarantee that every entry will fit. Direct index-line edits can be replaced by the next managed write. Keep full link targets intact even when a link exceeds 150 units; shorten display metadata or prune or merge topic files instead of cutting links.
#### Pinned memory
Put hand-curated documents that automatic memory maintenance should preserve
@@ -9321,102 +9321,165 @@ describe('Hosted Harness no-tool session', () => {
).toBe(204);
});
it('retains cleanup debt after interrupted channel settlement until original-owner release succeeds', async () => {
const channelTurn = await prewriteChannelWriteInterruption(true);
mockBrokerBroker();
vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue({
state: 'settled',
});
const release = vi.mocked(HostedWorkspaceBroker.prototype.release);
release.mockRejectedValueOnce(new Error('release unavailable'));
const server = await app(true);
const loaded = await headers(
supertest(server).post(`/session/${SESSION_ID}/load`),
).send({
managedSessionStore: store(),
toolProfile: 'hosted-workspace-files/1',
});
expect(loaded.status).toBe(200);
const authorize = (request: supertest.Test) =>
headers(request).set('X-Qwen-Client-Id', loaded.body.clientId as string);
const readJournal = () =>
LocalJsonlManagedSessionJournalStore.read({
transcriptPath: path.join(state.root, `${SESSION_ID}.jsonl`),
runtimeBaseDir: state.root,
sessionKey: {
tenantId: 'tenant',
workspaceId: 'workspace',
sessionId: SESSION_ID,
},
it.each([false, true])(
'retains cleanup debt after interrupted channel settlement until original-owner release succeeds (retry refused: %s)',
async (retryRefused) => {
const channelTurn = await prewriteChannelWriteInterruption(true);
mockBrokerBroker();
vi.spyOn(HostedWorkspaceBroker.prototype, 'status').mockResolvedValue({
state: 'settled',
});
await vi.waitFor(
async () => {
expect(release).toHaveBeenCalledTimes(1);
const status = await authorize(
supertest(server).get(`/session/${SESSION_ID}/status`),
).expect(200);
expect(status.body.recoveryBlocked).toBe(true);
},
// #14101: the debt settle crosses fsync-backed journal commits; the
// default 1s waitFor window flaked on a loaded ubuntu-latest runner.
{ timeout: settleTimeoutMs() },
);
const parked = await readJournal();
const terminals = parked.events.filter(
(event) => event.kind === 'turn.settled',
);
expect(terminals).toHaveLength(1);
expect(terminals[0]!.payload['turnId']).toBe(channelTurn);
expect(
parked.events.filter((event) => event.kind === 'hosted.cleanup'),
).toHaveLength(1);
expect(release).toHaveBeenLastCalledWith({
bindingId: 'binding',
generation: '1',
});
expect(state.model).not.toHaveBeenCalled();
const nextPrompt = [{ type: 'text', text: 'must remain blocked' }];
await authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`))
.send({
prompt: nextPrompt,
promptId: randomUUID(),
payloadDigest: `sha256:${createHash('sha256').update(JSON.stringify(nextPrompt)).digest('hex')}`,
})
.expect(409);
expect((await readJournal()).events).toHaveLength(parked.events.length);
expect(state.model).not.toHaveBeenCalled();
const redriven = await headers(
supertest(server).post(`/session/${SESSION_ID}/load`),
).send({
managedSessionStore: store(),
passiveManagedRuntimeRecovery: true,
});
expect(redriven.status).toBe(200);
expect(redriven.body.clientId).toBe(loaded.body.clientId);
expect(release).toHaveBeenCalledTimes(2);
expect(release).toHaveBeenLastCalledWith({
bindingId: 'binding',
generation: '1',
});
const status = await authorize(
supertest(server).get(`/session/${SESSION_ID}/status`),
).expect(200);
expect(status.body.recoveryBlocked).toBe(false);
const journal = await readJournal();
const confirmed = journal.events.filter(
(event) =>
event.kind === 'hosted.cleanup' &&
event.payload['state'] === 'confirmed',
);
expect(confirmed).toHaveLength(1);
expect(confirmed[0]!.sequence).toBeGreaterThan(terminals[0]!.sequence);
expect(
journal.events.filter((event) => event.kind === 'turn.settled'),
).toHaveLength(1);
await authorize(supertest(server).delete(`/session/${SESSION_ID}`)).expect(
204,
);
});
const release = vi.mocked(HostedWorkspaceBroker.prototype.release);
let finishAutomaticRetry!: () => void;
const automaticRetry = new Promise<void>((resolve) => {
finishAutomaticRetry = resolve;
});
release.mockRejectedValueOnce(new Error('release unavailable'));
release.mockImplementationOnce(async () => {
await automaticRetry;
if (retryRefused) throw new Error('retry unavailable');
});
const kick = HostedMonitorWakeScheduler.prototype.kick;
// Drive the retry at a barrier so the load overlaps it without racing
// the scheduler's clock.
const kicks = vi
.spyOn(HostedMonitorWakeScheduler.prototype, 'kick')
.mockImplementation(() => undefined)
.mockImplementationOnce(function (this: HostedMonitorWakeScheduler) {
kick.call(this);
});
const server = await app(true);
const loaded = await headers(
supertest(server).post(`/session/${SESSION_ID}/load`),
).send({
managedSessionStore: store(),
toolProfile: 'hosted-workspace-files/1',
});
expect(loaded.status).toBe(200);
const authorize = (request: supertest.Test) =>
headers(request).set(
'X-Qwen-Client-Id',
loaded.body.clientId as string,
);
const readJournal = () =>
LocalJsonlManagedSessionJournalStore.read({
transcriptPath: path.join(state.root, `${SESSION_ID}.jsonl`),
runtimeBaseDir: state.root,
sessionKey: {
tenantId: 'tenant',
workspaceId: 'workspace',
sessionId: SESSION_ID,
},
});
await vi.waitFor(
async () => {
expect(release).toHaveBeenCalledTimes(1);
const status = await authorize(
supertest(server).get(`/session/${SESSION_ID}/status`),
).expect(200);
expect(status.body.recoveryBlocked).toBe(true);
},
// #14101: the debt settle crosses fsync-backed journal commits; the
// default 1s waitFor window flaked on a loaded ubuntu-latest runner.
{ timeout: settleTimeoutMs() },
);
const parked = await readJournal();
const terminals = parked.events.filter(
(event) => event.kind === 'turn.settled',
);
expect(terminals).toHaveLength(1);
expect(terminals[0]!.payload['turnId']).toBe(channelTurn);
expect(
parked.events.filter((event) => event.kind === 'hosted.cleanup'),
).toHaveLength(1);
expect(release).toHaveBeenLastCalledWith({
bindingId: 'binding',
generation: '1',
});
expect(state.model).not.toHaveBeenCalled();
const nextPrompt = [{ type: 'text', text: 'must remain blocked' }];
await authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`))
.send({
prompt: nextPrompt,
promptId: randomUUID(),
payloadDigest: `sha256:${createHash('sha256').update(JSON.stringify(nextPrompt)).digest('hex')}`,
})
.expect(409);
expect((await readJournal()).events).toHaveLength(parked.events.length);
expect(state.model).not.toHaveBeenCalled();
const redriven = await (async () => {
const wake = kicks.mock.contexts[0] as HostedMonitorWakeScheduler;
let initialPassFinished = false;
await wake.exclusive(async () => {
initialPassFinished = true;
}, settleTimeoutMs());
expect(initialPassFinished).toBe(true);
kick.call(wake);
const cleanup = await import('./hosted-turn-cleanup.js');
const debt = vi.spyOn(cleanup, 'hasHostedCleanupDebt');
try {
await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(2), {
timeout: settleTimeoutMs(),
});
debt.mockClear();
const managed = capturedWakeSession().managed;
const loading = headers(
supertest(server).post(`/session/${SESSION_ID}/load`),
)
.send({
managedSessionStore: store(),
passiveManagedRuntimeRecovery: true,
})
.then((response) => response);
await vi.waitFor(
() =>
expect(
debt.mock.calls.some(([session]) => session === managed),
).toBe(true),
{ timeout: settleTimeoutMs() },
);
finishAutomaticRetry();
return await loading;
} finally {
finishAutomaticRetry();
debt.mockRestore();
}
})();
expect(redriven.status).toBe(200);
expect(redriven.body.clientId).toBe(loaded.body.clientId);
expect(release).toHaveBeenCalledTimes(retryRefused ? 3 : 2);
expect(release).toHaveBeenLastCalledWith({
bindingId: 'binding',
generation: '1',
});
for (const [owner] of release.mock.calls) {
expect(owner).toEqual({ bindingId: 'binding', generation: '1' });
}
for (const broker of release.mock.contexts) {
expect((broker as HostedWorkspaceBroker).runtimeSessionId).toBe(
hostedRuntimeSessionId(channelTurn),
);
}
const status = await authorize(
supertest(server).get(`/session/${SESSION_ID}/status`),
).expect(200);
expect(status.body.recoveryBlocked).toBe(false);
const journal = await readJournal();
const confirmed = journal.events.filter(
(event) =>
event.kind === 'hosted.cleanup' &&
event.payload['state'] === 'confirmed',
);
expect(confirmed).toHaveLength(1);
expect(confirmed[0]!.sequence).toBeGreaterThan(terminals[0]!.sequence);
expect(
journal.events.filter((event) => event.kind === 'turn.settled'),
).toHaveLength(1);
await authorize(
supertest(server).delete(`/session/${SESSION_ID}`),
).expect(204);
},
);
it('crosses a durable store fault to 503 instead of mislabelling it as 400 (F10)', async () => {
const server = await app(true);
@@ -22179,6 +22242,7 @@ describe('Hosted Harness no-tool session', () => {
runtimeLeaseOwedByTerminalRoute?: boolean;
blocked: boolean;
cleanupBlocked?: boolean;
cleanupRetry?: Promise<void>;
hooksBusy?: boolean;
recoveredTurn?: string;
mcpClosing?: boolean;
@@ -22947,6 +23011,166 @@ describe('Hosted Harness no-tool session', () => {
},
);
it('rechecks an admitted prompt before running queued cleanup', async () => {
vi.spyOn(HostedMonitorWakeScheduler.prototype, 'kick').mockImplementation(
() => undefined,
);
const f = await recoveryFileSession();
await f.submit();
await f.idle();
await f.owe();
const releases = vi.mocked(HostedWorkspaceBroker.prototype.release);
releases.mockClear();
let finishRelease!: () => void;
const releaseBarrier = new Promise<void>((resolve) => {
finishRelease = resolve;
});
releases.mockImplementationOnce(async () => {
await releaseBarrier;
throw new Error('release unavailable');
});
const load = () =>
headers(supertest(f.server).post(`/session/${SESSION_ID}/load`))
.send({
managedSessionStore: store(),
passiveManagedRuntimeRecovery: true,
})
.then((response) => response);
const first = load();
let second: ReturnType<typeof load> | undefined;
let finishPrompt: (() => void) | undefined;
try {
await vi.waitFor(() => expect(releases).toHaveBeenCalledOnce(), {
timeout: settleTimeoutMs(),
});
const firstRetry = f.resident.cleanupRetry;
expect(firstRetry).toBeDefined();
second = load();
await vi.waitFor(
() => {
expect(f.resident.cleanupRetry).toBeDefined();
expect(f.resident.cleanupRetry).not.toBe(firstRetry);
},
{ timeout: settleTimeoutMs() },
);
state.model.mockImplementationOnce(
({ signal }) =>
new Promise((resolve) => {
finishPrompt = () =>
resolve({ text: 'later', model: 'test-model' });
signal.addEventListener('abort', finishPrompt, { once: true });
}),
);
const prompt = [{ type: 'text', text: 'later active turn' }];
await f
.authorize(supertest(f.server).post(`/session/${SESSION_ID}/prompt`))
.send({
prompt,
promptId: randomUUID(),
payloadDigest: `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`,
})
.expect(202);
await vi.waitFor(() => expect(state.model).toHaveBeenCalledTimes(2), {
timeout: settleTimeoutMs(),
});
finishRelease();
expect((await first).status).toBe(200);
expect((await second).status).toBe(200);
expect(releases).toHaveBeenCalledOnce();
expect((await f.status()).body.hasActivePrompt).toBe(true);
expect(f.resident.cleanupBlocked).toBe(true);
expect(f.resident.cleanupRetry).toBeUndefined();
} finally {
finishRelease();
finishPrompt?.();
await Promise.all([first, second]);
await f.idle();
await f
.authorize(supertest(f.server).delete(`/session/${SESSION_ID}`))
.expect(204);
}
});
it('keeps a queued cleanup retry owned until the last caller settles', async () => {
vi.spyOn(HostedMonitorWakeScheduler.prototype, 'kick').mockImplementation(
() => undefined,
);
const f = await recoveryFileSession();
await f.submit();
await f.idle();
await f.owe();
const releases = vi.mocked(HostedWorkspaceBroker.prototype.release);
releases.mockClear();
let finishFirst!: () => void;
let finishSecond!: () => void;
const firstBarrier = new Promise<void>((resolve) => {
finishFirst = resolve;
});
const secondBarrier = new Promise<void>((resolve) => {
finishSecond = resolve;
});
releases.mockImplementationOnce(async () => {
await firstBarrier;
throw new Error('release unavailable');
});
releases.mockImplementationOnce(async () => {
await secondBarrier;
});
const load = () =>
headers(supertest(f.server).post(`/session/${SESSION_ID}/load`))
.send({
managedSessionStore: store(),
passiveManagedRuntimeRecovery: true,
})
.then((response) => response);
const first = load();
let second: ReturnType<typeof load> | undefined;
let third: ReturnType<typeof load> | undefined;
try {
await vi.waitFor(() => expect(releases).toHaveBeenCalledOnce(), {
timeout: settleTimeoutMs(),
});
const firstRetry = f.resident.cleanupRetry;
expect(firstRetry).toBeDefined();
second = load();
await vi.waitFor(
() => {
expect(f.resident.cleanupRetry).toBeDefined();
expect(f.resident.cleanupRetry).not.toBe(firstRetry);
},
{ timeout: settleTimeoutMs() },
);
const secondRetry = f.resident.cleanupRetry;
finishFirst();
await vi.waitFor(() => expect(releases).toHaveBeenCalledTimes(2), {
timeout: settleTimeoutMs(),
});
expect(f.resident.cleanupRetry).toBe(secondRetry);
third = load();
await vi.waitFor(
() => {
expect(f.resident.cleanupRetry).toBeDefined();
expect(f.resident.cleanupRetry).not.toBe(secondRetry);
},
{ timeout: settleTimeoutMs() },
);
finishSecond();
for (const response of await Promise.all([first, second, third])) {
expect(response.status).toBe(200);
}
expect(releases).toHaveBeenCalledTimes(2);
expect(f.resident.cleanupRetry).toBeUndefined();
expect((await f.status()).body.recoveryBlocked).toBe(false);
} finally {
finishFirst();
finishSecond();
await Promise.all([first, second, third]);
await f
.authorize(supertest(f.server).delete(`/session/${SESSION_ID}`))
.expect(204);
}
});
it('inspects original recovery evidence and rechecks only terminal cleanup', async () => {
const f = await recoveryFileSession();
await f.submit();
@@ -762,6 +762,7 @@ interface HostedSession {
lifecycleKind?: 'close' | 'delete';
cleanupBlocked?: boolean;
cleanupHolds?: Map<string, HostedCleanupHold>;
cleanupRetry?: Promise<void>;
recoveryCleanupOnly?: boolean;
lifecycleProtocolVersion?: 1 | 2;
/** Fetched Workspace instructions; undefined until the first fetch. */
@@ -2581,30 +2582,45 @@ async function retryHostedCleanup(
drain = false,
): Promise<void> {
if (!drain && unsettledPromptId(session)) return;
// A load can overlap the wake pump's retry. Re-read the debt with each
// caller's authority after the previous attempt settles.
const retry = (session.cleanupRetry ?? Promise.resolve())
.catch(() => undefined)
.then(async () => {
if (!drain && unsettledPromptId(session)) return;
try {
session.cleanupHolds?.clear();
if (hasHostedCleanupDebt(session.managed)) {
// Unsettled wake turns need their recovery pump to commit the terminal
// record before reconciliation can release their Runtime.
retainHostedCleanupHolds(
session,
await reconcileHostedTurnCleanup(
session.managed,
options,
undefined,
drain,
),
);
}
if (session.cleanupBlocked) liftRecoveryBlock(session);
session.cleanupBlocked = false;
} catch (cause) {
if (!session.blocked) {
session.cleanupBlocked = true;
session.blocked = true;
session.monitorWake?.kick();
}
throw cause;
}
});
session.cleanupRetry = retry;
try {
session.cleanupHolds?.clear();
if (hasHostedCleanupDebt(session.managed)) {
// Unsettled wake turns need their recovery pump to commit the terminal
// record before reconciliation can release their Runtime.
retainHostedCleanupHolds(
session,
await reconcileHostedTurnCleanup(
session.managed,
options,
undefined,
drain,
),
);
await retry;
} finally {
if (session.cleanupRetry === retry) {
session.cleanupRetry = undefined;
}
if (session.cleanupBlocked) liftRecoveryBlock(session);
session.cleanupBlocked = false;
} catch (cause) {
if (!session.blocked) {
session.cleanupBlocked = true;
session.blocked = true;
session.monitorWake?.kick();
}
throw cause;
}
}
@@ -6478,6 +6478,88 @@ describe('multi-workspace session dispatch', () => {
});
});
it('lists live sessions when closed channel history fills the persisted page', async () => {
await withRuntimeDir(async () => {
const liveSessionId = '550e8400-e29b-41d4-a716-4466554401a0';
await writeStoredSession({
sessionId: liveSessionId,
cwd: SECONDARY_CWD,
timestamp: '2026-07-01T00:00:00.000Z',
prompt: 'still live',
mtime: new Date('2026-07-01T00:00:00.000Z'),
});
for (let index = 0; index < 20; index += 1) {
const timestamp = `2026-07-08T00:${String(index).padStart(2, '0')}:00.000Z`;
await writeStoredSession({
sessionId: `550e8400-e29b-41d4-a716-4466554402${index
.toString(16)
.padStart(2, '0')}`,
cwd: SECONDARY_CWD,
timestamp,
prompt: `closed channel ${index}`,
mtime: new Date(timestamp),
sourceType: 'channel',
});
}
const { app } = makeHarness({
secondarySummaries: [
makeSummary(liveSessionId, SECONDARY_CWD, {
clientCount: 1,
updatedAt: '2026-07-01T00:01:00.000Z',
}),
],
});
const persistedPage = await request(app)
.get('/workspaces/secondary-id/sessions?size=20')
.set('Host', host())
.expect(200);
expect(persistedPage.body.sessions).toHaveLength(20);
expect(
persistedPage.body.sessions.map(
(session: { sessionId: string }) => session.sessionId,
),
).not.toContain(liveSessionId);
const livePage = await request(app)
.get('/workspaces/secondary-id/sessions?liveOnly=1')
.set('Host', host())
.expect(200);
expect(
livePage.body.sessions.map(
(session: { sessionId: string }) => session.sessionId,
),
).toEqual([liveSessionId]);
expect(livePage.body.sessions[0]).toMatchObject({
clientCount: 1,
displayName: liveSessionId,
});
});
});
it('rejects liveOnly combined with persisted-only filters or untrusted workspaces', async () => {
await withRuntimeDir(async () => {
const trusted = makeHarness();
const filtered = await request(trusted.app)
.get('/workspaces/secondary-id/sessions?liveOnly=1&view=organized')
.set('Host', host())
.expect(400);
expect(filtered.body.code).toBe('invalid_session_live_only_filter');
const invalid = await request(trusted.app)
.get('/workspaces/secondary-id/sessions?liveOnly=0')
.set('Host', host())
.expect(400);
expect(invalid.body.code).toBe('invalid_session_live_only');
const untrusted = makeHarness({ secondaryTrusted: false });
await request(untrusted.app)
.get('/workspaces/secondary-id/sessions?liveOnly=1')
.set('Host', host())
.expect(403);
});
});
it('exports the selected trusted workspace without falling back or starting ACP', async () => {
await withRuntimeDir(async () => {
const sessionId = '550e8400-e29b-41d4-a716-446655440280';
+46 -8
View File
@@ -10111,6 +10111,31 @@ export function registerSessionRoutes(
});
return;
}
const rawLiveOnly = req.query['liveOnly'];
let liveOnly = false;
if (rawLiveOnly !== undefined) {
if (rawLiveOnly !== '1') {
res.status(400).json({
error: '`liveOnly` must be "1"',
code: 'invalid_session_live_only',
});
return;
}
if (
view !== undefined ||
archiveState === 'archived' ||
parentSessionId !== undefined ||
parsedSource.sourceType !== undefined
) {
res.status(400).json({
error:
'`liveOnly` cannot be combined with organized view, archived state, parent, or source filters',
code: 'invalid_session_live_only_filter',
});
return;
}
liveOnly = true;
}
const options = {
...(cursor !== undefined ? { cursor } : {}),
...(size !== undefined ? { size } : {}),
@@ -10133,22 +10158,35 @@ export function registerSessionRoutes(
res.once('close', onResponseClosed);
if (req.aborted || res.destroyed) onRequestAborted();
try {
if (liveOnly && !runtime.trusted) {
res.status(403).json({
error: '`liveOnly` requires a trusted workspace',
code: 'untrusted_workspace',
});
return;
}
// Organized/archived views always need the persisted store: organized
// cursors are opaque (non-numeric) and archived-only workspaces have no
// active persisted sessions, so the live-only fallback would drop them.
// Metadata filters gather the whole workspace
// (persisted + live) to filter completely and paginates with an opaque
// activity cursor, so the numeric-cursor live fallback can't serve it.
// `liveOnly` bypasses the persisted store on purpose: its callers need
// the sessions holding slots, not a page of closed history.
const usePersisted =
readOnlySecondary ||
runtime.primary ||
view === 'organized' ||
archiveState === 'archived' ||
parentSessionId !== undefined ||
parsedSource.sourceType !== undefined ||
(cursor !== undefined && cursor !== ''
? isPersistedSessionCursor(cursor)
: await hasActivePersistedSessions(runtime, controller.signal));
(!liveOnly &&
(runtime.primary ||
view === 'organized' ||
archiveState === 'archived' ||
parentSessionId !== undefined ||
parsedSource.sourceType !== undefined ||
(cursor !== undefined && cursor !== ''
? isPersistedSessionCursor(cursor)
: await hasActivePersistedSessions(
runtime,
controller.signal,
))));
// The live path only reads cursor/size; persisted-only options
// (organized view or archived state) would be silently dropped there.
// usePersisted already routes those to the persisted path — assert it so
@@ -20107,6 +20107,35 @@ describe('useLlmStream', () => {
expect(infoMessages).toHaveLength(0);
});
it('should prefer a provider refusal message', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerLlmEventType.Finished,
value: {
reason: 'SAFETY',
usageMetadata: undefined,
finishMessage:
'Response refused due to cyber-related safety policy.',
},
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('Test refusal');
});
const infoItems = mockAddItem.mock.calls
.map(([item]) => item as HistoryItem)
.filter((item) => item.type === 'info');
expect(infoItems).toContainEqual({
type: 'info',
text: '⚠ Response refused due to cyber-related safety policy.',
});
});
it('should not add message for FINISH_REASON_UNSPECIFIED', async () => {
// Setup mock to return a stream with FINISH_REASON_UNSPECIFIED
mockSendMessageStream.mockReturnValue(
+7 -1
View File
@@ -3002,7 +3002,13 @@ export const useLlmStream = (
'Response stopped due to unexpected tool call.',
};
const message = finishReasonMessages[finishReason];
// `finishMessage` is provider-authored free text; only the safety path
// curates it (Anthropic refusal details), so other reasons keep their
// maintained notice even if a provider fills the field (#9005).
const message =
(finishReason === FinishReason.SAFETY
? event.value.finishMessage
: undefined) ?? finishReasonMessages[finishReason];
if (message) {
addItem(
{
@@ -278,6 +278,42 @@ describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => {
text: '⚠ Response stopped due to image safety violations.',
});
});
it('prefers the provider refusal message', () => {
const map = createEventMapper();
const out = map({
type: 'finished',
value: {
reason: 'SAFETY',
finishMessage: 'Response refused due to cyber-related safety policy.',
},
} as unknown as AnyEv);
expect(out).toContainEqual({
type: 'info',
text: '⚠ Response refused due to cyber-related safety policy.',
});
});
it('keeps the curated notice when a non-safety reason carries provider text', () => {
const map = createEventMapper();
const out = map({
type: 'finished',
value: {
reason: 'MAX_TOKENS',
finishMessage: 'Provider-authored stop detail.',
},
} as unknown as AnyEv);
expect(out).toContainEqual({
type: 'info',
text: '⚠ Response truncated due to token limits.',
});
expect(
out.some(
(item) =>
item.type === 'info' && item.text.includes('Provider-authored'),
),
).toBe(false);
});
});
describe('error events', () => {
+10 -2
View File
@@ -953,8 +953,16 @@ export function createEventMapper(
out.push({ type: 'retry-countdown-clear' });
// ink parity: handleFinishedEvent adds `{type: 'info'}` for
// non-STOP finish reasons.
const reason = (ev.value as { reason?: string } | undefined)?.reason;
const message = reason ? FINISH_REASON_NOTICES[reason] : undefined;
const value = ev.value as
| { reason?: string; finishMessage?: string }
| undefined;
const reason = value?.reason;
// `finishMessage` is provider-authored free text; only the safety path
// curates it (Anthropic refusal details), so other reasons keep their
// maintained notice even if a provider fills the field (#9005).
const message =
(reason === 'SAFETY' ? value?.finishMessage : undefined) ??
(reason ? FINISH_REASON_NOTICES[reason] : undefined);
if (message) out.push({ type: 'info', text: `⚠ ${message}` });
// Segment marker only — the turn settles when the live generator
// returns (backend emits `done`), NOT here: `finished` arrives
@@ -191,9 +191,10 @@ const messageDelta = (
stop_reason: string,
output_tokens: number,
usage: object = {},
stop_details?: object,
): SseEvent => ({
type: 'message_delta',
delta: { stop_reason },
delta: { stop_reason, ...(stop_details ? { stop_details } : {}) },
usage: { output_tokens, ...usage },
});
// Yields `events`, then fails with `error`.
@@ -2452,6 +2453,27 @@ describe('AnthropicContentGenerator', () => {
expect(lastFinish(chunks)).toBe(FinishReason.STOP);
});
it('surfaces a refusal stop-details category', async () => {
const { chunks, error } = await collectEvents(
messageDelta(
'refusal',
1,
{},
{
type: 'refusal',
category: 'cyber',
explanation: 'Provider-authored explanation',
},
),
);
expect(error).toBeUndefined();
expect(lastFinish(chunks)).toBe(FinishReason.SAFETY);
expect(
chunks.flatMap((chunk) => chunk.candidates ?? []).at(-1)?.finishMessage,
).toBe('Response refused due to cyber-related safety policy.');
});
it.each([
[
'rejects an open tool-use block after assistant payload at end of stream',
@@ -1414,6 +1414,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
let completionTokens = 0;
let completionTokensReported = false;
let finishReason: string | undefined;
let finishMessage: string | undefined;
const blocks = new Map<number, StreamingBlockState>();
const deferredToolCalls: GenerateContentResponse[] = [];
@@ -1620,6 +1621,10 @@ export class AnthropicContentGenerator implements ContentGenerator {
}
case 'message_delta': {
const stopReasonValue = event.delta.stop_reason;
finishMessage ??=
this.converter.mapAnthropicStopDetailsToFinishMessage(
(event.delta as { stop_details?: unknown }).stop_details,
);
if (stopReasonValue) {
finishReason = stopReasonValue;
const hasOpenToolCall = [...blocks.values()].some(
@@ -1719,6 +1724,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
cacheReadTokensReported: cachedTokensReported,
cacheCreationTokensReported,
}),
finishMessage,
);
collectedResponses.push(chunk);
yield chunk;
@@ -1748,6 +1754,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
cacheReadTokensReported: cachedTokensReported,
cacheCreationTokensReported,
}),
finishMessage,
);
collectedResponses.push(chunk);
yield chunk;
@@ -1895,6 +1902,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
model?: string,
finishReason?: string,
usageMetadata?: GenerateContentResponseUsageMetadata,
finishMessage?: string,
): GenerateContentResponse {
const response = new GenerateContentResponse();
response.responseId = responseId;
@@ -1916,6 +1924,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
index: 0,
safetyRatings: [],
...(mappedFinishReason ? { finishReason: mappedFinishReason } : {}),
...(finishMessage ? { finishMessage } : {}),
},
];
@@ -1740,6 +1740,58 @@ describe('AnthropicContentConverter', () => {
expect(parts).toEqual([fnCall('tool', { x: 1 }, 't1')]);
});
it('surfaces a refusal stop-details category', () => {
const response = respond({
stop_reason: 'refusal',
stop_details: {
type: 'refusal',
category: 'reasoning_extraction',
explanation: 'Provider-authored explanation',
},
});
expect(response.candidates?.[0]).toEqual(
expect.objectContaining({
finishReason: FinishReason.SAFETY,
finishMessage:
'Response refused because it requested hidden reasoning.',
}),
);
});
it.each([
['cyber', 'Response refused due to cyber-related safety policy.'],
['bio', 'Response refused due to biological safety policy.'],
[
'frontier_llm',
'Response refused due to frontier model development policy.',
],
['general_harms', 'Response refused due to a general safety policy.'],
// Provider text can be any string, including prototype keys: those must
// land on the generic notice instead of leaking `[object Object]`.
['__proto__', 'Response refused due to safety reasons.'],
['unmapped_category', 'Response refused due to safety reasons.'],
])(
'maps the %s refusal category to its curated notice',
(category, expected) => {
const response = respond({
stop_reason: 'refusal',
stop_details: { type: 'refusal', category },
});
expect(response.candidates?.[0]?.finishMessage).toBe(expected);
},
);
it('ignores stop_details that is not a refusal', () => {
const response = respond({
stop_reason: 'end_turn',
stop_details: { type: 'something_else', category: 'cyber' },
});
expect(response.candidates?.[0]?.finishMessage).toBeUndefined();
});
it('forwards cache_read_input_tokens and cache_creation_input_tokens through to usageMetadata', () => {
// A real mid-conversation Anthropic response carries all three prompt
// buckets at once: `input_tokens` (non-cached tail),
@@ -571,6 +571,12 @@ export class AnthropicContentConverter {
if (finishReason) {
candidate.finishReason = finishReason;
}
const finishMessage = this.mapAnthropicStopDetailsToFinishMessage(
(response as Anthropic.Message & { stop_details?: unknown }).stop_details,
);
if (finishMessage) {
candidate.finishMessage = finishMessage;
}
llmResponse.candidates = [candidate];
llmResponse.responseId = response.id;
@@ -1017,6 +1023,34 @@ export class AnthropicContentConverter {
return mapping[reason] || FinishReason.FINISH_REASON_UNSPECIFIED;
}
mapAnthropicStopDetailsToFinishMessage(
stopDetails?: unknown,
): string | undefined {
if (!stopDetails || typeof stopDetails !== 'object') return undefined;
const details = stopDetails as Record<string, unknown>;
if (details['type'] !== 'refusal') return undefined;
const messages: Record<string, string> = {
cyber: 'Response refused due to cyber-related safety policy.',
bio: 'Response refused due to biological safety policy.',
frontier_llm:
'Response refused due to frontier model development policy.',
reasoning_extraction:
'Response refused because it requested hidden reasoning.',
general_harms: 'Response refused due to a general safety policy.',
};
const category = details['category'];
if (typeof category !== 'string') {
return 'Response refused due to safety reasons.';
}
// `category` is provider text: an own-property check keeps `__proto__` /
// `constructor` on the unknown-category fallback instead of returning an
// object that would render as `[object Object]`.
return Object.prototype.hasOwnProperty.call(messages, category)
? messages[category]
: 'Response refused due to safety reasons.';
}
private isContentObject(
content: unknown,
): content is { role: string; parts: Part[] } {
+47
View File
@@ -30,6 +30,7 @@ import type {
} from '@google/genai';
import { ApiError } from '@google/genai';
import { AuthType, type ContentGenerator } from './contentGenerator.js';
import { FinishReason } from './genai-compat.js';
import { getPlanModeSystemReminder } from './prompts.js';
import {
LlmChat,
@@ -9697,6 +9698,52 @@ describe('LlmChat', async () => {
expect(historyText).toBe(recordedText(record));
});
it('preserves finishMessage on the deferred tool-result finish chunk', async () => {
vi.useFakeTimers();
chat.setHistory([
userText('read the file'),
content(
'model',
fnCall('read_file', { path: '/tmp/x' }, 'call_refusal'),
),
]);
mockStreamsOnce(
streamOf(textChunk('Request refused.'), {
candidates: [
{
finishReason: FinishReason.SAFETY,
finishMessage:
'Response refused due to cyber-related safety policy.',
},
],
} as GenerateContentResponse),
);
const events = await collectStreamWithFakeTimers(
await sendToolResult(
'call_refusal',
'prompt-deferred-finish-message',
),
10_000,
);
const finish = events
.filter((event) => event.type === StreamEventType.CHUNK)
.map(
(event) =>
(event as { value: GenerateContentResponse }).value
.candidates?.[0],
)
.find((candidate) => candidate?.finishReason);
expect(finish).toEqual(
expect.objectContaining({
finishReason: FinishReason.SAFETY,
finishMessage:
'Response refused due to cyber-related safety policy.',
}),
);
});
/**
* 'write a game': attempt 1 is cut after 'Here is the game: ', attempt 2
* yields only `close` and dies from `error`; `rest` (if given) answers.
+13 -1
View File
@@ -7011,6 +7011,7 @@ export class LlmChat {
currentUserTurn?.role === 'user' &&
currentUserTurn.parts?.some((part) => part.functionResponse) === true;
let deferredFinishReason: FinishReason | undefined;
let deferredFinishMessage: string | undefined;
// Captured if the upstream stream throws mid-iteration (typical on weak
// networks: SSE drops between `content_block_stop` of a tool_use and the
// terminal `message_stop`). We still build / record / push a partial
@@ -7230,7 +7231,11 @@ export class LlmChat {
for (const candidate of chunk.candidates ?? []) {
if (candidate.finishReason) {
deferredFinishReason ??= candidate.finishReason;
deferredFinishMessage ??= candidate.finishMessage;
delete candidate.finishReason;
// The message is replayed with the deferred reason at the end of
// the stream; keeping it here would ship a notice with no reason.
delete candidate.finishMessage;
}
}
}
@@ -7867,7 +7872,14 @@ export class LlmChat {
abortSignal?.throwIfAborted();
if (deferredFinishReason) {
yield {
candidates: [{ finishReason: deferredFinishReason }],
candidates: [
{
finishReason: deferredFinishReason,
...(deferredFinishMessage
? { finishMessage: deferredFinishMessage }
: {}),
},
],
usageMetadata,
} as GenerateContentResponse;
}
+22
View File
@@ -926,6 +926,28 @@ describe('Turn', () => {
]);
});
it('should include a provider finish message', async () => {
const events = await run([
textChunk('Content blocked', {
finishReason: 'SAFETY',
finishMessage: 'Response refused due to cyber-related safety policy.',
}),
]);
expect(events).toEqual([
contentEvent('Content blocked'),
{
type: LlmEventType.Finished,
value: {
reason: 'SAFETY',
usageMetadata: undefined,
finishMessage:
'Response refused due to cyber-related safety policy.',
},
},
]);
});
it('should yield finished event with undefined reason when there is no finish reason', async () => {
const events = await run([textChunk('Response without finish reason')]);
+3
View File
@@ -163,6 +163,7 @@ export interface SessionTokenLimitExceededValue {
export interface LlmFinishedEventValue {
reason: FinishReason | undefined;
usageMetadata: GenerateContentResponseUsageMetadata | undefined;
finishMessage?: string;
}
/**
@@ -925,6 +926,7 @@ export class Turn {
// Check if response was truncated or stopped for various reasons
const finishReason = resp.candidates?.[0]?.finishReason;
const finishMessage = resp.candidates?.[0]?.finishMessage;
// This is the key change: Only yield 'Finished' if there is a finishReason.
if (finishReason) {
@@ -950,6 +952,7 @@ export class Turn {
value: {
reason: finishReason,
usageMetadata: resp.usageMetadata,
...(finishMessage ? { finishMessage } : {}),
},
};
}
@@ -6,12 +6,46 @@
import { describe, expect, it } from 'vitest';
import {
formatIndexEntry,
MAX_INDEX_CHARS,
MAX_INDEX_LINES,
parseIndexEntry,
trimIndexToBudget,
trimIndexToBudgetDetailed,
} from './index-budget.js';
describe('index entry format', () => {
it.each([
{ link: '- [Topic](notes/topic.md)', description: 'hook', suffix: '' },
{
link: '- [Topic — title](notes/a%28b%29.md)',
description: undefined,
suffix: '',
},
{
link: '- [Topic](notes/topic.md)',
description: 'hook',
suffix: ' (also: notes/other.md)',
},
{
link: '- [Topic](notes/topic.md)',
description: undefined,
suffix: ' (also: notes/other.md, notes/third.md)',
},
])('round-trips the writer shape %j', (entry) => {
expect(parseIndexEntry(formatIndexEntry(entry))).toEqual(entry);
});
it.each([
'- **[Topic](notes/topic.md)** — hook',
'- [Topic](notes/a(b).md) — hook',
'- [Topic](notes/topic.md): hook',
'- [Topic](notes/topic.md) — h (also: x.md) note',
])('does not certify an unknown shape: %s', (entry) =>
expect(parseIndexEntry(entry)).toBeUndefined(),
);
});
describe('trimIndexToBudget', () => {
it('keeps empty and within-budget content unchanged', () => {
expect(trimIndexToBudget([])).toBe('');
+43
View File
@@ -56,6 +56,49 @@ export function describeDroppedIndexEntries(
}.`;
}
export const INDEX_HOOK_SEPARATOR = ' — ';
export const INDEX_ALSO_OPEN = ' (also: ';
interface IndexEntry {
link: string;
description: string | undefined;
suffix: string;
}
export function formatIndexEntry({
link,
description,
suffix,
}: IndexEntry): string {
return `${link}${description === undefined ? '' : `${INDEX_HOOK_SEPARATOR}${description}`}${suffix}`;
}
export function parseIndexEntry(line: string): IndexEntry | undefined {
const trimmed = line.trimEnd();
const link = /^-\s+\[(?:(?!\]\().)+\]\([^()\s]+\)/.exec(trimmed)?.[0];
if (!link) {
return undefined;
}
const rest = trimmed.slice(link.length);
const suffix =
/ \(also: [^()\s,]+\.md(?:, [^()\s,]+\.md)*\)[.!?]*$/i.exec(rest)?.[0] ??
'';
const hook = rest.slice(0, rest.length - suffix.length);
if (hook.includes(INDEX_ALSO_OPEN)) {
return undefined;
}
if (hook === '') {
return { link, description: undefined, suffix };
}
if (hook === INDEX_HOOK_SEPARATOR.trimEnd()) {
return { link, description: '', suffix };
}
if (!hook.startsWith(INDEX_HOOK_SEPARATOR)) {
return undefined;
}
return { link, description: hook.slice(INDEX_HOOK_SEPARATOR.length), suffix };
}
export interface IndexTrimResult {
/** Surviving entries, in index order. */
content: string;
@@ -201,11 +201,10 @@ describe('memory index omission diagnostics', () => {
[...lines, 'x'.repeat(25_003)].join('\n'),
);
for (const line of lines.slice(0, 200)) expect(prompt).toContain(line);
expect(prompt).toContain(
'No candidate entry exceeds the 150-unit line limit',
);
expect(prompt).toContain('Shortening entries cannot reduce the line count');
expect(prompt).not.toContain('Keep index entries to one line at most');
expect(prompt).toContain('shortening entries cannot help');
expect(prompt).toContain('prune or merge entries');
expect(prompt).not.toContain('also exceed the character budget');
});
it('does not blame a long entry already excluded by the line cap', () => {
+30
View File
@@ -27,6 +27,7 @@ import { ensureAutoMemoryScaffold } from './store.js';
import { scanUserAutoMemoryTopicDocuments } from './scan.js';
import * as trustedMemoryFilesystem from './trusted-memory-filesystem.js';
import { arePermissionBitsEnforced } from '../test-utils/permission-bits.js';
import { formatIndexEntry, parseIndexEntry } from './index-budget.js';
vi.mock('./trusted-memory-filesystem.js', { spy: true });
@@ -69,6 +70,35 @@ describe('managed auto-memory indexer', () => {
});
});
it.each(['hook', 'h'.repeat(120)])(
'keeps the writer and reader grammar aligned for grouped description %j',
(description) => {
const doc = (relativePath: string) => ({
scope: 'team' as const,
type: 'reference' as const,
relativePath,
filePath: `/tmp/${relativePath}`,
filename: path.basename(relativePath),
title: 'Topic — title',
description,
category: 'uncategorized' as const,
keywords: [],
usageScenarios: [],
body: '',
mtimeMs: 0,
});
const primary = `notes/${'a'.repeat(85)}.md`;
const index = buildTeamAutoMemoryIndex([doc(primary), doc('notes/b.md')]);
const entry = parseIndexEntry(index);
expect(entry).toEqual({
link: `- [Topic — title](${primary})`,
description: description.length > 24 ? undefined : description,
suffix: ' (also: notes/b.md)',
});
expect(entry && formatIndexEntry(entry)).toBe(index);
},
);
it('does not create a missing compatibility root while rebuilding', async () => {
const missingRoot = path.join(tempDir, 'missing-memory-root');
+9 -4
View File
@@ -30,6 +30,9 @@ import {
import type { AutoMemoryScope } from './types.js';
import type { AutoMemoryMetadata } from './types.js';
import {
formatIndexEntry,
INDEX_ALSO_OPEN,
INDEX_HOOK_SEPARATOR,
describeDroppedIndexEntries,
INDEX_SCAN_CAP_NOTICE_START,
INDEX_TRUNCATION_NOTICE_START,
@@ -46,8 +49,6 @@ const MAX_INDEX_FIELD_CHARS = 120;
// the bytes it costs, so the entry is then emitted without one — but a hook
// that fits whole in the leftover room is kept however small that room is.
const MIN_INDEX_HOOK_CHARS = 24;
const INDEX_HOOK_SEPARATOR = ' — ';
const INDEX_ALSO_OPEN = ' (also: ';
/**
* Shorten an already-sanitized field to `limit`, preferring a word boundary.
@@ -216,9 +217,13 @@ function docIndexLine(
// A hook that FITS whole costs nothing beyond its bytes — keep it. Drop the
// hook only when it would have to be CUT below MIN_INDEX_HOOK_CHARS.
if (description.length > room && room < MIN_INDEX_HOOK_CHARS) {
return `${link}${suffix}`;
return formatIndexEntry({ link, description: undefined, suffix });
}
return `${link}${INDEX_HOOK_SEPARATOR}${truncateIndexField(description, room)}${suffix}`;
return formatIndexEntry({
link,
description: truncateIndexField(description, room),
suffix,
});
}
/**
+589 -25
View File
@@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest';
import { buildManagedAutoMemoryIndex } from './indexer.js';
import {
INDEX_TRUNCATION_NOTICE,
INDEX_TRUNCATION_NOTICE_START,
INDEX_TRUNCATION_WARNING,
MAX_INDEX_CHARS,
MAX_INDEX_LINE_CHARS,
@@ -193,9 +194,12 @@ describe('managed auto-memory prompt helpers', () => {
expect(result).not.toContain('- [Oversized](');
expect(result).toContain(retainedEntry);
expect(result).toContain('Only part of it was loaded.');
expect(result).toContain(
expect(result).not.toContain(
`one line at most ${MAX_INDEX_LINE_CHARS} UTF-16 code units`,
);
expect(result).toContain(
'Prune or merge entries to fit the character budget.',
);
},
);
@@ -258,6 +262,7 @@ describe('managed auto-memory prompt helpers', () => {
.split('\n')
.filter((line) => line.startsWith('- ['));
expect(entries).toHaveLength(14);
expect(entries.join('\n').length).toBeLessThanOrEqual(MAX_INDEX_CHARS);
expect(generated.length).toBeGreaterThan(25_000);
expect(generated).toContain('only part of it was written.');
const index = generated.replaceAll('\n', newline);
@@ -277,6 +282,7 @@ describe('managed auto-memory prompt helpers', () => {
}
expect(result).toContain('only part of it was written.');
expect(result).not.toContain('Only part of it was loaded.');
expect(result).not.toContain('too many index entries');
},
);
@@ -416,37 +422,154 @@ describe('managed auto-memory prompt helpers', () => {
},
);
// Issue #14834: a store can blow the character budget while every entry stays
// within the per-line limit, because MAX_INDEX_LINES * MAX_INDEX_LINE_CHARS
// exceeds MAX_INDEX_CHARS. The old message blamed entry length in that state,
// which named a lever that cannot move the number.
const uniformOverBudgetIndex = () => {
// Issue #14834: ordinary entries can exceed the total character budget.
// Issue #15277: shortening descriptions can still recover those entries.
const uniformOverBudgetIndex = (
count = 170,
lineChars = MAX_INDEX_LINE_CHARS,
) => {
const entry = (i: number) => {
const prefix = `- [Topic ${i}](project/topic-${i}.md) — `;
return `${prefix}${'h'.repeat(MAX_INDEX_LINE_CHARS - prefix.length)}`;
return `${prefix}${'h'.repeat(lineChars - prefix.length)}`;
};
return Array.from({ length: 170 }, (_, i) => entry(i));
return Array.from({ length: count }, (_, i) => entry(i));
};
it('blames the entry count, not entry length, when every entry is legal', () => {
const lines = uniformOverBudgetIndex();
for (const line of lines) expect(line).toHaveLength(MAX_INDEX_LINE_CHARS);
const index = lines.join('\n');
expect(index.length).toBeGreaterThan(MAX_INDEX_CHARS);
expect(lines.length).toBeLessThan(MAX_MANAGED_AUTO_MEMORY_INDEX_LINES);
it.each(['project', 'user'])(
'directs %s character-budget edits to durable topic frontmatter',
(scope) => {
const index = uniformOverBudgetIndex().join('\n');
const result =
scope === 'project'
? buildManagedAutoMemoryPrompt('/tmp/memory', index)
: buildManagedAutoMemoryPrompt('/tmp/memory', null, {
memoryDir: '/tmp/user',
indexContent: index,
});
expect(result).toContain('Only part of it was loaded.');
expect(result).toContain('`description` frontmatter in the topic files');
expect(result).not.toContain('shorten index descriptions');
},
);
const result = buildManagedAutoMemoryPrompt('/tmp/memory', index);
it.each([160, 1])(
'does not prescribe cutting links when %i link-dominated entries overflow',
(count) => {
const lineChars = count === 1 ? MAX_INDEX_CHARS + 1 : 200;
const lines = Array.from({ length: count }, (_, i) => {
const start = `- [Long ${i}](reference/`;
const end = '.md)';
return `${start}${'x'.repeat(lineChars - start.length - end.length)}${end}`;
});
const index = lines.join('\n');
expect(index.length).toBeGreaterThan(MAX_INDEX_CHARS);
const result = buildManagedAutoMemoryPrompt('/tmp/memory', index);
expect(result).not.toContain('Keep index entries to one line at most');
expect(result).toContain(
'Prune or merge entries to fit the character budget.',
);
},
);
expect(result).toContain(
`${index.length} UTF-16 code units (limit: ${MAX_INDEX_CHARS}) — too many index entries (170)`,
it('does not apply the per-line limit to a protected link among removable descriptions', () => {
const link = `- [Long](reference/${'x'.repeat(200)}.md)`;
const lines = [link, ...uniformOverBudgetIndex(159, 200)];
expect(lines.join('\n').length).toBeGreaterThan(MAX_INDEX_CHARS);
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).not.toContain('index entries are too long');
expect(result).not.toContain('Keep index entries to one line at most');
expect(result).toContain('shortening entries cannot help');
expect(result).toContain('`description` frontmatter in the topic files');
expect(result).toContain(link);
});
it.each([
[170, MAX_INDEX_LINE_CHARS],
[200, 200],
])(
'offers effective character-budget advice for %i entries of %i units',
(count, lineChars) => {
const lines = uniformOverBudgetIndex(count, lineChars);
for (const line of lines) expect(line).toHaveLength(lineChars);
const index = lines.join('\n');
expect(index.length).toBeGreaterThan(MAX_INDEX_CHARS);
expect(lines.length).toBeLessThanOrEqual(
MAX_MANAGED_AUTO_MEMORY_INDEX_LINES,
);
expect(
lines.map((line) => line.slice(0, MAX_INDEX_LINE_CHARS)).join('\n')
.length,
).toBeGreaterThan(MAX_INDEX_CHARS);
const result = buildManagedAutoMemoryPrompt('/tmp/memory', index);
expect(result).toContain(
`${index.length} UTF-16 code units (limit: ${MAX_INDEX_CHARS}) — too many index entries (${count})`,
);
expect(result).not.toContain('index entries are too long');
expect(result).not.toContain('Keep index entries to one line at most');
expect(result).toMatch(/shortening nonempty `description` frontmatter/i);
expect(result).toContain('check the regenerated index');
expect(result).toMatch(/prune or merge memory files/i);
// The budget is a hardcoded constant with no settings surface, so the
// advice must not send the reader after a lever they cannot pull.
expect(result).not.toContain('raise the budget');
const shortened = lines.map((line) => line.slice(0, 100));
const recovered = buildManagedAutoMemoryPrompt(
'/tmp/memory',
shortened.join('\n'),
);
expect(recovered).not.toContain('Only part of it was loaded.');
expect(recovered.match(/^- \[Topic \d+\]/gm)).toHaveLength(count);
},
);
it('limits the cannot-shorten advice to a line-count overflow', () => {
const lines = uniformOverBudgetIndex(201, 100);
expect(lines.join('\n').length).toBeLessThan(MAX_INDEX_CHARS);
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).toContain('201 lines (limit: 200)');
expect(result).toContain('Shortening entries cannot reduce the line count');
expect(result).toContain('prune or merge entries');
// The budget is a hardcoded constant with no settings surface, so the
// advice must not send the reader after a lever they cannot pull.
expect(result).not.toContain('raise the budget');
expect(result).not.toContain('also exceed the character budget');
expect(result).not.toMatch(
/shortening nonempty `description` frontmatter/i,
);
const shortened = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.map((line) => line.slice(0, 50)).join('\n'),
);
expect(shortened).toContain('Dropped 1 of 201 entries');
const pruned = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.slice(0, 200).join('\n'),
);
expect(pruned).not.toContain('Only part of it was loaded.');
});
it('does not take length advice from an entry beyond the line cap', () => {
const lines = [
...uniformOverBudgetIndex(200, 100),
`- [Outside](notes/${'x'.repeat(MAX_INDEX_CHARS)}.md)`,
];
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).toContain('Dropped 1 of 201 entries: "Outside".');
expect(result).toContain('Shortening entries cannot reduce the line count');
expect(result).not.toContain('Keep index entries to one line at most');
expect(result).not.toContain('also exceed the character budget');
expect(result).not.toMatch(
/shortening nonempty `description` frontmatter/i,
);
expect(result.match(/^- \[Topic \d+\]/gm)).toHaveLength(200);
});
it('names what the budget evicted instead of only saying part was loaded', () => {
@@ -462,18 +585,68 @@ describe('managed auto-memory prompt helpers', () => {
expect(result).not.toContain('- [Topic 165](');
});
it('keeps blaming entry length when an entry really does exceed the line limit', () => {
const index = `- [Oversized](reference/${'%28'.repeat(9_000)}.md)\n- [Kept](reference/kept.md)`;
it('keeps length advice when a removable description causes the overflow', () => {
const index = `- [Oversized](reference/long.md) — ${'h'.repeat(27_000)}\n- [Kept](reference/kept.md)`;
const result = buildManagedAutoMemoryPrompt('/tmp/memory', index);
expect(result).toContain('index entries are too long');
expect(result).toContain(
`Keep index entries to one line at most ${MAX_INDEX_LINE_CHARS} UTF-16 code units`,
);
expect(result).not.toContain('shortening entries cannot help');
expect(result).toContain('Dropped 1 of 2 entries: "Oversized".');
});
it('omits description advice when link prefixes alone exceed the character budget', () => {
const prefixes = Array.from({ length: 200 }, (_, i) => {
const start = `- [Topic ${i}](reference/`;
const end = '.md) — ';
return `${start}${'x'.repeat(140 - start.length - end.length)}${end}`;
});
expect(prefixes.join('\n').length).toBeGreaterThan(MAX_INDEX_CHARS);
const lines = prefixes.map((prefix) => `${prefix}${'h'.repeat(10)}`);
for (const index of [
lines.join('\n'),
prefixes.join('\n'),
prefixes.map((prefix) => prefix.slice(0, -3)).join('\n'),
]) {
const result = buildManagedAutoMemoryPrompt('/tmp/memory', index);
expect(result).toContain(
'Prune or merge entries to fit the character budget.',
);
expect(result).not.toMatch(
/shortening nonempty `description` frontmatter/i,
);
}
const pruned = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.slice(0, 165).join('\n'),
);
expect(pruned).not.toContain('Only part of it was loaded.');
expect(pruned.match(/^- \[Topic \d+\]/gm)).toHaveLength(165);
});
it('keeps length advice ahead of line-count advice when both budgets overflow', () => {
const lines = [
...uniformOverBudgetIndex(150, 100),
...Array.from({ length: 50 }, (_, i) => {
const prefix = `- [Big ${i}](project/big-${i}.md) — `;
return `${prefix}${'h'.repeat(300 - prefix.length)}`;
}),
...uniformOverBudgetIndex(1, 100),
];
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).toContain('201 lines and 30300 UTF-16 code units');
expect(result).toContain(
`Keep index entries to one line at most ${MAX_INDEX_LINE_CHARS} UTF-16 code units`,
);
expect(result).not.toContain(
'Shortening entries cannot reduce the line count',
);
});
it('counts line-cap removals in the drop note when both budgets overflow', () => {
const entry = (i: number) => {
const prefix = `- [Note ${i}](notes/${i}.md) — `;
@@ -488,6 +661,397 @@ describe('managed auto-memory prompt helpers', () => {
// 35 evicted by the character budget + 50 by the 200-line cap.
expect(result).toContain('Dropped 85 of 250 entries');
expect(result).toContain('Shortening entries cannot reduce the line count');
expect(result).toContain(
'The first 200 entries also exceed the character budget',
);
expect(result).toContain(
'try shortening nonempty `description` frontmatter in the topic files',
);
const capped = lines.slice(0, 200);
const countOnly = buildManagedAutoMemoryPrompt(
'/tmp/memory',
capped.join('\n'),
);
expect(countOnly).toContain('Dropped 35 of 200 entries');
const shortened = buildManagedAutoMemoryPrompt(
'/tmp/memory',
capped.map((line) => line.slice(0, 124)).join('\n'),
);
expect(shortened).not.toContain('Only part of it was loaded.');
expect(shortened.match(/^- \[Note \d+\]/gm)).toHaveLength(200);
});
it('does not count the writer notice as entries or against shortening feasibility', () => {
const lines = uniformOverBudgetIndex(199);
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n') + INDEX_TRUNCATION_NOTICE,
);
expect(result).toContain('Dropped 34 of 199 entries');
expect(result).toContain('too many index entries (199)');
expect(result).toMatch(/shortening nonempty `description` frontmatter/i);
expect(result).not.toContain(
'Shortening entries cannot reduce the line count',
);
expect(result).not.toContain('The first');
});
it.each(['', ' '])(
'preserves grouped links with trailing whitespace %j when measuring description-free entries',
(trailingWhitespace) => {
const lines = Array.from({ length: 200 }, (_, i) => {
const link = `- [Topic ${i}](notes/${i}.md)`;
const suffix = ` (also: ${'x'.repeat(122 - link.length - 10)}.md)`;
return `${link} — hook${suffix}${trailingWhitespace}`;
});
expect(lines.join('\n').length).toBeGreaterThan(MAX_INDEX_CHARS);
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).toMatch(/shortening nonempty `description` frontmatter/i);
expect(result).toContain('check the regenerated index');
expect(lines.every((line) => line.length <= MAX_INDEX_LINE_CHARS)).toBe(
true,
);
},
);
it('does not treat a separator in a title as the description boundary', () => {
const lines = Array.from({ length: 200 }, (_, i) => {
const title = 'Topic — title';
const prefix = `- [${title}](notes/${'x'.repeat(105)}-${i}.md)`;
return `${prefix} — hook — detail`;
});
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).not.toMatch(
/shortening nonempty `description` frontmatter/i,
);
expect(result).toContain(
'Prune or merge entries to fit the character budget.',
);
});
it.each([' - ', ' – ', ': ', 'raw target', 'bold link'])(
'does not certify an unrecognized hand-edited entry shape: %s',
(shape) => {
const lines = Array.from({ length: 200 }, (_, i) => {
const link = `- [Topic ${i}](notes/${i}${shape === 'raw target' ? '(draft)' : ''}.md)`;
const prefix =
shape === 'bold link'
? `- **${link.slice(2)}** — `
: `${link}${shape === 'raw target' ? ' — ' : shape}`;
return `${prefix}${'h'.repeat(150 - prefix.length)}`;
});
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).toMatch(/shortening nonempty `description` frontmatter/i);
expect(result).not.toContain(
'Prune or merge entries to fit the character budget.',
);
expect(result).not.toContain('bring the total within');
},
);
it('does not mistake an also phrase inside a description for grouped links', () => {
const lines = Array.from({ length: 200 }, (_, i) => {
const prefix = `- [Topic ${i}](notes/${i}.md) — hook (also: `;
return `${prefix}${'h'.repeat(149 - prefix.length)})`;
});
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).toMatch(/shortening nonempty `description` frontmatter/i);
expect(result).toContain(
'Try shortening nonempty `description` frontmatter in the topic files',
);
expect(result).not.toContain('bring the total within');
});
it('keeps a non-anchored grouped suffix followed by text conditional', () => {
const links = Array.from({ length: 200 }, (_, i) => {
const start = `- [Topic ${i}](notes/`;
const end = `-${i}.md)`;
return `${start}${'x'.repeat(124 - start.length - end.length)}${end}`;
});
const lines = links.map((link) => `${link} — h (also: x.md) note`);
expect(lines.every((line) => line.length === 146)).toBe(true);
expect(lines.join('\n')).toHaveLength(29_399);
const groupsWithoutHooks = links.map((link) => `${link} (also: x.md) note`);
expect(groupsWithoutHooks.join('\n')).toHaveLength(28_599);
for (const index of [lines.join('\n'), groupsWithoutHooks.join('\n')]) {
const result = buildManagedAutoMemoryPrompt('/tmp/memory', index);
expect(result).toContain(
'Try shortening nonempty `description` frontmatter in the topic files',
);
expect(result).not.toContain('bring the total within');
expect(result).not.toContain(
'Prune or merge entries to fit the character budget.',
);
}
});
it.each(['', '.'])(
'does not promise shortening for hook-less grouped links ending in %j',
(punctuation) => {
const lines = Array.from({ length: 200 }, (_, i) => {
const link = `- [Topic ${i}](notes/${'x'.repeat(90)}-${i}.md)`;
return `${link} (also: notes/${'y'.repeat(12)}-${i}.md)${punctuation}`;
});
expect(lines.join('\n').length).toBeGreaterThan(MAX_INDEX_CHARS);
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).toContain(
'Prune or merge entries to fit the character budget.',
);
expect(result).not.toMatch(
/shortening nonempty `description` frontmatter/i,
);
},
);
it('keeps advice conditional for an ambiguous punctuated grouped suffix', () => {
const lines = Array.from({ length: 200 }, (_, i) => {
const link = `- [Topic ${i}](notes/${i}.md)`;
const suffix = ` (also: ${'x'.repeat(138 - link.length - 10)}.md).`;
return `${link} — hook${suffix}`;
});
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).toContain(
'Try shortening nonempty `description` frontmatter in the topic files',
);
expect(result).not.toContain('bring the total within');
});
it('does not certify a file-looking suffix inside a real writer description', () => {
const links: string[] = [];
const lines = Array.from({ length: 200 }, (_, i) => {
const title = `Topic ${i}`;
const start = `- [${title}](notes/`;
const end = `-${i}.md)`;
const relativePath = `notes/${'x'.repeat(124 - start.length - end.length)}-${i}.md`;
links.push(`- [${title}](${relativePath})`);
return buildManagedAutoMemoryIndex([
{
scope: 'project',
type: 'reference',
relativePath,
filePath: `/tmp/memory/${relativePath}`,
filename: relativePath.slice(6),
title,
description: 'h (also: x.md)',
category: 'uncategorized',
keywords: [],
usageScenarios: [],
body: '',
mtimeMs: 0,
},
]);
});
expect(links.join('\n')).toHaveLength(24_999);
expect(lines.every((line) => line.length <= MAX_INDEX_LINE_CHARS)).toBe(
true,
);
expect(lines.join('\n').length).toBeGreaterThan(MAX_INDEX_CHARS);
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).toContain(
'Try shortening nonempty `description` frontmatter in the topic files',
);
expect(result).not.toContain('bring the total within');
expect(result).not.toContain(
'Prune or merge entries to fit the character budget.',
);
expect(
buildManagedAutoMemoryPrompt('/tmp/memory', links.join('\n')),
).not.toContain('Only part of it was loaded.');
});
it('keeps prune-only advice when both budgets overflow and links alone cannot fit', () => {
const lines = Array.from({ length: 250 }, (_, i) => {
const link = `- [Topic ${i}](notes/${'x'.repeat(110)}-${i}.md)`;
return `${link} — hook`;
});
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n'),
);
expect(result).toContain(
'The first 200 entries also exceed the character budget',
);
expect(result).toContain('prune or merge more entries.');
expect(result).not.toMatch(
/shortening nonempty `description` frontmatter/i,
);
});
it('does not certify a description-free floor that writer fallback cannot reach', () => {
const docs = Array.from({ length: 200 }, (_, i) => {
const title = `Topic ${i}`;
const start = `- [${title}](notes/`;
const end = `-${i}.md)`;
const relativePath = `notes/${'x'.repeat(124 - start.length - end.length)}-${i}.md`;
expect(`- [${title}](${relativePath})`).toHaveLength(124);
return {
scope: 'project' as const,
type: 'reference' as const,
relativePath,
filePath: `/tmp/memory/${relativePath}`,
filename: `${i}.md`,
title,
description: 'h'.repeat(20),
category: 'uncategorized' as const,
keywords: [],
usageScenarios: [],
body: '',
mtimeMs: 0,
};
});
const index = docs
.map((doc) => buildManagedAutoMemoryIndex([doc]))
.join('\n');
expect(index.length).toBeGreaterThan(MAX_INDEX_CHARS);
for (let length = 0; length <= 20; length++) {
const regenerated = buildManagedAutoMemoryIndex(
docs.map((doc) => ({ ...doc, description: 'h'.repeat(length) })),
);
expect(regenerated.match(/^- \[Topic \d+\]/gm)!.length).toBeLessThan(200);
}
const result = buildManagedAutoMemoryPrompt('/tmp/memory', index);
expect(result).not.toContain('bring the total within');
expect(result).not.toContain('enough to fit');
expect(result).toContain(
'Try shortening nonempty `description` frontmatter',
);
expect(result).toContain('check the regenerated index');
});
it('does not let a writer notice withhold a feasible shortening lever', () => {
const lines = Array.from({ length: 201 }, (_, i) => {
const start = `- [Topic ${i}](notes/`;
const end = `-${i}.md)`;
const link = `${start}${'x'.repeat(124 - start.length - end.length)}${end}`;
expect(link).toHaveLength(124);
return `${link} — ${'h'.repeat(20)}`;
});
const floor = lines
.slice(0, 200)
.map((line) => line.slice(0, 124))
.join('\n');
expect(floor).toHaveLength(24_999);
const result = buildManagedAutoMemoryPrompt(
'/tmp/memory',
lines.join('\n') + INDEX_TRUNCATION_NOTICE,
);
expect(result).toContain(
'try shortening nonempty `description` frontmatter in the topic files',
);
});
it('keeps a stale writer notice without claiming 199 loaded entries were truncated', () => {
const index =
uniformOverBudgetIndex(199, 100).join('\n') + INDEX_TRUNCATION_NOTICE;
const result = buildManagedAutoMemoryPrompt('/tmp/memory', index);
expect(result).toContain(INDEX_TRUNCATION_WARNING);
expect(result).not.toContain('Only part of it was loaded.');
expect(result).not.toContain('prune or merge entries');
});
it('preserves the real writer notice without promising to reduce its saved total', () => {
const docs = Array.from({ length: 200 }, (_, i) => ({
scope: 'project' as const,
type: 'reference' as const,
relativePath: `notes/${i}.md`,
filePath: `/tmp/memory/notes/${i}.md`,
filename: `${i}.md`,
title: `Topic ${i}`,
description: 'h'.repeat(130),
category: 'uncategorized' as const,
keywords: [],
usageScenarios: [],
body: '',
mtimeMs: 0,
}));
for (const description of ['h'.repeat(130), 'h'.repeat(87), '']) {
const generated = buildManagedAutoMemoryIndex(
docs.map((doc) => ({ ...doc, description })),
);
const result = buildManagedAutoMemoryPrompt('/tmp/memory', generated);
expect(result).not.toContain('Only part of it was loaded.');
expect(result).not.toContain('bring the total within');
const noticeStart = generated.indexOf(INDEX_TRUNCATION_NOTICE_START);
if (description.length === 130) {
expect(noticeStart).toBeGreaterThanOrEqual(0);
expect(result).toContain(generated.slice(noticeStart));
} else {
expect(noticeStart).toBe(-1);
}
}
});
it('directs team description changes to topic-file frontmatter', () => {
const result = buildManagedAutoMemoryPrompt(
'/tmp/project',
null,
undefined,
{
memoryDir: '/tmp/team',
indexContent: uniformOverBudgetIndex().join('\n'),
},
);
expect(result).toContain('`description` frontmatter in the topic files');
expect(result).toContain('check the regenerated index');
expect(result).not.toContain('shorten index descriptions');
});
it('does not offer shortening when the real writer already used its type fallback', () => {
const docs = Array.from({ length: 200 }, (_, i) => {
const title = `Topic ${i}`;
const start = `- [${title}](notes/`;
const end = `-${i}.md)`;
const relativePath = `notes/${'x'.repeat(124 - start.length - end.length)}-${i}.md`;
expect(`- [${title}](${relativePath})`).toHaveLength(124);
return {
scope: 'project' as const,
type: 'reference' as const,
relativePath,
filePath: `/tmp/memory/${relativePath}`,
filename: relativePath.slice(6),
title,
description: '',
category: 'uncategorized' as const,
keywords: [],
usageScenarios: [],
body: '',
mtimeMs: 0,
};
});
const generated = buildManagedAutoMemoryIndex(docs);
expect(generated.length).toBeGreaterThan(MAX_INDEX_CHARS);
expect(generated).toContain(' — reference');
const noticeStart = generated.indexOf(INDEX_TRUNCATION_NOTICE_START);
expect(noticeStart).toBeGreaterThanOrEqual(0);
const result = buildManagedAutoMemoryPrompt('/tmp/memory', generated);
expect(result).toContain(generated.slice(noticeStart));
expect(result).not.toMatch(
/shortening nonempty `description` frontmatter/i,
);
expect(result).not.toContain('Only part of it was loaded.');
});
it('does not cut a surrogate pair when naming a dropped entry', () => {
+76 -14
View File
@@ -9,11 +9,13 @@ import { normalizeContent } from '../utils/textUtils.js';
import { AUTO_MEMORY_TREE_CATEGORIES } from './types.js';
import {
describeDroppedIndexEntries,
formatIndexEntry,
INDEX_SCAN_CAP_NOTICE_START,
INDEX_TRUNCATION_NOTICE_START,
MAX_INDEX_LINES as MAX_MANAGED_AUTO_MEMORY_INDEX_LINES,
MAX_INDEX_CHARS,
MAX_INDEX_LINE_CHARS,
parseIndexEntry,
trimIndexToBudgetDetailed,
} from './index-budget.js';
@@ -190,6 +192,20 @@ export const TRUSTING_RECALL_SECTION: readonly string[] = [
'A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot.',
];
function descriptionFreeIndexEntry(
line: string,
): { min: number; max: number } | undefined {
const entry = parseIndexEntry(line);
if (!entry) {
return undefined;
}
const max = formatIndexEntry({ ...entry, description: undefined }).length;
// A hook can itself end in text identical to a grouped-link suffix.
const min =
entry.description !== undefined && entry.suffix ? entry.link.length : max;
return { min, max };
}
function truncateManagedAutoMemoryIndex(indexContent: string): string {
const trimmed = normalizeContent(indexContent).trim();
const noticeStart = Math.max(
@@ -205,8 +221,8 @@ function truncateManagedAutoMemoryIndex(indexContent: string): string {
const entryContent = (
hasWriterNotice ? trimmed.slice(0, noticeStart) : trimmed
).trimEnd();
const lines = entryContent.split('\n');
const lineCount = lines.length;
const entries = entryContent.split('\n');
const lineCount = entries.length;
const charCount = entryContent.length;
const wasLineTruncated = lineCount > MAX_MANAGED_AUTO_MEMORY_INDEX_LINES;
const wasCharTruncated = charCount > MAX_INDEX_CHARS;
@@ -215,17 +231,54 @@ function truncateManagedAutoMemoryIndex(indexContent: string): string {
return trimmed;
}
const entries = lines;
const { content, dropped } = trimIndexToBudgetDetailed(entries);
const candidates = entries.slice(0, MAX_MANAGED_AUTO_MEMORY_INDEX_LINES);
const hasOverlongEntry = candidates.some(
(line) => line.length > MAX_INDEX_LINE_CHARS,
);
const candidateCharCount = candidates.join('\n').length;
let allOverlongEntriesShortenable = true;
const ordinaryCharCount = candidates
.map((line) => {
const entry = parseIndexEntry(line);
if (
!entry ||
entry.link.length + entry.suffix.length > MAX_INDEX_LINE_CHARS
) {
if (line.length > MAX_INDEX_LINE_CHARS) {
allOverlongEntriesShortenable = false;
}
return line;
}
return line.slice(0, MAX_INDEX_LINE_CHARS);
})
.join('\n').length;
let minimumCharCount = candidates.length - 1;
let maximumCharCount = minimumCharCount;
let allRecognized = true;
for (const line of candidates) {
const floor = descriptionFreeIndexEntry(line);
if (!floor) {
allRecognized = false;
break;
}
minimumCharCount += floor.min;
maximumCharCount += floor.max;
}
const canFitWithoutDescriptions = !allRecognized
? undefined
: maximumCharCount <= MAX_INDEX_CHARS
? true
: minimumCharCount > MAX_INDEX_CHARS
? false
: undefined;
// Blame long candidates only when the ordinary-entry bound clears overflow.
const overlongEntriesCauseOverflow =
allOverlongEntriesShortenable &&
candidateCharCount > MAX_INDEX_CHARS &&
ordinaryCharCount <= MAX_INDEX_CHARS;
const reason =
wasCharTruncated && !wasLineTruncated
? `${charCount} UTF-16 code units (limit: ${MAX_INDEX_CHARS}) — ${
hasOverlongEntry
overlongEntriesCauseOverflow
? 'index entries are too long'
: `too many index entries (${entries.length})`
}`
@@ -233,18 +286,27 @@ function truncateManagedAutoMemoryIndex(indexContent: string): string {
? `${lineCount} lines (limit: ${MAX_MANAGED_AUTO_MEMORY_INDEX_LINES})`
: `${lineCount} lines and ${charCount} UTF-16 code units`;
// Shortening a candidate cannot reduce the line count or an excluded tail.
const lever =
candidates.join('\n').length > MAX_INDEX_CHARS && hasOverlongEntry
? ` Keep index entries to one line at most ${MAX_INDEX_LINE_CHARS} UTF-16 code units; move detail into topic files. Prune or merge entries if the count is still over budget.`
: hasOverlongEntry
const shorteningAdvice =
'Try shortening nonempty `description` frontmatter in the topic files; check the regenerated index, and prune or merge memory files if it still exceeds the character budget.';
const combinedCharacterAdvice =
canFitWithoutDescriptions === false
? 'prune or merge more entries.'
: 'try shortening nonempty `description` frontmatter in the topic files; check the regenerated index, and prune or merge more memory files if necessary.';
const lever = overlongEntriesCauseOverflow
? ` Keep index entries to one line at most ${MAX_INDEX_LINE_CHARS} UTF-16 code units by shortening topic-file frontmatter; never cut link targets. Prune or merge memory files if the count is still over budget.`
: wasLineTruncated && candidateCharCount > MAX_INDEX_CHARS
? ` Shortening entries cannot reduce the line count — prune or merge entries. The first ${candidates.length} entries also exceed the character budget; ${combinedCharacterAdvice}`
: wasLineTruncated
? ' Shortening entries cannot reduce the line count — prune or merge entries.'
: ` No candidate entry exceeds the ${MAX_INDEX_LINE_CHARS}-unit line limit, so shortening entries cannot help — prune or merge entries.`;
: canFitWithoutDescriptions !== false
? ` ${shorteningAdvice}`
: ' Prune or merge entries to fit the character budget.';
return `${content}${writerNotice}\n\n> WARNING: MEMORY.md is ${reason}. Only part of it was loaded.${describeDroppedIndexEntries(
dropped,
entries.length,
)}${lever}`;
)}${lever} Make edits in the topic files; changes to regenerated index lines do not persist.`;
}
/**
@@ -1251,8 +1251,7 @@ final class RuntimeBindingProvisioning {
&& lease.getLeaseId().equals(attestation.getLeaseId())
&& lease.getEpoch() == attestation.getEpoch()
&& request.getScope().equals(attestation.getScope())
&& Objects.equals(request.getStorageId(),
attestation.getStorageId())
&& Objects.equals(request.getStorageId(), attestation.getStorageId())
&& seed.getProvisionRequestId().equals(
attestation.getProvisionRequestId());
}
@@ -2007,10 +2007,8 @@ public final class RuntimeBrokerService implements AutoCloseable, RuntimeBrokerO
public CompletionStage<Boolean> release(String harnessSessionId,
String runtimeSessionId, boolean drain, RuntimeLifecycleAuthority authority) {
requireOpen(closed);
String harnessId = BrokerValues.requireId(harnessSessionId,
"harnessSessionId");
String runtimeId = BrokerValues.requireId(runtimeSessionId,
"runtimeSessionId");
String harnessId = BrokerValues.requireId(harnessSessionId, "harnessSessionId");
String runtimeId = BrokerValues.requireId(runtimeSessionId, "runtimeSessionId");
CompletableFuture<SessionContext> local = bindingRegistry.session(runtimeId);
if (local == null) {
return releasedSession(harnessId, runtimeId, drain, authority);
@@ -2057,10 +2055,8 @@ public final class RuntimeBrokerService implements AutoCloseable, RuntimeBrokerO
String runtimeSessionId, String mountClaimId,
RuntimeLifecycleAuthority authority) {
requireOpen(closed);
String harnessId = BrokerValues.requireId(harnessSessionId,
"harnessSessionId");
String runtimeId = BrokerValues.requireId(runtimeSessionId,
"runtimeSessionId");
String harnessId = BrokerValues.requireId(harnessSessionId, "harnessSessionId");
String runtimeId = BrokerValues.requireId(runtimeSessionId, "runtimeSessionId");
String claimId = BrokerValues.requireId(mountClaimId, "mountClaimId");
CompletableFuture<SessionContext> local = bindingRegistry.session(runtimeId);
if (local == null) {
@@ -175,6 +175,9 @@
<argLine>@{argLine} -XX:MaxRAMPercentage=40</argLine>
<failIfNoTests>true</failIfNoTests>
<excludedGroups>fault-gate</excludedGroups>
<!-- H2 fixtures retain named databases for the JVM's lifetime.
Keep those catalogs scoped to a test class, not the whole suite. -->
<reuseForks>false</reuseForks>
<systemPropertyVariables>
<managed.module.root>${managed.module.root}</managed.module.root>
<qwen.cli.entry>${qwen.cli.entry}</qwen.cli.entry>
@@ -0,0 +1,30 @@
package com.alibaba.qwen.code.runtimebroker;
import org.junit.jupiter.api.Test;
import java.sql.DriverManager;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class H2ForkIsolationFirstTest {
@Test
void startsWithoutAnotherTestClassCatalog() throws SQLException {
boolean surefire = System.getProperty("surefire.test.class.path") != null;
if ("true".equals(System.getenv("GITHUB_ACTIONS"))) {
assertTrue(surefire, "CI must run the H2 fork-isolation check through Surefire");
}
assumeTrue(surefire, "This checks Maven's test-class fork boundary");
try (var connection = DriverManager.getConnection(
"jdbc:h2:mem:test-class-isolation;DB_CLOSE_DELAY=-1")) {
try (var tables = connection.getMetaData().getTables(null, "PUBLIC", "CLASS_OWNER", null)) {
assertFalse(tables.next(), "Another test class retained its H2 catalog in this JVM");
}
try (var statement = connection.createStatement()) {
statement.execute("CREATE TABLE class_owner (id INTEGER PRIMARY KEY)");
}
}
}
}
@@ -0,0 +1,30 @@
package com.alibaba.qwen.code.runtimebroker;
import org.junit.jupiter.api.Test;
import java.sql.DriverManager;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class H2ForkIsolationSecondTest {
@Test
void startsWithoutAnotherTestClassCatalog() throws SQLException {
boolean surefire = System.getProperty("surefire.test.class.path") != null;
if ("true".equals(System.getenv("GITHUB_ACTIONS"))) {
assertTrue(surefire, "CI must run the H2 fork-isolation check through Surefire");
}
assumeTrue(surefire, "This checks Maven's test-class fork boundary");
try (var connection = DriverManager.getConnection(
"jdbc:h2:mem:test-class-isolation;DB_CLOSE_DELAY=-1")) {
try (var tables = connection.getMetaData().getTables(null, "PUBLIC", "CLASS_OWNER", null)) {
assertFalse(tables.next(), "Another test class retained its H2 catalog in this JVM");
}
try (var statement = connection.createStatement()) {
statement.execute("CREATE TABLE class_owner (id INTEGER PRIMARY KEY)");
}
}
}
}
@@ -3656,9 +3656,15 @@ export class DaemonClient {
if (options?.sourceId !== undefined) {
query.set('sourceId', options.sourceId);
}
if (options?.liveOnly === true) {
query.set('liveOnly', '1');
}
// The ACP session/list mapping does not carry liveOnly; force native REST
// rather than silently serving persisted history.
return await this.jsonRequest<DaemonSessionListPage>(
`/workspace/${urlEncode(workspaceCwd)}/sessions?${query.toString()}`,
'GET /workspace/sessions',
options?.liveOnly === true ? { mode: 'rest' } : {},
);
}
@@ -9081,9 +9087,16 @@ export class WorkspaceDaemonClient {
if (options?.sourceId !== undefined) {
query.set('sourceId', options.sourceId);
}
return await this.get(
if (options?.liveOnly === true) {
query.set('liveOnly', '1');
}
// The ACP session/list mapping does not carry liveOnly; force native REST
// rather than silently serving persisted history.
return await this.client.workspaceJsonRequest<DaemonSessionListPage>(
this.workspaceSelector,
`/sessions?${query.toString()}`,
'GET /workspaces/:workspace/sessions',
options?.liveOnly === true ? { mode: 'rest' } : {},
);
}
+10 -2
View File
@@ -1948,6 +1948,14 @@ export interface DaemonSessionListPageOptions {
sourceType?: string;
/** Restrict the page to this source identifier. Requires `sourceType`. */
sourceId?: string;
/**
* List the sessions currently live in the workspace bridge instead of
* persisted history, so closed rows cannot crowd live sessions out of the
* first page. Served over native REST; the ACP transport does not carry it.
* Cannot be combined with `view`, archived `archiveState`,
* `parentSessionId`, `sourceType`, or `sourceId`.
*/
liveOnly?: boolean;
}
export interface DaemonSessionListPage {
@@ -1967,8 +1975,8 @@ export interface DaemonSessionCatalogWorkspace {
export interface DaemonSessionCatalogRequest {
/** 1–20 entries; `all` excludes internal workspaces. */
workspaces: 'all' | DaemonSessionCatalogWorkspace[];
/** Shared filters; pageSize is 1–100, default 20. */
options?: Omit<DaemonSessionListPageOptions, 'cursor'>;
/** Shared filters; pageSize is 1–100, default 20. `liveOnly` is not shared. */
options?: Omit<DaemonSessionListPageOptions, 'cursor' | 'liveOnly'>;
includeGroups?: boolean;
}
@@ -18,6 +18,7 @@ import {
import type { DaemonTransport } from '../../src/daemon/DaemonTransport.js';
import type { DaemonSessionAgentsStatus } from '../../src/daemon/index.js';
import { negotiateTransport } from '../../src/daemon/negotiateTransport.js';
import { AcpHttpTransport } from '../../src/daemon/AcpHttpTransport.js';
import {
DaemonCapabilityMissingError,
DAEMON_REASONING_SELECTIONS,
@@ -6631,6 +6632,54 @@ describe('DaemonClient', () => {
);
});
it('serializes liveOnly into both workspace session-list clients', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, {
sessions: [{ sessionId: 's-live', workspaceCwd: '/work/a' }],
}),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await client.listWorkspaceSessionsPage('/work/a', { liveOnly: true });
await client
.workspaceByCwd('/work/a')
.listWorkspaceSessionsPage({ liveOnly: true });
expect(calls.map((call) => call.url)).toEqual([
'http://daemon/workspace/%2Fwork%2Fa/sessions?size=20&liveOnly=1',
'http://daemon/workspaces/%2Fwork%2Fa/sessions?size=20&liveOnly=1',
]);
});
it('routes liveOnly through native REST when an ACP transport is configured', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, { sessions: [] }),
);
const transport = new AcpHttpTransport('http://daemon', undefined, fetch);
const transportFetch = vi
.spyOn(transport, 'fetch')
.mockRejectedValue(new Error('ACP transport must not serve liveOnly'));
const client = new DaemonClient({
baseUrl: 'http://daemon',
fetch,
transport,
});
await client.listWorkspaceSessionsPage('/work/a', {
pageSize: 100,
liveOnly: true,
});
await client
.workspaceByCwd('/work/a')
.listWorkspaceSessionsPage({ pageSize: 100, liveOnly: true });
expect(transportFetch).not.toHaveBeenCalled();
expect(calls.map((call) => call.url)).toEqual([
'http://daemon/workspace/%2Fwork%2Fa/sessions?size=100&liveOnly=1',
'http://daemon/workspaces/%2Fwork%2Fa/sessions?size=100&liveOnly=1',
]);
});
it('serializes source filters into both workspace session-list clients', async () => {
const { fetch, calls } = recordingFetch((request) =>
request.url.endsWith('/capabilities')
@@ -659,7 +659,10 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => {
}>();
expectTypeOf<DaemonSessionCatalogRequest>().toEqualTypeOf<{
workspaces: 'all' | DaemonSessionCatalogWorkspace[];
options?: Omit<Public.DaemonSessionListPageOptions, 'cursor'>;
options?: Omit<
Public.DaemonSessionListPageOptions,
'cursor' | 'liveOnly'
>;
includeGroups?: boolean;
}>();
expectTypeOf<DaemonSessionCatalogPage>().toEqualTypeOf<{
@@ -11,7 +11,9 @@ import type { ACPToolCall } from '../../adapters/types';
import {
extractText,
isShellToolName,
MAX_TOOL_PAYLOAD_LENGTH,
sanitizeControlChars,
stringifyValue,
truncateText,
} from '../messages/toolFormatting';
import { extractDiff, fencedCodeBlock } from '../messages/ToolGroup';
@@ -20,24 +22,12 @@ import { parseShellLiveOutput } from '../messages/tools/shellLiveOutput';
import styles from './tool-call-content.module.css';
/** The longest stretch of one argument or result put in the DOM by default. */
export const MAX_DETAIL_LENGTH = 4000;
export const MAX_DETAIL_LENGTH = MAX_TOOL_PAYLOAD_LENGTH;
export { stringifyValue };
const EDIT_TOOL = /^(edit|editfile|write|write_file|writefile)$/;
export function stringifyValue(value: unknown): string {
if (value === undefined || value === null) return '';
const text = typeof value === 'string' ? value : safeJson(value);
return text.trim();
}
function safeJson(value: unknown): string {
try {
return JSON.stringify(value, null, 2) ?? '';
} catch {
return '';
}
}
/** Valid JSON renders as a code block; anything else stays plain text. */
export function ToolCallDetail({
text,
@@ -128,6 +128,9 @@ describe('ReleaseSessionDialog selection', () => {
expect(scopedSessionsOptions).toEqual({
autoLoad: true,
maxAgeMs: 1_000,
pageSize: 1_000,
sourceType: null,
liveOnly: true,
});
// The dialog opens with no highlight at all; Enter has nothing to act on.
expect(rows().some(isCursor)).toBe(false);
@@ -220,6 +223,76 @@ describe('ReleaseSessionDialog selection', () => {
expect(dangerButton().disabled).toBe(true);
});
it('lists channel sessions without dropping existing live sources', async () => {
sessions.splice(
0,
sessions.length,
{
sessionId: 'channel-1',
workspaceCwd: '/work/repo',
displayName: 'Channel session',
sourceType: 'channel',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
{
sessionId: 'live-1',
workspaceCwd: '/work/repo',
displayName: 'Live session',
sourceType: 'qwen-live',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
{
sessionId: 'side-task-1',
workspaceCwd: '/work/repo',
displayName: 'Side task',
sourceType: 'side_task',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
{
sessionId: 'scheduled-task-1',
workspaceCwd: '/work/repo',
displayName: 'Scheduled task',
sourceType: 'scheduled_task',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
{
sessionId: 'standalone-1',
workspaceCwd: '/work/repo',
displayName: 'Standalone session',
sourceType: 'standalone',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
{
sessionId: 'agent-1',
workspaceCwd: '/work/repo',
displayName: 'Agent session',
sourceType: 'agent',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
);
releaseSessionMock.mockClear();
mount();
expect(rows()).toHaveLength(2);
expect(rows()[0].textContent).toContain('Channel session');
expect(rows()[1].textContent).toContain('Live session');
act(() => {
rows()[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(dangerButton().disabled).toBe(false);
await act(async () => {
dangerButton().click();
await Promise.resolve();
});
expect(releaseSessionMock).toHaveBeenCalledWith('channel-1');
});
it('clears the confirmed target and disarms release when the filter changes', () => {
mount();
@@ -10,6 +10,7 @@ import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
import { useFilterInput } from '../../hooks/useFilterInput';
import { SessionRow } from './SessionRow';
import { useScopedSessions } from '../../hooks/useScopedSessions';
import { SESSION_LIST_PAGE_SIZE } from '../../constants/sessions';
interface ReleaseSessionDialogProps {
onReleased: (sessionId: string) => void;
@@ -21,6 +22,11 @@ interface ReleaseSessionDialogProps {
const LIST_ID = 'release-session-list';
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
// Channel sessions consume the workspace cap but are hidden from the normal
// sidebar query; the release dialog must include them to free a slot. The
// default sidebar filter also admits qwen-live, so preserve that here.
const RELEASABLE_SOURCE_TYPES = new Set(['default', 'channel', 'qwen-live']);
export function ReleaseSessionDialog({
onReleased,
onError,
@@ -37,6 +43,12 @@ export function ReleaseSessionDialog({
} = useScopedSessions(workspaceCwd, {
autoLoad: true,
maxAgeMs: 1_000,
// The daemon caps the live page at 100 rows. Request the shared max so a
// full workspace usually fits; above 100 live rows this dialog does not
// follow nextCursor.
pageSize: SESSION_LIST_PAGE_SIZE,
sourceType: null,
liveOnly: true,
});
const currentSessionId = connection.sessionId;
const [deleting, setDeleting] = useState(false);
@@ -57,8 +69,12 @@ export function ReleaseSessionDialog({
if (sessionsError) setMessage(sessionsError.message);
}, [sessionsError]);
const releaseCandidates = sessions.filter(
(s) =>
s.sourceType === undefined || RELEASABLE_SOURCE_TYPES.has(s.sourceType),
);
const filtered = filterQuery
? sessions.filter((s) => {
? releaseCandidates.filter((s) => {
const q = filterQuery.toLowerCase();
return (
(s.displayName || '').toLowerCase().includes(q) ||
@@ -66,7 +82,7 @@ export function ReleaseSessionDialog({
sessionMatchesGitQuery(s, q)
);
})
: sessions;
: releaseCandidates;
const confirmRow = (index: number) => {
const session = filtered[index];
@@ -12,6 +12,7 @@ import {
} from '../../adapters/toolClassification';
import { transcriptBlocksToDaemonMessages } from '../../adapters/transcriptToMessages';
import { getTranslator, I18nProvider } from '../../i18n';
import { sanitizeControlChars } from './toolFormatting';
import {
WebShellCustomizationProvider,
type MarkdownRenderContext,
@@ -1052,7 +1053,13 @@ describe('tool row rendering', () => {
undefined,
'document',
);
expect(container.textContent).toContain(text);
// Valid JSON is pretty-printed for display; other text is shown exactly
// as recorded, including its trailing whitespace.
const expected =
toolName === 'tool_call'
? JSON.stringify(JSON.parse(text) as unknown, null, 2)
: text;
expect(container.textContent).toContain(expected);
expect(container.querySelector('iframe')).toBeNull();
},
);
@@ -3228,3 +3235,596 @@ describe('pending edit approval rows', () => {
expect(handedBack.textContent).toContain('handed back content');
});
});
describe('generic tool card details', () => {
it('pretty-prints arguments and a compact JSON result on expansion', () => {
const container = renderToolLine(
makeTool({
toolName: 'mcp__github__pull_request_read',
args: { method: 'get_check_runs', owner: 'QwenLM' },
rawOutput: '{"total_count":38,"check_runs":[{"name":"smoke"}]}',
}),
{ forceExpanded: true },
);
const text = container.textContent ?? '';
expect(text).toContain('Arguments');
expect(text).toContain('Result');
expect(text).toContain('"method": "get_check_runs"');
expect(text).toContain('"total_count": 38');
expect(text).not.toContain('"total_count":38');
});
it('keeps a non-JSON result as plain text', () => {
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__echo',
rawOutput: 'line one\nline two',
}),
{ forceExpanded: true },
);
expect(container.textContent).toContain('line one');
expect(container.textContent).toContain('line two');
expect(container.querySelector('[class*="payloadText"]')).not.toBeNull();
expect(container.querySelector('[class*="payloadJson"]')).toBeNull();
});
it('shows the failure text in the Result block', () => {
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__fail',
status: 'failed',
content: [{ type: 'content', content: { type: 'text', text: 'boom' } }],
}),
{ forceExpanded: true },
);
expect(container.textContent).toContain('Result');
expect(container.textContent).toContain('boom');
});
it('offers Show more for a payload over the inline cap', () => {
const tail = 'TAIL-MARKER';
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__dump',
rawOutput: `${'x'.repeat(4200)}${tail}`,
}),
{ forceExpanded: true },
);
expect(container.textContent).not.toContain(tail);
const more = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Show more',
);
expect(more).toBeDefined();
act(() => more?.click());
expect(container.textContent).toContain(tail);
});
it('renders a payload under the inline cap whole without Show more', () => {
const tail = 'SHORT-TAIL';
const rawOutput = `${'a'.repeat(3900 - tail.length)}${tail}`;
expect(rawOutput).toHaveLength(3900);
const container = renderToolLine(
makeTool({ toolName: 'mcp__demo__short', rawOutput }),
{ forceExpanded: true },
);
expect(container.textContent).toContain(tail);
expect(container.textContent).not.toContain('Content truncated');
expect(
[...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Show more',
),
).toBeUndefined();
});
it('offers Show more when pretty-printing grows a payload past the cap', () => {
const tail = 'TAIL-MARKER';
const items = [
...Array.from({ length: 160 }, (_, i) => ({ id: i, name: 'x' })),
{ id: 160, name: tail },
];
const compact = JSON.stringify(items);
// Guard the fixture: the raw payload fits inline, the formatted one does
// not, so the button must follow the formatted length.
expect(compact.length).toBeLessThanOrEqual(4000);
expect(JSON.stringify(items, null, 2).length).toBeGreaterThan(4000);
const container = renderToolLine(
makeTool({ toolName: 'mcp__demo__grow', rawOutput: compact }),
{ forceExpanded: true },
);
expect(container.textContent).not.toContain(tail);
const more = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Show more',
);
expect(more).toBeDefined();
act(() => more?.click());
expect(container.textContent).toContain(tail);
});
it('stops at the expanded cap and keeps the truncation notice', () => {
const midMarker = 'MID-MARKER';
const pastCap = 'PAST-40K-MARKER';
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__huge',
rawOutput: `${'x'.repeat(39_900)}${midMarker}${'x'.repeat(100)}${pastCap}`,
}),
{ forceExpanded: true },
);
expect(container.textContent).not.toContain(midMarker);
const more = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Show more',
);
expect(more).toBeDefined();
act(() => more?.click());
expect(container.textContent).toContain(midMarker);
expect(container.textContent).not.toContain(pastCap);
expect(container.textContent).toContain('Content truncated');
});
it('keeps the header description out of the expanded body', () => {
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__inspect',
title: 'Inspect check runs',
args: { method: 'get_check_runs' },
rawOutput: '{"total_count":38}',
}),
{ forceExpanded: true },
);
const occurrences = (container.textContent ?? '').split(
'Inspect check runs',
).length;
expect(occurrences - 1).toBe(1);
});
it('keeps a long description in the expanded card detail', () => {
const title =
`Inspect check runs ${'and report failures '.repeat(4)}`.trim();
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__inspect',
title,
args: { method: 'get_check_runs' },
rawOutput: '{"total_count":38}',
}),
{ forceExpanded: true },
);
const occurrences = (container.textContent ?? '').split(title).length;
expect(occurrences - 1).toBe(1);
});
it('keeps a long search description in the expanded card detail', () => {
const title =
`Search the repository ${'for matching files '.repeat(4)}`.trim();
const container = renderToolLine(
makeTool({
toolName: 'grep_search',
title,
args: { pattern: 'needle' },
rawOutput: 'No matches found',
}),
{ forceExpanded: true },
);
expect(
container.querySelector('[class*="expandedCardDetail"]')?.textContent,
).toContain(title);
});
it('keeps a malformed todo description in the expanded card detail', () => {
const description = 'Malformed todo payload';
const container = renderToolLine(
makeTool({
toolName: 'todo_write',
title: description,
args: { todos: 'not-a-list' },
}),
{ forceExpanded: true },
);
const occurrences = (container.textContent ?? '').split(description).length;
expect(occurrences - 1).toBe(1);
});
it('keeps a short description when a custom header omits it', () => {
const description = 'Inspect check runs';
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__inspect',
title: description,
args: { method: 'get_check_runs' },
rawOutput: '{"total_count":38}',
}),
{ forceExpanded: true },
{ renderToolHeaderExtra: () => <span>custom header</span> },
);
expect(
container.querySelector('[class*="expandedCardDetail"]')?.textContent,
).toContain(description);
});
it('keeps a short description in a single-tool document detail', () => {
const description = 'Inspect check runs';
const container = renderToolGroup(
[
makeTool({
toolName: 'mcp__demo__inspect',
title: description,
args: { method: 'get_check_runs' },
rawOutput: '{"total_count":38}',
}),
],
{},
undefined,
'document',
);
expect(
container.querySelector('[class*="expandedCardDetail"]')?.textContent,
).toContain(description);
});
it('keeps a long description when a single-tool card hides its header', () => {
const description = `Inspect check runs ${'and report failures '.repeat(8)}`;
const container = renderToolGroup(
[
makeTool({
toolName: 'mcp__demo__inspect',
title: description,
args: { method: 'get_check_runs' },
rawOutput: '{"total_count":38}',
}),
],
{},
undefined,
'document',
);
expect(container.textContent).toContain(description.trim());
});
it('does not render an empty Arguments section', () => {
const container = renderToolLine(
makeTool({ toolName: 'mcp__demo__empty', args: {}, rawOutput: 'ok' }),
{ forceExpanded: true },
);
expect(container.textContent).not.toContain('Arguments');
expect(container.textContent).toContain('Result');
});
it('joins every text content block in the Result', () => {
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__multi',
content: [
{ type: 'content', content: { type: 'text', text: 'first block' } },
{ type: 'content', content: { type: 'text', text: 'second block' } },
],
}),
{ forceExpanded: true },
);
expect(container.textContent).toContain('first block');
expect(container.textContent).toContain('second block');
});
it('shows a waiting Result for a pending call with arguments', () => {
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__ping',
status: 'in_progress',
args: { method: 'ping' },
}),
{ forceExpanded: true },
);
expect(container.textContent).toContain('Result');
expect(container.textContent).toContain('Waiting for output…');
});
it('shows a waiting Result for a pending call with no payload', () => {
const container = renderToolLine(
makeTool({ toolName: 'mcp__demo__slow', status: 'in_progress' }),
{ forceExpanded: true },
);
const occurrences = (container.textContent ?? '').split(
'Waiting for output…',
).length;
expect(occurrences - 1).toBe(1);
expect(container.textContent).toContain('Result');
});
it('sanitizes control characters in generic payloads', () => {
const raw = 'a\u202eb\x1bc';
const container = renderToolLine(
makeTool({ toolName: 'mcp__demo__unsafe', rawOutput: raw }),
{ forceExpanded: true },
);
const text = container.textContent ?? '';
expect(text).toContain('\\u202e');
expect(text).toContain('\\u001b');
expect(text).not.toContain('\u202e');
expect(text).not.toContain('\x1b');
});
it('sanitizes control characters in generic JSON payloads', () => {
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__unsafe-json',
rawOutput: '{"n":"a\u202eb"}',
}),
{ forceExpanded: true },
);
const text = container.textContent ?? '';
expect(text).toContain('\\u202e');
expect(text).not.toContain('\u202e');
});
it('offers Show more when escaping grows the payload past the cap', () => {
const tail = 'NUL-TAIL';
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__nul',
rawOutput: `${'\u0000'.repeat(2000)}${tail}`,
}),
{ forceExpanded: true },
);
const payload = container.querySelector('[class*="payloadText"]');
expect(payload).not.toBeNull();
expect(payload?.textContent?.length ?? 0).toBeLessThanOrEqual(4003);
expect(container.textContent).toContain('Content truncated');
expect(container.textContent).not.toContain(tail);
const more = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Show more',
);
expect(more).toBeDefined();
act(() => more?.click());
expect(container.textContent).toContain(tail);
});
it('does not repeat a cancelled call input as the result', () => {
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__ping',
status: 'completed',
wasCancelled: true,
args: { method: 'ping' },
rawOutput: {
status: 'cancelled',
reason: '{"method":"ping"}',
text: 'partial',
},
}),
{ forceExpanded: true },
);
const text = container.textContent ?? '';
expect(text).toContain('partial');
expect(text).not.toContain('"reason"');
expect(text.split('"method": "ping"').length - 1).toBe(1);
});
it('does not render the cancelled input dump as the result', () => {
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__ping',
status: 'completed',
wasCancelled: true,
args: { method: 'ping' },
rawOutput: {
status: 'cancelled',
reason: '{"method":"ping"}',
text: '{"method":"ping"}',
},
}),
{ forceExpanded: true },
);
const text = container.textContent ?? '';
expect(text).toContain('Cancelled');
expect(text.split('"method": "ping"').length - 1).toBe(1);
});
it.each(['output', 'stdout'] as const)(
'keeps cancelled %s output visible',
(key) => {
const output = `partial-${key}`;
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__cancel-output',
status: 'completed',
wasCancelled: true,
rawOutput: {
status: 'cancelled',
reason: '{"method":"ping"}',
[key]: output,
},
}),
{ forceExpanded: true },
);
const text = container.textContent ?? '';
expect(text).toContain(output);
expect(text).not.toContain('"reason"');
},
);
it('keeps a cancelled string output visible', () => {
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__cancel-string',
status: 'completed',
wasCancelled: true,
rawOutput: 'partial-string',
}),
{ forceExpanded: true },
);
expect(container.textContent).toContain('partial-string');
});
it('shows Cancelled when a cancelled call has no output', () => {
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__cancel-empty',
status: 'completed',
wasCancelled: true,
rawOutput: {},
}),
{ forceExpanded: true },
);
expect(container.textContent).toContain('Cancelled');
expect(container.textContent).not.toContain('Waiting for output…');
});
it('offers Show more for a large arguments payload', () => {
const tail = 'ARG-TAIL';
const container = renderToolLine(
makeTool({
toolName: 'mcp__demo__bigargs',
args: { body: `${'y'.repeat(4200)}${tail}` },
}),
{ forceExpanded: true },
);
expect(container.textContent).not.toContain(tail);
const more = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Show more',
);
expect(more).toBeDefined();
act(() => more?.click());
expect(container.textContent).toContain(tail);
expect(
[...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Show more',
),
).toBeUndefined();
});
it('offers Show more when escaping grows the arguments past the cap', () => {
const tail = 'ARG-ESCAPED-TAIL';
// JSON.stringify escapes C0 bytes but leaves the bidi controls that
// sanitizeControlChars renders as `\u202e`, so this payload only crosses
// the inline cap once escaping is counted.
const args = { note: `${'\u202e'.repeat(500)}${'a'.repeat(3000)}${tail}` };
const raw = JSON.stringify(args, null, 2);
expect(raw.length).toBeLessThanOrEqual(4000);
expect(sanitizeControlChars(raw).length).toBeGreaterThan(4000);
const container = renderToolLine(
makeTool({ toolName: 'mcp__demo__bidiargs', args }),
{ forceExpanded: true },
);
expect(container.textContent).not.toContain(tail);
const more = [...container.querySelectorAll('button')].find(
(button) => button.textContent === 'Show more',
);
expect(more).toBeDefined();
act(() => more?.click());
expect(container.textContent).toContain(tail);
});
it('bounds generic arguments in document mode', () => {
const tail = 'ARG-DOCUMENT-TAIL';
const container = renderToolGroup(
[
makeTool({
toolName: 'mcp__demo__bigargs',
args: { content: `${'x'.repeat(50_000)}${tail}` },
rawOutput: 'ok',
}),
],
{},
undefined,
'document',
);
expect(container.textContent).not.toContain(tail);
expect(container.textContent).toContain('Content truncated');
});
it('keeps document-mode JSON formatting above the interactive budget', () => {
const items = Array.from({ length: 1200 }, (_, i) => ({
id: i,
name: 'x'.repeat(30),
}));
const raw = JSON.stringify(items);
const formatted = JSON.stringify(items, null, 2);
expect(raw.length).toBeGreaterThan(40_000);
expect(formatted.length).toBeLessThan(100_000);
const container = renderToolGroup(
[
makeTool({
toolName: 'mcp__demo__docjson',
rawOutput: raw,
}),
],
{},
undefined,
'document',
);
expect(container.querySelector('[class*="payloadJson"]')).not.toBeNull();
expect(container.querySelector('[class*="payloadText"]')).toBeNull();
});
it('shows the full payload without Show more in document mode', () => {
const tail = 'DOCUMENT-TAIL';
const container = renderToolGroup(
[
makeTool({
toolName: 'mcp__demo__dump',
rawOutput: `${'x'.repeat(4200)}${tail}`,
}),
],
{},
undefined,
'document',
);
expect(container.textContent).toContain(tail);
expect(container.textContent).not.toContain('Show more');
});
it('keeps highlighting for a many-line JSON result within the char budget', () => {
const items = Array.from({ length: 400 }, (_, i) => ({ id: i }));
const container = renderToolGroup(
[
makeTool({
toolName: 'mcp__demo__many',
rawOutput: JSON.stringify(items),
}),
],
{},
undefined,
'document',
);
// Pretty-printed, the payload spans well over 1000 lines but stays within
// the render cap; it must render as a highlighted JSON block, not fall back
// to the plain-text container.
expect(container.querySelector('[class*="payloadJson"]')).not.toBeNull();
expect(container.querySelector('[class*="payloadText"]')).toBeNull();
});
});
@@ -53,7 +53,12 @@ import {
import {
extractText,
hasRecordedOutput,
EXPANDED_TOOL_PAYLOAD_LENGTH,
formatJsonPayload,
formatTokenCount,
MAX_TOOL_PAYLOAD_LENGTH,
sanitizeControlChars,
stringifyValue,
getAgentCancellationReason,
getAgentCurrentToolHint,
getAgentDescription,
@@ -141,9 +146,9 @@ function openMonitorDetailsOnce(
// Tools whose expanded row renders a kind-specific detail view (shell output /
// diff / file content / Q&A). Must stay in sync with the renderers in
// ToolLine's lineDetail block below. Tools NOT in this set have nothing extra
// to show when expanded, so they keep their one-line result summary instead of
// hiding it behind an empty detail area.
// ToolLine's lineDetail block below. Tools NOT in this set render the call's
// own arguments and recorded output in their expanded card; the collapsed row
// still shows the one-line summary from getToolResultSummary.
function hasDetailView(tool: ACPToolCall): boolean {
const name = tool.toolName.toLowerCase();
return (
@@ -228,6 +233,7 @@ function isTruncatedSessionDiff(raw: Record<string, unknown>): boolean {
const DESCRIPTION_EXPAND_THRESHOLD = 60;
const MAX_MARKDOWN_READ_CHARS = 200_000;
const MAX_MARKDOWN_READ_LINES = 1000;
const MAX_DOCUMENT_JSON_PAYLOAD_LENGTH = 100_000;
const READ_LANGUAGE_ALIASES: Record<string, string> = {
cjs: 'javascript',
cts: 'typescript',
@@ -356,6 +362,176 @@ function ToolExpandedCard({
);
}
function ToolPayloadBlock({
payload,
limit,
}: {
payload: { text: string; json: boolean };
limit: number;
}) {
const { t } = useI18n();
const renderMode = useTranscriptRenderMode();
// Escape before applying the cap: control-character escapes can expand a
// payload up to six characters per input character.
const escaped = sanitizeControlChars(truncateText(payload.text, limit));
const shown = escaped.length > limit ? truncateText(escaped, limit) : escaped;
const truncated = payload.text.length > limit || escaped.length > limit;
// Highlight a canonical JSON payload as long as it fits the Markdown budget.
// The former 1000-line read-file guard is deliberately not applied here: a
// tool result is one logical value, not a file preview, and can be short in
// characters yet span many lines once pretty-printed (e.g. a compact array of
// records). formatJsonPayload already caps the formatted text by render mode,
// and the read-file line budget lives on to guard ExpandedReadContent below.
const renderAsMarkdown =
payload.json && shown.length <= MAX_MARKDOWN_READ_CHARS;
return (
<>
{renderAsMarkdown ? (
<div className={styles.payloadJson}>
<Markdown content={fencedCodeBlock('json', shown)} />
</div>
) : (
<pre className={styles.payloadText}>
{payload.json ? shown : renderWithSessionLinks(shown, renderMode)}
</pre>
)}
{truncated && (
<p className={styles.payloadTruncated}>
{t('trajectory.inspector.truncated')}
</p>
)}
</>
);
}
function toolResultText(tool: ACPToolCall, includeExtractText = true): string {
const texts: string[] = [];
for (const block of tool.content ?? []) {
if (block.type === 'content' && typeof block.content?.text === 'string') {
texts.push(block.content.text);
}
}
if (texts.length > 0) return texts.join('\n');
return includeExtractText ? (extractText(tool) ?? '') : '';
}
function cancelledOutputText(rawOutput: unknown): string {
if (typeof rawOutput === 'string') return rawOutput;
if (
rawOutput === null ||
typeof rawOutput !== 'object' ||
Array.isArray(rawOutput)
) {
return '';
}
const record = rawOutput as Record<string, unknown>;
for (const key of ['text', 'output', 'stdout'] as const) {
const value = record[key];
if (typeof value !== 'string') continue;
// A cancelled call that recorded no output carries the redacted input dump
// in `text`, written there as the same string as `reason`; it is not output.
if (key === 'text' && value === record['reason']) continue;
return value;
}
return '';
}
function GenericToolDetails({
tool,
pending,
cancelled,
}: {
tool: ACPToolCall;
pending: boolean;
cancelled: boolean;
}) {
const { t } = useI18n();
const argumentsText = useMemo(
() =>
tool.args && Object.keys(tool.args).length > 0
? stringifyValue(tool.args)
: '',
[tool],
);
const resultText = useMemo(
() =>
toolResultText(tool, !tool.wasCancelled) ||
(tool.wasCancelled
? cancelledOutputText(tool.rawOutput)
: stringifyValue(tool.rawOutput)) ||
'',
[tool],
);
const renderMode = useTranscriptRenderMode();
const documentMode = renderMode === 'document';
const formatBudget = documentMode
? MAX_DOCUMENT_JSON_PAYLOAD_LENGTH
: EXPANDED_TOOL_PAYLOAD_LENGTH;
const [expanded, setExpanded] = useState(false);
const limit = documentMode
? Number.POSITIVE_INFINITY
: expanded
? EXPANDED_TOOL_PAYLOAD_LENGTH
: MAX_TOOL_PAYLOAD_LENGTH;
const argumentsLimit = documentMode ? EXPANDED_TOOL_PAYLOAD_LENGTH : limit;
// Pretty-printing grows a compact payload, and the blocks truncate what they
// render, so the "Show more" decision has to use the formatted lengths.
const argumentsPayload = useMemo(
() => formatJsonPayload(argumentsText, formatBudget, formatBudget),
[argumentsText, formatBudget],
);
const resultPayload = useMemo(
() => formatJsonPayload(resultText, formatBudget, formatBudget),
[resultText, formatBudget],
);
const argumentsNeedsExpansion = useMemo(
() =>
!documentMode &&
sanitizeControlChars(argumentsPayload.text).length >
MAX_TOOL_PAYLOAD_LENGTH,
[argumentsPayload.text, documentMode],
);
const resultNeedsExpansion = useMemo(
() =>
!documentMode &&
sanitizeControlChars(resultPayload.text).length > MAX_TOOL_PAYLOAD_LENGTH,
[resultPayload.text, documentMode],
);
const canExpand =
!expanded && (argumentsNeedsExpansion || resultNeedsExpansion);
return (
<div className={styles.payloadSections}>
{argumentsText && (
<section className={styles.payloadSection}>
<div className={styles.payloadLabel}>{t('turnCalls.arguments')}</div>
<ToolPayloadBlock payload={argumentsPayload} limit={argumentsLimit} />
</section>
)}
{(resultText || pending || cancelled) && (
<section className={styles.payloadSection}>
<div className={styles.payloadLabel}>{t('turnCalls.result')}</div>
{resultText ? (
<ToolPayloadBlock payload={resultPayload} limit={limit} />
) : cancelled ? (
<p className={styles.payloadEmpty}>{t('shell.result.cancelled')}</p>
) : (
<p className={styles.payloadEmpty}>{t('shell.result.waiting')}</p>
)}
</section>
)}
{canExpand && (
<button
type="button"
className={styles.payloadMore}
onClick={() => setExpanded(true)}
>
{t('trajectory.inspector.expand')}
</button>
)}
</div>
);
}
// Collapsed by default: the diff of this todo_write call (just-completed and
// just-started items), expanding to the full list on click. The per-snapshot
// diff comes from the timeline context, so this is isolated in its own
@@ -1133,7 +1309,8 @@ export const ToolLine = memo(function ToolLine({
const transcriptRenderMode = useTranscriptRenderMode();
const subagentDetails = useSubagentDetails();
const monitorDetails = useMonitorDetails();
const { hostOwnsEditDiffPreview } = useWebShellCustomization();
const { hostOwnsEditDiffPreview, renderToolHeaderExtra } =
useWebShellCustomization();
const monitorDetailsAvailable = monitorDetails !== undefined;
const mcpApp = getMcpAppDisplay(tool.rawOutput);
const mcpAppAllowed = useMcpAppAdmission(mcpApp);
@@ -1440,8 +1617,8 @@ export const ToolLine = memo(function ToolLine({
);
};
// Whether the expanded row renders a kind-specific detail view. When it does
// not (e.g. grep/glob/web_fetch with a long description), keep the result
// summary visible instead of replacing it with an empty detail area.
// not, the expanded card renders the call's own arguments and recorded
// output; the collapsed row still shows the one-line result summary.
const detailView = hasDetailView(tool);
const showDescriptionInDetail = expanded && descExpandable;
const useMarkdownDetail = isRead || isAdvisor;
@@ -1451,6 +1628,31 @@ export const ToolLine = memo(function ToolLine({
// Contentless tools still get a titled card when the user opens them.
const showExpandedSummaryPanel =
expanded && !detailView && (!isTodo || (!hasTodoList && !result));
const showGenericDetails = !mcpApp && showExpandedSummaryPanel;
// A card outside the built-in detail renderers shows the call itself rather
// than repeating the header summary. The values are already redacted at the
// daemon boundary and carried by the ACP tool call; no extra read is needed.
const hasGenericArguments =
showGenericDetails &&
Boolean(tool.args && Object.keys(tool.args).length > 0);
const hasContentText =
tool.content?.some(
(block) =>
block.type === 'content' &&
typeof block.content?.text === 'string' &&
block.content.text.length > 0,
) ?? false;
const hasRawOutput =
tool.rawOutput !== undefined &&
tool.rawOutput !== null &&
(typeof tool.rawOutput !== 'string' || tool.rawOutput.length > 0);
const hasGenericResult =
showGenericDetails && (hasContentText || hasRawOutput);
const hasGenericPayload =
hasGenericArguments ||
hasGenericResult ||
(showGenericDetails &&
(isActiveToolStatus(tool.status) || tool.wasCancelled === true));
return (
<div className={styles.line} data-transcript-tool-call-id={tool.callId}>
@@ -1576,17 +1778,25 @@ export const ToolLine = memo(function ToolLine({
{!mcpApp && showExpandedSummaryPanel && (
<ToolExpandedCard
title={displayName}
detail={expandedCardDetail}
detail={
hasGenericPayload &&
!showDescriptionInDetail &&
!hideHeader &&
!isTodo &&
!renderToolHeaderExtra
? undefined
: expandedCardDetail
}
status={tool.status}
action={filePreviewAction}
>
{result && (
<div
className={`${styles.lineOutput} ${styles.expandedLineOutput}`}
>
{renderWithSessionLinks(result, transcriptRenderMode)}
</div>
)}
{hasGenericPayload ? (
<GenericToolDetails
tool={tool}
pending={isActiveToolStatus(tool.status)}
cancelled={tool.wasCancelled === true}
/>
) : null}
</ToolExpandedCard>
)}
{!mcpApp &&
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import type { ACPToolCall } from '../../adapters/types';
import {
EXPANDED_TOOL_PAYLOAD_LENGTH,
extractRawOutputText,
formatJsonPayload,
formatToolDisplayName,
getAgentCurrentToolHint,
getSubagentDetailsUnavailableReason,
@@ -609,6 +611,85 @@ describe('toolFormatting', () => {
});
});
describe('formatJsonPayload', () => {
it('pretty-prints a compact object', () => {
expect(formatJsonPayload('{"a":1,"b":[2]}')).toEqual({
text: '{\n "a": 1,\n "b": [\n 2\n ]\n}',
json: true,
});
});
it('pretty-prints an array of objects', () => {
const formatted = formatJsonPayload('[{"id":1}]');
expect(formatted.json).toBe(true);
expect(formatted.text).toBe('[\n {\n "id": 1\n }\n]');
});
it('keeps an already indented document as JSON', () => {
expect(formatJsonPayload('{\n "a": 1\n}')).toEqual({
text: '{\n "a": 1\n}',
json: true,
});
});
it('leaves JSON Lines exactly as recorded', () => {
const text = '{"a":1}\n{"b":2}';
expect(formatJsonPayload(text)).toEqual({ text, json: false });
});
it('leaves a mixed JSON-lines payload untouched', () => {
const text = '{"a":1}\nnot json';
expect(formatJsonPayload(text)).toEqual({ text, json: false });
});
it.each(['hello', '42', '"text"', 'null', '{"a":'])(
'leaves %s untouched',
(text) => {
expect(formatJsonPayload(text)).toEqual({ text, json: false });
},
);
it('leaves non-canonical number literals as recorded', () => {
const text =
'{"n":1.2345678901234567890,"id":12345678901234567890,"neg":-0,"exp":1e3,"amount":1.10,"tiny":1e-400,"huge":1e400}';
expect(formatJsonPayload(text)).toEqual({ text, json: false });
});
it('leaves duplicate keys and their order as recorded', () => {
const text = '{"a":1,"a":2,"500":"server error","200":"ok"}';
expect(formatJsonPayload(text)).toEqual({ text, json: false });
});
it('preserves escaped string contents', () => {
const text = '{"s":"a\\"b}{","n":1}';
expect(formatJsonPayload(text)).toEqual({
text: '{\n "s": "a\\"b}{",\n "n": 1\n}',
json: true,
});
});
it('leaves deeply nested small JSON unformatted', () => {
const text = '{"a":'.repeat(40) + '1' + '}'.repeat(40);
expect(JSON.stringify(JSON.parse(text), null, 2).length).toBeLessThan(
EXPANDED_TOOL_PAYLOAD_LENGTH,
);
expect(formatJsonPayload(text)).toEqual({ text, json: false });
});
it('falls back when formatted output exceeds the render cap', () => {
const text = '{"a":1}';
expect(formatJsonPayload(text, 5)).toEqual({ text, json: false });
});
it('leaves an oversized payload alone instead of parsing it', () => {
const text = JSON.stringify({
value: 'x'.repeat(EXPANDED_TOOL_PAYLOAD_LENGTH),
});
expect(text.length).toBeGreaterThan(EXPANDED_TOOL_PAYLOAD_LENGTH);
expect(formatJsonPayload(text)).toEqual({ text, json: false });
});
});
describe('subagent detail availability', () => {
it.each([
[false, 'pending', false, 'subagent.creating'],
@@ -206,6 +206,107 @@ export function truncateText(text: string, max: number): string {
return text.slice(0, max) + '...';
}
/** Longest payload stretch a transcript card renders before "Show more". */
export const MAX_TOOL_PAYLOAD_LENGTH = 4000;
/** Ceiling after "Show more", matching the trajectory inspector. */
export const EXPANDED_TOOL_PAYLOAD_LENGTH = 40_000;
export function stringifyValue(value: unknown): string {
if (value === undefined || value === null) return '';
const text = typeof value === 'string' ? value : safeJson(value);
return text.trim();
}
function safeJson(value: unknown): string {
try {
return JSON.stringify(value, null, 2) ?? '';
} catch {
return '';
}
}
/**
* Re-indents a canonical JSON object or array for display. The input must be
* exactly its compact JSON form or its two-space form; anything else — plain
* text, a primitive, a truncated document, JSON Lines, a mixed JSON/text log
* or non-canonical JSON — is returned as recorded so the caller can fall back
* to plain text. Formatting therefore never rewrites literals, duplicate
* keys, key order or string escapes.
*
* These rules are scoped to the transcript tool card. The sibling
* ToolCallDetail used by the turn-calls / trajectory panes still fences via a
* bare JSON.parse, so identical bytes can render differently per surface;
* lifting one shared Arguments/Result renderer is tracked as a follow-up
* rather than widened into this change.
*/
export function formatJsonPayload(
text: string,
maxFormattedLength = EXPANDED_TOOL_PAYLOAD_LENGTH,
maxInputLength = EXPANDED_TOOL_PAYLOAD_LENGTH,
): {
text: string;
json: boolean;
} {
// Pretty-printing can expand a compact payload well past the render cap, and
// parsing a multi-megabyte string is not worth it for output the card will
// never show in full.
if (text.length > maxInputLength) {
return { text, json: false };
}
const trimmed = text.trim();
if (!trimmed) return { text, json: false };
const document = formatJsonDocument(trimmed, maxFormattedLength);
if (document !== undefined) return { text: document, json: true };
return { text, json: false };
}
const MAX_JSON_FORMAT_DEPTH = 32;
function formatJsonDocument(
text: string,
maxFormattedLength: number,
): string | undefined {
if (!/^[[{]/.test(text)) return undefined;
let value: unknown;
try {
value = JSON.parse(text);
} catch {
return undefined;
}
if (value === null || typeof value !== 'object') return undefined;
if (exceedsJsonDepth(value, MAX_JSON_FORMAT_DEPTH)) return undefined;
// Compare against both canonical forms; only an exact match is safe to
// reformat, because anything else would rewrite recorded literals or keys.
const pretty = JSON.stringify(value, null, 2);
if (text === pretty) return text;
const compact = JSON.stringify(value);
if (text !== compact || pretty.length > maxFormattedLength) return undefined;
return pretty;
}
function exceedsJsonDepth(root: unknown, maxDepth: number): boolean {
const stack: Array<[unknown, number]> = [[root, 1]];
while (stack.length > 0) {
const current = stack.pop();
if (!current) break;
const [value, depth] = current;
if (depth > maxDepth) return true;
if (Array.isArray(value)) {
for (const entry of value) stack.push([entry, depth + 1]);
} else if (value !== null && typeof value === 'object') {
for (const entry of Object.values(value)) {
stack.push([entry, depth + 1]);
}
}
}
return false;
}
// The tool-header description is shown single-line (CSS-ellipsised) when the
// row is collapsed and fully wrapped when it is expanded, so we keep the whole
// string rather than hard-capping it at a line's worth of characters. A
@@ -261,7 +261,7 @@ describe('SubAgentPanel sub-tool timestamps', () => {
expect(stepWindow?.className).toContain('tools');
});
it('hides non-standard sub-tool summaries until the row is expanded', () => {
it('hides non-standard sub-tool details until the row is expanded', () => {
const container = renderPanel(
makeAgentWithSubTool({
callId: 'sub-1',
@@ -275,9 +275,10 @@ describe('SubAgentPanel sub-tool timestamps', () => {
) as HTMLElement | undefined;
expect(row).toBeDefined();
expect(container.textContent).not.toContain('2 item(s)');
expect(container.textContent).not.toContain('package.json');
act(() => row!.click());
expect(container.textContent).toContain('2 item(s)');
expect(container.textContent).toContain('Result');
expect(container.textContent).toContain('package.json');
});
});
@@ -623,6 +623,78 @@
min-width: 0;
}
.payloadSections {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.payloadSection {
min-width: 0;
}
.payloadLabel {
margin-bottom: 4px;
color: var(--muted-foreground);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.payloadJson {
max-height: 240px;
overflow: auto;
}
.payloadText {
margin: 0;
padding: 8px 10px;
max-height: 240px;
overflow: auto;
border-radius: 5px;
background: var(--muted);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
:global([data-transcript-render-mode='document']) .payloadJson,
:global([data-transcript-render-mode='document']) .payloadText {
max-height: none;
overflow: visible;
}
.payloadEmpty,
.payloadTruncated {
margin: 0;
color: var(--muted-foreground);
font-size: 12px;
}
.payloadTruncated {
margin-top: 4px;
}
.payloadMore {
align-self: flex-start;
margin-top: 2px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--background);
color: var(--foreground);
font-size: 12px;
cursor: pointer;
}
.payloadMore:hover {
background: var(--muted);
}
.expandedAgentCard {
background: var(--secondary);
border: 0.5px solid var(--border);
@@ -42,8 +42,20 @@ let root: Root | null = null;
let container: HTMLDivElement | null = null;
let scopedResult: ReturnType<typeof useScopedSessions> | undefined;
function Probe({ cwd }: { cwd?: string }) {
const result = useScopedSessions(cwd, { autoLoad: true });
function Probe({
cwd,
sourceType,
liveOnly,
}: {
cwd?: string;
sourceType?: string | null;
liveOnly?: boolean;
}) {
const result = useScopedSessions(cwd, {
autoLoad: true,
...(sourceType !== undefined ? { sourceType } : {}),
...(liveOnly === true ? { liveOnly: true } : {}),
});
scopedResult = result;
const { sessions, deleteSessions, releaseSession } = result;
return (
@@ -67,8 +79,12 @@ function Probe({ cwd }: { cwd?: string }) {
);
}
function render(cwd?: string) {
act(() => root!.render(<Probe cwd={cwd} />));
function render(cwd?: string, sourceType?: string | null, liveOnly?: boolean) {
act(() =>
root!.render(
<Probe cwd={cwd} sourceType={sourceType} liveOnly={liveOnly} />,
),
);
}
beforeEach(() => {
@@ -175,6 +191,44 @@ describe('useScopedSessions', () => {
expect(primaryDeleteSessions).not.toHaveBeenCalled();
});
it('omits the source filter and requests live sessions for the release dialog', async () => {
listWorkspaceSessionsPage.mockResolvedValue({
sessions: [
{
sessionId: 'channel',
workspaceCwd: '/all-sources',
sourceType: 'channel',
},
] satisfies DaemonSessionSummary[],
});
render('/all-sources', null, true);
await act(async () => {
await listWorkspaceSessionsPage.mock.results[0]?.value;
});
expect(listWorkspaceSessionsPage).toHaveBeenCalledWith({ liveOnly: true });
expect(
container!.querySelector('[data-testid="sessions"]')?.textContent,
).toBe('channel');
});
it('forwards liveOnly through the unscoped primary catalog', async () => {
workspaceClient.listWorkspaceSessionsPage.mockResolvedValue({
sessions: [],
});
render(undefined, null, true);
await act(async () => {
await workspaceClient.listWorkspaceSessionsPage.mock.results[0]?.value;
});
expect(workspaceClient.listWorkspaceSessionsPage).toHaveBeenCalledWith(
'/primary',
{ liveOnly: true },
);
});
it('releases a scoped session without invalidating the primary catalog', async () => {
listWorkspaceSessionsPage.mockResolvedValue({ sessions: [] });
primaryReleaseSession.mockResolvedValue(undefined);
@@ -19,6 +19,10 @@ interface ScopedSessionsOptions {
view?: 'organized';
group?: string;
pollIntervalMs?: number;
/** `null` queries every source type; omitted keeps the sidebar default. */
sourceType?: string | null;
/** List live bridge sessions only, so closed history cannot hide them. */
liveOnly?: boolean;
}
export function useScopedSessions(
@@ -35,7 +39,13 @@ export function useScopedSessions(
view,
group,
pollIntervalMs,
liveOnly,
sourceType: sourceTypeOption,
} = options;
const sourceType =
sourceTypeOption === null
? undefined
: (sourceTypeOption ?? WEB_SHELL_SESSION_SOURCE_TYPE);
const primary = useWebShellSessions({
autoLoad,
enabled: enabled && !workspaceCwd,
@@ -44,7 +54,8 @@ export function useScopedSessions(
archiveState,
view,
group,
sourceType: WEB_SHELL_SESSION_SOURCE_TYPE,
...(sourceType !== undefined ? { sourceType } : {}),
...(liveOnly === true ? { liveOnly: true } : {}),
...(pollIntervalMs !== undefined ? { pollIntervalMs } : {}),
});
const primaryDeleteSession = primary.deleteSession;
@@ -63,11 +74,12 @@ export function useScopedSessions(
...(archiveState !== undefined ? { archiveState } : {}),
...(view !== undefined ? { view } : {}),
...(group !== undefined ? { group } : {}),
sourceType: WEB_SHELL_SESSION_SOURCE_TYPE,
...(sourceType !== undefined ? { sourceType } : {}),
...(liveOnly === true ? { liveOnly: true } : {}),
},
}
: undefined,
[archiveState, group, pageSize, view, workspaceCwd],
[archiveState, group, liveOnly, pageSize, sourceType, view, workspaceCwd],
);
const scoped = useSessionCatalogQuery(workspace.client, query, {
autoLoad,
@@ -48,6 +48,7 @@ export interface WebShellSessionsOptions extends SessionCatalogHookOptions {
sourceType?: string;
sourceId?: string;
parentSessionId?: string;
liveOnly?: boolean;
}
export function useSessionCatalogQuery(
@@ -519,6 +520,7 @@ export function useWebShellSessions(options: WebShellSessionsOptions = {}) {
sourceType,
sourceId,
parentSessionId,
liveOnly,
} = options;
const workspace = useWorkspace();
const legacy = useSessions({
@@ -544,11 +546,13 @@ export function useWebShellSessions(options: WebShellSessionsOptions = {}) {
...(sourceType !== undefined ? { sourceType } : {}),
...(sourceId !== undefined ? { sourceId } : {}),
...(parentSessionId !== undefined ? { parentSessionId } : {}),
...(liveOnly === true ? { liveOnly: true } : {}),
}),
[
archiveState,
cursor,
group,
liveOnly,
pageSize,
parentSessionId,
sourceId,
@@ -170,6 +170,7 @@ describe('SessionCatalogStore', () => {
{ parentSessionId: 'parent' },
{ sourceType: 'side-task' },
{ sourceId: 'source' },
{ liveOnly: true },
]) {
expect(getSessionCatalogQueryKey(base)).not.toBe(
getSessionCatalogQueryKey({
@@ -124,6 +124,7 @@ export function getSessionCatalogQueryKey(query: SessionCatalogQuery): string {
options?.parentSessionId ?? null,
options?.sourceType ?? null,
options?.sourceId ?? null,
options?.liveOnly ?? null,
]);
}
+39
View File
@@ -0,0 +1,39 @@
ARG BASE_IMAGE=node:22-bookworm-slim@sha256:5ec23b7c622b00547753bc6ffd78085dd868a68aeb41c8e0a1418f033e13d96d
FROM ${BASE_IMAGE}
ARG TARGETARCH
ARG RUNNER_VERSION=2.338.2
ARG RUNNER_RELEASE_URL=https://github.com/actions/runner/releases/download
ARG DEBIAN_MIRROR=deb.debian.org
ARG PIP_INDEX_URL=https://pypi.org/simple
RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources \
&& apt-get update -o Acquire::http::Timeout=30 \
&& apt-get install -y --no-install-recommends \
build-essential ca-certificates curl file gh git git-lfs jq libicu72 \
libkrb5-3 libssl3 libunwind8 procps python3 python3-pip python3-venv \
sudo tmux unzip zip zlib1g \
&& rm -rf /var/lib/apt/lists/* \
&& python3 -m venv /opt/ci-tools \
&& /opt/ci-tools/bin/pip install --no-cache-dir 'yamllint==1.35.1' \
&& echo 'node ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/runner
ENV PATH="/opt/ci-tools/bin:${PATH}"
WORKDIR /home/node/actions-runner
RUN case "${TARGETARCH}" in \
amd64) runner_arch=x64; runner_sha=baebf204307bac6193480852732f0aba2ae97b6c5597c244f4d5558390147c40 ;; \
arm64) runner_arch=arm64; runner_sha=670adb2f268fe21dcbb58b3db0e0aa9d5b3bddfe2c651481336e561ff86de945 ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& curl -fsSL --retry 3 --connect-timeout 15 --max-time 180 -o /tmp/runner.tar.gz \
"${RUNNER_RELEASE_URL}/v${RUNNER_VERSION}/actions-runner-linux-${runner_arch}-${RUNNER_VERSION}.tar.gz" \
&& echo "${runner_sha} /tmp/runner.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/runner.tar.gz \
&& rm /tmp/runner.tar.gz \
&& chown -R node:node /home/node/actions-runner
COPY entrypoint.sh /usr/local/bin/ephemeral-runner
RUN chmod 755 /usr/local/bin/ephemeral-runner
USER node
ENTRYPOINT ["/usr/local/bin/ephemeral-runner"]
+140
View File
@@ -0,0 +1,140 @@
# Ephemeral GitHub Actions runners
Each slot registers one just-in-time runner and runs it in a new container.
After at most one job, the manager removes the container and runner registration
before creating the next one. While the manager waits on a container it also
enforces a per-slot deadline and removes a container whose runner does not exit;
a manager crash is covered by reconciling leftover resources on the next start.
Each job has a separate Docker bridge with inter-container communication
disabled. Deleting the network is part of cleanup and a failure blocks slot
replacement. This does not itself prevent access to the host or private
networks: configure and verify the host's external egress controls separately.
Use a dedicated host for untrusted pull requests. Docker shares the host kernel;
jobs must not receive the host Docker socket, host directories, cloud instance
credentials, internal network access, or long-lived secrets. A runner label is a
scheduling selector, not an access-control boundary. Before enabling external
pull requests, verify they cannot select other sensitive runner pools.
## Build the image
The image supports Linux x64 and ARM64. Its Node base is pinned to an OCI index
digest and the runner downloads are checked against pinned SHA-256 digests.
Use BuildKit to select both the base image and runner architecture:
```sh
docker buildx build --platform linux/arm64 --load -t qwen-ephemeral-runner:trial scripts/ci/ephemeral-runner
```
BuildKit supplies `TARGETARCH`. With Docker's legacy builder, build on the target
architecture and pass its matching `TARGETARCH` explicitly (`amd64` or `arm64`).
That argument selects only the runner archive; it does not change the base image
architecture.
The runner image supports ARM64, but full PR CI on this image has not yet been
verified. The repository also selects ARM64 linters; verify the complete CI
toolchain before routing production work to either architecture.
Hosts with restricted upstream connectivity can set `DEBIAN_MIRROR`,
`PIP_INDEX_URL` or `RUNNER_RELEASE_URL` at build time. The runner archive is
still verified against its pinned digest when served from a mirror.
For a real deployment, publish the image to a registry and configure its
immutable `repository@sha256:...` digest. A local `sha256:...` image ID may be
used for a trial. Runner and base versions need routine security updates.
## Configure the manager
Run one manager on each host, managing only that host's Docker socket. Give each
host a unique `poolId`; matching runner labels let GitHub share work across the
hosts without a central scheduler. `slots` is a per-host limit, not a fleet total.
New runner names start with `ecs-qwen-` so the existing CI worker caps and test
timeouts apply. Restart recovery also accepts the earlier `qwen-` identities.
Install Docker, Node 22, `gh`, and `flock` on the host. The manager uses a
dedicated PAT supplied as `GH_TOKEN`. The account must administer the target
repository. Prefer a fine-grained PAT restricted to that repository with
Administration: write and a short expiry. GitHub currently requires this broader
permission for runner registration; it is not a runner-only credential. Some
organization or outside-collaborator policies prevent fine-grained access. Do
not silently replace it with a classic `repo` token, which can cover other
repositories the account can access.
Store `GH_TOKEN=<PAT>` in `/etc/qwen-ephemeral-runner/credentials.env`, owned by
root with mode `600`, inside the root-only configuration directory. The supplied
systemd unit loads this file; do not put it in shell history, config JSON, job
environments or the repository. Replace it and restart the service to rotate or
renew the token. The service fails startup if `GH_TOKEN` is missing. Docker child
environments are allowlisted, and only the one-time JIT configuration enters a
job container. Host root and Docker administrators are trusted and can access
host credentials; ordinary container isolation does not change that boundary.
Copy `config.example.json` to `/etc/qwen-ephemeral-runner/config.json` and
replace the placeholders: repository, actual runner group ID, unique host/pool
ID, immutable image reference, and matching architecture and labels. Use a
unique test label and a controlled test repository for a trial. The supplied
manual smoke workflow selects only `ecs-qwen-ephemeral-smoke`; a trial runner
can register with just that label to avoid matching generic Linux jobs. The example runs
one slot; increase only after measuring the host. Restrict configuration and
credential directories to the manager account.
The `ttlSeconds` limit starts when the container starts, including idle time.
A job assigned near the deadline can be interrupted. The manager removes and
replaces the container; GitHub remains authoritative for job status. The
manager logs cleanup but does not distinguish expiry from normal completion.
A nonzero or unknown container exit status delays replenishment by 60 seconds;
a successful short job is replenished immediately. GitHub remains authoritative
for job success. Diagnostic export is best effort: stderr is not archived, and
output exceeding the capture limits is discarded.
`minDiskBytes` applies to the Docker data filesystem. Configure and verify
disk quotas or a dedicated data volume as appropriate for the host.
## Install and operate
Install `manager.mjs` in `/opt/qwen-ephemeral-runner/`, install the supplied
unit in `/etc/systemd/system/`, and create the configured state/log directories
with permissions restricted to the manager account. From the trusted checkout:
```sh
sudo install -d /opt/qwen-ephemeral-runner
sudo install -d -m 700 /etc/qwen-ephemeral-runner /var/lib/qwen-ephemeral-runner /var/log/qwen-ephemeral-runner
sudo install -m 644 scripts/ci/ephemeral-runner/manager.mjs /opt/qwen-ephemeral-runner/manager.mjs
sudo install -m 644 scripts/ci/ephemeral-runner/qwen-ephemeral-runner.service /etc/systemd/system/qwen-ephemeral-runner.service
```
Finish the configuration and PAT setup above before starting. A Docker-authorized user
has host-level power; the unit is intended for a trusted dedicated host.
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now qwen-ephemeral-runner.service
sudo systemctl status qwen-ephemeral-runner.service
sudo journalctl -u qwen-ephemeral-runner.service -f
```
systemd restarts an unexpectedly exited manager after ten seconds, including a
clean exit. An explicit `systemctl stop` leaves it stopped. This is process
supervision, not hang detection; inspect the journal and runner availability if
slots are not replenishing.
Stopping the service stops replenishment, waits up to the configured grace
period, and cleans up its containers. The next start reconciles abandoned
resources first. A cleanup or ambiguous registration error blocks that slot;
inspect its state and GitHub runner list before manual reconciliation. Never
remove unrelated runners or run a global Docker prune.
```sh
sudo systemctl stop qwen-ephemeral-runner.service
```
After validating a single runner and its recovery paths, set the repository
variable `EXTERNAL_PR_EPHEMERAL_RUNNER_ENABLED=true` to route untrusted fork PR
build, test, lint and no-AK integration jobs to this pool. Unset, `false`, or any
other value keeps those jobs on `ubuntu-latest`. Trusted and same-repository PRs
retain their existing routing. The global kill-switch and hosted-fleet override
still win. Leave the switch off until the host and GitHub access policies have
been verified. Changing it affects newly evaluated jobs; cancel queued work and
start a new workflow run when rolling back. It does not detect idle runners or
migrate a queued job automatically.
Set `EXTERNAL_PR_EPHEMERAL_ARCH=arm64` for an ARM64 pool; the default is x64.
@@ -0,0 +1,16 @@
{
"repo": "QwenLM/qwen-code",
"runnerGroupId": 1,
"poolId": "external-ci-host-1",
"image": "registry.example.invalid/qwen-runner@sha256:0000000000000000000000000000000000000000000000000000000000000000",
"labels": ["self-hosted", "linux", "x64", "ecs-qwen-ephemeral"],
"slots": 1,
"cpus": 4,
"memory": "8g",
"pidsLimit": 1024,
"ttlSeconds": 10800,
"shutdownGraceSeconds": 60,
"minDiskBytes": 10737418240,
"stateDir": "/var/lib/qwen-ephemeral-runner",
"logDir": "/var/log/qwen-ephemeral-runner"
}
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
: "${RUNNER_JIT_CONFIG:?RUNNER_JIT_CONFIG is required}"
jit_config="$RUNNER_JIT_CONFIG"
unset RUNNER_JIT_CONFIG
cd /home/node/actions-runner
exec ./run.sh --jitconfig "$jit_config"
+620
View File
@@ -0,0 +1,620 @@
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import { realpathSync } from 'node:fs';
import {
lstat,
mkdir,
readFile,
readdir,
rename,
rm,
statfs,
writeFile,
} from 'node:fs/promises';
import { isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const sleep = (ms) =>
new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
export function command(
program,
args,
{ env = process.env, input, timeout = 30000, binary = false } = {},
) {
return new Promise((resolveCommand, reject) => {
const child = spawn(program, args, {
env,
stdio: ['pipe', 'pipe', 'pipe'],
});
const output = [];
let outputSize = 0;
let stderr = '';
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
child.stdout.on('data', (chunk) => {
outputSize += chunk.length;
if (outputSize > 4 * 1024 * 1024) child.kill('SIGKILL');
else output.push(chunk);
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
if (stderr.length > 1024 * 1024) child.kill('SIGKILL');
});
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.on('close', (code) => {
clearTimeout(timer);
if (code === 0 && outputSize <= 4 * 1024 * 1024)
resolveCommand(
binary ? Buffer.concat(output) : Buffer.concat(output).toString(),
);
else {
const status = Number(stderr.match(/HTTP (\d{3})/)?.[1] || 0);
const error = new Error(
`${program} failed (exit ${code}${status ? `, HTTP ${status}` : ''})`,
);
error.status = status;
error.exitCode = code;
reject(error);
}
});
child.stdin.on('error', () => {});
child.stdin.end(input);
});
}
function validate(c) {
if (
!/^[\w.-]+\/[\w.-]+$/.test(c.repo || '') ||
!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,39}$/.test(c.poolId || '') ||
!/^(?:[a-zA-Z0-9][a-zA-Z0-9._:/-]*@)?sha256:[a-f0-9]{64}$/.test(
c.image || '',
) ||
!Number.isInteger(c.slots) ||
c.slots < 1 ||
c.slots > 10 ||
!Number.isInteger(c.runnerGroupId) ||
c.runnerGroupId < 1 ||
!Array.isArray(c.labels) ||
!c.labels.length ||
!c.labels.every(
(label) =>
typeof label === 'string' && /^[a-zA-Z0-9_-]{1,63}$/.test(label),
) ||
typeof c.stateDir !== 'string' ||
!isAbsolute(c.stateDir) ||
typeof c.logDir !== 'string' ||
!isAbsolute(c.logDir) ||
(c.dockerSocket !== undefined &&
(typeof c.dockerSocket !== 'string' ||
!isAbsolute(c.dockerSocket) ||
c.dockerSocket.includes('\n'))) ||
!Number.isFinite(c.cpus) ||
!(c.cpus > 0) ||
!/^[1-9]\d*[mMgG]$/.test(c.memory || '') ||
!Number.isInteger(c.pidsLimit) ||
c.pidsLimit < 1 ||
!Number.isFinite(c.ttlSeconds) ||
!(c.ttlSeconds > 0) ||
!Number.isFinite(c.shutdownGraceSeconds) ||
!(c.shutdownGraceSeconds >= 0) ||
!Number.isSafeInteger(c.minDiskBytes) ||
c.minDiskBytes < 0
)
throw new Error('Invalid manager configuration');
}
export function createManager(config, dependencies = {}) {
validate(config);
const execute = dependencies.command || command;
const run = (program, args, options = {}) => {
if (program !== 'docker') return execute(program, args, options);
const source = options.env || process.env;
const env = {};
for (const key of ['PATH', 'HOME', 'DOCKER_CONFIG', 'XDG_RUNTIME_DIR']) {
if (source[key] !== undefined) env[key] = source[key];
}
env.DOCKER_HOST = `unix://${config.dockerSocket || '/var/run/docker.sock'}`;
if (args[0] === 'create' && options.env?.RUNNER_JIT_CONFIG)
env.RUNNER_JIT_CONFIG = options.env.RUNNER_JIT_CONFIG;
return execute(program, args, { ...options, env });
};
const wait = dependencies.sleep || sleep;
const diskStats = dependencies.statfs || statfs;
let stopping = false;
let stopAt = Infinity;
const stateDir = resolve(config.stateDir);
const logDir = resolve(config.logDir);
const lockPath = join(stateDir, 'manager.lock');
const slotPath = (slot) => join(stateDir, `slot-${slot}.json`);
const log = (message) => console.log(`[${config.poolId}] ${message}`);
function parseJson(text) {
try {
return JSON.parse(text);
} catch {
throw new Error('Invalid JSON response from GitHub');
}
}
async function gh(args, input) {
return run('gh', ['api', ...args, ...(input ? ['--input', '-'] : [])], {
env: process.env,
input: input && JSON.stringify(input),
});
}
async function save(slot, state) {
const path = slotPath(slot);
const temp = `${path}.${randomUUID()}.tmp`;
await writeFile(temp, JSON.stringify(state), { mode: 0o600 });
await rename(temp, path);
}
async function load(slot) {
try {
const raw = await readFile(slotPath(slot), 'utf8');
let state;
try {
state = JSON.parse(raw);
} catch {
throw new Error('Invalid slot state; manual reconciliation required');
}
// Reconcile identities created before the ECS-compatible name prefix.
const name =
typeof state?.name === 'string' ? state.name.replace(/^ecs-/, '') : '';
const prefix = `qwen-${config.poolId}-${slot}-`;
if (
!name.startsWith(prefix) ||
!/^[0-9a-f-]{36}$/.test(name.slice(prefix.length)) ||
typeof state.uncertain !== 'boolean' ||
(!state.uncertain &&
(!Number.isSafeInteger(state.runnerId) || state.runnerId < 1)) ||
(state.runnerId !== undefined &&
(!Number.isSafeInteger(state.runnerId) || state.runnerId < 1))
)
throw new Error('Invalid slot state; manual reconciliation required');
return state;
} catch (error) {
if (error.code === 'ENOENT') return null;
throw error;
}
}
async function listContainers(slot) {
const output = await run('docker', [
'ps',
'-aq',
'--no-trunc',
'--filter',
`label=qwen.pool=${config.poolId}`,
'--filter',
`label=qwen.slot=${slot}`,
]);
return output.trim().split('\n').filter(Boolean);
}
async function listNetworks(slot) {
const args = [
'network',
'ls',
'-q',
'--no-trunc',
'--filter',
`label=qwen.pool=${config.poolId}`,
];
if (slot !== undefined) args.push('--filter', `label=qwen.slot=${slot}`);
return (await run('docker', args)).trim().split('\n').filter(Boolean);
}
async function listRunners() {
const output = await gh([
'--paginate',
'--jq',
'.runners | @json',
`/repos/${config.repo}/actions/runners?per_page=100`,
]);
const pages = output.trim().split('\n').map(parseJson);
if (
!pages.every(
(runners) =>
Array.isArray(runners) &&
runners.every(
(runner) =>
typeof runner?.name === 'string' &&
Number.isSafeInteger(runner.id) &&
runner.id > 0,
),
)
)
throw new Error('Invalid GitHub runner list');
return pages.flat();
}
async function pruneLogs() {
const files = [];
for (const entry of await readdir(logDir, { withFileTypes: true })) {
if (
!entry.isFile() ||
!entry.name.replace(/^ecs-/, '').startsWith(`qwen-${config.poolId}-`) ||
!/\.(log|tar)$/.test(entry.name)
)
continue;
const path = join(logDir, entry.name);
const info = await lstat(path);
files.push({ path, size: info.size, time: info.mtimeMs });
}
files.sort((a, b) => b.time - a.time);
let size = 0;
for (const file of files) {
size += file.size;
if (file.time < Date.now() - 7 * 86400000 || size > 100 * 1024 * 1024)
await rm(file.path, { force: true });
}
}
async function cleanup(slot, state) {
const containers = await listContainers(slot);
const networks = await listNetworks(slot);
if ((containers.length || networks.length) && !state?.name)
throw new Error(
'Container has no recorded runner identity; manual reconciliation required',
);
for (const id of containers) {
try {
const logs = await run('docker', ['logs', '--tail', '10000', id]);
await writeFile(
join(
logDir,
`${state?.name || `slot-${slot}`}-${id.slice(0, 12)}.log`,
),
logs.slice(-1024 * 1024),
{ mode: 0o600 },
);
await pruneLogs();
} catch {
log(`slot ${slot}: container logs unavailable`);
}
try {
const archive = await run(
'docker',
['cp', `${id}:/home/node/actions-runner/_diag`, '-'],
{ binary: true },
);
await writeFile(
join(logDir, `${state.name}-${id.slice(0, 12)}.tar`),
archive,
{ mode: 0o600 },
);
await pruneLogs();
} catch {
log(
`slot ${slot}: runner diagnostics unavailable or larger than 4 MiB`,
);
}
await run('docker', ['rm', '-f', '-v', id]);
}
if ((await listContainers(slot)).length)
throw new Error('Container cleanup failed');
for (const id of networks) await run('docker', ['network', 'rm', id]);
if ((await listNetworks(slot)).length)
throw new Error('Network cleanup failed');
if (state?.name) {
const all = await listRunners();
const runners = all.filter((runner) => runner.name === state.name);
if (
state.runnerId &&
(runners.some((runner) => runner.id !== state.runnerId) ||
all.some(
(runner) =>
runner.id === state.runnerId && runner.name !== state.name,
))
)
throw new Error(
'Runner identity mismatch; manual reconciliation required',
);
if (
runners.length > 1 ||
runners.some(
(runner) => !Number.isSafeInteger(runner.id) || runner.id < 1,
)
)
throw new Error(
'Ambiguous runner identity; manual reconciliation required',
);
if (state.uncertain && !runners.length)
throw new Error(
'JIT response was lost; runner not found, manual reconciliation required',
);
for (const runner of runners) {
try {
await gh([
'-X',
'DELETE',
`/repos/${config.repo}/actions/runners/${runner.id}`,
]);
} catch (error) {
if (error.status !== 404) throw error;
}
}
}
await rm(slotPath(slot), { force: true });
log(`slot ${slot}: cleaned`);
}
async function cycle(slot) {
let earlyExit = false;
let state = await load(slot);
if (
state ||
(await listContainers(slot)).length ||
(await listNetworks(slot)).length
)
await cleanup(slot, state);
if (stopping) return;
const dockerRoot = (
await run('docker', ['info', '--format', '{{.DockerRootDir}}'])
).trim();
if (!isAbsolute(dockerRoot) || dockerRoot.includes('\n'))
throw new Error('Invalid Docker Root Dir');
const disk = await diskStats(dockerRoot);
if (disk.bavail * disk.bsize < config.minDiskBytes)
throw new Error(
`Insufficient disk capacity on Docker Root Dir ${dockerRoot}`,
);
state = {
name: `ecs-qwen-${config.poolId}-${slot}-${randomUUID()}`,
createdAt: new Date().toISOString(),
uncertain: true,
};
await save(slot, state);
let result;
try {
result = parseJson(
await gh(
[
'-X',
'POST',
`/repos/${config.repo}/actions/runners/generate-jitconfig`,
],
{
name: state.name,
runner_group_id: config.runnerGroupId,
labels: config.labels,
work_folder: '_work',
},
),
);
} catch (error) {
if (error.status >= 400 && error.status < 500 && error.status !== 408) {
await rm(slotPath(slot), { force: true });
} else {
await cleanup(slot, state);
}
throw error;
}
try {
if (
result?.runner?.name !== state.name ||
!Number.isSafeInteger(result.runner.id) ||
result.runner.id < 1
)
throw new Error('Invalid JIT runner identity');
state.runnerId = result.runner.id;
state.uncertain = false;
await save(slot, state);
if (!result.encoded_jit_config)
throw new Error('GitHub returned no JIT configuration');
const networkId = (
await run('docker', [
'network',
'create',
'--driver',
'bridge',
'--opt',
'com.docker.network.bridge.enable_icc=false',
'--label',
`qwen.pool=${config.poolId}`,
'--label',
`qwen.slot=${slot}`,
'--label',
`qwen.instance=${state.name}`,
state.name,
])
).trim();
if (!networkId) throw new Error('Docker returned no network ID');
state.networkId = networkId;
await save(slot, state);
const env = {
...process.env,
RUNNER_JIT_CONFIG: result.encoded_jit_config,
};
delete env.GH_TOKEN;
delete env.GITHUB_TOKEN;
const id = (
await run(
'docker',
[
'create',
'--init',
'--network',
networkId,
'--label',
`qwen.pool=${config.poolId}`,
'--label',
`qwen.slot=${slot}`,
'--label',
`qwen.instance=${state.name}`,
'--cpus',
String(config.cpus),
'--memory',
config.memory,
'--pids-limit',
String(config.pidsLimit),
'--log-driver',
'json-file',
'--log-opt',
'max-size=10m',
'--log-opt',
'max-file=3',
'--env',
'RUNNER_JIT_CONFIG',
config.image,
],
{ env },
)
).trim();
state.containerId = id;
await save(slot, state);
if (!id) throw new Error('Docker returned no container ID');
if (!stopping) {
await run('docker', ['start', id]);
log(
`slot ${slot}: started runner ${state.name}, id ${state.runnerId}, container ${id}`,
);
const deadline = Date.now() + config.ttlSeconds * 1000;
let exitCode;
const watching = run('docker', ['wait', id], {
timeout: (config.ttlSeconds + 10) * 1000,
}).then(
(output) => {
const status = output.trim();
exitCode =
/^\d+$/.test(status) && Number(status) <= 255
? Number(status)
: null;
return true;
},
() => {
exitCode = null;
return true;
},
);
while (Date.now() < Math.min(deadline, stopAt)) {
if (await Promise.race([watching, wait(1000).then(() => false)]))
break;
}
if (exitCode !== undefined && exitCode !== 0) {
earlyExit = true;
log(
`slot ${slot}: runner exit status ${exitCode ?? 'unknown'}; next JIT request is delayed`,
);
}
}
} finally {
await cleanup(slot, state);
}
return { earlyExit };
}
async function acquireLock() {
await mkdir(stateDir, { recursive: true, mode: 0o700 });
await mkdir(logDir, { recursive: true, mode: 0o700 });
const child = spawn('flock', ['-n', lockPath, 'cat'], {
stdio: ['pipe', 'pipe', 'ignore'],
});
await new Promise((resolveLock, reject) => {
child.once('error', reject);
child.once('exit', () =>
reject(new Error('Could not acquire manager lock')),
);
child.stdout.once('data', resolveLock);
child.stdin.on('error', () => {});
child.stdin.write('locked\n');
});
return child;
}
async function start() {
const lock = await acquireLock();
try {
const slots = (
await run('docker', [
'ps',
'-a',
'--filter',
`label=qwen.pool=${config.poolId}`,
'--format',
'{{.Label "qwen.slot"}}',
])
)
.trim()
.split('\n')
.filter(Boolean);
const states = (await readdir(stateDir))
.map((name) => name.match(/^slot-(\d+)\.json$/)?.[1])
.filter(Boolean);
const networkSlots = await Promise.all(
(await listNetworks()).map(async (id) =>
(
await run('docker', [
'network',
'inspect',
'--format',
'{{index .Labels "qwen.slot"}}',
id,
])
).trim(),
),
);
if (
[...slots, ...states, ...networkSlots].some(
(slot) =>
!/^\d+$/.test(slot) ||
Number(slot) < 1 ||
Number(slot) > config.slots,
)
) {
throw new Error(
'Pool contains resources outside configured slots; reconcile before starting',
);
}
await Promise.all(
Array.from({ length: config.slots }, async (_, i) => {
const slot = i + 1;
while (!stopping) {
try {
const result = await cycle(slot);
if (result?.earlyExit && !stopping) {
log(`slot ${slot}: backing off 60 s before replenishing`);
await wait(60000);
}
} catch (error) {
log(
`slot ${slot}: ${error.message}; retrying without replenishing`,
);
await wait(10000);
}
}
}),
);
} finally {
lock.stdin.end();
}
}
function stop() {
stopping = true;
stopAt = Math.min(stopAt, Date.now() + config.shutdownGraceSeconds * 1000);
}
return { start, stop, cycle, cleanup };
}
if (
process.argv[1] &&
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
if (!process.env.GH_TOKEN?.trim()) throw new Error('GH_TOKEN is required');
const config = JSON.parse(
await readFile(resolve(process.argv[2] || ''), 'utf8'),
);
const manager = createManager(config);
process.on('SIGTERM', manager.stop);
process.on('SIGINT', manager.stop);
await manager.start();
} catch (error) {
console.error(`manager: ${error.message}`);
process.exitCode = 1;
}
}
@@ -0,0 +1,822 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
mkdtemp,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { command as runCommand, createManager } from './manager.mjs';
async function fixture(t) {
const dir = await mkdtemp(join(tmpdir(), 'runner-manager-'));
t.after(() => rm(dir, { recursive: true, force: true }));
await mkdir(join(dir, 'state'));
await mkdir(join(dir, 'logs'));
const config = {
repo: 'owner/repo',
poolId: 'trial',
image: `image@sha256:${'a'.repeat(64)}`,
slots: 1,
runnerGroupId: 1,
labels: ['self-hosted', 'linux', 'ARM64', 'trial-only'],
cpus: 1,
memory: '1g',
pidsLimit: 100,
ttlSeconds: 2,
shutdownGraceSeconds: 0,
minDiskBytes: 0,
stateDir: join(dir, 'state'),
logDir: join(dir, 'logs'),
};
const calls = [];
const containers = new Map();
const networks = new Map();
let runner;
let counter = 0;
let foreignCounter = 0;
let failRemove = false;
let failNetworkRemove = false;
let keepContainerAfterRemove = false;
let loseJit = false;
const labelsFromArgs = (args) => {
const labels = {};
for (let i = 0; i < args.length; i += 1) {
if (args[i] !== '--label' || typeof args[i + 1] !== 'string') continue;
const at = args[i + 1].indexOf('=');
if (at > 0) labels[args[i + 1].slice(0, at)] = args[i + 1].slice(at + 1);
}
return labels;
};
const filtersFromArgs = (args) => {
const filters = [];
for (let i = 0; i < args.length; i += 1) {
if (args[i] === '--filter' && typeof args[i + 1] === 'string')
filters.push(args[i + 1].replace(/^label=/, ''));
}
return filters;
};
const matchesFilters = (entry, args) =>
filtersFromArgs(args).every((filter) => {
const at = filter.indexOf('=');
if (at < 0) return true;
return entry.labels[filter.slice(0, at)] === filter.slice(at + 1);
});
async function command(program, args, options = {}) {
calls.push({ program, args, options });
if (program === 'gh') {
const path = args.find((v) => v.startsWith('/'));
if (path.includes('generate-jitconfig')) {
const body = JSON.parse(options.input);
runner = { id: ++counter, name: body.name };
if (loseJit) throw new Error('network failure');
return JSON.stringify({ runner, encoded_jit_config: 'jit-secret' });
}
if (args.includes('DELETE')) {
runner = undefined;
return '';
}
return JSON.stringify(runner ? [runner] : []);
}
if (args[0] === 'ps') {
const matched = [...containers.entries()].filter(([, entry]) =>
matchesFilters(entry, args),
);
if (!matched.length) return '';
if (args.includes('--format'))
return `${matched.map(([, entry]) => entry.labels['qwen.slot'] ?? '').join('\n')}\n`;
return `${matched.map(([id]) => id).join('\n')}\n`;
}
if (args[0] === 'network' && args[1] === 'ls') {
const matched = [...networks.entries()].filter(([, entry]) =>
matchesFilters(entry, args),
);
return matched.length ? `${matched.map(([id]) => id).join('\n')}\n` : '';
}
if (args[0] === 'network' && args[1] === 'create') {
const id = `network-${counter}`;
networks.set(id, { labels: labelsFromArgs(args) });
return id;
}
if (args[0] === 'network' && args[1] === 'rm') {
if (failNetworkRemove) throw new Error('network removal failed');
networks.delete(args[args.length - 1]);
return '';
}
if (args[0] === 'network' && args[1] === 'inspect') {
const entry = networks.get(args[args.length - 1]);
return `${entry?.labels['qwen.slot'] ?? ''}\n`;
}
if (args[0] === 'info') return `${dir}\n`;
if (args[0] === 'create') {
const id = `container-${counter}`;
containers.set(id, { labels: labelsFromArgs(args) });
return id;
}
if (args[0] === 'logs') return 'runner log';
if (args[0] === 'rm') {
if (failRemove) throw new Error('docker unavailable');
if (!keepContainerAfterRemove) containers.delete(args[args.length - 1]);
return '';
}
if (args[0] === 'wait') return '0\n';
if (args[0] === 'start') return '';
return '';
}
return {
config,
calls,
command,
sleep: async () => {},
addForeignNetwork(labels) {
const id = `network-foreign-${++foreignCounter}`;
networks.set(id, { labels });
return id;
},
networkIds: () => [...networks.keys()],
setFailRemove: (v) => {
failRemove = v;
},
setLoseJit: (v) => {
loseJit = v;
},
setFailNetworkRemove: (v) => {
failNetworkRemove = v;
},
setKeepContainerAfterRemove: (v) => {
keepContainerAfterRemove = v;
},
};
}
test('creates a new runner and container per job and keeps GitHub token out of Docker', async (t) => {
const previousToken = process.env.GH_TOKEN;
process.env.GH_TOKEN = 'host-only-pat';
t.after(() => {
if (previousToken === undefined) delete process.env.GH_TOKEN;
else process.env.GH_TOKEN = previousToken;
});
const f = await fixture(t);
const manager = createManager(f.config, { command: f.command });
await manager.cycle(1);
await manager.cycle(1);
const creates = f.calls.filter(
(c) => c.program === 'docker' && c.args[0] === 'create',
);
const requests = f.calls.filter(
(c) =>
c.program === 'gh' &&
c.args.includes('/repos/owner/repo/actions/runners/generate-jitconfig'),
);
assert.ok(
requests.every((c) =>
JSON.parse(c.options.input).name.startsWith('ecs-qwen-'),
),
);
assert.ok(requests.every((c) => c.options.env.GH_TOKEN === 'host-only-pat'));
assert.equal(creates.length, 2);
assert.notEqual(
JSON.parse(requests[0].options.input).name,
JSON.parse(requests[1].options.input).name,
);
assert.deepEqual(
JSON.parse(requests[0].options.input).labels,
f.config.labels,
);
assert.ok(
creates.every(
(c) =>
!c.options.env.GH_TOKEN &&
c.options.env.RUNNER_JIT_CONFIG === 'jit-secret',
),
);
assert.deepEqual(
creates.map((c) => c.args[c.args.indexOf('--network') + 1]),
['network-1', 'network-2'],
);
const networks = f.calls.filter(
(c) =>
c.program === 'docker' &&
c.args[0] === 'network' &&
c.args[1] === 'create',
);
assert.equal(networks.length, 2);
assert.ok(
networks.every((c) =>
c.args.includes('com.docker.network.bridge.enable_icc=false'),
),
);
assert.equal(
f.calls.filter((c) => c.program === 'docker' && c.args[0] === 'rm').length,
2,
);
});
test('failed cleanup blocks replacement until recovery cleans the old container', async (t) => {
const f = await fixture(t);
const manager = createManager(f.config, { command: f.command });
f.setFailRemove(true);
await assert.rejects(manager.cycle(1), /docker unavailable/);
await assert.rejects(manager.cycle(1), /docker unavailable/);
assert.equal(
f.calls.filter((c) => c.program === 'docker' && c.args[0] === 'create')
.length,
1,
);
f.setFailRemove(false);
await manager.cycle(1);
const ops = f.calls
.filter((c) => c.program === 'docker')
.map((c) => c.args[0]);
assert.ok(ops.lastIndexOf('rm') > ops.lastIndexOf('create'));
assert.equal(ops.filter((op) => op === 'create').length, 2);
});
test('lost JIT response reconciles the recorded name before requesting another', async (t) => {
const f = await fixture(t);
const manager = createManager(f.config, { command: f.command });
f.setLoseJit(true);
await assert.rejects(manager.cycle(1), /network failure/);
await assert.rejects(readFile(join(f.config.stateDir, 'slot-1.json')), {
code: 'ENOENT',
});
assert.ok(
f.calls.some((c) => c.program === 'gh' && c.args.includes('DELETE')),
);
f.setLoseJit(false);
await manager.cycle(1);
});
test('an unconfirmed JIT request blocks replacement when no runner can be found', async (t) => {
const f = await fixture(t);
const command = (program, args, options) => {
if (
program === 'gh' &&
args.includes('/repos/owner/repo/actions/runners/generate-jitconfig')
) {
return Promise.reject(new Error('connection lost'));
}
return f.command(program, args, options);
};
const manager = createManager(f.config, { command });
await assert.rejects(manager.cycle(1), /manual reconciliation/);
await assert.rejects(manager.cycle(1), /manual reconciliation/);
const state = JSON.parse(
await readFile(join(f.config.stateDir, 'slot-1.json'), 'utf8'),
);
assert.equal(state.uncertain, true);
assert.equal(
f.calls.filter((c) => c.program === 'docker' && c.args[0] === 'create')
.length,
0,
);
});
test('a failed container start is cleaned up before another cycle', async (t) => {
const f = await fixture(t);
let fail = true;
const command = (program, args, options) => {
if (program === 'docker' && args[0] === 'start' && fail) {
fail = false;
return Promise.reject(new Error('start failed'));
}
return f.command(program, args, options);
};
const manager = createManager(f.config, { command });
await assert.rejects(manager.cycle(1), /start failed/);
assert.equal(
f.calls.filter((c) => c.program === 'docker' && c.args[0] === 'rm').length,
1,
);
await manager.cycle(1);
});
test('watchdog removes a container that does not exit', async (t) => {
const f = await fixture(t);
f.config.ttlSeconds = 0.01;
const command = (program, args, options) =>
program === 'docker' && args[0] === 'wait'
? new Promise(() => {})
: f.command(program, args, options);
await createManager(f.config, { command }).cycle(1);
assert.equal(
f.calls.filter((c) => c.program === 'docker' && c.args[0] === 'rm').length,
1,
);
});
test('checks Docker root disk rather than the state directory', async (t) => {
const f = await fixture(t);
let checked;
const manager = createManager(f.config, {
command: f.command,
statfs: async (path) => {
checked = path;
return { bavail: 0, bsize: 4096 };
},
});
f.config.minDiskBytes = 1;
await assert.rejects(manager.cycle(1), /Insufficient disk capacity/);
assert.equal(checked, join(f.config.stateDir, '..'));
assert.equal(f.calls.filter((c) => c.program === 'gh').length, 0);
const info = f.calls.find(
(c) => c.program === 'docker' && c.args[0] === 'info',
);
assert.equal(info.options.env.DOCKER_HOST, 'unix:///var/run/docker.sock');
});
test('rejects same-name runner with a different ID without deleting it', async (t) => {
const f = await fixture(t);
const state = {
name: 'qwen-trial-1-aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa',
runnerId: 42,
uncertain: false,
};
await writeFile(
join(f.config.stateDir, 'slot-1.json'),
JSON.stringify(state),
);
const command = (program, args, options) => {
if (program === 'gh' && args.includes('--paginate'))
return Promise.resolve(JSON.stringify([{ name: state.name, id: 43 }]));
return f.command(program, args, options);
};
await assert.rejects(
createManager(f.config, { command }).cycle(1),
/identity mismatch/,
);
assert.ok(
!f.calls.some((c) => c.program === 'gh' && c.args.includes('DELETE')),
);
assert.equal(
JSON.parse(await readFile(join(f.config.stateDir, 'slot-1.json'), 'utf8'))
.runnerId,
42,
);
});
test('reconciles a legacy identity on a later page before registering its replacement', async (t) => {
const f = await fixture(t);
const name = 'qwen-trial-1-aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa';
await writeFile(
join(f.config.stateDir, 'slot-1.json'),
JSON.stringify({
name,
runnerId: 42,
uncertain: false,
}),
);
let deleted = false;
const command = (program, args, options) => {
if (program === 'gh' && args.includes('--paginate') && !deleted) {
assert.ok(!args.includes('--slurp'));
assert.equal(args[args.indexOf('--jq') + 1], '.runners | @json');
return Promise.resolve('[]\n' + JSON.stringify([{ name, id: 42 }]));
}
if (
program === 'gh' &&
args.includes('/repos/owner/repo/actions/runners/42')
) {
deleted = true;
}
if (
program === 'gh' &&
args.includes('/repos/owner/repo/actions/runners/generate-jitconfig')
) {
assert.ok(
deleted,
'legacy registration must be removed before replacement',
);
}
return f.command(program, args, options);
};
await createManager(f.config, { command }).cycle(1);
assert.ok(deleted);
});
test('startup reclaims a live container before replenishing after a crash', async (t) => {
const f = await fixture(t);
const previousPath = process.env.PATH;
await writeFile(join(f.config.stateDir, 'flock'), '#!/bin/sh\nexec cat\n', {
mode: 0o755,
});
process.env.PATH = `${f.config.stateDir}:${previousPath}`;
t.after(() => {
process.env.PATH = previousPath;
});
await writeFile(
join(f.config.stateDir, 'slot-1.json'),
JSON.stringify({
name: 'ecs-qwen-trial-1-aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa',
runnerId: 42,
uncertain: false,
}),
);
let oldContainer = true;
let manager;
const command = async (program, args, options) => {
if (program === 'docker' && args[0] === 'ps') {
if (args.includes('--format')) {
// Docker quiet mode overrides --format, as on the trial host.
return oldContainer
? args.includes('-aq')
? 'old-container'
: '1'
: '';
}
if (oldContainer) return 'old-container';
}
if (
program === 'docker' &&
args[0] === 'rm' &&
args.includes('old-container')
) {
oldContainer = false;
return '';
}
if (
program === 'gh' &&
args.includes('/repos/owner/repo/actions/runners/generate-jitconfig')
) {
assert.equal(
oldContainer,
false,
'remove old container before registration',
);
}
if (program === 'docker' && args[0] === 'start') manager.stop();
return f.command(program, args, options);
};
manager = createManager(f.config, { command });
await manager.start();
assert.equal(oldContainer, false);
assert.equal(
f.calls.filter((c) => c.program === 'docker' && c.args[0] === 'create')
.length,
1,
);
});
test('accepts immutable local image IDs and rejects mutable tags and invalid configuration', async (t) => {
const f = await fixture(t);
assert.doesNotThrow(() =>
createManager({ ...f.config, image: `sha256:${'b'.repeat(64)}` }),
);
assert.throws(
() => createManager({ ...f.config, image: 'image:latest' }),
/Invalid manager configuration/,
);
assert.throws(
() =>
createManager({ ...f.config, dockerSocket: 'tcp://example.com:2375' }),
/Invalid manager configuration/,
);
assert.throws(
() => createManager({ ...f.config, cpus: Infinity }),
/Invalid manager configuration/,
);
});
test('does not expose malformed GitHub responses in errors', async (t) => {
const f = await fixture(t);
const command = (program, args, options) => {
if (
program === 'gh' &&
args.some((arg) => arg.includes('generate-jitconfig'))
) {
return f
.command(program, args, options)
.then(() => 'malformed-secret-token');
}
return f.command(program, args, options);
};
await assert.rejects(
createManager(f.config, { command }).cycle(1),
(error) => {
assert.match(error.message, /Invalid JSON response/);
assert.doesNotMatch(error.message, /malformed-secret-token/);
return true;
},
);
});
test('HTTP 429 rejection does not leave uncertain state and the next cycle can retry', async (t) => {
const f = await fixture(t);
const fakeGh = join(f.config.stateDir, 'gh');
await writeFile(
fakeGh,
'#!/bin/sh\necho "gh: API rate limit exceeded (HTTP 429)" >&2\nexit 1\n',
{ mode: 0o755 },
);
let rejected = false;
const command = (program, args, options) => {
if (
program === 'gh' &&
args.includes('/repos/owner/repo/actions/runners/generate-jitconfig') &&
!rejected
) {
rejected = true;
return runCommand(fakeGh, [], { env: {} });
}
return f.command(program, args, options);
};
const manager = createManager(f.config, { command });
await assert.rejects(manager.cycle(1), (error) => error.status === 429);
await assert.rejects(readFile(join(f.config.stateDir, 'slot-1.json')), {
code: 'ENOENT',
});
await manager.cycle(1);
assert.equal(
f.calls.filter((c) => c.program === 'docker' && c.args[0] === 'create')
.length,
1,
);
});
test('network cleanup failure prevents new runner and is reconciled on the next cycle', async (t) => {
const f = await fixture(t);
const manager = createManager(f.config, { command: f.command });
f.setFailNetworkRemove(true);
await assert.rejects(manager.cycle(1), /network removal failed/);
await assert.rejects(manager.cycle(1), /network removal failed/);
assert.equal(
f.calls.filter((c) => c.program === 'docker' && c.args[0] === 'create')
.length,
1,
);
f.setFailNetworkRemove(false);
await manager.cycle(1);
assert.equal(
f.calls.filter((c) => c.program === 'docker' && c.args[0] === 'create')
.length,
2,
);
});
test('a lost network create response is recovered using labels', async (t) => {
const f = await fixture(t);
const command = async (program, args, options) => {
const result = await f.command(program, args, options);
if (program === 'docker' && args[0] === 'network' && args[1] === 'create')
throw new Error('network create response lost');
return result;
};
await assert.rejects(
createManager(f.config, { command }).cycle(1),
/network create response lost/,
);
assert.equal(
f.calls.filter(
(c) =>
c.program === 'docker' && c.args[0] === 'network' && c.args[1] === 'rm',
).length,
1,
);
await assert.rejects(readFile(join(f.config.stateDir, 'slot-1.json')), {
code: 'ENOENT',
});
});
test('network deletion is verified even when docker reports success', async (t) => {
const f = await fixture(t);
const command = (program, args, options) => {
if (program === 'docker' && args[0] === 'network' && args[1] === 'rm')
return Promise.resolve('');
return f.command(program, args, options);
};
const manager = createManager(f.config, { command });
await assert.rejects(manager.cycle(1), /Network cleanup failed/);
await assert.rejects(manager.cycle(1), /Network cleanup failed/);
assert.equal(
f.calls.filter((c) => c.program === 'docker' && c.args[0] === 'create')
.length,
1,
);
});
test('passes only the docker environment allowlist and the JIT config by name', async (t) => {
const sentinels = {
AWS_SECRET_ACCESS_KEY: 'sentinel-secret',
SOME_DEPLOY_TOKEN: 'sentinel-token',
};
const previous = {};
for (const key of Object.keys(sentinels)) {
previous[key] = process.env[key];
process.env[key] = sentinels[key];
}
t.after(() => {
for (const key of Object.keys(sentinels)) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
});
const f = await fixture(t);
await createManager(f.config, { command: f.command }).cycle(1);
const create = f.calls.find(
(c) => c.program === 'docker' && c.args[0] === 'create',
);
const allowed = [
'DOCKER_CONFIG',
'DOCKER_HOST',
'HOME',
'PATH',
'RUNNER_JIT_CONFIG',
'XDG_RUNTIME_DIR',
];
assert.deepEqual(
Object.keys(create.options.env).filter((key) => !allowed.includes(key)),
[],
);
assert.ok(!JSON.stringify(create.options.env).includes('sentinel'));
assert.ok(!JSON.stringify(create.args).includes('sentinel'));
assert.equal(
create.args[create.args.indexOf('--log-driver') + 1],
'json-file',
);
const at = create.args.indexOf('--env');
assert.equal(create.args[at + 1], 'RUNNER_JIT_CONFIG');
});
test('labels pool and slot on create and honours recovery filters', async (t) => {
const f = await fixture(t);
const foreign = f.addForeignNetwork({
'qwen.pool': 'other',
'qwen.slot': '9',
});
const command = async (program, args, options) => {
const result = await f.command(program, args, options);
if (program === 'docker' && args[0] === 'network' && args[1] === 'create')
throw new Error('network create response lost');
return result;
};
await assert.rejects(
createManager(f.config, { command }).cycle(1),
/network create response lost/,
);
const created = f.calls.find(
(c) =>
c.program === 'docker' &&
c.args[0] === 'network' &&
c.args[1] === 'create',
);
assert.ok(created.args.includes('qwen.pool=trial'));
assert.ok(created.args.includes('qwen.slot=1'));
const removed = f.calls
.filter(
(c) =>
c.program === 'docker' && c.args[0] === 'network' && c.args[1] === 'rm',
)
.map((c) => c.args[c.args.length - 1]);
assert.deepEqual(removed, ['network-1']);
assert.ok(f.networkIds().includes(foreign));
});
test('container cleanup failure blocks replenishment even when docker reports success', async (t) => {
const f = await fixture(t);
f.setKeepContainerAfterRemove(true);
const manager = createManager(f.config, { command: f.command });
await assert.rejects(manager.cycle(1), /Container cleanup failed/);
const state = JSON.parse(
await readFile(join(f.config.stateDir, 'slot-1.json'), 'utf8'),
);
assert.ok(state.name);
assert.equal(state.runnerId, 1);
});
test('rejects corrupted slot state without deleting a runner', async (t) => {
const validName = 'qwen-trial-1-aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa';
const cases = [
{
label: 'foreign prefix',
state: {
name: 'qwen-other-1-aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa',
runnerId: 42,
uncertain: false,
},
},
{
label: 'non-UUID suffix',
state: {
name: 'qwen-trial-1-not-a-uuid',
runnerId: 42,
uncertain: false,
},
},
{
label: 'non-boolean uncertain',
state: { name: validName, runnerId: 42, uncertain: 'no' },
},
{
label: 'zero runner id',
state: { name: validName, runnerId: 0, uncertain: false },
},
];
for (const item of cases) {
const f = await fixture(t);
await writeFile(
join(f.config.stateDir, 'slot-1.json'),
JSON.stringify(item.state),
);
await assert.rejects(
createManager(f.config, { command: f.command }).cycle(1),
/Invalid slot state/,
item.label,
);
assert.ok(
!f.calls.some((c) => c.program === 'gh' && c.args.includes('DELETE')),
item.label,
);
}
});
test('backs off on a failed container even after the startup window', async (t) => {
const f = await fixture(t);
f.config.ttlSeconds = 120;
let now = Date.now();
t.mock.method(Date, 'now', () => now);
const command = (program, args, options) => {
if (program === 'docker' && args[0] === 'wait') {
now += 31000;
return Promise.resolve('126\n');
}
return f.command(program, args, options);
};
const result = await createManager(f.config, { command }).cycle(1);
assert.equal(result.earlyExit, true);
});
test('backs off before the next JIT request after a failed container', async (t) => {
const f = await fixture(t);
const previousPath = process.env.PATH;
await writeFile(join(f.config.stateDir, 'flock'), '#!/bin/sh\nexec cat\n', {
mode: 0o755,
});
process.env.PATH = `${f.config.stateDir}:${previousPath}`;
t.after(() => {
process.env.PATH = previousPath;
});
const sleeps = [];
let manager;
let jitRequests = 0;
const command = async (program, args, options) => {
if (
program === 'gh' &&
args.includes('/repos/owner/repo/actions/runners/generate-jitconfig')
) {
jitRequests += 1;
if (jitRequests > 1) manager.stop();
}
if (program === 'docker' && args[0] === 'wait') return '127\n';
return f.command(program, args, options);
};
manager = createManager(f.config, {
command,
sleep: async (ms) => {
sleeps.push(ms);
},
});
await manager.start();
// The 1 s poll that races `docker wait` also goes through the injected sleep;
// the replenishment backoff is the 60 s entry.
assert.equal(sleeps.filter((ms) => ms === 60000).length, 1);
assert.equal(sleeps.at(-1), 60000);
assert.equal(jitRequests, 2);
});
test('does not penalize a fast successful container', async (t) => {
const f = await fixture(t);
const result = await createManager(f.config, { command: f.command }).cycle(1);
assert.equal(result.earlyExit, false);
});
test('backs off when docker wait cannot determine container status', async (t) => {
const f = await fixture(t);
const command = (program, args, options) => {
if (program === 'docker' && args[0] === 'wait')
return Promise.reject(new Error('Docker disconnected'));
return f.command(program, args, options);
};
const result = await createManager(f.config, { command }).cycle(1);
assert.equal(result.earlyExit, true);
});
test('starts through a symlink and reports invalid configuration', async (t) => {
const f = await fixture(t);
const link = join(f.config.stateDir, 'manager.mjs');
await symlink(new URL('./manager.mjs', import.meta.url), link);
const result = spawnSync(
process.execPath,
[link, join(f.config.stateDir, 'missing.json')],
{
env: { ...process.env, GH_TOKEN: 'test-only' },
encoding: 'utf8',
},
);
assert.equal(result.status, 1);
assert.match(result.stderr, /^manager: /);
});
@@ -0,0 +1,18 @@
[Unit]
Description=Qwen Code ephemeral GitHub Actions runners
After=docker.service network-online.target
Requires=docker.service
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/qwen-ephemeral-runner/credentials.env
WorkingDirectory=/opt/qwen-ephemeral-runner
ExecStart=/usr/bin/env node /opt/qwen-ephemeral-runner/manager.mjs /etc/qwen-ephemeral-runner/config.json
Restart=always
RestartSec=10
KillMode=mixed
TimeoutStopSec=300
[Install]
WantedBy=multi-user.target
+72 -27
View File
@@ -327,6 +327,60 @@ function getNightlyVersion() {
};
}
// A channel base at or below the published stable would repeat a shipped
// version or move the `latest` tag backwards, so target the next patch after
// `latest` instead. Returns the base unchanged when no bump is needed.
// Read through the rollback-aware lookup so a rolled-back dist-tag can never
// become the base.
function bumpPastPublishedLatest(baseVersion, label, target, refBase) {
const latestStable =
getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]')?.latestVersion ?? '';
// A ref behind the published stable would publish its own content
// under a version it never contained, whether the preview base is ahead of
// latest (a release/0.25 dispatch mid-cycle) or behind it. Check before the
// no-bump early return so both states are caught.
if (
refBase &&
semver.valid(latestStable) &&
semver.lt(refBase, latestStable)
) {
throw new Error(
`Published latest ${latestStable} is ahead of the checked-out package.json version ${refBase}. Refusing a stale or cross-line stable release.`,
);
}
// When the preview base already leads `latest`, no bump is computed below,
// so the bumped-version check cannot fire. Only a base on another release
// line is refused here: a patch ahead on the ref's own line is the ordinary
// next release.
if (
refBase &&
semver.valid(baseVersion) &&
semver.lt(refBase, baseVersion) &&
semver.diff(refBase, baseVersion) !== 'patch'
) {
throw new Error(
`Derived base ${baseVersion} is on a different release line than the checked-out package.json version ${refBase}. Refusing a cross-line stable release.`,
);
}
if (
!semver.valid(latestStable) ||
!semver.valid(baseVersion) ||
semver.lt(latestStable, baseVersion)
) {
return baseVersion;
}
const bumped = semver.inc(latestStable, 'patch');
if (refBase && semver.lt(bumped, refBase)) {
throw new Error(
`Bumping to ${bumped} would publish the checked-out ${refBase} content on an older release line. Refusing a cross-line stable release.`,
);
}
console.error(
`${label} ${baseVersion} is at or below published latest ${latestStable}; bumping ${target} ${bumped}.`,
);
return bumped;
}
function getStableVersion(args) {
const tagResult = getAndVerifyTags('preview', 'v*-preview*');
let releaseVersion;
@@ -337,20 +391,23 @@ function getStableVersion(args) {
} else if (tagResult) {
releaseVersion = tagResult.latestVersion.replace(/-preview.*/, '');
validateVersion(releaseVersion, 'X.Y.Z', 'derived from preview dist-tag');
const latestStable = getVersionFromNPM('latest');
if (
latestStable &&
semver.valid(latestStable) &&
semver.gt(latestStable, releaseVersion)
) {
throw new Error(
`Derived stable version ${releaseVersion} is lower than published latest ${latestStable}. Refusing retrograde baseline.`,
);
}
const refBase = readJson('package.json').version.split('-')[0];
releaseVersion = bumpPastPublishedLatest(
releaseVersion,
'Preview baseline',
'the stable base to at least',
refBase,
);
} else {
const packageJson = readJson('package.json');
releaseVersion = packageJson.version.split('-')[0];
validateVersion(releaseVersion, 'X.Y.Z', 'package.json version');
releaseVersion = bumpPastPublishedLatest(
releaseVersion,
'Package version',
'the stable base to at least',
releaseVersion,
);
}
return {
@@ -375,23 +432,11 @@ function getPreviewVersion(args) {
// When the nightly base is already published as stable, the preview must
// target the next patch — otherwise the scheduled Tuesday release derives
// a version whose channel packages already exist on npm (E403).
// Use the rollback-aware lookup so a rolled-back dist-tag doesn't produce
// a retrograde preview base.
const latestTagResult = getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]');
const latestStable = latestTagResult?.latestVersion ?? '';
if (
latestStable &&
semver.valid(latestStable) &&
semver.valid(baseVersion)
) {
if (semver.gte(latestStable, baseVersion)) {
const bumped = semver.inc(latestStable, 'patch');
console.error(
`Nightly base ${baseVersion} is at or below published latest ${latestStable}; bumping preview base to ${bumped}.`,
);
baseVersion = bumped;
}
}
baseVersion = bumpPastPublishedLatest(
baseVersion,
'Nightly base',
'preview base to',
);
releaseVersion = baseVersion + '-preview.0';
validateVersion(
releaseVersion,
+10 -12
View File
@@ -116,8 +116,7 @@ describe('complete macOS test groups', () => {
// The extended ceilings below, except the test lane's, are scoped to the
// self-hosted pool: `runs-on` falls back to ubuntu-latest when
// MAINTAINER_ECS_RUNNER_DISABLED=true (and, for the association-gated lanes,
// for fork PRs from untrusted authors — lint_and_static reads lint_runner,
// which routes every PR to the pool), so those hosted paths keep the
// for untrusted fork PRs with the disposable pool disabled), so hosted paths keep the
// pre-contention ceiling. The ARM64 fleet renders
// `["self-hosted", "linux", "arm64"]` — no `ecs-qwen` label — so the scope
// cannot key on the x64 fleet's name. Evaluate the real timeout expression for
@@ -391,8 +390,11 @@ it('reports the required Linux check only when the build and every shard pass',
const gate = ci.jobs.test_ubuntu;
expect(gate.name).toBe('Test (ubuntu-latest, Node 22.x)');
expect(gate.needs).toEqual(['classify_pr', 'test_build', 'test']);
// On the shards' runners, not a hosted one that can queue it for minutes.
expect(gate['runs-on']).toBe(ci.jobs.test['runs-on']);
// The gate remains on the original association-gated runner, while test
// shards can opt into the disposable pool for untrusted forks.
expect(gate['runs-on']).toBe(
'${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || \'["ubuntu-latest"]\') }}',
);
expect(condOf('test_ubuntu')).toBe(condOf('test'));
expect(gate.steps[0].run).toBe(
'test "$BUILD_RESULT" = success && test "$SHARDS_RESULT" = success',
@@ -862,15 +864,11 @@ describe('post-merge push lane', () => {
).toContain('classify_pr');
});
it('keeps test on the association-gated output', () => {
// Nothing else reads test's runs-on: switching it to lint_runner would
// hand every untrusted fork the pool while the timeout pins above stayed
// green — timeoutMinutesOn derives each lane's output from its own
// runs-on, so it cannot catch a lane pointed at the wrong output. Pin the
// expression, not an equality with lint_and_static — the two are meant
// to differ.
it('keeps test on the opt-in disposable output', () => {
// The classifier defaults this output to ubuntu_runner, but selects the
// disposable pool for untrusted forks only when explicitly enabled.
expect(String(ci.jobs.test['runs-on'])).toBe(
'${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || \'["ubuntu-latest"]\') }}',
'${{ fromJSON(needs.classify_pr.outputs.pr_test_runner || \'["ubuntu-latest"]\') }}',
);
});
+434 -2
View File
@@ -664,7 +664,7 @@ describe('getVersion', () => {
);
});
it('should reject a stable release derived below the published latest version', () => {
it('should bump the stable base past the published latest when the preview lags it', () => {
const mockWithOlderPreviewVersions = (command) => {
if (
command.includes('npm view') &&
@@ -685,11 +685,443 @@ describe('getVersion', () => {
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithOlderPreviewVersions);
// The checked-out ref is on the same release line as published latest.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.9.0' }),
);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.9.1');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should keep the preview-derived base when the latest dist-tag is missing', () => {
// First stable release: previews exist but nothing has shipped stable,
// so the rollback-aware latest lookup returns null.
const mockWithMissingLatest = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.7.0-preview.1';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
throw new Error('npm error code E404');
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.7.0-preview.0', '0.7.0-preview.1']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithMissingLatest);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.7.0');
expect(result.npmTag).toBe('latest');
});
it('should ignore an invalid latest dist-tag when deriving the stable base', () => {
const mockWithInvalidLatest = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.7.0-preview.1';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return 'banana';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.7.0-preview.0', '0.7.0-preview.1']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithInvalidLatest);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.7.0');
expect(result.npmTag).toBe('latest');
});
it('should read the published latest through the rollback-aware lookup', () => {
// A rolled-back `latest` dist-tag must not become the stable base:
// 0.11.0 is the highest published stable even though the tag says
// 0.9.2, and publishing 0.9.3 would move the tag backwards.
const mockWithRolledBackLatest = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.9.0-preview.1';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.9.2';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.9.0-preview.1', '0.9.2', '0.11.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithRolledBackLatest);
// The ref is on the 0.11 line; only the dist-tag was rolled back.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.11.0' }),
);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.11.1');
expect(result.npmTag).toBe('latest');
});
it('should refuse a versionless stable release dispatched from a ref behind latest', () => {
// A release/0.25 dispatch resolves against the global dist-tags, which
// have moved on to 0.26. Bumping would publish 0.25 branch content as
// 0.26.9 under `latest`, so the stale dispatch must abort.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.25.9' }),
);
const mockWithOlderReleaseLine = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.26.3-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.8';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.3-preview.0', '0.26.8']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithOlderReleaseLine);
expect(() => getVersion({ type: 'stable' })).toThrow(
'Derived stable version 0.7.0 is lower than published latest 0.9.0',
'is ahead of the checked-out package.json version',
);
});
it('should refuse an older ref even when the preview base is ahead of latest', () => {
// Mid-cycle the preview tag runs ahead of the published stable. A
// release/0.25 dispatch must still abort instead of publishing its
// content as the future minor.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.25.9' }),
);
const mockWithAheadPreview = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.27.0-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.9';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.9', '0.27.0-preview.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithAheadPreview);
expect(() => getVersion({ type: 'stable' })).toThrow(
'is ahead of the checked-out package.json version',
);
});
it('should refuse a stale ref when the preview base is ahead and no bump happens', () => {
// release/v0.26 at 0.26.8 with `latest` also 0.26.8: the preview tag
// already carries 0.27.0, so no bump is computed and only a direct
// base-vs-ref comparison can refuse publishing 0.26 content as 0.27.0.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.26.8' }),
);
const mockWithAheadPreviewAtLatest = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.27.0-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.8';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.8', '0.27.0-preview.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithAheadPreviewAtLatest);
expect(() => getVersion({ type: 'stable' })).toThrow(
'Derived base 0.27.0 is on a different release line than the checked-out package.json version 0.26.8',
);
});
it('should allow the ordinary next patch when the checked-out ref matches latest', () => {
// main after v0.26.8: package.json 0.26.8, `latest` 0.26.8, and the
// Tuesday preview already targets 0.26.9. A versionless stable dispatch
// promotes that preview; it is not a cross-line release.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.26.8' }),
);
const mockWithNextPatchPreview = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.26.9-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.8';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.8', '0.26.9-preview.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNextPatchPreview);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.26.9');
expect(result.npmTag).toBe('latest');
});
it('should refuse a stable release when the ref is behind latest on the same line', () => {
// release/0.26.5 predates the 0.26.8 stable. Bumping it to 0.26.9
// would publish older content over a newer stable.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.26.5' }),
);
const mockWithStaleRef = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.26.3-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.8';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.3-preview.0', '0.26.8']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithStaleRef);
expect(() => getVersion({ type: 'stable' })).toThrow(
'is ahead of the checked-out package.json version',
);
});
it('should release a new minor when the checked-out ref is ahead of latest', () => {
// The ordinary new-minor dispatch: the preview tag already carries the
// next minor while the ref's base is ahead of `latest`.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.27.0' }),
);
const mockWithNextMinorPreview = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.27.0-preview.0';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.9';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.9', '0.27.0-preview.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNextMinorPreview);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.27.0');
expect(result.npmTag).toBe('latest');
});
it('should refuse a ref ahead of latest when the preview base lags it', () => {
// The preview tag still carries 0.26 while the ref is already 0.27.0.
// Bumping the lagging preview base to 0.26.10 would publish the 0.27.0
// content as a 0.26 patch on `latest`.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.27.0' }),
);
const mockWithLaggingPreview = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.26.0-preview.1';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.26.9';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.26.0-preview.1', '0.26.9']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithLaggingPreview);
expect(() => getVersion({ type: 'stable' })).toThrow(
'would publish the checked-out 0.27.0 content on an older release line',
);
});
it('should allow a bump that lands exactly on the checked-out ref version', () => {
// A ref at 0.9.1 publishing the next patch after latest 0.9.0 is the
// ordinary release; the cross-line guard must stay strictly-below.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.9.1' }),
);
const mockWithExactRefBump = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.7.0-preview.1';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.9.0';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([
'0.7.0-preview.0',
'0.7.0-preview.1',
'0.9.0',
]);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithExactRefBump);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.9.1');
expect(result.npmTag).toBe('latest');
});
it('should refuse a stale ref when no preview has ever been published', () => {
// The package.json fallback skipped the stale-ref check, so a ref at
// 0.8.0 could select 0.8.x while latest had already moved to 0.9.0.
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.8.0' }),
);
const mockWithNoPreviewRoute = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
throw new Error('npm error code E404');
if (command.includes('npm view') && command.includes('--tag=latest'))
return '0.9.0';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.8.0', '0.9.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNoPreviewRoute);
expect(() => getVersion({ type: 'stable' })).toThrow(
'is ahead of the checked-out package.json version',
);
});
it('should bump the stable base when the preview base equals the published latest', () => {
// The common ordering: previews for X.Y.Z exist, then X.Y.Z ships
// stable. The equality arm keeps the derived version unpublished
// even when the existence probes cannot confirm it.
const mockWithEqualLatest = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
return '0.9.0-preview.1';
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
)
return '0.9.0';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.9.0-preview.1', '0.9.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithEqualLatest);
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.9.0' }),
);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.9.1');
expect(result.npmTag).toBe('latest');
});
it('should describe the bumped stable base as a candidate, not the released version', () => {
const mockWithTaggedCandidate = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
)
throw new Error('npm error code E404');
if (command.includes('npm view') && command.includes('--tag=latest'))
return '0.9.0';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([
'0.7.0-preview.0',
'0.7.0-preview.1',
'0.9.0',
]);
if (
command.includes('npm view @qwen-code/[email protected] version')
)
return '0.9.1';
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithTaggedCandidate);
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.9.0' }),
);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.9.2');
expect(errorSpy.mock.calls.flat().join('\n')).toContain(
'bumping the stable base to at least 0.9.1.',
);
errorSpy.mockRestore();
});
});
it('runCli default dispatch prints the version JSON and exits 0', () => {
@@ -55,16 +55,18 @@ const ciYaml = parse(readFileSync('.github/workflows/ci.yml', 'utf8'));
// possibly dirty workspace. Match the pool itself, not just the output
// references that usually name it: jobs can also hard-code the shared label
// array, and a checkout on either form inherits the same leftovers.
// lint_and_static reads lint_runner (every PR reaches the pool), the other
// lanes ubuntu_runner — both name the same shared workspace.
// lint_and_static reads lint_runner (every PR reaches the pool), the test
// lanes read pr_test_runner, the other lanes ubuntu_runner — all name the same
// shared workspace. pr_test_runner is listed explicitly because jobs that read
// it are also kept in scope by their own sweep step, which would otherwise hide
// a future job that adds the pool and forgets the sweep.
// Include retained recovery steps on hosted jobs too, so rerouting cannot
// silently restore an untested sweep. Select by properties, not job name.
const sharedPoolSelector = /ubuntu_runner|lint_runner|pr_test_runner|ecs-qwen/;
const ciCleanSteps = Object.entries(ciYaml.jobs)
.filter(
([, job]) =>
(/ubuntu_runner|lint_runner|ecs-qwen/.test(
JSON.stringify(job['runs-on'] ?? ''),
) ||
(sharedPoolSelector.test(JSON.stringify(job['runs-on'] ?? '')) ||
(job.steps ?? []).some(
(s) => s.name === 'Clean stale .qwen before checkout',
)) &&
@@ -381,10 +383,24 @@ describe('review worktree cleanup steps', () => {
}
});
it('matches every pr_test_runner job with the shared-pool selector itself', () => {
const poolJobs = Object.entries(ciYaml.jobs).filter(([, job]) =>
JSON.stringify(job['runs-on'] ?? '').includes('pr_test_runner'),
);
expect(poolJobs.length).toBeGreaterThan(0);
for (const [id, job] of poolJobs) {
expect(
sharedPoolSelector.test(JSON.stringify(job['runs-on'] ?? '')),
`job "${id}" runs on pr_test_runner and must match the pool selector`,
).toBe(true);
}
});
it('keeps every shared-pool ci.yml checkout sweep pinned to paths.ts', () => {
expect(ciCleanSteps.map(({ id }) => id)).toEqual(
expect.arrayContaining([
'test',
'test_build',
'web_shell_e2e_smoke',
'integration_cli',
]),