@north-light/crouter 0.3.254 → 0.3.256
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/api/client.d.ts +13 -1
- package/dist/api/client.js +17 -0
- package/dist/api/dto/worktree.d.ts +17 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/clients/attach/viewer.js +936 -797
- package/dist/commands/api-client.js +4 -0
- package/dist/commands/sys/worktrees.d.ts +1 -0
- package/dist/commands/sys/worktrees.js +53 -0
- package/dist/commands/sys.js +2 -1
- package/dist/core/__tests__/integration/worktree-land.test.js +71 -1
- package/dist/core/__tests__/integration/worktree-reap.test.js +101 -0
- package/dist/core/__tests__/worktree-landing.test.d.ts +1 -0
- package/dist/core/__tests__/worktree-landing.test.js +11 -0
- package/dist/core/canvas/canvas.js +24 -8
- package/dist/core/canvas/pid.d.ts +6 -0
- package/dist/core/canvas/pid.js +48 -3
- package/dist/core/canvas/types.d.ts +41 -1
- package/dist/core/exclusive-lock.d.ts +6 -0
- package/dist/core/exclusive-lock.js +12 -0
- package/dist/core/git.d.ts +1 -1
- package/dist/core/git.js +5 -1
- package/dist/core/runtime/fleet.d.ts +6 -0
- package/dist/core/worktree-landing.d.ts +44 -0
- package/dist/core/worktree-landing.js +56 -0
- package/dist/core/worktree-quarantine.d.ts +19 -0
- package/dist/core/worktree-quarantine.js +54 -0
- package/dist/core/worktree-sweep.d.ts +60 -0
- package/dist/core/worktree-sweep.js +493 -0
- package/dist/core/worktree.d.ts +37 -0
- package/dist/core/worktree.js +149 -23
- package/dist/daemon/__tests__/startup-block-marker.test.d.ts +1 -0
- package/dist/daemon/__tests__/startup-block-marker.test.js +86 -0
- package/dist/daemon/api/handlers/worktree.js +5 -0
- package/dist/daemon/crtrd-cli.js +12 -7
- package/dist/daemon/crtrd.js +6 -1
- package/dist/daemon/fleet.d.ts +1 -0
- package/dist/daemon/fleet.js +3 -0
- package/dist/daemon/manage.js +22 -1
- package/dist/daemon/reconcilers/managed-worktree-sweep.d.ts +24 -0
- package/dist/daemon/reconcilers/managed-worktree-sweep.js +190 -0
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +11 -2
- package/dist/daemon/reconcilers/storage-maintenance.js +7 -2
- package/dist/daemon/startup-block-marker.d.ts +25 -0
- package/dist/daemon/startup-block-marker.js +99 -0
- package/dist/shared/generated-context.d.ts +2 -2
- package/dist/shared/generated-context.js +4 -4
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The daemon lane that drives managed-worktree reconciliation.
|
|
2
|
+
//
|
|
3
|
+
// It owns WHEN and HOW MANY; `core/worktree-sweep.ts` owns what a single
|
|
4
|
+
// reconciliation does. The lane never blocks the supervision tick: it registers
|
|
5
|
+
// one detached pass and returns immediately, and only one pass is ever in
|
|
6
|
+
// flight, so passes cannot overlap however long a repository takes to answer.
|
|
7
|
+
//
|
|
8
|
+
// NOTHING that spawns a subprocess runs on the tick, and nothing spawns one PER
|
|
9
|
+
// CANDIDATE. `run()` does no work beyond timing; the detached pass reads the
|
|
10
|
+
// canvas, applies the budget, and then takes ONE asynchronous `ps` snapshot
|
|
11
|
+
// that classifies every budgeted candidate's engine. A per-pid probe here would
|
|
12
|
+
// stall the daemon once per stuck worktree.
|
|
13
|
+
//
|
|
14
|
+
// Cost is flat regardless of backlog. The candidate scan touches only terminal
|
|
15
|
+
// nodes, and only those carrying an unreconciled worktree record reach a Git
|
|
16
|
+
// call at all — normally none, so the pass costs zero subprocesses. When there
|
|
17
|
+
// is a backlog, a fixed per-pass budget walks the candidates round-robin, so
|
|
18
|
+
// work per minute is the same whether three worktrees are stuck or three
|
|
19
|
+
// hundred.
|
|
20
|
+
import { getNode, getRow, listNodes } from '../../core/canvas/index.js';
|
|
21
|
+
import { isSafeNodeId } from '../../core/canvas/paths.js';
|
|
22
|
+
import { captureLivenessSnapshotAsync, recordedPidLiveness } from '../../core/canvas/pid.js';
|
|
23
|
+
import { emitEvent } from '../../core/events/emit.js';
|
|
24
|
+
import { operationIdContext } from '../../core/events/operation-id.js';
|
|
25
|
+
import { isDueForSweep, isReconcilable, reconcileManagedWorktree } from '../../core/worktree-sweep.js';
|
|
26
|
+
export const WORKTREE_SWEEP_INTERVAL_MS = 60 * 1000;
|
|
27
|
+
/** Candidates reconciled per pass. Bounds the work a large backlog can demand. */
|
|
28
|
+
export const WORKTREE_SWEEP_BUDGET = 3;
|
|
29
|
+
const TERMINAL_STATUSES = ['done', 'dead', 'canceled'];
|
|
30
|
+
function isTerminal(row) {
|
|
31
|
+
return TERMINAL_STATUSES.includes(row.status);
|
|
32
|
+
}
|
|
33
|
+
/** Is this node's engine definitively gone?
|
|
34
|
+
*
|
|
35
|
+
* Terminal status alone is not enough: `push final` marks a node `done` while
|
|
36
|
+
* its broker is still running out of the managed checkout, which is exactly
|
|
37
|
+
* the case that made cleanup deferred in the first place. So the sweep waits
|
|
38
|
+
* for terminal status AND the absence of any live engine, and an
|
|
39
|
+
* INDETERMINATE pid probe counts as live — uncertainty must never authorize
|
|
40
|
+
* deleting a checkout something may still be writing to.
|
|
41
|
+
*
|
|
42
|
+
* `isClaimed` rather than `has`: a revive that has reserved its slot but not
|
|
43
|
+
* yet registered its broker is a node coming back to life, and the checkout it
|
|
44
|
+
* is about to run in must not be removed underneath it. */
|
|
45
|
+
function engineIsGone(row, fleet, snapshot) {
|
|
46
|
+
if (fleet.isClaimed(row.node_id))
|
|
47
|
+
return false;
|
|
48
|
+
if (row.frozen_at !== null)
|
|
49
|
+
return false;
|
|
50
|
+
if (!isTerminal(row))
|
|
51
|
+
return false;
|
|
52
|
+
return recordedPidLiveness(row.pi_pid, row.pi_pid_identity, snapshot) === 'dead';
|
|
53
|
+
}
|
|
54
|
+
/** The eligibility recheck the reconciliation runs immediately before EVERY Git
|
|
55
|
+
* mutation, synchronously and with no subprocess: a revive that won the race
|
|
56
|
+
* makes this false and the mutation never happens.
|
|
57
|
+
*
|
|
58
|
+
* It re-reads the row rather than trusting the classification the pass began
|
|
59
|
+
* with, and it compares the recorded engine identity against the one that was
|
|
60
|
+
* classified dead — a row whose pid or pid identity moved has been relaunched,
|
|
61
|
+
* whatever its status column currently says. */
|
|
62
|
+
function eligibilityGuard(row, fleet) {
|
|
63
|
+
const nodeId = row.node_id;
|
|
64
|
+
const pid = row.pi_pid;
|
|
65
|
+
const identity = row.pi_pid_identity;
|
|
66
|
+
return {
|
|
67
|
+
stillEligible: () => {
|
|
68
|
+
if (fleet.isClaimed(nodeId))
|
|
69
|
+
return false;
|
|
70
|
+
const current = getRow(nodeId);
|
|
71
|
+
if (current === null || !isTerminal(current) || current.frozen_at !== null)
|
|
72
|
+
return false;
|
|
73
|
+
return current.pi_pid === pid && current.pi_pid_identity === identity;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export class ManagedWorktreeSweepReconciler {
|
|
78
|
+
lastSweepAt = Number.NEGATIVE_INFINITY;
|
|
79
|
+
inFlight = false;
|
|
80
|
+
/** Round-robin cursor over candidate node ids, so a permanently stuck record
|
|
81
|
+
* at the head of the list cannot starve the ones behind it. */
|
|
82
|
+
cursor = 0;
|
|
83
|
+
run(now, ctx) {
|
|
84
|
+
if (this.inFlight)
|
|
85
|
+
return;
|
|
86
|
+
if (now - this.lastSweepAt < WORKTREE_SWEEP_INTERVAL_MS)
|
|
87
|
+
return;
|
|
88
|
+
if (!ctx.lifecycle.acceptsDetachedWork())
|
|
89
|
+
return;
|
|
90
|
+
this.lastSweepAt = now;
|
|
91
|
+
this.inFlight = true;
|
|
92
|
+
const pass = this.sweep(now, ctx.fleet).finally(() => {
|
|
93
|
+
this.inFlight = false;
|
|
94
|
+
});
|
|
95
|
+
ctx.lifecycle.registerDetached(pass);
|
|
96
|
+
}
|
|
97
|
+
/** Terminal nodes whose managed worktree is unreconciled and whose backoff has
|
|
98
|
+
* elapsed. Canvas reads only — engine liveness is classified later, for the
|
|
99
|
+
* budgeted few, from one shared process snapshot. Returns null when the
|
|
100
|
+
* canvas could not be read, which authorizes nothing. */
|
|
101
|
+
candidates(now) {
|
|
102
|
+
let rows;
|
|
103
|
+
try {
|
|
104
|
+
rows = listNodes({ status: [...TERMINAL_STATUSES] });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
operationIdContext.fresh(() => {
|
|
108
|
+
emitEvent({ level: 'error', event: 'worktree.sweep.scan_failed', error: err });
|
|
109
|
+
});
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const due = [];
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
let wt;
|
|
115
|
+
try {
|
|
116
|
+
wt = getNode(row.node_id)?.managed_worktree;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
continue; // an unreadable record proves no ownership; leave it alone
|
|
120
|
+
}
|
|
121
|
+
if (!isReconcilable(wt) || wt == null)
|
|
122
|
+
continue;
|
|
123
|
+
if (!isDueForSweep(wt, now))
|
|
124
|
+
continue;
|
|
125
|
+
due.push(row.node_id);
|
|
126
|
+
}
|
|
127
|
+
return due;
|
|
128
|
+
}
|
|
129
|
+
async sweep(now, fleet) {
|
|
130
|
+
const candidates = this.candidates(now);
|
|
131
|
+
if (candidates === null || candidates.length === 0)
|
|
132
|
+
return;
|
|
133
|
+
if (this.cursor >= candidates.length)
|
|
134
|
+
this.cursor = 0;
|
|
135
|
+
const start = this.cursor;
|
|
136
|
+
const take = Math.min(WORKTREE_SWEEP_BUDGET, candidates.length);
|
|
137
|
+
const budgeted = [];
|
|
138
|
+
for (let i = 0; i < take; i += 1)
|
|
139
|
+
budgeted.push(candidates[(start + i) % candidates.length]);
|
|
140
|
+
this.cursor = (start + take) % candidates.length;
|
|
141
|
+
// One process-table read for the whole pass, off the tick. A null snapshot
|
|
142
|
+
// is unknown liveness, and unknown never authorizes destroying a checkout,
|
|
143
|
+
// so the pass simply ends and the next one retries.
|
|
144
|
+
const snapshot = await captureLivenessSnapshotAsync();
|
|
145
|
+
if (snapshot === null)
|
|
146
|
+
return;
|
|
147
|
+
for (const nodeId of budgeted) {
|
|
148
|
+
const row = getRow(nodeId);
|
|
149
|
+
if (row === null || !engineIsGone(row, fleet, snapshot))
|
|
150
|
+
continue;
|
|
151
|
+
await this.reconcileOne(row, now, fleet);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async reconcileOne(row, now, fleet) {
|
|
155
|
+
const nodeId = row.node_id;
|
|
156
|
+
const target = isSafeNodeId(nodeId) ? { node_id: nodeId } : {};
|
|
157
|
+
const affected = isSafeNodeId(nodeId) ? {} : { affected_node_id: nodeId };
|
|
158
|
+
try {
|
|
159
|
+
const outcome = await reconcileManagedWorktree(nodeId, eligibilityGuard(row, fleet), now);
|
|
160
|
+
operationIdContext.fresh(() => {
|
|
161
|
+
if (outcome.status === 'complete') {
|
|
162
|
+
emitEvent({
|
|
163
|
+
level: 'info',
|
|
164
|
+
event: 'worktree.sweep.reconciled',
|
|
165
|
+
outcome: 'succeeded',
|
|
166
|
+
...target,
|
|
167
|
+
fields: { ...affected, ...(outcome.stash === undefined ? {} : { base_checkout_stash: outcome.stash }) },
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (outcome.status === 'skipped')
|
|
172
|
+
return;
|
|
173
|
+
if (outcome.status === 'refused' && outcome.unchanged === true)
|
|
174
|
+
return; // nothing moved; nothing to say
|
|
175
|
+
emitEvent({
|
|
176
|
+
level: outcome.status === 'failed' ? 'error' : 'warn',
|
|
177
|
+
event: outcome.status === 'failed' ? 'worktree.sweep.failed' : 'worktree.sweep.refused',
|
|
178
|
+
outcome: outcome.status === 'failed' ? 'failed' : 'skipped',
|
|
179
|
+
...target,
|
|
180
|
+
fields: { ...affected, reason: outcome.reason, ...(outcome.detail === undefined ? {} : { detail: outcome.detail }) },
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
operationIdContext.fresh(() => {
|
|
186
|
+
emitEvent({ level: 'error', event: 'worktree.sweep.failed', ...target, error: err, fields: affected });
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
import type { FleetRegistry } from '../../core/runtime/fleet.js';
|
|
2
|
+
import type { DetachedWorkLifecycle } from './broker-supervision.js';
|
|
1
3
|
export declare const DEAD_REAP_GRACE_MS: number;
|
|
2
|
-
export
|
|
4
|
+
export interface StorageMaintenanceContext {
|
|
5
|
+
/** Carries the managed-worktree sweep's detached Git work off this tick. */
|
|
6
|
+
lifecycle: DetachedWorkLifecycle;
|
|
7
|
+
/** A live engine handle is the sweep's proof that a node is NOT disposable. */
|
|
8
|
+
fleet: FleetRegistry;
|
|
9
|
+
}
|
|
3
10
|
/** Recurring storage cleanup. The in-memory throttle clocks intentionally reset
|
|
4
|
-
* on daemon boot, so the first tick runs
|
|
11
|
+
* on daemon boot, so the first tick runs every full sweep — which is also how
|
|
12
|
+
* managed-worktree reconciliation gets its start-up pass. */
|
|
5
13
|
export declare class StorageMaintenanceReconciler {
|
|
6
14
|
private lastSpareSweepAt;
|
|
7
15
|
private lastGhostSweepAt;
|
|
16
|
+
private readonly worktreeSweep;
|
|
8
17
|
run(now: number, ctx: StorageMaintenanceContext): void;
|
|
9
18
|
/** Drop rows whose on-disk node dir is gone. Such a row can never be revived,
|
|
10
19
|
* focused, or closed — every one of those resolves the node through meta.json —
|
|
@@ -6,18 +6,20 @@ import { emitEvent } from '../../core/events/emit.js';
|
|
|
6
6
|
import { operationIdContext } from '../../core/events/operation-id.js';
|
|
7
7
|
import { listLivePanes, tearDownNode } from '../../core/runtime/placement.js';
|
|
8
8
|
import { reapStaleSpares } from '../../core/runtime/warm-pool.js';
|
|
9
|
+
import { ManagedWorktreeSweepReconciler } from './managed-worktree-sweep.js';
|
|
9
10
|
// How long a dead node's on-disk record must be quiet before its leftover
|
|
10
11
|
// placement is reaped. A fresh crash keeps its pane for inspection.
|
|
11
12
|
export const DEAD_REAP_GRACE_MS = 10 * 60_000;
|
|
12
13
|
const SPARE_SWEEP_INTERVAL_MS = 60 * 1000;
|
|
13
14
|
const GHOST_SWEEP_INTERVAL_MS = 60 * 1000;
|
|
14
15
|
/** Recurring storage cleanup. The in-memory throttle clocks intentionally reset
|
|
15
|
-
* on daemon boot, so the first tick runs
|
|
16
|
+
* on daemon boot, so the first tick runs every full sweep — which is also how
|
|
17
|
+
* managed-worktree reconciliation gets its start-up pass. */
|
|
16
18
|
export class StorageMaintenanceReconciler {
|
|
17
19
|
lastSpareSweepAt = Number.NEGATIVE_INFINITY;
|
|
18
20
|
lastGhostSweepAt = Number.NEGATIVE_INFINITY;
|
|
21
|
+
worktreeSweep = new ManagedWorktreeSweepReconciler();
|
|
19
22
|
run(now, ctx) {
|
|
20
|
-
void ctx; // no consumer today — kept only for six-way call-site uniformity
|
|
21
23
|
// Placement reap MUST precede focus GC, so GC catches a focus row the reap
|
|
22
24
|
// strands. Both run after the lifecycle tick, so a same-tick revive has
|
|
23
25
|
// already had the chance to re-anchor its focus.
|
|
@@ -25,6 +27,9 @@ export class StorageMaintenanceReconciler {
|
|
|
25
27
|
this.gcStaleFocuses();
|
|
26
28
|
this.reapGhostRows(now);
|
|
27
29
|
this.gcWarmPool(now);
|
|
30
|
+
// Registers detached async Git work and returns immediately: this lane is
|
|
31
|
+
// called un-awaited from a 2-second supervision tick and must not block it.
|
|
32
|
+
this.worktreeSweep.run(now, ctx);
|
|
28
33
|
}
|
|
29
34
|
/** Drop rows whose on-disk node dir is gone. Such a row can never be revived,
|
|
30
35
|
* focused, or closed — every one of those resolves the node through meta.json —
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { MigrationBlockedError } from '../migrations/activation.js';
|
|
2
|
+
export declare function daemonStartupBlockMarkerPath(): string;
|
|
3
|
+
/** Record that startup is blocked, so no OTHER process spawns into the same
|
|
4
|
+
* wall. Best-effort: a home we cannot write to still gets in-process
|
|
5
|
+
* suppression, which is strictly better than nothing. */
|
|
6
|
+
export declare function recordDaemonStartupBlocked(message: string, blockerPaths: readonly string[]): void;
|
|
7
|
+
/** Clear the marker — for a repair path that knows startup should be retried
|
|
8
|
+
* (a successful migration, a successful daemon start, an explicit reset). */
|
|
9
|
+
export declare function clearDaemonStartupBlocked(): void;
|
|
10
|
+
/** The recorded block message when startup is still blocked by the SAME corpus
|
|
11
|
+
* that produced it, or null when nothing is recorded or the corpus has since
|
|
12
|
+
* changed. A changed corpus retires the marker as a side effect of the read,
|
|
13
|
+
* so the very next spawner is free to try again.
|
|
14
|
+
*
|
|
15
|
+
* A marker naming no blocker paths at all is not self-healing — nothing about
|
|
16
|
+
* it can be observed to change — so it is treated as advisory only and
|
|
17
|
+
* retired on read rather than left to wedge autostart forever. */
|
|
18
|
+
export declare function daemonStartupBlocked(): string | null;
|
|
19
|
+
/** The blocked-migration failure, wherever it ended up in a thrown value.
|
|
20
|
+
* Startup releases its ownership claim before rethrowing, and a claim release
|
|
21
|
+
* that ALSO fails is reported as an aggregate — so the one error the spawner
|
|
22
|
+
* must react to can arrive nested. Matching only the bare instance would let
|
|
23
|
+
* the aggregate path fall through to a generic exit, and every suppression
|
|
24
|
+
* downstream is keyed on getting that exit code right. */
|
|
25
|
+
export declare function blockedMigrationIn(error: unknown): MigrationBlockedError | null;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// A blocked daemon startup is a STANDING condition, and the processes that
|
|
2
|
+
// keep retrying it are not one process. Every CLI invocation, every scheduled
|
|
3
|
+
// job tick, and every long-lived client is its own process with its own module
|
|
4
|
+
// state, so an in-memory suppression flag caps exactly one of them and lets the
|
|
5
|
+
// rest storm unchanged. This marker is that suppression made durable: one file
|
|
6
|
+
// in the canvas home, written by whoever first proved startup is blocked, and
|
|
7
|
+
// read by every would-be spawner before it spawns.
|
|
8
|
+
//
|
|
9
|
+
// The hazard of a durable "stop trying" record is that it outlives the problem
|
|
10
|
+
// and wedges a repaired install. So the marker is self-healing rather than
|
|
11
|
+
// permanent: it records the identity (path + size + mtime) of each blocker the
|
|
12
|
+
// daemon named, and any change to any of them retires the marker on the next
|
|
13
|
+
// read. Repairing the corpus — by `crtr sys migrate`, an editor, or deleting
|
|
14
|
+
// the offending file — is itself the thing that lifts the suppression, so no
|
|
15
|
+
// user is ever required to know this file exists.
|
|
16
|
+
import { rmSync } from 'node:fs';
|
|
17
|
+
import { statSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { crtrHome } from '../core/canvas/paths.js';
|
|
20
|
+
import { MigrationBlockedError } from '../migrations/activation.js';
|
|
21
|
+
import { atomicWriteJson, readJsonOrNull } from '../core/fs-utils.js';
|
|
22
|
+
export function daemonStartupBlockMarkerPath() {
|
|
23
|
+
return join(crtrHome(), 'daemon-startup-blocked.json');
|
|
24
|
+
}
|
|
25
|
+
function fingerprint(path) {
|
|
26
|
+
try {
|
|
27
|
+
const stat = statSync(path);
|
|
28
|
+
return { path, sizeBytes: stat.size, mtimeMs: stat.mtimeMs };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { path, sizeBytes: null, mtimeMs: null };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function sameFingerprint(a, b) {
|
|
35
|
+
return a.sizeBytes === b.sizeBytes && a.mtimeMs === b.mtimeMs;
|
|
36
|
+
}
|
|
37
|
+
/** Record that startup is blocked, so no OTHER process spawns into the same
|
|
38
|
+
* wall. Best-effort: a home we cannot write to still gets in-process
|
|
39
|
+
* suppression, which is strictly better than nothing. */
|
|
40
|
+
export function recordDaemonStartupBlocked(message, blockerPaths) {
|
|
41
|
+
const marker = {
|
|
42
|
+
recordedAt: Date.now(),
|
|
43
|
+
message,
|
|
44
|
+
blockers: blockerPaths.map(fingerprint),
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
atomicWriteJson(daemonStartupBlockMarkerPath(), marker);
|
|
48
|
+
}
|
|
49
|
+
catch { /* best effort */ }
|
|
50
|
+
}
|
|
51
|
+
/** Clear the marker — for a repair path that knows startup should be retried
|
|
52
|
+
* (a successful migration, a successful daemon start, an explicit reset). */
|
|
53
|
+
export function clearDaemonStartupBlocked() {
|
|
54
|
+
try {
|
|
55
|
+
rmSync(daemonStartupBlockMarkerPath(), { force: true });
|
|
56
|
+
}
|
|
57
|
+
catch { /* best effort */ }
|
|
58
|
+
}
|
|
59
|
+
/** The recorded block message when startup is still blocked by the SAME corpus
|
|
60
|
+
* that produced it, or null when nothing is recorded or the corpus has since
|
|
61
|
+
* changed. A changed corpus retires the marker as a side effect of the read,
|
|
62
|
+
* so the very next spawner is free to try again.
|
|
63
|
+
*
|
|
64
|
+
* A marker naming no blocker paths at all is not self-healing — nothing about
|
|
65
|
+
* it can be observed to change — so it is treated as advisory only and
|
|
66
|
+
* retired on read rather than left to wedge autostart forever. */
|
|
67
|
+
export function daemonStartupBlocked() {
|
|
68
|
+
const marker = readJsonOrNull(daemonStartupBlockMarkerPath());
|
|
69
|
+
if (marker === null || typeof marker.message !== 'string' || !Array.isArray(marker.blockers))
|
|
70
|
+
return null;
|
|
71
|
+
if (marker.blockers.length === 0) {
|
|
72
|
+
clearDaemonStartupBlocked();
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const changed = marker.blockers.some((recorded) => !sameFingerprint(recorded, fingerprint(recorded.path)));
|
|
76
|
+
if (changed) {
|
|
77
|
+
clearDaemonStartupBlocked();
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return marker.message;
|
|
81
|
+
}
|
|
82
|
+
/** The blocked-migration failure, wherever it ended up in a thrown value.
|
|
83
|
+
* Startup releases its ownership claim before rethrowing, and a claim release
|
|
84
|
+
* that ALSO fails is reported as an aggregate — so the one error the spawner
|
|
85
|
+
* must react to can arrive nested. Matching only the bare instance would let
|
|
86
|
+
* the aggregate path fall through to a generic exit, and every suppression
|
|
87
|
+
* downstream is keyed on getting that exit code right. */
|
|
88
|
+
export function blockedMigrationIn(error) {
|
|
89
|
+
if (error instanceof MigrationBlockedError)
|
|
90
|
+
return error;
|
|
91
|
+
if (error instanceof AggregateError) {
|
|
92
|
+
for (const nested of error.errors) {
|
|
93
|
+
const found = blockedMigrationIn(nested);
|
|
94
|
+
if (found !== null)
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
@@ -11,8 +11,8 @@ export declare const STALL_REPROMPT: string;
|
|
|
11
11
|
/** The daemon's parking mandate: the last turn of a conversation the unattended
|
|
12
12
|
* clock is concluding. Delivered live, wrapped in a `park` runtime card. It
|
|
13
13
|
* governs both the node's durable inheritance and reader-facing output:
|
|
14
|
-
* one update for subscribers and history, then a
|
|
15
|
-
*
|
|
14
|
+
* one update for subscribers and history, then a silent stop with no assistant
|
|
15
|
+
* prose for an external channel to relay.
|
|
16
16
|
*
|
|
17
17
|
* The durable inheritance shares a CONTRACT with `node yield`'s pre-invocation
|
|
18
18
|
* guide (roadmap current, short and shrinking; context dir for in-progress
|
|
@@ -16,20 +16,20 @@ export const STALL_REPROMPT = "You've stopped but you're not waiting on anyone a
|
|
|
16
16
|
/** The daemon's parking mandate: the last turn of a conversation the unattended
|
|
17
17
|
* clock is concluding. Delivered live, wrapped in a `park` runtime card. It
|
|
18
18
|
* governs both the node's durable inheritance and reader-facing output:
|
|
19
|
-
* one update for subscribers and history, then a
|
|
20
|
-
*
|
|
19
|
+
* one update for subscribers and history, then a silent stop with no assistant
|
|
20
|
+
* prose for an external channel to relay.
|
|
21
21
|
*
|
|
22
22
|
* The durable inheritance shares a CONTRACT with `node yield`'s pre-invocation
|
|
23
23
|
* guide (roadmap current, short and shrinking; context dir for in-progress
|
|
24
24
|
* material; memory only for gated permanent lessons) but not a string: yield is
|
|
25
25
|
* read by an agent choosing to refresh, this by an agent being told to conclude. */
|
|
26
26
|
export const PARK_SUMMARY_PROMPT = 'This conversation has been idle with nothing left to wake it, so it is being concluded. This is your last turn. Use it to leave a trustworthy inheritance, not to restart or broaden the work. Do these six things now, then stop.\n\n'
|
|
27
|
-
+ '1. Establish current truth. Check only state that may have changed outside the transcript and matters to resuming—such as the working tree, a remote run, or an external decision. Do not start new work; perform only a quick check needed to avoid recording an unverified claim. If no mandate or work ever began,
|
|
27
|
+
+ '1. Establish current truth. Check only state that may have changed outside the transcript and matters to resuming—such as the working tree, a remote run, or an external decision. Do not start new work; perform only a quick check needed to avoid recording an unverified claim. If no mandate or work ever began, record that plainly in the inheritance and keep every artifact minimal.\n\n'
|
|
28
28
|
+ '2. Rewrite `$CRTR_CONTEXT_DIR/roadmap.md` for a fresh context window. This is the only handoff document a later fresh cycle receives in full. Preserve the current goal and exit criteria when they still apply, then state the present outcome; what remains or is blocked; decisions or questions still open; exact recovery handles for in-flight state; and the first safe move on return. Keep strategy and present state, not a transcript recap. Delete stale and completed steps instead of marking them done; the roadmap should stay short and shrink. Write a minimal one now if none exists.\n\n'
|
|
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.
|
|
32
|
+
+ '6. Keep this entire turn silent in the conversation: produce no assistant prose before, between, or after tool calls, and do not narrate the work. Once every required tool call has finished, end the response immediately without emitting any text, sign-off, summary, acknowledgement, or other visible content. The deferred update from step 5 is the only reader-facing conclusion. 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.256",
|
|
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.256",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|