diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1a11cebbdf..138f77c091 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ - Fixed an HTTP 400 that killed every deep-interview session on the `google-antigravity` provider before the first assistant turn. The Round-0 topology `ask` schema pinned `round` with `z.literal(0)`, which zod serializes as `const: 0` and the Cloud Code Assist normalizer rewrites to a numeric `enum: [0]` — a shape CCA rejects (`TYPE_STRING`). `round` is now pinned with an integer range `[0, 0]` instead, so the wire schema carries `type: integer` with the bounds spilled into the description (the same treatment `ambiguity` already gets) and no numeric enum remains. Runtime contract unchanged: only `0` validates (#4606). - The terminal-app integration docs now cite the upstream work that backs each support rating: Gajae Code is proposed for Paseo's in-app ACP provider catalog ([getpaseo/paseo#3471](https://github.com/getpaseo/paseo/pull/3471)) and for Orca's built-in agent registry ([stablyai/orca#15025](https://github.com/stablyai/orca/pull/15025)), while T3 Code has no GJC harness and the integration shape is under discussion upstream ([pingdotgg/t3code#7290](https://github.com/pingdotgg/t3code/discussions/7290)). - The README controller section is now generic and SDK-first: it is titled for OpenClaw / Hermes / Grokbot / your own bot, and the copy-paste bootstrap prompt drives GJC through the broker-bound `gjc sdk session` CLI and the bundled [`sdk-skills/`](https://github.com/Yeachan-Heo/gajae-code/tree/main/sdk-skills) procedures instead of the Coordinator MCP setup. The prompt now names the real surface: `list`/`inspect`/`raw query` discovery, `session.create|fork|resume|close` lifecycle with an idempotency key, `send --op-ref` plus `status` reconciliation (one fresh op-ref per logical prompt; `unknown` is uncertainty, not proof of non-execution), `tail --until-idle`, and the `ask.answer` / `workflow.gate_answer` control allowlist. Coordinator MCP is kept as the pointer for event-driven fan-out across worktrees. +- Every session start under a non-writable cwd (e.g. a Windows console defaulting to `C:\Windows\System32`) no longer dies with an uncaught `EPERM` before any output: `FileGateStore.beginRuntimeInstance` no longer flushes at construction when the store holds no gates and no counters (the runtime instance id rides along with the first real mutation, preserving the documented lazy first-write contract), and `flushState` now runs `mkdirSync` inside the typed write boundary so a genuinely unwritable directory surfaces as `GateStoreWriteError` instead of a raw `ErrnoException` escaping the store abstraction (#4568). ## [0.14.0] - 2026-08-17 - Documented how to run GJC inside external agent shells. `README.md` gains a support-rated integration table for [Paseo](https://paseo.sh) (★★★★★ — `gjc setup paseo` writes a conformance-tested ACP provider), [Orca](https://onorca.dev) (★★★★ — GJC runs as a custom CLI agent per worktree), and [T3 Code](https://t3.codes) (★★★ experimental — no GJC harness exists upstream yet), and [`docs/terminal-app-integrations.md`](../../docs/terminal-app-integrations.md) carries the per-host setup, verification, cancel-semantics, and troubleshooting detail. The T3 Code row is deliberately marked unsupported rather than advertising an install command for a bridge that does not exist. diff --git a/packages/coding-agent/src/modes/shared/agent-wire/workflow-gate-broker.ts b/packages/coding-agent/src/modes/shared/agent-wire/workflow-gate-broker.ts index aa76dde43b..ed5d087414 100644 --- a/packages/coding-agent/src/modes/shared/agent-wire/workflow-gate-broker.ts +++ b/packages/coding-agent/src/modes/shared/agent-wire/workflow-gate-broker.ts @@ -1041,10 +1041,10 @@ export class FileGateStore implements GateStore { } } private flushState(next: FileState): void { - mkdirSync(path.dirname(this.filePath), { recursive: true }); const tmp = `${this.filePath}.tmp-${process.pid}-${Date.now()}`; let renamed = false; try { + mkdirSync(path.dirname(this.filePath), { recursive: true }); const fd = openSync(tmp, "w"); try { writeFileSync(fd, JSON.stringify(next, null, 2)); @@ -1125,6 +1125,15 @@ export class FileGateStore implements GateStore { } } next.runtimeInstanceId = instanceId; + // Nothing to persist yet: an empty store carries no gates or counters a + // later process could recover or quarantine, so stamping the runtime + // instance id now would mkdir under the cwd at construction time and + // defeat the lazy first-write contract (#4568). Adopt it in memory so it + // rides along with the first real mutation instead. + if (Object.keys(next.gates).length === 0 && Object.keys(next.counters).length === 0) { + this.state = next; + return; + } this.commit(next); } list(): PersistedGate[] { diff --git a/packages/coding-agent/test/sdk-workflow-gate-emitter.test.ts b/packages/coding-agent/test/sdk-workflow-gate-emitter.test.ts index d620741504..3ca346e51d 100644 --- a/packages/coding-agent/test/sdk-workflow-gate-emitter.test.ts +++ b/packages/coding-agent/test/sdk-workflow-gate-emitter.test.ts @@ -701,6 +701,16 @@ describe("SDK ToolSession forwards getWorkflowGateEmitter", () => { "workflow-gates.json", ); try { + // #4568: construction must not mkdir under the cwd; the durable store + // materializes on the first persisted gate, proving the persistent + // session still uses the file-backed store. + expect(fs.existsSync(persistentGatePath)).toBe(false); + const gatePromise = persistentSession.getWorkflowGateEmitter()!.emitGate({ + stage: "deep-interview", + kind: "question", + schema: { type: "string" }, + }); + gatePromise.catch(() => {}); expect(fs.existsSync(persistentGatePath)).toBe(true); } finally { await persistentSession.dispose(); diff --git a/packages/coding-agent/test/workflow-gate-broker.test.ts b/packages/coding-agent/test/workflow-gate-broker.test.ts index 4734b1980a..93fae75e15 100644 --- a/packages/coding-agent/test/workflow-gate-broker.test.ts +++ b/packages/coding-agent/test/workflow-gate-broker.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import * as path from "node:path"; @@ -7,6 +8,7 @@ import type { GateContinuation } from "../src/modes/shared/agent-wire/workflow-g import { FileGateStore, type GateAuditEvent, + GateStoreWriteError, isUnsupportedWindowsDirectorySyncError, MemoryGateStore, WorkflowGateBroker, @@ -491,7 +493,7 @@ describe("WorkflowGateBroker", () => { let syncs = 0; const store = new FileGateStore(file, () => { syncs++; - if (syncs === 8) throw new Error("parent fsync failed after accepted rename"); + if (syncs === 6) throw new Error("parent fsync failed after accepted rename"); }); const broker = new WorkflowGateBroker("run-uncertain-accepted", store, { advance: () => {} }); const gate = broker.openGate( @@ -507,4 +509,83 @@ describe("WorkflowGateBroker", () => { lifecycle: { reason: "continuation_owner_lost" }, }); }); + it("does not mkdir at construction on a fresh empty store under a non-writable cwd (#4568)", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gate-unwritable-fresh-")); + const storePath = path.join(dir, "workspace", ".gjc", "_session-s1", "state", "workflow-gates.json"); + const broker = new WorkflowGateBroker( + "run-4568-fresh", + new FileGateStore(storePath), + {}, + "8184568a-0000-4000-8000-000000000001", + ); + expect(broker.listPendingGates()).toEqual([]); + expect(fs.existsSync(path.dirname(storePath))).toBe(false); + + // The instance id rides along with the first real mutation. + const gate = broker.openGate({ stage: "ralplan", kind: "approval", schema: { type: "string" } }); + expect(fs.existsSync(storePath)).toBe(true); + const persisted = JSON.parse(readFileSync(storePath, "utf8")) as { runtimeInstanceId?: string }; + expect(persisted.runtimeInstanceId).toBe("8184568a-0000-4000-8000-000000000001"); + expect(new FileGateStore(storePath).get(gate.gate_id)).toMatchObject({ + status: "quarantined", + ownerInstanceId: "8184568a-0000-4000-8000-000000000001", + }); + }); + it("surfaces an unwritable directory as a typed GateStoreWriteError instead of a raw errno (#4568)", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gate-unwritable-write-")); + const storePath = path.join(dir, "workspace", ".gjc", "_session-s2", "state", "workflow-gates.json"); + const broker = new WorkflowGateBroker( + "run-4568-write", + new FileGateStore(storePath), + {}, + "8184568b-0000-4000-8000-000000000002", + ); + const eperm = new Error("EPERM: operation not permitted, mkdir") as NodeJS.ErrnoException; + eperm.code = "EPERM"; + const mkdirSync = vi.spyOn(fs, "mkdirSync").mockImplementation((() => { + throw eperm; + }) as typeof fs.mkdirSync); + try { + expect(() => broker.openGate({ stage: "ralplan", kind: "approval", schema: { type: "string" } })).toThrow( + GateStoreWriteError, + ); + } finally { + mkdirSync.mockRestore(); + } + // After the typed failure nothing was committed: the first real write retries cleanly. + expect(fs.existsSync(storePath)).toBe(false); + const gate = broker.openGate({ stage: "ralplan", kind: "approval", schema: { type: "string" } }); + expect(new FileGateStore(storePath).get(gate.gate_id)).toMatchObject({ status: "quarantined" }); + }); + it("keeps quarantining prior-instance records at construction once state exists (#4568)", () => { + const dir = mkdtempSync(path.join(tmpdir(), "gate-unwritable-restart-")); + const file = path.join(dir, "gates.json"); + const first = new WorkflowGateBroker( + "run-4568-restart", + new FileGateStore(file), + {}, + "8184568c-0000-4000-8000-000000000003", + ); + const gate = first.openGate( + { stage: "ralplan", kind: "approval", schema: { type: "string" } }, + liveContinuation(), + ); + const before = JSON.parse(readFileSync(file, "utf8")) as { counters: Record }; + const second = new WorkflowGateBroker( + "run-4568-restart", + new FileGateStore(file), + {}, + "8184568d-0000-4000-8000-000000000004", + ); + expect(second.listGateDiagnostics()).toMatchObject([ + { gate_id: gate.gate_id, lifecycle: { reason: "orphaned_after_process_restart" } }, + ]); + // The restart rewrite keeps the committed counter and stamps the new instance id. + const after = JSON.parse(readFileSync(file, "utf8")) as { + counters: Record; + runtimeInstanceId: string; + }; + expect(after.counters.ralplan).toBe(before.counters.ralplan); + expect(after.runtimeInstanceId).toBe("8184568d-0000-4000-8000-000000000004"); + }); });