@crouton-kit/crouter 0.3.39 → 0.3.41
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/build-root.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
- package/dist/clients/attach/attach-cmd.js +706 -733
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/web/server.js +5 -28
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
- package/dist/commands/canvas-rebuild-index.js +5 -5
- package/dist/commands/node-snapshot.js +9 -1
- package/dist/commands/node.js +37 -17
- package/dist/commands/push.js +8 -0
- package/dist/commands/revive.d.ts +10 -0
- package/dist/commands/revive.js +28 -10
- package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/setup-core.js +3 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/commands/worktree.d.ts +2 -0
- package/dist/commands/worktree.js +94 -0
- package/dist/core/__tests__/boot.test.js +4 -4
- package/dist/core/__tests__/canvas.test.js +19 -7
- package/dist/core/__tests__/child-followup.test.js +15 -5
- package/dist/core/__tests__/daemon-boot.test.js +6 -1
- package/dist/core/__tests__/daemon-wedge.test.js +18 -1
- package/dist/core/__tests__/fault-classifier.test.js +30 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
- package/dist/core/__tests__/full/spike-harness.test.js +8 -5
- package/dist/core/__tests__/grace-clock.test.js +18 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
- package/dist/core/__tests__/prune-to-limit.test.js +14 -0
- package/dist/core/__tests__/review-model-floor.test.js +32 -0
- package/dist/core/__tests__/revive.test.js +18 -4
- package/dist/core/__tests__/session-cycles.test.js +77 -0
- package/dist/core/__tests__/worktree.test.js +85 -0
- package/dist/core/canvas/boot.js +12 -7
- package/dist/core/canvas/canvas.d.ts +27 -8
- package/dist/core/canvas/canvas.js +54 -26
- package/dist/core/canvas/db.js +14 -0
- package/dist/core/canvas/history.js +1 -0
- package/dist/core/canvas/paths.d.ts +10 -9
- package/dist/core/canvas/paths.js +10 -9
- package/dist/core/canvas/pid.d.ts +155 -1
- package/dist/core/canvas/pid.js +306 -1
- package/dist/core/canvas/status-glyph.d.ts +7 -0
- package/dist/core/canvas/status-glyph.js +10 -1
- package/dist/core/canvas/types.d.ts +34 -0
- package/dist/core/fault-classifier.js +5 -1
- package/dist/core/runtime/bearings.js +4 -0
- package/dist/core/runtime/branded-host.d.ts +7 -0
- package/dist/core/runtime/branded-host.js +44 -17
- package/dist/core/runtime/broker-sdk.js +28 -68
- package/dist/core/runtime/broker.js +35 -4
- package/dist/core/runtime/host.d.ts +3 -3
- package/dist/core/runtime/host.js +148 -33
- package/dist/core/runtime/launch.d.ts +15 -15
- package/dist/core/runtime/launch.js +58 -4
- package/dist/core/runtime/naming.js +3 -2
- package/dist/core/runtime/nodes.d.ts +3 -1
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/pi-cli.d.ts +2 -0
- package/dist/core/runtime/pi-cli.js +51 -0
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +5 -2
- package/dist/core/runtime/recap.js +2 -1
- package/dist/core/runtime/revive.d.ts +21 -15
- package/dist/core/runtime/revive.js +87 -52
- package/dist/core/runtime/session-cycles.d.ts +30 -0
- package/dist/core/runtime/session-cycles.js +77 -0
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +152 -100
- package/dist/core/runtime/tmux.d.ts +7 -10
- package/dist/core/runtime/tmux.js +9 -11
- package/dist/core/worktree.d.ts +35 -0
- package/dist/core/worktree.js +158 -0
- package/dist/daemon/crtrd.d.ts +17 -8
- package/dist/daemon/crtrd.js +191 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
- package/dist/pi-extensions/canvas-context-intro.js +34 -23
- package/dist/pi-extensions/canvas-stophook.js +11 -5
- package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
- package/dist/web-client/assets/index-DrkcvANq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/docs/compat/hearth-crtr-v1.md +191 -0
- package/docs/public-api.md +75 -0
- package/package.json +3 -4
- package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
- package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
- package/dist/core/hearth/config.d.ts +0 -3
- package/dist/core/hearth/config.js +0 -108
- package/dist/core/hearth/guest-env.d.ts +0 -9
- package/dist/core/hearth/guest-env.js +0 -27
- package/dist/core/hearth/index.d.ts +0 -4
- package/dist/core/hearth/index.js +0 -4
- package/dist/core/hearth/model-auth-guest.d.ts +0 -8
- package/dist/core/hearth/model-auth-guest.js +0 -430
- package/dist/core/hearth/provider.d.ts +0 -36
- package/dist/core/hearth/provider.js +0 -10
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
- package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
- package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
- package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
- package/dist/core/hearth/providers/blaxel-home.js +0 -386
- package/dist/core/hearth/providers/blaxel.d.ts +0 -36
- package/dist/core/hearth/providers/blaxel.js +0 -364
- package/dist/core/hearth/providers/types.d.ts +0 -93
- package/dist/core/hearth/types.d.ts +0 -155
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
- package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
- package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
- package/dist/hearth/control-plane/config.d.ts +0 -21
- package/dist/hearth/control-plane/config.js +0 -77
- package/dist/hearth/control-plane/db.d.ts +0 -30
- package/dist/hearth/control-plane/db.js +0 -561
- package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
- package/dist/hearth/control-plane/hearth-target.js +0 -68
- package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
- package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
- package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
- package/dist/hearth/control-plane/ingress/route-store.js +0 -61
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
- package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
- package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
- package/dist/hearth/control-plane/main.d.ts +0 -1
- package/dist/hearth/control-plane/main.js +0 -88
- package/dist/hearth/control-plane/node-message.d.ts +0 -31
- package/dist/hearth/control-plane/node-message.js +0 -98
- package/dist/hearth/control-plane/register.d.ts +0 -15
- package/dist/hearth/control-plane/register.js +0 -34
- package/dist/hearth/control-plane/registry.d.ts +0 -22
- package/dist/hearth/control-plane/registry.js +0 -168
- package/dist/hearth/control-plane/relay.d.ts +0 -44
- package/dist/hearth/control-plane/relay.js +0 -711
- package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
- package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
- package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
- package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
- package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
- package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
- package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
- package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
- package/dist/hearth/control-plane/secrets.d.ts +0 -31
- package/dist/hearth/control-plane/secrets.js +0 -134
- package/dist/hearth/control-plane/server.d.ts +0 -27
- package/dist/hearth/control-plane/server.js +0 -482
- package/dist/hearth/control-plane/serving.d.ts +0 -15
- package/dist/hearth/control-plane/serving.js +0 -106
- package/dist/hearth/control-plane/session.d.ts +0 -68
- package/dist/hearth/control-plane/session.js +0 -273
- package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
- package/dist/hearth/control-plane/triggers/acl.js +0 -52
- package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
- package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
- package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
- package/dist/hearth/control-plane/triggers/deliver.js +0 -76
- package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
- package/dist/hearth/control-plane/triggers/envelope.js +0 -38
- package/dist/hearth/control-plane/types.d.ts +0 -86
- package/dist/hearth/control-plane/types.js +0 -1
- package/dist/hearth/control-plane/wake.d.ts +0 -86
- package/dist/hearth/control-plane/wake.js +0 -550
- package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
- /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
- /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
- /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
|
@@ -54,6 +54,10 @@ export interface SpawnChildOpts {
|
|
|
54
54
|
* choice at fire time (an explicit value re-validates; inheritance re-reads
|
|
55
55
|
* the spawner's THEN-current profile). */
|
|
56
56
|
profile?: string | null;
|
|
57
|
+
/** Create a managed worktree for this node and pin its cwd to it. */
|
|
58
|
+
worktree?: boolean;
|
|
59
|
+
/** Preallocated node id, used when a managed worktree must be named before birth. */
|
|
60
|
+
nodeId?: string;
|
|
57
61
|
}
|
|
58
62
|
/** Resolve a `--fork-from` value to an ABSOLUTE `.jsonl` source path for the
|
|
59
63
|
* broker fork (`SessionManager.forkFrom`, which loads a file — never a bare id).
|
|
@@ -13,15 +13,18 @@ import { spawnSync } from 'node:child_process';
|
|
|
13
13
|
import { readdirSync, existsSync } from 'node:fs';
|
|
14
14
|
import { isAbsolute, resolve, join } from 'node:path';
|
|
15
15
|
import { homedir } from 'node:os';
|
|
16
|
-
import { spawnNode, currentNodeContext, rootOfSpine } from './nodes.js';
|
|
16
|
+
import { spawnNode, currentNodeContext, rootOfSpine, newNodeId } from './nodes.js';
|
|
17
17
|
import { loadProfileManifest } from '../profiles/manifest.js';
|
|
18
18
|
import { selectProfileForCwd } from '../profiles/select.js';
|
|
19
19
|
import { buildLaunchSpec, buildPiArgv } from './launch.js';
|
|
20
|
+
import { gitSync } from '../git.js';
|
|
21
|
+
import { createManagedWorktree } from '../worktree.js';
|
|
22
|
+
import { usage } from '../errors.js';
|
|
20
23
|
import { writeGoal } from './kickoff.js';
|
|
21
24
|
import { hasRoadmap, seedRoadmap } from './roadmap.js';
|
|
22
25
|
import { buildIdentityAssertion, buildWakeBearings } from './bearings.js';
|
|
23
26
|
import { installMenuBinding, installNavBindings, installViewNavBindings } from './tmux-chrome.js';
|
|
24
|
-
import { getNode, fullName } from '../canvas/index.js';
|
|
27
|
+
import { getNode, fullName, recordPid } from '../canvas/index.js';
|
|
25
28
|
import { registerViewerFocus, openViewerWindow, waitForBrokerViewSocket, viewerSplitEnv, windowOfPane, currentTmux, inTmux, focusWindow, } from './placement.js';
|
|
26
29
|
import { transition } from './lifecycle.js';
|
|
27
30
|
import { headlessBrokerHost } from './host.js';
|
|
@@ -134,6 +137,7 @@ export async function bootRoot(opts) {
|
|
|
134
137
|
transition(meta.node_id, 'crash');
|
|
135
138
|
throw new Error(`failed to launch the broker engine for the front-door root ${meta.node_id} — nothing to attach to.`);
|
|
136
139
|
}
|
|
140
|
+
recordPid(meta.node_id, placed.pid);
|
|
137
141
|
// The terminal has nothing to attach to until view.sock accepts. The root is
|
|
138
142
|
// useless without its engine, so a socket timeout IS fatal here (unlike a
|
|
139
143
|
// background child's optional viewer, which is skipped on timeout).
|
|
@@ -170,7 +174,9 @@ function piSessionsRoot() {
|
|
|
170
174
|
function resolveSessionUuid(uuid) {
|
|
171
175
|
const sessionsRoot = piSessionsRoot();
|
|
172
176
|
if (!existsSync(sessionsRoot)) {
|
|
173
|
-
throw
|
|
177
|
+
throw usage(`--fork-from '${uuid}': no pi sessions store at ${sessionsRoot} to resolve it from — pass a node id or an absolute .jsonl path.`, {
|
|
178
|
+
next: 'Pass a live node id, an absolute .jsonl session path, or drop --fork-from.',
|
|
179
|
+
});
|
|
174
180
|
}
|
|
175
181
|
const matches = [];
|
|
176
182
|
for (const dir of readdirSync(sessionsRoot, { withFileTypes: true })) {
|
|
@@ -187,10 +193,15 @@ function resolveSessionUuid(uuid) {
|
|
|
187
193
|
}
|
|
188
194
|
const exact = matches.filter((m) => m.id === uuid);
|
|
189
195
|
const hit = exact.length > 0 ? exact : matches.filter((m) => m.id.startsWith(uuid));
|
|
190
|
-
if (hit.length === 0)
|
|
191
|
-
throw
|
|
196
|
+
if (hit.length === 0) {
|
|
197
|
+
throw usage(`--fork-from '${uuid}': no pi session with that id found.`, {
|
|
198
|
+
next: 'Pass a live node id, a real session uuid/prefix, or an absolute .jsonl session path.',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
192
201
|
if (hit.length > 1) {
|
|
193
|
-
throw
|
|
202
|
+
throw usage(`--fork-from '${uuid}': ambiguous — ${hit.length} sessions match that id prefix; pass the full id or the .jsonl path.`, {
|
|
203
|
+
next: 'Pass the full session uuid or an absolute .jsonl path to disambiguate.',
|
|
204
|
+
});
|
|
194
205
|
}
|
|
195
206
|
return hit[0].path;
|
|
196
207
|
}
|
|
@@ -203,7 +214,7 @@ function resolveSessionUuid(uuid) {
|
|
|
203
214
|
export function resolveForkSource(value) {
|
|
204
215
|
const v = value.trim();
|
|
205
216
|
if (v === '')
|
|
206
|
-
throw
|
|
217
|
+
throw usage('--fork-from requires a node id, session file, or session uuid.', { next: 'Pass a live node id, an absolute .jsonl session path, or a session uuid/prefix.' });
|
|
207
218
|
// A path (contains `/` or ends `.jsonl`) is a session file — ensure it is
|
|
208
219
|
// ABSOLUTE so the broker, launched in a different cwd, still resolves it.
|
|
209
220
|
if (v.includes('/') || v.endsWith('.jsonl')) {
|
|
@@ -215,7 +226,9 @@ export function resolveForkSource(value) {
|
|
|
215
226
|
if (n !== null) {
|
|
216
227
|
const src = n.pi_session_file;
|
|
217
228
|
if (src === undefined || src === null || src === '') {
|
|
218
|
-
throw
|
|
229
|
+
throw usage(`node ${v} has no pi session yet — it has not captured a session FILE to fork from (the broker forks a .jsonl path, not a bare id).`, {
|
|
230
|
+
next: 'Pick a node that has captured a session, or drop --fork-from.',
|
|
231
|
+
});
|
|
219
232
|
}
|
|
220
233
|
return src;
|
|
221
234
|
}
|
|
@@ -279,102 +292,141 @@ export async function spawnChild(opts) {
|
|
|
279
292
|
// Born WITHOUT a name — the canvas-goal-capture extension names it async from
|
|
280
293
|
// its first message (the kickoff task) inside its own pi process, so spawn
|
|
281
294
|
// never blocks on the LLM naming round-trip (the 2-3s freeze it used to cost).
|
|
295
|
+
const wantsWorktree = opts.worktree === true;
|
|
296
|
+
const nodeId = wantsWorktree ? (opts.nodeId ?? newNodeId()) : opts.nodeId;
|
|
297
|
+
// Preflight: resolve every fallible input BEFORE creating the worktree or the
|
|
298
|
+
// node. `--fork-from <bad>` (resolveForkSource) and an invalid `--profile`
|
|
299
|
+
// (resolveProfileId) must fail HERE, before anything exists to clean up or
|
|
300
|
+
// strand — catching them later (after the worktree/node are created) is what
|
|
301
|
+
// let `--worktree --fork-from <bad>` leave an active node pinned to a deleted
|
|
302
|
+
// cwd with an open managed_worktree.
|
|
282
303
|
const profileId = await resolveProfileId(opts.profile, spawner, opts.cwd);
|
|
283
|
-
const meta = spawnNode({
|
|
284
|
-
kind: opts.kind,
|
|
285
|
-
mode,
|
|
286
|
-
lifecycle,
|
|
287
|
-
cwd: opts.cwd,
|
|
288
|
-
name: opts.name ?? opts.kind,
|
|
289
|
-
// A root has no spine parent (top-level, nobody subscribes); it still
|
|
290
|
-
// records spawned_by=spawner when a node (not a human shell) spawned it.
|
|
291
|
-
// A child's parent IS its manager.
|
|
292
|
-
parent: root ? null : spawner,
|
|
293
|
-
spawnedBy: root ? (spawner ?? undefined) : undefined,
|
|
294
|
-
profile_id: profileId,
|
|
295
|
-
// Persist the RAW fork reference (not the resolved path) as provenance, so
|
|
296
|
-
// the boot intro can detect this is a fork and re-assert the node's own
|
|
297
|
-
// identity over the source's copied-in conversation.
|
|
298
|
-
forkFrom: opts.forkFrom,
|
|
299
|
-
modelOverride: opts.model,
|
|
300
|
-
launch,
|
|
301
|
-
});
|
|
302
|
-
// Persist the task as the child's goal for a fresh revive to re-read.
|
|
303
|
-
writeGoal(meta.node_id, opts.prompt);
|
|
304
|
-
// A fork copies an existing conversation into this child's first session
|
|
305
|
-
// (resolved to an absolute file path when forking from a node). Resolved here
|
|
306
|
-
// — not in buildPiArgv — so a bad reference fails the spawn loudly before any
|
|
307
|
-
// window opens, rather than after pi is already booting.
|
|
308
304
|
const forkFrom = opts.forkFrom !== undefined ? resolveForkSource(opts.forkFrom) : undefined;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
305
|
+
let managedWorktree;
|
|
306
|
+
let spawnCwd = opts.cwd;
|
|
307
|
+
try {
|
|
308
|
+
if (wantsWorktree) {
|
|
309
|
+
managedWorktree = createManagedWorktree(opts.cwd, nodeId);
|
|
310
|
+
spawnCwd = managedWorktree.path;
|
|
311
|
+
}
|
|
312
|
+
const meta = spawnNode({
|
|
313
|
+
kind: opts.kind,
|
|
314
|
+
mode,
|
|
315
|
+
lifecycle,
|
|
316
|
+
cwd: spawnCwd,
|
|
317
|
+
name: opts.name ?? opts.kind,
|
|
318
|
+
// A root has no spine parent (top-level, nobody subscribes); it still
|
|
319
|
+
// records spawned_by=spawner when a node (not a human shell) spawned it.
|
|
320
|
+
// A child's parent IS its manager.
|
|
321
|
+
parent: root ? null : spawner,
|
|
322
|
+
spawnedBy: root ? (spawner ?? undefined) : undefined,
|
|
323
|
+
profile_id: profileId,
|
|
324
|
+
// Persist the RAW fork reference (not the resolved path) as provenance, so
|
|
325
|
+
// the boot intro can detect this is a fork and re-assert the node's own
|
|
326
|
+
// identity over the source's copied-in conversation.
|
|
327
|
+
forkFrom: opts.forkFrom,
|
|
328
|
+
modelOverride: opts.model,
|
|
329
|
+
...(managedWorktree !== undefined ? { managedWorktree } : {}),
|
|
330
|
+
...(nodeId !== undefined ? { nodeId } : {}),
|
|
331
|
+
launch,
|
|
332
|
+
});
|
|
333
|
+
// Persist the task as the child's goal for a fresh revive to re-read.
|
|
334
|
+
writeGoal(meta.node_id, opts.prompt);
|
|
335
|
+
// A fork inherits the SOURCE node's entire first-person conversation, so the
|
|
336
|
+
// identity re-assertion must ride the STRONGEST channel too, not only the
|
|
337
|
+
// session-start bearings (a trailing custom_message the model weighs only by
|
|
338
|
+
// recency). Prepend it to the kickoff PROMPT — the message that actually
|
|
339
|
+
// triggers the fork's first turn, so the model acts on "you are node X, a
|
|
340
|
+
// FORK of <source>, NOT them" as live instruction. When the daemon births this
|
|
341
|
+
// node from a scheduled `spawn` wake, the <crtr-wake> provenance block leads
|
|
342
|
+
// (so "a timer birthed you" precedes the task) — mirroring the fork-identity
|
|
343
|
+
// prepend. Only the pi argv prompt is reframed; the persisted goal (writeGoal
|
|
344
|
+
// above) keeps the raw task.
|
|
345
|
+
const wakeBlock = opts.wakeOrigin !== undefined ? `${buildWakeBearings(opts.wakeOrigin)}\n\n` : '';
|
|
346
|
+
const idBlock = forkFrom !== undefined ? `${buildIdentityAssertion(meta.node_id)}\n\n` : '';
|
|
347
|
+
const kickoff = `${wakeBlock}${idBlock}${opts.prompt}`;
|
|
348
|
+
// A child created DIRECTLY as an orchestrator (mode='orchestrator') boots
|
|
349
|
+
// with the orchestrator persona but bypasses promote(), which is where a
|
|
350
|
+
// roadmap scaffold would normally be seeded. Lay one down here (goal
|
|
351
|
+
// pre-filled from the task) so the orchestrator has its memory artifact from
|
|
352
|
+
// birth, instead of waking memory-less. Guarded so it never clobbers.
|
|
353
|
+
if (mode === 'orchestrator' && !hasRoadmap(meta.node_id)) {
|
|
354
|
+
seedRoadmap(meta.node_id, { goal: opts.prompt.trim() });
|
|
355
|
+
}
|
|
356
|
+
// (The three scoped long-term memory stores are seeded for EVERY node at birth
|
|
357
|
+
// in spawnNode — no orchestrator-gated seeding needed here.)
|
|
358
|
+
// A --root spawned from inside tmux opens its one viewer in the CALLER'S
|
|
359
|
+
// CURRENT session (below), so it appears where the spawner is working; a
|
|
360
|
+
// managed child is a headless broker with NO viewer at all.
|
|
361
|
+
const here = root ? currentTmux() : null;
|
|
362
|
+
const inv = buildPiArgv(meta, { prompt: kickoff, forkFrom });
|
|
363
|
+
// CRTR_SUBTREE (the spine root) rides the detached broker's env so it can group
|
|
364
|
+
// the subtree; the host sets CRTR_FRONT_DOOR itself.
|
|
365
|
+
inv.env = { ...inv.env, CRTR_SUBTREE: rootOfSpine(meta.node_id) };
|
|
366
|
+
// Broker is the only host: launch the detached broker ENGINE. A managed child
|
|
367
|
+
// gets NO viewer — spawning it returns once the engine is launched and a human
|
|
368
|
+
// opens a viewer on demand via `crtr node focus` / surface attach. A --root gets
|
|
369
|
+
// one foreground viewer below.
|
|
370
|
+
const placed = launchBrokerOrCrash(meta.node_id, inv, {
|
|
371
|
+
cwd: meta.cwd,
|
|
372
|
+
name: fullName(meta),
|
|
373
|
+
resuming: false,
|
|
374
|
+
});
|
|
375
|
+
// Definitive failure: no broker pid ⇒ the node has no engine. Crash it (so the
|
|
376
|
+
// daemon doesn't watch a zombie 'active') and throw so `crtr node new` exits
|
|
377
|
+
// non-zero. transition('crash') is a legal from-LIVE move (still 'active' from
|
|
378
|
+
// spawnNode). Mirrors the old window===null crash.
|
|
379
|
+
if (placed.pid === null) {
|
|
380
|
+
transition(meta.node_id, 'crash');
|
|
381
|
+
throw new Error(`failed to launch the broker engine for ${meta.node_id} (${meta.name}) — the node was not started.`);
|
|
382
|
+
}
|
|
383
|
+
recordPid(meta.node_id, placed.pid);
|
|
384
|
+
// A --root is spawned to be DRIVEN directly: when the caller is inside tmux,
|
|
385
|
+
// open ONE foreground viewer in the caller's CURRENT session and bring it
|
|
386
|
+
// forefront so whoever asked for it picks up the conversation. Outside tmux a
|
|
387
|
+
// --root opens no viewer (an agent handing a root off) — never a throw. A
|
|
388
|
+
// managed child opens nothing. Non-fatal if tmux refuses (openViewerWindow
|
|
389
|
+
// returns null) or the socket never binds — `focus` opens a viewer later.
|
|
390
|
+
let window = null;
|
|
391
|
+
let session = null;
|
|
392
|
+
if (root && here !== null && await waitForBrokerViewSocket(meta.node_id, placed.exited)) {
|
|
393
|
+
const viewer = openViewerWindow(meta.node_id, here.session, { name: fullName(meta), cwd: opts.cwd });
|
|
394
|
+
if (viewer !== null && viewer.pane !== null && viewer.pane !== '') {
|
|
395
|
+
session = here.session;
|
|
396
|
+
window = windowOfPane(viewer.pane);
|
|
397
|
+
if (window !== null) {
|
|
398
|
+
try {
|
|
399
|
+
focusWindow(here.session, window);
|
|
400
|
+
}
|
|
401
|
+
catch { /* best-effort */ }
|
|
373
402
|
}
|
|
374
|
-
catch { /* best-effort */ }
|
|
375
403
|
}
|
|
376
404
|
}
|
|
405
|
+
const saved = getNode(meta.node_id);
|
|
406
|
+
return { node: saved, window, session };
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
// Cleanup scope: a managed worktree is only removed on failure while NO
|
|
410
|
+
// persisted node identity exists yet. spawnNode() persists the node row
|
|
411
|
+
// (and its meta files) BEFORE the later fallible steps in this try block
|
|
412
|
+
// (writeGoal, roadmap seeding, launch, viewer placement) — so a flag set
|
|
413
|
+
// after spawnNode() merely returns would still read false if one of THOSE
|
|
414
|
+
// later steps threw, and this catch would then delete the worktree out
|
|
415
|
+
// from under an already-persisted node whose cwd points at it. Check the
|
|
416
|
+
// canvas directly instead: once any node row exists for this id, keep the
|
|
417
|
+
// worktree — the node stays alive (crashed, if the launch itself failed)
|
|
418
|
+
// with its worktree intact rather than pinned to a deleted path.
|
|
419
|
+
if (managedWorktree !== undefined && nodeId !== undefined && getNode(nodeId) === null) {
|
|
420
|
+
const repoRoot = managedWorktree.repo_root;
|
|
421
|
+
try {
|
|
422
|
+
gitSync(['worktree', 'remove', managedWorktree.path], repoRoot);
|
|
423
|
+
}
|
|
424
|
+
catch { /* best-effort */ }
|
|
425
|
+
try {
|
|
426
|
+
gitSync(['branch', '-d', managedWorktree.branch], repoRoot);
|
|
427
|
+
}
|
|
428
|
+
catch { /* best-effort */ }
|
|
429
|
+
}
|
|
430
|
+
throw err;
|
|
377
431
|
}
|
|
378
|
-
const saved = getNode(meta.node_id);
|
|
379
|
-
return { node: saved, window, session };
|
|
380
432
|
}
|
|
@@ -164,16 +164,13 @@ export declare function respawnPaneSync(opts: RespawnPaneOpts): boolean;
|
|
|
164
164
|
/** Turn a pi argv array into a single shell command string.
|
|
165
165
|
*
|
|
166
166
|
* The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* one today). The value may be a multi-word launcher (e.g. `node --import
|
|
175
|
-
* tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
|
|
176
|
-
* binary is spliced verbatim ahead of them. */
|
|
167
|
+
* bundled pi CLI launched with the current Node binary. CRTR_PI_BINARY is a
|
|
168
|
+
* TEST-ONLY substitution seam: the integration-test harness points it at a
|
|
169
|
+
* deterministic fake-pi vehicle so a real `crtr node new` reaches the fake
|
|
170
|
+
* instead of the LLM pi, without depending on tmux/shell PATH inheritance. An
|
|
171
|
+
* explicit `binary` arg still overrides the env. The value may be a multi-word
|
|
172
|
+
* launcher (e.g. `node --import tsx/esm host.ts`); only argv entries are
|
|
173
|
+
* shell-quoted, so a multi-word binary is spliced verbatim ahead of them. */
|
|
177
174
|
export declare function piCommand(argv: string[], binary?: string): string;
|
|
178
175
|
/** List all window ids present in `session`. Returns [] if the session does
|
|
179
176
|
* not exist or tmux fails for any reason. Each entry is the raw window id
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { spawnSync } from 'node:child_process';
|
|
11
11
|
import { readConfig } from '../config.js';
|
|
12
12
|
import { defaultCanvasNavConfig } from '../../types.js';
|
|
13
|
+
import { bundledPiCommandForShell } from './pi-cli.js';
|
|
13
14
|
import { surfaceTmuxStyleArgs } from './surface-bg.js';
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Shell quoting + tmux invocation
|
|
@@ -327,17 +328,14 @@ export function respawnPaneSync(opts) {
|
|
|
327
328
|
/** Turn a pi argv array into a single shell command string.
|
|
328
329
|
*
|
|
329
330
|
* The binary defaults to `CRTR_PI_BINARY` when that env var is set, else the
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
|
|
338
|
-
* tsx/esm host.ts`); only the argv entries are shell-quoted, so a multi-word
|
|
339
|
-
* binary is spliced verbatim ahead of them. */
|
|
340
|
-
export function piCommand(argv, binary = process.env['CRTR_PI_BINARY'] ?? 'pi') {
|
|
331
|
+
* bundled pi CLI launched with the current Node binary. CRTR_PI_BINARY is a
|
|
332
|
+
* TEST-ONLY substitution seam: the integration-test harness points it at a
|
|
333
|
+
* deterministic fake-pi vehicle so a real `crtr node new` reaches the fake
|
|
334
|
+
* instead of the LLM pi, without depending on tmux/shell PATH inheritance. An
|
|
335
|
+
* explicit `binary` arg still overrides the env. The value may be a multi-word
|
|
336
|
+
* launcher (e.g. `node --import tsx/esm host.ts`); only argv entries are
|
|
337
|
+
* shell-quoted, so a multi-word binary is spliced verbatim ahead of them. */
|
|
338
|
+
export function piCommand(argv, binary = process.env['CRTR_PI_BINARY'] ?? bundledPiCommandForShell(shellQuote)) {
|
|
341
339
|
return [binary, ...argv.map(shellQuote)].join(' ');
|
|
342
340
|
}
|
|
343
341
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type NodeMeta, type ManagedWorktree } from './canvas/index.js';
|
|
2
|
+
export interface WorktreeCommandResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class WorktreeError extends Error {
|
|
8
|
+
code: string;
|
|
9
|
+
next: string;
|
|
10
|
+
detail?: string;
|
|
11
|
+
constructor(code: string, message: string, next: string, detail?: string);
|
|
12
|
+
}
|
|
13
|
+
export declare function managedWorktreesRoot(): string;
|
|
14
|
+
export declare function managedWorktreePath(nodeId: string): string;
|
|
15
|
+
export declare function hasOpenManagedWorktree(node: NodeMeta | null | undefined): boolean;
|
|
16
|
+
export declare function openManagedWorktreeForNode(nodeId: string): ManagedWorktree | null;
|
|
17
|
+
export declare function createManagedWorktree(cwd: string, nodeId: string): ManagedWorktree;
|
|
18
|
+
export declare function isRebaseInProgress(cwd: string): boolean;
|
|
19
|
+
export interface CloseManagedWorktreeResult {
|
|
20
|
+
node_id: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
worktree_path: string;
|
|
23
|
+
pushed_sha: string;
|
|
24
|
+
/** False when the push landed but the worktree checkout could not be removed
|
|
25
|
+
* automatically afterward — the close still counts as done (state is
|
|
26
|
+
* closed, push-final is unblocked); `worktree_remove_error` carries the
|
|
27
|
+
* manual cleanup note. */
|
|
28
|
+
worktree_removed: boolean;
|
|
29
|
+
worktree_remove_error?: string;
|
|
30
|
+
/** False when the push landed but the local branch could not be deleted
|
|
31
|
+
* afterward — same non-fatal treatment as worktree_removed. */
|
|
32
|
+
branch_deleted: boolean;
|
|
33
|
+
branch_delete_error?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function closeManagedWorktree(nodeId: string): CloseManagedWorktreeResult;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmdirSync } from 'node:fs';
|
|
2
|
+
import { gitSync } from './git.js';
|
|
3
|
+
import { crtrHome } from './canvas/paths.js';
|
|
4
|
+
import { getNode, updateNode } from './canvas/index.js';
|
|
5
|
+
export class WorktreeError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
next;
|
|
8
|
+
detail;
|
|
9
|
+
constructor(code, message, next, detail) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'WorktreeError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.next = next;
|
|
14
|
+
this.detail = detail;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function runGit(cwd, args, code, next) {
|
|
18
|
+
const res = gitSync(args, cwd);
|
|
19
|
+
if (res.status !== 0) {
|
|
20
|
+
const detail = (res.stderr.trim() || res.stdout.trim()).trim();
|
|
21
|
+
throw new WorktreeError(code, `git ${args.join(' ')} failed`, next, detail);
|
|
22
|
+
}
|
|
23
|
+
return res.stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
export function managedWorktreesRoot() {
|
|
26
|
+
return `${crtrHome()}/worktrees`;
|
|
27
|
+
}
|
|
28
|
+
export function managedWorktreePath(nodeId) {
|
|
29
|
+
return `${managedWorktreesRoot()}/${nodeId}`;
|
|
30
|
+
}
|
|
31
|
+
export function hasOpenManagedWorktree(node) {
|
|
32
|
+
return node?.managed_worktree?.state === 'open';
|
|
33
|
+
}
|
|
34
|
+
export function openManagedWorktreeForNode(nodeId) {
|
|
35
|
+
const node = getNode(nodeId);
|
|
36
|
+
const wt = node?.managed_worktree;
|
|
37
|
+
return wt?.state === 'open' ? wt : null;
|
|
38
|
+
}
|
|
39
|
+
function repoRootFor(cwd) {
|
|
40
|
+
return runGit(cwd, ['rev-parse', '--show-toplevel'], 'not_git_repo', 'Pass --cwd pointing inside a git repository.');
|
|
41
|
+
}
|
|
42
|
+
function requireOriginMain(repoRoot) {
|
|
43
|
+
return runGit(repoRoot, ['rev-parse', 'origin/main'], 'missing_origin_main', 'Managed worktrees land by rebasing onto origin/main. Fetch/create origin/main, then retry.');
|
|
44
|
+
}
|
|
45
|
+
export function createManagedWorktree(cwd, nodeId) {
|
|
46
|
+
const repoRoot = repoRootFor(cwd);
|
|
47
|
+
const baseSha = requireOriginMain(repoRoot);
|
|
48
|
+
const path = managedWorktreePath(nodeId);
|
|
49
|
+
if (existsSync(path)) {
|
|
50
|
+
throw new WorktreeError('worktree_path_exists', `managed worktree path already exists: ${path}`, 'Remove the stale path or choose a fresh node id.');
|
|
51
|
+
}
|
|
52
|
+
const branch = `crtr/${nodeId}`;
|
|
53
|
+
runGit(repoRoot, ['worktree', 'add', '-b', branch, path, 'origin/main'], 'worktree_add_failed', 'Inspect the git error, fix the repository/worktree state, then retry spawning with --worktree.');
|
|
54
|
+
return {
|
|
55
|
+
state: 'open',
|
|
56
|
+
path,
|
|
57
|
+
branch,
|
|
58
|
+
repo_root: repoRoot,
|
|
59
|
+
base_ref: 'origin/main',
|
|
60
|
+
base_sha: baseSha,
|
|
61
|
+
created: new Date().toISOString(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function statusPorcelain(cwd) {
|
|
65
|
+
return runGit(cwd, ['status', '--porcelain'], 'status_failed', 'Inspect the worktree manually, then retry.');
|
|
66
|
+
}
|
|
67
|
+
function currentBranch(cwd) {
|
|
68
|
+
return runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], 'branch_check_failed', 'Inspect the worktree manually, then retry.');
|
|
69
|
+
}
|
|
70
|
+
function gitPath(cwd, logicalPath) {
|
|
71
|
+
return runGit(cwd, ['rev-parse', '--git-path', logicalPath], 'git_path_failed', 'Inspect the worktree manually, then retry.');
|
|
72
|
+
}
|
|
73
|
+
export function isRebaseInProgress(cwd) {
|
|
74
|
+
return existsSync(gitPath(cwd, 'rebase-merge')) || existsSync(gitPath(cwd, 'rebase-apply'));
|
|
75
|
+
}
|
|
76
|
+
export function closeManagedWorktree(nodeId) {
|
|
77
|
+
const node = getNode(nodeId);
|
|
78
|
+
if (node === null) {
|
|
79
|
+
throw new WorktreeError('unknown_node', `unknown node: ${nodeId}`, 'Run this from a live node, or pass a real node id once that command supports it.');
|
|
80
|
+
}
|
|
81
|
+
const wt = openManagedWorktreeForNode(nodeId);
|
|
82
|
+
if (wt === null) {
|
|
83
|
+
throw new WorktreeError('no_managed_worktree', `node ${nodeId} has no open managed worktree`, 'Nothing to close for this node.');
|
|
84
|
+
}
|
|
85
|
+
if (!existsSync(wt.path)) {
|
|
86
|
+
throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, 'Ask the human with `crtr human ask` before changing node state; the checkout disappeared outside crouter.');
|
|
87
|
+
}
|
|
88
|
+
if (isRebaseInProgress(wt.path)) {
|
|
89
|
+
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 worktree close\`.`);
|
|
90
|
+
}
|
|
91
|
+
const dirty = statusPorcelain(wt.path);
|
|
92
|
+
if (dirty !== '') {
|
|
93
|
+
throw new WorktreeError('dirty_worktree', 'managed worktree has uncommitted changes', `Commit the work in ${wt.path} (or ask the human with \`crtr human ask\` if you do not know whether to keep it), then rerun \`crtr worktree close\`.`);
|
|
94
|
+
}
|
|
95
|
+
const branch = currentBranch(wt.path);
|
|
96
|
+
if (branch !== wt.branch) {
|
|
97
|
+
const found = branch === 'HEAD' ? 'detached HEAD' : branch;
|
|
98
|
+
throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}, found ${found})`, `Check out ${wt.branch} in ${wt.path} before closing, or ask the human with \`crtr human ask\` if you are unsure why it changed, then rerun \`crtr worktree close\`.`);
|
|
99
|
+
}
|
|
100
|
+
runGit(wt.path, ['fetch', 'origin', 'main'], 'fetch_failed', 'Network/remote state blocked landing. Ask the human with `crtr human ask` if this is not a transient git/network issue.');
|
|
101
|
+
const rebase = gitSync(['rebase', 'origin/main'], wt.path);
|
|
102
|
+
if (rebase.status !== 0) {
|
|
103
|
+
const detail = (rebase.stderr.trim() || rebase.stdout.trim()).trim();
|
|
104
|
+
throw new WorktreeError('rebase_failed', 'rebase onto origin/main did not complete', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr worktree close\`. If the blocker is not mechanical, ask the human with \`crtr human ask\`.`, detail);
|
|
105
|
+
}
|
|
106
|
+
const push = gitSync(['push', 'origin', 'HEAD:main'], wt.path);
|
|
107
|
+
if (push.status !== 0) {
|
|
108
|
+
const detail = (push.stderr.trim() || push.stdout.trim()).trim();
|
|
109
|
+
throw new WorktreeError('push_main_failed', 'could not fast-forward/push origin/main from this worktree', 'Do not force-push main. Ask the human with `crtr human ask` how to land this managed worktree.', detail);
|
|
110
|
+
}
|
|
111
|
+
// The push landing IS the point of no return: persist `state: 'closed'`
|
|
112
|
+
// IMMEDIATELY, before reading the landed sha or attempting any cleanup below.
|
|
113
|
+
// Worktree removal and branch deletion are best-effort from here on — their
|
|
114
|
+
// failure is cosmetic (a stray checkout / local ref) and must never re-strand
|
|
115
|
+
// `state: 'open'` behind a dead end that permanently blocks push-final.
|
|
116
|
+
updateNode(nodeId, { managed_worktree: { ...wt, state: 'closed', closed: new Date().toISOString() } });
|
|
117
|
+
const pushedSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'pushed_sha_failed', 'The branch landed and the node is already marked closed, but crouter could not read the landed commit SHA. Inspect the checkout manually to confirm the landed commit; no further close action is needed.');
|
|
118
|
+
const remove = gitSync(['worktree', 'remove', wt.path], wt.repo_root);
|
|
119
|
+
const worktreeRemoved = remove.status === 0;
|
|
120
|
+
if (worktreeRemoved && existsSync(wt.path)) {
|
|
121
|
+
// `git worktree remove` can leave the (now empty) mount dir behind; tidy it best-effort.
|
|
122
|
+
try {
|
|
123
|
+
rmdirSync(wt.path);
|
|
124
|
+
}
|
|
125
|
+
catch { /* non-empty or busy — leave it */ }
|
|
126
|
+
}
|
|
127
|
+
// wt.path IS the calling node's own pi-process cwd — a process can never chdir
|
|
128
|
+
// itself out of a directory that vanishes out from under it, so once removal
|
|
129
|
+
// deletes it every subsequent bash/chdir in this node fails permanently and the
|
|
130
|
+
// mandated post-close `crtr push final` can never run. Recreate it as an empty
|
|
131
|
+
// tombstone directory so the path still resolves; the branch is already landed
|
|
132
|
+
// and `state: 'closed'` already persisted above, so this is purely about keeping
|
|
133
|
+
// the node's cwd alive, not about worktree bookkeeping.
|
|
134
|
+
if (!existsSync(wt.path)) {
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(wt.path, { recursive: true });
|
|
137
|
+
}
|
|
138
|
+
catch { /* best-effort; leaves cwd broken only if this also fails */ }
|
|
139
|
+
}
|
|
140
|
+
const worktreeRemoveError = worktreeRemoved
|
|
141
|
+
? undefined
|
|
142
|
+
: `local worktree checkout at ${wt.path} landed but could not be removed automatically (${(remove.stderr.trim() || remove.stdout.trim()).trim()}); run \`git -C ${wt.repo_root} worktree remove ${wt.path}\` after confirming it is safe to delete.`;
|
|
143
|
+
const branchDelete = gitSync(['branch', '-d', wt.branch], wt.repo_root);
|
|
144
|
+
const branchDeleted = branchDelete.status === 0;
|
|
145
|
+
const branchDeleteError = branchDeleted
|
|
146
|
+
? undefined
|
|
147
|
+
: `local branch ${wt.branch} landed but could not be deleted automatically; run \`git -C ${wt.repo_root} branch -d ${wt.branch}\` after confirming it is merged.`;
|
|
148
|
+
return {
|
|
149
|
+
node_id: nodeId,
|
|
150
|
+
branch: wt.branch,
|
|
151
|
+
worktree_path: wt.path,
|
|
152
|
+
pushed_sha: pushedSha,
|
|
153
|
+
worktree_removed: worktreeRemoved,
|
|
154
|
+
...(worktreeRemoveError !== undefined ? { worktree_remove_error: worktreeRemoveError } : {}),
|
|
155
|
+
branch_deleted: branchDeleted,
|
|
156
|
+
...(branchDeleteError !== undefined ? { branch_delete_error: branchDeleteError } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|