@north-light/crouter 0.3.275 → 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 +406 -406
- 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/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/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
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)
|
|
@@ -9,7 +9,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { getNode } from '../../../core/canvas/canvas.js';
|
|
11
11
|
import { push, FinalizationError } from '../../../core/feed/feed.js';
|
|
12
|
-
import { autoDropCleanManagedWorktree, hasOpenManagedWorktree, missingWorktreeRecoveryHint } from '../../../core/worktree.js';
|
|
12
|
+
import { autoDropCleanManagedWorktree, branchIsPushedAtCurrentTip, hasOpenManagedWorktree, missingWorktreeRecoveryHint } from '../../../core/worktree.js';
|
|
13
13
|
import { reportsDir } from '../../../core/canvas/paths.js';
|
|
14
14
|
import { notFound, usage } from '../../../core/errors.js';
|
|
15
15
|
import { InputError } from '../../../core/io.js';
|
|
@@ -51,14 +51,19 @@ async function handlePush(ctx) {
|
|
|
51
51
|
// `final` with an OPEN managed worktree, BEFORE the push/transition (spec
|
|
52
52
|
// §6.2 — worktree fs ops are server-side). A provably-clean, zero-commit
|
|
53
53
|
// worktree is auto-dropped so a read-only or already-landed node
|
|
54
|
-
// can finish without an explicit `node worktree close`;
|
|
55
|
-
//
|
|
54
|
+
// can finish without an explicit `node worktree close`; a clean worktree
|
|
55
|
+
// whose exact tip is already on origin can also finish while retaining its
|
|
56
|
+
// branch and checkout. Any work that is neither locally landed nor remotely
|
|
57
|
+
// retained instead blocks with the parity `open_managed_worktree` error,
|
|
56
58
|
// round-tripped via CrtrError.details so the CLI renders the same InputError
|
|
57
59
|
// (next-hint included) it surfaced before B-5 re-homed this to the server.
|
|
58
60
|
let autoDroppedPath;
|
|
59
61
|
if (req.tier === 'final' && hasOpenManagedWorktree(getNode(id))) {
|
|
60
62
|
const dropped = autoDropCleanManagedWorktree(id);
|
|
61
|
-
if (dropped
|
|
63
|
+
if (dropped !== null) {
|
|
64
|
+
autoDroppedPath = dropped.worktree_path;
|
|
65
|
+
}
|
|
66
|
+
else if (!(await branchIsPushedAtCurrentTip(getNode(id).managed_worktree))) {
|
|
62
67
|
// Thrown as InputError so `toErrorBody` round-trips the FULL structured
|
|
63
68
|
// payload (code + message + next) into `ApiError.details`, and the CLI's
|
|
64
69
|
// shared `apiErrorToCliError` reconstructs it byte-for-byte — no
|
|
@@ -70,11 +75,10 @@ async function handlePush(ctx) {
|
|
|
70
75
|
const wt = getNode(id)?.managed_worktree;
|
|
71
76
|
return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
|
|
72
77
|
? missingWorktreeRecoveryHint(wt)
|
|
73
|
-
: 'Commit the remaining work in the worktree,
|
|
78
|
+
: 'Commit the remaining work in the worktree, push its branch to origin at its current tip, then rerun `crtr push final`; or run `crtr node worktree close` to land it locally.';
|
|
74
79
|
})(),
|
|
75
80
|
});
|
|
76
81
|
}
|
|
77
|
-
autoDroppedPath = dropped.worktree_path;
|
|
78
82
|
}
|
|
79
83
|
const result = await pushOrRethrow(id, req);
|
|
80
84
|
const transitioned = req.tier === 'final' && before !== undefined
|
|
@@ -100,7 +104,7 @@ async function pushOrRethrow(id, req) {
|
|
|
100
104
|
throw new InputError({
|
|
101
105
|
error: 'already_finalized',
|
|
102
106
|
message: 'This node already submitted its canonical final result.',
|
|
103
|
-
next: '
|
|
107
|
+
next: 'Push an update with the new information, then stop.',
|
|
104
108
|
});
|
|
105
109
|
}
|
|
106
110
|
if (err instanceof FinalizationError && err.code === 'finalization_forbidden') {
|
package/dist/types.d.ts
CHANGED
|
@@ -97,7 +97,7 @@ export interface PluginManifest {
|
|
|
97
97
|
commands?: string;
|
|
98
98
|
/** Plugin-root-relative path to a declarative hook manifest (`hooks.json`).
|
|
99
99
|
* Must be declared together with `hookExecutable`; hooks are trusted local
|
|
100
|
-
* code
|
|
100
|
+
* code. */
|
|
101
101
|
hooks?: string;
|
|
102
102
|
/** Plugin-root-relative executable that dispatches declarations in `hooks`.
|
|
103
103
|
* Must be a regular file with a POSIX executable permission bit. */
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.276",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.276",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|