@staix/agent-hub 0.12.42 → 0.12.43
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/CHANGELOG.md +17 -0
- package/README.md +5 -4
- package/docs/agent-notes/adapters.md +3 -1
- package/docs/agent-notes/bus.md +2 -0
- package/docs/agent-notes/daemon.md +7 -2
- package/docs/agent-notes/tasks.md +1 -0
- package/docs/agent-notes/tests.md +3 -1
- package/docs/operations.md +95 -21
- package/docs/quickstart.md +7 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +2 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +49 -9
- package/docs/verification/2026-10-21-0.12.43.md +50 -0
- package/docs/verification/2026-10-21-agy-1.4.3-t0.md +65 -0
- package/docs/verification/2026-10-21-pii-screen.md +14 -0
- package/docs/verified.json +13 -13
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +66 -27
- package/src/adapters/agy/hooks.ts +7 -2
- package/src/adapters/pi.ts +46 -16
- package/src/cli/completion.ts +173 -0
- package/src/cli/console-state.ts +107 -20
- package/src/cli/console.ts +9 -6
- package/src/cli/help.ts +2 -1
- package/src/cli/identity.ts +2 -2
- package/src/cli/main.js +5 -1
- package/src/cli/main.ts +6 -0
- package/src/cli/terminal-recovery.ts +18 -9
- package/src/hub/daemon.ts +138 -33
- package/src/hub/hub-tools.ts +1 -1
- package/src/hub/main-checkout.ts +2 -2
- package/src/hub/manager.ts +10 -2
- package/src/hub/settings.ts +1 -1
- package/src/hub/snapshots.ts +252 -17
- package/src/hub/tasks.ts +16 -2
package/src/hub/snapshots.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, type Stats } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { childEnv } from "./child-process.ts";
|
|
@@ -12,17 +13,28 @@ import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
|
12
13
|
* Per-turn workspace snapshots (issue #33): git tree objects written through a copy of the index, so the user's index
|
|
13
14
|
* and HEAD never change. They hold tracked and unignored files as `git add -A` sees them, shell-made changes included.
|
|
14
15
|
* ponytail: snapshots run synchronously on the turn boundary (before the agent sees its prompt); a very large tree
|
|
15
|
-
* costs that much latency per turn
|
|
16
|
+
* costs that much latency per turn for unbudgeted CLI calls. The daemon shares a bounded burst deadline;
|
|
17
|
+
* exhausted boundaries are unknown. `snapshots.enabled` is the switch and `turn_end.snapshotMs` the measurement.
|
|
18
|
+
* ponytail: the deadline caps Git child waits, not synchronous metadata/index-copy syscalls. If those become
|
|
19
|
+
* measured blockers, move capture to an isolated worker with native-boundary admission fencing.
|
|
16
20
|
* ponytail: the trees are unreferenced, so `git gc` may prune them after gc.pruneExpire (two weeks by default); undo
|
|
17
21
|
* of an older turn says so. Refs under refs/agenthub/ would keep them, at the cost of showing up in the user's repo.
|
|
18
22
|
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
export interface SnapshotBudget { deadline: number; }
|
|
24
|
+
export class SnapshotBudgetExceeded extends Error {
|
|
25
|
+
constructor() { super("snapshot burst budget exhausted; boundary evidence is unknown"); }
|
|
26
|
+
}
|
|
27
|
+
const git = (top: string | Repo, args: string[], env: Record<string, string> = {}, budget?: SnapshotBudget) => {
|
|
28
|
+
const argv = repoGitArgs(top, args);
|
|
29
|
+
const remaining = budget ? budget.deadline - performance.now() : SNAPSHOT_TIMEOUT_MS;
|
|
30
|
+
if (remaining <= 0) throw new SnapshotBudgetExceeded();
|
|
31
|
+
const result = hubGitSync(argv, {
|
|
32
|
+
encoding: "utf8", env: { ...repoGitEnv(top), ...env },
|
|
33
|
+
timeout: Math.max(1, Math.min(SNAPSHOT_TIMEOUT_MS, Math.floor(remaining))),
|
|
25
34
|
});
|
|
35
|
+
if (budget && (performance.now() >= budget.deadline || (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT")) throw new SnapshotBudgetExceeded();
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
26
38
|
const SNAPSHOT_TIMEOUT_MS = 10_000;
|
|
27
39
|
|
|
28
40
|
/** A repository and where the project sits in it: `prefix` is "" at the top level, else "sub/dir/". */
|
|
@@ -64,8 +76,8 @@ export function repoGitEnv(repo: string | Repo, env = process.env): NodeJS.Proce
|
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
/** The repository holding `root`, or undefined when it is not inside a work tree. */
|
|
67
|
-
export function repoOf(root: string): Repo | undefined {
|
|
68
|
-
const r = git(root, ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--show-prefix"]);
|
|
79
|
+
export function repoOf(root: string, budget?: SnapshotBudget): Repo | undefined {
|
|
80
|
+
const r = git(root, ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--show-prefix"], {}, budget);
|
|
69
81
|
if (r.status === 0) {
|
|
70
82
|
const [top, dir, prefix = ""] = r.stdout.replace(/\n$/, "").split("\n");
|
|
71
83
|
if (top && dir) return { top, dir, prefix };
|
|
@@ -82,7 +94,7 @@ export function repoOf(root: string): Repo | undefined {
|
|
|
82
94
|
if (ignore && (!ignore.isFile() || ignore.isSymbolicLink() || ignore.nlink !== 1)) return undefined;
|
|
83
95
|
if (!shadowStorageSafe(dir)) return undefined;
|
|
84
96
|
const repo: Repo = { top, dir: realPath(dir), prefix: "", shadow: true };
|
|
85
|
-
const probe = git(repo, ["rev-parse", "--absolute-git-dir"]);
|
|
97
|
+
const probe = git(repo, ["rev-parse", "--absolute-git-dir"], {}, budget);
|
|
86
98
|
return probe.status === 0 && realPath(probe.stdout.trim()) === repo.dir ? repo : undefined;
|
|
87
99
|
}
|
|
88
100
|
/** Opt-in creation only; a real repository is never replaced or modified. */
|
|
@@ -107,8 +119,228 @@ const literal = (path: string) => `:(literal)${path}`;
|
|
|
107
119
|
/** Only the project's own files: a hub in a subdirectory of a larger repository snapshots that subdirectory. */
|
|
108
120
|
const scope = (repo: Repo) => (repo.prefix ? literal(repo.prefix) : ".");
|
|
109
121
|
|
|
122
|
+
/** Committed HEAD tree, null for an unborn HEAD, undefined when evidence cannot be read. */
|
|
123
|
+
export function headTree(repo: Repo, budget?: SnapshotBudget): string | null | undefined {
|
|
124
|
+
const result = git(repo, ["rev-parse", "--verify", "--quiet", "HEAD^{tree}"], {}, budget);
|
|
125
|
+
return result.status === 0 ? result.stdout.trim() : result.status === 1 ? null : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One probe binds the committed HEAD id and tree; an unborn HEAD needs positive missing-ref proof. */
|
|
129
|
+
export function headState(repo: Repo, budget?: SnapshotBudget): { head: string | null; tree: string | null } | undefined {
|
|
130
|
+
const result = git(repo, ["rev-parse", "--quiet", "HEAD", "HEAD^{tree}"], {}, budget);
|
|
131
|
+
const values = result.status === 0 ? result.stdout.trim().split("\n") : [];
|
|
132
|
+
if (values.length === 2 && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(values[0]!) &&
|
|
133
|
+
/^[0-9a-f]+$/.test(values[1]!) && values[0]!.length === values[1]!.length) return { head: values[0]!, tree: values[1]! };
|
|
134
|
+
if (result.status === 0) return undefined;
|
|
135
|
+
const symbolic = git(repo, ["symbolic-ref", "--quiet", "HEAD"], {}, budget);
|
|
136
|
+
const ref = symbolic.status === 0 ? symbolic.stdout.trim() : "";
|
|
137
|
+
if (!ref.startsWith("refs/heads/")) return undefined;
|
|
138
|
+
const missing = git(repo, ["show-ref", "--verify", "--quiet", ref], {}, budget);
|
|
139
|
+
if (missing.status !== 1) return undefined;
|
|
140
|
+
const current = git(repo, ["rev-parse", "--verify", "--quiet", "HEAD"], {}, budget);
|
|
141
|
+
return current.status === 1 ? { head: null, tree: null } : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A bounded append cursor; raw reflog identities and messages never leave the reader. */
|
|
145
|
+
export interface HeadMoveCursor {
|
|
146
|
+
readonly head: string | null;
|
|
147
|
+
readonly tree: string | null;
|
|
148
|
+
readonly unbornRefHash?: string;
|
|
149
|
+
readonly dev: number;
|
|
150
|
+
readonly ino: number;
|
|
151
|
+
readonly size: number;
|
|
152
|
+
readonly anchorOffset: number;
|
|
153
|
+
readonly anchor: string;
|
|
154
|
+
}
|
|
155
|
+
const HEAD_LOG_TAIL_BYTES = 4096;
|
|
156
|
+
const HEAD_LOG_MAX_APPEND_BYTES = 128 * 1024;
|
|
157
|
+
const HEAD_LOG_MAX_MOVES = 64;
|
|
158
|
+
const checkSnapshotBudget = (budget?: SnapshotBudget) => { if (budget && performance.now() >= budget.deadline) throw new SnapshotBudgetExceeded(); };
|
|
159
|
+
const sameLogFile = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino;
|
|
160
|
+
function readHeadLog<T>(repo: Repo, budget: SnapshotBudget | undefined, read: (fd: number, stat: Stats) => T | undefined): T | undefined {
|
|
161
|
+
let fd: number | undefined;
|
|
162
|
+
try {
|
|
163
|
+
checkSnapshotBudget(budget);
|
|
164
|
+
const logs = join(repo.dir, "logs"), file = join(logs, "HEAD");
|
|
165
|
+
const directory = lstatSync(logs);
|
|
166
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) return undefined;
|
|
167
|
+
const named = lstatSync(file);
|
|
168
|
+
if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1) return undefined;
|
|
169
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
170
|
+
const before = fstatSync(fd);
|
|
171
|
+
if (!before.isFile() || before.nlink !== 1 || !sameLogFile(named, before) || before.size !== named.size) return undefined;
|
|
172
|
+
const result = read(fd, before);
|
|
173
|
+
const after = fstatSync(fd), current = lstatSync(file), parent = lstatSync(logs);
|
|
174
|
+
checkSnapshotBudget(budget);
|
|
175
|
+
if (!sameLogFile(before, after) || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs ||
|
|
176
|
+
!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !sameLogFile(after, current) || after.size !== current.size || after.mtimeMs !== current.mtimeMs || after.ctimeMs !== current.ctimeMs ||
|
|
177
|
+
!parent.isDirectory() || parent.isSymbolicLink() || !sameLogFile(directory, parent)) return undefined;
|
|
178
|
+
return result;
|
|
179
|
+
} catch (error) { if (error instanceof SnapshotBudgetExceeded) throw error; return undefined; }
|
|
180
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
181
|
+
}
|
|
182
|
+
function headLogBytes(fd: number, offset: number, length: number, budget?: SnapshotBudget): Buffer | undefined {
|
|
183
|
+
const data = Buffer.alloc(length);
|
|
184
|
+
let used = 0;
|
|
185
|
+
while (used < length) {
|
|
186
|
+
checkSnapshotBudget(budget);
|
|
187
|
+
const count = readSync(fd, data, used, length - used, offset + used);
|
|
188
|
+
if (!count) return undefined;
|
|
189
|
+
used += count;
|
|
190
|
+
}
|
|
191
|
+
return data;
|
|
192
|
+
}
|
|
193
|
+
const headLogHash = (data: Buffer) => createHash("sha256").update(data).digest("hex");
|
|
194
|
+
function headLogRow(line: string): { old: string; next: string; action: string } | undefined {
|
|
195
|
+
const row = /^([0-9a-f]{40}|[0-9a-f]{64}) ([0-9a-f]{40}|[0-9a-f]{64}) [^\r\n\t]+ [0-9]+ [+-][0-9]{4}\t([^\r\n]*)$/.exec(line);
|
|
196
|
+
return row && row[1]!.length === row[2]!.length ? { old: row[1]!, next: row[2]!, action: row[3]! } : undefined;
|
|
197
|
+
}
|
|
198
|
+
function missingHeadLog(repo: Repo): boolean {
|
|
199
|
+
try {
|
|
200
|
+
const parent = lstatSync(join(repo.dir, "logs"));
|
|
201
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) return false;
|
|
202
|
+
} catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
|
|
203
|
+
try { lstatSync(join(repo.dir, "logs", "HEAD")); return false; }
|
|
204
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
|
|
205
|
+
}
|
|
206
|
+
/** Read only a bounded, stable symbolic HEAD when the caller already proved it unborn. */
|
|
207
|
+
function symbolicHeadRef(repo: Repo, budget?: SnapshotBudget): string | undefined {
|
|
208
|
+
let fd: number | undefined;
|
|
209
|
+
try {
|
|
210
|
+
checkSnapshotBudget(budget);
|
|
211
|
+
const file = join(repo.dir, "HEAD"), named = lstatSync(file);
|
|
212
|
+
if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.size > 4096) return undefined;
|
|
213
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
214
|
+
const before = fstatSync(fd);
|
|
215
|
+
if (!before.isFile() || before.nlink !== 1 || !sameLogFile(named, before) || named.size !== before.size) return undefined;
|
|
216
|
+
const bytes = headLogBytes(fd, 0, before.size, budget);
|
|
217
|
+
const after = fstatSync(fd), current = lstatSync(file);
|
|
218
|
+
checkSnapshotBudget(budget);
|
|
219
|
+
if (!bytes || !sameLogFile(before, after) || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs ||
|
|
220
|
+
!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !sameLogFile(after, current) || after.size !== current.size ||
|
|
221
|
+
after.mtimeMs !== current.mtimeMs || after.ctimeMs !== current.ctimeMs) return undefined;
|
|
222
|
+
return /^ref: (refs\/heads\/[^\r\n]+)\n?$/.exec(bytes.toString("utf8"))?.[1];
|
|
223
|
+
} catch (error) { if (error instanceof SnapshotBudgetExceeded) throw error; return undefined; }
|
|
224
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
225
|
+
}
|
|
226
|
+
/** provenHead is host-only, paired with expectedTree from the very same headState probe. */
|
|
227
|
+
export function headMoveCursor(repo: Repo, expectedTree: string | null, budget?: SnapshotBudget, provenHead?: string | null): HeadMoveCursor | undefined {
|
|
228
|
+
if (provenHead !== undefined && (provenHead === null) !== (expectedTree === null)) return undefined;
|
|
229
|
+
if (expectedTree === null) {
|
|
230
|
+
let ref: string | undefined;
|
|
231
|
+
if (provenHead === null) ref = symbolicHeadRef(repo, budget);
|
|
232
|
+
else {
|
|
233
|
+
const symbolic = git(repo, ["symbolic-ref", "--quiet", "HEAD"], {}, budget);
|
|
234
|
+
ref = symbolic.status === 0 ? symbolic.stdout.trim() : undefined;
|
|
235
|
+
if (!ref?.startsWith("refs/heads/")) return undefined;
|
|
236
|
+
const missing = git(repo, ["show-ref", "--verify", "--quiet", ref], {}, budget);
|
|
237
|
+
if (missing.status !== 1) return undefined;
|
|
238
|
+
const current = git(repo, ["rev-parse", "--verify", "--quiet", "HEAD"], {}, budget);
|
|
239
|
+
if (current.status !== 1) return undefined;
|
|
240
|
+
}
|
|
241
|
+
if (!ref || !missingHeadLog(repo)) return undefined;
|
|
242
|
+
checkSnapshotBudget(budget);
|
|
243
|
+
return { head: null, tree: null, unbornRefHash: createHash("sha256").update(ref).digest("hex"), dev: 0, ino: 0, size: 0, anchorOffset: 0, anchor: headLogHash(Buffer.alloc(0)) };
|
|
244
|
+
}
|
|
245
|
+
const cursor = readHeadLog(repo, budget, (fd, stat): HeadMoveCursor | undefined => {
|
|
246
|
+
if (stat.size <= 0 || !Number.isSafeInteger(stat.size)) return undefined;
|
|
247
|
+
const anchorOffset = Math.max(0, stat.size - HEAD_LOG_TAIL_BYTES);
|
|
248
|
+
const tail = headLogBytes(fd, anchorOffset, stat.size - anchorOffset, budget);
|
|
249
|
+
if (!tail || tail[tail.length - 1] !== 10) return undefined;
|
|
250
|
+
const text = tail.toString("utf8"), start = text.lastIndexOf("\n", text.length - 2) + 1;
|
|
251
|
+
if (anchorOffset > 0 && start === 0) return undefined;
|
|
252
|
+
const last = headLogRow(text.slice(start, -1));
|
|
253
|
+
if (!last) return undefined;
|
|
254
|
+
return { head: last.next, tree: expectedTree, dev: stat.dev, ino: stat.ino, size: stat.size, anchorOffset, anchor: headLogHash(tail) };
|
|
255
|
+
});
|
|
256
|
+
if (!cursor) return undefined;
|
|
257
|
+
if (provenHead !== undefined) return cursor.head === provenHead ? cursor : undefined;
|
|
258
|
+
// Bind the observed log tip and its immutable tree to current HEAD in one bounded probe.
|
|
259
|
+
const current = git(repo, ["rev-parse", "--quiet", "HEAD", `${cursor.head}^{tree}`], {}, budget);
|
|
260
|
+
const values = current.status === 0 ? current.stdout.trim().split("\n") : [];
|
|
261
|
+
return values.length === 2 && values[0] === cursor.head && values[1] === expectedTree ? cursor : undefined;
|
|
262
|
+
}
|
|
263
|
+
export function incomingHeadPaths(repo: Repo, start: HeadMoveCursor, end: HeadMoveCursor, budget?: SnapshotBudget): string[] | undefined {
|
|
264
|
+
const validCursor = (cursor: HeadMoveCursor) => Number.isSafeInteger(cursor.size) && cursor.size >= 0 &&
|
|
265
|
+
(cursor.size > 0 ? !!cursor.head && !!cursor.tree : cursor.head === null && cursor.tree === null && /^[0-9a-f]{64}$/.test(cursor.unbornRefHash ?? "")) &&
|
|
266
|
+
cursor.anchorOffset === Math.max(0, cursor.size - HEAD_LOG_TAIL_BYTES) && /^[0-9a-f]{64}$/.test(cursor.anchor);
|
|
267
|
+
if (!validCursor(start) || !validCursor(end) || (start.size > 0 && (start.dev !== end.dev || start.ino !== end.ino)) ||
|
|
268
|
+
end.size < start.size || end.size - start.size > HEAD_LOG_MAX_APPEND_BYTES) return undefined;
|
|
269
|
+
if (!end.size) {
|
|
270
|
+
checkSnapshotBudget(budget);
|
|
271
|
+
const unchanged = start.unbornRefHash === end.unbornRefHash && missingHeadLog(repo);
|
|
272
|
+
checkSnapshotBudget(budget);
|
|
273
|
+
return unchanged ? [] : undefined;
|
|
274
|
+
}
|
|
275
|
+
const appended = readHeadLog(repo, budget, (fd, stat) => {
|
|
276
|
+
if (stat.dev !== end.dev || stat.ino !== end.ino || stat.size < end.size) return undefined;
|
|
277
|
+
for (const cursor of [start, end]) {
|
|
278
|
+
const anchor = headLogBytes(fd, cursor.anchorOffset, cursor.size - cursor.anchorOffset, budget);
|
|
279
|
+
if (!anchor || headLogHash(anchor) !== cursor.anchor) return undefined;
|
|
280
|
+
}
|
|
281
|
+
return headLogBytes(fd, start.size, end.size - start.size, budget);
|
|
282
|
+
});
|
|
283
|
+
if (!appended) return undefined;
|
|
284
|
+
if (!appended.length) return start.head === end.head && start.tree === end.tree ? [] : undefined;
|
|
285
|
+
if (appended[appended.length - 1] !== 10) return undefined;
|
|
286
|
+
const lines = appended.toString("utf8").slice(0, -1).split("\n");
|
|
287
|
+
if (lines.length > HEAD_LOG_MAX_MOVES) return undefined;
|
|
288
|
+
const own = new Set<string>(), incoming = new Set<string>();
|
|
289
|
+
let previous = start.head;
|
|
290
|
+
for (const line of lines) {
|
|
291
|
+
checkSnapshotBudget(budget);
|
|
292
|
+
const row = headLogRow(line);
|
|
293
|
+
if (!row || (previous === null ? !/^0+$/.test(row.old) : row.old !== previous)) return undefined;
|
|
294
|
+
// Git preserves the invoking command in sequencer actions (for example pull -q --rebase (pick)).
|
|
295
|
+
// Classify the native phase before considering ordinary command-shaped incoming actions.
|
|
296
|
+
const separator = row.action.indexOf(": ");
|
|
297
|
+
const action = separator < 0 ? "" : row.action.slice(0, separator);
|
|
298
|
+
const detail = separator < 0 ? "" : row.action.slice(separator + 2);
|
|
299
|
+
const sequencerOwn = /^.+ \((?:pick|reword|edit|squash|fixup|continue|finish)\)$/.test(action);
|
|
300
|
+
const ownMove = sequencerOwn || /^(?:commit(?: \(amend\)| \(initial\))?|cherry-pick)$/.test(action);
|
|
301
|
+
const incomingStart = /^(?:rebase|pull(?: [^(]*)?) \(start\)$/.test(action) && /^checkout .+/.test(detail);
|
|
302
|
+
const incomingCommand = !action.includes("(") && /^(?:merge|pull)(?: [^:]+)?$/.test(action) && !!detail;
|
|
303
|
+
let incomingMove = !ownMove && (incomingStart || incomingCommand || action === "checkout" && /^moving from .+ to .+$/.test(detail));
|
|
304
|
+
if (action === "reset" && /^moving to .+/.test(detail)) {
|
|
305
|
+
// Policy: a positively fast-forward reset is incoming, even when its target contains the peer's own
|
|
306
|
+
// descendant commit; reflog ancestry alone cannot distinguish that intent. Rewinds/unknown resets refuse.
|
|
307
|
+
const ancestor = git(repo, ["merge-base", "--is-ancestor", row.old, row.next], {}, budget);
|
|
308
|
+
if (ancestor.status !== 0) return undefined;
|
|
309
|
+
incomingMove = true;
|
|
310
|
+
}
|
|
311
|
+
if (!ownMove && !incomingMove) return undefined;
|
|
312
|
+
let paths: string[] | undefined;
|
|
313
|
+
if (/^0+$/.test(row.old)) {
|
|
314
|
+
const root = git(repo, ["diff-tree", "--root", "--no-commit-id", "-r", "--no-renames", "--name-only", "-z", row.next, "--", scope(repo)], {}, budget);
|
|
315
|
+
paths = root.status === 0 ? root.stdout.split("\0").filter(Boolean) : undefined;
|
|
316
|
+
} else paths = knownChangedPaths(repo, row.old, row.next, budget);
|
|
317
|
+
if (!paths) return undefined;
|
|
318
|
+
for (const path of paths) { checkSnapshotBudget(budget); (ownMove ? own : incoming).add(path); }
|
|
319
|
+
previous = row.next;
|
|
320
|
+
}
|
|
321
|
+
if (previous !== end.head) return undefined;
|
|
322
|
+
// A path touched by both kinds cannot be separated at file-level undo granularity.
|
|
323
|
+
const hasAncestor = (paths: Set<string>, path: string) => {
|
|
324
|
+
for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) {
|
|
325
|
+
checkSnapshotBudget(budget);
|
|
326
|
+
if (paths.has(path.slice(0, at))) return true;
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
};
|
|
330
|
+
for (const path of own) {
|
|
331
|
+
checkSnapshotBudget(budget);
|
|
332
|
+
if (incoming.has(path) || hasAncestor(incoming, path)) return undefined;
|
|
333
|
+
}
|
|
334
|
+
for (const path of incoming) {
|
|
335
|
+
checkSnapshotBudget(budget);
|
|
336
|
+
if (hasAncestor(own, path)) return undefined;
|
|
337
|
+
}
|
|
338
|
+
checkSnapshotBudget(budget);
|
|
339
|
+
return [...incoming];
|
|
340
|
+
}
|
|
341
|
+
|
|
110
342
|
/** A tree id for the project's files as they are now, leaving out the hub's own state. */
|
|
111
|
-
export function snapshot(repo: Repo): string | undefined {
|
|
343
|
+
export function snapshot(repo: Repo, budget?: SnapshotBudget): string | undefined {
|
|
112
344
|
const tmp = mkdtempSync(join(tmpdir(), "ahub-snap-"));
|
|
113
345
|
try {
|
|
114
346
|
const env = { GIT_INDEX_FILE: join(tmp, "index") };
|
|
@@ -122,12 +354,12 @@ export function snapshot(repo: Repo): string | undefined {
|
|
|
122
354
|
utimesSync(env.GIT_INDEX_FILE, atime, mtime);
|
|
123
355
|
}
|
|
124
356
|
// `ahub reset --all` archives hub.db (task text, PII included) under .agenthub/archive: never copy it either.
|
|
125
|
-
if (git(repo, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**", ":(exclude,glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(exclude,glob)**/.agenthub/shadow.git/**"] : [])], env).status !== 0) return undefined;
|
|
357
|
+
if (git(repo, ["add", "-A", "--", scope(repo), ":(exclude,glob)**/.agenthub/state/**", ":(exclude,glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(exclude,glob)**/.agenthub/shadow.git/**"] : [])], env, budget).status !== 0) return undefined;
|
|
126
358
|
// The copy starts from the user's index: hub state somebody tracked or staged is still in it, and the exclude above
|
|
127
359
|
// only keeps `add` from touching it. Take it out explicitly; -f because staged state the hub has rewritten since
|
|
128
360
|
// matches neither HEAD nor the file, which `rm --cached` otherwise refuses. Only the copy changes.
|
|
129
|
-
if (git(repo, ["rm", "-r", "-f", "--cached", "--quiet", "--ignore-unmatch", "--", ":(glob)**/.agenthub/state/**", ":(glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(glob)**/.agenthub/shadow.git/**"] : [])], env).status !== 0) return undefined;
|
|
130
|
-
const r = git(repo, ["write-tree"], env);
|
|
361
|
+
if (git(repo, ["rm", "-r", "-f", "--cached", "--quiet", "--ignore-unmatch", "--", ":(glob)**/.agenthub/state/**", ":(glob)**/.agenthub/archive/**", ...(repo.shadow ? [":(glob)**/.agenthub/shadow.git/**"] : [])], env, budget).status !== 0) return undefined;
|
|
362
|
+
const r = git(repo, ["write-tree"], env, budget);
|
|
131
363
|
return r.status === 0 ? r.stdout.trim() : undefined;
|
|
132
364
|
} finally {
|
|
133
365
|
rmSync(tmp, { recursive: true, force: true });
|
|
@@ -135,9 +367,12 @@ export function snapshot(repo: Repo): string | undefined {
|
|
|
135
367
|
}
|
|
136
368
|
|
|
137
369
|
/** Project paths (relative to the top level) that differ between two trees. */
|
|
370
|
+
export function knownChangedPaths(repo: Repo, from: string, to: string, budget?: SnapshotBudget): string[] | undefined {
|
|
371
|
+
const r = git(repo, ["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to, "--", scope(repo)], {}, budget);
|
|
372
|
+
return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : undefined;
|
|
373
|
+
}
|
|
138
374
|
export function changedPaths(repo: Repo, from: string, to: string): string[] {
|
|
139
|
-
|
|
140
|
-
return r.status === 0 ? r.stdout.split("\0").filter(Boolean) : [];
|
|
375
|
+
return knownChangedPaths(repo, from, to) ?? [];
|
|
141
376
|
}
|
|
142
377
|
|
|
143
378
|
const inTree = (top: string | Repo, tree: string, path: string) => git(top, ["rev-parse", "--verify", "--quiet", `${tree}:${path}`]).status === 0;
|
package/src/hub/tasks.ts
CHANGED
|
@@ -1783,7 +1783,7 @@ export class Tasks {
|
|
|
1783
1783
|
}
|
|
1784
1784
|
|
|
1785
1785
|
/** `override`: a person's explicit assign, which past refusals do not block and which drops an agent's reservation (#207). */
|
|
1786
|
-
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number } = {}): Promise<Task> {
|
|
1786
|
+
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; namedOwner?: PeerId; event?: string; note?: string; reason?: TaskMoveReason; clearOnFail?: boolean; exclude?: PeerId[]; context?: string; contextWithheld?: boolean; claim?: boolean; override?: boolean; roster?: boolean; planner?: boolean; expectedFlowRevision?: number; refuseNamed?: "pre" | "plain" } = {}): Promise<Task> {
|
|
1787
1787
|
if (task.state === "backlog") throw new PreEffectToolRefusal(`task #${task.id}: pull backlog work before assigning it`);
|
|
1788
1788
|
const mode = (this.d.sdlc?.config() ?? sdlcConfig(undefined)).mode;
|
|
1789
1789
|
let stagedClaimGap: string | undefined;
|
|
@@ -1797,6 +1797,7 @@ export class Tasks {
|
|
|
1797
1797
|
if (mode === "enforce") {
|
|
1798
1798
|
if (opts.claim && opts.namedOwner === by) stagedClaimGap = gap;
|
|
1799
1799
|
else {
|
|
1800
|
+
if (opts.refuseNamed && opts.namedOwner) throw new (opts.refuseNamed === "pre" ? PreEffectToolRefusal : Error)(`cannot assign task #${current.id} to ${opts.namedOwner}: ${gap}; planner: call hub_planner_groom then hub_plan_review`); // #601, before the stall write
|
|
1800
1801
|
this.reportStall(current, { kind: "no-eligible-peer", code: "policy" });
|
|
1801
1802
|
return current;
|
|
1802
1803
|
}
|
|
@@ -1814,6 +1815,11 @@ export class Tasks {
|
|
|
1814
1815
|
}
|
|
1815
1816
|
const passed = this.passedOver(task, a);
|
|
1816
1817
|
if (!a.owner) {
|
|
1818
|
+
// An explicit assignment says why the named peer cannot take it, before any write (#601).
|
|
1819
|
+
if (opts.refuseNamed && opts.namedOwner) {
|
|
1820
|
+
const why = a.refusals?.find(row => row.role === "owner" && row.peer === opts.namedOwner)?.reason ?? "not eligible";
|
|
1821
|
+
throw new (opts.refuseNamed === "pre" ? PreEffectToolRefusal : Error)(`cannot assign task #${task.id} to ${opts.namedOwner}: ${why}${/offline|not attached/.test(why) ? "; assign again once it is attached" : ""}`);
|
|
1822
|
+
}
|
|
1817
1823
|
if (!task.owner && task.state === "proposed") this.reportStall(task, this.ownerStallCause(task, a));
|
|
1818
1824
|
this.releaseNotice(opts.roster
|
|
1819
1825
|
? `task ${this.publicTitle(task)}: ${task.owner} is off roster and no peer can take it; it stays with ${task.owner} in ${task.state}; assign with: ahub task assign ${task.id} <peer>`
|
|
@@ -2048,6 +2054,14 @@ export class Tasks {
|
|
|
2048
2054
|
if (this.stallView?.key !== key) this.stallView = { key, stalls: this.stallCandidates.rows.filter(({ stall, task, planned }) => stall.cause.kind === "cancelled-dependency" || !(planned ? this.plannerAssignment(task, true) : assign(task, this.states(), this.d.routing(), { exclude: this.excluded(task), ...this.health() })).owner).map(({ stall }) => stall) };
|
|
2049
2055
|
return structuredClone(this.stallView.stalls);
|
|
2050
2056
|
}
|
|
2057
|
+
/** Why a proposal's named owner (a self-claim included) did not get it, from the stall its proposal recorded (#601); undefined when it did. */
|
|
2058
|
+
namedOwnerNote(task: Task, named: PeerId | undefined, by: PeerId): string | undefined {
|
|
2059
|
+
if (!named || task.owner || task.state !== "proposed") return undefined;
|
|
2060
|
+
const stall = this.d.board.stalls().find(row => row.task === task.id);
|
|
2061
|
+
if (!stall) return undefined;
|
|
2062
|
+
const code = stall.cause.refusals?.find(row => row.peer === named)?.code ?? (stall.cause.peer === named ? stall.cause.code : undefined);
|
|
2063
|
+
return `${named} was not assigned${code ? ` (${code})` : ""}: ${stall.cause.kind}; assign it again with hub_task_assign once ${named} can take it`;
|
|
2064
|
+
}
|
|
2051
2065
|
private ownerStallCause(task: Task, admission: Assignment): TaskStall["cause"] {
|
|
2052
2066
|
const refusal = admission.refusals?.find(row => row.role === "owner" && row.peer === task.reserved);
|
|
2053
2067
|
// Persist the existing stall vocabulary; the assignment/explain keeps the precise instance/family refusal.
|
|
@@ -3300,7 +3314,7 @@ export class Tasks {
|
|
|
3300
3314
|
return next;
|
|
3301
3315
|
}
|
|
3302
3316
|
// Work that waits is handed to nobody yet (issue #207): the peer becomes its reserved owner instead.
|
|
3303
|
-
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER, expectedFlowRevision });
|
|
3317
|
+
if (!this.waitsFor(task).length) return this.assignOwner(task, by, { candidates: [peer], namedOwner: peer, event: "reassigned", reason: "manual", override: by === USER, expectedFlowRevision, refuseNamed: preEffect ? "pre" : "plain" });
|
|
3304
3318
|
const next = this.d.board.update(task.id, by, "reserved", { reserved: peer }, `for ${peer}`, { reason: "manual" }, expectedFlowRevision);
|
|
3305
3319
|
this.releaseNotice(`task ${this.publicTitle(next)} reserved for ${peer} by ${by}; it is offered to ${peer} first once what it waits for is approved`);
|
|
3306
3320
|
return next;
|