mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
Merge branch 'main' into fix/ci-flaky-patrol-ecs-cold-start
Lint & Static red onfa9e3ca963was 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:
commit
669d4bda1d
69 files changed
+5937
-327
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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)。
|
||||
@@ -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. 后续工作
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[] } {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')]);
|
||||
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-2
@@ -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());
|
||||
}
|
||||
|
||||
+4
-8
@@ -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>
|
||||
|
||||
+30
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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' } : {},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]\') }}',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
]),
|
||||
|
||||
Reference in new issue
Block a user