Skip to content

feat(workflows): unify post-mortem stage chat across attach, restore/replay, and send (#1811) - #1813

Merged
flora131 merged 10 commits into
mainfrom
flora131/feature/resume-chat-enabled
Jul 15, 2026
Merged

feat(workflows): unify post-mortem stage chat across attach, restore/replay, and send (#1811)#1813
flora131 merged 10 commits into
mainfrom
flora131/feature/resume-chat-enabled

Conversation

@flora131

@flora131 flora131 commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

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 StageControlHandle rather than on whether a valid retained session exists.

Now consistently interactive:

  • same-process task / tasks / chain stages (already were);
  • completed-workflow inspection via /workflow resume (already was, feat(workflows): inspect completed runs from resume #1758);
  • generic /workflow attach / /workflow connect;
  • restored / durable-replayed completed stages after a process restart;
  • workflow({ action: "send" }).

Product semantics preserved

  • Open post-mortem chat is distinct from resume execution. Post-mortem chat only appends follow-up to the retained session; it never resumes, retries, rewinds, pauses, or re-dispatches the workflow. Handles are detached from run-level control from birth and reject pause/resume of execution.
  • Follow-up turns are append-in-place (matching feat(workflows): inspect completed runs from resume #1758 and same-process handles); the agent keeps its ordinary tools, so the conversation is not necessarily side-effect-free, but run/stage status, results, timings, checkpoints, replay metadata, and graph topology stay immutable.
  • Read-only fallback is preserved for prompt/HIL and boundary/summary nodes, skipped nodes, non-terminal handle-less stages (another process may still own the session), and missing/malformed/deleted session files. Recoverably failed stages keep their execution-resume semantics.

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.tsensurePostMortemStageHandle(runId, stage, deps): reuse existing non-disposed handle, then eligibility (terminal agent stage), retained-session validation, and a lazy detached handle via the existing createStageContext({ resumeFromSessionFile }) path, keyed and single-flighted by the real { runId, stageId } (including nested/expanded child stages). Returns an explicit unavailable reason.
  • stage-control-registry.tsgetOrCreateDetached atomic 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 reporting No live handle for stage.

LiveStageRuntime is 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); explicit delivery: "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, and git diff --check pass; full bun run test:unit (3449), full bun 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, and packages/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:

  • A shared resolver for reopening eligible terminal agent stages from retained Atomic session transcripts.
  • Detached, single-flight stage handles that allow follow-up chat without resuming workflow execution.
  • Attach/connect and workflow send paths that revive valid retained sessions on a live-handle miss.
  • Explicit no-op behavior for resume or steer delivery against completed post-mortem stages.
  • Documentation and tests covering retained-session validation, lifecycle disposal, nested attach routing, and send behavior.

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.

T-Rex T-Rex Logs

What T-Rex did

  • Ran Bun tests for test/unit/postmortem-stage-chat.test.ts, test/unit/stage-control-registry.test.ts, test/unit/workflow-attach-pane-11.test.ts, and test/unit/workflow-tool-send-postmortem.test.ts from /home/user/repo, capturing a log that includes the exact command, the working directory, Bun's summary, and the exit code.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
packages/workflows/src/runs/foreground/postmortem-stage-chat.ts Introduces the shared resolver and detached handle factory for terminal retained-session chats.
packages/workflows/src/runs/foreground/stage-control-registry.ts Adds atomic detached get-or-create registration for stale-handle replacement and single-flight post-mortem handles.
packages/workflows/src/extension/workflow-tool-send.ts Revives eligible completed stages on send registry miss and blocks explicit resume/steer against terminal post-mortem handles.
packages/workflows/src/tui/workflow-attach-pane.ts Uses owner-aware stage attachment and post-mortem handle fallback when opening stage chats.
packages/workflows/src/shared/session-transcript.ts Extracts shared JSONL transcript validation for reopenable retained Atomic sessions.
test/unit/postmortem-stage-chat.test.ts Adds resolver tests for eligibility, validation, single-flight, detached control, and resume rejection.
test/unit/workflow-tool-send-postmortem.test.ts Adds workflow send tests for post-mortem follow-up revival and explicit resume/steer noops.

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
Loading
%%{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 place
Loading

Reviews (10): Last reviewed commit: "fix(workflows): fence post-mortem sessio..." | Re-trigger Greptile

@mintlify

mintlify Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bastani 🟢 Ready View Preview Jul 15, 2026, 4:37 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Comment thread packages/workflows/src/runs/foreground/postmortem-stage-chat.ts Outdated
@mintlify

mintlify Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bastani 🟡 Building Jul 15, 2026, 4:36 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

flora131 added 5 commits July 15, 2026 08:28
…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
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
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
@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown

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
@lavaman131

lavaman131 commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Executable behavior proof — PASS ✅ (updated non-circular runtime)

Exact PR head: dc4a4c004df7f2079b6940b435e80aa7b300ffe3

Fresh remote verification:

git fetch --no-tags origin refs/pull/1813/head
local_before=dc4a4c004df7f2079b6940b435e80aa7b300ffe3
fetched_pr_head=dc4a4c004df7f2079b6940b435e80aa7b300ffe3
local_after=dc4a4c004df7f2079b6940b435e80aa7b300ffe3
match=yes

This updated toy imports the checkout's production workflow engine, durable backend/checkpoints, resolver, workflow send, control registry, extension lifecycle, and real Atomic createAgentSession + SessionManager persistence. The only behavioral substitution is session.agent.streamFn, which returns deterministic assistant text and never receives or writes the marker. The marker is submitted through production post-mortem delivery: "prompt"; Atomic's real prompt/event/session path appends it to JSONL.

Run:

bun /tmp/atomic-pr1813-toy-proof.ts

Observed twice successfully on Darwin arm64 / macOS 26.5.2 / Bun 1.3.14; latest output:

PRIMARY PASS {"run":"completed","stage":"completed","followUp":{"action":"send","runId":"pr1813-toy-run","stageId":"a1e358a7-d086-41c2-af2a-fa957163f603","delivery":"prompt","status":"ok","message":"Prompt sent to stage."},"resume":{"action":"send","runId":"pr1813-toy-run","stageId":"a1e358a7-d086-41c2-af2a-fa957163f603","delivery":"resume","status":"noop","message":"Cannot resume a terminal post-mortem stage; use delivery \"followUp\" or \"prompt\" to continue its retained conversation."},"markerCount":1,"workflowDispatches":1,"sessionCreates":2,"modelCalls":2,"executionStateUnchanged":true,"durableCheckpointStateUnchanged":true,"durableCheckpointCount":3,"detachedFromExecutionControl":true}
BOUNDARY PASS {"reason":"reload","promptCalls":0,"disposeCalls":1,"registryHandle":false}
NESTED CWD PASS {"expected":"/var/folders/cr/lt2lnmhd0g7c3g_62423frp80000gn/T/atomic-pr1813-proof-0yfdCn/durable-root-cwd","actual":"/var/folders/cr/lt2lnmhd0g7c3g_62423frp80000gn/T/atomic-pr1813-proof-0yfdCn/durable-root-cwd","owner":"nested-child","rootAliasPresent":false}
PASS: PR #1813 behavior-level toy proof

Asserted invariants:

  • A real one-stage workflow ran exactly once and finished completed; its agent stage finished completed with a production-created retained session.
  • After clearing its process-local handle, production workflow send + shared resolver reopened the transcript via real SessionManager.open; a post-mortem prompt appended PR1813_POSTMORTEM_MARKER_7f3d9a exactly once through Atomic's real session persistence.
  • The complete run/stage snapshot, durable workflow metadata, and nonempty durable checkpoint list were deep-equal before/after; workflow-body dispatch count stayed 1; the revived handle remained detached from execution controls.
  • Explicit delivery: "resume" returned structured noop with Cannot resume a terminal post-mortem stage; it made no model call and changed no execution/checkpoint state.
  • A pending detached session crossing real extension shutdown reason reload was disposed; its submitted prompt ran 0 times and its registry handle disappeared.
  • A nested completed stage reopened using its durable root's workflowCwd, while registry ownership remained the nested child run.

Independent source tracing confirmed the marker is constructed by production AgentSession.prompt, persisted by production AgentSession event handling and SessionManager.appendMessage, and written by SessionManager JSONL storage. It found no remaining circular mock or acceptance gap. Corroboration: an independent focused rerun reported 53 pass / 0 fail; bun run typecheck also passed.

Complete self-contained toy source
import 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.

@flora131
flora131 merged commit 0d9b783 into main Jul 15, 2026
10 checks passed
@flora131
flora131 deleted the flora131/feature/resume-chat-enabled branch August 14, 2026 01:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unify workflow stage post-mortem chat across resume, attach, restored/replayed stages, and workflow send

2 participants