From ed2245cea2f8d1d51762264503949fa99b07ed67 Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Fri, 9 Oct 2026 06:34:45 +0200 Subject: [PATCH 1/3] test(platform): load the parked-run predicates after the service Imported at the top of the board ops test, agent-runs.ts was sorted ahead of the service and bound it to the real project service, so the mocked project lookup never ran and all three cases failed. The test loads the two predicates where it compares them. --- .../backend/domains/tasks/service.ops-indicators.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/platform/backend/domains/tasks/service.ops-indicators.test.ts b/services/platform/backend/domains/tasks/service.ops-indicators.test.ts index 714dd1dbfe..8037172bd6 100644 --- a/services/platform/backend/domains/tasks/service.ops-indicators.test.ts +++ b/services/platform/backend/domains/tasks/service.ops-indicators.test.ts @@ -18,7 +18,6 @@ vi.mock('../projects/service.ts', async (importOriginal) => ({ // After the service: imported first, it leaves the service bound to the // project service without the mock above. -import { parkedRunSql, parkedWaitingReasonSql } from './agent-runs.ts'; import { getTaskOpsIndicators, getTaskOpsIndicatorsForAccessibleProjects, @@ -165,7 +164,10 @@ describe('the board reads every live agent run with its worker and wait', () => "ORDER BY (status = 'running') DESC, started_at_ms, seq", ); // Whether a run waits, and why, is read through the predicate every - // read of a waiting run shares. + // read of a waiting run shares. Loaded here, not at the top: imported + // ahead of the service, it binds the service to the real project service. + const { parkedRunSql, parkedWaitingReasonSql } = + await import('./agent-runs.ts'); expect(read?.values).toEqual([ parkedRunSql(), parkedWaitingReasonSql(), From b93706f7cd0331bdf87eacc4566c84f7076519ad Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Fri, 9 Oct 2026 06:34:46 +0200 Subject: [PATCH 2/3] fix(platform): give the agent worker rules ids of their own The sandbox spec stated SBX-R17 twice: the rule that a workspace outlives its sandbox landed first, and the agent worker rules took R17 to R20 in a branch beside it. The worker rules are SBX-R18 to SBX-R21 now, in the spec, the tasks spec and the tests that hold them. --- .../core/sandbox/session_naming.test.ts | 2 +- .../domains/sandbox/idle-release.test.ts | 2 +- .../backend/domains/sandbox/routes.test.ts | 2 +- .../domains/sandbox/sessions.admission.test.ts | 2 +- .../domains/sandbox/sessions.release.test.ts | 2 +- .../domains/sandbox/sessions.views.test.ts | 4 ++-- .../platform/backend/domains/sandbox/spec.md | 8 ++++---- .../backend/domains/tasks/agent-runs.test.ts | 2 +- .../domains/tasks/agent-workers.test.ts | 18 +++++++++--------- .../backend/domains/tasks/agent-workers.ts | 2 +- .../platform/backend/domains/tasks/spec.md | 2 +- 11 files changed, 23 insertions(+), 23 deletions(-) diff --git a/services/platform/backend/core/sandbox/session_naming.test.ts b/services/platform/backend/core/sandbox/session_naming.test.ts index 6830c1fe3f..4fd683c5cf 100644 --- a/services/platform/backend/core/sandbox/session_naming.test.ts +++ b/services/platform/backend/core/sandbox/session_naming.test.ts @@ -115,7 +115,7 @@ describe('agent worker sessions', () => { } }); - it('never takes a member worker for a standing one [SBX-R20]', () => { + it('never takes a member worker for a standing one [SBX-R21]', () => { for (const worker of [1, 2, 3]) { expect( isStandingProjectAgentSession( diff --git a/services/platform/backend/domains/sandbox/idle-release.test.ts b/services/platform/backend/domains/sandbox/idle-release.test.ts index 215daba9a6..e9d6d18575 100644 --- a/services/platform/backend/domains/sandbox/idle-release.test.ts +++ b/services/platform/backend/domains/sandbox/idle-release.test.ts @@ -149,7 +149,7 @@ describe('idle release worker', () => { ); }); - it('lets only a live run that names the stopped worker keep it [SBX-R18]', async () => { + it('lets only a live run that names the stopped worker keep it [SBX-R19]', async () => { const texts: string[] = []; const query = (strings: TemplateStringsArray) => { texts.push(strings.join('?').replaceAll(/\s+/g, ' ')); diff --git a/services/platform/backend/domains/sandbox/routes.test.ts b/services/platform/backend/domains/sandbox/routes.test.ts index 01670534d9..4a92f0f525 100644 --- a/services/platform/backend/domains/sandbox/routes.test.ts +++ b/services/platform/backend/domains/sandbox/routes.test.ts @@ -239,7 +239,7 @@ describe('sandbox settings read and write authority', () => { ]); }); - it('counts every run waiting for room beside the rows [SBX-R17]', async () => { + it('counts every run waiting for room beside the rows [SBX-R18]', async () => { listViews.mockResolvedValue([]); const counts = { total: 3, diff --git a/services/platform/backend/domains/sandbox/sessions.admission.test.ts b/services/platform/backend/domains/sandbox/sessions.admission.test.ts index 5c697527ec..3594b86ab7 100644 --- a/services/platform/backend/domains/sandbox/sessions.admission.test.ts +++ b/services/platform/backend/domains/sandbox/sessions.admission.test.ts @@ -207,7 +207,7 @@ describe('reserveSessionSlot — each workload against its own limit [SBX-R8]', }); }); -describe('every agent worker holds a slot of its own [SBX-R17]', () => { +describe('every agent worker holds a slot of its own [SBX-R18]', () => { const SECOND_WORKER = { ...AGENT_SESSION, sessionId: 'pa-agent-1-w2' }; it("admits an agent's second worker beside its first while a slot is left", async () => { diff --git a/services/platform/backend/domains/sandbox/sessions.release.test.ts b/services/platform/backend/domains/sandbox/sessions.release.test.ts index 47a4e78a52..646517f367 100644 --- a/services/platform/backend/domains/sandbox/sessions.release.test.ts +++ b/services/platform/backend/domains/sandbox/sessions.release.test.ts @@ -85,7 +85,7 @@ describe('releaseProjectAgentSessionSlot', () => { expect(update?.values).toEqual(['agent-1', 'org-1']); }); - it("stops a worker once its own run ended, whatever the agent's other workers do [SBX-R18]", async () => { + it("stops a worker once its own run ended, whatever the agent's other workers do [SBX-R19]", async () => { const { sql, statements } = fakeSql([{ id: 'row-1' }]); await releaseProjectAgentSessionSlot(sql, ARGS); diff --git a/services/platform/backend/domains/sandbox/sessions.views.test.ts b/services/platform/backend/domains/sandbox/sessions.views.test.ts index 41558016d3..a792da1870 100644 --- a/services/platform/backend/domains/sandbox/sessions.views.test.ts +++ b/services/platform/backend/domains/sandbox/sessions.views.test.ts @@ -186,7 +186,7 @@ describe('listSandboxViewsForOrg', () => { describe('agent workers on the Sandboxes page', () => { const AGENT = '0b7e7a4c-1f7e-4a39-9c55-6f1d3c1f2a10'; - test('names each worker of an agent and the task it works [SBX-R17]', async () => { + test('names each worker of an agent and the task it works [SBX-R18]', async () => { const worker = (sessionId: string, createdAt: number): SessionRow => ({ ...session(sessionId, createdAt), ownerId: AGENT, @@ -242,7 +242,7 @@ describe('agent workers on the Sandboxes page', () => { ]); }); - test('counts every waiting run by its reason, a park without one as unknown [SBX-R17]', async () => { + test('counts every waiting run by its reason, a park without one as unknown [SBX-R18]', async () => { const query = (strings: TemplateStringsArray, ...values: unknown[]) => { const text = strings.join('?'); // A run waits while it is parked, as every read of one tells it. diff --git a/services/platform/backend/domains/sandbox/spec.md b/services/platform/backend/domains/sandbox/spec.md index 97452a7e19..4934496f69 100644 --- a/services/platform/backend/domains/sandbox/spec.md +++ b/services/platform/backend/domains/sandbox/spec.md @@ -128,7 +128,7 @@ limit, because what frees is an empty workspace, not room. Every run of a project agent that works at the same time as another works in a sandbox of its own, a worker. One agent working three tasks at once has three workers. -### SBX-R17 · Every agent worker is one sandbox and holds one agent-worker slot +### SBX-R18 · Every agent worker is one sandbox and holds one agent-worker slot The limit of agent workers counts workers, not agents: one agent working three tasks at once holds three slots. A run that would need one more worker than the limit allows waits for a @@ -137,7 +137,7 @@ slot; a burst of starts opens no more workers than there are slots left. - **Example**: Ada's organization allows 2 agent workers. Scribe works two tasks → 2 of 2 are in use. Lector is started on a task → its run waits until one of Scribe's workers frees. -### SBX-R18 · A worker gives its slot back as soon as its own run ends +### SBX-R19 · A worker gives its slot back as soon as its own run ends A worker stops and frees its slot once no run of its own is left: none working in it, and none that has taken it and not started yet. The agent's other workers do not keep it up. A pinned @@ -148,7 +148,7 @@ until that process has ended. another. "Release notes" finishes → its worker stops and its slot is free at once, while "Changelog" keeps working. -### SBX-R19 · A run takes a free worker before a new one is opened +### SBX-R20 · A run takes a free worker before a new one is opened A starting run takes, in order: its task's previous worker, a worker that is still up, a stopped worker (lowest number first), and only then a new one. When the only free worker is @@ -158,7 +158,7 @@ it. - **Example**: Scribe's workers 1 and 2 are stopped. Ada starts Scribe on a new task → it works in worker 1, and no worker 3 is created. -### SBX-R20 · A member's runs work in that member's own workers +### SBX-R21 · A member's runs work in that member's own workers The runs a member starts work in workers kept for that member and the agent, apart from the workers of the runs editors start, and stay limited to their own task (`SBX-R7`) in whichever diff --git a/services/platform/backend/domains/tasks/agent-runs.test.ts b/services/platform/backend/domains/tasks/agent-runs.test.ts index 5a60c4de30..7a381295eb 100644 --- a/services/platform/backend/domains/tasks/agent-runs.test.ts +++ b/services/platform/backend/domains/tasks/agent-runs.test.ts @@ -1419,7 +1419,7 @@ describe('what every read shows of a waiting run [TASK-R25]', () => { }); }); -describe('wakeAgentParkedAgentRun — a worker that stays up wakes its own family [SBX-R18]', () => { +describe('wakeAgentParkedAgentRun — a worker that stays up wakes its own family [SBX-R19]', () => { const AGENT = '0b7e7a4c-1f7e-4a39-9c55-6f1d3c1f2a10'; const CANDIDATES = 'SELECT id, session_id AS "sessionId" FROM app.project_agent_runs'; diff --git a/services/platform/backend/domains/tasks/agent-workers.test.ts b/services/platform/backend/domains/tasks/agent-workers.test.ts index 16580b53b3..40b3d99153 100644 --- a/services/platform/backend/domains/tasks/agent-workers.test.ts +++ b/services/platform/backend/domains/tasks/agent-workers.test.ts @@ -90,20 +90,20 @@ describe('which worker a run starts in', () => { ).toEqual({ sessionId: w(1), worker: 1 }); }); - it('takes a stopped worker before it opens a new one [SBX-R19]', () => { + it('takes a stopped worker before it opens a new one [SBX-R20]', () => { // Workers 1 and 2 are stopped: the run works in worker 1, no worker 3. expect( chooseWorker(facts({ workers: [worker(2), worker(1)], room: 2 })), ).toEqual({ sessionId: w(1), worker: 1 }); }); - it('takes a worker that is still up before a stopped one [SBX-R19]', () => { + it('takes a worker that is still up before a stopped one [SBX-R20]', () => { expect( chooseWorker(facts({ workers: [worker(1), worker(2, { warm: true })] })), ).toEqual({ sessionId: w(2), worker: 2 }); }); - it('opens the lowest free number, worker 1 again once it is gone [SBX-R19]', () => { + it('opens the lowest free number, worker 1 again once it is gone [SBX-R20]', () => { expect(chooseWorker(facts({ workers: [] }))).toEqual({ sessionId: w(1), worker: 1, @@ -118,13 +118,13 @@ describe('which worker a run starts in', () => { ).toEqual({ sessionId: w(2), worker: 2 }); }); - it('waits for a Destroy rather than opening a worker beside it [SBX-R19]', () => { + it('waits for a Destroy rather than opening a worker beside it [SBX-R20]', () => { expect( chooseWorker(facts({ workers: [worker(1, { destroyPending: true })] })), ).toEqual({ wait: 'destroy_pending' }); }); - it('opens and wakes no worker the organization has no slot for [SBX-R17]', () => { + it('opens and wakes no worker the organization has no slot for [SBX-R18]', () => { // Every slot is held: a stopped worker would need one, a new one too. expect( chooseWorker( @@ -170,7 +170,7 @@ describe('which worker a run starts in', () => { ).toEqual({ sessionId: w(3), worker: 3 }); }); - it("names a member's workers within the member's family [SBX-R20]", () => { + it("names a member's workers within the member's family [SBX-R21]", () => { const base = memberWorkerSessionId(AGENT, 'user-mia', 1); expect( chooseWorker( @@ -395,7 +395,7 @@ describe('claiming a worker', () => { ); }); - it("moves a run whose starter lost the editor role into the member's family [SBX-R20]", async () => { + it("moves a run whose starter lost the editor role into the member's family [SBX-R21]", async () => { const member = memberWorkerSessionId(AGENT, 'user-ada', 1); vi.mocked(sessionIdForAgentRun).mockResolvedValue(member); const { sql } = claimSql({ @@ -408,7 +408,7 @@ describe('claiming a worker', () => { }); }); - it('never moves a member run into the standing family [SBX-R20]', async () => { + it('never moves a member run into the standing family [SBX-R21]', async () => { const member = memberWorkerSessionId(AGENT, 'user-mia', 1); const { sql } = claimSql({ run: { sessionId: member }, @@ -466,7 +466,7 @@ describe('a start that has not waited and a run woken for room [TASK-R25]', () = ); }); - it('still takes a worker that is up, which needs no room [SBX-R19]', async () => { + it('still takes a worker that is up, which needs no room [SBX-R20]', async () => { const { sql } = claimSql({ family: [{ sessionId: w(1), status: 'active', pinned: false }], inFlight: 1, diff --git a/services/platform/backend/domains/tasks/agent-workers.ts b/services/platform/backend/domains/tasks/agent-workers.ts index 642e1a8dbe..ddd6671cde 100644 --- a/services/platform/backend/domains/tasks/agent-workers.ts +++ b/services/platform/backend/domains/tasks/agent-workers.ts @@ -385,7 +385,7 @@ async function readWorkerFacts( // every kick names that one, so counting the name would send a task's // next run away from its own worker, and a burst of starts to new // workers, whenever another start came a moment earlier. The name still - // guards the worker against an idle release (`SBX-R18`). A start by an + // guards the worker against an idle release (`SBX-R19`). A start by an // image that never claims shares worker 1 with a claim while both images // serve; the per-session limit on processes bounds that. const others = await tx<{ sessionId: string }[]>` diff --git a/services/platform/backend/domains/tasks/spec.md b/services/platform/backend/domains/tasks/spec.md index 487b059c1a..6a8ba609e7 100644 --- a/services/platform/backend/domains/tasks/spec.md +++ b/services/platform/backend/domains/tasks/spec.md @@ -308,7 +308,7 @@ on its day, also after it is moved. ## One agent on several tasks An agent is a configuration; each of its runs works in a worker, a sandbox of its own. The -organization's limit of agent workers (`SBX-R17`) decides how many work at once. +organization's limit of agent workers (`SBX-R18`) decides how many work at once. ### TASK-R24 · An agent works each of its running tasks in a worker of its own From 62a6bcd4d75a8554cc7ab8ddfd4ae7bab4ba7453 Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Fri, 9 Oct 2026 06:34:46 +0200 Subject: [PATCH 3/3] test(platform): stop expecting a busy-agent refusal no writer records A start for a busy agent waits for a worker now, so no task writer records an agent_busy refusal any more; the walk's sanity anchor is the circuit breaker alone. The label stays for the rows older runs left. --- services/platform/app/features/tasks/lib/display.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/services/platform/app/features/tasks/lib/display.test.ts b/services/platform/app/features/tasks/lib/display.test.ts index 7fe4fc0f5b..9bf9c037f6 100644 --- a/services/platform/app/features/tasks/lib/display.test.ts +++ b/services/platform/app/features/tasks/lib/display.test.ts @@ -123,7 +123,6 @@ describe('run-admission refusals', () => { const codes = recordedRefusals(); // A walk that found nothing would pass anything. expect(codes.has('task_circuit_breaker')).toBe(true); - expect(codes.has('agent_busy')).toBe(true); expect( [...codes].filter((code) => !TASK_RUN_REFUSAL_LABEL_KEY[code]), ).toEqual([]);