@mikode13/harness 9.2.0 → 9.3.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.
Files changed (38) hide show
  1. package/README.md +6 -3
  2. package/dist/engines/domain/model/historyFollower.d.ts +1 -1
  3. package/dist/engines/domain/model/historyFollower.js +14 -3
  4. package/dist/engines/domain/model/historyFollower.js.map +1 -1
  5. package/dist/factory/infrastructure/agentLLMFactory.d.ts +2 -2
  6. package/dist/factory/infrastructure/agentLLMFactory.js +5 -5
  7. package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
  8. package/dist/recovery/domain/history.d.ts +3 -2
  9. package/dist/recovery/domain/history.js +6 -2
  10. package/dist/recovery/domain/history.js.map +1 -1
  11. package/dist/recovery/domain/recoveryStore.d.ts +47 -5
  12. package/dist/recovery/domain/recoveryStore.js.map +1 -1
  13. package/dist/recovery/domain/runChanges.js +11 -2
  14. package/dist/recovery/domain/runChanges.js.map +1 -1
  15. package/dist/recovery/infrastructure/fileRecoveryStore.d.ts +41 -9
  16. package/dist/recovery/infrastructure/fileRecoveryStore.js +217 -65
  17. package/dist/recovery/infrastructure/fileRecoveryStore.js.map +1 -1
  18. package/dist/tools/domain/editResponse.d.ts +7 -2
  19. package/dist/tools/domain/editResponse.js +33 -11
  20. package/dist/tools/domain/editResponse.js.map +1 -1
  21. package/dist/tools/domain/v4aPatch.d.ts +6 -6
  22. package/dist/tools/domain/v4aPatch.js +59 -21
  23. package/dist/tools/domain/v4aPatch.js.map +1 -1
  24. package/dist/tools/infrastructure/commandCheckpoint.d.ts +3 -2
  25. package/dist/tools/infrastructure/commandCheckpoint.js +21 -9
  26. package/dist/tools/infrastructure/commandCheckpoint.js.map +1 -1
  27. package/dist/tools/infrastructure/commandTool.js +1 -1
  28. package/dist/tools/infrastructure/commandTool.js.map +1 -1
  29. package/dist/tools/infrastructure/fileTools.d.ts +1 -1
  30. package/dist/tools/infrastructure/fileTools.js +3 -3
  31. package/dist/tools/infrastructure/fileTools.js.map +1 -1
  32. package/dist/tools/infrastructure/trackedEdits.d.ts +3 -3
  33. package/dist/tools/infrastructure/trackedEdits.js +17 -11
  34. package/dist/tools/infrastructure/trackedEdits.js.map +1 -1
  35. package/dist/tools/infrastructure/workspaceWrites.d.ts +8 -5
  36. package/dist/tools/infrastructure/workspaceWrites.js +19 -7
  37. package/dist/tools/infrastructure/workspaceWrites.js.map +1 -1
  38. package/package.json +1 -1
@@ -2,6 +2,8 @@ import { createHash, randomBytes } from 'node:crypto';
2
2
  import { link, mkdir, open, readdir, readFile, realpath, rename, rm, stat, truncate, unlink, } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, isAbsolute, join } from 'node:path';
5
+ import { classifyHostFailure, describeFailure, treatErrors, } from '#src/shared/domain/providerFailure';
6
+ import { Logger } from '#src/shared/infrastructure/logger';
5
7
  import { fileChanges, sameState } from "../domain/fileChanges.js";
6
8
  import { historyStart, NothingToMoveError, WorkspaceBusyError, WorkspaceMovedError, } from "../domain/recoveryStore.js";
7
9
  import { chainEntries, nextPruneStep } from "../domain/retention.js";
@@ -146,15 +148,19 @@ async function storeContent(contentDirectory, content) {
146
148
  * same instant could both believe they hold it.
147
149
  */
148
150
  export class FileRecoveryStore {
151
+ workspaceId;
149
152
  root;
150
153
  directory;
151
154
  keepRuns;
152
- constructor(root, directory, keepRuns) {
155
+ logger;
156
+ constructor(root, directory, keepRuns, logger, workspaceId) {
153
157
  this.root = root;
154
158
  this.directory = directory;
155
159
  this.keepRuns = keepRuns;
160
+ this.logger = logger;
161
+ this.workspaceId = workspaceId;
156
162
  }
157
- static async open({ root, directory = defaultStateDirectory(), keepRuns = defaultKeepRuns, }) {
163
+ static async open({ root, directory = defaultStateDirectory(), keepRuns = defaultKeepRuns, logger = new Logger(), }) {
158
164
  if (!Number.isInteger(keepRuns) || keepRuns < 3) {
159
165
  throw new RangeError(`keepRuns must be an integer of at least 3; got ${String(keepRuns)}`);
160
166
  }
@@ -164,7 +170,8 @@ export class FileRecoveryStore {
164
170
  await makeDirectory(join(workspace, 'runs'));
165
171
  await makeDirectory(join(workspace, 'content'));
166
172
  await writeDurably(join(workspace, 'workspace.json'), JSON.stringify({ root: realRoot }));
167
- return new FileRecoveryStore(realRoot, workspace, keepRuns);
173
+ // Real, so a state directory reached through a link is the history it holds.
174
+ return new FileRecoveryStore(realRoot, workspace, keepRuns, logger, await realpath(workspace));
168
175
  }
169
176
  async startRun() {
170
177
  const runId = newRunId();
@@ -201,6 +208,7 @@ export class FileRecoveryStore {
201
208
  contentDirectory: join(this.directory, 'content'),
202
209
  journal,
203
210
  discard: () => this.discardRun(record),
211
+ settle: finished => this.settleAtFinish(finished),
204
212
  release: () => this.unlock(runId),
205
213
  });
206
214
  }
@@ -236,8 +244,8 @@ export class FileRecoveryStore {
236
244
  if (run.status !== 'interrupted')
237
245
  continue;
238
246
  // A journal it cannot read leaves the run listed: whether it changed anything is unknown.
239
- const entries = await this.readEntries(run.runId, run.journal).catch(() => undefined);
240
- if (entries && changedNothing(entries))
247
+ const journal = await this.readJournal(run.runId, run.journal).catch(() => undefined);
248
+ if (journal && changedNothing(journal))
241
249
  gone.add(run.runId);
242
250
  }
243
251
  const parents = new Map(runs.map(run => [run.runId, run.parentRunId]));
@@ -262,9 +270,11 @@ export class FileRecoveryStore {
262
270
  await this.assertNotAbsorbed(runId);
263
271
  throw new UnknownRunError(runId);
264
272
  });
273
+ const { entries, unrecorded } = await this.readJournal(runId, record.journal);
265
274
  return {
266
275
  record: withLiveStatus(record, holder),
267
- entries: await this.readEntries(runId, record.journal),
276
+ entries,
277
+ unrecorded: unrecorded.map(({ path, by }) => ({ path, by })),
268
278
  };
269
279
  }
270
280
  async readRecord(runId) {
@@ -433,16 +443,16 @@ export class FileRecoveryStore {
433
443
  }
434
444
  /** Takes one run's changes back to its parent, or forward again, file by file. */
435
445
  async step(runId, direction, announce) {
436
- const { entries } = await this.readRun(runId);
446
+ const { entries, unrecorded } = await this.readRun(runId);
437
447
  const changes = fileChanges(entries, true);
438
- const conflicts = [];
448
+ // What the history could not copy is left as it is, in either direction.
449
+ const conflicting = new Set(unrecorded.map(change => change.path));
439
450
  // Undone newest first, so a folder the run created is empty by the time its file goes.
440
451
  for (const change of direction === 'undo' ? changes.reverse() : changes) {
441
- if (!(await this.moveFile(change, direction, announce))) {
442
- conflicts.push({ path: change.path, runId });
443
- }
452
+ if (!(await this.moveFile(change, direction, announce)))
453
+ conflicting.add(change.path);
444
454
  }
445
- return conflicts;
455
+ return [...conflicting].map((path) => ({ path, runId }));
446
456
  }
447
457
  /**
448
458
  * Writes the state the step leads to, only if the file holds the state it leaves. A file
@@ -536,12 +546,21 @@ export class FileRecoveryStore {
536
546
  async chain(older, newer) {
537
547
  const olderRecord = await this.readRecord(older);
538
548
  const newerRecord = await this.readRecord(newer);
539
- const entries = chainEntries(await this.readEntries(older, olderRecord.journal), await this.readEntries(newer, newerRecord.journal));
549
+ const olderJournal = await this.readJournal(older, olderRecord.journal);
550
+ const newerJournal = await this.readJournal(newer, newerRecord.journal);
551
+ const entries = chainEntries(olderJournal.entries, newerJournal.entries);
540
552
  const journal = `journal-${randomBytes(4).toString('hex')}.jsonl`;
541
553
  const lines = entries.flatMap(({ status, sequence, ...change }) => [
542
554
  { type: 'prepared', sequence, ...change },
543
555
  ...(status === 'prepared' ? [] : [{ type: status, sequence }]),
544
556
  ]);
557
+ // What either run could not copy stays uncopied: undoing the chain cannot take it back.
558
+ for (const [index, { path, by }] of [
559
+ ...olderJournal.unrecorded,
560
+ ...newerJournal.unrecorded,
561
+ ].entries()) {
562
+ lines.push({ type: 'unrecorded', sequence: entries.length + index + 1, path, by });
563
+ }
545
564
  await writeDurably(join(this.runDirectory(newer), journal), lines.map(line => `${JSON.stringify(line)}\n`).join(''));
546
565
  const record = {
547
566
  ...newerRecord,
@@ -607,16 +626,36 @@ export class FileRecoveryStore {
607
626
  await syncDirectory(this.runDirectory(record.runId));
608
627
  await rm(this.runDirectory(record.runId), { recursive: true, force: true });
609
628
  }
610
- /** Removes the stored contents no kept run refers to. */
629
+ /**
630
+ * Removes the stored contents no kept run refers to. An observation still open refers to its
631
+ * manifest and to every content in it, which settling it needs.
632
+ */
611
633
  async collectContent() {
612
634
  const referenced = new Set();
613
635
  for (const run of (await this.listRuns()).runs) {
614
- for (const entry of await this.readEntries(run.runId, run.journal)) {
636
+ const { entries, observations } = await this.readJournal(run.runId, run.journal);
637
+ for (const entry of entries) {
615
638
  for (const state of [entry.before, entry.after]) {
616
639
  if (state.exists)
617
640
  referenced.add(state.hash);
618
641
  }
619
642
  }
643
+ for (const observation of observations) {
644
+ referenced.add(observation.manifest);
645
+ const stored = await this.readContent(observation.manifest).catch((error) => {
646
+ // Never stored whole: settling it will find nothing to compare.
647
+ if (isNotFound(error))
648
+ return undefined;
649
+ throw error;
650
+ });
651
+ if (!stored)
652
+ continue;
653
+ const manifest = JSON.parse(stored.toString('utf8'));
654
+ for (const state of Object.values(manifest.files)) {
655
+ if (state.exists)
656
+ referenced.add(state.hash);
657
+ }
658
+ }
620
659
  }
621
660
  const contentDirectory = join(this.directory, 'content');
622
661
  for (const prefix of await readdir(contentDirectory)) {
@@ -632,10 +671,9 @@ export class FileRecoveryStore {
632
671
  await syncDirectory(this.directory);
633
672
  }
634
673
  /**
635
- * Marks every other run still recorded as `running` as `interrupted`: this run holds the lock,
636
- * so their processes are gone. Each change one left `prepared` is settled from what its file
637
- * holds now: its `after` means it was made, its `before` that it was not. A file that holds
638
- * neither stays `prepared`, because nothing tells whether the change reached it.
674
+ * Settles every other run left unfinished: one still recorded as `running` is `interrupted`,
675
+ * since this run holds the lock and its process is gone, and one that finished `unsettled`
676
+ * keeps its status.
639
677
  */
640
678
  async settleInterrupted(ownRunId) {
641
679
  const ids = (await readdir(join(this.directory, 'runs'))).filter(id => runIdPattern.test(id) && id !== ownRunId);
@@ -645,43 +683,80 @@ export class FileRecoveryStore {
645
683
  return undefined;
646
684
  throw error;
647
685
  });
648
- if (record?.status !== 'running')
686
+ if (record?.status === 'running' || record?.unsettled)
687
+ await this.settleRun(record);
688
+ }
689
+ }
690
+ /**
691
+ * Settles a run that finished with an observation open, under its own lock, before it releases
692
+ * the workspace. If that fails, its record stays `unsettled`, and the next run or move settles
693
+ * it, so the failure is reported and not thrown.
694
+ */
695
+ async settleAtFinish(record) {
696
+ try {
697
+ return await this.settleRun(record, { finishing: true });
698
+ }
699
+ catch (error) {
700
+ treatErrors(() => {
701
+ this.logger.warn(`The history could not settle what run ${record.runId} was observing when it finished; the next run or move settles it: ${describeFailure(error)}`);
702
+ }, classifyHostFailure, 'Recovery store logger failed while reporting an unsettled run');
703
+ return true;
704
+ }
705
+ }
706
+ /**
707
+ * Settles what a run left unfinished. Each change it left `prepared` is settled from what its
708
+ * file holds now: its `after` means it was made, its `before` that it was not. A file that holds
709
+ * neither stays `prepared`, because nothing tells whether the change reached it. Each
710
+ * observation it left open is settled from its manifest. A run still `running` becomes
711
+ * `interrupted`; any other keeps its status. Whether the run is still in the history: one that
712
+ * changed nothing leaves it if it was still `running`, or if it is `finishing`, which unties
713
+ * its turns from it. One that finished is kept otherwise, since the turns of a conversation may
714
+ * name it.
715
+ */
716
+ async settleRun(record, { finishing = false } = {}) {
717
+ const { runId } = record;
718
+ const journal = await this.readJournal(runId, record.journal);
719
+ const lines = [];
720
+ for (const entry of journal.entries) {
721
+ if (entry.status !== 'prepared' || !isAbsolute(entry.path))
649
722
  continue;
650
- const journal = await this.readJournal(runId, record.journal);
651
- const settled = [];
652
- for (const entry of journal.entries) {
653
- if (entry.status !== 'prepared' || !isAbsolute(entry.path))
654
- continue;
655
- const state = await currentState(entry.path);
656
- if (state && sameState(state, entry.after)) {
657
- settled.push({ type: 'applied', sequence: entry.sequence });
658
- }
659
- else if (state && sameState(state, entry.before)) {
660
- settled.push({ type: 'abandoned', sequence: entry.sequence });
661
- }
723
+ const state = await currentState(entry.path);
724
+ if (state && sameState(state, entry.after)) {
725
+ lines.push({ type: 'applied', sequence: entry.sequence });
662
726
  }
663
- let sequence = journal.lastSequence;
664
- for (const observation of journal.observations) {
665
- const lines = await this.settleObservation(observation, journal.entries, sequence);
666
- sequence += lines.length;
667
- settled.push(...lines);
668
- }
669
- await appendWhole(join(this.runDirectory(runId), record.journal ?? firstJournal), settled);
670
- if (changedNothing(await this.readEntries(runId, record.journal))) {
671
- await this.discardRun(record);
672
- continue;
727
+ else if (state && sameState(state, entry.before)) {
728
+ lines.push({ type: 'abandoned', sequence: entry.sequence });
673
729
  }
674
- // Last, so a crash before it leaves a run that is settled again, and settling is idempotent.
675
- await writeDurably(join(this.runDirectory(runId), 'run.json'), JSON.stringify({ ...record, status: 'interrupted' }));
676
730
  }
731
+ let sequence = journal.lastSequence;
732
+ for (const observation of journal.observations) {
733
+ const observed = await this.settleObservation(observation, journal, sequence);
734
+ sequence += observed.length;
735
+ lines.push(...observed);
736
+ }
737
+ await appendWhole(join(this.runDirectory(runId), record.journal ?? firstJournal), lines);
738
+ const leaves = finishing || record.status === 'running';
739
+ if (leaves && changedNothing(await this.readJournal(runId, record.journal))) {
740
+ await this.discardRun(record);
741
+ return false;
742
+ }
743
+ const settled = {
744
+ ...record,
745
+ status: record.status === 'running' ? 'interrupted' : record.status,
746
+ };
747
+ delete settled.unsettled;
748
+ // Last, so a crash before it leaves a run that is settled again, and settling is idempotent.
749
+ await writeDurably(join(this.runDirectory(runId), 'run.json'), JSON.stringify(settled));
750
+ return true;
677
751
  }
678
752
  /**
679
753
  * The lines that record what a command an interrupted run was observing changed: every file of
680
754
  * its manifest that holds something else now, as changed by that command, and then the end of
681
755
  * the observation. A file recorded after the observation began is left alone: the run
682
- * recorded it itself. One that is now a folder, a link or too large to record is left out.
756
+ * recorded it itself. One that is now a folder, a link or too large to record, or that was one
757
+ * and is no longer, or that cannot be read, is recorded as a change the history could not copy.
683
758
  */
684
- async settleObservation(observation, entries, lastSequence) {
759
+ async settleObservation(observation, { entries, unrecorded: uncopied, }, lastSequence) {
685
760
  const stored = await this.readContent(observation.manifest).catch((error) => {
686
761
  if (isNotFound(error))
687
762
  return undefined;
@@ -691,31 +766,74 @@ export class FileRecoveryStore {
691
766
  if (!stored)
692
767
  return [{ type: 'observed', sequence: observation.sequence }];
693
768
  const manifest = JSON.parse(stored.toString('utf8'));
694
- const recorded = new Set(entries.filter(entry => entry.sequence > observation.sequence).map(entry => entry.path));
769
+ // What the run recorded itself after the observation began, its changes and those it could
770
+ // not copy, such as when a command could not be confirmed stopped.
771
+ const recorded = new Set([...entries, ...uncopied]
772
+ .filter(line => line.sequence > observation.sequence)
773
+ .map(line => line.path));
695
774
  const lines = [];
696
775
  let sequence = lastSequence;
776
+ const unrecorded = (path) => ({
777
+ type: 'unrecorded',
778
+ sequence: ++sequence,
779
+ path,
780
+ by: observation.by,
781
+ });
697
782
  for (const [path, before] of Object.entries(manifest.files)) {
698
783
  if (!isAbsolute(path) || recorded.has(path))
699
784
  continue;
700
785
  const after = await this.saveState(path);
701
- if (!after || sameState(after, before))
786
+ if (after === undefined || after === 'unreadable') {
787
+ lines.push(unrecorded(path));
788
+ continue;
789
+ }
790
+ if (sameState(after, before))
702
791
  continue;
703
792
  sequence++;
704
793
  lines.push({ type: 'prepared', sequence, path, before, after, observedBy: observation.by }, { type: 'applied', sequence });
705
794
  }
795
+ // What it held before is unknown, so only a path that is no longer uncopyable has changed.
796
+ for (const path of manifest.uncopyable ?? []) {
797
+ if (!isAbsolute(path) || recorded.has(path))
798
+ continue;
799
+ const now = await currentState(path).catch((error) => {
800
+ this.warnUnreadable(path, error);
801
+ return 'unreadable';
802
+ });
803
+ if (now !== undefined)
804
+ lines.push(unrecorded(path));
805
+ }
706
806
  lines.push({ type: 'observed', sequence: observation.sequence });
707
807
  return lines;
708
808
  }
709
- /** What `path` holds now, with its content stored, or `undefined` for what no entry records. */
809
+ warnUnreadable(path, error) {
810
+ treatErrors(() => {
811
+ this.logger.warn(`The history could not read ${path} to settle what a command changed, and records it as a change undo cannot take back: ${describeFailure(error)}`);
812
+ }, classifyHostFailure, 'Recovery store logger failed while reporting an unreadable file');
813
+ }
814
+ /**
815
+ * What `path` holds now, with its content stored, or `undefined` for what no entry records.
816
+ * A file it cannot read is `unreadable`, and reported: it cannot be undone either, and settling
817
+ * never waits on it, or every later run would fail to start. A failure to store its content is
818
+ * thrown, so the observation stays open and settling it is tried again.
819
+ */
710
820
  async saveState(path) {
711
- const state = await currentState(path);
712
- if (!state?.exists)
713
- return state;
714
- const content = await readFile(path).catch((error) => {
715
- if (isNotFound(error))
716
- return undefined;
717
- throw error;
718
- });
821
+ let state;
822
+ let content;
823
+ try {
824
+ state = await currentState(path);
825
+ if (!state?.exists)
826
+ return state;
827
+ content = await readFile(path).catch((error) => {
828
+ if (isNotFound(error))
829
+ return undefined;
830
+ throw error;
831
+ });
832
+ }
833
+ catch (error) {
834
+ this.warnUnreadable(path, error);
835
+ return 'unreadable';
836
+ }
719
837
  if (!content)
720
838
  return { exists: false };
721
839
  const { hash } = await storeContent(join(this.directory, 'content'), content);
@@ -736,13 +854,15 @@ export class FileRecoveryStore {
736
854
  }
737
855
  catch (error) {
738
856
  // A run that stopped before opening its journal never changed anything.
739
- if (isNotFound(error))
740
- return { entries: [], observations: [], lastSequence: 0 };
857
+ if (isNotFound(error)) {
858
+ return { entries: [], observations: [], unrecorded: [], lastSequence: 0 };
859
+ }
741
860
  throw error;
742
861
  }
743
862
  const lines = text.split('\n');
744
863
  const entries = new Map();
745
864
  const observations = new Map();
865
+ const unrecorded = [];
746
866
  let lastSequence = 0;
747
867
  lines.forEach((text, index) => {
748
868
  if (text === '')
@@ -783,6 +903,10 @@ export class FileRecoveryStore {
783
903
  observations.delete(line.sequence);
784
904
  return;
785
905
  }
906
+ if (line.type === 'unrecorded') {
907
+ unrecorded.push({ sequence: line.sequence, path: line.path, by: line.by });
908
+ return;
909
+ }
786
910
  const entry = entries.get(line.sequence);
787
911
  if (entry)
788
912
  entry.status = line.type;
@@ -790,6 +914,7 @@ export class FileRecoveryStore {
790
914
  return {
791
915
  entries: [...entries.values()],
792
916
  observations: [...observations.values()],
917
+ unrecorded,
793
918
  lastSequence,
794
919
  };
795
920
  }
@@ -839,9 +964,12 @@ export class FileRecoveryStore {
839
964
  await unlink(path);
840
965
  }
841
966
  }
842
- /** No change it recorded may have reached a file: it recorded none, or abandoned each one. */
843
- function changedNothing(entries) {
844
- return entries.every(entry => entry.status === 'abandoned');
967
+ /**
968
+ * No change it recorded may have reached a file: it recorded none, or abandoned each one, and
969
+ * none it could not copy.
970
+ */
971
+ function changedNothing({ entries, unrecorded, }) {
972
+ return unrecorded.length === 0 && entries.every(entry => entry.status === 'abandoned');
845
973
  }
846
974
  /**
847
975
  * A run recorded as `running` only runs while it holds the lock, its process lives, and, in this
@@ -932,6 +1060,7 @@ class FileRunJournal {
932
1060
  contentDirectory;
933
1061
  journal;
934
1062
  discard;
1063
+ settle;
935
1064
  release;
936
1065
  sequence = 0;
937
1066
  stored = 0;
@@ -945,19 +1074,26 @@ class FileRunJournal {
945
1074
  // The changes recorded as prepared and not abandoned: those that may have reached a file. One
946
1075
  // whose `prepared` line failed is not here, since nothing writes a file before that line.
947
1076
  effects = new Set();
1077
+ // The observations begun and not yet observed: what they saw is not all recorded.
1078
+ observing = new Set();
1079
+ // A change it could not copy reached a file, and stays in the history.
1080
+ lost = false;
1081
+ // Settling what it left open as it finished found that it changed nothing.
1082
+ settledEmpty = false;
948
1083
  // Appends run one after another, so the lines never interleave.
949
1084
  pending = Promise.resolve();
950
1085
  finished = false;
951
1086
  // Set when an append fails: it may have left part of a line, and anything after it would be
952
1087
  // joined to that part and unreadable.
953
1088
  broken;
954
- constructor({ record, runDirectory, contentDirectory, journal, discard, release, }) {
1089
+ constructor({ record, runDirectory, contentDirectory, journal, discard, settle, release, }) {
955
1090
  this.runId = record.runId;
956
1091
  this.record = record;
957
1092
  this.runDirectory = runDirectory;
958
1093
  this.contentDirectory = contentDirectory;
959
1094
  this.journal = journal;
960
1095
  this.discard = discard;
1096
+ this.settle = settle;
961
1097
  this.release = release;
962
1098
  }
963
1099
  async saveContent(content) {
@@ -1000,6 +1136,7 @@ class FileRunJournal {
1000
1136
  await this.append({ type: 'observing', sequence, manifest, by });
1001
1137
  // Until it is observed, whatever ran may have changed a file.
1002
1138
  this.effects.add(sequence);
1139
+ this.observing.add(sequence);
1003
1140
  return sequence;
1004
1141
  }
1005
1142
  async observed(sequence, measure) {
@@ -1007,6 +1144,12 @@ class FileRunJournal {
1007
1144
  await this.append({ type: 'observed', sequence, ...(measure ? { measure } : {}) });
1008
1145
  // Its changes, if any, are entries of their own now.
1009
1146
  this.effects.delete(sequence);
1147
+ this.observing.delete(sequence);
1148
+ }
1149
+ async unrecorded(path, by) {
1150
+ this.assertOpen();
1151
+ await this.append({ type: 'unrecorded', sequence: ++this.sequence, path, by });
1152
+ this.lost = true;
1010
1153
  }
1011
1154
  get storedBytes() {
1012
1155
  return this.stored;
@@ -1031,7 +1174,7 @@ class FileRunJournal {
1031
1174
  return this.state.hash;
1032
1175
  }
1033
1176
  get changed() {
1034
- return this.effects.size > 0;
1177
+ return !this.settledEmpty && (this.lost || this.effects.size > 0);
1035
1178
  }
1036
1179
  async finish(status) {
1037
1180
  this.assertOpen();
@@ -1044,8 +1187,17 @@ class FileRunJournal {
1044
1187
  await this.discard();
1045
1188
  return;
1046
1189
  }
1047
- const record = { ...this.record, status, finishedAt: new Date().toISOString() };
1190
+ const unsettled = this.observing.size > 0;
1191
+ const record = {
1192
+ ...this.record,
1193
+ status,
1194
+ finishedAt: new Date().toISOString(),
1195
+ ...(unsettled ? { unsettled } : {}),
1196
+ };
1048
1197
  await writeDurably(join(this.runDirectory, 'run.json'), JSON.stringify(record));
1198
+ // Recorded first as unsettled, so a failure or a crash here leaves it to the next run.
1199
+ if (unsettled)
1200
+ this.settledEmpty = !(await this.settle(record));
1049
1201
  }
1050
1202
  finally {
1051
1203
  await this.release();