From 8888a09c2b6a84cd8fccf88460fa23099b2efad8 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 10 Aug 2026 02:14:29 +0800 Subject: [PATCH 1/6] feat: add broker-owned idle lifecycle Why: Simulator capacity should remain registered while low-concurrency work deterministically reuses warm aliases and safely shuts unused automated simulators down under explicit local policy. Changed: Added pin/warm/shutdown selection, boot-on-acquire, idle policy and reconciliation, confirmed cleanup, CLI/service scheduling, public-surface scanning, tests, and aligned public specifications. Verification: The full product suite and both required harness profiles passed on the complete feature tree before this manifest-aligned commit split. Affected: broker-core, client, public documentation, and specifications. Refs: spec/tasks/public-safe-on-demand-simulator-lifecycle.md Session: task-sessions/20260810-public-safe-idle-broker --- .gitignore | 1 + README.md | 23 + broker-core/error-contract.mjs | 4 + broker-core/index.mjs | 532 +++++++++++++++++- broker-core/test/broker-core.test.mjs | 389 ++++++++++++- client/README.md | 13 + client/bin/simbroker.mjs | 48 +- client/command-dispatch.mjs | 133 +++++ client/public-surface.mjs | 145 +++++ client/service/brokerd.mjs | 41 +- client/service/service-client.mjs | 35 +- client/test/brokerd.test.mjs | 134 +++++ client/test/public-surface.test.mjs | 69 +++ client/test/simbroker.test.mjs | 69 ++- spec/README.md | 9 +- spec/architecture.md | 21 +- spec/build-and-test.md | 31 +- spec/global-simulator-broker.md | 105 +++- spec/harness-integration.md | 8 +- spec/implementation-plan.md | 71 ++- spec/project-structure.md | 8 +- spec/tasks/README.md | 12 +- ...blic-safe-on-demand-simulator-lifecycle.md | 161 ++++++ 23 files changed, 2001 insertions(+), 61 deletions(-) create mode 100644 client/public-surface.mjs create mode 100644 client/test/public-surface.test.mjs create mode 100644 spec/tasks/public-safe-on-demand-simulator-lifecycle.md diff --git a/.gitignore b/.gitignore index 36af7c3..cff66c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.public-safety.local node_modules/ agent-harness/node_modules/ agent-harness/artifacts/ diff --git a/README.md b/README.md index 925f9f3..9fd8a9e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ Current status: - direct and service-backed broker failures now expose stable exit codes for invalid requests, unavailable capacity, repair-needed aliases, override-required flows, and internal failures - `host init --bootstrap-config` now provisions real simulator devices, including dual iPhone UI aliases (`ui-1`, `ui-2`), a second dedicated `build-fast` alias for overlapping build-test demand, and records their actual IDs and runtime versions in host config - `erase-on-acquire` leases now run behind a dedicated reset lock and roll back cleanly if reset fails before a lease is handed out +- lease acquisition prefers a matching pin, then compatible warm capacity, then + shutdown capacity; the most recently released alias wins inside each tier and + the broker boots it before returning success +- optional Automatic shutdown is configured only through broker commands, has + no source-defined duration, preserves every registered alias as standby, and + excludes pins, leases, manual aliases, and unhealthy devices - a macOS operator app now exists under `app/` with Overview, Simulators, Projects, and Events screens plus broker-backed actions for pinning, release, and lifecycle control - the macOS app overview, empty-state, simulator-detail, destructive-confirmation, and override-required remediation flows now have captured operator-facing evidence; the app also supports alternate broker roots with `--state-root`, `--host-config`, optional `--cli-path`, and direct deep-link review targeting - the broker now publishes a canonical `app-snapshot.json` read model under the broker state root for the app and smoke tooling @@ -28,6 +34,8 @@ Current status: - repo capacity can now be diagnosed with `simbroker capacity check`; missing broker-managed capacity can be previewed with non-mutating `capacity reconcile` and applied only with exact human confirmation of the current plan +- idle policy can be inspected and reconciled with `simbroker idle`; one-time + cleanup uses a count-only preview followed by exact human confirmation - broker-aware sample consumer repo artifacts now cover manual human, interactive agent, unattended agent, and CI patterns under `examples/harness-adoption/` - a guide-aligned `broker-harness-adoption` skill lives under `.agents/skills/broker-harness-adoption/` - the repo is ready for continued implementation on top of the active specs @@ -129,6 +137,7 @@ npm run package:distribution ```bash npm test +npm run verify:public-surface npm run test:install-smoke npm run test:app npm run test:client @@ -138,6 +147,12 @@ node client/bin/simbroker.mjs app snapshot node client/bin/simbroker.mjs capacity check --repo-root "$PWD" --purpose agent-ui-session --json node client/bin/simbroker.mjs capacity reconcile --repo-root "$PWD" --purpose agent-ui-session --json node client/bin/simbroker.mjs capacity reconcile --repo-root "$PWD" --purpose agent-ui-session --apply --confirm --actor-type human --actor-id --json +node client/bin/simbroker.mjs idle status --json +node client/bin/simbroker.mjs idle enable --grace-seconds <60-86400> --actor-type human --actor-id --json +node client/bin/simbroker.mjs idle disable --actor-type human --actor-id --json +node client/bin/simbroker.mjs idle reconcile --json +node client/bin/simbroker.mjs idle cleanup --json +node client/bin/simbroker.mjs idle cleanup --apply --confirm --actor-type human --actor-id --json node client/bin/simbroker.mjs pin create --repo-root "$PWD" --purpose manual-testing --alias manual-1 node client/bin/simbroker.mjs lease release --lease-file /tmp/simbroker-lease.json node client/bin/simbroker.mjs simulators boot --alias ui-1 @@ -147,6 +162,13 @@ npm run agent:context -- --paths spec/README.md --session-dir "$HOME/.codex/agen npm run agent:verify -- --profile spec-only --paths spec/README.md --session-dir "$HOME/.codex/agent-harness/simulator-broker-app/bootstrap" ``` +Automatic shutdown is unconfigured on a fresh install. Choose a valid duration +explicitly in the app or CLI; do not create or edit broker state files by hand. + +For an additional machine-local public-safety check, create an ignored +`.public-safety.local` with one private name, alias, or path per line. The +scanner reports matching rule numbers without printing the private values. + ## Security Please report security issues privately through GitHub Security Advisories when @@ -165,4 +187,5 @@ Contributor setup, verification, and PR expectations are documented in - [spec/architecture.md](spec/architecture.md) - [spec/implementation-plan.md](spec/implementation-plan.md) - [spec/harness-integration.md](spec/harness-integration.md) +- [spec/tasks/public-safe-on-demand-simulator-lifecycle.md](spec/tasks/public-safe-on-demand-simulator-lifecycle.md) - [references/README.md](references/README.md) diff --git a/broker-core/error-contract.mjs b/broker-core/error-contract.mjs index 0c0a39a..4021d72 100644 --- a/broker-core/error-contract.mjs +++ b/broker-core/error-contract.mjs @@ -56,6 +56,7 @@ const UNAVAILABLE_REASON_CODES = new Set([ ]); const REPAIR_NEEDED_REASON_CODES = new Set([ + "boot-on-acquire-failed", "reset-on-acquire-failed", "capacity-repair-required", "unhealthy-alias", @@ -66,6 +67,9 @@ const OVERRIDE_REQUIRED_REASON_CODES = new Set([ "capacity-human-required", "capacity-plan-stale", "human-override-required", + "idle-confirmation-required", + "idle-human-required", + "idle-plan-stale", "missing-override-reason", "override-alias-mismatch", "override-lease-mismatch", diff --git a/broker-core/index.mjs b/broker-core/index.mjs index dd20485..7209e69 100644 --- a/broker-core/index.mjs +++ b/broker-core/index.mjs @@ -23,6 +23,10 @@ const HOST_CONFIG_VERSION = 1; const KNOWN_PROJECTS_VERSION = 1; const PROJECT_CONFIG_VERSION = 1; const REGISTRY_VERSION = 1; +const IDLE_POLICY_VERSION = 1; +const IDLE_SCHEMA_VERSION = 1; +const MIN_IDLE_GRACE_SECONDS = 60; +const MAX_IDLE_GRACE_SECONDS = 86_400; const ALLOWED_DEVICE_FAMILIES = new Set(["iPhone", "iPad"]); const ALLOWED_RESET_POLICIES = new Set(["none", "erase-on-acquire"]); const ALLOWED_HEALTH_STATES = new Set(["healthy", "state-drift", "repair-needed", "repairing"]); @@ -91,6 +95,21 @@ function normalizeNonNegativeInteger(value) { return Number.isInteger(number) && number >= 0 ? number : null; } +function requireBoundedInteger(value, flagName, minimum, maximum) { + const number = Number(value); + if (Number.isInteger(number) && number >= minimum && number <= maximum) { + return number; + } + if (value === undefined || value === null) { + throw new BrokerError(`Missing required flag --${flagName}.`, { + reasonCode: "missing-flag", + }); + } + throw new BrokerError(`Flag --${flagName} must be an integer from ${minimum} through ${maximum}.`, { + reasonCode: "invalid-flag", + }); +} + function requirePositiveInteger(value, flagName) { const normalized = normalizePositiveInteger(value); if (normalized !== null) { @@ -1381,11 +1400,157 @@ function normalizeRegistry(rawRegistry, hostConfig, timestamp) { } return { aliases, + idle: { + lastCleanupResult: normalizeIdleCleanupResult(rawRegistry.idle?.lastCleanupResult), + }, updatedAt: timestamp, version: REGISTRY_VERSION, }; } +function normalizeIdleCleanupResult(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const completedAt = typeof value.completedAt === "string" ? value.completedAt : null; + const source = typeof value.source === "string" ? value.source : null; + const status = typeof value.status === "string" ? value.status : null; + const eligibleCount = normalizeNonNegativeInteger(value.eligibleCount); + const shutdownCount = normalizeNonNegativeInteger(value.shutdownCount); + const failureCount = normalizeNonNegativeInteger(value.failureCount); + if (!completedAt || !source || !status || eligibleCount === null || shutdownCount === null || failureCount === null) { + return null; + } + return { + completedAt, + eligibleCount, + failureCount, + shutdownCount, + source, + status, + }; +} + +function validateIdlePolicy(rawPolicy) { + const policy = requireObject(rawPolicy, "idle-policy"); + const keys = Object.keys(policy).sort(); + if (keys.length !== 2 || keys[0] !== "graceSeconds" || keys[1] !== "version") { + throw new BrokerError("idle-policy may contain only version and graceSeconds.", { + reasonCode: "invalid-config", + }); + } + validateVersion(policy.version, IDLE_POLICY_VERSION, "idle-policy"); + const graceSeconds = Number(policy.graceSeconds); + if (!Number.isInteger(graceSeconds) + || graceSeconds < MIN_IDLE_GRACE_SECONDS + || graceSeconds > MAX_IDLE_GRACE_SECONDS) { + throw new BrokerError(`idle-policy.graceSeconds must be an integer from ${MIN_IDLE_GRACE_SECONDS} through ${MAX_IDLE_GRACE_SECONDS}.`, { + field: "idle-policy.graceSeconds", + reasonCode: "invalid-config", + }); + } + return { + graceSeconds, + version: IDLE_POLICY_VERSION, + }; +} + +function readIdlePolicy(paths) { + const rawPolicy = readJsonIfExists(paths.idlePolicyPath); + return rawPolicy === null ? null : validateIdlePolicy(rawPolicy); +} + +function requireHumanIdleActor(options, action) { + if (options.actorType !== "human") { + throw new BrokerError(`${action} requires --actor-type human.`, { + reasonCode: "idle-human-required", + }); + } + if (typeof options.actorId !== "string" || options.actorId.trim() === "") { + throw new BrokerError(`${action} requires --actor-id .`, { + reasonCode: "idle-human-required", + }); + } + return options.actorId.trim(); +} + +function idleBaseCandidates(state) { + return state.hostConfig.aliases.filter((hostAlias) => { + const registryEntry = state.registry.aliases[hostAlias.alias]; + return registryEntry.powerState === "booted" + && registryEntry.health === "healthy" + && !state.leasesByAlias.has(hostAlias.alias) + && !state.pinsByAlias.has(hostAlias.alias) + && !hostAlias.capabilities.includes("manual-persistent"); + }); +} + +function idleDeadline(registryEntry, graceSeconds) { + const releasedAtMs = Date.parse(registryEntry.lastLeaseReleasedAt ?? ""); + if (!Number.isFinite(releasedAtMs)) { + return null; + } + return releasedAtMs + (graceSeconds * 1_000); +} + +function idleEligibleCandidates(state, policy, timestamp) { + const nowMs = Date.parse(timestamp); + return idleBaseCandidates(state).filter((hostAlias) => { + const deadline = idleDeadline(state.registry.aliases[hostAlias.alias], policy.graceSeconds); + return deadline !== null && deadline <= nowMs; + }); +} + +function buildIdleSummary(paths, state, timestamp) { + const policy = readIdlePolicy(paths); + if (!policy) { + return { + configured: false, + eligibleCount: 0, + graceSeconds: null, + lastCleanupResult: state.registry.idle.lastCleanupResult, + nextScheduledCleanupAt: null, + }; + } + const baseCandidates = idleBaseCandidates(state); + const deadlines = baseCandidates + .map((hostAlias) => idleDeadline(state.registry.aliases[hostAlias.alias], policy.graceSeconds)) + .filter((deadline) => deadline !== null); + return { + configured: true, + eligibleCount: idleEligibleCandidates(state, policy, timestamp).length, + graceSeconds: policy.graceSeconds, + lastCleanupResult: state.registry.idle.lastCleanupResult, + nextScheduledCleanupAt: deadlines.length === 0 + ? null + : new Date(Math.max(Date.parse(timestamp), Math.min(...deadlines))).toISOString(), + }; +} + +function idleCleanupPlan(state) { + const candidates = idleBaseCandidates(state); + const fingerprint = { + candidates: candidates.map((hostAlias) => ({ + alias: hostAlias.alias, + simulatorId: hostAlias.simulatorId, + })).sort((left, right) => left.alias.localeCompare(right.alias)), + command: "idle.cleanup", + schemaVersion: IDLE_SCHEMA_VERSION, + }; + return { + candidates, + publicPlan: { + command: "idle.cleanup", + eligibleCount: candidates.length, + mode: "preview", + ok: true, + planId: sha256Digest(fingerprint), + schemaVersion: IDLE_SCHEMA_VERSION, + status: candidates.length > 0 ? "changes_required" : "no_changes", + }, + }; +} + function listJsonFiles(dirPath) { if (!fs.existsSync(dirPath)) { return []; @@ -1506,9 +1671,22 @@ function buildCandidateAnalysis({ hostConfig, registry, leasesByAlias, pinsByAli function sortCandidates(candidates) { return [...candidates].sort((left, right) => { - const leaseComparison = compareNullableTimestamps(left.registryEntry.lastLeaseStartedAt, right.registryEntry.lastLeaseStartedAt); - if (leaseComparison !== 0) { - return leaseComparison; + const tier = (candidate) => { + if (candidate.pin) { + return 0; + } + return candidate.registryEntry.powerState === "booted" ? 1 : 2; + }; + const tierComparison = tier(left) - tier(right); + if (tierComparison !== 0) { + return tierComparison; + } + const releaseComparison = compareNullableTimestamps( + right.registryEntry.lastLeaseReleasedAt, + left.registryEntry.lastLeaseReleasedAt, + ); + if (releaseComparison !== 0) { + return releaseComparison; } return left.index - right.index; }); @@ -1758,12 +1936,14 @@ function createAppSnapshot(paths, state, { eventLimit = 50, timestamp } = {}) { aliasSnapshot(hostAlias, state.registry.aliases[hostAlias.alias], state.leasesByAlias.get(hostAlias.alias), state.pinsByAlias.get(hostAlias.alias))); const recentEvents = [...recentEventPayload.events] .sort((left, right) => sortByDescendingTimestamp(left, right, (event) => event.timestamp)); + const idle = buildIdleSummary(paths, state, timestamp); return { activeLeases, generatedAt: timestamp, hostConfigPath: paths.hostConfigPath, hostId: state.hostConfig.hostId, + idle, ok: true, overview: { leaseSaturation: simulators.length === 0 @@ -2589,6 +2769,7 @@ function assertBrokerPathsDistinct(paths) { brokerPathEntry("stateRoot", paths.stateRoot, { kind: "root" }), brokerPathEntry("appSnapshotPath", paths.appSnapshotPath), brokerPathEntry("knownProjectsPath", paths.knownProjectsPath), + brokerPathEntry("idlePolicyPath", paths.idlePolicyPath), brokerPathEntry("registryPath", paths.registryPath), brokerPathEntry("eventsPath", paths.eventsPath), brokerPathEntry("serviceLogPath", paths.serviceLogPath), @@ -3211,6 +3392,7 @@ export function resolveBrokerPaths({ evidenceDir: path.join(resolvedStateRoot, "evidence"), eventsPath: path.join(resolvedStateRoot, "events.ndjson"), hostConfigPath: resolvedHostConfigPath, + idlePolicyPath: path.join(resolvedStateRoot, "idle-policy.json"), knownProjectsPath: path.join(resolvedStateRoot, "known-projects.json"), leaseLockDir: path.join(resolvedStateRoot, "locks", "lease-mutation.lock"), leaseLockOwnerPath: path.join(resolvedStateRoot, "locks", "lease-mutation.lock", "owner.json"), @@ -3694,6 +3876,288 @@ export function writeAppSnapshotArtifactUnderMutationLock(paths, options = {}) { }); } +export function idlePolicyConfiguredBroker(paths) { + return readIdlePolicy(paths) !== null; +} + +export function enableIdlePolicyBroker(paths, options = {}) { + const timestamp = nowIso(options.now); + const actorId = requireHumanIdleActor(options, "Idle policy enable"); + const graceSeconds = requireBoundedInteger( + options.graceSeconds, + "grace-seconds", + MIN_IDLE_GRACE_SECONDS, + MAX_IDLE_GRACE_SECONDS, + ); + return withLeaseMutationLock(paths, () => { + ensureStatePaths(paths); + writeJsonAtomicRestricted(paths.idlePolicyPath, { + graceSeconds, + version: IDLE_POLICY_VERSION, + }); + appendEventRecord(paths, "idle.policy.enabled", { + alias: null, + actorType: "human", + jobId: null, + leaseId: null, + payload: { + actorId, + graceSeconds, + }, + projectId: null, + purposeId: null, + }, timestamp); + return { + command: "idle.enable", + configured: true, + graceSeconds, + ok: true, + schemaVersion: IDLE_SCHEMA_VERSION, + }; + }, { + now: timestamp, + processExists: options.processExists, + processSampler: options.processSampler, + timeoutMs: options.leaseLockTimeoutMilliseconds ?? DEFAULT_LOCK_TIMEOUT_MS, + }); +} + +export function disableIdlePolicyBroker(paths, options = {}) { + const timestamp = nowIso(options.now); + const actorId = requireHumanIdleActor(options, "Idle policy disable"); + return withLeaseMutationLock(paths, () => { + const unchanged = !fs.existsSync(paths.idlePolicyPath); + removeIfExists(paths.idlePolicyPath); + appendEventRecord(paths, "idle.policy.disabled", { + alias: null, + actorType: "human", + jobId: null, + leaseId: null, + payload: { + actorId, + unchanged, + }, + projectId: null, + purposeId: null, + }, timestamp); + return { + command: "idle.disable", + configured: false, + graceSeconds: null, + ok: true, + schemaVersion: IDLE_SCHEMA_VERSION, + unchanged, + }; + }, { + now: timestamp, + processExists: options.processExists, + processSampler: options.processSampler, + timeoutMs: options.leaseLockTimeoutMilliseconds ?? DEFAULT_LOCK_TIMEOUT_MS, + }); +} + +export function idleStatusBroker(paths, options = {}) { + const timestamp = nowIso(options.now); + return withLeaseMutationLock(paths, () => { + const state = loadBrokerState(paths, stateLoadOptions(options, timestamp)); + return { + command: "idle.status", + ok: true, + schemaVersion: IDLE_SCHEMA_VERSION, + ...buildIdleSummary(paths, state, timestamp), + }; + }, { + now: timestamp, + processExists: options.processExists, + processSampler: options.processSampler, + timeoutMs: options.leaseLockTimeoutMilliseconds ?? DEFAULT_LOCK_TIMEOUT_MS, + }); +} + +function idleCleanupStatus(shutdownCount, failureCount) { + if (failureCount > 0) { + return "repair_needed"; + } + return shutdownCount > 0 ? "success" : "no_changes"; +} + +function performIdleShutdowns(paths, state, candidates, options, timestamp, { command, eventType, source }) { + const adapter = resolveLifecycleAdapter(options); + let shutdownCount = 0; + let failureCount = 0; + for (const hostAlias of candidates) { + const registryEntry = state.registry.aliases[hostAlias.alias]; + try { + invokeLifecycleAdapter("shutdown", adapter, { + action: "shutdown", + alias: hostAlias.alias, + hostAlias, + paths, + requester: { + actorId: options.actorId ?? "idle-policy", + actorType: options.actorType ?? "system", + jobId: null, + }, + state, + timestamp, + }); + registryEntry.powerState = "shutdown"; + registryEntry.lastShutdownAt = timestamp; + registryEntry.updatedAt = timestamp; + shutdownCount += 1; + appendEventRecord(paths, "idle.simulator.shutdown", { + alias: hostAlias.alias, + actorType: options.actorType ?? "system", + jobId: null, + leaseId: null, + payload: { reasonCode: "idle-grace-expired", source }, + projectId: null, + purposeId: null, + }, timestamp); + } catch { + registryEntry.health = "repair-needed"; + registryEntry.driftReason = "idle-shutdown-failed"; + registryEntry.updatedAt = timestamp; + state.registry.updatedAt = timestamp; + failureCount += 1; + writeRegistry(paths, state.registry); + appendEventRecord(paths, "idle.simulator.shutdown_failed", { + alias: hostAlias.alias, + actorType: options.actorType ?? "system", + jobId: null, + leaseId: null, + payload: { reasonCode: "idle-shutdown-failed", source }, + projectId: null, + purposeId: null, + }, timestamp); + } + } + + const lastCleanupResult = { + completedAt: timestamp, + eligibleCount: candidates.length, + failureCount, + shutdownCount, + source, + status: idleCleanupStatus(shutdownCount, failureCount), + }; + if (candidates.length > 0 || options.persistNoChanges !== false) { + state.registry.idle.lastCleanupResult = lastCleanupResult; + state.registry.updatedAt = timestamp; + writeRegistry(paths, state.registry); + appendEventRecord(paths, eventType, { + alias: null, + actorType: options.actorType ?? "system", + jobId: null, + leaseId: null, + payload: { + eligibleCount: candidates.length, + failureCount, + shutdownCount, + source, + status: lastCleanupResult.status, + }, + projectId: null, + purposeId: null, + }, timestamp); + } + return { + command, + eligibleCount: candidates.length, + failureCount, + lastCleanupResult, + ok: true, + schemaVersion: IDLE_SCHEMA_VERSION, + shutdownCount, + status: lastCleanupResult.status, + }; +} + +export function reconcileIdleBroker(paths, options = {}) { + const timestamp = nowIso(options.now); + return withLeaseMutationLock(paths, () => { + const policy = readIdlePolicy(paths); + if (!policy) { + return { + command: "idle.reconcile", + configured: false, + eligibleCount: 0, + failureCount: 0, + ok: true, + schemaVersion: IDLE_SCHEMA_VERSION, + shutdownCount: 0, + status: "not_configured", + }; + } + const state = loadBrokerState(paths, stateLoadOptions(options, timestamp)); + const candidates = idleEligibleCandidates(state, policy, timestamp); + return { + configured: true, + graceSeconds: policy.graceSeconds, + ...performIdleShutdowns(paths, state, candidates, options, timestamp, { + command: "idle.reconcile", + eventType: "idle.reconciled", + source: options.source ?? "manual-reconcile", + }), + }; + }, { + now: timestamp, + processExists: options.processExists, + processSampler: options.processSampler, + timeoutMs: options.leaseLockTimeoutMilliseconds ?? DEFAULT_LOCK_TIMEOUT_MS, + }); +} + +export function cleanupIdleBroker(paths, options = {}) { + const timestamp = nowIso(options.now); + if (options.apply !== true) { + return withLeaseMutationLock(paths, () => { + const state = loadBrokerState(paths, stateLoadOptions(options, timestamp)); + return idleCleanupPlan(state).publicPlan; + }, { + now: timestamp, + processExists: options.processExists, + processSampler: options.processSampler, + timeoutMs: options.leaseLockTimeoutMilliseconds ?? DEFAULT_LOCK_TIMEOUT_MS, + }); + } + + const actorId = requireHumanIdleActor(options, "Idle cleanup apply"); + if (typeof options.confirmPlanId !== "string" || options.confirmPlanId.trim() === "") { + throw new BrokerError("Idle cleanup apply requires --confirm .", { + reasonCode: "idle-confirmation-required", + }); + } + return withLeaseMutationLock(paths, () => { + const state = loadBrokerState(paths, stateLoadOptions(options, timestamp)); + const plan = idleCleanupPlan(state); + if (options.confirmPlanId !== plan.publicPlan.planId) { + throw new BrokerError("Idle cleanup confirmation is stale; rerun preview and confirm the current plan.", { + reasonCode: "idle-plan-stale", + }); + } + const result = performIdleShutdowns(paths, state, plan.candidates, { + ...options, + actorId, + actorType: "human", + }, timestamp, { + command: "idle.cleanup", + eventType: "idle.cleanup.applied", + source: "confirmed-cleanup", + }); + return { + ...result, + mode: "apply", + planId: plan.publicPlan.planId, + }; + }, { + now: timestamp, + processExists: options.processExists, + processSampler: options.processSampler, + timeoutMs: options.leaseLockTimeoutMilliseconds ?? DEFAULT_LOCK_TIMEOUT_MS, + }); +} + export function explainLeaseBroker(paths, options = {}) { const timestamp = nowIso(options.now); return withLeaseMutationLock(paths, () => { @@ -5100,12 +5564,12 @@ function acquireSelection(paths, options = {}) { }; } -function rollbackAcquireLease(paths, state, leaseRecord, timestamp, error) { +function rollbackAcquireLease(paths, state, leaseRecord, timestamp, reasonCode) { removeLeaseRecordFiles(paths, leaseRecord); const registryEntry = state.registry.aliases[leaseRecord.alias]; registryEntry.activeLeaseId = null; registryEntry.health = "repair-needed"; - registryEntry.driftReason = error?.message ?? "reset-on-acquire-failed"; + registryEntry.driftReason = reasonCode; registryEntry.updatedAt = timestamp; state.registry.updatedAt = timestamp; writeRegistry(paths, state.registry); @@ -5139,6 +5603,50 @@ function maybeResetLeaseOnAcquire(paths, state, leaseRecord, options = {}) { writeRegistry(paths, state.registry); } +function maybeBootLeaseOnAcquire(paths, state, leaseRecord, options = {}) { + const registryEntry = state.registry.aliases[leaseRecord.alias]; + if (registryEntry.powerState === "booted") { + return false; + } + const hostAlias = findHostAliasOrThrow(state, leaseRecord.alias); + const adapter = resolveLifecycleAdapter(options); + invokeLifecycleAdapter("boot", adapter, { + action: "boot", + alias: leaseRecord.alias, + hostAlias, + paths, + requester: { + actorId: leaseRecord.actorId, + actorType: leaseRecord.actorType, + jobId: leaseRecord.jobId, + }, + state, + timestamp: leaseRecord.startedAt, + }); + registryEntry.powerState = "booted"; + registryEntry.health = "healthy"; + registryEntry.driftReason = null; + registryEntry.lastBootedAt = leaseRecord.startedAt; + registryEntry.updatedAt = leaseRecord.startedAt; + state.registry.updatedAt = leaseRecord.startedAt; + writeRegistry(paths, state.registry); + appendEventRecord(paths, "simulator.booted", { + alias: leaseRecord.alias, + actorType: leaseRecord.actorType, + jobId: leaseRecord.jobId, + leaseId: leaseRecord.leaseId, + payload: { + action: "boot", + actorId: leaseRecord.actorId, + implicitOnAcquire: true, + powerState: "booted", + }, + projectId: leaseRecord.projectId, + purposeId: leaseRecord.purposeId, + }, leaseRecord.startedAt); + return true; +} + export function acquireLeaseBroker(paths, options = {}) { const timestamp = nowIso(options.now); return withLeaseMutationLock(paths, () => { @@ -5220,7 +5728,7 @@ export function acquireLeaseBroker(paths, options = {}) { try { maybeResetLeaseOnAcquire(paths, selection.state, leaseRecord, options); } catch (error) { - rollbackAcquireLease(paths, selection.state, leaseRecord, selection.timestamp, error); + rollbackAcquireLease(paths, selection.state, leaseRecord, selection.timestamp, "reset-on-acquire-failed"); throw new BrokerError(`Failed to reset alias ${leaseRecord.alias} before handing out the lease.`, { alias: leaseRecord.alias, cause: error?.message ?? String(error), @@ -5230,6 +5738,18 @@ export function acquireLeaseBroker(paths, options = {}) { simulatorId: leaseRecord.simulatorId, }); } + try { + maybeBootLeaseOnAcquire(paths, selection.state, leaseRecord, options); + } catch (error) { + rollbackAcquireLease(paths, selection.state, leaseRecord, selection.timestamp, "boot-on-acquire-failed"); + throw new BrokerError(`Failed to boot alias ${leaseRecord.alias} before handing out the lease.`, { + alias: leaseRecord.alias, + cause: error?.message ?? String(error), + leaseId: leaseRecord.leaseId, + reasonCode: "boot-on-acquire-failed", + simulatorId: leaseRecord.simulatorId, + }); + } appendEventRecord(paths, "lease.acquired", { alias: leaseRecord.alias, actorType, diff --git a/broker-core/test/broker-core.test.mjs b/broker-core/test/broker-core.test.mjs index 0d28328..7794746 100644 --- a/broker-core/test/broker-core.test.mjs +++ b/broker-core/test/broker-core.test.mjs @@ -11,16 +11,21 @@ import { bootSimulatorBroker, checkCapacityBroker, clearPinBroker, + cleanupIdleBroker, containLeaseBroker, createPinBroker, + disableIdlePolicyBroker, doctorBroker, + enableIdlePolicyBroker, eraseSimulatorBroker, explainLeaseBroker, forgetKnownProjectBroker, hostStatusBroker, initBroker, initProjectBroker, + idleStatusBroker, readEventsBroker, + reconcileIdleBroker, registerLeaseProcessBroker, reconcileCapacityBroker, repairSimulatorBroker, @@ -3500,7 +3505,7 @@ test("app snapshot preserves explicitly skipped leases with dead owners", () => assert.equal(snapshot.simulators.find((simulator) => simulator.alias === lease.alias)?.activeLeaseId, lease.leaseId); }); -test("acquire and release rotate fairly across matching aliases", () => { +test("acquire and release reuse the most recently released warm alias", () => { const paths = makePaths(); writeBaseHostConfig(paths.hostConfigPath); writeBaseProject(paths.projectFilePath); @@ -3530,10 +3535,54 @@ test("acquire and release rotate fairly across matching aliases", () => { simctlAdapter: paths.simctl.adapter, }).lease; - assert.notEqual(firstLease.alias, secondLease.alias); + assert.equal(firstLease.alias, secondLease.alias); + const fixture = readJson(paths.simctl.statePath); + assert.equal(fixture.devices.find((device) => device.udid === secondLease.simulatorId).state, "Booted"); }); -test("stale leases are reclaimed and fairness still prefers the least recently used alias", () => { +test("repeated build leases reuse the same warm build alias", () => { + const paths = makePaths(); + const resolvedPaths = brokerPaths(paths); + initBroker(resolvedPaths, { + bootstrapConfig: true, + hostId: "bootstrap-host", + processExists: () => true, + simctlAdapter: paths.simctl.adapter, + }); + initProjectBroker(resolvedPaths, { + projectId: "build-reuse-demo", + projectName: "Build Reuse Demo", + repoRoot: path.join(paths.root, "repo"), + }); + const firstLease = acquireLeaseBroker(resolvedPaths, { + actorId: "build-agent-1", + actorType: "agent", + ownerPid: process.pid, + processExists: (pid) => pid === process.pid, + purposeId: "agent-build-test", + simctlAdapter: paths.simctl.adapter, + }).lease; + releaseLeaseBroker(resolvedPaths, { + leaseId: firstLease.leaseId, + processExists: (pid) => pid === process.pid, + simctlAdapter: paths.simctl.adapter, + }); + + const secondLease = acquireLeaseBroker(resolvedPaths, { + actorId: "build-agent-2", + actorType: "agent", + ownerPid: process.pid, + processExists: (pid) => pid === process.pid, + purposeId: "agent-build-test", + simctlAdapter: paths.simctl.adapter, + }).lease; + + assert.equal(firstLease.alias, secondLease.alias); + const fixture = readJson(paths.simctl.statePath); + assert.equal(fixture.devices.find((device) => device.udid === secondLease.simulatorId).state, "Booted"); +}); + +test("stale lease recovery restarts grace and reuses the reclaimed warm alias", () => { const paths = makePaths(); writeBaseHostConfig(paths.hostConfigPath); writeBaseProject(paths.projectFilePath); @@ -3561,13 +3610,345 @@ test("stale leases are reclaimed and fairness still prefers the least recently u simctlAdapter: paths.simctl.adapter, }).lease; - assert.notEqual(staleLease.alias, freshLease.alias); + assert.equal(staleLease.alias, freshLease.alias); assert.equal(fs.existsSync(path.join(paths.stateRoot, "leases", `${staleLease.leaseId}.json`)), false); assert.equal(fs.existsSync(staleLeaseArtifactPath), false); const events = readEventsBroker(resolvedPaths).events; assert.ok(events.some((event) => event.type === "lease.reclaimed" && event.leaseId === staleLease.leaseId)); }); +test("acquire marks an alias repair-needed and rolls back when boot fails", () => { + const paths = makePaths(); + writeBaseHostConfig(paths.hostConfigPath); + writeBaseProject(paths.projectFilePath); + const resolvedPaths = brokerPaths(paths); + const failingAdapter = { + ...paths.simctl.adapter, + bootDevice() { + throw new Error("private runtime detail"); + }, + }; + initBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + + assert.throws(() => { + acquireLeaseBroker(resolvedPaths, { + actorId: "agent-boot", + actorType: "agent", + ownerPid: process.pid, + processExists: (pid) => pid === process.pid, + purposeId: "agent-ui-session", + simctlAdapter: failingAdapter, + }); + }, (error) => error.payload?.reasonCode === "boot-on-acquire-failed" && error.exitCode === 4); + + assert.equal(fs.readdirSync(resolvedPaths.leasesDir).length, 0); + const registry = readJson(resolvedPaths.registryPath); + assert.equal(registry.aliases["ui-1"].health, "repair-needed"); + assert.equal(registry.aliases["ui-1"].driftReason, "boot-on-acquire-failed"); +}); + +test("shutdown candidates prefer the most recently released compatible alias", () => { + const paths = makePaths(); + writeBaseHostConfig(paths.hostConfigPath); + writeBaseProject(paths.projectFilePath); + const resolvedPaths = brokerPaths(paths); + initBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + + const first = acquireLeaseBroker(resolvedPaths, { + actorId: "agent-first", + actorType: "agent", + now: "2026-01-01T00:00:00.000Z", + ownerPid: process.pid, + processExists: (pid) => pid === process.pid, + purposeId: "agent-ui-session", + simctlAdapter: paths.simctl.adapter, + }).lease; + const second = acquireLeaseBroker(resolvedPaths, { + actorId: "agent-second", + actorType: "agent", + now: "2026-01-01T00:00:01.000Z", + ownerPid: process.pid, + processExists: (pid) => pid === process.pid, + purposeId: "agent-ui-session", + simctlAdapter: paths.simctl.adapter, + }).lease; + releaseLeaseBroker(resolvedPaths, { + leaseId: first.leaseId, + now: "2026-01-01T00:00:02.000Z", + processExists: (pid) => pid === process.pid, + simctlAdapter: paths.simctl.adapter, + }); + releaseLeaseBroker(resolvedPaths, { + leaseId: second.leaseId, + now: "2026-01-01T00:00:03.000Z", + processExists: (pid) => pid === process.pid, + simctlAdapter: paths.simctl.adapter, + }); + const fixture = readJson(paths.simctl.statePath); + fixture.devices.find((device) => device.udid === first.simulatorId).state = "Shutdown"; + fixture.devices.find((device) => device.udid === second.simulatorId).state = "Shutdown"; + writeJson(paths.simctl.statePath, fixture); + + const selected = acquireLeaseBroker(resolvedPaths, { + actorId: "agent-third", + actorType: "agent", + now: "2026-01-01T00:00:04.000Z", + ownerPid: process.pid, + processExists: (pid) => pid === process.pid, + purposeId: "agent-ui-session", + simctlAdapter: paths.simctl.adapter, + }).lease; + assert.equal(selected.alias, second.alias); +}); + +test("idle policy is absent by default, strictly bounded, and stored outside project state", () => { + const paths = makePaths(); + writeBaseHostConfig(paths.hostConfigPath); + writeBaseProject(paths.projectFilePath); + const resolvedPaths = brokerPaths(paths); + initBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + + const initial = idleStatusBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + assert.equal(initial.configured, false); + assert.equal(initial.graceSeconds, null); + assert.equal(fs.existsSync(resolvedPaths.idlePolicyPath), false); + assert.throws(() => { + enableIdlePolicyBroker(resolvedPaths, { + actorId: "operator", + actorType: "human", + graceSeconds: 59, + }); + }, (error) => error.payload?.reasonCode === "invalid-flag"); + + enableIdlePolicyBroker(resolvedPaths, { + actorId: "operator", + actorType: "human", + graceSeconds: 60, + }); + assert.deepEqual(readJson(resolvedPaths.idlePolicyPath), { + graceSeconds: 60, + version: 1, + }); + const configured = idleStatusBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + assert.equal(configured.configured, true); + assert.equal(configured.graceSeconds, 60); + assert.equal(appSnapshotBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })).idle.configured, true); + + disableIdlePolicyBroker(resolvedPaths, { + actorId: "operator", + actorType: "human", + }); + assert.equal(fs.existsSync(resolvedPaths.idlePolicyPath), false); +}); + +test("stale lease recovery starts a fresh idle grace period", () => { + const paths = makePaths(); + writeBaseHostConfig(paths.hostConfigPath); + writeBaseProject(paths.projectFilePath); + const resolvedPaths = brokerPaths(paths); + initBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + enableIdlePolicyBroker(resolvedPaths, { + actorId: "operator", + actorType: "human", + graceSeconds: 60, + }); + const lease = acquireLeaseBroker(resolvedPaths, { + actorId: "dead-agent", + actorType: "agent", + now: "2026-01-01T00:00:00.000Z", + ownerPid: 424242, + processExists: () => true, + purposeId: "agent-ui-session", + simctlAdapter: paths.simctl.adapter, + }).lease; + + const recovered = reconcileIdleBroker(resolvedPaths, { + now: "2026-01-01T01:00:00.000Z", + processExists: () => false, + simctlAdapter: paths.simctl.adapter, + }); + assert.equal(recovered.eligibleCount, 0); + assert.equal(readJson(resolvedPaths.registryPath).aliases[lease.alias].lastLeaseReleasedAt, "2026-01-01T01:00:00.000Z"); + assert.equal(readJson(paths.simctl.statePath).devices.find((device) => device.udid === lease.simulatorId).state, "Booted"); + + const atBoundary = reconcileIdleBroker(resolvedPaths, { + now: "2026-01-01T01:01:00.000Z", + processExists: () => false, + simctlAdapter: paths.simctl.adapter, + }); + assert.equal(atBoundary.shutdownCount, 1); + assert.equal(readJson(paths.simctl.statePath).devices.find((device) => device.udid === lease.simulatorId).state, "Shutdown"); +}); + +test("idle reconciliation waits for the broker mutation lock", () => { + const paths = makePaths(); + writeBaseHostConfig(paths.hostConfigPath); + writeBaseProject(paths.projectFilePath); + const resolvedPaths = brokerPaths(paths); + initBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + enableIdlePolicyBroker(resolvedPaths, { + actorId: "operator", + actorType: "human", + graceSeconds: 60, + }); + fs.mkdirSync(resolvedPaths.leaseLockDir, { recursive: true }); + writeJson(resolvedPaths.leaseLockOwnerPath, { + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.throws(() => { + reconcileIdleBroker(resolvedPaths, { + leaseLockTimeoutMilliseconds: 1, + processExists: (pid) => pid === process.pid, + simctlAdapter: paths.simctl.adapter, + }); + }, (error) => error.payload?.reasonCode === "alias-busy"); +}); + +test("idle reconciliation honors the grace boundary and all protected simulator classes", () => { + const paths = makePaths(); + writeBaseHostConfig(paths.hostConfigPath); + writeBaseProject(paths.projectFilePath); + const resolvedPaths = brokerPaths(paths); + initBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + enableIdlePolicyBroker(resolvedPaths, { + actorId: "operator", + actorType: "human", + graceSeconds: 60, + }); + const lease = acquireLeaseBroker(resolvedPaths, { + actorId: "agent-boundary", + actorType: "agent", + now: "2026-01-01T00:00:00.000Z", + ownerPid: process.pid, + processExists: (pid) => pid === process.pid, + purposeId: "agent-ui-session", + simctlAdapter: paths.simctl.adapter, + }).lease; + releaseLeaseBroker(resolvedPaths, { + leaseId: lease.leaseId, + now: "2026-01-01T00:00:00.000Z", + processExists: (pid) => pid === process.pid, + simctlAdapter: paths.simctl.adapter, + }); + + const beforeBoundary = reconcileIdleBroker(resolvedPaths, { + now: "2026-01-01T00:00:59.999Z", + processExists: (pid) => pid === process.pid, + simctlAdapter: paths.simctl.adapter, + }); + assert.equal(beforeBoundary.eligibleCount, 0); + assert.equal(readJson(paths.simctl.statePath).devices.find((device) => device.udid === lease.simulatorId).state, "Booted"); + + const atBoundary = reconcileIdleBroker(resolvedPaths, { + now: "2026-01-01T00:01:00.000Z", + processExists: (pid) => pid === process.pid, + simctlAdapter: paths.simctl.adapter, + }); + assert.equal(atBoundary.eligibleCount, 1); + assert.equal(atBoundary.shutdownCount, 1); + assert.equal(readJson(paths.simctl.statePath).devices.find((device) => device.udid === lease.simulatorId).state, "Shutdown"); + + const active = acquireLeaseBroker(resolvedPaths, { + actorId: "agent-active", + actorType: "agent", + now: "2026-01-01T00:02:00.000Z", + ownerPid: process.pid, + processExists: (pid) => pid === process.pid, + purposeId: "agent-ui-session", + simctlAdapter: paths.simctl.adapter, + }).lease; + const pin = createPinBroker(resolvedPaths, { + actorId: "operator", + actorType: "human", + alias: "ui-2", + purposeId: "agent-ui-session", + simctlAdapter: paths.simctl.adapter, + }).pin; + const fixture = readJson(paths.simctl.statePath); + fixture.devices.find((device) => device.udid === "SIM-MANUAL-1").state = "Booted"; + fixture.devices.find((device) => device.udid === "SIM-UI-2").state = "Booted"; + fixture.devices.find((device) => device.udid === "SIM-IPAD-1").state = "Booted"; + fixture.devices.push(createDeviceRecord({ + deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-16", + name: "External", + state: "Booted", + udid: "SIM-EXTERNAL-1", + })); + writeJson(paths.simctl.statePath, fixture); + const registry = readJson(resolvedPaths.registryPath); + registry.aliases["ui-2"].lastLeaseReleasedAt = "2025-12-31T00:00:00.000Z"; + registry.aliases["ipad-1"].health = "repair-needed"; + registry.aliases["ipad-1"].driftReason = "test-unhealthy"; + registry.aliases["ipad-1"].lastLeaseReleasedAt = "2025-12-31T00:00:00.000Z"; + writeJson(resolvedPaths.registryPath, registry); + + const protectedResult = reconcileIdleBroker(resolvedPaths, { + now: "2026-01-01T01:00:00.000Z", + processExists: (pid) => pid === process.pid, + simctlAdapter: paths.simctl.adapter, + }); + assert.equal(protectedResult.eligibleCount, 0); + assert.equal(readJson(paths.simctl.statePath).devices.find((device) => device.udid === active.simulatorId).state, "Booted"); + assert.equal(readJson(paths.simctl.statePath).devices.find((device) => device.udid === pin.alias.replace("ui-2", "SIM-UI-2")).state, "Booted"); + assert.equal(readJson(paths.simctl.statePath).devices.find((device) => device.udid === "SIM-MANUAL-1").state, "Booted"); + assert.equal(readJson(paths.simctl.statePath).devices.find((device) => device.udid === "SIM-EXTERNAL-1").state, "Booted"); +}); + +test("confirmed idle cleanup is count-only, plan-bound, and records shutdown failures once", () => { + const paths = makePaths(); + writeBaseHostConfig(paths.hostConfigPath); + writeBaseProject(paths.projectFilePath); + const fixture = readJson(paths.simctl.statePath); + for (const device of fixture.devices) { + device.state = "Booted"; + } + writeJson(paths.simctl.statePath, fixture); + const resolvedPaths = brokerPaths(paths); + initBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + + const preview = cleanupIdleBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + assert.equal(preview.eligibleCount, 3); + assert.equal(preview.status, "changes_required"); + const result = cleanupIdleBroker(resolvedPaths, { + actorId: "operator", + actorType: "human", + apply: true, + confirmPlanId: preview.planId, + lifecycleAdapter: { + shutdown({ hostAlias }) { + if (hostAlias.alias === "ui-2") { + throw new Error("private runtime detail"); + } + paths.simctl.adapter.shutdownDevice(hostAlias.simulatorId); + }, + }, + processExists: () => true, + simctlAdapter: paths.simctl.adapter, + }); + assert.equal(result.eligibleCount, 3); + assert.equal(result.shutdownCount, 2); + assert.equal(result.failureCount, 1); + assert.equal(result.status, "repair_needed"); + const serialized = JSON.stringify(result); + assert.equal(serialized.includes("ui-2"), false); + assert.equal(serialized.includes("SIM-UI-2"), false); + assert.equal(serialized.includes("private runtime detail"), false); + assert.equal(readJson(resolvedPaths.registryPath).aliases["ui-2"].driftReason, "idle-shutdown-failed"); + + assert.throws(() => { + cleanupIdleBroker(resolvedPaths, { + actorId: "operator", + actorType: "human", + apply: true, + confirmPlanId: preview.planId, + processExists: () => true, + simctlAdapter: paths.simctl.adapter, + }); + }, (error) => error.payload?.reasonCode === "idle-plan-stale" && error.exitCode === 5); +}); + test("releasing a healthy lease is not blocked by unrelated stale containment failure", () => { const paths = makePaths(); writeBaseHostConfig(paths.hostConfigPath); diff --git a/client/README.md b/client/README.md index 3665491..38d99a6 100644 --- a/client/README.md +++ b/client/README.md @@ -13,6 +13,11 @@ Current command families: - `host status` - `capacity check` - `capacity reconcile` +- `idle status` +- `idle enable` +- `idle disable` +- `idle reconcile` +- `idle cleanup` - `project init` - `project validate` - `project show` @@ -37,3 +42,11 @@ Onboarding helpers now included: - `capacity check` reports whether repo purposes have usable broker capacity - `capacity reconcile` previews missing additive capacity and applies only when a human operator confirms the exact current plan ID +- `idle enable` requires a human operator and an explicit duration from 60 + through 86400 seconds; no duration is configured by default +- `idle cleanup` returns a count-only preview and applies only when a human + confirms the exact current plan ID + +Normal policy-enabled lease acquisition starts `brokerd` lazily when needed so +scheduled reconciliation remains active. Explicit local-only mode does not +schedule reconciliation and reports that limitation. diff --git a/client/bin/simbroker.mjs b/client/bin/simbroker.mjs index 3ebd884..a4c9222 100755 --- a/client/bin/simbroker.mjs +++ b/client/bin/simbroker.mjs @@ -7,7 +7,7 @@ import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { BROKER_EXIT_CODES, INTERNAL_ERROR_REASON_CODE, resolveBrokerExitCode } from "../../broker-core/error-contract.mjs"; -import { BrokerError, resolveBrokerPaths } from "../../broker-core/index.mjs"; +import { BrokerError, idlePolicyConfiguredBroker, resolveBrokerPaths } from "../../broker-core/index.mjs"; import { createCommandRequest, executeBrokerCommand, @@ -26,7 +26,6 @@ import { } from "../service/service-client.mjs"; const BROKERD_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "brokerd.mjs"); -const SERVICE_START_TIMEOUT_MS = serviceStartupTimeoutMs(); const SERVICE_CONTROL_FLAGS = new Set([ "help", "host-config", @@ -255,7 +254,7 @@ async function startService(paths) { child.unref(); fs.closeSync(logFd); - const probe = await waitForService(paths, { timeoutMs: SERVICE_START_TIMEOUT_MS }); + const probe = await waitForService(paths, { timeoutMs: serviceStartupTimeoutMs({ paths }) }); if (!probe) { terminateSpawnedService(child); throw new BrokerError("Broker service failed to start.", { @@ -324,10 +323,24 @@ async function stopService(paths) { async function runServiceAwareRequest(paths, request) { const canUseService = !localOnlyMode(process.env); - const service = canUseService + let service = canUseService ? await probeService(paths, { timeoutMs: serviceCommandTimeoutMs(request) }) : null; + if (!service + && canUseService + && request.group === "lease" + && request.command === "acquire" + && idlePolicyConfiguredBroker(paths)) { + await startService(paths); + service = await probeService(paths, { timeoutMs: serviceCommandTimeoutMs(request) }); + if (!service) { + throw new BrokerError("Broker service did not become available for policy-enabled lease acquisition.", { + reasonCode: "service-unavailable", + }); + } + } + if (request.type === "events-stream") { if (service) { assertServiceMatchesPaths(paths, service); @@ -347,10 +360,33 @@ async function runServiceAwareRequest(paths, request) { const payload = await executeServiceCommand(paths, request, { expectedServiceIdentity: service.service, }); - return appendTransport(payload, "service"); + const result = appendTransport(payload, "service"); + const configured = typeof payload.configured === "boolean" + ? payload.configured + : idlePolicyConfiguredBroker(paths); + if (request.group === "idle" + || (request.group === "lease" && request.command === "acquire" && configured)) { + result.scheduler = { + active: configured, + limitation: null, + }; + } + return result; } - return appendTransport(executeBrokerCommand(paths, request), "direct"); + const payload = appendTransport(executeBrokerCommand(paths, request), "direct"); + const configured = request.group === "idle" && typeof payload.configured === "boolean" + ? payload.configured + : idlePolicyConfiguredBroker(paths); + if (request.group === "idle" || (request.group === "lease" && request.command === "acquire" && configured)) { + payload.scheduler = { + active: false, + limitation: localOnlyMode(process.env) + ? "local-only-mode" + : (configured ? "service-not-running" : null), + }; + } + return payload; } async function main() { diff --git a/client/command-dispatch.mjs b/client/command-dispatch.mjs index 5ab27c6..7d7ca03 100644 --- a/client/command-dispatch.mjs +++ b/client/command-dispatch.mjs @@ -9,17 +9,22 @@ import { bootSimulatorBroker, checkCapacityBroker, clearPinBroker, + cleanupIdleBroker, createPinBroker, + disableIdlePolicyBroker, doctorBroker, + enableIdlePolicyBroker, eraseSimulatorBroker, explainLeaseBroker, forgetKnownProjectBroker, hostStatusBroker, initBroker, initProjectBroker, + idleStatusBroker, listSimulatorsBroker, containLeaseBroker, readEventsBroker, + reconcileIdleBroker, registerLeaseProcessBroker, reconcileCapacityBroker, repairSimulatorBroker, @@ -372,6 +377,57 @@ function capacityOptions(paths, flags, { applyAllowed = false } = {}) { }; } +function idleHumanOptions(flags) { + return { + actorId: requireFlag(flags, "actor-id"), + actorType: requireFlag(flags, "actor-type"), + }; +} + +function idleEnableOptions(flags) { + rejectUnknownFlags(flags, new Set([ + "actor-id", + "actor-type", + "grace-seconds", + ...commonRequestFlags(), + ])); + return { + ...idleHumanOptions(flags), + graceSeconds: requirePositiveIntegerFlag(flags, "grace-seconds"), + }; +} + +function idleDisableOptions(flags) { + rejectUnknownFlags(flags, new Set([ + "actor-id", + "actor-type", + ...commonRequestFlags(), + ])); + return idleHumanOptions(flags); +} + +function idleCleanupOptions(flags) { + rejectUnknownFlags(flags, new Set([ + "actor-id", + "actor-type", + "apply", + "confirm", + ...commonRequestFlags(), + ])); + const apply = parseBooleanFlag(flags, "apply"); + if (!apply && (flags.has("confirm") || flags.has("actor-id") || flags.has("actor-type"))) { + throw new BrokerError("Idle cleanup confirmation and actor flags require --apply.", { + reasonCode: "invalid-flag", + }); + } + return { + actorId: flagValue(flags, "actor-id"), + actorType: flagValue(flags, "actor-type"), + apply, + confirmPlanId: flagValue(flags, "confirm"), + }; +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -387,6 +443,18 @@ function helpPayload(group) { group: "capacity", usage: "simbroker capacity ", }, + idle: { + commands: [ + "idle status", + "idle enable --grace-seconds <60-86400> --actor-type human --actor-id ", + "idle disable --actor-type human --actor-id ", + "idle reconcile", + "idle cleanup", + "idle cleanup --apply --confirm --actor-type human --actor-id ", + ], + group: "idle", + usage: "simbroker idle ", + }, host: { commands: [ "host init [--bootstrap-config] [--host-id ] [--ios-version ]", @@ -426,6 +494,7 @@ function helpPayload(group) { "project", "lease", "capacity", + "idle", "events", "pin", "simulators", @@ -460,6 +529,7 @@ export function createCommandRequest(paths, group, command, flags) { case "project:": case "lease:": case "capacity:": + case "idle:": return { group: "help", command: group, @@ -541,6 +611,43 @@ export function createCommandRequest(paths, group, command, flags) { options: capacityOptions(paths, flags, { applyAllowed: true }), type: "command", }; + case "idle:status": + rejectUnknownFlags(flags, new Set(commonRequestFlags())); + return { + group: "idle", + command: "status", + options: {}, + type: "command", + }; + case "idle:enable": + return { + group: "idle", + command: "enable", + options: idleEnableOptions(flags), + type: "command", + }; + case "idle:disable": + return { + group: "idle", + command: "disable", + options: idleDisableOptions(flags), + type: "command", + }; + case "idle:reconcile": + rejectUnknownFlags(flags, new Set(commonRequestFlags())); + return { + group: "idle", + command: "reconcile", + options: {}, + type: "command", + }; + case "idle:cleanup": + return { + group: "idle", + command: "cleanup", + options: idleCleanupOptions(flags), + type: "command", + }; case "simulators:list": return { group: "simulators", @@ -867,6 +974,7 @@ export function executeBrokerCommand(paths, request) { case "help:project": case "help:lease": case "help:capacity": + case "help:idle": return helpPayload(request.command); case "doctor:status": payload = doctorBroker(paths, options); @@ -895,6 +1003,21 @@ export function executeBrokerCommand(paths, request) { case "capacity:reconcile": payload = reconcileCapacityBroker(paths, options); break; + case "idle:status": + payload = idleStatusBroker(paths, options); + break; + case "idle:enable": + payload = enableIdlePolicyBroker(paths, options); + break; + case "idle:disable": + payload = disableIdlePolicyBroker(paths, options); + break; + case "idle:reconcile": + payload = reconcileIdleBroker(paths, options); + break; + case "idle:cleanup": + payload = cleanupIdleBroker(paths, options); + break; case "simulators:list": payload = listSimulatorsBroker(paths, options); break; @@ -965,6 +1088,16 @@ export function executeBrokerCommand(paths, request) { try { writeAppSnapshotArtifactUnderMutationLock(paths, snapshotOptions); } catch (error) { + if (request.group === "idle") { + payload = { + ...payload, + snapshotRefresh: { + ok: false, + reasonCode: error?.payload?.reasonCode ?? error?.reasonCode ?? "snapshot-refresh-failed", + }, + }; + return payload; + } const contractError = contractSnapshotRefreshError(error); if (contractError) { throw contractError; diff --git a/client/public-surface.mjs b/client/public-surface.mjs new file mode 100644 index 0000000..762919d --- /dev/null +++ b/client/public-surface.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const LOCAL_DENYLIST_NAME = ".public-safety.local"; +const PROHIBITED_TRACKED_BASENAMES = new Set([ + ".DS_Store", + LOCAL_DENYLIST_NAME, + "app-snapshot.json", + "brokerd.json", + "brokerd.log", + "host-config.json", + "idle-policy.json", + "registry.json", +]); + +function lineNumberForOffset(text, offset) { + let line = 1; + for (let index = 0; index < offset; index += 1) { + if (text.charCodeAt(index) === 10) { + line += 1; + } + } + return line; +} + +function localDenylistRules(denylistPath) { + if (!denylistPath || !fs.existsSync(denylistPath)) { + return []; + } + return fs.readFileSync(denylistPath, "utf8") + .split(/\r?\n/u) + .map((value, index) => ({ index: index + 1, value: value.trim() })) + .filter((rule) => rule.value !== "" && !rule.value.startsWith("#")); +} + +function defaultCandidateFiles(root) { + const output = execFileSync("git", [ + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + ], { + cwd: root, + encoding: "utf8", + }); + return output.split("\0").filter(Boolean); +} + +function textFileContent(filePath) { + const content = fs.readFileSync(filePath); + if (content.includes(0)) { + return null; + } + return content.toString("utf8"); +} + +export function scanPublicSurface({ + denylistPath, + files, + homePath = os.homedir(), + root, +} = {}) { + const resolvedRoot = path.resolve(root ?? execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + }).trim()); + const candidateFiles = files ?? defaultCandidateFiles(resolvedRoot); + const resolvedDenylistPath = denylistPath ?? path.join(resolvedRoot, LOCAL_DENYLIST_NAME); + const denylistRules = localDenylistRules(resolvedDenylistPath); + const builtInRules = [ + ...(typeof homePath === "string" && homePath.trim() !== "" + ? [{ label: "local-home-path", value: path.resolve(homePath) }] + : []), + ]; + const issues = []; + + for (const relativeFile of candidateFiles) { + const normalizedRelativeFile = relativeFile.split(path.sep).join("/"); + if (PROHIBITED_TRACKED_BASENAMES.has(path.posix.basename(normalizedRelativeFile))) { + issues.push({ + line: 1, + path: normalizedRelativeFile, + rule: "prohibited-local-artifact", + }); + continue; + } + const absoluteFile = path.resolve(resolvedRoot, relativeFile); + if (!absoluteFile.startsWith(`${resolvedRoot}${path.sep}`) || !fs.existsSync(absoluteFile) || !fs.statSync(absoluteFile).isFile()) { + continue; + } + const text = textFileContent(absoluteFile); + if (text === null) { + continue; + } + for (const rule of builtInRules) { + const offset = text.indexOf(rule.value); + if (offset !== -1) { + issues.push({ + line: lineNumberForOffset(text, offset), + path: normalizedRelativeFile, + rule: rule.label, + }); + } + } + for (const rule of denylistRules) { + const offset = text.indexOf(rule.value); + if (offset !== -1) { + issues.push({ + line: lineNumberForOffset(text, offset), + path: normalizedRelativeFile, + rule: `local-denylist-rule-${rule.index}`, + }); + } + } + } + + return { + filesScanned: candidateFiles.length, + issues, + ok: issues.length === 0, + }; +} + +function runCLI() { + const report = scanPublicSurface(); + if (report.ok) { + process.stdout.write(`Public surface verified (${report.filesScanned} files scanned).\n`); + return; + } + process.stderr.write(`Public surface verification failed with ${report.issues.length} issue(s).\n`); + for (const issue of report.issues) { + process.stderr.write(`${issue.path}:${issue.line} [${issue.rule}]\n`); + } + process.exitCode = 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runCLI(); +} diff --git a/client/service/brokerd.mjs b/client/service/brokerd.mjs index 99fb68c..6841ce5 100644 --- a/client/service/brokerd.mjs +++ b/client/service/brokerd.mjs @@ -16,7 +16,8 @@ import { BrokerError, appSnapshotBroker, readEventsBroker, - writeAppSnapshotArtifact, + reconcileIdleBroker, + writeAppSnapshotArtifactUnderMutationLock, } from "../../broker-core/index.mjs"; import { executeBrokerCommand, streamEventsLocal } from "../command-dispatch.mjs"; import { serviceCommandExecutionTimeoutMs } from "./service-client.mjs"; @@ -24,6 +25,7 @@ import { serviceCommandExecutionTimeoutMs } from "./service-client.mjs"; const SERVICE_REQUEST_BODY_TIMEOUT_MS = 30_000; const SERVICE_COMMAND_BODY_MAX_BYTES = 64 * 1024; const SERVICE_LOCK_OWNER_PID_IDENTITY_TOLERANCE_MS = 15_000; +const IDLE_RECONCILE_INTERVAL_MS = 30_000; function writeJsonAtomic(filePath, payload) { fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); @@ -767,9 +769,20 @@ export async function startBrokerService(paths, options = {}) { transport: "unix-http", }; + const writeSnapshot = options.writeAppSnapshotArtifact ?? writeAppSnapshotArtifactUnderMutationLock; + const reconcileIdle = options.reconcileIdleBroker ?? reconcileIdleBroker; + const runIdleReconciliation = (source) => { + const result = reconcileIdle(paths, { + ...(options.idleReconcileOptions ?? {}), + persistNoChanges: false, + source, + }); + writeSnapshot(paths, options.idleSnapshotOptions ?? {}); + return result; + }; + try { - const writeStartupSnapshot = options.writeAppSnapshotArtifact ?? writeAppSnapshotArtifact; - writeStartupSnapshot(paths); + runIdleReconciliation("service-startup"); } catch (error) { releaseStartupLock(); removeIfExists(paths.serviceMetadataPath); @@ -779,6 +792,8 @@ export async function startBrokerService(paths, options = {}) { let shuttingDown = false; let server = null; + let idleReconcileTimer = null; + let idleReconcileRunning = false; const activeConnections = new Set(); const activeRequests = new Set(); const activeEventStreams = new Set(); @@ -817,6 +832,10 @@ export async function startBrokerService(paths, options = {}) { return; } shuttingDown = true; + if (idleReconcileTimer !== null) { + (options.clearIntervalFn ?? clearInterval)(idleReconcileTimer); + idleReconcileTimer = null; + } removeIfExists(paths.serviceMetadataPath); for (const stream of activeEventStreams) { stream.controller.abort(); @@ -898,7 +917,7 @@ export async function startBrokerService(paths, options = {}) { assertExpectedServiceIdentity(metadata, body.expectedServiceIdentity); assertCommandFreshForDispatch(paths, body); const payload = executeBrokerCommand(paths, body); - const serviceMetadata = body.group === "capacity" ? {} : { servedBy: metadata }; + const serviceMetadata = body.group === "capacity" || body.group === "idle" ? {} : { servedBy: metadata }; sendJson(response, 200, { ok: true, ...payload, @@ -1071,6 +1090,20 @@ export async function startBrokerService(paths, options = {}) { restoreUmask(); fs.chmodSync(paths.serviceSocketPath, 0o600); writeJsonAtomic(paths.serviceMetadataPath, metadata); + idleReconcileTimer = (options.setIntervalFn ?? setInterval)(() => { + if (idleReconcileRunning || shuttingDown) { + return; + } + idleReconcileRunning = true; + try { + runIdleReconciliation("service-timer"); + } catch (error) { + (options.onIdleReconcileError ?? console.error)(error); + } finally { + idleReconcileRunning = false; + } + }, IDLE_RECONCILE_INTERVAL_MS); + idleReconcileTimer?.unref?.(); releaseStartupLock(); resolve({ metadata, diff --git a/client/service/service-client.mjs b/client/service/service-client.mjs index 1f3064e..c314523 100644 --- a/client/service/service-client.mjs +++ b/client/service/service-client.mjs @@ -22,8 +22,8 @@ const SIMCTL_INVENTORY_COMMANDS_PER_STATE_LOAD = 3; const PROCESS_SAMPLER_INVOCATIONS_PER_STATE_LOAD = 1; const SERVICE_STARTUP_LAUNCHER_OVERHEAD_MS = 5_000; const SERVICE_STARTUP_LOCK_PROCESS_SAMPLER_INVOCATIONS = 2; -const SERVICE_STARTUP_SNAPSHOT_STATE_LOADS = 1; -const LEASE_ACQUIRE_RESET_SIMCTL_COMMANDS = 2; +const SERVICE_STARTUP_STATE_LOADS = 2; +const LEASE_ACQUIRE_SIMCTL_COMMANDS = 3; const CAPACITY_APPLY_PLAN_EVALUATIONS = 2; const CAPACITY_APPLY_FINALIZATION_STATE_LOADS = 1; const CAPACITY_APPLY_FINAL_SNAPSHOT_STATE_LOADS = 1; @@ -131,6 +131,11 @@ function hostBootstrapRetirementCountFromPaths(paths) { return simulatorIds.size; } +function hostAliasCountFromPaths(paths, fallback = HOST_BOOTSTRAP_ALIAS_COUNT) { + const hostConfig = readJsonIfPresent(paths?.hostConfigPath); + return Array.isArray(hostConfig?.aliases) ? hostConfig.aliases.length : fallback; +} + function selectedCapacityPurposeCountFromPaths(paths, request) { const projectConfig = readJsonIfPresent(paths?.projectFilePath); const purposes = Array.isArray(projectConfig?.purposes) ? projectConfig.purposes : []; @@ -254,7 +259,7 @@ function serviceCommandUsesSerializedStateReadLock(request) { function leaseAcquireResetSimctlBudgetMs(request) { return request?.group === "lease" && request?.command === "acquire" - ? LEASE_ACQUIRE_RESET_SIMCTL_COMMANDS * SIMCTL_COMMAND_TIMEOUT_MS + ? LEASE_ACQUIRE_SIMCTL_COMMANDS * SIMCTL_COMMAND_TIMEOUT_MS : 0; } @@ -268,9 +273,21 @@ function serviceCommandUsesLeaseMutationLock(request) { if (request?.group === "simulators") { return ["boot", "erase", "repair", "shutdown"].includes(request?.command); } + if (request?.group === "idle") { + return true; + } return false; } +function idleShutdownSimctlBudgetMs(request, options = {}) { + if (request?.group !== "idle" + || !["cleanup", "reconcile"].includes(request?.command) + || (request.command === "cleanup" && request?.options?.apply !== true)) { + return 0; + } + return hostAliasCountFromPaths(options.paths) * SIMCTL_COMMAND_TIMEOUT_MS; +} + function simulatorLifecycleSimctlBudgetMs(request, options = {}) { if (request?.group !== "simulators") { return 0; @@ -449,6 +466,13 @@ export function serviceCommandExecutionTimeoutMs(request, options = {}) { + stateLoadBudgetForRequestMs + simulatorLifecycleSimctlBudgetMs(request, options); } + if (request?.group === "idle") { + return DEFAULT_COMMAND_TIMEOUT_MS + + leaseLockTimeoutMs(request) + + snapshotLockBudgetMs + + stateLoadBudgetForRequestMs + + idleShutdownSimctlBudgetMs(request, options); + } if (serviceCommandUsesLeaseMutationLock(request)) { return DEFAULT_COMMAND_TIMEOUT_MS + leaseLockTimeoutMs(request) @@ -465,8 +489,9 @@ export function serviceCommandTimeoutMs(request, options = {}) { return serviceCommandExecutionTimeoutMs(request, options) + commandQueueTimeoutMs(request); } -export function serviceStartupTimeoutMs() { - return stateLoadBudgetMs(SERVICE_STARTUP_SNAPSHOT_STATE_LOADS) +export function serviceStartupTimeoutMs(options = {}) { + return stateLoadBudgetMs(SERVICE_STARTUP_STATE_LOADS) + + (hostAliasCountFromPaths(options.paths) * SIMCTL_COMMAND_TIMEOUT_MS) + (SERVICE_STARTUP_LOCK_PROCESS_SAMPLER_INVOCATIONS * PROCESS_SAMPLER_TIMEOUT_MS) + SERVICE_STARTUP_LAUNCHER_OVERHEAD_MS; } diff --git a/client/test/brokerd.test.mjs b/client/test/brokerd.test.mjs index f64d9e7..66cc17e 100644 --- a/client/test/brokerd.test.mjs +++ b/client/test/brokerd.test.mjs @@ -373,6 +373,97 @@ test("service lifecycle routes CLI commands through brokerd and falls back after assert.equal(release.json.transport, "direct"); }); +test("policy-enabled lease acquisition lazily starts brokerd and local-only reports its limitation", async (t) => { + const fixture = makeFixture(1); + t.after(async () => stopServiceIfRunning(fixture)); + assert.equal(runCli(fixture, "host", "init").status, 0); + + const unconfigured = runCliWithEnv(fixture, { SIMBROKER_LOCAL_ONLY: "1" }, "idle", "status"); + assert.equal(unconfigured.status, 0); + assert.equal(unconfigured.json.configured, false); + assert.deepEqual(unconfigured.json.scheduler, { + active: false, + limitation: "local-only-mode", + }); + + const enabled = runCli( + fixture, + "idle", + "enable", + "--grace-seconds", + "60", + "--actor-type", + "human", + "--actor-id", + "operator", + ); + assert.equal(enabled.status, 0, enabled.stderr); + assert.equal(enabled.json.transport, "direct"); + assert.deepEqual(enabled.json.scheduler, { + active: false, + limitation: "service-not-running", + }); + assert.equal(runCli(fixture, "service", "status").json.running, false); + + const acquire = runCli( + fixture, + "lease", + "acquire", + "--repo-root", + fixture.repoRoot, + "--purpose", + "agent-ui-session", + "--actor-type", + "agent", + "--actor-id", + "agent-lazy-start", + "--owner-pid", + String(process.pid), + ); + assert.equal(acquire.status, 0, acquire.stderr); + assert.equal(acquire.json.transport, "service"); + assert.deepEqual(acquire.json.scheduler, { + active: true, + limitation: null, + }); + assert.equal(runCli(fixture, "service", "status").json.running, true); + + const status = runCli(fixture, "idle", "status"); + assert.equal(status.status, 0, status.stderr); + assert.equal(status.json.transport, "service"); + assert.equal(status.json.scheduler.active, true); + assert.equal(Object.hasOwn(status.json, "servedBy"), false); + assert.equal(JSON.stringify(status.json).includes(fixture.root), false); + + const release = runCli(fixture, "lease", "release", "--lease-id", acquire.json.lease.leaseId); + assert.equal(release.status, 0, release.stderr); + const preview = runCli(fixture, "idle", "cleanup"); + assert.equal(preview.status, 0, preview.stderr); + assert.equal(preview.json.eligibleCount, 1); + assert.equal(Object.hasOwn(preview.json, "servedBy"), false); + assert.equal(JSON.stringify(preview.json).includes("ui-1"), false); + assert.equal(JSON.stringify(preview.json).includes("SIM-UI-1"), false); + assert.equal(JSON.stringify(preview.json).includes(fixture.root), false); + const cleanup = runCli( + fixture, + "idle", + "cleanup", + "--apply", + "--confirm", + preview.json.planId, + "--actor-type", + "human", + "--actor-id", + "operator", + ); + assert.equal(cleanup.status, 0, cleanup.stderr); + assert.equal(cleanup.json.shutdownCount, 1); + assert.equal(Object.hasOwn(cleanup.json, "servedBy"), false); + assert.equal(JSON.stringify(cleanup.json).includes("ui-1"), false); + assert.equal(JSON.stringify(cleanup.json).includes("SIM-UI-1"), false); + assert.equal(JSON.stringify(cleanup.json).includes(fixture.root), false); +}); + test("service routes project forget through brokerd and refreshes the shared app snapshot", async (t) => { const fixture = makeFixture(); t.after(async () => stopServiceIfRunning(fixture)); @@ -826,6 +917,49 @@ test("brokerd publishes service metadata only after startup snapshot refresh", a assert.equal(fs.existsSync(paths.serviceMetadataPath), true); }); +test("brokerd reconciles immediately, every thirty seconds, refreshes snapshots, and cancels the timer", async () => { + const fixture = makeFixture(); + const paths = resolveBrokerPaths({ + hostConfigPath: fixture.hostConfigPath, + serviceSocketPath: path.join(fixture.root, "timer.sock"), + stateRoot: fixture.stateRoot, + }); + const sources = []; + let snapshotCount = 0; + let intervalMilliseconds = null; + let timerCallback = null; + let timerCleared = false; + const timer = { unref() {} }; + const service = await startBrokerService(paths, { + clearIntervalFn(value) { + assert.equal(value, timer); + timerCleared = true; + }, + reconcileIdleBroker(_servicePaths, options) { + sources.push(options.source); + return { ok: true }; + }, + setIntervalFn(callback, milliseconds) { + timerCallback = callback; + intervalMilliseconds = milliseconds; + return timer; + }, + writeAppSnapshotArtifact() { + snapshotCount += 1; + }, + }); + + assert.deepEqual(sources, ["service-startup"]); + assert.equal(snapshotCount, 1); + assert.equal(intervalMilliseconds, 30_000); + timerCallback(); + assert.deepEqual(sources, ["service-startup", "service-timer"]); + assert.equal(snapshotCount, 2); + + await service.shutdown({ exitProcess: false }); + assert.equal(timerCleared, true); +}); + test("service startup lock waits while another stale-lock reclaimer is active", () => { const root = makeTempDir(); const paths = resolveBrokerPaths({ diff --git a/client/test/public-surface.test.mjs b/client/test/public-surface.test.mjs new file mode 100644 index 0000000..cc8ef61 --- /dev/null +++ b/client/test/public-surface.test.mjs @@ -0,0 +1,69 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { scanPublicSurface } from "../public-surface.mjs"; + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "simbroker-public-surface-test-")); +} + +test("public surface scan reports a local home path without echoing the matched value", () => { + const root = makeTempDir(); + const localHome = path.join(root, "private-home"); + fs.writeFileSync(path.join(root, "README.md"), `machine path: ${localHome}/state\n`); + + const report = scanPublicSurface({ + files: ["README.md"], + homePath: localHome, + root, + }); + + assert.equal(report.ok, false); + assert.deepEqual(report.issues, [{ + line: 1, + path: "README.md", + rule: "local-home-path", + }]); + assert.equal(JSON.stringify(report).includes(localHome), false); +}); + +test("public surface scan applies an ignored operator denylist without echoing its values", () => { + const root = makeTempDir(); + const privateMarker = "operator-private-alias"; + fs.writeFileSync(path.join(root, ".public-safety.local"), `# local only\n${privateMarker}\n`); + fs.writeFileSync(path.join(root, "notes.md"), `do not publish ${privateMarker}\n`); + + const report = scanPublicSurface({ + denylistPath: path.join(root, ".public-safety.local"), + files: ["notes.md"], + homePath: "", + root, + }); + + assert.equal(report.ok, false); + assert.equal(report.issues[0].rule, "local-denylist-rule-2"); + assert.equal(JSON.stringify(report).includes(privateMarker), false); +}); + +test("public surface scan rejects tracked broker state artifacts and ignores binary content", () => { + const root = makeTempDir(); + fs.mkdirSync(path.join(root, "fixtures")); + fs.writeFileSync(path.join(root, "fixtures", "idle-policy.json"), "{}\n"); + fs.writeFileSync(path.join(root, "image.bin"), Buffer.from([0, 1, 2, 3])); + + const report = scanPublicSurface({ + files: ["fixtures/idle-policy.json", "image.bin"], + homePath: "", + root, + }); + + assert.equal(report.ok, false); + assert.deepEqual(report.issues, [{ + line: 1, + path: "fixtures/idle-policy.json", + rule: "prohibited-local-artifact", + }]); +}); diff --git a/client/test/simbroker.test.mjs b/client/test/simbroker.test.mjs index d90880d..6d53649 100644 --- a/client/test/simbroker.test.mjs +++ b/client/test/simbroker.test.mjs @@ -1101,6 +1101,39 @@ test("lease release request carries requester actor flags", () => { assert.equal(request.options.actorId, "operator-1"); }); +test("idle command requests enforce explicit policy and confirmed cleanup shapes", () => { + const { flags: enableFlags } = parseArgs([ + "--grace-seconds", "60", + "--actor-type", "human", + "--actor-id", "operator-1", + ]); + const enable = createCommandRequest({}, "idle", "enable", enableFlags); + assert.deepEqual(enable.options, { + actorId: "operator-1", + actorType: "human", + graceSeconds: 60, + }); + + const { flags: cleanupFlags } = parseArgs([ + "--apply", + "--confirm", "plan-1", + "--actor-type", "human", + "--actor-id", "operator-1", + ]); + const cleanup = createCommandRequest({}, "idle", "cleanup", cleanupFlags); + assert.deepEqual(cleanup.options, { + actorId: "operator-1", + actorType: "human", + apply: true, + confirmPlanId: "plan-1", + }); + + const { flags: invalidPreviewFlags } = parseArgs(["--actor-id", "operator-1"]); + assert.throws(() => { + createCommandRequest({}, "idle", "cleanup", invalidPreviewFlags); + }, (error) => error.payload?.reasonCode === "invalid-flag"); +}); + test("events watch follow rejects non-positive poll intervals", () => { for (const value of ["0", "-1"]) { const { flags } = parseArgs([ @@ -1198,12 +1231,12 @@ test("service containment timeout scales with term wait and diagnostics", () => }), 905_000); }); -test("service lease acquire timeout includes reset-on-acquire budget", () => { +test("service lease acquire timeout includes reset and boot-on-acquire budgets", () => { assert.equal(serviceCommandExecutionTimeoutMs({ command: "acquire", group: "lease", options: {}, - }), 1_185_250); + }), 1_305_250); assert.equal(serviceCommandExecutionTimeoutMs({ command: "acquire", group: "lease", @@ -1212,7 +1245,31 @@ test("service lease acquire timeout includes reset-on-acquire budget", () => { resetLockTimeoutMilliseconds: 120_000, resetSettleMilliseconds: 500, }, - }), 1_305_500); + }), 1_425_500); +}); + +test("service idle timeouts cover serialized state, snapshots, and bounded shutdown work", () => { + const fixture = makeFixture(); + const paths = resolveBrokerPaths({ + hostConfigPath: fixture.hostConfigPath, + projectFilePath: path.join(fixture.repoRoot, ".simulator-broker/project.json"), + stateRoot: fixture.stateRoot, + }); + assert.equal(serviceCommandExecutionTimeoutMs({ + command: "status", + group: "idle", + options: {}, + }, { paths }), 885_000); + assert.equal(serviceCommandExecutionTimeoutMs({ + command: "cleanup", + group: "idle", + options: { apply: true }, + }, { paths }), 1_125_000); + assert.equal(serviceCommandExecutionTimeoutMs({ + command: "reconcile", + group: "idle", + options: {}, + }, { paths }), 1_125_000); }); test("service mutation timeouts cover broker lock waits", () => { @@ -1363,10 +1420,10 @@ test("service command timeout budgets stale containment sampling from lease file assert.equal(serviceCommandTimeoutMs(request, { paths }), 945_000 + (2 * staleContainmentBudgetMs)); }); -test("service startup timeout covers startup lock, snapshot process, and inventory work", () => { +test("service startup timeout covers startup reconciliation, snapshot, and lock work", () => { assert.equal( serviceStartupTimeoutMs(), - (3 * SIMCTL_COMMAND_TIMEOUT_MS) + (3 * PROCESS_SAMPLER_TIMEOUT_MS) + 5_000, + (12 * SIMCTL_COMMAND_TIMEOUT_MS) + (4 * PROCESS_SAMPLER_TIMEOUT_MS) + 5_000, ); }); @@ -1724,7 +1781,7 @@ test("local event follow with a limit pages bursts without dropping older unseen secondLeaseFile, ).status, 0); } - if (sleepCount >= 3) { + if (sleepCount >= 4) { abortController.abort(); } }, diff --git a/spec/README.md b/spec/README.md index 5450ec1..c03d132 100644 --- a/spec/README.md +++ b/spec/README.md @@ -21,7 +21,8 @@ This repo exists to develop a reusable local simulator broker: | `spec/build-and-test.md` | Current setup and verification commands | | `spec/project-structure.md` | Folder contract for this seed repo | | `spec/agents.md` | Agent workflow and skill routing rules | -| `spec/tasks/README.md` | Worker-ready implementation tasks derived from the macOS app audit | +| `spec/tasks/README.md` | Worker-ready cross-layer and macOS audit implementation tasks | +| `spec/tasks/public-safe-on-demand-simulator-lifecycle.md` | Cross-layer contract for deterministic warm reuse and public-safe idle shutdown | | `references/README.md` | Public-safe reference and example policy | ## Current project status @@ -62,6 +63,12 @@ This repo exists to develop a reusable local simulator broker: `scripts/validate.sh` as the canonical full-repository validation entry point - implementation paths now require the `implementation` verification profile, while specs and harness contracts also require `spec-only` +- lease acquisition now boots the selected simulator and deterministic selection + prefers a matching pin, then warm compatible capacity, then shutdown capacity, + with most-recent release reuse inside each tier +- optional machine-local idle policy, scheduled reconciliation, confirmed cleanup, + app controls, and the `verify:public-surface` gate are implemented without a + shipped duration preference ## Lower-priority roadmap diff --git a/spec/architecture.md b/spec/architecture.md index 6e064bc..6d3bef6 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -19,8 +19,9 @@ Owns: - lease lifecycle - broker-mediated lifecycle policy for boot, shutdown, erase, and repair - registry state -- fairness and role ordering +- deterministic pin/warm/shutdown selection ordering - stale lease recovery +- idle-policy state, eligibility, reconciliation, and count-only cleanup plans - reset-lock and mutation-lock semantics - simulator inventory and `simctl` adapter boundaries - policy parsing and validation @@ -43,6 +44,8 @@ Current implementation slice: - real simulator provisioning during `host init --bootstrap-config` - registry drift synchronization and repair rebinding through broker-managed lifecycle flows - reset-lock coordination for `erase-on-acquire` +- boot-on-acquire and most-recent-release warm reuse +- state-root-only idle policy and safe idle shutdown under the broker mutation lock - capacity check and confirmed reconcile using the same repo/host policy, `simctl` adapter boundary, mutation lock, local transaction journal, rollback, and recovery model as the rest of broker authority @@ -67,6 +70,8 @@ Current implementation slice: through the same broker-core evaluator and apply engine as direct CLI mode - canonical `app-snapshot.json` artifact written into broker state for the macOS app - command endpoint consumed directly by the macOS app for broker-backed operator actions +- immediate startup idle reconciliation, a non-overlapping 30-second scheduler, + and snapshot refresh after each reconciliation Does not own: @@ -91,6 +96,10 @@ Owns: - capacity commands: `capacity check` diagnoses whether repo purposes can acquire capacity now; `capacity reconcile` previews and, with exact human confirmation, applies additive broker-managed capacity +- idle commands: status, explicit human enable/disable, reconcile, and + count-only cleanup preview plus exact human-confirmed apply +- lazy `brokerd` startup for normal acquisitions when idle policy is configured, + while explicit local-only mode remains unscheduled The CLI remains a thin control surface. It does not own simulator selection or registry mutation rules, but it may run wrapper-oriented orchestration that registers a local downstream process and calls broker-core containment. @@ -110,6 +119,8 @@ Current implementation slice: - explicit `--state-root` launch override plus direct pane/detail targeting for fixture, smoke, and operator review runs without rewriting the default broker install root - filterable simulator table plus detail panes for leases, pins, lifecycle fields, and event attribution - broker-backed operator actions for pin create and clear, lease release, and lifecycle commands with confirmation and override flows +- Overview Automatic shutdown status, explicit duration entry, policy + apply/disable, and count-confirmed cleanup over broker transport Does not own: @@ -124,6 +135,10 @@ The app and CLI must operate on the same broker authority and observe the same s All lifecycle actions for broker-managed aliases, including boot, shutdown, erase, repair, pin, and release, must route through the broker authority rather than direct client `simctl` calls. +Lease acquisition itself boots the selected alias before returning. Idle policy +and cleanup are also broker-only mutations; neither the app nor a consumer repo +may write broker state files directly. + All destructive cleanup for broker-managed build/test leases must also route through broker authority. Repo wrappers may start downstream commands, but process ownership metadata, memory checks, evidence bundles, lease release, and audit events belong to the broker contract. All capacity reconciliation for broker-managed aliases must route through @@ -140,10 +155,14 @@ The broker must remain transparent: clients need explainable denial reasons, hol - repo purpose definitions may include explicit iOS version or device family requirements - normal automation requests purposes and capabilities, not hardcoded aliases - explicit alias pins are an operator action, not the default repo integration path +- idle shutdown policy belongs only to machine-local broker state and is absent + until a human chooses a valid duration ## Baseline Broker Constraints - deterministic reservation, not opportunistic picking from `simctl` output +- one selection order: matching pin, compatible booted alias, compatible + shutdown alias; most recent release first inside a tier - explicit alias pools per role - manual aliases protected from automation - `erase-on-acquire` only for UI-like roles diff --git a/spec/build-and-test.md b/spec/build-and-test.md index fc6c418..6039131 100644 --- a/spec/build-and-test.md +++ b/spec/build-and-test.md @@ -20,6 +20,9 @@ A first extracted implementation slice now exists: - direct and service-backed capacity coverage for public-safe check output, deterministic reconcile preview, exact human-confirmed additive apply, rollback, committed recovery, idempotency, and audit events +- deterministic warm-reuse, boot-on-acquire, idle-policy boundary, exclusion, + stale-recovery, lock-race, scheduler, confirmed-cleanup, and failure coverage +- tracked-text public-surface scanning with an ignored local denylist extension - app-side operator controls for pin create and clear, lease release, and lifecycle actions over the shared broker authority - app launch-time fixture overrides through `--state-root`, `--host-config`, optional `--cli-path`, plus direct pane/detail targeting for deterministic screenshot and smoke scenarios - broker-owned state artifacts are restricted to the current user, and lease, containment, pin, and lifecycle mutations share the broker mutation authority whether invoked directly, through the service, or from the app @@ -148,12 +151,20 @@ npm run test:app:focus -- SimulatorBrokerAppTests/BrokerSnapshotLoaderTests --re npm run test:broker-core npm run test:client npm run test:harness-adoption +npm run verify:public-surface node client/bin/simbroker.mjs lease --help node client/bin/simbroker.mjs host --help node client/bin/simbroker.mjs capacity --help +node client/bin/simbroker.mjs idle --help node client/bin/simbroker.mjs capacity check --repo-root [--purpose ] --json node client/bin/simbroker.mjs capacity reconcile --repo-root [--purpose ] --json node client/bin/simbroker.mjs capacity reconcile --repo-root [--purpose ] --apply --confirm --actor-type human --actor-id --json +node client/bin/simbroker.mjs idle status --json +node client/bin/simbroker.mjs idle enable --grace-seconds <60-86400> --actor-type human --actor-id --json +node client/bin/simbroker.mjs idle disable --actor-type human --actor-id --json +node client/bin/simbroker.mjs idle reconcile --json +node client/bin/simbroker.mjs idle cleanup --json +node client/bin/simbroker.mjs idle cleanup --apply --confirm --actor-type human --actor-id --json bash scripts/install_local.sh bash scripts/install_distribution.sh --payload-root bash scripts/package_distribution.sh --team-id --signing-identity '' @@ -186,6 +197,11 @@ npm run agent:complete -- --session-dir "$HOME/.codex/agent-harness/simulator-br `spec-only`; mixed tasks must pass both profiles. - `WORKFLOW.md` invokes `./scripts/validate.sh` for normal Symphony validation. The validator runs `npm test` plus the canonical diff-integrity check. +- `npm test` begins with `verify:public-surface`. Public fixtures must use + temporary broker roots and synthetic identifiers; tests must never read from + or write to the default broker state root. +- `scripts/package_distribution.sh` runs the same public-surface check before + building or signing a release candidate. - `npm run agent:complete -- --session-dir ` is the close-out gate; it fails unless the required verification profiles passed against the current task-tree fingerprint and any blocking obligations are satisfied or the session is explicitly reported blocked. - Every meaningful task commit must use a structured git message with `Why:`, `Changed:`, `Verification:`, `Affected:`, `Refs:`, and `Session:` sections. Commit messages are durable public output: use repo-relative paths, GitHub URLs, commit SHAs, and public task artifact labels such as `task-sessions/` instead of machine-local or parent-relative paths. `agent:complete` rejects detected local-path forms in every new commit touching task paths. - `agent:complete` enforces a clean close-out: no new uncommitted task changes, no malformed task commit messages, no changed paths outside selected manifest path constraints, and no leftover untracked junk outside allowlisted local artifact paths. @@ -216,8 +232,19 @@ Add stronger profiles next for: no-changes idempotency, verified pre-commit rollback, recovery-required journal blocking, and committed transaction recovery through fixture-backed `simctl` -- `npm run test:client` proves service lifecycle, concurrent clients, startup readiness before service metadata publication, restart safety, malformed service response handling, expected service identity validation for command dispatch and stop dispatch, NDJSON event streaming including stop with an active follower, service-backed lifecycle-control flows against the fixture-backed `simctl` boundary, stable direct plus service-backed exit-code behavior, useful `lease --help` / `host --help` / `capacity --help` output, and direct/service capacity plan parity -- `npm run test:app` proves the XcodeGen project builds and the app decodes snapshots, filters pin candidates, bounds local CLI subprocesses, preserves refresh diagnostics after successful mutations whose snapshot reload fails, and routes broker-command errors correctly +- `npm run test:broker-core` also proves most-recent-release reuse for UI and + build capacity, concurrent standby expansion, boot-on-acquire rollback, + grace-boundary eligibility, all safety exclusions, stale grace restart, + mutation-lock serialization, shutdown failure repair state, and confirmed + count-only cleanup +- `npm run test:client` proves service lifecycle, concurrent clients, startup readiness before service metadata publication, restart safety, malformed service response handling, expected service identity validation for command dispatch and stop dispatch, NDJSON event streaming including stop with an active follower, service-backed lifecycle-control flows against the fixture-backed `simctl` boundary, stable direct plus service-backed exit-code behavior, useful command help, direct/service capacity and idle parity, lazy daemon start, local-only scheduler limitation, immediate startup reconciliation, 30-second timer wiring, and snapshot refresh +- `npm run test:app` proves the XcodeGen project builds and the app decodes snapshots, filters pin candidates, bounds local CLI subprocesses, preserves refresh diagnostics after successful mutations whose snapshot reload fails, routes broker-command errors correctly, and drives Automatic shutdown apply, disable, preview, confirmation, cleanup, and refresh flows +- the generated app test scheme receives a per-run temporary state root and + host-config path from `scripts/test_app.sh`; the XCTest host never launches + against the default broker state root +- `npm run verify:public-surface` proves the tracked public text has no current + home path or prohibited local broker artifact and applies optional rules from + ignored `.public-safety.local` without printing matched values - `npm run test:app:build` isolates compile-time failures with the same XcodeGen and derived-data settings used by the full suite - `npm run test:app:focus -- ` reruns one named XCTest scope and writes a stable `xcresult` bundle under `artifacts/app-tests/` unless the caller overrides the path explicitly - `./script/build_and_run.sh` is the canonical local macOS app run loop, stops only the app instance launched from the current checkout's built app path, and `./script/build_and_run.sh --verify` proves that built app launches as a foreground `.app` diff --git a/spec/global-simulator-broker.md b/spec/global-simulator-broker.md index b543e24..23717ed 100644 --- a/spec/global-simulator-broker.md +++ b/spec/global-simulator-broker.md @@ -1,8 +1,8 @@ # Global Simulator Broker -Related: `spec/README.md`, `spec/architecture.md`, `spec/implementation-plan.md`, `spec/build-and-test.md`, `spec/project-structure.md`, `references/README.md` +Related: `spec/README.md`, `spec/architecture.md`, `spec/implementation-plan.md`, `spec/build-and-test.md`, `spec/project-structure.md`, `spec/tasks/public-safe-on-demand-simulator-lifecycle.md`, `references/README.md` > **Document ID:** `GSB-001` -> **Version:** `0.13.13` +> **Version:** `0.14.0` > **Last Updated:** `2026-08-10` > **Status:** `Draft` > **Owner:** `spec-steward` @@ -33,6 +33,8 @@ A first extracted implementation slice now exists: - real `simctl` adapter boundary for provisioning, lifecycle actions, reset-on-acquire coordination, and drift observation - stable exit-code and service HTTP-status contract for broker failures - lease-scoped containment for broker-aware build/test wrappers, including downstream process metadata, memory ceiling checks, evidence bundles, and stale-owner cleanup when process metadata exists +- deterministic warm reuse, boot-on-acquire, optional machine-local idle shutdown, + confirmed cleanup, and public-safe operator controls The extracted system must work for any repo that uses AI agentic development harnesses, CI jobs, or human-operated simulator workflows on one machine. @@ -69,6 +71,8 @@ The extracted system must work for any repo that uses AI agentic development har - machine-local state root under `~/Library/Application Support/SimulatorBroker/state` - canonical `registry.json` - canonical `leases/.json` +- optional state-root-only `idle-policy.json` containing exactly `version` and + `graceSeconds`; absence means not configured - broker mutation lock and separate reset lock - lease records containing alias, simulator ID, role, owner label, pid, cwd, session dir, timing, reset policy, and broker lease file path - broker-owned state directories and files are restricted to the current user; share leases through broker APIs or emitted lease artifacts, not by relaxing state-root permissions @@ -78,8 +82,12 @@ The extracted system must work for any repo that uses AI agentic development har ### Current reservation semantics - reclaim stale leases when the owning process is gone -- rank free aliases by oldest `lastLeaseStartedAt` -- break equal-usage ties by configured role order +- rank a matching pin first, then compatible booted aliases, then compatible + shutdown aliases +- within each tier prefer the most recently released alias and use configured + alias order only as the final deterministic tie-breaker +- complete any required reset and boot the selected simulator before returning + a successful lease - fail invalid explicit alias or simulator overrides - avoid borrowing aliases across configured role pools - serialize slow erase/reset work for UI roles under a dedicated reset lock @@ -102,7 +110,8 @@ Must own: - policy schema and validation - deterministic reservation algorithm - stale recovery -- fairness rotation +- deterministic warm-reuse ordering +- idle-policy validation, eligibility, reconciliation, and cleanup planning - reset coordination - `simctl` adapter boundary - purpose and capability resolution @@ -129,6 +138,9 @@ Current implementation slice: - simulator repair persists replaced simulator IDs as pending retirements when old-device shutdown or deletion fails after the repaired host config commits, and later repair/maintenance attempts must retry those pending retirements until deletion succeeds - registry synchronization against `simctl` so missing, unavailable, or mismatched simulators become `repair-needed` - reset-on-acquire coordination behind a dedicated reset lock with rollback on reset failure +- boot-on-acquire with lease rollback and `repair-needed` state on boot failure +- policy-driven idle shutdown under the mutation lock, with stale recovery + starting a fresh grace period and shutdown failures becoming repair-needed ### `brokerd` @@ -155,6 +167,8 @@ Current implementation slice: - broker-mediated lifecycle-control requests for `boot`, `shutdown`, `erase`, and `repair` - command transport consumed by the macOS app for broker-backed operator actions - service-backed commands observing the same real `simctl`-synchronized alias health and repair state as direct CLI mode +- immediate idle reconciliation before the startup snapshot, non-overlapping + reconciliation every 30 seconds, and snapshot refresh after every timer run ### `client` @@ -183,6 +197,10 @@ Current implementation slice: - `service start`, `service status`, and `service stop` - automatic service routing when `brokerd` is available - `simulators boot`, `simulators shutdown`, `simulators erase`, and `simulators repair` +- `idle status`, human-attributed `idle enable` and `idle disable`, immediate + `idle reconcile`, and count-only `idle cleanup` preview plus confirmed apply +- policy-enabled normal lease acquisition lazily starts `brokerd` when needed; + explicit local-only mode remains unscheduled and reports that limitation - command option parsing rejects unknown flags before dispatch for lifecycle commands, lease acquire/register/contain/release, capacity commands, service control, event watches, pin mutations, and host initialization so misspelled state or routing flags cannot fall through to defaults before a destructive operation - boolean option parsing honors explicit `false` and rejects unsupported values for command-shaping booleans, including lease containment diagnostic capture and owner-kill controls - stable non-zero exit codes shared by direct CLI mode and service-backed CLI mode @@ -206,6 +224,8 @@ Current implementation slice: - Projects screen with per-project purpose counts and active aliases - Events screen with recent broker activity and event attribution - broker-backed actions for pin create and clear, lease release, and lifecycle requests with human override confirmation +- Overview **Automatic shutdown** status and broker-backed Apply, Disable, and + count-confirmed cleanup actions; unconfigured policy leaves duration blank - first-run setup classification for missing CLI, missing host config, stopped service, and snapshot-refresh states - app-driven host bootstrap, service start, and snapshot refresh through bounded, cancellation-aware installed `simbroker` CLI subprocesses without creating an app-only mutation path; the app runner uses command-specific setup budgets that include preliminary service probe transfer, command transfer, service queue allowance, startup snapshot process sampling and inventory, and launcher headroom, and bounds process-tree discovery during cancellation before falling back to root-process signaling - repo onboarding guidance in the app when the machine is ready but no broker-aware repo has been registered yet @@ -253,7 +273,9 @@ Default operational decisions for v1: - repo project config uses explicit requirements only; there is no soft-preference layer - repo project config v1 supports only `deviceFamily` and `iosVersion` in `requires` -- when multiple aliases satisfy a requirement set, normal fairness rotation chooses among them +- when multiple aliases satisfy a requirement set, the single deterministic + warm-reuse ordering chooses among them; no legacy rotation mode exists +- acquisition succeeds only after the selected simulator is booted - active-holder lifecycle actions require an active lease ID or lease file reference; actor ID and actor type are attribution metadata, not authorization credentials - only humans may force-override another live holder for urgent repair - human-forced repair overrides must include `forceOverride`, `overrideReason`, `expectedAlias`, and `expectedLeaseId` @@ -277,7 +299,58 @@ Failure-contract defaults for v1: - app mutation flows must not clear or overwrite snapshot refresh errors after a successful broker mutation; a mutation is user-visible success only after the follow-up snapshot refresh succeeds or is superseded by a newer successful refresh - service HTTP status classes should stay aligned with the same failure groups: `400` invalid request, `404` unknown route, `409` unavailable or conflict, `412` override-required, `423` repair-needed, and `500` internal failure -## 7. Capacity check and confirmed reconcile contract +## 7. Public-safe idle lifecycle contract + +Idle shutdown is opt-in machine policy. The broker stores it only at +`/idle-policy.json`, outside repositories, with exactly this shape: + +```json +{ + "version": 1, + "graceSeconds": 60 +} +``` + +The value shown is the minimum valid value, not a default. `graceSeconds` must +be an integer from `60` through `86400`; absence of the file means not +configured. Source, docs, fixtures, and onboarding must not embed an operator's +chosen duration. + +The public command surface is: + +```text +simbroker idle status +simbroker idle enable --grace-seconds <60-86400> --actor-type human --actor-id +simbroker idle disable --actor-type human --actor-id +simbroker idle reconcile +simbroker idle cleanup +simbroker idle cleanup --apply --confirm --actor-type human --actor-id +``` + +Enable, disable, and cleanup apply require explicit human attribution. Cleanup +preview is non-mutating and count-only. Apply recomputes the candidate set under +the mutation lock and requires the exact current plan ID. There is no unattended +confirmation bypass. + +Reconciliation takes the broker mutation lock, re-reads leases and inventory, +and shuts down only registered aliases that are booted, healthy, unleased, +unpinned, non-`manual-persistent`, and at or beyond the recorded release time +plus grace. Stale recovery records a new release time and therefore restarts +grace. Unknown externally booted devices remain untouched. Shutdown failure +marks the alias `repair-needed` with `idle-shutdown-failed`, so the scheduler +does not repeatedly retry it. + +`brokerd` reconciles immediately at startup and every 30 seconds thereafter, +without overlapping runs, and refreshes the app snapshot after each run. A +policy-enabled normal acquisition lazily starts `brokerd` when it is absent. +Explicit local-only mode never schedules work and reports `local-only-mode`. + +Events and snapshots summarize configuration state, grace duration, eligible +count, last cleanup result, and next scheduled cleanup. Idle and cleanup +summaries must not return local paths, aliases, simulator IDs, operator IDs, or +raw runtime errors. + +## 8. Capacity check and confirmed reconcile contract Repos can ask the broker to classify simulator capacity before starting simulator-dependent work: @@ -348,7 +421,7 @@ environment-variable bypass in v1. The broker chooses the additive alias name, display name, device type, newest compatible stable iOS runtime, and reset policy. Existing aliases, simulator -IDs, leases, pins, fairness, and reset semantics are never deleted, erased, +IDs, leases, pins, deterministic selection state, and reset semantics are never deleted, erased, repurposed, renamed, or repaired by capacity reconcile. Busy or pinned matching capacity is non-actionable and never creates extra capacity. Missing runtime or device type blocks apply instead of downloading or guessing. @@ -386,11 +459,11 @@ state root and emits one terminal event: `capacity.reconcile.applied`, `capacity.reconcile.rollback_incomplete`. Preview and check do not write audit events or transaction journals. -## 8. Lease-scoped build/test containment contract +## 9. Lease-scoped build/test containment contract Broker-managed build/test leases must be safe even when the consumer app, `xcodebuild`, or simulator-launched test host is defective. Containment is lease-scoped only; the broker must not become a generic host process killer. -Runtime metadata is optional for backward compatibility, but broker-aware wrappers should record it before downstream simulator work starts: +Runtime metadata is optional; broker-aware wrappers should record it before downstream simulator work starts: - lease owner PID and process group - downstream command PID and process group @@ -439,7 +512,7 @@ The evidence bundle must be a directory under the lease evidence root and includ Stale owner reclaim must preserve the existing `lease.reclaimed` behavior for legacy leases without process metadata. If a stale lease has registered process metadata or a memory ceiling, stale reclaim must run containment and emit `lease.contained` instead of silently deleting the lease JSON. -## 9. Public-source completion criteria for this repo +## 10. Public-source completion criteria for this repo This repo is ready for public-source collaboration only if: @@ -449,15 +522,21 @@ This repo is ready for public-source collaboration only if: - agent harness commands run in this repo - useful skills are available locally in `.agents/skills/` - a fresh agent can begin here without opening a private product repo first +- `verify:public-surface` runs in the normal gate, scans tracked text for real + home paths and prohibited local artifacts, and supports an ignored + `.public-safety.local` operator denylist without disclosing matched values +- public tests use temporary broker roots and synthetic identities, never the + default broker state root -## 10. Remaining implementation detail +## 11. Remaining implementation detail - implementation may still choose internal type names and module boundaries, but the external contract above is fixed for v1 -## 11. Document History +## 12. Document History | Version | Date | Summary | | --- | --- | --- | +| 0.14.0 | 2026-08-10 | Replaced lease rotation with deterministic warm reuse, required boot-on-acquire, and added opt-in public-safe idle lifecycle, scheduler, cleanup, app, and verification contracts. | | 0.13.13 | 2026-08-10 | Added locked, explicit, idempotent cleanup for inactive local project registrations with lease/pin conflict protection and snapshot refresh. | | 0.13.12 | 2026-08-04 | Clarified startup-lock sampler timeout coverage, per-state-load stale containment budget retries, deterministic snapshot host-config fixtures, and non-remediable erase conflicts. | | 0.13.11 | 2026-08-03 | Clarified stale containment timeout budgets, startup-lock PID lifetime validation, snapshot host-config identity, and repair-only live-holder overrides. | diff --git a/spec/harness-integration.md b/spec/harness-integration.md index e1a04d7..8e0829f 100644 --- a/spec/harness-integration.md +++ b/spec/harness-integration.md @@ -2,8 +2,8 @@ Related: `spec/README.md`, `spec/implementation-plan.md`, `spec/global-simulator-broker.md`, `spec/agents.md`, `spec/build-and-test.md` > **Document ID:** `GSB-HARNESS-001` -> **Version:** `0.4.0` -> **Last Updated:** `2026-07-20` +> **Version:** `0.4.1` +> **Last Updated:** `2026-08-10` > **Status:** `Draft` > **Owner:** `spec-steward` @@ -199,7 +199,8 @@ This is the canonical harness flow for local scripts, agent sessions, and CI. then apply that exact plan with `--apply --confirm --actor-type human --actor-id `. 4. Acquire a lease by purpose. -5. Read simulator metadata from the lease artifact. +5. Read simulator metadata from the lease artifact. Successful acquisition + means the selected simulator has already been booted by the broker. 6. Run simulator-dependent work. 7. Register downstream process metadata. 8. Monitor memory ceilings when configured. @@ -404,4 +405,5 @@ The harness-awareness phase is complete only when: | Version | Date | Summary | | --- | --- | --- | +| 0.4.1 | 2026-08-10 | Clarified that successful acquisition returns a broker-booted simulator. | | 0.4.0 | 2026-07-20 | Added capacity check and operator-confirmed reconcile guidance for simulator-dependent harness preflights. | diff --git a/spec/implementation-plan.md b/spec/implementation-plan.md index 01cd820..5a5a0d0 100644 --- a/spec/implementation-plan.md +++ b/spec/implementation-plan.md @@ -2,8 +2,8 @@ Related: `spec/README.md`, `spec/global-simulator-broker.md`, `spec/architecture.md`, `spec/harness-integration.md`, `spec/build-and-test.md`, `spec/project-structure.md` > **Document ID:** `GSB-PLAN-001` -> **Version:** `0.8.7` -> **Last Updated:** `2026-04-10` +> **Version:** `0.9.0` +> **Last Updated:** `2026-08-10` > **Status:** `Draft` > **Owner:** `spec-steward` > **Implementation owners:** `spec-steward`, `ios-dev` @@ -37,7 +37,8 @@ This plan intentionally does not include any single-repo migration work. It focu Implemented now: -- file-backed `broker-core` for host and repo validation, registry state, leases, pins, events, fairness, and stale recovery +- file-backed `broker-core` for host and repo validation, registry state, leases, + pins, events, deterministic warm reuse, and stale recovery - `simbroker` CLI with repo-root discovery, JSON payloads, deterministic lease-file emission, event inspection, and pin management - `brokerd` local authority with same-user Unix-socket transport, service lifecycle commands, and CLI auto-routing when the service is running - broker-mediated lifecycle controls for `boot`, `shutdown`, `erase`, and `repair`, including human override validation and audit events @@ -63,6 +64,10 @@ Implemented now: - automated Node tests plus manual CLI smoke artifacts for the repo-integration path - broker-aware sample consumer repo artifacts and harness smoke tests under `examples/harness-adoption/` - initial `broker-harness-adoption` skill scaffold under `.agents/skills/` +- boot-on-acquire plus matching-pin, warm, then shutdown selection using the + most recently released alias within each tier +- opt-in machine-local idle shutdown, `brokerd` scheduling, count-confirmed + cleanup, macOS app controls, and tracked-text public-safety verification Still pending: @@ -203,7 +208,9 @@ Required behavior: Requirement semantics: - requirements filter the candidate aliases before lease ranking begins -- when multiple aliases satisfy the same requirements, the broker uses its normal fairness and health rules to choose among them +- when multiple aliases satisfy the same requirements, the broker filters by + health and uses its single matching-pin, warm, then shutdown order with most + recent release first inside a tier - a requirement mismatch must fail with a structured explanation of the unsatisfied fields and the closest available alternatives - host inventory remains authoritative; repo requirements narrow selection but do not create aliases or bypass host policy @@ -218,7 +225,8 @@ Validation and matching rules: - `iosVersion: "18"` matches any installed iOS `18.x` runtime - `iosVersion: "18.2"` matches only iOS `18.2` - device type names such as `iPhone 16` are not allowed in repo config v1 -- if no `requires` block is present, selection is driven only by capability, fairness, health, and pin state +- if no `requires` block is present, selection is driven only by capability, + health, lease and pin state, power state, and most recent release time Illustrative shape: @@ -322,11 +330,17 @@ Required command families: - `simbroker events watch` - `simbroker pin create` - `simbroker pin clear` +- `simbroker idle status` +- `simbroker idle enable` +- `simbroker idle disable` +- `simbroker idle reconcile` +- `simbroker idle cleanup` Required CLI properties: - every mutating and status command supports `--json` - `lease acquire` supports `--repo-root`, `--project-file`, `--purpose`, `--actor-type`, `--actor-id`, `--job-id`, `--job-kind`, `--session-dir`, and `--lease-file` +- successful `lease acquire` returns only after the selected simulator is booted - `events watch` supports a streaming machine-readable mode such as JSON lines - denial payloads and validation failures must include a stable reason code, current holder details when relevant, and suggested next actions - every CLI error payload includes `reasonCode` and `exitCode` @@ -363,6 +377,7 @@ Required app actions by the end-state: - create and clear pins - release active leases - boot, shutdown, erase, or repair simulators subject to confirmation and policy +- configure or disable Automatic shutdown and run count-confirmed idle cleanup - filter by project, purpose, actor type, and health ### 4.6 Broker-mediated lifecycle control @@ -439,7 +454,7 @@ Required rules: | Surface | Role in the plan | |---|---| | `spec/` | source of truth for contracts, implementation order, and verification gates | -| `broker-core/` | deterministic state model, fairness algorithm, liveness, reset coordination, schema validation, event generation | +| `broker-core/` | deterministic state model, warm-reuse ordering, liveness, reset/boot coordination, idle reconciliation, schema validation, event generation | | `client/` | CLI surface, repo integration helpers, stable JSON contract, local service client, and the first broker service target | | `app/` | macOS visibility and operator actions built on the same broker authority | | `examples/harness-adoption/` | public consumer-repo fixture for broker-aware harness integration | @@ -745,6 +760,45 @@ Current implementation slice: - `client/service/brokerd.mjs` maps broker failures into stable HTTP status classes instead of flattening all broker errors to `400` - `client/test/simbroker.test.mjs` and `client/test/brokerd.test.mjs` now assert the published failure contract +### Phase 9 — Public-safe on-demand simulator lifecycle + +Status: + +- implemented + +Goal: + +- retain all registered aliases as normally shutdown standby capacity while + making low-concurrency work reuse only the warm simulator capacity it needs + +Deliverables: + +- one deterministic selection order with no legacy rotation path +- boot-on-acquire and repair-needed rollback on boot failure +- absent-by-default state-root idle policy with an explicit human duration +- immediate and 30-second `brokerd` reconciliation plus lazy service start +- public CLI/API and macOS Overview controls for status, policy, and confirmed cleanup +- snapshot/event summaries that do not disclose local simulator identifiers +- `verify:public-surface` in the normal test and release gate + +Verification: + +- core tests for warm reuse, concurrency, eligibility exclusions, stale recovery, + lock races, boundary timing, shutdown failure, and confirmed cleanup +- direct/service client tests for parser parity, lazy daemon behavior, service + restart, scheduler execution, privacy, and snapshot updates +- macOS tests for explicit duration, apply/disable, preview confirmation, cleanup, + and refresh behavior +- public-surface scanner tests using temporary roots and synthetic values + +Exit criteria: + +- sequential demand reuses one warm alias per compatible workload while + concurrent demand can use additional retained standby aliases +- automated aliases shut down only after an operator-configured grace period +- pins, live leases, manual aliases, unhealthy aliases, and external devices are untouched +- no local duration, identity, alias, simulator ID, or state root ships in public source + ## 7. Cross-cutting verification strategy Each implementation phase must add or strengthen deterministic verification profiles. @@ -757,6 +811,7 @@ Required verification categories: - multi-process integration tests across sample repos - app runtime and screenshot evidence - clean-machine install and onboarding smoke tests +- public-surface text scanning and temporary-root fixture enforcement Expected profile growth beyond `spec-only`: @@ -775,4 +830,8 @@ Expected profile growth beyond `spec-only`: - AI agents may request repair but may not force-override another live holder - drift detection is moderate: validate on broker startup, lease boundaries, broker-mediated actions, and normal status refreshes rather than continuous heavy polling - only `repair-needed` and `repairing` block new leases; `state-drift` remains observable but non-blocking +- lease selection has one order only: matching pin, compatible booted alias, + compatible shutdown alias, with most recently released first inside each tier +- acquisition returns only after the selected simulator is booted +- idle policy is absent by default and has no migration or legacy-selection mode - there are no remaining product-level open questions in this plan diff --git a/spec/project-structure.md b/spec/project-structure.md index c69348d..30195a0 100644 --- a/spec/project-structure.md +++ b/spec/project-structure.md @@ -13,7 +13,7 @@ Related: `spec/README.md`, `spec/global-simulator-broker.md`, `references/README - `references/` — public-safe reference notes; copied product snapshots are not checked in - `app/` — XcodeGen spec, SwiftUI source, and XCTest coverage for the macOS operator app - `broker-core/` — reusable broker logic; current file-backed slice, lease containment helpers, shared `simctl` adapter, and error-contract boundaries live here -- `client/` — CLI, local service, and compatibility layer; `client/bin/` contains `simbroker` and `brokerd`, and `client/service/` contains the Unix-socket authority implementation +- `client/` — CLI, local service, and compatibility layer; `client/bin/` contains `simbroker` and `brokerd`, `client/service/` contains the Unix-socket authority implementation, and `client/public-surface.mjs` implements the public text safety gate - `script/` — canonical app run-loop entrypoints and shared macOS build preflight helpers such as `build_and_run.sh` - `scripts/` — repo-owned helper scripts including the canonical `validate.sh` full-repository gate, app generation, repo-local install, @@ -33,6 +33,12 @@ The broker state root may contain runtime artifacts in addition to source-of-tru - `leases/` — active lease JSON files - `events.ndjson` — append-only audit events - `registry.json`, `pins/`, and `known-projects.json` — broker state +- `idle-policy.json` — optional policy containing only `version` and + `graceSeconds`; absent by default and written only through broker commands - `evidence/` — default lease containment evidence root when a wrapper does not provide a run-local evidence path +No state artifact belongs in a consumer repository. In particular, +`idle-policy.json`, app snapshots, daemon metadata, host config, aliases, +simulator IDs, and operator attribution remain machine-local. + Consumer wrappers should prefer a run-local evidence directory such as `/simulator-broker-evidence` so failure bundles travel with the rest of the job artifacts. diff --git a/spec/tasks/README.md b/spec/tasks/README.md index 8607360..0d4963f 100644 --- a/spec/tasks/README.md +++ b/spec/tasks/README.md @@ -1,10 +1,10 @@ -# macOS App Task Specs +# Implementation Task Specs Related: `spec/README.md`, `spec/agents.md`, `spec/build-and-test.md`, `app/README.md` ## Purpose -This directory contains the worker-ready implementation tasks derived from the April 11, 2026 macOS app audit. -All nine task specs in this directory are now implemented; keep them as source-of-truth contracts plus completion history for the remediation set. +This directory contains worker-ready implementation tasks for cross-layer broker work and the April 11, 2026 macOS app audit. +The nine audit tasks and the public-safe on-demand lifecycle task are implemented; keep them as source-of-truth contracts plus completion history. Each task spec is written so a worker can take ownership of one issue or one tightly related issue cluster and execute it end to end without opening a clarification loop first. @@ -30,6 +30,12 @@ Each task document must be treated as a full worker contract: | Install and package smoke tests do not prove the installed app launches | `spec/tasks/macos-installed-app-launch-smoke.md` | | Portable package flow is still local-debug signed and not distribution-ready | `spec/tasks/macos-distribution-readiness.md` | +## Cross-layer feature contracts + +| Feature | Task spec | +|---|---| +| Deterministic warm reuse, boot-on-acquire, local idle shutdown, operator controls, and public-surface safety | `spec/tasks/public-safe-on-demand-simulator-lifecycle.md` | + ## Not included here The following audit findings were already addressed in commit `7df76bd0a99949d062d2cd83575c8df731a29151` and therefore do not need new worker specs in this directory: diff --git a/spec/tasks/public-safe-on-demand-simulator-lifecycle.md b/spec/tasks/public-safe-on-demand-simulator-lifecycle.md new file mode 100644 index 0000000..268951d --- /dev/null +++ b/spec/tasks/public-safe-on-demand-simulator-lifecycle.md @@ -0,0 +1,161 @@ +# Public-Safe On-Demand Simulator Lifecycle +Related: `spec/tasks/README.md`, `spec/global-simulator-broker.md`, `spec/architecture.md`, `spec/build-and-test.md`, `spec/harness-integration.md` + +> **Document ID:** `GSB-TASK-010` +> **Version:** `1.0.0` +> **Last Updated:** `2026-08-10` +> **Status:** `Implemented` +> **Owner:** `spec-steward` +> **Implementation Owners:** `broker-core`, `client`, `macos-app` + +## 1. Objective + +Simulator Broker owns simulator reuse, idle shutdown, and operator controls. Consumer repositories remain unchanged: releasing an existing lease is sufficient. + +Registered aliases remain standby capacity and are never deleted by idle management. A low-concurrency client repeatedly reuses the same warm compatible alias; additional aliases are used only when concurrent demand requires them. When an operator configures an idle policy, unused automated simulators return to the shutdown state after the configured grace period. + +## 2. Fixed product decisions + +- Lease acquisition returns the selected simulator booted and ready for use. +- Candidate selection uses one rule only: matching pin, compatible booted alias, then compatible shutdown alias. Within each tier, the most recently released alias wins; configured alias order is the final deterministic tie-breaker. +- There is no legacy rotation mode, migration path, compatibility flag, or fallback selector. +- Idle policy is absent by default. Source, docs, fixtures, and onboarding provide no default duration. +- Idle management never deletes or renames registered aliases. +- The app and CLI are clients of broker authority and never write broker configuration or state directly. + +## 3. Scope and boundaries + +### In scope + +- deterministic warm reuse and boot-on-acquire +- broker-state-only idle policy +- direct and service-backed idle CLI/API commands +- scheduled reconciliation in `brokerd` +- one-time, human-confirmed cleanup of existing idle simulators +- count-only idle summaries and local audit events +- macOS Overview controls +- public-surface verification + +### Out of scope + +- consumer repository source or configuration changes +- deleting retained simulator capacity +- per-project idle policy +- unattended policy mutation or cleanup apply +- migration or backward-compatibility behavior +- committing a machine's aliases, simulator IDs, state roots, operator identity, or chosen duration + +## 4. Broker requirements + +### REQ-001 — Deterministic selection and acquisition + +After capability, explicit requirement, health, lease, pin, and optional explicit-alias filtering, the broker ranks available candidates as follows: + +1. an alias pinned to the requesting project and purpose +2. a compatible booted alias +3. a compatible shutdown alias + +Within each tier, a later valid `lastLeaseReleasedAt` ranks first. Missing or equal timestamps fall back to host-config order. The broker records the lease, performs the purpose's reset if required, boots a shutdown selection, and only then returns success. Reset or boot failure rolls back the lease and marks the alias `repair-needed`. + +### REQ-002 — Local policy artifact + +The policy is stored only at `/idle-policy.json`, outside every repository, with this exact schema: + +```json +{ + "version": 1, + "graceSeconds": 60 +} +``` + +The numeric value above demonstrates the minimum valid shape, not a shipped default. `graceSeconds` must be an integer from `60` through `86400`. Any additional field is invalid. File absence means not configured. + +### REQ-003 — CLI and API + +The public command surface is: + +```text +simbroker idle status +simbroker idle enable --grace-seconds <60-86400> --actor-type human --actor-id +simbroker idle disable --actor-type human --actor-id +simbroker idle reconcile +simbroker idle cleanup +simbroker idle cleanup --apply --confirm --actor-type human --actor-id +``` + +Policy enable/disable and cleanup apply require a non-empty human actor ID. Cleanup preview is non-mutating and returns only a candidate count, status, and deterministic plan ID. Apply must recompute the plan under the mutation lock and reject missing, non-human, or stale confirmation with exit code `5`. + +### REQ-004 — Scheduled reconciliation + +When `brokerd` starts, it immediately reconciles idle state before publishing its initial snapshot. It then reconciles every 30 seconds and refreshes the app snapshot after each run. Reconciliation executions must not overlap. + +A normal client lease acquisition lazily starts `brokerd` when policy is configured and no service is running. Explicit local-only mode neither starts nor schedules the service and reports `local-only-mode` as its scheduler limitation. + +### REQ-005 — Eligibility and locking + +Reconciliation takes the existing broker mutation lock and re-reads leases and simulator inventory. A registered alias is eligible only when all of these are true at the reconciliation timestamp: + +- power state is `booted` +- health is exactly `healthy` +- no active lease exists +- no pin exists +- capability is not `manual-persistent` +- `lastLeaseReleasedAt + graceSeconds` is at or before the reconciliation timestamp + +Stale-lease recovery records the recovery timestamp as the new release time, starting a fresh grace period. Unknown externally booted devices are not registered candidates and remain untouched. + +### REQ-006 — Failure handling and observability + +Successful shutdown changes the alias power state to `shutdown`. Shutdown failure changes its health to `repair-needed` with the stable reason `idle-shutdown-failed`, preventing timer retries until repair. + +Broker events and the app snapshot expose: + +- whether policy is configured +- configured grace duration or `null` +- currently eligible count +- last cleanup result +- next scheduled cleanup timestamp or `null` + +Cleanup results and idle command summaries may expose counts, status, timestamps, plan IDs, and stable reason codes. They must not expose local paths, host aliases, simulator IDs, operator IDs, or raw runtime errors. + +## 5. macOS app requirements + +The Overview screen contains an **Automatic shutdown** section that shows configured/not-configured state, grace duration, eligible count, last result, and these actions: + +- **Apply** validates an explicitly entered integer from `60` through `86400` and calls `idle enable` through broker service transport. +- **Disable** calls `idle disable` through broker service transport. +- **Clean idle simulators now** first requests cleanup preview, presents a count-only confirmation, and applies the confirmed plan only after operator approval. + +The duration field is blank when policy is unconfigured. The app refreshes its broker snapshot after policy mutation, lease mutation, scheduled reconciliation, service restart, and cleanup. It does not read or write `idle-policy.json` directly. + +## 6. Public-safety requirements + +- No real home path, host alias, simulator ID, state root, local timing choice, operator identity, consumer-product name, local log, screenshot, seeded configuration, or preference may enter tracked public text. +- Public fixtures use temporary roots and synthetic identifiers. +- Tests never use the default broker state root. +- `npm run verify:public-surface` scans tracked text plus non-ignored untracked text for the current home path and prohibited broker-state artifacts. +- An ignored root-level `.public-safety.local` may contain one literal denylist value per line. The scanner reports only rule numbers and paths, never the values. +- The normal `npm test` and release verification path runs `verify:public-surface`. + +## 7. Verification traceability + +| Requirement | Deterministic evidence | +|---|---| +| REQ-001 | repeated UI/build acquisition reuses the warm alias; concurrent demand uses standby; shutdown selection uses newest release; boot/reset failures roll back | +| REQ-002 | absent policy, exact schema, boundary validation, machine-local path tests | +| REQ-003 | parser, direct CLI, service parity, human confirmation, stale-plan, and privacy tests | +| REQ-004 | immediate startup reconcile, 30-second timer, lazy daemon start, local-only limitation, restart persistence tests | +| REQ-005 | grace boundary, lease, pin, manual, unhealthy, external-device, stale recovery, and mutation-lock tests | +| REQ-006 | successful shutdown, one-shot repair-needed failure, count-only serialization, event and snapshot tests | +| App | Swift model decoding, store commands, confirmation flow, snapshot refresh, and app build/test suite | +| Public safety | scanner unit tests plus `npm run verify:public-surface` in the normal gate | + +## 8. Rollout boundary + +Release and machine rollout are separate steps. After a public release, the local operator chooses and enables the desired valid duration, applies one confirmed cleanup plan, and verifies that sequential work reuses warm aliases while concurrent work expands only to the required standby aliases. The chosen value and resulting local state remain outside this repository. + +## Document history + +| Version | Date | Author | Changes | +|---|---|---|---| +| 1.0.0 | 2026-08-10 | Codex | Finalized and implemented the public-safe lifecycle contract with boot-on-acquire and no compatibility mode. | From b673c9c02687a9fad261441b844fc2d484a77ecd Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 10 Aug 2026 02:18:51 +0800 Subject: [PATCH 2/6] feat: add automatic shutdown controls Why: Give operators an explicit, broker-backed way to configure and run safe idle cleanup without writing host state from the app. Changed: Add the Overview automatic-shutdown section, count-only cleanup confirmation, broker command integration, snapshot refresh behavior, temporary-root app tests, and distribution-time public-surface verification. Verification: npm test; scripts/test_app.sh Affected: app/**, scripts/package_distribution.sh, scripts/test_app.sh Refs: spec/tasks/public-safe-on-demand-simulator-lifecycle.md Session: task-sessions/20260810-public-safe-idle-app --- app/README.md | 9 ++ app/Sources/AutomaticShutdownSection.swift | 162 +++++++++++++++++++++ app/Sources/BrokerCommandSupport.swift | 7 + app/Sources/BrokerDashboardStore.swift | 108 ++++++++++++++ app/Sources/BrokerServiceClient.swift | 62 +++++++- app/Sources/BrokerSnapshotModels.swift | 18 +++ app/Sources/OverviewScreen.swift | 2 + app/Tests/BrokerDashboardStoreTests.swift | 101 +++++++++++++ app/Tests/BrokerServiceClientTests.swift | 24 ++- app/Tests/Fixtures/busy-snapshot.json | 7 + app/project.yml | 3 + scripts/package_distribution.sh | 3 + scripts/test_app.sh | 8 + 13 files changed, 511 insertions(+), 3 deletions(-) create mode 100644 app/Sources/AutomaticShutdownSection.swift diff --git a/app/README.md b/app/README.md index c5d25ce..57dd453 100644 --- a/app/README.md +++ b/app/README.md @@ -15,6 +15,8 @@ Current implementation: - XCTest coverage under `app/Tests/` - runtime data source is the broker-owned `app-snapshot.json` artifact under the broker state root - broker mutations are sent through the local `brokerd` Unix socket so the app shares the same authority as the CLI +- Overview includes Automatic shutdown status, explicit duration entry, + Apply/Disable actions, and count-confirmed cleanup over broker transport - local-debug packaging stays available through `scripts/package_local.sh` - Release distribution packaging now lives in `scripts/package_distribution.sh` and writes an explicit readiness summary instead of implying shipping readiness @@ -44,6 +46,8 @@ npm run package:distribution If you already have the repo checkout on the target machine, prefer `npm run install:local` plus `source "$HOME/Library/Application Support/SimulatorBroker/install/env.sh"` over `npm run package:local`. `bash scripts/test_app.sh` writes stable result bundles under `artifacts/app-tests/` by default and prints the exact `xcodebuild` command it executes, so focused reruns can be collected into a task session without reconstructing the command by hand. +Each run also gives the XCTest host a fresh temporary broker state root and +host-config path instead of touching the default local broker installation. The Codex app `Run` action is expected to point at `./script/build_and_run.sh`. @@ -57,8 +61,13 @@ When the local service is running, the app can: - create and clear pins - release active leases - request `boot`, `shutdown`, `erase`, and `repair` +- configure or disable Automatic shutdown with an explicitly entered valid duration +- preview a count and confirm one-time cleanup of currently idle automated simulators - surface broker override-required errors and ask the human for confirmation details +Automatic shutdown is unconfigured initially, so the duration field is blank. +The app never writes host configuration, policy, or state files directly. + The current local install flow copies the built app bundle to `~/Applications/Simulator Broker.app` by default. For smoke tests or alternate broker fixtures, override the state root at launch time: diff --git a/app/Sources/AutomaticShutdownSection.swift b/app/Sources/AutomaticShutdownSection.swift new file mode 100644 index 0000000..cbde930 --- /dev/null +++ b/app/Sources/AutomaticShutdownSection.swift @@ -0,0 +1,162 @@ +import SwiftUI + +struct AutomaticShutdownSection: View { + @Bindable var store: BrokerDashboardStore + let idle: BrokerIdleSummary + + @State private var graceSecondsText = "" + + var body: some View { + GroupBox("Automatic shutdown") { + VStack(alignment: .leading, spacing: 16) { + summaryGrid + + Divider() + + controls + + Text(inputGuidance) + .font(.caption) + .foregroundStyle(graceSecondsText.isEmpty || graceSeconds != nil ? Color.secondary : Color.red) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .onAppear(perform: synchronizeInput) + .onChange(of: idle.graceSeconds) { _, _ in + synchronizeInput() + } + .confirmationDialog( + Text("Clean idle simulators now?"), + isPresented: cleanupConfirmationPresented, + presenting: store.pendingIdleCleanupRequest + ) { request in + Button(cleanupButtonTitle(count: request.eligibleCount), role: .destructive) { + store.confirmIdleCleanup() + } + } message: { request in + Text(cleanupConfirmationMessage(count: request.eligibleCount)) + } + } + + private var summaryGrid: some View { + LazyVGrid( + columns: [ + GridItem(.flexible(minimum: 220), spacing: 18), + GridItem(.flexible(minimum: 220), spacing: 18), + ], + alignment: .leading, + spacing: 12 + ) { + LabeledContent("Policy") { + StatusPill( + color: idle.configured ? .green : .secondary, + title: idle.configured ? "Configured" : "Not configured" + ) + } + LabeledContent("Grace duration", value: configuredDurationText) + LabeledContent("Eligible now", value: "\(idle.eligibleCount)") + LabeledContent("Last result", value: lastResultText) + } + } + + private var controls: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + TextField("60–86400 seconds", text: $graceSecondsText) + .frame(width: 180) + .textFieldStyle(.roundedBorder) + .accessibilityLabel("Automatic shutdown grace duration in seconds") + + Button("Apply", action: applyPolicy) + .buttonStyle(.borderedProminent) + .disabled(graceSeconds == nil || store.canSendCommands == false || store.isApplyingAction) + + Button("Disable", action: disablePolicy) + .disabled(idle.configured == false || store.canSendCommands == false || store.isApplyingAction) + + Spacer() + + Button("Clean idle simulators now", role: .destructive) { + store.requestIdleCleanup() + } + .disabled(store.canSendCommands == false || store.isApplyingAction) + } + } + + private var cleanupConfirmationPresented: Binding { + Binding( + get: { store.pendingIdleCleanupRequest != nil }, + set: { isPresented in + if isPresented == false { + store.pendingIdleCleanupRequest = nil + } + } + ) + } + + private var configuredDurationText: String { + guard let graceSeconds = idle.graceSeconds else { + return "Not configured" + } + return "\(graceSeconds) seconds" + } + + private var graceSeconds: Int? { + guard let value = Int(graceSecondsText), (60 ... 86_400).contains(value) else { + return nil + } + return value + } + + private var inputGuidance: String { + if graceSecondsText.isEmpty { + return "Enter a whole number of seconds from 60 through 86400." + } + if graceSeconds == nil { + return "The duration must be a whole number from 60 through 86400 seconds." + } + return "Unused automated simulators are shut down after this grace period." + } + + private var lastResultText: String { + guard let result = idle.lastCleanupResult else { + return "No cleanup recorded" + } + let status = result.status.replacingOccurrences(of: "_", with: " ").capitalized + return "\(status) · \(result.shutdownCount) shut down · \(result.failureCount) need repair" + } + + private func cleanupButtonTitle(count: Int) -> String { + count == 1 ? "Shut down 1 simulator" : "Shut down \(count) simulators" + } + + private func cleanupConfirmationMessage(count: Int) -> String { + count == 1 + ? "1 currently idle simulator is eligible for shutdown." + : "\(count) currently idle simulators are eligible for shutdown." + } + + private func synchronizeInput() { + graceSecondsText = idle.graceSeconds.map(String.init) ?? "" + } + + private func applyPolicy() { + guard let graceSeconds else { return } + Task { @MainActor in + do { + try await store.applyIdlePolicy(graceSeconds: graceSeconds) + } catch { + store.lastErrorMessage = error.localizedDescription + } + } + } + + private func disablePolicy() { + Task { @MainActor in + do { + try await store.disableIdlePolicy() + } catch { + store.lastErrorMessage = error.localizedDescription + } + } + } +} diff --git a/app/Sources/BrokerCommandSupport.swift b/app/Sources/BrokerCommandSupport.swift index 4b17978..f43b664 100644 --- a/app/Sources/BrokerCommandSupport.swift +++ b/app/Sources/BrokerCommandSupport.swift @@ -47,6 +47,13 @@ struct BrokerPendingLeaseReleaseRequest: Identifiable { var id: String { lease.leaseId } } +struct BrokerPendingIdleCleanupRequest: Identifiable { + let eligibleCount: Int + let planId: String + + var id: String { planId } +} + private struct BrokerDashboardStoreFocusedKey: FocusedValueKey { typealias Value = BrokerDashboardStore } diff --git a/app/Sources/BrokerDashboardStore.swift b/app/Sources/BrokerDashboardStore.swift index 0e05b85..024447f 100644 --- a/app/Sources/BrokerDashboardStore.swift +++ b/app/Sources/BrokerDashboardStore.swift @@ -78,11 +78,15 @@ final class BrokerDashboardStore { var loadedState: BrokerLoadedState? var pendingClearPinRequest: BrokerPendingClearPinRequest? var pendingCreatePinRequest: BrokerPendingCreatePinRequest? + var pendingIdleCleanupRequest: BrokerPendingIdleCleanupRequest? var pendingLifecycleRequest: BrokerPendingLifecycleRequest? var pendingOverrideRequest: BrokerLifecycleOverrideRequest? var pendingReleaseLeaseRequest: BrokerPendingLeaseReleaseRequest? var selectedPane: BrokerNavigationPane = .overview { didSet { + if selectedPane != .overview { + pendingIdleCleanupRequest = nil + } guard selectedPane != .simulators else { return } @@ -597,6 +601,110 @@ final class BrokerDashboardStore { } } + func applyIdlePolicy(graceSeconds: Int) async throws { + guard (60 ... 86_400).contains(graceSeconds) else { + throw BrokerServiceCommandClientError.transportFailure("Enter a whole number of seconds from 60 through 86400.") + } + let request = BrokerCommandRequest( + command: "enable", + group: "idle", + options: [ + "actorId": .string(Self.appHumanActorId), + "actorType": .string("human"), + "graceSeconds": .int(graceSeconds), + ] + ) + try await executeMutation( + actionName: "idle-enable", + successMessage: "Automatic shutdown updated." + ) { [self, request] in + _ = try await self.commandClient.send(request) + } + } + + func disableIdlePolicy() async throws { + let request = BrokerCommandRequest( + command: "disable", + group: "idle", + options: [ + "actorId": .string(Self.appHumanActorId), + "actorType": .string("human"), + ] + ) + try await executeMutation( + actionName: "idle-disable", + successMessage: "Automatic shutdown disabled." + ) { [self, request] in + _ = try await self.commandClient.send(request) + } + } + + func requestIdleCleanup() { + guard canSendCommands, isApplyingAction == false else { + return + } + Task { + isApplyingAction = true + defer { isApplyingAction = false } + do { + let response = try await commandClient.send( + BrokerCommandRequest(command: "cleanup", group: "idle", options: [:]) + ) + guard let eligibleCount = response.eligibleCount, + let planId = response.planId, + planId.isEmpty == false + else { + throw BrokerServiceCommandClientError.invalidJSONResponse + } + pendingIdleCleanupRequest = BrokerPendingIdleCleanupRequest( + eligibleCount: eligibleCount, + planId: planId + ) + lastErrorMessage = nil + } catch { + lastErrorMessage = error.localizedDescription + } + } + } + + func confirmIdleCleanup() { + guard let pendingIdleCleanupRequest else { + return + } + self.pendingIdleCleanupRequest = nil + Task { + do { + try await applyConfirmedIdleCleanup(pendingIdleCleanupRequest) + } catch { + lastErrorMessage = error.localizedDescription + } + } + } + + private func applyConfirmedIdleCleanup(_ cleanup: BrokerPendingIdleCleanupRequest) async throws { + let request = BrokerCommandRequest( + command: "cleanup", + group: "idle", + options: [ + "actorId": .string(Self.appHumanActorId), + "actorType": .string("human"), + "apply": .bool(true), + "confirmPlanId": .string(cleanup.planId), + ] + ) + do { + try await executeMutation( + actionName: "idle-cleanup", + successMessage: "Idle cleanup completed. Review the last result below." + ) { [self, request] in + _ = try await self.commandClient.send(request) + } + } catch { + _ = await refresh(silent: true) + throw error + } + } + func releaseLease(_ lease: BrokerLease) async throws { let request = BrokerCommandRequest( command: "release", diff --git a/app/Sources/BrokerServiceClient.swift b/app/Sources/BrokerServiceClient.swift index e8b394f..2b72f9c 100644 --- a/app/Sources/BrokerServiceClient.swift +++ b/app/Sources/BrokerServiceClient.swift @@ -110,7 +110,7 @@ struct BrokerCommandRequest: Sendable { private static let hostBootstrapReplacementStateLoads = 1 private static let hostBootstrapRetirementStateLoads = 1 private static let hostBootstrapSimctlCommandsPerAlias = 5 - private static let leaseAcquireResetSimctlCommands = 2 + private static let leaseAcquireSimctlCommands = 3 private static let processSamplerInvocationsPerStateLoad = 1 private static let processSamplerTimeoutSeconds = 10 private static let simctlInventoryCommandsPerStateLoad = 3 @@ -201,6 +201,13 @@ struct BrokerCommandRequest: Sendable { + stateLoadBudget + simulatorLifecycleSimctlBudgetSeconds(paths: paths) } + if group == "idle" { + return Self.defaultCommandTimeoutSeconds + + leaseLockTimeoutSeconds + + snapshotLockBudget + + stateLoadBudget + + idleShutdownSimctlBudgetSeconds(paths: paths) + } if usesLeaseMutationLock { return Self.defaultCommandTimeoutSeconds + leaseLockTimeoutSeconds @@ -412,7 +419,7 @@ struct BrokerCommandRequest: Sendable { private var leaseAcquireResetSimctlBudgetSeconds: Int { group == "lease" && command == "acquire" - ? Self.leaseAcquireResetSimctlCommands * Self.simctlCommandTimeoutSeconds + ? Self.leaseAcquireSimctlCommands * Self.simctlCommandTimeoutSeconds : 0 } @@ -434,6 +441,9 @@ struct BrokerCommandRequest: Sendable { if group == "simulators" { return ["boot", "erase", "repair", "shutdown"].contains(command) } + if group == "idle" { + return true + } return false } @@ -490,6 +500,21 @@ struct BrokerCommandRequest: Sendable { return commandCount * Self.simctlCommandTimeoutSeconds } + private func idleShutdownSimctlBudgetSeconds(paths: BrokerRuntimePaths?) -> Int { + guard group == "idle", + command == "reconcile" || (command == "cleanup" && options["apply"]?.boolValue == true) + else { + return 0 + } + guard let paths, + let data = try? Data(contentsOf: paths.hostConfigURL), + let hostConfig = try? JSONDecoder().decode(BrokerHostConfigTimeoutSummary.self, from: data) + else { + return Self.hostBootstrapAliasCount * Self.simctlCommandTimeoutSeconds + } + return (hostConfig.aliases?.count ?? 0) * Self.simctlCommandTimeoutSeconds + } + private func timeoutSeconds(option: String, fallbackMilliseconds: Int = 0) -> Int { let milliseconds = timeoutMilliseconds(option: option, fallbackMilliseconds: fallbackMilliseconds) guard milliseconds > 0 else { @@ -537,12 +562,45 @@ private struct BrokerHostConfigTimeoutSummary: Decodable { struct BrokerCommandEnvelope: Decodable, Sendable { let currentHolder: BrokerLeaseSummary? + let eligibleCount: Int? let error: String? let exitCode: Int? + let failureCount: Int? let ok: Bool? + let planId: String? let reasonCode: String? let requiredConfirmationFields: [String]? + let shutdownCount: Int? + let status: String? let unchanged: Bool? + + init( + currentHolder: BrokerLeaseSummary?, + eligibleCount: Int? = nil, + error: String?, + exitCode: Int?, + failureCount: Int? = nil, + ok: Bool?, + planId: String? = nil, + reasonCode: String?, + requiredConfirmationFields: [String]?, + shutdownCount: Int? = nil, + status: String? = nil, + unchanged: Bool? + ) { + self.currentHolder = currentHolder + self.eligibleCount = eligibleCount + self.error = error + self.exitCode = exitCode + self.failureCount = failureCount + self.ok = ok + self.planId = planId + self.reasonCode = reasonCode + self.requiredConfirmationFields = requiredConfirmationFields + self.shutdownCount = shutdownCount + self.status = status + self.unchanged = unchanged + } } struct BrokerLifecycleOverrideRequest: Identifiable, Sendable { diff --git a/app/Sources/BrokerSnapshotModels.swift b/app/Sources/BrokerSnapshotModels.swift index ff5f1a2..f04c061 100644 --- a/app/Sources/BrokerSnapshotModels.swift +++ b/app/Sources/BrokerSnapshotModels.swift @@ -5,6 +5,7 @@ struct BrokerAppSnapshot: Decodable, Sendable { let generatedAt: String let hostConfigPath: String? let hostId: String + let idle: BrokerIdleSummary let ok: Bool let overview: BrokerOverview let pins: [BrokerPin] @@ -14,6 +15,23 @@ struct BrokerAppSnapshot: Decodable, Sendable { let stateRoot: String } +struct BrokerIdleSummary: Decodable, Sendable { + let configured: Bool + let eligibleCount: Int + let graceSeconds: Int? + let lastCleanupResult: BrokerIdleCleanupResult? + let nextScheduledCleanupAt: String? +} + +struct BrokerIdleCleanupResult: Decodable, Sendable { + let completedAt: String + let eligibleCount: Int + let failureCount: Int + let shutdownCount: Int + let source: String + let status: String +} + struct BrokerOverview: Decodable, Sendable { let leaseSaturation: Double let leasedAliases: Int diff --git a/app/Sources/OverviewScreen.swift b/app/Sources/OverviewScreen.swift index 47dfd55..d345fbd 100644 --- a/app/Sources/OverviewScreen.swift +++ b/app/Sources/OverviewScreen.swift @@ -44,6 +44,8 @@ struct OverviewScreen: View { ) } + AutomaticShutdownSection(store: store, idle: snapshot.idle) + GroupBox("Broker source") { LazyVGrid( columns: [ diff --git a/app/Tests/BrokerDashboardStoreTests.swift b/app/Tests/BrokerDashboardStoreTests.swift index a296a55..4b70f10 100644 --- a/app/Tests/BrokerDashboardStoreTests.swift +++ b/app/Tests/BrokerDashboardStoreTests.swift @@ -3,6 +3,99 @@ import XCTest @MainActor final class BrokerDashboardStoreTests: XCTestCase { + func testIdlePolicyCommandsUseHumanActorAndRefreshSnapshot() async throws { + let snapshot = try loadFixture(named: "busy-snapshot") + let loadedState = makeLoadedState(snapshot: snapshot) + let commandClient = RecordingCommandClient() + let store = BrokerDashboardStore( + loader: StubSnapshotLoader(state: loadedState), + commandClient: commandClient, + runtimePaths: loadedState.paths + ) + store.loadedState = loadedState + + try await store.applyIdlePolicy(graceSeconds: 120) + try await store.disableIdlePolicy() + + let requests = await commandClient.requests() + XCTAssertEqual(requests.count, 2) + XCTAssertEqual(requests[0].group, "idle") + XCTAssertEqual(requests[0].command, "enable") + XCTAssertEqual(requests[0].options["graceSeconds"]?.intValue, 120) + XCTAssertEqual(requests[0].options["actorType"]?.stringValue, "human") + XCTAssertEqual(requests[0].options["actorId"]?.stringValue, "simulator-broker-app") + XCTAssertEqual(requests[1].group, "idle") + XCTAssertEqual(requests[1].command, "disable") + XCTAssertEqual(requests[1].options["actorType"]?.stringValue, "human") + XCTAssertEqual(requests[1].options["actorId"]?.stringValue, "simulator-broker-app") + } + + func testIdlePolicyRejectsInvalidDurationBeforeSending() async throws { + let snapshot = try loadFixture(named: "busy-snapshot") + let loadedState = makeLoadedState(snapshot: snapshot) + let commandClient = RecordingCommandClient() + let store = BrokerDashboardStore( + loader: StubSnapshotLoader(state: loadedState), + commandClient: commandClient, + runtimePaths: loadedState.paths + ) + store.loadedState = loadedState + + do { + try await store.applyIdlePolicy(graceSeconds: 59) + XCTFail("Expected invalid duration") + } catch { + XCTAssertEqual(error.localizedDescription, "Enter a whole number of seconds from 60 through 86400.") + } + let requests = await commandClient.requests() + XCTAssertTrue(requests.isEmpty) + } + + func testIdleCleanupStagesCountOnlyPreviewThenSendsConfirmedPlan() async throws { + let snapshot = try loadFixture(named: "busy-snapshot") + let loadedState = makeLoadedState(snapshot: snapshot) + let commandClient = RecordingCommandClient() + await commandClient.enqueueResponse( + BrokerCommandEnvelope( + currentHolder: nil, + eligibleCount: 2, + error: nil, + exitCode: nil, + ok: true, + planId: "cleanup-plan", + reasonCode: nil, + requiredConfirmationFields: nil, + status: "changes_required", + unchanged: nil + ) + ) + let store = BrokerDashboardStore( + loader: StubSnapshotLoader(state: loadedState), + commandClient: commandClient, + runtimePaths: loadedState.paths + ) + store.loadedState = loadedState + + store.requestIdleCleanup() + try await waitUntil { store.pendingIdleCleanupRequest != nil } + XCTAssertEqual(store.pendingIdleCleanupRequest?.eligibleCount, 2) + XCTAssertEqual(store.pendingIdleCleanupRequest?.planId, "cleanup-plan") + + store.confirmIdleCleanup() + try await waitUntil { await commandClient.requests().count == 2 } + let requests = await commandClient.requests() + XCTAssertEqual(requests[0].group, "idle") + XCTAssertEqual(requests[0].command, "cleanup") + XCTAssertTrue(requests[0].options.isEmpty) + XCTAssertEqual(requests[1].group, "idle") + XCTAssertEqual(requests[1].command, "cleanup") + XCTAssertEqual(requests[1].options["apply"]?.boolValue, true) + XCTAssertEqual(requests[1].options["confirmPlanId"]?.stringValue, "cleanup-plan") + XCTAssertEqual(requests[1].options["actorType"]?.stringValue, "human") + XCTAssertEqual(requests[1].options["actorId"]?.stringValue, "simulator-broker-app") + XCTAssertNil(store.pendingIdleCleanupRequest) + } + func testCreatePinSendsProjectFilePathPurposeAndNote() async throws { let snapshot = try loadFixture(named: "busy-snapshot") let loadedState = makeLoadedState(snapshot: snapshot) @@ -1011,12 +1104,16 @@ final class BrokerDashboardStoreTests: XCTestCase { private actor RecordingCommandClient: BrokerCommandSending { private var recordedRequests: [BrokerCommandRequest] = [] private var stubbedError: Error? + private var stubbedResponses: [BrokerCommandEnvelope] = [] func send(_ request: BrokerCommandRequest) async throws -> BrokerCommandEnvelope { recordedRequests.append(request) if let stubbedError { throw stubbedError } + if stubbedResponses.isEmpty == false { + return stubbedResponses.removeFirst() + } return BrokerCommandEnvelope( currentHolder: nil, error: nil, @@ -1035,6 +1132,10 @@ private actor RecordingCommandClient: BrokerCommandSending { func setError(_ error: Error?) { stubbedError = error } + + func enqueueResponse(_ response: BrokerCommandEnvelope) { + stubbedResponses.append(response) + } } private actor RecordingLocalCommandRunner: BrokerLocalCommandRunning { diff --git a/app/Tests/BrokerServiceClientTests.swift b/app/Tests/BrokerServiceClientTests.swift index 509deca..ff4f090 100644 --- a/app/Tests/BrokerServiceClientTests.swift +++ b/app/Tests/BrokerServiceClientTests.swift @@ -99,7 +99,7 @@ final class BrokerServiceClientTests: XCTestCase { ).executionTimeoutSeconds, 7100 ) - XCTAssertEqual(BrokerCommandRequest(command: "acquire", group: "lease", options: [:]).executionTimeoutSeconds, 1191) + XCTAssertEqual(BrokerCommandRequest(command: "acquire", group: "lease", options: [:]).executionTimeoutSeconds, 1311) XCTAssertEqual(BrokerCommandRequest(command: "release", group: "lease", options: [:]).executionTimeoutSeconds, 890) XCTAssertEqual(BrokerCommandRequest(command: "release", group: "lease", options: [:]).transferTimeoutSeconds, 950) XCTAssertEqual(BrokerCommandRequest(command: "create", group: "pin", options: [:]).executionTimeoutSeconds, 890) @@ -107,6 +107,11 @@ final class BrokerServiceClientTests: XCTestCase { XCTAssertEqual(BrokerCommandRequest(command: "status", group: "host", options: [:]).executionTimeoutSeconds, 890) XCTAssertEqual(BrokerCommandRequest(command: "status", group: "doctor", options: [:]).executionTimeoutSeconds, 890) XCTAssertEqual(BrokerCommandRequest(command: "explain", group: "lease", options: [:]).executionTimeoutSeconds, 890) + XCTAssertEqual(BrokerCommandRequest(command: "status", group: "idle", options: [:]).executionTimeoutSeconds, 890) + XCTAssertEqual( + BrokerCommandRequest(command: "cleanup", group: "idle", options: ["apply": .bool(true)]).executionTimeoutSeconds, + 1610 + ) XCTAssertEqual(BrokerCommandRequest(command: "shutdown", group: "simulators", options: [:]).executionTimeoutSeconds, 1010) XCTAssertEqual(BrokerCommandRequest(command: "shutdown", group: "simulators", options: [:]).transferTimeoutSeconds, 1070) XCTAssertEqual(BrokerCommandRequest(command: "erase", group: "simulators", options: [:]).executionTimeoutSeconds, 1130) @@ -167,6 +172,23 @@ final class BrokerServiceClientTests: XCTestCase { ) } + func testIdleCleanupEnvelopeDecodesCountOnlyPlanFields() throws { + let envelope = try JSONDecoder().decode(BrokerCommandEnvelope.self, from: Data(""" + { + "eligibleCount": 2, + "ok": true, + "planId": "cleanup-plan", + "schemaVersion": 1, + "status": "changes_required" + } + """.utf8)) + + XCTAssertEqual(envelope.eligibleCount, 2) + XCTAssertEqual(envelope.planId, "cleanup-plan") + XCTAssertEqual(envelope.status, "changes_required") + XCTAssertTrue(envelope.ok == true) + } + func testCommandTransferTimeoutsCoverStaleContainmentBudget() throws { let fixture = try makeServiceFixture() let leasesURL = fixture.paths.stateRoot.appending(path: "leases") diff --git a/app/Tests/Fixtures/busy-snapshot.json b/app/Tests/Fixtures/busy-snapshot.json index 1ae41a8..e7c6e5d 100644 --- a/app/Tests/Fixtures/busy-snapshot.json +++ b/app/Tests/Fixtures/busy-snapshot.json @@ -3,6 +3,13 @@ "generatedAt": "2026-04-09T10:15:00Z", "hostConfigPath": "/tmp/simbroker-fixture/host-config.json", "hostId": "fixture-host", + "idle": { + "configured": false, + "eligibleCount": 0, + "graceSeconds": null, + "lastCleanupResult": null, + "nextScheduledCleanupAt": null + }, "stateRoot": "/tmp/simbroker-fixture/state", "overview": { "leaseSaturation": 0.67, diff --git a/app/project.yml b/app/project.yml index 984fb38..f2b6742 100644 --- a/app/project.yml +++ b/app/project.yml @@ -44,3 +44,6 @@ schemes: test: targets: - name: SimulatorBrokerAppTests + environmentVariables: + SIMBROKER_HOST_CONFIG: "$(SIMBROKER_TEST_HOST_CONFIG)" + SIMBROKER_STATE_ROOT: "$(SIMBROKER_TEST_STATE_ROOT)" diff --git a/scripts/package_distribution.sh b/scripts/package_distribution.sh index e533231..ee0d47c 100755 --- a/scripts/package_distribution.sh +++ b/scripts/package_distribution.sh @@ -120,6 +120,9 @@ done validate_archive_name "$archive_name" +cd "$repo_root" +npm run verify:public-surface + if [[ -z "$signing_identity" ]]; then echo "package_distribution.sh requires --signing-identity or SIMBROKER_DISTRIBUTION_SIGNING_IDENTITY." >&2 exit 1 diff --git a/scripts/test_app.sh b/scripts/test_app.sh index 668a313..9a15be7 100755 --- a/scripts/test_app.sh +++ b/scripts/test_app.sh @@ -7,6 +7,12 @@ derived_data_path="${DERIVED_DATA_PATH:-$repo_root/DerivedData/SimulatorBrokerAp result_bundle_path="" build_only=false declare -a only_testing_filters=() +test_runtime_root="$(mktemp -d "${TMPDIR:-/tmp}/simbroker-app-tests.XXXXXX")" + +cleanup() { + rm -rf "$test_runtime_root" +} +trap cleanup EXIT usage() { cat <<'EOF' @@ -133,6 +139,8 @@ declare -a xcodebuild_command=( -derivedDataPath "$derived_data_path" -destination "platform=macOS" -resultBundlePath "$result_bundle_path" + "SIMBROKER_TEST_HOST_CONFIG=$test_runtime_root/host-config.json" + "SIMBROKER_TEST_STATE_ROOT=$test_runtime_root/state" ) if ((${#only_testing_filters[@]} > 0)); then From 5ff7e6450cea217fedec36ad40d50d5e16a844c8 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 10 Aug 2026 02:21:42 +0800 Subject: [PATCH 3/6] chore: gate public surface verification Why: Prevent machine-local identities, paths, and operator artifacts from entering tracked public text. Changed: Run the public-surface scanner from the normal test gate and expose a dedicated verification command. Verification: npm test; npm run verify:public-surface Affected: package.json Refs: spec/tasks/public-safe-on-demand-simulator-lifecycle.md Session: task-sessions/20260810-public-safe-idle-gate --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c7624fa..cb11757 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "install:local": "bash ./scripts/install_local.sh", "package:distribution": "bash ./scripts/package_distribution.sh", "package:local": "bash ./scripts/package_local.sh", - "test": "npm run test:broker-core && npm run test:client && npm run test:harness-adoption && npm run test:app", + "test": "npm run verify:public-surface && npm run test:broker-core && npm run test:client && npm run test:harness-adoption && npm run test:app", "test:app": "bash ./scripts/test_app.sh", "test:app:build": "bash ./scripts/test_app.sh --build-only", "test:app:focus": "bash ./scripts/test_app.sh --only-testing", @@ -27,7 +27,8 @@ "test:client": "node --test client/test/*.test.mjs", "test:harness-adoption": "node --test examples/harness-adoption/test/*.test.mjs", "test:install-smoke": "bash ./scripts/install_smoke.sh", - "test:package-smoke": "bash ./scripts/package_smoke.sh" + "test:package-smoke": "bash ./scripts/package_smoke.sh", + "verify:public-surface": "node client/public-surface.mjs" }, "version": "0.1.0", "description": "Local simulator broker and macOS operator app for coordinated iOS Simulator workflows", From 3065c9e6e10cebc1082e7c54832b1f01a33c3468 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 10 Aug 2026 02:47:14 +0800 Subject: [PATCH 4/6] autopilot: address PR #1 review feedback Why: - Resolve actionable GitHub review feedback for PR #1. Changed: - Applied safe fixes for all 4 active PR feedback items. Verification passed: `npm run test:broker-core`, `npm run test:client`, `bash scripts/test_app.sh --only-testing SimulatorBrokerAppTests/BrokerDashboardStoreTests/testSnapshotDecodesMissingIdleAsUnconfiguredDefault`, `npm run verify:public-surface`, and `git diff --check`. Verification: - npm run agent:complete -- --session-dir [controller artifact: jobs/pr-1/20260810-023703-55123191-5916-4a75-9d90-3abbda7c4268/task-session] - npm run agent:context -- --paths-file [controller artifact: jobs/pr-1/20260810-023703-55123191-5916-4a75-9d90-3abbda7c4268/actual-paths.txt] --session-dir [controller artifact: jobs/pr-1/20260810-023703-55123191-5916-4a75-9d90-3abbda7c4268/task-session] - npm run agent:verify -- --profile implementation --paths-file [controller artifact: jobs/pr-1/20260810-023703-55123191-5916-4a75-9d90-3abbda7c4268/task-session/verify/implementation/paths.txt] --session-dir [controller artifact: jobs/pr-1/20260810-023703-55123191-5916-4a75-9d90-3abbda7c4268/task-session] - npm run agent:verify -- --profile spec-only --paths-file [controller artifact: jobs/pr-1/20260810-023703-55123191-5916-4a75-9d90-3abbda7c4268/task-session/verify/spec-only/paths.txt] --session-dir [controller artifact: jobs/pr-1/20260810-023703-55123191-5916-4a75-9d90-3abbda7c4268/task-session] Affected: - .gitignore - README.md - app/README.md - app/Sources/AutomaticShutdownSection.swift - app/Sources/BrokerCommandSupport.swift - app/Sources/BrokerDashboardStore.swift - app/Sources/BrokerServiceClient.swift - app/Sources/BrokerSnapshotModels.swift - app/Sources/OverviewScreen.swift - app/Tests/BrokerDashboardStoreTests.swift - app/Tests/BrokerServiceClientTests.swift - app/Tests/Fixtures/busy-snapshot.json - app/project.yml - broker-core/error-contract.mjs - broker-core/index.mjs - broker-core/test/broker-core.test.mjs - client/README.md - client/bin/simbroker.mjs - client/command-dispatch.mjs - client/public-surface.mjs - client/service/brokerd.mjs - client/service/service-client.mjs - client/test/brokerd.test.mjs - client/test/public-surface.test.mjs - client/test/simbroker.test.mjs - package.json - scripts/package_distribution.sh - scripts/test_app.sh - spec/README.md - spec/architecture.md - spec/build-and-test.md - spec/global-simulator-broker.md - spec/harness-integration.md - spec/implementation-plan.md - spec/project-structure.md - spec/tasks/README.md - spec/tasks/public-safe-on-demand-simulator-lifecycle.md Refs: - https://github.com/fiveonecode/simulator-broker/pull/1 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744815959 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744815966 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744815970 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744815975 Session: - task-session: [controller artifact: jobs/pr-1/20260810-023703-55123191-5916-4a75-9d90-3abbda7c4268/task-session] - report: [controller artifact: jobs/pr-1/20260810-023703-55123191-5916-4a75-9d90-3abbda7c4268/report.md] --- app/Sources/BrokerSnapshotModels.swift | 39 +++++++++++++ app/Tests/BrokerDashboardStoreTests.swift | 18 ++++++ broker-core/index.mjs | 2 +- broker-core/test/broker-core.test.mjs | 44 +++++++++++++++ client/command-dispatch.mjs | 16 +++--- client/service/service-client.mjs | 8 +++ client/test/simbroker.test.mjs | 68 ++++++++++++++++++++++- 7 files changed, 185 insertions(+), 10 deletions(-) diff --git a/app/Sources/BrokerSnapshotModels.swift b/app/Sources/BrokerSnapshotModels.swift index f04c061..8233544 100644 --- a/app/Sources/BrokerSnapshotModels.swift +++ b/app/Sources/BrokerSnapshotModels.swift @@ -13,6 +13,37 @@ struct BrokerAppSnapshot: Decodable, Sendable { let recentEvents: [BrokerEvent] let simulators: [BrokerSimulator] let stateRoot: String + + private enum CodingKeys: String, CodingKey { + case activeLeases + case generatedAt + case hostConfigPath + case hostId + case idle + case ok + case overview + case pins + case projects + case recentEvents + case simulators + case stateRoot + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + activeLeases = try container.decode([BrokerLease].self, forKey: .activeLeases) + generatedAt = try container.decode(String.self, forKey: .generatedAt) + hostConfigPath = try container.decodeIfPresent(String.self, forKey: .hostConfigPath) + hostId = try container.decode(String.self, forKey: .hostId) + idle = try container.decodeIfPresent(BrokerIdleSummary.self, forKey: .idle) ?? .unconfigured + ok = try container.decode(Bool.self, forKey: .ok) + overview = try container.decode(BrokerOverview.self, forKey: .overview) + pins = try container.decode([BrokerPin].self, forKey: .pins) + projects = try container.decode([BrokerProjectSummary].self, forKey: .projects) + recentEvents = try container.decode([BrokerEvent].self, forKey: .recentEvents) + simulators = try container.decode([BrokerSimulator].self, forKey: .simulators) + stateRoot = try container.decode(String.self, forKey: .stateRoot) + } } struct BrokerIdleSummary: Decodable, Sendable { @@ -21,6 +52,14 @@ struct BrokerIdleSummary: Decodable, Sendable { let graceSeconds: Int? let lastCleanupResult: BrokerIdleCleanupResult? let nextScheduledCleanupAt: String? + + static let unconfigured = BrokerIdleSummary( + configured: false, + eligibleCount: 0, + graceSeconds: nil, + lastCleanupResult: nil, + nextScheduledCleanupAt: nil + ) } struct BrokerIdleCleanupResult: Decodable, Sendable { diff --git a/app/Tests/BrokerDashboardStoreTests.swift b/app/Tests/BrokerDashboardStoreTests.swift index 4b70f10..0d35346 100644 --- a/app/Tests/BrokerDashboardStoreTests.swift +++ b/app/Tests/BrokerDashboardStoreTests.swift @@ -3,6 +3,24 @@ import XCTest @MainActor final class BrokerDashboardStoreTests: XCTestCase { + func testSnapshotDecodesMissingIdleAsUnconfiguredDefault() throws { + let fixturesRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appending(path: "Fixtures") + let data = try Data(contentsOf: fixturesRoot.appending(path: "busy-snapshot.json")) + var jsonObject = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + jsonObject.removeValue(forKey: "idle") + let snapshotData = try JSONSerialization.data(withJSONObject: jsonObject) + + let snapshot = try JSONDecoder().decode(BrokerAppSnapshot.self, from: snapshotData) + + XCTAssertFalse(snapshot.idle.configured) + XCTAssertEqual(snapshot.idle.eligibleCount, 0) + XCTAssertNil(snapshot.idle.graceSeconds) + XCTAssertNil(snapshot.idle.lastCleanupResult) + XCTAssertNil(snapshot.idle.nextScheduledCleanupAt) + } + func testIdlePolicyCommandsUseHumanActorAndRefreshSnapshot() async throws { let snapshot = try loadFixture(named: "busy-snapshot") let loadedState = makeLoadedState(snapshot: snapshot) diff --git a/broker-core/index.mjs b/broker-core/index.mjs index 7209e69..8b577b2 100644 --- a/broker-core/index.mjs +++ b/broker-core/index.mjs @@ -4112,7 +4112,7 @@ export function cleanupIdleBroker(paths, options = {}) { const timestamp = nowIso(options.now); if (options.apply !== true) { return withLeaseMutationLock(paths, () => { - const state = loadBrokerState(paths, stateLoadOptions(options, timestamp)); + const state = readBrokerStateSnapshot(paths, stateLoadOptions(options, timestamp)); return idleCleanupPlan(state).publicPlan; }, { now: timestamp, diff --git a/broker-core/test/broker-core.test.mjs b/broker-core/test/broker-core.test.mjs index 7794746..88b2a66 100644 --- a/broker-core/test/broker-core.test.mjs +++ b/broker-core/test/broker-core.test.mjs @@ -3949,6 +3949,50 @@ test("confirmed idle cleanup is count-only, plan-bound, and records shutdown fai }, (error) => error.payload?.reasonCode === "idle-plan-stale" && error.exitCode === 5); }); +test("idle cleanup preview does not reclaim stale containment-aware leases", () => { + const paths = makePaths(); + writeBaseHostConfig(paths.hostConfigPath); + writeBaseProject(paths.projectFilePath); + const resolvedPaths = brokerPaths(paths); + const processes = makeProcessFixture([ + { command: "xcodebuild test SIM-UI-1", pgid: 2000, pid: 2000, ppid: 1, rssBytes: 40 * 1024 * 1024 }, + ]); + + initBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + const lease = acquireLeaseBroker(resolvedPaths, { + actorId: "dead-agent", + actorType: "agent", + ownerPid: 999999, + processExists: () => true, + purposeId: "agent-ui-session", + simctlAdapter: paths.simctl.adapter, + }).lease; + registerLeaseProcessBroker(resolvedPaths, { + command: "xcodebuild test", + commandPgid: 2000, + commandPid: 2000, + leaseId: lease.leaseId, + processExists: () => true, + processSampler: processes.sampler, + simctlAdapter: paths.simctl.adapter, + }); + + const preview = cleanupIdleBroker(resolvedPaths, { + processController: processes.controller, + processExists: () => false, + processSampler: processes.sampler, + simctlAdapter: paths.simctl.adapter, + termWaitMs: 0, + }); + + assert.equal(preview.eligibleCount, 1); + assert.equal(preview.status, "changes_required"); + assert.equal(processes.isAlive(2000), true); + assert.deepEqual(processes.actions, []); + assert.equal(fs.existsSync(path.join(paths.stateRoot, "leases", `${lease.leaseId}.json`)), true); + assert.equal(readEventsBroker(resolvedPaths).events.some((event) => event.type === "lease.contained"), false); +}); + test("releasing a healthy lease is not blocked by unrelated stale containment failure", () => { const paths = makePaths(); writeBaseHostConfig(paths.hostConfigPath); diff --git a/client/command-dispatch.mjs b/client/command-dispatch.mjs index 7d7ca03..cace3e9 100644 --- a/client/command-dispatch.mjs +++ b/client/command-dispatch.mjs @@ -1089,14 +1089,14 @@ export function executeBrokerCommand(paths, request) { writeAppSnapshotArtifactUnderMutationLock(paths, snapshotOptions); } catch (error) { if (request.group === "idle") { - payload = { - ...payload, - snapshotRefresh: { - ok: false, - reasonCode: error?.payload?.reasonCode ?? error?.reasonCode ?? "snapshot-refresh-failed", - }, - }; - return payload; + const contractError = contractSnapshotRefreshError(error); + if (contractError) { + throw contractError; + } + throw new BrokerError("Failed to refresh the app snapshot after the idle command committed.", { + cause: error?.message ?? String(error), + reasonCode: "snapshot-refresh-failed", + }); } const contractError = contractSnapshotRefreshError(error); if (contractError) { diff --git a/client/service/service-client.mjs b/client/service/service-client.mjs index c314523..a69dd44 100644 --- a/client/service/service-client.mjs +++ b/client/service/service-client.mjs @@ -23,6 +23,7 @@ const PROCESS_SAMPLER_INVOCATIONS_PER_STATE_LOAD = 1; const SERVICE_STARTUP_LAUNCHER_OVERHEAD_MS = 5_000; const SERVICE_STARTUP_LOCK_PROCESS_SAMPLER_INVOCATIONS = 2; const SERVICE_STARTUP_STATE_LOADS = 2; +const SERVICE_STARTUP_LEASE_LOCK_WAITS = 2; const LEASE_ACQUIRE_SIMCTL_COMMANDS = 3; const CAPACITY_APPLY_PLAN_EVALUATIONS = 2; const CAPACITY_APPLY_FINALIZATION_STATE_LOADS = 1; @@ -490,7 +491,14 @@ export function serviceCommandTimeoutMs(request, options = {}) { } export function serviceStartupTimeoutMs(options = {}) { + const startupIdleReconcileRequest = { + command: "reconcile", + group: "idle", + options: {}, + }; return stateLoadBudgetMs(SERVICE_STARTUP_STATE_LOADS) + + staleContainmentBudgetMs(startupIdleReconcileRequest, options, 1) + + (SERVICE_STARTUP_LEASE_LOCK_WAITS * DEFAULT_LOCK_TIMEOUT_MS) + (hostAliasCountFromPaths(options.paths) * SIMCTL_COMMAND_TIMEOUT_MS) + (SERVICE_STARTUP_LOCK_PROCESS_SAMPLER_INVOCATIONS * PROCESS_SAMPLER_TIMEOUT_MS) + SERVICE_STARTUP_LAUNCHER_OVERHEAD_MS; diff --git a/client/test/simbroker.test.mjs b/client/test/simbroker.test.mjs index 6d53649..adbc992 100644 --- a/client/test/simbroker.test.mjs +++ b/client/test/simbroker.test.mjs @@ -764,6 +764,38 @@ test("lease acquire preserves the committed lease when snapshot refresh fails", assert.equal(fs.existsSync(path.join(paths.leasesDir, `${acquired.lease.leaseId}.json`)), true); }); +test("idle mutations surface final snapshot refresh failures", () => { + const fixture = makeFixture(); + assert.equal(runCli(fixture, "host", "init").status, 0); + const snapshotDirectory = path.join(fixture.root, "snapshot-directory"); + fs.mkdirSync(snapshotDirectory); + const paths = { + ...resolveBrokerPaths({ + hostConfigPath: fixture.hostConfigPath, + projectFilePath: path.join(fixture.repoRoot, ".simulator-broker/project.json"), + stateRoot: fixture.stateRoot, + }), + appSnapshotPath: snapshotDirectory, + }; + + assert.throws(() => { + executeBrokerCommand(paths, { + command: "enable", + group: "idle", + options: { + actorId: "operator", + actorType: "human", + graceSeconds: 60, + processExists: () => true, + simctlAdapter: fixture.simctl.adapter, + }, + }); + }, (error) => + error.payload?.reasonCode === "snapshot-refresh-failed" + && error.payload?.error === "Failed to refresh the app snapshot after the idle command committed."); + assert.equal(readJson(paths.idlePolicyPath).graceSeconds, 60); +}); + test("final snapshot refresh propagates process sampler timeouts", () => { const fixture = makeFixture(); const projectFilePath = path.join(fixture.repoRoot, ".simulator-broker/project.json"); @@ -1423,7 +1455,41 @@ test("service command timeout budgets stale containment sampling from lease file test("service startup timeout covers startup reconciliation, snapshot, and lock work", () => { assert.equal( serviceStartupTimeoutMs(), - (12 * SIMCTL_COMMAND_TIMEOUT_MS) + (4 * PROCESS_SAMPLER_TIMEOUT_MS) + 5_000, + (12 * SIMCTL_COMMAND_TIMEOUT_MS) + (4 * PROCESS_SAMPLER_TIMEOUT_MS) + 120_000 + 5_000, + ); +}); + +test("service startup timeout budgets stale containment from lease files", () => { + const fixture = makeFixture(); + const paths = resolveBrokerPaths({ + hostConfigPath: fixture.hostConfigPath, + stateRoot: fixture.stateRoot, + }); + writeJson(path.join(paths.leasesDir, "stale-containment-1.json"), { + leaseId: "stale-containment-1", + runtime: { + commandPid: 4242, + }, + }); + writeJson(path.join(paths.leasesDir, "stale-containment-2.json"), { + leaseId: "stale-containment-2", + runtime: { + ownerPgid: 5252, + }, + }); + const staleContainmentBudgetMs = 2 * ( + (STALE_CONTAINMENT_PROCESS_SAMPLER_INVOCATIONS * PROCESS_SAMPLER_TIMEOUT_MS) + + DEFAULT_CONTAINMENT_TERM_WAIT_MS + + DEFAULT_CONTAINMENT_POST_KILL_WAIT_MS + ); + + assert.equal( + serviceStartupTimeoutMs({ paths }), + (8 * SIMCTL_COMMAND_TIMEOUT_MS) + + (4 * PROCESS_SAMPLER_TIMEOUT_MS) + + 120_000 + + staleContainmentBudgetMs + + 5_000, ); }); From a4bb425b4737a191c60ea3113d3d04bcdf9f9b4f Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 10 Aug 2026 03:05:23 +0800 Subject: [PATCH 5/6] autopilot: address PR #1 review feedback Why: - Resolve actionable GitHub review feedback for PR #1. Changed: - Applied narrowly scoped fixes for all three active review items. Focused verification passed: node --test --test-name-pattern "idle cleanup" broker-core/test/broker-core.test.mjs; node --test --test-name-pattern "idle mutations surface final snapshot refresh failures" client/test/simbroker.test.mjs; node --test client/test/public-surface.test.mjs; npm run verify:public-surface; git diff --check. Verification: - npm run agent:complete -- --session-dir [controller artifact: jobs/pr-1/20260810-025809-ae1ef991-8a63-47ec-be58-6fb8ef18b0d4/task-session] - npm run agent:context -- --paths-file [controller artifact: jobs/pr-1/20260810-025809-ae1ef991-8a63-47ec-be58-6fb8ef18b0d4/actual-paths.txt] --session-dir [controller artifact: jobs/pr-1/20260810-025809-ae1ef991-8a63-47ec-be58-6fb8ef18b0d4/task-session] - npm run agent:verify -- --profile implementation --paths-file [controller artifact: jobs/pr-1/20260810-025809-ae1ef991-8a63-47ec-be58-6fb8ef18b0d4/task-session/verify/implementation/paths.txt] --session-dir [controller artifact: jobs/pr-1/20260810-025809-ae1ef991-8a63-47ec-be58-6fb8ef18b0d4/task-session] - npm run agent:verify -- --profile spec-only --paths-file [controller artifact: jobs/pr-1/20260810-025809-ae1ef991-8a63-47ec-be58-6fb8ef18b0d4/task-session/verify/spec-only/paths.txt] --session-dir [controller artifact: jobs/pr-1/20260810-025809-ae1ef991-8a63-47ec-be58-6fb8ef18b0d4/task-session] Affected: - .gitignore - README.md - app/README.md - app/Sources/AutomaticShutdownSection.swift - app/Sources/BrokerCommandSupport.swift - app/Sources/BrokerDashboardStore.swift - app/Sources/BrokerServiceClient.swift - app/Sources/BrokerSnapshotModels.swift - app/Sources/OverviewScreen.swift - app/Tests/BrokerDashboardStoreTests.swift - app/Tests/BrokerServiceClientTests.swift - app/Tests/Fixtures/busy-snapshot.json - app/project.yml - broker-core/error-contract.mjs - broker-core/index.mjs - broker-core/test/broker-core.test.mjs - client/README.md - client/bin/simbroker.mjs - client/command-dispatch.mjs - client/public-surface.mjs - client/service/brokerd.mjs - client/service/service-client.mjs - client/test/brokerd.test.mjs - client/test/public-surface.test.mjs - client/test/simbroker.test.mjs - package.json - scripts/package_distribution.sh - scripts/test_app.sh - spec/README.md - spec/architecture.md - spec/build-and-test.md - spec/global-simulator-broker.md - spec/harness-integration.md - spec/implementation-plan.md - spec/project-structure.md - spec/tasks/README.md - spec/tasks/public-safe-on-demand-simulator-lifecycle.md Refs: - https://github.com/fiveonecode/simulator-broker/pull/1 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744875471 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744875474 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744875478 Session: - task-session: [controller artifact: jobs/pr-1/20260810-025809-ae1ef991-8a63-47ec-be58-6fb8ef18b0d4/task-session] - report: [controller artifact: jobs/pr-1/20260810-025809-ae1ef991-8a63-47ec-be58-6fb8ef18b0d4/report.md] --- broker-core/index.mjs | 27 +++++++++++---------- broker-core/test/broker-core.test.mjs | 34 +++++++++++++++++++++++++++ client/command-dispatch.mjs | 9 +++++-- client/public-surface.mjs | 1 - client/test/public-surface.test.mjs | 24 +++++++++++++++++++ client/test/simbroker.test.mjs | 5 +++- 6 files changed, 83 insertions(+), 17 deletions(-) diff --git a/broker-core/index.mjs b/broker-core/index.mjs index 8b577b2..d8e5b35 100644 --- a/broker-core/index.mjs +++ b/broker-core/index.mjs @@ -4001,19 +4001,6 @@ function performIdleShutdowns(paths, state, candidates, options, timestamp, { co state, timestamp, }); - registryEntry.powerState = "shutdown"; - registryEntry.lastShutdownAt = timestamp; - registryEntry.updatedAt = timestamp; - shutdownCount += 1; - appendEventRecord(paths, "idle.simulator.shutdown", { - alias: hostAlias.alias, - actorType: options.actorType ?? "system", - jobId: null, - leaseId: null, - payload: { reasonCode: "idle-grace-expired", source }, - projectId: null, - purposeId: null, - }, timestamp); } catch { registryEntry.health = "repair-needed"; registryEntry.driftReason = "idle-shutdown-failed"; @@ -4030,7 +4017,21 @@ function performIdleShutdowns(paths, state, candidates, options, timestamp, { co projectId: null, purposeId: null, }, timestamp); + continue; } + registryEntry.powerState = "shutdown"; + registryEntry.lastShutdownAt = timestamp; + registryEntry.updatedAt = timestamp; + shutdownCount += 1; + appendEventRecord(paths, "idle.simulator.shutdown", { + alias: hostAlias.alias, + actorType: options.actorType ?? "system", + jobId: null, + leaseId: null, + payload: { reasonCode: "idle-grace-expired", source }, + projectId: null, + purposeId: null, + }, timestamp); } const lastCleanupResult = { diff --git a/broker-core/test/broker-core.test.mjs b/broker-core/test/broker-core.test.mjs index 88b2a66..3364cf6 100644 --- a/broker-core/test/broker-core.test.mjs +++ b/broker-core/test/broker-core.test.mjs @@ -3949,6 +3949,40 @@ test("confirmed idle cleanup is count-only, plan-bound, and records shutdown fai }, (error) => error.payload?.reasonCode === "idle-plan-stale" && error.exitCode === 5); }); +test("idle cleanup audit append failures do not mark shut down aliases for repair", () => { + const paths = makePaths(); + writeBaseHostConfig(paths.hostConfigPath); + writeBaseProject(paths.projectFilePath); + const fixture = readJson(paths.simctl.statePath); + for (const device of fixture.devices) { + device.state = "Booted"; + } + writeJson(paths.simctl.statePath, fixture); + const resolvedPaths = brokerPaths(paths); + initBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + + const preview = cleanupIdleBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + fs.rmSync(resolvedPaths.eventsPath, { force: true }); + fs.mkdirSync(resolvedPaths.eventsPath); + + const result = cleanupIdleBroker(resolvedPaths, { + actorId: "operator", + actorType: "human", + apply: true, + confirmPlanId: preview.planId, + processExists: () => true, + simctlAdapter: paths.simctl.adapter, + }); + + assert.equal(result.eligibleCount, 3); + assert.equal(result.shutdownCount, 3); + assert.equal(result.failureCount, 0); + assert.equal(result.status, "success"); + const registry = readJson(resolvedPaths.registryPath); + assert.equal(registry.aliases["ui-1"].powerState, "shutdown"); + assert.notEqual(registry.aliases["ui-1"].driftReason, "idle-shutdown-failed"); +}); + test("idle cleanup preview does not reclaim stale containment-aware leases", () => { const paths = makePaths(); writeBaseHostConfig(paths.hostConfigPath); diff --git a/client/command-dispatch.mjs b/client/command-dispatch.mjs index cace3e9..e88f59f 100644 --- a/client/command-dispatch.mjs +++ b/client/command-dispatch.mjs @@ -1093,10 +1093,15 @@ export function executeBrokerCommand(paths, request) { if (contractError) { throw contractError; } - throw new BrokerError("Failed to refresh the app snapshot after the idle command committed.", { - cause: error?.message ?? String(error), + const snapshotError = new BrokerError("Failed to refresh the app snapshot after the idle command committed.", { reasonCode: "snapshot-refresh-failed", }); + Object.defineProperty(snapshotError, "cause", { + configurable: true, + value: error, + writable: true, + }); + throw snapshotError; } const contractError = contractSnapshotRefreshError(error); if (contractError) { diff --git a/client/public-surface.mjs b/client/public-surface.mjs index 762919d..7915f05 100644 --- a/client/public-surface.mjs +++ b/client/public-surface.mjs @@ -44,7 +44,6 @@ function defaultCandidateFiles(root) { "ls-files", "-z", "--cached", - "--others", "--exclude-standard", ], { cwd: root, diff --git a/client/test/public-surface.test.mjs b/client/test/public-surface.test.mjs index cc8ef61..a1b254e 100644 --- a/client/test/public-surface.test.mjs +++ b/client/test/public-surface.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { execFileSync } from "node:child_process"; import { scanPublicSurface } from "../public-surface.mjs"; @@ -67,3 +68,26 @@ test("public surface scan rejects tracked broker state artifacts and ignores bin rule: "prohibited-local-artifact", }]); }); + +test("default public surface candidates ignore untracked scratch files", () => { + const root = makeTempDir(); + const localHome = path.join(root, "private-home"); + execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); + fs.writeFileSync(path.join(root, "README.md"), "public docs\n"); + fs.writeFileSync(path.join(root, "release-notes.md"), `machine path: ${localHome}/state\n`); + fs.writeFileSync(path.join(root, "scratch.md"), `local scratch: ${localHome}/scratch\n`); + execFileSync("git", ["add", "README.md", "release-notes.md"], { cwd: root, stdio: "ignore" }); + + const report = scanPublicSurface({ + homePath: localHome, + root, + }); + + assert.equal(report.ok, false); + assert.equal(report.filesScanned, 2); + assert.deepEqual(report.issues, [{ + line: 1, + path: "release-notes.md", + rule: "local-home-path", + }]); +}); diff --git a/client/test/simbroker.test.mjs b/client/test/simbroker.test.mjs index adbc992..9db375e 100644 --- a/client/test/simbroker.test.mjs +++ b/client/test/simbroker.test.mjs @@ -792,7 +792,10 @@ test("idle mutations surface final snapshot refresh failures", () => { }); }, (error) => error.payload?.reasonCode === "snapshot-refresh-failed" - && error.payload?.error === "Failed to refresh the app snapshot after the idle command committed."); + && error.payload?.error === "Failed to refresh the app snapshot after the idle command committed." + && error.cause?.message.includes(snapshotDirectory) + && error.payload?.cause === undefined + && !JSON.stringify(error.payload).includes(snapshotDirectory)); assert.equal(readJson(paths.idlePolicyPath).graceSeconds, 60); }); From 451d83321c2438fd95eff21e9f411016b62a8e91 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 10 Aug 2026 03:50:19 +0800 Subject: [PATCH 6/6] autopilot: address PR #1 review feedback Why: - Resolve actionable GitHub review feedback for PR #1. Changed: - Repaired the implementation-profile verification failure by increasing the timeout for the harness doctor-output regression that exceeded Vitest's 5s default under full verification load. Additional checks passed: `npm --prefix agent-harness run build`, `npm --prefix agent-harness test`, and `npm run verify:public-surface`. Verification: - npm run agent:complete -- --session-dir [controller artifact: jobs/pr-1/20260810-032447-92125911-ed1c-44b9-a0b2-447985ce0fe4/task-session] - npm run agent:context -- --paths-file [controller artifact: jobs/pr-1/20260810-032447-92125911-ed1c-44b9-a0b2-447985ce0fe4/actual-paths.txt] --session-dir [controller artifact: jobs/pr-1/20260810-032447-92125911-ed1c-44b9-a0b2-447985ce0fe4/task-session] - npm run agent:verify -- --profile implementation --paths-file [controller artifact: jobs/pr-1/20260810-032447-92125911-ed1c-44b9-a0b2-447985ce0fe4/task-session/verify/implementation/paths.txt] --session-dir [controller artifact: jobs/pr-1/20260810-032447-92125911-ed1c-44b9-a0b2-447985ce0fe4/task-session] - npm run agent:verify -- --profile spec-only --paths-file [controller artifact: jobs/pr-1/20260810-032447-92125911-ed1c-44b9-a0b2-447985ce0fe4/task-session/verify/spec-only/paths.txt] --session-dir [controller artifact: jobs/pr-1/20260810-032447-92125911-ed1c-44b9-a0b2-447985ce0fe4/task-session] Affected: - .gitignore - README.md - agent-harness/tests/runtime.test.ts - app/README.md - app/Sources/AutomaticShutdownSection.swift - app/Sources/BrokerCommandSupport.swift - app/Sources/BrokerDashboardStore.swift - app/Sources/BrokerServiceClient.swift - app/Sources/BrokerSnapshotModels.swift - app/Sources/OverviewScreen.swift - app/Tests/BrokerDashboardStoreTests.swift - app/Tests/BrokerServiceClientTests.swift - app/Tests/Fixtures/busy-snapshot.json - app/project.yml - broker-core/error-contract.mjs - broker-core/index.mjs - broker-core/simctl.mjs - broker-core/test/broker-core.test.mjs - client/README.md - client/bin/simbroker.mjs - client/command-dispatch.mjs - client/public-surface.mjs - client/service/brokerd.mjs - client/service/service-client.mjs - client/test/brokerd.test.mjs - client/test/public-surface.test.mjs - client/test/simbroker.test.mjs - package.json - scripts/package_distribution.sh - scripts/test_app.sh - spec/README.md - spec/architecture.md - spec/build-and-test.md - spec/global-simulator-broker.md - spec/harness-integration.md - spec/implementation-plan.md - spec/project-structure.md - spec/tasks/README.md - spec/tasks/public-safe-on-demand-simulator-lifecycle.md Refs: - https://github.com/fiveonecode/simulator-broker/pull/1 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744949497 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744949501 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744949502 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744949504 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744949509 - https://github.com/fiveonecode/simulator-broker/pull/1#discussion_r3744949513 Session: - task-session: [controller artifact: jobs/pr-1/20260810-032447-92125911-ed1c-44b9-a0b2-447985ce0fe4/task-session] - report: [controller artifact: jobs/pr-1/20260810-032447-92125911-ed1c-44b9-a0b2-447985ce0fe4/report.md] --- agent-harness/tests/runtime.test.ts | 2 +- broker-core/index.mjs | 21 +++++- broker-core/simctl.mjs | 1 + broker-core/test/broker-core.test.mjs | 68 +++++++++++++++++++ client/bin/simbroker.mjs | 76 ++++++++++++++++++++-- client/command-dispatch.mjs | 3 +- client/public-surface.mjs | 19 +++++- client/service/brokerd.mjs | 15 ++++- client/service/service-client.mjs | 4 +- client/test/brokerd.test.mjs | 10 ++- client/test/public-surface.test.mjs | 63 ++++++++++++++++-- client/test/simbroker.test.mjs | 94 ++++++++++++++++++++++++++- spec/build-and-test.md | 2 +- spec/global-simulator-broker.md | 2 +- 14 files changed, 353 insertions(+), 27 deletions(-) diff --git a/agent-harness/tests/runtime.test.ts b/agent-harness/tests/runtime.test.ts index ee28621..dc371b6 100644 --- a/agent-harness/tests/runtime.test.ts +++ b/agent-harness/tests/runtime.test.ts @@ -181,7 +181,7 @@ describe("runtime argument parsing", () => { expect(doctorJson.baseProfiles).toEqual(["implementation"]); expect(doctorJson.obligationProfiles).toEqual([]); - }); + }, 15_000); it("reports macOS build tools for app implementation doctor output", () => { const output = runHarnessCommand([ diff --git a/broker-core/index.mjs b/broker-core/index.mjs index d8e5b35..69d15f9 100644 --- a/broker-core/index.mjs +++ b/broker-core/index.mjs @@ -1456,7 +1456,17 @@ function validateIdlePolicy(rawPolicy) { } function readIdlePolicy(paths) { - const rawPolicy = readJsonIfExists(paths.idlePolicyPath); + let rawPolicy; + try { + rawPolicy = readJsonIfExists(paths.idlePolicyPath); + } catch (error) { + if (error instanceof SyntaxError) { + throw new BrokerError("Idle policy contains invalid JSON.", { + reasonCode: "invalid-config", + }); + } + throw error; + } return rawPolicy === null ? null : validateIdlePolicy(rawPolicy); } @@ -2593,6 +2603,7 @@ function withLeaseMutationLock(paths, work, { processSampler = processExists === defaultProcessExists ? defaultProcessSampler : null, pollMs = DEFAULT_LOCK_POLL_MS, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS, + wait = true, } = {}) { ensureStatePaths(paths); const startedAt = Date.now(); @@ -2611,6 +2622,12 @@ function withLeaseMutationLock(paths, work, { throw error; } + if (!wait) { + throw new BrokerError("The broker lease mutation lock is already held.", { + reasonCode: "alias-busy", + }); + } + const lockSnapshot = readLockSnapshot(paths.leaseLockDir, paths.leaseLockOwnerPath, { processExists, processSampler, @@ -3873,6 +3890,7 @@ export function writeAppSnapshotArtifactUnderMutationLock(paths, options = {}) { processExists: options.processExists, processSampler: options.processSampler, timeoutMs: options.leaseLockTimeoutMilliseconds ?? DEFAULT_LOCK_TIMEOUT_MS, + wait: options.leaseMutationLockWait !== false, }); } @@ -4106,6 +4124,7 @@ export function reconcileIdleBroker(paths, options = {}) { processExists: options.processExists, processSampler: options.processSampler, timeoutMs: options.leaseLockTimeoutMilliseconds ?? DEFAULT_LOCK_TIMEOUT_MS, + wait: options.leaseMutationLockWait !== false, }); } diff --git a/broker-core/simctl.mjs b/broker-core/simctl.mjs index 5d84fae..4e9dfb1 100644 --- a/broker-core/simctl.mjs +++ b/broker-core/simctl.mjs @@ -166,6 +166,7 @@ export function createSystemSimctlAdapter({ commandRunner = defaultCommandRunner if (typeof result === "object" && result !== null && result.exitCode !== 0) { throwSimctlResult(args, result); } + runCommand(["bootstatus", simulatorId, "-b"]); }, createDevice(name, deviceTypeId, runtimeId) { return String(runCommand(["create", name, deviceTypeId, runtimeId])).trim(); diff --git a/broker-core/test/broker-core.test.mjs b/broker-core/test/broker-core.test.mjs index 3364cf6..7cfb6e5 100644 --- a/broker-core/test/broker-core.test.mjs +++ b/broker-core/test/broker-core.test.mjs @@ -1299,6 +1299,26 @@ test("system simctl boot propagates nonzero command results", () => { }, (error) => error.exitCode === 60 && error.stderr === "Unable to boot device"); }); +test("system simctl boot waits for boot status before returning", () => { + const calls = []; + const adapter = createSystemSimctlAdapter({ + commandRunner(args, options) { + calls.push({ args, options }); + return ""; + }, + }); + + adapter.bootDevice("SIM-BOOT"); + + assert.deepEqual(calls.map((call) => call.args), [ + ["boot", "SIM-BOOT"], + ["bootstatus", "SIM-BOOT", "-b"], + ]); + assert.equal(calls[0].options.allowFailure, true); + assert.equal(calls[0].options.timeoutMs, SIMCTL_COMMAND_TIMEOUT_MS); + assert.equal(calls[1].options.timeoutMs, SIMCTL_COMMAND_TIMEOUT_MS); +}); + test("system simctl inventory commands use an expanded output buffer", () => { const calls = []; const adapter = createSystemSimctlAdapter({ @@ -3741,6 +3761,28 @@ test("idle policy is absent by default, strictly bounded, and stored outside pro assert.equal(fs.existsSync(resolvedPaths.idlePolicyPath), false); }); +test("malformed idle policy JSON maps to public-safe invalid config", () => { + const paths = makePaths(); + writeBaseHostConfig(paths.hostConfigPath); + writeBaseProject(paths.projectFilePath); + const resolvedPaths = brokerPaths(paths); + initBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + fs.writeFileSync(resolvedPaths.idlePolicyPath, "{not-json\n"); + + for (const operation of [ + () => idleStatusBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })), + () => reconcileIdleBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })), + ]) { + assert.throws(operation, (error) => { + const serialized = JSON.stringify(error.payload); + return error.payload?.reasonCode === "invalid-config" + && error.exitCode === 2 + && serialized.includes(paths.root) === false + && "stack" in error.payload === false; + }); + } +}); + test("stale lease recovery starts a fresh idle grace period", () => { const paths = makePaths(); writeBaseHostConfig(paths.hostConfigPath); @@ -3806,6 +3848,32 @@ test("idle reconciliation waits for the broker mutation lock", () => { }, (error) => error.payload?.reasonCode === "alias-busy"); }); +test("idle reconciliation supports nonblocking lease mutation lock attempts", () => { + const paths = makePaths(); + writeBaseHostConfig(paths.hostConfigPath); + writeBaseProject(paths.projectFilePath); + const resolvedPaths = brokerPaths(paths); + initBroker(resolvedPaths, runtimeOptions(paths, { processExists: () => true })); + enableIdlePolicyBroker(resolvedPaths, { + actorId: "operator", + actorType: "human", + graceSeconds: 60, + }); + fs.mkdirSync(resolvedPaths.leaseLockDir, { recursive: true }); + writeJson(resolvedPaths.leaseLockOwnerPath, { + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.throws(() => { + reconcileIdleBroker(resolvedPaths, { + leaseMutationLockWait: false, + processExists: (pid) => pid === process.pid, + simctlAdapter: paths.simctl.adapter, + }); + }, (error) => error.payload?.reasonCode === "alias-busy"); +}); + test("idle reconciliation honors the grace boundary and all protected simulator classes", () => { const paths = makePaths(); writeBaseHostConfig(paths.hostConfigPath); diff --git a/client/bin/simbroker.mjs b/client/bin/simbroker.mjs index a4c9222..a421108 100755 --- a/client/bin/simbroker.mjs +++ b/client/bin/simbroker.mjs @@ -185,16 +185,68 @@ async function serviceStatus(paths) { }; } -async function waitForService(paths, { timeoutMs = 5000 } = {}) { +function observeChildExit(child) { + return new Promise((resolve, reject) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve({ code: child.exitCode, signal: child.signalCode }); + return; + } + child.once("exit", (code, signal) => { + resolve({ code, signal }); + }); + child.once("error", reject); + }); +} + +async function waitForSpawnedService(paths, child, { timeoutMs = 5000 } = {}) { const startedAt = Date.now(); + const childExit = observeChildExit(child); while (Date.now() - startedAt < timeoutMs) { - const probe = await probeService(paths, { timeoutMs: 250 }); - if (probe) { - return probe; + const outcome = await Promise.race([ + probeService(paths, { timeoutMs: 250 }).then((probe) => ({ probe })), + childExit.then((exit) => ({ exit })), + ]); + if (outcome.exit) { + const probe = await probeService(paths, { timeoutMs: 250 }); + if (probe) { + return { + childExit: null, + probe, + }; + } + return { + childExit: outcome.exit, + probe: null, + }; + } + if (outcome.probe) { + return { + childExit: null, + probe: outcome.probe, + }; + } + const delay = await Promise.race([ + sleep(100).then(() => null), + childExit.then((exit) => exit), + ]); + if (delay) { + const probe = await probeService(paths, { timeoutMs: 250 }); + if (probe) { + return { + childExit: null, + probe, + }; + } + return { + childExit: delay, + probe: null, + }; } - await sleep(100); } - return null; + return { + childExit: null, + probe: null, + }; } async function waitForServiceToStop(paths, { timeoutMs = 5000 } = {}) { @@ -254,7 +306,17 @@ async function startService(paths) { child.unref(); fs.closeSync(logFd); - const probe = await waitForService(paths, { timeoutMs: serviceStartupTimeoutMs({ paths }) }); + const { childExit, probe } = await waitForSpawnedService(paths, child, { timeoutMs: serviceStartupTimeoutMs({ paths }) }); + if (childExit) { + throw new BrokerError("Broker service exited before it became available.", { + exitStatus: childExit.code, + logPath: paths.serviceLogPath, + reasonCode: "service-unavailable", + serviceMetadataPath: paths.serviceMetadataPath, + serviceSocketPath: paths.serviceSocketPath, + signal: childExit.signal, + }); + } if (!probe) { terminateSpawnedService(child); throw new BrokerError("Broker service failed to start.", { diff --git a/client/command-dispatch.mjs b/client/command-dispatch.mjs index e88f59f..e0c3a83 100644 --- a/client/command-dispatch.mjs +++ b/client/command-dispatch.mjs @@ -1084,7 +1084,8 @@ export function executeBrokerCommand(paths, request) { snapshotOptions.skipLeaseIds = [snapshotLeaseId]; } const isReadOnlyCapacityCommand = request.group === "capacity" && options.apply !== true; - if (!isReadOnlyCapacityCommand) { + const isIdleCleanupPreview = request.group === "idle" && request.command === "cleanup" && options.apply !== true; + if (!isReadOnlyCapacityCommand && !isIdleCleanupPreview) { try { writeAppSnapshotArtifactUnderMutationLock(paths, snapshotOptions); } catch (error) { diff --git a/client/public-surface.mjs b/client/public-surface.mjs index 7915f05..bb7a200 100644 --- a/client/public-surface.mjs +++ b/client/public-surface.mjs @@ -14,11 +14,28 @@ const PROHIBITED_TRACKED_BASENAMES = new Set([ "app-snapshot.json", "brokerd.json", "brokerd.log", + "events.ndjson", "host-config.json", "idle-policy.json", + "known-projects.json", "registry.json", ]); +const PROHIBITED_TRACKED_STATE_DIRS = new Set([ + "capacity-transactions", + "evidence", + "leases", + "pins", +]); + +function isProhibitedTrackedArtifact(relativeFile) { + if (PROHIBITED_TRACKED_BASENAMES.has(path.posix.basename(relativeFile))) { + return true; + } + const segments = relativeFile.split("/"); + return segments.some((segment) => PROHIBITED_TRACKED_STATE_DIRS.has(segment)); +} + function lineNumberForOffset(text, offset) { let line = 1; for (let index = 0; index < offset; index += 1) { @@ -81,7 +98,7 @@ export function scanPublicSurface({ for (const relativeFile of candidateFiles) { const normalizedRelativeFile = relativeFile.split(path.sep).join("/"); - if (PROHIBITED_TRACKED_BASENAMES.has(path.posix.basename(normalizedRelativeFile))) { + if (isProhibitedTrackedArtifact(normalizedRelativeFile)) { issues.push({ line: 1, path: normalizedRelativeFile, diff --git a/client/service/brokerd.mjs b/client/service/brokerd.mjs index 6841ce5..49ee572 100644 --- a/client/service/brokerd.mjs +++ b/client/service/brokerd.mjs @@ -26,6 +26,7 @@ const SERVICE_REQUEST_BODY_TIMEOUT_MS = 30_000; const SERVICE_COMMAND_BODY_MAX_BYTES = 64 * 1024; const SERVICE_LOCK_OWNER_PID_IDENTITY_TOLERANCE_MS = 15_000; const IDLE_RECONCILE_INTERVAL_MS = 30_000; +const LEASE_MUTATION_LOCK_BUSY_REASON_CODE = "alias-busy"; function writeJsonAtomic(filePath, payload) { fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); @@ -771,13 +772,18 @@ export async function startBrokerService(paths, options = {}) { const writeSnapshot = options.writeAppSnapshotArtifact ?? writeAppSnapshotArtifactUnderMutationLock; const reconcileIdle = options.reconcileIdleBroker ?? reconcileIdleBroker; - const runIdleReconciliation = (source) => { + const runIdleReconciliation = (source, { waitForLeaseMutationLock = true } = {}) => { + const lockOptions = waitForLeaseMutationLock ? {} : { leaseMutationLockWait: false }; const result = reconcileIdle(paths, { ...(options.idleReconcileOptions ?? {}), + ...lockOptions, persistNoChanges: false, source, }); - writeSnapshot(paths, options.idleSnapshotOptions ?? {}); + writeSnapshot(paths, { + ...(options.idleSnapshotOptions ?? {}), + ...lockOptions, + }); return result; }; @@ -1096,8 +1102,11 @@ export async function startBrokerService(paths, options = {}) { } idleReconcileRunning = true; try { - runIdleReconciliation("service-timer"); + runIdleReconciliation("service-timer", { waitForLeaseMutationLock: false }); } catch (error) { + if (error instanceof BrokerError && error.payload?.reasonCode === LEASE_MUTATION_LOCK_BUSY_REASON_CODE) { + return; + } (options.onIdleReconcileError ?? console.error)(error); } finally { idleReconcileRunning = false; diff --git a/client/service/service-client.mjs b/client/service/service-client.mjs index a69dd44..3770e14 100644 --- a/client/service/service-client.mjs +++ b/client/service/service-client.mjs @@ -24,7 +24,7 @@ const SERVICE_STARTUP_LAUNCHER_OVERHEAD_MS = 5_000; const SERVICE_STARTUP_LOCK_PROCESS_SAMPLER_INVOCATIONS = 2; const SERVICE_STARTUP_STATE_LOADS = 2; const SERVICE_STARTUP_LEASE_LOCK_WAITS = 2; -const LEASE_ACQUIRE_SIMCTL_COMMANDS = 3; +const LEASE_ACQUIRE_SIMCTL_COMMANDS = 4; const CAPACITY_APPLY_PLAN_EVALUATIONS = 2; const CAPACITY_APPLY_FINALIZATION_STATE_LOADS = 1; const CAPACITY_APPLY_FINAL_SNAPSHOT_STATE_LOADS = 1; @@ -37,7 +37,7 @@ const HOST_BOOTSTRAP_REPLACEMENT_STATE_LOADS = 1; const HOST_BOOTSTRAP_RETIREMENT_STATE_LOADS = 1; const HOST_BOOTSTRAP_SIMCTL_COMMANDS_PER_ALIAS = SIMCTL_INVENTORY_COMMANDS_PER_STATE_LOAD + 2; const SIMULATOR_LIFECYCLE_SIMCTL_COMMANDS = { - boot: 1, + boot: 2, erase: 2, repair: 10, shutdown: 1, diff --git a/client/test/brokerd.test.mjs b/client/test/brokerd.test.mjs index 66cc17e..19255f7 100644 --- a/client/test/brokerd.test.mjs +++ b/client/test/brokerd.test.mjs @@ -925,6 +925,8 @@ test("brokerd reconciles immediately, every thirty seconds, refreshes snapshots, stateRoot: fixture.stateRoot, }); const sources = []; + const reconcileLockWaits = []; + const snapshotLockWaits = []; let snapshotCount = 0; let intervalMilliseconds = null; let timerCallback = null; @@ -937,6 +939,7 @@ test("brokerd reconciles immediately, every thirty seconds, refreshes snapshots, }, reconcileIdleBroker(_servicePaths, options) { sources.push(options.source); + reconcileLockWaits.push(options.leaseMutationLockWait); return { ok: true }; }, setIntervalFn(callback, milliseconds) { @@ -944,16 +947,21 @@ test("brokerd reconciles immediately, every thirty seconds, refreshes snapshots, intervalMilliseconds = milliseconds; return timer; }, - writeAppSnapshotArtifact() { + writeAppSnapshotArtifact(_servicePaths, options) { + snapshotLockWaits.push(options.leaseMutationLockWait); snapshotCount += 1; }, }); assert.deepEqual(sources, ["service-startup"]); + assert.deepEqual(reconcileLockWaits, [undefined]); + assert.deepEqual(snapshotLockWaits, [undefined]); assert.equal(snapshotCount, 1); assert.equal(intervalMilliseconds, 30_000); timerCallback(); assert.deepEqual(sources, ["service-startup", "service-timer"]); + assert.deepEqual(reconcileLockWaits, [undefined, false]); + assert.deepEqual(snapshotLockWaits, [undefined, false]); assert.equal(snapshotCount, 2); await service.shutdown({ exitProcess: false }); diff --git a/client/test/public-surface.test.mjs b/client/test/public-surface.test.mjs index a1b254e..8830595 100644 --- a/client/test/public-surface.test.mjs +++ b/client/test/public-surface.test.mjs @@ -52,21 +52,72 @@ test("public surface scan applies an ignored operator denylist without echoing i test("public surface scan rejects tracked broker state artifacts and ignores binary content", () => { const root = makeTempDir(); fs.mkdirSync(path.join(root, "fixtures")); + fs.mkdirSync(path.join(root, "state", "capacity-transactions"), { recursive: true }); + fs.mkdirSync(path.join(root, "state", "evidence", "lease-1"), { recursive: true }); + fs.mkdirSync(path.join(root, "state", "leases"), { recursive: true }); + fs.mkdirSync(path.join(root, "state", "pins"), { recursive: true }); fs.writeFileSync(path.join(root, "fixtures", "idle-policy.json"), "{}\n"); + fs.writeFileSync(path.join(root, "state", "capacity-transactions", "txn.json"), "{}\n"); + fs.writeFileSync(path.join(root, "state", "events.ndjson"), "{}\n"); + fs.writeFileSync(path.join(root, "state", "evidence", "lease-1", "broker-status-after.json"), "{}\n"); + fs.writeFileSync(path.join(root, "state", "known-projects.json"), "{}\n"); + fs.writeFileSync(path.join(root, "state", "leases", "lease-1.json"), "{}\n"); + fs.writeFileSync(path.join(root, "state", "pins", "pin-1.json"), "{}\n"); fs.writeFileSync(path.join(root, "image.bin"), Buffer.from([0, 1, 2, 3])); const report = scanPublicSurface({ - files: ["fixtures/idle-policy.json", "image.bin"], + files: [ + "fixtures/idle-policy.json", + "state/capacity-transactions/txn.json", + "state/events.ndjson", + "state/evidence/lease-1/broker-status-after.json", + "state/known-projects.json", + "state/leases/lease-1.json", + "state/pins/pin-1.json", + "image.bin", + ], homePath: "", root, }); assert.equal(report.ok, false); - assert.deepEqual(report.issues, [{ - line: 1, - path: "fixtures/idle-policy.json", - rule: "prohibited-local-artifact", - }]); + assert.deepEqual(report.issues, [ + { + line: 1, + path: "fixtures/idle-policy.json", + rule: "prohibited-local-artifact", + }, + { + line: 1, + path: "state/capacity-transactions/txn.json", + rule: "prohibited-local-artifact", + }, + { + line: 1, + path: "state/events.ndjson", + rule: "prohibited-local-artifact", + }, + { + line: 1, + path: "state/evidence/lease-1/broker-status-after.json", + rule: "prohibited-local-artifact", + }, + { + line: 1, + path: "state/known-projects.json", + rule: "prohibited-local-artifact", + }, + { + line: 1, + path: "state/leases/lease-1.json", + rule: "prohibited-local-artifact", + }, + { + line: 1, + path: "state/pins/pin-1.json", + rule: "prohibited-local-artifact", + }, + ]); }); test("default public surface candidates ignore untracked scratch files", () => { diff --git a/client/test/simbroker.test.mjs b/client/test/simbroker.test.mjs index 9db375e..0c8d4a9 100644 --- a/client/test/simbroker.test.mjs +++ b/client/test/simbroker.test.mjs @@ -232,6 +232,52 @@ function runCliAsync(fixture, envOverrides, ...args) { }); } +function runCliAsyncWithTimeout(fixture, envOverrides, timeoutMs, ...args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [ + CLI_PATH, + "--host-config", fixture.hostConfigPath, + "--state-root", fixture.stateRoot, + ...args, + ], { + env: { + ...process.env, + ...fixture.simctl?.env, + ...envOverrides, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, timeoutMs); + timeout.unref?.(); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (status, signal) => { + clearTimeout(timeout); + resolve({ + json: stdout ? JSON.parse(stdout) : null, + signal, + status, + stderr, + stdout, + timedOut, + }); + }); + }); +} + function listen(server, socketPath) { return new Promise((resolve, reject) => { server.once("error", reject); @@ -731,6 +777,33 @@ test("capacity apply contains snapshot refresh failures after finalization", () assert.equal(transactions.some((transaction) => transaction.status === "finalized"), true); }); +test("idle cleanup preview skips final app snapshot refresh", () => { + const fixture = makeFixture(); + assert.equal(runCli(fixture, "host", "init").status, 0); + const snapshotDirectory = path.join(fixture.root, "snapshot-directory"); + fs.mkdirSync(snapshotDirectory); + const paths = { + ...resolveBrokerPaths({ + hostConfigPath: fixture.hostConfigPath, + projectFilePath: fixture.projectFilePath, + stateRoot: fixture.stateRoot, + }), + appSnapshotPath: snapshotDirectory, + }; + + const preview = executeBrokerCommand(paths, { + command: "cleanup", + group: "idle", + options: { + processExists: () => true, + simctlAdapter: fixture.simctl.adapter, + }, + }); + + assert.equal(preview.mode, "preview"); + assert.equal(preview.status, "no_changes"); +}); + test("lease acquire preserves the committed lease when snapshot refresh fails", () => { const fixture = makeFixture(); assert.equal(runCli(fixture, "host", "init").status, 0); @@ -1271,7 +1344,7 @@ test("service lease acquire timeout includes reset and boot-on-acquire budgets", command: "acquire", group: "lease", options: {}, - }), 1_305_250); + }), 1_425_250); assert.equal(serviceCommandExecutionTimeoutMs({ command: "acquire", group: "lease", @@ -1280,7 +1353,7 @@ test("service lease acquire timeout includes reset and boot-on-acquire budgets", resetLockTimeoutMilliseconds: 120_000, resetSettleMilliseconds: 500, }, - }), 1_425_500); + }), 1_545_500); }); test("service idle timeouts cover serialized state, snapshots, and bounded shutdown work", () => { @@ -1380,6 +1453,11 @@ test("service mutation timeouts cover broker lock waits", () => { group: "pin", options: {}, }), 885_000); + assert.equal(serviceCommandExecutionTimeoutMs({ + command: "boot", + group: "simulators", + options: {}, + }), 1_125_000); assert.equal(serviceCommandExecutionTimeoutMs({ command: "shutdown", group: "simulators", @@ -1496,6 +1574,18 @@ test("service startup timeout budgets stale containment from lease files", () => ); }); +test("service start stops waiting when the spawned brokerd exits before readiness", async () => { + const fixture = makeFixture(); + assert.equal(runCli(fixture, "host", "init").status, 0); + fs.writeFileSync(path.join(fixture.stateRoot, "idle-policy.json"), "{not-json\n"); + + const result = await runCliAsyncWithTimeout(fixture, {}, 3000, "service", "start"); + + assert.equal(result.timedOut, false, result.stderr); + assert.equal(result.status, 3); + assert.equal(result.json.reasonCode, "service-unavailable"); +}); + test("service probe treats request timeout as unavailable instead of missing", async (t) => { const root = makeTempDir(); const socketPath = path.join(root, "busy.sock"); diff --git a/spec/build-and-test.md b/spec/build-and-test.md index 6039131..0f459a7 100644 --- a/spec/build-and-test.md +++ b/spec/build-and-test.md @@ -237,7 +237,7 @@ Add stronger profiles next for: grace-boundary eligibility, all safety exclusions, stale grace restart, mutation-lock serialization, shutdown failure repair state, and confirmed count-only cleanup -- `npm run test:client` proves service lifecycle, concurrent clients, startup readiness before service metadata publication, restart safety, malformed service response handling, expected service identity validation for command dispatch and stop dispatch, NDJSON event streaming including stop with an active follower, service-backed lifecycle-control flows against the fixture-backed `simctl` boundary, stable direct plus service-backed exit-code behavior, useful command help, direct/service capacity and idle parity, lazy daemon start, local-only scheduler limitation, immediate startup reconciliation, 30-second timer wiring, and snapshot refresh +- `npm run test:client` proves service lifecycle, concurrent clients, startup readiness before service metadata publication, restart safety, malformed service response handling, expected service identity validation for command dispatch and stop dispatch, NDJSON event streaming including stop with an active follower, service-backed lifecycle-control flows and boot readiness budgets against the fixture-backed `simctl` boundary, stable direct plus service-backed exit-code behavior, useful command help, direct/service capacity and idle parity, lazy daemon start, local-only scheduler limitation, immediate startup reconciliation, 30-second timer wiring, and snapshot refresh - `npm run test:app` proves the XcodeGen project builds and the app decodes snapshots, filters pin candidates, bounds local CLI subprocesses, preserves refresh diagnostics after successful mutations whose snapshot reload fails, routes broker-command errors correctly, and drives Automatic shutdown apply, disable, preview, confirmation, cleanup, and refresh flows - the generated app test scheme receives a per-run temporary state root and host-config path from `scripts/test_app.sh`; the XCTest host never launches diff --git a/spec/global-simulator-broker.md b/spec/global-simulator-broker.md index 23717ed..289c304 100644 --- a/spec/global-simulator-broker.md +++ b/spec/global-simulator-broker.md @@ -163,7 +163,7 @@ Current implementation slice: - service stop closes active event streams before shutdown completes - malformed JSON from a running service is reported as service unavailability by clients instead of escaping as an uncaught parser failure - service startup must not publish a listenable socket or service metadata until the initial broker-owned app snapshot refresh has completed or explicitly reported a missing-host setup state; the CLI start launcher must wait within the bounded startup snapshot budget for one shared process-table sample, startup-lock owner PID lifetime sampling, and all inventory commands, validate startup-lock owner PID lifetime before accepting an old lock as live, and terminate the spawned daemon on startup timeout -- service-backed timeout budgets must reserve one shared process-table sample per broker state load; stale-lease containment process samples discovered from lease files before command dispatch for every broker state load that can retry containment; serialized read lock waits for `host status`, `doctor`, and `lease explain`; final app snapshot lock waits for every command that publishes the shared app snapshot artifact; capacity check inventory reads; lease acquire reset `simctl` shutdown/erase work; and host bootstrap runtime lookup, baseline inventory, starter-alias provisioning, replacement-retirement, and final snapshot refresh work before the transport timeout is advertised +- service-backed timeout budgets must reserve one shared process-table sample per broker state load; stale-lease containment process samples discovered from lease files before command dispatch for every broker state load that can retry containment; serialized read lock waits for `host status`, `doctor`, and `lease explain`; final app snapshot lock waits for every command that publishes the shared app snapshot artifact; capacity check inventory reads; lease acquire reset `simctl` shutdown/erase work plus boot readiness wait; explicit simulator boot readiness wait; and host bootstrap runtime lookup, baseline inventory, starter-alias provisioning, replacement-retirement, and final snapshot refresh work before the transport timeout is advertised - broker-mediated lifecycle-control requests for `boot`, `shutdown`, `erase`, and `repair` - command transport consumed by the macOS app for broker-backed operator actions - service-backed commands observing the same real `simctl`-synchronized alias health and repair state as direct CLI mode