@agentxm/workspace-operations 0.28.12 → 0.28.13

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 (35) hide show
  1. package/dist/src/index.d.ts +11 -10
  2. package/dist/src/index.js +16 -12
  3. package/dist/src/operations/scan-plan-readiness.d.ts +1 -1
  4. package/dist/src/operations/scan-plan-readiness.js +1 -1
  5. package/dist/src/plan/apply-plan.js +8 -8
  6. package/dist/src/plan/execution-candidate.d.ts +7 -1
  7. package/dist/src/plan/execution-candidate.js +9 -2
  8. package/dist/src/plan/interruption-resolution.d.ts +50 -0
  9. package/dist/src/plan/interruption-resolution.js +158 -0
  10. package/dist/src/plan/operation-journal.d.ts +9 -9
  11. package/dist/src/plan/operation-journal.js +5 -20
  12. package/dist/src/plan/operation-resolution.d.ts +1 -1
  13. package/dist/src/plan/plan-execution-fixtures.d.ts +2 -2
  14. package/dist/src/plan/plan-execution-fixtures.js +2 -2
  15. package/dist/src/plan/plan-execution.d.ts +48 -0
  16. package/dist/src/plan/plan-execution.js +40 -0
  17. package/dist/src/plan/plan.d.ts +9 -19
  18. package/dist/src/plan/resolve-plan-interaction.d.ts +1 -1
  19. package/dist/src/plan/resolve-plan-interaction.js +1 -1
  20. package/dist/src/plan/resolve-plan.d.ts +38 -23
  21. package/dist/src/plan/resolve-plan.js +114 -96
  22. package/dist/src/plan/step-failure-conversions.d.ts +2 -1
  23. package/dist/src/testing.d.ts +22 -1
  24. package/dist/src/testing.js +23 -1
  25. package/package.json +12 -16
  26. package/dist/src/live.d.ts +0 -13
  27. package/dist/src/live.js +0 -12
  28. package/dist/src/operations/load-workspace.d.ts +0 -44
  29. package/dist/src/operations/load-workspace.js +0 -80
  30. package/dist/src/operations/memory-transition-lock.d.ts +0 -20
  31. package/dist/src/operations/memory-transition-lock.js +0 -57
  32. package/dist/src/operations/transaction.d.ts +0 -60
  33. package/dist/src/operations/transaction.js +0 -358
  34. package/dist/src/operations/transition-lock.d.ts +0 -72
  35. package/dist/src/operations/transition-lock.js +0 -298
@@ -1,298 +0,0 @@
1
- // @effect-diagnostics nodeBuiltinImport:off — proper-lockfile requires a callback-style fs; the adapter is confined to the lock path
2
- /**
3
- * Workspace transition lock.
4
- *
5
- * One cross-process lock serializes mutation-class operations on a workspace.
6
- * A plan-family apply acquires it after confirmation — planning, network
7
- * acquisition, preview, and the confirmation decision stay lock-free — then
8
- * revalidates its candidate and applies while holding it; the workspace
9
- * transaction reuses the invocation's hold, or acquires its own when no
10
- * plan-family hold exists. A contending invocation waits with a visible
11
- * reason up to a bound, then terminates blocked with a machine-readable
12
- * reference to the holder.
13
- *
14
- * Each acquisition records a distinct owner token in the holder metadata.
15
- * Ownership is proven only by an exact token match — never inferred from a
16
- * pid or from missing or unreadable metadata — and a holder write that fails
17
- * releases the acquisition instead of holding anonymously.
18
- *
19
- * The lock file lives at `.axm/tmp/workspace-transition.lock`; its path is
20
- * load-bearing for operational tooling and must not move casually.
21
- *
22
- * Acquisition is atomic with respect to interruption: the narrow region from
23
- * the library granting the hold through finalizer registration is masked, so
24
- * an interrupt can never strand a granted lock, while contention waits stay
25
- * interruptible. Only the lock-is-held error class is absorbed by the bounded
26
- * wait; any other acquisition error surfaces immediately as a typed failure.
27
- *
28
- * @experimental This API is unstable and may change without notice.
29
- */
30
- import * as nodeFs from "node:fs";
31
- import { randomBytes } from "node:crypto";
32
- import * as Deferred from "effect/Deferred";
33
- import * as Duration from "effect/Duration";
34
- import * as Effect from "effect/Effect";
35
- import * as FileSystem from "effect/FileSystem";
36
- import * as Option from "effect/Option";
37
- import * as Path from "effect/Path";
38
- import * as lockfile from "proper-lockfile";
39
- import { TransitionLockError, WorkspaceDirectoryError, WorkspaceTransitionCompromised, } from "@agentxm/workspace-state";
40
- export const TRANSITION_LOCK_FILENAME = "workspace-transition.lock";
41
- // Staleness must tolerate a saturated event loop: a heavy apply starves the
42
- // mtime-update timer, and a slack smaller than the starvation lets a LIVE
43
- // holder's lock self-declare compromised — after which release refuses and the
44
- // dir is left for the next invocation to wait out. 25 s of slack covers the
45
- // longest observed starvation with room; a crashed holder delays a contender
46
- // at most this long, still inside the 60 s wait bound.
47
- const LOCK_STALE_MILLIS = 30_000;
48
- const LOCK_UPDATE_MILLIS = 5_000;
49
- const WAIT_INTERVAL = Duration.millis(250);
50
- /** How long a contending invocation serializes behind the holder. */
51
- export const TRANSITION_WAIT_BOUND_MILLIS = 60_000;
52
- /**
53
- * Held transition locks in this process, keyed by resolved workspace
54
- * directory. Owned exclusively by `acquireWorkspaceTransitionLock`, whose
55
- * scope finalizer removes the entry, so an entry's lifetime is exactly the
56
- * lock's; the workspace transaction consults it to avoid deadlocking on the
57
- * invocation's own hold and to observe compromise of that hold.
58
- */
59
- // eslint-disable-next-line no-restricted-syntax -- Owned exclusively by acquireWorkspaceTransitionLock: an entry's lifetime is exactly the lock's, and the acquisition's scope finalizer removes it.
60
- const heldTransitions = new Map();
61
- export const isWorkspaceTransitionHeldByThisInvocation = (workspaceDir) => heldTransitions.has(workspaceDir);
62
- /** The live hold for a workspace this process acquired, when one exists. */
63
- export const heldWorkspaceTransition = (workspaceDir) => heldTransitions.get(workspaceDir);
64
- const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string"
65
- ? cause.code
66
- : undefined;
67
- export const transitionLockPath = (path, workspaceDir) => path.join(workspaceDir, "tmp", TRANSITION_LOCK_FILENAME);
68
- /**
69
- * The filesystem handed to `proper-lockfile`. Identical to the platform fs
70
- * except that removing a transition-lock directory first clears the tool's
71
- * own holder metadata inside it. The library reclaims a stale lock and
72
- * releases a held one with a non-recursive `rmdir`; without this, a lock
73
- * directory left by a crashed process — which always still contains
74
- * `holder.json` — could never be reclaimed, and every later mutation would
75
- * wait out its bound and report a false contention. Removal stays
76
- * non-recursive past that one owned name, so foreign content keeps blocking
77
- * removal the way it always did.
78
- */
79
- const lockDirectoryFs = {
80
- ...nodeFs,
81
- rmdir: (target, callback) => {
82
- if (typeof target === "string" && target.endsWith(TRANSITION_LOCK_FILENAME)) {
83
- nodeFs.unlink(`${target}/holder.json`, () => {
84
- nodeFs.rmdir(target, callback);
85
- });
86
- return;
87
- }
88
- nodeFs.rmdir(target, callback);
89
- },
90
- rmdirSync: (target) => {
91
- if (typeof target === "string" && target.endsWith(TRANSITION_LOCK_FILENAME)) {
92
- try {
93
- nodeFs.unlinkSync(`${target}/holder.json`);
94
- }
95
- catch {
96
- // Absent metadata blocks nothing; foreign content still blocks rmdir.
97
- }
98
- }
99
- nodeFs.rmdirSync(target);
100
- },
101
- };
102
- /**
103
- * Acquisition-error classes the bounded contention wait absorbs: the lock is
104
- * held (`ELOCKED`), or the attempt raced the holder's own acquire, release,
105
- * or staleness sweep (`ENOENT`/`EEXIST` from proper-lockfile's internal
106
- * stat-remove-retry). Every other class — permissions, foreign state
107
- * squatting the path — is not resolved by waiting and surfaces immediately.
108
- */
109
- const isContentionErrorCode = (code) => code === "ELOCKED" || code === "ENOENT" || code === "EEXIST";
110
- const readHolder = (fs, path, lockPath) => fs.readFileString(path.join(lockPath, "holder.json")).pipe(Effect.map((content) => {
111
- const parsed = JSON.parse(content);
112
- if (typeof parsed === "object" &&
113
- parsed !== null &&
114
- "command" in parsed &&
115
- typeof parsed.command === "string" &&
116
- "pid" in parsed &&
117
- typeof parsed.pid === "number") {
118
- return Option.some({
119
- command: parsed.command,
120
- pid: parsed.pid,
121
- ...("candidateId" in parsed && typeof parsed.candidateId === "string"
122
- ? { candidateId: parsed.candidateId }
123
- : {}),
124
- ...("token" in parsed && typeof parsed.token === "string" ? { token: parsed.token } : {}),
125
- });
126
- }
127
- return Option.none();
128
- }), Effect.catch(() => Effect.succeed(Option.none())));
129
- const writeHolder = (fs, path, lockPath, holder) => fs
130
- .writeFileString(path.join(lockPath, "holder.json"), JSON.stringify(holder))
131
- .pipe(Effect.mapError((cause) => new TransitionLockError({ path: lockPath, step: "record-holder", cause })));
132
- /**
133
- * Acquire the workspace transition lock, waiting up to the bound while
134
- * another invocation holds it. Resolves `None` when acquired (the release is
135
- * a scope finalizer) and `Some(contention)` when the bound elapsed.
136
- */
137
- export const acquireWorkspaceTransitionLock = (args) => Effect.gen(function* () {
138
- const fs = yield* FileSystem.FileSystem;
139
- const path = yield* Path.Path;
140
- const workspaceDir = path.resolve(args.workspaceDir);
141
- const scratchDir = path.join(workspaceDir, "tmp");
142
- const lockPath = path.join(scratchDir, TRANSITION_LOCK_FILENAME);
143
- const waitBound = args.waitBoundMillis ?? TRANSITION_WAIT_BOUND_MILLIS;
144
- const workspaceExisted = yield* fs
145
- .exists(workspaceDir)
146
- .pipe(Effect.mapError((cause) => new WorkspaceDirectoryError({ path: workspaceDir, step: "inspect", cause })));
147
- const removeEmptyScratch = fs.readDirectory(scratchDir).pipe(Effect.flatMap((entries) => entries.length === 0
148
- ? fs.remove(scratchDir, { recursive: true, force: false })
149
- : Effect.void), Effect.ignore);
150
- const removeNewEmptyWorkspace = workspaceExisted
151
- ? Effect.void
152
- : fs.readDirectory(workspaceDir).pipe(Effect.flatMap((entries) => entries.length === 0
153
- ? fs.remove(workspaceDir, { recursive: true, force: false })
154
- : Effect.void), Effect.ignore);
155
- const compromisedSignal = Deferred.makeUnsafe();
156
- let waitedMillis = 0;
157
- let reportedWaiting = false;
158
- while (true) {
159
- // The previous holder removes an empty scratch directory when it
160
- // releases. Recreate it before every attempt so that a waiter can
161
- // acquire after that cleanup instead of mistaking ENOENT for a hold.
162
- yield* fs
163
- .makeDirectory(scratchDir, { recursive: true })
164
- .pipe(Effect.mapError((cause) => new TransitionLockError({ path: scratchDir, step: "create-scratch", cause })));
165
- // One attempt is atomic with respect to interruption: from the library
166
- // granting the hold through finalizer registration there is no
167
- // interruptible gap, so an interrupt requested mid-acquisition defers
168
- // until the release finalizer exists and then releases through it. The
169
- // mask stays narrow — a single no-retry grant plus local metadata —
170
- // and the contention wait below remains interruptible.
171
- const attempt = yield* Effect.uninterruptible(Effect.gen(function* () {
172
- const granted = yield* Effect.tryPromise({
173
- try: () => lockfile.lock(workspaceDir, {
174
- lockfilePath: lockPath,
175
- realpath: false,
176
- retries: 0,
177
- fs: lockDirectoryFs,
178
- stale: args.timingMillis?.stale ?? LOCK_STALE_MILLIS,
179
- update: args.timingMillis?.update ?? LOCK_UPDATE_MILLIS,
180
- // The default handler throws inside a timer: an uncatchable
181
- // crash that sprays raw frames on stderr. Complete the typed
182
- // compromise signal instead — the workspace transaction races
183
- // against it and never continues mutating after ownership is
184
- // lost.
185
- onCompromised: (cause) => {
186
- Deferred.doneUnsafe(compromisedSignal, Effect.fail(new WorkspaceTransitionCompromised({ workspaceDir, lockPath, cause })));
187
- },
188
- }),
189
- catch: (cause) => ({ cause, code: errorCode(cause) }),
190
- }).pipe(Effect.result);
191
- if (granted._tag === "Failure") {
192
- if (isContentionErrorCode(granted.failure.code)) {
193
- return { _tag: "held" };
194
- }
195
- return {
196
- _tag: "failed",
197
- error: new TransitionLockError({
198
- path: lockPath,
199
- step: "acquire",
200
- cause: granted.failure.cause,
201
- }),
202
- };
203
- }
204
- const release = granted.success;
205
- const token = randomBytes(16).toString("hex");
206
- const holderStamped = yield* Effect.gen(function* () {
207
- const lockInfo = yield* fs
208
- .stat(lockPath)
209
- .pipe(Effect.mapError((cause) => new TransitionLockError({ path: lockPath, step: "inspect-timestamp", cause })));
210
- const lockMtime = yield* Option.match(lockInfo.mtime, {
211
- onNone: () => Effect.fail(new TransitionLockError({ path: lockPath, step: "missing-timestamp" })),
212
- onSome: Effect.succeed,
213
- });
214
- yield* writeHolder(fs, path, lockPath, {
215
- ...args.holder,
216
- token,
217
- });
218
- // proper-lockfile proves ownership by comparing the directory's
219
- // mtime with the timestamp it recorded at acquisition. Writing
220
- // holder.json changes that directory mtime, so restore the exact
221
- // acquired value before the first refresh observes it.
222
- yield* fs
223
- .utimes(lockPath, lockMtime, lockMtime)
224
- .pipe(Effect.mapError((cause) => new TransitionLockError({ path: lockPath, step: "preserve-timestamp", cause })));
225
- }).pipe(Effect.result);
226
- if (holderStamped._tag === "Failure") {
227
- // The holder metadata is the only ownership evidence this
228
- // acquisition will ever have; holding without it would be
229
- // indistinguishable from a successor's half-written acquisition.
230
- // Release the lock and fail rather than hold anonymously.
231
- yield* Effect.tryPromise({
232
- try: () => release(),
233
- catch: (cause) => new TransitionLockError({ path: lockPath, step: "release", cause }),
234
- }).pipe(Effect.ignore);
235
- return { _tag: "failed", error: holderStamped.failure };
236
- }
237
- heldTransitions.set(workspaceDir, {
238
- compromised: Deferred.await(compromisedSignal),
239
- isCompromised: () => Deferred.isDoneUnsafe(compromisedSignal),
240
- });
241
- yield* Effect.addFinalizer(() => Effect.gen(function* () {
242
- heldTransitions.delete(workspaceDir);
243
- // Removal requires an exact owner-token match, proven before
244
- // release() — which would remove the directory unconditionally.
245
- // Absent or unreadable holder metadata is indistinguishable from
246
- // a successor that reclaimed the stale hold but has not yet
247
- // written its holder file, and is never license to remove; the
248
- // unowned in-process bookkeeping self-resolves as compromised on
249
- // its next update tick without touching the directory.
250
- const residualHolder = yield* readHolder(fs, path, lockPath);
251
- const ownsResidual = Option.match(residualHolder, {
252
- onNone: () => false,
253
- onSome: (value) => value.token === token,
254
- });
255
- if (ownsResidual) {
256
- yield* Effect.tryPromise({
257
- try: () => release(),
258
- catch: (cause) => new TransitionLockError({ path: lockPath, step: "release", cause }),
259
- }).pipe(Effect.ignore);
260
- // A compromised hold makes release() refuse; the directory is
261
- // still ours by exact token match — remove it directly.
262
- yield* fs.remove(lockPath, { recursive: true, force: true }).pipe(Effect.ignore);
263
- }
264
- yield* removeEmptyScratch;
265
- yield* removeNewEmptyWorkspace;
266
- }));
267
- return { _tag: "acquired" };
268
- }));
269
- if (attempt._tag === "acquired") {
270
- return Option.none();
271
- }
272
- if (attempt._tag === "failed") {
273
- yield* removeEmptyScratch;
274
- yield* removeNewEmptyWorkspace;
275
- return yield* attempt.error;
276
- }
277
- // The lock is held: serialize behind the holder with a visible reason,
278
- // up to the bound — the loser serializes or times out into a
279
- // categorized blocked, never an internal crash.
280
- const holder = yield* readHolder(fs, path, lockPath);
281
- if (!reportedWaiting && args.onWaiting !== undefined) {
282
- reportedWaiting = true;
283
- yield* args.onWaiting(holder);
284
- }
285
- if (waitedMillis >= waitBound) {
286
- yield* removeEmptyScratch;
287
- yield* removeNewEmptyWorkspace;
288
- return Option.some({ holder, waitedMillis });
289
- }
290
- yield* Effect.sleep(WAIT_INTERVAL);
291
- waitedMillis += Duration.toMillis(WAIT_INTERVAL);
292
- }
293
- });
294
- export const liveWorkspaceTransitionLock = {
295
- acquire: acquireWorkspaceTransitionLock,
296
- held: heldWorkspaceTransition,
297
- };
298
- //# sourceMappingURL=transition-lock.js.map