@north-light/crouter 0.3.274 → 0.3.276
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/builtin-memory/02-turn-lifecycle/01-terminal.md +1 -1
- package/dist/clients/attach/viewer.js +459 -459
- package/dist/commands/cron.js +3 -3
- package/dist/commands/node/create.js +3 -1
- package/dist/commands/node-worktree.js +7 -7
- package/dist/commands/pkg/plugin-manage.js +19 -8
- package/dist/commands/push.js +1 -1
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +12 -10
- package/dist/core/__tests__/integration/worktree-land.test.js +7 -2
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +3 -16
- package/dist/core/canvas/browse/render.js +82 -67
- package/dist/core/canvas/status-glyph.d.ts +4 -3
- package/dist/core/canvas/status-glyph.js +17 -9
- package/dist/core/canvas/types.d.ts +18 -0
- package/dist/core/command-plugins/bundle.d.ts +7 -0
- package/dist/core/command-plugins/bundle.js +18 -4
- package/dist/core/git.d.ts +5 -1
- package/dist/core/git.js +18 -4
- package/dist/core/runtime/spawn.d.ts +2 -0
- package/dist/core/runtime/spawn.js +1 -1
- package/dist/core/runtime/surface-bg.d.ts +6 -4
- package/dist/core/runtime/surface-bg.js +39 -22
- package/dist/core/tui/draw.d.ts +2 -0
- package/dist/core/tui/draw.js +15 -0
- package/dist/core/worktree-sweep.js +28 -5
- package/dist/core/worktree.d.ts +1 -12
- package/dist/core/worktree.js +222 -10
- package/dist/daemon/api/handlers/crons.js +5 -4
- package/dist/daemon/api/handlers/messages.js +5 -0
- package/dist/daemon/api/handlers/nodes.js +2 -0
- package/dist/daemon/api/handlers/reports.js +11 -7
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -62,6 +62,8 @@ export interface SpawnChildOpts {
|
|
|
62
62
|
worktree?: boolean;
|
|
63
63
|
/** Local branch the managed worktree is cut from and later landed onto. */
|
|
64
64
|
worktreeBase?: string;
|
|
65
|
+
/** Invocation-selected repository from which to cut a managed worktree. */
|
|
66
|
+
worktreeCwd?: string;
|
|
65
67
|
/** Preallocated node id, used when a managed worktree must be named before birth. */
|
|
66
68
|
nodeId?: string;
|
|
67
69
|
/** Hidden ambient "current situation" text — NOT the visible prompt/body.
|
|
@@ -251,7 +251,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
251
251
|
let spawnCwd = opts.cwd;
|
|
252
252
|
try {
|
|
253
253
|
if (wantsWorktree) {
|
|
254
|
-
managedWorktree = createManagedWorktree(opts.cwd, nodeId, opts.worktreeBase);
|
|
254
|
+
managedWorktree = createManagedWorktree(opts.worktreeCwd ?? opts.cwd, nodeId, opts.worktreeBase);
|
|
255
255
|
spawnCwd = managedWorktree.path;
|
|
256
256
|
}
|
|
257
257
|
// Spine: a managed child reports up to its spawner (has a manager); an
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
/** The SGR escape that turns ON the distinct-surface background (theme
|
|
2
|
-
*
|
|
1
|
+
/** The SGR escape that turns ON the distinct-surface background (the theme's
|
|
2
|
+
* card plane), e.g. `\x1b[48;2;52;53;65m`. Pair with `\x1b[49m` to reset just
|
|
3
3
|
* the background. Throws if no process has initialized the theme — callers on a
|
|
4
4
|
* render path (the attach overlay) always have, by construction. */
|
|
5
5
|
export declare function surfaceBgAnsi(): string;
|
|
6
|
-
/** tmux flags that frame a `display-menu` / `display-popup`
|
|
7
|
-
* surface
|
|
6
|
+
/** tmux flags that frame a `display-menu` / `display-popup` as a distinct
|
|
7
|
+
* surface — a rounded border in the theme accent around a body painted with the
|
|
8
|
+
* theme's card plane. The accent border is what carries the "you are somewhere
|
|
9
|
+
* else" signal, because a card sits close to the page by design.
|
|
8
10
|
* Empty when the theme is unavailable in this process (chrome stays default; the
|
|
9
11
|
* attach viewer re-installs the menu with these once it has themed). One knob:
|
|
10
12
|
* every crtr tmux float shares this single definition. */
|
|
@@ -3,10 +3,24 @@
|
|
|
3
3
|
// crtr's overlays and popups must read as a SEPARATE surface from the normal one
|
|
4
4
|
// (CTO ruling, insights/terminal-ui/inline-ui-placement #11): a popup whose background matches
|
|
5
5
|
// the surface has invisible edges, so you can't tell where the float begins. We
|
|
6
|
-
// paint them
|
|
7
|
-
//
|
|
6
|
+
// paint them from the active pi theme — theme-derived, so it adapts to light/dark
|
|
7
|
+
// and is ONE centralized knob, not a scattered hardcode.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// WHICH ROLE: `userMessageBg`, the theme's CARD plane — not `selectedBg`. Every
|
|
10
|
+
// theme places its card one step off the page background (gruvbox-material
|
|
11
|
+
// #32302f vs #282828; pi's own dark #343541, light #e8e8e8) while `selectedBg` is
|
|
12
|
+
// a SELECTION BAND, deliberately pushed far off the page so a highlighted row
|
|
13
|
+
// lifts. Painting a 90%×85% takeover with the selection band made the whole
|
|
14
|
+
// screen that band: every dim/muted role lost the contrast it was designed
|
|
15
|
+
// against (gruvbox dim text fell to 2.1:1), and the row highlight drawn ON TOP
|
|
16
|
+
// had nowhere left to go. The card plane keeps content legibility and leaves the
|
|
17
|
+
// selection band free to do its actual job inside the popup.
|
|
18
|
+
//
|
|
19
|
+
// A card sits close to the page by design, so the EDGE signal rule 11 demands
|
|
20
|
+
// comes from the border, not the fill: we paint the rounded border in the theme
|
|
21
|
+
// accent so the float's boundary is unmistakable.
|
|
22
|
+
//
|
|
23
|
+
// pi 0.79 does not re-export these roles through its public API (the `.` entry
|
|
10
24
|
// omits `getResolvedThemeColors` and the live `theme` singleton). It DOES publish
|
|
11
25
|
// the live `Theme` instance on a process-global symbol once `initTheme()` has run
|
|
12
26
|
// — the same hook pi's own SDK reads (see broker.ts `ctx.ui.theme`). We read it
|
|
@@ -26,43 +40,46 @@ function liveTheme() {
|
|
|
26
40
|
const t = globalThis[THEME_SYMBOL];
|
|
27
41
|
return (t ?? undefined);
|
|
28
42
|
}
|
|
29
|
-
/** The SGR escape that turns ON the distinct-surface background (theme
|
|
30
|
-
*
|
|
43
|
+
/** The SGR escape that turns ON the distinct-surface background (the theme's
|
|
44
|
+
* card plane), e.g. `\x1b[48;2;52;53;65m`. Pair with `\x1b[49m` to reset just
|
|
31
45
|
* the background. Throws if no process has initialized the theme — callers on a
|
|
32
46
|
* render path (the attach overlay) always have, by construction. */
|
|
33
47
|
export function surfaceBgAnsi() {
|
|
34
48
|
const t = liveTheme();
|
|
35
49
|
if (t === undefined)
|
|
36
50
|
throw new Error('surfaceBgAnsi: pi theme not initialized in this process');
|
|
37
|
-
return t.getBgAnsi('
|
|
51
|
+
return t.getBgAnsi('userMessageBg');
|
|
38
52
|
}
|
|
39
|
-
/**
|
|
40
|
-
* truecolor theme, `colourN` on a 256-colour
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
function
|
|
44
|
-
const
|
|
45
|
-
if (t === undefined)
|
|
46
|
-
return undefined;
|
|
47
|
-
const ansi = t.getBgAnsi('selectedBg');
|
|
48
|
-
const tc = /48;2;(\d+);(\d+);(\d+)/.exec(ansi);
|
|
53
|
+
/** An SGR colour parameter (`48;2;r;g;b` or `38;5;n`) as a tmux colour token —
|
|
54
|
+
* `#rrggbb` on a truecolor theme, `colourN` on a 256-colour one. tmux cannot
|
|
55
|
+
* read pi's theme, so we translate the SGR pi already resolved for us.
|
|
56
|
+
* undefined when the role is empty (the SGR is a bare default-colour reset). */
|
|
57
|
+
function tmuxColor(ansi) {
|
|
58
|
+
const tc = /[34]8;2;(\d+);(\d+);(\d+)/.exec(ansi);
|
|
49
59
|
if (tc !== null) {
|
|
50
60
|
const hex = (n) => Number(n).toString(16).padStart(2, '0');
|
|
51
61
|
return `#${hex(tc[1])}${hex(tc[2])}${hex(tc[3])}`;
|
|
52
62
|
}
|
|
53
|
-
const c256 = /
|
|
63
|
+
const c256 = /[34]8;5;(\d+)/.exec(ansi);
|
|
54
64
|
if (c256 !== null)
|
|
55
65
|
return `colour${c256[1]}`;
|
|
56
|
-
return undefined;
|
|
66
|
+
return undefined;
|
|
57
67
|
}
|
|
58
|
-
/** tmux flags that frame a `display-menu` / `display-popup`
|
|
59
|
-
* surface
|
|
68
|
+
/** tmux flags that frame a `display-menu` / `display-popup` as a distinct
|
|
69
|
+
* surface — a rounded border in the theme accent around a body painted with the
|
|
70
|
+
* theme's card plane. The accent border is what carries the "you are somewhere
|
|
71
|
+
* else" signal, because a card sits close to the page by design.
|
|
60
72
|
* Empty when the theme is unavailable in this process (chrome stays default; the
|
|
61
73
|
* attach viewer re-installs the menu with these once it has themed). One knob:
|
|
62
74
|
* every crtr tmux float shares this single definition. */
|
|
63
75
|
export function surfaceTmuxStyleArgs() {
|
|
64
|
-
const
|
|
76
|
+
const t = liveTheme();
|
|
77
|
+
if (t === undefined)
|
|
78
|
+
return [];
|
|
79
|
+
const bg = tmuxColor(t.getBgAnsi('userMessageBg'));
|
|
65
80
|
if (bg === undefined)
|
|
66
81
|
return [];
|
|
67
|
-
|
|
82
|
+
const accent = tmuxColor(t.getFgAnsi('accent'));
|
|
83
|
+
const border = accent === undefined ? `bg=${bg}` : `fg=${accent},bg=${bg}`;
|
|
84
|
+
return ['-b', 'rounded', '-s', `bg=${bg}`, '-S', border];
|
|
68
85
|
}
|
package/dist/core/tui/draw.d.ts
CHANGED
package/dist/core/tui/draw.js
CHANGED
|
@@ -22,6 +22,21 @@ const REVERSE = `${ESC}7m`;
|
|
|
22
22
|
const DIM = `${ESC}2m`;
|
|
23
23
|
const BOLD = `${ESC}1m`;
|
|
24
24
|
const ITALIC = `${ESC}3m`;
|
|
25
|
+
// ── The recessive text ramp ────────────────────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// Two explicit greys for text that must sit BEHIND the primary content without
|
|
28
|
+
// disappearing into it. Use these instead of the two things that look equivalent
|
|
29
|
+
// and are not:
|
|
30
|
+
//
|
|
31
|
+
// - basic-16 bright-black (`90`) resolves against the TERMINAL's palette, which
|
|
32
|
+
// a dark theme routinely sets within a hair of its own background;
|
|
33
|
+
// - SGR DIM (`2`) is a multiplier, so stacking it on an already-dark grey lands
|
|
34
|
+
// at mud — and stacking it on THESE would undo the contrast they encode.
|
|
35
|
+
//
|
|
36
|
+
// Never combine either with these. Ratios below are against the two planes a
|
|
37
|
+
// raw-ANSI view can render on: a bare dark terminal and a themed popup card.
|
|
38
|
+
export const META_FG = '38;5;246'; // 4.3–4.9:1 — metadata you actively scan
|
|
39
|
+
export const RECESSIVE_FG = '38;5;243'; // 2.9–3.3:1 — deliberately behind, still read
|
|
25
40
|
/** An SGR fg/bg parameter is digits and semicolons only (e.g. '32', '1;36', '236').
|
|
26
41
|
* Guards styleSpan against a non-numeric value (a color name) producing a broken
|
|
27
42
|
* escape sequence. */
|
|
@@ -336,7 +336,7 @@ export async function reconcileManagedWorktree(nodeId, guard, now = Date.now())
|
|
|
336
336
|
return { status: 'skipped', reason: 'already_reconciled' };
|
|
337
337
|
const cwd = existsSync(wt.path) ? wt.path : wt.repo_root;
|
|
338
338
|
if (!existsSync(cwd)) {
|
|
339
|
-
return
|
|
339
|
+
return persistComplete(nodeId, wt, guard);
|
|
340
340
|
}
|
|
341
341
|
const commonDir = await commonGitDirAsync(cwd);
|
|
342
342
|
if (commonDir === null) {
|
|
@@ -423,9 +423,15 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
|
|
|
423
423
|
const list = await gitAsync(['worktree', 'list', '--porcelain'], wt.path);
|
|
424
424
|
if (list.status !== 0)
|
|
425
425
|
return { status: 'failed', reason: 'worktree_list_failed', detail: detailOf(list) };
|
|
426
|
-
|
|
426
|
+
// An explicit `close` already proved a branch-moved checkout's identity and
|
|
427
|
+
// authorized its later disposal for THIS exact branch — never adopted here.
|
|
428
|
+
// Only the recorded authorization changes which branch this pass expects;
|
|
429
|
+
// everything else below is re-verified exactly as for an ordinary record.
|
|
430
|
+
const disposal = wt.disposal;
|
|
431
|
+
const expectedBranch = disposal?.observed_branch ?? wt.branch;
|
|
432
|
+
const registeredPath = registeredCheckoutPath(list.stdout, wt.path, expectedBranch);
|
|
427
433
|
if (registeredPath === null) {
|
|
428
|
-
return { status: 'refused', reason: 'not_registered', detail: `${wt.path} is not registered to refs/heads/${
|
|
434
|
+
return { status: 'refused', reason: 'not_registered', detail: `${wt.path} is not registered to refs/heads/${expectedBranch}; crtr will not act on a path it cannot prove it owns` };
|
|
429
435
|
}
|
|
430
436
|
const main = mainWorktreePath(list.stdout);
|
|
431
437
|
if (main === null || samePath(main, registeredPath) || !existsSync(main)) {
|
|
@@ -439,8 +445,8 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
|
|
|
439
445
|
const head = await gitAsync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
|
|
440
446
|
if (head.status !== 0)
|
|
441
447
|
return { status: 'failed', reason: 'branch_check_failed', detail: detailOf(head) };
|
|
442
|
-
if (head.stdout.trim() !==
|
|
443
|
-
return { status: 'refused', reason: 'wrong_branch', detail: `expected ${
|
|
448
|
+
if (head.stdout.trim() !== expectedBranch) {
|
|
449
|
+
return { status: 'refused', reason: 'wrong_branch', detail: `expected ${expectedBranch}, found ${head.stdout.trim() === 'HEAD' ? 'detached HEAD' : head.stdout.trim()}` };
|
|
444
450
|
}
|
|
445
451
|
// Uncommitted work blocks removal unconditionally. No containment proof
|
|
446
452
|
// overrides this: those reason about committed content, and nothing here has
|
|
@@ -454,6 +460,23 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
|
|
|
454
460
|
let branchSha = await revParseRef(wt.path, 'HEAD');
|
|
455
461
|
if (branchSha === null)
|
|
456
462
|
return { status: 'failed', reason: 'head_unreadable', detail: `could not read HEAD in ${wt.path}` };
|
|
463
|
+
if (disposal !== undefined) {
|
|
464
|
+
// Re-verify the exact identity `close` authorized before mutating anything.
|
|
465
|
+
// A mismatch invalidates the authorization instead of adopting whatever is
|
|
466
|
+
// observed now — only the explicit command that wrote it may refresh it.
|
|
467
|
+
const commonDir = await commonGitDirAsync(wt.path);
|
|
468
|
+
if (commonDir !== disposal.common_dir || branchSha !== disposal.observed_sha) {
|
|
469
|
+
return { status: 'refused', reason: 'disposal_authorization_stale', detail: `${wt.path} changed since \`close\` authorized its disposal; rerun \`crtr node worktree close\` from that node to re-authorize, or ask the user with \`crtr human send\`` };
|
|
470
|
+
}
|
|
471
|
+
if (!guard.stillEligible())
|
|
472
|
+
return NODE_REVIVED;
|
|
473
|
+
const removed = await gitAsync(['worktree', 'remove', registeredPath], main);
|
|
474
|
+
if (removed.status !== 0)
|
|
475
|
+
return { status: 'refused', reason: 'worktree_remove_failed', detail: detailOf(removed) };
|
|
476
|
+
// `disposal.observed_branch` is not a `crtr/*` branch crouter created — it
|
|
477
|
+
// is preserved, never deleted, unlike the ordinary path below.
|
|
478
|
+
return persistComplete(nodeId, wt, guard);
|
|
479
|
+
}
|
|
457
480
|
let proof = await proveContained(wt.path, wt, branchSha);
|
|
458
481
|
let stash;
|
|
459
482
|
if (proof === null && wt.land_intent !== undefined) {
|
package/dist/core/worktree.d.ts
CHANGED
|
@@ -115,17 +115,6 @@ export interface AutoDroppedWorktree {
|
|
|
115
115
|
* explicit close: removal and branch deletion are deferred until that owner
|
|
116
116
|
* has exited. */
|
|
117
117
|
export declare function autoDropCleanManagedWorktreeLocked(nodeId: string, wt: ManagedWorktree, proof: CleanEmptyProof): AutoDroppedWorktree | null;
|
|
118
|
-
|
|
119
|
-
* nothing to land is auto-dropped instead of blocking on an explicit
|
|
120
|
-
* `node worktree close` — one the caller may have been told NOT to run
|
|
121
|
-
* because a manager already landed the same change another way, or
|
|
122
|
-
* that never had anything to close in the first place (a read-only
|
|
123
|
-
* review node). Returns null and changes nothing when the worktree is not
|
|
124
|
-
* PROVABLY empty-and-clean by `proveCleanEmptyManagedWorktree`, OR when a
|
|
125
|
-
* concurrent mutation invalidates that proof before the record is closed
|
|
126
|
-
* (see `autoDropCleanManagedWorktreeLocked`); the caller's existing
|
|
127
|
-
* `open_managed_worktree` guard then still applies unchanged — real
|
|
128
|
-
* commits, real uncommitted changes, and a lost race all require the
|
|
129
|
-
* explicit path. */
|
|
118
|
+
export declare function branchIsPushedAtCurrentTip(wt: ManagedWorktree): Promise<boolean>;
|
|
130
119
|
export declare function autoDropCleanManagedWorktree(nodeId: string): AutoDroppedWorktree | null;
|
|
131
120
|
export {};
|
package/dist/core/worktree.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
4
|
-
import { gitSync } from './git.js';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { gitAsync, gitSync } from './git.js';
|
|
5
5
|
import { crtrHome } from './canvas/paths.js';
|
|
6
6
|
import { isPidAlive } from './canvas/pid.js';
|
|
7
7
|
import { getNode, updateNode } from './canvas/index.js';
|
|
@@ -44,7 +44,27 @@ export function openManagedWorktreeForNode(nodeId) {
|
|
|
44
44
|
return wt?.state === 'open' ? wt : null;
|
|
45
45
|
}
|
|
46
46
|
function repoRootFor(cwd) {
|
|
47
|
-
|
|
47
|
+
const result = gitSync(['rev-parse', '--show-toplevel'], cwd);
|
|
48
|
+
if (result.status !== 0) {
|
|
49
|
+
const detail = (result.stderr.trim() || result.stdout.trim()).trim();
|
|
50
|
+
throw new WorktreeError('not_git_repo', `cwd ${cwd} is not a git repository`, 'Pass --cwd <real repo> pointing inside a git repository.', detail);
|
|
51
|
+
}
|
|
52
|
+
return result.stdout.trim();
|
|
53
|
+
}
|
|
54
|
+
function isManagedWorktreeBase(repoRoot) {
|
|
55
|
+
const path = relative(canonicalPath(managedWorktreesRoot()), canonicalPath(repoRoot));
|
|
56
|
+
return path !== '' && path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path);
|
|
57
|
+
}
|
|
58
|
+
function recordedBranchExists(cwd, branch) {
|
|
59
|
+
const ref = `refs/heads/${branch}`;
|
|
60
|
+
const exists = gitSync(['show-ref', '--verify', '--quiet', ref], cwd);
|
|
61
|
+
if (exists.status === 0)
|
|
62
|
+
return true;
|
|
63
|
+
if (exists.status === 1 && exists.executionFailed !== true)
|
|
64
|
+
return false;
|
|
65
|
+
const stderr = exists.stderr.trim() || '<empty>';
|
|
66
|
+
const execution = exists.processError === undefined ? '' : `\nprocess error: ${exists.processError}`;
|
|
67
|
+
throw new WorktreeError('recorded_branch_inspection_failed', `could not inspect recorded branch '${branch}' (git show-ref exited with status ${exists.status})`, 'Inspect the repository and its Git metadata, then retry.', `status: ${exists.status}\nstderr: ${stderr}${execution}`);
|
|
48
68
|
}
|
|
49
69
|
function resolveLandableBase(repoRoot, baseRef) {
|
|
50
70
|
const ref = `refs/heads/${baseRef}`;
|
|
@@ -142,6 +162,43 @@ function registeredWorktree(repoRoot, path, branch) {
|
|
|
142
162
|
}
|
|
143
163
|
return false;
|
|
144
164
|
}
|
|
165
|
+
function proveBranchContained(cwd, baseRef, branchSha) {
|
|
166
|
+
const candidates = [];
|
|
167
|
+
const localBaseRef = `refs/heads/${baseRef}`;
|
|
168
|
+
const localBase = gitSync(['rev-parse', '--verify', '--quiet', localBaseRef], cwd);
|
|
169
|
+
if (localBase.status === 0)
|
|
170
|
+
candidates.push({ ref: localBaseRef, sha: localBase.stdout.trim() });
|
|
171
|
+
const upstream = gitSync(['rev-parse', '--symbolic-full-name', '--verify', `${baseRef}@{upstream}`], cwd);
|
|
172
|
+
const upstreamRef = upstream.status === 0 && upstream.stdout.trim().startsWith('refs/') ? upstream.stdout.trim() : `refs/remotes/origin/${baseRef}`;
|
|
173
|
+
const upstreamSha = gitSync(['rev-parse', '--verify', '--quiet', upstreamRef], cwd);
|
|
174
|
+
if (upstreamSha.status === 0)
|
|
175
|
+
candidates.push({ ref: upstreamRef, sha: upstreamSha.stdout.trim() });
|
|
176
|
+
if (candidates.length === 0)
|
|
177
|
+
return null;
|
|
178
|
+
for (const c of candidates) {
|
|
179
|
+
if (gitSync(['merge-base', '--is-ancestor', branchSha, c.sha], cwd).status === 0)
|
|
180
|
+
return { containingRef: c.ref, containingSha: c.sha };
|
|
181
|
+
}
|
|
182
|
+
for (const c of candidates) {
|
|
183
|
+
const cherry = gitSync(['cherry', c.sha, branchSha], cwd);
|
|
184
|
+
if (cherry.status !== 0)
|
|
185
|
+
continue;
|
|
186
|
+
const lines = cherry.stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
187
|
+
if (lines.every((l) => l.startsWith('-')))
|
|
188
|
+
return { containingRef: c.ref, containingSha: c.sha };
|
|
189
|
+
}
|
|
190
|
+
for (const c of candidates) {
|
|
191
|
+
const candidateTree = gitSync(['rev-parse', '--verify', `${c.sha}^{tree}`], cwd);
|
|
192
|
+
if (candidateTree.status !== 0)
|
|
193
|
+
continue;
|
|
194
|
+
const merged = gitSync(['merge-tree', '--write-tree', c.sha, branchSha], cwd);
|
|
195
|
+
if (merged.status !== 0)
|
|
196
|
+
continue;
|
|
197
|
+
if (merged.stdout.split('\n')[0]?.trim() === candidateTree.stdout.trim())
|
|
198
|
+
return { containingRef: c.ref, containingSha: c.sha };
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
145
202
|
/** Undo a creation attempt, but only resources whose recorded path, branch,
|
|
146
203
|
* and original base still prove they belong to that attempt. */
|
|
147
204
|
function rollbackManagedWorktreeLocked(wt, pathWasAbsent) {
|
|
@@ -235,6 +292,9 @@ function seedManagedWorktreeExclude(repoRoot) {
|
|
|
235
292
|
}
|
|
236
293
|
export function createManagedWorktree(cwd, nodeId, baseRef = 'main') {
|
|
237
294
|
const repoRoot = repoRootFor(cwd);
|
|
295
|
+
if (isManagedWorktreeBase(repoRoot)) {
|
|
296
|
+
throw new WorktreeError('managed_worktree_base', `cwd ${repoRoot} is a managed worktree and cannot be used as a managed worktree base`, 'Pass --cwd <real repo> pointing at the repository’s primary checkout.');
|
|
297
|
+
}
|
|
238
298
|
return withRepositoryWorktreeLock(repoRoot, () => {
|
|
239
299
|
const baseSha = resolveLandableBase(repoRoot, baseRef);
|
|
240
300
|
const path = managedWorktreePath(nodeId);
|
|
@@ -386,6 +446,67 @@ function deferredCleanupResult(wt) {
|
|
|
386
446
|
branch_delete_error: `cleanup pending: local branch ${wt.branch} remains checked out at ${wt.path}; delete it only after that checkout is removed and its landed commit is still contained in ${wt.base_ref}.`,
|
|
387
447
|
};
|
|
388
448
|
}
|
|
449
|
+
/** The disposal path for `close` when the recorded `crtr/*` branch is gone:
|
|
450
|
+
* ordinary PR work moved this checkout onto a real branch. This never lands
|
|
451
|
+
* or rebases that branch — it belongs to a workflow crouter does not own —
|
|
452
|
+
* and never deletes it. It only AUTHORIZES the daemon sweep to remove the
|
|
453
|
+
* checkout later, once this node's own broker has exited, and only after
|
|
454
|
+
* proving the branch's content is already delivered. Runs after the caller
|
|
455
|
+
* already proved the checkout clean and not mid-rebase. */
|
|
456
|
+
function closeBranchMovedWorktree(nodeId, wt, actualBranch) {
|
|
457
|
+
if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true) {
|
|
458
|
+
throw new WorktreeError('wrong_branch', `managed worktree's recorded branch ${wt.branch} no longer exists, and its checkout at ${wt.path} is not registered to ${actualBranch} either`, `Inspect ${wt.path} and its git worktree registration, then ask the user with \`crtr human send\` if you are unsure how to proceed.`);
|
|
459
|
+
}
|
|
460
|
+
const actualSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'branch_check_failed', 'Inspect the worktree manually, then retry.');
|
|
461
|
+
const proof = proveBranchContained(wt.path, wt.base_ref, actualSha);
|
|
462
|
+
if (proof === null) {
|
|
463
|
+
throw new WorktreeError('branch_moved_unlanded', `managed worktree's recorded branch ${wt.branch} no longer exists; its checkout is now on ${actualBranch} at ${actualSha}, which carries content local base branch '${wt.base_ref}' does not yet contain`, `Land or push ${actualBranch} through its own workflow (merge its PR, or fast-forward '${wt.base_ref}' to include it) before closing, or ask the user with \`crtr human send\` if you are unsure how ${actualBranch} should be delivered.`);
|
|
464
|
+
}
|
|
465
|
+
const commonDir = commonGitDir(wt.repo_root);
|
|
466
|
+
// Recheck identity immediately before writing the authorization: nothing
|
|
467
|
+
// about the checkout may have moved between the proof above and this write.
|
|
468
|
+
const stillRegistered = registeredWorktree(wt.repo_root, wt.path, actualBranch) === true;
|
|
469
|
+
const stillOnBranch = currentBranch(wt.path) === actualBranch;
|
|
470
|
+
const stillAtSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'branch_check_failed', 'Inspect the worktree manually, then retry.') === actualSha;
|
|
471
|
+
if (!stillRegistered || !stillOnBranch || !stillAtSha) {
|
|
472
|
+
throw new WorktreeError('wrong_branch', `managed worktree at ${wt.path} changed while closing`, `Inspect ${wt.path}, then rerun \`crtr node worktree close\`.`);
|
|
473
|
+
}
|
|
474
|
+
const disposal = { observed_branch: actualBranch, observed_sha: actualSha, common_dir: commonDir, authorized: new Date().toISOString() };
|
|
475
|
+
persistClosedWorktreeState(nodeId, { ...wt, disposal }, 'pending');
|
|
476
|
+
const remove = `git -C ${shellQuote(wt.repo_root)} worktree remove ${shellQuote(wt.path)}`;
|
|
477
|
+
return {
|
|
478
|
+
node_id: nodeId,
|
|
479
|
+
branch: actualBranch,
|
|
480
|
+
worktree_path: wt.path,
|
|
481
|
+
// Nothing was rebased or fast-forwarded here: `${actualBranch}`'s content
|
|
482
|
+
// was already proven contained in `${proof.containingRef}` at `${proof.containingSha}`.
|
|
483
|
+
landed_sha: actualSha,
|
|
484
|
+
worktree_removed: false,
|
|
485
|
+
worktree_remove_error: `cleanup pending: the caller checkout at ${wt.path} remains intact. After its node exits, run \`${remove}\` after confirming it is safe to delete.`,
|
|
486
|
+
branch_deleted: false,
|
|
487
|
+
branch_delete_error: `${actualBranch} is not a crtr-managed branch and will never be deleted automatically; it is preserved once the checkout above is removed.`,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** Word the `no_managed_worktree` refusal from the record's ACTUAL state
|
|
491
|
+
* rather than a blanket "no worktree". A `closed`/`cleanup: pending` record
|
|
492
|
+
* still has its checkout on disk, scheduled for the daemon sweep to finish —
|
|
493
|
+
* that is not lost ownership, and three separate reports (#849, #862, #872)
|
|
494
|
+
* were misdiagnosed because the old wording read as if it were. */
|
|
495
|
+
function noManagedWorktreeMessage(nodeId, verb) {
|
|
496
|
+
const wt = getNode(nodeId)?.managed_worktree;
|
|
497
|
+
if (wt == null)
|
|
498
|
+
return { message: `node ${nodeId} has no managed worktree`, next: `Nothing to ${verb} for this node.` };
|
|
499
|
+
if (wt.state === 'abandoned') {
|
|
500
|
+
const branch = wt.abandoned?.branch ?? wt.branch;
|
|
501
|
+
return { message: `node ${nodeId}'s managed worktree was already abandoned`, next: `Nothing to ${verb} for this node; its work was retained on branch ${branch}.` };
|
|
502
|
+
}
|
|
503
|
+
if (wt.state === 'closed') {
|
|
504
|
+
return wt.cleanup === 'complete'
|
|
505
|
+
? { message: `node ${nodeId}'s managed worktree is already closed and its cleanup is complete`, next: `Nothing to ${verb} for this node.` }
|
|
506
|
+
: { message: `node ${nodeId}'s managed worktree is already closed; this is not lost ownership — its checkout at ${wt.path} is scheduled for automatic cleanup once that is safe`, next: `Nothing to ${verb} for this node. The daemon sweep finishes removing ${wt.path} on its own; inspect it directly only if it still needs manual attention.` };
|
|
507
|
+
}
|
|
508
|
+
return { message: `node ${nodeId} has no open managed worktree`, next: `Nothing to ${verb} for this node.` };
|
|
509
|
+
}
|
|
389
510
|
function closeManagedWorktreeLocked(nodeId) {
|
|
390
511
|
const node = getNode(nodeId);
|
|
391
512
|
if (node === null) {
|
|
@@ -393,7 +514,8 @@ function closeManagedWorktreeLocked(nodeId) {
|
|
|
393
514
|
}
|
|
394
515
|
const wt = openManagedWorktreeForNode(nodeId);
|
|
395
516
|
if (wt === null) {
|
|
396
|
-
|
|
517
|
+
const { message, next } = noManagedWorktreeMessage(nodeId, 'close');
|
|
518
|
+
throw new WorktreeError('no_managed_worktree', message, next);
|
|
397
519
|
}
|
|
398
520
|
if (!existsSync(wt.path)) {
|
|
399
521
|
const proof = proveAbsentPathManagedWorktree(wt);
|
|
@@ -427,7 +549,16 @@ function closeManagedWorktreeLocked(nodeId) {
|
|
|
427
549
|
const branch = currentBranch(wt.path);
|
|
428
550
|
if (branch !== wt.branch) {
|
|
429
551
|
const found = branch === 'HEAD' ? 'detached HEAD' : branch;
|
|
430
|
-
|
|
552
|
+
const recordedRefExists = recordedBranchExists(wt.path, wt.branch);
|
|
553
|
+
if (recordedRefExists || branch === 'HEAD') {
|
|
554
|
+
throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}, found ${found})`, recordedRefExists
|
|
555
|
+
? `Check out ${wt.branch} in ${wt.path} before closing, or ask the user with \`crtr human send\` if you are unsure why it changed, then rerun \`crtr node worktree close\`.`
|
|
556
|
+
: `The recorded branch ${wt.branch} no longer exists, and ${wt.path} is in detached HEAD, so there is no branch to preserve. Check out or create a branch there before closing, or ask the user with \`crtr human send\` if you are unsure how to proceed.`);
|
|
557
|
+
}
|
|
558
|
+
// The recorded crtr/* branch is gone: ordinary PR work moved this checkout
|
|
559
|
+
// onto a real branch. The checkout is already proven clean and not
|
|
560
|
+
// mid-rebase (both checked above), so disposal may proceed.
|
|
561
|
+
return closeBranchMovedWorktree(nodeId, wt, branch);
|
|
431
562
|
}
|
|
432
563
|
let baseSha;
|
|
433
564
|
try {
|
|
@@ -527,16 +658,45 @@ export function closeManagedWorktree(nodeId) {
|
|
|
527
658
|
const ABANDON_AUTHOR_NAME = 'crtr-abandon';
|
|
528
659
|
const ABANDON_AUTHOR_EMAIL = 'crtr-abandon@localhost';
|
|
529
660
|
const ABANDON_SUBJECT = 'crtr-abandon';
|
|
530
|
-
function persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by) {
|
|
661
|
+
function persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by, branch = wt.branch) {
|
|
531
662
|
updateNode(nodeId, {
|
|
532
663
|
cwd: wt.repo_root,
|
|
533
664
|
managed_worktree: {
|
|
534
665
|
...wt,
|
|
535
666
|
state: 'abandoned',
|
|
536
|
-
abandoned: { branch
|
|
667
|
+
abandoned: { branch, tip_sha: tipSha, at, by },
|
|
537
668
|
},
|
|
538
669
|
});
|
|
539
670
|
}
|
|
671
|
+
/** The disposal path for `abandon` when the recorded `crtr/*` branch is gone:
|
|
672
|
+
* ordinary PR work moved this checkout onto a real branch. Abandon never
|
|
673
|
+
* lands that branch and — unlike the ordinary path above — never invents a
|
|
674
|
+
* commit on it: a synthetic `crtr-abandon` marker on someone's real PR
|
|
675
|
+
* branch is not a trade this code may make on their behalf. So this
|
|
676
|
+
* requires the checkout already clean and not mid-operation, then removes
|
|
677
|
+
* only the checkout and preserves the actual branch and its commits exactly
|
|
678
|
+
* as they stand. The caller already confirmed the node is not running. */
|
|
679
|
+
function abandonBranchMovedWorktree(nodeId, wt, actualBranch, by) {
|
|
680
|
+
if (isRebaseInProgress(wt.path)) {
|
|
681
|
+
throw new WorktreeError('rebase_in_progress', 'this managed worktree is mid-rebase', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr node worktree abandon\`.`);
|
|
682
|
+
}
|
|
683
|
+
const dirty = statusPorcelain(wt.path);
|
|
684
|
+
if (dirty !== '') {
|
|
685
|
+
throw new WorktreeError('dirty_worktree', 'managed worktree has uncommitted changes', `The recorded branch ${wt.branch} no longer exists; its checkout is now on ${actualBranch} with uncommitted changes crtr will not commit on your behalf. Commit or stash them in ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
|
|
686
|
+
}
|
|
687
|
+
if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true) {
|
|
688
|
+
throw new WorktreeError('wrong_branch', `managed worktree's recorded branch ${wt.branch} no longer exists, and its checkout at ${wt.path} is not registered to ${actualBranch} either`, `Inspect ${wt.path} and its git worktree registration, then ask the user with \`crtr human send\` if you are unsure how to proceed.`);
|
|
689
|
+
}
|
|
690
|
+
const actualSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'abandon_tip_failed', 'Inspect the worktree manually, then retry.');
|
|
691
|
+
// Recheck identity immediately before the destructive step.
|
|
692
|
+
if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true || currentBranch(wt.path) !== actualBranch) {
|
|
693
|
+
throw new WorktreeError('wrong_branch', `managed worktree at ${wt.path} changed while abandoning`, `Inspect ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
|
|
694
|
+
}
|
|
695
|
+
runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
696
|
+
const at = new Date().toISOString();
|
|
697
|
+
persistAbandonedWorktreeState(nodeId, wt, actualSha, at, by, actualBranch);
|
|
698
|
+
return { node_id: nodeId, branch: actualBranch, tip_sha: actualSha, worktree_path: wt.path, branch_deleted: false };
|
|
699
|
+
}
|
|
540
700
|
function createAbandonMarker(wt, parentSha, nodeId, at) {
|
|
541
701
|
const treeSha = runGit(wt.repo_root, ['rev-parse', `${parentSha}^{tree}`], 'abandon_tree_failed', 'Inspect the branch manually, then retry.');
|
|
542
702
|
const markerSha = runGit(wt.repo_root, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit-tree', treeSha, '-p', parentSha, '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the branch manually, then retry.');
|
|
@@ -550,7 +710,8 @@ function abandonManagedWorktreeLocked(nodeId, by) {
|
|
|
550
710
|
}
|
|
551
711
|
const wt = openManagedWorktreeForNode(nodeId);
|
|
552
712
|
if (wt === null) {
|
|
553
|
-
|
|
713
|
+
const { message, next } = noManagedWorktreeMessage(nodeId, 'abandon');
|
|
714
|
+
throw new WorktreeError('no_managed_worktree', message, next);
|
|
554
715
|
}
|
|
555
716
|
if (node.status === 'active' || node.status === 'idle' || isPidAlive(node.pi_pid)) {
|
|
556
717
|
throw new WorktreeError('node_running', `node ${nodeId} is still running`, 'Stop the node before abandoning its managed worktree.');
|
|
@@ -558,8 +719,17 @@ function abandonManagedWorktreeLocked(nodeId, by) {
|
|
|
558
719
|
if (!existsSync(wt.path)) {
|
|
559
720
|
throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, missingWorktreeRecoveryHint(wt));
|
|
560
721
|
}
|
|
561
|
-
|
|
562
|
-
|
|
722
|
+
const actualBranch = currentBranch(wt.path);
|
|
723
|
+
if (actualBranch !== wt.branch) {
|
|
724
|
+
const recordedRefExists = recordedBranchExists(wt.path, wt.branch);
|
|
725
|
+
if (recordedRefExists || actualBranch === 'HEAD') {
|
|
726
|
+
throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}${actualBranch === 'HEAD' ? ', found detached HEAD' : ''})`, recordedRefExists
|
|
727
|
+
? `Check out ${wt.branch} in ${wt.path}, then rerun \`crtr node worktree abandon\`.`
|
|
728
|
+
: `The recorded branch ${wt.branch} no longer exists, and ${wt.path} is in detached HEAD, so there is no branch to preserve. Check out or create a branch there, then rerun \`crtr node worktree abandon\`.`);
|
|
729
|
+
}
|
|
730
|
+
// The recorded crtr/* branch is gone: ordinary PR work moved this checkout
|
|
731
|
+
// onto a real branch.
|
|
732
|
+
return abandonBranchMovedWorktree(nodeId, wt, actualBranch, by);
|
|
563
733
|
}
|
|
564
734
|
const at = new Date().toISOString();
|
|
565
735
|
const clean = proveCleanEmptyManagedWorktree(wt);
|
|
@@ -575,6 +745,15 @@ function abandonManagedWorktreeLocked(nodeId, by) {
|
|
|
575
745
|
persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
|
|
576
746
|
return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
|
|
577
747
|
}
|
|
748
|
+
// A clean branch with committed work already has its complete history. The
|
|
749
|
+
// abandon record supplies the disposition; an empty provenance commit would
|
|
750
|
+
// only move the local branch away from an already-pushed tip.
|
|
751
|
+
if (!isRebaseInProgress(wt.path) && statusPorcelain(wt.path) === '') {
|
|
752
|
+
runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
753
|
+
const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
|
|
754
|
+
persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
|
|
755
|
+
return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
|
|
756
|
+
}
|
|
578
757
|
runGit(wt.path, ['add', '--all'], 'abandon_stage_failed', 'Inspect the worktree manually, then retry.');
|
|
579
758
|
runGit(wt.path, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit', '--allow-empty', '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the worktree manually, then retry.');
|
|
580
759
|
runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
@@ -775,6 +954,39 @@ export function autoDropCleanManagedWorktreeLocked(nodeId, wt, proof) {
|
|
|
775
954
|
* `open_managed_worktree` guard then still applies unchanged — real
|
|
776
955
|
* commits, real uncommitted changes, and a lost race all require the
|
|
777
956
|
* explicit path. */
|
|
957
|
+
function cleanCurrentBranchTip(wt) {
|
|
958
|
+
if (wt.state !== 'open' || !existsSync(wt.path) || isRebaseInProgress(wt.path))
|
|
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
|
+
const status = gitSync(['status', '--porcelain'], wt.path);
|
|
964
|
+
if (status.status !== 0 || status.stdout.trim() !== '')
|
|
965
|
+
return null;
|
|
966
|
+
const head = gitSync(['rev-parse', 'HEAD'], wt.path);
|
|
967
|
+
return head.status === 0 ? head.stdout.trim() : null;
|
|
968
|
+
}
|
|
969
|
+
export async function branchIsPushedAtCurrentTip(wt) {
|
|
970
|
+
const initialTip = cleanCurrentBranchTip(wt);
|
|
971
|
+
if (initialTip === null)
|
|
972
|
+
return false;
|
|
973
|
+
// A cached origin/<branch> can be stale in either direction. Query origin's
|
|
974
|
+
// branch ref directly and require exact-tip equality: a remote that has an
|
|
975
|
+
// older commit would still leave newer local work stranded at finalization.
|
|
976
|
+
// This runs asynchronously with a bounded, non-interactive Git invocation so
|
|
977
|
+
// an unreachable remote never stalls crtrd's event loop.
|
|
978
|
+
const ssh = process.env['GIT_SSH_COMMAND'] ?? process.env['GIT_SSH'] ?? 'ssh';
|
|
979
|
+
const remote = await gitAsync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${wt.branch}`], wt.path, undefined, { timeoutMs: 10_000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: `${ssh} -o BatchMode=yes` } });
|
|
980
|
+
if (remote.status !== 0)
|
|
981
|
+
return false;
|
|
982
|
+
const expectedRef = `refs/heads/${wt.branch}`;
|
|
983
|
+
const matchingLine = remote.stdout.split('\n').find((line) => line.endsWith(`\t${expectedRef}`));
|
|
984
|
+
if (matchingLine?.slice(0, matchingLine.indexOf('\t')) !== initialTip)
|
|
985
|
+
return false;
|
|
986
|
+
// The remote proof is useful only if the local checkout is still the exact
|
|
987
|
+
// clean tip it inspected when finalization begins.
|
|
988
|
+
return cleanCurrentBranchTip(wt) === initialTip;
|
|
989
|
+
}
|
|
778
990
|
export function autoDropCleanManagedWorktree(nodeId) {
|
|
779
991
|
const wt = openManagedWorktreeForNode(nodeId);
|
|
780
992
|
if (wt === null)
|
|
@@ -170,12 +170,13 @@ function handleArm(ctx) {
|
|
|
170
170
|
? requireUtcIso(req.expires_at, 'expires_at')
|
|
171
171
|
: null;
|
|
172
172
|
const envJson = typeof req.env_json === 'string' && req.env_json !== '' ? requireEnvJson(req.env_json) : null;
|
|
173
|
-
const onOutput = requireEnum(req.on_output, 'on_output', ['silent', 'on-failure', 'always', 'on-change'], 'on-failure');
|
|
174
|
-
const overlap = requireEnum(req.overlap, 'overlap', ['skip', 'queue', 'replace'], 'skip');
|
|
175
|
-
const tier = requireEnum(req.tier, 'tier', ['normal', 'urgent', 'critical'], 'normal');
|
|
176
173
|
// Sink: required by the delivering dispositions, meaningless otherwise —
|
|
177
|
-
// progressive disclosure via the error that names the need.
|
|
174
|
+
// progressive disclosure via the error that names the need. A node sink is
|
|
175
|
+
// polling its recipient, so unchanged stdout must not wake it again by default.
|
|
178
176
|
const rawSink = typeof req.sink === 'string' && req.sink.trim() !== '' ? req.sink.trim() : null;
|
|
177
|
+
const onOutput = requireEnum(req.on_output, 'on_output', ['silent', 'on-failure', 'always', 'on-change'], rawSink?.startsWith('node:') ? 'on-change' : 'on-failure');
|
|
178
|
+
const overlap = requireEnum(req.overlap, 'overlap', ['skip', 'queue', 'replace'], 'skip');
|
|
179
|
+
const tier = requireEnum(req.tier, 'tier', ['normal', 'urgent', 'critical'], 'normal');
|
|
179
180
|
const delivers = onOutput === 'always' || onOutput === 'on-change';
|
|
180
181
|
if (delivers && rawSink === null) {
|
|
181
182
|
throw new InputError({
|
|
@@ -232,6 +232,11 @@ async function handleMessage(ctx) {
|
|
|
232
232
|
};
|
|
233
233
|
return { status: 201, body };
|
|
234
234
|
}
|
|
235
|
+
// Interactive delivery has the same finalization doorway as durable mail:
|
|
236
|
+
// a live broker remains reachable briefly after its final claim, but must not
|
|
237
|
+
// accept a mandate it cannot finalize. `--reopen` is the deliberate exception.
|
|
238
|
+
if (req.delivery === 'interactive' && !req.reopen)
|
|
239
|
+
assertNotFinalized(id);
|
|
235
240
|
// An interactive reopen commits before even choosing its engine-or-inbox
|
|
236
241
|
// route. A live transport race then falls back with this same committed state.
|
|
237
242
|
let reopenCommitted = false;
|
|
@@ -141,6 +141,8 @@ function buildRecipe(req, parent, cwd) {
|
|
|
141
141
|
recipe.worktree = true;
|
|
142
142
|
if (typeof req.worktree === 'string')
|
|
143
143
|
recipe.worktreeBase = req.worktree;
|
|
144
|
+
if (req.worktree !== undefined)
|
|
145
|
+
recipe.worktreeCwd = resolve(req.pin_cwd ?? req.cwd ?? cwd);
|
|
144
146
|
if (req.fork_from !== undefined)
|
|
145
147
|
recipe.forkFrom = req.fork_from;
|
|
146
148
|
if (req.model !== undefined)
|