@loicngr/kobo 1.10.0 → 1.10.1

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 CHANGED
@@ -4,6 +4,10 @@ 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.10.1
8
+
9
+ - fix(auto-loop): recover from interrupted sessions (#18)
10
+
7
11
  ## 1.10.0
8
12
 
9
13
  - fix(agent): make Claude sessions recover cleanly from a stuck post-result
@@ -388,6 +388,9 @@ setMessageHandler(async (type, payload) => {
388
388
  }
389
389
  if (type === 'workspace:stop' && p?.workspaceId) {
390
390
  try {
391
+ // A deliberate stop must not be treated like a recoverable engine
392
+ // interruption: disable before the engine emits session:ended(killed).
393
+ autoLoopService.disable(p.workspaceId, 'user-action');
391
394
  stopAgent(p.workspaceId);
392
395
  }
393
396
  catch (err) {
@@ -395,11 +395,12 @@ function reuseOrCreateFreshSession(workspaceId, existingSessionId, model) {
395
395
  }
396
396
  // ── Event handler ─────────────────────────────────────────────────────────────
397
397
  /**
398
- * Snapshot of the `tasks` done-count at `session:started`, read back and
399
- * cleared at `session:ended` to compute the per-session delta. Used by
400
- * `auto-loop-service.onSessionEnded` for stall detection.
398
+ * Snapshots of task completion and task state at `session:started`, read back
399
+ * at `session:ended` for auto-loop stall detection. A task moved to
400
+ * `in_progress` is meaningful progress even if it is not done yet.
401
401
  */
402
402
  const tasksDoneSnapshot = new Map();
403
+ const taskStateSnapshot = new Map();
403
404
  function getDoneTaskCount(workspaceId) {
404
405
  try {
405
406
  const db = getDb();
@@ -415,9 +416,23 @@ function getDoneTaskCount(workspaceId) {
415
416
  return 0;
416
417
  }
417
418
  }
419
+ function getTaskStateSignature(workspaceId) {
420
+ try {
421
+ const db = getDb();
422
+ const rows = db
423
+ .prepare('SELECT id, status, updated_at FROM tasks WHERE workspace_id = ? ORDER BY id')
424
+ .all(workspaceId);
425
+ return JSON.stringify(rows);
426
+ }
427
+ catch (err) {
428
+ console.warn('[orchestrator] getTaskStateSignature failed, returning empty state:', err);
429
+ return '';
430
+ }
431
+ }
418
432
  /** Clear the in-memory done-count snapshot for a workspace (called on delete). */
419
433
  export function forgetTasksDoneSnapshot(workspaceId) {
420
434
  tasksDoneSnapshot.delete(workspaceId);
435
+ taskStateSnapshot.delete(workspaceId);
421
436
  }
422
437
  /** Drop the resume-failed flag for a workspace (called on delete). */
423
438
  export function forgetResumeFailed(workspaceId) {
@@ -444,6 +459,7 @@ function handleEvent(workspaceId, agentSessionId, ev) {
444
459
  // can compute a delta for auto-loop stall detection.
445
460
  if (ev.kind === 'session:started') {
446
461
  tasksDoneSnapshot.set(workspaceId, getDoneTaskCount(workspaceId));
462
+ taskStateSnapshot.set(workspaceId, getTaskStateSignature(workspaceId));
447
463
  }
448
464
  // Legacy fallback: the built-in `ScheduleWakeup` tool (CLI tradition) isn't
449
465
  // declared by the SDK, so we intercept the tool:call event and apply the
@@ -531,8 +547,12 @@ function handleEvent(workspaceId, agentSessionId, ev) {
531
547
  const isResumeFailed = resumeFailedSet.delete(workspaceId);
532
548
  const before = tasksDoneSnapshot.get(workspaceId) ?? getDoneTaskCount(workspaceId);
533
549
  const after = getDoneTaskCount(workspaceId);
534
- const delta = Math.max(0, after - before);
550
+ const completedDelta = Math.max(0, after - before);
551
+ const taskStateBefore = taskStateSnapshot.get(workspaceId) ?? getTaskStateSignature(workspaceId);
552
+ const taskStateAfter = getTaskStateSignature(workspaceId);
553
+ const progressDelta = completedDelta > 0 || taskStateBefore !== taskStateAfter ? 1 : 0;
535
554
  tasksDoneSnapshot.delete(workspaceId);
555
+ taskStateSnapshot.delete(workspaceId);
536
556
  clearPendingForSession(workspaceId, agentSessionId);
537
557
  // Must run BEFORE autoLoopService.onSessionEnded → spawnNextIteration →
538
558
  // startAgent, otherwise startAgent throws "Agent already running" because
@@ -546,7 +566,7 @@ function handleEvent(workspaceId, agentSessionId, ev) {
546
566
  // disable() clears it, and the cleanup hook needs to know whether this was
547
567
  // a mid-loop session (never cleans) or a standalone one.
548
568
  const wasAutoLoop = autoLoopService.getStatus(workspaceId).auto_loop;
549
- autoLoopService.onSessionEnded(workspaceId, effectiveReason, delta);
569
+ autoLoopService.onSessionEnded(workspaceId, effectiveReason, progressDelta);
550
570
  cleanupScriptService.onSessionEnded(workspaceId, effectiveReason, { wasAutoLoop });
551
571
  }
552
572
  if (ev.kind === 'session:user-input-requested') {
@@ -82,7 +82,15 @@ export function disable(workspaceId, reason) {
82
82
  return;
83
83
  const db = getDb();
84
84
  db.prepare('UPDATE workspaces SET auto_loop = 0 WHERE id = ?').run(workspaceId);
85
- emitEphemeral(workspaceId, 'autoloop:disabled', { reason });
85
+ const diagnostic = {
86
+ reason,
87
+ tasksPending: countPendingTasks(workspaceId),
88
+ noProgressStreak: row.no_progress_streak,
89
+ status: row.status,
90
+ };
91
+ console.info(`[auto-loop] disabled workspace '${workspaceId}' reason=${reason}` +
92
+ ` pending=${diagnostic.tasksPending} streak=${diagnostic.noProgressStreak} status=${diagnostic.status}`);
93
+ emitEphemeral(workspaceId, 'autoloop:disabled', diagnostic);
86
94
  // The loop finished every task — run the project's cleanup script. Other
87
95
  // disable reasons (stall / error / user-action) leave tasks unfinished, so
88
96
  // they intentionally skip the cleanup.
@@ -93,15 +101,15 @@ export function disable(workspaceId, reason) {
93
101
  /**
94
102
  * Route a `session:ended` event into the auto-loop state machine.
95
103
  *
96
- * Called by orchestrator.handleEvent. The delta is the number of tasks that
97
- * transitioned to `done` during this session (computed from a snapshot taken
98
- * at `session:started`).
104
+ * Called by orchestrator.handleEvent. The delta records whether task state
105
+ * progressed during this session, including transitions such as pending to
106
+ * in_progress, not only completion.
99
107
  *
100
108
  * When status is `quota` we skip spawning: the orchestrator's handleQuota
101
109
  * already scheduled a backoff timer and will call `onQuotaBackoffExpired` once
102
110
  * the window closes — that function owns the next spawn in that case.
103
111
  */
104
- export function onSessionEnded(workspaceId, reason, tasksDoneDelta) {
112
+ export function onSessionEnded(workspaceId, reason, taskProgressDelta) {
105
113
  const row = getRow(workspaceId);
106
114
  if (!row)
107
115
  return;
@@ -115,7 +123,7 @@ export function onSessionEnded(workspaceId, reason, tasksDoneDelta) {
115
123
  // will resume the deferred turn explicitly.
116
124
  if (row.status === 'awaiting-user')
117
125
  return;
118
- if (reason === 'error' || reason === 'killed') {
126
+ if (reason === 'error') {
119
127
  disable(workspaceId, 'error');
120
128
  return;
121
129
  }
@@ -126,7 +134,7 @@ export function onSessionEnded(workspaceId, reason, tasksDoneDelta) {
126
134
  return;
127
135
  const db = getDb();
128
136
  let streak;
129
- if (tasksDoneDelta > 0) {
137
+ if (taskProgressDelta > 0) {
130
138
  db.prepare('UPDATE workspaces SET no_progress_streak = 0 WHERE id = ?').run(workspaceId);
131
139
  streak = 0;
132
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "Kōbō — multi-workspace agent manager for Claude Code. Orchestrates isolated git worktrees with dev servers, Notion integration, and MCP tools.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",