diff --git a/.github/workflows/browser-runtime-pin.yml b/.github/workflows/browser-runtime-pin.yml index 0c94226a1..2686e79bd 100644 --- a/.github/workflows/browser-runtime-pin.yml +++ b/.github/workflows/browser-runtime-pin.yml @@ -25,5 +25,7 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 + # The check parses its upstream answers with zod. + - run: npm ci --ignore-scripts --no-audit - name: the runtime pin is current run: node scripts/check-browser-runtime-pin.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eab10b99d..602e97dcb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -91,7 +91,7 @@ jobs: - name: unit lint gates: 'lint:js:unit' - name: build - gates: 'typecheck check:badges check:tokens check:l10n check:reference check:host-api check:plan check:roadmap check:catches check:usable-reply check:wrap-up-request check:no-output-cap check:no-round-cap deadcode cycles duplication build security:audit' + gates: 'typecheck check:badges check:tokens check:l10n check:reference check:host-api check:plan check:roadmap check:catches check:usable-reply check:wrap-up-request check:no-output-cap check:no-round-cap check:workflow-scripts deadcode cycles duplication build security:audit' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e9d8fbc2..5c25bc6cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,13 @@ jobs: echo "::error::tag ${GITHUB_REF_NAME} points at ${GITHUB_SHA}, which is not on main" >&2 exit 1 fi + # The pin check parses its upstream answers with zod, so the + # dependencies are installed first (scripts/check-workflow-scripts.mjs + # holds every job to that). + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + - run: npm ci --ignore-scripts --no-audit # M81 A1: never ship an expired browser check runtime pin, or one more # than 14 days behind the newest Stable (design spec v4 §4.2). - name: the browser check runtime pin is current @@ -213,6 +220,15 @@ jobs: path: reused/*.cdx.json if-no-files-found: error retention-days: 30 + # The receipt travels with them, so a recovery from this run checks + # the same per-asset hashes (release-reuse.mjs findRecovery). + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: steps.check.outputs.reused == 'true' && steps.lookup.outputs.legacy-recovery != 'true' + with: + name: source-tree-${{ steps.lookup.outputs.tree }} + path: reused/release-build.json + if-no-files-found: error + retention-days: 30 - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.check.outputs.reused == 'true' && hashFiles('reused-helpers/x64/**') != '' with: @@ -341,18 +357,40 @@ jobs: staged=1 fi done + # Every VSIX (the universal one and the six targets) carries the + # Linux voice bundle. Its dist/voiceLinux.js is only a loader: the + # code is that member of the VSIX's runtime archive. + voice_linux() { + unzip -p "$1" extension/dist/runtime.bundles.json.br > release/runtime.bundles.json.br + node scripts/runtime-archive-member.mjs release/runtime.bundles.json.br voiceLinux.js > release/voiceLinux.js + rm release/runtime.bundles.json.br + } + shopt -s nullglob + vsixes=(release/*.vsix) + if [ "${#vsixes[@]}" -eq 0 ]; then + echo '::error::no VSIX to check the helper pins of' >&2 + exit 1 + fi if [ "$staged" = 1 ]; then # The tagged source carries unset pins by design; measure the # staged helpers (and this tag) into the checker's own copy so - # it verifies the VSIX against real bytes, not empty pins. + # it verifies each VSIX against real bytes, not empty pins. node scripts/build-linux-helper-pins.mjs helpers/x64 helpers/arm64 - unzip -p release/*.vsix extension/dist/voiceLinux.js > release/voiceLinux.js - node scripts/check-linux-helper-pins.mjs --bundle release/voiceLinux.js --assets release/helpers - rm release/voiceLinux.js - elif unzip -p release/*.vsix extension/dist/voiceLinux.js | grep -q 'linux-x64-muse-dictate'; then - echo '::error::this VSIX pins helper assets but none were staged' >&2 - exit 1 + for vsix in "${vsixes[@]}"; do + voice_linux "$vsix" + echo "${vsix##*/}:" + node scripts/check-linux-helper-pins.mjs --bundle release/voiceLinux.js --assets release/helpers + rm release/voiceLinux.js + done else + for vsix in "${vsixes[@]}"; do + voice_linux "$vsix" + if grep -q 'linux-x64-muse-dictate' release/voiceLinux.js; then + echo "::error::${vsix##*/} pins helper assets but none were staged" >&2 + exit 1 + fi + rm release/voiceLinux.js + done rmdir release/helpers fi - name: checksums for every release asset diff --git a/CHANGELOG.md b/CHANGELOG.md index 498accece..9189ba043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project are documented here. The format follows [Semantic Versioning](https://semver.org/). Entries record what actually happened, not what was planned; superseded entries are kept. -## [0.19.1] - 2026-10-21 +## [0.19.0] - 2026-10-21 ### Highlights @@ -388,6 +388,18 @@ al.` notice suffixes, terminal periods, paired email/URL contacts) while Windows PowerShell starts slowly (a fresh machine, heavy antivirus): the process identity a vault writer records is read by a small native reader instead, and nothing gives up after 10 seconds. +- On Windows, steps that read or end processes through Windows PowerShell, + `taskkill` or the process table are no longer killed after 20 seconds. On + a slow machine Windows PowerShell's first start can take longer, and the + step it served (building a Windows helper, reading a process tree, ending + one) then failed. These steps, and their macOS and Linux counterparts, now + run until they end; Stop, or a deadline the caller sets, still ends them. +- Reports no longer give up on a source after 5 seconds. A slow answer from + GitHub, the Marketplace, Open VSX, npm or a large repository's git history + used to make that part of the report read "unavailable"; each source now + waits for its answer, and Stop still ends the report. A source rate-limited + without saying until when is asked again on the next report instead of + being held back for 5 seconds. - On Windows as an elevated administrator (Windows Server's defaults), work the resource governor starts is no longer refused, and the private folder for a screen-recording preview is locked to its owner alone. @@ -852,11 +864,19 @@ al.` notice suffixes, terminal periods, paired email/URL contacts) while ### Release infrastructure -- 0.19.1 is the first published 0.19 release. Its first tag, `v0.19.0`, - was never published: the release stopped at its own checks, before - building anything, because the browser check's pinned Chrome for Testing - runtime had fallen more than 14 days behind Chrome's Stable channel. A - tag never moves, so the renewed pin ships as 0.19.1. +- Two earlier release tags for this version stopped at the release's own + checks, before anything was built or published: first because the browser + check's pinned Chrome for Testing runtime had fallen more than 14 days + behind Chrome's Stable channel, then because the pin check, which had just + begun reading Google's version lists through a schema, could not load that + schema's library in a job that installed nothing. Both are fixed; the + unpublished tags were deleted and 0.19.0 was tagged again. +- A new gate, `npm run check:workflow-scripts`, fails any workflow step + that runs one of the repository's scripts before its job installs the + dependencies that script imports. +- The release checks every package's Linux helper pins (each VSIX's voice + bundle read from its runtime archive), and a release that reused its CI + packages can be recovered from like one that built them. - Every fake test is gone. Deleted: the tests that used stand-ins or invented data, the tests that only checked that some text, key or file exists, the editor host checks that drove the fake CLI, the fake-only diff --git a/PLAN.md b/PLAN.md index e9cbf7ce1..0f5a42fd9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -417,7 +417,8 @@ all identity/lease assertions and every deadline. Restart the thirty idle and thirty loaded complete-file sequence and record this excluded failed attempt. **SPAWN017B follow-up (2026-10-08, win11).** Govern helper compilation in -one bootstrap tier: heavy admission, PROCESS_TABLE_TIMEOUT_MS deadline, +one bootstrap tier: heavy admission, PROCESS_TABLE_TIMEOUT_MS deadline +(removed 2026-10-21 under D121: only the caller's cancel or deadline stops it), bounded output, whole-tree cancellation and observed exit before publication. The job builder cannot join a job made by its own not-yet-built helper. Bind runtime process admission to the existing runtime resource host and @@ -12856,8 +12857,13 @@ Needs: .`). rate-limited until {time}". Requests are conditional (ETags) into a cache under `/reports/v1/cache/`, whose age is the source's freshness. Pages are bounded. - - **Timeouts** per source (`REPORT_SOURCE_TIMEOUT_MS`); the network - sources sit outside the local time budget (decision 14). + - **No timeout of ours** (D121/D208, 2026-10-21): a source read runs + until it settles or the caller's signal (the person's cancel) aborts; + the former `REPORT_SOURCE_TIMEOUT_MS` is gone, and its 5-second figure + survives only as `REPORT_SOURCE_FRESH_MS`, which labels a source + `fresh` or `stale` and stops nothing. A rate-limited origin is held + only until the server's own Retry-After or reset. The network sources + sit outside the local time budget (decision 14). 7. **Four formats.** - **Markdown:** GitHub-flavoured tables, LF, one trailing newline. @@ -15265,13 +15271,12 @@ they hold on Muse Code 1.4.7 and later. Record: - **Q-0191-LINUX-VAULT — the vault in VS Code on Linux (2026-10-20).** **Resolved by the owner: ship with the limitation documented, fix in the - next release** (the owner named it 0.19.1 before 0.19.0's tag was refused - and the release itself became 0.19.1). The .vsix carries no `@napi-rs/keyring` binding (about + next release** (0.19.1; the release itself is 0.19.0). The .vsix carries no `@napi-rs/keyring` binding (about 3.4 MB unpacked against ~200 KB of package budget headroom), so the extension's Linux build has no Secret Service slot, and the TPM2 slot probes `/dev/tpmrm0` directly, which a user outside the `tss` group cannot open; on a stock Linux desktop the vault therefore cannot be set up in VS - Code (the terminal agent installs the binding and works). 0.19.1 makes the + Code (the terminal agent installs the binding and works). 0.19.0 makes the `storeUnavailable` sentence say this, and the CHANGELOG and README record it. Options for the next release: seal through `systemd-creds`' user scope (systemd 256+, no device access), the Secret Service over D-Bus without a native @@ -35913,10 +35918,10 @@ memory fixtures, and real ACP delivery uses a three-call caller allowance. | `test/unit/schedulePaid.test.ts:74` (`MALFORMED_USD`) | `'NaN' as UsdAmount` | Defense-in-depth probes inject malformed money into the estimate and durable reservation ports. `Usd.from` and the schema both reject NaN, so no valid constructor can produce this branded value; constructing it inside the probe would bypass the production refusal being tested. Test-only; both refusal assertions are retained. | | `test/unit/vault/vaultPanelBinding.test.ts` (`pressedTitle`) | `value as never` for a dialog's pressed item | The vscode mock keeps the overloaded dialog type (its last overload resolves `MessageItem`), while `VaultPanelHost` compares the pressed title as a plain string. The cast carries only the literal titles the test itself passes (`Keep File`, `Delete the File`, `Continue`); the host still compares them against `UI_TEXT` before acting, so no foreign value can cross. Test-only. | -| SPAWN017B location | Escape hatch | Reason | Removal | -| --------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | -| core/resources/bootstrap.ts | detect-child-process nosemgrep on spawn | The shared compiler boundary holds heavy admission, strips credentials, caps output and deadline, and observes exit; it cannot use its not-yet-built containment helper. | Keep the bootstrap-tier boundary guarded by native tree and admission regressions. | -| core/resources/bootstrap.ts | detect-child-process nosemgrep on taskkill | Fixed trusted OS terminator, owned numeric PID, empty credential environment and bounded output/deadline; termination must remain usable at pause. | Retain the native whole-tree drill; compiled payloads use jobs. | +| SPAWN017B location | Escape hatch | Reason | Removal | +| --------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| core/resources/bootstrap.ts | detect-child-process nosemgrep on spawn | The shared compiler boundary holds heavy admission, strips credentials, caps output, stops only on the caller's own cancel or deadline (none of ours, 2026-10-21), and observes exit; it cannot use its not-yet-built containment helper. | Keep the bootstrap-tier boundary guarded by native tree and admission regressions. | +| core/resources/bootstrap.ts | detect-child-process nosemgrep on taskkill | Fixed trusted OS terminator, owned numeric PID, empty credential environment and bounded output, with no deadline of ours (2026-10-21); termination must remain usable at pause. | Retain the native whole-tree drill; compiled payloads use jobs. | | 0.15.0 hosted-CI location | Escape hatch | Reason | Removal | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -39921,24 +39926,7 @@ Unresolved items remain delivery/security gaps, not accepted legal conclusions: ## 10. Definition of done and release records -**0.19.1 preparation (2026-10-21).** `release/0.19.1` cut from `main` at -`e48e8c6bce` (PR #237's merge): `package.json` and `package-lock.json` at -0.19.1, the browser check's runtime pin renewed (Chrome for Testing 156.0.8078.47, r1706539, published 2026-10-20, from the new `scripts/pin-browser-runtime.mjs`), CHANGELOG's -`[0.19.0]` section renamed `[0.19.1]` with the reason under Release -infrastructure, the roadmap regenerated. - -**0.19.0 (2026-10-21, tag `v0.19.0` on main's release merge `e48e8c6bce`, PR #237, release run 39569020330: never published).** -PR #237 (`release/0.19.0`, head `93fd23ec70`) merged after first-attempt -green CI, Hosts, Forks and Browser check runs; the merge commit's Hosts run -was green and `rule2-gate.mjs release --sha e48e8c6bce` read five green -first runs. The release run stopped at its tag checks: -`check-browser-runtime-pin.mjs` found Stable 156.0.8078.47 published 21.060 -days after the pinned 154.0.8037.92 (at most 14). Nothing was built or -published, on any channel. A tag does not move (docs/RELEASING.md), so -0.19.0 is spent and the release ships as 0.19.1 (above). PR #237's earlier -runs and their fixes are in `docs/certification/cifinal0190.md` §12. - -**0.19.0 preparation (2026-10-17).** `release/0.19.0` cut from `main-now` at +**0.19.0 preparation (2026-10-17; tagged again 2026-10-21).** `release/0.19.0` cut from `main-now` at `81dacaf5a7`, merging in order PR #185 (`fix/sandbox-say`: the Linux sandbox cannot-start notice), PR #189 (`ms/msm112`: the production build runs through a bounded pool of 8), PR #190 (`ms/msm56`: Muse Voice stays unavailable with @@ -40133,6 +40121,33 @@ no conflicts, no generated-file changes. JOIN0190 (2026-10-19/20, lane `release/0.19.0-join0190`): 107 area candidates merged in order (PR/A/B/C areas, limits, deps, vault, standins, audits, tests, notes, bundles, activation, full-suite runs), carrying PR #154, #158, #159, #169, #194, #195, #207, #215, #218, #219, #220, #222, #231, #232, #233, #234, #245, #246, #247, #249, #250 and #251; closing fix jobs 179 (AREA C vault+limits+deps+standins), 180 (NOLIMITSFINAL2), 181 (REDFIX0190), 182 (GRVFIXB3), 183 (GRVFIXB1) and 184 (GRVFIXB2). PR #203 re-landed (`8c91573c0` reverts the hold `659d179ff1`, latest head merged). Release preparation on the lane: CHANGELOG `[0.19.0]` rewritten person-facing within 76,800 bytes (whatsNew 55/55 green); duplicate D119/D121/D123/D140 renumbered above D206; `check:catches` baseline regenerated green; PLAN archived below 85% of 4 MiB with `check:plan` green; jscpd duplication 0 tree-wide; surface-English split back to its 25 KiB budget; legalFix previews ride the fix chunk (scan 143.6/150 KiB, fix 70.6/75); playbook rename evidence out of the report chunk (54.7/59); modelApi capped at the measured 530 KiB (527.9 of authorized turn-path weight); activation budget holds with headroom; bundle-split gate cleared (probe environment beside the resolver, `$ZodError` exported, capability-record allowance in both its sites); §8 rows for the bundle-diet disables; fileLease and schedule-delivery suites green on the final tree. The universal .vsix packs in hosted CI (its Linux helpers build there); the 3,072,000-byte cap stands. +On 2026-10-21, by the owner's order, the +release ships as 0.19.0: a failed check never moves the version. Two tags +stopped at the release's own tag checks before anything was built or +published, and both are deleted before 0.19.0 is tagged again: + +- `v0.19.0` on `e48e8c6bce` (PR #237's merge, release run 39569020330): + `check-browser-runtime-pin.mjs` found Stable 156.0.8078.47 published + 21.060 days after the pinned 154.0.8037.92 (at most 14). PR #255 renewed + the pin (Chrome for Testing 156.0.8078.47, r1706539, published 2026-10-20, + from the new `scripts/pin-browser-runtime.mjs`) and parsed its upstream + answers with zod; for that pull request the version read 0.19.1. +- `v0.19.1` on `ff7c78d7a3` (PR #255's merge, release run 39667463849, + after first-attempt green CI, Hosts and Browser check runs and a green + `rule2-gate.mjs release`): `check-browser-runtime-pin.mjs` could not + load `zod`, because the tag-checks job installed nothing; no + pull-request run executes that job. + +PR #257 (`release/0.19.2`, cut from `main` at `ff7c78d7a3`): the tag +checks and the weekly pin job install before the pin check; the new +`check:workflow-scripts` gate; the release's Linux helper step reads each +VSIX's voice bundle from its runtime archive and checks all seven; recovery +accepts a release that reused verified CI artifacts (both found by +reviewing every job after the tag checks); `package.json`, +`package-lock.json` and the CHANGELOG section back at 0.19.0; the roadmap +regenerated. Records and drills: `docs/certification/cifinal0190.md` +§12–§18. + **0.18.2 released (2026-10-12, tag `v0.18.2` on main's release merge `898a26c5e`, PR #153, release run 38329786779).** Pull request 153 (`release/0.18.2`, head `d243e0771`, cut from `int/train-0182` at `5bc38fbe0` and merged with main at `e763f4938`) merged at diff --git a/README.md b/README.md index b8c196bdf..ad1874241 100644 --- a/README.md +++ b/README.md @@ -5918,7 +5918,7 @@ runtime effect: the vault always uses the operating system's credential store, the same one every editor and the ACP agent use, plus a one-time recovery code shown at setup. The internal POSIX broker uses Linux Secret Service and the macOS login Keychain. Linux requires an available Secret Service and an unlocked keyring; -a signed-in desktop session normally supplies them. Known issue in 0.19.1: the +a signed-in desktop session normally supplies them. Known issue in 0.19.0: the VS Code extension's Linux build carries no Secret Service component, so in VS Code on Linux the vault can be set up only with a TPM your account can reach (usually by joining the `tss` group); the terminal agent @@ -6223,6 +6223,7 @@ recorded in [the M78 certification](docs/certification/m78.md). | `npm run security:secrets` | `gitleaks git` over the repository history | | `npm run check:l10n` | The localization gate: every table in `l10n/` has every key of the English one (`src/shared/l10n/en.ts`) with the same `{slots}`, code spans and bold markers, exactly the plural forms its language uses, and nothing left in English but the names `l10n/untranslated.json` allows; every string `package.json` shows is a `%key%` of `package.nls.json`; and nothing reads `UI_TEXT` while its module loads. After packaging, `npm run check:l10n -- --packaged dist/vsix-package` applies the same strict checks to the shipped bytes and requires values identical to source | | `npm run check:host-api` | The host API gate (PLAN.md D60): checks `docs/ide-compatibility/host-api.md`, the record of every VS Code API the extension uses and where, the files that import `vscode`, the Node built-ins and what the webview needs from its host, against the source; fails when it is stale (`-- --write` regenerates it) and when the engine, the protocol, the webview or a portable host module reaches `vscode` | +| `npm run check:workflow-scripts` | The workflow script gate: every repository file `node` loads in a `.github/workflows/` step that runs before its job's `npm ci` or `npm install` (`node `, or a relative import in inline `node -e` code) may import Node's built-in modules only, followed through every relative import (read from esbuild's metafile), and inline code may import no package; one that does fails, naming the workflow, job, step, file, package and importing file | | `npm run quality:gates` | `format:check`, `lint`, `typecheck`, `check:l10n`, `check:host-api`, `deadcode`, `cycles`, `duplication`, `test:unit`, `build`, `security:audit`: what CI runs on all three platforms | | `npm run quality` | `quality:gates`, then `test:a11y`, `security:secrets` and `security:sast`; **exits non-zero on any finding** | | `npm run quality:ci` | `quality:gates`, `test:a11y`, then `test:integration` (no secrets or SAST); CI itself runs these as separate steps, see Releases | @@ -6406,8 +6407,13 @@ the packages, inventories and source-tree receipt for 30 days; The owner can force a rebuild with Actions variable `RELEASE_FORCE_REBUILD=true`. Successful pull-request, merge-queue (`merge_group`) and main-push CI runs qualify by their recorded checkout tree. Manual recovery on a version tag keeps -`artifacts_run_id`: the earlier Release build is validated, and its original -bytes pass through the same verification/staging job. Invalid recovery stops; +`artifacts_run_id`: the earlier Release run, one that built its packages or +one that reused verified CI artifacts (it keeps their receipt), is validated, +and its original bytes pass through the same verification/staging job. Before +publishing, the release checks every VSIX's Linux helper pins against the +staged helper files, reading each package's voice bundle from its runtime +archive with `node scripts/runtime-archive-member.mjs + voiceLinux.js`. Invalid recovery stops; cancelled runs cannot publish. Older builds without a receipt retain inventory, manifest and download-integrity checks; see the recovery guide for that limit. An M80 `v0` tag update blocked by the release-tags ruleset is reported separately diff --git a/ROADMAP.md b/ROADMAP.md index 8a0533588..6a6ff1ccf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ - + What Muse Spark Code has shipped, what is in the next release, what is being built and what is planned. Plans change: an item can move, shrink or be dropped, and no date is promised. Versions stay below 1.0; there is no 1.0 promise or date. @@ -10,13 +10,7 @@ Features land for every supported editor: VS Code and VS Code-based editors, and Labels come from the project plan: _Preview_ means usable, with documented limits. The [changelog](CHANGELOG.md) has the detail of every release, and the small tags (such as M40) are milestone ids in the project plan. -**Contents:** [In the next release (0.19.1)](#in-the-next-release-0191) · [In progress](#in-progress) · [Planned](#planned) · [Shipped](#shipped) - -## In the next release (0.19.1) - -Merged for 0.19.1 and being prepared for release. - -- Usage and billing: **Exact money everywhere** — Every cost, budget and usage total kept as an exact decimal dollar amount, with exact period comparisons and historical days or months refused with an explanation when their dollars cannot be preserved. M121 +**Contents:** [In progress](#in-progress) · [Planned](#planned) · [Shipped](#shipped) ## In progress @@ -108,6 +102,10 @@ Designed in the plan and not started yet. By release, newest first. +### 0.19.0 (2026-10-21) + +- Usage and billing: **Exact money everywhere** — Every cost, budget and usage total kept as an exact decimal dollar amount, with exact period comparisons and historical days or months refused with an explanation when their dollars cannot be preserved. M121 + ### 0.18.2 (2026-10-12) - Platform: **Updated product pictures** — The README pictures show the extension's own mark, and the Get Started walkthrough's four pictures show the current panel, including the sign-in screen's Start with your own model choice. diff --git a/docs/certification/cifinal0190.md b/docs/certification/cifinal0190.md index ae5ff7520..97e99d261 100644 --- a/docs/certification/cifinal0190.md +++ b/docs/certification/cifinal0190.md @@ -593,3 +593,175 @@ Proof, on this PC against the live sources: string: must match pattern /^x$/ at channels.Stable.revision") and the generator exited 1 with the same message before any download. Restored: the check exits 0 and the schemas parse the live answers again. + +## 16. 0.19.1's tag refused: the tag checks could not load zod + +- `v0.19.1` on `ff7c78d7a3` (PR #255's merge; release run 39667463849): the + tag checks stopped the release before any build or publication. + `node scripts/check-browser-runtime-pin.mjs` exited 1 with + `Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'zod' imported from +.../scripts/check-browser-runtime-pin.mjs`. Section 15's commit + (`45796ee1e0`) made the pin check parse its upstream answers with zod; the + release's tag-checks job checks out the repository and runs the check + with no `npm ci`, and no pull-request run executes that job, so every + check on PR #255 was green. The weekly `browser-runtime-pin.yml` job had + the same gap. (The release was first moved to 0.19.2; by the owner's order + it stays 0.19.0: section 18.) +- The fix: the tag-checks job sets up Node 22 and runs + `npm ci --ignore-scripts --no-audit` before the pin check (after the + cheap tag, version and ancestry checks); the weekly pin job installs too. +- The gate: `scripts/check-workflow-scripts.mjs` (`npm run +check:workflow-scripts`, in `quality:gates` and the hosted static gates) + reads every workflow, and for each `node scripts/...` step that runs + before its job's `npm ci` or `npm install`, bundles the script with + esbuild, every package external, and fails on any package its metafile + shows the script or a file it reaches importing. + +Drills, on this PC: + +- Red, the real defect: on `ff7c78d7a3`'s workflows the gate exited 1 with + four errors, two per job (`release.yml › tag checks › the browser check +runtime pin is current` and `browser-runtime-pin.yml › pin is current › +the runtime pin is current`): `imports 'zod' +(scripts/lib/browserRuntimeSources.mjs)` and `imports 'zod' +(scripts/check-browser-runtime-pin.mjs)`. +- Green with the fix: exit 0, "34 workflow steps run scripts/; the 2 before + their job's install import Node's built-in modules only". +- Red on purpose: the new `npm ci` step removed from `release.yml`: exit 1, + the two tag-checks errors; restored: exit 0. + +## 17. The release jobs after the tag checks, reviewed before tagging again + +Both refused tags stopped in the first job, so no release job after it has +run since 0.18.2. A read-only review of that path against PR #255's real CI +artifacts (run 39663073396) found: + +- **The helper step could not pass (fixed).** `release.yml`'s "stage the + Linux helper release assets when present" ran `unzip -p release/*.vsix +extension/dist/voiceLinux.js`; with seven VSIX files `unzip` read the + other six as entry names and exited 11, before the GitHub Release. Behind + it, `dist/voiceLinux.js` in a VSIX is a loader: the code is the + `voiceLinux.js` member of `dist/runtime.bundles.json.br`. The step now + reads that member from every VSIX with the new + `scripts/runtime-archive-member.mjs` and checks each against the staged + helpers. +- **Recovery from a reused release could not work (fixed).** A release that + reuses verified CI artifacts skips its build and uploaded no source-tree + receipt, so `findRecovery` refused it ("recovery build did not pass"). The + reuse job now uploads the receipt with the packages, and `findRecovery` + accepts a run whose reuse job passed and whose build was skipped; the + artifact and receipt checks are unchanged. +- **Universal plus six targeted packages on the registries (checked, no + change).** The Marketplace documents a package published without a target + as the fallback for platforms with no package of their own. Both + registries carry exactly this shape today for redhat.java 1.59.2026102108 + (Marketplace: six targets and one with none; Open VSX: `universal` and six + targets). Open VSX `/api/redhat/java/1.59.2026102108` returns the + universal download and `/api/redhat/java/linux-x64/1.59.2026102108` the + linux-x64 one; the Marketplace `vspackage` returned 54,062,181 bytes with + no target and 137,877,667 with `?targetPlatform=linux-x64`. These are the + URLs `verifyPublished` reads for each artifact. +- The full-build fallback has never run on a tag; it runs only when no CI + run matches the tag tree, so nothing merges into `main` between PR #257's + pull-request CI and its tag, and the tree match is confirmed before + tagging. + +Drills, on this PC, with the step's `run` text taken from the workflow and +run under `bash --noprofile --norc -eo pipefail` on PR #255's seven VSIX +files and both helper builds: + +- Red: the previous step text exited 11 ("caution: filename not matched: + release/muse-spark-code-darwin-arm64-0.19.1.vsix", one per extra VSIX). +- Green: the new step exited 0, "Helper pins accepted" for all seven VSIX + files, 24 helper assets staged. +- Red on purpose: one byte appended to the x64 `libggml.so`: exit 1, "Helper + pins rejected: x64/libggml.so: digest missing from the bundle"; restored: + exit 0, seven accepted. +- No helpers staged with these VSIX files: exit 1, "muse-spark-code-0.19.1.vsix + pins helper assets but none were staged". +- Recovery, against the real GitHub API: the previous `findRecovery` + refused the reused v0.18.2 release run 38329786779 ("recovery build did + not pass"); the new one accepted it (commit `898a26c5e3`, its three + package artifacts). It still refuses v0.19.1's failed run 39667463849 + ("recovery build did not pass") and a run of another tag ("ineligible + recovery run"). + +## 18. The release stays 0.19.0 + +By the owner's order (2026-10-21), a failed check never moves the version: +the release ships as 0.19.0. PR #257 had moved it to 0.19.2; its version, +CHANGELOG section, PLAN record and roadmap are back at 0.19.0. The two +refused tags, `v0.19.0` (`e48e8c6bce`) and `v0.19.1` (`ff7c78d7a3`), +published nothing on any channel (no GitHub Release, no npm version, no +Marketplace or Open VSX version), and are deleted before `v0.19.0` is +created on PR #257's merge. + +## 19. PR #257's review findings + +- The manifest test's added count of `if: steps.check.outputs.reused == +'true'` lines, and the line checking the receipt step's name, only + checked workflow text (AGENTS.md: such checks are not tests). Both are + removed; the recovery fix's proof is the real-API run in section 17. +- `check-workflow-scripts.mjs` read only `node `, so a helper loaded + from inline code before an install (`node --input-type=module -e "import +{ buildLinuxHelper } from './scripts/build-linux-helper.mjs'..."` in + build.yml, forks.yml and hosts.yml) went unchecked. The gate now also + reads inline `-e`, `--eval`, `-p` and `--print` code: each relative import + is followed like a file `node` runs, and a package import fails on its + own. It now sees 43 files, 8 of them loaded before their job's install. + +Drills, on this PC: + +- Red: `import 'zod'` added to `scripts/build-vault-peer.mjs`: exit 1, three + errors (build.yml `compile native helpers`, forks.yml and hosts.yml + `package helper`), each "node loads scripts/build-vault-peer.mjs, which + imports 'zod'". Restored: exit 0. +- Red: `import { z } from 'zod';` added to build.yml's pre-install inline + helper build: exit 1, "inline node code imports 'zod', and the job installs + nothing before this step". Restored: exit 0. (The same edit to a hosts.yml + step that runs after its job's `npm ci` stayed green, as it should.) + +## 20. PR #257's third run: our own time limits on slow Windows runners + +PR #257 head `87753d1877` (CI run 39675615471, capacity run 39675898149): +red on hosted Windows only, in code the pull request did not change, as on +PR #255's second run and PR #257's first (capacity run 39672218426): a +different set of cases each time, each one ended by a time limit of ours on +a slow runner: `runnerHelperNative` past its 60 s, the schedule background +entry past the integration runs' 20 s, the screen recorder's Windows +PowerShell identity read killed by the process table's 20 s +(`killed: true, SIGTERM`), and store reads `unavailable` after the report +sources' 5 s. By the owner's decision (2026-10-21) the limits go in this +release: + +- Report sources: no deadline of ours ends a read, a post or a rate-limit + hold (`docs/certification/lim-reports.md`). +- Process queries and their stops: no deadline of ours on Windows + PowerShell, `taskkill` or process-table reads, the POSIX tree supervisor + arms a clock only for a caller's deadline and bounds none of its own + `ps` reads, and the media and vault stop paths wait for the process's own + exit (`docs/certification/nolimitsprocs0190.md`). +- The two test cases wait however long the machine takes. +- `callerStop` (`src/core/timeouts.ts`) reads 0 and Infinity as no + deadline, so the report reads' `timeout: Infinity` never reaches + `AbortSignal.timeout`, which throws on it (checked: `ERR_OUT_OF_RANGE`). +- `resourceCreatedSweep`'s 5,000-file case took 7.5 to 13.6 s in fresh + worktrees on this PC and 15 to 16.2 s in this worktree (the same commit, + the same `node_modules`, the same disk speed by a 5,000-file write + benchmark), against the repository's 15 s Windows budget: it waits however + long it takes too (16.2 s here, passing). +- PR #257 head `5366bc8d15` (CI run 39703467949, capacity run + 39703535342), two Windows reds: + - Integration: `TypeError: Cannot read properties of undefined (reading +'timeout')`. On Windows the wake test is registered with `test.skip`, + which in mocha's TDD interface returns nothing (`common.js`: `skip: +function (title) { context.test(title); }`), and `.timeout(0)` was + chained on it. The no-limit timeout now goes on that test only where it + runs. + - `windowsVaultPanelFlow` (shard 6): the panel's first open settled on + `uncreated` with two "The vault operation failed. Try again." toasts. + It passes here alone and in the whole of shard 6 + (`npx vitest run --shard=6/16`: the vault flow green; one symlink case + refused by this PC's account), and no vault path reaches the process + helpers whose deadlines changed. The toast check printed no log; it now + prints the panel's log with the toasts, so a repeat names its cause. diff --git a/docs/certification/lim-reports.md b/docs/certification/lim-reports.md new file mode 100644 index 000000000..6d6df5ffb --- /dev/null +++ b/docs/certification/lim-reports.md @@ -0,0 +1,80 @@ +# LIM-REPORTS: no timeout of ours ends a report source read (D121/D208) + +Lane LIM-REPORTS, branch `lim/reports`, base `87753d1877`, Windows 11 +workstation, 2026-10-21. `REPORT_SOURCE_TIMEOUT_MS` (5,000) abandoned report +source reads after five seconds: GitHub, the Marketplace, Open VSX and npm +store reads, the local git reads, the schedules store's lease wait and a +saved report's read. On slow Windows CI runners a real store read ran past +it and came back `unavailable`, so `storeProject.test.ts` and +`reportProductionTransport.test.ts` failed intermittently. The owner's rule +(D121, D208, 2026-10-16: "no limit in the codebase that is not about a +model") leaves only a provider's documented limit, a value the person set, +or the person's own cancel to stop a read. + +## What changed, site by site + +| Site | Before | After | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `cache.ts` `ReportNetworkReader.read` | `setTimeout` aborted the read's controller with `source-timeout` after 5 s | No timer. The caller's signal ends the read; the controller only stops pages still in flight once the read settled | +| `cache.ts` `rateLimit` | A limited answer naming no future instant held the origin for an invented 5 s | Only the server's own Retry-After, or GitHub's reset at the floor, holds the origin; otherwise the next read asks the server again | +| `cache.ts` dispatch, `admission.ts` | A `source-timeout` abort stepped `timedOut`; refusals read `source-deadline` | The `timedOut` event is gone (nothing could raise it); an abort refuses with `source-cancelled` | +| `github.ts` `ghReportTransport` | `gh api` ran with `timeout: 5000` | `timeout: Infinity`: gh's own exit or the caller's signal ends it | +| `github.ts` `postReport` | A 5 s timer abandoned the post (`post-deadline`) | No timer; only the caller's signal ends it (`post-cancelled`); a dispatched post still stays uncertain | +| `local.ts` `localSource` | `context.deadline ?? startedAt + 5000` armed a timeout on every read | No default: a deadline binds only when the caller hands one in (none does in production now); non-finite values name none. The race and settle grace moved into a helper | +| `local.ts` `sourceFreshness`, `checkRuns.ts` | Labelled `fresh`/`stale` against the timeout constant | Same labels against `REPORT_SOURCE_FRESH_MS` (5,000), which stops nothing | +| `engine.ts` generate | `deadline: Date.now() + 5000` on every source context | No deadline; one caller signal for the generation | +| `engine.ts` check journal `commit` | `rev-parse HEAD` under `AbortSignal.timeout(5000)` | `commit(signal)` takes the turn's signal (`verifyLoop.ts`, `reportCheckBundle.ts`, `ModelApiHost.ts`), so Stop ends it | +| `main.ts` `readSaved` | A saved report read under `AbortSignal.timeout(5000)` | Reads to end of file; the person's interrupt of the command stops it | +| `selections.ts` | `AbortSignal.any([outer, AbortSignal.timeout(5000)])` | The caller's signal alone | +| `playbookSchedulesInputs.ts` | The lease wait ended `busy` at `now + 5000 - SCHEDULE_LEASE_POLL_MS` | No deadline: the read waits for the holder's release or the lease's expiry, or the caller's cancel | +| `runtime/reporting/sources.ts` `reportGitIo` | git probe and reads with `timeout: 5000` | `timeout: Infinity` | +| `core/resources/commands.ts` `execResourceFile` | Any absent or finite `timeout` armed `AbortSignal.timeout` (absent: 20 s) | `timeout: Infinity` names no deadline (as `src/host/git.ts` reads it); absent and finite values are unchanged | +| `constants.ts` | `REPORT_SOURCE_TIMEOUT_MS = 5000` | Deleted; `REPORT_SOURCE_FRESH_MS = 5000` labels only | +| `en.ts` and the 14 tables, `reportSourceReasons.cancelled` | "The source read was cancelled or timed out." | "The source read was cancelled." (no default timeout remains to name) | +| PLAN.md D93 | "**Timeouts** per source (`REPORT_SOURCE_TIMEOUT_MS`)" | "**No timeout of ours**", with the label constant and the server-only rate-limit hold | + +## Tests changed + +- `playbookSchedulesInputs.test.ts`: "reports a held production lease as + busy" and "stops a queued reader on its deadline" asserted the 5 s + deadline. They are replaced by one case that holds a real production + lease for 6 s (past the former deadline), checks the read is still + waiting, releases the holder and gets an `ok` record, with the tree frozen + after it settled. "ends every refresh busy" waited out five deadlines; it + now cancels five refreshes through their own signals, checks each is + `cancelled` and that none acquired the lease, then reads fresh. +- `reportEngineCancellation.test.ts`: the handed-deadline case is renamed + (there is no default timeout to beat); the admission queue case retires + its owner with `aborted`, since `timedOut` is gone. + +## Red drills + +| Drill | Command | Red | Green | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------ | +| `AbortSignal.timeout(1)` added to `ReportNetworkReader.read`'s signal | `npx vitest run test/unit/storeProject.test.ts test/unit/reportProductionTransport.test.ts` | exit 1, 7 failed: `AssertionError: stores: The report could not be generated. (source-cancelled): expected 'unavailable' to be 'ok'` | exit 0 | +| `deadlineMs: Date.now() + 4750` restored on the schedules reader's lock | `npx vitest run test/unit/playbookSchedulesInputs.test.ts -t contention` | exit 1: "waits for a held production lease past the former deadline" `expected true to be false` (the read settled busy before 6 s) | exit 0 | + +Both stores suites serve the committed 2026-10-11 captures through the +injected public fetch (AGENTS.md rule 14); no network is reached. + +## Local gates (Windows 11 workstation) + +`npx prettier --check` and `npx eslint --max-warnings 0` on the touched +files, `npm run typecheck`, `node scripts/check-l10n.mjs`, +`npm run check:catches`, `npm run duplication`, `npm run deadcode`, +`npm run cycles`, `npm run check:plan`, `npm run check:roadmap` and +`npm run build` (budgets held) all exit 0. `check:catches` first named +eight changed sites: the same handlers with their bodies unchanged, whose +identity hash moved because code around them changed (per-file class counts +are equal before and after). They were audited and the baseline regenerated +with `--write-baseline`; no count rose or fell. + +`npx vitest related --run` exits 1: 11 files and +8 tests failed of 22,176. Six tests call `fs.symlink` in their own setup and +get Windows `EPERM` (no symlink privilege on this account): +`reportSourceReasons` (2), `reports.e2e` (1), `importIo` (1), +`vault/brokerSocketDirectory` (2). Five Windows vault suites failed their +setup on `%TEMP%` (bootstrap code 23), `mediaConvert` on its media root and +`Palette` on one price text; those seven files rerun with +`TEMP=C:/Users/Randy/mx-vtmp` pass (46 tests, exit 0). The symlink cases +stay failing here; hosted CI decides them. diff --git a/docs/certification/nolimitsprocs0190.md b/docs/certification/nolimitsprocs0190.md new file mode 100644 index 000000000..5b1648f6d --- /dev/null +++ b/docs/certification/nolimitsprocs0190.md @@ -0,0 +1,65 @@ +# NOLIMITSPROCS: no deadline of ours on process queries and their stops (D121) + +Lane `lim/procs`, base `87753d1877`, Windows 11 Pro 10.0.26200 (this rig), +2026-10-21. Hosted CI failed `test/unit/windowsScreenRecorderNative.test.ts` +with `Command failed: ...powershell.exe ... +[Security.Principal.WindowsIdentity]::GetCurrent().User.Value` (`killed: true`, +`SIGTERM`): `runProgram`'s default deadline, `PROCESS_TABLE_TIMEOUT_MS` +(20,000 ms), killed Windows PowerShell's first start on a slow runner. The +owner's rule (D121, D208, 2026-10-16: "no limit in the codebase that is not +about a model") leaves only a provider's documented limit, a value the person +set, a deadline the caller passes, or the person's own cancel to stop work. + +## What was removed + +| Site | Old bound of ours | Now | +| ----------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/host/processTree.ts` `runProgram` | default `timeoutMs = 20_000` | no default: `execFile` gets a `timeout` only when the caller passes one | +| `src/host/processTree.ts` `runTreeSync` | supervisor spec `timeout` and `spawnSync` `timeout` defaulted to 20 s (+ 2 × exit wait) | both only from the caller's positive `timeout`; the supervisor arms its timer only when `spec.timeout` is a finite positive number (`setTimeout(fn, undefined)` fires at once), and `spawnSync` gets no timeout | +| `src/core/bootstrapCommand.ts` | `AbortSignal.timeout(options.timeoutMs ?? 20_000)` | `callerStop(options.signal, options.timeoutMs)`: only the signals that exist, else none | +| `src/core/resources/commands.ts` | `AbortSignal.timeout(options.timeout ?? 20_000)` | `callerStop(options.signal, options.timeout)`; `timeout: 0` means none, as with `execFile` | +| `src/core/resources/process.ts` | bootstrap `taskkill` `timeout: 20_000` | none: the stop the caller asked for runs to taskkill's end | +| `src/core/resources/trees/run.ts` | `runTreeProgram = boundedTreeProgram(20_000)` | `boundedTreeProgram()` takes an optional time bound; the OS probes run until they exit (`macRss.ts` keeps passing its own `MEDIA_RSS_SAMPLE_RUN_TIMEOUT_MS`, a separate limit) | +| `src/core/media/record/windows.ts` | helper killed 20 s after Stop/Cancel | waits for the helper's own exit; a dead helper channel is killed at once (nothing could answer it); the person's Cancel or the window closing while a stop is pending kills at once | +| `src/host/vault/vaultExecSpawn.ts` | termination raced against 20 s rejecting with `vault.noAccess`; cleanup `Math.min` | termination itself is awaited; the cleanup run takes the runner's own `timeoutMs` | +| `test/unit/windowsScreenRecorderNative.test.ts` | `timeout: PROCESS_TABLE_TIMEOUT_MS` on two `execFileGuarded` calls | no timeout | + +`PROCESS_TABLE_TIMEOUT_MS` is deleted. No user-facing sentence named the +20-second bound (English and the 14 tables checked); PLAN.md §8's two +SPAWN017B rows and the SPAWN017B follow-up line now say the bootstrap tier +stops only on the caller's cancel or deadline. + +## What stays, and why + +- `POSIX_TREE_EXIT_WAIT_MS` / the supervisor's `cleanupMs` and + `TREE_EXIT_WAIT_MS`: the grace between SIGTERM/SIGSTOP and SIGKILL while a + kill the caller already asked for runs. Out of this lane's scope. Note for + the lead: the supervisor's periodic `/bin/ps` reads also use `cleanupMs` + as their `execFileSync` timeout, so a `ps` slower than 2 s ends the effect + even with no caller deadline. +- `ORPHAN_SWEEP_ROUNDS`, `SCREEN_RECORDING_REMOVE_ATTEMPTS`, + `RESOURCE_HANDOFF_TIMEOUT_MS` (detaches an opener, never kills it), and the + deadlines callers pass explicitly (`GIT_TIMEOUT_MS` from the playbook's + `runTreeSync` callers, `VAULT_BROKER_NATIVE_TIMEOUT_MS`, + `SCHEDULE_POLL_INTERVAL_MS`, `REPORT_SOURCE_TIMEOUT_MS` to + `execResourceFile`): different classes, listed for the lead. + +## Drills + +- **runProgram (this rig, Windows).** With the change, temporarily + `timeoutMs = 1` as runProgram's default: + `npx vitest run test/unit/windowsScreenRecorderNative.test.ts` exits 1 + (`Error: Command failed: C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -NonInteractive -Command [Security.Principal.WindowsIdentity]::GetCurrent().User.Value`, + `killed: true, signal: 'SIGTERM'`, all 40 skipped after the failed + `beforeAll`). Restored: exits 0, 40 passed. +- **runTreeSync supervisor (new `test/unit/treeSyncNoDeadline.test.ts`, + POSIX only).** Vitest's native binaries here are Windows-only, so the + test's two cases were bundled with esbuild (`temp/treeSyncDrill.ts`, + importing the real `runTreeSync`, `workspaceChildEnvironment`, + `withoutCredentials`) and run under WSL Arch's node v26.9.0: exit 0 (`PASS +no caller timeout runs to its end {"status":0,"stdout":"done\n"}`, `PASS +caller's timeout still ends it {"status":1,"stderr":"ETIMEDOUT"}`). With + the supervisor's guard removed in the bundle (timer armed unconditionally, + as before): exit 1, `FAIL no caller timeout runs to its end +{"status":1,"stdout":"","stderr":"ETIMEDOUT"}`. Hosted CI's Linux and + macOS jobs run the test itself. diff --git a/docs/roadmap/entries.json b/docs/roadmap/entries.json index 8706af657..9ed3e1d9d 100644 --- a/docs/roadmap/entries.json +++ b/docs/roadmap/entries.json @@ -883,7 +883,7 @@ "title": "Exact money everywhere", "summary": "Every cost, budget and usage total kept as an exact decimal dollar amount, with exact period comparisons and historical days or months refused with an explanation when their dollars cannot be preserved.", "area": "usage-billing", - "release": "0.19.1" + "release": "0.19.0" }, { "id": "M122", diff --git a/l10n/ui.cs.json b/l10n/ui.cs.json index 1fe8ef689..75ca9a69e 100644 --- a/l10n/ui.cs.json +++ b/l10n/ui.cs.json @@ -2145,7 +2145,7 @@ "missing": "Zdrojový soubor nebo repozitář chybí.", "invalid": "Zdroj má nepodporovaný formát nebo neplatná data.", "limit": "Zdroj překračuje limit čtení sestavy.", - "cancelled": "Čtení zdroje bylo zrušeno nebo vypršel časový limit.", + "cancelled": "Čtení zdroje bylo zrušeno.", "failed": "Zdroj se nepodařilo přečíst celý.", "refused": "Cesta ke zdroji je soukromá nebo mimo povolenou složku.", "disabled": "Tento zdroj využití agenta není zapnutý.", diff --git a/l10n/ui.de.json b/l10n/ui.de.json index 5d3451ee1..3344ef835 100644 --- a/l10n/ui.de.json +++ b/l10n/ui.de.json @@ -2087,7 +2087,7 @@ "missing": "Die Quelldatei oder das Repository fehlt.", "invalid": "Die Quelle hat ein nicht unterstütztes Format oder ungültige Daten.", "limit": "Die Quelle überschreitet das Leselimit des Berichts.", - "cancelled": "Das Lesen der Quelle wurde abgebrochen oder hat das Zeitlimit überschritten.", + "cancelled": "Das Lesen der Quelle wurde abgebrochen.", "failed": "Die Quelle konnte nicht vollständig gelesen werden.", "refused": "Der Quellpfad ist privat oder liegt außerhalb des erlaubten Ordners.", "disabled": "Diese Quelle für die Agentennutzung ist nicht aktiviert.", diff --git a/l10n/ui.es.json b/l10n/ui.es.json index e014ad654..6d793bd9f 100644 --- a/l10n/ui.es.json +++ b/l10n/ui.es.json @@ -2116,7 +2116,7 @@ "missing": "Falta el archivo de origen o el repositorio.", "invalid": "El origen tiene un formato no compatible o datos no válidos.", "limit": "El origen supera el límite de lectura del informe.", - "cancelled": "Se canceló la lectura del origen o se agotó el tiempo.", + "cancelled": "Se canceló la lectura del origen.", "failed": "No se pudo leer el origen completo.", "refused": "La ruta del origen es privada o está fuera de su carpeta permitida.", "disabled": "Este origen de uso del agente no está activado.", diff --git a/l10n/ui.fr.json b/l10n/ui.fr.json index 38419809b..71842cc50 100644 --- a/l10n/ui.fr.json +++ b/l10n/ui.fr.json @@ -2116,7 +2116,7 @@ "missing": "Le fichier source ou le dépôt est absent.", "invalid": "La source a un format non pris en charge ou des données invalides.", "limit": "La source dépasse la limite de lecture du rapport.", - "cancelled": "La lecture de la source a été annulée ou a dépassé le délai.", + "cancelled": "La lecture de la source a été annulée.", "failed": "La source n’a pas pu être lue entièrement.", "refused": "Le chemin de la source est privé ou hors du dossier autorisé.", "disabled": "Cette source d’utilisation de l’agent n’est pas activée.", diff --git a/l10n/ui.hu.json b/l10n/ui.hu.json index 23047f727..bd3422c56 100644 --- a/l10n/ui.hu.json +++ b/l10n/ui.hu.json @@ -2087,7 +2087,7 @@ "missing": "A forrásfájl vagy az adattár hiányzik.", "invalid": "A forrás formátuma nem támogatott, vagy érvénytelen adatokat tartalmaz.", "limit": "A forrás meghaladja a jelentés olvasási korlátját.", - "cancelled": "A forrás olvasása megszakadt, vagy lejárt az időkorlát.", + "cancelled": "A forrás olvasása megszakadt.", "failed": "A forrást nem sikerült teljesen beolvasni.", "refused": "A forrás elérési útja bizalmas, vagy az engedélyezett mappán kívül van.", "disabled": "Ez az ügynökhasználati forrás nincs bekapcsolva.", diff --git a/l10n/ui.it.json b/l10n/ui.it.json index a3b034beb..0af733ecf 100644 --- a/l10n/ui.it.json +++ b/l10n/ui.it.json @@ -2116,7 +2116,7 @@ "missing": "Il file di origine o il repository manca.", "invalid": "L’origine ha un formato non supportato o dati non validi.", "limit": "L’origine supera il limite di lettura del report.", - "cancelled": "La lettura dell’origine è stata annullata o è scaduta.", + "cancelled": "La lettura dell’origine è stata annullata.", "failed": "Non è stato possibile leggere tutta l’origine.", "refused": "Il percorso dell’origine è privato o esterno alla cartella consentita.", "disabled": "Questa origine di utilizzo dell’agente non è abilitata.", diff --git a/l10n/ui.ja.json b/l10n/ui.ja.json index 75c228ca7..edc06297f 100644 --- a/l10n/ui.ja.json +++ b/l10n/ui.ja.json @@ -2058,7 +2058,7 @@ "missing": "ソースファイルまたはリポジトリがありません。", "invalid": "ソースの形式が未対応か、データが無効です。", "limit": "ソースがレポートの読み取り上限を超えています。", - "cancelled": "ソースの読み取りがキャンセルされたか、タイムアウトしました。", + "cancelled": "ソースの読み取りがキャンセルされました。", "failed": "ソース全体を読み取れませんでした。", "refused": "ソースのパスは非公開か、許可されたフォルダーの外にあります。", "disabled": "このエージェント使用量ソースは有効になっていません。", diff --git a/l10n/ui.ko.json b/l10n/ui.ko.json index 3688912e0..8dd634f8b 100644 --- a/l10n/ui.ko.json +++ b/l10n/ui.ko.json @@ -2058,7 +2058,7 @@ "missing": "원본 파일 또는 저장소가 없습니다.", "invalid": "원본 형식이 지원되지 않거나 데이터가 잘못되었습니다.", "limit": "원본이 보고서 읽기 제한을 초과합니다.", - "cancelled": "원본 읽기가 취소되었거나 시간이 초과되었습니다.", + "cancelled": "원본 읽기가 취소되었습니다.", "failed": "원본 전체를 읽을 수 없습니다.", "refused": "원본 경로가 비공개이거나 허용된 폴더 밖에 있습니다.", "disabled": "이 에이전트 사용량 원본은 활성화되지 않았습니다.", diff --git a/l10n/ui.pl.json b/l10n/ui.pl.json index af1fb57ed..4f875ebec 100644 --- a/l10n/ui.pl.json +++ b/l10n/ui.pl.json @@ -2145,7 +2145,7 @@ "missing": "Brakuje pliku źródłowego lub repozytorium.", "invalid": "Źródło ma nieobsługiwany format lub nieprawidłowe dane.", "limit": "Źródło przekracza limit odczytu raportu.", - "cancelled": "Odczyt źródła został anulowany lub przekroczył limit czasu.", + "cancelled": "Odczyt źródła został anulowany.", "failed": "Nie udało się odczytać całego źródła.", "refused": "Ścieżka źródła jest prywatna lub znajduje się poza dozwolonym folderem.", "disabled": "To źródło użycia agenta nie jest włączone.", diff --git a/l10n/ui.pt-br.json b/l10n/ui.pt-br.json index 1733fa3af..f080f2ea2 100644 --- a/l10n/ui.pt-br.json +++ b/l10n/ui.pt-br.json @@ -2116,7 +2116,7 @@ "missing": "O arquivo de origem ou o repositório está ausente.", "invalid": "A origem tem um formato não compatível ou dados inválidos.", "limit": "A origem excede o limite de leitura do relatório.", - "cancelled": "A leitura da origem foi cancelada ou excedeu o tempo limite.", + "cancelled": "A leitura da origem foi cancelada.", "failed": "Não foi possível ler toda a origem.", "refused": "O caminho da origem é privado ou está fora da pasta permitida.", "disabled": "Esta origem de uso do agente não está ativada.", diff --git a/l10n/ui.ru.json b/l10n/ui.ru.json index 5a0fd126d..632ea3b58 100644 --- a/l10n/ui.ru.json +++ b/l10n/ui.ru.json @@ -2145,7 +2145,7 @@ "missing": "Исходный файл или репозиторий отсутствует.", "invalid": "Формат источника не поддерживается или данные неверны.", "limit": "Источник превышает предел чтения отчёта.", - "cancelled": "Чтение источника отменено или превышено время ожидания.", + "cancelled": "Чтение источника отменено.", "failed": "Не удалось полностью прочитать источник.", "refused": "Путь источника закрыт или находится вне разрешённой папки.", "disabled": "Этот источник использования агента не включён.", diff --git a/l10n/ui.tr.json b/l10n/ui.tr.json index 3fd716a5f..bb0a079f7 100644 --- a/l10n/ui.tr.json +++ b/l10n/ui.tr.json @@ -2087,7 +2087,7 @@ "missing": "Kaynak dosya veya depo eksik.", "invalid": "Kaynağın biçimi desteklenmiyor veya verileri geçersiz.", "limit": "Kaynak, rapor okuma sınırını aşıyor.", - "cancelled": "Kaynak okuma iptal edildi veya zaman aşımına uğradı.", + "cancelled": "Kaynak okuma iptal edildi.", "failed": "Kaynağın tamamı okunamadı.", "refused": "Kaynak yolu özeldir veya izin verilen klasörün dışındadır.", "disabled": "Bu aracı kullanım kaynağı etkin değil.", diff --git a/l10n/ui.zh-cn.json b/l10n/ui.zh-cn.json index 8b45982b0..9b142616d 100644 --- a/l10n/ui.zh-cn.json +++ b/l10n/ui.zh-cn.json @@ -2058,7 +2058,7 @@ "missing": "源文件或仓库不存在。", "invalid": "来源的格式不受支持或数据无效。", "limit": "来源超过报告读取上限。", - "cancelled": "来源读取已取消或超时。", + "cancelled": "来源读取已取消。", "failed": "无法完整读取来源。", "refused": "来源路径属于私密路径或位于允许的文件夹之外。", "disabled": "此代理用量来源未启用。", diff --git a/l10n/ui.zh-tw.json b/l10n/ui.zh-tw.json index e6c001ff1..13e258a1d 100644 --- a/l10n/ui.zh-tw.json +++ b/l10n/ui.zh-tw.json @@ -2058,7 +2058,7 @@ "missing": "來源檔案或儲存庫不存在。", "invalid": "來源的格式不受支援或資料無效。", "limit": "來源超過報告讀取上限。", - "cancelled": "來源讀取已取消或逾時。", + "cancelled": "來源讀取已取消。", "failed": "無法完整讀取來源。", "refused": "來源路徑屬於私密路徑或位於允許的資料夾之外。", "disabled": "此代理程式用量來源未啟用。", diff --git a/package-lock.json b/package-lock.json index d05f40189..9acf5cc75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "muse-spark-code", - "version": "0.19.1", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "muse-spark-code", - "version": "0.19.1", + "version": "0.19.0", "license": "MIT", "dependencies": { "@napi-rs/keyring": "2.1.1", diff --git a/package.json b/package.json index f7db45671..a9b63266e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "muse-spark-code", "displayName": "%displayName%", "description": "%description%", - "version": "0.19.1", + "version": "0.19.0", "publisher": "RandyNorthrup", "license": "MIT", "private": true, @@ -1899,6 +1899,7 @@ "check:wrap-up-request": "node scripts/check-wrap-up-request.mjs src/core/backends/modelapi/ModelApiHost.ts", "check:no-output-cap": "node scripts/check-no-output-continuation-cap.mjs src/core/backends/modelapi/ModelApiHost.ts", "check:no-round-cap": "node scripts/check-no-round-cap.mjs src/core/backends/modelapi/ModelApiHost.ts", + "check:workflow-scripts": "node scripts/check-workflow-scripts.mjs", "build:confine": "node scripts/build-confine.mjs", "roadmap:generate": "node scripts/gen-roadmap.mjs", "format": "prettier --write .", @@ -1927,7 +1928,7 @@ "test:integration:run": "vscode-test", "security:audit": "node scripts/audit.mjs", "security:secrets": "gitleaks git --redact --no-banner .", - "quality:gates": "run-s format:check lint typecheck check:badges check:tokens check:l10n check:reference check:host-api check:plan check:roadmap check:catches check:usable-reply check:wrap-up-request check:no-output-cap check:no-round-cap deadcode cycles duplication test:unit build security:audit", + "quality:gates": "run-s format:check lint typecheck check:badges check:tokens check:l10n check:reference check:host-api check:plan check:roadmap check:catches check:usable-reply check:wrap-up-request check:no-output-cap check:no-round-cap check:workflow-scripts deadcode cycles duplication test:unit build security:audit", "quality": "run-s quality:gates test:a11y test:legal-a11y security:secrets security:sast", "quality:ci": "run-s quality:gates test:a11y test:integration", "vscode:prepublish": "npm run build", diff --git a/scripts/catch-baseline.json b/scripts/catch-baseline.json index 67e6c0c60..0d24a7318 100644 --- a/scripts/catch-baseline.json +++ b/scripts/catch-baseline.json @@ -1500,12 +1500,12 @@ }, { "file": "src/core/media/record/windows.ts", - "identity": "ce9a9acd10e146ad8f64efc3cc5b64ae3a100e8530fd052c25de5b6afd8c83ec", + "identity": "bd5660f07d9f82351b8f864784c8ed06fcfc17c057b45b8f2b7f229b3e4cf9dc", "class": "CLEANUP" }, { "file": "src/core/media/record/windows.ts", - "identity": "93ab3a7830fccc99e38975ceae813739486d372e84f593675bb8b8ed8a167a48", + "identity": "2ccdc342da780a77e2482f465d3f69b3b8eda611618df90b83eadd6c2134353f", "class": "CLEANUP" }, { @@ -1530,7 +1530,7 @@ }, { "file": "src/core/media/record/windows.ts", - "identity": "6ad6e078ce25b11c601dcbd75d7287ec206e6cbc526c89c614db2c3d899a2213", + "identity": "84ee34f52631c09e87d0317dbd3e11dc28501a23d2c473b9b032e3829b999c5f", "class": "GENERIC" }, { @@ -1790,7 +1790,7 @@ }, { "file": "src/core/reporting/checkRuns.ts", - "identity": "65f32cc602abe71f130db7835ebb59ec39cce5533a43fbaa78bbf7fd69d9be48", + "identity": "d63887da0094e2797566c223926514e41fe088e48520c0965ac8137c9deb30d6", "class": "GENERIC" }, { @@ -1925,7 +1925,7 @@ }, { "file": "src/core/reporting/sources/cache.ts", - "identity": "e4c62d86b3771b05e0b4bef2486174f9cf66ec48252ff9013772fba134c2c408", + "identity": "effc3c984e27ff939bc1fb97b80cb34410da72357c140c0d0df9154f4fdc0a35", "class": "GENERIC" }, { @@ -1950,12 +1950,12 @@ }, { "file": "src/core/reporting/sources/github.ts", - "identity": "6869b039474e23edb66dee920ad2edcf4ece1973fde3c699a5d570e9f8388534", + "identity": "d60fe63aa77ba49eda40d895b07a976cebf6ba220fe25321a43f481b019f4075", "class": "GENERIC" }, { "file": "src/core/reporting/sources/github.ts", - "identity": "796a2b52f9de1968ca4e2048d8b6a10ce04b1500ef90187834923d61d16857a7", + "identity": "7be7f2bf9fa74152eb81bda8a257b06fe53f84c1f576398ab3807b7ecb01743b", "class": "GENERIC" }, { @@ -1975,17 +1975,17 @@ }, { "file": "src/core/reporting/sources/local.ts", - "identity": "ddcf5d7216099f4cc58fdfd9f5eb10518f3b64f67c37d21bc17ec423fa77486a", + "identity": "c99b116868cd93c12568ee1fedff4d4069383c03c1bab203685a9442ffb0aae0", "class": "SWALLOW" }, { "file": "src/core/reporting/sources/local.ts", - "identity": "a6d6fe46d244b4f0d3155ec5b6b448897c8c2b0f37ddeda56460fed0e8ae683e", + "identity": "0a8574e48de5320c3db87fd566bad925b63f478b85c4a4d8040e4567be0187cd", "class": "CLEANUP" }, { "file": "src/core/reporting/sources/local.ts", - "identity": "bf88c8e7420bf49de1b7710378e70abb4e6122380c89807610523a59635ebc58", + "identity": "2cb0453635852d448d3688f3c17f265a42f9ef188e9e4c34e97802ae6fcfdfa6", "class": "GENERIC" }, { @@ -4110,7 +4110,7 @@ }, { "file": "src/host/vault/vaultExecSpawn.ts", - "identity": "a8bb0f78fc59596cdd8b550a2dcac31ee90dde8756e2da18046e3f063d7ff1a1", + "identity": "efc7f046795198cf3a551ce86e242cfc7153b338b60535b608e0e7e3cf48551e", "class": "CLEANUP" }, { @@ -5230,7 +5230,7 @@ }, { "file": "src/runtime/reporting/selections.ts", - "identity": "042eab266aa7b5beb5f2331612ebee889cb75b1561257a9c0a2e13440b7fd4d1", + "identity": "465aa92f12812f7258d40a7bcd9e9cfaf43cf97586f8b9077ec492aac5ad3305", "class": "CLEANUP" }, { diff --git a/scripts/check-workflow-scripts.mjs b/scripts/check-workflow-scripts.mjs new file mode 100644 index 000000000..da7ff0525 --- /dev/null +++ b/scripts/check-workflow-scripts.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +// Every workflow step that runs the repository's code with `node` runs with +// only what its job installed. A job that has run no `npm ci` or +// `npm install` before the step has no node_modules, so the file it runs +// (`node `), every file inline code imports (`node -e "import ... +// from './scripts/...'"`), and every file those reach through relative +// imports may import Node's built-in modules only, and inline code may +// import no package itself. +// A 0.19.0 release tag was refused this way: the browser check's pin check gained a +// zod import, and the release's tag-checks job never installed anything +// (docs/certification/cifinal0190.md section 16). +// +// Imports are read the way Node will load them: esbuild bundles each script +// with every package left external and reports, in its metafile, each file +// reached and each import that stays outside the bundle. A step that runs +// before its job's install and imports a package fails the check, naming +// the workflow, the job, the step, the file it loads, the package and the +// file that imports it. +// +// node scripts/check-workflow-scripts.mjs + +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { builtinModules } from 'node:module' +import path from 'node:path' +import process from 'node:process' +import { build } from 'esbuild' +import { parse } from 'yaml' + +const root = path.resolve(import.meta.dirname, '..') +const WORKFLOWS = path.join(root, '.github', 'workflows') +const INSTALL = /\bnpm\s+(?:ci|install|i)\b/ +const NODE_FILE = /\bnode\s+(?:\.\/)?([\w./-]+\.[cm]?js)\b/g +// Inline code handed to node with -e, --eval, -p or --print, and the module +// specifiers it imports. +const NODE_INLINE = /\bnode\b[^\n]*?\s(?:-e|--eval|-p|--print)\s+(["'])([\s\S]*?)\1/g +const SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(?\s*)['"]([^'"]+)['"]/g +const builtins = new Set(builtinModules) + +/** Whether `specifier` names one of Node's built-in modules. */ +function isBuiltin(specifier) { + return specifier.startsWith('node:') || builtins.has(specifier.split('/', 1)[0]) +} + +/** Each package `script` imports, directly or through its relative imports, with the importing file. */ +async function packageImports(script) { + const { metafile } = await build({ + entryPoints: [script], + bundle: true, + write: false, + metafile: true, + platform: 'node', + format: 'esm', + packages: 'external', + logLevel: 'silent', + }) + const found = [] + for (const [file, input] of Object.entries(metafile.inputs)) { + for (const entry of input.imports) { + if (entry.external === true && !isBuiltin(entry.path)) { + found.push({ specifier: entry.path, file }) + } + } + } + return found +} + +/** What a `run` text has node load: repository files, and packages inline code imports. */ +function nodeEntriesOf(run, directory) { + const entries = [] + const addFile = (relative) => { + const file = path.resolve(directory, relative) + if (existsSync(file) && file.startsWith(root + path.sep)) { + entries.push({ file }) + } + } + for (const match of run.matchAll(NODE_FILE)) { + addFile(match[1]) + } + for (const inline of run.matchAll(NODE_INLINE)) { + const specifiers = inline[2].matchAll(SPECIFIER) + for (const [, specifier] of specifiers) { + if (specifier.startsWith('.')) { + addFile(specifier) + } else if (!isBuiltin(specifier)) { + entries.push({ specifier }) + } + } + } + return entries +} + +/** One job's steps in order: what each has node load, and whether the job has installed by then. */ +function scriptSteps(job, jobId) { + const found = [] + let hasInstalled = false + const jobDirectory = job?.defaults?.run?.['working-directory'] ?? '.' + const steps = job?.steps ?? [] + for (const [index, step] of steps.entries()) { + if (typeof step?.run !== 'string') { + continue + } + const directory = path.resolve(root, step['working-directory'] ?? jobDirectory) + const where = `${job?.name ?? jobId} › ${step.name ?? `step ${String(index + 1)}`}` + for (const entry of nodeEntriesOf(step.run, directory)) { + found.push({ where, entry, hasInstalled }) + } + hasInstalled ||= INSTALL.test(step.run) + } + return found +} + +const problems = [] +let stepCount = 0 +let beforeInstall = 0 +const workflowFiles = readdirSync(WORKFLOWS) + .filter((file) => /\.ya?ml$/.test(file)) + .toSorted((left, right) => left.localeCompare(right)) +for (const name of workflowFiles) { + const workflow = parse(readFileSync(path.join(WORKFLOWS, name), 'utf8')) + const jobs = Object.entries(workflow?.jobs ?? {}) + for (const [jobId, job] of jobs) { + for (const { where, entry, hasInstalled } of scriptSteps(job, jobId)) { + stepCount += 1 + if (hasInstalled) { + continue + } + beforeInstall += 1 + if (entry.specifier !== undefined) { + problems.push( + `${name} › ${where}: inline node code imports '${entry.specifier}', and the job installs nothing before this step`, + ) + continue + } + const shown = path.relative(root, entry.file).split(path.sep).join('/') + const imports = await packageImports(entry.file) + for (const { specifier, file } of imports) { + problems.push( + `${name} › ${where}: node loads ${shown}, which imports '${specifier}' (${file}), and the job installs nothing before this step`, + ) + } + } + } +} + +if (problems.length > 0) { + for (const problem of problems) { + console.error(`::error::${problem}`) + } + process.exit(1) +} +console.log( + `ok ${String(stepCount)} files node loads in workflow steps; the ${String(beforeInstall)} loaded before their job's install import Node's built-in modules only`, +) diff --git a/scripts/release-reuse.mjs b/scripts/release-reuse.mjs index 6b9a1d8db..79e2b54cc 100644 --- a/scripts/release-reuse.mjs +++ b/scripts/release-reuse.mjs @@ -33,6 +33,18 @@ const RECEIPT = z.object({ }) const MANIFEST = z.object({ name: z.string(), version: z.string() }) const ARTIFACT_NAMES = ['muse-spark-code-vsix', 'muse-spark-code-acp', 'muse-spark-code-sboms'] +// The release job that reuses verified CI artifacts, and the build jobs a +// release that built its own packages must have passed. +const REUSE_JOB = 'verified CI artifacts or full-build fallback' +const RECOVERY_BUILD_JOBS = [ + 'quality (ubuntu-latest)', + 'quality (windows-latest)', + 'quality (macos-latest)', + 'dictation helper (macos)', + 'package (.vsix)', + 'gitleaks', + 'semgrep', +] const LOOKUP_PAGES = 3 const LOOKUP_TIMEOUT_MS = 120_000 @@ -124,15 +136,17 @@ export async function findRecovery(repository, tag, runId, request) { const successful = new Set( jobs.jobs.filter((job) => job.conclusion === 'success').map((job) => job.name), ) - for (const name of [ - 'quality (ubuntu-latest)', - 'quality (windows-latest)', - 'quality (macos-latest)', - 'dictation helper (macos)', - 'package (.vsix)', - 'gitleaks', - 'semgrep', - ]) { + // A release that reused verified CI artifacts (the usual case) skipped its + // build: its reuse job passed and uploaded the packages it verified, and + // they are checked below like a build's. + const isReusedRun = + successful.has(REUSE_JOB) && + jobs.jobs.every( + (job) => + !(job.name === 'build' || job.name.startsWith('build / ')) || job.conclusion === 'skipped', + ) + const requiredBuildJobs = isReusedRun ? [] : RECOVERY_BUILD_JOBS + for (const name of requiredBuildJobs) { if (!successful.has(`build / ${name}`)) throw new Error('recovery build did not pass') } const { artifacts } = ARTIFACTS.parse( diff --git a/scripts/runtime-archive-member.mjs b/scripts/runtime-archive-member.mjs new file mode 100644 index 000000000..c12667e73 --- /dev/null +++ b/scripts/runtime-archive-member.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +// Prints one member of a packed runtime archive: `dist/runtime.bundles.json.br` +// in a VSIX, written by scripts/lib/packageArchive.mjs. In the package, +// `dist/` is only a loader that reads its code from this archive, so +// the release reads `voiceLinux.js` this way to check the helper pins each +// VSIX really carries (.github/workflows/release.yml). +// +// node scripts/runtime-archive-member.mjs + +import { readFileSync } from 'node:fs' +import process from 'node:process' +import { brotliDecompressSync } from 'node:zlib' + +const args = process.argv.slice(2) +if (args.length !== 2) { + console.error('usage: node scripts/runtime-archive-member.mjs ') + process.exit(2) +} +const [file, member] = args +const archive = JSON.parse(brotliDecompressSync(readFileSync(file)).toString('utf8')) +if (archive?.version !== 1 || typeof archive.bundles !== 'object' || archive.bundles === null) { + console.error(`${file} is not a version 1 runtime archive`) + process.exit(1) +} +const source = Object.hasOwn(archive.bundles, member) ? archive.bundles[member] : undefined +if (typeof source !== 'string') { + console.error(`${file} has no ${member}`) + process.exit(1) +} +process.stdout.write(source) diff --git a/src/core/backends/modelapi/ModelApiHost.ts b/src/core/backends/modelapi/ModelApiHost.ts index 1b403ec1d..9472ca30d 100644 --- a/src/core/backends/modelapi/ModelApiHost.ts +++ b/src/core/backends/modelapi/ModelApiHost.ts @@ -11595,7 +11595,7 @@ export class ModelApiSession implements ScheduledAgentSession { // HEAD capture can wait: confinement must be checked after it settles. if (journal !== undefined && request.checkName !== undefined) { try { - commit = await journal.commit() + commit = await journal.commit(signal) } catch { this.deps.log.warn('Check-run journal commit unavailable') } diff --git a/src/core/backends/modelapi/verifyLoop.ts b/src/core/backends/modelapi/verifyLoop.ts index dda1dbd77..a068e6c62 100644 --- a/src/core/backends/modelapi/verifyLoop.ts +++ b/src/core/backends/modelapi/verifyLoop.ts @@ -24,7 +24,7 @@ import { shellOutcome, type ShellResult } from './tools' export interface VerifyHooks { /** M113-H: the shared journal adapter captures HEAD before execution, never shell text. */ readonly checkRuns?: { - readonly commit: () => Promise + readonly commit: (signal: AbortSignal) => Promise readonly append: (record: CheckRunRecord) => Promise } /** `museSpark.diagnosticsAfterEdits`. */ diff --git a/src/core/bootstrapCommand.ts b/src/core/bootstrapCommand.ts index 912fe4fb2..2917ed468 100644 --- a/src/core/bootstrapCommand.ts +++ b/src/core/bootstrapCommand.ts @@ -1,16 +1,19 @@ import { noWorkspaceChildEnvironment } from './execGuard' import { spawnResourceProcess } from './resources/launcher' import { realpathSync } from 'node:fs' -import { CLI_OUTPUT_MAX_BYTES, PROCESS_TABLE_TIMEOUT_MS } from '../shared/constants' +import { CLI_OUTPUT_MAX_BYTES } from '../shared/constants' +import { callerStop } from './timeouts' // A client of the lazy facade, like any other governed command: the launcher // and its policy stay in the first-use resourceGovernor bundle, so this runner // may ride in every bundle that builds a helper. /** Bootstrap tier: the compiler builds the containment helper it cannot yet use. -A failure always names its exit code, signal and whether it was stopped; -`isOutputReported` adds the bounded stdout and stderr (csc writes its -diagnostics to stdout), for callers whose output is public, like compilers. +It runs until it ends, or until the caller's own `signal` or `timeoutMs` +stops it; nothing of ours does. A failure always names its exit code, +signal and whether it was stopped; `isOutputReported` adds the bounded +stdout and stderr (csc writes its diagnostics to stdout), for callers whose +output is public, like compilers. */ export async function runBootstrap( file: string, @@ -24,15 +27,15 @@ export async function runBootstrap( isOutputReported?: boolean } = {}, ): Promise { - const deadline = AbortSignal.timeout(options.timeoutMs ?? PROCESS_TABLE_TIMEOUT_MS) - const signal = - options.signal === undefined ? deadline : AbortSignal.any([options.signal, deadline]) + // Only the caller's own cancel or deadline stops the build: a cold + // Windows PowerShell or compiler start can take longer than any bound of ours. + const signal = callerStop(options.signal, options.timeoutMs) // A build-time helper builds with the host cwd, never a workspace cwd: // absolute entries only, links followed. const { child: root, stop: stopTree } = await spawnResourceProcess('bootstrap', file, args, { env: noWorkspaceChildEnvironment(process.platform, env, realpathSync), cwd: options.cwd, - signal, + ...(signal !== undefined && { signal }), }) return await new Promise((resolve, reject) => { const output: Buffer[] = [] @@ -46,8 +49,8 @@ export async function runBootstrap( stopping ??= stopTree() void stopping.catch(reject) } - signal.addEventListener('abort', stop, { once: true }) - if (signal.aborted) stop() + signal?.addEventListener('abort', stop, { once: true }) + if (signal?.aborted === true) stop() const read = (bytes: Buffer, kept: Buffer[] | undefined) => { size += bytes.length if (size > CLI_OUTPUT_MAX_BYTES) { @@ -64,7 +67,7 @@ export async function runBootstrap( root.stdin.on('error', stop) root.once('error', stop) root.once('close', (code, exitSignal) => { - signal.removeEventListener('abort', stop) + signal?.removeEventListener('abort', stop) void (async () => { await stopping if (isFailed || code !== 0) { diff --git a/src/core/media/record/windows.ts b/src/core/media/record/windows.ts index 0db805cd4..9646c7dac 100644 --- a/src/core/media/record/windows.ts +++ b/src/core/media/record/windows.ts @@ -6,7 +6,6 @@ import { MEDIA_MAX_UPLOAD_MIB, MEDIA_FILE_ID_MIN_BYTES, MILLISECONDS_PER_SECOND, - PROCESS_TABLE_TIMEOUT_MS, SCREEN_RECORDING_REMOVE_ATTEMPTS, SCREEN_RECORDING_REMOVE_RETRY_MS, } from '../../../shared/constants' @@ -198,34 +197,44 @@ export function windowsScreenRecorder(deps: WindowsRecorderDeps): WindowsScreenR const child = deps.launch(helper, args) let reason = unavailable() let countdown: ReturnType | undefined - let deadline: ReturnType | undefined let remaining = options?.maxSeconds ?? 0 const exited = new Promise((resolve) => { child.onExit((code) => { state.hasExited = true clearInterval(countdown) - clearTimeout(deadline) resolve(code) }) }) + // Ends the helper now, cancelled; its exit port then settles the run. + // Only for a helper that can no longer be asked, or one the person + // cancels while it is still finishing: never on a clock of ours. + const kill = () => { + state.isCancelled = true + void child.kill().catch(() => { + /* The exit port still settles the run. */ + }) + } + // Asks the helper once, then waits for its own exit: finishing or + // cancelling a recording takes as long as it takes (D121, no deadline). const end = (shouldCancel: boolean) => { state.isCancelled ||= shouldCancel if (state.hasRequestedStop || state.hasExited) return state.hasRequestedStop = true - deadline = setTimeout(() => { - state.isCancelled = true - void child.kill().catch(() => { - /* The exit port still settles the run. */ - }) - }, PROCESS_TABLE_TIMEOUT_MS) try { child.send(shouldCancel ? 'cancel' : 'stop') } catch { - // A dead helper channel counts as cancelled; the kill deadline - // below still ends the helper and settles the run. - state.isCancelled = true + // A dead helper channel cannot take the request, so nothing would + // ever answer it: the helper is ended now and the run settles. + kill() } } + // The person's Cancel or the window closing. A helper already asked + // to stop that is still running is ended at once: the person's own + // cancel stops the work; no clock of ours does. + const cancelByPerson = () => { + if (state.hasRequestedStop && !state.hasExited) kill() + else end(true) + } child.onLine((line) => { let value: unknown try { @@ -305,9 +314,7 @@ export function windowsScreenRecorder(deps: WindowsRecorderDeps): WindowsScreenR if (remaining === 0) end(false) }, MILLISECONDS_PER_SECOND) }) - cancelChild = () => { - end(true) - } + cancelChild = cancelByPerson if (isCancelled()) end(true) let cleanup: Promise | undefined // Share in-flight/successful cleanup, but let a later discard retry a @@ -348,7 +355,6 @@ export function windowsScreenRecorder(deps: WindowsRecorderDeps): WindowsScreenR return outcome } finally { clearInterval(countdown) - clearTimeout(deadline) unsubscribeShutdown() active = undefined } @@ -360,7 +366,7 @@ export function windowsScreenRecorder(deps: WindowsRecorderDeps): WindowsScreenR await result }, cancel: async () => { - end(true) + cancelByPerson() const outcome = await result if (outcome.ok) await outcome.preview.dispose() }, diff --git a/src/core/reporting/checkRuns.ts b/src/core/reporting/checkRuns.ts index 7037bec44..785a7f051 100644 --- a/src/core/reporting/checkRuns.ts +++ b/src/core/reporting/checkRuns.ts @@ -3,7 +3,7 @@ import { CHECK_NAME_MAX_CHARS, REPORT_CHECK_RUNS_MAX, REPORT_MAX_ID_CHARS, - REPORT_SOURCE_TIMEOUT_MS, + REPORT_SOURCE_FRESH_MS, } from '../../shared/constants' import { reportsMethods } from '../../shared/hostApi/reports' import { type ReportStorage } from './history' @@ -110,7 +110,7 @@ export class CheckRunJournal { ? { status: 'ok' as const, reason: null } : { status: 'partial' as const, reason: reportText.reportUi.generationFailed }), observedAt, - freshness: { state: ageMs > REPORT_SOURCE_TIMEOUT_MS ? 'stale' : 'fresh', ageMs }, + freshness: { state: ageMs > REPORT_SOURCE_FRESH_MS ? 'stale' : 'fresh', ageMs }, }, data: parsed.entries, } diff --git a/src/core/reporting/sources/admission.ts b/src/core/reporting/sources/admission.ts index ca5063962..054dda046 100644 --- a/src/core/reporting/sources/admission.ts +++ b/src/core/reporting/sources/admission.ts @@ -11,8 +11,7 @@ export interface ReportAdmissionState { } export type ReportAdmissionEvent = | { - readonly type: - 'requested' | 'transportFailed' | 'effectFailed' | 'aborted' | 'timedOut' | 'released' + readonly type: 'requested' | 'transportFailed' | 'effectFailed' | 'aborted' | 'released' readonly generation: G } | { @@ -59,11 +58,8 @@ export function reportAdmissionStep( if (current?.generation !== generation) { if (event.type === 'transportReturned') effects.push({ type: 'cancelBody', generation, response: event.response }) - if ( - (event.type === 'aborted' || event.type === 'timedOut') && - state.waiting.includes(generation) - ) { - effects.push({ type: 'refuse', generation, reason: 'source-deadline' }) + if (event.type === 'aborted' && state.waiting.includes(generation)) { + effects.push({ type: 'refuse', generation, reason: 'source-cancelled' }) return { state: { ...state, waiting: state.waiting.filter((item) => item !== generation) }, effects, @@ -123,15 +119,10 @@ export function reportAdmissionStep( } } case 'aborted': - case 'timedOut': case 'transportFailed': case 'effectFailed': { if (current.phase !== 'releasing') - return retire( - event.type === 'transportFailed' || event.type === 'effectFailed' - ? 'source-failed' - : 'source-deadline', - ) + return retire(event.type === 'aborted' ? 'source-cancelled' : 'source-failed') // A failed refusal still needs a safe rejection; its pending release // effect continues in the shell without retiring the owner twice. if (event.type === 'effectFailed') diff --git a/src/core/reporting/sources/cache.ts b/src/core/reporting/sources/cache.ts index 18b69f9ec..c047171f8 100644 --- a/src/core/reporting/sources/cache.ts +++ b/src/core/reporting/sources/cache.ts @@ -5,7 +5,6 @@ import { GITHUB_MEDIA_TYPE, MILLISECONDS_PER_SECOND, REPORT_GITHUB_RATE_FLOOR, - REPORT_SOURCE_TIMEOUT_MS, } from '../../../shared/constants' import { fill } from '../../../shared/l10n/text' import type { ReportSourceRecord } from '../../../shared/reportSchema' @@ -137,8 +136,6 @@ function scrubStructured(value: unknown, scrub: (text: string) => string): unkno } const HTTP_NOT_MODIFIED = 304 -const HTTP_FORBIDDEN = 403 -const HTTP_TOO_MANY_REQUESTS = 429 const PUBLIC_HOSTS = new Set([ 'api.github.com', 'marketplace.visualstudio.com', @@ -288,12 +285,12 @@ export class ReportNetworkReader { } } - /** Bound admission and transport by the source signal, even if a port ignores it. */ + /** End admission and transport on the caller's signal, even if a port ignores it. */ private async untilAborted(work: Promise, signal: AbortSignal): Promise { let abortListener: (() => void) | undefined const aborted = new Promise((_resolve, reject) => { abortListener = () => { - reject(new ReportNetworkFailure('source-deadline')) + reject(new ReportNetworkFailure('source-cancelled')) } signal.addEventListener('abort', abortListener, { once: true }) if (signal.aborted) abortListener() @@ -316,10 +313,7 @@ export class ReportNetworkReader { signal.throwIfAborted() return await new Promise((resolve, reject) => { const abort = (): void => { - this.step(origin, { - type: signal.reason === 'source-timeout' ? 'timedOut' : 'aborted', - generation, - }) + this.step(origin, { type: 'aborted', generation }) } const generation: NetworkGeneration = { request, @@ -484,17 +478,11 @@ export class ReportNetworkReader { Number.isFinite(until) ? until : now, Number(reset) * MILLISECONDS_PER_SECOND, ) - if ( - isAtFloor || - Number.isFinite(until) || - response.status === HTTP_TOO_MANY_REQUESTS || - (retry !== null && response.status === HTTP_FORBIDDEN) - ) { - // Missing/malformed reset cannot authorize another dispatch immediately. - until = until > now && Number.isFinite(until) ? until : now + REPORT_SOURCE_TIMEOUT_MS - return until - } - return null + // Only the server's own instant holds the origin: its Retry-After, or + // the reset GitHub names at the floor. A limited answer that names no + // future instant invents no hold of ours (D121/D208); the next read + // asks the server again, and its answer says whether it is still limited. + return Number.isFinite(until) && until > now ? until : null } private async body(response: Response, signal: AbortSignal): Promise { @@ -535,7 +523,11 @@ export class ReportNetworkReader { return isReportNetworkAllowed(this.deps.policy, context, hostname) } - /** One deadline covers all pages, parsing, egress admission and storage. */ + /** + One caller's signal (the person's cancel) covers all pages, parsing, + egress admission and storage. No deadline of ours abandons the read + (D121/D208): it runs until it settles or that signal aborts. + */ public async read( context: SourceReadContext, id: string, @@ -585,9 +577,6 @@ export class ReportNetworkReader { data: schema.parse(scrubStructured(result.data, this.deps.scrub)), } } - const timer = setTimeout(() => { - controller.abort('source-timeout') - }, REPORT_SOURCE_TIMEOUT_MS) try { return await this.untilAborted(work(), signal) } catch (error: unknown) { @@ -597,7 +586,7 @@ export class ReportNetworkReader { `${context.reportText.reportUi.generationFailed} (${this.deps.scrub(detail)})`, ) } finally { - clearTimeout(timer) + // Pages still in flight after the read settled stop with it. controller.abort() } } diff --git a/src/core/reporting/sources/github.ts b/src/core/reporting/sources/github.ts index 12d053d75..bb243abef 100644 --- a/src/core/reporting/sources/github.ts +++ b/src/core/reporting/sources/github.ts @@ -5,7 +5,6 @@ import { GITHUB_CHECKS_PAGE_SIZE, GITHUB_MEDIA_TYPE, REPORT_MAX_ROWS, - REPORT_SOURCE_TIMEOUT_MS, } from '../../../shared/constants' import { reportCiRunSchema, @@ -465,7 +464,9 @@ export function ghReportTransport(options: ReportGhOptions): ReportNetworkTransp encoding: 'utf8', env: withoutCredentials(options.environment), signal, - timeout: REPORT_SOURCE_TIMEOUT_MS, + // No deadline of ours (D121/D208): gh's own exit or the caller's + // signal ends the request. + timeout: Infinity, maxBuffer: options.maxBytes, windowsHide: true, workspaceRoot: options.workspaceRoot, @@ -604,16 +605,16 @@ export async function postReport( const signal = AbortSignal.any([context.signal, controller.signal]) const dispatch = { hasStarted: false } let listener: (() => void) | undefined + // Only the caller's signal (the person's cancel) ends the post: no + // deadline of ours abandons it (D121/D208). A post already dispatched + // when it fires stays uncertain, never retried. const aborted = new Promise((_resolve, reject) => { listener = () => { - reject(new ReportNetworkFailure('post-deadline')) + reject(new ReportNetworkFailure('post-cancelled')) } signal.addEventListener('abort', listener, { once: true }) if (signal.aborted) listener() }) - const timer = setTimeout(() => { - controller.abort() - }, REPORT_SOURCE_TIMEOUT_MS) const work = async (): Promise => { if (!(await deps.policy.allowEgress(url, signal))) return { status: 'refused', reason: context.reportText.reportUi.networkOff } @@ -655,7 +656,6 @@ export async function postReport( reason: context.reportText.reportUi.generationFailed, } } finally { - clearTimeout(timer) if (listener !== undefined) signal.removeEventListener('abort', listener) controller.abort() } diff --git a/src/core/reporting/sources/local.ts b/src/core/reporting/sources/local.ts index 7191306c6..de94b1968 100644 --- a/src/core/reporting/sources/local.ts +++ b/src/core/reporting/sources/local.ts @@ -4,7 +4,7 @@ import { ACP_WORKSPACE_HASH_CHARS, REPORT_SOURCE_SETTLE_MS, REPORT_MAX_TEXT_CHARS, - REPORT_SOURCE_TIMEOUT_MS, + REPORT_SOURCE_FRESH_MS, } from '../../../shared/constants' import { pathModule } from '../../workspaceRoot' import { reportSourceSchema } from '../../../shared/reportSchema' @@ -185,13 +185,83 @@ export function sourceFreshness( if (observedAt === null) return { state: 'unknown', ageMs: null } const ageMs = Date.parse(asOf) - Date.parse(observedAt) if (!Number.isFinite(ageMs) || ageMs < 0) return { state: 'unknown', ageMs: null } - return { state: ageMs <= REPORT_SOURCE_TIMEOUT_MS ? 'fresh' : 'stale', ageMs } + return { state: ageMs <= REPORT_SOURCE_FRESH_MS ? 'fresh' : 'stale', ageMs } } export interface LocalObservation { readonly data: T readonly observedAt?: string readonly partial?: string } + +/** +A read raced against the deadline its caller handed in. The race is against +that deadline only, never the abort: a cancelled cooperative read still +settles through its own shutdown instead of being abandoned mid-flight. +When the deadline fires the abort is already on the read's signal, so its +settlement is awaited, bounded by the settle grace, before the cancellation +returns (RVREPORTJOIN P2-3). +*/ +async function readBeforeDeadline(pending: Promise, timeout: AbortSignal): Promise { + // A settlement that lands after the bounded wait below has moved on + // must never surface as an unhandled rejection. + void pending.then(ignoreSettlement).catch(ignoreSettlement) + let stopTimeout: (() => void) | undefined + let stopGrace: (() => void) | undefined + try { + const expired = new Promise((_resolve, reject) => { + const fire = () => { + reject(new LocalSourceError('cancelled')) + } + if (timeout.aborted) fire() + else { + timeout.addEventListener('abort', fire, { once: true }) + stopTimeout = () => { + timeout.removeEventListener('abort', fire) + } + } + }) + try { + return await Promise.race([pending, expired]) + } catch (error) { + if (!timeout.aborted) throw error + // The handed deadline fired while the read was in flight: the + // abort is already on the read's signal, so a cooperative reader + // settles promptly. Wait for that settlement, bounded by the + // settle grace, instead of returning while it still runs. + const grace = AbortSignal.timeout(REPORT_SOURCE_SETTLE_MS) + const settled = new Promise((resolve, reject) => { + const fire = (): void => { + reject(new LocalSourceError('cancelled')) + } + if (grace.aborted) fire() + else { + grace.addEventListener('abort', fire, { once: true }) + stopGrace = () => { + grace.removeEventListener('abort', fire) + } + } + void pending + .then(function readSettled(): void { + resolve() + }) + .catch(function readFailed(): void { + resolve() + }) + }) + try { + await settled + } catch { + // The grace expired while a wedged reader still runs: the outcome + // stays cancellation, and the late settlement stays silent above. + } + throw error + } + } finally { + stopTimeout?.() + stopGrace?.() + } +} + export function localSource( kind: K, read: (context: SourceReadContext) => Promise>, @@ -200,84 +270,35 @@ export function localSource( kind, id: kind, async read(context): Promise> { - // The engine hands every source its abort signal and its deadline; the - // timeout enforces the handed deadline, defaulting to the source - // timeout for older callers. The read's own settlement is awaited, so - // an abort settles the read instead of abandoning it mid-flight: after - // an abort the signal stays aborted and no further read, write or - // publish the source starts can succeed. + // The engine hands every source the caller's abort signal (the + // person's cancel). No deadline of ours ends a read (D121/D208): it + // runs until it settles or that signal aborts, and a deadline binds + // only when the caller hands one in. The read's own settlement is + // awaited, so an abort settles the read instead of abandoning it + // mid-flight: after an abort the signal stays aborted and no further + // read, write or publish the source starts can succeed. const startedAt = Date.now() - const deadlineMs = context.deadline ?? startedAt + REPORT_SOURCE_TIMEOUT_MS + const deadlineMs = + context.deadline !== undefined && Number.isFinite(context.deadline) + ? context.deadline + : undefined let signal: AbortSignal = context.signal - let stopTimeout: (() => void) | undefined - let stopGrace: (() => void) | undefined try { // An already-expired deadline starts nothing: this check is // synchronous, never reliant on a zero-millisecond timeout aborting // in time (RVREPORTJOIN P2-3). context.signal.throwIfAborted() - if (deadlineMs <= startedAt) throw new LocalSourceError('cancelled') - const timeout = AbortSignal.timeout(Math.max(0, deadlineMs - Date.now())) - signal = AbortSignal.any([context.signal, timeout]) + if (deadlineMs !== undefined && deadlineMs <= startedAt) + throw new LocalSourceError('cancelled') + const timeout = + deadlineMs === undefined + ? undefined + : AbortSignal.timeout(Math.max(0, deadlineMs - Date.now())) + if (timeout !== undefined) signal = AbortSignal.any([context.signal, timeout]) signal.throwIfAborted() const pending = read({ ...context, signal }) - // A settlement that lands after the bounded wait below has moved on - // must never surface as an unhandled rejection. - void pending.then(ignoreSettlement).catch(ignoreSettlement) - // The race is against the handed deadline only, never the abort: a - // cancelled cooperative read still settles through its own shutdown - // instead of being abandoned mid-flight, while a reader that ignores - // the signal cannot outlive the per-source timeout. - const expired = new Promise((_resolve, reject) => { - const fire = () => { - reject(new LocalSourceError('cancelled')) - } - if (timeout.aborted) fire() - else { - timeout.addEventListener('abort', fire, { once: true }) - stopTimeout = () => { - timeout.removeEventListener('abort', fire) - } - } - }) - let result: LocalObservation - try { - result = await Promise.race([pending, expired]) - } catch (error) { - if (!timeout.aborted) throw error - // The handed deadline fired while the read was in flight: the - // abort is already on the read's signal, so a cooperative reader - // settles promptly. Wait for that settlement, bounded by the - // settle grace, instead of returning while it still runs - // (RVREPORTJOIN P2-3). - const grace = AbortSignal.timeout(REPORT_SOURCE_SETTLE_MS) - const settled = new Promise((resolve, reject) => { - const fire = (): void => { - reject(new LocalSourceError('cancelled')) - } - if (grace.aborted) fire() - else { - grace.addEventListener('abort', fire, { once: true }) - stopGrace = () => { - grace.removeEventListener('abort', fire) - } - } - void pending - .then(function readSettled(): void { - resolve() - }) - .catch(function readFailed(): void { - resolve() - }) - }) - try { - await settled - } catch { - // The grace expired while a wedged reader still runs: the outcome - // stays cancellation, and the late settlement stays silent above. - } - throw error - } + const result = + timeout === undefined ? await pending : await readBeforeDeadline(pending, timeout) signal.throwIfAborted() const observedAt = result.observedAt ?? context.asOf const freshness = sourceFreshness(context.asOf, observedAt) @@ -316,9 +337,6 @@ export function localSource( }, data: null, } - } finally { - stopTimeout?.() - stopGrace?.() } }, } diff --git a/src/core/reporting/sources/types.ts b/src/core/reporting/sources/types.ts index 4b51dc904..88c7b0300 100644 --- a/src/core/reporting/sources/types.ts +++ b/src/core/reporting/sources/types.ts @@ -322,10 +322,10 @@ export interface SourceReadContext { readonly signal: AbortSignal /** - Last instant (epoch milliseconds) a source may start new work. The - engine sets it from the source timeout; a wait that outlasts it with a - live holder retires as busy, and no new read, write or publish starts - past it. Absent in older fakes, which keep the source timeout. + Last instant (epoch milliseconds) a source may start new work, only when + the caller hands one in: the read's signal aborts at it and no new read, + write or publish starts past it. There is no default of ours (D121/D208): + absent, a read runs until it settles or `signal` aborts. */ readonly deadline?: number } diff --git a/src/core/resources/commands.ts b/src/core/resources/commands.ts index 636af041d..746106cd8 100644 --- a/src/core/resources/commands.ts +++ b/src/core/resources/commands.ts @@ -15,7 +15,8 @@ import { } from '../execGuard' import { environmentValue } from '../execGuard' import { existsSync, realpathSync } from 'node:fs' -import { CLI_OUTPUT_MAX_BYTES, PROCESS_TABLE_TIMEOUT_MS } from '../../shared/constants' +import { CLI_OUTPUT_MAX_BYTES } from '../../shared/constants' +import { callerStop } from '../timeouts' import { spawnResourceProcess } from './process' /** @@ -175,13 +176,14 @@ export async function execResourceFile( : { workspaceRoot: options.workspaceRoot } const file = resolveCommand(command, env, workspace) const childEnv = childEnvironment(env, workspace) - const deadline = AbortSignal.timeout(options.timeout ?? PROCESS_TABLE_TIMEOUT_MS) - const signal = - options.signal === undefined ? deadline : AbortSignal.any([options.signal, deadline]) + // Only the caller's own cancel or `timeout` stops the command, as with + // execFile: no deadline of ours (a cold Windows PowerShell can take longer). + // An absent, 0 or Infinity timeout names none (as `src/host/git.ts` reads it). + const signal = callerStop(options.signal, options.timeout) const launched = await spawnResourceProcess(profile, file, args, { env: childEnv, ...(options.cwd !== undefined && { cwd: options.cwd }), - signal, + ...(signal !== undefined && { signal }), }) const { child, stop } = launched child.stdin.end() @@ -201,9 +203,9 @@ export async function execResourceFile( reject(new Error('Governed command tree stop failed', { cause: error })) }) } - // One named deadline covers admission and the run (the launch also stops on it). - signal.addEventListener('abort', abort, { once: true }) - if (signal.aborted) abort() + // The caller's stop covers admission and the run (the launch also stops on it). + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted === true) abort() child.stdout.on('data', (bytes: Buffer) => { bytesRead += bytes.length if (bytesRead > max) abort() @@ -218,7 +220,7 @@ export async function execResourceFile( abort() }) child.once('close', (code) => { - signal.removeEventListener('abort', abort) + signal?.removeEventListener('abort', abort) void (async () => { await stopping const outcome = await launched.outcome() diff --git a/src/core/resources/process.ts b/src/core/resources/process.ts index 26afecdf2..7680611f7 100644 --- a/src/core/resources/process.ts +++ b/src/core/resources/process.ts @@ -17,7 +17,6 @@ import type { ResourceProcessIdentity } from '../../shared/resources' import { CLI_OUTPUT_MAX_BYTES, MILLISECONDS_PER_SECOND, - PROCESS_TABLE_TIMEOUT_MS, RESOURCE_HANDOFF_TIMEOUT_MS, RESOURCE_JOB_EMPTY_MS, RESOURCE_JOB_SAMPLE_MS, @@ -146,15 +145,15 @@ async function stopBootstrap(root: ChildProcess): Promise { if (systemRoot === undefined) throw new Error('Bootstrap tree termination unavailable') await new Promise((resolve, reject) => { // OS termination infrastructure must remain usable while admission is paused. - // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- Fixed SystemRoot taskkill, numeric owned root PID, bounded output/deadline, credential-free environment; emergency termination cannot queue behind pause (PLAN SPAWN017B/SPAWN017C, section 8). + // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- Fixed SystemRoot taskkill, numeric owned root PID, bounded output, credential-free environment; emergency termination cannot queue behind pause (PLAN SPAWN017B/SPAWN017C, section 8). execFileGuarded( path.win32.join(systemRoot, WINDOWS_TASKKILL_RELATIVE_PATH), ['/PID', String(pid), '/T', '/F'], { // Fixed `taskkill` under SystemRoot: absolute entries only, links followed. + // No deadline of ours: the stop the caller asked for runs to taskkill's end. env: noWorkspaceChildEnvironment('win32', { SystemRoot: systemRoot }, realpathSync), windowsHide: true, - timeout: PROCESS_TABLE_TIMEOUT_MS, maxBuffer: CLI_OUTPUT_MAX_BYTES, }, (error) => { diff --git a/src/core/resources/trees/run.ts b/src/core/resources/trees/run.ts index b15103acb..fe3221030 100644 --- a/src/core/resources/trees/run.ts +++ b/src/core/resources/trees/run.ts @@ -1,5 +1,4 @@ import { type GuardedChildEnv } from '../../executables' -import { PROCESS_TABLE_TIMEOUT_MS } from '../../../shared/constants' import { execFileGuarded } from '../../guardedChildProcess' export type ResourceTreeRun = ( @@ -21,8 +20,11 @@ export function coalescedTreeRead(read: () => Promise): () => Promise { } } -/** A probe runner with its own time bound and, when given, an output bound. */ -export function boundedTreeProgram(timeoutMs: number, maxBuffer?: number): ResourceTreeRun { +/** +A probe runner with, when given, its caller's own time bound and output +bound; with no time bound the probe runs until it exits. +*/ +export function boundedTreeProgram(timeoutMs?: number, maxBuffer?: number): ResourceTreeRun { return (file, args, env) => new Promise((resolve, reject) => { execFileGuarded( @@ -31,7 +33,7 @@ export function boundedTreeProgram(timeoutMs: number, maxBuffer?: number): Resou { env, windowsHide: true, - timeout: timeoutMs, + ...(timeoutMs !== undefined && { timeout: timeoutMs }), ...(maxBuffer !== undefined && { maxBuffer }), }, (error, stdout) => { @@ -42,5 +44,9 @@ export function boundedTreeProgram(timeoutMs: number, maxBuffer?: number): Resou }) } -/** Probes inherit no credential variables and run only absolute OS/helper paths. */ -export const runTreeProgram: ResourceTreeRun = boundedTreeProgram(PROCESS_TABLE_TIMEOUT_MS) +/** +Probes inherit no credential variables and run only absolute OS/helper +paths. No deadline of ours: a cold Windows PowerShell start can take +longer than any bound, and killing it failed the work it served. +*/ +export const runTreeProgram: ResourceTreeRun = boundedTreeProgram() diff --git a/src/core/timeouts.ts b/src/core/timeouts.ts index 9f058aeda..97a2ffe0e 100644 --- a/src/core/timeouts.ts +++ b/src/core/timeouts.ts @@ -38,6 +38,25 @@ export function setLongTimeout(callback: () => void, delayMs: number): () => voi } } +/** +The stop a caller asked for, and nothing of ours (owner 2026-10-16: no +limit in the codebase that is not about a model): the caller's own cancel, +its own deadline (a positive, finite number of milliseconds, as `execFile` +reads one; 0 and Infinity name none, as `src/host/git.ts` reads them), both, +or neither, in which case the work runs to its own end. +*/ +export function callerStop( + signal: AbortSignal | undefined, + timeoutMs: number | undefined, +): AbortSignal | undefined { + const hasDeadline = timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0 + const stops = [ + ...(signal === undefined ? [] : [signal]), + ...(hasDeadline ? [AbortSignal.timeout(timeoutMs)] : []), + ] + return stops.length === 0 ? undefined : AbortSignal.any(stops) +} + /** `promise`, or a `DeadlineError` with `message` once `timeoutMs` has passed. The timer is always cleared; the promise itself keeps running (the caller diff --git a/src/host/conversation/conversationController.ts b/src/host/conversation/conversationController.ts index 1b4b5da5d..c297684a9 100644 --- a/src/host/conversation/conversationController.ts +++ b/src/host/conversation/conversationController.ts @@ -1971,7 +1971,7 @@ export class ConversationController { `text`. It is said at once unless the panel has moved on to another conversation since: a backend restart or exit meanwhile, or the opening having already let go of the conversation it was replacing, does not - hush it (0.19.1: the failure appeared only on the next History open, and + hush it (0.19.0: the failure appeared only on the next History open, and the message typed meanwhile was never sent). Left with neither a session nor one to resume, the panel keeps the words for every later message, and the notice offers a new conversation as the way on (D26). diff --git a/src/host/processTree.ts b/src/host/processTree.ts index 8a13440bf..839205fcd 100644 --- a/src/host/processTree.ts +++ b/src/host/processTree.ts @@ -38,7 +38,6 @@ import { ORPHAN_SWEEP_ROUNDS, POSIX_TREE_EXIT_POLL_MS, POSIX_TREE_EXIT_WAIT_MS, - PROCESS_TABLE_TIMEOUT_MS, SHELL_JOB_TYPE_NAME, TREE_EXIT_WAIT_MS, WINDOWS_POWERSHELL_COMMAND_ARGS, @@ -55,7 +54,10 @@ const KILLED_EXIT_CODE = 1 Runs a program to its end: its stdout, or a rejection on a failure exit. The environment is always a guarded child environment (only `workspaceChildEnvironment` / `noWorkspaceChildEnvironment` can produce -one), so a raw `process.env` does not type-check here. +one), so a raw `process.env` does not type-check here. `timeoutMs` is the +caller's own deadline; without one the program runs until it exits (no +deadline of ours: Windows PowerShell's first start on a slow machine can +take longer than any bound, and killing it failed the work it served). */ export type RunProgram = ( file: string, @@ -96,12 +98,16 @@ export function treeSpawnOptions(platform: NodeJS.Platform): { readonly detached return { detached: platform !== 'win32' } } -export const runProgram: RunProgram = (file, args, env, timeoutMs = PROCESS_TABLE_TIMEOUT_MS) => +export const runProgram: RunProgram = (file, args, env, timeoutMs) => new Promise((resolve, reject) => { execFile( file, [...args], - { windowsHide: true, timeout: timeoutMs, env: withoutCredentials(env) }, + { + windowsHide: true, + ...(timeoutMs !== undefined && { timeout: timeoutMs }), + env: withoutCredentials(env), + }, (error, stdout) => { if (error === null) { resolve(stdout) @@ -453,7 +459,7 @@ let deadline; const counts = { stdout: 0, stderr: 0 }; function table() { return execFileSync('/bin/ps', ['-axo', 'pid=,ppid=,lstart='], { - encoding: 'utf8', timeout: spec.cleanupMs, maxBuffer: spec.maxBuffer, + encoding: 'utf8', maxBuffer: spec.maxBuffer, env: { ...process.env, LC_ALL: 'C' } }).split('\n').flatMap(line => { const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line); @@ -498,7 +504,7 @@ function signal(row, name) { let live; try { const text = execFileSync('/bin/ps', ['-o', 'lstart=', '-p', String(row.pid)], { - encoding: 'utf8', timeout: spec.cleanupMs, env: { ...process.env, LC_ALL: 'C' } + encoding: 'utf8', env: { ...process.env, LC_ALL: 'C' } }).trim(); if (text) live = { ...row, start: text }; } catch (error) { @@ -547,7 +553,9 @@ poll = setInterval(() => { try { capture(); } catch (error) { failed = true; process.stderr.write(error.message); endTree(); } }, spec.pollMs); -deadline = setTimeout(() => { +// Only the caller's own deadline is armed: setTimeout with no delay would +// fire at once, and an absent timeout means the effect runs to its end. +if (Number.isFinite(spec.timeout) && spec.timeout > 0) deadline = setTimeout(() => { failed = true; process.stderr.write('ETIMEDOUT'); endTree(); @@ -558,7 +566,9 @@ deadline = setTimeout(() => { POSIX synchronous caller, asynchronous tree supervision. The supplied environment is always the guarded child environment for the effect's working directory (only `workspaceChildEnvironment` can produce one), and -`cwd` is required; no shell parses the effect. +`cwd` is required; no shell parses the effect. Only the caller's own +`timeout` (positive, as spawnSync reads one) ends the effect early; without +one it runs to its end. */ export function runTreeSync( command: string, @@ -568,9 +578,11 @@ export function runTreeSync( readonly cwd: string }, ): SpawnSyncReturns { + const { timeout: callerTimeout, ...spawnOptions } = options + const timeout = callerTimeout !== undefined && callerTimeout > 0 ? callerTimeout : undefined const maxBuffer = options.maxBuffer ?? GIT_OUTPUT_MAX_BYTES return spawnSync(process.execPath, ['-e', POSIX_TREE_SUPERVISOR], { - ...options, + ...spawnOptions, // Child streams keep the caller's bound. Reserve room for the // supervisor's failure text so the caller cannot kill it mid-cleanup. maxBuffer: maxBuffer * 2, @@ -578,13 +590,15 @@ export function runTreeSync( JSON.stringify({ command, args, - timeout: options.timeout ?? PROCESS_TABLE_TIMEOUT_MS, + ...(timeout !== undefined && { timeout }), maxBuffer, pollMs: POSIX_TREE_EXIT_POLL_MS, - cleanupMs: POSIX_TREE_EXIT_WAIT_MS, }), ), - timeout: (options.timeout ?? PROCESS_TABLE_TIMEOUT_MS) + POSIX_TREE_EXIT_WAIT_MS * 2, + // Only the supervisor's own clock, at the caller's deadline, ends the + // tree; nothing of ours bounds its process-table reads or the cleanup + // after it (a clock here could kill the supervisor mid-cleanup and leave + // the tree running). No caller deadline, no clock at all. encoding: 'buffer', }) } diff --git a/src/host/reporting/reportCheckBundle.ts b/src/host/reporting/reportCheckBundle.ts index fcdaaf57e..384818579 100644 --- a/src/host/reporting/reportCheckBundle.ts +++ b/src/host/reporting/reportCheckBundle.ts @@ -27,5 +27,5 @@ export function reportingCheckJournal( }) let journal: ReturnType | undefined const get = () => (journal ??= load().createReportingCheckJournal(deps.context)) - return { commit: () => get().commit(), append: (record) => get().append(record) } + return { commit: (signal) => get().commit(signal), append: (record) => get().append(record) } } diff --git a/src/host/vault/vaultExecSpawn.ts b/src/host/vault/vaultExecSpawn.ts index 5dc6f8443..3aca39e28 100644 --- a/src/host/vault/vaultExecSpawn.ts +++ b/src/host/vault/vaultExecSpawn.ts @@ -1,10 +1,5 @@ import path from 'node:path' -import { - UI_TEXT, - VAULT_LIMITS, - PROCESS_TABLE_TIMEOUT_MS, - SHELL_DRAIN_GRACE_MS, -} from '../../shared/constants' +import { UI_TEXT, VAULT_LIMITS, SHELL_DRAIN_GRACE_MS } from '../../shared/constants' import { vaultExecEnvelopeSchema, vaultExecResultSchema, @@ -88,23 +83,15 @@ export async function spawnVaultFeeder( let hasTerminationFailed = false const stop = () => { kill ??= (async () => { - let deadline: ReturnType | undefined + // The termination itself is awaited, however long the contained + // tree takes to end: no deadline of ours abandons it. try { - await Promise.race([ - terminateContained(child), - new Promise((_resolve, reject) => { - deadline = setTimeout(() => { - reject(new Error(UI_TEXT.vault.noAccess)) - }, PROCESS_TABLE_TIMEOUT_MS) - }), - ]) + await terminateContained(child) } catch { // Best effort ends the root's group/job; the flag still fails the result below. hasTerminationFailed = true // Only the native port can prove the compound tree. await killTree(child, deps.tree, startedAt, deps.job) - } finally { - clearTimeout(deadline) } })() void (async () => { @@ -230,7 +217,7 @@ export function vaultExecProcessRunner( workspaceRoot: invocation.cwd, realpath: realpathSync, }), - timeoutMs: invocation.cleanup ? Math.min(timeoutMs, PROCESS_TABLE_TIMEOUT_MS) : timeoutMs, + timeoutMs, signal, tree, job, diff --git a/src/runtime/main.ts b/src/runtime/main.ts index 9775ce4da..045689b8b 100644 --- a/src/runtime/main.ts +++ b/src/runtime/main.ts @@ -55,7 +55,6 @@ import { REFERENCE_BUNDLE_FILE, BOUNDED_FILE_READ_CHUNK_BYTES, SCHEDULE_STORE_SUBFOLDER, - REPORT_SOURCE_TIMEOUT_MS, CONNECTOR_BUNDLE_FILE, } from '../shared/constants' import type { SecretStore } from '../host/auth/credentialStore' @@ -790,11 +789,13 @@ function runtimeReports( }, readSaved: async (cwd, file) => { // NOLIMITS1FIX: a saved report reads whole at any size — the - // removed 16 MiB bound was invented. + // removed 16 MiB bound was invented. No deadline of ours either + // (D121/D208): the read runs to end-of-file, and the person's + // interrupt of this command is what stops it. const bytes = await readBoundedFile( path.resolve(cwd, file), Infinity, - AbortSignal.timeout(REPORT_SOURCE_TIMEOUT_MS), + new AbortController().signal, ) const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) return value diff --git a/src/runtime/reporting/engine.ts b/src/runtime/reporting/engine.ts index 1a608e074..9dbfc9403 100644 --- a/src/runtime/reporting/engine.ts +++ b/src/runtime/reporting/engine.ts @@ -5,7 +5,6 @@ import { REPORT_EMPTY_HASH, REPORT_NEXT_STEP_LIMIT, REPORT_FORMAT_VERSION, - REPORT_SOURCE_TIMEOUT_MS, type REPORT_FAIL_ON, } from '../../shared/constants' import { setUiText } from '../../shared/l10n/text' @@ -152,9 +151,10 @@ export function createReportingServices(context: ReportingContext): ReportsServi ...(context.schedules !== undefined && { schedules: context.schedules }), ...(context.estimate !== undefined && { estimate: context.estimate }), }).sources - // One signal and one deadline for the whole generation: every source - // observes the same abort and the same last instant for new work, and - // the engine awaits each read's own settlement, never racing it. + // One signal for the whole generation, the caller's: every source + // observes the same abort (the person's cancel), no deadline of ours + // ends a read (D121/D208), and the engine awaits each read's own + // settlement, never racing it. const readContext = { reportText, locale, @@ -163,7 +163,6 @@ export function createReportingServices(context: ReportingContext): ReportsServi asOf: options.asOf, workspaceKey: key, signal, - deadline: Date.now() + REPORT_SOURCE_TIMEOUT_MS, } let planText: string | undefined const sources: ReportSourcePorts = { @@ -204,7 +203,7 @@ export function createReportingServices(context: ReportingContext): ReportsServi }), ...context.sources, } - // All reads share the request stamp; source ports bound time and cancellation. + // All reads share the request stamp; source ports observe cancellation. const [ initialPlan, pkg, @@ -512,10 +511,11 @@ export function createReportingCheckJournal( scrub: reportScrubber({ workspaceRoot: root, localRoots: [homedir()] }), }) return { - async commit(): Promise { + /** HEAD at the check's start; the caller's signal (the turn's Stop) is its only end. */ + async commit(signal: AbortSignal): Promise { const result = await reportGitIo(root, process.platform, process.env).run( ['rev-parse', '--verify', 'HEAD'], - AbortSignal.timeout(REPORT_SOURCE_TIMEOUT_MS), + signal, ) if (result.code !== 0 || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(result.stdout.trim())) throw new Error(context.l10n.table.reportUi.generationFailed) diff --git a/src/runtime/reporting/playbookSchedulesInputs.ts b/src/runtime/reporting/playbookSchedulesInputs.ts index 5a7d187a1..e629c4cda 100644 --- a/src/runtime/reporting/playbookSchedulesInputs.ts +++ b/src/runtime/reporting/playbookSchedulesInputs.ts @@ -11,11 +11,7 @@ import { FilePlaybookJournal, PlaybookHistoryLostError, } from '../../core/orchestration/playbook/journal' -import { - REPORT_SOURCE_TIMEOUT_MS, - SCHEDULE_FS_RETRY_ATTEMPTS, - SCHEDULE_LEASE_POLL_MS, -} from '../../shared/constants' +import { SCHEDULE_FS_RETRY_ATTEMPTS } from '../../shared/constants' import type { ScheduleFsPort } from '../../core/schedules/journal' import { createScheduleStore } from '../../core/schedules/store' import { LocalSourceError } from '../../core/reporting/sources/local' @@ -101,15 +97,12 @@ export function createSchedulesReportReader(input: { }): SchedulesReportReader { return { read: async (context: SourceReadContext) => { - // A cancelled or timed-out source read stops, never abandons: the - // signal and this read's deadline travel through the store to the lock - // acquisition, which checks them on every turn and before every write - // and performs no read, write or publish after either fires. The window - // always starts at read start: fixture and refresh stamps stay out of - // it, so a late refresh still gets its full wait. Less one poll turn - // of settle budget, so this verdict precedes the source's own timeout - // and the race reports busy, never cancelled. - const deadlineMs = Date.now() + REPORT_SOURCE_TIMEOUT_MS - SCHEDULE_LEASE_POLL_MS + // A cancelled source read stops, never abandons: the caller's signal + // (the person's cancel) travels through the store to the lock + // acquisition, which checks it on every turn and before every write + // and performs no read, write or publish after it fires. No deadline + // of ours ends the wait for a held lease (D121/D208): the read takes + // the lease once its holder releases it or the lease expires. const { signal } = context const raw = createNodeScheduleFs(input.storeRoot) const fs: ScheduleFsPort = { @@ -118,7 +111,7 @@ export function createSchedulesReportReader(input: { publish: (file, content, guard) => raw.publish(file, content, guard), replace: (file, content, guard) => raw.replace(file, content, guard), remove: (file, canRetryTransient) => raw.remove(file, canRetryTransient), - lock: (key, work) => raw.lock(key, work, { signal, deadlineMs }), + lock: (key, work) => raw.lock(key, work, { signal }), } const store = createScheduleStore(fs) for (let attempt = 0; attempt < SCHEDULE_FS_RETRY_ATTEMPTS; attempt += 1) { diff --git a/src/runtime/reporting/selections.ts b/src/runtime/reporting/selections.ts index 4c7657100..a1c2484ba 100644 --- a/src/runtime/reporting/selections.ts +++ b/src/runtime/reporting/selections.ts @@ -1,10 +1,6 @@ import type { UiText } from '../../shared/l10n/en' import * as z from 'zod/mini' -import { - GIT_METADATA_OPTIONS, - REPORT_GIT_MAX_COMMITS, - REPORT_SOURCE_TIMEOUT_MS, -} from '../../shared/constants' +import { GIT_METADATA_OPTIONS, REPORT_GIT_MAX_COMMITS } from '../../shared/constants' import type { ReportOptions } from '../../shared/reportSchema' import type { ReportSelectionPorts } from '../../core/reporting/collect/repository' import type { @@ -52,15 +48,18 @@ function unavailable(id: string, reportText: UiText): SourceResult { } } -/** Bounded ancestry proves ranges and lane membership; timestamps never stand in for a graph. */ +/** +Bounded ancestry proves ranges and lane membership; timestamps never stand +in for a graph. The caller's signal (the person's cancel) is the only end of +these git reads: no deadline of ours abandons them (D121/D208). +*/ export async function readReportSelections( snapshot: SourceSnapshot, options: ReportOptions, io: ReportGitIo, - outerSignal: AbortSignal, + signal: AbortSignal, reportText: UiText, ): Promise { - const signal = AbortSignal.any([outerSignal, AbortSignal.timeout(REPORT_SOURCE_TIMEOUT_MS)]) const git = snapshot.sources.git.data const latestTag = git?.tags.toSorted( (a, b) => Date.parse(b.at) - Date.parse(a.at) || (a.name < b.name ? -1 : 1), @@ -133,7 +132,7 @@ export async function readReportSelections( isPartial, ) } catch { - /* A missing ref, bounded graph or exhausted deadline remains unavailable evidence. */ + /* A missing ref, bounded graph or cancelled read remains unavailable evidence. */ } } if ( diff --git a/src/runtime/reporting/sources.ts b/src/runtime/reporting/sources.ts index 6467ad25a..e84167080 100644 --- a/src/runtime/reporting/sources.ts +++ b/src/runtime/reporting/sources.ts @@ -4,7 +4,6 @@ import { GIT_OUTPUT_MAX_BYTES, REPORT_MAX_ROWS, REPORT_STORAGE_LINK_COUNT, - REPORT_SOURCE_TIMEOUT_MS, } from '../../shared/constants' import { LocalSourceError, @@ -257,7 +256,9 @@ export function reportGitIo( env: commandEnvironment, encoding: 'utf8', windowsHide: true, - timeout: REPORT_SOURCE_TIMEOUT_MS, + // No deadline of ours (D121/D208): git's own exit or the caller's + // signal ends the probe. + timeout: Infinity, maxBuffer: GIT_OUTPUT_MAX_BYTES, signal, }) @@ -289,7 +290,9 @@ export function reportGitIo( env: commandEnvironment, encoding: 'utf8', windowsHide: true, - timeout: REPORT_SOURCE_TIMEOUT_MS, + // No deadline of ours (D121/D208): git's own exit or the caller's + // signal ends the read. + timeout: Infinity, maxBuffer: GIT_OUTPUT_MAX_BYTES, signal, }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index e4f9de9b0..e0f6b7f92 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -4633,7 +4633,9 @@ export const POSIX_TREE_EXIT_POLL_MS = 20 // is compiled once into the extension's storage. Where no job is possible, // the shell is given this long to exit after taskkill, and then its orphans // are looked up by their dead parent's id and killed, one generation per -// round, in this many rounds at most; each helper run is given this long. +// round, in this many rounds at most. A helper run itself (PowerShell, the +// process table, taskkill) has no deadline of ours: Windows PowerShell's +// first start on a slow machine outlasted the old 20 s and was killed. export const SHELL_JOB_TYPE_NAME = 'MuseSparkJob' export const SHELL_JOB_FOLDER = 'shell-job' // .NET Framework ships with Windows PowerShell 5.1; its x86 compiler is @@ -4646,7 +4648,6 @@ export const WINDOWS_FRAMEWORK_RELATIVE_PATH = 'Microsoft.NET\\Framework\\v4.0.3 export const SHELL_JOB_NAME_PREFIX = 'Local\\MuseSparkShell-' export const TREE_EXIT_WAIT_MS = 10_000 export const ORPHAN_SWEEP_ROUNDS = 5 -export const PROCESS_TABLE_TIMEOUT_MS = 20_000 /** OS hand-off adapters (opener, clipboard) must return promptly; only their root is owned. Windows PowerShell's cold start for Set-Clipboard sets the bound. */ export const RESOURCE_HANDOFF_TIMEOUT_MS = 10_000 export const OUTPUT_REF_PREFIX = 'tool_patch-' @@ -6190,7 +6191,12 @@ export const REPORT_NETWORK_MAX_BYTES = 2 * 1024 * 1024 // past it refuses as `page-bound` instead of truncating silently (C-M113-6). export const REPORT_NETWORK_MAX_PAGES = 10 export const REPORT_NETWORK_CACHE_ENTRIES = 100 -export const REPORT_SOURCE_TIMEOUT_MS = 5000 +// Labels only, never a bound: a source observed within this age of the +// report's stamp reads `fresh`, an older one `stale`. Nothing waits on it, +// stops at it or refuses past it; no timeout of ours ends a source read +// (D121/D208): only the caller's signal (the person's cancel) or a deadline +// the caller hands in does. +export const REPORT_SOURCE_FRESH_MS = 5000 // NOLIMITS1FIX: report source files read at any size — the removed 16 MiB // bound (which A30 had renamed from the session-export cap) was invented. // Row and text bounds elsewhere (`REPORT_MAX_ROWS`, `REPORT_MAX_TEXT_CHARS`) diff --git a/src/shared/l10n/en.ts b/src/shared/l10n/en.ts index 52c7244b7..b8d25fea1 100644 --- a/src/shared/l10n/en.ts +++ b/src/shared/l10n/en.ts @@ -2530,7 +2530,7 @@ export const EN = { missing: 'The source file or repository is missing.', invalid: 'The source has an unsupported format or invalid data.', limit: 'The source exceeds the report read limit.', - cancelled: 'The source read was cancelled or timed out.', + cancelled: 'The source read was cancelled.', failed: 'The source could not be read completely.', refused: 'The source path is private or outside its allowed folder.', disabled: 'This agent usage source is not enabled.', diff --git a/src/webview/state/uiState.ts b/src/webview/state/uiState.ts index 9daa1fdf7..efd17fb00 100644 --- a/src/webview/state/uiState.ts +++ b/src/webview/state/uiState.ts @@ -2615,7 +2615,7 @@ The messages this panel sent that the host has not answered yet, carried after another conversation's history, numbered on from it. The host holds a message sent while a conversation opens and answers it in the one that opened, sent or refused, so its card and typed text never vanish with the -transcript it was typed under (0.19.1). +transcript it was typed under (0.19.0). */ function unansweredCards(state: UiState, after: number): readonly UserEntry[] { return state.transcript diff --git a/test/integration/scheduleBackground.test.ts b/test/integration/scheduleBackground.test.ts index 6636c9d8b..c6c386fee 100644 --- a/test/integration/scheduleBackground.test.ts +++ b/test/integration/scheduleBackground.test.ts @@ -44,6 +44,12 @@ async function coordinator(nextWake: number | undefined) { return { ...realBackgroundHarness(folder, nextWake), folder } } +// The entry's real OS calls (the Windows Task Scheduler through PowerShell) +// take as long as the machine needs: a slow hosted Windows runner ran past +// the integration runs' 20 s budget twice (PR #255, PR #257) where a warm run +// takes 7 to 11 s. No limit of ours here (owner 2026-10-16): each test below +// is `.timeout(0)` (mocha copies a suite's timeout into a test when the test +// is added); the other suites keep the repository's budget. suite('schedule background entry in the real host', () => { let disposeGovernor: (() => void) | undefined let governorChannel: vscode.LogOutputChannel | undefined @@ -139,10 +145,12 @@ suite('schedule background entry in the real host', () => { } finally { await removeFolder(folder) } - }) + }).timeout(0) const registerTest = process.platform === 'win32' ? test.skip : test - registerTest( + // mocha's TDD `test.skip` returns nothing at run time (its types say a + // Test), so the no-limit timeout goes only on the platforms where it runs. + const wakeTest = registerTest( 'a due wake reaches entry.register, which really refuses the unverified launcher', async () => { const { background, consent, entry, folder } = await coordinator(WAKE) @@ -164,6 +172,7 @@ suite('schedule background entry in the real host', () => { } }, ) + if (process.platform !== 'win32') wakeTest.timeout(0) test('an explicit removal retires the entry with no consent record', async () => { const { background, entry, folder } = await coordinator(undefined) @@ -174,5 +183,5 @@ suite('schedule background entry in the real host', () => { } finally { await removeFolder(folder) } - }) + }).timeout(0) }) diff --git a/test/unit/helpers/vault/panelFlowDriver.ts b/test/unit/helpers/vault/panelFlowDriver.ts index 8fca8415a..59cc934c2 100644 --- a/test/unit/helpers/vault/panelFlowDriver.ts +++ b/test/unit/helpers/vault/panelFlowDriver.ts @@ -105,7 +105,7 @@ export async function drivePanelFlow( }, { timeout: STEP_TIMEOUT_MS, interval: 50 }, ) - expect(toasts, `${label}: no failure toast`).toEqual([]) + expect(toasts, `${label}: no failure toast; log ${JSON.stringify(logged)}`).toEqual([]) const frame = frames.at(-1) if (frame === undefined) throw new Error('the panel published nothing') return frame diff --git a/test/unit/manifest.test.ts b/test/unit/manifest.test.ts index 996c89ac1..bb759b786 100644 --- a/test/unit/manifest.test.ts +++ b/test/unit/manifest.test.ts @@ -573,8 +573,6 @@ describe('packaging (M26)', () => { expect(reuse).toContain('node scripts/release-reuse.mjs verify reused') expect(reuse).toContain("echo 'reused=false'") expect(reuse).toContain('Full rebuild: a CI artifact download failed.') - // D140 re-uploads the two helper builds on reuse: three becomes five. - expect(count(reuse, /if: steps.check.outputs.reused == 'true'/g)).toBe(5) expect(build).toContain('needs: [verify, reuse]') expect(build).toContain("github.event_name == 'push' && needs.reuse.outputs.reused != 'true'") expect(build).toContain('uses: ./.github/workflows/build.yml') diff --git a/test/unit/playbookSchedulesInputs.test.ts b/test/unit/playbookSchedulesInputs.test.ts index a4ab19d4f..e5b1e0129 100644 --- a/test/unit/playbookSchedulesInputs.test.ts +++ b/test/unit/playbookSchedulesInputs.test.ts @@ -90,9 +90,9 @@ describe('schedules report reader failures', () => { // one-shot reconciliation, the fencing-CAS retry and the persistent // ownership-loss cases hooked the reader's lock/publish calls through the // module factory: interception, unrepresentable through the real reader. - // Their verdicts are withdrawn in docs/certification/msm113.md; the busy - // verdict itself stays proven below against a real held lease, and the - // retry loop is admission's own suites. + // Their verdicts are withdrawn in docs/certification/msm113.md; the wait + // for a real held lease is proven below (no deadline of ours turns it + // busy), and the retry loop is admission's own suites. it('reads damaged stored JSON as invalid, never a generic failure', async () => { await withSchedulesRoot('schedules-corrupt-', async (root) => { @@ -255,29 +255,43 @@ async function forgeJournalOverflow(root: string, changes: unknown) { } describe('schedules report contention waits', () => { - // The production 5 s source deadline must elapse while a valid holder owns - // the lease, so this needs longer than the repository default timeout. + // Past the removed 5 s source deadline (D121/D208): a read still waiting + // this long proves no deadline of ours ends it. + const PAST_FORMER_DEADLINE_MS = 6000 + + // The holder keeps the lease past the former deadline, so this needs + // longer than the repository default timeout. it( - 'reports a held production lease as busy with no late publish', + 'waits for a held production lease past the former deadline, then reads it', { timeout: 30_000 }, async () => { await withSchedulesRoot('schedules-held-lease-', async (root) => { const { release, held } = await holdProductionLease(root) try { - // A valid writer holds the lease past the source deadline: the - // verdict is the Host busy sentence, never cancelled or timed out. - const result = await sourceFor(root).read(freshContext()) - expect(result.record.status).toBe('unavailable') - expect(result.record.reason).toBe(UI_TEXT.teamHostBusy) + let isSettled = false + const pending = (async () => { + try { + return await sourceFor(root).read(freshContext()) + } finally { + isSettled = true + } + })() + // A valid writer holds the lease: the read keeps waiting, never + // busy, cancelled or timed out on a clock of ours. + await delay(PAST_FORMER_DEADLINE_MS) + expect(isSettled).toBe(false) release.resolve(undefined) await held + const result = await pending + expect(result.record.status).toBe('ok') + expect(result.data).not.toBeNull() const settled = await inventory(root) await delay(300) - // The stopped reader performs no further read, write or publish - // once the holder lets go: the tree stays exactly as it settled. - // (Lock attempts leave no trace, so the single attempt is not - // asserted here.) + // The reader released its own lease when it settled: no further + // read, write or publish follows, and the tree stays as it settled. expect(await inventory(root)).toEqual(settled) + const reread = await readerFor(root).read(context()) + expect(reread.schedules).toHaveLength(1) } finally { release.resolve(undefined) } @@ -285,10 +299,10 @@ describe('schedules report contention waits', () => { }, ) - // Five sequential refreshes against a persistent holder: each one ends - // busy on its own wait, files stay untouched, and releasing the holder - // afterwards serves a fresh read. Needs five production waits back to back. - it('ends every refresh busy without accumulating readers', { timeout: 60_000 }, async () => { + // Five refreshes against a persistent holder, each cancelled by its + // caller: each ends cancelled, none acquires the lease, and releasing the + // holder afterwards serves a fresh read. + it('ends every cancelled refresh without accumulating readers', async () => { await withSchedulesRoot('schedules-refresh-', async (root) => { const { release, held } = await holdProductionLease(root) try { @@ -298,9 +312,13 @@ describe('schedules report contention waits', () => { files.filter((file) => !file.endsWith('.heartbeat')) const before = withoutPulse(await inventory(root)) for (let refresh = 0; refresh < 5; refresh += 1) { - const result = await sourceFor(root).read(freshContext()) + const controller = new AbortController() + const pending = sourceFor(root).read({ ...freshContext(), signal: controller.signal }) + await delay(50) + controller.abort() + const result = await pending expect(result.record.status).toBe('unavailable') - expect(result.record.reason).toBe(UI_TEXT.teamHostBusy) + expect(result.record.reason).toBe(UI_TEXT.reportSourceReasons.cancelled) } // No refresh acquired the lease, so no fence header or marker // appeared: repeated refreshes accumulate no readers. @@ -315,28 +333,6 @@ describe('schedules report contention waits', () => { }) }) - // The full 5 s reader wait must elapse against the gated lock, so this - // needs longer than the repository default timeout. - it('stops a queued reader on its deadline with a frozen tree', { timeout: 30_000 }, async () => { - await withSchedulesRoot('schedules-queued-reader-', async (root) => { - const { release, held } = await holdProductionLease(root) - try { - expect(await codeOf(() => readerFor(root).read(freshContext()))).toBe('busy') - release.resolve(undefined) - await held - const settled = await inventory(root) - await delay(300) - // No read, listing, write or removal followed the stop: the tree - // stays exactly as it settled. - expect(await inventory(root)).toEqual(settled) - const reread = await readerFor(root).read(context()) - expect(reread.schedules).toHaveLength(1) - } finally { - release.resolve(undefined) - } - }) - }) - it('still reports an explicit abort as cancelled, never busy', async () => { await withSchedulesRoot('schedules-abort-', async (root) => { const { release, held } = await holdProductionLease(root) @@ -568,7 +564,7 @@ describe('queue vocabulary coverage', () => { // NOTE (MSM113FIX): the per-word lock-rejection table is gone. It made // the lock fail with each queue word through the module factory: // interception, and lock attempts leave no real trace to assert. Each - // verdict stays proven for real elsewhere in this file: busy against a + // verdict stays proven for real elsewhere in this file: the wait for a // real held lease, invalid through forged fence headers, failed through // forged store states. The retry rows (ownership loss) and the heartbeat // failure have no deterministic real trigger; their runtime diff --git a/test/unit/reportEngineCancellation.test.ts b/test/unit/reportEngineCancellation.test.ts index b9c5b382f..f5b6ced52 100644 --- a/test/unit/reportEngineCancellation.test.ts +++ b/test/unit/reportEngineCancellation.test.ts @@ -30,7 +30,7 @@ function sleep(ms: number): Promise { describe.each(REPORT_KINDS)('%s cancellation', (kind) => { // Ports are per source, kinds per report: exercise each kind through its // own first registry source, which is the port the engine hands the - // signal and deadline for that kind. + // caller's signal for that kind. const source = REPORT_KIND_REGISTRY[kind].sources[0] it('never starts a read past an abort', async () => { const controller = new AbortController() @@ -119,7 +119,8 @@ describe.each(REPORT_KINDS)('%s cancellation', (kind) => { }) }) -it('honors the handed deadline instead of the default timeout', async () => { +// The engine hands no deadline (D121/D208); one a caller hands in still binds. +it('honors a deadline the caller hands in', async () => { const started = Date.now() const port = localSource('session', async ({ signal }) => { await new Promise((_resolve, reject) => { @@ -161,7 +162,7 @@ describe('admission queue', () => { expect(state.waiting).toHaveLength(1) state = reportAdmissionStep(state, { type: 'aborted', generation: waiter }).state expect(state.waiting).toHaveLength(0) - state = reportAdmissionStep(state, { type: 'timedOut', generation: owner }).state + state = reportAdmissionStep(state, { type: 'aborted', generation: owner }).state state = reportAdmissionStep(state, { type: 'released', generation: owner }).state expect(state).toEqual(empty) } diff --git a/test/unit/resourceCreatedSweep.test.ts b/test/unit/resourceCreatedSweep.test.ts index dc1293c11..d5f9dfa84 100644 --- a/test/unit/resourceCreatedSweep.test.ts +++ b/test/unit/resourceCreatedSweep.test.ts @@ -165,5 +165,8 @@ describe('created registry sweep', () => { await second.clean() expect(await folderNames(dir)).toEqual(['registry.json', 'trees']) expect(JSON.parse(await readFile(path.join(dir, 'registry.json'), 'utf8'))).toHaveLength(2) - }) + // 5,000 real files planted and retired through the native helpers take + // 7.5 to 13.6 s on this project's Windows PC, near the repository's 15 s + // Windows budget: no limit of ours on it (owner 2026-10-16). + }, 0) }) diff --git a/test/unit/runnerHelperNative.test.ts b/test/unit/runnerHelperNative.test.ts index 97befb975..f26f8e489 100644 --- a/test/unit/runnerHelperNative.test.ts +++ b/test/unit/runnerHelperNative.test.ts @@ -10,17 +10,18 @@ import { runnerEnvironment } from '../../src/core/runners/runnerConfig' import { WINDOWS_POWERSHELL_COMMAND_ARGS } from '../../src/shared/constants' import { runnerTestGit as git } from './helpers/runnerProcesses' -// This case compiles and launches the native job helper for setup and check transactions. -const NATIVE_RUNNER_TRANSACTION_TIMEOUT_MS = 60_000 +// This case compiles and launches the native job helper for setup and check +// transactions through PowerShell, and waits for them however long the +// machine takes (owner 2026-10-16: no limits of ours): about 7 s here, past +// 60 s once on a hosted Windows runner (PR #257). +const NO_TIME_LIMIT = 0 const execute = promisify(execFile) const powerShell = process.platform === 'win32' ? 'powershell.exe' : 'pwsh' const env = runnerEnvironment(process.env) const hasParser = - spawnSync(powerShell, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { - env, - timeout: 10_000, - }).status === 0 + spawnSync(powerShell, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { env }).status === + 0 const helper = path.join(process.cwd(), 'native', 'runner', 'runner-helper.ps1') describe('native Windows runner certification', () => { @@ -97,9 +98,8 @@ foreach ($command in $commands) { if ($command.CommandElements.Count -ne 1) { th '10', ]) expect(JSON.parse(started.stdout)).toMatchObject({ runId: 'native-run', state: 'running' }) - const deadline = Date.now() + 30_000 let result: { state: string; exitCode?: number | undefined } = { state: 'running' } - while (result.state === 'running' && Date.now() < deadline) { + while (result.state === 'running') { const status = await invoke(['status', root, 'native-run']) result = z .object({ state: z.string(), exitCode: z.optional(z.number()) }) @@ -116,6 +116,6 @@ foreach ($command in $commands) { if ($command.CommandElements.Count -ne 1) { th if (hasRetirement) await rm(folder, { recursive: true, force: true }) } }, - NATIVE_RUNNER_TRANSACTION_TIMEOUT_MS, + NO_TIME_LIMIT, ) }) diff --git a/test/unit/treeSyncNoDeadline.test.ts b/test/unit/treeSyncNoDeadline.test.ts new file mode 100644 index 000000000..282cbd688 --- /dev/null +++ b/test/unit/treeSyncNoDeadline.test.ts @@ -0,0 +1,53 @@ +// The POSIX tree supervisor behind runTreeSync arms no clock of ours (D121, +// owner 2026-10-16): an effect with no caller timeout runs to its own end, +// and only a timeout the caller names ends it early. The supervisor reads +// its deadline from the spec it is given; with none, `setTimeout(fn, +// undefined)` would fire at once and kill the effect, so this runs the real +// supervisor, the real /bin/sh and the real process table. +import { mkdtemp, realpath } from 'node:fs/promises' +import { realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { workspaceChildEnvironment } from '../../src/core/execGuard' +import { withoutCredentials } from '../../src/core/credentialEnvironment' +import { runTreeSync } from '../../src/host/processTree' +import { removeFolder } from './helpers/temporaryFolders' + +const folder = { path: '' } + +function effectOptions(timeout?: number) { + return { + cwd: folder.path, + env: workspaceChildEnvironment(process.platform, withoutCredentials(process.env), { + workspaceRoot: folder.path, + realpath: realpathSync, + }), + ...(timeout !== undefined && { timeout }), + } +} + +describe.skipIf(process.platform === 'win32')('runTreeSync with no deadline of ours', () => { + beforeAll(async () => { + folder.path = await realpath(await mkdtemp(path.join(tmpdir(), 'muse-tree-sync-'))) + }) + afterAll(() => removeFolder(folder.path)) + + it('runs an effect with no caller timeout to its own end', () => { + const result = runTreeSync('/bin/sh', ['-c', 'sleep 1.5; echo done'], effectOptions()) + expect({ + status: result.status, + stdout: result.stdout.toString('utf8'), + stderr: result.stderr.toString('utf8'), + }).toEqual({ status: 0, stdout: 'done\n', stderr: '' }) + }) + + it("still ends the effect at the caller's own timeout", () => { + const result = runTreeSync('/bin/sh', ['-c', 'sleep 5; echo done'], effectOptions(300)) + expect({ + status: result.status, + stdout: result.stdout.toString('utf8'), + stderr: result.stderr.toString('utf8'), + }).toEqual({ status: 1, stdout: '', stderr: 'ETIMEDOUT' }) + }) +}) diff --git a/test/unit/windowsScreenRecorderNative.test.ts b/test/unit/windowsScreenRecorderNative.test.ts index 3a69bdb26..d739902f6 100644 --- a/test/unit/windowsScreenRecorderNative.test.ts +++ b/test/unit/windowsScreenRecorderNative.test.ts @@ -12,11 +12,7 @@ import { import path from 'node:path' import { windowsPowerShell } from '../../src/core/execGuard' import { windowsTrustedPathVerifier } from '../../src/runtime/windowsTrustedPath' -import { - MEDIA_FILE_ID_MIN_BYTES, - MEDIA_MAX_UPLOAD_MIB, - PROCESS_TABLE_TIMEOUT_MS, -} from '../../src/shared/constants' +import { MEDIA_FILE_ID_MIN_BYTES, MEDIA_MAX_UPLOAD_MIB } from '../../src/shared/constants' import { createWindowsLatestPort, resolveWindowsRecordingFolders, @@ -229,7 +225,6 @@ describe.skipIf(process.platform !== 'win32')( { env, windowsHide: true, - timeout: PROCESS_TABLE_TIMEOUT_MS, }, (error, stdout) => { if (error === null) resolve(stdout) @@ -283,7 +278,6 @@ describe.skipIf(process.platform !== 'win32')( { env, windowsHide: true, - timeout: PROCESS_TABLE_TIMEOUT_MS, }, (error, stdout) => { resolve({ code: error?.code ?? 0, stdout })