Skip to content

Parked session never resumes after an eve start restart: continuation POST is accepted, the run is re-enqueued, and nothing else ever happens (world-local, 0.27.13) #1450

Open

Description

@smixs

Eve version

0.27.13 (bundled workflow core 5.0.0-beta.37, deployment dpl_local@5.0.0-beta.31), Node v24.15.0, Linux x86_64, default world-local, production mode (eve build + eve start).

Problem

A client session parked at session.waiting cannot be continued after the eve server is restarted. The continuation is accepted — session.send() resolves, the saved continuationToken is echoed back — but result() never resolves, the model provider is never called, and the run produces no further work. It hangs indefinitely (observed well past the 60s timeout used in the repro below).

The failure is completely silent: no error, no warning, no rejected queue delivery, nothing on stdout/stderr beyond the normal [world-local] Re-enqueued 2 active run(s) on startup.

Sessions created after the restart work normally on the same server process, so the runtime, the queue handler, and the provider are all healthy — only the pre-restart parked session is stuck.

This looks like the same class of bug as #715 ("Local world re-enqueues parked interactive sessions into an unhandled queue on restart", fixed in 0.22.6 by "Resume active local workflow runs on the agent-scoped queue after restarting the eve server"), but with the outcome inverted. In #715 the re-enqueue was rejected as Unhandled queue and yet resume-by-continuation still worked; here the re-enqueue is accepted silently and resume-by-continuation does not work at all.

What the durable state looks like while wedged

After the resume attempt times out, .eve/.workflow-data shows the delivery was persisted and then dropped on the floor:

  • Exactly one new file appears, the incoming message event for the parked run:
    events/<parkedRunId>-evnt_....json, "eventType": "hook_received", carrying the correct conversation hook token and the user's text in its payload.
  • No new step is created, no run record is updated (runs/<parkedRunId>.json mtime unchanged), no stream event is emitted.
  • The parked run stays "status": "running" with startedAt / updatedAt frozen at its original creation time.
  • Its run lock stays .locks/runs/<parkedRunId>.pending.
  • The mock provider records 0 requests during the resume attempt (4 for the whole run, all from the turns that worked).

So the hook-received event is durably written for the right run, and then the run is simply never woken to consume it.

Reproduction

A minimal, self-contained app (one agent file, no tools, no skills, no channels) and a local mock OpenAI-compatible provider — no network and no real model needed. All four files are inlined below.

mkdir eve-repro && cd eve-repro
npm init -y
npm pkg set type=module
npm i eve@0.27.13 ai@7.0.39 @ai-sdk/openai-compatible@3.0.18 zod@4.4.3
# add agent/agent.ts, agent/instructions.md, mock-openai.mjs, repro.mjs from below
node repro.mjs

The driver:

  1. eve build, then eve start --host 127.0.0.1 --port <free>, poll GET / until healthy.
  2. Turn 1 on a fresh session → REPLICA_OK.
  3. Turn 2 seeds a marker: Remember this code: CEDAR-4729 → REMEMBERED.
  4. Control A (pre-restart): rehydrate with client.session(session.state) and take another turn — works. This proves the saved state is a usable handle; only the restart breaks it.
  5. Save session.state, then stop the server with SIGTERM to the process group (15s grace, well past eve's 5s graceful window), and eve start again on the same port and data dir.
  6. Control B (post-restart): a brand-new session takes a turn — works.
  7. Snapshot .eve/.workflow-data (path → mtime, recursive), then client.session(savedState).send("What code did I ask you to remember? ...") under a 60s timeout, then snapshot again and diff.

Observed

All output below is from a single run; the parked session is wrun_01KYV2ZGM639PTPVB366TSRWH8.

[repro] turn 1 on a fresh session
[repro]   reply: "REPLICA_OK"
[repro] turn 2: seed the marker
[repro]   reply: "REMEMBERED"
[repro] control: resume from saved state WITHOUT restarting (proves the saved state is usable)
[repro]   reply: "REPLICA_OK"
[repro]   saved session state: {"continuationToken":"eve:78d5149d-7ff7-40a7-9f5b-920f2cfe57c5","sessionId":"wrun_01KYV2ZGM639PTPVB366TSRWH8","streamIndex":25}
[repro] restart eve (SIGTERM to the process group, then start again)
Stopping server gracefully (5s)...
[world-local] Re-enqueued 2 active run(s) on startup
[repro] control: a fresh session after the restart
[repro] send accepted (sessionId=wrun_01KYV2ZPBHGF9DDX8HHD2GR151) token=eve:6996ac6a-7b76-4439-92ae-ff1f0fd619da
[repro]   reply: "REPLICA_OK" (fresh sessions work)
[repro] .workflow-data before resume: 259 files
[repro] resume the parked session across the restart
[repro] send accepted (sessionId=wrun_01KYV2ZGM639PTPVB366TSRWH8) token=eve:78d5149d-7ff7-40a7-9f5b-920f2cfe57c5
[repro] --- resume result ---
[repro]   REPRODUCED: turn timed out after 60s (POST accepted: true, continuationToken: eve:78d5149d-7ff7-40a7-9f5b-920f2cfe57c5)
[repro]   provider calls during resume: 0
[repro]   .workflow-data new files: 1 -> events/wrun_01KYV2ZGM639PTPVB366TSRWH8-evnt_01KYV2ZVX3NRHWJ4BXRXFX3V1S.json
[repro]   .workflow-data touched files: 0
[repro]       new: events/wrun_01KYV2ZGM639PTPVB366TSRWH8-evnt_01KYV2ZVX3NRHWJ4BXRXFX3V1S.json
[repro]   total provider calls this run: 4

That single new file, events/wrun_01KYV2ZGM639PTPVB366TSRWH8-evnt_01KYV2ZVX3NRHWJ4BXRXFX3V1S.json — note the correct run id and the user's message inside the payload:

{
  "eventType": "hook_received",
  "specVersion": 5,
  "correlationId": "hook_01KYV2ZGM6BCQXCGJ6FJGP7PFR",
  "eventData": {
    "token": "eve:eve:78d5149d-7ff7-40a7-9f5b-920f2cfe57c5",
    "payload": { "__type": "Uint8Array", "data": "<base64>" }
  },
  "runId": "wrun_01KYV2ZGM639PTPVB366TSRWH8",
  "eventId": "evnt_01KYV2ZVX3NRHWJ4BXRXFX3V1S",
  "createdAt": "2026-07-31T03:20:22.691Z"
}

Its decoded payload is exactly the message that should have woken the run:

devl[{"auth":1,"kind":4,"payloads":5,"requestId":-1},{...},{},"local-dev","deliver",[6],
{"inputResponses":-1,"message":7,"context":-1,"outputSchema":-1},
"What code did I ask you to remember? Reply with the code only."]

The full event timeline for that run makes the contrast plain. Before the restart, every conversation hook_received is followed within ~200ms by turn-control hook activity and a step_created / step_started / step_completed triplet. The post-restart hook_received at 03:20:22.691 is the last event the run ever produces:

03:20:11.172  run_created
03:20:11.216  run_started
03:20:11.277  step_created / step_started
03:20:11.320  step_completed
03:20:11.355  hook_created   (conversation token eve:eve:78d5149d-…)
03:20:11.355  hook_created   (…:auth)
03:20:11.425  hook_created   (…:turn-control:0)
03:20:11.451  step_created / step_started
03:20:11.517  step_completed
03:20:11.955  hook_received  (conversation token)   <- turn 2, works
03:20:12.105  hook_received  (…:turn-control:0)
03:20:12.127  hook_created   (…:turn-control:1)
03:20:12.160  step_created / step_started
03:20:12.213  step_completed
03:20:12.432  hook_received  (conversation token)   <- control A, works
03:20:12.491  hook_received  (…:turn-control:1)
03:20:12.521  hook_created   (…:turn-control:2)
03:20:12.521  hook_disposed  (…:turn-control:0)
03:20:12.549  step_created / step_started
03:20:12.611  step_completed
--- server restarted here ---
03:20:22.691  hook_received  (conversation token)   <- resume, nothing follows, ever

And the run record and lock from the same run, after the resume attempt timed out:

{ "status": "running",
  "createdAt": "2026-07-31T03:20:11.172Z",
  "startedAt": "2026-07-31T03:20:11.216Z",
  "updatedAt": "2026-07-31T03:20:11.216Z" }
.locks/runs/wrun_01KYV2ZGM639PTPVB366TSRWH8.pending

Reproduced 5 out of 5 runs, deterministically.

Expected

client.session(savedState).send(...) after a server restart resumes the parked session: the run wakes, the model is called with the replayed transcript, and result() resolves with the answer (here, CEDAR-4729). Failing that, the continuation should be rejected loudly rather than accepted and silently discarded.

Files

package.json

{
  "name": "eve-repro",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "eve build",
    "start": "eve start",
    "repro": "node repro.mjs"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "dependencies": {
    "@ai-sdk/openai-compatible": "^3.0.18",
    "ai": "^7.0.39",
    "eve": "^0.27.13",
    "zod": "^4.4.3"
  }
}

agent/agent.ts

import { defineAgent } from "eve";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";

const model = createOpenAICompatible({
  name: "mock",
  baseURL: process.env.MOCK_BASE_URL!,
  apiKey: "x",
})("mock-model");

// The mock model has no AI Gateway metadata, so the context window must be stated here
// or `eve build` refuses to compile the compaction trigger.
export default defineAgent({ model, modelContextWindowTokens: 131072 });

agent/instructions.md

You are a terse test agent. Answer in as few words as possible.

mock-openai.mjs

// Standalone mock OpenAI-compatible provider: POST /v1/chat/completions.
// Default reply "REPLICA_OK"; echoes a CEDAR-#### marker found in the transcript when asked.
// Logs every request to MOCK_LOG (one JSON line per request) so the driver can tell
// whether the agent server ever called the provider.
import { createServer } from "node:http";
import { appendFileSync } from "node:fs";

const MODEL = "mock-model";
const LOG = process.env.MOCK_LOG;

function partText(content) {
  if (typeof content === "string") return content;
  if (Array.isArray(content)) return content.map((p) => (typeof p === "string" ? p : (p?.text ?? ""))).join(" ");
  return "";
}
const transcriptText = (messages) => messages.map((m) => partText(m?.content)).join("\n");
function lastUserText(messages) {
  for (let i = messages.length - 1; i >= 0; i--) if (messages[i]?.role === "user") return partText(messages[i].content);
  return "";
}
function chooseResponse(body) {
  const messages = Array.isArray(body?.messages) ? body.messages : [];
  const last = lastUserText(messages);
  if (/What code did I ask you to remember/i.test(last)) {
    const match = transcriptText(messages).match(/CEDAR-\d+/);
    return match ? match[0] : "MISSING_MARKER";
  }
  if (/Remember this code/i.test(last)) return "REMEMBERED";
  return "REPLICA_OK";
}
const completionJson = (text) => ({
  id: "chatcmpl-mock",
  object: "chat.completion",
  created: 1,
  model: MODEL,
  choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }],
  usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 },
});
function streamChunks(text) {
  const base = { id: "chatcmpl-mock", object: "chat.completion.chunk", created: 1, model: MODEL };
  return [
    { ...base, choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] },
    { ...base, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] },
    { ...base, choices: [], usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 } },
  ];
}
async function readBody(req) {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  return Buffer.concat(chunks).toString("utf8");
}

export async function startMockOpenAiServer() {
  const requests = [];
  const server = createServer(async (req, res) => {
    if (req.method !== "POST" || req.url !== "/v1/chat/completions") {
      res.writeHead(404, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: { message: `no route: ${req.method} ${req.url}` } }));
      return;
    }
    let body;
    try {
      body = JSON.parse(await readBody(req));
    } catch {
      res.writeHead(400, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: { message: "invalid JSON body" } }));
      return;
    }
    requests.push(body);
    if (LOG) appendFileSync(LOG, JSON.stringify({ at: new Date().toISOString(), last: lastUserText(body.messages ?? []) }) + "\n");
    const text = chooseResponse(body);
    if (body?.stream) {
      res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
      for (const chunk of streamChunks(text)) res.write(`data: ${JSON.stringify(chunk)}\n\n`);
      res.end("data: [DONE]\n\n");
    } else {
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify(completionJson(text)));
    }
  });
  await new Promise((resolve, reject) => {
    server.once("error", reject);
    server.listen(0, "127.0.0.1", resolve);
  });
  const { port } = server.address();
  return {
    baseUrl: `http://127.0.0.1:${port}/v1`,
    requests,
    close: () => new Promise((resolve) => server.close(resolve)),
  };
}

// Standalone mode: `node mock-openai.mjs` prints its base URL and stays up.
if (import.meta.url === `file://${process.argv[1]}`) {
  const mock = await startMockOpenAiServer();
  console.log(mock.baseUrl);
}

repro.mjs

// Minimal reproduction for the eve 0.27.13 session-resume wedge.
//
// Scenario: park a client session, restart the eve server, then continue the parked
// session with client.session(savedState).send(...). The POST is accepted (a continuation
// token comes back) but result() never resolves and the workflow data directory stays frozen.
//
// Run: MOCK_BASE_URL is wired up automatically; just `node repro.mjs` from this directory.
import { spawn } from "node:child_process";
import { createServer } from "node:net";
import { readdirSync, statSync, existsSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { startMockOpenAiServer } from "./mock-openai.mjs";

const APP = dirname(fileURLToPath(import.meta.url));
const MARKER = "CEDAR-4729";
const HEALTH_TIMEOUT_MS = 90_000;
const TURN_TIMEOUT_MS = 60_000;
const WORKFLOW_DIR = join(APP, ".eve", ".workflow-data");

const logs = [];
const note = (line) => {
  logs.push(line);
  if (logs.length > 600) logs.shift();
  if (process.env.VERBOSE === "1") console.error(line);
};
const say = (line) => console.log(`[repro] ${line}`);

async function freePort() {
  return new Promise((resolve, reject) => {
    const srv = createServer();
    srv.once("error", reject);
    srv.listen(0, "127.0.0.1", () => {
      const { port } = srv.address();
      srv.close(() => resolve(port));
    });
  });
}

function run(cmd, args, env) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, { cwd: APP, env, stdio: ["ignore", "pipe", "pipe"] });
    const capture = (buf) => String(buf).split("\n").filter(Boolean).forEach(note);
    child.stdout.on("data", capture);
    child.stderr.on("data", capture);
    child.on("error", reject);
    child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited with ${code}`))));
  });
}

function startEve(env, port) {
  const child = spawn(
    process.execPath,
    [join(APP, "node_modules/eve/bin/eve.js"), "start", "--host", "127.0.0.1", "--port", String(port)],
    { cwd: APP, env, detached: true, stdio: ["ignore", "pipe", "pipe"] },
  );
  const capture = (buf) => String(buf).split("\n").filter(Boolean).forEach(note);
  child.stdout.on("data", capture);
  child.stderr.on("data", capture);
  return child;
}

async function stopEve(child) {
  if (!child || child.exitCode !== null) return;
  const gone = new Promise((resolve) => child.once("exit", resolve));
  try {
    process.kill(-child.pid, "SIGTERM");
  } catch {
    return;
  }
  const timer = new Promise((resolve) => setTimeout(resolve, 15_000, "timeout"));
  if ((await Promise.race([gone, timer])) === "timeout") {
    try {
      process.kill(-child.pid, "SIGKILL");
    } catch {
      /* already dead */
    }
    await gone;
  }
}

async function waitForHealth(port, child) {
  const deadline = Date.now() + HEALTH_TIMEOUT_MS;
  while (Date.now() < deadline) {
    if (child.exitCode !== null) throw new Error(`eve exited with ${child.exitCode} before becoming healthy`);
    try {
      const res = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2000) });
      if (res.ok) return;
    } catch {
      /* not up yet */
    }
    await new Promise((r) => setTimeout(r, 250));
  }
  throw new Error(`eve did not become healthy within ${HEALTH_TIMEOUT_MS / 1000}s`);
}

// Recursive snapshot of .eve/.workflow-data: relative path -> mtimeMs.
function snapshotWorkflowData(dir = WORKFLOW_DIR, base = WORKFLOW_DIR, out = new Map()) {
  if (!existsSync(dir)) return out;
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) snapshotWorkflowData(full, base, out);
    else out.set(full.slice(base.length + 1), statSync(full).mtimeMs);
  }
  return out;
}

function diffSnapshots(before, after) {
  const added = [...after.keys()].filter((k) => !before.has(k));
  const changed = [...after.keys()].filter((k) => before.has(k) && before.get(k) !== after.get(k));
  return { added, changed };
}

const accepted = { send: false, token: null };

async function turn(session, prompt, timeoutMs = TURN_TIMEOUT_MS) {
  let timer;
  accepted.send = false;
  accepted.token = null;
  try {
    const result = await Promise.race([
      session.send(prompt).then((r) => {
        accepted.send = true;
        accepted.token = session.state?.continuationToken ?? null;
        note(`[repro] send accepted (sessionId=${session.state?.sessionId ?? "new"}) token=${accepted.token}`);
        return r.result();
      }),
      new Promise((_, reject) => {
        timer = setTimeout(
          () => reject(new Error(`turn timed out after ${timeoutMs / 1000}s (POST accepted: ${accepted.send}, continuationToken: ${accepted.token})`)),
          timeoutMs,
        );
      }),
    ]);
    note(`[repro] turn done: status=${JSON.stringify(result?.status)}`);
    if (result.status === "failed" || !result.message) throw new Error(`turn failed: status=${result.status}`);
    return result.message.trim();
  } finally {
    clearTimeout(timer);
  }
}

async function main() {
  if (process.env.KEEP_STATE !== "1") rmSync(join(APP, ".eve"), { recursive: true, force: true });
  const mock = await startMockOpenAiServer();
  const port = await freePort();
  const env = { ...process.env, MOCK_BASE_URL: mock.baseUrl, PORT: String(port), NODE_ENV: "production" };
  let eve = null;
  let reproduced = false;
  try {
    say("build");
    await run(process.execPath, [join(APP, "node_modules/eve/bin/eve.js"), "build"], env);

    say(`start (port ${port})`);
    eve = startEve(env, port);
    await waitForHealth(port, eve);

    const { Client } = await import("eve/client");
    const client = new Client({ host: `http://127.0.0.1:${port}` });

    say("turn 1 on a fresh session");
    const session = client.session();
    const first = await turn(session, "Reply with a status word.");
    say(`  reply: ${JSON.stringify(first)}`);

    say("turn 2: seed the marker");
    const seeded = await turn(session, `Remember this code: ${MARKER}`);
    say(`  reply: ${JSON.stringify(seeded)}`);
    say("control: resume from saved state WITHOUT restarting (proves the saved state is usable)");
    const rehydrated = client.session(session.state);
    const preRestart = await turn(rehydrated, "Reply with a status word.");
    say(`  reply: ${JSON.stringify(preRestart)}`);

    const savedState = rehydrated.state;
    say(`  saved session state: ${JSON.stringify(savedState)}`);

    say("restart eve (SIGTERM to the process group, then start again)");
    await stopEve(eve);
    eve = startEve(env, port);
    await waitForHealth(port, eve);

    say("control: a fresh session after the restart");
    const fresh = await turn(client.session(), "Reply with a status word.");
    say(`  reply: ${JSON.stringify(fresh)} (fresh sessions work)`);

    // Let the control run's writes settle so the diff below only reflects the resume attempt.
    await new Promise((r) => setTimeout(r, 5000));
    const mockBefore = mock.requests.length;
    const before = snapshotWorkflowData();
    say(`.workflow-data before resume: ${before.size} files`);

    say("resume the parked session across the restart");
    let resumeError = null;
    let resumed = null;
    try {
      resumed = await turn(client.session(savedState), "What code did I ask you to remember? Reply with the code only.");
    } catch (err) {
      resumeError = err;
    }
    const after = snapshotWorkflowData();
    const { added, changed } = diffSnapshots(before, after);
    const mockCalls = mock.requests.length - mockBefore;

    say("--- resume result ---");
    if (resumeError) {
      reproduced = true;
      say(`  REPRODUCED: ${resumeError.message}`);
    } else {
      say(`  resume returned ${JSON.stringify(resumed)} (expected ${MARKER})`);
      if (resumed !== MARKER) reproduced = true;
    }
    say(`  provider calls during resume: ${mockCalls}`);
    const parked = savedState.sessionId;
    const forParked = (list) => list.filter((f) => f.includes(parked));
    say(`  .workflow-data new files: ${added.length}${added.length ? ` -> ${added.slice(0, 20).join(", ")}` : ""}`);
    say(`  .workflow-data touched files: ${changed.length}${changed.length ? ` -> ${changed.slice(0, 20).join(", ")}` : ""}`);
    say(`  ...of those, belonging to the parked run ${parked}: ${forParked(added).length} new, ${forParked(changed).length} touched`);
    for (const f of forParked(added)) say(`      new: ${f}`);
    for (const f of forParked(changed)) say(`      touched: ${f}`);
    say(`  total provider calls this run: ${mock.requests.length}`);
    say(reproduced ? "VERDICT: session-resume wedge REPRODUCED on bare eve" : "VERDICT: resume worked, NOT reproduced");
  } catch (err) {
    console.error(`[repro] FAILED: ${err?.message ?? err}`);
    process.exitCode = 1;
  } finally {
    console.error("--- last server output ---");
    for (const line of logs.slice(-80)) console.error(line);
    await stopEve(eve);
    await mock.close();
  }
  if (reproduced) process.exitCode = 2;
}

await main();

Notes

  • Two small things eve build required for a truly minimal agent, in case they matter for anyone re-running this: modelContextWindowTokens must be set for a model with no AI Gateway metadata (otherwise "Cannot compile agent compaction because the primary compaction trigger model … does not have known AI Gateway context window metadata"), and agent/instructions.md must exist.
  • The repro exits 2 when the wedge reproduces, 1 on setup failure, 0 if resume works. VERBOSE=1 streams the eve server output; KEEP_STATE=1 keeps .eve from the previous run instead of starting clean.

Downstream context

Found while investigating smixs/iva#104, where a production assistant on eve lost every parked conversation across each deploy restart. The same scenario worked in that app before its upgrade to the 0.24.x line (reported downstream; not re-tested here), and the reproduction above shows it is not caused by anything in that app's code — a bare agent with no tools, skills, hooks, or channels wedges the same way.

Activity

  1. iroiro147 commented on Aug 1, 2026

    @iroiro147
    Contributor

    Investigated as a follow-up to the high-fidelity repro in the body.

    Boundary of the bug: the seam is on the @workflow/world side of the continuation contract, not in eve's session-delivery path.

    What eve does correctly (verified in source + your repro):

    1. The continuation POST resolves, persists the hook_received event for the parked run, and returns the expected continuationToken (events/<runId>-evnt_*.json written).
    2. The runtime on the same process is healthy (fresh sessions work).

    What's missing is the bridge from "event durably written for run X" to "world wakes X's step runner to consume that event." The startup log Re-enqueued N active run(s) on startup shows world-local does re-enqueue parked runs on boot, but the re-enqueue appears to put the run in a step-executor's queue that has no consumer for a new-turn continuation event — only the in-flight session.waiting semantics.

    This is between two systems (eve ↔ @workflow/world), not fixable by a single eve patch without diverging from the world's run-resumption contract. The prior #715 fix landed on the world's side (the re-enqueue itself, in 0.22.6), and any follow-up needs the same shape: either a world change that hooks newly delivered events into re-enqueued runs, or an eve-side re-dispatch helper that explicitly wakes the workflow run after persisting the event — predicated on a world API for doing so.

    I'd rather flag this than ship a workaround. Happy to pick it back up once the intended seam is confirmed (world-side fix vs eve-side explicit wake), or if a maintainer points at the existing re-enqueue→continuation wiring that should be carrying this event.

  2. sloops77 commented on Sep 15, 2026

    @sloops77

    Still present on eve 0.54.3, and reachable from eve dev as well as eve start.

    Environment

    eve 0.54.3, bundled @workflow/core 5.0.0-beta.50, @workflow/world 5.0.0-beta.34, @workflow/world-local 5.0.0-beta.43. Node 24.14.0, Linux x86_64, 4 vCPU CI runner.

    Two differences from the original report. This is development mode, where Next's withEve integration forks eve dev --no-ui, rather than eve build plus eve start. And the restart is not graceful: an integration-test harness kills the process tree and starts a new one over the same .eve/.workflow-data.

    What we see

    A session takes one turn and parks. The harness restarts the application. The continuation POST /eve/v1/session/:id answers 202, and then nothing happens. The stream delivers no event and the turn never runs.

    The durable state matches the description in this issue:

    Durable run: workflowEntry status=running started=2026-09-15T00:36:22.984Z updated=2026-09-15T00:36:22.984Z
    Durable hooks: eve:session:<runId>:inbox, <runId>:auth, <runId>:turn-control:0
    Durable steps: createSessionStep x1, startSessionTimeoutStep x1, dispatchTurnStep x1, forwardTurnCancellationStep x1
    Durable waits: none
    

    dispatchTurnStep stays at one, so no step is created for the continuation. startedAt and updatedAt are frozen at the run's creation. The session's inbox hook is intact on disk when the read gives up 60 s later. The runtime prints its boot line and its health response and nothing else, and no [world-local] delivery error appears.

    Difference worth noting

    Ours is intermittent rather than deterministic. On a loaded 4 vCPU runner it fires in roughly one run in three or four. It has never reproduced on macOS or inside a pinned Linux container, where the same case passes in about 9 s. That fits a race rather than a permanent wedge, so whatever consumes the re-enqueued run may sometimes win.

    Ruled out on our side

    Three harness changes left the failure rate unchanged, which supports the conclusion in the comment above that this is not an eve teardown or boot problem:

    • waiting for the whole eve dev process tree to exit before starting the replacement, so no orphaned runtime overlaps the new one on the same data directory
    • signalling eve dev before Next's exit hook can send it a second SIGTERM, so its own cleanup completes
    • gating readiness on the dev runtime-artifacts route, which answers 503 until the watcher exists, rather than on /eve/v1/health, which the worker answers two boot phases before world.start()

    We have worked around it by not restarting the runtime in the affected tests.

  3. roboeve commented on Sep 28, 2026

    @roboeve
    Contributor

    Confirmed on tested e574dd3 (origin/main is now 8149c72; workflow code/dependencies are unchanged): after restart, the continuation POST is accepted but the saved session times out after 60s with zero provider calls; fresh sessions work. Command: node /workspace/.repro/issue-1450/repro.mjs (exit 2).

  4. arielrahmane commented on Oct 26, 2026

    @arielrahmane

    Cross-reference: vercel/workflow#5083 documents a concrete local-storage crash-consistency defect that can cause a follow-up turn to stall after a restart.

    In a built Eve 0.52.5 fixture with a local mock model, stopping immediately after the reply / session.waiting left either:

    • a step's .created marker without its canonical record or step_created event; or
    • a .terminal marker / completed record without its corresponding step_completed event.

    Recovery then reported already created / Cannot modify step in terminal state while replay still needed the missing transition. One retained trace looped more than 4,000 times without progress.

    The linked report contains a standalone reproducer for three interruption points, with a passing no-crash control and repeated failed retries. All three storage cases also reproduce in the current published @workflow/world-local@5.1.2 and in the compiled local World bundled with current eve@0.83.3, using spec 8. Current-version verification is storage-only; the full Eve restart fixture was run on 0.52.5.

    This provides one concrete mechanism to investigate here; it does not establish that every reproduction in #1450 has this cause. It is also separate from the 860-second orphaned-inline-owner delay tracked in vercel/workflow#4939 and repaired by vercel/workflow#4940. Releasing ownership does not restore missing step records/events.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcorep1

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions