Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/features/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
121 changes: 121 additions & 0 deletions packages/features/src/coding-agent-status.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
194 changes: 194 additions & 0 deletions packages/features/src/coding-agent-status.ts
Original file line number Diff line number Diff line change
@@ -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<string, CodingAgentStatus>;

const STATUS_WORDS: ReadonlySet<string> = 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<StatusStanding, "now" | "awaitingLineAt">,
): 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<StatusStanding, "now" | "awaitingLineAt">,
): 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;
}
5 changes: 3 additions & 2 deletions packages/service/src/agents/coder-host/bounds.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -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. */
Expand Down
Loading
Loading