@ai-dossier/sched 0.67.1 → 0.68.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/dist/batch-dispatch.d.ts.map +1 -1
- package/dist/batch-dispatch.js +116 -2
- package/dist/batch-dispatch.js.map +1 -1
- package/dist/engine.d.ts +7 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +128 -3
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -3
- package/dist/index.js.map +1 -1
- package/dist/preserve.d.ts +194 -0
- package/dist/preserve.d.ts.map +1 -0
- package/dist/preserve.js +675 -0
- package/dist/preserve.js.map +1 -0
- package/dist/project.d.ts +2 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +1 -0
- package/dist/project.js.map +1 -1
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +3 -0
- package/dist/scheduler.js.map +1 -1
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +3 -1
- package/dist/status.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/preserve.js
ADDED
|
@@ -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
|