Merge commit '3a70078ab9972f61a364c064f21e2301b53f360d' into brennanb2025/mobile-i18n-audit

This commit is contained in:
Brennan Benson
2026-08-01 02:48:50 -07:00
4 changed files with 190 additions and 16 deletions
+79 -1
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-31",
"updatedAt": "2026-08-01",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -10,6 +10,84 @@
}
},
"gates": [
{
"id": "terminal-provider.login-session-retirement",
"title": "macOS login-session retirement preserves live daemons through transient rejection bursts",
"maturity": "experimental",
"protection": "partial",
"owner": "daemon-terminal",
"layer": "macos-daemon-lifecycle",
"surfaces": [
"GUI-spawned macOS daemon",
"local PTY survival",
"daemon reconnect and replacement"
],
"platforms": ["macos"],
"providers": ["local-daemon"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local-daemon"],
"coverageNotes": "A deterministic fake clock covers short-burst recovery, sustained session death, event-trigger preemption, timer suspension, resolver corroboration, and shutdown cancellation. The production-only watch remains behind the macOS GUI launch flag; SSH/headless, WSL, Linux, Windows, mobile, and relay paths are unaffected.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/11749",
"https://github.com/stablyai/orca/issues/7936"
],
"invariant": "A GUI-spawned macOS daemon may retire for PAM rejections only after it previously accepted login wrapping, receives three conclusive rejections spanning at least one uninterrupted 120-second observation window, and observes explicitly unhealthy in-process resolver state. A conclusive acceptance or a sleep/App Nap-sized timer gap resets the rejection window, and client or PTY activity cannot shorten its scheduled backoff.",
"oracle": "Arm the watch with an accepted probe, inject three rejections over 20 seconds plus unhealthy resolver state, then inject client and PTY activity and require zero resolver reads, zero retirement calls, and exactly four probes until the 120-second boundary. Return acceptance at that boundary and require the daemon to survive. Jump wall time by one hour without firing the scheduled timer, require rejection evidence to restart after wake, and again recover without retirement. Separately keep rejecting through an uninterrupted boundary and require one retirement, while healthy or unknown resolver state suppresses it and stop aborts an in-flight resolver check.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/macos-login-session-death-watch.test.ts --reporter=dot"
],
"testFiles": ["src/main/daemon/macos-login-session-death-watch.test.ts"],
"assertionRefs": [
{
"file": "src/main/daemon/macos-login-session-death-watch.test.ts",
"assertions": [
"preserves the daemon when a short PAM rejection burst recovers after wake",
"does not count a suspended timer gap as rejection evidence",
"retires only after sustained conclusive rejections with a degraded resolver",
"suppresses retirement while resolver health is healthy or unknown, then retires on explicit degradation",
"stop prevents an in-flight resolver check from retiring the daemon"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-01",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/macos-login-session-death-watch.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 0.1,
"summary": "The focused state-machine suite passed 19 tests, including the field-shaped 20-second rejection burst, suspended-timer rebaselining, bounded recovery retry, sustained-death convergence, resolver suppression, trigger coalescing, and shutdown cancellation."
}
],
"runtimeBudget": {
"p95Seconds": 2,
"scope": "deterministic login-session death-watch state-machine suite"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The deterministic suite passes locally; focused CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "On origin/main, the field-shaped accepted then three-rejection sequence calls onRetire after 20 seconds and fails the recovery oracle. With the minimum observation window and non-preemptible rejection schedule, the identical oracle preserves the daemon and recovers at the boundary."
},
"performanceBudget": {
"required": true,
"evidence": "The watch retains one timer and one in-flight probe. A rejection burst runs the existing two 10-second confirmation probes, then one recovery probe at the 120-second boundary; client and PTY activity cannot pull that deadline earlier. Resolver work is skipped until the time floor. No polling loop, startup await, session scan, renderer work, provider fanout, listener, or retained payload was added."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
"Capture field evidence from macOS sleep/wake and a full GUI logout without a false retirement or missed stale-session recovery.",
"Keep the exact probe-count, no-early-resolver, trigger-preemption, acceptance-reset, and shutdown-abort assertions green."
],
"knownGaps": [
"A real dead macOS GUI login session cannot be fabricated without ending the runner's login session; sustained-death coverage uses deterministic PAM and resolver oracles.",
"The two-minute production window and one-hour timer suspension are covered by fake-clock tests rather than a real sleep/wake run.",
"No multi-process aggregate throttle is added; each stale daemon independently obeys the same non-preemptible observation window."
],
"demotionRule": "Keep experimental or demote if any activity trigger shortens the rejection window, a transient burst reaches resolver retirement authority, sustained dead-session evidence no longer converges, shutdown permits late retirement, or the focused gate flakes without an identified product or harness fault."
},
{
"id": "ssh-relay.staged-upload-recovery",
"title": "SSH relay uploads remain retryable before the shared install lock",
+1
View File
@@ -210,6 +210,7 @@ async function main(): Promise<void> {
timing: {
periodicProbeMs: 2_000,
rejectionRecheckMs: 500,
minimumRejectionSpanMs: 2_000,
ptyExitDebounceMs: 200,
clientActivityMinGapMs: 1_000,
minProbeGapMs: 100
@@ -38,7 +38,7 @@ class FakeClock {
if (!due) {
break
}
this.nowMs = due.at
this.nowMs = Math.max(this.nowMs, due.at)
due.cleared = true
due.callback()
// Why: probes are async; let their promise chains settle before firing the next timer.
@@ -47,6 +47,10 @@ class FakeClock {
this.nowMs = target
}
suspend(ms: number): void {
this.nowMs += ms
}
pendingCount(): number {
return this.timers.filter((t) => !t.cleared).length
}
@@ -84,6 +88,7 @@ function createWatch(
timing: {
periodicProbeMs: 120_000,
rejectionRecheckMs: 10_000,
minimumRejectionSpanMs: 120_000,
ptyExitDebounceMs: 2_000,
clientActivityMinGapMs: 30_000,
minProbeGapMs: 5_000,
@@ -102,9 +107,9 @@ function createWatch(
}
describe('MacosLoginSessionDeathWatch', () => {
it('retires after consecutive conclusive rejections once armed, with a degraded resolver', async () => {
it('retires only after sustained conclusive rejections with a degraded resolver', async () => {
const { watch, clock, onRetire } = createWatch({
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED]
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED]
})
watch.start()
await drainMicrotasks()
@@ -113,7 +118,9 @@ describe('MacosLoginSessionDeathWatch', () => {
await clock.advance(120_000) // periodic → rejection 1
await clock.advance(10_000) // recheck → rejection 2
expect(onRetire).not.toHaveBeenCalled()
await clock.advance(10_000) // recheck → rejection 3 → retire
await clock.advance(10_000) // recheck → rejection 3 → deferred
expect(onRetire).not.toHaveBeenCalled()
await clock.advance(100_000) // rejection 3 after the observation window → retire
expect(onRetire).toHaveBeenCalledWith({
cause: 'pam-rejections',
rejections: 3,
@@ -121,6 +128,57 @@ describe('MacosLoginSessionDeathWatch', () => {
})
})
it('preserves the daemon when a short PAM rejection burst recovers after wake', async () => {
const readResolverHealth = vi.fn(async () => 'unhealthy' as const)
const { watch, clock, onRetire, probe } = createWatch({
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, ACCEPTED],
readResolverHealth
})
watch.start()
await drainMicrotasks()
await clock.advance(120_000)
await clock.advance(10_000)
await clock.advance(10_000)
expect(probe).toHaveBeenCalledTimes(4)
expect(readResolverHealth).not.toHaveBeenCalled()
expect(onRetire).not.toHaveBeenCalled()
watch.notifyClientActivity()
watch.notifyPtyExit()
await clock.advance(99_999)
expect(probe).toHaveBeenCalledTimes(4)
await clock.advance(1)
expect(probe).toHaveBeenCalledTimes(5)
expect(readResolverHealth).not.toHaveBeenCalled()
expect(onRetire).not.toHaveBeenCalled()
})
it('does not count a suspended timer gap as rejection evidence', async () => {
const readResolverHealth = vi.fn(async () => 'unhealthy' as const)
const { watch, clock, onRetire, probe } = createWatch({
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED, ACCEPTED],
readResolverHealth
})
watch.start()
await drainMicrotasks()
await clock.advance(120_000)
clock.suspend(60 * 60 * 1000)
await clock.advance(0)
await clock.advance(10_000)
await clock.advance(10_000)
expect(probe).toHaveBeenCalledTimes(5)
expect(readResolverHealth).not.toHaveBeenCalled()
expect(onRetire).not.toHaveBeenCalled()
await clock.advance(100_000)
expect(probe).toHaveBeenCalledTimes(6)
expect(onRetire).not.toHaveBeenCalled()
})
it('never retires when the session was never conclusively accepted', async () => {
const { watch, clock, onRetire } = createWatch({
outcomes: [REJECTED, REJECTED, REJECTED, REJECTED, REJECTED]
@@ -183,20 +241,21 @@ describe('MacosLoginSessionDeathWatch', () => {
'suppresses retirement while resolver health is %s, then retires on explicit degradation',
async (initialResolverHealth) => {
const { watch, clock, onRetire, probe, setResolverHealth } = createWatch({
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED]
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED, REJECTED]
})
setResolverHealth(initialResolverHealth)
watch.start()
await drainMicrotasks()
await clock.advance(120_000)
await clock.advance(10_000)
await clock.advance(10_000) // threshold reached but resolver did not corroborate death
await clock.advance(10_000)
await clock.advance(100_000) // threshold reached but resolver did not corroborate death
expect(onRetire).not.toHaveBeenCalled()
const probesAtSuppression = probe.mock.calls.length
setResolverHealth('unhealthy')
await clock.advance(10_000)
await clock.advance(119_999)
expect(probe).toHaveBeenCalledTimes(probesAtSuppression)
await clock.advance(110_000) // suppressed states return to the bounded periodic cadence
await clock.advance(1) // suppressed states return to the bounded periodic cadence
expect(onRetire).toHaveBeenCalledTimes(1)
}
)
@@ -334,7 +393,7 @@ describe('MacosLoginSessionDeathWatch', () => {
resolveHealth = resolve
})
const { watch, clock, onRetire } = createWatch({
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED],
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED],
readResolverHealth: (signal) => {
resolverSignal = signal
return resolverHealth
@@ -344,7 +403,8 @@ describe('MacosLoginSessionDeathWatch', () => {
await drainMicrotasks()
await clock.advance(120_000)
await clock.advance(10_000)
await clock.advance(10_000) // retirement is now waiting on resolver health
await clock.advance(10_000)
await clock.advance(100_000) // retirement is now waiting on resolver health
watch.stop()
expect(resolverSignal?.aborted).toBe(true)
@@ -2,12 +2,12 @@ import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell'
import type { DaemonFileLog } from './daemon-file-log'
import type { SystemResolverHealth } from './types'
// Why: three conclusive PAM verdicts spread over recheck intervals keep a transient
// rejection storm (PAM db reload, OS update) from retiring a daemon whose login
// session is still alive; a real logout rejects conclusively on every probe.
// Why: retain count confirmation while the elapsed window below blocks short bursts.
const REQUIRED_CONSECUTIVE_REJECTIONS = 3
const PERIODIC_PROBE_MS = 120_000
const REJECTION_RECHECK_MS = 10_000
// Why: a full periodic window separates logout from short wake/PAM recovery bursts.
const MINIMUM_REJECTION_SPAN_MS = PERIODIC_PROBE_MS
const PTY_EXIT_DEBOUNCE_MS = 2_000
// Why: a client hello right after login is the fastest death signal for a stale
// daemon, but steady reconnects must not turn hellos into a PAM probe storm.
@@ -35,6 +35,7 @@ export type MacosLoginSessionDeathWatchOptions = {
timing?: Partial<{
periodicProbeMs: number
rejectionRecheckMs: number
minimumRejectionSpanMs: number
ptyExitDebounceMs: number
clientActivityMinGapMs: number
minProbeGapMs: number
@@ -65,6 +66,7 @@ export class MacosLoginSessionDeathWatch {
private readonly clock: WatchClock
private readonly periodicProbeMs: number
private readonly rejectionRecheckMs: number
private readonly minimumRejectionSpanMs: number
private readonly ptyExitDebounceMs: number
private readonly clientActivityMinGapMs: number
private readonly minProbeGapMs: number
@@ -73,6 +75,7 @@ export class MacosLoginSessionDeathWatch {
// a host where the wrapper never worked has no death signal to trust.
private armed = false
private consecutiveRejections = 0
private firstRejectionAtMs: number | null = null
private lastProbeStartedAtMs: number | null = null
private probeInFlight = false
private stopped = false
@@ -99,6 +102,7 @@ export class MacosLoginSessionDeathWatch {
}
this.periodicProbeMs = opts.timing?.periodicProbeMs ?? PERIODIC_PROBE_MS
this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? REJECTION_RECHECK_MS
this.minimumRejectionSpanMs = opts.timing?.minimumRejectionSpanMs ?? MINIMUM_REJECTION_SPAN_MS
this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? PTY_EXIT_DEBOUNCE_MS
this.clientActivityMinGapMs = opts.timing?.clientActivityMinGapMs ?? CLIENT_ACTIVITY_MIN_GAP_MS
this.minProbeGapMs = opts.timing?.minProbeGapMs ?? MIN_PROBE_GAP_MS
@@ -169,9 +173,10 @@ export class MacosLoginSessionDeathWatch {
}
this.scheduledProbeAtMs = this.clock.now() + delayMs
this.scheduledProbeTimer = this.clock.setTimeout(() => {
const scheduledAtMs = this.scheduledProbeAtMs
this.scheduledProbeTimer = null
this.scheduledProbeAtMs = null
void this.runProbe(trigger)
void this.runProbe(trigger, scheduledAtMs ?? undefined)
}, delayMs)
}
@@ -181,6 +186,9 @@ export class MacosLoginSessionDeathWatch {
private scheduleProbeNoLaterThan(delayMs: number, trigger: string): void {
const requestedAtMs = this.clock.now() + delayMs
if (this.consecutiveRejections > 0 && this.scheduledProbeAtMs !== null) {
return
}
if (this.scheduledProbeAtMs !== null && this.scheduledProbeAtMs <= requestedAtMs) {
return
}
@@ -200,7 +208,7 @@ export class MacosLoginSessionDeathWatch {
}
}
private async runProbe(trigger: string): Promise<void> {
private async runProbe(trigger: string, scheduledAtMs?: number): Promise<void> {
if (this.stopped || this.retired) {
return
}
@@ -209,6 +217,20 @@ export class MacosLoginSessionDeathWatch {
this.retainPendingProbe(trigger)
return
}
if (this.consecutiveRejections > 0 && scheduledAtMs === undefined) {
return
}
const timerGapMs =
scheduledAtMs === undefined ? 0 : Math.max(0, this.clock.now() - scheduledAtMs)
if (this.consecutiveRejections > 0 && timerGapMs > this.minProbeGapMs * 3) {
// Why: sleep/App Nap pauses probes; elapsed wall time is not rejection evidence.
this.consecutiveRejections = 0
this.firstRejectionAtMs = null
this.log.log('login-session-rejection-window-reset', {
cause: 'timer-gap',
timerGapMs
})
}
const elapsedSinceProbe =
this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs
if (
@@ -246,6 +268,7 @@ export class MacosLoginSessionDeathWatch {
}
this.armed = true
this.consecutiveRejections = 0
this.firstRejectionAtMs = null
this.scheduleNextProbe(this.periodicProbeMs)
return
}
@@ -254,6 +277,7 @@ export class MacosLoginSessionDeathWatch {
this.scheduleNextProbe(this.periodicProbeMs)
return
}
this.firstRejectionAtMs ??= this.clock.now()
this.consecutiveRejections++
this.log.log('login-session-probe-rejected', {
trigger,
@@ -263,6 +287,17 @@ export class MacosLoginSessionDeathWatch {
this.scheduleNextProbe(this.rejectionRecheckMs)
return
}
const rejectionSpanMs = this.clock.now() - this.firstRejectionAtMs
if (rejectionSpanMs < this.minimumRejectionSpanMs) {
const retryInMs = this.minimumRejectionSpanMs - rejectionSpanMs
this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1
this.log.log('login-session-retire-deferred', {
cause: 'pam-rejections',
retryInMs
})
this.scheduleNextProbe(retryInMs)
return
}
await this.retireIfResolverDegraded(() => {
this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1
}, abortController.signal)