mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Merge commit '3a70078ab9972f61a364c064f21e2301b53f360d' into brennanb2025/mobile-i18n-audit
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user