@north-light/crouter 0.3.303 → 0.3.305
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/__tests__/integration/client.test.js +37 -36
- package/dist/api/client.d.ts +32 -55
- package/dist/api/client.js +102 -98
- package/dist/api/dto/messages.d.ts +6 -5
- package/dist/api/errors.d.ts +3 -0
- package/dist/api/errors.js +10 -0
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/builtin-memory/internal/plugins.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +87 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +31 -26
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +33 -26
- package/dist/clients/attach/viewer.js +584 -584
- package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
- package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
- package/dist/commands/api-client.d.ts +3 -3
- package/dist/commands/api-client.js +3 -3
- package/dist/commands/memory/read.js +2 -1
- package/dist/commands/node/bash.js +6 -6
- package/dist/commands/node/create.js +7 -1
- package/dist/commands/node/message.js +3 -3
- package/dist/commands/node/wait.js +1 -1
- package/dist/commands/node-worktree.js +5 -1
- package/dist/commands/sys/daemon.js +11 -13
- package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
- package/dist/core/__tests__/daemon-boot.test.js +1 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +14 -15
- package/dist/core/__tests__/helpers/harness.d.ts +2 -0
- package/dist/core/__tests__/helpers/harness.js +9 -0
- package/dist/core/__tests__/integration/tmux-surface.test.js +53 -0
- package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
- package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
- package/dist/core/__tests__/kickoff.test.js +40 -0
- package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +25 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
- package/dist/core/__tests__/seam/deadline-wake-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/seam/deadline-wake-delivery.test.js +138 -0
- package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.d.ts +1 -0
- package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.js +60 -0
- package/dist/core/bash-jobs.d.ts +20 -8
- package/dist/core/bash-jobs.js +40 -21
- package/dist/core/canvas/types.d.ts +4 -2
- package/dist/core/command.js +1 -1
- package/dist/core/fault-classifier.d.ts +7 -1
- package/dist/core/fault-classifier.js +9 -4
- package/dist/core/provider-failure.d.ts +30 -0
- package/dist/core/provider-failure.js +86 -0
- package/dist/core/runtime/broker/event-projection.d.ts +0 -3
- package/dist/core/runtime/broker/event-projection.js +2 -15
- package/dist/core/runtime/broker/fault-retry.js +16 -0
- package/dist/core/runtime/broker-persona-guidance.js +12 -0
- package/dist/core/runtime/broker.js +0 -5
- package/dist/core/runtime/fault.d.ts +2 -1
- package/dist/core/runtime/fault.js +39 -1
- package/dist/core/runtime/host.js +10 -1
- package/dist/core/runtime/kickoff.d.ts +3 -0
- package/dist/core/runtime/kickoff.js +24 -4
- package/dist/core/runtime/reopen.d.ts +2 -2
- package/dist/core/runtime/reopen.js +6 -4
- package/dist/core/runtime/revive.js +3 -3
- package/dist/core/runtime/spawn.js +5 -6
- package/dist/core/runtime/tmux-bindings.js +14 -3
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/worktree-close.d.ts +4 -0
- package/dist/core/worktree-close.js +228 -0
- package/dist/core/worktree-containment.d.ts +14 -0
- package/dist/core/worktree-containment.js +48 -0
- package/dist/core/worktree-mutation-async.d.ts +13 -0
- package/dist/core/worktree-mutation-async.js +281 -0
- package/dist/core/worktree-sweep.js +24 -82
- package/dist/core/worktree.d.ts +1 -0
- package/dist/core/worktree.js +7 -4
- package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
- package/dist/daemon/api/__tests__/reopen-delivery.test.js +31 -0
- package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +36 -4
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +33 -14
- package/dist/daemon/api/handlers/reports.js +3 -2
- package/dist/daemon/api/handlers/worktree.js +7 -6
- package/dist/daemon/fleet.d.ts +1 -1
- package/dist/daemon/fleet.js +30 -12
- package/dist/daemon/manage.d.ts +8 -5
- package/dist/daemon/manage.js +63 -40
- package/dist/daemon/reconcilers/broker-supervision.js +10 -1
- package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
- package/dist/pi-extensions/canvas-bash-valve.js +73 -43
- package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { getNode, updateNode } from './canvas/index.js';
|
|
4
|
+
import { isPidAlive } from './canvas/pid.js';
|
|
5
|
+
import { CONFIG_FILE, CRTR_DIR_NAME, STATE_FILE } from '../types.js';
|
|
6
|
+
import { withExclusiveDirectoryLockAsync } from './exclusive-lock.js';
|
|
7
|
+
import { gitAsync } from './git.js';
|
|
8
|
+
import { WORKTREE_LOCK_WAIT_MS, checkoutPathForBranchInList, isManagedWorktreeBase, managedWorktreePath, persistClosedWorktreeState, worktreeLockPathForCommonDir, WorktreeError } from './worktree.js';
|
|
9
|
+
import { proveBranchContained } from './worktree-containment.js';
|
|
10
|
+
import { refDeleteTransactionStdin } from './worktree-landing.js';
|
|
11
|
+
const LOCK_WAIT_MS = Math.min(WORKTREE_LOCK_WAIT_MS, 25_000);
|
|
12
|
+
function detailOf(result) { return (result.stderr.trim() || result.stdout.trim()).trim(); }
|
|
13
|
+
async function runGit(cwd, args, code, next) {
|
|
14
|
+
const result = await gitAsync(args, cwd);
|
|
15
|
+
if (result.status !== 0)
|
|
16
|
+
throw new WorktreeError(code, `git ${args.join(' ')} failed`, next, detailOf(result));
|
|
17
|
+
return result.stdout.trim();
|
|
18
|
+
}
|
|
19
|
+
async function commonGitDir(cwd) {
|
|
20
|
+
return realpathSync(resolve(cwd, await runGit(cwd, ['rev-parse', '--git-common-dir'], 'git_common_dir_failed', 'Inspect the repository git metadata, then retry.')));
|
|
21
|
+
}
|
|
22
|
+
async function withRepositoryLock(cwd, action) {
|
|
23
|
+
const commonDir = await commonGitDir(cwd);
|
|
24
|
+
return withExclusiveDirectoryLockAsync(worktreeLockPathForCommonDir(commonDir), action, {
|
|
25
|
+
timeoutMs: LOCK_WAIT_MS,
|
|
26
|
+
timeoutError: () => new WorktreeError('worktree_lock_timeout', `timed out waiting to create a managed worktree in ${commonDir}`, 'Wait for the other managed-worktree operation to finish, then retry.'),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function resolveLandableBase(cwd, baseRef) {
|
|
30
|
+
const ref = `refs/heads/${baseRef}`;
|
|
31
|
+
const exists = await gitAsync(['show-ref', '--verify', '--quiet', ref], cwd);
|
|
32
|
+
if (exists.status === 1 && exists.executionFailed !== true)
|
|
33
|
+
throw new WorktreeError('missing_base_branch', `managed worktree base '${baseRef}' does not name an existing local branch`, 'The base must be a local branch that landing can fast-forward — freshen your local branch from origin first (rebase-not-merge), then base on it.');
|
|
34
|
+
if (exists.status !== 0)
|
|
35
|
+
throw new WorktreeError('base_branch_inspection_failed', `could not inspect local base branch '${baseRef}' (git show-ref exited with status ${exists.status})`, 'Inspect the repository and its Git metadata, then retry.', `status: ${exists.status}\nstderr: ${exists.stderr.trim() || '<empty>'}`);
|
|
36
|
+
return runGit(cwd, ['rev-parse', '--verify', ref], 'base_branch_resolve_failed', 'Inspect the local base branch, then retry.');
|
|
37
|
+
}
|
|
38
|
+
function seedExclude(repoRoot, commonDir) {
|
|
39
|
+
try {
|
|
40
|
+
const path = join(commonDir, 'info', 'exclude');
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
42
|
+
let current = '';
|
|
43
|
+
try {
|
|
44
|
+
current = readFileSync(path, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
catch { /* first write */ }
|
|
47
|
+
const patterns = [`/${CRTR_DIR_NAME}/${CONFIG_FILE}`, `/${CRTR_DIR_NAME}/${STATE_FILE}`];
|
|
48
|
+
if (patterns.every((pattern) => current.split('\n').map((line) => line.trim()).includes(pattern)))
|
|
49
|
+
return;
|
|
50
|
+
writeFileSync(path, `${current}${current !== '' && !current.endsWith('\n') ? '\n' : ''}# crtr: exclude crtr's own untracked project-scope bookkeeping (auto-added at managed-worktree creation)\n${patterns.filter((pattern) => !current.split('\n').map((line) => line.trim()).includes(pattern)).join('\n')}\n`);
|
|
51
|
+
}
|
|
52
|
+
catch { /* cosmetic only */ }
|
|
53
|
+
}
|
|
54
|
+
export async function rollbackManagedWorktreeAsync(wt) {
|
|
55
|
+
await withRepositoryLock(wt.repo_root, async () => {
|
|
56
|
+
const branch = await gitAsync(['rev-parse', '--verify', `refs/heads/${wt.branch}`], wt.repo_root);
|
|
57
|
+
if (branch.status !== 0 || branch.stdout.trim() !== wt.base_sha)
|
|
58
|
+
return;
|
|
59
|
+
const list = await gitAsync(['worktree', 'list', '--porcelain'], wt.repo_root);
|
|
60
|
+
if (list.status !== 0)
|
|
61
|
+
return;
|
|
62
|
+
if (list.stdout.split('\n').some((line) => line.trim() === `branch refs/heads/${wt.branch}`)) {
|
|
63
|
+
if ((await gitAsync(['worktree', 'remove', '--force', wt.path], wt.repo_root)).status !== 0)
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
else if (existsSync(wt.path)) {
|
|
67
|
+
try {
|
|
68
|
+
rmSync(wt.path, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
await gitAsync(['update-ref', '-d', `refs/heads/${wt.branch}`, wt.base_sha], wt.repo_root);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
export async function createManagedWorktreeAsync(cwd, nodeId, baseRef = 'main') {
|
|
78
|
+
const repoRoot = await runGit(cwd, ['rev-parse', '--show-toplevel'], 'not_git_repo', 'Pass --cwd <real repo> pointing inside a git repository.');
|
|
79
|
+
if (isManagedWorktreeBase(repoRoot)) {
|
|
80
|
+
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.');
|
|
81
|
+
}
|
|
82
|
+
const commonDir = await commonGitDir(repoRoot);
|
|
83
|
+
return withExclusiveDirectoryLockAsync(worktreeLockPathForCommonDir(commonDir), async () => {
|
|
84
|
+
const baseSha = await resolveLandableBase(repoRoot, baseRef);
|
|
85
|
+
const path = managedWorktreePath(nodeId);
|
|
86
|
+
const branch = `crtr/${nodeId}`;
|
|
87
|
+
if (existsSync(path))
|
|
88
|
+
throw new WorktreeError('worktree_path_exists', `managed worktree path already exists: ${path}`, 'Remove the stale path or choose a fresh node id.');
|
|
89
|
+
if ((await gitAsync(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], repoRoot)).status === 0)
|
|
90
|
+
throw new WorktreeError('worktree_branch_exists', `managed worktree branch already exists: ${branch}`, 'Remove the stale branch or choose a fresh node id.');
|
|
91
|
+
const wt = { state: 'open', path, branch, repo_root: repoRoot, base_ref: baseRef, base_sha: baseSha, created: new Date().toISOString() };
|
|
92
|
+
await runGit(repoRoot, ['branch', branch, baseSha], 'worktree_branch_create_failed', 'Inspect the git error, fix the repository/worktree state, then retry spawning with --worktree.');
|
|
93
|
+
const added = await gitAsync(['worktree', 'add', path, branch], repoRoot);
|
|
94
|
+
if (added.status !== 0) {
|
|
95
|
+
await gitAsync(['update-ref', '-d', `refs/heads/${branch}`, baseSha], repoRoot);
|
|
96
|
+
throw new WorktreeError('worktree_add_failed', 'git worktree add failed', 'Inspect the git error, fix the repository/worktree state, then retry spawning with --worktree.', detailOf(added));
|
|
97
|
+
}
|
|
98
|
+
seedExclude(repoRoot, commonDir);
|
|
99
|
+
return wt;
|
|
100
|
+
}, { timeoutMs: LOCK_WAIT_MS, timeoutError: () => new WorktreeError('worktree_lock_timeout', `timed out waiting to create a managed worktree in ${commonDir}`, 'Wait for the other managed-worktree operation to finish, then retry.') });
|
|
101
|
+
}
|
|
102
|
+
async function cleanTip(wt) {
|
|
103
|
+
if (wt.state !== 'open' || !existsSync(wt.path))
|
|
104
|
+
return null;
|
|
105
|
+
const branch = await gitAsync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
|
|
106
|
+
const status = await gitAsync(['status', '--porcelain'], wt.path);
|
|
107
|
+
const head = await gitAsync(['rev-parse', 'HEAD'], wt.path);
|
|
108
|
+
if (branch.status !== 0 || branch.stdout.trim() !== wt.branch || status.status !== 0 || status.stdout.trim() !== '' || head.status !== 0)
|
|
109
|
+
return null;
|
|
110
|
+
const gitPath = await gitAsync(['rev-parse', '--git-path', 'rebase-merge'], wt.path);
|
|
111
|
+
if (gitPath.status !== 0 || existsSync(gitPath.stdout.trim()))
|
|
112
|
+
return null;
|
|
113
|
+
return head.stdout.trim();
|
|
114
|
+
}
|
|
115
|
+
async function containedInLocalBase(wt, head) {
|
|
116
|
+
try {
|
|
117
|
+
const base = await resolveLandableBase(wt.path, wt.base_ref);
|
|
118
|
+
return (await gitAsync(['merge-base', '--is-ancestor', head, base], wt.path)).status === 0;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof WorktreeError && error.code === 'missing_base_branch' && head === wt.base_sha)
|
|
122
|
+
return true;
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function deferred(wt) {
|
|
127
|
+
return { node_id: '', branch: wt.branch, worktree_path: wt.path, worktree_removed: false, worktree_remove_error: `cleanup pending: the caller checkout at ${wt.path} remains intact.`, branch_deleted: false, branch_delete_error: `cleanup pending: local branch ${wt.branch} remains checked out at ${wt.path}.` };
|
|
128
|
+
}
|
|
129
|
+
/** A missing checkout can be concluded only when its branch is already gone,
|
|
130
|
+
* or the recorded base or its upstream contains the branch and it has no other
|
|
131
|
+
* checkout. */
|
|
132
|
+
async function proveAbsentPathManagedWorktree(wt) {
|
|
133
|
+
if (wt.state !== 'open' || existsSync(wt.path))
|
|
134
|
+
return null;
|
|
135
|
+
const branchRef = `refs/heads/${wt.branch}`;
|
|
136
|
+
const branchExists = await gitAsync(['show-ref', '--verify', '--quiet', branchRef], wt.repo_root);
|
|
137
|
+
if (branchExists.status === 1 && branchExists.executionFailed !== true)
|
|
138
|
+
return { branchSha: null };
|
|
139
|
+
if (branchExists.status !== 0)
|
|
140
|
+
return null;
|
|
141
|
+
const branch = await gitAsync(['rev-parse', '--verify', branchRef], wt.repo_root);
|
|
142
|
+
if (branch.status !== 0)
|
|
143
|
+
return null;
|
|
144
|
+
const branchSha = branch.stdout.trim();
|
|
145
|
+
const worktreeList = await gitAsync(['worktree', 'list', '--porcelain'], wt.repo_root);
|
|
146
|
+
if (worktreeList.status !== 0 || checkoutPathForBranchInList(worktreeList.stdout, wt.branch) !== null)
|
|
147
|
+
return null;
|
|
148
|
+
const containment = await proveBranchContained(wt.repo_root, wt, branchSha);
|
|
149
|
+
return containment === null ? null : { branchSha, containment };
|
|
150
|
+
}
|
|
151
|
+
async function autoDropAbsentPathManagedWorktreeLocked(nodeId, wt, proof) {
|
|
152
|
+
if (existsSync(wt.path))
|
|
153
|
+
return null;
|
|
154
|
+
if (proof.branchSha === null) {
|
|
155
|
+
persistClosedWorktreeState(nodeId, wt, 'complete');
|
|
156
|
+
return { node_id: nodeId, branch: wt.branch, worktree_path: wt.path, worktree_removed: true, branch_deleted: true };
|
|
157
|
+
}
|
|
158
|
+
if (proof.containment === undefined)
|
|
159
|
+
return null;
|
|
160
|
+
// `update-ref` bypasses Git's checked-out-branch protection. A person can
|
|
161
|
+
// add a worktree after the list above, so the delivered branch stays intact:
|
|
162
|
+
// its containment proof concludes this node without risking that checkout.
|
|
163
|
+
persistClosedWorktreeState(nodeId, wt, 'complete');
|
|
164
|
+
return { node_id: nodeId, branch: wt.branch, worktree_path: wt.path, worktree_removed: true, branch_deleted: false, branch_delete_error: `local branch ${wt.branch} is delivered but retained because its checkout status can change outside crtr's repository lock.` };
|
|
165
|
+
}
|
|
166
|
+
/** Conclude an absent checkout only from the same branch-delivery proof used by
|
|
167
|
+
* push-final and explicit close. */
|
|
168
|
+
export async function concludeAbsentManagedWorktreeAsync(nodeId) {
|
|
169
|
+
const wt = getNode(nodeId)?.managed_worktree;
|
|
170
|
+
if (wt === undefined || wt === null || wt.state !== 'open' || existsSync(wt.path))
|
|
171
|
+
return null;
|
|
172
|
+
if (await proveAbsentPathManagedWorktree(wt) === null)
|
|
173
|
+
return null;
|
|
174
|
+
return withRepositoryLock(wt.repo_root, async () => {
|
|
175
|
+
const locked = getNode(nodeId)?.managed_worktree;
|
|
176
|
+
if (locked === undefined || locked === null || locked.state !== 'open')
|
|
177
|
+
return null;
|
|
178
|
+
const proof = await proveAbsentPathManagedWorktree(locked);
|
|
179
|
+
if (proof === null)
|
|
180
|
+
return null;
|
|
181
|
+
const dropped = await autoDropAbsentPathManagedWorktreeLocked(nodeId, locked, proof);
|
|
182
|
+
return dropped === null ? null : { dropped, landedSha: proof.branchSha ?? locked.base_sha };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
export async function autoDropCleanManagedWorktreeAsync(nodeId) {
|
|
186
|
+
const wt = getNode(nodeId)?.managed_worktree;
|
|
187
|
+
if (wt === undefined || wt === null || wt.state !== 'open')
|
|
188
|
+
return null;
|
|
189
|
+
if (!existsSync(wt.path))
|
|
190
|
+
return (await concludeAbsentManagedWorktreeAsync(nodeId))?.dropped ?? null;
|
|
191
|
+
const initial = await cleanTip(wt);
|
|
192
|
+
if (initial === null || !(await containedInLocalBase(wt, initial)))
|
|
193
|
+
return null;
|
|
194
|
+
return withRepositoryLock(wt.path, async () => {
|
|
195
|
+
const locked = getNode(nodeId)?.managed_worktree;
|
|
196
|
+
if (locked === undefined || locked === null || locked.state !== 'open')
|
|
197
|
+
return null;
|
|
198
|
+
const head = await cleanTip(locked);
|
|
199
|
+
if (head === null || head !== initial || !(await containedInLocalBase(locked, head)))
|
|
200
|
+
return null;
|
|
201
|
+
persistClosedWorktreeState(nodeId, locked, 'pending');
|
|
202
|
+
return { ...deferred(locked), node_id: nodeId };
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
async function rebaseInProgress(cwd) {
|
|
206
|
+
for (const path of ['rebase-merge', 'rebase-apply']) {
|
|
207
|
+
const result = await gitAsync(['rev-parse', '--git-path', path], cwd);
|
|
208
|
+
if (result.status !== 0 || existsSync(result.stdout.trim()))
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
function persistAbandoned(nodeId, wt, tip, at, by, branch = wt.branch) {
|
|
214
|
+
updateNode(nodeId, { cwd: wt.repo_root, managed_worktree: { ...wt, state: 'abandoned', abandoned: { branch, tip_sha: tip, at, by } } });
|
|
215
|
+
}
|
|
216
|
+
export async function abandonManagedWorktreeAsync(nodeId, by) {
|
|
217
|
+
const wt = getNode(nodeId)?.managed_worktree;
|
|
218
|
+
if (wt === undefined || wt === null || wt.state !== 'open')
|
|
219
|
+
throw new WorktreeError('no_managed_worktree', `node ${nodeId} has no open managed worktree`, 'Nothing to abandon for this node.');
|
|
220
|
+
return withRepositoryLock(wt.path, async () => {
|
|
221
|
+
const node = getNode(nodeId);
|
|
222
|
+
const current = node?.managed_worktree;
|
|
223
|
+
if (node === null || current === undefined || current === null || current.state !== 'open')
|
|
224
|
+
throw new WorktreeError('no_managed_worktree', `node ${nodeId} has no open managed worktree`, 'Nothing to abandon for this node.');
|
|
225
|
+
if (node.status === 'active' || node.status === 'idle' || isPidAlive(node.pi_pid))
|
|
226
|
+
throw new WorktreeError('node_running', `node ${nodeId} is still running`, 'Stop the node before abandoning its managed worktree.');
|
|
227
|
+
if (!existsSync(current.path))
|
|
228
|
+
throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${current.path}`, 'Inspect the branch and worktree registration, then retry.');
|
|
229
|
+
const branch = await runGit(current.path, ['rev-parse', '--abbrev-ref', 'HEAD'], 'branch_check_failed', 'Inspect the worktree manually, then retry.');
|
|
230
|
+
const recorded = await gitAsync(['show-ref', '--verify', '--quiet', `refs/heads/${current.branch}`], current.path);
|
|
231
|
+
if (branch !== current.branch) {
|
|
232
|
+
if (recorded.status === 0 || branch === 'HEAD')
|
|
233
|
+
throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${current.branch}, found ${branch === 'HEAD' ? 'detached HEAD' : branch})`, `Inspect ${current.path}, then rerun \`crtr node worktree abandon\`.`);
|
|
234
|
+
if (await rebaseInProgress(current.path) || (await runGit(current.path, ['status', '--porcelain'], 'status_failed', 'Inspect the worktree manually, then retry.')) !== '')
|
|
235
|
+
throw new WorktreeError('dirty_worktree', 'managed worktree has uncommitted changes', `Commit or stash them in ${current.path}, then rerun \`crtr node worktree abandon\`.`);
|
|
236
|
+
const tip = await runGit(current.path, ['rev-parse', 'HEAD'], 'abandon_tip_failed', 'Inspect the worktree manually, then retry.');
|
|
237
|
+
await runGit(current.repo_root, ['worktree', 'remove', current.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
238
|
+
const at = new Date().toISOString();
|
|
239
|
+
persistAbandoned(nodeId, current, tip, at, by, branch);
|
|
240
|
+
return { node_id: nodeId, branch, tip_sha: tip, worktree_path: current.path, branch_deleted: false };
|
|
241
|
+
}
|
|
242
|
+
const head = await runGit(current.path, ['rev-parse', 'HEAD'], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
|
|
243
|
+
let cleanBase = null;
|
|
244
|
+
try {
|
|
245
|
+
const base = await resolveLandableBase(current.path, current.base_ref);
|
|
246
|
+
if ((await gitAsync(['merge-base', '--is-ancestor', head, base], current.path)).status === 0)
|
|
247
|
+
cleanBase = base;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch' || head !== current.base_sha)
|
|
251
|
+
throw error;
|
|
252
|
+
cleanBase = current.base_sha;
|
|
253
|
+
}
|
|
254
|
+
const at = new Date().toISOString();
|
|
255
|
+
const status = await runGit(current.path, ['status', '--porcelain'], 'status_failed', 'Inspect the worktree manually, then retry.');
|
|
256
|
+
if (cleanBase !== null && head === current.base_sha) {
|
|
257
|
+
const tree = await runGit(current.repo_root, ['rev-parse', `${head}^{tree}`], 'abandon_tree_failed', 'Inspect the branch manually, then retry.');
|
|
258
|
+
const marker = await runGit(current.repo_root, ['-c', 'user.name=crtr-abandon', '-c', 'user.email=crtr-abandon@localhost', 'commit-tree', tree, '-p', head, '-m', 'crtr-abandon', '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the branch manually, then retry.');
|
|
259
|
+
await runGit(current.repo_root, ['update-ref', `refs/heads/${current.branch}`, marker, head], 'abandon_marker_failed', 'The branch changed while abandon was starting; inspect the worktree, then retry.');
|
|
260
|
+
await runGit(current.repo_root, ['worktree', 'remove', current.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
261
|
+
const deleted = await gitAsync(['update-ref', '--stdin'], current.repo_root, refDeleteTransactionStdin(current.branch, marker, `refs/heads/${current.base_ref}`, cleanBase));
|
|
262
|
+
if (deleted.status === 0) {
|
|
263
|
+
persistClosedWorktreeState(nodeId, current, 'complete');
|
|
264
|
+
return { node_id: nodeId, branch: current.branch, tip_sha: marker, worktree_path: current.path, branch_deleted: true };
|
|
265
|
+
}
|
|
266
|
+
persistAbandoned(nodeId, current, marker, at, by);
|
|
267
|
+
return { node_id: nodeId, branch: current.branch, tip_sha: marker, worktree_path: current.path, branch_deleted: false };
|
|
268
|
+
}
|
|
269
|
+
if (!await rebaseInProgress(current.path) && status === '') {
|
|
270
|
+
await runGit(current.repo_root, ['worktree', 'remove', current.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
271
|
+
persistAbandoned(nodeId, current, head, at, by);
|
|
272
|
+
return { node_id: nodeId, branch: current.branch, tip_sha: head, worktree_path: current.path, branch_deleted: false };
|
|
273
|
+
}
|
|
274
|
+
await runGit(current.path, ['add', '--all'], 'abandon_stage_failed', 'Inspect the worktree manually, then retry.');
|
|
275
|
+
await runGit(current.path, ['-c', 'user.name=crtr-abandon', '-c', 'user.email=crtr-abandon@localhost', 'commit', '--allow-empty', '-m', 'crtr-abandon', '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the worktree manually, then retry.');
|
|
276
|
+
const tip = await runGit(current.path, ['rev-parse', 'HEAD'], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
|
|
277
|
+
await runGit(current.repo_root, ['worktree', 'remove', current.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
278
|
+
persistAbandoned(nodeId, current, tip, at, by);
|
|
279
|
+
return { node_id: nodeId, branch: current.branch, tip_sha: tip, worktree_path: current.path, branch_deleted: false };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
@@ -21,7 +21,9 @@ import { resolve } from 'node:path';
|
|
|
21
21
|
import { getNode, updateNode } from './canvas/index.js';
|
|
22
22
|
import { withExclusiveDirectoryLockAsync } from './exclusive-lock.js';
|
|
23
23
|
import { gitAsync } from './git.js';
|
|
24
|
+
import { proveBranchContained, upstreamTip } from './worktree-containment.js';
|
|
24
25
|
import { parseStatusPorcelain, planLandIntoCheckout, refDeleteTransactionStdin } from './worktree-landing.js';
|
|
26
|
+
import { shellQuote } from '../shared/shell-quote.js';
|
|
25
27
|
import { WORKTREE_LOCK_WAIT_MS, checkoutPathForBranchInList, persistClosedWorktreeState, samePath, worktreeLockPathForCommonDir } from './worktree.js';
|
|
26
28
|
/** Escalating recheck interval, in minutes, indexed by consecutive refusal. The
|
|
27
29
|
* last tier is the cap AND the quarantine threshold: at that point the record
|
|
@@ -101,95 +103,32 @@ function registeredCheckoutPath(porcelain, path, branch) {
|
|
|
101
103
|
}
|
|
102
104
|
return null;
|
|
103
105
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
async function containmentCandidates(cwd, wt) {
|
|
107
|
-
const candidates = [];
|
|
108
|
-
const localBaseRef = `refs/heads/${wt.base_ref}`;
|
|
109
|
-
const localBase = await revParseRef(cwd, localBaseRef);
|
|
110
|
-
if (localBase !== null)
|
|
111
|
-
candidates.push({ ref: localBaseRef, sha: localBase });
|
|
112
|
-
const upstream = await gitAsync(['rev-parse', '--symbolic-full-name', '--verify', `${wt.base_ref}@{upstream}`], cwd);
|
|
113
|
-
const upstreamRef = upstream.status === 0 && upstream.stdout.trim().startsWith('refs/')
|
|
114
|
-
? upstream.stdout.trim()
|
|
115
|
-
: `refs/remotes/origin/${wt.base_ref}`;
|
|
116
|
-
const upstreamSha = await revParseRef(cwd, upstreamRef);
|
|
117
|
-
if (upstreamSha !== null)
|
|
118
|
-
candidates.push({ ref: upstreamRef, sha: upstreamSha });
|
|
119
|
-
return candidates;
|
|
120
|
-
}
|
|
121
|
-
/** Does removing this branch lose content the candidate does not already have?
|
|
122
|
-
*
|
|
123
|
-
* The invariant is about CONTENT, not history. A rebase rewrites every SHA, so
|
|
124
|
-
* a fully-landed branch can look diverged; a squash-merge leaves no matching
|
|
125
|
-
* commit at all. An ancestry-only proof would refuse exactly the cases this
|
|
126
|
-
* sweep exists to clean up, so three layers run in increasing cost, and any one
|
|
127
|
-
* of them is sufficient:
|
|
128
|
-
*
|
|
129
|
-
* 1. ancestry — the tip is already reachable from the candidate.
|
|
130
|
-
* 2. patch identity — every commit not in the candidate by SHA has a
|
|
131
|
-
* patch-equivalent commit there (`git cherry`). The rebase case.
|
|
132
|
-
* 3. content no-op — merging the branch in would change nothing. The
|
|
133
|
-
* squash-merge case, and most remaining "looks bad but isn't" states.
|
|
134
|
-
*
|
|
135
|
-
* Only when all three fail does the branch genuinely carry unlanded content.
|
|
136
|
-
* Every layer fails CLOSED: a Git version without `merge-tree --write-tree`, an
|
|
137
|
-
* unreadable object, any non-zero exit — none of them prove anything, so the
|
|
138
|
-
* branch survives. */
|
|
139
|
-
async function proveContained(cwd, wt, branchSha) {
|
|
140
|
-
const candidates = await containmentCandidates(cwd, wt);
|
|
141
|
-
if (candidates.length === 0)
|
|
142
|
-
return null;
|
|
143
|
-
for (const candidate of candidates) {
|
|
144
|
-
if ((await gitAsync(['merge-base', '--is-ancestor', branchSha, candidate.sha], cwd)).status === 0) {
|
|
145
|
-
return { containingRef: candidate.ref, containingSha: candidate.sha, layer: 'ancestry' };
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
for (const candidate of candidates) {
|
|
149
|
-
const cherry = await gitAsync(['cherry', candidate.sha, branchSha], cwd);
|
|
150
|
-
if (cherry.status !== 0)
|
|
151
|
-
continue;
|
|
152
|
-
const lines = cherry.stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
153
|
-
if (lines.every((line) => line.startsWith('-'))) {
|
|
154
|
-
return { containingRef: candidate.ref, containingSha: candidate.sha, layer: 'patch_identity' };
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
for (const candidate of candidates) {
|
|
158
|
-
const candidateTree = await revParseRef(cwd, `${candidate.sha}^{tree}`);
|
|
159
|
-
if (candidateTree === null)
|
|
160
|
-
continue;
|
|
161
|
-
const merged = await gitAsync(['merge-tree', '--write-tree', candidate.sha, branchSha], cwd);
|
|
162
|
-
if (merged.status !== 0)
|
|
163
|
-
continue; // conflict, or a Git too old for this flag
|
|
164
|
-
if (merged.stdout.split('\n')[0]?.trim() === candidateTree) {
|
|
165
|
-
return { containingRef: candidate.ref, containingSha: candidate.sha, layer: 'content_noop' };
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
106
|
+
// Content containment is shared with explicit close. A delivered branch is
|
|
107
|
+
// never rebased onto a local base merely because that base later diverged.
|
|
170
108
|
// Change detection
|
|
171
|
-
/** Base
|
|
172
|
-
* reads the same
|
|
173
|
-
* nothing that decided the refusal has moved. An unreadable checkout
|
|
174
|
-
* `null`, which never compares equal, so uncertainty always re-proves. */
|
|
109
|
+
/** Base, upstream, and branch tips plus checkout state as they are right now.
|
|
110
|
+
* A pass that reads the same values as the refusal that preceded it can stop
|
|
111
|
+
* there: nothing that decided the refusal has moved. An unreadable checkout
|
|
112
|
+
* records `null`, which never compares equal, so uncertainty always re-proves. */
|
|
175
113
|
async function observe(wt) {
|
|
176
114
|
const present = existsSync(wt.path);
|
|
177
115
|
const cwd = present ? wt.path : wt.repo_root;
|
|
178
116
|
const base = await revParseRef(cwd, `refs/heads/${wt.base_ref}`);
|
|
117
|
+
const upstream = await upstreamTip(cwd, wt.base_ref);
|
|
179
118
|
const branch = await revParseRef(cwd, `refs/heads/${wt.branch}`);
|
|
180
119
|
if (!present)
|
|
181
|
-
return { base, branch, checkout: 'absent' };
|
|
120
|
+
return { base, upstream: upstream?.sha ?? null, branch, checkout: 'absent' };
|
|
182
121
|
const head = await gitAsync(['rev-parse', 'HEAD'], wt.path);
|
|
183
122
|
const status = await gitAsync(['status', '--porcelain'], wt.path);
|
|
184
123
|
const checkout = head.status !== 0 || status.status !== 0
|
|
185
124
|
? null
|
|
186
125
|
: createHash('sha256').update(`${head.stdout.trim()}\n${status.stdout}`).digest('hex');
|
|
187
|
-
return { base, branch, checkout };
|
|
126
|
+
return { base, upstream: upstream?.sha ?? null, branch, checkout };
|
|
188
127
|
}
|
|
189
128
|
function sameObservation(a, b) {
|
|
190
129
|
if (a.checkout === null || b.checkout === null)
|
|
191
130
|
return false;
|
|
192
|
-
return a.base === b.base && a.branch === b.branch && a.checkout === b.checkout;
|
|
131
|
+
return a.base === b.base && a.upstream === b.upstream && a.branch === b.branch && a.checkout === b.checkout;
|
|
193
132
|
}
|
|
194
133
|
// Recording an unfinished pass
|
|
195
134
|
/** Persist why this pass did not finish, with the fingerprint it decided
|
|
@@ -258,18 +197,21 @@ async function landIntoBaseCheckoutAsync(baseCheckout, branch, baseSha, landedSh
|
|
|
258
197
|
const stashSha = await revParseRef(baseCheckout, 'refs/stash');
|
|
259
198
|
if (stashSha === null || stashSha === beforeSha)
|
|
260
199
|
return merge(); // nothing was saved
|
|
200
|
+
const recovery = `local changes from ${baseCheckout} are retained in stash ${stashSha}; recover them with \`git -C ${shellQuote(baseCheckout)} stash apply ${stashSha}\` and resolve any conflict.`;
|
|
261
201
|
const landed = await merge();
|
|
262
202
|
if (!landed.ok) {
|
|
263
|
-
if ((await gitAsync(['stash', 'apply', stashSha], baseCheckout)).status === 0)
|
|
203
|
+
if ((await gitAsync(['stash', 'apply', stashSha], baseCheckout)).status === 0) {
|
|
264
204
|
await dropStashIfStillTip(baseCheckout, stashSha);
|
|
265
|
-
|
|
205
|
+
return landed;
|
|
206
|
+
}
|
|
207
|
+
return { ok: false, detail: `${landed.detail}\n${recovery}` };
|
|
266
208
|
}
|
|
267
209
|
if ((await gitAsync(['stash', 'apply', stashSha], baseCheckout)).status === 0) {
|
|
268
210
|
await dropStashIfStillTip(baseCheckout, stashSha);
|
|
269
211
|
return { ok: true };
|
|
270
212
|
}
|
|
271
213
|
await gitAsync(['reset', '--hard', landedSha], baseCheckout);
|
|
272
|
-
return { ok: true, stash: `
|
|
214
|
+
return { ok: true, stash: `the shared base checkout was reset to landed commit ${landedSha} because local changes conflicted with it. ${recovery}` };
|
|
273
215
|
}
|
|
274
216
|
async function dropStashIfStillTip(cwd, stashSha) {
|
|
275
217
|
if ((await revParseRef(cwd, 'refs/stash')) === stashSha)
|
|
@@ -336,13 +278,13 @@ export async function reconcileManagedWorktree(nodeId, guard, now = Date.now())
|
|
|
336
278
|
return { status: 'skipped', reason: 'already_reconciled' };
|
|
337
279
|
const cwd = existsSync(wt.path) ? wt.path : wt.repo_root;
|
|
338
280
|
if (!existsSync(cwd)) {
|
|
339
|
-
return
|
|
281
|
+
return finish(nodeId, wt, { status: 'refused', reason: 'repo_unavailable', detail: `neither the recorded checkout nor repository root exists, so ${wt.branch} cannot be proved absent` }, { base: null, upstream: null, branch: null, checkout: null }, now, guard);
|
|
340
282
|
}
|
|
341
283
|
const commonDir = await commonGitDirAsync(cwd);
|
|
342
284
|
if (commonDir === null) {
|
|
343
|
-
return finish(nodeId, wt, { status: 'failed', reason: 'git_dir_unreadable', detail: `could not resolve the git common dir from ${cwd}` }, { base: null, branch: null, checkout: null }, now, guard);
|
|
285
|
+
return finish(nodeId, wt, { status: 'failed', reason: 'git_dir_unreadable', detail: `could not resolve the git common dir from ${cwd}` }, { base: null, upstream: null, branch: null, checkout: null }, now, guard);
|
|
344
286
|
}
|
|
345
|
-
let observed = { base: null, branch: null, checkout: null };
|
|
287
|
+
let observed = { base: null, upstream: null, branch: null, checkout: null };
|
|
346
288
|
let outcome;
|
|
347
289
|
try {
|
|
348
290
|
outcome = await withExclusiveDirectoryLockAsync(worktreeLockPathForCommonDir(commonDir), async () => {
|
|
@@ -412,7 +354,7 @@ async function concludeAbsentCheckout(nodeId, wt, observed, guard) {
|
|
|
412
354
|
if (elsewhere !== null) {
|
|
413
355
|
return { status: 'refused', reason: 'branch_checked_out_elsewhere', detail: `${wt.branch} is checked out at ${elsewhere}` };
|
|
414
356
|
}
|
|
415
|
-
const proof = await
|
|
357
|
+
const proof = await proveBranchContained(admin, wt, observed.branch);
|
|
416
358
|
if (proof === null) {
|
|
417
359
|
return { status: 'refused', reason: 'unlanded_work', detail: `${wt.branch} carries content no base or upstream ref contains; its checkout is already gone, so the branch is the only copy` };
|
|
418
360
|
}
|
|
@@ -477,7 +419,7 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
|
|
|
477
419
|
// is preserved, never deleted, unlike the ordinary path below.
|
|
478
420
|
return persistComplete(nodeId, wt, guard);
|
|
479
421
|
}
|
|
480
|
-
let proof = await
|
|
422
|
+
let proof = await proveBranchContained(wt.path, wt, branchSha);
|
|
481
423
|
let stash;
|
|
482
424
|
if (proof === null && wt.land_intent !== undefined) {
|
|
483
425
|
const landed = await finishBlockedLand(wt, list.stdout, guard);
|
|
@@ -485,7 +427,7 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
|
|
|
485
427
|
return landed.ineligible === true ? NODE_REVIVED : { status: 'refused', reason: 'land_blocked', detail: landed.detail };
|
|
486
428
|
branchSha = landed.landedSha;
|
|
487
429
|
stash = landed.stash;
|
|
488
|
-
proof = await
|
|
430
|
+
proof = await proveBranchContained(wt.path, wt, branchSha);
|
|
489
431
|
}
|
|
490
432
|
if (proof === null) {
|
|
491
433
|
return { status: 'refused', reason: 'unlanded_work', detail: `${wt.branch} carries commits its base does not contain, and this node never asked to land them` };
|
package/dist/core/worktree.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare function managedWorktreesRoot(): string;
|
|
|
14
14
|
export declare function managedWorktreePath(nodeId: string): string;
|
|
15
15
|
export declare function hasOpenManagedWorktree(node: NodeMeta | null | undefined): boolean;
|
|
16
16
|
export declare function openManagedWorktreeForNode(nodeId: string): ManagedWorktree | null;
|
|
17
|
+
export declare function isManagedWorktreeBase(repoRoot: string): boolean;
|
|
17
18
|
/** Shared parse for a `git worktree list --porcelain` blob already in hand —
|
|
18
19
|
* callers that must not throw on a read failure (an absent-path proof that
|
|
19
20
|
* needs to fail closed rather than error) call `gitSync` themselves and pass
|
package/dist/core/worktree.js
CHANGED
|
@@ -51,7 +51,7 @@ function repoRootFor(cwd) {
|
|
|
51
51
|
}
|
|
52
52
|
return result.stdout.trim();
|
|
53
53
|
}
|
|
54
|
-
function isManagedWorktreeBase(repoRoot) {
|
|
54
|
+
export function isManagedWorktreeBase(repoRoot) {
|
|
55
55
|
const path = relative(canonicalPath(managedWorktreesRoot()), canonicalPath(repoRoot));
|
|
56
56
|
return path !== '' && path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path);
|
|
57
57
|
}
|
|
@@ -412,13 +412,16 @@ function landIntoBaseCheckout(baseCheckout, branch, baseSha, landedSha) {
|
|
|
412
412
|
const stashSha = after.status === 0 ? after.stdout.trim() : null;
|
|
413
413
|
if (stashSha === null || stashSha === beforeSha)
|
|
414
414
|
return merge(); // nothing was saved
|
|
415
|
+
const recovery = `local changes from ${baseCheckout} are retained in stash ${stashSha}; recover them with \`git -C ${shellQuote(baseCheckout)} stash apply ${stashSha}\` and resolve any conflict.`;
|
|
415
416
|
const landed = merge();
|
|
416
417
|
if (!landed.ok) {
|
|
417
418
|
// The failed merge left the tree exactly as the stash found it, so this
|
|
418
419
|
// re-apply is clean; restore before surfacing the refusal.
|
|
419
|
-
if (gitSync(['stash', 'apply', stashSha], baseCheckout).status === 0)
|
|
420
|
+
if (gitSync(['stash', 'apply', stashSha], baseCheckout).status === 0) {
|
|
420
421
|
dropStashIfStillTip(baseCheckout, stashSha);
|
|
421
|
-
|
|
422
|
+
return landed;
|
|
423
|
+
}
|
|
424
|
+
return { ok: false, detail: `${landed.detail}\n${recovery}` };
|
|
422
425
|
}
|
|
423
426
|
if (gitSync(['stash', 'apply', stashSha], baseCheckout).status === 0) {
|
|
424
427
|
dropStashIfStillTip(baseCheckout, stashSha);
|
|
@@ -427,7 +430,7 @@ function landIntoBaseCheckout(baseCheckout, branch, baseSha, landedSha) {
|
|
|
427
430
|
gitSync(['reset', '--hard', landedSha], baseCheckout);
|
|
428
431
|
return {
|
|
429
432
|
ok: true,
|
|
430
|
-
stash: `
|
|
433
|
+
stash: `the shared base checkout was reset to landed commit ${landedSha} because local changes conflicted with it. ${recovery}`,
|
|
431
434
|
};
|
|
432
435
|
}
|
|
433
436
|
/** Drop a stash entry only while it is still the tip `stash@{0}` names, so a
|
|
@@ -49,15 +49,17 @@ test('a Unix socket bind failure rejects readiness and releases daemon ownership
|
|
|
49
49
|
assert.equal(existsSync(pidfile), false, 'failed readiness removes the pidfile mirror');
|
|
50
50
|
assert.equal(existsSync(socketPath), true, 'the pre-existing directory was never mistaken for a serving socket');
|
|
51
51
|
assert.match(readFileSync(join(canvasHome, 'crtrd.log'), 'utf8'), /api\.server\.failed/, 'the bind failure is recorded for cold-start diagnostics');
|
|
52
|
+
rmSync(socketPath, { recursive: true, force: true });
|
|
52
53
|
writeFileSync(pidfile, String(process.pid));
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
existing_pid: process.pid,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
try {
|
|
55
|
+
const result = await spawnDaemon();
|
|
56
|
+
assert.deepEqual({ started: result.started, existing_pid: result.existing_pid, running: result.running, serving: result.serving }, { started: false, existing_pid: process.pid, running: true, serving: false }, 'a live owner with an unavailable socket remains in place');
|
|
57
|
+
assert.match(result.probe_error ?? '', /ENOENT|ECONNREFUSED/, 'the result retains the actual failed probe');
|
|
58
|
+
process.kill(process.pid, 0);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
rmSync(pidfile, { force: true });
|
|
62
|
+
}
|
|
61
63
|
const retryClaim = claimDaemonOwnership('post-api-bind-failure');
|
|
62
64
|
assert.equal(retryClaim.claimed, true, 'failed readiness releases the authoritative ownership claim');
|
|
63
65
|
if (retryClaim.claimed) {
|
|
@@ -8,7 +8,9 @@ import { closeDb } from '../../../core/canvas/db.js';
|
|
|
8
8
|
import { viewSocketPath } from '../../../core/canvas/paths.js';
|
|
9
9
|
import { readInboxSince } from '../../../core/feed/inbox.js';
|
|
10
10
|
import { headlessBrokerHost } from '../../../core/runtime/host.js';
|
|
11
|
+
import { bindTestFleet } from '../../../core/__tests__/helpers/fleet.js';
|
|
11
12
|
import { messageRoutes } from '../handlers/messages.js';
|
|
13
|
+
import { nodeRoutes } from '../handlers/nodes.js';
|
|
12
14
|
let home;
|
|
13
15
|
const priorHome = process.env['CRTR_HOME'];
|
|
14
16
|
before(() => {
|
|
@@ -20,6 +22,7 @@ before(() => {
|
|
|
20
22
|
beforeEach(() => {
|
|
21
23
|
closeDb();
|
|
22
24
|
rmSync(home, { recursive: true, force: true });
|
|
25
|
+
bindTestFleet();
|
|
23
26
|
});
|
|
24
27
|
after(() => {
|
|
25
28
|
closeDb();
|
|
@@ -57,6 +60,19 @@ async function sendMessage(id, body) {
|
|
|
57
60
|
};
|
|
58
61
|
return messageRoutes[0].handler(ctx);
|
|
59
62
|
}
|
|
63
|
+
async function revive(id, body) {
|
|
64
|
+
const ctx = {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
path: `/v1/nodes/${id}/revive`,
|
|
67
|
+
params: { id },
|
|
68
|
+
query: new URLSearchParams(),
|
|
69
|
+
body,
|
|
70
|
+
};
|
|
71
|
+
const route = nodeRoutes.find((candidate) => candidate.pattern === '/v1/nodes/:id/revive');
|
|
72
|
+
if (route === undefined)
|
|
73
|
+
throw new Error('missing node revive route');
|
|
74
|
+
return route.handler(ctx);
|
|
75
|
+
}
|
|
60
76
|
async function startLiveBroker(id, failDelivery = false) {
|
|
61
77
|
let observed = null;
|
|
62
78
|
const server = createServer((socket) => {
|
|
@@ -170,6 +186,21 @@ test('reopen makes live terminal, finalized terminal, and parked targets residen
|
|
|
170
186
|
headlessBrokerHost.launch = originalLaunch;
|
|
171
187
|
}
|
|
172
188
|
});
|
|
189
|
+
test('standalone revive reopen restores the same resident lifecycle before booting', async () => {
|
|
190
|
+
const id = 'standalone-reopen';
|
|
191
|
+
createNode(node(id, 'terminal', 'done'));
|
|
192
|
+
finalize(id);
|
|
193
|
+
const originalLaunch = headlessBrokerHost.launch;
|
|
194
|
+
headlessBrokerHost.launch = (() => ({ pid: 0x7ffffffe, exited: Promise.resolve({ code: 0, signal: null }) }));
|
|
195
|
+
try {
|
|
196
|
+
const result = await revive(id, { reopen: true });
|
|
197
|
+
assert.equal(result.status, 200, 'standalone revive accepts an explicit reopen');
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
201
|
+
}
|
|
202
|
+
assertReopened(id);
|
|
203
|
+
});
|
|
173
204
|
test('a reopen commit failure delivers nothing', async () => {
|
|
174
205
|
const id = 'commit-failure';
|
|
175
206
|
createNode(node(id, 'terminal', 'done'));
|