@triflux/remote 10.0.1 → 10.25.1
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/hub/pipe.mjs +239 -121
- package/hub/public/dashboard.html +68 -7
- package/hub/server.mjs +2181 -701
- package/hub/store-adapter.mjs +101 -37
- package/hub/store.mjs +356 -153
- package/hub/team/agent-map.json +26 -7
- package/hub/team/ansi.mjs +245 -146
- package/hub/team/backend.mjs +84 -17
- package/hub/team/build-worker-prompt.mjs +42 -0
- package/hub/team/check-mcp-hub.mjs +49 -0
- package/hub/team/claude-daemon-control.mjs +219 -0
- package/hub/team/claude-native-bridge.mjs +784 -0
- package/hub/team/claude-session-projection.mjs +65 -0
- package/hub/team/cli/commands/attach.mjs +57 -37
- package/hub/team/cli/commands/control.mjs +29 -8
- package/hub/team/cli/commands/debug.mjs +95 -74
- package/hub/team/cli/commands/focus.mjs +80 -53
- package/hub/team/cli/commands/interrupt.mjs +18 -6
- package/hub/team/cli/commands/kill.mjs +16 -5
- package/hub/team/cli/commands/list.mjs +31 -24
- package/hub/team/cli/commands/send.mjs +19 -6
- package/hub/team/cli/commands/start/index.mjs +154 -33
- package/hub/team/cli/commands/start/parse-args.mjs +89 -12
- package/hub/team/cli/commands/start/start-headless.mjs +127 -37
- package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
- package/hub/team/cli/commands/start/start-mux.mjs +122 -73
- package/hub/team/cli/commands/start/start-wt.mjs +86 -69
- package/hub/team/cli/commands/status.mjs +43 -14
- package/hub/team/cli/commands/stop.mjs +11 -4
- package/hub/team/cli/commands/task.mjs +8 -3
- package/hub/team/cli/commands/tasks.mjs +13 -13
- package/hub/team/cli/index.mjs +2 -2
- package/hub/team/cli/manifest.mjs +38 -8
- package/hub/team/cli/render.mjs +52 -30
- package/hub/team/cli/services/attach-fallback.mjs +74 -54
- package/hub/team/cli/services/hub-client.mjs +45 -17
- package/hub/team/cli/services/member-selector.mjs +37 -30
- package/hub/team/cli/services/native-control.mjs +144 -116
- package/hub/team/cli/services/runtime-mode.mjs +55 -1
- package/hub/team/cli/services/state-store.mjs +66 -9
- package/hub/team/cli/services/task-model.mjs +40 -30
- package/hub/team/codex-review.mjs +521 -0
- package/hub/team/conductor-mesh-bridge.mjs +25 -24
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +1379 -671
- package/hub/team/consensus-meta.mjs +176 -0
- package/hub/team/dashboard-anchor.mjs +4 -5
- package/hub/team/dashboard-layout.mjs +3 -1
- package/hub/team/dashboard-open.mjs +21 -119
- package/hub/team/dashboard.mjs +76 -28
- package/hub/team/event-log.mjs +18 -10
- package/hub/team/execution-mode.mjs +241 -0
- package/hub/team/extract-completion-payload.mjs +110 -0
- package/hub/team/git-preflight.mjs +286 -0
- package/hub/team/handoff.mjs +49 -18
- package/hub/team/headless-bridge-session.mjs +17 -0
- package/hub/team/headless.mjs +2065 -1156
- package/hub/team/health-probe.mjs +232 -69
- package/hub/team/launcher-template.mjs +23 -13
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/native-supervisor.mjs +90 -26
- package/hub/team/native.mjs +74 -35
- package/hub/team/nativeProxy.mjs +184 -113
- package/hub/team/notify.mjs +147 -78
- package/hub/team/orchestrator.mjs +173 -161
- package/hub/team/pane.mjs +43 -15
- package/hub/team/process-cleanup.mjs +69 -25
- package/hub/team/psmux.mjs +1067 -264
- package/hub/team/recovery-store.mjs +149 -0
- package/hub/team/remote-probe.mjs +69 -53
- package/hub/team/remote-session.mjs +131 -59
- package/hub/team/remote-watcher.mjs +62 -34
- package/hub/team/retry-state-machine.mjs +267 -0
- package/hub/team/routing.mjs +51 -25
- package/hub/team/runtime-strategy.mjs +200 -0
- package/hub/team/schemas/worker-completion.json +39 -0
- package/hub/team/sentinel-capture.mjs +127 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/session.mjs +605 -611
- package/hub/team/shared.mjs +13 -13
- package/hub/team/staleState.mjs +72 -30
- package/hub/team/swarm-cli.mjs +390 -0
- package/hub/team/swarm-hypervisor.mjs +1526 -198
- package/hub/team/swarm-intent.mjs +216 -0
- package/hub/team/swarm-locks.mjs +140 -25
- package/hub/team/swarm-planner.mjs +121 -21
- package/hub/team/swarm-preflight.mjs +317 -0
- package/hub/team/swarm-reconciler.mjs +128 -23
- package/hub/team/synapse-cli.mjs +458 -0
- package/hub/team/synapse-http.mjs +60 -0
- package/hub/team/synapse-registry.mjs +329 -0
- package/hub/team/terminal-opener.mjs +216 -0
- package/hub/team/tui-core.mjs +288 -0
- package/hub/team/tui-lite.mjs +234 -146
- package/hub/team/tui-remote-adapter.mjs +21 -37
- package/hub/team/tui-synapse.mjs +223 -0
- package/hub/team/tui-viewer.mjs +104 -45
- package/hub/team/tui-widgets.mjs +262 -0
- package/hub/team/tui.mjs +936 -449
- package/hub/team/worker-completion-validator.mjs +84 -0
- package/hub/team/worker-sandbox.mjs +93 -0
- package/hub/team/worker-signal.mjs +263 -0
- package/hub/team/worker-signal.types.d.ts +52 -0
- package/hub/team/worktree-lifecycle.mjs +518 -64
- package/hub/team/wt-manager.mjs +297 -26
- package/hub/tools.mjs +474 -252
- package/hub/tray.mjs +411 -368
- package/hub/workers/claude-worker.mjs +162 -118
- package/hub/workers/codex-app-server-worker.mjs +1015 -0
- package/hub/workers/codex-mcp.mjs +724 -507
- package/hub/workers/delegator-mcp.mjs +663 -337
- package/hub/workers/factory.mjs +100 -21
- package/hub/workers/gemini-worker.mjs +179 -86
- package/hub/workers/interface.mjs +23 -2
- package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
- package/hub/workers/worker-utils.mjs +22 -16
- package/package.json +4 -3
- package/scripts/lib/agent-json.mjs +27 -0
- package/scripts/lib/async.mjs +174 -0
- package/scripts/lib/claudemd-scanner.mjs +223 -0
- package/scripts/lib/cli-agy.mjs +62 -0
- package/scripts/lib/cli-claude.mjs +78 -0
- package/scripts/lib/cli-codex.mjs +199 -0
- package/scripts/lib/cli-gemini.mjs +67 -0
- package/scripts/lib/codex-recovery.sh +50 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +59 -0
- package/scripts/lib/doctor-env-checks.mjs +121 -0
- package/scripts/lib/dynamic-route-cli.mjs +107 -0
- package/scripts/lib/env-probe.mjs +269 -0
- package/scripts/lib/env.mjs +14 -0
- package/scripts/lib/gemini-profiles.mjs +119 -0
- package/scripts/lib/handoff.mjs +187 -0
- package/scripts/lib/hook-utils.mjs +16 -0
- package/scripts/lib/hub.mjs +48 -0
- package/scripts/lib/keyword-rules.mjs +195 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +883 -0
- package/scripts/lib/mcp-gateway-servers.mjs +70 -0
- package/scripts/lib/mcp-guard-engine.mjs +2120 -0
- package/scripts/lib/mcp-health.mjs +773 -0
- package/scripts/lib/mcp-manifest.mjs +89 -0
- package/scripts/lib/mcp-server-catalog.mjs +355 -0
- package/scripts/lib/pid.mjs +63 -0
- package/scripts/lib/process-utils.mjs +26 -0
- package/scripts/lib/psmux-info.mjs +179 -0
- package/scripts/lib/quota.mjs +47 -0
- package/scripts/lib/remote-spawn-transfer.mjs +226 -0
- package/scripts/lib/skill-state.mjs +220 -0
- package/scripts/lib/skill-template.mjs +375 -0
- package/scripts/lib/team.mjs +75 -0
- package/scripts/lib/timeout.mjs +66 -0
- package/scripts/lib/tmp.mjs +26 -0
- package/scripts/lib/toml.mjs +113 -0
|
@@ -3,34 +3,168 @@
|
|
|
3
3
|
// Convention: .codex-swarm/wt-{slug} paths, swarm/{runId}/{slug} branches.
|
|
4
4
|
// Remote support: host option → SSH-based git operations via remote-session.mjs.
|
|
5
5
|
|
|
6
|
-
import { execFile } from
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { remoteGit, validateHost } from
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { access, mkdir, readdir, realpath, rm } from "node:fs/promises";
|
|
8
|
+
import { normalize, relative, resolve } from "node:path";
|
|
9
|
+
import { remoteGit, validateHost } from "./remote-session.mjs";
|
|
10
10
|
|
|
11
|
-
const SWARM_ROOT =
|
|
11
|
+
const SWARM_ROOT = ".codex-swarm";
|
|
12
12
|
const SLEEP_MS = 2000; // WT race-guard (MEMORY.md: wt-attach-spacing)
|
|
13
13
|
|
|
14
|
+
// Tracked files intentionally removed from swarm worktrees. Keep this list
|
|
15
|
+
// narrow: .claude-plugin metadata must be preserved for Codex worker startup.
|
|
16
|
+
export const EXPECTED_WORKTREE_DELETIONS = Object.freeze([]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse `git status --short` output into a dirty-file list. Filters out paths
|
|
20
|
+
* in `expectedDeletions` only when the XY status code indicates a deletion
|
|
21
|
+
* (X='D' or Y='D'). Modifications / additions / untracked of the same paths
|
|
22
|
+
* remain dirty — otherwise a worker could silently corrupt those files and
|
|
23
|
+
* bypass F6 no_commit_guard (Codex review #134 round 2).
|
|
24
|
+
*
|
|
25
|
+
* git status --short XY codes reference:
|
|
26
|
+
* ' D' unstaged delete, 'D ' staged delete, 'DD' both — filtered if path
|
|
27
|
+
* is in expectedDeletions.
|
|
28
|
+
* ' M'/'M '/'MM' modify, '??' untracked, 'A ' add, 'R ' rename — kept.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} rawStatus — stdout of `git status --short`
|
|
31
|
+
* @param {string[]} [expectedDeletions=EXPECTED_WORKTREE_DELETIONS] — paths eligible for deletion-only skip
|
|
32
|
+
* @returns {string[]} — remaining dirty paths after filtering
|
|
33
|
+
*/
|
|
34
|
+
export function extractDirtyFiles(
|
|
35
|
+
rawStatus,
|
|
36
|
+
expectedDeletions = EXPECTED_WORKTREE_DELETIONS,
|
|
37
|
+
) {
|
|
38
|
+
const skip = new Set(expectedDeletions);
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const raw of String(rawStatus ?? "").split(/\r?\n/)) {
|
|
41
|
+
if (raw.length < 3) continue;
|
|
42
|
+
const xy = raw.slice(0, 2);
|
|
43
|
+
const path = raw.slice(2).trim();
|
|
44
|
+
if (!path) continue;
|
|
45
|
+
const isDeletion = xy.includes("D");
|
|
46
|
+
if (isDeletion && skip.has(path)) continue;
|
|
47
|
+
out.push(path);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
14
52
|
function git(args, cwd) {
|
|
15
53
|
return new Promise((res, rej) => {
|
|
16
|
-
execFile(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
54
|
+
execFile(
|
|
55
|
+
"git",
|
|
56
|
+
args,
|
|
57
|
+
{
|
|
58
|
+
cwd,
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
timeout: 30_000,
|
|
61
|
+
env: {
|
|
62
|
+
...process.env,
|
|
63
|
+
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || "Triflux",
|
|
64
|
+
GIT_AUTHOR_EMAIL:
|
|
65
|
+
process.env.GIT_AUTHOR_EMAIL || "triflux@example.invalid",
|
|
66
|
+
GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || "Triflux",
|
|
67
|
+
GIT_COMMITTER_EMAIL:
|
|
68
|
+
process.env.GIT_COMMITTER_EMAIL || "triflux@example.invalid",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
(err, stdout, stderr) => {
|
|
72
|
+
if (err) {
|
|
73
|
+
const msg = `git ${args[0]} failed: ${stderr?.trim() || err.message}`;
|
|
74
|
+
rej(new Error(msg));
|
|
75
|
+
} else {
|
|
76
|
+
res(stdout.trim());
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
);
|
|
24
80
|
});
|
|
25
81
|
}
|
|
26
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Best-effort shutdown for the Git fsmonitor daemon attached to a worktree.
|
|
85
|
+
* Missing or already-stopped daemons are treated as successful no-ops because
|
|
86
|
+
* cleanup should not be blocked by watcher state.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} worktreePath
|
|
89
|
+
* @param {(args: string[], cwd: string) => Promise<string>} [gitImpl=git]
|
|
90
|
+
* @returns {Promise<{ ok: boolean, stopped: boolean, reason?: 'not_running', error?: string }>}
|
|
91
|
+
*/
|
|
92
|
+
async function stopFsmonitorDaemon(worktreePath, gitImpl = git) {
|
|
93
|
+
try {
|
|
94
|
+
await gitImpl(["fsmonitor--daemon", "stop"], worktreePath);
|
|
95
|
+
return { ok: true, stopped: true };
|
|
96
|
+
} catch (err) {
|
|
97
|
+
const msg = String(err?.message || err || "");
|
|
98
|
+
if (
|
|
99
|
+
/not running|not watching|fsmonitor--daemon is not running/iu.test(msg)
|
|
100
|
+
) {
|
|
101
|
+
return { ok: true, stopped: false, reason: "not_running" };
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, stopped: false, error: msg };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
27
107
|
function sleep(ms) {
|
|
28
108
|
return new Promise((r) => setTimeout(r, ms));
|
|
29
109
|
}
|
|
30
110
|
|
|
31
111
|
/** Normalize path for Windows compatibility. */
|
|
32
112
|
function normPath(p) {
|
|
33
|
-
return normalize(p).replace(/\\/g,
|
|
113
|
+
return normalize(p).replace(/\\/g, "/");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function normExistingPath(p) {
|
|
117
|
+
try {
|
|
118
|
+
return normPath(await realpath(p));
|
|
119
|
+
} catch {
|
|
120
|
+
return normPath(p);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolveCleanupTarget(worktreePath, rootDir) {
|
|
125
|
+
const resolvedRoot = resolve(rootDir);
|
|
126
|
+
const resolvedWorktree = resolve(worktreePath);
|
|
127
|
+
const normalizedRoot = normPath(resolvedRoot).replace(/\/+$/u, "");
|
|
128
|
+
const normalizedWorktree = normPath(resolvedWorktree).replace(/\/+$/u, "");
|
|
129
|
+
|
|
130
|
+
if (normalizedWorktree === normalizedRoot) {
|
|
131
|
+
throw new Error("refusing to cleanup the main working tree");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const rel = normPath(relative(resolvedRoot, resolvedWorktree));
|
|
135
|
+
if (
|
|
136
|
+
!rel ||
|
|
137
|
+
rel === "." ||
|
|
138
|
+
rel === ".." ||
|
|
139
|
+
rel.startsWith("../") ||
|
|
140
|
+
rel.includes("/../")
|
|
141
|
+
) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`refusing to cleanup path outside rootDir: ${worktreePath}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const allowed =
|
|
148
|
+
rel.startsWith(".codex-swarm/wt-") || rel.startsWith(".triflux/");
|
|
149
|
+
if (!allowed) {
|
|
150
|
+
throw new Error(`refusing to cleanup unsafe path: ${worktreePath}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
resolvedRoot,
|
|
155
|
+
resolvedWorktree,
|
|
156
|
+
normalizedWorktree,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function branchExists(branchName, rootDir, gitImpl = git) {
|
|
161
|
+
if (!branchName) return false;
|
|
162
|
+
try {
|
|
163
|
+
const listed = await gitImpl(["branch", "--list", branchName], rootDir);
|
|
164
|
+
return listed.trim().length > 0;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
34
168
|
}
|
|
35
169
|
|
|
36
170
|
/**
|
|
@@ -45,22 +179,45 @@ function normPath(p) {
|
|
|
45
179
|
* @param {object} [opts.remoteEnv] — remote environment from probeRemoteEnv()
|
|
46
180
|
* @returns {Promise<{ worktreePath: string, branchName: string, remote: boolean }>}
|
|
47
181
|
*/
|
|
48
|
-
export async function ensureWorktree({
|
|
182
|
+
export async function ensureWorktree({
|
|
183
|
+
slug,
|
|
184
|
+
runId,
|
|
185
|
+
rootDir = process.cwd(),
|
|
186
|
+
baseBranch = "main",
|
|
187
|
+
host,
|
|
188
|
+
remoteEnv,
|
|
189
|
+
}) {
|
|
49
190
|
const branchName = `swarm/${runId}/${slug}`;
|
|
50
191
|
|
|
51
192
|
// ── Remote path: SSH-based worktree creation ──
|
|
52
193
|
if (host && remoteEnv) {
|
|
53
|
-
const remoteRoot = rootDir.replace(/\\/g,
|
|
194
|
+
const remoteRoot = rootDir.replace(/\\/g, "/");
|
|
54
195
|
const remoteWtDir = `${remoteRoot}/${SWARM_ROOT}/wt-${slug}`;
|
|
55
196
|
|
|
56
|
-
try {
|
|
197
|
+
try {
|
|
198
|
+
remoteGit(host, remoteEnv, ["worktree", "prune"], remoteRoot);
|
|
199
|
+
} catch {
|
|
200
|
+
/* best-effort */
|
|
201
|
+
}
|
|
57
202
|
|
|
58
203
|
try {
|
|
59
|
-
remoteGit(
|
|
204
|
+
remoteGit(
|
|
205
|
+
host,
|
|
206
|
+
remoteEnv,
|
|
207
|
+
["worktree", "add", remoteWtDir, "-b", branchName, baseBranch],
|
|
208
|
+
remoteRoot,
|
|
209
|
+
);
|
|
60
210
|
} catch {
|
|
61
211
|
try {
|
|
62
|
-
remoteGit(
|
|
63
|
-
|
|
212
|
+
remoteGit(
|
|
213
|
+
host,
|
|
214
|
+
remoteEnv,
|
|
215
|
+
["worktree", "add", remoteWtDir, branchName],
|
|
216
|
+
remoteRoot,
|
|
217
|
+
);
|
|
218
|
+
} catch {
|
|
219
|
+
/* already exists — acceptable */
|
|
220
|
+
}
|
|
64
221
|
}
|
|
65
222
|
|
|
66
223
|
return { worktreePath: remoteWtDir, branchName, remote: true };
|
|
@@ -74,22 +231,27 @@ export async function ensureWorktree({ slug, runId, rootDir = process.cwd(), bas
|
|
|
74
231
|
// Check if worktree already exists
|
|
75
232
|
try {
|
|
76
233
|
await access(wtDir);
|
|
77
|
-
await git([
|
|
234
|
+
await git(["rev-parse", "--is-inside-work-tree"], wtDir);
|
|
78
235
|
return { worktreePath: normPath(wtDir), branchName, remote: false };
|
|
79
236
|
} catch {
|
|
80
237
|
// Doesn't exist or invalid — create fresh
|
|
81
238
|
}
|
|
82
239
|
|
|
83
240
|
try {
|
|
84
|
-
await git([
|
|
85
|
-
} catch {
|
|
241
|
+
await git(["worktree", "prune"], rootDir);
|
|
242
|
+
} catch {
|
|
243
|
+
/* best-effort */
|
|
244
|
+
}
|
|
86
245
|
|
|
87
246
|
await sleep(SLEEP_MS);
|
|
88
247
|
|
|
89
248
|
try {
|
|
90
|
-
await git(
|
|
249
|
+
await git(
|
|
250
|
+
["worktree", "add", wtDir, "-b", branchName, baseBranch],
|
|
251
|
+
rootDir,
|
|
252
|
+
);
|
|
91
253
|
} catch {
|
|
92
|
-
await git([
|
|
254
|
+
await git(["worktree", "add", wtDir, branchName], rootDir);
|
|
93
255
|
}
|
|
94
256
|
|
|
95
257
|
return { worktreePath: normPath(wtDir), branchName, remote: false };
|
|
@@ -104,18 +266,22 @@ export async function ensureWorktree({ slug, runId, rootDir = process.cwd(), bas
|
|
|
104
266
|
* @param {string} [opts.rootDir=process.cwd()]
|
|
105
267
|
* @returns {Promise<{ integrationBranch: string, baseCommit: string }>}
|
|
106
268
|
*/
|
|
107
|
-
export async function prepareIntegrationBranch({
|
|
269
|
+
export async function prepareIntegrationBranch({
|
|
270
|
+
runId,
|
|
271
|
+
baseBranch,
|
|
272
|
+
rootDir = process.cwd(),
|
|
273
|
+
}) {
|
|
108
274
|
const integrationBranch = `swarm/${runId}/merge`;
|
|
109
275
|
|
|
110
276
|
// Record base commit for rollback
|
|
111
|
-
const baseCommit = await git([
|
|
277
|
+
const baseCommit = await git(["rev-parse", baseBranch], rootDir);
|
|
112
278
|
|
|
113
279
|
// Create integration branch from base
|
|
114
280
|
try {
|
|
115
|
-
await git([
|
|
281
|
+
await git(["branch", integrationBranch, baseBranch], rootDir);
|
|
116
282
|
} catch {
|
|
117
283
|
// Branch may already exist — reset to base
|
|
118
|
-
await git([
|
|
284
|
+
await git(["branch", "-f", integrationBranch, baseBranch], rootDir);
|
|
119
285
|
}
|
|
120
286
|
|
|
121
287
|
return { integrationBranch, baseCommit };
|
|
@@ -125,71 +291,349 @@ export async function prepareIntegrationBranch({ runId, baseBranch, rootDir = pr
|
|
|
125
291
|
* Rebase a shard branch onto the integration branch.
|
|
126
292
|
* Uses rebase + fast-forward only (no merge commits).
|
|
127
293
|
*
|
|
294
|
+
* Synapse v1: when a `preflight` checker + `sessionContext` are provided,
|
|
295
|
+
* the rebase is pre-flighted against active sessions' dirty files/leases.
|
|
296
|
+
* Blocked rebases return `{ ok: false, preflight: decision }` without touching
|
|
297
|
+
* any branch. If preflight is omitted, behavior is unchanged.
|
|
298
|
+
*
|
|
128
299
|
* @param {object} opts
|
|
129
300
|
* @param {string} opts.shardBranch — the shard's branch name
|
|
130
301
|
* @param {string} opts.integrationBranch — target integration branch
|
|
131
302
|
* @param {string} [opts.rootDir=process.cwd()]
|
|
132
|
-
* @
|
|
303
|
+
* @param {{ checkRebase: Function } | null} [opts.preflight=null] — git-preflight instance
|
|
304
|
+
* @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
|
|
305
|
+
* @returns {Promise<{ ok: boolean, headCommit?: string, strategy?: string, commits?: number, notes?: string, fallbackReason?: string, error?: string, preflight?: object }>}
|
|
133
306
|
*/
|
|
134
|
-
export async function rebaseShardOntoIntegration({
|
|
307
|
+
export async function rebaseShardOntoIntegration({
|
|
308
|
+
shardBranch,
|
|
309
|
+
integrationBranch,
|
|
310
|
+
rootDir = process.cwd(),
|
|
311
|
+
preflight = null,
|
|
312
|
+
sessionContext = null,
|
|
313
|
+
}) {
|
|
314
|
+
// Synapse v1 pre-flight: bail out before mutating branches when another
|
|
315
|
+
// session claims overlapping files.
|
|
316
|
+
if (preflight && sessionContext) {
|
|
317
|
+
const decision = preflight.checkRebase(
|
|
318
|
+
{ branch: integrationBranch },
|
|
319
|
+
sessionContext,
|
|
320
|
+
);
|
|
321
|
+
if (!decision.allowed) {
|
|
322
|
+
return {
|
|
323
|
+
ok: false,
|
|
324
|
+
error: `git-preflight blocked rebase: ${decision.reason || "overlap"}`,
|
|
325
|
+
preflight: decision,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// #127 BUG-E: capture caller's branch BEFORE any checkout. Without
|
|
331
|
+
// restoring it in finally, this function leaks main repo HEAD onto
|
|
332
|
+
// integrationBranch and every subsequent Edit/commit lands on the swarm
|
|
333
|
+
// temp branch silently (observed 2026-04-20: probe + edits + commits all
|
|
334
|
+
// ended up on swarm/.../merge while user thought they were on main).
|
|
335
|
+
let originalBranch = null;
|
|
336
|
+
try {
|
|
337
|
+
const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], rootDir);
|
|
338
|
+
if (head && head !== "HEAD") originalBranch = head;
|
|
339
|
+
} catch {
|
|
340
|
+
/* detached HEAD or unknown — skip restore */
|
|
341
|
+
}
|
|
342
|
+
|
|
135
343
|
// Backup integration HEAD for rollback
|
|
136
|
-
const backupCommit = await git([
|
|
344
|
+
const backupCommit = await git(["rev-parse", integrationBranch], rootDir);
|
|
345
|
+
|
|
346
|
+
let shaList = [];
|
|
347
|
+
let ffError = null;
|
|
137
348
|
|
|
138
349
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
350
|
+
const log = await git(
|
|
351
|
+
[
|
|
352
|
+
"log",
|
|
353
|
+
"--reverse",
|
|
354
|
+
"--format=%H",
|
|
355
|
+
`${integrationBranch}..${shardBranch}`,
|
|
356
|
+
],
|
|
357
|
+
rootDir,
|
|
358
|
+
);
|
|
359
|
+
shaList = log
|
|
360
|
+
.split("\n")
|
|
361
|
+
.map((s) => s.trim())
|
|
362
|
+
.filter(Boolean);
|
|
363
|
+
|
|
364
|
+
await git(["checkout", integrationBranch], rootDir);
|
|
141
365
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
366
|
+
try {
|
|
367
|
+
await git(["merge", "--ff-only", shardBranch], rootDir);
|
|
368
|
+
const headCommit = await git(["rev-parse", "HEAD"], rootDir);
|
|
369
|
+
return {
|
|
370
|
+
ok: true,
|
|
371
|
+
headCommit,
|
|
372
|
+
strategy: "auto-ff",
|
|
373
|
+
commits: shaList.length,
|
|
374
|
+
notes: "linear (merge --ff-only)",
|
|
375
|
+
};
|
|
376
|
+
} catch (err) {
|
|
377
|
+
ffError = err;
|
|
378
|
+
try {
|
|
379
|
+
await git(["merge", "--abort"], rootDir);
|
|
380
|
+
} catch {
|
|
381
|
+
/* ff-only usually leaves no merge state */
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
await git(["reset", "--hard", backupCommit], rootDir);
|
|
385
|
+
} catch {
|
|
386
|
+
/* fallback below will report if cherry-pick also fails */
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// #127: cherry-pick fallback instead of branch merge. Rebase/ff paths can
|
|
391
|
+
// fail when histories diverge or a shard branch is checked out by a swarm
|
|
392
|
+
// worktree. Cherry-pick reads commits without moving the shard branch ref.
|
|
393
|
+
for (const sha of shaList) {
|
|
394
|
+
await git(["cherry-pick", sha], rootDir);
|
|
395
|
+
}
|
|
145
396
|
|
|
146
|
-
const headCommit = await git([
|
|
147
|
-
return {
|
|
397
|
+
const headCommit = await git(["rev-parse", "HEAD"], rootDir);
|
|
398
|
+
return {
|
|
399
|
+
ok: true,
|
|
400
|
+
headCommit,
|
|
401
|
+
strategy: "cherry-pick",
|
|
402
|
+
commits: shaList.length,
|
|
403
|
+
fallbackReason: ffError?.message || null,
|
|
404
|
+
notes: ffError
|
|
405
|
+
? `auto-ff failed: ${ffError.message}`
|
|
406
|
+
: "cherry-pick fallback",
|
|
407
|
+
};
|
|
148
408
|
} catch (err) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
409
|
+
try {
|
|
410
|
+
await git(["cherry-pick", "--abort"], rootDir);
|
|
411
|
+
} catch {
|
|
412
|
+
/* already clean */
|
|
413
|
+
}
|
|
153
414
|
|
|
154
|
-
|
|
415
|
+
// #129 BUG-J: Roll back integrationBranch without mutating whatever
|
|
416
|
+
// branch HEAD currently points at. The previous implementation did a
|
|
417
|
+
// blind `reset --hard backupCommit` on current HEAD — if the earlier
|
|
418
|
+
// `git checkout integrationBranch` inside the try block failed (e.g.
|
|
419
|
+
// integrationBranch was already checked out by a swarm worktree) the
|
|
420
|
+
// caller's branch silently rewound to integrationBranch's backup commit.
|
|
421
|
+
// Observed 2026-04-20: fix branch tree lost to main HEAD during swarm
|
|
422
|
+
// probe, requiring `git merge --ff-only origin/<branch>` recovery.
|
|
423
|
+
let currentBranch = null;
|
|
424
|
+
try {
|
|
425
|
+
const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], rootDir);
|
|
426
|
+
if (head && head !== "HEAD") currentBranch = head;
|
|
427
|
+
} catch {
|
|
428
|
+
/* detached — fall through to ref-only update */
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (currentBranch === integrationBranch) {
|
|
432
|
+
// HEAD is on integrationBranch — reset advances this branch only.
|
|
433
|
+
try {
|
|
434
|
+
await git(["reset", "--hard", backupCommit], rootDir);
|
|
435
|
+
} catch {
|
|
436
|
+
/* best-effort */
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
// HEAD is elsewhere (originalBranch, detached, or another branch).
|
|
440
|
+
// Update the integrationBranch ref directly; never touch current HEAD.
|
|
441
|
+
try {
|
|
442
|
+
await git(["branch", "-f", integrationBranch, backupCommit], rootDir);
|
|
443
|
+
} catch {
|
|
444
|
+
/* best-effort: branch may be checked out in another worktree */
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
ok: false,
|
|
450
|
+
error: err.message,
|
|
451
|
+
strategy: "failed",
|
|
452
|
+
commits: shaList.length,
|
|
453
|
+
fallbackReason: ffError?.message || null,
|
|
454
|
+
};
|
|
455
|
+
} finally {
|
|
456
|
+
// Always restore caller's branch. Skip if originalBranch is null
|
|
457
|
+
// (detached HEAD case) or already on target.
|
|
458
|
+
if (originalBranch) {
|
|
459
|
+
try {
|
|
460
|
+
await git(["checkout", originalBranch], rootDir);
|
|
461
|
+
} catch {
|
|
462
|
+
/* best-effort — caller will see HEAD on integrationBranch */
|
|
463
|
+
}
|
|
464
|
+
}
|
|
155
465
|
}
|
|
156
466
|
}
|
|
157
467
|
|
|
158
468
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
469
|
+
* Safely remove a worktree directory and delete its branch.
|
|
470
|
+
* Refuses to touch the main working tree or paths outside known swarm state.
|
|
161
471
|
*
|
|
162
472
|
* @param {object} opts
|
|
163
473
|
* @param {string} opts.worktreePath
|
|
164
|
-
* @param {string} [opts.branchName]
|
|
474
|
+
* @param {string} [opts.branchName]
|
|
165
475
|
* @param {string} [opts.rootDir=process.cwd()]
|
|
166
476
|
* @param {boolean} [opts.force=false]
|
|
167
477
|
*/
|
|
168
|
-
export async function
|
|
169
|
-
|
|
478
|
+
export async function cleanupWorktree({
|
|
479
|
+
worktreePath,
|
|
480
|
+
branchName,
|
|
481
|
+
rootDir = process.cwd(),
|
|
482
|
+
force = false,
|
|
483
|
+
_git = git,
|
|
484
|
+
}) {
|
|
485
|
+
const { resolvedWorktree } = resolveCleanupTarget(worktreePath, rootDir);
|
|
486
|
+
const fsmonitorStop = await stopFsmonitorDaemon(resolvedWorktree, _git);
|
|
487
|
+
const forceArgs = force ? ["--force"] : [];
|
|
170
488
|
|
|
171
|
-
// Remove worktree (with retry for Windows file handle issues — E5)
|
|
172
489
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
173
490
|
try {
|
|
174
|
-
await
|
|
491
|
+
await _git(
|
|
492
|
+
["worktree", "remove", ...forceArgs, resolvedWorktree],
|
|
493
|
+
rootDir,
|
|
494
|
+
);
|
|
175
495
|
break;
|
|
176
|
-
} catch (
|
|
496
|
+
} catch (_err) {
|
|
177
497
|
if (attempt === 2) {
|
|
178
|
-
|
|
179
|
-
|
|
498
|
+
try {
|
|
499
|
+
await rm(resolvedWorktree, { recursive: true, force: true });
|
|
500
|
+
} catch {
|
|
501
|
+
/* ignore */
|
|
502
|
+
}
|
|
180
503
|
}
|
|
181
504
|
await sleep(SLEEP_MS);
|
|
182
505
|
}
|
|
183
506
|
}
|
|
184
507
|
|
|
185
|
-
// Prune stale worktree references
|
|
186
508
|
await sleep(SLEEP_MS);
|
|
187
|
-
try {
|
|
509
|
+
try {
|
|
510
|
+
await _git(["worktree", "prune"], rootDir);
|
|
511
|
+
} catch {
|
|
512
|
+
/* best-effort */
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (branchName && (await branchExists(branchName, rootDir, _git))) {
|
|
516
|
+
try {
|
|
517
|
+
await _git(["branch", "-D", branchName], rootDir);
|
|
518
|
+
} catch {
|
|
519
|
+
/* branch may already be gone */
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return { ok: true, fsmonitorStop };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Remove a worktree and its branch.
|
|
528
|
+
* Follows WT race-guard: sleep between operations.
|
|
529
|
+
*
|
|
530
|
+
* Synapse v1: when `preflight` + `sessionContext` are provided, the removal
|
|
531
|
+
* is pre-flighted against active sessions — blocked if another session is
|
|
532
|
+
* still registered to this worktree path.
|
|
533
|
+
*
|
|
534
|
+
* @param {object} opts
|
|
535
|
+
* @param {string} opts.worktreePath
|
|
536
|
+
* @param {string} [opts.branchName] — optional branch to delete
|
|
537
|
+
* @param {string} [opts.rootDir=process.cwd()]
|
|
538
|
+
* @param {boolean} [opts.force=false]
|
|
539
|
+
* @param {{ checkWorktreeRemove: Function } | null} [opts.preflight=null]
|
|
540
|
+
* @param {{ sessionId: string, workerId?: string } | null} [opts.sessionContext=null]
|
|
541
|
+
*/
|
|
542
|
+
export async function pruneWorktree({
|
|
543
|
+
worktreePath,
|
|
544
|
+
branchName,
|
|
545
|
+
rootDir = process.cwd(),
|
|
546
|
+
force = false,
|
|
547
|
+
preflight = null,
|
|
548
|
+
sessionContext = null,
|
|
549
|
+
}) {
|
|
550
|
+
if (preflight && sessionContext) {
|
|
551
|
+
const decision = preflight.checkWorktreeRemove(
|
|
552
|
+
{ worktreePath },
|
|
553
|
+
sessionContext,
|
|
554
|
+
);
|
|
555
|
+
if (!decision.allowed) {
|
|
556
|
+
return {
|
|
557
|
+
ok: false,
|
|
558
|
+
error: `git-preflight blocked worktree-remove: ${decision.reason || "active_worktree"}`,
|
|
559
|
+
preflight: decision,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const cleanupResult = await cleanupWorktree({
|
|
565
|
+
worktreePath,
|
|
566
|
+
branchName,
|
|
567
|
+
rootDir,
|
|
568
|
+
force,
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
return { ok: true, fsmonitorStop: cleanupResult.fsmonitorStop };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* #34 L3: Detect and remove orphan worktree directories.
|
|
576
|
+
* Compares .codex-swarm/wt-* directories against `git worktree list`.
|
|
577
|
+
* Directories not registered as worktrees are removed.
|
|
578
|
+
*
|
|
579
|
+
* @param {object} [opts]
|
|
580
|
+
* @param {string} [opts.rootDir=process.cwd()]
|
|
581
|
+
* @returns {Promise<string[]>} removed directory names
|
|
582
|
+
*/
|
|
583
|
+
export async function pruneOrphanWorktrees({ rootDir = process.cwd() } = {}) {
|
|
584
|
+
const swarmDir = resolve(rootDir, SWARM_ROOT);
|
|
585
|
+
const removed = [];
|
|
586
|
+
|
|
587
|
+
let entries;
|
|
588
|
+
try {
|
|
589
|
+
entries = await readdir(swarmDir);
|
|
590
|
+
} catch {
|
|
591
|
+
return removed; // .codex-swarm/ doesn't exist → nothing to clean
|
|
592
|
+
}
|
|
188
593
|
|
|
189
|
-
|
|
190
|
-
if (
|
|
191
|
-
|
|
594
|
+
const wtDirs = entries.filter((e) => e.startsWith("wt-"));
|
|
595
|
+
if (wtDirs.length === 0) return removed;
|
|
596
|
+
|
|
597
|
+
// Get registered worktree paths from git
|
|
598
|
+
let registeredPaths;
|
|
599
|
+
try {
|
|
600
|
+
const raw = await git(["worktree", "list", "--porcelain"], rootDir);
|
|
601
|
+
registeredPaths = new Set(
|
|
602
|
+
await Promise.all(
|
|
603
|
+
raw
|
|
604
|
+
.split("\n")
|
|
605
|
+
.filter((l) => l.startsWith("worktree "))
|
|
606
|
+
.map((l) => normExistingPath(l.slice("worktree ".length))),
|
|
607
|
+
),
|
|
608
|
+
);
|
|
609
|
+
} catch {
|
|
610
|
+
return removed; // git worktree list failed → don't remove anything
|
|
192
611
|
}
|
|
612
|
+
|
|
613
|
+
for (const dir of wtDirs) {
|
|
614
|
+
const fullPath = resolve(swarmDir, dir);
|
|
615
|
+
const normalized = await normExistingPath(fullPath);
|
|
616
|
+
if (!registeredPaths.has(normalized)) {
|
|
617
|
+
try {
|
|
618
|
+
await stopFsmonitorDaemon(fullPath).catch(() => null);
|
|
619
|
+
await rm(fullPath, { recursive: true, force: true });
|
|
620
|
+
removed.push(dir);
|
|
621
|
+
} catch {
|
|
622
|
+
/* best-effort */
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Prune stale git references
|
|
628
|
+
if (removed.length > 0) {
|
|
629
|
+
try {
|
|
630
|
+
await git(["worktree", "prune"], rootDir);
|
|
631
|
+
} catch {
|
|
632
|
+
/* best-effort */
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return removed;
|
|
193
637
|
}
|
|
194
638
|
|
|
195
639
|
/**
|
|
@@ -206,24 +650,34 @@ export async function pruneWorktree({ worktreePath, branchName, rootDir = proces
|
|
|
206
650
|
* @param {string} [opts.rootDir=process.cwd()] — local repo root
|
|
207
651
|
* @returns {Promise<{ ok: boolean, localRef?: string, error?: string }>}
|
|
208
652
|
*/
|
|
209
|
-
export async function fetchRemoteShard({
|
|
653
|
+
export async function fetchRemoteShard({
|
|
654
|
+
host,
|
|
655
|
+
sshUser,
|
|
656
|
+
remoteRepoPath,
|
|
657
|
+
branchName,
|
|
658
|
+
rootDir = process.cwd(),
|
|
659
|
+
}) {
|
|
210
660
|
validateHost(host);
|
|
211
661
|
|
|
212
662
|
const remoteName = `_swarm-${host}-${Date.now()}`;
|
|
213
663
|
const sshUrl = `ssh://${sshUser}@${host}${remoteRepoPath}`;
|
|
214
664
|
|
|
215
665
|
try {
|
|
216
|
-
await git([
|
|
666
|
+
await git(["remote", "add", remoteName, sshUrl], rootDir);
|
|
217
667
|
|
|
218
|
-
await git([
|
|
668
|
+
await git(["fetch", remoteName, branchName, "--no-tags"], rootDir);
|
|
219
669
|
|
|
220
670
|
const localRef = `${remoteName}/${branchName}`;
|
|
221
|
-
const headCommit = await git([
|
|
671
|
+
const headCommit = await git(["rev-parse", `FETCH_HEAD`], rootDir);
|
|
222
672
|
|
|
223
673
|
return { ok: true, localRef, headCommit };
|
|
224
674
|
} catch (err) {
|
|
225
675
|
return { ok: false, error: err.message };
|
|
226
676
|
} finally {
|
|
227
|
-
try {
|
|
677
|
+
try {
|
|
678
|
+
await git(["remote", "remove", remoteName], rootDir);
|
|
679
|
+
} catch {
|
|
680
|
+
/* cleanup */
|
|
681
|
+
}
|
|
228
682
|
}
|
|
229
683
|
}
|