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
5 changes: 5 additions & 0 deletions .changeset/think-pre-stream-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/think": patch
---

Recover an interrupted Think turn that had opened its stream but persisted no content by re-running its user message instead of trying to continue it. Think opens the resumable stream row before inference, so a Durable Object reset in the window before the first chunk left a turn with a stream id, no partial, and a user message as the latest leaf; recovery classified it as `continue`, found no assistant message to continue from, and marked the incident `skipped`. A parent tailing such a child through agent tools then sealed the run as an error. This mirrors `AIChatAgent`'s empty-partial new-turn rule (#1691).
10 changes: 5 additions & 5 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:

- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p 'require(\"playwright/package.json\").version')" >> $GITHUB_OUTPUT
run: echo "version=$(node -p "require('playwright/package.json').version")" >> $GITHUB_OUTPUT

- name: Cache Playwright browsers
uses: actions/cache@v5
Expand Down Expand Up @@ -72,7 +72,7 @@ jobs:

- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p 'require(\"playwright/package.json\").version')" >> $GITHUB_OUTPUT
run: echo "version=$(node -p "require('playwright/package.json').version")" >> $GITHUB_OUTPUT

- name: Cache Playwright browsers
uses: actions/cache@v5
Expand Down Expand Up @@ -214,7 +214,7 @@ jobs:

- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p 'require(\"playwright/package.json\").version')" >> $GITHUB_OUTPUT
run: echo "version=$(node -p "require('playwright/package.json').version")" >> $GITHUB_OUTPUT

- name: Cache Playwright browsers
uses: actions/cache@v5
Expand Down Expand Up @@ -249,7 +249,7 @@ jobs:

- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p 'require(\"playwright/package.json\").version')" >> $GITHUB_OUTPUT
run: echo "version=$(node -p "require('playwright/package.json').version")" >> $GITHUB_OUTPUT

- name: Cache Playwright browsers
uses: actions/cache@v5
Expand Down Expand Up @@ -295,7 +295,7 @@ jobs:

- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p 'require(\"playwright/package.json\").version')" >> $GITHUB_OUTPUT
run: echo "version=$(node -p "require('playwright/package.json').version")" >> $GITHUB_OUTPUT

- name: Cache Playwright browsers
uses: actions/cache@v5
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pullrequest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:

- name: Get Playwright version
id: playwright-version
run: echo "version=$(node -p 'require(\"playwright/package.json\").version')" >> $GITHUB_OUTPUT
run: echo "version=$(node -p "require('playwright/package.json').version")" >> $GITHUB_OUTPUT

- name: Cache Playwright browsers
uses: actions/cache@v5
Expand Down
40 changes: 17 additions & 23 deletions packages/agents/src/e2e-tests/fiber-eviction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,28 +81,6 @@ function killProcessOnPort(port: number): void {
}
}

function killProcessTree(pid: number): void {
let children: number[] = [];
try {
children = execSync(`pgrep -P ${pid} 2>/dev/null || true`)
.toString()
.trim()
.split("\n")
.filter(Boolean)
.map(Number);
} catch {
// pgrep may be unavailable; killing the parent is still useful.
}
for (const childPid of children) {
killProcessTree(childPid);
}
try {
process.kill(pid, "SIGKILL");
} catch {
// Already dead
}
}

function startWrangler(): ChildProcess {
const configPath = path.join(__dirname, "wrangler.jsonc");
const child = spawn(
Expand All @@ -122,6 +100,9 @@ function startWrangler(): ChildProcess {
{
cwd: __dirname,
stdio: ["pipe", "pipe", "pipe"],
// A process-group leader, so killProcess() can take down wrangler and
// every workerd it spawns in one signal.
detached: true,
env: { ...process.env, NODE_ENV: "test" }
}
);
Expand Down Expand Up @@ -178,7 +159,20 @@ function killProcess(child: ChildProcess): Promise<void> {
clearTimeout(fallback);
resolve();
});
killProcessTree(child.pid);
// The child is a process-group leader (spawned detached): one signal
// takes down npm exec, wrangler, esbuild, and every workerd. Walking the
// tree with pgrep and killing children first leaves a window in which
// wrangler respawns workerd; that orphan keeps the port and the restart
// fails with "Address already in use".
try {
process.kill(-child.pid, "SIGKILL");
} catch {
try {
process.kill(child.pid, "SIGKILL");
} catch {
// Already dead.
}
}
});
}

Expand Down
40 changes: 17 additions & 23 deletions packages/agents/src/e2e-tests/recovery-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,28 +71,6 @@ export function killProcessOnPort(port: number): void {
}
}

function killProcessTree(pid: number): void {
let children: number[] = [];
try {
children = execSync(`pgrep -P ${pid} 2>/dev/null || true`)
.toString()
.trim()
.split("\n")
.filter(Boolean)
.map(Number);
} catch {
// pgrep may be unavailable; killing the parent is still useful.
}
for (const childPid of children) {
killProcessTree(childPid);
}
try {
process.kill(pid, "SIGKILL");
} catch {
// Already dead
}
}

export function startWrangler(h: Harness): ChildProcess {
const child = spawn(
"npx",
Expand All @@ -111,6 +89,9 @@ export function startWrangler(h: Harness): ChildProcess {
{
cwd: h.configPath.replace(/\/wrangler\.jsonc$/, ""),
stdio: ["pipe", "pipe", "pipe"],
// A process-group leader, so killProcess() can take down wrangler and
// every workerd it spawns in one signal.
detached: true,
env: { ...process.env, NODE_ENV: "test" }
}
);
Expand Down Expand Up @@ -177,7 +158,20 @@ export function killProcess(child: ChildProcess): Promise<void> {
clearTimeout(fallback);
resolve();
});
killProcessTree(child.pid);
// The child is a process-group leader (spawned detached): one signal
// takes down npm exec, wrangler, esbuild, and every workerd. Walking the
// tree with pgrep and killing children first leaves a window in which
// wrangler respawns workerd; that orphan keeps the port and the restart
// fails with "Address already in use".
try {
process.kill(-child.pid, "SIGKILL");
} catch {
try {
process.kill(child.pid, "SIGKILL");
} catch {
// Already dead.
}
}
});
}

Expand Down
24 changes: 14 additions & 10 deletions packages/ai-chat/src/e2e-tests/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,9 @@ export class ChatAbortedExhaustAgent extends ExhaustionBaseAgent {
/**
* Exhausts recovery via `work_budget_exceeded`: `maxRecoveryWork: 0` seals the
* incident as soon as the turn produces ANY recovery work. Unlike the base
* agent, this one emits enough chunks to bump the durable progress/work meter
* (a `text-start` past the flush threshold) BEFORE hanging, so each detection
* sees work accrue beyond the baseline.
* agent, this one emits enough chunks to land one durable segment in the
* stream log BEFORE hanging, so each detection sees work accrue beyond the
* baseline.
*/
export class ChatWorkBudgetExhaustAgent extends ExhaustionBaseAgent {
override chatRecovery: ChatRecoveryConfig = {
Expand All @@ -170,15 +170,19 @@ export class ChatWorkBudgetExhaustAgent extends ExhaustionBaseAgent {
_onFinish: unknown,
_options?: OnChatMessageOptions
): Promise<Response> {
// Emit a single `text-start` then hang. `text-start` bumps the durable
// recovery work/progress meter at production time (independent of flush),
// so each interruption banks one unit of work. Staying below the 10-chunk
// flush threshold keeps the recoverable partial empty (the retry path),
// which avoids the continuation suppression that would swallow a re-emitted
// text-start on the continue path.
// The work meter is derived from the stream log: a chunk counts once its
// packed segment (ten chunks) is flushed, so a lone `text-start` banks
// nothing. Emit exactly one segment's worth of chunks — enough to flush —
// then hang, so each attempt banks one unit of work and the second
// interruption exceeds a zero budget.
const chunks: Array<{ type: string; [k: string]: unknown }> = [
{ type: "start", messageId: `asst-${Date.now()}` },
{ type: "text-start" }
{ type: "text-start", id: "t1" },
...Array.from({ length: 8 }, (_, i) => ({
type: "text-delta",
id: "t1",
delta: `work-${i} `
}))
];
const encoder = new TextEncoder();
let index = 0;
Expand Down
64 changes: 5 additions & 59 deletions packages/think/src/e2e-tests/action-ledger-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
* exactly once — never stuck behind a permanent `ActionPendingError`
*/
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { spawn, type ChildProcess } from "node:child_process";
import { killProcess, killProcessOnPort } from "./wrangler-process";
import { setDefaultAutoSelectFamily } from "node:net";
import "./harden-net";
import path from "node:path";
Expand All @@ -33,49 +34,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function killProcessOnPort(port: number): void {
try {
const output = execSync(
`lsof -tiTCP:${port} -sTCP:LISTEN 2>/dev/null || true`
)
.toString()
.trim();
if (output) {
for (const pid of output.split("\n").filter(Boolean)) {
try {
process.kill(Number(pid), "SIGKILL");
} catch {
// Already dead
}
}
}
} catch {
// lsof not available
}
}

function killProcessTree(pid: number): void {
let children: number[] = [];
try {
children = execSync(`pgrep -P ${pid} 2>/dev/null || true`)
.toString()
.trim()
.split("\n")
.filter(Boolean)
.map(Number);
} catch {
// pgrep may be unavailable; killing the parent is still useful.
}
for (const childPid of children) {
killProcessTree(childPid);
}
try {
process.kill(pid, "SIGKILL");
} catch {
// Already dead
}
}

function startWrangler(): ChildProcess {
const configPath = path.join(__dirname, "wrangler.jsonc");
const child = spawn(
Expand All @@ -95,6 +53,9 @@ function startWrangler(): ChildProcess {
{
cwd: __dirname,
stdio: ["pipe", "pipe", "pipe"],
// A process-group leader, so killProcess() can take down wrangler and
// every workerd it spawns in one signal.
detached: true,
env: { ...process.env, NODE_ENV: "test" }
}
);
Expand Down Expand Up @@ -138,21 +99,6 @@ async function waitForPortFree(maxAttempts = 30, delayMs = 500): Promise<void> {
throw new Error(`Port ${PORT} did not free in time`);
}

function killProcess(child: ChildProcess): Promise<void> {
return new Promise((resolve) => {
if (!child.pid) {
resolve();
return;
}
const fallback = setTimeout(resolve, 3000);
child.on("exit", () => {
clearTimeout(fallback);
resolve();
});
killProcessTree(child.pid);
});
}

async function restartWrangler(child: ChildProcess): Promise<ChildProcess> {
await killProcess(child);
await waitForPortFree();
Expand Down
Loading
Loading