@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.
- package/AGENTS.md +36 -1
- package/CHANGELOG.md +6 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +31 -74
- package/dist/mcp-server/kobo-tasks-server.js +51 -4
- package/dist/server/db/auto-loop-schema.js +44 -0
- package/dist/server/db/migrations.js +4 -0
- package/dist/server/db/review-return-schema.js +10 -0
- package/dist/server/db/schema.js +4 -0
- package/dist/server/index.js +10 -9
- package/dist/server/routes/auto-loop-messages.js +51 -0
- package/dist/server/routes/workspaces.js +46 -136
- package/dist/server/services/agent/engines/claude-code/engine.js +1 -0
- package/dist/server/services/agent/engines/codex/engine.js +91 -31
- package/dist/server/services/agent/orchestrator.js +237 -88
- package/dist/server/services/agent/session-controller.js +42 -2
- package/dist/server/services/auto-loop-message-service.js +79 -0
- package/dist/server/services/auto-loop-service.js +225 -86
- package/dist/server/services/auto-loop-state-service.js +30 -0
- package/dist/server/services/cron-service.js +30 -16
- package/dist/server/services/quota-backoff-service.js +42 -10
- package/dist/server/services/review-return-service.js +64 -0
- package/dist/server/services/review-service.js +187 -0
- package/dist/server/services/settings-service.js +3 -3
- package/dist/server/services/task-mutations.js +186 -0
- package/dist/server/services/wakeup-service.js +16 -1
- package/dist/server/services/workspace-message-service.js +16 -2
- package/dist/server/services/workspace-service.js +25 -36
- package/dist/shared/auto-loop-prompts.js +22 -11
- package/dist/shared/auto-loop-types.js +1 -0
- package/dist/shared/review.js +6 -0
- package/dist/shared/task-verification.js +11 -0
- package/package.json +12 -12
- package/src/client/dist/pwa/assets/{ActivityFeed-D1e-zV3O.js → ActivityFeed-D_y4aikp.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-DX1WGFea.js → ChangelogPage-DSKCvpr2.js} +1 -1
- package/src/client/dist/pwa/assets/{ClosePopup-BqW5dy-O.js → ClosePopup-CWjIRHxu.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-B4ne55Xj.js → ComparisonPanel-DDxcsxyC.js} +1 -1
- package/src/client/dist/pwa/assets/{CreatePage-Bx_GpUni.js → CreatePage-DKVEknbL.js} +2 -2
- package/src/client/dist/pwa/assets/{DashboardPage-Cv6HJEcO.js → DashboardPage-Dg2s9nnv.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-COwGxPt6.js → DiffViewer-BSzNScIr.js} +4 -4
- package/src/client/dist/pwa/assets/{DocumentsPanel-DCXpLLb7.js → DocumentsPanel-DfEoI_f7.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-DRL3RCkM.js → DrawerToggleButton-D85PwhKb.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-CuG8MlgF.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-XrOuMoDJ.js → HealthPage-CaLCpejR.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-CDxYIJ_s.js +2 -0
- package/src/client/dist/pwa/assets/MainLayout-DTLNunOi.css +1 -0
- package/src/client/dist/pwa/assets/{QBadge-DbkxGtht.js → QBadge-FWC1zDCq.js} +1 -1
- package/src/client/dist/pwa/assets/{QBanner-DhqH95GW.js → QBanner-CcRI_QIy.js} +1 -1
- package/src/client/dist/pwa/assets/QBtn-DEdx_Y1V.js +1 -0
- package/src/client/dist/pwa/assets/{QBtnDropdown-163oM6T_.js → QBtnDropdown-qJWGcZpC.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnGroup-DELKYo3u.js → QBtnGroup-CAoD6G0Q.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnToggle-BI3k3Ch2.js → QBtnToggle-C_hQ2UCQ.js} +1 -1
- package/src/client/dist/pwa/assets/{QChip-owciRuZu.js → QChip-DCPUhqXX.js} +1 -1
- package/src/client/dist/pwa/assets/QDrawer-DX3PhMqC.js +1 -0
- package/src/client/dist/pwa/assets/{QItemSection-D_5_0UrX.js → QItemSection-BhqphaH6.js} +1 -1
- package/src/client/dist/pwa/assets/{QList-qupzuEqO.js → QList-CHm4JNP0.js} +1 -1
- package/src/client/dist/pwa/assets/{QMarkupTable-B9QuKtK_.js → QMarkupTable-CNpiA9oq.js} +1 -1
- package/src/client/dist/pwa/assets/{QPage-DaX9sMQC.js → QPage-BXBw2Alx.js} +1 -1
- package/src/client/dist/pwa/assets/QSelect-CY4TWW_j.js +1 -0
- package/src/client/dist/pwa/assets/{QSpinnerDots-DnCV3VzI.js → QSpinnerDots-BkFiCsH3.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-BQWY7aeM.js → QTooltip-cDK5AOQ5.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-Bos94OyR.js → SchedulePanel-LvHiZ6ZH.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-DOHkP5lj.js → SearchPage-BO-J4aNc.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-DtGsd0XH.js → SettingsPage-Aw89fRSq.js} +2 -2
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-DL4kp9Ah.js → SplitWorkspacePage-BIEceNwH.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-DHDnyiYh.js → TerminalPanel-DitOCad1.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-BnHXDR6F.js → TouchPan-DvPAhZsa.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-ClVcB6mO.js → TourReplayButton-CJYdH3Jf.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-Bxe6oIJI.js → WorkspaceAttentionLabels-CY-9z9nw.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DAVo-E4p.css +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-xH_OyA3r.js +4 -0
- package/src/client/dist/pwa/assets/{browser-Bkx4fbxl.js → browser-BsB6cFv4.js} +1 -1
- package/src/client/dist/pwa/assets/build-path-tree-CW-ER1Mt.js +1 -0
- package/src/client/dist/pwa/assets/{de-BU0dILBs.js → de-Mu7biceO.js} +2 -2
- package/src/client/dist/pwa/assets/documents-SCd3w8xV.js +1 -0
- package/src/client/dist/pwa/assets/{editor.api-DreEG0If.js → editor.api-BUdM7I6y.js} +1 -1
- package/src/client/dist/pwa/assets/editor.worker-iO5iN0DB.js +33 -0
- package/src/client/dist/pwa/assets/efforts-DTJQP9or.js +9 -0
- package/src/client/dist/pwa/assets/engineFeatures-B-9jHocY.css +1 -0
- package/src/client/dist/pwa/assets/engineFeatures-Bj6Ob41d.js +1 -0
- package/src/client/dist/pwa/assets/{es-CNp0s9na.js → es-BY5IfNI2.js} +2 -2
- package/src/client/dist/pwa/assets/expand-template-SM8vvYyO.js +1 -0
- package/src/client/dist/pwa/assets/{format-B7z3F-Mw.js → format-BjQxf4pe.js} +3 -3
- package/src/client/dist/pwa/assets/{formatters-BTBiU0v3.js → formatters-BejhmNTV.js} +1 -1
- package/src/client/dist/pwa/assets/{fr-RqJyWSdm.js → fr-BMXCzFCM.js} +2 -2
- package/src/client/dist/pwa/assets/{freemarker2-BZNTVQ3K.js → freemarker2-5d5GhyRp.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-k8tN0dI3.js → handlebars-DaafEsDK.js} +1 -1
- package/src/client/dist/pwa/assets/{html-iZL3bbXq.js → html-CwHBDd2A.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-CgWW5vfb.js +1 -0
- package/src/client/dist/pwa/assets/index-0wtBh1Xt.css +1 -0
- package/src/client/dist/pwa/assets/index-Di9Sz5E8.js +20 -0
- package/src/client/dist/pwa/assets/{it-DSxNuCwL.js → it-ByPZQK_K.js} +2 -2
- package/src/client/dist/pwa/assets/{javascript-TV6gg0MO.js → javascript-DSNIEEH5.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-Cifa5ujF.js → liquid-Dma1UpA1.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-Bnj_8yGE.js → mdx-D8rqEAws.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-pEh0n9Wk.js → monaco-contributions-2E7O2zoi.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-D7NmJUJH.js → monaco.contribution-DqOgo72G.js} +2 -2
- package/src/client/dist/pwa/assets/{network-auth-3_5p4fdc.js → network-auth-DNmhJTZn.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-D_nf9AeF.js → notify-theme-B6Hiwvwb.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-CUnxC1xw.js → pending-diff-open-CeiVIFZn.js} +1 -1
- package/src/client/dist/pwa/assets/permissionModes-DWdtf81n.js +36 -0
- package/src/client/dist/pwa/assets/{pinia-BKqmoVru.js → pinia-CTJ3OfP3.js} +1 -1
- package/src/client/dist/pwa/assets/{private.use-form-C7umbGO-.js → private.use-form-Bk8ti264.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-CihwpDAA.js → project-color-BCp9SWoI.js} +1 -1
- package/src/client/dist/pwa/assets/{python-DOoNBSNL.js → python-f-9NqRlI.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-Bvq5vVpJ.js → razor-DsCmoX0h.js} +1 -1
- package/src/client/dist/pwa/assets/reactivity.esm-bundler-Bo9anJjg.js +1 -0
- package/src/client/dist/pwa/assets/render-chat-markdown-DK5BD9mU.js +67 -0
- package/src/client/dist/pwa/assets/{runtime-core.esm-bundler-CDnVUfeZ.js → runtime-core.esm-bundler-CNFKm4ij.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-VVzxoeyM.js → typescript-cEHg4L10.js} +1 -1
- package/src/client/dist/pwa/assets/{use-checkbox-FnauAgTp.js → use-checkbox-CnvHBhSK.js} +1 -1
- package/src/client/dist/pwa/assets/{use-id-BIuaWKkB.js → use-id-wXd4c8FA.js} +1 -1
- package/src/client/dist/pwa/assets/use-is-mobile-DpKI3XMy.js +1 -0
- package/src/client/dist/pwa/assets/{use-search-index-status-DtLzygcy.js → use-search-index-status-Bluf9L99.js} +1 -1
- package/src/client/dist/pwa/assets/use-tours-BmTbosmB.js +2 -0
- package/src/client/dist/pwa/assets/{workspace-templates-1ui80Kqo.js → workspace-templates-DUWDLMIX.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-CzGFIxQa.js → xml-Plt-Y7we.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-Bl7nDJeC.js → yaml-ScEmaAeN.js} +1 -1
- package/src/client/dist/pwa/index.html +10 -10
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +68 -104
- package/src/mcp-server/kobo-tasks-server.ts +56 -4
- package/src/client/dist/pwa/assets/GitPanel-C9mz4GXF.js +0 -3
- package/src/client/dist/pwa/assets/MainLayout-BwV89R-l.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-CwWavQjV.js +0 -2
- package/src/client/dist/pwa/assets/QBtn-CZaBclNw.js +0 -1
- package/src/client/dist/pwa/assets/QDrawer-DNEj8wLZ.js +0 -1
- package/src/client/dist/pwa/assets/QSelect-BUlOQe5k.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-Clc4Fl3B.js +0 -4
- package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +0 -1
- package/src/client/dist/pwa/assets/build-path-tree-BpSY7N5O.js +0 -1
- package/src/client/dist/pwa/assets/documents-BIlWh3o-.js +0 -1
- package/src/client/dist/pwa/assets/editor.worker-CwQAd_xr.js +0 -30
- package/src/client/dist/pwa/assets/efforts-D_yTzymv.js +0 -9
- package/src/client/dist/pwa/assets/engineFeatures-C33LTVkK.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-wRhAXT-1.js +0 -1
- package/src/client/dist/pwa/assets/flUhRq6tzZclQEJ-Vdg-IuiaDsNa-Dr0goTwe.woff +0 -0
- package/src/client/dist/pwa/assets/i18n--PCet00a.js +0 -1
- package/src/client/dist/pwa/assets/index-CPfrY38K.js +0 -20
- package/src/client/dist/pwa/assets/index-CUoUy6tH.css +0 -1
- package/src/client/dist/pwa/assets/permissionModes-DC51Pz84.js +0 -36
- package/src/client/dist/pwa/assets/reactivity.esm-bundler-DEJj9cFv.js +0 -1
- package/src/client/dist/pwa/assets/render-chat-markdown-BQ_saiy5.js +0 -67
- package/src/client/dist/pwa/assets/use-is-mobile-CWjqe8gC.js +0 -1
- 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
|
-
|
|
42
|
-
|
|
44
|
+
const runtime = getRuntime(workspaceId, row?.auto_loop_ready === 1);
|
|
45
|
+
const pending = quotaBackoffService.getPending(workspaceId);
|
|
43
46
|
return {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
228
|
+
block(workspaceId, 'error');
|
|
144
229
|
return;
|
|
145
230
|
}
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
if (row.status === 'quota')
|
|
273
|
+
}
|
|
274
|
+
if (getRuntime(id).state === 'blocked')
|
|
194
275
|
continue;
|
|
195
|
-
|
|
196
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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
|
|
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 (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
if (
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
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
|
|
474
|
+
if (task && isFinalTask(task)) {
|
|
371
475
|
overrideBlock = buildFinalizationIterationBlock(finalizationSettings);
|
|
372
476
|
}
|
|
373
|
-
else if (task
|
|
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
|
-
|
|
481
|
+
let prompt = PROMPT_TEMPLATE.replaceAll('{n}', String(iterationNumber))
|
|
378
482
|
.replaceAll('{sessionModeSuffix}', continuousSession ? ', session continue' : '')
|
|
379
|
-
.replaceAll('{taskId}', task
|
|
380
|
-
.replaceAll('{taskTitle}', task
|
|
381
|
-
.replaceAll('{isAcceptanceCriterion}', String(task
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
437
|
-
taskTitle: task
|
|
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
|
+
}
|