@loicngr/kobo 1.11.35 → 1.11.37

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 (102) hide show
  1. package/AGENTS.md +15 -0
  2. package/CHANGELOG.md +9 -0
  3. package/dist/server/index.js +11 -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 +122 -91
  7. package/dist/server/services/agent/engines/claude-code/engine.js +26 -0
  8. package/dist/server/services/agent/engines/claude-code/event-mapper.js +10 -10
  9. package/dist/server/services/agent/engines/codex/client.js +1 -0
  10. package/dist/server/services/agent/engines/codex/engine.js +47 -10
  11. package/dist/server/services/agent/engines/codex/event-mapper.js +5 -1
  12. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +21 -8
  13. package/dist/server/services/agent/engines/codex/jsonrpc/transport.js +30 -3
  14. package/dist/server/services/agent/orchestrator.js +175 -68
  15. package/dist/server/services/agent/session-controller.js +12 -2
  16. package/dist/server/services/auto-loop-service.js +71 -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/quota-backoff-service.js +25 -8
  24. package/dist/server/services/wakeup-service.js +37 -3
  25. package/dist/server/services/worktree-purge-service.js +3 -7
  26. package/dist/server/utils/agent-stop-result.js +12 -0
  27. package/dist/server/utils/git-ops.js +8 -1
  28. package/dist/server/utils/git-worktree-fingerprint.js +86 -0
  29. package/dist/server/utils/safe-path.js +21 -0
  30. package/dist/server/utils/script-runner.js +32 -9
  31. package/dist/server/utils/workspace-lifecycle-guard.js +27 -0
  32. package/package.json +1 -1
  33. package/src/client/dist/pwa/assets/{ActivityFeed-Bakgvay6.css → ActivityFeed-BDyFBoxd.css} +1 -1
  34. package/src/client/dist/pwa/assets/ActivityFeed-C_-hxIo8.js +8 -0
  35. package/src/client/dist/pwa/assets/{ChangelogPage-DVLWWvvz.js → ChangelogPage-CBlGJ4zT.js} +1 -1
  36. package/src/client/dist/pwa/assets/{ComparisonPanel-LZrvk6a6.js → ComparisonPanel-DZAE2X9j.js} +1 -1
  37. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +1 -0
  38. package/src/client/dist/pwa/assets/CreatePage-ArmtcF4d.js +2 -0
  39. package/src/client/dist/pwa/assets/{DashboardPage-0odIhseb.js → DashboardPage-eQTCXcdz.js} +1 -1
  40. package/src/client/dist/pwa/assets/{DiffViewer-NWB2uQkz.js → DiffViewer-CaT7vuSr.js} +3 -3
  41. package/src/client/dist/pwa/assets/{DocumentsPanel-3yApHgbl.js → DocumentsPanel-A0isCTaI.js} +1 -1
  42. package/src/client/dist/pwa/assets/{DrawerToggleButton-C_bNtZsE.js → DrawerToggleButton-T70fh8dp.js} +1 -1
  43. package/src/client/dist/pwa/assets/{GitPanel-BMjWwiAX.js → GitPanel-CAvk0G7h.js} +3 -3
  44. package/src/client/dist/pwa/assets/{HealthPage-Vyr1YG2o.js → HealthPage-D8Mr0V6w.js} +1 -1
  45. package/src/client/dist/pwa/assets/{MainLayout-s8_9FPE2.js → MainLayout-x6Es4Da-.js} +2 -2
  46. package/src/client/dist/pwa/assets/{QBtnDropdown-BxuXEBp5.js → QBtnDropdown-CQqzzlFI.js} +1 -1
  47. package/src/client/dist/pwa/assets/{QDrawer-BdlDiU6a.js → QDrawer-Cwj2qh0r.js} +1 -1
  48. package/src/client/dist/pwa/assets/{QSelect-BrSemS3o.js → QSelect-BSPtuXJg.js} +1 -1
  49. package/src/client/dist/pwa/assets/{QTooltip-CLCFHW2D.js → QTooltip-DC_kDKku.js} +1 -1
  50. package/src/client/dist/pwa/assets/{SchedulePanel-F9bdAq6K.js → SchedulePanel-9aJSoz6Y.js} +1 -1
  51. package/src/client/dist/pwa/assets/{SearchPage-CBCefYqc.js → SearchPage-BjNvtE_U.js} +1 -1
  52. package/src/client/dist/pwa/assets/{SettingsPage-BPgTHmKj.js → SettingsPage-CvLnT73V.js} +1 -1
  53. package/src/client/dist/pwa/assets/{SplitWorkspacePage-DDs2nuoT.js → SplitWorkspacePage-DTao0DHr.js} +1 -1
  54. package/src/client/dist/pwa/assets/{TerminalPanel-CulE0wTU.js → TerminalPanel-BrKIW-bJ.js} +1 -1
  55. package/src/client/dist/pwa/assets/{TouchPan-DUgqs2cW.js → TouchPan-_n1AQgoo.js} +1 -1
  56. package/src/client/dist/pwa/assets/{TourReplayButton-CdHpGBdr.js → TourReplayButton-CJF3nVvn.js} +1 -1
  57. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-W5SvqzXO.js → WorkspaceAttentionLabels-CxesUiIm.js} +1 -1
  58. package/src/client/dist/pwa/assets/{WorkspacePage-DYQrfeYw.css → WorkspacePage-DoXtwpjV.css} +1 -1
  59. package/src/client/dist/pwa/assets/WorkspacePage-MxBE0mr3.js +4 -0
  60. package/src/client/dist/pwa/assets/{browser-CnuqDTyc.js → browser-DGWpkvN3.js} +1 -1
  61. package/src/client/dist/pwa/assets/{build-path-tree-JodIO7-I.js → build-path-tree-Ba1dSLgi.js} +1 -1
  62. package/src/client/dist/pwa/assets/{de-Dt_m-H83.js → de-DVK21RB6.js} +1 -1
  63. package/src/client/dist/pwa/assets/{documents-3ZBK1pXE.js → documents-BMHzjW_g.js} +1 -1
  64. package/src/client/dist/pwa/assets/{editor.api-DZ9WO_J7.js → editor.api-CYTuNwqu.js} +1 -1
  65. package/src/client/dist/pwa/assets/{efforts-LrtJPx-j.js → efforts-BPlRSHXp.js} +1 -1
  66. package/src/client/dist/pwa/assets/{engineFeatures-DmnHj_uD.js → engineFeatures-DVB2rklO.js} +1 -1
  67. package/src/client/dist/pwa/assets/{es-DFg25DCS.js → es-C8cQwDZM.js} +1 -1
  68. package/src/client/dist/pwa/assets/{expand-template-Dv4JC2zU.js → expand-template-Cj82YE-O.js} +1 -1
  69. package/src/client/dist/pwa/assets/{format-CHjfjuhr.js → format-Dk7O3LhA.js} +1 -1
  70. package/src/client/dist/pwa/assets/{formatters-BsgH8CoH.js → formatters-TGjvs7Ca.js} +1 -1
  71. package/src/client/dist/pwa/assets/{fr-CQ1slpol.js → fr-BVOMZ2Bl.js} +1 -1
  72. package/src/client/dist/pwa/assets/{freemarker2-Dla55SIR.js → freemarker2-39lO_dWH.js} +1 -1
  73. package/src/client/dist/pwa/assets/{handlebars-DtuuZy7D.js → handlebars-M_0V82cD.js} +1 -1
  74. package/src/client/dist/pwa/assets/{html-CKYOCR8W.js → html-yILqYDsl.js} +1 -1
  75. package/src/client/dist/pwa/assets/i18n-2UeMfd5m.js +1 -0
  76. package/src/client/dist/pwa/assets/{index-CIP08MpU.js → index-BJU5dnkF.js} +2 -2
  77. package/src/client/dist/pwa/assets/{it-w09rfaP7.js → it-CxkAoOj4.js} +1 -1
  78. package/src/client/dist/pwa/assets/{javascript-BxzTnsIQ.js → javascript-DrW-amDc.js} +1 -1
  79. package/src/client/dist/pwa/assets/{liquid-C8BfPFUy.js → liquid-D_mXmUoL.js} +1 -1
  80. package/src/client/dist/pwa/assets/{mdx-DZm_YXkC.js → mdx-E8O3lvR5.js} +1 -1
  81. package/src/client/dist/pwa/assets/{monaco-contributions-HDbRHiNR.js → monaco-contributions-C2K3sl_V.js} +1 -1
  82. package/src/client/dist/pwa/assets/{monaco.contribution-DRW-851R.js → monaco.contribution-DLhHQIJn.js} +2 -2
  83. package/src/client/dist/pwa/assets/{network-auth-Ce9hsryo.js → network-auth-BUwWtnui.js} +1 -1
  84. package/src/client/dist/pwa/assets/{notify-theme-OBpMEBvP.js → notify-theme-1mOpY2vN.js} +1 -1
  85. package/src/client/dist/pwa/assets/{pending-diff-open-CVQxWyzP.js → pending-diff-open-BL7dKRqF.js} +1 -1
  86. package/src/client/dist/pwa/assets/{permissionModes-CVFHS2ec.js → permissionModes-BtC3_P-P.js} +1 -1
  87. package/src/client/dist/pwa/assets/{project-color-mcpQp2o6.js → project-color-BFBFZWeG.js} +1 -1
  88. package/src/client/dist/pwa/assets/{python-CO5iQN74.js → python-DoZAYim6.js} +1 -1
  89. package/src/client/dist/pwa/assets/{razor-BC7D8LMf.js → razor-9bZ30g2y.js} +1 -1
  90. package/src/client/dist/pwa/assets/{render-chat-markdown-Cb15kvox.js → render-chat-markdown-7EtWNjG0.js} +1 -1
  91. package/src/client/dist/pwa/assets/{typescript-BCHCSNpD.js → typescript-C1CBfL3L.js} +1 -1
  92. package/src/client/dist/pwa/assets/{use-tours-DhLhglwr.js → use-tours-BJ81clv6.js} +1 -1
  93. package/src/client/dist/pwa/assets/{workspace-templates-CoWrOEac.js → workspace-templates-r1-bipjM.js} +1 -1
  94. package/src/client/dist/pwa/assets/{xml-y9M3NAIZ.js → xml-COuu5isK.js} +1 -1
  95. package/src/client/dist/pwa/assets/{yaml-CthlToPo.js → yaml-ChZ7WqaJ.js} +1 -1
  96. package/src/client/dist/pwa/index.html +1 -1
  97. package/src/client/dist/pwa/sw.js +1 -1
  98. package/src/client/dist/pwa/assets/ActivityFeed-YRbIRutK.js +0 -8
  99. package/src/client/dist/pwa/assets/CreatePage-DxOyY65u.css +0 -1
  100. package/src/client/dist/pwa/assets/CreatePage-gFrn4xl9.js +0 -2
  101. package/src/client/dist/pwa/assets/WorkspacePage-B6O7j57u.js +0 -4
  102. package/src/client/dist/pwa/assets/i18n-DTfKQUMf.js +0 -1
@@ -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
  }
@@ -1,33 +1,15 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { nanoid } from 'nanoid';
5
+ import { ensureDirectoryInside } from '../utils/safe-path.js';
4
6
  const ALLOWED_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
5
7
  const IMAGES_DIR = '.ai/images';
6
8
  const INDEX_FILE = 'index.json';
7
9
  // Per-worktree lock to serialize index.json writes
8
10
  const locks = new Map();
9
11
  function ensureRealImagesDirectory(worktreePath) {
10
- const realWorktree = fs.realpathSync(worktreePath);
11
- let current = worktreePath;
12
- for (const segment of ['.ai', 'images']) {
13
- current = path.join(current, segment);
14
- if (fs.existsSync(current)) {
15
- const stat = fs.lstatSync(current);
16
- if (stat.isSymbolicLink())
17
- throw new Error(`Symbolic links are not allowed in image storage: ${current}`);
18
- if (!stat.isDirectory())
19
- throw new Error(`Image storage path is not a directory: ${current}`);
20
- }
21
- else {
22
- fs.mkdirSync(current);
23
- }
24
- const realCurrent = fs.realpathSync(current);
25
- const relative = path.relative(realWorktree, realCurrent);
26
- if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
27
- throw new Error('Image storage path escapes the worktree');
28
- }
29
- }
30
- return fs.realpathSync(current);
12
+ return ensureDirectoryInside(worktreePath, '.ai/images');
31
13
  }
32
14
  function withLock(worktreePath, fn) {
33
15
  const prev = locks.get(worktreePath) ?? Promise.resolve();
@@ -56,9 +38,17 @@ function writeIndex(imagesDir, entries) {
56
38
  if (fs.existsSync(indexPath) && fs.lstatSync(indexPath).isSymbolicLink()) {
57
39
  throw new Error('Symbolic links are not allowed for image index');
58
40
  }
59
- const tempPath = path.join(imagesDir, `.index-${process.pid}.tmp`);
60
- fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2));
61
- fs.renameSync(tempPath, indexPath);
41
+ const tempPath = path.join(imagesDir, `.index-${randomUUID()}.tmp`);
42
+ let created = false;
43
+ try {
44
+ fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2), { flag: 'wx', mode: 0o600 });
45
+ created = true;
46
+ fs.renameSync(tempPath, indexPath);
47
+ }
48
+ finally {
49
+ if (created && fs.existsSync(tempPath))
50
+ fs.unlinkSync(tempPath);
51
+ }
62
52
  }
63
53
  /** Save an image buffer to `.ai/images/` and update the index. Returns the UID and relative path. */
64
54
  export async function saveImage(worktreePath, fileBuffer, originalName) {
@@ -75,10 +65,17 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
75
65
  await withLock(worktreePath, () => {
76
66
  // Write the image file inside the lock so both the file write and index
77
67
  // update happen atomically — avoids orphan files on crash between the two.
78
- fs.writeFileSync(path.join(imagesDir, filename), fileBuffer);
79
- const entries = readIndex(imagesDir);
80
- entries.push({ uid, originalName, createdAt: new Date().toISOString() });
81
- writeIndex(imagesDir, entries);
68
+ const imagePath = path.join(imagesDir, filename);
69
+ fs.writeFileSync(imagePath, fileBuffer, { flag: 'wx', mode: 0o600 });
70
+ try {
71
+ const entries = readIndex(imagesDir);
72
+ entries.push({ uid, originalName, createdAt: new Date().toISOString() });
73
+ writeIndex(imagesDir, entries);
74
+ }
75
+ catch (err) {
76
+ fs.unlinkSync(imagePath);
77
+ throw err;
78
+ }
82
79
  });
83
80
  return { uid, relativePath: `${IMAGES_DIR}/${filename}` };
84
81
  }
@@ -5,7 +5,10 @@ import path from 'node:path';
5
5
  import * as gitOps from '../utils/git-ops.js';
6
6
  import { GitConflictError } from '../utils/git-ops.js';
7
7
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
8
+ import { fingerprintWorktree } from '../utils/git-worktree-fingerprint.js';
9
+ import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
8
10
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
11
+ import { hasController } from './agent/orchestrator.js';
9
12
  import { createWorktree, listWorktrees, removeWorktreeUnlocked } from './worktree-service.js';
10
13
  /**
11
14
  * A `pathCollision.worktreePath` decision is untrusted input reaching
@@ -114,8 +117,10 @@ function diagnoseChanges(worktreePath) {
114
117
  /** Classify the workspace, if any, that already tracks this branch.
115
118
  * A purged worktree outranks the archived flag: purging always archives, and
116
119
  * "recreate the worktree" is the useful offer, not "unarchive". */
117
- export function resolveWorkspaceState(workspaces, branch) {
118
- const match = workspaces.find((w) => w.workingBranch === branch);
120
+ export function resolveWorkspaceState(workspaces, branch, projectPath) {
121
+ const match = workspaces.find((w) => w.workingBranch === branch &&
122
+ typeof w.projectPath === 'string' &&
123
+ path.resolve(w.projectPath) === path.resolve(projectPath));
119
124
  if (!match)
120
125
  return { state: 'none' };
121
126
  if (match.worktreePurgedAt)
@@ -134,10 +139,13 @@ function safeRevParse(repoPath, ref) {
134
139
  }
135
140
  }
136
141
  /** Hash the observed state so `/resolve` can refuse a plan built on stale facts. */
137
- export function computeFingerprint(report) {
142
+ export async function computeFingerprint(report) {
138
143
  const localHead = safeRevParse(report.projectPath, report.headBranch);
139
144
  const remoteHead = safeRevParse(report.projectPath, `origin/${report.headBranch}`);
140
145
  const payload = JSON.stringify({
146
+ contents: report.worktree.state === 'orphan' || report.worktree.state === 'attached'
147
+ ? await fingerprintWorktree(report.worktree.path)
148
+ : null,
141
149
  localHead,
142
150
  remoteHead,
143
151
  worktree: report.worktree,
@@ -174,7 +182,7 @@ export function diagnoseLocalState(projectPath, headBranch, worktreesPath, attac
174
182
  headBranch,
175
183
  targetWorktreePath,
176
184
  blockers,
177
- workspace: resolveWorkspaceState(workspaces, headBranch),
185
+ workspace: resolveWorkspaceState(workspaces, headBranch, projectPath),
178
186
  worktree,
179
187
  localChanges: inspectPath ? diagnoseChanges(inspectPath) : EMPTY_CHANGES,
180
188
  ongoingOperation: inspectPath ? gitOps.getOngoingGitOperation(inspectPath) : null,
@@ -249,14 +257,35 @@ export class StaleDiagnosisError extends Error {
249
257
  * or a source-branch change.
250
258
  */
251
259
  export function resolvePrCheckout(input) {
260
+ const initial = diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths, input.workspaces);
261
+ const owner = initial.worktree.state === 'attached'
262
+ ? initial.worktree.workspaceId
263
+ : initial.workspace.state !== 'none'
264
+ ? initial.workspace.id
265
+ : null;
266
+ const resolve = () => resolvePrCheckoutLocked(input, owner);
267
+ return owner ? withWorkspaceLifecycleGuard(owner, resolve) : resolve();
268
+ }
269
+ function resolvePrCheckoutLocked(input, owner) {
252
270
  return withGitRepoLock(input.projectPath, async () => {
271
+ if (owner && hasController(owner))
272
+ throw new Error('Stop the workspace agent before changing its checkout');
253
273
  const applied = [];
254
274
  // 1. Refresh, then refuse a plan built on facts that no longer hold.
255
275
  gitOps.fetchSourceBranch(input.projectPath, input.headBranch);
256
276
  applied.push({ kind: 'fetch', detail: `origin/${input.headBranch}` });
257
277
  const fresh = diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths ?? new Map(), input.workspaces ?? []);
258
- if (computeFingerprint(fresh) !== input.fingerprint)
278
+ if ((await computeFingerprint(fresh)) !== input.fingerprint)
259
279
  throw new StaleDiagnosisError(fresh);
280
+ if ((fresh.worktree.state === 'orphan' || fresh.worktree.state === 'attached') &&
281
+ input.decisions.orphanWorktree === 'create-elsewhere') {
282
+ throw new Error('Branch is already checked out. Attach its existing worktree instead.');
283
+ }
284
+ if (fresh.localChanges.present &&
285
+ (input.decisions.localChanges ?? 'keep') === 'keep' &&
286
+ input.decisions.divergence === 'reset-hard') {
287
+ throw new Error('Cannot preserve local changes during a hard reset. Stash or commit them first, or keep the branch.');
288
+ }
260
289
  // 2. Clear stale worktree metadata before anything needs the path.
261
290
  if (fresh.worktree.state === 'stale-metadata') {
262
291
  git(input.projectPath, ['worktree', 'prune']);