From 957a208a4088d87fd9b8487120230a2e5a3e9050 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sun, 13 Sep 2026 22:12:55 +0300 Subject: [PATCH 1/5] The daemon reads no queue: it starts /work-queue when the agent-data branch moves The drain half of Auto PM goes. The daemon no longer reads the agent queue, claims tickets for a run, pins an entry into the prompt, or deletes the entry at settle. It watches the head of the agent-data branch instead: a commit no daemon wrote starts one agent told /work-queue, the routine skill this package now ships and links into every checkout. The agent composes the tickets and queue skills itself. Its own commits move the branch again, so the daemon fires again as the run ends; a run that finds nothing queued moves nothing, and the chain stops. Then the rotation gets its turn. One heartbeat a day is the belt. Every commit the daemon writes on its own (a run's record, a routine lock, a plan claim) goes through one funnel that signs it with a `Daemon: ` trailer, so a record never starts the next run and two daemons sharing a branch never fire at each other. Gone with it: the drain preset and its recognition, todo-loop.ts and the merge gate that read the session's TODO file, the ticket start option, the ticket event and the card's ticket field, the (fix #N) title, the PATH entries and the three skill links in every checkout, AGENT_ID as an import from the tickets skill, and the tickets and queue sync calls, replaced by one pull of agent-data. The Overview's implementing lane goes with the ticket field; the dashboard half brings it back from the claim. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DAh91ACHayiEsgT1k2i8Nf --- .../dashboard/components/DashboardPage.tsx | 2 +- .../dashboard/components/HotTickets.test.tsx | 59 +- .../dashboard/components/HotTickets.tsx | 33 +- .../dashboard/components/RoutineWork.test.tsx | 62 +- .../dashboard/components/RoutineWork.tsx | 57 +- .../dashboard/components/TicketsPage.test.tsx | 7 +- .../dashboard/components/TicketsPage.tsx | 4 +- .../components/TicketsPanel.test.tsx | 9 +- .../dashboard/components/TicketsPanel.tsx | 9 +- packages/framework/package.json | 3 +- .../framework/prompts/presets/drain_queue.md | 1 - packages/framework/skills/work-queue/SKILL.md | 7 + packages/framework/src/agent-id.ts | 7 + packages/framework/src/agent-telemetry.ts | 2 +- packages/framework/src/agent.test.ts | 73 +- packages/framework/src/agent.ts | 36 +- packages/framework/src/auto-pm.test.ts | 1262 ++++++++--------- packages/framework/src/auto-pm.ts | 668 ++++----- packages/framework/src/await-gate.ts | 7 +- packages/framework/src/cli.test.ts | 17 +- packages/framework/src/cli.ts | 67 +- packages/framework/src/client.ts | 9 +- packages/framework/src/daemon-runtime.test.ts | 27 +- packages/framework/src/daemon-runtime.ts | 62 +- .../framework/src/daemon-services.test.ts | 249 ++-- packages/framework/src/daemon-services.ts | 139 +- .../framework/src/daemon-workspace.test.ts | 38 +- packages/framework/src/daemon-writes.test.ts | 74 + packages/framework/src/daemon-writes.ts | 55 + packages/framework/src/daemon.test.ts | 7 + packages/framework/src/daemon.ts | 7 +- .../src/dashboard-rpc/control.test.ts | 54 - .../framework/src/dashboard-rpc/control.ts | 15 +- .../framework/src/dashboard-rpc/quota.test.ts | 4 +- packages/framework/src/dashboard-rpc/quota.ts | 12 +- .../src/dashboard/agent-handoff.test.ts | 11 +- .../framework/src/dashboard/agent-handoff.ts | 18 +- .../framework/src/dashboard/overview.test.ts | 54 - packages/framework/src/dashboard/overview.ts | 48 +- packages/framework/src/dashboard/queue.ts | 2 +- packages/framework/src/dashboard/types.ts | 14 +- .../e2e/story-projects-and-settings.test.ts | 4 +- .../src/e2e/story-tickets-and-queue.test.ts | 38 +- packages/framework/src/events.test.ts | 4 - packages/framework/src/events.ts | 16 +- packages/framework/src/preset-catalog.test.ts | 8 +- packages/framework/src/preset-catalog.ts | 25 +- packages/framework/src/registry.ts | 2 +- .../framework/src/store/agent-store.test.ts | 10 - packages/framework/src/store/agent-store.ts | 11 - .../framework/src/store/run-record.test.ts | 3 +- packages/framework/src/store/run-record.ts | 8 +- packages/framework/src/system-prompt.ts | 2 +- packages/framework/src/terminal.ts | 4 - packages/framework/src/todo-loop.test.ts | 350 ----- packages/framework/src/todo-loop.ts | 233 --- packages/framework/src/turn-gate.ts | 4 +- packages/skill-branches/src/checkout.ts | 5 +- packages/skill-branches/src/skill-links.ts | 9 +- 59 files changed, 1414 insertions(+), 2613 deletions(-) delete mode 100644 packages/framework/prompts/presets/drain_queue.md create mode 100644 packages/framework/skills/work-queue/SKILL.md create mode 100644 packages/framework/src/daemon-writes.test.ts create mode 100644 packages/framework/src/daemon-writes.ts delete mode 100644 packages/framework/src/todo-loop.test.ts delete mode 100644 packages/framework/src/todo-loop.ts diff --git a/packages/framework/dashboard/components/DashboardPage.tsx b/packages/framework/dashboard/components/DashboardPage.tsx index acf0b9f3d..f86625b11 100644 --- a/packages/framework/dashboard/components/DashboardPage.tsx +++ b/packages/framework/dashboard/components/DashboardPage.tsx @@ -79,7 +79,7 @@ export function DashboardPage({ that fires one now. */} - + ) diff --git a/packages/framework/dashboard/components/HotTickets.test.tsx b/packages/framework/dashboard/components/HotTickets.test.tsx index 499cbd262..9cc3d16a4 100644 --- a/packages/framework/dashboard/components/HotTickets.test.tsx +++ b/packages/framework/dashboard/components/HotTickets.test.tsx @@ -26,7 +26,7 @@ const ht = (file: string, projectName: string, bucket: HotBucket, over: Record { test('with no hot tickets it names the empty lanes rather than claiming no tickets exist', async () => { onHotTickets.mockResolvedValue([]) - render( {}} onSelectAgent={vi.fn()} />) + render( {}} />) await waitFor(() => expect(screen.getByText('Nothing in progress, queued, or high priority.')).toBeTruthy()) }) @@ -37,7 +37,7 @@ describe('HotTickets (#1112)', () => { ht('c.md', 'alpha', 'ai-queue'), ]) let picked: string | null = null - render( (picked = id)} onSelectAgent={vi.fn()} />) + render( (picked = id)} />) await waitFor(() => expect(screen.getByText('a')).toBeTruthy()) expect(screen.getByText('In progress')).toBeTruthy() expect(screen.getByText('AI Queue')).toBeTruthy() @@ -45,56 +45,12 @@ describe('HotTickets (#1112)', () => { fireEvent.click(screen.getByText('b')) expect(picked).toBe('beta') }) - - test('a ticket a run is implementing right now says so, over its plan/spike (#1117)', async () => { - onHotTickets.mockResolvedValue([ - { ...ht('a.md', 'alpha', 'in-progress', { planned: true }), agentId: 'run-7' }, - ht('b.md', 'alpha', 'in-progress', { planned: true }), - ]) - render( {}} onSelectAgent={vi.fn()} />) - await waitFor(() => expect(screen.getByText('a')).toBeTruthy()) - // Live work outranks the mark older work left behind, so the same lane can say which is which. - expect(screen.getByText('implementing')).toBeTruthy() - expect(screen.getByText('planned')).toBeTruthy() - }) - - test('a ticket being implemented with no plan or spike still gets a tag (#1117)', async () => { - // The gap the agent link opens up: in-progress used to imply planned-or-spiked, so a bare - // implementing ticket would have shown an unexplained row. - onHotTickets.mockResolvedValue([{ ...ht('a.md', 'alpha', 'in-progress'), agentId: 'run-7' }]) - render( {}} onSelectAgent={vi.fn()} />) - await waitFor(() => expect(screen.getByText('implementing')).toBeTruthy()) - }) -}) - -describe('a hot ticket that names a run opens that run', () => { - test('an implemented ticket goes to its session, not its project home', async () => { - // The in-progress lane exists because an agent said it is implementing this ticket (#1117), so - // the session it names is what the row is reporting on. - onHotTickets.mockResolvedValue([{ ...ht('a.md', 'alpha', 'in-progress'), agentId: 'run-9' }]) - const onSelectAgent = vi.fn() - const onSelectProject = vi.fn() - render() - fireEvent.click(await screen.findByText('a')) - expect(onSelectAgent).toHaveBeenCalledWith('alpha', 'run-9') - expect(onSelectProject).not.toHaveBeenCalled() - }) - - test('a ticket with no run still goes to its project', async () => { - onHotTickets.mockResolvedValue([ht('b.md', 'beta', 'ai-queue')]) - const onSelectAgent = vi.fn() - const onSelectProject = vi.fn() - render() - fireEvent.click(await screen.findByText('b')) - expect(onSelectProject).toHaveBeenCalledWith('beta') - expect(onSelectAgent).not.toHaveBeenCalled() - }) }) describe('a hot ticket with no run prefills the launcher it lands on', () => { test('the click carries the ticket over as a composer draft', async () => { onHotTickets.mockResolvedValue([ht('b.md', 'beta', 'ai-queue')]) - render() + render() fireEvent.click(await screen.findByText('b')) // Without this the row was a dead end: the launcher came up empty and the one fact the row // carried — which ticket — was dropped. Composer takes this at mount (#1066). @@ -105,13 +61,4 @@ describe('a hot ticket with no run prefills the launcher it lands on', () => { // The title is prose the agent would have to search for; the file is the ticket's identity. expect(workOnTicketDraft('add-oauth.md')).toContain('tickets/add-oauth.md') }) - - test('a ticket that opens a live session leaves no draft behind', async () => { - onHotTickets.mockResolvedValue([{ ...ht('a.md', 'alpha', 'in-progress'), agentId: 'run-9' }]) - render() - fireEvent.click(await screen.findByText('a')) - // That click goes to a session, not a launcher, so a draft stashed here would surface later - // on the next unrelated visit to one. - expect(takePendingDraft()).toBeNull() - }) }) diff --git a/packages/framework/dashboard/components/HotTickets.tsx b/packages/framework/dashboard/components/HotTickets.tsx index 18cc35564..5ff7807bc 100644 --- a/packages/framework/dashboard/components/HotTickets.tsx +++ b/packages/framework/dashboard/components/HotTickets.tsx @@ -52,14 +52,7 @@ const LEFT_LANES: LaneDef[] = [ ] const RIGHT_LANES: LaneDef[] = [{ key: 'high-priority', label: 'High priority', dot: 'bg-info' }] -export function HotTickets({ - onSelectProject, - onSelectAgent, -}: { - onSelectProject: (id: string) => void - /** A ticket an agent is implementing knows which run (#1117), so its row opens that session. */ - onSelectAgent: (id: string, agentId: string) => void -}) { +export function HotTickets({ onSelectProject }: { onSelectProject: (id: string) => void }) { const { value: tickets } = usePolled(onHotTickets, EMPTY, 10_000, []) const renderLane = (lane: LaneDef) => ( @@ -68,7 +61,6 @@ export function HotTickets({ lane={lane} tickets={tickets.filter(t => t.bucket === lane.key)} onSelectProject={onSelectProject} - onSelectAgent={onSelectAgent} /> ) @@ -100,12 +92,10 @@ function Lane({ lane, tickets, onSelectProject, - onSelectAgent, }: { lane: LaneDef tickets: HotTicket[] onSelectProject: (id: string) => void - onSelectAgent: (id: string, agentId: string) => void }) { const empty = tickets.length === 0 return ( @@ -128,9 +118,8 @@ function Lane({ render={