praxis-agent 0.60.0 → 0.61.0
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/README.md +6 -1
- package/dist/application/managed-worktree.d.ts +28 -0
- package/dist/application/managed-worktree.js +606 -6
- package/dist/application/subagent-service.js +11 -0
- package/dist/application/workflow-worktree.d.ts +9 -0
- package/dist/application/workflow-worktree.js +31 -4
- package/dist/cli/protocol.d.ts +1 -0
- package/dist/cli/protocol.js +37 -7
- package/dist/cli-runtime.d.ts +1 -0
- package/dist/cli-runtime.js +48 -7
- package/dist/hooks/claude-hooks.js +16 -2
- package/dist/persistence/managed-worktree-store.d.ts +41 -0
- package/dist/persistence/managed-worktree-store.js +341 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,6 +102,7 @@ Common non-interactive operations:
|
|
|
102
102
|
|
|
103
103
|
```sh
|
|
104
104
|
praxis -p "Inspect this project"
|
|
105
|
+
printf 'Inspect this project\n' | praxis -p
|
|
105
106
|
praxis -p --output-format json "Summarize the test failures"
|
|
106
107
|
praxis --resume
|
|
107
108
|
praxis sessions --json
|
|
@@ -213,7 +214,11 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
|
|
|
213
214
|
Claude-compatible main-thread agent definitions with native prompt, model,
|
|
214
215
|
tool, memory, first-turn, and resume behavior. Agent execution uses one
|
|
215
216
|
durable lifecycle vocabulary with bounded cancellation and drain,
|
|
216
|
-
continuation, notifications, and single-owner orphan recovery.
|
|
217
|
+
continuation, notifications, and single-owner orphan recovery. Isolated
|
|
218
|
+
Workflow turns use ownership-recorded repo-local temporary worktrees, run
|
|
219
|
+
trust-admitted synchronous lifecycle hooks, safely roll back blocked
|
|
220
|
+
creation, and retain dirty, committed, unsafe, or removal-blocked results
|
|
221
|
+
for inspection. Experimental
|
|
217
222
|
local Teams (`PRAXIS_ENABLE_TEAMS=true`) stay absent from ordinary startup by
|
|
218
223
|
default and add durable task ownership plus one ordered mailbox with stable
|
|
219
224
|
identities, fixed broadcast recipients, durable cursors, bounded retention,
|
|
@@ -6,6 +6,34 @@ export interface ManagedWorktree {
|
|
|
6
6
|
cwd: string;
|
|
7
7
|
cleanup(): Promise<ManagedWorktreeCleanup>;
|
|
8
8
|
}
|
|
9
|
+
export interface ManagedWorktreeHookInput {
|
|
10
|
+
readonly worktreePath: string;
|
|
11
|
+
readonly worktreeKind: 'workflow' | 'agent' | 'team';
|
|
12
|
+
readonly worktreeId: string;
|
|
13
|
+
readonly ownerId: string;
|
|
14
|
+
readonly baseCommit: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ManagedWorktreeRemoveHookInput extends ManagedWorktreeHookInput {
|
|
17
|
+
readonly reason: 'normal' | 'reconcile';
|
|
18
|
+
}
|
|
19
|
+
export interface ManagedWorktreeHookOutcome {
|
|
20
|
+
blockedReason?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ManagedWorktreeHooks {
|
|
23
|
+
afterCreate(input: ManagedWorktreeHookInput): Promise<ManagedWorktreeHookOutcome>;
|
|
24
|
+
beforeRemove(input: ManagedWorktreeRemoveHookInput): Promise<ManagedWorktreeHookOutcome>;
|
|
25
|
+
}
|
|
26
|
+
export interface OwnedManagedWorktreeOptions {
|
|
27
|
+
cwd: string;
|
|
28
|
+
stateRoot: string;
|
|
29
|
+
directoryName: string;
|
|
30
|
+
ownerId: string;
|
|
31
|
+
label: 'Agent' | 'Workflow' | 'Team';
|
|
32
|
+
kind: 'workflow' | 'agent' | 'team';
|
|
33
|
+
policy: 'ephemeral' | 'durable';
|
|
34
|
+
hooks?: ManagedWorktreeHooks;
|
|
35
|
+
}
|
|
36
|
+
export declare function createOwnedManagedWorktree(options: OwnedManagedWorktreeOptions): Promise<ManagedWorktree>;
|
|
9
37
|
export declare function createManagedWorktree(options: {
|
|
10
38
|
cwd: string;
|
|
11
39
|
parentDirectory: string;
|
|
@@ -1,17 +1,617 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, mkdir, open, realpath } from 'node:fs/promises';
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
7
|
import { promisify } from 'node:util';
|
|
8
|
+
import { ManagedWorktreeStore, } from '../persistence/managed-worktree-store.js';
|
|
9
|
+
import { resolveProjectIdentity } from '../platform/project-identity.js';
|
|
10
|
+
import { writeFileAtomically } from '../platform/atomic-write.js';
|
|
11
|
+
import { ExclusiveFileLease, } from '../platform/exclusive-file-lease.js';
|
|
5
12
|
const execFileAsync = promisify(execFile);
|
|
13
|
+
async function gitRaw(cwd, args) {
|
|
14
|
+
return (await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf8' })).stdout;
|
|
15
|
+
}
|
|
6
16
|
async function git(cwd, args) {
|
|
7
|
-
return (await
|
|
17
|
+
return (await gitRaw(cwd, args)).trim();
|
|
18
|
+
}
|
|
19
|
+
async function gitOptional(cwd, args) {
|
|
20
|
+
try {
|
|
21
|
+
return await git(cwd, args);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code === 1)
|
|
25
|
+
return null;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
8
28
|
}
|
|
9
29
|
async function registeredWorktrees(root) {
|
|
10
|
-
const output = await
|
|
30
|
+
const output = await gitRaw(root, ['worktree', 'list', '--porcelain', '-z']);
|
|
11
31
|
return new Set(output
|
|
12
|
-
.split('\
|
|
32
|
+
.split('\0')
|
|
13
33
|
.filter((line) => line.startsWith('worktree '))
|
|
14
|
-
.map((line) => resolve(line.slice('worktree '.length))));
|
|
34
|
+
.map((line) => resolve(root, line.slice('worktree '.length))));
|
|
35
|
+
}
|
|
36
|
+
const COMPONENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/u;
|
|
37
|
+
const MARKER_FIELDS = new Set(['version', 'worktreeId', 'repositoryRoot']);
|
|
38
|
+
const LOCAL_EXCLUDE_PATTERN = '/.praxis/worktrees/';
|
|
39
|
+
function managedWorktreeId(repositoryRoot, kind, ownerId) {
|
|
40
|
+
return createHash('sha256')
|
|
41
|
+
.update(`${repositoryRoot}\0${kind}\0${ownerId}`)
|
|
42
|
+
.digest('hex');
|
|
43
|
+
}
|
|
44
|
+
function validOwnerId(ownerId) {
|
|
45
|
+
return (ownerId.length > 0 &&
|
|
46
|
+
ownerId.length <= 256 &&
|
|
47
|
+
!Array.from(ownerId).some((character) => {
|
|
48
|
+
const code = character.codePointAt(0) ?? 0;
|
|
49
|
+
return code <= 0x1f || code === 0x7f;
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
function worktreeError(options, message) {
|
|
53
|
+
return new Error(`${options.label} worktree ${message}`);
|
|
54
|
+
}
|
|
55
|
+
async function assertRealDirectory(path, description) {
|
|
56
|
+
let current = resolve(path);
|
|
57
|
+
const parts = [];
|
|
58
|
+
while (true) {
|
|
59
|
+
parts.unshift(current);
|
|
60
|
+
const parent = resolve(current, '..');
|
|
61
|
+
if (parent === current)
|
|
62
|
+
break;
|
|
63
|
+
current = parent;
|
|
64
|
+
}
|
|
65
|
+
for (const part of parts) {
|
|
66
|
+
try {
|
|
67
|
+
const entry = await lstat(part);
|
|
68
|
+
if (entry.isSymbolicLink())
|
|
69
|
+
throw new Error(`${description} must not be a symlink`);
|
|
70
|
+
if (!entry.isDirectory())
|
|
71
|
+
throw new Error(`${description} must be a directory`);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code === 'ENOENT')
|
|
75
|
+
continue;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function acquireLease(lease, description) {
|
|
81
|
+
for (let attempt = 0; attempt < 400; attempt += 1) {
|
|
82
|
+
const handle = await lease.tryAcquire();
|
|
83
|
+
if (handle)
|
|
84
|
+
return handle;
|
|
85
|
+
await sleep(5);
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`Timed out acquiring ${description}`);
|
|
88
|
+
}
|
|
89
|
+
async function readRegularFile(path, description) {
|
|
90
|
+
let handle;
|
|
91
|
+
try {
|
|
92
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code === 'ELOOP') {
|
|
96
|
+
throw new Error(`${description} must be a regular file`);
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
if (!(await handle.stat()).isFile()) {
|
|
102
|
+
throw new Error(`${description} must be a regular file`);
|
|
103
|
+
}
|
|
104
|
+
return await handle.readFile({ encoding: 'utf8' });
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await handle.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function ensureManagedRootIgnored(repositoryRoot) {
|
|
111
|
+
const commonValue = await git(repositoryRoot, [
|
|
112
|
+
'rev-parse',
|
|
113
|
+
'--git-common-dir',
|
|
114
|
+
]);
|
|
115
|
+
const commonDirectory = await realpath(isAbsolute(commonValue)
|
|
116
|
+
? commonValue
|
|
117
|
+
: resolve(repositoryRoot, commonValue));
|
|
118
|
+
const infoDirectory = join(commonDirectory, 'info');
|
|
119
|
+
await assertRealDirectory(commonDirectory, 'Git common directory');
|
|
120
|
+
await assertRealDirectory(infoDirectory, 'Git info directory');
|
|
121
|
+
await mkdir(infoDirectory, { recursive: true, mode: 0o700 });
|
|
122
|
+
await assertRealDirectory(infoDirectory, 'Git info directory');
|
|
123
|
+
const excludePath = join(infoDirectory, 'exclude');
|
|
124
|
+
const lease = await acquireLease(new ExclusiveFileLease(`${excludePath}.praxis.lock`), 'managed worktree ignore lock');
|
|
125
|
+
try {
|
|
126
|
+
let source = '';
|
|
127
|
+
try {
|
|
128
|
+
source = await readRegularFile(excludePath, 'Git info exclude');
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (error.code !== 'ENOENT')
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
const lines = source.split(/\r?\n/u);
|
|
135
|
+
if (lines.includes(LOCAL_EXCLUDE_PATTERN))
|
|
136
|
+
return;
|
|
137
|
+
const prefix = source.length > 0 && !source.endsWith('\n') ? '\n' : '';
|
|
138
|
+
const next = `${source}${prefix}${LOCAL_EXCLUDE_PATTERN}\n`;
|
|
139
|
+
const committed = await writeFileAtomically(excludePath, next, {
|
|
140
|
+
mode: 0o600,
|
|
141
|
+
beforeCommit: async () => {
|
|
142
|
+
try {
|
|
143
|
+
return ((await readRegularFile(excludePath, 'Git info exclude')) === source);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
return error.code === 'ENOENT' && !source;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
if (!committed) {
|
|
151
|
+
throw new Error('Git info exclude changed during managed worktree setup');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await lease.release();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function linkedGitDirectory(worktreePath) {
|
|
159
|
+
const value = await git(worktreePath, ['rev-parse', '--git-dir']);
|
|
160
|
+
return realpath(isAbsolute(value) ? value : resolve(worktreePath, value));
|
|
161
|
+
}
|
|
162
|
+
async function writeMarker(path, record) {
|
|
163
|
+
const gitDirectory = await linkedGitDirectory(path);
|
|
164
|
+
const marker = join(gitDirectory, 'PRAXIS_WORKTREE');
|
|
165
|
+
try {
|
|
166
|
+
await lstat(marker);
|
|
167
|
+
throw new Error('Managed worktree marker already exists');
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (error.code !== 'ENOENT')
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
const committed = await writeFileAtomically(marker, `${JSON.stringify({ version: 1, worktreeId: record.worktreeId, repositoryRoot: record.repositoryRoot })}\n`, {
|
|
174
|
+
mode: 0o600,
|
|
175
|
+
beforeCommit: async () => {
|
|
176
|
+
try {
|
|
177
|
+
await lstat(marker);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
return error.code === 'ENOENT';
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
if (!committed)
|
|
186
|
+
throw new Error('Managed worktree marker already exists');
|
|
187
|
+
}
|
|
188
|
+
async function readMarker(worktreePath, record) {
|
|
189
|
+
const markerPath = join(await linkedGitDirectory(worktreePath), 'PRAXIS_WORKTREE');
|
|
190
|
+
let marker;
|
|
191
|
+
try {
|
|
192
|
+
marker = JSON.parse(await readRegularFile(markerPath, 'worktree marker'));
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
throw new Error('worktree marker is invalid');
|
|
196
|
+
}
|
|
197
|
+
if (typeof marker !== 'object' ||
|
|
198
|
+
marker === null ||
|
|
199
|
+
Array.isArray(marker) ||
|
|
200
|
+
Object.keys(marker).length !== MARKER_FIELDS.size ||
|
|
201
|
+
Object.keys(marker).some((key) => !MARKER_FIELDS.has(key)) ||
|
|
202
|
+
marker.version !== 1 ||
|
|
203
|
+
marker.worktreeId !== record.worktreeId ||
|
|
204
|
+
marker.repositoryRoot !== record.repositoryRoot) {
|
|
205
|
+
throw new Error('worktree marker does not match ownership record');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function inspectOwnedCheckout(record, registered) {
|
|
209
|
+
const registrations = registered ?? (await registeredWorktrees(record.repositoryRoot));
|
|
210
|
+
const pathEntry = await lstat(record.worktreePath);
|
|
211
|
+
if (pathEntry.isSymbolicLink() || !pathEntry.isDirectory()) {
|
|
212
|
+
throw new Error('worktree path is not a real directory');
|
|
213
|
+
}
|
|
214
|
+
await assertRealDirectory(record.worktreePath, 'managed worktree path');
|
|
215
|
+
if ((await realpath(record.worktreePath)) !== resolve(record.worktreePath)) {
|
|
216
|
+
throw new Error('worktree path is not canonical');
|
|
217
|
+
}
|
|
218
|
+
if (!registrations.has(resolve(record.worktreePath))) {
|
|
219
|
+
throw new Error('worktree is not registered');
|
|
220
|
+
}
|
|
221
|
+
const [status, head, topLevel] = await Promise.all([
|
|
222
|
+
git(record.worktreePath, ['status', '--porcelain']),
|
|
223
|
+
git(record.worktreePath, ['rev-parse', 'HEAD']),
|
|
224
|
+
git(record.worktreePath, ['rev-parse', '--show-toplevel']),
|
|
225
|
+
]);
|
|
226
|
+
const worktreeRoot = await realpath(resolve(record.worktreePath, topLevel));
|
|
227
|
+
if (worktreeRoot !== resolve(record.worktreePath)) {
|
|
228
|
+
throw new Error('registered worktree root does not match');
|
|
229
|
+
}
|
|
230
|
+
const root = await resolveProjectIdentity(record.worktreePath);
|
|
231
|
+
if (root !== record.repositoryRoot) {
|
|
232
|
+
throw new Error('repository identity does not match');
|
|
233
|
+
}
|
|
234
|
+
await readMarker(record.worktreePath, record);
|
|
235
|
+
const branch = await gitOptional(record.worktreePath, [
|
|
236
|
+
'symbolic-ref',
|
|
237
|
+
'--quiet',
|
|
238
|
+
'--short',
|
|
239
|
+
'HEAD',
|
|
240
|
+
]);
|
|
241
|
+
if (branch)
|
|
242
|
+
throw new Error('worktree is not detached');
|
|
243
|
+
return { status, head };
|
|
244
|
+
}
|
|
245
|
+
function sameOwnership(left, right) {
|
|
246
|
+
return (left.worktreeId === right.worktreeId &&
|
|
247
|
+
left.kind === right.kind &&
|
|
248
|
+
left.policy === right.policy &&
|
|
249
|
+
left.ownerId === right.ownerId &&
|
|
250
|
+
left.repositoryRoot === right.repositoryRoot &&
|
|
251
|
+
left.worktreePath === right.worktreePath &&
|
|
252
|
+
left.branch === right.branch &&
|
|
253
|
+
left.baseCommit === right.baseCommit &&
|
|
254
|
+
left.createdAt === right.createdAt);
|
|
255
|
+
}
|
|
256
|
+
function nextRecord(record, state, retentionReason) {
|
|
257
|
+
const base = { ...record };
|
|
258
|
+
delete base.retentionReason;
|
|
259
|
+
return {
|
|
260
|
+
...base,
|
|
261
|
+
state,
|
|
262
|
+
updatedAt: new Date().toISOString(),
|
|
263
|
+
...(retentionReason === undefined ? {} : { retentionReason }),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
async function retain(store, record, reason) {
|
|
267
|
+
try {
|
|
268
|
+
await store.update(nextRecord(record, 'retained', reason));
|
|
269
|
+
return { retained: true, reason };
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
return {
|
|
273
|
+
retained: true,
|
|
274
|
+
reason: `${reason}; could not persist retention state: ${error instanceof Error ? error.message : String(error)}`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
export async function createOwnedManagedWorktree(options) {
|
|
279
|
+
if (!COMPONENT_PATTERN.test(options.directoryName)) {
|
|
280
|
+
throw worktreeError(options, 'name is invalid');
|
|
281
|
+
}
|
|
282
|
+
if (!validOwnerId(options.ownerId)) {
|
|
283
|
+
throw worktreeError(options, 'owner ID is invalid');
|
|
284
|
+
}
|
|
285
|
+
if (!['workflow', 'agent', 'team'].includes(options.kind)) {
|
|
286
|
+
throw worktreeError(options, 'kind is invalid');
|
|
287
|
+
}
|
|
288
|
+
let repositoryRoot;
|
|
289
|
+
let baseCommit;
|
|
290
|
+
try {
|
|
291
|
+
;
|
|
292
|
+
[repositoryRoot, baseCommit] = await Promise.all([
|
|
293
|
+
resolveProjectIdentity(options.cwd),
|
|
294
|
+
git(options.cwd, ['rev-parse', 'HEAD']),
|
|
295
|
+
]);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
throw worktreeError(options, 'isolation requires a Git repository');
|
|
299
|
+
}
|
|
300
|
+
const kindRoot = join(repositoryRoot, '.praxis', 'worktrees', options.kind);
|
|
301
|
+
const worktreePath = join(kindRoot, options.directoryName);
|
|
302
|
+
const rootRelative = relative(kindRoot, worktreePath);
|
|
303
|
+
if (!rootRelative ||
|
|
304
|
+
rootRelative === '..' ||
|
|
305
|
+
rootRelative.startsWith(`..${sep}`) ||
|
|
306
|
+
isAbsolute(rootRelative)) {
|
|
307
|
+
throw worktreeError(options, 'path escapes its kind root');
|
|
308
|
+
}
|
|
309
|
+
await assertRealDirectory(join(repositoryRoot, '.praxis'), 'managed worktree parent');
|
|
310
|
+
await assertRealDirectory(join(repositoryRoot, '.praxis', 'worktrees'), 'managed worktree parent');
|
|
311
|
+
await assertRealDirectory(kindRoot, 'managed worktree kind root');
|
|
312
|
+
await ensureManagedRootIgnored(repositoryRoot);
|
|
313
|
+
const worktreeId = managedWorktreeId(repositoryRoot, options.kind, options.ownerId);
|
|
314
|
+
const store = new ManagedWorktreeStore(options.stateRoot, repositoryRoot, worktreeId);
|
|
315
|
+
const lease = await store.acquireLease();
|
|
316
|
+
if (!lease)
|
|
317
|
+
throw worktreeError(options, `is already owned: ${worktreePath}`);
|
|
318
|
+
const now = new Date().toISOString();
|
|
319
|
+
const record = {
|
|
320
|
+
version: 1,
|
|
321
|
+
worktreeId,
|
|
322
|
+
kind: options.kind,
|
|
323
|
+
policy: options.policy,
|
|
324
|
+
ownerId: options.ownerId,
|
|
325
|
+
repositoryRoot,
|
|
326
|
+
worktreePath,
|
|
327
|
+
branch: null,
|
|
328
|
+
baseCommit,
|
|
329
|
+
state: 'creating',
|
|
330
|
+
createdAt: now,
|
|
331
|
+
updatedAt: now,
|
|
332
|
+
};
|
|
333
|
+
let recordCreated = false;
|
|
334
|
+
let gitCreated = false;
|
|
335
|
+
let markerCreated = false;
|
|
336
|
+
let created = false;
|
|
337
|
+
try {
|
|
338
|
+
await store.create(record);
|
|
339
|
+
recordCreated = true;
|
|
340
|
+
try {
|
|
341
|
+
const entry = await lstat(worktreePath);
|
|
342
|
+
if (entry.isSymbolicLink()) {
|
|
343
|
+
throw worktreeError(options, 'path must not be a symlink');
|
|
344
|
+
}
|
|
345
|
+
throw worktreeError(options, `path already exists: ${worktreePath}`);
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
if (error.code !== 'ENOENT')
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
await mkdir(kindRoot, { recursive: true });
|
|
352
|
+
await assertRealDirectory(kindRoot, 'managed worktree kind root');
|
|
353
|
+
await git(repositoryRoot, [
|
|
354
|
+
'worktree',
|
|
355
|
+
'add',
|
|
356
|
+
'--detach',
|
|
357
|
+
worktreePath,
|
|
358
|
+
baseCommit,
|
|
359
|
+
]);
|
|
360
|
+
gitCreated = true;
|
|
361
|
+
await writeMarker(worktreePath, record);
|
|
362
|
+
markerCreated = true;
|
|
363
|
+
if (options.hooks) {
|
|
364
|
+
let outcome;
|
|
365
|
+
try {
|
|
366
|
+
outcome = await options.hooks.afterCreate({
|
|
367
|
+
worktreePath,
|
|
368
|
+
worktreeKind: record.kind,
|
|
369
|
+
worktreeId: record.worktreeId,
|
|
370
|
+
ownerId: record.ownerId,
|
|
371
|
+
baseCommit: record.baseCommit,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
throw new Error(`WorktreeCreate hook failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
376
|
+
}
|
|
377
|
+
if (outcome.blockedReason) {
|
|
378
|
+
throw new Error(`WorktreeCreate hook blocked: ${outcome.blockedReason}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
await store.update(nextRecord(record, 'active'));
|
|
382
|
+
created = true;
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
let rollbackError;
|
|
386
|
+
let uncertainArtifact = false;
|
|
387
|
+
if (gitCreated) {
|
|
388
|
+
try {
|
|
389
|
+
if (!markerCreated) {
|
|
390
|
+
throw new Error('ownership marker was not published');
|
|
391
|
+
}
|
|
392
|
+
const inspection = await inspectOwnedCheckout(record);
|
|
393
|
+
if (inspection.status.length > 0) {
|
|
394
|
+
throw new Error('created worktree is no longer clean');
|
|
395
|
+
}
|
|
396
|
+
if (inspection.head !== record.baseCommit) {
|
|
397
|
+
throw new Error('created worktree HEAD no longer matches its base');
|
|
398
|
+
}
|
|
399
|
+
await git(repositoryRoot, ['worktree', 'remove', worktreePath]);
|
|
400
|
+
}
|
|
401
|
+
catch (removeError) {
|
|
402
|
+
rollbackError = removeError;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
try {
|
|
407
|
+
const registered = await registeredWorktrees(repositoryRoot);
|
|
408
|
+
const pathExists = await lstat(worktreePath)
|
|
409
|
+
.then(() => true)
|
|
410
|
+
.catch((pathError) => {
|
|
411
|
+
if (pathError.code === 'ENOENT') {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
throw pathError;
|
|
415
|
+
});
|
|
416
|
+
uncertainArtifact = pathExists || registered.has(resolve(worktreePath));
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
uncertainArtifact = true;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (recordCreated) {
|
|
423
|
+
try {
|
|
424
|
+
const retentionReason = rollbackError
|
|
425
|
+
? `Could not roll back ${worktreePath}: ${rollbackError.message}`
|
|
426
|
+
: uncertainArtifact
|
|
427
|
+
? `Creation failed with an unverified artifact at ${worktreePath}`
|
|
428
|
+
: undefined;
|
|
429
|
+
const failed = retentionReason
|
|
430
|
+
? nextRecord(record, 'retained', retentionReason)
|
|
431
|
+
: nextRecord(record, 'released');
|
|
432
|
+
await store.update(failed);
|
|
433
|
+
}
|
|
434
|
+
catch (stateError) {
|
|
435
|
+
rollbackError ??= stateError;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const primary = error instanceof Error ? error.message : String(error);
|
|
439
|
+
throw worktreeError(options, `could not be created: ${primary}${rollbackError ? `; cleanup warning: ${rollbackError.message}` : ''}`);
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
if (!created)
|
|
443
|
+
await lease.release();
|
|
444
|
+
}
|
|
445
|
+
let executionLease = lease;
|
|
446
|
+
let removedResult;
|
|
447
|
+
let cleanupInFlight;
|
|
448
|
+
return {
|
|
449
|
+
cwd: worktreePath,
|
|
450
|
+
cleanup: async () => {
|
|
451
|
+
if (removedResult)
|
|
452
|
+
return removedResult;
|
|
453
|
+
if (cleanupInFlight)
|
|
454
|
+
return cleanupInFlight;
|
|
455
|
+
const heldLease = executionLease;
|
|
456
|
+
executionLease = undefined;
|
|
457
|
+
cleanupInFlight = cleanupOwnedManagedWorktree(store, options, record, heldLease);
|
|
458
|
+
try {
|
|
459
|
+
const result = await cleanupInFlight;
|
|
460
|
+
if (!result.retained)
|
|
461
|
+
removedResult = result;
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
cleanupInFlight = undefined;
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
async function cleanupOwnedManagedWorktree(store, options, original, heldLease) {
|
|
471
|
+
const lease = heldLease ?? (await store.acquireLease());
|
|
472
|
+
if (!lease)
|
|
473
|
+
return {
|
|
474
|
+
retained: true,
|
|
475
|
+
reason: `${options.label} worktree cleanup is already in progress`,
|
|
476
|
+
};
|
|
477
|
+
try {
|
|
478
|
+
let record;
|
|
479
|
+
try {
|
|
480
|
+
record = await store.read();
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
return {
|
|
484
|
+
retained: true,
|
|
485
|
+
reason: `Could not inspect ${options.label.toLowerCase()} worktree record: ${error.message}`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
if (!sameOwnership(record, original)) {
|
|
489
|
+
return {
|
|
490
|
+
retained: true,
|
|
491
|
+
reason: `${options.label} worktree ownership record does not match`,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (record.state === 'released')
|
|
495
|
+
return { retained: false };
|
|
496
|
+
if (record.state !== 'active' &&
|
|
497
|
+
record.state !== 'retained' &&
|
|
498
|
+
record.state !== 'releasing') {
|
|
499
|
+
return {
|
|
500
|
+
retained: true,
|
|
501
|
+
reason: `${options.label} worktree is not releasable`,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if (record.policy === 'durable') {
|
|
505
|
+
return retain(store, record, `${options.label} worktree uses durable retention policy`);
|
|
506
|
+
}
|
|
507
|
+
const releasing = nextRecord(record, 'releasing');
|
|
508
|
+
try {
|
|
509
|
+
await store.update(releasing);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
return {
|
|
513
|
+
retained: true,
|
|
514
|
+
reason: `Could not update ${options.label.toLowerCase()} worktree record: ${error.message}`,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const registered = await registeredWorktrees(record.repositoryRoot).catch(() => null);
|
|
518
|
+
if (!registered) {
|
|
519
|
+
return retain(store, releasing, `Could not inspect registered ${options.label.toLowerCase()} worktrees`);
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
await lstat(record.worktreePath);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
if (error.code !== 'ENOENT') {
|
|
526
|
+
return retain(store, releasing, `Could not inspect ${options.label.toLowerCase()} worktree path: ${error instanceof Error ? error.message : String(error)}`);
|
|
527
|
+
}
|
|
528
|
+
if (registered.has(resolve(record.worktreePath))) {
|
|
529
|
+
return retain(store, releasing, `${options.label} worktree is missing but remains registered at ${record.worktreePath}`);
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
await store.update(nextRecord(releasing, 'released'));
|
|
533
|
+
return { retained: false };
|
|
534
|
+
}
|
|
535
|
+
catch (stateError) {
|
|
536
|
+
return {
|
|
537
|
+
retained: false,
|
|
538
|
+
reason: `${options.label} worktree is already absent but release state could not be persisted: ${stateError instanceof Error ? stateError.message : String(stateError)}`,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
let inspection;
|
|
543
|
+
try {
|
|
544
|
+
inspection = await inspectOwnedCheckout(record, registered);
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
const reason = `Could not verify ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
|
|
548
|
+
return retain(store, releasing, reason);
|
|
549
|
+
}
|
|
550
|
+
if (inspection.status.length > 0) {
|
|
551
|
+
const reason = `${options.label} worktree has uncommitted changes and was retained at ${record.worktreePath}`;
|
|
552
|
+
return retain(store, releasing, reason);
|
|
553
|
+
}
|
|
554
|
+
if (inspection.head !== record.baseCommit) {
|
|
555
|
+
const reason = `${options.label} worktree has commits and was retained at ${record.worktreePath}`;
|
|
556
|
+
return retain(store, releasing, reason);
|
|
557
|
+
}
|
|
558
|
+
if (options.hooks) {
|
|
559
|
+
let outcome;
|
|
560
|
+
try {
|
|
561
|
+
outcome = await options.hooks.beforeRemove({
|
|
562
|
+
worktreePath: record.worktreePath,
|
|
563
|
+
worktreeKind: record.kind,
|
|
564
|
+
worktreeId: record.worktreeId,
|
|
565
|
+
ownerId: record.ownerId,
|
|
566
|
+
baseCommit: record.baseCommit,
|
|
567
|
+
reason: 'normal',
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
return retain(store, releasing, `WorktreeRemove hook failed for ${record.worktreePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
572
|
+
}
|
|
573
|
+
if (outcome.blockedReason) {
|
|
574
|
+
return retain(store, releasing, `WorktreeRemove hook blocked for ${record.worktreePath}: ${outcome.blockedReason}`);
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
const postHookRegistered = await registeredWorktrees(record.repositoryRoot);
|
|
578
|
+
const postHookInspection = await inspectOwnedCheckout(record, postHookRegistered);
|
|
579
|
+
if (postHookInspection.status.length > 0) {
|
|
580
|
+
return retain(store, releasing, `WorktreeRemove hook left uncommitted changes in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
|
|
581
|
+
}
|
|
582
|
+
if (postHookInspection.head !== record.baseCommit) {
|
|
583
|
+
return retain(store, releasing, `WorktreeRemove hook created commits in ${record.worktreePath}; worktree was retained at ${record.worktreePath}`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
return retain(store, releasing, `WorktreeRemove hook left worktree unsafe at ${record.worktreePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
await git(record.repositoryRoot, [
|
|
592
|
+
'worktree',
|
|
593
|
+
'remove',
|
|
594
|
+
record.worktreePath,
|
|
595
|
+
]);
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
const reason = `Could not remove ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
|
|
599
|
+
return retain(store, releasing, reason);
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
await store.update(nextRecord(releasing, 'released'));
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
return {
|
|
606
|
+
retained: false,
|
|
607
|
+
reason: `Could not update ${options.label.toLowerCase()} worktree record after removal: ${error.message}`,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return { retained: false };
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
await lease.release();
|
|
614
|
+
}
|
|
15
615
|
}
|
|
16
616
|
export async function createManagedWorktree(options) {
|
|
17
617
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/u.test(options.directoryName)) {
|
|
@@ -1393,6 +1393,17 @@ export class ClaudeSubagentExecutor {
|
|
|
1393
1393
|
praxisRoot: sessionPaths.praxisRoot,
|
|
1394
1394
|
runId: options.runId,
|
|
1395
1395
|
agentId: options.agentId,
|
|
1396
|
+
...(this.options.hooks
|
|
1397
|
+
? {
|
|
1398
|
+
hookContext: {
|
|
1399
|
+
runner: this.options.hooks,
|
|
1400
|
+
sessionId: options.sessionId,
|
|
1401
|
+
transcriptPath: paths.transcriptFile,
|
|
1402
|
+
permissionMode: 'default',
|
|
1403
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
1404
|
+
},
|
|
1405
|
+
}
|
|
1406
|
+
: {}),
|
|
1396
1407
|
})
|
|
1397
1408
|
: null;
|
|
1398
1409
|
const agentCwd = isolation?.cwd ?? this.cwd();
|