baxian 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/README.zh-CN.md +4 -3
- package/dist/agent/branch.d.ts.map +1 -1
- package/dist/agent/branch.js +7 -10
- package/dist/agent/branch.js.map +1 -1
- package/dist/agent/detect/manifest.d.ts +2 -1
- package/dist/agent/detect/manifest.d.ts.map +1 -1
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +49 -29
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/net-exec.d.ts +0 -1
- package/dist/agent/net-exec.d.ts.map +1 -1
- package/dist/agent/net-exec.js +1 -5
- package/dist/agent/net-exec.js.map +1 -1
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +2 -3
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/repo-store.d.ts +9 -15
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +156 -151
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/tmux.d.ts +0 -5
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +1 -1
- package/dist/agent/tmux.js.map +1 -1
- package/dist/cli.js +3 -3
- package/dist/cli.js.map +1 -1
- package/dist/config/backup.d.ts +0 -1
- package/dist/config/backup.d.ts.map +1 -1
- package/dist/config/backup.js +27 -18
- package/dist/config/backup.js.map +1 -1
- package/dist/config/loader.d.ts +2 -5
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +22 -35
- package/dist/config/loader.js.map +1 -1
- package/dist/config/validator.js +1 -1
- package/dist/config/validator.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -11
- package/dist/index.js.map +1 -1
- package/dist/lifecycle/restart-sentinel.d.ts.map +1 -1
- package/dist/lifecycle/restart-sentinel.js +2 -1
- package/dist/lifecycle/restart-sentinel.js.map +1 -1
- package/dist/platform/git-driver.d.ts +0 -2
- package/dist/platform/git-driver.d.ts.map +1 -1
- package/dist/platform/git-driver.js +2 -2
- package/dist/platform/git-driver.js.map +1 -1
- package/dist/platform/manifest.d.ts +0 -1
- package/dist/platform/manifest.d.ts.map +1 -1
- package/dist/platform/manifest.js +0 -12
- package/dist/platform/manifest.js.map +1 -1
- package/dist/platform/platform-poller.d.ts +2 -1
- package/dist/platform/platform-poller.d.ts.map +1 -1
- package/dist/platform/plugin-roots.d.ts +1 -1
- package/dist/platform/plugin-roots.d.ts.map +1 -1
- package/dist/platform/plugin-roots.js +2 -4
- package/dist/platform/plugin-roots.js.map +1 -1
- package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +1 -1
- package/dist/platform/pr-binding.d.ts +2 -1
- package/dist/platform/pr-binding.d.ts.map +1 -1
- package/dist/platform/pr-conversation-cache.d.ts +0 -5
- package/dist/platform/pr-conversation-cache.d.ts.map +1 -1
- package/dist/platform/pr-conversation-cache.js +5 -5
- package/dist/platform/pr-conversation-cache.js.map +1 -1
- package/dist/platform/startup.d.ts +1 -1
- package/dist/platform/startup.d.ts.map +1 -1
- package/dist/platform/startup.js +3 -3
- package/dist/platform/startup.js.map +1 -1
- package/dist/platform/verdict-engine.d.ts +3 -2
- package/dist/platform/verdict-engine.d.ts.map +1 -1
- package/dist/shared/constants.d.ts +1 -6
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +0 -5
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +3 -3
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/state/agent-store.d.ts.map +1 -1
- package/dist/state/agent-store.js +2 -1
- package/dist/state/agent-store.js.map +1 -1
- package/dist/state/error-record-store.d.ts.map +1 -1
- package/dist/state/error-record-store.js +2 -1
- package/dist/state/error-record-store.js.map +1 -1
- package/dist/state/lock.d.ts.map +1 -1
- package/dist/state/lock.js +2 -1
- package/dist/state/lock.js.map +1 -1
- package/dist/state/managed-path.d.ts +2 -0
- package/dist/state/managed-path.d.ts.map +1 -0
- package/dist/state/managed-path.js +11 -0
- package/dist/state/managed-path.js.map +1 -0
- package/dist/state/pet-store.d.ts.map +1 -1
- package/dist/state/pet-store.js +2 -1
- package/dist/state/pet-store.js.map +1 -1
- package/dist/state/process-lock.d.ts.map +1 -1
- package/dist/state/process-lock.js +2 -1
- package/dist/state/process-lock.js.map +1 -1
- package/dist/state/task-store.d.ts +0 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +1 -16
- package/dist/state/task-store.js.map +1 -1
- package/dist/web/assets/{index-CzVzThuT.js → index-CF2C4Fq-.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/agent/lineage.d.ts +0 -14
- package/dist/agent/lineage.d.ts.map +0 -1
- package/dist/agent/lineage.js +0 -33
- package/dist/agent/lineage.js.map +0 -1
package/dist/agent/repo-store.js
CHANGED
|
@@ -10,7 +10,7 @@ const FETCH_THROTTLE_MS = 30_000;
|
|
|
10
10
|
const BAXIAN_RUNTIME_DIRS = [
|
|
11
11
|
'.baxian',
|
|
12
12
|
];
|
|
13
|
-
|
|
13
|
+
class BaxianRuntimeDirsError extends Error {
|
|
14
14
|
reason;
|
|
15
15
|
constructor(reason, message) {
|
|
16
16
|
super(message);
|
|
@@ -38,156 +38,126 @@ export async function ensureBaxianRuntimeDirsSafe(runner, absRepoPath) {
|
|
|
38
38
|
}
|
|
39
39
|
const dirs = BAXIAN_RUNTIME_DIRS.map(dir => `${absRepoPath}/${dir}`);
|
|
40
40
|
const operands = dirs.map(shellQuote).join(' ');
|
|
41
|
-
const q = shellQuote(absRepoPath);
|
|
42
|
-
const command = `[ "$(cd -- ${q} 2>/dev/null && pwd -P)" = ${q} ] || exit 9; ` +
|
|
43
|
-
`for p in ${operands}; do ` +
|
|
44
|
-
`if [ -L "$p" ] || { [ -e "$p" ] && [ ! -d "$p" ]; }; then exit 9; fi; ` +
|
|
45
|
-
`done && mkdir -p ${operands} && ` +
|
|
46
|
-
`for p in ${operands}; do [ -d "$p" ] && [ ! -L "$p" ] || exit 9; done`;
|
|
47
41
|
let result;
|
|
48
42
|
try {
|
|
49
|
-
result = await runner.exec(`
|
|
43
|
+
result = await runner.exec(`mkdir -p ${operands}`);
|
|
50
44
|
}
|
|
51
45
|
catch (err) {
|
|
52
46
|
const message = err instanceof Error ? err.message : String(err);
|
|
53
|
-
throw new BaxianRuntimeDirsError('runtime-path-probe-failed', `Could not
|
|
47
|
+
throw new BaxianRuntimeDirsError('runtime-path-probe-failed', `Could not prepare .baxian runtime paths in ${absRepoPath}: ${message}`);
|
|
54
48
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (result.exitCode === 9 && !guardUnknown) {
|
|
58
|
-
throw new BaxianRuntimeDirsError('unsafe-runtime-path', `Workdir ${absRepoPath} has an unsafe .baxian runtime path; `
|
|
59
|
-
+ 'baxian requires real directories contained in the Workdir.');
|
|
60
|
-
}
|
|
61
|
-
throw new BaxianRuntimeDirsError('runtime-path-probe-failed', `${guardUnknown ? 'Unknown result while verifying' : 'Failed to verify'} .baxian runtime paths in `
|
|
49
|
+
if (execOutcomeUnknown(result) || result.exitCode !== 0) {
|
|
50
|
+
throw new BaxianRuntimeDirsError('runtime-path-probe-failed', `${execOutcomeUnknown(result) ? 'Unknown result while preparing' : 'Failed to prepare'} .baxian runtime paths in `
|
|
62
51
|
+ `${absRepoPath}: ${result.stderr || result.stdout || `exit ${result.exitCode}`}`);
|
|
63
52
|
}
|
|
64
53
|
}
|
|
65
|
-
function
|
|
66
|
-
const
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
export function canonicalSelfGuard(path) {
|
|
70
|
-
const q = shellQuote(path);
|
|
71
|
-
return `[ "$(cd -- ${q} 2>/dev/null && pwd -P)" = ${q} ]`;
|
|
54
|
+
export function trashBatchDirUnder(trashRoot, reason) {
|
|
55
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
56
|
+
return `${trashRoot}/${stamp}-${reason}-${randomUUID().slice(0, 8)}`;
|
|
72
57
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return
|
|
58
|
+
// The batch sits beside its sources inside the agent tree, so mv never crosses filesystems.
|
|
59
|
+
export function trashBatchDir(home, agentId, reason) {
|
|
60
|
+
return trashBatchDirUnder(`${home}/.baxian/agents/${agentId}/.baxian-trash`, reason);
|
|
76
61
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return checks.join(' && ');
|
|
62
|
+
const TRASHED = 'BX_TRASHED';
|
|
63
|
+
const TRASH_ABSENT = 'BX_TRASH_ABSENT';
|
|
64
|
+
const MV_PRECHECK = 'BX_MV_PRECHECK';
|
|
65
|
+
// Absent counts as success (force-remove semantics); mkdir/mv failures must surface as a non-zero exit.
|
|
66
|
+
function trashMoveCommand(path, batchDir) {
|
|
67
|
+
const p = shellQuote(path);
|
|
68
|
+
const b = shellQuote(batchDir);
|
|
69
|
+
return (`if [ ! -e ${p} ] && [ ! -L ${p} ]; then printf '%s' ${TRASH_ABSENT}; ` +
|
|
70
|
+
`elif mkdir -p ${b}; then ` +
|
|
71
|
+
`base=$(basename -- ${p}); t=${b}/"$base"; n=0; ` +
|
|
72
|
+
`while [ -e "$t" ] || [ -L "$t" ]; do n=$((n+1)); t=${b}/"$base.$n"; done; ` +
|
|
73
|
+
`mv -- ${p} "$t" && printf '%s' ${TRASHED}; ` +
|
|
74
|
+
`else false; ` +
|
|
75
|
+
`fi`);
|
|
92
76
|
}
|
|
93
|
-
|
|
94
|
-
const rm = opts.recursive ? 'rm -rf' : 'rm -f';
|
|
95
|
-
return `${ancestorSymlinkGuard(root, target)} && ${rm} -- ${shellQuote(target)}`;
|
|
96
|
-
}
|
|
97
|
-
const SWEEP_REMOVED = 'BX_SWEEP_REMOVED';
|
|
98
|
-
const SWEEP_REFUSED = 'BX_SWEEP_REFUSED';
|
|
99
|
-
async function sweepGuardedPath(runner, path, guardClause) {
|
|
100
|
-
const rm = `rm -f -- ${shellQuote(path)} && printf '%s' ${shellQuote(SWEEP_REMOVED)}`;
|
|
101
|
-
const cmd = guardClause
|
|
102
|
-
? `if ${guardClause}; then ${rm}; else printf '%s' ${shellQuote(SWEEP_REFUSED)}; fi`
|
|
103
|
-
: rm;
|
|
77
|
+
async function sweepToTrash(runner, path, batchDir) {
|
|
104
78
|
let res;
|
|
105
79
|
try {
|
|
106
|
-
res = await runner.exec(
|
|
80
|
+
res = await runner.exec(trashMoveCommand(path, batchDir));
|
|
107
81
|
}
|
|
108
82
|
catch (err) {
|
|
109
|
-
console.warn(`[fs]
|
|
83
|
+
console.warn(`[fs] trash ${path}: outcome UNKNOWN (exec rejected) — target kept, may linger: ${err instanceof Error ? err.message : String(err)}`);
|
|
110
84
|
return;
|
|
111
85
|
}
|
|
112
|
-
if (
|
|
113
|
-
console.warn(`[fs]
|
|
86
|
+
if (execOutcomeUnknown(res)) {
|
|
87
|
+
console.warn(`[fs] trash ${path}: outcome UNKNOWN (exit ${res.exitCode}: ${res.stderr.trim() || 'no stderr'}) — target kept, may linger`);
|
|
114
88
|
return;
|
|
115
89
|
}
|
|
116
|
-
if (res.
|
|
117
|
-
|
|
118
|
-
if (res.stdout.includes(SWEEP_REFUSED)) {
|
|
119
|
-
console.warn(`[fs] sweep ${path}: guard refused — target deliberately kept (rebound ancestor; not out-reaching)`);
|
|
90
|
+
if (res.exitCode !== 0) {
|
|
91
|
+
console.warn(`[fs] trash ${path}: move failed (exit ${res.exitCode}: ${res.stderr.trim() || 'no stderr'}) — target kept in place`);
|
|
120
92
|
return;
|
|
121
93
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
await sweepGuardedPath(runner, path, guardClause);
|
|
94
|
+
if (res.stdout.includes(TRASH_ABSENT) || res.stdout.includes(TRASHED))
|
|
95
|
+
return;
|
|
96
|
+
console.warn(`[fs] trash ${path}: move reported failure (no marker; exit ${res.exitCode}) — target kept in place`);
|
|
126
97
|
}
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
return guardRoot ? `${ancestorSymlinkGuard(guardRoot, final)} && ${finalIsRealDir}` : finalIsRealDir;
|
|
98
|
+
export async function sweepStrayFile(runner, path, batchDir) {
|
|
99
|
+
await sweepToTrash(runner, path, batchDir);
|
|
130
100
|
}
|
|
131
|
-
export async function
|
|
101
|
+
export async function stageFile(runner, tmp, content, opts) {
|
|
132
102
|
const buf = typeof content === 'string' ? Buffer.from(content, 'utf8') : content;
|
|
133
|
-
const guard = ancestorSymlinkGuard(root, tmp);
|
|
134
103
|
const dir = tmp.slice(0, tmp.lastIndexOf('/'));
|
|
135
104
|
const privateCreate = opts.mode === undefined ? '' : 'umask 077 && ';
|
|
136
105
|
const enforceMode = opts.mode === undefined
|
|
137
106
|
? ''
|
|
138
107
|
: ` && chmod ${opts.mode.toString(8)} ${shellQuote(tmp)}`;
|
|
139
|
-
const cmd =
|
|
108
|
+
const cmd = `mkdir -p ${shellQuote(dir)} && ${privateCreate}cat > ${shellQuote(tmp)}${enforceMode}`;
|
|
140
109
|
let res;
|
|
141
110
|
try {
|
|
142
111
|
res = await runner.execWithStdin(cmd, buf);
|
|
143
112
|
}
|
|
144
113
|
catch (err) {
|
|
145
|
-
await sweepStrayFile(runner, tmp,
|
|
114
|
+
await sweepStrayFile(runner, tmp, opts.trashDir);
|
|
146
115
|
throw new ExecOutcomeUnknownError(`staged write of ${tmp} failed (exec layer): ${err instanceof Error ? err.message : String(err)}`);
|
|
147
116
|
}
|
|
148
117
|
if (execOutcomeUnknown(res)) {
|
|
149
|
-
await sweepStrayFile(runner, tmp,
|
|
118
|
+
await sweepStrayFile(runner, tmp, opts.trashDir);
|
|
150
119
|
throw new ExecOutcomeUnknownError(`staged write of ${tmp} outcome unknown (exit ${res.exitCode}): ${res.stderr.trim() || 'no stderr'}`);
|
|
151
120
|
}
|
|
152
121
|
if (res.exitCode !== 0) {
|
|
153
|
-
await sweepStrayFile(runner, tmp,
|
|
154
|
-
throw new Error(`staged write of ${tmp} failed (exit ${res.exitCode}): ${res.stderr.trim() || '
|
|
122
|
+
await sweepStrayFile(runner, tmp, opts.trashDir);
|
|
123
|
+
throw new Error(`staged write of ${tmp} failed (exit ${res.exitCode}): ${res.stderr.trim() || 'no stderr'}`);
|
|
155
124
|
}
|
|
156
125
|
}
|
|
157
|
-
export async function moveFileIntoPlace(runner, tmp, final, opts
|
|
126
|
+
export async function moveFileIntoPlace(runner, tmp, final, opts) {
|
|
158
127
|
const tmpBase = tmp.slice(tmp.lastIndexOf('/') + 1);
|
|
128
|
+
// mv -f onto a directory target nests instead of replacing: fail closed ahead, verify a plain file landed after.
|
|
159
129
|
const clauses = [
|
|
160
|
-
...(opts.guardRoot ? [ancestorSymlinkGuard(opts.guardRoot, final)] : []),
|
|
161
130
|
`[ -f ${shellQuote(tmp)} ]`,
|
|
162
131
|
`[ ! -L ${shellQuote(tmp)} ]`,
|
|
163
132
|
`[ ! -d ${shellQuote(final)} ]`,
|
|
133
|
+
`printf '%s' ${MV_PRECHECK}`,
|
|
164
134
|
`mv -f -- ${shellQuote(tmp)} ${shellQuote(final)}`,
|
|
165
135
|
`[ -f ${shellQuote(final)} ]`,
|
|
166
136
|
`[ ! -L ${shellQuote(final)} ]`,
|
|
167
137
|
];
|
|
168
|
-
const guardCovers = (p) => opts.guardRoot !== undefined && isUnder(opts.guardRoot, p);
|
|
169
|
-
const tmpGuard = guardCovers(tmp) ? ancestorSymlinkGuard(opts.guardRoot, tmp) : undefined;
|
|
170
|
-
const nestedGuard = nestedSweepGuard(final, guardCovers(final) ? opts.guardRoot : undefined);
|
|
171
138
|
const nested = `${final}/${tmpBase}`;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
await
|
|
139
|
+
// The nested path resolves THROUGH a symlink final; sweep it only when the precheck marker proves mv actually ran, else an unrelated same-named file behind the link would be dragged into trash.
|
|
140
|
+
const sweepFailure = async (mvMayHaveRun) => {
|
|
141
|
+
await sweepToTrash(runner, tmp, opts.trashDir);
|
|
142
|
+
if (mvMayHaveRun)
|
|
143
|
+
await sweepToTrash(runner, nested, opts.trashDir);
|
|
175
144
|
};
|
|
176
145
|
let res;
|
|
177
146
|
try {
|
|
178
147
|
res = await runner.exec(clauses.join(' && '));
|
|
179
148
|
}
|
|
180
149
|
catch (err) {
|
|
181
|
-
await
|
|
150
|
+
await sweepFailure(false);
|
|
182
151
|
throw new ExecOutcomeUnknownError(`atomic replace of ${final} failed (exec layer): ${err instanceof Error ? err.message : String(err)}`);
|
|
183
152
|
}
|
|
153
|
+
const mvMayHaveRun = res.stdout.includes(MV_PRECHECK);
|
|
184
154
|
if (execOutcomeUnknown(res)) {
|
|
185
|
-
await
|
|
155
|
+
await sweepFailure(mvMayHaveRun);
|
|
186
156
|
throw new ExecOutcomeUnknownError(`atomic replace of ${final} outcome unknown (exit ${res.exitCode}): ${res.stderr.trim() || 'no stderr'}`);
|
|
187
157
|
}
|
|
188
158
|
if (res.exitCode !== 0) {
|
|
189
|
-
await
|
|
190
|
-
throw new Error(`atomic replace of ${final} failed (exit ${res.exitCode}): ${res.stderr.trim() || 'target must be a plain file
|
|
159
|
+
await sweepFailure(mvMayHaveRun);
|
|
160
|
+
throw new Error(`atomic replace of ${final} failed (exit ${res.exitCode}): ${res.stderr.trim() || 'target must be a plain file'}`);
|
|
191
161
|
}
|
|
192
162
|
}
|
|
193
163
|
export class RepoStore {
|
|
@@ -301,16 +271,14 @@ export class RepoStore {
|
|
|
301
271
|
throw new Error(`Configured Workdir ${absRepoPath} does not exist; ` +
|
|
302
272
|
`baxian never creates a user-specified Workdir implicitly.`);
|
|
303
273
|
}
|
|
304
|
-
const guardRoot = await this.resolveHome();
|
|
305
274
|
const parent = absRepoPath.replace(/\/[^/]+$/, '');
|
|
306
275
|
const staging = `${absRepoPath}.claim-${randomUUID()}`;
|
|
307
|
-
const
|
|
308
|
-
const mk = await this.runner.exec(`${stagingGuard} && mkdir -p ${shellQuote(parent)}`);
|
|
276
|
+
const mk = await this.runner.exec(`mkdir -p ${shellQuote(parent)}`);
|
|
309
277
|
if (mk.exitCode !== 0)
|
|
310
|
-
throw new Error(`Failed to mkdir ${parent}
|
|
278
|
+
throw new Error(`Failed to mkdir ${parent}: ${mk.stderr}`);
|
|
311
279
|
const cloneCmd = this.cloneWithGh
|
|
312
|
-
? `${
|
|
313
|
-
: `${
|
|
280
|
+
? `${this.ghCloneHostEnv()}${GIT_NET_ENV} gh repo clone ${shellQuote(repoSlug(this.repo))} ${shellQuote(staging)} --no-upstream`
|
|
281
|
+
: `${GIT_NET_ENV} git clone ${shellQuote(this.repo)} ${shellQuote(staging)}`;
|
|
314
282
|
let clone;
|
|
315
283
|
try {
|
|
316
284
|
clone = await execNetwork(this.runner, cloneCmd, {
|
|
@@ -319,15 +287,15 @@ export class RepoStore {
|
|
|
319
287
|
});
|
|
320
288
|
}
|
|
321
289
|
catch (err) {
|
|
322
|
-
await this.
|
|
290
|
+
await this.trashStaging(staging, err instanceof Error ? err.message : String(err));
|
|
323
291
|
throw err;
|
|
324
292
|
}
|
|
325
293
|
if (clone.exitCode !== 0) {
|
|
326
|
-
await this.
|
|
294
|
+
await this.trashStaging(staging, `clone exit ${clone.exitCode}`);
|
|
327
295
|
const cmd = this.cloneWithGh ? 'gh repo clone' : 'git clone';
|
|
328
296
|
throw new Error(redactGitCredentials(`${cmd} ${this.repo} failed: ${clone.stderr || clone.stdout}`));
|
|
329
297
|
}
|
|
330
|
-
await this.promoteStaging(staging, absRepoPath
|
|
298
|
+
await this.promoteStaging(staging, absRepoPath);
|
|
331
299
|
if (this.isGitHub && parseGitRemote(this.repo) !== null) {
|
|
332
300
|
return this.syncMatchingOriginUrl(absRepoPath);
|
|
333
301
|
}
|
|
@@ -376,7 +344,11 @@ export class RepoStore {
|
|
|
376
344
|
throw new Error(`${absRepoPath} is not a working-tree Git repository.`);
|
|
377
345
|
}
|
|
378
346
|
const canonicalTop = await probe(`cd ${shellQuote(top.stdout.trim())} && pwd -P`, 'canonical top');
|
|
379
|
-
|
|
347
|
+
// Physical-to-physical: the auto Workdir path may route through symlinks (e.g. a relocated ~/.baxian/agents), so the logical path itself never matches the toplevel.
|
|
348
|
+
const canonicalWorkdir = await probe(`cd ${path} && pwd -P`, 'canonical workdir');
|
|
349
|
+
if (canonicalTop.exitCode !== 0 || canonicalWorkdir.exitCode !== 0
|
|
350
|
+
|| canonicalTop.stdout.trim() === ''
|
|
351
|
+
|| canonicalTop.stdout.trim() !== canonicalWorkdir.stdout.trim()) {
|
|
380
352
|
throw new Error(`Workdir ${absRepoPath} must be the repository's exact top-level directory, not a subdirectory.`);
|
|
381
353
|
}
|
|
382
354
|
const bare = await probe(`git -C ${path} rev-parse --is-bare-repository`, 'bare flag');
|
|
@@ -409,11 +381,15 @@ export class RepoStore {
|
|
|
409
381
|
}
|
|
410
382
|
async ensureBaxianExcluded(absRepoPath) {
|
|
411
383
|
const path = shellQuote(absRepoPath);
|
|
412
|
-
|
|
384
|
+
// Leading slash anchors to the repo root (an unanchored `.baxian` would hide sub/.baxian anywhere); no trailing slash so a user-planted symlink .baxian is still covered.
|
|
385
|
+
const command = `cd ${path} && p="$(git rev-parse --git-path info/exclude)" && ` +
|
|
413
386
|
`mkdir -p "$(dirname "$p")" && ` +
|
|
414
|
-
`{ grep -qxF '
|
|
415
|
-
`git check-ignore -q -- .baxian
|
|
387
|
+
`{ grep -qxF '/.baxian' "$p" 2>/dev/null || printf '%s\\n' '/.baxian' >> "$p"; } && ` +
|
|
388
|
+
`git check-ignore -q -- .baxian`;
|
|
416
389
|
const excluded = await this.runner.exec(`sh -c ${shellQuote(command)}`);
|
|
390
|
+
if (execOutcomeUnknown(excluded)) {
|
|
391
|
+
throw new ExecOutcomeUnknownError(`.baxian exclude probe in ${absRepoPath} outcome unknown (exit ${excluded.exitCode}): ${excluded.stderr.trim() || 'no stderr'}`);
|
|
392
|
+
}
|
|
417
393
|
if (excluded.exitCode !== 0) {
|
|
418
394
|
throw new Error(`Failed to make .baxian runtime files invisible to Git in ${absRepoPath}.`);
|
|
419
395
|
}
|
|
@@ -436,12 +412,11 @@ export class RepoStore {
|
|
|
436
412
|
return false;
|
|
437
413
|
if (!accessMethodDiffers(this.repo, originUrl))
|
|
438
414
|
return false;
|
|
439
|
-
const
|
|
440
|
-
const unset = await this.probe(`${guard} && git -C ${shellQuote(absRepoPath)} config --unset-all remote.origin.pushurl`, `remote.origin.pushurl removal at ${absRepoPath}`);
|
|
415
|
+
const unset = await this.probe(`git -C ${shellQuote(absRepoPath)} config --unset-all remote.origin.pushurl`, `remote.origin.pushurl removal at ${absRepoPath}`);
|
|
441
416
|
if (unset.exitCode !== 0 && unset.exitCode !== 5) {
|
|
442
417
|
throw new Error(redactGitCredentials(`Failed to clear remote.origin.pushurl at ${absRepoPath} (exit ${unset.exitCode}): ${unset.stderr.trim()}`));
|
|
443
418
|
}
|
|
444
|
-
const result = await this.runner.exec(
|
|
419
|
+
const result = await this.runner.exec(`git -C ${shellQuote(absRepoPath)} config --replace-all remote.origin.url ${shellQuote(this.repo)}`);
|
|
445
420
|
if (result.exitCode !== 0) {
|
|
446
421
|
throw new Error(redactGitCredentials(`Failed to update origin URL at ${absRepoPath}: ${result.stderr}`));
|
|
447
422
|
}
|
|
@@ -463,91 +438,113 @@ export class RepoStore {
|
|
|
463
438
|
return false;
|
|
464
439
|
throw new Error(`Cannot probe workdir ${absRepoPath} (exit ${probe.exitCode}): ${probe.stderr.trim() || 'exec layer failure'}`);
|
|
465
440
|
}
|
|
466
|
-
async
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
441
|
+
async trashDir(reason) {
|
|
442
|
+
return trashBatchDir(await this.resolveHome(), this.agentId, reason);
|
|
443
|
+
}
|
|
444
|
+
async trashStaging(staging, reason) {
|
|
445
|
+
console.warn(`[repo-store] trashing staged clone ${staging}: ${redactGitCredentials(reason)}`);
|
|
446
|
+
let batchDir;
|
|
447
|
+
try {
|
|
448
|
+
batchDir = await this.trashDir('staging');
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
console.warn(`[repo-store] cannot resolve trash dir for staged clone ${staging}; kept in place:`, err);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
471
454
|
try {
|
|
472
|
-
const
|
|
473
|
-
if (
|
|
474
|
-
console.warn(`[repo-store]
|
|
455
|
+
const moved = await this.runner.exec(trashMoveCommand(staging, batchDir));
|
|
456
|
+
if (execOutcomeUnknown(moved)) {
|
|
457
|
+
console.warn(`[repo-store] staged clone ${staging} trash outcome UNKNOWN (exit ${moved.exitCode}: ${moved.stderr.trim() || 'no stderr'}); debris may remain`);
|
|
475
458
|
}
|
|
476
|
-
else if (
|
|
477
|
-
console.warn(`[repo-store] staged clone ${staging}
|
|
459
|
+
else if (moved.exitCode !== 0) {
|
|
460
|
+
console.warn(`[repo-store] failed to trash staged clone ${staging}: ${moved.stderr.trim() || `exit ${moved.exitCode}`}`);
|
|
478
461
|
}
|
|
479
|
-
else if (!
|
|
480
|
-
console.warn(`[repo-store] staged clone ${staging}
|
|
462
|
+
else if (!moved.stdout.includes(TRASHED) && !moved.stdout.includes(TRASH_ABSENT)) {
|
|
463
|
+
console.warn(`[repo-store] staged clone ${staging} trash outcome unknown (no marker); debris may remain`);
|
|
481
464
|
}
|
|
482
465
|
}
|
|
483
466
|
catch (err) {
|
|
484
|
-
console.warn(`[repo-store] failed to
|
|
467
|
+
console.warn(`[repo-store] failed to trash staged clone ${staging}:`, err);
|
|
485
468
|
}
|
|
486
469
|
}
|
|
487
|
-
async promoteStaging(staging, absRepoPath
|
|
470
|
+
async promoteStaging(staging, absRepoPath) {
|
|
488
471
|
const base = staging.slice(staging.lastIndexOf('/') + 1);
|
|
489
472
|
const nestedPath = `${absRepoPath}/${base}`;
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
await this.discardStaging(staging, reason, stagingGuard);
|
|
494
|
-
await this.discardStaging(nestedPath, reason, nestedGuard);
|
|
473
|
+
const trashBoth = async (reason) => {
|
|
474
|
+
await this.trashStaging(staging, reason);
|
|
475
|
+
await this.trashStaging(nestedPath, reason);
|
|
495
476
|
};
|
|
496
477
|
const nonce = randomUUID();
|
|
497
478
|
const claimRel = '.git/baxian-promote-claim';
|
|
498
479
|
const stagingClaim = `${staging}/${claimRel}`;
|
|
499
480
|
const finalClaim = `${absRepoPath}/${claimRel}`;
|
|
500
|
-
const
|
|
501
|
-
|
|
481
|
+
const mark = await this.runner.exec(`printf %s ${shellQuote(nonce)} > ${shellQuote(stagingClaim)}`);
|
|
482
|
+
if (execOutcomeUnknown(mark)) {
|
|
483
|
+
await this.trashStaging(staging, `promote-claim write outcome unknown: ${mark.stderr.trim() || `exit ${mark.exitCode}`}`);
|
|
484
|
+
throw new ExecOutcomeUnknownError(`Promote-claim write for ${staging} outcome unknown (exit ${mark.exitCode}): ${mark.stderr.trim() || 'no stderr'}`);
|
|
485
|
+
}
|
|
502
486
|
if (mark.exitCode !== 0) {
|
|
503
|
-
await this.
|
|
487
|
+
await this.trashStaging(staging, `promote-claim write failed: ${mark.stderr.trim() || `exit ${mark.exitCode}`}`);
|
|
504
488
|
throw new Error(`Cannot stamp staged clone ${staging}: ${mark.stderr.trim() || `exit ${mark.exitCode}`}`);
|
|
505
489
|
}
|
|
506
|
-
const promoteCmd =
|
|
507
|
-
`
|
|
508
|
-
`mv ${shellQuote(staging)} ${shellQuote(absRepoPath)}`;
|
|
490
|
+
const promoteCmd = `[ ! -e ${shellQuote(absRepoPath)} ] && [ ! -L ${shellQuote(absRepoPath)} ] && ` +
|
|
491
|
+
`printf '%s' ${MV_PRECHECK} && mv ${shellQuote(staging)} ${shellQuote(absRepoPath)}`;
|
|
509
492
|
let promote;
|
|
510
493
|
try {
|
|
511
494
|
promote = await this.runner.exec(promoteCmd);
|
|
512
495
|
}
|
|
513
496
|
catch (err) {
|
|
514
|
-
await this.reconcilePromote(staging, absRepoPath, nestedPath, finalClaim, nonce,
|
|
497
|
+
await this.reconcilePromote(staging, absRepoPath, nestedPath, finalClaim, nonce, `mv exec rejected: ${err instanceof Error ? err.message : String(err)}`);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// Unknown outrank exit 0: a transient-tainted reply proves nothing about whether mv ran.
|
|
501
|
+
if (execOutcomeUnknown(promote)) {
|
|
502
|
+
await this.reconcilePromote(staging, absRepoPath, nestedPath, finalClaim, nonce, `mv outcome unknown (exit ${promote.exitCode}): ${promote.stderr.trim()}`);
|
|
515
503
|
return;
|
|
516
504
|
}
|
|
517
505
|
if (promote.exitCode === 0) {
|
|
518
506
|
const nested = await this.runner.exec(`test -e ${shellQuote(nestedPath)}`);
|
|
519
|
-
if (execOutcomeUnknown(nested)
|
|
507
|
+
if (execOutcomeUnknown(nested)) {
|
|
508
|
+
throw new Error(`Cannot verify promoted clone at ${absRepoPath}: nested probe outcome unknown (exit ${nested.exitCode}): ${nested.stderr.trim() || 'no stderr'}; nothing moved.`);
|
|
509
|
+
}
|
|
510
|
+
if (nested.exitCode !== 0 && nested.exitCode !== 1) {
|
|
520
511
|
const reason = `promote verification failed (exit ${nested.exitCode}): ${nested.stderr.trim() || 'exec layer failure'}`;
|
|
521
|
-
await
|
|
512
|
+
await trashBoth(reason);
|
|
522
513
|
throw new Error(`Cannot verify promoted clone at ${absRepoPath}; ${reason}`);
|
|
523
514
|
}
|
|
524
515
|
if (nested.exitCode === 1) {
|
|
525
|
-
const cleared = await this.clearPromoteClaim(finalClaim,
|
|
516
|
+
const cleared = await this.clearPromoteClaim(finalClaim, nonce);
|
|
526
517
|
if (cleared !== 'removed') {
|
|
527
518
|
throw new Error(`Promote to ${absRepoPath} landed but its ownership marker was ${cleared} (replaced/foreign or unverifiable); refusing to adopt. Retry DELETE/clone.`);
|
|
528
519
|
}
|
|
529
520
|
return;
|
|
530
521
|
}
|
|
531
|
-
await this.
|
|
522
|
+
await this.trashStaging(nestedPath, `final path ${absRepoPath} was recreated concurrently`);
|
|
532
523
|
throw new Error(`Final path ${absRepoPath} was recreated while promoting; staged clone withdrawn.`);
|
|
533
524
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
525
|
+
// Without the precheck marker mv never started, so final/<base> cannot be ours — only the staging we provably own is withdrawn.
|
|
526
|
+
if (promote.stdout.includes(MV_PRECHECK)) {
|
|
527
|
+
await trashBoth(`promote failed: ${promote.stderr.trim() || `exit ${promote.exitCode}`}`);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
await this.trashStaging(staging, `promote failed before mv: ${promote.stderr.trim() || `exit ${promote.exitCode}`}`);
|
|
537
531
|
}
|
|
538
|
-
await discardBoth(`promote failed: ${promote.stderr.trim() || `exit ${promote.exitCode}`}`);
|
|
539
532
|
throw new Error(`Cannot promote staged clone into ${absRepoPath}: ${promote.stderr.trim() || `exit ${promote.exitCode}`}`);
|
|
540
533
|
}
|
|
541
|
-
async reconcilePromote(staging, absRepoPath, nestedPath, finalClaim, nonce,
|
|
534
|
+
async reconcilePromote(staging, absRepoPath, nestedPath, finalClaim, nonce, cause) {
|
|
535
|
+
// Reconciliation probes are remote execs too: an unknown probe must never drive a move.
|
|
542
536
|
const stagingExists = await this.runner.exec(`test -e ${shellQuote(staging)}`);
|
|
537
|
+
if (execOutcomeUnknown(stagingExists)) {
|
|
538
|
+
throw new Error(`Cannot reconcile promote to ${absRepoPath}: staging probe outcome unknown (exit ${stagingExists.exitCode}): ${stagingExists.stderr.trim() || 'no stderr'}; nothing moved. ${cause}`);
|
|
539
|
+
}
|
|
543
540
|
if (stagingExists.exitCode === 0) {
|
|
544
|
-
await this.
|
|
541
|
+
await this.trashStaging(staging, `mv not executed (${cause})`);
|
|
545
542
|
throw new Error(`Promote to ${absRepoPath} not executed; staged clone withdrawn. ${cause}`);
|
|
546
543
|
}
|
|
547
544
|
if (stagingExists.exitCode === 1) {
|
|
548
545
|
const marker = await this.runner.exec(`cat ${shellQuote(finalClaim)} 2>/dev/null`);
|
|
549
546
|
if (marker.exitCode === 0 && marker.stdout.trim() === nonce) {
|
|
550
|
-
const cleared = await this.clearPromoteClaim(finalClaim,
|
|
547
|
+
const cleared = await this.clearPromoteClaim(finalClaim, nonce);
|
|
551
548
|
if (cleared !== 'removed') {
|
|
552
549
|
throw new Error(`Promote to ${absRepoPath} reconciliation: ownership marker was ${cleared} (final replaced after the nonce read); refusing to adopt. ${cause}`);
|
|
553
550
|
}
|
|
@@ -555,27 +552,36 @@ export class RepoStore {
|
|
|
555
552
|
return;
|
|
556
553
|
}
|
|
557
554
|
const nested = await this.runner.exec(`test -e ${shellQuote(nestedPath)}`);
|
|
555
|
+
if (execOutcomeUnknown(nested)) {
|
|
556
|
+
throw new Error(`Cannot reconcile promote to ${absRepoPath}: nested probe outcome unknown (exit ${nested.exitCode}): ${nested.stderr.trim() || 'no stderr'}; nothing moved. ${cause}`);
|
|
557
|
+
}
|
|
558
558
|
if (nested.exitCode === 0) {
|
|
559
|
-
await this.
|
|
559
|
+
await this.trashStaging(nestedPath, `mv nested on target race (${cause})`);
|
|
560
560
|
throw new Error(`Promote to ${absRepoPath} nested on a target race; staged clone withdrawn. ${cause}`);
|
|
561
561
|
}
|
|
562
562
|
}
|
|
563
563
|
console.warn(`[repo-store] promote to ${absRepoPath} outcome UNKNOWN — staging/final/nested all inconclusive, nothing removed (${cause})`);
|
|
564
564
|
throw new Error(`Cannot reconcile promote to ${absRepoPath}; all locations inconclusive. ${cause}`);
|
|
565
565
|
}
|
|
566
|
-
async clearPromoteClaim(finalClaim,
|
|
566
|
+
async clearPromoteClaim(finalClaim, nonce) {
|
|
567
567
|
try {
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
if (
|
|
571
|
-
|
|
568
|
+
const batchDir = await this.trashDir('claim');
|
|
569
|
+
// Nonce re-check and mv in ONE command (a separate cat→mv could displace a replaced foreign marker); the echoed marker surfaces a nonce-refused clear that the `if`'s exit 0 would otherwise mask.
|
|
570
|
+
const moved = await this.runner.exec(`if [ "$(cat ${shellQuote(finalClaim)} 2>/dev/null)" = ${shellQuote(nonce)} ]; ` +
|
|
571
|
+
`then mkdir -p ${shellQuote(batchDir)} && mv -- ${shellQuote(finalClaim)} ${shellQuote(batchDir)}/ && echo BX_MARKER_TRASHED; else echo BX_MARKER_REFUSED; fi`);
|
|
572
|
+
if (moved.exitCode !== 0 || execOutcomeUnknown(moved)) {
|
|
573
|
+
console.warn(`[repo-store] failed to clear promote-claim marker ${finalClaim}: ${moved.stderr.trim() || `exit ${moved.exitCode}`}`);
|
|
572
574
|
return 'failed';
|
|
573
575
|
}
|
|
574
|
-
if (
|
|
575
|
-
console.warn(`[repo-store] promote-claim marker ${finalClaim} NOT cleared:
|
|
576
|
+
if (moved.stdout.includes('BX_MARKER_REFUSED')) {
|
|
577
|
+
console.warn(`[repo-store] promote-claim marker ${finalClaim} NOT cleared: nonce mismatch (foreign clone); left in place`);
|
|
576
578
|
return 'refused';
|
|
577
579
|
}
|
|
578
|
-
|
|
580
|
+
// Only an explicit TRASHED marker proves the move; a bare exit 0 (empty or truncated stdout) is not a positive outcome under the three-state discipline.
|
|
581
|
+
if (moved.stdout.includes('BX_MARKER_TRASHED'))
|
|
582
|
+
return 'removed';
|
|
583
|
+
console.warn(`[repo-store] promote-claim marker ${finalClaim} clear reported no outcome marker; treating as failed`);
|
|
584
|
+
return 'failed';
|
|
579
585
|
}
|
|
580
586
|
catch (err) {
|
|
581
587
|
console.warn(`[repo-store] failed to clear promote-claim marker ${finalClaim}:`, err);
|
|
@@ -585,8 +591,7 @@ export class RepoStore {
|
|
|
585
591
|
async recoverEmptyLeftoverDir(absRepoPath) {
|
|
586
592
|
if (this.configuredWorkdir)
|
|
587
593
|
return false;
|
|
588
|
-
const
|
|
589
|
-
const undo = await this.probe(`${ancestorSymlinkGuard(guardRoot, absRepoPath)} && rmdir ${shellQuote(absRepoPath)}`, `leftover dir ${absRepoPath}`);
|
|
594
|
+
const undo = await this.probe(`rmdir ${shellQuote(absRepoPath)}`, `leftover dir ${absRepoPath}`);
|
|
590
595
|
if (undo.exitCode === 0) {
|
|
591
596
|
console.warn(`[repo-store] removed leftover empty dir ${absRepoPath}; retrying clone`);
|
|
592
597
|
return true;
|
|
@@ -597,7 +602,7 @@ export class RepoStore {
|
|
|
597
602
|
const last = this.cache.lastFetchAt.get(cacheKey) ?? 0;
|
|
598
603
|
if (Date.now() - last < FETCH_THROTTLE_MS)
|
|
599
604
|
return;
|
|
600
|
-
const result = await execNetwork(this.runner,
|
|
605
|
+
const result = await execNetwork(this.runner, `cd ${shellQuote(absRepoPath)} && ${GIT_NET_ENV} git fetch --all --prune && git remote set-head origin --auto`);
|
|
601
606
|
if (result.exitCode !== 0) {
|
|
602
607
|
throw new Error(`git fetch failed or origin/HEAD could not be refreshed at ${absRepoPath}: ${result.stderr}`);
|
|
603
608
|
}
|