@north-light/crouter 0.3.255 → 0.3.257
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/client.d.ts +13 -1
- package/dist/api/client.js +17 -0
- package/dist/api/dto/worktree.d.ts +17 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/clients/attach/viewer.js +936 -797
- package/dist/commands/api-client.js +4 -0
- package/dist/commands/sys/worktrees.d.ts +1 -0
- package/dist/commands/sys/worktrees.js +53 -0
- package/dist/commands/sys.js +2 -1
- package/dist/core/__tests__/integration/worktree-land.test.js +71 -1
- package/dist/core/__tests__/integration/worktree-reap.test.js +101 -0
- package/dist/core/__tests__/worktree-landing.test.d.ts +1 -0
- package/dist/core/__tests__/worktree-landing.test.js +11 -0
- package/dist/core/canvas/canvas.js +24 -8
- package/dist/core/canvas/pid.d.ts +6 -0
- package/dist/core/canvas/pid.js +48 -3
- package/dist/core/canvas/types.d.ts +41 -1
- package/dist/core/exclusive-lock.d.ts +6 -0
- package/dist/core/exclusive-lock.js +12 -0
- package/dist/core/git.d.ts +1 -1
- package/dist/core/git.js +5 -1
- package/dist/core/runtime/fleet.d.ts +6 -0
- package/dist/core/worktree-landing.d.ts +44 -0
- package/dist/core/worktree-landing.js +56 -0
- package/dist/core/worktree-quarantine.d.ts +19 -0
- package/dist/core/worktree-quarantine.js +54 -0
- package/dist/core/worktree-sweep.d.ts +60 -0
- package/dist/core/worktree-sweep.js +493 -0
- package/dist/core/worktree.d.ts +37 -0
- package/dist/core/worktree.js +149 -23
- package/dist/daemon/__tests__/startup-block-marker.test.d.ts +1 -0
- package/dist/daemon/__tests__/startup-block-marker.test.js +86 -0
- package/dist/daemon/api/handlers/worktree.js +5 -0
- package/dist/daemon/crtrd-cli.js +12 -7
- package/dist/daemon/crtrd.js +6 -1
- package/dist/daemon/fleet.d.ts +1 -0
- package/dist/daemon/fleet.js +3 -0
- package/dist/daemon/manage.js +22 -1
- package/dist/daemon/reconcilers/managed-worktree-sweep.d.ts +24 -0
- package/dist/daemon/reconcilers/managed-worktree-sweep.js +190 -0
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +11 -2
- package/dist/daemon/reconcilers/storage-maintenance.js +7 -2
- package/dist/daemon/startup-block-marker.d.ts +25 -0
- package/dist/daemon/startup-block-marker.js +99 -0
- package/dist/shared/generated-context.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -19,6 +19,7 @@ import { join } from 'node:path';
|
|
|
19
19
|
import { CrtrClient, ApiError } from '../api/index.js';
|
|
20
20
|
import { apiErrorToCliError } from '../core/io.js';
|
|
21
21
|
import { autostartDisabled, ensureDaemon } from '../daemon/manage.js';
|
|
22
|
+
import { daemonStartupBlocked } from '../daemon/startup-block-marker.js';
|
|
22
23
|
import { DAEMON_STARTUP_WINDOW_MS } from '../daemon/startup-policy.js';
|
|
23
24
|
import { readState } from '../core/config.js';
|
|
24
25
|
import { resolveRemoteCanvasTarget } from '../core/remote-canvas-target.js';
|
|
@@ -126,6 +127,9 @@ export function cliClient(opts) {
|
|
|
126
127
|
// On a cold-start timeout, enrich the bare `daemon_unavailable` with the
|
|
127
128
|
// tail of crtrd's event log instead of discarding it.
|
|
128
129
|
coldStartDiagnostic: readColdStartDiagnostic,
|
|
130
|
+
// A blocked startup is standing: every poll left in the window would report
|
|
131
|
+
// the same thing this already knows, so fail now and carry the repair.
|
|
132
|
+
coldStartAbort: daemonStartupBlocked,
|
|
129
133
|
coldStartPollWindowMs: DAEMON_STARTUP_WINDOW_MS,
|
|
130
134
|
});
|
|
131
135
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const sysWorktreesLeaf: import("../../core/command.js").LeafDef;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { defineLeaf } from '../../core/command.js';
|
|
2
|
+
import { cliClient, rethrowAsCliError } from '../api-client.js';
|
|
3
|
+
function formatSize(bytes) {
|
|
4
|
+
if (bytes === null || !Number.isFinite(bytes) || bytes < 0)
|
|
5
|
+
return 'unmeasurable';
|
|
6
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
7
|
+
let value = bytes;
|
|
8
|
+
let unit = 0;
|
|
9
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
10
|
+
value /= 1024;
|
|
11
|
+
unit++;
|
|
12
|
+
}
|
|
13
|
+
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
|
|
14
|
+
}
|
|
15
|
+
export const sysWorktreesLeaf = defineLeaf({
|
|
16
|
+
name: 'worktrees',
|
|
17
|
+
description: 'list managed worktrees quarantined by daemon cleanup',
|
|
18
|
+
whenToUse: 'daemon cleanup has stopped retrying managed worktrees it could not reconcile and you need the recorded refusal, remaining checkout, and disk cost for human intervention',
|
|
19
|
+
help: {
|
|
20
|
+
name: 'sys worktrees',
|
|
21
|
+
summary: 'list managed worktrees the daemon cleanup sweep quarantined',
|
|
22
|
+
params: [],
|
|
23
|
+
output: [
|
|
24
|
+
{ name: 'worktrees', type: 'QuarantinedWorktreeDTO[]', required: true, constraint: 'Oldest refusal first. Each record: {node_id, path, branch, repo_root, reason, detail?, attempts, last_attempt, size_bytes}; size_bytes is null when the checkout path is gone or cannot be measured.' },
|
|
25
|
+
],
|
|
26
|
+
outputKind: 'object',
|
|
27
|
+
effects: ['None. Read-only.'],
|
|
28
|
+
},
|
|
29
|
+
run: async () => {
|
|
30
|
+
try {
|
|
31
|
+
return { worktrees: await cliClient().listQuarantinedWorktrees() };
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
rethrowAsCliError(err);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
render: (result) => {
|
|
38
|
+
const worktrees = result['worktrees'];
|
|
39
|
+
if (worktrees.length === 0)
|
|
40
|
+
return '0 quarantined managed worktrees.';
|
|
41
|
+
return `${worktrees.length} quarantined managed worktree${worktrees.length === 1 ? '' : 's'}:\n\n${worktrees.map((worktree) => {
|
|
42
|
+
const details = [
|
|
43
|
+
`- \`${worktree.node_id}\` — ${worktree.reason} · ${formatSize(worktree.size_bytes)} · ${worktree.attempts} attempt${worktree.attempts === 1 ? '' : 's'} · ${worktree.last_attempt}`,
|
|
44
|
+
` - path: \`${worktree.path}\``,
|
|
45
|
+
` - branch: \`${worktree.branch}\``,
|
|
46
|
+
` - repo root: \`${worktree.repo_root}\``,
|
|
47
|
+
];
|
|
48
|
+
if (worktree.detail !== undefined)
|
|
49
|
+
details.push(` - detail: ${worktree.detail}`);
|
|
50
|
+
return details.join('\n');
|
|
51
|
+
}).join('\n')}`;
|
|
52
|
+
},
|
|
53
|
+
});
|
package/dist/commands/sys.js
CHANGED
|
@@ -5,6 +5,7 @@ import { daemonBranch } from './sys/daemon.js';
|
|
|
5
5
|
import { sysDoctorLeaf } from './sys/doctor.js';
|
|
6
6
|
import { sysFeedbackLeaf } from './sys/feedback.js';
|
|
7
7
|
import { sysLogsLeaf } from './sys/logs.js';
|
|
8
|
+
import { sysWorktreesLeaf } from './sys/worktrees.js';
|
|
8
9
|
import { sysSettingsLeaf } from './sys/settings.js';
|
|
9
10
|
import { sysSetupLeaf } from './sys/setup.js';
|
|
10
11
|
import { supportBranch } from './sys/support.js';
|
|
@@ -25,6 +26,6 @@ export function registerSys() {
|
|
|
25
26
|
summary: 'crtr system configuration, settings, diagnostics, and self-management',
|
|
26
27
|
model: 'Configuration and settings own durable behavior; diagnostics and support observe it. Setup, migration, sync, and update change installed or generated state, while daemon control owns the supervisor lifecycle.',
|
|
27
28
|
},
|
|
28
|
-
children: [sysSettingsLeaf, configBranch, sysSetupLeaf, sysDoctorLeaf, contextBranch, sysFeedbackLeaf, sysLogsLeaf, supportBranch, sysUpdateLeaf, sysVersionLeaf, sysMigrateLeaf, sysSyncBranch, daemonBranch],
|
|
29
|
+
children: [sysSettingsLeaf, configBranch, sysSetupLeaf, sysDoctorLeaf, contextBranch, sysFeedbackLeaf, sysLogsLeaf, sysWorktreesLeaf, supportBranch, sysUpdateLeaf, sysVersionLeaf, sysMigrateLeaf, sysSyncBranch, daemonBranch],
|
|
29
30
|
});
|
|
30
31
|
}
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
// parallel files; they share the fixture shape, not state.
|
|
5
5
|
import { test, before, after, beforeEach } from 'node:test';
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
|
-
import { mkdtempSync, mkdirSync, rmSync, existsSync, statSync, writeFileSync, chmodSync } from 'node:fs';
|
|
7
|
+
import { mkdtempSync, mkdirSync, rmSync, existsSync, statSync, writeFileSync, readFileSync, chmodSync } from 'node:fs';
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { gitSync } from '../../git.js';
|
|
12
12
|
import { createManagedWorktree, closeManagedWorktree, managedWorktreePath, rollbackManagedWorktree } from '../../worktree.js';
|
|
13
|
+
import { reconcileManagedWorktree } from '../../worktree-sweep.js';
|
|
13
14
|
import { createNode, getNode } from '../../canvas/canvas.js';
|
|
14
15
|
import { closeDb } from '../../canvas/db.js';
|
|
15
16
|
let home;
|
|
@@ -35,6 +36,10 @@ function node(id, over = {}) {
|
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
before(() => {
|
|
39
|
+
// NOT realpath'd on purpose: the macOS tmpdir is reached through the
|
|
40
|
+
// `/var` → `/private/var` symlink, so the recorded managed-worktree path and
|
|
41
|
+
// git's canonical `worktree list` output differ as strings while naming one
|
|
42
|
+
// directory. Every path-ownership check must judge identity, not spelling.
|
|
38
43
|
home = mkdtempSync(join(tmpdir(), 'crtr-worktree-home-'));
|
|
39
44
|
process.env['CRTR_HOME'] = home;
|
|
40
45
|
// Bare "remote" wired up as origin so the test can prove landing NEVER pushes
|
|
@@ -89,6 +94,71 @@ test('closeManagedWorktree leaves the active caller checkout intact with named d
|
|
|
89
94
|
assert.equal(meta?.cwd, wt.repo_root, 'a later revive uses the durable base checkout');
|
|
90
95
|
assert.equal(meta?.pi_session_id, null, 'the live transcript remains sole-writer-owned, so close invalidates its stale resume identity');
|
|
91
96
|
assert.equal(meta?.pi_session_file, null, 'a later revive launches fresh rather than loading the header that still names this checkout');
|
|
97
|
+
return reconcileManagedWorktree(nodeId, { stillEligible: () => true }).then((outcome) => {
|
|
98
|
+
assert.equal(outcome.status, 'complete', 'the daemon-owned sweep consumes pending cleanup after the caller exits');
|
|
99
|
+
assert.equal(existsSync(wt.path), false, 'the sweep removes the deferred caller checkout');
|
|
100
|
+
assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 1, 'the sweep deletes the now-contained managed branch');
|
|
101
|
+
const swept = getNode(nodeId)?.managed_worktree;
|
|
102
|
+
assert.deepEqual({ state: swept?.state, cleanup: swept?.cleanup }, { state: 'closed', cleanup: 'complete' });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
test('close lands underneath disjoint tracked changes in the base checkout', () => {
|
|
106
|
+
const nodeId = 'wt-close-disjoint-base-dirt';
|
|
107
|
+
writeFileSync(join(sourceDir, 'base-local.txt'), 'base version\n');
|
|
108
|
+
run(['add', 'base-local.txt'], sourceDir);
|
|
109
|
+
run(['commit', '-m', 'base local file'], sourceDir);
|
|
110
|
+
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
111
|
+
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
112
|
+
writeFileSync(join(wt.path, 'incoming-disjoint.txt'), 'child work\n');
|
|
113
|
+
run(['add', 'incoming-disjoint.txt'], wt.path);
|
|
114
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'disjoint child work'], wt.path);
|
|
115
|
+
writeFileSync(join(sourceDir, 'base-local.txt'), 'base local edit\n');
|
|
116
|
+
const result = closeManagedWorktree(nodeId);
|
|
117
|
+
assert.equal(gitSync(['rev-parse', 'main'], sourceDir).stdout.trim(), result.landed_sha, 'the child commit fast-forwards main despite disjoint local dirt');
|
|
118
|
+
assert.equal(readFileSync(join(sourceDir, 'base-local.txt'), 'utf8'), 'base local edit\n', 'the base checkout local edit survives untouched');
|
|
119
|
+
assert.match(gitSync(['status', '--porcelain'], sourceDir).stdout, /M base-local.txt/, 'the base local edit remains uncommitted');
|
|
120
|
+
assert.equal(gitSync(['rev-parse', '--verify', '--quiet', 'refs/stash'], sourceDir).status, 1, 'disjoint changes require no stash');
|
|
121
|
+
run(['reset', '--hard'], sourceDir);
|
|
122
|
+
});
|
|
123
|
+
test('close stashes and restores overlapping tracked changes without leaving a stash', () => {
|
|
124
|
+
const nodeId = 'wt-close-overlap-base-dirt';
|
|
125
|
+
// The child commit and the base local edit touch the SAME file (so landing
|
|
126
|
+
// takes the stash path) but in far-apart regions, so the stash re-apply
|
|
127
|
+
// merges cleanly rather than conflicting.
|
|
128
|
+
const original = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n') + '\n';
|
|
129
|
+
writeFileSync(join(sourceDir, 'overlap.txt'), original);
|
|
130
|
+
run(['add', 'overlap.txt'], sourceDir);
|
|
131
|
+
run(['commit', '-m', 'overlap base file'], sourceDir);
|
|
132
|
+
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
133
|
+
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
134
|
+
const childEdit = original.replace('line 1\n', 'line 1 child-changed\n');
|
|
135
|
+
writeFileSync(join(wt.path, 'overlap.txt'), childEdit);
|
|
136
|
+
run(['add', 'overlap.txt'], wt.path);
|
|
137
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'overlap child work'], wt.path);
|
|
138
|
+
const baseEdit = original.replace('line 20\n', 'line 20 base-changed\n');
|
|
139
|
+
writeFileSync(join(sourceDir, 'overlap.txt'), baseEdit);
|
|
140
|
+
const result = closeManagedWorktree(nodeId);
|
|
141
|
+
assert.equal(gitSync(['rev-parse', 'main'], sourceDir).stdout.trim(), result.landed_sha, 'the child commit fast-forwards main under the local edit');
|
|
142
|
+
const landed = childEdit.replace('line 20\n', 'line 20 base-changed\n');
|
|
143
|
+
assert.equal(readFileSync(join(sourceDir, 'overlap.txt'), 'utf8'), landed, 'the disjoint local edit is restored over the landed child change');
|
|
144
|
+
assert.equal(result.base_checkout_stash, undefined, 'a clean stash apply leaves no preserved-stash note');
|
|
145
|
+
assert.match(gitSync(['status', '--porcelain'], sourceDir).stdout, /M overlap.txt/, 'the restored base edit remains uncommitted');
|
|
146
|
+
assert.equal(gitSync(['rev-parse', '--verify', '--quiet', 'refs/stash'], sourceDir).status, 1, 'a clean stash apply drops the temporary stash');
|
|
147
|
+
run(['reset', '--hard'], sourceDir);
|
|
148
|
+
});
|
|
149
|
+
test('close refuses an incoming path that collides with an untracked base file', () => {
|
|
150
|
+
const nodeId = 'wt-close-untracked-collision';
|
|
151
|
+
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
152
|
+
createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
|
|
153
|
+
writeFileSync(join(wt.path, 'incoming-collision.txt'), 'child work\n');
|
|
154
|
+
run(['add', 'incoming-collision.txt'], wt.path);
|
|
155
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'incoming collision'], wt.path);
|
|
156
|
+
writeFileSync(join(sourceDir, 'incoming-collision.txt'), 'base untracked work\n');
|
|
157
|
+
assert.throws(() => closeManagedWorktree(nodeId), (error) => error instanceof Error && 'code' in error && error.code === 'land_base_failed', 'close must refuse rather than overwrite an untracked base file');
|
|
158
|
+
assert.equal(readFileSync(join(sourceDir, 'incoming-collision.txt'), 'utf8'), 'base untracked work\n', 'the base untracked file remains intact');
|
|
159
|
+
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'a refused land remains unreconciled');
|
|
160
|
+
assert.ok(getNode(nodeId)?.managed_worktree?.land_intent, 'the blocked close records the intent the sweep may later finish');
|
|
161
|
+
rmSync(join(sourceDir, 'incoming-collision.txt'));
|
|
92
162
|
});
|
|
93
163
|
test('two concurrent creates from linked worktrees serialize on their shared repository', async () => {
|
|
94
164
|
const worktreeModule = new URL('../../worktree.ts', import.meta.url).href;
|
|
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { gitSync } from '../../git.js';
|
|
11
11
|
import { createManagedWorktree, closeManagedWorktree } from '../../worktree.js';
|
|
12
|
+
import { reconcileManagedWorktree } from '../../worktree-sweep.js';
|
|
12
13
|
import { createApiServer } from '../../../daemon/api/server.js';
|
|
13
14
|
import { apiSocketPath } from '../../canvas/paths.js';
|
|
14
15
|
import { CrtrClient } from '../../../api/index.js';
|
|
@@ -37,6 +38,10 @@ function node(id, over = {}) {
|
|
|
37
38
|
};
|
|
38
39
|
}
|
|
39
40
|
before(() => {
|
|
41
|
+
// NOT realpath'd on purpose: the macOS tmpdir is reached through the
|
|
42
|
+
// `/var` → `/private/var` symlink, so the recorded managed-worktree path and
|
|
43
|
+
// git's canonical `worktree list` output differ as strings while naming one
|
|
44
|
+
// directory. Every path-ownership check must judge identity, not spelling.
|
|
40
45
|
home = mkdtempSync(join(tmpdir(), 'crtr-worktree-home-'));
|
|
41
46
|
process.env['CRTR_HOME'] = home;
|
|
42
47
|
// Bare "remote" wired up as origin so the test can prove landing NEVER pushes
|
|
@@ -85,6 +90,102 @@ test('autoDropCleanManagedWorktree drops a clean, zero-commit worktree', async (
|
|
|
85
90
|
assert.equal(meta?.cwd, wt.repo_root, 'auto-dropped node cwd repoints to the durable base checkout');
|
|
86
91
|
assert.equal(gitSync(['rev-parse', '--git-dir'], meta.cwd).status, 0, 'repointed cwd resolves to a real git checkout');
|
|
87
92
|
assert.ok(existsSync(join(wt.path, '.git')), 'the intact caller worktree remains a git checkout');
|
|
93
|
+
const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
|
|
94
|
+
assert.equal(outcome.status, 'complete', 'the daemon-owned sweep consumes pending cleanup after the caller exits');
|
|
95
|
+
assert.equal(existsSync(wt.path), false, 'the sweep removes the deferred caller checkout');
|
|
96
|
+
assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 1, 'the sweep deletes the clean managed branch');
|
|
97
|
+
const swept = getNode(nodeId)?.managed_worktree;
|
|
98
|
+
assert.deepEqual({ state: swept?.state, cleanup: swept?.cleanup }, { state: 'closed', cleanup: 'complete' });
|
|
99
|
+
});
|
|
100
|
+
test('the sweep refuses a managed checkout with uncommitted changes', async () => {
|
|
101
|
+
const nodeId = 'wt-sweep-dirty';
|
|
102
|
+
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
103
|
+
createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
|
|
104
|
+
writeFileSync(join(wt.path, 'uncommitted.txt'), 'must survive\n');
|
|
105
|
+
const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
|
|
106
|
+
assert.equal(outcome.status, 'refused');
|
|
107
|
+
assert.equal(outcome.status === 'refused' ? outcome.reason : undefined, 'dirty_worktree');
|
|
108
|
+
assert.ok(existsSync(join(wt.path, 'uncommitted.txt')), 'the uncommitted file remains in the checkout');
|
|
109
|
+
assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 0, 'the managed branch remains');
|
|
110
|
+
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'the record remains unreconciled');
|
|
111
|
+
});
|
|
112
|
+
// The lock the sweep holds serializes worktree operations, NOT node lifecycle:
|
|
113
|
+
// a revive can land in any await between the containment proof and the removal.
|
|
114
|
+
// So eligibility is re-asked immediately before every destructive step, and a
|
|
115
|
+
// node that came back keeps the checkout it is now running in.
|
|
116
|
+
test('the sweep stops before removing a checkout whose node revived mid-pass', async () => {
|
|
117
|
+
const nodeId = 'wt-sweep-revived-mid-pass';
|
|
118
|
+
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
119
|
+
createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
|
|
120
|
+
// True on the first ask (the pass starts), false afterwards (the revive won).
|
|
121
|
+
// Everything about this worktree is otherwise disposable: it is clean and its
|
|
122
|
+
// tip is its base, so ONLY the recheck can save it.
|
|
123
|
+
let asks = 0;
|
|
124
|
+
const outcome = await reconcileManagedWorktree(nodeId, {
|
|
125
|
+
stillEligible: () => {
|
|
126
|
+
asks += 1;
|
|
127
|
+
return asks === 1;
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
assert.ok(asks > 1, 'eligibility is re-asked before the destructive steps, not once per pass');
|
|
131
|
+
assert.equal(outcome.status, 'skipped');
|
|
132
|
+
assert.equal(outcome.status === 'skipped' ? outcome.reason : undefined, 'node_no_longer_terminal');
|
|
133
|
+
assert.ok(existsSync(join(wt.path, '.git')), 'the revived node keeps the checkout it is running in');
|
|
134
|
+
assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 0, 'its branch survives');
|
|
135
|
+
const record = getNode(nodeId)?.managed_worktree;
|
|
136
|
+
assert.equal(record?.state, 'open', 'the record is untouched, so a later pass can resume it');
|
|
137
|
+
assert.equal(record?.sweep, undefined, 'a revived node is not charged a refusal or a backoff');
|
|
138
|
+
});
|
|
139
|
+
test('the sweep refuses unlanded commits when close never recorded land intent', async () => {
|
|
140
|
+
const nodeId = 'wt-sweep-no-land-intent';
|
|
141
|
+
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
142
|
+
createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
|
|
143
|
+
writeFileSync(join(wt.path, 'unlanded.txt'), 'must survive\n');
|
|
144
|
+
run(['add', 'unlanded.txt'], wt.path);
|
|
145
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'unlanded work'], wt.path);
|
|
146
|
+
const branchSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
|
|
147
|
+
const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
|
|
148
|
+
assert.equal(outcome.status, 'refused');
|
|
149
|
+
assert.equal(outcome.status === 'refused' ? outcome.reason : undefined, 'unlanded_work');
|
|
150
|
+
assert.ok(existsSync(wt.path), 'the checkout remains available for a human');
|
|
151
|
+
assert.equal(gitSync(['rev-parse', wt.branch], sourceDir).stdout.trim(), branchSha, 'the unlanded branch remains intact');
|
|
152
|
+
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'the record remains unreconciled');
|
|
153
|
+
});
|
|
154
|
+
test('the sweep removes a rebased branch whose patches already landed', async () => {
|
|
155
|
+
const nodeId = 'wt-sweep-patch-identity';
|
|
156
|
+
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
157
|
+
createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
|
|
158
|
+
writeFileSync(join(wt.path, 'patch-identity.txt'), 'same content\n');
|
|
159
|
+
run(['add', 'patch-identity.txt'], wt.path);
|
|
160
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'child patch'], wt.path);
|
|
161
|
+
const childSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
|
|
162
|
+
writeFileSync(join(sourceDir, 'patch-identity.txt'), 'same content\n');
|
|
163
|
+
run(['add', 'patch-identity.txt'], sourceDir);
|
|
164
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'landed independently'], sourceDir);
|
|
165
|
+
assert.notEqual(gitSync(['rev-parse', 'main'], sourceDir).stdout.trim(), childSha, 'the independently landed patch has a different commit identity');
|
|
166
|
+
const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
|
|
167
|
+
assert.equal(outcome.status, 'complete', 'patch identity proves the rebased branch content is already present');
|
|
168
|
+
assert.equal(existsSync(wt.path), false, 'the redundant checkout is removed');
|
|
169
|
+
assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 1, 'the redundant branch is deleted');
|
|
170
|
+
assert.equal(getNode(nodeId)?.managed_worktree?.cleanup, 'complete');
|
|
171
|
+
});
|
|
172
|
+
test('the sweep removes a squash-merged branch whose merge is a content no-op', async () => {
|
|
173
|
+
const nodeId = 'wt-sweep-content-noop';
|
|
174
|
+
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
175
|
+
createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
|
|
176
|
+
writeFileSync(join(wt.path, 'squash-one.txt'), 'one\n');
|
|
177
|
+
run(['add', 'squash-one.txt'], wt.path);
|
|
178
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'first child patch'], wt.path);
|
|
179
|
+
writeFileSync(join(wt.path, 'squash-two.txt'), 'two\n');
|
|
180
|
+
run(['add', 'squash-two.txt'], wt.path);
|
|
181
|
+
run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'second child patch'], wt.path);
|
|
182
|
+
run(['merge', '--squash', wt.branch], sourceDir);
|
|
183
|
+
run(['commit', '-m', 'squash landed child work'], sourceDir);
|
|
184
|
+
const outcome = await reconcileManagedWorktree(nodeId, { stillEligible: () => true });
|
|
185
|
+
assert.equal(outcome.status, 'complete', 'a content-no-op merge proves squash-landed work is safe to remove');
|
|
186
|
+
assert.equal(existsSync(wt.path), false, 'the redundant checkout is removed');
|
|
187
|
+
assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], sourceDir).status, 1, 'the squash-landed branch is deleted');
|
|
188
|
+
assert.equal(getNode(nodeId)?.managed_worktree?.cleanup, 'complete');
|
|
88
189
|
});
|
|
89
190
|
// The same real-commit case close already protects: a worktree with actual
|
|
90
191
|
// unlanded work must NEVER be auto-dropped by push-final.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseStatusPorcelain, planLandIntoCheckout } from '../worktree-landing.js';
|
|
4
|
+
test('parseStatusPorcelain separates tracked modifications, untracked files, and rename destinations', () => {
|
|
5
|
+
assert.deepEqual(parseStatusPorcelain(' M modified.txt\nM staged.txt\nR old-name.txt -> new-name.txt\n?? untracked.txt\n!! ignored.txt\n'), { tracked: ['modified.txt', 'staged.txt', 'new-name.txt'], untracked: ['untracked.txt'] });
|
|
6
|
+
});
|
|
7
|
+
test('planLandIntoCheckout selects direct landing, a tracked-change stash, or an untracked collision refusal', () => {
|
|
8
|
+
assert.deepEqual(planLandIntoCheckout({ tracked: ['local.txt'], untracked: ['scratch.txt'] }, ['incoming.txt']), { kind: 'direct' });
|
|
9
|
+
assert.deepEqual(planLandIntoCheckout({ tracked: ['local.txt'], untracked: ['scratch.txt'] }, ['local.txt']), { kind: 'stash', overlapping: ['local.txt'] });
|
|
10
|
+
assert.deepEqual(planLandIntoCheckout({ tracked: ['local.txt'], untracked: ['scratch.txt'] }, ['scratch.txt']), { kind: 'refuse', collisions: ['scratch.txt'] });
|
|
11
|
+
});
|
|
@@ -836,16 +836,32 @@ export function rebuildIndex() {
|
|
|
836
836
|
recordSpawn(meta.node_id, prov);
|
|
837
837
|
}
|
|
838
838
|
}
|
|
839
|
-
/** True when this node's identity is still required to own a managed checkout.
|
|
840
|
-
|
|
841
|
-
|
|
839
|
+
/** True when this node's identity is still required to own a managed checkout.
|
|
840
|
+
*
|
|
841
|
+
* Ownership outlives the OPEN state. A closed record whose cleanup is still
|
|
842
|
+
* pending names a checkout and a branch that are physically still there, and
|
|
843
|
+
* this node record is the only proof of who they belong to — deleting it turns
|
|
844
|
+
* a retained residual into an ownerless orphan the daemon sweep can never
|
|
845
|
+
* safely reclaim, because it must never guess at a worktree it cannot prove it
|
|
846
|
+
* created. So prune may take this node only once cleanup is COMPLETE. A
|
|
847
|
+
* historical closed record written before the cleanup field existed carries no
|
|
848
|
+
* such proof and counts as incomplete.
|
|
849
|
+
*
|
|
850
|
+
* An abandoned record is deliberately NOT protected: abandon already removed
|
|
851
|
+
* the checkout and retains its branch for a person on purpose, so there is no
|
|
852
|
+
* residual for the sweep to own. */
|
|
853
|
+
function hasUnreconciledManagedWorktree(nodeId) {
|
|
854
|
+
const wt = getNode(nodeId)?.managed_worktree;
|
|
855
|
+
if (wt == null || wt.state === 'abandoned')
|
|
856
|
+
return false;
|
|
857
|
+
return wt.state === 'open' || wt.cleanup !== 'complete';
|
|
842
858
|
}
|
|
843
859
|
/** Whether a row may be removed by an automatic collection sweep. */
|
|
844
860
|
export function isCollectible(row, opts = {}) {
|
|
845
861
|
if (!['done', 'dead', 'canceled'].includes(row.status)
|
|
846
862
|
|| row.pi_pid !== null
|
|
847
863
|
|| row.frozen_at !== null
|
|
848
|
-
||
|
|
864
|
+
|| hasUnreconciledManagedWorktree(row.node_id)
|
|
849
865
|
|| row.node_id === envNodeId())
|
|
850
866
|
return false;
|
|
851
867
|
return opts.pastTtl === true || !['crashed', 'stranded', null].includes(row.terminal_reason ?? null);
|
|
@@ -860,7 +876,7 @@ export function isCollectible(row, opts = {}) {
|
|
|
860
876
|
* state are removed in one canvas write boundary. Best-effort dir removal
|
|
861
877
|
* follows the commit. */
|
|
862
878
|
export function deleteNode(nodeId) {
|
|
863
|
-
if (
|
|
879
|
+
if (hasUnreconciledManagedWorktree(nodeId))
|
|
864
880
|
return { deleted: false };
|
|
865
881
|
const result = withCanvasWrite((db) => {
|
|
866
882
|
db.prepare('DELETE FROM nodes WHERE node_id = ?').run(nodeId);
|
|
@@ -875,9 +891,9 @@ export function deleteNodes(nodeIds) {
|
|
|
875
891
|
const ids = [...new Set(nodeIds)];
|
|
876
892
|
if (ids.length === 0)
|
|
877
893
|
return [];
|
|
878
|
-
const blocked = ids.filter(
|
|
894
|
+
const blocked = ids.filter(hasUnreconciledManagedWorktree);
|
|
879
895
|
if (blocked.length > 0) {
|
|
880
|
-
throw new Error(`refusing to delete nodes with
|
|
896
|
+
throw new Error(`refusing to delete nodes with unreconciled managed worktrees: ${blocked.join(', ')}`);
|
|
881
897
|
}
|
|
882
898
|
const placeholders = ids.map(() => '?').join(',');
|
|
883
899
|
const deleted = withCanvasWrite((db) => {
|
|
@@ -941,7 +957,7 @@ export function pruneNodes(opts) {
|
|
|
941
957
|
status: r['status'],
|
|
942
958
|
created: r['created'],
|
|
943
959
|
}))
|
|
944
|
-
.filter((node) => !
|
|
960
|
+
.filter((node) => !hasUnreconciledManagedWorktree(node.node_id));
|
|
945
961
|
const candidates = [...terminal, ...stale];
|
|
946
962
|
if (dryRun || candidates.length === 0)
|
|
947
963
|
return { pruned: candidates, dryRun };
|
|
@@ -153,6 +153,12 @@ export interface PsLivenessSnapshot {
|
|
|
153
153
|
childrenOf: ReadonlyMap<number, number[]>;
|
|
154
154
|
}
|
|
155
155
|
export declare function captureLivenessSnapshot(): PsLivenessSnapshot | null;
|
|
156
|
+
/** The same sample without blocking the event loop, for callers running inside
|
|
157
|
+
* crtrd's detached work: one `ps` read classifies every pid they care about,
|
|
158
|
+
* where a per-pid `spawnSync` probe would stall broker supervision and the API
|
|
159
|
+
* socket for the duration of each probe. `null` means the probe failed, which
|
|
160
|
+
* every caller must preserve as unknown rather than absence. */
|
|
161
|
+
export declare function captureLivenessSnapshotAsync(): Promise<PsLivenessSnapshot | null>;
|
|
156
162
|
/** Every transitive descendant of `rootPid`, discovered by walking `ps`'s
|
|
157
163
|
* pid/ppid table (BFS) — NOT process-GROUP membership. The pi SDK's bash tool
|
|
158
164
|
* spawns its shell child with
|
package/dist/core/canvas/pid.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// above runtime/). It is a pure process-existence utility, NOT data-model access,
|
|
7
7
|
// so it is exempt from the "only canvas.ts touches the db" rule. canvas.ts,
|
|
8
8
|
// revive.ts, placement.ts, and crtrd.ts all probe liveness through it.
|
|
9
|
-
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
10
10
|
import { readFileSync } from 'node:fs';
|
|
11
11
|
import { readKernelBootId } from './boot-id.js';
|
|
12
12
|
/** Classify the current `ps` row for a pid. BSD ps and procps-ng both report a
|
|
@@ -330,12 +330,16 @@ export function composeIdentity(pid, lstart, deps = {}) {
|
|
|
330
330
|
const bootId = deps.bootId !== undefined ? deps.bootId : bootIdBase();
|
|
331
331
|
return `${bootId ?? lstart}#${ticks}`;
|
|
332
332
|
}
|
|
333
|
+
/** The `ps` invocation both snapshot captures share, so the synchronous and
|
|
334
|
+
* asynchronous forms can never sample different columns. */
|
|
335
|
+
const PS_SNAPSHOT_ARGS = ['-Ao', 'pid=,ppid=,stat=,lstart=,command='];
|
|
336
|
+
const PS_SNAPSHOT_TIMEOUT_MS = 2000;
|
|
333
337
|
export function captureLivenessSnapshot() {
|
|
334
338
|
let stdout;
|
|
335
339
|
try {
|
|
336
|
-
const r = spawnSync('ps', [
|
|
340
|
+
const r = spawnSync('ps', [...PS_SNAPSHOT_ARGS], {
|
|
337
341
|
encoding: 'utf8',
|
|
338
|
-
timeout:
|
|
342
|
+
timeout: PS_SNAPSHOT_TIMEOUT_MS,
|
|
339
343
|
maxBuffer: 8 * 1024 * 1024,
|
|
340
344
|
});
|
|
341
345
|
if (r.status !== 0 || typeof r.stdout !== 'string')
|
|
@@ -345,6 +349,47 @@ export function captureLivenessSnapshot() {
|
|
|
345
349
|
catch {
|
|
346
350
|
return null;
|
|
347
351
|
}
|
|
352
|
+
return parseLivenessTable(stdout);
|
|
353
|
+
}
|
|
354
|
+
/** The same sample without blocking the event loop, for callers running inside
|
|
355
|
+
* crtrd's detached work: one `ps` read classifies every pid they care about,
|
|
356
|
+
* where a per-pid `spawnSync` probe would stall broker supervision and the API
|
|
357
|
+
* socket for the duration of each probe. `null` means the probe failed, which
|
|
358
|
+
* every caller must preserve as unknown rather than absence. */
|
|
359
|
+
export async function captureLivenessSnapshotAsync() {
|
|
360
|
+
const stdout = await new Promise((resolve) => {
|
|
361
|
+
let child;
|
|
362
|
+
try {
|
|
363
|
+
child = spawn('ps', [...PS_SNAPSHOT_ARGS]);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return resolve(null);
|
|
367
|
+
}
|
|
368
|
+
let out = '';
|
|
369
|
+
let settled = false;
|
|
370
|
+
const settle = (value) => {
|
|
371
|
+
if (settled)
|
|
372
|
+
return;
|
|
373
|
+
settled = true;
|
|
374
|
+
clearTimeout(timer);
|
|
375
|
+
resolve(value);
|
|
376
|
+
};
|
|
377
|
+
const timer = setTimeout(() => {
|
|
378
|
+
try {
|
|
379
|
+
child.kill('SIGKILL');
|
|
380
|
+
}
|
|
381
|
+
catch { /* already gone */ }
|
|
382
|
+
settle(null);
|
|
383
|
+
}, PS_SNAPSHOT_TIMEOUT_MS);
|
|
384
|
+
timer.unref?.();
|
|
385
|
+
child.stdout.on('data', (d) => (out += d.toString()));
|
|
386
|
+
child.stderr.on('data', () => { });
|
|
387
|
+
child.on('error', () => settle(null));
|
|
388
|
+
child.on('close', (status) => settle(status === 0 ? out : null));
|
|
389
|
+
});
|
|
390
|
+
return stdout === null ? null : parseLivenessTable(stdout);
|
|
391
|
+
}
|
|
392
|
+
function parseLivenessTable(stdout) {
|
|
348
393
|
const processes = new Map();
|
|
349
394
|
const childrenOf = new Map();
|
|
350
395
|
for (const line of stdout.split('\n')) {
|
|
@@ -48,10 +48,41 @@ export interface LaunchSpec {
|
|
|
48
48
|
/** Extra env injected into the pi process. */
|
|
49
49
|
env: Record<string, string>;
|
|
50
50
|
}
|
|
51
|
+
/** The repository fingerprint a refusal was decided against. An examination
|
|
52
|
+
* that reads the same three values can skip the whole proof cascade: nothing
|
|
53
|
+
* about the refusal can have changed. */
|
|
54
|
+
export interface ManagedWorktreeObservation {
|
|
55
|
+
/** Tip of `refs/heads/<base_ref>`, or null when it no longer resolves. */
|
|
56
|
+
base: string | null;
|
|
57
|
+
/** Tip of `refs/heads/<branch>`, or null when the branch is gone. */
|
|
58
|
+
branch: string | null;
|
|
59
|
+
/** `absent`, or a hash of the checkout's HEAD plus `git status --porcelain`. */
|
|
60
|
+
checkout: string | null;
|
|
61
|
+
}
|
|
62
|
+
/** Reconciliation bookkeeping owned SOLELY by the daemon's managed-worktree
|
|
63
|
+
* sweep. Present only while a record is not yet reconciled; cleared when
|
|
64
|
+
* cleanup completes. */
|
|
65
|
+
export interface ManagedWorktreeSweep {
|
|
66
|
+
/** Structured code for why the last pass did not complete cleanup. */
|
|
67
|
+
reason: string;
|
|
68
|
+
detail?: string;
|
|
69
|
+
observed: ManagedWorktreeObservation;
|
|
70
|
+
/** Consecutive passes that examined this record without completing it. */
|
|
71
|
+
attempts: number;
|
|
72
|
+
last_attempt: string;
|
|
73
|
+
/** The sweep does not re-examine this record before this instant. */
|
|
74
|
+
next_attempt: string;
|
|
75
|
+
/** Set once `attempts` reaches the terminal backoff tier: this record needs a
|
|
76
|
+
* human, and `crtr sys worktrees` lists it with its reason. It is still
|
|
77
|
+
* fingerprint-checked at the capped interval, so work that lands later is
|
|
78
|
+
* still cleaned up. */
|
|
79
|
+
quarantined?: true;
|
|
80
|
+
}
|
|
51
81
|
export interface ManagedWorktree {
|
|
52
82
|
state: 'open' | 'closed' | 'abandoned';
|
|
53
83
|
/** A closed worktree remains registered until its owner has exited, so Git
|
|
54
|
-
* never deletes the caller's live checkout beneath it.
|
|
84
|
+
* never deletes the caller's live checkout beneath it. The daemon sweep is
|
|
85
|
+
* the one authority that turns `pending` into `complete`. */
|
|
55
86
|
cleanup?: 'pending' | 'complete';
|
|
56
87
|
path: string;
|
|
57
88
|
branch: string;
|
|
@@ -66,6 +97,15 @@ export interface ManagedWorktree {
|
|
|
66
97
|
at: string;
|
|
67
98
|
by: string;
|
|
68
99
|
};
|
|
100
|
+
/** Recorded by `close` when the rebase succeeded but fast-forwarding the base
|
|
101
|
+
* was blocked. The sweep lands a dead node's commits ONLY for a record
|
|
102
|
+
* carrying this: the daemon finishes a job someone started, it never invents
|
|
103
|
+
* the intent to land. */
|
|
104
|
+
land_intent?: {
|
|
105
|
+
at: string;
|
|
106
|
+
blocked: string;
|
|
107
|
+
};
|
|
108
|
+
sweep?: ManagedWorktreeSweep;
|
|
69
109
|
}
|
|
70
110
|
/** Immutable companion provenance. The origin session/leaf/model coordinates
|
|
71
111
|
* remain canonical in the review row rather than being duplicated into node
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** The pid currently holding this lock, read straight off the marker name (the
|
|
2
|
+
* token opens with the owner's pid). Null when the lock is free, unowned, or
|
|
3
|
+
* names a token we cannot parse. Lets a caller tell "my child is doing the
|
|
4
|
+
* work" apart from "my child is queued behind someone else's work" without
|
|
5
|
+
* any new bookkeeping. */
|
|
6
|
+
export declare function exclusiveLockOwnerPid(path: string): number | null;
|
|
1
7
|
export interface ExclusiveLockOptions {
|
|
2
8
|
/** Grace before a lock that names no owner at all is cleared. */
|
|
3
9
|
staleMs?: number;
|
|
@@ -91,6 +91,18 @@ function reclaimIfOwnerGone(path, staleMs) {
|
|
|
91
91
|
throw error;
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
/** The pid currently holding this lock, read straight off the marker name (the
|
|
95
|
+
* token opens with the owner's pid). Null when the lock is free, unowned, or
|
|
96
|
+
* names a token we cannot parse. Lets a caller tell "my child is doing the
|
|
97
|
+
* work" apart from "my child is queued behind someone else's work" without
|
|
98
|
+
* any new bookkeeping. */
|
|
99
|
+
export function exclusiveLockOwnerPid(path) {
|
|
100
|
+
const token = observedMarkerToken(path);
|
|
101
|
+
if (token === null)
|
|
102
|
+
return null;
|
|
103
|
+
const pid = Number(token.split('.', 1)[0]);
|
|
104
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
105
|
+
}
|
|
94
106
|
function tryAcquire(path, staleMs) {
|
|
95
107
|
const token = `${process.pid}.${randomUUID()}`;
|
|
96
108
|
try {
|
package/dist/core/git.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface GitSyncOptions {
|
|
|
11
11
|
timeoutMs?: number;
|
|
12
12
|
}
|
|
13
13
|
export declare function gitSync(args: string[], cwd?: string, input?: string, options?: GitSyncOptions): GitResult;
|
|
14
|
-
export declare function gitAsync(args: string[], cwd?: string): Promise<GitResult>;
|
|
14
|
+
export declare function gitAsync(args: string[], cwd?: string, input?: string): Promise<GitResult>;
|
|
15
15
|
export declare function clone(url: string, dest: string, opts?: {
|
|
16
16
|
ref?: string;
|
|
17
17
|
depth?: number;
|
package/dist/core/git.js
CHANGED
|
@@ -10,9 +10,13 @@ export function gitSync(args, cwd, input, options = {}) {
|
|
|
10
10
|
const timedOut = res.error?.code === 'ETIMEDOUT';
|
|
11
11
|
return { status, stdout, stderr, ...(completed ? {} : { executionFailed: true }), ...(processError === undefined ? {} : { processError }), ...(timedOut ? { timedOut: true } : {}) };
|
|
12
12
|
}
|
|
13
|
-
export async function gitAsync(args, cwd) {
|
|
13
|
+
export async function gitAsync(args, cwd, input) {
|
|
14
14
|
return new Promise((resolve) => {
|
|
15
15
|
const child = spawn('git', args, { cwd });
|
|
16
|
+
if (input === undefined)
|
|
17
|
+
child.stdin.end();
|
|
18
|
+
else
|
|
19
|
+
child.stdin.end(input);
|
|
16
20
|
let stdout = '';
|
|
17
21
|
let stderr = '';
|
|
18
22
|
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
@@ -37,6 +37,12 @@ export interface FleetRegistry {
|
|
|
37
37
|
register(nodeId: string, handle: HostHandle): void;
|
|
38
38
|
/** The reviveNode double-launch guard: a fleet entry IS liveness. */
|
|
39
39
|
has(nodeId: string): boolean;
|
|
40
|
+
/** True while ANY launch claim exists for this node — a registered broker OR
|
|
41
|
+
* an in-flight reservation. `has` alone reads false during the window
|
|
42
|
+
* between `reserveSlot` and registration, so a caller asking "could this
|
|
43
|
+
* node come back to life right now?" (the managed-worktree sweep, before it
|
|
44
|
+
* destroys a checkout) must ask this instead. */
|
|
45
|
+
isClaimed(nodeId: string): boolean;
|
|
40
46
|
/** Live-fleet checks pass (yield-stall, wedge, fatal-fault). */
|
|
41
47
|
get(nodeId: string): FleetEntry | undefined;
|
|
42
48
|
/** The capacity count: registered brokers only, excluding reservations. */
|