@loicngr/kobo 1.11.13 → 1.11.15
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/CHANGELOG.md +8 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +26 -3
- package/dist/mcp-server/kobo-tasks-server.js +27 -0
- package/dist/server/db/migrations.js +13 -0
- package/dist/server/db/schema.js +2 -1
- package/dist/server/index.js +5 -7
- package/dist/server/routes/export.js +2 -1
- package/dist/server/routes/workspaces.js +15 -0
- package/dist/server/services/agent/engines/claude-code/engine.js +38 -11
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +3 -1
- package/dist/server/services/agent/engines/claude-code/options-builder.js +2 -1
- package/dist/server/services/agent/engines/codex/engine.js +2 -0
- package/dist/server/services/agent/engines/codex/options-builder.js +2 -1
- package/dist/server/services/agent/engines/types.js +1 -0
- package/dist/server/services/agent/orchestrator.js +114 -17
- package/dist/server/services/auto-loop-service.js +6 -0
- package/dist/server/services/cron-service.js +2 -1
- package/dist/server/services/dev-server-service.js +13 -1
- package/dist/server/services/wakeup-service.js +14 -12
- package/dist/server/utils/git-ops.js +3 -2
- package/dist/server/utils/safe-path.js +34 -0
- package/package.json +3 -3
- package/src/client/dist/pwa/assets/{ActivityFeed-Ds8cAMNc.js → ActivityFeed-C7NLbzD6.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-DO6DNO4Q.js → ChangelogPage-DwWh9JgQ.js} +1 -1
- package/src/client/dist/pwa/assets/{ClosePopup-DuAqn8Pb.js → ClosePopup-B6KJBZyG.js} +1 -1
- package/src/client/dist/pwa/assets/{CreatePage-BnMSo_79.js → CreatePage-pyI9OKPs.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-CMnFGReI.js → DiffViewer-DuNeI5C3.js} +3 -3
- package/src/client/dist/pwa/assets/{HealthPage-BRAxQKSP.js → HealthPage-CCJBw0MT.js} +1 -1
- package/src/client/dist/pwa/assets/{MainLayout-BN1ug95K.js → MainLayout-CaVTdsZl.js} +2 -2
- package/src/client/dist/pwa/assets/{QBtnToggle-DsCDNQzX.js → QBtnToggle-CadHfv93.js} +1 -1
- package/src/client/dist/pwa/assets/{QChip-DWGDTZnX.js → QChip-CktOt0oC.js} +1 -1
- package/src/client/dist/pwa/assets/{QSelect-8Ip46vhk.js → QSelect-C0yTmkrq.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-Bz4g3bmr.js → QTooltip-Cho7yoPz.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-BD53VJEr.js → SearchPage-DmfKy4pY.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-3rYdJZga.js → SettingsPage-B-CtW1eE.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-BKu8V4eN.js → TouchPan-BAN0KuNq.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-CbC_t5Rx.js → WorkspacePage-DHXPtEsY.js} +3 -3
- package/src/client/dist/pwa/assets/{cssMode-vaMVESGy.js → cssMode-DXZAn6vO.js} +1 -1
- package/src/client/dist/pwa/assets/{documents-CyKQ9lT6.js → documents-B23TsgeA.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-CH3dhpKq.js → editor.api-A6JQ_eAF.js} +1 -1
- package/src/client/dist/pwa/assets/{expand-template-DQQQftsk.js → expand-template-CFOxSpLI.js} +1 -1
- package/src/client/dist/pwa/assets/{freemarker2-deLpERsj.js → freemarker2-CUIeOWM8.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-DdjS05Tc.js → handlebars-Cyql1jca.js} +1 -1
- package/src/client/dist/pwa/assets/{html-pK1ZEFvP.js → html-tH-6-_8T.js} +1 -1
- package/src/client/dist/pwa/assets/{htmlMode-DJFDI9Ru.js → htmlMode-VmrCyH2a.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-Ds8Ww7mW.js +1 -0
- package/src/client/dist/pwa/assets/{index-B_OmZ65F.js → index-BvLZetuF.js} +3 -3
- package/src/client/dist/pwa/assets/{javascript-DbuFfxco.js → javascript-CDVRMaa9.js} +1 -1
- package/src/client/dist/pwa/assets/{jsonMode-B0Fu8Dcc.js → jsonMode-Ck7yXgSl.js} +1 -1
- package/src/client/dist/pwa/assets/{kobo-commands-rb43ZeWG.js → kobo-commands-DmKgSiYn.js} +1 -1
- package/src/client/dist/pwa/assets/{layout-tAFrvQ5V.js → layout-Bx3LBcNr.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-DqDIKOCy.js → liquid-DuPNGLDX.js} +1 -1
- package/src/client/dist/pwa/assets/{lspLanguageFeatures-CVcEibfJ.js → lspLanguageFeatures-nffl_SEi.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-Bs4CovhI.js → mdx-0sVmmIxp.js} +1 -1
- package/src/client/dist/pwa/assets/{network-auth-DYrrh7Zr.js → network-auth-Chk1CKmH.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-D1IYH8Pd.js → pending-diff-open-5x3bHt_s.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-DO6uj29M.js → project-color-DPlEC0w5.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BJoP0uN-.js → python-C7z_oSTh.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-cwD-YBcr.js → razor-Y5ev_nkW.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-DZnIueUV.js → render-chat-markdown-Btg3ez9I.js} +1 -1
- package/src/client/dist/pwa/assets/{tsMode-of3oS9sU.js → tsMode-RLBeqCG7.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-BTWMoUxn.js → typescript-DLXsl8qJ.js} +1 -1
- package/src/client/dist/pwa/assets/{use-onboarding-BTFkRBjE.js → use-onboarding-B-38VMWL.js} +1 -1
- package/src/client/dist/pwa/assets/{use-quasar-C1RCDSb6.js → use-quasar-BBt-28X1.js} +1 -1
- package/src/client/dist/pwa/assets/{vs-DDeaNKsH.js → vs-Ddu-KQME.js} +3 -3
- package/src/client/dist/pwa/assets/{workers-DQnKuGcq.js → workers-Dt0XxgF3.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-RglGWD_3.js → xml-CNH2EfiJ.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-CSqGQpai.js → yaml-FpVlkkq1.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +24 -4
- package/src/mcp-server/kobo-tasks-server.ts +27 -0
- package/src/client/dist/pwa/assets/i18n-CK38o-IF.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to Kōbō are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
|
|
5
5
|
section — the in-app "What's new" dialog reads this file.
|
|
6
6
|
|
|
7
|
+
## 1.11.15
|
|
8
|
+
|
|
9
|
+
- feat(agent): harden autonomous session lifecycle
|
|
10
|
+
|
|
11
|
+
## 1.11.14
|
|
12
|
+
|
|
13
|
+
- ⬆️ develop - refactor(deps): Bump SDKs + refactor shutdown + hardening sécurité
|
|
14
|
+
|
|
7
15
|
## 1.11.13
|
|
8
16
|
|
|
9
17
|
- fix(engines): unstick agent sessions that never emit session:ended
|
|
@@ -4,6 +4,7 @@ import { nanoid } from 'nanoid';
|
|
|
4
4
|
import * as cronService from '../server/services/cron-service.js';
|
|
5
5
|
import * as settingsService from '../server/services/settings-service.js';
|
|
6
6
|
import { slugifyProjectName } from '../server/utils/project-slug.js';
|
|
7
|
+
import { resolveExistingPathInside } from '../server/utils/safe-path.js';
|
|
7
8
|
import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
|
|
8
9
|
/** Allowed task status values. */
|
|
9
10
|
export const VALID_TASK_STATUSES = ['pending', 'in_progress', 'done'];
|
|
@@ -296,6 +297,7 @@ export function getTicketSourcesHandler(worktreePath) {
|
|
|
296
297
|
export const DOCUMENT_DIRS = ['docs/plans', 'docs/superpowers', '.ai/thoughts'];
|
|
297
298
|
/** Depth cap to keep recursion bounded even on pathological symlink loops. */
|
|
298
299
|
const DOC_MAX_DEPTH = 8;
|
|
300
|
+
const DOC_MAX_FILE_BYTES = 1024 * 1024;
|
|
299
301
|
function walkMarkdownFiles(rootAbs, rootRel, out, depth = 0) {
|
|
300
302
|
if (depth > DOC_MAX_DEPTH)
|
|
301
303
|
return;
|
|
@@ -313,11 +315,13 @@ function walkMarkdownFiles(rootAbs, rootRel, out, depth = 0) {
|
|
|
313
315
|
const relEntry = `${rootRel}/${entry}`;
|
|
314
316
|
let stat;
|
|
315
317
|
try {
|
|
316
|
-
stat = fs.
|
|
318
|
+
stat = fs.lstatSync(absEntry);
|
|
317
319
|
}
|
|
318
320
|
catch {
|
|
319
321
|
continue;
|
|
320
322
|
}
|
|
323
|
+
if (stat.isSymbolicLink())
|
|
324
|
+
continue;
|
|
321
325
|
if (stat.isDirectory()) {
|
|
322
326
|
walkMarkdownFiles(absEntry, relEntry, out, depth + 1);
|
|
323
327
|
}
|
|
@@ -336,7 +340,14 @@ export function listDocumentsHandler(worktreePath) {
|
|
|
336
340
|
const absDir = path.join(worktreePath, dir);
|
|
337
341
|
if (!fs.existsSync(absDir))
|
|
338
342
|
continue;
|
|
339
|
-
|
|
343
|
+
try {
|
|
344
|
+
if (fs.lstatSync(absDir).isSymbolicLink())
|
|
345
|
+
continue;
|
|
346
|
+
walkMarkdownFiles(resolveExistingPathInside(worktreePath, absDir), dir, documents);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// A document directory outside the worktree is never eligible.
|
|
350
|
+
}
|
|
340
351
|
}
|
|
341
352
|
documents.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime());
|
|
342
353
|
return documents;
|
|
@@ -361,7 +372,19 @@ export function readDocumentHandler(worktreePath, relPath) {
|
|
|
361
372
|
if (!fs.existsSync(abs)) {
|
|
362
373
|
throw new Error(`Document not found: ${normalized}`);
|
|
363
374
|
}
|
|
364
|
-
|
|
375
|
+
const lstat = fs.lstatSync(abs);
|
|
376
|
+
if (lstat.isSymbolicLink()) {
|
|
377
|
+
// Resolve for a consistent error if the link escapes; internal links are
|
|
378
|
+
// rejected too so list/read share the same no-symlink contract.
|
|
379
|
+
resolveExistingPathInside(worktreePath, abs);
|
|
380
|
+
throw new Error('Symbolic links are not allowed in documents');
|
|
381
|
+
}
|
|
382
|
+
const safeAbs = resolveExistingPathInside(worktreePath, abs);
|
|
383
|
+
const stat = fs.statSync(safeAbs);
|
|
384
|
+
if (stat.size > DOC_MAX_FILE_BYTES) {
|
|
385
|
+
throw new Error(`Document too large (max ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB)`);
|
|
386
|
+
}
|
|
387
|
+
return { path: normalized, content: fs.readFileSync(safeAbs, 'utf-8') };
|
|
365
388
|
}
|
|
366
389
|
/**
|
|
367
390
|
* Append a thought / decision / note to
|
|
@@ -188,6 +188,21 @@ const WORKSPACE_SCOPED_TOOLS = [
|
|
|
188
188
|
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
189
189
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
190
190
|
},
|
|
191
|
+
{
|
|
192
|
+
name: 'set_auto_loop',
|
|
193
|
+
description: 'Enable or disable auto-loop for THIS workspace. Read get_workspace_info first. Enable it only when the user explicitly asks for autonomous continuation and pending tasks remain; disable it only when the user explicitly asks. Kōbō resumes the next iteration automatically after a session ends while it is enabled.',
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: {
|
|
197
|
+
enabled: {
|
|
198
|
+
type: 'boolean',
|
|
199
|
+
description: 'True to enable auto-loop; false to disable it.',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
required: ['enabled'],
|
|
203
|
+
},
|
|
204
|
+
annotations: { destructiveHint: false, openWorldHint: false },
|
|
205
|
+
},
|
|
191
206
|
{
|
|
192
207
|
name: 'set_workspace_agent_description',
|
|
193
208
|
description: "Set or clear the workspace's agent-side description (≤ 200 chars). Pass an empty string to clear. The user sees this string under the workspace title in the sidebar (it takes precedence over the user-controlled `description` field). Plain text only. The current value is available via get_workspace_info as agentDescription. NOTE: there is a separate user-controlled `description` field — do NOT try to write it; it has no MCP tool.",
|
|
@@ -641,6 +656,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
641
656
|
if (name === 'get_workspace_info') {
|
|
642
657
|
return ok(getWorkspaceInfoHandler(db, workspaceId));
|
|
643
658
|
}
|
|
659
|
+
if (name === 'set_auto_loop') {
|
|
660
|
+
if (typeof a.enabled !== 'boolean')
|
|
661
|
+
return fail('enabled parameter is required and must be boolean');
|
|
662
|
+
try {
|
|
663
|
+
const method = a.enabled ? 'POST' : 'DELETE';
|
|
664
|
+
const result = await backendRequest(method, `/api/workspaces/${workspaceId}/auto-loop`);
|
|
665
|
+
return ok(result);
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
669
|
+
}
|
|
670
|
+
}
|
|
644
671
|
if (name === 'set_workspace_agent_description') {
|
|
645
672
|
const description = a.description;
|
|
646
673
|
if (typeof description !== 'string')
|
|
@@ -591,6 +591,19 @@ export const migrations = [
|
|
|
591
591
|
`);
|
|
592
592
|
},
|
|
593
593
|
},
|
|
594
|
+
{
|
|
595
|
+
version: 35,
|
|
596
|
+
name: 'add-agent-session-task-progress-baseline',
|
|
597
|
+
migrate: (db) => {
|
|
598
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'agent_sessions'").get();
|
|
599
|
+
if (!table)
|
|
600
|
+
return;
|
|
601
|
+
const columns = db.prepare('PRAGMA table_info(agent_sessions)').all();
|
|
602
|
+
if (!columns.some((column) => column.name === 'task_progress_baseline')) {
|
|
603
|
+
db.exec('ALTER TABLE agent_sessions ADD COLUMN task_progress_baseline TEXT');
|
|
604
|
+
}
|
|
605
|
+
},
|
|
606
|
+
},
|
|
594
607
|
];
|
|
595
608
|
/** Current schema version — always equals the highest migration version. */
|
|
596
609
|
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
package/dist/server/db/schema.js
CHANGED
package/dist/server/index.js
CHANGED
|
@@ -25,12 +25,12 @@ import templatesRouter from './routes/templates.js';
|
|
|
25
25
|
import usageRoutes from './routes/usage.js';
|
|
26
26
|
import voiceRouter from './routes/voice.js';
|
|
27
27
|
import workspacesRouter from './routes/workspaces.js';
|
|
28
|
-
import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
|
|
28
|
+
import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
|
|
29
29
|
import * as autoLoopService from './services/auto-loop-service.js';
|
|
30
30
|
import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
|
|
31
31
|
import * as cronService from './services/cron-service.js';
|
|
32
32
|
import { createDailyDbBackupIfNeeded, createPreMigrationBackup } from './services/db-backup-service.js';
|
|
33
|
-
import { startDevServer, stopDevServer } from './services/dev-server-service.js';
|
|
33
|
+
import { startDevServer, stopAllDevServers, stopDevServer } from './services/dev-server-service.js';
|
|
34
34
|
import { authorizeWsUpgrade, generateToken, getLanUrls, resolveBindHost, resolveNetworkAccessEnvOverrides, } from './services/network-access-service.js';
|
|
35
35
|
import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
|
|
36
36
|
import * as quotaBackoffService from './services/quota-backoff-service.js';
|
|
@@ -42,7 +42,6 @@ import * as wakeupService from './services/wakeup-service.js';
|
|
|
42
42
|
import { emit, emitEphemeral, handleConnection, setMessageHandler } from './services/websocket-service.js';
|
|
43
43
|
import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
|
|
44
44
|
import { getClientSpaPath, getDbPath, getKoboHome, getPackageVersion } from './utils/paths.js';
|
|
45
|
-
import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/process-tracker.js';
|
|
46
45
|
console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
|
|
47
46
|
// Initialize DB + run migrations
|
|
48
47
|
const db = getDb();
|
|
@@ -72,7 +71,6 @@ void createDailyDbBackupIfNeeded(db, getDbPath()).then((r) => {
|
|
|
72
71
|
}
|
|
73
72
|
});
|
|
74
73
|
// Initialize process cleanup, agent watchdog, PR watcher, and wakeup rehydration
|
|
75
|
-
initProcessCleanup();
|
|
76
74
|
reconcileOrphanSessions();
|
|
77
75
|
startWatchdog();
|
|
78
76
|
wakeupService.rehydrate();
|
|
@@ -490,10 +488,10 @@ async function gracefulShutdown(signal) {
|
|
|
490
488
|
catch {
|
|
491
489
|
// Best-effort
|
|
492
490
|
}
|
|
493
|
-
//
|
|
491
|
+
// Stop managed agents and both direct/Docker dev servers before closing DB.
|
|
494
492
|
try {
|
|
495
|
-
|
|
496
|
-
console.log('[kobo]
|
|
493
|
+
await Promise.all([stopAllAgents(), stopAllDevServers()]);
|
|
494
|
+
console.log('[kobo] Agents and dev servers stopped');
|
|
497
495
|
}
|
|
498
496
|
catch {
|
|
499
497
|
// Best-effort
|
|
@@ -5,7 +5,8 @@ import * as workspaceService from '../services/workspace-service.js';
|
|
|
5
5
|
const app = new Hono();
|
|
6
6
|
/** Quote a CSV cell when it contains a delimiter, quote, or newline (RFC 4180). */
|
|
7
7
|
function csvCell(value) {
|
|
8
|
-
|
|
8
|
+
const safeValue = /^[\s]*[=+\-@]/.test(value) ? `'${value}` : value;
|
|
9
|
+
return /[",\r\n]/.test(safeValue) ? `"${safeValue.replace(/"/g, '""')}"` : safeValue;
|
|
9
10
|
}
|
|
10
11
|
function csvRow(values) {
|
|
11
12
|
return values.map(csvCell).join(',');
|
|
@@ -42,6 +42,7 @@ import * as worktreeService from '../services/worktree-service.js';
|
|
|
42
42
|
import { resolveUniqueBranchAndPath } from '../utils/branch-resolver.js';
|
|
43
43
|
import * as gitOps from '../utils/git-ops.js';
|
|
44
44
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
45
|
+
import * as safePath from '../utils/safe-path.js';
|
|
45
46
|
import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
46
47
|
/** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
|
|
47
48
|
const app = new Hono();
|
|
@@ -2692,6 +2693,13 @@ app.get('/:id/diff-file', (c) => {
|
|
|
2692
2693
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
2693
2694
|
}
|
|
2694
2695
|
const worktreePath = workspace.worktreePath;
|
|
2696
|
+
try {
|
|
2697
|
+
safePath.assertPathInside(worktreePath, filePath);
|
|
2698
|
+
}
|
|
2699
|
+
catch (err) {
|
|
2700
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2701
|
+
return c.json({ error: message }, 400);
|
|
2702
|
+
}
|
|
2695
2703
|
if (c.req.query('mode') === 'commits') {
|
|
2696
2704
|
const from = c.req.query('from');
|
|
2697
2705
|
const to = c.req.query('to');
|
|
@@ -2750,6 +2758,13 @@ app.post('/:id/rollback-file', async (c) => {
|
|
|
2750
2758
|
if (!filePath) {
|
|
2751
2759
|
return c.json({ error: 'Missing or invalid `path` field' }, 400);
|
|
2752
2760
|
}
|
|
2761
|
+
try {
|
|
2762
|
+
safePath.assertPathInside(workspace.worktreePath, filePath);
|
|
2763
|
+
}
|
|
2764
|
+
catch (err) {
|
|
2765
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2766
|
+
return c.json({ error: message }, 400);
|
|
2767
|
+
}
|
|
2753
2768
|
let target;
|
|
2754
2769
|
try {
|
|
2755
2770
|
target = gitOps.rollbackFile(workspace.worktreePath, workspace.workingBranch, filePath);
|
|
@@ -97,7 +97,9 @@ export function createClaudeCodeEngine() {
|
|
|
97
97
|
async start(options, onEvent) {
|
|
98
98
|
const abortController = new AbortController();
|
|
99
99
|
const mapperState = createMapperState();
|
|
100
|
-
|
|
100
|
+
// UI/tool ids can differ from the SDK task id. Key by the stable UI id
|
|
101
|
+
// so a terminal notification that omits task_id still clears the task.
|
|
102
|
+
const activeSubagentTaskIds = new Map();
|
|
101
103
|
// Pending canUseTool callbacks, keyed by SDK ctx.toolUseID.
|
|
102
104
|
const pendingResolvers = new Map();
|
|
103
105
|
const isInteractive = options.agentPermissionMode === 'interactive';
|
|
@@ -232,6 +234,22 @@ export function createClaudeCodeEngine() {
|
|
|
232
234
|
let iteratorRunning = false;
|
|
233
235
|
let userInterrupted = false;
|
|
234
236
|
let completedResponses = 0;
|
|
237
|
+
let turnCompletedEmittedForResponse = 0;
|
|
238
|
+
// `result` is the Claude Agent SDK's per-turn completion signal. Keep
|
|
239
|
+
// it distinct from iterator completion: the SDK may still drain
|
|
240
|
+
// informational events afterwards, while Kōbō must retain the session
|
|
241
|
+
// for orchestration and auto-loop until `session:ended`.
|
|
242
|
+
const emitTurnCompletedIfSettled = () => {
|
|
243
|
+
if (completedResponses === 0 ||
|
|
244
|
+
completedResponses === turnCompletedEmittedForResponse ||
|
|
245
|
+
activeSubagentTaskIds.size > 0 ||
|
|
246
|
+
pendingResolvers.size > 0 ||
|
|
247
|
+
inputStream.hasUnansweredInput(completedResponses)) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
turnCompletedEmittedForResponse = completedResponses;
|
|
251
|
+
safeEmit({ kind: 'turn:completed' });
|
|
252
|
+
};
|
|
235
253
|
// Guard so the post-result drain watchdog and the natural loop exit (or
|
|
236
254
|
// catch block) never both emit `session:ended` for the same run.
|
|
237
255
|
let sessionEndedEmitted = false;
|
|
@@ -267,8 +285,12 @@ export function createClaudeCodeEngine() {
|
|
|
267
285
|
return;
|
|
268
286
|
}
|
|
269
287
|
console.warn(`[claude-engine] SDK stream inactive ${TEXT_IDLE_TIMEOUT_MS}ms after a text-only response — forcing session:ended`);
|
|
270
|
-
|
|
271
|
-
|
|
288
|
+
if (userInterrupted)
|
|
289
|
+
emitSessionEnded('killed', null);
|
|
290
|
+
else if (mapperState.sawErrorResult)
|
|
291
|
+
emitSessionEnded('error', null);
|
|
292
|
+
else
|
|
293
|
+
emitSessionEnded('watchdog', null);
|
|
272
294
|
abortController.abort();
|
|
273
295
|
}, TEXT_IDLE_TIMEOUT_MS);
|
|
274
296
|
textIdleTimer.unref?.();
|
|
@@ -278,8 +300,12 @@ export function createClaudeCodeEngine() {
|
|
|
278
300
|
return;
|
|
279
301
|
resultDrainTimer = setTimeout(() => {
|
|
280
302
|
console.warn(`[claude-engine] SDK generator still open ${RESULT_DRAIN_TIMEOUT_MS}ms after 'result' — forcing session:ended`);
|
|
281
|
-
|
|
282
|
-
|
|
303
|
+
if (userInterrupted)
|
|
304
|
+
emitSessionEnded('killed', null);
|
|
305
|
+
else if (mapperState.sawErrorResult)
|
|
306
|
+
emitSessionEnded('error', null);
|
|
307
|
+
else
|
|
308
|
+
emitSessionEnded('watchdog', null);
|
|
283
309
|
abortController.abort();
|
|
284
310
|
}, RESULT_DRAIN_TIMEOUT_MS);
|
|
285
311
|
resultDrainTimer.unref?.();
|
|
@@ -309,7 +335,7 @@ export function createClaudeCodeEngine() {
|
|
|
309
335
|
if (inputStream.hasUnansweredInput(completedResponses))
|
|
310
336
|
return;
|
|
311
337
|
console.warn(`[claude-engine] Subagent(s) still tracked active ${SUBAGENT_STALL_TIMEOUT_MS}ms after the last turn ended — forcing session drain.`);
|
|
312
|
-
|
|
338
|
+
activeSubagentTaskIds.clear();
|
|
313
339
|
// This is a forced, unclean termination (the subagent may still be
|
|
314
340
|
// alive server-side) — never report it as a normal completion, so
|
|
315
341
|
// auto-loop doesn't treat an orphaned run as forward progress.
|
|
@@ -328,16 +354,16 @@ export function createClaudeCodeEngine() {
|
|
|
328
354
|
if (ev.kind !== 'subagent:progress')
|
|
329
355
|
continue;
|
|
330
356
|
if (ev.status === 'running')
|
|
331
|
-
|
|
357
|
+
activeSubagentTaskIds.set(ev.toolCallId, ev.taskId ?? ev.toolCallId);
|
|
332
358
|
else
|
|
333
|
-
|
|
359
|
+
activeSubagentTaskIds.delete(ev.toolCallId);
|
|
334
360
|
}
|
|
335
361
|
// The stall watchdog is only armed while we're waiting purely on
|
|
336
362
|
// background subagents (no further SDK turn expected unless one
|
|
337
363
|
// reports done). React the moment the last one clears instead of
|
|
338
364
|
// waiting for a 'result' message that may never come.
|
|
339
365
|
if (subagentStallTimer &&
|
|
340
|
-
|
|
366
|
+
activeSubagentTaskIds.size === 0 &&
|
|
341
367
|
!inputStream.hasUnansweredInput(completedResponses)) {
|
|
342
368
|
clearSubagentStallWatchdog();
|
|
343
369
|
inputStream.close();
|
|
@@ -358,9 +384,10 @@ export function createClaudeCodeEngine() {
|
|
|
358
384
|
if (msg.type === 'result') {
|
|
359
385
|
clearTextIdleWatchdog();
|
|
360
386
|
completedResponses++;
|
|
387
|
+
emitTurnCompletedIfSettled();
|
|
361
388
|
// A queued forced message starts the next response on this same SDK stream.
|
|
362
389
|
if (!inputStream.hasUnansweredInput(completedResponses)) {
|
|
363
|
-
if (
|
|
390
|
+
if (activeSubagentTaskIds.size === 0) {
|
|
364
391
|
clearSubagentStallWatchdog();
|
|
365
392
|
inputStream.close();
|
|
366
393
|
armResultDrainWatchdog();
|
|
@@ -454,7 +481,7 @@ export function createClaudeCodeEngine() {
|
|
|
454
481
|
// background subagent task keeps running unless told to stop via
|
|
455
482
|
// its own API, which would otherwise leave the Stop button
|
|
456
483
|
// appearing to do nothing for up to SUBAGENT_STALL_TIMEOUT_MS.
|
|
457
|
-
for (const taskId of
|
|
484
|
+
for (const taskId of activeSubagentTaskIds.values()) {
|
|
458
485
|
try {
|
|
459
486
|
void q.stopTask(taskId).catch(() => {
|
|
460
487
|
/* best-effort */
|
|
@@ -192,7 +192,8 @@ export function mapSdkMessage(msg, state) {
|
|
|
192
192
|
return events;
|
|
193
193
|
}
|
|
194
194
|
if (subtype === 'task_started' || subtype === 'task_progress' || subtype === 'task_notification') {
|
|
195
|
-
const
|
|
195
|
+
const taskId = typeof parsed.task_id === 'string' ? parsed.task_id : undefined;
|
|
196
|
+
const toolCallId = typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : taskId;
|
|
196
197
|
if (toolCallId) {
|
|
197
198
|
const usage = parsed.usage;
|
|
198
199
|
// Unlike task_started/task_progress (in-flight signals), task_notification
|
|
@@ -204,6 +205,7 @@ export function mapSdkMessage(msg, state) {
|
|
|
204
205
|
events.push({
|
|
205
206
|
kind: 'subagent:progress',
|
|
206
207
|
toolCallId,
|
|
208
|
+
...(taskId ? { taskId } : {}),
|
|
207
209
|
status: isDone ? 'done' : 'running',
|
|
208
210
|
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
|
209
211
|
taskType: typeof parsed.task_type === 'string' ? parsed.task_type : undefined,
|
|
@@ -2,7 +2,7 @@ const KOBO_MCP_BRIEF = [
|
|
|
2
2
|
'[Kōbō MCP] This workspace exposes a dedicated MCP server with tools prefixed `kobo__`.',
|
|
3
3
|
'Non-interactive mode: this session runs via the Claude Agent SDK in single-shot mode. To ask the user a question, call the `AskUserQuestion` tool — Kōbō will pause the session, collect the answer asynchronously, and resume.',
|
|
4
4
|
'⚠️ HARD RULE — emit AT MOST ONE `AskUserQuestion` tool call per turn, and emit it ALONE (no other tool calls in the same response). The Claude Agent SDK rejects parallel defers: a second `AskUserQuestion` in the same turn breaks the pause mechanism, makes both calls fail with "AskUserQuestion fails", and the user sees the panel arrive only at the end of the turn (if at all). If you have N questions, group them as a multi-question array inside ONE call: `AskUserQuestion({ questions: [{ question: "Q1", options: [...] }, { question: "Q2", options: [...] }] })`. After invoking it, STOP — do not emit any other tool call. Wait for the resume.',
|
|
5
|
-
'Plan mode: when running with `permissionMode: plan`, the read-only restriction applies to MCP tools too. For Kōbō: `kobo__list_*`, `kobo__read_document`, `kobo__search_codebase`, `kobo__get_*` are fine; `kobo__mark_task_done`, `kobo__log_thought`, `kobo__set_workspace_status` are mutations and must wait until the plan is approved.',
|
|
5
|
+
'Plan mode: when running with `permissionMode: plan`, the read-only restriction applies to MCP tools too. For Kōbō: `kobo__list_*`, `kobo__read_document`, `kobo__search_codebase`, `kobo__get_*` are fine; `kobo__mark_task_done`, `kobo__log_thought`, `kobo__set_workspace_status`, `kobo__set_auto_loop` are mutations and must wait until the plan is approved.',
|
|
6
6
|
'Conventions — read these BEFORE starting work, not as a fallback:',
|
|
7
7
|
'• `kobo__list_tasks` first on any non-trivial turn, then `kobo__mark_task_done` as each item completes.',
|
|
8
8
|
'• `kobo__list_documents` / `kobo__read_document` to discover existing plans and specs under docs/ and .ai/thoughts/ before writing new ones.',
|
|
@@ -10,6 +10,7 @@ const KOBO_MCP_BRIEF = [
|
|
|
10
10
|
'• `kobo__search_codebase` to recall prior chat history (conversations, not source — use Grep for source).',
|
|
11
11
|
'• `kobo__read_workspace_events_csv` to read paginated user/agent history from this workspace across sessions when prior context is useful. Use `limit` + `offset` (and optional `session_id`) instead of loading everything.',
|
|
12
12
|
'• `kobo__get_workspace_info` / `kobo__get_git_info` / `kobo__get_ticket` for context.',
|
|
13
|
+
'• If `kobo__get_workspace_info` reports `autoLoop: false`, call `kobo__set_auto_loop({ enabled: true })` only when the user explicitly asks for autonomous continuation; never disable it unless explicitly asked.',
|
|
13
14
|
'• `kobo__set_workspace_status` when the mission is done or irrecoverably blocked. Never set `idle` while this session is active; use AskUserQuestion for clarification, or end the turn.',
|
|
14
15
|
'• `kobo__schedule_wakeup` / `kobo__cancel_wakeup` to schedule (or cancel) a follow-up session — prefer these over the built-in `ScheduleWakeup` tool.',
|
|
15
16
|
'Each tool carries its own "WHEN to use" guidance in its description — follow it.',
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
const CODEX_KOBO_MCP_BRIEF = [
|
|
2
2
|
'[Kōbō MCP] This workspace exposes a dedicated MCP server named `kobo-tasks`. The Codex CLI surfaces its tools under the literal name `mcp__kobo-tasks__<tool>` — always use that full form when invoking them.',
|
|
3
|
-
'Plan mode: when running with a read-only sandbox, the read-only restriction applies to MCP tools too. Reads (`mcp__kobo-tasks__list_*`, `mcp__kobo-tasks__read_document`, `mcp__kobo-tasks__search_codebase`, `mcp__kobo-tasks__get_*`) are fine; mutations (`mcp__kobo-tasks__mark_task_done`, `mcp__kobo-tasks__log_thought`, `mcp__kobo-tasks__set_workspace_status`) must wait until the plan is approved.',
|
|
3
|
+
'Plan mode: when running with a read-only sandbox, the read-only restriction applies to MCP tools too. Reads (`mcp__kobo-tasks__list_*`, `mcp__kobo-tasks__read_document`, `mcp__kobo-tasks__search_codebase`, `mcp__kobo-tasks__get_*`) are fine; mutations (`mcp__kobo-tasks__mark_task_done`, `mcp__kobo-tasks__log_thought`, `mcp__kobo-tasks__set_workspace_status`, `mcp__kobo-tasks__set_auto_loop`) must wait until the plan is approved.',
|
|
4
4
|
'Conventions — read these BEFORE starting work, not as a fallback:',
|
|
5
5
|
'• `mcp__kobo-tasks__list_tasks` first on any non-trivial turn, then `mcp__kobo-tasks__mark_task_done` as each item completes.',
|
|
6
6
|
'• `mcp__kobo-tasks__list_documents` / `mcp__kobo-tasks__read_document` to discover existing plans and specs under docs/ and .ai/thoughts/ before writing new ones.',
|
|
7
7
|
'• `mcp__kobo-tasks__log_thought` to persist notable decisions to `.ai/thoughts/logs/<date>-<slug>.md`.',
|
|
8
8
|
'• `mcp__kobo-tasks__search_codebase` to recall prior chat history (conversations, not source — use shell tools for source).',
|
|
9
9
|
'• `mcp__kobo-tasks__get_workspace_info` / `mcp__kobo-tasks__get_git_info` / `mcp__kobo-tasks__get_ticket` for context.',
|
|
10
|
+
'• If `mcp__kobo-tasks__get_workspace_info` reports `autoLoop: false`, call `mcp__kobo-tasks__set_auto_loop({ enabled: true })` only when the user explicitly asks for autonomous continuation; never disable it unless explicitly asked.',
|
|
10
11
|
'• `mcp__kobo-tasks__set_workspace_status` when the mission is done or irrecoverably blocked. Never set `idle` while this session is active; ask the user or end the turn instead.',
|
|
11
12
|
'• `mcp__kobo-tasks__schedule_wakeup` / `mcp__kobo-tasks__cancel_wakeup` to schedule (or cancel) a follow-up session.',
|
|
12
13
|
'Each tool carries its own "WHEN to use" guidance in its description — follow it.',
|
|
@@ -488,21 +488,77 @@ function reuseOrCreateFreshSession(workspaceId, existingSessionId, model, engine
|
|
|
488
488
|
return agentSessionId;
|
|
489
489
|
}
|
|
490
490
|
const taskProgressSnapshots = new Map();
|
|
491
|
-
function
|
|
492
|
-
|
|
493
|
-
workspaceSnapshots.set(agentSessionId, {
|
|
491
|
+
function createTaskProgressSnapshot(workspaceId) {
|
|
492
|
+
return {
|
|
494
493
|
doneCount: getDoneTaskCount(workspaceId),
|
|
495
494
|
stateSignature: getTaskStateSignature(workspaceId),
|
|
496
|
-
}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function rememberTaskProgressSnapshot(workspaceId, agentSessionId, snapshot = createTaskProgressSnapshot(workspaceId)) {
|
|
498
|
+
const workspaceSnapshots = taskProgressSnapshots.get(workspaceId) ?? new Map();
|
|
499
|
+
workspaceSnapshots.set(agentSessionId, snapshot);
|
|
497
500
|
taskProgressSnapshots.set(workspaceId, workspaceSnapshots);
|
|
498
501
|
}
|
|
502
|
+
function parseTaskProgressSnapshot(value) {
|
|
503
|
+
if (!value)
|
|
504
|
+
return undefined;
|
|
505
|
+
try {
|
|
506
|
+
const parsed = JSON.parse(value);
|
|
507
|
+
if (typeof parsed.doneCount !== 'number' || typeof parsed.stateSignature !== 'string')
|
|
508
|
+
return undefined;
|
|
509
|
+
return { doneCount: parsed.doneCount, stateSignature: parsed.stateSignature };
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function captureTaskProgressBaseline(workspaceId, agentSessionId) {
|
|
516
|
+
const snapshot = createTaskProgressSnapshot(workspaceId);
|
|
517
|
+
rememberTaskProgressSnapshot(workspaceId, agentSessionId, snapshot);
|
|
518
|
+
try {
|
|
519
|
+
getDb()
|
|
520
|
+
.prepare('UPDATE agent_sessions SET task_progress_baseline = ? WHERE id = ?')
|
|
521
|
+
.run(JSON.stringify(snapshot), agentSessionId);
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
console.warn('[orchestrator] Failed to persist task progress baseline:', err);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function ensureTaskProgressBaseline(workspaceId, agentSessionId) {
|
|
528
|
+
if (taskProgressSnapshots.get(workspaceId)?.has(agentSessionId))
|
|
529
|
+
return;
|
|
530
|
+
try {
|
|
531
|
+
const row = getDb().prepare('SELECT task_progress_baseline FROM agent_sessions WHERE id = ?').get(agentSessionId);
|
|
532
|
+
if (row?.task_progress_baseline)
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
// The in-memory fallback below is still useful in narrow unit-test and
|
|
537
|
+
// bootstrap windows where the persisted session row is unavailable.
|
|
538
|
+
}
|
|
539
|
+
captureTaskProgressBaseline(workspaceId, agentSessionId);
|
|
540
|
+
}
|
|
499
541
|
function consumeTaskProgressSnapshot(workspaceId, agentSessionId) {
|
|
500
542
|
const workspaceSnapshots = taskProgressSnapshots.get(workspaceId);
|
|
501
|
-
const
|
|
543
|
+
const memorySnapshot = workspaceSnapshots?.get(agentSessionId);
|
|
502
544
|
workspaceSnapshots?.delete(agentSessionId);
|
|
503
545
|
if (workspaceSnapshots?.size === 0)
|
|
504
546
|
taskProgressSnapshots.delete(workspaceId);
|
|
505
|
-
|
|
547
|
+
try {
|
|
548
|
+
const db = getDb();
|
|
549
|
+
const readAndClear = db.transaction((id) => {
|
|
550
|
+
const row = db.prepare('SELECT task_progress_baseline FROM agent_sessions WHERE id = ?').get(id);
|
|
551
|
+
db.prepare('UPDATE agent_sessions SET task_progress_baseline = NULL WHERE id = ?').run(id);
|
|
552
|
+
return row?.task_progress_baseline ?? null;
|
|
553
|
+
});
|
|
554
|
+
const persistedSnapshot = parseTaskProgressSnapshot(readAndClear(agentSessionId));
|
|
555
|
+
if (persistedSnapshot)
|
|
556
|
+
return persistedSnapshot;
|
|
557
|
+
}
|
|
558
|
+
catch (err) {
|
|
559
|
+
console.warn('[orchestrator] Failed to consume task progress baseline:', err);
|
|
560
|
+
}
|
|
561
|
+
return memorySnapshot;
|
|
506
562
|
}
|
|
507
563
|
function rememberResumeFailed(workspaceId, agentSessionId) {
|
|
508
564
|
const workspaceSessions = resumeFailedSessions.get(workspaceId) ?? new Set();
|
|
@@ -588,10 +644,11 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
588
644
|
if (ev.kind === 'rate_limit') {
|
|
589
645
|
latestRateLimitInfo.set(workspaceId, ev.info);
|
|
590
646
|
}
|
|
591
|
-
//
|
|
592
|
-
//
|
|
647
|
+
// The normal start path captures this synchronously before engine.start.
|
|
648
|
+
// Keep an idempotent fallback for engines/tests that emit session:started
|
|
649
|
+
// without first going through startAgent.
|
|
593
650
|
if (ev.kind === 'session:started') {
|
|
594
|
-
|
|
651
|
+
ensureTaskProgressBaseline(workspaceId, agentSessionId);
|
|
595
652
|
}
|
|
596
653
|
// Legacy fallback: the built-in `ScheduleWakeup` tool (CLI tradition) isn't
|
|
597
654
|
// declared by the SDK, so we intercept the tool:call event and apply the
|
|
@@ -700,12 +757,23 @@ function handleEvent(workspaceId, agentSessionId, sourceController, ev) {
|
|
|
700
757
|
// its persisted prompts too, otherwise a reconnect resurrects a panel
|
|
701
758
|
// whose backend callback has already been discarded.
|
|
702
759
|
purgeAllPersistedUserInputRequests(workspaceId, agentSessionId);
|
|
760
|
+
// A watchdog end means Kōbō forced the stream closed after the engine
|
|
761
|
+
// failed to drain. It is neither a clean completion nor evidence of an
|
|
762
|
+
// agent stall. Move auto-loop work onto the existing persisted, bounded
|
|
763
|
+
// transient-retry path before lifecycle cleanup observes the status.
|
|
764
|
+
const watchdogRecovery = ev.reason === 'watchdog' && getWs(workspaceId)?.autoLoop === true;
|
|
765
|
+
if (watchdogRecovery) {
|
|
766
|
+
console.warn(`[auto-loop] watchdog recovery scheduled for workspace '${workspaceId}'`);
|
|
767
|
+
void handleTransientAutoLoopFailure(workspaceId);
|
|
768
|
+
}
|
|
703
769
|
// Must run BEFORE autoLoopService.onSessionEnded → spawnNextIteration →
|
|
704
770
|
// startAgent, otherwise startAgent throws "Agent already running" because
|
|
705
771
|
// the just-ended controller is still in the map.
|
|
706
772
|
const ownsWorkspaceLifecycle = onSessionEnded(workspaceId, agentSessionId, sourceController, ev.exitCode, ev.reason, isResumeFailed);
|
|
707
773
|
if (!ownsWorkspaceLifecycle)
|
|
708
774
|
return;
|
|
775
|
+
if (watchdogRecovery)
|
|
776
|
+
return;
|
|
709
777
|
// resume_failed exits with an error but the workspace is fine (stale id
|
|
710
778
|
// cleared, next iteration will start fresh) — report 'completed' to
|
|
711
779
|
// auto-loop so it continues.
|
|
@@ -905,6 +973,9 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
|
|
|
905
973
|
else {
|
|
906
974
|
agentSessionId = reuseOrCreateFreshSession(workspaceId, existingSessionId, model, engineId);
|
|
907
975
|
}
|
|
976
|
+
// Persist before the asynchronous engine can emit its first event. This
|
|
977
|
+
// survives a Kōbō restart and does not depend on SDK system:init delivery.
|
|
978
|
+
captureTaskProgressBaseline(workspaceId, agentSessionId);
|
|
908
979
|
const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
|
|
909
980
|
const options = {
|
|
910
981
|
workspaceId,
|
|
@@ -987,12 +1058,7 @@ export function interruptAgent(workspaceId, options = {}) {
|
|
|
987
1058
|
autoLoopService.disable(workspaceId, 'user-action');
|
|
988
1059
|
}
|
|
989
1060
|
}
|
|
990
|
-
|
|
991
|
-
export function stopAgent(workspaceId) {
|
|
992
|
-
const ctrl = controllers.get(workspaceId);
|
|
993
|
-
if (!ctrl) {
|
|
994
|
-
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
995
|
-
}
|
|
1061
|
+
async function stopController(workspaceId, ctrl) {
|
|
996
1062
|
wakeupService.cancel(workspaceId, 'stopped');
|
|
997
1063
|
// If the session was waiting on a question/permission, normalize the state
|
|
998
1064
|
// synchronously so callers (archive, delete, manual stop) see a clean
|
|
@@ -1020,11 +1086,42 @@ export function stopAgent(workspaceId) {
|
|
|
1020
1086
|
controllers.delete(workspaceId);
|
|
1021
1087
|
// Manual stop should also drop any pending quota auto-resume.
|
|
1022
1088
|
quotaBackoffService.cancel(workspaceId, 'user');
|
|
1023
|
-
|
|
1024
|
-
|
|
1089
|
+
await ctrl.stop();
|
|
1090
|
+
}
|
|
1091
|
+
/** Gracefully stop an agent (the engine handles SIGTERM + SIGKILL). */
|
|
1092
|
+
export function stopAgent(workspaceId) {
|
|
1093
|
+
const ctrl = controllers.get(workspaceId);
|
|
1094
|
+
if (!ctrl) {
|
|
1095
|
+
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
1096
|
+
}
|
|
1097
|
+
// Fire-and-forget: callers such as archive/delete must remain synchronous.
|
|
1098
|
+
void stopController(workspaceId, ctrl).catch((err) => {
|
|
1025
1099
|
console.error('[orchestrator] controller.stop failed:', err);
|
|
1026
1100
|
});
|
|
1027
1101
|
}
|
|
1102
|
+
/** Stop every live engine before process shutdown, with a bounded wait per engine. */
|
|
1103
|
+
export async function stopAllAgents(timeoutMs = 3_000) {
|
|
1104
|
+
const stops = [...controllers.entries()].map(async ([workspaceId, ctrl]) => {
|
|
1105
|
+
let timeout;
|
|
1106
|
+
try {
|
|
1107
|
+
await Promise.race([
|
|
1108
|
+
stopController(workspaceId, ctrl),
|
|
1109
|
+
new Promise((resolve) => {
|
|
1110
|
+
timeout = setTimeout(resolve, timeoutMs);
|
|
1111
|
+
timeout.unref?.();
|
|
1112
|
+
}),
|
|
1113
|
+
]);
|
|
1114
|
+
}
|
|
1115
|
+
catch (err) {
|
|
1116
|
+
console.error(`[orchestrator] Failed to stop '${workspaceId}' during shutdown:`, err);
|
|
1117
|
+
}
|
|
1118
|
+
finally {
|
|
1119
|
+
if (timeout)
|
|
1120
|
+
clearTimeout(timeout);
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
await Promise.all(stops);
|
|
1124
|
+
}
|
|
1028
1125
|
/** Write a user message to the running agent. */
|
|
1029
1126
|
export async function sendMessage(workspaceId, content, expectedSessionId) {
|
|
1030
1127
|
const ctrl = controllers.get(workspaceId);
|
|
@@ -123,6 +123,12 @@ export function onSessionEnded(workspaceId, reason, taskProgressDelta) {
|
|
|
123
123
|
// will resume the deferred turn explicitly.
|
|
124
124
|
if (row.status === 'awaiting-user')
|
|
125
125
|
return;
|
|
126
|
+
// A watchdog denotes Kōbō's own forced recovery from a stuck engine stream.
|
|
127
|
+
// The orchestrator owns its bounded backoff/retry path; counting it as a
|
|
128
|
+
// normal no-progress iteration would falsely disable an otherwise healthy
|
|
129
|
+
// loop after three SDK teardown timeouts.
|
|
130
|
+
if (reason === 'watchdog')
|
|
131
|
+
return;
|
|
126
132
|
if (reason === 'error') {
|
|
127
133
|
disable(workspaceId, 'error');
|
|
128
134
|
return;
|