Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d661a30
refactor(coding-agent): move the semantic-edge ledger onto the event-…
snimu Sep 3, 2026
02f1cdd
fix(coding-agent): make the explicit ledger reader's ENOENT contract …
snimu Sep 4, 2026
9e6f959
docs(coding-agent): state the event-log tail rule once
snimu Sep 4, 2026
4904fa0
Merge remote-tracking branch 'origin/main' into refactor/semantic-edg…
snimu Sep 4, 2026
ebc1a94
fix(coding-agent): write event-log appends fully and gate appends on …
snimu Sep 4, 2026
e88fc98
fix(coding-agent): reclaim short event-log writes instead of completi…
snimu Sep 4, 2026
d760284
fix(coding-agent): leave the torn tail on a short write instead of re…
snimu Sep 4, 2026
d749cf9
refactor(coding-agent): compress event-log comments
snimu Sep 4, 2026
04e456d
Merge commit '915c78f42c248b08238dd27fcd4bcab32c60beab' into consolid…
sethkarten Sep 6, 2026
0fda5e4
fix(ai): omit the default service tier, reprice cache writes from mes…
sethkarten Sep 6, 2026
536b8fb
fix(tui,coding-agent): survive lone surrogates in table cells and ter…
sethkarten Sep 7, 2026
57b2406
fix(coding-agent): restart dead kernels on ensure() and read mcp>=2 t…
sethkarten Sep 7, 2026
aad009f
fix: one crash-safe owner for durable state writes
sethkarten Sep 7, 2026
dd95bad
fix(coding-agent): one zombie-aware process-liveness probe
sethkarten Sep 7, 2026
387e109
fix(coding-agent): snapshot transfer ids from the materialized cursor…
sethkarten Sep 7, 2026
f0f129b
fix(coding-agent): failed workers recover on touch; roster gaps answe…
sethkarten Sep 7, 2026
5e6b81f
fix(coding-agent): seven session and IO correctness defects
sethkarten Sep 7, 2026
d2efb41
fix(coding-agent): coalesce child-usage attribution and gate agent-st…
sethkarten Sep 7, 2026
2497b28
fix(coding-agent): incremental single-flight session metadata scans
sethkarten Sep 7, 2026
be1fac8
fix(coding-agent): memoize the passive RLM topology derivation
sethkarten Sep 7, 2026
5ccfad6
fix(coding-agent): preserve accounting and metadata across deferred u…
sethkarten Sep 7, 2026
1c2c9f2
fix: preserve session accounting and read-only persistence boundaries
sethkarten Sep 7, 2026
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
1 change: 1 addition & 0 deletions packages/ai/.changes/service-tier-default-omitted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed GitHub Copilot requests to omit unsupported service tiers while preserving explicit tiers for other providers, and corrected Anthropic cache-write pricing when streaming usage changes.
16 changes: 15 additions & 1 deletion packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import type {
MessageParam,
RawMessageStreamEvent,
} from "@anthropic-ai/sdk/resources/messages.js";
import { getAnthropicCacheWriteCost, hasStandardAnthropicCachePricing } from "../cache-pricing.js";
import {
type AnthropicCacheCreationUsage,
getAnthropicCacheWriteCost,
hasStandardAnthropicCachePricing,
} from "../cache-pricing.js";
import { getEnvApiKey } from "../env-api-keys.js";
import { calculateCost, clampThinkingLevel } from "../models.js";
import type {
Expand Down Expand Up @@ -695,6 +699,16 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
if (event.usage.cache_creation_input_tokens != null) {
output.usage.cacheWrite = event.usage.cache_creation_input_tokens;
}
// The SDK's MessageDeltaUsage type omits cache_creation, but the wire carries it.
const deltaCacheCreation = (event.usage as { cache_creation?: AnthropicCacheCreationUsage | null })
.cache_creation;
if (cacheControl && usesAnthropicCachePricing && deltaCacheCreation) {
cacheWriteCost = getAnthropicCacheWriteCost(
model.cost.input,
cacheControl.ttl === "1h" ? "1h" : "5m",
deltaCacheCreation,
);
}
output.usage.totalTokens =
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
calculateCost(
Expand Down
4 changes: 3 additions & 1 deletion packages/ai/src/providers/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,9 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
params.temperature = options?.temperature;
}

if (options?.serviceTier !== undefined) {
// GitHub Copilot rejects the service_tier FIELD itself (400) for every value.
// Elsewhere it is always sent: absence means "auto" (project tier), not "default".
if (options?.serviceTier !== undefined && model.provider !== "github-copilot") {
params.service_tier = options.serviceTier;
}

Expand Down
35 changes: 28 additions & 7 deletions packages/ai/test/anthropic-sse-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,13 @@ function createFakeAnthropicClient(response: Response): Anthropic {
} as unknown as Anthropic;
}

function createCacheUsageEvents(cacheCreation: {
ephemeral_5m_input_tokens: number;
ephemeral_1h_input_tokens: number;
}): Array<{ event: string; data: string }> {
const cacheWriteTokens = cacheCreation.ephemeral_5m_input_tokens + cacheCreation.ephemeral_1h_input_tokens;
type CacheCreation = { ephemeral_5m_input_tokens: number; ephemeral_1h_input_tokens: number };

function createCacheUsageEvents(
cacheCreation: CacheCreation,
deltaCacheCreation?: CacheCreation,
): Array<{ event: string; data: string }> {
const tokens = (c: CacheCreation) => c.ephemeral_5m_input_tokens + c.ephemeral_1h_input_tokens;
return [
{
event: "message_start",
Expand All @@ -94,7 +96,7 @@ function createCacheUsageEvents(cacheCreation: {
input_tokens: 12,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: cacheWriteTokens,
cache_creation_input_tokens: tokens(cacheCreation),
cache_creation: cacheCreation,
},
},
Expand All @@ -109,7 +111,8 @@ function createCacheUsageEvents(cacheCreation: {
input_tokens: 12,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: cacheWriteTokens,
cache_creation_input_tokens: tokens(deltaCacheCreation ?? cacheCreation),
...(deltaCacheCreation ? { cache_creation: deltaCacheCreation } : {}),
},
}),
},
Expand Down Expand Up @@ -153,6 +156,24 @@ describe("Anthropic raw SSE parsing", () => {
expect(result.usage.cost.cacheWrite).toBeCloseTo(testCase.expectedCacheWriteCost);
});

it("reprices cache writes from a message_delta usage breakdown", async () => {
const model = getModel("anthropic", "claude-haiku-4-5");
const response = createSseResponse(
createCacheUsageEvents(
{ ephemeral_5m_input_tokens: 1000, ephemeral_1h_input_tokens: 0 },
{ ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 2000 },
),
);
const result = await streamAnthropic(
model,
{ messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }] },
{ client: createFakeAnthropicClient(response), cacheRetention: "long" },
).result();

expect(result.usage.cacheWrite).toBe(2000);
// 2000 one-hour tokens at 2x input cost, not the stale 1.25x rate from message_start.
expect(result.usage.cost.cacheWrite).toBeCloseTo(0.004, 6);
});
it("preserves configured cache write pricing for non-Anthropic models", async () => {
const model = getModel("minimax", "MiniMax-M2.7-highspeed");
const response = createSseResponse(
Expand Down
2 changes: 2 additions & 0 deletions packages/ai/test/openai-codex-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,8 @@ describe("openai-codex streaming", () => {
});

it.each([
// "default" must stay on the wire: absence means "auto" (the project tier).
["gpt-5.1-codex", "default", 1],
["gpt-5.1-codex", "flex", 0.5],
["gpt-5.1-codex", "priority", 2],
["gpt-5.4", "priority", 2],
Expand Down
35 changes: 35 additions & 0 deletions packages/ai/test/openai-responses-copilot-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,41 @@ describe("openai-responses provider defaults", () => {
expect(captured).toEqual({ sessionId: null, clientRequestId: null });
});

it.each([
["github-copilot" as const, "auto" as const, false],
["github-copilot" as const, "default" as const, false],
["openai" as const, "default" as const, true],
])("scopes service_tier serialization to the provider (%s, %s)", async (provider, serviceTier, expected) => {
const base = getModel("openai", "gpt-5.4");
const model = { ...base, provider };
const sse = `data: ${JSON.stringify({
type: "response.completed",
response: {
status: "completed",
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2, input_tokens_details: { cached_tokens: 0 } },
},
})}\n\n`;
let wireBody: Record<string, unknown> | undefined;
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
wireBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
});

const result = await streamOpenAIResponses(
model,
{ systemPrompt: "sys", messages: [{ role: "user", content: "hi", timestamp: Date.now() }] },
{ apiKey: "test-key", serviceTier },
).result();

expect(result.stopReason).toBe("stop");
// Copilot rejects the FIELD for every value; elsewhere absence means "auto"
// (the project tier), so an explicit "default" must stay on the wire.
expect(wireBody && "service_tier" in wireBody).toBe(expected);
if (expected) {
expect((wireBody as Record<string, unknown>).service_tier).toBe(serviceTier);
}
});

it.each([
["gpt-5.4", "priority", 2],
["gpt-5.5", "priority", 2.5],
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/dead-kernel-memo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- A Python kernel that dies after a successful startup is restarted on the next use instead of every call being handed the dead kernel forever, and skill-MCP tools advertise their real input schemas again under mcp>=2 (the SDK renamed the field to input_schema).
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Moved the semantic-edge ledger's append and replay IO onto the shared event-log substrate. One behavior unified across both ledgers: an unterminated final line is an uncommitted append — skipped on read and truncated before the next append, never newline-completed.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Made every durable JSON/JSONL state write crash-safe through one shared atomic-write owner (temp file + rename, Windows rename retry): auth.json is no longer written in place (an interrupted write can no longer log you out everywhere), the auth migration writes its destination before destroying its sources, racing first-time settings writers no longer silently discard each other, and the kernel bootstrap lock can no longer be stolen mid-reclaim. Session files now repair crash damage (torn tails, zero-filled records) at open instead of silently losing the next message, and a session lease whose owner file is momentarily unreadable is no longer treated as stale and destroyed.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed unbounded session-journal growth from derived bookkeeping: child usage attribution now flushes one entry per child turn instead of one per model request, and idle status sweeps no longer persist fabricated fallback verdicts, duplicate statuses, or retry failed summary generations (including paid model calls) every 25 seconds on unchanged content.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed session-list refreshes re-reading entire session files on every change: metadata scans now resume from the last scanned byte offset, stop at the file size seen at scan start, and concurrent readers of the same session share one scan.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed daemon request latency on large agent trees: the passive-subagent topology is derived once and memoized, with every consumer (session list, snapshots, cron recovery, agent messaging, passivation) reading the cached walk until the spawn ledger, residency, or a child session file changes.
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/session-io-defects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Seven small correctness fixes: compaction keeps only the final turn when the budget is crossed inside trailing tool results (instead of silently keeping everything); a retry whose scheduled continue cannot run ends the retry instead of leaving the session stuck retrying; saved subagent sessions with a lost parent edge still display as subagents; tail truncation rescues an oversized final line even when output ends with a newline; a failed output-spill stream degrades to the in-memory tail instead of crashing the process; piped stdin and a CLI instruction are joined with a blank line instead of glued together; and frontmatter parses behind a UTF-8 BOM.
2 changes: 2 additions & 0 deletions packages/coding-agent/.changes/session-reader-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Prevented session export and daemon-client startup from repairing or rewriting transcripts owned by another process.
- Enforced the retained session-scan usage cache limit for oversized transcripts.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- The WebP EXIF chunk scan reads chunk sizes as unsigned, so a crafted or corrupt image can no longer hang the process in an infinite scan loop.
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/worker-snapshot-cursor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed chunked session-snapshot transfers so the transfer id names the exact materialized snapshot cut, and a mismatched or restarted transfer now fails only that transfer (clients resync) instead of bouncing the whole worker channel.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed zombie processes being treated as live owners by the daemon supervisor ownership registry, session leases, supervisor launch locks, `daemon ps` process stops, and update-restart liveness checks; all process liveness probes now share the zombie-aware helper.
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/worker-state-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed daemon sessions bricking behind a terminal failed worker state: attach, create, and retry now re-run recovery for a failed worker whose process is verified alive, and a known-but-still-recovering session answers with a structured retryable error instead of "Unknown active session".
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/zai-default-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- The zai provider default model now points at glm-5.3; the previous default was removed from the catalog and silently fell back to a template model.
49 changes: 29 additions & 20 deletions packages/coding-agent/src/cli/daemon-ps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ import {
import { defaultDaemonSocketDir, defaultDaemonSocketPath, normalizeSocketPath } from "../modes/daemon/daemon-socket.js";
import { acquireDaemonShutdownAdmission } from "../modes/daemon/daemon-supervisor-ownership.js";
import type { DaemonWorkerDescriptor } from "../modes/daemon/daemon-worker-protocol.js";
import { signalProcessGroupOrProcess } from "../utils/child-process.js";
import {
isProcessAlive,
processGroupHasLiveMember,
processIdExists,
signalProcessGroupIfHeld,
} from "../utils/child-process.js";
import { formatDaemonListTable } from "./daemon-ps-format.js";
import { promptYesNo } from "./daemon-stop-confirm.js";

Expand Down Expand Up @@ -1061,34 +1066,47 @@ async function stopTrackedProcess(
expectedStartId: string | undefined,
assertAdmission: () => Promise<void>,
): Promise<boolean> {
if (!isProcessAlive(pid)) {
if (trackedProcessStopped(pid)) {
return true;
}
if (!expectedStartId || getProcessStartId(pid) !== expectedStartId) {
if (!expectedStartId || !trackedLeaderIdentityCurrent(pid, expectedStartId)) {
return false;
}
await assertAdmission();
if (getProcessStartId(pid) !== expectedStartId) {
if (!trackedLeaderIdentityCurrent(pid, expectedStartId)) {
return false;
}
signalProcessGroupOrProcess(pid, "SIGTERM");
signalProcessGroupIfHeld(pid, "SIGTERM");
let deadline = Date.now() + 500;
while (isProcessAlive(pid) && Date.now() < deadline) {
while (!trackedProcessStopped(pid) && Date.now() < deadline) {
await delay(25);
}
if (!isProcessAlive(pid)) {
if (trackedProcessStopped(pid)) {
return true;
}
await assertAdmission();
if (getProcessStartId(pid) !== expectedStartId) {
if (!trackedLeaderIdentityCurrent(pid, expectedStartId)) {
return false;
}
signalProcessGroupOrProcess(pid, "SIGKILL");
signalProcessGroupIfHeld(pid, "SIGKILL");
deadline = Date.now() + 1000;
while (isProcessAlive(pid) && Date.now() < deadline) {
while (!trackedProcessStopped(pid) && Date.now() < deadline) {
await delay(25);
}
return !isProcessAlive(pid);
return trackedProcessStopped(pid);
}

/** A GROUP stop completes when the leader is gone AND no live member remains; unreaped zombies do not block it. */
function trackedProcessStopped(pid: number): boolean {
return !isProcessAlive(pid) && !processGroupHasLiveMember(pid);
}

/** Identity gates guard pid reuse, so they apply only while the leader exists; a pgid cannot be reused while members hold it. */
function trackedLeaderIdentityCurrent(pid: number, expectedStartId: string): boolean {
if (!processIdExists(pid)) {
return true;
}
return getProcessStartId(pid) === expectedStartId;
}

export async function runReap(json: boolean, force: boolean): Promise<void> {
Expand Down Expand Up @@ -1220,15 +1238,6 @@ async function forceKillDaemon(pid: number): Promise<void> {
}
}

function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Expand Down
10 changes: 1 addition & 9 deletions packages/coding-agent/src/cli/daemon-update-restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
DAEMON_WORKER_SUPERVISOR_SOCKET_ENV,
DAEMON_WORKER_TOKEN_ENV,
} from "../modes/daemon/daemon-worker-protocol.js";
import { isProcessAlive } from "../utils/child-process.js";
import { createCliSubprocessLaunchSpec } from "./subprocess-launch.js";

export const DAEMON_UPDATE_RESTART_COORDINATOR_FLAG = "--internal-update-restart-coordinator";
Expand Down Expand Up @@ -354,15 +355,6 @@ async function withCoordinatorRegistryGuard<T>(registryDir: string, action: () =
}
}

function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ESRCH";
}
return true;
}

function matchesProcessStartId(identity: DaemonUpdateRestartProcessIdentity): boolean {
if (!identity.processStartId) {
return true;
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/cli/initial-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function buildInitialMessage({
}

return {
initialMessage: parts.length > 0 ? parts.join("") : undefined,
initialMessage: parts.length > 0 ? parts.join("\n\n") : undefined,
initialImages: fileImages && fileImages.length > 0 ? fileImages : undefined,
};
}
Loading
Loading