diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index add64bc..f74bea8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: outputs: base: ${{ steps.validation_base.outputs.base }} docs_only: ${{ steps.validation_scope.outputs.docs_only }} + changelog: ${{ steps.validation_scope.outputs.changelog }} localization: ${{ steps.validation_scope.outputs.localization }} scripts: ${{ steps.validation_scope.outputs.scripts }} producer: ${{ steps.validation_scope.outputs.producer }} @@ -124,6 +125,7 @@ jobs: ./script/development_flow.py ci-context \ --event push \ --base main \ + --release-preparation "${{ steps.validation_scope.outputs.changelog }}" \ --format github >>"$GITHUB_OUTPUT" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index dc127d8..e417a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Keep each user-visible change in its own bullet. Write the English text first, t ## [Unreleased] -## [0.4.3] - 2026-08-12 +## [0.4.3] - 2026-08-13 ### Changed / 变更 @@ -26,6 +26,14 @@ Keep each user-visible change in its own bullet. Write the English text first, t - Each App launch now runs a full Agent connection check and shows actionable global notices for plugin, connector, authorization, restart, and channel issues. App 每次启动都会完整检查 Agent 连接,并针对插件、连接器、授权、重启与通道问题显示可操作的全局提示。 + +### Fixed / 修复 + + +- Cold launch now presents the registered Control Center window through the same single-instance activation path used by reopen requests, instead of leaving the window hidden behind the desktop pet. + + 冷启动现在通过与重新打开请求相同的单实例激活路径呈现已注册的控制中心窗口,不再只显示桌宠而将控制中心隐藏。 + ## [0.4.2] - 2026-08-12 ### Fixed / 修复 diff --git a/apps/macos/Sources/AgentPetCompanion/App/AgentPetCompanionApp.swift b/apps/macos/Sources/AgentPetCompanion/App/AgentPetCompanionApp.swift index d2c2862..ad64ee9 100644 --- a/apps/macos/Sources/AgentPetCompanion/App/AgentPetCompanionApp.swift +++ b/apps/macos/Sources/AgentPetCompanion/App/AgentPetCompanionApp.swift @@ -62,6 +62,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { AppDiagnostics.shared.log(.notice, category: "lifecycle", event: "app_did_finish_launching") NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) + // Defer one turn so SwiftUI installs the primary activation handler + // and AppKit completes its launch-time scene ordering before the + // shared presenter fronts the registered Control Center window. + DispatchQueue.main.async { + AppSingleInstanceCoordinator.shared.activatePrimaryInstance() + } } func applicationDidBecomeActive(_ notification: Notification) { diff --git a/apps/macos/Sources/AgentPetCompanion/App/AppStore.swift b/apps/macos/Sources/AgentPetCompanion/App/AppStore.swift index 11bb541..fe92f0c 100644 --- a/apps/macos/Sources/AgentPetCompanion/App/AppStore.swift +++ b/apps/macos/Sources/AgentPetCompanion/App/AppStore.swift @@ -820,6 +820,7 @@ final class AppStore: ObservableObject { private(set) var controlCenterIsOpen = false private var pendingMainWindowPresentation = false private var pendingMainWindowPresentationChecksRuntimeHandoff = true + private var pendingControlCenterFronting = false private var generationMessagesTask: Task? private var latestGenerationRestoreAttemptState = LatestGenerationRestoreAttemptState.notAttempted private var latestGenerationRestoreAttemptSequence: UInt64 = 0 @@ -1501,7 +1502,10 @@ final class AppStore: ObservableObject { let checksRuntimeHandoff = pendingMainWindowPresentationChecksRuntimeHandoff pendingMainWindowPresentation = false pendingMainWindowPresentationChecksRuntimeHandoff = true - guard !checksRuntimeHandoff || !runtimeHandoffIfNeeded() else { return } + guard !checksRuntimeHandoff || !runtimeHandoffIfNeeded() else { + pendingControlCenterFronting = false + return + } presenter() NSApp?.activate(ignoringOtherApps: true) } @@ -1526,6 +1530,11 @@ final class AppStore: ObservableObject { self.overlayController.controlCenterDidClose() } } + if pendingControlCenterFronting { + pendingControlCenterFronting = false + window.makeKeyAndOrderFront(nil) + NSApp?.activate(ignoringOtherApps: true) + } } func presentManualAppInstallation(_ request: AppManualInstallationRequest) { @@ -1614,7 +1623,9 @@ final class AppStore: ObservableObject { private func presentMainWindow(checkRuntimeHandoff: Bool) { guard !checkRuntimeHandoff || !runtimeHandoffIfNeeded() else { return } + pendingControlCenterFronting = true if frontExistingMainWindow() { + pendingControlCenterFronting = false pendingMainWindowPresentation = false pendingMainWindowPresentationChecksRuntimeHandoff = true NSApp?.activate(ignoringOtherApps: true) diff --git a/apps/macos/Tests/AgentPetCompanionTests/AppLifecycleContractTests.swift b/apps/macos/Tests/AgentPetCompanionTests/AppLifecycleContractTests.swift index eba49a7..fb27dfa 100644 --- a/apps/macos/Tests/AgentPetCompanionTests/AppLifecycleContractTests.swift +++ b/apps/macos/Tests/AgentPetCompanionTests/AppLifecycleContractTests.swift @@ -138,6 +138,34 @@ struct AppLifecycleContractTests { #expect(presentations == 1) } + @MainActor + @Test + func coldLaunchPresentationFrontsTheWindowWhenRegistrationWinsTheRace() { + let store = AppStore( + bootstrapHooks: AppStoreBootstrapHooks( + ensureRunning: { .alreadyHealthy }, + recover: { .alreadyHealthy }, + refreshSnapshot: { _ in }, + onReady: { _ in } + ), + runtimeHandoffIfNeeded: { false } + ) + let window = ControlCenterFrontRecordingWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + + store.presentMainWindow() + #expect(window.frontRequestCount == 0) + + store.registerControlCenterWindow(window) + + #expect(window.frontRequestCount == 1) + #expect(store.controlCenterIsOpen) + } + @MainActor @Test func aboutWindowFirstDoesNotInterceptAControlCenterReopen() { @@ -278,6 +306,10 @@ struct AppLifecycleContractTests { #"applicationShouldHandleReopen[\s\S]*?activatePrimaryInstance\(\)[\s\S]*?return false"#, in: appSource )) + #expect(LifecycleSource.matches( + #"applicationDidFinishLaunching[\s\S]*?DispatchQueue\.main\.async\s*\{\s*AppSingleInstanceCoordinator\.shared\.activatePrimaryInstance\(\)"#, + in: appSource + )) #expect(LifecycleSource.matches( #"MenuBarExtra\s*\{[\s\S]*?Button\s*\(\s*APCLocalization\.text\(\.appActionOpenControlCenter\)\s*\)\s*\{\s*store\.presentMainWindow\(\)"#, in: appSource @@ -599,6 +631,15 @@ struct AppLifecycleContractTests { } } +@MainActor +private final class ControlCenterFrontRecordingWindow: NSWindow { + private(set) var frontRequestCount = 0 + + override func makeKeyAndOrderFront(_ sender: Any?) { + frontRequestCount += 1 + } +} + private actor BundledPetSeedRetryProbe { struct Snapshot: Sendable { let seedAttempts: Int diff --git a/docs/architecture/runtime-and-ipc.md b/docs/architecture/runtime-and-ipc.md index 5f946e4..403b249 100644 --- a/docs/architecture/runtime-and-ipc.md +++ b/docs/architecture/runtime-and-ipc.md @@ -15,6 +15,8 @@ The App and PetCore each hold a private instance lock. A second App instance req Closing the control-center window leaves the menu bar and enabled overlay active. Reopen targets the registered control-center scene; About cannot intercept it. Standard Quit closes the App and overlay. A LaunchAgent-hosted PetCore remains available, while a direct-child fallback is tied to the App process. +A primary cold launch queues one activation through that same registered control-center presenter on the main run-loop turn after AppKit finishes launching. If SwiftUI has not installed the presenter or registered its NSWindow yet, the request remains pending until both steps complete; AppKit activation then fronts that exact window. Cold launch, Dock reopen, and secondary-instance activation therefore converge on the same single window. + ## Startup and managed runtime ```mermaid diff --git a/docs/development/parallel-development.md b/docs/development/parallel-development.md index 99ce2d3..d9ba323 100644 --- a/docs/development/parallel-development.md +++ b/docs/development/parallel-development.md @@ -107,7 +107,7 @@ PRs that change the CI/Release/merge/proof/ruleset control-plane inventory are i - Ready direct and train PRs to `main` run the complete source, integration, Rust, Swift, stress, bundle, and stable `Required CI` gate. They auto-merge only after every required result succeeds and the branch remains mergeable. / 所有 ready 的 direct/train → `main` PR 均运行完整门禁,仅在全部必选结果成功且仍可合并时自动合并。 - Task PRs to a train run path-scoped CI and auto-merge independently. Train status checks are intentionally non-strict so unrelated completed task PRs do not continuously invalidate each other. / task → train PR 按路径运行 CI 并独立自动合并;train 检查刻意使用 non-strict,避免无关任务互相持续失效。 -- A successful direct or final train PR records a bounded candidate proof for its tested merge tree. After squash merge, the trusted merger explicitly dispatches CI for the exact new `main` SHA because GitHub suppresses ordinary follow-on workflow events created with `GITHUB_TOKEN`. That main validation reuses the proof only when the same-repository PR, successful `Required CI`, merge parents, artifact, and exact Git tree all match; it then issues the main-bound Release proof without repeating the complete suites. Any missing, expired, ambiguous, or mismatched evidence automatically falls back to the complete main CI. / direct PR 或最终 train PR 成功后会为其已测试合并树记录有界候选证明。squash 合并后,受信任合并器会为新的精确 `main` SHA 显式派发 CI,因为 GitHub 会抑制由 `GITHUB_TOKEN` 产生的普通后续 workflow 事件。该 main 验证只有在同仓库 PR、成功的 `Required CI`、合并父提交、artifact 与精确 Git tree 全部匹配时才复用证明,并直接签发绑定 main 的 Release 证明而不重复全量测试;任何缺失、过期、歧义或不匹配都会自动回退完整 main CI。 +- A successful direct or final train PR records a bounded candidate proof for its tested merge tree. After squash merge, the trusted merger explicitly dispatches CI for the exact new `main` SHA because GitHub suppresses ordinary follow-on workflow events created with `GITHUB_TOKEN`. That main validation reuses the proof only when the same-repository PR, successful `Required CI`, merge parents, artifact, and exact Git tree all match; it then issues the main-bound Release proof without repeating the complete suites. Any missing, expired, ambiguous, or mismatched evidence automatically falls back to the complete main CI. A fallback main push no longer has the source branch name, so its release-preparation identity is restored only from the validation scope's exact typed `CHANGELOG.md` path flag; PR validation continues to require the managed `gd-ops/release/*` branch identity. / direct PR 或最终 train PR 成功后会为其已测试合并树记录有界候选证明。squash 合并后,受信任合并器会为新的精确 `main` SHA 显式派发 CI,因为 GitHub 会抑制由 `GITHUB_TOKEN` 产生的普通后续工作流事件。该 main 验证只有在同仓库 PR、成功的 `Required CI`、合并父提交、artifact 与精确 Git tree 全部匹配时才复用证明,并直接签发绑定 main 的 Release 证明而不重复全量测试;任何缺失、过期、歧义或不匹配都会自动回退完整 main CI。fallback main push 已没有源分支名,因此仅通过 validation scope 中精确、类型化的 `CHANGELOG.md` 路径标志恢复 release-preparation 身份;PR 验证仍要求受管的 `gd-ops/release/*` 分支身份。 - A task PR merged into a train has no post-merge train-push CI and emits no Release candidate proof. The final train → `main` PR is the single complete integrated candidate, so train development remains path-scoped without weakening the final proof. / task PR 合入 train 后不触发 train push CI,也不生成 Release 候选证明;最终 train → `main` PR 才是唯一完整的集成候选,因此 train 开发保持按路径验证,同时不削弱最终证明。 ## Changelog and final train integration / 变更日志与 train 收口 diff --git a/script/development_flow.py b/script/development_flow.py index ddb02b3..c833090 100755 --- a/script/development_flow.py +++ b/script/development_flow.py @@ -381,11 +381,18 @@ class CIContext: release_preparation: bool -def ci_context(event: str, base: str, head: str, draft: bool) -> CIContext: +def ci_context( + event: str, + base: str, + head: str, + draft: bool, + *, + release_preparation: bool = False, +) -> CIContext: if event == "push": if base != MAIN_BRANCH: fail("authoritative push CI is restricted to main") - return CIContext("main-push", True, True, False) + return CIContext("main-push", True, True, release_preparation) if event != "pull_request": fail("CI development lane supports only pull_request and push") if base == MAIN_BRANCH: @@ -1002,6 +1009,11 @@ def parse_args() -> argparse.Namespace: ci.add_argument("--base", required=True) ci.add_argument("--head", default="") ci.add_argument("--draft", choices=("true", "false"), default="false") + ci.add_argument( + "--release-preparation", + choices=("true", "false", "1", "0"), + default="false", + ) ci.add_argument("--format", choices=("json", "github"), default="json") status = subparsers.add_parser("status") @@ -1080,7 +1092,13 @@ def main() -> int: ) print(json.dumps(asdict(decision), sort_keys=True)) elif args.command == "ci-context": - context = ci_context(args.event, args.base, args.head, args.draft == "true") + context = ci_context( + args.event, + args.base, + args.head, + args.draft == "true", + release_preparation=args.release_preparation in ("true", "1"), + ) if args.format == "github": for key, value in asdict(context).items(): rendered = "1" if value is True else "0" if value is False else value diff --git a/script/tests/test_validation_tooling.py b/script/tests/test_validation_tooling.py index 7035c11..be947e0 100755 --- a/script/tests/test_validation_tooling.py +++ b/script/tests/test_validation_tooling.py @@ -435,9 +435,17 @@ class ValidationScopeTests(unittest.TestCase): def test_changelog_fragment_only_is_documentation_scope(self) -> None: scope = validation_scope.classify(["changes/unreleased/task-42.json"]) self.assertTrue(scope.docs_only) + self.assertFalse(scope.changelog) self.assertEqual(scope.rust_mode, "none") self.assertFalse(scope.bundle) + def test_root_changelog_has_an_exact_typed_scope_flag(self) -> None: + scope = validation_scope.classify( + ["CHANGELOG.md", "changes/unreleased/task-42.json"] + ) + self.assertTrue(scope.changelog) + self.assertTrue(scope.docs_only) + def test_overlay_change_uses_focused_swift_and_recommends_live_ui(self) -> None: scope = validation_scope.classify( ["apps/macos/Sources/AgentPetCompanion/Overlay/OverlayRootView.swift"] @@ -935,9 +943,22 @@ class DevelopmentFlowTests(unittest.TestCase): ) push = development_flow.ci_context("push", "main", "", False) self.assertEqual( - (push.lane, push.full_candidate, push.release_source), - ("main-push", True, True), + ( + push.lane, + push.full_candidate, + push.release_source, + push.release_preparation, + ), + ("main-push", True, True, False), ) + release_push = development_flow.ci_context( + "push", + "main", + "", + False, + release_preparation=True, + ) + self.assertTrue(release_push.release_preparation) class ChangelogFragmentTests(unittest.TestCase): diff --git a/script/validate_app_lifecycle_contract.sh b/script/validate_app_lifecycle_contract.sh index e319522..ae8354b 100755 --- a/script/validate_app_lifecycle_contract.sh +++ b/script/validate_app_lifecycle_contract.sh @@ -56,6 +56,15 @@ checks = { ) is not None ), + "primary cold launch is routed through the registered control center presenter": ( + re.search( + r'applicationDidFinishLaunching.*?DispatchQueue\.main\.async\s*\{\s*' + r'AppSingleInstanceCoordinator\.shared\.activatePrimaryInstance\(\)', + app, + re.S, + ) + is not None + ), "menu bar reopens through the shared presenter": ( re.search( r'MenuBarExtra\s*\{.*?Button\s*\(\s*' diff --git a/script/validate_build_scripts_safety.sh b/script/validate_build_scripts_safety.sh index 3d4ad5d..890d031 100755 --- a/script/validate_build_scripts_safety.sh +++ b/script/validate_build_scripts_safety.sh @@ -127,6 +127,8 @@ fi # compile-only, while CI owns complete tests, source proof, and App assembly. rg -Fq './script/validation_scope.py' "$CI_WORKFLOW" rg -Fq './script/development_flow.py ci-context' "$CI_WORKFLOW" +rg -Fq 'changelog: ${{ steps.validation_scope.outputs.changelog }}' "$CI_WORKFLOW" +rg -Fq -- '--release-preparation "${{ steps.validation_scope.outputs.changelog }}"' "$CI_WORKFLOW" rg -Fq './script/changelog_fragments.py policy' "$CI_WORKFLOW" rg -Fq -- '--release-preparation "${{ needs.scope.outputs.release_preparation }}"' "$CI_WORKFLOW" rg -Fq 'needs.scope.outputs.actionable' "$CI_WORKFLOW" diff --git a/script/validation_scope.py b/script/validation_scope.py index 413b371..5aaff61 100755 --- a/script/validation_scope.py +++ b/script/validation_scope.py @@ -75,6 +75,7 @@ def recommends_computer_use(path: str) -> bool: class ValidationScope: changed_count: int docs_only: bool + changelog: bool localization: bool scripts: bool producer: bool @@ -123,6 +124,7 @@ def classify(paths: list[str]) -> ValidationScope: swift_mode = "full" localization = any(is_localization(path) for path in normalized) + changelog = "CHANGELOG.md" in path_set scripts = any(path.startswith(("script/", ".github/")) for path in normalized) producer = any( path.startswith(("skills/agent-pet-maker/", "skills/agent-pet-studio/")) @@ -185,6 +187,7 @@ def classify(paths: list[str]) -> ValidationScope: return ValidationScope( changed_count=len(normalized), docs_only=docs_only, + changelog=changelog, localization=localization, scripts=scripts, producer=producer, @@ -231,6 +234,7 @@ def emit(scope: ValidationScope, output_format: str) -> None: names = { "changed_count": "APC_CHANGED_COUNT", "docs_only": "APC_CHANGED_DOCS_ONLY", + "changelog": "APC_CHANGED_CHANGELOG", "localization": "APC_CHANGED_LOCALIZATION", "scripts": "APC_CHANGED_SCRIPTS", "producer": "APC_CHANGED_PRODUCER",