Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## [Unreleased]

### Fixed

- Anthropic `ping` keepalives no longer reset stream progress, so responses that stop producing content now reach the idle timeout instead of hanging indefinitely.
- The documented `GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS` environment variable now takes effect: the stream-watchdog idle-timeout helpers resolve it GJC-first before the legacy `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` / `PI_STREAM_IDLE_TIMEOUT_MS` aliases (previously only the `PI_`-prefixed names were read, so setting the documented GJC name was a silent no-op).

## [0.11.10] - 2026-07-25

## [0.11.9] - 2026-07-24
Expand Down
36 changes: 36 additions & 0 deletions packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,40 @@ function shouldIgnoreAnthropicPreambleEvent(eventType: unknown): boolean {
return !ANTHROPIC_PRE_MESSAGE_START_EVENT_TYPES.has(eventType);
}

function createAnthropicStreamProgressPredicate(): (event: unknown) => boolean {
let outputTokens = -1;

return event => {
if (!isRecord(event) || typeof event.type !== "string") return false;
if (
event.type === "message_start" ||
event.type === "content_block_start" ||
event.type === "content_block_stop" ||
event.type === "message_stop"
) {
return true;
}
if (event.type === "content_block_delta") {
if (!isRecord(event.delta)) return false;
const delta = event.delta;
return (
(typeof delta.text === "string" && delta.text.length > 0) ||
(typeof delta.thinking === "string" && delta.thinking.length > 0) ||
(typeof delta.partial_json === "string" && delta.partial_json.length > 0) ||
(typeof delta.signature === "string" && delta.signature.length > 0)
);
}
if (event.type === "message_delta") {
if (isRecord(event.delta) && event.delta.stop_reason != null) return true;
if (!isRecord(event.usage) || typeof event.usage.output_tokens !== "number") return false;
if (event.usage.output_tokens <= outputTokens) return false;
outputTokens = event.usage.output_tokens;
return true;
}
return false;
};
}

function isTransientStreamEnvelopeError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return (
Expand Down Expand Up @@ -1457,6 +1491,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
let sawEvent = false;
let sawMessageStart = false;
let sawTerminalEnvelope = false;
const isProgressEvent = createAnthropicStreamProgressPredicate();

for await (const event of iterateWithIdleTimeout(anthropicStream, {
idleTimeoutMs,
Expand All @@ -1466,6 +1501,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
onIdle: () => activeAbortTracker.abortLocally(idleTimeoutAbortError),
onFirstItemTimeout: () => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
abortSignal: options?.signal,
isProgressItem: isProgressEvent,
})) {
sawEvent = true;
if (sawProviderSafetyStop) {
Expand Down
22 changes: 15 additions & 7 deletions packages/ai/src/utils/idle-iterator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,28 @@ function normalizeIdleTimeoutMs(value: string | undefined, fallback: number): nu
/**
* Returns the idle timeout used for provider streaming transports.
*
* `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` is accepted as a backward-compatible alias.
* `GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS` is honored first; `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` is a backward-compatible alias.
* Set `PI_STREAM_IDLE_TIMEOUT_MS=0` to disable the watchdog.
*
* Providers that legitimately stream much slower than the global default can pass
* `fallbackMs` to widen the floor used when neither env var nor caller option is set.
* Caller options still take precedence; env overrides still trump the fallback.
*/
export function getStreamIdleTimeoutMs(fallbackMs: number = DEFAULT_STREAM_IDLE_TIMEOUT_MS): number | undefined {
return normalizeIdleTimeoutMs($env.PI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS, fallbackMs);
return normalizeIdleTimeoutMs(
$env.GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS,
fallbackMs,
);
}

/**
* Returns the idle timeout used for OpenAI-family streaming transports.
*
* Set `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS=0` to disable the watchdog.
* Honors `GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS` first (`PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` is the legacy alias). Set `=0` to disable.
*/
export function getOpenAIStreamIdleTimeoutMs(): number | undefined {
return normalizeIdleTimeoutMs(
$env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS,
$env.GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS,
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
);
}
Expand Down Expand Up @@ -173,16 +176,14 @@ export async function* iterateWithIdleTimeout<T>(
}
}

const nextResultPromise = withRacy(iterator.next());

const racers: Array<
Promise<
| { kind: "next"; result: IteratorResult<T> }
| { kind: "error"; error: unknown }
| { kind: "timeout" }
| { kind: "abort" }
>
> = [nextResultPromise];
> = [];

let timer: NodeJS.Timeout | undefined;
let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
Expand All @@ -207,6 +208,13 @@ export async function* iterateWithIdleTimeout<T>(
racers.push(promise);
}

// Arm timeout/abort races before asking the source for its next item. A
// periodic keepalive iterator commonly registers its own timer inside
// `next()`; registering that first lets equal-deadline keepalives win every
// race and extend the idle window forever. Already-buffered items still
// settle as microtasks before a 0ms watchdog.
racers.unshift(withRacy(iterator.next()));

try {
const outcome = await Promise.race(racers);
if (outcome.kind === "abort") {
Expand Down
51 changes: 51 additions & 0 deletions packages/ai/test/anthropic-stream-timeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,4 +300,55 @@ describe("anthropic first-event timeout retries", () => {
},
]);
});

it("does not let Anthropic ping events keep a stalled response alive", async () => {
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => {
const response = new Response(null, { status: 200, headers: { "request-id": "req_ping_stall" } });
const data: MockAnthropicStream = {
async *[Symbol.asyncIterator]() {
yield {
type: "message_start",
message: {
id: "msg_ping_stall",
usage: {
input_tokens: 12,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
};
yield {
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
};
yield {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "checking" },
};
while (!requestOptions?.signal?.aborted) {
await Bun.sleep(1);
yield { type: "ping" };
}
},
};
return {
async withResponse() {
return { data, response, request_id: "req_ping_stall" };
},
} as never;
}) as unknown as Anthropic["messages"]["create"];
const client = { messages: { create } } as Anthropic;

const result = await streamAnthropic(model, context, {
client,
streamFirstEventTimeoutMs: 5000,
streamIdleTimeoutMs: 5,
}).result();

expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Anthropic stream stalled while waiting for the next event");
});
});
31 changes: 31 additions & 0 deletions packages/ai/test/stream-timeout-defaults.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import {
getOpenAIStreamIdleTimeoutMs,
getProviderFirstEventTimeoutFallbackMs,
getStreamFirstEventTimeoutMs,
getStreamIdleTimeoutMs,
Expand All @@ -17,6 +18,7 @@ import {
const ENV_KEYS = [
"PI_STREAM_IDLE_TIMEOUT_MS",
"PI_OPENAI_STREAM_IDLE_TIMEOUT_MS",
"GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS",
"PI_STREAM_FIRST_EVENT_TIMEOUT_MS",
] as const;

Expand Down Expand Up @@ -63,6 +65,35 @@ describe("getStreamIdleTimeoutMs(fallbackMs)", () => {
Bun.env.PI_STREAM_IDLE_TIMEOUT_MS = "0";
expect(getStreamIdleTimeoutMs(300_000)).toBeUndefined();
});

it("honors the documented GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS override", () => {
Bun.env.GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS = "77";
expect(getStreamIdleTimeoutMs(300_000)).toBe(77);
});

it("resolves GJC-first: GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS wins over legacy PI_STREAM_IDLE_TIMEOUT_MS", () => {
Bun.env.GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS = "77";
Bun.env.PI_STREAM_IDLE_TIMEOUT_MS = "42";
expect(getStreamIdleTimeoutMs(300_000)).toBe(77);
});

it("treats GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS=0 as a watchdog disable", () => {
Bun.env.GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS = "0";
expect(getStreamIdleTimeoutMs(300_000)).toBeUndefined();
});
});

describe("getOpenAIStreamIdleTimeoutMs()", () => {
it("honors the documented GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS first", () => {
Bun.env.GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS = "88";
Bun.env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS = "42";
expect(getOpenAIStreamIdleTimeoutMs()).toBe(88);
});

it("falls back to the legacy PI_OPENAI_STREAM_IDLE_TIMEOUT_MS alias", () => {
Bun.env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS = "42";
expect(getOpenAIStreamIdleTimeoutMs()).toBe(42);
});
});

describe("getStreamFirstEventTimeoutMs(idleTimeoutMs, fallbackMs)", () => {
Expand Down
10 changes: 10 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@

## [Unreleased]

### Added

- Ralplan consensus planning now enforces a finite planner/revision iteration budget at the native write path (default 5, configurable via `gjc.ralplan.maxIterations`). Opening another planner/revision pass past the cap fails closed with exit code 3 and an operator-visible `PLANNING-STUCK` marker instead of silent unbounded re-review; `final`/post-interview escalation remains allowed without auto-implementation. The cap also floors against on-disk `stage-*-{planner,revision}.md` artifacts so a wiped, truncated, or malformed `index.jsonl` cannot fail open after prior openers (#3165).

### Fixed

- Questions about `ultragoal` behavior now stay on the direct-answer path instead of being misclassified as requests to start the durable workflow.
- Aligned the startup GJC Forge splash border with the composer trailing gutter, including the one-row constrained fallback.
- `gjc resume` and delete no longer pay a durable (fsync-backed) lock acquisition for managed session tombstones that have nothing left to reconcile; a scope with many accumulated already-completed tombstones opens noticeably faster (#3067).
- `gjc deep-interview apply-round-result` no longer fails with `DI_INTERNAL_ERROR` on every call, which made the deep-interview workflow unable to score a single round. Three defects stacked: the Round-0 topology gate is recorded as a permanently unscorable `answered` shell (`--round` must be >= 1) yet counted toward the "earlier rounds must be scored" precondition, deadlocking every later round; the round-result decoder materializes omitted optional keys as `undefined`, which canonical JSON rejected outright, so any request omitting `targeting`/`ontology`/`bookkeeping` could not be digested; and `scoreToUnits` tested the raw float product, so ordinary scores whose scaling misses the integer grid (`0.69 * 10_000` is `6900.000000000001`) were rejected as non-integral 1e-4 units. Round-0 gate shells are now excluded from the ordering precondition, canonical JSON drops `undefined` object properties like `JSON.stringify` (array elements and the top-level value stay strict), and 1e-4 unit conversion is decided from the shortest round-trip decimal so genuinely off-grid precision such as `0.00005` and `0.05000000000000001` is still rejected.
- Task output-limit environment overrides now accept only complete positive decimal safe integers; malformed, fractional, exponent-form, whitespace-padded, and precision-losing values fall back to the documented defaults instead of being partially parsed (#3175).

## [0.11.10] - 2026-07-25
### Changed
Expand All @@ -28,6 +36,8 @@
- Delegated-task and subagent status surfaces now distinguish provider recovery from normal running, identify first-event versus idle-stream stalls, show retry budget and provider-progress age, and aggregate concurrent degradation by provider (#3071).
- Telegram notification daemon ownership hardening (#3048): Bot API outcomes now share one honest classifier so both the initiating `429` response and cooldown-suppressed calls settle retryably instead of being lost or falsely rejected, including selected acknowledgements; exclusive operator work is registered before its callback can throw; notification health degrades corrupt daemon-state JSON to a warning; root-registration ownership tokens propagate through injected and built-in ensure, rollback, reconciliation, teardown, and abandoned-startup cleanup seams, with token-bearing rows refusing tokenless cleanup while genuinely legacy rows retain root-match behavior; and initial daemon readiness is published only after the matching heartbeat sidecar rename is durable, so no waiter can attach during the proof window.
- `/new`, `fork()`, handoff, `/resume`, and branch/tree-jump transitions now complete verified managed `local://` legacy-root migration for the successor session identity *before* that identity is published to the agent, the workflow-gate emitter, or extension hooks, so those consumers cannot resolve `local://` against an ungated root. Matches cold-start `createAgentSession()` (#2797) and extends `/resume` (#2925). Sending a prompt right after `/new` no longer fails with "local:// legacy migration must complete before path resolution". The `SessionManager` rotates its own session id before this gate runs, so a residual window remains between that rotation and gate completion; it has no reachable in-process synchronous `local://` consumer under the session-transition admission lease. Closing it atomically is tracked in #3138.
- Telegram notification topics now fence malformed successful `createForumTopic` responses per session endpoint, preventing repeated ambiguous topic creation while keeping explicit Bot API failures retryable.
- Workflow-state readers and handoff paths no longer write corrupt-state warnings straight to `process.stderr`, which painted raw bytes over the live TUI composer during interactive sessions. Warnings now route through the TUI-safe file logger while `gjc state read`/`status`/`handoff` still surface them on the structured command-result `stderr`, so corrupt state stays distinguishable from absent state for CLI/automation (#3002).

- Managed model fallback now gives each exhausted entry at most one retry with a rotated credential before advancing, so repeated quota failures cannot consume the attempts reserved for downstream models.
- Telegram notification topics now fence malformed successful `createForumTopic` responses per session endpoint, preventing repeated ambiguous topic creation while keeping explicit Bot API failures retryable.
Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/src/config/settings-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,11 @@ export const SETTINGS_SCHEMA = {
default: 0.05,
validate: (value: number) => Number.isFinite(value) && value > 0 && value <= 1,
},
"gjc.ralplan.maxIterations": {
type: "number",
default: 5,
validate: (value: number) => Number.isInteger(value) && value >= 1 && value <= 20,
},

// ────────────────────────────────────────────────────────────────────────
// Appearance
Expand Down
Loading