@loicngr/kobo 1.7.5 → 1.7.6
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/README.md +8 -3
- package/dist/mcp-server/kobo-tasks-handlers.js +15 -0
- package/dist/mcp-server/kobo-tasks-server.js +117 -8
- package/dist/server/db/migrations.js +38 -0
- package/dist/server/db/schema.js +16 -0
- package/dist/server/index.js +2 -0
- package/dist/server/routes/health.js +68 -3
- package/dist/server/routes/workspaces.js +102 -1
- package/dist/server/services/agent/engines/claude-code/engine.js +13 -9
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +95 -10
- package/dist/server/services/agent/orchestrator.js +41 -0
- package/dist/server/services/auto-loop-service.js +8 -3
- package/dist/server/services/cron-service.js +279 -0
- package/dist/server/services/wakeup-service.js +1 -1
- package/dist/server/services/workspace-service.js +18 -0
- package/dist/server/utils/git-ops.js +8 -1
- package/package.json +2 -1
- package/src/client/dist/spa/assets/{ActivityFeed-oW9PgZ8E.js → ActivityFeed-BboSPm4b.js} +2 -2
- package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
- package/src/client/dist/spa/assets/{AutoLoopChip-Y53cnGfZ.js → AutoLoopChip-w8D77bI5.js} +1 -1
- package/src/client/dist/spa/assets/{CreatePage-CuD7sMR7.js → CreatePage-BDObLDJc.js} +1 -1
- package/src/client/dist/spa/assets/{DiffViewer-rc3tE9fq.js → DiffViewer-CblFgn8w.js} +3 -3
- package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
- package/src/client/dist/spa/assets/HealthPage-CBSw7e5q.js +1 -0
- package/src/client/dist/spa/assets/{MainLayout-B9i06p7n.js → MainLayout-DhaYycak.js} +17 -17
- package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
- package/src/client/dist/spa/assets/{SearchPage-DdX7JZCD.js → SearchPage-cZTwP4Lf.js} +1 -1
- package/src/client/dist/spa/assets/{SettingsPage-Dnj1CWc3.js → SettingsPage-C1efO0VM.js} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-DHp20nl-.js → WorkspacePage-3jcof896.js} +3 -3
- package/src/client/dist/spa/assets/{cssMode-DSB5jkRt.js → cssMode-BFLYiiEw.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-Bcw50eFD.js → editor.api-2asmmhth.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-D9piVGaH.js → editor.main-ChCYZyez.js} +3 -3
- package/src/client/dist/spa/assets/{expand-template-BIPuNAYV.js → expand-template-CXQFkQOJ.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-CVh_Zh8H.js → freemarker2-BaBL9E9G.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-CpCgELpu.js → handlebars-BxDour4L.js} +1 -1
- package/src/client/dist/spa/assets/{html-ikWDpvWk.js → html-C6hnkfIL.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-C9TTCKih.js → htmlMode-9zT3-dmz.js} +1 -1
- package/src/client/dist/spa/assets/i18n-CLY0XI9-.js +1 -0
- package/src/client/dist/spa/assets/index-D6wj_wQ9.js +2 -0
- package/src/client/dist/spa/assets/{javascript-C4OlkNeA.js → javascript-C3YjvKbE.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-BiD34_86.js → jsonMode-DcJDgMzf.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-Dty0Ui2c.js → liquid-CsT8SjJM.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-yiUjOVv6.js → mdx-CT3yVSyc.js} +1 -1
- package/src/client/dist/spa/assets/{models-BDkLiht9.js → models-BsjWUKqM.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-Bz9yFPWR.js → monaco.contribution-DKGNz1oQ.js} +2 -2
- package/src/client/dist/spa/assets/{purify.es-BIY760fF.js → purify.es-CPieV82n.js} +1 -1
- package/src/client/dist/spa/assets/{python-7SPSWQoD.js → python-Ca5miKgj.js} +1 -1
- package/src/client/dist/spa/assets/{razor-eagZawXK.js → razor-7qzusGRc.js} +1 -1
- package/src/client/dist/spa/assets/{render-chat-markdown-TvAqpDih.js → render-chat-markdown-Bqq2G-yI.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-CLYG2xeJ.js → tsMode-BdvO8jZ2.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-CzOXM8yS.js → typescript-BfVNzhgs.js} +1 -1
- package/src/client/dist/spa/assets/{xml-2_0_6RAX.js → xml-DGNXGqXL.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-CtpgNyXs.js → yaml-CtAtOyt5.js} +1 -1
- package/src/client/dist/spa/index.html +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +20 -0
- package/src/mcp-server/kobo-tasks-server.ts +123 -7
- package/src/client/dist/spa/assets/HealthPage-Dz0yGGMB.js +0 -1
- package/src/client/dist/spa/assets/MainLayout-DDa3rGKA.css +0 -1
- package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +0 -1
- package/src/client/dist/spa/assets/index-DuK38XN5.js +0 -2
|
@@ -29,6 +29,14 @@ function makeBucket(id, source) {
|
|
|
29
29
|
const details = used !== undefined && limit !== undefined ? `${String(used)} / ${String(limit)}` : undefined;
|
|
30
30
|
return { id, label, usedPct: Math.max(0, Math.min(100, usedPct)), resetsAt, details };
|
|
31
31
|
}
|
|
32
|
+
const RATE_LIMIT_STATUSES = new Set(['allowed', 'allowed_warning', 'rejected']);
|
|
33
|
+
function extractStatus(info) {
|
|
34
|
+
const raw = info.status;
|
|
35
|
+
if (typeof raw === 'string' && RATE_LIMIT_STATUSES.has(raw)) {
|
|
36
|
+
return raw;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
32
40
|
function normalizeRateLimitInfo(info) {
|
|
33
41
|
const buckets = [];
|
|
34
42
|
if (typeof info.rateLimitType === 'string') {
|
|
@@ -50,10 +58,32 @@ function normalizeRateLimitInfo(info) {
|
|
|
50
58
|
buckets.push(b);
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
|
-
|
|
61
|
+
const status = extractStatus(info);
|
|
62
|
+
return status ? { buckets, status } : { buckets };
|
|
54
63
|
}
|
|
64
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
65
|
+
/**
|
|
66
|
+
* Canonical "out of quota" surfaces from the Claude SDK and CLI. Centralised
|
|
67
|
+
* so the three call-sites stay in sync:
|
|
68
|
+
* - `result` events with an error subtype (`parsed.error` / `parsed.result`)
|
|
69
|
+
* - assistant `message:text` blocks (the SDK occasionally streams the user-
|
|
70
|
+
* visible quota notice as plain assistant text instead of a structured
|
|
71
|
+
* error result; see workspace `-GyiAYM7X4xTWyZbcHGiR` session #25 for the
|
|
72
|
+
* repro that motivated this path)
|
|
73
|
+
* - CLI stderr in `engine.ts`
|
|
74
|
+
*
|
|
75
|
+
* Patterns are kept loose on purpose to absorb minor wording drift between
|
|
76
|
+
* Anthropic's surfaces (`rate_limit_exceeded`, `Claude AI usage limit
|
|
77
|
+
* reached`, `You're out of extra usage`, `quota exceeded`).
|
|
78
|
+
*/
|
|
79
|
+
export const QUOTA_PATTERN = /out of extra usage|rate[_ ]limit|usage limit|quota exceeded/i;
|
|
55
80
|
export function createMapperState() {
|
|
56
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
sessionStartedEmitted: false,
|
|
83
|
+
openMessages: new Map(),
|
|
84
|
+
sawErrorResult: false,
|
|
85
|
+
quotaErrorEmitted: false,
|
|
86
|
+
};
|
|
57
87
|
}
|
|
58
88
|
/** Known SDK `result` subtypes that indicate the run failed. */
|
|
59
89
|
export const KNOWN_ERROR_RESULT_SUBTYPES = new Set(['error_max_turns', 'error_during_execution']);
|
|
@@ -64,6 +94,36 @@ function isErrorResultSubtype(subtype) {
|
|
|
64
94
|
return true;
|
|
65
95
|
return subtype.startsWith('error');
|
|
66
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* SDK error codes (`SDKAssistantMessageError`) that map to a quota exhaustion
|
|
99
|
+
* — the user has hit the 5h/7d cap or run out of overage credits.
|
|
100
|
+
* - `'rate_limit'`: classic 429 / Anthropic rate-limit reached
|
|
101
|
+
* - `'billing_error'`: claude.ai overage credits exhausted
|
|
102
|
+
*/
|
|
103
|
+
export const QUOTA_ASSISTANT_ERRORS = new Set(['rate_limit', 'billing_error']);
|
|
104
|
+
/**
|
|
105
|
+
* Emit an `error/quota` event exactly once per SDK run, regardless of which
|
|
106
|
+
* surface detected the quota (stderr, SDK iterator, message:text fallback…).
|
|
107
|
+
* Also sets `sawErrorResult` so the engine surfaces
|
|
108
|
+
* `session:ended.reason='error'`, which the orchestrator then maps to a
|
|
109
|
+
* `quota` status transition via the `category: 'quota'` discriminator.
|
|
110
|
+
*
|
|
111
|
+
* Exported so the stderr path in `engine.ts` (which bypasses `mapSdkMessage`)
|
|
112
|
+
* can share the same one-shot guard. Without this, two quota surfaces in the
|
|
113
|
+
* same run would call `handleQuota` twice → `retryCount` doubled and the
|
|
114
|
+
* persisted backoff row overwritten.
|
|
115
|
+
*/
|
|
116
|
+
export function tryEmitQuota(state, emit, message) {
|
|
117
|
+
if (state.quotaErrorEmitted)
|
|
118
|
+
return;
|
|
119
|
+
state.quotaErrorEmitted = true;
|
|
120
|
+
state.sawErrorResult = true;
|
|
121
|
+
emit({ kind: 'error', category: 'quota', message });
|
|
122
|
+
}
|
|
123
|
+
/** Internal wrapper for the in-mapper push pattern. */
|
|
124
|
+
function tryEmitQuotaError(state, events, message) {
|
|
125
|
+
tryEmitQuota(state, (ev) => events.push(ev), message);
|
|
126
|
+
}
|
|
67
127
|
/**
|
|
68
128
|
* Maps a single typed `SDKMessage` to zero or more `AgentEvent`s, mutating
|
|
69
129
|
* `state` as needed.
|
|
@@ -80,7 +140,13 @@ export function mapSdkMessage(msg, state) {
|
|
|
80
140
|
if (type === 'rate_limit_event') {
|
|
81
141
|
const info = parsed.rate_limit_info;
|
|
82
142
|
if (info && typeof info === 'object') {
|
|
83
|
-
|
|
143
|
+
const normalized = normalizeRateLimitInfo(info);
|
|
144
|
+
events.push({ kind: 'rate_limit', info: normalized });
|
|
145
|
+
// `status: 'rejected'` from the SDK is the explicit "request blocked,
|
|
146
|
+
// out of quota" signal — the most reliable structured surface.
|
|
147
|
+
if (normalized.status === 'rejected') {
|
|
148
|
+
tryEmitQuotaError(state, events, 'Rate limit rejected by Claude SDK (rate_limit_event)');
|
|
149
|
+
}
|
|
84
150
|
}
|
|
85
151
|
return events;
|
|
86
152
|
}
|
|
@@ -129,6 +195,14 @@ export function mapSdkMessage(msg, state) {
|
|
|
129
195
|
return events;
|
|
130
196
|
}
|
|
131
197
|
if (type === 'assistant') {
|
|
198
|
+
// `SDKAssistantMessage.error` is a typed enum that includes 'rate_limit'
|
|
199
|
+
// and 'billing_error' — explicit, structured quota signals. Surface them
|
|
200
|
+
// before any text processing so the orchestrator transitions to `quota`
|
|
201
|
+
// even on otherwise empty assistant turns.
|
|
202
|
+
const assistantError = typeof parsed.error === 'string' ? parsed.error : undefined;
|
|
203
|
+
if (assistantError && QUOTA_ASSISTANT_ERRORS.has(assistantError)) {
|
|
204
|
+
tryEmitQuotaError(state, events, `Assistant message error: ${assistantError}`);
|
|
205
|
+
}
|
|
132
206
|
const message = parsed.message;
|
|
133
207
|
const messageId = typeof message?.id === 'string' ? message.id : 'unknown';
|
|
134
208
|
const content = Array.isArray(message?.content) ? message?.content : [];
|
|
@@ -149,11 +223,19 @@ export function mapSdkMessage(msg, state) {
|
|
|
149
223
|
for (const block of content) {
|
|
150
224
|
const blockType = block.type;
|
|
151
225
|
if (blockType === 'text' && typeof block.text === 'string') {
|
|
152
|
-
|
|
226
|
+
const text = block.text;
|
|
227
|
+
events.push({ kind: 'message:text', messageId, text, streaming: true });
|
|
153
228
|
msgState.sawText = true;
|
|
154
|
-
if (
|
|
229
|
+
if (text.includes('[BRAINSTORM_COMPLETE]')) {
|
|
155
230
|
events.push({ kind: 'session:brainstorm-complete' });
|
|
156
231
|
}
|
|
232
|
+
// Last-resort fallback: some SDK runs surface the quota notice as
|
|
233
|
+
// plain assistant text without setting `assistant.error` or a
|
|
234
|
+
// `result.error`. The structured signals above cover modern SDK
|
|
235
|
+
// versions; this regex absorbs older or drifted wordings.
|
|
236
|
+
if (QUOTA_PATTERN.test(text)) {
|
|
237
|
+
tryEmitQuotaError(state, events, text);
|
|
238
|
+
}
|
|
157
239
|
}
|
|
158
240
|
if (blockType === 'tool_use') {
|
|
159
241
|
events.push({
|
|
@@ -211,12 +293,15 @@ export function mapSdkMessage(msg, state) {
|
|
|
211
293
|
if (isErrorResultSubtype(subtype)) {
|
|
212
294
|
state.sawErrorResult = true;
|
|
213
295
|
const detail = (typeof parsed.error === 'string' && parsed.error) || (typeof parsed.result === 'string' && parsed.result) || '';
|
|
214
|
-
|
|
215
|
-
// to the regex so the orchestrator transitions the workspace to `quota`
|
|
216
|
-
// (not `error`) and the auto-loop backoff path engages.
|
|
217
|
-
const isQuota = /out of extra usage|rate limit|usage limit/i.test(detail);
|
|
296
|
+
const isQuota = QUOTA_PATTERN.test(detail);
|
|
218
297
|
const message = detail ? `Agent run failed (${subtype}): ${detail}` : `Agent run failed (${subtype})`;
|
|
219
|
-
|
|
298
|
+
if (isQuota) {
|
|
299
|
+
// Coordinate with the structured quota path so we never emit twice.
|
|
300
|
+
tryEmitQuotaError(state, events, message);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
events.push({ kind: 'error', category: 'other', message });
|
|
304
|
+
}
|
|
220
305
|
}
|
|
221
306
|
const usage = parsed.usage;
|
|
222
307
|
if (usage) {
|
|
@@ -4,6 +4,7 @@ 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
6
|
import * as autoLoopService from '../auto-loop-service.js';
|
|
7
|
+
import * as cronService from '../cron-service.js';
|
|
7
8
|
import * as quotaBackoffService from '../quota-backoff-service.js';
|
|
8
9
|
import { getEffectiveSettings } from '../settings-service.js';
|
|
9
10
|
import { refreshNow } from '../usage/poller.js';
|
|
@@ -447,6 +448,46 @@ function handleEvent(workspaceId, agentSessionId, ev) {
|
|
|
447
448
|
wakeupService.schedule(workspaceId, delay, prompt, reason, agentSessionId);
|
|
448
449
|
}
|
|
449
450
|
}
|
|
451
|
+
// Same legacy bridge for the SDK's native `CronCreate`. The native tool is
|
|
452
|
+
// session-only (the cron dies when the agent session exits and is not
|
|
453
|
+
// persisted to disk), which makes it useless for any real "schedule a
|
|
454
|
+
// recurring trigger" need. We intercept the tool:call and arm an equivalent
|
|
455
|
+
// kobo cron in parallel — persistent across restarts, owned by the backend.
|
|
456
|
+
if (ev.kind === 'tool:call' && ev.name === 'CronCreate') {
|
|
457
|
+
const input = ev.input;
|
|
458
|
+
const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
|
|
459
|
+
// The SDK's exact field name has drifted across versions — try the most
|
|
460
|
+
// likely candidates. If none match we log the input shape so the user
|
|
461
|
+
// can extend this list.
|
|
462
|
+
const expression = (typeof input?.cron === 'string' && input.cron) ||
|
|
463
|
+
(typeof input?.schedule === 'string' && input.schedule) ||
|
|
464
|
+
(typeof input?.expression === 'string' && input.expression) ||
|
|
465
|
+
'';
|
|
466
|
+
if (prompt && expression) {
|
|
467
|
+
console.warn(`[orchestrator] Native CronCreate intercepted for workspace '${workspaceId}' — armed equivalent kobo cron. Prefer kobo__cron_create.`);
|
|
468
|
+
try {
|
|
469
|
+
cronService.arm(workspaceId, {
|
|
470
|
+
expression,
|
|
471
|
+
prompt,
|
|
472
|
+
label: 'from-native-CronCreate',
|
|
473
|
+
agentSessionId,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
console.error('[orchestrator] Failed to mirror native CronCreate as kobo cron:', err);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
else if (prompt || input) {
|
|
481
|
+
console.warn(`[orchestrator] Native CronCreate intercepted but unrecognised input shape (workspace '${workspaceId}'):`, Object.keys(input ?? {}));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// Native `CronDelete` and `CronList` are noisy but harmless to ignore.
|
|
485
|
+
// The native cron is session-only so deletion is moot once the session
|
|
486
|
+
// ends; the kobo equivalents (kobo__cron_delete / kobo__cron_list) are
|
|
487
|
+
// the persistent path. Log to track usage and avoid silent confusion.
|
|
488
|
+
if (ev.kind === 'tool:call' && (ev.name === 'CronDelete' || ev.name === 'CronList')) {
|
|
489
|
+
console.warn(`[orchestrator] Native ${ev.name} called on workspace '${workspaceId}' — has no effect on kobo crons. Use kobo__${ev.name === 'CronDelete' ? 'cron_delete' : 'cron_list'} instead.`);
|
|
490
|
+
}
|
|
450
491
|
if (ev.kind === 'skills:discovered') {
|
|
451
492
|
availableSkills = ev.skills;
|
|
452
493
|
try {
|
|
@@ -54,12 +54,17 @@ export function enable(workspaceId) {
|
|
|
54
54
|
if (row.auto_loop_ready !== 1) {
|
|
55
55
|
throw new Error(`Workspace '${workspaceId}' is not ready for auto-loop (run grooming first)`);
|
|
56
56
|
}
|
|
57
|
+
// Refuse to enable when there is nothing to spawn — without this, auto_loop
|
|
58
|
+
// would flip to 1 silently with no iteration running, locking the chat input
|
|
59
|
+
// (auto-loop banner) without doing any work. The user must add a task or
|
|
60
|
+
// unmark a done task before re-enabling.
|
|
61
|
+
const pending = countPendingTasks(workspaceId);
|
|
62
|
+
if (pending === 0) {
|
|
63
|
+
throw new Error(`Workspace '${workspaceId}' has no pending tasks; add or unmark a task before enabling auto-loop`);
|
|
64
|
+
}
|
|
57
65
|
const db = getDb();
|
|
58
66
|
db.prepare('UPDATE workspaces SET auto_loop = 1, no_progress_streak = 0 WHERE id = ?').run(workspaceId);
|
|
59
67
|
emitEphemeral(workspaceId, 'autoloop:enabled', {});
|
|
60
|
-
const pending = countPendingTasks(workspaceId);
|
|
61
|
-
if (pending === 0)
|
|
62
|
-
return;
|
|
63
68
|
if (orchestrator.hasController(workspaceId))
|
|
64
69
|
return;
|
|
65
70
|
// spawnNextIteration throws on initial spawn failure (see flag).
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
2
|
+
import { nanoid } from 'nanoid';
|
|
3
|
+
import { getDb } from '../db/index.js';
|
|
4
|
+
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
5
|
+
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
|
+
import * as orchestrator from './agent/orchestrator.js';
|
|
7
|
+
import * as settingsService from './settings-service.js';
|
|
8
|
+
import { emitEphemeral } from './websocket-service.js';
|
|
9
|
+
export const MIN_DELAY_BETWEEN_FIRES_SECONDS = 60;
|
|
10
|
+
// Node `setTimeout` stores the delay as a 32-bit signed int. Anything above
|
|
11
|
+
// 2^31-1 ms (~24.8 days) triggers `TimeoutOverflowWarning` and silently
|
|
12
|
+
// truncates to 1ms — which causes the timer to fire instantly and loop
|
|
13
|
+
// when the real target is years away (e.g. `@yearly` or `0 0 1 1 *`).
|
|
14
|
+
// We cap each setTimeout at this max and re-arm in the callback if the
|
|
15
|
+
// real fire time hasn't been reached yet.
|
|
16
|
+
const MAX_SETTIMEOUT_MS = 2_000_000_000; // ~23 days, with margin under 2^31-1
|
|
17
|
+
const timers = new Map();
|
|
18
|
+
/**
|
|
19
|
+
* Arm a setTimeout that fires `fireOrSkip(id)` when `fireAt` is reached.
|
|
20
|
+
* Replaces any existing timer for this id. Handles long horizons by
|
|
21
|
+
* chaining capped timeouts, since Node's setTimeout overflows past ~24.8
|
|
22
|
+
* days.
|
|
23
|
+
*/
|
|
24
|
+
function scheduleAt(id, fireAt) {
|
|
25
|
+
const previous = timers.get(id);
|
|
26
|
+
if (previous)
|
|
27
|
+
clearTimeout(previous);
|
|
28
|
+
const deltaMs = Math.max(0, fireAt.getTime() - Date.now());
|
|
29
|
+
const cappedMs = Math.min(deltaMs, MAX_SETTIMEOUT_MS);
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
timers.delete(id);
|
|
32
|
+
if (Date.now() >= fireAt.getTime()) {
|
|
33
|
+
fireOrSkip(id);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
// Long-horizon cron: hop forward another chunk and re-check.
|
|
37
|
+
scheduleAt(id, fireAt);
|
|
38
|
+
}
|
|
39
|
+
}, cappedMs);
|
|
40
|
+
timer.unref?.();
|
|
41
|
+
timers.set(id, timer);
|
|
42
|
+
}
|
|
43
|
+
function rowToCron(row) {
|
|
44
|
+
return {
|
|
45
|
+
id: row.id,
|
|
46
|
+
workspaceId: row.workspace_id,
|
|
47
|
+
expression: row.expression,
|
|
48
|
+
prompt: row.prompt,
|
|
49
|
+
label: row.label,
|
|
50
|
+
agentSessionId: row.agent_session_id,
|
|
51
|
+
nextFireAt: row.next_fire_at,
|
|
52
|
+
lastFiredAt: row.last_fired_at,
|
|
53
|
+
oneShot: row.one_shot === 1,
|
|
54
|
+
createdAt: row.created_at,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Compute the next fire time for an expression strictly after `from`.
|
|
59
|
+
* Uses cron-parser 5.x API. Throws with a descriptive error if the
|
|
60
|
+
* expression is invalid. Helpers `@hourly` / `@daily` / `@weekly` /
|
|
61
|
+
* `@monthly` / `@yearly` are accepted natively.
|
|
62
|
+
*/
|
|
63
|
+
function nextAfter(expression, from) {
|
|
64
|
+
try {
|
|
65
|
+
const it = CronExpressionParser.parse(expression, { currentDate: from });
|
|
66
|
+
return it.next().toDate();
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
70
|
+
throw new Error(`Invalid cron expression: ${expression} — ${msg}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Validate the expression, persist the row, arm a setTimeout for the next
|
|
75
|
+
* fire, emit `cron:created`. Throws on invalid expression OR when the next
|
|
76
|
+
* fire is < MIN_DELAY_BETWEEN_FIRES_SECONDS seconds in the future.
|
|
77
|
+
*/
|
|
78
|
+
export function arm(workspaceId, args) {
|
|
79
|
+
const now = new Date();
|
|
80
|
+
const next = nextAfter(args.expression, now);
|
|
81
|
+
const deltaMs = next.getTime() - now.getTime();
|
|
82
|
+
if (deltaMs < MIN_DELAY_BETWEEN_FIRES_SECONDS * 1000) {
|
|
83
|
+
throw new Error(`Cron expression resolves too close to now (minimum ${MIN_DELAY_BETWEEN_FIRES_SECONDS}s); use a longer interval`);
|
|
84
|
+
}
|
|
85
|
+
const id = nanoid();
|
|
86
|
+
const db = getDb();
|
|
87
|
+
db.prepare(`INSERT INTO pending_crons (id, workspace_id, expression, prompt, label, agent_session_id, next_fire_at, last_fired_at, one_shot, created_at)
|
|
88
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(id, workspaceId, args.expression, args.prompt, args.label ?? null, args.agentSessionId ?? null, next.toISOString(), args.oneShot ? 1 : 0, now.toISOString());
|
|
89
|
+
scheduleAt(id, next);
|
|
90
|
+
const cron = rowToCron(db.prepare('SELECT * FROM pending_crons WHERE id = ?').get(id));
|
|
91
|
+
emitEphemeral(workspaceId, 'cron:created', { cron });
|
|
92
|
+
return cron;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Remove a single cron by id. Idempotent — returns false if no row matched.
|
|
96
|
+
* Emits `cron:cancelled` only when a row was actually deleted.
|
|
97
|
+
*/
|
|
98
|
+
export function cancel(id, reason) {
|
|
99
|
+
const db = getDb();
|
|
100
|
+
const row = db.prepare('SELECT workspace_id FROM pending_crons WHERE id = ?').get(id);
|
|
101
|
+
if (!row)
|
|
102
|
+
return false;
|
|
103
|
+
db.prepare('DELETE FROM pending_crons WHERE id = ?').run(id);
|
|
104
|
+
const previous = timers.get(id);
|
|
105
|
+
if (previous) {
|
|
106
|
+
clearTimeout(previous);
|
|
107
|
+
timers.delete(id);
|
|
108
|
+
}
|
|
109
|
+
emitEphemeral(row.workspace_id, 'cron:cancelled', { id, reason });
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Remove every cron for a workspace. Returns the number of rows deleted.
|
|
114
|
+
* Used by archive + delete cascades.
|
|
115
|
+
*/
|
|
116
|
+
export function cancelAllForWorkspace(workspaceId, reason) {
|
|
117
|
+
const db = getDb();
|
|
118
|
+
const rows = db.prepare('SELECT id FROM pending_crons WHERE workspace_id = ?').all(workspaceId);
|
|
119
|
+
let deleted = 0;
|
|
120
|
+
for (const r of rows) {
|
|
121
|
+
if (cancel(r.id, reason))
|
|
122
|
+
deleted++;
|
|
123
|
+
}
|
|
124
|
+
return deleted;
|
|
125
|
+
}
|
|
126
|
+
export function getCron(id) {
|
|
127
|
+
const db = getDb();
|
|
128
|
+
const row = db.prepare('SELECT * FROM pending_crons WHERE id = ?').get(id);
|
|
129
|
+
return row ? rowToCron(row) : null;
|
|
130
|
+
}
|
|
131
|
+
export function listForWorkspace(workspaceId) {
|
|
132
|
+
const db = getDb();
|
|
133
|
+
const rows = db
|
|
134
|
+
.prepare('SELECT * FROM pending_crons WHERE workspace_id = ? ORDER BY next_fire_at ASC')
|
|
135
|
+
.all(workspaceId);
|
|
136
|
+
return rows.map(rowToCron);
|
|
137
|
+
}
|
|
138
|
+
export function listAll() {
|
|
139
|
+
const db = getDb();
|
|
140
|
+
const rows = db.prepare('SELECT * FROM pending_crons ORDER BY next_fire_at ASC').all();
|
|
141
|
+
return rows.map(rowToCron);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Internal — invoked by setTimeout when a cron's `next_fire_at` elapses.
|
|
145
|
+
* Either fires (calls orchestrator.startAgent with resume=true) or skips
|
|
146
|
+
* (when a controller is already active for the workspace), then recomputes
|
|
147
|
+
* the next occurrence and re-arms a fresh setTimeout. Best-effort: any
|
|
148
|
+
* unexpected error is logged and the cron is preserved when possible.
|
|
149
|
+
*/
|
|
150
|
+
function fireOrSkip(id) {
|
|
151
|
+
try {
|
|
152
|
+
timers.delete(id);
|
|
153
|
+
const db = getDb();
|
|
154
|
+
const row = db.prepare('SELECT * FROM pending_crons WHERE id = ?').get(id);
|
|
155
|
+
if (!row)
|
|
156
|
+
return; // cancelled in flight
|
|
157
|
+
const wsRow = db
|
|
158
|
+
.prepare(`SELECT project_path, working_branch, worktree_path, model, agent_permission_mode, reasoning_effort, archived_at
|
|
159
|
+
FROM workspaces WHERE id = ?`)
|
|
160
|
+
.get(row.workspace_id);
|
|
161
|
+
if (!wsRow || wsRow.archived_at !== null) {
|
|
162
|
+
cancel(id, wsRow ? 'archive' : 'deleted');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
let status = 'skipped-active';
|
|
166
|
+
if (!orchestrator.hasController(row.workspace_id)) {
|
|
167
|
+
status = 'fired';
|
|
168
|
+
try {
|
|
169
|
+
const globalSettings = settingsService.getGlobalSettings();
|
|
170
|
+
const projectSettings = settingsService.getProjectSettings(wsRow.project_path);
|
|
171
|
+
const projectSlug = globalSettings.worktreesPrefixByProject
|
|
172
|
+
? slugifyProjectName(projectSettings?.displayName ?? '', wsRow.project_path)
|
|
173
|
+
: undefined;
|
|
174
|
+
const worktreePath = wsRow.worktree_path ??
|
|
175
|
+
resolveWorkspaceWorktreePath(wsRow.project_path, wsRow.working_branch, globalSettings.worktreesPath, projectSlug);
|
|
176
|
+
const stored = wsRow.agent_permission_mode;
|
|
177
|
+
const agentPermissionMode = stored === 'plan' || stored === 'strict' || stored === 'interactive' ? stored : 'bypass';
|
|
178
|
+
// agent_session_id encodes the cron's mode: non-NULL means "resume
|
|
179
|
+
// that session" (pinned at create time); NULL means "fresh session
|
|
180
|
+
// every fire" (clean context, no conversation continuity).
|
|
181
|
+
const resumeMode = row.agent_session_id !== null;
|
|
182
|
+
orchestrator.startAgent(row.workspace_id, worktreePath, row.prompt, wsRow.model, resumeMode, agentPermissionMode, row.agent_session_id ?? undefined, wsRow.reasoning_effort);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
console.error(`[cron-service] startAgent at fire time failed for cron '${id}':`, err);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const now = new Date();
|
|
189
|
+
let nextFire;
|
|
190
|
+
try {
|
|
191
|
+
nextFire = nextAfter(row.expression, now);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
// Defensive — the expression validated at create time, so reaching here
|
|
195
|
+
// means cron-parser changed its acceptance rules between the original
|
|
196
|
+
// arm and this fire. Cancel as 'completed' (the cron self-terminates)
|
|
197
|
+
// rather than 'user' which would imply the user requested it.
|
|
198
|
+
console.error(`[cron-service] failed to recompute next fire for cron '${id}':`, err);
|
|
199
|
+
cancel(id, 'completed');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// One-shot crons cancel themselves after a real fire (not on skip-active —
|
|
203
|
+
// the user expects the cron to actually run once, so a skipped tick must
|
|
204
|
+
// be retried at the next occurrence). Recurring crons re-arm normally.
|
|
205
|
+
if (status === 'fired' && row.one_shot === 1) {
|
|
206
|
+
db.prepare(`UPDATE pending_crons SET last_fired_at = ? WHERE id = ?`).run(now.toISOString(), id);
|
|
207
|
+
emitEphemeral(row.workspace_id, 'cron:fired', {
|
|
208
|
+
id,
|
|
209
|
+
status,
|
|
210
|
+
nextFireAt: null,
|
|
211
|
+
lastFiredAt: now.toISOString(),
|
|
212
|
+
oneShotConsumed: true,
|
|
213
|
+
});
|
|
214
|
+
cancel(id, 'completed');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
db.prepare(`UPDATE pending_crons SET next_fire_at = ?, last_fired_at = ? WHERE id = ?`).run(nextFire.toISOString(), now.toISOString(), id);
|
|
218
|
+
scheduleAt(id, nextFire);
|
|
219
|
+
emitEphemeral(row.workspace_id, 'cron:fired', {
|
|
220
|
+
id,
|
|
221
|
+
status,
|
|
222
|
+
nextFireAt: nextFire.toISOString(),
|
|
223
|
+
lastFiredAt: now.toISOString(),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
console.error(`[cron-service] fireOrSkip uncaught error for cron '${id}':`, err);
|
|
228
|
+
timers.delete(id);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Re-arm timers for rows persisted across restart. Skip-missed semantics:
|
|
233
|
+
* if the stored next_fire_at is in the past, recompute next() based on the
|
|
234
|
+
* current time and update the row before arming (mirror of POSIX crontab —
|
|
235
|
+
* no catchup spam after server downtime).
|
|
236
|
+
*
|
|
237
|
+
* Rows pointing at deleted/archived workspaces are removed without firing.
|
|
238
|
+
*/
|
|
239
|
+
export function restoreOnBoot() {
|
|
240
|
+
try {
|
|
241
|
+
// Boot semantics: clear any existing in-memory timers before rearming.
|
|
242
|
+
for (const t of timers.values())
|
|
243
|
+
clearTimeout(t);
|
|
244
|
+
timers.clear();
|
|
245
|
+
const db = getDb();
|
|
246
|
+
const rows = db.prepare('SELECT * FROM pending_crons').all();
|
|
247
|
+
const now = new Date();
|
|
248
|
+
for (const row of rows) {
|
|
249
|
+
try {
|
|
250
|
+
const wsRow = db.prepare('SELECT archived_at FROM workspaces WHERE id = ?').get(row.workspace_id);
|
|
251
|
+
if (!wsRow || wsRow.archived_at !== null) {
|
|
252
|
+
db.prepare('DELETE FROM pending_crons WHERE id = ?').run(row.id);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const storedDate = new Date(row.next_fire_at);
|
|
256
|
+
let nextFireAt;
|
|
257
|
+
if (storedDate.getTime() <= now.getTime()) {
|
|
258
|
+
// Skip-missed semantics — no catchup spam after downtime.
|
|
259
|
+
const next = nextAfter(row.expression, now);
|
|
260
|
+
nextFireAt = next.toISOString();
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// Future row: normalise the persisted ISO format.
|
|
264
|
+
nextFireAt = storedDate.toISOString();
|
|
265
|
+
}
|
|
266
|
+
db.prepare('UPDATE pending_crons SET next_fire_at = ? WHERE id = ?').run(nextFireAt, row.id);
|
|
267
|
+
scheduleAt(row.id, new Date(nextFireAt));
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
console.error(`[cron-service] restoreOnBoot row failed for cron '${row.id}':`, err);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
console.error('[cron-service] restoreOnBoot failed:', err);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** @internal test-only */
|
|
279
|
+
export const _timers = timers;
|
|
@@ -5,7 +5,7 @@ import * as orchestrator from './agent/orchestrator.js';
|
|
|
5
5
|
import * as settingsService from './settings-service.js';
|
|
6
6
|
import { emitEphemeral } from './websocket-service.js';
|
|
7
7
|
const MIN_DELAY_SECONDS = 60;
|
|
8
|
-
const MAX_DELAY_SECONDS =
|
|
8
|
+
const MAX_DELAY_SECONDS = 21600;
|
|
9
9
|
const STALE_WAKEUP_GRACE_MS = 5 * 60 * 1000;
|
|
10
10
|
const AUTONOMOUS_LOOP_SENTINEL = '<<autonomous-loop-dynamic>>';
|
|
11
11
|
const AUTONOMOUS_LOOP_FALLBACK_PROMPT = 'Continue where you left off.';
|
|
@@ -3,6 +3,7 @@ import { getDb } from '../db/index.js';
|
|
|
3
3
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
4
4
|
import * as orchestrator from './agent/orchestrator.js';
|
|
5
5
|
import * as autoLoopService from './auto-loop-service.js';
|
|
6
|
+
import * as cronService from './cron-service.js';
|
|
6
7
|
import * as quotaBackoffService from './quota-backoff-service.js';
|
|
7
8
|
import * as wakeupService from './wakeup-service.js';
|
|
8
9
|
import { emitEphemeral } from './websocket-service.js';
|
|
@@ -363,6 +364,15 @@ export function deleteWorkspace(id) {
|
|
|
363
364
|
catch (err) {
|
|
364
365
|
console.error('[workspace-service] cancel quota backoff on delete failed:', err);
|
|
365
366
|
}
|
|
367
|
+
// Cancel every pending cron BEFORE the FK cascade removes the rows so
|
|
368
|
+
// in-memory setTimeout timers are cleared. Best-effort: failure must not
|
|
369
|
+
// block delete.
|
|
370
|
+
try {
|
|
371
|
+
cronService.cancelAllForWorkspace(id, 'deleted');
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
console.error('[workspace-service] cancel crons on delete failed:', err);
|
|
375
|
+
}
|
|
366
376
|
// Drop the cached rate_limit.info so memory doesn't leak on workspace
|
|
367
377
|
// churn. The Map has no FK to clean up for it automatically.
|
|
368
378
|
orchestrator.forgetRateLimitInfo(id);
|
|
@@ -463,6 +473,14 @@ export function archiveWorkspace(id) {
|
|
|
463
473
|
catch (err) {
|
|
464
474
|
console.error('[workspace-service] cancel quota backoff on archive failed:', err);
|
|
465
475
|
}
|
|
476
|
+
// Cancel every pending cron — archived workspaces must not fire scheduled
|
|
477
|
+
// prompts. Best-effort: failure here must not block archive.
|
|
478
|
+
try {
|
|
479
|
+
cronService.cancelAllForWorkspace(id, 'archive');
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
console.error('[workspace-service] cancel crons on archive failed:', err);
|
|
483
|
+
}
|
|
466
484
|
// Disable auto-loop — archived workspaces should not keep looping.
|
|
467
485
|
// Idempotent: no-op if auto_loop was already 0.
|
|
468
486
|
autoLoopService.disable(id, 'user-action');
|
|
@@ -583,7 +583,14 @@ export function getUnpushedChangedFiles(repoPath, branchName, remote = 'origin')
|
|
|
583
583
|
export function getFileAtRef(repoPath, ref, filePath) {
|
|
584
584
|
const resolvedRef = resolveBase(repoPath, ref);
|
|
585
585
|
try {
|
|
586
|
-
|
|
586
|
+
// Bypass the `git()` helper here: it `.trimEnd()`s the output, which would
|
|
587
|
+
// strip trailing newlines from the original file content and produce a
|
|
588
|
+
// false diff against `getFileContent`'s untrimmed `readFileSync` output
|
|
589
|
+
// (last line marked added/removed even when identical).
|
|
590
|
+
return execFileSync('git', ['show', `${resolvedRef}:${filePath}`], {
|
|
591
|
+
cwd: repoPath,
|
|
592
|
+
encoding: 'utf-8',
|
|
593
|
+
});
|
|
587
594
|
}
|
|
588
595
|
catch {
|
|
589
596
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loicngr/kobo",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.6",
|
|
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",
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"@hono/node-server": "^1.19.13",
|
|
71
71
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
72
72
|
"better-sqlite3": "^12.8.0",
|
|
73
|
+
"cron-parser": "^5.5.0",
|
|
73
74
|
"hono": "^4.12.12",
|
|
74
75
|
"nanoid": "^5.1.7",
|
|
75
76
|
"node-pty": "^1.1.0",
|