@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.
- package/dist/api/dto/nodes.d.ts +4 -0
- package/dist/clients/attach/viewer.js +281 -281
- package/dist/core/__tests__/extension-abort.test.d.ts +1 -0
- package/dist/core/__tests__/extension-abort.test.js +18 -0
- package/dist/core/__tests__/serial/worktree.test.js +83 -0
- package/dist/core/runtime/broker/extension-abort.d.ts +9 -0
- package/dist/core/runtime/broker/extension-abort.js +23 -0
- package/dist/core/runtime/broker/rebind.js +7 -1
- package/dist/core/worktree.d.ts +4 -0
- package/dist/core/worktree.js +137 -9
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +57 -0
- package/dist/daemon/api/handlers/nodes.js +17 -2
- package/dist/daemon/api/handlers/reports.js +8 -3
- package/dist/pi-extensions/canvas-stophook.js +8 -4
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -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({
|
|
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();
|
package/dist/core/worktree.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/worktree.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
635
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
484
|
-
//
|
|
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
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.190",
|
|
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.190",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|