From 7e5d73fdda23df1491f0596625b1e91100ac65aa Mon Sep 17 00:00:00 2001 From: Charles Pan <30245070+charleslpan@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:25:47 +0000 Subject: [PATCH] refactor(features,service): an agent's status is read by one pure rule the desktop can run The service's status rule, its starting grace and its line grace, moves to @luke/features/coding-agent-status as a pure function of the newest turn, when the newest awaiting line was written, and the instant read at, with statusClockEdge, the next instant the clock alone moves the status. The service reads every status, every line's standing, and every clock edge through it as before, so a desktop following an agent's rows over Neon Realtime reads the same status and knows when to read it again. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- packages/features/package.json | 1 + .../features/src/coding-agent-status.test.ts | 121 +++++++++++ packages/features/src/coding-agent-status.ts | 194 ++++++++++++++++++ .../service/src/agents/coder-host/bounds.ts | 5 +- .../service/src/agents/coder-host/status.ts | 150 ++++---------- 5 files changed, 355 insertions(+), 116 deletions(-) create mode 100644 packages/features/src/coding-agent-status.test.ts create mode 100644 packages/features/src/coding-agent-status.ts diff --git a/packages/features/package.json b/packages/features/package.json index 7451832586..03d8126183 100644 --- a/packages/features/package.json +++ b/packages/features/package.json @@ -34,6 +34,7 @@ "./coding-agent-failure-words": "./src/coding-agent-failure-words.js", "./chapters-model-choice": "./src/chapters-model-choice.js", "./chat-mentions": "./src/chat-mentions.js", + "./coding-agent-status": "./src/coding-agent-status.js", "./chat-wire": "./src/chat-wire.js", "./coding-agent-view": "./src/coding-agent-view.js", "./coding-agent-wire": "./src/coding-agent-wire.js", diff --git a/packages/features/src/coding-agent-status.test.ts b/packages/features/src/coding-agent-status.test.ts new file mode 100644 index 0000000000..8b37e29750 --- /dev/null +++ b/packages/features/src/coding-agent-status.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { TURN_STATUS } from "@luke/wire"; +import { test } from "vitest"; +import { + codingAgentStatusAt, + LINE_GRACE_MILLIS, + lineAwaitsTurn, + STARTING_GRACE_MILLIS, + type StatusTurn, + statusClockEdge, +} from "./coding-agent-status.js"; +import { CODING_AGENT_STATUS } from "./coding-agent-wire.js"; + +const STARTED = 1_700_000_000_000; + +/** The agent read `sinceStart` after its Start, with the newest awaiting line written at `lineAt` where one stands. */ +function standing(sinceStart: number, lineAt?: number) { + return { createdAt: STARTED, now: STARTED + sinceStart, awaitingLineAt: lineAt }; +} + +/** A newest turn queued a second after the Start, in the state given. */ +function turn(status: string, overrides: Partial = {}): StatusTurn { + return { + status, + cancelRequested: false, + queuedAt: STARTED + 1_000, + settledAt: null, + sessionEnded: false, + ...overrides, + }; +} + +test("an agent with no turn reads starting through its grace and failed past it, and the edge is the first instant it fails", () => { + assert.equal(codingAgentStatusAt(undefined, standing(0)), CODING_AGENT_STATUS.STARTING); + assert.equal( + codingAgentStatusAt(undefined, standing(STARTING_GRACE_MILLIS)), + CODING_AGENT_STATUS.STARTING, + ); + const edge = statusClockEdge(undefined, standing(1_000)); + assert.equal(edge, STARTED + STARTING_GRACE_MILLIS + 1); + assert.equal( + codingAgentStatusAt(undefined, standing((edge ?? 0) - STARTED)), + CODING_AGENT_STATUS.FAILED, + ); + assert.equal(statusClockEdge(undefined, standing(STARTING_GRACE_MILLIS + 1)), undefined); +}); + +test("a turn reads as its own word ended, a running one with a Stop asked reads cancelled, and the clock moves neither", () => { + const running = turn(TURN_STATUS.RUNNING); + assert.equal(codingAgentStatusAt(running, standing(0)), CODING_AGENT_STATUS.RUNNING); + assert.equal( + codingAgentStatusAt(turn(TURN_STATUS.QUEUED), standing(0)), + CODING_AGENT_STATUS.RUNNING, + ); + assert.equal( + codingAgentStatusAt({ ...running, cancelRequested: true }, standing(0)), + CODING_AGENT_STATUS.CANCELLED, + ); + assert.equal( + codingAgentStatusAt(turn(TURN_STATUS.SETTLED, { cancelRequested: true }), standing(0)), + CODING_AGENT_STATUS.COMPLETED, + ); + assert.equal(codingAgentStatusAt(turn("paused"), standing(0)), CODING_AGENT_STATUS.FAILED); + assert.equal(statusClockEdge(running, standing(0)), undefined); + assert.equal(statusClockEdge(turn(TURN_STATUS.SETTLED), standing(0)), undefined); +}); + +test("a line awaiting its turn reads running inside its grace, counted from the later of its writing and the newest turn's end, and failed past it, the edge the first instant it fails", () => { + const settled = turn(TURN_STATUS.SETTLED, { settledAt: STARTED + 10_000 }); + const lineAt = STARTED + 20_000; + const inside = standing(20_000 + LINE_GRACE_MILLIS, lineAt); + assert.equal(codingAgentStatusAt(settled, inside), CODING_AGENT_STATUS.RUNNING); + assert.equal(lineAwaitsTurn(settled, inside), true); + const edge = statusClockEdge(settled, standing(20_000, lineAt)); + assert.equal(edge, lineAt + LINE_GRACE_MILLIS + 1); + const past = standing((edge ?? 0) - STARTED, lineAt); + assert.equal(codingAgentStatusAt(settled, past), CODING_AGENT_STATUS.FAILED); + assert.equal(lineAwaitsTurn(settled, past), false, "Stop and archive read the agent idle"); + assert.equal(statusClockEdge(settled, past), undefined); + + // A turn that ended after the line was written counts the grace from its end. + const later = turn(TURN_STATUS.FAILED, { settledAt: lineAt + 60_000 }); + assert.equal( + statusClockEdge(later, standing(20_000, lineAt)), + lineAt + 60_000 + LINE_GRACE_MILLIS + 1, + ); + + // With no turn at all, a line holds an agent running past the starting grace, for its own grace. + const noTurn = standing(STARTING_GRACE_MILLIS * 2, STARTED + STARTING_GRACE_MILLIS * 2); + assert.equal(codingAgentStatusAt(undefined, noTurn), CODING_AGENT_STATUS.RUNNING); +}); + +test("a line beside a running turn awaits it whatever the clock says, so nothing but a row moves it", () => { + const running = turn(TURN_STATUS.RUNNING); + const long = standing(LINE_GRACE_MILLIS * 10, STARTED + 2_000); + assert.equal(codingAgentStatusAt(running, long), CODING_AGENT_STATUS.RUNNING); + assert.equal(lineAwaitsTurn(running, long), true); + assert.equal(statusClockEdge(running, long), undefined); +}); + +test("a line written before the newest turn was queued was that turn's own, and awaits nothing once it ended", () => { + const settled = turn(TURN_STATUS.SETTLED, { settledAt: STARTED + 5_000 }); + const opener = standing(6_000, STARTED + 500); + assert.equal(codingAgentStatusAt(settled, opener), CODING_AGENT_STATUS.COMPLETED); + assert.equal(lineAwaitsTurn(settled, opener), false); + assert.equal(statusClockEdge(settled, opener), undefined); +}); + +test("a turn failed because its session ended for good leaves every line awaiting nothing", () => { + const ended = turn(TURN_STATUS.FAILED, { settledAt: STARTED + 5_000, sessionEnded: true }); + const steer = standing(6_000, STARTED + 5_500); + assert.equal(codingAgentStatusAt(ended, steer), CODING_AGENT_STATUS.FAILED); + assert.equal(lineAwaitsTurn(ended, steer), false); + assert.equal(statusClockEdge(ended, steer), undefined); +}); + +test("an agent started blank with no turn reads completed however long ago it started, and the clock moves it nowhere", () => { + const blank = { ...standing(STARTING_GRACE_MILLIS * 3), blank: true }; + assert.equal(codingAgentStatusAt(undefined, blank), CODING_AGENT_STATUS.COMPLETED); + assert.equal(statusClockEdge(undefined, { ...standing(0), blank: true }), undefined); +}); diff --git a/packages/features/src/coding-agent-status.ts b/packages/features/src/coding-agent-status.ts new file mode 100644 index 0000000000..2429eb0c49 --- /dev/null +++ b/packages/features/src/coding-agent-status.ts @@ -0,0 +1,194 @@ +import { TURN_STATUS } from "@luke/wire"; +import { CODING_AGENT_STATUS, type CodingAgentStatus } from "./coding-agent-wire.js"; + +/** + * coding-agent-status.ts -- where a coding agent stands, read from its newest turn at an instant: the one rule the service and the desktop both read an agent's status by. + * + * An agent's status is its conversation's newest turn row and the one + * thing beside it, a message of the developer's still awaiting its turn: + * `starting` before the first turn is queued, `running` while eve runs a + * turn or a line waits for the turn it will open, and how the newest turn + * ended after. A turn still running that carries a Stop stamp reads as + * cancelled already, since the Stop is on its way to eve and nothing the + * turn still does changes where it ends. An agent with no turn row long + * past its Start (`STARTING_GRACE_MILLIS`) reads as failed rather than + * starting forever, as one that never started. + * + * A line that awaits its turn reads as running whatever the newest turn + * says, because the session took the message and the turn it opens is on + * its way, but only for so long: a line that has waited past + * `LINE_GRACE_MILLIS` with no turn running reads as failed, as one whose + * turn is not coming, so the developer is offered Continue on it rather + * than watching it run forever. A line older than the newest turn's + * queueing that the turn never took awaits nothing: that turn was its, and + * ended without the store hearing it received. A turn failed because its + * session ended for good leaves every line awaiting nothing, since the + * session that would have taken one is gone. + * + * The rule is pure and takes the instant it reads at, because the two + * graces are the parts of it the rows alone do not say: a reader following + * the rows live reads the status again at `statusClockEdge`, the next + * instant the clock alone moves it. + */ + +/** + * How long after its Start an agent with no turn row yet still reads as + * starting. eve took the session and its first turn lands in seconds; an + * agent still without one past this is read as failed, so nothing reads + * starting forever. + */ +export const STARTING_GRACE_MILLIS = 5 * 60 * 1000; + +/** + * How long a line of the developer's may stand with no turn to take it + * while none runs, and still read the agent as running. eve opens the turn + * on a line it took in seconds, as it lands the first turn of a Start + * (`STARTING_GRACE_MILLIS`, whose length this shares); a line still waiting + * past this is one whose turn is not coming. + */ +export const LINE_GRACE_MILLIS = 5 * 60 * 1000; + +/** The status a turn row's own status word reads as. */ +const STATUS_OF_TURN = { + [TURN_STATUS.QUEUED]: CODING_AGENT_STATUS.RUNNING, + [TURN_STATUS.RUNNING]: CODING_AGENT_STATUS.RUNNING, + [TURN_STATUS.SETTLED]: CODING_AGENT_STATUS.COMPLETED, + [TURN_STATUS.CANCELLED]: CODING_AGENT_STATUS.CANCELLED, + [TURN_STATUS.FAILED]: CODING_AGENT_STATUS.FAILED, +} as const satisfies Record; + +const STATUS_WORDS: ReadonlySet = new Set(Object.keys(STATUS_OF_TURN)); + +function isTurnStatusWord(status: string): status is keyof typeof STATUS_OF_TURN { + return STATUS_WORDS.has(status); +} + +/** What of an agent's newest turn its status is read from, each instant in epoch milliseconds. */ +export interface StatusTurn { + /** The turn's own status word. */ + readonly status: string; + /** Whether a Stop was asked of it. */ + readonly cancelRequested: boolean; + /** When it was queued. */ + readonly queuedAt: number; + /** When it settled; none while it is queued or runs. */ + readonly settledAt: number | null; + /** Whether it failed because its session ended for good. */ + readonly sessionEnded: boolean; +} + +/** When the agent was started and the instant it is read at, in epoch milliseconds, and when the newest line of the developer's still awaiting its turn was written, where one stands. */ +export interface StatusStanding { + readonly createdAt: number; + readonly now: number; + readonly awaitingLineAt?: number | undefined; + /** Whether the agent was started blank, handed neither the plan nor a prompt, so no first turn is on its way; unsaid reads as not. */ + readonly blank?: boolean | undefined; +} + +/** Where a line of the developer's with no turn stands: none, awaiting the turn it opens, or unanswered past the grace. */ +const LINE_STANDING = { + NONE: "none", + AWAITS: "awaits", + UNANSWERED: "unanswered", +} as const; + +type LineStanding = (typeof LINE_STANDING)[keyof typeof LINE_STANDING]; + +/** Where the newest awaiting line stands, and from when its grace is counted where the clock decides it. */ +interface LineReading { + readonly standing: LineStanding; + readonly since?: number; +} + +/** Whether a turn row still runs, or is queued to: what takes a line at its next step, however long the step it is on runs. */ +function turnRuns(turn: StatusTurn): boolean { + return ( + isTurnStatusWord(turn.status) && STATUS_OF_TURN[turn.status] === CODING_AGENT_STATUS.RUNNING + ); +} + +/** + * Where the newest line awaiting a turn stands, and from when its grace is + * counted where the clock decides it. A turn still running takes it, so it + * awaits. With none running, a line written before the newest turn was + * queued was that turn's opener, and the turn ended without the store + * hearing it received, so it awaits nothing; a line written once the turn + * was queued was a steer to it and still counts. Else it awaits for the + * grace, counted from when it was written or the newest turn ended, + * whichever came later, since that is how long it has stood with nothing + * running to take it, and is unanswered past it. + */ +function lineOf( + turn: StatusTurn | undefined, + standing: Pick, +): LineReading { + const line = standing.awaitingLineAt; + if (line === undefined) return { standing: LINE_STANDING.NONE }; + if (turn?.sessionEnded === true) return { standing: LINE_STANDING.NONE }; + if (turn !== undefined && turnRuns(turn)) return { standing: LINE_STANDING.AWAITS }; + if (turn !== undefined && line < turn.queuedAt) return { standing: LINE_STANDING.NONE }; + const since = Math.max(line, turn?.settledAt ?? 0); + return { + standing: + standing.now - since <= LINE_GRACE_MILLIS ? LINE_STANDING.AWAITS : LINE_STANDING.UNANSWERED, + since, + }; +} + +/** Whether a line of the developer's awaits the turn it opens, which keeps the agent at work: what an archive or a Stop reads the agent idle by. */ +export function lineAwaitsTurn( + turn: StatusTurn | undefined, + standing: Pick, +): boolean { + return lineOf(turn, standing).standing === LINE_STANDING.AWAITS; +} + +/** + * Where an agent stands, from its newest turn; running where a line awaits + * its turn, failed where one went unanswered past its grace, starting with + * no turn inside the grace, failed with none past it. An agent started blank + * with no turn yet has done all it was asked, which is nothing, so it reads + * as completed, however long ago it started: no first turn is on its way. + */ +export function codingAgentStatusAt( + turn: StatusTurn | undefined, + standing: StatusStanding, +): CodingAgentStatus { + const line = lineOf(turn, standing).standing; + if (line === LINE_STANDING.AWAITS) return CODING_AGENT_STATUS.RUNNING; + if (line === LINE_STANDING.UNANSWERED) return CODING_AGENT_STATUS.FAILED; + if (turn === undefined && standing.blank === true) return CODING_AGENT_STATUS.COMPLETED; + if (turn === undefined) { + return standing.now - standing.createdAt <= STARTING_GRACE_MILLIS + ? CODING_AGENT_STATUS.STARTING + : CODING_AGENT_STATUS.FAILED; + } + // A row outside the vocabulary is a write this build does not know, read as the one status that claims nothing. + if (!isTurnStatusWord(turn.status)) return CODING_AGENT_STATUS.FAILED; + const status = STATUS_OF_TURN[turn.status]; + return status === CODING_AGENT_STATUS.RUNNING && turn.cancelRequested + ? CODING_AGENT_STATUS.CANCELLED + : status; +} + +/** + * The next instant past which the clock alone moves the agent's status, in + * epoch milliseconds: a line's grace running out, from running to failed, + * or, with no turn and no line, the starting grace running out, from + * starting to failed. None where nothing but a row changing can move it: a + * turn runs, a line counts for nothing, or the grace is already past. + */ +export function statusClockEdge( + turn: StatusTurn | undefined, + standing: StatusStanding, +): number | undefined { + const line = lineOf(turn, standing); + if (line.standing === LINE_STANDING.AWAITS) { + return line.since === undefined ? undefined : line.since + LINE_GRACE_MILLIS + 1; + } + if (line.standing === LINE_STANDING.UNANSWERED || turn !== undefined) return undefined; + if (standing.blank === true) return undefined; + const edge = standing.createdAt + STARTING_GRACE_MILLIS; + return standing.now <= edge ? edge + 1 : undefined; +} diff --git a/packages/service/src/agents/coder-host/bounds.ts b/packages/service/src/agents/coder-host/bounds.ts index b3c6224b98..9ccc2b3679 100644 --- a/packages/service/src/agents/coder-host/bounds.ts +++ b/packages/service/src/agents/coder-host/bounds.ts @@ -1,3 +1,4 @@ +import { LINE_GRACE_MILLIS, STARTING_GRACE_MILLIS } from "@luke/features/coding-agent-status"; import { Duration } from "effect"; import { REPOSITORY_SANDBOX_CONTRACT } from "../repository-sandbox-contract.js"; @@ -84,7 +85,7 @@ export const CODER = { * agent still without one past this is read as failed, so nothing reads * starting forever and no transcript read is held for it. */ - STARTING_GRACE: Duration.minutes(5), + STARTING_GRACE: Duration.millis(STARTING_GRACE_MILLIS), /** * How long a line of the developer's may stand with no turn to take it * while none runs, and still read the agent as running. eve opens the @@ -95,7 +96,7 @@ export const CODER = { * written. It is read as failed rather than running forever, so the * developer is offered Continue on it. */ - LINE_GRACE: Duration.minutes(5), + LINE_GRACE: Duration.millis(LINE_GRACE_MILLIS), } as const; /** The environment the coding-agent host reads beside the names every hosted route already honours. */ diff --git a/packages/service/src/agents/coder-host/status.ts b/packages/service/src/agents/coder-host/status.ts index 994c02a5ed..59bde0471e 100644 --- a/packages/service/src/agents/coder-host/status.ts +++ b/packages/service/src/agents/coder-host/status.ts @@ -8,10 +8,15 @@ import { } from "@luke/database/models/coding-agent-store"; import type { StoredArchive, StoredAutomationOrigin } from "@luke/database/schemas/coding-agent"; import { AUTOMATION_FIRING } from "@luke/features/automation-wire"; +import { + codingAgentStatusAt, + type StatusTurn, + lineAwaitsTurn as sharedLineAwaitsTurn, + statusClockEdge as sharedStatusClockEdge, +} from "@luke/features/coding-agent-status"; import { CODING_AGENT_BRIEF, CODING_AGENT_FAILURE, - CODING_AGENT_STATUS, CONTEXT_WINDOW, type CodingAgentArchivedBy, type CodingAgentFailure, @@ -20,10 +25,9 @@ import { type CodingAgentStatus, type CodingAgentSummary, } from "@luke/features/coding-agent-wire"; -import { Duration } from "effect"; import { BRAIN_REQUEST_FAILURE, TURN_STATUS } from "../../core.js"; import { REPOSITORY_REFUSAL } from "../repository-shell.js"; -import { CODER, CODER_REFUSAL } from "./bounds.js"; +import { CODER_REFUSAL } from "./bounds.js"; /** * status.ts -- where a coding agent stands, read from its newest turn. @@ -35,14 +39,14 @@ import { CODER, CODER_REFUSAL } from "./bounds.js"; * ended after. A turn still running that carries a Stop stamp * reads as cancelled already, since the Stop is on its way to eve and * nothing the turn still does changes where it ends. An agent with no turn - * row long past its Start (`CODER.STARTING_GRACE`) reads as failed rather + * row long past its Start (`STARTING_GRACE_MILLIS`) reads as failed rather * than starting forever, as one that never started: eve took the session, * and its first turn lands in seconds or not at all. A line that awaits its * turn reads as running whatever the newest turn says, because the session * took the message and the turn it opens is on its way * (`@luke/database/models/message-reads`'s `awaitingLinesOf` is where a * line is counted as waiting), but only for so long: a line that has - * waited past `CODER.LINE_GRACE` with no turn running reads as failed, as + * waited past `LINE_GRACE_MILLIS` with no turn running reads as failed, as * one whose turn is not coming, so the developer is offered Continue on it * rather than watching it run forever. Failed rather than as the newest * turn says, because the line went unanswered, and a settled agent would @@ -58,21 +62,6 @@ import { CODER, CODER_REFUSAL } from "./bounds.js"; * line says Failed alone. Nothing of the detail itself leaves here. */ -/** The status a turn row's own status word reads as. */ -const STATUS_OF_TURN = { - [TURN_STATUS.QUEUED]: CODING_AGENT_STATUS.RUNNING, - [TURN_STATUS.RUNNING]: CODING_AGENT_STATUS.RUNNING, - [TURN_STATUS.SETTLED]: CODING_AGENT_STATUS.COMPLETED, - [TURN_STATUS.CANCELLED]: CODING_AGENT_STATUS.CANCELLED, - [TURN_STATUS.FAILED]: CODING_AGENT_STATUS.FAILED, -} as const satisfies Record; - -const STATUS_WORDS: ReadonlySet = new Set(Object.keys(STATUS_OF_TURN)); - -function isTurnStatusWord(status: string): status is keyof typeof STATUS_OF_TURN { - return STATUS_WORDS.has(status); -} - /** When the agent was started, when it is being read, which is what an agent with no turn is judged by, when the newest line of the developer's still awaiting its turn was written, and whether it was started blank. */ export interface AgentStanding { readonly createdAt: Date; @@ -90,74 +79,17 @@ export function startedBlank(agent: CodingAgent | Pick return brief === CODING_AGENT_BRIEF.BLANK; } -/** Whether an agent with no turn row is still inside the grace its first turn may land in. */ -function withinStartingGrace(standing: AgentStanding): boolean { - return standing.now - standing.createdAt.getTime() <= Duration.toMillis(CODER.STARTING_GRACE); -} - -/** Where a line of the developer's with no turn stands: none, awaiting the turn it opens, or unanswered past the grace. */ -const LINE_STANDING = { - NONE: "none", - AWAITS: "awaits", - UNANSWERED: "unanswered", -} as const; - -type LineStanding = (typeof LINE_STANDING)[keyof typeof LINE_STANDING]; - -/** Whether a turn row still runs, or is queued to: what takes a line at its next step, however long the step it is on runs. */ -function turnRuns(turn: CodingAgentLatestTurn): boolean { - return ( - isTurnStatusWord(turn.status) && STATUS_OF_TURN[turn.status] === CODING_AGENT_STATUS.RUNNING - ); -} - -/** - * Where the newest line awaiting a turn stands. A turn still running takes - * it, so it awaits. With none running, a line written before the newest - * turn was queued was that turn's opener, written under the lock before - * eve's first write of the turn (`steer.ts`), and the turn ended without the - * store hearing it received, so it awaits nothing; a line written once the - * turn was queued was a steer to it and still counts. Else it awaits for - * the grace, counted from when it was written or the newest turn ended, - * whichever came later, since that is how long it has stood with nothing - * running to take it, and is unanswered past it. A turn failed because its - * session ended for good leaves every line awaiting nothing, since the - * session that would have taken one is gone and no message reopens it. - */ -/** Where a line stands, and since when it has stood with nothing running to take it, where its grace counts. */ -interface LineRead { - readonly standing: LineStanding; - readonly since?: number; -} - -function lineOf( - turn: CodingAgentLatestTurn | undefined, - standing: Pick, -): LineRead { - const line = standing.awaitingLineAt?.getTime(); - if (line === undefined) return { standing: LINE_STANDING.NONE }; - if (turn?.failure === BRAIN_REQUEST_FAILURE.SESSION_ENDED) - return { standing: LINE_STANDING.NONE }; - if (turn !== undefined && turnRuns(turn)) return { standing: LINE_STANDING.AWAITS }; - if (turn !== undefined && line < turn.queuedAt.getTime()) { - return { standing: LINE_STANDING.NONE }; - } - const since = Math.max(line, turn?.settledAt?.getTime() ?? 0); - return { - standing: - standing.now - since <= Duration.toMillis(CODER.LINE_GRACE) - ? LINE_STANDING.AWAITS - : LINE_STANDING.UNANSWERED, - since, - }; -} - -/** Where the newest line awaiting a turn stands (`lineOf`). */ -function lineStandingOf( - turn: CodingAgentLatestTurn | undefined, - standing: Pick, -): LineStanding { - return lineOf(turn, standing).standing; +/** What of the newest turn the shared rule reads (`@luke/features/coding-agent-status`), each instant in epoch milliseconds. */ +function statusTurnOf(turn: CodingAgentLatestTurn | undefined): StatusTurn | undefined { + return turn === undefined + ? undefined + : { + status: turn.status, + cancelRequested: turn.cancelRequestedAt !== null, + queuedAt: turn.queuedAt.getTime(), + settledAt: turn.settledAt?.getTime() ?? null, + sessionEnded: turn.failure === BRAIN_REQUEST_FAILURE.SESSION_ENDED, + }; } /** Whether a line of the developer's awaits the turn it opens, which keeps the agent at work: what an archive or a Stop reads the agent idle by. */ @@ -165,7 +97,10 @@ export function lineAwaitsTurn( turn: CodingAgentLatestTurn | undefined, standing: Pick, ): boolean { - return lineStandingOf(turn, standing) === LINE_STANDING.AWAITS; + return sharedLineAwaitsTurn(statusTurnOf(turn), { + now: standing.now, + awaitingLineAt: standing.awaitingLineAt?.getTime(), + }); } /** @@ -179,21 +114,12 @@ export function codingAgentStatusOf( turn: CodingAgentLatestTurn | undefined, standing: AgentStanding, ): CodingAgentStatus { - const line = lineStandingOf(turn, standing); - if (line === LINE_STANDING.AWAITS) return CODING_AGENT_STATUS.RUNNING; - if (line === LINE_STANDING.UNANSWERED) return CODING_AGENT_STATUS.FAILED; - if (turn === undefined && standing.blank === true) return CODING_AGENT_STATUS.COMPLETED; - if (turn === undefined) { - return withinStartingGrace(standing) - ? CODING_AGENT_STATUS.STARTING - : CODING_AGENT_STATUS.FAILED; - } - // A row outside the vocabulary is a write this build does not know, read as the one status that claims nothing. - if (!isTurnStatusWord(turn.status)) return CODING_AGENT_STATUS.FAILED; - const status = STATUS_OF_TURN[turn.status]; - return status === CODING_AGENT_STATUS.RUNNING && turn.cancelRequestedAt !== null - ? CODING_AGENT_STATUS.CANCELLED - : status; + return codingAgentStatusAt(statusTurnOf(turn), { + createdAt: standing.createdAt.getTime(), + now: standing.now, + awaitingLineAt: standing.awaitingLineAt?.getTime(), + blank: standing.blank === true, + }); } /** @@ -206,16 +132,12 @@ export function statusClockEdge( turn: CodingAgentLatestTurn | undefined, standing: AgentStanding, ): number | undefined { - const line = lineOf(turn, standing); - if (line.standing === LINE_STANDING.AWAITS) { - return line.since === undefined - ? undefined - : line.since + Duration.toMillis(CODER.LINE_GRACE) + 1; - } - if (line.standing === LINE_STANDING.UNANSWERED || turn !== undefined) return undefined; - if (standing.blank === true) return undefined; - const edge = standing.createdAt.getTime() + Duration.toMillis(CODER.STARTING_GRACE); - return standing.now <= edge ? edge + 1 : undefined; + return sharedStatusClockEdge(statusTurnOf(turn), { + createdAt: standing.createdAt.getTime(), + now: standing.now, + awaitingLineAt: standing.awaitingLineAt?.getTime(), + blank: standing.blank === true, + }); } /**