feat(workflows): unify post-mortem stage chat across attach, restore/replay, and send (#1811) - #1813
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
…replay, and send Generalize the completed-workflow-inspection detached-handle pattern (#1758) into a shared post-mortem stage-chat resolver so any eligible terminal agent stage with a valid retained Atomic session reopens as an interactive follow-up conversation — not only completed-inspection, but also generic /workflow attach and /workflow connect, restored/replayed durable snapshots after a process restart, and workflow send. Adds ensurePostMortemStageHandle: reuses an existing non-disposed handle, validates the retained sessionFile is an existing readable context-bearing transcript, and lazily reopens it through a detached, single-flight StageControlRegistry.getOrCreateDetached handle keyed by the real runId/stageId. Follow-ups append in place; run/stage status, results, timings, checkpoints, replay metadata, and graph topology stay immutable, and post-mortem chat can never resume/retry/rewind/pause/re-dispatch execution. Stages without a valid retained agent session keep the read-only transcript; recoverably failed stages keep execution-resume semantics. Refs: #1811 Assistant-model: Claude Opus 4.8
Assistant-model: GPT-5.6 Sol
Assistant-model: GPT-5.6 Sol
Route automatic delivery on a streaming post-mortem stage to follow-up instead of rejecting it as an explicit execution steer. Assistant-model: GPT-5.6 Sol
Resolve exact compatible live workflow IDs before enumerating the completed durable catalog, avoiding unbounded resume latency from unrelated retained runs. Assistant-model: GPT-5.6 Sol
4419c86 to
005c2e3
Compare
Merge latest origin/main while retaining post-mortem stage chat behavior. Keep exact paused top-level resume on the live fast path, exclude nested child runs from top-level resolution, and isolate durable-state regression tests from user history. Assistant-model: GPT-5.6 Sol
Merge latest origin/main while retaining the top-level resume fast path and nested-child exclusion. Route nested stage attachment through its owning run, surface retained-session unavailability, and dispose sessions created after registry teardown without delaying normal attachment. Assistant-model: GPT-5.6 Sol
Initialize slash-command resources before seeding the shared workflow store so concurrent test cleanup cannot erase the nested attach fixture during module loading. Assistant-model: GPT-5.6 Sol
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
Merge latest origin/main while retaining the resume and post-mortem fixes. Carry the resolved child run through overlay attachment when sibling stages share an ID, and budget unavailable-session callouts from their wrapped height so narrow terminals retain the full explanation. Assistant-model: GPT-5.6 Sol
Assistant-model: GPT-5.6 Sol
Executable behavior proof — PASS ✅ (updated non-circular runtime)Exact PR head: Fresh remote verification: This updated toy imports the checkout's production workflow engine, durable backend/checkpoints, resolver, Run: bun /tmp/atomic-pr1813-toy-proof.tsObserved twice successfully on Darwin arm64 / macOS 26.5.2 / Bun 1.3.14; latest output: Asserted invariants:
Independent source tracing confirmed the marker is constructed by production Complete self-contained toy sourceimport assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const REPO = "/Users/tonystark/Documents/projects/atomic-pr-1813-rebase-main";
const MARKER = "PR1813_POSTMORTEM_MARKER_7f3d9a";
const root = mkdtempSync(join(tmpdir(), "atomic-pr1813-proof-"));
process.env.HOME = join(root, "home");
process.env.ATOMIC_HOME = join(root, "atomic-home");
mkdirSync(process.env.HOME, { recursive: true });
mkdirSync(process.env.ATOMIC_HOME, { recursive: true });
const { workflow } = await import(`${REPO}/packages/workflows/src/authoring/workflow.ts`);
const { run } = await import(`${REPO}/packages/workflows/src/runs/foreground/executor.ts`);
const { store } = await import(`${REPO}/packages/workflows/src/shared/store.ts`);
const { InMemoryDurableBackend } = await import(`${REPO}/packages/workflows/src/durable/backend.ts`);
const { setDurableBackend } = await import(`${REPO}/packages/workflows/src/durable/factory.ts`);
const { workflowSendAction } = await import(`${REPO}/packages/workflows/src/extension/workflow-tool-send.ts`);
const { postMortemDepsForRun } = await import(`${REPO}/packages/workflows/src/extension/postmortem-deps.ts`);
const { ensurePostMortemStageHandle } = await import(`${REPO}/packages/workflows/src/runs/foreground/postmortem-stage-chat.ts`);
const { stageControlRegistry } = await import(`${REPO}/packages/workflows/src/runs/foreground/stage-control-registry.ts`);
const extensionModule = await import(`${REPO}/packages/workflows/src/extension/index.ts`);
const { createAgentSession } = await import(`${REPO}/packages/coding-agent/src/core/sdk.ts`);
const { AuthStorage } = await import(`${REPO}/packages/coding-agent/src/core/auth-storage.ts`);
const { ModelRegistry } = await import(`${REPO}/packages/coding-agent/src/core/model-registry.ts`);
const { createAssistantMessageEventStream } = await import(`${REPO}/node_modules/@earendil-works/pi-ai/dist/compat.js`);
const cwd = join(root, "workflow-cwd");
const sessions = join(root, "sessions");
const agentDir = join(root, "agent");
mkdirSync(cwd, { recursive: true });
mkdirSync(sessions, { recursive: true });
mkdirSync(agentDir, { recursive: true });
let retainedSessionFile: string | undefined;
function boundarySession(hooks: { prompt?: (text: string) => void; dispose?: () => void } = {}) {
return {
sessionFile: retainedSessionFile,
sessionId: "boundary-session",
agent: undefined,
model: undefined,
thinkingLevel: "off",
messages: [],
isStreaming: false,
async prompt(text: string) { hooks.prompt?.(text); },
async followUp() {},
async steer() {},
subscribe() { return () => {}; },
async setModel() {}, setThinkingLevel() {}, async cycleModel() { return undefined; }, cycleThinkingLevel() { return undefined; },
async navigateTree() { return { cancelled: false }; }, async compact() { return {}; }, abortCompaction() {}, async abort() {},
dispose() { hooks.dispose?.(); },
getLastAssistantText() { return "boundary"; },
};
}
const fauxModel = {
id: "pr1813-faux", name: "PR1813 Faux Model", api: "anthropic-messages", provider: "pr1813-faux",
baseUrl: "http://127.0.0.1:1", reasoning: false, input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 4096,
} as const;
function deterministicModelStream(text: string) {
const stream = createAssistantMessageEventStream();
stream.end({
role: "assistant", content: [{ type: "text", text }], api: fauxModel.api, provider: fauxModel.provider, model: fauxModel.id,
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop", timestamp: Date.now(),
});
return stream;
}
try {
store.clear();
stageControlRegistry.clear();
const backend = new InMemoryDurableBackend();
setDurableBackend(backend);
const counts = { workflowBodies: 0, sessionCreates: 0, modelCalls: 0 };
const createCwds: string[] = [];
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
authStorage.setRuntimeApiKey(fauxModel.provider, "deterministic-test-key");
const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json"));
const adapters = {
agentSession: {
async create(options: { cwd?: string; sessionManager?: { getEntries?: () => readonly unknown[] } }) {
counts.sessionCreates += 1;
createCwds.push(options.cwd ?? "<undefined>");
if (counts.sessionCreates > 1) assert.ok((options.sessionManager?.getEntries?.().length ?? 0) >= 2, "production SessionManager.open restored transcript entries");
const { session } = await createAgentSession({ ...options, cwd: options.cwd ?? cwd, agentDir, model: fauxModel, authStorage, modelRegistry, tools: [] });
session.agent.streamFn = () => {
counts.modelCalls += 1;
return deterministicModelStream(counts.modelCalls === 1 ? "toy-stage-complete" : "toy-follow-up-complete");
};
return session;
},
},
};
const def = workflow({
name: "pr1813-toy",
description: "one completed agent stage with a retained session",
inputs: {},
outputs: {},
async run(ctx: { stage(name: string): { prompt(text: string): Promise<string> } }) {
counts.workflowBodies += 1;
await ctx.stage("toy-agent").prompt("Complete once");
return {};
},
});
const runId = "pr1813-toy-run";
const result = await run(def, {}, { runId, cwd, defaultSessionDir: sessions, adapters, store });
assert.equal(result.status, "completed");
const completed = store.runs().find((candidate: { id: string }) => candidate.id === runId)!;
const stage = completed.stages[0]!;
assert.equal(completed.status, "completed");
assert.equal(stage.status, "completed");
assert.equal(typeof stage.sessionFile, "string");
retainedSessionFile = stage.sessionFile;
assert.ok(retainedSessionFile.startsWith(sessions));
assert.deepEqual(counts, { workflowBodies: 1, sessionCreates: 1, modelCalls: 1 });
// Simulate process-local handle loss while retaining durable/store/session state.
stageControlRegistry.clear();
const executionBefore = structuredClone(completed);
const durableBefore = structuredClone(backend.getWorkflow(runId));
const checkpointsBefore = structuredClone(backend.listCheckpoints(runId));
assert.ok(checkpointsBefore.length > 0, "completed toy stage produced a durable checkpoint");
const transcriptBefore = readFileSync(retainedSessionFile, "utf8");
const resolverDeps = () => postMortemDepsForRun(runId, {
adapters,
resolveDefaultStageSessionDir: () => sessions,
});
const follow = await workflowSendAction({ runId, stageId: stage.id, text: MARKER, delivery: "prompt" }, { resolvePostMortemDeps: resolverDeps });
const transcriptAfter = readFileSync(retainedSessionFile, "utf8");
assert.deepEqual({ status: follow.status, delivery: follow.delivery }, { status: "ok", delivery: "prompt" });
assert.equal(transcriptAfter.slice(transcriptBefore.length).includes(MARKER), true);
assert.equal(transcriptAfter.match(new RegExp(MARKER, "g"))?.length, 1);
assert.deepEqual(store.runs().find((candidate: { id: string }) => candidate.id === runId), executionBefore);
assert.deepEqual(backend.getWorkflow(runId), durableBefore);
assert.deepEqual(backend.listCheckpoints(runId), checkpointsBefore);
assert.deepEqual(counts, { workflowBodies: 1, sessionCreates: 2, modelCalls: 2 });
assert.deepEqual(stageControlRegistry.run(runId).stages(), [], "post-mortem handle stays detached from execution controls");
const resume = await workflowSendAction({ runId, stageId: stage.id, text: "do not redispatch", delivery: "resume" }, { resolvePostMortemDeps: resolverDeps });
assert.equal(resume.status, "noop");
assert.match(resume.message, /Cannot resume a terminal post-mortem stage/);
assert.deepEqual(store.runs().find((candidate: { id: string }) => candidate.id === runId), executionBefore);
assert.deepEqual(backend.getWorkflow(runId), durableBefore);
assert.deepEqual(counts, { workflowBodies: 1, sessionCreates: 2, modelCalls: 2 });
assert.deepEqual(backend.listCheckpoints(runId), checkpointsBefore);
console.log("PRIMARY PASS", JSON.stringify({
run: completed.status,
stage: stage.status,
followUp: follow,
resume,
markerCount: transcriptAfter.match(new RegExp(MARKER, "g"))?.length,
workflowDispatches: counts.workflowBodies,
sessionCreates: counts.sessionCreates,
modelCalls: counts.modelCalls,
executionStateUnchanged: true,
durableCheckpointStateUnchanged: true,
durableCheckpointCount: checkpointsBefore.length,
detachedFromExecutionControl: true,
}));
// Safety proof 1: real extension session_shutdown handler clears a pending detached handle.
let shutdown: ((event: { reason: string }) => unknown) | undefined;
extensionModule.default({
registerTool() {}, registerCommand() {}, registerMessageRenderer() {}, registerFlag() {}, registerShortcut() {},
on(event: string, handler: (event: { reason: string }) => unknown) { if (event === "session_shutdown") shutdown = handler; },
disableAsyncDiscovery: true,
});
assert.ok(shutdown);
stageControlRegistry.clear();
const creationStarted = Promise.withResolvers<void>();
const created = Promise.withResolvers<ReturnType<typeof boundarySession>>();
let boundaryPromptCalls = 0;
let boundaryDisposeCalls = 0;
const pending = ensurePostMortemStageHandle("boundary-run", { ...stage, id: "boundary-stage" }, {
registry: stageControlRegistry,
cwd,
adapters: { agentSession: { async create() { creationStarted.resolve(); return created.promise; } } },
});
assert.equal(pending.ok, true);
assert.ok(pending.ok);
const submitted = pending.handle.prompt("must-not-run-after-reload");
await creationStarted.promise;
await Promise.resolve(shutdown!({ reason: "reload" }));
created.resolve(boundarySession({ prompt() { boundaryPromptCalls += 1; }, dispose() { boundaryDisposeCalls += 1; } }));
await assert.rejects(submitted, /session has been disposed/);
assert.equal(boundaryPromptCalls, 0);
assert.equal(boundaryDisposeCalls, 1);
assert.equal(pending.handle.isDisposed, true);
assert.equal(stageControlRegistry.get("boundary-run", "boundary-stage"), undefined);
console.log("BOUNDARY PASS", JSON.stringify({ reason: "reload", promptCalls: boundaryPromptCalls, disposeCalls: boundaryDisposeCalls, registryHandle: false }));
// Safety proof 2: nested completed stage gets cwd from durable root, while ownership stays child-local.
stageControlRegistry.clear();
const durableRootCwd = join(root, "durable-root-cwd");
mkdirSync(durableRootCwd, { recursive: true });
backend.registerWorkflow({ workflowId: "nested-root", rootWorkflowId: "nested-root", name: "root", inputs: {}, createdAt: 1, status: "completed", invocationCwd: join(root, "wrong-invocation"), workflowCwd: durableRootCwd });
const nestedStage = { ...stage, id: "nested-stage" };
store.recordRunStart({ id: "nested-child", name: "child", inputs: {}, status: "completed", stages: [nestedStage], startedAt: 1, endedAt: 2, parentRunId: "nested-root", parentStageId: "call-child", rootRunId: "nested-root" });
let reopenedCwd: string | undefined;
const nestedDeps = postMortemDepsForRun("nested-child", {
adapters: { agentSession: { async create(options: { cwd?: string }) { reopenedCwd = options.cwd; return boundarySession(); } } },
resolveDefaultStageSessionDir: () => sessions,
});
const nested = ensurePostMortemStageHandle("nested-child", nestedStage, nestedDeps);
assert.equal(nested.ok, true);
assert.ok(nested.ok);
await nested.handle.ensureAttached();
assert.equal(nestedDeps.cwd, durableRootCwd);
assert.equal(reopenedCwd, durableRootCwd);
assert.equal(stageControlRegistry.get("nested-child", "nested-stage"), nested.handle);
assert.equal(stageControlRegistry.get("nested-root", "nested-stage"), undefined);
console.log("NESTED CWD PASS", JSON.stringify({ expected: durableRootCwd, actual: reopenedCwd, owner: "nested-child", rootAliasPresent: false }));
console.log("PASS: PR #1813 behavior-level toy proof");
} finally {
stageControlRegistry.clear();
store.clear();
setDurableBackend(undefined);
rmSync(root, { recursive: true, force: true });
}PASS: PR #1813 supports retained-session post-mortem conversation while terminal workflow execution remains read-only, rejects execution resume, and satisfies the session-boundary and durable-root-cwd safeguards. |
Summary
Closes #1811. Generalizes the safe detached-handle pattern from completed durable-workflow inspection (#1758) into a single shared post-mortem stage-chat resolver, so any eligible terminal agent stage with a valid retained Atomic session opens as an interactive follow-up conversation — regardless of how it is reached. This removes the observed inconsistency where interactivity depended on whether the current process still held a
StageControlHandlerather than on whether a valid retained session exists.Now consistently interactive:
task/tasks/chainstages (already were);/workflow resume(already was, feat(workflows): inspect completed runs from resume #1758);/workflow attach//workflow connect;workflow({ action: "send" }).Product semantics preserved
Implementation
shared/session-transcript.ts— extracted retained-session validation (existing, readable, context-bearing transcript), reused by the completed catalog and the resolver.runs/foreground/postmortem-stage-chat.ts—ensurePostMortemStageHandle(runId, stage, deps): reuse existing non-disposed handle, then eligibility (terminal agent stage), retained-session validation, and a lazy detached handle via the existingcreateStageContext({ resumeFromSessionFile })path, keyed and single-flighted by the real{ runId, stageId }(including nested/expanded child stages). Returns an explicit unavailable reason.stage-control-registry.ts—getOrCreateDetachedatomic get-or-create that registers detached from control and disposes a stale/disposed predecessor.durable/completed-inspection.ts— refactored onto the shared handle factory (behavior preserved).extension/postmortem-deps.ts,extension-factory.ts,overlay-adapter.ts,workflow-attach-pane.ts— thread a resolver into the attach pane so a live-handle miss revives before mounting the chat.workflow-tool-send.ts+workflow-tool.ts— on a registry miss, revive via the resolver and deliver as a conversational follow-up instead of reportingNo live handle for stage.LiveStageRuntimeis deliberately not fabricated (no scheduler / store-mutation / exit / finalization dependencies).Tests
postmortem-stage-chat.test.ts(9) — eligibility, retained-session validation (no_adapter/not_terminal/no_session/invalid_session), single-flight, detach from run control, append-in-place without status mutation, and pause/resume rejection without opening or appending to the retained session.stage-control-registry.test.ts(+3) — detached get-or-create, reuse, disposed-handle replacement.workflow-attach-pane-11.test.ts(3) — revival on registry miss, live-handle short-circuit, unavailable read-only fallback.workflow-tool-send-postmortem.test.ts(4) — registry miss + valid session delivers a follow-up (status unchanged); explicitdelivery: "resume"rejects without appending or mutating terminal status; invalid/absent session stays a no-op.Verification
bun run typecheck,bun run lint,bun run check:file-length, andgit diff --checkpass; fullbun run test:unit(3449), fullbun run test:integration(248 passed, 1 environment skip), and the referenced integration suites (overlay-entrypoints-commands,overlay-resume-regressions) are green. The native Windows CI-contract failure was a deterministic test-harness timeout: this process-heavy contract invokes the release verifier twice through temporary Git worktrees and reached Bun’s 5s default (5007.91ms), while Linux and five local repeats passed. The test now has a narrow 30s per-test budget; fresh native Windows CI passed the contract in 3664.87ms and the complete Windows job passed. All commit/push hooks passed.Docs
Updated
packages/coding-agent/docs/workflows.md,packages/workflows/README.md, andpackages/workflows/CHANGELOG.md(Unreleased → Added) documenting the resume-vs-post-mortem distinction, eligible stage types, append-in-place behavior, tool side effects, and read-only fallback conditions.Assistant-model: Claude Opus 4.8
Greptile Summary
This PR unifies post-mortem chat for completed workflow stages across the main inspection and messaging surfaces. The main changes are:
workflow sendpaths that revive valid retained sessions on a live-handle miss.Confidence Score: 5/5
Safe to merge with low risk.
The changed paths validate retained sessions before reopening, keep post-mortem handles detached from workflow execution control, and cover the main attach, lifecycle, and send flows with tests.
No files require special attention.
What T-Rex did
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant User as Attach / workflow send participant Resolver as ensurePostMortemStageHandle participant Registry as StageControlRegistry participant Validator as session-transcript validation participant Context as createStageContext participant Session as Retained Atomic session User->>Registry: get(runId, stageId) alt live handle exists Registry-->>User: existing handle else registry miss User->>Resolver: resolve runId + stage snapshot Resolver->>Validator: isReopenableSessionTranscript(sessionFile) alt valid terminal retained session Resolver->>Registry: getOrCreateDetached(runId, stageId) Registry->>Context: create detached post-mortem handle Context->>Session: lazy reopen from sessionFile on first prompt/followUp Registry-->>User: detached handle else unavailable Resolver-->>User: read-only fallback reason end end User->>Session: append prompt/follow-up in place%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant User as Attach / workflow send participant Resolver as ensurePostMortemStageHandle participant Registry as StageControlRegistry participant Validator as session-transcript validation participant Context as createStageContext participant Session as Retained Atomic session User->>Registry: get(runId, stageId) alt live handle exists Registry-->>User: existing handle else registry miss User->>Resolver: resolve runId + stage snapshot Resolver->>Validator: isReopenableSessionTranscript(sessionFile) alt valid terminal retained session Resolver->>Registry: getOrCreateDetached(runId, stageId) Registry->>Context: create detached post-mortem handle Context->>Session: lazy reopen from sessionFile on first prompt/followUp Registry-->>User: detached handle else unavailable Resolver-->>User: read-only fallback reason end end User->>Session: append prompt/follow-up in placeReviews (10): Last reviewed commit: "fix(workflows): fence post-mortem sessio..." | Re-trigger Greptile