@ai-dossier/sched 0.67.1 → 0.68.0

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.
@@ -0,0 +1,675 @@
1
+ "use strict";
2
+ /**
3
+ * Restart never discards work (#945 ask 3, #940 ask 1).
4
+ *
5
+ * Before the engine respawns an agent onto a worktree that already exists (a
6
+ * tail, a batch member, a takeover), whatever the dead agent left there is
7
+ * preserved: a WIP commit under `refs/sched-rescue/<unit>-<ts>` (a NON-branch
8
+ * ref namespace: pushing it triggers no CI and appears in no branch list),
9
+ * pushed to origin, journaled `work-preserved`, and the respawned agent is told
10
+ * to resume from it instead of resetting to the last pushed head (#920 lost 16
11
+ * gated-but-uncommitted files to exactly that).
12
+ *
13
+ * SECURITY: a rescue ref that reaches origin is world-readable on a PUBLIC repo,
14
+ * and deleting a ref does NOT unpublish it (GitHub keeps objects reachable by
15
+ * SHA), so the TTL below is housekeeping, not privacy. Therefore:
16
+ * - only tracked changes (`git add -u`, deletions included) and unpushed
17
+ * commits are ever PUSHED. A rescue whose tree also holds untracked /
18
+ * staged-new files stays LOCAL (`refs/sched-rescue/*` in the local repo; the
19
+ * worktree and its .git survive a respawn anyway);
20
+ * - as defence in depth the FINAL tree is filtered: any path new relative to
21
+ * HEAD that matches a secret pattern (`.env*`, `*.pem`, `*.key`, `*token*`,
22
+ * `*.tfstate`, `kubeconfig`, `.docker/config.json`, …) is dropped, and
23
+ * untracked files are size/count capped, tracked changes over
24
+ * {@link RESCUE_MAX_TRACKED_BYTES} are left out. Skips are counted in the
25
+ * journal and told to the respawned agent.
26
+ * Submodule / nested-repo contents are NOT captured (one commit of the outer repo).
27
+ *
28
+ * TTL: rescue refs are disposable. `pruneRescueRefs` deletes those whose NAME
29
+ * timestamp is older than {@link RESCUE_REF_TTL_MS} (14 days) locally and on
30
+ * origin (listed with `ls-remote`), plus their pushed markers; `sched start`
31
+ * runs it, and the engine repeats it daily outside the state lock.
32
+ *
33
+ * The commit is built with a THROWAWAY index (`GIT_INDEX_FILE`, seeded from the
34
+ * real index), so the worktree, its index and its HEAD are untouched —
35
+ * preservation can never be the thing that loses work. Idempotent: an existing
36
+ * rescue ref for the same unit whose tree equals the current tree is reused, so
37
+ * a respawn loop does not mint (or re-push) a ref per tick. Everything goes
38
+ * through the injected `ExecFn` (never throws), so tests use a real temp repo
39
+ * and the engine's own bounded-timeout exec.
40
+ */
41
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
42
+ if (k2 === undefined) k2 = k;
43
+ var desc = Object.getOwnPropertyDescriptor(m, k);
44
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
45
+ desc = { enumerable: true, get: function() { return m[k]; } };
46
+ }
47
+ Object.defineProperty(o, k2, desc);
48
+ }) : (function(o, m, k, k2) {
49
+ if (k2 === undefined) k2 = k;
50
+ o[k2] = m[k];
51
+ }));
52
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
53
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
54
+ }) : function(o, v) {
55
+ o["default"] = v;
56
+ });
57
+ var __importStar = (this && this.__importStar) || (function () {
58
+ var ownKeys = function(o) {
59
+ ownKeys = Object.getOwnPropertyNames || function (o) {
60
+ var ar = [];
61
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
62
+ return ar;
63
+ };
64
+ return ownKeys(o);
65
+ };
66
+ return function (mod) {
67
+ if (mod && mod.__esModule) return mod;
68
+ var result = {};
69
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
70
+ __setModuleDefault(result, mod);
71
+ return result;
72
+ };
73
+ })();
74
+ Object.defineProperty(exports, "__esModule", { value: true });
75
+ exports.RESCUE_MAX_TRACKED_BYTES = exports.RESCUE_MAX_FILES = exports.RESCUE_MAX_TOTAL_BYTES = exports.RESCUE_MAX_FILE_BYTES = exports.RESCUE_REF_TTL_MS = exports.RESCUE_REF_PREFIX = void 0;
76
+ exports.probeWorktree = probeWorktree;
77
+ exports.isSecretPath = isSecretPath;
78
+ exports.selectRescuableUntracked = selectRescuableUntracked;
79
+ exports.rescueUnitSlug = rescueUnitSlug;
80
+ exports.rescueRefName = rescueRefName;
81
+ exports.firstOccurrence = firstOccurrence;
82
+ exports.preserveWork = preserveWork;
83
+ exports.skippedSummary = skippedSummary;
84
+ exports.preservedWorkInstruction = preservedWorkInstruction;
85
+ exports.isRegisteredWorktree = isRegisteredWorktree;
86
+ exports.takeoverWorktreeRefusal = takeoverWorktreeRefusal;
87
+ exports.findGatedWorkEvidence = findGatedWorkEvidence;
88
+ exports.pushedHeadDate = pushedHeadDate;
89
+ exports.rescueRefTime = rescueRefTime;
90
+ exports.pruneRescueRefs = pruneRescueRefs;
91
+ const fs = __importStar(require("node:fs"));
92
+ const path = __importStar(require("node:path"));
93
+ const dossier_root_1 = require("./dossier-root");
94
+ const teardown_1 = require("./teardown");
95
+ exports.RESCUE_REF_PREFIX = 'refs/sched-rescue/';
96
+ /** Rescue refs older than this are pruned (locally and on origin). */
97
+ exports.RESCUE_REF_TTL_MS = 14 * 24 * 60 * 60 * 1000;
98
+ /** Untracked-file caps: anything beyond stays in the worktree and is reported, never pushed. */
99
+ exports.RESCUE_MAX_FILE_BYTES = 1024 * 1024;
100
+ exports.RESCUE_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
101
+ exports.RESCUE_MAX_FILES = 500;
102
+ /** A tracked change larger than this is left out of the rescue (the worktree keeps it). */
103
+ exports.RESCUE_MAX_TRACKED_BYTES = 50 * 1024 * 1024;
104
+ const realpath = (p) => {
105
+ try {
106
+ return fs.realpathSync(p);
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ };
112
+ // Counts of NUL-terminated entries, never listings (a huge dirty tree must not overflow the
113
+ // exec's output buffer). git's exit status is checked BEFORE counting: a failing `git status`
114
+ // piped straight into `wc` would read as "0 dirty files" and a corrupt index as clean.
115
+ const STATUS_COUNT_SCRIPT = 't="$(mktemp)" || exit 1; trap \'rm -f "$t"\' EXIT; ' +
116
+ 'git --no-optional-locks status --porcelain -z "$@" > "$t" || exit 1; tr -cd \'\\0\' < "$t" | wc -c';
117
+ /** Null when `worktree` is not its own git work tree root (or has no HEAD); `unknown` when git cannot report its state. */
118
+ function probeWorktree(exec, worktree) {
119
+ const toplevel = exec('git', ['rev-parse', '--show-toplevel'], worktree);
120
+ if (toplevel === null)
121
+ return null;
122
+ const a = realpath(toplevel);
123
+ if (a === null || a !== realpath(worktree))
124
+ return null;
125
+ const head = exec('git', ['rev-parse', 'HEAD'], worktree);
126
+ if (head === null)
127
+ return null;
128
+ const all = exec('sh', ['-c', STATUS_COUNT_SCRIPT, 'sh'], worktree);
129
+ const tracked = exec('sh', ['-c', STATUS_COUNT_SCRIPT, 'sh', '--untracked-files=no'], worktree);
130
+ const unpushed = exec('git', ['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree);
131
+ const n = (s) => (s === null ? Number.NaN : Number.parseInt(s.trim(), 10));
132
+ if (Number.isNaN(n(all)) || Number.isNaN(n(tracked)) || Number.isNaN(n(unpushed))) {
133
+ return {
134
+ dirty_files: 1,
135
+ tracked_dirty_files: 1,
136
+ unpushed_commits: 0,
137
+ head: head.trim(),
138
+ unknown: true,
139
+ };
140
+ }
141
+ return {
142
+ dirty_files: n(all),
143
+ tracked_dirty_files: n(tracked),
144
+ unpushed_commits: n(unpushed),
145
+ head: head.trim(),
146
+ };
147
+ }
148
+ /** Paths new to the tree (untracked / staged-new) that must never be part of a rescue commit. */
149
+ const SECRET_PATH_PATTERNS = [
150
+ /(^|\/)\.env/i,
151
+ /\.env$/i,
152
+ /\.(pem|key|p12|pfx|jks|keystore|gpg|kdbx|ppk)$/i,
153
+ /(^|\/)id_(rsa|ed25519|ecdsa|dsa)/i,
154
+ /credential/i,
155
+ /secret/i,
156
+ /(^|\/)\.(npmrc|netrc|pgpass|git-credentials)$/i,
157
+ /(^|\/)\.(ssh|aws|gnupg|kube)\//i,
158
+ /\.env\./i,
159
+ /\.(key|pem)\./i,
160
+ /token/i,
161
+ /\.tfstate(\.|$)/i,
162
+ /\.tfvars(\.|$)/i,
163
+ /kubeconfig/i,
164
+ /(^|\/)\.docker\/config\.json$/i,
165
+ /(^|\/)\.(pypirc|vault-token|htpasswd|s3cfg)$/i,
166
+ ];
167
+ function isSecretPath(rel) {
168
+ return SECRET_PATH_PATTERNS.some((re) => re.test(rel));
169
+ }
170
+ /** Apply the secret-pattern exclusion and the size/count caps to candidate untracked paths. */
171
+ function selectRescuableUntracked(worktree, candidates) {
172
+ const sel = {
173
+ allowed: [],
174
+ excluded: [],
175
+ skipped: { secret: 0, large: 0, over_limit: 0, not_a_file: 0 },
176
+ };
177
+ let total = 0;
178
+ for (const rel of [...new Set(candidates)].sort()) {
179
+ if (rel === '' || rel.endsWith('/')) {
180
+ // A nested repository / submodule directory: contents are not captured.
181
+ if (rel !== '') {
182
+ sel.excluded.push(rel);
183
+ sel.skipped.not_a_file++;
184
+ }
185
+ continue;
186
+ }
187
+ if (isSecretPath(rel)) {
188
+ sel.excluded.push(rel);
189
+ sel.skipped.secret++;
190
+ continue;
191
+ }
192
+ let size;
193
+ try {
194
+ const st = fs.lstatSync(path.join(worktree, rel));
195
+ if (!st.isFile() && !st.isSymbolicLink()) {
196
+ sel.excluded.push(rel);
197
+ sel.skipped.not_a_file++;
198
+ continue;
199
+ }
200
+ size = st.isSymbolicLink() ? 0 : st.size;
201
+ }
202
+ catch {
203
+ continue; // vanished between listing and stat
204
+ }
205
+ if (size > exports.RESCUE_MAX_FILE_BYTES) {
206
+ sel.excluded.push(rel);
207
+ sel.skipped.large++;
208
+ continue;
209
+ }
210
+ if (sel.allowed.length >= exports.RESCUE_MAX_FILES || total + size > exports.RESCUE_MAX_TOTAL_BYTES) {
211
+ sel.excluded.push(rel);
212
+ sel.skipped.over_limit++;
213
+ continue;
214
+ }
215
+ total += size;
216
+ sel.allowed.push(rel);
217
+ }
218
+ return sel;
219
+ }
220
+ /** `batch:b-20260929-01#601` → `batch-b-20260929-01-601` — safe as a ref component and a path. */
221
+ function rescueUnitSlug(unit) {
222
+ // `..`, a leading `.` and `.lock` are invalid in a ref name (`git check-ref-format`).
223
+ return (unit
224
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
225
+ .replace(/\.{2,}/g, '.')
226
+ .replace(/^[-.]+|[-.]+$/g, '') || 'unit');
227
+ }
228
+ /** The ref name below {@link RESCUE_REF_PREFIX}: `<slug>-<UTC timestamp>`. */
229
+ function rescueRefName(unit, now) {
230
+ const ts = now
231
+ .toISOString()
232
+ .replace(/[-:]/g, '')
233
+ .replace(/\.\d+Z$/, 'Z');
234
+ return `${rescueUnitSlug(unit)}-${ts}`;
235
+ }
236
+ // Step 1 builds a throwaway index at $1 seeded from the REAL index ($src is resolved BEFORE
237
+ // GIT_INDEX_FILE is exported, or it would point at the throwaway itself and every staged
238
+ // rename / intent-to-add would be lost), then applies `add -u`, the allowed untracked list
239
+ // ($2), the reset list ($3: over-large tracked changes back to HEAD) and the exclude list
240
+ // ($4: secret / capped new files). Lists are NUL-separated pathspec files; GIT_LITERAL_PATHSPECS
241
+ // stops a filename containing glob characters from matching other files.
242
+ const INDEX_SCRIPT = `
243
+ set -e
244
+ src="$(git rev-parse --git-path index)"
245
+ if [ -f "$src" ]; then cp "$src" "$1"; else GIT_INDEX_FILE="$1" git read-tree HEAD; fi
246
+ export GIT_INDEX_FILE="$1"
247
+ git add -u
248
+ if [ -s "$2" ]; then GIT_LITERAL_PATHSPECS=1 git add --pathspec-from-file="$2" --pathspec-file-nul; fi
249
+ if [ -s "$3" ]; then GIT_LITERAL_PATHSPECS=1 git reset -q HEAD --pathspec-from-file="$3" --pathspec-file-nul; fi
250
+ if [ -s "$4" ]; then GIT_LITERAL_PATHSPECS=1 git rm --cached -q -f --ignore-unmatch --pathspec-from-file="$4" --pathspec-file-nul; fi
251
+ `;
252
+ // Step 2 lists the paths NEW to the final index relative to HEAD (A/C/R; renames split so a
253
+ // rename INTO a secret name shows up as an add).
254
+ const NEW_PATHS_SCRIPT = 'GIT_INDEX_FILE="$1" git diff-index --cached --no-renames --name-only -z --diff-filter=ACR HEAD';
255
+ // Step 3 drops paths ($2 list) from the final index.
256
+ const DROP_SCRIPT = 'GIT_INDEX_FILE="$1" GIT_LITERAL_PATHSPECS=1 git rm --cached -q -f --ignore-unmatch --pathspec-from-file="$2" --pathspec-file-nul';
257
+ // Step 4 ($1 index, $2 ref name, $3 slug, $4 message): write the tree, reuse an existing rescue
258
+ // for the same unit and tree, else commit-tree + update-ref (must-not-exist, suffix on clash).
259
+ const COMMIT_SCRIPT = `
260
+ set -e
261
+ export GIT_INDEX_FILE="$1"
262
+ tree="$(git write-tree)"
263
+ existing="$(git for-each-ref --sort=-creatordate --format='%(objectname) %(tree) %(refname)' "refs/sched-rescue/$3-*" | awk -v t="$tree" -v p="refs/sched-rescue/$3-" '$2 == t && index($3, p) == 1 { r = substr($3, length(p) + 1); if (r ~ /^[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z(-[0-9]+)?$/) { print $1 " " $3; exit } }')"
264
+ if [ -n "$existing" ]; then
265
+ echo "reused $existing"
266
+ exit 0
267
+ fi
268
+ commit="$(git -c user.name='ai-dossier sched' -c user.email='sched@ai-dossier.invalid' commit-tree "$tree" -p HEAD -m "$4")"
269
+ ref="refs/sched-rescue/$2"
270
+ n=1
271
+ while ! git update-ref "$ref" "$commit" "" 2>/dev/null; do
272
+ n=$((n + 1))
273
+ if [ "$n" -gt 20 ]; then exit 1; fi
274
+ ref="refs/sched-rescue/$2-$n"
275
+ done
276
+ echo "created $commit $ref"
277
+ `;
278
+ const announced = new WeakMap();
279
+ /**
280
+ * True the first time `key` is seen for `scope` (e.g. the journal): a respawn loop that
281
+ * fails the same way every tick journals ONCE per (unit, reason), not once per tick
282
+ * (#610/#630 pattern).
283
+ */
284
+ function firstOccurrence(scope, key) {
285
+ const set = announced.get(scope) ?? new Set();
286
+ announced.set(scope, set);
287
+ if (set.has(key))
288
+ return false;
289
+ set.add(key);
290
+ return true;
291
+ }
292
+ const splitNul = (s) => (s ?? '').split('\0').filter((f) => f !== '');
293
+ function preserveWork(exec, opts) {
294
+ const probe = probeWorktree(exec, opts.worktree);
295
+ if (probe === null)
296
+ return { kind: 'skipped', reason: 'not-a-worktree-or-unreadable' };
297
+ if (probe.unknown) {
298
+ return {
299
+ kind: 'failed',
300
+ reason: 'git could not report the worktree state (treated as dirty)',
301
+ probe,
302
+ };
303
+ }
304
+ if (probe.dirty_files === 0 && probe.unpushed_commits === 0)
305
+ return { kind: 'clean', probe };
306
+ const gitdirOut = exec('git', ['rev-parse', '--absolute-git-dir'], opts.worktree);
307
+ if (gitdirOut === null)
308
+ return { kind: 'failed', reason: 'could not locate the git dir', probe };
309
+ const gitdir = gitdirOut.trim();
310
+ const candidates = [
311
+ ...splitNul(exec('git', ['ls-files', '-z', '--others', '--exclude-standard'], opts.worktree)),
312
+ ...splitNul(exec('git', ['diff', '--cached', '--name-only', '-z', '--diff-filter=A'], opts.worktree)),
313
+ ];
314
+ const sel = selectRescuableUntracked(opts.worktree, candidates);
315
+ // Tracked changes are captured by `add -u`, but a huge one is left out (reset to HEAD's version).
316
+ const largeTracked = splitNul(exec('git', ['diff', 'HEAD', '--name-only', '-z', '--diff-filter=MT'], opts.worktree)).filter((rel) => {
317
+ try {
318
+ return fs.lstatSync(path.join(opts.worktree, rel)).size > exports.RESCUE_MAX_TRACKED_BYTES;
319
+ }
320
+ catch {
321
+ return false;
322
+ }
323
+ });
324
+ const skipped = { ...sel.skipped, large: sel.skipped.large + largeTracked.length };
325
+ const tag = `${process.pid}-${opts.now.getTime()}`;
326
+ const idx = path.join(gitdir, `sched-rescue-index-${tag}`);
327
+ const files = {
328
+ add: path.join(gitdir, `sched-rescue-add-${tag}`),
329
+ reset: path.join(gitdir, `sched-rescue-reset-${tag}`),
330
+ exclude: path.join(gitdir, `sched-rescue-exclude-${tag}`),
331
+ drop: path.join(gitdir, `sched-rescue-drop-${tag}`),
332
+ };
333
+ const nul = (l) => l.map((f) => `${f}\0`).join('');
334
+ const refName = rescueRefName(opts.unit, opts.now);
335
+ const message = `WIP rescue: ${opts.unit} (${probe.dirty_files} uncommitted file(s), ${probe.unpushed_commits} unpushed commit(s)) preserved by sched before respawn`;
336
+ let out;
337
+ let newPaths;
338
+ try {
339
+ try {
340
+ fs.writeFileSync(files.add, nul(sel.allowed), { mode: 0o600 });
341
+ fs.writeFileSync(files.reset, nul(largeTracked), { mode: 0o600 });
342
+ fs.writeFileSync(files.exclude, nul(sel.excluded), { mode: 0o600 });
343
+ }
344
+ catch {
345
+ return { kind: 'failed', reason: 'could not write the rescue pathspec lists', probe };
346
+ }
347
+ const built = exec('sh', [
348
+ '-c',
349
+ `${INDEX_SCRIPT}\necho ok`,
350
+ 'sched-rescue',
351
+ idx,
352
+ files.add,
353
+ files.reset,
354
+ files.exclude,
355
+ ], opts.worktree);
356
+ if (built === null)
357
+ return { kind: 'failed', reason: 'could not build the rescue index', probe };
358
+ // Final-tree filter: whatever reached the index (renames, `add -N`, staged-new) is re-checked.
359
+ const listed = exec('sh', ['-c', NEW_PATHS_SCRIPT, 'sched-rescue', idx], opts.worktree);
360
+ if (listed === null)
361
+ return { kind: 'failed', reason: 'could not list the rescue tree', probe };
362
+ newPaths = splitNul(listed);
363
+ const dropped = newPaths.filter(isSecretPath);
364
+ if (dropped.length > 0) {
365
+ fs.writeFileSync(files.drop, nul(dropped), { mode: 0o600 });
366
+ const rm = exec('sh', ['-c', DROP_SCRIPT, 'sched-rescue', idx, files.drop], opts.worktree);
367
+ if (rm === null)
368
+ return { kind: 'failed', reason: 'could not filter the rescue tree', probe };
369
+ skipped.secret += dropped.length;
370
+ newPaths = newPaths.filter((p) => !isSecretPath(p));
371
+ }
372
+ out = exec('sh', ['-c', COMMIT_SCRIPT, 'sched-rescue', idx, refName, rescueUnitSlug(opts.unit), message], opts.worktree);
373
+ }
374
+ finally {
375
+ // Also runs after an exec timeout: the throwaway index is never left behind.
376
+ for (const f of [idx, ...Object.values(files)])
377
+ fs.rmSync(f, { force: true });
378
+ }
379
+ const line = out?.split('\n').find((l) => /^(created|reused) /.test(l));
380
+ const parts = line?.split(' ');
381
+ if (!parts || parts.length < 3) {
382
+ return { kind: 'failed', reason: 'could not create the rescue commit', probe };
383
+ }
384
+ const reused = parts[0] === 'reused';
385
+ const sha = parts[1] ?? '';
386
+ const ref = parts[2] ?? '';
387
+ if (!ref.startsWith(exports.RESCUE_REF_PREFIX)) {
388
+ return { kind: 'failed', reason: `unexpected rescue ref '${ref}'`, probe };
389
+ }
390
+ // Only tracked changes + unpushed commits may leave the machine: a tree with untracked /
391
+ // staged-new files stays local (a pushed ref is world-readable on a public repo forever).
392
+ const localOnly = newPaths.length > 0;
393
+ // A reused rescue was pushed (or attempted) on an earlier tick: only push again when no
394
+ // success marker exists, so a respawn loop is not a push per tick.
395
+ const marker = path.join(gitdir, `sched-rescue-pushed-${sha}`);
396
+ let pushed = !localOnly && fs.existsSync(marker);
397
+ if (opts.push !== false && !localOnly && !pushed) {
398
+ pushed = exec('git', ['push', 'origin', `${ref}:${ref}`], opts.worktree) !== null;
399
+ if (pushed) {
400
+ try {
401
+ fs.writeFileSync(marker, `${ref}\n`, { mode: 0o600 });
402
+ }
403
+ catch {
404
+ // marker is an optimisation only
405
+ }
406
+ }
407
+ }
408
+ return {
409
+ kind: 'preserved',
410
+ probe,
411
+ work: {
412
+ ref,
413
+ sha,
414
+ pushed,
415
+ reused,
416
+ local_only: localOnly,
417
+ untracked_included: newPaths.length,
418
+ worktree: opts.worktree,
419
+ head: probe.head,
420
+ dirty_files: probe.dirty_files,
421
+ unpushed_commits: probe.unpushed_commits,
422
+ skipped,
423
+ },
424
+ };
425
+ }
426
+ /** Human text for what `preserveWork` deliberately left out ('' when nothing). */
427
+ function skippedSummary(skipped) {
428
+ const parts = [
429
+ skipped.secret > 0 ? `${skipped.secret} matching a secret pattern` : '',
430
+ skipped.large > 0 ? `${skipped.large} over the size cap` : '',
431
+ skipped.over_limit > 0 ? `${skipped.over_limit} beyond the count/size cap` : '',
432
+ skipped.not_a_file > 0 ? `${skipped.not_a_file} nested repo/non-file` : '',
433
+ ].filter(Boolean);
434
+ return parts.length === 0
435
+ ? ''
436
+ : `${parts.join(', ')} file(s) were NOT captured (still in the worktree)`;
437
+ }
438
+ /**
439
+ * The instruction appended to a respawned agent's prompt. Refs/paths are
440
+ * engine-derived (rescue names are slugged), never agent- or comment-supplied.
441
+ */
442
+ function preservedWorkInstruction(work) {
443
+ const where = work.pushed
444
+ ? `pushed to origin as ${work.ref}`
445
+ : `saved locally as ${work.ref}${work.local_only ? ' (local only: it includes untracked files, which are never pushed)' : ''}`;
446
+ const skipped = skippedSummary(work.skipped);
447
+ return (`PRESERVED WORK — the previous agent for this unit exited leaving ${work.dirty_files} ` +
448
+ `uncommitted file(s) and ${work.unpushed_commits} unpushed commit(s) in ${work.worktree}. ` +
449
+ `The scheduler preserved them as commit ${work.sha} (${where}); the ` +
450
+ 'worktree still contains them exactly as they were left. RESUME from that state: do not ' +
451
+ 'reset, checkout another branch, clean, or re-create the worktree, and never fall back to the ' +
452
+ 'last pushed head. Inspect `git status` / `git diff`, commit the work onto your own branch ' +
453
+ `(or \`git merge ${work.sha}\` if the worktree was recreated), and continue.${skipped ? ` Note: ${skipped}.` : ''} If the worktree ` +
454
+ `directory is gone, \`git worktree add <path> ${work.sha}\`.`);
455
+ }
456
+ /** True when `worktree` is registered in `repoDir`'s `git worktree list` — a path taken from an issue comment is never acted on otherwise. */
457
+ function isRegisteredWorktree(exec, repoDir, worktree) {
458
+ if (!path.isAbsolute(worktree) || worktree.includes('\0'))
459
+ return false;
460
+ const list = exec('git', ['worktree', 'list', '--porcelain'], repoDir);
461
+ if (list === null)
462
+ return false;
463
+ return list
464
+ .split('\n')
465
+ .some((l) => l.startsWith('worktree ') && l.slice('worktree '.length) === worktree);
466
+ }
467
+ /** Every worktree path the state's batches hold, with the unit that owns it. */
468
+ function batchWorktreeClaims(state) {
469
+ const claims = [];
470
+ for (const b of state.batches) {
471
+ const owner = `batch:${b.id}`;
472
+ if (b.worktree)
473
+ claims.push({ path: b.worktree, owner });
474
+ if (b.member_worktree)
475
+ claims.push({ path: b.member_worktree, owner });
476
+ for (const r of b.member_runs ?? [])
477
+ if (r.worktree)
478
+ claims.push({ path: r.worktree, owner });
479
+ }
480
+ return claims;
481
+ }
482
+ /**
483
+ * Why a takeover must NOT preserve (and so must not touch) `worktree`, or null
484
+ * when it is safe. The path comes from an issue COMMENT (the run's setup
485
+ * milestone), so it is untrusted: it must be a registered worktree of this
486
+ * repo, under the sanctioned worktree roots, never the main checkout, and not
487
+ * one a batch (another live unit) holds.
488
+ */
489
+ function takeoverWorktreeRefusal(exec, opts) {
490
+ const { repoDir, worktree } = opts;
491
+ if (!isRegisteredWorktree(exec, repoDir, worktree))
492
+ return 'not-a-registered-worktree';
493
+ const real = realpath(worktree);
494
+ if (real === null)
495
+ return 'worktree-missing';
496
+ const top = exec('git', ['rev-parse', '--show-toplevel'], repoDir);
497
+ const mainCheckouts = [repoDir, top ?? ''].map(realpath).filter((p) => p !== null);
498
+ if (mainCheckouts.includes(real))
499
+ return 'main-checkout';
500
+ const safeRoots = [top ?? repoDir, repoDir].map((r) => path.resolve(r));
501
+ if (!safeRoots.some((r) => (0, teardown_1.isSafeWorktree)(r, worktree)) &&
502
+ !(0, teardown_1.isSafeWorktree)((0, dossier_root_1.projectRootFor)(repoDir), worktree)) {
503
+ return 'outside-worktree-roots';
504
+ }
505
+ const claim = batchWorktreeClaims(opts.state).find((c) => c.owner !== opts.unit && realpath(c.path) === real);
506
+ if (claim)
507
+ return `claimed-by-${claim.owner}`;
508
+ return null;
509
+ }
510
+ /**
511
+ * The newest `caps.jsonl` gate row for this worktree decides (#940 ask 1): a
512
+ * passing `gate.batch` run that started on a DIRTY tree, after the last pushed
513
+ * head, while the worktree still holds local work — i.e. gated code that only
514
+ * ever existed uncommitted and that an unattended respawn could discard.
515
+ *
516
+ * Only rows carrying #941's `git_tree`/`dirty` are considered — a legacy row
517
+ * (no git fields) is ignored: the engine's own post-landing gate rows look
518
+ * exactly like that and are not evidence of lost work. A clean row is never
519
+ * evidence either: its code was a commit, which the rescue ref / history keeps.
520
+ * A `git_probe` failure counts as dirty. Only the NEWEST matching row decides,
521
+ * so an old dirty run cannot block after a newer clean/failed one. Worktree
522
+ * paths compare by realpath; a future-dated `sinceIso` (clock skew) is ignored.
523
+ */
524
+ function findGatedWorkEvidence(opts) {
525
+ let raw;
526
+ try {
527
+ raw = fs.readFileSync(opts.capsFile, 'utf8');
528
+ }
529
+ catch {
530
+ return null;
531
+ }
532
+ if (!opts.hasLocalWork)
533
+ return null;
534
+ const wantCwd = realpath(opts.worktree) ?? opts.worktree;
535
+ const capability = opts.capability ?? 'gate.batch';
536
+ const parsedSince = Date.parse(opts.sinceIso);
537
+ const nowMs = (opts.now ?? new Date()).getTime();
538
+ const since = Number.isNaN(parsedSince) || parsedSince > nowMs + 60_000
539
+ ? Number.NEGATIVE_INFINITY
540
+ : parsedSince;
541
+ const lines = raw.split('\n');
542
+ for (let i = lines.length - 1; i >= 0; i--) {
543
+ if (!lines[i])
544
+ continue;
545
+ let row;
546
+ try {
547
+ row = JSON.parse(lines[i]);
548
+ }
549
+ catch {
550
+ continue;
551
+ }
552
+ if (row.capability !== capability ||
553
+ row.git_tree === undefined ||
554
+ typeof row.cwd !== 'string' ||
555
+ typeof row.timestamp !== 'string' ||
556
+ (realpath(row.cwd) ?? row.cwd) !== wantCwd) {
557
+ continue;
558
+ }
559
+ // The newest matching row decides.
560
+ const at = Date.parse(row.timestamp);
561
+ if (Number.isNaN(at) || at <= since || row.outcome !== 'ok')
562
+ return null;
563
+ // The gated tree is now HEAD's tree: it was committed since, nothing gated is uncommitted.
564
+ if (opts.headTree && row.git_tree === opts.headTree)
565
+ return null;
566
+ return row.dirty === true || row.git_probe !== undefined
567
+ ? { timestamp: row.timestamp, dirty: true }
568
+ : null;
569
+ }
570
+ return null;
571
+ }
572
+ /** Committer date (ISO) of the last pushed head: the upstream when there is one, else HEAD (detached HEAD included). Null when unreadable. */
573
+ function pushedHeadDate(exec, worktree) {
574
+ const upstream = exec('git', ['log', '-1', '--format=%cI', '@{u}'], worktree);
575
+ if (upstream !== null && upstream.trim() !== '')
576
+ return upstream.trim();
577
+ const head = exec('git', ['log', '-1', '--format=%cI', 'HEAD'], worktree);
578
+ return head !== null && head.trim() !== '' ? head.trim() : null;
579
+ }
580
+ const REF_TS = /-(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z(?:-\d+)?$/;
581
+ /** The mint time encoded in a rescue ref NAME (`<slug>-<UTC ts>[-n]`), or null when it has none. */
582
+ function rescueRefTime(ref) {
583
+ const m = REF_TS.exec(ref);
584
+ if (!m)
585
+ return null;
586
+ const [y = 0, mo = 1, d = 1, h = 0, mi = 0, se = 0] = m.slice(1).map(Number);
587
+ const t = Date.UTC(y, mo - 1, d, h, mi, se);
588
+ return Number.isNaN(t) ? null : t;
589
+ }
590
+ /** Every git dir of `repoDir`'s repository (main + linked worktrees) — where `sched-rescue-pushed-*` markers live. */
591
+ function repoGitDirs(exec, repoDir) {
592
+ const common = exec('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], repoDir);
593
+ if (common === null || common === '')
594
+ return [];
595
+ const dirs = [common];
596
+ try {
597
+ for (const w of fs.readdirSync(path.join(common, 'worktrees'))) {
598
+ dirs.push(path.join(common, 'worktrees', w));
599
+ }
600
+ }
601
+ catch {
602
+ // no linked worktrees
603
+ }
604
+ return dirs;
605
+ }
606
+ /**
607
+ * Delete rescue refs older than `ttlMs` (by the timestamp in the ref NAME),
608
+ * locally and on origin (listed with `git ls-remote`, so refs pushed from other
609
+ * clones are covered), and the `sched-rescue-pushed-<sha>` markers of refs that
610
+ * no longer exist. A ref whose remote deletion FAILED for a reason other than
611
+ * "already gone" is kept so the next prune retries it. Never throws.
612
+ *
613
+ * Housekeeping only: deleting a pushed ref does not unpublish it.
614
+ */
615
+ function pruneRescueRefs(exec, repoDir, now, ttlMs = exports.RESCUE_REF_TTL_MS) {
616
+ const shaOf = new Map();
617
+ const local = exec('git', ['for-each-ref', '--format=%(refname) %(objectname)', exports.RESCUE_REF_PREFIX], repoDir);
618
+ for (const line of (local ?? '').split('\n')) {
619
+ const [ref, sha] = line.trim().split(' ');
620
+ if (ref?.startsWith(exports.RESCUE_REF_PREFIX))
621
+ shaOf.set(ref, sha ?? '');
622
+ }
623
+ const hasOrigin = exec('git', ['remote', 'get-url', 'origin'], repoDir) !== null;
624
+ let remoteRefs = null;
625
+ if (hasOrigin) {
626
+ const listing = exec('git', ['ls-remote', 'origin', `${exports.RESCUE_REF_PREFIX}*`], repoDir);
627
+ if (listing !== null) {
628
+ remoteRefs = new Set();
629
+ for (const line of listing.split('\n')) {
630
+ const [sha, ref] = line.trim().split(/\s+/);
631
+ if (ref?.startsWith(exports.RESCUE_REF_PREFIX)) {
632
+ remoteRefs.add(ref);
633
+ if (!shaOf.has(ref))
634
+ shaOf.set(ref, sha ?? '');
635
+ }
636
+ }
637
+ }
638
+ }
639
+ const pruned = [];
640
+ const goneShas = new Set();
641
+ for (const [ref, sha] of shaOf) {
642
+ const at = rescueRefTime(ref);
643
+ if (at === null || now.getTime() - at <= ttlMs)
644
+ continue;
645
+ const onRemote = remoteRefs === null ? hasOrigin : remoteRefs.has(ref);
646
+ if (onRemote && exec('git', ['push', 'origin', '--delete', ref], repoDir) === null) {
647
+ // Failed: only proceed when the remote confirms the ref is already absent.
648
+ const remote = exec('git', ['ls-remote', 'origin', ref], repoDir);
649
+ if (remote === null || remote.trim() !== '')
650
+ continue;
651
+ }
652
+ if (!local?.includes(`${ref} `) || exec('git', ['update-ref', '-d', ref], repoDir) !== null) {
653
+ pruned.push(ref);
654
+ goneShas.add(sha);
655
+ }
656
+ }
657
+ // Markers: drop those of pruned refs, and any whose sha no rescue ref points at any more.
658
+ const liveShas = new Set([...shaOf].filter(([r]) => !pruned.includes(r)).map(([, sha]) => sha));
659
+ for (const dir of repoGitDirs(exec, repoDir)) {
660
+ try {
661
+ for (const f of fs.readdirSync(dir)) {
662
+ const m = /^sched-rescue-pushed-([0-9a-f]+)$/.exec(f);
663
+ const msha = m?.[1] ?? '';
664
+ if (m && (goneShas.has(msha) || !liveShas.has(msha))) {
665
+ fs.rmSync(path.join(dir, f), { force: true });
666
+ }
667
+ }
668
+ }
669
+ catch {
670
+ // unreadable git dir
671
+ }
672
+ }
673
+ return pruned;
674
+ }
675
+ //# sourceMappingURL=preserve.js.map