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 fa91a72e..135bad83 100644 --- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx +++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx @@ -2,7 +2,8 @@ // login minter. Opening it starts the login (`POST …/claude-login`), which // answers with the URL claude printed; the operator opens it and pastes the // code back (`POST …/claude-login/code`). Closing it while the code is -// still awaited stops the login (`DELETE …/claude-login`). +// still awaited stops the login (`DELETE …/claude-login`). Closing while the +// start is still in flight stops it once the start answers. // // Mounted only while open, so each open is a fresh login. import { useEffect, useState } from "preact/hooks"; @@ -15,9 +16,12 @@ import { TextField } from "../../ui/text-field/TextField.js"; import { afterCode, afterStart, + beginStart, claudeLoginPath, + isLatestStart, leavesLoginRunning, pastedCode, + startOutlivedDialog, type LoginStep, } from "./ClaudeLoginSteps.js"; import "./ClaudeLoginDialog.css"; @@ -38,10 +42,15 @@ 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, isLatestStart(agent, mine))) { + void fetch(claudeLoginPath(agent), { method: "DELETE" }); + return; + } const next = afterStart( problem ? { problem } : ((await r.json()) as { url: string }), ); 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 5366130b..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,6 +34,35 @@ 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 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 && latest; +} + // What the code form may submit: the pasted code without surrounding // whitespace, or nothing. export function pastedCode(raw: string): string | null { diff --git a/frontend/packages/swarm-ui/test/claude-login.test.mjs b/frontend/packages/swarm-ui/test/claude-login.test.mjs index e61e05ff..8ba401ce 100644 --- a/frontend/packages/swarm-ui/test/claude-login.test.mjs +++ b/frontend/packages/swarm-ui/test/claude-login.test.mjs @@ -6,9 +6,12 @@ import { test } from "node:test"; import { afterCode, afterStart, + beginStart, claudeLoginPath, + isLatestStart, leavesLoginRunning, pastedCode, + startOutlivedDialog, } from "../src/agent/claude-login/ClaudeLoginSteps.ts"; test("the routes name the agent, escaped", () => { @@ -48,6 +51,28 @@ 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 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", () => { assert.equal(pastedCode(" abc-def \n"), "abc-def"); assert.equal(pastedCode(" \n "), null);