@loicngr/kobo 1.11.36 → 1.11.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/AGENTS.md +18 -1
  2. package/CHANGELOG.md +8 -0
  3. package/dist/server/index.js +20 -24
  4. package/dist/server/routes/dev-server.js +2 -1
  5. package/dist/server/routes/pull-requests.js +5 -4
  6. package/dist/server/routes/workspaces.js +161 -109
  7. package/dist/server/services/agent/engines/claude-code/engine.js +11 -0
  8. package/dist/server/services/agent/engines/codex/client.js +1 -0
  9. package/dist/server/services/agent/engines/codex/engine.js +47 -10
  10. package/dist/server/services/agent/engines/codex/event-mapper.js +8 -2
  11. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +21 -8
  12. package/dist/server/services/agent/engines/codex/jsonrpc/transport.js +30 -3
  13. package/dist/server/services/agent/event-router.js +1 -1
  14. package/dist/server/services/agent/orchestrator.js +219 -78
  15. package/dist/server/services/agent/session-controller.js +12 -2
  16. package/dist/server/services/auto-loop-service.js +60 -6
  17. package/dist/server/services/change-source-branch-service.js +36 -4
  18. package/dist/server/services/cron-service.js +19 -0
  19. package/dist/server/services/db-backup-service.js +30 -0
  20. package/dist/server/services/dev-server-service.js +167 -38
  21. package/dist/server/services/image-service.js +25 -28
  22. package/dist/server/services/pr-checkout-service.js +34 -5
  23. package/dist/server/services/pr-watcher-service.js +5 -3
  24. package/dist/server/services/quota-backoff-service.js +25 -8
  25. package/dist/server/services/wakeup-service.js +23 -3
  26. package/dist/server/services/websocket-service.js +10 -2
  27. package/dist/server/services/workspace-service.js +8 -4
  28. package/dist/server/services/worktree-purge-service.js +3 -7
  29. package/dist/server/utils/agent-stop-result.js +12 -0
  30. package/dist/server/utils/git-ops.js +8 -1
  31. package/dist/server/utils/git-worktree-fingerprint.js +86 -0
  32. package/dist/server/utils/safe-path.js +21 -0
  33. package/dist/server/utils/script-runner.js +32 -9
  34. package/dist/server/utils/workspace-lifecycle-guard.js +27 -0
  35. package/package.json +1 -1
  36. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +8 -0
  37. package/src/client/dist/pwa/assets/{ActivityFeed-Bakgvay6.css → ActivityFeed-Chsw1Mh7.css} +1 -1
  38. package/src/client/dist/pwa/assets/{ChangelogPage-BFGYQ6eY.js → ChangelogPage-D8563008.js} +1 -1
  39. package/src/client/dist/pwa/assets/ComparisonPanel-Bd0X08sg.js +2 -0
  40. package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +1 -0
  41. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +1 -0
  42. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +2 -0
  43. package/src/client/dist/pwa/assets/DashboardPage-D8g7_r4S.css +1 -0
  44. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +1 -0
  45. package/src/client/dist/pwa/assets/{DiffViewer-DSlSAIiW.js → DiffViewer-BpsLNxnj.js} +3 -3
  46. package/src/client/dist/pwa/assets/{DocumentsPanel-C1eXTw_Q.js → DocumentsPanel-BJsJ0fNs.js} +1 -1
  47. package/src/client/dist/pwa/assets/{DrawerToggleButton-BxYpUb4y.js → DrawerToggleButton-BVLO5K5d.js} +1 -1
  48. package/src/client/dist/pwa/assets/{GitPanel-DnBWSdVp.js → GitPanel-Cal_wzC0.js} +3 -3
  49. package/src/client/dist/pwa/assets/{HealthPage-rX1TiaP9.js → HealthPage-RFZS7Lao.js} +1 -1
  50. package/src/client/dist/pwa/assets/{MainLayout-Ceuh5uJj.js → MainLayout-vvyiRcql.js} +2 -2
  51. package/src/client/dist/pwa/assets/{QBtnDropdown-K7syiyyH.js → QBtnDropdown-X8oNU5Mb.js} +1 -1
  52. package/src/client/dist/pwa/assets/{QDrawer-DapzE8DH.js → QDrawer-Cij17skX.js} +1 -1
  53. package/src/client/dist/pwa/assets/{QSelect-Bodml89k.js → QSelect-xd-AlGo_.js} +1 -1
  54. package/src/client/dist/pwa/assets/{QTooltip-K-9OdDfq.js → QTooltip-CwcCGSWq.js} +1 -1
  55. package/src/client/dist/pwa/assets/{SchedulePanel-CfumTzAa.js → SchedulePanel-CXRUsz6G.js} +1 -1
  56. package/src/client/dist/pwa/assets/{SearchPage-AhZYS-dS.js → SearchPage-DopbgLxh.js} +1 -1
  57. package/src/client/dist/pwa/assets/{SettingsPage-BoHXWvKn.js → SettingsPage-Jx6TnOzR.js} +1 -1
  58. package/src/client/dist/pwa/assets/{SplitWorkspacePage-3wK11mcu.js → SplitWorkspacePage-D7lGFRKs.js} +1 -1
  59. package/src/client/dist/pwa/assets/{TerminalPanel-CKg0BeNK.js → TerminalPanel-DI8DHb9e.js} +1 -1
  60. package/src/client/dist/pwa/assets/{TouchPan-D6ctyPYf.js → TouchPan-B3nrkCY8.js} +1 -1
  61. package/src/client/dist/pwa/assets/{TourReplayButton-Dn4Dy_kH.js → TourReplayButton-DU1UShMY.js} +1 -1
  62. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-DnoabsfK.js → WorkspaceAttentionLabels-C-yN5IX8.js} +1 -1
  63. package/src/client/dist/pwa/assets/{WorkspacePage-DYQrfeYw.css → WorkspacePage-BAC6u9OP.css} +1 -1
  64. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +4 -0
  65. package/src/client/dist/pwa/assets/{browser-DHpSUK8-.js → browser-f9MHQjfi.js} +1 -1
  66. package/src/client/dist/pwa/assets/{build-path-tree-DtEjsZlh.js → build-path-tree-BWBzP8os.js} +1 -1
  67. package/src/client/dist/pwa/assets/{de-Dt_m-H83.js → de-QkDLrcGq.js} +1 -1
  68. package/src/client/dist/pwa/assets/{documents-B2dg1wRf.js → documents-C2cLnEBQ.js} +1 -1
  69. package/src/client/dist/pwa/assets/{editor.api-Cd1D35oj.js → editor.api-sw146MWN.js} +1 -1
  70. package/src/client/dist/pwa/assets/{efforts-IB1vPjJQ.js → efforts-C5ZZgCH4.js} +1 -1
  71. package/src/client/dist/pwa/assets/{engineFeatures-DYSyDEJe.js → engineFeatures-CAVMDawq.js} +1 -1
  72. package/src/client/dist/pwa/assets/{es-DFg25DCS.js → es-BgQFKlo8.js} +1 -1
  73. package/src/client/dist/pwa/assets/{expand-template-CNAHiesL.js → expand-template-B6xAlAaM.js} +1 -1
  74. package/src/client/dist/pwa/assets/{format-DidwgbbX.js → format-BwWP6Aay.js} +1 -1
  75. package/src/client/dist/pwa/assets/{formatters-BjzyUHlu.js → formatters-DyWtO-nR.js} +1 -1
  76. package/src/client/dist/pwa/assets/{fr-CQ1slpol.js → fr-DAdLWjNM.js} +1 -1
  77. package/src/client/dist/pwa/assets/{freemarker2-jNGWawVw.js → freemarker2-DZ5Hm8UH.js} +1 -1
  78. package/src/client/dist/pwa/assets/{handlebars-DfSqClrW.js → handlebars-BRUGpTA7.js} +1 -1
  79. package/src/client/dist/pwa/assets/{html-ByLX-n8T.js → html-DNOayNNs.js} +1 -1
  80. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +1 -0
  81. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +20 -0
  82. package/src/client/dist/pwa/assets/{it-w09rfaP7.js → it-Bumaewp1.js} +1 -1
  83. package/src/client/dist/pwa/assets/{javascript-Dja1fHfv.js → javascript-C0tDQU3z.js} +1 -1
  84. package/src/client/dist/pwa/assets/{liquid-D315hW6C.js → liquid-CN46O6OF.js} +1 -1
  85. package/src/client/dist/pwa/assets/{mdx-B5yUwpzv.js → mdx-DM-lrr8Q.js} +1 -1
  86. package/src/client/dist/pwa/assets/{monaco-contributions-NfxrU0r3.js → monaco-contributions-CkN7y-8c.js} +1 -1
  87. package/src/client/dist/pwa/assets/{monaco.contribution-D1hXPkDZ.js → monaco.contribution-2BcAOLjc.js} +2 -2
  88. package/src/client/dist/pwa/assets/{network-auth-CARVtbNe.js → network-auth-KYS4snQH.js} +1 -1
  89. package/src/client/dist/pwa/assets/{notify-theme-CKC3-EAZ.js → notify-theme-a91Tcr6o.js} +1 -1
  90. package/src/client/dist/pwa/assets/{pending-diff-open-BlLrQaW7.js → pending-diff-open-DOtELZfB.js} +1 -1
  91. package/src/client/dist/pwa/assets/{permissionModes-DMzFEQhr.js → permissionModes-C-9Crj-c.js} +1 -1
  92. package/src/client/dist/pwa/assets/{project-color-CFjdIj6W.js → project-color-BRe4oyNx.js} +1 -1
  93. package/src/client/dist/pwa/assets/{python-Dc6mnEip.js → python-BRpJG6sZ.js} +1 -1
  94. package/src/client/dist/pwa/assets/{razor-DArVFBGO.js → razor-CehCmvHl.js} +1 -1
  95. package/src/client/dist/pwa/assets/{render-chat-markdown-BTPAjU2g.js → render-chat-markdown-DlbQLZ02.js} +1 -1
  96. package/src/client/dist/pwa/assets/{typescript-COE1vIyB.js → typescript-Dt_8mVuj.js} +1 -1
  97. package/src/client/dist/pwa/assets/{use-tours-C8x2V1w5.js → use-tours-CSVM_54K.js} +1 -1
  98. package/src/client/dist/pwa/assets/{workspace-templates-DHyA_GEW.js → workspace-templates-GCObcjO6.js} +1 -1
  99. package/src/client/dist/pwa/assets/{xml-eYjr4cGR.js → xml-_gOwdzB4.js} +1 -1
  100. package/src/client/dist/pwa/assets/{yaml-UooOcssF.js → yaml-BxeBqpGf.js} +1 -1
  101. package/src/client/dist/pwa/index.html +1 -1
  102. package/src/client/dist/pwa/sw.js +1 -1
  103. package/src/client/dist/pwa/assets/ActivityFeed-BPx-xhFY.js +0 -8
  104. package/src/client/dist/pwa/assets/ComparisonPanel-C6DfjpZZ.js +0 -2
  105. package/src/client/dist/pwa/assets/ComparisonPanel-x1iuICgd.css +0 -1
  106. package/src/client/dist/pwa/assets/CreatePage-DxOyY65u.css +0 -1
  107. package/src/client/dist/pwa/assets/CreatePage-ypEzkAKv.js +0 -2
  108. package/src/client/dist/pwa/assets/DashboardPage-DsVRjzzy.js +0 -1
  109. package/src/client/dist/pwa/assets/DashboardPage-DsWn5cWl.css +0 -1
  110. package/src/client/dist/pwa/assets/WorkspacePage-BI7CW2yT.js +0 -4
  111. package/src/client/dist/pwa/assets/i18n-CnjXQjxy.js +0 -1
  112. package/src/client/dist/pwa/assets/index-DcwcFDrZ.js +0 -20
@@ -2,10 +2,12 @@ import fs from 'node:fs';
2
2
  import { buildE2eIterationBlock, buildFinalizationIterationBlock } from '../../shared/auto-loop-prompts.js';
3
3
  import { getDb } from '../db/index.js';
4
4
  import { slugifyProjectName } from '../utils/project-slug.js';
5
+ import { deferUntilWorkspaceAvailable, isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
5
6
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
6
7
  import * as orchestrator from './agent/orchestrator.js';
7
8
  import * as cleanupScriptService from './cleanup-script-service.js';
8
9
  import * as lifecycleHookService from './lifecycle-hook-service.js';
10
+ import * as quotaBackoffService from './quota-backoff-service.js';
9
11
  import * as settingsService from './settings-service.js';
10
12
  import { getSuitePrompts } from './skill-suite-prompts.js';
11
13
  import { emit, emitEphemeral } from './websocket-service.js';
@@ -129,7 +131,7 @@ export function onSessionEnded(workspaceId, reason, taskProgressDelta) {
129
131
  return;
130
132
  // Don't spawn a competing session while paused on canUseTool — the user
131
133
  // will resume the deferred turn explicitly.
132
- if (row.status === 'awaiting-user')
134
+ if (row.status === 'awaiting-user' || row.status === 'compacting')
133
135
  return;
134
136
  // A watchdog denotes Kōbō's own forced recovery from a stuck engine stream.
135
137
  // The orchestrator owns its bounded backoff/retry path; counting it as a
@@ -295,7 +297,9 @@ function hasFreeAgentSlot() {
295
297
  * free, exactly one starts and the other two keep waiting rather than each
296
298
  * re-announcing that they wait.
297
299
  */
298
- export function resumeWaitingWorkspaces() {
300
+ export function resumeWaitingWorkspaces(excluded = new Set()) {
301
+ if (orchestrator.isShuttingDown())
302
+ return;
299
303
  const db = getDb();
300
304
  const rows = db
301
305
  .prepare(`SELECT id, status FROM workspaces
@@ -303,13 +307,15 @@ export function resumeWaitingWorkspaces() {
303
307
  ORDER BY updated_at ASC`)
304
308
  .all();
305
309
  for (const row of rows) {
310
+ if (excluded.has(row.id))
311
+ continue;
306
312
  if (!hasFreeAgentSlot())
307
313
  return;
308
314
  if (orchestrator.hasController(row.id))
309
315
  continue;
310
316
  // Same guards as spawnNextIteration's own callers: a quota backoff or a
311
317
  // pending question owns the next start of that workspace.
312
- if (row.status === 'awaiting-user' || row.status === 'quota')
318
+ if (row.status === 'awaiting-user' || row.status === 'compacting' || row.status === 'quota')
313
319
  continue;
314
320
  try {
315
321
  spawnNextIteration(row.id);
@@ -320,11 +326,18 @@ export function resumeWaitingWorkspaces() {
320
326
  }
321
327
  }
322
328
  function spawnNextIteration(workspaceId, opts = {}) {
329
+ // Lifecycle/ready callbacks can outlive timer suspension; preserve their persisted loop intent.
330
+ if (orchestrator.isShuttingDown())
331
+ return;
323
332
  const row = getRow(workspaceId);
324
- if (!row)
333
+ if (!row || row.archived_at || !row.auto_loop)
334
+ return;
335
+ if (deferUntilWorkspaceAvailable(workspaceId, resumeWaitingWorkspaces))
336
+ return;
337
+ if (orchestrator.hasController(workspaceId))
325
338
  return;
326
339
  // Same guard as onSessionEnded — never race a deferred-resume start.
327
- if (row.status === 'awaiting-user')
340
+ if (row.status === 'awaiting-user' || row.status === 'compacting')
328
341
  return;
329
342
  const task = pickNextTask(workspaceId);
330
343
  if (!task) {
@@ -439,7 +452,18 @@ function spawnNextIteration(workspaceId, opts = {}) {
439
452
  * - `status !== 'quota'` (user already manually resumed, or another path
440
453
  * transitioned the workspace)
441
454
  */
442
- export function onQuotaBackoffExpired(workspaceId) {
455
+ const pendingQuotaStops = new Set();
456
+ const QUOTA_STOP_RETRY_MS = 15_000;
457
+ function deferQuotaRetry(workspaceId, pending) {
458
+ const attempt = pending ?? quotaBackoffService.getPending(workspaceId);
459
+ quotaBackoffService.arm(workspaceId, QUOTA_STOP_RETRY_MS, {
460
+ resetsAt: attempt?.resetsAt ?? null,
461
+ source: attempt?.source ?? 'fallback_ladder',
462
+ reason: attempt?.reason ?? 'quota',
463
+ retryCount: attempt?.retryCount ?? 1,
464
+ });
465
+ }
466
+ export function onQuotaBackoffExpired(workspaceId, pending) {
443
467
  const row = getRow(workspaceId);
444
468
  if (!row)
445
469
  return;
@@ -449,6 +473,36 @@ export function onQuotaBackoffExpired(workspaceId) {
449
473
  return;
450
474
  if (row.status !== 'quota')
451
475
  return;
476
+ if (pendingQuotaStops.has(workspaceId) || orchestrator.isShuttingDown() || isWorkspaceLifecycleBusy(workspaceId)) {
477
+ deferQuotaRetry(workspaceId, pending);
478
+ return;
479
+ }
480
+ if (orchestrator.hasController(workspaceId)) {
481
+ pendingQuotaStops.add(workspaceId);
482
+ const stopped = orchestrator.stopAgentAndWait(workspaceId, undefined, 'replacement');
483
+ // The timer already consumed its row. Persist recovery immediately, after the
484
+ // technical stop's synchronous cancellation, so shutdown/crash during the await loses no intent.
485
+ deferQuotaRetry(workspaceId, pending);
486
+ void stopped
487
+ .then((outcome) => {
488
+ // Stop, archive, or a manual replacement may have superseded this retry while we awaited the engine.
489
+ const current = getRow(workspaceId);
490
+ if (!current || current.archived_at || !current.auto_loop || current.status !== 'quota')
491
+ return;
492
+ if (outcome === 'timeout' || outcome === 'failed' || orchestrator.isShuttingDown()) {
493
+ deferQuotaRetry(workspaceId, pending);
494
+ return;
495
+ }
496
+ updateWorkspaceStatus(workspaceId, 'idle');
497
+ quotaBackoffService.cancel(workspaceId, 'completed');
498
+ spawnNextIteration(workspaceId);
499
+ })
500
+ .catch((err) => {
501
+ console.error(`[auto-loop] quota controller stop failed for '${workspaceId}':`, err);
502
+ })
503
+ .finally(() => pendingQuotaStops.delete(workspaceId));
504
+ return;
505
+ }
452
506
  // The timer has consumed its persisted row. Release quota ownership before
453
507
  // checking capacity so resumeWaitingWorkspaces can pick this workspace up
454
508
  // when a slot frees, even if no agent can start at this instant.
@@ -1,3 +1,4 @@
1
+ import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
1
2
  // src/server/services/change-source-branch-service.ts
2
3
  import { spawn } from 'node:child_process';
3
4
  import * as path from 'node:path';
@@ -44,6 +45,15 @@ const BACKUP_BRANCHES_KEPT = 3;
44
45
  * running, unknown base). Returns a status discriminating the outcome.
45
46
  */
46
47
  export async function changeSourceBranch(workspaceId, newBase) {
48
+ // Always acquire workspace ownership before the shared repository lock.
49
+ return withWorkspaceLifecycleGuard(workspaceId, () => {
50
+ const workspace = getWorkspace(workspaceId);
51
+ if (!workspace)
52
+ throw new Error(`Workspace '${workspaceId}' not found`);
53
+ return withGitRepoLock(workspace.worktreePath, () => changeSourceBranchGuarded(workspaceId, newBase));
54
+ });
55
+ }
56
+ async function changeSourceBranchGuarded(workspaceId, newBase) {
47
57
  const workspace = getWorkspace(workspaceId);
48
58
  if (!workspace)
49
59
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -85,16 +95,16 @@ export async function changeSourceBranch(workspaceId, newBase) {
85
95
  }
86
96
  // A stale index.lock makes every git write fail half-way through, and the
87
97
  // raw git message tells the user nothing. Typical origin: a setup/cleanup
88
- // script SIGKILLed at its timeout during a commit. The custom-script path
89
- // does not go through the repository lock, so the check is immediate here.
98
+ // script SIGKILLed at its timeout during a commit. Both paths hold the lock.
90
99
  gitOps.assertNoIndexLock(worktreePath);
100
+ gitOps.assertCurrentBranch(worktreePath, workingBranch);
91
101
  return runCustomScript(workspace, oldBase, trimmedNew, effective.changeSourceBranchScript);
92
102
  }
93
- // The whole built-in strategy runs under the repository lock: it fetches,
103
+ // Both strategies run under the repository lock: it fetches,
94
104
  // resets and cherry-picks, all of which touch the COMMON git dir shared by
95
105
  // every worktree of the project. A concurrent fetch from the PR watcher
96
106
  // during the cherry-pick leaves it aborted mid-way.
97
- return withGitRepoLock(worktreePath, () => runBuiltInStrategy(workspaceId, worktreePath, workingBranch, workspace.projectPath, oldBase, trimmedNew));
107
+ return runBuiltInStrategy(workspaceId, worktreePath, workingBranch, workspace.projectPath, oldBase, trimmedNew);
98
108
  }
99
109
  /** The built-in cherry-pick strategy. Always called under the repository lock. */
100
110
  async function runBuiltInStrategy(workspaceId, worktreePath, workingBranch, projectPath, oldBase, trimmedNew) {
@@ -114,6 +124,10 @@ async function runBuiltInStrategy(workspaceId, worktreePath, workingBranch, proj
114
124
  catch {
115
125
  // offline / no remote — proceed with local refs
116
126
  }
127
+ assertWorkspaceUnchanged(workspaceId, worktreePath, workingBranch, oldBase);
128
+ gitOps.assertCurrentBranch(worktreePath, workingBranch);
129
+ if (getAgentStatus(workspaceId) !== null)
130
+ throw new Error('Cannot change the source branch while the agent is running');
117
131
  if (!gitOps.branchExists(worktreePath, trimmedNew, 'origin')) {
118
132
  throw new Error(`Source branch 'origin/${trimmedNew}' does not exist`);
119
133
  }
@@ -235,6 +249,12 @@ async function runCustomScript(workspace, oldBase, newBase, script) {
235
249
  catch (err) {
236
250
  console.warn('[change-source-branch] PR lookup failed, KOBO_PR_NUMBER will be empty:', err);
237
251
  }
252
+ assertWorkspaceUnchanged(workspace.id, workspace.worktreePath, workspace.workingBranch, oldBase);
253
+ gitOps.assertCurrentBranch(workspace.worktreePath, workspace.workingBranch);
254
+ if (gitOps.worktreeHasChangesStrict(workspace.worktreePath))
255
+ return { status: 'dirty', forcePushNeeded: false, commitCount: 0 };
256
+ if (getAgentStatus(workspace.id) !== null)
257
+ throw new Error('Cannot change the source branch while the agent is running');
238
258
  return new Promise((resolve, reject) => {
239
259
  const child = spawn('bash', ['-c', script], {
240
260
  cwd: workspace.worktreePath,
@@ -292,3 +312,15 @@ async function runCustomScript(workspace, oldBase, newBase, script) {
292
312
  });
293
313
  });
294
314
  }
315
+ /** Async lookups must not authorize mutations using obsolete workspace metadata. */
316
+ function assertWorkspaceUnchanged(id, worktreePath, branch, sourceBranch) {
317
+ const current = getWorkspace(id);
318
+ if (!current ||
319
+ current.archivedAt ||
320
+ current.worktreePurgedAt ||
321
+ current.worktreePath !== worktreePath ||
322
+ current.workingBranch !== branch ||
323
+ current.sourceBranch !== sourceBranch) {
324
+ throw new Error('Workspace changed during source-branch validation; retry the operation');
325
+ }
326
+ }
@@ -2,6 +2,7 @@ import { CronExpressionParser } from 'cron-parser';
2
2
  import { nanoid } from 'nanoid';
3
3
  import { getDb } from '../db/index.js';
4
4
  import { slugifyProjectName } from '../utils/project-slug.js';
5
+ import { isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
5
6
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
6
7
  import * as orchestrator from './agent/orchestrator.js';
7
8
  import * as settingsService from './settings-service.js';
@@ -15,6 +16,14 @@ export const MIN_DELAY_BETWEEN_FIRES_SECONDS = 60;
15
16
  // real fire time hasn't been reached yet.
16
17
  const MAX_SETTIMEOUT_MS = 2_000_000_000; // ~23 days, with margin under 2^31-1
17
18
  const timers = new Map();
19
+ let suspended = false;
20
+ /** Preserve cron intent and deadlines while the process is shutting down. */
21
+ export function suspendForShutdown() {
22
+ suspended = true;
23
+ for (const timer of timers.values())
24
+ clearTimeout(timer);
25
+ timers.clear();
26
+ }
18
27
  /**
19
28
  * Arm a setTimeout that fires `fireOrSkip(id)` when `fireAt` is reached.
20
29
  * Replaces any existing timer for this id. Handles long horizons by
@@ -22,6 +31,8 @@ const timers = new Map();
22
31
  * days.
23
32
  */
24
33
  function scheduleAt(id, fireAt) {
34
+ if (suspended)
35
+ return;
25
36
  const previous = timers.get(id);
26
37
  if (previous)
27
38
  clearTimeout(previous);
@@ -154,6 +165,8 @@ export function listAll() {
154
165
  * unexpected error is logged and the cron is preserved when possible.
155
166
  */
156
167
  function fireOrSkip(id) {
168
+ if (suspended)
169
+ return;
157
170
  try {
158
171
  timers.delete(id);
159
172
  const db = getDb();
@@ -168,6 +181,10 @@ function fireOrSkip(id) {
168
181
  cancel(id, wsRow ? 'archive' : 'deleted');
169
182
  return;
170
183
  }
184
+ if (isWorkspaceLifecycleBusy(row.workspace_id)) {
185
+ scheduleAt(id, new Date(Date.now() + 15_000));
186
+ return;
187
+ }
171
188
  let status = 'skipped-active';
172
189
  if (!orchestrator.hasController(row.workspace_id)) {
173
190
  try {
@@ -244,6 +261,8 @@ function fireOrSkip(id) {
244
261
  * Rows pointing at deleted/archived workspaces are removed without firing.
245
262
  */
246
263
  export function restoreOnBoot() {
264
+ suspendForShutdown();
265
+ suspended = false;
247
266
  try {
248
267
  // Boot semantics: clear any existing in-memory timers before rearming.
249
268
  for (const t of timers.values())
@@ -90,3 +90,33 @@ export async function createPreMigrationBackup(db, dbPath, tag, keepCount = DEFA
90
90
  throw err;
91
91
  }
92
92
  }
93
+ /** Recheck daily-backup eligibility hourly, including on long-running servers. */
94
+ export function startDailyDbBackupScheduler(backup, intervalMs = 60 * 60 * 1000) {
95
+ let inFlight = null;
96
+ let stopped = false;
97
+ const attempt = async () => {
98
+ try {
99
+ await backup();
100
+ }
101
+ catch (err) {
102
+ console.error('[kobo] Scheduled DB backup failed:', err);
103
+ }
104
+ };
105
+ const tick = () => {
106
+ if (stopped || inFlight)
107
+ return;
108
+ inFlight = attempt().finally(() => {
109
+ inFlight = null;
110
+ });
111
+ };
112
+ const timer = setInterval(tick, intervalMs);
113
+ timer.unref?.();
114
+ tick();
115
+ return {
116
+ async stop() {
117
+ stopped = true;
118
+ clearInterval(timer);
119
+ await inFlight;
120
+ },
121
+ };
122
+ }
@@ -1,6 +1,7 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
4
5
  import { getProjectSettings } from './settings-service.js';
5
6
  import { emitEphemeral } from './websocket-service.js';
6
7
  import { getWorkspace, listWorkspaces, updateDevServerStatus } from './workspace-service.js';
@@ -23,9 +24,13 @@ function runCommand(command, args, options = {}) {
23
24
  // ── State ──────────────────────────────────────────────────────────────────────
24
25
  /** workspaceId -> spawned dev-server process */
25
26
  const trackedProcesses = new Map();
27
+ const stoppingProcesses = new Map();
28
+ const generations = new Map();
26
29
  /** Test-only: clear the tracked-processes map between tests. */
27
30
  export function _resetTrackedProcessesForTests() {
28
31
  trackedProcesses.clear();
32
+ stoppingProcesses.clear();
33
+ generations.clear();
29
34
  }
30
35
  // ── Pure helpers ───────────────────────────────────────────────────────────────
31
36
  /**
@@ -84,23 +89,29 @@ export function resolveInstance(projectPath, workingBranch) {
84
89
  }
85
90
  return null;
86
91
  }
87
- function containerBelongsToProject(containerName, projectName) {
88
- const name = containerName.toLowerCase();
89
- const project = projectName.toLowerCase();
90
- return name === project || name.startsWith(`${project}-`) || name.startsWith(`${project}_`);
91
- }
92
92
  // ── Docker helpers ─────────────────────────────────────────────────────────────
93
93
  /**
94
94
  * List all running Docker container names.
95
95
  * Uses execFile so Docker inspection cannot block the Node event loop.
96
96
  */
97
- export async function listRunningContainers() {
97
+ export async function listRunningContainers(projectName) {
98
98
  try {
99
- const output = await runCommand('docker', ['ps', '--format', '{{.Names}}'], { timeout: 10_000 });
99
+ const args = projectName
100
+ ? [
101
+ 'ps',
102
+ '--filter',
103
+ `label=com.docker.compose.project=${projectName}`,
104
+ '--format',
105
+ '{{.Names}}\t{{.Label "com.docker.compose.project"}}',
106
+ ]
107
+ : ['ps', '--format', '{{.Names}}'];
108
+ const output = await runCommand('docker', args, { timeout: 10_000 });
100
109
  return output
101
110
  .split('\n')
102
111
  .map((s) => s.trim())
103
- .filter(Boolean);
112
+ .filter(Boolean)
113
+ .filter((line) => !projectName || line.split('\t')[1] === projectName)
114
+ .map((line) => (projectName ? line.split('\t')[0] : line));
104
115
  }
105
116
  catch {
106
117
  return [];
@@ -128,8 +139,7 @@ export async function getStatus(projectPath, workingBranch, workspaceId) {
128
139
  containers: [],
129
140
  };
130
141
  }
131
- const running = await listRunningContainers();
132
- const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
142
+ const matching = await listRunningContainers(config.projectName);
133
143
  if (matching.length > 0) {
134
144
  return {
135
145
  status: 'running',
@@ -167,6 +177,7 @@ export async function getStatus(projectPath, workingBranch, workspaceId) {
167
177
  * Start the dev-server for a workspace.
168
178
  */
169
179
  export function startDevServer(workspaceId) {
180
+ assertWorkspaceLifecycleAvailable(workspaceId);
170
181
  const workspace = getWorkspace(workspaceId);
171
182
  if (!workspace) {
172
183
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -179,6 +190,8 @@ export function startDevServer(workspaceId) {
179
190
  // silently overwriting the tracked process — the first process would
180
191
  // otherwise become untrackable (never killed by stopDevServer) and its
181
192
  // exit handler could later clobber state set by the second process.
193
+ if (stoppingProcesses.has(workspaceId))
194
+ throw new Error(`Dev server for workspace '${workspaceId}' is stopping`);
182
195
  if (trackedProcesses.has(workspaceId)) {
183
196
  throw new Error(`Dev server for workspace '${workspaceId}' is already starting`);
184
197
  }
@@ -196,6 +209,8 @@ export function startDevServer(workspaceId) {
196
209
  stdio: ['ignore', 'pipe', 'pipe'],
197
210
  detached: true,
198
211
  });
212
+ const generation = (generations.get(workspaceId) ?? 0) + 1;
213
+ generations.set(workspaceId, generation);
199
214
  trackedProcesses.set(workspaceId, proc);
200
215
  // Log stdout/stderr for debugging
201
216
  proc.stdout?.on('data', (data) => {
@@ -205,21 +220,35 @@ export function startDevServer(workspaceId) {
205
220
  console.error(`[dev-server:${instanceName}] ${data.toString().trim()}`);
206
221
  });
207
222
  proc.on('exit', (code) => {
208
- trackedProcesses.delete(workspaceId);
209
- void getStatus(workspace.projectPath, workspace.workingBranch)
210
- .then((currentStatus) => {
211
- updateDevServerStatus(workspaceId, currentStatus.status);
212
- emitEphemeral(workspaceId, 'devserver:status', currentStatus);
213
- })
214
- .catch((err) => {
215
- console.error(`[dev-server] Failed to refresh status for workspace ${workspaceId}:`, err);
223
+ // Shell exit does not imply that its background children have stopped.
224
+ void (async () => {
225
+ while (trackedProcesses.get(workspaceId) === proc && !stoppingProcesses.has(workspaceId)) {
226
+ if (proc.pid && (await hasLiveProcessGroup(proc.pid))) {
227
+ await pollDelay(250);
228
+ continue;
229
+ }
230
+ if (trackedProcesses.get(workspaceId) !== proc || stoppingProcesses.has(workspaceId))
231
+ return;
232
+ trackedProcesses.delete(workspaceId);
233
+ const currentStatus = await getStatus(workspace.projectPath, workspace.workingBranch);
234
+ if (generations.get(workspaceId) !== generation || stoppingProcesses.has(workspaceId))
235
+ return;
236
+ updateDevServerStatus(workspaceId, currentStatus.status);
237
+ emitEphemeral(workspaceId, 'devserver:status', currentStatus);
238
+ return;
239
+ }
240
+ })().catch((err) => {
241
+ console.error(`[dev-server] Failed to confirm process-group exit for workspace ${workspaceId}:`, err);
216
242
  });
217
- if (code !== 0) {
243
+ if (code !== 0)
218
244
  console.error(`[dev-server] Process exited with code ${code} for workspace ${workspaceId}`);
219
- }
220
245
  });
221
246
  proc.on('error', (err) => {
222
- trackedProcesses.delete(workspaceId);
247
+ if (trackedProcesses.get(workspaceId) !== proc)
248
+ return;
249
+ // A signal failure is also an 'error' event; it does not prove process exit.
250
+ if (!proc.pid)
251
+ trackedProcesses.delete(workspaceId);
223
252
  updateDevServerStatus(workspaceId, 'error');
224
253
  console.error(`[dev-server] Process error for workspace ${workspaceId}:`, err);
225
254
  emitEphemeral(workspaceId, 'devserver:status', {
@@ -249,6 +278,96 @@ export function startDevServer(workspaceId) {
249
278
  * Stop the dev-server for a workspace.
250
279
  */
251
280
  export async function stopDevServer(workspaceId) {
281
+ const pending = stoppingProcesses.get(workspaceId);
282
+ if (pending)
283
+ return pending;
284
+ generations.set(workspaceId, (generations.get(workspaceId) ?? 0) + 1);
285
+ const stopping = stopDevServerOwned(workspaceId);
286
+ stoppingProcesses.set(workspaceId, stopping);
287
+ try {
288
+ return await stopping;
289
+ }
290
+ finally {
291
+ if (stoppingProcesses.get(workspaceId) === stopping)
292
+ stoppingProcesses.delete(workspaceId);
293
+ }
294
+ }
295
+ function pollDelay(ms) {
296
+ return new Promise((resolve) => {
297
+ const timer = setTimeout(resolve, ms);
298
+ timer.unref?.();
299
+ });
300
+ }
301
+ /** A zombie cannot write files, even if the OS has not reaped its PID yet. */
302
+ async function hasLiveProcessGroup(groupId) {
303
+ try {
304
+ process.kill(-groupId, 0);
305
+ }
306
+ catch (err) {
307
+ if (err.code === 'ESRCH')
308
+ return false;
309
+ throw err;
310
+ }
311
+ const processes = await runCommand('ps', ['-eo', 'pgid=,stat='], { timeout: 1000 });
312
+ return processes.split('\n').some((line) => {
313
+ const [group, state] = line.trim().split(/\s+/);
314
+ return Number(group) === groupId && !!state && !/^[ZX]/.test(state);
315
+ });
316
+ }
317
+ async function signalGroupAndWait(groupId, signal, timeoutMs) {
318
+ try {
319
+ process.kill(-groupId, signal);
320
+ }
321
+ catch (err) {
322
+ if (err.code === 'ESRCH')
323
+ return true;
324
+ throw err;
325
+ }
326
+ const deadline = Date.now() + timeoutMs;
327
+ while (await hasLiveProcessGroup(groupId)) {
328
+ if (Date.now() >= deadline)
329
+ return false;
330
+ await pollDelay(50);
331
+ }
332
+ return true;
333
+ }
334
+ function signalAndWait(proc, signal, timeoutMs) {
335
+ if (proc.pid)
336
+ return signalGroupAndWait(proc.pid, signal, timeoutMs);
337
+ if (proc.exitCode != null || proc.signalCode != null)
338
+ return Promise.resolve(true);
339
+ return new Promise((resolve, reject) => {
340
+ const finish = (exited) => {
341
+ clearTimeout(timer);
342
+ proc.removeListener('exit', onExit);
343
+ proc.removeListener('error', onError);
344
+ resolve(exited);
345
+ };
346
+ const onExit = () => finish(true);
347
+ const onError = (err) => {
348
+ clearTimeout(timer);
349
+ proc.removeListener('exit', onExit);
350
+ proc.removeListener('error', onError);
351
+ reject(err);
352
+ };
353
+ const timer = setTimeout(() => finish(false), timeoutMs);
354
+ proc.once('exit', onExit);
355
+ proc.once('error', onError);
356
+ try {
357
+ if (proc.pid)
358
+ process.kill(-proc.pid, signal);
359
+ else
360
+ proc.kill(signal);
361
+ }
362
+ catch (err) {
363
+ clearTimeout(timer);
364
+ proc.removeListener('exit', onExit);
365
+ proc.removeListener('error', onError);
366
+ reject(err);
367
+ }
368
+ });
369
+ }
370
+ async function stopDevServerOwned(workspaceId) {
252
371
  const workspace = getWorkspace(workspaceId);
253
372
  if (!workspace) {
254
373
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -260,20 +379,15 @@ export async function stopDevServer(workspaceId) {
260
379
  // Kill tracked process first (covers Node servers and any spawned process)
261
380
  const tracked = trackedProcesses.get(workspaceId);
262
381
  if (tracked) {
263
- try {
264
- if (tracked.pid) {
265
- process.kill(-tracked.pid, 'SIGTERM');
266
- }
267
- else {
268
- tracked.kill('SIGTERM');
269
- }
270
- }
271
- catch (err) {
272
- console.error('[dev-server] Failed to kill tracked process:', err instanceof Error ? err.message : err);
273
- }
274
- trackedProcesses.delete(workspaceId);
382
+ const exited = (await signalAndWait(tracked, 'SIGTERM', 3_000)) || (await signalAndWait(tracked, 'SIGKILL', 1_000));
383
+ if (!exited)
384
+ throw new Error('Dev server stop is not confirmed; retry after the process exits');
385
+ if (trackedProcesses.get(workspaceId) === tracked)
386
+ trackedProcesses.delete(workspaceId);
275
387
  }
276
388
  const settings = getProjectSettings(workspace.projectPath);
389
+ let stopError;
390
+ let customStopSucceeded = false;
277
391
  if (settings?.devServer.stopCommand) {
278
392
  try {
279
393
  await runCommand('bash', ['-c', settings.devServer.stopCommand], {
@@ -285,9 +399,10 @@ export async function stopDevServer(workspaceId) {
285
399
  },
286
400
  timeout: 30_000,
287
401
  });
402
+ customStopSucceeded = true;
288
403
  }
289
404
  catch (err) {
290
- console.error(`[dev-server] Stop command failed:`, err instanceof Error ? err.message : err);
405
+ stopError = err;
291
406
  }
292
407
  }
293
408
  // Always try docker compose down with project name if we have one
@@ -295,11 +410,26 @@ export async function stopDevServer(workspaceId) {
295
410
  if (config?.projectName) {
296
411
  try {
297
412
  await runCommand('docker', ['compose', '-p', config.projectName, 'down'], { cwd, timeout: 30_000 });
413
+ stopError = undefined;
298
414
  }
299
- catch {
300
- // May already be stopped by the custom command — ignore
415
+ catch (err) {
416
+ stopError = err;
417
+ // The custom command may use -f/--env-file absent from the generic fallback.
418
+ // Only an independent, successful Docker query can confirm it stopped everything.
419
+ if (customStopSucceeded) {
420
+ try {
421
+ const remaining = await runCommand('docker', ['ps', '--filter', `label=com.docker.compose.project=${config.projectName}`, '--format', '{{.ID}}'], { timeout: 10_000 });
422
+ if (!remaining.trim())
423
+ stopError = undefined;
424
+ }
425
+ catch {
426
+ // An unavailable daemon is not confirmation of shutdown.
427
+ }
428
+ }
301
429
  }
302
430
  }
431
+ if (stopError)
432
+ throw new Error(`Dev server stop failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
303
433
  const status = {
304
434
  status: 'stopped',
305
435
  instanceName,
@@ -338,8 +468,7 @@ export async function getDevServerLogs(workspaceId, tail = 200) {
338
468
  if (!config) {
339
469
  return 'No dev-server instance found';
340
470
  }
341
- const running = await listRunningContainers();
342
- const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
471
+ const matching = await listRunningContainers(config.projectName);
343
472
  if (matching.length === 0) {
344
473
  return 'No running containers found';
345
474
  }