@loicngr/kobo 2.1.15 → 2.1.18

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 (128) hide show
  1. package/AGENTS.md +61 -0
  2. package/CHANGELOG.md +12 -0
  3. package/CONFIGURATION.md +74 -17
  4. package/README.md +7 -3
  5. package/dist/mcp-server/kobo-tasks-server.js +19 -104
  6. package/dist/mcp-server/workspace-dialogue-client.js +15 -3
  7. package/dist/server/db/migrations.js +2 -0
  8. package/dist/server/db/schema.js +2 -0
  9. package/dist/server/db/workspace-message-batch-schema.js +9 -0
  10. package/dist/server/index.js +8 -0
  11. package/dist/server/middleware/workspace-creation-maintenance.js +13 -0
  12. package/dist/server/routes/maintenance.js +32 -0
  13. package/dist/server/routes/mcp.js +69 -3
  14. package/dist/server/routes/pull-requests.js +2 -1
  15. package/dist/server/routes/workspace-messages.js +22 -0
  16. package/dist/server/routes/workspaces.js +60 -4
  17. package/dist/server/services/agent/orchestrator.js +21 -6
  18. package/dist/server/services/agent/session-controller.js +4 -1
  19. package/dist/server/services/cron-service.js +7 -0
  20. package/dist/server/services/pr-checkout-rollback-service.js +51 -0
  21. package/dist/server/services/pr-checkout-service.js +21 -1
  22. package/dist/server/services/quota-backoff-service.js +7 -0
  23. package/dist/server/services/update-maintenance-service.js +44 -0
  24. package/dist/server/services/wakeup-service.js +21 -3
  25. package/dist/server/services/workspace-creation-mcp-service.js +214 -0
  26. package/dist/server/services/workspace-group-message-mcp-service.js +58 -0
  27. package/dist/server/services/workspace-group-message-service.js +182 -0
  28. package/dist/server/services/workspace-lifecycle-mcp-service.js +72 -0
  29. package/dist/server/services/workspace-message-service.js +5 -0
  30. package/dist/shared/pr-checkout-steps.js +86 -0
  31. package/dist/shared/workspace-creation-tools.js +182 -0
  32. package/dist/shared/workspace-group-message-tools.js +134 -0
  33. package/dist/shared/workspace-group-messages.js +27 -0
  34. package/dist/shared/workspace-lifecycle-tools.js +112 -0
  35. package/docker-compose.vps.yml +60 -0
  36. package/docs/deployment-vps.md +506 -0
  37. package/docs/getting-started.md +9 -0
  38. package/package.json +8 -1
  39. package/scripts/kobo-gh +9 -0
  40. package/scripts/kobo-github-app-token +190 -0
  41. package/scripts/kobo-install +392 -0
  42. package/scripts/kobo-update +499 -0
  43. package/src/client/dist/pwa/assets/ActivityFeed-S3XGw3WW.js +2 -0
  44. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-lKjfaC6V.js → AutoLoopFinalReviewSettings-CC8ICe_I.js} +1 -1
  45. package/src/client/dist/pwa/assets/{ChangelogPage-BplM7qEB.js → ChangelogPage-C1aiPBoi.js} +1 -1
  46. package/src/client/dist/pwa/assets/{ComparisonPanel-DxK5v9w_.js → ComparisonPanel-DElbn4ac.js} +1 -1
  47. package/src/client/dist/pwa/assets/CreatePage-Bx58t4YW.js +2 -0
  48. package/src/client/dist/pwa/assets/{DashboardPage-BsP-MATS.js → DashboardPage-D1S3QGp4.js} +1 -1
  49. package/src/client/dist/pwa/assets/DiffViewer-BH4X9ru1.js +8 -0
  50. package/src/client/dist/pwa/assets/{DocumentsPanel-CGrf8Q3H.js → DocumentsPanel-C4AvyZF3.js} +1 -1
  51. package/src/client/dist/pwa/assets/{DrawerToggleButton-BANeLSsR.js → DrawerToggleButton-LzIEeeJY.js} +1 -1
  52. package/src/client/dist/pwa/assets/GitPanel-BP3N_4nh.js +3 -0
  53. package/src/client/dist/pwa/assets/{HealthPage-BVTNx-92.js → HealthPage-Cm13vxOi.js} +1 -1
  54. package/src/client/dist/pwa/assets/MainLayout-DGmMkfuw.js +2 -0
  55. package/src/client/dist/pwa/assets/{MainLayout-C6nvIQjU.css → MainLayout-DoAEgANU.css} +1 -1
  56. package/src/client/dist/pwa/assets/{MemoryPanel-C71rTpv9.js → MemoryPanel-C6tzCv6D.js} +1 -1
  57. package/src/client/dist/pwa/assets/{MemoryProposalList-CoqhUGrv.js → MemoryProposalList-CzpzIirK.js} +1 -1
  58. package/src/client/dist/pwa/assets/{QBtnDropdown-U9Ey5W8h.js → QBtnDropdown-BnuKPevV.js} +1 -1
  59. package/src/client/dist/pwa/assets/{QExpansionItem-Be_A1MT1.js → QExpansionItem-DjaUhgjt.js} +1 -1
  60. package/src/client/dist/pwa/assets/{QScrollArea-D4lYzAPQ.js → QScrollArea-Dpksslru.js} +1 -1
  61. package/src/client/dist/pwa/assets/QSelect-Dh4CjliE.js +1 -0
  62. package/src/client/dist/pwa/assets/{QTooltip-kStOgY0X.js → QTooltip-CH45NuVs.js} +1 -1
  63. package/src/client/dist/pwa/assets/{SchedulePanel-C7nTGdWE.js → SchedulePanel-QAjXuTIN.js} +1 -1
  64. package/src/client/dist/pwa/assets/{SearchPage-C6J72pkQ.js → SearchPage-B1pbwDej.js} +1 -1
  65. package/src/client/dist/pwa/assets/SettingsPage-CUQefsDh.js +9 -0
  66. package/src/client/dist/pwa/assets/{SplitWorkspacePage-Bqn4gdVw.js → SplitWorkspacePage-CJ7e-KPh.js} +1 -1
  67. package/src/client/dist/pwa/assets/{TerminalPanel-zLeDCReI.js → TerminalPanel-DVomz3oo.js} +10 -10
  68. package/src/client/dist/pwa/assets/{ToolCallItem-B8b_Sagj.js → ToolCallItem-BntDHc6p.js} +3 -3
  69. package/src/client/dist/pwa/assets/{TouchPan-Bl8VzIDK.js → TouchPan-DwqfsAxH.js} +1 -1
  70. package/src/client/dist/pwa/assets/{TourReplayButton-pL0HCboU.js → TourReplayButton-OIavXdUg.js} +1 -1
  71. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-9Eomx0rQ.js → WorkspaceAttentionLabels-CYwN1-Rf.js} +1 -1
  72. package/src/client/dist/pwa/assets/WorkspacePage-CAsY7Hl-.js +4 -0
  73. package/src/client/dist/pwa/assets/{browser-C-PJ4alw.js → browser-Cl0d1gbu.js} +1 -1
  74. package/src/client/dist/pwa/assets/{build-path-tree-SW9rj7B_.js → build-path-tree-J3YQeMuV.js} +1 -1
  75. package/src/client/dist/pwa/assets/{custom-sounds-toz-4drX.js → custom-sounds-D-T5rpgv.js} +1 -1
  76. package/src/client/dist/pwa/assets/de-C-RtXLqL.js +17 -0
  77. package/src/client/dist/pwa/assets/{editor.api-BJTPhnh8.js → editor.api-BoFiCjBZ.js} +1 -1
  78. package/src/client/dist/pwa/assets/{efforts-BO9xGn9q.js → efforts-Y6XotW97.js} +1 -1
  79. package/src/client/dist/pwa/assets/es-CBlW91ZK.js +17 -0
  80. package/src/client/dist/pwa/assets/{expand-template-BOZMtWau.js → expand-template-sfOHWudd.js} +1 -1
  81. package/src/client/dist/pwa/assets/{format-hEnpRyit.js → format-mWov137C.js} +1 -1
  82. package/src/client/dist/pwa/assets/{formatters-DD_eFc4z.js → formatters-B_XsveM0.js} +1 -1
  83. package/src/client/dist/pwa/assets/fr-Hfzp_sF5.js +17 -0
  84. package/src/client/dist/pwa/assets/{freemarker2-B6nUofvT.js → freemarker2-2pGNakhH.js} +1 -1
  85. package/src/client/dist/pwa/assets/{handlebars-4P3gtM8p.js → handlebars-Bn2V6Liw.js} +1 -1
  86. package/src/client/dist/pwa/assets/{html-C9ZzWo-y.js → html-BBhkJkf2.js} +1 -1
  87. package/src/client/dist/pwa/assets/i18n-sBCWwf8Y.js +1 -0
  88. package/src/client/dist/pwa/assets/index-DCpe-Tfm.js +20 -0
  89. package/src/client/dist/pwa/assets/it-Bl-v27qg.js +17 -0
  90. package/src/client/dist/pwa/assets/{javascript-BGEC5ggd.js → javascript-Ba0G68U2.js} +1 -1
  91. package/src/client/dist/pwa/assets/{liquid-DNtF630O.js → liquid-C5VrzHr_.js} +1 -1
  92. package/src/client/dist/pwa/assets/{mdx-CICwyOd0.js → mdx-DrPRiuKb.js} +1 -1
  93. package/src/client/dist/pwa/assets/{monaco-contributions-BkuwvgrT.js → monaco-contributions-qQUY6VS7.js} +1 -1
  94. package/src/client/dist/pwa/assets/{monaco.contribution-GX8XA-PE.js → monaco.contribution-Dtq2RsyB.js} +2 -2
  95. package/src/client/dist/pwa/assets/network-auth-BPJpXK4L.js +1 -0
  96. package/src/client/dist/pwa/assets/notify-theme-Bl9iRsZB.js +1 -0
  97. package/src/client/dist/pwa/assets/{project-color-B1SMFjSe.js → project-color-Dzvvmaml.js} +1 -1
  98. package/src/client/dist/pwa/assets/{python-n5uPdTVS.js → python-Cuf_7umq.js} +1 -1
  99. package/src/client/dist/pwa/assets/{razor-AOj0nsUt.js → razor-DcVpxFeh.js} +1 -1
  100. package/src/client/dist/pwa/assets/{typescript-Uhkrqihh.js → typescript-Bjcc3kYC.js} +1 -1
  101. package/src/client/dist/pwa/assets/{use-search-index-status-CW0BdpBZ.js → use-search-index-status-Bwf40BEI.js} +1 -1
  102. package/src/client/dist/pwa/assets/use-tours-CWafZt-m.js +2 -0
  103. package/src/client/dist/pwa/assets/{use-worktree-restore-BZxibZS5.js → use-worktree-restore-DkcM2FAZ.js} +1 -1
  104. package/src/client/dist/pwa/assets/{workspace-templates-W557e1-m.js → workspace-templates-DL_Cs7mD.js} +1 -1
  105. package/src/client/dist/pwa/assets/{xml-B-hnMgEs.js → xml-BmJwqJOz.js} +1 -1
  106. package/src/client/dist/pwa/assets/{yaml-DqXkTvgm.js → yaml-DAssfLhS.js} +1 -1
  107. package/src/client/dist/pwa/index.html +1 -1
  108. package/src/client/dist/pwa/sw.js +1 -1
  109. package/src/mcp-server/README.md +130 -12
  110. package/src/mcp-server/kobo-tasks-server.ts +27 -105
  111. package/src/mcp-server/workspace-dialogue-client.ts +18 -3
  112. package/src/client/dist/pwa/assets/ActivityFeed-D7jouiuP.js +0 -2
  113. package/src/client/dist/pwa/assets/CreatePage-D5ilP5kt.js +0 -2
  114. package/src/client/dist/pwa/assets/DiffViewer-Cd15vFWj.js +0 -8
  115. package/src/client/dist/pwa/assets/GitPanel-CEPV5Qw0.js +0 -3
  116. package/src/client/dist/pwa/assets/MainLayout-DBKtwilW.js +0 -2
  117. package/src/client/dist/pwa/assets/QSelect-fS0DLOd1.js +0 -1
  118. package/src/client/dist/pwa/assets/SettingsPage-SuH-aIzv.js +0 -9
  119. package/src/client/dist/pwa/assets/WorkspacePage-B6clgURl.js +0 -4
  120. package/src/client/dist/pwa/assets/de-Ddxe8cc6.js +0 -17
  121. package/src/client/dist/pwa/assets/es-DBJIQeWW.js +0 -17
  122. package/src/client/dist/pwa/assets/fr-yBzIIPaQ.js +0 -17
  123. package/src/client/dist/pwa/assets/i18n-2-ULabk2.js +0 -1
  124. package/src/client/dist/pwa/assets/index-Vk99jtxD.js +0 -20
  125. package/src/client/dist/pwa/assets/it-D44U-PT1.js +0 -17
  126. package/src/client/dist/pwa/assets/network-auth-KnWzjYIc.js +0 -1
  127. package/src/client/dist/pwa/assets/notify-theme-CWuZTz4Z.js +0 -1
  128. package/src/client/dist/pwa/assets/use-tours-CndFlyL-.js +0 -2
@@ -20,6 +20,7 @@ import gitRouter from './routes/git.js';
20
20
  import healthRouter from './routes/health.js';
21
21
  import imagesRouter from './routes/images.js';
22
22
  import integrationsRouter from './routes/integrations.js';
23
+ import maintenanceRouter from './routes/maintenance.js';
23
24
  import mcpRouter from './routes/mcp.js';
24
25
  import memoryRouter from './routes/memory.js';
25
26
  import { migrationRouter } from './routes/migration.js';
@@ -32,6 +33,7 @@ import soundsRouter from './routes/sounds.js';
32
33
  import templatesRouter from './routes/templates.js';
33
34
  import usageRoutes from './routes/usage.js';
34
35
  import voiceRouter from './routes/voice.js';
36
+ import workspaceMessagesRouter from './routes/workspace-messages.js';
35
37
  import workspaceTemplatesRouter from './routes/workspace-templates.js';
36
38
  import workspacesRouter from './routes/workspaces.js';
37
39
  import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, resumePendingReviewReturns, setBackendPort, startAgent, startWatchdog, stopAgentAndWait, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
@@ -57,6 +59,7 @@ import { startUpdateChecker, stopUpdateChecker } from './services/update-check-s
57
59
  import { startUsagePoller, stopUsagePoller } from './services/usage/index.js';
58
60
  import * as wakeupService from './services/wakeup-service.js';
59
61
  import { handleConnection, setMessageHandler } from './services/websocket-service.js';
62
+ import { reconcileGroupMessageBatches, stopGroupMessageBatches } from './services/workspace-group-message-service.js';
60
63
  import { deliverWorkspaceMessage } from './services/workspace-message-service.js';
61
64
  import { getWorkspace } from './services/workspace-service.js';
62
65
  import { resolveRetentionConfig } from './services/ws-events-retention-service.js';
@@ -84,6 +87,7 @@ catch (err) {
84
87
  }
85
88
  runMigrations(db);
86
89
  reconcileMessageRequests(db);
90
+ reconcileGroupMessageBatches(db);
87
91
  reconcileMemoryContextsOnStartup();
88
92
  startSearchIndex();
89
93
  // Event retention. OPT-IN: disabled by default, so this is a no-op until the
@@ -155,6 +159,7 @@ app.use('/api/*', networkAuthMiddleware);
155
159
  app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion() }));
156
160
  // Mount route sub-routers
157
161
  app.route('/api/workspaces', workspacesRouter);
162
+ app.route('/api/workspace-messages', workspaceMessagesRouter);
158
163
  app.route('/api/mcp', mcpRouter);
159
164
  app.route('/api/memory', memoryRouter);
160
165
  app.route('/api/pull-requests', pullRequestsRouter);
@@ -174,6 +179,7 @@ app.route('/api/workspaces', documentsRouter);
174
179
  app.route('/api/workspaces', exportRouter);
175
180
  app.route('/api/search', searchRouter);
176
181
  app.route('/api/health', healthRouter);
182
+ app.route('/api/maintenance', maintenanceRouter);
177
183
  app.route('/api/engines', enginesRouter);
178
184
  app.route('/api/environment', environmentRouter);
179
185
  app.route('/api/integrations', integrationsRouter);
@@ -413,6 +419,7 @@ async function gracefulShutdown(signal, exitCode = 0) {
413
419
  return;
414
420
  isShuttingDown = true;
415
421
  stopUpdateChecker();
422
+ const groupMessagesStopped = stopGroupMessageBatches();
416
423
  console.log(`\n[kobo] Received ${signal}, shutting down gracefully…`);
417
424
  const forceExitTimer = setTimeout(() => {
418
425
  console.error('[kobo] Graceful shutdown timed out; forcing exit');
@@ -479,6 +486,7 @@ async function gracefulShutdown(signal, exitCode = 0) {
479
486
  finally {
480
487
  try {
481
488
  const stopped = await Promise.allSettled([
489
+ groupMessagesStopped,
482
490
  stopWsEventsRetention(),
483
491
  stopSearchIndex(),
484
492
  dailyBackupScheduler.stop(),
@@ -0,0 +1,13 @@
1
+ import { beginWorkspaceCreation } from '../services/update-maintenance-service.js';
2
+ /** Admission remains held across every await and the handler's rollback paths. */
3
+ export const workspaceCreationMaintenance = async (c, next) => {
4
+ const release = beginWorkspaceCreation();
5
+ if (!release)
6
+ return c.json({ code: 'update-maintenance', error: 'Workspace creation is unavailable during update maintenance' }, 503);
7
+ try {
8
+ await next();
9
+ }
10
+ finally {
11
+ release();
12
+ }
13
+ };
@@ -0,0 +1,32 @@
1
+ import { Hono } from 'hono';
2
+ import { isIdleForUpdate, resumeAfterUpdateMaintenance } from '../services/agent/orchestrator.js';
3
+ import { acquireUpdateMaintenance, isUpdateMaintenanceActive, releaseUpdateMaintenance, } from '../services/update-maintenance-service.js';
4
+ import { preserveForUpdateMaintenance } from '../services/wakeup-service.js';
5
+ /** Protected by the same network-token/Host/Origin middleware as other APIs. */
6
+ const app = new Hono();
7
+ app.get('/update', (c) => c.json({ protocol: 1, active: isUpdateMaintenanceActive() }));
8
+ app.on(['POST', 'DELETE'], '/update', async (c) => {
9
+ const body = await c.req.json().catch(() => null);
10
+ if (!body || typeof body.owner !== 'string' || !/^[a-f0-9]{32}$/.test(body.owner)) {
11
+ return c.json({ error: 'Expected a 32-character update reservation identifier' }, 400);
12
+ }
13
+ if (c.req.method === 'DELETE') {
14
+ if (!releaseUpdateMaintenance(body.owner))
15
+ return c.json({ error: 'Update reservation belongs to another owner' }, 409);
16
+ resumeAfterUpdateMaintenance();
17
+ return c.json({ protocol: 1, active: false });
18
+ }
19
+ // Both operations are synchronous: no agent can start between this check and reservation.
20
+ const result = acquireUpdateMaintenance(body.owner, () => {
21
+ if (!isIdleForUpdate())
22
+ return false;
23
+ // Commit deferral before acknowledging admission. Future wakeups may become
24
+ // overdue while the stopped volume is backed up and migrated.
25
+ preserveForUpdateMaintenance();
26
+ return true;
27
+ });
28
+ if (result !== 'acquired')
29
+ return c.json({ error: result === 'busy' ? 'Kōbō is busy' : 'Update already reserved' }, 409);
30
+ return c.json({ protocol: 1, active: true });
31
+ });
32
+ export default app;
@@ -4,21 +4,73 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
4
4
  import { Hono } from 'hono';
5
5
  import { bodyLimit } from 'hono/body-limit';
6
6
  import { EXTERNAL_MEMORY_TOOL_DEFINITIONS, isMemoryToolName } from '../../shared/memory-tools.js';
7
+ import { isWorkspaceCreationTool, MAX_MCP_CREATION_REQUEST_BYTES, WORKSPACE_CREATION_TOOLS, } from '../../shared/workspace-creation-tools.js';
7
8
  import { EXTERNAL_MCP_TOOLS } from '../../shared/workspace-dialogue-tools.js';
9
+ import { isWorkspaceGroupMessageTool, WORKSPACE_GROUP_MESSAGE_TOOLS, } from '../../shared/workspace-group-message-tools.js';
10
+ import { isWorkspaceLifecycleTool, WORKSPACE_LIFECYCLE_TOOLS } from '../../shared/workspace-lifecycle-tools.js';
8
11
  import { createMcpClientContext } from '../services/mcp-client-context.js';
9
12
  import { executeMemoryMcpTool } from '../services/memory-mcp-service.js';
13
+ import { executeWorkspaceCreationTool, WorkspaceCreationMcpError } from '../services/workspace-creation-mcp-service.js';
10
14
  import { executeWorkspaceDialogueTool } from '../services/workspace-dialogue-service.js';
15
+ import { executeWorkspaceGroupMessageTool, WorkspaceGroupMessageMcpError, } from '../services/workspace-group-message-mcp-service.js';
16
+ import { executeWorkspaceLifecycleTool, WorkspaceLifecycleMcpError, } from '../services/workspace-lifecycle-mcp-service.js';
11
17
  import { prepareMemoryToolEnvelope } from '../utils/memory-token-budget.js';
12
18
  const app = new Hono();
13
- app.use('*', bodyLimit({ maxSize: 1024 * 1024 }));
19
+ // Only creation envelopes may carry the larger, base64 attachment payload.
20
+ // Other tools retain their existing 1 MiB limit, including malformed envelopes.
21
+ app.use('*', bodyLimit({ maxSize: MAX_MCP_CREATION_REQUEST_BYTES }));
22
+ app.use('*', async (c, next) => {
23
+ if (c.req.raw.body) {
24
+ const body = await c.req.raw.clone().arrayBuffer();
25
+ if (body.byteLength > 1024 * 1024) {
26
+ let creation = false;
27
+ try {
28
+ const envelope = JSON.parse(new TextDecoder().decode(body));
29
+ creation = envelope?.method === 'tools/call' && envelope?.params?.name === 'create_workspace';
30
+ }
31
+ catch {
32
+ /* Preserve the normal body-size rejection for malformed input. */
33
+ }
34
+ if (!creation)
35
+ return c.json({ error: 'MCP request is too large' }, 413);
36
+ }
37
+ }
38
+ return next();
39
+ });
40
+ /** Dispatch only the supported MCP operations through the existing UI handlers. */
41
+ async function dispatchWorkspaceRequest(pathname, init) {
42
+ if (pathname === '/api/workspaces/archived')
43
+ throw new Error('Bulk operations are not supported by workspace MCP tools');
44
+ const isWorkspaceOperation = (init.method === 'POST' &&
45
+ (pathname === '/api/workspaces' ||
46
+ /^\/api\/workspaces\/[^/]+\/(unarchive|archive|purge-worktree|restore-worktree)$/.test(pathname))) ||
47
+ ((init.method === 'GET' || init.method === 'DELETE') && /^\/api\/workspaces\/[^/]+$/.test(pathname));
48
+ if (isWorkspaceOperation) {
49
+ const { default: routes } = await import('./workspaces.js');
50
+ return routes.request(`http://kobo.internal${pathname.slice('/api/workspaces'.length) || '/'}`, init);
51
+ }
52
+ if (init.method === 'POST' &&
53
+ (pathname === '/api/pull-requests/diagnose' || pathname === '/api/pull-requests/resolve')) {
54
+ const { default: routes } = await import('./pull-requests.js');
55
+ return routes.request(`http://kobo.internal${pathname.slice('/api/pull-requests'.length)}`, init);
56
+ }
57
+ throw new Error('Unsupported workspace operation');
58
+ }
14
59
  app.all('/', async (c) => {
15
60
  const server = new Server({ name: 'kobo-workspaces', version: '1.0.0' }, { capabilities: { tools: {} } });
16
61
  const transport = new WebStandardStreamableHTTPServerTransport({
17
62
  sessionIdGenerator: undefined,
18
63
  enableJsonResponse: true,
64
+ maxRequestBodySize: MAX_MCP_CREATION_REQUEST_BYTES,
19
65
  });
20
66
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
21
- tools: [...EXTERNAL_MCP_TOOLS, ...EXTERNAL_MEMORY_TOOL_DEFINITIONS],
67
+ tools: [
68
+ ...EXTERNAL_MCP_TOOLS,
69
+ ...WORKSPACE_CREATION_TOOLS,
70
+ ...WORKSPACE_LIFECYCLE_TOOLS,
71
+ ...WORKSPACE_GROUP_MESSAGE_TOOLS,
72
+ ...EXTERNAL_MEMORY_TOOL_DEFINITIONS,
73
+ ],
22
74
  }));
23
75
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
24
76
  const isMemory = isMemoryToolName(request.params.name);
@@ -26,7 +78,13 @@ app.all('/', async (c) => {
26
78
  const source = createMcpClientContext(c.req.header('X-Kobo-Client-Name'), c.req.header('X-Kobo-Client-Transport') === 'stdio' ? 'stdio' : 'http', c.req.header('X-Kobo-Client-Name-Encoding'));
27
79
  const data = isMemory
28
80
  ? executeMemoryMcpTool(request.params.name, request.params.arguments ?? {}, source)
29
- : await executeWorkspaceDialogueTool(request.params.name, request.params.arguments ?? {}, source);
81
+ : isWorkspaceCreationTool(request.params.name)
82
+ ? await executeWorkspaceCreationTool(request.params.name, request.params.arguments ?? {}, dispatchWorkspaceRequest)
83
+ : isWorkspaceLifecycleTool(request.params.name)
84
+ ? await executeWorkspaceLifecycleTool(request.params.name, request.params.arguments ?? {}, dispatchWorkspaceRequest)
85
+ : isWorkspaceGroupMessageTool(request.params.name)
86
+ ? executeWorkspaceGroupMessageTool(request.params.name, request.params.arguments ?? {}, source)
87
+ : await executeWorkspaceDialogueTool(request.params.name, request.params.arguments ?? {}, source);
30
88
  if (isMemory) {
31
89
  const bounded = prepareMemoryToolEnvelope(data, { targetTokens: 1_000, transport: 'external' });
32
90
  if (bounded.data.memoryOutputSuppressed === true)
@@ -46,6 +104,14 @@ app.all('/', async (c) => {
46
104
  };
47
105
  }
48
106
  catch (error) {
107
+ if (error instanceof WorkspaceGroupMessageMcpError) {
108
+ const data = { error: error.message, status: error.status };
109
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
110
+ }
111
+ if (error instanceof WorkspaceCreationMcpError || error instanceof WorkspaceLifecycleMcpError) {
112
+ const data = { error: error.message, status: error.status, stage: error.stage, details: error.details };
113
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
114
+ }
49
115
  if (isMemory) {
50
116
  const bounded = prepareMemoryToolEnvelope({
51
117
  error: error instanceof Error ? error.message : String(error),
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { Hono } from 'hono';
3
+ import { workspaceCreationMaintenance } from '../middleware/workspace-creation-maintenance.js';
3
4
  import { getForgeProvider } from '../services/forge/registry.js';
4
5
  import { resolveForge } from '../services/forge/resolve.js';
5
6
  import * as prCheckout from '../services/pr-checkout-service.js';
@@ -86,7 +87,7 @@ app.post('/diagnose', async (c) => {
86
87
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
87
88
  }
88
89
  });
89
- app.post('/resolve', async (c) => {
90
+ app.post('/resolve', workspaceCreationMaintenance, async (c) => {
90
91
  try {
91
92
  const body = await c.req
92
93
  .json()
@@ -0,0 +1,22 @@
1
+ import { Hono } from 'hono';
2
+ import { bodyLimit } from 'hono/body-limit';
3
+ import { MAX_GROUP_MESSAGE_REQUEST_BYTES } from '../../shared/workspace-group-messages.js';
4
+ import { GroupMessageError, getGroupMessageBatch, startGroupMessageBatch, } from '../services/workspace-group-message-service.js';
5
+ const app = new Hono();
6
+ app.use('*', bodyLimit({ maxSize: MAX_GROUP_MESSAGE_REQUEST_BYTES }));
7
+ app.post('/', async (c) => {
8
+ const body = await c.req.json().catch(() => null);
9
+ try {
10
+ return c.json(startGroupMessageBatch(body), 202);
11
+ }
12
+ catch (error) {
13
+ if (error instanceof GroupMessageError)
14
+ return c.json({ error: error.message }, error.status);
15
+ throw error;
16
+ }
17
+ });
18
+ app.get('/:id', (c) => {
19
+ const batch = getGroupMessageBatch(c.req.param('id'));
20
+ return batch ? c.json(batch) : c.json({ error: 'Group message not found' }, 404);
21
+ });
22
+ export default app;
@@ -19,6 +19,7 @@ import { MAX_ATTACHMENT_REQUEST_BYTES } from '../../shared/attachments.js';
19
19
  import { AUTO_LOOP_HARD_RULES, buildAutoLoopGroomingSteps, buildGroomingIntro } from '../../shared/auto-loop-prompts.js';
20
20
  import { getDb } from '../db/index.js';
21
21
  import { migrationGuard } from '../middleware/migration-guard.js';
22
+ import { workspaceCreationMaintenance } from '../middleware/workspace-creation-maintenance.js';
22
23
  import { listEngines } from '../services/agent/engines/registry.js';
23
24
  import * as agentManager from '../services/agent/orchestrator.js';
24
25
  import * as archiveScriptService from '../services/archive-script-service.js';
@@ -105,8 +106,16 @@ app.use('/:id/*', async (c, next) => {
105
106
  }
106
107
  await next();
107
108
  });
108
- // These handlers own teardown across awaits; purge/delete/restore own their guard in the service.
109
- for (const route of ['/:id/archive', '/:id/run-setup-script', '/:id/cancel-source-change', '/:id/rollback-file']) {
109
+ // Branch/path mutations must also exclude teardown across awaits.
110
+ // Purge/delete/restore own their guard in the service.
111
+ for (const route of [
112
+ '/:id/archive',
113
+ '/:id/run-setup-script',
114
+ '/:id/cancel-source-change',
115
+ '/:id/rollback-file',
116
+ '/:id/rename-branch',
117
+ '/:id/resync-branch',
118
+ ]) {
110
119
  app.use(route, async (c, next) => {
111
120
  try {
112
121
  await withWorkspaceLifecycleGuard(c.req.param('id'), next);
@@ -444,7 +453,7 @@ const creationBodyLimit = bodyLimit({
444
453
  maxSize: MAX_ATTACHMENT_REQUEST_BYTES,
445
454
  onError: (c) => c.json({ error: 'Workspace creation request is too large', step: 'validate' }, 413),
446
455
  });
447
- app.post('/', migrationGuard, creationBodyLimit, async (c) => {
456
+ app.post('/', workspaceCreationMaintenance, migrationGuard, creationBodyLimit, async (c) => {
448
457
  // Declared outside the try so the outermost catch (any unforeseen throw,
449
458
  // including a malformed request body) can still name a step instead of
450
459
  // leaving the create-progress channel silent. Updated in lockstep with
@@ -570,6 +579,7 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
570
579
  // the working branch from git itself — the body.workingBranch is ignored.
571
580
  let useReusedWorktree = false;
572
581
  let reusedDerivedBranch = null;
582
+ let reusedIdentity = null;
573
583
  if (body.worktreePath) {
574
584
  currentStep = 'inspect-worktree';
575
585
  emitCreateProgress(creationId, currentStep);
@@ -598,6 +608,14 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
598
608
  return c.json({ error: failure, step: 'inspect-worktree' }, 422);
599
609
  }
600
610
  reusedDerivedBranch = branch;
611
+ const directory = fs.statSync(body.worktreePath);
612
+ const gitEntry = fs.statSync(path.join(body.worktreePath, '.git'));
613
+ reusedIdentity = {
614
+ device: directory.dev,
615
+ inode: directory.ino,
616
+ gitDevice: gitEntry.dev,
617
+ gitInode: gitEntry.ino,
618
+ };
601
619
  }
602
620
  catch (err) {
603
621
  const message = err instanceof Error ? err.message : String(err);
@@ -781,6 +799,36 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
781
799
  const creation = await withGitRepoLock(body.projectPath, async () => {
782
800
  let prospectiveWorktreePath;
783
801
  if (useReusedWorktree) {
802
+ // Imports above may yield long enough for a failed PR creation to remove
803
+ // its checkout. Recheck the exact directory and branch under the same
804
+ // repository lock as compensation before any record/context files exist.
805
+ try {
806
+ const worktreePath = body.worktreePath;
807
+ const directory = fs.statSync(worktreePath);
808
+ const gitEntry = fs.statSync(path.join(worktreePath, '.git'));
809
+ const commonDir = execFileSync('git', ['-C', worktreePath, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
810
+ encoding: 'utf-8',
811
+ timeout: 30_000,
812
+ }).trim();
813
+ const branch = execFileSync('git', ['-C', worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
814
+ encoding: 'utf-8',
815
+ timeout: 30_000,
816
+ }).trim();
817
+ if (!reusedIdentity ||
818
+ !directory.isDirectory() ||
819
+ directory.dev !== reusedIdentity.device ||
820
+ directory.ino !== reusedIdentity.inode ||
821
+ gitEntry.dev !== reusedIdentity.gitDevice ||
822
+ gitEntry.ino !== reusedIdentity.gitInode ||
823
+ realPathOrResolved(commonDir) !== realPathOrResolved(path.join(body.projectPath, '.git')) ||
824
+ branch !== reusedDerivedBranch)
825
+ throw new Error('The worktree directory, repository or branch changed during creation; retry from a fresh diagnosis');
826
+ }
827
+ catch (err) {
828
+ const failure = `Failed to revalidate worktree: ${err instanceof Error ? err.message : String(err)}`;
829
+ emitCreateFailed(creationId, 'inspect-worktree', failure);
830
+ return c.json({ error: failure, step: 'inspect-worktree' }, 422);
831
+ }
784
832
  // Another attachment may have completed while this request waited for the lock.
785
833
  const existing = getDb().prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
786
834
  if (existing) {
@@ -3180,6 +3228,12 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
3180
3228
  return c.json({ error: message }, workspaceErrorStatus(err));
3181
3229
  }
3182
3230
  });
3231
+ class WorkspaceBranchConfirmationError extends Error {
3232
+ code = 'branch-confirmation-mismatch';
3233
+ constructor() {
3234
+ super('The workspace branch changed or does not match confirmationBranch. Confirm its current branch before deleting.');
3235
+ }
3236
+ }
3183
3237
  // Shared teardown for a single workspace: stops the agent, destroys the
3184
3238
  // terminal, removes the owned worktree, optionally deletes local/remote
3185
3239
  // branches, then deletes the DB row (cascades to tasks/sessions/events).
@@ -3191,6 +3245,8 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
3191
3245
  const current = workspaceService.getWorkspace(workspace.id);
3192
3246
  if (!current)
3193
3247
  throw new Error(`Workspace '${workspace.id}' not found`);
3248
+ if (opts.confirmationBranch !== undefined && opts.confirmationBranch !== current.workingBranch)
3249
+ throw new WorkspaceBranchConfirmationError();
3194
3250
  // A bulk deletion may have captured the list before restoration completed.
3195
3251
  if (workspace.archivedAt && !current.archivedAt) {
3196
3252
  throw new Error('Workspace was unarchived while deletion was pending. Retry from its current state.');
@@ -3335,7 +3391,7 @@ app.delete('/:id', migrationGuard, async (c) => {
3335
3391
  return c.json({ ok: true, warnings }, 200);
3336
3392
  }
3337
3393
  catch (err) {
3338
- if (err instanceof WorkspaceLifecycleBusyError)
3394
+ if (err instanceof WorkspaceLifecycleBusyError || err instanceof WorkspaceBranchConfirmationError)
3339
3395
  return c.json({ code: err.code, error: err.message }, 409);
3340
3396
  const message = err instanceof Error ? err.message : String(err);
3341
3397
  return c.json({ error: message }, workspaceErrorStatus(err));
@@ -20,6 +20,7 @@ import { buildReviewReturnPrompt, claimReviewReturn, completeReviewReturn, getRe
20
20
  import { activateSession, SESSION_RECENCY_ORDER } from '../session-activity-service.js';
21
21
  import { requestHandoffStop, suspendHandoffTransfers } from '../session-handoff-runtime.js';
22
22
  import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
23
+ import { isUpdateMaintenanceActive } from '../update-maintenance-service.js';
23
24
  import { refreshNow } from '../usage/poller.js';
24
25
  import * as wakeupService from '../wakeup-service.js';
25
26
  import { emit, emitEphemeral } from '../websocket-service.js';
@@ -67,7 +68,7 @@ let shuttingDown = false;
67
68
  let capacityTimer;
68
69
  const stoppedCapacityOwners = new Set();
69
70
  function notifyCapacityAvailable(stoppedWorkspaceId) {
70
- if (shuttingDown)
71
+ if (isShuttingDown())
71
72
  return;
72
73
  stoppedCapacityOwners.add(stoppedWorkspaceId);
73
74
  if (capacityTimer)
@@ -77,7 +78,7 @@ function notifyCapacityAvailable(stoppedWorkspaceId) {
77
78
  capacityTimer = undefined;
78
79
  const excluded = new Set(stoppedCapacityOwners);
79
80
  stoppedCapacityOwners.clear();
80
- if (shuttingDown)
81
+ if (isShuttingDown())
81
82
  return;
82
83
  try {
83
84
  resumePendingReviewReturns();
@@ -1365,7 +1366,7 @@ function revokeControllerMemoryCapability(controller) {
1365
1366
  const reviewReturnDispatchOwners = new Map();
1366
1367
  /** Resume durable review work only after the previous runtime has closed and admission allows it. */
1367
1368
  export function resumePendingReviewReturns(onlyWorkspaceId) {
1368
- if (shuttingDown)
1369
+ if (isShuttingDown())
1369
1370
  return;
1370
1371
  for (const pending of listReviewReturns()) {
1371
1372
  const id = pending.workspaceId;
@@ -1465,6 +1466,8 @@ export function cancelReviewReturn(workspaceId) {
1465
1466
  * query the controller via `_getControllers()` for tests.
1466
1467
  */
1467
1468
  export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort, beforeDispatch, launch) {
1469
+ if (isUpdateMaintenanceActive())
1470
+ throw new Error('Cannot start an agent during update maintenance');
1468
1471
  if (shuttingDown)
1469
1472
  throw new Error('Cannot start an agent while the server is shutting down');
1470
1473
  assertWorkspaceLifecycleAvailable(workspaceId, launch?.lifecycleOwner);
@@ -1914,7 +1917,20 @@ export async function stopAllAgents(timeoutMs = 3_000) {
1914
1917
  }
1915
1918
  /** Automatic continuations must preserve their durable intent during teardown. */
1916
1919
  export function isShuttingDown() {
1917
- return shuttingDown;
1920
+ return shuttingDown || isUpdateMaintenanceActive();
1921
+ }
1922
+ /** Used synchronously by update reservation; include runtimes still closing and pending replacements. */
1923
+ export function isIdleForUpdate() {
1924
+ if (shuttingDown || controllers.size || pendingStarts.size)
1925
+ return false;
1926
+ const db = getDb();
1927
+ return (!db.prepare("SELECT 1 FROM agent_sessions WHERE status = 'running' LIMIT 1").get() &&
1928
+ !db
1929
+ .prepare("SELECT 1 FROM workspaces WHERE status IN ('extracting','brainstorming','executing','compacting') LIMIT 1")
1930
+ .get());
1931
+ }
1932
+ export function resumeAfterUpdateMaintenance() {
1933
+ notifyCapacityAvailable('');
1918
1934
  }
1919
1935
  /** Deliver a scheduled check without stopping background work or cancelling the wakeup as user input. */
1920
1936
  export function sendWakeupIfWaiting(workspaceId, content, expectedSessionId) {
@@ -1947,8 +1963,7 @@ export async function sendMessage(workspaceId, content, expectedSessionId, befor
1947
1963
  throw new Error(`Session '${expectedSessionId}' is not active for workspace '${workspaceId}'`);
1948
1964
  }
1949
1965
  wakeupService.cancel(workspaceId, 'user-message');
1950
- beforeDispatch?.();
1951
- await ctrl.sendMessage(content);
1966
+ await ctrl.sendMessage(content, beforeDispatch);
1952
1967
  }
1953
1968
  export async function sendMessageForFallback(workspaceId, content) {
1954
1969
  assertWorkspaceLifecycleAvailable(workspaceId);
@@ -106,7 +106,7 @@ export class SessionController {
106
106
  }
107
107
  this._status = 'running';
108
108
  }
109
- async sendMessage(content) {
109
+ async sendMessage(content, beforeDispatch) {
110
110
  if (!this._startPromise)
111
111
  throw new Error('SessionController not started');
112
112
  await this._startPromise;
@@ -114,6 +114,9 @@ export class SessionController {
114
114
  throw new Error('SessionController is stopping');
115
115
  if (!this._engineProcess)
116
116
  throw new Error('SessionController not started');
117
+ // Startup may have yielded while the workspace's delivery mode changed.
118
+ // Validate immediately before handing the message to the engine.
119
+ beforeDispatch?.();
117
120
  await this._engineProcess.sendMessage(content);
118
121
  }
119
122
  interrupt() {
@@ -7,6 +7,7 @@ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
7
7
  import * as orchestrator from './agent/orchestrator.js';
8
8
  import * as autoLoopService from './auto-loop-service.js';
9
9
  import * as settingsService from './settings-service.js';
10
+ import { deferForUpdateMaintenance } from './update-maintenance-service.js';
10
11
  import { emitEphemeral } from './websocket-service.js';
11
12
  export const MIN_DELAY_BETWEEN_FIRES_SECONDS = 60;
12
13
  // Node `setTimeout` stores the delay as a 32-bit signed int. Anything above
@@ -185,6 +186,12 @@ export function listAll() {
185
186
  function fireOrSkip(id) {
186
187
  if (suspended)
187
188
  return;
189
+ const pendingTimer = timers.get(id);
190
+ if (deferForUpdateMaintenance(`cron:${id}`, () => {
191
+ if (timers.get(id) === pendingTimer)
192
+ fireOrSkip(id);
193
+ }))
194
+ return;
188
195
  try {
189
196
  timers.delete(id);
190
197
  const db = getDb();
@@ -0,0 +1,51 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getDb } from '../db/index.js';
5
+ import { buildNonInteractiveGitEnv } from '../utils/git-ops.js';
6
+ import { withGitRepoLock } from '../utils/git-repo-lock.js';
7
+ /** Undo only our unchanged checkout, never the PR branch or earlier checkout decisions. */
8
+ export async function rollbackCreatedPrCheckout(projectPath, checkout) {
9
+ const retained = (reason) => ({ removed: false, worktreePath: checkout.worktreePath, reason });
10
+ if (!checkout.createdWorktree)
11
+ return retained('The checkout existed before this request');
12
+ try {
13
+ return await withGitRepoLock(projectPath, () => {
14
+ // Workspace adoption uses this same lock. Keep checks and removal synchronous
15
+ // so another request cannot attach this checkout between the ownership read and removal.
16
+ const owners = getDb().prepare('SELECT worktree_path FROM workspaces').all();
17
+ const resolved = fs.realpathSync(checkout.worktreePath);
18
+ if (owners.some(({ worktree_path }) => {
19
+ try {
20
+ return fs.realpathSync(worktree_path) === resolved;
21
+ }
22
+ catch {
23
+ return path.resolve(worktree_path) === resolved;
24
+ }
25
+ }))
26
+ return retained('The checkout is attached to a workspace');
27
+ const stat = fs.lstatSync(checkout.worktreePath);
28
+ const identity = checkout.createdWorktree;
29
+ if (!stat.isDirectory() || stat.dev !== identity.device || stat.ino !== identity.inode)
30
+ return retained('The checkout directory was replaced');
31
+ const git = (cwd, args) => execFileSync('git', args, {
32
+ cwd,
33
+ encoding: 'utf8',
34
+ timeout: 30_000,
35
+ maxBuffer: 16 * 1024 * 1024,
36
+ env: buildNonInteractiveGitEnv(),
37
+ }).trim();
38
+ if (git(resolved, ['rev-parse', 'HEAD']) !== identity.head ||
39
+ git(resolved, ['symbolic-ref', '--short', 'HEAD']) !== checkout.workingBranch)
40
+ return retained('The checkout branch or HEAD changed');
41
+ if (git(resolved, ['status', '--porcelain', '--untracked-files=all', '--ignored']))
42
+ return retained('The checkout contains local or ignored files');
43
+ // No --force: Git performs its own final dirty/locked-worktree checks.
44
+ git(projectPath, ['worktree', 'remove', '--', resolved]);
45
+ return { removed: true, worktreePath: checkout.worktreePath };
46
+ });
47
+ }
48
+ catch (error) {
49
+ return retained(error instanceof Error ? error.message : String(error));
50
+ }
51
+ }
@@ -227,6 +227,8 @@ export async function applyBranchStrategy(repoPath, branch, strategy) {
227
227
  if (strategy === 'keep')
228
228
  return { strategy, backupBranch: null };
229
229
  if (strategy === 'fast-forward') {
230
+ // A caller cannot turn fast-forward into a silent reset of local commits.
231
+ await readGitAsync(repoPath, ['merge-base', '--is-ancestor', branch, remoteRef]);
230
232
  await runGitAsync(repoPath, ['branch', '--force', branch, remoteRef]);
231
233
  return { strategy, backupBranch: null };
232
234
  }
@@ -301,6 +303,9 @@ function resolvePrCheckoutLocked(input, owner) {
301
303
  const fresh = await diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths ?? new Map(), input.workspaces ?? []);
302
304
  if ((await computeFingerprint(fresh)) !== input.fingerprint)
303
305
  throw new StaleDiagnosisError(fresh);
306
+ if (input.decisions.divergence === 'fast-forward' &&
307
+ (fresh.branch.state === 'ahead' || fresh.branch.state === 'diverged'))
308
+ throw new Error('Fast-forward cannot discard local commits. Keep, rebase or explicitly reset the branch.');
304
309
  if ((fresh.worktree.state === 'orphan' || fresh.worktree.state === 'attached') &&
305
310
  input.decisions.orphanWorktree === 'create-elsewhere') {
306
311
  throw new Error('Branch is already checked out. Attach its existing worktree instead.');
@@ -392,7 +397,16 @@ function resolvePrCheckoutLocked(input, owner) {
392
397
  // realignment from step 4 (if any) is NOT undone here — a `reset-hard`
393
398
  // strategy already left its own `kobo-backup/<branch>-<ts>` safety net,
394
399
  // and fast-forward/rebase are non-destructive by construction.
400
+ let createdWorktree;
395
401
  try {
402
+ if (created) {
403
+ const stat = fs.lstatSync(worktreePath);
404
+ createdWorktree = {
405
+ device: stat.dev,
406
+ inode: stat.ino,
407
+ head: await runGitAsync(worktreePath, ['rev-parse', 'HEAD']),
408
+ };
409
+ }
396
410
  input.afterWorktreeHook?.();
397
411
  }
398
412
  catch (err) {
@@ -409,7 +423,13 @@ function resolvePrCheckoutLocked(input, owner) {
409
423
  }
410
424
  throw err;
411
425
  }
412
- return { worktreePath, workingBranch: input.headBranch, sourceBranch: input.baseBranch, applied };
426
+ return {
427
+ worktreePath,
428
+ workingBranch: input.headBranch,
429
+ sourceBranch: input.baseBranch,
430
+ applied,
431
+ ...(createdWorktree ? { createdWorktree } : {}),
432
+ };
413
433
  });
414
434
  }
415
435
  /** Realign a branch from inside its own worktree, where ref surgery is refused. */
@@ -1,4 +1,5 @@
1
1
  import { getDb } from '../db/index.js';
2
+ import { deferForUpdateMaintenance } from './update-maintenance-service.js';
2
3
  import { emitEphemeral } from './websocket-service.js';
3
4
  import { getWorkspace } from './workspace-service.js';
4
5
  const timers = new Map();
@@ -142,6 +143,12 @@ export function restoreOnBoot(onFire) {
142
143
  function fireOrSkip(workspaceId) {
143
144
  if (suspended)
144
145
  return;
146
+ const pendingTimer = timers.get(workspaceId);
147
+ if (deferForUpdateMaintenance(`quota-backoff:${workspaceId}`, () => {
148
+ if (timers.get(workspaceId) === pendingTimer)
149
+ fireOrSkip(workspaceId);
150
+ }))
151
+ return;
145
152
  timers.delete(workspaceId);
146
153
  const pending = getPending(workspaceId);
147
154
  if (!pending)