From 91a02f69c1c8050381c18fea6f7dc463a95fc743 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 9 Oct 2026 15:04:44 +0200 Subject: [PATCH 1/2] fix(review): remove the pending consent response deadline --- assets/orchestrator-prompts.md | 2 +- extensions/gentle-ai.ts | 90 +++---------------- tests/native-review-parity.test.ts | 60 +++---------- tests/provider-defect-handoff.test.ts | 2 +- ...ion-standing-permission-controller.test.ts | 21 +++-- 5 files changed, 35 insertions(+), 140 deletions(-) diff --git a/assets/orchestrator-prompts.md b/assets/orchestrator-prompts.md index 102fea826..ea5dd0e45 100644 --- a/assets/orchestrator-prompts.md +++ b/assets/orchestrator-prompts.md @@ -17,7 +17,7 @@ Before losslessly relaying any blocking choice envelope, classify its semantic a When anything else produced it, there is no report and no handoff. That includes the model provider (context limits reached, rate limits, a refusal to process an input), the client runtime (a session that must be restarted, a crashed or empty sub-agent result, a dispatcher that never dispatched), the environment, and the user's own repository state. Do not name the component you believe is responsible, do not suggest where else to file it, and do not ask. Say plainly what blocked the work in the ordinary conversation, then continue or stop as the workflow dictates. A report system that files other projects' defects stops meaning anything when it files ours. -`consent-binding-expired` and `consent-binding-already-consumed` are local lifecycle outcomes, not Gentle AI provider defects. An unknown consent binding is reportable only when independent evidence proves a fresh, same-session, unconsumed binding was lost. Never infer that evidence from the old combined stale-binding message. +Pending consent has no response deadline. `consent-binding-already-consumed` is a local lifecycle outcome, not a Gentle AI provider defect. An unknown consent binding is reportable only when independent evidence proves a fresh, same-session, unconsumed binding was lost. Never infer that evidence from the old combined stale-binding message. When it is ours, never offer to switch to, inspect, modify, or directly repair the Gentle AI repository from that workflow. If an upstream envelope offers direct repair, do not silently mutate it: reject it as semantically inadmissible and issue this separate orchestrator-owned handoff envelope. diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 60681dc88..f09ac6608 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -6365,7 +6365,6 @@ function nativeInspectInputRejection(reason: string, field?: string): Record; } const PENDING_REVIEW_CONSENT_DISPOSITION = { - EXPIRED: "expired", CONSUMED: "consumed", } as const; @@ -6460,12 +6456,6 @@ export class PendingReviewConsentRegistry { return true; } - expire(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean { - if (!this.remove(sessionKey, pending)) return false; - this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED); - return true; - } - discard(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): void { this.remove(sessionKey, pending); } @@ -6520,24 +6510,13 @@ function pendingReviewConsentSessionKey(context: ExtensionContext | undefined, f } function consumePendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): boolean { - if (!registry.consume(sessionKey, pending)) return false; - if (pending.expiry !== undefined) clearTimeout(pending.expiry); - pending.expiry = undefined; - return true; + return registry.consume(sessionKey, pending); } function discardPendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void { - if (pending.expiry !== undefined) clearTimeout(pending.expiry); - pending.expiry = undefined; registry.discard(sessionKey, pending); } -function expirePendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void { - if (pending.expiry !== undefined) clearTimeout(pending.expiry); - pending.expiry = undefined; - if (registry.expire(sessionKey, pending)) pending.cleanupCandidate(); -} - function cleanupPendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void { discardPendingReviewConsent(pending, registry, sessionKey); pending.cleanupCandidate(); @@ -6547,21 +6526,8 @@ function cleanupAllPendingReviewConsents(registry: PendingReviewConsentRegistry, for (const pending of registry.take(sessionKey)) cleanupPendingReviewConsent(pending, registry, sessionKey); } -// An unused consent binding and the candidate view retained exclusively for -// that binding expire as one lifecycle unit. TTL expiry is observable the -// moment synchronous time says `expiresAt <= now`, so cleanup must be -// synchronous with respect to that observation — the queued cleanup -// macrotask is a safety net, not the authority. Pruning here (before any -// later START may reuse the retained view) keeps timer order from deciding -// correctness: a fresh candidate retry never reuses a view whose binding -// already expired, so it cannot trip `candidate-target-projection-drift`. -function pruneExpiredReviewConsents(registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey, now: () => number): void { - const pendingReviewConsents = registry.get(sessionKey); - if (pendingReviewConsents === undefined) return; - for (const pending of [...pendingReviewConsents.values()]) { - if (pending.expiresAt <= now()) expirePendingReviewConsent(pending, registry, sessionKey); - } -} +// Pending consent waits for the human without a deadline. Candidate verification, +// one-shot consumption, replacement, and session teardown still own its lifecycle. function reviewConsentDigest(consent: ReviewConsentEnvelope): string { return createHash("sha256").update(JSON.stringify(consent)).digest("hex"); @@ -6623,13 +6589,12 @@ function completedGrantedReviewConsent(outcome: Record): boolea } // gentle-pi#516: a binding this session does not hold (already answered, -// expired, or issued by another Pi session or process) used to fall through +// or issued by another Pi process) used to fall through // to the plain negotiated STATUS, which reads exactly like a healthy pre-start // "ready" and sent the model back into START for a second consent prompt. The // fact is local and proven before any provider call, so the outcome names the // binding and the exit; the current STATUS rides along as context only. const STALE_CONSENT_BINDING_DIAGNOSTIC_CODE = { - EXPIRED: "consent-binding-expired", ALREADY_CONSUMED: "consent-binding-already-consumed", UNKNOWN: "consent-binding-unknown", } as const; @@ -6643,9 +6608,6 @@ interface StaleConsentBindingDiagnostics { function staleConsentBindingDiagnostics(binding: string, disposition: PendingReviewConsentDisposition | undefined): StaleConsentBindingDiagnostics { const exit = "Run START again for this candidate to obtain a fresh consent envelope and answer that envelope's binding once; do not resend this binding."; - if (disposition === PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED) { - return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.EXPIRED, message: `consent binding ${binding} expired after ${PENDING_REVIEW_CONSENT_TTL_MS / 60_000} minutes without an answer. ${exit}` }; - } if (disposition === PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED) { return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.ALREADY_CONSUMED, message: `consent binding ${binding} was already consumed by an earlier answer. ${exit}` }; } @@ -8286,8 +8248,6 @@ async function executeReviewControllerOperation( retainedUntrackedSelections: Map = new Map(), pendingReviewConsentRegistry: PendingReviewConsentRegistry = processPendingReviewConsentRegistry, pendingReviewConsentFallbackKey: symbol = Symbol("pending-review-consent-fallback"), - reviewConsentNow: () => number = Date.now, - reviewConsentScheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void } = setTimeout, intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission, ): Promise> { const parameters = parseReviewControllerParameters(parametersValue); @@ -8791,12 +8751,8 @@ async function executeReviewControllerOperation( const resolved = pendingReviewConsentRegistry.resolve(input.consentBinding); const pending = resolved?.pending; const owningSession = resolved?.sessionKey ?? pendingReviewConsentSession; - if (pending === undefined || pending.expiresAt <= reviewConsentNow()) { - const disposition = pending === undefined - ? pendingReviewConsentRegistry.staleDisposition(input.consentBinding) - : PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED; - const stale = staleConsentBindingDiagnostics(input.consentBinding, disposition); - if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession); + if (pending === undefined) { + const stale = staleConsentBindingDiagnostics(input.consentBinding, pendingReviewConsentRegistry.staleDisposition(input.consentBinding)); if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); try { const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { @@ -8904,7 +8860,7 @@ async function executeReviewControllerOperation( const rejected = (stopSelector !== undefined && stopSelector.targetIdentity !== status.targetIdentity) || input === undefined || canonicalReviewCaptureBinding(input) !== canonicalBinding || exactCollectArgument(input, "target_identity") !== status.targetIdentity || exactCollectArgument(input, "projection") !== status.projection.projection || exactCollectArgument(input, "base_tree") !== status.projection.baseTree || exactCollectArgument(input, "candidate_tree") !== status.projection.currentCandidateTree || !Array.isArray(eligible) || selected.reason !== undefined || selected.intendedUntracked!.some((path) => !eligible.includes(path)); if (rejected) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" }; const submission = { argumentTokens: input.submission!.argumentTokens, value: JSON.stringify({ schema: "gentle-ai.review-intended-untracked-selection/v1", untracked_scope: scope, expected_untracked_inventory: inventory, intended_untracked: selected.intendedUntracked }) }; - const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, ...committedSelector, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, reviewConsentNow, reviewConsentScheduleTimer, submission); + const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, ...committedSelector, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, submission); return { ...result, operation: parameters.operation }; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) { @@ -9079,19 +9035,13 @@ async function executeReviewControllerOperation( // gentle-pi#323: the replay key must fold in the current candidate // content identity. Without it, a second START with identical // {cwd, lineageId, input, inputPath} reuses a still-live (never - // lineage-bound) frozen candidate view from within the consent TTL - // window even after the live candidate content changed underneath + // lineage-bound) frozen candidate view retained for pending consent + // even after the live candidate content changed underneath // it, and dead-ends at candidate-target-projection-drift with no // recovery. Folding in currentCandidateTree makes a content change // mint a fresh replay key -- and therefore a fresh candidate view -- // instead of reusing the stale one. const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null, candidateTree: target.projection.currentCandidateTree, providerBaseTree: providerBaseTree ?? null }); - // Synchronously drop any binding whose TTL has already elapsed - // before reusing its retained candidate view, so a fresh-candidate - // retry cannot reuse a view tied to an expired binding and trip - // candidate-target-projection-drift. Timer order must not decide - // correctness: the queued cleanup macrotask may not have fired yet. - pruneExpiredReviewConsents(pendingReviewConsentRegistry, pendingReviewConsentSession, reviewConsentNow); const candidateIntendedUntracked = target.projection.intendedUntracked; let candidateView: ReturnType | undefined; let nativeStartAttempted = false; @@ -9125,7 +9075,7 @@ async function executeReviewControllerOperation( const repositoryCwd = realpathSync(defaultCwd); const consentDigest = reviewConsentDigest(error.consent); const pendingReviewConsents = pendingReviewConsentRegistry.get(pendingReviewConsentSession); - const existing = [...(pendingReviewConsents?.values() ?? [])].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest && pending.expiresAt > reviewConsentNow()); + const existing = [...(pendingReviewConsents?.values() ?? [])].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest); if (existing === undefined) { for (const pending of [...(pendingReviewConsents?.values() ?? [])]) { if (pending.candidateView.token === consentCandidateView.token) { @@ -9146,19 +9096,13 @@ async function executeReviewControllerOperation( cleanupCandidate: () => { if (candidateCleaned) return; candidateCleaned = true; - try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent expiry/teardown still completes. */ } + try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent teardown still completes. */ } }, ...(retainedUntrackedSelection === undefined ? {} : { untrackedSelection: retainedUntrackedSelection }), consent: error.consent, consentDigest, - expiresAt: reviewConsentNow() + PENDING_REVIEW_CONSENT_TTL_MS, }; pendingReviewConsentRegistry.add(pendingReviewConsentSession, pending); - pending.expiry = reviewConsentScheduleTimer( - () => expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession), - PENDING_REVIEW_CONSENT_TTL_MS, - ); - pending.expiry.unref(); } return { operation: parameters.operation, @@ -9410,12 +9354,6 @@ export interface GentleAiRuntimeDependencies { // An injected registry gives tests and host integrations explicit ownership; // normal package registrations share the module-local process-memory registry. pendingReviewConsentRegistry?: PendingReviewConsentRegistry; - // Deterministic test seam for the consent-binding TTL clock. Production - // leaves both undefined so the consent path observes real wall-clock time; - // tests inject a fake clock so expiry is observable without a 10-minute - // sleep and without relying on the queued cleanup macrotask firing. - now?: () => number; - scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void }; // The environment the session's child processes inherit; tests inject a // plain object so the handshake declaration is observable without // touching the test runner's own process.env. @@ -9446,8 +9384,6 @@ function createGentleAiExtensionForTesting( ? acquireChildStandingReviewPermissionClient(dependencies.processEnv ?? process.env) : undefined; const childStandingReviewPermission = dependencies.childStandingReviewPermissionClient ?? childStandingReviewPermissionLease?.client; - const reviewConsentNow = dependencies.now ?? (() => Date.now()); - const reviewConsentScheduleTimer = dependencies.scheduleTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); const pendingReviewConsentRegistry = dependencies.pendingReviewConsentRegistry ?? processPendingReviewConsentRegistry; const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary; const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter(); @@ -9784,8 +9720,6 @@ function createGentleAiExtensionForTesting( retainedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, - reviewConsentNow, - reviewConsentScheduleTimer, ); if (details.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED && details.outcome === "native-approved-acknowledgement-completed" && @@ -9821,8 +9755,6 @@ function createGentleAiExtensionForTesting( retainedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, - reviewConsentNow, - reviewConsentScheduleTimer, ); let permissionWorkspaceRoot: string | undefined; try { diff --git a/tests/native-review-parity.test.ts b/tests/native-review-parity.test.ts index bb1e10328..0c0b23757 100644 --- a/tests/native-review-parity.test.ts +++ b/tests/native-review-parity.test.ts @@ -181,8 +181,6 @@ interface ParityRuntime { interface ParityRuntimeOptions { candidateViews?: CandidateViewRegistry; pendingReviewConsentRegistry?: PendingReviewConsentRegistry; - now?: () => number; - scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void }; } function parityRuntime(nativeReviewCli: NativeReviewCli | null, options: ParityRuntimeOptions = {}): ParityRuntime { @@ -193,8 +191,6 @@ function parityRuntime(nativeReviewCli: NativeReviewCli | null, options: ParityR nativeReviewCli, candidateViews: options.candidateViews ?? new CandidateViewRegistry(), pendingReviewConsentRegistry: options.pendingReviewConsentRegistry ?? new PendingReviewConsentRegistry(), - now: options.now, - scheduleTimer: options.scheduleTimer, } as unknown as Parameters[0]; __testing.createGentleAiExtension(dependencies)({ on(name: string, handler: RegisteredEvent) { events.set(name, handler); }, @@ -330,7 +326,7 @@ async function answerConsent(runtime: ParityRuntime, cwd: string, binding: unkno // that names itself and its exit. It still carries the current negotiated // STATUS as reconciliation context, but it never reads as a healthy // pre-start "ready". -function assertStaleConsentBinding(outcome: Record, binding: unknown, code: "consent-binding-unknown" | "consent-binding-expired" | "consent-binding-already-consumed"): void { +function assertStaleConsentBinding(outcome: Record, binding: unknown, code: "consent-binding-unknown" | "consent-binding-already-consumed"): void { assert.equal(outcome.status, "blocked"); assert.equal(outcome.outcome, "consent-binding-stale"); assert.equal(outcome.consent_binding, binding); @@ -677,7 +673,7 @@ test("unavailable and ambiguous consent follow-ups never replay a consumed bindi assert.equal(statusCalls, callsBeforeStaleReconciliation + 1, "already-consumed binding reconciliation performs exactly one additional STATUS call"); }); -test("concurrent answers atomically claim consent before review-mode gating and ignore a queued expiry callback", async (t) => { +test("concurrent answers atomically claim consent before review-mode gating", async (t) => { const cwd = repository(t); const fixture = consentNative(cwd); const candidateViews = new CandidateViewRegistry(); @@ -687,7 +683,6 @@ test("concurrent answers atomically claim consent before review-mode gating and cleanupCalls += 1; cleanup(token); }; - const scheduled: Array<() => void> = []; let releaseModeGate: (() => void) | undefined; const modeGate = new Promise((resolve) => { releaseModeGate = resolve; }); let modeCalls = 0; @@ -702,13 +697,12 @@ test("concurrent answers atomically claim consent before review-mode gating and let nativeAnswerCalls = 0; fixture.native.answerConsent = async (request) => { nativeAnswerCalls += 1; - assert.equal(cleanupCalls, 0, "a queued expiry callback must not clean claimed candidate authority before the provider answer"); + assert.equal(cleanupCalls, 0, "candidate authority remains live before the provider answer"); await providerGate; return await nativeAnswer(request); }; const runtime = parityRuntime(fixture.native, { candidateViews, - scheduleTimer: (callback) => { scheduled.push(callback); return { unref() {} }; }, }); const blocked = await beginConsent(runtime, cwd); fixture.native.reviewMode = async () => { @@ -726,9 +720,7 @@ test("concurrent answers atomically claim consent before review-mode gating and assert.equal(modeCalls, 1, "the first answer pauses at the asynchronous review-mode gate"); const second = answerConsent(runtime, cwd, blocked.consent_binding, "declined"); await new Promise((resolve) => setImmediate(resolve)); - assert.equal(scheduled.length, 1); - scheduled[0]!(); - assert.equal(cleanupCalls, 0, "a queued expiry callback is a harmless no-op after the answer claim"); + assert.equal(cleanupCalls, 0, "claimed consent retains candidate authority until the answer settles"); releaseModeGate!(); releaseProvider!(); @@ -741,47 +733,19 @@ test("concurrent answers atomically claim consent before review-mode gating and assert.equal(cleanupCalls, 1, "only the answered binding cleans candidate authority"); }); -test("timer-cleaned and synchronously pruned consent bindings remain expired within their session", async (t) => { +test("unanswered consent remains reusable after the former deadline and answers only once", async (t) => { const cwd = repository(t); const fixture = consentNative(cwd); - const registry = new PendingReviewConsentRegistry(); - const start = 1_000; - let now = start; - const scheduled: Array<() => void> = []; - const runtime = parityRuntime(fixture.native, { - pendingReviewConsentRegistry: registry, - now: () => now, - scheduleTimer: (callback) => { scheduled.push(callback); return { unref() {} }; }, - }); + const runtime = parityRuntime(fixture.native); + let now = 1_000; + t.mock.method(Date, "now", () => now); const first = await beginConsent(runtime, cwd); + now += 24 * 60 * 60 * 1000; const reused = await beginConsent(runtime, cwd); assert.equal(reused.consent_binding, first.consent_binding); - assert.equal(scheduled.length, 1); - now += 10 * 60 * 1000; - // The real TTL callback drops the frozen candidate immediately. Its - // session-local disposition remains only to classify this stale answer. - scheduled[0]!(); - const timerExpired = await answerConsent(runtime, cwd, first.consent_binding, "declined"); - assert.equal(timerExpired.operation, "answer-consent"); - assertStaleConsentBinding(timerExpired, first.consent_binding, "consent-binding-expired"); - assert.match(String((timerExpired.diagnostics as { message?: unknown }).message), /expired after 10 minutes/); - const second = await beginConsent(runtime, cwd); - assert.notEqual(second.consent_binding, first.consent_binding); - assert.equal(scheduled.length, 2); - assertStaleConsentBinding(await answerConsent(runtime, cwd, first.consent_binding, "declined"), first.consent_binding, "consent-binding-expired"); - - // No second callback runs: START synchronously prunes this unused binding - // before it can reuse the frozen candidate, and its answer stays typed expiry. - now += 10 * 60 * 1000; - const third = await beginConsent(runtime, cwd); - assert.notEqual(third.consent_binding, second.consent_binding); - assertStaleConsentBinding(await answerConsent(runtime, cwd, second.consent_binding, "declined"), second.consent_binding, "consent-binding-expired"); - - const reloaded = parityRuntime(fixture.native, { pendingReviewConsentRegistry: new PendingReviewConsentRegistry() }); - assertStaleConsentBinding(await answerConsent(reloaded, cwd, first.consent_binding, "declined"), first.consent_binding, "consent-binding-unknown"); - const afterReload = await beginConsent(reloaded, cwd); - assert.notEqual(afterReload.consent_binding, third.consent_binding); - assert.equal(fixture.starts.count, 5); + const answered = await answerConsent(runtime, cwd, first.consent_binding, "declined"); + assert.equal(answered.outcome, "consent-declined-this-candidate"); + assertStaleConsentBinding(await answerConsent(runtime, cwd, first.consent_binding, "declined"), first.consent_binding, "consent-binding-already-consumed"); }); test("native START maps only provider facts and omits absent evidence", async (t) => { diff --git a/tests/provider-defect-handoff.test.ts b/tests/provider-defect-handoff.test.ts index 1782db120..b351b8c6a 100644 --- a/tests/provider-defect-handoff.test.ts +++ b/tests/provider-defect-handoff.test.ts @@ -93,7 +93,7 @@ test("orchestrator-delegation.md states the admissibility-before-relay rule", () }); test("orchestrator-delegation.md excludes local consent lifecycle outcomes from provider-defect reporting", () => { - assert.match(DELEGATION, /`consent-binding-expired` and `consent-binding-already-consumed` are local lifecycle outcomes, not Gentle AI provider defects/i); + assert.match(DELEGATION, /Pending consent has no response deadline\. `consent-binding-already-consumed` is a local lifecycle outcome, not a Gentle AI provider defect/i); assert.match(DELEGATION, /An unknown consent binding is reportable only when independent evidence proves a fresh, same-session, unconsumed binding was lost/i); assert.match(DELEGATION, /Never infer that evidence from the old combined stale-binding message/i); }); diff --git a/tests/review-session-standing-permission-controller.test.ts b/tests/review-session-standing-permission-controller.test.ts index 22ec8f750..d4fe0162a 100644 --- a/tests/review-session-standing-permission-controller.test.ts +++ b/tests/review-session-standing-permission-controller.test.ts @@ -249,7 +249,6 @@ function piConsent() { } function controllerHarness(cwd: string, processEnv: NodeJS.ProcessEnv = {}, options: { - now?: () => number; answerConsentError?: Error & { mutationOutcome?: "none" | "unknown" }; startAction?: "created" | "resumed" | "replayed" | "closed" | "blocked-scope-action"; childStandingReviewPermissionClient?: ChildStandingReviewPermissionClient; @@ -284,7 +283,7 @@ function controllerHarness(cwd: string, processEnv: NodeJS.ProcessEnv = {}, opti return { kind: "started", start: { lineageId: `lineage-${answers.length}`, state: action === "blocked-scope-action" ? "unreviewed" : "approved", riskLevel: "high", selectedLenses: [], changedFiles: 1, changedLines: 2, correctionBudget: 0, action, lensesRequired: false, riskReasons: [] } }; }, } as unknown as NativeReviewCli; - createGentleAiExtension({ nativeReviewCli: native, candidateViews: new CandidateViewRegistry(), processEnv, now: options.now, childStandingReviewPermissionClient: options.childStandingReviewPermissionClient })({ + createGentleAiExtension({ nativeReviewCli: native, candidateViews: new CandidateViewRegistry(), processEnv, childStandingReviewPermissionClient: options.childStandingReviewPermissionClient })({ on(name: string, handler: RegisteredEvent) { events.set(name, handler); }, registerCommand(name: string, definition: RegisteredCommand) { commands.set(name, definition); }, registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); }, @@ -471,10 +470,11 @@ test("blocked-scope-action never persists host permission", async (t) => { assert.deepEqual(runtime.answers, ["granted", "declined"]); }); -test("a stale consent result with contextual native status never arms host permission", async (t) => { +test("a late consent answer is accepted once and arms host permission without another prompt", async (t) => { const cwd = reviewRepository(t); let now = 0; - const runtime = controllerHarness(cwd, {}, { now: () => now }); + t.mock.method(Date, "now", () => now); + const runtime = controllerHarness(cwd); const manager = {}; let prompts = 0; const ctx = interactiveContext(cwd, manager, async (_title, options) => { @@ -486,14 +486,13 @@ test("a stale consent result with contextual native status never arms host permi return options[1]; }); const start = { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }; - const stale = (await runtime.controller.execute("expired-host-choice", start, undefined, undefined, ctx)).details; - assert.equal(stale.outcome, "consent-binding-stale"); - assert.equal(stale.native_invocation_attempted, false); - assert.equal(typeof stale.result, "object", "native STATUS is contextual evidence, not a successful START result"); + const accepted = (await runtime.controller.execute("late-host-choice", start, undefined, undefined, ctx)).details; + assert.notEqual(accepted.outcome, "consent-binding-stale"); + assert.deepEqual(runtime.answers, ["granted"]); writeFileSync(join(cwd, "app.ts"), "export const value = 3;\n"); - await runtime.controller.execute("after-stale", start, undefined, undefined, ctx); - assert.equal(prompts, 2, "the next fresh consent envelope must still prompt after a stale contextual result"); - assert.deepEqual(runtime.answers, ["declined"]); + await runtime.controller.execute("after-late-answer", start, undefined, undefined, ctx); + assert.equal(prompts, 1, "a late session grant must not cause a second prompt"); + assert.deepEqual(runtime.answers, ["granted", "granted"]); }); test("a failed consent invocation never arms host permission", async (t) => { From 5ecf349bd250fb03eedebd9c9d697bb05a50b3a9 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 9 Oct 2026 15:11:01 +0200 Subject: [PATCH 2/2] test(agents): await background exit delivery instead of a fixed delay --- tests/gentle-agents.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index 0ab890186..0dbecf0b4 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -5876,7 +5876,10 @@ test("bash_background returns at once, and an idle parent gets one stored exit n assert.equal(sent.length, 0, "nothing reaches the parent while the job runs"); jobs.runs[0]!.onData(Buffer.from("check build: fail\n")); jobs.runs[0]!.exit(1); - await jobIo(); + await eventually( + () => sent.some((entry) => entry.message.customType === "gentle-jobs.notice") && userMessages.length > 0, + "the closed job log must deliver its exit notice and wake before assertions", + ); const notices = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice"); assert.equal(notices.length, 1, "the exit is reported exactly once"); assert.deepEqual(notices[0]!.options, { triggerTurn: false }, "an idle parent stores the notice without a direct turn");