refactor: align agent lifecycle

This commit is contained in:
Peter Steinberger
2026-01-05 05:55:02 +01:00
parent ce5fd84432
commit a7d33c06f9
22 changed files with 332 additions and 208 deletions

View File

@@ -9,6 +9,7 @@
- TUI: migrate key handling to the updated pi-tui Key matcher API. - TUI: migrate key handling to the updated pi-tui Key matcher API.
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`. - macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
- macOS: Settings now use a sidebar layout to avoid toolbar overflow in Connections. - macOS: Settings now use a sidebar layout to avoid toolbar overflow in Connections.
- macOS: drop deprecated `afterMs` from agent wait params to match gateway schema.
### Maintenance ### Maintenance
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome. - Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.

View File

@@ -424,21 +424,17 @@ public struct AgentParams: Codable, Sendable {
public struct AgentWaitParams: Codable, Sendable { public struct AgentWaitParams: Codable, Sendable {
public let runid: String public let runid: String
public let afterms: Int?
public let timeoutms: Int? public let timeoutms: Int?
public init( public init(
runid: String, runid: String,
afterms: Int?,
timeoutms: Int? timeoutms: Int?
) { ) {
self.runid = runid self.runid = runid
self.afterms = afterms
self.timeoutms = timeoutms self.timeoutms = timeoutms
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case runid = "runId" case runid = "runId"
case afterms = "afterMs"
case timeoutms = "timeoutMs" case timeoutms = "timeoutMs"
} }
} }

61
docs/agent-loop.md Normal file
View File

@@ -0,0 +1,61 @@
---
summary: "Agent loop lifecycle, streams, and wait semantics"
read_when:
- You need an exact walkthrough of the agent loop or lifecycle events
---
# Agent Loop (Clawdis)
Short, exact flow of one agent run. Source of truth: current code in `src/`.
## Entry points
- Gateway RPC: `agent` and `agent.wait` in `src/gateway/server-methods/agent.ts`.
- CLI: `agentCommand` in `src/commands/agent.ts`.
## High-level flow
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
2) `agentCommand` runs the agent:
- resolves model + thinking/verbose defaults
- loads skills snapshot
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
- emits **lifecycle end/error** if the embedded loop does not emit one
3) `runEmbeddedPiAgent`:
- builds `AgentSession` and subscribes to pi events
- streams assistant deltas + tool events
- enforces timeout -> aborts run if exceeded
- returns payloads + usage metadata
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdis `agent` stream:
- tool events => `stream: "tool"`
- assistant deltas => `stream: "assistant"`
- lifecycle events => `stream: "lifecycle"` (`phase: "start" | "end" | "error"`)
5) `agent.wait` uses `waitForAgentJob`:
- waits for **lifecycle end/error** for `runId`
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
## Event streams (today)
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
- `assistant`: streamed deltas from pi-agent-core
- `tool`: streamed tool events from pi-agent-core
## Chat surface handling
- `createAgentEventHandler` in `src/gateway/server-chat.ts`:
- buffers assistant deltas
- emits chat `delta` messages
- emits chat `final` when **lifecycle end/error** arrives
## Timeouts
- `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides.
- Agent runtime: `agent.timeoutSeconds` default 600s; enforced in `runEmbeddedPiAgent` abort timer.
## Where things can end early
- Agent timeout (abort)
- AbortSignal (cancel)
- Gateway disconnect or RPC timeout
- `agent.wait` timeout (wait-only, does not stop agent)
## Files
- `src/gateway/server-methods/agent.ts`
- `src/gateway/server-methods/agent-job.ts`
- `src/commands/agent.ts`
- `src/agents/pi-embedded-runner.ts`
- `src/agents/pi-embedded-subscribe.ts`
- `src/gateway/server-chat.ts`

View File

@@ -0,0 +1,65 @@
---
summary: "Refactor plan: unify agent lifecycle events and wait semantics"
read_when:
- Refactoring agent lifecycle events or wait behavior
---
# Refactor: Agent Loop
Goal: align Clawdis run lifecycle with pi/mom semantics, remove ambiguity between "job" and "agent_end".
## Problem
- Two lifecycles today:
- `job` (gateway wrapper) => used by `agent.wait` + chat final
- pi-agent `agent_end` (inner loop) => only logged
- This can finalize early (job done) while late assistant deltas still arrive.
- `afterMs` and timeouts can cause false timeouts in `agent.wait`.
## Reference (mom)
- Single lifecycle: `agent_start`/`agent_end` from pi-agent-core event stream.
- `waitForIdle()` resolves on `agent_end`.
- No separate job state exposed to clients.
## Proposed refactor (breaking allowed)
1) Replace public `job` stream with `lifecycle` stream
- `stream: "lifecycle"`
- `data: { phase: "start" | "end" | "error", startedAt, endedAt, error? }`
2) `agent.wait` waits on lifecycle end/error only
- remove `afterMs`
- return `{ runId, status, startedAt, endedAt, error? }`
3) Chat final emitted on lifecycle end only
- deltas still from `assistant` stream
4) Centralize run registry
- one map keyed by runId: sessionKey, startedAt, lastSeq, bufferedText
- clear on lifecycle end
## Implementation outline
- `src/agents/pi-embedded-subscribe.ts`
- emit lifecycle start/end events (translate pi `agent_start`/`agent_end`)
- `src/infra/agent-events.ts`
- add `"lifecycle"` to stream type
- `src/gateway/protocol/schema.ts`
- update AgentEvent schema; update AgentWait params (remove afterMs, add status)
- `src/gateway/server-methods/agent-job.ts`
- rename to `agent-wait.ts` or similar; wait on lifecycle end/error
- `src/gateway/server-chat.ts`
- finalize on lifecycle end (not job)
- `src/commands/agent.ts`
- stop emitting `job` externally (keep internal log if needed)
## Migration notes (breaking)
- Update all callers of `agent.wait` to new response shape.
- Update tests that expect `timeout` based on job events.
- If any UI relies on job state, map lifecycle instead.
## Risks
- If lifecycle events are dropped, wait/chat could hang; add timeout in `agent.wait` to fail fast.
- Late deltas after lifecycle end should be ignored; keep seq tracking + drop.
## Acceptance
- One lifecycle visible to clients.
- `agent.wait` resolves when agent loop ends, not wrapper completion.
- Chat final never emits before last assistant delta.
## Rollout (if we wanted safety)
- Gate with config flag `agent.lifecycleMode = "legacy"|"refactor"`.
- Remove legacy after one release.

View File

@@ -261,12 +261,6 @@ describe("sessions tools", () => {
).toBe(true); ).toBe(true);
expect(waitCalls).toHaveLength(8); expect(waitCalls).toHaveLength(8);
expect(historyOnlyCalls).toHaveLength(8); expect(historyOnlyCalls).toHaveLength(8);
expect(
waitCalls.some(
(call) =>
typeof (call.params as { afterMs?: number })?.afterMs === "number",
),
).toBe(true);
expect(sendCallCount).toBe(0); expect(sendCallCount).toBe(0);
}); });

View File

@@ -616,6 +616,18 @@ export function subscribeEmbeddedPiSession(params: {
if (evt.type === "agent_start") { if (evt.type === "agent_start") {
log.debug(`embedded run agent start: runId=${params.runId}`); log.debug(`embedded run agent start: runId=${params.runId}`);
emitAgentEvent({
runId: params.runId,
stream: "lifecycle",
data: {
phase: "start",
startedAt: Date.now(),
},
});
params.onAgentEvent?.({
stream: "lifecycle",
data: { phase: "start" },
});
} }
if (evt.type === "auto_compaction_start") { if (evt.type === "auto_compaction_start") {
@@ -638,6 +650,18 @@ export function subscribeEmbeddedPiSession(params: {
if (evt.type === "agent_end") { if (evt.type === "agent_end") {
log.debug(`embedded run agent end: runId=${params.runId}`); log.debug(`embedded run agent end: runId=${params.runId}`);
emitAgentEvent({
runId: params.runId,
stream: "lifecycle",
data: {
phase: "end",
endedAt: Date.now(),
},
});
params.onAgentEvent?.({
stream: "lifecycle",
data: { phase: "end" },
});
if (pendingCompactionRetry > 0) { if (pendingCompactionRetry > 0) {
resolveCompactionRetry(); resolveCompactionRetry();
} else { } else {

View File

@@ -151,16 +151,11 @@ export function createSessionsSendTool(opts?: {
typeof response?.runId === "string" && response.runId typeof response?.runId === "string" && response.runId
? response.runId ? response.runId
: stepIdem; : stepIdem;
const stepAcceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
const stepWaitMs = Math.min(step.timeoutMs, 60_000); const stepWaitMs = Math.min(step.timeoutMs, 60_000);
const wait = (await callGateway({ const wait = (await callGateway({
method: "agent.wait", method: "agent.wait",
params: { params: {
runId: stepRunId, runId: stepRunId,
afterMs: stepAcceptedAt,
timeoutMs: stepWaitMs, timeoutMs: stepWaitMs,
}, },
timeoutMs: stepWaitMs + 2000, timeoutMs: stepWaitMs + 2000,
@@ -171,7 +166,7 @@ export function createSessionsSendTool(opts?: {
const runAgentToAgentFlow = async ( const runAgentToAgentFlow = async (
roundOneReply?: string, roundOneReply?: string,
runInfo?: { runId: string; acceptedAt?: number }, runInfo?: { runId: string },
) => { ) => {
try { try {
let primaryReply = roundOneReply; let primaryReply = roundOneReply;
@@ -182,7 +177,6 @@ export function createSessionsSendTool(opts?: {
method: "agent.wait", method: "agent.wait",
params: { params: {
runId: runInfo.runId, runId: runInfo.runId,
afterMs: runInfo.acceptedAt,
timeoutMs: waitMs, timeoutMs: waitMs,
}, },
timeoutMs: waitMs + 2000, timeoutMs: waitMs + 2000,
@@ -277,14 +271,10 @@ export function createSessionsSendTool(opts?: {
params: sendParams, params: sendParams,
timeoutMs: 10_000, timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number }; })) as { runId?: string; acceptedAt?: number };
const acceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
if (typeof response?.runId === "string" && response.runId) { if (typeof response?.runId === "string" && response.runId) {
runId = response.runId; runId = response.runId;
} }
void runAgentToAgentFlow(undefined, { runId, acceptedAt }); void runAgentToAgentFlow(undefined, { runId });
return jsonResult({ return jsonResult({
runId, runId,
status: "accepted", status: "accepted",
@@ -306,7 +296,6 @@ export function createSessionsSendTool(opts?: {
} }
} }
let acceptedAt: number | undefined;
try { try {
const response = (await callGateway({ const response = (await callGateway({
method: "agent", method: "agent",
@@ -316,9 +305,6 @@ export function createSessionsSendTool(opts?: {
if (typeof response?.runId === "string" && response.runId) { if (typeof response?.runId === "string" && response.runId) {
runId = response.runId; runId = response.runId;
} }
if (typeof response?.acceptedAt === "number") {
acceptedAt = response.acceptedAt;
}
} catch (err) { } catch (err) {
const messageText = const messageText =
err instanceof Error err instanceof Error
@@ -341,7 +327,6 @@ export function createSessionsSendTool(opts?: {
method: "agent.wait", method: "agent.wait",
params: { params: {
runId, runId,
afterMs: acceptedAt,
timeoutMs, timeoutMs,
}, },
timeoutMs: timeoutMs + 2000, timeoutMs: timeoutMs + 2000,

View File

@@ -1,3 +1 @@
68f18193053997f3dee16de6b0be0bcd97dc70ff8200c77f687479e8b19b78e1 971128267a38be786e60f2e900770da48ea305c8cebe6c53ab56e6ea86d924df
||||||| Stash base
7daf1cbf58ef395b74c2690c439ac7b3cb536e8eb124baf72ad41da4f542204d

View File

@@ -352,17 +352,7 @@ export async function agentCommand(
const sessionFile = resolveSessionTranscriptPath(sessionId); const sessionFile = resolveSessionTranscriptPath(sessionId);
const startedAt = Date.now(); const startedAt = Date.now();
emitAgentEvent({ let lifecycleEnded = false;
runId,
stream: "job",
data: {
state: "started",
startedAt,
to: opts.to ?? null,
sessionId,
isNewSession,
},
});
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>; let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = provider; let fallbackProvider = provider;
@@ -399,6 +389,13 @@ export async function agentCommand(
abortSignal: opts.abortSignal, abortSignal: opts.abortSignal,
extraSystemPrompt: opts.extraSystemPrompt, extraSystemPrompt: opts.extraSystemPrompt,
onAgentEvent: (evt) => { onAgentEvent: (evt) => {
if (
evt.stream === "lifecycle" &&
typeof evt.data?.phase === "string" &&
(evt.data.phase === "end" || evt.data.phase === "error")
) {
lifecycleEnded = true;
}
emitAgentEvent({ emitAgentEvent({
runId, runId,
stream: evt.stream, stream: evt.stream,
@@ -410,33 +407,31 @@ export async function agentCommand(
result = fallbackResult.result; result = fallbackResult.result;
fallbackProvider = fallbackResult.provider; fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model; fallbackModel = fallbackResult.model;
if (!lifecycleEnded) {
emitAgentEvent({ emitAgentEvent({
runId, runId,
stream: "job", stream: "lifecycle",
data: { data: {
state: "done", phase: "end",
startedAt, startedAt,
endedAt: Date.now(), endedAt: Date.now(),
to: opts.to ?? null,
sessionId,
durationMs: Date.now() - startedAt,
aborted: result.meta.aborted ?? false, aborted: result.meta.aborted ?? false,
}, },
}); });
}
} catch (err) { } catch (err) {
if (!lifecycleEnded) {
emitAgentEvent({ emitAgentEvent({
runId, runId,
stream: "job", stream: "lifecycle",
data: { data: {
state: "error", phase: "error",
startedAt, startedAt,
endedAt: Date.now(), endedAt: Date.now(),
to: opts.to ?? null,
sessionId,
durationMs: Date.now() - startedAt,
error: String(err), error: String(err),
}, },
}); });
}
throw err; throw err;
} }

View File

@@ -218,7 +218,6 @@ export const AgentParamsSchema = Type.Object(
export const AgentWaitParamsSchema = Type.Object( export const AgentWaitParamsSchema = Type.Object(
{ {
runId: NonEmptyString, runId: NonEmptyString,
afterMs: Type.Optional(Type.Integer({ minimum: 0 })),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
}, },
{ additionalProperties: false }, { additionalProperties: false },

View File

@@ -208,9 +208,9 @@ export function createAgentEventHandler({
agentRunSeq.set(evt.runId, evt.seq); agentRunSeq.set(evt.runId, evt.seq);
broadcast("agent", agentPayload); broadcast("agent", agentPayload);
const jobState = const lifecyclePhase =
evt.stream === "job" && typeof evt.data?.state === "string" evt.stream === "lifecycle" && typeof evt.data?.phase === "string"
? evt.data.state ? evt.data.phase
: null; : null;
if (sessionKey) { if (sessionKey) {
@@ -218,7 +218,7 @@ export function createAgentEventHandler({
if (evt.stream === "assistant" && typeof evt.data?.text === "string") { if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
const clientRunId = chatLink?.clientRunId ?? evt.runId; const clientRunId = chatLink?.clientRunId ?? evt.runId;
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
} else if (jobState === "done" || jobState === "error") { } else if (lifecyclePhase === "end" || lifecyclePhase === "error") {
if (chatLink) { if (chatLink) {
const finished = chatRunState.registry.shift(evt.runId); const finished = chatRunState.registry.shift(evt.runId);
if (!finished) { if (!finished) {
@@ -229,7 +229,7 @@ export function createAgentEventHandler({
finished.sessionKey, finished.sessionKey,
finished.clientRunId, finished.clientRunId,
evt.seq, evt.seq,
jobState, lifecyclePhase === "error" ? "error" : "done",
evt.data?.error, evt.data?.error,
); );
} else { } else {
@@ -237,14 +237,14 @@ export function createAgentEventHandler({
sessionKey, sessionKey,
evt.runId, evt.runId,
evt.seq, evt.seq,
jobState, lifecyclePhase === "error" ? "error" : "done",
evt.data?.error, evt.data?.error,
); );
} }
} }
} }
if (jobState === "done" || jobState === "error") { if (lifecyclePhase === "end" || lifecyclePhase === "error") {
clearAgentRunContext(evt.runId); clearAgentRunContext(evt.runId);
} }
}; };

View File

@@ -1,50 +1,48 @@
import { onAgentEvent } from "../../infra/agent-events.js"; import { onAgentEvent } from "../../infra/agent-events.js";
const AGENT_JOB_CACHE_TTL_MS = 10 * 60_000; const AGENT_RUN_CACHE_TTL_MS = 10 * 60_000;
const agentJobCache = new Map<string, AgentJobSnapshot>(); const agentRunCache = new Map<string, AgentRunSnapshot>();
const agentRunStarts = new Map<string, number>(); const agentRunStarts = new Map<string, number>();
let agentJobListenerStarted = false; let agentRunListenerStarted = false;
type AgentJobSnapshot = { type AgentRunSnapshot = {
runId: string; runId: string;
state: "done" | "error"; status: "ok" | "error";
startedAt?: number; startedAt?: number;
endedAt?: number; endedAt?: number;
error?: string; error?: string;
ts: number; ts: number;
}; };
function pruneAgentJobCache(now = Date.now()) { function pruneAgentRunCache(now = Date.now()) {
for (const [runId, entry] of agentJobCache) { for (const [runId, entry] of agentRunCache) {
if (now - entry.ts > AGENT_JOB_CACHE_TTL_MS) { if (now - entry.ts > AGENT_RUN_CACHE_TTL_MS) {
agentJobCache.delete(runId); agentRunCache.delete(runId);
} }
} }
} }
function recordAgentJobSnapshot(entry: AgentJobSnapshot) { function recordAgentRunSnapshot(entry: AgentRunSnapshot) {
pruneAgentJobCache(entry.ts); pruneAgentRunCache(entry.ts);
agentJobCache.set(entry.runId, entry); agentRunCache.set(entry.runId, entry);
} }
function ensureAgentJobListener() { function ensureAgentRunListener() {
if (agentJobListenerStarted) return; if (agentRunListenerStarted) return;
agentJobListenerStarted = true; agentRunListenerStarted = true;
onAgentEvent((evt) => { onAgentEvent((evt) => {
if (!evt) return; if (!evt) return;
if (evt.stream !== "job") return; if (evt.stream !== "lifecycle") return;
const state = evt.data?.state; const phase = evt.data?.phase;
if (state === "started") { if (phase === "start") {
const startedAt = const startedAt =
typeof evt.data?.startedAt === "number" typeof evt.data?.startedAt === "number"
? (evt.data.startedAt as number) ? (evt.data.startedAt as number)
: undefined; : undefined;
if (startedAt !== undefined) { agentRunStarts.set(evt.runId, startedAt ?? Date.now());
agentRunStarts.set(evt.runId, startedAt);
}
return; return;
} }
if (state !== "done" && state !== "error") return; if (phase !== "end" && phase !== "error") return;
const startedAt = const startedAt =
typeof evt.data?.startedAt === "number" typeof evt.data?.startedAt === "number"
? (evt.data.startedAt as number) ? (evt.data.startedAt as number)
@@ -58,9 +56,9 @@ function ensureAgentJobListener() {
? (evt.data.error as string) ? (evt.data.error as string)
: undefined; : undefined;
agentRunStarts.delete(evt.runId); agentRunStarts.delete(evt.runId);
recordAgentJobSnapshot({ recordAgentRunSnapshot({
runId: evt.runId, runId: evt.runId,
state: state === "error" ? "error" : "done", status: phase === "error" ? "error" : "ok",
startedAt, startedAt,
endedAt, endedAt,
error, error,
@@ -69,34 +67,24 @@ function ensureAgentJobListener() {
}); });
} }
function matchesAfterMs(entry: AgentJobSnapshot, afterMs?: number) { function getCachedAgentRun(runId: string) {
if (afterMs === undefined) return true; pruneAgentRunCache();
if (typeof entry.startedAt === "number") return entry.startedAt >= afterMs; return agentRunCache.get(runId);
if (typeof entry.endedAt === "number") return entry.endedAt >= afterMs;
return false;
}
function getCachedAgentJob(runId: string, afterMs?: number) {
pruneAgentJobCache();
const cached = agentJobCache.get(runId);
if (!cached) return undefined;
return matchesAfterMs(cached, afterMs) ? cached : undefined;
} }
export async function waitForAgentJob(params: { export async function waitForAgentJob(params: {
runId: string; runId: string;
afterMs?: number;
timeoutMs: number; timeoutMs: number;
}): Promise<AgentJobSnapshot | null> { }): Promise<AgentRunSnapshot | null> {
const { runId, afterMs, timeoutMs } = params; const { runId, timeoutMs } = params;
ensureAgentJobListener(); ensureAgentRunListener();
const cached = getCachedAgentJob(runId, afterMs); const cached = getCachedAgentRun(runId);
if (cached) return cached; if (cached) return cached;
if (timeoutMs <= 0) return null; if (timeoutMs <= 0) return null;
return await new Promise((resolve) => { return await new Promise((resolve) => {
let settled = false; let settled = false;
const finish = (entry: AgentJobSnapshot | null) => { const finish = (entry: AgentRunSnapshot | null) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
clearTimeout(timer); clearTimeout(timer);
@@ -104,10 +92,15 @@ export async function waitForAgentJob(params: {
resolve(entry); resolve(entry);
}; };
const unsubscribe = onAgentEvent((evt) => { const unsubscribe = onAgentEvent((evt) => {
if (!evt || evt.stream !== "job") return; if (!evt || evt.stream !== "lifecycle") return;
if (evt.runId !== runId) return; if (evt.runId !== runId) return;
const state = evt.data?.state; const phase = evt.data?.phase;
if (state !== "done" && state !== "error") return; if (phase !== "end" && phase !== "error") return;
const cached = getCachedAgentRun(runId);
if (cached) {
finish(cached);
return;
}
const startedAt = const startedAt =
typeof evt.data?.startedAt === "number" typeof evt.data?.startedAt === "number"
? (evt.data.startedAt as number) ? (evt.data.startedAt as number)
@@ -120,20 +113,19 @@ export async function waitForAgentJob(params: {
typeof evt.data?.error === "string" typeof evt.data?.error === "string"
? (evt.data.error as string) ? (evt.data.error as string)
: undefined; : undefined;
const snapshot: AgentJobSnapshot = { const snapshot: AgentRunSnapshot = {
runId: evt.runId, runId: evt.runId,
state: state === "error" ? "error" : "done", status: phase === "error" ? "error" : "ok",
startedAt, startedAt,
endedAt, endedAt,
error, error,
ts: Date.now(), ts: Date.now(),
}; };
recordAgentJobSnapshot(snapshot); recordAgentRunSnapshot(snapshot);
if (!matchesAfterMs(snapshot, afterMs)) return;
finish(snapshot); finish(snapshot);
}); });
const timer = setTimeout(() => finish(null), Math.max(1, timeoutMs)); const timer = setTimeout(() => finish(null), Math.max(1, timeoutMs));
}); });
} }
ensureAgentJobListener(); ensureAgentRunListener();

View File

@@ -288,10 +288,6 @@ export const agentHandlers: GatewayRequestHandlers = {
} }
const p = params as AgentWaitParams; const p = params as AgentWaitParams;
const runId = p.runId.trim(); const runId = p.runId.trim();
const afterMs =
typeof p.afterMs === "number" && Number.isFinite(p.afterMs)
? Math.max(0, Math.floor(p.afterMs))
: undefined;
const timeoutMs = const timeoutMs =
typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs) typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs)
? Math.max(0, Math.floor(p.timeoutMs)) ? Math.max(0, Math.floor(p.timeoutMs))
@@ -299,7 +295,6 @@ export const agentHandlers: GatewayRequestHandlers = {
const snapshot = await waitForAgentJob({ const snapshot = await waitForAgentJob({
runId, runId,
afterMs,
timeoutMs, timeoutMs,
}); });
if (!snapshot) { if (!snapshot) {
@@ -311,7 +306,7 @@ export const agentHandlers: GatewayRequestHandlers = {
} }
respond(true, { respond(true, {
runId, runId,
status: snapshot.state === "done" ? "ok" : "error", status: snapshot.status,
startedAt: snapshot.startedAt, startedAt: snapshot.startedAt,
endedAt: snapshot.endedAt, endedAt: snapshot.endedAt,
error: snapshot.error, error: snapshot.error,

View File

@@ -463,8 +463,8 @@ describe("gateway server agent", () => {
}); });
emitAgentEvent({ emitAgentEvent({
runId: "run-auto-1", runId: "run-auto-1",
stream: "job", stream: "lifecycle",
data: { state: "done" }, data: { phase: "end" },
}); });
const evt = await finalChatP; const evt = await finalChatP;
@@ -518,21 +518,20 @@ describe("gateway server agent", () => {
await server.close(); await server.close();
}); });
test("agent.wait resolves after job completes", async () => { test("agent.wait resolves after lifecycle end", async () => {
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws); await connectOk(ws);
const waitP = rpcReq(ws, "agent.wait", { const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-1", runId: "run-wait-1",
afterMs: 100,
timeoutMs: 1000, timeoutMs: 1000,
}); });
setTimeout(() => { setTimeout(() => {
emitAgentEvent({ emitAgentEvent({
runId: "run-wait-1", runId: "run-wait-1",
stream: "job", stream: "lifecycle",
data: { state: "done", startedAt: 200, endedAt: 210 }, data: { phase: "end", startedAt: 200, endedAt: 210 },
}); });
}, 10); }, 10);
@@ -545,14 +544,14 @@ describe("gateway server agent", () => {
await server.close(); await server.close();
}); });
test("agent.wait resolves when job completed before wait call", async () => { test("agent.wait resolves when lifecycle ended before wait call", async () => {
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws); await connectOk(ws);
emitAgentEvent({ emitAgentEvent({
runId: "run-wait-early", runId: "run-wait-early",
stream: "job", stream: "lifecycle",
data: { state: "done", startedAt: 50, endedAt: 55 }, data: { phase: "end", startedAt: 50, endedAt: 55 },
}); });
const res = await rpcReq(ws, "agent.wait", { const res = await rpcReq(ws, "agent.wait", {
@@ -567,41 +566,7 @@ describe("gateway server agent", () => {
await server.close(); await server.close();
}); });
test("agent.wait ignores jobs before afterMs", async () => { test("agent.wait times out when no lifecycle ends", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-2",
afterMs: 500,
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-2",
stream: "job",
data: { state: "done", startedAt: 200, endedAt: 220 },
});
}, 10);
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-2",
stream: "job",
data: { state: "done", startedAt: 700, endedAt: 710 },
});
}, 20);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(700);
ws.close();
await server.close();
});
test("agent.wait times out when no job completes", async () => {
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws); await connectOk(ws);
@@ -615,4 +580,63 @@ describe("gateway server agent", () => {
ws.close(); ws.close();
await server.close(); await server.close();
}); });
test("agent.wait returns error on lifecycle error", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-err",
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-err",
stream: "lifecycle",
data: { phase: "error", error: "boom" },
});
}, 10);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("error");
expect(res.payload.error).toBe("boom");
ws.close();
await server.close();
});
test("agent.wait uses lifecycle start timestamp when end omits it", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-start",
timeoutMs: 1000,
});
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "start", startedAt: 123 },
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "end", endedAt: 456 },
});
}, 10);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(123);
expect(res.payload.endedAt).toBe(456);
ws.close();
await server.close();
});
}); });

View File

@@ -831,8 +831,8 @@ describe("gateway server chat", () => {
emitAgentEvent({ emitAgentEvent({
runId: "sess-main", runId: "sess-main",
stream: "job", stream: "lifecycle",
data: { state: "done" }, data: { phase: "end" },
}); });
const final1 = await final1P; const final1 = await final1P;
@@ -853,8 +853,8 @@ describe("gateway server chat", () => {
emitAgentEvent({ emitAgentEvent({
runId: "sess-main", runId: "sess-main",
stream: "job", stream: "lifecycle",
data: { state: "done" }, data: { phase: "end" },
}); });
const final2 = await final2P; const final2 = await final2P;

View File

@@ -173,9 +173,9 @@ describe("gateway server health/presence", () => {
o.type === "event" && o.type === "event" &&
o.event === "agent" && o.event === "agent" &&
o.payload?.runId === runId && o.payload?.runId === runId &&
o.payload?.stream === "job", o.payload?.stream === "lifecycle",
); );
emitAgentEvent({ runId, stream: "job", data: { msg: "hi" } }); emitAgentEvent({ runId, stream: "lifecycle", data: { msg: "hi" } });
const evt = await evtPromise; const evt = await evtPromise;
expect(evt.payload.runId).toBe(runId); expect(evt.payload.runId).toBe(runId);
expect(typeof evt.seq).toBe("number"); expect(typeof evt.seq).toBe("number");

View File

@@ -699,8 +699,8 @@ describe("gateway server node/bridge", () => {
runId: "sess-main", runId: "sess-main",
seq: 2, seq: 2,
ts: Date.now(), ts: Date.now(),
stream: "job", stream: "lifecycle",
data: { state: "done" }, data: { phase: "end" },
}); });
await new Promise((r) => setTimeout(r, 25)); await new Promise((r) => setTimeout(r, 25));
@@ -841,8 +841,8 @@ describe("gateway server node/bridge", () => {
runId: "sess-main", runId: "sess-main",
seq: 2, seq: 2,
ts: Date.now(), ts: Date.now(),
stream: "job", stream: "lifecycle",
data: { state: "done" }, data: { phase: "end" },
}); });
const evt = await finalChatP; const evt = await finalChatP;

View File

@@ -14,7 +14,7 @@ import {
installGatewayTestHooks(); installGatewayTestHooks();
describe("sessions_send gateway loopback", () => { describe("sessions_send gateway loopback", () => {
it("returns reply when job finishes before agent.wait", async () => { it("returns reply when lifecycle ends before agent.wait", async () => {
const port = await getFreePort(); const port = await getFreePort();
const prevPort = process.env.CLAWDBOT_GATEWAY_PORT; const prevPort = process.env.CLAWDBOT_GATEWAY_PORT;
process.env.CLAWDBOT_GATEWAY_PORT = String(port); process.env.CLAWDBOT_GATEWAY_PORT = String(port);
@@ -35,8 +35,8 @@ describe("sessions_send gateway loopback", () => {
const startedAt = Date.now(); const startedAt = Date.now();
emitAgentEvent({ emitAgentEvent({
runId, runId,
stream: "job", stream: "lifecycle",
data: { state: "started", startedAt, sessionId }, data: { phase: "start", startedAt },
}); });
let text = "pong"; let text = "pong";
@@ -60,12 +60,11 @@ describe("sessions_send gateway loopback", () => {
emitAgentEvent({ emitAgentEvent({
runId, runId,
stream: "job", stream: "lifecycle",
data: { data: {
state: "done", phase: "end",
startedAt, startedAt,
endedAt: Date.now(), endedAt: Date.now(),
sessionId,
}, },
}); });
}); });

View File

@@ -121,13 +121,9 @@ export function summarizeAgentEventForWsLog(
return extra; return extra;
} }
if (stream === "job") { if (stream === "lifecycle") {
const state = typeof data.state === "string" ? data.state : undefined; const phase = typeof data.phase === "string" ? data.phase : undefined;
if (state) extra.state = state; if (phase) extra.phase = phase;
if (data.to === null) extra.to = null;
else if (typeof data.to === "string") extra.to = data.to;
if (typeof data.durationMs === "number")
extra.ms = Math.round(data.durationMs);
if (typeof data.aborted === "boolean") extra.aborted = data.aborted; if (typeof data.aborted === "boolean") extra.aborted = data.aborted;
const error = typeof data.error === "string" ? data.error : undefined; const error = typeof data.error === "string" ? data.error : undefined;
if (error?.trim()) extra.error = compactPreview(error, 120); if (error?.trim()) extra.error = compactPreview(error, 120);

View File

@@ -25,10 +25,10 @@ describe("agent-events sequencing", () => {
list.push(evt.seq); list.push(evt.seq);
}); });
emitAgentEvent({ runId: "run-1", stream: "job", data: {} }); emitAgentEvent({ runId: "run-1", stream: "lifecycle", data: {} });
emitAgentEvent({ runId: "run-1", stream: "job", data: {} }); emitAgentEvent({ runId: "run-1", stream: "lifecycle", data: {} });
emitAgentEvent({ runId: "run-2", stream: "job", data: {} }); emitAgentEvent({ runId: "run-2", stream: "lifecycle", data: {} });
emitAgentEvent({ runId: "run-1", stream: "job", data: {} }); emitAgentEvent({ runId: "run-1", stream: "lifecycle", data: {} });
stop(); stop();

View File

@@ -1,5 +1,5 @@
export type AgentEventStream = export type AgentEventStream =
| "job" | "lifecycle"
| "tool" | "tool"
| "assistant" | "assistant"
| "error" | "error"

View File

@@ -375,11 +375,11 @@ export async function runTui(opts: TuiOptions) {
tui.requestRender(); tui.requestRender();
return; return;
} }
if (evt.stream === "job") { if (evt.stream === "lifecycle") {
const state = typeof evt.data?.state === "string" ? evt.data.state : ""; const phase = typeof evt.data?.phase === "string" ? evt.data.phase : "";
if (state === "started") setStatus("running"); if (phase === "start") setStatus("running");
if (state === "done") setStatus("idle"); if (phase === "end") setStatus("idle");
if (state === "error") setStatus("error"); if (phase === "error") setStatus("error");
tui.requestRender(); tui.requestRender();
} }
}; };