@north-light/crouter 0.3.231 → 0.3.233
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 +3 -1
- package/dist/api/client.js +4 -0
- package/dist/api/dto/canvas.d.ts +10 -0
- package/dist/api/dto/common.d.ts +1 -1
- package/dist/api/dto/health.d.ts +2 -1
- package/dist/api/dto/lifecycle.d.ts +3 -4
- package/dist/api/dto/messages.d.ts +5 -4
- package/dist/api/dto/nodes.d.ts +2 -0
- package/dist/api/dto/profiles.d.ts +5 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/00-runtime-base/00-authoring.md +8 -0
- package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
- package/dist/builtin-memory/01-spine/00-has-manager.md +1 -1
- package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +5 -0
- package/dist/builtin-memory/02-turn-lifecycle/02-resident.md +5 -3
- package/dist/builtin-memory/04-orchestration-kernel.md +2 -2
- package/dist/builtin-memory/insights/capture.md +3 -2
- package/dist/builtin-memory/internal/memory-loading.md +4 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +6 -1
- package/dist/clients/attach/render/diagram.js +13 -5
- package/dist/clients/attach/render/page-block.d.ts +0 -1
- package/dist/clients/attach/render/page-block.js +4 -57
- package/dist/clients/attach/viewer.js +570 -563
- package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
- package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
- package/dist/clients/inbox/controller.d.ts +10 -0
- package/dist/clients/inbox/controller.js +56 -12
- package/dist/clients/inbox/tui/input.js +38 -10
- package/dist/clients/inbox/tui/page-body.d.ts +10 -0
- package/dist/clients/inbox/tui/page-body.js +66 -0
- package/dist/clients/inbox/tui/panel.js +6 -4
- package/dist/clients/inbox/tui/render.js +89 -17
- package/dist/clients/inbox/tui/types.d.ts +4 -4
- package/dist/commands/__tests__/human.test.js +18 -3
- package/dist/commands/__tests__/node-message.test.js +3 -3
- package/dist/commands/api-client.js +1 -7
- package/dist/commands/canvas-config.js +6 -14
- package/dist/commands/canvas-use.js +4 -6
- package/dist/commands/cron.js +16 -22
- package/dist/commands/human/prompts.d.ts +1 -1
- package/dist/commands/human/prompts.js +118 -112
- package/dist/commands/human/request.js +13 -14
- package/dist/commands/human/review.js +3 -4
- package/dist/commands/human/shared.d.ts +6 -0
- package/dist/commands/human/shared.js +43 -5
- package/dist/commands/human.js +1 -1
- package/dist/commands/memory/delete.js +4 -6
- package/dist/commands/memory/edit.js +0 -4
- package/dist/commands/memory/move.js +3 -5
- package/dist/commands/memory/shared.d.ts +1 -1
- package/dist/commands/memory/shared.js +11 -7
- package/dist/commands/memory/write.js +60 -29
- package/dist/commands/memory.js +1 -1
- package/dist/commands/node/bash.js +6 -9
- package/dist/commands/node/create.js +89 -22
- package/dist/commands/node/inspect.js +3 -3
- package/dist/commands/node/lifecycle.js +30 -27
- package/dist/commands/node/message.js +24 -45
- package/dist/commands/node/subscription.js +6 -15
- package/dist/commands/node/wait.js +2 -3
- package/dist/commands/node-lifecycle-revive.js +1 -12
- package/dist/commands/pkg/browse/actions.js +2 -3
- package/dist/commands/pkg/market-manage.js +2 -5
- package/dist/commands/pkg/plugin-manage.js +7 -8
- package/dist/commands/profile/default.js +5 -5
- package/dist/commands/profile/delete.js +1 -1
- package/dist/commands/profile/env.js +9 -15
- package/dist/commands/profile/kind.js +3 -7
- package/dist/commands/profile/meta.js +3 -5
- package/dist/commands/profile/new.js +0 -6
- package/dist/commands/profile/pause.js +4 -8
- package/dist/commands/profile/project.js +5 -9
- package/dist/commands/profile/rename.js +3 -7
- package/dist/commands/profile/show.js +3 -3
- package/dist/commands/profile.js +4 -3
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/config.js +3 -4
- package/dist/commands/sys/support/prepare.js +8 -4
- package/dist/commands/sys/support/submit.js +2 -3
- package/dist/commands/sys/sync-deps.js +1 -9
- package/dist/commands/sys/sync-project-guidance.js +1 -7
- package/dist/commands/sys/sync-skills.js +1 -11
- package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
- package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
- package/dist/core/__tests__/history-inbox.test.js +11 -1
- package/dist/core/__tests__/human-deliver.test.js +2 -1
- package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
- package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
- package/dist/core/__tests__/lifecycle.test.js +30 -2
- package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
- package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
- package/dist/core/canvas/attention.d.ts +2 -0
- package/dist/core/canvas/attention.js +25 -18
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/canvas/history.js +20 -2
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/command.js +33 -8
- package/dist/core/help.d.ts +28 -2
- package/dist/core/help.js +46 -10
- package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
- package/dist/core/human/component-docs.js +4 -4
- package/dist/core/human/page-html-markdown.d.ts +8 -0
- package/dist/core/human/page-html-markdown.js +260 -0
- package/dist/core/memory/lint.d.ts +15 -0
- package/dist/core/memory/lint.js +150 -90
- package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
- package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
- package/dist/core/profiles/fuzzy-match.d.ts +19 -0
- package/dist/core/profiles/fuzzy-match.js +92 -0
- package/dist/core/profiles/manifest.d.ts +14 -7
- package/dist/core/profiles/manifest.js +62 -12
- package/dist/core/profiles/select.d.ts +3 -1
- package/dist/core/profiles/select.js +5 -3
- package/dist/core/profiles/state-block.js +4 -3
- package/dist/core/runtime/boot-root.d.ts +3 -2
- package/dist/core/runtime/canvas-extensions.d.ts +7 -1
- package/dist/core/runtime/canvas-extensions.js +8 -1
- package/dist/core/runtime/lifecycle.d.ts +11 -2
- package/dist/core/runtime/lifecycle.js +15 -2
- package/dist/core/runtime/model-selection.d.ts +4 -0
- package/dist/core/runtime/model-selection.js +5 -0
- package/dist/core/runtime/nodes.js +5 -0
- package/dist/core/runtime/reopen.d.ts +6 -0
- package/dist/core/runtime/reopen.js +12 -1
- package/dist/core/runtime/revive.d.ts +6 -0
- package/dist/core/runtime/revive.js +22 -2
- package/dist/core/runtime/spawn.d.ts +5 -2
- package/dist/core/runtime/spawn.js +18 -32
- package/dist/core/runtime/structured-output.d.ts +6 -0
- package/dist/core/runtime/structured-output.js +6 -0
- package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
- package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
- package/dist/core/substrate/__tests__/surface-match-pre-command.test.d.ts +1 -0
- package/dist/core/substrate/__tests__/surface-match-pre-command.test.js +92 -0
- package/dist/core/substrate/frontmatter-validation.js +1 -1
- package/dist/core/substrate/injected-store.d.ts +6 -0
- package/dist/core/substrate/injected-store.js +24 -0
- package/dist/core/substrate/on-read.d.ts +17 -1
- package/dist/core/substrate/on-read.js +38 -3
- package/dist/core/substrate/schema.d.ts +3 -3
- package/dist/core/substrate/schema.js +4 -4
- package/dist/core/substrate/surface-match.d.ts +19 -0
- package/dist/core/substrate/surface-match.js +224 -12
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/user-settings.js +1 -1
- package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
- package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
- package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
- package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
- package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
- package/dist/daemon/api/handlers/broker-ops.js +21 -0
- package/dist/daemon/api/handlers/canvas.js +10 -0
- package/dist/daemon/api/handlers/messages.js +25 -16
- package/dist/daemon/api/handlers/nodes.js +5 -0
- package/dist/daemon/api/handlers/profiles.js +22 -1
- package/dist/daemon/cron-run.js +19 -1
- package/dist/daemon/manage.d.ts +16 -1
- package/dist/daemon/manage.js +20 -1
- package/dist/daemon/park-pending.d.ts +13 -0
- package/dist/daemon/park-pending.js +42 -0
- package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
- package/dist/daemon/reconcilers/broker-supervision.js +139 -21
- package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
- package/dist/daemon/reconcilers/live-obligation.js +7 -3
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
- package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
- package/dist/pi-extensions/__tests__/pre-command-gate.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/pre-command-gate.test.js +220 -0
- package/dist/pi-extensions/canvas-doc-substrate.d.ts +14 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +75 -2
- package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
- package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
- package/dist/shared/generated-context.d.ts +7 -0
- package/dist/shared/generated-context.js +11 -0
- package/package.json +4 -4
- package/runtime.lock.json +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +0 -47
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// park-pending.ts — the in-flight parking turns, one map keyed by node id.
|
|
2
|
+
//
|
|
3
|
+
// When the unattended clock expires on a live resident, the daemon does not
|
|
4
|
+
// terminalize it: it asks the still-live engine for one summary turn and hands
|
|
5
|
+
// the terminal decision to broker settlement. This module is the only record
|
|
6
|
+
// that a given node's next settlement means "park", read by exactly two
|
|
7
|
+
// collaborators inside crtrd — the supervision reconciler (which armed it, and
|
|
8
|
+
// which enforces its abort conditions and grace) and the broker-settle handler
|
|
9
|
+
// (which consumes it).
|
|
10
|
+
//
|
|
11
|
+
// The pending park is PROCESS-LOCAL on purpose. The park clock and the pending
|
|
12
|
+
// park are the same authority and must die together: if crtrd is replaced
|
|
13
|
+
// mid-turn, the successor has no clock reading and no pending park, the node
|
|
14
|
+
// simply stays live, and its 15-minute clock restarts. Nothing is inconsistent
|
|
15
|
+
// and nothing needs repair. A durable in-flight flag would instead need its own
|
|
16
|
+
// reconciliation on every daemon start to avoid stranding a node that never got
|
|
17
|
+
// its prompt.
|
|
18
|
+
/** node id → the timestamp its parking turn was requested at. */
|
|
19
|
+
const pending = new Map();
|
|
20
|
+
/** Arm a pending park for `nodeId`. The caller delivers the parking prompt. */
|
|
21
|
+
export function beginPendingPark(nodeId, now) {
|
|
22
|
+
pending.set(nodeId, now);
|
|
23
|
+
}
|
|
24
|
+
export function isPendingPark(nodeId) {
|
|
25
|
+
return pending.has(nodeId);
|
|
26
|
+
}
|
|
27
|
+
/** When the pending park was armed, or undefined when none is armed. */
|
|
28
|
+
export function pendingParkStartedAt(nodeId) {
|
|
29
|
+
return pending.get(nodeId);
|
|
30
|
+
}
|
|
31
|
+
/** Delete-and-report: true only for the caller that actually took the pending
|
|
32
|
+
* park. Both the supervision tick and the settle handler run on the daemon
|
|
33
|
+
* thread with no await between their check and this call, so exactly one of
|
|
34
|
+
* them ever enacts a given park. */
|
|
35
|
+
export function consumePendingPark(nodeId) {
|
|
36
|
+
return pending.delete(nodeId);
|
|
37
|
+
}
|
|
38
|
+
/** Every node with a parking turn in flight — the reconciler's sweep drops the
|
|
39
|
+
* ones whose broker has left the fleet. */
|
|
40
|
+
export function pendingParkIds() {
|
|
41
|
+
return Array.from(pending.keys());
|
|
42
|
+
}
|
|
@@ -16,6 +16,7 @@ export declare const YIELD_STALL_GRACE_MS: number;
|
|
|
16
16
|
export declare const WEDGE_QUIET_MS: number;
|
|
17
17
|
export declare const WEDGE_CPU_MAX_PERCENT = 2;
|
|
18
18
|
export declare function unattendedParkMsForDaemon(): number;
|
|
19
|
+
export declare function parkSummaryGraceMsForDaemon(): number;
|
|
19
20
|
export type WedgeVerdict = 'leave' | 'pending' | 'wedged';
|
|
20
21
|
export declare function yieldStallVerdict(piPidAlive: boolean | null, intent: NodeRow['intent'], busy: boolean, stalledFor: number | null): 'leave' | 'pending' | 'kill';
|
|
21
22
|
export declare function wedgeVerdict(input: {
|
|
@@ -38,7 +39,19 @@ export declare class BrokerSupervisionReconciler {
|
|
|
38
39
|
warning: number;
|
|
39
40
|
automaticReviveCap: number;
|
|
40
41
|
}>;
|
|
42
|
+
/** Every condition that resets the unattended clock EXCEPT `isBusy` — the
|
|
43
|
+
* node is unavailable to park, attended, or has work waiting. Short-circuits
|
|
44
|
+
* in cost order so the common tick does no file reads it can avoid.
|
|
45
|
+
* Evaluated for a pending park too, where a busy engine is precisely what is
|
|
46
|
+
* expected (it is writing the summary) and every other hit aborts. */
|
|
47
|
+
private parkResetCondition;
|
|
41
48
|
private handleUnattendedParking;
|
|
49
|
+
/** Deliver the parking prompt over the live broker's serialized frame loop.
|
|
50
|
+
* Two outcomes cancel the park immediately: an unreachable broker (nothing
|
|
51
|
+
* live to summarize with) falls through to the degraded enactment, and a
|
|
52
|
+
* `steer` route means a turn started between the idle gate and the frame, so
|
|
53
|
+
* the node is working again and its clock simply restarts. */
|
|
54
|
+
private requestParkSummary;
|
|
42
55
|
private warnOverCapInTmux;
|
|
43
56
|
private handleYieldStall;
|
|
44
57
|
private readTreeSample;
|
|
@@ -21,6 +21,10 @@ import { readCursor, readInboxSince } from '../../core/feed/inbox.js';
|
|
|
21
21
|
import { activeBackgroundBashJobs } from '../../core/bash-jobs.js';
|
|
22
22
|
import { focusOf } from '../../core/runtime/placement.js';
|
|
23
23
|
import { hasAttachedBrokerViewers } from '../../core/runtime/broker/client-registry.js';
|
|
24
|
+
import { deliverLive } from '../../core/runtime/interactive-deliver.js';
|
|
25
|
+
import { BrokerUnreachableError } from '../../core/runtime/broker-request.js';
|
|
26
|
+
import { formatCard, PARK_SUMMARY_PROMPT } from '../../shared/generated-context.js';
|
|
27
|
+
import { beginPendingPark, consumePendingPark, pendingParkIds, pendingParkStartedAt } from '../park-pending.js';
|
|
24
28
|
import { applyParkEvent, parkEvent } from './live-obligation.js';
|
|
25
29
|
const execFileAsync = promisify(execFile);
|
|
26
30
|
function configuredPositiveInteger(name, fallback) {
|
|
@@ -43,6 +47,9 @@ export const WEDGE_CPU_MAX_PERCENT = 2;
|
|
|
43
47
|
const OVER_CAP_WARN_THROTTLE_MS = 5 * 60_000;
|
|
44
48
|
const CAPACITY_LOG_THROTTLE_MS = 30_000;
|
|
45
49
|
const DEFAULT_UNATTENDED_PARK_MS = 15 * 60_000;
|
|
50
|
+
// Longer than a summary turn and shorter than the unattended clock itself, so a
|
|
51
|
+
// stuck parking turn cannot hold a broker for a whole extra clock period.
|
|
52
|
+
const DEFAULT_PARK_SUMMARY_GRACE_MS = 5 * 60_000;
|
|
46
53
|
export function unattendedParkMsForDaemon() {
|
|
47
54
|
const raw = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
48
55
|
if (raw === undefined)
|
|
@@ -50,6 +57,13 @@ export function unattendedParkMsForDaemon() {
|
|
|
50
57
|
const parsed = Number(raw);
|
|
51
58
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_UNATTENDED_PARK_MS;
|
|
52
59
|
}
|
|
60
|
+
export function parkSummaryGraceMsForDaemon() {
|
|
61
|
+
const raw = process.env['CRTR_TEST_PARK_SUMMARY_GRACE_MS'];
|
|
62
|
+
if (raw === undefined)
|
|
63
|
+
return DEFAULT_PARK_SUMMARY_GRACE_MS;
|
|
64
|
+
const parsed = Number(raw);
|
|
65
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PARK_SUMMARY_GRACE_MS;
|
|
66
|
+
}
|
|
53
67
|
export function yieldStallVerdict(piPidAlive, intent, busy, stalledFor) {
|
|
54
68
|
if (piPidAlive !== true)
|
|
55
69
|
return 'leave';
|
|
@@ -155,7 +169,7 @@ export class BrokerSupervisionReconciler {
|
|
|
155
169
|
this.handleYieldStall(row, entry.pid, now);
|
|
156
170
|
await this.handleWedgeDetection(id, entry.pid, row.pi_pid_identity, now);
|
|
157
171
|
this.handleFatalFault(id);
|
|
158
|
-
this.handleUnattendedParking(id, getNode(id), entry.pid, now);
|
|
172
|
+
this.handleUnattendedParking(id, getNode(id), entry.pid, now, ctx);
|
|
159
173
|
}
|
|
160
174
|
catch (err) {
|
|
161
175
|
this.unattendedSince.delete(id);
|
|
@@ -172,39 +186,90 @@ export class BrokerSupervisionReconciler {
|
|
|
172
186
|
if (!ctx.fleet.has(id))
|
|
173
187
|
this.unattendedSince.delete(id);
|
|
174
188
|
}
|
|
189
|
+
// A broker that died mid-summary leaves no pending park behind: its parking
|
|
190
|
+
// turn can never settle, and the node's next clock starts from scratch.
|
|
191
|
+
for (const id of pendingParkIds()) {
|
|
192
|
+
if (!ctx.fleet.has(id))
|
|
193
|
+
consumePendingPark(id);
|
|
194
|
+
}
|
|
175
195
|
return thresholds;
|
|
176
196
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
if (row === null
|
|
187
|
-
|| row.pi_session_id == null
|
|
197
|
+
/** Every condition that resets the unattended clock EXCEPT `isBusy` — the
|
|
198
|
+
* node is unavailable to park, attended, or has work waiting. Short-circuits
|
|
199
|
+
* in cost order so the common tick does no file reads it can avoid.
|
|
200
|
+
* Evaluated for a pending park too, where a busy engine is precisely what is
|
|
201
|
+
* expected (it is writing the summary) and every other hit aborts. */
|
|
202
|
+
parkResetCondition(id, row, livePid, now) {
|
|
203
|
+
if (row.pi_session_id == null
|
|
188
204
|
|| row.pi_pid !== livePid
|
|
189
205
|
|| row.status === 'done'
|
|
190
206
|
|| row.status === 'canceled'
|
|
191
207
|
|| row.status === 'dead'
|
|
192
208
|
|| row.intent !== null
|
|
193
|
-
|| isBusy(id)
|
|
194
209
|
|| activeBackgroundBashJobs(contextDir(id)).length > 0
|
|
195
210
|
|| focusOf(id) !== null
|
|
196
|
-
|| hasAttachedBrokerViewers(id))
|
|
197
|
-
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
211
|
+
|| hasAttachedBrokerViewers(id))
|
|
212
|
+
return true;
|
|
200
213
|
const fault = readFault(id);
|
|
201
214
|
const activeAutoProviderRetry = fault !== null
|
|
202
215
|
&& fault.retry.disposition === 'auto'
|
|
203
216
|
&& fault.retry.by === 'daemon'
|
|
204
217
|
&& (fault.kind === 'connection' || fault.kind === 'rate-limit' || fault.kind === 'overloaded')
|
|
205
218
|
&& (fault.retry.nextAt !== undefined || nextFaultRetry(fault, now) !== null);
|
|
206
|
-
if (activeAutoProviderRetry
|
|
207
|
-
|
|
219
|
+
if (activeAutoProviderRetry)
|
|
220
|
+
return true;
|
|
221
|
+
return readInboxSince(id, readCursor(id)).some((entry) => entry.tier !== 'deferred');
|
|
222
|
+
}
|
|
223
|
+
handleUnattendedParking(id, row, livePid, now, ctx) {
|
|
224
|
+
// An unclaimed warm-pool spare is not a conversation: the booted broker it
|
|
225
|
+
// holds IS its whole value, and nobody is meant to be attending it. Its
|
|
226
|
+
// lifetime belongs to `reapStaleSpares` (warm-pool.ts) and its own TTL, so
|
|
227
|
+
// parking one here would empty the pool every 15 minutes.
|
|
228
|
+
if (isWarmSpare(id)) {
|
|
229
|
+
this.unattendedSince.delete(id);
|
|
230
|
+
consumePendingPark(id);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (row === null) {
|
|
234
|
+
this.unattendedSince.delete(id);
|
|
235
|
+
consumePendingPark(id);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const reset = this.parkResetCondition(id, row, livePid, now);
|
|
239
|
+
// A parking turn is in flight for this node: it owns the decision until it
|
|
240
|
+
// settles, aborts, or runs out of grace.
|
|
241
|
+
const parkingSince = pendingParkStartedAt(id);
|
|
242
|
+
if (parkingSince !== undefined) {
|
|
243
|
+
if (reset) {
|
|
244
|
+
// Something worth attending to arrived (or the node became unavailable)
|
|
245
|
+
// during the summary turn. The node stays live and the park consequence
|
|
246
|
+
// is discarded; its clock restarts from scratch.
|
|
247
|
+
consumePendingPark(id);
|
|
248
|
+
this.unattendedSince.delete(id);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (now - parkingSince < parkSummaryGraceMsForDaemon())
|
|
252
|
+
return;
|
|
253
|
+
// The degraded path: park anyway, with the marker but no report, exactly
|
|
254
|
+
// as this clock behaved before the summary turn existed. Status flips
|
|
255
|
+
// BEFORE the host dies (the crash-safety invariant).
|
|
256
|
+
if (!consumePendingPark(id))
|
|
257
|
+
return;
|
|
258
|
+
this.unattendedSince.delete(id);
|
|
259
|
+
emitEvent({
|
|
260
|
+
level: 'warn',
|
|
261
|
+
event: 'broker.park.summary_expired',
|
|
262
|
+
...(isSafeNodeId(id) ? { node_id: id } : {}),
|
|
263
|
+
fields: {
|
|
264
|
+
...(isSafeNodeId(id) ? {} : { affected_node_id: id }),
|
|
265
|
+
grace_ms: parkSummaryGraceMsForDaemon(),
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
applyParkEvent(id, 'park');
|
|
269
|
+
headlessBrokerHost.teardown(id);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (reset || isBusy(id)) {
|
|
208
273
|
this.unattendedSince.delete(id);
|
|
209
274
|
return;
|
|
210
275
|
}
|
|
@@ -216,8 +281,61 @@ export class BrokerSupervisionReconciler {
|
|
|
216
281
|
if (now - since < unattendedParkMsForDaemon())
|
|
217
282
|
return;
|
|
218
283
|
this.unattendedSince.delete(id);
|
|
219
|
-
|
|
220
|
-
|
|
284
|
+
const event = parkEvent(id, row.lifecycle);
|
|
285
|
+
if (event === 'release') {
|
|
286
|
+
// A pause, not a terminal write: something can still wake this node, so
|
|
287
|
+
// its row stays wakeable while the idle broker is released. Skipping the
|
|
288
|
+
// teardown here would hold the broker process for as long as the
|
|
289
|
+
// obligation lasts — the exact leak this clock exists to close.
|
|
290
|
+
applyParkEvent(id, event);
|
|
291
|
+
headlessBrokerHost.teardown(id);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (event === 'cancel') {
|
|
295
|
+
applyParkEvent(id, event);
|
|
296
|
+
headlessBrokerHost.teardown(id);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// A resident ends its conversation the way a worker ends a task: it gets one
|
|
300
|
+
// last turn to leave a roadmap and a report behind. Terminalization moves to
|
|
301
|
+
// broker settlement, which re-evaluates this verdict once that turn ends.
|
|
302
|
+
if (!ctx.lifecycle.acceptsDetachedWork())
|
|
303
|
+
return;
|
|
304
|
+
beginPendingPark(id, now);
|
|
305
|
+
ctx.lifecycle.registerDetached(this.requestParkSummary(id));
|
|
306
|
+
}
|
|
307
|
+
/** Deliver the parking prompt over the live broker's serialized frame loop.
|
|
308
|
+
* Two outcomes cancel the park immediately: an unreachable broker (nothing
|
|
309
|
+
* live to summarize with) falls through to the degraded enactment, and a
|
|
310
|
+
* `steer` route means a turn started between the idle gate and the frame, so
|
|
311
|
+
* the node is working again and its clock simply restarts. */
|
|
312
|
+
async requestParkSummary(id) {
|
|
313
|
+
try {
|
|
314
|
+
const route = await deliverLive(id, formatCard('park', {}, PARK_SUMMARY_PROMPT));
|
|
315
|
+
if (route === 'steer' && consumePendingPark(id))
|
|
316
|
+
return;
|
|
317
|
+
emitEvent({
|
|
318
|
+
level: 'info',
|
|
319
|
+
event: 'broker.park.summary_requested',
|
|
320
|
+
...(isSafeNodeId(id) ? { node_id: id } : {}),
|
|
321
|
+
fields: { ...(isSafeNodeId(id) ? {} : { affected_node_id: id }), route },
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
if (!consumePendingPark(id))
|
|
326
|
+
return;
|
|
327
|
+
if (err instanceof BrokerUnreachableError) {
|
|
328
|
+
applyParkEvent(id, 'park');
|
|
329
|
+
headlessBrokerHost.teardown(id);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
emitEvent({
|
|
333
|
+
level: 'error',
|
|
334
|
+
event: 'broker.park.summary_failed',
|
|
335
|
+
...(isSafeNodeId(id) ? { node_id: id } : { fields: { affected_node_id: id } }),
|
|
336
|
+
error: err,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
221
339
|
}
|
|
222
340
|
async warnOverCapInTmux(count, warning, max) {
|
|
223
341
|
const now = Date.now();
|
|
@@ -16,8 +16,10 @@ export declare function hasPendingWake(nodeId: string): boolean;
|
|
|
16
16
|
* reconciliation (dormant-inbox) apply.
|
|
17
17
|
*
|
|
18
18
|
* `release` — something can still wake it, so parking is a pause.
|
|
19
|
-
* `
|
|
20
|
-
* conversation, and the row becomes prunable history.
|
|
19
|
+
* `park` — a resident with nothing left to wake it has completed its
|
|
20
|
+
* conversation, and the row becomes prunable history. The live
|
|
21
|
+
* clock asks it for a summary turn first; this verdict is what
|
|
22
|
+
* that turn's settlement re-evaluates before enacting.
|
|
21
23
|
* `cancel` — a terminal node that never pushed a final was torn down before
|
|
22
24
|
* finishing, which is what `canceled` records; it stays revivable
|
|
23
25
|
* and becomes prunable. A live subscriber still awaiting its
|
|
@@ -29,7 +31,11 @@ export declare function hasPendingWake(nodeId: string): boolean;
|
|
|
29
31
|
* terminalized row no longer takes that cycle, so writing one off would drop
|
|
30
32
|
* content the sender already handed over.
|
|
31
33
|
*/
|
|
32
|
-
export type ParkEvent = Extract<LifecycleEvent, 'release' | '
|
|
34
|
+
export type ParkEvent = Extract<LifecycleEvent, 'release' | 'park' | 'cancel'>;
|
|
33
35
|
export declare function parkEvent(nodeId: string, lifecycle: Lifecycle): ParkEvent;
|
|
34
|
-
/** Apply an unattended decision and notify subscribers of automatic completion.
|
|
36
|
+
/** Apply an unattended decision and notify subscribers of automatic completion.
|
|
37
|
+
* This is the DEGRADED enactment: no summary turn ran, so subscribers get the
|
|
38
|
+
* doctrine wake instead of the node's own report. The live clock's good path
|
|
39
|
+
* (broker settlement after a parking turn) enacts `park` directly and fans
|
|
40
|
+
* nothing — the node's report is the notice. */
|
|
35
41
|
export declare function applyParkEvent(nodeId: string, event: ParkEvent): void;
|
|
@@ -36,13 +36,17 @@ export function parkEvent(nodeId, lifecycle) {
|
|
|
36
36
|
if (readInboxSince(nodeId, readCursor(nodeId)).length > 0)
|
|
37
37
|
return 'release';
|
|
38
38
|
if (lifecycle === 'resident')
|
|
39
|
-
return '
|
|
39
|
+
return 'park';
|
|
40
40
|
return hasLiveSubscriber(nodeId) ? 'release' : 'cancel';
|
|
41
41
|
}
|
|
42
|
-
/** Apply an unattended decision and notify subscribers of automatic completion.
|
|
42
|
+
/** Apply an unattended decision and notify subscribers of automatic completion.
|
|
43
|
+
* This is the DEGRADED enactment: no summary turn ran, so subscribers get the
|
|
44
|
+
* doctrine wake instead of the node's own report. The live clock's good path
|
|
45
|
+
* (broker settlement after a parking turn) enacts `park` directly and fans
|
|
46
|
+
* nothing — the node's report is the notice. */
|
|
43
47
|
export function applyParkEvent(nodeId, event) {
|
|
44
48
|
const node = transition(nodeId, event);
|
|
45
|
-
if (event !== '
|
|
49
|
+
if (event !== 'park')
|
|
46
50
|
return;
|
|
47
51
|
fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child done — ${fullName(node)} (${nodeId}) was automatically marked done after its unattended grace with nothing left to wake it. It sent no final report.`, { reason: 'child-auto-done', child: nodeId });
|
|
48
52
|
}
|
|
@@ -2,7 +2,6 @@ export declare const DEAD_REAP_GRACE_MS: number;
|
|
|
2
2
|
/** Recurring storage cleanup. The in-memory throttle clocks intentionally reset
|
|
3
3
|
* on daemon boot, so the first tick runs both full sweeps. */
|
|
4
4
|
export declare class StorageMaintenanceReconciler {
|
|
5
|
-
private lastPruneAt;
|
|
6
5
|
private lastSpareSweepAt;
|
|
7
6
|
private lastGhostSweepAt;
|
|
8
7
|
run(now: number): void;
|
|
@@ -19,9 +18,6 @@ export declare class StorageMaintenanceReconciler {
|
|
|
19
18
|
/** Delete focus rows whose recorded tmux pane no longer exists. A failed tmux
|
|
20
19
|
* probe is "can't tell" and never authorizes deleting every viewport. */
|
|
21
20
|
private gcStaleFocuses;
|
|
22
|
-
/** Count-based full-canvas prune, throttled to once per hour. Live rows are
|
|
23
|
-
* never deleted by pruneToLimit, so the true floor can exceed AUTO_PRUNE_MAX. */
|
|
24
|
-
private autoPrune;
|
|
25
21
|
/** Collect dead or expired hidden warm-pool spares. */
|
|
26
22
|
private gcWarmPool;
|
|
27
23
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { closeFocusRow, listFocuses, listNodes,
|
|
3
|
+
import { closeFocusRow, listFocuses, listNodes, reapGhostRows, } from '../../core/canvas/index.js';
|
|
4
4
|
import { isSafeNodeId, jobDir, nodeDir } from '../../core/canvas/paths.js';
|
|
5
5
|
import { emitEvent } from '../../core/events/emit.js';
|
|
6
6
|
import { operationIdContext } from '../../core/events/operation-id.js';
|
|
@@ -9,14 +9,11 @@ import { reapStaleSpares } from '../../core/runtime/warm-pool.js';
|
|
|
9
9
|
// How long a dead node's on-disk record must be quiet before its leftover
|
|
10
10
|
// placement is reaped. A fresh crash keeps its pane for inspection.
|
|
11
11
|
export const DEAD_REAP_GRACE_MS = 10 * 60_000;
|
|
12
|
-
const AUTO_PRUNE_MAX = 2000;
|
|
13
|
-
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
14
12
|
const SPARE_SWEEP_INTERVAL_MS = 60 * 1000;
|
|
15
13
|
const GHOST_SWEEP_INTERVAL_MS = 60 * 1000;
|
|
16
14
|
/** Recurring storage cleanup. The in-memory throttle clocks intentionally reset
|
|
17
15
|
* on daemon boot, so the first tick runs both full sweeps. */
|
|
18
16
|
export class StorageMaintenanceReconciler {
|
|
19
|
-
lastPruneAt = Number.NEGATIVE_INFINITY;
|
|
20
17
|
lastSpareSweepAt = Number.NEGATIVE_INFINITY;
|
|
21
18
|
lastGhostSweepAt = Number.NEGATIVE_INFINITY;
|
|
22
19
|
run(now) {
|
|
@@ -26,7 +23,6 @@ export class StorageMaintenanceReconciler {
|
|
|
26
23
|
this.reapDeadResidue(now);
|
|
27
24
|
this.gcStaleFocuses();
|
|
28
25
|
this.reapGhostRows(now);
|
|
29
|
-
this.autoPrune(now);
|
|
30
26
|
this.gcWarmPool(now);
|
|
31
27
|
}
|
|
32
28
|
/** Drop rows whose on-disk node dir is gone. Such a row can never be revived,
|
|
@@ -192,34 +188,6 @@ export class StorageMaintenanceReconciler {
|
|
|
192
188
|
return;
|
|
193
189
|
}
|
|
194
190
|
}
|
|
195
|
-
/** Count-based full-canvas prune, throttled to once per hour. Live rows are
|
|
196
|
-
* never deleted by pruneToLimit, so the true floor can exceed AUTO_PRUNE_MAX. */
|
|
197
|
-
autoPrune(now) {
|
|
198
|
-
if (now - this.lastPruneAt < PRUNE_INTERVAL_MS)
|
|
199
|
-
return;
|
|
200
|
-
this.lastPruneAt = now;
|
|
201
|
-
operationIdContext.fresh(() => {
|
|
202
|
-
try {
|
|
203
|
-
const before = listNodes().length;
|
|
204
|
-
const { pruned } = pruneToLimit({ maxNodes: AUTO_PRUNE_MAX });
|
|
205
|
-
if (pruned.length > 0) {
|
|
206
|
-
emitEvent({
|
|
207
|
-
level: 'info',
|
|
208
|
-
event: 'canvas.auto_prune.completed',
|
|
209
|
-
outcome: 'succeeded',
|
|
210
|
-
fields: {
|
|
211
|
-
before_count: before,
|
|
212
|
-
kept_count: before - pruned.length,
|
|
213
|
-
pruned_count: pruned.length,
|
|
214
|
-
},
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
catch (err) {
|
|
219
|
-
emitEvent({ level: 'error', event: 'canvas.auto_prune.failed', error: err });
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
191
|
/** Collect dead or expired hidden warm-pool spares. */
|
|
224
192
|
gcWarmPool(now) {
|
|
225
193
|
if (now - this.lastSpareSweepAt < SPARE_SWEEP_INTERVAL_MS)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Regression: the `pre-command` gate holds a bash command BEFORE it runs and
|
|
2
|
+
// releases it once its memory is in the transcript. The release is not a stored
|
|
3
|
+
// decision — it is the exposure ledger, and that is exactly what makes it
|
|
4
|
+
// fragile:
|
|
5
|
+
//
|
|
6
|
+
// • Compaction drops the delivered guidance out of the transcript while the
|
|
7
|
+
// ledger still claims it was delivered. Without the demotion pass the doc is
|
|
8
|
+
// held once and then silently absent for the rest of the session. Worse, a
|
|
9
|
+
// demotion that left a rankless entry behind would crash the node's whole
|
|
10
|
+
// substrate on its next load — so the ledger is reloaded FROM DISK here.
|
|
11
|
+
// • pi preflights the siblings of one assistant message sequentially and then
|
|
12
|
+
// executes them concurrently, so without a per-turn flag the second sibling
|
|
13
|
+
// would find the doc already exposed and slip past the hold its sibling just
|
|
14
|
+
// raised.
|
|
15
|
+
// • A session whose corpus carries no `pre-command` doc at all is the common
|
|
16
|
+
// case and must never pay a daemon round-trip. Proven by closing the daemon:
|
|
17
|
+
// if the short-circuit stopped working the call would fail loud.
|
|
18
|
+
//
|
|
19
|
+
// The gate runs against the real canvas api server, so the subject the matcher
|
|
20
|
+
// gates on is the one crtrd actually serves.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --conditions=crtr-src --import tsx/esm --test src/pi-extensions/__tests__/pre-command-gate.test.ts
|
|
23
|
+
import { test, before, beforeEach, afterEach, after } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { registerCanvasDocSubstrate } from '../canvas-doc-substrate.js';
|
|
29
|
+
import { createNode } from '../../core/canvas/canvas.js';
|
|
30
|
+
import { closeDb } from '../../core/canvas/db.js';
|
|
31
|
+
import { apiSocketPath } from '../../core/canvas/paths.js';
|
|
32
|
+
import { resetScopeCache } from '../../core/scope.js';
|
|
33
|
+
import { loadContextExposureState } from '../../core/substrate/injected-store.js';
|
|
34
|
+
import { clearSessionCache } from '../../core/substrate/session-cache.js';
|
|
35
|
+
import { createApiServer } from '../../daemon/api/server.js';
|
|
36
|
+
const DOCTRINE_BODY = 'PRE-COMMAND-DOCTRINE-BODY';
|
|
37
|
+
let home;
|
|
38
|
+
let cwd;
|
|
39
|
+
let server = null;
|
|
40
|
+
let origCwd;
|
|
41
|
+
let origHome;
|
|
42
|
+
let origNode;
|
|
43
|
+
let origProfile;
|
|
44
|
+
async function waitForSocket(path) {
|
|
45
|
+
for (let i = 0; i < 200; i++) {
|
|
46
|
+
if (existsSync(path))
|
|
47
|
+
return;
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`api socket never bound at ${path}`);
|
|
51
|
+
}
|
|
52
|
+
async function closeServer() {
|
|
53
|
+
const running = server;
|
|
54
|
+
server = null;
|
|
55
|
+
if (running !== null)
|
|
56
|
+
await running.close();
|
|
57
|
+
}
|
|
58
|
+
/** A user-scope doc that holds every `deploy …` invocation until it is read. */
|
|
59
|
+
function writeDoctrineDoc(event) {
|
|
60
|
+
const store = join(home, 'user', '.crouter', 'memory');
|
|
61
|
+
mkdirSync(store, { recursive: true });
|
|
62
|
+
const path = join(store, 'deploy-doctrine.md');
|
|
63
|
+
writeFileSync(path, '---\nkind: knowledge\n' +
|
|
64
|
+
'when-and-why-to-read: When deploying, this knowledge should be read because it carries the approval boundary.\n' +
|
|
65
|
+
`surfaces:\n - {on: ${event}, at: content, match: "deploy*"}\n---\n` +
|
|
66
|
+
`${DOCTRINE_BODY}\n`);
|
|
67
|
+
clearSessionCache();
|
|
68
|
+
return realpathSync(path);
|
|
69
|
+
}
|
|
70
|
+
/** A canvas node the api server can assemble a subject for, selected as the
|
|
71
|
+
* session's node. Each test uses its own id: the extension's exposure state is
|
|
72
|
+
* a per-node process singleton. */
|
|
73
|
+
function seedNode(id) {
|
|
74
|
+
createNode({
|
|
75
|
+
node_id: id,
|
|
76
|
+
name: id,
|
|
77
|
+
created: new Date().toISOString(),
|
|
78
|
+
cwd,
|
|
79
|
+
kind: 'general',
|
|
80
|
+
mode: 'base',
|
|
81
|
+
lifecycle: 'terminal',
|
|
82
|
+
status: 'active',
|
|
83
|
+
});
|
|
84
|
+
mkdirSync(join(home, 'nodes', id), { recursive: true });
|
|
85
|
+
process.env['CRTR_NODE_ID'] = id;
|
|
86
|
+
}
|
|
87
|
+
/** The registered extension, driven through the pi events it actually binds. */
|
|
88
|
+
function registerSubstrate() {
|
|
89
|
+
const handlers = new Map();
|
|
90
|
+
registerCanvasDocSubstrate({
|
|
91
|
+
on(event, handler) {
|
|
92
|
+
const bound = handlers.get(event) ?? [];
|
|
93
|
+
bound.push(handler);
|
|
94
|
+
handlers.set(event, bound);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
async function fire(event, payload) {
|
|
98
|
+
const bound = handlers.get(event) ?? [];
|
|
99
|
+
assert.equal(bound.length, 1, `the substrate binds exactly one ${event} handler`);
|
|
100
|
+
return await bound[0](payload, {});
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
toolCall: (toolName, input) => fire('tool_call', { toolName, input }),
|
|
104
|
+
bash: (command) => fire('tool_call', { toolName: 'bash', input: { command } }),
|
|
105
|
+
endTurn: async () => void (await fire('turn_end', {})),
|
|
106
|
+
compact: async () => void (await fire('session_compact', {})),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function assertHeld(result, what) {
|
|
110
|
+
assert.ok(result !== undefined, `${what} is held`);
|
|
111
|
+
assert.equal(result.block, true);
|
|
112
|
+
assert.ok(result.reason.startsWith('HELD — nothing ran.'), `the held result opens with the notice; got: ${result.reason}`);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
before(() => {
|
|
116
|
+
origCwd = process.cwd();
|
|
117
|
+
origHome = process.env['HOME'];
|
|
118
|
+
origNode = process.env['CRTR_NODE_ID'];
|
|
119
|
+
origProfile = process.env['CRTR_PROFILE_ID'];
|
|
120
|
+
});
|
|
121
|
+
beforeEach(async () => {
|
|
122
|
+
closeDb();
|
|
123
|
+
if (home)
|
|
124
|
+
rmSync(home, { recursive: true, force: true });
|
|
125
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-pre-command-'));
|
|
126
|
+
cwd = join(home, 'user', 'work');
|
|
127
|
+
mkdirSync(cwd, { recursive: true });
|
|
128
|
+
process.chdir(cwd);
|
|
129
|
+
process.env['HOME'] = join(home, 'user');
|
|
130
|
+
process.env['CRTR_HOME'] = home;
|
|
131
|
+
delete process.env['CRTR_NODE_ID'];
|
|
132
|
+
delete process.env['CRTR_PROFILE_ID'];
|
|
133
|
+
resetScopeCache();
|
|
134
|
+
clearSessionCache();
|
|
135
|
+
server = createApiServer();
|
|
136
|
+
await waitForSocket(apiSocketPath());
|
|
137
|
+
});
|
|
138
|
+
afterEach(async () => {
|
|
139
|
+
try {
|
|
140
|
+
await closeServer();
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
process.chdir(origCwd);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
after(() => {
|
|
147
|
+
process.chdir(origCwd);
|
|
148
|
+
closeDb();
|
|
149
|
+
if (home)
|
|
150
|
+
rmSync(home, { recursive: true, force: true });
|
|
151
|
+
resetScopeCache();
|
|
152
|
+
clearSessionCache();
|
|
153
|
+
delete process.env['CRTR_HOME'];
|
|
154
|
+
if (origHome === undefined)
|
|
155
|
+
delete process.env['HOME'];
|
|
156
|
+
else
|
|
157
|
+
process.env['HOME'] = origHome;
|
|
158
|
+
if (origNode === undefined)
|
|
159
|
+
delete process.env['CRTR_NODE_ID'];
|
|
160
|
+
else
|
|
161
|
+
process.env['CRTR_NODE_ID'] = origNode;
|
|
162
|
+
if (origProfile === undefined)
|
|
163
|
+
delete process.env['CRTR_PROFILE_ID'];
|
|
164
|
+
else
|
|
165
|
+
process.env['CRTR_PROFILE_ID'] = origProfile;
|
|
166
|
+
});
|
|
167
|
+
test('a matching command is held with its doc, every sibling in the message with it, and the re-issue runs', async () => {
|
|
168
|
+
const docPath = writeDoctrineDoc('pre-command');
|
|
169
|
+
seedNode('gate-hold');
|
|
170
|
+
const pi = registerSubstrate();
|
|
171
|
+
const held = assertHeld(await pi.bash('deploy prod'), 'the first matching command');
|
|
172
|
+
assert.ok(held.reason.includes(DOCTRINE_BODY), `the doc rides back as the blocked call's result; got: ${held.reason}`);
|
|
173
|
+
assert.match(held.reason, /re-issue your WHOLE command/, 'the copy steers a whole-command re-issue, never one clause');
|
|
174
|
+
// Exposure is registered AT BLOCK TIME — physical identity and content alias
|
|
175
|
+
// both — and that single fact is what releases the re-issue below.
|
|
176
|
+
const afterBlock = loadContextExposureState('gate-hold');
|
|
177
|
+
assert.deepEqual(afterBlock.exposures.get(docPath), { transcript: 3 }, 'the held doc is recorded as delivered');
|
|
178
|
+
assert.equal(afterBlock.exposures.size, 2, 'the content alias is recorded alongside the path');
|
|
179
|
+
// Siblings of the same assistant message. The doc is already exposed, so
|
|
180
|
+
// without the per-turn flag the render would come back empty and let them
|
|
181
|
+
// through. The flag covers the whole message, matching or not: every command
|
|
182
|
+
// in it was authored under the worldview this doc just corrected.
|
|
183
|
+
const sibling = assertHeld(await pi.bash('deploy staging'), 'a matching sibling');
|
|
184
|
+
assert.match(sibling.reason, /Another command in this same message/);
|
|
185
|
+
assertHeld(await pi.bash('ls -la'), 'a non-matching sibling of a held command');
|
|
186
|
+
await pi.endTurn();
|
|
187
|
+
// The release: the doc is in the transcript, so there is nothing left to
|
|
188
|
+
// deliver and nothing left to hold.
|
|
189
|
+
assert.equal(await pi.bash('deploy prod'), undefined, 'the identical command re-issued in a later turn runs');
|
|
190
|
+
assert.equal(await pi.bash('ls -la'), undefined, 'a command matching nothing is untouched');
|
|
191
|
+
});
|
|
192
|
+
test('compaction re-arms the hold and leaves an exposure ledger that reloads', async () => {
|
|
193
|
+
const docPath = writeDoctrineDoc('pre-command');
|
|
194
|
+
seedNode('gate-compact');
|
|
195
|
+
const pi = registerSubstrate();
|
|
196
|
+
assertHeld(await pi.bash('deploy prod'), 'the first matching command');
|
|
197
|
+
await pi.endTurn();
|
|
198
|
+
assert.equal(await pi.bash('deploy prod'), undefined, 'the doc is in the transcript, so the command runs');
|
|
199
|
+
await pi.endTurn();
|
|
200
|
+
await pi.compact();
|
|
201
|
+
// Reloaded from disk: the demotion must DELETE the rankless entry rather than
|
|
202
|
+
// leave `{}` behind, which the v3 loader rejects outright — this call is the
|
|
203
|
+
// proof it did.
|
|
204
|
+
const afterCompact = loadContextExposureState('gate-compact');
|
|
205
|
+
assert.equal(afterCompact.exposures.has(docPath), false, 'compaction forgets the delivery that compaction dropped');
|
|
206
|
+
assert.equal(afterCompact.exposures.size, 0, 'the content alias is forgotten too, and no rankless entry survives');
|
|
207
|
+
const reheld = assertHeld(await pi.bash('deploy prod'), 'the same command after compaction');
|
|
208
|
+
assert.ok(reheld.reason.includes(DOCTRINE_BODY), 'guidance the transcript no longer carries is delivered again');
|
|
209
|
+
});
|
|
210
|
+
test('a corpus with no pre-command doc never reaches the daemon', async () => {
|
|
211
|
+
writeDoctrineDoc('command');
|
|
212
|
+
seedNode('gate-quiet');
|
|
213
|
+
const pi = registerSubstrate();
|
|
214
|
+
// No daemon. Every path below must return before the subject lookup; one that
|
|
215
|
+
// does not fails loud with `daemon_unavailable` instead of passing quietly.
|
|
216
|
+
await closeServer();
|
|
217
|
+
assert.equal(await pi.toolCall('read', { file: '/tmp/x' }), undefined, 'a non-bash call is not the gate’s business');
|
|
218
|
+
assert.equal(await pi.toolCall('bash', { command: ' ' }), undefined, 'a bash call carrying no command is not held');
|
|
219
|
+
assert.equal(await pi.bash('deploy prod'), undefined, 'a post-execution `command` doc never holds anything');
|
|
220
|
+
});
|