From 7199d3cab927dad0cfed55a9efd8955982ac88d5 Mon Sep 17 00:00:00 2001 From: Charles Pan <30245070+charleslpan@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:06:19 +0000 Subject: [PATCH] refactor(host,desktop): the agent ledger is fed from the live task list, and lists nothing on a clock The tasks view carries every agent on a task as its task's own list names it, which main feeds the notices' ledger from: the thirty-second watch that listed each watched task's agents and read each agent with no task is gone, and so is every ask for the list again after an agent's start, end, or pull request, since the followed list moves by itself. A task the list drops takes its agents and their dots with it. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- packages/features/src/task-list-live.ts | 40 +++- packages/features/src/tasks-view.ts | 8 + .../src/act-port/agents-procedures.test.ts | 41 +--- .../host/src/act-port/agents-procedures.ts | 8 +- packages/host/src/agent-notices.test.ts | 107 +++------- packages/host/src/agent-notices.ts | 198 ++++-------------- packages/host/src/client/client-notices.ts | 28 ++- packages/host/src/compose-tasks.test.ts | 6 + packages/host/src/compose-tasks.ts | 7 +- packages/host/src/task-list-follow.ts | 24 ++- .../service/src/tasks/task-list-live.test.ts | 40 +++- 11 files changed, 200 insertions(+), 307 deletions(-) diff --git a/packages/features/src/task-list-live.ts b/packages/features/src/task-list-live.ts index 8f649301bc..6c95b33e46 100644 --- a/packages/features/src/task-list-live.ts +++ b/packages/features/src/task-list-live.ts @@ -12,6 +12,7 @@ import { CONTEXT_WINDOW, type CodingAgentArchivedBy, type CodingAgentStatus, + type CodingAgentSummary, codingAgentPullRequestSchema, codingAgentSummarySchema, PULL_REQUEST_STATE, @@ -324,8 +325,8 @@ function archivedByOf(row: AgentLiveRow): CodingAgentArchivedBy | undefined { }; } -/** An agent with no plan as the list carries it, as its own page reads it, its status read at `now`. */ -function tasklessAgentOf(row: AgentLiveRow, now: number): TasklessAgent { +/** One agent as the tabs draw it, on a plan or with none, its status read at `now`, as the service's summary of it says it. */ +function agentSummaryOf(row: AgentLiveRow, now: number): CodingAgentSummary { const servers = (row.disabledServers ?? []).filter( (name) => integrationConnectionOf(name) !== undefined, ); @@ -333,7 +334,7 @@ function tasklessAgentOf(row: AgentLiveRow, now: number): TasklessAgent { const started = runStartedAt(row, now); return { id: row.id, - planId: null, + planId: row.planId, model: row.model, effort: row.effort, ...(row.contextWindow === CONTEXT_WINDOW.STANDARD @@ -350,10 +351,19 @@ function tasklessAgentOf(row: AgentLiveRow, now: number): TasklessAgent { ...(row.starterName === null ? undefined : { startedBy: { userId: row.starterId, name: row.starterName } }), - automationId: row.automationId, + ...(row.automationId === null ? undefined : { automationId: row.automationId }), ...(row.automationName === null ? undefined : { automationName: row.automationName }), ...(row.startedFrom === null ? undefined : { from: row.startedFrom }), ...(started === undefined ? undefined : { runStartedAt: started }), + }; +} + +/** An agent with no plan as the list carries it, as its own page reads it, its status read at `now`. */ +function tasklessAgentOf(row: AgentLiveRow, now: number): TasklessAgent { + return { + ...agentSummaryOf(row, now), + planId: null, + automationId: row.automationId, workspaceId: row.workspaceId, repository: row.repository, activityAt: activityAt(row), @@ -372,6 +382,28 @@ function pullRequestOf(published: AgentLiveRow["published"]) { return { pullRequest: { number, title, url, state, checks } }; } +/** First started first, and by id among those started at one instant, as a plan's agents are listed. */ +function firstStarted(a: AgentLiveRow, b: AgentLiveRow): number { + if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** + * Every agent on a plan, the archived ones left out, each as the plan's own + * list of agents names it with its status read at `now`, the first started + * first: what the desktop's ledger of agents reads where it once listed + * each plan's agents again on a clock. + */ +export function taskAgentsOfLiveRows( + rows: TaskListLiveRows, + now: number, +): readonly CodingAgentSummary[] { + return rows.agents + .filter((row) => row.planId !== null && row.archivedAt === null) + .toSorted(firstStarted) + .map((row) => agentSummaryOf(row, now)); +} + /** * The plan list as the held read answers it with every archived plan and * agent included, as the live rows stand at `now`: the plans newest started diff --git a/packages/features/src/tasks-view.ts b/packages/features/src/tasks-view.ts index e9de2da826..a6a5a5818c 100644 --- a/packages/features/src/tasks-view.ts +++ b/packages/features/src/tasks-view.ts @@ -7,6 +7,7 @@ import { CODING_AGENT_BOUNDS, codingAgentCallIdSchema, codingAgentMessagesAnswerSchema, + codingAgentSummarySchema, storedObjectSchema, tasklessAgentSchema, turnRecordFields, @@ -403,6 +404,13 @@ export const tasksViewSchema = EffectSchema.Struct({ tasks: EffectSchema.Array(taskSummarySchema), /** The agents with no plan in the account's workspaces, newest started first, as the last list read answered; absent before the first that carried them. */ tasklessAgents: EffectSchema.optionalKey(EffectSchema.Array(tasklessAgentSchema)), + /** + * Every agent on a plan, the archived ones left out, each as its plan's + * own list names it, the first started first: present only while the list + * is followed live over Neon Realtime, when it is what the notices' ledger + * reads in place of listing each plan's agents again on a clock. + */ + taskAgents: EffectSchema.optionalKey(EffectSchema.Array(codingAgentSummarySchema)), listStatus: tasksReadSchema, /** The workspaces the account is a member of, its first one first, as the last list read answered; none before the first lands. */ workspaces: EffectSchema.Array(workspaceSchema), diff --git a/packages/host/src/act-port/agents-procedures.test.ts b/packages/host/src/act-port/agents-procedures.test.ts index fa8f216ca1..bacbc02bfc 100644 --- a/packages/host/src/act-port/agents-procedures.test.ts +++ b/packages/host/src/act-port/agents-procedures.test.ts @@ -20,11 +20,7 @@ import { type CodingAgentTranscriptKey, TRANSCRIPT_KIND, } from "@luke/features/coding-agent-view"; -import { - CHECK_SUMMARY, - CODING_AGENT_STATUS, - PULL_REQUEST_STATE, -} from "@luke/features/coding-agent-wire"; +import { CODING_AGENT_STATUS } from "@luke/features/coding-agent-wire"; import { FILE_VIEWED_STATE, PULL_REQUEST_ACTION, @@ -63,22 +59,6 @@ const STARTED = { const UNANSWERED = { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } as const; -/** What an agent published, as the service answers it: its branch and an open pull request. */ -const PUBLISHED = { - repository: "acme/relay", - branch: "luke/invites", - pullRequest: { - number: 41, - title: "Teammate invitations", - url: "https://github.com/acme/relay/pull/41", - state: PULL_REQUEST_STATE.OPEN, - checks: CHECK_SUMMARY.PASSING, - additions: 12, - deletions: 3, - changedFiles: 2, - }, -}; - /** Each kind's refusal, as the window drew it before the kinds moved onto the port. */ const REFUSAL = { [AGENT_ACT_KIND.CODING_AGENTS_MODELS]: "Could not read the models on this system.", @@ -296,9 +276,6 @@ function windowPort( observeModels: (models) => { noted.push(`models:${models.length}`); }, - observePublished: (agentId, pullRequest) => { - noted.push(`published:${agentId}=${pullRequest?.number ?? "none"}`); - }, observeTaskGone: (taskId) => { noted.push(`gone:${taskId}`); }, @@ -494,22 +471,6 @@ it("a panel's word on what it shows is noted whole, the Chat as none where it na assert.deepEqual(asked, []); }); -it("a panel's read of an agent's own pull request is noted as what it published, and a read of another by its number is not", async () => { - const { call, noted } = windowPort(PANEL, { - answers: { codingAgentPullRequest: () => Effect.succeed(PUBLISHED) }, - }); - - const own = await call(AGENT_ACT_KIND.CODING_AGENTS_PULL_REQUEST, { agentId: AGENT_ID }); - const other = await call(AGENT_ACT_KIND.CODING_AGENTS_PULL_REQUEST, { - agentId: AGENT_ID, - number: 52, - }); - - assert.deepEqual(own, { answer: PUBLISHED }); - assert.deepEqual(other, { answer: PUBLISHED }); - assert.deepEqual(noted, [`published:${AGENT_ID}=41`]); -}); - it("a panel's change to an agent's pull request reaches the host as asked and hears how GitHub took it, and its read of which files it viewed hears each file's mark", async () => { const viewed = { outcome: PULL_REQUEST_WRITE_OUTCOME.DONE, diff --git a/packages/host/src/act-port/agents-procedures.ts b/packages/host/src/act-port/agents-procedures.ts index 8b9b6636e9..7197880494 100644 --- a/packages/host/src/act-port/agents-procedures.ts +++ b/packages/host/src/act-port/agents-procedures.ts @@ -122,7 +122,6 @@ export interface AgentProceduresDependencies { | "observeTitle" | "observeMessaged" | "observeModels" - | "observePublished" | "observeTaskGone" | "shown" | "shownInbox" @@ -360,12 +359,7 @@ export function agentProcedures(dependencies: AgentProceduresDependencies) { AGENT_ACT_KIND.CODING_AGENTS_PULL_REQUEST ].effect(function* ({ input, context, errors }) { yield* refuseUnlessPanel(context.actor, () => errors.REFUSED()); - // Note that a read of another pull request by its number is no word on what the agent itself published. - return yield* noted(host.codingAgentPullRequest(input), (answer) => { - if ("pullRequest" in answer && input.number === undefined) { - notices.observePublished(input.agentId, answer.pullRequest); - } - }); + return yield* host.codingAgentPullRequest(input); }), [AGENT_ACT_KIND.CODING_AGENTS_CHANGES]: actPort[AGENT_ACT_KIND.CODING_AGENTS_CHANGES].effect( function* ({ input, context, errors }) { diff --git a/packages/host/src/agent-notices.test.ts b/packages/host/src/agent-notices.test.ts index 06faf3c7e6..91bddd4dcc 100644 --- a/packages/host/src/agent-notices.test.ts +++ b/packages/host/src/agent-notices.test.ts @@ -11,8 +11,6 @@ import { AUTOMATION_RUN_REASON } from "@luke/features/automation-wire"; import { CODING_AGENT_CALL_FAILURE, type CodingAgentAccountInboxAnswerView, - type CodingAgentListAnswer, - type CodingAgentPageAnswerView, type CodingAgentPullRequestAnswerView, } from "@luke/features/coding-agent-view"; import { @@ -37,11 +35,12 @@ import { import type { AppInboxSlice, AppInboxTask, AppInboxTaskHolding } from "./app-state.js"; const TASK_ID = "7b0f5f3e-2c1d-4c7a-9a55-5e3b6f1d2a10"; +/** How long an unanswered read of what waits rests before it is asked again. */ +const WATCH = Duration.seconds(30); const AGENT_ID = "8c1a6a4f-3d2e-4d8b-8b66-6f4c7a2e3b21"; const TASK_NAME = "Teammate invitations"; const MODEL = "anthropic/claude-opus-5.5"; const TURN_ID = "3f1c2b7a-9d4e-4a6b-8c1d-2e5f6a7b8c9d"; -const WATCH = Duration.seconds(30); const ACCOUNT = "dean@example.com"; const OTHER_ACCOUNT = "sam@example.com"; @@ -80,7 +79,6 @@ type InboxAnswerGiven = * test's own that records what leaves through each. */ function fixture() { - const listed: string[] = []; const readPullRequests: string[] = []; const published = new Map(); const posted: AgentNotice[] = []; @@ -120,34 +118,16 @@ function fixture() { readonly workspaceId?: string; }[] = []; const unseen: (readonly string[])[] = []; - const answers = new Map(); - const pages = new Map(); - const readAgents: string[] = []; let focus: ((focused: boolean) => void) | undefined; let accountChanged: ((signedInAs: string | undefined) => void) | undefined; let signedInAs: string | undefined = ACCOUNT; let accountId: string | undefined = ACCOUNT_ID; let notifying = true; - let listReads = 0; const notices = Effect.gen(function* () { inboxAnswers = yield* Queue.unbounded(); accounts = yield* Queue.unbounded(); Queue.offerUnsafe(accounts, signedInAs); return yield* createAgentNotices({ - listAgents: (taskId) => - Effect.sync(() => { - listed.push(taskId); - const queue = answers.get(taskId) ?? []; - const answer = queue.length > 1 ? queue.shift() : queue[0]; - return answer ?? { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }; - }), - readAgent: (agentId) => - Effect.sync(() => { - readAgents.push(agentId); - const queue = pages.get(agentId) ?? []; - const answer = queue.length > 1 ? queue.shift() : queue[0]; - return answer ?? { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }; - }), readPullRequest: (agentId) => Effect.sync(() => { readPullRequests.push(agentId); @@ -171,9 +151,6 @@ function fixture() { accountChanged = undefined; }; }, - onTaskAgentsMoved: () => { - listReads += 1; - }, readAccountInbox: (after) => Effect.suspend(() => { inboxReads.push(after); @@ -192,13 +169,11 @@ function fixture() { chatOn: () => chatOn, openInbox: (taskId) => openedInboxes.push(taskId), openTasklessItem: (item) => openedTaskless.push(item), - watchInterval: WATCH, report: () => undefined, }); }).pipe(Effect.provide(doors)); return { notices, - listed, readPullRequests, posted, toasted, @@ -208,13 +183,6 @@ function fixture() { opened, openedTaskless, unseen, - /** The list answers a task's reads hear, in order; the last stands for every read after it. */ - answer: (taskId: string, ...queue: CodingAgentListAnswer[]) => answers.set(taskId, queue), - /** The page answers an agent's own reads hear, in order; the last stands for every read after it. */ - answerPage: (agentId: string, ...queue: CodingAgentPageAnswerView[]) => - pages.set(agentId, queue), - /** Every agent read by itself, in order. */ - readAgents, focus: (focused: boolean) => { assert.ok(focus, "the notices subscribe to the window's focus"); focus(focused); @@ -264,7 +232,6 @@ function fixture() { /** What was said, without the clicks. */ said: () => posted.map(({ title, body }) => ({ title, body })), /** How many times the task list was asked for again. */ - listReads: () => listReads, }; } it.effect( @@ -285,11 +252,31 @@ it.effect( assert.deepEqual(f.unseen.at(-1), []); // The answer that was on its way lands as a first sighting. notices.observeAgents([{ ...other, status: CODING_AGENT_STATUS.COMPLETED }]); - yield* TestClock.adjust(WATCH); + yield* settled; assert.equal(f.posted.length, 1); - assert.deepEqual(f.listed, []); }), ); +it.effect( + "the plan list followed live feeds the ledger: an end it carries is announced, and a plan it drops takes its agents' dots with it", + () => + Effect.gen(function* () { + const f = fixture(); + const notices = yield* f.notices; + f.focus(false); + + notices.observeFollowed([agent(CODING_AGENT_STATUS.RUNNING, { turnId: null })], [TASK_ID]); + notices.observeFollowed([agent(CODING_AGENT_STATUS.RUNNING)], [TASK_ID]); + notices.observeFollowed([agent(CODING_AGENT_STATUS.COMPLETED)], [TASK_ID]); + yield* settled; + + assert.deepEqual(f.said(), [{ title: TASK_NAME, body: "Opus 5.5 finished" }]); + assert.deepEqual(f.unseen.at(-1), [AGENT_ID]); + + notices.observeFollowed([], []); + assert.deepEqual(f.unseen.at(-1), []); + }), +); + /** * Every step the inbox read has to take, taken: the accounts' stream hands * the account on, the read it switched to asks, and the look writes what @@ -468,27 +455,18 @@ function tasklessAgent( }; } -/** The agent's own page as its read answers it. */ -function pageOf(held: TasklessAgent): CodingAgentPageAnswerView { - const { automationName: _name, activityAt: _at, ...page } = held; - return { agent: page }; -} - it.effect( - "an automation's agent with no plan, held from the plan list, is read by itself while it works and announces its end under the automation's name, its click opening its own page", + "an automation's agent with no plan, held from the plan list, announces its end under the automation's name, its click opening its own page", () => Effect.gen(function* () { const f = fixture(); const notices = yield* f.notices; f.focus(false); - const running = tasklessAgent(CODING_AGENT_STATUS.RUNNING); - f.answerPage(AGENT_ID, pageOf(tasklessAgent(CODING_AGENT_STATUS.COMPLETED))); - notices.observeTaskless([running]); - yield* TestClock.adjust(WATCH); + notices.observeTaskless([tasklessAgent(CODING_AGENT_STATUS.RUNNING)]); + notices.observeTaskless([tasklessAgent(CODING_AGENT_STATUS.COMPLETED)]); + yield* settled; - assert.deepEqual(f.readAgents, [AGENT_ID]); - assert.deepEqual(f.listed, []); assert.deepEqual(f.said(), [ { title: AUTOMATION_NAME, body: "Triage overnight issues finished" }, ]); @@ -568,11 +546,9 @@ it.effect( const theirs = { startedBy: { userId: "user-sam", name: "Sam" } }; notices.observeTaskless([tasklessAgent(CODING_AGENT_STATUS.RUNNING, theirs)]); - yield* TestClock.adjust(WATCH); notices.observeTaskless([tasklessAgent(CODING_AGENT_STATUS.COMPLETED, theirs)]); yield* Effect.yieldNow; - assert.deepEqual(f.readAgents, []); assert.deepEqual(f.posted, []); assert.deepEqual(f.unseen, []); }), @@ -592,32 +568,11 @@ it.effect( notices.observeAgents([theirs]); notices.observeMessaged(theirs); - yield* TestClock.adjust(WATCH); - - assert.deepEqual(f.readAgents, []); - assert.equal(f.listReads(), 0); - }), -); -it.effect( - "an agent with no plan named by a message's answer is watched by itself, not by a plan's list, and one deleted meanwhile is let go", - () => - Effect.gen(function* () { - const f = fixture(); - const notices = yield* f.notices; - f.focus(false); - f.answerPage(AGENT_ID, { failure: CODING_AGENT_CALL_FAILURE.NOT_FOUND }); - - notices.observeMessaged({ - ...agent(CODING_AGENT_STATUS.RUNNING), - planId: null, - startedBy: { userId: ACCOUNT_ID, name: "Dana" }, - }); - yield* TestClock.adjust(WATCH); - yield* TestClock.adjust(WATCH); + notices.observeAgents([{ ...theirs, status: CODING_AGENT_STATUS.COMPLETED }]); + yield* settled; - assert.deepEqual(f.listed, []); - assert.deepEqual(f.readAgents, [AGENT_ID]); assert.deepEqual(f.posted, []); + assert.deepEqual(f.unseen, []); }), ); /** An item of an agent with no task waiting on the developer, as the account's inbox read carries it. */ diff --git a/packages/host/src/agent-notices.ts b/packages/host/src/agent-notices.ts index ff0065a92a..aa3e504126 100644 --- a/packages/host/src/agent-notices.ts +++ b/packages/host/src/agent-notices.ts @@ -9,16 +9,12 @@ import { type AccountTasklessEntry, } from "@luke/features/account-inbox-wire"; import { failedRunText } from "@luke/features/automation-list-model"; -import { - CODING_AGENT_CALL_FAILURE, - type CodingAgentAccountInboxAnswerView, - type CodingAgentListAnswer, - type CodingAgentPageAnswerView, - type CodingAgentPullRequestAnswerView, +import type { + CodingAgentAccountInboxAnswerView, + CodingAgentPullRequestAnswerView, } from "@luke/features/coding-agent-view"; import { CODING_AGENT_STATUS, - type CodingAgentPullRequest, type CodingAgentStatus, type CodingAgentSummary, type TasklessAgent, @@ -28,7 +24,6 @@ import { linkLabels } from "@luke/features/mention-link"; import { agentTabLabel, modelLabel } from "@luke/features/model-label"; import type { CatalogModel } from "@luke/features/models-wire"; import type { TasksView } from "@luke/features/tasks-view"; -import { scheduleOnce } from "@luke/runtime/effect"; import { Context, Duration, Effect, Queue, Schedule, type Scope, Stream } from "effect"; import type { AppInboxSlice, AppInboxWaitingItem } from "./app-state.js"; import type { ChatShown } from "./contract/agents.js"; @@ -37,19 +32,17 @@ import type { ChatShown } from "./contract/agents.js"; const UNANSWERED_LOOK = "unanswered"; /** - * agent-notices.ts -- tells the developer when a coding agent's turn ends or an inbox item lands: the ledger of where each agent stands, the poll that keeps it current while no tab does, the held read of what waits across the account, and the notification, toast, and badge they announce by. + * agent-notices.ts -- tells the developer when a coding agent's turn ends or an inbox item lands: the ledger of where each agent stands, fed by the plan list followed live, the follow of what waits across the account, and the notification, toast, and badge they announce by. * * The panel reads an agent's status only while its tab is on screen, so * main keeps a ledger of its own, fed by every coding-agent answer that * passes through the acts (a list, a Start, a Stop, a transcript page) and - * by one read of its own: while any agent in the ledger is starting or - * running, its task's agents are listed again every `WATCH_INTERVAL`, the - * panel's own list read on a clock rather than a second kind of read, so an - * agent on a plan the developer has left is still watched. An agent an - * automation started with no plan is held from the plan list, which carries - * the account's own beside the plans, and from any answer naming it, and - * while it works it is read again by itself on the same clock, as its page - * reads it, having no plan to be listed with. The ledger + * by the plan list, followed live over Neon Realtime, which carries every + * agent on a plan and every one with no plan as each moves, so an agent on + * a plan the developer has left is still heard and nothing is read on a + * clock. An agent an automation started with no plan is held from the plan + * list, which carries the account's own beside the plans, and from any + * answer naming it. The ledger * keys an agent's run by the turn its status is read from (`turnId`): a * status that moves from still writing to ended under one turn is that * turn's end, announced once, and as the agent's unseen dot, until its tab @@ -61,9 +54,8 @@ const UNANSWERED_LOOK = "unanswered"; * notifications off, which leaves the dot and the toast. A message * to an agent that had ended reads it running under the ended turn's id * until eve opens the next turn, which is the old run read again and not - * a new one: the ledger keeps the end it announced, and on the message's - * own answer keeps the task watched until the new turn's id arrives, whose - * own end is the next notice. A list that landed late, describing a turn + * a new one: the ledger keeps the end it announced until the new turn's id + * arrives, whose own end is the next notice. A list that landed late, describing a turn * the agent has left behind, says nothing newer. A Stop is the developer's * own and announces nothing. The ledger * is this launch's and this account's, so an agent that ended before Luke @@ -86,16 +78,9 @@ const UNANSWERED_LOOK = "unanswered"; * read found nothing, or could not be made, says finished as before. The * agent's unseen dot is set as the end is seen, ahead of the read. * - * The sidebar's task rows say which tasks have an agent at work and which - * a pull request, from the task list, which is read on no clock. So the - * ledger asks for the list again whenever it sees an agent start or end, - * and whenever a read of what an agent published, the panel's or the - * notice's own, finds a pull request other than the one it last saw for - * that agent, since that read is what the service keeps for the list. - * * The notices hold one more read of their own: what waits on the developer - * across the account's tasks, every open inbox item, on the service's held - * read, for as long as an account is signed in, so an item an agent posts + * across the account's tasks, every open inbox item, followed over Neon + * Realtime for as long as an account is signed in, so an item an agent posts * is heard whichever task is open, or none, and whichever Mac started the * agent. Of those, only the items of agents the account started wait on * it, since an agent runs on its starter's keys and asks its starter: each @@ -118,9 +103,8 @@ const UNANSWERED_LOOK = "unanswered"; * Chat, and counted in the Dock's badge beside the items. A chat * announcement says who and why, "Invite emails messaged you", under the task of * the channel it stands in, and nothing of what was said. Another account, or nobody, - * ends the read held for the last, and lets go of its items, its counts, - * and its badge before anything of the next is read. A read that did not - * answer is asked again on the watch's clock. + * ends the follow of the last, and lets go of its items, its counts, + * and its badge before anything of the next is read. * * The same read carries the newest run of each of the account's own * automations that failed to start lately, which is written for the @@ -131,9 +115,6 @@ const UNANSWERED_LOOK = "unanswered"; * run, in its automation's workspace; nothing is said while the account has Automations off. */ -/** How often a task with an agent still writing has its agents listed again. */ -const WATCH_INTERVAL = Duration.seconds(30); - /** * How long before the last answer of what waits was read an item's change * still counts as since it: room for the clocks of the service's instances @@ -307,11 +288,10 @@ function guardedDoors( }; } +/** How long a read of what waits that went unanswered rests before it is asked again. */ +const INBOX_RETRY = Duration.seconds(30); + export interface AgentNoticesDependencies { - /** The plan's agents with their status, as the host answers them: the watch's read of a plan's agents. */ - listAgents: (taskId: string) => Effect.Effect; - /** One agent as its own page reads it: the watch's read of an agent with no plan. */ - readAgent: (agentId: string) => Effect.Effect; /** What the agent published, read once as a completed turn ends for the pull request the notice names. */ readPullRequest: (agentId: string) => Effect.Effect; /** The task as a notice's title names it (`noticeTaskTitle`), or nothing for a task main holds no name for. */ @@ -330,8 +310,6 @@ export interface AgentNoticesDependencies { accountId: () => string | undefined; /** The account as each change of it says, for as long as the subscription stands: the ledger is no one else's. */ onAccountChanged: (listener: (signedInAs: string | undefined) => void) => () => void; - /** An agent started or ended, or a read found it published something new: the task list is read again, so its rows say so. */ - onTaskAgentsMoved: () => void; /** What waits across the account past a cursor, as the host answers it: the inbox read's one read. */ readAccountInbox: (after: string) => Effect.Effect; /** Who is signed in, by the account's own key, from where the launch stands and at each change: whose items the inbox read reads. */ @@ -353,7 +331,8 @@ export interface AgentNoticesDependencies { openChat: (workspaceId: string) => void; /** Whether the window offers Chat, without which nothing waiting in chat is counted or announced. */ chatOn: () => boolean; - watchInterval?: Duration.Duration; + /** How long a read of what waits that went unanswered rests before it is asked again. */ + inboxRetry?: Duration.Duration; report: (line: string) => void; } @@ -366,14 +345,14 @@ export interface AgentNotices { observeStatus(agentId: string, status: CodingAgentStatus): void; /** One agent's title as a transcript page carried it, which the list that named the agent may have read before the title landed; said whatever the agent's standing. */ observeTitle(agentId: string, title: string | null): void; - /** The agent as the answer to a message carried it: running, and with a line awaiting the turn it opens, which keeps its task watched for that turn. */ + /** The agent as the answer to a message carried it: running, with a line awaiting the turn it opens. */ observeMessaged(agent: CodingAgentSummary): void; /** The catalog as the panel last read it, for the model's name. */ observeModels(models: readonly CatalogModel[]): void; - /** What one agent published, as a read of it answered: the pull request, or none. */ - observePublished(agentId: string, pullRequest: CodingAgentPullRequest | null): void; /** A task the host no longer holds: its agents are forgotten. */ observeTaskGone(taskId: string): void; + /** Every agent on a plan as the list followed live carries them, and the plans it holds: an agent of any other plan is forgotten. */ + observeFollowed(agents: readonly CodingAgentSummary[], taskIds: readonly string[]): void; /** The panel said which agent's tab it shows, or none. */ shown(agentId: string | null): void; /** The panel said which task's Inbox tab it shows, or none. */ @@ -382,7 +361,7 @@ export interface AgentNotices { shownChat(shown: ChatShown | null): void; } -/** What the ledger keeps of one agent: enough to watch it, to name it, and to tell one turn's end from the next's. */ +/** What the ledger keeps of one agent: enough to name it, and to tell one turn's end from the next's. */ interface HeldAgent { /** The plan it works on; null for one an automation started with no plan. */ taskId: string | null; @@ -394,8 +373,6 @@ interface HeldAgent { status: CodingAgentStatus; /** The turn the status is read from; none before the first. */ turnId: string | null; - /** Whether a message of the developer's awaits a turn not yet opened, which keeps the task watched past the end already announced. */ - awaiting: boolean; /** The turns the agent has left behind, which a list that landed late may still describe. */ pastTurns: ReadonlySet; } @@ -430,22 +407,6 @@ function stillWriting(status: CodingAgentStatus): boolean { return WRITING_STATUSES.has(status); } -/** Whether two reads of what an agent published say the same of it in a task's row: the same pull request, standing the same, of the same size. */ -function samePublished( - a: CodingAgentPullRequest | null, - b: CodingAgentPullRequest | null, -): boolean { - if (a === null || b === null) return a === b; - return ( - a.number === b.number && - a.title === b.title && - a.state === b.state && - a.checks === b.checks && - a.additions === b.additions && - a.deletions === b.deletions - ); -} - /** * What the notification says, built in one place: the plan's name, or the * automation's for an agent with no plan, over the agent as its tab names it @@ -641,24 +602,22 @@ function sameTasks(one: readonly AccountInboxTask[], other: readonly AccountInbo } /** - * The notices, built on the ambient scope: the watch forks into it and the - * focus subscription is given back when it closes, so both end with the - * launch rather than with a handle a caller had to remember. + * The notices, built on the ambient scope: the inbox follow forks into it + * and the focus subscription is given back when it closes, so both end with + * the launch rather than with a handle a caller had to remember. */ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgentNotices")( function* ( dependencies: AgentNoticesDependencies, ): Effect.fn.Return { - const { listAgents, readPullRequest, taskName, open, onUnseenChanged, report } = dependencies; + const { readPullRequest, taskName, open, onUnseenChanged, report } = dependencies; + const interval = dependencies.inboxRetry ?? INBOX_RETRY; const doors = guardedDoors(yield* AgentNoticeDoors, report); const ledger = new Map(); /** What each agent with no plan is announced under, by agent: its automation's name, or its repository where the reader may not read the automation. */ const tasklessSubjects = new Map(); - /** What each agent was last read to have published, by agent. */ - const published = new Map(); const unseen = new Set(); const ends = yield* Queue.unbounded(); - const interval = dependencies.watchInterval ?? WATCH_INTERVAL; let models: readonly CatalogModel[] | undefined; let shownAgent: string | null = null; let focused = false; @@ -697,13 +656,6 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent Queue.offerUnsafe(ends, { agentId, agent, focused }); } - /** A read of what the agent published answered: the list is read again where it found other than the last read did, or a pull request where none was read before. */ - function observePublished(agentId: string, pullRequest: CodingAgentPullRequest | null): void { - const was = published.get(agentId) ?? null; - published.set(agentId, pullRequest); - if (!samePublished(was, pullRequest)) dependencies.onTaskAgentsMoved(); - } - /** The pull request a completed turn opened, where the read finds one; nothing for any other end, or a read that did not answer. */ const pullRequestOf = (end: TurnEnd): Effect.Effect => end.agent.status !== CODING_AGENT_STATUS.COMPLETED @@ -711,7 +663,6 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent : readPullRequest(end.agentId).pipe( Effect.map((answer) => { if (!("pullRequest" in answer)) return undefined; - observePublished(end.agentId, answer.pullRequest); return answer.pullRequest?.number; }), Effect.catchDefect((defect) => @@ -977,8 +928,8 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent * whatever the last account left is let go first, and nobody's is * nothing to read. Note that an answer is let go where the account it * was read for is no longer signed in, because the read may land in the - * tick before the change ends it, and the next read waits on the - * watch's clock rather than asking again at once for an account that + * tick before the change ends it, and the next read waits + * `INBOX_RETRY` rather than asking again at once for an account that * has left. */ const waitingOf = (reader: string | undefined): Effect.Effect => @@ -988,7 +939,7 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent publishWaiting({ items: [], plans: [] }, []); if (reader === undefined) return Effect.void; let cursor = INBOX_CURSOR_START; - // A read left unanswered, or answered for an account that has since left, is no answer: it is asked again on the watch's clock. + // A read left unanswered, or answered for an account that has since left, is no answer: it is asked again after `INBOX_RETRY`. const look = Effect.gen(function* () { const answer = yield* dependencies.readAccountInbox(cursor); if (dependencies.signedInAs() !== reader || "failure" in answer) { @@ -997,7 +948,7 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent cursor = answer.cursor; observeWaiting(answer); }); - // A look that died is reported and asked again on the watch's clock, as a tick of the watch is. + // A look that died is reported and asked again after `INBOX_RETRY`. const reported = Effect.catchDefect(look, (defect) => Effect.andThen( Effect.sync(() => @@ -1020,7 +971,6 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent if (was === undefined) { if (ledger.size === 0) holder = dependencies.signedInAs(); ledger.set(agentId, next); - if (stillWriting(next.status)) dependencies.onTaskAgentsMoved(); return; } // The reads overlap, so a list can land after a newer read moved the @@ -1035,7 +985,6 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent ledger.set(agentId, { ...next, pastTurns: was.pastTurns }); if (stillWriting(next.status)) return; ended(agentId, next); - dependencies.onTaskAgentsMoved(); return; } // A new turn: the one before it is left behind, and its own end is the next notice. @@ -1043,7 +992,6 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent if (was.turnId !== null) pastTurns.add(was.turnId); ledger.set(agentId, { ...next, pastTurns }); if (!stillWriting(next.status)) ended(agentId, next); - dependencies.onTaskAgentsMoved(); } /** Lets go of every agent `keep` refuses, their dots with them, as a plan or an agent deleted or an account left has no tab to clear one. */ @@ -1052,7 +1000,6 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent for (const [agentId, agent] of ledger) { if (keep(agent, agentId)) continue; ledger.delete(agentId); - published.delete(agentId); tasklessSubjects.delete(agentId); if (unseen.delete(agentId)) dotted = true; } @@ -1064,27 +1011,6 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent publishUnseen(); } - /** Whether the agent is still writing, or awaits the turn a message opens, which keeps it watched. */ - function watched(agent: HeldAgent): boolean { - return stillWriting(agent.status) || agent.awaiting; - } - - /** The plans with an agent watched, each listed again on the watch's clock. */ - function watchedTasks(): readonly string[] { - const tasks = new Set(); - for (const agent of ledger.values()) { - if (agent.taskId !== null && watched(agent)) tasks.add(agent.taskId); - } - return [...tasks]; - } - - /** The agents with no plan watched, each read again by itself on the watch's clock. */ - function watchedTaskless(): readonly string[] { - return [...ledger] - .filter(([, agent]) => agent.taskId === null && watched(agent)) - .map(([agentId]) => agentId); - } - /** The agent as the ledger holds it, on a plan or with none. */ function heldOf( agent: Pick< @@ -1099,7 +1025,6 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent automationName: agent.automationName, status: agent.status, turnId: agent.turnId, - awaiting: false, pastTurns: new Set(), }; } @@ -1148,13 +1073,6 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent if (!heldHere(agent)) return; noteSubject(agent); observe(agent.id, heldOf(agent)); - // The line awaits the turn it opens: the task is watched until that turn names itself. - const held = ledger.get(agent.id); - if (held !== undefined && !stillWriting(held.status) && !held.awaiting) { - ledger.set(agent.id, { ...held, awaiting: true }); - // The service reads the agent running from the message on, so the list is read again now. - dependencies.onTaskAgentsMoved(); - } }, observeStatus: (agentId, status) => { const held = ledger.get(agentId); @@ -1167,8 +1085,12 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent observeModels: (next) => { models = next; }, - observePublished, observeTaskGone: (taskId) => forget((agent) => agent.taskId !== taskId), + observeFollowed: (agents, taskIds) => { + const held = new Set(taskIds); + forget((agent) => agent.taskId === null || held.has(agent.taskId)); + notices.observeAgents(agents); + }, shown: (agentId) => { shownAgent = agentId; seen(agentId); @@ -1181,48 +1103,6 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent }, }; - /** One tick of the watch: each watched plan listed again, read as the panel's own list is, and each watched agent with no plan read as its page is. */ - const tick = Effect.suspend(() => - Effect.andThen( - Effect.forEach( - watchedTasks(), - (taskId) => - Effect.map(listAgents(taskId), (answer) => { - if ("agents" in answer) notices.observeAgents(answer.agents); - else if (answer.failure === CODING_AGENT_CALL_FAILURE.NOT_FOUND) { - notices.observeTaskGone(taskId); - } - }), - { discard: true }, - ), - Effect.forEach( - watchedTaskless(), - (agentId) => - Effect.map(dependencies.readAgent(agentId), (answer) => { - if ("agent" in answer) observe(agentId, heldOf(answer.agent)); - else if (answer.failure === CODING_AGENT_CALL_FAILURE.NOT_FOUND) { - forget((_agent, held) => held !== agentId); - } - }), - { discard: true }, - ), - ), - ); - // A tick that died is reported rather than left to end the watch: a - // repeat that failed once would never list again for the rest of the run. - const watch = Effect.catchDefect(tick, (defect) => - Effect.sync(() => { - report( - `the coding-agent watch failed: ${defect instanceof Error ? defect.message : String(defect)}`, - ); - }), - ); - // The watch never fires at the fork itself: nothing is in the ledger at - // launch, so the first tick is one interval on, and each after it. - yield* scheduleOnce( - Duration.toMillis(interval), - Effect.repeat(watch, Schedule.spaced(interval)), - ); // Each account's inbox read in turn, the read held for the last one // ended as the next is signed in, so nothing of one is read for another. yield* Effect.forkScoped( diff --git a/packages/host/src/client/client-notices.ts b/packages/host/src/client/client-notices.ts index 7163186684..b80498e82b 100644 --- a/packages/host/src/client/client-notices.ts +++ b/packages/host/src/client/client-notices.ts @@ -77,8 +77,6 @@ export const linkClientNotices = /* @__PURE__ */ Effect.fn("client/linkClientNot ); }; const notices = yield* createAgentNotices({ - listAgents: (taskId) => host.codingAgentList({ taskId: taskId }), - readAgent: (agentId) => host.codingAgentPage({ agentId }), readPullRequest: (agentId) => host.codingAgentPullRequest({ agentId }), taskName: (taskId) => noticeTaskTitle(state.snapshot().tasks, taskId), ...presenter, @@ -94,10 +92,6 @@ export const linkClientNotices = /* @__PURE__ */ Effect.fn("client/linkClientNot }, onAccountChanged: (listener) => host.onAccountChanged((account) => listener(accountKey(account))), - // An agent starting or ending, or a new pull request, is read on the sidebar's rows from the list. - onTaskAgentsMoved: () => { - forkReported(host.tasksRefresh(), "the task list refresh"); - }, readAccountInbox: (after) => host.codingAgentAccountInbox({ after }), accounts: Stream.map(state.changes, (document) => accountKey(document.account)), onInboxChanged: (inbox) => state.update({ inbox }), @@ -124,6 +118,28 @@ export const linkClientNotices = /* @__PURE__ */ Effect.fn("client/linkClientNot ), ), ); + // Every agent on a task, while the list is followed live, is the notices' + // to hold in place of the watch's own reads, and a task the list no longer + // holds takes its agents with it. + yield* Effect.forkScoped( + Stream.runForEach( + Stream.changes(Stream.map(state.changes, (document) => document.tasks.taskAgents)), + (agents) => + Effect.catchDefect( + Effect.sync(() => { + if (agents === undefined) return; + const taskIds = state.snapshot().tasks.tasks.map((each) => each.id); + notices.observeFollowed(agents, taskIds); + }), + (defect) => + Effect.sync(() => + report( + `the agents on tasks could not be noted: ${defect instanceof Error ? defect.message : String(defect)}`, + ), + ), + ), + ), + ); // A teammate's change or an installation made on GitHub may have moved the // workspaces; the host reads them no oftener than once a minute. yield* Effect.acquireRelease( diff --git a/packages/host/src/compose-tasks.test.ts b/packages/host/src/compose-tasks.test.ts index 846a109a26..a67fd8b4e0 100644 --- a/packages/host/src/compose-tasks.test.ts +++ b/packages/host/src/compose-tasks.test.ts @@ -4358,6 +4358,11 @@ it.effect( [drawn?.agents?.working, drawn?.agents?.agents.map((line) => line.status)], [1, [CODING_AGENT_STATUS.STARTING]], ); + assert.deepEqual( + last()?.taskAgents?.map((each) => [each.planId, each.status, each.turnId]), + [[INVITES, CODING_AGENT_STATUS.STARTING, null]], + ); + yield* call(GATEWAY_METHOD.TASKS_REFRESH); yield* call(GATEWAY_METHOD.TASKS_DELETE, { taskId: BILLING }); yield* TestClock.adjust(Duration.minutes(5)); @@ -4435,6 +4440,7 @@ it.effect( realtime.deliverPart("agents", []); yield* realtime.settled; assert.equal(last()?.tasks[0]?.name, "Teammate invitations, live"); + assert.deepEqual(last()?.taskAgents, []); realtime.failPart("plans"); yield* realtime.settled; diff --git a/packages/host/src/compose-tasks.ts b/packages/host/src/compose-tasks.ts index 36726bb1fa..d2f32e58dc 100644 --- a/packages/host/src/compose-tasks.ts +++ b/packages/host/src/compose-tasks.ts @@ -858,10 +858,11 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct ), openLive: dependencies.openLive ?? openRealtimeClient, now: kernel.now, - publish: (listed) => { + publish: ({ list, taskAgents }) => { write({ - tasks: listed.plans, - tasklessAgents: listed.planlessAgents ?? [], + tasks: list.plans, + tasklessAgents: list.planlessAgents ?? [], + taskAgents, listStatus: TASKS_READ.READY, }); // A name the service gave the open task reaches its document as the list carries it. diff --git a/packages/host/src/task-list-follow.ts b/packages/host/src/task-list-follow.ts index f5958406eb..7f0f2103c9 100644 --- a/packages/host/src/task-list-follow.ts +++ b/packages/host/src/task-list-follow.ts @@ -1,6 +1,8 @@ +import type { CodingAgentSummary } from "@luke/features/coding-agent-wire"; import { type TaskListLiveAnswer, type TaskListLiveRows, + taskAgentsOfLiveRows, taskListGraceEdge, taskListLiveRowsSchema, taskListOfLiveRows, @@ -34,6 +36,11 @@ const LIVE_RESTART = Duration.seconds(5); /** A seal as the follow asks for it: sealed, or with `poll` its rows inline; a failure where the service answered none, `gone` where it never will again. */ type Sealed = TaskListLiveAnswer | { readonly failure: string; readonly gone?: boolean }; +/** The list as the live rows make it at one instant, and every agent on a plan beside it. */ +interface LiveTaskList { + readonly list: TaskListAnswer; + readonly taskAgents: readonly CodingAgentSummary[]; +} export interface TaskListFollowDependencies { /** The sealed statements, or with `poll` their rows inline, fetched from the service. */ @@ -41,17 +48,20 @@ export interface TaskListFollowDependencies { readonly openLive: (url: string) => LiveClient | PromiseLike; /** Now, by the host's own clock, which renewals are timed by. */ readonly now: () => number; - /** The list, each time its rows or the clock move it. */ - readonly publish: (list: TaskListAnswer) => void; + /** The list, and every agent on a task as its task's own list names it, each time the rows or the clock move them. */ + readonly publish: (read: LiveTaskList) => void; /** That the list could not be read, each time a seal goes unanswered; the list already drawn stands. */ readonly failed: () => void; } /** The list the rows make now, and again at each instant the clock alone moves it, until the rows move. */ -function listsOf(rows: TaskListLiveRows): Stream.Stream { +function listsOf(rows: TaskListLiveRows): Stream.Stream { return Stream.unwrap( Effect.map(Clock.currentTimeMillis, (at) => { - const now = Stream.succeed(taskListOfLiveRows(rows, at)); + const now = Stream.succeed({ + list: taskListOfLiveRows(rows, at), + taskAgents: taskAgentsOfLiveRows(rows, at), + }); const edge = taskListGraceEdge(rows, at); if (edge === undefined) return now; const later = Stream.fromEffect(Effect.sleep(Duration.millis(edge - at))); @@ -67,7 +77,7 @@ function listsOf(rows: TaskListLiveRows): Stream.Stream { function liveLists( dependencies: TaskListFollowDependencies, first: TaskListLiveAnswer, -): Stream.Stream { +): Stream.Stream { const decode = Schema.decodeUnknownOption(taskListLiveRowsSchema); const rows = liveRows( { @@ -99,8 +109,8 @@ export function followTaskList(dependencies: TaskListFollowDependencies): Effect return true; } // A read the service refused for good, as one under another session, ends the follow rather than starting it again. - const gone = yield* Stream.runForEach(liveLists(dependencies, sealed), (list) => - Effect.sync(() => dependencies.publish(list)), + const gone = yield* Stream.runForEach(liveLists(dependencies, sealed), (read) => + Effect.sync(() => dependencies.publish(read)), ).pipe( Effect.as(false), Effect.catch((broken) => Effect.succeed(broken.reason === LIVE_GONE)), diff --git a/packages/service/src/tasks/task-list-live.test.ts b/packages/service/src/tasks/task-list-live.test.ts index b8fb4dca28..248462edc0 100644 --- a/packages/service/src/tasks/task-list-live.test.ts +++ b/packages/service/src/tasks/task-list-live.test.ts @@ -6,10 +6,12 @@ import { Database } from "@luke/database/core/query"; import { archiveCodingAgent, createCodingAgent, + listCodingAgents, pinCodingAgent, recordCodingAgentPublished, } from "@luke/database/models/coding-agent-store"; import { listTasks, updateTask } from "@luke/database/models/task-store"; +import { readAccountNames } from "@luke/database/models/workspace-store"; import { user } from "@luke/database/schemas/auth"; import { codingAgent } from "@luke/database/schemas/coding-agent"; import { messages, turns } from "@luke/database/schemas/storage"; @@ -28,7 +30,11 @@ import { PULL_REQUEST_STATE, } from "@luke/features/coding-agent-wire"; import { GITHUB_ACCESS } from "@luke/features/github-repositories-wire"; -import { taskListLiveRowsSchema, taskListOfLiveRows } from "@luke/features/task-list-live"; +import { + taskAgentsOfLiveRows, + taskListLiveRowsSchema, + taskListOfLiveRows, +} from "@luke/features/task-list-live"; import { MESSAGE_AUTHOR, MESSAGE_CHANNEL, @@ -40,6 +46,8 @@ import { import { eq } from "drizzle-orm"; import { Clock, Effect, Option, Schema } from "effect"; import { SqlClient } from "effect/sql"; +import { codingAgentSummary, summaryAccounts } from "../agents/coder-host/status.js"; +import { standingsOf } from "../agents/coder-host/stop.js"; import { listTasklessAgents, withTaskAgents } from "../agents/task-agents.js"; import { BRAIN_REQUEST_FAILURE } from "../core.js"; import { taskListLiveStatements } from "./task-list-live.js"; @@ -151,15 +159,29 @@ const lineOf = ( const runLive = (statement: LiveStatement) => Effect.flatMap(SqlClient.SqlClient, (sql) => sql.unsafe(statement.sql, [...statement.params])); -/** The list as the desktop builds it from the live rows, at `now`. */ -const liveListOf = (userId: string, now: number) => +/** The rows the live statements deliver to the account, decoded as the desktop decodes them. */ +const liveRowsOf = (userId: string) => Effect.gen(function* () { const statements = Option.getOrThrow(taskListLiveStatements(userId)); - const rows = yield* Schema.decodeUnknownEffect(taskListLiveRowsSchema)({ + return yield* Schema.decodeUnknownEffect(taskListLiveRowsSchema)({ plans: yield* runLive(statements.plans), agents: yield* runLive(statements.agents), }); - return taskListOfLiveRows(rows, now); + }); + +/** The list as the desktop builds it from the live rows, at `now`. */ +const liveListOf = (userId: string, now: number) => + Effect.map(liveRowsOf(userId), (rows) => taskListOfLiveRows(rows, now)); + +/** A plan's agents as its own list answers them at `now`, as the service's endpoint builds them. */ +const heldTaskAgentsOf = (userId: string, taskId: string, now: number) => + Effect.gen(function* () { + const agents = yield* listCodingAgents(userId, taskId); + const standing = yield* standingsOf(agents.map((agent) => agent.conversationId)); + const names = yield* readAccountNames(agents.flatMap(summaryAccounts)); + return agents.map((agent) => + codingAgentSummary(agent, standing(agent.conversationId), now, names), + ); }); /** @@ -273,6 +295,14 @@ it.layer(getTestDB())("the plan list's live statements", (it) => { for (const reader of [owner, teammate, outsider]) { assert.deepEqual(yield* liveListOf(reader, NOW), yield* heldListOf(reader, NOW)); } + // Every plan's agents, as each plan's own list names them, for the desktop's ledger. + for (const reader of [owner, teammate]) { + const taskAgents = taskAgentsOfLiveRows(yield* liveRowsOf(reader), NOW); + assert.deepEqual(taskAgents, [ + ...(yield* heldTaskAgentsOf(reader, plan.id, NOW)), + ...(yield* heldTaskAgentsOf(reader, shelved.id, NOW)), + ]); + } const read = yield* liveListOf(owner, NOW); const agents = read.plans.find((each) => each.id === plan.id)?.agents; assert.deepEqual([agents?.total, agents?.working, agents?.pullRequestTotal], [7, 2, 1]);