From 6df7e3d90e1651e37943a0ff907769dc97f8d6c6 Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 10:33:41 +0800 Subject: [PATCH 01/19] docs: extend permission lifecycle through broker --- ...01-zcode-0165-permission-turn-lifecycle.md | 43 +++++++++++++++++++ ...e-0165-permission-turn-lifecycle-design.md | 25 ++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-01-zcode-0165-permission-turn-lifecycle.md b/docs/superpowers/plans/2026-09-01-zcode-0165-permission-turn-lifecycle.md index bbb108e..f0f54a4 100644 --- a/docs/superpowers/plans/2026-09-01-zcode-0165-permission-turn-lifecycle.md +++ b/docs/superpowers/plans/2026-09-01-zcode-0165-permission-turn-lifecycle.md @@ -6,6 +6,8 @@ **Architecture:** Add a non-destructive completion observer alongside the existing destructive waiter in the protocol/client layer. Migrate only `executeJob` to that observer and explicitly release its local turn during unconditional teardown; retain compatibility fallbacks for injected test clients that expose only the historical interface. +**Follow-up architecture:** Carry that lifecycle boundary through the managed broker. The broker forwards legacy completion without consuming its upstream turn, and the managed client acknowledges the executor's authoritative cleanup through an exact-owner broker control request. Also accept the observed bounded `requestedAt` field on 0.16.5 permission requests. + **Tech Stack:** Node.js 22.13, ECMAScript modules, `node:test`, the existing JSON-RPC protocol client and job executor. --- @@ -17,6 +19,10 @@ - Modify `scripts/lib/review.mjs`: use non-destructive observation for `legacyWake` and release the local turn during teardown. - Modify `tests/process-zcode.test.mjs`: cover low-level observer, permission, timeout, release, and destructive-wait invariants. - Modify `tests/job-control.test.mjs`: cover captured 0.16.5 executor ordering and success/error cleanup. +- Modify `scripts/zcode-broker.mjs`: retain the upstream route through legacy wake and release it only on exact managed-client acknowledgement or existing authoritative cleanup. +- Modify `tests/zcode-client.test.mjs`: cover broker ordering, acknowledgement ownership, cleanup, and permission forwarding. +- Modify `tests/fixtures/fake-zcode-cli.mjs`: emit the captured 0.16.5 permission shape after the false legacy completion. +- Modify `tests/integration/companion.test.mjs`: cover fresh and resumed production managed paths. ### Task 1: Add non-destructive completion observation @@ -216,3 +222,40 @@ git commit -m "build: refresh marketplace snapshot" ``` If there are no generated tracked changes, skip this commit. + +### Task 4: Reproduce the managed-broker and 0.16.5 schema failures + +**Files:** +- Test: `tests/zcode-client.test.mjs` +- Test fixture: `tests/fixtures/fake-zcode-cli.mjs` +- Test: `tests/process-zcode.test.mjs` + +- [ ] Add an opt-in captured fixture permission request after the false legacy completion and before the authoritative v4 terminal. Include the real numeric `requestedAt` field. +- [ ] Add a direct protocol test proving the current validator rejects that captured field. +- [ ] Add a managed broker test proving the early completion currently removes the route and the later permission is not allowed. +- [ ] Run only these tests and record deterministic RED output before changing production code. + +### Task 5: Retain and explicitly acknowledge managed broker turns + +**Files:** +- Modify: `scripts/lib/zcode-protocol.mjs` +- Modify: `scripts/lib/zcode-client.mjs` +- Modify: `scripts/zcode-broker.mjs` +- Test: `tests/zcode-client.test.mjs` +- Test: `tests/process-zcode.test.mjs` + +- [ ] Accept only bounded optional `requestedAt` permission timestamps. +- [ ] Replace broker-side destructive terminal consumption with forwarding/non-destructive observation that retains the exact upstream turn. +- [ ] Add an authenticated exact-session broker acknowledgement used by managed `releaseTurn()`. It locally releases the upstream turn, settles exact pending permissions, removes the route, and is idempotent for the same completed downstream cleanup without stopping the ZCode session or changing durable ownership. +- [ ] Reject foreign, stale, malformed, and conflicting acknowledgements without touching a newer turn. +- [ ] Run focused tests to GREEN, self-review, and commit. + +### Task 6: Lock the production fresh/resumed path and re-verify release readiness + +**Files:** +- Modify: `tests/integration/companion.test.mjs` +- Modify if required by generated parity checks: marketplace snapshot through the existing builder only + +- [ ] Extend the captured 0.16.5 managed integration to require an allowed permission response after the false legacy completion for both fresh and resumed sends. +- [ ] Run focused protocol, broker, executor, and companion tests. +- [ ] Run `npm run check`, review the complete follow-up diff, refresh the marketplace snapshot if required, and complete spec then quality review before pushing PR #52. diff --git a/docs/superpowers/specs/2026-09-01-zcode-0165-permission-turn-lifecycle-design.md b/docs/superpowers/specs/2026-09-01-zcode-0165-permission-turn-lifecycle-design.md index b9e6387..da850be 100644 --- a/docs/superpowers/specs/2026-09-01-zcode-0165-permission-turn-lifecycle-design.md +++ b/docs/superpowers/specs/2026-09-01-zcode-0165-permission-turn-lifecycle-design.md @@ -6,17 +6,25 @@ The executor already treats ZCode 0.16.5 `state.updated` notifications with reas ZCode 0.16.5 can emit that legacy notification immediately after admission, then request tool permission while the real runtime turn is still active. Once the legacy waiter deletes the turn, the permission request fails the exact-active-turn check and is returned as JSON-RPC `-32000` (`ZCODE_PERMISSION_SESSION_INVALID`). This is independent of the configured Codex permission mode: the incident job already carried `bypassPermissions`. +### Follow-up incident: managed broker boundary + +The first implementation preserved the executor-side broker client turn, but production uses two protocol layers. The managed broker still registered `consumeTerminalsWith()` on its upstream appserver connection. That callback consumed the same admission-time legacy notification, settled permission routes, and deleted the broker's active session before the executor could perform authoritative v4/snapshot reconciliation. The resumed incident therefore failed before `decidePermission()` was reached even though the persisted snapshot was `bypassPermissions` and the installed plugin matched the fix byte-for-byte. + +ZCode 0.16.5 permission requests also carry a numeric `requestedAt` field. The captured fixture omitted it and the strict protocol validator rejected it. Compatibility must accept this bounded transport metadata while continuing to reject unknown fields. + ## Goals - Keep the exact turn armed after an admission-time legacy completion wake so later permission requests can be evaluated normally. - Preserve the current destructive behavior of `waitForCompletion()` for every existing caller. - Clear local turn state when `executeJob` reaches its real terminal or cleanup boundary. +- Keep the managed broker's upstream turn and permission route alive until the executor confirms its authoritative terminal/cleanup boundary. +- Accept the observed 0.16.5 `requestedAt` permission-request field. - Cover the captured 0.16.5 event ordering with deterministic regression tests. ## Non-goals - No change to `decidePermission`, permission snapshots, risk mapping, or offered-response validation. -- No new appserver flag, permission field, broker authorization rule, or persisted schema. +- No new appserver flag, permission policy, broker ownership rule, or persisted schema. - No reinterpretation of legacy completion as authoritative success or failure. - No broad refactor of the protocol or executor lifecycle. @@ -38,10 +46,23 @@ Because the wake no longer consumes local state, `executeJob` must explicitly re The cleanup operation is local and idempotent. It must not send `session/stop`, alter durable job state, or replace the existing cancellation paths. Successful terminal, provider failure, remote interruption, local abort, timeout, and error cleanup all converge on the same local turn release when a session was created or resumed. +### Managed broker terminal acknowledgement + +For a direct appserver client, `releaseTurn()` remains local. For an authenticated managed-broker client, release first sends a narrow broker-only acknowledgement for the exact session turn, then clears the downstream local turn. The broker validates session ownership and the exact active socket/token, locally releases the corresponding upstream protocol turn, settles only that turn's pending permission tasks, and removes its active route. It must not call `session/stop`, release durable session ownership, or accept a stale/foreign acknowledgement. + +The broker no longer treats a legacy `prompt_completed` notification as authority to delete its route. It forwards the validated notification to the active client as a wake and retains the upstream turn. Existing authoritative stop, owner-release, disconnect, protocol-close, and explicit terminal-acknowledgement paths remain responsible for cleanup. + +### Permission request compatibility + +The strict request validator accepts optional `requestedAt` only when it is a finite, non-negative safe integer timestamp. All existing required fields, risk levels, option validation, exact offered-response validation, and unknown-field rejection remain unchanged. The captured 0.16.5 fixture includes this field so broker and direct-client tests exercise the production request shape. + ### Safety invariants - A validated early legacy completion leaves `turnState(sessionId) === 'armed'`. - A later permission request for that session reaches the configured handler and returns one offered response. +- A managed broker retains the exact route after an early legacy completion and forwards a later permission request. +- Only the exact owning client can acknowledge and release the broker turn. +- A 0.16.5 request with bounded `requestedAt` is accepted; malformed timestamps and unknown fields remain rejected. - Authoritative executor teardown leaves `turnState(sessionId) === null`. - Ordinary `waitForCompletion()` still leaves `turnState(sessionId) === null` immediately after resolution. - Permission policy and durable job outcomes are unchanged. @@ -53,6 +74,8 @@ The cleanup operation is local and idempotent. It must not send `session/stop`, 3. Add an executor regression with captured 0.16.5 ordering: admission, early legacy wake, later permission request, authoritative terminal, successful result, and final local turn cleanup. 4. Exercise cleanup on a non-success path so an observer cannot leave an armed turn behind. 5. Run focused tests, then `npm run check` before review and PR creation. +6. Add a broker-level captured-order regression where permission follows the false legacy completion, and assert explicit acknowledgement releases both protocol layers. +7. Exercise fresh and resumed managed execution with the same captured ordering. ## Rollout and compatibility From 91c40c7d9f6160654865666cf7e1ec6959c1c423 Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 10:47:05 +0800 Subject: [PATCH 02/19] fix: release managed turns at exact broker boundary --- scripts/lib/zcode-client.mjs | 17 +++++---- scripts/lib/zcode-protocol.mjs | 8 +++-- scripts/zcode-broker.mjs | 33 +++++++++++++++--- tests/fixtures/fake-zcode-cli.mjs | 9 +++++ tests/process-zcode.test.mjs | 41 ++++++++++++++++++++++ tests/zcode-client.test.mjs | 57 +++++++++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 13 deletions(-) diff --git a/scripts/lib/zcode-client.mjs b/scripts/lib/zcode-client.mjs index 3f9c717..5e257d0 100644 --- a/scripts/lib/zcode-client.mjs +++ b/scripts/lib/zcode-client.mjs @@ -31,7 +31,7 @@ export const IMPORTED_HISTORY_SOURCE = 'claudeCode'; export class ZCodeClient { /** @param {import('./zcode-protocol.mjs').ZCodeProtocolClient} protocol @param {string} [workspace] @param {boolean} [workspaceBound] */ - constructor(protocol, workspace, workspaceBound = false) { this.protocol = protocol; this.defaultWorkspace = workspace === undefined ? null : resolve(workspace); this.workspaceBound = workspaceBound; this.sessionCatalogs = new Map(); this.sessionWorkspaces = new Map(); this.initialEmptySessions = new Set(); } + constructor(protocol, workspace, workspaceBound = false) { this.protocol = protocol; this.defaultWorkspace = workspace === undefined ? null : resolve(workspace); this.workspaceBound = workspaceBound; this.sessionCatalogs = new Map(); this.sessionWorkspaces = new Map(); this.initialEmptySessions = new Set(); this.exactTurnRelease = workspaceBound ? null : false; this.exactTurnReleaseProbe = null; this.armedBoundaries = new Map(); } /** @param {{workspace:string,sessionId?:string,model?:{providerId:string,modelId:string,variant?:string},importedHistory?:{title?:string,createdAt?:number,updatedAt?:number,messages:Array<{role:'user'|'assistant',content:string,timestamp?:number}>}}} input */ async createSession(input) { @@ -73,6 +73,7 @@ export class ZCodeClient { /** @param {string} sessionId @param {string} content @param {Record} [options] */ async send(sessionId, content, options = {}) { requireSessionId(sessionId); if (typeof content !== 'string') throw inputError(); requireExactObject(options, [], []); + await this.ensureExactTurnReleaseCapability(); this.initialEmptySessions.delete(sessionId); this.protocol.beginTurn(sessionId); const inputId = randomUUID(); @@ -80,14 +81,15 @@ export class ZCodeClient { try { result = await this.protocol.request('session/send', { sessionId, inputId, queryId: inputId, content }); } catch (error) { this.protocol.abortTurn(sessionId); throw error; } if (!plainObject(result) || result.accepted !== true || result.sessionId !== sessionId || !Number.isSafeInteger(result.stateRevision) || result.stateRevision < 0 || result.modelRuntimeRevision !== undefined && !nonEmpty(result.modelRuntimeRevision)) { this.protocol.abortTurn(sessionId); throw outputError('session/send'); } this.protocol.armTurn(sessionId, result.stateRevision, inputId); + this.armedBoundaries.set(sessionId, { stateRevision: result.stateRevision, inputId }); return { ...result, inputId }; } /** @param {string} sessionId */ async readSession(sessionId) { requireSessionId(sessionId); const result = await this.protocol.request('session/read', { sessionId }); validateSnapshot(result, sessionId, this.expectedWorkspace(sessionId), 'session/read'); this.sessionCatalogs.set(sessionId, result.settings.model); return result; } /** @param {string} sessionId */ async resumeSession(sessionId) { requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); const result = await this.protocol.request('session/resume', { sessionId }); validateSnapshot(result, sessionId, this.expectedWorkspace(sessionId), 'session/resume'); this.sessionCatalogs.set(sessionId, result.settings.model); this.sessionWorkspaces.set(sessionId, result.session.workspace.workspacePath); return result; } /** @param {number} [timeoutMs] */ async listSessions(timeoutMs) { const result = requireObjectResult(await this.protocol.request('session/list', {}, timeoutMs), 'session/list'); if (!Array.isArray(result.sessions) || !result.sessions.every(validSessionInfo)) throw outputError('session/list'); return result; } - /** @param {string} sessionId @param {number} [timeoutMs] */ async stopSession(sessionId, timeoutMs) { requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); const result = await this.protocol.request('session/stop', { sessionId }, timeoutMs); if (!boundedUpstreamObject(result)) throw outputError('session/stop'); if (!this.protocol.acceptBrokerControl) this.protocol.cancelTurn(sessionId); return {}; } - /** @param {number} [timeoutMs] */ async brokerCapabilities(timeoutMs) { const result = await requestBrokerHealth(this, timeoutMs); return { releaseOwnerExclusions: result.capabilities?.releaseOwnerExclusions === true }; } + /** @param {string} sessionId @param {number} [timeoutMs] */ async stopSession(sessionId, timeoutMs) { requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); const result = await this.protocol.request('session/stop', { sessionId }, timeoutMs); if (!boundedUpstreamObject(result)) throw outputError('session/stop'); if (!this.protocol.acceptBrokerControl) this.protocol.cancelTurn(sessionId); this.armedBoundaries.delete(sessionId); return {}; } + /** @param {number} [timeoutMs] */ async brokerCapabilities(timeoutMs) { const result = await requestBrokerHealth(this, timeoutMs, true); this.exactTurnRelease = result.capabilities?.exactTurnRelease === true; return { releaseOwnerExclusions: result.capabilities?.releaseOwnerExclusions === true }; } /** @param {string[]} [excludeSessionIds] @param {number} [timeoutMs] */ async releaseOwner(excludeSessionIds, timeoutMs) { if (excludeSessionIds !== undefined && (!Array.isArray(excludeSessionIds) || excludeSessionIds.length > 1_000 || new Set(excludeSessionIds).size !== excludeSessionIds.length || !excludeSessionIds.every((sessionId) => isSafeIdentifier(sessionId)))) throw inputError(); const result = await this.protocol.request('broker/releaseOwner', excludeSessionIds === undefined ? {} : { excludeSessionIds }, timeoutMs); if (!plainObject(result) || !Array.isArray(result.releasedSessionIds) || !Array.isArray(result.failedSessionIds) || !result.releasedSessionIds.every((sessionId) => isSafeIdentifier(sessionId)) || !result.failedSessionIds.every((sessionId) => isSafeIdentifier(sessionId)) || !Number.isSafeInteger(result.deferredSessionCount) || result.deferredSessionCount < 0) throw outputError('broker/releaseOwner'); return result; } @@ -128,9 +130,12 @@ export class ZCodeClient { this.sessionCatalogs.set(sessionId, result.settings.model); return result; } - /** Wait for a validated terminal notification; no deadline applies unless configured on the client or supplied here. @param {string} sessionId @param {number} [timeoutMs] */ waitForCompletion(sessionId, timeoutMs) { return this.protocol.waitForCompletion(sessionId, timeoutMs); } + /** Wait for a validated terminal notification; no deadline applies unless configured on the client or supplied here. @param {string} sessionId @param {number} [timeoutMs] */ async waitForCompletion(sessionId, timeoutMs) { const boundary = this.armedBoundaries.get(sessionId); const completion = await this.protocol.waitForCompletion(sessionId, timeoutMs); if (this.exactTurnRelease === true && boundary) await this.releaseExactTurn(sessionId, boundary); else this.armedBoundaries.delete(sessionId); return completion; } /** Observe a validated terminal notification without consuming the active turn. @param {string} sessionId @param {number} [timeoutMs] */ observeCompletion(sessionId, timeoutMs) { return this.protocol.observeCompletion(sessionId, timeoutMs); } - /** Locally release an active turn without sending an upstream request. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); this.protocol.releaseTurn(sessionId); } + /** Release the exact managed broker turn, then always clear local turn state. Direct clients clear synchronously. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); const boundary = this.armedBoundaries.get(sessionId); if (!this.workspaceBound || this.exactTurnRelease !== true || !boundary) { this.protocol.releaseTurn(sessionId); this.armedBoundaries.delete(sessionId); return Promise.resolve(); } return this.releaseExactTurn(sessionId, boundary); } + /** @param {string} sessionId @param {{stateRevision:number,inputId:string}} boundary */ + async releaseExactTurn(sessionId, boundary) { try { const result = await this.protocol.request('broker/releaseTurn', { sessionId, inputId: boundary.inputId, stateRevision: boundary.stateRevision }); if (!plainObject(result) || Object.keys(result).length !== 0) throw outputError('broker/releaseTurn'); } finally { this.protocol.releaseTurn(sessionId); if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); } } + async ensureExactTurnReleaseCapability() { if (!this.workspaceBound || this.exactTurnRelease !== null) return; this.exactTurnReleaseProbe ??= requestBrokerHealth(this, undefined, true).then((result) => { this.exactTurnRelease = result.capabilities?.exactTurnRelease === true; }, (error) => { this.exactTurnReleaseProbe = null; throw error; }); await this.exactTurnReleaseProbe; } /** Exact local protocol invariant used to prove whether this client owns an active turn. @param {string} sessionId */ turnState(sessionId) { requireSessionId(sessionId); return this.protocol.turnState(sessionId); } /** @param {string} sessionId @param {{connectionId:string,clientMode:'desktop-continuous'|'web-remote-replayable'}} options */ async subscribeConversation(sessionId, options) { @@ -278,7 +283,7 @@ async function releaseOwnerWithRetry(client, excludeSessionIds, deadline, reques async function verifyBrokerIdentity(client, identity, deadline, requestTimeoutMs) { const health = await requestBrokerHealth(client, boundedCleanupTimeout(deadline, requestTimeoutMs)); if (!Number.isSafeInteger(health.pid) || health.pid <= 1 || !isSafeIdentifier(health.instanceId) || health.pid !== identity.pid || health.instanceId !== identity.instanceId) throw outputError('broker/health'); return { releaseOwnerExclusions: health.capabilities?.releaseOwnerExclusions === true }; } /** @param {ZCodeClient} client @param {number|undefined} timeoutMs */ -async function requestBrokerHealth(client, timeoutMs) { const result = await client.protocol.request('broker/health', {}, timeoutMs); if (!plainObject(result) || result.ok !== true) throw outputError('broker/health'); return result; } +async function requestBrokerHealth(client, timeoutMs, advertiseExactTurnRelease = true) { const result = await client.protocol.request('broker/health', advertiseExactTurnRelease ? { clientCapabilities: { exactTurnRelease: true } } : {}, timeoutMs); if (!plainObject(result) || result.ok !== true) throw outputError('broker/health'); return result; } /** @param {unknown[]} errors */ function boundedCauseCodeCounts(errors) { const counts = /** @type {Record} */ ({}); for (const error of errors.slice(0, 32)) { const candidate = (/** @type {{code?:unknown}} */ (error))?.code; const code = typeof candidate === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(candidate) ? candidate : 'UNKNOWN'; counts[code] = (counts[code] ?? 0) + 1; } return counts; } diff --git a/scripts/lib/zcode-protocol.mjs b/scripts/lib/zcode-protocol.mjs index 186ec62..bd06443 100644 --- a/scripts/lib/zcode-protocol.mjs +++ b/scripts/lib/zcode-protocol.mjs @@ -48,6 +48,7 @@ export class ZCodeProtocolClient { this.closeHandler = null; this.terminalHandler = null; this.consumeTerminal = false; + this.terminalObserver = null; this.acceptBrokerControl = options.acceptBrokerControl === true; this.waiterSessions = new Set(); this.permissionRequestIds = new Map(); @@ -92,6 +93,8 @@ export class ZCodeProtocolClient { setSubscriberErrorHandler(handler) { if (typeof handler !== 'function') throw protocolInputError(); this.subscriberErrorHandler = handler; } /** Broker-only terminal hook. Validated terminal notifications are consumed before this callback. @param {(params:any,turn:{status:'armed',baseline:number,inputId:string})=>void} handler */ consumeTerminalsWith(handler) { if (typeof handler !== 'function') throw protocolInputError(); this.terminalHandler = handler; this.consumeTerminal = true; } + /** Broker-only non-destructive terminal hook. The terminal is not queued or expired. @param {(params:any,turn:{status:'armed',baseline:number,inputId:string})=>void} handler */ + observeTerminalsWith(handler) { if (typeof handler !== 'function') throw protocolInputError(); this.terminalObserver = handler; } /** @param {(error:PluginError)=>void} handler */ setCloseHandler(handler) { if (typeof handler !== 'function') throw protocolInputError(); this.closeHandler = handler; } /** @param {string} sessionId */ @@ -320,7 +323,7 @@ export class ZCodeProtocolClient { rejectCompletionWaiters(error) { for (const waiter of this.completionWaiters) { if (waiter.timer) clearTimeout(waiter.timer); waiter.unsubscribe(); this.waiterSessions.delete(waiter.sessionId); waiter.reject(error); } this.completionWaiters.clear(); } /** @param {string} sessionId @param {any} params */ - queueCompletion(sessionId, params) { if (this.consumeTerminal) { const turn = this.turns.get(sessionId); this.abortTurn(sessionId); if (turn?.status === 'armed' && typeof turn.baseline === 'number' && typeof turn.inputId === 'string') this.terminalHandler?.(params, { status: 'armed', baseline: turn.baseline, inputId: turn.inputId }); return; } if (!this.completed.has(sessionId) && this.completed.size >= 1024) { this.fail(new PluginError('ZCODE_COMPLETION_OVERFLOW', 'Too many unconsumed completions were received.', { category: 'protocol', remedy: 'Restart the connection and consume completions promptly.' })); return; } const queue = this.completed.get(sessionId) ?? []; queue.splice(0, queue.length, params); this.completed.set(sessionId, queue); clearTimeout(this.completionExpiry.get(sessionId)); const expiry = setTimeout(() => this.cancelTurn(sessionId), 10 * 60_000); expiry.unref?.(); this.completionExpiry.set(sessionId, expiry); } + queueCompletion(sessionId, params) { const turn = this.turns.get(sessionId); if (this.consumeTerminal) { this.abortTurn(sessionId); if (turn?.status === 'armed' && typeof turn.baseline === 'number' && typeof turn.inputId === 'string') this.terminalHandler?.(params, { status: 'armed', baseline: turn.baseline, inputId: turn.inputId }); return; } if (this.terminalObserver) { if (turn?.status === 'armed' && typeof turn.baseline === 'number' && typeof turn.inputId === 'string') this.terminalObserver(params, { status: 'armed', baseline: turn.baseline, inputId: turn.inputId }); return; } if (!this.completed.has(sessionId) && this.completed.size >= 1024) { this.fail(new PluginError('ZCODE_COMPLETION_OVERFLOW', 'Too many unconsumed completions were received.', { category: 'protocol', remedy: 'Restart the connection and consume completions promptly.' })); return; } const queue = this.completed.get(sessionId) ?? []; queue.splice(0, queue.length, params); this.completed.set(sessionId, queue); clearTimeout(this.completionExpiry.get(sessionId)); const expiry = setTimeout(() => this.cancelTurn(sessionId), 10 * 60_000); expiry.unref?.(); this.completionExpiry.set(sessionId, expiry); } } export class BoundedWriter { @@ -466,12 +469,13 @@ function redactSecrets(value) { /** @param {Record} value */ function validatePermissionRequest(value) { const required = ['requestId', 'sessionId', 'toolCallId', 'toolName', 'reason', 'riskLevel', 'input', 'options']; - const allowed = [...required, 'turnId', 'origin']; + const allowed = [...required, 'turnId', 'origin', 'requestedAt']; if (required.some((key) => !Object.hasOwn(value, key)) || Object.keys(value).some((key) => !allowed.includes(key)) || !required.slice(0, 5).every((key) => nonEmpty(value[key])) || !['low', 'medium', 'high', 'critical'].includes(value.riskLevel) || !Array.isArray(value.options) || value.options.length === 0 || !value.options.every(validPermissionOption) || value.turnId !== undefined && !nonEmpty(value.turnId) + || value.requestedAt !== undefined && (!Number.isSafeInteger(value.requestedAt) || value.requestedAt < 0) || value.origin !== undefined && !validPermissionOrigin(value.origin)) throw malformedFrame(); } /** @param {unknown} value */ diff --git a/scripts/zcode-broker.mjs b/scripts/zcode-broker.mjs index a071d2d..f7d21f3 100644 --- a/scripts/zcode-broker.mjs +++ b/scripts/zcode-broker.mjs @@ -31,12 +31,13 @@ const MAX_CONVERSATION_FRAME_BYTES = 64 * 1024; const MAX_OWNER_OPERATION_LEASES = 256; const MAX_CONCURRENT_OWNER_RELEASES = 16; const MAX_TERMINAL_WINNER_EVIDENCE = 256; +const MAX_RELEASED_TURN_TOMBSTONES = 256; const RAW_ENDPOINT_PROBE_MS = 100; export const MIN_BROKER_IDLE_TIMEOUT_MS = 1_000; export const MAX_BROKER_IDLE_TIMEOUT_MS = 3_600_000; -const LOCAL_BROKER_METHODS = new Set(['session/create', 'session/send', 'session/read', 'session/resume', 'session/list', 'session/stop', 'session/setModel', 'session/updateRuntimeModelConfig', 'session/setThoughtLevel', 'v4/conversation/subscribe', 'v4/conversation/unsubscribe', 'broker/health', 'broker/releaseOwner']); +const LOCAL_BROKER_METHODS = new Set(['session/create', 'session/send', 'session/read', 'session/resume', 'session/list', 'session/stop', 'session/setModel', 'session/updateRuntimeModelConfig', 'session/setThoughtLevel', 'v4/conversation/subscribe', 'v4/conversation/unsubscribe', 'broker/health', 'broker/releaseOwner', 'broker/releaseTurn']); const OWNER_SCOPED_SESSION_METHODS = new Set(['session/read', 'session/resume', 'session/setModel', 'session/updateRuntimeModelConfig', 'session/setThoughtLevel']); -const EXCLUSIVE_SESSION_METHODS = new Set(['session/create', 'session/send', 'session/stop', 'v4/conversation/subscribe', 'v4/conversation/unsubscribe', 'broker/releaseSession']); +const EXCLUSIVE_SESSION_METHODS = new Set(['session/create', 'session/send', 'session/stop', 'v4/conversation/subscribe', 'v4/conversation/unsubscribe', 'broker/releaseSession', 'broker/releaseTurn']); // One admission authority owns every transient broker fence. Durable ownership // remains in sessionOwners, so a store reload cannot erase in-flight claims. @@ -239,7 +240,7 @@ export async function ensureZCodeBroker(options) { export class ZCodeBroker { /** @param {{endpoint:string,ownershipPath?:string,brokerToken:string,launch:{command:string,args:string[],target?:string},workspace:string,launchCwd?:string,env?:NodeJS.ProcessEnv,idleTimeoutMs?:number,maxFrameBytes?:number,maxOutboundBytes?:number,drainTimeoutMs?:number,instanceId?:string,identityPath?:string,publishIdentityAfterListen?:boolean}} options */ - constructor(options) { if (typeof options?.brokerToken !== 'string' || options.brokerToken.length < 32 || !validIdleTimeoutOption(options?.idleTimeoutMs) || !validWireOption(options?.maxFrameBytes, 16 * 1024 * 1024) || !validWireOption(options?.maxOutboundBytes, 64 * 1024 * 1024) || !validDrainOption(options?.drainTimeoutMs) || isWindowsNamedPipe(options?.endpoint) && (typeof options?.ownershipPath !== 'string' || !options.ownershipPath)) throw brokerInputError(); let workspace; try { workspace = realpathSync.native(resolve(options.workspace)); } catch { throw brokerInputError(); } this.options = { ...options, workspace }; this.ownershipPath = options.ownershipPath ?? `${options.endpoint}.owners.json`; this.ownershipStoreEstablished = false; this.ownershipRevision = 0; this.uncertainOwnerReleases = new Map(); this.ownerCommitTokens = new Map(); this.server = null; this.protocol = null; this.protocolPromise = null; this.retiredProtocolGeneration = null; this.sockets = new Set(); this.socketWriters = new WeakMap(); this.authenticated = new WeakSet(); this.existingProtocolOnlySockets = new WeakSet(); this.socketOwnerIds = new WeakMap(); this.sessionOwners = new Map(); this.admission = new BrokerAdmission((sessionId) => this.sessionOwners.get(sessionId)?.ownerId, () => this.scheduleIdleShutdown()); this.activeSessionSockets = new Map(); this.terminalWinnerEvidence = new Map(); this.admittingSessions = new Map(); this.stoppingSessions = new Map(); this.conversationSubscriptions = new Map(); this.orphanedConversationSubscriptions = new Map(); this.conversationSubscriptionGeneration = null; this.orphanRetryPromise = null; this.pendingConversationTopics = new Map(); this.permissionPending = new Map(); this.retiredPermissionResponses = new Map(); this.localTasks = new Set(); this.releaseTasks = new Set(); this.nextPermissionId = 1_000_000_000; this.owners = 0; this.activeSessions = new Set(); this.fastIdleRequested = false; this.idleTimer = null; this.closing = false; this.closePromise = null; } + constructor(options) { if (typeof options?.brokerToken !== 'string' || options.brokerToken.length < 32 || !validIdleTimeoutOption(options?.idleTimeoutMs) || !validWireOption(options?.maxFrameBytes, 16 * 1024 * 1024) || !validWireOption(options?.maxOutboundBytes, 64 * 1024 * 1024) || !validDrainOption(options?.drainTimeoutMs) || isWindowsNamedPipe(options?.endpoint) && (typeof options?.ownershipPath !== 'string' || !options.ownershipPath)) throw brokerInputError(); let workspace; try { workspace = realpathSync.native(resolve(options.workspace)); } catch { throw brokerInputError(); } this.options = { ...options, workspace }; this.ownershipPath = options.ownershipPath ?? `${options.endpoint}.owners.json`; this.ownershipStoreEstablished = false; this.ownershipRevision = 0; this.uncertainOwnerReleases = new Map(); this.ownerCommitTokens = new Map(); this.server = null; this.protocol = null; this.protocolPromise = null; this.retiredProtocolGeneration = null; this.sockets = new Set(); this.socketWriters = new WeakMap(); this.authenticated = new WeakSet(); this.existingProtocolOnlySockets = new WeakSet(); this.exactTurnReleaseSockets = new WeakSet(); this.socketOwnerIds = new WeakMap(); this.sessionOwners = new Map(); this.admission = new BrokerAdmission((sessionId) => this.sessionOwners.get(sessionId)?.ownerId, () => this.scheduleIdleShutdown()); this.activeSessionSockets = new Map(); this.releasedTurnTombstones = new Map(); this.terminalWinnerEvidence = new Map(); this.admittingSessions = new Map(); this.stoppingSessions = new Map(); this.conversationSubscriptions = new Map(); this.orphanedConversationSubscriptions = new Map(); this.conversationSubscriptionGeneration = null; this.orphanRetryPromise = null; this.pendingConversationTopics = new Map(); this.permissionPending = new Map(); this.retiredPermissionResponses = new Map(); this.localTasks = new Set(); this.releaseTasks = new Set(); this.nextPermissionId = 1_000_000_000; this.owners = 0; this.activeSessions = new Set(); this.fastIdleRequested = false; this.idleTimer = null; this.closing = false; this.closePromise = null; } async start() { if (this.server) return this; @@ -312,13 +313,14 @@ export class ZCodeBroker { } if (!frame || !Number.isSafeInteger(frame.id) || typeof frame.method !== 'string' || !frame.params || typeof frame.params !== 'object') { socket.destroy(); return; } if (!LOCAL_BROKER_METHODS.has(frame.method)) { writeRequestError(socket, frame.id, brokerInputError()); return; } - if (frame.method === 'broker/health') { writeLocal(socket, { id: frame.id, result: { ok: this.retiredProtocolGeneration === null, pid: process.pid, instanceId: this.options.instanceId, capabilities: { releaseOwnerExclusions: true } } }); return; } + if (frame.method === 'broker/health') { const capabilities = frame.params.clientCapabilities; if (Object.keys(frame.params).some((key) => key !== 'clientCapabilities') || capabilities !== undefined && (!capabilities || typeof capabilities !== 'object' || Object.keys(capabilities).some((key) => key !== 'exactTurnRelease') || capabilities.exactTurnRelease !== true)) { writeRequestError(socket, frame.id, brokerInputError()); return; } if (capabilities?.exactTurnRelease === true) this.exactTurnReleaseSockets.add(socket); writeLocal(socket, { id: frame.id, result: { ok: this.retiredProtocolGeneration === null, pid: process.pid, instanceId: this.options.instanceId, capabilities: { releaseOwnerExclusions: true, exactTurnRelease: true } } }); return; } if (frame.method === 'session/create' && !validCreateWorkspace(frame.params.workspace, this.options.workspace)) { writeRequestError(socket, frame.id, brokerInputError()); return; } const releaseDeadline = frame.method === 'broker/releaseOwner' ? Date.now() + OWNER_RELEASE_BUDGET_MS : undefined; let releaseExcluded; if (frame.method === 'broker/releaseOwner') { try { releaseExcluded = frame.params.excludeSessionIds ?? []; if (Object.keys(frame.params).some((key) => key !== 'excludeSessionIds') || !Array.isArray(releaseExcluded) || releaseExcluded.length > 1_000 || new Set(releaseExcluded).size !== releaseExcluded.length || !releaseExcluded.every((sessionId) => isSafeIdentifier(sessionId))) throw brokerInputError(); } catch (error) { writeRequestError(socket, frame.id, error); return; } } + if (frame.method === 'broker/releaseTurn' && (!this.exactTurnReleaseSockets.has(socket) || Object.keys(frame.params).length !== 3 || !isSafeIdentifier(frame.params.sessionId) || !isSafeIdentifier(frame.params.inputId) || !Number.isSafeInteger(frame.params.stateRevision) || frame.params.stateRevision < 0)) { writeRequestError(socket, frame.id, brokerInputError()); return; } const conversationSessionId = frame.method === 'broker/releaseOwner' ? null : sessionIdFromConversationRequest(frame); if (conversationSessionId === false) { writeRequestError(socket, frame.id, brokerInputError()); return; } const requestedSessionId = conversationSessionId ?? frame.params.sessionId; @@ -344,6 +346,7 @@ export class ZCodeBroker { try { if (!ownershipReloaded) await this.reloadOwnership(); } catch (error) { writeRequestError(socket, frame.id, error); return; } const existingOwner = typeof requestedSessionId === 'string' ? this.sessionOwners.get(requestedSessionId) : null; if (existingOwner && existingOwner.ownerId !== ownerId || typeof requestedSessionId === 'string' && !existingOwner && !claimMethod) { writeSessionOwnerDenied(socket, frame.id); return; } + if (frame.method === 'broker/releaseTurn' && !this.activeSessionSockets.has(frame.params.sessionId)) { const tombstone = this.releasedTurnTombstones.get(turnReleaseKey(ownerId, frame.params)); if (tombstone?.socket === socket) { writeLocal(socket, { id: frame.id, result: {} }); return; } writeRequestError(socket, frame.id, turnReleaseMismatch()); return; } let subscriptionToken; let stopToken; let stoppedGeneration; let ownerCommitToken; let unsubscribeRecord; let protocol; if (frame.method === 'session/send') { sendToken = sessionAdmission.token; this.admittingSessions.set(frame.params.sessionId, sendToken); } if (frame.method === 'session/stop') { stoppedGeneration = this.activeSessionSockets.get(frame.params.sessionId); stopToken = sessionAdmission.token; this.stoppingSessions.set(frame.params.sessionId, { token: stopToken, activeToken: stoppedGeneration?.token ?? null }); } @@ -351,6 +354,7 @@ export class ZCodeBroker { if (this.existingProtocolOnlySockets.has(socket)) { if (!this.protocol) throw existingProtocolUnavailable(); protocol = this.protocol; } else protocol = await this.getProtocol(); if (sessionAdmission) this.admission.bindSessionProtocol(sessionAdmission, protocol); + if (frame.method === 'broker/releaseTurn') { await this.releaseExactTurn(socket, ownerId, frame.params, protocol, sessionAdmission); writeLocal(socket, { id: frame.id, result: {} }); return; } if (frame.method === 'session/create') { if (!this.admission.ownerRequestCurrent(ownerAdmission)) throw brokerInputError(); ownerCommitToken = randomBytes(16).toString('hex'); this.ownerCommitTokens.set(ownerCommitToken, protocol); } if (frame.method === 'session/send') { if (this.admittingSessions.get(frame.params.sessionId) !== sendToken) throw brokerInputError(); this.terminalWinnerEvidence.delete(frame.params.sessionId); protocol.beginTurn(frame.params.sessionId); this.activeSessionSockets.set(frame.params.sessionId, { socket, token: sendToken }); } if (frame.method === 'v4/conversation/subscribe') { @@ -562,7 +566,7 @@ export class ZCodeBroker { if (message.params?.sessionId && sessionOwner) writeLocal(sessionOwner, message); }); protocol.setPermissionHandler((request) => this.requestPermission(request)); - protocol.consumeTerminalsWith((params, turn) => { const active = this.activeSessionSockets.get(params.sessionId); if (active?.baseline === turn.baseline && active.inputId === turn.inputId) { this.recordTerminalWinner(params.sessionId, protocol, active); if (active.socket?.writable) writeLocal(active.socket, { method: 'state.updated', params }); this.settleTurnPermissions(params.sessionId, active.token); this.activeSessionSockets.delete(params.sessionId); this.activeSessions.delete(params.sessionId); this.scheduleIdleShutdown(); } }); + protocol.observeTerminalsWith((params, turn) => { const active = this.activeSessionSockets.get(params.sessionId); if (active?.baseline !== turn.baseline || active.inputId !== turn.inputId) return; if (active.socket?.writable) writeLocal(active.socket, { method: 'state.updated', params }); if (active.socket && this.exactTurnReleaseSockets.has(active.socket)) return; protocol.releaseTurn(params.sessionId); this.recordTerminalWinner(params.sessionId, protocol, active); this.settleTurnPermissions(params.sessionId, active.token); this.activeSessionSockets.delete(params.sessionId); this.activeSessions.delete(params.sessionId); this.scheduleIdleShutdown(); }); protocol.setCloseHandler(() => this.clearProtocolGeneration(protocol)); return protocol; } catch (error) { if (this.protocol === protocol) this.protocol = null; await protocol.close().catch(() => {}); throw error; } @@ -662,6 +666,23 @@ export class ZCodeBroker { for (const [id, pending] of this.permissionPending) if (pending.request.sessionId === sessionId && pending.turnToken === turnToken) { clearTimeout(pending.timer); this.permissionPending.delete(id); this.retirePermissionResponse(id, pending.socket); pending.resolve(offeredDeny(pending.request)); } } + async releaseExactTurn(socket, ownerId, params, protocol, admission) { + const active = this.activeSessionSockets.get(params.sessionId); + if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || active?.socket !== socket || active.baseline !== params.stateRevision || active.inputId !== params.inputId || typeof active.token !== 'string') throw turnReleaseMismatch(); + const activeToken = active.token; + this.settleTurnPermissions(params.sessionId, activeToken); + // Let the upstream server-request task write its deny before releaseTurn + // aborts session-scoped tasks. Two promise turns cover both await edges. + await Promise.resolve(); await Promise.resolve(); + if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId)?.token !== activeToken) throw turnReleaseMismatch(); + protocol.releaseTurn(params.sessionId); + if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId)?.token !== activeToken) throw turnReleaseMismatch(); + this.activeSessionSockets.delete(params.sessionId); this.activeSessions.delete(params.sessionId); + const key = turnReleaseKey(ownerId, params); this.releasedTurnTombstones.delete(key); this.releasedTurnTombstones.set(key, { socket }); + while (this.releasedTurnTombstones.size > MAX_RELEASED_TURN_TOMBSTONES) this.releasedTurnTombstones.delete(this.releasedTurnTombstones.keys().next().value); + this.scheduleIdleShutdown(); + } + retirePermissionResponse(id, socket) { this.retiredPermissionResponses.set(id, { socket }); while (this.retiredPermissionResponses.size > 256) this.retiredPermissionResponses.delete(this.retiredPermissionResponses.keys().next().value); @@ -926,6 +947,8 @@ function existingProtocolUnavailable() { return new PluginError('ZCODE_BROKER_PR function protocolRetiring() { return new PluginError('ZCODE_PROTOCOL_RETIRING', 'The previous ZCode protocol generation has not closed safely.', { category: 'state', remedy: 'Retry after the retired protocol generation closes.' }); } function brokerUnhealthyError() { return new PluginError('ZCODE_BROKER_UNHEALTHY', 'The recorded ZCode broker identity cannot be safely replaced after its health check failed.', { category: 'state', remedy: 'Stop or repair the recorded broker process before retrying.' }); } function brokerInputError() { return new PluginError('ZCODE_BROKER_INPUT_INVALID', 'ZCode broker input is invalid.', { category: 'validation', remedy: 'Provide a data root, workspace, endpoint, and launch target.' }); } +function turnReleaseMismatch() { return new PluginError('ZCODE_TURN_RELEASE_MISMATCH', 'The exact ZCode turn release did not match the active turn.', { category: 'state', remedy: 'Do not retry the stale release after starting a newer turn.' }); } +function turnReleaseKey(ownerId, params) { return JSON.stringify([ownerId, params.sessionId, params.stateRevision, params.inputId]); } function ownerReleaseTimeout() { return new PluginError('ZCODE_OWNER_RELEASE_TIMEOUT', 'The ZCode owner release exceeded its bounded storage budget.', { category: 'timeout', remedy: 'Retry after the active owner-store operation completes.' }); } function identityCleanupError(path, cause) { return new PluginError('ZCODE_BROKER_IDENTITY_CLEANUP_FAILED', 'The ZCode broker identity could not be safely removed.', { category: 'storage', remedy: 'Inspect the broker identity path and retry cleanup.', ...(cause === undefined ? {} : { cause }), details: { path } }); } diff --git a/tests/fixtures/fake-zcode-cli.mjs b/tests/fixtures/fake-zcode-cli.mjs index e5f5a99..4ba71ed 100644 --- a/tests/fixtures/fake-zcode-cli.mjs +++ b/tests/fixtures/fake-zcode-cli.mjs @@ -306,8 +306,17 @@ async function sendCaptured0165Turn(message, p, session) { session.messages.push(turnMessages[0]); session.projectionStatus = 'running'; session.stateRevision = sendIndex * 2 - 1; const accepted = { id: message.id, result: { sessionId: p.sessionId, accepted: true, stateRevision: session.stateRevision } }; await traceCaptured0165(sendIndex, 'send-accepted', accepted); send(accepted); + while (!await captured0165GateReleased(process.env.FAKE_ZCODE_CAPTURED_0165_LEGACY_GATE, sendIndex)) await new Promise((resolve) => setTimeout(resolve, 5)); const legacy = { method: 'state.updated', params: { type: 'state.updated', scope: 'session', sessionId: p.sessionId, revision: session.stateRevision + 1, reason: 'prompt_completed', patch: { status: 'idle' } } }; await traceCaptured0165(sendIndex, 'legacy-prompt-completed', legacy); send(legacy); + if (process.env.FAKE_ZCODE_CAPTURED_0165_PERMISSION === '1') { + const id = permissionId++; + send({ id, method: 'interaction/requestPermission', params: { + requestId: `permission-${id}`, sessionId: p.sessionId, toolCallId: `captured-tool-${sendIndex}`, toolName: 'write', + reason: 'captured 0.16.5 fixture', riskLevel: 'medium', input: { path: 'README.md' }, requestedAt: 1_786_233_601_742, + options: [{ optionId: 'allow', kind: 'allow', name: 'Allow', response: { decision: 'allow' } }, { optionId: 'deny', kind: 'deny', name: 'Deny', response: { decision: 'deny' } }], + } }); + } while (!await captured0165GateReleased(process.env.FAKE_ZCODE_CAPTURED_0165_RUNNING_GATE, sendIndex)) await new Promise((resolve) => setTimeout(resolve, 5)); const turnRow = { rowId: 100 + sendIndex, turnId: `captured-turn-${sendIndex}`, createdAt: 1_786_233_600_000, diff --git a/tests/process-zcode.test.mjs b/tests/process-zcode.test.mjs index fcadd45..0eeecff 100644 --- a/tests/process-zcode.test.mjs +++ b/tests/process-zcode.test.mjs @@ -256,6 +256,47 @@ test('observed completion leaves the turn armed and a later permission request c protocol.releaseTurn('session-1'); }); +test('broker terminal observer leaves no completion queue or expiry and observes early arm completion', () => { + const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.exitCode = 0; child.signalCode = null; + const protocol = new ZCodeProtocolClient(child); const observed = []; + protocol.observeTerminalsWith((params, turn) => observed.push({ params, turn })); + protocol.beginTurn('session-1'); + protocol.handleLine(JSON.stringify({ method: 'state.updated', params: { scope: 'session', sessionId: 'session-1', revision: 2, reason: 'prompt_completed' } })); + protocol.armTurn('session-1', 1, 'input-1'); + assert.equal(observed.length, 1); assert.equal(observed[0].turn.inputId, 'input-1'); + assert.equal(protocol.turnState('session-1'), 'armed'); assert.equal(protocol.completed.size, 0); assert.equal(protocol.completionExpiry.size, 0); assert.equal(protocol.earlyCompletions.size, 0); + protocol.releaseTurn('session-1'); +}); + +test('permission request accepts the captured 0.16.5 requestedAt timestamp', async () => { + const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.exitCode = 0; child.signalCode = null; + const protocol = new ZCodeProtocolClient(child); let handled = 0; + protocol.beginTurn('session-1'); protocol.armTurn('session-1', 1, 'input-1'); + protocol.setPermissionHandler(() => { handled += 1; return { decision: 'allow' }; }); + protocol.handleLine(JSON.stringify({ id: 99, method: 'interaction/requestPermission', params: { requestId: 'permission-99', sessionId: 'session-1', toolCallId: 'tool-1', toolName: 'write', reason: 'captured 0.16.5 fixture', riskLevel: 'medium', input: { path: 'README.md' }, options: [{ optionId: 'allow', kind: 'allow', name: 'Allow', response: { decision: 'allow' } }, { optionId: 'deny', kind: 'deny', name: 'Deny', response: { decision: 'deny' } }], requestedAt: 1_786_233_601_742 } })); + await new Promise((resolve) => setImmediate(resolve)); + const response = JSON.parse(child.stdin.read().toString()); + assert.deepEqual({ handled, response }, { handled: 1, response: { id: 99, result: { decision: 'allow' } } }); + protocol.releaseTurn('session-1'); +}); + +test('permission request rejects malformed requestedAt without invoking the handler', async () => { + for (const requestedAt of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, '1786233601742']) { + const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.exitCode = 0; child.signalCode = null; + const protocol = new ZCodeProtocolClient(child); let handled = 0; + protocol.beginTurn('session-1'); protocol.armTurn('session-1', 1, 'input-1'); + protocol.setPermissionHandler(() => { handled += 1; return { decision: 'allow' }; }); + protocol.handleLine(JSON.stringify({ id: 99, method: 'interaction/requestPermission', params: { requestId: 'permission-99', sessionId: 'session-1', toolCallId: 'tool-1', toolName: 'write', reason: 'captured 0.16.5 fixture', riskLevel: 'medium', input: {}, options: [{ optionId: 'allow', kind: 'allow', name: 'Allow', response: { decision: 'allow' } }, { optionId: 'deny', kind: 'deny', name: 'Deny', response: { decision: 'deny' } }], requestedAt } })); + await new Promise((resolve) => setImmediate(resolve)); + const response = JSON.parse(child.stdin.read().toString()); + assert.equal(handled, 0, String(requestedAt)); + assert.equal(response.id, 99); + assert.equal(response.result, undefined); + assert.equal(response.error?.code, -32000); + protocol.releaseTurn('session-1'); + } +}); + test('completion observer timeout unregisters without ending the active turn', async () => { const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.exitCode = 0; child.signalCode = null; const protocol = new ZCodeProtocolClient(child); diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index fa57cee..76473ff 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -1447,6 +1447,63 @@ test('typed client uses a local broker whose single CLI owner handles permission } }); +test('captured 0.16.5 broker turn keeps its exact route for permission until explicit release', { timeout: 5_000 }, async () => { + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-captured-permission-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = 'c'.repeat(64); const record = join(directory, 'calls.jsonl'); const legacyGate = join(directory, 'legacy-gate.json'); const terminalGate = join(directory, 'terminal-gate.json'); await writeFile(legacyGate, JSON.stringify({ version: 1, releaseThrough: 0 })); await writeFile(terminalGate, JSON.stringify({ version: 1, releaseThrough: 0 })); + const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_VERSION: '0.16.5', FAKE_ZCODE_CONVERSATION_SCENARIO: 'captured-0165', FAKE_ZCODE_CAPTURED_0165_PERMISSION: '1', FAKE_ZCODE_CAPTURED_0165_LEGACY_GATE: legacyGate, FAKE_ZCODE_CAPTURED_0165_TERMINAL_GATE: terminalGate, FAKE_ZCODE_RECORD: record } }).start(); + const client = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId: 'captured-permission-owner', completionTimeoutMs: 2_000 }); let sessionId; let released = false; + try { + sessionId = (await client.createSession({ workspace: directory })).session.sessionId; + const terminalFrames = []; client.subscribe((message) => { if (message.method === 'v4/conversation/frame' && message.params?.frame?.payload?.deltas?.some((delta) => delta.row?.state === 'completedSuccess')) terminalFrames.push(message); }); + await client.subscribeConversation(sessionId, { connectionId: 'captured-permission-connection', clientMode: 'desktop-continuous' }); + let handled = 0; client.setPermissionHandler(() => { handled += 1; return { decision: 'allow' }; }); + const sending = client.send(sessionId, 'captured permission after false legacy completion'); + for (let index = 0; index < 200 && !Number.isSafeInteger(broker.activeSessionSockets.get(sessionId)?.baseline); index += 1) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); + const exactRoute = broker.activeSessionSockets.get(sessionId); assert.ok(exactRoute); await writeFile(legacyGate, JSON.stringify({ version: 1, releaseThrough: 1 })); await sending; + const legacy = await client.observeCompletion(sessionId); assert.equal(legacy.reason, 'prompt_completed'); + const calls = await waitForRecordedCalls(record, (entries) => entries.some((entry) => entry.id === 9000 && (entry.result || entry.error)), 1_000); + await writeFile(terminalGate, JSON.stringify({ version: 1, releaseThrough: 1 })); + for (let index = 0; index < 200 && terminalFrames.length === 0; index += 1) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); + const response = calls.find((entry) => entry.id === 9000 && (entry.result || entry.error)); + assert.deepEqual({ routeRetained: broker.activeSessionSockets.get(sessionId) === exactRoute, handled, result: response?.result, error: response?.error, authoritativeTerminalObserved: terminalFrames.length === 1 }, { routeRetained: true, handled: 1, result: { decision: 'allow' }, error: undefined, authoritativeTerminalObserved: true }); + await client.releaseTurn(sessionId); released = true; + assert.equal(client.turnState(sessionId), null); + assert.equal(broker.activeSessionSockets.has(sessionId), false); + assert.equal(broker.activeSessions.has(sessionId), false); + assert.equal(broker.protocol.turnState(sessionId), null); + } finally { + await writeFile(legacyGate, JSON.stringify({ version: 1, releaseThrough: 1 })).catch(() => {}); + await writeFile(terminalGate, JSON.stringify({ version: 1, releaseThrough: 1 })).catch(() => {}); + if (sessionId && !released) await Promise.resolve(client.releaseTurn(sessionId)).catch(() => {}); + await client.close(); await broker.close(); await rm(directory, { recursive: true, force: true }); + } +}); + +test('exact turn release rejects foreign and stale tuples while duplicate acknowledgement is idempotent', async () => { + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-exact-release-')); const ownerId = 'exact-release-owner'; const sessionId = 'exact-release-session'; const writes = []; const foreignWrites = []; + const socket = { writable: true, destroyed: false, zcodeWriter: { write: (line) => writes.push(JSON.parse(line)) }, destroy() {} }; const foreign = { writable: true, destroyed: false, zcodeWriter: { write: (line) => foreignWrites.push(JSON.parse(line)) }, destroy() {} }; + const broker = newTestBroker({ endpoint: join(directory, 'broker.sock'), brokerToken: 'a'.repeat(64), workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture } }); + for (const [peer, peerOwner] of [[socket, ownerId], [foreign, 'exact-release-foreign-owner']]) { broker.authenticated.add(peer); broker.socketOwnerIds.set(peer, peerOwner); } + broker.exactTurnReleaseSockets.add(socket); broker.exactTurnReleaseSockets.add(foreign); + broker.sessionOwners.set(sessionId, { ownerId, socket }); broker.reloadOwnership = async () => {}; + const released = []; const protocol = { releaseTurn: (releasedSessionId) => released.push(releasedSessionId) }; broker.protocol = protocol; + const old = { socket, token: 'exact-release-old-token', baseline: 7, inputId: 'exact-release-old-input' }; broker.activeSessionSockets.set(sessionId, old); broker.activeSessions.add(sessionId); + const request = (peer, id, params) => broker.handleLocal(peer, JSON.stringify({ id, method: 'broker/releaseTurn', params })); const tuple = { sessionId, stateRevision: 7, inputId: 'exact-release-old-input' }; + try { + await request(foreign, 1, tuple); assert.equal(foreignWrites.at(-1)?.error?.code, -32041); assert.equal(broker.activeSessionSockets.get(sessionId), old); + await request(socket, 2, { ...tuple, inputId: 'wrong-input' }); assert.equal(writes.at(-1)?.error?.data?.pluginError?.code, 'ZCODE_TURN_RELEASE_MISMATCH'); assert.equal(broker.activeSessionSockets.get(sessionId), old); + await request(socket, 3, tuple); assert.deepEqual(writes.at(-1)?.result, {}); assert.deepEqual(released, [sessionId]); assert.equal(broker.activeSessionSockets.has(sessionId), false); + await request(socket, 4, tuple); assert.deepEqual(writes.at(-1)?.result, {}); assert.deepEqual(released, [sessionId]); + const newer = { socket, token: 'exact-release-new-token', baseline: 8, inputId: 'exact-release-new-input' }; broker.activeSessionSockets.set(sessionId, newer); broker.activeSessions.add(sessionId); + await request(socket, 5, tuple); assert.equal(writes.at(-1)?.error?.data?.pluginError?.code, 'ZCODE_TURN_RELEASE_MISMATCH'); assert.equal(broker.activeSessionSockets.get(sessionId), newer); assert.equal(broker.activeSessions.has(sessionId), true); assert.deepEqual(released, [sessionId]); + } finally { broker.cancelIdleShutdown(); await rm(directory, { recursive: true, force: true }); } +}); + +test('managed client falls back to local release when broker health lacks exact release capability', async () => { + const calls = []; const turns = new Map(); const protocol = { acceptBrokerControl: true, request: async (method, params) => { calls.push({ method, params }); if (method === 'broker/health') return { ok: true, capabilities: {} }; if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: 3 }; throw new Error(method); }, beginTurn: (sessionId) => turns.set(sessionId, 'sending'), armTurn: (sessionId) => turns.set(sessionId, 'armed'), abortTurn: (sessionId) => turns.delete(sessionId), releaseTurn: (sessionId) => turns.delete(sessionId), turnState: (sessionId) => turns.get(sessionId) ?? null }; + const client = new ZCodeClient(protocol, process.cwd(), true); await client.send('legacy-broker-session', 'hello'); await client.releaseTurn('legacy-broker-session'); + assert.deepEqual(calls.map((call) => call.method), ['broker/health', 'session/send']); assert.deepEqual(calls[0].params, { clientCapabilities: { exactTurnRelease: true } }); assert.equal(turns.size, 0); +}); + test('natural terminal denies its exact pending permission and tombstones a late local approval', async () => { const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-permission-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = '8'.repeat(64); const record = join(directory, 'calls.jsonl'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_PERMISSION: '1', FAKE_ZCODE_RECORD: record } }).start(); const client = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId: 'terminal-permission-owner', completionTimeoutMs: 1_000 }); let approve; try { From dc9c67b1ee5a73e080d4efaa73dd424090bb7b51 Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:00:51 +0800 Subject: [PATCH 03/19] fix: await managed turn release before closing --- scripts/lib/review.mjs | 2 +- tests/job-control.test.mjs | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/lib/review.mjs b/scripts/lib/review.mjs index 6683b18..61185db 100644 --- a/scripts/lib/review.mjs +++ b/scripts/lib/review.mjs @@ -343,7 +343,7 @@ export async function executeJob(input) { // Cleanup order is part of the progress lifecycle contract. await cleanupProgress(); try { - if (sessionId && typeof client.releaseTurn === 'function') client.releaseTurn(sessionId); + if (sessionId && typeof client.releaseTurn === 'function') await client.releaseTurn(sessionId); } catch (cleanupError) { if (!primaryError && output?.job?.status !== 'succeeded') primaryError = cleanupError; } diff --git a/tests/job-control.test.mjs b/tests/job-control.test.mjs index 5240678..36a4f22 100644 --- a/tests/job-control.test.mjs +++ b/tests/job-control.test.mjs @@ -1535,7 +1535,7 @@ test('ordinary execution keeps a pending accepted completion alive without stopp assert.equal(output.job.status, 'succeeded'); assert.equal(output.result, 'completed after pending'); assert.equal(stops, 0); }); -test('0.16.5 foreground execution treats legacy completion as admission and waits for the true turn terminal', async () => { +test('0.16.5 foreground execution treats legacy completion as admission and waits for the true turn terminal', async (t) => { const { root, workspace, store } = await setup(); const job = await store.reserveJob({ workspace, ...reservation }); const sessionId = 'zs-0165-true-terminal'; const subscriptionId = 'subscription-0165'; /** @type {null|((message:any)=>void)} */ let handler = null; @@ -1545,6 +1545,9 @@ test('0.16.5 foreground execution treats legacy completion as admission and wait /** @type {any} */ let permissionDecision; let signalPermission = () => {}; const permissionDecided = new Promise((resolve) => { signalPermission = () => resolve(undefined); }); /** @type {string[]} */ const cleanupCalls = []; + let signalReleaseStarted = () => {}; const releaseStarted = new Promise((resolve) => { signalReleaseStarted = () => resolve(undefined); }); + let settleRelease = () => {}; const releaseSettlement = new Promise((resolve) => { settleRelease = () => resolve(undefined); }); + t.after(() => settleRelease()); const boundaryReached = new Promise((resolve) => { signalBoundary = () => resolve(undefined); }); const legacy = { method: 'state.updated', params: { type: 'state.updated', scope: 'session', sessionId, revision: 1, reason: 'prompt_completed', patch: {}, futureNotificationField: true } }; const client = { @@ -1578,7 +1581,7 @@ test('0.16.5 foreground execution treats legacy completion as admission and wait ] }; }, stopSession: async () => {}, - releaseTurn: (/** @type {string} */ releasedSessionId) => { cleanupCalls.push(`release:${releasedSessionId}`); }, + releaseTurn: async (/** @type {string} */ releasedSessionId) => { cleanupCalls.push(`release:${releasedSessionId}`); signalReleaseStarted(); await releaseSettlement; }, close: async () => { cleanupCalls.push('close'); }, }; const execution = executeJob({ @@ -1594,6 +1597,11 @@ test('0.16.5 foreground execution treats legacy completion as admission and wait emit(conversationFrame(/** @type {any} */ ({ sessionId, subscriptionId, ordinal: 2, fromSeq: 484, toSeq: 485, deltas: [captured0165TurnRow({ rowId: 101, turnId: 'turn-0165', state: 'running' })] }))); trueTerminal = true; emit(conversationFrame(/** @type {any} */ ({ sessionId, subscriptionId, ordinal: 3, fromSeq: 485, toSeq: 486, deltas: [captured0165TurnRow({ rowId: 101, turnId: 'turn-0165', state: 'completedSuccess' })] }))); + let executionSettled = false; void execution.then(() => { executionSettled = true; }, () => { executionSettled = true; }); + await releaseStarted; await new Promise((resolve) => setImmediate(resolve)); + assert.equal(executionSettled, false, 'execution must remain pending until managed turn release settles'); + assert.deepEqual(cleanupCalls, [`release:${sessionId}`], 'client close must wait for managed turn release settlement'); + settleRelease(); const output = await execution; assert.equal(output.result, 'real 0.16.5 result'); assert.equal(output.job.status, 'succeeded'); @@ -1616,7 +1624,7 @@ test('durable success remains authoritative when local turn release fails', asyn { info: { role: 'assistant', messageId: 'assistant-release-after-success', parentMessageId: 'input-release-after-success', finish: 'stop' }, parts: [{ type: 'text', text: 'durable release result' }] }, ] }), stopSession: async () => {}, - releaseTurn: (/** @type {string} */ releasedSessionId) => { cleanupCalls.push(`release:${releasedSessionId}`); throw releaseError; }, + releaseTurn: async (/** @type {string} */ releasedSessionId) => { cleanupCalls.push(`release:${releasedSessionId}`); await Promise.resolve(); throw releaseError; }, close: async () => { cleanupCalls.push('close'); }, }; const output = await executeJob({ job, workspace, dataRoot, store, client, task: 'task' }); @@ -1641,7 +1649,7 @@ test('executor releases the local turn before close on failure and preserves the observeCompletion: async () => { throw primary; }, waitForCompletion: async () => { throw new Error('legacy waiter must not be used'); }, stopSession: async () => {}, - releaseTurn: (/** @type {string} */ releasedSessionId) => { cleanupCalls.push(`release:${releasedSessionId}`); throw releaseError; }, + releaseTurn: async (/** @type {string} */ releasedSessionId) => { cleanupCalls.push(`release:${releasedSessionId}`); await Promise.resolve(); throw releaseError; }, close: async () => { cleanupCalls.push('close'); }, }; const caught = await executeJob({ job, workspace, dataRoot: join(root, 'data'), store, client, task: 'task' }).catch((error) => error); From 247649e4c224e59a6bb83c2a354f807f0c9e089a Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:01:32 +0800 Subject: [PATCH 04/19] test: cover exact release compatibility cleanup --- tests/zcode-client.test.mjs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index 76473ff..03ab5fd 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -21,6 +21,13 @@ import { scaleTestTimeout, testTimeoutMultiplier } from './helpers/test-timeouts const fixture = fileURLToPath(new URL('./fixtures/fake-zcode-cli.mjs', import.meta.url)); const brokerStartupFault = fileURLToPath(new URL('./fixtures/broker-startup-fault.cjs', import.meta.url)); const MACOS_UNIX_SOCKET_PATH_MAX_BYTES = 104; +async function createPreExactReleaseClient(options) { + const client = await createZCodeClient(options); + // Pre-capability clients were still workspace-bound managed clients; they + // simply never advertised broker/releaseTurn support in broker/health. + client.exactTurnRelease = false; + return client; +} test('CI timeout multiplier is bounded and defaults to one', () => { assert.equal(testTimeoutMultiplier({}), 1); @@ -1505,11 +1512,13 @@ test('managed client falls back to local release when broker health lacks exact }); test('natural terminal denies its exact pending permission and tombstones a late local approval', async () => { - const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-permission-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = '8'.repeat(64); const record = join(directory, 'calls.jsonl'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_PERMISSION: '1', FAKE_ZCODE_RECORD: record } }).start(); const client = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId: 'terminal-permission-owner', completionTimeoutMs: 1_000 }); let approve; + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-permission-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = '8'.repeat(64); const record = join(directory, 'calls.jsonl'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_PERMISSION: '1', FAKE_ZCODE_RECORD: record } }).start(); const client = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId: 'terminal-permission-owner', completionTimeoutMs: 1_000 }); let approve; let clientClosed = false; try { const { session: { sessionId } } = await client.createSession({ workspace: directory }); let permissionEntered; const entered = new Promise((resolvePromise) => { permissionEntered = resolvePromise; }); const approval = new Promise((resolvePromise) => { approve = () => resolvePromise({ decision: 'allow' }); }); client.setPermissionHandler(async () => { permissionEntered(); return approval; }); await client.send(sessionId, 'complete while permission is pending'); await entered; const completion = await client.waitForCompletion(sessionId); assert.equal(completion.reason, 'prompt_completed'); assert.equal(broker.activeSessionSockets.has(sessionId), false); assert.equal(broker.activeSessions.has(sessionId), false); assert.equal(broker.permissionPending.size, 0); assert.equal(broker.retiredPermissionResponses.size, 1); - approve(); await waitForRecordedCalls(record, (calls) => calls.some((call) => call.id === 9000 && call.result)); const permissionResponses = (await readRecordedCalls(record)).filter((call) => call.id === 9000 && call.result); assert.deepEqual(permissionResponses.map((call) => call.result), [{ decision: 'deny' }]); assert.deepEqual(await client.brokerCapabilities(), { releaseOwnerExclusions: true }); assert.equal(broker.retiredPermissionResponses.size, 0); - } finally { approve?.(); await client.close(); await broker.close(); await rm(directory, { recursive: true, force: true }); } + approve(); await waitForRecordedCalls(record, (calls) => calls.some((call) => call.id === 9000 && call.result)); const permissionResponses = (await readRecordedCalls(record)).filter((call) => call.id === 9000 && call.result); assert.deepEqual(permissionResponses.map((call) => call.result), [{ decision: 'deny' }]); assert.equal(broker.retiredPermissionResponses.size, 1); + client.setPermissionHandler(() => ({ decision: 'allow' })); await client.send(sessionId, 'a later turn is not affected by the tombstone'); assert.equal((await client.waitForCompletion(sessionId)).reason, 'prompt_completed'); await waitForRecordedCalls(record, (calls) => calls.some((call) => call.id === 9001 && call.result?.decision === 'allow')); assert.equal(broker.retiredPermissionResponses.size, 1); + await client.close(); clientClosed = true; for (let index = 0; index < 100 && broker.retiredPermissionResponses.size; index += 1) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); assert.equal(broker.retiredPermissionResponses.size, 0); + } finally { approve?.(); if (!clientClosed) await client.close(); await broker.close(); await rm(directory, { recursive: true, force: true }); } }); test('failed send denies its exact pending permission and tombstones a late local approval', async () => { @@ -2470,35 +2479,35 @@ test('a pending stop fences new sends until its exact acknowledgement', { timeou }); test('a natural terminal remains waitable when direct-stop cleanup receives additive fields', async () => { - const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-wins-stop-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = '8'.repeat(64); const ownerId = 'terminal-wins-stop-owner'; const completionGate = join(directory, 'completion.gate'); const stopGate = join(directory, 'stop.gate'); const stopReached = join(directory, 'stop.reached'); await writeFile(completionGate, 'hold'); await writeFile(stopGate, 'hold'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_COMPLETION_GATE: completionGate, FAKE_ZCODE_STOP_GATE: stopGate, FAKE_ZCODE_STOP_GATE_REACHED: stopReached, FAKE_ZCODE_CONVERSATION_UNSUBSCRIBE_MALFORMED: '1' } }).start(); const client = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId, completionTimeoutMs: 1_000 }); + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-wins-stop-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = '8'.repeat(64); const ownerId = 'terminal-wins-stop-owner'; const completionGate = join(directory, 'completion.gate'); const stopGate = join(directory, 'stop.gate'); const stopReached = join(directory, 'stop.reached'); await writeFile(completionGate, 'hold'); await writeFile(stopGate, 'hold'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_COMPLETION_GATE: completionGate, FAKE_ZCODE_STOP_GATE: stopGate, FAKE_ZCODE_STOP_GATE_REACHED: stopReached, FAKE_ZCODE_CONVERSATION_UNSUBSCRIBE_MALFORMED: '1' } }).start(); const client = await createPreExactReleaseClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId, completionTimeoutMs: 1_000 }); try { const sessionId = (await client.createSession({ workspace: directory })).session.sessionId; await client.subscribeConversation(sessionId, { connectionId: 'terminal-wins-stop-connection', clientMode: 'desktop-continuous' }); await client.send(sessionId, 'terminal wins direct stop'); const stopping = client.stopSession(sessionId); const deadline = Date.now() + 1_000; while ((await readFile(stopReached, 'utf8').catch(() => '')) !== 'blocked' && Date.now() < deadline) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); assert.equal(await readFile(stopReached, 'utf8'), 'blocked'); await writeFile(completionGate, 'release'); for (let index = 0; index < 200 && broker.activeSessionSockets.has(sessionId); index += 1) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); assert.equal(broker.activeSessionSockets.has(sessionId), false); for (let index = 0; index < 200 && !client.protocol.completed.get(sessionId)?.length; index += 1) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); assert.equal(client.protocol.completed.get(sessionId)?.length, 1); await writeFile(stopGate, 'release'); assert.deepEqual(await stopping, {}); const completion = await client.waitForCompletion(sessionId); assert.equal(completion.reason, 'prompt_completed'); assert.ok(broker.protocol); assert.equal(broker.conversationSubscriptions.size, 0); } finally { await writeFile(completionGate, 'release').catch(() => {}); await writeFile(stopGate, 'release').catch(() => {}); await client.close(); await broker.close(); await rm(directory, { recursive: true, force: true }); } }); test('a natural terminal remains waitable when owner-release cleanup receives additive fields', async () => { - const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-wins-release-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = '9'.repeat(64); const ownerId = 'terminal-wins-release-owner'; const completionGate = join(directory, 'completion.gate'); const stopGate = join(directory, 'stop.gate'); const stopReached = join(directory, 'stop.reached'); const record = join(directory, 'calls.jsonl'); await writeFile(completionGate, 'hold'); await writeFile(stopGate, 'hold'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_COMPLETION_GATE: completionGate, FAKE_ZCODE_STOP_GATE: stopGate, FAKE_ZCODE_STOP_GATE_REACHED: stopReached, FAKE_ZCODE_CONVERSATION_UNSUBSCRIBE_MALFORMED: '1', FAKE_ZCODE_RECORD: record } }).start(); const worker = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId, completionTimeoutMs: 1_000 }); const controller = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId }); + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-wins-release-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = '9'.repeat(64); const ownerId = 'terminal-wins-release-owner'; const completionGate = join(directory, 'completion.gate'); const stopGate = join(directory, 'stop.gate'); const stopReached = join(directory, 'stop.reached'); const record = join(directory, 'calls.jsonl'); await writeFile(completionGate, 'hold'); await writeFile(stopGate, 'hold'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_COMPLETION_GATE: completionGate, FAKE_ZCODE_STOP_GATE: stopGate, FAKE_ZCODE_STOP_GATE_REACHED: stopReached, FAKE_ZCODE_CONVERSATION_UNSUBSCRIBE_MALFORMED: '1', FAKE_ZCODE_RECORD: record } }).start(); const worker = await createPreExactReleaseClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId, completionTimeoutMs: 1_000 }); const controller = await createPreExactReleaseClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId }); try { const sessionId = (await worker.createSession({ workspace: directory })).session.sessionId; await worker.subscribeConversation(sessionId, { connectionId: 'terminal-wins-release-connection', clientMode: 'desktop-continuous' }); await worker.send(sessionId, 'terminal wins owner release'); const releasing = controller.releaseOwner([]); const deadline = Date.now() + 1_000; while ((await readFile(stopReached, 'utf8').catch(() => '')) !== 'blocked' && Date.now() < deadline) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); assert.equal(await readFile(stopReached, 'utf8'), 'blocked'); await writeFile(completionGate, 'release'); for (let index = 0; index < 200 && broker.activeSessionSockets.has(sessionId); index += 1) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); assert.equal(broker.activeSessionSockets.has(sessionId), false); for (let index = 0; index < 200 && !worker.protocol.completed.get(sessionId)?.length; index += 1) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); assert.equal(worker.protocol.completed.get(sessionId)?.length, 1); await writeFile(stopGate, 'release'); const released = await releasing; assert.deepEqual(released.releasedSessionIds, [sessionId]); const completion = await worker.waitForCompletion(sessionId); assert.equal(completion.reason, 'prompt_completed'); assert.equal(broker.sessionOwners.has(sessionId), false); assert.ok(broker.protocol); assert.equal((await readRecordedCalls(record)).filter((call) => call.method === 'session/stop').length, 1); } finally { await writeFile(completionGate, 'release').catch(() => {}); await writeFile(stopGate, 'release').catch(() => {}); await worker.close(); await controller.close(); await broker.close(); await rm(directory, { recursive: true, force: true }); } }); test('a direct stop retry consumes its exact natural terminal winner with additive cleanup fields', async () => { - const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-retry-stop-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = 'a'.repeat(64); const ownerId = 'terminal-retry-stop-owner'; const completionGate = join(directory, 'completion.gate'); const stopGate = join(directory, 'stop.gate'); const stopReached = join(directory, 'stop.reached'); const record = join(directory, 'calls.jsonl'); await writeFile(completionGate, 'hold'); await writeFile(stopGate, 'hold'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_COMPLETION_GATE: completionGate, FAKE_ZCODE_STOP_GATE: stopGate, FAKE_ZCODE_STOP_GATE_REACHED: stopReached, FAKE_ZCODE_STOP_ERROR_ONCE: '1', FAKE_ZCODE_CONVERSATION_UNSUBSCRIBE_MALFORMED: '1', FAKE_ZCODE_RECORD: record } }).start(); const client = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId, completionTimeoutMs: 1_000 }); + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-retry-stop-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = 'a'.repeat(64); const ownerId = 'terminal-retry-stop-owner'; const completionGate = join(directory, 'completion.gate'); const stopGate = join(directory, 'stop.gate'); const stopReached = join(directory, 'stop.reached'); const record = join(directory, 'calls.jsonl'); await writeFile(completionGate, 'hold'); await writeFile(stopGate, 'hold'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_COMPLETION_GATE: completionGate, FAKE_ZCODE_STOP_GATE: stopGate, FAKE_ZCODE_STOP_GATE_REACHED: stopReached, FAKE_ZCODE_STOP_ERROR_ONCE: '1', FAKE_ZCODE_CONVERSATION_UNSUBSCRIBE_MALFORMED: '1', FAKE_ZCODE_RECORD: record } }).start(); const client = await createPreExactReleaseClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId, completionTimeoutMs: 1_000 }); try { const sessionId = (await client.createSession({ workspace: directory })).session.sessionId; await client.subscribeConversation(sessionId, { connectionId: 'terminal-retry-stop-connection', clientMode: 'desktop-continuous' }); await client.send(sessionId, 'terminal survives direct retry'); const firstStop = client.stopSession(sessionId); const deadline = Date.now() + 1_000; while ((await readFile(stopReached, 'utf8').catch(() => '')) !== 'blocked' && Date.now() < deadline) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); await writeFile(completionGate, 'release'); for (let index = 0; index < 200 && broker.activeSessionSockets.has(sessionId); index += 1) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); assert.equal(broker.activeSessionSockets.has(sessionId), false); await writeFile(stopGate, 'release'); await assert.rejects(firstStop, { code: 'ZCODE_REQUEST_FAILED' }); assert.equal(broker.terminalWinnerEvidence.size, 1); assert.deepEqual(await client.stopSession(sessionId), {}); assert.equal(broker.terminalWinnerEvidence.size, 0); assert.equal((await client.waitForCompletion(sessionId)).reason, 'prompt_completed'); assert.ok(broker.protocol); assert.equal((await readRecordedCalls(record)).filter((call) => call.method === 'session/stop').length, 2); } finally { await writeFile(completionGate, 'release').catch(() => {}); await writeFile(stopGate, 'release').catch(() => {}); await client.close(); await broker.close(); await rm(directory, { recursive: true, force: true }); } }); test('an owner release retry consumes its exact natural terminal winner with additive cleanup fields', async () => { - const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-retry-release-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = 'b'.repeat(64); const ownerId = 'terminal-retry-release-owner'; const completionGate = join(directory, 'completion.gate'); const stopGate = join(directory, 'stop.gate'); const stopReached = join(directory, 'stop.reached'); const record = join(directory, 'calls.jsonl'); await writeFile(completionGate, 'hold'); await writeFile(stopGate, 'hold'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_COMPLETION_GATE: completionGate, FAKE_ZCODE_STOP_GATE: stopGate, FAKE_ZCODE_STOP_GATE_REACHED: stopReached, FAKE_ZCODE_STOP_ERROR_ONCE: '1', FAKE_ZCODE_CONVERSATION_UNSUBSCRIBE_MALFORMED: '1', FAKE_ZCODE_RECORD: record } }).start(); const worker = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId, completionTimeoutMs: 1_000 }); const controller = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId }); + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-retry-release-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = 'b'.repeat(64); const ownerId = 'terminal-retry-release-owner'; const completionGate = join(directory, 'completion.gate'); const stopGate = join(directory, 'stop.gate'); const stopReached = join(directory, 'stop.reached'); const record = join(directory, 'calls.jsonl'); await writeFile(completionGate, 'hold'); await writeFile(stopGate, 'hold'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_COMPLETION_GATE: completionGate, FAKE_ZCODE_STOP_GATE: stopGate, FAKE_ZCODE_STOP_GATE_REACHED: stopReached, FAKE_ZCODE_STOP_ERROR_ONCE: '1', FAKE_ZCODE_CONVERSATION_UNSUBSCRIBE_MALFORMED: '1', FAKE_ZCODE_RECORD: record } }).start(); const worker = await createPreExactReleaseClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId, completionTimeoutMs: 1_000 }); const controller = await createPreExactReleaseClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId }); try { const sessionId = (await worker.createSession({ workspace: directory })).session.sessionId; await worker.subscribeConversation(sessionId, { connectionId: 'terminal-retry-release-connection', clientMode: 'desktop-continuous' }); await worker.send(sessionId, 'terminal survives release retry'); const firstRelease = controller.releaseOwner([]); const deadline = Date.now() + 1_000; while ((await readFile(stopReached, 'utf8').catch(() => '')) !== 'blocked' && Date.now() < deadline) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); await writeFile(completionGate, 'release'); for (let index = 0; index < 200 && broker.activeSessionSockets.has(sessionId); index += 1) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); assert.equal(broker.activeSessionSockets.has(sessionId), false); await writeFile(stopGate, 'release'); const failed = await firstRelease; assert.deepEqual(failed.releasedSessionIds, []); assert.deepEqual(failed.failedSessionIds, [sessionId]); assert.equal(broker.terminalWinnerEvidence.size, 1); const released = await controller.releaseOwner([]); assert.deepEqual(released.releasedSessionIds, [sessionId]); assert.deepEqual(released.failedSessionIds, []); assert.equal(broker.terminalWinnerEvidence.size, 0); assert.equal((await worker.waitForCompletion(sessionId)).reason, 'prompt_completed'); assert.equal(broker.sessionOwners.has(sessionId), false); assert.ok(broker.protocol); assert.equal((await readRecordedCalls(record)).filter((call) => call.method === 'session/stop').length, 2); } finally { await writeFile(completionGate, 'release').catch(() => {}); await writeFile(stopGate, 'release').catch(() => {}); await worker.close(); await controller.close(); await broker.close(); await rm(directory, { recursive: true, force: true }); } }); test('an owner release retains natural terminal evidence until durable ownership commits', async () => { - const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-durable-retry-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = 'c'.repeat(64); const ownerId = 'terminal-durable-retry-owner'; const completionGate = join(directory, 'completion.gate'); const stopGate = join(directory, 'stop.gate'); const stopReached = join(directory, 'stop.reached'); const record = join(directory, 'calls.jsonl'); await writeFile(completionGate, 'hold'); await writeFile(stopGate, 'hold'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_COMPLETION_GATE: completionGate, FAKE_ZCODE_STOP_GATE: stopGate, FAKE_ZCODE_STOP_GATE_REACHED: stopReached, FAKE_ZCODE_CONVERSATION_UNSUBSCRIBE_MALFORMED_AFTER: '2', FAKE_ZCODE_RECORD: record } }).start(); const worker = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId, completionTimeoutMs: 1_000 }); const controller = await createZCodeClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId }); + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-terminal-durable-retry-')); const endpoint = brokerEndpointFor({ dataRoot: directory, workspace: directory }); const brokerToken = 'c'.repeat(64); const ownerId = 'terminal-durable-retry-owner'; const completionGate = join(directory, 'completion.gate'); const stopGate = join(directory, 'stop.gate'); const stopReached = join(directory, 'stop.reached'); const record = join(directory, 'calls.jsonl'); await writeFile(completionGate, 'hold'); await writeFile(stopGate, 'hold'); const broker = await newTestBroker({ endpoint, brokerToken, workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture }, env: { ...process.env, FAKE_ZCODE_COMPLETION_GATE: completionGate, FAKE_ZCODE_STOP_GATE: stopGate, FAKE_ZCODE_STOP_GATE_REACHED: stopReached, FAKE_ZCODE_CONVERSATION_UNSUBSCRIBE_MALFORMED_AFTER: '2', FAKE_ZCODE_RECORD: record } }).start(); const worker = await createPreExactReleaseClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId, completionTimeoutMs: 1_000 }); const controller = await createPreExactReleaseClient({ workspace: directory, brokerEndpoint: endpoint, brokerToken, ownerId }); try { const sessionId = (await worker.createSession({ workspace: directory })).session.sessionId; await worker.subscribeConversation(sessionId, { connectionId: 'terminal-durable-retry-first', clientMode: 'desktop-continuous' }); await worker.send(sessionId, 'terminal survives durable retry'); const writeOwnerStore = broker.writeOwnerStore.bind(broker); const durableError = new Error('durable release failed before apply'); let failWrite = true; broker.writeOwnerStore = async (...args) => { if (failWrite) { failWrite = false; throw durableError; } return writeOwnerStore(...args); }; const releaseSocket = { destroyed: false }; const releaseDeadline = () => Date.now() + scaleTestTimeout(600); const firstRelease = broker.releaseOwner(releaseSocket, ownerId, [], releaseDeadline()); const deadline = Date.now() + scaleTestTimeout(1_000); while ((await readFile(stopReached, 'utf8').catch(() => '')) !== 'blocked' && Date.now() < deadline) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); await writeFile(completionGate, 'release'); for (let index = 0; index < 200 && broker.activeSessionSockets.has(sessionId); index += 1) await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); await writeFile(stopGate, 'release'); await assert.rejects(firstRelease); assert.equal(broker.sessionOwners.get(sessionId)?.ownerId, ownerId); assert.equal(broker.terminalWinnerEvidence.size, 1); for (let index = 0; index < 256; index += 1) broker.recordTerminalWinner(`durable-eviction-${index}`, broker.protocol, { token: `durable-eviction-token-${index}`, baseline: index, inputId: `durable-eviction-input-${index}` }); assert.equal(broker.terminalWinnerEvidence.has(sessionId), false); await worker.subscribeConversation(sessionId, { connectionId: 'terminal-durable-retry-second', clientMode: 'desktop-continuous' }); const released = await broker.releaseOwner(releaseSocket, ownerId, [], releaseDeadline()); assert.deepEqual(released.releasedSessionIds, [sessionId]); assert.deepEqual(released.failedSessionIds, []); assert.equal(broker.sessionOwners.has(sessionId), false); assert.equal(broker.terminalWinnerEvidence.size, 256); assert.equal((await readRecordedCalls(record)).filter((call) => call.method === 'session/stop').length, 2); } finally { await writeFile(completionGate, 'release').catch(() => {}); await writeFile(stopGate, 'release').catch(() => {}); await worker.close(); await controller.close(); await broker.close(); await rm(directory, { recursive: true, force: true }); } From e679ce7f57f93c96009c066796a8d8772cb0a459 Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:02:37 +0800 Subject: [PATCH 05/19] fix: type exact turn capability probe --- scripts/lib/zcode-client.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/lib/zcode-client.mjs b/scripts/lib/zcode-client.mjs index 5e257d0..010eba8 100644 --- a/scripts/lib/zcode-client.mjs +++ b/scripts/lib/zcode-client.mjs @@ -31,7 +31,13 @@ export const IMPORTED_HISTORY_SOURCE = 'claudeCode'; export class ZCodeClient { /** @param {import('./zcode-protocol.mjs').ZCodeProtocolClient} protocol @param {string} [workspace] @param {boolean} [workspaceBound] */ - constructor(protocol, workspace, workspaceBound = false) { this.protocol = protocol; this.defaultWorkspace = workspace === undefined ? null : resolve(workspace); this.workspaceBound = workspaceBound; this.sessionCatalogs = new Map(); this.sessionWorkspaces = new Map(); this.initialEmptySessions = new Set(); this.exactTurnRelease = workspaceBound ? null : false; this.exactTurnReleaseProbe = null; this.armedBoundaries = new Map(); } + constructor(protocol, workspace, workspaceBound = false) { + this.protocol = protocol; this.defaultWorkspace = workspace === undefined ? null : resolve(workspace); this.workspaceBound = workspaceBound; + this.sessionCatalogs = new Map(); this.sessionWorkspaces = new Map(); this.initialEmptySessions = new Set(); this.exactTurnRelease = workspaceBound ? null : false; + /** @type {Promise|null} */ + this.exactTurnReleaseProbe = null; + this.armedBoundaries = new Map(); + } /** @param {{workspace:string,sessionId?:string,model?:{providerId:string,modelId:string,variant?:string},importedHistory?:{title?:string,createdAt?:number,updatedAt?:number,messages:Array<{role:'user'|'assistant',content:string,timestamp?:number}>}}} input */ async createSession(input) { From 1dc5848b089a485fd3afb783ad020d4ba402b0bd Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:09:14 +0800 Subject: [PATCH 06/19] fix: retain exact turn release authority on failure --- scripts/lib/zcode-client.mjs | 4 ++-- tests/zcode-client.test.mjs | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/scripts/lib/zcode-client.mjs b/scripts/lib/zcode-client.mjs index 010eba8..998488f 100644 --- a/scripts/lib/zcode-client.mjs +++ b/scripts/lib/zcode-client.mjs @@ -138,9 +138,9 @@ export class ZCodeClient { /** Wait for a validated terminal notification; no deadline applies unless configured on the client or supplied here. @param {string} sessionId @param {number} [timeoutMs] */ async waitForCompletion(sessionId, timeoutMs) { const boundary = this.armedBoundaries.get(sessionId); const completion = await this.protocol.waitForCompletion(sessionId, timeoutMs); if (this.exactTurnRelease === true && boundary) await this.releaseExactTurn(sessionId, boundary); else this.armedBoundaries.delete(sessionId); return completion; } /** Observe a validated terminal notification without consuming the active turn. @param {string} sessionId @param {number} [timeoutMs] */ observeCompletion(sessionId, timeoutMs) { return this.protocol.observeCompletion(sessionId, timeoutMs); } - /** Release the exact managed broker turn, then always clear local turn state. Direct clients clear synchronously. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); const boundary = this.armedBoundaries.get(sessionId); if (!this.workspaceBound || this.exactTurnRelease !== true || !boundary) { this.protocol.releaseTurn(sessionId); this.armedBoundaries.delete(sessionId); return Promise.resolve(); } return this.releaseExactTurn(sessionId, boundary); } + /** Release the exact managed broker turn, then clear local turn state after acknowledgement. Direct clients clear synchronously. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); const boundary = this.armedBoundaries.get(sessionId); if (!this.workspaceBound || this.exactTurnRelease !== true || !boundary) { this.protocol.releaseTurn(sessionId); this.armedBoundaries.delete(sessionId); return Promise.resolve(); } return this.releaseExactTurn(sessionId, boundary); } /** @param {string} sessionId @param {{stateRevision:number,inputId:string}} boundary */ - async releaseExactTurn(sessionId, boundary) { try { const result = await this.protocol.request('broker/releaseTurn', { sessionId, inputId: boundary.inputId, stateRevision: boundary.stateRevision }); if (!plainObject(result) || Object.keys(result).length !== 0) throw outputError('broker/releaseTurn'); } finally { this.protocol.releaseTurn(sessionId); if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); } } + async releaseExactTurn(sessionId, boundary) { const result = await this.protocol.request('broker/releaseTurn', { sessionId, inputId: boundary.inputId, stateRevision: boundary.stateRevision }); if (!plainObject(result) || Object.keys(result).length !== 0) throw outputError('broker/releaseTurn'); this.protocol.releaseTurn(sessionId); if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); } async ensureExactTurnReleaseCapability() { if (!this.workspaceBound || this.exactTurnRelease !== null) return; this.exactTurnReleaseProbe ??= requestBrokerHealth(this, undefined, true).then((result) => { this.exactTurnRelease = result.capabilities?.exactTurnRelease === true; }, (error) => { this.exactTurnReleaseProbe = null; throw error; }); await this.exactTurnReleaseProbe; } /** Exact local protocol invariant used to prove whether this client owns an active turn. @param {string} sessionId */ turnState(sessionId) { requireSessionId(sessionId); return this.protocol.turnState(sessionId); } /** @param {string} sessionId @param {{connectionId:string,clientMode:'desktop-continuous'|'web-remote-replayable'}} options */ diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index 03ab5fd..e294541 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -1505,6 +1505,46 @@ test('exact turn release rejects foreign and stale tuples while duplicate acknow } finally { broker.cancelIdleShutdown(); await rm(directory, { recursive: true, force: true }); } }); +test('managed client retains exact release authority until broker acknowledgement', async (t) => { + for (const failureMode of ['pre-commit rejection', 'lost acknowledgement']) await t.test(failureMode, async () => { + const sessionId = `release-retry-${failureMode.replaceAll(' ', '-')}`; const turns = new Map(); const calls = []; let releaseAttempts = 0; let brokerRoute = true; let tombstoned = false; + const protocol = { + acceptBrokerControl: true, + request: async (method, params) => { + calls.push({ method, params }); + if (method === 'broker/health') return { ok: true, capabilities: { exactTurnRelease: true } }; + if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: 11 }; + if (method !== 'broker/releaseTurn') throw new Error(`unexpected ${method}`); + releaseAttempts += 1; + if (releaseAttempts === 1) { + if (failureMode === 'lost acknowledgement') { brokerRoute = false; tombstoned = true; } + throw new Error(`simulated ${failureMode}`); + } + if (brokerRoute) brokerRoute = false; + else if (!tombstoned) throw new Error('exact broker route was lost without a tombstone'); + return {}; + }, + beginTurn: (id) => { if (turns.has(id)) throw new Error('turn already active'); turns.set(id, 'sending'); }, + armTurn: (id) => turns.set(id, 'armed'), + abortTurn: (id) => turns.delete(id), + releaseTurn: (id) => turns.delete(id), + turnState: (id) => turns.get(id) ?? null, + }; + const client = new ZCodeClient(protocol, process.cwd(), true); + const sent = await client.send(sessionId, 'first turn'); + await assert.rejects(client.releaseTurn(sessionId), new RegExp(failureMode, 'u')); + assert.equal(client.turnState(sessionId), 'armed'); + if (failureMode === 'pre-commit rejection') assert.equal(brokerRoute, true); + await assert.rejects(client.send(sessionId, 'must remain fenced'), /turn already active/u); + assert.equal(calls.filter((call) => call.method === 'session/send').length, 1); + await client.releaseTurn(sessionId); + assert.equal(client.turnState(sessionId), null); + assert.equal(brokerRoute, false); + const releases = calls.filter((call) => call.method === 'broker/releaseTurn'); + assert.deepEqual(releases.map((call) => call.params), Array(2).fill({ sessionId, inputId: sent.inputId, stateRevision: 11 })); + }); +}); + test('managed client falls back to local release when broker health lacks exact release capability', async () => { const calls = []; const turns = new Map(); const protocol = { acceptBrokerControl: true, request: async (method, params) => { calls.push({ method, params }); if (method === 'broker/health') return { ok: true, capabilities: {} }; if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: 3 }; throw new Error(method); }, beginTurn: (sessionId) => turns.set(sessionId, 'sending'), armTurn: (sessionId) => turns.set(sessionId, 'armed'), abortTurn: (sessionId) => turns.delete(sessionId), releaseTurn: (sessionId) => turns.delete(sessionId), turnState: (sessionId) => turns.get(sessionId) ?? null }; const client = new ZCodeClient(protocol, process.cwd(), true); await client.send('legacy-broker-session', 'hello'); await client.releaseTurn('legacy-broker-session'); From c09d51a77d85b048f89ab24556b0af6c9c11536a Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:11:12 +0800 Subject: [PATCH 07/19] test: cover resumed permission after legacy completion --- tests/fixtures/fake-zcode-cli.mjs | 5 +++-- tests/integration/companion.test.mjs | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/fake-zcode-cli.mjs b/tests/fixtures/fake-zcode-cli.mjs index 4ba71ed..35961f9 100644 --- a/tests/fixtures/fake-zcode-cli.mjs +++ b/tests/fixtures/fake-zcode-cli.mjs @@ -311,11 +311,12 @@ async function sendCaptured0165Turn(message, p, session) { await traceCaptured0165(sendIndex, 'legacy-prompt-completed', legacy); send(legacy); if (process.env.FAKE_ZCODE_CAPTURED_0165_PERMISSION === '1') { const id = permissionId++; - send({ id, method: 'interaction/requestPermission', params: { + const permission = { id, method: 'interaction/requestPermission', params: { requestId: `permission-${id}`, sessionId: p.sessionId, toolCallId: `captured-tool-${sendIndex}`, toolName: 'write', reason: 'captured 0.16.5 fixture', riskLevel: 'medium', input: { path: 'README.md' }, requestedAt: 1_786_233_601_742, options: [{ optionId: 'allow', kind: 'allow', name: 'Allow', response: { decision: 'allow' } }, { optionId: 'deny', kind: 'deny', name: 'Deny', response: { decision: 'deny' } }], - } }); + } }; + await traceCaptured0165(sendIndex, 'permission-request', permission); send(permission); } while (!await captured0165GateReleased(process.env.FAKE_ZCODE_CAPTURED_0165_RUNNING_GATE, sendIndex)) await new Promise((resolve) => setTimeout(resolve, 5)); const turnRow = { diff --git a/tests/integration/companion.test.mjs b/tests/integration/companion.test.mjs index c510f68..99af0a4 100644 --- a/tests/integration/companion.test.mjs +++ b/tests/integration/companion.test.mjs @@ -5105,6 +5105,7 @@ test('captured 0.16.5 true terminal gates fresh and continuation results after f await atomicWriteJson(terminalGate, { version: 1, releaseThrough: 0 }); const env = { FAKE_ZCODE_VERSION: '0.16.5', FAKE_ZCODE_CONVERSATION_SCENARIO: 'captured-0165', + FAKE_ZCODE_CAPTURED_0165_PERMISSION: '1', FAKE_ZCODE_RECORD: record, FAKE_ZCODE_CAPTURED_0165_TRACE: trace, FAKE_ZCODE_CAPTURED_0165_RUNNING_GATE: runningGate, FAKE_ZCODE_CAPTURED_0165_TERMINAL_GATE: terminalGate, @@ -5117,6 +5118,7 @@ test('captured 0.16.5 true terminal gates fresh and continuation results after f let settled = false; const execution = companion(context, ['rescue', mode, `captured 0.16.5 turn ${turn}`], env).finally(() => { settled = true; }); const readTrace = () => readFile(trace, 'utf8').then((contents) => contents.trim().split('\n').filter(Boolean).map((line) => JSON.parse(line))).catch(() => []); + const readRequests = () => readFile(record, 'utf8').then((contents) => contents.trim().split('\n').filter(Boolean).map((line) => JSON.parse(line))).catch(() => []); let result; try { await waitFor(async () => (await readTrace()).some((entry) => entry.sendCount === turn && entry.phase === 'legacy-prompt-completed'), @@ -5124,6 +5126,19 @@ test('captured 0.16.5 true terminal gates fresh and continuation results after f assert.equal((await readTrace()).some((entry) => entry.sendCount === turn && entry.phase === 'turn-running'), false, 'the captured legacy-only gap must precede every runtime running frame'); assert.equal(settled, false, 'false legacy completion without a runtime frame must not settle the companion'); + await waitFor(async () => { + const permission = (await readTrace()).find((entry) => entry.sendCount === turn && entry.phase === 'permission-request')?.message; + return permission && (await readRequests()).some((request) => request.id === permission.id && request.result?.decision === 'allow'); + }, `captured 0.16.5 turn ${turn} permission was not allowed during its legacy-only gap`); + const permission = (await readTrace()).find((entry) => entry.sendCount === turn && entry.phase === 'permission-request').message; + assert.equal(permission.params.requestId, `permission-${permission.id}`); + assert.equal(permission.params.toolCallId, `captured-tool-${turn}`); + assert.equal(permission.params.riskLevel, 'medium'); + assert.equal(permission.params.requestedAt, 1_786_233_601_742); + const offeredAllow = permission.params.options.find((option) => option.kind === 'allow').response; + assert.deepEqual(offeredAllow, { decision: 'allow' }); + const permissionResponses = (await readRequests()).filter((request) => request.id === permission.id); + assert.deepEqual(permissionResponses, [{ id: permission.id, result: offeredAllow }]); await atomicWriteJson(runningGate, { version: 1, releaseThrough: turn }); await waitFor(async () => { const marker = await readFile(runningReached, 'utf8').then(JSON.parse).catch(() => null); @@ -5178,7 +5193,7 @@ test('captured 0.16.5 true terminal gates fresh and continuation results after f for (const turn of [1, 2]) { const entries = peerTrace.filter((entry) => entry.sendCount === turn); const protocolEntries = entries.filter((entry) => !entry.phase.startsWith('session-read-')); - assert.deepEqual(protocolEntries.map((entry) => entry.phase), ['subscribe-ack', 'initial-frame', 'send-accepted', 'legacy-prompt-completed', 'turn-running', 'turn-terminal']); + assert.deepEqual(protocolEntries.map((entry) => entry.phase), ['subscribe-ack', 'initial-frame', 'send-accepted', 'legacy-prompt-completed', 'permission-request', 'turn-running', 'turn-terminal']); assert.doesNotMatch(JSON.stringify(protocolEntries), /future/u, 'captured qualification data must not contain synthetic additive fields'); const ack = protocolEntries[0].message.result.ack; assert.deepEqual(Object.keys(protocolEntries[0].message.result), ['ack']); From 713b89855f9c24479e74c1dfa14e660f58fdb26b Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:19:24 +0800 Subject: [PATCH 08/19] fix: drain permission tasks before turn release --- scripts/lib/zcode-protocol.mjs | 12 ++++++++++++ scripts/zcode-broker.mjs | 5 ++--- tests/process-zcode.test.mjs | 19 +++++++++++++++++++ tests/zcode-client.test.mjs | 18 ++++++++++++++++-- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/scripts/lib/zcode-protocol.mjs b/scripts/lib/zcode-protocol.mjs index bd06443..93f9b37 100644 --- a/scripts/lib/zcode-protocol.mjs +++ b/scripts/lib/zcode-protocol.mjs @@ -113,6 +113,18 @@ export class ZCodeProtocolClient { /** Locally release a turn without sending an upstream request. @param {string} sessionId */ releaseTurn(sessionId) { if (!nonEmpty(sessionId)) throw protocolInputError(); this.cancelTurn(sessionId); } + /** Wait for the server requests currently tracked for one session. @param {string} sessionId */ + async drainServerTasksForSession(sessionId) { + if (!nonEmpty(sessionId)) throw protocolInputError(); + const tasks = []; + for (const [controller, taskSessionId] of this.serverTaskSessions) { + if (taskSessionId !== sessionId) continue; + const task = this.serverTasksByController.get(controller); + if (task) tasks.push(task); + } + if (tasks.length) await Promise.allSettled(tasks); + } + /** @param {(message:any)=>void} handler */ subscribe(handler) { if (typeof handler !== 'function' || this.subscribers.size >= 256) throw protocolInputError(); this.subscribers.add(handler); return () => this.subscribers.delete(handler); } diff --git a/scripts/zcode-broker.mjs b/scripts/zcode-broker.mjs index f7d21f3..61c19b9 100644 --- a/scripts/zcode-broker.mjs +++ b/scripts/zcode-broker.mjs @@ -281,6 +281,7 @@ export class ZCodeBroker { clearTimeout(authTimer); this.socketWriters.get(socket)?.close(); this.sockets.delete(socket); this.existingProtocolOnlySockets.delete(socket); for (const [id, pending] of this.permissionPending) if (pending.socket === socket) { clearTimeout(pending.timer); this.permissionPending.delete(id); pending.resolve(offeredDeny(pending.request)); } for (const [id, retired] of this.retiredPermissionResponses) if (retired.socket === socket) this.retiredPermissionResponses.delete(id); + for (const [key, tombstone] of this.releasedTurnTombstones) if (tombstone.socket === socket) this.releasedTurnTombstones.delete(key); for (const owner of this.sessionOwners.values()) if (owner.socket === socket) owner.socket = null; for (const active of this.activeSessionSockets.values()) if (active.socket === socket) active.socket = null; const cleanup = this.cleanupSocketSubscriptions(socket); this.localTasks.add(cleanup); void cleanup.finally(() => { this.localTasks.delete(cleanup); this.scheduleIdleShutdown(); }); @@ -671,9 +672,7 @@ export class ZCodeBroker { if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || active?.socket !== socket || active.baseline !== params.stateRevision || active.inputId !== params.inputId || typeof active.token !== 'string') throw turnReleaseMismatch(); const activeToken = active.token; this.settleTurnPermissions(params.sessionId, activeToken); - // Let the upstream server-request task write its deny before releaseTurn - // aborts session-scoped tasks. Two promise turns cover both await edges. - await Promise.resolve(); await Promise.resolve(); + await protocol.drainServerTasksForSession(params.sessionId); if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId)?.token !== activeToken) throw turnReleaseMismatch(); protocol.releaseTurn(params.sessionId); if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId)?.token !== activeToken) throw turnReleaseMismatch(); diff --git a/tests/process-zcode.test.mjs b/tests/process-zcode.test.mjs index 0eeecff..24c1082 100644 --- a/tests/process-zcode.test.mjs +++ b/tests/process-zcode.test.mjs @@ -256,6 +256,25 @@ test('observed completion leaves the turn armed and a later permission request c protocol.releaseTurn('session-1'); }); +test('session server-task drain waits through permission handler barriers until the response is written', async () => { + const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.exitCode = 0; child.signalCode = null; + const protocol = new ZCodeProtocolClient(child); + protocol.beginTurn('session-1'); protocol.armTurn('session-1', 1, 'input-1'); + let releaseBarrier; const barrier = new Promise((resolve) => { releaseBarrier = resolve; }); + let enteredHandler; const handlerEntered = new Promise((resolve) => { enteredHandler = resolve; }); + protocol.setPermissionHandler(async () => { enteredHandler(); await Promise.resolve(); await barrier; await Promise.resolve(); return { decision: 'deny' }; }); + protocol.handleLine(JSON.stringify({ id: 99, method: 'interaction/requestPermission', params: { requestId: 'r', sessionId: 'session-1', toolCallId: 't', toolName: 'write', reason: 'test', riskLevel: 'low', input: {}, options: [{ optionId: 'deny', kind: 'deny', name: 'Deny', response: { decision: 'deny' } }] } })); + await handlerEntered; + let drained = false; const draining = protocol.drainServerTasksForSession('session-1').then(() => { drained = true; }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(drained, false); + assert.equal(child.stdin.readableLength, 0); + releaseBarrier(); await draining; + assert.equal(drained, true); + assert.deepEqual(JSON.parse(child.stdin.read().toString()), { id: 99, result: { decision: 'deny' } }); + protocol.releaseTurn('session-1'); +}); + test('broker terminal observer leaves no completion queue or expiry and observes early arm completion', () => { const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.exitCode = 0; child.signalCode = null; const protocol = new ZCodeProtocolClient(child); const observed = []; diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index e294541..434e078 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -6,6 +6,7 @@ import { join, resolve } from 'node:path'; import { realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; import net from 'node:net'; import test from 'node:test'; @@ -1492,19 +1493,32 @@ test('exact turn release rejects foreign and stale tuples while duplicate acknow for (const [peer, peerOwner] of [[socket, ownerId], [foreign, 'exact-release-foreign-owner']]) { broker.authenticated.add(peer); broker.socketOwnerIds.set(peer, peerOwner); } broker.exactTurnReleaseSockets.add(socket); broker.exactTurnReleaseSockets.add(foreign); broker.sessionOwners.set(sessionId, { ownerId, socket }); broker.reloadOwnership = async () => {}; - const released = []; const protocol = { releaseTurn: (releasedSessionId) => released.push(releasedSessionId) }; broker.protocol = protocol; + const released = []; let releaseDrain; const drainBarrier = new Promise((resolve) => { releaseDrain = resolve; }); let denyRecorded = false; + const protocol = { drainServerTasksForSession: async () => { await drainBarrier; denyRecorded = true; }, releaseTurn: (releasedSessionId) => { assert.equal(denyRecorded, true, 'permission deny must be written before releasing the protocol turn'); released.push(releasedSessionId); } }; broker.protocol = protocol; const old = { socket, token: 'exact-release-old-token', baseline: 7, inputId: 'exact-release-old-input' }; broker.activeSessionSockets.set(sessionId, old); broker.activeSessions.add(sessionId); const request = (peer, id, params) => broker.handleLocal(peer, JSON.stringify({ id, method: 'broker/releaseTurn', params })); const tuple = { sessionId, stateRevision: 7, inputId: 'exact-release-old-input' }; try { await request(foreign, 1, tuple); assert.equal(foreignWrites.at(-1)?.error?.code, -32041); assert.equal(broker.activeSessionSockets.get(sessionId), old); await request(socket, 2, { ...tuple, inputId: 'wrong-input' }); assert.equal(writes.at(-1)?.error?.data?.pluginError?.code, 'ZCODE_TURN_RELEASE_MISMATCH'); assert.equal(broker.activeSessionSockets.get(sessionId), old); - await request(socket, 3, tuple); assert.deepEqual(writes.at(-1)?.result, {}); assert.deepEqual(released, [sessionId]); assert.equal(broker.activeSessionSockets.has(sessionId), false); + const releasing = request(socket, 3, tuple); await new Promise((resolvePromise) => setImmediate(resolvePromise)); assert.deepEqual(released, []); assert.equal(broker.activeSessionSockets.get(sessionId), old); releaseDrain(); await releasing; assert.deepEqual(writes.at(-1)?.result, {}); assert.deepEqual(released, [sessionId]); assert.equal(broker.activeSessionSockets.has(sessionId), false); await request(socket, 4, tuple); assert.deepEqual(writes.at(-1)?.result, {}); assert.deepEqual(released, [sessionId]); const newer = { socket, token: 'exact-release-new-token', baseline: 8, inputId: 'exact-release-new-input' }; broker.activeSessionSockets.set(sessionId, newer); broker.activeSessions.add(sessionId); await request(socket, 5, tuple); assert.equal(writes.at(-1)?.error?.data?.pluginError?.code, 'ZCODE_TURN_RELEASE_MISMATCH'); assert.equal(broker.activeSessionSockets.get(sessionId), newer); assert.equal(broker.activeSessions.has(sessionId), true); assert.deepEqual(released, [sessionId]); } finally { broker.cancelIdleShutdown(); await rm(directory, { recursive: true, force: true }); } }); +test('socket close removes only its exact turn-release tombstones', async () => { + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-release-tombstone-close-')); + const broker = newTestBroker({ endpoint: join(directory, 'broker.sock'), brokerToken: 'b'.repeat(64), workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture } }); + class FakeSocket extends EventEmitter { constructor() { super(); this.writable = true; this.destroyed = false; } setEncoding() {} write() { return true; } destroy() { this.destroyed = true; this.emit('close'); } } + const closedSocket = new FakeSocket(); const liveSocket = new FakeSocket(); broker.accept(closedSocket); broker.accept(liveSocket); + broker.releasedTurnTombstones.set('closed-a', { socket: closedSocket }); broker.releasedTurnTombstones.set('live', { socket: liveSocket }); broker.releasedTurnTombstones.set('closed-b', { socket: closedSocket }); + closedSocket.destroy(); await new Promise((resolvePromise) => setImmediate(resolvePromise)); + assert.deepEqual([...broker.releasedTurnTombstones.keys()], ['live']); + assert.equal(broker.releasedTurnTombstones.get('live')?.socket, liveSocket); + liveSocket.destroy(); broker.cancelIdleShutdown(); await rm(directory, { recursive: true, force: true }); +}); + test('managed client retains exact release authority until broker acknowledgement', async (t) => { for (const failureMode of ['pre-commit rejection', 'lost acknowledgement']) await t.test(failureMode, async () => { const sessionId = `release-retry-${failureMode.replaceAll(' ', '-')}`; const turns = new Map(); const calls = []; let releaseAttempts = 0; let brokerRoute = true; let tombstoned = false; From 4647e37094c440262cb1a313fec2eec4f88866ba Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:21:06 +0800 Subject: [PATCH 09/19] test: type captured permission fixture --- tests/integration/companion.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/companion.test.mjs b/tests/integration/companion.test.mjs index 99af0a4..cb18979 100644 --- a/tests/integration/companion.test.mjs +++ b/tests/integration/companion.test.mjs @@ -5135,7 +5135,7 @@ test('captured 0.16.5 true terminal gates fresh and continuation results after f assert.equal(permission.params.toolCallId, `captured-tool-${turn}`); assert.equal(permission.params.riskLevel, 'medium'); assert.equal(permission.params.requestedAt, 1_786_233_601_742); - const offeredAllow = permission.params.options.find((option) => option.kind === 'allow').response; + const offeredAllow = permission.params.options.find((/** @type {any} */ option) => option.kind === 'allow').response; assert.deepEqual(offeredAllow, { decision: 'allow' }); const permissionResponses = (await readRequests()).filter((request) => request.id === permission.id); assert.deepEqual(permissionResponses, [{ id: permission.id, result: offeredAllow }]); From 3c77c742f8b1842bfe08fcd2fffdcf24717f720d Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:30:37 +0800 Subject: [PATCH 10/19] fix: retry exact turn cleanup before close --- scripts/lib/review.mjs | 19 +++++++++++---- scripts/lib/zcode-client.mjs | 9 ++++---- tests/job-control.test.mjs | 33 ++++++++++++++++++++++---- tests/zcode-client.test.mjs | 45 ++++++++++++++++++++++++++++++------ 4 files changed, 85 insertions(+), 21 deletions(-) diff --git a/scripts/lib/review.mjs b/scripts/lib/review.mjs index 61185db..a1ddde6 100644 --- a/scripts/lib/review.mjs +++ b/scripts/lib/review.mjs @@ -342,12 +342,21 @@ export async function executeJob(input) { } // Cleanup order is part of the progress lifecycle contract. await cleanupProgress(); - try { - if (sessionId && typeof client.releaseTurn === 'function') await client.releaseTurn(sessionId); - } catch (cleanupError) { - if (!primaryError && output?.job?.status !== 'succeeded') primaryError = cleanupError; + let releaseError; + if (sessionId && typeof client.releaseTurn === 'function') { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { await client.releaseTurn(sessionId); releaseError = undefined; break; } + catch (cleanupError) { releaseError = cleanupError; } + } + } + if (releaseError) { + await jobLog?.appendBlock('Cleanup diagnostic', 'ZCode turn release cleanup was incomplete.', Date.now() + OPTIONAL_PROGRESS_FENCE_MS).catch(() => {}); + if (!primaryError && output?.job?.status !== 'succeeded') primaryError = releaseError; + } + try { await client.close(); } + catch { + await jobLog?.appendBlock('Cleanup diagnostic', 'ZCode client close cleanup was incomplete.', Date.now() + OPTIONAL_PROGRESS_FENCE_MS).catch(() => {}); } - await client.close().catch(() => {}); if (!primaryError && appliedFinalization && output?.job?.status === 'succeeded' && typeof output.result === 'string') { await jobLog?.appendBlock('Final output', output.result, Date.now() + OPTIONAL_PROGRESS_FENCE_MS); } diff --git a/scripts/lib/zcode-client.mjs b/scripts/lib/zcode-client.mjs index 998488f..9860e8f 100644 --- a/scripts/lib/zcode-client.mjs +++ b/scripts/lib/zcode-client.mjs @@ -30,10 +30,10 @@ const OWNER_CLEANUP_LEGACY_BATCH_SIZE = 8; export const IMPORTED_HISTORY_SOURCE = 'claudeCode'; export class ZCodeClient { - /** @param {import('./zcode-protocol.mjs').ZCodeProtocolClient} protocol @param {string} [workspace] @param {boolean} [workspaceBound] */ - constructor(protocol, workspace, workspaceBound = false) { + /** @param {import('./zcode-protocol.mjs').ZCodeProtocolClient} protocol @param {string} [workspace] @param {boolean} [workspaceBound] @param {boolean} [advertiseExactTurnRelease] */ + constructor(protocol, workspace, workspaceBound = false, advertiseExactTurnRelease = true) { this.protocol = protocol; this.defaultWorkspace = workspace === undefined ? null : resolve(workspace); this.workspaceBound = workspaceBound; - this.sessionCatalogs = new Map(); this.sessionWorkspaces = new Map(); this.initialEmptySessions = new Set(); this.exactTurnRelease = workspaceBound ? null : false; + this.sessionCatalogs = new Map(); this.sessionWorkspaces = new Map(); this.initialEmptySessions = new Set(); this.exactTurnRelease = workspaceBound && advertiseExactTurnRelease ? null : false; /** @type {Promise|null} */ this.exactTurnReleaseProbe = null; this.armedBoundaries = new Map(); @@ -80,6 +80,7 @@ export class ZCodeClient { async send(sessionId, content, options = {}) { requireSessionId(sessionId); if (typeof content !== 'string') throw inputError(); requireExactObject(options, [], []); await this.ensureExactTurnReleaseCapability(); + if (this.exactTurnRelease === true && this.armedBoundaries.has(sessionId)) throw new PluginError('ZCODE_TURN_ACTIVE', 'A turn is already active for this session.', { category: 'state', remedy: 'Wait for the active turn to finish.' }); this.initialEmptySessions.delete(sessionId); this.protocol.beginTurn(sessionId); const inputId = randomUUID(); @@ -136,7 +137,7 @@ export class ZCodeClient { this.sessionCatalogs.set(sessionId, result.settings.model); return result; } - /** Wait for a validated terminal notification; no deadline applies unless configured on the client or supplied here. @param {string} sessionId @param {number} [timeoutMs] */ async waitForCompletion(sessionId, timeoutMs) { const boundary = this.armedBoundaries.get(sessionId); const completion = await this.protocol.waitForCompletion(sessionId, timeoutMs); if (this.exactTurnRelease === true && boundary) await this.releaseExactTurn(sessionId, boundary); else this.armedBoundaries.delete(sessionId); return completion; } + /** Wait for a validated terminal notification; no deadline applies unless configured on the client or supplied here. @param {string} sessionId @param {number} [timeoutMs] */ async waitForCompletion(sessionId, timeoutMs) { const boundary = this.armedBoundaries.get(sessionId); const completion = await this.protocol.waitForCompletion(sessionId, timeoutMs); if (this.exactTurnRelease === true && boundary) await this.releaseExactTurn(sessionId, boundary).catch(() => {}); else this.armedBoundaries.delete(sessionId); return completion; } /** Observe a validated terminal notification without consuming the active turn. @param {string} sessionId @param {number} [timeoutMs] */ observeCompletion(sessionId, timeoutMs) { return this.protocol.observeCompletion(sessionId, timeoutMs); } /** Release the exact managed broker turn, then clear local turn state after acknowledgement. Direct clients clear synchronously. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); const boundary = this.armedBoundaries.get(sessionId); if (!this.workspaceBound || this.exactTurnRelease !== true || !boundary) { this.protocol.releaseTurn(sessionId); this.armedBoundaries.delete(sessionId); return Promise.resolve(); } return this.releaseExactTurn(sessionId, boundary); } /** @param {string} sessionId @param {{stateRevision:number,inputId:string}} boundary */ diff --git a/tests/job-control.test.mjs b/tests/job-control.test.mjs index 36a4f22..7d17ef9 100644 --- a/tests/job-control.test.mjs +++ b/tests/job-control.test.mjs @@ -1611,7 +1611,7 @@ test('0.16.5 foreground execution treats legacy completion as admission and wait test('durable success remains authoritative when local turn release fails', async () => { const { root, workspace, store } = await setup(); const job = await store.reserveJob({ workspace, ...reservation }); const dataRoot = join(root, 'data'); const sessionId = 'zs-release-after-success'; - const releaseError = new Error('local release failed after durable success'); + const releaseError = new Error('PRIVATE_RELEASE_FAILURE_AFTER_DURABLE_SUCCESS'); /** @type {string[]} */ const cleanupCalls = []; const client = { createSession: async () => ({ session: { sessionId }, settings: { model: { current: { providerId: 'p', modelId: 'm' }, available: [] } }, messages: [] }), @@ -1625,15 +1625,38 @@ test('durable success remains authoritative when local turn release fails', asyn ] }), stopSession: async () => {}, releaseTurn: async (/** @type {string} */ releasedSessionId) => { cleanupCalls.push(`release:${releasedSessionId}`); await Promise.resolve(); throw releaseError; }, - close: async () => { cleanupCalls.push('close'); }, + close: async () => { cleanupCalls.push('close'); throw new Error('PRIVATE_CLOSE_FAILURE_AFTER_DURABLE_SUCCESS'); }, }; const output = await executeJob({ job, workspace, dataRoot, store, client, task: 'task' }); assert.equal(output.job.status, 'succeeded'); assert.equal(output.result, 'durable release result'); - assert.deepEqual(cleanupCalls, [`release:${sessionId}`, 'close']); + assert.deepEqual(cleanupCalls, [`release:${sessionId}`, `release:${sessionId}`, 'close']); const persisted = await store.readJob(workspace, job.id); assert.equal(persisted.status, 'succeeded'); - assert.match(await readFile(persisted.logFile, 'utf8'), /Final output\ndurable release result\n/); + const log = await readFile(persisted.logFile, 'utf8'); + assert.match(log, /Final output\ndurable release result\n/); + assert.equal((log.match(/ZCode turn release cleanup was incomplete\./g) ?? []).length, 1); + assert.equal((log.match(/ZCode client close cleanup was incomplete\./g) ?? []).length, 1); + assert.doesNotMatch(log, /PRIVATE_RELEASE_FAILURE|PRIVATE_CLOSE_FAILURE/u); +}); + +test('executor retries exact turn release once before close after a lost acknowledgement', async () => { + const { root, workspace, store } = await setup(); const job = await store.reserveJob({ workspace, ...reservation }); + const dataRoot = join(root, 'data'); const sessionId = 'zs-release-retry-after-success'; let releaseAttempts = 0; + /** @type {string[]} */ const cleanupCalls = []; + const client = { + createSession: async () => ({ session: { sessionId }, settings: { model: { current: { providerId: 'p', modelId: 'm' }, available: [] } }, messages: [] }), + setPermissionHandler: () => {}, subscribe: silentSubscribe, + send: async () => ({ inputId: 'input-release-retry-after-success', stateRevision: 1 }), observeCompletion: async () => {}, + readSession: async () => ({ projection: { status: 'completed' }, runtime: { stateRevision: 2 }, messages: [completedUser('input-release-retry-after-success'), { info: { role: 'assistant', messageId: 'assistant-release-retry-after-success', parentMessageId: 'input-release-retry-after-success', finish: 'stop' }, parts: [{ type: 'text', text: 'retry result' }] }] }), + releaseTurn: async (/** @type {string} */ releasedSessionId) => { cleanupCalls.push(`release:${releasedSessionId}`); releaseAttempts += 1; if (releaseAttempts === 1) throw new Error('simulated lost acknowledgement'); }, + close: async () => { cleanupCalls.push('close'); }, + }; + const output = await executeJob({ job, workspace, dataRoot, store, client, task: 'task' }); + assert.equal(output.job.status, 'succeeded'); assert.equal(output.result, 'retry result'); + assert.deepEqual(cleanupCalls, [`release:${sessionId}`, `release:${sessionId}`, 'close']); + const log = await readFile((await store.readJob(workspace, job.id)).logFile, 'utf8'); + assert.doesNotMatch(log, /turn release cleanup was incomplete/u); }); test('executor releases the local turn before close on failure and preserves the primary error', async () => { @@ -1654,7 +1677,7 @@ test('executor releases the local turn before close on failure and preserves the }; const caught = await executeJob({ job, workspace, dataRoot: join(root, 'data'), store, client, task: 'task' }).catch((error) => error); assert.equal(caught, primary); - assert.deepEqual(cleanupCalls, [`release:${sessionId}`, 'close']); + assert.deepEqual(cleanupCalls, [`release:${sessionId}`, `release:${sessionId}`, 'close']); }); test('execution does not wait indefinitely for a late initial baseline and uses coherent snapshot fallback', { timeout: 10_000 }, async () => { diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index 434e078..7195319 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -14,7 +14,7 @@ import { createExistingManagedZCodeClient, createManagedZCodeClient, createZCode import { brokerEndpointFor, brokerIdentityNameForWireOptions, ensureZCodeBroker, inspectBrokerIdentity, probeBrokerHealth, reconcileBrokerOwnership, writeBrokerIdentity, ZCodeBroker as ZCodeBrokerClass } from '../scripts/zcode-broker.mjs'; import { atomicWriteJson, withFileLock } from '../scripts/lib/fs.mjs'; import { PluginError } from '../scripts/lib/errors.mjs'; -import { isCorrelatedZCodeResponseError } from '../scripts/lib/zcode-protocol.mjs'; +import { connectZCodeBroker, isCorrelatedZCodeResponseError } from '../scripts/lib/zcode-protocol.mjs'; import { resolveWorkspaceStorage } from '../scripts/lib/workspace.mjs'; import { validCreateSnapshot, validSetupAuthProbeSnapshot, validSnapshot } from '../scripts/lib/zcode-schema.mjs'; import { scaleTestTimeout, testTimeoutMultiplier } from './helpers/test-timeouts.mjs'; @@ -23,11 +23,14 @@ const fixture = fileURLToPath(new URL('./fixtures/fake-zcode-cli.mjs', import.me const brokerStartupFault = fileURLToPath(new URL('./fixtures/broker-startup-fault.cjs', import.meta.url)); const MACOS_UNIX_SOCKET_PATH_MAX_BYTES = 104; async function createPreExactReleaseClient(options) { - const client = await createZCodeClient(options); - // Pre-capability clients were still workspace-bound managed clients; they - // simply never advertised broker/releaseTurn support in broker/health. - client.exactTurnRelease = false; - return client; + const workspace = realpathSync(options.workspace); + const protocol = await connectZCodeBroker(options.brokerEndpoint, { + cwd: workspace, brokerToken: options.brokerToken, ownerId: options.ownerId, + requestTimeoutMs: options.requestTimeoutMs, completionTimeoutMs: options.completionTimeoutMs, + }); + // A pre-capability client authenticates to the broker but never advertises + // exactTurnRelease through broker/health. + return new ZCodeClient(protocol, workspace, true, false); } test('CI timeout multiplier is bounded and defaults to one', () => { @@ -1549,7 +1552,7 @@ test('managed client retains exact release authority until broker acknowledgemen await assert.rejects(client.releaseTurn(sessionId), new RegExp(failureMode, 'u')); assert.equal(client.turnState(sessionId), 'armed'); if (failureMode === 'pre-commit rejection') assert.equal(brokerRoute, true); - await assert.rejects(client.send(sessionId, 'must remain fenced'), /turn already active/u); + await assert.rejects(client.send(sessionId, 'must remain fenced'), { code: 'ZCODE_TURN_ACTIVE' }); assert.equal(calls.filter((call) => call.method === 'session/send').length, 1); await client.releaseTurn(sessionId); assert.equal(client.turnState(sessionId), null); @@ -1559,6 +1562,34 @@ test('managed client retains exact release authority until broker acknowledgemen }); }); +test('managed completion remains observable when exact release fails and fences the next send until retry', async () => { + const sessionId = 'completion-release-retry-session'; const turns = new Map(); const calls = []; let releaseAttempts = 0; + const completion = { reason: 'prompt_completed', stateRevision: 12 }; + const protocol = { + acceptBrokerControl: true, + request: async (method, params) => { + calls.push({ method, params }); + if (method === 'broker/health') return { ok: true, capabilities: { exactTurnRelease: true } }; + if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: releaseAttempts === 2 ? 13 : 12 }; + if (method === 'broker/releaseTurn') { releaseAttempts += 1; if (releaseAttempts === 1) throw new Error('simulated lost release acknowledgement'); return {}; } + throw new Error(`unexpected ${method}`); + }, + beginTurn: (id) => { if (turns.has(id)) throw new PluginError('ZCODE_TURN_ACTIVE', 'already active'); turns.set(id, 'sending'); }, + armTurn: (id) => turns.set(id, 'armed'), abortTurn: (id) => turns.delete(id), releaseTurn: (id) => turns.delete(id), + waitForCompletion: async (id) => { turns.delete(id); return completion; }, turnState: (id) => turns.get(id) ?? null, + }; + const client = new ZCodeClient(protocol, process.cwd(), true); + const first = await client.send(sessionId, 'first turn'); + assert.equal(await client.waitForCompletion(sessionId), completion, 'validated completion must not be replaced by cleanup failure'); + assert.equal(client.turnState(sessionId), null, 'the destructive protocol waiter already consumed local turn state'); + await assert.rejects(client.send(sessionId, 'must remain fenced'), { code: 'ZCODE_TURN_ACTIVE' }); + assert.equal(calls.filter((call) => call.method === 'session/send').length, 1); + await client.releaseTurn(sessionId); + assert.deepEqual(calls.filter((call) => call.method === 'broker/releaseTurn').map((call) => call.params), Array(2).fill({ sessionId, inputId: first.inputId, stateRevision: 12 })); + await client.send(sessionId, 'after exact retry'); + assert.equal(calls.filter((call) => call.method === 'session/send').length, 2); +}); + test('managed client falls back to local release when broker health lacks exact release capability', async () => { const calls = []; const turns = new Map(); const protocol = { acceptBrokerControl: true, request: async (method, params) => { calls.push({ method, params }); if (method === 'broker/health') return { ok: true, capabilities: {} }; if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: 3 }; throw new Error(method); }, beginTurn: (sessionId) => turns.set(sessionId, 'sending'), armTurn: (sessionId) => turns.set(sessionId, 'armed'), abortTurn: (sessionId) => turns.delete(sessionId), releaseTurn: (sessionId) => turns.delete(sessionId), turnState: (sessionId) => turns.get(sessionId) ?? null }; const client = new ZCodeClient(protocol, process.cwd(), true); await client.send('legacy-broker-session', 'hello'); await client.releaseTurn('legacy-broker-session'); From 20fb94ed770808a3eac7f455d525e6e19c23d3ba Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:32:02 +0800 Subject: [PATCH 11/19] fix: fence turn release against socket close --- scripts/zcode-broker.mjs | 4 ++-- tests/zcode-client.test.mjs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/zcode-broker.mjs b/scripts/zcode-broker.mjs index 61c19b9..064a8f1 100644 --- a/scripts/zcode-broker.mjs +++ b/scripts/zcode-broker.mjs @@ -673,9 +673,9 @@ export class ZCodeBroker { const activeToken = active.token; this.settleTurnPermissions(params.sessionId, activeToken); await protocol.drainServerTasksForSession(params.sessionId); - if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId)?.token !== activeToken) throw turnReleaseMismatch(); + if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId) !== active || active.socket !== socket || active.token !== activeToken || active.baseline !== params.stateRevision || active.inputId !== params.inputId) throw turnReleaseMismatch(); protocol.releaseTurn(params.sessionId); - if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId)?.token !== activeToken) throw turnReleaseMismatch(); + if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId) !== active || active.socket !== socket || active.token !== activeToken || active.baseline !== params.stateRevision || active.inputId !== params.inputId) throw turnReleaseMismatch(); this.activeSessionSockets.delete(params.sessionId); this.activeSessions.delete(params.sessionId); const key = turnReleaseKey(ownerId, params); this.releasedTurnTombstones.delete(key); this.releasedTurnTombstones.set(key, { socket }); while (this.releasedTurnTombstones.size > MAX_RELEASED_TURN_TOMBSTONES) this.releasedTurnTombstones.delete(this.releasedTurnTombstones.keys().next().value); diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index 7195319..34750d8 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -1522,6 +1522,24 @@ test('socket close removes only its exact turn-release tombstones', async () => liveSocket.destroy(); broker.cancelIdleShutdown(); await rm(directory, { recursive: true, force: true }); }); +test('socket close during exact release drain cannot commit or create a dead tombstone', async () => { + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-release-drain-close-')); const ownerId = 'release-drain-close-owner'; const sessionId = 'release-drain-close-session'; + const broker = newTestBroker({ endpoint: join(directory, 'broker.sock'), brokerToken: 'd'.repeat(64), workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture } }); + class FakeSocket extends EventEmitter { constructor() { super(); this.writable = true; this.destroyed = false; } setEncoding() {} write() { return true; } destroy() { if (this.destroyed) return; this.destroyed = true; this.writable = false; this.emit('close'); } } + const socket = new FakeSocket(); broker.accept(socket); broker.authenticated.add(socket); broker.socketOwnerIds.set(socket, ownerId); broker.exactTurnReleaseSockets.add(socket); + broker.sessionOwners.set(sessionId, { ownerId, socket }); broker.reloadOwnership = async () => {}; + let releaseDrain; const drainBarrier = new Promise((resolve) => { releaseDrain = resolve; }); let enteredDrain; const drainEntered = new Promise((resolve) => { enteredDrain = resolve; }); const released = []; + broker.protocol = { drainServerTasksForSession: async () => { enteredDrain(); await drainBarrier; }, releaseTurn: (releasedSessionId) => released.push(releasedSessionId) }; + broker.activeSessionSockets.set(sessionId, { socket, token: 'release-drain-close-token', baseline: 9, inputId: 'release-drain-close-input' }); broker.activeSessions.add(sessionId); + const releasing = broker.handleLocal(socket, JSON.stringify({ id: 1, method: 'broker/releaseTurn', params: { sessionId, stateRevision: 9, inputId: 'release-drain-close-input' } })); + await drainEntered; socket.destroy(); releaseDrain(); await releasing; + assert.deepEqual(released, []); + assert.equal(broker.activeSessionSockets.get(sessionId)?.socket, null); + assert.equal(broker.activeSessions.has(sessionId), true); + assert.equal(broker.releasedTurnTombstones.size, 0); + broker.cancelIdleShutdown(); await rm(directory, { recursive: true, force: true }); +}); + test('managed client retains exact release authority until broker acknowledgement', async (t) => { for (const failureMode of ['pre-commit rejection', 'lost acknowledgement']) await t.test(failureMode, async () => { const sessionId = `release-retry-${failureMode.replaceAll(' ', '-')}`; const turns = new Map(); const calls = []; let releaseAttempts = 0; let brokerRoute = true; let tombstoned = false; From fc55b92d3505a20b7627b4eb499486607f11bcaa Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:40:01 +0800 Subject: [PATCH 12/19] fix: close exact release permission races --- scripts/lib/zcode-client.mjs | 15 ++++++++++++- scripts/lib/zcode-protocol.mjs | 15 ++++++++----- scripts/zcode-broker.mjs | 26 +++++++++++++-------- tests/process-zcode.test.mjs | 14 ++++++++++++ tests/zcode-client.test.mjs | 41 ++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 16 deletions(-) diff --git a/scripts/lib/zcode-client.mjs b/scripts/lib/zcode-client.mjs index 9860e8f..9cdd227 100644 --- a/scripts/lib/zcode-client.mjs +++ b/scripts/lib/zcode-client.mjs @@ -137,7 +137,20 @@ export class ZCodeClient { this.sessionCatalogs.set(sessionId, result.settings.model); return result; } - /** Wait for a validated terminal notification; no deadline applies unless configured on the client or supplied here. @param {string} sessionId @param {number} [timeoutMs] */ async waitForCompletion(sessionId, timeoutMs) { const boundary = this.armedBoundaries.get(sessionId); const completion = await this.protocol.waitForCompletion(sessionId, timeoutMs); if (this.exactTurnRelease === true && boundary) await this.releaseExactTurn(sessionId, boundary).catch(() => {}); else this.armedBoundaries.delete(sessionId); return completion; } + /** Wait for a validated terminal notification; no deadline applies unless configured on the client or supplied here. @param {string} sessionId @param {number} [timeoutMs] */ + async waitForCompletion(sessionId, timeoutMs) { + const boundary = this.armedBoundaries.get(sessionId); + let completion; + try { completion = await this.protocol.waitForCompletion(sessionId, timeoutMs); } + catch (error) { + if (this.exactTurnRelease === true && boundary) await this.releaseExactTurn(sessionId, boundary).catch(() => {}); + else this.armedBoundaries.delete(sessionId); + throw error; + } + if (this.exactTurnRelease === true && boundary) await this.releaseExactTurn(sessionId, boundary).catch(() => {}); + else this.armedBoundaries.delete(sessionId); + return completion; + } /** Observe a validated terminal notification without consuming the active turn. @param {string} sessionId @param {number} [timeoutMs] */ observeCompletion(sessionId, timeoutMs) { return this.protocol.observeCompletion(sessionId, timeoutMs); } /** Release the exact managed broker turn, then clear local turn state after acknowledgement. Direct clients clear synchronously. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); const boundary = this.armedBoundaries.get(sessionId); if (!this.workspaceBound || this.exactTurnRelease !== true || !boundary) { this.protocol.releaseTurn(sessionId); this.armedBoundaries.delete(sessionId); return Promise.resolve(); } return this.releaseExactTurn(sessionId, boundary); } /** @param {string} sessionId @param {{stateRevision:number,inputId:string}} boundary */ diff --git a/scripts/lib/zcode-protocol.mjs b/scripts/lib/zcode-protocol.mjs index 93f9b37..60557b6 100644 --- a/scripts/lib/zcode-protocol.mjs +++ b/scripts/lib/zcode-protocol.mjs @@ -116,13 +116,16 @@ export class ZCodeProtocolClient { /** Wait for the server requests currently tracked for one session. @param {string} sessionId */ async drainServerTasksForSession(sessionId) { if (!nonEmpty(sessionId)) throw protocolInputError(); - const tasks = []; - for (const [controller, taskSessionId] of this.serverTaskSessions) { - if (taskSessionId !== sessionId) continue; - const task = this.serverTasksByController.get(controller); - if (task) tasks.push(task); + for (;;) { + const tasks = []; + for (const [controller, taskSessionId] of this.serverTaskSessions) { + if (taskSessionId !== sessionId) continue; + const task = this.serverTasksByController.get(controller); + if (task) tasks.push(task); + } + if (!tasks.length) return; + await Promise.allSettled(tasks); } - if (tasks.length) await Promise.allSettled(tasks); } /** @param {(message:any)=>void} handler */ diff --git a/scripts/zcode-broker.mjs b/scripts/zcode-broker.mjs index 064a8f1..7dabc2a 100644 --- a/scripts/zcode-broker.mjs +++ b/scripts/zcode-broker.mjs @@ -613,6 +613,7 @@ export class ZCodeBroker { async requestPermission(request) { const activeSession = this.activeSessionSockets.get(request.sessionId); const socket = activeSession?.socket; + if (typeof activeSession?.token === 'string' && activeSession.releasingToken === activeSession.token) return offeredDeny(request); if (!socket?.writable) return offeredDeny(request); const id = this.nextPermissionId++; if (this.permissionPending.size >= 256) return offeredDeny(request); @@ -671,15 +672,22 @@ export class ZCodeBroker { const active = this.activeSessionSockets.get(params.sessionId); if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || active?.socket !== socket || active.baseline !== params.stateRevision || active.inputId !== params.inputId || typeof active.token !== 'string') throw turnReleaseMismatch(); const activeToken = active.token; - this.settleTurnPermissions(params.sessionId, activeToken); - await protocol.drainServerTasksForSession(params.sessionId); - if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId) !== active || active.socket !== socket || active.token !== activeToken || active.baseline !== params.stateRevision || active.inputId !== params.inputId) throw turnReleaseMismatch(); - protocol.releaseTurn(params.sessionId); - if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId) !== active || active.socket !== socket || active.token !== activeToken || active.baseline !== params.stateRevision || active.inputId !== params.inputId) throw turnReleaseMismatch(); - this.activeSessionSockets.delete(params.sessionId); this.activeSessions.delete(params.sessionId); - const key = turnReleaseKey(ownerId, params); this.releasedTurnTombstones.delete(key); this.releasedTurnTombstones.set(key, { socket }); - while (this.releasedTurnTombstones.size > MAX_RELEASED_TURN_TOMBSTONES) this.releasedTurnTombstones.delete(this.releasedTurnTombstones.keys().next().value); - this.scheduleIdleShutdown(); + active.releasingToken = activeToken; + let protocolReleased = false; + try { + this.settleTurnPermissions(params.sessionId, activeToken); + await protocol.drainServerTasksForSession(params.sessionId); + if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId) !== active || active.socket !== socket || active.token !== activeToken || active.baseline !== params.stateRevision || active.inputId !== params.inputId) throw turnReleaseMismatch(); + protocol.releaseTurn(params.sessionId); protocolReleased = true; + if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId) !== active || active.socket !== socket || active.token !== activeToken || active.baseline !== params.stateRevision || active.inputId !== params.inputId) throw turnReleaseMismatch(); + this.activeSessionSockets.delete(params.sessionId); this.activeSessions.delete(params.sessionId); + const key = turnReleaseKey(ownerId, params); this.releasedTurnTombstones.delete(key); this.releasedTurnTombstones.set(key, { socket }); + while (this.releasedTurnTombstones.size > MAX_RELEASED_TURN_TOMBSTONES) this.releasedTurnTombstones.delete(this.releasedTurnTombstones.keys().next().value); + this.scheduleIdleShutdown(); + } catch (error) { + if (!protocolReleased && this.activeSessionSockets.get(params.sessionId) === active && active.releasingToken === activeToken) delete active.releasingToken; + throw error; + } } retirePermissionResponse(id, socket) { diff --git a/tests/process-zcode.test.mjs b/tests/process-zcode.test.mjs index 24c1082..7f02877 100644 --- a/tests/process-zcode.test.mjs +++ b/tests/process-zcode.test.mjs @@ -275,6 +275,20 @@ test('session server-task drain waits through permission handler barriers until protocol.releaseTurn('session-1'); }); +test('session server-task drain reaches a fixed point when a second request arrives mid-drain', async () => { + const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.exitCode = 0; child.signalCode = null; + const protocol = new ZCodeProtocolClient(child); protocol.beginTurn('session-1'); protocol.armTurn('session-1', 1, 'input-1'); + const barriers = []; const entered = []; let handlerCount = 0; + protocol.setPermissionHandler(async () => { const index = handlerCount++; let release; const barrier = new Promise((resolve) => { release = resolve; }); barriers[index] = release; entered[index]?.(); await barrier; return { decision: 'deny' }; }); + const waitForEntry = (index) => new Promise((resolve) => { entered[index] = resolve; }); + const firstEntered = waitForEntry(0); protocol.handleLine(JSON.stringify({ id: 101, method: 'interaction/requestPermission', params: { requestId: 'r1', sessionId: 'session-1', toolCallId: 't1', toolName: 'write', reason: 'test', riskLevel: 'low', input: {}, options: [{ optionId: 'deny', kind: 'deny', name: 'Deny', response: { decision: 'deny' } }] } })); await firstEntered; + let drained = false; const draining = protocol.drainServerTasksForSession('session-1').then(() => { drained = true; }); + const secondEntered = waitForEntry(1); protocol.handleLine(JSON.stringify({ id: 102, method: 'interaction/requestPermission', params: { requestId: 'r2', sessionId: 'session-1', toolCallId: 't2', toolName: 'write', reason: 'test', riskLevel: 'low', input: {}, options: [{ optionId: 'deny', kind: 'deny', name: 'Deny', response: { decision: 'deny' } }] } })); await secondEntered; + barriers[0](); await new Promise((resolve) => setImmediate(resolve)); assert.equal(drained, false, 'a request entering during drain must join the same fixed point'); + barriers[1](); await draining; assert.equal(drained, true); + protocol.releaseTurn('session-1'); +}); + test('broker terminal observer leaves no completion queue or expiry and observes early arm completion', () => { const child = new EventEmitter(); child.stdin = new PassThrough(); child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.exitCode = 0; child.signalCode = null; const protocol = new ZCodeProtocolClient(child); const observed = []; diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index 34750d8..fd48244 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -1540,6 +1540,23 @@ test('socket close during exact release drain cannot commit or create a dead tom broker.cancelIdleShutdown(); await rm(directory, { recursive: true, force: true }); }); +test('exact release fence immediately denies a permission arriving during drain', async () => { + const directory = await mkdtemp(join(tmpdir(), 'zcode-broker-release-permission-fence-')); const ownerId = 'release-permission-fence-owner'; const sessionId = 'release-permission-fence-session'; const writes = []; + const socket = { writable: true, destroyed: false, zcodeWriter: { write: (line) => writes.push(JSON.parse(line)) }, destroy() {} }; + const broker = newTestBroker({ endpoint: join(directory, 'broker.sock'), brokerToken: 'e'.repeat(64), workspace: directory, launch: { command: process.execPath, args: [fixture], target: fixture } }); + broker.authenticated.add(socket); broker.socketOwnerIds.set(socket, ownerId); broker.exactTurnReleaseSockets.add(socket); broker.sessionOwners.set(sessionId, { ownerId, socket }); broker.reloadOwnership = async () => {}; + let releaseDrain; const drainBarrier = new Promise((resolve) => { releaseDrain = resolve; }); let enteredDrain; const drainEntered = new Promise((resolve) => { enteredDrain = resolve; }); + broker.protocol = { drainServerTasksForSession: async () => { enteredDrain(); await drainBarrier; }, releaseTurn: () => {} }; + const active = { socket, token: 'release-permission-fence-token', baseline: 10, inputId: 'release-permission-fence-input' }; broker.activeSessionSockets.set(sessionId, active); broker.activeSessions.add(sessionId); + const releasing = broker.handleLocal(socket, JSON.stringify({ id: 1, method: 'broker/releaseTurn', params: { sessionId, stateRevision: 10, inputId: active.inputId } })); await drainEntered; + const lateRequest = { requestId: 'late-release-request', sessionId, options: [{ response: { decision: 'allow' } }, { response: { decision: 'deny' } }] }; + const latePermission = broker.requestPermission(lateRequest); await Promise.resolve(); + assert.equal(broker.permissionPending.size, 0); + assert.deepEqual(await latePermission, { decision: 'deny' }); + assert.equal(writes.some((frame) => frame.method === 'interaction/requestPermission'), false); + releaseDrain(); await releasing; broker.cancelIdleShutdown(); await rm(directory, { recursive: true, force: true }); +}); + test('managed client retains exact release authority until broker acknowledgement', async (t) => { for (const failureMode of ['pre-commit rejection', 'lost acknowledgement']) await t.test(failureMode, async () => { const sessionId = `release-retry-${failureMode.replaceAll(' ', '-')}`; const turns = new Map(); const calls = []; let releaseAttempts = 0; let brokerRoute = true; let tombstoned = false; @@ -1608,6 +1625,30 @@ test('managed completion remains observable when exact release fails and fences assert.equal(calls.filter((call) => call.method === 'session/send').length, 2); }); +test('managed completion timeout preserves its error while releasing the exact broker turn', async () => { + const sessionId = 'completion-timeout-release-session'; const turns = new Map(); const calls = []; const timeoutError = new PluginError('ZCODE_COMPLETION_TIMEOUT', 'completion timed out'); let sendRevision = 21; + const protocol = { + acceptBrokerControl: true, + request: async (method, params) => { + calls.push({ method, params }); + if (method === 'broker/health') return { ok: true, capabilities: { exactTurnRelease: true } }; + if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: sendRevision++ }; + if (method === 'broker/releaseTurn') return {}; + throw new Error(`unexpected ${method}`); + }, + beginTurn: (id) => { if (turns.has(id)) throw new PluginError('ZCODE_TURN_ACTIVE', 'already active'); turns.set(id, 'sending'); }, + armTurn: (id) => turns.set(id, 'armed'), abortTurn: (id) => turns.delete(id), releaseTurn: (id) => turns.delete(id), + waitForCompletion: async (id) => { turns.delete(id); throw timeoutError; }, turnState: (id) => turns.get(id) ?? null, + }; + const client = new ZCodeClient(protocol, process.cwd(), true); + const first = await client.send(sessionId, 'timed turn'); + assert.equal(await client.waitForCompletion(sessionId).catch((error) => error), timeoutError); + assert.deepEqual(calls.filter((call) => call.method === 'broker/releaseTurn').map((call) => call.params), [{ sessionId, inputId: first.inputId, stateRevision: 21 }]); + assert.equal(client.turnState(sessionId), null); + await client.send(sessionId, 'after timeout cleanup'); + assert.equal(calls.filter((call) => call.method === 'session/send').length, 2); +}); + test('managed client falls back to local release when broker health lacks exact release capability', async () => { const calls = []; const turns = new Map(); const protocol = { acceptBrokerControl: true, request: async (method, params) => { calls.push({ method, params }); if (method === 'broker/health') return { ok: true, capabilities: {} }; if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: 3 }; throw new Error(method); }, beginTurn: (sessionId) => turns.set(sessionId, 'sending'), armTurn: (sessionId) => turns.set(sessionId, 'armed'), abortTurn: (sessionId) => turns.delete(sessionId), releaseTurn: (sessionId) => turns.delete(sessionId), turnState: (sessionId) => turns.get(sessionId) ?? null }; const client = new ZCodeClient(protocol, process.cwd(), true); await client.send('legacy-broker-session', 'hello'); await client.releaseTurn('legacy-broker-session'); From 3fc8811fbdacb020670176310957053631546af1 Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:42:53 +0800 Subject: [PATCH 13/19] fix: preserve completion timeout deadline --- scripts/lib/zcode-client.mjs | 2 +- tests/zcode-client.test.mjs | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/lib/zcode-client.mjs b/scripts/lib/zcode-client.mjs index 9cdd227..07c4eeb 100644 --- a/scripts/lib/zcode-client.mjs +++ b/scripts/lib/zcode-client.mjs @@ -143,7 +143,7 @@ export class ZCodeClient { let completion; try { completion = await this.protocol.waitForCompletion(sessionId, timeoutMs); } catch (error) { - if (this.exactTurnRelease === true && boundary) await this.releaseExactTurn(sessionId, boundary).catch(() => {}); + if (this.exactTurnRelease === true && boundary) void this.releaseExactTurn(sessionId, boundary).catch(() => {}); else this.armedBoundaries.delete(sessionId); throw error; } diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index fd48244..63c3d09 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -1649,6 +1649,28 @@ test('managed completion timeout preserves its error while releasing the exact b assert.equal(calls.filter((call) => call.method === 'session/send').length, 2); }); +test('managed completion timeout is not delayed by a stuck exact release request', async () => { + const sessionId = 'completion-timeout-stuck-release-session'; const turns = new Map(); const calls = []; const timeoutError = new PluginError('ZCODE_COMPLETION_TIMEOUT', 'completion timed out'); + const protocol = { + acceptBrokerControl: true, + request: async (method, params) => { + calls.push({ method, params }); + if (method === 'broker/health') return { ok: true, capabilities: { exactTurnRelease: true } }; + if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: 22 }; + if (method === 'broker/releaseTurn') return new Promise(() => {}); + throw new Error(`unexpected ${method}`); + }, + beginTurn: (id) => { if (turns.has(id)) throw new PluginError('ZCODE_TURN_ACTIVE', 'already active'); turns.set(id, 'sending'); }, + armTurn: (id) => turns.set(id, 'armed'), abortTurn: (id) => turns.delete(id), releaseTurn: (id) => turns.delete(id), + waitForCompletion: async (id) => { turns.delete(id); throw timeoutError; }, turnState: (id) => turns.get(id) ?? null, + }; + const client = new ZCodeClient(protocol, process.cwd(), true); await client.send(sessionId, 'timed turn'); + const observed = await Promise.race([client.waitForCompletion(sessionId).catch((error) => error), new Promise((resolve) => setTimeout(() => resolve('release-delayed-timeout'), 25))]); + assert.equal(observed, timeoutError); + assert.equal(calls.filter((call) => call.method === 'broker/releaseTurn').length, 1); + await assert.rejects(client.send(sessionId, 'must remain fenced'), { code: 'ZCODE_TURN_ACTIVE' }); +}); + test('managed client falls back to local release when broker health lacks exact release capability', async () => { const calls = []; const turns = new Map(); const protocol = { acceptBrokerControl: true, request: async (method, params) => { calls.push({ method, params }); if (method === 'broker/health') return { ok: true, capabilities: {} }; if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: 3 }; throw new Error(method); }, beginTurn: (sessionId) => turns.set(sessionId, 'sending'), armTurn: (sessionId) => turns.set(sessionId, 'armed'), abortTurn: (sessionId) => turns.delete(sessionId), releaseTurn: (sessionId) => turns.delete(sessionId), turnState: (sessionId) => turns.get(sessionId) ?? null }; const client = new ZCodeClient(protocol, process.cwd(), true); await client.send('legacy-broker-session', 'hello'); await client.releaseTurn('legacy-broker-session'); From b766685342f3e79c5db7895b63989465b1dcfc70 Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 11:50:27 +0800 Subject: [PATCH 14/19] build: refresh permission lifecycle marketplace snapshot --- marketplace/.agents/plugins/provenance.json | 22 +++++----- .../plugins/zcode/scripts/lib/review.mjs | 19 ++++++--- .../zcode/scripts/lib/zcode-client.mjs | 39 ++++++++++++++---- .../zcode/scripts/lib/zcode-protocol.mjs | 23 ++++++++++- .../plugins/zcode/scripts/zcode-broker.mjs | 40 ++++++++++++++++--- 5 files changed, 113 insertions(+), 30 deletions(-) diff --git a/marketplace/.agents/plugins/provenance.json b/marketplace/.agents/plugins/provenance.json index c075c01..687d2fc 100644 --- a/marketplace/.agents/plugins/provenance.json +++ b/marketplace/.agents/plugins/provenance.json @@ -1,15 +1,15 @@ { "packageVersion": "0.1.0", "pluginVersion": "0.1.0", - "sourceRef": "ee1fa5c9778cf6dd02e094cf2bfe7f2835c02dab", - "sourceSha": "ee1fa5c9778cf6dd02e094cf2bfe7f2835c02dab", + "sourceRef": "3fc8811fbdacb020670176310957053631546af1", + "sourceSha": "3fc8811fbdacb020670176310957053631546af1", "dependencyLock": { "file": "npm-shrinkwrap.json", "sha256": "fa927194e6ca0b25c1d3f428859b2ab4798b8eabb4d31bc87188d73c630f9938" }, "content": { "algorithm": "sha256", - "sha256": "40d1a65be6976f5f070b2784a35635f5aba4597c4846ec798b86d3f30dd77af7", + "sha256": "9f611f410861e1eeda7674098b6708c46cc73853da95cd6383c2b48e4fee37fd", "files": [ { "path": ".agents/plugins/marketplace.json", @@ -658,8 +658,8 @@ }, { "path": "plugins/zcode/scripts/lib/review.mjs", - "size": 43572, - "sha256": "0d945d223ce618ee28e2e81831ce855df1b18cc975f45dcc5538b8cc6e934d11" + "size": 44075, + "sha256": "8575a666fadd8022373e6c54ea4aabe7e3af708d1d64d002d9da85dc454378c1" }, { "path": "plugins/zcode/scripts/lib/session-progress.mjs", @@ -703,8 +703,8 @@ }, { "path": "plugins/zcode/scripts/lib/zcode-client.mjs", - "size": 52495, - "sha256": "0d4c854d4045260cc86d14d30e647db22efb08bd4f9ad30a2f795c3b5e37f901" + "size": 55125, + "sha256": "938d7f5928a44c94faa10a99336d818f30868d56f51a814c98000f54bc2a507b" }, { "path": "plugins/zcode/scripts/lib/zcode-discovery.mjs", @@ -713,8 +713,8 @@ }, { "path": "plugins/zcode/scripts/lib/zcode-protocol.mjs", - "size": 42018, - "sha256": "68e98ef8be8b670648e3add7966fb1619b3836be0ce6b2a29e678899f68f9426" + "size": 43295, + "sha256": "d01d37715518b956ed6ff6b2d0f1fafadc51c9c4998ce3617f14ef64230ba409" }, { "path": "plugins/zcode/scripts/lib/zcode-runtime-config.mjs", @@ -728,8 +728,8 @@ }, { "path": "plugins/zcode/scripts/zcode-broker.mjs", - "size": 103199, - "sha256": "3861c818720c569ac483c1b6488d0165e039f392d1ab9a5186e2175a71008a08" + "size": 107670, + "sha256": "c85c04fb48bacae4ca41d937ec9de0905d25fdb7f99bd76e4c122a876bd673ba" }, { "path": "plugins/zcode/scripts/zcode-companion.mjs", diff --git a/marketplace/plugins/zcode/scripts/lib/review.mjs b/marketplace/plugins/zcode/scripts/lib/review.mjs index 6683b18..a1ddde6 100644 --- a/marketplace/plugins/zcode/scripts/lib/review.mjs +++ b/marketplace/plugins/zcode/scripts/lib/review.mjs @@ -342,12 +342,21 @@ export async function executeJob(input) { } // Cleanup order is part of the progress lifecycle contract. await cleanupProgress(); - try { - if (sessionId && typeof client.releaseTurn === 'function') client.releaseTurn(sessionId); - } catch (cleanupError) { - if (!primaryError && output?.job?.status !== 'succeeded') primaryError = cleanupError; + let releaseError; + if (sessionId && typeof client.releaseTurn === 'function') { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { await client.releaseTurn(sessionId); releaseError = undefined; break; } + catch (cleanupError) { releaseError = cleanupError; } + } + } + if (releaseError) { + await jobLog?.appendBlock('Cleanup diagnostic', 'ZCode turn release cleanup was incomplete.', Date.now() + OPTIONAL_PROGRESS_FENCE_MS).catch(() => {}); + if (!primaryError && output?.job?.status !== 'succeeded') primaryError = releaseError; + } + try { await client.close(); } + catch { + await jobLog?.appendBlock('Cleanup diagnostic', 'ZCode client close cleanup was incomplete.', Date.now() + OPTIONAL_PROGRESS_FENCE_MS).catch(() => {}); } - await client.close().catch(() => {}); if (!primaryError && appliedFinalization && output?.job?.status === 'succeeded' && typeof output.result === 'string') { await jobLog?.appendBlock('Final output', output.result, Date.now() + OPTIONAL_PROGRESS_FENCE_MS); } diff --git a/marketplace/plugins/zcode/scripts/lib/zcode-client.mjs b/marketplace/plugins/zcode/scripts/lib/zcode-client.mjs index 3f9c717..07c4eeb 100644 --- a/marketplace/plugins/zcode/scripts/lib/zcode-client.mjs +++ b/marketplace/plugins/zcode/scripts/lib/zcode-client.mjs @@ -30,8 +30,14 @@ const OWNER_CLEANUP_LEGACY_BATCH_SIZE = 8; export const IMPORTED_HISTORY_SOURCE = 'claudeCode'; export class ZCodeClient { - /** @param {import('./zcode-protocol.mjs').ZCodeProtocolClient} protocol @param {string} [workspace] @param {boolean} [workspaceBound] */ - constructor(protocol, workspace, workspaceBound = false) { this.protocol = protocol; this.defaultWorkspace = workspace === undefined ? null : resolve(workspace); this.workspaceBound = workspaceBound; this.sessionCatalogs = new Map(); this.sessionWorkspaces = new Map(); this.initialEmptySessions = new Set(); } + /** @param {import('./zcode-protocol.mjs').ZCodeProtocolClient} protocol @param {string} [workspace] @param {boolean} [workspaceBound] @param {boolean} [advertiseExactTurnRelease] */ + constructor(protocol, workspace, workspaceBound = false, advertiseExactTurnRelease = true) { + this.protocol = protocol; this.defaultWorkspace = workspace === undefined ? null : resolve(workspace); this.workspaceBound = workspaceBound; + this.sessionCatalogs = new Map(); this.sessionWorkspaces = new Map(); this.initialEmptySessions = new Set(); this.exactTurnRelease = workspaceBound && advertiseExactTurnRelease ? null : false; + /** @type {Promise|null} */ + this.exactTurnReleaseProbe = null; + this.armedBoundaries = new Map(); + } /** @param {{workspace:string,sessionId?:string,model?:{providerId:string,modelId:string,variant?:string},importedHistory?:{title?:string,createdAt?:number,updatedAt?:number,messages:Array<{role:'user'|'assistant',content:string,timestamp?:number}>}}} input */ async createSession(input) { @@ -73,6 +79,8 @@ export class ZCodeClient { /** @param {string} sessionId @param {string} content @param {Record} [options] */ async send(sessionId, content, options = {}) { requireSessionId(sessionId); if (typeof content !== 'string') throw inputError(); requireExactObject(options, [], []); + await this.ensureExactTurnReleaseCapability(); + if (this.exactTurnRelease === true && this.armedBoundaries.has(sessionId)) throw new PluginError('ZCODE_TURN_ACTIVE', 'A turn is already active for this session.', { category: 'state', remedy: 'Wait for the active turn to finish.' }); this.initialEmptySessions.delete(sessionId); this.protocol.beginTurn(sessionId); const inputId = randomUUID(); @@ -80,14 +88,15 @@ export class ZCodeClient { try { result = await this.protocol.request('session/send', { sessionId, inputId, queryId: inputId, content }); } catch (error) { this.protocol.abortTurn(sessionId); throw error; } if (!plainObject(result) || result.accepted !== true || result.sessionId !== sessionId || !Number.isSafeInteger(result.stateRevision) || result.stateRevision < 0 || result.modelRuntimeRevision !== undefined && !nonEmpty(result.modelRuntimeRevision)) { this.protocol.abortTurn(sessionId); throw outputError('session/send'); } this.protocol.armTurn(sessionId, result.stateRevision, inputId); + this.armedBoundaries.set(sessionId, { stateRevision: result.stateRevision, inputId }); return { ...result, inputId }; } /** @param {string} sessionId */ async readSession(sessionId) { requireSessionId(sessionId); const result = await this.protocol.request('session/read', { sessionId }); validateSnapshot(result, sessionId, this.expectedWorkspace(sessionId), 'session/read'); this.sessionCatalogs.set(sessionId, result.settings.model); return result; } /** @param {string} sessionId */ async resumeSession(sessionId) { requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); const result = await this.protocol.request('session/resume', { sessionId }); validateSnapshot(result, sessionId, this.expectedWorkspace(sessionId), 'session/resume'); this.sessionCatalogs.set(sessionId, result.settings.model); this.sessionWorkspaces.set(sessionId, result.session.workspace.workspacePath); return result; } /** @param {number} [timeoutMs] */ async listSessions(timeoutMs) { const result = requireObjectResult(await this.protocol.request('session/list', {}, timeoutMs), 'session/list'); if (!Array.isArray(result.sessions) || !result.sessions.every(validSessionInfo)) throw outputError('session/list'); return result; } - /** @param {string} sessionId @param {number} [timeoutMs] */ async stopSession(sessionId, timeoutMs) { requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); const result = await this.protocol.request('session/stop', { sessionId }, timeoutMs); if (!boundedUpstreamObject(result)) throw outputError('session/stop'); if (!this.protocol.acceptBrokerControl) this.protocol.cancelTurn(sessionId); return {}; } - /** @param {number} [timeoutMs] */ async brokerCapabilities(timeoutMs) { const result = await requestBrokerHealth(this, timeoutMs); return { releaseOwnerExclusions: result.capabilities?.releaseOwnerExclusions === true }; } + /** @param {string} sessionId @param {number} [timeoutMs] */ async stopSession(sessionId, timeoutMs) { requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); const result = await this.protocol.request('session/stop', { sessionId }, timeoutMs); if (!boundedUpstreamObject(result)) throw outputError('session/stop'); if (!this.protocol.acceptBrokerControl) this.protocol.cancelTurn(sessionId); this.armedBoundaries.delete(sessionId); return {}; } + /** @param {number} [timeoutMs] */ async brokerCapabilities(timeoutMs) { const result = await requestBrokerHealth(this, timeoutMs, true); this.exactTurnRelease = result.capabilities?.exactTurnRelease === true; return { releaseOwnerExclusions: result.capabilities?.releaseOwnerExclusions === true }; } /** @param {string[]} [excludeSessionIds] @param {number} [timeoutMs] */ async releaseOwner(excludeSessionIds, timeoutMs) { if (excludeSessionIds !== undefined && (!Array.isArray(excludeSessionIds) || excludeSessionIds.length > 1_000 || new Set(excludeSessionIds).size !== excludeSessionIds.length || !excludeSessionIds.every((sessionId) => isSafeIdentifier(sessionId)))) throw inputError(); const result = await this.protocol.request('broker/releaseOwner', excludeSessionIds === undefined ? {} : { excludeSessionIds }, timeoutMs); if (!plainObject(result) || !Array.isArray(result.releasedSessionIds) || !Array.isArray(result.failedSessionIds) || !result.releasedSessionIds.every((sessionId) => isSafeIdentifier(sessionId)) || !result.failedSessionIds.every((sessionId) => isSafeIdentifier(sessionId)) || !Number.isSafeInteger(result.deferredSessionCount) || result.deferredSessionCount < 0) throw outputError('broker/releaseOwner'); return result; } @@ -128,9 +137,25 @@ export class ZCodeClient { this.sessionCatalogs.set(sessionId, result.settings.model); return result; } - /** Wait for a validated terminal notification; no deadline applies unless configured on the client or supplied here. @param {string} sessionId @param {number} [timeoutMs] */ waitForCompletion(sessionId, timeoutMs) { return this.protocol.waitForCompletion(sessionId, timeoutMs); } + /** Wait for a validated terminal notification; no deadline applies unless configured on the client or supplied here. @param {string} sessionId @param {number} [timeoutMs] */ + async waitForCompletion(sessionId, timeoutMs) { + const boundary = this.armedBoundaries.get(sessionId); + let completion; + try { completion = await this.protocol.waitForCompletion(sessionId, timeoutMs); } + catch (error) { + if (this.exactTurnRelease === true && boundary) void this.releaseExactTurn(sessionId, boundary).catch(() => {}); + else this.armedBoundaries.delete(sessionId); + throw error; + } + if (this.exactTurnRelease === true && boundary) await this.releaseExactTurn(sessionId, boundary).catch(() => {}); + else this.armedBoundaries.delete(sessionId); + return completion; + } /** Observe a validated terminal notification without consuming the active turn. @param {string} sessionId @param {number} [timeoutMs] */ observeCompletion(sessionId, timeoutMs) { return this.protocol.observeCompletion(sessionId, timeoutMs); } - /** Locally release an active turn without sending an upstream request. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); this.protocol.releaseTurn(sessionId); } + /** Release the exact managed broker turn, then clear local turn state after acknowledgement. Direct clients clear synchronously. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); const boundary = this.armedBoundaries.get(sessionId); if (!this.workspaceBound || this.exactTurnRelease !== true || !boundary) { this.protocol.releaseTurn(sessionId); this.armedBoundaries.delete(sessionId); return Promise.resolve(); } return this.releaseExactTurn(sessionId, boundary); } + /** @param {string} sessionId @param {{stateRevision:number,inputId:string}} boundary */ + async releaseExactTurn(sessionId, boundary) { const result = await this.protocol.request('broker/releaseTurn', { sessionId, inputId: boundary.inputId, stateRevision: boundary.stateRevision }); if (!plainObject(result) || Object.keys(result).length !== 0) throw outputError('broker/releaseTurn'); this.protocol.releaseTurn(sessionId); if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); } + async ensureExactTurnReleaseCapability() { if (!this.workspaceBound || this.exactTurnRelease !== null) return; this.exactTurnReleaseProbe ??= requestBrokerHealth(this, undefined, true).then((result) => { this.exactTurnRelease = result.capabilities?.exactTurnRelease === true; }, (error) => { this.exactTurnReleaseProbe = null; throw error; }); await this.exactTurnReleaseProbe; } /** Exact local protocol invariant used to prove whether this client owns an active turn. @param {string} sessionId */ turnState(sessionId) { requireSessionId(sessionId); return this.protocol.turnState(sessionId); } /** @param {string} sessionId @param {{connectionId:string,clientMode:'desktop-continuous'|'web-remote-replayable'}} options */ async subscribeConversation(sessionId, options) { @@ -278,7 +303,7 @@ async function releaseOwnerWithRetry(client, excludeSessionIds, deadline, reques async function verifyBrokerIdentity(client, identity, deadline, requestTimeoutMs) { const health = await requestBrokerHealth(client, boundedCleanupTimeout(deadline, requestTimeoutMs)); if (!Number.isSafeInteger(health.pid) || health.pid <= 1 || !isSafeIdentifier(health.instanceId) || health.pid !== identity.pid || health.instanceId !== identity.instanceId) throw outputError('broker/health'); return { releaseOwnerExclusions: health.capabilities?.releaseOwnerExclusions === true }; } /** @param {ZCodeClient} client @param {number|undefined} timeoutMs */ -async function requestBrokerHealth(client, timeoutMs) { const result = await client.protocol.request('broker/health', {}, timeoutMs); if (!plainObject(result) || result.ok !== true) throw outputError('broker/health'); return result; } +async function requestBrokerHealth(client, timeoutMs, advertiseExactTurnRelease = true) { const result = await client.protocol.request('broker/health', advertiseExactTurnRelease ? { clientCapabilities: { exactTurnRelease: true } } : {}, timeoutMs); if (!plainObject(result) || result.ok !== true) throw outputError('broker/health'); return result; } /** @param {unknown[]} errors */ function boundedCauseCodeCounts(errors) { const counts = /** @type {Record} */ ({}); for (const error of errors.slice(0, 32)) { const candidate = (/** @type {{code?:unknown}} */ (error))?.code; const code = typeof candidate === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(candidate) ? candidate : 'UNKNOWN'; counts[code] = (counts[code] ?? 0) + 1; } return counts; } diff --git a/marketplace/plugins/zcode/scripts/lib/zcode-protocol.mjs b/marketplace/plugins/zcode/scripts/lib/zcode-protocol.mjs index 186ec62..60557b6 100644 --- a/marketplace/plugins/zcode/scripts/lib/zcode-protocol.mjs +++ b/marketplace/plugins/zcode/scripts/lib/zcode-protocol.mjs @@ -48,6 +48,7 @@ export class ZCodeProtocolClient { this.closeHandler = null; this.terminalHandler = null; this.consumeTerminal = false; + this.terminalObserver = null; this.acceptBrokerControl = options.acceptBrokerControl === true; this.waiterSessions = new Set(); this.permissionRequestIds = new Map(); @@ -92,6 +93,8 @@ export class ZCodeProtocolClient { setSubscriberErrorHandler(handler) { if (typeof handler !== 'function') throw protocolInputError(); this.subscriberErrorHandler = handler; } /** Broker-only terminal hook. Validated terminal notifications are consumed before this callback. @param {(params:any,turn:{status:'armed',baseline:number,inputId:string})=>void} handler */ consumeTerminalsWith(handler) { if (typeof handler !== 'function') throw protocolInputError(); this.terminalHandler = handler; this.consumeTerminal = true; } + /** Broker-only non-destructive terminal hook. The terminal is not queued or expired. @param {(params:any,turn:{status:'armed',baseline:number,inputId:string})=>void} handler */ + observeTerminalsWith(handler) { if (typeof handler !== 'function') throw protocolInputError(); this.terminalObserver = handler; } /** @param {(error:PluginError)=>void} handler */ setCloseHandler(handler) { if (typeof handler !== 'function') throw protocolInputError(); this.closeHandler = handler; } /** @param {string} sessionId */ @@ -110,6 +113,21 @@ export class ZCodeProtocolClient { /** Locally release a turn without sending an upstream request. @param {string} sessionId */ releaseTurn(sessionId) { if (!nonEmpty(sessionId)) throw protocolInputError(); this.cancelTurn(sessionId); } + /** Wait for the server requests currently tracked for one session. @param {string} sessionId */ + async drainServerTasksForSession(sessionId) { + if (!nonEmpty(sessionId)) throw protocolInputError(); + for (;;) { + const tasks = []; + for (const [controller, taskSessionId] of this.serverTaskSessions) { + if (taskSessionId !== sessionId) continue; + const task = this.serverTasksByController.get(controller); + if (task) tasks.push(task); + } + if (!tasks.length) return; + await Promise.allSettled(tasks); + } + } + /** @param {(message:any)=>void} handler */ subscribe(handler) { if (typeof handler !== 'function' || this.subscribers.size >= 256) throw protocolInputError(); this.subscribers.add(handler); return () => this.subscribers.delete(handler); } @@ -320,7 +338,7 @@ export class ZCodeProtocolClient { rejectCompletionWaiters(error) { for (const waiter of this.completionWaiters) { if (waiter.timer) clearTimeout(waiter.timer); waiter.unsubscribe(); this.waiterSessions.delete(waiter.sessionId); waiter.reject(error); } this.completionWaiters.clear(); } /** @param {string} sessionId @param {any} params */ - queueCompletion(sessionId, params) { if (this.consumeTerminal) { const turn = this.turns.get(sessionId); this.abortTurn(sessionId); if (turn?.status === 'armed' && typeof turn.baseline === 'number' && typeof turn.inputId === 'string') this.terminalHandler?.(params, { status: 'armed', baseline: turn.baseline, inputId: turn.inputId }); return; } if (!this.completed.has(sessionId) && this.completed.size >= 1024) { this.fail(new PluginError('ZCODE_COMPLETION_OVERFLOW', 'Too many unconsumed completions were received.', { category: 'protocol', remedy: 'Restart the connection and consume completions promptly.' })); return; } const queue = this.completed.get(sessionId) ?? []; queue.splice(0, queue.length, params); this.completed.set(sessionId, queue); clearTimeout(this.completionExpiry.get(sessionId)); const expiry = setTimeout(() => this.cancelTurn(sessionId), 10 * 60_000); expiry.unref?.(); this.completionExpiry.set(sessionId, expiry); } + queueCompletion(sessionId, params) { const turn = this.turns.get(sessionId); if (this.consumeTerminal) { this.abortTurn(sessionId); if (turn?.status === 'armed' && typeof turn.baseline === 'number' && typeof turn.inputId === 'string') this.terminalHandler?.(params, { status: 'armed', baseline: turn.baseline, inputId: turn.inputId }); return; } if (this.terminalObserver) { if (turn?.status === 'armed' && typeof turn.baseline === 'number' && typeof turn.inputId === 'string') this.terminalObserver(params, { status: 'armed', baseline: turn.baseline, inputId: turn.inputId }); return; } if (!this.completed.has(sessionId) && this.completed.size >= 1024) { this.fail(new PluginError('ZCODE_COMPLETION_OVERFLOW', 'Too many unconsumed completions were received.', { category: 'protocol', remedy: 'Restart the connection and consume completions promptly.' })); return; } const queue = this.completed.get(sessionId) ?? []; queue.splice(0, queue.length, params); this.completed.set(sessionId, queue); clearTimeout(this.completionExpiry.get(sessionId)); const expiry = setTimeout(() => this.cancelTurn(sessionId), 10 * 60_000); expiry.unref?.(); this.completionExpiry.set(sessionId, expiry); } } export class BoundedWriter { @@ -466,12 +484,13 @@ function redactSecrets(value) { /** @param {Record} value */ function validatePermissionRequest(value) { const required = ['requestId', 'sessionId', 'toolCallId', 'toolName', 'reason', 'riskLevel', 'input', 'options']; - const allowed = [...required, 'turnId', 'origin']; + const allowed = [...required, 'turnId', 'origin', 'requestedAt']; if (required.some((key) => !Object.hasOwn(value, key)) || Object.keys(value).some((key) => !allowed.includes(key)) || !required.slice(0, 5).every((key) => nonEmpty(value[key])) || !['low', 'medium', 'high', 'critical'].includes(value.riskLevel) || !Array.isArray(value.options) || value.options.length === 0 || !value.options.every(validPermissionOption) || value.turnId !== undefined && !nonEmpty(value.turnId) + || value.requestedAt !== undefined && (!Number.isSafeInteger(value.requestedAt) || value.requestedAt < 0) || value.origin !== undefined && !validPermissionOrigin(value.origin)) throw malformedFrame(); } /** @param {unknown} value */ diff --git a/marketplace/plugins/zcode/scripts/zcode-broker.mjs b/marketplace/plugins/zcode/scripts/zcode-broker.mjs index a071d2d..7dabc2a 100644 --- a/marketplace/plugins/zcode/scripts/zcode-broker.mjs +++ b/marketplace/plugins/zcode/scripts/zcode-broker.mjs @@ -31,12 +31,13 @@ const MAX_CONVERSATION_FRAME_BYTES = 64 * 1024; const MAX_OWNER_OPERATION_LEASES = 256; const MAX_CONCURRENT_OWNER_RELEASES = 16; const MAX_TERMINAL_WINNER_EVIDENCE = 256; +const MAX_RELEASED_TURN_TOMBSTONES = 256; const RAW_ENDPOINT_PROBE_MS = 100; export const MIN_BROKER_IDLE_TIMEOUT_MS = 1_000; export const MAX_BROKER_IDLE_TIMEOUT_MS = 3_600_000; -const LOCAL_BROKER_METHODS = new Set(['session/create', 'session/send', 'session/read', 'session/resume', 'session/list', 'session/stop', 'session/setModel', 'session/updateRuntimeModelConfig', 'session/setThoughtLevel', 'v4/conversation/subscribe', 'v4/conversation/unsubscribe', 'broker/health', 'broker/releaseOwner']); +const LOCAL_BROKER_METHODS = new Set(['session/create', 'session/send', 'session/read', 'session/resume', 'session/list', 'session/stop', 'session/setModel', 'session/updateRuntimeModelConfig', 'session/setThoughtLevel', 'v4/conversation/subscribe', 'v4/conversation/unsubscribe', 'broker/health', 'broker/releaseOwner', 'broker/releaseTurn']); const OWNER_SCOPED_SESSION_METHODS = new Set(['session/read', 'session/resume', 'session/setModel', 'session/updateRuntimeModelConfig', 'session/setThoughtLevel']); -const EXCLUSIVE_SESSION_METHODS = new Set(['session/create', 'session/send', 'session/stop', 'v4/conversation/subscribe', 'v4/conversation/unsubscribe', 'broker/releaseSession']); +const EXCLUSIVE_SESSION_METHODS = new Set(['session/create', 'session/send', 'session/stop', 'v4/conversation/subscribe', 'v4/conversation/unsubscribe', 'broker/releaseSession', 'broker/releaseTurn']); // One admission authority owns every transient broker fence. Durable ownership // remains in sessionOwners, so a store reload cannot erase in-flight claims. @@ -239,7 +240,7 @@ export async function ensureZCodeBroker(options) { export class ZCodeBroker { /** @param {{endpoint:string,ownershipPath?:string,brokerToken:string,launch:{command:string,args:string[],target?:string},workspace:string,launchCwd?:string,env?:NodeJS.ProcessEnv,idleTimeoutMs?:number,maxFrameBytes?:number,maxOutboundBytes?:number,drainTimeoutMs?:number,instanceId?:string,identityPath?:string,publishIdentityAfterListen?:boolean}} options */ - constructor(options) { if (typeof options?.brokerToken !== 'string' || options.brokerToken.length < 32 || !validIdleTimeoutOption(options?.idleTimeoutMs) || !validWireOption(options?.maxFrameBytes, 16 * 1024 * 1024) || !validWireOption(options?.maxOutboundBytes, 64 * 1024 * 1024) || !validDrainOption(options?.drainTimeoutMs) || isWindowsNamedPipe(options?.endpoint) && (typeof options?.ownershipPath !== 'string' || !options.ownershipPath)) throw brokerInputError(); let workspace; try { workspace = realpathSync.native(resolve(options.workspace)); } catch { throw brokerInputError(); } this.options = { ...options, workspace }; this.ownershipPath = options.ownershipPath ?? `${options.endpoint}.owners.json`; this.ownershipStoreEstablished = false; this.ownershipRevision = 0; this.uncertainOwnerReleases = new Map(); this.ownerCommitTokens = new Map(); this.server = null; this.protocol = null; this.protocolPromise = null; this.retiredProtocolGeneration = null; this.sockets = new Set(); this.socketWriters = new WeakMap(); this.authenticated = new WeakSet(); this.existingProtocolOnlySockets = new WeakSet(); this.socketOwnerIds = new WeakMap(); this.sessionOwners = new Map(); this.admission = new BrokerAdmission((sessionId) => this.sessionOwners.get(sessionId)?.ownerId, () => this.scheduleIdleShutdown()); this.activeSessionSockets = new Map(); this.terminalWinnerEvidence = new Map(); this.admittingSessions = new Map(); this.stoppingSessions = new Map(); this.conversationSubscriptions = new Map(); this.orphanedConversationSubscriptions = new Map(); this.conversationSubscriptionGeneration = null; this.orphanRetryPromise = null; this.pendingConversationTopics = new Map(); this.permissionPending = new Map(); this.retiredPermissionResponses = new Map(); this.localTasks = new Set(); this.releaseTasks = new Set(); this.nextPermissionId = 1_000_000_000; this.owners = 0; this.activeSessions = new Set(); this.fastIdleRequested = false; this.idleTimer = null; this.closing = false; this.closePromise = null; } + constructor(options) { if (typeof options?.brokerToken !== 'string' || options.brokerToken.length < 32 || !validIdleTimeoutOption(options?.idleTimeoutMs) || !validWireOption(options?.maxFrameBytes, 16 * 1024 * 1024) || !validWireOption(options?.maxOutboundBytes, 64 * 1024 * 1024) || !validDrainOption(options?.drainTimeoutMs) || isWindowsNamedPipe(options?.endpoint) && (typeof options?.ownershipPath !== 'string' || !options.ownershipPath)) throw brokerInputError(); let workspace; try { workspace = realpathSync.native(resolve(options.workspace)); } catch { throw brokerInputError(); } this.options = { ...options, workspace }; this.ownershipPath = options.ownershipPath ?? `${options.endpoint}.owners.json`; this.ownershipStoreEstablished = false; this.ownershipRevision = 0; this.uncertainOwnerReleases = new Map(); this.ownerCommitTokens = new Map(); this.server = null; this.protocol = null; this.protocolPromise = null; this.retiredProtocolGeneration = null; this.sockets = new Set(); this.socketWriters = new WeakMap(); this.authenticated = new WeakSet(); this.existingProtocolOnlySockets = new WeakSet(); this.exactTurnReleaseSockets = new WeakSet(); this.socketOwnerIds = new WeakMap(); this.sessionOwners = new Map(); this.admission = new BrokerAdmission((sessionId) => this.sessionOwners.get(sessionId)?.ownerId, () => this.scheduleIdleShutdown()); this.activeSessionSockets = new Map(); this.releasedTurnTombstones = new Map(); this.terminalWinnerEvidence = new Map(); this.admittingSessions = new Map(); this.stoppingSessions = new Map(); this.conversationSubscriptions = new Map(); this.orphanedConversationSubscriptions = new Map(); this.conversationSubscriptionGeneration = null; this.orphanRetryPromise = null; this.pendingConversationTopics = new Map(); this.permissionPending = new Map(); this.retiredPermissionResponses = new Map(); this.localTasks = new Set(); this.releaseTasks = new Set(); this.nextPermissionId = 1_000_000_000; this.owners = 0; this.activeSessions = new Set(); this.fastIdleRequested = false; this.idleTimer = null; this.closing = false; this.closePromise = null; } async start() { if (this.server) return this; @@ -280,6 +281,7 @@ export class ZCodeBroker { clearTimeout(authTimer); this.socketWriters.get(socket)?.close(); this.sockets.delete(socket); this.existingProtocolOnlySockets.delete(socket); for (const [id, pending] of this.permissionPending) if (pending.socket === socket) { clearTimeout(pending.timer); this.permissionPending.delete(id); pending.resolve(offeredDeny(pending.request)); } for (const [id, retired] of this.retiredPermissionResponses) if (retired.socket === socket) this.retiredPermissionResponses.delete(id); + for (const [key, tombstone] of this.releasedTurnTombstones) if (tombstone.socket === socket) this.releasedTurnTombstones.delete(key); for (const owner of this.sessionOwners.values()) if (owner.socket === socket) owner.socket = null; for (const active of this.activeSessionSockets.values()) if (active.socket === socket) active.socket = null; const cleanup = this.cleanupSocketSubscriptions(socket); this.localTasks.add(cleanup); void cleanup.finally(() => { this.localTasks.delete(cleanup); this.scheduleIdleShutdown(); }); @@ -312,13 +314,14 @@ export class ZCodeBroker { } if (!frame || !Number.isSafeInteger(frame.id) || typeof frame.method !== 'string' || !frame.params || typeof frame.params !== 'object') { socket.destroy(); return; } if (!LOCAL_BROKER_METHODS.has(frame.method)) { writeRequestError(socket, frame.id, brokerInputError()); return; } - if (frame.method === 'broker/health') { writeLocal(socket, { id: frame.id, result: { ok: this.retiredProtocolGeneration === null, pid: process.pid, instanceId: this.options.instanceId, capabilities: { releaseOwnerExclusions: true } } }); return; } + if (frame.method === 'broker/health') { const capabilities = frame.params.clientCapabilities; if (Object.keys(frame.params).some((key) => key !== 'clientCapabilities') || capabilities !== undefined && (!capabilities || typeof capabilities !== 'object' || Object.keys(capabilities).some((key) => key !== 'exactTurnRelease') || capabilities.exactTurnRelease !== true)) { writeRequestError(socket, frame.id, brokerInputError()); return; } if (capabilities?.exactTurnRelease === true) this.exactTurnReleaseSockets.add(socket); writeLocal(socket, { id: frame.id, result: { ok: this.retiredProtocolGeneration === null, pid: process.pid, instanceId: this.options.instanceId, capabilities: { releaseOwnerExclusions: true, exactTurnRelease: true } } }); return; } if (frame.method === 'session/create' && !validCreateWorkspace(frame.params.workspace, this.options.workspace)) { writeRequestError(socket, frame.id, brokerInputError()); return; } const releaseDeadline = frame.method === 'broker/releaseOwner' ? Date.now() + OWNER_RELEASE_BUDGET_MS : undefined; let releaseExcluded; if (frame.method === 'broker/releaseOwner') { try { releaseExcluded = frame.params.excludeSessionIds ?? []; if (Object.keys(frame.params).some((key) => key !== 'excludeSessionIds') || !Array.isArray(releaseExcluded) || releaseExcluded.length > 1_000 || new Set(releaseExcluded).size !== releaseExcluded.length || !releaseExcluded.every((sessionId) => isSafeIdentifier(sessionId))) throw brokerInputError(); } catch (error) { writeRequestError(socket, frame.id, error); return; } } + if (frame.method === 'broker/releaseTurn' && (!this.exactTurnReleaseSockets.has(socket) || Object.keys(frame.params).length !== 3 || !isSafeIdentifier(frame.params.sessionId) || !isSafeIdentifier(frame.params.inputId) || !Number.isSafeInteger(frame.params.stateRevision) || frame.params.stateRevision < 0)) { writeRequestError(socket, frame.id, brokerInputError()); return; } const conversationSessionId = frame.method === 'broker/releaseOwner' ? null : sessionIdFromConversationRequest(frame); if (conversationSessionId === false) { writeRequestError(socket, frame.id, brokerInputError()); return; } const requestedSessionId = conversationSessionId ?? frame.params.sessionId; @@ -344,6 +347,7 @@ export class ZCodeBroker { try { if (!ownershipReloaded) await this.reloadOwnership(); } catch (error) { writeRequestError(socket, frame.id, error); return; } const existingOwner = typeof requestedSessionId === 'string' ? this.sessionOwners.get(requestedSessionId) : null; if (existingOwner && existingOwner.ownerId !== ownerId || typeof requestedSessionId === 'string' && !existingOwner && !claimMethod) { writeSessionOwnerDenied(socket, frame.id); return; } + if (frame.method === 'broker/releaseTurn' && !this.activeSessionSockets.has(frame.params.sessionId)) { const tombstone = this.releasedTurnTombstones.get(turnReleaseKey(ownerId, frame.params)); if (tombstone?.socket === socket) { writeLocal(socket, { id: frame.id, result: {} }); return; } writeRequestError(socket, frame.id, turnReleaseMismatch()); return; } let subscriptionToken; let stopToken; let stoppedGeneration; let ownerCommitToken; let unsubscribeRecord; let protocol; if (frame.method === 'session/send') { sendToken = sessionAdmission.token; this.admittingSessions.set(frame.params.sessionId, sendToken); } if (frame.method === 'session/stop') { stoppedGeneration = this.activeSessionSockets.get(frame.params.sessionId); stopToken = sessionAdmission.token; this.stoppingSessions.set(frame.params.sessionId, { token: stopToken, activeToken: stoppedGeneration?.token ?? null }); } @@ -351,6 +355,7 @@ export class ZCodeBroker { if (this.existingProtocolOnlySockets.has(socket)) { if (!this.protocol) throw existingProtocolUnavailable(); protocol = this.protocol; } else protocol = await this.getProtocol(); if (sessionAdmission) this.admission.bindSessionProtocol(sessionAdmission, protocol); + if (frame.method === 'broker/releaseTurn') { await this.releaseExactTurn(socket, ownerId, frame.params, protocol, sessionAdmission); writeLocal(socket, { id: frame.id, result: {} }); return; } if (frame.method === 'session/create') { if (!this.admission.ownerRequestCurrent(ownerAdmission)) throw brokerInputError(); ownerCommitToken = randomBytes(16).toString('hex'); this.ownerCommitTokens.set(ownerCommitToken, protocol); } if (frame.method === 'session/send') { if (this.admittingSessions.get(frame.params.sessionId) !== sendToken) throw brokerInputError(); this.terminalWinnerEvidence.delete(frame.params.sessionId); protocol.beginTurn(frame.params.sessionId); this.activeSessionSockets.set(frame.params.sessionId, { socket, token: sendToken }); } if (frame.method === 'v4/conversation/subscribe') { @@ -562,7 +567,7 @@ export class ZCodeBroker { if (message.params?.sessionId && sessionOwner) writeLocal(sessionOwner, message); }); protocol.setPermissionHandler((request) => this.requestPermission(request)); - protocol.consumeTerminalsWith((params, turn) => { const active = this.activeSessionSockets.get(params.sessionId); if (active?.baseline === turn.baseline && active.inputId === turn.inputId) { this.recordTerminalWinner(params.sessionId, protocol, active); if (active.socket?.writable) writeLocal(active.socket, { method: 'state.updated', params }); this.settleTurnPermissions(params.sessionId, active.token); this.activeSessionSockets.delete(params.sessionId); this.activeSessions.delete(params.sessionId); this.scheduleIdleShutdown(); } }); + protocol.observeTerminalsWith((params, turn) => { const active = this.activeSessionSockets.get(params.sessionId); if (active?.baseline !== turn.baseline || active.inputId !== turn.inputId) return; if (active.socket?.writable) writeLocal(active.socket, { method: 'state.updated', params }); if (active.socket && this.exactTurnReleaseSockets.has(active.socket)) return; protocol.releaseTurn(params.sessionId); this.recordTerminalWinner(params.sessionId, protocol, active); this.settleTurnPermissions(params.sessionId, active.token); this.activeSessionSockets.delete(params.sessionId); this.activeSessions.delete(params.sessionId); this.scheduleIdleShutdown(); }); protocol.setCloseHandler(() => this.clearProtocolGeneration(protocol)); return protocol; } catch (error) { if (this.protocol === protocol) this.protocol = null; await protocol.close().catch(() => {}); throw error; } @@ -608,6 +613,7 @@ export class ZCodeBroker { async requestPermission(request) { const activeSession = this.activeSessionSockets.get(request.sessionId); const socket = activeSession?.socket; + if (typeof activeSession?.token === 'string' && activeSession.releasingToken === activeSession.token) return offeredDeny(request); if (!socket?.writable) return offeredDeny(request); const id = this.nextPermissionId++; if (this.permissionPending.size >= 256) return offeredDeny(request); @@ -662,6 +668,28 @@ export class ZCodeBroker { for (const [id, pending] of this.permissionPending) if (pending.request.sessionId === sessionId && pending.turnToken === turnToken) { clearTimeout(pending.timer); this.permissionPending.delete(id); this.retirePermissionResponse(id, pending.socket); pending.resolve(offeredDeny(pending.request)); } } + async releaseExactTurn(socket, ownerId, params, protocol, admission) { + const active = this.activeSessionSockets.get(params.sessionId); + if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || active?.socket !== socket || active.baseline !== params.stateRevision || active.inputId !== params.inputId || typeof active.token !== 'string') throw turnReleaseMismatch(); + const activeToken = active.token; + active.releasingToken = activeToken; + let protocolReleased = false; + try { + this.settleTurnPermissions(params.sessionId, activeToken); + await protocol.drainServerTasksForSession(params.sessionId); + if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId) !== active || active.socket !== socket || active.token !== activeToken || active.baseline !== params.stateRevision || active.inputId !== params.inputId) throw turnReleaseMismatch(); + protocol.releaseTurn(params.sessionId); protocolReleased = true; + if (this.protocol !== protocol || !this.admission.sessionRequestCurrent(admission, protocol) || this.activeSessionSockets.get(params.sessionId) !== active || active.socket !== socket || active.token !== activeToken || active.baseline !== params.stateRevision || active.inputId !== params.inputId) throw turnReleaseMismatch(); + this.activeSessionSockets.delete(params.sessionId); this.activeSessions.delete(params.sessionId); + const key = turnReleaseKey(ownerId, params); this.releasedTurnTombstones.delete(key); this.releasedTurnTombstones.set(key, { socket }); + while (this.releasedTurnTombstones.size > MAX_RELEASED_TURN_TOMBSTONES) this.releasedTurnTombstones.delete(this.releasedTurnTombstones.keys().next().value); + this.scheduleIdleShutdown(); + } catch (error) { + if (!protocolReleased && this.activeSessionSockets.get(params.sessionId) === active && active.releasingToken === activeToken) delete active.releasingToken; + throw error; + } + } + retirePermissionResponse(id, socket) { this.retiredPermissionResponses.set(id, { socket }); while (this.retiredPermissionResponses.size > 256) this.retiredPermissionResponses.delete(this.retiredPermissionResponses.keys().next().value); @@ -926,6 +954,8 @@ function existingProtocolUnavailable() { return new PluginError('ZCODE_BROKER_PR function protocolRetiring() { return new PluginError('ZCODE_PROTOCOL_RETIRING', 'The previous ZCode protocol generation has not closed safely.', { category: 'state', remedy: 'Retry after the retired protocol generation closes.' }); } function brokerUnhealthyError() { return new PluginError('ZCODE_BROKER_UNHEALTHY', 'The recorded ZCode broker identity cannot be safely replaced after its health check failed.', { category: 'state', remedy: 'Stop or repair the recorded broker process before retrying.' }); } function brokerInputError() { return new PluginError('ZCODE_BROKER_INPUT_INVALID', 'ZCode broker input is invalid.', { category: 'validation', remedy: 'Provide a data root, workspace, endpoint, and launch target.' }); } +function turnReleaseMismatch() { return new PluginError('ZCODE_TURN_RELEASE_MISMATCH', 'The exact ZCode turn release did not match the active turn.', { category: 'state', remedy: 'Do not retry the stale release after starting a newer turn.' }); } +function turnReleaseKey(ownerId, params) { return JSON.stringify([ownerId, params.sessionId, params.stateRevision, params.inputId]); } function ownerReleaseTimeout() { return new PluginError('ZCODE_OWNER_RELEASE_TIMEOUT', 'The ZCode owner release exceeded its bounded storage budget.', { category: 'timeout', remedy: 'Retry after the active owner-store operation completes.' }); } function identityCleanupError(path, cause) { return new PluginError('ZCODE_BROKER_IDENTITY_CLEANUP_FAILED', 'The ZCode broker identity could not be safely removed.', { category: 'storage', remedy: 'Inspect the broker identity path and retry cleanup.', ...(cause === undefined ? {} : { cause }), details: { path } }); } From 9b1b65af0703dfeb885759ec174049a2f3d1f343 Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 12:23:46 +0800 Subject: [PATCH 15/19] fix: serialize timeout cleanup with stop --- scripts/lib/zcode-client.mjs | 29 +++++++++++++--- tests/zcode-client.test.mjs | 66 ++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/scripts/lib/zcode-client.mjs b/scripts/lib/zcode-client.mjs index 07c4eeb..fc1d4cd 100644 --- a/scripts/lib/zcode-client.mjs +++ b/scripts/lib/zcode-client.mjs @@ -37,6 +37,7 @@ export class ZCodeClient { /** @type {Promise|null} */ this.exactTurnReleaseProbe = null; this.armedBoundaries = new Map(); + this.deferredReleaseBoundaries = new Map(); this.stopIntents = new Map(); } /** @param {{workspace:string,sessionId?:string,model?:{providerId:string,modelId:string,variant?:string},importedHistory?:{title?:string,createdAt?:number,updatedAt?:number,messages:Array<{role:'user'|'assistant',content:string,timestamp?:number}>}}} input */ @@ -95,7 +96,22 @@ export class ZCodeClient { /** @param {string} sessionId */ async readSession(sessionId) { requireSessionId(sessionId); const result = await this.protocol.request('session/read', { sessionId }); validateSnapshot(result, sessionId, this.expectedWorkspace(sessionId), 'session/read'); this.sessionCatalogs.set(sessionId, result.settings.model); return result; } /** @param {string} sessionId */ async resumeSession(sessionId) { requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); const result = await this.protocol.request('session/resume', { sessionId }); validateSnapshot(result, sessionId, this.expectedWorkspace(sessionId), 'session/resume'); this.sessionCatalogs.set(sessionId, result.settings.model); this.sessionWorkspaces.set(sessionId, result.session.workspace.workspacePath); return result; } /** @param {number} [timeoutMs] */ async listSessions(timeoutMs) { const result = requireObjectResult(await this.protocol.request('session/list', {}, timeoutMs), 'session/list'); if (!Array.isArray(result.sessions) || !result.sessions.every(validSessionInfo)) throw outputError('session/list'); return result; } - /** @param {string} sessionId @param {number} [timeoutMs] */ async stopSession(sessionId, timeoutMs) { requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); const result = await this.protocol.request('session/stop', { sessionId }, timeoutMs); if (!boundedUpstreamObject(result)) throw outputError('session/stop'); if (!this.protocol.acceptBrokerControl) this.protocol.cancelTurn(sessionId); this.armedBoundaries.delete(sessionId); return {}; } + /** @param {string} sessionId @param {number} [timeoutMs] */ + async stopSession(sessionId, timeoutMs) { + requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); + const boundary = this.armedBoundaries.get(sessionId); if (boundary) this.stopIntents.set(sessionId, boundary); + try { + const result = await this.protocol.request('session/stop', { sessionId }, timeoutMs); if (!boundedUpstreamObject(result)) throw outputError('session/stop'); + if (!this.protocol.acceptBrokerControl) this.protocol.cancelTurn(sessionId); + if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); + if (this.deferredReleaseBoundaries.get(sessionId) === boundary) this.deferredReleaseBoundaries.delete(sessionId); + return {}; + } catch (error) { + if (this.stopIntents.get(sessionId) === boundary) this.stopIntents.delete(sessionId); + if (boundary && this.deferredReleaseBoundaries.get(sessionId) === boundary && this.armedBoundaries.get(sessionId) === boundary) this.scheduleDeferredExactTurnRelease(sessionId, boundary); + throw error; + } finally { if (this.stopIntents.get(sessionId) === boundary) this.stopIntents.delete(sessionId); } + } /** @param {number} [timeoutMs] */ async brokerCapabilities(timeoutMs) { const result = await requestBrokerHealth(this, timeoutMs, true); this.exactTurnRelease = result.capabilities?.exactTurnRelease === true; return { releaseOwnerExclusions: result.capabilities?.releaseOwnerExclusions === true }; } /** @param {string[]} [excludeSessionIds] @param {number} [timeoutMs] */ async releaseOwner(excludeSessionIds, timeoutMs) { if (excludeSessionIds !== undefined && (!Array.isArray(excludeSessionIds) || excludeSessionIds.length > 1_000 || new Set(excludeSessionIds).size !== excludeSessionIds.length || !excludeSessionIds.every((sessionId) => isSafeIdentifier(sessionId)))) throw inputError(); const result = await this.protocol.request('broker/releaseOwner', excludeSessionIds === undefined ? {} : { excludeSessionIds }, timeoutMs); if (!plainObject(result) || !Array.isArray(result.releasedSessionIds) || !Array.isArray(result.failedSessionIds) || !result.releasedSessionIds.every((sessionId) => isSafeIdentifier(sessionId)) || !result.failedSessionIds.every((sessionId) => isSafeIdentifier(sessionId)) || !Number.isSafeInteger(result.deferredSessionCount) || result.deferredSessionCount < 0) throw outputError('broker/releaseOwner'); return result; } @@ -143,7 +159,8 @@ export class ZCodeClient { let completion; try { completion = await this.protocol.waitForCompletion(sessionId, timeoutMs); } catch (error) { - if (this.exactTurnRelease === true && boundary) void this.releaseExactTurn(sessionId, boundary).catch(() => {}); + if (error instanceof PluginError && error.code === 'ZCODE_SESSION_STOPPED') { this.protocol.releaseTurn(sessionId); if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); } + else if (this.exactTurnRelease === true && boundary) this.deferExactTurnRelease(sessionId, boundary); else this.armedBoundaries.delete(sessionId); throw error; } @@ -152,9 +169,13 @@ export class ZCodeClient { return completion; } /** Observe a validated terminal notification without consuming the active turn. @param {string} sessionId @param {number} [timeoutMs] */ observeCompletion(sessionId, timeoutMs) { return this.protocol.observeCompletion(sessionId, timeoutMs); } - /** Release the exact managed broker turn, then clear local turn state after acknowledgement. Direct clients clear synchronously. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); const boundary = this.armedBoundaries.get(sessionId); if (!this.workspaceBound || this.exactTurnRelease !== true || !boundary) { this.protocol.releaseTurn(sessionId); this.armedBoundaries.delete(sessionId); return Promise.resolve(); } return this.releaseExactTurn(sessionId, boundary); } + /** Release the exact managed broker turn, then clear local turn state after acknowledgement. Direct clients clear synchronously. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); const boundary = this.armedBoundaries.get(sessionId); if (!this.workspaceBound || this.exactTurnRelease !== true || !boundary) { this.protocol.releaseTurn(sessionId); this.armedBoundaries.delete(sessionId); this.deferredReleaseBoundaries.delete(sessionId); return Promise.resolve(); } return this.releaseExactTurn(sessionId, boundary); } + /** @param {string} sessionId @param {{stateRevision:number,inputId:string}} boundary */ + async releaseExactTurn(sessionId, boundary) { const result = await this.protocol.request('broker/releaseTurn', { sessionId, inputId: boundary.inputId, stateRevision: boundary.stateRevision }); if (!plainObject(result) || Object.keys(result).length !== 0) throw outputError('broker/releaseTurn'); this.protocol.releaseTurn(sessionId); if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); if (this.deferredReleaseBoundaries.get(sessionId) === boundary) this.deferredReleaseBoundaries.delete(sessionId); } + /** Let an immediate caller stop supersede timeout cleanup; otherwise release on the next event-loop turn. @param {string} sessionId @param {{stateRevision:number,inputId:string}} boundary */ + deferExactTurnRelease(sessionId, boundary) { this.deferredReleaseBoundaries.set(sessionId, boundary); this.scheduleDeferredExactTurnRelease(sessionId, boundary); } /** @param {string} sessionId @param {{stateRevision:number,inputId:string}} boundary */ - async releaseExactTurn(sessionId, boundary) { const result = await this.protocol.request('broker/releaseTurn', { sessionId, inputId: boundary.inputId, stateRevision: boundary.stateRevision }); if (!plainObject(result) || Object.keys(result).length !== 0) throw outputError('broker/releaseTurn'); this.protocol.releaseTurn(sessionId); if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); } + scheduleDeferredExactTurnRelease(sessionId, boundary) { setImmediate(() => { if (this.deferredReleaseBoundaries.get(sessionId) !== boundary || this.stopIntents.get(sessionId) === boundary || this.armedBoundaries.get(sessionId) !== boundary) return; this.deferredReleaseBoundaries.delete(sessionId); void this.releaseExactTurn(sessionId, boundary).catch(() => {}); }); } async ensureExactTurnReleaseCapability() { if (!this.workspaceBound || this.exactTurnRelease !== null) return; this.exactTurnReleaseProbe ??= requestBrokerHealth(this, undefined, true).then((result) => { this.exactTurnRelease = result.capabilities?.exactTurnRelease === true; }, (error) => { this.exactTurnReleaseProbe = null; throw error; }); await this.exactTurnReleaseProbe; } /** Exact local protocol invariant used to prove whether this client owns an active turn. @param {string} sessionId */ turnState(sessionId) { requireSessionId(sessionId); return this.protocol.turnState(sessionId); } /** @param {string} sessionId @param {{connectionId:string,clientMode:'desktop-continuous'|'web-remote-replayable'}} options */ diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index 63c3d09..f4931fa 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -1643,6 +1643,7 @@ test('managed completion timeout preserves its error while releasing the exact b const client = new ZCodeClient(protocol, process.cwd(), true); const first = await client.send(sessionId, 'timed turn'); assert.equal(await client.waitForCompletion(sessionId).catch((error) => error), timeoutError); + await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(calls.filter((call) => call.method === 'broker/releaseTurn').map((call) => call.params), [{ sessionId, inputId: first.inputId, stateRevision: 21 }]); assert.equal(client.turnState(sessionId), null); await client.send(sessionId, 'after timeout cleanup'); @@ -1667,10 +1668,75 @@ test('managed completion timeout is not delayed by a stuck exact release request const client = new ZCodeClient(protocol, process.cwd(), true); await client.send(sessionId, 'timed turn'); const observed = await Promise.race([client.waitForCompletion(sessionId).catch((error) => error), new Promise((resolve) => setTimeout(() => resolve('release-delayed-timeout'), 25))]); assert.equal(observed, timeoutError); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(calls.filter((call) => call.method === 'broker/releaseTurn').length, 1); await assert.rejects(client.send(sessionId, 'must remain fenced'), { code: 'ZCODE_TURN_ACTIVE' }); }); +test('managed completion timeout lets an immediate stop supersede deferred exact release', async () => { + const sessionId = 'completion-timeout-stop-session'; const turns = new Map(); const calls = []; const timeoutError = new PluginError('ZCODE_COMPLETION_TIMEOUT', 'completion timed out'); let acknowledgeStop; const stopAck = new Promise((resolve) => { acknowledgeStop = resolve; }); + const protocol = { + acceptBrokerControl: true, + request: async (method, params) => { + calls.push({ method, params }); + if (method === 'broker/health') return { ok: true, capabilities: { exactTurnRelease: true } }; + if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: 23 }; + if (method === 'session/stop') return stopAck; + if (method === 'broker/releaseTurn') return {}; + throw new Error(`unexpected ${method}`); + }, + beginTurn: (id) => turns.set(id, 'sending'), armTurn: (id) => turns.set(id, 'armed'), abortTurn: (id) => turns.delete(id), cancelTurn: (id) => turns.delete(id), releaseTurn: (id) => turns.delete(id), + waitForCompletion: async (id) => { turns.delete(id); throw timeoutError; }, turnState: (id) => turns.get(id) ?? null, + }; + const client = new ZCodeClient(protocol, process.cwd(), true); await client.send(sessionId, 'timed turn'); + assert.equal(await client.waitForCompletion(sessionId).catch((error) => error), timeoutError); + const stopping = client.stopSession(sessionId); await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls.filter((call) => ['session/stop', 'broker/releaseTurn'].includes(call.method)).map((call) => call.method), ['session/stop']); + acknowledgeStop({}); await stopping; await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls.filter((call) => ['session/stop', 'broker/releaseTurn'].includes(call.method)).map((call) => call.method), ['session/stop']); +}); + +test('managed completion timeout resumes deferred exact release when immediate stop fails', async () => { + const sessionId = 'completion-timeout-stop-failure-session'; const turns = new Map(); const calls = []; const timeoutError = new PluginError('ZCODE_COMPLETION_TIMEOUT', 'completion timed out'); const stopError = new Error('stop failed'); let rejectStop; const stopAck = new Promise((resolve, reject) => { rejectStop = reject; }); + const protocol = { + acceptBrokerControl: true, + request: async (method, params) => { + calls.push({ method, params }); + if (method === 'broker/health') return { ok: true, capabilities: { exactTurnRelease: true } }; + if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: 24 }; + if (method === 'session/stop') return stopAck; + if (method === 'broker/releaseTurn') return {}; + throw new Error(`unexpected ${method}`); + }, + beginTurn: (id) => turns.set(id, 'sending'), armTurn: (id) => turns.set(id, 'armed'), abortTurn: (id) => turns.delete(id), cancelTurn: (id) => turns.delete(id), releaseTurn: (id) => turns.delete(id), + waitForCompletion: async (id) => { turns.delete(id); throw timeoutError; }, turnState: (id) => turns.get(id) ?? null, + }; + const client = new ZCodeClient(protocol, process.cwd(), true); await client.send(sessionId, 'timed turn'); assert.equal(await client.waitForCompletion(sessionId).catch((error) => error), timeoutError); + const stopping = client.stopSession(sessionId); await new Promise((resolve) => setImmediate(resolve)); assert.equal(calls.filter((call) => call.method === 'broker/releaseTurn').length, 0); + rejectStop(stopError); assert.equal(await stopping.catch((error) => error), stopError); await new Promise((resolve) => setImmediate(resolve)); + assert.equal(calls.filter((call) => call.method === 'broker/releaseTurn').length, 1); +}); + +test('managed broker stop rejection destructively clears its exact boundary without release', async () => { + const sessionId = 'completion-broker-stopped-session'; const turns = new Map(); const calls = []; const stoppedError = new PluginError('ZCODE_SESSION_STOPPED', 'session stopped'); let revision = 25; + const protocol = { + acceptBrokerControl: true, + request: async (method, params) => { + calls.push({ method, params }); + if (method === 'broker/health') return { ok: true, capabilities: { exactTurnRelease: true } }; + if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: revision++ }; + if (method === 'broker/releaseTurn') return {}; + throw new Error(`unexpected ${method}`); + }, + beginTurn: (id) => turns.set(id, 'sending'), armTurn: (id) => turns.set(id, 'armed'), abortTurn: (id) => turns.delete(id), releaseTurn: (id) => turns.delete(id), + waitForCompletion: async (id) => { turns.delete(id); throw stoppedError; }, turnState: (id) => turns.get(id) ?? null, + }; + const client = new ZCodeClient(protocol, process.cwd(), true); await client.send(sessionId, 'stopped turn'); + assert.equal(await client.waitForCompletion(sessionId).catch((error) => error), stoppedError); await new Promise((resolve) => setImmediate(resolve)); + assert.equal(calls.filter((call) => call.method === 'broker/releaseTurn').length, 0); + await client.send(sessionId, 'after broker stop'); assert.equal(calls.filter((call) => call.method === 'session/send').length, 2); +}); + test('managed client falls back to local release when broker health lacks exact release capability', async () => { const calls = []; const turns = new Map(); const protocol = { acceptBrokerControl: true, request: async (method, params) => { calls.push({ method, params }); if (method === 'broker/health') return { ok: true, capabilities: {} }; if (method === 'session/send') return { accepted: true, sessionId: params.sessionId, stateRevision: 3 }; throw new Error(method); }, beginTurn: (sessionId) => turns.set(sessionId, 'sending'), armTurn: (sessionId) => turns.set(sessionId, 'armed'), abortTurn: (sessionId) => turns.delete(sessionId), releaseTurn: (sessionId) => turns.delete(sessionId), turnState: (sessionId) => turns.get(sessionId) ?? null }; const client = new ZCodeClient(protocol, process.cwd(), true); await client.send('legacy-broker-session', 'hello'); await client.releaseTurn('legacy-broker-session'); From 790b82b360d74c3168f02b0bded9776b1b6f5da0 Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 12:24:16 +0800 Subject: [PATCH 16/19] build: refresh timeout cleanup marketplace snapshot --- marketplace/.agents/plugins/provenance.json | 10 +++---- .../zcode/scripts/lib/zcode-client.mjs | 29 ++++++++++++++++--- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/marketplace/.agents/plugins/provenance.json b/marketplace/.agents/plugins/provenance.json index 687d2fc..8d48286 100644 --- a/marketplace/.agents/plugins/provenance.json +++ b/marketplace/.agents/plugins/provenance.json @@ -1,15 +1,15 @@ { "packageVersion": "0.1.0", "pluginVersion": "0.1.0", - "sourceRef": "3fc8811fbdacb020670176310957053631546af1", - "sourceSha": "3fc8811fbdacb020670176310957053631546af1", + "sourceRef": "9b1b65af0703dfeb885759ec174049a2f3d1f343", + "sourceSha": "9b1b65af0703dfeb885759ec174049a2f3d1f343", "dependencyLock": { "file": "npm-shrinkwrap.json", "sha256": "fa927194e6ca0b25c1d3f428859b2ab4798b8eabb4d31bc87188d73c630f9938" }, "content": { "algorithm": "sha256", - "sha256": "9f611f410861e1eeda7674098b6708c46cc73853da95cd6383c2b48e4fee37fd", + "sha256": "04443494d3f15c34e2d2f92acebea3a133d97cd9cab41f9034140e33e85d3020", "files": [ { "path": ".agents/plugins/marketplace.json", @@ -703,8 +703,8 @@ }, { "path": "plugins/zcode/scripts/lib/zcode-client.mjs", - "size": 55125, - "sha256": "938d7f5928a44c94faa10a99336d818f30868d56f51a814c98000f54bc2a507b" + "size": 57159, + "sha256": "0f6542ca1d80f44745b1f1ff7d3f065db24b0e9f14060a99a174728b2cb7e604" }, { "path": "plugins/zcode/scripts/lib/zcode-discovery.mjs", diff --git a/marketplace/plugins/zcode/scripts/lib/zcode-client.mjs b/marketplace/plugins/zcode/scripts/lib/zcode-client.mjs index 07c4eeb..fc1d4cd 100644 --- a/marketplace/plugins/zcode/scripts/lib/zcode-client.mjs +++ b/marketplace/plugins/zcode/scripts/lib/zcode-client.mjs @@ -37,6 +37,7 @@ export class ZCodeClient { /** @type {Promise|null} */ this.exactTurnReleaseProbe = null; this.armedBoundaries = new Map(); + this.deferredReleaseBoundaries = new Map(); this.stopIntents = new Map(); } /** @param {{workspace:string,sessionId?:string,model?:{providerId:string,modelId:string,variant?:string},importedHistory?:{title?:string,createdAt?:number,updatedAt?:number,messages:Array<{role:'user'|'assistant',content:string,timestamp?:number}>}}} input */ @@ -95,7 +96,22 @@ export class ZCodeClient { /** @param {string} sessionId */ async readSession(sessionId) { requireSessionId(sessionId); const result = await this.protocol.request('session/read', { sessionId }); validateSnapshot(result, sessionId, this.expectedWorkspace(sessionId), 'session/read'); this.sessionCatalogs.set(sessionId, result.settings.model); return result; } /** @param {string} sessionId */ async resumeSession(sessionId) { requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); const result = await this.protocol.request('session/resume', { sessionId }); validateSnapshot(result, sessionId, this.expectedWorkspace(sessionId), 'session/resume'); this.sessionCatalogs.set(sessionId, result.settings.model); this.sessionWorkspaces.set(sessionId, result.session.workspace.workspacePath); return result; } /** @param {number} [timeoutMs] */ async listSessions(timeoutMs) { const result = requireObjectResult(await this.protocol.request('session/list', {}, timeoutMs), 'session/list'); if (!Array.isArray(result.sessions) || !result.sessions.every(validSessionInfo)) throw outputError('session/list'); return result; } - /** @param {string} sessionId @param {number} [timeoutMs] */ async stopSession(sessionId, timeoutMs) { requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); const result = await this.protocol.request('session/stop', { sessionId }, timeoutMs); if (!boundedUpstreamObject(result)) throw outputError('session/stop'); if (!this.protocol.acceptBrokerControl) this.protocol.cancelTurn(sessionId); this.armedBoundaries.delete(sessionId); return {}; } + /** @param {string} sessionId @param {number} [timeoutMs] */ + async stopSession(sessionId, timeoutMs) { + requireSessionId(sessionId); this.initialEmptySessions.delete(sessionId); + const boundary = this.armedBoundaries.get(sessionId); if (boundary) this.stopIntents.set(sessionId, boundary); + try { + const result = await this.protocol.request('session/stop', { sessionId }, timeoutMs); if (!boundedUpstreamObject(result)) throw outputError('session/stop'); + if (!this.protocol.acceptBrokerControl) this.protocol.cancelTurn(sessionId); + if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); + if (this.deferredReleaseBoundaries.get(sessionId) === boundary) this.deferredReleaseBoundaries.delete(sessionId); + return {}; + } catch (error) { + if (this.stopIntents.get(sessionId) === boundary) this.stopIntents.delete(sessionId); + if (boundary && this.deferredReleaseBoundaries.get(sessionId) === boundary && this.armedBoundaries.get(sessionId) === boundary) this.scheduleDeferredExactTurnRelease(sessionId, boundary); + throw error; + } finally { if (this.stopIntents.get(sessionId) === boundary) this.stopIntents.delete(sessionId); } + } /** @param {number} [timeoutMs] */ async brokerCapabilities(timeoutMs) { const result = await requestBrokerHealth(this, timeoutMs, true); this.exactTurnRelease = result.capabilities?.exactTurnRelease === true; return { releaseOwnerExclusions: result.capabilities?.releaseOwnerExclusions === true }; } /** @param {string[]} [excludeSessionIds] @param {number} [timeoutMs] */ async releaseOwner(excludeSessionIds, timeoutMs) { if (excludeSessionIds !== undefined && (!Array.isArray(excludeSessionIds) || excludeSessionIds.length > 1_000 || new Set(excludeSessionIds).size !== excludeSessionIds.length || !excludeSessionIds.every((sessionId) => isSafeIdentifier(sessionId)))) throw inputError(); const result = await this.protocol.request('broker/releaseOwner', excludeSessionIds === undefined ? {} : { excludeSessionIds }, timeoutMs); if (!plainObject(result) || !Array.isArray(result.releasedSessionIds) || !Array.isArray(result.failedSessionIds) || !result.releasedSessionIds.every((sessionId) => isSafeIdentifier(sessionId)) || !result.failedSessionIds.every((sessionId) => isSafeIdentifier(sessionId)) || !Number.isSafeInteger(result.deferredSessionCount) || result.deferredSessionCount < 0) throw outputError('broker/releaseOwner'); return result; } @@ -143,7 +159,8 @@ export class ZCodeClient { let completion; try { completion = await this.protocol.waitForCompletion(sessionId, timeoutMs); } catch (error) { - if (this.exactTurnRelease === true && boundary) void this.releaseExactTurn(sessionId, boundary).catch(() => {}); + if (error instanceof PluginError && error.code === 'ZCODE_SESSION_STOPPED') { this.protocol.releaseTurn(sessionId); if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); } + else if (this.exactTurnRelease === true && boundary) this.deferExactTurnRelease(sessionId, boundary); else this.armedBoundaries.delete(sessionId); throw error; } @@ -152,9 +169,13 @@ export class ZCodeClient { return completion; } /** Observe a validated terminal notification without consuming the active turn. @param {string} sessionId @param {number} [timeoutMs] */ observeCompletion(sessionId, timeoutMs) { return this.protocol.observeCompletion(sessionId, timeoutMs); } - /** Release the exact managed broker turn, then clear local turn state after acknowledgement. Direct clients clear synchronously. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); const boundary = this.armedBoundaries.get(sessionId); if (!this.workspaceBound || this.exactTurnRelease !== true || !boundary) { this.protocol.releaseTurn(sessionId); this.armedBoundaries.delete(sessionId); return Promise.resolve(); } return this.releaseExactTurn(sessionId, boundary); } + /** Release the exact managed broker turn, then clear local turn state after acknowledgement. Direct clients clear synchronously. @param {string} sessionId */ releaseTurn(sessionId) { requireSessionId(sessionId); const boundary = this.armedBoundaries.get(sessionId); if (!this.workspaceBound || this.exactTurnRelease !== true || !boundary) { this.protocol.releaseTurn(sessionId); this.armedBoundaries.delete(sessionId); this.deferredReleaseBoundaries.delete(sessionId); return Promise.resolve(); } return this.releaseExactTurn(sessionId, boundary); } + /** @param {string} sessionId @param {{stateRevision:number,inputId:string}} boundary */ + async releaseExactTurn(sessionId, boundary) { const result = await this.protocol.request('broker/releaseTurn', { sessionId, inputId: boundary.inputId, stateRevision: boundary.stateRevision }); if (!plainObject(result) || Object.keys(result).length !== 0) throw outputError('broker/releaseTurn'); this.protocol.releaseTurn(sessionId); if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); if (this.deferredReleaseBoundaries.get(sessionId) === boundary) this.deferredReleaseBoundaries.delete(sessionId); } + /** Let an immediate caller stop supersede timeout cleanup; otherwise release on the next event-loop turn. @param {string} sessionId @param {{stateRevision:number,inputId:string}} boundary */ + deferExactTurnRelease(sessionId, boundary) { this.deferredReleaseBoundaries.set(sessionId, boundary); this.scheduleDeferredExactTurnRelease(sessionId, boundary); } /** @param {string} sessionId @param {{stateRevision:number,inputId:string}} boundary */ - async releaseExactTurn(sessionId, boundary) { const result = await this.protocol.request('broker/releaseTurn', { sessionId, inputId: boundary.inputId, stateRevision: boundary.stateRevision }); if (!plainObject(result) || Object.keys(result).length !== 0) throw outputError('broker/releaseTurn'); this.protocol.releaseTurn(sessionId); if (this.armedBoundaries.get(sessionId) === boundary) this.armedBoundaries.delete(sessionId); } + scheduleDeferredExactTurnRelease(sessionId, boundary) { setImmediate(() => { if (this.deferredReleaseBoundaries.get(sessionId) !== boundary || this.stopIntents.get(sessionId) === boundary || this.armedBoundaries.get(sessionId) !== boundary) return; this.deferredReleaseBoundaries.delete(sessionId); void this.releaseExactTurn(sessionId, boundary).catch(() => {}); }); } async ensureExactTurnReleaseCapability() { if (!this.workspaceBound || this.exactTurnRelease !== null) return; this.exactTurnReleaseProbe ??= requestBrokerHealth(this, undefined, true).then((result) => { this.exactTurnRelease = result.capabilities?.exactTurnRelease === true; }, (error) => { this.exactTurnReleaseProbe = null; throw error; }); await this.exactTurnReleaseProbe; } /** Exact local protocol invariant used to prove whether this client owns an active turn. @param {string} sessionId */ turnState(sessionId) { requireSessionId(sessionId); return this.protocol.turnState(sessionId); } /** @param {string} sessionId @param {{connectionId:string,clientMode:'desktop-continuous'|'web-remote-replayable'}} options */ From 8e1f14105bd3687f27c366dd6f4d34aeefcb1415 Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 12:50:33 +0800 Subject: [PATCH 17/19] test: canonicalize legacy client workspace asynchronously --- tests/zcode-client.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index f4931fa..25a58ea 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -1,6 +1,6 @@ // @ts-nocheck import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { realpathSync } from 'node:fs'; @@ -23,7 +23,7 @@ const fixture = fileURLToPath(new URL('./fixtures/fake-zcode-cli.mjs', import.me const brokerStartupFault = fileURLToPath(new URL('./fixtures/broker-startup-fault.cjs', import.meta.url)); const MACOS_UNIX_SOCKET_PATH_MAX_BYTES = 104; async function createPreExactReleaseClient(options) { - const workspace = realpathSync(options.workspace); + const workspace = await realpath(resolve(options.workspace)); const protocol = await connectZCodeBroker(options.brokerEndpoint, { cwd: workspace, brokerToken: options.brokerToken, ownerId: options.ownerId, requestTimeoutMs: options.requestTimeoutMs, completionTimeoutMs: options.completionTimeoutMs, From 7a4c377e514ee5623a5966a0bf83d7d8345fb9b7 Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 13:05:33 +0800 Subject: [PATCH 18/19] test: allow app server fixture startup before timeout --- tests/codex-app-server.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/codex-app-server.test.mjs b/tests/codex-app-server.test.mjs index 24bc7ec..003616b 100644 --- a/tests/codex-app-server.test.mjs +++ b/tests/codex-app-server.test.mjs @@ -223,7 +223,7 @@ test('ambient spawn child identity preserves bounded timeout abort overflow and }); /** @type {Array<[string, Record, Record, string]>} */ const cases = [ - ['timeout', { FAKE_CODEX_HANG: 'thread/read' }, { timeoutMs: 100 }, 'CODEX_APP_SERVER_TIMEOUT'], + ['timeout', { FAKE_CODEX_HANG: 'thread/read' }, { timeoutMs: 1_000 }, 'CODEX_APP_SERVER_TIMEOUT'], ['overflow', { FAKE_CODEX_NOTIFICATION: '1', FAKE_CODEX_OTHER_ID: '1' }, { maxOutputBytes: 100 }, 'CODEX_APP_SERVER_OUTPUT_TOO_LARGE'], ]; for (const [name, env, bounds, code] of cases) await t.test(name, async () => { From 1f40454c35f130deb8cd5b4e2caa54a86330f04f Mon Sep 17 00:00:00 2001 From: vitry Date: Tue, 1 Sep 2026 13:47:23 +0800 Subject: [PATCH 19/19] test: scale owner release deadlines on Windows --- tests/zcode-client.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/zcode-client.test.mjs b/tests/zcode-client.test.mjs index 25a58ea..ff0d9f6 100644 --- a/tests/zcode-client.test.mjs +++ b/tests/zcode-client.test.mjs @@ -2035,7 +2035,7 @@ test('owner release cleans sixteen subscriptions concurrently within one shared for (let index = 0; index < 16; index += 1) { const sessionId = `budget-session-${index}`; sessions[sessionId] = ownerId; broker.sessionOwners.set(sessionId, { ownerId, socket, claimToken: null }); broker.conversationSubscriptions.set(`budget-${index}`, { socket, topic: `conversation/${sessionId}`, subscriptionId: `budget-sub-${index}`, connectionId: `budget-connection-${index}`, sessionId, ownerId }); } await writeFile(`${endpoint}.owners.json`, JSON.stringify({ version: 1, sessions })); broker.ownershipStoreEstablished = true; let unsubscribeCalls = 0; let releaseFirstBatch; let releaseSecondBatch; let markFirstBatchEntered; let markSecondBatchEntered; const firstBatchEntered = new Promise((resolvePromise) => { markFirstBatchEntered = resolvePromise; }); const secondBatchEntered = new Promise((resolvePromise) => { markSecondBatchEntered = resolvePromise; }); const firstBatchGate = new Promise((resolvePromise) => { releaseFirstBatch = resolvePromise; }); const secondBatchGate = new Promise((resolvePromise) => { releaseSecondBatch = resolvePromise; }); broker.protocol = { request: async (method) => { if (method === 'session/stop') return {}; unsubscribeCalls += 1; const batchGate = unsubscribeCalls <= 8 ? firstBatchGate : secondBatchGate; if (unsubscribeCalls === 8) markFirstBatchEntered(); if (unsubscribeCalls === 16) markSecondBatchEntered(); await batchGate; throw new Error('slow unsubscribe failure'); }, cancelTurn() {} }; - const releasing = broker.releaseOwner(socket, ownerId, []); await firstBatchEntered; assert.equal(unsubscribeCalls, 8, 'the first bounded cleanup batch must enter concurrently'); releaseFirstBatch(); await secondBatchEntered; assert.equal(unsubscribeCalls, 16, 'the second bounded cleanup batch must enter after the first settles'); releaseSecondBatch(); const released = await releasing; + const releasing = broker.releaseOwner(socket, ownerId, [], Date.now() + scaleTestTimeout(600)); await firstBatchEntered; assert.equal(unsubscribeCalls, 8, 'the first bounded cleanup batch must enter concurrently'); releaseFirstBatch(); await secondBatchEntered; assert.equal(unsubscribeCalls, 16, 'the second bounded cleanup batch must enter after the first settles'); releaseSecondBatch(); const released = await releasing; assert.equal(released.releasedSessionIds.length, 16); assert.equal(released.failedSessionIds.length, 0); assert.equal(unsubscribeCalls, 16); assert.equal(broker.orphanedConversationSubscriptions.size, 16); await rm(directory, { recursive: true, force: true }); }); @@ -2062,7 +2062,7 @@ test('an idle owner release keeps its valid stop acknowledgement through malform }); let releaseSettled = false; - releasing = broker.releaseOwner(socket, ownerId, []); + releasing = broker.releaseOwner(socket, ownerId, [], Date.now() + scaleTestTimeout(600)); void releasing.then(() => { releaseSettled = true; }, () => { releaseSettled = true; }); await withTestDeadlineKeepalive(() => closeEntered, scaleTestTimeout(2_000)); assert.equal(releaseSettled, false); assert.equal(closeSettled, false);