Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- Fixed the bundled workflows `/workflow resume` picker to include successful completed durable workflows, open them for inspection without re-running completed workflow work, and retain completed file-backed durable state while hiding stale completed entries with missing checkpoint/session data ([#1532](https://github.com/bastani-inc/atomic/issues/1532)).

## [0.9.3-alpha.3] - 2026-06-27

### Changed
Expand Down
16 changes: 8 additions & 8 deletions packages/coding-agent/docs/workflows.md

Large diffs are not rendered by default.

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

## [Unreleased]

### Fixed

- Fixed `/workflow resume` so successful completed durable workflows remain listed with `✓ completed` rows and open the completed workflow detail/chat snapshot for inspection instead of flowing through durable replay/re-dispatch. Completed file-backed durable state is retained for openable workflows, while stale completed entries lacking backend checkpoint or referenced session data stay hidden. ([#1532](https://github.com/bastani-inc/atomic/issues/1532))

## [0.9.3-alpha.3] - 2026-06-27

### Changed
Expand Down
12 changes: 12 additions & 0 deletions packages/workflows/src/durable/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export interface DurableWorkflowBackend {
* resumable after failure). Used by the `/workflow resume` selector.
*/
listResumableWorkflows(): readonly ResumableWorkflowEntry[];
/** List successful completed workflows that still have durable checkpoint data. */
listCompletedWorkflows(): readonly ResumableWorkflowEntry[];

/** Export a session-cache entry for the given workflow (for JSONL persistence). */
toCacheEntry(workflowId: string): DurableCheckpointEntry | undefined;
Expand Down Expand Up @@ -270,6 +272,12 @@ export class InMemoryDurableBackend implements DurableWorkflowBackend {
.map((rec) => toResumableEntry(rec.handle));
}

listCompletedWorkflows(): readonly ResumableWorkflowEntry[] {
return [...this.workflows.values()]
.filter((rec) => isRootWorkflow(rec.handle) && isCompletedHandle(rec.handle))
.map((rec) => toResumableEntry(rec.handle));
}

toCacheEntry(workflowId: string): DurableCheckpointEntry | undefined {
const rec = this.workflows.get(workflowId);
if (!rec) return undefined;
Expand Down Expand Up @@ -325,6 +333,10 @@ function isResumableHandle(handle: DurableWorkflowHandle): boolean {
return (handle.status === "running" || handle.status === "paused") && hasResumeProgress(handle);
}

function isCompletedHandle(handle: DurableWorkflowHandle): boolean {
return handle.status === "completed" && hasResumeProgress(handle);
}

function toResumableEntry(handle: DurableWorkflowHandle): ResumableWorkflowEntry {
return {
workflowId: handle.workflowId,
Expand Down
164 changes: 164 additions & 0 deletions packages/workflows/src/durable/completed-open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/** Read-only durable completed workflow opener for `/workflow resume`. */

import { existsSync } from "node:fs";
import type { DurableCheckpoint, DurableStageCheckpoint, ResumableWorkflowEntry, WorkflowSerializableObject } from "./types.js";
import type { DurableWorkflowBackend } from "./backend.js";
import { resolveDurableEntry } from "./resume-runtime.js";
import type { Store } from "../shared/store.js";
import type { RunSnapshot, StageSnapshot } from "../shared/store-types.js";
import type { WorkflowInputValues } from "../shared/types.js";

export type OpenCompletedDurableResult =
| { ok: true; runId: string; workflowId: string; name: string; message: string }
| { ok: false; reason: "not_found" | "ambiguous" | "stale"; message: string };

interface StageDraft {
readonly key: string;
readonly name: string;
readonly firstCompletedAt: number;
readonly output?: DurableStageCheckpoint["output"];
readonly sessionId?: string;
readonly sessionFile?: string;
}

export function listOpenableCompletedWorkflows(backend: DurableWorkflowBackend): readonly ResumableWorkflowEntry[] {
return backend.listCompletedWorkflows().filter((entry) => completedWorkflowSnapshot(backend, entry) !== undefined);
}

export function openCompletedDurableWorkflow(
workflowIdOrPrefix: string,
deps: { readonly durableBackend: DurableWorkflowBackend; readonly store: Store },
catalog?: readonly ResumableWorkflowEntry[],
): OpenCompletedDurableResult {
const backend = deps.durableBackend;
const resolved = resolveDurableEntry(workflowIdOrPrefix, catalog ?? listOpenableCompletedWorkflows(backend));
if (resolved === undefined) {
return { ok: false, reason: "not_found", message: `No completed durable workflow found for id/prefix: ${workflowIdOrPrefix}` };
}
if ("kind" in resolved) {
return {
ok: false,
reason: "ambiguous",
message: `Ambiguous completed workflow prefix "${workflowIdOrPrefix}" matches: ${resolved.matches.map((m) => `${m.name} (${m.workflowId.slice(0, 8)})`).join(", ")}`,
};
}
const snapshot = completedWorkflowSnapshot(backend, resolved);
if (snapshot === undefined) {
return {
ok: false,
reason: "stale",
message: `Completed workflow ${resolved.workflowId.slice(0, 8)} is stale or missing durable checkpoint/session data and cannot be opened.`,
};
}
const existing = deps.store.runs().find((run) => run.id === snapshot.id);
if (existing !== undefined) {
if (existing.status === "completed") {
return completedOpenResult(existing.id, existing.name);
}
if (existing.endedAt === undefined && existing.status !== "paused") {
return {
ok: false,
reason: "stale",
message: `Workflow ${snapshot.id.slice(0, 8)} is already active in this session; attach with /workflow connect ${snapshot.id.slice(0, 8)} instead.`,
};
}
deps.store.removeRun(existing.id);
}
deps.store.recordRunStart(snapshot);
return completedOpenResult(snapshot.id, snapshot.name);
}

function completedOpenResult(runId: string, name: string): OpenCompletedDurableResult {
return {
ok: true,
runId,
workflowId: runId,
name,
message: `Opened completed durable workflow "${name}" (${runId.slice(0, 8)}) for inspection.`,
};
}

function completedWorkflowSnapshot(backend: DurableWorkflowBackend, entry: ResumableWorkflowEntry): RunSnapshot | undefined {
const handle = backend.getWorkflow(entry.workflowId);
if (handle === undefined || handle.status !== "completed") return undefined;
const checkpoints = backend.listCheckpoints(entry.workflowId);
if (checkpoints.length === 0) return undefined;
if (!hasAvailableSessionFiles(checkpoints)) return undefined;
const stages = stageSnapshotsFromCheckpoints(checkpoints, handle.updatedAt);
return {
id: handle.workflowId,
name: handle.name,
inputs: { ...(handle.inputs as WorkflowSerializableObject) } as WorkflowInputValues,
status: "completed",
stages,
startedAt: handle.createdAt,
endedAt: handle.updatedAt,
durationMs: Math.max(0, handle.updatedAt - handle.createdAt),
resumable: false,
};
}

function hasAvailableSessionFiles(checkpoints: readonly DurableCheckpoint[]): boolean {
return checkpoints.every((checkpoint) => {
if (checkpoint.kind !== "stage" || checkpoint.sessionFile === undefined) return true;
return existsSync(checkpoint.sessionFile);
});
}

function stageSnapshotsFromCheckpoints(checkpoints: readonly DurableCheckpoint[], fallbackCompletedAt: number): StageSnapshot[] {
const drafts = new Map<string, StageDraft>();
for (const checkpoint of checkpoints) {
if (checkpoint.kind !== "stage") continue;
const key = checkpoint.replayKey;
const existing = drafts.get(key);
drafts.set(key, {
key,
name: existing?.name ?? checkpoint.name,
firstCompletedAt: Math.min(existing?.firstCompletedAt ?? checkpoint.completedAt, checkpoint.completedAt),
...("output" in checkpoint ? { output: checkpoint.output } : existing?.output !== undefined ? { output: existing.output } : {}),
...(checkpoint.sessionId !== undefined ? { sessionId: checkpoint.sessionId } : existing?.sessionId !== undefined ? { sessionId: existing.sessionId } : {}),
...(checkpoint.sessionFile !== undefined ? { sessionFile: checkpoint.sessionFile } : existing?.sessionFile !== undefined ? { sessionFile: existing.sessionFile } : {}),
});
}
const stageDrafts = [...drafts.values()].sort((a, b) => a.firstCompletedAt - b.firstCompletedAt);
if (stageDrafts.length > 0) return stageDrafts.map((draft, index) => stageSnapshotFromDraft(draft, index));
return [syntheticCheckpointStage(checkpoints.length, fallbackCompletedAt)];
}

function stageSnapshotFromDraft(draft: StageDraft, index: number): StageSnapshot {
return {
id: `durable-stage-${index + 1}`,
name: draft.name,
status: "completed",
parentIds: [],
startedAt: draft.firstCompletedAt,
endedAt: draft.firstCompletedAt,
durationMs: 0,
...(draft.output !== undefined ? { result: stringifyOutput(draft.output) } : {}),
replayKey: draft.key,
toolEvents: [],
attachable: false,
...(draft.sessionId !== undefined ? { sessionId: draft.sessionId } : {}),
...(draft.sessionFile !== undefined ? { sessionFile: draft.sessionFile } : {}),
};
}

function syntheticCheckpointStage(checkpointCount: number, completedAt: number): StageSnapshot {
return {
id: "durable-checkpoints",
name: "durable checkpoints",
status: "completed",
parentIds: [],
startedAt: completedAt,
endedAt: completedAt,
durationMs: 0,
result: `${checkpointCount} durable checkpoint${checkpointCount === 1 ? "" : "s"}`,
toolEvents: [],
attachable: false,
};
}

function stringifyOutput(value: DurableStageCheckpoint["output"]): string {
if (typeof value === "string") return value;
return JSON.stringify(value);
}
1 change: 1 addition & 0 deletions packages/workflows/src/durable/dbos-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export class DbosDurableBackend implements DurableWorkflowBackend {
}

listResumableWorkflows(): readonly ResumableWorkflowEntry[] { return this.mem.listResumableWorkflows(); }
listCompletedWorkflows(): readonly ResumableWorkflowEntry[] { return this.mem.listCompletedWorkflows(); }
toCacheEntry(workflowId: string) { return this.mem.toCacheEntry(workflowId); }
reset(): void { this.mem.reset(); this.hydrated.clear(); this.writeQueue = Promise.resolve(); this.writeErrors = []; }
async flush(): Promise<void> {
Expand Down
13 changes: 12 additions & 1 deletion packages/workflows/src/durable/file-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ export class FileDurableBackend implements DurableWorkflowBackend {
return this.mem.listResumableWorkflows();
}

listCompletedWorkflows() {
this.ensureLoaded();
return this.mem.listCompletedWorkflows();
}

toCacheEntry(workflowId: string) {
this.ensureLoaded();
return this.mem.toCacheEntry(workflowId);
Expand Down Expand Up @@ -194,6 +199,12 @@ export class WorkflowFileDurableBackend implements DurableWorkflowBackend {
return mem.listResumableWorkflows();
}

listCompletedWorkflows() {
const mem = new InMemoryDurableBackend();
mem.importAll(mergeRecords([], this.readAllRecords()));
return mem.listCompletedWorkflows();
}

toCacheEntry(workflowId: string) {
return this.backendFor(workflowId).toCacheEntry(workflowId);
}
Expand Down Expand Up @@ -407,7 +418,7 @@ function chmodBestEffort(path: string, mode: number): void {
}

function isPrunableTerminalStatus(status: DurableWorkflowStatus, resumable?: boolean): boolean {
if (status === "completed" || status === "cancelled") return true;
if (status === "cancelled") return true;
return (status === "failed" || status === "blocked") && resumable === false;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/workflows/src/durable/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ export {
export {
scanResumableWorkflows,
listResumableFromBackend,
listCompletedFromBackend,
persistDurableCacheEntry,
formatResumableWorkflowList,
} from "./resume-catalog.js";
export { listOpenableCompletedWorkflows, openCompletedDurableWorkflow, type OpenCompletedDurableResult } from "./completed-open.js";
export {
createToolPrimitive,
createCheckpointIdGenerator,
Expand Down
9 changes: 7 additions & 2 deletions packages/workflows/src/durable/resume-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ export function listResumableFromBackend(backend: DurableWorkflowBackend): reado
return backend.listResumableWorkflows();
}

export function listCompletedFromBackend(backend: DurableWorkflowBackend): readonly ResumableWorkflowEntry[] {
return backend.listCompletedWorkflows();
}

/**
* Append a durable checkpoint entry to a session JSONL persistence port.
* This caches the top-level workflow metadata so a future session can discover
Expand Down Expand Up @@ -206,12 +210,13 @@ export function persistDurableCacheEntry(
*/
export function formatResumableWorkflowList(entries: readonly ResumableWorkflowEntry[]): string {
if (entries.length === 0) return "No resumable workflows found.";
const hasCompleted = entries.some((e) => e.status === "completed");
const lines = entries.map((e, i) => {
const id = e.workflowId.slice(0, 8);
const status = e.status.padEnd(8);
const status = e.status === "completed" ? "✓ completed" : e.status.padEnd(8);
const checkpoints = `${e.completedCheckpoints} checkpoint${e.completedCheckpoints === 1 ? "" : "s"}`;
const label = e.label ? ` "${e.label}"` : "";
return ` ${i + 1}. ${id} ${status} ${e.name}${label} (${checkpoints})`;
});
return `Resumable workflows:\n${lines.join("\n")}`;
return `${hasCompleted ? "Workflow resume targets" : "Resumable workflows"}:\n${lines.join("\n")}`;
}
9 changes: 7 additions & 2 deletions packages/workflows/src/durable/scoped-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
*
* Only checkpoint read/write methods are scoped. Lifecycle methods
* (`registerWorkflow`, `setWorkflowStatus`, `listResumableWorkflows`,
* `toCacheEntry`, `getWorkflow`) are no-ops for scoped children because child
* runs are never independently resumable — only the root workflow is resumed.
* `listCompletedWorkflows`, `toCacheEntry`, `getWorkflow`) are no-ops for
* scoped children because child runs are never independently resumable — only
* the root workflow is resumed.
*
* cross-ref: issue #1498 — child side effects under the root durable workflow.
*/
Expand Down Expand Up @@ -114,6 +115,10 @@ export class ScopedDurableBackend implements DurableWorkflowBackend {
return [];
}

listCompletedWorkflows(): readonly ResumableWorkflowEntry[] {
return [];
}

toCacheEntry(_workflowId: string): undefined {
return undefined;
}
Expand Down
Loading
Loading