diff --git a/package-lock.json b/package-lock.json index f4df6c6..d189ca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@types/node": "^26.6.2", - "oxlint": "^1.85.0", + "oxlint": "^1.87.0", "tsx": "^4.23.15", "typescript": "^7.0.2" }, @@ -472,9 +472,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz", - "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz", + "integrity": "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==", "cpu": [ "arm" ], @@ -489,9 +489,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz", - "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.87.0.tgz", + "integrity": "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==", "cpu": [ "arm64" ], @@ -506,9 +506,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz", - "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.87.0.tgz", + "integrity": "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==", "cpu": [ "arm64" ], @@ -523,9 +523,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz", - "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.87.0.tgz", + "integrity": "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==", "cpu": [ "x64" ], @@ -540,9 +540,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz", - "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.87.0.tgz", + "integrity": "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==", "cpu": [ "x64" ], @@ -557,9 +557,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz", - "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.87.0.tgz", + "integrity": "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==", "cpu": [ "arm" ], @@ -574,9 +574,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz", - "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.87.0.tgz", + "integrity": "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==", "cpu": [ "arm" ], @@ -591,9 +591,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz", - "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.87.0.tgz", + "integrity": "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==", "cpu": [ "arm64" ], @@ -611,9 +611,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz", - "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.87.0.tgz", + "integrity": "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==", "cpu": [ "arm64" ], @@ -631,9 +631,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz", - "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.87.0.tgz", + "integrity": "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==", "cpu": [ "ppc64" ], @@ -651,9 +651,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz", - "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.87.0.tgz", + "integrity": "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==", "cpu": [ "riscv64" ], @@ -671,9 +671,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz", - "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.87.0.tgz", + "integrity": "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==", "cpu": [ "riscv64" ], @@ -691,9 +691,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz", - "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.87.0.tgz", + "integrity": "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==", "cpu": [ "s390x" ], @@ -711,9 +711,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz", - "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.87.0.tgz", + "integrity": "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==", "cpu": [ "x64" ], @@ -731,9 +731,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz", - "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.87.0.tgz", + "integrity": "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==", "cpu": [ "x64" ], @@ -751,9 +751,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz", - "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.87.0.tgz", + "integrity": "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==", "cpu": [ "arm64" ], @@ -768,9 +768,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz", - "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.87.0.tgz", + "integrity": "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==", "cpu": [ "arm64" ], @@ -785,9 +785,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz", - "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.87.0.tgz", + "integrity": "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==", "cpu": [ "ia32" ], @@ -802,9 +802,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz", - "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.87.0.tgz", + "integrity": "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==", "cpu": [ "x64" ], @@ -1226,9 +1226,9 @@ } }, "node_modules/oxlint": { - "version": "1.85.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", - "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==", + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz", + "integrity": "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==", "dev": true, "license": "MIT", "bin": { @@ -1241,28 +1241,28 @@ "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.85.0", - "@oxlint/binding-android-arm64": "1.85.0", - "@oxlint/binding-darwin-arm64": "1.85.0", - "@oxlint/binding-darwin-x64": "1.85.0", - "@oxlint/binding-freebsd-x64": "1.85.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", - "@oxlint/binding-linux-arm-musleabihf": "1.85.0", - "@oxlint/binding-linux-arm64-gnu": "1.85.0", - "@oxlint/binding-linux-arm64-musl": "1.85.0", - "@oxlint/binding-linux-ppc64-gnu": "1.85.0", - "@oxlint/binding-linux-riscv64-gnu": "1.85.0", - "@oxlint/binding-linux-riscv64-musl": "1.85.0", - "@oxlint/binding-linux-s390x-gnu": "1.85.0", - "@oxlint/binding-linux-x64-gnu": "1.85.0", - "@oxlint/binding-linux-x64-musl": "1.85.0", - "@oxlint/binding-openharmony-arm64": "1.85.0", - "@oxlint/binding-win32-arm64-msvc": "1.85.0", - "@oxlint/binding-win32-ia32-msvc": "1.85.0", - "@oxlint/binding-win32-x64-msvc": "1.85.0" + "@oxlint/binding-android-arm-eabi": "1.87.0", + "@oxlint/binding-android-arm64": "1.87.0", + "@oxlint/binding-darwin-arm64": "1.87.0", + "@oxlint/binding-darwin-x64": "1.87.0", + "@oxlint/binding-freebsd-x64": "1.87.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", + "@oxlint/binding-linux-arm-musleabihf": "1.87.0", + "@oxlint/binding-linux-arm64-gnu": "1.87.0", + "@oxlint/binding-linux-arm64-musl": "1.87.0", + "@oxlint/binding-linux-ppc64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-musl": "1.87.0", + "@oxlint/binding-linux-s390x-gnu": "1.87.0", + "@oxlint/binding-linux-x64-gnu": "1.87.0", + "@oxlint/binding-linux-x64-musl": "1.87.0", + "@oxlint/binding-openharmony-arm64": "1.87.0", + "@oxlint/binding-win32-arm64-msvc": "1.87.0", + "@oxlint/binding-win32-ia32-msvc": "1.87.0", + "@oxlint/binding-win32-x64-msvc": "1.87.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=7.0.2001", + "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "peerDependenciesMeta": { diff --git a/package.json b/package.json index b5e8ab3..6b1ac40 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ }, "devDependencies": { "@types/node": "^26.6.2", - "oxlint": "^1.85.0", + "oxlint": "^1.87.0", "tsx": "^4.23.15", "typescript": "^7.0.2" }, diff --git a/src/budget.test.ts b/src/budget.test.ts new file mode 100644 index 0000000..e57e38b --- /dev/null +++ b/src/budget.test.ts @@ -0,0 +1,69 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { allocateChildBudgets } from "./budget.js"; + +/** + * The child budget split (`src/budget.ts`). + * + * This module had no test file at all while the arithmetic it owned was + * wrong: `Math.max(1, …)` clamps made each child look owed at least one token, + * so four children on a two-token remainder each drew 1 and the SUM exceeded + * the pool it was split from — while `parentBudget` still reported 2. The + * invariant that matters is the sum, so it is pinned over a table rather than + * by the examples that follow. + */ + +test("the sum of allocations never exceeds the parent's remaining budget", () => { + for (const parent of [0, 1, 2, 3, 4, 5, 9, 10, 99, 100, 250_000]) { + for (const count of [1, 2, 3, 4]) { + const r = allocateChildBudgets(parent, count); + const sum = r.children.reduce((s, c) => s + c.allocated, 0); + assert.ok( + sum + r.coordinationBudget <= parent, + `parent=${parent} count=${count}: children ${sum} + coord ${r.coordinationBudget} > ${parent}` + ); + assert.equal(r.parentBudget, parent, "the reported pool is the real pool, not a rounded one"); + assert.equal(r.children.length, count, "every child is still reported, funded or not"); + } + } +}); + +test("a tight pool zeroes the children it cannot fund instead of flooring them", () => { + const r = allocateChildBudgets(2, 4); + assert.deepEqual(r.children.map((c) => c.allocated), [0, 0, 0, 0]); + assert.equal(r.coordinationBudget, 1, "the remainder stays with the coordination reserve"); + // The old clamp produced [1,1,1,1] here — 4 tokens out of a 2-token pool. + assert.equal(r.children.reduce((s, c) => s + c.allocated, 0), 0); +}); + +test("the default 85% child share / 15% coordination reserve", () => { + const r = allocateChildBudgets(100, 1); + assert.equal(r.children[0]?.allocated, 85); + assert.equal(r.coordinationBudget, 15); + const two = allocateChildBudgets(100, 2); + assert.equal(two.children[0]?.allocated, 42); + assert.equal(two.children[1]?.allocated, 42); + assert.equal(two.coordinationBudget, 15, "the reserve is held once, not per child"); +}); + +test("a negative or zero pool allocates nothing and reports no NaN", () => { + for (const parent of [0, -5, -1000]) { + const r = allocateChildBudgets(parent, 4); + assert.equal(r.children.reduce((s, c) => s + c.allocated, 0), 0); + assert.equal(r.coordinationBudget, 0); + for (const c of r.children) assert.equal(Number.isFinite(c.allocated), true); + } +}); + +test("childrenCount 0 allocates an empty list, not an infinity", () => { + const r = allocateChildBudgets(1000, 0); + assert.deepEqual(r.children, []); + assert.equal(r.coordinationBudget, 150, "the reserve is the pool's own remainder"); +}); + +test("an explicit childShare overrides the default and still honours the sum", () => { + const r = allocateChildBudgets(100, 2, { childShare: 0.5 }); + assert.equal(r.children[0]?.allocated, 25); + assert.equal(r.coordinationBudget, 50); + assert.ok(r.children.reduce((s, c) => s + c.allocated, 0) + r.coordinationBudget <= 100); +}); diff --git a/src/budget.ts b/src/budget.ts index 9341edd..2d9662b 100644 --- a/src/budget.ts +++ b/src/budget.ts @@ -1,5 +1,21 @@ import { sha256Hex } from "./hash.js"; +/** + * A ledger masquerading as an allocation — narrowed to its two real lines. + * + * This module answers exactly one question: given what the parent has left, + * how much does each child get, and how much stays with the parent? The + * `used`/`status` fields are carried by no caller and the hashed child ids are + * a constant string over the index, so they exist to be read, not to be + * written; the invariant that matters is `Σ allocated ≤ parentRemaining`, which + * is pinned by `src/budget.test.ts` over a table of (parent, count) pairs + * rather than by the examples in it. + * + * Surfaces must read `allocated === 0` as "not enough pool for this child" and + * say so: the child runner deliberately will not re-floor a 0 into 1 (see + * `runChildAgent`), because a floor is how the split stops being a split. + */ + export type ChildAllocation = { id: string; allocated: number; @@ -28,10 +44,17 @@ export function allocateChildBudgets( ): BudgetAllocationResult { const childShare = options.childShare ?? DEFAULT_CHILD_SHARE; - const budgetForChildren = Math.max(1, Math.floor(parentRemaining * childShare)); - const coordBudget = Math.max(0, parentRemaining - budgetForChildren); + // No `Math.max(1, …)` clamp anywhere. A clamp makes each child look owed at + // least one token, so four children on a 2-token remainder each get 1 and + // the SUM exceeds the pool it was split from — the over-allocation that + // made "children are capped by the parent's budget" false exactly when the + // budget was tight enough to matter. The remainder belongs to the + // coordination reserve and is reported as unallocated, not distributed. + const pool = Math.max(0, Math.floor(parentRemaining)); + const budgetForChildren = Math.floor(pool * childShare); + const coordBudget = pool - budgetForChildren; - const perChild = Math.max(1, Math.floor(budgetForChildren / childrenCount)); + const perChild = childrenCount <= 0 ? 0 : Math.floor(budgetForChildren / childrenCount); const children: ChildAllocation[] = []; for (let i = 0; i < childrenCount; i++) { diff --git a/src/loop.ts b/src/loop.ts index bb94bd1..ca39ee6 100644 --- a/src/loop.ts +++ b/src/loop.ts @@ -952,6 +952,11 @@ export async function agentLoop(args: LoopArgs): Promise { onChildEvent: (text) => emit("tool", text), remainingBudget: args.tokenBudget === undefined ? undefined : Math.max(0, args.tokenBudget - (promptTokens + completionTokens)), rotationModels: args.models, + // The run's dollar ceiling, forwarded to a SINGLE child (see + // ToolContext.costCheck: a fan-out holding the same closure + // would each see only its own buckets and report a ceiling it + // spends MAX_FANOUT times). + ...(args.costCheck === undefined ? {} : { costCheck: args.costCheck }), retryWait: args.retryWait, compactTokens: args.compactTokens, disallowedTools: args.disallowedTools, @@ -1436,6 +1441,11 @@ export async function agentLoop(args: LoopArgs): Promise { remainingBudget: args.tokenBudget === undefined ? undefined : Math.max(0, args.tokenBudget - (promptTokens + completionTokens)), rotationModels: args.models, + // The run's dollar ceiling, forwarded to a SINGLE child (see + // ToolContext.costCheck: a fan-out holding the same closure + // would each see only its own buckets and report a ceiling it + // spends MAX_FANOUT times). + ...(args.costCheck === undefined ? {} : { costCheck: args.costCheck }), retryWait: args.retryWait, compactTokens: args.compactTokens, disallowedTools: args.disallowedTools, diff --git a/src/subagents.ts b/src/subagents.ts index ba24146..88e415f 100644 --- a/src/subagents.ts +++ b/src/subagents.ts @@ -562,6 +562,14 @@ export type ChildRunOptions = { parentRunId?: string; /** Parent's `--debug` sink — a child's decisions join the same log. */ debug?: Debug; + /** + * The run's dollar ceiling, forwarded to a SINGLE child only. A fan-out + * deliberately does not get it: four concurrent children each holding the + * same closure each see only their own buckets, so each would believe it + * alone owns the ceiling and the fan-out would spend four times what it + * reports. Its ceiling stays at the parent's turn boundary. + */ + costCheck?: LoopArgs["costCheck"]; /** Progress lines, already prefixed with the agent name. */ onEvent?: (text: string) => void; /** Per-child wall clock — the child's signal aborts when it fires. */ @@ -603,9 +611,17 @@ export async function runChildAgent(opts: ChildRunOptions): Promise 0 ? { models: opts.models } : {}), diff --git a/src/tools/child-run.ts b/src/tools/child-run.ts index d340044..e69be4c 100644 --- a/src/tools/child-run.ts +++ b/src/tools/child-run.ts @@ -47,6 +47,10 @@ export function runChild( disallowedTools: ctx.disallowedTools, parentRunId: ctx.parentRunId, debug: ctx.debug, + // The parent's dollar ceiling rides a single child only (delegate_many's + // four concurrent holders would each see only their own buckets, so the + // fan-out keeps its ceiling at the parent's turn boundary). + ...(ctx.costCheck === undefined ? {} : { costCheck: ctx.costCheck }), deadlineMs: child.deadlineMs, ...(ctx.onChildEvent === undefined ? {} : { onEvent: ctx.onChildEvent }), }); diff --git a/src/tools/delegate.ts b/src/tools/delegate.ts index 5fbca8d..e605cea 100644 --- a/src/tools/delegate.ts +++ b/src/tools/delegate.ts @@ -63,18 +63,27 @@ export async function runDelegate(ctx: ToolContext, args: { agent: string; task: const budget = ctx.remainingBudget === undefined ? undefined : allocateChildBudgets(ctx.remainingBudget, 1); + const share = budget?.children[0]?.allocated; + ctx.onChildEvent?.(`[${agent.name}] started: ${args.task.slice(0, 120)}`); + if (share === 0) { + // The pool cannot fund this child. Spawning it would over-allocate the + // parent's own ceiling, so it is refused with the numbers, not run. + return { + ok: false, + output: `delegate: the parent's remaining token pool (${ctx.remainingBudget}) is too small to fund ${agent.name} (allocated 0) — spend fewer steps, raise --token-budget, or do the work in this run`, + }; + } if (budget !== undefined) { ctx.onChildEvent?.( `[budget] parent=${budget.parentBudget} coord=${budget.coordinationBudget}` + ` child=${budget.children[0]?.allocated ?? 0} (85% share, 15% reserve)` ); } - ctx.onChildEvent?.(`[${agent.name}] started: ${args.task.slice(0, 120)}`); const r = await runChild(ctx, { agent, task: args.task, signal, - tokenBudget: budget?.children[0]?.allocated, + tokenBudget: share, deadlineMs: DELEGATE_TIMEOUT_MS, }); ctx.onChildUsage?.(r.usageByModel); diff --git a/src/tools/delegate_many.ts b/src/tools/delegate_many.ts index 0ae5751..5cbc385 100644 --- a/src/tools/delegate_many.ts +++ b/src/tools/delegate_many.ts @@ -91,22 +91,45 @@ export async function runDelegateMany(ctx: ToolContext, args: { entries: Entry[] const budget = ctx.remainingBudget === undefined ? undefined : allocateChildBudgets(ctx.remainingBudget, agents.length); + // The visible start is unconditional. It used to sit inside the + // `budget !== undefined` branch, so a fan-out on a run without a token + // budget started several children with nothing between the last event and + // the first report — the exact "silent silence" the line exists to prevent. + for (const { name, task } of agents) { + ctx.onChildEvent?.(`[${name}] started: ${task.slice(0, 120)}`); + } + // `allocated === 0` is the split saying "the pool cannot fund this child". + // Running it anyway is how four children once drew four tokens out of a + // two-token pool, so a zero is dropped — and named, because a silently + // dropped perspective is the unaccountable outcome this codebase refuses. + const dropped = agents.filter((_, i) => budget?.children[i]?.allocated === 0); + if (dropped.length > 0) { + ctx.onChildEvent?.( + `[budget] dropped ${dropped.length} child(ren): ${dropped.map((d) => d.name).join(", ")} — each allocated 0 of the parent's remaining pool (${ctx.remainingBudget})` + ); + } + const runnable = agents + .map((a, i) => ({ entry: a, index: i, tokens: budget?.children[i]?.allocated })) + .filter((r) => r.tokens === undefined || r.tokens > 0); + if (runnable.length === 0) { + return { + ok: false, + output: `delegate_many: the parent's remaining token pool (${ctx.remainingBudget}) cannot fund any child (each allocated 0) — spend fewer steps, raise --token-budget, or do the work in this run`, + }; + } if (budget !== undefined) { - for (const { name, task } of agents) { - ctx.onChildEvent?.(`[${name}] started: ${task.slice(0, 120)}`); - } ctx.onChildEvent?.( `[budget] parent=${budget.parentBudget} coord=${budget.coordinationBudget}` + - ` children=${budget.children.reduce((s, c) => s + c.allocated, 0)} (${agents.length} children, 85% share, 15% reserve)` + ` children=${runnable.reduce((s, r) => s + (r.tokens ?? 0), 0)} (${runnable.length} of ${agents.length} children, 85% share, 15% reserve)` ); } const runs = await Promise.all( - agents.map((agentEntry, i) => + runnable.map((r) => runChild(ctx, { - agent: agentEntry.def, - task: agentEntry.task, + agent: r.entry.def, + task: r.entry.task, signal, - tokenBudget: budget?.children[i]?.allocated, + tokenBudget: r.tokens, deadlineMs: DELEGATE_TIMEOUT_MS, }) ) @@ -114,15 +137,16 @@ export async function runDelegateMany(ctx: ToolContext, args: { entries: Entry[] const lines: string[] = []; const runIds: string[] = []; let failed = 0; - runs.forEach((r, i) => { + runs.forEach((r, k) => { ctx.onChildUsage?.(r.usageByModel); - const name = agents[i]?.name ?? `agent${i + 1}`; + const name = runnable[k]?.entry.name ?? `agent${k + 1}`; + const slot = runnable[k]?.index ?? k; if (r.ok) { runIds.push(r.runId); - lines.push(`#${i + 1} [${name}]\n${r.text}`); + lines.push(`#${slot + 1} [${name}]\n${r.text}`); } else { failed += 1; - lines.push(`#${i + 1} [${name}] FAILED: ${r.error}`); + lines.push(`#${slot + 1} [${name}] FAILED: ${r.error}`); } }); const tail = runIds.length > 0 ? `\n[subagent runIds: ${runIds.join(", ")}]` : ""; diff --git a/src/tools/types.ts b/src/tools/types.ts index eff2ef7..c11d794 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -2,6 +2,7 @@ import type { ChatPort } from "../provider-port.js"; import type { UsageBucket } from "../outcomes.js"; import type { Debug } from "../debug.js"; import type { ToolFilter } from "../tool-filter.js"; +import type { StopReason } from "../loop.js"; import type { AgentDef } from "../subagents.js"; /** @@ -96,6 +97,14 @@ export type ToolContext = { /** Same-provider rotation candidates + 429 retry-wait, forwarded so a * rate-limited child rotates instead of dying (parallel fan-out friendly). */ rotationModels?: string[]; + /** + * The run's dollar ceiling as the parent evaluates it. Forwarded to a SINGLE + * child (not a fan-out: four concurrent children each holding the same + * closure each see only their own buckets, so the fan-out would report a + * ceiling it spends four times). Same shape as `LoopArgs.costCheck`, and + * injected by the surface — core never imports a price table. + */ + costCheck?: (buckets: UsageBucket[]) => { stopReason: StopReason; message: string } | null; retryWait?: boolean; /** Parent's compaction ceiling (children share the transcript-size policy). */ compactTokens?: number;