Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/framework/dashboard/components/AiQueue.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ The user wants several queued entries worked on at once — the drain routine's

#### Business logic

Each project's header carries a fan-out button beside a count. Clicking the button starts one agent per open entry, taken from the top of that project's queue, as many as the count says. The count is edited right beside the button, defaults to three, and is kept between one and the same maximum as the routine panel's concurrent-agents setting. With fewer open entries than the count, the batch is just the open entries — the button's label always names the number of agents a click would actually start.
Each project's header carries a fan-out button beside a count. Clicking the button starts one agent per open entry, taken from the top of that project's queue, as many as the count says. The count is edited right beside the button, defaults to three, and is floored at one with no maximum, like the routine panel's concurrent-agents setting. With fewer open entries than the count, the batch is just the open entries — the button's label always names the number of agents a click would actually start.

Each agent of the batch is started exactly as the single play button starts one: pinned to its own entry's raw queue line, unattended. The agents are started one after another, and the first failed start ends the batch — the remaining entries are not started, and the failure is reported under the list the same way a single start's is.

Expand Down
9 changes: 4 additions & 5 deletions packages/framework/dashboard/components/AiQueue.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react'
import type { ProjectQueue } from '../../src/index.js'
import { agentOptionsFromPreferences, MAX_AUTO_PM_CONCURRENCY } from '../../src/client.js'
import { agentOptionsFromPreferences } from '../../src/client.js'
import { FastForward, ListTodo, Loader2, Play } from 'lucide-react'
import { queueEntryLabel } from '../lib/queue-entry.js'
import { usePreferences } from '../lib/preferences.js'
Expand Down Expand Up @@ -148,22 +148,21 @@ export function AiQueue({
<input
type="number"
min={1}
max={MAX_AUTO_PM_CONCURRENCY}
step={1}
value={fanOutCount(q.projectId)}
aria-label="How many agents to spin up"
onChange={event => {
// Clamped like the routine panel's concurrency box: a number input
// Floored like the routine panel's concurrency box: a number input
// still hands back whatever was typed, and an emptied box is mid-edit
// rather than a count — `Number('')` is 0, and the clamp would turn a
// rather than a count — `Number('')` is 0, and the floor would turn a
// cleared field into a saved 1.
const typed = event.target.value.trim()
if (!typed) return
const next = Math.round(Number(typed))
if (!Number.isFinite(next)) return
setFanOutCounts(counts => ({
...counts,
[q.projectId]: Math.min(Math.max(next, 1), MAX_AUTO_PM_CONCURRENCY),
[q.projectId]: Math.max(next, 1),
}))
}}
className="h-7 w-11 shrink-0 rounded border border-border bg-background px-1 text-center text-xs tabular-nums text-foreground"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ See `## User story`.

#### Business logic

A number beside the schedule switch sets how many agents the routines keep going at once while there is queued work, clamped between one and the allowed maximum. A cleared box is treated as mid-edit and saves nothing, so clearing it does not silently store the minimum. When the preference is unset, the number shown is the daemon's own default, so the figure on screen is the figure the daemon would use.
A number beside the schedule switch sets how many agents the routines keep going at once while there is queued work, floored at one and with no maximum. A cleared box is treated as mid-edit and saves nothing, so clearing it does not silently store the minimum. When the preference is unset, the number shown is the daemon's own default, so the figure on screen is the figure the daemon would use.

Under it, a sentence states the consequence: at one agent, work runs only while nothing else is running and the week's allowance is not already spent; above one, up to that many agents are kept going on queued work, still only while the week's allowance is not spent.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ What the tests cover: the "Routine work" card's rows, its Run now paths, and the

**Trigger routine now.** It fires the sweep instead of waiting out the countdown, and stays available with the schedule off, where its hover says auto-run stays off. Its answer is reported: a single project's message plainly, several projects' messages each prefixed by folder name, and "not running the sweep" for a dashboard without one. The sweep-backed Run now clicks report their outcome the same way.

**Concurrent agents.** The box shows the daemon's default until it is set, up to the allowed maximum; typing writes the value clamped to that maximum and to a minimum of one, and emptying the box writes nothing. The sentence under it follows the number rather than promising an idle machine.
**Concurrent agents.** The box shows the daemon's default until it is set and offers no maximum; typing writes the value floored at one — any higher count is written as typed — and emptying the box writes nothing. The sentence under it follows the number rather than promising an idle machine.

## Before modifying/creating SPEC.md files

Expand Down
11 changes: 6 additions & 5 deletions packages/framework/dashboard/components/RoutineWork.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
AUTO_PM_DRAIN_JOB,
AUTO_PM_MAINTENANCE_JOB,
DEFAULT_AUTO_PM_CONCURRENCY,
MAX_AUTO_PM_CONCURRENCY,
} from '../../src/client.js'
import { hoverTooltip } from '../test-utils.js'

Expand Down Expand Up @@ -512,17 +511,19 @@ describe('RoutineWork (#1159)', () => {
const box = (await screen.findByLabelText('Concurrent agents')) as HTMLInputElement
// The default rather than 1, so the number on screen is the number the sweep would use.
expect(box.value).toBe(String(DEFAULT_AUTO_PM_CONCURRENCY))
expect(box.max).toBe(String(MAX_AUTO_PM_CONCURRENCY))
// No maximum: how many agents to run at once is the user's call.
expect(box.max).toBe('')
})

test('typing a concurrency writes it, clamped to the cap', async () => {
test('typing a concurrency writes it, floored at one', async () => {
renderCard()
const box = await screen.findByLabelText('Concurrent agents')
fireEvent.change(box, { target: { value: '5' } })
expect(updatePreferences).toHaveBeenCalledWith({ autoPmConcurrency: 5 })
// The store clamps too, but a number input still hands back whatever was typed into it.
// No upper bound: a big count is written as typed.
fireEvent.change(box, { target: { value: '999' } })
expect(updatePreferences).toHaveBeenCalledWith({ autoPmConcurrency: MAX_AUTO_PM_CONCURRENCY })
expect(updatePreferences).toHaveBeenCalledWith({ autoPmConcurrency: 999 })
// The store floors too, but a number input still hands back whatever was typed into it.
fireEvent.change(box, { target: { value: '0' } })
expect(updatePreferences).toHaveBeenCalledWith({ autoPmConcurrency: 1 })
// An emptied box is not a preference: it must not write NaN into the home file.
Expand Down
8 changes: 3 additions & 5 deletions packages/framework/dashboard/components/RoutineWork.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { AutoPmJob, AutoPmOnly, AutoPmOutcome, ProjectSummary } from '../..
import {
AUTO_PM_ROUTINES,
DEFAULT_AUTO_PM_CONCURRENCY,
MAX_AUTO_PM_CONCURRENCY,
agentOptionsFromPreferences,
} from '../../src/client.js'
import { CalendarClock, ChevronDown, Play } from 'lucide-react'
Expand Down Expand Up @@ -404,19 +403,18 @@ export function RoutineWork({
id="auto-pm-concurrency"
type="number"
min={1}
max={MAX_AUTO_PM_CONCURRENCY}
step={1}
value={concurrency}
onChange={event => {
// Clamped here as well as in the store, because a number input still hands
// Floored here as well as in the store, because a number input still hands
// back whatever was typed. An emptied box is mid-edit rather than a setting:
// it has to be caught by hand, since `Number('')` is 0, not NaN, and the clamp
// it has to be caught by hand, since `Number('')` is 0, not NaN, and the floor
// below would turn a cleared field into a saved 1.
const typed = event.target.value.trim()
if (!typed) return
const next = Math.round(Number(typed))
if (!Number.isFinite(next)) return
updatePreferences({ autoPmConcurrency: Math.min(Math.max(next, 1), MAX_AUTO_PM_CONCURRENCY) })
updatePreferences({ autoPmConcurrency: Math.max(next, 1) })
}}
className="w-16 rounded border border-border bg-background px-2 py-1 text-sm text-foreground"
/>
Expand Down
2 changes: 1 addition & 1 deletion packages/framework/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export { interventionKey, activityKey } from './dashboard/keys.js'
// GitHub reach take an empty backlog for a real one. Pure, and its only import is a type.
export { SeenTracker } from './dashboard/keyed-watcher.js'
export type { ProjectionRead } from './dashboard/projects.js'
export { NOTIFICATION_DEFAULTS, MAX_SPEND_OFFSET, DEFAULT_SPEND_OFFSET, DEFAULT_AUTO_PM_CONCURRENCY, MAX_AUTO_PM_CONCURRENCY, notifies, notifyMethodEnabled, notifyCategoryEnabled, type NotifyMethod, type NotifyCategory } from './preference-defaults.js'
export { NOTIFICATION_DEFAULTS, MAX_SPEND_OFFSET, DEFAULT_SPEND_OFFSET, DEFAULT_AUTO_PM_CONCURRENCY, notifies, notifyMethodEnabled, notifyCategoryEnabled, type NotifyMethod, type NotifyCategory } from './preference-defaults.js'
// The preferences -> run options mapping (#858), shared with the daemon so an unattended agent
// starts with the same settings a launcher-started one would. Pure field logic, no Node imports.
export { agentOptionsFromPreferences, handoffFromPreferences, preferencesFromFileConfig } from './agent-options.js'
Expand Down
6 changes: 3 additions & 3 deletions packages/framework/src/preference-defaults.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ What an unset preference means, and the bounds shared by the controls that write

- **Notifications are a 2×2** - two delivery methods (browser, Discord) crossed with two categories ("needs you", plain activity); a notification is delivered only when its method and its category are both on.
- **Defaults follow reach** - browser and "needs you" fire unless turned off; anything reaching outward (Discord) or merely informative (plain activity) is opt-in.
- **Shared bounds** - the slider that moves the unattended-work spend limit reaches at most 50 percentage points either way from the quota boundary, and defaults to half a day's allowance ahead of it; Auto PM runs 2 agents at once by default, 10 at most.
- **Shared bounds** - the slider that moves the unattended-work spend limit reaches at most 50 percentage points either way from the quota boundary, and defaults to half a day's allowance ahead of it; Auto PM runs 2 agents at once by default.

## Business logic

Expand All @@ -26,11 +26,11 @@ The composition used to be open-coded per call site, which let one site get a ca

#### Business logic

The slider that offsets the unattended-work spend limit from the quota boundary is bounded at ±50 percentage points, and its default position is half a day's worth of the week's allowance ahead of the boundary (100/14 points). The number of agents Auto PM's draining routine may keep going at once defaults to 2 and is capped at 10. These numbers live here because the control that writes each value runs in the browser while the sanitizer that clamps it runs in the daemon — both must import the same number.
The slider that offsets the unattended-work spend limit from the quota boundary is bounded at ±50 percentage points, and its default position is half a day's worth of the week's allowance ahead of the boundary (100/14 points). The number of agents Auto PM's draining routine may keep going at once defaults to 2, with no upper bound. These numbers live here because the control that writes each value runs in the browser while the sanitizer that clamps it runs in the daemon — both must import the same number.

#### Rationale

A default spend limit sitting exactly on the boundary stops unattended work the moment the account is precisely on pace — which is normal jitter, not overspending. The half-day cushion gives it room to breathe without meaningfully loosening the policy. Auto PM's default of 2 (not 1) is the smallest value that makes the overlap feature visible at all while staying conservative about quota.
A default spend limit sitting exactly on the boundary stops unattended work the moment the account is precisely on pace — which is normal jitter, not overspending. The half-day cushion gives it room to breathe without meaningfully loosening the policy. Auto PM's default of 2 (not 1) is the smallest value that makes the overlap feature visible at all while staying conservative about quota. The agent count has no upper bound because how many agents to run at once is the user's call — the week's allowance is what actually paces unattended work.

## Before modifying/creating SPEC.md files

Expand Down
7 changes: 0 additions & 7 deletions packages/framework/src/preference-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,3 @@ export const DEFAULT_SPEND_OFFSET = 100 / (7 * 2)
* number that shows it while staying conservative about quota.
*/
export const DEFAULT_AUTO_PM_CONCURRENCY = 2

/**
* The most agents the routine may be asked to keep going at once (#1204). Like
* {@link MAX_SPEND_OFFSET}, the control that writes the value is in the browser and the sanitizer
* that clamps it is in the daemon, so the bound has to be one number both can import.
*/
export const MAX_AUTO_PM_CONCURRENCY = 10
2 changes: 1 addition & 1 deletion packages/framework/src/registry.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Autonomy:

- **Auto PM** - let the daemon start work by itself. Absent means off: it spends the user's allowance without being asked.
- **routine opt-out** - the routines Auto PM must not fire, named individually. Absent or empty means every routine runs. It lists exceptions rather than selections so that a routine added in a later version is on for everyone, instead of silently never running for whoever saved the setting before it shipped; and it names routines rather than numbering them so reordering them cannot move which one is switched off. The names are stored as written and are not checked against the known routines, so a name from a newer version survives a downgrade instead of being erased by it.
- **routine concurrency** - how many agents a routine may keep going at once on one project. Absent means the standard default; the value is rounded, capped, and floored at one, because zero is what the Auto PM switch itself already means and a hand-edited zero would otherwise wedge the routine while the switch still read as on. Only the draining routine fans out — it takes work off the agent queue, one pinned entry per agent, so several at once do disjoint work. The routines that invent work each rewrite the queue file, so they stay one agent per tick whatever this says.
- **routine concurrency** - how many agents a routine may keep going at once on one project. Absent means the standard default; the value is rounded and floored at one — with no upper bound — because zero is what the Auto PM switch itself already means and a hand-edited zero would otherwise wedge the routine while the switch still read as on. Only the draining routine fans out — it takes work off the agent queue, one pinned entry per agent, so several at once do disjoint work. The routines that invent work each rewrite the queue file, so they stay one agent per tick whatever this says.
- **routine project** - which project the Routine work card's "Run now" button targets. Absent means the first registered project. It is a stored setting rather than card state because the choice decides which repo spends quota and gets branches pushed, and card state forgot it on the most common navigation there is — open an agent, come back — so the next click landed on the user's real project. An id that no longer names a registered project simply falls back.
- **spend offset** - how far the limit for unattended spending sits from the quota boundary, in percentage points. Absent means the standard cushion ahead of the boundary rather than sitting exactly on it. Negative holds unattended work back further; positive lets it borrow into the days still to come. It is an offset rather than an absolute percentage so the limit travels with the boundary as the week goes on, instead of being overtaken by it on the second day. The stored value is rounded and clamped, so a hand-edited file cannot put the limit anywhere the slider could not.

Expand Down
Loading
Loading