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: 1 addition & 1 deletion packages/coding-agent/docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ Named runs go to the background. Common controls:

When a paused stage is resumed with a message, Atomic lets the stage answer that resume message, then (if the stage has not already finalized) injects `Continue where you left off. If you believe you are finished with your original task (or a redefined task if the user told you), stop.` into the same stage session before normal stage completion/readiness handling. This keeps interrupted work moving without asking you to manually type a second continuation prompt while also preventing stages that already finished their scoped work from overstepping.

Durable `/workflow resume` preserves completed stage metadata and graph topology. When it reopens an unfinished stage session mid-chat, it uses the same continuation prompt shown above. Replayed `ctx.stage`, `ctx.task`, `ctx.chain`, `ctx.parallel`, and child-workflow checkpoints keep their original summaries, timing, session/model metadata, and parallel fanout parentage in status and graph views instead of appearing as freshly flattened replay nodes.
Durable `/workflow resume` preserves completed stage metadata, active-stage elapsed time, and graph topology. While an LM stage or task is active, repeated durable checkpoints refresh its accumulated pause-adjusted duration even when its session file does not change. Each new Atomic process that reopens the unfinished session mid-chat starts from the latest saved baseline and uses the same continuation prompt shown above, so repeated process-boundary resumes keep status, graph, stored, and lifecycle duration cumulative without double-counting pauses from earlier process segments. Replayed `ctx.stage`, `ctx.task`, `ctx.chain`, `ctx.parallel`, and child-workflow checkpoints keep their original summaries, timing, session/model metadata, and parallel fanout parentage instead of appearing as freshly flattened replay nodes.

Workflow stage sessions are marked as **internal** and excluded from the standard `/resume`, `atomic -r`, and `--continue` history so they do not clutter your interactive session picker. They remain resumable and inspectable through the workflow-specific commands and tool actions shown here (`/workflow resume`, `/workflow attach`, `workflow({ action: "status" | "stages" | "stage" | "resume" })`), which read the run/stage store and its `sessionFile` links directly. Passing a stage session's file path to `--session` still opens it explicitly. Legacy workflow sessions created before this marker behavior lack the signal and will continue to appear in the standard history until they age out.

Expand Down
1 change: 1 addition & 0 deletions packages/workflows/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Fixed `/workflow resume` so its single globally newest-first, deduplicated picker includes current paused or recoverably failed and durable resumable entries alongside authoritative successful completed workflows. Completed rows use green `✓ completed` styling and open an immutable detail/chat snapshot with transcript follow-up conversation, without durable re-dispatch or workflow side-effect replay. Full IDs take precedence and prefixes resolve across the mixed namespace; completed rows require checkpoints and at least one strictly valid retained conversation, invalid per-stage paths cannot attach chat, repeated inspection refreshes changed authoritative chat handles, and selector mount failures close safely. Existing resumable workflow and ordinary internal-session history behavior remains on the original paths. ([#1532](https://github.com/bastani-inc/atomic/issues/1532))
- Fixed `/workflow resume` to deduplicate live and durable choices with live-row precedence, then globally sort the combined selector by latest run/stage or durable-update activity, newest first, with deterministic workflow-id tie-breaking independent of source enumeration order ([#1714](https://github.com/bastani-inc/atomic/issues/1714)).
- Fixed startup and restored-session workflow notices to use the authoritative durable resume catalog's status/progress rules: failed and blocked root workflows are advertised unless explicitly marked `resumable: false`, running and paused workflows require durable progress, nested child workflows and stale cache-only entries are excluded, and legacy direct or payload-wrapped checkpoint entries are parsed correctly.
- Fixed durable mid-stage workflow resume to refresh pause-adjusted active stage/task duration at repeated session checkpoints, preserve it through file and DBOS hydration, and continue accumulating it across repeated process-boundary resumes without resetting the baseline when concurrent tracked stage calls begin, double-counting earlier pauses, changing replay identity, or re-running completed side effects ([#1713](https://github.com/bastani-inc/atomic/issues/1713)).

## [0.9.8] - 2026-07-12

Expand Down
2 changes: 1 addition & 1 deletion packages/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ Input overrides are bare `key=value` tokens (no leading `--`). Values are JSON-p

Named workflow launches always run as **background tasks** in interactive sessions. Model-launched direct `task`, `tasks`, and `chain` calls must set top-level `async: true` so the chat editor stays free; inspection and control calls are unaffected. Foreground launches are reserved for explicit user requests or technical requirements, with notice before launch. Press **F2** (or `/workflow connect <run-id>`) to attach to the live graph viewer; HIL prompts (`ctx.ui.input/confirm/select/editor/custom`) appear as awaiting-input graph nodes. Press Enter on a focused node, or click a visible graph node directly, to open that stage and answer locally, never as a modal dialog over the chat. While the graph pane is active, vertical wheel/trackpad gestures pan vertically and horizontal gestures pan wide graphs left and right when the terminal reports them, without falling through to the main chat or terminal scrollback. Attached stage chats capture mouse/trackpad wheel events by default so scrolling stays inside the active stage transcript or prompt instead of falling through to terminal/main-chat scrollback. Press `ctrl+t` to toggle **copy mode**: copy mode disables workflow-chat mouse reporting so normal terminal/tmux text selection can work; press `ctrl+t` again to leave copy mode and restore workflow-chat scrolling. Archived read-only stage transcripts show the same copy-mode footer/status, allowing their transcript text to be selected and copied while preserving `esc` close and `ctrl+d` graph navigation. While copy mode is on, wheel/trackpad gestures are handled by the terminal/tmux and may scroll terminal scrollback, so leave copy mode before using the wheel again. Human input is detected when those runtime `ctx.ui.*` calls execute; workflows no longer have a declaration-time HIL flag.

Durable resume preserves the original completed-stage shape: replayed stage/task/parallel/child checkpoints retain summaries, durations, session/model metadata, and parallel branch parentage in the graph instead of flattening fanout branches into a sequential replay chain.
Durable resume preserves both completed and active stage timing. Replayed stage/task/parallel/child checkpoints retain summaries, durations, session/model metadata, and parallel branch parentage in the graph instead of flattening fanout branches into a sequential replay chain. While an LM stage or task is active, repeated durable session checkpoints refresh its pause-adjusted accumulated duration even when the session file is unchanged. Every later `/workflow resume` starts from the latest saved baseline, so repeated process-boundary resumes keep status, graph, stored, and lifecycle durations cumulative while subtracting only pauses from the current process segment.

Nested `ctx.workflow(...)` calls are displayed as an expanded graph within the top-level run. `/workflow status` and run pickers list only top-level user-launched workflows, not implementation-owned child runs. The `workflow` tool's `stages`, `stage`, `transcript`, `send`, `pause`, `interrupt`, and `resume` actions can still target visible child stage ids, prefixes, or names from the expanded graph; Atomic routes the control action to the owning nested run internally. (`stages`, `stage`, `transcript`, and `send` are `workflow` tool actions, not `/workflow` slash subcommands; the slash command exposes `connect`, `attach`, `pause`, `list`, `status`, `interrupt`, `kill`, `resume`, `reload`, and `inputs`.)

Expand Down
23 changes: 19 additions & 4 deletions packages/workflows/src/durable/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,13 @@ export interface DurableWorkflowBackend {
/** Look up a cached stage output by replay key. */
getStageOutput(workflowId: string, replayKey: string): WorkflowSerializableValue | undefined;

/** Look up resumable stage session metadata by replay key, when available. */
getStageSession(workflowId: string, replayKey: string): { sessionId?: string; sessionFile?: string } | undefined;
/** Look up the latest resumable stage session and accumulated active timing. */
getStageSession(workflowId: string, replayKey: string): {
sessionId?: string;
sessionFile?: string;
startedAt?: number;
durationMs?: number;
} | undefined;

/** List all checkpoints for a workflow (in completion order). */
listCheckpoints(workflowId: string): readonly DurableCheckpoint[];
Expand Down Expand Up @@ -249,10 +254,20 @@ export class InMemoryDurableBackend implements DurableWorkflowBackend {
return checkpoint !== undefined && "output" in checkpoint ? checkpoint.output : undefined;
}

getStageSession(workflowId: string, replayKey: string): { sessionId?: string; sessionFile?: string } | undefined {
getStageSession(workflowId: string, replayKey: string): {
sessionId?: string;
sessionFile?: string;
startedAt?: number;
durationMs?: number;
} | undefined {
const checkpoint = this.workflows.get(workflowId)?.stageSessionByReplayKey.get(replayKey);
if (checkpoint?.sessionId === undefined && checkpoint?.sessionFile === undefined) return undefined;
return { ...(checkpoint.sessionId !== undefined ? { sessionId: checkpoint.sessionId } : {}), ...(checkpoint.sessionFile !== undefined ? { sessionFile: checkpoint.sessionFile } : {}) };
return {
...(checkpoint.sessionId !== undefined ? { sessionId: checkpoint.sessionId } : {}),
...(checkpoint.sessionFile !== undefined ? { sessionFile: checkpoint.sessionFile } : {}),
...(checkpoint.startedAt !== undefined ? { startedAt: checkpoint.startedAt } : {}),
...(checkpoint.durationMs !== undefined ? { durationMs: checkpoint.durationMs } : {}),
};
}

listCheckpoints(workflowId: string): readonly DurableCheckpoint[] {
Expand Down
7 changes: 6 additions & 1 deletion packages/workflows/src/durable/scoped-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ export class ScopedDurableBackend implements DurableWorkflowBackend {
return this.inner.getStageOutput(this.scope.rootWorkflowId, this.scopeKey(replayKey));
}

getStageSession(_workflowId: string, replayKey: string): { sessionId?: string; sessionFile?: string } | undefined {
getStageSession(_workflowId: string, replayKey: string): {
sessionId?: string;
sessionFile?: string;
startedAt?: number;
durationMs?: number;
} | undefined {
return this.inner.getStageSession(this.scope.rootWorkflowId, this.scopeKey(replayKey));
}

Expand Down
37 changes: 29 additions & 8 deletions packages/workflows/src/durable/stage-primitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { DurableWorkflowBackend } from "./backend.js";
import type { ParallelFailFastScope } from "../runs/foreground/executor-types.js";
import { durableHash } from "./backend.js";
import { recordCheckpointDurably } from "./tool-primitive.js";
import { elapsedStageMs } from "../shared/timing.js";
import { RESUME_CONTINUATION_PROMPT } from "../shared/resume-continuation.js";
import type { DurableStageCheckpoint } from "./types.js";
export type DurableCompletedStageCheckpoint = DurableStageCheckpoint & { readonly output: WorkflowSerializableValue };
Expand All @@ -17,6 +18,7 @@ export interface DurableStageDeps {
readonly nextCheckpointId: () => string;
readonly nextReplayKey: (stageName: string) => string;
readonly replayKeyForCompletedStage?: (stage: StageSnapshot) => string | undefined;
readonly now?: () => number;
}

export async function recordStageCheckpoint(deps: DurableStageDeps, stage: StageSnapshot): Promise<boolean> {
Expand Down Expand Up @@ -51,17 +53,25 @@ export async function recordStageCheckpoint(deps: DurableStageDeps, stage: Stage
export async function recordStageSessionCheckpoint(deps: DurableStageDeps, stage: StageSnapshot): Promise<boolean> {
const replayKey = deps.replayKeyForCompletedStage?.(stage) ?? stage.replayKey ?? deps.nextReplayKey(stage.name);
if (stage.sessionFile === undefined) return false;
const checkpointNow = deps.now?.() ?? Date.now();
const durationMs = elapsedStageMs(stage, checkpointNow) ?? 0;
const current = deps.backend.getStageSession(deps.workflowId, replayKey);
if (current?.sessionFile === stage.sessionFile) return false;
if (current !== undefined
&& current.sessionId === stage.sessionId
&& current.sessionFile === stage.sessionFile
&& current.startedAt === stage.startedAt
&& current.durationMs === durationMs) return false;
const checkpoint: DurableStageCheckpoint = {
kind: "stage",
workflowId: deps.workflowId,
checkpointId: stageSessionCheckpointId(replayKey, stage),
checkpointId: stageSessionCheckpointId(replayKey, stage, durationMs),
name: stage.name,
replayKey,
...(stage.sessionId !== undefined ? { sessionId: stage.sessionId } : {}),
sessionFile: stage.sessionFile,
completedAt: Date.now(),
...(stage.startedAt !== undefined ? { startedAt: stage.startedAt } : {}),
durationMs,
completedAt: checkpointNow,
};
await recordCheckpointDurably(deps.backend, checkpoint);
return true;
Expand Down Expand Up @@ -107,7 +117,10 @@ export function createDurableStagePrimitive(input: {
const liveOptions: StageOptions | undefined = {
...(options ?? {}),
durableReplayKey: replayKey,
...(isMidSessionResume ? { resumeFromSessionFile: session.sessionFile } : {}),
...(isMidSessionResume ? {
resumeFromSessionFile: session.sessionFile,
durableAccumulatedDurationMs: session.durationMs ?? 0,
} : {}),
};
const live = withMidSessionResumePrompt(input.stage(name, liveOptions, replayKey), isMidSessionResume);
if (options?.schema === undefined) return live;
Expand Down Expand Up @@ -139,7 +152,10 @@ export function createDurableTaskPrimitive(input: {
const taskOptions: WorkflowTaskOptions = {
...options,
durableReplayKey: replayKey,
...(session?.sessionFile !== undefined ? { resumeFromSessionFile: session.sessionFile } : {}),
...(session?.sessionFile !== undefined ? {
resumeFromSessionFile: session.sessionFile,
durableAccumulatedDurationMs: session.durationMs ?? 0,
} : {}),
};
const result = await input.task(name, taskOptions, stageFailFastScope);
await recordCheckpointDurably(input.backend, {
Expand Down Expand Up @@ -299,8 +315,11 @@ function metadataValue<K extends keyof DurableStageCheckpoint>(
checkpoints: readonly DurableStageCheckpoint[],
key: K,
): Pick<DurableStageCheckpoint, K> | Record<string, never> {
const value = checkpoints.find((checkpoint) => checkpoint[key] !== undefined)?.[key];
return value === undefined ? {} : { [key]: value } as Pick<DurableStageCheckpoint, K>;
for (let index = checkpoints.length - 1; index >= 0; index -= 1) {
const value = checkpoints[index]?.[key];
if (value !== undefined) return { [key]: value } as Pick<DurableStageCheckpoint, K>;
}
return {};
}


Expand All @@ -317,10 +336,12 @@ export function stableCheckpointId(kind: string, replayKey: string): string {
return `${kind}:${replayKey}`;
}

function stageSessionCheckpointId(replayKey: string, stage: StageSnapshot): string {
function stageSessionCheckpointId(replayKey: string, stage: StageSnapshot, durationMs: number): string {
return `${stableCheckpointId("stage-session", replayKey)}:${durableHash({
sessionId: stage.sessionId ?? "",
sessionFile: stage.sessionFile ?? "",
startedAt: stage.startedAt ?? 0,
durationMs,
})}`;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { raceAbort } from "./executor-abort.js";
import { hasExplicitFastModeCandidate } from "./executor-direct-helpers.js";
import { applyFailureToStage } from "./executor-lifecycle.js";
import { isTerminalStage } from "./executor-scheduler.js";
import { rebasedStageStartedAt } from "../../shared/timing.js";

export interface TrackedStageCallOptions {
readonly eagerSession?: boolean;
Expand Down Expand Up @@ -110,8 +111,9 @@ export function createTrackedStageCaller(input: {
}
}
if (trackStageLifecycle) {
const now = Date.now();
runtime.stageSnapshot.status = "running";
runtime.stageSnapshot.startedAt = Date.now();
runtime.stageSnapshot.startedAt ??= rebasedStageStartedAt(input.options?.durableAccumulatedDurationMs, now);
const hasNoExplicitModelConfig = input.options?.model === undefined && input.options?.fallbackModels === undefined;
const promptAdapterHandlesInitialPrompt = input.adapters.prompt !== undefined;
if (callOptions.eagerSession && !promptAdapterHandlesInitialPrompt && (hasNoExplicitModelConfig || await hasExplicitFastModeCandidate({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function stripWorkflowOnlyOptions(
forkFromSessionFile,
resumeFromSessionFile,
durableReplayKey: _durableReplayKey,
durableAccumulatedDurationMs: _durableAccumulatedDurationMs,
sessionDir,
gitWorktreeDir: _gitWorktreeDir,
baseBranch: _baseBranch,
Expand Down
4 changes: 4 additions & 0 deletions packages/workflows/src/shared/timing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ function nonNegative(ms: number): number {
return Math.max(0, ms);
}

export function rebasedStageStartedAt(accumulatedDurationMs: number | undefined, resumedAt: number): number {
return resumedAt - nonNegative(accumulatedDurationMs ?? 0);
}

function elapsedFromStart(
startedAt: number,
now: number,
Expand Down
2 changes: 2 additions & 0 deletions packages/workflows/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ export interface StageOptions<TSchemaDef extends TSchema | undefined = TSchema |
resumeFromSessionFile?: string;
/** Internal durable replay key used to map a live LM session to durable resume state. */
durableReplayKey?: string;
/** Internal durable timing baseline accumulated before a process-boundary resume. */
durableAccumulatedDurationMs?: number;
}

// ---------------------------------------------------------------------------
Expand Down
19 changes: 19 additions & 0 deletions test/unit/durable-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,25 @@ describe("FileDurableBackend", () => {
assert.equal(backend2.getWorkflow(WORKFLOW_ID)!.name, "file-workflow");
});

test("preserves latest active-stage timing across a file process boundary", () => {
const replayKey = "stage:analyze:1";
backend.recordCheckpoint({
kind: "stage", workflowId: WORKFLOW_ID, checkpointId: "stage-session:1", name: "analyze", replayKey,
sessionFile: "/tmp/analyze.jsonl", startedAt: 1000, durationMs: 400, completedAt: 1400,
});
backend.recordCheckpoint({
kind: "stage", workflowId: WORKFLOW_ID, checkpointId: "stage-session:2", name: "analyze", replayKey,
sessionFile: "/tmp/analyze.jsonl", startedAt: 1000, durationMs: 750, completedAt: 1750,
});

const fresh = new FileDurableBackend(join(tmpDir, "state.json"));
assert.deepEqual(fresh.getStageSession(WORKFLOW_ID, replayKey), {
sessionFile: "/tmp/analyze.jsonl",
startedAt: 1000,
durationMs: 750,
});
});

test("lists resumable workflows from a new backend instance", () => {
backend.recordCheckpoint(makeToolCheckpoint(WORKFLOW_ID, "progress", "h-progress", "done"));
backend.setWorkflowStatus(WORKFLOW_ID, "paused");
Expand Down
Loading
Loading