@loicngr/kobo 1.11.27 → 1.11.29
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 +19 -1
- package/CHANGELOG.md +10 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +17 -1
- package/dist/server/db/migrations.js +34 -0
- package/dist/server/db/schema.js +3 -1
- package/dist/server/index.js +80 -23
- package/dist/server/middleware/host-check-middleware.js +80 -0
- package/dist/server/routes/changelog.js +51 -3
- package/dist/server/routes/notion.js +1 -1
- package/dist/server/routes/pull-requests.js +6 -2
- package/dist/server/routes/sentry.js +1 -1
- package/dist/server/routes/settings.js +23 -8
- package/dist/server/routes/usage.js +12 -0
- package/dist/server/routes/workspaces.js +222 -80
- package/dist/server/services/agent/engines/claude-code/engine.js +3 -0
- package/dist/server/services/agent/engines/codex/engine.js +18 -0
- package/dist/server/services/agent/orchestrator.js +129 -34
- package/dist/server/services/agent/session-controller.js +28 -0
- package/dist/server/services/auto-loop-service.js +65 -0
- package/dist/server/services/awaiting-user-reminder-service.js +115 -0
- package/dist/server/services/change-source-branch-service.js +1 -1
- package/dist/server/services/comparison-stats-service.js +43 -0
- package/dist/server/services/forge/resolve.js +29 -1
- package/dist/server/services/lifecycle-hook-service.js +114 -0
- package/dist/server/services/network-access-service.js +187 -6
- package/dist/server/services/pr-watcher-service.js +27 -1
- package/dist/server/services/settings-service.js +147 -4
- package/dist/server/services/usage/reliability.js +81 -0
- package/dist/server/services/wakeup-service.js +23 -0
- package/dist/server/services/websocket-service.js +31 -3
- package/dist/server/services/workspace-service.js +36 -4
- package/dist/server/services/worktree-purge-service.js +1 -1
- package/dist/server/utils/git-ops.js +159 -1
- package/dist/server/utils/paths.js +19 -0
- package/dist/server/utils/safe-path.js +21 -1
- package/dist/server/utils/script-runner.js +2 -1
- package/dist/server/utils/workspace-name.js +37 -0
- package/dist/shared/consts.js +22 -0
- package/dist/shared/models.js +4 -4
- package/package.json +3 -2
- package/src/client/dist/pwa/assets/{ActivityFeed-LEd9NEbI.css → ActivityFeed-C6XSxdJ0.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-Dm-h5c8M.js +8 -0
- package/src/client/dist/pwa/assets/ChangelogPage-tLjrU4kh.js +1 -0
- package/src/client/dist/pwa/assets/ClosePopup-CekolbY5.js +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-CsdFl1JL.js +2 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-x1iuICgd.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-B1iRRP_d.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-DeKYK6uC.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-BqgK4lAf.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-CNTYrE-N.js +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-CxXPJSsu.js +8 -0
- package/src/client/dist/pwa/assets/{DiffViewer-BV8SvUIi.css → DiffViewer-DnR79ycb.css} +1 -1
- package/src/client/dist/pwa/assets/DocumentsPanel-Du7qG8uv.js +1 -0
- package/src/client/dist/pwa/assets/DrawerToggleButton-D0i0wUjb.js +1 -0
- package/src/client/dist/pwa/assets/GitPanel-tuTj7lhu.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-DL_RBg9X.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-CuPokl9k.js +2 -0
- package/src/client/dist/pwa/assets/MainLayout-ExB0xYHB.css +1 -0
- package/src/client/dist/pwa/assets/QBanner-CDiXUya7.js +1 -0
- package/src/client/dist/pwa/assets/QBtnDropdown-VaaGdcMX.js +1 -0
- package/src/client/dist/pwa/assets/QChip-DpUYTCOJ.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-CEUi4Tz0.js +1 -0
- package/src/client/dist/pwa/assets/QMarkupTable-DcoQpjPu.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-CyWvfNkF.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-c_MuOKwZ.js +1 -0
- package/src/client/dist/pwa/assets/{SettingsPage-CDZhPV6A.css → SettingsPage-2vdOZu1C.css} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-QkPo7j0B.js +10 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-HIuH2z-Y.js → TerminalPanel-Io_WshA3.js} +3 -3
- package/src/client/dist/pwa/assets/TouchPan-DO0Y0lcH.js +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-NqQsltOl.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-Ct8QYOU_.js +4 -0
- package/src/client/dist/pwa/assets/browser-Bg25_sDh.js +8 -0
- package/src/client/dist/pwa/assets/build-path-tree-CmeIBsJq.js +1 -0
- package/src/client/dist/pwa/assets/de-DUTfyzTm.js +17 -0
- package/src/client/dist/pwa/assets/documents-DuT_pUBs.js +1 -0
- package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +1 -0
- package/src/client/dist/pwa/assets/editor.api-B7h7KULV.js +413 -0
- package/src/client/dist/pwa/assets/{efforts-Dn1yRgeo.js → efforts-CFRBNt5s.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-DmG1z2N2.js +1 -0
- package/src/client/dist/pwa/assets/es-ft4_w4dQ.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-DXzAXadA.js +1 -0
- package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +1 -0
- package/src/client/dist/pwa/assets/format-klpA_BIb.js +435 -0
- package/src/client/dist/pwa/assets/{formatters-D0dU6VcA.js → formatters-CQ90Icr4.js} +1 -1
- package/src/client/dist/pwa/assets/fr-SGSKyQ_T.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-qQC_1HDe.js → freemarker2-AB32EM-g.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-pyc5TcAt.js → handlebars-C87HpXgr.js} +1 -1
- package/src/client/dist/pwa/assets/{html-DOaftBCT.js → html-fZ2Q-EN9.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-jUVcMUna.js +1 -0
- package/src/client/dist/pwa/assets/index-BgV6UXly.js +18 -0
- package/src/client/dist/pwa/assets/it-C5q5LNGX.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-BTsYFFLK.js → javascript-DaRn-B3h.js} +1 -1
- package/src/client/dist/pwa/assets/layout-DdQC-2Sz.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-BdTymdAY.js → liquid-BxIQD86X.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DzJktAFj.js → mdx-BB8ir9N_.js} +1 -1
- package/src/client/dist/pwa/assets/monaco-contributions-B0YHWDBM.js +51 -0
- package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +1 -0
- package/src/client/dist/pwa/assets/monaco.contribution-Nxc5lpNj.js +2 -0
- package/src/client/dist/pwa/assets/network-auth-0EqcSNjT.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-D1FTBWWF.js → notify-theme-CJQIBqdX.js} +1 -1
- package/src/client/dist/pwa/assets/pending-diff-open-BTLtsmQt.js +1 -0
- package/src/client/dist/pwa/assets/permissionModes-BR2CNlno.js +36 -0
- package/src/client/dist/pwa/assets/pinia-ClsyLh3i.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-eoGJvY_F.js → project-color-CZmTicBX.js} +1 -1
- package/src/client/dist/pwa/assets/{python-onIjs5sJ.js → python-BdCzK6Il.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-DqQGiaiD.js → razor-ClnzXrTz.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-BwbknTum.js → render-chat-markdown-CzR8e-Uh.js} +1 -1
- package/src/client/dist/pwa/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/src/client/dist/pwa/assets/{typescript-BnI60_wU.js → typescript-Dk26pRlO.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-3io6bTPe.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-BwDLVLlc.js +2 -0
- package/src/client/dist/pwa/assets/use-quasar-B2sgdABh.js +1 -0
- package/src/client/dist/pwa/assets/vue-i18n-DtajJy_8.js +3 -0
- package/src/client/dist/pwa/assets/{xml-DQUlBYuZ.js → xml-DdBTQmk2.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-D3GiqGbP.js → yaml-DTv5uGmK.js} +1 -1
- package/src/client/dist/pwa/index.html +5 -4
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +17 -1
- package/src/client/dist/pwa/assets/ActivityFeed-XJeDwa82.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-Bjls89ZX.js +0 -1
- package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BzHQd4uE.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-DNPD0uVB.js +0 -8
- package/src/client/dist/pwa/assets/DocumentsPanel-Osdun-Om.js +0 -1
- package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +0 -1
- package/src/client/dist/pwa/assets/GitPanel-8_xQ7V2r.js +0 -3
- package/src/client/dist/pwa/assets/HealthPage-CSEPHslq.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-Bdh2JQfn.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-X1T4sh65.js +0 -2
- package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +0 -1
- package/src/client/dist/pwa/assets/QBtnDropdown-aUlDUJBU.js +0 -1
- package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +0 -1
- package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-DIoiKpwZ.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CKNNtnwv.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-njeIse66.js +0 -16
- package/src/client/dist/pwa/assets/TouchPan-CMDAeqj6.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-Bk7pIvC0.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-B07ATM5W.js +0 -1
- package/src/client/dist/pwa/assets/css.worker-JKZQyld4.js +0 -93
- package/src/client/dist/pwa/assets/cssMode-BvD76b3P.js +0 -1
- package/src/client/dist/pwa/assets/documents-D7AZJAiR.js +0 -1
- package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +0 -1
- package/src/client/dist/pwa/assets/editor.api-GJX9JYVf.js +0 -847
- package/src/client/dist/pwa/assets/engineFeatures-C4wJRlwO.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-0hMEF4rZ.js +0 -1
- package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +0 -506
- package/src/client/dist/pwa/assets/htmlMode-DRXGGPOb.js +0 -1
- package/src/client/dist/pwa/assets/i18n-mEG61A5h.js +0 -1
- package/src/client/dist/pwa/assets/index-DZ4aWwQs.js +0 -82
- package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +0 -62
- package/src/client/dist/pwa/assets/jsonMode-3Glcck88.js +0 -7
- package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +0 -1
- package/src/client/dist/pwa/assets/lspLanguageFeatures-CvwRMMY9.js +0 -4
- package/src/client/dist/pwa/assets/network-auth-D59LKtK4.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-DrdnFOI2.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-_rSNmU9M.js +0 -36
- package/src/client/dist/pwa/assets/ts.worker-C-oGa8JT.js +0 -67738
- package/src/client/dist/pwa/assets/tsMode-B24qEjDH.js +0 -11
- package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +0 -1
- package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +0 -1
- package/src/client/dist/pwa/assets/use-onboarding-WR8vvxBw.js +0 -2
- package/src/client/dist/pwa/assets/vs-DVt5kpUB.js +0 -56
- package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +0 -1
- package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +0 -3
- package/src/client/dist/pwa/assets/workers-D28S2iKq.js +0 -1
|
@@ -6,6 +6,7 @@ import * as autoLoopService from '../auto-loop-service.js';
|
|
|
6
6
|
import * as cleanupScriptService from '../cleanup-script-service.js';
|
|
7
7
|
import * as cronService from '../cron-service.js';
|
|
8
8
|
import { resolveForge } from '../forge/resolve.js';
|
|
9
|
+
import * as lifecycleHookService from '../lifecycle-hook-service.js';
|
|
9
10
|
import * as quotaBackoffService from '../quota-backoff-service.js';
|
|
10
11
|
import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
|
|
11
12
|
import { refreshNow } from '../usage/poller.js';
|
|
@@ -268,6 +269,9 @@ function runWatchdog() {
|
|
|
268
269
|
if (controllers.get(workspaceId) === ctrl)
|
|
269
270
|
controllers.delete(workspaceId);
|
|
270
271
|
retryCounts.delete(workspaceId);
|
|
272
|
+
// This end never goes through handleEvent → onSessionEnded, so the user's
|
|
273
|
+
// hook would otherwise miss precisely the case it is most useful for.
|
|
274
|
+
fireSessionEndedHook(workspaceId, ctrl.agentSessionId, 'killed', null, ctrl);
|
|
271
275
|
try {
|
|
272
276
|
const db = getDb();
|
|
273
277
|
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ? WHERE id = ?').run('error', new Date().toISOString(), ctrl.agentSessionId);
|
|
@@ -413,6 +417,9 @@ function readEffectiveSettingsSafe(projectPath) {
|
|
|
413
417
|
cleanupScriptOnlyOnChanges: false,
|
|
414
418
|
archiveScript: '',
|
|
415
419
|
changeSourceBranchScript: '',
|
|
420
|
+
sessionEndedScript: '',
|
|
421
|
+
prMergedScript: '',
|
|
422
|
+
autoLoopDisabledScript: '',
|
|
416
423
|
notionStatusProperty: '',
|
|
417
424
|
notionInProgressStatus: '',
|
|
418
425
|
};
|
|
@@ -856,6 +863,9 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
856
863
|
// a mid-loop session (never cleans) or a standalone one.
|
|
857
864
|
const wasAutoLoop = autoLoopService.getStatus(workspaceId).auto_loop;
|
|
858
865
|
autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta);
|
|
866
|
+
// A slot just freed. Any auto-loop workspace parked on the concurrency
|
|
867
|
+
// limit has no session of its own to bring it back; this one does it.
|
|
868
|
+
autoLoopService.resumeWaitingWorkspaces();
|
|
859
869
|
cleanupScriptService.onSessionEnded(workspaceId, effectiveReason, { wasAutoLoop });
|
|
860
870
|
}
|
|
861
871
|
if (ev.kind === 'session:user-input-requested' && !sourceControllerIsStopping) {
|
|
@@ -913,11 +923,43 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
913
923
|
}
|
|
914
924
|
}
|
|
915
925
|
}
|
|
926
|
+
/**
|
|
927
|
+
* Sessions whose session-ended hook already ran. Two paths can report the
|
|
928
|
+
* same end — the watchdog's dead-engine sweep, then the engine's own late
|
|
929
|
+
* `session:ended` — and the user's script must run once per session, not
|
|
930
|
+
* once per report. Bounded: cleared past a size no single process reaches.
|
|
931
|
+
*/
|
|
932
|
+
const sessionEndedHookFired = new Set();
|
|
933
|
+
function fireSessionEndedHook(workspaceId, agentSessionId, reason, exitCode, controller) {
|
|
934
|
+
if (sessionEndedHookFired.has(agentSessionId))
|
|
935
|
+
return;
|
|
936
|
+
if (sessionEndedHookFired.size > 10_000)
|
|
937
|
+
sessionEndedHookFired.clear();
|
|
938
|
+
sessionEndedHookFired.add(agentSessionId);
|
|
939
|
+
// The worktree is about to be removed by the very operation that stopped
|
|
940
|
+
// this session; a script spawned in it would lose its cwd mid-run.
|
|
941
|
+
const cause = controller?.stopCause;
|
|
942
|
+
if (cause === 'delete' || cause === 'purge')
|
|
943
|
+
return;
|
|
944
|
+
void lifecycleHookService.onSessionEnded(workspaceId, {
|
|
945
|
+
sessionId: agentSessionId,
|
|
946
|
+
reason,
|
|
947
|
+
exitCode,
|
|
948
|
+
stopCause: controller?.status === 'stopping' ? (cause ?? 'user') : '',
|
|
949
|
+
// Read BEFORE auto-loop reacts to this end (which may disable it): the
|
|
950
|
+
// hook wants to know whether the next iteration is about to start.
|
|
951
|
+
autoLoopActive: getWs(workspaceId)?.autoLoop === true,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
916
954
|
function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode, reason, resumeFailed = false) {
|
|
917
955
|
// Update the agent_sessions row
|
|
918
956
|
try {
|
|
919
957
|
const db = getDb();
|
|
920
|
-
|
|
958
|
+
// `status` only distinguishes completed from error, by exit code. Keep the
|
|
959
|
+
// reason too: "the watchdog force-ended it", "the user stopped it" and "it
|
|
960
|
+
// finished on its own" all landed on the same two values, so after the fact
|
|
961
|
+
// nothing could say why a session stopped.
|
|
962
|
+
db.prepare('UPDATE agent_sessions SET status = ?, ended_at = ?, end_reason = ? WHERE id = ?').run(exitCode === 0 ? 'completed' : 'error', new Date().toISOString(), reason, agentSessionId);
|
|
921
963
|
}
|
|
922
964
|
catch (err) {
|
|
923
965
|
console.error('[orchestrator] Failed to update agent_sessions on exit:', err);
|
|
@@ -926,6 +968,10 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
926
968
|
const isSuperseded = sourceController !== undefined && registeredController !== undefined && registeredController !== sourceController;
|
|
927
969
|
if (isSuperseded)
|
|
928
970
|
return false;
|
|
971
|
+
// A superseded end (above) is a session that was replaced, not one that
|
|
972
|
+
// ended; everything past this point is a real end the user may want to act
|
|
973
|
+
// on, whatever the reason — the script reads KOBO_SESSION_END_REASON.
|
|
974
|
+
fireSessionEndedHook(workspaceId, agentSessionId, reason, exitCode, sourceController);
|
|
929
975
|
const currentWorkspace = getWs(workspaceId);
|
|
930
976
|
const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
|
|
931
977
|
const wasStopping = sourceController?.status === 'stopping';
|
|
@@ -1170,7 +1216,8 @@ export function interruptAgent(workspaceId, options = {}) {
|
|
|
1170
1216
|
* is untracked by anyone in this process; only the OS or the user can end it.
|
|
1171
1217
|
*/
|
|
1172
1218
|
export const STOP_AGENT_TIMEOUT_MS = 15_000;
|
|
1173
|
-
async function stopController(workspaceId, ctrl) {
|
|
1219
|
+
async function stopController(workspaceId, ctrl, cause = 'user') {
|
|
1220
|
+
ctrl.stopCause = cause;
|
|
1174
1221
|
wakeupService.cancel(workspaceId, 'stopped');
|
|
1175
1222
|
// Normalize the state synchronously so callers (archive, delete, manual
|
|
1176
1223
|
// stop) see a clean workspace immediately — without waiting for the async
|
|
@@ -1256,11 +1303,13 @@ async function stopWithTimeout(ctrl, timeoutMs) {
|
|
|
1256
1303
|
* switch — MUST use this: the fire-and-forget `stopAgent` returns while the
|
|
1257
1304
|
* engine may still be writing files.
|
|
1258
1305
|
*/
|
|
1259
|
-
export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS
|
|
1306
|
+
export async function stopAgentAndWait(workspaceId, timeoutMs = STOP_AGENT_TIMEOUT_MS,
|
|
1307
|
+
/** Why. `delete` and `purge` tell the session-ended hook to stand down. */
|
|
1308
|
+
cause = 'user') {
|
|
1260
1309
|
const ctrl = controllers.get(workspaceId);
|
|
1261
1310
|
if (!ctrl)
|
|
1262
1311
|
return 'not-running';
|
|
1263
|
-
const finished = await raceWithTimeout(stopController(workspaceId, ctrl).then(() => true, (err) => {
|
|
1312
|
+
const finished = await raceWithTimeout(stopController(workspaceId, ctrl, cause).then(() => true, (err) => {
|
|
1264
1313
|
console.error(`[orchestrator] controller.stop failed for '${workspaceId}':`, err);
|
|
1265
1314
|
return true;
|
|
1266
1315
|
}), timeoutMs, () => false);
|
|
@@ -1647,6 +1696,10 @@ export function getAgentStatus(workspaceId) {
|
|
|
1647
1696
|
export function hasController(workspaceId) {
|
|
1648
1697
|
return controllers.has(workspaceId);
|
|
1649
1698
|
}
|
|
1699
|
+
/** How many agent sessions are live right now, across every workspace. */
|
|
1700
|
+
export function runningAgentCount() {
|
|
1701
|
+
return controllers.size;
|
|
1702
|
+
}
|
|
1650
1703
|
/** The agent_session_id of the active controller for the workspace, if any. */
|
|
1651
1704
|
export function getActiveSessionId(workspaceId) {
|
|
1652
1705
|
return controllers.get(workspaceId)?.agentSessionId;
|
|
@@ -1791,24 +1844,58 @@ async function handleQuota(workspaceId, _agentSessionId) {
|
|
|
1791
1844
|
// Never silent: a refused transition here loses the backoff two lines down.
|
|
1792
1845
|
console.warn(`[orchestrator] Could not transition workspace '${workspaceId}' to quota:`, err);
|
|
1793
1846
|
}
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1847
|
+
// Everything below reads the settings file, the workspace row and the
|
|
1848
|
+
// backoff table. Both call sites are fire-and-forget (`void handleQuota(…)`)
|
|
1849
|
+
// and the process treats an unhandled rejection as fatal, so a transient
|
|
1850
|
+
// failure here must not escape: hitting a quota while the database is busy
|
|
1851
|
+
// would otherwise kill Kōbō and every agent running under it.
|
|
1852
|
+
try {
|
|
1853
|
+
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
1854
|
+
const autoLoopEnabled = getWs(workspaceId)?.autoLoop === true;
|
|
1855
|
+
const maxRetries = autoLoopEnabled ? (getGlobalSettings().autoLoopMaxRetries ?? 5) : 5;
|
|
1856
|
+
if (autoLoopEnabled && retryCount >= maxRetries) {
|
|
1857
|
+
autoLoopService.disable(workspaceId, 'error');
|
|
1858
|
+
try {
|
|
1859
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
1860
|
+
}
|
|
1861
|
+
catch {
|
|
1862
|
+
// The loop is disabled even if an already-terminal status rejects this transition.
|
|
1863
|
+
}
|
|
1864
|
+
return;
|
|
1804
1865
|
}
|
|
1805
|
-
|
|
1866
|
+
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
|
|
1867
|
+
retryCounts.set(workspaceId, retryCount + 1);
|
|
1868
|
+
// The quotaBackoffService owns the timer + the persistent row + the
|
|
1869
|
+
// 'agent:quota-backoff' WS emit. Hand off everything to it.
|
|
1870
|
+
quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'quota' });
|
|
1871
|
+
}
|
|
1872
|
+
catch (err) {
|
|
1873
|
+
console.error(`[orchestrator] Could not arm the quota backoff for workspace '${workspaceId}':`, err);
|
|
1874
|
+
// The status was already moved to 'quota' above, and the banner offering
|
|
1875
|
+
// "resume now" only renders once a backoff row exists. Leaving it there
|
|
1876
|
+
// would strand the workspace waiting on a timer nobody armed, with the
|
|
1877
|
+
// console line as its only trace. 'error' has a banner of its own.
|
|
1878
|
+
failVisibly(workspaceId);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Last resort when the retry machinery itself fails: stop the loop and move the
|
|
1883
|
+
* workspace to a status the UI actually surfaces, so the user sees something to
|
|
1884
|
+
* act on instead of a workspace that has quietly stopped moving.
|
|
1885
|
+
*/
|
|
1886
|
+
function failVisibly(workspaceId) {
|
|
1887
|
+
try {
|
|
1888
|
+
autoLoopService.disable(workspaceId, 'error');
|
|
1889
|
+
}
|
|
1890
|
+
catch (err) {
|
|
1891
|
+
console.error(`[orchestrator] Could not disable the auto-loop for workspace '${workspaceId}':`, err);
|
|
1892
|
+
}
|
|
1893
|
+
try {
|
|
1894
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
1895
|
+
}
|
|
1896
|
+
catch {
|
|
1897
|
+
// An already-terminal status refusing this transition is fine.
|
|
1806
1898
|
}
|
|
1807
|
-
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
|
|
1808
|
-
retryCounts.set(workspaceId, retryCount + 1);
|
|
1809
|
-
// The quotaBackoffService owns the timer + the persistent row + the
|
|
1810
|
-
// 'agent:quota-backoff' WS emit. Hand off everything to it.
|
|
1811
|
-
quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source, reason: 'quota' });
|
|
1812
1899
|
}
|
|
1813
1900
|
/** First retry after a transient failure (watchdog recovery, HTTP 500). The
|
|
1814
1901
|
* quota ladder's 15-minute floor is tuned for real quota windows — a forced
|
|
@@ -1826,22 +1913,30 @@ async function handleTransientAutoLoopFailure(workspaceId) {
|
|
|
1826
1913
|
catch (err) {
|
|
1827
1914
|
console.warn(`[orchestrator] Could not transition workspace '${workspaceId}' to quota for a transient failure:`, err);
|
|
1828
1915
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1916
|
+
// Same reasoning as handleQuota: fire-and-forget call sites plus a fatal
|
|
1917
|
+
// unhandled-rejection policy mean a failure here must stay contained.
|
|
1918
|
+
try {
|
|
1919
|
+
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
1920
|
+
const maxRetries = getGlobalSettings().autoLoopMaxRetries ?? 5;
|
|
1921
|
+
if (retryCount >= maxRetries) {
|
|
1922
|
+
autoLoopService.disable(workspaceId, 'error');
|
|
1923
|
+
try {
|
|
1924
|
+
updateWorkspaceStatus(workspaceId, 'error');
|
|
1925
|
+
}
|
|
1926
|
+
catch {
|
|
1927
|
+
// The loop is disabled even if an already-terminal status rejects this transition.
|
|
1928
|
+
}
|
|
1929
|
+
return;
|
|
1838
1930
|
}
|
|
1839
|
-
|
|
1931
|
+
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
|
|
1932
|
+
const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
|
|
1933
|
+
retryCounts.set(workspaceId, retryCount + 1);
|
|
1934
|
+
quotaBackoffService.arm(workspaceId, effectiveDelayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
|
|
1935
|
+
}
|
|
1936
|
+
catch (err) {
|
|
1937
|
+
console.error(`[orchestrator] Could not arm the transient retry for workspace '${workspaceId}':`, err);
|
|
1938
|
+
failVisibly(workspaceId);
|
|
1840
1939
|
}
|
|
1841
|
-
const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount, false);
|
|
1842
|
-
const effectiveDelayMs = retryCount === 0 ? Math.min(delayMs, TRANSIENT_FIRST_RETRY_MS) : delayMs;
|
|
1843
|
-
retryCounts.set(workspaceId, retryCount + 1);
|
|
1844
|
-
quotaBackoffService.arm(workspaceId, effectiveDelayMs, { resetsAt: resetsAt ?? null, source, reason: 'transient' });
|
|
1845
1940
|
}
|
|
1846
1941
|
/** @internal test-only — re-export of `handleQuota` for direct testing. */
|
|
1847
1942
|
export const _handleQuota = handleQuota;
|
|
@@ -3,9 +3,21 @@ export class SessionController {
|
|
|
3
3
|
agentSessionId;
|
|
4
4
|
engine;
|
|
5
5
|
onEvent;
|
|
6
|
+
/**
|
|
7
|
+
* Set by the orchestrator when it stops this controller on purpose. A
|
|
8
|
+
* delete or purge is about to remove the worktree, and a hook spawned in it
|
|
9
|
+
* would run in a directory being deleted — the hook path checks this.
|
|
10
|
+
*/
|
|
11
|
+
stopCause;
|
|
6
12
|
_engineProcess;
|
|
7
13
|
_startPromise;
|
|
8
14
|
_status = 'running';
|
|
15
|
+
/**
|
|
16
|
+
* Set by `stop()`. Distinct from `_status` so the check in `startEngine` is
|
|
17
|
+
* not narrowed away by the compiler: `stop()` can flip it while that method
|
|
18
|
+
* is awaiting the engine.
|
|
19
|
+
*/
|
|
20
|
+
_stopRequested = false;
|
|
9
21
|
/**
|
|
10
22
|
* Wall-clock creation time. The watchdog needs it: `engineProcess` stays
|
|
11
23
|
* undefined until `engine.start` resolves, and without a grace window any
|
|
@@ -38,6 +50,21 @@ export class SessionController {
|
|
|
38
50
|
await this._startPromise;
|
|
39
51
|
}
|
|
40
52
|
async startEngine(options) {
|
|
53
|
+
// Already stopped before we got here. The orchestrator chains `start`
|
|
54
|
+
// behind a zombie eviction that can take up to STOP_AGENT_TIMEOUT_MS, and a
|
|
55
|
+
// stop landing in that window resolves immediately and reports "stopped" —
|
|
56
|
+
// so the caller (delete, purge-worktree) is already removing the worktree.
|
|
57
|
+
// Spawning into it and stopping afterwards, as the check below does, is too
|
|
58
|
+
// late; the process must never exist.
|
|
59
|
+
//
|
|
60
|
+
// Still report an end: the orchestrator already created the session row,
|
|
61
|
+
// and only session:ended closes it. Without this the row stayed `running`
|
|
62
|
+
// until the next boot — blocking deleteSession and tagging the next
|
|
63
|
+
// messages onto a ghost session.
|
|
64
|
+
if (this._stopRequested) {
|
|
65
|
+
this.handle({ kind: 'session:ended', reason: 'killed', exitCode: null });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
41
68
|
const process = await this.engine.start(options, (ev) => this.handle(ev));
|
|
42
69
|
this._engineProcess = process;
|
|
43
70
|
if (this._status === 'stopping') {
|
|
@@ -63,6 +90,7 @@ export class SessionController {
|
|
|
63
90
|
this._engineProcess.interrupt();
|
|
64
91
|
}
|
|
65
92
|
async stop() {
|
|
93
|
+
this._stopRequested = true;
|
|
66
94
|
this._status = 'stopping';
|
|
67
95
|
// `startEngine` may still be in flight: `_engineProcess` stays undefined
|
|
68
96
|
// until `engine.start` resolves, so without this wait `stop()` would
|
|
@@ -5,6 +5,7 @@ import { slugifyProjectName } from '../utils/project-slug.js';
|
|
|
5
5
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
6
|
import * as orchestrator from './agent/orchestrator.js';
|
|
7
7
|
import * as cleanupScriptService from './cleanup-script-service.js';
|
|
8
|
+
import * as lifecycleHookService from './lifecycle-hook-service.js';
|
|
8
9
|
import * as settingsService from './settings-service.js';
|
|
9
10
|
import { getSuitePrompts } from './skill-suite-prompts.js';
|
|
10
11
|
import { emit, emitEphemeral } from './websocket-service.js';
|
|
@@ -91,6 +92,13 @@ export function disable(workspaceId, reason) {
|
|
|
91
92
|
console.info(`[auto-loop] disabled workspace '${workspaceId}' reason=${reason}` +
|
|
92
93
|
` pending=${diagnostic.tasksPending} streak=${diagnostic.noProgressStreak} status=${diagnostic.status}`);
|
|
93
94
|
emitEphemeral(workspaceId, 'autoloop:disabled', diagnostic);
|
|
95
|
+
// The user's own hook, if they wrote one. Deliberately fired for EVERY
|
|
96
|
+
// reason, not only 'completed' like the cleanup script below: a loop that
|
|
97
|
+
// stalled is precisely the case worth being told about.
|
|
98
|
+
void lifecycleHookService.onAutoLoopDisabled(workspaceId, {
|
|
99
|
+
reason,
|
|
100
|
+
tasksPending: diagnostic.tasksPending,
|
|
101
|
+
});
|
|
94
102
|
// The loop finished every task — run the project's cleanup script. Other
|
|
95
103
|
// disable reasons (stall / error / user-action) leave tasks unfinished, so
|
|
96
104
|
// they intentionally skip the cleanup.
|
|
@@ -262,6 +270,51 @@ function computeIterationNumber(workspaceId) {
|
|
|
262
270
|
* Worktree-missing edge: if the worktree directory has been deleted on disk,
|
|
263
271
|
* `orchestrator.startAgent` throws during engine.start — caught below.
|
|
264
272
|
*/
|
|
273
|
+
/**
|
|
274
|
+
* Whether another unattended session may start right now. `0` — the default —
|
|
275
|
+
* means no limit. Only the auto-loop consults this: a session the user starts
|
|
276
|
+
* by hand always goes through.
|
|
277
|
+
*/
|
|
278
|
+
function hasFreeAgentSlot() {
|
|
279
|
+
const limit = settingsService.getGlobalSettings().maxConcurrentAgents;
|
|
280
|
+
if (!limit || limit <= 0)
|
|
281
|
+
return true;
|
|
282
|
+
return orchestrator.runningAgentCount() < limit;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Give every auto-loop workspace parked on "waiting for a free agent slot"
|
|
286
|
+
* another chance. Called by the orchestrator after ANY session ends: a parked
|
|
287
|
+
* workspace has no session of its own to end, so nothing else would ever ask
|
|
288
|
+
* spawnNextIteration on its behalf again.
|
|
289
|
+
*
|
|
290
|
+
* Stops as soon as the slots are full again, so with three parked and one
|
|
291
|
+
* free, exactly one starts and the other two keep waiting rather than each
|
|
292
|
+
* re-announcing that they wait.
|
|
293
|
+
*/
|
|
294
|
+
export function resumeWaitingWorkspaces() {
|
|
295
|
+
const db = getDb();
|
|
296
|
+
const rows = db
|
|
297
|
+
.prepare(`SELECT id, status FROM workspaces
|
|
298
|
+
WHERE auto_loop = 1 AND auto_loop_ready = 1 AND archived_at IS NULL
|
|
299
|
+
ORDER BY updated_at ASC`)
|
|
300
|
+
.all();
|
|
301
|
+
for (const row of rows) {
|
|
302
|
+
if (!hasFreeAgentSlot())
|
|
303
|
+
return;
|
|
304
|
+
if (orchestrator.hasController(row.id))
|
|
305
|
+
continue;
|
|
306
|
+
// Same guards as spawnNextIteration's own callers: a quota backoff or a
|
|
307
|
+
// pending question owns the next start of that workspace.
|
|
308
|
+
if (row.status === 'awaiting-user' || row.status === 'quota')
|
|
309
|
+
continue;
|
|
310
|
+
try {
|
|
311
|
+
spawnNextIteration(row.id);
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
console.error(`[auto-loop] resume after slot freed failed for workspace '${row.id}':`, err);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
265
318
|
function spawnNextIteration(workspaceId, opts = {}) {
|
|
266
319
|
const row = getRow(workspaceId);
|
|
267
320
|
if (!row)
|
|
@@ -274,6 +327,18 @@ function spawnNextIteration(workspaceId, opts = {}) {
|
|
|
274
327
|
disable(workspaceId, 'completed');
|
|
275
328
|
return;
|
|
276
329
|
}
|
|
330
|
+
// Hold off rather than pile on. Ten auto-loop workspaces waking together
|
|
331
|
+
// hammer the same rate limit and each land in their own backoff, so nobody
|
|
332
|
+
// gets through. The loop stays enabled: the next session to end anywhere
|
|
333
|
+
// calls back in here, and by then a slot has freed.
|
|
334
|
+
if (!hasFreeAgentSlot()) {
|
|
335
|
+
console.log(`[auto-loop] workspace '${workspaceId}' is waiting for a free agent slot`);
|
|
336
|
+
emitEphemeral(workspaceId, 'autoloop:waiting-for-slot', {
|
|
337
|
+
running: orchestrator.runningAgentCount(),
|
|
338
|
+
limit: settingsService.getGlobalSettings().maxConcurrentAgents,
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
277
342
|
const iterationNumber = computeIterationNumber(workspaceId);
|
|
278
343
|
// Override block: replaces the standard iteration prompt body when the task
|
|
279
344
|
// title carries a recognized prefix (case-sensitive, trailing space required).
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { getDb } from '../db/index.js';
|
|
2
|
+
import { getGlobalSettings } from './settings-service.js';
|
|
3
|
+
import { broadcastAll } from './websocket-service.js';
|
|
4
|
+
/** How often the reminder loop looks at the clock. */
|
|
5
|
+
const TICK_INTERVAL_MS = 60_000;
|
|
6
|
+
/**
|
|
7
|
+
* Which waiting workspaces are owed a reminder now.
|
|
8
|
+
*
|
|
9
|
+
* `state` is updated in place: entries appear when a workspace starts waiting
|
|
10
|
+
* and disappear when it stops, so answering a question resets its clock.
|
|
11
|
+
*
|
|
12
|
+
* Pure apart from that mutation, which is what makes the interval arithmetic
|
|
13
|
+
* — the part that is easy to get wrong — testable without a timer or a DB.
|
|
14
|
+
*/
|
|
15
|
+
export function computeDueReminders(params) {
|
|
16
|
+
const { awaiting, now, intervalMs, state } = params;
|
|
17
|
+
// Disabled. Clear the state too: re-enabling later should start from now,
|
|
18
|
+
// not from a clock that kept running while the feature was off.
|
|
19
|
+
if (intervalMs <= 0) {
|
|
20
|
+
state.clear();
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const stillWaiting = new Set(awaiting.map((w) => w.id));
|
|
24
|
+
for (const id of state.keys()) {
|
|
25
|
+
if (!stillWaiting.has(id))
|
|
26
|
+
state.delete(id);
|
|
27
|
+
}
|
|
28
|
+
const due = [];
|
|
29
|
+
for (const workspace of awaiting) {
|
|
30
|
+
const existing = state.get(workspace.id);
|
|
31
|
+
if (!existing) {
|
|
32
|
+
// First sight. The question was just asked, so nothing is overdue yet.
|
|
33
|
+
state.set(workspace.id, { firstSeenAt: now, lastRemindedAt: null, remindersSent: 0 });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// Measured from the LAST reminder (or the first sighting), not as a
|
|
37
|
+
// multiple of the interval since the start: that way a change of interval
|
|
38
|
+
// applies from the next reminder, and a laptop waking from sleep owing
|
|
39
|
+
// four reminders sends one, not four, for the same unanswered question.
|
|
40
|
+
const since = existing.lastRemindedAt ?? existing.firstSeenAt;
|
|
41
|
+
if (now - since < intervalMs)
|
|
42
|
+
continue;
|
|
43
|
+
existing.lastRemindedAt = now;
|
|
44
|
+
existing.remindersSent += 1;
|
|
45
|
+
due.push({
|
|
46
|
+
id: workspace.id,
|
|
47
|
+
name: workspace.name,
|
|
48
|
+
waitingMs: now - existing.firstSeenAt,
|
|
49
|
+
reminderNumber: existing.remindersSent,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return due;
|
|
53
|
+
}
|
|
54
|
+
const reminderState = new Map();
|
|
55
|
+
let timer = null;
|
|
56
|
+
function listAwaitingWorkspaces() {
|
|
57
|
+
return getDb()
|
|
58
|
+
.prepare(`SELECT id, name FROM workspaces WHERE status = 'awaiting-user' AND archived_at IS NULL`)
|
|
59
|
+
.all();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* One pass of the reminder loop. Broadcast rather than emitted to the
|
|
63
|
+
* workspace's subscribers: the whole point is to reach the user who is looking
|
|
64
|
+
* at something else, or at nothing.
|
|
65
|
+
*
|
|
66
|
+
* Never throws — it runs on a timer nobody is watching, and an exception here
|
|
67
|
+
* would silently end the loop for the rest of the process's life.
|
|
68
|
+
*/
|
|
69
|
+
export function runReminderTick(now = Date.now()) {
|
|
70
|
+
try {
|
|
71
|
+
const minutes = getGlobalSettings().awaitingUserReminderMinutes;
|
|
72
|
+
// The API refuses more than a day; a hand-edited settings.json gets the
|
|
73
|
+
// same ceiling here rather than a reminder that fires next week.
|
|
74
|
+
const intervalMs = typeof minutes === 'number' && minutes > 0 ? Math.min(minutes, 1440) * 60_000 : 0;
|
|
75
|
+
if (intervalMs <= 0) {
|
|
76
|
+
reminderState.clear();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const reminder of computeDueReminders({
|
|
80
|
+
awaiting: listAwaitingWorkspaces(),
|
|
81
|
+
now,
|
|
82
|
+
intervalMs,
|
|
83
|
+
state: reminderState,
|
|
84
|
+
})) {
|
|
85
|
+
broadcastAll('workspace:awaiting-reminder', {
|
|
86
|
+
workspaceId: reminder.id,
|
|
87
|
+
workspaceName: reminder.name,
|
|
88
|
+
waitingMinutes: Math.round(reminder.waitingMs / 60_000),
|
|
89
|
+
reminderNumber: reminder.reminderNumber,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
console.error('[awaiting-user-reminder] tick failed:', err);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Start the reminder loop. Idempotent. */
|
|
98
|
+
export function startAwaitingUserReminder() {
|
|
99
|
+
if (timer)
|
|
100
|
+
return;
|
|
101
|
+
timer = setInterval(() => runReminderTick(), TICK_INTERVAL_MS);
|
|
102
|
+
timer.unref?.();
|
|
103
|
+
}
|
|
104
|
+
/** Stop the reminder loop. */
|
|
105
|
+
export function stopAwaitingUserReminder() {
|
|
106
|
+
if (timer) {
|
|
107
|
+
clearInterval(timer);
|
|
108
|
+
timer = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** @internal */
|
|
112
|
+
export function _resetForTest() {
|
|
113
|
+
stopAwaitingUserReminder();
|
|
114
|
+
reminderState.clear();
|
|
115
|
+
}
|
|
@@ -109,7 +109,7 @@ async function runBuiltInStrategy(workspaceId, worktreePath, workingBranch, proj
|
|
|
109
109
|
// the branchExists check below is the authoritative gate for the new base
|
|
110
110
|
// (it throws a clean error, mapped to a 400, rather than a raw fetch error).
|
|
111
111
|
try {
|
|
112
|
-
gitOps.
|
|
112
|
+
await gitOps.fetchAllBranchesAsync(worktreePath);
|
|
113
113
|
}
|
|
114
114
|
catch {
|
|
115
115
|
// offline / no remote — proceed with local refs
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tools an engine calls to ask the user a question. The persisted
|
|
3
|
+
* `session:user-input-requested` event is purged once answered, so the tool
|
|
4
|
+
* call — which stays — is what gets counted.
|
|
5
|
+
*/
|
|
6
|
+
const QUESTION_TOOLS = ['AskUserQuestion', 'request_user_input'];
|
|
7
|
+
export function computeWorkspaceActivityStats(db, workspaceId) {
|
|
8
|
+
const sessions = db
|
|
9
|
+
.prepare(`SELECT COUNT(*) AS count,
|
|
10
|
+
COALESCE(SUM(CASE WHEN ended_at IS NOT NULL
|
|
11
|
+
THEN MAX(0, (julianday(ended_at) - julianday(started_at)) * 86400000.0)
|
|
12
|
+
ELSE 0 END), 0) AS duration_ms
|
|
13
|
+
FROM agent_sessions WHERE workspace_id = ?`)
|
|
14
|
+
.get(workspaceId);
|
|
15
|
+
const metrics = db
|
|
16
|
+
.prepare(`SELECT COALESCE(SUM(tool_calls), 0) AS tool_calls, COALESCE(SUM(errors), 0) AS errors,
|
|
17
|
+
COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens
|
|
18
|
+
FROM session_event_metrics WHERE workspace_id = ?`)
|
|
19
|
+
.get(workspaceId);
|
|
20
|
+
const messages = db
|
|
21
|
+
.prepare(`SELECT
|
|
22
|
+
SUM(CASE WHEN type = 'user:message' AND json_extract(payload, '$.sender') = 'user' THEN 1 ELSE 0 END) AS user_messages,
|
|
23
|
+
SUM(CASE WHEN type = 'user:message' AND json_extract(payload, '$.sender') != 'user' THEN 1 ELSE 0 END) AS injected,
|
|
24
|
+
COUNT(DISTINCT CASE WHEN type = 'agent:event' AND json_extract(payload, '$.kind') = 'message:text'
|
|
25
|
+
THEN json_extract(payload, '$.messageId') END) AS agent_messages,
|
|
26
|
+
SUM(CASE WHEN type = 'agent:event' AND json_extract(payload, '$.kind') = 'tool:call'
|
|
27
|
+
AND json_extract(payload, '$.name') IN (${QUESTION_TOOLS.map(() => '?').join(', ')})
|
|
28
|
+
THEN 1 ELSE 0 END) AS questions
|
|
29
|
+
FROM ws_events WHERE workspace_id = ? AND json_valid(payload)`)
|
|
30
|
+
.get(...QUESTION_TOOLS, workspaceId);
|
|
31
|
+
return {
|
|
32
|
+
sessions: sessions.count,
|
|
33
|
+
durationMs: Math.round(sessions.duration_ms),
|
|
34
|
+
userMessages: messages.user_messages ?? 0,
|
|
35
|
+
injectedPrompts: messages.injected ?? 0,
|
|
36
|
+
agentMessages: messages.agent_messages,
|
|
37
|
+
questions: messages.questions ?? 0,
|
|
38
|
+
toolCalls: metrics.tool_calls,
|
|
39
|
+
errors: metrics.errors,
|
|
40
|
+
inputTokens: metrics.input_tokens,
|
|
41
|
+
outputTokens: metrics.output_tokens,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -18,12 +18,30 @@ function readRemoteUrl(projectPath) {
|
|
|
18
18
|
return execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
19
19
|
cwd: projectPath,
|
|
20
20
|
encoding: 'utf-8',
|
|
21
|
+
// Without a bound, a git hanging on a network filesystem blocks the whole
|
|
22
|
+
// event loop: this runs synchronously, and the pr-watcher calls it for
|
|
23
|
+
// every workspace on every tick.
|
|
24
|
+
timeout: 5_000,
|
|
21
25
|
}).trim();
|
|
22
26
|
}
|
|
23
27
|
catch {
|
|
24
28
|
return '';
|
|
25
29
|
}
|
|
26
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Auto-detected forge per project path. The remote of a checkout does not
|
|
33
|
+
* change while Kōbō runs, and resolving it costs a synchronous subprocess that
|
|
34
|
+
* the pr-watcher would otherwise pay per workspace, per 30 s tick, twice.
|
|
35
|
+
* Bounded by TTL so a remote that is added or repointed is picked up without a
|
|
36
|
+
* restart, and only the `auto` path is cached — an explicit setting is already
|
|
37
|
+
* free to read.
|
|
38
|
+
*/
|
|
39
|
+
const autoForgeCache = new Map();
|
|
40
|
+
const AUTO_FORGE_TTL_MS = 5 * 60_000;
|
|
41
|
+
/** Drop the cached auto-detection. Exported for tests. */
|
|
42
|
+
export function _clearForgeCache() {
|
|
43
|
+
autoForgeCache.clear();
|
|
44
|
+
}
|
|
27
45
|
/**
|
|
28
46
|
* Resolve the forge for a project: the explicit per-project setting wins;
|
|
29
47
|
* `auto` (the default) classifies the origin remote URL.
|
|
@@ -33,5 +51,15 @@ export function resolveForge(projectPath) {
|
|
|
33
51
|
if (setting === 'github' || setting === 'gitlab' || setting === 'bitbucket-community' || setting === 'none') {
|
|
34
52
|
return setting;
|
|
35
53
|
}
|
|
36
|
-
|
|
54
|
+
const cached = autoForgeCache.get(projectPath);
|
|
55
|
+
if (cached && Date.now() - cached.readAt < AUTO_FORGE_TTL_MS)
|
|
56
|
+
return cached.forge;
|
|
57
|
+
const remoteUrl = readRemoteUrl(projectPath);
|
|
58
|
+
const forge = forgeFromRemoteUrl(remoteUrl);
|
|
59
|
+
// A read that failed (git timing out on a network mount, a transient lock)
|
|
60
|
+
// is not knowledge: caching it would disable PR actions for five minutes on
|
|
61
|
+
// a project that has a perfectly good remote.
|
|
62
|
+
if (remoteUrl)
|
|
63
|
+
autoForgeCache.set(projectPath, { forge, readAt: Date.now() });
|
|
64
|
+
return forge;
|
|
37
65
|
}
|