@moretea-labs/forge 1.7.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +13 -0
- package/CLAUDE.md +2 -0
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/agents/openai.yaml +1 -1
- package/assets/partials-agents/02-operating-mode.partial.md +2 -0
- package/assets/plugin-registry.v1.json +3 -3
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/check-architecture-sync.sh +17 -0
- package/assets/templates/helpers/inspect-project-state.ts +8 -6
- package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
- package/assets/templates/helpers/workflow-contract.ts +5 -1
- package/docs/architecture/CURRENT.md +12 -4
- package/docs/forge-plugin-management.md +1 -1
- package/docs/operations/controller-performance-and-502.md +19 -0
- package/docs/operations/homebrew.md +2 -2
- package/package.json +5 -2
- package/scripts/architecture-event.ts +2 -820
- package/scripts/architecture-queue.sh +2 -645
- package/scripts/archive-architecture-request.sh +2 -242
- package/scripts/archive-workflow.sh +1 -291
- package/scripts/benchmark-work-efficiency.ts +0 -0
- package/scripts/capability-config.ts +2 -392
- package/scripts/capability-resolver.ts +2 -503
- package/scripts/capture-plan.sh +1 -332
- package/scripts/check-agent-tooling.sh +2 -1531
- package/scripts/check-architecture-sync.sh +2 -302
- package/scripts/check-brain-manifest.sh +2 -253
- package/scripts/check-context-files.sh +2 -47
- package/scripts/check-deploy-sql-order.sh +2 -115
- package/scripts/check-runtime-architecture.mjs +243 -16
- package/scripts/check-skill-version.ts +3 -191
- package/scripts/check-source-duplication.mjs +61 -0
- package/scripts/check-stable-baseline.ts +0 -0
- package/scripts/check-task-sync.sh +2 -63
- package/scripts/check-task-workflow.sh +2 -1000
- package/scripts/codex-handoff-resume.sh +2 -267
- package/scripts/context-contract-sync.sh +2 -514
- package/scripts/contract-run.ts +2 -463
- package/scripts/contract-worktree.sh +1 -702
- package/scripts/ensure-task-workflow.sh +1 -1192
- package/scripts/inspect-project-state.ts +4 -279
- package/scripts/lib/project-init-lib.sh +7 -2
- package/scripts/lib/run-forge-helper.sh +19 -0
- package/scripts/lib/run-forge-helper.ts +26 -0
- package/scripts/migrate-project-template.sh +1 -33
- package/scripts/migrate-workflow-docs.ts +4 -412
- package/scripts/new-plan.sh +1 -180
- package/scripts/new-spec.sh +1 -47
- package/scripts/new-sprint.sh +1 -46
- package/scripts/plan-to-todo.sh +1 -771
- package/scripts/prepare-codex-handoff.sh +2 -201
- package/scripts/prepare-handoff.sh +1 -78
- package/scripts/refresh-current-status.sh +2 -467
- package/scripts/run-governed-gate.ts +3 -0
- package/scripts/select-agent-context-blocks.sh +2 -64
- package/scripts/ship-worktrees.sh +1 -531
- package/scripts/sprint-backlog.sh +1 -834
- package/scripts/stage-runtime-release.ts +2 -1
- package/scripts/summarize-failures.sh +2 -123
- package/scripts/switch-plan.sh +1 -150
- package/scripts/sync-brain-docs.sh +2 -379
- package/scripts/sync-generated-authority.mjs +175 -0
- package/scripts/verify-contract.sh +2 -593
- package/scripts/verify-sprint.sh +1 -500
- package/scripts/workflow-contract.ts +4 -201
- package/scripts/workstream-sync.sh +2 -261
- package/src/cli/agent-jobs/executor-health.ts +1 -1
- package/src/cli/commands/plugin.ts +12 -3
- package/src/cli/controller/check-runner.ts +7 -2
- package/src/cli/controller/context-pack.ts +46 -21
- package/src/cli/github/contracts.ts +16 -0
- package/src/cli/github/github.ts +2 -16
- package/src/cli/github/session.ts +2 -1
- package/src/cli/local-bridge/facade-api.ts +37 -47
- package/src/cli/local-bridge/server.ts +2 -2
- package/src/cli/mcp/legacy-tool-service.ts +10 -33
- package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
- package/src/cli/mcp/repository-tools.ts +85 -89
- package/src/cli/mcp/server.ts +57 -13
- package/src/cli/mcp/tool-contract.ts +14 -0
- package/src/cli/mcp/toolset.ts +2 -2
- package/src/cli/mcp/transports/http.ts +23 -0
- package/src/cli/repositories/command-classifier.ts +2 -2
- package/src/cli/repositories/command-executor.ts +24 -6
- package/src/cli/repositories/repository-snapshot.ts +5 -2
- package/src/cli/repositories/structured-git.ts +57 -3
- package/src/cli/repository/inspector.ts +11 -3
- package/src/runtime/context/generic-lsp-provider.ts +1 -1
- package/src/runtime/context/semantic-navigation-contract.ts +61 -0
- package/src/runtime/context/semantic-navigation.ts +9 -61
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +18 -25
- package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
- package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
- package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
- package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
- package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
- package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
- package/src/runtime/control-plane/execution/work-handle-authority.ts +96 -0
- package/src/runtime/control-plane/execution/work-handle-store.ts +1 -1
- package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
- package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
- package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
- package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
- package/src/runtime/control-plane/execution/work-task-receipt.ts +4 -43
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +13 -3
- package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
- package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +5 -1
- package/src/runtime/control-plane/facade/controller-round-relay.ts +164 -37
- package/src/runtime/control-plane/facade/controller-session-store.ts +183 -24
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +15 -1
- package/src/runtime/control-plane/facade/goal-workloop.ts +329 -148
- package/src/runtime/control-plane/facade/index.ts +2 -0
- package/src/runtime/control-plane/facade/plan-contract-store.ts +90 -22
- package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
- package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
- package/src/runtime/control-plane/facade/types.ts +68 -5
- package/src/runtime/control-plane/facade/work-contract-store.ts +3 -209
- package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
- package/src/runtime/control-plane/global-scheduler/maintenance.ts +14 -7
- package/src/runtime/control-plane/global-scheduler/scheduler.ts +5 -1
- package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +13 -0
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +15 -6
- package/src/runtime/control-plane/launcher/launch-reservation-store.ts +54 -5
- package/src/runtime/control-plane/launcher/thin-launcher.ts +56 -7
- package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
- package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
- package/src/runtime/control-plane/routing/route-policy.ts +18 -6
- package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
- package/src/runtime/evidence/process-check-execution.ts +18 -0
- package/src/runtime/execution/jobs/child-reference.ts +6 -2
- package/src/runtime/execution/process-runtime/check-result.ts +53 -18
- package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
- package/src/runtime/execution/process-runtime/command-facade.ts +39 -17
- package/src/runtime/execution/process-runtime/lightweight-managed.ts +20 -3
- package/src/runtime/execution/process-runtime/persisted-check.ts +4 -1
- package/src/runtime/execution/process-runtime/runtime.ts +10 -0
- package/src/runtime/execution/process-runtime/types.ts +2 -12
- package/src/runtime/execution/work-activity.ts +21 -0
- package/src/runtime/execution/workers/executor.ts +18 -13
- package/src/runtime/gateway/mcp/execution-tools.ts +35 -2930
- package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
- package/src/runtime/gateway/mcp/router.ts +2 -2
- package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +21 -2
- package/src/runtime/gateway/mcp/runtime-tools.ts +805 -1009
- package/src/runtime/plugins/app-store-connect-adapter.ts +93 -28
- package/src/runtime/plugins/browser-adapter.ts +256 -1083
- package/src/runtime/plugins/browser-handoff-host.ts +2 -25
- package/src/runtime/plugins/browser-handoff.ts +18 -3
- package/src/runtime/plugins/browser-macos-bridge.ts +121 -16
- package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
- package/src/runtime/plugins/browser-native-session-service.ts +222 -0
- package/src/runtime/plugins/browser-runtime-contract.ts +2 -0
- package/src/runtime/plugins/browser-runtime.ts +10 -0
- package/src/runtime/plugins/browser-session-store.ts +121 -0
- package/src/runtime/plugins/browser-session-types.ts +98 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +4 -4
- package/src/runtime/plugins/external-adapter.ts +92 -58
- package/src/runtime/plugins/macos-capability-broker.ts +0 -8
- package/src/runtime/plugins/store.ts +66 -9
- package/src/runtime/plugins/types.ts +3 -1
- package/src/runtime/recovery/maintenance-executor.ts +61 -25
- package/src/runtime/root/package-connector-service.ts +46 -4
- package/src/runtime/root/package-runtime-release.ts +1 -1
- package/src/runtime/root/package-runtime-service.ts +33 -2
- package/src/runtime/root/release-manifest.ts +137 -16
- package/src/runtime/root/release-materialize.ts +109 -35
- package/src/runtime/root/types.ts +10 -0
- package/src/runtime/standalone-recovery/core.ts +425 -194
- package/src/runtime/standalone-recovery/entry.ts +1 -1
- package/src/runtime/standalone-recovery/installer.ts +1 -1
- package/src/runtime/workflow/schedules/engine.ts +113 -87
- package/src/runtime/workflow/schedules/settlement.ts +26 -23
- package/src/runtime/workflow/schedules/store.ts +79 -10
|
@@ -513,8 +513,34 @@ function recoveryLockOwnerAlive(lock: RecoveryLock): boolean {
|
|
|
513
513
|
return observed === undefined || observed === lock.processStartTime;
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
+
function recoveryLockOwnerAttributable(owner: RecoveryLock): boolean {
|
|
517
|
+
return Boolean(owner.action && owner.requestId?.trim());
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function recoveryLockIdentityFailure(owner: RecoveryLock): string {
|
|
521
|
+
return `RECOVERY_OPERATION_LOCK_IDENTITY_UNCERTAIN: live mutation lock pid=${owner.pid} instance=${owner.instanceId} is missing action/request identity`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function assertRecoveryLockOwnerAttributable(config: RecoveryConfig, owner: RecoveryLock): void {
|
|
525
|
+
if (recoveryLockOwnerAttributable(owner)) return;
|
|
526
|
+
audit(config, 'recovery_operation_lock_identity_uncertain', {
|
|
527
|
+
pid: owner.pid,
|
|
528
|
+
instanceId: owner.instanceId,
|
|
529
|
+
action: owner.action ?? null,
|
|
530
|
+
requestId: owner.requestId ?? null,
|
|
531
|
+
acquiredAt: owner.acquiredAt,
|
|
532
|
+
});
|
|
533
|
+
throw new Error(recoveryLockIdentityFailure(owner));
|
|
534
|
+
}
|
|
535
|
+
|
|
516
536
|
function recoveryBusyDetail(owner: RecoveryLock): string {
|
|
517
|
-
return `Recovery mutation already in progress: action=${owner.action ?? '
|
|
537
|
+
return `Recovery mutation already in progress: action=${owner.action ?? 'invalid'} request=${owner.requestId ?? 'invalid'} pid=${owner.pid} instance=${owner.instanceId}`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function liveRecoveryMutationLock(config: RecoveryConfig): RecoveryLock | undefined {
|
|
541
|
+
const owner = json<RecoveryLock>(lockPath(config));
|
|
542
|
+
if (!owner || !Number.isInteger(owner.pid) || owner.pid <= 0 || typeof owner.instanceId !== 'string' || !owner.instanceId.trim()) return undefined;
|
|
543
|
+
return recoveryLockOwnerAlive(owner) ? owner : undefined;
|
|
518
544
|
}
|
|
519
545
|
|
|
520
546
|
async function withLock<T>(
|
|
@@ -524,14 +550,16 @@ async function withLock<T>(
|
|
|
524
550
|
): Promise<RecoveryLockAttempt<T>> {
|
|
525
551
|
const path = lockPath(config);
|
|
526
552
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
553
|
+
const instanceId = randomUUID();
|
|
554
|
+
const requestId = intent.requestId?.trim() || `internal:${intent.action}:${instanceId}`;
|
|
527
555
|
const lock: RecoveryLock = {
|
|
528
556
|
schemaVersion: 1,
|
|
529
557
|
pid: process.pid,
|
|
530
|
-
instanceId
|
|
558
|
+
instanceId,
|
|
531
559
|
processStartTime: processStartTime(process.pid),
|
|
532
560
|
acquiredAt: new Date().toISOString(),
|
|
533
561
|
action: intent.action,
|
|
534
|
-
|
|
562
|
+
requestId,
|
|
535
563
|
};
|
|
536
564
|
|
|
537
565
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
@@ -548,13 +576,31 @@ async function withLock<T>(
|
|
|
548
576
|
if (!existing || !Number.isInteger(existing.pid) || typeof existing.instanceId !== 'string') {
|
|
549
577
|
throw new Error('RECOVERY_OPERATION_LOCK_UNCERTAIN');
|
|
550
578
|
}
|
|
551
|
-
if (recoveryLockOwnerAlive(existing))
|
|
579
|
+
if (recoveryLockOwnerAlive(existing)) {
|
|
580
|
+
assertRecoveryLockOwnerAttributable(config, existing);
|
|
581
|
+
return { acquired: false, owner: existing };
|
|
582
|
+
}
|
|
552
583
|
const latest = json<RecoveryLock>(path);
|
|
553
584
|
if (!latest || latest.instanceId !== existing.instanceId) {
|
|
554
|
-
if (attempt === 1)
|
|
585
|
+
if (attempt === 1) {
|
|
586
|
+
if (latest && recoveryLockOwnerAlive(latest)) {
|
|
587
|
+
assertRecoveryLockOwnerAttributable(config, latest);
|
|
588
|
+
return { acquired: false, owner: latest };
|
|
589
|
+
}
|
|
590
|
+
audit(config, 'recovery_operation_lock_race', {
|
|
591
|
+
existingInstanceId: existing.instanceId,
|
|
592
|
+
existingPid: existing.pid,
|
|
593
|
+
latestInstanceId: latest?.instanceId ?? null,
|
|
594
|
+
latestPid: latest?.pid ?? null,
|
|
595
|
+
});
|
|
596
|
+
throw new Error('RECOVERY_OPERATION_LOCK_RACE');
|
|
597
|
+
}
|
|
555
598
|
continue;
|
|
556
599
|
}
|
|
557
|
-
if (recoveryLockOwnerAlive(latest))
|
|
600
|
+
if (recoveryLockOwnerAlive(latest)) {
|
|
601
|
+
assertRecoveryLockOwnerAttributable(config, latest);
|
|
602
|
+
return { acquired: false, owner: latest };
|
|
603
|
+
}
|
|
558
604
|
try { writeFileSync(`${path}.stale-${Date.now()}-${existing.instanceId}`, readFileSync(path)); } catch { /* evidence best effort */ }
|
|
559
605
|
rmSync(path, { force: true });
|
|
560
606
|
continue;
|
|
@@ -632,7 +678,7 @@ async function probeExternalMcp(transport: RecoveryHttpTransport, url: string):
|
|
|
632
678
|
|
|
633
679
|
function publicGatewayReadinessEndpoint(endpoint: string): string {
|
|
634
680
|
const url = new URL(endpoint);
|
|
635
|
-
url.pathname = '/ready';
|
|
681
|
+
url.pathname = '/transport-ready';
|
|
636
682
|
url.search = '';
|
|
637
683
|
url.hash = '';
|
|
638
684
|
return url.toString();
|
|
@@ -644,6 +690,7 @@ async function probePublicGatewayReadiness(
|
|
|
644
690
|
): Promise<{ ok: boolean; detail: string; status?: number; value?: unknown }> {
|
|
645
691
|
const controller = new AbortController();
|
|
646
692
|
const timer = setTimeout(() => controller.abort('RECOVERY_HTTP_TIMEOUT'), 4_000);
|
|
693
|
+
const surface = 'transport-ready';
|
|
647
694
|
try {
|
|
648
695
|
const response = await transport.request({
|
|
649
696
|
url: publicGatewayReadinessEndpoint(endpoint),
|
|
@@ -651,6 +698,18 @@ async function probePublicGatewayReadiness(
|
|
|
651
698
|
timeoutMs: 4_000,
|
|
652
699
|
signal: controller.signal,
|
|
653
700
|
});
|
|
701
|
+
if (response.status === 404 || response.status === 405) {
|
|
702
|
+
// Older Connectors predate the transport-scoped capacity surface. Their
|
|
703
|
+
// whole-control-plane /ready route is intentionally not a transport
|
|
704
|
+
// liveness contract and can depend on the Runtime that Recovery is in the
|
|
705
|
+
// middle of restarting. Falling back to it creates a circular cutover
|
|
706
|
+
// dependency. Verify the actual MCP transport instead: a successful
|
|
707
|
+
// initialize response or the expected OAuth Bearer challenge proves the
|
|
708
|
+
// legacy Connector transport is reachable without weakening 5xx/timeout
|
|
709
|
+
// failure handling.
|
|
710
|
+
const legacy = await probeExternalMcp(transport, endpoint);
|
|
711
|
+
return { ...legacy, detail: `legacy-mcp ${legacy.detail}` };
|
|
712
|
+
}
|
|
654
713
|
let sessionCapacity: unknown;
|
|
655
714
|
try {
|
|
656
715
|
const payload = JSON.parse(response.body) as { sessionCapacity?: unknown };
|
|
@@ -663,12 +722,12 @@ async function probePublicGatewayReadiness(
|
|
|
663
722
|
);
|
|
664
723
|
return {
|
|
665
724
|
ok: response.ok && !recoveryRecommended,
|
|
666
|
-
detail:
|
|
725
|
+
detail: `${surface} HTTP ${response.status}${recoveryRecommended ? '; session capacity recommends Connector recovery' : ''}`,
|
|
667
726
|
status: response.status,
|
|
668
727
|
...(sessionCapacity === undefined ? {} : { value: sessionCapacity }),
|
|
669
728
|
};
|
|
670
729
|
} catch (error) {
|
|
671
|
-
return { ok: false, detail: error instanceof Error ? error.message.slice(0, 180) : 'request failed' };
|
|
730
|
+
return { ok: false, detail: `${surface} ${error instanceof Error ? error.message.slice(0, 180) : 'request failed'}` };
|
|
672
731
|
} finally {
|
|
673
732
|
clearTimeout(timer);
|
|
674
733
|
}
|
|
@@ -855,8 +914,11 @@ export async function verifyStableRuntime(
|
|
|
855
914
|
: { ok: false, detail: 'canonical Runtime endpoint is unavailable' };
|
|
856
915
|
if (config.publicMcpUrl) {
|
|
857
916
|
probes.external_mcp_http = await probeExternalMcp(transport, config.publicMcpUrl);
|
|
858
|
-
|
|
917
|
+
const connectorReadinessEndpoint = config.primaryConnectorService?.localMcpUrl?.trim() || config.publicMcpUrl;
|
|
918
|
+
probes.primary_connector_ready = await probePublicGatewayReadiness(transport, connectorReadinessEndpoint);
|
|
859
919
|
}
|
|
920
|
+
const primaryConnectorLocal = await probePrimaryConnectorLocal(config, transport);
|
|
921
|
+
if (primaryConnectorLocal) probes.primary_connector_local = primaryConnectorLocal;
|
|
860
922
|
if (config.gateway) probes.recovery_gateway = await probe(transport, `http://${config.gateway.host}:${config.gateway.port}/health`);
|
|
861
923
|
const watchdogHealth = observeRecoveryWatchdogHealth(config.controllerHome);
|
|
862
924
|
probes.recovery_watchdog = {
|
|
@@ -1369,6 +1431,7 @@ export interface PrimaryRuntimeRecoveryDependencies {
|
|
|
1369
1431
|
runCommand?: CommandRunner;
|
|
1370
1432
|
verifyLocal?: (config: RecoveryConfig) => Promise<VerifyResult>;
|
|
1371
1433
|
runtimeRunning?: (config: RecoveryConfig) => boolean;
|
|
1434
|
+
ensureRuntimeLaunchContract?: (controllerHome: string) => void;
|
|
1372
1435
|
now?: () => number;
|
|
1373
1436
|
sleep?: (ms: number) => Promise<void>;
|
|
1374
1437
|
}
|
|
@@ -1402,6 +1465,8 @@ function primaryRuntimeLaunchdService(config: RecoveryConfig, uid: number): Laun
|
|
|
1402
1465
|
}
|
|
1403
1466
|
|
|
1404
1467
|
export interface PrimaryConnectorRecoveryDependencies {
|
|
1468
|
+
/** Durable caller identity propagated into the Recovery mutation lock and audit trail. */
|
|
1469
|
+
requestId?: string;
|
|
1405
1470
|
platform?: NodeJS.Platform;
|
|
1406
1471
|
currentUid?: () => Promise<number | undefined>;
|
|
1407
1472
|
runCommand?: CommandRunner;
|
|
@@ -1424,10 +1489,13 @@ function primaryConnectorLaunchdService(config: RecoveryConfig, uid: number): La
|
|
|
1424
1489
|
return { uid, domain, target: `${domain}/${label}`, label, plistPath };
|
|
1425
1490
|
}
|
|
1426
1491
|
|
|
1427
|
-
async function probePrimaryConnectorLocal(
|
|
1492
|
+
async function probePrimaryConnectorLocal(
|
|
1493
|
+
config: RecoveryConfig,
|
|
1494
|
+
transport = createRecoveryHttpTransport(config.controllerHome),
|
|
1495
|
+
): Promise<{ ok: boolean; detail: string; status?: number } | undefined> {
|
|
1428
1496
|
const localMcpUrl = config.primaryConnectorService?.localMcpUrl?.trim();
|
|
1429
1497
|
if (!localMcpUrl) return undefined;
|
|
1430
|
-
return probeExternalMcp(
|
|
1498
|
+
return probeExternalMcp(transport, localMcpUrl);
|
|
1431
1499
|
}
|
|
1432
1500
|
|
|
1433
1501
|
function activePackageConnectorReleaseBinding(config: RecoveryConfig): PackageConnectorReleaseBinding | undefined {
|
|
@@ -1503,7 +1571,16 @@ export async function restartPrimaryConnector(
|
|
|
1503
1571
|
): Promise<PrimaryConnectorRestartResult> {
|
|
1504
1572
|
const verifyLocal = dependencies.verifyLocal ?? verifyLocalRuntime;
|
|
1505
1573
|
const initialLocal = await verifyLocal(config);
|
|
1506
|
-
|
|
1574
|
+
// `verifyLocalRuntime` also reports the Connector's own loopback probe.
|
|
1575
|
+
// That probe is expected to be false when this recovery action is needed,
|
|
1576
|
+
// so it must not prevent a healthy canonical Runtime from repairing the
|
|
1577
|
+
// Connector service.
|
|
1578
|
+
const canonicalRuntimeHealthy = initialLocal.runtime.ok
|
|
1579
|
+
&& initialLocal.runtime.running
|
|
1580
|
+
&& initialLocal.runtime.ready
|
|
1581
|
+
&& !initialLocal.runtime.stale
|
|
1582
|
+
&& initialLocal.probes.active_gateway?.ok === true;
|
|
1583
|
+
if (!canonicalRuntimeHealthy) {
|
|
1507
1584
|
return {
|
|
1508
1585
|
ok: false,
|
|
1509
1586
|
attempted: false,
|
|
@@ -1528,7 +1605,7 @@ export async function restartPrimaryConnector(
|
|
|
1528
1605
|
const now = dependencies.now ?? Date.now;
|
|
1529
1606
|
const wait = dependencies.sleep ?? sleep;
|
|
1530
1607
|
const timeoutMs = config.primaryConnectorService?.postRestartVerifyTimeoutMs ?? 30_000;
|
|
1531
|
-
const locked = await withLock(config, { action: 'restart_primary_connector' }, async () => {
|
|
1608
|
+
const locked = await withLock(config, { action: 'restart_primary_connector', requestId: dependencies.requestId }, async () => {
|
|
1532
1609
|
const tunnelConfigured = Boolean(configuredPrimaryPublicTunnel(config));
|
|
1533
1610
|
const repairConnectorBinding = dependencies.repairConnectorBinding ?? repairPrimaryConnectorBinding;
|
|
1534
1611
|
const bindingRepair = await repairConnectorBinding(config);
|
|
@@ -1818,6 +1895,77 @@ async function cleanupVerifiedStalePrimaryRuntimeListener(input: {
|
|
|
1818
1895
|
: { released: false, cleaned: false, detail: 'verified stale Forge Runtime listener remained on the port after SIGKILL', pid };
|
|
1819
1896
|
}
|
|
1820
1897
|
|
|
1898
|
+
interface PrimaryRuntimeStoppedTransition {
|
|
1899
|
+
ok: boolean;
|
|
1900
|
+
detail: string;
|
|
1901
|
+
staleListenerCleanup?: PrimaryRuntimePortCleanupResult;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Stop the one canonical Runtime service and prove that every execution owner
|
|
1906
|
+
* boundary is gone before release authority may change. This is the shared
|
|
1907
|
+
* transition primitive for activation and recovery; callers must not publish
|
|
1908
|
+
* or roll back a release after a partial stop.
|
|
1909
|
+
*/
|
|
1910
|
+
async function stopPrimaryRuntimeForReleaseTransition(input: {
|
|
1911
|
+
config: RecoveryConfig;
|
|
1912
|
+
service: LaunchdService;
|
|
1913
|
+
now: () => number;
|
|
1914
|
+
wait: (ms: number) => Promise<void>;
|
|
1915
|
+
runCommand: CommandRunner;
|
|
1916
|
+
runtimeRunning: (config: RecoveryConfig) => boolean;
|
|
1917
|
+
}): Promise<PrimaryRuntimeStoppedTransition> {
|
|
1918
|
+
const stopped = await input.runCommand('launchctl', ['bootout', input.service.target], 15_000);
|
|
1919
|
+
const stoppedCleanly = stopped.ok || /not found|no such process|could not find service|service is not loaded/i.test(`${stopped.stderr}\n${stopped.stdout}`);
|
|
1920
|
+
if (!stoppedCleanly) {
|
|
1921
|
+
return { ok: false, detail: `primary Runtime bootout failed: ${stopped.stderr || stopped.stdout || stopped.status}` };
|
|
1922
|
+
}
|
|
1923
|
+
const runtimeStopped = await waitForPrimaryRuntimeState({
|
|
1924
|
+
config: input.config,
|
|
1925
|
+
expectedRunning: false,
|
|
1926
|
+
timeoutMs: 20_000,
|
|
1927
|
+
now: input.now,
|
|
1928
|
+
wait: input.wait,
|
|
1929
|
+
runtimeRunning: input.runtimeRunning,
|
|
1930
|
+
});
|
|
1931
|
+
if (!runtimeStopped) return { ok: false, detail: 'primary Runtime owner remained live after bounded launchd bootout' };
|
|
1932
|
+
const serviceUnloaded = await waitForLaunchdServiceUnloaded({
|
|
1933
|
+
service: input.service,
|
|
1934
|
+
timeoutMs: 20_000,
|
|
1935
|
+
now: input.now,
|
|
1936
|
+
wait: input.wait,
|
|
1937
|
+
runCommand: input.runCommand,
|
|
1938
|
+
});
|
|
1939
|
+
if (!serviceUnloaded) return { ok: false, detail: 'primary Runtime launchd service remained loaded after bounded bootout' };
|
|
1940
|
+
|
|
1941
|
+
let portReleased = await waitForPrimaryRuntimePortReleased({
|
|
1942
|
+
config: input.config,
|
|
1943
|
+
timeoutMs: 20_000,
|
|
1944
|
+
now: input.now,
|
|
1945
|
+
wait: input.wait,
|
|
1946
|
+
runCommand: input.runCommand,
|
|
1947
|
+
});
|
|
1948
|
+
let staleListenerCleanup: PrimaryRuntimePortCleanupResult | undefined;
|
|
1949
|
+
if (!portReleased) {
|
|
1950
|
+
staleListenerCleanup = await cleanupVerifiedStalePrimaryRuntimeListener({
|
|
1951
|
+
config: input.config,
|
|
1952
|
+
uid: input.service.uid,
|
|
1953
|
+
now: input.now,
|
|
1954
|
+
wait: input.wait,
|
|
1955
|
+
runCommand: input.runCommand,
|
|
1956
|
+
});
|
|
1957
|
+
portReleased = staleListenerCleanup.released;
|
|
1958
|
+
}
|
|
1959
|
+
if (!portReleased) {
|
|
1960
|
+
return {
|
|
1961
|
+
ok: false,
|
|
1962
|
+
detail: `primary Runtime TCP port remained occupied after bounded launchd bootout: ${staleListenerCleanup?.detail ?? 'listener did not release'}`,
|
|
1963
|
+
staleListenerCleanup,
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
return { ok: true, detail: 'primary Runtime service, owner, and TCP listener are stopped', staleListenerCleanup };
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1821
1969
|
async function verifyPrimaryRuntimeAfterStart(input: {
|
|
1822
1970
|
config: RecoveryConfig;
|
|
1823
1971
|
timeoutMs: number;
|
|
@@ -1834,6 +1982,73 @@ async function verifyPrimaryRuntimeAfterStart(input: {
|
|
|
1834
1982
|
return observed;
|
|
1835
1983
|
}
|
|
1836
1984
|
|
|
1985
|
+
interface PrimaryRuntimeRebindStartResult {
|
|
1986
|
+
ok: boolean;
|
|
1987
|
+
detail: string;
|
|
1988
|
+
verify: VerifyResult;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/**
|
|
1992
|
+
* Complete the only safe restart sequence after release authority changes:
|
|
1993
|
+
* rebind the release-pinned launchd contract, start the sole Runtime service,
|
|
1994
|
+
* and require whole-Runtime verification. A started-but-unverified Runtime is
|
|
1995
|
+
* stopped again so callers never leave a restart loop behind.
|
|
1996
|
+
*/
|
|
1997
|
+
async function rebindStartAndVerifyPrimaryRuntime(input: {
|
|
1998
|
+
config: RecoveryConfig;
|
|
1999
|
+
service: LaunchdService;
|
|
2000
|
+
runCommand: CommandRunner;
|
|
2001
|
+
now: () => number;
|
|
2002
|
+
wait: (ms: number) => Promise<void>;
|
|
2003
|
+
verifyLocal: (config: RecoveryConfig) => Promise<VerifyResult>;
|
|
2004
|
+
ensureRuntimeLaunchContract?: (controllerHome: string) => void;
|
|
2005
|
+
beforeStart?: () => void;
|
|
2006
|
+
contractFailureContext?: string;
|
|
2007
|
+
timeoutMs: number;
|
|
2008
|
+
successDetail: string;
|
|
2009
|
+
}): Promise<PrimaryRuntimeRebindStartResult> {
|
|
2010
|
+
try {
|
|
2011
|
+
const ensureRuntimeLaunchContract = input.ensureRuntimeLaunchContract
|
|
2012
|
+
?? ((controllerHome: string) => { ensureForgeRuntimeLaunchAgentContract({ controllerHome, installUserLaunchAgent: true }); });
|
|
2013
|
+
ensureRuntimeLaunchContract(input.config.controllerHome);
|
|
2014
|
+
} catch (error) {
|
|
2015
|
+
return {
|
|
2016
|
+
ok: false,
|
|
2017
|
+
detail: `primary Runtime launchd contract rebuild failed ${input.contractFailureContext ?? 'after release transition'}: ${error instanceof Error ? error.message : String(error)}`,
|
|
2018
|
+
verify: await input.verifyLocal(input.config),
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
try {
|
|
2023
|
+
input.beforeStart?.();
|
|
2024
|
+
} catch (error) {
|
|
2025
|
+
return {
|
|
2026
|
+
ok: false,
|
|
2027
|
+
detail: `release transition pre-start preparation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2028
|
+
verify: await input.verifyLocal(input.config),
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
const started = await ensureLaunchdServiceStarted(input.service, input.runCommand);
|
|
2033
|
+
if (!started.ok) {
|
|
2034
|
+
return { ok: false, detail: started.detail, verify: await input.verifyLocal(input.config) };
|
|
2035
|
+
}
|
|
2036
|
+
const verify = await verifyPrimaryRuntimeAfterStart({
|
|
2037
|
+
config: input.config,
|
|
2038
|
+
timeoutMs: input.timeoutMs,
|
|
2039
|
+
now: input.now,
|
|
2040
|
+
wait: input.wait,
|
|
2041
|
+
verifyLocal: input.verifyLocal,
|
|
2042
|
+
});
|
|
2043
|
+
if (verify.ok) return { ok: true, detail: input.successDetail, verify };
|
|
2044
|
+
await input.runCommand('launchctl', ['bootout', input.service.target], 15_000);
|
|
2045
|
+
return {
|
|
2046
|
+
ok: false,
|
|
2047
|
+
detail: 'release transition started a Runtime that failed whole-Runtime verification; service was stopped to prevent a restart loop',
|
|
2048
|
+
verify,
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
|
|
1837
2052
|
export async function restartPrimaryRuntime(
|
|
1838
2053
|
config: RecoveryConfig,
|
|
1839
2054
|
dependencies: PrimaryRuntimeRecoveryDependencies = {},
|
|
@@ -1897,44 +2112,21 @@ export async function recoverPrimaryRuntime(
|
|
|
1897
2112
|
const before = await verifyLocal(config);
|
|
1898
2113
|
if (before.ok) return { ok: true, attempted: false, noOp: true, detail: 'Canonical Forge Runtime recovered before rollback', serviceTarget: service.target, verify: before } satisfies PrimaryRuntimeRecoveryResult;
|
|
1899
2114
|
|
|
1900
|
-
const stopped = await
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
const runtimeStopped = await waitForPrimaryRuntimeState({ config, expectedRunning: false, timeoutMs: 20_000, now, wait, runtimeRunning });
|
|
1908
|
-
if (!runtimeStopped) {
|
|
1909
|
-
const detail = 'primary Runtime owner remained live after bounded launchd bootout';
|
|
1910
|
-
audit(config, 'primary_runtime_recovery_stop_unverified', { serviceTarget: service.target });
|
|
1911
|
-
return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
|
|
1912
|
-
}
|
|
1913
|
-
const serviceUnloaded = await waitForLaunchdServiceUnloaded({ service, timeoutMs: 20_000, now, wait, runCommand });
|
|
1914
|
-
if (!serviceUnloaded) {
|
|
1915
|
-
const detail = 'primary Runtime launchd service remained loaded after bounded bootout';
|
|
1916
|
-
audit(config, 'primary_runtime_recovery_stop_unverified', { serviceTarget: service.target, detail });
|
|
1917
|
-
return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
let portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
|
|
1921
|
-
let staleListenerCleanup: PrimaryRuntimePortCleanupResult | undefined;
|
|
1922
|
-
if (!portReleased) {
|
|
1923
|
-
staleListenerCleanup = await cleanupVerifiedStalePrimaryRuntimeListener({ config, uid: service.uid, now, wait, runCommand });
|
|
1924
|
-
portReleased = staleListenerCleanup.released;
|
|
1925
|
-
if (staleListenerCleanup.cleaned) {
|
|
1926
|
-
audit(config, 'primary_runtime_recovery_stale_listener_cleaned', {
|
|
1927
|
-
serviceTarget: service.target,
|
|
1928
|
-
pid: staleListenerCleanup.pid,
|
|
1929
|
-
signal: staleListenerCleanup.signal,
|
|
1930
|
-
detail: staleListenerCleanup.detail,
|
|
1931
|
-
});
|
|
1932
|
-
}
|
|
2115
|
+
const stopped = await stopPrimaryRuntimeForReleaseTransition({ config, service, now, wait, runCommand, runtimeRunning });
|
|
2116
|
+
if (!stopped.ok) {
|
|
2117
|
+
const action = stopped.detail.startsWith('primary Runtime bootout failed')
|
|
2118
|
+
? 'primary_runtime_recovery_stop_failed'
|
|
2119
|
+
: 'primary_runtime_recovery_stop_unverified';
|
|
2120
|
+
audit(config, action, { serviceTarget: service.target, detail: stopped.detail, pid: stopped.staleListenerCleanup?.pid });
|
|
2121
|
+
return { ok: false, attempted: true, detail: stopped.detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
|
|
1933
2122
|
}
|
|
1934
|
-
if (
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2123
|
+
if (stopped.staleListenerCleanup?.cleaned) {
|
|
2124
|
+
audit(config, 'primary_runtime_recovery_stale_listener_cleaned', {
|
|
2125
|
+
serviceTarget: service.target,
|
|
2126
|
+
pid: stopped.staleListenerCleanup.pid,
|
|
2127
|
+
signal: stopped.staleListenerCleanup.signal,
|
|
2128
|
+
detail: stopped.staleListenerCleanup.detail,
|
|
2129
|
+
});
|
|
1938
2130
|
}
|
|
1939
2131
|
|
|
1940
2132
|
const rollback = await rollbackPreviousLocked(config, reason);
|
|
@@ -1943,26 +2135,30 @@ export async function recoverPrimaryRuntime(
|
|
|
1943
2135
|
return { ok: false, attempted: true, detail: rollback.detail, serviceTarget: service.target, rollback, verify: rollback.verify ?? await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
|
|
1944
2136
|
}
|
|
1945
2137
|
|
|
1946
|
-
const
|
|
1947
|
-
if (!started.ok) {
|
|
1948
|
-
audit(config, 'primary_runtime_recovery_start_failed', { serviceTarget: service.target, detail: started.detail, rollbackOperationId: rollback.operationId });
|
|
1949
|
-
return { ok: false, attempted: true, detail: started.detail, serviceTarget: service.target, rollback, verify: await verifyLocal(config) } satisfies PrimaryRuntimeRecoveryResult;
|
|
1950
|
-
}
|
|
1951
|
-
const after = await verifyPrimaryRuntimeAfterStart({
|
|
2138
|
+
const restarted = await rebindStartAndVerifyPrimaryRuntime({
|
|
1952
2139
|
config,
|
|
1953
|
-
|
|
2140
|
+
service,
|
|
2141
|
+
runCommand,
|
|
1954
2142
|
now,
|
|
1955
2143
|
wait,
|
|
1956
2144
|
verifyLocal,
|
|
2145
|
+
ensureRuntimeLaunchContract: dependencies.ensureRuntimeLaunchContract,
|
|
2146
|
+
contractFailureContext: 'after rollback',
|
|
2147
|
+
timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 45_000,
|
|
2148
|
+
successDetail: 'previous whole-Runtime release and SQLite backup restored, restarted, and verified',
|
|
1957
2149
|
});
|
|
1958
|
-
if (
|
|
1959
|
-
audit(config, 'primary_runtime_recovery_succeeded', { serviceTarget: service.target, rollbackOperationId: rollback.operationId, restoredRelease:
|
|
1960
|
-
return { ok: true, attempted: true, detail:
|
|
2150
|
+
if (restarted.ok) {
|
|
2151
|
+
audit(config, 'primary_runtime_recovery_succeeded', { serviceTarget: service.target, rollbackOperationId: rollback.operationId, restoredRelease: restarted.verify.releases.active?.revision });
|
|
2152
|
+
return { ok: true, attempted: true, detail: restarted.detail, serviceTarget: service.target, rollback, verify: restarted.verify } satisfies PrimaryRuntimeRecoveryResult;
|
|
1961
2153
|
}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
2154
|
+
const contractFailure = restarted.detail.startsWith('primary Runtime launchd contract rebuild failed');
|
|
2155
|
+
audit(config, contractFailure ? 'primary_runtime_recovery_launchd_contract_failed' : 'primary_runtime_recovery_unverified', {
|
|
2156
|
+
serviceTarget: service.target,
|
|
2157
|
+
rollbackOperationId: rollback.operationId,
|
|
2158
|
+
detail: restarted.detail,
|
|
2159
|
+
reasonCodes: restarted.verify.runtime.reasonCodes,
|
|
2160
|
+
});
|
|
2161
|
+
return { ok: false, attempted: true, detail: restarted.detail, serviceTarget: service.target, rollback, verify: restarted.verify } satisfies PrimaryRuntimeRecoveryResult;
|
|
1966
2162
|
});
|
|
1967
2163
|
if (!locked.acquired) return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(locked.owner), serviceTarget: service.target, verify: initial };
|
|
1968
2164
|
return locked.value;
|
|
@@ -2160,44 +2356,22 @@ export async function activateRuntimeRelease(
|
|
|
2160
2356
|
return { ok: false, attempted: false, noOp: true, detail, operationId } satisfies RuntimeReleaseActivationResult;
|
|
2161
2357
|
}
|
|
2162
2358
|
const before = await verifyLocal(config);
|
|
2163
|
-
const stopped = await
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
const runtimeStopped = await waitForPrimaryRuntimeState({ config, expectedRunning: false, timeoutMs: 20_000, now, wait, runtimeRunning });
|
|
2171
|
-
if (!runtimeStopped) {
|
|
2172
|
-
const detail = 'primary Runtime owner remained live after bounded launchd bootout';
|
|
2173
|
-
audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId });
|
|
2174
|
-
return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
|
|
2175
|
-
}
|
|
2176
|
-
const serviceUnloaded = await waitForLaunchdServiceUnloaded({ service, timeoutMs: 20_000, now, wait, runCommand });
|
|
2177
|
-
if (!serviceUnloaded) {
|
|
2178
|
-
const detail = 'primary Runtime launchd service remained loaded after bounded bootout';
|
|
2179
|
-
audit(config, 'runtime_release_activation_stop_unverified', { serviceTarget: service.target, operationId, detail });
|
|
2180
|
-
return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
|
|
2181
|
-
}
|
|
2182
|
-
let portReleased = await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand });
|
|
2183
|
-
let staleListenerCleanup: PrimaryRuntimePortCleanupResult | undefined;
|
|
2184
|
-
if (!portReleased) {
|
|
2185
|
-
staleListenerCleanup = await cleanupVerifiedStalePrimaryRuntimeListener({ config, uid: service.uid, now, wait, runCommand });
|
|
2186
|
-
portReleased = staleListenerCleanup.released;
|
|
2187
|
-
if (staleListenerCleanup.cleaned) {
|
|
2188
|
-
audit(config, 'runtime_release_activation_stale_listener_cleaned', {
|
|
2189
|
-
serviceTarget: service.target,
|
|
2190
|
-
operationId,
|
|
2191
|
-
pid: staleListenerCleanup.pid,
|
|
2192
|
-
signal: staleListenerCleanup.signal,
|
|
2193
|
-
detail: staleListenerCleanup.detail,
|
|
2194
|
-
});
|
|
2195
|
-
}
|
|
2359
|
+
const stopped = await stopPrimaryRuntimeForReleaseTransition({ config, service, now, wait, runCommand, runtimeRunning });
|
|
2360
|
+
if (!stopped.ok) {
|
|
2361
|
+
const action = stopped.detail.startsWith('primary Runtime bootout failed')
|
|
2362
|
+
? 'runtime_release_activation_stop_failed'
|
|
2363
|
+
: 'runtime_release_activation_stop_unverified';
|
|
2364
|
+
audit(config, action, { serviceTarget: service.target, operationId, detail: stopped.detail, pid: stopped.staleListenerCleanup?.pid });
|
|
2365
|
+
return { ok: false, attempted: true, detail: stopped.detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
|
|
2196
2366
|
}
|
|
2197
|
-
if (
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2367
|
+
if (stopped.staleListenerCleanup?.cleaned) {
|
|
2368
|
+
audit(config, 'runtime_release_activation_stale_listener_cleaned', {
|
|
2369
|
+
serviceTarget: service.target,
|
|
2370
|
+
operationId,
|
|
2371
|
+
pid: stopped.staleListenerCleanup.pid,
|
|
2372
|
+
signal: stopped.staleListenerCleanup.signal,
|
|
2373
|
+
detail: stopped.staleListenerCleanup.detail,
|
|
2374
|
+
});
|
|
2201
2375
|
}
|
|
2202
2376
|
let committed: RuntimeReleaseAuthority;
|
|
2203
2377
|
try {
|
|
@@ -2212,73 +2386,59 @@ export async function activateRuntimeRelease(
|
|
|
2212
2386
|
audit(config, 'runtime_release_activation_commit_mismatch', { serviceTarget: service.target, operationId });
|
|
2213
2387
|
return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
|
|
2214
2388
|
}
|
|
2215
|
-
try {
|
|
2216
|
-
ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome, installUserLaunchAgent: true });
|
|
2217
|
-
} catch (error) {
|
|
2218
|
-
const detail = `runtime launchd stable-release contract update failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2219
|
-
audit(config, 'runtime_release_activation_launchd_contract_failed', { serviceTarget: service.target, operationId, detail });
|
|
2220
|
-
return { ok: false, attempted: true, detail, serviceTarget: service.target, verify: await verifyLocal(config) } satisfies RuntimeReleaseActivationResult;
|
|
2221
|
-
}
|
|
2222
2389
|
let storageMigration: ControllerHomeStorageMigration | undefined;
|
|
2223
|
-
|
|
2390
|
+
const activated = await rebindStartAndVerifyPrimaryRuntime({
|
|
2391
|
+
config,
|
|
2392
|
+
service,
|
|
2393
|
+
runCommand,
|
|
2394
|
+
now,
|
|
2395
|
+
wait,
|
|
2396
|
+
verifyLocal,
|
|
2397
|
+
timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 60_000,
|
|
2398
|
+
successDetail: 'requested Runtime release started and passed whole-Runtime verification',
|
|
2399
|
+
beforeStart: () => {
|
|
2400
|
+
storageMigration = migrateStoppedRepoLocalControllerHomeStorage(config.controllerHome, platform);
|
|
2401
|
+
if (storageMigration.migrated) {
|
|
2402
|
+
audit(config, 'runtime_controller_home_noindex_migrated', {
|
|
2403
|
+
serviceTarget: service.target,
|
|
2404
|
+
operationId,
|
|
2405
|
+
logicalHome: storageMigration.logicalHome,
|
|
2406
|
+
physicalHome: storageMigration.physicalHome,
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
},
|
|
2410
|
+
});
|
|
2411
|
+
let after = activated.verify;
|
|
2224
2412
|
let activationFailureDetail: string | undefined;
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
audit(config, 'runtime_controller_home_noindex_migrated', {
|
|
2229
|
-
serviceTarget: service.target,
|
|
2230
|
-
operationId,
|
|
2231
|
-
logicalHome: storageMigration.logicalHome,
|
|
2232
|
-
physicalHome: storageMigration.physicalHome,
|
|
2233
|
-
});
|
|
2234
|
-
}
|
|
2235
|
-
} catch (error) {
|
|
2236
|
-
activationFailureDetail = `Controller Home .noindex migration failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
2237
|
-
audit(config, 'runtime_controller_home_noindex_migration_failed', { serviceTarget: service.target, operationId, detail: activationFailureDetail });
|
|
2413
|
+
if (activated.ok && after.releases.active?.revision === candidate.manifest.releaseId) {
|
|
2414
|
+
audit(config, 'runtime_release_activation_succeeded', { serviceTarget: service.target, operationId, requestId: lockRequestId, activeRevision: after.releases.active?.revision, expectedAuthorityRevision: guard.expectedAuthorityRevision, expectedActiveReleaseId: guard.expectedActiveReleaseId, controllerHomeStorageMigrated: storageMigration?.migrated === true });
|
|
2415
|
+
return { ok: true, attempted: true, detail: storageMigration?.migrated ? 'requested Runtime release activated, Controller Home migrated to .noindex storage, and whole-Runtime verification passed' : 'requested Runtime release activated and passed whole-Runtime verification', serviceTarget: service.target, operationId, verify: after } satisfies RuntimeReleaseActivationResult;
|
|
2238
2416
|
}
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2417
|
+
if (!activated.ok) {
|
|
2418
|
+
activationFailureDetail = activated.detail;
|
|
2419
|
+
const auditAction = activated.detail.startsWith('primary Runtime launchd contract rebuild failed')
|
|
2420
|
+
? 'runtime_release_activation_launchd_contract_failed'
|
|
2421
|
+
: activated.detail.startsWith('release transition pre-start preparation failed')
|
|
2422
|
+
? 'runtime_controller_home_noindex_migration_failed'
|
|
2423
|
+
: activated.detail.includes('start')
|
|
2424
|
+
? 'runtime_release_activation_start_failed'
|
|
2425
|
+
: 'runtime_release_activation_unverified';
|
|
2426
|
+
audit(config, auditAction, { serviceTarget: service.target, operationId, detail: activated.detail });
|
|
2242
2427
|
} else {
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
activationFailureDetail = started.detail;
|
|
2246
|
-
audit(config, 'runtime_release_activation_start_failed', { serviceTarget: service.target, operationId, detail: started.detail });
|
|
2247
|
-
after = await verifyLocal(config);
|
|
2248
|
-
} else {
|
|
2249
|
-
after = await verifyPrimaryRuntimeAfterStart({
|
|
2250
|
-
config,
|
|
2251
|
-
timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 60_000,
|
|
2252
|
-
now,
|
|
2253
|
-
wait,
|
|
2254
|
-
verifyLocal,
|
|
2255
|
-
});
|
|
2256
|
-
if (after.ok && after.releases.active?.revision === candidate.manifest.releaseId) {
|
|
2257
|
-
audit(config, 'runtime_release_activation_succeeded', { serviceTarget: service.target, operationId, requestId: lockRequestId, activeRevision: after.releases.active?.revision, expectedAuthorityRevision: guard.expectedAuthorityRevision, expectedActiveReleaseId: guard.expectedActiveReleaseId, controllerHomeStorageMigrated: storageMigration?.migrated === true });
|
|
2258
|
-
return { ok: true, attempted: true, detail: storageMigration?.migrated ? 'requested Runtime release activated, Controller Home migrated to .noindex storage, and whole-Runtime verification passed' : 'requested Runtime release activated and passed whole-Runtime verification', serviceTarget: service.target, operationId, verify: after } satisfies RuntimeReleaseActivationResult;
|
|
2259
|
-
}
|
|
2260
|
-
}
|
|
2428
|
+
activationFailureDetail = `activated Runtime release identity mismatch: expected ${candidate.manifest.releaseId}, observed ${after.releases.active?.revision ?? 'none'}`;
|
|
2429
|
+
audit(config, 'runtime_release_activation_commit_mismatch', { serviceTarget: service.target, operationId, detail: activationFailureDetail });
|
|
2261
2430
|
}
|
|
2262
2431
|
|
|
2263
2432
|
// Activation failed: stop, restore the previous whole release and its SQLite
|
|
2264
2433
|
// backup, restart the one service, and require verification again.
|
|
2265
|
-
await runCommand('launchctl', ['bootout', service.target], 15_000);
|
|
2266
2434
|
let rollback: RollbackResult;
|
|
2267
|
-
const
|
|
2268
|
-
|
|
2269
|
-
? await waitForLaunchdServiceUnloaded({ service, timeoutMs: 20_000, now, wait, runCommand })
|
|
2270
|
-
: false;
|
|
2271
|
-
const rollbackPortReleased = rollbackRuntimeStopped && rollbackServiceUnloaded
|
|
2272
|
-
? await waitForPrimaryRuntimePortReleased({ config, timeoutMs: 20_000, now, wait, runCommand })
|
|
2273
|
-
: false;
|
|
2274
|
-
if (!rollbackRuntimeStopped || !rollbackServiceUnloaded || !rollbackPortReleased) {
|
|
2435
|
+
const rollbackStop = await stopPrimaryRuntimeForReleaseTransition({ config, service, now, wait, runCommand, runtimeRunning });
|
|
2436
|
+
if (!rollbackStop.ok) {
|
|
2275
2437
|
rollback = {
|
|
2276
2438
|
ok: false,
|
|
2277
|
-
detail:
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
? 'candidate Runtime launchd service remained loaded after bounded rollback bootout'
|
|
2281
|
-
: 'candidate Runtime TCP port remained occupied after bounded rollback bootout',
|
|
2439
|
+
detail: rollbackStop.detail
|
|
2440
|
+
.replace('primary Runtime', 'candidate Runtime')
|
|
2441
|
+
.replace('bounded launchd bootout', 'bounded rollback bootout'),
|
|
2282
2442
|
};
|
|
2283
2443
|
} else {
|
|
2284
2444
|
try {
|
|
@@ -2294,7 +2454,6 @@ export async function activateRuntimeRelease(
|
|
|
2294
2454
|
physicalHome: storageMigration.physicalHome,
|
|
2295
2455
|
});
|
|
2296
2456
|
}
|
|
2297
|
-
ensureForgeRuntimeLaunchAgentContract({ controllerHome: config.controllerHome, installUserLaunchAgent: true });
|
|
2298
2457
|
const previousRevision = previousActive?.releaseId ?? '';
|
|
2299
2458
|
const previousIdentity = previousActive?.artifactIdentity;
|
|
2300
2459
|
if (
|
|
@@ -2303,24 +2462,18 @@ export async function activateRuntimeRelease(
|
|
|
2303
2462
|
) {
|
|
2304
2463
|
throw new Error('RECOVERY_RUNTIME_RELEASE_ROLLBACK_AUTHORITY_MISMATCH');
|
|
2305
2464
|
}
|
|
2306
|
-
const restarted = await
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
rollback = { ok: true, operationId: rollbackOperationId, detail: 'previous whole-Runtime release and SQLite backup restored, restarted, and verified', verify: rolledBack };
|
|
2319
|
-
} else {
|
|
2320
|
-
await runCommand('launchctl', ['bootout', service.target], 15_000);
|
|
2321
|
-
rollback = { ok: false, operationId: rollbackOperationId, detail: 'previous whole-Runtime release started but failed verification; service was stopped to prevent a restart loop', verify: rolledBack };
|
|
2322
|
-
}
|
|
2323
|
-
}
|
|
2465
|
+
const restarted = await rebindStartAndVerifyPrimaryRuntime({
|
|
2466
|
+
config,
|
|
2467
|
+
service,
|
|
2468
|
+
runCommand,
|
|
2469
|
+
now,
|
|
2470
|
+
wait,
|
|
2471
|
+
verifyLocal,
|
|
2472
|
+
contractFailureContext: 'after rollback',
|
|
2473
|
+
timeoutMs: configuredPrimaryRuntimeService(config).postRestartVerifyTimeoutMs ?? 60_000,
|
|
2474
|
+
successDetail: 'previous whole-Runtime release and SQLite backup restored, restarted, and verified',
|
|
2475
|
+
});
|
|
2476
|
+
rollback = { ok: restarted.ok, operationId: rollbackOperationId, detail: restarted.detail, verify: restarted.verify };
|
|
2324
2477
|
} catch (error) {
|
|
2325
2478
|
rollback = { ok: false, detail: error instanceof Error ? error.message : 'previous whole-Runtime release rollback failed' };
|
|
2326
2479
|
}
|
|
@@ -2369,15 +2522,26 @@ export async function stageAndActivateConfiguredRuntimeRelease(
|
|
|
2369
2522
|
// Capture the base before staging. Staging can take long enough for another
|
|
2370
2523
|
// activation to win; the later publish must prove this base is still current.
|
|
2371
2524
|
const expectedAuthority = releaseAuthority(config);
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2525
|
+
const stagedAttempt = await withLock(config, {
|
|
2526
|
+
action: 'stage_and_activate_runtime_release',
|
|
2527
|
+
...(requestId?.trim() ? { requestId: requestId.trim() } : {}),
|
|
2528
|
+
}, async () => {
|
|
2529
|
+
try {
|
|
2530
|
+
const staged = (dependencies.stage ?? stageRuntimeReleaseFromCandidateSource)({ controllerHome: config.controllerHome, sourceRoot });
|
|
2531
|
+
assertRuntimeReleaseFiles(staged);
|
|
2532
|
+
return { ok: true as const, staged };
|
|
2533
|
+
} catch (error) {
|
|
2534
|
+
return { ok: false as const, detail: error instanceof Error ? error.message : 'Runtime release staging failed' };
|
|
2535
|
+
}
|
|
2536
|
+
});
|
|
2537
|
+
if (!stagedAttempt.acquired) {
|
|
2538
|
+
return { ok: false, attempted: false, noOp: true, detail: recoveryBusyDetail(stagedAttempt.owner) };
|
|
2380
2539
|
}
|
|
2540
|
+
if (!stagedAttempt.value.ok) {
|
|
2541
|
+
audit(config, 'runtime_release_stage_failed', { sourceRoot, detail: stagedAttempt.value.detail });
|
|
2542
|
+
return { ok: false, attempted: false, noOp: true, detail: stagedAttempt.value.detail };
|
|
2543
|
+
}
|
|
2544
|
+
const staged: StagedRuntimeRelease = stagedAttempt.value.staged;
|
|
2381
2545
|
const activationGuard: RuntimeReleaseActivationGuard = {
|
|
2382
2546
|
...(requestId?.trim() ? { requestId: requestId.trim() } : {}),
|
|
2383
2547
|
expectedAuthorityRevision: expectedAuthority?.revision ?? null,
|
|
@@ -2691,6 +2855,35 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2691
2855
|
fullVerificationPerformed = true;
|
|
2692
2856
|
}
|
|
2693
2857
|
const lastFullVerifyAt = fullVerificationPerformed ? now : scopedPrior.lastFullVerifyAt;
|
|
2858
|
+
const activeMutation = liveRecoveryMutationLock(config);
|
|
2859
|
+
if (activeMutation) {
|
|
2860
|
+
const attributable = recoveryLockOwnerAttributable(activeMutation);
|
|
2861
|
+
if (!attributable) {
|
|
2862
|
+
audit(config, 'recovery_operation_lock_identity_uncertain', {
|
|
2863
|
+
pid: activeMutation.pid,
|
|
2864
|
+
instanceId: activeMutation.instanceId,
|
|
2865
|
+
action: activeMutation.action ?? null,
|
|
2866
|
+
requestId: activeMutation.requestId ?? null,
|
|
2867
|
+
acquiredAt: activeMutation.acquiredAt,
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
const reason = attributable
|
|
2871
|
+
? `Recovery mutation in progress: action=${activeMutation.action} request=${activeMutation.requestId}; watchdog repair escalation suppressed`
|
|
2872
|
+
: 'Recovery mutation lock identity is uncertain; watchdog repair escalation suppressed';
|
|
2873
|
+
const state: WatchdogState = {
|
|
2874
|
+
...scopedPrior,
|
|
2875
|
+
failures: 0,
|
|
2876
|
+
firstFailureAt: undefined,
|
|
2877
|
+
publicTunnelFailures: 0,
|
|
2878
|
+
publicTunnelFirstFailureAt: undefined,
|
|
2879
|
+
primaryConnectorFailures: 0,
|
|
2880
|
+
primaryConnectorFirstFailureAt: undefined,
|
|
2881
|
+
lastFullVerifyAt,
|
|
2882
|
+
lastDecision: 'degraded',
|
|
2883
|
+
lastReason: reason,
|
|
2884
|
+
};
|
|
2885
|
+
return { state, decision: { action: 'degraded', reason }, verify: verified };
|
|
2886
|
+
}
|
|
2694
2887
|
if (runtimeStartupGrace && !localVerify.ok) {
|
|
2695
2888
|
const state: WatchdogState = {
|
|
2696
2889
|
...scopedPrior,
|
|
@@ -2709,18 +2902,34 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2709
2902
|
}
|
|
2710
2903
|
const recoveryHealthy = verified.probes.recovery_gateway?.ok !== false
|
|
2711
2904
|
&& verified.probes.recovery_external_http?.ok !== false;
|
|
2712
|
-
const primaryRuntimeHealthy = localVerify.ok
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2905
|
+
const primaryRuntimeHealthy = localVerify.runtime.ok
|
|
2906
|
+
&& localVerify.runtime.running
|
|
2907
|
+
&& localVerify.runtime.ready
|
|
2908
|
+
&& !localVerify.runtime.stale
|
|
2909
|
+
&& localVerify.probes.active_gateway?.ok === true;
|
|
2910
|
+
const primaryConnectorConfigured = Boolean(config.primaryConnectorService);
|
|
2911
|
+
const primaryConnectorLocalFailed = verified.probes.primary_connector_local?.ok === false;
|
|
2912
|
+
const primaryConnectorCapacityFailed = connectorCapacityRecoveryRecommended(verified);
|
|
2913
|
+
const primaryPublicTransportManaged = Boolean(configuredPrimaryPublicTunnel(config));
|
|
2914
|
+
const primaryPublicTransportFailed = Boolean(
|
|
2915
|
+
config.publicMcpUrl
|
|
2716
2916
|
&& primaryRuntimeHealthy
|
|
2917
|
+
&& !primaryConnectorCapacityFailed
|
|
2717
2918
|
&& (
|
|
2718
2919
|
verified.probes.external_mcp_http?.ok === false
|
|
2719
2920
|
|| verified.probes.primary_connector_ready?.ok === false
|
|
2720
|
-
|| connectorCapacityRecoveryRecommended(verified)
|
|
2721
2921
|
),
|
|
2722
2922
|
);
|
|
2723
|
-
|
|
2923
|
+
const primaryConnectorFailed = Boolean(
|
|
2924
|
+
primaryConnectorConfigured
|
|
2925
|
+
&& primaryRuntimeHealthy
|
|
2926
|
+
&& (
|
|
2927
|
+
primaryConnectorLocalFailed
|
|
2928
|
+
|| primaryConnectorCapacityFailed
|
|
2929
|
+
|| (primaryPublicTransportManaged && primaryPublicTransportFailed)
|
|
2930
|
+
),
|
|
2931
|
+
);
|
|
2932
|
+
if (primaryRuntimeHealthy && !primaryConnectorFailed && !primaryPublicTransportFailed && recoveryHealthy) {
|
|
2724
2933
|
if (fullVerificationPerformed && verified.ok && !matchingKnownGood(config, verified.releases.active)) {
|
|
2725
2934
|
try {
|
|
2726
2935
|
const attestation = await withLock(config, { action: 'attest_known_good' }, async () => persistVerifiedKnownGood(config, verified));
|
|
@@ -2759,6 +2968,26 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2759
2968
|
};
|
|
2760
2969
|
return { state, decision: { action: 'healthy', reason: 'primary runtime and standalone Recovery verification passed' }, verify: verified };
|
|
2761
2970
|
}
|
|
2971
|
+
if (primaryRuntimeHealthy && !primaryConnectorFailed && primaryPublicTransportFailed && recoveryHealthy) {
|
|
2972
|
+
const reason = primaryPublicTransportManaged
|
|
2973
|
+
? 'primary public transport is unavailable but no safely repairable primary Connector path was established'
|
|
2974
|
+
: 'primary public transport is unavailable while the local Connector is healthy, but no managed primary public tunnel is configured; external transport repair is required';
|
|
2975
|
+
const state: WatchdogState = {
|
|
2976
|
+
...recordWatchdogRuntimeHealthy(
|
|
2977
|
+
scopeWatchdogStateToRuntimeRelease(scopedPrior, verified.releases.active),
|
|
2978
|
+
now,
|
|
2979
|
+
watchdogRuntimeRestartBudgetStableMs(config),
|
|
2980
|
+
),
|
|
2981
|
+
failures: scopedPrior.failures + 1,
|
|
2982
|
+
firstFailureAt: scopedPrior.firstFailureAt ?? now,
|
|
2983
|
+
primaryConnectorFailures: 0,
|
|
2984
|
+
primaryConnectorFirstFailureAt: undefined,
|
|
2985
|
+
lastFullVerifyAt,
|
|
2986
|
+
lastDecision: 'degraded',
|
|
2987
|
+
lastReason: reason,
|
|
2988
|
+
};
|
|
2989
|
+
return { state, decision: { action: 'degraded', reason }, verify: verified };
|
|
2990
|
+
}
|
|
2762
2991
|
const publicTunnelFailed = isExternalTunnelFailure(config, verified, localVerify);
|
|
2763
2992
|
const evidenceClasses = Object.entries(localVerify.probes)
|
|
2764
2993
|
.filter(([name, value]) => !name.startsWith('recovery_') && !value.ok)
|
|
@@ -2805,7 +3034,7 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2805
3034
|
primaryConnectorFailures: 0,
|
|
2806
3035
|
primaryConnectorFirstFailureAt: undefined,
|
|
2807
3036
|
};
|
|
2808
|
-
if (!
|
|
3037
|
+
if (!primaryRuntimeHealthy) state.runtimeHealthySince = undefined;
|
|
2809
3038
|
const primaryConfig = configuredPrimaryRuntimeService(config);
|
|
2810
3039
|
const decision = decideWatchdog({
|
|
2811
3040
|
...state,
|
|
@@ -2813,7 +3042,7 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2813
3042
|
evidenceClasses,
|
|
2814
3043
|
activeKnownGood,
|
|
2815
3044
|
previousKnownGood,
|
|
2816
|
-
primaryRuntimeFailed: !
|
|
3045
|
+
primaryRuntimeFailed: !primaryRuntimeHealthy,
|
|
2817
3046
|
runtimeMaximumRestartAttempts: primaryConfig.maximumRestartAttempts,
|
|
2818
3047
|
runtimeRestartCooldownMs: primaryConfig.restartCooldownMs,
|
|
2819
3048
|
runtimeMinimumFailures: primaryConfig.minimumFailures,
|
|
@@ -2850,7 +3079,9 @@ export async function watchdogTick(config: RecoveryConfig, prior: WatchdogState)
|
|
|
2850
3079
|
return { state: { ...state, recoveryGatewayRestartUsed: true }, decision, verify: verified, recoveryGatewayRestart };
|
|
2851
3080
|
}
|
|
2852
3081
|
if (decision.action === 'restart_primary_connector') {
|
|
2853
|
-
const primaryConnectorRestart = await restartPrimaryConnector(config
|
|
3082
|
+
const primaryConnectorRestart = await restartPrimaryConnector(config, {
|
|
3083
|
+
requestId: `watchdog:restart_primary_connector:${now}`,
|
|
3084
|
+
});
|
|
2854
3085
|
const attempts = (state.primaryConnectorRestartAttempts ?? 0) + (primaryConnectorRestart.attempted ? 1 : 0);
|
|
2855
3086
|
const nextState: WatchdogState = primaryConnectorRestart.ok
|
|
2856
3087
|
? {
|