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
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` experience to mix successful completed workflows into the existing globally newest-first, deduplicated picker with green completed styling, resolve full IDs and prefixes across live, durable, and completed targets, and reopen retained stage chats for follow-up without re-running workflow code or replaying side effects. Completed durable state is retained for authoritative inspection; rows need checkpoints and at least one strictly valid retained conversation, invalid per-stage transcript paths cannot open chat, repeated inspection refreshes changed authoritative chat handles, and selector mount failures close safely. ([#1532](https://github.com/bastani-inc/atomic/issues/1532))

## [0.9.8] - 2026-07-12

### Changed
Expand Down
18 changes: 11 additions & 7 deletions packages/coding-agent/docs/workflows.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/coding-agent/src/core/session-manager-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export interface SessionInfo {
messageCount: number;
firstMessage: string;
allMessagesText: string;
/** Optional semantic color for synthetic selector rows. */
messageColor?: "success" | "warning" | "accent";
}

export interface ContextDeletionFilters {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,13 @@ export class SessionList implements Component, Focusable {
const truncatedMsg = truncateToWidth(normalizedMessage, Math.max(10, availableForMsg), "…");

// Style message
let messageColor: "error" | "warning" | "accent" | null = null;
let messageColor: "error" | "warning" | "accent" | "success" | null = null;
if (isConfirmingDelete) {
messageColor = "error";
} else if (isCurrent) {
messageColor = "accent";
} else if (session.messageColor !== undefined) {
messageColor = session.messageColor;
} else if (hasName) {
messageColor = "warning";
}
Expand Down
1 change: 1 addition & 0 deletions packages/workflows/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Fixed the builtin `open-claude-design` artifact directory fallback to use a per-user OS tmpdir namespace (`open-claude-design-<username>`), preventing `EACCES` failures on shared hosts where another user already owns the plain `<tmpdir>/open-claude-design` directory. The legacy shared path remains a secondary fallback, and the last-resort synthesized path now also uses the per-user namespace so best-effort feedback persistence can actually create it.
- Prevented the synced Impeccable fixture's nested `git init` from inheriting hook-local Git repository variables and corrupting a linked repository's shared `core.worktree`. Runner-managed reusable worktrees now remap propagated invoking-repository `cwd` values and relative direct outputs into the selected worktree; reject blank, self, nested, foreign, lexical-escape, symlink-escape, and changed cached targets before opening a later session; key cached identity by canonical repository/target across equivalent path and ref spellings; revalidate newly created targets; and expose clearer workflow tool guidance/schema descriptions so natural-language worktree requests cannot be mistaken for runtime configuration. Temporary direct task and parallel worktrees also derive omitted/relative task cwd values from the runner invocation cwd and clean up when startup fails before their workflow callback, while relative outputs are persisted to stable runner-owned artifacts before cleanup and reject parent traversal, child symlink escapes, or a linked trusted artifact root across direct, parallel, and chain modes; blank `chainDir` values no longer bypass reusable-worktree output routing.
- Fixed the active Workflow Orchestrator graph pane to pan left and right for horizontal trackpad and terminal mouse-wheel events while preserving vertical wheel panning and pane-local input capture ([#1756](https://github.com/bastani-inc/atomic/issues/1756)).
- 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))

## [0.9.8] - 2026-07-12

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 root workflows with durable checkpoint progress. */
listCompletedWorkflows(): readonly ResumableWorkflowEntry[];

/** Export a session-cache entry for the given workflow (for JSONL persistence). */
toCacheEntry(workflowId: string): DurableCheckpointEntry | undefined;
Expand Down Expand Up @@ -274,6 +276,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 @@ -333,6 +341,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
245 changes: 245 additions & 0 deletions packages/workflows/src/durable/completed-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { readFileSync, statSync } from "node:fs";
import type { RunSnapshot, StageSnapshot } from "../shared/store-types.js";
import type { WorkflowInputValues } from "../shared/types.js";
import type { DurableWorkflowBackend } from "./backend.js";
import type {
DurableCheckpoint,
DurableStageCheckpoint,
ResumableWorkflowEntry,
} from "./types.js";
import { resolveDurableEntry } from "./resume-runtime.js";

export type CompletedWorkflowResolution =
| { readonly kind: "found"; readonly entry: ResumableWorkflowEntry; readonly snapshot: RunSnapshot }
| { readonly kind: "ambiguous"; readonly matches: readonly ResumableWorkflowEntry[] }
| { readonly kind: "not_found" }
| { readonly kind: "stale"; readonly entry: ResumableWorkflowEntry };

interface SessionTranscriptEntry {
readonly type?: string;
readonly id?: string;
readonly timestamp?: string;
readonly message?: {
readonly role?: string;
readonly content?: string | object;
};
}

interface StageDraft {
readonly replayKey: string;
readonly name: string;
readonly firstCompletedAt: number;
readonly output?: DurableStageCheckpoint["output"];
readonly result?: string;
readonly sessionId?: string;
readonly sessionFile?: string;
readonly startedAt?: number;
readonly endedAt?: number;
readonly durationMs?: number;
readonly model?: string;
readonly fastMode?: boolean;
readonly attemptedModels?: readonly string[];
readonly modelAttempts?: DurableStageCheckpoint["modelAttempts"];
}

/** Authoritative completed rows. This path is deliberately separate from resumability. */
export function listCompletedFromBackend(
backend: DurableWorkflowBackend,
): readonly ResumableWorkflowEntry[] {
return backend.listCompletedWorkflows();
}

/** Completed rows whose authoritative checkpoints and referenced transcripts still exist. */
export function listOpenableCompletedWorkflows(
backend: DurableWorkflowBackend,
): readonly ResumableWorkflowEntry[] {
return listCompletedFromBackend(backend)
.filter((entry) => completedWorkflowSnapshot(backend, entry) !== undefined)
.sort((a, b) => b.updatedAt - a.updatedAt);
}

export function resolveCompletedWorkflow(
workflowIdOrPrefix: string,
backend: DurableWorkflowBackend,
openableCatalog: readonly ResumableWorkflowEntry[] = listOpenableCompletedWorkflows(backend),
): CompletedWorkflowResolution {
const resolved = resolveDurableEntry(workflowIdOrPrefix, openableCatalog);
if (resolved !== undefined) {
if ("kind" in resolved) return { kind: "ambiguous", matches: resolved.matches };
const snapshot = completedWorkflowSnapshot(backend, resolved);
return snapshot === undefined
? { kind: "stale", entry: resolved }
: { kind: "found", entry: resolved, snapshot };
}

const authoritative = resolveDurableEntry(workflowIdOrPrefix, listCompletedFromBackend(backend));
if (authoritative === undefined) return { kind: "not_found" };
if ("kind" in authoritative) return { kind: "ambiguous", matches: authoritative.matches };
return { kind: "stale", entry: authoritative };
}

export 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;
const stages = stageSnapshotsFromCheckpoints(checkpoints, handle.updatedAt).map(validatedStageTranscript);
if (!stages.some((stage) => stage.sessionFile !== undefined)) return undefined;

return {
id: handle.workflowId,
name: handle.name,
inputs: { ...handle.inputs } as WorkflowInputValues,
status: "completed",
stages,
startedAt: handle.createdAt,
endedAt: handle.updatedAt,
durationMs: Math.max(0, handle.updatedAt - handle.createdAt),
resumable: false,
};
}

function validatedStageTranscript(stage: StageSnapshot): StageSnapshot {
if (stage.sessionFile === undefined || isReopenableSessionTranscript(stage.sessionFile)) return stage;
const { sessionFile, ...withoutSessionFile } = stage;
void sessionFile;
return withoutSessionFile;
}

function isReopenableSessionTranscript(path: string): boolean {
try {
const stats = statSync(path);
if (!stats.isFile() || stats.size === 0) return false;
const lines = readFileSync(path, "utf8").split("\n").filter((line) => line.trim().length > 0);
if (lines.length < 2) return false;
const entries: SessionTranscriptEntry[] = [];
for (const line of lines) {
const parsed = JSON.parse(line) as object;
if (typeof parsed !== "object" || parsed === null) return false;
entries.push(parsed as SessionTranscriptEntry);
}
const header = entries[0];
return header?.type === "session" && typeof header.id === "string" && entries.some(isUsableContextMessage);
} catch {
return false;
}
}

function isUsableContextMessage(entry: SessionTranscriptEntry): boolean {
return entry.type === "message"
&& typeof entry.id === "string"
&& typeof entry.timestamp === "string"
&& typeof entry.message?.role === "string"
&& hasUsableMessageContent(entry.message.content);
}

function hasUsableMessageContent(content: string | object | undefined): boolean {
if (typeof content === "string") return content.trim().length > 0;
return Array.isArray(content) && content.some(hasUsableContentBlock);
}

function hasUsableContentBlock(block: object): boolean {
if (typeof block !== "object" || block === null) return false;
const contentBlock = block as {
readonly text?: string;
readonly thinking?: string;
readonly data?: string;
readonly name?: string;
};
return [contentBlock.text, contentBlock.thinking, contentBlock.data, contentBlock.name]
.some((value) => typeof value === "string" && value.trim().length > 0);
}

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 existing = drafts.get(checkpoint.replayKey);
drafts.set(checkpoint.replayKey, mergeStageDraft(existing, checkpoint));
}
const ordered = [...drafts.values()].sort((a, b) => a.firstCompletedAt - b.firstCompletedAt);
if (ordered.length === 0) return [syntheticCheckpointStage(checkpoints.length, fallbackCompletedAt)];
return ordered.map(stageSnapshotFromDraft);
}

function mergeStageDraft(
existing: StageDraft | undefined,
checkpoint: DurableStageCheckpoint,
): StageDraft {
return {
replayKey: checkpoint.replayKey,
name: existing?.name ?? checkpoint.name,
firstCompletedAt: Math.min(existing?.firstCompletedAt ?? checkpoint.completedAt, checkpoint.completedAt),
...valueOrExisting("output", checkpoint, existing),
...valueOrExisting("result", checkpoint, existing),
...valueOrExisting("sessionId", checkpoint, existing),
...valueOrExisting("sessionFile", checkpoint, existing),
...valueOrExisting("startedAt", checkpoint, existing),
...valueOrExisting("endedAt", checkpoint, existing),
...valueOrExisting("durationMs", checkpoint, existing),
...valueOrExisting("model", checkpoint, existing),
...valueOrExisting("fastMode", checkpoint, existing),
...valueOrExisting("attemptedModels", checkpoint, existing),
...valueOrExisting("modelAttempts", checkpoint, existing),
};
}

function valueOrExisting<
K extends keyof Omit<StageDraft, "replayKey" | "name" | "firstCompletedAt">
>(key: K, checkpoint: DurableStageCheckpoint, existing: StageDraft | undefined): Pick<StageDraft, K> | object {
const checkpointValue = checkpoint[key];
if (checkpointValue !== undefined) return { [key]: checkpointValue } as Pick<StageDraft, K>;
const existingValue = existing?.[key];
return existingValue === undefined ? {} : { [key]: existingValue } as Pick<StageDraft, K>;
}

function stageSnapshotFromDraft(draft: StageDraft, index: number): StageSnapshot {
const startedAt = draft.startedAt ?? draft.firstCompletedAt;
const endedAt = draft.endedAt ?? draft.firstCompletedAt;
return {
id: `completed-stage-${index + 1}`,
name: draft.name,
status: "completed",
parentIds: [],
startedAt,
endedAt,
durationMs: draft.durationMs ?? Math.max(0, endedAt - startedAt),
...(stageResult(draft) !== undefined ? { result: stageResult(draft) } : {}),
replayKey: draft.replayKey,
toolEvents: [],
attachable: false,
...(draft.sessionId !== undefined ? { sessionId: draft.sessionId } : {}),
...(draft.sessionFile !== undefined ? { sessionFile: draft.sessionFile } : {}),
...(draft.model !== undefined ? { model: draft.model } : {}),
...(draft.fastMode !== undefined ? { fastMode: draft.fastMode } : {}),
...(draft.attemptedModels !== undefined ? { attemptedModels: draft.attemptedModels } : {}),
...(draft.modelAttempts !== undefined ? { modelAttempts: draft.modelAttempts } : {}),
};
}

function stageResult(draft: StageDraft): string | undefined {
if (draft.result !== undefined) return draft.result;
if (draft.output === undefined) return undefined;
return typeof draft.output === "string" ? draft.output : JSON.stringify(draft.output);
}

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