From c2f17799fcf42be6c3b4f1fe46034c390654f529 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 21 Oct 2026 21:10:08 +0200 Subject: [PATCH] swarm-ui: only the newest start's late stop ends a claude login --- .../agent/claude-login/ClaudeLoginDialog.tsx | 5 +++- .../agent/claude-login/ClaudeLoginSteps.ts | 25 +++++++++++++++--- .../swarm-ui/test/claude-login.test.mjs | 26 ++++++++++++++++--- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx index 77e799c1..135bad83 100644 --- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx +++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx @@ -16,7 +16,9 @@ import { TextField } from "../../ui/text-field/TextField.js"; import { afterCode, afterStart, + beginStart, claudeLoginPath, + isLatestStart, leavesLoginRunning, pastedCode, startOutlivedDialog, @@ -40,11 +42,12 @@ export function ClaudeLoginDialog({ useEffect(() => { let live = true; + const mine = beginStart(agent); void (async () => { try { const r = await fetch(claudeLoginPath(agent), { method: "POST" }); const problem = await problemOf(r); - if (startOutlivedDialog(live, problem)) { + if (startOutlivedDialog(live, problem, isLatestStart(agent, mine))) { void fetch(claudeLoginPath(agent), { method: "DELETE" }); return; } diff --git a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginSteps.ts b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginSteps.ts index 50f46660..59a849f6 100644 --- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginSteps.ts +++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginSteps.ts @@ -34,14 +34,33 @@ export function leavesLoginRunning(s: LoginStep): boolean { return s.step === "code"; } +// The newest start per agent. The minter keeps one login per agent and a +// new start replaces it, so a late stop from an older start would end the +// newer dialog's login. +const latestStart = new Map(); + +// Marks a start as the agent's newest and returns its number. +export function beginStart(agent: string): number { + const n = (latestStart.get(agent) ?? 0) + 1; + latestStart.set(agent, n); + return n; +} + +// Whether `n` is still the agent's newest start. +export function isLatestStart(agent: string, n: number): boolean { + return latestStart.get(agent) === n; +} + // Whether a start that answered after the dialog went away left a login -// running: it did unless the start itself failed. The dialog stops it then, -// since nothing is left to. +// running that nothing else owns: it did unless the start itself failed or a +// newer start has replaced it. The dialog stops it then, since nothing is +// left to. export function startOutlivedDialog( dialogLive: boolean, problem: ProblemDetails | null, + latest: boolean, ): boolean { - return !dialogLive && problem === null; + return !dialogLive && problem === null && latest; } // What the code form may submit: the pasted code without surrounding diff --git a/frontend/packages/swarm-ui/test/claude-login.test.mjs b/frontend/packages/swarm-ui/test/claude-login.test.mjs index 4f707e34..8ba401ce 100644 --- a/frontend/packages/swarm-ui/test/claude-login.test.mjs +++ b/frontend/packages/swarm-ui/test/claude-login.test.mjs @@ -6,7 +6,9 @@ import { test } from "node:test"; import { afterCode, afterStart, + beginStart, claudeLoginPath, + isLatestStart, leavesLoginRunning, pastedCode, startOutlivedDialog, @@ -49,10 +51,26 @@ test("only a login waiting for its code is stopped on close", () => { assert.equal(leavesLoginRunning({ step: "stored" }), false); }); -test("a start that answers after the dialog closed is stopped, unless it failed", () => { - assert.equal(startOutlivedDialog(false, null), true); - assert.equal(startOutlivedDialog(true, null), false); - assert.equal(startOutlivedDialog(false, { detail: "no minter" }), false); +test("a start that answers after the dialog closed is stopped, unless it failed or was replaced", () => { + assert.equal(startOutlivedDialog(false, null, true), true); + assert.equal(startOutlivedDialog(true, null, true), false); + assert.equal( + startOutlivedDialog(false, { detail: "no minter" }, true), + false, + ); + assert.equal(startOutlivedDialog(false, null, false), false); +}); + +test("only an agent's newest start counts as the latest", () => { + const first = beginStart("login-test-a"); + assert.equal(isLatestStart("login-test-a", first), true); + const second = beginStart("login-test-a"); + assert.equal(isLatestStart("login-test-a", first), false); + assert.equal(isLatestStart("login-test-a", second), true); + // Another agent's starts are counted on their own. + const other = beginStart("login-test-b"); + assert.equal(isLatestStart("login-test-a", second), true); + assert.equal(isLatestStart("login-test-b", other), true); }); test("a pasted code is trimmed, and a blank one is nothing", () => {