@loicngr/kobo 1.6.7 → 1.6.9
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 +4 -1
- package/README.md +3 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +19 -1
- package/dist/mcp-server/kobo-tasks-server.js +27 -1
- package/dist/server/db/migrations.js +11 -0
- package/dist/server/db/schema.js +3 -0
- package/dist/server/index.js +2 -0
- package/dist/server/routes/workspaces.js +168 -18
- package/dist/server/services/agent/engines/claude-code/engine.js +5 -0
- package/dist/server/services/agent/engines/claude-code/stream-parser.js +162 -0
- package/dist/server/services/agent/orchestrator.js +167 -18
- package/dist/server/services/auto-loop-service.js +311 -0
- package/dist/server/services/pr-watcher-service.js +14 -0
- package/dist/server/services/workspace-service.js +22 -0
- package/dist/shared/auto-loop-prompts.js +28 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-ChWogUP-.js +7 -0
- package/src/client/dist/spa/assets/{ActivityFeed-ZLFD0ABF.css → ActivityFeed-D3Y4qOBg.css} +1 -1
- package/src/client/dist/spa/assets/CreatePage-Bk5v8_20.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-Cr7gCb6F.js +2 -0
- package/src/client/dist/spa/assets/{DiffViewer-DQvXQTEa.js → DiffViewer-DIwYNrvc.js} +2 -2
- package/src/client/dist/spa/assets/{HealthPage-Bme28RgK.js → HealthPage-BsiMW46f.js} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-BAimmdFy.css → MainLayout-DKDosaB2.css} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-DbElmr1D.js → MainLayout-dWdvXPUq.js} +12 -12
- package/src/client/dist/spa/assets/{SearchPage-C-WJxRPe.js → SearchPage-Cb5p2C1s.js} +1 -1
- package/src/client/dist/spa/assets/{SettingsPage-BfJMIJnY.js → SettingsPage-n5CoKCHp.js} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-CM676R3B.css → WorkspacePage-CI1BxN04.css} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-D0I1dB_Y.js +4 -0
- package/src/client/dist/spa/assets/{build-path-tree-DuCsR4mF.js → build-path-tree-Cx4Gbg4-.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-Du10lHBq.js → cssMode-C_KSkvTO.js} +1 -1
- package/src/client/dist/spa/assets/{documents-AXuTuYab.js → documents-CotyNumY.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-rtN9xKX_.js → editor.api-C37o4gcc.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-Bjkd_T6v.js → editor.main-B1LanICm.js} +3 -3
- package/src/client/dist/spa/assets/{freemarker2-A3kMxtRq.js → freemarker2-DElE6rHa.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-D_rpe5yL.js → handlebars-DgFLhirU.js} +1 -1
- package/src/client/dist/spa/assets/{html-DXwQospq.js → html-Co1lVBCW.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-D5FQHZJa.js → htmlMode-Bou9uwBJ.js} +1 -1
- package/src/client/dist/spa/assets/i18n-BY0mxocP.js +1 -0
- package/src/client/dist/spa/assets/index-CbTmiNhf.js +2 -0
- package/src/client/dist/spa/assets/{javascript-Co6I4XSh.js → javascript-BzHMqYPo.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-Bp7yDKPn.js → jsonMode-DQriwWfG.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-DNcFzYOe.js → liquid-DfnWCF9s.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-DBavZVIR.js → mdx-C3N0ZOTO.js} +1 -1
- package/src/client/dist/spa/assets/{models-CBrNFRtB.js → models-CuoIuROK.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-DUmoyOMN.js → monaco.contribution-BWEoU0OQ.js} +2 -2
- package/src/client/dist/spa/assets/{python-DmL-S2ZK.js → python-XRtT3KuX.js} +1 -1
- package/src/client/dist/spa/assets/rate-limit-labels-EtqMmGAk.js +10 -0
- package/src/client/dist/spa/assets/{razor-Bpa1oEQM.js → razor-K5_2jeu8.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-LSHCraGE.js → tsMode-T4aykrTz.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-2KSBXoSD.js → typescript-CU2l4an1.js} +1 -1
- package/src/client/dist/spa/assets/{xml-CCjUFDf8.js → xml-BXeGSs28.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-w5memWnS.js → yaml-DkchexIG.js} +1 -1
- package/src/client/dist/spa/index.html +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +24 -1
- package/src/mcp-server/kobo-tasks-server.ts +29 -0
- package/src/client/dist/spa/assets/ActivityFeed-yFyT9xvb.js +0 -7
- package/src/client/dist/spa/assets/CreatePage-CYtKx6Ji.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-DRaf5RN1.js +0 -2
- package/src/client/dist/spa/assets/WorkspacePage-CjPLcpSW.js +0 -4
- package/src/client/dist/spa/assets/i18n-ByXz1S2J.js +0 -1
- package/src/client/dist/spa/assets/index-C-IVb0lG.js +0 -2
- package/src/client/dist/spa/assets/rate-limit-labels-Su-L56A2.js +0 -6
|
@@ -3,6 +3,7 @@ import { nanoid } from 'nanoid';
|
|
|
3
3
|
import { getDb } from '../../db/index.js';
|
|
4
4
|
import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
|
|
5
5
|
import { unregisterProcess } from '../../utils/process-tracker.js';
|
|
6
|
+
import * as autoLoopService from '../auto-loop-service.js';
|
|
6
7
|
import { getEffectiveSettings } from '../settings-service.js';
|
|
7
8
|
import * as wakeupService from '../wakeup-service.js';
|
|
8
9
|
import { emitEphemeral } from '../websocket-service.js';
|
|
@@ -33,6 +34,8 @@ let availableSkills = (() => {
|
|
|
33
34
|
})();
|
|
34
35
|
/** workspaceId -> retry count (for quota backoff) */
|
|
35
36
|
const retryCounts = new Map();
|
|
37
|
+
/** Tracks workspaces where the current session failed due to a stale --resume session ID. */
|
|
38
|
+
const resumeFailedSet = new Set();
|
|
36
39
|
/** workspaceId -> backoff timer */
|
|
37
40
|
const backoffTimers = new Map();
|
|
38
41
|
// ── Watchdog ──────────────────────────────────────────────────────────────────
|
|
@@ -251,8 +254,33 @@ function reuseOrCreateFreshSession(workspaceId, existingSessionId) {
|
|
|
251
254
|
return agentSessionId;
|
|
252
255
|
}
|
|
253
256
|
// ── Event handler ─────────────────────────────────────────────────────────────
|
|
257
|
+
/**
|
|
258
|
+
* Snapshot of the `tasks` done-count at `session:started`, read back and
|
|
259
|
+
* cleared at `session:ended` to compute the per-session delta. Used by
|
|
260
|
+
* `auto-loop-service.onSessionEnded` for stall detection.
|
|
261
|
+
*/
|
|
262
|
+
const tasksDoneSnapshot = new Map();
|
|
263
|
+
function getDoneTaskCount(workspaceId) {
|
|
264
|
+
const db = getDb();
|
|
265
|
+
const row = db
|
|
266
|
+
.prepare('SELECT COUNT(*) AS c FROM tasks WHERE workspace_id = ? AND status = ?')
|
|
267
|
+
.get(workspaceId, 'done');
|
|
268
|
+
return row.c;
|
|
269
|
+
}
|
|
270
|
+
/** Clear the in-memory done-count snapshot for a workspace (called on delete). */
|
|
271
|
+
export function forgetTasksDoneSnapshot(workspaceId) {
|
|
272
|
+
tasksDoneSnapshot.delete(workspaceId);
|
|
273
|
+
}
|
|
254
274
|
function handleEvent(workspaceId, agentSessionId, ev) {
|
|
255
275
|
routeEvent(workspaceId, agentSessionId, ev);
|
|
276
|
+
if (ev.kind === 'rate_limit') {
|
|
277
|
+
latestRateLimitInfo.set(workspaceId, ev.info);
|
|
278
|
+
}
|
|
279
|
+
// Snapshot the done-count at session start so the session:ended hook below
|
|
280
|
+
// can compute a delta for auto-loop stall detection.
|
|
281
|
+
if (ev.kind === 'session:started') {
|
|
282
|
+
tasksDoneSnapshot.set(workspaceId, getDoneTaskCount(workspaceId));
|
|
283
|
+
}
|
|
256
284
|
if (ev.kind === 'tool:call' && ev.name === 'ScheduleWakeup') {
|
|
257
285
|
const input = ev.input;
|
|
258
286
|
const delay = typeof input?.delaySeconds === 'number' ? input.delaySeconds : 0;
|
|
@@ -274,7 +302,10 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
274
302
|
}
|
|
275
303
|
if (ev.kind === 'session:brainstorm-complete') {
|
|
276
304
|
try {
|
|
277
|
-
|
|
305
|
+
const ws = getWs(workspaceId);
|
|
306
|
+
if (ws && ws.status !== 'executing') {
|
|
307
|
+
updateWorkspaceStatus(workspaceId, 'executing');
|
|
308
|
+
}
|
|
278
309
|
}
|
|
279
310
|
catch (err) {
|
|
280
311
|
console.error('[orchestrator] Failed to transition to executing:', err);
|
|
@@ -283,8 +314,31 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
283
314
|
if (ev.kind === 'error' && ev.category === 'quota') {
|
|
284
315
|
handleQuota(workspaceId, agentSessionId);
|
|
285
316
|
}
|
|
317
|
+
if (ev.kind === 'error' && ev.category === 'resume_failed') {
|
|
318
|
+
resumeFailedSet.add(workspaceId);
|
|
319
|
+
clearStaleEngineSessionId(workspaceId);
|
|
320
|
+
}
|
|
286
321
|
if (ev.kind === 'session:ended') {
|
|
287
|
-
onSessionEnded
|
|
322
|
+
// Pop the resume_failed flag before any cleanup so both onSessionEnded paths see it.
|
|
323
|
+
const isResumeFailed = resumeFailedSet.delete(workspaceId);
|
|
324
|
+
// Compute the auto-loop done-delta BEFORE the internal cleanup because
|
|
325
|
+
// onSessionEnded(internal) may throw / trigger follow-ups; also read the
|
|
326
|
+
// snapshot FIRST so a later re-entry can't overwrite it.
|
|
327
|
+
const before = tasksDoneSnapshot.get(workspaceId) ?? getDoneTaskCount(workspaceId);
|
|
328
|
+
const after = getDoneTaskCount(workspaceId);
|
|
329
|
+
const delta = Math.max(0, after - before);
|
|
330
|
+
tasksDoneSnapshot.delete(workspaceId);
|
|
331
|
+
// Internal cleanup REMOVES the controller from the map. This must run
|
|
332
|
+
// BEFORE autoLoopService.onSessionEnded → spawnNextIteration → startAgent,
|
|
333
|
+
// otherwise startAgent throws "Agent already running" because the
|
|
334
|
+
// just-ended controller is still in the map.
|
|
335
|
+
onSessionEnded(workspaceId, agentSessionId, ev.exitCode, isResumeFailed);
|
|
336
|
+
// When a resume failed the session exited with an error but there's
|
|
337
|
+
// nothing wrong with the workspace — the stale session ID has been cleared
|
|
338
|
+
// and the next iteration will start fresh. Treat it as 'completed' so
|
|
339
|
+
// auto-loop continues without disabling.
|
|
340
|
+
const effectiveReason = isResumeFailed ? 'completed' : ev.reason;
|
|
341
|
+
autoLoopService.onSessionEnded(workspaceId, effectiveReason, delta);
|
|
288
342
|
}
|
|
289
343
|
if (ev.kind === 'session:started' && ev.engineSessionId) {
|
|
290
344
|
sessionIds.set(workspaceId, ev.engineSessionId);
|
|
@@ -312,7 +366,9 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
312
366
|
}
|
|
313
367
|
}
|
|
314
368
|
}
|
|
315
|
-
function onSessionEnded(workspaceId, agentSessionId, exitCode) {
|
|
369
|
+
function onSessionEnded(workspaceId, agentSessionId, exitCode, resumeFailed = false) {
|
|
370
|
+
const currentWorkspace = getWs(workspaceId);
|
|
371
|
+
const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
|
|
316
372
|
const ctrl = controllers.get(workspaceId);
|
|
317
373
|
const wasStopping = ctrl?.status === 'stopping';
|
|
318
374
|
// Identity-preserving cleanup: only remove the controller if the map still
|
|
@@ -322,7 +378,9 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode) {
|
|
|
322
378
|
controllers.delete(workspaceId);
|
|
323
379
|
}
|
|
324
380
|
unregisterProcess(workspaceId);
|
|
325
|
-
|
|
381
|
+
if (!preserveQuotaBackoff) {
|
|
382
|
+
retryCounts.delete(workspaceId);
|
|
383
|
+
}
|
|
326
384
|
// Update the agent_sessions row
|
|
327
385
|
try {
|
|
328
386
|
const db = getDb();
|
|
@@ -336,13 +394,27 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode) {
|
|
|
336
394
|
// the "stopped" status. No legacy emit needed.
|
|
337
395
|
return;
|
|
338
396
|
}
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
backoffTimers.
|
|
397
|
+
// When the session hit quota, handleQuota() already transitioned the
|
|
398
|
+
// workspace to `quota` and armed the retry timer. Keep that timer alive
|
|
399
|
+
// and preserve the `quota` status so auto-loop can resume after reset.
|
|
400
|
+
if (!preserveQuotaBackoff) {
|
|
401
|
+
const pendingBackoff = backoffTimers.get(workspaceId);
|
|
402
|
+
if (pendingBackoff) {
|
|
403
|
+
clearTimeout(pendingBackoff);
|
|
404
|
+
backoffTimers.delete(workspaceId);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (preserveQuotaBackoff) {
|
|
408
|
+
try {
|
|
409
|
+
markWorkspaceUnread(workspaceId);
|
|
410
|
+
emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// best-effort
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
344
416
|
}
|
|
345
|
-
if (exitCode !== null && exitCode !== 0) {
|
|
417
|
+
if (exitCode !== null && exitCode !== 0 && !resumeFailed) {
|
|
346
418
|
try {
|
|
347
419
|
updateWorkspaceStatus(workspaceId, 'error');
|
|
348
420
|
}
|
|
@@ -517,12 +589,81 @@ export function getRunningCount() {
|
|
|
517
589
|
return controllers.size;
|
|
518
590
|
}
|
|
519
591
|
/** Kobo built-in slash commands injected into the skill list (without leading /). */
|
|
520
|
-
const KOBO_COMMANDS = ['kobo-check-progress'];
|
|
592
|
+
const KOBO_COMMANDS = ['kobo-check-progress', 'kobo-prep-autoloop'];
|
|
521
593
|
/** Cached list of slash commands discovered from the last agent init, plus Kobo built-ins. */
|
|
522
594
|
export function getAvailableSkills() {
|
|
523
595
|
return [...KOBO_COMMANDS, ...availableSkills];
|
|
524
596
|
}
|
|
525
597
|
// ── Quota handling ────────────────────────────────────────────────────────────
|
|
598
|
+
/**
|
|
599
|
+
* Last `rate_limit.info` received per workspace. Used by handleQuota to
|
|
600
|
+
* schedule the backoff at the actual reset time instead of a hardcoded
|
|
601
|
+
* exponential. In-memory only — rebuilt on the next rate_limit event after
|
|
602
|
+
* a server restart.
|
|
603
|
+
*/
|
|
604
|
+
const latestRateLimitInfo = new Map();
|
|
605
|
+
/** Clear the rate-limit info cache for a workspace (called on deleteWorkspace). */
|
|
606
|
+
export function forgetRateLimitInfo(workspaceId) {
|
|
607
|
+
latestRateLimitInfo.delete(workspaceId);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Null out the engine_session_id on all agent_sessions rows for a workspace
|
|
611
|
+
* and clear the in-memory sessionIds cache. Called when a --resume attempt
|
|
612
|
+
* fails ("No conversation found with session ID") so that the next startAgent
|
|
613
|
+
* call starts a fresh conversation instead of retrying the stale ID.
|
|
614
|
+
*/
|
|
615
|
+
function clearStaleEngineSessionId(workspaceId) {
|
|
616
|
+
try {
|
|
617
|
+
const db = getDb();
|
|
618
|
+
db.prepare('UPDATE agent_sessions SET engine_session_id = NULL WHERE workspace_id = ?').run(workspaceId);
|
|
619
|
+
sessionIds.delete(workspaceId);
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
console.error('[orchestrator] Failed to clear stale engine session ID:', err);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const QUOTA_SAFETY_MARGIN_MS = 30_000;
|
|
626
|
+
const QUOTA_MAX_BACKOFF_MS = 24 * 60 * 60 * 1000;
|
|
627
|
+
const QUOTA_SATURATION_THRESHOLD_PCT = 95;
|
|
628
|
+
/**
|
|
629
|
+
* Fallback backoff ladder (in minutes) used when the `rate_limit` info
|
|
630
|
+
* isn't usable. Indexed by `retryCount`; anything past the last entry
|
|
631
|
+
* clamps to the final value (5 h) — long enough to cross a weekly
|
|
632
|
+
* bucket reset if the rate_limit info truly never arrives.
|
|
633
|
+
*/
|
|
634
|
+
const QUOTA_FALLBACK_LADDER_MINUTES = [15, 30, 60, 180, 300];
|
|
635
|
+
/**
|
|
636
|
+
* Compute the delay before retrying a workspace hit by quota.
|
|
637
|
+
*
|
|
638
|
+
* Prefers the `resetsAt` of the saturated bucket with the furthest-future
|
|
639
|
+
* reset (a tighter bucket will unlock by then anyway). Falls back to a
|
|
640
|
+
* fixed ladder (15 → 30 → 60 → 180 → 300 min) whenever the rate_limit
|
|
641
|
+
* info is missing, malformed, or implausible.
|
|
642
|
+
*/
|
|
643
|
+
export function computeQuotaBackoffMs(workspaceId, retryCount) {
|
|
644
|
+
const info = latestRateLimitInfo.get(workspaceId);
|
|
645
|
+
if (info?.buckets?.length) {
|
|
646
|
+
const candidates = info.buckets
|
|
647
|
+
.filter((b) => b.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT && typeof b.resetsAt === 'string')
|
|
648
|
+
.map((b) => ({ resetsAt: b.resetsAt, ts: new Date(b.resetsAt).getTime() }))
|
|
649
|
+
.filter((x) => !Number.isNaN(x.ts))
|
|
650
|
+
.sort((a, b) => b.ts - a.ts);
|
|
651
|
+
const chosen = candidates[0];
|
|
652
|
+
if (chosen) {
|
|
653
|
+
const delta = chosen.ts - Date.now() + QUOTA_SAFETY_MARGIN_MS;
|
|
654
|
+
if (delta > 0 && delta <= QUOTA_MAX_BACKOFF_MS) {
|
|
655
|
+
return { delayMs: delta, resetsAt: chosen.resetsAt, source: 'rate_limit_info' };
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const idx = Math.min(Math.max(0, retryCount), QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
|
|
660
|
+
const backoffMinutes = QUOTA_FALLBACK_LADDER_MINUTES[idx];
|
|
661
|
+
return { delayMs: backoffMinutes * 60 * 1000, source: 'exponential_fallback' };
|
|
662
|
+
}
|
|
663
|
+
/** @internal Test-only. */
|
|
664
|
+
export function _test_setRateLimitInfo(workspaceId, info) {
|
|
665
|
+
latestRateLimitInfo.set(workspaceId, info);
|
|
666
|
+
}
|
|
526
667
|
function handleQuota(workspaceId, _agentSessionId) {
|
|
527
668
|
try {
|
|
528
669
|
updateWorkspaceStatus(workspaceId, 'quota');
|
|
@@ -533,16 +674,19 @@ function handleQuota(workspaceId, _agentSessionId) {
|
|
|
533
674
|
// The quota state is already signalled by the `error { category: 'quota' }`
|
|
534
675
|
// AgentEvent that triggered this handler. No legacy `agent:status { quota }`
|
|
535
676
|
// emit needed.
|
|
536
|
-
//
|
|
677
|
+
// Prefer the actual resetsAt from the last rate_limit event; fall back to
|
|
678
|
+
// the 15/30/60min exponential schedule when that info isn't usable.
|
|
537
679
|
const retryCount = retryCounts.get(workspaceId) ?? 0;
|
|
538
|
-
const
|
|
539
|
-
const backoffMs =
|
|
680
|
+
const { delayMs, resetsAt, source } = computeQuotaBackoffMs(workspaceId, retryCount);
|
|
681
|
+
const backoffMs = delayMs;
|
|
540
682
|
retryCounts.set(workspaceId, retryCount + 1);
|
|
541
683
|
// Surface the backoff schedule as an ephemeral event so the UI can display
|
|
542
684
|
// retry count / wait time without polluting the persistent event log.
|
|
543
685
|
emitEphemeral(workspaceId, 'agent:quota-backoff', {
|
|
544
686
|
retryCount: retryCount + 1,
|
|
545
|
-
backoffMinutes,
|
|
687
|
+
backoffMinutes: Math.round(delayMs / 60_000),
|
|
688
|
+
resetsAt,
|
|
689
|
+
source,
|
|
546
690
|
});
|
|
547
691
|
const timer = setTimeout(() => {
|
|
548
692
|
backoffTimers.delete(workspaceId);
|
|
@@ -552,8 +696,13 @@ function handleQuota(workspaceId, _agentSessionId) {
|
|
|
552
696
|
return;
|
|
553
697
|
}
|
|
554
698
|
try {
|
|
555
|
-
|
|
556
|
-
|
|
699
|
+
if (freshWs.autoLoop) {
|
|
700
|
+
autoLoopService.onQuotaBackoffExpired(workspaceId);
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
|
|
704
|
+
startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
|
|
705
|
+
}
|
|
557
706
|
}
|
|
558
707
|
catch (err) {
|
|
559
708
|
console.error(`[orchestrator] Quota retry for workspace '${workspaceId}' failed:`, err);
|
|
@@ -597,4 +746,4 @@ export function _runWatchdogForTest() {
|
|
|
597
746
|
runWatchdog();
|
|
598
747
|
}
|
|
599
748
|
/** Test-only export. Not part of the public module API. */
|
|
600
|
-
export const __test__ = { handleEvent };
|
|
749
|
+
export const __test__ = { handleEvent, handleQuota };
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getDb } from '../db/index.js';
|
|
4
|
+
import * as orchestrator from './agent/orchestrator.js';
|
|
5
|
+
import { emit, emitEphemeral } from './websocket-service.js';
|
|
6
|
+
import { listTasks } from './workspace-service.js';
|
|
7
|
+
const NO_PROGRESS_STALL_THRESHOLD = 3;
|
|
8
|
+
function getRow(workspaceId) {
|
|
9
|
+
const db = getDb();
|
|
10
|
+
const row = db
|
|
11
|
+
.prepare(`SELECT id, project_path, working_branch, model, permission_mode, reasoning_effort,
|
|
12
|
+
status, auto_loop, auto_loop_ready, no_progress_streak, archived_at
|
|
13
|
+
FROM workspaces WHERE id = ?`)
|
|
14
|
+
.get(workspaceId);
|
|
15
|
+
return row ?? null;
|
|
16
|
+
}
|
|
17
|
+
function countPendingTasks(workspaceId) {
|
|
18
|
+
const db = getDb();
|
|
19
|
+
const row = db
|
|
20
|
+
.prepare('SELECT COUNT(*) AS c FROM tasks WHERE workspace_id = ? AND status != ?')
|
|
21
|
+
.get(workspaceId, 'done');
|
|
22
|
+
return row.c;
|
|
23
|
+
}
|
|
24
|
+
function countDoneTasks(workspaceId) {
|
|
25
|
+
const db = getDb();
|
|
26
|
+
const row = db
|
|
27
|
+
.prepare('SELECT COUNT(*) AS c FROM tasks WHERE workspace_id = ? AND status = ?')
|
|
28
|
+
.get(workspaceId, 'done');
|
|
29
|
+
return row.c;
|
|
30
|
+
}
|
|
31
|
+
export function getStatus(workspaceId) {
|
|
32
|
+
const row = getRow(workspaceId);
|
|
33
|
+
if (!row)
|
|
34
|
+
return { auto_loop: false, auto_loop_ready: false, no_progress_streak: 0 };
|
|
35
|
+
return {
|
|
36
|
+
auto_loop: row.auto_loop === 1,
|
|
37
|
+
auto_loop_ready: row.auto_loop_ready === 1,
|
|
38
|
+
no_progress_streak: row.no_progress_streak,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Enable auto-loop for the workspace. Spawns immediately if idle + pending
|
|
43
|
+
* tasks. If the initial spawn fails (e.g. worktree missing, engine misconfig),
|
|
44
|
+
* re-throws so the HTTP caller gets a 4xx instead of a silent 200 — the
|
|
45
|
+
* workspace will already have been auto-disabled by `spawnNextIteration`.
|
|
46
|
+
*/
|
|
47
|
+
export function enable(workspaceId) {
|
|
48
|
+
const row = getRow(workspaceId);
|
|
49
|
+
if (!row)
|
|
50
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
51
|
+
if (row.auto_loop_ready !== 1) {
|
|
52
|
+
throw new Error(`Workspace '${workspaceId}' is not ready for auto-loop (run grooming first)`);
|
|
53
|
+
}
|
|
54
|
+
const db = getDb();
|
|
55
|
+
db.prepare('UPDATE workspaces SET auto_loop = 1, no_progress_streak = 0 WHERE id = ?').run(workspaceId);
|
|
56
|
+
emitEphemeral(workspaceId, 'autoloop:enabled', {});
|
|
57
|
+
const pending = countPendingTasks(workspaceId);
|
|
58
|
+
if (pending === 0)
|
|
59
|
+
return;
|
|
60
|
+
if (orchestrator.hasController(workspaceId))
|
|
61
|
+
return;
|
|
62
|
+
// spawnNextIteration throws on initial spawn failure (see flag).
|
|
63
|
+
spawnNextIteration(workspaceId, { throwOnStartAgentError: true });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Disable auto-loop. Idempotent: if the flag is already 0, returns without
|
|
67
|
+
* emitting a duplicate `autoloop:disabled` event.
|
|
68
|
+
*/
|
|
69
|
+
export function disable(workspaceId, reason) {
|
|
70
|
+
const row = getRow(workspaceId);
|
|
71
|
+
if (!row || row.auto_loop !== 1)
|
|
72
|
+
return;
|
|
73
|
+
const db = getDb();
|
|
74
|
+
db.prepare('UPDATE workspaces SET auto_loop = 0 WHERE id = ?').run(workspaceId);
|
|
75
|
+
emitEphemeral(workspaceId, 'autoloop:disabled', { reason });
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Route a `session:ended` event into the auto-loop state machine.
|
|
79
|
+
*
|
|
80
|
+
* Called by orchestrator.handleEvent. The delta is the number of tasks that
|
|
81
|
+
* transitioned to `done` during this session (computed from a snapshot taken
|
|
82
|
+
* at `session:started`).
|
|
83
|
+
*
|
|
84
|
+
* When status is `quota` we skip spawning: the orchestrator's handleQuota
|
|
85
|
+
* already scheduled a backoff timer and will call `onQuotaBackoffExpired` once
|
|
86
|
+
* the window closes — that function owns the next spawn in that case.
|
|
87
|
+
*/
|
|
88
|
+
export function onSessionEnded(workspaceId, reason, tasksDoneDelta) {
|
|
89
|
+
const row = getRow(workspaceId);
|
|
90
|
+
if (!row)
|
|
91
|
+
return;
|
|
92
|
+
if (row.auto_loop !== 1)
|
|
93
|
+
return;
|
|
94
|
+
// When a quota backoff is in flight (orchestrator.handleQuota scheduled a
|
|
95
|
+
// timer), let that timer own the next spawn so the backoff delay is respected.
|
|
96
|
+
if (row.status === 'quota')
|
|
97
|
+
return;
|
|
98
|
+
if (reason === 'error' || reason === 'killed') {
|
|
99
|
+
disable(workspaceId, 'error');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// When grooming hasn't run yet (auto_loop_ready=false), the loop is "armed"
|
|
103
|
+
// but waiting for tasks to be created. Skip streak tracking and task checks —
|
|
104
|
+
// onAutoLoopReadySet() will trigger the first spawn once grooming completes.
|
|
105
|
+
if (row.auto_loop_ready !== 1)
|
|
106
|
+
return;
|
|
107
|
+
const db = getDb();
|
|
108
|
+
let streak;
|
|
109
|
+
if (tasksDoneDelta > 0) {
|
|
110
|
+
db.prepare('UPDATE workspaces SET no_progress_streak = 0 WHERE id = ?').run(workspaceId);
|
|
111
|
+
streak = 0;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
db.prepare('UPDATE workspaces SET no_progress_streak = no_progress_streak + 1 WHERE id = ?').run(workspaceId);
|
|
115
|
+
streak = row.no_progress_streak + 1;
|
|
116
|
+
}
|
|
117
|
+
if (streak >= NO_PROGRESS_STALL_THRESHOLD) {
|
|
118
|
+
disable(workspaceId, 'stall');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (countPendingTasks(workspaceId) === 0) {
|
|
122
|
+
disable(workspaceId, 'completed');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
spawnNextIteration(workspaceId);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Re-register auto-loop timers at server boot. For every non-archived workspace
|
|
129
|
+
* with `auto_loop=true` and no active controller, either spawn the next
|
|
130
|
+
* iteration (if pending tasks exist) or disable with reason=completed.
|
|
131
|
+
*/
|
|
132
|
+
export function rehydrate() {
|
|
133
|
+
try {
|
|
134
|
+
const db = getDb();
|
|
135
|
+
const rows = db.prepare('SELECT id FROM workspaces WHERE auto_loop = 1 AND archived_at IS NULL').all();
|
|
136
|
+
for (const { id } of rows) {
|
|
137
|
+
try {
|
|
138
|
+
if (orchestrator.hasController(id))
|
|
139
|
+
continue;
|
|
140
|
+
if (countPendingTasks(id) === 0) {
|
|
141
|
+
disable(id, 'completed');
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
spawnNextIteration(id);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
console.error(`[auto-loop-service] rehydrate failed for workspace ${id}:`, err);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.error('[auto-loop-service] rehydrate failed:', err);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Clear in-memory state on workspace delete. Placeholder — nothing cached today. */
|
|
156
|
+
export function forgetAutoLoopState(workspaceId) {
|
|
157
|
+
void workspaceId;
|
|
158
|
+
}
|
|
159
|
+
// ── Internal ──────────────────────────────────────────────────────────────────
|
|
160
|
+
const PROMPT_TEMPLATE = `[Kōbō auto-loop — iteration #{n}]
|
|
161
|
+
|
|
162
|
+
Current pending task (highest priority, non-acceptance-criterion first):
|
|
163
|
+
- Task ID: {taskId}
|
|
164
|
+
- Title: {taskTitle}
|
|
165
|
+
- Is acceptance criterion: {isAcceptanceCriterion}
|
|
166
|
+
|
|
167
|
+
Your job this iteration:
|
|
168
|
+
1. Read \`kobo__list_tasks\` to see all tasks and the big picture.
|
|
169
|
+
2. Implement the SINGLE task above and nothing else. Do not pick a different task.
|
|
170
|
+
3. Run the project's quality checks (lint, typecheck, tests). Check \`.ai/.git-conventions.md\` for the exact commands if unclear.
|
|
171
|
+
4. If checks fail, fix until they pass. If blocked, leave the task unchanged and explain in chat.
|
|
172
|
+
5. Commit with a conventional message (\`feat: [short description]\` or similar per repo conventions).
|
|
173
|
+
6. Code review gate — BEFORE marking the task done, dispatch an independent code-reviewer subagent via the Task tool with \`subagent_type: "code-reviewer"\` (or \`"superpowers:code-reviewer"\` / \`"pr-review-toolkit:code-reviewer"\` — use whichever exists in this environment; fall back to \`superpowers:requesting-code-review\` skill if none is available). Brief the reviewer with: what you just implemented, the task title, and the commit SHA (via \`git rev-parse HEAD\`). Ask specifically whether the change matches the task scope, whether edge cases are handled, and whether the commit is clean.
|
|
174
|
+
7. Act on the review:
|
|
175
|
+
- If Critical/Important issues: fix them, amend or add a fix-up commit, re-run checks from step 3. Do NOT mark_task_done.
|
|
176
|
+
- If only Minor issues: fix them if trivial (< 2 min), otherwise note them in the chat and proceed.
|
|
177
|
+
- If approved with no issues: proceed.
|
|
178
|
+
8. Only if the review cleared (or only minor notes remain), call \`kobo__mark_task_done(taskId="{taskId}")\`.
|
|
179
|
+
|
|
180
|
+
Do NOT modify other tasks' state. Do NOT create a PR. Do NOT skip the checks.
|
|
181
|
+
Do NOT run \`kill\`, \`pkill\`, \`killall\`, \`pgrep -k\`, or any process-killing command — you may tear down the Kōbō server itself or sibling dev servers. If a dev server needs restarting, let the user do it.
|
|
182
|
+
When you're done (success or blocked), end your turn cleanly.`;
|
|
183
|
+
function pickNextTask(workspaceId) {
|
|
184
|
+
const pending = listTasks(workspaceId).filter((t) => t.status !== 'done');
|
|
185
|
+
if (pending.length === 0)
|
|
186
|
+
return null;
|
|
187
|
+
// Rule D: non-acceptance first, each group in sort_order (listTasks orders).
|
|
188
|
+
const nonCriteria = pending.filter((t) => !t.isAcceptanceCriterion);
|
|
189
|
+
const criteria = pending.filter((t) => t.isAcceptanceCriterion);
|
|
190
|
+
return [...nonCriteria, ...criteria][0] ?? null;
|
|
191
|
+
}
|
|
192
|
+
function computeIterationNumber(workspaceId) {
|
|
193
|
+
const done = countDoneTasks(workspaceId);
|
|
194
|
+
const status = getStatus(workspaceId);
|
|
195
|
+
return done + status.no_progress_streak + 1;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Pick the next task, build the prompt, call `orchestrator.startAgent`.
|
|
199
|
+
*
|
|
200
|
+
* When called by `onSessionEnded` / `rehydrate`, `startAgent` throws are
|
|
201
|
+
* swallowed and the loop auto-disables (`reason: 'error'`). When called from
|
|
202
|
+
* `enable` (initial user-driven spawn), we want the HTTP endpoint to surface
|
|
203
|
+
* the failure instead of lying with 200, so the caller passes
|
|
204
|
+
* `throwOnStartAgentError: true` and we re-throw after disabling.
|
|
205
|
+
*
|
|
206
|
+
* Worktree-missing edge: if the worktree directory has been deleted on disk,
|
|
207
|
+
* `orchestrator.startAgent` throws during engine.start — caught below.
|
|
208
|
+
*/
|
|
209
|
+
function spawnNextIteration(workspaceId, opts = {}) {
|
|
210
|
+
const row = getRow(workspaceId);
|
|
211
|
+
if (!row)
|
|
212
|
+
return;
|
|
213
|
+
const task = pickNextTask(workspaceId);
|
|
214
|
+
if (!task) {
|
|
215
|
+
disable(workspaceId, 'completed');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const iterationNumber = computeIterationNumber(workspaceId);
|
|
219
|
+
const prompt = PROMPT_TEMPLATE.replaceAll('{n}', String(iterationNumber))
|
|
220
|
+
.replaceAll('{taskId}', task.id)
|
|
221
|
+
.replaceAll('{taskTitle}', task.title)
|
|
222
|
+
.replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion));
|
|
223
|
+
const worktreePath = path.join(row.project_path, '.worktrees', row.working_branch);
|
|
224
|
+
// Auto-loop iterations always run in auto-accept mode. Plan mode blocks MCP
|
|
225
|
+
// tools (kobo__mark_task_done, etc.) and Edit/Write/Bash — everything the
|
|
226
|
+
// iteration needs — so honoring a 'plan' setting here would deadlock the loop.
|
|
227
|
+
const permissionMode = 'auto-accept';
|
|
228
|
+
// Pre-check: if the worktree directory is gone (user `rm -rf`-ed it),
|
|
229
|
+
// fail loudly rather than letting startAgent throw a deep engine error.
|
|
230
|
+
if (!fs.existsSync(worktreePath)) {
|
|
231
|
+
const msg = `Worktree directory missing: ${worktreePath}`;
|
|
232
|
+
console.error('[auto-loop-service]', msg);
|
|
233
|
+
disable(workspaceId, 'error');
|
|
234
|
+
if (opts.throwOnStartAgentError)
|
|
235
|
+
throw new Error(msg);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
let agentSessionId;
|
|
239
|
+
try {
|
|
240
|
+
const agent = orchestrator.startAgent(workspaceId, worktreePath, prompt, row.model, false, // resume=false — fresh context for each iteration
|
|
241
|
+
permissionMode, undefined, row.reasoning_effort);
|
|
242
|
+
agentSessionId = agent.agentSessionId;
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
console.error('[auto-loop-service] startAgent failed:', err);
|
|
246
|
+
disable(workspaceId, 'error');
|
|
247
|
+
if (opts.throwOnStartAgentError)
|
|
248
|
+
throw err;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// Persist the iteration prompt as a system-prompt message so the user can
|
|
252
|
+
// see exactly what was sent to Claude when they switch to this auto-loop
|
|
253
|
+
// session in the UI. Tagged with the new agentSessionId so the per-session
|
|
254
|
+
// filter picks it up only on its own session.
|
|
255
|
+
emit(workspaceId, 'user:message', { content: prompt, sender: 'system-prompt' }, agentSessionId);
|
|
256
|
+
const tasksPending = countPendingTasks(workspaceId);
|
|
257
|
+
const tasksDone = countDoneTasks(workspaceId);
|
|
258
|
+
emitEphemeral(workspaceId, 'autoloop:iteration-started', {
|
|
259
|
+
iterationNumber,
|
|
260
|
+
taskId: task.id,
|
|
261
|
+
taskTitle: task.title,
|
|
262
|
+
tasksPending,
|
|
263
|
+
tasksDone,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Called by orchestrator.handleQuota's backoff timer when auto-loop is enabled.
|
|
268
|
+
* Spawns the next auto-loop iteration if the workspace is still in quota status
|
|
269
|
+
* with auto_loop active; no-ops otherwise (race-safe).
|
|
270
|
+
*/
|
|
271
|
+
export function onQuotaBackoffExpired(workspaceId) {
|
|
272
|
+
const row = getRow(workspaceId);
|
|
273
|
+
if (!row)
|
|
274
|
+
return;
|
|
275
|
+
if (row.auto_loop !== 1)
|
|
276
|
+
return;
|
|
277
|
+
if (row.status !== 'quota')
|
|
278
|
+
return;
|
|
279
|
+
spawnNextIteration(workspaceId);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Called when a workspace transitions to auto_loop_ready=true (grooming done).
|
|
283
|
+
* If the loop is armed (auto_loop=1) and tasks exist, spawns the first iteration.
|
|
284
|
+
* This handles the creation-time autoLoop flag: the brainstorming session ends
|
|
285
|
+
* without triggering a spawn (auto_loop_ready was false), so this function
|
|
286
|
+
* starts the loop once the user completes grooming.
|
|
287
|
+
*/
|
|
288
|
+
export function onAutoLoopReadySet(workspaceId) {
|
|
289
|
+
const row = getRow(workspaceId);
|
|
290
|
+
if (!row)
|
|
291
|
+
return;
|
|
292
|
+
if (row.auto_loop !== 1)
|
|
293
|
+
return;
|
|
294
|
+
if (row.archived_at !== null)
|
|
295
|
+
return;
|
|
296
|
+
if (orchestrator.hasController(workspaceId))
|
|
297
|
+
return;
|
|
298
|
+
if (countPendingTasks(workspaceId) === 0)
|
|
299
|
+
return;
|
|
300
|
+
spawnNextIteration(workspaceId);
|
|
301
|
+
}
|
|
302
|
+
// ── Test-only ─────────────────────────────────────────────────────────────────
|
|
303
|
+
/** @internal */
|
|
304
|
+
export function _test_setAutoLoopReady(workspaceId, ready) {
|
|
305
|
+
const db = getDb();
|
|
306
|
+
db.prepare('UPDATE workspaces SET auto_loop_ready = ? WHERE id = ?').run(ready ? 1 : 0, workspaceId);
|
|
307
|
+
}
|
|
308
|
+
/** @internal */
|
|
309
|
+
export function _test_pickNextTask(workspaceId) {
|
|
310
|
+
return pickNextTask(workspaceId);
|
|
311
|
+
}
|
|
@@ -16,6 +16,20 @@ let timer = null;
|
|
|
16
16
|
let checking = false;
|
|
17
17
|
/** Tracks the last known PR state per workspace to detect transitions. */
|
|
18
18
|
const lastKnownState = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* Read-only snapshot of PR states known to the watcher, keyed by workspace id.
|
|
21
|
+
* Used by the drawer to show a small PR-open indicator without N separate
|
|
22
|
+
* `gh pr view` calls per workspace. Only contains entries where a PR has been
|
|
23
|
+
* detected at least once by the watcher since boot; workspaces without a PR
|
|
24
|
+
* are absent from the map.
|
|
25
|
+
*/
|
|
26
|
+
export function getAllPrStates() {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const [id, state] of lastKnownState) {
|
|
29
|
+
out[id] = state;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
19
33
|
async function checkPrStatuses() {
|
|
20
34
|
const workspaces = listWorkspaces(false); // non-archived only
|
|
21
35
|
// Clean up entries for workspaces that no longer exist
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid';
|
|
2
2
|
import { getDb } from '../db/index.js';
|
|
3
|
+
import * as orchestrator from './agent/orchestrator.js';
|
|
4
|
+
import * as autoLoopService from './auto-loop-service.js';
|
|
3
5
|
import * as wakeupService from './wakeup-service.js';
|
|
4
6
|
/** Allowed status transitions per current status. Enforced by updateWorkspaceStatus. */
|
|
5
7
|
const VALID_TRANSITIONS = {
|
|
@@ -36,6 +38,9 @@ function mapWorkspace(row) {
|
|
|
36
38
|
favoritedAt: row.favorited_at,
|
|
37
39
|
tags: parseTags(row.tags),
|
|
38
40
|
engine: row.engine ?? 'claude-code',
|
|
41
|
+
autoLoop: row.auto_loop === 1,
|
|
42
|
+
autoLoopReady: row.auto_loop_ready === 1,
|
|
43
|
+
noProgressStreak: row.no_progress_streak ?? 0,
|
|
39
44
|
createdAt: row.created_at,
|
|
40
45
|
updatedAt: row.updated_at,
|
|
41
46
|
};
|
|
@@ -213,6 +218,11 @@ export function deleteWorkspace(id) {
|
|
|
213
218
|
// The DB row is removed via ON DELETE CASCADE, but the timer would
|
|
214
219
|
// otherwise fire and hit an empty workspace.
|
|
215
220
|
wakeupService.cancel(id, 'deleted');
|
|
221
|
+
// Drop the cached rate_limit.info so memory doesn't leak on workspace
|
|
222
|
+
// churn. The Map has no FK to clean up for it automatically.
|
|
223
|
+
orchestrator.forgetRateLimitInfo(id);
|
|
224
|
+
orchestrator.forgetTasksDoneSnapshot(id);
|
|
225
|
+
autoLoopService.forgetAutoLoopState(id);
|
|
216
226
|
const db = getDb();
|
|
217
227
|
db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
|
|
218
228
|
}
|
|
@@ -296,6 +306,9 @@ export function archiveWorkspace(id) {
|
|
|
296
306
|
}
|
|
297
307
|
// Cancel any pending wakeup — archived workspaces should not wake up.
|
|
298
308
|
wakeupService.cancel(id, 'archived');
|
|
309
|
+
// Disable auto-loop — archived workspaces should not keep looping.
|
|
310
|
+
// Idempotent: no-op if auto_loop was already 0.
|
|
311
|
+
autoLoopService.disable(id, 'user-action');
|
|
299
312
|
const now = new Date().toISOString();
|
|
300
313
|
db.prepare('UPDATE workspaces SET archived_at = ?, updated_at = ? WHERE id = ?').run(now, now, id);
|
|
301
314
|
return getWorkspace(id);
|
|
@@ -324,6 +337,15 @@ export function setFavorite(id) {
|
|
|
324
337
|
}
|
|
325
338
|
return getWorkspace(id);
|
|
326
339
|
}
|
|
340
|
+
/** Flip the `auto_loop_ready` flag for a workspace. Used by the grooming MCP tool + the "Force ready" override. */
|
|
341
|
+
export function setAutoLoopReady(id, ready) {
|
|
342
|
+
const workspace = getWorkspace(id);
|
|
343
|
+
if (!workspace)
|
|
344
|
+
throw new Error(`Workspace '${id}' not found`);
|
|
345
|
+
const db = getDb();
|
|
346
|
+
db.prepare('UPDATE workspaces SET auto_loop_ready = ? WHERE id = ?').run(ready ? 1 : 0, id);
|
|
347
|
+
return getWorkspace(id);
|
|
348
|
+
}
|
|
327
349
|
/** Remove a workspace from favorites. Idempotent: safe to call on a non-favorite, though `updated_at` still refreshes. */
|
|
328
350
|
export function unsetFavorite(id) {
|
|
329
351
|
const db = getDb();
|