Skip to content
Closed
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
21 changes: 20 additions & 1 deletion apps/hub/src/artifacts/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,21 @@ export function createArtifactService(
),
)
.orderBy(desc(workflowRun.endedAt));
// A run held on an approval is still mid-turn, so its activity reads as work; the pending
// record is what says it waits on the person.
const heldOnApproval = new Set(
(
await rawDB
.select({ runId: approval.anchorRunId })
.from(approval)
.where(
and(
eq(approval.tenantId, actor.tenantId),
eq(approval.status, "pending"),
),
)
).map((row) => row.runId),
);
const inFlight: InFlightRun[] = [];
const failed: FailedRun[] = [];
for (const run of specialists) {
Expand All @@ -694,7 +709,11 @@ export function createArtifactService(
role,
startedAt: run.startedAt.toISOString(),
status: run.status,
state: seen?.working ? "working" : "waiting",
state: heldOnApproval.has(run.runId)
? "approval"
: seen?.working
? "working"
: "waiting",
...(seen?.since ? { since: seen.since } : {}),
...(run.concept === null ? {} : { concept: run.concept }),
...(progress ? { progress } : {}),
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,8 @@ export const wording = {
/** What a hand-off works on when nothing names it yet. */
unnamed: "the task",
waiting: "Waiting for your go-ahead",
/** The run is held on a request only the person can approve, such as a package install. */
approval: "Paused for your approval",
/** The hand-off's approval ended without a yes, so nothing started. */
declined: {
rejected: "Not started — you said not yet",
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/pages/workspace/dispatch-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ export type DispatchStatus =
}
| { readonly state: "starting" }
| { readonly state: "working"; readonly since: string }
/** Mid-turn but held on an approval only the person can give. */
| { readonly state: "approval" }
| {
readonly state: "ready";
readonly title: string;
Expand Down Expand Up @@ -331,6 +333,7 @@ export function dispatchStatus(
return dispatch.runId === undefined
? { state: "starting" }
: { state: "finished" };
if (run.state === "approval") return { state: "approval" };
if (run.state === "working")
return { state: "working", since: run.since ?? run.startedAt };
// A parked run that filed its document is done with it, whenever this session first saw it.
Expand All @@ -340,11 +343,11 @@ export function dispatchStatus(
: { state: "finished" };
}

/** The hand-off's run is still starting or at work, so its work is current. */
/** The hand-off's run is still starting, at work or held on an approval, so its work is current. */
export function dispatchLive(dispatch: Dispatch, room: DispatchRoom): boolean {
noteDispatch(room, dispatch);
const { state } = dispatchStatus(dispatch, room);
return state === "starting" || state === "working";
return state === "starting" || state === "working" || state === "approval";
}

/** What a hand-off works on: "Constraints", "Quiet concept", "the app". */
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/pages/workspace/dispatch-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect, test } from "bun:test";
import {
dispatchLive,
dispatchStatus,
dispatchesOf,
type DispatchRoom,
} from "./dispatch-card.tsx";
import { workUnitMessage, workUnits } from "./work-unit.ts";

const room = (state: "working" | "waiting" | "approval"): DispatchRoom => ({
artifacts: [],
inFlight: [
{
runId: "run-coder",
role: "coder",
startedAt: "2026-11-02T01:41:25.000Z",
status: "running",
state,
since: "2026-11-02T01:41:30.000Z",
},
],
failed: [],
seen: new Map(),
approvals: [],
activity: [],
});

const [coder] = dispatchesOf(
{
kind: "tool-trace",
toolCallId: "call-coder",
name: "deploy_coder",
status: "output-available",
input: { instruction: "Build it." },
},
{ content: JSON.stringify({ runId: "run-coder" }), isError: false },
);
if (coder === undefined) throw new Error("no dispatch");

test("a run held on an approval reads as paused for the person, not as at work", () => {
expect(dispatchStatus(coder, room("working"))).toEqual({
state: "working",
since: "2026-11-02T01:41:30.000Z",
});
expect(dispatchStatus(coder, room("approval"))).toEqual({
state: "approval",
});
// Its work is still current: a document it builds from should not change under it.
expect(dispatchLive(coder, room("approval"))).toBe(true);
});

test("the hand-off's own step shows the approval wait instead of running", () => {
const [unit] = workUnits([{ at: 100, dispatch: coder }], [], []).units;
if (unit === undefined) throw new Error("no unit");
const message = workUnitMessage(unit, { state: "approval" });
const [step] = message.parts;
expect(step).toMatchObject({
kind: "tool-trace",
toolCallId: "call-coder",
status: "approval-requested",
});
// Nothing streams while the person is the one being waited on.
expect(message.status).toBe("sent");
});
3 changes: 3 additions & 0 deletions apps/web/src/pages/workspace/work-unit-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ function WorkLine({
</>
);
}
// Nothing moves until the person answers, so no shimmer and no clock running on it.
case "approval":
return text.approval;
case "ready":
return text.steps(text.done[dispatch.role](subject), steps);
case "finished":
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/pages/workspace/work-unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ const CALL_STATUS: Record<DispatchStatus["state"], ToolTraceStatus> = {
declined: "output-denied",
starting: "pending",
working: "running",
approval: "approval-requested",
ready: "output-available",
finished: "output-available",
failed: "error",
Expand Down
5 changes: 3 additions & 2 deletions packages/schemas/src/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,9 @@ export const InFlightRun = type({
startedAt: "string > 0",
// Interchange's run status: deployed until its first assignment arrives, then running.
status: "'deployed' | 'running'",
// Mid-turn, or parked between turns waiting for mail; a parked run is idle, not at work.
state: "'working' | 'waiting'",
// Mid-turn; parked between turns waiting for mail, which is idle, not at work; or mid-turn but
// held on an approval only the person can give, so nothing moves until they answer.
state: "'working' | 'waiting' | 'approval'",
// When the current state began, once the hub has seen the run start or end a turn.
"since?": "string > 0",
// The design concept a designer run works on, filed as that concept's design_option.
Expand Down