@ai-dossier/sched 0.67.1 → 0.68.1-next.541.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 CHANGED
@@ -464,6 +464,58 @@ Only SIGINT had a handler, so a SIGTERM/SIGHUP (another session's restart, a clo
464
464
  terminal) killed the engine with no line and no lease release. That is now logged; if the
465
465
  2026-09-29 cause was SIGKILL/OOM, the missing `engine-exit` plus the stale lease is now the
466
466
  documented signature (`journalctl -k` for the OOM window).
467
+ ### Restart never discards work (#945, #940)
468
+
469
+ Before the engine respawns an agent onto a worktree that already exists — a batch **tail**, a
470
+ batch **member** reusing its worktree, an issue **takeover** — it preserves whatever the dead
471
+ agent left there (`preserve.ts`):
472
+
473
+ - a WIP commit under the **non-branch** ref `refs/sched-rescue/<unit>-<timestamp>` (adds no branch,
474
+ triggers no CI), holding every tracked change (`git add -u`, deletions and staged renames
475
+ included), the unpushed commits, and those untracked / staged-new files that pass the **secret
476
+ and size filter**, journaled as `work-preserved` (`branch` = the ref). **Only tracked changes and
477
+ unpushed commits are ever pushed.** A rescue whose tree also holds untracked / staged-new files
478
+ stays **local** (the ref lives in the local repo; the worktree and its `.git` survive a respawn
479
+ anyway) and says `local only` in the journal and the respawn prompt. The final tree is filtered
480
+ once more: any path new relative to HEAD (a rename into a secret name, `git add -N`, staged-new)
481
+ matching `.env*`, `*.env.*`, `*.pem`/`*.key` (and `*.pem.*`/`*.key.*`), `id_rsa*`, `id_ed25519*`,
482
+ `*credentials*`, `*secret*`, `*token*`, `*.p12`, `*.pfx`, `*.tfstate*`, `*.tfvars`, `kubeconfig`,
483
+ `.kube/`, `.docker/config.json`, `.pypirc`, `.vault-token`, `.htpasswd`, `.s3cfg`, `.npmrc`,
484
+ `.netrc`, `.ssh/` … is dropped. Untracked files over 1 MiB, past 500 files / 16 MiB, tracked
485
+ changes over 50 MiB and submodule/nested-repo contents are not captured; they stay in the
486
+ worktree and the journal / prompt count what was skipped and why. This filter is
487
+ defence-in-depth: the rescue bypasses the pre-commit secret scan;
488
+ - **TTL is housekeeping, not privacy.** A pushed rescue ref is **world-readable on a public
489
+ repository, and deleting a ref does NOT unpublish it** (GitHub keeps objects reachable by SHA).
490
+ Rescue refs older than 14 days (by the timestamp in the ref name) are deleted locally and on
491
+ origin (listed with `git ls-remote`, so refs pushed from other clones are covered) together with
492
+ their `sched-rescue-pushed-<sha>` markers: at `sched start` and then daily outside the state
493
+ lock. A ref whose remote deletion fails for any reason but "already gone" is kept and retried.
494
+ Find live ones with `git for-each-ref refs/sched-rescue`;
495
+ - built with a throwaway index, so the worktree, its index and its HEAD are untouched — the
496
+ respawned agent still finds the files in place, and preservation cannot itself lose work;
497
+ - idempotent: an unchanged tree reuses its existing rescue ref instead of minting one per tick;
498
+ - the respawned agent's prompt gets a `PRESERVED WORK` instruction naming the ref and forbidding a
499
+ reset to the last pushed head. A failed push is journaled (`pushed: false` in the detail) and is
500
+ not fatal; an unpreservable worktree journals `work-preserve-failed` and is left as it is.
501
+
502
+ A takeover's worktree path comes from a setup milestone (an issue comment), so it is acted on only
503
+ when `git worktree list` registers it in this repository, it is under the sanctioned `worktrees/`
504
+ roots, it is not the main checkout, and no batch holds it (`takeoverWorktreeRefusal`; a refusal is
505
+ journaled `work-preserve-failed` and never blocks the respawn). **The takeover rescue runs outside
506
+ the state lock** (it shells out to `gh`, `git` and `git push`): a stalled unit enters recovery on
507
+ one tick and its takeover is spawned on the next, after the rescue. Takeover preservation is on
508
+ only when the engine is given a `rescueExec` (the CLI wires one, 120 s per call).
509
+
510
+ **Batch tail, respawn only:** when the previous tail exited unverified and its worktree holds
511
+ uncommitted files, or the NEWEST `gate.batch` row in `caps.jsonl` for that worktree (matched by realpath of `cwd`) is a
512
+ passing run on a **dirty** tree (`dirty: true` or `git_probe` set — #941's fields) newer than the
513
+ last pushed head while local work exists, the engine does **not** respawn. Rows without #941's
514
+ fields (the engine's own post-landing gate rows) and clean rows are never evidence; an older dirty
515
+ row is superseded by a newer clean or failed one. It blocks the batch `tail-dirty-worktree`, with the evidence and the
516
+ rescue ref in the journal and `sched status`. Commit or discard the work in the worktree, then
517
+ `sched resume --batch <id>`. A tail that left only unpushed commits is respawned, told to resume
518
+ from the rescue ref. `sched resume --batch` deliberately does NOT re-run this guard: by resuming, the operator has looked at the worktree.
467
519
 
468
520
  ### Zombie-run fencing (#504)
469
521
 
@@ -1 +1 @@
1
- {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"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"}
@@ -106,6 +106,7 @@ exports.reconcileKeptWorktrees = reconcileKeptWorktrees;
106
106
  exports.memberDispatchModeFor = memberDispatchModeFor;
107
107
  exports.runBatchTick = runBatchTick;
108
108
  const fs = __importStar(require("node:fs"));
109
+ const os = __importStar(require("node:os"));
109
110
  const path = __importStar(require("node:path"));
110
111
  const core_1 = require("@ai-dossier/core");
111
112
  const worktree_pool_1 = require("@ai-dossier/worktree-pool");
@@ -117,6 +118,7 @@ const dossier_root_1 = require("./dossier-root");
117
118
  const groundtruth_1 = require("./groundtruth");
118
119
  const journal_1 = require("./journal");
119
120
  const merge_mechanism_1 = require("./merge-mechanism");
121
+ const preserve_1 = require("./preserve");
120
122
  const readiness_1 = require("./readiness");
121
123
  const recovery_1 = require("./recovery");
122
124
  const run_log_1 = require("./run-log");
@@ -166,6 +168,98 @@ function sanitizeUntrustedText(value) {
166
168
  function journalEvent(deps, event, unitId, extra = {}) {
167
169
  deps.journal.append((0, journal_1.unitEvent)(event, unitId, extra), deps.now());
168
170
  }
171
+ /**
172
+ * #945: preserve whatever a dead agent left in `worktree` before a respawn
173
+ * reuses it (see `preserve.ts`). Journals the outcome; returns the preserved
174
+ * work for the respawned agent's prompt, or undefined when there was nothing to
175
+ * preserve (clean) or preservation was not possible (failure is journaled, the
176
+ * worktree is untouched either way — the respawn is not made worse by it).
177
+ */
178
+ function preserveForRespawn(deps, unitId, worktree, now) {
179
+ const outcome = (0, preserve_1.preserveWork)(deps.exec, { worktree, unit: unitId, now });
180
+ return journalPreserveOutcome(deps, unitId, worktree, outcome);
181
+ }
182
+ function journalPreserveOutcome(deps, unitId, worktree, outcome) {
183
+ if (outcome.kind === 'preserved') {
184
+ const w = outcome.work;
185
+ // A REUSED rescue was journaled when it was minted; a respawn loop must not
186
+ // add a line per tick.
187
+ if (!w.reused) {
188
+ const skipped = (0, preserve_1.skippedSummary)(w.skipped);
189
+ journalEvent(deps, 'work-preserved', unitId, {
190
+ branch: w.ref,
191
+ worktree,
192
+ detail: `${w.dirty_files} uncommitted file(s) + ${w.unpushed_commits} unpushed commit(s) on head ${w.head} preserved as ${w.sha} at ${w.ref} (${w.pushed ? 'pushed' : w.local_only ? 'local only — includes untracked files, never pushed' : 'NOT pushed — local ref only'})${skipped ? `; ${skipped}` : ''}; the respawned agent is told to resume from it`,
193
+ });
194
+ }
195
+ return w;
196
+ }
197
+ if (outcome.kind === 'failed' && (0, preserve_1.firstOccurrence)(deps.journal, `${unitId}|${outcome.reason}`)) {
198
+ journalEvent(deps, 'work-preserve-failed', unitId, {
199
+ worktree,
200
+ detail: `${outcome.reason} — ${outcome.probe.dirty_files} uncommitted file(s), ${outcome.probe.unpushed_commits} unpushed commit(s) left in place in the worktree`,
201
+ });
202
+ }
203
+ return undefined;
204
+ }
205
+ /**
206
+ * #940 ask 1 + #945: before RESPAWNING a batch tail onto its batch worktree,
207
+ * preserve what the dead tail left there, and refuse the respawn when that
208
+ * includes uncommitted files or a passing DIRTY `gate.batch` run past the last
209
+ * pushed head (`findGatedWorkEvidence`) — the operator decides commit vs
210
+ * discard (`tail-dirty-worktree`, resumable via `sched resume --batch`, which
211
+ * deliberately does not re-run this guard: the operator has looked). Clean or
212
+ * only-unpushed: respawn, told to resume from the rescue ref.
213
+ */
214
+ function guardTailRespawn(deps, batch, now) {
215
+ const worktree = batch.worktree;
216
+ if (worktree === null)
217
+ return { block: null, instruction: null };
218
+ const unitId = unit(batch.id);
219
+ const outcome = (0, preserve_1.preserveWork)(deps.exec, { worktree, unit: unitId, now });
220
+ const work = journalPreserveOutcome(deps, unitId, worktree, outcome);
221
+ if (outcome.kind === 'skipped')
222
+ return { block: null, instruction: null };
223
+ const probe = outcome.kind === 'preserved' || outcome.kind === 'failed' || outcome.kind === 'clean'
224
+ ? outcome.probe
225
+ : null;
226
+ if (probe === null)
227
+ return { block: null, instruction: null };
228
+ const since = (0, preserve_1.pushedHeadDate)(deps.exec, worktree);
229
+ const gate = since === null
230
+ ? null
231
+ : (0, preserve_1.findGatedWorkEvidence)({
232
+ capsFile: path.join(deps.homeDir ?? os.homedir(), '.dossier', 'caps.jsonl'),
233
+ worktree,
234
+ sinceIso: since,
235
+ headTree: headTreeOf(deps, worktree),
236
+ hasLocalWork: probe.dirty_files > 0 || probe.unpushed_commits > 0,
237
+ now,
238
+ });
239
+ // Stray untracked files (logs, scratch) that the rescue deliberately left out must not wedge a
240
+ // respawn: block on tracked changes, on untracked work the rescue captured, or when the
241
+ // state is unknowable (a failed rescue counts everything as dirty).
242
+ const rescuedUntracked = work?.untracked_included ?? 0;
243
+ const dirty = outcome.kind === 'failed'
244
+ ? probe.dirty_files > 0
245
+ : probe.tracked_dirty_files > 0 || rescuedUntracked > 0;
246
+ if (dirty || gate !== null) {
247
+ // Its own event: `work-preserved` above already said (once) what was saved, or
248
+ // `work-preserve-failed` said it was not — this line must not claim otherwise.
249
+ journalEvent(deps, 'tail-respawn-refused', unitId, {
250
+ worktree,
251
+ reason: 'tail-dirty-worktree',
252
+ detail: `tail respawn refused: ${probe.dirty_files} uncommitted file(s) at head ${probe.head}${gate ? `; a gate.batch ok row at ${gate.timestamp} ran on a dirty tree newer than the last pushed head` : ''}${work ? `; a rescue exists at ${work.ref} (${work.sha})` : outcome.kind === 'failed' ? '; NO rescue could be made' : ''}. Commit or discard in ${worktree}, then \`sched resume --batch ${batch.id}\``,
253
+ });
254
+ return { block: 'tail-dirty-worktree', instruction: null };
255
+ }
256
+ return { block: null, instruction: work ? (0, preserve_1.preservedWorkInstruction)(work) : null };
257
+ }
258
+ /** HEAD's tree id (for gate-evidence comparison), null when unreadable. */
259
+ function headTreeOf(deps, worktree) {
260
+ const tree = deps.exec('git', ['rev-parse', 'HEAD^{tree}'], worktree);
261
+ return tree === null || tree.trim() === '' ? null : tree.trim();
262
+ }
169
263
  /** Whether the slot's agent is still alive (pid-start-safe: a reused pid reads dead). */
170
264
  function agentAlive(deps, slot) {
171
265
  return slot.pid !== null && deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined);
@@ -1160,7 +1254,9 @@ function spawnMemberAgent(deps, dispatch, state, slot, batchId, now, result, tar
1160
1254
  // #822: a member respawned after running the wrong procedure.
1161
1255
  (batch.reprompted_members.some((r) => r.issue === memberIssue)
1162
1256
  ? (0, dispatch_1.wrongProcedureDirective)(memberIssue, batchId)
1163
- : '');
1257
+ : '') +
1258
+ // #945: a respawn onto a reused worktree resumes from what was preserved there.
1259
+ (target.member.preserved ? `\n\n${(0, preserve_1.preservedWorkInstruction)(target.member.preserved)}` : '');
1164
1260
  const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, target.index, memberIssue);
1165
1261
  // #629: captured BEFORE spawning, mirroring `engine.ts`'s own
1166
1262
  // `spawnAndRecord` — the log is per-role and append-mode, so the size at
@@ -1451,6 +1547,10 @@ function spawnMemberContinuation(deps, config, dispatch, batchId, now, result) {
1451
1547
  worktree: batch.member_worktree,
1452
1548
  poolClaimed: batch.member_pool_claimed,
1453
1549
  };
1550
+ // #945: preserve whatever the previous agent left BEFORE the respawn, outside the lock.
1551
+ const preserved = preserveForRespawn(deps, `batch:${batchId}#${memberIssue}`, member.worktree, now);
1552
+ if (preserved)
1553
+ member.preserved = preserved;
1454
1554
  }
1455
1555
  else {
1456
1556
  if (batch.member_worktree !== null) {
@@ -1514,12 +1614,23 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
1514
1614
  // #887: read outside the store lock (a cache miss runs `gh api`).
1515
1615
  const mechanism = deps.groundTruth.mergeMechanism?.();
1516
1616
  const pending = (0, state_1.findBatch)(before, batchId);
1617
+ // #945/#940: a RESPAWN (the previous tail exited unverified) never lands on
1618
+ // work it cannot see — preserve first, outside the store lock.
1619
+ let resumeNote = null;
1517
1620
  if (pending) {
1518
1621
  const refusal = tailDispatchRefusal(before, pending);
1519
1622
  if (refusal !== null) {
1520
1623
  blockBatchForOperator(deps, config, batchId, { reason: refusal, milestonePhase: 'batch-review' }, now, result);
1521
1624
  return;
1522
1625
  }
1626
+ if (pending.agent_exits?.phase === 'tail' && pending.agent_exits.count >= 1) {
1627
+ const guard = guardTailRespawn(deps, pending, now);
1628
+ if (guard.block !== null) {
1629
+ blockBatchForOperator(deps, config, batchId, { reason: guard.block, milestonePhase: 'batch-review' }, now, result);
1630
+ return;
1631
+ }
1632
+ resumeNote = guard.instruction;
1633
+ }
1523
1634
  }
1524
1635
  claimAndSpawn(deps, config, batchId, 'reviewing', now, (state, slot) => {
1525
1636
  const batch = (0, state_1.findBatch)(state, batchId);
@@ -1545,7 +1656,7 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
1545
1656
  const cmd = spawnSpec.cmd;
1546
1657
  const prompt = (0, dispatch_1.buildBatchTailPrompt)(dispatch.batchTailPrompt, batchId, batch.anchor, landed, batch.worktree,
1547
1658
  // #887: the repo's detected merge mechanism decides detached vs attached ship.
1548
- mechanism);
1659
+ mechanism) + (resumeNote === null ? '' : `\n\n${resumeNote}`);
1549
1660
  const logFile = (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId);
1550
1661
  // #629: fences `handleDeadDispatchApiError`'s classification to this
1551
1662
  // dispatch's slice — see `spawnMember`'s identical comment.
@@ -4480,6 +4591,9 @@ function spawnParallelMembers(deps, config, dispatch, batchId, now, result) {
4480
4591
  worktree: next.run.worktree,
4481
4592
  poolClaimed: next.run.pool_claimed,
4482
4593
  };
4594
+ const preserved = preserveForRespawn(deps, (0, state_1.batchMemberUnit)(batchId, next.issue), member.worktree, now);
4595
+ if (preserved)
4596
+ member.preserved = preserved;
4483
4597
  }
4484
4598
  else {
4485
4599
  // The serial-member fields describe no member of a parallel batch —