Skip to content

feat(desktop): make app updates background and task-aware - #1992

Merged
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates
Aug 4, 2026
Merged

feat(desktop): make app updates background and task-aware#1992
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates

Conversation

@me2seeks

Copy link
Copy Markdown
Contributor

Summary

Update discovery used to depend on the renderer staying alive, and installing a downloaded update had no view of work still running in the main process.

This moves the updater lifecycle into Electron main: the first check runs about 10 seconds after app readiness, later checks run four hours after the previous check settles, and electron-updater downloads an available release automatically. The renderer now only projects the current status and listens for pushes; the existing sidebar button remains the only update surface, with no download-complete popup and no install-on-quit behavior.

Restart/install is guarded by a main-owned activity snapshot. Session turns are counted by stable task identity, and Automation scheduler activity is merged with stream activity without double-counting the same fire. The first install request authorizes interrupting zero tasks. After the user confirms the reported count, the follow-up request is limited to that count; if more tasks start while the dialog is open, the new count is shown for confirmation instead of silently widening the authorization.

Closes#1931

中文说明

本 PR 将更新检查和自动下载的生命周期移到 Electron 主进程:应用 ready 约 10 秒后首次检查,之后在上一次检查结束 4 小时后再次检查。渲染进程只读取状态并订阅推送;下载完成后仍只通过现有侧栏按钮提示,不弹窗,也不会在退出时自动安装。

重启安装前,主进程会合并会话 turn 与 Automation scheduler 的活动状态,并按稳定任务标识去重。首次请求不允许中断任何任务;用户确认 N 个任务后,后续请求最多只能中断这 N 个任务。如果确认期间又启动了任务,会显示新的数量并再次询问,而不是扩大原授权。

Verification

  • Node 24: @maka/runtime full suite — 2,762 passed, 3 skipped
  • Node 24: @maka/desktop full main suite — 1,354 passed
  • Node 24: @maka/ui full suite — 256 passed
  • npm run typecheck
  • npm run lint -- --error-on-warnings
  • npm run format:check
  • npm --workspace @maka/desktop run build
  • Desktop console, accessibility, and copy checks
  • Knip for apps/desktop and packages/ui
  • Electron fixture: main-process delayed status reached downloaded, the sidebar exposed Restart without a completion popup, and the no-active-work path proceeded without confirmation

Review focus

  • SessionActivityRegistry.activeTaskCount() and AutomationScheduler.inFlightAutomationIds() form the main-process activity snapshot; the shared automation key prevents a scheduler fire and its stream lease from counting twice.
  • maxInterruptibleActiveTasks is an authorization ceiling, not a stale informational count. A higher current count returns to the confirmation flow.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review findings (5 independent deepseek-v4-flash perspectives)

The direction (updater lifecycle into main, auto-download, single-button install) is right, and the wiring against electron-updater@6.8.9 was verified correct (autoDownload/autoInstallOnAppQuit semantics, macOS delayed-install, cache sha512 re-validation, single-instance lock, fail-closed snapshot). Two blockers and several scope questions below.

P1 — Background shell/PTY tasks are invisible to the activity snapshot: an install silently kills them with no confirmation

  • Predicted failure: a user runs a long run_in_background=true task (build/download/server — the feature's typical use), the turn ends, the user clicks Restart: getActiveTaskCount() returns 0 → the first request {maxInterruptibleActiveTasks: 0} passes → quitAndInstall(false, true)shellRuns.terminateAll() kills the process tree. No dialog ever appears. This is exactly the "no work interruption without confirmation" scenario the PR claims to eliminate.
  • Evidence: snapshot composes only two sources — boot.ts:648-650 (sessionActivities.activeTaskCount(inFlightAutomationIds().map(automationActivityKey))). Background shell runs hold no activity lease (all three reserve( call sites are turn-scoped and unrelated to shell-run-manager). But background tasks do survive the turn: shell-run-manager.ts:72LifecycleCause = 'timeout' | 'cancel' | 'shutdown' has no turn-end cause; runBackgroundBash (:219) doesn't join live.finished. And they are killed on the update quit: app-lifecycle.ts:398shellRuns.terminateAll() runs in the shutdown path on both platforms (Windows: quitAndInstallapp.quit()before-quit → quit coordinator; macOS: Squirrel [NSApp terminate] → Electron overrides -terminate:Browser::QuitHandleBeforeQuit → same coordinator). liveCount()/livePtyCount() accessors already exist (shell-run-manager.ts:567-571).
  • Fix (pick one): (a) fold shellRuns.liveCount() into the snapshot (one line + a test), or (b) explicitly narrow scope — state in the PR body and copy that background shell tasks are NOT protected by the confirmation.

P2 — Scope: the task-aware mechanism grew well beyond the issue's minimal design

Issue #1931 asks for "inject hasActiveTasks" — a boolean composed from existing main-process activity, plus a confirmation dialog; "if nothing is running, install immediately". The PR instead built a full authorization machine: precise counting with stable task-identity dedup (activityKey plumbing ×3, taskLeaseCounts parallel map, additionalTaskKeys, inFlightAutomationIds() lifecycle), a maxInterruptibleActiveTasks ceiling ("first request authorizes zero; follow-ups limited to that count"), and an escalation loop re-confirming when the count grows.

From first principles: after the user confirms, installUpdate calls quitAndInstall in the same synchronous tick — there is no "afterwards" window for a ceiling to protect. The only escalation-relevant window is "tasks start while the dialog is open", which a fresh count read handles without ceiling semantics. The precision machinery is also where the bugs live (this P1 and the per-session undercount below). Please choose: return to the minimal boolean + count-display design (which also makes the P1 fix trivial), or keep the ceiling mechanism with a written rationale for what it protects that a fresh re-read does not.

P2 — Download stall/failure leaves no in-app recovery path

checkForUpdates short-circuits on downloading/downloaded (app-update-service.ts:256), the sidebar button is disabled for available/downloading (session-sidebar-nav.tsx:116), and the click is a no-op in those states (app-shell.tsx:438-439). The old error/available click-triggered downloadUpdate() retry was removed. A download that stalls without an error event (hung socket/proxy) permanently freezes the surface until restart, and a failed auto-download is silent with a misleading "Update available" title. (Confirmed independently by 3 of 5 perspectives.)

P2 — installUpdate returns {ok:true} even when no install will occur

BaseUpdater.install reports failure only via dispatchError, never by throwing (BaseUpdater.js:13-27,51-53), and on macOS MacUpdater.quitAndInstall is fire-and-forget (MacUpdater.js:240-256) — a Squirrel-side failure (code-sign validation, proxy fetch) surfaces only as a forwarded 'error' event, after the IPC reply already returned ok. The renderer toasts only on kind === 'failed', so the user confirms interrupting work and nothing happens, silently. Suggest an installing status that maps a subsequent 'error' to install_failed.

P2 — Merge conflict with current main requires a semantic decision

git merge-tree shows a real conflict in app-lifecycle.ts: main (#1994) deleted project-startup-migration.ts (runProjectStartupMigration has zero references on main) while the PR head still calls it at app-lifecycle.ts:175; mechanical resolution produces TS2305. Rebase must decide whether to drop the migration call, then re-run desktop typecheck/build.

P3 (non-blocking)

  • The boot.ts getActiveTaskCount composition (the PR's headline invariant) has no integration test — this is why the P1 slipped through.
  • Same-session concurrent turns share session:{id} key → the confirmation count understates streams killed (runtime-kernel.ts:166 explicitly supports concurrent runs; sessions:send has no idle gate).
  • MAKA_UPDATE_MOCK_STATE=available is now a dead-end (mock publishes available once, install requires downloaded, button disabled).
  • Dead params platform/arch (declared app-update-service.ts:59-60, passed boot.ts:641-642, never read).
  • Install authorization is renderer-trusted consent, not a main-side security boundary — acceptable given sandboxing, worth one sentence in the PR body.

Gate: FAIL (P1 open). The P1 must be fixed or explicitly narrowed before merge; P2s need handling or a written deferral with reason.

@Astro-Han

Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor
中文说明

关于 task-aware 的设计,我有一点个人的观察,供你参考:issue #1931 原意是一个布尔判断 + 安装前确认,现在实现成了"精确计数 + 授权上限 + 重新确认"的状态机。确认后安装是立即执行的,这套机制实际保护的窗口很小,复杂度却主要集中在计数逻辑上——P1 的 shell 任务漏计也源于此。

只是建议,你可以按自己的判断来:如果回归到最小实现(布尔 + 并入 shellRuns.liveCount()),P1 和复杂度都会一起消掉;如果保留现有机制,也完全没问题,方便的话在 PR 里说明一下 ceiling 的价值即可。两条路都支持。

@me2seeks

Copy link
Copy Markdown
ContributorAuthor

感谢建议,这个分析很有帮助。我重新顺着安装时序看了一遍,ceiling 实际保护的主要是确认框打开期间“任务数量增加”这一小段,而且也无法识别数量不变时的任务替换,确实不值得为此保留整套计数状态机。

我会回到 issue 的最小方案:使用 boolean consent,把 shellRuns.liveCount() 纳入活动判断,并移除精确计数、ceiling 和重复确认逻辑。接下来会按这个方向修复,也会一起处理其余 findings。

@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 0c0eaf4 to 2428c06CompareAugust 3, 2026 15:13
@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 2428c06 to fc1991dCompareAugust 3, 2026 15:24

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The two blockers from the previous round are genuinely fixed: hasInterruptibleUpdateWork is now a 17-line boolean union of session / Automation / shell-run activity, and the counting-plus-ceiling state machine (activityKey plumbing, taskLeaseCounts, escalation loop) is gone. Download stall/failure now has a real recovery path via retryUpdateDownload with cancellation-token semantics. CI is green and the branch merges cleanly with current main.

I re-reviewed the head through three independent perspectives (lifecycle correctness, design minimality, test quality); all three came back with no P0/P1. The main-process half reads as close to minimal: one timer, one boolean, three two-line read-only accessors, with the injected clock, operation-tagged error states, and mock fixture all load-bearing.

Four non-blocking items below. None gate this PR — fix, defer, or close them as you see fit.

P2 — installing has no exit when Squirrel stalls silently. On macOS with autoInstallOnAppQuit = false, update-downloaded is dispatched when the local proxy server starts, so squirrelDownloadedUpdate is still false at click time and MacUpdater.quitAndInstall takes the else branch (MacUpdater.js:240-256): it registers a listener, kicks off an async checkForUpdates(), and returns without quitting. Status stays installing, which short-circuits both checkForUpdates (app-update-service.ts:304) and retryUpdateDownload (:351), and updateReminder (app-shell.tsx:416-420) excludes installing, so the button disappears. A genuine Squirrel error is fine — MacUpdater.js:18-21 forwards it and you map it to error/install — so this is only the silent-hang path. Smallest fix: stop short-circuiting on installing and fall back to downloaded on the next scheduled check.

P3 — openUpdateDownload is now dead end to end. The sidebar button only renders for available | downloading | downloaded | error-with-latestVersion, and all four are routed to install or retry, so the else branch at app-shell.tsx:483 is unreachable — along with the preload method, the app:openUpdateDownload handler (app-ipc-main.ts:84), and the service method. Either delete the chain or wire it to a state that can actually reach it.

P3 — Retry is unguarded where install is guarded.isDisabled was removed from the sidebar button, and a click during downloading cancels the in-flight download and restarts from 0%. Install has updateInstallInFlightRef; retry has no equivalent, so the one destructive action in the surface is the least protected.

P3 — The renderer half has no tests.app-update-install.ts is covered as a pure function, but the wiring is not: the receivedPush race guard, the click-branch routing, and the double-click guard would all survive being reverted. AGENTS.md asks for one representative Playwright journey when a flow crosses renderer and main, and the deterministic seam (MAKA_UPDATE_MOCK_STATE, with installUpdate returning ok without quitting in mock mode) already exists and is unused.

中文说明

已 approve。上一轮的两个阻塞项确实修掉了:活动判断回到 17 行的布尔并集,计数 + ceiling 那套状态机整体删除,下载停滞也有了真实的取消重下路径。CI 全绿,与 main 无冲突。

我从生命周期正确性、设计最小性、测试质量三个独立视角重新过了一遍,都没有 P0/P1。主进程这一半基本已是最小解。

上面四条都不阻塞合并,是否处理你自己判断。P2 是 macOS 上 Squirrel 静默挂起时 installing 没有出口(真正报错的路径你已经兜住了);两个 P3 分别是 openUpdateDownload 整条链路已不可达,以及 downloading 时点按钮会取消并从 0 重下且无保护;最后一个 P3 是 renderer 那一半没有测试,而确定性 mock seam 已经存在。

@Astro-Han
Astro-Han merged commit b5377bd into apache:mainAug 4, 2026
11 checks passed
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.

feat(update): auto-download, main-process checks, and task-aware install

2 participants

@me2seeks@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(desktop): make app updates background and task-aware by me2seeks · Pull Request #1992 · apache/maka · GitHub
Skip to content

feat(desktop): make app updates background and task-aware - #1992

Merged
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates
Aug 4, 2026
Merged

feat(desktop): make app updates background and task-aware#1992
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates

Conversation

@me2seeks

Copy link
Copy Markdown
Contributor

Summary

Update discovery used to depend on the renderer staying alive, and installing a downloaded update had no view of work still running in the main process.

This moves the updater lifecycle into Electron main: the first check runs about 10 seconds after app readiness, later checks run four hours after the previous check settles, and electron-updater downloads an available release automatically. The renderer now only projects the current status and listens for pushes; the existing sidebar button remains the only update surface, with no download-complete popup and no install-on-quit behavior.

Restart/install is guarded by a main-owned activity snapshot. Session turns are counted by stable task identity, and Automation scheduler activity is merged with stream activity without double-counting the same fire. The first install request authorizes interrupting zero tasks. After the user confirms the reported count, the follow-up request is limited to that count; if more tasks start while the dialog is open, the new count is shown for confirmation instead of silently widening the authorization.

Closes#1931

中文说明

本 PR 将更新检查和自动下载的生命周期移到 Electron 主进程:应用 ready 约 10 秒后首次检查,之后在上一次检查结束 4 小时后再次检查。渲染进程只读取状态并订阅推送;下载完成后仍只通过现有侧栏按钮提示,不弹窗,也不会在退出时自动安装。

重启安装前,主进程会合并会话 turn 与 Automation scheduler 的活动状态,并按稳定任务标识去重。首次请求不允许中断任何任务;用户确认 N 个任务后,后续请求最多只能中断这 N 个任务。如果确认期间又启动了任务,会显示新的数量并再次询问,而不是扩大原授权。

Verification

  • Node 24: @maka/runtime full suite — 2,762 passed, 3 skipped
  • Node 24: @maka/desktop full main suite — 1,354 passed
  • Node 24: @maka/ui full suite — 256 passed
  • npm run typecheck
  • npm run lint -- --error-on-warnings
  • npm run format:check
  • npm --workspace @maka/desktop run build
  • Desktop console, accessibility, and copy checks
  • Knip for apps/desktop and packages/ui
  • Electron fixture: main-process delayed status reached downloaded, the sidebar exposed Restart without a completion popup, and the no-active-work path proceeded without confirmation

Review focus

  • SessionActivityRegistry.activeTaskCount() and AutomationScheduler.inFlightAutomationIds() form the main-process activity snapshot; the shared automation key prevents a scheduler fire and its stream lease from counting twice.
  • maxInterruptibleActiveTasks is an authorization ceiling, not a stale informational count. A higher current count returns to the confirmation flow.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review findings (5 independent deepseek-v4-flash perspectives)

The direction (updater lifecycle into main, auto-download, single-button install) is right, and the wiring against electron-updater@6.8.9 was verified correct (autoDownload/autoInstallOnAppQuit semantics, macOS delayed-install, cache sha512 re-validation, single-instance lock, fail-closed snapshot). Two blockers and several scope questions below.

P1 — Background shell/PTY tasks are invisible to the activity snapshot: an install silently kills them with no confirmation

  • Predicted failure: a user runs a long run_in_background=true task (build/download/server — the feature's typical use), the turn ends, the user clicks Restart: getActiveTaskCount() returns 0 → the first request {maxInterruptibleActiveTasks: 0} passes → quitAndInstall(false, true)shellRuns.terminateAll() kills the process tree. No dialog ever appears. This is exactly the "no work interruption without confirmation" scenario the PR claims to eliminate.
  • Evidence: snapshot composes only two sources — boot.ts:648-650 (sessionActivities.activeTaskCount(inFlightAutomationIds().map(automationActivityKey))). Background shell runs hold no activity lease (all three reserve( call sites are turn-scoped and unrelated to shell-run-manager). But background tasks do survive the turn: shell-run-manager.ts:72LifecycleCause = 'timeout' | 'cancel' | 'shutdown' has no turn-end cause; runBackgroundBash (:219) doesn't join live.finished. And they are killed on the update quit: app-lifecycle.ts:398shellRuns.terminateAll() runs in the shutdown path on both platforms (Windows: quitAndInstallapp.quit()before-quit → quit coordinator; macOS: Squirrel [NSApp terminate] → Electron overrides -terminate:Browser::QuitHandleBeforeQuit → same coordinator). liveCount()/livePtyCount() accessors already exist (shell-run-manager.ts:567-571).
  • Fix (pick one): (a) fold shellRuns.liveCount() into the snapshot (one line + a test), or (b) explicitly narrow scope — state in the PR body and copy that background shell tasks are NOT protected by the confirmation.

P2 — Scope: the task-aware mechanism grew well beyond the issue's minimal design

Issue #1931 asks for "inject hasActiveTasks" — a boolean composed from existing main-process activity, plus a confirmation dialog; "if nothing is running, install immediately". The PR instead built a full authorization machine: precise counting with stable task-identity dedup (activityKey plumbing ×3, taskLeaseCounts parallel map, additionalTaskKeys, inFlightAutomationIds() lifecycle), a maxInterruptibleActiveTasks ceiling ("first request authorizes zero; follow-ups limited to that count"), and an escalation loop re-confirming when the count grows.

From first principles: after the user confirms, installUpdate calls quitAndInstall in the same synchronous tick — there is no "afterwards" window for a ceiling to protect. The only escalation-relevant window is "tasks start while the dialog is open", which a fresh count read handles without ceiling semantics. The precision machinery is also where the bugs live (this P1 and the per-session undercount below). Please choose: return to the minimal boolean + count-display design (which also makes the P1 fix trivial), or keep the ceiling mechanism with a written rationale for what it protects that a fresh re-read does not.

P2 — Download stall/failure leaves no in-app recovery path

checkForUpdates short-circuits on downloading/downloaded (app-update-service.ts:256), the sidebar button is disabled for available/downloading (session-sidebar-nav.tsx:116), and the click is a no-op in those states (app-shell.tsx:438-439). The old error/available click-triggered downloadUpdate() retry was removed. A download that stalls without an error event (hung socket/proxy) permanently freezes the surface until restart, and a failed auto-download is silent with a misleading "Update available" title. (Confirmed independently by 3 of 5 perspectives.)

P2 — installUpdate returns {ok:true} even when no install will occur

BaseUpdater.install reports failure only via dispatchError, never by throwing (BaseUpdater.js:13-27,51-53), and on macOS MacUpdater.quitAndInstall is fire-and-forget (MacUpdater.js:240-256) — a Squirrel-side failure (code-sign validation, proxy fetch) surfaces only as a forwarded 'error' event, after the IPC reply already returned ok. The renderer toasts only on kind === 'failed', so the user confirms interrupting work and nothing happens, silently. Suggest an installing status that maps a subsequent 'error' to install_failed.

P2 — Merge conflict with current main requires a semantic decision

git merge-tree shows a real conflict in app-lifecycle.ts: main (#1994) deleted project-startup-migration.ts (runProjectStartupMigration has zero references on main) while the PR head still calls it at app-lifecycle.ts:175; mechanical resolution produces TS2305. Rebase must decide whether to drop the migration call, then re-run desktop typecheck/build.

P3 (non-blocking)

  • The boot.ts getActiveTaskCount composition (the PR's headline invariant) has no integration test — this is why the P1 slipped through.
  • Same-session concurrent turns share session:{id} key → the confirmation count understates streams killed (runtime-kernel.ts:166 explicitly supports concurrent runs; sessions:send has no idle gate).
  • MAKA_UPDATE_MOCK_STATE=available is now a dead-end (mock publishes available once, install requires downloaded, button disabled).
  • Dead params platform/arch (declared app-update-service.ts:59-60, passed boot.ts:641-642, never read).
  • Install authorization is renderer-trusted consent, not a main-side security boundary — acceptable given sandboxing, worth one sentence in the PR body.

Gate: FAIL (P1 open). The P1 must be fixed or explicitly narrowed before merge; P2s need handling or a written deferral with reason.

@Astro-Han

Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor
中文说明

关于 task-aware 的设计,我有一点个人的观察,供你参考:issue #1931 原意是一个布尔判断 + 安装前确认,现在实现成了"精确计数 + 授权上限 + 重新确认"的状态机。确认后安装是立即执行的,这套机制实际保护的窗口很小,复杂度却主要集中在计数逻辑上——P1 的 shell 任务漏计也源于此。

只是建议,你可以按自己的判断来:如果回归到最小实现(布尔 + 并入 shellRuns.liveCount()),P1 和复杂度都会一起消掉;如果保留现有机制,也完全没问题,方便的话在 PR 里说明一下 ceiling 的价值即可。两条路都支持。

@me2seeks

Copy link
Copy Markdown
ContributorAuthor

感谢建议,这个分析很有帮助。我重新顺着安装时序看了一遍,ceiling 实际保护的主要是确认框打开期间“任务数量增加”这一小段,而且也无法识别数量不变时的任务替换,确实不值得为此保留整套计数状态机。

我会回到 issue 的最小方案:使用 boolean consent,把 shellRuns.liveCount() 纳入活动判断,并移除精确计数、ceiling 和重复确认逻辑。接下来会按这个方向修复,也会一起处理其余 findings。

@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 0c0eaf4 to 2428c06CompareAugust 3, 2026 15:13
@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 2428c06 to fc1991dCompareAugust 3, 2026 15:24

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The two blockers from the previous round are genuinely fixed: hasInterruptibleUpdateWork is now a 17-line boolean union of session / Automation / shell-run activity, and the counting-plus-ceiling state machine (activityKey plumbing, taskLeaseCounts, escalation loop) is gone. Download stall/failure now has a real recovery path via retryUpdateDownload with cancellation-token semantics. CI is green and the branch merges cleanly with current main.

I re-reviewed the head through three independent perspectives (lifecycle correctness, design minimality, test quality); all three came back with no P0/P1. The main-process half reads as close to minimal: one timer, one boolean, three two-line read-only accessors, with the injected clock, operation-tagged error states, and mock fixture all load-bearing.

Four non-blocking items below. None gate this PR — fix, defer, or close them as you see fit.

P2 — installing has no exit when Squirrel stalls silently. On macOS with autoInstallOnAppQuit = false, update-downloaded is dispatched when the local proxy server starts, so squirrelDownloadedUpdate is still false at click time and MacUpdater.quitAndInstall takes the else branch (MacUpdater.js:240-256): it registers a listener, kicks off an async checkForUpdates(), and returns without quitting. Status stays installing, which short-circuits both checkForUpdates (app-update-service.ts:304) and retryUpdateDownload (:351), and updateReminder (app-shell.tsx:416-420) excludes installing, so the button disappears. A genuine Squirrel error is fine — MacUpdater.js:18-21 forwards it and you map it to error/install — so this is only the silent-hang path. Smallest fix: stop short-circuiting on installing and fall back to downloaded on the next scheduled check.

P3 — openUpdateDownload is now dead end to end. The sidebar button only renders for available | downloading | downloaded | error-with-latestVersion, and all four are routed to install or retry, so the else branch at app-shell.tsx:483 is unreachable — along with the preload method, the app:openUpdateDownload handler (app-ipc-main.ts:84), and the service method. Either delete the chain or wire it to a state that can actually reach it.

P3 — Retry is unguarded where install is guarded.isDisabled was removed from the sidebar button, and a click during downloading cancels the in-flight download and restarts from 0%. Install has updateInstallInFlightRef; retry has no equivalent, so the one destructive action in the surface is the least protected.

P3 — The renderer half has no tests.app-update-install.ts is covered as a pure function, but the wiring is not: the receivedPush race guard, the click-branch routing, and the double-click guard would all survive being reverted. AGENTS.md asks for one representative Playwright journey when a flow crosses renderer and main, and the deterministic seam (MAKA_UPDATE_MOCK_STATE, with installUpdate returning ok without quitting in mock mode) already exists and is unused.

中文说明

已 approve。上一轮的两个阻塞项确实修掉了:活动判断回到 17 行的布尔并集,计数 + ceiling 那套状态机整体删除,下载停滞也有了真实的取消重下路径。CI 全绿,与 main 无冲突。

我从生命周期正确性、设计最小性、测试质量三个独立视角重新过了一遍,都没有 P0/P1。主进程这一半基本已是最小解。

上面四条都不阻塞合并,是否处理你自己判断。P2 是 macOS 上 Squirrel 静默挂起时 installing 没有出口(真正报错的路径你已经兜住了);两个 P3 分别是 openUpdateDownload 整条链路已不可达,以及 downloading 时点按钮会取消并从 0 重下且无保护;最后一个 P3 是 renderer 那一半没有测试,而确定性 mock seam 已经存在。

@Astro-Han
Astro-Han merged commit b5377bd into apache:mainAug 4, 2026
11 checks passed
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.

feat(update): auto-download, main-process checks, and task-aware install

2 participants

@me2seeks@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(desktop): make app updates background and task-aware by me2seeks · Pull Request #1992 · apache/maka · GitHub
Skip to content

feat(desktop): make app updates background and task-aware - #1992

Merged
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates
Aug 4, 2026
Merged

feat(desktop): make app updates background and task-aware#1992
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates

Conversation

@me2seeks

Copy link
Copy Markdown
Contributor

Summary

Update discovery used to depend on the renderer staying alive, and installing a downloaded update had no view of work still running in the main process.

This moves the updater lifecycle into Electron main: the first check runs about 10 seconds after app readiness, later checks run four hours after the previous check settles, and electron-updater downloads an available release automatically. The renderer now only projects the current status and listens for pushes; the existing sidebar button remains the only update surface, with no download-complete popup and no install-on-quit behavior.

Restart/install is guarded by a main-owned activity snapshot. Session turns are counted by stable task identity, and Automation scheduler activity is merged with stream activity without double-counting the same fire. The first install request authorizes interrupting zero tasks. After the user confirms the reported count, the follow-up request is limited to that count; if more tasks start while the dialog is open, the new count is shown for confirmation instead of silently widening the authorization.

Closes#1931

中文说明

本 PR 将更新检查和自动下载的生命周期移到 Electron 主进程:应用 ready 约 10 秒后首次检查,之后在上一次检查结束 4 小时后再次检查。渲染进程只读取状态并订阅推送;下载完成后仍只通过现有侧栏按钮提示,不弹窗,也不会在退出时自动安装。

重启安装前,主进程会合并会话 turn 与 Automation scheduler 的活动状态,并按稳定任务标识去重。首次请求不允许中断任何任务;用户确认 N 个任务后,后续请求最多只能中断这 N 个任务。如果确认期间又启动了任务,会显示新的数量并再次询问,而不是扩大原授权。

Verification

  • Node 24: @maka/runtime full suite — 2,762 passed, 3 skipped
  • Node 24: @maka/desktop full main suite — 1,354 passed
  • Node 24: @maka/ui full suite — 256 passed
  • npm run typecheck
  • npm run lint -- --error-on-warnings
  • npm run format:check
  • npm --workspace @maka/desktop run build
  • Desktop console, accessibility, and copy checks
  • Knip for apps/desktop and packages/ui
  • Electron fixture: main-process delayed status reached downloaded, the sidebar exposed Restart without a completion popup, and the no-active-work path proceeded without confirmation

Review focus

  • SessionActivityRegistry.activeTaskCount() and AutomationScheduler.inFlightAutomationIds() form the main-process activity snapshot; the shared automation key prevents a scheduler fire and its stream lease from counting twice.
  • maxInterruptibleActiveTasks is an authorization ceiling, not a stale informational count. A higher current count returns to the confirmation flow.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review findings (5 independent deepseek-v4-flash perspectives)

The direction (updater lifecycle into main, auto-download, single-button install) is right, and the wiring against electron-updater@6.8.9 was verified correct (autoDownload/autoInstallOnAppQuit semantics, macOS delayed-install, cache sha512 re-validation, single-instance lock, fail-closed snapshot). Two blockers and several scope questions below.

P1 — Background shell/PTY tasks are invisible to the activity snapshot: an install silently kills them with no confirmation

  • Predicted failure: a user runs a long run_in_background=true task (build/download/server — the feature's typical use), the turn ends, the user clicks Restart: getActiveTaskCount() returns 0 → the first request {maxInterruptibleActiveTasks: 0} passes → quitAndInstall(false, true)shellRuns.terminateAll() kills the process tree. No dialog ever appears. This is exactly the "no work interruption without confirmation" scenario the PR claims to eliminate.
  • Evidence: snapshot composes only two sources — boot.ts:648-650 (sessionActivities.activeTaskCount(inFlightAutomationIds().map(automationActivityKey))). Background shell runs hold no activity lease (all three reserve( call sites are turn-scoped and unrelated to shell-run-manager). But background tasks do survive the turn: shell-run-manager.ts:72LifecycleCause = 'timeout' | 'cancel' | 'shutdown' has no turn-end cause; runBackgroundBash (:219) doesn't join live.finished. And they are killed on the update quit: app-lifecycle.ts:398shellRuns.terminateAll() runs in the shutdown path on both platforms (Windows: quitAndInstallapp.quit()before-quit → quit coordinator; macOS: Squirrel [NSApp terminate] → Electron overrides -terminate:Browser::QuitHandleBeforeQuit → same coordinator). liveCount()/livePtyCount() accessors already exist (shell-run-manager.ts:567-571).
  • Fix (pick one): (a) fold shellRuns.liveCount() into the snapshot (one line + a test), or (b) explicitly narrow scope — state in the PR body and copy that background shell tasks are NOT protected by the confirmation.

P2 — Scope: the task-aware mechanism grew well beyond the issue's minimal design

Issue #1931 asks for "inject hasActiveTasks" — a boolean composed from existing main-process activity, plus a confirmation dialog; "if nothing is running, install immediately". The PR instead built a full authorization machine: precise counting with stable task-identity dedup (activityKey plumbing ×3, taskLeaseCounts parallel map, additionalTaskKeys, inFlightAutomationIds() lifecycle), a maxInterruptibleActiveTasks ceiling ("first request authorizes zero; follow-ups limited to that count"), and an escalation loop re-confirming when the count grows.

From first principles: after the user confirms, installUpdate calls quitAndInstall in the same synchronous tick — there is no "afterwards" window for a ceiling to protect. The only escalation-relevant window is "tasks start while the dialog is open", which a fresh count read handles without ceiling semantics. The precision machinery is also where the bugs live (this P1 and the per-session undercount below). Please choose: return to the minimal boolean + count-display design (which also makes the P1 fix trivial), or keep the ceiling mechanism with a written rationale for what it protects that a fresh re-read does not.

P2 — Download stall/failure leaves no in-app recovery path

checkForUpdates short-circuits on downloading/downloaded (app-update-service.ts:256), the sidebar button is disabled for available/downloading (session-sidebar-nav.tsx:116), and the click is a no-op in those states (app-shell.tsx:438-439). The old error/available click-triggered downloadUpdate() retry was removed. A download that stalls without an error event (hung socket/proxy) permanently freezes the surface until restart, and a failed auto-download is silent with a misleading "Update available" title. (Confirmed independently by 3 of 5 perspectives.)

P2 — installUpdate returns {ok:true} even when no install will occur

BaseUpdater.install reports failure only via dispatchError, never by throwing (BaseUpdater.js:13-27,51-53), and on macOS MacUpdater.quitAndInstall is fire-and-forget (MacUpdater.js:240-256) — a Squirrel-side failure (code-sign validation, proxy fetch) surfaces only as a forwarded 'error' event, after the IPC reply already returned ok. The renderer toasts only on kind === 'failed', so the user confirms interrupting work and nothing happens, silently. Suggest an installing status that maps a subsequent 'error' to install_failed.

P2 — Merge conflict with current main requires a semantic decision

git merge-tree shows a real conflict in app-lifecycle.ts: main (#1994) deleted project-startup-migration.ts (runProjectStartupMigration has zero references on main) while the PR head still calls it at app-lifecycle.ts:175; mechanical resolution produces TS2305. Rebase must decide whether to drop the migration call, then re-run desktop typecheck/build.

P3 (non-blocking)

  • The boot.ts getActiveTaskCount composition (the PR's headline invariant) has no integration test — this is why the P1 slipped through.
  • Same-session concurrent turns share session:{id} key → the confirmation count understates streams killed (runtime-kernel.ts:166 explicitly supports concurrent runs; sessions:send has no idle gate).
  • MAKA_UPDATE_MOCK_STATE=available is now a dead-end (mock publishes available once, install requires downloaded, button disabled).
  • Dead params platform/arch (declared app-update-service.ts:59-60, passed boot.ts:641-642, never read).
  • Install authorization is renderer-trusted consent, not a main-side security boundary — acceptable given sandboxing, worth one sentence in the PR body.

Gate: FAIL (P1 open). The P1 must be fixed or explicitly narrowed before merge; P2s need handling or a written deferral with reason.

@Astro-Han

Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor
中文说明

关于 task-aware 的设计,我有一点个人的观察,供你参考:issue #1931 原意是一个布尔判断 + 安装前确认,现在实现成了"精确计数 + 授权上限 + 重新确认"的状态机。确认后安装是立即执行的,这套机制实际保护的窗口很小,复杂度却主要集中在计数逻辑上——P1 的 shell 任务漏计也源于此。

只是建议,你可以按自己的判断来:如果回归到最小实现(布尔 + 并入 shellRuns.liveCount()),P1 和复杂度都会一起消掉;如果保留现有机制,也完全没问题,方便的话在 PR 里说明一下 ceiling 的价值即可。两条路都支持。

@me2seeks

Copy link
Copy Markdown
ContributorAuthor

感谢建议,这个分析很有帮助。我重新顺着安装时序看了一遍,ceiling 实际保护的主要是确认框打开期间“任务数量增加”这一小段,而且也无法识别数量不变时的任务替换,确实不值得为此保留整套计数状态机。

我会回到 issue 的最小方案:使用 boolean consent,把 shellRuns.liveCount() 纳入活动判断,并移除精确计数、ceiling 和重复确认逻辑。接下来会按这个方向修复,也会一起处理其余 findings。

@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 0c0eaf4 to 2428c06CompareAugust 3, 2026 15:13
@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 2428c06 to fc1991dCompareAugust 3, 2026 15:24

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The two blockers from the previous round are genuinely fixed: hasInterruptibleUpdateWork is now a 17-line boolean union of session / Automation / shell-run activity, and the counting-plus-ceiling state machine (activityKey plumbing, taskLeaseCounts, escalation loop) is gone. Download stall/failure now has a real recovery path via retryUpdateDownload with cancellation-token semantics. CI is green and the branch merges cleanly with current main.

I re-reviewed the head through three independent perspectives (lifecycle correctness, design minimality, test quality); all three came back with no P0/P1. The main-process half reads as close to minimal: one timer, one boolean, three two-line read-only accessors, with the injected clock, operation-tagged error states, and mock fixture all load-bearing.

Four non-blocking items below. None gate this PR — fix, defer, or close them as you see fit.

P2 — installing has no exit when Squirrel stalls silently. On macOS with autoInstallOnAppQuit = false, update-downloaded is dispatched when the local proxy server starts, so squirrelDownloadedUpdate is still false at click time and MacUpdater.quitAndInstall takes the else branch (MacUpdater.js:240-256): it registers a listener, kicks off an async checkForUpdates(), and returns without quitting. Status stays installing, which short-circuits both checkForUpdates (app-update-service.ts:304) and retryUpdateDownload (:351), and updateReminder (app-shell.tsx:416-420) excludes installing, so the button disappears. A genuine Squirrel error is fine — MacUpdater.js:18-21 forwards it and you map it to error/install — so this is only the silent-hang path. Smallest fix: stop short-circuiting on installing and fall back to downloaded on the next scheduled check.

P3 — openUpdateDownload is now dead end to end. The sidebar button only renders for available | downloading | downloaded | error-with-latestVersion, and all four are routed to install or retry, so the else branch at app-shell.tsx:483 is unreachable — along with the preload method, the app:openUpdateDownload handler (app-ipc-main.ts:84), and the service method. Either delete the chain or wire it to a state that can actually reach it.

P3 — Retry is unguarded where install is guarded.isDisabled was removed from the sidebar button, and a click during downloading cancels the in-flight download and restarts from 0%. Install has updateInstallInFlightRef; retry has no equivalent, so the one destructive action in the surface is the least protected.

P3 — The renderer half has no tests.app-update-install.ts is covered as a pure function, but the wiring is not: the receivedPush race guard, the click-branch routing, and the double-click guard would all survive being reverted. AGENTS.md asks for one representative Playwright journey when a flow crosses renderer and main, and the deterministic seam (MAKA_UPDATE_MOCK_STATE, with installUpdate returning ok without quitting in mock mode) already exists and is unused.

中文说明

已 approve。上一轮的两个阻塞项确实修掉了:活动判断回到 17 行的布尔并集,计数 + ceiling 那套状态机整体删除,下载停滞也有了真实的取消重下路径。CI 全绿,与 main 无冲突。

我从生命周期正确性、设计最小性、测试质量三个独立视角重新过了一遍,都没有 P0/P1。主进程这一半基本已是最小解。

上面四条都不阻塞合并,是否处理你自己判断。P2 是 macOS 上 Squirrel 静默挂起时 installing 没有出口(真正报错的路径你已经兜住了);两个 P3 分别是 openUpdateDownload 整条链路已不可达,以及 downloading 时点按钮会取消并从 0 重下且无保护;最后一个 P3 是 renderer 那一半没有测试,而确定性 mock seam 已经存在。

@Astro-Han
Astro-Han merged commit b5377bd into apache:mainAug 4, 2026
11 checks passed
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.

feat(update): auto-download, main-process checks, and task-aware install

2 participants

@me2seeks@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(desktop): make app updates background and task-aware by me2seeks · Pull Request #1992 · apache/maka · GitHub
Skip to content

feat(desktop): make app updates background and task-aware - #1992

Merged
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates
Aug 4, 2026
Merged

feat(desktop): make app updates background and task-aware#1992
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates

Conversation

@me2seeks

Copy link
Copy Markdown
Contributor

Summary

Update discovery used to depend on the renderer staying alive, and installing a downloaded update had no view of work still running in the main process.

This moves the updater lifecycle into Electron main: the first check runs about 10 seconds after app readiness, later checks run four hours after the previous check settles, and electron-updater downloads an available release automatically. The renderer now only projects the current status and listens for pushes; the existing sidebar button remains the only update surface, with no download-complete popup and no install-on-quit behavior.

Restart/install is guarded by a main-owned activity snapshot. Session turns are counted by stable task identity, and Automation scheduler activity is merged with stream activity without double-counting the same fire. The first install request authorizes interrupting zero tasks. After the user confirms the reported count, the follow-up request is limited to that count; if more tasks start while the dialog is open, the new count is shown for confirmation instead of silently widening the authorization.

Closes#1931

中文说明

本 PR 将更新检查和自动下载的生命周期移到 Electron 主进程:应用 ready 约 10 秒后首次检查,之后在上一次检查结束 4 小时后再次检查。渲染进程只读取状态并订阅推送;下载完成后仍只通过现有侧栏按钮提示,不弹窗,也不会在退出时自动安装。

重启安装前,主进程会合并会话 turn 与 Automation scheduler 的活动状态,并按稳定任务标识去重。首次请求不允许中断任何任务;用户确认 N 个任务后,后续请求最多只能中断这 N 个任务。如果确认期间又启动了任务,会显示新的数量并再次询问,而不是扩大原授权。

Verification

  • Node 24: @maka/runtime full suite — 2,762 passed, 3 skipped
  • Node 24: @maka/desktop full main suite — 1,354 passed
  • Node 24: @maka/ui full suite — 256 passed
  • npm run typecheck
  • npm run lint -- --error-on-warnings
  • npm run format:check
  • npm --workspace @maka/desktop run build
  • Desktop console, accessibility, and copy checks
  • Knip for apps/desktop and packages/ui
  • Electron fixture: main-process delayed status reached downloaded, the sidebar exposed Restart without a completion popup, and the no-active-work path proceeded without confirmation

Review focus

  • SessionActivityRegistry.activeTaskCount() and AutomationScheduler.inFlightAutomationIds() form the main-process activity snapshot; the shared automation key prevents a scheduler fire and its stream lease from counting twice.
  • maxInterruptibleActiveTasks is an authorization ceiling, not a stale informational count. A higher current count returns to the confirmation flow.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review findings (5 independent deepseek-v4-flash perspectives)

The direction (updater lifecycle into main, auto-download, single-button install) is right, and the wiring against electron-updater@6.8.9 was verified correct (autoDownload/autoInstallOnAppQuit semantics, macOS delayed-install, cache sha512 re-validation, single-instance lock, fail-closed snapshot). Two blockers and several scope questions below.

P1 — Background shell/PTY tasks are invisible to the activity snapshot: an install silently kills them with no confirmation

  • Predicted failure: a user runs a long run_in_background=true task (build/download/server — the feature's typical use), the turn ends, the user clicks Restart: getActiveTaskCount() returns 0 → the first request {maxInterruptibleActiveTasks: 0} passes → quitAndInstall(false, true)shellRuns.terminateAll() kills the process tree. No dialog ever appears. This is exactly the "no work interruption without confirmation" scenario the PR claims to eliminate.
  • Evidence: snapshot composes only two sources — boot.ts:648-650 (sessionActivities.activeTaskCount(inFlightAutomationIds().map(automationActivityKey))). Background shell runs hold no activity lease (all three reserve( call sites are turn-scoped and unrelated to shell-run-manager). But background tasks do survive the turn: shell-run-manager.ts:72LifecycleCause = 'timeout' | 'cancel' | 'shutdown' has no turn-end cause; runBackgroundBash (:219) doesn't join live.finished. And they are killed on the update quit: app-lifecycle.ts:398shellRuns.terminateAll() runs in the shutdown path on both platforms (Windows: quitAndInstallapp.quit()before-quit → quit coordinator; macOS: Squirrel [NSApp terminate] → Electron overrides -terminate:Browser::QuitHandleBeforeQuit → same coordinator). liveCount()/livePtyCount() accessors already exist (shell-run-manager.ts:567-571).
  • Fix (pick one): (a) fold shellRuns.liveCount() into the snapshot (one line + a test), or (b) explicitly narrow scope — state in the PR body and copy that background shell tasks are NOT protected by the confirmation.

P2 — Scope: the task-aware mechanism grew well beyond the issue's minimal design

Issue #1931 asks for "inject hasActiveTasks" — a boolean composed from existing main-process activity, plus a confirmation dialog; "if nothing is running, install immediately". The PR instead built a full authorization machine: precise counting with stable task-identity dedup (activityKey plumbing ×3, taskLeaseCounts parallel map, additionalTaskKeys, inFlightAutomationIds() lifecycle), a maxInterruptibleActiveTasks ceiling ("first request authorizes zero; follow-ups limited to that count"), and an escalation loop re-confirming when the count grows.

From first principles: after the user confirms, installUpdate calls quitAndInstall in the same synchronous tick — there is no "afterwards" window for a ceiling to protect. The only escalation-relevant window is "tasks start while the dialog is open", which a fresh count read handles without ceiling semantics. The precision machinery is also where the bugs live (this P1 and the per-session undercount below). Please choose: return to the minimal boolean + count-display design (which also makes the P1 fix trivial), or keep the ceiling mechanism with a written rationale for what it protects that a fresh re-read does not.

P2 — Download stall/failure leaves no in-app recovery path

checkForUpdates short-circuits on downloading/downloaded (app-update-service.ts:256), the sidebar button is disabled for available/downloading (session-sidebar-nav.tsx:116), and the click is a no-op in those states (app-shell.tsx:438-439). The old error/available click-triggered downloadUpdate() retry was removed. A download that stalls without an error event (hung socket/proxy) permanently freezes the surface until restart, and a failed auto-download is silent with a misleading "Update available" title. (Confirmed independently by 3 of 5 perspectives.)

P2 — installUpdate returns {ok:true} even when no install will occur

BaseUpdater.install reports failure only via dispatchError, never by throwing (BaseUpdater.js:13-27,51-53), and on macOS MacUpdater.quitAndInstall is fire-and-forget (MacUpdater.js:240-256) — a Squirrel-side failure (code-sign validation, proxy fetch) surfaces only as a forwarded 'error' event, after the IPC reply already returned ok. The renderer toasts only on kind === 'failed', so the user confirms interrupting work and nothing happens, silently. Suggest an installing status that maps a subsequent 'error' to install_failed.

P2 — Merge conflict with current main requires a semantic decision

git merge-tree shows a real conflict in app-lifecycle.ts: main (#1994) deleted project-startup-migration.ts (runProjectStartupMigration has zero references on main) while the PR head still calls it at app-lifecycle.ts:175; mechanical resolution produces TS2305. Rebase must decide whether to drop the migration call, then re-run desktop typecheck/build.

P3 (non-blocking)

  • The boot.ts getActiveTaskCount composition (the PR's headline invariant) has no integration test — this is why the P1 slipped through.
  • Same-session concurrent turns share session:{id} key → the confirmation count understates streams killed (runtime-kernel.ts:166 explicitly supports concurrent runs; sessions:send has no idle gate).
  • MAKA_UPDATE_MOCK_STATE=available is now a dead-end (mock publishes available once, install requires downloaded, button disabled).
  • Dead params platform/arch (declared app-update-service.ts:59-60, passed boot.ts:641-642, never read).
  • Install authorization is renderer-trusted consent, not a main-side security boundary — acceptable given sandboxing, worth one sentence in the PR body.

Gate: FAIL (P1 open). The P1 must be fixed or explicitly narrowed before merge; P2s need handling or a written deferral with reason.

@Astro-Han

Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor
中文说明

关于 task-aware 的设计,我有一点个人的观察,供你参考:issue #1931 原意是一个布尔判断 + 安装前确认,现在实现成了"精确计数 + 授权上限 + 重新确认"的状态机。确认后安装是立即执行的,这套机制实际保护的窗口很小,复杂度却主要集中在计数逻辑上——P1 的 shell 任务漏计也源于此。

只是建议,你可以按自己的判断来:如果回归到最小实现(布尔 + 并入 shellRuns.liveCount()),P1 和复杂度都会一起消掉;如果保留现有机制,也完全没问题,方便的话在 PR 里说明一下 ceiling 的价值即可。两条路都支持。

@me2seeks

Copy link
Copy Markdown
ContributorAuthor

感谢建议,这个分析很有帮助。我重新顺着安装时序看了一遍,ceiling 实际保护的主要是确认框打开期间“任务数量增加”这一小段,而且也无法识别数量不变时的任务替换,确实不值得为此保留整套计数状态机。

我会回到 issue 的最小方案:使用 boolean consent,把 shellRuns.liveCount() 纳入活动判断,并移除精确计数、ceiling 和重复确认逻辑。接下来会按这个方向修复,也会一起处理其余 findings。

@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 0c0eaf4 to 2428c06CompareAugust 3, 2026 15:13
@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 2428c06 to fc1991dCompareAugust 3, 2026 15:24

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The two blockers from the previous round are genuinely fixed: hasInterruptibleUpdateWork is now a 17-line boolean union of session / Automation / shell-run activity, and the counting-plus-ceiling state machine (activityKey plumbing, taskLeaseCounts, escalation loop) is gone. Download stall/failure now has a real recovery path via retryUpdateDownload with cancellation-token semantics. CI is green and the branch merges cleanly with current main.

I re-reviewed the head through three independent perspectives (lifecycle correctness, design minimality, test quality); all three came back with no P0/P1. The main-process half reads as close to minimal: one timer, one boolean, three two-line read-only accessors, with the injected clock, operation-tagged error states, and mock fixture all load-bearing.

Four non-blocking items below. None gate this PR — fix, defer, or close them as you see fit.

P2 — installing has no exit when Squirrel stalls silently. On macOS with autoInstallOnAppQuit = false, update-downloaded is dispatched when the local proxy server starts, so squirrelDownloadedUpdate is still false at click time and MacUpdater.quitAndInstall takes the else branch (MacUpdater.js:240-256): it registers a listener, kicks off an async checkForUpdates(), and returns without quitting. Status stays installing, which short-circuits both checkForUpdates (app-update-service.ts:304) and retryUpdateDownload (:351), and updateReminder (app-shell.tsx:416-420) excludes installing, so the button disappears. A genuine Squirrel error is fine — MacUpdater.js:18-21 forwards it and you map it to error/install — so this is only the silent-hang path. Smallest fix: stop short-circuiting on installing and fall back to downloaded on the next scheduled check.

P3 — openUpdateDownload is now dead end to end. The sidebar button only renders for available | downloading | downloaded | error-with-latestVersion, and all four are routed to install or retry, so the else branch at app-shell.tsx:483 is unreachable — along with the preload method, the app:openUpdateDownload handler (app-ipc-main.ts:84), and the service method. Either delete the chain or wire it to a state that can actually reach it.

P3 — Retry is unguarded where install is guarded.isDisabled was removed from the sidebar button, and a click during downloading cancels the in-flight download and restarts from 0%. Install has updateInstallInFlightRef; retry has no equivalent, so the one destructive action in the surface is the least protected.

P3 — The renderer half has no tests.app-update-install.ts is covered as a pure function, but the wiring is not: the receivedPush race guard, the click-branch routing, and the double-click guard would all survive being reverted. AGENTS.md asks for one representative Playwright journey when a flow crosses renderer and main, and the deterministic seam (MAKA_UPDATE_MOCK_STATE, with installUpdate returning ok without quitting in mock mode) already exists and is unused.

中文说明

已 approve。上一轮的两个阻塞项确实修掉了:活动判断回到 17 行的布尔并集,计数 + ceiling 那套状态机整体删除,下载停滞也有了真实的取消重下路径。CI 全绿,与 main 无冲突。

我从生命周期正确性、设计最小性、测试质量三个独立视角重新过了一遍,都没有 P0/P1。主进程这一半基本已是最小解。

上面四条都不阻塞合并,是否处理你自己判断。P2 是 macOS 上 Squirrel 静默挂起时 installing 没有出口(真正报错的路径你已经兜住了);两个 P3 分别是 openUpdateDownload 整条链路已不可达,以及 downloading 时点按钮会取消并从 0 重下且无保护;最后一个 P3 是 renderer 那一半没有测试,而确定性 mock seam 已经存在。

@Astro-Han
Astro-Han merged commit b5377bd into apache:mainAug 4, 2026
11 checks passed
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.

feat(update): auto-download, main-process checks, and task-aware install

2 participants

@me2seeks@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(desktop): make app updates background and task-aware by me2seeks · Pull Request #1992 · apache/maka · GitHub
Skip to content

feat(desktop): make app updates background and task-aware - #1992

Merged
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates
Aug 4, 2026
Merged

feat(desktop): make app updates background and task-aware#1992
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates

Conversation

@me2seeks

Copy link
Copy Markdown
Contributor

Summary

Update discovery used to depend on the renderer staying alive, and installing a downloaded update had no view of work still running in the main process.

This moves the updater lifecycle into Electron main: the first check runs about 10 seconds after app readiness, later checks run four hours after the previous check settles, and electron-updater downloads an available release automatically. The renderer now only projects the current status and listens for pushes; the existing sidebar button remains the only update surface, with no download-complete popup and no install-on-quit behavior.

Restart/install is guarded by a main-owned activity snapshot. Session turns are counted by stable task identity, and Automation scheduler activity is merged with stream activity without double-counting the same fire. The first install request authorizes interrupting zero tasks. After the user confirms the reported count, the follow-up request is limited to that count; if more tasks start while the dialog is open, the new count is shown for confirmation instead of silently widening the authorization.

Closes#1931

中文说明

本 PR 将更新检查和自动下载的生命周期移到 Electron 主进程:应用 ready 约 10 秒后首次检查,之后在上一次检查结束 4 小时后再次检查。渲染进程只读取状态并订阅推送;下载完成后仍只通过现有侧栏按钮提示,不弹窗,也不会在退出时自动安装。

重启安装前,主进程会合并会话 turn 与 Automation scheduler 的活动状态,并按稳定任务标识去重。首次请求不允许中断任何任务;用户确认 N 个任务后,后续请求最多只能中断这 N 个任务。如果确认期间又启动了任务,会显示新的数量并再次询问,而不是扩大原授权。

Verification

  • Node 24: @maka/runtime full suite — 2,762 passed, 3 skipped
  • Node 24: @maka/desktop full main suite — 1,354 passed
  • Node 24: @maka/ui full suite — 256 passed
  • npm run typecheck
  • npm run lint -- --error-on-warnings
  • npm run format:check
  • npm --workspace @maka/desktop run build
  • Desktop console, accessibility, and copy checks
  • Knip for apps/desktop and packages/ui
  • Electron fixture: main-process delayed status reached downloaded, the sidebar exposed Restart without a completion popup, and the no-active-work path proceeded without confirmation

Review focus

  • SessionActivityRegistry.activeTaskCount() and AutomationScheduler.inFlightAutomationIds() form the main-process activity snapshot; the shared automation key prevents a scheduler fire and its stream lease from counting twice.
  • maxInterruptibleActiveTasks is an authorization ceiling, not a stale informational count. A higher current count returns to the confirmation flow.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review findings (5 independent deepseek-v4-flash perspectives)

The direction (updater lifecycle into main, auto-download, single-button install) is right, and the wiring against electron-updater@6.8.9 was verified correct (autoDownload/autoInstallOnAppQuit semantics, macOS delayed-install, cache sha512 re-validation, single-instance lock, fail-closed snapshot). Two blockers and several scope questions below.

P1 — Background shell/PTY tasks are invisible to the activity snapshot: an install silently kills them with no confirmation

  • Predicted failure: a user runs a long run_in_background=true task (build/download/server — the feature's typical use), the turn ends, the user clicks Restart: getActiveTaskCount() returns 0 → the first request {maxInterruptibleActiveTasks: 0} passes → quitAndInstall(false, true)shellRuns.terminateAll() kills the process tree. No dialog ever appears. This is exactly the "no work interruption without confirmation" scenario the PR claims to eliminate.
  • Evidence: snapshot composes only two sources — boot.ts:648-650 (sessionActivities.activeTaskCount(inFlightAutomationIds().map(automationActivityKey))). Background shell runs hold no activity lease (all three reserve( call sites are turn-scoped and unrelated to shell-run-manager). But background tasks do survive the turn: shell-run-manager.ts:72LifecycleCause = 'timeout' | 'cancel' | 'shutdown' has no turn-end cause; runBackgroundBash (:219) doesn't join live.finished. And they are killed on the update quit: app-lifecycle.ts:398shellRuns.terminateAll() runs in the shutdown path on both platforms (Windows: quitAndInstallapp.quit()before-quit → quit coordinator; macOS: Squirrel [NSApp terminate] → Electron overrides -terminate:Browser::QuitHandleBeforeQuit → same coordinator). liveCount()/livePtyCount() accessors already exist (shell-run-manager.ts:567-571).
  • Fix (pick one): (a) fold shellRuns.liveCount() into the snapshot (one line + a test), or (b) explicitly narrow scope — state in the PR body and copy that background shell tasks are NOT protected by the confirmation.

P2 — Scope: the task-aware mechanism grew well beyond the issue's minimal design

Issue #1931 asks for "inject hasActiveTasks" — a boolean composed from existing main-process activity, plus a confirmation dialog; "if nothing is running, install immediately". The PR instead built a full authorization machine: precise counting with stable task-identity dedup (activityKey plumbing ×3, taskLeaseCounts parallel map, additionalTaskKeys, inFlightAutomationIds() lifecycle), a maxInterruptibleActiveTasks ceiling ("first request authorizes zero; follow-ups limited to that count"), and an escalation loop re-confirming when the count grows.

From first principles: after the user confirms, installUpdate calls quitAndInstall in the same synchronous tick — there is no "afterwards" window for a ceiling to protect. The only escalation-relevant window is "tasks start while the dialog is open", which a fresh count read handles without ceiling semantics. The precision machinery is also where the bugs live (this P1 and the per-session undercount below). Please choose: return to the minimal boolean + count-display design (which also makes the P1 fix trivial), or keep the ceiling mechanism with a written rationale for what it protects that a fresh re-read does not.

P2 — Download stall/failure leaves no in-app recovery path

checkForUpdates short-circuits on downloading/downloaded (app-update-service.ts:256), the sidebar button is disabled for available/downloading (session-sidebar-nav.tsx:116), and the click is a no-op in those states (app-shell.tsx:438-439). The old error/available click-triggered downloadUpdate() retry was removed. A download that stalls without an error event (hung socket/proxy) permanently freezes the surface until restart, and a failed auto-download is silent with a misleading "Update available" title. (Confirmed independently by 3 of 5 perspectives.)

P2 — installUpdate returns {ok:true} even when no install will occur

BaseUpdater.install reports failure only via dispatchError, never by throwing (BaseUpdater.js:13-27,51-53), and on macOS MacUpdater.quitAndInstall is fire-and-forget (MacUpdater.js:240-256) — a Squirrel-side failure (code-sign validation, proxy fetch) surfaces only as a forwarded 'error' event, after the IPC reply already returned ok. The renderer toasts only on kind === 'failed', so the user confirms interrupting work and nothing happens, silently. Suggest an installing status that maps a subsequent 'error' to install_failed.

P2 — Merge conflict with current main requires a semantic decision

git merge-tree shows a real conflict in app-lifecycle.ts: main (#1994) deleted project-startup-migration.ts (runProjectStartupMigration has zero references on main) while the PR head still calls it at app-lifecycle.ts:175; mechanical resolution produces TS2305. Rebase must decide whether to drop the migration call, then re-run desktop typecheck/build.

P3 (non-blocking)

  • The boot.ts getActiveTaskCount composition (the PR's headline invariant) has no integration test — this is why the P1 slipped through.
  • Same-session concurrent turns share session:{id} key → the confirmation count understates streams killed (runtime-kernel.ts:166 explicitly supports concurrent runs; sessions:send has no idle gate).
  • MAKA_UPDATE_MOCK_STATE=available is now a dead-end (mock publishes available once, install requires downloaded, button disabled).
  • Dead params platform/arch (declared app-update-service.ts:59-60, passed boot.ts:641-642, never read).
  • Install authorization is renderer-trusted consent, not a main-side security boundary — acceptable given sandboxing, worth one sentence in the PR body.

Gate: FAIL (P1 open). The P1 must be fixed or explicitly narrowed before merge; P2s need handling or a written deferral with reason.

@Astro-Han

Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor
中文说明

关于 task-aware 的设计,我有一点个人的观察,供你参考:issue #1931 原意是一个布尔判断 + 安装前确认,现在实现成了"精确计数 + 授权上限 + 重新确认"的状态机。确认后安装是立即执行的,这套机制实际保护的窗口很小,复杂度却主要集中在计数逻辑上——P1 的 shell 任务漏计也源于此。

只是建议,你可以按自己的判断来:如果回归到最小实现(布尔 + 并入 shellRuns.liveCount()),P1 和复杂度都会一起消掉;如果保留现有机制,也完全没问题,方便的话在 PR 里说明一下 ceiling 的价值即可。两条路都支持。

@me2seeks

Copy link
Copy Markdown
ContributorAuthor

感谢建议,这个分析很有帮助。我重新顺着安装时序看了一遍,ceiling 实际保护的主要是确认框打开期间“任务数量增加”这一小段,而且也无法识别数量不变时的任务替换,确实不值得为此保留整套计数状态机。

我会回到 issue 的最小方案:使用 boolean consent,把 shellRuns.liveCount() 纳入活动判断,并移除精确计数、ceiling 和重复确认逻辑。接下来会按这个方向修复,也会一起处理其余 findings。

@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 0c0eaf4 to 2428c06CompareAugust 3, 2026 15:13
@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 2428c06 to fc1991dCompareAugust 3, 2026 15:24

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The two blockers from the previous round are genuinely fixed: hasInterruptibleUpdateWork is now a 17-line boolean union of session / Automation / shell-run activity, and the counting-plus-ceiling state machine (activityKey plumbing, taskLeaseCounts, escalation loop) is gone. Download stall/failure now has a real recovery path via retryUpdateDownload with cancellation-token semantics. CI is green and the branch merges cleanly with current main.

I re-reviewed the head through three independent perspectives (lifecycle correctness, design minimality, test quality); all three came back with no P0/P1. The main-process half reads as close to minimal: one timer, one boolean, three two-line read-only accessors, with the injected clock, operation-tagged error states, and mock fixture all load-bearing.

Four non-blocking items below. None gate this PR — fix, defer, or close them as you see fit.

P2 — installing has no exit when Squirrel stalls silently. On macOS with autoInstallOnAppQuit = false, update-downloaded is dispatched when the local proxy server starts, so squirrelDownloadedUpdate is still false at click time and MacUpdater.quitAndInstall takes the else branch (MacUpdater.js:240-256): it registers a listener, kicks off an async checkForUpdates(), and returns without quitting. Status stays installing, which short-circuits both checkForUpdates (app-update-service.ts:304) and retryUpdateDownload (:351), and updateReminder (app-shell.tsx:416-420) excludes installing, so the button disappears. A genuine Squirrel error is fine — MacUpdater.js:18-21 forwards it and you map it to error/install — so this is only the silent-hang path. Smallest fix: stop short-circuiting on installing and fall back to downloaded on the next scheduled check.

P3 — openUpdateDownload is now dead end to end. The sidebar button only renders for available | downloading | downloaded | error-with-latestVersion, and all four are routed to install or retry, so the else branch at app-shell.tsx:483 is unreachable — along with the preload method, the app:openUpdateDownload handler (app-ipc-main.ts:84), and the service method. Either delete the chain or wire it to a state that can actually reach it.

P3 — Retry is unguarded where install is guarded.isDisabled was removed from the sidebar button, and a click during downloading cancels the in-flight download and restarts from 0%. Install has updateInstallInFlightRef; retry has no equivalent, so the one destructive action in the surface is the least protected.

P3 — The renderer half has no tests.app-update-install.ts is covered as a pure function, but the wiring is not: the receivedPush race guard, the click-branch routing, and the double-click guard would all survive being reverted. AGENTS.md asks for one representative Playwright journey when a flow crosses renderer and main, and the deterministic seam (MAKA_UPDATE_MOCK_STATE, with installUpdate returning ok without quitting in mock mode) already exists and is unused.

中文说明

已 approve。上一轮的两个阻塞项确实修掉了:活动判断回到 17 行的布尔并集,计数 + ceiling 那套状态机整体删除,下载停滞也有了真实的取消重下路径。CI 全绿,与 main 无冲突。

我从生命周期正确性、设计最小性、测试质量三个独立视角重新过了一遍,都没有 P0/P1。主进程这一半基本已是最小解。

上面四条都不阻塞合并,是否处理你自己判断。P2 是 macOS 上 Squirrel 静默挂起时 installing 没有出口(真正报错的路径你已经兜住了);两个 P3 分别是 openUpdateDownload 整条链路已不可达,以及 downloading 时点按钮会取消并从 0 重下且无保护;最后一个 P3 是 renderer 那一半没有测试,而确定性 mock seam 已经存在。

@Astro-Han
Astro-Han merged commit b5377bd into apache:mainAug 4, 2026
11 checks passed
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.

feat(update): auto-download, main-process checks, and task-aware install

2 participants

@me2seeks@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(desktop): make app updates background and task-aware by me2seeks · Pull Request #1992 · apache/maka · GitHub
Skip to content

feat(desktop): make app updates background and task-aware - #1992

Merged
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates
Aug 4, 2026
Merged

feat(desktop): make app updates background and task-aware#1992
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates

Conversation

@me2seeks

Copy link
Copy Markdown
Contributor

Summary

Update discovery used to depend on the renderer staying alive, and installing a downloaded update had no view of work still running in the main process.

This moves the updater lifecycle into Electron main: the first check runs about 10 seconds after app readiness, later checks run four hours after the previous check settles, and electron-updater downloads an available release automatically. The renderer now only projects the current status and listens for pushes; the existing sidebar button remains the only update surface, with no download-complete popup and no install-on-quit behavior.

Restart/install is guarded by a main-owned activity snapshot. Session turns are counted by stable task identity, and Automation scheduler activity is merged with stream activity without double-counting the same fire. The first install request authorizes interrupting zero tasks. After the user confirms the reported count, the follow-up request is limited to that count; if more tasks start while the dialog is open, the new count is shown for confirmation instead of silently widening the authorization.

Closes#1931

中文说明

本 PR 将更新检查和自动下载的生命周期移到 Electron 主进程:应用 ready 约 10 秒后首次检查,之后在上一次检查结束 4 小时后再次检查。渲染进程只读取状态并订阅推送;下载完成后仍只通过现有侧栏按钮提示,不弹窗,也不会在退出时自动安装。

重启安装前,主进程会合并会话 turn 与 Automation scheduler 的活动状态,并按稳定任务标识去重。首次请求不允许中断任何任务;用户确认 N 个任务后,后续请求最多只能中断这 N 个任务。如果确认期间又启动了任务,会显示新的数量并再次询问,而不是扩大原授权。

Verification

  • Node 24: @maka/runtime full suite — 2,762 passed, 3 skipped
  • Node 24: @maka/desktop full main suite — 1,354 passed
  • Node 24: @maka/ui full suite — 256 passed
  • npm run typecheck
  • npm run lint -- --error-on-warnings
  • npm run format:check
  • npm --workspace @maka/desktop run build
  • Desktop console, accessibility, and copy checks
  • Knip for apps/desktop and packages/ui
  • Electron fixture: main-process delayed status reached downloaded, the sidebar exposed Restart without a completion popup, and the no-active-work path proceeded without confirmation

Review focus

  • SessionActivityRegistry.activeTaskCount() and AutomationScheduler.inFlightAutomationIds() form the main-process activity snapshot; the shared automation key prevents a scheduler fire and its stream lease from counting twice.
  • maxInterruptibleActiveTasks is an authorization ceiling, not a stale informational count. A higher current count returns to the confirmation flow.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review findings (5 independent deepseek-v4-flash perspectives)

The direction (updater lifecycle into main, auto-download, single-button install) is right, and the wiring against electron-updater@6.8.9 was verified correct (autoDownload/autoInstallOnAppQuit semantics, macOS delayed-install, cache sha512 re-validation, single-instance lock, fail-closed snapshot). Two blockers and several scope questions below.

P1 — Background shell/PTY tasks are invisible to the activity snapshot: an install silently kills them with no confirmation

  • Predicted failure: a user runs a long run_in_background=true task (build/download/server — the feature's typical use), the turn ends, the user clicks Restart: getActiveTaskCount() returns 0 → the first request {maxInterruptibleActiveTasks: 0} passes → quitAndInstall(false, true)shellRuns.terminateAll() kills the process tree. No dialog ever appears. This is exactly the "no work interruption without confirmation" scenario the PR claims to eliminate.
  • Evidence: snapshot composes only two sources — boot.ts:648-650 (sessionActivities.activeTaskCount(inFlightAutomationIds().map(automationActivityKey))). Background shell runs hold no activity lease (all three reserve( call sites are turn-scoped and unrelated to shell-run-manager). But background tasks do survive the turn: shell-run-manager.ts:72LifecycleCause = 'timeout' | 'cancel' | 'shutdown' has no turn-end cause; runBackgroundBash (:219) doesn't join live.finished. And they are killed on the update quit: app-lifecycle.ts:398shellRuns.terminateAll() runs in the shutdown path on both platforms (Windows: quitAndInstallapp.quit()before-quit → quit coordinator; macOS: Squirrel [NSApp terminate] → Electron overrides -terminate:Browser::QuitHandleBeforeQuit → same coordinator). liveCount()/livePtyCount() accessors already exist (shell-run-manager.ts:567-571).
  • Fix (pick one): (a) fold shellRuns.liveCount() into the snapshot (one line + a test), or (b) explicitly narrow scope — state in the PR body and copy that background shell tasks are NOT protected by the confirmation.

P2 — Scope: the task-aware mechanism grew well beyond the issue's minimal design

Issue #1931 asks for "inject hasActiveTasks" — a boolean composed from existing main-process activity, plus a confirmation dialog; "if nothing is running, install immediately". The PR instead built a full authorization machine: precise counting with stable task-identity dedup (activityKey plumbing ×3, taskLeaseCounts parallel map, additionalTaskKeys, inFlightAutomationIds() lifecycle), a maxInterruptibleActiveTasks ceiling ("first request authorizes zero; follow-ups limited to that count"), and an escalation loop re-confirming when the count grows.

From first principles: after the user confirms, installUpdate calls quitAndInstall in the same synchronous tick — there is no "afterwards" window for a ceiling to protect. The only escalation-relevant window is "tasks start while the dialog is open", which a fresh count read handles without ceiling semantics. The precision machinery is also where the bugs live (this P1 and the per-session undercount below). Please choose: return to the minimal boolean + count-display design (which also makes the P1 fix trivial), or keep the ceiling mechanism with a written rationale for what it protects that a fresh re-read does not.

P2 — Download stall/failure leaves no in-app recovery path

checkForUpdates short-circuits on downloading/downloaded (app-update-service.ts:256), the sidebar button is disabled for available/downloading (session-sidebar-nav.tsx:116), and the click is a no-op in those states (app-shell.tsx:438-439). The old error/available click-triggered downloadUpdate() retry was removed. A download that stalls without an error event (hung socket/proxy) permanently freezes the surface until restart, and a failed auto-download is silent with a misleading "Update available" title. (Confirmed independently by 3 of 5 perspectives.)

P2 — installUpdate returns {ok:true} even when no install will occur

BaseUpdater.install reports failure only via dispatchError, never by throwing (BaseUpdater.js:13-27,51-53), and on macOS MacUpdater.quitAndInstall is fire-and-forget (MacUpdater.js:240-256) — a Squirrel-side failure (code-sign validation, proxy fetch) surfaces only as a forwarded 'error' event, after the IPC reply already returned ok. The renderer toasts only on kind === 'failed', so the user confirms interrupting work and nothing happens, silently. Suggest an installing status that maps a subsequent 'error' to install_failed.

P2 — Merge conflict with current main requires a semantic decision

git merge-tree shows a real conflict in app-lifecycle.ts: main (#1994) deleted project-startup-migration.ts (runProjectStartupMigration has zero references on main) while the PR head still calls it at app-lifecycle.ts:175; mechanical resolution produces TS2305. Rebase must decide whether to drop the migration call, then re-run desktop typecheck/build.

P3 (non-blocking)

  • The boot.ts getActiveTaskCount composition (the PR's headline invariant) has no integration test — this is why the P1 slipped through.
  • Same-session concurrent turns share session:{id} key → the confirmation count understates streams killed (runtime-kernel.ts:166 explicitly supports concurrent runs; sessions:send has no idle gate).
  • MAKA_UPDATE_MOCK_STATE=available is now a dead-end (mock publishes available once, install requires downloaded, button disabled).
  • Dead params platform/arch (declared app-update-service.ts:59-60, passed boot.ts:641-642, never read).
  • Install authorization is renderer-trusted consent, not a main-side security boundary — acceptable given sandboxing, worth one sentence in the PR body.

Gate: FAIL (P1 open). The P1 must be fixed or explicitly narrowed before merge; P2s need handling or a written deferral with reason.

@Astro-Han

Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor
中文说明

关于 task-aware 的设计,我有一点个人的观察,供你参考:issue #1931 原意是一个布尔判断 + 安装前确认,现在实现成了"精确计数 + 授权上限 + 重新确认"的状态机。确认后安装是立即执行的,这套机制实际保护的窗口很小,复杂度却主要集中在计数逻辑上——P1 的 shell 任务漏计也源于此。

只是建议,你可以按自己的判断来:如果回归到最小实现(布尔 + 并入 shellRuns.liveCount()),P1 和复杂度都会一起消掉;如果保留现有机制,也完全没问题,方便的话在 PR 里说明一下 ceiling 的价值即可。两条路都支持。

@me2seeks

Copy link
Copy Markdown
ContributorAuthor

感谢建议,这个分析很有帮助。我重新顺着安装时序看了一遍,ceiling 实际保护的主要是确认框打开期间“任务数量增加”这一小段,而且也无法识别数量不变时的任务替换,确实不值得为此保留整套计数状态机。

我会回到 issue 的最小方案:使用 boolean consent,把 shellRuns.liveCount() 纳入活动判断,并移除精确计数、ceiling 和重复确认逻辑。接下来会按这个方向修复,也会一起处理其余 findings。

@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 0c0eaf4 to 2428c06CompareAugust 3, 2026 15:13
@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 2428c06 to fc1991dCompareAugust 3, 2026 15:24

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The two blockers from the previous round are genuinely fixed: hasInterruptibleUpdateWork is now a 17-line boolean union of session / Automation / shell-run activity, and the counting-plus-ceiling state machine (activityKey plumbing, taskLeaseCounts, escalation loop) is gone. Download stall/failure now has a real recovery path via retryUpdateDownload with cancellation-token semantics. CI is green and the branch merges cleanly with current main.

I re-reviewed the head through three independent perspectives (lifecycle correctness, design minimality, test quality); all three came back with no P0/P1. The main-process half reads as close to minimal: one timer, one boolean, three two-line read-only accessors, with the injected clock, operation-tagged error states, and mock fixture all load-bearing.

Four non-blocking items below. None gate this PR — fix, defer, or close them as you see fit.

P2 — installing has no exit when Squirrel stalls silently. On macOS with autoInstallOnAppQuit = false, update-downloaded is dispatched when the local proxy server starts, so squirrelDownloadedUpdate is still false at click time and MacUpdater.quitAndInstall takes the else branch (MacUpdater.js:240-256): it registers a listener, kicks off an async checkForUpdates(), and returns without quitting. Status stays installing, which short-circuits both checkForUpdates (app-update-service.ts:304) and retryUpdateDownload (:351), and updateReminder (app-shell.tsx:416-420) excludes installing, so the button disappears. A genuine Squirrel error is fine — MacUpdater.js:18-21 forwards it and you map it to error/install — so this is only the silent-hang path. Smallest fix: stop short-circuiting on installing and fall back to downloaded on the next scheduled check.

P3 — openUpdateDownload is now dead end to end. The sidebar button only renders for available | downloading | downloaded | error-with-latestVersion, and all four are routed to install or retry, so the else branch at app-shell.tsx:483 is unreachable — along with the preload method, the app:openUpdateDownload handler (app-ipc-main.ts:84), and the service method. Either delete the chain or wire it to a state that can actually reach it.

P3 — Retry is unguarded where install is guarded.isDisabled was removed from the sidebar button, and a click during downloading cancels the in-flight download and restarts from 0%. Install has updateInstallInFlightRef; retry has no equivalent, so the one destructive action in the surface is the least protected.

P3 — The renderer half has no tests.app-update-install.ts is covered as a pure function, but the wiring is not: the receivedPush race guard, the click-branch routing, and the double-click guard would all survive being reverted. AGENTS.md asks for one representative Playwright journey when a flow crosses renderer and main, and the deterministic seam (MAKA_UPDATE_MOCK_STATE, with installUpdate returning ok without quitting in mock mode) already exists and is unused.

中文说明

已 approve。上一轮的两个阻塞项确实修掉了:活动判断回到 17 行的布尔并集,计数 + ceiling 那套状态机整体删除,下载停滞也有了真实的取消重下路径。CI 全绿,与 main 无冲突。

我从生命周期正确性、设计最小性、测试质量三个独立视角重新过了一遍,都没有 P0/P1。主进程这一半基本已是最小解。

上面四条都不阻塞合并,是否处理你自己判断。P2 是 macOS 上 Squirrel 静默挂起时 installing 没有出口(真正报错的路径你已经兜住了);两个 P3 分别是 openUpdateDownload 整条链路已不可达,以及 downloading 时点按钮会取消并从 0 重下且无保护;最后一个 P3 是 renderer 那一半没有测试,而确定性 mock seam 已经存在。

@Astro-Han
Astro-Han merged commit b5377bd into apache:mainAug 4, 2026
11 checks passed
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.

feat(update): auto-download, main-process checks, and task-aware install

2 participants

@me2seeks@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(desktop): make app updates background and task-aware by me2seeks · Pull Request #1992 · apache/maka · GitHub
Skip to content

feat(desktop): make app updates background and task-aware - #1992

Merged
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates
Aug 4, 2026
Merged

feat(desktop): make app updates background and task-aware#1992
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates

Conversation

@me2seeks

Copy link
Copy Markdown
Contributor

Summary

Update discovery used to depend on the renderer staying alive, and installing a downloaded update had no view of work still running in the main process.

This moves the updater lifecycle into Electron main: the first check runs about 10 seconds after app readiness, later checks run four hours after the previous check settles, and electron-updater downloads an available release automatically. The renderer now only projects the current status and listens for pushes; the existing sidebar button remains the only update surface, with no download-complete popup and no install-on-quit behavior.

Restart/install is guarded by a main-owned activity snapshot. Session turns are counted by stable task identity, and Automation scheduler activity is merged with stream activity without double-counting the same fire. The first install request authorizes interrupting zero tasks. After the user confirms the reported count, the follow-up request is limited to that count; if more tasks start while the dialog is open, the new count is shown for confirmation instead of silently widening the authorization.

Closes#1931

中文说明

本 PR 将更新检查和自动下载的生命周期移到 Electron 主进程:应用 ready 约 10 秒后首次检查,之后在上一次检查结束 4 小时后再次检查。渲染进程只读取状态并订阅推送;下载完成后仍只通过现有侧栏按钮提示,不弹窗,也不会在退出时自动安装。

重启安装前,主进程会合并会话 turn 与 Automation scheduler 的活动状态,并按稳定任务标识去重。首次请求不允许中断任何任务;用户确认 N 个任务后,后续请求最多只能中断这 N 个任务。如果确认期间又启动了任务,会显示新的数量并再次询问,而不是扩大原授权。

Verification

  • Node 24: @maka/runtime full suite — 2,762 passed, 3 skipped
  • Node 24: @maka/desktop full main suite — 1,354 passed
  • Node 24: @maka/ui full suite — 256 passed
  • npm run typecheck
  • npm run lint -- --error-on-warnings
  • npm run format:check
  • npm --workspace @maka/desktop run build
  • Desktop console, accessibility, and copy checks
  • Knip for apps/desktop and packages/ui
  • Electron fixture: main-process delayed status reached downloaded, the sidebar exposed Restart without a completion popup, and the no-active-work path proceeded without confirmation

Review focus

  • SessionActivityRegistry.activeTaskCount() and AutomationScheduler.inFlightAutomationIds() form the main-process activity snapshot; the shared automation key prevents a scheduler fire and its stream lease from counting twice.
  • maxInterruptibleActiveTasks is an authorization ceiling, not a stale informational count. A higher current count returns to the confirmation flow.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review findings (5 independent deepseek-v4-flash perspectives)

The direction (updater lifecycle into main, auto-download, single-button install) is right, and the wiring against electron-updater@6.8.9 was verified correct (autoDownload/autoInstallOnAppQuit semantics, macOS delayed-install, cache sha512 re-validation, single-instance lock, fail-closed snapshot). Two blockers and several scope questions below.

P1 — Background shell/PTY tasks are invisible to the activity snapshot: an install silently kills them with no confirmation

  • Predicted failure: a user runs a long run_in_background=true task (build/download/server — the feature's typical use), the turn ends, the user clicks Restart: getActiveTaskCount() returns 0 → the first request {maxInterruptibleActiveTasks: 0} passes → quitAndInstall(false, true)shellRuns.terminateAll() kills the process tree. No dialog ever appears. This is exactly the "no work interruption without confirmation" scenario the PR claims to eliminate.
  • Evidence: snapshot composes only two sources — boot.ts:648-650 (sessionActivities.activeTaskCount(inFlightAutomationIds().map(automationActivityKey))). Background shell runs hold no activity lease (all three reserve( call sites are turn-scoped and unrelated to shell-run-manager). But background tasks do survive the turn: shell-run-manager.ts:72LifecycleCause = 'timeout' | 'cancel' | 'shutdown' has no turn-end cause; runBackgroundBash (:219) doesn't join live.finished. And they are killed on the update quit: app-lifecycle.ts:398shellRuns.terminateAll() runs in the shutdown path on both platforms (Windows: quitAndInstallapp.quit()before-quit → quit coordinator; macOS: Squirrel [NSApp terminate] → Electron overrides -terminate:Browser::QuitHandleBeforeQuit → same coordinator). liveCount()/livePtyCount() accessors already exist (shell-run-manager.ts:567-571).
  • Fix (pick one): (a) fold shellRuns.liveCount() into the snapshot (one line + a test), or (b) explicitly narrow scope — state in the PR body and copy that background shell tasks are NOT protected by the confirmation.

P2 — Scope: the task-aware mechanism grew well beyond the issue's minimal design

Issue #1931 asks for "inject hasActiveTasks" — a boolean composed from existing main-process activity, plus a confirmation dialog; "if nothing is running, install immediately". The PR instead built a full authorization machine: precise counting with stable task-identity dedup (activityKey plumbing ×3, taskLeaseCounts parallel map, additionalTaskKeys, inFlightAutomationIds() lifecycle), a maxInterruptibleActiveTasks ceiling ("first request authorizes zero; follow-ups limited to that count"), and an escalation loop re-confirming when the count grows.

From first principles: after the user confirms, installUpdate calls quitAndInstall in the same synchronous tick — there is no "afterwards" window for a ceiling to protect. The only escalation-relevant window is "tasks start while the dialog is open", which a fresh count read handles without ceiling semantics. The precision machinery is also where the bugs live (this P1 and the per-session undercount below). Please choose: return to the minimal boolean + count-display design (which also makes the P1 fix trivial), or keep the ceiling mechanism with a written rationale for what it protects that a fresh re-read does not.

P2 — Download stall/failure leaves no in-app recovery path

checkForUpdates short-circuits on downloading/downloaded (app-update-service.ts:256), the sidebar button is disabled for available/downloading (session-sidebar-nav.tsx:116), and the click is a no-op in those states (app-shell.tsx:438-439). The old error/available click-triggered downloadUpdate() retry was removed. A download that stalls without an error event (hung socket/proxy) permanently freezes the surface until restart, and a failed auto-download is silent with a misleading "Update available" title. (Confirmed independently by 3 of 5 perspectives.)

P2 — installUpdate returns {ok:true} even when no install will occur

BaseUpdater.install reports failure only via dispatchError, never by throwing (BaseUpdater.js:13-27,51-53), and on macOS MacUpdater.quitAndInstall is fire-and-forget (MacUpdater.js:240-256) — a Squirrel-side failure (code-sign validation, proxy fetch) surfaces only as a forwarded 'error' event, after the IPC reply already returned ok. The renderer toasts only on kind === 'failed', so the user confirms interrupting work and nothing happens, silently. Suggest an installing status that maps a subsequent 'error' to install_failed.

P2 — Merge conflict with current main requires a semantic decision

git merge-tree shows a real conflict in app-lifecycle.ts: main (#1994) deleted project-startup-migration.ts (runProjectStartupMigration has zero references on main) while the PR head still calls it at app-lifecycle.ts:175; mechanical resolution produces TS2305. Rebase must decide whether to drop the migration call, then re-run desktop typecheck/build.

P3 (non-blocking)

  • The boot.ts getActiveTaskCount composition (the PR's headline invariant) has no integration test — this is why the P1 slipped through.
  • Same-session concurrent turns share session:{id} key → the confirmation count understates streams killed (runtime-kernel.ts:166 explicitly supports concurrent runs; sessions:send has no idle gate).
  • MAKA_UPDATE_MOCK_STATE=available is now a dead-end (mock publishes available once, install requires downloaded, button disabled).
  • Dead params platform/arch (declared app-update-service.ts:59-60, passed boot.ts:641-642, never read).
  • Install authorization is renderer-trusted consent, not a main-side security boundary — acceptable given sandboxing, worth one sentence in the PR body.

Gate: FAIL (P1 open). The P1 must be fixed or explicitly narrowed before merge; P2s need handling or a written deferral with reason.

@Astro-Han

Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor
中文说明

关于 task-aware 的设计,我有一点个人的观察,供你参考:issue #1931 原意是一个布尔判断 + 安装前确认,现在实现成了"精确计数 + 授权上限 + 重新确认"的状态机。确认后安装是立即执行的,这套机制实际保护的窗口很小,复杂度却主要集中在计数逻辑上——P1 的 shell 任务漏计也源于此。

只是建议,你可以按自己的判断来:如果回归到最小实现(布尔 + 并入 shellRuns.liveCount()),P1 和复杂度都会一起消掉;如果保留现有机制,也完全没问题,方便的话在 PR 里说明一下 ceiling 的价值即可。两条路都支持。

@me2seeks

Copy link
Copy Markdown
ContributorAuthor

感谢建议,这个分析很有帮助。我重新顺着安装时序看了一遍,ceiling 实际保护的主要是确认框打开期间“任务数量增加”这一小段,而且也无法识别数量不变时的任务替换,确实不值得为此保留整套计数状态机。

我会回到 issue 的最小方案:使用 boolean consent,把 shellRuns.liveCount() 纳入活动判断,并移除精确计数、ceiling 和重复确认逻辑。接下来会按这个方向修复,也会一起处理其余 findings。

@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 0c0eaf4 to 2428c06CompareAugust 3, 2026 15:13
@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 2428c06 to fc1991dCompareAugust 3, 2026 15:24

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The two blockers from the previous round are genuinely fixed: hasInterruptibleUpdateWork is now a 17-line boolean union of session / Automation / shell-run activity, and the counting-plus-ceiling state machine (activityKey plumbing, taskLeaseCounts, escalation loop) is gone. Download stall/failure now has a real recovery path via retryUpdateDownload with cancellation-token semantics. CI is green and the branch merges cleanly with current main.

I re-reviewed the head through three independent perspectives (lifecycle correctness, design minimality, test quality); all three came back with no P0/P1. The main-process half reads as close to minimal: one timer, one boolean, three two-line read-only accessors, with the injected clock, operation-tagged error states, and mock fixture all load-bearing.

Four non-blocking items below. None gate this PR — fix, defer, or close them as you see fit.

P2 — installing has no exit when Squirrel stalls silently. On macOS with autoInstallOnAppQuit = false, update-downloaded is dispatched when the local proxy server starts, so squirrelDownloadedUpdate is still false at click time and MacUpdater.quitAndInstall takes the else branch (MacUpdater.js:240-256): it registers a listener, kicks off an async checkForUpdates(), and returns without quitting. Status stays installing, which short-circuits both checkForUpdates (app-update-service.ts:304) and retryUpdateDownload (:351), and updateReminder (app-shell.tsx:416-420) excludes installing, so the button disappears. A genuine Squirrel error is fine — MacUpdater.js:18-21 forwards it and you map it to error/install — so this is only the silent-hang path. Smallest fix: stop short-circuiting on installing and fall back to downloaded on the next scheduled check.

P3 — openUpdateDownload is now dead end to end. The sidebar button only renders for available | downloading | downloaded | error-with-latestVersion, and all four are routed to install or retry, so the else branch at app-shell.tsx:483 is unreachable — along with the preload method, the app:openUpdateDownload handler (app-ipc-main.ts:84), and the service method. Either delete the chain or wire it to a state that can actually reach it.

P3 — Retry is unguarded where install is guarded.isDisabled was removed from the sidebar button, and a click during downloading cancels the in-flight download and restarts from 0%. Install has updateInstallInFlightRef; retry has no equivalent, so the one destructive action in the surface is the least protected.

P3 — The renderer half has no tests.app-update-install.ts is covered as a pure function, but the wiring is not: the receivedPush race guard, the click-branch routing, and the double-click guard would all survive being reverted. AGENTS.md asks for one representative Playwright journey when a flow crosses renderer and main, and the deterministic seam (MAKA_UPDATE_MOCK_STATE, with installUpdate returning ok without quitting in mock mode) already exists and is unused.

中文说明

已 approve。上一轮的两个阻塞项确实修掉了:活动判断回到 17 行的布尔并集,计数 + ceiling 那套状态机整体删除,下载停滞也有了真实的取消重下路径。CI 全绿,与 main 无冲突。

我从生命周期正确性、设计最小性、测试质量三个独立视角重新过了一遍,都没有 P0/P1。主进程这一半基本已是最小解。

上面四条都不阻塞合并,是否处理你自己判断。P2 是 macOS 上 Squirrel 静默挂起时 installing 没有出口(真正报错的路径你已经兜住了);两个 P3 分别是 openUpdateDownload 整条链路已不可达,以及 downloading 时点按钮会取消并从 0 重下且无保护;最后一个 P3 是 renderer 那一半没有测试,而确定性 mock seam 已经存在。

@Astro-Han
Astro-Han merged commit b5377bd into apache:mainAug 4, 2026
11 checks passed
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.

feat(update): auto-download, main-process checks, and task-aware install

2 participants

@me2seeks@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(desktop): make app updates background and task-aware by me2seeks · Pull Request #1992 · apache/maka · GitHub
Skip to content

feat(desktop): make app updates background and task-aware - #1992

Merged
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates
Aug 4, 2026
Merged

feat(desktop): make app updates background and task-aware#1992
Astro-Han merged 4 commits into
apache:mainfrom
me2seeks:feat/1931-task-aware-updates

Conversation

@me2seeks

Copy link
Copy Markdown
Contributor

Summary

Update discovery used to depend on the renderer staying alive, and installing a downloaded update had no view of work still running in the main process.

This moves the updater lifecycle into Electron main: the first check runs about 10 seconds after app readiness, later checks run four hours after the previous check settles, and electron-updater downloads an available release automatically. The renderer now only projects the current status and listens for pushes; the existing sidebar button remains the only update surface, with no download-complete popup and no install-on-quit behavior.

Restart/install is guarded by a main-owned activity snapshot. Session turns are counted by stable task identity, and Automation scheduler activity is merged with stream activity without double-counting the same fire. The first install request authorizes interrupting zero tasks. After the user confirms the reported count, the follow-up request is limited to that count; if more tasks start while the dialog is open, the new count is shown for confirmation instead of silently widening the authorization.

Closes#1931

中文说明

本 PR 将更新检查和自动下载的生命周期移到 Electron 主进程:应用 ready 约 10 秒后首次检查,之后在上一次检查结束 4 小时后再次检查。渲染进程只读取状态并订阅推送;下载完成后仍只通过现有侧栏按钮提示,不弹窗,也不会在退出时自动安装。

重启安装前,主进程会合并会话 turn 与 Automation scheduler 的活动状态,并按稳定任务标识去重。首次请求不允许中断任何任务;用户确认 N 个任务后,后续请求最多只能中断这 N 个任务。如果确认期间又启动了任务,会显示新的数量并再次询问,而不是扩大原授权。

Verification

  • Node 24: @maka/runtime full suite — 2,762 passed, 3 skipped
  • Node 24: @maka/desktop full main suite — 1,354 passed
  • Node 24: @maka/ui full suite — 256 passed
  • npm run typecheck
  • npm run lint -- --error-on-warnings
  • npm run format:check
  • npm --workspace @maka/desktop run build
  • Desktop console, accessibility, and copy checks
  • Knip for apps/desktop and packages/ui
  • Electron fixture: main-process delayed status reached downloaded, the sidebar exposed Restart without a completion popup, and the no-active-work path proceeded without confirmation

Review focus

  • SessionActivityRegistry.activeTaskCount() and AutomationScheduler.inFlightAutomationIds() form the main-process activity snapshot; the shared automation key prevents a scheduler fire and its stream lease from counting twice.
  • maxInterruptibleActiveTasks is an authorization ceiling, not a stale informational count. A higher current count returns to the confirmation flow.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review findings (5 independent deepseek-v4-flash perspectives)

The direction (updater lifecycle into main, auto-download, single-button install) is right, and the wiring against electron-updater@6.8.9 was verified correct (autoDownload/autoInstallOnAppQuit semantics, macOS delayed-install, cache sha512 re-validation, single-instance lock, fail-closed snapshot). Two blockers and several scope questions below.

P1 — Background shell/PTY tasks are invisible to the activity snapshot: an install silently kills them with no confirmation

  • Predicted failure: a user runs a long run_in_background=true task (build/download/server — the feature's typical use), the turn ends, the user clicks Restart: getActiveTaskCount() returns 0 → the first request {maxInterruptibleActiveTasks: 0} passes → quitAndInstall(false, true)shellRuns.terminateAll() kills the process tree. No dialog ever appears. This is exactly the "no work interruption without confirmation" scenario the PR claims to eliminate.
  • Evidence: snapshot composes only two sources — boot.ts:648-650 (sessionActivities.activeTaskCount(inFlightAutomationIds().map(automationActivityKey))). Background shell runs hold no activity lease (all three reserve( call sites are turn-scoped and unrelated to shell-run-manager). But background tasks do survive the turn: shell-run-manager.ts:72LifecycleCause = 'timeout' | 'cancel' | 'shutdown' has no turn-end cause; runBackgroundBash (:219) doesn't join live.finished. And they are killed on the update quit: app-lifecycle.ts:398shellRuns.terminateAll() runs in the shutdown path on both platforms (Windows: quitAndInstallapp.quit()before-quit → quit coordinator; macOS: Squirrel [NSApp terminate] → Electron overrides -terminate:Browser::QuitHandleBeforeQuit → same coordinator). liveCount()/livePtyCount() accessors already exist (shell-run-manager.ts:567-571).
  • Fix (pick one): (a) fold shellRuns.liveCount() into the snapshot (one line + a test), or (b) explicitly narrow scope — state in the PR body and copy that background shell tasks are NOT protected by the confirmation.

P2 — Scope: the task-aware mechanism grew well beyond the issue's minimal design

Issue #1931 asks for "inject hasActiveTasks" — a boolean composed from existing main-process activity, plus a confirmation dialog; "if nothing is running, install immediately". The PR instead built a full authorization machine: precise counting with stable task-identity dedup (activityKey plumbing ×3, taskLeaseCounts parallel map, additionalTaskKeys, inFlightAutomationIds() lifecycle), a maxInterruptibleActiveTasks ceiling ("first request authorizes zero; follow-ups limited to that count"), and an escalation loop re-confirming when the count grows.

From first principles: after the user confirms, installUpdate calls quitAndInstall in the same synchronous tick — there is no "afterwards" window for a ceiling to protect. The only escalation-relevant window is "tasks start while the dialog is open", which a fresh count read handles without ceiling semantics. The precision machinery is also where the bugs live (this P1 and the per-session undercount below). Please choose: return to the minimal boolean + count-display design (which also makes the P1 fix trivial), or keep the ceiling mechanism with a written rationale for what it protects that a fresh re-read does not.

P2 — Download stall/failure leaves no in-app recovery path

checkForUpdates short-circuits on downloading/downloaded (app-update-service.ts:256), the sidebar button is disabled for available/downloading (session-sidebar-nav.tsx:116), and the click is a no-op in those states (app-shell.tsx:438-439). The old error/available click-triggered downloadUpdate() retry was removed. A download that stalls without an error event (hung socket/proxy) permanently freezes the surface until restart, and a failed auto-download is silent with a misleading "Update available" title. (Confirmed independently by 3 of 5 perspectives.)

P2 — installUpdate returns {ok:true} even when no install will occur

BaseUpdater.install reports failure only via dispatchError, never by throwing (BaseUpdater.js:13-27,51-53), and on macOS MacUpdater.quitAndInstall is fire-and-forget (MacUpdater.js:240-256) — a Squirrel-side failure (code-sign validation, proxy fetch) surfaces only as a forwarded 'error' event, after the IPC reply already returned ok. The renderer toasts only on kind === 'failed', so the user confirms interrupting work and nothing happens, silently. Suggest an installing status that maps a subsequent 'error' to install_failed.

P2 — Merge conflict with current main requires a semantic decision

git merge-tree shows a real conflict in app-lifecycle.ts: main (#1994) deleted project-startup-migration.ts (runProjectStartupMigration has zero references on main) while the PR head still calls it at app-lifecycle.ts:175; mechanical resolution produces TS2305. Rebase must decide whether to drop the migration call, then re-run desktop typecheck/build.

P3 (non-blocking)

  • The boot.ts getActiveTaskCount composition (the PR's headline invariant) has no integration test — this is why the P1 slipped through.
  • Same-session concurrent turns share session:{id} key → the confirmation count understates streams killed (runtime-kernel.ts:166 explicitly supports concurrent runs; sessions:send has no idle gate).
  • MAKA_UPDATE_MOCK_STATE=available is now a dead-end (mock publishes available once, install requires downloaded, button disabled).
  • Dead params platform/arch (declared app-update-service.ts:59-60, passed boot.ts:641-642, never read).
  • Install authorization is renderer-trusted consent, not a main-side security boundary — acceptable given sandboxing, worth one sentence in the PR body.

Gate: FAIL (P1 open). The P1 must be fixed or explicitly narrowed before merge; P2s need handling or a written deferral with reason.

@Astro-Han

Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor
中文说明

关于 task-aware 的设计,我有一点个人的观察,供你参考:issue #1931 原意是一个布尔判断 + 安装前确认,现在实现成了"精确计数 + 授权上限 + 重新确认"的状态机。确认后安装是立即执行的,这套机制实际保护的窗口很小,复杂度却主要集中在计数逻辑上——P1 的 shell 任务漏计也源于此。

只是建议,你可以按自己的判断来:如果回归到最小实现(布尔 + 并入 shellRuns.liveCount()),P1 和复杂度都会一起消掉;如果保留现有机制,也完全没问题,方便的话在 PR 里说明一下 ceiling 的价值即可。两条路都支持。

@me2seeks

Copy link
Copy Markdown
ContributorAuthor

感谢建议,这个分析很有帮助。我重新顺着安装时序看了一遍,ceiling 实际保护的主要是确认框打开期间“任务数量增加”这一小段,而且也无法识别数量不变时的任务替换,确实不值得为此保留整套计数状态机。

我会回到 issue 的最小方案:使用 boolean consent,把 shellRuns.liveCount() 纳入活动判断,并移除精确计数、ceiling 和重复确认逻辑。接下来会按这个方向修复,也会一起处理其余 findings。

@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 0c0eaf4 to 2428c06CompareAugust 3, 2026 15:13
@me2seeks
me2seeksforce-pushed the feat/1931-task-aware-updates branch from 2428c06 to fc1991dCompareAugust 3, 2026 15:24

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving. The two blockers from the previous round are genuinely fixed: hasInterruptibleUpdateWork is now a 17-line boolean union of session / Automation / shell-run activity, and the counting-plus-ceiling state machine (activityKey plumbing, taskLeaseCounts, escalation loop) is gone. Download stall/failure now has a real recovery path via retryUpdateDownload with cancellation-token semantics. CI is green and the branch merges cleanly with current main.

I re-reviewed the head through three independent perspectives (lifecycle correctness, design minimality, test quality); all three came back with no P0/P1. The main-process half reads as close to minimal: one timer, one boolean, three two-line read-only accessors, with the injected clock, operation-tagged error states, and mock fixture all load-bearing.

Four non-blocking items below. None gate this PR — fix, defer, or close them as you see fit.

P2 — installing has no exit when Squirrel stalls silently. On macOS with autoInstallOnAppQuit = false, update-downloaded is dispatched when the local proxy server starts, so squirrelDownloadedUpdate is still false at click time and MacUpdater.quitAndInstall takes the else branch (MacUpdater.js:240-256): it registers a listener, kicks off an async checkForUpdates(), and returns without quitting. Status stays installing, which short-circuits both checkForUpdates (app-update-service.ts:304) and retryUpdateDownload (:351), and updateReminder (app-shell.tsx:416-420) excludes installing, so the button disappears. A genuine Squirrel error is fine — MacUpdater.js:18-21 forwards it and you map it to error/install — so this is only the silent-hang path. Smallest fix: stop short-circuiting on installing and fall back to downloaded on the next scheduled check.

P3 — openUpdateDownload is now dead end to end. The sidebar button only renders for available | downloading | downloaded | error-with-latestVersion, and all four are routed to install or retry, so the else branch at app-shell.tsx:483 is unreachable — along with the preload method, the app:openUpdateDownload handler (app-ipc-main.ts:84), and the service method. Either delete the chain or wire it to a state that can actually reach it.

P3 — Retry is unguarded where install is guarded.isDisabled was removed from the sidebar button, and a click during downloading cancels the in-flight download and restarts from 0%. Install has updateInstallInFlightRef; retry has no equivalent, so the one destructive action in the surface is the least protected.

P3 — The renderer half has no tests.app-update-install.ts is covered as a pure function, but the wiring is not: the receivedPush race guard, the click-branch routing, and the double-click guard would all survive being reverted. AGENTS.md asks for one representative Playwright journey when a flow crosses renderer and main, and the deterministic seam (MAKA_UPDATE_MOCK_STATE, with installUpdate returning ok without quitting in mock mode) already exists and is unused.

中文说明

已 approve。上一轮的两个阻塞项确实修掉了:活动判断回到 17 行的布尔并集,计数 + ceiling 那套状态机整体删除,下载停滞也有了真实的取消重下路径。CI 全绿,与 main 无冲突。

我从生命周期正确性、设计最小性、测试质量三个独立视角重新过了一遍,都没有 P0/P1。主进程这一半基本已是最小解。

上面四条都不阻塞合并,是否处理你自己判断。P2 是 macOS 上 Squirrel 静默挂起时 installing 没有出口(真正报错的路径你已经兜住了);两个 P3 分别是 openUpdateDownload 整条链路已不可达,以及 downloading 时点按钮会取消并从 0 重下且无保护;最后一个 P3 是 renderer 那一半没有测试,而确定性 mock seam 已经存在。

@Astro-Han
Astro-Han merged commit b5377bd into apache:mainAug 4, 2026
11 checks passed
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.

feat(update): auto-download, main-process checks, and task-aware install

2 participants

@me2seeks@Astro-Han