Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/orchestrator-prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
90 changes: 11 additions & 79 deletions extensions/gentle-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6365,7 +6365,6 @@ function nativeInspectInputRejection(reason: string, field?: string): Record<str
};
}

const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
const REVIEW_SESSION_PERMISSION_STATUS_KEY = "gentle-review-session-permission";
const REVIEW_SESSION_PERMISSION_STATUS_TEXT = "reviews allowed for this session";

Expand All @@ -6382,12 +6381,9 @@ interface PendingReviewConsent {
untrackedSelection?: RetainedNativeUntrackedSelection;
consent: ReviewConsentEnvelope;
consentDigest: string;
expiresAt: number;
expiry?: ReturnType<typeof setTimeout>;
}

const PENDING_REVIEW_CONSENT_DISPOSITION = {
EXPIRED: "expired",
CONSUMED: "consumed",
} as const;

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
Expand All @@ -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");
Expand Down Expand Up @@ -6623,13 +6589,12 @@ function completedGrantedReviewConsent(outcome: Record<string, unknown>): 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;
Expand All @@ -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}` };
}
Expand Down Expand Up @@ -8286,8 +8248,6 @@ async function executeReviewControllerOperation(
retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = 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<Record<string, unknown>> {
const parameters = parseReviewControllerParameters(parametersValue);
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<CandidateViewRegistry["create"]> | undefined;
let nativeStartAttempted = false;
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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" &&
Expand Down Expand Up @@ -9821,8 +9755,6 @@ function createGentleAiExtensionForTesting(
retainedSelections,
pendingReviewConsentRegistry,
pendingReviewConsentFallbackKey,
reviewConsentNow,
reviewConsentScheduleTimer,
);
let permissionWorkspaceRoot: string | undefined;
try {
Expand Down
5 changes: 4 additions & 1 deletion tests/gentle-agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading