@loicngr/kobo 1.11.43 → 1.11.44

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 (144) hide show
  1. package/AGENTS.md +36 -1
  2. package/CHANGELOG.md +6 -0
  3. package/dist/mcp-server/kobo-tasks-handlers.js +31 -74
  4. package/dist/mcp-server/kobo-tasks-server.js +51 -4
  5. package/dist/server/db/auto-loop-schema.js +44 -0
  6. package/dist/server/db/migrations.js +4 -0
  7. package/dist/server/db/review-return-schema.js +10 -0
  8. package/dist/server/db/schema.js +4 -0
  9. package/dist/server/index.js +10 -9
  10. package/dist/server/routes/auto-loop-messages.js +51 -0
  11. package/dist/server/routes/workspaces.js +46 -136
  12. package/dist/server/services/agent/engines/claude-code/engine.js +1 -0
  13. package/dist/server/services/agent/engines/codex/engine.js +91 -31
  14. package/dist/server/services/agent/orchestrator.js +237 -88
  15. package/dist/server/services/agent/session-controller.js +42 -2
  16. package/dist/server/services/auto-loop-message-service.js +79 -0
  17. package/dist/server/services/auto-loop-service.js +225 -86
  18. package/dist/server/services/auto-loop-state-service.js +30 -0
  19. package/dist/server/services/cron-service.js +30 -16
  20. package/dist/server/services/quota-backoff-service.js +42 -10
  21. package/dist/server/services/review-return-service.js +64 -0
  22. package/dist/server/services/review-service.js +187 -0
  23. package/dist/server/services/settings-service.js +3 -3
  24. package/dist/server/services/task-mutations.js +186 -0
  25. package/dist/server/services/wakeup-service.js +16 -1
  26. package/dist/server/services/workspace-message-service.js +16 -2
  27. package/dist/server/services/workspace-service.js +25 -36
  28. package/dist/shared/auto-loop-prompts.js +22 -11
  29. package/dist/shared/auto-loop-types.js +1 -0
  30. package/dist/shared/review.js +6 -0
  31. package/dist/shared/task-verification.js +11 -0
  32. package/package.json +12 -12
  33. package/src/client/dist/pwa/assets/{ActivityFeed-D1e-zV3O.js → ActivityFeed-D_y4aikp.js} +1 -1
  34. package/src/client/dist/pwa/assets/{ChangelogPage-DX1WGFea.js → ChangelogPage-DSKCvpr2.js} +1 -1
  35. package/src/client/dist/pwa/assets/{ClosePopup-BqW5dy-O.js → ClosePopup-CWjIRHxu.js} +1 -1
  36. package/src/client/dist/pwa/assets/{ComparisonPanel-B4ne55Xj.js → ComparisonPanel-DDxcsxyC.js} +1 -1
  37. package/src/client/dist/pwa/assets/{CreatePage-Bx_GpUni.js → CreatePage-DKVEknbL.js} +2 -2
  38. package/src/client/dist/pwa/assets/{DashboardPage-Cv6HJEcO.js → DashboardPage-Dg2s9nnv.js} +1 -1
  39. package/src/client/dist/pwa/assets/{DiffViewer-COwGxPt6.js → DiffViewer-BSzNScIr.js} +4 -4
  40. package/src/client/dist/pwa/assets/{DocumentsPanel-DCXpLLb7.js → DocumentsPanel-DfEoI_f7.js} +1 -1
  41. package/src/client/dist/pwa/assets/{DrawerToggleButton-DRL3RCkM.js → DrawerToggleButton-D85PwhKb.js} +1 -1
  42. package/src/client/dist/pwa/assets/GitPanel-CuG8MlgF.js +3 -0
  43. package/src/client/dist/pwa/assets/{HealthPage-XrOuMoDJ.js → HealthPage-CaLCpejR.js} +1 -1
  44. package/src/client/dist/pwa/assets/MainLayout-CDxYIJ_s.js +2 -0
  45. package/src/client/dist/pwa/assets/MainLayout-DTLNunOi.css +1 -0
  46. package/src/client/dist/pwa/assets/{QBadge-DbkxGtht.js → QBadge-FWC1zDCq.js} +1 -1
  47. package/src/client/dist/pwa/assets/{QBanner-DhqH95GW.js → QBanner-CcRI_QIy.js} +1 -1
  48. package/src/client/dist/pwa/assets/QBtn-DEdx_Y1V.js +1 -0
  49. package/src/client/dist/pwa/assets/{QBtnDropdown-163oM6T_.js → QBtnDropdown-qJWGcZpC.js} +1 -1
  50. package/src/client/dist/pwa/assets/{QBtnGroup-DELKYo3u.js → QBtnGroup-CAoD6G0Q.js} +1 -1
  51. package/src/client/dist/pwa/assets/{QBtnToggle-BI3k3Ch2.js → QBtnToggle-C_hQ2UCQ.js} +1 -1
  52. package/src/client/dist/pwa/assets/{QChip-owciRuZu.js → QChip-DCPUhqXX.js} +1 -1
  53. package/src/client/dist/pwa/assets/QDrawer-DX3PhMqC.js +1 -0
  54. package/src/client/dist/pwa/assets/{QItemSection-D_5_0UrX.js → QItemSection-BhqphaH6.js} +1 -1
  55. package/src/client/dist/pwa/assets/{QList-qupzuEqO.js → QList-CHm4JNP0.js} +1 -1
  56. package/src/client/dist/pwa/assets/{QMarkupTable-B9QuKtK_.js → QMarkupTable-CNpiA9oq.js} +1 -1
  57. package/src/client/dist/pwa/assets/{QPage-DaX9sMQC.js → QPage-BXBw2Alx.js} +1 -1
  58. package/src/client/dist/pwa/assets/QSelect-CY4TWW_j.js +1 -0
  59. package/src/client/dist/pwa/assets/{QSpinnerDots-DnCV3VzI.js → QSpinnerDots-BkFiCsH3.js} +1 -1
  60. package/src/client/dist/pwa/assets/{QTooltip-BQWY7aeM.js → QTooltip-cDK5AOQ5.js} +1 -1
  61. package/src/client/dist/pwa/assets/{SchedulePanel-Bos94OyR.js → SchedulePanel-LvHiZ6ZH.js} +1 -1
  62. package/src/client/dist/pwa/assets/{SearchPage-DOHkP5lj.js → SearchPage-BO-J4aNc.js} +1 -1
  63. package/src/client/dist/pwa/assets/{SettingsPage-DtGsd0XH.js → SettingsPage-Aw89fRSq.js} +2 -2
  64. package/src/client/dist/pwa/assets/{SplitWorkspacePage-DL4kp9Ah.js → SplitWorkspacePage-BIEceNwH.js} +1 -1
  65. package/src/client/dist/pwa/assets/{TerminalPanel-DHDnyiYh.js → TerminalPanel-DitOCad1.js} +1 -1
  66. package/src/client/dist/pwa/assets/{TouchPan-BnHXDR6F.js → TouchPan-DvPAhZsa.js} +1 -1
  67. package/src/client/dist/pwa/assets/{TourReplayButton-ClVcB6mO.js → TourReplayButton-CJYdH3Jf.js} +1 -1
  68. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-Bxe6oIJI.js → WorkspaceAttentionLabels-CY-9z9nw.js} +1 -1
  69. package/src/client/dist/pwa/assets/WorkspacePage-DAVo-E4p.css +1 -0
  70. package/src/client/dist/pwa/assets/WorkspacePage-xH_OyA3r.js +4 -0
  71. package/src/client/dist/pwa/assets/{browser-Bkx4fbxl.js → browser-BsB6cFv4.js} +1 -1
  72. package/src/client/dist/pwa/assets/build-path-tree-CW-ER1Mt.js +1 -0
  73. package/src/client/dist/pwa/assets/{de-BU0dILBs.js → de-Mu7biceO.js} +2 -2
  74. package/src/client/dist/pwa/assets/documents-SCd3w8xV.js +1 -0
  75. package/src/client/dist/pwa/assets/{editor.api-DreEG0If.js → editor.api-BUdM7I6y.js} +1 -1
  76. package/src/client/dist/pwa/assets/editor.worker-iO5iN0DB.js +33 -0
  77. package/src/client/dist/pwa/assets/efforts-DTJQP9or.js +9 -0
  78. package/src/client/dist/pwa/assets/engineFeatures-B-9jHocY.css +1 -0
  79. package/src/client/dist/pwa/assets/engineFeatures-Bj6Ob41d.js +1 -0
  80. package/src/client/dist/pwa/assets/{es-CNp0s9na.js → es-BY5IfNI2.js} +2 -2
  81. package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +1 -0
  82. package/src/client/dist/pwa/assets/{format-B7z3F-Mw.js → format-BjQxf4pe.js} +3 -3
  83. package/src/client/dist/pwa/assets/{formatters-BTBiU0v3.js → formatters-BejhmNTV.js} +1 -1
  84. package/src/client/dist/pwa/assets/{fr-RqJyWSdm.js → fr-BMXCzFCM.js} +2 -2
  85. package/src/client/dist/pwa/assets/{freemarker2-BZNTVQ3K.js → freemarker2-5d5GhyRp.js} +1 -1
  86. package/src/client/dist/pwa/assets/{handlebars-k8tN0dI3.js → handlebars-DaafEsDK.js} +1 -1
  87. package/src/client/dist/pwa/assets/{html-iZL3bbXq.js → html-CwHBDd2A.js} +1 -1
  88. package/src/client/dist/pwa/assets/i18n-CgWW5vfb.js +1 -0
  89. package/src/client/dist/pwa/assets/index-0wtBh1Xt.css +1 -0
  90. package/src/client/dist/pwa/assets/index-Di9Sz5E8.js +20 -0
  91. package/src/client/dist/pwa/assets/{it-DSxNuCwL.js → it-ByPZQK_K.js} +2 -2
  92. package/src/client/dist/pwa/assets/{javascript-TV6gg0MO.js → javascript-DSNIEEH5.js} +1 -1
  93. package/src/client/dist/pwa/assets/{liquid-Cifa5ujF.js → liquid-Dma1UpA1.js} +1 -1
  94. package/src/client/dist/pwa/assets/{mdx-Bnj_8yGE.js → mdx-D8rqEAws.js} +1 -1
  95. package/src/client/dist/pwa/assets/{monaco-contributions-pEh0n9Wk.js → monaco-contributions-2E7O2zoi.js} +1 -1
  96. package/src/client/dist/pwa/assets/{monaco.contribution-D7NmJUJH.js → monaco.contribution-DqOgo72G.js} +2 -2
  97. package/src/client/dist/pwa/assets/{network-auth-3_5p4fdc.js → network-auth-DNmhJTZn.js} +1 -1
  98. package/src/client/dist/pwa/assets/{notify-theme-D_nf9AeF.js → notify-theme-B6Hiwvwb.js} +1 -1
  99. package/src/client/dist/pwa/assets/{pending-diff-open-CUnxC1xw.js → pending-diff-open-CeiVIFZn.js} +1 -1
  100. package/src/client/dist/pwa/assets/permissionModes-DWdtf81n.js +36 -0
  101. package/src/client/dist/pwa/assets/{pinia-BKqmoVru.js → pinia-CTJ3OfP3.js} +1 -1
  102. package/src/client/dist/pwa/assets/{private.use-form-C7umbGO-.js → private.use-form-Bk8ti264.js} +1 -1
  103. package/src/client/dist/pwa/assets/{project-color-CihwpDAA.js → project-color-BCp9SWoI.js} +1 -1
  104. package/src/client/dist/pwa/assets/{python-DOoNBSNL.js → python-f-9NqRlI.js} +1 -1
  105. package/src/client/dist/pwa/assets/{razor-Bvq5vVpJ.js → razor-DsCmoX0h.js} +1 -1
  106. package/src/client/dist/pwa/assets/reactivity.esm-bundler-Bo9anJjg.js +1 -0
  107. package/src/client/dist/pwa/assets/render-chat-markdown-DK5BD9mU.js +67 -0
  108. package/src/client/dist/pwa/assets/{runtime-core.esm-bundler-CDnVUfeZ.js → runtime-core.esm-bundler-CNFKm4ij.js} +1 -1
  109. package/src/client/dist/pwa/assets/{typescript-VVzxoeyM.js → typescript-cEHg4L10.js} +1 -1
  110. package/src/client/dist/pwa/assets/{use-checkbox-FnauAgTp.js → use-checkbox-CnvHBhSK.js} +1 -1
  111. package/src/client/dist/pwa/assets/{use-id-BIuaWKkB.js → use-id-wXd4c8FA.js} +1 -1
  112. package/src/client/dist/pwa/assets/use-is-mobile-DpKI3XMy.js +1 -0
  113. package/src/client/dist/pwa/assets/{use-search-index-status-DtLzygcy.js → use-search-index-status-Bluf9L99.js} +1 -1
  114. package/src/client/dist/pwa/assets/use-tours-BmTbosmB.js +2 -0
  115. package/src/client/dist/pwa/assets/{workspace-templates-1ui80Kqo.js → workspace-templates-DUWDLMIX.js} +1 -1
  116. package/src/client/dist/pwa/assets/{xml-CzGFIxQa.js → xml-Plt-Y7we.js} +1 -1
  117. package/src/client/dist/pwa/assets/{yaml-Bl7nDJeC.js → yaml-ScEmaAeN.js} +1 -1
  118. package/src/client/dist/pwa/index.html +10 -10
  119. package/src/client/dist/pwa/sw.js +1 -1
  120. package/src/mcp-server/kobo-tasks-handlers.ts +68 -104
  121. package/src/mcp-server/kobo-tasks-server.ts +56 -4
  122. package/src/client/dist/pwa/assets/GitPanel-C9mz4GXF.js +0 -3
  123. package/src/client/dist/pwa/assets/MainLayout-BwV89R-l.css +0 -1
  124. package/src/client/dist/pwa/assets/MainLayout-CwWavQjV.js +0 -2
  125. package/src/client/dist/pwa/assets/QBtn-CZaBclNw.js +0 -1
  126. package/src/client/dist/pwa/assets/QDrawer-DNEj8wLZ.js +0 -1
  127. package/src/client/dist/pwa/assets/QSelect-BUlOQe5k.js +0 -1
  128. package/src/client/dist/pwa/assets/WorkspacePage-Clc4Fl3B.js +0 -4
  129. package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +0 -1
  130. package/src/client/dist/pwa/assets/build-path-tree-BpSY7N5O.js +0 -1
  131. package/src/client/dist/pwa/assets/documents-BIlWh3o-.js +0 -1
  132. package/src/client/dist/pwa/assets/editor.worker-CwQAd_xr.js +0 -30
  133. package/src/client/dist/pwa/assets/efforts-D_yTzymv.js +0 -9
  134. package/src/client/dist/pwa/assets/engineFeatures-C33LTVkK.js +0 -1
  135. package/src/client/dist/pwa/assets/expand-template-wRhAXT-1.js +0 -1
  136. package/src/client/dist/pwa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
  137. package/src/client/dist/pwa/assets/i18n--PCet00a.js +0 -1
  138. package/src/client/dist/pwa/assets/index-CPfrY38K.js +0 -20
  139. package/src/client/dist/pwa/assets/index-CUoUy6tH.css +0 -1
  140. package/src/client/dist/pwa/assets/permissionModes-DC51Pz84.js +0 -36
  141. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DEJj9cFv.js +0 -1
  142. package/src/client/dist/pwa/assets/render-chat-markdown-BQ_saiy5.js +0 -67
  143. package/src/client/dist/pwa/assets/use-is-mobile-CWjqe8gC.js +0 -1
  144. package/src/client/dist/pwa/assets/use-tours-BKeYrJaQ.js +0 -2
@@ -0,0 +1,79 @@
1
+ import { getDb } from '../db/index.js';
2
+ import { emitEphemeral } from './websocket-service.js';
3
+ export function listLoopMessages(workspaceId) {
4
+ return getDb()
5
+ .prepare(`SELECT id,client_message_id AS clientMessageId,content,state,session_id AS sessionId,
6
+ created_at AS createdAt FROM auto_loop_messages WHERE workspace_id=? AND state!='delivered' ORDER BY id`)
7
+ .all(workspaceId);
8
+ }
9
+ export function hasLoopMessage(workspaceId, clientMessageId) {
10
+ return !!getDb()
11
+ .prepare('SELECT 1 FROM auto_loop_messages WHERE workspace_id=? AND client_message_id=?')
12
+ .get(workspaceId, clientMessageId);
13
+ }
14
+ export function enqueueLoopMessage(workspaceId, content, clientMessageId, source) {
15
+ if (!content.trim() || !clientMessageId.trim())
16
+ throw new Error('Message content and clientMessageId are required');
17
+ if (content.length > 100_000 || clientMessageId.length > 200)
18
+ throw new Error('Message is too large');
19
+ const db = getDb();
20
+ db.transaction(() => {
21
+ const existing = db
22
+ .prepare('SELECT content,source FROM auto_loop_messages WHERE workspace_id=? AND client_message_id=?')
23
+ .get(workspaceId, clientMessageId);
24
+ const encodedSource = source ? JSON.stringify(source) : null;
25
+ if (existing) {
26
+ if (existing.content !== content || existing.source !== encodedSource)
27
+ throw new Error('Message identifier already used with different content');
28
+ return;
29
+ }
30
+ db.prepare('INSERT INTO auto_loop_messages(workspace_id,client_message_id,content,source,created_at) VALUES (?,?,?,?,?)').run(workspaceId, clientMessageId, content, encodedSource, new Date().toISOString());
31
+ // Instructions received after an earlier validation require a fresh final pass.
32
+ db.prepare("UPDATE tasks SET status='pending',verification=NULL WHERE workspace_id=? AND role='finalization'").run(workspaceId);
33
+ })();
34
+ emitEphemeral(workspaceId, 'autoloop:messages', {});
35
+ }
36
+ /** Claim before dispatch; an interrupted claim must never be replayed blindly. */
37
+ export function claimLoopMessages(workspaceId) {
38
+ return getDb().transaction(() => {
39
+ const messages = listLoopMessages(workspaceId).filter((m) => m.state === 'pending');
40
+ getDb()
41
+ .prepare("UPDATE auto_loop_messages SET state='dispatching' WHERE workspace_id=? AND state='pending'")
42
+ .run(workspaceId);
43
+ return messages;
44
+ })();
45
+ }
46
+ export function bindLoopMessages(workspaceId, sessionId) {
47
+ getDb()
48
+ .prepare("UPDATE auto_loop_messages SET session_id=? WHERE workspace_id=? AND state='dispatching' AND session_id IS NULL")
49
+ .run(sessionId, workspaceId);
50
+ emitEphemeral(workspaceId, 'autoloop:messages', {});
51
+ }
52
+ /** Return the number settled by this turn, excluding earlier deliveries on a resumed session. */
53
+ export function settleLoopMessages(workspaceId, sessionId, completed) {
54
+ if (!sessionId)
55
+ return 0;
56
+ const result = getDb()
57
+ .prepare("UPDATE auto_loop_messages SET state=? WHERE workspace_id=? AND session_id=? AND state='dispatching'")
58
+ .run(completed ? 'delivered' : 'unknown', workspaceId, sessionId);
59
+ emitEphemeral(workspaceId, 'autoloop:messages', {});
60
+ return result.changes;
61
+ }
62
+ export function recoverLoopMessages(workspaceId) {
63
+ getDb()
64
+ .prepare("UPDATE auto_loop_messages SET state='unknown' WHERE workspace_id=? AND state='dispatching'")
65
+ .run(workspaceId);
66
+ }
67
+ export function resolveLoopMessage(workspaceId, id, action) {
68
+ const row = getDb()
69
+ .prepare('SELECT state FROM auto_loop_messages WHERE workspace_id=? AND id=?')
70
+ .get(workspaceId, id);
71
+ if (!row)
72
+ throw new Error('Queued message not found');
73
+ if (action === 'cancel' ? row.state !== 'pending' : row.state !== 'unknown')
74
+ throw new Error('Message is already being delivered or has been resolved');
75
+ getDb()
76
+ .prepare('UPDATE auto_loop_messages SET state=?,session_id=NULL WHERE workspace_id=? AND id=?')
77
+ .run(action === 'retry' ? 'pending' : 'delivered', workspaceId, id);
78
+ emitEphemeral(workspaceId, 'autoloop:messages', {});
79
+ }
@@ -1,23 +1,26 @@
1
1
  import fs from 'node:fs';
2
- import { buildE2eIterationBlock, buildFinalizationIterationBlock } from '../../shared/auto-loop-prompts.js';
2
+ import { AUTO_LOOP_HARD_RULES, AUTO_LOOP_ITERATION_RULES, buildAutoLoopGroomingSteps, 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
5
  import { deferUntilWorkspaceAvailable, isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
6
6
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
7
7
  import * as orchestrator from './agent/orchestrator.js';
8
+ import { bindLoopMessages, claimLoopMessages, enqueueLoopMessage, hasLoopMessage, listLoopMessages, recoverLoopMessages, settleLoopMessages, } from './auto-loop-message-service.js';
9
+ import { getRuntime, setRuntime } from './auto-loop-state-service.js';
8
10
  import * as cleanupScriptService from './cleanup-script-service.js';
9
11
  import * as lifecycleHookService from './lifecycle-hook-service.js';
10
12
  import * as quotaBackoffService from './quota-backoff-service.js';
11
13
  import * as settingsService from './settings-service.js';
12
14
  import { getSuitePrompts } from './skill-suite-prompts.js';
15
+ import { invalidateTaskFinalization } from './task-mutations.js';
13
16
  import { emit, emitEphemeral } from './websocket-service.js';
14
- import { listTasks, updateWorkspaceStatus } from './workspace-service.js';
17
+ import { createTask, listTasks, updateWorkspaceStatus } from './workspace-service.js';
15
18
  const NO_PROGRESS_STALL_THRESHOLD = 3;
16
19
  function getRow(workspaceId) {
17
20
  const db = getDb();
18
21
  const row = db
19
22
  .prepare(`SELECT id, project_path, working_branch, worktree_path, model, permission_mode, agent_permission_mode, reasoning_effort,
20
- status, auto_loop, auto_loop_ready, auto_loop_session_mode, no_progress_streak, archived_at
23
+ status, auto_loop, auto_loop_ready, auto_loop_session_mode, no_progress_streak, archived_at, worktree_purged_at
21
24
  FROM workspaces WHERE id = ?`)
22
25
  .get(workspaceId);
23
26
  return row ?? null;
@@ -38,25 +41,81 @@ function countDoneTasks(workspaceId) {
38
41
  }
39
42
  export function getStatus(workspaceId) {
40
43
  const row = getRow(workspaceId);
41
- if (!row)
42
- return { auto_loop: false, auto_loop_ready: false, no_progress_streak: 0 };
44
+ const runtime = getRuntime(workspaceId, row?.auto_loop_ready === 1);
45
+ const pending = quotaBackoffService.getPending(workspaceId);
43
46
  return {
44
- auto_loop: row.auto_loop === 1,
45
- auto_loop_ready: row.auto_loop_ready === 1,
46
- no_progress_streak: row.no_progress_streak,
47
+ ...runtime,
48
+ state: !row?.auto_loop
49
+ ? runtime.state === 'completed'
50
+ ? 'completed'
51
+ : 'stopped'
52
+ : runtime.state === 'blocked'
53
+ ? 'blocked'
54
+ : pending || row.status === 'quota' || row.status === 'awaiting-user' || row.status === 'compacting'
55
+ ? 'waiting'
56
+ : runtime.state,
57
+ reason: pending ? pending.reason : row?.status === 'awaiting-user' ? 'awaiting-user' : runtime.reason,
58
+ retry_at: pending?.targetAt ?? null,
59
+ auto_loop: row?.auto_loop === 1,
60
+ auto_loop_ready: row?.auto_loop_ready === 1,
61
+ no_progress_streak: row?.no_progress_streak ?? 0,
47
62
  };
48
63
  }
64
+ /** Durable instructions join the next iteration, never a stale engine session. */
65
+ export function queueInstruction(workspaceId, content, clientMessageId, source) {
66
+ if (hasLoopMessage(workspaceId, clientMessageId)) {
67
+ enqueueLoopMessage(workspaceId, content, clientMessageId, source);
68
+ return true;
69
+ }
70
+ const row = getRow(workspaceId);
71
+ if (!row?.auto_loop)
72
+ return false;
73
+ if (row.archived_at || row.worktree_purged_at || isWorkspaceLifecycleBusy(workspaceId))
74
+ throw new Error('Workspace is unavailable');
75
+ enqueueLoopMessage(workspaceId, content, clientMessageId, source);
76
+ spawnNextIteration(workspaceId);
77
+ return true;
78
+ }
79
+ /** Incomplete work keeps its intent, but requires an explicit human resume. */
80
+ export function block(workspaceId, reason) {
81
+ if (getRow(workspaceId)?.auto_loop !== 1)
82
+ return;
83
+ quotaBackoffService.cancel(workspaceId, 'completed');
84
+ setRuntime(workspaceId, { state: 'blocked', reason });
85
+ }
86
+ /** Shared admission gate for all unattended starts. */
87
+ export function canStartAutomatically(workspaceId) {
88
+ const row = getRow(workspaceId);
89
+ return (!!row &&
90
+ !row.archived_at &&
91
+ !row.worktree_purged_at &&
92
+ !orchestrator.isShuttingDown() &&
93
+ !isWorkspaceLifecycleBusy(workspaceId) &&
94
+ !orchestrator.hasController(workspaceId) &&
95
+ !['awaiting-user', 'compacting'].includes(row.status) &&
96
+ // A manual workspace keeps its quota status after expiry. Its explicit
97
+ // wakeup/cron may resume then; an enabled loop still belongs to quota recovery.
98
+ !(row.status === 'quota' && row.auto_loop === 1) &&
99
+ !quotaBackoffService.getPending(workspaceId) &&
100
+ getRuntime(workspaceId).state !== 'blocked' &&
101
+ hasFreeAgentSlot());
102
+ }
49
103
  /**
50
104
  * Enable auto-loop for the workspace. Spawns immediately if idle + pending
51
105
  * tasks. If the initial spawn fails (e.g. worktree missing, engine misconfig),
52
- * re-throws so the HTTP caller gets a 4xx instead of a silent 200 — the
53
- * workspace will already have been auto-disabled by `spawnNextIteration`.
106
+ * re-throws so the HTTP caller gets a 4xx; the mission remains explicitly blocked.
54
107
  */
55
108
  export function enable(workspaceId) {
56
109
  const row = getRow(workspaceId);
57
110
  if (!row)
58
111
  throw new Error(`Workspace '${workspaceId}' not found`);
59
- if (row.auto_loop_ready !== 1) {
112
+ if (row.archived_at)
113
+ throw new Error(`Workspace '${workspaceId}' is already archived`);
114
+ if (row.worktree_purged_at)
115
+ throw new Error(`Workspace '${workspaceId}' is unavailable`);
116
+ if (isWorkspaceLifecycleBusy(workspaceId))
117
+ throw new Error(`Workspace '${workspaceId}' is busy`);
118
+ if (row.auto_loop_ready !== 1 && !row.auto_loop) {
60
119
  throw new Error(`Workspace '${workspaceId}' is not ready for auto-loop (run grooming first)`);
61
120
  }
62
121
  // Refuse to enable when there is nothing to spawn — without this, auto_loop
@@ -64,11 +123,24 @@ export function enable(workspaceId) {
64
123
  // (auto-loop banner) without doing any work. The user must add a task or
65
124
  // unmark a done task before re-enabling.
66
125
  const pending = countPendingTasks(workspaceId);
67
- if (pending === 0) {
126
+ if (pending === 0 && !row.auto_loop) {
68
127
  throw new Error(`Workspace '${workspaceId}' has no pending tasks; add or unmark a task before enabling auto-loop`);
69
128
  }
129
+ if (getRuntime(workspaceId).state === 'blocked' &&
130
+ row.status === 'quota' &&
131
+ !quotaBackoffService.getPending(workspaceId) &&
132
+ !orchestrator.hasController(workspaceId))
133
+ updateWorkspaceStatus(workspaceId, 'idle');
70
134
  const db = getDb();
71
135
  db.prepare('UPDATE workspaces SET auto_loop = 1, no_progress_streak = 0 WHERE id = ?').run(workspaceId);
136
+ orchestrator.resetAutoLoopRetries(workspaceId);
137
+ db.prepare('DELETE FROM auto_loop_progress WHERE workspace_id=?').run(workspaceId);
138
+ setRuntime(workspaceId, {
139
+ state: 'waiting',
140
+ reason: null,
141
+ diagnostic_attempts: 0,
142
+ phase: row.auto_loop_ready ? 'execution' : 'grooming',
143
+ });
72
144
  emitEphemeral(workspaceId, 'autoloop:enabled', {});
73
145
  if (orchestrator.hasController(workspaceId))
74
146
  return;
@@ -83,7 +155,13 @@ export function disable(workspaceId, reason) {
83
155
  const row = getRow(workspaceId);
84
156
  if (row?.auto_loop !== 1)
85
157
  return;
158
+ if (reason === 'error' || reason === 'stall' || reason === 'awaiting-clarification') {
159
+ block(workspaceId, reason);
160
+ return;
161
+ }
86
162
  const db = getDb();
163
+ quotaBackoffService.cancel(workspaceId, 'completed');
164
+ setRuntime(workspaceId, { state: reason === 'completed' ? 'completed' : 'stopped', reason });
87
165
  db.prepare('UPDATE workspaces SET auto_loop = 0 WHERE id = ?').run(workspaceId);
88
166
  const diagnostic = {
89
167
  reason,
@@ -94,9 +172,7 @@ export function disable(workspaceId, reason) {
94
172
  console.info(`[auto-loop] disabled workspace '${workspaceId}' reason=${reason}` +
95
173
  ` pending=${diagnostic.tasksPending} streak=${diagnostic.noProgressStreak} status=${diagnostic.status}`);
96
174
  emitEphemeral(workspaceId, 'autoloop:disabled', diagnostic);
97
- // The user's own hook, if they wrote one. Deliberately fired for EVERY
98
- // reason, not only 'completed' like the cleanup script below: a loop that
99
- // stalled is precisely the case worth being told about.
175
+ // Only actual disable transitions fire this hook. A blocked mission retains its intent.
100
176
  void lifecycleHookService.onAutoLoopDisabled(workspaceId, {
101
177
  reason,
102
178
  tasksPending: diagnostic.tasksPending,
@@ -114,17 +190,26 @@ export function disable(workspaceId, reason) {
114
190
  * Called by orchestrator.handleEvent. The delta records whether task state
115
191
  * progressed during this session, including transitions such as pending to
116
192
  * in_progress, not only completion.
193
+ * Instruction-intake turns preserve the stagnation budget: their prompt only
194
+ * integrates requirements. The orchestrator reports them after settling delivery.
117
195
  *
118
196
  * When status is `quota` we skip spawning: the orchestrator's handleQuota
119
197
  * already scheduled a backoff timer and will call `onQuotaBackoffExpired` once
120
198
  * the window closes — that function owns the next spawn in that case.
121
199
  */
122
- export function onSessionEnded(workspaceId, reason, taskProgressDelta) {
200
+ export function onSessionEnded(workspaceId, reason, taskProgressDelta, instructionIntake = false) {
123
201
  const row = getRow(workspaceId);
124
202
  if (!row)
125
203
  return;
126
204
  if (row.auto_loop !== 1)
127
205
  return;
206
+ const settledInstructions = settleLoopMessages(workspaceId, getRuntime(workspaceId).current_session_id, reason === 'completed');
207
+ // The orchestrator may already have settled this exact turn's deliveries.
208
+ instructionIntake ||= settledInstructions > 0;
209
+ if (listLoopMessages(workspaceId).some((m) => m.state === 'unknown')) {
210
+ block(workspaceId, 'message-delivery-unknown');
211
+ return;
212
+ }
128
213
  // When a quota backoff is in flight (orchestrator.handleQuota scheduled a
129
214
  // timer), let that timer own the next spawn so the backoff delay is respected.
130
215
  if (row.status === 'quota')
@@ -140,30 +225,24 @@ export function onSessionEnded(workspaceId, reason, taskProgressDelta) {
140
225
  if (reason === 'watchdog')
141
226
  return;
142
227
  if (reason === 'error') {
143
- disable(workspaceId, 'error');
228
+ block(workspaceId, 'error');
144
229
  return;
145
230
  }
146
- // When grooming hasn't run yet (auto_loop_ready=false), the loop is "armed"
147
- // but waiting for tasks to be created. Skip streak tracking and task checks —
148
- // onAutoLoopReadySet() will trigger the first spawn once grooming completes.
149
- if (row.auto_loop_ready !== 1)
231
+ if (getRuntime(workspaceId).state === 'blocked')
150
232
  return;
151
- const db = getDb();
152
- let streak;
153
- if (taskProgressDelta > 0) {
154
- db.prepare('UPDATE workspaces SET no_progress_streak = 0 WHERE id = ?').run(workspaceId);
155
- streak = 0;
156
- }
157
- else {
158
- db.prepare('UPDATE workspaces SET no_progress_streak = no_progress_streak + 1 WHERE id = ?').run(workspaceId);
159
- streak = row.no_progress_streak + 1;
160
- }
161
- if (streak >= NO_PROGRESS_STALL_THRESHOLD) {
162
- disable(workspaceId, 'stall');
233
+ if (row.auto_loop_ready === 1 && countPendingTasks(workspaceId) === 0 && listLoopMessages(workspaceId).length === 0) {
234
+ spawnNextIteration(workspaceId);
163
235
  return;
164
236
  }
165
- if (countPendingTasks(workspaceId) === 0) {
166
- disable(workspaceId, 'completed');
237
+ const db = getDb();
238
+ const runtime = getRuntime(workspaceId);
239
+ const streak = taskProgressDelta > 0 ? 0 : row.no_progress_streak + (instructionIntake ? 0 : 1);
240
+ db.prepare('UPDATE workspaces SET no_progress_streak=? WHERE id=?').run(streak, workspaceId);
241
+ if (taskProgressDelta > 0)
242
+ setRuntime(workspaceId, { diagnostic_attempts: 0 });
243
+ // One diagnostic session after three stagnant iterations, followed by two attempts.
244
+ if (!instructionIntake && taskProgressDelta <= 0 && runtime.diagnostic_attempts >= 3) {
245
+ block(workspaceId, 'No progress after diagnostic and two further attempts. Review the session findings and resume after resolving the blocker.');
167
246
  return;
168
247
  }
169
248
  spawnNextIteration(workspaceId);
@@ -176,24 +255,33 @@ export function onSessionEnded(workspaceId, reason, taskProgressDelta) {
176
255
  export function rehydrate() {
177
256
  try {
178
257
  const db = getDb();
258
+ const interrupted = db
259
+ .prepare("SELECT DISTINCT workspace_id FROM auto_loop_messages WHERE state='dispatching'")
260
+ .all();
261
+ for (const row of interrupted)
262
+ if (!orchestrator.hasController(row.workspace_id))
263
+ recoverLoopMessages(row.workspace_id);
179
264
  const rows = db.prepare('SELECT id FROM workspaces WHERE auto_loop = 1 AND archived_at IS NULL').all();
180
265
  for (const { id } of rows) {
181
266
  try {
182
267
  if (orchestrator.hasController(id))
183
268
  continue;
184
- // Workspaces still in grooming (ready=0) have their session killed by
185
- // the server reload. Don't disable — the user can re-trigger grooming
186
- // manually. Auto-disable on missing pending tasks would also fire here
187
- // if the agent hadn't yet seeded any task before the reload.
188
- const row = getRow(id);
189
- if (row?.auto_loop_ready !== 1)
269
+ recoverLoopMessages(id);
270
+ if (listLoopMessages(id).some((m) => m.state === 'unknown')) {
271
+ block(id, 'message-delivery-unknown');
190
272
  continue;
191
- // Persisted quota timers own the restart of these workspaces. Boot
192
- // restores them after this pass; starting here bypasses their delay.
193
- if (row.status === 'quota')
273
+ }
274
+ if (getRuntime(id).state === 'blocked')
194
275
  continue;
195
- if (countPendingTasks(id) === 0) {
196
- disable(id, 'completed');
276
+ const row = getRow(id);
277
+ if (row?.status === 'quota') {
278
+ if (!quotaBackoffService.getPending(id))
279
+ quotaBackoffService.arm(id, 15_000, {
280
+ resetsAt: null,
281
+ source: 'fallback_ladder',
282
+ reason: 'quota',
283
+ retryCount: 1,
284
+ });
197
285
  continue;
198
286
  }
199
287
  spawnNextIteration(id);
@@ -214,7 +302,7 @@ export function forgetAutoLoopState(workspaceId) {
214
302
  // ── Internal ──────────────────────────────────────────────────────────────────
215
303
  const PROMPT_TEMPLATE = `[Kōbō auto-loop — iteration #{n}{sessionModeSuffix}]
216
304
 
217
- Current pending task (highest priority, non-acceptance-criterion first):
305
+ Current pending task (workspace order; finalization follows all todos and criteria):
218
306
  - Task ID: {taskId}
219
307
  - Title: {taskTitle}
220
308
  - Is acceptance criterion: {isAcceptanceCriterion}
@@ -254,21 +342,31 @@ function pickNextTask(workspaceId) {
254
342
  const pending = listTasks(workspaceId).filter((t) => t.status !== 'done');
255
343
  if (pending.length === 0)
256
344
  return null;
257
- // Rule D: non-acceptance first, each group in sort_order (listTasks orders).
258
- const nonCriteria = pending.filter((t) => !t.isAcceptanceCriterion);
259
- const criteria = pending.filter((t) => t.isAcceptanceCriterion);
260
- return [...nonCriteria, ...criteria][0] ?? null;
345
+ return pending.find((t) => !isFinalTask(t)) ?? pending[0] ?? null;
261
346
  }
262
- function computeIterationNumber(workspaceId) {
263
- const done = countDoneTasks(workspaceId);
264
- const status = getStatus(workspaceId);
265
- return done + status.no_progress_streak + 1;
347
+ function isFinalTask(task) {
348
+ return task.role === 'finalization';
349
+ }
350
+ function ensureFinalVerification(workspaceId) {
351
+ const tasks = listTasks(workspaceId);
352
+ if (!tasks.some(isFinalTask)) {
353
+ createTask(workspaceId, {
354
+ title: '[FINAL] Verify all todos and acceptance criteria',
355
+ sortOrder: Math.max(-1, ...tasks.map((t) => t.sortOrder)) + 1,
356
+ });
357
+ }
358
+ else {
359
+ // Legacy completed finalizations have no evidence: preserve work but revalidate completion.
360
+ getDb()
361
+ .prepare("UPDATE tasks SET status='pending' WHERE workspace_id=? AND role='finalization' AND status='done' AND verification IS NULL")
362
+ .run(workspaceId);
363
+ }
266
364
  }
267
365
  /**
268
366
  * Pick the next task, build the prompt, call `orchestrator.startAgent`.
269
367
  *
270
368
  * When called by `onSessionEnded` / `rehydrate`, `startAgent` throws are
271
- * swallowed and the loop auto-disables (`reason: 'error'`). When called from
369
+ * swallowed and the loop is blocked with the failure reason. When called from
272
370
  * `enable` (initial user-driven spawn), we want the HTTP endpoint to surface
273
371
  * the failure instead of lying with 200, so the caller passes
274
372
  * `throwOnStartAgentError: true` and we re-throw after disabling.
@@ -303,7 +401,7 @@ export function resumeWaitingWorkspaces(excluded = new Set()) {
303
401
  const db = getDb();
304
402
  const rows = db
305
403
  .prepare(`SELECT id, status FROM workspaces
306
- WHERE auto_loop = 1 AND auto_loop_ready = 1 AND archived_at IS NULL
404
+ WHERE auto_loop = 1 AND archived_at IS NULL
307
405
  ORDER BY updated_at ASC`)
308
406
  .all();
309
407
  for (const row of rows) {
@@ -334,29 +432,35 @@ function spawnNextIteration(workspaceId, opts = {}) {
334
432
  return;
335
433
  if (deferUntilWorkspaceAvailable(workspaceId, resumeWaitingWorkspaces))
336
434
  return;
337
- if (orchestrator.hasController(workspaceId))
338
- return;
339
- // Same guard as onSessionEnded — never race a deferred-resume start.
340
- if (row.status === 'awaiting-user' || row.status === 'compacting')
435
+ if (!canStartAutomatically(workspaceId)) {
436
+ if (getRuntime(workspaceId).state !== 'blocked' && !orchestrator.hasController(workspaceId)) {
437
+ setRuntime(workspaceId, { state: 'waiting', reason: row.status === 'quota' ? 'quota' : 'capacity-or-lifecycle' });
438
+ if (!hasFreeAgentSlot())
439
+ emitEphemeral(workspaceId, 'autoloop:waiting-for-slot', {
440
+ running: orchestrator.runningAgentCount(),
441
+ limit: settingsService.getGlobalSettings().maxConcurrentAgents,
442
+ });
443
+ }
341
444
  return;
342
- const task = pickNextTask(workspaceId);
343
- if (!task) {
344
- disable(workspaceId, 'completed');
445
+ }
446
+ if (listLoopMessages(workspaceId).some((m) => m.state === 'unknown' || m.state === 'dispatching')) {
447
+ block(workspaceId, 'message-delivery-unknown');
345
448
  return;
346
449
  }
347
- // Hold off rather than pile on. Ten auto-loop workspaces waking together
348
- // hammer the same rate limit and each land in their own backoff, so nobody
349
- // gets through. The loop stays enabled: the next session to end anywhere
350
- // calls back in here, and by then a slot has freed.
351
- if (!hasFreeAgentSlot()) {
352
- console.log(`[auto-loop] workspace '${workspaceId}' is waiting for a free agent slot`);
353
- emitEphemeral(workspaceId, 'autoloop:waiting-for-slot', {
354
- running: orchestrator.runningAgentCount(),
355
- limit: settingsService.getGlobalSettings().maxConcurrentAgents,
356
- });
450
+ const grooming = row.auto_loop_ready !== 1;
451
+ if (listLoopMessages(workspaceId).some((m) => m.state === 'pending'))
452
+ invalidateTaskFinalization(getDb(), workspaceId);
453
+ if (!grooming)
454
+ ensureFinalVerification(workspaceId);
455
+ const task = grooming ? null : pickNextTask(workspaceId);
456
+ const pendingInstructions = listLoopMessages(workspaceId).filter((m) => m.state === 'pending');
457
+ if (!grooming && !task && pendingInstructions.length === 0) {
458
+ disable(workspaceId, 'completed');
357
459
  return;
358
460
  }
359
- const iterationNumber = computeIterationNumber(workspaceId);
461
+ const runtime = getRuntime(workspaceId);
462
+ const diagnostic = row.no_progress_streak >= NO_PROGRESS_STALL_THRESHOLD && runtime.diagnostic_attempts === 0;
463
+ const iterationNumber = runtime.iteration + 1;
360
464
  // Override block: replaces the standard iteration prompt body when the task
361
465
  // title carries a recognized prefix (case-sensitive, trailing space required).
362
466
  // Empty string otherwise so the placeholder collapses cleanly in PROMPT_TEMPLATE.
@@ -367,20 +471,30 @@ function spawnNextIteration(workspaceId, opts = {}) {
367
471
  // Finalization cascades project || global; E2E stays project-only.
368
472
  const finalizationSettings = settingsService.getEffectiveFinalization(row.project_path);
369
473
  let overrideBlock = '';
370
- if (task.title.startsWith('[FINAL] ')) {
474
+ if (task && isFinalTask(task)) {
371
475
  overrideBlock = buildFinalizationIterationBlock(finalizationSettings);
372
476
  }
373
- else if (task.title.startsWith('[E2E] ') && e2eSettings.framework) {
477
+ else if (task?.title.startsWith('[E2E] ') && e2eSettings.framework) {
374
478
  overrideBlock = buildE2eIterationBlock(e2eSettings);
375
479
  }
376
480
  const continuousSession = row.auto_loop_session_mode === 'continuous';
377
- const prompt = PROMPT_TEMPLATE.replaceAll('{n}', String(iterationNumber))
481
+ let prompt = PROMPT_TEMPLATE.replaceAll('{n}', String(iterationNumber))
378
482
  .replaceAll('{sessionModeSuffix}', continuousSession ? ', session continue' : '')
379
- .replaceAll('{taskId}', task.id)
380
- .replaceAll('{taskTitle}', task.title)
381
- .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion))
483
+ .replaceAll('{taskId}', task?.id ?? '')
484
+ .replaceAll('{taskTitle}', task?.title ?? '')
485
+ .replaceAll('{isAcceptanceCriterion}', String(task?.isAcceptanceCriterion ?? false))
382
486
  .replaceAll('{overrideBlock}', overrideBlock)
383
487
  .replaceAll('{reviewGate}', getActiveAutoLoopReviewGate());
488
+ if (grooming)
489
+ prompt = `[Kōbō auto-loop — resume grooming]
490
+ Resume the interrupted preparation. Read the workspace conversation with kobo__read_workspace_events_csv, recover the user's goal and constraints, and inspect existing tasks before making changes. Do not duplicate tasks.
491
+ ${buildAutoLoopGroomingSteps(e2eSettings, finalizationSettings)}
492
+ ${AUTO_LOOP_HARD_RULES}`;
493
+ if (diagnostic)
494
+ prompt = `[Kōbō auto-loop — diagnostic]
495
+ Three iterations produced no task progress. Read recent workspace events, explain the blocker, and try a different approach or decompose the task. Never mark unfinished or unverified work done. If intervention is required, state the precise action needed.
496
+ ${prompt}`;
497
+ prompt += `\n${AUTO_LOOP_ITERATION_RULES}`;
384
498
  const globalSettings = settingsService.getGlobalSettings();
385
499
  const projectSlug = globalSettings.worktreesPrefixByProject
386
500
  ? slugifyProjectName(projectSettings?.displayName ?? '', row.project_path)
@@ -400,12 +514,15 @@ function spawnNextIteration(workspaceId, opts = {}) {
400
514
  if (!fs.existsSync(worktreePath)) {
401
515
  const msg = `Worktree directory missing: ${worktreePath}`;
402
516
  console.error('[auto-loop-service]', msg);
403
- disable(workspaceId, 'error');
517
+ block(workspaceId, msg);
404
518
  if (opts.throwOnStartAgentError)
405
519
  throw new Error(msg);
406
520
  return;
407
521
  }
408
522
  let agentSessionId;
523
+ const instructions = claimLoopMessages(workspaceId);
524
+ if (instructions.length)
525
+ prompt = `[Kōbō auto-loop — integrate user instructions]\nRead kobo__list_tasks and relevant workspace history. Integrate the following instructions into the todos and acceptance criteria, preserving the user's constraints. Update existing tasks and create missing ones before ending this turn. Do not implement or finalize work during this intake turn: the next iteration will select the updated highest-priority task. Leave finalization open.\n${instructions.map((m) => `- ${m.content}`).join('\n')}\n${grooming ? prompt : AUTO_LOOP_ITERATION_RULES}`;
409
526
  try {
410
527
  const agent = orchestrator.startAgent(workspaceId, worktreePath, prompt, row.model,
411
528
  // resume=false spawns a fresh session per iteration (default); resume=true
@@ -414,12 +531,34 @@ function spawnNextIteration(workspaceId, opts = {}) {
414
531
  // prior session to resume (e.g. the very first iteration), orchestrator's
415
532
  // resolveSessionForResume gracefully falls back to a fresh session — no
416
533
  // special-casing needed here.
417
- continuousSession, agentPermissionMode, undefined, row.reasoning_effort);
534
+ grooming || continuousSession, agentPermissionMode, undefined, row.reasoning_effort);
418
535
  agentSessionId = agent.agentSessionId;
536
+ if (agentSessionId)
537
+ bindLoopMessages(workspaceId, agentSessionId);
538
+ setRuntime(workspaceId, {
539
+ phase: grooming ? 'grooming' : task && isFinalTask(task) ? 'finalization' : 'execution',
540
+ state: 'active',
541
+ reason: null,
542
+ iteration: iterationNumber,
543
+ current_task_id: task?.id ?? null,
544
+ current_session_id: agentSessionId ?? null,
545
+ // An intake prompt replaces the diagnostic/work prompt and forbids implementation.
546
+ diagnostic_attempts: instructions.length
547
+ ? runtime.diagnostic_attempts
548
+ : diagnostic
549
+ ? 1
550
+ : runtime.diagnostic_attempts > 0
551
+ ? runtime.diagnostic_attempts + 1
552
+ : 0,
553
+ });
419
554
  }
420
555
  catch (err) {
556
+ // Synchronous rejection precedes dispatch; return only this unbound claim to the queue.
557
+ getDb()
558
+ .prepare("UPDATE auto_loop_messages SET state='pending' WHERE workspace_id=? AND state='dispatching' AND session_id IS NULL")
559
+ .run(workspaceId);
421
560
  console.error('[auto-loop-service] startAgent failed:', err);
422
- disable(workspaceId, 'error');
561
+ block(workspaceId, err instanceof Error ? err.message : String(err));
423
562
  if (opts.throwOnStartAgentError)
424
563
  throw err;
425
564
  return;
@@ -433,8 +572,8 @@ function spawnNextIteration(workspaceId, opts = {}) {
433
572
  const tasksDone = countDoneTasks(workspaceId);
434
573
  emitEphemeral(workspaceId, 'autoloop:iteration-started', {
435
574
  iterationNumber,
436
- taskId: task.id,
437
- taskTitle: task.title,
575
+ taskId: task?.id ?? null,
576
+ taskTitle: task?.title ?? 'Grooming',
438
577
  tasksPending,
439
578
  tasksDone,
440
579
  });
@@ -0,0 +1,30 @@
1
+ import { getDb } from '../db/index.js';
2
+ import { emitEphemeral } from './websocket-service.js';
3
+ export function getRuntime(workspaceId, ready = false) {
4
+ const row = getDb()
5
+ .prepare(`SELECT phase,state,reason,iteration,diagnostic_attempts,current_task_id,current_session_id
6
+ FROM auto_loop_runs WHERE workspace_id=?`)
7
+ .get(workspaceId);
8
+ return (row ?? {
9
+ phase: ready ? 'execution' : 'grooming',
10
+ state: 'waiting',
11
+ reason: null,
12
+ iteration: 0,
13
+ diagnostic_attempts: 0,
14
+ current_task_id: null,
15
+ current_session_id: null,
16
+ });
17
+ }
18
+ export function setRuntime(workspaceId, patch) {
19
+ const current = { ...getRuntime(workspaceId), ...patch };
20
+ getDb()
21
+ .prepare(`INSERT INTO auto_loop_runs
22
+ (workspace_id,phase,state,reason,iteration,diagnostic_attempts,current_task_id,current_session_id,updated_at)
23
+ VALUES (@id,@phase,@state,@reason,@iteration,@diagnostic_attempts,@current_task_id,@current_session_id,@now)
24
+ ON CONFLICT(workspace_id) DO UPDATE SET phase=excluded.phase,state=excluded.state,reason=excluded.reason,
25
+ iteration=excluded.iteration,diagnostic_attempts=excluded.diagnostic_attempts,current_task_id=excluded.current_task_id,
26
+ current_session_id=excluded.current_session_id,updated_at=excluded.updated_at`)
27
+ .run({ ...current, id: workspaceId, now: new Date().toISOString() });
28
+ emitEphemeral(workspaceId, 'autoloop:state', current);
29
+ return current;
30
+ }