Skip to content
Open
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
39 changes: 33 additions & 6 deletions nodejs/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
} from "./generated/rpc.js";
import type { ContextTier } from "./generated/session-events.js";
import type { CopilotSession } from "./session.js";
import type { FactoryLimits, FactoryMeta } from "./types.js";
import type { FactoryMeta } from "./types.js";

export type { FactoryRunResult };
export type {
Expand Down Expand Up @@ -47,13 +47,15 @@ export type FactoryRunsPage = FactoryListRunsResult;
/**
* Run statuses a factory run can no longer move away from.
*
* A run is either still in flight (`pending`, `running`) or settled into one of
* these four. Terminal state is final: once written it is never reopened, so a
* caller that observes one of these can stop watching the run.
* A run is either still in flight (`pending`, `running`) or its current attempt
* has settled into one of these states. A paused run can later start a new
* attempt under the same run ID, but callers waiting on the current attempt can
* stop watching once they observe it.
*/
const FACTORY_TERMINAL_STATUSES: ReadonlySet<FactoryRunStatus> = new Set([
"completed",
"halted",
"paused",
"cancelled",
"error",
]);
Expand Down Expand Up @@ -139,6 +141,22 @@ export interface FactoryStepOptions {
volatile?: boolean;
}

/**
* Per-invocation factory resource ceiling overrides.
*
* An omitted field preserves the existing/default ceiling, a number replaces
* it, and `null` explicitly makes that dimension unlimited.
*
* @experimental Part of the experimental Agent Factories surface and may
* change or be removed in future SDK or CLI releases.
*/
export interface FactoryLimitOverrides {
maxConcurrentSubagents?: number | null;
maxTotalSubagents?: number | null;
maxAiCredits?: number | null;
timeoutSeconds?: number | null;
}

/**
* One stage in a per-item factory pipeline.
*
Expand Down Expand Up @@ -168,6 +186,13 @@ export interface FactoryContext<TArgs extends JsonValue = JsonValue> {
producer: () => Promise<JsonValue> | JsonValue,
options?: FactoryStepOptions
): Promise<JsonValue>;
/**
* Pause this run at a durable, one-shot checkpoint.
*
* The first attempt to reach a key pauses and aborts cooperatively. A
* resumed attempt returns from the same key and continues.
*/
pause(key: string): Promise<void>;
/**
* Run thunks concurrently and await all of them.
*
Expand Down Expand Up @@ -259,7 +284,7 @@ export interface RunOptions<TArgs extends JsonValue = JsonValue> {
/** Input surfaced as `context.args`. */
args?: TArgs;
/** Optional per-invocation resource ceiling overrides. */
limits?: FactoryLimits;
limits?: FactoryLimitOverrides;
/** Whether to notify the originating session when the factory completes. */
notifyOnComplete?: boolean;
/** Whether to emit factory phase names to the session transcript. */
Expand All @@ -280,7 +305,7 @@ export interface RunOptions<TArgs extends JsonValue = JsonValue> {
*/
export interface ResumeOptions {
/** Optional per-invocation resource ceiling overrides. */
limits?: FactoryLimits;
limits?: FactoryLimitOverrides;
/** Whether to notify the originating session when the factory completes. */
notifyOnComplete?: boolean;
/** Whether to emit factory phase names to the session transcript. */
Expand Down Expand Up @@ -375,6 +400,8 @@ export interface SessionFactoryApi {
runId: string,
options?: Omit<FactoryGetRunProgressRequest, "runId">
): Promise<FactoryProgressPage>;
/** Pause a running factory and return its settled envelope. */
pause(runId: string): Promise<FactoryRunResult>;
Comment on lines +403 to +404
/** Cancel a factory run and return its terminal envelope. */
cancel(runId: string): Promise<FactoryRunResult>;
}
Expand Down
1 change: 1 addition & 0 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export type {
export type {
RunOptions,
ResumeOptions,
FactoryLimitOverrides,
FactoryResumeErrorCode,
SessionFactoryApi,
FactoryAgentOptions,
Expand Down
76 changes: 66 additions & 10 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { MessageConnection } from "vscode-jsonrpc/node.js";
import { ConnectionError, ErrorCodes, ResponseError } from "vscode-jsonrpc/node.js";
import { createSessionRpc } from "./generated/rpc.js";
import { createInternalSessionRpc, createSessionRpc } from "./generated/rpc.js";
import type {
ClientSessionApiHandlers,
CanvasActionInvokeResult,
Expand Down Expand Up @@ -108,16 +108,29 @@ function copyDefinedFactoryAgentOption<TKey extends keyof FactoryAgentOptions>(
}
}

const factoryExecutionStore = new AsyncLocalStorage<{ active: boolean }>();
type FactoryExecutionContext = {
active: boolean;
helperScope?: "parallel" | "pipeline";
};

const factoryExecutionStore = new AsyncLocalStorage<FactoryExecutionContext>();

function throwIfFactoryExecutionIsActive(): void {
if (factoryExecutionStore.getStore()?.active) {
throw new Error(
"factory.run and factory.resume are not allowed while a factory body is running on this call path."
"factory.run and factory.resume, and factory.pause are not allowed while a factory body is running on this call path."
);
}
}

function runInFactoryHelperScope<TResult>(
helperScope: "parallel" | "pipeline",
callback: () => Promise<TResult> | TResult
): Promise<TResult> | TResult {
const current = factoryExecutionStore.getStore();
return factoryExecutionStore.run({ active: current?.active ?? false, helperScope }, callback);
}

/**
* Convert a raw hook input received over the wire into its public-facing shape.
* This deserializes the numeric Unix-ms `timestamp` field on BaseHookInput
Expand Down Expand Up @@ -188,7 +201,7 @@ async function runFactoryParallel<TResult>(
return Promise.all(
thunks.map((thunk) =>
Promise.resolve()
.then(() => thunk())
.then(() => runInFactoryHelperScope("parallel", thunk))
.catch((error) => {
// Cancellation and hard runtime failures must propagate out
// of the combinator rather than be mapped to a successful
Expand Down Expand Up @@ -220,7 +233,9 @@ async function runFactoryPipeline(
let previous = item;
for (const stage of stages) {
try {
previous = await stage(previous, item, index);
previous = await runInFactoryHelperScope("pipeline", () =>
stage(previous, item, index)
);
} catch (error) {
// Propagate cancellation and hard runtime failures instead
// of mapping them to `null`, so an aborted stage — or one
Expand Down Expand Up @@ -437,6 +452,7 @@ export class CopilotSession {
private hooks?: SessionHooks;
private transformCallbacks?: Map<string, SectionTransformFn>;
private _rpc: ReturnType<typeof createSessionRpc> | null = null;
private _internalRpc: ReturnType<typeof createInternalSessionRpc> | null = null;
private traceContextProvider?: TraceContextProvider;
private readonly managedSettingsEnabled: boolean;
private _capabilities: SessionCapabilities = {};
Expand Down Expand Up @@ -517,6 +533,10 @@ export class CopilotSession {
getRunDetail: (runId) => this.rpc.factory.getRunDetail({ runId }),
getRunProgress: (runId, options = {}) =>
this.rpc.factory.getRunProgress({ runId, ...options }),
pause: async (runId) => {
throwIfFactoryExecutionIsActive();
return this.rpc.factory.pause({ runId });
},
cancel: async (runId) => this.rpc.factory.cancel({ runId }),
};

Expand Down Expand Up @@ -654,6 +674,14 @@ export class CopilotSession {
return this._rpc;
}

/** @internal */
private get internalRpc(): ReturnType<typeof createInternalSessionRpc> {
if (!this._internalRpc) {
this._internalRpc = createInternalSessionRpc(this.connection, this.sessionId);
}
return this._internalRpc;
}

/**
* Path to the session workspace directory when infinite sessions are enabled.
* Contains checkpoints/, plan.md, and files/ subdirectories.
Expand Down Expand Up @@ -1561,6 +1589,36 @@ export class CopilotSession {
);
return result;
},
pause: async (key: string): Promise<void> => {
if (typeof key !== "string" || key.length === 0) {
throw new Error("Factory pause checkpoint key must not be empty");
}
const helperScope = factoryExecutionStore.getStore()?.helperScope;
if (helperScope !== undefined) {
throw new Error(
`Factory pause checkpoints are not allowed inside ${helperScope}() branches`
);
}
await progress.flush();
const response = await awaitFactoryOperation(
() =>
self.internalRpc.factory.pauseAtCheckpoint({
runId: params.runId,
executionToken: params.executionToken,
key,
}),
controller.signal
);
switch (response.action) {
case "continue":
return;
case "pause":
await awaitFactoryOperation(
() => new Promise<never>(() => {}),
controller.signal
);
}
},
parallel: runFactoryParallel,
pipeline: runFactoryPipeline,
factory: async () => {
Expand Down Expand Up @@ -1596,11 +1654,9 @@ export class CopilotSession {
},
async abort(params) {
const controllersForRun = self.factoryAbortControllers.get(params.runId);
if (controllersForRun !== undefined) {
const reason = new DOMException("Factory run was aborted", "AbortError");
for (const controller of controllersForRun.values()) {
controller.abort(reason);
}
const controller = controllersForRun?.get(params.executionToken);
if (controller !== undefined) {
controller.abort(new DOMException("Factory run was aborted", "AbortError"));
}
return {};
},
Expand Down
Loading
Loading