Skip to content

Commit 2f86165

Browse files
Add session.factory.waitForRun for run settlement
Adds an explicit settlement await to the factory API so a caller can block on a run reaching a terminal status without hand-rolling a poll loop. `waitForRun(runId, { signal? })` resolves with the terminal envelope once the run settles into completed, error, halted, or cancelled, and resolves immediately when it has already settled. It subscribes to the run's `factory.run_updated` invalidation events and re-reads the durable envelope rather than polling on a timer. The subscription is installed before the first read so a transition landing between the two cannot be missed, and re-reads are serialized so a burst of events collapses into one in-flight read — the run's revision advances once per operation, so bursts are the normal case. The subscription is released on settle, abort, and read failure. Aborting rejects the wait and leaves the run executing; cancel() remains the way to stop it. Also exports `isFactoryRunTerminal(status)` and the `FactoryRunStatus` type so callers driving their own loop share one definition of terminal. This is additive and carries no wire change: it is built entirely on the existing `getRun` method and `factory.run_updated` session event, so it is correct against the current foreground runtime, where it resolves on the first read. It becomes load-bearing under background execution, where `run()` resolves with a running envelope and `result` is only readable after settlement. Verification: 94 factory unit tests pass (81 existing, 13 new). The runtime's Agent Factories E2E suite passes 19/19 under STRICT_CAPTURES against a build of this branch. The coalescing and unsubscribe tests were mutation-tested — each goes red with its guard removed.
1 parent 44d9159 commit 2f86165

6 files changed

Lines changed: 328 additions & 1 deletion

File tree

nodejs/docs/factories.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,25 @@ const page = await session.factory.getRunProgress(runId, {
210210
211211
`getRun(runId)` reads the latest run envelope, and `cancel(runId)` cancels a run and returns its terminal envelope.
212212
213+
`waitForRun(runId, options?)` resolves with the terminal envelope once the run settles into `completed`, `error`, `halted`, or `cancelled`, and resolves immediately when it has already settled:
214+
215+
```ts
216+
const settled = await session.factory.waitForRun(runId);
217+
if (settled.status === "completed") {
218+
console.log(settled.result);
219+
}
220+
```
221+
222+
It watches `factory.run_updated` and re-reads the durable envelope rather than polling on a timer, and it collapses a burst of invalidation events into a single in-flight read. Pass a `signal` to stop waiting:
223+
224+
```ts
225+
const controller = new AbortController();
226+
setTimeout(() => controller.abort(), 30_000);
227+
const settled = await session.factory.waitForRun(runId, { signal: controller.signal });
228+
```
229+
230+
Aborting rejects the wait and has no effect on the run, which keeps executing — use `cancel(runId)` to actually stop it. Because a terminal envelope is final, the resolved value never changes afterwards. `isFactoryRunTerminal(status)` exposes the same terminal-status test for callers driving their own loop.
231+
213232
Listen for the ephemeral `factory.run_updated` event. Its `{ runId, revision }` payload is an invalidation signal. Re-read the desired API when a newer monotonic revision arrives.
214233
215234
Revisions cover durable lifecycle, accounting, phase, agent, and progress changes. Continuous read-time fields can change without a new revision. These include `observedAt`, active-time calculations, live counts, and a live agent's status or prompt-safe activity text. Factory prompts are never exposed by these APIs. A run is visible only through the session that owns it.

nodejs/src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export type { ExtensionInfo, FactoryLimits, FactoryMeta } from "./types.js";
4040
export {
4141
defineFactory,
4242
FactoryResumeError,
43+
isFactoryRunTerminal,
4344
type RunOptions,
4445
type ResumeOptions,
4546
type FactoryResumeErrorCode,
@@ -53,6 +54,7 @@ export {
5354
type FactoryPipelineStage,
5455
type FactoryStepOptions,
5556
type FactoryRunResult,
57+
type FactoryRunStatus,
5658
type FactoryRunSummary,
5759
type FactoryRunDetail,
5860
type FactoryProgressPage,

nodejs/src/factory.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
FactoryProgressPage,
88
FactoryRunDetail,
99
FactoryRunResult,
10+
FactoryRunStatus,
1011
FactoryRunSummary,
1112
} from "./generated/rpc.js";
1213
import type { CopilotSession } from "./session.js";
@@ -25,9 +26,34 @@ export type {
2526
FactoryProgressPage,
2627
FactoryRunDetail,
2728
FactoryRunResult,
29+
FactoryRunStatus,
2830
FactoryRunSummary,
2931
} from "./generated/rpc.js";
3032

33+
/**
34+
* Run statuses a factory run can no longer move away from.
35+
*
36+
* A run is either still in flight (`pending`, `running`) or settled into one of
37+
* these four. Terminal state is final: once written it is never reopened, so a
38+
* caller that observes one of these can stop watching the run.
39+
*/
40+
const FACTORY_TERMINAL_STATUSES: ReadonlySet<FactoryRunStatus> = new Set([
41+
"completed",
42+
"halted",
43+
"cancelled",
44+
"error",
45+
]);
46+
47+
/**
48+
* Whether a factory run status is terminal.
49+
*
50+
* @experimental Part of the experimental Agent Factories surface and may
51+
* change or be removed in future SDK or CLI releases.
52+
*/
53+
export function isFactoryRunTerminal(status: FactoryRunStatus): boolean {
54+
return FACTORY_TERMINAL_STATUSES.has(status);
55+
}
56+
3157
declare const factoryHandleBrand: unique symbol;
3258

3359
/** A value that can be represented losslessly on the SDK JSON wire. */
@@ -230,6 +256,24 @@ export interface SessionFactoryApi {
230256
resume(runId: string, options?: ResumeOptions): Promise<FactoryRunResult>;
231257
/** Read the latest durable envelope for a factory run. */
232258
getRun(runId: string): Promise<FactoryRunResult>;
259+
/**
260+
* Wait for a run to settle and resolve with its terminal envelope.
261+
*
262+
* Resolves as soon as the run reaches `completed`, `error`, `halted`, or
263+
* `cancelled`, and resolves immediately when it has already settled. A
264+
* terminal envelope is final, so the resolved value never changes
265+
* afterwards.
266+
*
267+
* This watches the run's `factory.run_updated` invalidation events and
268+
* re-reads the durable envelope rather than polling on a timer. Pass a
269+
* `signal` to stop waiting; aborting rejects and has no effect on the run
270+
* itself, which keeps executing. Use {@link SessionFactoryApi.cancel} to
271+
* actually stop it.
272+
*/
273+
waitForRun(
274+
runId: string,
275+
options?: { signal?: AbortSignal }
276+
): Promise<FactoryRunResult>;
233277
/** List this session's durable factory runs in creation order. */
234278
listRuns(): Promise<FactoryRunSummary[]>;
235279
/** Read durable phases, direct agents, and the latest progress tail for a run. */

nodejs/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export { CopilotClient } from "./client.js";
1212
export { RuntimeConnection } from "./types.js";
1313
export { BuiltInTools, ToolSet } from "./toolSet.js";
1414
export { CopilotSession, type AssistantMessageEvent } from "./session.js";
15-
export { defineFactory, FactoryResumeError } from "./factory.js";
15+
export { defineFactory, FactoryResumeError, isFactoryRunTerminal } from "./factory.js";
1616
export {
1717
Canvas,
1818
CanvasError,
@@ -178,6 +178,7 @@ export type {
178178
FactoryPipelineStage,
179179
FactoryStepOptions,
180180
FactoryRunResult,
181+
FactoryRunStatus,
181182
FactoryRunSummary,
182183
FactoryRunDetail,
183184
FactoryProgressPage,

nodejs/src/session.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ import type {
6464
import {
6565
getFactoryDefinition,
6666
FactoryResumeError,
67+
isFactoryRunTerminal,
6768
type FactoryResumeErrorCode,
69+
type FactoryRunResult,
6870
type RunOptions,
6971
type SessionFactoryApi,
7072
type FactoryContext,
@@ -447,13 +449,92 @@ export class CopilotSession {
447449
return response.run;
448450
}) as SessionFactoryApi["resume"],
449451
getRun: (runId) => this.rpc.factory.getRun({ runId }),
452+
waitForRun: (runId, options) => this.waitForFactoryRun(runId, options?.signal),
450453
listRuns: async () => (await this.rpc.factory.listRuns()).runs,
451454
getRunDetail: (runId) => this.rpc.factory.getRunDetail({ runId }),
452455
getRunProgress: (runId, options = {}) =>
453456
this.rpc.factory.getRunProgress({ runId, ...options }),
454457
cancel: (runId) => this.rpc.factory.cancel({ runId }),
455458
};
456459

460+
/**
461+
* Resolve when a factory run reaches a terminal status.
462+
*
463+
* The subscription is installed *before* the first read so a transition
464+
* landing between the two cannot be missed, and re-reads are serialized so
465+
* overlapping invalidation events cannot interleave — the run's revision
466+
* advances once per operation, so a burst of events is common and must
467+
* collapse into a single in-flight read.
468+
*/
469+
private waitForFactoryRun(
470+
runId: string,
471+
signal?: AbortSignal
472+
): Promise<FactoryRunResult> {
473+
const abortError = (): unknown =>
474+
signal?.reason ?? new DOMException("Factory run wait was aborted", "AbortError");
475+
if (signal?.aborted === true) {
476+
return Promise.reject(abortError());
477+
}
478+
479+
return new Promise<FactoryRunResult>((resolve, reject) => {
480+
let settled = false;
481+
let reading = false;
482+
let rereadRequested = false;
483+
let unsubscribe: (() => void) | undefined;
484+
let onAbort: (() => void) | undefined;
485+
486+
const finish = (complete: () => void): void => {
487+
if (settled) {
488+
return;
489+
}
490+
settled = true;
491+
unsubscribe?.();
492+
if (onAbort !== undefined) {
493+
signal?.removeEventListener("abort", onAbort);
494+
}
495+
complete();
496+
};
497+
498+
const read = async (): Promise<void> => {
499+
if (settled) {
500+
return;
501+
}
502+
if (reading) {
503+
rereadRequested = true;
504+
return;
505+
}
506+
reading = true;
507+
try {
508+
do {
509+
rereadRequested = false;
510+
const envelope = await this.rpc.factory.getRun({ runId });
511+
if (isFactoryRunTerminal(envelope.status)) {
512+
finish(() => resolve(envelope));
513+
return;
514+
}
515+
} while (rereadRequested && !settled);
516+
} catch (error) {
517+
finish(() => reject(error));
518+
} finally {
519+
reading = false;
520+
}
521+
};
522+
523+
if (signal !== undefined) {
524+
onAbort = (): void => finish(() => reject(abortError()));
525+
signal.addEventListener("abort", onAbort, { once: true });
526+
}
527+
528+
unsubscribe = this.on("factory.run_updated", (event) => {
529+
if (event.data.runId === runId) {
530+
void read();
531+
}
532+
});
533+
534+
void read();
535+
});
536+
}
537+
457538
/**
458539
* Creates a new CopilotSession instance.
459540
*

0 commit comments

Comments
 (0)