@north-light/crouter 0.3.253 → 0.3.254
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/clients/attach/__tests__/group-activity.test.js +81 -0
- package/dist/clients/attach/render/group-activity.d.ts +9 -1
- package/dist/clients/attach/render/group-activity.js +75 -111
- package/dist/clients/attach/render/group-recap.js +24 -9
- package/dist/clients/attach/viewer.js +508 -509
- package/dist/commands/memory/delete.js +2 -0
- package/dist/core/__tests__/daemon-boot.test.js +5 -5
- package/dist/core/__tests__/preview-mirror-cap.test.js +16 -0
- package/dist/core/exclusive-lock.js +13 -2
- package/dist/core/io.d.ts +11 -2
- package/dist/core/io.js +10 -11
- package/dist/core/substrate/surface-match.js +7 -0
- package/dist/daemon/__tests__/autostart-storm.test.d.ts +1 -0
- package/dist/daemon/__tests__/autostart-storm.test.js +166 -0
- package/dist/daemon/__tests__/integration/migration-startup.test.js +4 -7
- package/dist/daemon/crtrd-cli.js +15 -1
- package/dist/daemon/crtrd.js +14 -1
- package/dist/daemon/manage.d.ts +24 -3
- package/dist/daemon/manage.js +98 -12
- package/dist/daemon/startup-policy.d.ts +5 -0
- package/dist/daemon/startup-policy.js +5 -0
- package/dist/migrations/activation.d.ts +9 -0
- package/dist/migrations/activation.js +14 -1
- package/dist/pi-extensions/canvas-bash-valve.js +4 -0
- package/dist/pi-extensions/canvas-preview-result.js +32 -7
- package/dist/shared/generated-context.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
package/dist/daemon/manage.js
CHANGED
|
@@ -14,7 +14,7 @@ import { hostExecPath } from '../core/runtime/branded-host.js';
|
|
|
14
14
|
// Pulled from the lean db-free pidfile module (NOT crtrd.js, whose module graph
|
|
15
15
|
// reaches openDb) so the CLI daemon front door stays canvas.db-free (plan B-0).
|
|
16
16
|
import { isDaemonRunning, readPidfile, isPidAlive } from './pidfile.js';
|
|
17
|
-
import { DAEMON_STARTUP_WINDOW_MS } from './startup-policy.js';
|
|
17
|
+
import { DAEMON_EXIT_STARTUP_BLOCKED, DAEMON_STARTUP_WINDOW_MS } from './startup-policy.js';
|
|
18
18
|
import { envNoDaemonAutostart } from '../shared/env.js';
|
|
19
19
|
import { CrtrClient } from '../api/index.js';
|
|
20
20
|
// Daemon env sanitization
|
|
@@ -225,6 +225,16 @@ function sleepMs(ms) {
|
|
|
225
225
|
setTimeout(resolve, ms);
|
|
226
226
|
});
|
|
227
227
|
}
|
|
228
|
+
/** Thrown when the spawned daemon exited on `DAEMON_EXIT_STARTUP_BLOCKED` — a
|
|
229
|
+
* standing condition (a blocked on-disk migration) that the very next spawn
|
|
230
|
+
* would hit identically. Callers must stop retrying and surface the repair;
|
|
231
|
+
* the daemon has already written the detail to `crtrd.err`. */
|
|
232
|
+
export class DaemonStartupBlockedError extends Error {
|
|
233
|
+
constructor(pid) {
|
|
234
|
+
super(`daemon ${pid} exited: startup is blocked by a standing condition that retrying cannot clear. See crtrd.err, then run \`crtr sys migrate\`.`);
|
|
235
|
+
this.name = 'DaemonStartupBlockedError';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
228
238
|
/** Tiny bounded post-spawn guard: wait for the daemon pidfile and live pid to
|
|
229
239
|
* appear before reporting success. This catches the "started:true but not yet
|
|
230
240
|
* plausibly alive" race without turning startup into a retry loop.
|
|
@@ -255,6 +265,9 @@ export async function verifyDaemonStartup(pid, timeoutMs = DAEMON_STARTUP_WINDOW
|
|
|
255
265
|
if (exitState.signal !== null) {
|
|
256
266
|
throw new Error(`daemon ${pid} exited before becoming ready (by ${exitState.signal})`);
|
|
257
267
|
}
|
|
268
|
+
if (exitState.code === DAEMON_EXIT_STARTUP_BLOCKED) {
|
|
269
|
+
throw new DaemonStartupBlockedError(pid);
|
|
270
|
+
}
|
|
258
271
|
if (exitState.code !== 0) {
|
|
259
272
|
throw new Error(`daemon ${pid} exited before becoming ready (with exit code ${exitState.code ?? '?'})`);
|
|
260
273
|
}
|
|
@@ -286,6 +299,9 @@ export async function waitForDaemonExit(pid, timeoutMs = DAEMON_SHUTDOWN_WINDOW_
|
|
|
286
299
|
* `stopDaemonProcess` gives up. A killed process is reaped by the kernel, so
|
|
287
300
|
* this only absorbs scheduling latency (or an uninterruptible wait). */
|
|
288
301
|
const DAEMON_KILL_WINDOW_MS = 2_000;
|
|
302
|
+
/** How long a failed-to-start daemon gets to go down before the reap gives up.
|
|
303
|
+
* Short on purpose: nothing is waiting on this process any more. */
|
|
304
|
+
const DAEMON_REAP_WINDOW_MS = 5_000;
|
|
289
305
|
/** Stop the RECORDED daemon: SIGTERM, and if it does not exit within the wait
|
|
290
306
|
* window, escalate to SIGKILL exactly as `sweepStrayDaemons` does for a stray.
|
|
291
307
|
*
|
|
@@ -390,9 +406,29 @@ export async function spawnDaemon() {
|
|
|
390
406
|
exitState = { code, signal };
|
|
391
407
|
});
|
|
392
408
|
child.unref();
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
409
|
+
// Reap on failure. verifyDaemonStartup gives up at its deadline but the child
|
|
410
|
+
// it was watching is still running — and a daemon that failed to become ready
|
|
411
|
+
// is typically parked deep in startup (blocked on the migration lock, whose
|
|
412
|
+
// own window is DAEMON_STARTUP_WINDOW_MS), so it lingers long after the
|
|
413
|
+
// spawner walked away. Leaving it is what let repeated autostarts ACCUMULATE
|
|
414
|
+
// instead of merely repeating: each attempt added a resident process that
|
|
415
|
+
// outlived the attempt, and the machine went down under the pile rather than
|
|
416
|
+
// under any single failure.
|
|
417
|
+
let existingPid;
|
|
418
|
+
try {
|
|
419
|
+
existingPid = await verifyDaemonStartup(pid, DAEMON_STARTUP_WINDOW_MS, {
|
|
420
|
+
childExited: () => exitState,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
if (exitState === null) {
|
|
425
|
+
try {
|
|
426
|
+
await stopDaemonProcess(pid, DAEMON_REAP_WINDOW_MS);
|
|
427
|
+
}
|
|
428
|
+
catch { /* best effort */ }
|
|
429
|
+
}
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
396
432
|
if (existingPid !== null) {
|
|
397
433
|
return { started: false, existing_pid: existingPid };
|
|
398
434
|
}
|
|
@@ -415,17 +451,67 @@ export async function spawnDaemon() {
|
|
|
415
451
|
export function autostartDisabled() {
|
|
416
452
|
return envNoDaemonAutostart() || process.argv.includes('--no-autostart');
|
|
417
453
|
}
|
|
418
|
-
|
|
419
|
-
|
|
454
|
+
// Autostart rate limiting. `ensureDaemon` is the one implicit spawn path and is
|
|
455
|
+
// reached on every cold socket — including each redial of a long-lived attach
|
|
456
|
+
// client. Unguarded that is an open loop: a daemon that cannot start is retried
|
|
457
|
+
// forever, by every client, with nothing tracking that the last attempt failed
|
|
458
|
+
// for a reason the next one will hit too.
|
|
459
|
+
const AUTOSTART_BACKOFF_BASE_MS = 5_000;
|
|
460
|
+
const AUTOSTART_BACKOFF_MAX_MS = 60_000;
|
|
461
|
+
let autostartInFlight = false;
|
|
462
|
+
let autostartFailures = 0;
|
|
463
|
+
let autostartNextAttemptAt = 0;
|
|
464
|
+
let autostartBlocked = null;
|
|
465
|
+
function autostartBackoffMs(failures) {
|
|
466
|
+
return Math.min(AUTOSTART_BACKOFF_BASE_MS * 2 ** (failures - 1), AUTOSTART_BACKOFF_MAX_MS);
|
|
467
|
+
}
|
|
468
|
+
/** Why implicit autostart is currently suppressed, or null when it is free to
|
|
469
|
+
* run. Set only by a `DaemonStartupBlockedError`, which no retry can clear —
|
|
470
|
+
* an explicit `crtr sys daemon start` still calls `spawnDaemon` directly and
|
|
471
|
+
* reports the failure in full. */
|
|
472
|
+
export function daemonAutostartSuppression() {
|
|
473
|
+
return autostartBlocked;
|
|
474
|
+
}
|
|
475
|
+
/** Clear the suppression and the backoff — for a caller that has repaired the
|
|
476
|
+
* standing condition and wants autostart live again without a new process. */
|
|
477
|
+
export function resetDaemonAutostart() {
|
|
478
|
+
autostartFailures = 0;
|
|
479
|
+
autostartNextAttemptAt = 0;
|
|
480
|
+
autostartBlocked = null;
|
|
481
|
+
}
|
|
482
|
+
/** Start the daemon if it is not already running. No-op if already up, if
|
|
483
|
+
* autostart is suppressed for this invocation, while an attempt is in flight,
|
|
484
|
+
* or while backing off from a recent failure.
|
|
420
485
|
* Silently swallows spawn errors (the canvas still works without the daemon;
|
|
421
486
|
* nodes just won't be auto-revived). */
|
|
422
|
-
export function ensureDaemon() {
|
|
487
|
+
export function ensureDaemon(deps = {}) {
|
|
488
|
+
const running = deps.isDaemonRunning ?? isDaemonRunning;
|
|
489
|
+
const spawn = deps.spawnDaemon ?? spawnDaemon;
|
|
490
|
+
const now = deps.now ?? Date.now;
|
|
423
491
|
if (autostartDisabled())
|
|
424
492
|
return;
|
|
425
|
-
if (
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
493
|
+
if (autostartBlocked !== null)
|
|
494
|
+
return;
|
|
495
|
+
if (autostartInFlight)
|
|
496
|
+
return;
|
|
497
|
+
if (now() < autostartNextAttemptAt)
|
|
498
|
+
return;
|
|
499
|
+
if (running()) {
|
|
500
|
+
resetDaemonAutostart();
|
|
501
|
+
return;
|
|
430
502
|
}
|
|
503
|
+
autostartInFlight = true;
|
|
504
|
+
void spawn().then(() => {
|
|
505
|
+
autostartFailures = 0;
|
|
506
|
+
autostartNextAttemptAt = 0;
|
|
507
|
+
}, (error) => {
|
|
508
|
+
// Intentionally silent — a missing dist/daemon/crtrd-cli.js (dev mode,
|
|
509
|
+
// pre-build) must not break the calling command.
|
|
510
|
+
autostartFailures += 1;
|
|
511
|
+
autostartNextAttemptAt = now() + autostartBackoffMs(autostartFailures);
|
|
512
|
+
if (error instanceof DaemonStartupBlockedError)
|
|
513
|
+
autostartBlocked = error.message;
|
|
514
|
+
}).finally(() => {
|
|
515
|
+
autostartInFlight = false;
|
|
516
|
+
});
|
|
431
517
|
}
|
|
@@ -1 +1,6 @@
|
|
|
1
1
|
export declare const DAEMON_STARTUP_WINDOW_MS: number;
|
|
2
|
+
/** Exit code a daemon uses when startup hit a STANDING condition that retrying
|
|
3
|
+
* cannot clear (today: a blocked on-disk migration). Distinct from a generic
|
|
4
|
+
* failure so the spawner can stop retrying and surface the repair instead of
|
|
5
|
+
* respawning into the same wall. 78 is sysexits' EX_CONFIG. */
|
|
6
|
+
export declare const DAEMON_EXIT_STARTUP_BLOCKED = 78;
|
|
@@ -1 +1,6 @@
|
|
|
1
1
|
export const DAEMON_STARTUP_WINDOW_MS = 5 * 60 * 1_000;
|
|
2
|
+
/** Exit code a daemon uses when startup hit a STANDING condition that retrying
|
|
3
|
+
* cannot clear (today: a blocked on-disk migration). Distinct from a generic
|
|
4
|
+
* failure so the spawner can stop retrying and surface the repair instead of
|
|
5
|
+
* respawning into the same wall. 78 is sysexits' EX_CONFIG. */
|
|
6
|
+
export const DAEMON_EXIT_STARTUP_BLOCKED = 78;
|
|
@@ -8,6 +8,15 @@ export interface ForcedMigrationOptions extends CoordinationOptions {
|
|
|
8
8
|
dryRun?: boolean;
|
|
9
9
|
explicitDirs?: readonly string[];
|
|
10
10
|
}
|
|
11
|
+
/** A migration blocker is a STANDING condition, not a transient fault: the same
|
|
12
|
+
* corpus produces the same blockers on every boot, so a caller that retries a
|
|
13
|
+
* daemon start against one retries forever. Typing it lets the startup path
|
|
14
|
+
* tell "try again" apart from "this needs a human", and carry the repair line
|
|
15
|
+
* to whoever can act on it. */
|
|
16
|
+
export declare class MigrationBlockedError extends Error {
|
|
17
|
+
readonly blockerPaths: readonly string[];
|
|
18
|
+
constructor(message: string, blockerPaths: readonly string[]);
|
|
19
|
+
}
|
|
11
20
|
/** Run startup migration only when this home lacks the current completion marker. */
|
|
12
21
|
export declare function activateOnDiskMigrations(options?: CoordinationOptions): Promise<OnDiskMigrationRunResult | null>;
|
|
13
22
|
export declare function ensureOnDiskMigrations(): Promise<void>;
|
|
@@ -20,6 +20,19 @@ function blockerPaths(result) {
|
|
|
20
20
|
...(blocker.storeRoot === undefined ? [] : [blocker.storeRoot]),
|
|
21
21
|
]))];
|
|
22
22
|
}
|
|
23
|
+
/** A migration blocker is a STANDING condition, not a transient fault: the same
|
|
24
|
+
* corpus produces the same blockers on every boot, so a caller that retries a
|
|
25
|
+
* daemon start against one retries forever. Typing it lets the startup path
|
|
26
|
+
* tell "try again" apart from "this needs a human", and carry the repair line
|
|
27
|
+
* to whoever can act on it. */
|
|
28
|
+
export class MigrationBlockedError extends Error {
|
|
29
|
+
blockerPaths;
|
|
30
|
+
constructor(message, blockerPaths) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'MigrationBlockedError';
|
|
33
|
+
this.blockerPaths = blockerPaths;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
23
36
|
function activationError(result) {
|
|
24
37
|
const paths = blockerPaths(result);
|
|
25
38
|
const findings = result.blockers.map((blocker) => `${blocker.kind}: ${blocker.message}`);
|
|
@@ -37,7 +50,7 @@ function activationError(result) {
|
|
|
37
50
|
if (writes.length > 0)
|
|
38
51
|
lines.push(`Writes completed before failure: ${writes.join(', ')}`);
|
|
39
52
|
lines.push('Repair: crtr sys migrate');
|
|
40
|
-
return new
|
|
53
|
+
return new MigrationBlockedError(lines.join('\n'), paths);
|
|
41
54
|
}
|
|
42
55
|
async function coordinate(mode, options) {
|
|
43
56
|
const lockPath = onDiskMigrationLockPath();
|
|
@@ -322,6 +322,10 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
|
|
|
322
322
|
let childEnv = env ?? process.env;
|
|
323
323
|
if (previewPath !== undefined) {
|
|
324
324
|
mkdirSync(dirname(previewPath), { recursive: true });
|
|
325
|
+
// crtr APPENDS one JSONL record per invocation, so this call starts
|
|
326
|
+
// from an empty file: a path left behind by a call whose result event
|
|
327
|
+
// never arrived must not fold its records into this one's.
|
|
328
|
+
rmSync(previewPath, { force: true });
|
|
325
329
|
childEnv = { ...childEnv, [PREVIEW_RESULT_PATH_ENV]: previewPath };
|
|
326
330
|
}
|
|
327
331
|
const shellPath = resolveAdmittedHostCommand('bash', childEnv, execution.cwd);
|
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A crtr leaf returns a record, but the CLI normally renders that record to
|
|
4
4
|
// stdout for the agent. stdout is model context, so the bash valve hands every
|
|
5
|
-
// bash call a private file path in the child environment, crtr
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// bash call a private file path in the child environment, crtr mirrors its
|
|
6
|
+
// record there, and this hook attaches the record to Pi's ToolResult.details.
|
|
7
|
+
// The attach viewer can render a structured preview without parsing the
|
|
8
|
+
// agent-facing text.
|
|
9
|
+
//
|
|
10
|
+
// One bash call may run several crtr invocations, so the file is JSONL — one
|
|
11
|
+
// record per invocation, in order. Two fields are attached: `crtrPreview` is
|
|
12
|
+
// the LAST record, the single-object shape every already-persisted transcript
|
|
13
|
+
// and the inline command card were written against; `crtrPreviews` is the full
|
|
14
|
+
// list, for consumers that must see every invocation in the call.
|
|
9
15
|
import { envNodeId } from '../shared/env.js';
|
|
10
16
|
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
11
17
|
import { previewResultPath } from '../core/preview-result-path.js';
|
|
@@ -17,6 +23,24 @@ function isPreviewRecord(value) {
|
|
|
17
23
|
|| (Array.isArray(record.jsonl) && record.jsonl.every((item) => item !== null && typeof item === 'object' && !Array.isArray(item)))
|
|
18
24
|
|| (record.error !== undefined && record.error !== null && typeof record.error === 'object' && !Array.isArray(record.error));
|
|
19
25
|
}
|
|
26
|
+
/** Every valid record in the mirror file. A single-object file (every record
|
|
27
|
+
* written before the channel became JSONL) parses as one line, so historical
|
|
28
|
+
* transcripts and a mid-upgrade broker read identically. One malformed line is
|
|
29
|
+
* skipped rather than discarding the invocations around it. */
|
|
30
|
+
function readPreviewRecords(path) {
|
|
31
|
+
const records = [];
|
|
32
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
33
|
+
if (line.trim() === '')
|
|
34
|
+
continue;
|
|
35
|
+
try {
|
|
36
|
+
const record = JSON.parse(line);
|
|
37
|
+
if (isPreviewRecord(record))
|
|
38
|
+
records.push(record);
|
|
39
|
+
}
|
|
40
|
+
catch { /* one unreadable record never costs the others */ }
|
|
41
|
+
}
|
|
42
|
+
return records;
|
|
43
|
+
}
|
|
20
44
|
/** Loaded into every canvas broker. Plain Pi sessions have no node id and
|
|
21
45
|
* deliberately receive no filesystem transport. */
|
|
22
46
|
export function registerCanvasPreviewResult(pi) {
|
|
@@ -29,10 +53,11 @@ export function registerCanvasPreviewResult(pi) {
|
|
|
29
53
|
if (!existsSync(path))
|
|
30
54
|
return;
|
|
31
55
|
try {
|
|
32
|
-
const
|
|
33
|
-
|
|
56
|
+
const records = readPreviewRecords(path);
|
|
57
|
+
const last = records.at(-1);
|
|
58
|
+
if (last === undefined)
|
|
34
59
|
return;
|
|
35
|
-
return { details: { ...(event.details ?? {}), crtrPreview:
|
|
60
|
+
return { details: { ...(event.details ?? {}), crtrPreview: last, crtrPreviews: records } };
|
|
36
61
|
}
|
|
37
62
|
catch {
|
|
38
63
|
// A malformed side-channel record is not agent-visible output and must
|
|
@@ -29,7 +29,7 @@ export const PARK_SUMMARY_PROMPT = 'This conversation has been idle with nothing
|
|
|
29
29
|
+ '3. Put supporting material in your context directory only when the roadmap would become bulky without it. Rewrite existing living documents rather than leave superseded versions. Name every supporting file the next cycle must read from the roadmap and say what it is for—the revive shows filenames but does not inject their contents. Task state, identifiers, and recovery detail belong here, not in memory.\n\n'
|
|
30
30
|
+ '4. Use memory only for a non-obvious, reusable lesson that should survive this task and is not already recorded. Read `crtr memory write -h`, find before writing, and choose the narrowest scope that will reach the next agent who needs it. Do not put a conversation recap, task status, recovery handles, or facts already captured in code or docs into memory.\n\n'
|
|
31
31
|
+ '5. Push exactly one regular update with `crtr push update --tier deferred`, never `crtr push final`. Write it for subscribers and history, not as a second roadmap. Its first line must stand alone as the current outcome, blocker, or decision that matters; then include only unfinished work, a needed decision, and concrete handles a subscriber may need. This concludes the conversation; it does not finish the mandate.\n\n'
|
|
32
|
-
+ '6. End with a short sign-off to the reader in second person: say you are putting your notes in order and pausing here until they come back,
|
|
32
|
+
+ '6. End with a short, one-sentence sign-off to the reader in second person: say you are putting your notes in order and pausing here until they come back. If you saved any memories, describe what you did extremely briefly (i.e. "I noted your preference for XYZ" or "I updated my memories on ABC"). Keep all visible text this turn—including the update — about their work, not crouter\'s machinery: do not mention roadmap or context paths, filing a report, parking, idling, residency, or node ids. Then stop. A later message reopens you on a fresh context window grounded in your goal and roadmap, so the inheritance you leave now is what you get back.';
|
|
33
33
|
/** Static recovery prompts shared by the broker producer and display classifier. */
|
|
34
34
|
export const AUTH_FAULT_RECOVERY_BODY = 'Provider credentials were just updated (a new login landed). Your previous turn stopped on a provider authentication failure. Continue from where you left off and retry the work that failed.';
|
|
35
35
|
export const CONNECTION_FAULT_RECOVERY_BODY = 'The network connection is back online. Your previous turn stopped on a connection error (the network was down). Continue from where you left off and retry the work that failed.';
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.254",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.254",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|