@north-light/crouter 0.3.300 → 0.3.302
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/dto/reports.d.ts +8 -8
- package/dist/clients/attach/session/profile-files.d.ts +6 -3
- package/dist/clients/attach/session/profile-files.js +39 -10
- package/dist/clients/attach/viewer.js +663 -663
- package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +30 -1
- package/dist/commands/memory/read.js +12 -14
- package/dist/commands/node/create.js +1 -1
- package/dist/commands/node/lifecycle.js +36 -8
- package/dist/commands/push.js +4 -4
- package/dist/core/__tests__/human-action-delivery.test.js +13 -11
- package/dist/core/__tests__/integration/revive.test.js +3 -3
- package/dist/core/__tests__/node-outcome.test.js +1 -6
- package/dist/core/__tests__/seam/dormancy-release.test.js +42 -0
- package/dist/core/__tests__/stop-guard.test.js +64 -0
- package/dist/core/bash-jobs.d.ts +22 -0
- package/dist/core/bash-jobs.js +38 -1
- package/dist/core/canvas/crons.d.ts +20 -4
- package/dist/core/canvas/crons.js +54 -7
- package/dist/core/canvas/delivery-contract.d.ts +19 -0
- package/dist/core/canvas/delivery-contract.js +8 -0
- package/dist/core/canvas/human-deliveries.d.ts +3 -17
- package/dist/core/canvas/node-outcome-deliveries.d.ts +3 -19
- package/dist/core/canvas/node-outcome-deliveries.js +0 -4
- package/dist/core/keybindings/catalog.js +1 -1
- package/dist/core/memory-resolver.d.ts +3 -1
- package/dist/core/memory-resolver.js +3 -3
- package/dist/core/nested-stores.d.ts +1 -1
- package/dist/core/nested-stores.js +3 -3
- package/dist/core/runtime/bearings-render.js +1 -1
- package/dist/core/runtime/close.js +8 -6
- package/dist/core/runtime/stop-guard.js +6 -6
- package/dist/core/worktree.js +3 -6
- package/dist/daemon/__tests__/node-outcome-delivery.test.js +3 -2
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +21 -11
- package/dist/daemon/api/handlers/reports.js +1 -1
- package/dist/daemon/api/map.d.ts +1 -1
- package/dist/daemon/api/map.js +2 -2
- package/dist/daemon/cron/sinks.d.ts +1 -1
- package/dist/daemon/cron/sinks.js +2 -2
- package/dist/daemon/delivery/run-delivery.d.ts +25 -0
- package/dist/daemon/delivery/run-delivery.js +164 -0
- package/dist/daemon/human/deliver-action.d.ts +1 -14
- package/dist/daemon/human/deliver-action.js +5 -165
- package/dist/daemon/node-outcome/deliver-outcome.d.ts +1 -12
- package/dist/daemon/node-outcome/deliver-outcome.js +6 -152
- package/dist/daemon/reconcilers/live-obligation.js +2 -2
- package/dist/pi-extensions/canvas-bash-valve.js +7 -10
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
- package/dist/daemon/cron-sink.d.ts +0 -26
- package/dist/daemon/cron-sink.js +0 -43
|
@@ -3,6 +3,16 @@ export type CronOverlap = 'skip' | 'queue' | 'replace';
|
|
|
3
3
|
export type CronOnOutput = 'silent' | 'on-failure' | 'always' | 'on-change';
|
|
4
4
|
export type CronState = 'active' | 'paused';
|
|
5
5
|
export type CronRunState = 'idle' | 'running';
|
|
6
|
+
/** The decoded delivery target of a stored cron sink. */
|
|
7
|
+
export type ParsedCronSink = {
|
|
8
|
+
kind: 'node';
|
|
9
|
+
node: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'spawn';
|
|
12
|
+
spawn: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'human';
|
|
15
|
+
} | null;
|
|
6
16
|
/** A hydrated `crons` row. */
|
|
7
17
|
export interface Cron {
|
|
8
18
|
cron_id: string;
|
|
@@ -96,6 +106,10 @@ export interface CronRunRecord {
|
|
|
96
106
|
/** Ring bound for `cron_runs`: recordCronRun prunes each cron's history to
|
|
97
107
|
* its most recent this-many rows. */
|
|
98
108
|
export declare const CRON_RUN_RING = 20;
|
|
109
|
+
/** Decode the stored cron sink JSON. A malformed or unrecognised value has no sink. */
|
|
110
|
+
export declare function parseCronSink(sinkJson: string): ParsedCronSink;
|
|
111
|
+
/** Render a stored cron sink as the arm-time CLI spec, or null when it has no destination. */
|
|
112
|
+
export declare function cronSinkDisplay(sinkJson: string): string | null;
|
|
99
113
|
/** The ONLY sanctioned way to signal a cron run's process group from a stored
|
|
100
114
|
* row. Re-captures the CURRENT identity of `run_pid` and signals only when it
|
|
101
115
|
* MATCHES the fingerprint recorded at lease acquisition. Returns whether the
|
|
@@ -180,10 +194,12 @@ export declare function setCronState(cron_id: string, state: CronState): boolean
|
|
|
180
194
|
* previous run's stdout. Written after EVERY settled run of an on-change
|
|
181
195
|
* cron, delivered or not. */
|
|
182
196
|
export declare function setCronLastOutputHash(cron_id: string, hash: string): void;
|
|
183
|
-
/** True when
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
|
|
197
|
+
/** True when a cron can still wake this node. An active deadline is a wake
|
|
198
|
+
* source by its anchor; output delivery is a wake source by its decoded node
|
|
199
|
+
* sink. Scheduled rows must remain active and unexpired, while a leased run
|
|
200
|
+
* remains eligible through settlement even if it has since been paused or
|
|
201
|
+
* expired. */
|
|
202
|
+
export declare function hasPendingCronWake(nodeId: string): boolean;
|
|
187
203
|
/** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
|
|
188
204
|
* A real wake consumes the wait at its delivery boundary or, as a dormant
|
|
189
205
|
* fallback, in reviveNode; recovery replacement is not a wake. Terminal
|
|
@@ -60,6 +60,36 @@ function cronFrom(r) {
|
|
|
60
60
|
updated: r['updated'],
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
|
+
/** Decode the stored cron sink JSON. A malformed or unrecognised value has no sink. */
|
|
64
|
+
export function parseCronSink(sinkJson) {
|
|
65
|
+
try {
|
|
66
|
+
const sink = JSON.parse(sinkJson);
|
|
67
|
+
if (typeof sink['node'] === 'string')
|
|
68
|
+
return { kind: 'node', node: sink['node'] };
|
|
69
|
+
if (typeof sink['spawn'] === 'string')
|
|
70
|
+
return { kind: 'spawn', spawn: sink['spawn'] };
|
|
71
|
+
if (sink['human'] === true)
|
|
72
|
+
return { kind: 'human' };
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// A malformed stored value cannot deliver but remains listable and runnable.
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/** Render a stored cron sink as the arm-time CLI spec, or null when it has no destination. */
|
|
80
|
+
export function cronSinkDisplay(sinkJson) {
|
|
81
|
+
const sink = parseCronSink(sinkJson);
|
|
82
|
+
if (sink === null)
|
|
83
|
+
return null;
|
|
84
|
+
switch (sink.kind) {
|
|
85
|
+
case 'node':
|
|
86
|
+
return `node:${sink.node}`;
|
|
87
|
+
case 'spawn':
|
|
88
|
+
return `spawn:${sink.spawn}`;
|
|
89
|
+
case 'human':
|
|
90
|
+
return 'human';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
// Signal safety
|
|
64
94
|
/** The ONLY sanctioned way to signal a cron run's process group from a stored
|
|
65
95
|
* row. Re-captures the CURRENT identity of `run_pid` and signals only when it
|
|
@@ -261,13 +291,30 @@ export function setCronLastOutputHash(cron_id, hash) {
|
|
|
261
291
|
.prepare('UPDATE crons SET last_output_hash = ?, updated = ? WHERE cron_id = ?')
|
|
262
292
|
.run(hash, new Date().toISOString(), cron_id);
|
|
263
293
|
}
|
|
264
|
-
/** True when
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
294
|
+
/** True when a cron can still wake this node. An active deadline is a wake
|
|
295
|
+
* source by its anchor; output delivery is a wake source by its decoded node
|
|
296
|
+
* sink. Scheduled rows must remain active and unexpired, while a leased run
|
|
297
|
+
* remains eligible through settlement even if it has since been paused or
|
|
298
|
+
* expired. */
|
|
299
|
+
export function hasPendingCronWake(nodeId) {
|
|
300
|
+
const rows = openDb().prepare(`SELECT anchor_node, cancel_on_wake, sink
|
|
301
|
+
FROM crons
|
|
302
|
+
WHERE (anchor_node = ? AND cancel_on_wake = 1 AND state = 'active')
|
|
303
|
+
OR (
|
|
304
|
+
on_output IN ('always', 'on-change')
|
|
305
|
+
AND (
|
|
306
|
+
run_state = 'running'
|
|
307
|
+
OR (run_state = 'idle' AND state = 'active' AND (expires_at IS NULL OR expires_at > ?))
|
|
308
|
+
)
|
|
309
|
+
)`).all(nodeId, new Date().toISOString());
|
|
310
|
+
for (const row of rows) {
|
|
311
|
+
if (row['anchor_node'] === nodeId && Number(row['cancel_on_wake']) !== 0)
|
|
312
|
+
return true;
|
|
313
|
+
const sink = parseCronSink(row['sink']);
|
|
314
|
+
if (sink?.kind === 'node' && sink.node === nodeId)
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
271
318
|
}
|
|
272
319
|
/** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
|
|
273
320
|
* A real wake consumes the wait at its delivery boundary or, as a dormant
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface DeliveryFailure {
|
|
2
|
+
kind: 'exit' | 'signal' | 'timeout' | 'spawn_error';
|
|
3
|
+
exitCode?: number;
|
|
4
|
+
signal?: string;
|
|
5
|
+
message?: string;
|
|
6
|
+
stderr?: string;
|
|
7
|
+
}
|
|
8
|
+
export type DeliverySettlement = {
|
|
9
|
+
state: 'accepted';
|
|
10
|
+
} | {
|
|
11
|
+
state: 'permanent_failed';
|
|
12
|
+
failure: DeliveryFailure;
|
|
13
|
+
} | {
|
|
14
|
+
state: 'pending';
|
|
15
|
+
nextAttemptAt: number;
|
|
16
|
+
failure: DeliveryFailure;
|
|
17
|
+
};
|
|
18
|
+
/** Durable exponential retry delay for the attempt that just failed. */
|
|
19
|
+
export declare function deliveryBackoffMs(attempt: number): number;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The durable contract shared by every retried delivery: what a failed attempt
|
|
2
|
+
// records, how an attempt settles, and when the next attempt becomes due. The
|
|
3
|
+
// canvas modules own the rows; `daemon/delivery/run-delivery.ts` owns the
|
|
4
|
+
// process that produces these settlements.
|
|
5
|
+
/** Durable exponential retry delay for the attempt that just failed. */
|
|
6
|
+
export function deliveryBackoffMs(attempt) {
|
|
7
|
+
return Math.min(5_000 * 2 ** (attempt - 1), 3_600_000);
|
|
8
|
+
}
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
+
import type { DeliveryFailure, DeliverySettlement } from './delivery-contract.js';
|
|
1
2
|
export type ActionDeliveryState = 'pending' | 'running' | 'accepted' | 'permanent_failed';
|
|
2
|
-
export
|
|
3
|
-
kind: 'exit' | 'signal' | 'timeout' | 'spawn_error';
|
|
4
|
-
exitCode?: number;
|
|
5
|
-
signal?: string;
|
|
6
|
-
message?: string;
|
|
7
|
-
stderr?: string;
|
|
8
|
-
}
|
|
3
|
+
export type ActionDeliveryFailure = DeliveryFailure;
|
|
9
4
|
export interface ActionDeliveryRecord {
|
|
10
5
|
requestId: string;
|
|
11
6
|
state: ActionDeliveryState;
|
|
@@ -37,16 +32,7 @@ export declare function readActionDelivery(requestId: string): ActionDeliveryRec
|
|
|
37
32
|
export declare function dueActionDeliveries(now: number): ActionDeliveryRecord[];
|
|
38
33
|
/** Atomically start one due delivery attempt. A null result lost the claim. */
|
|
39
34
|
export declare function claimActionDelivery(requestId: string, now: number, claimOwner: string): ActionDeliveryRecord | null;
|
|
40
|
-
export type ActionDeliverySettlement =
|
|
41
|
-
state: 'accepted';
|
|
42
|
-
} | {
|
|
43
|
-
state: 'permanent_failed';
|
|
44
|
-
failure: ActionDeliveryFailure;
|
|
45
|
-
} | {
|
|
46
|
-
state: 'pending';
|
|
47
|
-
nextAttemptAt: number;
|
|
48
|
-
failure: ActionDeliveryFailure;
|
|
49
|
-
};
|
|
35
|
+
export type ActionDeliverySettlement = DeliverySettlement;
|
|
50
36
|
/** Settle only the still-owned running attempt, so a stale process cannot overwrite a newer claim. */
|
|
51
37
|
export declare function settleActionDelivery(requestId: string, attempt: number, claimOwner: string, settlement: ActionDeliverySettlement, now: number): boolean;
|
|
52
38
|
/** A new daemon deliberately redelivers interrupted completions at least once. */
|
|
@@ -1,14 +1,7 @@
|
|
|
1
|
+
import type { DeliveryFailure, DeliverySettlement } from './delivery-contract.js';
|
|
1
2
|
import { type NodeOutcomeKind, type TerminalReason } from './types.js';
|
|
2
3
|
export type OutcomeDeliveryState = 'armed' | 'pending' | 'running' | 'accepted' | 'permanent_failed';
|
|
3
|
-
export
|
|
4
|
-
kind: 'exit' | 'signal' | 'timeout' | 'spawn_error';
|
|
5
|
-
exitCode?: number;
|
|
6
|
-
signal?: string;
|
|
7
|
-
message?: string;
|
|
8
|
-
stderr?: string;
|
|
9
|
-
}
|
|
10
|
-
/** Durable exponential retry delay for the attempt that just failed. */
|
|
11
|
-
export declare function outcomeDeliveryBackoffMs(attempt: number): number;
|
|
4
|
+
export type OutcomeDeliveryFailure = DeliveryFailure;
|
|
12
5
|
export interface OutcomeDeliveryRecord {
|
|
13
6
|
nodeId: string;
|
|
14
7
|
state: OutcomeDeliveryState;
|
|
@@ -61,16 +54,7 @@ export declare function armOutcomeDelivery(row: {
|
|
|
61
54
|
export declare function readOutcomeDelivery(nodeId: string): OutcomeDeliveryRecord | null;
|
|
62
55
|
export declare function dueOutcomeDeliveries(now: number): OutcomeDeliveryRecord[];
|
|
63
56
|
export declare function claimOutcomeDelivery(nodeId: string, now: number, claimOwner: string): OutcomeDeliveryRecord | null;
|
|
64
|
-
export type OutcomeDeliverySettlement =
|
|
65
|
-
state: 'accepted';
|
|
66
|
-
} | {
|
|
67
|
-
state: 'permanent_failed';
|
|
68
|
-
failure: OutcomeDeliveryFailure;
|
|
69
|
-
} | {
|
|
70
|
-
state: 'pending';
|
|
71
|
-
nextAttemptAt: number;
|
|
72
|
-
failure: OutcomeDeliveryFailure;
|
|
73
|
-
};
|
|
57
|
+
export type OutcomeDeliverySettlement = DeliverySettlement;
|
|
74
58
|
/** Settle only the still-owned running attempt, fencing stale delivery processes. */
|
|
75
59
|
export declare function settleOutcomeDelivery(nodeId: string, attempt: number, claimOwner: string, settlement: OutcomeDeliverySettlement, now: number): boolean;
|
|
76
60
|
export declare function recoverRunningOutcomeDeliveries(now: number): number;
|
|
@@ -3,10 +3,6 @@ import { openDb, withCanvasWrite } from './db.js';
|
|
|
3
3
|
import { getNode } from './canvas.js';
|
|
4
4
|
import { OUTCOME_PAYLOAD_MAX_BYTES } from './types.js';
|
|
5
5
|
import { composeNodeOutcomeDocument } from '../runtime/outcome-document.js';
|
|
6
|
-
/** Durable exponential retry delay for the attempt that just failed. */
|
|
7
|
-
export function outcomeDeliveryBackoffMs(attempt) {
|
|
8
|
-
return Math.min(5_000 * 2 ** (attempt - 1), 3_600_000);
|
|
9
|
-
}
|
|
10
6
|
function serializedPayload(payload) {
|
|
11
7
|
if (payload === undefined)
|
|
12
8
|
return null;
|
|
@@ -84,7 +84,7 @@ export const BINDING_CATALOG = Object.freeze([
|
|
|
84
84
|
...attachEntry('crtr.tmux.menu.attach.model-ladder.previous', 'crtr.attach.model-ladder.previous', 'Previous model', 'Move to the previous model ladder rung.', ['['], ['alt+shift+m']),
|
|
85
85
|
...attachEntry('crtr.tmux.menu.attach.command.inspect', 'crtr.attach.command.inspect', 'Inspect loaded command', 'Open the loaded slash command’s expanded prompt.', ['h'], ['alt+shift+h']),
|
|
86
86
|
...attachEntry('crtr.tmux.menu.attach.file-review', 'crtr.attach.file-review', 'Review a file', 'Open a line-anchored review of a file named in the transcript.', [], ['alt+shift+r']),
|
|
87
|
-
...attachEntry('crtr.tmux.menu.attach.profile-files', 'crtr.attach.profile-files', 'Search profile files', 'Fuzzy-match path segments across the node family’s working directories, profile projects and memory, and ancestor crouter memory stores; Ctrl+Y copies a selected path.', ['f'], ['ctrl+f']),
|
|
87
|
+
...attachEntry('crtr.tmux.menu.attach.profile-files', 'crtr.attach.profile-files', 'Search profile files', 'Fuzzy-match path segments across the node family’s working directories, their context and reports directories, profile projects and memory, and ancestor crouter memory stores; Ctrl+Y copies a selected path.', ['f'], ['ctrl+f']),
|
|
88
88
|
...attachEntry('crtr.tmux.menu.attach.search', 'crtr.attach.search', 'Search transcript', 'Search the transcript as it is currently displayed — folded tool output is not searched until it is unfolded.', ['/'], ['alt+/']),
|
|
89
89
|
...attachEntry('crtr.tmux.menu.attach.whip', 'crtr.attach.whip', 'Whip agent', 'Interrupt the agent and send a playful command.', ['w'], []),
|
|
90
90
|
b('crtr.attach.scroll.up', 'Attach', 'Scroll up', 'Scroll the transcript one line up.', ['shift+up'], 'terminal', ATTACH_CONTEXTS),
|
|
@@ -145,6 +145,8 @@ export interface MemoryResolutionOpts extends MemoryCandidateOpts {
|
|
|
145
145
|
* boot render, on-read resolvedDocs, the slash-command snapshot, and
|
|
146
146
|
* persona resolution, which stay on the flat ancestor+profile stack. */
|
|
147
147
|
includeDescendants?: boolean;
|
|
148
|
+
/** Report when bounded descendant discovery omits an owner. Defaults to the inverse of `quiet`, so callers can preserve quiet malformed-document handling while still making an incomplete corpus visible to their caller. */
|
|
149
|
+
reportDiscovery?: boolean;
|
|
148
150
|
}
|
|
149
151
|
/** A loaded corpus and the exact-identity queries over it. */
|
|
150
152
|
export interface MemoryView {
|
|
@@ -195,7 +197,7 @@ export declare function openProjectMemoryStore(ownerDir: string): MemoryStoreDes
|
|
|
195
197
|
/** Every store a target sees, in precedence order: node, each project store
|
|
196
198
|
* nearest-first (its enabled plugins after it), then profile, user, builtin.
|
|
197
199
|
* One real store reached twice contributes once, at its first position. */
|
|
198
|
-
export declare function memoryStoresInPrecedence(target: MemoryTarget, scope?: MemoryScope, includeDescendants?: boolean,
|
|
200
|
+
export declare function memoryStoresInPrecedence(target: MemoryTarget, scope?: MemoryScope, includeDescendants?: boolean, reportDiscovery?: boolean): MemoryStoreDescriptor[];
|
|
199
201
|
/** The native (non-plugin) MOUNTED store dirs in resolution precedence.
|
|
200
202
|
* Addresses a store directly when the document itself may be absent, which is
|
|
201
203
|
* how a DELETED doc's revision log is still found: history outlives the doc,
|
|
@@ -193,7 +193,7 @@ export function openProjectMemoryStore(ownerDir) {
|
|
|
193
193
|
/** Every store a target sees, in precedence order: node, each project store
|
|
194
194
|
* nearest-first (its enabled plugins after it), then profile, user, builtin.
|
|
195
195
|
* One real store reached twice contributes once, at its first position. */
|
|
196
|
-
export function memoryStoresInPrecedence(target, scope, includeDescendants = false,
|
|
196
|
+
export function memoryStoresInPrecedence(target, scope, includeDescendants = false, reportDiscovery = true) {
|
|
197
197
|
const out = [];
|
|
198
198
|
const projectStores = (crtrRoot, projectMemory) => {
|
|
199
199
|
out.push(projectStoreDescriptor(dirname(crtrRoot), join(crtrRoot, 'memory'), projectMemory));
|
|
@@ -211,7 +211,7 @@ export function memoryStoresInPrecedence(target, scope, includeDescendants = fal
|
|
|
211
211
|
// nested one. They carry the neutral ceiling — no profile entry names
|
|
212
212
|
// them, and descendant discovery is off for every automatic delivery path.
|
|
213
213
|
if (includeDescendants) {
|
|
214
|
-
for (const root of descendantStoreRoots(annotated.map(({ root: r }) => r),
|
|
214
|
+
for (const root of descendantStoreRoots(annotated.map(({ root: r }) => r), reportDiscovery)) {
|
|
215
215
|
projectStores(root, NEUTRAL_PROJECT_MEMORY);
|
|
216
216
|
}
|
|
217
217
|
}
|
|
@@ -438,7 +438,7 @@ function makeView(stores, docs, scope) {
|
|
|
438
438
|
* with its migration remedy and contributes nothing. */
|
|
439
439
|
export function loadMemoryTargetView(target, opts = {}) {
|
|
440
440
|
const quiet = opts.quiet ?? false;
|
|
441
|
-
const stores = memoryStoresInPrecedence(target, opts.scope, opts.includeDescendants ?? false, quiet);
|
|
441
|
+
const stores = memoryStoresInPrecedence(target, opts.scope, opts.includeDescendants ?? false, opts.reportDiscovery ?? !quiet);
|
|
442
442
|
const docs = [];
|
|
443
443
|
for (const store of stores) {
|
|
444
444
|
if (store.mountStatus === 'absent')
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* excluding centrally gives the resolver and lint the identical call with no
|
|
10
10
|
* duplicated dedupe logic.
|
|
11
11
|
*/
|
|
12
|
-
export declare function descendantStoreRoots(ancestorRoots: string[],
|
|
12
|
+
export declare function descendantStoreRoots(ancestorRoots: string[], reportBudgetExceeded?: boolean): string[];
|
|
13
13
|
/** Complete descendant-store discovery for state migrations. Unlike the
|
|
14
14
|
* interactive addressing lane, this filesystem walk has no time or depth
|
|
15
15
|
* budget, crosses embedded repositories, admits plugin-only `.crouter` roots,
|
|
@@ -39,7 +39,7 @@ const cache = new Map();
|
|
|
39
39
|
* excluding centrally gives the resolver and lint the identical call with no
|
|
40
40
|
* duplicated dedupe logic.
|
|
41
41
|
*/
|
|
42
|
-
export function descendantStoreRoots(ancestorRoots,
|
|
42
|
+
export function descendantStoreRoots(ancestorRoots, reportBudgetExceeded = true) {
|
|
43
43
|
const key = [...ancestorRoots].sort().join('\n');
|
|
44
44
|
const cached = cache.get(key);
|
|
45
45
|
if (cached !== undefined)
|
|
@@ -56,8 +56,8 @@ export function descendantStoreRoots(ancestorRoots, quiet = false) {
|
|
|
56
56
|
owners.push(owner);
|
|
57
57
|
}
|
|
58
58
|
const tripBudget = (owner) => {
|
|
59
|
-
if (
|
|
60
|
-
warn(`memory: nested-store discovery under ${owner} exceeded its ${TIME_BUDGET_MS}ms budget — nested stores
|
|
59
|
+
if (reportBudgetExceeded) {
|
|
60
|
+
warn(`memory: nested-store discovery under ${owner} exceeded its ${TIME_BUDGET_MS}ms budget — this result may omit nested stores. To read a document from a known nested store, pass its owner as \`--dir <owner>\`; that exact-store read bypasses discovery.`);
|
|
61
61
|
}
|
|
62
62
|
};
|
|
63
63
|
const found = [];
|
|
@@ -237,5 +237,5 @@ export function buildIdentityAssertion(nodeId, kind, mode, forkFrom, sourceLabel
|
|
|
237
237
|
export function worktreeNote(worktree) {
|
|
238
238
|
if (worktree?.state !== 'open')
|
|
239
239
|
return '';
|
|
240
|
-
return `Branch: \`${worktree.branch}\`\nWorktree: \`${worktree.path}\`\nBase: \`${worktree.base_ref}\` @ \`${worktree.base_sha}\`\nCommit work here.
|
|
240
|
+
return `Branch: \`${worktree.branch}\`\nWorktree: \`${worktree.path}\`\nBase: \`${worktree.base_ref}\` @ \`${worktree.base_sha}\`\nCommit work here. Before finishing, land it with \`crtr node worktree close\` or keep the checkout and branch when the worktree is clean and its exact tip exists on any branch on origin. Auto-drop applies only when the worktree is clean (no uncommitted changes) and nothing remains to land (zero commits ahead of the base, or its commits are already contained in the base); otherwise, unlanded commits or uncommitted changes block finalization.`;
|
|
241
241
|
}
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Closing a node tears down the focused node and every descendant it
|
|
4
4
|
// EXCLUSIVELY owns, walking DOWN the subscribes_to spine (subscriptionsOf = a
|
|
5
|
-
// node's reports/children).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// node's reports/children). A node that never produced substantive assistant
|
|
6
|
+
// output is reaped and cannot be revived. Streaming nodes and nodes with an open
|
|
7
|
+
// managed worktree are retained; other closed nodes keep their pi session,
|
|
8
|
+
// canvas edges, and on-disk state for a later revive (`crtr node lifecycle
|
|
9
|
+
// revive` / focus → `pi --session <id>`).
|
|
8
10
|
//
|
|
9
11
|
// Per node, in this order — the order matters twice:
|
|
10
12
|
//
|
|
@@ -171,9 +173,9 @@ export function closeNode(rootId, opts = {}) {
|
|
|
171
173
|
// Surviving managers captured BEFORE any teardown — a reap (below) deletes
|
|
172
174
|
// this node's edges, so the step-4 fan-out must read them up front.
|
|
173
175
|
const survivors = subscribersOf(id).filter((s) => !closing.has(s.node_id));
|
|
174
|
-
// 0)
|
|
175
|
-
//
|
|
176
|
-
//
|
|
176
|
+
// 0) A node with no substantive assistant output is a useless shell — don't
|
|
177
|
+
// park it as a canceled husk; reap it outright (engine + viewer + row +
|
|
178
|
+
// dir). reapIfEmpty handles the teardown; when it fires we
|
|
177
179
|
// skip the cancel transition + resume notice (the node is gone) but still
|
|
178
180
|
// fan the "child gone" wake out to surviving managers below.
|
|
179
181
|
if (!reapIfEmpty(id)) {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// manual cleanup.
|
|
24
24
|
// • otherwise → a TERMINAL node with nothing live to wait for and no
|
|
25
25
|
// final pushed. Re-prompt it to finish or escalate.
|
|
26
|
-
import { hasActiveLiveSubscription, hasLiveMessageWait,
|
|
26
|
+
import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCronWake, getNode, contextDir } from '../canvas/index.js';
|
|
27
27
|
import { activeBackgroundBashJobs } from '../bash-jobs.js';
|
|
28
28
|
import { formatCard, formatStructuredOutputReprompt, STALL_REPROMPT } from '../../shared/generated-context.js';
|
|
29
29
|
import { readOutputRequest } from './structured-output.js';
|
|
@@ -41,12 +41,12 @@ function formatInvalidStructuredOutputReprompt(error) {
|
|
|
41
41
|
`After resolving the file, retry with \`crtr push result\` (or \`crtr push result --decline "<reason>" --code <token>\` when the schema cannot be honestly satisfied). ` +
|
|
42
42
|
`If this is unexpected, escalate to the user using \`crtr human send\`.`);
|
|
43
43
|
}
|
|
44
|
-
/** The wake sources that legitimize a terminal node's dormancy: a pending
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
/** The wake sources that legitimize a terminal node's dormancy: a pending cron
|
|
45
|
+
* wake, an active subscription to a live publisher, an explicit live controller
|
|
46
|
+
* wait, or a live background bash job whose completion sends an inbox message.
|
|
47
|
+
* Parentage alone is deliberately absent. */
|
|
48
48
|
function wakeCapableWait(nodeId, backgroundJobsRunning) {
|
|
49
|
-
if (
|
|
49
|
+
if (hasPendingCronWake(nodeId))
|
|
50
50
|
return 'scheduled';
|
|
51
51
|
if (hasActiveLiveSubscription(nodeId)
|
|
52
52
|
|| hasLiveMessageWait(nodeId)
|
package/dist/core/worktree.js
CHANGED
|
@@ -954,12 +954,9 @@ export function autoDropCleanManagedWorktreeLocked(nodeId, wt, proof) {
|
|
|
954
954
|
* `open_managed_worktree` guard then still applies unchanged — real
|
|
955
955
|
* commits, real uncommitted changes, and a lost race all require the
|
|
956
956
|
* explicit path. */
|
|
957
|
-
function
|
|
957
|
+
function cleanCurrentCheckoutTip(wt) {
|
|
958
958
|
if (wt.state !== 'open' || !existsSync(wt.path) || isRebaseInProgress(wt.path))
|
|
959
959
|
return null;
|
|
960
|
-
const branch = gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
|
|
961
|
-
if (branch.status !== 0 || branch.stdout.trim() !== wt.branch)
|
|
962
|
-
return null;
|
|
963
960
|
const status = gitSync(['status', '--porcelain'], wt.path);
|
|
964
961
|
if (status.status !== 0 || status.stdout.trim() !== '')
|
|
965
962
|
return null;
|
|
@@ -967,7 +964,7 @@ function cleanCurrentBranchTip(wt) {
|
|
|
967
964
|
return head.status === 0 ? head.stdout.trim() : null;
|
|
968
965
|
}
|
|
969
966
|
export async function branchIsPushedAtCurrentTip(wt) {
|
|
970
|
-
const initialTip =
|
|
967
|
+
const initialTip = cleanCurrentCheckoutTip(wt);
|
|
971
968
|
if (initialTip === null)
|
|
972
969
|
return false;
|
|
973
970
|
// A cached origin branch can be stale in either direction. Query origin's
|
|
@@ -989,7 +986,7 @@ export async function branchIsPushedAtCurrentTip(wt) {
|
|
|
989
986
|
return false;
|
|
990
987
|
// The remote proof is useful only if the local checkout is still the exact
|
|
991
988
|
// clean tip it inspected when finalization begins.
|
|
992
|
-
return
|
|
989
|
+
return cleanCurrentCheckoutTip(wt) === initialTip;
|
|
993
990
|
}
|
|
994
991
|
export function autoDropCleanManagedWorktree(nodeId) {
|
|
995
992
|
const wt = openManagedWorktreeForNode(nodeId);
|
|
@@ -8,7 +8,8 @@ import { createNode } from '../../core/canvas/canvas.js';
|
|
|
8
8
|
import { isPidAlive, killProcessGroup } from '../../core/canvas/pid.js';
|
|
9
9
|
import { armOutcomeDelivery, claimOutcomeDelivery, readOutcomeDelivery } from '../../core/canvas/node-outcome-deliveries.js';
|
|
10
10
|
import { transition } from '../../core/runtime/lifecycle.js';
|
|
11
|
-
import {
|
|
11
|
+
import { DELIVERY_TIMEOUT_MS } from '../delivery/run-delivery.js';
|
|
12
|
+
import { deliverOutcome } from '../node-outcome/deliver-outcome.js';
|
|
12
13
|
let home;
|
|
13
14
|
let processGroupFiles;
|
|
14
15
|
const priorHome = process.env.CRTR_HOME;
|
|
@@ -70,7 +71,7 @@ test('timeout SIGKILL escalation reaps a SIGTERM-ignoring descendant after its l
|
|
|
70
71
|
processGroupFiles.push(processGroupPid);
|
|
71
72
|
const delivery = claimScript('timeout', `#!/bin/sh\necho $$ > ${processGroupPid}\n(\n trap '' TERM\n while :; do sleep 30; done\n) &\necho $! > ${childPid}\ntrap 'exit 0' TERM\nwhile :; do sleep 30; done\n`);
|
|
72
73
|
const originalSetTimeout = global.setTimeout;
|
|
73
|
-
global.setTimeout = ((callback, delay, ...args) => originalSetTimeout(callback, delay ===
|
|
74
|
+
global.setTimeout = ((callback, delay, ...args) => originalSetTimeout(callback, delay === DELIVERY_TIMEOUT_MS ? 1_000 : delay, ...args));
|
|
74
75
|
try {
|
|
75
76
|
await deliverOutcome(delivery, 'test-daemon');
|
|
76
77
|
}
|
|
@@ -29,7 +29,7 @@ import { CrtrClient } from '../../../../api/index.js';
|
|
|
29
29
|
import { registerPush } from '../../../../commands/push.js';
|
|
30
30
|
import { registerNode } from '../../../../commands/node.js';
|
|
31
31
|
import { createNode, getNode } from '../../../../core/canvas/canvas.js';
|
|
32
|
-
import { createManagedWorktree } from '../../../../core/worktree.js';
|
|
32
|
+
import { branchIsPushedAtCurrentTip, createManagedWorktree } from '../../../../core/worktree.js';
|
|
33
33
|
import { readInboxSince } from '../../../../core/feed/inbox.js';
|
|
34
34
|
import { gitSync } from '../../../../core/git.js';
|
|
35
35
|
import { InputError } from '../../../../core/io.js';
|
|
@@ -187,28 +187,38 @@ test('re-homed guard: `crtr push final` on a node with a managed worktree holdin
|
|
|
187
187
|
rmSync(repo, { recursive: true, force: true });
|
|
188
188
|
}
|
|
189
189
|
});
|
|
190
|
-
test('`crtr push final`
|
|
190
|
+
test('`crtr push final` retains a clean managed worktree checked out on a pushed PR branch (#1109, #1061)', async () => {
|
|
191
191
|
const repo = initRepo();
|
|
192
192
|
const remote = mkdtempSync(join(tmpdir(), 'crtr-leafparity-remote-'));
|
|
193
193
|
try {
|
|
194
194
|
git(['init', '--bare'], remote);
|
|
195
195
|
git(['remote', 'add', 'origin', remote], repo);
|
|
196
196
|
git(['push', '-u', 'origin', 'main'], repo);
|
|
197
|
-
const id = 'wt-
|
|
197
|
+
const id = 'wt-pushed-pr-branch-final';
|
|
198
198
|
const wt = createManagedWorktree(repo, id);
|
|
199
199
|
writeFileSync(join(wt.path, 'delivered-via-pr.txt'), 'delivered via a PR branch\n');
|
|
200
200
|
git(['add', '.'], wt.path);
|
|
201
|
-
git(['commit', '-m', 'delivered via
|
|
201
|
+
git(['commit', '-m', 'delivered via PR branch'], wt.path);
|
|
202
202
|
const tip = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
|
|
203
|
-
const
|
|
204
|
-
git(['
|
|
205
|
-
|
|
206
|
-
|
|
203
|
+
const prBranch = 'fix/pushed-pr-worktree-final';
|
|
204
|
+
git(['switch', '-c', prBranch], wt.path);
|
|
205
|
+
git(['push', 'origin', `HEAD:refs/heads/${wt.branch}`], wt.path);
|
|
206
|
+
git(['push', 'origin', `HEAD:refs/heads/${prBranch}`], wt.path);
|
|
207
|
+
assert.equal(gitSync(['rev-parse', `refs/heads/${wt.branch}`], wt.path).stdout.trim(), tip, 'the recorded branch remains at the checkout tip');
|
|
208
|
+
assert.equal(gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path).stdout.trim(), prBranch, 'the checkout uses the PR branch instead of the recorded branch');
|
|
209
|
+
assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${wt.branch}`], wt.path).stdout.split('\t')[0], tip, 'origin carries the recorded branch tip');
|
|
210
|
+
assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${prBranch}`], wt.path).stdout.split('\t')[0], tip, 'origin carries the PR branch tip');
|
|
211
|
+
const untracked = join(wt.path, 'generated.uid');
|
|
212
|
+
writeFileSync(untracked, 'untracked generated file\n');
|
|
213
|
+
assert.equal(await branchIsPushedAtCurrentTip(wt), false, 'an untracked file still rejects the origin-retention proof');
|
|
214
|
+
rmSync(untracked);
|
|
207
215
|
createNode(worktreeNode(id, wt.path, wt));
|
|
208
|
-
const out = await runPushAs(id, 'final', 'delivered through a
|
|
216
|
+
const out = await runPushAs(id, 'final', 'delivered through a PR branch');
|
|
209
217
|
assert.equal(out['worktree_auto_dropped'], undefined, 'the remote-tip allowance retains the open worktree');
|
|
210
|
-
assert.equal(getNode(id)?.managed_worktree?.state, 'open', 'the remote-tip allowance does not close the worktree');
|
|
211
|
-
assert.equal(getNode(id)?.
|
|
218
|
+
assert.equal(getNode(id)?.managed_worktree?.state, 'open', 'the remote-tip allowance does not close the worktree record');
|
|
219
|
+
assert.equal(getNode(id)?.managed_worktree?.branch, wt.branch, 'the retained record keeps its recorded branch');
|
|
220
|
+
assert.equal(gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path).stdout.trim(), prBranch, 'finalization does not change the checked-out PR branch');
|
|
221
|
+
assert.equal(getNode(id)?.status, 'done', 'the node finalizes when both pushed branches have its exact tip');
|
|
212
222
|
}
|
|
213
223
|
finally {
|
|
214
224
|
rmSync(repo, { recursive: true, force: true });
|
|
@@ -100,7 +100,7 @@ async function guardFinalWorktree(id) {
|
|
|
100
100
|
// command-local mapper needed (plan C-5).
|
|
101
101
|
throw new InputError({
|
|
102
102
|
error: 'open_managed_worktree',
|
|
103
|
-
message: 'This node still owns an open managed worktree — `push final`
|
|
103
|
+
message: 'This node still owns an open managed worktree that is neither clean and locally landed nor clean with its exact tip on origin — `push final` cannot finish.',
|
|
104
104
|
next: (() => {
|
|
105
105
|
const wt = getNode(id)?.managed_worktree;
|
|
106
106
|
return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
|
package/dist/daemon/api/map.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ManagedWorktree, NodeMeta, NodeRow, NodeRuntime, SubscriptionRef } from '../../core/canvas/types.js';
|
|
2
2
|
import type { OutcomeDeliveryRecord } from '../../core/canvas/node-outcome-deliveries.js';
|
|
3
3
|
import type { ReviewComment, ReviewCommentEvent, ReviewErrorCode, ReviewRecord, ReviewResult } from '../../core/review/types.js';
|
|
4
|
-
import type
|
|
4
|
+
import { type Cron, type CronRunRecord } from '../../core/canvas/crons.js';
|
|
5
5
|
import type { ProfileEntry } from '../../core/profiles/manifest.js';
|
|
6
6
|
import type { ReviveResult } from '../../core/runtime/revive.js';
|
|
7
7
|
import type { CloseNodeResult } from '../../core/runtime/close.js';
|
package/dist/daemon/api/map.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
13
13
|
import { outputResultPath } from '../../core/runtime/structured-output.js';
|
|
14
14
|
import { ReviewOperationError as ReviewOperationErrorClass } from '../../core/review/types.js';
|
|
15
|
+
import { cronSinkDisplay } from '../../core/canvas/crons.js';
|
|
15
16
|
import { isProfilePaused, sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
|
|
16
17
|
import { contextDir, inboxPath, nodeDir, nodeMetaPath, reportsDir, reviewResultPath, transcriptPath, viewSocketPath, } from '../../core/canvas/paths.js';
|
|
17
18
|
import { FinalizationError } from '../../core/feed/feed.js';
|
|
@@ -21,7 +22,6 @@ import { CrtrError } from '../../core/errors.js';
|
|
|
21
22
|
import { WorktreeError } from '../../core/worktree.js';
|
|
22
23
|
import { ApiError } from '../../api/errors.js';
|
|
23
24
|
import { ExitCode } from '../../types.js';
|
|
24
|
-
import { sinkDisplay } from '../cron-sink.js';
|
|
25
25
|
import { responsePath } from '../../core/human/convention.js';
|
|
26
26
|
import { ticketDir } from '../../core/human/root.js';
|
|
27
27
|
// Node projections
|
|
@@ -314,7 +314,7 @@ export function toCronDTO(c, lastRun) {
|
|
|
314
314
|
run_timeout_s: c.run_timeout_s,
|
|
315
315
|
overlap: c.overlap,
|
|
316
316
|
on_output: c.on_output,
|
|
317
|
-
sink:
|
|
317
|
+
sink: cronSinkDisplay(c.sink),
|
|
318
318
|
tier: c.tier,
|
|
319
319
|
state: c.state,
|
|
320
320
|
held: c.held,
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Cron } from '../../core/canvas/crons.js';
|
|
2
2
|
export declare function deliverToSink(c: Cron, stdout: string): Promise<string>;
|
|
@@ -13,7 +13,7 @@ import { reviveNode } from '../../core/runtime/revive.js';
|
|
|
13
13
|
import { isBrokerLive } from '../../core/runtime/model-swap.js';
|
|
14
14
|
import { spawnChild } from '../../core/runtime/spawn.js';
|
|
15
15
|
import { cronWakeOrigin } from '../../core/runtime/bearings.js';
|
|
16
|
-
import {
|
|
16
|
+
import { parseCronSink } from '../../core/canvas/crons.js';
|
|
17
17
|
import { resolveLiveProfile, resolveLiveProfileName } from './armed-context.js';
|
|
18
18
|
async function deliverNodeSink(c, target, stdout) {
|
|
19
19
|
const meta = getNode(target);
|
|
@@ -89,7 +89,7 @@ function deliverHumanSink(c, stdout) {
|
|
|
89
89
|
publishPage(ticketDir(id), prepared);
|
|
90
90
|
}
|
|
91
91
|
export async function deliverToSink(c, stdout) {
|
|
92
|
-
const sink =
|
|
92
|
+
const sink = parseCronSink(c.sink);
|
|
93
93
|
if (sink === null)
|
|
94
94
|
return 'no sink armed — nothing delivered';
|
|
95
95
|
if (sink.kind === 'node') {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type DeliverySettlement } from '../../core/canvas/delivery-contract.js';
|
|
2
|
+
export declare const DELIVERY_TIMEOUT_MS = 120000;
|
|
3
|
+
export declare const DELIVERY_STDERR_TAIL_BYTES: number;
|
|
4
|
+
export interface DeliveryProcessOutcome {
|
|
5
|
+
exitCode: number | null;
|
|
6
|
+
signal: string | null;
|
|
7
|
+
timedOut: boolean;
|
|
8
|
+
spawnError?: Error;
|
|
9
|
+
stderr: string;
|
|
10
|
+
}
|
|
11
|
+
/** Process contract mapping. stdout deliberately does not participate. */
|
|
12
|
+
export declare function deliverySettlementForOutcome(outcome: DeliveryProcessOutcome, attempt: number, now: number): DeliverySettlement;
|
|
13
|
+
/** One already-claimed attempt: where to deliver, what to deliver, and which
|
|
14
|
+
* attempt this is. The caller resolved argv and cwd when the target was
|
|
15
|
+
* registered, so a later config edit cannot redirect this delivery. */
|
|
16
|
+
export interface DeliveryJob {
|
|
17
|
+
argv: string[];
|
|
18
|
+
cwd: string;
|
|
19
|
+
/** The stored document; its `delivery.attempt` is stamped per attempt. */
|
|
20
|
+
document: unknown;
|
|
21
|
+
attempt: number;
|
|
22
|
+
}
|
|
23
|
+
/** Spawn and settle one already-claimed delivery. `settle` writes the outcome
|
|
24
|
+
* to whichever durable row owns this attempt. */
|
|
25
|
+
export declare function runDelivery(job: DeliveryJob, settle: (settlement: DeliverySettlement) => void): Promise<void>;
|