@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.
- package/dist/gc/daemon-entry.d.ts +15 -0
- package/dist/gc/daemon-entry.d.ts.map +1 -0
- package/dist/gc/daemon.d.ts +71 -0
- package/dist/gc/daemon.d.ts.map +1 -0
- package/dist/gc/index.d.ts +14 -0
- package/dist/gc/index.d.ts.map +1 -0
- package/dist/gc/runner.d.ts +132 -0
- package/dist/gc/runner.d.ts.map +1 -0
- package/dist/gc/state.d.ts +94 -0
- package/dist/gc/state.d.ts.map +1 -0
- package/dist/gc/transcript.d.ts +130 -0
- package/dist/gc/transcript.d.ts.map +1 -0
- package/dist/sentient/daemon-entry.d.ts +11 -0
- package/dist/sentient/daemon-entry.d.ts.map +1 -0
- package/dist/sentient/daemon.d.ts +160 -0
- package/dist/sentient/daemon.d.ts.map +1 -0
- package/dist/sentient/index.d.ts +18 -0
- package/dist/sentient/index.d.ts.map +1 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts +44 -0
- package/dist/sentient/ingesters/brain-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts +45 -0
- package/dist/sentient/ingesters/nexus-ingester.d.ts.map +1 -0
- package/dist/sentient/ingesters/test-ingester.d.ts +43 -0
- package/dist/sentient/ingesters/test-ingester.d.ts.map +1 -0
- package/dist/sentient/proposal-rate-limiter.d.ts +93 -0
- package/dist/sentient/proposal-rate-limiter.d.ts.map +1 -0
- package/dist/sentient/propose-tick.d.ts +105 -0
- package/dist/sentient/propose-tick.d.ts.map +1 -0
- package/dist/sentient/state.d.ts +143 -0
- package/dist/sentient/state.d.ts.map +1 -0
- package/dist/sentient/tick.d.ts +193 -0
- package/dist/sentient/tick.d.ts.map +1 -0
- package/package.json +76 -8
- package/src/gc/__tests__/runner.test.ts +367 -0
- package/src/gc/__tests__/state.test.ts +169 -0
- package/src/gc/__tests__/transcript.test.ts +371 -0
- package/src/gc/daemon-entry.ts +26 -0
- package/src/gc/daemon.ts +251 -0
- package/src/gc/index.ts +14 -0
- package/src/gc/runner.ts +378 -0
- package/src/gc/state.ts +140 -0
- package/src/gc/transcript.ts +380 -0
- package/src/sentient/__tests__/brain-ingester.test.ts +154 -0
- package/src/sentient/__tests__/daemon.test.ts +472 -0
- package/src/sentient/__tests__/dream-tick.test.ts +200 -0
- package/src/sentient/__tests__/nexus-ingester.test.ts +138 -0
- package/src/sentient/__tests__/proposal-rate-limiter.test.ts +247 -0
- package/src/sentient/__tests__/propose-tick.test.ts +296 -0
- package/src/sentient/__tests__/test-ingester.test.ts +104 -0
- package/src/sentient/daemon-entry.ts +20 -0
- package/src/sentient/daemon.ts +471 -0
- package/src/sentient/index.ts +18 -0
- package/src/sentient/ingesters/brain-ingester.ts +122 -0
- package/src/sentient/ingesters/nexus-ingester.ts +171 -0
- package/src/sentient/ingesters/test-ingester.ts +205 -0
- package/src/sentient/proposal-rate-limiter.ts +172 -0
- package/src/sentient/propose-tick.ts +415 -0
- package/src/sentient/state.ts +229 -0
- 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
|
+
}
|