@crouton-kit/crouter 0.3.39 → 0.3.40

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.
@@ -252,3 +252,111 @@ test('§4 retry: a second consecutive retryable failure throws (bounded to one r
252
252
  // Both the first and the retry generation are self-cleaned — no orphan survives a double failure.
253
253
  assert.equal(destroyed.length, 2, 'both failed generations are self-cleaned');
254
254
  });
255
+ // Contract: when the volume is held by a sandbox outside the tag-scoped destroy set — an
256
+ // untagged holder the sweep cannot reach — the guard must detect it immediately and throw an
257
+ // actionable error, never spin `pollVolumeDetached` to its VOLUME_DETACH_TIMEOUT_MS deadline.
258
+ /** A fake whose tag-scoped `listMachines` returns `[]` (nothing tagged for this home) while
259
+ * `getVolumeAttachment` reports the volume held by an untagged sandbox outside the destroy set.
260
+ * Counts `getVolumeAttachment` calls so the test can assert the guard short-circuits on the
261
+ * first read rather than looping into `pollVolumeDetached`. */
262
+ function makeUntaggedHolderProvider(holderName, destroyed, attachmentCalls) {
263
+ const fake = {
264
+ providerName: 'blaxel',
265
+ imageRef: 'crouter-hearth-home-0-3-35',
266
+ templateVersion: '0.3.35',
267
+ volumeSizeGb: 4,
268
+ async listMachines() {
269
+ return []; // tag-scoped list finds nothing — the holder carries no externalId tag
270
+ },
271
+ async destroy(machineId) {
272
+ destroyed.push(machineId);
273
+ return { kind: 'destroyed' };
274
+ },
275
+ async getVolumeAttachment() {
276
+ attachmentCalls.count += 1;
277
+ return `sandbox:${holderName}`;
278
+ },
279
+ async createMachine(input) {
280
+ return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
281
+ },
282
+ async exec() {
283
+ return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
284
+ },
285
+ async waitForPort() { },
286
+ async publishHttpPort(machineId, input) {
287
+ return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
288
+ },
289
+ async getMachine(machineId) {
290
+ return { providerName: 'blaxel', machineId, state: 'running' };
291
+ },
292
+ };
293
+ return fake;
294
+ }
295
+ test('loud guard: an untagged legacy sandbox holding the volume fails FAST with an actionable error, never the 120s poll', async () => {
296
+ const destroyed = [];
297
+ const attachmentCalls = { count: 0 };
298
+ const backend = new BlaxelHomeBackend(makeConfig(), makeUntaggedHolderProvider('hearth-prod-1', destroyed, attachmentCalls));
299
+ await assert.rejects(() => backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36'), (err) => {
300
+ assert.ok(err instanceof Error, 'must throw an Error');
301
+ assert.match(err.message, /hearth-prod-1/, 'error must name the untagged holder sandbox');
302
+ assert.match(err.message, /migrate/i, 'error must instruct the operator to migrate the home');
303
+ return true;
304
+ });
305
+ // The tag-scoped sweep destroyed nothing (empty family) and the guard fired on the FIRST
306
+ // attachment read — it never looped into `pollVolumeDetached`'s wait/retry cycle.
307
+ assert.equal(destroyed.length, 0, 'nothing was destroyed — the holder was never tag-scoped');
308
+ assert.equal(attachmentCalls.count, 1, 'getVolumeAttachment must be read exactly once before failing fast, never polled to deadline');
309
+ });
310
+ /** A fake modeling the normal path: the row-pointed generation is tag-scoped and destroyed, and
311
+ * the volume reports still attached to THAT generation on the guard's read, then detached (null)
312
+ * on the next read inside `pollVolumeDetached` — a real destroy+detach seconds apart. Confirms
313
+ * the guard does NOT fire for a holder within the destroy set, and the poll still runs. */
314
+ function makeDestroyedHolderProvider(destroyed, attachmentCalls) {
315
+ const doomedName = `${VOLUME_ID}-gold`;
316
+ const fake = {
317
+ providerName: 'blaxel',
318
+ imageRef: 'crouter-hearth-home-0-3-35',
319
+ templateVersion: '0.3.35',
320
+ volumeSizeGb: 4,
321
+ async listMachines(filter) {
322
+ if (filter?.externalId !== VOLUME_ID)
323
+ return [];
324
+ return [{ providerName: 'blaxel', machineId: doomedName, state: 'running' }];
325
+ },
326
+ async destroy(machineId) {
327
+ destroyed.push(machineId);
328
+ return { kind: 'destroyed' };
329
+ },
330
+ async getVolumeAttachment() {
331
+ attachmentCalls.count += 1;
332
+ // First read (the guard, right after destroy): still shows the just-destroyed generation
333
+ // as the holder — a real detach is not instantaneous. Every subsequent read (inside
334
+ // `pollVolumeDetached`): the platform has confirmed detach.
335
+ return attachmentCalls.count === 1 ? `sandbox:${doomedName}` : null;
336
+ },
337
+ async createMachine(input) {
338
+ return { providerName: 'blaxel', machineId: input.name ?? VOLUME_ID, state: 'running' };
339
+ },
340
+ async exec() {
341
+ return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
342
+ },
343
+ async waitForPort() { },
344
+ async publishHttpPort(machineId, input) {
345
+ return { routeId: input.routeName ?? `${machineId}-web`, machineId, targetPort: input.targetPort, url: `https://${machineId}.example` };
346
+ },
347
+ async getMachine(machineId) {
348
+ return { providerName: 'blaxel', machineId, state: 'running' };
349
+ },
350
+ };
351
+ return fake;
352
+ }
353
+ test('loud guard: does NOT fire when the holder is a sandbox the tag-scoped sweep just destroyed — normal recreate still completes', async () => {
354
+ const destroyed = [];
355
+ const attachmentCalls = { count: 0 };
356
+ const backend = new BlaxelHomeBackend(makeConfig(), makeDestroyedHolderProvider(destroyed, attachmentCalls));
357
+ const refresh = await backend.recreateOnImage(makeDescriptor(), 'crouter-hearth-home-0-3-36', '0.3.36');
358
+ assert.ok(destroyed.includes(`${VOLUME_ID}-gold`), 'the tag-scoped generation must still be destroyed');
359
+ assert.ok(attachmentCalls.count >= 2, 'the guard read plus at least one poll read must both occur');
360
+ assert.match(refresh.providerSandboxId, new RegExp(`^${VOLUME_ID}-g[0-9a-z]+$`));
361
+ assert.equal(refresh.templateVersion, '0.3.36');
362
+ });
@@ -8,11 +8,17 @@ export declare class BlaxelHomeBackend {
8
8
  wake(homeInput: HomeProviderDescriptor): Promise<HomeProviderRefresh>;
9
9
  /** Destroy EVERY generation of this home via a deterministic name-family sweep — never a
10
10
  * point-in-time `attachedTo` read, which Blaxel's autonomous idle→STANDBY detach can race
11
- * ahead of, leaving a live orphan the read cannot see (orphan-cleanup-fix.md §1/§3.3). Then
12
- * poll until the platform reports the volume detached. Safe because at sweep time the
13
- * replacement does not exist yet (its name is minted after) and any live family member is by
14
- * construction either the generation being replaced or an uncommitted leftover the home's
15
- * durable state is the volume, never a sandbox; one wake per home runs at a time. */
11
+ * ahead of, leaving a live orphan the read cannot see (orphan-cleanup-fix.md §1/§3.3). The
12
+ * destroy set is intentionally TAG-SCOPED (externalId = volumeId) and is never widened by a
13
+ * name-family fallback (MAJOR-1) so after destroying it, read the volume's current holder
14
+ * ONCE: if the volume is still attached to a sandbox that is NOT one we just destroyed, that
15
+ * holder is outside the strict sweep's reach (an untagged holder, or a foreign claim) and will
16
+ * never detach on its own, so fail fast with an actionable error instead of silently spinning
17
+ * the detach poll to its timeout. Otherwise poll
18
+ * until the platform reports the volume detached. Safe because at sweep time the replacement
19
+ * does not exist yet (its name is minted after) and any live family member is by construction
20
+ * either the generation being replaced or an uncommitted leftover — the home's durable state
21
+ * is the volume, never a sandbox; one wake per home runs at a time. */
16
22
  private sweepAndReleaseVolume;
17
23
  /** Poll the volume until the platform reports it detached — the create-precondition gate
18
24
  * (recreate-race-fix.md §3.2). Reads only; never destroys. Emits `hearth.recreate.volume_wait`
@@ -189,11 +189,17 @@ export class BlaxelHomeBackend {
189
189
  }
190
190
  /** Destroy EVERY generation of this home via a deterministic name-family sweep — never a
191
191
  * point-in-time `attachedTo` read, which Blaxel's autonomous idle→STANDBY detach can race
192
- * ahead of, leaving a live orphan the read cannot see (orphan-cleanup-fix.md §1/§3.3). Then
193
- * poll until the platform reports the volume detached. Safe because at sweep time the
194
- * replacement does not exist yet (its name is minted after) and any live family member is by
195
- * construction either the generation being replaced or an uncommitted leftover the home's
196
- * durable state is the volume, never a sandbox; one wake per home runs at a time. */
192
+ * ahead of, leaving a live orphan the read cannot see (orphan-cleanup-fix.md §1/§3.3). The
193
+ * destroy set is intentionally TAG-SCOPED (externalId = volumeId) and is never widened by a
194
+ * name-family fallback (MAJOR-1) so after destroying it, read the volume's current holder
195
+ * ONCE: if the volume is still attached to a sandbox that is NOT one we just destroyed, that
196
+ * holder is outside the strict sweep's reach (an untagged holder, or a foreign claim) and will
197
+ * never detach on its own, so fail fast with an actionable error instead of silently spinning
198
+ * the detach poll to its timeout. Otherwise poll
199
+ * until the platform reports the volume detached. Safe because at sweep time the replacement
200
+ * does not exist yet (its name is minted after) and any live family member is by construction
201
+ * either the generation being replaced or an uncommitted leftover — the home's durable state
202
+ * is the volume, never a sandbox; one wake per home runs at a time. */
197
203
  async sweepAndReleaseVolume(volumeId, homeId) {
198
204
  // Scope the list to sandboxes Hearth tagged for THIS home (externalId = volumeId), then keep
199
205
  // the name-family matcher as an AND-intersection on top (orphan-cleanup-fix MAJOR-1). The
@@ -207,6 +213,19 @@ export class BlaxelHomeBackend {
207
213
  await this.provider.destroy(name); // sequential; destroy tolerates not_found
208
214
  if (doomed.length > 0)
209
215
  emitMarker('hearth.recreate.sweep', { home_id: homeId, volume_id: volumeId, destroyed: doomed });
216
+ // Loud guard: the sweep above is strictly tag-scoped, so it can only ever destroy a sandbox
217
+ // that was created with `externalId = volumeId`. Read the volume's current holder ONCE,
218
+ // before the detach poll, to catch the case that sweep is structurally unable to fix — a
219
+ // holder that is NOT one of the sandboxes just destroyed (`doomed`) will never detach, and
220
+ // the poll below would spin the full VOLUME_DETACH_TIMEOUT_MS only to throw the same thing
221
+ // less clearly. Fail fast instead, naming the holder so an operator can act on it directly.
222
+ const holder = await this.provider.getVolumeAttachment(volumeId);
223
+ if (holder !== null) {
224
+ const holderName = holder.startsWith('sandbox:') ? holder.slice('sandbox:'.length) : holder;
225
+ if (!doomed.includes(holderName)) {
226
+ throw general(`legacy untagged sandbox ${holderName} holds volume ${volumeId} for home ${homeId} — the tag-scoped sweep cannot reclaim it (no externalId tag); migrate this home (delete the sandbox so a plain wake recreates it tagged) before rolling`, { homeId, volumeId, holder: holderName });
227
+ }
228
+ }
210
229
  await this.pollVolumeDetached(volumeId, homeId);
211
230
  }
212
231
  /** Poll the volume until the platform reports it detached — the create-precondition gate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.39",
3
+ "version": "0.3.40",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",