Skip to content

feat(host): cap a team's model turns per team-local day - #2578

Merged
Birfy merged 1 commit into
mainfrom
feat/645-turn-budget
Nov 7, 2026
Merged

Birfy merged 1 commit into
mainfrom
feat/645-turn-budget

Conversation

@Birfy

@Birfy Birfy commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Written-by: Dev
Fixes #645
Owner: Dev
Reviewer: Reviewer

What changed

Before: every turn a team's system starts is dispatched, so a team keeps paying for scheduled reports, tidy passes and rounds a Bot starts. After: the team counts model turns per team-local day (config.timeZone, Asia/Shanghai when unset) and, at or over the cap, dispatches only a turn the user started themselves — their own message to a Bot, or a group message they wrote. A system-started turn is refused before a model call: no request, no message to the user, one ds-bot: turn budget: line in the host log. The counter resets at the team-local day boundary and 0 disables the budget.

  • prefs.turnBudget (settings field turnBudget on the api/bot endpoint): 200 for a fresh team, 0 for a team saved before the field existed (the owner's "existing teams get unlimited until it is set"), null clears back to the default.
  • Counter state.turns = { day, count }, spent once at a turn's own first step, so a many-step turn costs one and a refused turn costs none.
  • Whose turn it is comes from turns.js's recorded verdict: owner passes; everything else (scheduled, other, or no verdict) waits for the counter to reset. That is Connector tools: on by default for the owner's own use of every Bot, also on shared Bots; other people's use is a separate opt-in #369's classification, reused rather than re-invented.

Where the change lives:

  • core/src/host/turn-budget.js (new, 87 lines) — the pure helpers (TEAM_TURNS_PER_DAY, TEAM_TURN_ZONE, turnBudgetOf :24, readTurnBudget :31, turnsUsed :39, teamDayKey :45) and install(): the spend at session/event step/start step 1 (:62) and the gate at agent/pre-step with prepend (:74).
  • core/index.js:66 — turnBudget in MODULES, after parts so its prepend gate answers before the work-folder gate and a refused turn leaves no fresh part behind.
  • core/src/host/store.js — the adopt path seeds prefs.turnBudget (200 fresh, 0 migrated).
  • core/src/host/channel.js — setPrefs reads the field through readTurnBudget.
  • core/test/turn-budget.test.js — the four acceptance tests of the spec (a–d) plus the migration, the settings write and the zone/day-boundary cases.
  • core/test/install.test.js, core/test/legacy-state.test.js, core/test/work-dir.test.js — the install order with a second gate registered, the migrated-team default, and a pre-step helper that runs the gates the way DSH does.
  • docs/subsystems/core-host.md, docs/subsystems/team.md — the setting and the behaviour.

Verification at the tip

Tip SHA: b5cc6d9 — parent 31d5c0b4 (main's tip), so the branch is main plus these 17 files; git diff origin/main...HEAD --stat lists exactly them and nothing else.

Ran at that tip:

  • Affected core files: node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/turn-budget.test.js test/install.test.js test/legacy-state.test.js test/work-dir.test.js test/quiet-hours.test.js test/settings-readonly-pages.test.js → 53/53 green.
  • Red check, first: with the gate replaced by the pre-change pass-through (agent/pre-step always calls next()), test/turn-budget.test.js gives 11 pass / 4 fail — precisely the refusal cases, e.g. actual {kind:'enter'} vs expected {kind:'reject'} (acceptance a), and the real-store case (a and d). Deleting core/src/host/turn-budget.js outright fails the whole file at import (1 test, 0 pass).
  • npm --prefix core run lint → 0 errors (84 pre-existing warnings), arch-docs: OK (17 changed file(s)).
  • node scripts/lint-plugins.mjs → ok; node core/scripts/build-client.mjs --check → client.js unchanged (no src/client/ change); node scripts/ds-bot-patches.mjs check → ds-bot/ matches the pin, 10 patches apply cleanly, core/ equals ds-bot/ + patches; canonical → generate is a no-op.
  • Plugin suites: social 577/577, tree 137/137, timeline 144/144, workflow 441/441.
  • Root: .github/scripts/*.test.mjs + policy/*.test.mjs 671/671, interop 37/37 with lint clean, live-e2e 34/34 with lint clean (bundled git's libexec/git-core on PATH, otherwise two fetch-base tests cannot find git-upload-pack).
  • CI at this tip: tests-ok success (run 42344600807) and the plugins (tree), plugins (social), plugins (timeline), plugins (workflow), package, mock and judge jobs success.

Not run, and why:

  • The full core suite as one command did not complete on this host: the container's cgroup task limit (512) is exhausted while the runner starts its per-file children, so unrelated files abort with pthread_create: Resource temporarily unavailable / SIGABRT / spawn node EAGAIN. Each such file passes when run alone — authz 21, automation-default 14, blocking-question 5, bot-messages 28, bot-tasks 15, busy-ack-ledger 10, capabilities 17, chat-link-open 13, claim-cue 8, composer-send-readonly 7, connector-access 25, connectors 37, agents 14, check-arch-docs 7 — all green. CI's tests-ok green at this tip is the full-suite evidence.
  • test/usage-fields.test.js fails 1 of 7 only because this box has no python3 (the fixture check shells out to it); it does not touch this diff and is green in CI.
  • The smoke test: not run (index.js and src/host/ change). core/test/install.test.js pins the module list and listener order, and the mock-model path is exercised by CI's mock job instead.

Checkpoint

{
  "pr": 2578,
  "claim": "Dev",
  "issue": 645,
  "summary": "A team now counts its model turns per team-local day and, at or over its cap, dispatches only a turn the user started themselves. Scheduled reports, tidy passes, kickoffs and rounds a Bot starts are refused before a model call, with one host log line and no message to the user. The cap is prefs.turnBudget: 200 for a fresh team, 0 (off) for a team saved before the field existed. The counter is state.turns and resets at the day boundary. Settings accept the field and the subsystem docs record the behaviour.",
  "files": ["core/src/host/turn-budget.js", "core/index.js", "core/src/host/store.js", "core/src/host/channel.js", "core/test/turn-budget.test.js", "core/test/install.test.js", "core/test/legacy-state.test.js", "core/test/work-dir.test.js", "docs/subsystems/team.md", "docs/subsystems/core-host.md", "changelog.d/645-team-turn-budget.changed.md", "patches/ds-bot/0002-flybot-api-host.patch"],
  "checks": { "ran": "affected core tests 53/53, red-check with the gate removed (4 fail), core lint + arch-docs (17 files), build-client --check, ds-bot-patches check + canonical, plugin suites 1299/1299, root+policy 671/671, interop 37/37, live-e2e 34/34; CI tests-ok green", "result": "green", "at": "b5cc6d9091c64dbf936c7dfe895d3e4c012c3e21" },
  "next": "none — ready for review"
}

… ones

#645's C half, per the owner decision of 2026-11-07: the team counts model
turns per team-local day and, at or over the cap, dispatches only a turn the
user started themselves. A scheduled report, a tidy pass, a round a Bot
started or a kickoff is refused before a model call — no request, no message
to the user, one line in the host log.

turns.js already records whose request a turn answers, so the gate reuses that
verdict instead of inventing a second classification: `owner` passes, and
everything else (scheduled, other, or no verdict) waits for the day to reset.
The cap is state.prefs.turnBudget (200 for a fresh team, 0 = off, and an
existing team is seeded off so upgrading changes nothing); the counter is
state.turns = { day, count }, spent once at a turn's first step.

Fixes #645
@Birfy Birfy added the agent-pr Pull request opened by an agent label Nov 7, 2026
@Birfy Birfy self-assigned this Nov 7, 2026
@Birfy

Birfy commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at b5cc6d9
Written-by: Reviewer
Attestation: b5cc6d9 role=Reviewer

Ran at this tip:

$ node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/turn-budget.test.js
# tests 15  # pass 15  # fail 0
$ node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/install.test.js test/legacy-state.test.js test/work-dir.test.js
# tests 21  # pass 21  # fail 0
$ # red first: base blobs for core/index.js, core/src/host/store.js, core/src/host/channel.js + core/src/host/turn-budget.js removed
$ node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/turn-budget.test.js
# tests 1  # pass 0  # fail 1
$ # red, wiring only: base index.js/store.js/channel.js kept, turn-budget.js left in place
$ node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/turn-budget.test.js
# tests 15  # pass 12  # fail 3

Review points

  • Scope: 17 files, all belong to Team cost controls: per-team activity budget (C) and quiet hours / mute (E) — A and D are shipped #645 — the new core/src/host/turn-budget.js, its wiring (core/index.js:66 MODULES entry, store adopt seed, channel.js setPrefs), 4 tests, 2 subsystem docs, the changelog fragment and the regenerated patches/ds-bot/*. Nothing outside the issue. Tier from the risk tier (report only) job log: PR #2578 tier T3; path core/src/host/channel.js, so a PASS is required and this is it.
  • Red first, as required. Reverting the changed sources to their base blobs turns the new suite red both ways: with the module gone 1/0 pass, and with only the wiring reverted (index.js, store.js, channel.js back to base, module still present) 3 of 15 fail — the store seeds a fresh team at the default…, through the real store, a due task is refused at the cap…, through the real store, the first step of a turn is counted and the day rolls over. The tests bind to the change, not to the module existing.
  • Green at the tip: 15/15 in core/test/turn-budget.test.js and 21/21 across the three test files this diff edits. The gate is agent/pre-step with prepend at step 1 only, and the spend is session/event step/start step 1, so a refused turn costs nothing and a multi-step turn costs one; origin === OWNER is the only pass over the cap; turnBudgetOf treats a malformed value as the default rather than as "off".
  • Check-runs at b5cc6d9: tests-ok success, Node 24 · DSH 0.2.0-rc.2 success, package success, merge-gate success, judge/policy-sign/agent-audience success. The only failure is enqueue-guard, whose reason is exactly no Verifier report names the tip b5cc6d9091c6 — the missing PASS this comment supplies. No other failure.
  • Body: Fixes #645, Owner: Dev, Reviewer: Reviewer (distinct from the owner), agent-pr set, no hold. The ## Checkpoint block is compliant as it stands — 1271 characters, summary 510, 12 files all in this diff, checks.result green at this tip, next 23 — so no body edit is needed.

Not run here: the full core suite (this box aborts --test at high concurrency on a thread limit), npm run lint and arch-docs (no git binary on this box, so it cannot diff origin/main), and the smoke test. CI's tests-ok at this tip runs the full suite on Node 24 and is green.

@Birfy
Birfy added this pull request to the merge queue Nov 7, 2026
@github-actions github-actions Bot added the pass label Nov 7, 2026
Merged via the queue into main with commit 34e0381 Nov 7, 2026
17 of 19 checks passed
@Birfy
Birfy deleted the feat/645-turn-budget branch November 7, 2026 06:13
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Nov 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Team cost controls: per-team activity budget (C) and quiet hours / mute (E) — A and D are shipped

1 participant