@north-light/crouter 0.3.188 → 0.3.190

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { abortSessionFromExtension, runRefreshAbort } from '../runtime/broker/extension-abort.js';
4
+ test('refresh extension abort clears Pi follow-ups before abort while an ordinary abort preserves them', () => {
5
+ const events = [];
6
+ const session = {
7
+ clearQueue: () => { events.push('clear'); },
8
+ abort: async () => { events.push('abort'); },
9
+ };
10
+ abortSessionFromExtension(session);
11
+ assert.deepEqual(events, ['abort'], 'ordinary extension abort leaves queued messages in the current conversation');
12
+ events.length = 0;
13
+ runRefreshAbort(() => abortSessionFromExtension(session));
14
+ assert.deepEqual(events, ['clear', 'abort'], 'refresh removes queued messages before stopping the discarded conversation');
15
+ events.length = 0;
16
+ abortSessionFromExtension(session);
17
+ assert.deepEqual(events, ['abort'], 'the refresh scope does not leak into a later critical abort');
18
+ });
@@ -434,6 +434,89 @@ test('autoDropCleanManagedWorktree drops a clean, zero-commit worktree', async (
434
434
  });
435
435
  // The same real-commit case close already protects: a worktree with actual
436
436
  // unlanded work must NEVER be auto-dropped by push-final.
437
+ // #671 also makes the explicit close terminal: once the checkout was safely
438
+ // removed after remote delivery, close concludes the record rather than
439
+ // sending the node to a recovery command that cannot address an absent path.
440
+ test('closeManagedWorktree concludes an absent checkout whose branch is delivered to its base upstream (#671)', () => {
441
+ const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-671-close-'));
442
+ const remote = mkdtempSync(join(tmpdir(), 'crtr-worktree-671-close-remote-'));
443
+ try {
444
+ run(['init', '-b', 'main'], repo);
445
+ run(['config', 'user.email', 'test@example.com'], repo);
446
+ run(['config', 'user.name', 'Test'], repo);
447
+ writeFileSync(join(repo, 'README.md'), 'hello\n');
448
+ run(['add', '.'], repo);
449
+ run(['commit', '-m', 'init'], repo);
450
+ run(['init', '--bare'], remote);
451
+ run(['remote', 'add', 'origin', remote], repo);
452
+ run(['push', '-u', 'origin', 'main'], repo);
453
+ const nodeId = 'wt-671-close';
454
+ const wt = createManagedWorktree(repo, nodeId);
455
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
456
+ writeFileSync(join(wt.path, 'delivered.txt'), 'delivered remotely\n');
457
+ run(['add', '.'], wt.path);
458
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'delivered remotely'], wt.path);
459
+ const deliveredSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
460
+ run(['push', 'origin', `${wt.branch}:main`], wt.path);
461
+ run(['fetch', 'origin'], repo);
462
+ run(['worktree', 'remove', wt.path], repo);
463
+ const result = closeManagedWorktree(nodeId);
464
+ assert.equal(result.landed_sha, deliveredSha, 'close reports the already-delivered branch tip');
465
+ assert.equal(result.worktree_removed, true, 'the absent path is already removed');
466
+ assert.equal(result.branch_deleted, true, 'the redundant local branch is deleted with upstream proof');
467
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'closed');
468
+ assert.equal(getNode(nodeId)?.cwd, wt.repo_root, 'the closed record repoints revive to the durable base checkout');
469
+ }
470
+ finally {
471
+ rmSync(repo, { recursive: true, force: true });
472
+ rmSync(remote, { recursive: true, force: true });
473
+ }
474
+ });
475
+ // #671 review finding (Major): the recorded path being absent does not mean
476
+ // the branch has no live checkout — `git worktree move` can relocate it
477
+ // elsewhere. The branch must never be low-level-deleted while still checked
478
+ // out somewhere, even when its tip is already contained in the base upstream.
479
+ test('closeManagedWorktree refuses when the recorded path is absent but the branch is checked out elsewhere with uncommitted work', () => {
480
+ const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-671-moved-'));
481
+ const remote = mkdtempSync(join(tmpdir(), 'crtr-worktree-671-moved-remote-'));
482
+ const movedPath = mkdtempSync(join(tmpdir(), 'crtr-worktree-671-moved-dest-'));
483
+ rmSync(movedPath, { recursive: true, force: true });
484
+ try {
485
+ run(['init', '-b', 'main'], repo);
486
+ run(['config', 'user.email', 'test@example.com'], repo);
487
+ run(['config', 'user.name', 'Test'], repo);
488
+ writeFileSync(join(repo, 'README.md'), 'hello\n');
489
+ run(['add', '.'], repo);
490
+ run(['commit', '-m', 'init'], repo);
491
+ run(['init', '--bare'], remote);
492
+ run(['remote', 'add', 'origin', remote], repo);
493
+ run(['push', '-u', 'origin', 'main'], repo);
494
+ const nodeId = 'wt-671-moved';
495
+ const wt = createManagedWorktree(repo, nodeId);
496
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
497
+ writeFileSync(join(wt.path, 'delivered.txt'), 'delivered remotely\n');
498
+ run(['add', '.'], wt.path);
499
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'delivered remotely'], wt.path);
500
+ run(['push', 'origin', `${wt.branch}:main`], wt.path);
501
+ run(['fetch', 'origin'], repo);
502
+ // Relocate the checkout — the recorded `wt.path` is now absent, but the
503
+ // branch is still live at `movedPath`, and unrelated new work is added
504
+ // there AFTER the move, so it is not reachable from origin/main.
505
+ run(['worktree', 'move', wt.path, movedPath], repo);
506
+ assert.equal(existsSync(wt.path), false, 'the recorded path must actually be absent for this test to be meaningful');
507
+ writeFileSync(join(movedPath, 'uncommitted-after-move.txt'), 'must not be lost\n');
508
+ assert.throws(() => closeManagedWorktree(nodeId), (error) => error instanceof Error && 'code' in error && error.code === 'worktree_missing', 'a branch still checked out elsewhere must never be concluded from the absent recorded path alone');
509
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'the record must stay open — the branch has a live checkout with uncommitted work');
510
+ const refCheck = gitSync(['rev-parse', '--verify', `refs/heads/${wt.branch}`], repo);
511
+ assert.equal(refCheck.status, 0, 'the branch must be preserved — it is still checked out');
512
+ assert.ok(existsSync(join(movedPath, 'uncommitted-after-move.txt')), 'the uncommitted work at the relocated checkout must remain intact');
513
+ }
514
+ finally {
515
+ rmSync(repo, { recursive: true, force: true });
516
+ rmSync(remote, { recursive: true, force: true });
517
+ rmSync(movedPath, { recursive: true, force: true });
518
+ }
519
+ });
437
520
  test('autoDropCleanManagedWorktree refuses a worktree with a real commit ahead of base', async () => {
438
521
  const { autoDropCleanManagedWorktree } = await import('../../worktree.js');
439
522
  const nodeId = 'wt-auto-drop-real-work';
@@ -0,0 +1,9 @@
1
+ interface AbortableSession {
2
+ clearQueue(): unknown;
3
+ abort(): Promise<unknown>;
4
+ }
5
+ /** Mark one synchronous ExtensionContext.abort() call as a refresh boundary. */
6
+ export declare function runRefreshAbort(abort: () => void): void;
7
+ /** Bound into Pi's extension context by the broker for every session generation. */
8
+ export declare function abortSessionFromExtension(session: AbortableSession): void;
9
+ export {};
@@ -0,0 +1,23 @@
1
+ // ExtensionContext.abort() reaches the broker through AgentSession's bound
2
+ // abortHandler. A refresh-yield is the one extension abort that must also clear
3
+ // Pi's queued steering/follow-up messages: those messages remain durable in the
4
+ // canvas inbox while intent='refresh', and the fresh kickoff replays them.
5
+ // Other aborts (notably a critical inbox entry) leave queues intact because the
6
+ // current conversation still owns those already-handed-off messages.
7
+ let refreshAbortScope = false;
8
+ /** Mark one synchronous ExtensionContext.abort() call as a refresh boundary. */
9
+ export function runRefreshAbort(abort) {
10
+ refreshAbortScope = true;
11
+ try {
12
+ abort();
13
+ }
14
+ finally {
15
+ refreshAbortScope = false;
16
+ }
17
+ }
18
+ /** Bound into Pi's extension context by the broker for every session generation. */
19
+ export function abortSessionFromExtension(session) {
20
+ if (refreshAbortScope)
21
+ session.clearQueue();
22
+ void session.abort();
23
+ }
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { jobDir } from '../../canvas/paths.js';
4
4
  import { emitEvent } from '../../events/emit.js';
5
5
  import { operationIdContext } from '../../events/operation-id.js';
6
+ import { abortSessionFromExtension } from './extension-abort.js';
6
7
  const MAX_ADMITTED_REPLACEMENTS = 2;
7
8
  const REPLACEMENT_DEADLINE_MS = 30_000;
8
9
  const REPLACEMENT_DEADLINE_EXCEEDED = Symbol('replacement-deadline-exceeded');
@@ -52,7 +53,12 @@ export class RebindDriver {
52
53
  this.agentRunActiveValue = false;
53
54
  this.deps.onSessionRebind();
54
55
  this.deps.toolGroups.resetForSession();
55
- await candidateSession.bindExtensions({ uiContext: this.deps.uiContext(), mode: 'rpc', shutdownHandler: () => this.requestShutdown() });
56
+ await candidateSession.bindExtensions({
57
+ uiContext: this.deps.uiContext(),
58
+ mode: 'rpc',
59
+ abortHandler: () => abortSessionFromExtension(candidateSession),
60
+ shutdownHandler: () => this.requestShutdown(),
61
+ });
56
62
  if (ticket !== this.latestRebindRequest)
57
63
  return;
58
64
  this.removeInstalledSubscription();
@@ -38,6 +38,10 @@ export declare function closeManagedWorktree(nodeId: string): CloseManagedWorktr
38
38
  type WorktreeCleanupRunner = (args: string[], cwd: string, options: GitSyncOptions) => GitResult;
39
39
  /** Test seam for the bounded post-land cleanup regression. */
40
40
  export declare function setWorktreeCleanupRunnerForTest(runner?: WorktreeCleanupRunner): void;
41
+ /** Recovery guidance for a vanished checkout whose refs do not prove that its
42
+ * work is delivered. This deliberately never recommends `worktree close`:
43
+ * that command cannot operate on the absent checkout. */
44
+ export declare function missingWorktreeRecoveryHint(wt: ManagedWorktree): string;
41
45
  interface CleanEmptyProof {
42
46
  /** The branch's HEAD SHA at the moment this was proven clean-and-contained
43
47
  * — the exact value the destructive delete must be conditioned on. */
@@ -59,8 +59,15 @@ function resolveLandableBase(repoRoot, baseRef) {
59
59
  * if it is not checked out in any worktree. */
60
60
  function checkoutPathForBranch(repoRoot, branch) {
61
61
  const out = runGit(repoRoot, ['worktree', 'list', '--porcelain'], 'worktree_list_failed', 'Inspect the repository worktrees manually, then retry.');
62
+ return checkoutPathForBranchInList(out, branch);
63
+ }
64
+ /** Shared parse for a `git worktree list --porcelain` blob already in hand —
65
+ * callers that must not throw on a read failure (an absent-path proof that
66
+ * needs to fail closed rather than error) call `gitSync` themselves and pass
67
+ * the output here instead of going through `checkoutPathForBranch`. */
68
+ function checkoutPathForBranchInList(porcelain, branch) {
62
69
  let current = null;
63
- for (const line of out.split('\n')) {
70
+ for (const line of porcelain.split('\n')) {
64
71
  if (line.startsWith('worktree '))
65
72
  current = line.slice('worktree '.length).trim();
66
73
  else if (line === `branch refs/heads/${branch}`)
@@ -256,7 +263,26 @@ function closeManagedWorktreeLocked(nodeId) {
256
263
  throw new WorktreeError('no_managed_worktree', `node ${nodeId} has no open managed worktree`, 'Nothing to close for this node.');
257
264
  }
258
265
  if (!existsSync(wt.path)) {
259
- throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, 'Ask the human with `crtr human ask` before changing node state; the checkout disappeared outside crouter.');
266
+ const proof = proveAbsentPathManagedWorktree(wt);
267
+ if (proof === null) {
268
+ throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, missingWorktreeRecoveryHint(wt));
269
+ }
270
+ const dropped = autoDropAbsentPathManagedWorktreeLocked(nodeId, wt, proof);
271
+ if (dropped === null) {
272
+ throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, missingWorktreeRecoveryHint(wt));
273
+ }
274
+ return {
275
+ node_id: nodeId,
276
+ branch: wt.branch,
277
+ worktree_path: wt.path,
278
+ // No local fast-forward happened in this recovery path. When the branch
279
+ // still exists, this is its remotely/local-base-delivered tip; when it
280
+ // was already deleted, the recorded creation base is the only durable
281
+ // SHA left to report through the established close result contract.
282
+ landed_sha: proof.branchSha ?? wt.base_sha,
283
+ worktree_removed: true,
284
+ branch_deleted: true,
285
+ };
260
286
  }
261
287
  if (isRebaseInProgress(wt.path)) {
262
288
  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 close\`.`);
@@ -458,7 +484,13 @@ function bestEffortRemoveWorktreeCheckout(wt, options = {}) {
458
484
  * simultaneously exactly what was proven, so a base rewrite can never leave
459
485
  * a proven commit unreachable from any head. */
460
486
  function bestEffortDeleteBranchWithBaseProof(wt, branchSha, baseSha) {
461
- const stdin = `verify refs/heads/${wt.base_ref} ${baseSha}\ndelete refs/heads/${wt.branch} ${branchSha}\n`;
487
+ return bestEffortDeleteBranchWithContainmentProof(wt, branchSha, `refs/heads/${wt.base_ref}`, baseSha);
488
+ }
489
+ /** Delete a source branch only while the ref that proved it already delivered
490
+ * is still at the exact inspected SHA. `containingRef` can be the recorded
491
+ * local base or that base's remote-tracking upstream. */
492
+ function bestEffortDeleteBranchWithContainmentProof(wt, branchSha, containingRef, containingSha) {
493
+ const stdin = `verify ${containingRef} ${containingSha}\ndelete refs/heads/${wt.branch} ${branchSha}\n`;
462
494
  const transaction = gitSync(['update-ref', '--stdin'], wt.repo_root, stdin);
463
495
  const branchDeleted = transaction.status === 0;
464
496
  if (branchDeleted)
@@ -469,6 +501,80 @@ function bestEffortDeleteBranchWithBaseProof(wt, branchSha, baseSha) {
469
501
  branchDeleteError: `local branch ${wt.branch} could not be deleted automatically (${detail}); run \`git -C ${shellQuote(wt.repo_root)} branch -D ${shellQuote(wt.branch)}\` after confirming it is merged.`,
470
502
  };
471
503
  }
504
+ /** A missing checkout can still be concluded, but only from refs: either the
505
+ * recorded local branch is already gone, or its tip is contained in the
506
+ * recorded base or that base's upstream. Filesystem absence alone proves
507
+ * nothing about commits. */
508
+ function proveAbsentPathManagedWorktree(wt) {
509
+ if (wt.state !== 'open' || existsSync(wt.path))
510
+ return null;
511
+ const branchRef = `refs/heads/${wt.branch}`;
512
+ const branchExists = gitSync(['show-ref', '--verify', '--quiet', branchRef], wt.repo_root);
513
+ if (branchExists.status === 1)
514
+ return { branchSha: null };
515
+ if (branchExists.status !== 0)
516
+ return null;
517
+ const branch = gitSync(['rev-parse', '--verify', branchRef], wt.repo_root);
518
+ if (branch.status !== 0)
519
+ return null;
520
+ const branchSha = branch.stdout.trim();
521
+ // The recorded path being absent does NOT mean the branch has no live
522
+ // checkout: `git worktree move` (or a remove-then-re-add elsewhere) can
523
+ // relocate it to a different path while leaving `wt.path` gone. Deleting a
524
+ // branch that is still checked out somewhere else would bypass Git's own
525
+ // checked-out-branch protection and can abandon uncommitted work at that
526
+ // other path. Fail closed on any ambiguity from the inspection itself.
527
+ const worktreeList = gitSync(['worktree', 'list', '--porcelain'], wt.repo_root);
528
+ if (worktreeList.status !== 0)
529
+ return null;
530
+ if (checkoutPathForBranchInList(worktreeList.stdout, wt.branch) !== null)
531
+ return null;
532
+ const candidates = [];
533
+ const localBaseRef = `refs/heads/${wt.base_ref}`;
534
+ const localBaseExists = gitSync(['show-ref', '--verify', '--quiet', localBaseRef], wt.repo_root);
535
+ if (localBaseExists.status === 0) {
536
+ const localBase = gitSync(['rev-parse', '--verify', localBaseRef], wt.repo_root);
537
+ if (localBase.status !== 0)
538
+ return null;
539
+ candidates.push({ ref: localBaseRef, sha: localBase.stdout.trim() });
540
+ }
541
+ else if (localBaseExists.status !== 1) {
542
+ return null;
543
+ }
544
+ const upstream = gitSync(['rev-parse', '--symbolic-full-name', '--verify', `${wt.base_ref}@{upstream}`], wt.repo_root);
545
+ if (upstream.status === 0) {
546
+ const ref = upstream.stdout.trim();
547
+ const sha = gitSync(['rev-parse', '--verify', ref], wt.repo_root);
548
+ if (ref.startsWith('refs/') && sha.status === 0)
549
+ candidates.push({ ref, sha: sha.stdout.trim() });
550
+ }
551
+ else {
552
+ // Managed bases normally track origin. Retained records from before that
553
+ // configuration existed can still prove delivery through origin/<base>.
554
+ const ref = `refs/remotes/origin/${wt.base_ref}`;
555
+ const exists = gitSync(['show-ref', '--verify', '--quiet', ref], wt.repo_root);
556
+ if (exists.status === 0) {
557
+ const sha = gitSync(['rev-parse', '--verify', ref], wt.repo_root);
558
+ if (sha.status === 0)
559
+ candidates.push({ ref, sha: sha.stdout.trim() });
560
+ }
561
+ else if (exists.status !== 1) {
562
+ return null;
563
+ }
564
+ }
565
+ for (const candidate of candidates) {
566
+ if (gitSync(['merge-base', '--is-ancestor', branchSha, candidate.sha], wt.repo_root).status === 0) {
567
+ return { branchSha, containingRef: candidate.ref, containingSha: candidate.sha };
568
+ }
569
+ }
570
+ return null;
571
+ }
572
+ /** Recovery guidance for a vanished checkout whose refs do not prove that its
573
+ * work is delivered. This deliberately never recommends `worktree close`:
574
+ * that command cannot operate on the absent checkout. */
575
+ export function missingWorktreeRecoveryHint(wt) {
576
+ return `The recorded checkout path is gone, but crtr cannot prove ${wt.branch} is already delivered. Push or land ${wt.branch}, then rerun \`crtr push final\`; if you cannot establish what happened to the branch, ask the human with \`crtr human ask\`.`;
577
+ }
472
578
  /** Fix B's shared "nothing to land" test — the SAME clean-worktree definition
473
579
  * `close`'s own dirty check now naturally satisfies once `.git/info/exclude`
474
580
  * hides crtr's own `.crouter/config.json`/`state.json` bookkeeping (Fix A),
@@ -519,6 +625,25 @@ export function proveCleanEmptyManagedWorktree(wt) {
519
625
  return null;
520
626
  return { branchSha, baseSha };
521
627
  }
628
+ /** Conclude an absent-path record only after `proveAbsentPathManagedWorktree`
629
+ * has established the same no-unlanded-work property required by auto-drop.
630
+ * There is no checkout left to remove; persistClosedWorktreeState writes the
631
+ * closed record and repoints cwd exactly like the normal tombstone path. */
632
+ function autoDropAbsentPathManagedWorktreeLocked(nodeId, wt, proof) {
633
+ if (existsSync(wt.path))
634
+ return null;
635
+ if (proof.branchSha === null) {
636
+ persistClosedWorktreeState(nodeId, wt);
637
+ return { node_id: nodeId, branch: wt.branch, worktree_path: wt.path, worktree_removed: true, branch_deleted: true };
638
+ }
639
+ if (proof.containingRef === undefined || proof.containingSha === undefined)
640
+ return null;
641
+ const { branchDeleted } = bestEffortDeleteBranchWithContainmentProof(wt, proof.branchSha, proof.containingRef, proof.containingSha);
642
+ if (!branchDeleted)
643
+ return null;
644
+ persistClosedWorktreeState(nodeId, wt);
645
+ return { node_id: nodeId, branch: wt.branch, worktree_path: wt.path, worktree_removed: true, branch_deleted: true };
646
+ }
522
647
  /** Exported (see `proveCleanEmptyManagedWorktree`'s doc) so regression tests
523
648
  * can call it directly with a proof captured before injecting a race.
524
649
  * Returns null — persisting NO state change — when the immediate base
@@ -623,7 +748,8 @@ export function autoDropCleanManagedWorktree(nodeId) {
623
748
  return null;
624
749
  // A negative read-only preflight must fail before waiting on the repository
625
750
  // mutation lock: real unlanded work should reach push-final's guard promptly.
626
- if (proveCleanEmptyManagedWorktree(wt) === null)
751
+ const pathAbsent = !existsSync(wt.path);
752
+ if (pathAbsent ? proveAbsentPathManagedWorktree(wt) === null : proveCleanEmptyManagedWorktree(wt) === null)
627
753
  return null;
628
754
  return withRepositoryWorktreeLock(wt.repo_root, () => {
629
755
  // Re-fetch inside the lock: state may have changed since the caller's
@@ -631,11 +757,13 @@ export function autoDropCleanManagedWorktree(nodeId) {
631
757
  const locked = openManagedWorktreeForNode(nodeId);
632
758
  if (locked === null)
633
759
  return null;
634
- // The preflight only avoids needless lock contention. Re-prove the full
635
- // predicate under the lock before any cleanup.
760
+ // Re-prove the matching predicate under the lock before any state or ref
761
+ // mutation. A path that reappeared follows the ordinary checkout path.
762
+ if (!existsSync(locked.path)) {
763
+ const proof = proveAbsentPathManagedWorktree(locked);
764
+ return proof === null ? null : autoDropAbsentPathManagedWorktreeLocked(nodeId, locked, proof);
765
+ }
636
766
  const proof = proveCleanEmptyManagedWorktree(locked);
637
- if (proof === null)
638
- return null;
639
- return autoDropCleanManagedWorktreeLocked(nodeId, locked, proof);
767
+ return proof === null ? null : autoDropCleanManagedWorktreeLocked(nodeId, locked, proof);
640
768
  });
641
769
  }
@@ -198,6 +198,63 @@ test('auto-drop: `crtr push final` on a node with a CLEAN, zero-commit managed w
198
198
  rmSync(repo, { recursive: true, force: true });
199
199
  }
200
200
  });
201
+ // #671: after a failed local close, a node may safely deliver its branch to
202
+ // the base's upstream and remove its own clean checkout. The path is gone, so
203
+ // only the remote-tracking ref can prove finalization will not discard work.
204
+ test('`crtr push final` concludes an absent managed worktree whose branch is delivered to its base upstream (#671)', async () => {
205
+ const repo = initRepo();
206
+ const remote = mkdtempSync(join(tmpdir(), 'crtr-leafparity-remote-'));
207
+ try {
208
+ git(['init', '--bare'], remote);
209
+ git(['remote', 'add', 'origin', remote], repo);
210
+ git(['push', '-u', 'origin', 'main'], repo);
211
+ const id = 'wt-671-upstream-delivered';
212
+ const wt = createManagedWorktree(repo, id);
213
+ writeFileSync(join(wt.path, 'delivered.txt'), 'delivered remotely\n');
214
+ git(['add', '.'], wt.path);
215
+ git(['commit', '-m', 'delivered remotely'], wt.path);
216
+ const deliveredSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
217
+ // This is the incident delivery route: origin/main contains the branch,
218
+ // while the local base remains deliberately untouched.
219
+ git(['push', 'origin', `${wt.branch}:main`], wt.path);
220
+ git(['fetch', 'origin'], repo);
221
+ assert.equal(gitSync(['merge-base', '--is-ancestor', deliveredSha, 'origin/main'], repo).status, 0, 'the remote-tracking upstream must prove delivery');
222
+ git(['worktree', 'remove', wt.path], repo);
223
+ assert.equal(existsSync(wt.path), false, 'the incident shape has no checkout path left');
224
+ createNode(worktreeNode(id, wt.path, wt));
225
+ const out = await runPushAs(id, 'final', 'delivered remotely and cleaned up');
226
+ assert.equal(out['worktree_auto_dropped'], true, 'the absent-path proof must unblock finalization');
227
+ assert.equal(getNode(id)?.managed_worktree?.state, 'closed', 'the record is tombstoned as closed');
228
+ assert.equal(getNode(id)?.cwd, wt.repo_root, 'the closed record repoints revive to the durable base checkout');
229
+ assert.equal(getNode(id)?.status, 'done', 'finalization completes after the proven drop');
230
+ }
231
+ finally {
232
+ rmSync(repo, { recursive: true, force: true });
233
+ rmSync(remote, { recursive: true, force: true });
234
+ }
235
+ });
236
+ test('`crtr push final` keeps an absent managed worktree open when its branch is not delivered (#671)', async () => {
237
+ const repo = initRepo();
238
+ try {
239
+ const id = 'wt-671-absent-unlanded';
240
+ const wt = createManagedWorktree(repo, id);
241
+ writeFileSync(join(wt.path, 'unlanded.txt'), 'must remain reachable\n');
242
+ git(['add', '.'], wt.path);
243
+ git(['commit', '-m', 'unlanded work'], wt.path);
244
+ git(['worktree', 'remove', wt.path], repo);
245
+ createNode(worktreeNode(id, wt.path, wt));
246
+ await assert.rejects(() => runPushAs(id, 'final', 'must block'), (e) => e instanceof InputError
247
+ && e.payload.error === 'open_managed_worktree'
248
+ && typeof e.payload.next === 'string'
249
+ && e.payload.next.includes(`Push or land ${wt.branch}`)
250
+ && !e.payload.next.includes('node worktree close'), 'an absent checkout with unlanded commits must remain blocked with a usable recovery path');
251
+ assert.equal(getNode(id)?.managed_worktree?.state, 'open', 'the ambiguous record must stay open');
252
+ assert.equal(gitSync(['rev-parse', '--verify', `refs/heads/${wt.branch}`], repo).status, 0, 'the unlanded branch must remain reachable');
253
+ }
254
+ finally {
255
+ rmSync(repo, { recursive: true, force: true });
256
+ }
257
+ });
201
258
  // ---------------------------------------------------------------------------
202
259
  // `node worktree close` error parity — the SPECIFIC WorktreeError code + its
203
260
  // `next` remediation round-trip verbatim through the server → ApiError → the
@@ -85,6 +85,15 @@ function parseCreateBody(body) {
85
85
  if (typeof b['kind'] !== 'string' || b['kind'].trim() === '') {
86
86
  throw usage('a node create requires a kind');
87
87
  }
88
+ const rootLifecycle = b['root_lifecycle'];
89
+ if (rootLifecycle !== undefined &&
90
+ rootLifecycle !== 'resident' &&
91
+ rootLifecycle !== 'terminal') {
92
+ throw usage("root_lifecycle must be 'resident' or 'terminal'");
93
+ }
94
+ if (rootLifecycle !== undefined && b['root'] !== true) {
95
+ throw usage('root_lifecycle is only valid with root: true');
96
+ }
88
97
  return b;
89
98
  }
90
99
  /** The directory a new node is pinned to. An explicit `pin_cwd` wins; else a
@@ -122,6 +131,9 @@ function buildRecipe(req, parent, cwd) {
122
131
  recipe.parent = parent;
123
132
  if (req.root === true)
124
133
  recipe.root = true;
134
+ if (req.root_lifecycle !== undefined) {
135
+ recipe.rootLifecycle = req.root_lifecycle;
136
+ }
125
137
  if (req.worktree === true || typeof req.worktree === 'string')
126
138
  recipe.worktree = true;
127
139
  if (typeof req.worktree === 'string')
@@ -143,7 +155,8 @@ function buildRecipe(req, parent, cwd) {
143
155
  * kickoff, so anything that shapes the node AT BIRTH — a prompt to run, a
144
156
  * forked conversation, a managed worktree, a caller-pinned node id, a spine
145
157
  * parent — disqualifies it. `kind`, `mode`, `model`, `name`, `situational_context`
146
- * and `output_schema` are all applied at claim time and so do not. */
158
+ * and `output_schema` are all applied at claim time and so do not. A terminal
159
+ * root is not warm-compatible: every spare is resident. */
147
160
  function warmRequestFor(req, cwd, profileId) {
148
161
  if (!warmPoolEnabled())
149
162
  return null;
@@ -151,6 +164,8 @@ function warmRequestFor(req, cwd, profileId) {
151
164
  return null;
152
165
  if (req.root !== true || req.parent != null)
153
166
  return null;
167
+ if (req.root_lifecycle === 'terminal')
168
+ return null;
154
169
  if ((req.prompt ?? '') !== '')
155
170
  return null;
156
171
  if (req.fork_from !== undefined || req.worktree !== undefined || req.node_id !== undefined)
@@ -210,7 +225,7 @@ export async function handleCreate(ctx, deps = {}) {
210
225
  const profileId = await resolveProfileId(req.profile, parent ?? null, contextCwd);
211
226
  const cwd = resolveNodeCwd(req, parent, profileId, contextCwd);
212
227
  const launch = buildLaunchSpec(req.kind, (req.mode ?? 'base'), {
213
- lifecycle: req.root === true ? 'resident' : 'terminal',
228
+ lifecycle: req.root === true ? (req.root_lifecycle ?? 'resident') : 'terminal',
214
229
  hasManager: req.root !== true,
215
230
  ...(req.model !== undefined ? { model: req.model } : {}),
216
231
  cwd,
@@ -4,11 +4,11 @@
4
4
  // /nodes/{id}/reports` lists the node's stored report files.
5
5
  //
6
6
  // `{id}` is the REPORTING node (client-side = CRTR_NODE_ID).
7
- import { readdirSync, readFileSync, statSync } from 'node:fs';
7
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { getNode } from '../../../index.js';
10
10
  import { push, FinalizationError } from '../../../core/feed/feed.js';
11
- import { autoDropCleanManagedWorktree, hasOpenManagedWorktree } from '../../../core/worktree.js';
11
+ import { autoDropCleanManagedWorktree, hasOpenManagedWorktree, missingWorktreeRecoveryHint } from '../../../core/worktree.js';
12
12
  import { reportsDir } from '../../../core/canvas/paths.js';
13
13
  import { notFound, usage } from '../../../core/errors.js';
14
14
  import { InputError } from '../../../core/io.js';
@@ -58,7 +58,12 @@ async function handlePush(ctx) {
58
58
  throw new InputError({
59
59
  error: 'open_managed_worktree',
60
60
  message: 'This node still owns an open managed worktree — `push final` is blocked until it is closed.',
61
- next: 'Commit the remaining work in the worktree, run `crtr node worktree close`, then rerun `crtr push final`.',
61
+ next: (() => {
62
+ const wt = getNode(id)?.managed_worktree;
63
+ return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
64
+ ? missingWorktreeRecoveryHint(wt)
65
+ : 'Commit the remaining work in the worktree, run `crtr node worktree close`, then rerun `crtr push final`.';
66
+ })(),
62
67
  });
63
68
  }
64
69
  autoDroppedPath = dropped.worktree_path;
@@ -32,6 +32,7 @@ import { emitEvent } from '../core/events/emit.js';
32
32
  import { activeBackgroundBashJobs } from '../core/bash-jobs.js';
33
33
  import { CONTEXT_NUDGE_CUSTOM_TYPE, formatContextNudge, formatPersonaTransition } from '../shared/generated-context.js';
34
34
  import { isReviewBoundary } from '../core/runtime/session-visibility.js';
35
+ import { runRefreshAbort } from '../core/runtime/broker/extension-abort.js';
35
36
  /** Condense a tool call into one short line for the activity cue. Picks the most
36
37
  * identifying arg (command / path / pattern / description), collapses whitespace,
37
38
  * and hard-caps the length so a giant bash command can't bloat telemetry.json. */
@@ -479,11 +480,14 @@ export function registerCanvasStophook(pi) {
479
480
  touchBusy(nodeId); // wedge heartbeat: the tool returning is a sign of life
480
481
  clearFault(nodeId, { link: 'daemon→node' }); // a tool that was "stuck" just returned — the wedge notice was resolved
481
482
  if ((await brokerExtensionState(nodeId)).node.intent === 'refresh') {
482
- // Stop this turn immediately so the large context does not keep running,
483
- // but leave the broker alive through agent_settled. Only a successful
484
- // daemon settle directive may request Pi shutdown.
483
+ // Stop this turn immediately so the large context does not keep running.
484
+ // The broker clears Pi's queued steering/follow-up messages inside this
485
+ // refresh scope; their durable inbox entries stay uncommitted and the
486
+ // fresh kickoff replays them. Leave the broker alive through
487
+ // agent_settled: only a successful daemon settle directive may request
488
+ // Pi shutdown.
485
489
  try {
486
- ctx?.abort?.();
490
+ runRefreshAbort(() => ctx?.abort?.());
487
491
  }
488
492
  catch { /* ignore */ }
489
493
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.188",
3
+ "version": "0.3.190",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.188",
3
+ "version": "0.3.190",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.188",
9
+ "version": "0.3.190",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {