@mikode13/harness 11.0.1 → 11.2.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.
Files changed (120) hide show
  1. package/README.md +186 -16
  2. package/dist/agent/domain/agent.d.ts +41 -3
  3. package/dist/agent/domain/agent.js.map +1 -1
  4. package/dist/agent/domain/approval.d.ts +20 -0
  5. package/dist/agent/domain/approval.js.map +1 -1
  6. package/dist/agent/domain/inbox.d.ts +42 -0
  7. package/dist/agent/domain/inbox.js +81 -0
  8. package/dist/agent/domain/inbox.js.map +1 -0
  9. package/dist/agent/domain/oneRunAtATime.d.ts +17 -0
  10. package/dist/agent/domain/oneRunAtATime.js +32 -0
  11. package/dist/agent/domain/oneRunAtATime.js.map +1 -0
  12. package/dist/agent/domain/runContext.d.ts +9 -0
  13. package/dist/agent/domain/runContext.js +5 -0
  14. package/dist/agent/domain/runContext.js.map +1 -1
  15. package/dist/agent/domain/session.d.ts +15 -0
  16. package/dist/agent/domain/session.js +80 -0
  17. package/dist/agent/domain/session.js.map +1 -0
  18. package/dist/engines/claude/infrastructure/model/claudeAgent.d.ts +6 -2
  19. package/dist/engines/claude/infrastructure/model/claudeAgent.js +20 -5
  20. package/dist/engines/claude/infrastructure/model/claudeAgent.js.map +1 -1
  21. package/dist/engines/codex/infrastructure/model/codexAgent.d.ts +5 -1
  22. package/dist/engines/codex/infrastructure/model/codexAgent.js +13 -2
  23. package/dist/engines/codex/infrastructure/model/codexAgent.js.map +1 -1
  24. package/dist/engines/domain/model/backgroundAgents.d.ts +5 -2
  25. package/dist/engines/domain/model/backgroundAgents.js +11 -3
  26. package/dist/engines/domain/model/backgroundAgents.js.map +1 -1
  27. package/dist/engines/domain/model/compactor.d.ts +40 -0
  28. package/dist/engines/domain/model/compactor.js +5 -0
  29. package/dist/engines/domain/model/compactor.js.map +1 -0
  30. package/dist/engines/domain/model/llmAgent.d.ts +53 -2
  31. package/dist/engines/domain/model/llmAgent.js +419 -24
  32. package/dist/engines/domain/model/llmAgent.js.map +1 -1
  33. package/dist/engines/domain/model/recentCompaction.d.ts +39 -0
  34. package/dist/engines/domain/model/recentCompaction.js +119 -0
  35. package/dist/engines/domain/model/recentCompaction.js.map +1 -0
  36. package/dist/engines/domain/model/stepContext.js +3 -0
  37. package/dist/engines/domain/model/stepContext.js.map +1 -1
  38. package/dist/engines/domain/model/triagedCompaction.d.ts +27 -0
  39. package/dist/engines/domain/model/triagedCompaction.js +60 -0
  40. package/dist/engines/domain/model/triagedCompaction.js.map +1 -0
  41. package/dist/factory/infrastructure/agentLLMFactory.d.ts +56 -15
  42. package/dist/factory/infrastructure/agentLLMFactory.js +144 -25
  43. package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
  44. package/dist/index.d.ts +6 -2
  45. package/dist/index.js +3 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/llm/domain/contextTriage.d.ts +26 -0
  48. package/dist/llm/domain/contextTriage.js +3 -0
  49. package/dist/llm/domain/contextTriage.js.map +1 -0
  50. package/dist/llm/domain/llm.d.ts +18 -1
  51. package/dist/llm/domain/llm.js.map +1 -1
  52. package/dist/llm/domain/message.d.ts +18 -1
  53. package/dist/llm/domain/message.js +20 -0
  54. package/dist/llm/domain/message.js.map +1 -1
  55. package/dist/llm/infrastructure/claudeLLMClient.d.ts +4 -5
  56. package/dist/llm/infrastructure/claudeLLMClient.js +39 -13
  57. package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
  58. package/dist/llm/infrastructure/jevContextTriage.d.ts +18 -0
  59. package/dist/llm/infrastructure/jevContextTriage.js +92 -0
  60. package/dist/llm/infrastructure/jevContextTriage.js.map +1 -0
  61. package/dist/llm/infrastructure/openAILLMClient.d.ts +4 -5
  62. package/dist/llm/infrastructure/openAILLMClient.js +20 -9
  63. package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
  64. package/dist/orchestration/domain/model/orchestratorAgent.d.ts +9 -1
  65. package/dist/orchestration/domain/model/orchestratorAgent.js +20 -3
  66. package/dist/orchestration/domain/model/orchestratorAgent.js.map +1 -1
  67. package/dist/recovery/domain/history.d.ts +10 -3
  68. package/dist/recovery/domain/history.js +1 -0
  69. package/dist/recovery/domain/history.js.map +1 -1
  70. package/dist/recovery/domain/recoveryStore.d.ts +14 -2
  71. package/dist/recovery/domain/recoveryStore.js.map +1 -1
  72. package/dist/recovery/infrastructure/fileRecoveryStore.d.ts +21 -2
  73. package/dist/recovery/infrastructure/fileRecoveryStore.js +58 -6
  74. package/dist/recovery/infrastructure/fileRecoveryStore.js.map +1 -1
  75. package/dist/recovery/infrastructure/folderClaims.d.ts +27 -0
  76. package/dist/recovery/infrastructure/folderClaims.js +124 -0
  77. package/dist/recovery/infrastructure/folderClaims.js.map +1 -0
  78. package/dist/shared/domain/session.d.ts +12 -0
  79. package/dist/shared/domain/session.js +2 -0
  80. package/dist/shared/domain/session.js.map +1 -0
  81. package/dist/tools/domain/preparedCall.d.ts +5 -2
  82. package/dist/tools/domain/preparedCall.js +7 -1
  83. package/dist/tools/domain/preparedCall.js.map +1 -1
  84. package/dist/tools/domain/readRegistry.d.ts +11 -0
  85. package/dist/tools/domain/readRegistry.js.map +1 -1
  86. package/dist/tools/infrastructure/agentAsTool.d.ts +6 -0
  87. package/dist/tools/infrastructure/agentAsTool.js +9 -0
  88. package/dist/tools/infrastructure/agentAsTool.js.map +1 -1
  89. package/dist/tools/infrastructure/commandTool.js +6 -1
  90. package/dist/tools/infrastructure/commandTool.js.map +1 -1
  91. package/dist/tools/infrastructure/exploreTool.js +2 -0
  92. package/dist/tools/infrastructure/exploreTool.js.map +1 -1
  93. package/dist/tools/infrastructure/fileTools.d.ts +2 -0
  94. package/dist/tools/infrastructure/fileTools.js +10 -1
  95. package/dist/tools/infrastructure/fileTools.js.map +1 -1
  96. package/dist/tools/infrastructure/removeWorktreeTool.d.ts +23 -0
  97. package/dist/tools/infrastructure/removeWorktreeTool.js +178 -0
  98. package/dist/tools/infrastructure/removeWorktreeTool.js.map +1 -0
  99. package/dist/tools/infrastructure/subagents.d.ts +3 -1
  100. package/dist/tools/infrastructure/subagents.js +2 -1
  101. package/dist/tools/infrastructure/subagents.js.map +1 -1
  102. package/dist/tools/infrastructure/trackedEdits.js +1 -0
  103. package/dist/tools/infrastructure/trackedEdits.js.map +1 -1
  104. package/dist/tools/infrastructure/workspaceWrites.d.ts +17 -1
  105. package/dist/tools/infrastructure/workspaceWrites.js +48 -17
  106. package/dist/tools/infrastructure/workspaceWrites.js.map +1 -1
  107. package/dist/tools/infrastructure/worktrees.d.ts +49 -0
  108. package/dist/tools/infrastructure/worktrees.js +154 -0
  109. package/dist/tools/infrastructure/worktrees.js.map +1 -0
  110. package/dist/tools/infrastructure/writeCeiling.d.ts +16 -0
  111. package/dist/tools/infrastructure/writeCeiling.js +49 -0
  112. package/dist/tools/infrastructure/writeCeiling.js.map +1 -0
  113. package/dist/tools/infrastructure/writeSession.d.ts +5 -1
  114. package/dist/tools/infrastructure/writeSession.js +7 -3
  115. package/dist/tools/infrastructure/writeSession.js.map +1 -1
  116. package/dist/workflow/domain/workflow.d.ts +3 -0
  117. package/dist/workflow/domain/workflow.js.map +1 -1
  118. package/dist/workflow/domain/workflowRunner.js +8 -2
  119. package/dist/workflow/domain/workflowRunner.js.map +1 -1
  120. package/package.json +2 -1
@@ -1,3 +1,4 @@
1
+ import type { Session } from '#src/shared/domain/session';
1
2
  /**
2
3
  * What one path held at one moment: nothing, or a regular file with this content and mode.
3
4
  * `hash` names the content in the store, so the bytes themselves never travel in a record.
@@ -95,6 +96,8 @@ export interface RunRecord {
95
96
  unsettled?: true;
96
97
  /** The process that ran it, so a run whose process died can be told from a live one. */
97
98
  pid: number;
99
+ /** The session it came from. Absent for a run recorded before runs recorded one. */
100
+ session?: Session;
98
101
  }
99
102
  /**
100
103
  * The recovery record of one run that writes. Every method returns only once what it records
@@ -209,14 +212,23 @@ export interface RecoveryStore {
209
212
  * same one.
210
213
  */
211
214
  readonly workspaceId: string;
215
+ /**
216
+ * What its runs may write in, as real paths: its root and the other folders it was opened
217
+ * with. A run claims them while it holds the workspace, so no worktree they lie in is removed
218
+ * meanwhile.
219
+ */
220
+ readonly writeFolders: readonly string[];
212
221
  /**
213
222
  * Starts recording a run and holds the workspace for it until `finish`. The new run's parent
214
223
  * is the run the workspace is at, and the workspace is at the new run from then on. Runs a
215
224
  * dead process left `running` are settled first.
216
225
  *
217
- * @throws {WorkspaceBusyError} when another live run is writing to the workspace.
226
+ * @throws {WorkspaceBusyError} when another live run is writing to the workspace, or a
227
+ * worktree it may write in is being removed.
218
228
  */
219
- startRun(): Promise<RunJournal>;
229
+ startRun(options?: {
230
+ session?: Session;
231
+ }): Promise<RunJournal>;
220
232
  /**
221
233
  * Every run, oldest first, and the run the workspace is at (`head`), absent before the first.
222
234
  * A run still recorded as `running` whose process is gone is listed as `interrupted`.
@@ -1 +1 @@
1
- {"version":3,"file":"recoveryStore.js","sourceRoot":"","sources":["../../../src/recovery/domain/recoveryStore.ts"],"names":[],"mappings":"AAgKA,oFAAoF;AACpF,MAAM,CAAC,MAAM,YAAY,GAAG,OAAO,CAAC;AAyCpC,2FAA2F;AAC3F,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAC5C,YAAY,SAA0B;QACrC,KAAK,CAAC,SAAS,KAAK,MAAM,CAAC,CAAC,CAAC,yBAAyB,CAAC,CAAC,CAAC,yBAAyB,CAAC,CAAC;QACpF,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IAClC,CAAC;CACD;AAqED,uFAAuF;AACvF,MAAM,OAAO,mBAAoB,SAAQ,KAAK;IAC7C,uEAAuE;IAC9D,QAAQ,CAAS;IAC1B,wDAAwD;IAC/C,MAAM,CAAS;IAExB,YAAY,QAAgB,EAAE,MAAc;QAC3C,MAAM,IAAI,GAAG,CAAC,KAAa,EAAE,EAAE,CAAC,CAAC,KAAK,KAAK,YAAY,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,OAAO,KAAK,EAAE,CAAC,CAAC;QACxF,KAAK,CAAC,iCAAiC,IAAI,CAAC,QAAQ,CAAC,cAAc,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACnF,IAAI,CAAC,IAAI,GAAG,qBAAqB,CAAC;QAClC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACtB,CAAC;CACD;AAED,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IACnC,KAAK,CAAS;IAEvB,YAAY,KAAa;QACxB,KAAK,CAAC,0BAA0B,KAAK,mBAAmB,CAAC,CAAC;QAC1D,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;QACjC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACpB,CAAC;CACD","sourcesContent":["/**\n * What one path held at one moment: nothing, or a regular file with this content and mode.\n * `hash` names the content in the store, so the bytes themselves never travel in a record.\n */\nexport type FileState = { exists: false } | { exists: true; hash: string; mode: number };\n\n/**\n * One change to one file. It is recorded as `prepared` before the file is touched, and only\n * then applied, so a run that stops halfway always leaves a record of what it may have done.\n * `abandoned` means the change was prepared but never happened.\n */\nexport interface JournalEntry {\n\tsequence: number;\n\t/** The file's real path, absolute: a run may write to more than one root of its workspace. */\n\tpath: string;\n\tbefore: FileState;\n\tafter: FileState;\n\t/** Folders the change creates for a new file, outermost first: undo removes them if empty. */\n\tcreatedFolders?: string[];\n\t/**\n\t * The command whose run the change was seen around, for a change no tool of the harness made\n\t * itself. It was found by comparing the file before and after, not journaled before it was made.\n\t */\n\tobservedBy?: string;\n\tstatus: 'prepared' | 'applied' | 'abandoned';\n}\n\n/**\n * What a group of files held right before something outside the journal ran, such as a\n * command: the content of each, by its real path, absolute. It is stored as content, and the\n * journal names its hash before the command starts.\n */\nexport interface Manifest {\n\tfiles: Record<string, FileState>;\n\t/**\n\t * Covered paths it could not copy, such as a folder, a link or a file too large: what they\n\t * held is unknown, so a change to one can be named but not undone.\n\t */\n\tuncopyable?: string[];\n}\n\n/**\n * A path a command changed into, or from, something the history cannot copy, such as a file\n * over the size limit: what it held before or after is unknown. Undo and redo leave it as it is,\n * and report it as a conflict.\n */\nexport interface UnrecordedChange {\n\t/** The real path, absolute. */\n\tpath: string;\n\t/** The command it was seen around. */\n\tby: string;\n}\n\n/** What one observation cost: before and after together. */\nexport interface ObservationMeasure {\n\t/** Files captured before, and compared after. */\n\tfiles: number;\n\tbytesRead: number;\n\t/** New content stored, which the store did not hold already. */\n\tbytesStored: number;\n\t/** Spent capturing and comparing, not running what was observed. */\n\tdurationMs: number;\n}\n\n/**\n * `interrupted` is a run whose process died while it was writing. The next run to take the\n * workspace settles each change it left `prepared`, from what the file holds now.\n */\nexport type RunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted';\n\nexport interface RunRecord {\n\trunId: string;\n\t/** The real path of the workspace root the run wrote to. */\n\troot: string;\n\t/**\n\t * The run the workspace was at when this one started, so runs form a tree: going back to an\n\t * earlier run and writing again starts a new branch. Absent for the first run.\n\t */\n\tparentRunId?: string;\n\t/**\n\t * Runs whose changes retention chained into this one, oldest first. They are no longer in\n\t * the history: this run now goes back to where the oldest of them started.\n\t */\n\tabsorbed?: string[];\n\t/** The journal file that holds the run's changes. Absent while it is the first one written. */\n\tjournal?: string;\n\tstartedAt: string;\n\tfinishedAt?: string;\n\tstatus: RunStatus;\n\t/**\n\t * It finished with an observation still open, such as a command whose changes could not be\n\t * compared after it ran: its journal may not hold all it changed yet. The next run or move\n\t * settles it, as for an interrupted run, and keeps its status.\n\t */\n\tunsettled?: true;\n\t/** The process that ran it, so a run whose process died can be told from a live one. */\n\tpid: number;\n}\n\n/**\n * The recovery record of one run that writes. Every method returns only once what it records\n * is on disk: a change is never made before the record that would undo it exists. Once a step\n * fails to reach the journal, the journal refuses every later step, because a line written\n * after a broken one could not be read back.\n */\nexport interface RunJournal {\n\treadonly runId: string;\n\t/** Stores `content` and returns the hash that names it. Content stored before is reused. */\n\tsaveContent(content: Buffer): Promise<string>;\n\t/** Records a change about to be made, and returns its sequence number. */\n\tprepare(change: Omit<JournalEntry, 'sequence' | 'status'>): Promise<number>;\n\t/** The change was made. */\n\tapplied(sequence: number): Promise<void>;\n\t/** The change was not made, and the file still holds its `before`. */\n\tabandoned(sequence: number): Promise<void>;\n\t/**\n\t * Records that `by`, such as a command, is about to change files the journal cannot see it\n\t * change, and that the stored `manifest` holds what they hold now. Each change it makes is\n\t * then recorded as its own entry, prepared and applied at once, before `observed`. If the run\n\t * stops first, the next run settles it: every file of the manifest that changed since is\n\t * recorded as changed by `by`. A file it created is not in the manifest, and is then missed.\n\t */\n\tobserve(manifest: string, by: string): Promise<number>;\n\t/**\n\t * Every change the observation found is recorded. `measure`, what capturing and comparing the\n\t * files cost, is kept with it.\n\t */\n\tobserved(sequence: number, measure?: ObservationMeasure): Promise<void>;\n\t/**\n\t * Records that `by` changed `path` into, or from, something the history cannot copy. A move\n\t * over the run leaves it as it is, and is not complete.\n\t */\n\tunrecorded(path: string, by: string): Promise<void>;\n\t/** How many bytes of content this run stored that the store did not hold yet. */\n\treadonly storedBytes: number;\n\t/** The path of every change it recorded as applied, in order: each one reached a file. */\n\treadonly appliedPaths: readonly string[];\n\t/**\n\t * What the files this run changed hold now, as one hash of their paths and states. A file back\n\t * to what it held before the run's first change to it counts as unchanged, so a change undone\n\t * gives the state from before it.\n\t */\n\tcontentState(): string;\n\t/**\n\t * Whether a change it recorded may have reached a file: one prepared and not abandoned, or one\n\t * it could not copy. It holds whatever happens to the record afterwards, except once `finish`\n\t * settled an observation the run left open and found that the run changed nothing: the run\n\t * left the history, so it is `false`.\n\t */\n\treadonly changed: boolean;\n\t/**\n\t * Ends the run and releases the workspace for the next writer. A run that `changed` nothing\n\t * leaves the history, and the workspace is back at the run before it. If removing it fails,\n\t * the store still treats it as gone, and the next run or move finishes removing it. A run that\n\t * ends with an observation open is settled from its manifest now, or by the next run or move\n\t * if that fails.\n\t */\n\tfinish(status: Exclude<RunStatus, 'running' | 'interrupted'>): Promise<void>;\n}\n\n/** The point before any run: going there takes the workspace back to how it was. */\nexport const historyStart = 'start';\n\n/**\n * A file a move left as it was: it did not hold what the history expected there, or a command\n * changed it into, or from, something the history cannot copy.\n */\nexport interface MoveConflict {\n\tpath: string;\n\t/** The run whose change could not be undone or redone. */\n\trunId: string;\n}\n\n/**\n * One move of the workspace through its history, kept so an agent that runs next can tell\n * that its conversation is about a state the workspace has left.\n */\nexport interface Revision {\n\t/** Counts the moves of this workspace, from 1. */\n\trevision: number;\n\t/** The run the workspace was at; absent for the start of the history. */\n\tfrom?: string;\n\t/** The run the workspace is at now; absent for the start of the history. */\n\tto?: string;\n\tat: string;\n\t/** Why the user went there, when the host asked. */\n\treason?: string;\n\t/** Every file reached the state the history expected. Otherwise `conflicts` names those that did not. */\n\tcomplete: boolean;\n\tconflicts: MoveConflict[];\n}\n\nexport interface MoveOptions {\n\t/** Why the user is moving the workspace, recorded with the move. */\n\treason?: string;\n\t/**\n\t * Where the workspace must be for the move to happen: a run, or `historyStart`. Checked under\n\t * the workspace lock, so a move confirmed against one state never runs from another.\n\t */\n\tfrom?: string;\n}\n\n/** There is nothing to undo at the start of the history, or nothing to redo at its tip. */\nexport class NothingToMoveError extends Error {\n\tconstructor(direction: 'undo' | 'redo') {\n\t\tsuper(direction === 'undo' ? 'There is no run to undo' : 'There is no run to redo');\n\t\tthis.name = 'NothingToMoveError';\n\t}\n}\n\n/**\n * Where the runs that write to one workspace keep what is needed to undo them, outside the\n * workspace itself. Only one run writes to a workspace at a time.\n */\nexport interface RecoveryStore {\n\t/**\n\t * The history on disk this store keeps, from the real paths of its root and its state\n\t * directory: two stores opened over one history, under any spelling of those paths, have the\n\t * same one.\n\t */\n\treadonly workspaceId: string;\n\t/**\n\t * Starts recording a run and holds the workspace for it until `finish`. The new run's parent\n\t * is the run the workspace is at, and the workspace is at the new run from then on. Runs a\n\t * dead process left `running` are settled first.\n\t *\n\t * @throws {WorkspaceBusyError} when another live run is writing to the workspace.\n\t */\n\tstartRun(): Promise<RunJournal>;\n\t/**\n\t * Every run, oldest first, and the run the workspace is at (`head`), absent before the first.\n\t * A run still recorded as `running` whose process is gone is listed as `interrupted`.\n\t */\n\tlistRuns(): Promise<{ head?: string; runs: RunRecord[] }>;\n\t/**\n\t * Moves the workspace to the state right after `target`, or to how it was before any run\n\t * with `historyStart`. It undoes runs one by one back to the closest common ancestor, then\n\t * redoes runs one by one forward to `target`. Each file is written only if it holds the\n\t * state the history expects there; one that does not is left as it is and reported, and the\n\t * move is then not `complete`. A move the process did not finish is finished by the next\n\t * move or run.\n\t *\n\t * @throws {WorkspaceBusyError} when a run or another move holds the workspace.\n\t * @throws {WorkspaceMovedError} when `from` was given and the workspace is elsewhere.\n\t * @throws {UnknownRunError} when `target` is not in the history.\n\t * @throws {HistoryExpiredError} when retention chained `target` into a later run.\n\t */\n\tgoTo(target: string, options?: MoveOptions): Promise<Revision>;\n\t/**\n\t * Moves the workspace to the run before the one it is at.\n\t *\n\t * @throws {NothingToMoveError} at the start of the history.\n\t */\n\tundo(options?: MoveOptions): Promise<Revision>;\n\t/**\n\t * Moves the workspace to the next run: towards where it was most recently, or the newest\n\t * branch.\n\t *\n\t * @throws {NothingToMoveError} when no run follows the one it is at.\n\t */\n\tredo(options?: MoveOptions): Promise<Revision>;\n\t/** Every move of the workspace, oldest first. */\n\tlistRevisions(): Promise<Revision[]>;\n\t/**\n\t * A run's record, its changes, in the order they were prepared, and the changes it could not\n\t * copy.\n\t *\n\t * @throws {UnknownRunError} when `runId` was never a run of this workspace.\n\t * @throws {HistoryExpiredError} when retention removed `runId` or chained it into a later run.\n\t */\n\treadRun(\n\t\trunId: string,\n\t): Promise<{ record: RunRecord; entries: JournalEntry[]; unrecorded: UnrecordedChange[] }>;\n\t/** The bytes a hash names. */\n\treadContent(hash: string): Promise<Buffer>;\n}\n\n/** A move was asked from one point of the history, and the workspace is at another. */\nexport class WorkspaceMovedError extends Error {\n\t/** Where the move expected the workspace: a run, or `historyStart`. */\n\treadonly expected: string;\n\t/** Where the workspace is: a run, or `historyStart`. */\n\treadonly actual: string;\n\n\tconstructor(expected: string, actual: string) {\n\t\tconst name = (point: string) => (point === historyStart ? 'the start' : `run ${point}`);\n\t\tsuper(`The workspace is no longer at ${name(expected)}: it is at ${name(actual)}`);\n\t\tthis.name = 'WorkspaceMovedError';\n\t\tthis.expected = expected;\n\t\tthis.actual = actual;\n\t}\n}\n\nexport class WorkspaceBusyError extends Error {\n\treadonly runId: string;\n\n\tconstructor(runId: string) {\n\t\tsuper(`The workspace is busy: ${runId} is writing to it`);\n\t\tthis.name = 'WorkspaceBusyError';\n\t\tthis.runId = runId;\n\t}\n}\n"]}
1
+ {"version":3,"file":"recoveryStore.js","sourceRoot":"","sources":["../../../src/recovery/domain/recoveryStore.ts"],"names":[],"mappings":"AAoKA,oFAAoF;AACpF,MAAM,CAAC,MAAM,YAAY,GAAG,OAAO,CAAC;AAyCpC,2FAA2F;AAC3F,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAC5C,YAAY,SAA0B;QACrC,KAAK,CAAC,SAAS,KAAK,MAAM,CAAC,CAAC,CAAC,yBAAyB,CAAC,CAAC,CAAC,yBAAyB,CAAC,CAAC;QACpF,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IAClC,CAAC;CACD;AA4ED,uFAAuF;AACvF,MAAM,OAAO,mBAAoB,SAAQ,KAAK;IAC7C,uEAAuE;IAC9D,QAAQ,CAAS;IAC1B,wDAAwD;IAC/C,MAAM,CAAS;IAExB,YAAY,QAAgB,EAAE,MAAc;QAC3C,MAAM,IAAI,GAAG,CAAC,KAAa,EAAE,EAAE,CAAC,CAAC,KAAK,KAAK,YAAY,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,OAAO,KAAK,EAAE,CAAC,CAAC;QACxF,KAAK,CAAC,iCAAiC,IAAI,CAAC,QAAQ,CAAC,cAAc,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACnF,IAAI,CAAC,IAAI,GAAG,qBAAqB,CAAC;QAClC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACtB,CAAC;CACD;AAED,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IACnC,KAAK,CAAS;IAEvB,YAAY,KAAa;QACxB,KAAK,CAAC,0BAA0B,KAAK,mBAAmB,CAAC,CAAC;QAC1D,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;QACjC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACpB,CAAC;CACD","sourcesContent":["import type { Session } from '#src/shared/domain/session';\n\n/**\n * What one path held at one moment: nothing, or a regular file with this content and mode.\n * `hash` names the content in the store, so the bytes themselves never travel in a record.\n */\nexport type FileState = { exists: false } | { exists: true; hash: string; mode: number };\n\n/**\n * One change to one file. It is recorded as `prepared` before the file is touched, and only\n * then applied, so a run that stops halfway always leaves a record of what it may have done.\n * `abandoned` means the change was prepared but never happened.\n */\nexport interface JournalEntry {\n\tsequence: number;\n\t/** The file's real path, absolute: a run may write to more than one root of its workspace. */\n\tpath: string;\n\tbefore: FileState;\n\tafter: FileState;\n\t/** Folders the change creates for a new file, outermost first: undo removes them if empty. */\n\tcreatedFolders?: string[];\n\t/**\n\t * The command whose run the change was seen around, for a change no tool of the harness made\n\t * itself. It was found by comparing the file before and after, not journaled before it was made.\n\t */\n\tobservedBy?: string;\n\tstatus: 'prepared' | 'applied' | 'abandoned';\n}\n\n/**\n * What a group of files held right before something outside the journal ran, such as a\n * command: the content of each, by its real path, absolute. It is stored as content, and the\n * journal names its hash before the command starts.\n */\nexport interface Manifest {\n\tfiles: Record<string, FileState>;\n\t/**\n\t * Covered paths it could not copy, such as a folder, a link or a file too large: what they\n\t * held is unknown, so a change to one can be named but not undone.\n\t */\n\tuncopyable?: string[];\n}\n\n/**\n * A path a command changed into, or from, something the history cannot copy, such as a file\n * over the size limit: what it held before or after is unknown. Undo and redo leave it as it is,\n * and report it as a conflict.\n */\nexport interface UnrecordedChange {\n\t/** The real path, absolute. */\n\tpath: string;\n\t/** The command it was seen around. */\n\tby: string;\n}\n\n/** What one observation cost: before and after together. */\nexport interface ObservationMeasure {\n\t/** Files captured before, and compared after. */\n\tfiles: number;\n\tbytesRead: number;\n\t/** New content stored, which the store did not hold already. */\n\tbytesStored: number;\n\t/** Spent capturing and comparing, not running what was observed. */\n\tdurationMs: number;\n}\n\n/**\n * `interrupted` is a run whose process died while it was writing. The next run to take the\n * workspace settles each change it left `prepared`, from what the file holds now.\n */\nexport type RunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted';\n\nexport interface RunRecord {\n\trunId: string;\n\t/** The real path of the workspace root the run wrote to. */\n\troot: string;\n\t/**\n\t * The run the workspace was at when this one started, so runs form a tree: going back to an\n\t * earlier run and writing again starts a new branch. Absent for the first run.\n\t */\n\tparentRunId?: string;\n\t/**\n\t * Runs whose changes retention chained into this one, oldest first. They are no longer in\n\t * the history: this run now goes back to where the oldest of them started.\n\t */\n\tabsorbed?: string[];\n\t/** The journal file that holds the run's changes. Absent while it is the first one written. */\n\tjournal?: string;\n\tstartedAt: string;\n\tfinishedAt?: string;\n\tstatus: RunStatus;\n\t/**\n\t * It finished with an observation still open, such as a command whose changes could not be\n\t * compared after it ran: its journal may not hold all it changed yet. The next run or move\n\t * settles it, as for an interrupted run, and keeps its status.\n\t */\n\tunsettled?: true;\n\t/** The process that ran it, so a run whose process died can be told from a live one. */\n\tpid: number;\n\t/** The session it came from. Absent for a run recorded before runs recorded one. */\n\tsession?: Session;\n}\n\n/**\n * The recovery record of one run that writes. Every method returns only once what it records\n * is on disk: a change is never made before the record that would undo it exists. Once a step\n * fails to reach the journal, the journal refuses every later step, because a line written\n * after a broken one could not be read back.\n */\nexport interface RunJournal {\n\treadonly runId: string;\n\t/** Stores `content` and returns the hash that names it. Content stored before is reused. */\n\tsaveContent(content: Buffer): Promise<string>;\n\t/** Records a change about to be made, and returns its sequence number. */\n\tprepare(change: Omit<JournalEntry, 'sequence' | 'status'>): Promise<number>;\n\t/** The change was made. */\n\tapplied(sequence: number): Promise<void>;\n\t/** The change was not made, and the file still holds its `before`. */\n\tabandoned(sequence: number): Promise<void>;\n\t/**\n\t * Records that `by`, such as a command, is about to change files the journal cannot see it\n\t * change, and that the stored `manifest` holds what they hold now. Each change it makes is\n\t * then recorded as its own entry, prepared and applied at once, before `observed`. If the run\n\t * stops first, the next run settles it: every file of the manifest that changed since is\n\t * recorded as changed by `by`. A file it created is not in the manifest, and is then missed.\n\t */\n\tobserve(manifest: string, by: string): Promise<number>;\n\t/**\n\t * Every change the observation found is recorded. `measure`, what capturing and comparing the\n\t * files cost, is kept with it.\n\t */\n\tobserved(sequence: number, measure?: ObservationMeasure): Promise<void>;\n\t/**\n\t * Records that `by` changed `path` into, or from, something the history cannot copy. A move\n\t * over the run leaves it as it is, and is not complete.\n\t */\n\tunrecorded(path: string, by: string): Promise<void>;\n\t/** How many bytes of content this run stored that the store did not hold yet. */\n\treadonly storedBytes: number;\n\t/** The path of every change it recorded as applied, in order: each one reached a file. */\n\treadonly appliedPaths: readonly string[];\n\t/**\n\t * What the files this run changed hold now, as one hash of their paths and states. A file back\n\t * to what it held before the run's first change to it counts as unchanged, so a change undone\n\t * gives the state from before it.\n\t */\n\tcontentState(): string;\n\t/**\n\t * Whether a change it recorded may have reached a file: one prepared and not abandoned, or one\n\t * it could not copy. It holds whatever happens to the record afterwards, except once `finish`\n\t * settled an observation the run left open and found that the run changed nothing: the run\n\t * left the history, so it is `false`.\n\t */\n\treadonly changed: boolean;\n\t/**\n\t * Ends the run and releases the workspace for the next writer. A run that `changed` nothing\n\t * leaves the history, and the workspace is back at the run before it. If removing it fails,\n\t * the store still treats it as gone, and the next run or move finishes removing it. A run that\n\t * ends with an observation open is settled from its manifest now, or by the next run or move\n\t * if that fails.\n\t */\n\tfinish(status: Exclude<RunStatus, 'running' | 'interrupted'>): Promise<void>;\n}\n\n/** The point before any run: going there takes the workspace back to how it was. */\nexport const historyStart = 'start';\n\n/**\n * A file a move left as it was: it did not hold what the history expected there, or a command\n * changed it into, or from, something the history cannot copy.\n */\nexport interface MoveConflict {\n\tpath: string;\n\t/** The run whose change could not be undone or redone. */\n\trunId: string;\n}\n\n/**\n * One move of the workspace through its history, kept so an agent that runs next can tell\n * that its conversation is about a state the workspace has left.\n */\nexport interface Revision {\n\t/** Counts the moves of this workspace, from 1. */\n\trevision: number;\n\t/** The run the workspace was at; absent for the start of the history. */\n\tfrom?: string;\n\t/** The run the workspace is at now; absent for the start of the history. */\n\tto?: string;\n\tat: string;\n\t/** Why the user went there, when the host asked. */\n\treason?: string;\n\t/** Every file reached the state the history expected. Otherwise `conflicts` names those that did not. */\n\tcomplete: boolean;\n\tconflicts: MoveConflict[];\n}\n\nexport interface MoveOptions {\n\t/** Why the user is moving the workspace, recorded with the move. */\n\treason?: string;\n\t/**\n\t * Where the workspace must be for the move to happen: a run, or `historyStart`. Checked under\n\t * the workspace lock, so a move confirmed against one state never runs from another.\n\t */\n\tfrom?: string;\n}\n\n/** There is nothing to undo at the start of the history, or nothing to redo at its tip. */\nexport class NothingToMoveError extends Error {\n\tconstructor(direction: 'undo' | 'redo') {\n\t\tsuper(direction === 'undo' ? 'There is no run to undo' : 'There is no run to redo');\n\t\tthis.name = 'NothingToMoveError';\n\t}\n}\n\n/**\n * Where the runs that write to one workspace keep what is needed to undo them, outside the\n * workspace itself. Only one run writes to a workspace at a time.\n */\nexport interface RecoveryStore {\n\t/**\n\t * The history on disk this store keeps, from the real paths of its root and its state\n\t * directory: two stores opened over one history, under any spelling of those paths, have the\n\t * same one.\n\t */\n\treadonly workspaceId: string;\n\t/**\n\t * What its runs may write in, as real paths: its root and the other folders it was opened\n\t * with. A run claims them while it holds the workspace, so no worktree they lie in is removed\n\t * meanwhile.\n\t */\n\treadonly writeFolders: readonly string[];\n\t/**\n\t * Starts recording a run and holds the workspace for it until `finish`. The new run's parent\n\t * is the run the workspace is at, and the workspace is at the new run from then on. Runs a\n\t * dead process left `running` are settled first.\n\t *\n\t * @throws {WorkspaceBusyError} when another live run is writing to the workspace, or a\n\t * worktree it may write in is being removed.\n\t */\n\tstartRun(options?: { session?: Session }): Promise<RunJournal>;\n\t/**\n\t * Every run, oldest first, and the run the workspace is at (`head`), absent before the first.\n\t * A run still recorded as `running` whose process is gone is listed as `interrupted`.\n\t */\n\tlistRuns(): Promise<{ head?: string; runs: RunRecord[] }>;\n\t/**\n\t * Moves the workspace to the state right after `target`, or to how it was before any run\n\t * with `historyStart`. It undoes runs one by one back to the closest common ancestor, then\n\t * redoes runs one by one forward to `target`. Each file is written only if it holds the\n\t * state the history expects there; one that does not is left as it is and reported, and the\n\t * move is then not `complete`. A move the process did not finish is finished by the next\n\t * move or run.\n\t *\n\t * @throws {WorkspaceBusyError} when a run or another move holds the workspace.\n\t * @throws {WorkspaceMovedError} when `from` was given and the workspace is elsewhere.\n\t * @throws {UnknownRunError} when `target` is not in the history.\n\t * @throws {HistoryExpiredError} when retention chained `target` into a later run.\n\t */\n\tgoTo(target: string, options?: MoveOptions): Promise<Revision>;\n\t/**\n\t * Moves the workspace to the run before the one it is at.\n\t *\n\t * @throws {NothingToMoveError} at the start of the history.\n\t */\n\tundo(options?: MoveOptions): Promise<Revision>;\n\t/**\n\t * Moves the workspace to the next run: towards where it was most recently, or the newest\n\t * branch.\n\t *\n\t * @throws {NothingToMoveError} when no run follows the one it is at.\n\t */\n\tredo(options?: MoveOptions): Promise<Revision>;\n\t/** Every move of the workspace, oldest first. */\n\tlistRevisions(): Promise<Revision[]>;\n\t/**\n\t * A run's record, its changes, in the order they were prepared, and the changes it could not\n\t * copy.\n\t *\n\t * @throws {UnknownRunError} when `runId` was never a run of this workspace.\n\t * @throws {HistoryExpiredError} when retention removed `runId` or chained it into a later run.\n\t */\n\treadRun(\n\t\trunId: string,\n\t): Promise<{ record: RunRecord; entries: JournalEntry[]; unrecorded: UnrecordedChange[] }>;\n\t/** The bytes a hash names. */\n\treadContent(hash: string): Promise<Buffer>;\n}\n\n/** A move was asked from one point of the history, and the workspace is at another. */\nexport class WorkspaceMovedError extends Error {\n\t/** Where the move expected the workspace: a run, or `historyStart`. */\n\treadonly expected: string;\n\t/** Where the workspace is: a run, or `historyStart`. */\n\treadonly actual: string;\n\n\tconstructor(expected: string, actual: string) {\n\t\tconst name = (point: string) => (point === historyStart ? 'the start' : `run ${point}`);\n\t\tsuper(`The workspace is no longer at ${name(expected)}: it is at ${name(actual)}`);\n\t\tthis.name = 'WorkspaceMovedError';\n\t\tthis.expected = expected;\n\t\tthis.actual = actual;\n\t}\n}\n\nexport class WorkspaceBusyError extends Error {\n\treadonly runId: string;\n\n\tconstructor(runId: string) {\n\t\tsuper(`The workspace is busy: ${runId} is writing to it`);\n\t\tthis.name = 'WorkspaceBusyError';\n\t\tthis.runId = runId;\n\t}\n}\n"]}
@@ -1,3 +1,4 @@
1
+ import type { Session } from '#src/shared/domain/session';
1
2
  import type { ILogger } from '#src/shared/domain/logger';
2
3
  import { type JournalEntry, type MoveOptions, type Revision, type RecoveryStore, type RunJournal, type RunRecord, type UnrecordedChange } from '../domain/recoveryStore.ts';
3
4
  /** Where an application keeps state that is neither configuration nor cache, per platform. */
@@ -23,12 +24,15 @@ export declare function defaultStateDirectory(env?: NodeJS.ProcessEnv, platform?
23
24
  */
24
25
  export declare class FileRecoveryStore implements RecoveryStore {
25
26
  readonly workspaceId: string;
27
+ readonly writeFolders: readonly string[];
26
28
  private readonly root;
27
29
  private readonly directory;
28
30
  private readonly keepRuns;
29
31
  private readonly logger;
32
+ private readonly claims;
33
+ private readonly releases;
30
34
  private constructor();
31
- static open({ root, directory, keepRuns, logger, }: {
35
+ static open({ root, directory, keepRuns, logger, writeRoots, }: {
32
36
  root: string;
33
37
  /** Defaults to the platform's state directory. */
34
38
  directory?: string;
@@ -40,8 +44,15 @@ export declare class FileRecoveryStore implements RecoveryStore {
40
44
  keepRuns?: number;
41
45
  /** Where it reports a failure it absorbs. Defaults to standard error. */
42
46
  logger?: ILogger;
47
+ /**
48
+ * The folders besides `root` that its runs may write in, such as the other `write` roots,
49
+ * so no worktree they lie in is removed while a run writes there.
50
+ */
51
+ writeRoots?: readonly string[];
43
52
  }): Promise<FileRecoveryStore>;
44
- startRun(): Promise<RunJournal>;
53
+ startRun({ session }?: {
54
+ session?: Session;
55
+ }): Promise<RunJournal>;
45
56
  listRuns(): Promise<{
46
57
  head?: string;
47
58
  runs: RunRecord[];
@@ -69,6 +80,14 @@ export declare class FileRecoveryStore implements RecoveryStore {
69
80
  * most the step in progress, and that step is safe to take again.
70
81
  */
71
82
  private takeSteps;
83
+ /**
84
+ * Claims each file the runs on a route changed outside the folders this store claims: a run
85
+ * may have written in a folder this store was not opened with, such as a second root a history
86
+ * reopened by its first root alone does not know. A worktree holding one is not removed while
87
+ * the move writes there.
88
+ */
89
+ private claimFiles;
90
+ private stepAlong;
72
91
  /** Takes one run's changes back to its parent, or forward again, file by file. */
73
92
  private step;
74
93
  /**
@@ -1,13 +1,14 @@
1
1
  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
- import { dirname, isAbsolute, join } from 'node:path';
4
+ import { dirname, isAbsolute, join, sep } from 'node:path';
5
+ import { FolderClaims } from "./folderClaims.js";
5
6
  import { classifyHostFailure, describeFailure, treatErrors, } from '#src/shared/domain/providerFailure';
6
7
  import { Logger } from '#src/shared/infrastructure/logger';
7
8
  import { fileChanges, sameState } from "../domain/fileChanges.js";
8
9
  import { historyStart, NothingToMoveError, WorkspaceBusyError, WorkspaceMovedError, } from "../domain/recoveryStore.js";
9
10
  import { chainEntries, nextPruneStep } from "../domain/retention.js";
10
- import { HistoryExpiredError, redoTarget, route, UnknownRunError } from "../domain/runTree.js";
11
+ import { HistoryExpiredError, redoTarget, route, UnknownRunError, } from "../domain/runTree.js";
11
12
  import { currentState, hasRealParents, removeEmptyFolders, removeFile, removeTemporary, temporaryPath, writeFileState, } from "./workspaceFiles.js";
12
13
  const appName = 'mikode-harness';
13
14
  // The store holds source code the agent touched: only its owner may read it.
@@ -149,18 +150,24 @@ async function storeContent(contentDirectory, content) {
149
150
  */
150
151
  export class FileRecoveryStore {
151
152
  workspaceId;
153
+ writeFolders;
152
154
  root;
153
155
  directory;
154
156
  keepRuns;
155
157
  logger;
156
- constructor(root, directory, keepRuns, logger, workspaceId) {
158
+ claims;
159
+ // What releases the claim of each run or move holding the workspace.
160
+ releases = new Map();
161
+ constructor(root, directory, keepRuns, logger, workspaceId, { claims, folders }) {
157
162
  this.root = root;
158
163
  this.directory = directory;
159
164
  this.keepRuns = keepRuns;
160
165
  this.logger = logger;
161
166
  this.workspaceId = workspaceId;
167
+ this.claims = claims;
168
+ this.writeFolders = folders;
162
169
  }
163
- static async open({ root, directory = defaultStateDirectory(), keepRuns = defaultKeepRuns, logger = new Logger(), }) {
170
+ static async open({ root, directory = defaultStateDirectory(), keepRuns = defaultKeepRuns, logger = new Logger(), writeRoots = [], }) {
164
171
  if (!Number.isInteger(keepRuns) || keepRuns < 3) {
165
172
  throw new RangeError(`keepRuns must be an integer of at least 3; got ${String(keepRuns)}`);
166
173
  }
@@ -170,10 +177,17 @@ export class FileRecoveryStore {
170
177
  await makeDirectory(join(workspace, 'runs'));
171
178
  await makeDirectory(join(workspace, 'content'));
172
179
  await writeDurably(join(workspace, 'workspace.json'), JSON.stringify({ root: realRoot }));
180
+ const folders = [
181
+ realRoot,
182
+ ...(await Promise.all(writeRoots.map(path => realpath(path).catch(() => path)))),
183
+ ];
173
184
  // 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));
185
+ return new FileRecoveryStore(realRoot, workspace, keepRuns, logger, await realpath(workspace), {
186
+ claims: new FolderClaims(await realpath(directory)),
187
+ folders,
188
+ });
175
189
  }
176
- async startRun() {
190
+ async startRun({ session } = {}) {
177
191
  const runId = newRunId();
178
192
  try {
179
193
  // Inside, because taking the lock can fail after publishing it. Unlocking removes only a
@@ -196,6 +210,7 @@ export class FileRecoveryStore {
196
210
  startedAt: new Date().toISOString(),
197
211
  status: 'running',
198
212
  pid: process.pid,
213
+ ...(session ? { session } : {}),
199
214
  };
200
215
  await writeDurably(join(runDirectory, 'run.json'), JSON.stringify(record));
201
216
  // After the record, so the head never names a run that does not exist.
@@ -399,6 +414,34 @@ export class FileRecoveryStore {
399
414
  */
400
415
  async takeSteps(head, runs, pending) {
401
416
  const { undo, redo } = route(runs, head.runId, pending.to);
417
+ const release = await this.claimFiles([...undo, ...redo]);
418
+ try {
419
+ return await this.stepAlong(head, runs, pending, { undo, redo });
420
+ }
421
+ finally {
422
+ await release();
423
+ }
424
+ }
425
+ /**
426
+ * Claims each file the runs on a route changed outside the folders this store claims: a run
427
+ * may have written in a folder this store was not opened with, such as a second root a history
428
+ * reopened by its first root alone does not know. A worktree holding one is not removed while
429
+ * the move writes there.
430
+ */
431
+ async claimFiles(runIds) {
432
+ const outside = new Set();
433
+ for (const runId of runIds) {
434
+ const { entries } = await this.readRun(runId);
435
+ for (const { path } of fileChanges(entries, true)) {
436
+ if (!this.writeFolders.some(folder => within(path, folder)))
437
+ outside.add(path);
438
+ }
439
+ }
440
+ if (outside.size === 0)
441
+ return () => Promise.resolve();
442
+ return this.claims.claimWriting(`files-${newRunId()}`, [...outside]);
443
+ }
444
+ async stepAlong(head, runs, pending, { undo, redo }) {
402
445
  const parents = new Map(runs.map(run => [run.runId, run.parentRunId]));
403
446
  let reached = head.runId;
404
447
  const save = () => this.writeHead({ ...(reached ? { runId: reached } : {}), revision: head.revision, pending });
@@ -931,6 +974,9 @@ export class FileRecoveryStore {
931
974
  // Written whole before it appears, so a lock that exists always names its holder.
932
975
  await writeDurably(path, JSON.stringify({ runId, pid: process.pid }), { exclusive: true });
933
976
  heldHere.add(runId);
977
+ // Under the lock: a removal of a worktree this run may write in keeps it out. The
978
+ // caller unlocks when this throws.
979
+ this.releases.set(runId, await this.claims.claimWriting(runId, this.writeFolders));
934
980
  return;
935
981
  }
936
982
  catch (error) {
@@ -958,6 +1004,9 @@ export class FileRecoveryStore {
958
1004
  async unlock(runId) {
959
1005
  // First: the run has ended even if removing its lock fails, so that lock is stale.
960
1006
  heldHere.delete(runId);
1007
+ const release = this.releases.get(runId);
1008
+ this.releases.delete(runId);
1009
+ await release?.();
961
1010
  const path = join(this.directory, 'lock');
962
1011
  // Only its own lock: one cleared as stale and taken by another run is not this run's.
963
1012
  if ((await readLock(path))?.runId === runId)
@@ -1049,6 +1098,9 @@ async function readLock(path) {
1049
1098
  }
1050
1099
  return { runId: 'unknown' };
1051
1100
  }
1101
+ function within(path, folder) {
1102
+ return path === folder || path.startsWith(`${folder}${sep}`);
1103
+ }
1052
1104
  function contentPath(contentDirectory, hash) {
1053
1105
  // Two levels, so no folder holds every file a workspace ever stored.
1054
1106
  return join(contentDirectory, hash.slice(0, 2), hash);