Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -3746,6 +3746,8 @@ interface Store {

This is the stable core exposed by the standalone authoring declaration. Atomic's runtime store also has graph, prompt, session, pause/resume, snapshot, and subscription methods used by embedded integrations; those richer runtime controls are not part of the lean workflow-package `Store` contract shown here.

The embedded runtime's `graphSnapshot()` returns one deeply frozen, payload-free projection for each store version; repeated reads at the same version return the same object. Runtime code must change graph-visible state through a version-bumping store method before another task can observe it. `subscribeInvalidation()` reports those changes synchronously without creating a full snapshot. Legacy `subscribe(snapshot)` consumers still receive a full cloned snapshot; this includes status-file output when `statusFile: true`, while the default `statusFile: false` path avoids that payload traversal.

### `createCancellationRegistry()` / `cancellationRegistry`

```typescript
Expand Down
2 changes: 2 additions & 0 deletions packages/workflows/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Fixed

- Fixed workflow graph rendering for large, wide fan-outs by clipping cards, edges, and composed rows to the visible viewport, retaining cached topology and layout across status-only updates, and making idle animation eligibility constant-time ([#2100](https://github.com/bastani-inc/atomic/issues/2100)).
- Fixed every interactive store mutation cloning the full run payload before the graph could repaint. A status or question update used to build a complete `JSON.stringify`/`JSON.parse` snapshot, traversing workflow inputs, authored stage result bodies, child output values, and tool input/output bodies, so mutation cost scaled with payload size rather than with the graph. Store observation now offers a synchronous invalidation-only channel plus one immutable memoized graph projection per store version, and the graph, overlay, attach pane, stage chat, widget, lifecycle and HIL notifications, resume picker, and send admission all read that projection. In the default configuration (`statusFile: false`), mutations no longer build the legacy full snapshot; enabling `statusFile` retains that snapshot and status-file serialization cost. Topology, status, timing, prompts, attachment state, notices, bounded returned-status fields, durable tool summaries, and child output counts are preserved; the existing `Store.snapshot()` / `Store.subscribe(snapshot)` contract is unchanged for external consumers ([#2100](https://github.com/bastani-inc/atomic/issues/2100)).
- Fixed the memoized graph projection keeping whole run results in memory. Truncating a result field with `slice()` leaves a V8 SlicedString that points at its untruncated parent, so a 1 KiB projected field kept a multi-megabyte run result alive — inside the store's cached projection, and inside every long-lived holder of one — long after the run itself was removed. Truncated fields are now copied into fresh flat strings, so a session with large workflow results no longer grows heap ([#2100](https://github.com/bastani-inc/atomic/issues/2100)).

## [0.9.11-alpha.9] - 2026-08-01

Expand Down
6 changes: 5 additions & 1 deletion packages/workflows/src/extension/extension-runtime-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { StageAdapters } from "../runs/foreground/stage-runner.js";
import type { SessionManager } from "../shared/persistence-restore.js";
import { stageUiBroker } from "../shared/stage-ui-broker.js";
import { store } from "../shared/store.js";
import { readGraphStoreSnapshot } from "../shared/store-observation.js";
import type { RunSnapshot } from "../shared/store-types.js";
import type {
WorkflowExecutionPolicy,
Expand Down Expand Up @@ -87,7 +88,10 @@ export function createWorkflowExtensionRuntimeState(
const lifecycleNotificationState = createWorkflowLifecycleNotificationState();
const hilAnswerNotificationState = createWorkflowHilAnswerNotificationState();
const beforeRestoreCompleted = (snapshots: readonly RunSnapshot[]): void => {
seedWorkflowLifecycleNotificationState(lifecycleNotificationState, { ...store.snapshot(), runs: snapshots });
seedWorkflowLifecycleNotificationState(lifecycleNotificationState, {
...readGraphStoreSnapshot(store),
runs: snapshots,
});
};
const lifecycleNotificationConfigRef: { current: WorkflowLifecycleNotificationConfig } = {
current: WORKFLOW_CONFIG_DEFAULTS.workflowNotifications,
Expand Down
7 changes: 5 additions & 2 deletions packages/workflows/src/extension/hil-answer-notifications.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { StageUiBroker } from "../shared/stage-ui-broker.js";
import type { Store } from "../shared/store.js";
import { readGraphStoreSnapshot, subscribeStoreInvalidation } from "../shared/store-observation.js";
import type {
PendingPrompt,
PromptKind,
Expand Down Expand Up @@ -99,10 +100,12 @@ export function installWorkflowHilAnswerNotifications(options: WorkflowHilAnswer
}
};

const unsubscribeStore = options.store.subscribe(inspectWorkflowPromptAnswers);
const unsubscribeStore = subscribeStoreInvalidation(options.store, () =>
inspectWorkflowPromptAnswers(readGraphStoreSnapshot(options.store)),
);
const unsubscribeBroker = options.stageUiBroker?.onStagePromptResolved((event) => {
if (event.answerSource === "workflow_tool") return;
const answeredStage = findStageSnapshot(options.store.snapshot(), event.runId, event.stageId);
const answeredStage = findStageSnapshot(readGraphStoreSnapshot(options.store), event.runId, event.stageId);
if (answeredStage === undefined) return;

emitOnce(
Expand Down
9 changes: 6 additions & 3 deletions packages/workflows/src/extension/lifecycle-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "../shared/returned-run-status.js";
import { isTopLevelWorkflowRun } from "../shared/run-visibility.js";
import type { Store } from "../shared/store.js";
import { readGraphStoreSnapshot, subscribeStoreInvalidation } from "../shared/store-observation.js";
import type {
PendingPrompt,
PromptKind,
Expand Down Expand Up @@ -175,7 +176,9 @@ export function installWorkflowLifecycleNotifications(options: WorkflowLifecycle
const notifyOn = new Set<WorkflowLifecycleNoticeKind>(options.config.notifyOn);
const state = options.state ?? createWorkflowLifecycleNotificationState();
let delivery!: ReturnType<typeof createLifecycleNoticeDelivery>;
if (options.seedExisting !== false) seedWorkflowLifecycleNotificationState(state, options.store.snapshot());
if (options.seedExisting !== false) {
seedWorkflowLifecycleNotificationState(state, readGraphStoreSnapshot(options.store));
}

const emit = (details: WorkflowLifecycleNoticeDetails): boolean | Promise<boolean> => {
try {
Expand Down Expand Up @@ -266,8 +269,8 @@ export function installWorkflowLifecycleNotifications(options: WorkflowLifecycle
if (notifyOn.has(details.kind)) delivery.deliver(key, details);
}

const unsubscribe = options.store.subscribe(inspect);
inspect(options.store.snapshot());
const unsubscribe = subscribeStoreInvalidation(options.store, () => inspect(readGraphStoreSnapshot(options.store)));
inspect(readGraphStoreSnapshot(options.store));
return () => {
unsubscribe();
delivery.dispose();
Expand Down
5 changes: 3 additions & 2 deletions packages/workflows/src/extension/postmortem-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { stageControlRegistry } from "../runs/foreground/stage-control-registry.js";
import type { StageAdapters } from "../runs/foreground/stage-runner.js";
import { store } from "../shared/store.js";
import { readGraphStoreSnapshot } from "../shared/store-observation.js";

export interface PostMortemResolverDeps {
readonly adapters: StageAdapters;
Expand All @@ -33,7 +34,7 @@ function resolveStageCwd(runId: string): string | undefined {
try {
const backend = getDurableBackend();
const owningHandle = backend.getWorkflow(runId);
const run = store.snapshot().runs.find((candidate) => candidate.id === runId);
const run = readGraphStoreSnapshot(store).runs.find((candidate) => candidate.id === runId);
const rootRunId = run?.rootRunId ?? owningHandle?.rootWorkflowId;
const cwdHandle = rootRunId === undefined ? owningHandle : (backend.getWorkflow(rootRunId) ?? owningHandle);
return cwdHandle?.workflowCwd ?? cwdHandle?.invocationCwd ?? undefined;
Expand Down Expand Up @@ -61,7 +62,7 @@ export function createPostMortemHandleResolver(
deps: PostMortemResolverDeps,
): (runId: string, stageId: string) => EnsurePostMortemStageHandleResult | undefined {
return (runId, stageId) => {
const run = store.snapshot().runs.find((candidate) => candidate.id === runId);
const run = readGraphStoreSnapshot(store).runs.find((candidate) => candidate.id === runId);
const stage = run?.stages.find((candidate) => candidate.id === stageId);
if (stage === undefined) return undefined;
return ensurePostMortemStageHandle(runId, stage, postMortemDepsForRun(runId, deps));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { isWorkflowRunResumable } from "../durable/resume-eligibility.js";
import type { ResumableWorkflowEntry } from "../durable/types.js";
import { topLevelWorkflowRuns } from "../shared/run-visibility.js";
import type { Store } from "../shared/store.js";
import { subscribeStoreInvalidation } from "../shared/store-observation.js";
import type { RunSnapshot } from "../shared/store-types.js";
import { workflowRunResumeCandidate } from "../shared/workflow-artifacts.js";
import type { WorkflowResumeRefresh } from "../tui/workflow-resume-selector.js";
Expand Down Expand Up @@ -77,7 +78,7 @@ export function resumePickerLiveUpdateOptions(
runtime: ExtensionRuntime,
): ResumePickerLiveUpdateOptions {
return {
watch: (onChange) => runStore.subscribe(() => onChange()),
watch: (onChange) => subscribeStoreInvalidation(runStore, onChange),
refresh: async () => {
const current = collectResumePickerLiveRuns(runStore);
const catalog = await prepareWorkflowResumeCatalog(runtime, current.suppressedLiveIds);
Expand Down
3 changes: 2 additions & 1 deletion packages/workflows/src/extension/workflow-resume-shadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getLoadableDurableWorkflow } from "../durable/workflow-status-transitio
import { type JobTracker, jobTracker } from "../runs/background/job-tracker.js";
import { type StageControlRegistry, stageControlRegistry } from "../runs/foreground/stage-control-registry.js";
import { expandWorkflowGraph } from "../shared/expanded-workflow-graph.js";
import { readGraphStoreSnapshot } from "../shared/store-observation.js";
import type { Store } from "../shared/store-public-types.js";
import type { RunSnapshot } from "../shared/store-types.js";

Expand All @@ -28,7 +29,7 @@ export function classifyDurableResumeShadow(
const jobs = deps.jobs ?? jobTracker;
if (jobs.has(run.id)) return "not_shadow";
const controls = deps.stageControls ?? stageControlRegistry;
const graph = expandWorkflowGraph(store.snapshot(), run.id);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(store), run.id);
const controlRunIds = new Set<string>([run.id]);
for (const stage of graph.stages) controlRunIds.add(stage.workflowGraphTarget.runId);
if ([...controlRunIds].some((runId) => controls.run(runId).stages().length > 0)) return "not_shadow";
Expand Down
5 changes: 3 additions & 2 deletions packages/workflows/src/extension/workflow-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { isFullRunId, malformedRunIdMessage, RUN_ID_LENGTH } from "../shared/run-id.js";
import { topLevelWorkflowRuns } from "../shared/run-visibility.js";
import { store } from "../shared/store.js";
import { readGraphStoreSnapshot } from "../shared/store-observation.js";
import type { RunStatus } from "../shared/store-types.js";
import type { OverlayPiSurface } from "../tui/overlay-adapter.js";
import type { PiExecuteContext, WorkflowToolArgs } from "./public-types.js";
Expand Down Expand Up @@ -115,7 +116,7 @@ export type ToolStageTarget = { ok: true; runId?: string; stageId?: string } | {
export function resolveStageTarget(runId: string, stageTarget?: string): ToolStageTarget {
const target = stageTarget?.trim();
if (!target) return { ok: true, runId };
const graph = expandWorkflowGraph(store.snapshot(), runId);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(store), runId);
const exactVirtualIds = graph.stages.filter((stage) => stage.id === target);
if (exactVirtualIds.length === 1) return resolvedStageTarget(exactVirtualIds[0]!);
if (exactVirtualIds.length > 1) return ambiguousStageTarget(target, exactVirtualIds);
Expand Down Expand Up @@ -166,7 +167,7 @@ export type ControlNodeTarget =
export function resolveControlNodeTarget(runId: string, stageTarget?: string): ControlNodeTarget {
const target = stageTarget?.trim();
if (!target) return { ok: true, kind: "run" };
const graph = expandWorkflowGraph(store.snapshot(), runId);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(store), runId);
const nodes = graph.renderStages;
const candidates: Array<readonly ExpandedWorkflowStage[]> = [
nodes.filter((node) => node.id === target),
Expand Down
3 changes: 2 additions & 1 deletion packages/workflows/src/extension/workflow-tool-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { coerceStageInputAnswer, hasStageInputAnswerContent, type StageInputAnsw
import { stageUiBroker } from "../shared/stage-ui-broker.js";
import { store } from "../shared/store.js";
import { isTerminalRunStatus } from "../shared/store-internal.js";
import { subscribeStoreInvalidation } from "../shared/store-observation.js";
import { reciprocalWorkflowRootRunId } from "../shared/workflow-run-ownership.js";
import type { WorkflowToolArgs } from "./public-types.js";
import type { WorkflowToolResult } from "./render-result.js";
Expand Down Expand Up @@ -271,7 +272,7 @@ export async function workflowSendAction(
admitted = true;
};
const terminalPending = Promise.withResolvers<never>();
const unsubscribeTerminal = store.subscribe(() => {
const unsubscribeTerminal = subscribeStoreInvalidation(store, () => {
if (admitted) return;
const terminal = terminalWorkflowSendResultForRoot(rootRunId, args.stageId?.trim() ?? resolvedStageId);
if (terminal !== undefined) terminalPending.reject(new WorkflowSendAdmissionError(terminal));
Expand Down
5 changes: 3 additions & 2 deletions packages/workflows/src/runs/background/quit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { WorkflowGracefulQuitError } from "../../engine/workflow-tool-abort.js";
import { expandWorkflowGraph } from "../../shared/expanded-workflow-graph.js";
import { topLevelWorkflowRuns } from "../../shared/run-visibility.js";
import { store as defaultStore } from "../../shared/store.js";
import { readGraphStoreSnapshot } from "../../shared/store-observation.js";
import type { Store } from "../../shared/store-public-types.js";
import type { RunSnapshot, StageSnapshot } from "../../shared/store-types.js";
import type { WorkflowCancelledToolNode, WorkflowToolNodeIdentity } from "../../shared/types.js";
Expand Down Expand Up @@ -100,7 +101,7 @@ export async function quitRun(
if (!run) return { ok: false, runId, reason: "not_found" };
if (run.endedAt !== undefined) return { ok: false, runId, reason: "already_ended" };

const graph = expandWorkflowGraph(activeStore.snapshot(), runId);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(activeStore), runId);
const handles = controllableHandles(activeStore, registry, runId);
const admissionBoundaries = controllableAdmissionBoundaries(activeStore, toolControls, runId);
const promptStages = graph.stages.filter(
Expand Down Expand Up @@ -303,7 +304,7 @@ function controllableHandles(
registry: StageControlRegistry,
runId: string,
): Array<{ controlRunId: string; handle: StageControlHandle }> {
const graph = expandWorkflowGraph(activeStore.snapshot(), runId);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(activeStore), runId);
const controlRunIds = new Set<string>([runId]);
for (const stage of graph.stages) controlRunIds.add(stage.workflowGraphTarget.runId);
return [...controlRunIds].flatMap((controlRunId) =>
Expand Down
3 changes: 2 additions & 1 deletion packages/workflows/src/runs/background/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { effectiveRunStatus } from "../../shared/returned-run-status.js";
import { topLevelWorkflowRuns } from "../../shared/run-visibility.js";
import type { Store } from "../../shared/store.js";
import { store as defaultStore } from "../../shared/store.js";
import { readGraphStoreSnapshot } from "../../shared/store-observation.js";
import type { RunSnapshot, RunStatus, StageSnapshot } from "../../shared/store-types.js";
import type { WorkflowPersistencePort } from "../../shared/types.js";
import type { StageControlRegistry } from "../foreground/stage-control-registry.js";
Expand Down Expand Up @@ -89,7 +90,7 @@ export { type InspectRunResult, inspectRun, type RunDetail } from "./run-inspect
export function statusRuns(opts?: { all?: boolean; store?: Store }): RunStatusEntry[] {
const activeStore = opts?.store ?? defaultStore;

const snapshot = activeStore.snapshot();
const snapshot = readGraphStoreSnapshot(activeStore);
return topLevelWorkflowRuns(snapshot.runs).map((run) => {
const graph = expandWorkflowGraph(snapshot, run.id);
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { expandWorkflowGraph } from "../../shared/expanded-workflow-graph.js";
import { readGraphStoreSnapshot } from "../../shared/store-observation.js";
import type { Store } from "../../shared/store-public-types.js";
import { reciprocalWorkflowRootRunId } from "../../shared/workflow-run-ownership.js";

/** Control-run ids visible below one workflow boundary, in graph order. */
export function expandedControlRunIds(store: Store, runId: string): string[] {
const graph = expandWorkflowGraph(store.snapshot(), runId);
const graph = expandWorkflowGraph(readGraphStoreSnapshot(store), runId);
const ids = new Set<string>([runId]);
for (const stage of graph.stages) ids.add(stage.workflowGraphTarget.runId);
// Tool-only nested runs own no stage, yet their in-flight ctx.tool nodes are
Expand Down
4 changes: 4 additions & 0 deletions packages/workflows/src/runs/foreground/executor-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ export interface RunFailureMetadata {
readonly retryAfterMs?: number;
}

/**
* Apply failure fields to a live stage. Executor callers invoke this next to
* a version-bumping stage store method, with no intervening asynchronous work.
*/
export function applyFailureToStage(stage: StageSnapshot, failure: WorkflowFailure): void {
stage.status = "failed";
stage.error = failure.userMessage;
Expand Down
2 changes: 2 additions & 0 deletions packages/workflows/src/runs/foreground/executor-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export function createStageScheduler(input: {
input.runSnapshot.stages.find((stage) => stage.id === stageId);

const setStageParentIds = (stage: StageSnapshot, parentIds: readonly string[]): void => {
// The tracked-stage caller invokes this next to the version-bumping
// `recordStageStart` method, with no intervening asynchronous work.
stage.parentIds = Object.freeze([...parentIds]);
};

Expand Down
Loading
Loading