@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.
Files changed (51) hide show
  1. package/dist/api/dto/reports.d.ts +8 -8
  2. package/dist/clients/attach/session/profile-files.d.ts +6 -3
  3. package/dist/clients/attach/session/profile-files.js +39 -10
  4. package/dist/clients/attach/viewer.js +663 -663
  5. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +30 -1
  6. package/dist/commands/memory/read.js +12 -14
  7. package/dist/commands/node/create.js +1 -1
  8. package/dist/commands/node/lifecycle.js +36 -8
  9. package/dist/commands/push.js +4 -4
  10. package/dist/core/__tests__/human-action-delivery.test.js +13 -11
  11. package/dist/core/__tests__/integration/revive.test.js +3 -3
  12. package/dist/core/__tests__/node-outcome.test.js +1 -6
  13. package/dist/core/__tests__/seam/dormancy-release.test.js +42 -0
  14. package/dist/core/__tests__/stop-guard.test.js +64 -0
  15. package/dist/core/bash-jobs.d.ts +22 -0
  16. package/dist/core/bash-jobs.js +38 -1
  17. package/dist/core/canvas/crons.d.ts +20 -4
  18. package/dist/core/canvas/crons.js +54 -7
  19. package/dist/core/canvas/delivery-contract.d.ts +19 -0
  20. package/dist/core/canvas/delivery-contract.js +8 -0
  21. package/dist/core/canvas/human-deliveries.d.ts +3 -17
  22. package/dist/core/canvas/node-outcome-deliveries.d.ts +3 -19
  23. package/dist/core/canvas/node-outcome-deliveries.js +0 -4
  24. package/dist/core/keybindings/catalog.js +1 -1
  25. package/dist/core/memory-resolver.d.ts +3 -1
  26. package/dist/core/memory-resolver.js +3 -3
  27. package/dist/core/nested-stores.d.ts +1 -1
  28. package/dist/core/nested-stores.js +3 -3
  29. package/dist/core/runtime/bearings-render.js +1 -1
  30. package/dist/core/runtime/close.js +8 -6
  31. package/dist/core/runtime/stop-guard.js +6 -6
  32. package/dist/core/worktree.js +3 -6
  33. package/dist/daemon/__tests__/node-outcome-delivery.test.js +3 -2
  34. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +21 -11
  35. package/dist/daemon/api/handlers/reports.js +1 -1
  36. package/dist/daemon/api/map.d.ts +1 -1
  37. package/dist/daemon/api/map.js +2 -2
  38. package/dist/daemon/cron/sinks.d.ts +1 -1
  39. package/dist/daemon/cron/sinks.js +2 -2
  40. package/dist/daemon/delivery/run-delivery.d.ts +25 -0
  41. package/dist/daemon/delivery/run-delivery.js +164 -0
  42. package/dist/daemon/human/deliver-action.d.ts +1 -14
  43. package/dist/daemon/human/deliver-action.js +5 -165
  44. package/dist/daemon/node-outcome/deliver-outcome.d.ts +1 -12
  45. package/dist/daemon/node-outcome/deliver-outcome.js +6 -152
  46. package/dist/daemon/reconcilers/live-obligation.js +2 -2
  47. package/dist/pi-extensions/canvas-bash-valve.js +7 -10
  48. package/package.json +1 -1
  49. package/runtime.lock.json +5 -5
  50. package/dist/daemon/cron-sink.d.ts +0 -26
  51. 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 this node has a pending cancel-on-wake cron. The stop guard uses
184
- * this focused deadline check to distinguish legitimate clock dormancy from a
185
- * terminal node that stopped without finishing. */
186
- export declare function hasPendingCancelOnWakeCron(anchor_node: string): boolean;
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 this node has a pending cancel-on-wake cron. The stop guard uses
265
- * this focused deadline check to distinguish legitimate clock dormancy from a
266
- * terminal node that stopped without finishing. */
267
- export function hasPendingCancelOnWakeCron(anchor_node) {
268
- return (openDb()
269
- .prepare("SELECT 1 FROM crons WHERE anchor_node = ? AND cancel_on_wake = 1 AND state = 'active' LIMIT 1")
270
- .get(anchor_node) !== undefined);
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 interface ActionDeliveryFailure {
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 interface OutcomeDeliveryFailure {
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, quiet?: boolean): MemoryStoreDescriptor[];
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, quiet = 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), quiet)) {
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[], quiet?: boolean): 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, quiet = false) {
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 (!quiet) {
60
- warn(`memory: nested-store discovery under ${owner} exceeded its ${TIME_BUDGET_MS}ms budget — nested stores may be missing from this listing; register deep stores explicitly (add their dirs to the selected profile's projects)`);
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. Land it with \`crtr node worktree close\` before finishing. 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); unlanded commits (commits not already contained in the base) or uncommitted changes require an explicit close/landing decision.`;
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). Nothing is deleted: pi_session_id, the canvas
6
- // edges, and all on-disk state persist, so any closed node can later be revived
7
- // (`crtr node lifecycle revive` / focus → `pi --session <id>`). A close is a pause, not a reap.
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) An EMPTY node (engine never produced an assistant message) is a useless
175
- // shell — don't park it as a canceled husk, reap it outright (engine +
176
- // viewer + row + dir). reapIfEmpty handles the teardown; when it fires we
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, hasPendingCancelOnWakeCron, getNode, contextDir } from '../canvas/index.js';
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
- * cancel-on-wake deadline, an active subscription to a live publisher, an
46
- * explicit live controller wait, or a live background bash job whose completion
47
- * sends an inbox message. Parentage alone is deliberately absent. */
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 (hasPendingCancelOnWakeCron(nodeId))
49
+ if (hasPendingCronWake(nodeId))
50
50
  return 'scheduled';
51
51
  if (hasActiveLiveSubscription(nodeId)
52
52
  || hasLiveMessageWait(nodeId)
@@ -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 cleanCurrentBranchTip(wt) {
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 = cleanCurrentBranchTip(wt);
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 cleanCurrentBranchTip(wt) === initialTip;
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 { OUTCOME_DELIVERY_TIMEOUT_MS, deliverOutcome } from '../node-outcome/deliver-outcome.js';
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 === OUTCOME_DELIVERY_TIMEOUT_MS ? 1_000 : delay, ...args));
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` concludes a clean managed worktree whose tip exists on origin only under a different branch name (#912)', async () => {
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-912-differently-named-remote-branch';
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 differently named branch'], wt.path);
201
+ git(['commit', '-m', 'delivered via PR branch'], wt.path);
202
202
  const tip = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
203
- const remoteBranch = 'fix/issue-912-worktree-final';
204
- git(['push', 'origin', `HEAD:refs/heads/${remoteBranch}`], wt.path);
205
- assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${wt.branch}`], wt.path).status, 2, 'the recorded crtr branch is absent from origin');
206
- assert.equal(gitSync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${remoteBranch}`], wt.path).stdout.split('\t')[0], tip, 'origin carries the exact tip under the PR branch name');
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 differently named PR branch');
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)?.status, 'done', 'the node finalizes when origin carries its exact tip under the PR branch name');
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` is blocked until it is closed.',
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)
@@ -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 { Cron, CronRunRecord } from '../../core/canvas/crons.js';
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';
@@ -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: sinkDisplay(c.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 { Cron } from '../../core/canvas/crons.js';
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 { parseSink } from '../cron-sink.js';
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 = parseSink(c.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>;