Skip to content

fix(macos): 运行配置加载不再等待 Spring 索引 - #305

Open
Farewell0375 wants to merge 13 commits into
1lck:previewfrom
Farewell0375:fix/300-run-config-load-ordering
Open

fix(macos): 运行配置加载不再等待 Spring 索引#305
Farewell0375 wants to merge 13 commits into
1lck:previewfrom
Farewell0375:fix/300-run-config-load-ordering

Conversation

@Farewell0375

@Farewell0375Farewell0375 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

修复 #300

问题

打开大型多模块 Maven 项目后,Run 面板长时间显示"未找到项目运行配置",即使 .lithe/run/generated.json 已存在且完全有效。这段时间内点击"识别并生成"并确认后没有任何反应——没有错误、没有进度、没有日志。

在一个 9765 个 Java 文件的项目上,这个窗口约 11 分钟。

原因

两处。

一、loadProjectServices 把 Spring 索引串在运行配置加载之前。

await springFeature.load(...) 排在 projectDevelopment.loadProject(...) 前面,而后者才负责把 configurationStatus 置为 .ready 并给 RunService.projectURL 赋值。Spring 索引的耗时随 Java 源文件数量增长(见 #299),运行配置、测试发现,以及 WorkspaceFeatureModel.rebuild 在回调之后的那次 Git 刷新,全都被压在它后面。

需要说明的是,即使 Spring 索引很快,这个串行耦合本身仍然是缺陷——它只是把不可用窗口从分钟级缩短到秒级,窗口依然存在。

二、RunService.generateRunConfigurations() 在项目未加载时静默返回。

guardlet projectURL else{return}

reset() 在打开项目时把 projectURL 置为 nil。窗口期内点击识别会命中这个 guard 直接返回,UI 无任何状态变化,用户无法区分"操作被丢弃"和"操作失败"。

改动

把 Spring 索引改为调度而非等待。SpringFeatureModel 新增 scheduleLoad,复用它已有的 reloadTask 取消机制和 generation 令牌(reset() 本来就会 cancel 它)。任务管理留在状态所在的层,AppModel 里不出现裸 Task。

用显式状态取代静默返回。RunConfigurationGenerationState 新增 .projectNotLoadedRunView 渲染为"项目仍在加载中"。没有复用 .failed,因为什么都没有失败——那会让标题显示成"项目识别失败"。这个状态不跨 Rust 边界,是纯 macOS 应用层的瞬时状态,不需要新增 shared fixture。

补齐入口一致性。toggleRuntoggleMaventoggleDebug 在激活执行模块后都会按需加载项目,但 runSelectedConfigurationAfterActivationstartDebuggingAfterActivation 不会,导致"打开项目后第一个动作就是点运行按钮"这条路径会冷激活出一个没人给它加载项目的 RunService。新增 RunService.isProjectLoaded,让这两个入口和工具窗口入口行为一致。

没有采用"让 activateExecutionModule() 自己负责加载项目"的方案:loadProjectServices 本身就会调它,会形成递归,需要额外的防重入标志,风险大于收益。

验证

swift build 通过
./scripts/test-macos.sh 663 个测试 / 77 个 suite 全部通过
./scripts/verify-service-boundaries.sh 通过
./scripts/verify-shared-contracts.sh 通过

新增 4 个测试:

测试锁住的行为
identificationBeforeProjectLoadReportsUnloadedProjectWithoutGenerating未绑定项目时识别请求不落到 store,状态为 .projectNotLoaded
identificationAfterProjectLoadGeneratesAndClearsTheUnloadedState绑定后识别行为与改动前一致
scheduleLoadDefersIndexingAndPublishesTheResult调度立即返回,索引结果稍后到达
scheduleLoadReplacesAPendingSchedule被取代的调度不会重复执行全量索引
runningBeforeTheSnapshotLoadsBindsTheProjectRun 入口在快照绑定项目前自行加载
debuggingBeforeTheSnapshotLoadsBindsTheProjectDebug 入口同上
openingTheRunToolWindowBindsTheProject工具窗口入口的既有行为不回退

后两个 scheduleLoad 测试用 objectWillChange 加本地超时的等待器,不依赖固定 sleep;调度未发布前的断言由 actor 模型保证,无需闸门。

入口测试用一个不可读的工作区让快照返回 unavailable,从而跳过 onSnapshotLoaded,把入口变成唯一能绑定项目的路径。去掉 AppModel+Development.swift 里两处 loadProjectServicesIfRunProjectIsUnbound 调用后,Run 与 Debug 两个测试失败、工具窗口测试仍通过,确认它们能抓到回归。

在报告问题的那个 9765 文件项目上做过手动验证:运行配置在项目树出现后几秒内列出全部 27 条,此时 Spring 面板仍在索引中。

已知限制

已经开始执行的 Rust spring.index 调用无法中断。 该命令没有取消点,所以一次已经进入 load 的索引会跑完。scheduleLoad 现在会在任务体开头检查 Task.isCancelled,因此尚未开始的调度不再重复执行全量索引;这一点由 scheduleLoadReplacesAPendingSchedule 覆盖。降低单次索引本身的成本属于 #299 的范围,本 PR 不涉及 rust/lithe-core

Opening a large multi-module Maven workspace left Run and Debug unusable for
as long as the Spring index took, because loadProjectServices awaited it
before loading build-system and run state. Schedule the index instead so run
configurations, test discovery, and the Git refresh that follows no longer
depend on its duration.
Identifying the project during that window was dropped silently, which is
indistinguishable from a broken button. Report the unloaded workspace as an
explicit state, and let the Run and Debug entry points load the project on
demand the way the tool-window entry points already do.
Refs 1lck#300
Farewell0375and others added 3 commits August 28, 2026 17:10
CI 的测试稳定性门禁拒绝了原先的两个 scheduleLoad 测试:轮询里用了真实
Task.sleep,测试替身里用了无界的 DispatchSemaphore.wait()。改成由 actor 模型
保证的同步断言,加上以 objectWillChange 为事件源、带本地超时的等待。
重写后的测试暴露出一个真实缺陷:Swift 的取消是协作式的,reloadTask.cancel()
并不会阻止任务体运行,而 load 里没有取消检查点,所以被取代的那次调度仍然会跑
完一次全量工作区索引,只是结果被 generation 令牌丢弃。为 scheduleLoad 补上
Task.isCancelled 检查,与既有的 scheduleReload 保持一致。
Refs 1lck#300
之前只靠编译期类型检查和手动验证,没有测试覆盖这两个入口。
新增的三个测试用一个不可读的工作区让快照返回 unavailable,从而跳过
onSnapshotLoaded,把入口变成唯一能绑定项目的路径。把 AppModel+Development 里
两处 loadProjectServicesIfRunProjectIsUnbound 调用去掉后,Run 与 Debug 两个
测试会失败,而覆盖既有行为的工具窗口测试仍然通过,说明它们确实能抓到回归。
把 objectWillChange 加本地超时的等待器提成 ObservableChangeWaiter,供
SpringFeatureModelTests 与新测试共用,超时收到 5 秒以留出计时预算。
Refs 1lck#300

@1lck1lck left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

整体修复方向正确:Spring 索引与运行配置加载解耦符合问题根因,Execution module、Core contracts、视图与平台适配器的边界也保持清晰。

但冷启动入口目前会把“URL 已绑定”当成“工作区快照已就绪”,在 projectFiles 仍为空时允许生成配置,存在写出不完整 generated.json 的数据完整性风险,因此建议修复阻塞批注后再合并。

非阻塞建议已分别标注:将项目加载建模为携带 workspace/snapshot 身份的显式状态;为不可取消的 Spring 全量索引增加 single-flight/latest-pending 语义;同时建议在项目加载与生成路径记录 workspace、文件数量、load/generation ID 和状态转换原因,降低后续竞态问题的排查难度。项目加载编排长期可收口到 ProjectDevelopmentFeatureModel,避免 AppModel 根据服务内部布尔值承担业务时序。

Comment threadmacos/Sources/Lithe/Models/AppModel/AppModel+Development.swift Outdated
Comment threadmacos/Tests/LitheTests/RunEntryPointTests.swift Outdated
Comment threadmacos/Sources/LitheExecutionModule/Services/RunService.swift Outdated
@1lck

1lck commented Aug 28, 2026

Copy link
Copy Markdown
Owner

@Farewell0375 你好 以上是一些pr阻塞点 麻烦处理一下哦 如果您有一些其他好的方案也可以在此PR下与我讨论

评审指出冷启动入口把"URL 已绑定"当成了"工作区快照已就绪"。快照未完成时
projectFiles 为空,入口仍会绑定 projectURL 并允许确认"识别并生成",而
runConfig.generate 只扫描传入的 paths,于是可能写出缺少 Java 入口的
.lithe/run/generated.json。
把两件事显式分开。新增 ProjectLoadState(idle / loading / bound / ready /
failed):bound 表示已绑定工作区但清单是临时的,只允许读取既有配置;ready 携带
工作区与快照标识,才允许生成。快照标识由 WorkspaceFeatureModel 拥有——它是唯一
应用快照的地方——经 AppModel 与 ProjectDevelopmentFeatureModel 传到 RunService。
snapshotID 缺省为 nil,因此忘记传递的调用方会退到 bound 而不是误判为就绪。
generateRunConfigurations 现在要求当前工作区处于 ready,否则报
projectNotReady(原 projectNotLoaded,改名以覆盖"已绑定但清单不完整")。
MacServiceContainer 增加 workspaceOperations 注入缝,与既有的 moduleStore 等
可选覆盖一致,使 AppModel 层能用可控快照做测试。Search 与本地历史仍使用具体的
RustWorkspaceOperations,它们依赖 WorkspaceOperations 之外的能力。
测试改为断言数据完整性而非仅仅"已绑定":入口测试用受控快照,先阻塞、按下 Run、
断言未就绪且生成被拒且未写出配置,再放行含真实 Java 文件的快照并断言就绪;
ExecutionModuleTests 断言生成实际扫描的清单恰好是快照上报的那份。去掉
RunService 的就绪守卫后,后者会直接暴露 generatedInventories == [[]]。
Refs 1lck#300
@Farewell0375

Copy link
Copy Markdown
ContributorAuthor

@1lck 感谢评审,阻塞项和测试覆盖都已处理,a53ee941。细节回在对应批注下了,这里总结。

阻塞项确认成立,是我引入的。 冷启动入口把"URL 已绑定"当成"快照已就绪",快照未完成时 projectFiles 为空,生成会写出缺少 Java 入口的 generated.json。补充一点你的分析之外的成因:Rust 侧的文件系统探测器会独立走磁盘,所以 npm/maven/gradle 那部分还在;丢的恰好是只吃 paths 的 Java 主类扫描,以及依赖它的 Spring Boot 主类收编。

修法:新增 ProjectLoadStateidle / loading / bound / ready(workspace:snapshotID:) / failed)。bound 表示已绑定但清单临时,只允许读取既有配置;ready 才允许生成。快照标识由 WorkspaceFeatureModel 拥有并逐层传递,snapshotID 缺省 nil 使遗漏的调用方退到 bound 而非误判就绪。isProjectLoaded 已移除,readiness 由 ProjectDevelopmentFeatureModel 对外提供。

测试改为断言数据完整性。 入口测试改用受控的 WorkspaceOperations:阻塞快照 → 按下 Run → 断言未就绪、生成被拒、磁盘上没有写出配置 → 放行含真实 Java 文件的快照 → 断言就绪。生成实际扫描哪份清单在 ExecutionModuleTests 断言(Swift 测试二进制不链接 Rust Core,lithe_bridge_version() 返回 unlinked)。

反向验证:一次性 worktree 里去掉就绪守卫后,generatedInventories → [[]],空清单被送去扫描,正是你描述的路径;入口测试同时失败,"正常打开项目"那个仍通过。

为测试新增的注入缝MacServiceContainer.init 增加 workspaceOperations 可选覆盖,与既有的 moduleStore 等同类。Search 与本地历史仍用具体的 RustWorkspaceOperations

验证test-macos.sh 667 个测试全过(新增 2 个,另修正 2 个既有集成测试——它们模拟的是快照已就绪的流程,现在显式传 snapshotID);verify-service-boundaries.shverify-shared-contracts.shverify-module-boundaries.sh、测试稳定性门禁均通过。

两条非阻塞建议我在批注下各自回了想法:ProjectLoadState 已采纳;Spring 的 single-flight 我建议等 #299 落地后单开 issue,因为它需要 Rust 侧先有取消点,而且单次索引降到约 2.4 秒后重复索引的代价会小很多。结构化日志那条我认为独立于 single-flight,可以先做——你希望放本 PR 还是另开,我都可以。

@1lck1lck left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

整体方向没问题,之前的初始空清单问题也已经修了。现在还剩 3 个点:一个会让仓库门禁失败,另外两个是新旧快照和提前执行的时序问题。建议修完再合并,细节见行内批注。

Comment threadmacos/Sources/Lithe/Models/AppModel/AppModel.swift Outdated
Comment threadmacos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift Outdated
Comment threadmacos/Sources/Lithe/Models/AppModel/AppModel+Development.swift Outdated
处理评审的三点。
一、AppModel.swift 与 preview 合并后达到 1806 行,超过 verify-service-boundaries
的 1800 上限。把加载编排移到 AppModel+Development.swift,现为 1781 行。上一轮
本地验证没有先合并 base,所以漏掉了这个失败。
二、snapshotID 放进了 .ready 却没参与比较。同一工作区刷新时,
WorkspaceFeatureModel 在发布新快照与调用 onSnapshotLoaded 之间隔着两个 await,
这段窗口里 RunService 仍持有上一份清单却报就绪,重新识别会漏掉新增的入口。
isReady 现在同时比较工作区与快照标识,snapshotID 为 nil 时一律视为未就绪。
三、加载可能只到 .bound,而运行入口只看 configurationStatus。磁盘上已有配置时
会在完整文件列表与 Maven 信息就绪前直接启动,工具链解析因此缺少 Maven 项目。
运行与调试入口改为要求就绪,未就绪时记下 pendingRunAction 并返回;工作区重建
必然以 loadProjectServices 收尾,由它恢复这次操作,因此不需要轮询或等待。
识别现在统一走 AppModel.generateRunConfigurations,先把服务提升到当前快照再执行。
这是 RunService 内部仍可用自身状态判断的前提。
Refs 1lck#300
上一版的入口测试只证明了推迟与恢复,没能覆盖评审要的"已有配置 + 快照阻塞"
子场景:真实存储的 inspect 要经 Rust Core,而 Swift 测试二进制不链接它
(bridge.c 提供 weak 桩,isAvailable 为 false),所以 configurationStatus
无法在测试里变成 ready。
给 MacServiceContainer 增加 runConfigurationOperations 可选覆盖,与已有的
workspaceOperations、moduleStore 等注入缝同类。测试用一个直接报 ready 的替身
构造该前提,并记录 launchPlan 请求次数:快照阻塞期间断言运行被推迟、
configurationStatus 确为 ready、launchPlan 从未被请求;放行快照后断言恢复执行
且就绪。
未选择改为链接 Rust 的真实测试:CI 中没有任何任务在链接 Rust 的情况下执行
LitheTests(verify-rust-core.sh 只跑 cargo test、swift build 与独立的桥接验证
程序),因此这样的测试会永远跳过。仓库中已有 9 处依赖 isAvailable 的跳过测试,
不宜再添。
Refs 1lck#300
@Farewell0375

Copy link
Copy Markdown
ContributorAuthor

@1lck 三条都已处理,131e3ae1a63d39a4。细节回在对应批注下,这里总结。

门禁那条是我的验证流程漏洞。 上一轮本地跑门禁是通过的(1799 行,上限 1800),但分支处于 BEHINDpreview 也改了同一个文件,合并后才到 1806。我在 worktree 里试算合并结果复现了失败。这轮起先 merge base 再验证,两个 PR 现在都不落后 preview。加载编排已移到 AppModel+Development.swift,现为 1781 行。

snapshotID 现在参与比较。 你指出的窗口确实存在且不窄——rebuild 在发布新快照与调用 onSnapshotLoaded 之间隔着 restoreSessionupdateWatchConfiguration 两个挂起点。做法是让识别与运行/调试统一先经 AppModel.ensureRunProjectReady 比对当前快照,这样 RunService 不需要反向感知工作区。前提是每条生成路径都走这个漏斗,我核对过只有 WorkbenchView 一处实际调用,已改道。

未就绪时运行改为推迟而非降级执行。 按你说的记录待执行动作,由快照驱动的加载恢复,不在生产路径上放等待。

测试覆盖了完整场景,但代价是第二个注入缝。 Swift 测试二进制不链接 Rust Core(bridge.c 是 weak 桩),所以"已有配置"这个前提只能靠替身构造。我给容器加了 runConfigurationOperations 覆盖,测试断言快照阻塞期间 launchPlan 从未被请求。这一点我在批注里请你确认——如果你认为不该为测试在组合根上开第二个缝,我可以把测试降级到 ProjectDevelopmentFeatureModel 层,代价是测不到 AppModel 的守卫顺序。

每一条我都做了反向验证:去掉对应守卫后新测试失败、其余通过。

验证test-macos.sh 672 个测试全过;verify-service-boundaries.shverify-shared-contracts.shverify-module-boundaries.sh、测试稳定性门禁均通过。

上一轮提到的 Spring single-flight 与结构化日志两条非阻塞建议仍建议在 #299 合并后单开 issue,理由写在那条批注下。

避免 await 前后快照错配,以及快照已消费后入口仍用旧捕获 defer 导致 Run 永久挂起。
@Farewell0375

Copy link
Copy Markdown
ContributorAuthor

@1lck 最后两条未闭环项已处理,9ec89470。细节回在对应批注下,这里总结。

files + snapshotID 同源传递。WorkspaceSnapshot 现在自带 id,工作区侧以 appliedSnapshot 一次发布;loadProjectServices / onSnapshotLoaded / ensureRunProjectReady 都显式接收这对值,不再在 await 之后回读全局身份去拼代次。

恢复启动断言补全。 已有配置 + 快照阻塞的测试改为等待并断言 launchPlanCallCount == 1(阻塞期为 0),避免只看 pendingRunAction == nil 漏掉「清了却没真正再发 Run」。

自查多修一处竞态。 入口自己的 provisional load 还在 inspect 里时,快照可能已经落地并跑完 deferred resume(当时还没有 pending)。入口返回后若仍用 await 前捕获的 nil 判断,会误 defer 且再也没人 resume。ensureRunProjectReady 现在在 await 后重读 current 快照;由 runResumesWhenTheSnapshotLandsDuringTheEntryPointsOwnLoad 锁住,去掉修复后该测试失败。

另外:配置解析失败与 inventory 就绪继续分开(configurationStatus vs projectLoadState),并补了 unreadableConfigurationStillAllowsRegeneration,避免坏掉的 generated.json 把生成路径也堵住。

验证:RunEntryPointTests 6/6(含 timing harness)、./scripts/test-macos.sh 674/79 通过、verify-service-boundaries / verify-test-stability 通过。

// The applied snapshot is the event a deferred Run waits for, and
// it carries its own identity so the pair cannot drift.
await self.loadProjectServices(
at: workspaceURL,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

【阻塞 / P1】这里仍把 snapshot.files + snapshot.id 与回调执行时重新读取的 self.workspaceURL 组合,三者并非同源。WorkspaceFeatureModel.rebuild 在通过一次 isCurrent 检查并发布快照后,还会 await restoreSessionawait updateWatchConfiguration,之后才调用 onSnapshotLoaded。如果这期间从项目 A 切换到 B,A 的旧回调仍可能继续,此处读到的却是 B,于是会把 A.files + A.id 按 B 加载;resumesDeferredRunAction: true 还可能恢复 B 的 pending Run,最终用 A 的清单启动 B。请让 workspace 身份与 snapshot 一起传递,并在回调前再次拒绝 stale workspace;同时补一个“旧项目快照发布后、回调前切换项目”的竞态测试。

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

成立,已修,fa807e1c

onSnapshotLoaded 现在接收 rebuild 传入的 workspaceURL,不再回读 self.workspaceURLrebuildrestoreSession / updateWatchConfiguration / 回调前都会再次 isCurrent(),过期直接 .stale 且不交付回调,因此 A 的清单不会按 B 加载,也不会用 resumesDeferredRunAction 恢复 B 的 pending。

rebuildRejectsStaleWorkspaceBeforeSnapshotCallback 锁住:在 restore 挂起期间翻转 isCurrent 后,断言回调计数为 0。去掉这些守卫后该测试失败。

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“回调开始前切换 workspace”这一段已经修好,新增测试也通过;但 workspace 身份还没有贯穿入口任务本身。可稳定复现:在 A 调用 startRunConfiguration,让 ensureRunProjectReady 挂在 inspect;切到 B(这里会清空 pending),再放行 A 的 inspect。旧任务返回 false 后,deferRunAction 会重新读取当前的 self.workspaceURL,于是把 A 的具体 configuration 记成了 B 的 pending;B 快照到达后就可能按 B 恢复 A 的配置。我加的反向测试 directStartFromTheOldWorkspaceIsNotDeferredIntoTheNewWorkspace 在当前 head 稳定失败。建议入口开始时一次捕获 workspace 身份,并让 readiness 结果区分“当前 workspace 等快照”和“任务已 stale”;后者直接丢弃,不能再用全局当前 URL defer。另外 stale A 的回调也不应清掉属于当前 B 的 pending,最好用同一个 token/current guard 覆盖每个 await 后的恢复点。

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

成立,已修,82169547

工作区身份现在贯穿入口任务本身:

  • 每个入口(Run / Debug / 直接启动 / 批量服务 / Restart / 识别)在任何 await 之前一次性捕获 workspaceURL?.standardizedFileURL,之后不再回读全局当前 URL。
  • readiness 结果按你的建议分成三种:RunProjectReadiness.ready / .waitingForSnapshot(workspace:) / .stale.stale 直接丢弃,不 defer;.waitingForSnapshot 携带任务自己捕获的 workspace,deferRunAction(_:for:) 只按它登记并再次校验 current。
  • stale A 的回调不再动 B 的 pending:resumeDeferredRunActionaction.workspace 与本次加载的 workspace 不一致时只 return,不清空;clearPendingRunAction(for:) 同样只清属于本任务 workspace 的那一条。
  • 每个 await 后的恢复点都加了 current 守卫,包括 loadProjectServices 内部:模块激活之后、loadProject 之后各校验一次,避免把 A 的清单写进 B 的 run 服务。

回归测试用你给的名字和场景 directStartFromTheOldWorkspaceIsNotDeferredIntoTheNewWorkspace:A 上 startRunConfiguration 挂在 inspect → 切到 B(断言 pending 被清空)→ B 自己 defer 出 .startConfiguration(B) → 放行 A 的 inspect,断言 A 的配置没有被登记成 B 的 pending,且 B 的 pending 存活。为「不该发生」的那一步单独用了 1s 短期限,避免用长等待掩盖问题。

一处选型说明:守卫用的是 workspace URL 身份而不是自增 token。差别只在「关闭后重开同一路径」这一种情况——在途任务仍会被判为 current,动作会在重开后按同一工程恢复,不会跨工程写错清单。如果你倾向彻底闭合这个窗口,我可以再加一个世代计数。

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这轮跨不同 workspace 的修复和测试我复核通过;你最后提到的“关闭/重开同一路径”窗口需要一并闭合,建议采用 generation/token,URL 身份还不够。已稳定复现:同一路径第一次打开后让 startRunConfiguration(A) 挂在 inspect;再次 openProjectDirectly 同一路径并让 startRunConfiguration(B) 成为当前 pending;随后放行第一次打开的 inspect。因为两个任务捕获的 URL 相等,旧任务仍被判 current,最终把 B 的 pending 覆盖成 A。反向测试 directStartFromAnEarlierOpeningOfTheSameWorkspaceIsDiscarded 当前稳定失败。这个 token 也应进入 workspaceFeature.rebuild(... isCurrent:) 的判定,而不只是 Run 入口:否则同路径重开时旧 rebuild 也可能在 reset 后重新发布旧 snapshot。请让每次 open/close 推进 workspace generation,并让 rebuild、入口任务及各 await 后的 current guard 同时比较 URL + generation;补上上述同路径重开测试。这样 workspace 身份才真正闭环。

Comment threadmacos/Sources/Lithe/Models/AppModel/AppModel+Development.swift Outdated
@1lck

1lck commented Aug 29, 2026

Copy link
Copy Markdown
Owner

@Farewell0375 你好,感谢前面几轮认真处理评审意见。目前还有 3 处与工作区快照时序相关的阻塞点,细节和复现场景已经分别写在对应行内批注中:

  1. 旧项目的 snapshot 回调可能与新项目的 workspace 身份错配;
  2. ensureRunProjectReady 返回 false 后,生成路径仍可能继续扫描旧 inventory;
  3. Run 面板的直接运行与批量运行入口仍会绕过 .ready 守卫。

麻烦有空时一起修复并补上对应的竞态/入口回归测试。处理完成后请在 PR 下艾特 @1lck 复审即可,谢谢!

让 rebuild 在挂起点后再次拒绝过期 workspace,并把 workspace 与 snapshot 一并传给 onSnapshotLoaded;ensure 失败时停止生成;直接启动与批量服务入口统一走 readiness 守卫并保留 pending 意图。
@Farewell0375

Copy link
Copy Markdown
ContributorAuthor

@1lck 三条阻塞项都已处理,fa807e1c。细节回在对应批注下,这里总结。

1. workspace 与 snapshot 同源。onSnapshotLoaded 改为接收 rebuild 的 workspaceURLrebuild 在 restore / watch / 回调前再次拒绝 stale,过期不交付回调。

2. ensure == false 停止生成。 报告 projectNotReady 并 return,避免在仍持有 .ready(A) 时扫描旧 inventory。

3. 启动入口收口。startRunConfiguration / runAllServiceConfigurations 统一走 readiness 守卫;pending 保留具体 configuration / 批量意图。

测试(均做过反向验证):

测试锁住的行为
rebuildRejectsStaleWorkspaceBeforeSnapshotCallback回调前切项目不交付 onSnapshotLoaded
generateRefusesStaleReadyInventoryWhenSnapshotAdvancesDuringLoad加载 A 期间发布 B 不得扫旧 inventory
startRunConfigurationDefersAndResumesAfterTheSnapshotArrives主播放入口推迟并恢复
runAllServicesDefersAndResumesAfterTheSnapshotArrives批量服务入口同上

验证test-macos.sh 此前全量通过;本次新增/相关入口测试与 timing harness、verify-service-boundaries / verify-module-boundaries / verify-test-stability 均通过。AppModel.swift 仍为 1788 行。

请复审,谢谢。

@1lck1lck left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 fa807e1c:作者本轮新增的 3 项主体修复和 9 条 RunEntryPointTests 均已复核,其中“快照文件与 ID 同源”和“readiness 为 false 时禁止生成旧 inventory”两条已关闭;直接启动与批量服务的 defer/resume 也通过。边界、共享契约、模块边界和测试稳定性校验全部通过。当前仍有两个同一状态机内的阻塞缺口,已续写在原讨论中:一是 Restart 仍绕过 readiness;二是入口任务跨 workspace await 后会把 A 的动作重新登记为 B 的 pending。两条都用反向测试稳定复现,因此本轮暂时维持 changes requested。修好并补回归测试后请再 @1lck 复审。

Restart 走 ensureRunProjectReady/pending 恢复;入口捕获 workspace 区分 stale 与 waiting,避免旧任务 defer 到新工程或清掉对方 pending。
@Farewell0375

Copy link
Copy Markdown
ContributorAuthor

@1lck 本轮两个阻塞缺口已修完,82169547,请复审。

1. Restart 绕过 readiness
Restart 纳入与其它入口相同的 ensureRunProjectReady / pending 漏斗,PendingRunAction.Kind 增加 .restart。同时把判定依据补全:ensureRunProjectReady 区分「从未加载过该 workspace 的完整清单」(入口自行加载已发布的 scan)和「已有完整清单但快照被取代」(交给快照回调推进,入口不争用)。为此在契约层加 ProjectLoadState.hasReadyInventory(for:),经 RunService / RunFeatureModel 透传,AppModel 不解构服务内部状态枚举。
回归:restartDefersWhenANewerSnapshotIsPublishedButNotYetConsumed

2. 入口任务跨 workspace 身份
入口在任何 await 前一次性捕获 workspace;readiness 返回 .ready / .waitingForSnapshot(workspace:) / .stale,stale 直接丢弃不 defer;stale 回调不再清掉属于当前工程的 pending;loadProjectServices 内每个 await 后补 current 守卫。
回归:directStartFromTheOldWorkspaceIsNotDeferredIntoTheNewWorkspace

测试稳定性
自查时发现我上一轮新增的几条断言在等一个不经模型发布的计数(launchPlanCallCount),且 5s 期限撑不过一整轮快照驱动加载,会偶发失败。已改为由测试替身在 launchPlan 内部开门信号量作为确定性同步点,并把本文件跨加载的正向等待统一到与既有门一致的期限;「不该发生」的负向等待仍保持 1s。焦点套件连续 5 次全绿。

验证

  • ./scripts/test-macos.sh:680 tests passed
  • ./scripts/verify-service-boundaries.sh:通过
  • ./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh:通过
  • 反向验证:禁用「快照被取代 → 等待回调」分支后,Restart 测试以 launchPlanCallCount → 2) == 1 失败,生成测试扫到旧 inventory 变成 .succeeded

一处待你定调:current 守卫用 workspace URL 身份而非自增 token,残留窗口只有「关闭后重开同一路径」,后果是在途动作按同一工程恢复,不跨工程写错清单。需要彻底闭合我再加世代计数。

@1lck1lck left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 82169547:Restart readiness 与跨不同 workspace 的 stale/pending 保护均已验证通过,对应 Restart 讨论已关闭;作者新增的 11 条入口测试本地全部通过,边界/契约/模块/测试稳定性校验也通过。当前只剩 1 条阻塞:URL 无法区分同一路径的不同打开世代,旧会话任务可覆盖新会话 pending,且旧 rebuild 也可能在 reset 后发布快照;已用反向测试稳定复现并续写到原 workspace 讨论,建议用 URL + generation/token 一次性闭环。GitHub Swift tests 的红灯来自未改动的 JDTLS 指纹测试达到 15 秒性能阈值,不是本轮入口测试失败;后续新提交会重新触发 CI。修复 generation/token 并补同路径重开回归测试后请再 @1lck

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Farewell0375@1lck