Skip to content

Commit ac43ef4

Browse files
willwashburnclaude
andauthored
feat(harness-driver): add lifecycle-aware SpawnedAgentHandle (#1050)
* ci(publish): publish @agent-relay/harnesses on release @agent-relay/harnesses was set up as a public package (no private flag, publishConfig.access public, versioned in lockstep) but was never wired into the publish workflow, so it never reached npm. External SDK consumers (e.g. relayflows) need it for the prebuilt PTY harnesses and the definePtyHarness/createHuman author helpers. Add a publish-harnesses job to the package=all path. It runs after publish-packages — where its exact-version workspace deps (@agent-relay/sdk, @agent-relay/harness-driver) land on the registry — so an external `npm install @agent-relay/harnesses@<v>` can always resolve its dependencies. Mirrors the existing provenance + skip-if-exists publish pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(publish): gate release on publish-harnesses; trim changelog Address PR review: publish-harnesses ran outside the release gate, so a tag/release could be cut even if harness publishing failed. - create-release now needs publish-harnesses and its `if` requires the job to not have failed. It tolerates `skipped` so package=main releases (where publish-harnesses does not run) are not blocked. - summary job lists the harness publish result. - Trim the changelog bullet to impact-first per the repo changelog rule. Leaving the new job's actions on @v4 tags to match the rest of the workflow (the repo uses tag refs throughout; SHA-pinning would be inconsistent and is not the enforced policy here). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(harness-driver): add lifecycle-aware SpawnedAgentHandle `spawnPty`/`spawnCli`/`spawnHeadless` now return a `SpawnedAgentHandle` instead of a bare `SpawnAgentResult`. The handle is a structural superset (still carries name/runtime/sessionId/pid) so existing callers are unaffected, and it adds the promise-based lifecycle operations consumers previously had to reconstruct from the raw broker event stream: - waitForExit() -> { reason, code, signal } - waitForIdle() -> { reason, idleSecs } - exit / exitCode / exitSignal (synchronous view of a prior exit) - release() All operations are backed by the client's broker event stream and its event history, so they are replay-correct: awaiting after the agent has already exited resolves immediately from history rather than hanging. Typechecks clean; harness-driver tests pass; dependents (harnesses, sdk) unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(harness-driver): source waitForIdle from broker event stream The client `eventBus` only carries call-site hook events; broker events like `agentIdle` are never emitted onto it in direct-client usage, so `addListener('agentIdle', ...)` never fired and `waitForIdle()` could hang. Match the `agent_idle` BrokerEvent on the transport stream via `onEvent` instead — consistent with how `waitForExit()` already works. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(harness-driver): ensure broker event stream is live in handle waits `transport.onEvent()` only registers a listener; events arrive only once the stream is connected. Call the idempotent `connectEvents()` at the start of `waitForExit`/`waitForIdle` so they receive events even if the consumer never connected the stream, instead of hanging until timeout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a14f65f commit ac43ef4

3 files changed

Lines changed: 170 additions & 8 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* Lifecycle-aware handle for a spawned agent.
3+
*
4+
* `HarnessDriverClient.spawnPty()` / `spawnCli()` / `spawnHeadless()` return one
5+
* of these instead of a bare {@link SpawnAgentResult}. It is a structural
6+
* superset of `SpawnAgentResult` (it still carries `name` / `runtime` /
7+
* `sessionId` / `pid`), so existing callers are unaffected, and it adds the
8+
* promise-based lifecycle operations consumers previously had to reconstruct
9+
* from the raw broker event stream:
10+
*
11+
* - `waitForExit()` — resolve when the agent exits, with `code` / `signal`.
12+
* - `waitForIdle()` — resolve on the next idle signal (or on exit).
13+
* - `exit` / `exitCode` / `exitSignal` — synchronous view of a prior exit.
14+
* - `release()` — release the agent via the broker.
15+
*
16+
* All operations are backed by the client's broker event stream and its event
17+
* history, so they are replay-correct: calling `waitForExit()` after the agent
18+
* has already exited resolves immediately from history rather than hanging.
19+
*/
20+
import type { HarnessDriverClient } from './client.js';
21+
import type { AgentRuntime, BrokerEvent } from './protocol.js';
22+
import type { SpawnAgentResult } from './types.js';
23+
24+
export interface AgentExitInfo {
25+
/** `'exited'` when the agent exited; `'timeout'` when the wait elapsed first. */
26+
reason: 'exited' | 'timeout';
27+
/** Process exit code, when the broker reported one. */
28+
code?: number;
29+
/** Terminating signal, when the agent was killed by one. */
30+
signal?: string;
31+
}
32+
33+
export interface AgentIdleInfo {
34+
/** `'idle'` on an idle signal, `'exited'` if the agent exited first, `'timeout'` otherwise. */
35+
reason: 'idle' | 'exited' | 'timeout';
36+
/** Seconds the agent has been idle, when `reason === 'idle'`. */
37+
idleSecs?: number;
38+
/** Exit details, when `reason === 'exited'`. */
39+
exit?: AgentExitInfo;
40+
}
41+
42+
export class SpawnedAgentHandle implements SpawnAgentResult {
43+
readonly name: string;
44+
readonly runtime: AgentRuntime;
45+
readonly sessionId?: string;
46+
readonly pid?: number;
47+
48+
constructor(
49+
result: SpawnAgentResult,
50+
private readonly client: HarnessDriverClient
51+
) {
52+
this.name = result.name;
53+
this.runtime = result.runtime;
54+
this.sessionId = result.sessionId;
55+
this.pid = result.pid;
56+
}
57+
58+
/** Exit info if the agent has already exited (from broker event history), else `undefined`. */
59+
get exit(): AgentExitInfo | undefined {
60+
const exited = this.client.getLastEvent('agent_exited', this.name);
61+
if (exited && exited.kind === 'agent_exited') {
62+
return { reason: 'exited', code: exited.code, signal: exited.signal };
63+
}
64+
const exit = this.client.getLastEvent('agent_exit', this.name);
65+
if (exit && exit.kind === 'agent_exit') {
66+
return { reason: 'exited' };
67+
}
68+
return undefined;
69+
}
70+
71+
get exitCode(): number | undefined {
72+
return this.exit?.code;
73+
}
74+
75+
get exitSignal(): string | undefined {
76+
return this.exit?.signal;
77+
}
78+
79+
/**
80+
* Resolve when the agent exits (with `code` / `signal` when the broker reports
81+
* them), or with `{ reason: 'timeout' }` if `timeoutMs` elapses first. Replays
82+
* a prior exit from broker history, so it is safe to call after the fact.
83+
*/
84+
waitForExit(timeoutMs?: number): Promise<AgentExitInfo> {
85+
// Ensure the broker event stream is live — `onEvent` only registers a
86+
// listener; without an active connection no events ever arrive. Idempotent.
87+
this.client.connectEvents();
88+
89+
const already = this.exit;
90+
if (already) return Promise.resolve(already);
91+
92+
return new Promise<AgentExitInfo>((resolve) => {
93+
let timer: ReturnType<typeof setTimeout> | undefined;
94+
const settle = (info: AgentExitInfo) => {
95+
if (timer) clearTimeout(timer);
96+
unsub();
97+
resolve(info);
98+
};
99+
const unsub = this.client.onEvent((event: BrokerEvent) => {
100+
const exit = matchExit(event, this.name);
101+
if (exit) settle(exit);
102+
});
103+
if (timeoutMs !== undefined) {
104+
timer = setTimeout(() => settle({ reason: 'timeout' }), timeoutMs);
105+
}
106+
});
107+
}
108+
109+
/**
110+
* Resolve on the next idle signal for this agent (edge-triggered: a fresh
111+
* signal after the call, matching how runners poll-then-nudge). Also resolves
112+
* if the agent exits first, or with `{ reason: 'timeout' }` after `timeoutMs`.
113+
*/
114+
waitForIdle(timeoutMs?: number): Promise<AgentIdleInfo> {
115+
// Ensure the broker event stream is live (see waitForExit). Idempotent.
116+
this.client.connectEvents();
117+
118+
const already = this.exit;
119+
if (already) return Promise.resolve({ reason: 'exited', exit: already });
120+
121+
return new Promise<AgentIdleInfo>((resolve) => {
122+
let timer: ReturnType<typeof setTimeout> | undefined;
123+
const settle = (info: AgentIdleInfo) => {
124+
if (timer) clearTimeout(timer);
125+
unsub();
126+
resolve(info);
127+
};
128+
// Idle and exit both arrive on the broker event stream. The named
129+
// `agentIdle` event bus is only populated by call-site hooks (not broker
130+
// events) in direct-client usage, so it must not be used here.
131+
const unsub = this.client.onEvent((event: BrokerEvent) => {
132+
if (event.kind === 'agent_idle' && event.name === this.name) {
133+
settle({ reason: 'idle', idleSecs: event.idle_secs });
134+
return;
135+
}
136+
const exit = matchExit(event, this.name);
137+
if (exit) settle({ reason: 'exited', exit });
138+
});
139+
if (timeoutMs !== undefined) {
140+
timer = setTimeout(() => settle({ reason: 'timeout' }), timeoutMs);
141+
}
142+
});
143+
}
144+
145+
/** Release the agent via the broker. */
146+
release(reason?: string): Promise<{ name: string }> {
147+
return this.client.release(this.name, reason);
148+
}
149+
}
150+
151+
/** Match an exit `BrokerEvent` for `name`, normalising the two exit kinds. */
152+
function matchExit(event: BrokerEvent, name: string): AgentExitInfo | undefined {
153+
if (event.kind === 'agent_exited' && event.name === name) {
154+
return { reason: 'exited', code: event.code, signal: event.signal };
155+
}
156+
if (event.kind === 'agent_exit' && event.name === name) {
157+
return { reason: 'exited' };
158+
}
159+
return undefined;
160+
}

packages/harness-driver/src/client.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import type {
4343
ListAgent,
4444
} from './types.js';
4545
import { EventBus } from './event-bus.js';
46+
import { SpawnedAgentHandle } from './agent-handle.js';
4647
import type {
4748
AfterAgentReleaseContext,
4849
AfterAgentSpawnContext,
@@ -621,7 +622,7 @@ export class HarnessDriverClient {
621622

622623
// ── Agent lifecycle ────────────────────────────────────────────────
623624

624-
async spawnPty(input: SpawnPtyInput): Promise<SpawnAgentResult> {
625+
async spawnPty(input: SpawnPtyInput): Promise<SpawnedAgentHandle> {
625626
const beforeCtx: BeforeAgentSpawnContext<SpawnPtyInput> = {
626627
kind: 'pty',
627628
input,
@@ -638,14 +639,14 @@ export class HarnessDriverClient {
638639
});
639640
const result = SpawnAgentResultSchema.parse(rawResult);
640641
await this.emitAfterSpawn(beforeCtx, resolvedInput, t0, result, undefined);
641-
return result;
642+
return new SpawnedAgentHandle(result, this);
642643
} catch (err) {
643644
await this.emitAfterSpawn(beforeCtx, resolvedInput, t0, undefined, err);
644645
throw err;
645646
}
646647
}
647648

648-
async spawnCli(input: SpawnCliInput): Promise<SpawnAgentResult> {
649+
async spawnCli(input: SpawnCliInput): Promise<SpawnedAgentHandle> {
649650
const beforeCtx: BeforeAgentSpawnContext<SpawnCliInput> = {
650651
kind: 'cli',
651652
input,
@@ -659,7 +660,7 @@ export class HarnessDriverClient {
659660
private async spawnCliWithContext(
660661
beforeCtx: BeforeAgentSpawnContext<SpawnCliInput>,
661662
input: SpawnCliInput
662-
): Promise<SpawnAgentResult> {
663+
): Promise<SpawnedAgentHandle> {
663664
const t0 = Date.now();
664665
const resolvedInput = await this.runBeforeSpawn(beforeCtx);
665666
const transport = resolveSpawnTransport(resolvedInput);
@@ -680,14 +681,14 @@ export class HarnessDriverClient {
680681
});
681682
const result = SpawnAgentResultSchema.parse(rawResult);
682683
await this.emitAfterSpawn(beforeCtx, resolvedInput, t0, result, undefined);
683-
return result;
684+
return new SpawnedAgentHandle(result, this);
684685
} catch (err) {
685686
await this.emitAfterSpawn(beforeCtx, resolvedInput, t0, undefined, err);
686687
throw err;
687688
}
688689
}
689690

690-
async spawnHeadless(input: SpawnHeadlessInput): Promise<SpawnAgentResult> {
691+
async spawnHeadless(input: SpawnHeadlessInput): Promise<SpawnedAgentHandle> {
691692
const cliInput: SpawnCliInput = { ...input, transport: 'headless' };
692693
const beforeCtx: BeforeAgentSpawnContext<SpawnCliInput> = {
693694
kind: 'headless',
@@ -699,11 +700,11 @@ export class HarnessDriverClient {
699700
return this.spawnCliWithContext(beforeCtx, cliInput);
700701
}
701702

702-
async spawnClaude(input: Omit<SpawnCliInput, 'cli'>): Promise<SpawnAgentResult> {
703+
async spawnClaude(input: Omit<SpawnCliInput, 'cli'>): Promise<SpawnedAgentHandle> {
703704
return this.spawnCli({ ...input, cli: 'claude' });
704705
}
705706

706-
async spawnOpencode(input: Omit<SpawnCliInput, 'cli'>): Promise<SpawnAgentResult> {
707+
async spawnOpencode(input: Omit<SpawnCliInput, 'cli'>): Promise<SpawnedAgentHandle> {
707708
return this.spawnCli({ ...input, cli: 'opencode' });
708709
}
709710

packages/harness-driver/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './driver-types.js';
2+
export * from './agent-handle.js';
23
export * from './broker-driver.js';
34
export * from './actions.js';
45
export * from './client.js';

0 commit comments

Comments
 (0)