Fix Agent Pet Companion 0.4.3 release blockers (#21)

## Summary

- restore release-preparation context on `main` pushes only when typed
validation scope reports an exact `CHANGELOG.md` change
- make primary cold launch converge on the shared control-center
presenter and retain fronting until the SwiftUI window is registered
- add control-plane, lifecycle, and race-regression coverage for both
release blockers

## Validation

- `python3 script/tests/test_validation_tooling.py` (53 passed)
- `python3 script/development_domains.py validate`
- `./script/validate_build_scripts_safety.sh --static-only`
- `./script/validate_app_lifecycle_contract.sh` (12/12 passed)
- `./script/validate_swift_tests.sh --scope all` (745 passed)
- `./script/validate_pre_push.sh`
- `./script/changelog_fragments.py policy --base-ref origin/main
--release-preparation true`

## Release note

This amends the still-unpublished v0.4.3 release preparation. The exact
merged `main` commit will receive the mandatory host UI acceptance
before release dispatch.

## Development claim / 开发声明

- Domain: `release-control-plane`
- Claim: `control-plane`
- Base commit: `7c57bbdb16b24074df16dd64ec51d3fc28782a7f`
- Release preparation: `yes`
- Focused validation:
  - `python3 script/tests/test_validation_tooling.py`
  - `./script/validate_build_scripts_safety.sh --static-only`

<!-- apc-engineering-coordination-v1
{"claim_overlap_rejections":0,"merge_tree_conflicts":0,"schema_version":"apc.engineering-coordination.v1","task_created_at":"2026-08-12T16:56:32Z","train_conflict_resolutions":0}
-->

---------

Co-authored-by: gd-ops <gd-ops@xmfunny.com>
This commit is contained in:
xjxandgd-ops authored and GitHub committed 2026-08-13 01:13:01 +08:00
1 parent 7c57bbdb16
commit 72c66a67fb
12 files changed
+132 -8

No files matched your search

+2
View File
@@ -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
+9 -1
View File
@@ -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 / 修复
<!-- apc-fragment:release-blockers-20260813 -->
- 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 / 修复
@@ -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) {
@@ -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<Void, Never>?
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)
@@ -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
+2
View File
@@ -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
+1 -1
View File
@@ -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 收口
+21 -3
View File
@@ -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
+23 -2
View File
@@ -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):
@@ -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*'
+2
View File
@@ -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"
+4
View File
@@ -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",