From 66f24e5535226e6c30c6a93c7b9c01e9465e9f56 Mon Sep 17 00:00:00 2001 From: Charles Pan <30245070+charleslpan@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:08:21 +0000 Subject: [PATCH 1/3] fix(host): an agent archived off a plan the list still holds takes its unread dot with it The ledger is fed from the followed task list (#3284), which leaves archived agents out. It forgot an agent only when its whole plan left the list, so an agent archived while its unread dot showed kept the dot. The ledger now also forgets an agent of a held plan that the list no longer names. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- packages/host/src/agent-notices.test.ts | 18 ++++++++++++++++++ packages/host/src/agent-notices.ts | 9 +++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/host/src/agent-notices.test.ts b/packages/host/src/agent-notices.test.ts index 91bddd4dcc..1ff524167c 100644 --- a/packages/host/src/agent-notices.test.ts +++ b/packages/host/src/agent-notices.test.ts @@ -277,6 +277,24 @@ it.effect( }), ); +it.effect( + "an agent the followed list no longer names on a plan it still holds, as one archived, takes its dot with it", + () => + Effect.gen(function* () { + const f = fixture(); + const notices = yield* f.notices; + f.focus(false); + + notices.observeFollowed([agent(CODING_AGENT_STATUS.RUNNING)], [TASK_ID]); + notices.observeFollowed([agent(CODING_AGENT_STATUS.COMPLETED)], [TASK_ID]); + yield* settled; + assert.deepEqual(f.unseen.at(-1), [AGENT_ID]); + + notices.observeFollowed([], [TASK_ID]); + 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 diff --git a/packages/host/src/agent-notices.ts b/packages/host/src/agent-notices.ts index aa3e504126..46e656a11b 100644 --- a/packages/host/src/agent-notices.ts +++ b/packages/host/src/agent-notices.ts @@ -351,7 +351,7 @@ export interface AgentNotices { observeModels(models: readonly CatalogModel[]): 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. */ + /** Every agent on a plan as the list followed live carries them, and the plans it holds: an agent of any other plan, or one of a held plan the list no longer names, as one archived, 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; @@ -1088,7 +1088,12 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent observeTaskGone: (taskId) => forget((agent) => agent.taskId !== taskId), observeFollowed: (agents, taskIds) => { const held = new Set(taskIds); - forget((agent) => agent.taskId === null || held.has(agent.taskId)); + // The list names every agent on its plans but the archived, so one it no longer names takes its dot with it. + const named = new Set(agents.map((agent) => agent.id)); + forget( + (agent, agentId) => + agent.taskId === null || (held.has(agent.taskId) && named.has(agentId)), + ); notices.observeAgents(agents); }, shown: (agentId) => { From 786f99b01925db17c058919b902d6201d05aab98 Mon Sep 17 00:00:00 2001 From: Charles Pan <30245070+charleslpan@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:12:03 +0000 Subject: [PATCH 2/3] fix(host): an agent only a Start answered is kept until the followed list names it A push of the plans can carry an agents part from before a Start, so the ledger forgets a missing agent only once the followed list had named it. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- packages/host/src/agent-notices.test.ts | 18 ++++++++++++++++++ packages/host/src/agent-notices.ts | 10 ++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/host/src/agent-notices.test.ts b/packages/host/src/agent-notices.test.ts index 1ff524167c..a009052b6e 100644 --- a/packages/host/src/agent-notices.test.ts +++ b/packages/host/src/agent-notices.test.ts @@ -295,6 +295,24 @@ it.effect( }), ); +it.effect( + "an agent a Start answered is kept through a push that does not name it yet, so its quick end is still announced", + () => + Effect.gen(function* () { + const f = fixture(); + const notices = yield* f.notices; + f.focus(false); + + notices.observeAgents([agent(CODING_AGENT_STATUS.RUNNING)]); + notices.observeFollowed([], [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]); + }), +); + /** * 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 diff --git a/packages/host/src/agent-notices.ts b/packages/host/src/agent-notices.ts index 46e656a11b..24733bdb4d 100644 --- a/packages/host/src/agent-notices.ts +++ b/packages/host/src/agent-notices.ts @@ -617,6 +617,8 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent /** 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(); const unseen = new Set(); + /** The agents on plans the followed list last named, so one it stops naming is known to have left it rather than not reached it yet. */ + let followedAgents = new Set(); const ends = yield* Queue.unbounded(); let models: readonly CatalogModel[] | undefined; let shownAgent: string | null = null; @@ -1088,12 +1090,16 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent observeTaskGone: (taskId) => forget((agent) => agent.taskId !== taskId), observeFollowed: (agents, taskIds) => { const held = new Set(taskIds); - // The list names every agent on its plans but the archived, so one it no longer names takes its dot with it. + // The list names every agent on its plans but the archived, so one it named and no longer + // does takes its dot with it. One only a Start answered is kept until the list names it, since + // a push of the plans may carry agents from before that Start. const named = new Set(agents.map((agent) => agent.id)); forget( (agent, agentId) => - agent.taskId === null || (held.has(agent.taskId) && named.has(agentId)), + agent.taskId === null || + (held.has(agent.taskId) && (named.has(agentId) || !followedAgents.has(agentId))), ); + followedAgents = named; notices.observeAgents(agents); }, shown: (agentId) => { From 9eae498b1078e89c4ebc4b6c772dddfe6b3648c7 Mon Sep 17 00:00:00 2001 From: Charles Pan <30245070+charleslpan@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:15:04 +0000 Subject: [PATCH 3/3] fix(host): an agent the followed list never names is let go after a few pushes One only a Start answered, then archived before the list named it, would have kept its dot for good. It is now kept through two pushes that leave it out, which covers a push carrying agents from before the Start, and let go on the third. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- packages/host/src/agent-notices.test.ts | 21 +++++++++++++++++++ packages/host/src/agent-notices.ts | 27 ++++++++++++++++++------- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/host/src/agent-notices.test.ts b/packages/host/src/agent-notices.test.ts index a009052b6e..397a6aea1a 100644 --- a/packages/host/src/agent-notices.test.ts +++ b/packages/host/src/agent-notices.test.ts @@ -313,6 +313,27 @@ it.effect( }), ); +it.effect( + "an agent a Start answered that the list never names, as one archived before it did, is let go after a few pushes with its dot", + () => + Effect.gen(function* () { + const f = fixture(); + const notices = yield* f.notices; + f.focus(false); + + notices.observeAgents([agent(CODING_AGENT_STATUS.RUNNING)]); + notices.observeAgents([agent(CODING_AGENT_STATUS.COMPLETED)]); + yield* settled; + assert.deepEqual(f.unseen.at(-1), [AGENT_ID]); + + notices.observeFollowed([], [TASK_ID]); + notices.observeFollowed([], [TASK_ID]); + assert.deepEqual(f.unseen.at(-1), [AGENT_ID], "kept through a push or two that predate it"); + notices.observeFollowed([], [TASK_ID]); + 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 diff --git a/packages/host/src/agent-notices.ts b/packages/host/src/agent-notices.ts index 24733bdb4d..e8244e0a9a 100644 --- a/packages/host/src/agent-notices.ts +++ b/packages/host/src/agent-notices.ts @@ -137,6 +137,9 @@ const ENDED_WORD = { [CODING_AGENT_STATUS.FAILED]: "failed", } as const; +/** How many pushes of the followed list in a row may leave out an agent only a Start answered before the ledger lets it go. */ +const UNNAMED_PUSHES_KEPT = 2; + /** What kind of event a notice tells of. */ export const NOTICE_KIND = { AGENT_END: "agent-end", @@ -619,6 +622,8 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent const unseen = new Set(); /** The agents on plans the followed list last named, so one it stops naming is known to have left it rather than not reached it yet. */ let followedAgents = new Set(); + /** How many pushes in a row have left out each agent the list has not named yet, as one only a Start answered. */ + const unnamedPushes = new Map(); const ends = yield* Queue.unbounded(); let models: readonly CatalogModel[] | undefined; let shownAgent: string | null = null; @@ -1091,14 +1096,22 @@ export const createAgentNotices = /* @__PURE__ */ Effect.fn("desktop/createAgent observeFollowed: (agents, taskIds) => { const held = new Set(taskIds); // The list names every agent on its plans but the archived, so one it named and no longer - // does takes its dot with it. One only a Start answered is kept until the list names it, since - // a push of the plans may carry agents from before that Start. + // does takes its dot with it. One only a Start answered is kept through a push or two that + // leave it out, since a push of the plans may carry agents from before that Start, and let go + // after that, as one archived before the list ever named it. const named = new Set(agents.map((agent) => agent.id)); - forget( - (agent, agentId) => - agent.taskId === null || - (held.has(agent.taskId) && (named.has(agentId) || !followedAgents.has(agentId))), - ); + forget((agent, agentId) => { + if (agent.taskId === null) return true; + if (!held.has(agent.taskId)) return false; + if (named.has(agentId)) return true; + if (followedAgents.has(agentId)) return false; + const pushes = (unnamedPushes.get(agentId) ?? 0) + 1; + unnamedPushes.set(agentId, pushes); + return pushes <= UNNAMED_PUSHES_KEPT; + }); + for (const agentId of unnamedPushes.keys()) { + if (named.has(agentId) || !ledger.has(agentId)) unnamedPushes.delete(agentId); + } followedAgents = named; notices.observeAgents(agents); },