diff --git a/changelog.d/645-team-turn-budget.changed.md b/changelog.d/645-team-turn-budget.changed.md new file mode 100644 index 00000000..c0695aff --- /dev/null +++ b/changelog.d/645-team-turn-budget.changed.md @@ -0,0 +1 @@ +A team can now cap its model turns per team-local day (`prefs.turnBudget`, 200 for a fresh team, `0` turns the budget off). At or over the cap only a turn the user starts themselves is still dispatched; scheduled reports, tidy passes and rounds a Bot starts are refused before a model call, with one line in the host log ([#645](https://github.com/ThinkFlowLab/FlyBot/issues/645)). diff --git a/core/index.js b/core/index.js index 20a8f3e2..df6e8d4f 100644 --- a/core/index.js +++ b/core/index.js @@ -33,6 +33,7 @@ import * as approvals from './src/host/approvals.js' import * as resume from './src/host/resume.js' import * as memory from './src/host/memory.js' import * as parts from './src/host/parts.js' +import * as turnBudget from './src/host/turn-budget.js' import * as images from './src/host/images.js' import * as browser from './src/host/browser.js' import * as secrets from './src/host/secrets.js' @@ -62,7 +63,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel] +const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, turnBudget, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/src/host/channel.js b/core/src/host/channel.js index df5bbbc9..980f0265 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -6,6 +6,7 @@ import { questionReply } from './parts.js' import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js' import { isFaceRoom } from './room-membership.js' import { readQuietHours } from '../shared/quiet-hours.js' +import { readTurnBudget } from './turn-budget.js' import { faceMentions } from './face-mentions.js' import { visibleText } from '../shared/preview.js' @@ -300,6 +301,14 @@ export function install(rt) { if (quiet === undefined) delete state.prefs.quietHours else state.prefs.quietHours = quiet } + // Team turn budget (#645, the C half): a whole number of turns per team-local day, `0` off. + // Clearing the field is not "off" — it goes back to the default the store seeded — so an + // invalid value is refused rather than stored as a number nobody meant. + if (body.turnBudget !== undefined) { + const budget = readTurnBudget(body.turnBudget) + if (budget === undefined) delete state.prefs.turnBudget + else state.prefs.turnBudget = budget + } return rt.save() } diff --git a/core/src/host/store.js b/core/src/host/store.js index 411103b4..0527fa6b 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -9,6 +9,7 @@ import { LEGACY_THEMES, MAX_ENTRY, MAX_EXCHANGES, ROOM_MODES } from './constants import { homeLock } from './home-lock.js' import { openJournal } from './journal.js' import { tempWritable } from './real-dir.js' +import { TEAM_TURNS_PER_DAY } from './turn-budget.js' import { codePoints, currentColor, normalize, unquote } from './text.js' // The saved team's format version. Version 2 (#47) adds the identity schema: member @@ -257,6 +258,13 @@ export function install(rt) { // Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset. if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {} let migrated = !hadCapabilities + // Team turn budget (#645, the C half; owner decision 2026-11-07): a fresh team starts at the + // default cap, and a team saved before the budget existed stays off (0) until the owner sets a + // number. The file itself is what tells them apart, so one line here is one migration. + if (state.prefs.turnBudget === undefined) { + state.prefs.turnBudget = parsed === null || parsed === undefined ? TEAM_TURNS_PER_DAY : 0 + migrated = true + } if (!isRecord(state.projects)) { state.projects = {} migrated = true diff --git a/core/src/host/turn-budget.js b/core/src/host/turn-budget.js new file mode 100644 index 00000000..5c1aae58 --- /dev/null +++ b/core/src/host/turn-budget.js @@ -0,0 +1,87 @@ +// Team turn budget (#645, the C half; owner decision 2026-11-07). A team spends model turns, and +// a team spends too many of them on work nobody asked for: the count is per team-local day, and at +// or over the cap only a turn the user started themselves is dispatched. A timer's turn, a tidy's, +// a round a Bot started and a kickoff are refused before the model — no request, no line to the +// user, one line in the host log. `0` is off. +// +// Two facts keep the count honest. A turn is spent when its own first step starts +// (`session/event` `step/start`, step 1), so a turn of many steps costs one and a turn the gate +// refused never costs any. The gate is `agent/pre-step`, the seam parts.js already refuses a +// step at: `{ kind: 'reject' }` ends the turn before a model call, and DSH reports it as blocked. +// +// Whose turn it is comes from turns.js's recorded verdict (turn-origin.js): `owner` is the user's +// own message or a line they wrote in a group; everything else — `scheduled`, `other`, or a turn +// whose messages carried no verdict — is system-started and waits for the counter to reset. +import { dayKey } from './project-metrics.js' +import { OWNER } from './turn-origin.js' + +// The cap a fresh team starts with, and the day a team counted in before it had a zone of its own. +export const TEAM_TURNS_PER_DAY = 200 +export const TEAM_TURN_ZONE = 'Asia/Shanghai' + +// The cap the team runs with: a whole number, `0` is off, and an unset or malformed field keeps +// the fresh-team default rather than quietly turning the budget off. +export function turnBudgetOf(prefs) { + const value = prefs?.turnBudget + return Number.isSafeInteger(value) && value >= 0 ? value : TEAM_TURNS_PER_DAY +} + +// The settings write (`prefs` on the api): a whole number of turns, `0` for off, `null` to clear +// back to the default. Anything else is refused, never stored as a number nobody meant. +export function readTurnBudget(value) { + if (value === null || value === undefined) return undefined + if (!Number.isSafeInteger(value) || value < 0) throw Object.assign(new Error('Turn budget must be a whole number of turns (0 turns the budget off)'), { code: 'bot/turn-budget' }) + return value +} + +// What the saved counter says about one day: a record from another day is not a count at all, so +// the day boundary needs no timer — it resets the first time it is read or written. +export function turnsUsed(record, day) { + return record?.day === day && Number.isSafeInteger(record.count) && record.count > 0 ? record.count : 0 +} + +// The team's day. The clock the host already keeps (`timeZone`) is the team's zone; a host without +// one counts in Asia/Shanghai, and a zone Intl cannot read falls back to it rather than throwing. +export function teamDayKey(rt, at) { + const wanted = typeof rt?.config?.timeZone === 'string' && rt.config.timeZone !== '' ? rt.config.timeZone : TEAM_TURN_ZONE + try { + new Intl.DateTimeFormat('en-CA', { timeZone: wanted }) + return dayKey(at, wanted) + } catch { + return dayKey(at, TEAM_TURN_ZONE) + } +} + +export function install(rt) { + const { ctx } = rt + const day = () => teamDayKey(rt, Date.now()) + const nameOf = id => rt.botOf(rt.selfOf(id))?.name ?? id + + // One turn, one spend, on its first step. A refused turn never reaches a step and a long turn + // pays once, so the counter measures dispatched turns and not model calls. + ctx.on('session/event', (session, event) => { + const id = session?.id + if (id === undefined || event?.type !== 'step/start' || event.data?.step !== 1) return + if (!rt.isBotSession(id)) return + const today = day() + void rt.mutate(() => { + const used = turnsUsed(rt.state.turns, today) + rt.state.turns = { day: today, count: used + 1 } + }).catch(error => rt.warn('ds-bot: turn budget: a turn was not counted: %s', error)) + }) + + // Before the turn's first step reaches a model: over the cap, only the user's own turn goes on. + ctx.on('agent/pre-step', async ({ agent, step }, next) => { + await rt.load() + if (step !== 1 || !rt.isBotSession(agent.id)) return next() + const cap = turnBudgetOf(rt.state.prefs) + if (cap === 0) return next() + const today = day() + const used = turnsUsed(rt.state.turns, today) + if (used < cap) return next() + const origin = rt.live?.get(agent.id)?.origin + if (origin === OWNER) return next() + rt.warn('ds-bot: turn budget: %d of %d turns used on %s (%s); a %s turn for %s is not dispatched', used, cap, today, TEAM_TURN_ZONE, origin ?? 'system', nameOf(agent.id)) + return { kind: 'reject' } + }, { prepend: true }) +} diff --git a/core/test/install.test.js b/core/test/install.test.js index 2574408c..ec073f32 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -98,6 +98,10 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'effect ds-bot: archive a part once its job ends', 'on agent/status', 'on llm/stream', + // #645: the team turn budget, installed after parts so its gate answers first (it registers + // with prepend) and its spend listener sees turns.js's records. + 'on session/event', + 'on agent/pre-step (prepend)', 'on llm/stream', 'on agent/status', 'tool request_secret', diff --git a/core/test/legacy-state.test.js b/core/test/legacy-state.test.js index c543da0a..7cfece11 100644 --- a/core/test/legacy-state.test.js +++ b/core/test/legacy-state.test.js @@ -51,7 +51,8 @@ test('a team saved with the legacy grok theme opens with the mono theme', async const { call, stop, home } = open(savedTeam({ theme: 'grok', accent: '#123456' })) t.after(stop) const team = (await call('state', {})).value - assert.deepEqual(team.prefs, { theme: 'mono', accent: '#123456' }) + // A team saved before #645 existed is seeded with its budget off (0), not the fresh-team 200. + assert.deepEqual(team.prefs, { theme: 'mono', accent: '#123456', turnBudget: 0 }) assert.equal(JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).prefs.theme, 'mono') }) diff --git a/core/test/turn-budget.test.js b/core/test/turn-budget.test.js new file mode 100644 index 00000000..9e79e7e4 --- /dev/null +++ b/core/test/turn-budget.test.js @@ -0,0 +1,255 @@ +// Team turn budget (#645, the C half; owner decision 2026-11-07). A team's model turns are +// counted per team-local day; at or over the cap only a turn the user started themselves is +// dispatched. The gate answers before a step reaches a model, so "refused" means no request at +// all — that is what these tests pin, on the seam DSH reports as a blocked turn. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { format } from 'node:util' +import { test } from 'node:test' +import { createRuntime, provideFlyBot } from '../index.js' +import { fakeContext } from './fake-ctx.js' +import { + TEAM_TURNS_PER_DAY, TEAM_TURN_ZONE, install, readTurnBudget, teamDayKey, turnBudgetOf, turnsUsed, +} from '../src/host/turn-budget.js' + +// 2026-11-07 13:00 in Asia/Shanghai, so the team-local day is 2026-11-07 while UTC is still 11-07. +const NOW = Date.parse('2026-11-07T05:00:00Z') + +// The module with no store behind it: every read and write is a plain object this test can +// inspect, which is what makes "next was never called" an assertion and not an inference. +function harness({ cap = TEAM_TURNS_PER_DAY, count, day = '2026-11-07', origin = 'scheduled', bot = true, config = {} } = {}) { + const ctx = fakeContext() + const warnings = [] + const saves = [] + const rt = { + ctx, + config, + state: { prefs: { turnBudget: cap }, ...(count === undefined ? {} : { turns: { day, count } }) }, + live: new Map([['b', { turn: 4, origin }]]), + load: async () => {}, + save: async () => {}, + mutate: async (change) => { const result = await change(); saves.push(JSON.parse(JSON.stringify(rt.state))); return result }, + isBotSession: id => bot && id === 'b', + botOf: () => ({ name: 'Writer' }), + selfOf: id => id, + warn: (...args) => warnings.push(format(...args)), + } + install(rt) + const gate = ctx.listeners.get('agent/pre-step')[0] + const events = ctx.listeners.get('session/event')[0] + return { + rt, gate, events, warnings, saves, + // The step-1 pre-step chain as DSH runs it: `next` settles into the step it would take. + step: async ({ id = 'b', step = 1, next } = {}) => { + let called = 0 + const decision = await gate({ agent: { id }, step, signal: new AbortController().signal }, async () => { + called += 1 + return next ?? { kind: 'enter', messages: [{ id: 'm1' }] } + }) + return { decision, called } + }, + startStep: (id = 'b', step = 1) => events({ id }, { type: 'step/start', data: { turn: 1, step } }), + turnStart: (id = 'b') => events({ id }, { type: 'turn/start', data: { turn: 4 } }), + message: (id = 'b', source = { kind: 'user' }) => events({ id }, { type: 'user/message', data: { source, content: [{ type: 'text', text: 'hi' }] } }), + } +} + +test('the cap is a whole number of turns: 200 when unset, 0 is off, a malformed value keeps the default', () => { + assert.equal(turnBudgetOf({}), TEAM_TURNS_PER_DAY) + assert.equal(turnBudgetOf({ turnBudget: 0 }), 0) + assert.equal(turnBudgetOf({ turnBudget: 37 }), 37) + for (const value of [-1, 1.5, '40', null, Number.NaN]) assert.equal(turnBudgetOf({ turnBudget: value }), TEAM_TURNS_PER_DAY) +}) + +test('the settings write takes a whole number or 0, clears on null, and refuses the rest', () => { + assert.equal(readTurnBudget(0), 0) + assert.equal(readTurnBudget(200), 200) + assert.equal(readTurnBudget(null), undefined) + for (const value of [-1, 1.5, '200', Number.NaN, {}]) assert.throws(() => readTurnBudget(value), /whole number of turns/) +}) + +test('the team day is the team clock: Asia/Shanghai by default, the configured zone when set', () => { + const at = Date.parse('2026-11-06T15:59:59.999Z') + assert.equal(teamDayKey({}, at), '2026-11-06') + assert.equal(teamDayKey({}, at + 1), '2026-11-07') + const utcEnd = Date.parse('2026-11-06T23:59:59.999Z') + assert.equal(teamDayKey({ config: { timeZone: 'UTC' } }, utcEnd), '2026-11-06') + assert.equal(teamDayKey({ config: { timeZone: 'UTC' } }, utcEnd + 1), '2026-11-07') + // A zone Intl cannot read counts in the team default instead of throwing. + assert.equal(teamDayKey({ config: { timeZone: 'Not/AZone' } }, NOW), '2026-11-07') + assert.equal(TEAM_TURN_ZONE, 'Asia/Shanghai') +}) + +test('a saved count from another day is not a count', () => { + assert.equal(turnsUsed(undefined, '2026-11-07'), 0) + assert.equal(turnsUsed({ day: '2026-11-06', count: 900 }, '2026-11-07'), 0) + assert.equal(turnsUsed({ day: '2026-11-07', count: 900 }, '2026-11-07'), 900) + assert.equal(turnsUsed({ day: '2026-11-07', count: -3 }, '2026-11-07'), 0) +}) + +test('over the cap a system-started turn is refused before a model call, with one log line', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 2, count: 2, origin: 'scheduled' }) + const { decision, called } = await h.step() + assert.deepEqual(decision, { kind: 'reject' }) + assert.equal(called, 0, 'no model step is taken for a refused turn') + assert.equal(h.warnings.length, 1) + assert.match(h.warnings[0], /turn budget: 2 of 2 turns used on 2026-11-07 \(Asia\/Shanghai\); a scheduled turn for Writer is not dispatched/) +}) + +test('a turn with no recorded origin is system-started too', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 1, count: 1 }) + h.rt.live.set('b', { turn: 4 }) + const { decision, called } = await h.step() + assert.deepEqual(decision, { kind: 'reject' }) + assert.equal(called, 0) +}) + +test('over the cap the user\'s own turn is still dispatched', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 2, count: 9, origin: 'owner' }) + const decision = { kind: 'enter', messages: [{ id: 'm1' }] } + const out = await h.step({ next: decision }) + assert.equal(out.called, 1) + assert.equal(out.decision, decision) +}) + +test('under the cap, at 0, and after the day boundary nothing is refused', async (t) => { + t.mock.method(Date, 'now', () => NOW) + for (const options of [ + { cap: 2, count: 1, origin: 'scheduled' }, // under the cap + { cap: 0, count: 999, origin: 'scheduled' }, // 0 is off + { cap: 2, count: 999, day: '2026-11-06', origin: 'scheduled' }, // yesterday resets + ]) { + const h = harness(options) + const out = await h.step() + assert.equal(out.called, 1, JSON.stringify(options)) + } +}) + +test('a turn already running is not cut off at its later steps', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 1, count: 9, origin: 'scheduled' }) + const out = await h.step({ step: 2 }) + assert.equal(out.called, 1) + assert.notDeepEqual(out.decision, { kind: 'reject' }) +}) + +test('an ordinary DSH Session, not a Bot Session, is never gated', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 1, count: 9, origin: 'scheduled', bot: false }) + assert.equal((await h.step()).called, 1) +}) + +test('a turn is spent once, on its first step, and only for a Bot Session', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 200, count: 3 }) + h.startStep('b', 1) + await delay(0) + assert.deepEqual(h.rt.state.turns, { day: '2026-11-07', count: 4 }) + h.startStep('b', 2) + h.startStep('b', 3) + h.startStep('other', 1) + await delay(0) + assert.deepEqual(h.rt.state.turns, { day: '2026-11-07', count: 4 }) + assert.equal(h.saves.length, 1) +}) + +test('a refused turn is never counted: it starts no step', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 1, count: 1, origin: 'scheduled' }) + const { decision } = await h.step() + assert.deepEqual(decision, { kind: 'reject' }) + await delay(0) + assert.equal(h.rt.state.turns.count, 1) + assert.equal(h.saves.length, 0) +}) + +// The same rules through the real store: an existing team is seeded off, a fresh one at the +// default, and the turn `turns.js` judged the user's is the one that passes the real gate. +function runtime(t, seed) { + const home = mkdtempSync(join(tmpdir(), 'dsb-turn-budget-')) + if (seed !== undefined) writeFileSync(join(home, 'state.json'), JSON.stringify(seed)) + const ctx = fakeContext() + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async sessionId => ({ agent: { id: sessionId, session: { id: sessionId }, inbox: { nextStep: [], nextTurn: [], remove() {} } } }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + provideFlyBot(ctx, rt.flybot) + let seq = 0 + const emit = (id, type, data) => { + for (const listener of ctx.listeners.get('session/event') ?? []) listener({ id }, { type, seq: ++seq, data }) + } + const gate = ctx.listeners.get('agent/pre-step').at(-1) + const step = async (id, next = { kind: 'enter', messages: [] }) => { + let called = 0 + const decision = await gate({ agent: { id }, step: 1, signal: new AbortController().signal }, async () => { called += 1; return next }) + return { decision, called } + } + t.after(() => { try { ctx.dispose() } catch {} rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + return { rt, ctx, home, emit, gate, step } +} + +const TEAM = { + version: 1, + mainBotIds: ['a'], + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 }, b: { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 2 } }, + rooms: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, +} + +test('the store seeds a fresh team at the default and an existing one off until the owner sets it', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const fresh = runtime(t, undefined) + await fresh.rt.load() + assert.equal(fresh.rt.state.prefs.turnBudget, TEAM_TURNS_PER_DAY) + assert.equal(JSON.parse(readFileSync(join(fresh.home, 'state.json'), 'utf8')).prefs.turnBudget, TEAM_TURNS_PER_DAY) + + const saved = runtime(t, { ...TEAM, prefs: {} }) + await saved.rt.load() + assert.equal(saved.rt.state.prefs.turnBudget, 0) + assert.equal(JSON.parse(readFileSync(join(saved.home, 'state.json'), 'utf8')).prefs.turnBudget, 0) +}) + +test('through the real store, a due task is refused at the cap and the user\'s own line still runs', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const { rt, emit, step } = runtime(t, { ...TEAM, prefs: { turnBudget: 1 }, turns: { day: '2026-11-07', count: 1 } }) + await rt.load() + // A due task: DSH appends the message as `schedule`, then the turn starts. + emit('b', 'user/message', { source: { kind: 'schedule' }, content: [{ type: 'text', text: 'Daily briefing' }] }) + emit('b', 'turn/start', { turn: 1 }) + assert.equal(rt.live.get('b').origin, 'scheduled') + const refused = await step('b') + assert.deepEqual(refused.decision, { kind: 'reject' }) + assert.equal(refused.called, 0) + + // The user's own line: DSH's prompt carries `kind: 'user'`, which turns.js records as the owner's. + emit('b', 'turn/end', { turn: 1, reason: { kind: 'blocked' } }) + emit('b', 'user/message', { source: { kind: 'user', rpcId: 'r1' }, content: [{ type: 'text', text: 'how are you' }] }) + emit('b', 'turn/start', { turn: 2 }) + assert.equal(rt.live.get('b').origin, 'owner') + const allowed = await step('b') + assert.equal(allowed.called, 1) +}) + +test('through the real store, the first step of a turn is counted and the day rolls over', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const { rt, emit } = runtime(t, { ...TEAM, prefs: { turnBudget: 200 }, turns: { day: '2026-11-06', count: 42 } }) + await rt.load() + emit('b', 'turn/start', { turn: 1 }) + emit('b', 'step/start', { turn: 1, step: 1 }) + await delay(50) + await rt.savesSettled?.() + assert.deepEqual(rt.state.turns, { day: '2026-11-07', count: 1 }) + assert.equal(JSON.parse(readFileSync(join(rt.home, 'state.json'), 'utf8')).turns.count, 1) +}) diff --git a/core/test/work-dir.test.js b/core/test/work-dir.test.js index c5e9e6c1..66e76f8b 100644 --- a/core/test/work-dir.test.js +++ b/core/test/work-dir.test.js @@ -99,9 +99,16 @@ async function start({ services = {}, workspaces = [] } = {}) { method: 'POST', body: JSON.stringify({ endpoint, payload }), }))).json() const tool = (name, args, sessionId) => ctx.registeredTools.get(name).execute(args, { agent: agentOf(sessionId), name, callId: 'c1' }) - // Runs the parts check, registered last; 'next' means the step goes on. - const preStep = (sessionId, messages = [], step = 1) => ctx.listeners.get('agent/pre-step').at(-1)( - { agent: agentOf(sessionId), messages, step, signal: AbortSignal.timeout(60_000) }, async () => 'next') + // Runs the pre-step gates the way DSH does — the listener registered last answers first, and its + // next() runs the one registered before it. `next` is the answer the step goes on with; a gate + // that refuses returns `{ kind: 'reject' }` and the ones behind it never run. + const preStep = (sessionId, messages = [], step = 1) => { + const chain = ctx.listeners.get('agent/pre-step') + const run = index => index < 0 + ? Promise.resolve({ kind: 'next', messages }) + : chain[index]({ agent: agentOf(sessionId), messages, step, signal: AbortSignal.timeout(60_000) }, () => run(index - 1)) + return run(chain.length - 1).then(decision => decision?.kind === 'next' ? 'next' : decision) + } const state = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) // Waits until the Bot's own chat runs in `dir` and its move has finished. const workingIn = async (botId, dir) => { diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 0ceb9ca5..1835675f 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -406,6 +406,12 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - A line arriving while a turn runs is not held: it is delivered into that run as a **steer** (`mode: 'steer'`, #707) so the running turn reads it at its next step and can change direction. The window flushes what it holds first, in arrival order, and those held lines steer too once a turn is running, so a line can never overtake an older one into the same run; the new line opens its own turn only when no turn runs (`mode: 'queue'`). A Bot-to-Bot `deliver` never waits behind the window — it has its own queue and dedupe (#641). - A group room is not collected: its line must reach the Session at once so a running round can drain it (`group_relay`, #299), so `settleLine` is only reached when `rt.roomOf(sessionId)` is undefined. The pending timer is cleared on dispose. Covered by `core/test/settle.test.js`. +### Team turn budget (#645, owner decision 2026-11-07) + +- `src/host/turn-budget.js` counts **model turns per team per team-local day** and refuses the system-started ones once the cap is reached. The cap is `state.prefs.turnBudget` — a whole number, `0` is off, a malformed or missing value reads as `TEAM_TURNS_PER_DAY` (200). The store seeds it in `adopt()`: a fresh team (no `state.json` yet) starts at 200, and a team saved before the budget existed starts at `0` so upgrading changes nothing until the owner sets a number. `set-prefs` writes the field through `readTurnBudget` (`null` clears back to the default) and `channel.js` refuses anything that is not a non-negative whole number. +- The counter is `state.turns = { day, count }`. A turn spends one turn when its first step starts (`session/event` `step/start`, step 1): one turn costs one however many steps it runs, a turn the gate refused costs none, and the count is per Bot Session (`isBotSession`), so a group member's turn and an own-chat turn both spend. The day is `teamDayKey` — the host's configured `timeZone`, `Asia/Shanghai` when unset — so the boundary needs no timer: a record from another day reads as 0 and the next spend rewrites it. +- The gate is an `agent/pre-step` listener (`prepend`, so it answers before the step is assembled) and it acts only on a turn's first step: at or over the cap, a turn whose recorded verdict is `owner` (`turns.js` / `turn-origin.js`: the user's own message, the answer to their card, a line they wrote in a group, or what the host carries on for that) is dispatched as usual, and every other turn — `scheduled`, `other`, or no verdict at all — answers `{ kind: 'reject' }`. DSH ends that turn as `blocked`: no model request is made and nothing is said to the user; `rt.warn` writes one line naming the count, the cap, the day and the Bot. A turn already running is never cut off at a later step. Covered by `core/test/turn-budget.test.js`. + ### Own-chat reads say what a cap left out (#1906) - `read_own_chat` shows at most `OWN_CHAT_SEARCH` (20) search hits, and `OWN_CHAT_READ` (12) latest messages on a bare read. Both caps stay where they are, but the answer now says what it left out: a capped search ends with `N more not shown; search with narrower words, or read around one of these anchors.`, and a capped bare read's header reads `Your own chat (newest 12 of 15, oldest first)`. Exactly at the cap nothing was left out, so nothing is added. diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index d4eece37..b66e3330 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -42,6 +42,8 @@ Shipped behavior stays what [core/README.md](../../core/README.md) (roles: You / initiative. That is prompt text in `core/src/host/prompt.js`, not a team-mode setting, and it changes the unaddressed default only: a Bot that is addressed answers as before. +**The turn budget (part C of #645, shipped; owner decision 2026-11-07) is a setting, not team mode.** The team counts model turns per team-local day (`state.turns`), carries the cap in `state.prefs.turnBudget` (200 for a fresh team, 0 — off — for a team saved before the field existed), and at or over the cap dispatches only a turn the user started themselves; the system-started ones (a timer, a tidy, a round a Bot started, a kickoff) are refused before a model call. It is one setting of the single-owner deployment and adds no owner scope, cross-owner routing or team-mode gate. Implementation in [core-host](core-host.md#team-turn-budget-645-owner-decision-2026-11-07). + ## Schema foundation (landed, #47) Shipped by the core store (`core/src/host/store.js`, owned by [ds-bot-core](ds-bot-core.md)) as **data records only**. It replaces implicit single-user ownership with explicit stable identities while the product stays single-human: diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index db7b43fa..860f2fcf 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -11840,14 +11840,20 @@ diff --git a/test/install.test.js b/test/install.test.js 'on approval/request (prepend)', 'on agent/inbox/discarded', 'on session/event', -@@ -81,6 +81,7 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { +@@ -81,8 +81,13 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'on agent/pre-step (prepend)', 'on agent/request-error (prepend)', 'on agent/request-error (prepend)', + 'effect ds-bot: archive a part once its job ends', 'on agent/status', 'on llm/stream', ++ // #645: the team turn budget, installed after parts so its gate answers first (it registers ++ // with prepend) and its spend listener sees turns.js's records. ++ 'on session/event', ++ 'on agent/pre-step (prepend)', 'on llm/stream', + 'on agent/status', + 'tool request_secret', @@ -91,12 +91,24 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { 'on llm/stream', 'effect ds-bot: load secrets', @@ -15256,6 +15262,26 @@ diff --git a/test/tool-node.test.js b/test/tool-node.test.js diff --git a/test/work-dir.test.js b/test/work-dir.test.js --- a/test/work-dir.test.js +++ b/test/work-dir.test.js +@@ -99,9 +99,16 @@ async function start({ services = {}, workspaces = [] } = {}) { + method: 'POST', body: JSON.stringify({ endpoint, payload }), + }))).json() + const tool = (name, args, sessionId) => ctx.registeredTools.get(name).execute(args, { agent: agentOf(sessionId), name, callId: 'c1' }) +- // Runs the parts check, registered last; 'next' means the step goes on. +- const preStep = (sessionId, messages = [], step = 1) => ctx.listeners.get('agent/pre-step').at(-1)( +- { agent: agentOf(sessionId), messages, step, signal: AbortSignal.timeout(60_000) }, async () => 'next') ++ // Runs the pre-step gates the way DSH does — the listener registered last answers first, and its ++ // next() runs the one registered before it. `next` is the answer the step goes on with; a gate ++ // that refuses returns `{ kind: 'reject' }` and the ones behind it never run. ++ const preStep = (sessionId, messages = [], step = 1) => { ++ const chain = ctx.listeners.get('agent/pre-step') ++ const run = index => index < 0 ++ ? Promise.resolve({ kind: 'next', messages }) ++ : chain[index]({ agent: agentOf(sessionId), messages, step, signal: AbortSignal.timeout(60_000) }, () => run(index - 1)) ++ return run(chain.length - 1).then(decision => decision?.kind === 'next' ? 'next' : decision) ++ } + const state = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + // Waits until the Bot's own chat runs in `dir` and its move has finished. + const workingIn = async (botId, dir) => { @@ -219,6 +219,32 @@ test('an idle Bot\'s own chat moves to its new folder at once, and back', async assert.match(handoffOf(agentOf('s2')), new RegExp(`because your working folder changed to ${escapeRegExp(team)}\\.`)) }) diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index d78744dc..3e6d1865 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -23,7 +23,7 @@ diff --git a/index.js b/index.js import * as delivery from './src/host/delivery.js' import * as roster from './src/host/roster.js' import * as folders from './src/host/folders.js' -@@ -14,6 +14,9 @@ import * as agents from './src/host/agents.js' +@@ -14,22 +14,35 @@ import * as agents from './src/host/agents.js' import * as prompt from './src/host/prompt.js' import * as timeContext from './src/host/time-context.js' import * as tools from './src/host/tools.js' @@ -33,7 +33,11 @@ diff --git a/index.js b/index.js import * as chatIndex from './src/host/chat-index.js' import * as groups from './src/host/groups.js' import * as approvals from './src/host/approvals.js' -@@ -24,12 +24,21 @@ import * as images from './src/host/images.js' + import * as resume from './src/host/resume.js' + import * as memory from './src/host/memory.js' + import * as parts from './src/host/parts.js' ++import * as turnBudget from './src/host/turn-budget.js' + import * as images from './src/host/images.js' import * as browser from './src/host/browser.js' import * as secrets from './src/host/secrets.js' import * as connectors from './src/host/connectors.js' @@ -60,7 +64,7 @@ diff --git a/index.js b/index.js // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel] ++const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, turnBudget, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, health, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) @@ -797,7 +801,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js diff --git a/src/host/store.js b/src/host/store.js --- a/src/host/store.js +++ b/src/host/store.js -@@ -1,21 +1,76 @@ +@@ -1,21 +1,77 @@ // Store: the saved team (state.json), its migrations, the lock that keeps a second dsh // host on the same data from writing it, and the indexes that map a Session to the Bot // or group chat it belongs to. @@ -813,6 +817,7 @@ diff --git a/src/host/store.js b/src/host/store.js -import { currentColor, normalize, unquote } from './text.js' +import { openJournal } from './journal.js' +import { tempWritable } from './real-dir.js' ++import { TEAM_TURNS_PER_DAY } from './turn-budget.js' +import { codePoints, currentColor, normalize, unquote } from './text.js' + +// The saved team's format version. Version 2 (#47) adds the identity schema: member @@ -1005,10 +1010,17 @@ diff --git a/src/host/store.js b/src/host/store.js return undefined } -@@ -104,6 +104,20 @@ export function install(rt) { +@@ -104,6 +104,27 @@ export function install(rt) { // Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset. if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {} let migrated = !hadCapabilities ++ // Team turn budget (#645, the C half; owner decision 2026-11-07): a fresh team starts at the ++ // default cap, and a team saved before the budget existed stays off (0) until the owner sets a ++ // number. The file itself is what tells them apart, so one line here is one migration. ++ if (state.prefs.turnBudget === undefined) { ++ state.prefs.turnBudget = parsed === null || parsed === undefined ? TEAM_TURNS_PER_DAY : 0 ++ migrated = true ++ } + if (!isRecord(state.projects)) { + state.projects = {} + migrated = true @@ -2574,6 +2586,19 @@ diff --git a/test/fake-ctx.js b/test/fake-ctx.js effect(execute, label) { calls.push(`effect ${label}`) const dispose = execute() +diff --git a/test/legacy-state.test.js b/test/legacy-state.test.js +--- a/test/legacy-state.test.js ++++ b/test/legacy-state.test.js +@@ -51,7 +51,8 @@ test('a team saved with the legacy grok theme opens with the mono theme', async + const { call, stop, home } = open(savedTeam({ theme: 'grok', accent: '#123456' })) + t.after(stop) + const team = (await call('state', {})).value +- assert.deepEqual(team.prefs, { theme: 'mono', accent: '#123456' }) ++ // A team saved before #645 existed is seeded with its budget off (0), not the fresh-team 200. ++ assert.deepEqual(team.prefs, { theme: 'mono', accent: '#123456', turnBudget: 0 }) + assert.equal(JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).prefs.theme, 'mono') + }) + diff --git a/test/update-bot.test.js b/test/update-bot.test.js --- a/test/update-bot.test.js +++ b/test/update-bot.test.js diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 3f37eec6..bc4e3a4a 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -267,7 +267,7 @@ diff --git a/src/client/mark.js b/src/client/mark.js diff --git a/src/host/channel.js b/src/host/channel.js --- a/src/host/channel.js +++ b/src/host/channel.js -@@ -1,10 +1,20 @@ +@@ -1,10 +1,21 @@ // Client channel: the team view the browser renders, and the POST /api/bot endpoints. import { randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -276,6 +276,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js' +import { isFaceRoom } from './room-membership.js' +import { readQuietHours } from '../shared/quiet-hours.js' ++import { readTurnBudget } from './turn-budget.js' +import { faceMentions } from './face-mentions.js' +import { visibleText } from '../shared/preview.js' @@ -540,7 +541,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js function setPrefs(body) { if (body.theme !== undefined) { -@@ -97,28 +97,64 @@ export function install(rt) { +@@ -97,28 +97,72 @@ export function install(rt) { else if (body.motion === 'quiet' || body.motion === 'lively') state.prefs.motion = body.motion else throw invalid('Motion must be quiet, normal or lively') } @@ -569,6 +570,14 @@ diff --git a/src/host/channel.js b/src/host/channel.js + const quiet = readQuietHours(body.quietHours, state.prefs.quietHours) + if (quiet === undefined) delete state.prefs.quietHours + else state.prefs.quietHours = quiet ++ } ++ // Team turn budget (#645, the C half): a whole number of turns per team-local day, `0` off. ++ // Clearing the field is not "off" — it goes back to the default the store seeded — so an ++ // invalid value is refused rather than stored as a number nobody meant. ++ if (body.turnBudget !== undefined) { ++ const budget = readTurnBudget(body.turnBudget) ++ if (budget === undefined) delete state.prefs.turnBudget ++ else state.prefs.turnBudget = budget + } return rt.save() } diff --git a/patches/ds-bot/files/src/host/turn-budget.js b/patches/ds-bot/files/src/host/turn-budget.js new file mode 100644 index 00000000..5c1aae58 --- /dev/null +++ b/patches/ds-bot/files/src/host/turn-budget.js @@ -0,0 +1,87 @@ +// Team turn budget (#645, the C half; owner decision 2026-11-07). A team spends model turns, and +// a team spends too many of them on work nobody asked for: the count is per team-local day, and at +// or over the cap only a turn the user started themselves is dispatched. A timer's turn, a tidy's, +// a round a Bot started and a kickoff are refused before the model — no request, no line to the +// user, one line in the host log. `0` is off. +// +// Two facts keep the count honest. A turn is spent when its own first step starts +// (`session/event` `step/start`, step 1), so a turn of many steps costs one and a turn the gate +// refused never costs any. The gate is `agent/pre-step`, the seam parts.js already refuses a +// step at: `{ kind: 'reject' }` ends the turn before a model call, and DSH reports it as blocked. +// +// Whose turn it is comes from turns.js's recorded verdict (turn-origin.js): `owner` is the user's +// own message or a line they wrote in a group; everything else — `scheduled`, `other`, or a turn +// whose messages carried no verdict — is system-started and waits for the counter to reset. +import { dayKey } from './project-metrics.js' +import { OWNER } from './turn-origin.js' + +// The cap a fresh team starts with, and the day a team counted in before it had a zone of its own. +export const TEAM_TURNS_PER_DAY = 200 +export const TEAM_TURN_ZONE = 'Asia/Shanghai' + +// The cap the team runs with: a whole number, `0` is off, and an unset or malformed field keeps +// the fresh-team default rather than quietly turning the budget off. +export function turnBudgetOf(prefs) { + const value = prefs?.turnBudget + return Number.isSafeInteger(value) && value >= 0 ? value : TEAM_TURNS_PER_DAY +} + +// The settings write (`prefs` on the api): a whole number of turns, `0` for off, `null` to clear +// back to the default. Anything else is refused, never stored as a number nobody meant. +export function readTurnBudget(value) { + if (value === null || value === undefined) return undefined + if (!Number.isSafeInteger(value) || value < 0) throw Object.assign(new Error('Turn budget must be a whole number of turns (0 turns the budget off)'), { code: 'bot/turn-budget' }) + return value +} + +// What the saved counter says about one day: a record from another day is not a count at all, so +// the day boundary needs no timer — it resets the first time it is read or written. +export function turnsUsed(record, day) { + return record?.day === day && Number.isSafeInteger(record.count) && record.count > 0 ? record.count : 0 +} + +// The team's day. The clock the host already keeps (`timeZone`) is the team's zone; a host without +// one counts in Asia/Shanghai, and a zone Intl cannot read falls back to it rather than throwing. +export function teamDayKey(rt, at) { + const wanted = typeof rt?.config?.timeZone === 'string' && rt.config.timeZone !== '' ? rt.config.timeZone : TEAM_TURN_ZONE + try { + new Intl.DateTimeFormat('en-CA', { timeZone: wanted }) + return dayKey(at, wanted) + } catch { + return dayKey(at, TEAM_TURN_ZONE) + } +} + +export function install(rt) { + const { ctx } = rt + const day = () => teamDayKey(rt, Date.now()) + const nameOf = id => rt.botOf(rt.selfOf(id))?.name ?? id + + // One turn, one spend, on its first step. A refused turn never reaches a step and a long turn + // pays once, so the counter measures dispatched turns and not model calls. + ctx.on('session/event', (session, event) => { + const id = session?.id + if (id === undefined || event?.type !== 'step/start' || event.data?.step !== 1) return + if (!rt.isBotSession(id)) return + const today = day() + void rt.mutate(() => { + const used = turnsUsed(rt.state.turns, today) + rt.state.turns = { day: today, count: used + 1 } + }).catch(error => rt.warn('ds-bot: turn budget: a turn was not counted: %s', error)) + }) + + // Before the turn's first step reaches a model: over the cap, only the user's own turn goes on. + ctx.on('agent/pre-step', async ({ agent, step }, next) => { + await rt.load() + if (step !== 1 || !rt.isBotSession(agent.id)) return next() + const cap = turnBudgetOf(rt.state.prefs) + if (cap === 0) return next() + const today = day() + const used = turnsUsed(rt.state.turns, today) + if (used < cap) return next() + const origin = rt.live?.get(agent.id)?.origin + if (origin === OWNER) return next() + rt.warn('ds-bot: turn budget: %d of %d turns used on %s (%s); a %s turn for %s is not dispatched', used, cap, today, TEAM_TURN_ZONE, origin ?? 'system', nameOf(agent.id)) + return { kind: 'reject' } + }, { prepend: true }) +} diff --git a/patches/ds-bot/files/test/turn-budget.test.js b/patches/ds-bot/files/test/turn-budget.test.js new file mode 100644 index 00000000..9e79e7e4 --- /dev/null +++ b/patches/ds-bot/files/test/turn-budget.test.js @@ -0,0 +1,255 @@ +// Team turn budget (#645, the C half; owner decision 2026-11-07). A team's model turns are +// counted per team-local day; at or over the cap only a turn the user started themselves is +// dispatched. The gate answers before a step reaches a model, so "refused" means no request at +// all — that is what these tests pin, on the seam DSH reports as a blocked turn. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { format } from 'node:util' +import { test } from 'node:test' +import { createRuntime, provideFlyBot } from '../index.js' +import { fakeContext } from './fake-ctx.js' +import { + TEAM_TURNS_PER_DAY, TEAM_TURN_ZONE, install, readTurnBudget, teamDayKey, turnBudgetOf, turnsUsed, +} from '../src/host/turn-budget.js' + +// 2026-11-07 13:00 in Asia/Shanghai, so the team-local day is 2026-11-07 while UTC is still 11-07. +const NOW = Date.parse('2026-11-07T05:00:00Z') + +// The module with no store behind it: every read and write is a plain object this test can +// inspect, which is what makes "next was never called" an assertion and not an inference. +function harness({ cap = TEAM_TURNS_PER_DAY, count, day = '2026-11-07', origin = 'scheduled', bot = true, config = {} } = {}) { + const ctx = fakeContext() + const warnings = [] + const saves = [] + const rt = { + ctx, + config, + state: { prefs: { turnBudget: cap }, ...(count === undefined ? {} : { turns: { day, count } }) }, + live: new Map([['b', { turn: 4, origin }]]), + load: async () => {}, + save: async () => {}, + mutate: async (change) => { const result = await change(); saves.push(JSON.parse(JSON.stringify(rt.state))); return result }, + isBotSession: id => bot && id === 'b', + botOf: () => ({ name: 'Writer' }), + selfOf: id => id, + warn: (...args) => warnings.push(format(...args)), + } + install(rt) + const gate = ctx.listeners.get('agent/pre-step')[0] + const events = ctx.listeners.get('session/event')[0] + return { + rt, gate, events, warnings, saves, + // The step-1 pre-step chain as DSH runs it: `next` settles into the step it would take. + step: async ({ id = 'b', step = 1, next } = {}) => { + let called = 0 + const decision = await gate({ agent: { id }, step, signal: new AbortController().signal }, async () => { + called += 1 + return next ?? { kind: 'enter', messages: [{ id: 'm1' }] } + }) + return { decision, called } + }, + startStep: (id = 'b', step = 1) => events({ id }, { type: 'step/start', data: { turn: 1, step } }), + turnStart: (id = 'b') => events({ id }, { type: 'turn/start', data: { turn: 4 } }), + message: (id = 'b', source = { kind: 'user' }) => events({ id }, { type: 'user/message', data: { source, content: [{ type: 'text', text: 'hi' }] } }), + } +} + +test('the cap is a whole number of turns: 200 when unset, 0 is off, a malformed value keeps the default', () => { + assert.equal(turnBudgetOf({}), TEAM_TURNS_PER_DAY) + assert.equal(turnBudgetOf({ turnBudget: 0 }), 0) + assert.equal(turnBudgetOf({ turnBudget: 37 }), 37) + for (const value of [-1, 1.5, '40', null, Number.NaN]) assert.equal(turnBudgetOf({ turnBudget: value }), TEAM_TURNS_PER_DAY) +}) + +test('the settings write takes a whole number or 0, clears on null, and refuses the rest', () => { + assert.equal(readTurnBudget(0), 0) + assert.equal(readTurnBudget(200), 200) + assert.equal(readTurnBudget(null), undefined) + for (const value of [-1, 1.5, '200', Number.NaN, {}]) assert.throws(() => readTurnBudget(value), /whole number of turns/) +}) + +test('the team day is the team clock: Asia/Shanghai by default, the configured zone when set', () => { + const at = Date.parse('2026-11-06T15:59:59.999Z') + assert.equal(teamDayKey({}, at), '2026-11-06') + assert.equal(teamDayKey({}, at + 1), '2026-11-07') + const utcEnd = Date.parse('2026-11-06T23:59:59.999Z') + assert.equal(teamDayKey({ config: { timeZone: 'UTC' } }, utcEnd), '2026-11-06') + assert.equal(teamDayKey({ config: { timeZone: 'UTC' } }, utcEnd + 1), '2026-11-07') + // A zone Intl cannot read counts in the team default instead of throwing. + assert.equal(teamDayKey({ config: { timeZone: 'Not/AZone' } }, NOW), '2026-11-07') + assert.equal(TEAM_TURN_ZONE, 'Asia/Shanghai') +}) + +test('a saved count from another day is not a count', () => { + assert.equal(turnsUsed(undefined, '2026-11-07'), 0) + assert.equal(turnsUsed({ day: '2026-11-06', count: 900 }, '2026-11-07'), 0) + assert.equal(turnsUsed({ day: '2026-11-07', count: 900 }, '2026-11-07'), 900) + assert.equal(turnsUsed({ day: '2026-11-07', count: -3 }, '2026-11-07'), 0) +}) + +test('over the cap a system-started turn is refused before a model call, with one log line', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 2, count: 2, origin: 'scheduled' }) + const { decision, called } = await h.step() + assert.deepEqual(decision, { kind: 'reject' }) + assert.equal(called, 0, 'no model step is taken for a refused turn') + assert.equal(h.warnings.length, 1) + assert.match(h.warnings[0], /turn budget: 2 of 2 turns used on 2026-11-07 \(Asia\/Shanghai\); a scheduled turn for Writer is not dispatched/) +}) + +test('a turn with no recorded origin is system-started too', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 1, count: 1 }) + h.rt.live.set('b', { turn: 4 }) + const { decision, called } = await h.step() + assert.deepEqual(decision, { kind: 'reject' }) + assert.equal(called, 0) +}) + +test('over the cap the user\'s own turn is still dispatched', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 2, count: 9, origin: 'owner' }) + const decision = { kind: 'enter', messages: [{ id: 'm1' }] } + const out = await h.step({ next: decision }) + assert.equal(out.called, 1) + assert.equal(out.decision, decision) +}) + +test('under the cap, at 0, and after the day boundary nothing is refused', async (t) => { + t.mock.method(Date, 'now', () => NOW) + for (const options of [ + { cap: 2, count: 1, origin: 'scheduled' }, // under the cap + { cap: 0, count: 999, origin: 'scheduled' }, // 0 is off + { cap: 2, count: 999, day: '2026-11-06', origin: 'scheduled' }, // yesterday resets + ]) { + const h = harness(options) + const out = await h.step() + assert.equal(out.called, 1, JSON.stringify(options)) + } +}) + +test('a turn already running is not cut off at its later steps', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 1, count: 9, origin: 'scheduled' }) + const out = await h.step({ step: 2 }) + assert.equal(out.called, 1) + assert.notDeepEqual(out.decision, { kind: 'reject' }) +}) + +test('an ordinary DSH Session, not a Bot Session, is never gated', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 1, count: 9, origin: 'scheduled', bot: false }) + assert.equal((await h.step()).called, 1) +}) + +test('a turn is spent once, on its first step, and only for a Bot Session', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 200, count: 3 }) + h.startStep('b', 1) + await delay(0) + assert.deepEqual(h.rt.state.turns, { day: '2026-11-07', count: 4 }) + h.startStep('b', 2) + h.startStep('b', 3) + h.startStep('other', 1) + await delay(0) + assert.deepEqual(h.rt.state.turns, { day: '2026-11-07', count: 4 }) + assert.equal(h.saves.length, 1) +}) + +test('a refused turn is never counted: it starts no step', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const h = harness({ cap: 1, count: 1, origin: 'scheduled' }) + const { decision } = await h.step() + assert.deepEqual(decision, { kind: 'reject' }) + await delay(0) + assert.equal(h.rt.state.turns.count, 1) + assert.equal(h.saves.length, 0) +}) + +// The same rules through the real store: an existing team is seeded off, a fresh one at the +// default, and the turn `turns.js` judged the user's is the one that passes the real gate. +function runtime(t, seed) { + const home = mkdtempSync(join(tmpdir(), 'dsb-turn-budget-')) + if (seed !== undefined) writeFileSync(join(home, 'state.json'), JSON.stringify(seed)) + const ctx = fakeContext() + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async sessionId => ({ agent: { id: sessionId, session: { id: sessionId }, inbox: { nextStep: [], nextTurn: [], remove() {} } } }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + provideFlyBot(ctx, rt.flybot) + let seq = 0 + const emit = (id, type, data) => { + for (const listener of ctx.listeners.get('session/event') ?? []) listener({ id }, { type, seq: ++seq, data }) + } + const gate = ctx.listeners.get('agent/pre-step').at(-1) + const step = async (id, next = { kind: 'enter', messages: [] }) => { + let called = 0 + const decision = await gate({ agent: { id }, step: 1, signal: new AbortController().signal }, async () => { called += 1; return next }) + return { decision, called } + } + t.after(() => { try { ctx.dispose() } catch {} rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + return { rt, ctx, home, emit, gate, step } +} + +const TEAM = { + version: 1, + mainBotIds: ['a'], + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 }, b: { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 2 } }, + rooms: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, +} + +test('the store seeds a fresh team at the default and an existing one off until the owner sets it', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const fresh = runtime(t, undefined) + await fresh.rt.load() + assert.equal(fresh.rt.state.prefs.turnBudget, TEAM_TURNS_PER_DAY) + assert.equal(JSON.parse(readFileSync(join(fresh.home, 'state.json'), 'utf8')).prefs.turnBudget, TEAM_TURNS_PER_DAY) + + const saved = runtime(t, { ...TEAM, prefs: {} }) + await saved.rt.load() + assert.equal(saved.rt.state.prefs.turnBudget, 0) + assert.equal(JSON.parse(readFileSync(join(saved.home, 'state.json'), 'utf8')).prefs.turnBudget, 0) +}) + +test('through the real store, a due task is refused at the cap and the user\'s own line still runs', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const { rt, emit, step } = runtime(t, { ...TEAM, prefs: { turnBudget: 1 }, turns: { day: '2026-11-07', count: 1 } }) + await rt.load() + // A due task: DSH appends the message as `schedule`, then the turn starts. + emit('b', 'user/message', { source: { kind: 'schedule' }, content: [{ type: 'text', text: 'Daily briefing' }] }) + emit('b', 'turn/start', { turn: 1 }) + assert.equal(rt.live.get('b').origin, 'scheduled') + const refused = await step('b') + assert.deepEqual(refused.decision, { kind: 'reject' }) + assert.equal(refused.called, 0) + + // The user's own line: DSH's prompt carries `kind: 'user'`, which turns.js records as the owner's. + emit('b', 'turn/end', { turn: 1, reason: { kind: 'blocked' } }) + emit('b', 'user/message', { source: { kind: 'user', rpcId: 'r1' }, content: [{ type: 'text', text: 'how are you' }] }) + emit('b', 'turn/start', { turn: 2 }) + assert.equal(rt.live.get('b').origin, 'owner') + const allowed = await step('b') + assert.equal(allowed.called, 1) +}) + +test('through the real store, the first step of a turn is counted and the day rolls over', async (t) => { + t.mock.method(Date, 'now', () => NOW) + const { rt, emit } = runtime(t, { ...TEAM, prefs: { turnBudget: 200 }, turns: { day: '2026-11-06', count: 42 } }) + await rt.load() + emit('b', 'turn/start', { turn: 1 }) + emit('b', 'step/start', { turn: 1, step: 1 }) + await delay(50) + await rt.savesSettled?.() + assert.deepEqual(rt.state.turns, { day: '2026-11-07', count: 1 }) + assert.equal(JSON.parse(readFileSync(join(rt.home, 'state.json'), 'utf8')).turns.count, 1) +}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 4dc68db8..3dc892c2 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -69,7 +69,8 @@ "src/host/home-lock.js", "test/fake-ctx.js", "test/blocking-question.test.js", - "test/update-bot.test.js" + "test/update-bot.test.js", + "test/legacy-state.test.js" ], "0003-flybot-api-client": [ "src/client/index.js",