@cleocode/core 2026.4.98 → 2026.4.99

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.
Files changed (59) hide show
  1. package/dist/gc/daemon-entry.d.ts +15 -0
  2. package/dist/gc/daemon-entry.d.ts.map +1 -0
  3. package/dist/gc/daemon.d.ts +71 -0
  4. package/dist/gc/daemon.d.ts.map +1 -0
  5. package/dist/gc/index.d.ts +14 -0
  6. package/dist/gc/index.d.ts.map +1 -0
  7. package/dist/gc/runner.d.ts +132 -0
  8. package/dist/gc/runner.d.ts.map +1 -0
  9. package/dist/gc/state.d.ts +94 -0
  10. package/dist/gc/state.d.ts.map +1 -0
  11. package/dist/gc/transcript.d.ts +130 -0
  12. package/dist/gc/transcript.d.ts.map +1 -0
  13. package/dist/sentient/daemon-entry.d.ts +11 -0
  14. package/dist/sentient/daemon-entry.d.ts.map +1 -0
  15. package/dist/sentient/daemon.d.ts +160 -0
  16. package/dist/sentient/daemon.d.ts.map +1 -0
  17. package/dist/sentient/index.d.ts +18 -0
  18. package/dist/sentient/index.d.ts.map +1 -0
  19. package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
  20. package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
  21. package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
  22. package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
  23. package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
  24. package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
  25. package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
  26. package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
  27. package/dist/sentient/propose-tick.d.ts +105 -0
  28. package/dist/sentient/propose-tick.d.ts.map +1 -0
  29. package/dist/sentient/state.d.ts +143 -0
  30. package/dist/sentient/state.d.ts.map +1 -0
  31. package/dist/sentient/tick.d.ts +193 -0
  32. package/dist/sentient/tick.d.ts.map +1 -0
  33. package/package.json +76 -8
  34. package/src/gc/__tests__/runner.test.ts +367 -0
  35. package/src/gc/__tests__/state.test.ts +169 -0
  36. package/src/gc/__tests__/transcript.test.ts +371 -0
  37. package/src/gc/daemon-entry.ts +26 -0
  38. package/src/gc/daemon.ts +251 -0
  39. package/src/gc/index.ts +14 -0
  40. package/src/gc/runner.ts +378 -0
  41. package/src/gc/state.ts +140 -0
  42. package/src/gc/transcript.ts +380 -0
  43. package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
  44. package/src/sentient/__tests__/daemon.test.ts +472 -0
  45. package/src/sentient/__tests__/dream-tick.test.ts +200 -0
  46. package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
  47. package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
  48. package/src/sentient/__tests__/propose-tick.test.ts +296 -0
  49. package/src/sentient/__tests__/test-ingester.test.ts +104 -0
  50. package/src/sentient/daemon-entry.ts +20 -0
  51. package/src/sentient/daemon.ts +471 -0
  52. package/src/sentient/index.ts +18 -0
  53. package/src/sentient/ingesters/brain-ingester.ts +122 -0
  54. package/src/sentient/ingesters/nexus-ingester.ts +171 -0
  55. package/src/sentient/ingesters/test-ingester.ts +205 -0
  56. package/src/sentient/proposal-rate-limiter.ts +172 -0
  57. package/src/sentient/propose-tick.ts +415 -0
  58. package/src/sentient/state.ts +229 -0
  59. package/src/sentient/tick.ts +688 -0
@@ -0,0 +1,688 @@
1
+ /**
2
+ * Sentient Loop Tick — Single-iteration tick runner for the Tier-1 daemon.
3
+ *
4
+ * A tick is one complete pass of:
5
+ * 1. Check killSwitch (abort if true)
6
+ * 2. Pick an unblocked task via @cleocode/core/sdk
7
+ * 3. Check killSwitch again (abort if true)
8
+ * 4. Spawn worker via `cleo orchestrate spawn <taskId> --adapter <adapter>`
9
+ * 5. Check killSwitch again before recording result
10
+ * 6. Record success (receipt + stats) or failure (retry/backoff)
11
+ *
12
+ * Each step re-reads the state file so that a killSwitch flipped mid-tick is
13
+ * honoured on the very next instruction (Round 2 audit §1: "mid-experiment
14
+ * kill limbo").
15
+ *
16
+ * Rate limit: driven by the cron schedule (`*\/5 * * * *` → ≤12 ticks/hour ≤
17
+ * 12 spawns/hour). No in-tick sleep is required — cron provides the cadence.
18
+ *
19
+ * Scoped OUT: Tier 2 (propose) and Tier 3 (sandbox auto-merge) per ADR-054.
20
+ *
21
+ * @task T946
22
+ * @see ADR-054 — Sentient Loop Tier-1
23
+ */
24
+
25
+ import { spawn } from 'node:child_process';
26
+ import type { Task } from '@cleocode/contracts';
27
+ import {
28
+ incrementStats,
29
+ patchSentientState,
30
+ readSentientState,
31
+ type SentientState,
32
+ type StuckTaskRecord,
33
+ } from './state.js';
34
+
35
+ // NOTE: `checkAndDream` is lazy-imported inside `maybeTriggerDream` to keep the
36
+ // test surface small — tests that don't exercise the dream path never load
37
+ // the brain.db stack.
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Dream-cycle trigger constants (T996)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Number of new brain observations since the last consolidation that causes
45
+ * the tick loop to trigger a dream cycle (volume tier).
46
+ * Configurable via the injected `dreamVolumeThreshold` option.
47
+ */
48
+ export const DREAM_VOLUME_THRESHOLD_DEFAULT = 50;
49
+
50
+ /**
51
+ * Number of consecutive no-task ticks before the idle dream trigger fires.
52
+ * Represents "N idle ticks" — when no task has been picked for this many
53
+ * consecutive ticks, the system is considered sufficiently idle.
54
+ */
55
+ export const DREAM_IDLE_TICKS_DEFAULT = 5;
56
+
57
+ // NOTE: `@cleocode/core/sdk` and `@cleocode/core/tasks` are lazy-imported
58
+ // inside the helpers that use them (`defaultPickTask`, writeSuccessReceipt,
59
+ // writeFailureReceipt). That keeps the test surface tiny — tests that inject
60
+ // their own `pickTask` / `spawn` never trigger the real SDK load.
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Constants
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /** Default adapter used when spawning workers. */
67
+ export const DEFAULT_ADAPTER = 'claude-code' as const;
68
+
69
+ /**
70
+ * Backoff delays between successive retries for the same task (milliseconds).
71
+ * Index n = delay before attempt n+1. After exhaustion the task is `stuck`.
72
+ * 30 s → 5 min → 30 min, then the task is marked stuck.
73
+ */
74
+ export const RETRY_BACKOFF_MS: readonly number[] = [30_000, 300_000, 1_800_000];
75
+
76
+ /**
77
+ * Maximum spawn attempts per task before it is classified as `stuck`.
78
+ * Matches RETRY_BACKOFF_MS.length but surfaced as a named constant for
79
+ * readability in tests and status output.
80
+ */
81
+ export const MAX_TASK_ATTEMPTS = RETRY_BACKOFF_MS.length;
82
+
83
+ /**
84
+ * Threshold for self-pause: if this many tasks become `stuck` within a
85
+ * rolling 1-hour window, the daemon flips killSwitch=true and exits.
86
+ */
87
+ export const SELF_PAUSE_STUCK_THRESHOLD = 5;
88
+
89
+ /** Rolling window (ms) used for stuck-rate calculation. */
90
+ export const SELF_PAUSE_WINDOW_MS = 60 * 60 * 1000;
91
+
92
+ /** Reason stored on the state file when self-pause fires. */
93
+ export const SELF_PAUSE_REASON = 'self-pause: 5 stuck tasks in 1 hour';
94
+
95
+ /** Max wall-clock time for a single spawn before forceful kill (30 min). */
96
+ export const SPAWN_TIMEOUT_MS = 30 * 60 * 1000;
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Tick outcome types
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /** Discriminant for the tick outcome. */
103
+ export type TickOutcomeKind =
104
+ | 'killed' // killSwitch was active at some checkpoint
105
+ | 'no-task' // no unblocked task available
106
+ | 'backoff' // a task is in retry backoff, skipped this tick
107
+ | 'success' // spawn exited 0
108
+ | 'failure' // spawn exited non-zero, retry scheduled
109
+ | 'stuck' // attempts exhausted, task marked stuck
110
+ | 'self-paused' // stuck-rate threshold tripped self-pause
111
+ | 'error'; // unexpected error in tick machinery itself
112
+
113
+ /** Structured outcome of a single tick. */
114
+ export interface TickOutcome {
115
+ /** Discriminant describing how the tick ended. */
116
+ kind: TickOutcomeKind;
117
+ /** Task id that was the subject of this tick (if any). */
118
+ taskId: string | null;
119
+ /** Human-readable detail (one line). */
120
+ detail: string;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Options
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /** Options for {@link runTick}. */
128
+ export interface TickOptions {
129
+ /** Absolute path to the project root (contains `.cleo/`). */
130
+ projectRoot: string;
131
+ /** Absolute path to sentient-state.json. */
132
+ statePath: string;
133
+ /**
134
+ * Adapter to pass to `cleo orchestrate spawn --adapter`. Defaults to
135
+ * {@link DEFAULT_ADAPTER}. Overridden in tests via options.spawn.
136
+ */
137
+ adapter?: string;
138
+ /**
139
+ * Dry-run mode: skip the actual `cleo orchestrate spawn` subprocess and
140
+ * treat the pick as a no-op (still records picked stat). Used by
141
+ * `cleo sentient tick --dry-run`.
142
+ */
143
+ dryRun?: boolean;
144
+ /**
145
+ * Override for the spawn function — lets tests inject a deterministic fake
146
+ * without forking real subprocesses. Must resolve to an exit code.
147
+ *
148
+ * When omitted, the default implementation spawns
149
+ * `cleo orchestrate spawn <taskId> --adapter <adapter>` and resolves with
150
+ * the child's exit code.
151
+ */
152
+ spawn?: (taskId: string, adapter: string) => Promise<SpawnResult>;
153
+ /**
154
+ * Override for the "pick next unblocked task" source. Lets tests return
155
+ * a deterministic task without constructing a SQLite fixture.
156
+ */
157
+ pickTask?: (projectRoot: string) => Promise<Task | null>;
158
+ /**
159
+ * New observation count since last consolidation that triggers the volume
160
+ * dream cycle. Defaults to {@link DREAM_VOLUME_THRESHOLD_DEFAULT}.
161
+ * Pass 0 to disable volume trigger. Injected by tests.
162
+ */
163
+ dreamVolumeThreshold?: number;
164
+ /**
165
+ * Number of consecutive no-task ticks before the idle dream trigger fires.
166
+ * Defaults to {@link DREAM_IDLE_TICKS_DEFAULT}.
167
+ * Pass 0 to disable idle trigger. Injected by tests.
168
+ */
169
+ dreamIdleTicks?: number;
170
+ /**
171
+ * Override for the dream trigger function — lets tests assert dream calls
172
+ * without touching the real brain.db stack.
173
+ * Signature mirrors `checkAndDream` from `@cleocode/core`.
174
+ */
175
+ checkAndDream?: (
176
+ projectRoot: string,
177
+ opts?: { volumeThreshold?: number; inline?: boolean },
178
+ ) => Promise<{ triggered: boolean; tier: string | null; skippedReason?: string }>;
179
+ }
180
+
181
+ /** Result of a spawn invocation. */
182
+ export interface SpawnResult {
183
+ /** Process exit code (0 = success). */
184
+ exitCode: number;
185
+ /** Captured stdout, truncated by the caller if needed. */
186
+ stdout: string;
187
+ /** Captured stderr, truncated by the caller if needed. */
188
+ stderr: string;
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Helpers
193
+ // ---------------------------------------------------------------------------
194
+
195
+ /**
196
+ * Fresh-load state and return true if killSwitch is active. Used at every
197
+ * checkpoint to avoid mid-tick kill limbo (Round 2 audit §1).
198
+ */
199
+ async function killSwitchActive(statePath: string): Promise<boolean> {
200
+ const state = await readSentientState(statePath);
201
+ return state.killSwitch === true;
202
+ }
203
+
204
+ /**
205
+ * Build the list of stuck timestamps that fall inside the rolling window.
206
+ */
207
+ function pruneStuckWindow(timestamps: readonly number[], now: number): number[] {
208
+ const cutoff = now - SELF_PAUSE_WINDOW_MS;
209
+ return timestamps.filter((t) => t >= cutoff);
210
+ }
211
+
212
+ /**
213
+ * Default SDK-backed task picker. Delegates to the orchestration domain via
214
+ * the @cleocode/core/sdk facade.
215
+ *
216
+ * Tier-1 scope: we pick any unblocked, non-proposed task regardless of which
217
+ * epic it belongs to — the picker walks the full task set to find the next
218
+ * actionable item.
219
+ */
220
+ async function defaultPickTask(projectRoot: string): Promise<Task | null> {
221
+ // Lazy import so unit tests that inject `pickTask` never trigger the SDK
222
+ // load (which pulls in the full @cleocode/core graph).
223
+ const { Cleo } = await import('@cleocode/core/sdk');
224
+ const { getReadyTasks } = await import('@cleocode/core/tasks');
225
+
226
+ const cleo = await Cleo.init(projectRoot);
227
+ // Use find() to get candidate tasks. We specifically avoid 'proposed' by
228
+ // only filtering on pending/active/blocked. getReadyTasks() from the
229
+ // dependency-check module is authoritative for "unblocked".
230
+ const pending = (await cleo.tasks.find({ status: 'pending', limit: 500 })) as {
231
+ success?: boolean;
232
+ data?: { tasks?: Task[] };
233
+ };
234
+ const candidates: Task[] = Array.isArray(pending?.data?.tasks) ? pending.data.tasks : [];
235
+ if (candidates.length === 0) return null;
236
+
237
+ const ready = getReadyTasks(candidates);
238
+ if (ready.length === 0) return null;
239
+
240
+ // Deterministic pick: lowest id wins (reproducible for tests).
241
+ ready.sort((a, b) => a.id.localeCompare(b.id));
242
+ return ready[0];
243
+ }
244
+
245
+ /**
246
+ * Default spawn implementation. Shells out to
247
+ * `cleo orchestrate spawn <taskId> --adapter <adapter>` and captures output.
248
+ *
249
+ * Note: we MUST shell out here — the spawn verb shells out to
250
+ * claude-code / gemini-cli / ollama as external tools. Using the SDK
251
+ * directly is not possible without re-implementing adapter dispatch.
252
+ */
253
+ function defaultSpawn(taskId: string, adapter: string, projectRoot: string): Promise<SpawnResult> {
254
+ return new Promise<SpawnResult>((resolve) => {
255
+ const args = ['orchestrate', 'spawn', taskId, '--adapter', adapter];
256
+ const child = spawn('cleo', args, {
257
+ cwd: projectRoot,
258
+ env: { ...process.env, CLEO_SENTIENT_SPAWN: '1' },
259
+ stdio: ['ignore', 'pipe', 'pipe'],
260
+ });
261
+
262
+ let stdout = '';
263
+ let stderr = '';
264
+
265
+ child.stdout?.on('data', (chunk: Buffer) => {
266
+ stdout += chunk.toString('utf-8');
267
+ });
268
+ child.stderr?.on('data', (chunk: Buffer) => {
269
+ stderr += chunk.toString('utf-8');
270
+ });
271
+
272
+ const timer = setTimeout(() => {
273
+ child.kill('SIGTERM');
274
+ }, SPAWN_TIMEOUT_MS);
275
+
276
+ child.on('error', (err: Error) => {
277
+ clearTimeout(timer);
278
+ resolve({
279
+ exitCode: 1,
280
+ stdout,
281
+ stderr: stderr + `\n[sentient] spawn error: ${err.message}`,
282
+ });
283
+ });
284
+ child.on('exit', (code) => {
285
+ clearTimeout(timer);
286
+ resolve({
287
+ exitCode: code ?? 1,
288
+ stdout: stdout.slice(-4000),
289
+ stderr: stderr.slice(-4000),
290
+ });
291
+ });
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Record a successful spawn to the brain via `memory.observe`.
297
+ * Swallows errors: receipt write must never break the tick.
298
+ */
299
+ async function writeSuccessReceipt(
300
+ projectRoot: string,
301
+ taskId: string,
302
+ exitCode: number,
303
+ ): Promise<void> {
304
+ try {
305
+ const { Cleo } = await import('@cleocode/core/sdk');
306
+ const cleo = await Cleo.init(projectRoot);
307
+ await cleo.memory.observe({
308
+ text: `sentient-tier1: task ${taskId} completed successfully (exit=${exitCode})`,
309
+ title: `sentient-receipt: ${taskId}`,
310
+ });
311
+ } catch {
312
+ // Receipt is best-effort; do not fail the tick.
313
+ }
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Dream-cycle trigger state (T996)
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /**
321
+ * Number of consecutive no-task ticks since the last successful task pick.
322
+ * Used by the idle dream trigger: when this counter reaches `dreamIdleTicks`,
323
+ * `checkAndDream` is called with the idle tier.
324
+ *
325
+ * Reset to 0 whenever a task is successfully picked.
326
+ */
327
+ let consecutiveIdleTicks = 0;
328
+
329
+ /**
330
+ * Evaluate volume + idle dream triggers and call `checkAndDream` when either
331
+ * fires. Errors are swallowed — dream trigger must never crash the tick.
332
+ *
333
+ * @param projectRoot - Project root for brain.db resolution.
334
+ * @param opts - Tick options (provides thresholds + injectable checkAndDream).
335
+ * @param pickedTask - Whether a task was picked this tick (resets idle counter).
336
+ */
337
+ async function maybeTriggerDream(
338
+ projectRoot: string,
339
+ opts: TickOptions,
340
+ pickedTask: boolean,
341
+ ): Promise<void> {
342
+ const volumeThreshold = opts.dreamVolumeThreshold ?? DREAM_VOLUME_THRESHOLD_DEFAULT;
343
+ const idleTicksThreshold = opts.dreamIdleTicks ?? DREAM_IDLE_TICKS_DEFAULT;
344
+
345
+ // Disable both triggers when thresholds are 0 (test escape hatch).
346
+ if (volumeThreshold <= 0 && idleTicksThreshold <= 0) return;
347
+
348
+ if (pickedTask) {
349
+ consecutiveIdleTicks = 0;
350
+ } else {
351
+ consecutiveIdleTicks += 1;
352
+ }
353
+
354
+ const dreamer =
355
+ opts.checkAndDream ??
356
+ (async (root: string, dreamerOpts?: { volumeThreshold?: number; inline?: boolean }) => {
357
+ const { checkAndDream } = await import('@cleocode/core/internal');
358
+ return checkAndDream(root, dreamerOpts);
359
+ });
360
+
361
+ try {
362
+ await dreamer(projectRoot, {
363
+ volumeThreshold: volumeThreshold > 0 ? volumeThreshold : undefined,
364
+ inline: false,
365
+ }).catch((err: unknown) => {
366
+ console.warn('[sentient/tick] dream trigger error:', err);
367
+ });
368
+ } catch (err) {
369
+ console.warn('[sentient/tick] dream trigger threw:', err);
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Record a failure to the brain via `memory.observe`.
375
+ * Swallows errors: receipt write must never break the tick.
376
+ */
377
+ async function writeFailureReceipt(
378
+ projectRoot: string,
379
+ taskId: string,
380
+ attempt: number,
381
+ exitCode: number,
382
+ reason: string,
383
+ ): Promise<void> {
384
+ try {
385
+ const { Cleo } = await import('@cleocode/core/sdk');
386
+ const cleo = await Cleo.init(projectRoot);
387
+ await cleo.memory.observe({
388
+ text:
389
+ `sentient-tier1: task ${taskId} failed (attempt=${attempt}/${MAX_TASK_ATTEMPTS}, ` +
390
+ `exit=${exitCode}). reason=${reason.slice(0, 500)}`,
391
+ title: `sentient-failure: ${taskId}`,
392
+ });
393
+ } catch {
394
+ // Receipt is best-effort.
395
+ }
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Public API
400
+ // ---------------------------------------------------------------------------
401
+
402
+ /**
403
+ * Run a single tick of the sentient loop.
404
+ *
405
+ * Every checkpoint re-reads state so that a killSwitch flipped mid-tick is
406
+ * honoured on the next instruction.
407
+ *
408
+ * @param options - Tick options (see {@link TickOptions})
409
+ * @returns Structured outcome describing how the tick ended.
410
+ */
411
+ export async function runTick(options: TickOptions): Promise<TickOutcome> {
412
+ const { projectRoot, statePath } = options;
413
+ const adapter = options.adapter ?? DEFAULT_ADAPTER;
414
+ const now = Date.now();
415
+
416
+ // -- Checkpoint 1: killSwitch before any work ------------------------------
417
+ if (await killSwitchActive(statePath)) {
418
+ await incrementStats(statePath, { ticksKilled: 1 });
419
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
420
+ return { kind: 'killed', taskId: null, detail: 'killSwitch active before pick' };
421
+ }
422
+
423
+ // -- Pick next unblocked task ---------------------------------------------
424
+ const picker = options.pickTask ?? defaultPickTask;
425
+ let task: Task | null;
426
+ try {
427
+ task = await picker(projectRoot);
428
+ } catch (err) {
429
+ const message = err instanceof Error ? err.message : String(err);
430
+ await incrementStats(statePath, { ticksExecuted: 1 });
431
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
432
+ return { kind: 'error', taskId: null, detail: `picker threw: ${message}` };
433
+ }
434
+
435
+ if (task === null) {
436
+ await incrementStats(statePath, { ticksExecuted: 1 });
437
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
438
+ return { kind: 'no-task', taskId: null, detail: 'no unblocked tasks available' };
439
+ }
440
+
441
+ // -- Respect per-task backoff ---------------------------------------------
442
+ const preSpawnState = await readSentientState(statePath);
443
+ const existingStuck: StuckTaskRecord | undefined = preSpawnState.stuckTasks[task.id];
444
+ if (existingStuck && existingStuck.nextRetryAt > now) {
445
+ await incrementStats(statePath, { ticksExecuted: 1 });
446
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
447
+ return {
448
+ kind: 'backoff',
449
+ taskId: task.id,
450
+ detail: `task ${task.id} in backoff until ${new Date(existingStuck.nextRetryAt).toISOString()}`,
451
+ };
452
+ }
453
+
454
+ // -- Checkpoint 2: killSwitch before spawn --------------------------------
455
+ if (await killSwitchActive(statePath)) {
456
+ await incrementStats(statePath, { ticksKilled: 1 });
457
+ await patchSentientState(statePath, { lastTickAt: new Date(now).toISOString() });
458
+ return { kind: 'killed', taskId: task.id, detail: 'killSwitch active before spawn' };
459
+ }
460
+
461
+ // -- Mark task active ------------------------------------------------------
462
+ await incrementStats(statePath, { tasksPicked: 1 });
463
+ await patchSentientState(statePath, { activeTaskId: task.id });
464
+
465
+ // -- Spawn worker ---------------------------------------------------------
466
+ let spawnResult: SpawnResult;
467
+ if (options.dryRun === true) {
468
+ spawnResult = {
469
+ exitCode: 0,
470
+ stdout: '[dry-run] spawn skipped',
471
+ stderr: '',
472
+ };
473
+ } else {
474
+ try {
475
+ const spawner = options.spawn ?? ((tid, adp) => defaultSpawn(tid, adp, projectRoot));
476
+ spawnResult = await spawner(task.id, adapter);
477
+ } catch (err) {
478
+ const message = err instanceof Error ? err.message : String(err);
479
+ spawnResult = { exitCode: 1, stdout: '', stderr: `spawn threw: ${message}` };
480
+ }
481
+ }
482
+
483
+ // -- Checkpoint 3: killSwitch before recording ----------------------------
484
+ if (await killSwitchActive(statePath)) {
485
+ await incrementStats(statePath, { ticksKilled: 1 });
486
+ await patchSentientState(statePath, {
487
+ lastTickAt: new Date(Date.now()).toISOString(),
488
+ activeTaskId: null,
489
+ });
490
+ return {
491
+ kind: 'killed',
492
+ taskId: task.id,
493
+ detail: 'killSwitch active after spawn; result not recorded',
494
+ };
495
+ }
496
+
497
+ // -- Classify + record -----------------------------------------------------
498
+ if (spawnResult.exitCode === 0) {
499
+ await writeSuccessReceipt(projectRoot, task.id, spawnResult.exitCode);
500
+ // Clear stuck entry on success.
501
+ const post = await readSentientState(statePath);
502
+ const { [task.id]: _removed, ...rest } = post.stuckTasks;
503
+ void _removed;
504
+ await patchSentientState(statePath, {
505
+ stuckTasks: rest,
506
+ activeTaskId: null,
507
+ lastTickAt: new Date(Date.now()).toISOString(),
508
+ });
509
+ await incrementStats(statePath, { tasksCompleted: 1, ticksExecuted: 1 });
510
+ return {
511
+ kind: 'success',
512
+ taskId: task.id,
513
+ detail: `task ${task.id} completed (exit=0)`,
514
+ };
515
+ }
516
+
517
+ // -- Failure path: increment attempts, record backoff or stuck -----------
518
+ const currentAttempts = existingStuck?.attempts ?? 0;
519
+ const nextAttempts = currentAttempts + 1;
520
+ const failureReason = spawnResult.stderr.slice(-500) || `exit=${spawnResult.exitCode}`;
521
+
522
+ await writeFailureReceipt(
523
+ projectRoot,
524
+ task.id,
525
+ nextAttempts,
526
+ spawnResult.exitCode,
527
+ failureReason,
528
+ );
529
+ await incrementStats(statePath, { tasksFailed: 1, ticksExecuted: 1 });
530
+
531
+ if (nextAttempts >= MAX_TASK_ATTEMPTS) {
532
+ // Mark task stuck. Record timestamp in rolling window; self-pause if ≥ threshold.
533
+ const windowed = pruneStuckWindow(preSpawnState.stuckTimestamps, now);
534
+ windowed.push(now);
535
+
536
+ const stuckRecord: StuckTaskRecord = {
537
+ attempts: nextAttempts,
538
+ lastFailureAt: new Date(now).toISOString(),
539
+ nextRetryAt: Number.MAX_SAFE_INTEGER, // owner-only release
540
+ lastReason: failureReason,
541
+ };
542
+
543
+ const post = await readSentientState(statePath);
544
+ const updatedStuckTasks: Record<string, StuckTaskRecord> = {
545
+ ...post.stuckTasks,
546
+ [task.id]: stuckRecord,
547
+ };
548
+
549
+ const shouldSelfPause = windowed.length >= SELF_PAUSE_STUCK_THRESHOLD;
550
+
551
+ await patchSentientState(statePath, {
552
+ stuckTasks: updatedStuckTasks,
553
+ stuckTimestamps: windowed,
554
+ activeTaskId: null,
555
+ lastTickAt: new Date(now).toISOString(),
556
+ ...(shouldSelfPause ? { killSwitch: true, killSwitchReason: SELF_PAUSE_REASON } : {}),
557
+ });
558
+
559
+ if (shouldSelfPause) {
560
+ return {
561
+ kind: 'self-paused',
562
+ taskId: task.id,
563
+ detail:
564
+ `task ${task.id} is stuck; self-pause fired ` +
565
+ `(${windowed.length}/${SELF_PAUSE_STUCK_THRESHOLD} stucks in window)`,
566
+ };
567
+ }
568
+
569
+ return {
570
+ kind: 'stuck',
571
+ taskId: task.id,
572
+ detail:
573
+ `task ${task.id} stuck after ${nextAttempts} attempts; ` +
574
+ `owner must re-enable via \`cleo sentient resume\``,
575
+ };
576
+ }
577
+
578
+ // Schedule next retry with backoff.
579
+ const backoff =
580
+ RETRY_BACKOFF_MS[nextAttempts - 1] ?? RETRY_BACKOFF_MS[RETRY_BACKOFF_MS.length - 1];
581
+ const stuckRecord: StuckTaskRecord = {
582
+ attempts: nextAttempts,
583
+ lastFailureAt: new Date(now).toISOString(),
584
+ nextRetryAt: now + backoff,
585
+ lastReason: failureReason,
586
+ };
587
+ const post = await readSentientState(statePath);
588
+ await patchSentientState(statePath, {
589
+ stuckTasks: { ...post.stuckTasks, [task.id]: stuckRecord },
590
+ activeTaskId: null,
591
+ lastTickAt: new Date(now).toISOString(),
592
+ });
593
+
594
+ return {
595
+ kind: 'failure',
596
+ taskId: task.id,
597
+ detail:
598
+ `task ${task.id} failed (attempt=${nextAttempts}/${MAX_TASK_ATTEMPTS}); ` +
599
+ `retry scheduled at ${new Date(now + backoff).toISOString()}`,
600
+ };
601
+ }
602
+
603
+ /**
604
+ * Convenience wrapper used by the daemon cron handler and the `cleo sentient
605
+ * tick` CLI command. Reads state, runs a tick, swallows any unexpected
606
+ * exception so the cron scheduler never sees a rejection.
607
+ *
608
+ * After the tick completes, evaluates volume + idle dream triggers via
609
+ * {@link maybeTriggerDream}. Dream errors are swallowed independently so
610
+ * they never affect the tick outcome.
611
+ *
612
+ * @param options - Tick options
613
+ * @returns The tick outcome (or an `error` outcome if the tick itself threw).
614
+ */
615
+ export async function safeRunTick(options: TickOptions): Promise<TickOutcome> {
616
+ let outcome: TickOutcome;
617
+ try {
618
+ outcome = await runTick(options);
619
+ } catch (err) {
620
+ const message = err instanceof Error ? err.message : String(err);
621
+ try {
622
+ await incrementStats(options.statePath, { ticksExecuted: 1 });
623
+ } catch {
624
+ // ignore
625
+ }
626
+ outcome = { kind: 'error', taskId: null, detail: `tick threw: ${message}` };
627
+ }
628
+
629
+ // Dream trigger: fire volume + idle checks after every tick.
630
+ // A task was "picked" when the tick progressed past the no-task check
631
+ // (i.e. kind is not 'no-task', 'killed', or 'error').
632
+ const pickedTask =
633
+ outcome.kind !== 'no-task' &&
634
+ outcome.kind !== 'killed' &&
635
+ outcome.kind !== 'error' &&
636
+ outcome.taskId !== null;
637
+
638
+ await maybeTriggerDream(options.projectRoot, options, pickedTask).catch(() => {
639
+ // Dream errors must never propagate to the tick caller.
640
+ });
641
+
642
+ return outcome;
643
+ }
644
+
645
+ /**
646
+ * Type-narrowing helper used by tests and status rendering to identify tick
647
+ * outcomes that consumed a retry attempt.
648
+ */
649
+ export function isFailureOutcome(
650
+ outcome: TickOutcome,
651
+ ): outcome is TickOutcome & { kind: 'failure' | 'stuck' | 'self-paused' } {
652
+ return outcome.kind === 'failure' || outcome.kind === 'stuck' || outcome.kind === 'self-paused';
653
+ }
654
+
655
+ /**
656
+ * Returns a shallow view of the current state's kill status.
657
+ * Exposed for diagnostic/test consumers.
658
+ */
659
+ export async function getKillStatus(
660
+ statePath: string,
661
+ ): Promise<Pick<SentientState, 'killSwitch' | 'killSwitchReason'>> {
662
+ const state = await readSentientState(statePath);
663
+ return { killSwitch: state.killSwitch, killSwitchReason: state.killSwitchReason };
664
+ }
665
+
666
+ /**
667
+ * Reset dream-cycle in-process state.
668
+ *
669
+ * Intended for test teardown only — clears `consecutiveIdleTicks` so that
670
+ * successive test cases start from a clean slate.
671
+ *
672
+ * @internal
673
+ */
674
+ export function _resetDreamTickState(): void {
675
+ consecutiveIdleTicks = 0;
676
+ }
677
+
678
+ /**
679
+ * Return the current consecutive-idle-tick counter value.
680
+ *
681
+ * Read-only accessor for test assertions. The counter is reset to 0 whenever
682
+ * a task is picked, and incremented on each no-task tick.
683
+ *
684
+ * @internal
685
+ */
686
+ export function _getConsecutiveIdleTicks(): number {
687
+ return consecutiveIdleTicks;
688
+ }