From 9b579a24acf2c5881387f9eeb287e7424e58c522 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:27:19 +0000 Subject: [PATCH 1/3] take a task the agent never finished as left open after 20 minutes without activity, not abandoned after a day --- packages/api/src/activity/route.test.ts | 12 +- packages/api/src/activity/route.ts | 14 +- packages/api/src/agent-side/route.ts | 2 +- packages/api/src/agent-side/tasks.test.ts | 79 ++++++---- packages/api/src/agent-side/tasks.ts | 39 ++--- packages/api/src/agent-side/tools.test.ts | 4 +- packages/api/src/mcp/route.test.ts | 2 +- .../migration.sql | 22 +++ packages/db/prisma/schema.prisma | 10 +- packages/db/scripts/seed.ts | 42 ++++-- packages/db/src/audit.ts | 2 +- packages/db/src/task.ts | 136 +++++++++++------- packages/docs/content/agents/mcp.mdx | 8 +- packages/utils/src/tasks.test.ts | 22 +-- packages/utils/src/tasks.ts | 23 +-- packages/web/src/agent-loaders.ts | 4 +- packages/web/src/client/agent-client.ts | 2 +- packages/web/src/client/schemas.ts | 6 +- .../web/src/components/activity-timeline.tsx | 6 +- .../web/src/components/audit-list.test.tsx | 2 +- .../web/src/components/goal-and-outcome.tsx | 2 +- packages/web/src/components/run-timeline.tsx | 8 +- .../web/src/components/timeline-chart.tsx | 16 +-- .../web/src/lib/activity-timeline.test.ts | 29 ++-- packages/web/src/lib/activity-timeline.ts | 2 +- packages/web/src/lib/agent-errors.ts | 2 +- packages/web/src/lib/agent-task.test.ts | 2 +- packages/web/src/lib/agent-task.ts | 2 +- packages/web/src/lib/run-timeline.test.ts | 16 +-- packages/web/src/lib/run-timeline.ts | 4 +- packages/web/src/lib/timeline-window.test.ts | 34 ++--- packages/web/src/lib/timeline-window.ts | 38 ++--- packages/web/src/lib/timeline.ts | 4 +- packages/web/src/pages/activity-page.tsx | 8 +- .../web/src/pages/agent/agent-task-page.tsx | 2 +- packages/web/src/pages/home-page.test.tsx | 4 +- packages/web/src/pages/task-page.tsx | 2 +- 37 files changed, 364 insertions(+), 248 deletions(-) create mode 100644 packages/db/prisma/migrations/v034_rename_task_last_started_at_to_last_active_at/migration.sql diff --git a/packages/api/src/activity/route.test.ts b/packages/api/src/activity/route.test.ts index b6de0136..d0e930de 100644 --- a/packages/api/src/activity/route.test.ts +++ b/packages/api/src/activity/route.test.ts @@ -52,7 +52,7 @@ describe('handleActivityTimelineRoute', () => { createdAt: new Date('2026-10-15T20:00:00Z'), finishedAt: null, outcome: null, - lastStartedAt: new Date('2026-10-15T20:00:00Z'), + lastActiveAt: new Date('2026-10-15T20:00:00Z'), }; const entry = { id: 'entry-1', @@ -97,7 +97,7 @@ describe('handleActivityTimelineRoute', () => { startedAt: '2026-10-15T20:00:00.000Z', finishedAt: null, outcome: null, - lastStartedAt: '2026-10-15T20:00:00.000Z', + lastActiveAt: '2026-10-15T20:00:00.000Z', }, ], entries: [{...fields, at: createdAt.toISOString(), userName: null}], @@ -125,7 +125,7 @@ describe('handleActivityTimelineRoute', () => { createdAt: new Date(createdAt), finishedAt: null, outcome: null, - lastStartedAt: new Date(createdAt), + lastActiveAt: new Date(createdAt), }); const notes = task('notes', null, '2026-10-01T10:00:00Z'); const pricing = task('pricing', null, '2026-10-01T10:20:00Z'); @@ -212,7 +212,7 @@ describe('handleListActivityRoute', () => { createdAt: new Date('2026-10-15T09:00:00Z'), finishedAt: new Date('2026-10-15T09:30:00Z'), outcome: 'Archived 12 newsletters', - lastStartedAt: new Date('2026-10-15T09:00:00Z'), + lastActiveAt: new Date('2026-10-15T09:00:00Z'), }, ]), ); @@ -269,7 +269,7 @@ describe('handleListActivityRoute', () => { startedAt: '2026-10-15T09:00:00.000Z', finishedAt: '2026-10-15T09:30:00.000Z', outcome: 'Archived 12 newsletters', - lastStartedAt: '2026-10-15T09:00:00.000Z', + lastActiveAt: '2026-10-15T09:00:00.000Z', }, ], }); @@ -287,7 +287,7 @@ describe('handleTaskRunRoute', () => { createdAt: new Date(start), finishedAt: finish === null ? null : new Date(finish), outcome: null, - lastStartedAt: new Date(start), + lastActiveAt: new Date(start), }); // Notes, with a sub-task and one under that; calendar, handed on from a task the person does not see. const notes = task('notes', '2026-10-15T20:11:00Z', null); diff --git a/packages/api/src/activity/route.ts b/packages/api/src/activity/route.ts index 67a79445..f334931a 100644 --- a/packages/api/src/activity/route.ts +++ b/packages/api/src/activity/route.ts @@ -53,17 +53,17 @@ export type ActivityTaskResponse = { goal: string; via: 'web' | 'mcp'; startedAt: string; - // Null while the agent hasn't called it finished, abandoned (@proxy/utils/tasks) or not. + // Null while the agent hasn't called it finished, left open (@proxy/utils/tasks) or not. finishedAt: string | null; // What was done or found, as the agent said when it finished; null where it said nothing. outcome: string | null; - // When it, or the last of its sub-tasks at any depth, was started: one never finished is taken - // as abandoned a day after (@proxy/utils/tasks). - lastStartedAt: string; + // When it, or one of its sub-tasks at any depth, was last started or had a request made for it: + // one never finished is taken as left open 20 minutes after (@proxy/utils/tasks). + lastActiveAt: string; }; -function toActivityTask({createdAt, finishedAt, lastStartedAt, ...task}: AgentTaskRow): ActivityTaskResponse { - return {...task, startedAt: createdAt.toISOString(), finishedAt: finishedAt?.toISOString() ?? null, lastStartedAt: lastStartedAt.toISOString()}; +function toActivityTask({createdAt, finishedAt, lastActiveAt, ...task}: AgentTaskRow): ActivityTaskResponse { + return {...task, startedAt: createdAt.toISOString(), finishedAt: finishedAt?.toISOString() ?? null, lastActiveAt: lastActiveAt.toISOString()}; } // `?agentId=proxy` is what Personal Agent Proxy did itself. @@ -208,7 +208,7 @@ export function handleActivityTimelineRoute(request: AuthenticatedRequest): Prom const ids = tasks.map((task) => task.id); // Only the stretch read shares out the most requests, so none goes to what came after it. const rows = $(await listTaskEntries(viewer, ids, MAX_TIMELINE_ENTRIES, {from, to})); - // Read back from before now, a task given up on may have had requests made after the stretch: + // Read back from before now, a task left open may have had requests made after the stretch: // the last of them says when it ended (@/lib/timeline-window's endsOf in the web app). const later = to < now ? $(await listLastTaskEntries(viewer, ids, to)) : []; return { diff --git a/packages/api/src/agent-side/route.ts b/packages/api/src/agent-side/route.ts index 1c60f85d..09584cfa 100644 --- a/packages/api/src/agent-side/route.ts +++ b/packages/api/src/agent-side/route.ts @@ -29,7 +29,7 @@ export function handleAgentListTasksRoute(request: AgentRequest): Promise> { return getAgentOpenTask(request.agent, request.params.taskId ?? ''); diff --git a/packages/api/src/agent-side/tasks.test.ts b/packages/api/src/agent-side/tasks.test.ts index 9979bbca..cee1685d 100644 --- a/packages/api/src/agent-side/tasks.test.ts +++ b/packages/api/src/agent-side/tasks.test.ts @@ -5,8 +5,9 @@ const createAgentTask = mock(); const getAgentTask = mock(); const finishAgentTask = mock(); const listOpenAgentTasks = mock(); +const touchAgentTask = mock(); -mock.module('@proxy/db/task', () => ({createAgentTask, getAgentTask, finishAgentTask, listOpenAgentTasks})); +mock.module('@proxy/db/task', () => ({createAgentTask, getAgentTask, finishAgentTask, listOpenAgentTasks, touchAgentTask})); const agent = {agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'claude', name: 'Claude', orgSlug: 'org-1', experiments: []}; const caller = {agent, via: 'mcp' as const}; @@ -20,7 +21,7 @@ const row = { createdAt: new Date('2026-10-16T09:00:00Z'), finishedAt: null, outcome: null, - lastStartedAt: new Date('2026-10-16T09:00:00Z'), + lastActiveAt: new Date('2026-10-16T09:00:00Z'), }; const finishedRow = {...row, finishedAt: new Date('2026-10-16T09:30:00Z'), outcome: 'Archived 12 newsletters'}; // The agent's task a sub-task is started for. @@ -32,6 +33,7 @@ beforeEach(() => { createAgentTask.mockResolvedValue(Ok(row)); getAgentTask.mockResolvedValue(Ok(row)); finishAgentTask.mockResolvedValue(Ok(finishedRow)); + touchAgentTask.mockResolvedValue(Ok(true)); }); describe('startAgentTask', () => { @@ -61,8 +63,8 @@ describe('startAgentTask', () => { }); describe('with a parent', () => { - // Three hours after the parent started. - beforeEach(() => setSystemTime(new Date('2026-10-16T12:00:00Z'))); + // Ten minutes after the parent started. + beforeEach(() => setSystemTime(new Date('2026-10-16T09:10:00Z'))); afterEach(() => setSystemTime()); test('starts a sub-task of one of the agent’s tasks under way, answering with the parent', async () => { @@ -94,19 +96,19 @@ describe('startAgentTask', () => { expect(createAgentTask).not.toHaveBeenCalled(); }); - test('a parent finished or abandoned is refused as a conflict, saying to start a new task', async () => { + test('a parent finished or left open is refused as a conflict, saying to start a new task', async () => { const {startAgentTask} = await import('./tasks'); getAgentTask.mockResolvedValue(Ok({...parentRow, finishedAt: new Date('2026-10-16T10:00:00Z')})); const finished = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'}); - getAgentTask.mockResolvedValue(Ok({...parentRow, lastStartedAt: new Date('2026-10-15T11:00:00Z')})); - const abandoned = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'}); + getAgentTask.mockResolvedValue(Ok({...parentRow, lastActiveAt: new Date('2026-10-16T08:50:00Z')})); + const leftOpen = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'}); expect(finished.unwrapErr()).toEqual({ kind: 'conflict', statusCode: 409, message: 'Task task-0 (Plan the week) is finished. Start a new task for what you are doing now, and give its id as task.', }); - expect(abandoned.unwrapErr()).toMatchObject({kind: 'conflict'}); + expect(leftOpen.unwrapErr()).toMatchObject({kind: 'conflict'}); expect(createAgentTask).not.toHaveBeenCalled(); }); @@ -162,25 +164,35 @@ describe('finishAgentTask', () => { expect(result.unwrapErr()).toMatchObject({kind: 'not_found', resource: 'task', id: 'task-9'}); }); - test('a task abandoned, left unfinished, is refused as a conflict', async () => { + test('a task left open, never finished, is refused as a conflict', async () => { finishAgentTask.mockResolvedValue(Ok(row)); const {finishAgentTask: finish} = await import('./tasks'); const result = await finish(caller, {task: 'task-1', outcome: 'Archived 12 newsletters'}); - expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('taken as abandoned')}); + expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('taken as left open')}); }); }); describe('requireOpenTask', () => { - // Three hours after the task started. - beforeEach(() => setSystemTime(new Date('2026-10-16T12:00:00Z'))); + // Ten minutes after the task started. + beforeEach(() => setSystemTime(new Date('2026-10-16T09:10:00Z'))); afterEach(() => setSystemTime()); - test('answers the agent’s task while it is under way', async () => { + test('answers the agent’s task while it is under way, the call counting as something done for it', async () => { const {requireOpenTask} = await import('./tasks'); const result = await requireOpenTask(agent, 'task-1'); expect(getAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', 'task-1'); + expect(touchAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', 'task-1'); + expect(result.unwrap()).toBe(row); + }); + + test('a call within a minute of the last thing done for the task is not written again', async () => { + const {requireOpenTask} = await import('./tasks'); + setSystemTime(new Date('2026-10-16T09:00:59Z')); + const result = await requireOpenTask(agent, 'task-1'); + + expect(touchAgentTask).not.toHaveBeenCalled(); expect(result.unwrap()).toBe(row); }); @@ -197,40 +209,51 @@ describe('requireOpenTask', () => { statusCode: 409, message: 'Task task-1 (Triage inbox) is finished. Start a new task for what you are doing now, and give its id as task.', }); + expect(touchAgentTask).not.toHaveBeenCalled(); }); - test('a task never finished is abandoned a day after it, or the last of its sub-tasks, started', async () => { + test('a task never finished is left open 20 minutes after anything was last done for it or its sub-tasks', async () => { const {requireOpenTask} = await import('./tasks'); - setSystemTime(new Date('2026-10-17T08:59:59Z')); + setSystemTime(new Date('2026-10-16T09:19:59Z')); const lastSecond = await requireOpenTask(agent, 'task-1'); - setSystemTime(new Date('2026-10-17T09:00:00Z')); - const abandoned = await requireOpenTask(agent, 'task-1'); + touchAgentTask.mockClear(); + setSystemTime(new Date('2026-10-16T09:20:00Z')); + const leftOpen = await requireOpenTask(agent, 'task-1'); expect(lastSecond.unwrap()).toBe(row); - expect(abandoned.unwrapErr()).toEqual({ + expect(leftOpen.unwrapErr()).toEqual({ kind: 'conflict', statusCode: 409, message: - 'Task task-1 (Triage inbox) was never finished, and neither it nor any of its sub-tasks was started in the last day, so it is taken as abandoned. Start a new task for what you are doing now, and give its id as task.', + 'Task task-1 (Triage inbox) was never finished, and nothing was done for it or its sub-tasks in the last 20 minutes, so it is taken as left open. Start a new task for what you are doing now, and give its id as task.', }); + expect(touchAgentTask).not.toHaveBeenCalled(); }); - test('a task started over a day ago is under way while a sub-task of it started within the day', async () => { + test('a task started long ago is under way while something was done for it in the last 20 minutes', async () => { const {requireOpenTask} = await import('./tasks'); - const withSubTask = {...row, lastStartedAt: new Date('2026-10-17T05:00:00Z')}; - getAgentTask.mockResolvedValue(Ok(withSubTask)); - setSystemTime(new Date('2026-10-17T12:00:00Z')); + const active = {...row, lastActiveAt: new Date('2026-10-16T14:00:00Z')}; + getAgentTask.mockResolvedValue(Ok(active)); + setSystemTime(new Date('2026-10-16T14:15:00Z')); const underWay = await requireOpenTask(agent, 'task-1'); - setSystemTime(new Date('2026-10-18T05:00:00Z')); - const abandoned = await requireOpenTask(agent, 'task-1'); + setSystemTime(new Date('2026-10-16T14:20:00Z')); + const leftOpen = await requireOpenTask(agent, 'task-1'); + + expect(underWay.unwrap()).toBe(active); + expect(leftOpen.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('taken as left open')}); + }); + + test('a task finished or left open between the check and the call is refused as a conflict', async () => { + touchAgentTask.mockResolvedValue(Ok(false)); + const {requireOpenTask} = await import('./tasks'); + const result = await requireOpenTask(agent, 'task-1'); - expect(underWay.unwrap()).toBe(withSubTask); - expect(abandoned.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('taken as abandoned')}); + expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('taken as left open')}); }); }); describe('getAgentOpenTask', () => { - beforeEach(() => setSystemTime(new Date('2026-10-16T12:00:00Z'))); + beforeEach(() => setSystemTime(new Date('2026-10-16T09:10:00Z'))); afterEach(() => setSystemTime()); test('answers the task while it is under way, and refuses it as a call for it would be', async () => { diff --git a/packages/api/src/agent-side/tasks.ts b/packages/api/src/agent-side/tasks.ts index abe64fca..f1b470ca 100644 --- a/packages/api/src/agent-side/tasks.ts +++ b/packages/api/src/agent-side/tasks.ts @@ -4,7 +4,7 @@ import {z} from 'zod'; import {ApiErr, type ApiError, Do, parseSchema, requirePresent} from '@proxy/utils'; import {taskEndedAt} from '@proxy/utils/tasks'; import type {SignedInAgent} from '@proxy/db/agent'; -import {type AgentTaskRow, createAgentTask, finishAgentTask as markAgentTaskFinished, getAgentTask, listOpenAgentTasks} from '@proxy/db/task'; +import {type AgentTaskRow, createAgentTask, finishAgentTask as markAgentTaskFinished, getAgentTask, listOpenAgentTasks, touchAgentTask} from '@proxy/db/task'; import type {AgentCaller} from './tools'; @@ -24,6 +24,11 @@ import type {AgentCaller} from './tools'; const MAX_TITLE = 120; const MAX_GOAL = 2000; const MAX_OUTCOME = 2000; +// How stale a task's last activity may be before a call made for it writes it again: so a call is +// not a write each, and a task is left open 19 to 20 minutes after the last call made for it. +const TOUCH_AFTER_MS = 60 * 1000; +// Why a task never finished can't be worked on any more (@proxy/utils/tasks). +const leftOpen = (taskId: string, title: string) => `Task ${taskId} (${title}) was never finished, and nothing was done for it or its sub-tasks in the last 20 minutes, so it is taken as left open`; const startSchema = z.object({title: z.string(), goal: z.string(), parent: z.string().optional()}); const finishSchema = z.object({task: z.string(), outcome: z.string().optional()}); @@ -72,7 +77,7 @@ export function startAgentTask(caller: AgentCaller, args: unknown): Promise> { @@ -106,9 +111,7 @@ export function finishAgentTask(caller: AgentCaller, args: unknown): Promise> { return Do(async ($) => { @@ -125,14 +129,17 @@ export function requireOpenTask(agent: SignedInAgent, taskId: string): Promise ({logAgentRequest})); const getAgentTask = mock(); -mock.module('@proxy/db/task', () => ({getAgentTask, createAgentTask: mock(), finishAgentTask: mock(), listOpenAgentTasks: mock()})); +mock.module('@proxy/db/task', () => ({getAgentTask, createAgentTask: mock(), finishAgentTask: mock(), listOpenAgentTasks: mock(), touchAgentTask: mock(async () => Ok(true))})); const listConnectionsWithCredential = mock(); @@ -47,7 +47,7 @@ const caller = {agent: signedIn, via: 'mcp' as const, taskId: 'task-1'}; // The task every run here is for, under way. // Started a moment ago, so it is under way whenever the tests run. -const task = {id: 'task-1', agentId: 'agent-1', title: 'Find the Visa', goal: 'Look up the card number', via: 'mcp', createdAt: new Date(), finishedAt: null, lastStartedAt: new Date()}; +const task = {id: 'task-1', agentId: 'agent-1', title: 'Find the Visa', goal: 'Look up the card number', via: 'mcp', createdAt: new Date(), finishedAt: null, lastActiveAt: new Date()}; const vaultConnection = { id: 'vault-1', diff --git a/packages/api/src/mcp/route.test.ts b/packages/api/src/mcp/route.test.ts index 3dc3930a..ea5669bd 100644 --- a/packages/api/src/mcp/route.test.ts +++ b/packages/api/src/mcp/route.test.ts @@ -42,7 +42,7 @@ const task = { createdAt: new Date('2026-10-16T09:00:00Z'), finishedAt: null, outcome: null, - lastStartedAt: new Date('2026-10-16T09:00:00Z'), + lastActiveAt: new Date('2026-10-16T09:00:00Z'), }; const started = {task: 'task-1', parent: null, title: 'Triage inbox', goal: 'Archive the newsletters', startedAt: '2026-10-16T09:00:00.000Z', finishedAt: null, outcome: null}; diff --git a/packages/db/prisma/migrations/v034_rename_task_last_started_at_to_last_active_at/migration.sql b/packages/db/prisma/migrations/v034_rename_task_last_started_at_to_last_active_at/migration.sql new file mode 100644 index 00000000..e0960983 --- /dev/null +++ b/packages/db/prisma/migrations/v034_rename_task_last_started_at_to_last_active_at/migration.sql @@ -0,0 +1,22 @@ +-- A task never finished is taken as left open 20 minutes after anything was last done for it or +-- for one of its sub-tasks at any depth, no longer a day after the last of them was started. Each +-- task's is the latest start, or request made, in the tree below it. +-- AlterTable +ALTER TABLE "AgentTask" RENAME COLUMN "lastStartedAt" TO "lastActiveAt"; + +WITH RECURSIVE "below" AS ( + SELECT "id" AS "taskId", "id" FROM "AgentTask" + UNION ALL + SELECT "below"."taskId", "task"."id" + FROM "AgentTask" AS "task" + JOIN "below" ON "task"."parentId" = "below"."id" +) +UPDATE "AgentTask" +SET "lastActiveAt" = "latest"."at" +FROM ( + SELECT "below"."taskId", MAX("entry"."createdAt") AS "at" + FROM "below" + JOIN "AuditEntry" AS "entry" ON "entry"."taskId" = "below"."id" + GROUP BY "below"."taskId" +) AS "latest" +WHERE "AgentTask"."id" = "latest"."taskId" AND "latest"."at" > "AgentTask"."lastActiveAt"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 233a917e..17a6390c 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -412,11 +412,11 @@ model AgentTask { // The member whose own the agent is, who alone sees the task; null for a shared agent's, which // everyone in the organization sees. ownerUserId String? - // When it, or the last of its sub-tasks at any depth, was started: one never finished is taken - // as abandoned a day after (@proxy/utils/tasks). Never before any of its sub-tasks', so a parent - // is never abandoned while one is under way. Set with createdAt, and on every task above a - // sub-task when it starts. - lastStartedAt DateTime + // When it, or the last of its sub-tasks at any depth, was started or had a request made for it: + // one never finished is taken as left open 20 minutes after (@proxy/utils/tasks). Never before + // any of its sub-tasks', so a parent is never left open while one is under way. Set with + // createdAt, and on it and every task above it when a sub-task starts or a request is made. + lastActiveAt DateTime createdAt DateTime @default(now()) diff --git a/packages/db/scripts/seed.ts b/packages/db/scripts/seed.ts index 6d6401a8..7cbb0089 100644 --- a/packages/db/scripts/seed.ts +++ b/packages/db/scripts/seed.ts @@ -770,7 +770,7 @@ async function seedJob(agent: (typeof agents)[number], startedAt: number): Promi goal: part.goal, via, createdAt: new Date(at(part.from)), - lastStartedAt: new Date(at(lastStart(part))), + lastActiveAt: new Date(at(lastStart(part))), finishedAt: new Date(at(part.to)), outcome: part.outcome, }, @@ -858,8 +858,9 @@ for (const agent of agents) { const concurrent = await Promise.all( Array.from({length: CONCURRENT_TASKS}, async (_, index) => { const startedAt = now - (110 - index * 20) * MINUTE - random() * 15 * MINUTE; - // The first two are done; the rest are under way. + // The first two are done; the rest are under way, with something done for them lately. const finishedAt = index < 2 ? startedAt + (25 + random() * 30) * MINUTE : null; + const lastActiveAt = finishedAt === null ? now - random() * 5 * MINUTE : startedAt; const taskVia: 'web' | 'mcp' = random() < 0.7 ? 'mcp' : 'web'; const task = await db.agentTask.create({ data: { @@ -869,7 +870,7 @@ for (const agent of agents) { ...pick(TASKS), via: taskVia, createdAt: new Date(startedAt), - lastStartedAt: new Date(startedAt), + lastActiveAt: new Date(lastActiveAt), finishedAt: finishedAt === null ? null : new Date(finishedAt), }, }); @@ -884,11 +885,12 @@ for (const agent of agents) { task ? [task] : [], )) { const parentDone = parent.endsAt !== now; - let lastStartedAt = parent.startedAt; + let parentActiveAt = parent.startedAt; for (const [index, part] of SUB_TASKS.entries()) { const startedAt = parent.startedAt + (2 + index * 3 + random() * 2) * MINUTE; const finishedAt = parentDone || index === 0 ? startedAt + (4 + random() * 8) * MINUTE : null; + const lastActiveAt = finishedAt === null ? now - random() * 5 * MINUTE : startedAt; const task = await db.agentTask.create({ data: { orgId: org.id, @@ -899,18 +901,18 @@ for (const agent of agents) { goal: part.goal, via: parent.via, createdAt: new Date(startedAt), - lastStartedAt: new Date(startedAt), + lastActiveAt: new Date(lastActiveAt), finishedAt: finishedAt === null ? null : new Date(finishedAt), outcome: finishedAt === null ? null : part.outcome, }, }); taskCount += 1; subTasks.push({id: task.id, via: parent.via, startedAt, endsAt: finishedAt ?? now}); - lastStartedAt = Math.max(lastStartedAt, startedAt); + parentActiveAt = Math.max(parentActiveAt, lastActiveAt); } - await db.agentTask.update({ - where: {id: parent.id}, - data: {lastStartedAt: new Date(lastStartedAt)}, + await db.agentTask.updateMany({ + where: {id: parent.id, lastActiveAt: {lt: new Date(parentActiveAt)}}, + data: {lastActiveAt: new Date(parentActiveAt)}, }); } const pool = [...concurrent, ...subTasks]; @@ -969,7 +971,7 @@ for (const agent of agents) { ...pick(TASKS), via, createdAt: new Date(createdAt - MINUTE), - lastStartedAt: new Date(createdAt - MINUTE), + lastActiveAt: new Date(createdAt - MINUTE), finishedAt: new Date((requests[lastIndex]?.createdAt ?? createdAt) + MINUTE), }, }); @@ -1007,6 +1009,26 @@ for (const agent of agents) { await db.agent.update({where: {id: agent.id}, data: {lastActiveAt: new Date(lastActiveAt)}}); } +// Each task's last activity is no earlier than the last request made for it or its sub-tasks at any +// depth, as the api keeps it. +await db.$executeRaw` + WITH RECURSIVE "below" AS ( + SELECT "id" AS "taskId", "id" FROM "AgentTask" WHERE "orgId" = ${org.id} + UNION ALL + SELECT "below"."taskId", "task"."id" + FROM "AgentTask" AS "task" + JOIN "below" ON "task"."parentId" = "below"."id" + ) + UPDATE "AgentTask" + SET "lastActiveAt" = "latest"."at" + FROM ( + SELECT "below"."taskId", MAX("entry"."createdAt") AS "at" + FROM "below" + JOIN "AuditEntry" AS "entry" ON "entry"."taskId" = "below"."id" + GROUP BY "below"."taskId" + ) AS "latest" + WHERE "AgentTask"."id" = "latest"."taskId" AND "latest"."at" > "AgentTask"."lastActiveAt"`; + // ---- A sign-in link ------------------------------------------------------------------------- // A magic link as the api makes one (`packages/api/src/auth/email.ts`): only the token's hash is diff --git a/packages/db/src/audit.ts b/packages/db/src/audit.ts index a37816a6..d0e22c30 100644 --- a/packages/db/src/audit.ts +++ b/packages/db/src/audit.ts @@ -187,7 +187,7 @@ export async function listTaskEntries( /** * The last request the viewer sees made for each of `taskIds` after `after`, where one was, in the - * order they were made: for a stretch of time read that ends before now, when a task given up on + * order they were made: for a stretch of time read that ends before now, when a task left open * last had anything done for it, which is when the web app shows it as having ended. */ export async function listLastTaskEntries( diff --git a/packages/db/src/task.ts b/packages/db/src/task.ts index bcbccead..8a5a35c7 100644 --- a/packages/db/src/task.ts +++ b/packages/db/src/task.ts @@ -2,7 +2,7 @@ import type {AuditVia, Prisma} from '@prisma/client'; import type {Result} from 'ts-results-es'; import {type ApiError, wrapDb} from '@proxy/utils'; -import {TASK_ABANDONED_AFTER_MS} from '@proxy/utils/tasks'; +import {TASK_LEFT_OPEN_AFTER_MS} from '@proxy/utils/tasks'; import {db} from '.'; import {type Viewer, visibleTo} from './viewer'; @@ -21,9 +21,9 @@ export type AgentTaskRow = { finishedAt: Date | null; // What was done or found, as the agent said when it finished; null where it said nothing. outcome: string | null; - // When it, or the last of its sub-tasks at any depth, was started: one never finished is taken - // as abandoned a day after (@proxy/utils/tasks). - lastStartedAt: Date; + // When it, or one of its sub-tasks at any depth, was last started or had a request made for it: + // one never finished is taken as left open 20 minutes after (@proxy/utils/tasks). + lastActiveAt: Date; }; const taskSelect = { @@ -36,17 +36,17 @@ const taskSelect = { createdAt: true, finishedAt: true, outcome: true, - lastStartedAt: true, + lastActiveAt: true, } as const; /** * Starts a task for the agent: what it is called and what the person asked for, and the agent's * task it is a sub-task of, where it is one. The parent and every task above it are locked while * the sub-task is made, so none can be finished in between and leave the sub-task open, and each - * takes the sub-task's start as the last of its own (`lastStartedAt`), so none is abandoned before - * it; `Ok(null)` means the parent is no longer under way, finished or abandoned (@proxy/utils/tasks). - * `ownerUserId` is the member whose own the agent is, who alone sees the task; null for a shared - * agent's. + * takes the sub-task's start as the last thing done for it (`lastActiveAt`), so none is left open + * before it; `Ok(null)` means the parent is no longer under way, finished or left open + * (@proxy/utils/tasks). `ownerUserId` is the member whose own the agent is, who alone sees the + * task; null for a shared agent's. */ export async function createAgentTask(data: { orgId: string; @@ -62,53 +62,81 @@ export async function createAgentTask(data: { return wrapDb(() => { const now = new Date(); return db.agentTask.create({ - data: {...data, createdAt: now, lastStartedAt: now}, + data: {...data, createdAt: now, lastActiveAt: now}, select: taskSelect, }); }); } return wrapDb(() => db.$transaction(async (tx) => { - // Locked from the top down, the order finishing a task locks its sub-tasks in - // (finishSubTasks), so the two can't wait on each other. - const above = await tx.$queryRaw< - Array<{id: string; finishedAt: Date | null; lastStartedAt: Date}> - >` - WITH RECURSIVE "line" AS ( - SELECT "id", "parentId", 0 AS "depth" FROM "AgentTask" - WHERE "id" = ${parentId} AND "orgId" = ${orgId} AND "agentId" = ${agentId} - UNION ALL - SELECT "task"."id", "task"."parentId", "line"."depth" + 1 - FROM "AgentTask" AS "task" - JOIN "line" ON "task"."id" = "line"."parentId" - ) - SELECT "task"."id", "task"."finishedAt", "task"."lastStartedAt" - FROM "AgentTask" AS "task" - JOIN "line" ON "task"."id" = "line"."id" - ORDER BY "line"."depth" DESC - FOR UPDATE OF "task"`; const now = new Date(); - const parent = above.find((task) => task.id === parentId); - if ( - !parent || - parent.finishedAt !== null || - parent.lastStartedAt.getTime() <= now.getTime() - TASK_ABANDONED_AFTER_MS - ) { + if (!(await touchLine(tx, {orgId, agentId, taskId: parentId}, now))) { return null; } - // Where another instance's clock is ahead, one may have set a later start already. - await tx.agentTask.updateMany({ - where: {id: {in: above.map((task) => task.id)}, lastStartedAt: {lt: now}}, - data: {lastStartedAt: now}, - }); return tx.agentTask.create({ - data: {...data, createdAt: now, lastStartedAt: now}, + data: {...data, createdAt: now, lastActiveAt: now}, select: taskSelect, }); }), ); } +/** + * Takes now as the last thing done for the agent's task `taskId` and every task above it, as a + * request made for it is (`lastActiveAt`), so none is left open while the agent is still at work on + * it. `Ok(false)` means it is no longer under way, finished or left open (@proxy/utils/tasks), or + * the agent has no such task. + */ +export async function touchAgentTask( + orgId: string, + agentId: string, + taskId: string, +): Promise> { + return wrapDb(() => db.$transaction((tx) => touchLine(tx, {orgId, agentId, taskId}, new Date()))); +} + +// Locks the task and every task above it, and takes `now` as the last thing done for each; false, +// with none changed, where the task is no longer under way. A task under way has every task above it +// under way too: finishing one finishes its sub-tasks, and none is left open before them. +async function touchLine( + tx: Prisma.TransactionClient, + {orgId, agentId, taskId}: {orgId: string; agentId: string; taskId: string}, + now: Date, +): Promise { + // Locked from the top down, the order finishing a task locks its sub-tasks in (finishSubTasks), + // so the two can't wait on each other. + const above = await tx.$queryRaw< + Array<{id: string; finishedAt: Date | null; lastActiveAt: Date}> + >` + WITH RECURSIVE "line" AS ( + SELECT "id", "parentId", 0 AS "depth" FROM "AgentTask" + WHERE "id" = ${taskId} AND "orgId" = ${orgId} AND "agentId" = ${agentId} + UNION ALL + SELECT "task"."id", "task"."parentId", "line"."depth" + 1 + FROM "AgentTask" AS "task" + JOIN "line" ON "task"."id" = "line"."parentId" + ) + SELECT "task"."id", "task"."finishedAt", "task"."lastActiveAt" + FROM "AgentTask" AS "task" + JOIN "line" ON "task"."id" = "line"."id" + ORDER BY "line"."depth" DESC + FOR UPDATE OF "task"`; + const task = above.find((row) => row.id === taskId); + if ( + !task || + task.finishedAt !== null || + task.lastActiveAt.getTime() <= now.getTime() - TASK_LEFT_OPEN_AFTER_MS + ) { + return false; + } + // Where another instance's clock is ahead, one may have set a later time already. + await tx.agentTask.updateMany({ + where: {id: {in: above.map((row) => row.id)}, lastActiveAt: {lt: now}}, + data: {lastActiveAt: now}, + }); + return true; +} + /** `Ok(null)` means the agent has no such task: unknown, or another agent's. */ export async function getAgentTask( orgId: string, @@ -124,7 +152,7 @@ export async function getAgentTask( * Marks the agent's task finished now, with what was done where the agent said, and its sub-tasks * still under way finished with it, in one transaction. One already finished keeps the time it was, * and takes the outcome only where it has none, as a sub-task finished with its parent does; one - * abandoned (@proxy/utils/tasks) stays as it is, ended when it was abandoned. `Ok(null)` means the + * left open (@proxy/utils/tasks) stays as it is, ended when it was left open. `Ok(null)` means the * agent has no such task. */ export async function finishAgentTask( @@ -136,19 +164,19 @@ export async function finishAgentTask( return wrapDb(() => db.$transaction(async (tx) => { const finishedAt = new Date(); - const abandonedBefore = new Date(finishedAt.getTime() - TASK_ABANDONED_AFTER_MS); + const leftOpenBefore = new Date(finishedAt.getTime() - TASK_LEFT_OPEN_AFTER_MS); const {count} = await tx.agentTask.updateMany({ where: { id: taskId, orgId, agentId, finishedAt: null, - lastStartedAt: {gt: abandonedBefore}, + lastActiveAt: {gt: leftOpenBefore}, }, data: {finishedAt, outcome}, }); if (count > 0) { - await finishSubTasks(tx, {orgId, agentId, finishedAt, abandonedBefore}, [taskId]); + await finishSubTasks(tx, {orgId, agentId, finishedAt, leftOpenBefore}, [taskId]); } if (count === 0 && outcome !== null) { await tx.agentTask.updateMany({ @@ -163,11 +191,11 @@ export async function finishAgentTask( // Finishes the sub-tasks of `parentIds` still under way, level by level down the tree. Each level // is read after the one above is updated, which waits for a sub-task being made under it -// (createAgentTask), so none is left open. One abandoned keeps when it ended, as do its own -// sub-tasks, abandoned no later than it. +// (createAgentTask), so none is left under way. One left open keeps when it ended, as do its own +// sub-tasks, left open no later than it. async function finishSubTasks( tx: Prisma.TransactionClient, - at: {orgId: string; agentId: string; finishedAt: Date; abandonedBefore: Date}, + at: {orgId: string; agentId: string; finishedAt: Date; leftOpenBefore: Date}, parentIds: string[], ): Promise { const open = await tx.agentTask.findMany({ @@ -180,13 +208,13 @@ async function finishSubTasks( const ids = open.map((task) => task.id); // Not one finished since it was read, which keeps its own time and outcome. await tx.agentTask.updateMany({ - where: {id: {in: ids}, finishedAt: null, lastStartedAt: {gt: at.abandonedBefore}}, + where: {id: {in: ids}, finishedAt: null, lastActiveAt: {gt: at.leftOpenBefore}}, data: {finishedAt: at.finishedAt}, }); await finishSubTasks(tx, at, ids); } -/** The agent's tasks still under way, newest first: none finished, and none abandoned (@proxy/utils/tasks). */ +/** The agent's tasks still under way, newest first: none finished, and none left open (@proxy/utils/tasks). */ export async function listOpenAgentTasks( orgId: string, agentId: string, @@ -197,7 +225,7 @@ export async function listOpenAgentTasks( orgId, agentId, finishedAt: null, - lastStartedAt: {gt: new Date(Date.now() - TASK_ABANDONED_AFTER_MS)}, + lastActiveAt: {gt: new Date(Date.now() - TASK_LEFT_OPEN_AFTER_MS)}, }, select: taskSelect, orderBy: {createdAt: 'desc'}, @@ -207,8 +235,8 @@ export async function listOpenAgentTasks( /** * The tasks the viewer sees, shared agents' and their own agents', under way at some point between - * `from` and `to`: started by `to` and not finished before `from`, nor abandoned before it, a day - * after it or the last of its sub-tasks started. Of those, the newest `take` started on their own, + * `from` and `to`: started by `to` and not finished before `from`, nor left open before it, 20 + * minutes after anything was last done for it or its sub-tasks. Of those, the newest `take` started on their own, * newest first; a sub-task ends no later than its parent, which is under way then too, so the rest * are sub-tasks of these (listSubTasks). */ @@ -218,7 +246,7 @@ export async function listAgentTasksBetween( to: Date, take: number, ): Promise> { - const startedSince = new Date(from.getTime() - TASK_ABANDONED_AFTER_MS); + const activeSince = new Date(from.getTime() - TASK_LEFT_OPEN_AFTER_MS); return wrapDb(() => db.agentTask.findMany({ where: { @@ -227,7 +255,7 @@ export async function listAgentTasksBetween( createdAt: {lte: to}, AND: [ { - OR: [{finishedAt: {gte: from}}, {finishedAt: null, lastStartedAt: {gte: startedSince}}], + OR: [{finishedAt: {gte: from}}, {finishedAt: null, lastActiveAt: {gte: activeSince}}], }, ], }, diff --git a/packages/docs/content/agents/mcp.mdx b/packages/docs/content/agents/mcp.mdx index 03c0bb11..5084406e 100644 --- a/packages/docs/content/agents/mcp.mdx +++ b/packages/docs/content/agents/mcp.mdx @@ -86,10 +86,10 @@ Before an agent does anything with Personal Agent Proxy, it has to say what it i task: a title in a few words, such as "Triage this morning's inbox", and a goal, what you asked for in a sentence or two. Every call it makes after that names the task, and a call without one, or for a task that is finished, is refused. When the agent has done what you asked, or given it up, it -finishes the task, saying what it did, and starts a new one for the next thing you ask for. A task -the agent never finishes is taken as abandoned a day after it, or the last of its sub-tasks, -started, and refused from then on too. An agent can have any number of tasks under way at once, -each with its own requests. +finishes the task, saying what it did, and starts a new one for the next thing you ask for. Agents +don't always say so: a task the agent never finishes is taken as left open once 20 minutes pass +with nothing done for it or its sub-tasks, and refused from then on too. An agent can have any +number of tasks under way at once, each with its own requests. An agent may split what you asked for among sub-agents of its own, which sign in as it does and have its access. Each part is a sub-task, started with the task it was handed from as its parent, diff --git a/packages/utils/src/tasks.test.ts b/packages/utils/src/tasks.test.ts index 31dc0ebd..d9435c79 100644 --- a/packages/utils/src/tasks.test.ts +++ b/packages/utils/src/tasks.test.ts @@ -2,28 +2,28 @@ import {describe, expect, test} from 'bun:test'; import {taskEndedAt} from './tasks'; -const lastStartedAt = new Date('2026-10-16T09:00:00Z'); +const lastActiveAt = new Date('2026-10-16T09:00:00Z'); describe('taskEndedAt', () => { test('is when the task was finished', () => { const finishedAt = new Date('2026-10-16T10:00:00Z'); - expect(taskEndedAt({lastStartedAt, finishedAt}, new Date('2026-10-20T00:00:00Z'))).toEqual( + expect(taskEndedAt({lastActiveAt, finishedAt}, new Date('2026-10-20T00:00:00Z'))).toEqual( finishedAt, ); }); - test('is null for a task open less than a day since it or its last sub-task was started', () => { + test('is null for a task open less than 20 minutes since anything was done for it', () => { expect( - taskEndedAt({lastStartedAt, finishedAt: null}, new Date('2026-10-17T08:59:59Z')), + taskEndedAt({lastActiveAt, finishedAt: null}, new Date('2026-10-16T09:19:59Z')), ).toBeNull(); }); - test('is a day after it or its last sub-task was started for a task never finished', () => { - expect( - taskEndedAt({lastStartedAt, finishedAt: null}, new Date('2026-10-17T09:00:00Z')), - ).toEqual(new Date('2026-10-17T09:00:00Z')); - expect( - taskEndedAt({lastStartedAt, finishedAt: null}, new Date('2026-10-30T00:00:00Z')), - ).toEqual(new Date('2026-10-17T09:00:00Z')); + test('is 20 minutes after anything was last done for it for a task never finished', () => { + expect(taskEndedAt({lastActiveAt, finishedAt: null}, new Date('2026-10-16T09:20:00Z'))).toEqual( + new Date('2026-10-16T09:20:00Z'), + ); + expect(taskEndedAt({lastActiveAt, finishedAt: null}, new Date('2026-10-30T00:00:00Z'))).toEqual( + new Date('2026-10-16T09:20:00Z'), + ); }); }); diff --git a/packages/utils/src/tasks.ts b/packages/utils/src/tasks.ts index bfcc92e2..6ddf9483 100644 --- a/packages/utils/src/tasks.ts +++ b/packages/utils/src/tasks.ts @@ -1,22 +1,23 @@ -// An agent says when it has finished a task, but it may never say so: a task still open a day -// after it, or the last of its sub-tasks at any depth, was started is taken as abandoned, ended -// then. So a task isn't given up on while a sub-agent is still at work on part of it, and a parent -// is never abandoned before any of its sub-tasks. One abandoned is no longer under way, so no call -// can be made for it; the web app shows it as having ended at the last request made for it. +// An agent says when it has finished a task, but it often doesn't: a task still open 20 minutes +// after anything was last done for it or for one of its sub-tasks at any depth, a sub-task started +// or a request made, is taken as left open, ended then. So a task isn't ended while a sub-agent is +// still at work on part of it, and a parent is never left open before any of its sub-tasks. One +// left open is no longer under way, so no call can be made for it; the web app shows it as having +// ended at the last request made for it. -export const TASK_ABANDONED_AFTER_MS = 24 * 60 * 60 * 1000; +export const TASK_LEFT_OPEN_AFTER_MS = 20 * 60 * 1000; /** - * When a task ended: when it was finished, or, where it never was and that is past, a day after it - * or the last of its sub-tasks was started (`lastStartedAt`); null while it is under way. + * When a task ended: when it was finished, or, where it never was and that is past, 20 minutes + * after anything was last done for it or its sub-tasks (`lastActiveAt`); null while it is under way. */ export function taskEndedAt( - task: {lastStartedAt: Date; finishedAt: Date | null}, + task: {lastActiveAt: Date; finishedAt: Date | null}, now: Date, ): Date | null { if (task.finishedAt !== null) { return task.finishedAt; } - const abandonedAt = new Date(task.lastStartedAt.getTime() + TASK_ABANDONED_AFTER_MS); - return abandonedAt <= now ? abandonedAt : null; + const leftOpenAt = new Date(task.lastActiveAt.getTime() + TASK_LEFT_OPEN_AFTER_MS); + return leftOpenAt <= now ? leftOpenAt : null; } diff --git a/packages/web/src/agent-loaders.ts b/packages/web/src/agent-loaders.ts index 3b3272e1..f3c0f2dd 100644 --- a/packages/web/src/agent-loaders.ts +++ b/packages/web/src/agent-loaders.ts @@ -16,7 +16,7 @@ import {fetchErrorToResponse} from '@/lib/loader-utils'; // The agent side's pages. Every records request is made for the task in the page's address, // checked and logged by the api; a page shows what came back, a refusal included. A task the api -// refuses, unknown, finished or abandoned, sends the agent back to its tasks. +// refuses, unknown, finished or left open, sends the agent back to its tasks. // No live session, a revoked login included, sends the agent to its sign-in. function unwrapAgentResult(result: Result): T { @@ -31,7 +31,7 @@ function unwrapAgentResult(result: Result): T { export type Outcome = {kind: 'ok'; value: T} | {kind: 'denied'} | {kind: 'missing'}; -// A run refused for its task, one finished since the page opened or a day left abandoned, sends +// A run refused for its task, one finished since the page opened or left open, sends // the agent back to its tasks, rather than show the page as refused or missing. function toOutcome(taskId: string, result: Result): Outcome { if (result.isErr() && isTaskRefusal(result.error)) { diff --git a/packages/web/src/client/agent-client.ts b/packages/web/src/client/agent-client.ts index 523b14a8..313dab5e 100644 --- a/packages/web/src/client/agent-client.ts +++ b/packages/web/src/client/agent-client.ts @@ -56,7 +56,7 @@ export function listAgentTasks() { return apiRequest('GET', '/api/agent/tasks', z.object({tasks: z.array(agentTaskSchema)})); } -/** The task while it is under way: 404 for one unknown, 409 for one finished or abandoned. */ +/** The task while it is under way: 404 for one unknown, 409 for one finished or left open. */ export function getAgentTask(taskId: string) { return apiRequest('GET', `/api/agent/tasks/${encodeURIComponent(taskId)}`, agentTaskSchema); } diff --git a/packages/web/src/client/schemas.ts b/packages/web/src/client/schemas.ts index 5861fc34..ebabe56f 100644 --- a/packages/web/src/client/schemas.ts +++ b/packages/web/src/client/schemas.ts @@ -97,9 +97,9 @@ export const activityTaskSchema = z.object({ finishedAt: z.string().nullable(), // What was done or found, as the agent said when it finished; null where it said nothing. outcome: z.string().nullable(), - // When it, or the last of its sub-tasks at any depth, was started: one never finished is taken - // as abandoned a day after (@proxy/utils/tasks). - lastStartedAt: z.string(), + // When it, or one of its sub-tasks at any depth, was last started or had a request made for it: + // one never finished is taken as left open 20 minutes after (@proxy/utils/tasks). + lastActiveAt: z.string(), }); // The home page's timeline: every task under way at some point between `from` and `to`, with its diff --git a/packages/web/src/components/activity-timeline.tsx b/packages/web/src/components/activity-timeline.tsx index d285ce0c..33ecaf0e 100644 --- a/packages/web/src/components/activity-timeline.tsx +++ b/packages/web/src/components/activity-timeline.tsx @@ -30,7 +30,7 @@ import { prepareActivityTimeline, type Window, } from '@/lib/activity-timeline'; -import {ABANDONED_OPACITY, MIN_TRACK_PX, TASK_COLOR_CLASS} from '@/lib/timeline'; +import {LEFT_OPEN_OPACITY, MIN_TRACK_PX, TASK_COLOR_CLASS} from '@/lib/timeline'; import {zoomedOn} from '@/lib/timeline-window'; import type {ActivityTask, AgentLogin, AuditEntry, Connection} from '@/lib/types'; import {cn} from '@proxy/ui/lib/utils'; @@ -199,8 +199,8 @@ export function ActivityTimeline({ height={BAR_H} clippedStart={bar.clippedStart} clippedEnd={bar.clippedEnd} - opacity={bar.status === 'abandoned' ? ABANDONED_OPACITY : undefined} - dashed={bar.status === 'abandoned'} + opacity={bar.status === 'leftOpen' ? LEFT_OPEN_OPACITY : undefined} + dashed={bar.status === 'leftOpen'} /> {bar.status === 'running' && ( )} {row.members.map((member, memberIndex) => ( @@ -166,8 +166,8 @@ export function RunTimeline({tasks, entries, now, connections, onOpenEntry}: Pro y={TRACK_TOP} height={TRACK_H} clippedEnd={row.status === 'running' && member.end >= width - 1} - opacity={row.status === 'abandoned' ? ABANDONED_OPACITY : undefined} - dashed={row.status === 'abandoned'} + opacity={row.status === 'leftOpen' ? LEFT_OPEN_OPACITY : undefined} + dashed={row.status === 'leftOpen'} /> ))} {row.status === 'running' && ( diff --git a/packages/web/src/components/timeline-chart.tsx b/packages/web/src/components/timeline-chart.tsx index b3c59669..492f7d43 100644 --- a/packages/web/src/components/timeline-chart.tsx +++ b/packages/web/src/components/timeline-chart.tsx @@ -5,7 +5,7 @@ import type {Back} from '@/lib/back'; import {connectionLabel, describeEntry, locateEntry} from '@/lib/access'; import {formatTime} from '@/lib/format'; import { - ABANDONED_OPACITY, + LEFT_OPEN_OPACITY, formatDuration, merged, MIN_TRACK_PX, @@ -598,7 +598,7 @@ export function ChartLegend({rows}: {rows: LegendRow[]}) { const any = (test: (row: LegendRow) => boolean) => rows.some(test); const waiting = any((row) => (row.waiting ?? []).length > 0); const running = any((row) => row.status === 'running'); - const abandoned = any((row) => row.status === 'abandoned'); + const leftOpen = any((row) => row.status === 'leftOpen'); const blocked = any((row) => row.requests.some((request) => request.entry.outcome === 'denied')); const failed = any((row) => row.requests.some((request) => request.entry.outcome === 'failed')); return ( @@ -606,7 +606,7 @@ export function ChartLegend({rows}: {rows: LegendRow[]}) { }>Task and its requests {waiting && }>Waiting on sub-tasks} {running && }>Under way} - {abandoned && }>Given up} + {leftOpen && }>Left open} {blocked && }>Blocked} {failed && }>Failed} @@ -623,20 +623,20 @@ function Key({swatch, children}: {swatch: ReactNode; children: ReactNode}) { } /** - * A swatch of a task's track, for the legend: with requests on it, hatched, under way, given up, or + * A swatch of a task's track, for the legend: with requests on it, hatched, under way, left open, or * with a request blocked or failed. */ function TrackSwatch({ requests = false, hatched = false, running = false, - abandoned = false, + leftOpen = false, outcome, }: { requests?: boolean; hatched?: boolean; running?: boolean; - abandoned?: boolean; + leftOpen?: boolean; outcome?: 'denied' | 'failed'; }) { return ( @@ -646,8 +646,8 @@ function TrackSwatch({ span={{start: 0, end: 24}} y={1} height={10} - opacity={abandoned ? ABANDONED_OPACITY : undefined} - dashed={abandoned} + opacity={leftOpen ? LEFT_OPEN_OPACITY : undefined} + dashed={leftOpen} /> {requests && } {requests && } diff --git a/packages/web/src/lib/activity-timeline.test.ts b/packages/web/src/lib/activity-timeline.test.ts index d2375220..273aa946 100644 --- a/packages/web/src/lib/activity-timeline.test.ts +++ b/packages/web/src/lib/activity-timeline.test.ts @@ -25,8 +25,8 @@ function task(overrides: Partial): ActivityTask { startedAt: '2026-10-16T11:00:00Z', finishedAt: null, outcome: null, - // No sub-task started after it, unless a test gives one. - lastStartedAt: overrides.startedAt ?? '2026-10-16T11:00:00Z', + // Nothing done for it after it started, unless a test gives a time. + lastActiveAt: overrides.startedAt ?? '2026-10-16T11:00:00Z', ...overrides, }; } @@ -72,7 +72,8 @@ function build( } describe('layActivityTimeline', () => { - const inbox = task({}); + // Under way, a request made for it five minutes ago. + const inbox = task({lastActiveAt: '2026-10-16T11:55:00Z'}); const call = task({ id: 't2', title: 'Prepare for the Acme call', @@ -88,7 +89,11 @@ describe('layActivityTimeline', () => { }); test('every agent’s row, those with a task under way first; tasks one after another share a lane, overlapping ones each take one', () => { - const overlapping = task({id: 't4', startedAt: '2026-10-16T11:30:00Z'}); + const overlapping = task({ + id: 't4', + startedAt: '2026-10-16T11:30:00Z', + lastActiveAt: '2026-10-16T11:50:00Z', + }); const timeline = build([report, inbox, call, overlapping], [], now, window, WIDTH); expect(timeline.rows.map((row) => row.agentId)).toEqual(['a1', 'a2']); expect(timeline.rows[0]?.working).toBe(2); @@ -144,7 +149,7 @@ describe('layActivityTimeline', () => { ]); }); - test('a task given up on ends at its last request, not a day after it started', () => { + test('a task left open ends at its last request, not 20 minutes after it', () => { const stale = task({id: 'stale', startedAt: '2026-10-15T09:00:00Z'}); const timeline = build( [stale], @@ -154,7 +159,7 @@ describe('layActivityTimeline', () => { WIDTH, ); const bar = timeline.rows[0]?.lanes[0]?.[0]; - expect(bar?.status).toBe('abandoned'); + expect(bar?.status).toBe('leftOpen'); expect(bar?.members).toEqual([{start: 0, end: 100}]); }); @@ -275,7 +280,11 @@ describe('layActivityTimeline', () => { ['t1'], ]); // Another task that overlaps goes to the first lane that is free, the empty one. - const overlapping = task({id: 't4', startedAt: '2026-10-16T11:30:00Z'}); + const overlapping = task({ + id: 't4', + startedAt: '2026-10-16T11:30:00Z', + lastActiveAt: '2026-10-16T11:50:00Z', + }); const both = build([inbox, overlapping], [], now, window, WIDTH, new Map([['t1', 1]])); expect(laneAssignments(both)).toEqual( new Map([ @@ -284,7 +293,11 @@ describe('layActivityTimeline', () => { ]), ); // A task new to the window, though it started earlier, doesn't take a kept lane. - const earlier = task({id: 't5', startedAt: '2026-10-16T10:30:00Z'}); + const earlier = task({ + id: 't5', + startedAt: '2026-10-16T10:30:00Z', + lastActiveAt: '2026-10-16T11:45:00Z', + }); const newcomer = build([inbox, earlier], [], now, window, WIDTH, new Map([['t1', 0]])); expect(laneAssignments(newcomer)).toEqual( new Map([ diff --git a/packages/web/src/lib/activity-timeline.ts b/packages/web/src/lib/activity-timeline.ts index a6404416..53600dee 100644 --- a/packages/web/src/lib/activity-timeline.ts +++ b/packages/web/src/lib/activity-timeline.ts @@ -30,7 +30,7 @@ export type ActivityBar = Span & { tasks: ActivityTask[]; // Where each task runs. members: Span[]; - // The task's, or, for several, under way if any is, else abandoned if every one was. + // The task's, or, for several, under way if any is, else left open if every one was. status: TaskStatus; // From when the first started to when the last ended, or to now, in milliseconds since the epoch. fromMs: number; diff --git a/packages/web/src/lib/agent-errors.ts b/packages/web/src/lib/agent-errors.ts index 9d47951e..4005652f 100644 --- a/packages/web/src/lib/agent-errors.ts +++ b/packages/web/src/lib/agent-errors.ts @@ -13,7 +13,7 @@ export function apiMessage(body: unknown): string | null { export function agentErrorMessage(error: FetchError): string { if (isTaskRefusal(error)) { - return 'This task can’t be worked on anymore: it is finished, was left open over a day, or isn’t this login’s. Switch task, or start a new one.'; + return 'This task can’t be worked on anymore: it is finished, went 20 minutes with nothing done for it, or isn’t this login’s. Switch task, or start a new one.'; } if (error.kind === 'http' && error.status === 403) { return 'This login is not allowed to do that.'; diff --git a/packages/web/src/lib/agent-task.test.ts b/packages/web/src/lib/agent-task.test.ts index 15c1e6f9..5875ad05 100644 --- a/packages/web/src/lib/agent-task.test.ts +++ b/packages/web/src/lib/agent-task.test.ts @@ -16,7 +16,7 @@ describe('isTaskRefusal', () => { body, }); - test('is a task that is unknown, finished or abandoned, and no other refusal', () => { + test('is a task that is unknown, finished or left open, and no other refusal', () => { expect(isTaskRefusal(refused(404, {error: 'not_found', resource: 'task'}))).toBe(true); expect( isTaskRefusal( diff --git a/packages/web/src/lib/agent-task.ts b/packages/web/src/lib/agent-task.ts index bec32bab..b60cdd5b 100644 --- a/packages/web/src/lib/agent-task.ts +++ b/packages/web/src/lib/agent-task.ts @@ -26,7 +26,7 @@ export function useTaskId(): string { /** * The api refused a run for its task, not for anything else about it: the task is unknown (404 for - * a `task`), or finished or abandoned (409). The pages send the agent back to its tasks. + * a `task`), or finished or left open (409). The pages send the agent back to its tasks. */ export function isTaskRefusal(error: FetchError): boolean { if (error.kind !== 'http') { diff --git a/packages/web/src/lib/run-timeline.test.ts b/packages/web/src/lib/run-timeline.test.ts index e361ac76..b52022c3 100644 --- a/packages/web/src/lib/run-timeline.test.ts +++ b/packages/web/src/lib/run-timeline.test.ts @@ -24,8 +24,8 @@ function task(overrides: Partial): ActivityTask { startedAt: '2026-10-16T09:00:00Z', finishedAt: null, outcome: null, - // No sub-task started after it, unless a test gives one. - lastStartedAt: overrides.startedAt ?? '2026-10-16T09:00:00Z', + // Nothing done for it after it started, unless a test gives a time. + lastActiveAt: overrides.startedAt ?? '2026-10-16T09:00:00Z', ...overrides, }; } @@ -291,11 +291,11 @@ describe('buildRunTimeline', () => { expect(again.map((row) => row.id)).toEqual(['parent', groupId(burst)]); }); - test('a group of tasks given up on is not handed back', () => { + test('a group of tasks left open is not handed back', () => { const parent = task({ id: 'parent', startedAt: '2026-10-14T09:00:00Z', - lastStartedAt: '2026-10-14T09:02:00Z', + lastActiveAt: '2026-10-14T09:02:00Z', }); const burst = ['a', 'b'].map((id, index) => task({ @@ -312,7 +312,7 @@ describe('buildRunTimeline', () => { expanded: new Set(['parent']), }).rows; const group = rows.find((row) => row.kind === 'group'); - expect(group?.status).toBe('abandoned'); + expect(group?.status).toBe('leftOpen'); expect(group?.handedBack).toBe(false); }); @@ -326,11 +326,11 @@ describe('buildRunTimeline', () => { expect(row?.durationMs).toBe(5 * MINUTE); }); - test('a task given up on runs until the last thing done for it or what it handed on', () => { + test('a task left open runs until the last thing done for it or what it handed on', () => { const parent = task({ id: 'parent', startedAt: '2026-10-15T07:00:00Z', - lastStartedAt: '2026-10-15T08:00:00Z', + lastActiveAt: '2026-10-15T08:00:00Z', }); const sub = task({id: 'sub', parentId: 'parent', startedAt: '2026-10-15T08:00:00Z'}); const timeline = buildRunTimeline( @@ -343,7 +343,7 @@ describe('buildRunTimeline', () => { WIDTH, ); const row = timeline.rows.find((candidate) => candidate.id === 'parent'); - expect(row?.status).toBe('abandoned'); + expect(row?.status).toBe('leftOpen'); expect(row?.durationMs).toBe(90 * MINUTE); expect(row?.end).toBe(WIDTH); }); diff --git a/packages/web/src/lib/run-timeline.ts b/packages/web/src/lib/run-timeline.ts index 9a38f1c2..797c490e 100644 --- a/packages/web/src/lib/run-timeline.ts +++ b/packages/web/src/lib/run-timeline.ts @@ -33,7 +33,7 @@ export type RunRow = { parentRow: number | null; // The task, or the group's members, in the order started. tasks: ActivityTask[]; - // The task's, or, for a group, under way if any member is, else abandoned if every member was. + // The task's, or, for a group, under way if any member is, else left open if every member was. status: TaskStatus; // Where its track runs, in pixels from the chart's left edge. start: number; @@ -343,7 +343,7 @@ export function buildRunTimeline( task.finishedAt === parent.finishedAt ); }; - // Every one of them finished, and one or more by its agent saying so: one given up on was + // Every one of them finished, and one or more by its agent saying so: one left open was // handed back to nothing. const handedBack = (of: ActivityTask[]) => of.every((task) => taskStatus(task, now) === 'finished') && diff --git a/packages/web/src/lib/timeline-window.test.ts b/packages/web/src/lib/timeline-window.test.ts index 076b5266..0035592a 100644 --- a/packages/web/src/lib/timeline-window.test.ts +++ b/packages/web/src/lib/timeline-window.test.ts @@ -25,24 +25,23 @@ function task(overrides: Partial): ActivityTask { finishedAt: null, parentId: null, outcome: null, - // No sub-task started after it, unless a test gives one. - lastStartedAt: overrides.startedAt ?? '2026-10-16T11:30:00Z', + // Nothing done for it after it started, unless a test gives a time. + lastActiveAt: overrides.startedAt ?? '2026-10-16T11:30:00Z', ...overrides, }; } describe('taskStatus', () => { - test('a task is running until it is finished, or a day after it started, abandoned', () => { - expect(taskStatus(task({}), now)).toBe('running'); + test('a task is running until it is finished, or 20 minutes after it started, left open', () => { expect(taskStatus(task({finishedAt: '2026-10-16T11:58:00Z'}), now)).toBe('finished'); - expect(taskStatus(task({startedAt: '2026-10-15T12:00:01Z'}), now)).toBe('running'); - expect(taskStatus(task({startedAt: '2026-10-15T12:00:00Z'}), now)).toBe('abandoned'); + expect(taskStatus(task({startedAt: '2026-10-16T11:40:01Z'}), now)).toBe('running'); + expect(taskStatus(task({startedAt: '2026-10-16T11:40:00Z'}), now)).toBe('leftOpen'); }); - test('a task started over a day ago runs on while a sub-task of it started within the day', () => { - const parent = {startedAt: '2026-10-15T09:00:00Z', lastStartedAt: '2026-10-15T14:00:00Z'}; + test('a task started long ago runs on while something was done for it in the last 20 minutes', () => { + const parent = {startedAt: '2026-10-16T09:00:00Z', lastActiveAt: '2026-10-16T11:50:00Z'}; expect(taskStatus(task(parent), now)).toBe('running'); - expect(taskStatus(task(parent), new Date('2026-10-16T14:00:00Z'))).toBe('abandoned'); + expect(taskStatus(task(parent), new Date('2026-10-16T12:10:00Z'))).toBe('leftOpen'); }); }); @@ -67,23 +66,24 @@ describe('endsOf', () => { test('a finished task ended when it was finished, and one under way has not ended', () => { const finished = task({id: 'done', finishedAt: '2026-10-16T11:58:00Z'}); - const ends = endsOf([finished, task({})], [request('2026-10-16T11:30:00Z')], now); + const open = task({lastActiveAt: '2026-10-16T11:50:00Z'}); + const ends = endsOf([finished, open], [request('2026-10-16T11:30:00Z')], now); expect(ends.get('done')).toEqual(new Date('2026-10-16T11:58:00Z')); expect(ends.get('t1')).toBeNull(); }); - test('an abandoned task ended when its last request was done, or when it started with none', () => { - const abandoned = task({startedAt: '2026-10-15T09:00:00Z'}); + test('a task left open ended when its last request was done, or when it started with none', () => { + const leftOpen = task({startedAt: '2026-10-15T09:00:00Z'}); const requests = [request('2026-10-15T09:10:00Z'), request('2026-10-15T09:40:00Z')]; - expect(endsOf([abandoned], requests, now).get('t1')).toEqual(new Date('2026-10-15T09:40:00Z')); - expect(endsOf([abandoned], [], now).get('t1')).toEqual(new Date('2026-10-15T09:00:00Z')); + expect(endsOf([leftOpen], requests, now).get('t1')).toEqual(new Date('2026-10-15T09:40:00Z')); + expect(endsOf([leftOpen], [], now).get('t1')).toEqual(new Date('2026-10-15T09:00:00Z')); }); - test('an abandoned task ended no earlier than the last of its sub-tasks, at any depth', () => { + test('a task left open ended no earlier than the last of its sub-tasks, at any depth', () => { const parent = task({ id: 'groceries', startedAt: '2026-10-15T07:00:00Z', - lastStartedAt: '2026-10-15T10:00:00Z', + lastActiveAt: '2026-10-15T10:00:00Z', }); const compare = task({ id: 'compare', @@ -95,7 +95,7 @@ describe('endsOf', () => { id: 'slot', parentId: 'groceries', startedAt: '2026-10-15T09:00:00Z', - lastStartedAt: '2026-10-15T10:00:00Z', + lastActiveAt: '2026-10-15T10:00:00Z', }); const calendar = task({id: 'calendar', parentId: 'slot', startedAt: '2026-10-15T10:00:00Z'}); const ends = endsOf( diff --git a/packages/web/src/lib/timeline-window.ts b/packages/web/src/lib/timeline-window.ts index 54320bff..ec148d5a 100644 --- a/packages/web/src/lib/timeline-window.ts +++ b/packages/web/src/lib/timeline-window.ts @@ -3,7 +3,7 @@ import {requestTimes} from '@/lib/timeline'; import type {ActivityTask, AuditEntry} from '@/lib/types'; // The home page's timeline looks back over a window of time ending now, chosen from a few on offer -// or zoomed to any length; and a task, there and on its page, is under way, finished or abandoned. +// or zoomed to any length; and a task, there and on its page, is under way, finished or left open. // The windows on offer, each a stretch of time ending now. Zooming goes between and beyond them. export const WINDOWS = [ @@ -90,35 +90,35 @@ function describeMinutes(minutes: number): string { return hours % 24 === 0 ? `${days}d` : `${days}d ${hours % 24}h`; } -// A task is running until the agent calls it finished, or until a day after it or the last of its -// sub-tasks started, when one never finished is taken as abandoned (@proxy/utils/tasks). -export type TaskStatus = 'running' | 'finished' | 'abandoned'; +// A task is running until the agent calls it finished, or until 20 minutes after anything was last +// done for it or its sub-tasks, when one never finished is taken as left open (@proxy/utils/tasks). +export type TaskStatus = 'running' | 'finished' | 'leftOpen'; export const STATUS_LABEL: Record = { running: 'Under way', finished: 'Finished', - abandoned: 'Abandoned', + leftOpen: 'Left open', }; -// When it and the last of its sub-tasks were started and when it was finished, all a task's status -// needs. -type TaskTimes = Pick; +// When it started, when anything was last done for it or its sub-tasks and when it was finished, +// all a task's status needs. +type TaskTimes = Pick; -// When the task was given up on, if it has been by `now`: a day after it or the last of its -// sub-tasks started, never finished (@proxy/utils/tasks); null while it is under way, or where it -// was finished. -function abandonedAt(task: TaskTimes, now: Date): Date | null { +// When the task was taken as left open, if it has been by `now`: 20 minutes after anything was +// last done for it or its sub-tasks, never finished (@proxy/utils/tasks); null while it is under +// way, or where it was finished. +function leftOpenAt(task: TaskTimes, now: Date): Date | null { if (task.finishedAt !== null) { return null; } - return taskEndedAt({lastStartedAt: new Date(task.lastStartedAt), finishedAt: null}, now); + return taskEndedAt({lastActiveAt: new Date(task.lastActiveAt), finishedAt: null}, now); } // A task with what it was handed on from, all its end needs. type TaskInTree = TaskTimes & Pick; /** - * When each of `tasks` ended, if it has by `now`: when it was finished; or, given up on, when the + * When each of `tasks` ended, if it has by `now`: when it was finished; or, left open, when the * last of `entries` made for it was done or the last of its sub-tasks ended, or when it started * where there was neither, since nothing was done for it after. Null while it is under way. */ @@ -160,11 +160,11 @@ export function endsOf( if (task.finishedAt !== null) { return new Date(task.finishedAt); } - if (abandonedAt(task, now) === null) { + if (leftOpenAt(task, now) === null) { return null; } const started = new Date(task.startedAt).getTime(); - // A sub-task is abandoned no later than its parent (@proxy/utils/tasks), so each has ended. + // A sub-task is left open no later than its parent (@proxy/utils/tasks), so each has ended. return new Date( Math.max( started, @@ -183,15 +183,15 @@ export function taskStatus(task: TaskTimes, now: Date): TaskStatus { if (task.finishedAt !== null) { return 'finished'; } - return abandonedAt(task, now) === null ? 'running' : 'abandoned'; + return leftOpenAt(task, now) === null ? 'running' : 'leftOpen'; } -/** The status of several tasks drawn as one: under way if any is, else abandoned if every one was. */ +/** The status of several tasks drawn as one: under way if any is, else left open if every one was. */ export function combinedStatus(statuses: TaskStatus[]): TaskStatus { if (statuses.includes('running')) { return 'running'; } - return statuses.every((status) => status === 'abandoned') ? 'abandoned' : 'finished'; + return statuses.every((status) => status === 'leftOpen') ? 'leftOpen' : 'finished'; } // The stretches of time a window is marked off in: the windows on offer each have their own, a diff --git a/packages/web/src/lib/timeline.ts b/packages/web/src/lib/timeline.ts index 9d64e29a..4ab2db53 100644 --- a/packages/web/src/lib/timeline.ts +++ b/packages/web/src/lib/timeline.ts @@ -21,9 +21,9 @@ export type TimelineRequest = Span & {entry: AuditEntry}; // and red are left out, as amber and red mean a request was blocked or failed. export const TASK_COLOR_CLASS = 'text-[#2a78d6] dark:text-[#3987e5]'; -// How faint a task's track is, and one given up. +// How faint a task's track is, and one left open. export const TRACK_OPACITY = 0.2; -export const ABANDONED_OPACITY = 0.1; +export const LEFT_OPEN_OPACITY = 0.1; // A track is drawn at least this wide, so a task done in a moment still reads as one. export const MIN_TRACK_PX = 6; diff --git a/packages/web/src/pages/activity-page.tsx b/packages/web/src/pages/activity-page.tsx index d0bfcd99..390a4ab6 100644 --- a/packages/web/src/pages/activity-page.tsx +++ b/packages/web/src/pages/activity-page.tsx @@ -164,17 +164,17 @@ function TaskCard({ ); } -// Finished once the agent says so; one it never finished is taken as given up a day after it or the -// last of its sub-tasks started (@proxy/utils/tasks). +// Finished once the agent says so; one it never finished is taken as left open 20 minutes after +// anything was last done for it or its sub-tasks (@proxy/utils/tasks). function TaskBadge({task}: {task: ActivityTask}) { // When the page was opened, as the log's entries are. const [now] = useState(() => new Date()); if (task.finishedAt) { return Finished; } - const ended = taskEndedAt({lastStartedAt: new Date(task.lastStartedAt), finishedAt: null}, now); + const ended = taskEndedAt({lastActiveAt: new Date(task.lastActiveAt), finishedAt: null}, now); if (ended) { - return Abandoned; + return Left open; } return Under way; } diff --git a/packages/web/src/pages/agent/agent-task-page.tsx b/packages/web/src/pages/agent/agent-task-page.tsx index ee1d0079..cd20b632 100644 --- a/packages/web/src/pages/agent/agent-task-page.tsx +++ b/packages/web/src/pages/agent/agent-task-page.tsx @@ -29,7 +29,7 @@ export function AgentTaskPage() { const notice = noticeFromState(useLocation().state) ?? (refused - ? `Task ${refused} can’t be worked on anymore: it is finished, was left open over a day, or isn’t this login’s. Carry on with another, or start one for what you are doing now` + ? `Task ${refused} can’t be worked on anymore: it is finished, went 20 minutes with nothing done for it, or isn’t this login’s. Carry on with another, or start one for what you are doing now` : null); const [error, setError] = useState(null); diff --git a/packages/web/src/pages/home-page.test.tsx b/packages/web/src/pages/home-page.test.tsx index 9ed743dc..9d79dbae 100644 --- a/packages/web/src/pages/home-page.test.tsx +++ b/packages/web/src/pages/home-page.test.tsx @@ -57,7 +57,7 @@ const activity: ActivityTimeline = { goal: 'Archive the newsletters and flag what needs an answer', via: 'mcp', startedAt: '2026-10-16T11:00:00.000Z', - lastStartedAt: '2026-10-16T11:00:00.000Z', + lastActiveAt: '2026-10-16T11:00:00.000Z', finishedAt: null, parentId: null, outcome: null, @@ -69,7 +69,7 @@ const activity: ActivityTimeline = { goal: 'Pull the notes and draft an agenda', via: 'web', startedAt: '2026-10-16T08:00:00.000Z', - lastStartedAt: '2026-10-16T08:00:00.000Z', + lastActiveAt: '2026-10-16T08:00:00.000Z', finishedAt: '2026-10-16T08:30:00.000Z', parentId: null, outcome: null, diff --git a/packages/web/src/pages/task-page.tsx b/packages/web/src/pages/task-page.tsx index ff44e6a5..a1e47ca8 100644 --- a/packages/web/src/pages/task-page.tsx +++ b/packages/web/src/pages/task-page.tsx @@ -96,7 +96,7 @@ function TaskRunPage({ const status = taskStatus(task, now); const agent = agents.find((candidate) => candidate.id === run.agentId); const own = run.entries.filter((entry) => entry.taskId === task.id); - // Given up on, it ended no earlier than what it handed on. + // Left open, it ended no earlier than what it handed on. const ended = endsOf(run.tasks, run.entries, now).get(task.id) ?? now; const summary = [ formatDuration(ended.getTime() - new Date(task.startedAt).getTime()), From 1f085c16e0d6bd941665d535c07ac8fe07a863db Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:07:30 +0000 Subject: [PATCH 2/3] take a task left open up again with the next call made for it A call, a sub-task started under it or finish_task for a task left open takes it and every task above it up again, unless one of them was finished, rather than refusing it and asking for a new task. So an agent coming back after a pause carries on with the same task, and a built-in sub-agent that slept keeps its task instead of getting a new one. --- packages/api/src/agent-side/route.ts | 5 +- packages/api/src/agent-side/tasks.test.ts | 65 +++++----- packages/api/src/agent-side/tasks.ts | 47 ++++--- packages/api/src/harness/thread-task.ts | 44 ------- packages/api/src/harness/tool-job.test.ts | 5 +- packages/api/src/harness/tool-job.ts | 12 +- packages/db/prisma/schema.prisma | 15 ++- packages/db/src/harness.ts | 25 +--- packages/db/src/task.test.ts | 122 ++++++++++++++++++ packages/db/src/task.ts | 52 +++----- packages/docs/content/agents/mcp.mdx | 5 +- packages/utils/src/tasks.ts | 7 +- packages/web/src/agent-loaders.ts | 6 +- packages/web/src/client/agent-client.ts | 2 +- packages/web/src/lib/agent-errors.ts | 2 +- packages/web/src/lib/agent-task.test.ts | 2 +- packages/web/src/lib/agent-task.ts | 3 +- packages/web/src/pages/activity-page.tsx | 2 +- .../src/pages/agent/agent-home-page.test.tsx | 4 +- .../web/src/pages/agent/agent-task-page.tsx | 2 +- 20 files changed, 239 insertions(+), 188 deletions(-) delete mode 100644 packages/api/src/harness/thread-task.ts create mode 100644 packages/db/src/task.test.ts diff --git a/packages/api/src/agent-side/route.ts b/packages/api/src/agent-side/route.ts index 09584cfa..0e40402f 100644 --- a/packages/api/src/agent-side/route.ts +++ b/packages/api/src/agent-side/route.ts @@ -28,8 +28,9 @@ export function handleAgentListTasksRoute(request: AgentRequest): Promise> { return getAgentOpenTask(request.agent, request.params.taskId ?? ''); diff --git a/packages/api/src/agent-side/tasks.test.ts b/packages/api/src/agent-side/tasks.test.ts index 2424dd75..004df6c3 100644 --- a/packages/api/src/agent-side/tasks.test.ts +++ b/packages/api/src/agent-side/tasks.test.ts @@ -97,29 +97,36 @@ describe('startAgentTask', () => { expect(createAgentTask).not.toHaveBeenCalled(); }); - test('a parent finished or left open is refused as a conflict, saying to start a new task', async () => { + test('a parent finished is refused as a conflict, saying to start a new task', async () => { const {startAgentTask} = await import('./tasks'); getAgentTask.mockResolvedValue(Ok({...parentRow, finishedAt: new Date('2026-10-16T10:00:00Z')})); const finished = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'}); - getAgentTask.mockResolvedValue(Ok({...parentRow, lastActiveAt: new Date('2026-10-16T08:50:00Z')})); - const leftOpen = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'}); expect(finished.unwrapErr()).toEqual({ kind: 'conflict', statusCode: 409, message: 'Task task-0 (Plan the week) is finished. Start a new task for what you are doing now, and give its id as task.', }); - expect(leftOpen.unwrapErr()).toMatchObject({kind: 'conflict'}); expect(createAgentTask).not.toHaveBeenCalled(); }); + test('a parent left open is taken up again, and the sub-task started under it', async () => { + getAgentTask.mockResolvedValue(Ok({...parentRow, lastActiveAt: new Date('2026-10-16T08:30:00Z')})); + createAgentTask.mockResolvedValue(Ok({...row, parentId: 'task-0'})); + const {startAgentTask} = await import('./tasks'); + const result = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'}); + + expect(touchAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', 'task-0'); + expect(result.unwrap()).toEqual({...response, parent: 'task-0'}); + }); + test('a parent finished between the check and the start is refused as a conflict too', async () => { getAgentTask.mockResolvedValue(Ok(parentRow)); createAgentTask.mockResolvedValue(Ok(null)); const {startAgentTask} = await import('./tasks'); const result = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'}); - expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('Task task-0 is no longer under way')}); + expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('Task task-0, or a task it is part of, is finished')}); }); }); }); @@ -165,12 +172,16 @@ describe('finishAgentTask', () => { expect(result.unwrapErr()).toMatchObject({kind: 'not_found', resource: 'task', id: 'task-9'}); }); - test('a task left open, never finished, is refused as a conflict', async () => { + test('a task left open under a task finished without it is refused as a conflict', async () => { finishAgentTask.mockResolvedValue(Ok(row)); const {finishAgentTask: finish} = await import('./tasks'); const result = await finish(caller, {task: 'task-1', outcome: 'Archived 12 newsletters'}); - expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('taken as left open')}); + expect(result.unwrapErr()).toEqual({ + kind: 'conflict', + statusCode: 409, + message: "Task task-1 (Triage inbox) was left open, and a task it is part of was finished without it, so it can't be finished any more.", + }); }); }); @@ -213,43 +224,25 @@ describe('requireOpenTask', () => { expect(touchAgentTask).not.toHaveBeenCalled(); }); - test('a task never finished is left open 20 minutes after anything was last done for it or its sub-tasks', async () => { + test('a task left open, 20 minutes or more with nothing done for it, is taken up again by the call', async () => { const {requireOpenTask} = await import('./tasks'); - setSystemTime(new Date('2026-10-16T09:19:59Z')); - const lastSecond = await requireOpenTask(agent, 'task-1'); - touchAgentTask.mockClear(); - setSystemTime(new Date('2026-10-16T09:20:00Z')); - const leftOpen = await requireOpenTask(agent, 'task-1'); - - expect(lastSecond.unwrap()).toBe(row); - expect(leftOpen.unwrapErr()).toEqual({ - kind: 'conflict', - statusCode: 409, - message: - 'Task task-1 (Triage inbox) was never finished, and nothing was done for it or its sub-tasks in the last 20 minutes, so it is taken as left open. Start a new task for what you are doing now, and give its id as task.', - }); - expect(touchAgentTask).not.toHaveBeenCalled(); - }); + setSystemTime(new Date('2026-10-16T11:00:00Z')); + const result = await requireOpenTask(agent, 'task-1'); - test('a task started long ago is under way while something was done for it in the last 20 minutes', async () => { - const {requireOpenTask} = await import('./tasks'); - const active = {...row, lastActiveAt: new Date('2026-10-16T14:00:00Z')}; - getAgentTask.mockResolvedValue(Ok(active)); - setSystemTime(new Date('2026-10-16T14:15:00Z')); - const underWay = await requireOpenTask(agent, 'task-1'); - setSystemTime(new Date('2026-10-16T14:20:00Z')); - const leftOpen = await requireOpenTask(agent, 'task-1'); - - expect(underWay.unwrap()).toBe(active); - expect(leftOpen.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('taken as left open')}); + expect(touchAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', 'task-1'); + expect(result.unwrap()).toBe(row); }); - test('a task finished or left open between the check and the call is refused as a conflict', async () => { + test('a task finished since it was read, or left open under a task finished, is refused as a conflict', async () => { touchAgentTask.mockResolvedValue(Ok(false)); const {requireOpenTask} = await import('./tasks'); const result = await requireOpenTask(agent, 'task-1'); - expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('taken as left open')}); + expect(result.unwrapErr()).toEqual({ + kind: 'conflict', + statusCode: 409, + message: 'Task task-1 (Triage inbox), or a task it is part of, is finished. Start a new task for what you are doing now, and give its id as task.', + }); }); }); diff --git a/packages/api/src/agent-side/tasks.ts b/packages/api/src/agent-side/tasks.ts index f1b470ca..271d8105 100644 --- a/packages/api/src/agent-side/tasks.ts +++ b/packages/api/src/agent-side/tasks.ts @@ -2,7 +2,6 @@ import {Err, type Result} from 'ts-results-es'; import {z} from 'zod'; import {ApiErr, type ApiError, Do, parseSchema, requirePresent} from '@proxy/utils'; -import {taskEndedAt} from '@proxy/utils/tasks'; import type {SignedInAgent} from '@proxy/db/agent'; import {type AgentTaskRow, createAgentTask, finishAgentTask as markAgentTaskFinished, getAgentTask, listOpenAgentTasks, touchAgentTask} from '@proxy/db/task'; @@ -17,8 +16,10 @@ import type {AgentCaller} from './tools'; * * When an agent splits a task among sub-agents of its own, which sign in as it does, it starts a * sub-task for each part, with the task as its parent, so the tasks of one job form a tree the - * person can follow. The parent is one of the agent's own tasks still under way. Finishing a task - * finishes its sub-tasks still under way too. + * person can follow. The parent is one of the agent's own tasks not finished. Finishing a task + * finishes its sub-tasks still under way too. A task the agent left open, never finished with + * nothing done for it in 20 minutes, is taken up again by the next call made for it + * (@proxy/utils/tasks). */ const MAX_TITLE = 120; @@ -27,8 +28,9 @@ const MAX_OUTCOME = 2000; // How stale a task's last activity may be before a call made for it writes it again: so a call is // not a write each, and a task is left open 19 to 20 minutes after the last call made for it. const TOUCH_AFTER_MS = 60 * 1000; -// Why a task never finished can't be worked on any more (@proxy/utils/tasks). -const leftOpen = (taskId: string, title: string) => `Task ${taskId} (${title}) was never finished, and nothing was done for it or its sub-tasks in the last 20 minutes, so it is taken as left open`; +// Why a task can't be worked on any more, where it, or a task above it, was finished since it was +// read; or, left open, a task above it was finished without it (@proxy/utils/tasks). +const finishedAbove = (taskId: string, title: string) => `Task ${taskId} (${title}), or a task it is part of, is finished. Start a new task for what you are doing now, and give its id as task.`; const startSchema = z.object({title: z.string(), goal: z.string(), parent: z.string().optional()}); const finishSchema = z.object({task: z.string(), outcome: z.string().optional()}); @@ -53,7 +55,8 @@ function toResponse(task: AgentTaskRow): AgentTaskResponse { /** * Starts a task for the agent: `{title, goal}`, both trimmed and neither empty, and `parent` where - * it is a sub-task, the id of one of the agent's tasks still under way. + * it is a sub-task, the id of one of the agent's tasks not finished, taken up again where it was + * left open. */ export function startAgentTask(caller: AgentCaller, args: unknown): Promise> { return Do(async ($) => { @@ -77,20 +80,20 @@ export function startAgentTask(caller: AgentCaller, args: unknown): Promise> { return Do(async ($) => ({tasks: $(await listOpenAgentTasks(agent.orgId, agent.agentId)).map(toResponse)})); } -/** The agent's task `taskId` while it is under way, refused as a call made for it would be (`requireOpenTask`). */ +/** The agent's task `taskId` while it is not finished, refused as a call made for it would be (`requireOpenTask`). */ export function getAgentOpenTask(agent: SignedInAgent, taskId: string): Promise> { return Do(async ($) => toResponse($(await requireOpenTask(agent, taskId)))); } @@ -99,8 +102,8 @@ export function getAgentOpenTask(agent: SignedInAgent, taskId: string): Promise< * Marks the agent's task `{task}` finished, with `outcome`, what was done or found, where the * agent gives one; its sub-tasks still under way are finished with it. One finished already keeps * when it was, taking the outcome only where it has none, such as a sub-task finished with its - * parent; one left open (@proxy/utils/tasks) can't be finished any more, and is refused as a - * conflict. + * parent. One left open (@proxy/utils/tasks) is taken up again to be finished, unless a task it is + * part of was finished without it, when it is refused as a conflict. */ export function finishAgentTask(caller: AgentCaller, args: unknown): Promise> { return Do(async ($) => { @@ -111,7 +114,7 @@ export function finishAgentTask(caller: AgentCaller, args: unknown): Promise> { return Do(async ($) => { @@ -129,17 +133,12 @@ export function requireOpenTask(agent: SignedInAgent, taskId: string): Promise> { - return Do(async ($) => { - if (thread.taskId === null) { - return $(Err(ApiErr.conflict('The agent talking with the person makes no calls of its own'))); - } - const task = $(requirePresent($(await getAgentTask(thread.orgId, thread.agentId, thread.taskId)), ApiErr.notFound('task', thread.taskId))); - if (taskEndedAt({lastActiveAt: task.lastActiveAt, finishedAt: task.finishedAt}, now) === null) { - return task.id; - } - const made = $( - await createAgentTask({ - orgId: thread.orgId, - agentId: thread.agentId, - ownerUserId: thread.userId, - personaId: thread.personaId, - parentId: null, - title: task.title, - goal: task.goal, - via: 'harness', - }), - ); - const id = $(requirePresent(made, ApiErr.internalError(new Error('A task with no parent was not made')))).id; - $(await setThreadTask(thread.id, id)); - return id; - }); -} diff --git a/packages/api/src/harness/tool-job.test.ts b/packages/api/src/harness/tool-job.test.ts index 458335af..c6dee028 100644 --- a/packages/api/src/harness/tool-job.test.ts +++ b/packages/api/src/harness/tool-job.test.ts @@ -12,9 +12,6 @@ const getThreadAgent = mock(async () => Ok(signedIn)); const startSubAgent = mock(async () => Ok(true)); mock.module('@proxy/db/harness', () => ({getJobThread, getEntry, completeToolJob, failJob, finishJob, getThreadAgent, startSubAgent})); -const ensureThreadTask = mock(async () => Ok('task-2')); -mock.module('./thread-task', () => ({ensureThreadTask})); - const callMcpTool = mock(); mock.module('../mcp/tools', () => ({callMcpTool})); @@ -73,7 +70,7 @@ describe('runToolJob', () => { const result = await runToolJob(job('c1'), 'w1', now); expect(result.isOk()).toBe(true); - expect(callMcpTool).toHaveBeenCalledWith(signedIn, 'read', {connection: 'mail-1', tool: 'emails_list', task: 'task-2'}, false, 'harness'); + expect(callMcpTool).toHaveBeenCalledWith(signedIn, 'read', {connection: 'mail-1', tool: 'emails_list', task: 'task-1'}, false, 'harness'); expect(completeToolJob).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', {callId: 'c1', text: '{"records":[]}', isError: false}); }); diff --git a/packages/api/src/harness/tool-job.ts b/packages/api/src/harness/tool-job.ts index 7292e925..90a94442 100644 --- a/packages/api/src/harness/tool-job.ts +++ b/packages/api/src/harness/tool-job.ts @@ -5,7 +5,6 @@ import {callArguments, DELEGATE_TOOL, delegateArgs, SLEEP_TOOL, sleepUntil, type import {completeToolJob, failJob, finishJob, getEntry, getJobThread, getThreadAgent, type JobRow, startSubAgent, type ThreadRow} from '@proxy/db/harness'; import {callMcpTool} from '../mcp/tools'; -import {ensureThreadTask} from './thread-task'; import {isGateTool} from './tool-surface'; /** @@ -45,11 +44,14 @@ function cutResult(text: string): string { } // Runs the call through the gate, as the sub-agent acts in the thread, for its task. -function runGateTool(thread: ThreadRow, name: string, args: Record, now: Date): Promise> { +function runGateTool(thread: ThreadRow, name: string, args: Record): Promise> { return Do(async ($) => { + if (thread.taskId === null) { + return $(Err(ApiErr.conflict('The agent talking with the person makes no calls of its own'))); + } const signedIn = $(requirePresent($(await getThreadAgent(thread)), ApiErr.notFound('agent', thread.agentId))); - const taskId = $(await ensureThreadTask(thread, now)); - const result = await callMcpTool(signedIn, name, {...args, task: taskId}, false, 'harness'); + // One left open, as when the sub-agent slept long, is taken up again by the call (@proxy/utils/tasks). + const result = await callMcpTool(signedIn, name, {...args, task: thread.taskId}, false, 'harness'); return {text: cutResult(result.content.map((part) => part.text).join('\n')), isError: result.isError === true}; }); } @@ -84,7 +86,7 @@ function answer(thread: ThreadRow, job: JobRow, call: ToolCall, now: Date): Prom if (call.name === 'run' && job.attempts > 1) { return error('The outcome of this call is unknown: the worker running it was interrupted before it could record the answer. Check what it changed before making it again.'); } - return $(await runGateTool(thread, call.name, args, now)); + return $(await runGateTool(thread, call.name, args)); }); } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 0f5650f0..d476e377 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -422,9 +422,10 @@ model AgentTask { // say whose it was; null for every other task. As a column, as the agent is. personaId String? // When it, or the last of its sub-tasks at any depth, was started or had a request made for it: - // one never finished is taken as left open 20 minutes after (@proxy/utils/tasks). Never before - // any of its sub-tasks', so a parent is never left open while one is under way. Set with - // createdAt, and on it and every task above it when a sub-task starts or a request is made. + // one never finished is taken as left open 20 minutes after, until the next request takes it up + // again (@proxy/utils/tasks). Never before any of its sub-tasks', so a parent is never left open + // while one is under way. Set with createdAt, and on it and every task above it when a sub-task + // starts, a request is made or it is finished. lastActiveAt DateTime createdAt DateTime @default(now()) @@ -559,10 +560,10 @@ model Persona { // A thread of the built-in agent, holding its log of entries: a member's conversation with a // persona, or a sub-agent started from it. Every call a sub-agent makes is for its thread's task, -// as any agent's calls are; a task left open, 20 minutes on with nothing done for it, is replaced -// by a new one. A conversation has no task: its agent makes no calls but to delegate. Everything -// that happens to the agent is an entry (AgentEntry), and everything that makes it do something -// is a job (AgentJob). +// as any agent's calls are, taken up again by the next where it was left open, as when it slept +// long. A conversation has no task: its agent makes no calls but to delegate. Everything that +// happens to the agent is an entry (AgentEntry), and everything that makes it do something is a +// job (AgentJob). model AgentThread { id String @id @default(cuid()) orgId String diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts index 6f72afa4..763b6383 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -18,7 +18,6 @@ import { readyForInference, type ToolCall, } from '@proxy/utils/harness'; -import {TASK_LEFT_OPEN_AFTER_MS} from '@proxy/utils/tasks'; import {db} from '.'; import {signedInAgentSelect, type SignedInAgent, toSignedInAgent} from './signed-in-agent'; @@ -464,16 +463,6 @@ export async function listJobEvents(threadId: string): Promise> { - return (await wrapDb(() => db.agentThread.update({where: {id: threadId}, data: {taskId}}))).map( - () => undefined, - ); -} - // Locks the thread's row for the rest of the transaction, and gives the thread as it is; null // when there is no such thread. async function lockThread(tx: Tx, threadId: string): Promise { @@ -586,9 +575,9 @@ async function answerCall( } // Ends a sub-agent's work with its report: its thread is done, its task finished with the report -// as the outcome (unless left open meanwhile, as a task is 20 minutes after anything was last done -// for it), and the report answers the call that started it, in the thread it was started from, -// from the sub-agent's job that ended with it. With the sub-agent's thread locked; its parent's is locked here, after. +// as the outcome, taken up again where it was left open meanwhile (@proxy/utils/tasks), and the +// report answers the call that started it, in the thread it was started from, from the sub-agent's +// job that ended with it. With the sub-agent's thread locked; its parent's is locked here, after. async function endSubAgent( tx: Tx, thread: ThreadRow, @@ -606,12 +595,8 @@ async function endSubAgent( await tx.agentThread.update({where: {id: thread.id}, data: {finishedAt: now}}); if (thread.taskId !== null) { await tx.agentTask.updateMany({ - where: { - id: thread.taskId, - finishedAt: null, - lastActiveAt: {gt: new Date(now.getTime() - TASK_LEFT_OPEN_AFTER_MS)}, - }, - data: {finishedAt: now, outcome: cut(report.text, MAX_TASK_TEXT)}, + where: {id: thread.taskId, finishedAt: null}, + data: {finishedAt: now, lastActiveAt: now, outcome: cut(report.text, MAX_TASK_TEXT)}, }); } const parent = await lockThread(tx, thread.parentThreadId); diff --git a/packages/db/src/task.test.ts b/packages/db/src/task.test.ts new file mode 100644 index 00000000..2890a490 --- /dev/null +++ b/packages/db/src/task.test.ts @@ -0,0 +1,122 @@ +import {afterAll, beforeAll, describe, expect, test} from 'bun:test'; +import type {Result} from 'ts-results-es'; + +import type {ApiError} from '@proxy/utils'; + +import {db} from '.'; +import {createAgentTask, finishAgentTask, getAgentTask, touchAgentTask} from './task'; + +// How tasks left open are taken up again, against the local database: run with it up (`bun run +// dev`) and the environment loaded, `bun --env-file=../../.env test src/task.test.ts` from this +// package. Without DATABASE_URL, as `bun run test` runs, there is nothing to run against, and this +// is skipped. Everything made here is one organization's, deleted at the end. +const databaseUrl = process.env.DATABASE_URL; + +const HOUR = 60 * 60 * 1000; + +function unwrap(result: Result): T { + if (result.isErr()) { + throw new Error(JSON.stringify(result.error)); + } + return result.value; +} + +describe.skipIf(!databaseUrl)('tasks, against the database', () => { + const stamp = `${Date.now()}-${process.pid}`; + let userId = ''; + let orgId = ''; + let agentId = ''; + + beforeAll(async () => { + const user = await db.user.create({data: {email: `task-test-${stamp}@example.test`}}); + userId = user.id; + const org = await db.organization.create({ + data: {name: 'Task test', slug: `task-test-${stamp}`, ownerUserId: userId}, + }); + orgId = org.id; + const agent = await db.agent.create({ + data: { + orgId, + providerId: 'claude', + name: 'Claude', + username: `task-test-${stamp}`, + passwordHash: '', + }, + }); + agentId = agent.id; + }); + + afterAll(async () => { + if (orgId !== '') { + await db.organization.delete({where: {id: orgId}}); + } + if (userId !== '') { + await db.user.delete({where: {id: userId}}); + } + await db.$disconnect(); + }); + + // A task with a sub-task, both last worked on an hour ago, so both left open. + async function leftOpenLine(title: string): Promise<{parentId: string; childId: string}> { + const base = { + orgId, + agentId, + ownerUserId: null, + goal: 'Archive the newsletters', + via: 'mcp' as const, + }; + const parent = unwrap(await createAgentTask({...base, parentId: null, title})); + const child = unwrap( + await createAgentTask({...base, parentId: parent?.id ?? null, title: `${title}, part`}), + ); + if (!parent || !child) { + throw new Error('the tasks were not made'); + } + const anHourAgo = new Date(Date.now() - HOUR); + await db.agentTask.updateMany({ + where: {id: {in: [parent.id, child.id]}}, + data: {lastActiveAt: anHourAgo}, + }); + return {parentId: parent.id, childId: child.id}; + } + + test('a call for a sub-task left open takes it up again, and the task above it too', async () => { + const {parentId, childId} = await leftOpenLine('Triage inbox'); + const before = Date.now(); + + expect(unwrap(await touchAgentTask(orgId, agentId, childId))).toBe(true); + + const parent = unwrap(await getAgentTask(orgId, agentId, parentId)); + const child = unwrap(await getAgentTask(orgId, agentId, childId)); + expect(child?.lastActiveAt.getTime()).toBeGreaterThanOrEqual(before); + expect(parent?.lastActiveAt.getTime()).toBeGreaterThanOrEqual(before); + }); + + test('a task left open is taken up again to be finished, with what was done', async () => { + const {parentId} = await leftOpenLine('Plan the week'); + + const finished = unwrap(await finishAgentTask(orgId, agentId, parentId, 'Listed the events')); + + expect(finished?.finishedAt).not.toBeNull(); + expect(finished?.outcome).toBe('Listed the events'); + }); + + test('a sub-task left open under a task finished without it stays as it was', async () => { + const {parentId, childId} = await leftOpenLine('Book travel'); + unwrap(await finishAgentTask(orgId, agentId, parentId, 'Booked the train')); + const before = unwrap(await getAgentTask(orgId, agentId, childId)); + + expect(unwrap(await touchAgentTask(orgId, agentId, childId))).toBe(false); + const finished = unwrap(await finishAgentTask(orgId, agentId, childId, 'Found a hotel')); + + expect(finished?.finishedAt).toBeNull(); + expect(finished?.lastActiveAt).toEqual(before?.lastActiveAt); + }); + + test('a task finished is not taken up again', async () => { + const {parentId} = await leftOpenLine('Reply to Dana'); + unwrap(await finishAgentTask(orgId, agentId, parentId, null)); + + expect(unwrap(await touchAgentTask(orgId, agentId, parentId))).toBe(false); + }); +}); diff --git a/packages/db/src/task.ts b/packages/db/src/task.ts index c9778eb1..c72d87c7 100644 --- a/packages/db/src/task.ts +++ b/packages/db/src/task.ts @@ -47,9 +47,9 @@ const taskSelect = { * task it is a sub-task of, where it is one. The parent and every task above it are locked while * the sub-task is made, so none can be finished in between and leave the sub-task open, and each * takes the sub-task's start as the last thing done for it (`lastActiveAt`), so none is left open - * before it; `Ok(null)` means the parent is no longer under way, finished or left open - * (@proxy/utils/tasks). `ownerUserId` is the member whose own the agent is, who alone sees the - * task; null for a shared agent's. + * before it, and one left open is taken up again; `Ok(null)` means the parent, or a task above it, + * is finished (@proxy/utils/tasks). `ownerUserId` is the member whose own the agent is, who alone + * sees the task; null for a shared agent's. */ export async function createAgentTask(data: { orgId: string; @@ -89,8 +89,8 @@ export async function createAgentTask(data: { /** * Takes now as the last thing done for the agent's task `taskId` and every task above it, as a * request made for it is (`lastActiveAt`), so none is left open while the agent is still at work on - * it. `Ok(false)` means it is no longer under way, finished or left open (@proxy/utils/tasks), or - * the agent has no such task. + * it, and one left open is taken up again (@proxy/utils/tasks). `Ok(false)` means it, or a task + * above it, is finished, or the agent has no such task. */ export async function touchAgentTask( orgId: string, @@ -101,8 +101,9 @@ export async function touchAgentTask( } // Locks the task and every task above it, and takes `now` as the last thing done for each; false, -// with none changed, where the task is no longer under way. A task under way has every task above it -// under way too: finishing one finishes its sub-tasks, and none is left open before them. +// with none changed, where any of them is finished. So a task taken up again takes up every task +// above it too, none left open before it; and one left open when its parent was finished, which +// it was not finished with (finishSubTasks), stays as it is. async function touchLine( tx: Prisma.TransactionClient, {orgId, agentId, taskId}: {orgId: string; agentId: string; taskId: string}, @@ -126,12 +127,7 @@ async function touchLine( JOIN "line" ON "task"."id" = "line"."id" ORDER BY "line"."depth" DESC FOR UPDATE OF "task"`; - const task = above.find((row) => row.id === taskId); - if ( - !task || - task.finishedAt !== null || - task.lastActiveAt.getTime() <= now.getTime() - TASK_LEFT_OPEN_AFTER_MS - ) { + if (!above.some((row) => row.id === taskId) || above.some((row) => row.finishedAt !== null)) { return false; } // Where another instance's clock is ahead, one may have set a later time already. @@ -155,10 +151,12 @@ export async function getAgentTask( /** * Marks the agent's task finished now, with what was done where the agent said, and its sub-tasks - * still under way finished with it, in one transaction. One already finished keeps the time it was, - * and takes the outcome only where it has none, as a sub-task finished with its parent does; one - * left open (@proxy/utils/tasks) stays as it is, ended when it was left open. `Ok(null)` means the - * agent has no such task. + * still under way finished with it, in one transaction. Finishing is something done for it and + * every task above it, as a request is (touchLine), so one left open (@proxy/utils/tasks) is taken + * up again to be finished. One already finished keeps the time it was, and takes the outcome only + * where it has none, as a sub-task finished with its parent does; one left open under a task + * finished without it stays as it is, ended when it was left open. `Ok(null)` means the agent has + * no such task. */ export async function finishAgentTask( orgId: string, @@ -169,21 +167,13 @@ export async function finishAgentTask( return wrapDb(() => db.$transaction(async (tx) => { const finishedAt = new Date(); - const leftOpenBefore = new Date(finishedAt.getTime() - TASK_LEFT_OPEN_AFTER_MS); - const {count} = await tx.agentTask.updateMany({ - where: { - id: taskId, - orgId, - agentId, - finishedAt: null, - lastActiveAt: {gt: leftOpenBefore}, - }, - data: {finishedAt, outcome}, - }); - if (count > 0) { + const open = await touchLine(tx, {orgId, agentId, taskId}, finishedAt); + if (open) { + await tx.agentTask.update({where: {id: taskId}, data: {finishedAt, outcome}}); + const leftOpenBefore = new Date(finishedAt.getTime() - TASK_LEFT_OPEN_AFTER_MS); await finishSubTasks(tx, {orgId, agentId, finishedAt, leftOpenBefore}, [taskId]); } - if (count === 0 && outcome !== null) { + if (!open && outcome !== null) { await tx.agentTask.updateMany({ where: {id: taskId, orgId, agentId, finishedAt: {not: null}, outcome: null}, data: {outcome}, @@ -197,7 +187,7 @@ export async function finishAgentTask( // Finishes the sub-tasks of `parentIds` still under way, level by level down the tree. Each level // is read after the one above is updated, which waits for a sub-task being made under it // (createAgentTask), so none is left under way. One left open keeps when it ended, as do its own -// sub-tasks, left open no later than it. +// sub-tasks, left open no later than it, and can't be taken up again (touchLine). async function finishSubTasks( tx: Prisma.TransactionClient, at: {orgId: string; agentId: string; finishedAt: Date; leftOpenBefore: Date}, diff --git a/packages/docs/content/agents/mcp.mdx b/packages/docs/content/agents/mcp.mdx index 5084406e..f0c590c6 100644 --- a/packages/docs/content/agents/mcp.mdx +++ b/packages/docs/content/agents/mcp.mdx @@ -88,8 +88,9 @@ in a sentence or two. Every call it makes after that names the task, and a call a task that is finished, is refused. When the agent has done what you asked, or given it up, it finishes the task, saying what it did, and starts a new one for the next thing you ask for. Agents don't always say so: a task the agent never finishes is taken as left open once 20 minutes pass -with nothing done for it or its sub-tasks, and refused from then on too. An agent can have any -number of tasks under way at once, each with its own requests. +with nothing done for it or its sub-tasks. If the agent comes back to it, say after you stepped +away, its next call picks the task up again where it was. An agent can have any number of tasks +under way at once, each with its own requests. An agent may split what you asked for among sub-agents of its own, which sign in as it does and have its access. Each part is a sub-task, started with the task it was handed from as its parent, diff --git a/packages/utils/src/tasks.ts b/packages/utils/src/tasks.ts index 6ddf9483..d40d4c31 100644 --- a/packages/utils/src/tasks.ts +++ b/packages/utils/src/tasks.ts @@ -1,9 +1,10 @@ // An agent says when it has finished a task, but it often doesn't: a task still open 20 minutes // after anything was last done for it or for one of its sub-tasks at any depth, a sub-task started // or a request made, is taken as left open, ended then. So a task isn't ended while a sub-agent is -// still at work on part of it, and a parent is never left open before any of its sub-tasks. One -// left open is no longer under way, so no call can be made for it; the web app shows it as having -// ended at the last request made for it. +// still at work on part of it, and a parent is never left open before any of its sub-tasks. The web +// app shows one left open as having ended at the last request made for it. It is taken up again, +// with every task above it, by the next call made for it, a sub-task started under it or the agent +// finishing it, unless a task it is part of was finished meanwhile. export const TASK_LEFT_OPEN_AFTER_MS = 20 * 60 * 1000; diff --git a/packages/web/src/agent-loaders.ts b/packages/web/src/agent-loaders.ts index f3c0f2dd..fbd6623f 100644 --- a/packages/web/src/agent-loaders.ts +++ b/packages/web/src/agent-loaders.ts @@ -16,7 +16,7 @@ import {fetchErrorToResponse} from '@/lib/loader-utils'; // The agent side's pages. Every records request is made for the task in the page's address, // checked and logged by the api; a page shows what came back, a refusal included. A task the api -// refuses, unknown, finished or left open, sends the agent back to its tasks. +// refuses, unknown or finished, sends the agent back to its tasks. // No live session, a revoked login included, sends the agent to its sign-in. function unwrapAgentResult(result: Result): T { @@ -31,8 +31,8 @@ function unwrapAgentResult(result: Result): T { export type Outcome = {kind: 'ok'; value: T} | {kind: 'denied'} | {kind: 'missing'}; -// A run refused for its task, one finished since the page opened or left open, sends -// the agent back to its tasks, rather than show the page as refused or missing. +// A run refused for its task, one finished since the page opened, sends the agent back to its +// tasks, rather than show the page as refused or missing. function toOutcome(taskId: string, result: Result): Outcome { if (result.isErr() && isTaskRefusal(result.error)) { throw redirect(taskRefusedPath(taskId)); diff --git a/packages/web/src/client/agent-client.ts b/packages/web/src/client/agent-client.ts index 313dab5e..df674090 100644 --- a/packages/web/src/client/agent-client.ts +++ b/packages/web/src/client/agent-client.ts @@ -56,7 +56,7 @@ export function listAgentTasks() { return apiRequest('GET', '/api/agent/tasks', z.object({tasks: z.array(agentTaskSchema)})); } -/** The task while it is under way: 404 for one unknown, 409 for one finished or left open. */ +/** The task while it is not finished: 404 for one unknown, 409 for one finished. */ export function getAgentTask(taskId: string) { return apiRequest('GET', `/api/agent/tasks/${encodeURIComponent(taskId)}`, agentTaskSchema); } diff --git a/packages/web/src/lib/agent-errors.ts b/packages/web/src/lib/agent-errors.ts index 4005652f..efa50062 100644 --- a/packages/web/src/lib/agent-errors.ts +++ b/packages/web/src/lib/agent-errors.ts @@ -13,7 +13,7 @@ export function apiMessage(body: unknown): string | null { export function agentErrorMessage(error: FetchError): string { if (isTaskRefusal(error)) { - return 'This task can’t be worked on anymore: it is finished, went 20 minutes with nothing done for it, or isn’t this login’s. Switch task, or start a new one.'; + return 'This task can’t be worked on anymore: it, or the task it is part of, is finished, or it isn’t this login’s. Switch task, or start a new one.'; } if (error.kind === 'http' && error.status === 403) { return 'This login is not allowed to do that.'; diff --git a/packages/web/src/lib/agent-task.test.ts b/packages/web/src/lib/agent-task.test.ts index 5875ad05..261930de 100644 --- a/packages/web/src/lib/agent-task.test.ts +++ b/packages/web/src/lib/agent-task.test.ts @@ -16,7 +16,7 @@ describe('isTaskRefusal', () => { body, }); - test('is a task that is unknown, finished or left open, and no other refusal', () => { + test('is a task that is unknown or finished, and no other refusal', () => { expect(isTaskRefusal(refused(404, {error: 'not_found', resource: 'task'}))).toBe(true); expect( isTaskRefusal( diff --git a/packages/web/src/lib/agent-task.ts b/packages/web/src/lib/agent-task.ts index b60cdd5b..b0eac240 100644 --- a/packages/web/src/lib/agent-task.ts +++ b/packages/web/src/lib/agent-task.ts @@ -26,7 +26,8 @@ export function useTaskId(): string { /** * The api refused a run for its task, not for anything else about it: the task is unknown (404 for - * a `task`), or finished or left open (409). The pages send the agent back to its tasks. + * a `task`), or finished, or under a task finished (409). The pages send the agent back to its + * tasks. */ export function isTaskRefusal(error: FetchError): boolean { if (error.kind !== 'http') { diff --git a/packages/web/src/pages/activity-page.tsx b/packages/web/src/pages/activity-page.tsx index 02339753..753c7b9f 100644 --- a/packages/web/src/pages/activity-page.tsx +++ b/packages/web/src/pages/activity-page.tsx @@ -172,7 +172,7 @@ function TaskCard({ } // Finished once the agent says so; one it never finished is taken as left open 20 minutes after -// anything was last done for it or its sub-tasks (@proxy/utils/tasks). +// anything was last done for it or its sub-tasks, until the next request (@proxy/utils/tasks). function TaskBadge({task}: {task: ActivityTask}) { // When the page was opened, as the log's entries are. const [now] = useState(() => new Date()); diff --git a/packages/web/src/pages/agent/agent-home-page.test.tsx b/packages/web/src/pages/agent/agent-home-page.test.tsx index ec148700..88187dfe 100644 --- a/packages/web/src/pages/agent/agent-home-page.test.tsx +++ b/packages/web/src/pages/agent/agent-home-page.test.tsx @@ -74,7 +74,9 @@ describe('AgentTaskPage', () => { renderAgentSide('/agent?refused=task-9'); expect( - await screen.findByText(/^Task task-9 can’t be worked on anymore: it is finished/), + await screen.findByText( + /^Task task-9 can’t be worked on anymore: it, or the task it is part of, is finished/, + ), ).toBeDefined(); }); }); diff --git a/packages/web/src/pages/agent/agent-task-page.tsx b/packages/web/src/pages/agent/agent-task-page.tsx index cd20b632..07749297 100644 --- a/packages/web/src/pages/agent/agent-task-page.tsx +++ b/packages/web/src/pages/agent/agent-task-page.tsx @@ -29,7 +29,7 @@ export function AgentTaskPage() { const notice = noticeFromState(useLocation().state) ?? (refused - ? `Task ${refused} can’t be worked on anymore: it is finished, went 20 minutes with nothing done for it, or isn’t this login’s. Carry on with another, or start one for what you are doing now` + ? `Task ${refused} can’t be worked on anymore: it, or the task it is part of, is finished, or it isn’t this login’s. Carry on with another, or start one for what you are doing now` : null); const [error, setError] = useState(null); From 72380a8731f9f17bf79becaee0f922717d72dbfa Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:31:55 +0000 Subject: [PATCH 3/3] keep the old task column through the deploy, read a task page without taking it up, and end a task left open at its last activity The migration adds lastActiveAt beside lastStartedAt rather than renaming it, so instances still on the version before keep working while a deploy replaces them; a later migration drops it. Opening a task on the agent pages only reads it. A sub-agent whose task was finished while it was at work gets a new one, as it has no start_task of its own. A task left open is drawn as ending at its lastActiveAt, which the timeline reads it by too, so its bar and status agree. The once-a-minute write lives in touchAgentTask, and starting a sub-task takes its parent up once. --- packages/api/src/activity/route.test.ts | 12 +- packages/api/src/activity/route.ts | 10 +- packages/api/src/agent-side/route.ts | 4 +- packages/api/src/agent-side/tasks.test.ts | 41 ++++--- packages/api/src/agent-side/tasks.ts | 57 ++++++---- packages/api/src/agent-side/tools.test.ts | 9 +- packages/api/src/harness/thread-task.test.ts | 81 ++++++++++++++ packages/api/src/harness/thread-task.ts | 45 ++++++++ packages/api/src/harness/tool-job.test.ts | 5 +- packages/api/src/harness/tool-job.ts | 8 +- .../migration.sql | 8 +- packages/db/prisma/schema.prisma | 5 +- packages/db/src/audit.ts | 29 ----- packages/db/src/harness.ts | 10 ++ packages/db/src/task.test.ts | 85 ++++++++++++-- packages/db/src/task.ts | 105 +++++++++++++----- packages/utils/src/tasks.ts | 2 +- packages/web/src/lib/timeline-window.test.ts | 23 +++- packages/web/src/lib/timeline-window.ts | 16 +-- 19 files changed, 414 insertions(+), 141 deletions(-) create mode 100644 packages/api/src/harness/thread-task.test.ts create mode 100644 packages/api/src/harness/thread-task.ts rename packages/db/prisma/migrations/{v037_rename_task_last_started_at_to_last_active_at => v037_add_task_last_active_at}/migration.sql (64%) diff --git a/packages/api/src/activity/route.test.ts b/packages/api/src/activity/route.test.ts index 5a297103..00941c65 100644 --- a/packages/api/src/activity/route.test.ts +++ b/packages/api/src/activity/route.test.ts @@ -5,7 +5,6 @@ import type {Viewer} from '@proxy/db/viewer'; const listAuditEntries = mock(); const listTaskEntries = mock(); -const listLastTaskEntries = mock(); const listMemberNames = mock(async (): Promise => Ok([])); @@ -14,7 +13,7 @@ mock.module('@proxy/db/organization', () => ({getMembership: mock(async () => Ok const requireViewer = mock(async () => Ok({orgId: 'org-1', userId: 'user-1', slug: 'org-1', name: 'Workspace', role: 'owner', onboarded: true, experiments: []})); mock.module('../utils/user-org', () => ({requireViewer, requireOwner: requireViewer})); -mock.module('@proxy/db/audit', () => ({listAuditEntries, listTaskEntries, listLastTaskEntries})); +mock.module('@proxy/db/audit', () => ({listAuditEntries, listTaskEntries})); const listAgentTasks = mock(); const listAgentTasksBetween = mock(); @@ -33,7 +32,6 @@ beforeEach(() => { listAgentTasks.mockResolvedValue(Ok([])); listAgentTasksBetween.mockResolvedValue(Ok([])); listSubTasks.mockResolvedValue(Ok([])); - listLastTaskEntries.mockResolvedValue(Ok([])); setSystemTime(new Date('2026-10-15T23:30:00Z')); }); @@ -81,8 +79,6 @@ describe('handleActivityTimelineRoute', () => { expect(listAgentTasksBetween).toHaveBeenCalledWith(viewer, new Date('2026-10-14T23:30:00Z'), new Date('2026-10-15T23:30:00Z'), 200); expect(listSubTasks).toHaveBeenCalledWith(viewer, ['task-1'], 1000, {newest: true}); expect(listTaskEntries).toHaveBeenCalledWith(viewer, ['task-1'], 2000, {from: new Date('2026-10-14T23:30:00Z'), to: new Date('2026-10-15T23:30:00Z')}); - // Read up to now, there is nothing after the stretch to read. - expect(listLastTaskEntries).not.toHaveBeenCalled(); const {createdAt, ...fields} = entry; expect(result.unwrap()).toEqual({ now: '2026-10-15T23:30:00.000Z', @@ -151,9 +147,7 @@ describe('handleActivityTimelineRoute', () => { error: null, createdAt: new Date('2026-10-01T10:30:00Z'), }; - const after = {...inside, id: 'after', createdAt: new Date('2026-10-03T08:00:00Z')}; listTaskEntries.mockResolvedValue(Ok([inside])); - listLastTaskEntries.mockResolvedValue(Ok([after])); const {handleActivityTimelineRoute} = await import('./route'); const result = await handleActivityTimelineRoute(makeRequest({from: '2026-10-01T00:00:00Z', to: '2026-10-02T00:00:00Z'}) as never); @@ -162,9 +156,7 @@ describe('handleActivityTimelineRoute', () => { expect(listSubTasks).toHaveBeenCalledWith(viewer, ['pricing', 'notes'], 1000, {newest: true}); const stretch = {from: new Date('2026-10-01T00:00:00Z'), to: new Date('2026-10-02T00:00:00Z')}; expect(listTaskEntries).toHaveBeenCalledWith(viewer, ['pricing', 'sub', 'notes'], 2000, stretch); - // Each task's last request after the stretch, for when one given up on ended. - expect(listLastTaskEntries).toHaveBeenCalledWith(viewer, ['pricing', 'sub', 'notes'], stretch.to); - expect(result.unwrap().entries.map((one) => one.id)).toEqual(['inside', 'after']); + expect(result.unwrap().entries.map((one) => one.id)).toEqual(['inside']); expect(result.unwrap().to).toBe('2026-10-02T00:00:00.000Z'); expect(result.unwrap().tasks.map((one) => one.id)).toEqual(['pricing', 'sub', 'notes']); }); diff --git a/packages/api/src/activity/route.ts b/packages/api/src/activity/route.ts index 1ca79dbd..138fbb03 100644 --- a/packages/api/src/activity/route.ts +++ b/packages/api/src/activity/route.ts @@ -2,7 +2,7 @@ import {Err, type Result} from 'ts-results-es'; import {z} from 'zod'; import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils'; -import {type AuditEntryRow, listAuditEntries, listLastTaskEntries, listTaskEntries} from '@proxy/db/audit'; +import {type AuditEntryRow, listAuditEntries, listTaskEntries} from '@proxy/db/audit'; import {listMemberNames} from '@proxy/db/organization'; import type {Look} from '@proxy/utils/harness'; import {listPersonasById} from '@proxy/db/harness'; @@ -197,8 +197,7 @@ export type ActivityTimelineResponse = { to: string; // Every task under way at some point between `from` and `to`, with its sub-tasks, newest first. tasks: ActivityTaskResponse[]; - // The requests made for them between `from` and `to`, in the order they were made; and, where - // `to` is before now, each task's last made after it. + // The requests made for them between `from` and `to`, in the order they were made. entries: AuditEntryResponse[]; }; @@ -227,15 +226,12 @@ export function handleActivityTimelineRoute(request: AuthenticatedRequest): Prom const ids = tasks.map((task) => task.id); // Only the stretch read shares out the most requests, so none goes to what came after it. const rows = $(await listTaskEntries(viewer, ids, MAX_TIMELINE_ENTRIES, {from, to})); - // Read back from before now, a task left open may have had requests made after the stretch: - // the last of them says when it ended (@/lib/timeline-window's endsOf in the web app). - const later = to < now ? $(await listLastTaskEntries(viewer, ids, to)) : []; return { now: now.toISOString(), from: from.toISOString(), to: to.toISOString(), tasks: $(await toActivityTasks(viewer, tasks)), - entries: $(await toEntries(viewer, [...rows, ...later])), + entries: $(await toEntries(viewer, rows)), }; }); } diff --git a/packages/api/src/agent-side/route.ts b/packages/api/src/agent-side/route.ts index 0e40402f..7fb514f9 100644 --- a/packages/api/src/agent-side/route.ts +++ b/packages/api/src/agent-side/route.ts @@ -29,8 +29,8 @@ export function handleAgentListTasksRoute(request: AgentRequest): Promise> { return getAgentOpenTask(request.agent, request.params.taskId ?? ''); diff --git a/packages/api/src/agent-side/tasks.test.ts b/packages/api/src/agent-side/tasks.test.ts index 004df6c3..9d3e2533 100644 --- a/packages/api/src/agent-side/tasks.test.ts +++ b/packages/api/src/agent-side/tasks.test.ts @@ -6,8 +6,9 @@ const getAgentTask = mock(); const finishAgentTask = mock(); const listOpenAgentTasks = mock(); const touchAgentTask = mock(); +const isAgentTaskOpen = mock(); -mock.module('@proxy/db/task', () => ({createAgentTask, getAgentTask, finishAgentTask, listOpenAgentTasks, touchAgentTask})); +mock.module('@proxy/db/task', () => ({createAgentTask, getAgentTask, finishAgentTask, isAgentTaskOpen, listOpenAgentTasks, touchAgentTask})); const agent = {agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'claude', name: 'Claude', orgSlug: 'org-1', experiments: []}; const caller = {agent, via: 'mcp' as const}; @@ -35,6 +36,7 @@ beforeEach(() => { getAgentTask.mockResolvedValue(Ok(row)); finishAgentTask.mockResolvedValue(Ok(finishedRow)); touchAgentTask.mockResolvedValue(Ok(true)); + isAgentTaskOpen.mockResolvedValue(Ok(true)); }); describe('startAgentTask', () => { @@ -110,13 +112,14 @@ describe('startAgentTask', () => { expect(createAgentTask).not.toHaveBeenCalled(); }); - test('a parent left open is taken up again, and the sub-task started under it', async () => { + test('a parent left open has the sub-task started under it, which takes it up again, not before', async () => { getAgentTask.mockResolvedValue(Ok({...parentRow, lastActiveAt: new Date('2026-10-16T08:30:00Z')})); createAgentTask.mockResolvedValue(Ok({...row, parentId: 'task-0'})); const {startAgentTask} = await import('./tasks'); const result = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'}); - expect(touchAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', 'task-0'); + expect(touchAgentTask).not.toHaveBeenCalled(); + expect(createAgentTask).toHaveBeenCalledWith(expect.objectContaining({parentId: 'task-0'})); expect(result.unwrap()).toEqual({...response, parent: 'task-0'}); }); @@ -126,7 +129,7 @@ describe('startAgentTask', () => { const {startAgentTask} = await import('./tasks'); const result = await startAgentTask(caller, {title: 'Triage inbox', goal: 'Archive the newsletters', parent: 'task-0'}); - expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('Task task-0, or a task it is part of, is finished')}); + expect(result.unwrapErr()).toMatchObject({kind: 'conflict', message: expect.stringContaining('Task task-0 (Plan the week), or a task it is part of, is finished')}); }); }); }); @@ -195,16 +198,7 @@ describe('requireOpenTask', () => { const result = await requireOpenTask(agent, 'task-1'); expect(getAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', 'task-1'); - expect(touchAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', 'task-1'); - expect(result.unwrap()).toBe(row); - }); - - test('a call within a minute of the last thing done for the task is not written again', async () => { - const {requireOpenTask} = await import('./tasks'); - setSystemTime(new Date('2026-10-16T09:00:59Z')); - const result = await requireOpenTask(agent, 'task-1'); - - expect(touchAgentTask).not.toHaveBeenCalled(); + expect(touchAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', row); expect(result.unwrap()).toBe(row); }); @@ -229,7 +223,7 @@ describe('requireOpenTask', () => { setSystemTime(new Date('2026-10-16T11:00:00Z')); const result = await requireOpenTask(agent, 'task-1'); - expect(touchAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', 'task-1'); + expect(touchAgentTask).toHaveBeenCalledWith('org-1', 'agent-1', row); expect(result.unwrap()).toBe(row); }); @@ -259,4 +253,21 @@ describe('getAgentOpenTask', () => { expect(open.unwrap()).toEqual(response); expect(finished.unwrapErr()).toMatchObject({kind: 'conflict', statusCode: 409}); }); + + test('only reads the task: one left open stays so, and one under a task finished is refused', async () => { + const {getAgentOpenTask} = await import('./tasks'); + setSystemTime(new Date('2026-10-16T11:00:00Z')); + const leftOpen = await getAgentOpenTask(agent, 'task-1'); + isAgentTaskOpen.mockResolvedValue(Ok(false)); + const under = await getAgentOpenTask(agent, 'task-1'); + + expect(leftOpen.unwrap()).toEqual(response); + expect(isAgentTaskOpen).toHaveBeenCalledWith('org-1', 'agent-1', 'task-1'); + expect(under.unwrapErr()).toEqual({ + kind: 'conflict', + statusCode: 409, + message: 'Task task-1 (Triage inbox), or a task it is part of, is finished. Start a new task for what you are doing now, and give its id as task.', + }); + expect(touchAgentTask).not.toHaveBeenCalled(); + }); }); diff --git a/packages/api/src/agent-side/tasks.ts b/packages/api/src/agent-side/tasks.ts index 271d8105..9106a38b 100644 --- a/packages/api/src/agent-side/tasks.ts +++ b/packages/api/src/agent-side/tasks.ts @@ -3,7 +3,7 @@ import {z} from 'zod'; import {ApiErr, type ApiError, Do, parseSchema, requirePresent} from '@proxy/utils'; import type {SignedInAgent} from '@proxy/db/agent'; -import {type AgentTaskRow, createAgentTask, finishAgentTask as markAgentTaskFinished, getAgentTask, listOpenAgentTasks, touchAgentTask} from '@proxy/db/task'; +import {type AgentTaskRow, createAgentTask, finishAgentTask as markAgentTaskFinished, getAgentTask, isAgentTaskOpen, listOpenAgentTasks, touchAgentTask} from '@proxy/db/task'; import type {AgentCaller} from './tools'; @@ -25,12 +25,10 @@ import type {AgentCaller} from './tools'; const MAX_TITLE = 120; const MAX_GOAL = 2000; const MAX_OUTCOME = 2000; -// How stale a task's last activity may be before a call made for it writes it again: so a call is -// not a write each, and a task is left open 19 to 20 minutes after the last call made for it. -const TOUCH_AFTER_MS = 60 * 1000; // Why a task can't be worked on any more, where it, or a task above it, was finished since it was // read; or, left open, a task above it was finished without it (@proxy/utils/tasks). -const finishedAbove = (taskId: string, title: string) => `Task ${taskId} (${title}), or a task it is part of, is finished. Start a new task for what you are doing now, and give its id as task.`; +const finishedAbove = (task: Pick) => + `Task ${task.id} (${task.title}), or a task it is part of, is finished. Start a new task for what you are doing now, and give its id as task.`; const startSchema = z.object({title: z.string(), goal: z.string(), parent: z.string().optional()}); const finishSchema = z.object({task: z.string(), outcome: z.string().optional()}); @@ -76,15 +74,17 @@ export function startAgentTask(caller: AgentCaller, args: unknown): Promise ({tasks: $(await listOpenAgentTasks(agent.orgId, agent.agentId)).map(toResponse)})); } -/** The agent's task `taskId` while it is not finished, refused as a call made for it would be (`requireOpenTask`). */ +/** + * The agent's task `taskId` while it is not finished, refused as a call made for it would be + * (`requireOpenTask`), but only read: one left open stays so until a call is made for it. + */ export function getAgentOpenTask(agent: SignedInAgent, taskId: string): Promise> { - return Do(async ($) => toResponse($(await requireOpenTask(agent, taskId)))); + return Do(async ($) => { + const task = $(await readUnfinishedTask(agent, taskId)); + if (!$(await isAgentTaskOpen(agent.orgId, agent.agentId, taskId))) { + return $(Err(ApiErr.conflict(finishedAbove(task)))); + } + return toResponse(task); + }); } /** @@ -128,18 +137,24 @@ export function finishAgentTask(caller: AgentCaller, args: unknown): Promise> { + return Do(async ($) => { + const task = $(await readUnfinishedTask(agent, taskId)); + // False where it was finished since it was read, or, left open, a task above it was. + if (!$(await touchAgentTask(agent.orgId, agent.agentId, task))) { + return $(Err(ApiErr.conflict(finishedAbove(task)))); + } + return task; + }); +} + +// The agent's task `taskId`, read: unknown, or another agent's, is not found, and one finished a +// conflict. +function readUnfinishedTask(agent: SignedInAgent, taskId: string): Promise> { return Do(async ($) => { const task = $(requirePresent($(await getAgentTask(agent.orgId, agent.agentId, taskId)), ApiErr.notFound('task', taskId))); if (task.finishedAt !== null) { return $(Err(ApiErr.conflict(`Task ${taskId} (${task.title}) is finished. Start a new task for what you are doing now, and give its id as task.`))); } - if (Date.now() - task.lastActiveAt.getTime() < TOUCH_AFTER_MS) { - return task; - } - // False where it was finished since it was read, or, left open, a task above it was. - if (!$(await touchAgentTask(agent.orgId, agent.agentId, taskId))) { - return $(Err(ApiErr.conflict(finishedAbove(taskId, task.title)))); - } return task; }); } diff --git a/packages/api/src/agent-side/tools.test.ts b/packages/api/src/agent-side/tools.test.ts index e022e6aa..b2718e96 100644 --- a/packages/api/src/agent-side/tools.test.ts +++ b/packages/api/src/agent-side/tools.test.ts @@ -14,7 +14,14 @@ mock.module('@proxy/db/audit', () => ({logAgentRequest})); const getAgentTask = mock(); -mock.module('@proxy/db/task', () => ({getAgentTask, createAgentTask: mock(), finishAgentTask: mock(), listOpenAgentTasks: mock(), touchAgentTask: mock(async () => Ok(true))})); +mock.module('@proxy/db/task', () => ({ + getAgentTask, + createAgentTask: mock(), + finishAgentTask: mock(), + isAgentTaskOpen: mock(), + listOpenAgentTasks: mock(), + touchAgentTask: mock(async () => Ok(true)), +})); const listConnectionsWithCredential = mock(); diff --git a/packages/api/src/harness/thread-task.test.ts b/packages/api/src/harness/thread-task.test.ts new file mode 100644 index 00000000..4dc3452d --- /dev/null +++ b/packages/api/src/harness/thread-task.test.ts @@ -0,0 +1,81 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Ok} from 'ts-results-es'; + +const getAgentTask = mock(); +const createAgentTask = mock(); +mock.module('@proxy/db/task', () => ({getAgentTask, createAgentTask})); + +const setThreadTask = mock(async () => Ok(undefined)); +mock.module('@proxy/db/harness', () => ({setThreadTask})); + +const now = new Date('2026-10-21T10:00:00Z'); +// A sub-agent's thread, at work on its task. +const thread = { + id: 'thread-1', + orgId: 'org-1', + agentId: 'agent-1', + personaId: 'persona-1', + userId: 'user-1', + parentThreadId: 'thread-0', + parentCallId: 'c0', + title: 'Find the invoices', + taskId: 'task-1', + finishedAt: null, + createdAt: now, +}; +const task = { + id: 'task-1', + agentId: 'agent-1', + personaId: 'persona-1', + parentId: null, + title: 'Find the invoices', + goal: 'Collect the October invoices from the mail', + via: 'harness' as const, + createdAt: now, + finishedAt: null, + outcome: null, + lastActiveAt: now, +}; + +beforeEach(() => { + mock.clearAllMocks(); + createAgentTask.mockResolvedValue(Ok({...task, id: 'task-2'})); +}); + +describe('ensureThreadTask', () => { + test('keeps the task it has while it is not finished, left open included', async () => { + getAgentTask.mockResolvedValue(Ok({...task, lastActiveAt: new Date('2026-10-20T10:00:00Z')})); + const {ensureThreadTask} = await import('./thread-task'); + const result = await ensureThreadTask(thread); + + expect(result.unwrap()).toBe('task-1'); + expect(createAgentTask).not.toHaveBeenCalled(); + }); + + test('one finished while the sub-agent is at work is replaced with a new one for the same work', async () => { + getAgentTask.mockResolvedValue(Ok({...task, finishedAt: new Date('2026-10-21T09:00:00Z')})); + const {ensureThreadTask} = await import('./thread-task'); + const result = await ensureThreadTask(thread); + + expect(result.unwrap()).toBe('task-2'); + expect(createAgentTask).toHaveBeenCalledWith({ + orgId: 'org-1', + agentId: 'agent-1', + ownerUserId: 'user-1', + personaId: 'persona-1', + parentId: null, + title: 'Find the invoices', + goal: 'Collect the October invoices from the mail', + via: 'harness', + }); + expect(setThreadTask).toHaveBeenCalledWith('thread-1', 'task-2'); + }); + + test('the agent talking with the person has no task to make calls for', async () => { + const {ensureThreadTask} = await import('./thread-task'); + const result = await ensureThreadTask({...thread, taskId: null}); + + expect(result.unwrapErr().kind).toBe('conflict'); + expect(getAgentTask).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/harness/thread-task.ts b/packages/api/src/harness/thread-task.ts new file mode 100644 index 00000000..a94da5df --- /dev/null +++ b/packages/api/src/harness/thread-task.ts @@ -0,0 +1,45 @@ +import {Err, type Result} from 'ts-results-es'; + +import {type ApiError, ApiErr, Do, requirePresent} from '@proxy/utils'; +import {createAgentTask, getAgentTask} from '@proxy/db/task'; +import {setThreadTask, type ThreadRow} from '@proxy/db/harness'; + +/** + * The task a sub-agent's calls are made for, as every agent's calls are, so the activity log says + * which piece of work each request was for. It is the member's, as the conversation it was + * delegated from is, so what the sub-agent did is theirs alone to see. A sub-agent starts with + * one, made as it is (db/harness startSubAgent). One left open, as when it slept long, is taken up + * again by its next call (@proxy/utils/tasks); one finished while it is still at work, as by + * finish_task from the agent's login elsewhere, is replaced with a new one for the same piece of + * work, the title and goal the one before had, since the gate refuses calls for it and the + * sub-agent has no start_task of its own (tool-surface). The agent talking with the person has + * none: it makes no calls but to delegate. + */ + +/** The sub-agent's task, not finished: the one it has, or a new one in its place. */ +export function ensureThreadTask(thread: ThreadRow): Promise> { + return Do(async ($) => { + if (thread.taskId === null) { + return $(Err(ApiErr.conflict('The agent talking with the person makes no calls of its own'))); + } + const task = $(requirePresent($(await getAgentTask(thread.orgId, thread.agentId, thread.taskId)), ApiErr.notFound('task', thread.taskId))); + if (task.finishedAt === null) { + return task.id; + } + const made = $( + await createAgentTask({ + orgId: thread.orgId, + agentId: thread.agentId, + ownerUserId: thread.userId, + personaId: thread.personaId, + parentId: null, + title: task.title, + goal: task.goal, + via: 'harness', + }), + ); + const id = $(requirePresent(made, ApiErr.internalError(new Error('A task with no parent was not made')))).id; + $(await setThreadTask(thread.id, id)); + return id; + }); +} diff --git a/packages/api/src/harness/tool-job.test.ts b/packages/api/src/harness/tool-job.test.ts index c6dee028..458335af 100644 --- a/packages/api/src/harness/tool-job.test.ts +++ b/packages/api/src/harness/tool-job.test.ts @@ -12,6 +12,9 @@ const getThreadAgent = mock(async () => Ok(signedIn)); const startSubAgent = mock(async () => Ok(true)); mock.module('@proxy/db/harness', () => ({getJobThread, getEntry, completeToolJob, failJob, finishJob, getThreadAgent, startSubAgent})); +const ensureThreadTask = mock(async () => Ok('task-2')); +mock.module('./thread-task', () => ({ensureThreadTask})); + const callMcpTool = mock(); mock.module('../mcp/tools', () => ({callMcpTool})); @@ -70,7 +73,7 @@ describe('runToolJob', () => { const result = await runToolJob(job('c1'), 'w1', now); expect(result.isOk()).toBe(true); - expect(callMcpTool).toHaveBeenCalledWith(signedIn, 'read', {connection: 'mail-1', tool: 'emails_list', task: 'task-1'}, false, 'harness'); + expect(callMcpTool).toHaveBeenCalledWith(signedIn, 'read', {connection: 'mail-1', tool: 'emails_list', task: 'task-2'}, false, 'harness'); expect(completeToolJob).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', {callId: 'c1', text: '{"records":[]}', isError: false}); }); diff --git a/packages/api/src/harness/tool-job.ts b/packages/api/src/harness/tool-job.ts index 90a94442..aeb3a2fe 100644 --- a/packages/api/src/harness/tool-job.ts +++ b/packages/api/src/harness/tool-job.ts @@ -5,6 +5,7 @@ import {callArguments, DELEGATE_TOOL, delegateArgs, SLEEP_TOOL, sleepUntil, type import {completeToolJob, failJob, finishJob, getEntry, getJobThread, getThreadAgent, type JobRow, startSubAgent, type ThreadRow} from '@proxy/db/harness'; import {callMcpTool} from '../mcp/tools'; +import {ensureThreadTask} from './thread-task'; import {isGateTool} from './tool-surface'; /** @@ -46,12 +47,9 @@ function cutResult(text: string): string { // Runs the call through the gate, as the sub-agent acts in the thread, for its task. function runGateTool(thread: ThreadRow, name: string, args: Record): Promise> { return Do(async ($) => { - if (thread.taskId === null) { - return $(Err(ApiErr.conflict('The agent talking with the person makes no calls of its own'))); - } const signedIn = $(requirePresent($(await getThreadAgent(thread)), ApiErr.notFound('agent', thread.agentId))); - // One left open, as when the sub-agent slept long, is taken up again by the call (@proxy/utils/tasks). - const result = await callMcpTool(signedIn, name, {...args, task: thread.taskId}, false, 'harness'); + const taskId = $(await ensureThreadTask(thread)); + const result = await callMcpTool(signedIn, name, {...args, task: taskId}, false, 'harness'); return {text: cutResult(result.content.map((part) => part.text).join('\n')), isError: result.isError === true}; }); } diff --git a/packages/db/prisma/migrations/v037_rename_task_last_started_at_to_last_active_at/migration.sql b/packages/db/prisma/migrations/v037_add_task_last_active_at/migration.sql similarity index 64% rename from packages/db/prisma/migrations/v037_rename_task_last_started_at_to_last_active_at/migration.sql rename to packages/db/prisma/migrations/v037_add_task_last_active_at/migration.sql index e0960983..fb4a8162 100644 --- a/packages/db/prisma/migrations/v037_rename_task_last_started_at_to_last_active_at/migration.sql +++ b/packages/db/prisma/migrations/v037_add_task_last_active_at/migration.sql @@ -1,8 +1,14 @@ -- A task never finished is taken as left open 20 minutes after anything was last done for it or -- for one of its sub-tasks at any depth, no longer a day after the last of them was started. Each -- task's is the latest start, or request made, in the tree below it. +-- Added beside "lastStartedAt", not in its place: the instances still on the version before read +-- and write that while a deploy replaces them. A later migration drops it. Both take a default, so +-- a task either version makes has both. -- AlterTable -ALTER TABLE "AgentTask" RENAME COLUMN "lastStartedAt" TO "lastActiveAt"; +ALTER TABLE "AgentTask" ADD COLUMN "lastActiveAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ALTER COLUMN "lastStartedAt" SET DEFAULT CURRENT_TIMESTAMP; + +UPDATE "AgentTask" SET "lastActiveAt" = "lastStartedAt"; WITH RECURSIVE "below" AS ( SELECT "id" AS "taskId", "id" FROM "AgentTask" diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index d476e377..bc258340 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -426,7 +426,10 @@ model AgentTask { // again (@proxy/utils/tasks). Never before any of its sub-tasks', so a parent is never left open // while one is under way. Set with createdAt, and on it and every task above it when a sub-task // starts, a request is made or it is finished. - lastActiveAt DateTime + lastActiveAt DateTime @default(now()) + // What lastActiveAt took over from, read by nothing: kept only for the instances still on the + // version before while a deploy replaces them, and dropped by the next migration. + lastStartedAt DateTime @default(now()) createdAt DateTime @default(now()) diff --git a/packages/db/src/audit.ts b/packages/db/src/audit.ts index d0e22c30..3459c62f 100644 --- a/packages/db/src/audit.ts +++ b/packages/db/src/audit.ts @@ -185,35 +185,6 @@ export async function listTaskEntries( }); } -/** - * The last request the viewer sees made for each of `taskIds` after `after`, where one was, in the - * order they were made: for a stretch of time read that ends before now, when a task left open - * last had anything done for it, which is when the web app shows it as having ended. - */ -export async function listLastTaskEntries( - viewer: Viewer, - taskIds: string[], - after: Date, -): Promise> { - if (taskIds.length === 0) { - return wrapDb(async () => []); - } - return wrapDb(async () => { - const last = await db.$queryRaw>` - SELECT DISTINCT ON ("taskId") "id" - FROM "AuditEntry" - WHERE ${visibleSql(viewer)} - AND "taskId" IN (${Prisma.join(taskIds)}) - AND "createdAt" > ${after} - ORDER BY "taskId", "createdAt" DESC, "id" DESC`; - return db.auditEntry.findMany({ - where: {id: {in: last.map((row) => row.id)}}, - orderBy: {createdAt: 'asc'}, - select: entrySelect, - }); - }); -} - /** * How many requests each task keeps where tasks with `counts` requests share `take`: the most that * fits, a task with fewer keeping all of its own, and at least one; null where every request fits. diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts index 763b6383..d1940198 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -463,6 +463,16 @@ export async function listJobEvents(threadId: string): Promise> { + return (await wrapDb(() => db.agentThread.update({where: {id: threadId}, data: {taskId}}))).map( + () => undefined, + ); +} + // Locks the thread's row for the rest of the transaction, and gives the thread as it is; null // when there is no such thread. async function lockThread(tx: Tx, threadId: string): Promise { diff --git a/packages/db/src/task.test.ts b/packages/db/src/task.test.ts index 2890a490..23b2c220 100644 --- a/packages/db/src/task.test.ts +++ b/packages/db/src/task.test.ts @@ -4,7 +4,15 @@ import type {Result} from 'ts-results-es'; import type {ApiError} from '@proxy/utils'; import {db} from '.'; -import {createAgentTask, finishAgentTask, getAgentTask, touchAgentTask} from './task'; +import { + type AgentTaskRow, + createAgentTask, + finishAgentTask, + getAgentTask, + isAgentTaskOpen, + listAgentTasksBetween, + touchAgentTask, +} from './task'; // How tasks left open are taken up again, against the local database: run with it up (`bun run // dev`) and the environment loaded, `bun --env-file=../../.env test src/task.test.ts` from this @@ -56,7 +64,7 @@ describe.skipIf(!databaseUrl)('tasks, against the database', () => { await db.$disconnect(); }); - // A task with a sub-task, both last worked on an hour ago, so both left open. + // A task with a sub-task, both started and last worked on an hour ago, so both left open. async function leftOpenLine(title: string): Promise<{parentId: string; childId: string}> { const base = { orgId, @@ -75,16 +83,24 @@ describe.skipIf(!databaseUrl)('tasks, against the database', () => { const anHourAgo = new Date(Date.now() - HOUR); await db.agentTask.updateMany({ where: {id: {in: [parent.id, child.id]}}, - data: {lastActiveAt: anHourAgo}, + data: {createdAt: anHourAgo, lastActiveAt: anHourAgo}, }); return {parentId: parent.id, childId: child.id}; } + async function read(taskId: string): Promise { + const task = unwrap(await getAgentTask(orgId, agentId, taskId)); + if (!task) { + throw new Error(`no task ${taskId}`); + } + return task; + } + test('a call for a sub-task left open takes it up again, and the task above it too', async () => { const {parentId, childId} = await leftOpenLine('Triage inbox'); const before = Date.now(); - expect(unwrap(await touchAgentTask(orgId, agentId, childId))).toBe(true); + expect(unwrap(await touchAgentTask(orgId, agentId, await read(childId)))).toBe(true); const parent = unwrap(await getAgentTask(orgId, agentId, parentId)); const child = unwrap(await getAgentTask(orgId, agentId, childId)); @@ -104,9 +120,10 @@ describe.skipIf(!databaseUrl)('tasks, against the database', () => { test('a sub-task left open under a task finished without it stays as it was', async () => { const {parentId, childId} = await leftOpenLine('Book travel'); unwrap(await finishAgentTask(orgId, agentId, parentId, 'Booked the train')); - const before = unwrap(await getAgentTask(orgId, agentId, childId)); + const before = await read(childId); - expect(unwrap(await touchAgentTask(orgId, agentId, childId))).toBe(false); + expect(unwrap(await isAgentTaskOpen(orgId, agentId, childId))).toBe(false); + expect(unwrap(await touchAgentTask(orgId, agentId, before))).toBe(false); const finished = unwrap(await finishAgentTask(orgId, agentId, childId, 'Found a hotel')); expect(finished?.finishedAt).toBeNull(); @@ -117,6 +134,60 @@ describe.skipIf(!databaseUrl)('tasks, against the database', () => { const {parentId} = await leftOpenLine('Reply to Dana'); unwrap(await finishAgentTask(orgId, agentId, parentId, null)); - expect(unwrap(await touchAgentTask(orgId, agentId, parentId))).toBe(false); + expect(unwrap(await touchAgentTask(orgId, agentId, await read(parentId)))).toBe(false); + }); + + test('a task done for within the last minute is not written again', async () => { + const {childId} = await leftOpenLine('Sort receipts'); + const aMomentAgo = new Date(Date.now() - 30 * 1000); + await db.agentTask.update({where: {id: childId}, data: {lastActiveAt: aMomentAgo}}); + + expect(unwrap(await touchAgentTask(orgId, agentId, await read(childId)))).toBe(true); + expect((await read(childId)).lastActiveAt).toEqual(aMomentAgo); + }); + + test('a task left open is read as open without being taken up again', async () => { + const {parentId, childId} = await leftOpenLine('Draft the agenda'); + const before = await read(childId); + + expect(unwrap(await isAgentTaskOpen(orgId, agentId, childId))).toBe(true); + expect(unwrap(await isAgentTaskOpen(orgId, agentId, 'no-such-task'))).toBe(false); + expect((await read(childId)).lastActiveAt).toEqual(before.lastActiveAt); + expect((await read(parentId)).lastActiveAt).toEqual(before.lastActiveAt); + }); + + test('a stretch of time holds the tasks left open that had something done for them in it, and those under way', async () => { + const viewer = {orgId, userId}; + const {parentId: before} = await leftOpenLine('Left open before the stretch'); + const {parentId: inside} = await leftOpenLine('Left open in the stretch'); + const {parentId: running} = await leftOpenLine('Still under way'); + const now = Date.now(); + await db.agentTask.update({ + where: {id: before}, + data: {lastActiveAt: new Date(now - 40 * 60 * 1000)}, + }); + await db.agentTask.update({ + where: {id: inside}, + data: {lastActiveAt: new Date(now - 25 * 60 * 1000)}, + }); + await db.agentTask.update({ + where: {id: running}, + data: {lastActiveAt: new Date(now - 5 * 60 * 1000)}, + }); + + // The half hour up to ten minutes ago. + const listed = unwrap( + await listAgentTasksBetween( + viewer, + new Date(now - 30 * 60 * 1000), + new Date(now - 10 * 60 * 1000), + 200, + ), + ); + const ids = listed.map((task) => task.id); + + expect(ids).toContain(inside); + expect(ids).toContain(running); + expect(ids).not.toContain(before); }); }); diff --git a/packages/db/src/task.ts b/packages/db/src/task.ts index c72d87c7..cc29c4ab 100644 --- a/packages/db/src/task.ts +++ b/packages/db/src/task.ts @@ -1,5 +1,5 @@ -import type {AuditVia, Prisma} from '@prisma/client'; -import type {Result} from 'ts-results-es'; +import {type AuditVia, Prisma} from '@prisma/client'; +import {Ok, type Result} from 'ts-results-es'; import {type ApiError, wrapDb} from '@proxy/utils'; import {TASK_LEFT_OPEN_AFTER_MS} from '@proxy/utils/tasks'; @@ -28,6 +28,10 @@ export type AgentTaskRow = { lastActiveAt: Date; }; +// How stale a task's lastActiveAt may be before something done for it writes it again: so a call +// is not a write each, and a task is left open 19 to 20 minutes after the last call made for it. +const TOUCH_AFTER_MS = 60 * 1000; + const taskSelect = { id: true, agentId: true, @@ -87,33 +91,55 @@ export async function createAgentTask(data: { } /** - * Takes now as the last thing done for the agent's task `taskId` and every task above it, as a - * request made for it is (`lastActiveAt`), so none is left open while the agent is still at work on - * it, and one left open is taken up again (@proxy/utils/tasks). `Ok(false)` means it, or a task - * above it, is finished, or the agent has no such task. + * Takes now as the last thing done for the agent's task and every task above it, as a request made + * for it is (`lastActiveAt`), so none is left open while the agent is still at work on it, and one + * left open is taken up again (@proxy/utils/tasks). `Ok(false)` means it, or a task above it, is + * finished, or the agent has no such task. One read unfinished and done for within the last + * minute isn't written again, nor the tasks above it read: none of them can have been finished + * since without finishing it too (finishSubTasks). */ export async function touchAgentTask( + orgId: string, + agentId: string, + task: Pick, +): Promise> { + if (task.finishedAt !== null) { + return Ok(false); + } + if (Date.now() - task.lastActiveAt.getTime() < TOUCH_AFTER_MS) { + return Ok(true); + } + return wrapDb(() => + db.$transaction((tx) => touchLine(tx, {orgId, agentId, taskId: task.id}, new Date())), + ); +} + +/** + * Whether the agent's task and every task above it are not finished, read without taking any of + * them up; `Ok(false)` where the agent has no such task. + */ +export async function isAgentTaskOpen( orgId: string, agentId: string, taskId: string, ): Promise> { - return wrapDb(() => db.$transaction((tx) => touchLine(tx, {orgId, agentId, taskId}, new Date()))); + return wrapDb(async () => { + const line = await lineOf(db, {orgId, agentId, taskId}, {lock: false}); + return isOpen(line, taskId); + }); } -// Locks the task and every task above it, and takes `now` as the last thing done for each; false, -// with none changed, where any of them is finished. So a task taken up again takes up every task -// above it too, none left open before it; and one left open when its parent was finished, which -// it was not finished with (finishSubTasks), stays as it is. -async function touchLine( - tx: Prisma.TransactionClient, +type LineRow = {id: string; finishedAt: Date | null}; + +// The task and every task above it, from the top down; with `lock`, locked for the rest of the +// transaction in that order, the one finishing a task locks its sub-tasks in (finishSubTasks), so +// the two can't wait on each other. +function lineOf( + client: Prisma.TransactionClient, {orgId, agentId, taskId}: {orgId: string; agentId: string; taskId: string}, - now: Date, -): Promise { - // Locked from the top down, the order finishing a task locks its sub-tasks in (finishSubTasks), - // so the two can't wait on each other. - const above = await tx.$queryRaw< - Array<{id: string; finishedAt: Date | null; lastActiveAt: Date}> - >` + {lock}: {lock: boolean}, +): Promise { + return client.$queryRaw` WITH RECURSIVE "line" AS ( SELECT "id", "parentId", 0 AS "depth" FROM "AgentTask" WHERE "id" = ${taskId} AND "orgId" = ${orgId} AND "agentId" = ${agentId} @@ -122,17 +148,34 @@ async function touchLine( FROM "AgentTask" AS "task" JOIN "line" ON "task"."id" = "line"."parentId" ) - SELECT "task"."id", "task"."finishedAt", "task"."lastActiveAt" + SELECT "task"."id", "task"."finishedAt" FROM "AgentTask" AS "task" JOIN "line" ON "task"."id" = "line"."id" ORDER BY "line"."depth" DESC - FOR UPDATE OF "task"`; - if (!above.some((row) => row.id === taskId) || above.some((row) => row.finishedAt !== null)) { + ${lock ? Prisma.sql`FOR UPDATE OF "task"` : Prisma.empty}`; +} + +// Whether the line of `taskId` holds it, with none of it finished. +function isOpen(line: LineRow[], taskId: string): boolean { + return line.some((row) => row.id === taskId) && line.every((row) => row.finishedAt === null); +} + +// Locks the task and every task above it, and takes `now` as the last thing done for each; false, +// with none changed, where any of them is finished. So a task taken up again takes up every task +// above it too, none left open before it; and one left open when its parent was finished, which +// it was not finished with (finishSubTasks), stays as it is. +async function touchLine( + tx: Prisma.TransactionClient, + {orgId, agentId, taskId}: {orgId: string; agentId: string; taskId: string}, + now: Date, +): Promise { + const line = await lineOf(tx, {orgId, agentId, taskId}, {lock: true}); + if (!isOpen(line, taskId)) { return false; } // Where another instance's clock is ahead, one may have set a later time already. await tx.agentTask.updateMany({ - where: {id: {in: above.map((row) => row.id)}, lastActiveAt: {lt: now}}, + where: {id: {in: line.map((row) => row.id)}, lastActiveAt: {lt: now}}, data: {lastActiveAt: now}, }); return true; @@ -230,10 +273,11 @@ export async function listOpenAgentTasks( /** * The tasks the viewer sees, shared agents' and their own agents', under way at some point between - * `from` and `to`: started by `to` and not finished before `from`, nor left open before it, 20 - * minutes after anything was last done for it or its sub-tasks. Of those, the newest `take` - * started on their own, newest first; a sub-task ends no later than its parent, which is under way - * then too, so the rest are sub-tasks of these (listSubTasks). + * `from` and `to`: started by `to` and not finished before `from`, nor, left open, last had + * anything done for it or its sub-tasks before it, which is where the web app ends it + * (@/lib/timeline-window's endsOf). Of those, the newest `take` started on their own, newest + * first; a sub-task ends no later than its parent, which is under way then too, so the rest are + * sub-tasks of these (listSubTasks). */ export async function listAgentTasksBetween( viewer: Viewer, @@ -241,7 +285,10 @@ export async function listAgentTasksBetween( to: Date, take: number, ): Promise> { - const activeSince = new Date(from.getTime() - TASK_LEFT_OPEN_AFTER_MS); + // lastActiveAt is a minute behind at most (touchAgentTask), and one under way runs to now. + const activeSince = new Date( + Math.min(from.getTime() - TOUCH_AFTER_MS, Date.now() - TASK_LEFT_OPEN_AFTER_MS), + ); return wrapDb(() => db.agentTask.findMany({ where: { diff --git a/packages/utils/src/tasks.ts b/packages/utils/src/tasks.ts index d40d4c31..4c0cc19a 100644 --- a/packages/utils/src/tasks.ts +++ b/packages/utils/src/tasks.ts @@ -2,7 +2,7 @@ // after anything was last done for it or for one of its sub-tasks at any depth, a sub-task started // or a request made, is taken as left open, ended then. So a task isn't ended while a sub-agent is // still at work on part of it, and a parent is never left open before any of its sub-tasks. The web -// app shows one left open as having ended at the last request made for it. It is taken up again, +// app shows one left open as having ended when anything was last done for it. It is taken up again, // with every task above it, by the next call made for it, a sub-task started under it or the agent // finishing it, unless a task it is part of was finished meanwhile. diff --git a/packages/web/src/lib/timeline-window.test.ts b/packages/web/src/lib/timeline-window.test.ts index 94ee1252..988f4d9a 100644 --- a/packages/web/src/lib/timeline-window.test.ts +++ b/packages/web/src/lib/timeline-window.test.ts @@ -73,11 +73,26 @@ describe('endsOf', () => { expect(ends.get('t1')).toBeNull(); }); - test('a task left open ended when its last request was done, or when it started with none', () => { - const leftOpen = task({startedAt: '2026-10-15T09:00:00Z'}); + test('a task left open ended when anything was last done for it, logged or not', () => { + const leftOpen = task({ + startedAt: '2026-10-15T09:00:00Z', + lastActiveAt: '2026-10-15T09:50:00Z', + }); const requests = [request('2026-10-15T09:10:00Z'), request('2026-10-15T09:40:00Z')]; - expect(endsOf([leftOpen], requests, now).get('t1')).toEqual(new Date('2026-10-15T09:40:00Z')); - expect(endsOf([leftOpen], [], now).get('t1')).toEqual(new Date('2026-10-15T09:00:00Z')); + expect(endsOf([leftOpen], requests, now).get('t1')).toEqual(new Date('2026-10-15T09:50:00Z')); + expect(endsOf([task({startedAt: '2026-10-15T09:00:00Z'})], [], now).get('t1')).toEqual( + new Date('2026-10-15T09:00:00Z'), + ); + }); + + test('a task left open ended no earlier than its last request, made in the minute after it was last written', () => { + const leftOpen = task({ + startedAt: '2026-10-15T09:00:00Z', + lastActiveAt: '2026-10-15T09:40:00Z', + }); + expect(endsOf([leftOpen], [request('2026-10-15T09:40:30Z')], now).get('t1')).toEqual( + new Date('2026-10-15T09:40:30Z'), + ); }); test('a task left open ended no earlier than the last of its sub-tasks, at any depth', () => { diff --git a/packages/web/src/lib/timeline-window.ts b/packages/web/src/lib/timeline-window.ts index ec148d5a..6e80adc9 100644 --- a/packages/web/src/lib/timeline-window.ts +++ b/packages/web/src/lib/timeline-window.ts @@ -118,9 +118,11 @@ function leftOpenAt(task: TaskTimes, now: Date): Date | null { type TaskInTree = TaskTimes & Pick; /** - * When each of `tasks` ended, if it has by `now`: when it was finished; or, left open, when the - * last of `entries` made for it was done or the last of its sub-tasks ended, or when it started - * where there was neither, since nothing was done for it after. Null while it is under way. + * When each of `tasks` ended, if it has by `now`: when it was finished; or, left open, when + * anything was last done for it or its sub-tasks (`lastActiveAt`), or the last of `entries` made + * for it was done or the last of its sub-tasks ended where that is later, as it can be by the + * minute `lastActiveAt` is written at most once in (db/task's touchAgentTask). Null while it is + * under way. */ export function endsOf( tasks: TaskInTree[], @@ -163,13 +165,13 @@ export function endsOf( if (leftOpenAt(task, now) === null) { return null; } - const started = new Date(task.startedAt).getTime(); + const active = new Date(task.lastActiveAt).getTime(); // A sub-task is left open no later than its parent (@proxy/utils/tasks), so each has ended. return new Date( Math.max( - started, - lastRequest.get(task.id) ?? started, - ...(children.get(task.id) ?? []).map((child) => endOf(child)?.getTime() ?? started), + active, + lastRequest.get(task.id) ?? active, + ...(children.get(task.id) ?? []).map((child) => endOf(child)?.getTime() ?? active), ), ); };