@aviaratech/ai-delivery 0.3.5 → 0.3.7

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 +65 -1
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/agent.js.map +1 -1
  5. package/dist/cli.js +35 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/dispatch.js +21 -1
  8. package/dist/dispatch.js.map +1 -1
  9. package/dist/github/commandIdentityPolicy.js +1 -0
  10. package/dist/github/commandIdentityPolicy.js.map +1 -1
  11. package/dist/issue.d.ts +1 -0
  12. package/dist/issue.js +22 -10
  13. package/dist/issue.js.map +1 -1
  14. package/dist/lifecycle.test.js +470 -4
  15. package/dist/lifecycle.test.js.map +1 -1
  16. package/dist/mcp/index.js +1 -1
  17. package/dist/mcp/tools.d.ts +31 -0
  18. package/dist/mcp/tools.js +27 -0
  19. package/dist/mcp/tools.js.map +1 -1
  20. package/dist/pr.d.ts +1 -1
  21. package/dist/services/worktreeRegistry.d.ts +12 -0
  22. package/dist/services/worktreeRegistry.js +146 -4
  23. package/dist/services/worktreeRegistry.js.map +1 -1
  24. package/dist/verification.d.ts +34 -0
  25. package/dist/verification.js +488 -64
  26. package/dist/verification.js.map +1 -1
  27. package/dist/worktree.d.ts +3 -0
  28. package/dist/worktree.js +38 -8
  29. package/dist/worktree.js.map +1 -1
  30. package/dist/worktreeTransition.d.ts +173 -0
  31. package/dist/worktreeTransition.js +1012 -0
  32. package/dist/worktreeTransition.js.map +1 -0
  33. package/dist/worktreeTransition.test.d.ts +1 -0
  34. package/dist/worktreeTransition.test.js +1179 -0
  35. package/dist/worktreeTransition.test.js.map +1 -0
  36. package/package.json +1 -1
  37. package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
  38. package/plugins/ai-delivery/skills/worktree-lifecycle/SKILL.md +6 -0
@@ -1,7 +1,7 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { createRequire } from 'node:module';
4
- import { existsSync, unlinkSync, lstatSync, readFileSync, readdirSync, realpathSync, statfsSync } from 'node:fs';
4
+ import { closeSync, existsSync, fsyncSync, openSync, unlinkSync, lstatSync, mkdirSync, rmdirSync, renameSync, readFileSync, readdirSync, realpathSync, statfsSync, } from 'node:fs';
5
5
  import { basename, dirname, join, resolve, sep } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { z } from 'zod';
@@ -125,6 +125,254 @@ function assertResourceBounds(bounds) {
125
125
  if (process.platform === 'win32')
126
126
  throw new DeliveryError('Resource observation requires POSIX process support.');
127
127
  }
128
+ const OUTPUT_GATE_ENV = 'AI_DELIVERY_OUTPUT_OBSERVATION_GATE';
129
+ const GateOwnerSchema = z.strictObject({ pid: z.number().int().positive(), identity: z.string().min(1) });
130
+ const OutputGateSchema = z.strictObject({ directory: z.string().min(1), id: z.uuid(), owner: GateOwnerSchema });
131
+ const GateClaimSchema = z.strictObject({
132
+ gateId: z.uuid(),
133
+ id: z.uuid(),
134
+ kind: z.enum(['scan', 'fixture']),
135
+ owner: GateOwnerSchema,
136
+ });
137
+ function gateOwner() {
138
+ const owner = processSnapshot().get(process.pid);
139
+ if (!owner)
140
+ throw new DeliveryError('Filesystem observation gate owner is unavailable.');
141
+ return { pid: owner.pid, identity: owner.identity };
142
+ }
143
+ function assertGateDirectory(path) {
144
+ const metadata = lstatSync(path);
145
+ if (!metadata.isDirectory() || (metadata.mode & 0o077) !== 0 || realpathSync(path) !== path)
146
+ throw new DeliveryError('Filesystem observation gate directory is invalid.');
147
+ }
148
+ function readOutputGate(value) {
149
+ try {
150
+ const gate = OutputGateSchema.parse(JSON.parse(value));
151
+ assertOutputGate(gate);
152
+ return gate;
153
+ }
154
+ catch (error) {
155
+ throw new DeliveryError('Filesystem observation gate capability is invalid or stale: ' +
156
+ (error instanceof Error ? error.message : String(error)));
157
+ }
158
+ }
159
+ function gateOwnerAlive(owner, snapshot = processSnapshot()) {
160
+ const current = snapshot.get(owner.pid);
161
+ return current?.identity === owner.identity && !current.status.startsWith('Z');
162
+ }
163
+ function assertOutputGate(gate, abandoned = false) {
164
+ assertGateDirectory(gate.directory);
165
+ const manifest = OutputGateSchema.parse(JSON.parse(assertPrivateFile(join(gate.directory, 'gate.json')).toString('utf8')));
166
+ const snapshot = processSnapshot();
167
+ let actor = snapshot.get(process.pid);
168
+ const ancestors = new Set();
169
+ while (actor && actor.pid !== gate.owner.pid && !ancestors.has(actor.pid)) {
170
+ ancestors.add(actor.pid);
171
+ actor = snapshot.get(actor.ppid);
172
+ }
173
+ if (digestValue(manifest) !== digestValue(gate) ||
174
+ (abandoned
175
+ ? gateOwnerAlive(gate.owner, snapshot)
176
+ : !gateOwnerAlive(gate.owner, snapshot) || actor?.identity !== gate.owner.identity))
177
+ throw new DeliveryError('Filesystem observation gate ownership changed, was abandoned or is not an inherited ancestor.');
178
+ }
179
+ function readGateClaim(gate, path) {
180
+ const claim = GateClaimSchema.parse(JSON.parse(assertPrivateFile(path).toString('utf8')));
181
+ if (claim.gateId !== gate.id)
182
+ throw new DeliveryError('Filesystem observation gate claim belongs to another run.');
183
+ return claim;
184
+ }
185
+ function gateScanRequests(gate) {
186
+ const directory = join(gate.directory, 'requests');
187
+ assertGateDirectory(directory);
188
+ const requests = readdirSync(directory);
189
+ if (requests.length > 1024 || requests.some((name) => !/^[a-f0-9-]{36}\.json$/u.test(name)))
190
+ throw new DeliveryError('Filesystem observation gate scan requests are invalid.');
191
+ const live = [];
192
+ const snapshot = processSnapshot();
193
+ for (const name of requests) {
194
+ let claim;
195
+ try {
196
+ claim = readGateClaim(gate, join(directory, name));
197
+ }
198
+ catch (error) {
199
+ if (error.code === 'ENOENT')
200
+ continue;
201
+ throw error;
202
+ }
203
+ if (claim.kind !== 'scan' || name !== claim.id + '.json' || !gateOwnerAlive(claim.owner, snapshot))
204
+ throw new DeliveryError('Filesystem observation gate scan request was abandoned.');
205
+ live.push(name);
206
+ }
207
+ return live;
208
+ }
209
+ function removeGateClaim(gate, path, claim) {
210
+ if (digestValue(readGateClaim(gate, path)) !== digestValue(claim))
211
+ throw new DeliveryError('Filesystem observation gate claim changed before release.');
212
+ unlinkSync(path);
213
+ }
214
+ async function withOutputGateLock(gate, kind, operation, signal) {
215
+ if (!gate)
216
+ return operation();
217
+ assertOutputGate(gate);
218
+ const claim = { gateId: gate.id, id: randomUUID(), kind, owner: gateOwner() };
219
+ const request = kind === 'scan' ? join(gate.directory, 'requests', claim.id + '.json') : undefined;
220
+ const lock = join(gate.directory, 'held');
221
+ const holder = join(lock, 'owner.json');
222
+ if (request) {
223
+ const staging = join(gate.directory, claim.id + '.pending');
224
+ writePrivateJsonFileAtomically(staging, claim);
225
+ renameSync(staging, request);
226
+ }
227
+ let acquired;
228
+ let incompleteSince;
229
+ let fixtureWait;
230
+ const release = () => {
231
+ if (!acquired)
232
+ return;
233
+ const metadata = lstatSync(lock);
234
+ if (!metadata.isDirectory() || metadata.dev !== acquired.dev || metadata.ino !== acquired.ino)
235
+ throw new DeliveryError('Filesystem observation gate lock changed before release.');
236
+ if (readdirSync(lock).join(',') !== 'owner.json')
237
+ throw new DeliveryError('Filesystem observation gate lock has unrelated metadata.');
238
+ removeGateClaim(gate, holder, claim);
239
+ rmdirSync(lock);
240
+ acquired = undefined;
241
+ };
242
+ try {
243
+ for (;;) {
244
+ if (signal?.aborted)
245
+ throw new DeliveryError('Filesystem observation gate wait cancelled.');
246
+ assertOutputGate(gate);
247
+ const requests = gateScanRequests(gate);
248
+ if (kind === 'scan' || requests.length === 0) {
249
+ let created = false;
250
+ try {
251
+ mkdirSync(lock, { mode: 0o700 });
252
+ created = true;
253
+ }
254
+ catch (error) {
255
+ if (error.code !== 'EEXIST')
256
+ throw error;
257
+ }
258
+ if (created) {
259
+ const metadata = lstatSync(lock);
260
+ acquired = { dev: metadata.dev, ino: metadata.ino };
261
+ writePrivateJsonFileAtomically(holder, claim);
262
+ // A scan may queue between the fixture's request check and atomic lock acquisition.
263
+ if (kind === 'scan' || gateScanRequests(gate).length === 0)
264
+ return await operation();
265
+ release();
266
+ }
267
+ }
268
+ if (existsSync(lock)) {
269
+ try {
270
+ assertGateDirectory(lock);
271
+ const current = readGateClaim(gate, holder);
272
+ incompleteSince = undefined;
273
+ if (!gateOwnerAlive(current.owner))
274
+ throw new DeliveryError('Filesystem observation gate fixture or scanner was abandoned.');
275
+ if (kind === 'scan' && current.kind === 'fixture') {
276
+ if (fixtureWait?.claimId !== current.id)
277
+ fixtureWait = { claimId: current.id, since: Date.now() };
278
+ // A negative reader that blocks prevents strict output observation; terminate this command, never steal its live lease.
279
+ if (Date.now() - fixtureWait.since > 5000)
280
+ throw new DeliveryError('Filesystem-negative fixture did not release a waiting output scan within 5 seconds; reader or teardown is stalled.');
281
+ }
282
+ else
283
+ fixtureWait = undefined;
284
+ }
285
+ catch (error) {
286
+ if (error.code !== 'ENOENT')
287
+ throw error;
288
+ incompleteSince ??= Date.now();
289
+ // A concrete publication failure, never an elapsed fixture-work allowance or stale-lock takeover.
290
+ if (Date.now() - incompleteSince > 2000)
291
+ throw new DeliveryError('Filesystem observation gate owner publication is incomplete.');
292
+ }
293
+ }
294
+ await new Promise((resolve) => setTimeout(resolve, 100));
295
+ }
296
+ }
297
+ finally {
298
+ try {
299
+ release();
300
+ }
301
+ finally {
302
+ if (request)
303
+ removeGateClaim(gate, request, claim);
304
+ }
305
+ }
306
+ }
307
+ /** Run a real filesystem-negative fixture between strict scans; the callback must clean it up before returning. */
308
+ export async function withVerificationFilesystemFixture(operation) {
309
+ const capability = process.env[OUTPUT_GATE_ENV];
310
+ return withOutputGateLock(capability === undefined ? undefined : readOutputGate(capability), 'fixture', operation);
311
+ }
312
+ function retireOutputGate(gate, abandoned = false) {
313
+ assertOutputGate(gate, abandoned);
314
+ const requests = join(gate.directory, 'requests');
315
+ const held = join(gate.directory, 'held');
316
+ if (readdirSync(gate.directory).some((name) => !['gate.json', 'requests', 'held'].includes(name)))
317
+ throw new DeliveryError('Filesystem observation gate has unrelated or incomplete metadata.');
318
+ if (existsSync(held)) {
319
+ assertGateDirectory(held);
320
+ if (readdirSync(held).join(',') !== 'owner.json')
321
+ throw new DeliveryError('Filesystem observation gate lock has unrelated metadata.');
322
+ const claim = readGateClaim(gate, join(held, 'owner.json'));
323
+ if (gateOwnerAlive(claim.owner))
324
+ throw new DeliveryError('Filesystem observation gate still has a live holder.');
325
+ removeGateClaim(gate, join(held, 'owner.json'), claim);
326
+ rmdirSync(held);
327
+ }
328
+ for (const name of readdirSync(requests)) {
329
+ if (!/^[a-f0-9-]{36}\.json$/u.test(name))
330
+ throw new DeliveryError('Filesystem observation gate has unrelated metadata.');
331
+ const path = join(requests, name);
332
+ const claim = readGateClaim(gate, path);
333
+ if (claim.kind !== 'scan' || name !== claim.id + '.json')
334
+ throw new DeliveryError('Filesystem observation gate has unrelated scan metadata.');
335
+ if (gateOwnerAlive(claim.owner))
336
+ throw new DeliveryError('Filesystem observation gate still has a live scan request.');
337
+ removeGateClaim(gate, path, claim);
338
+ }
339
+ rmdirSync(requests);
340
+ if (digestValue(JSON.parse(assertPrivateFile(join(gate.directory, 'gate.json')).toString('utf8'))) !==
341
+ digestValue(gate))
342
+ throw new DeliveryError('Filesystem observation gate changed before retirement.');
343
+ unlinkSync(join(gate.directory, 'gate.json'));
344
+ rmdirSync(gate.directory);
345
+ }
346
+ async function withOutputObservationGate(root, enabled, operation) {
347
+ const inherited = process.env[OUTPUT_GATE_ENV];
348
+ if (inherited !== undefined)
349
+ return operation(readOutputGate(inherited));
350
+ if (!enabled)
351
+ return operation(undefined);
352
+ const id = randomUUID();
353
+ const namespace = join(gitCommonDir(root), 'ai-delivery', 'output-observation', worktreeDigest(root).slice(7));
354
+ ensurePrivateDirectoryDurably(namespace);
355
+ // The existing writer has recovered and confirmed old command quiescence before this operation.
356
+ for (const priorId of readdirSync(namespace)) {
357
+ z.uuid().parse(priorId);
358
+ const path = join(namespace, priorId);
359
+ assertGateDirectory(path);
360
+ const prior = OutputGateSchema.parse(JSON.parse(assertPrivateFile(join(path, 'gate.json')).toString('utf8')));
361
+ if (prior.id !== priorId || prior.directory !== path || gateOwnerAlive(prior.owner))
362
+ throw new DeliveryError('Prior filesystem observation gate is live or has conflicting ownership.');
363
+ retireOutputGate(prior, true);
364
+ }
365
+ const directory = join(namespace, id);
366
+ ensurePrivateDirectoryDurably(join(directory, 'requests'));
367
+ const gate = { directory, id, owner: gateOwner() };
368
+ writePrivateJsonFileAtomically(join(directory, 'gate.json'), gate);
369
+ try {
370
+ return await operation(gate);
371
+ }
372
+ finally {
373
+ retireOutputGate(gate);
374
+ }
375
+ }
128
376
  const OutputBaselineCheckpointSchema = z.strictObject({
129
377
  baselineId: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
130
378
  files: z.array(z.tuple([z.string().min(1), z.number().int().nonnegative().safe()])),
@@ -187,7 +435,7 @@ function scanOutputRoots(roots) {
187
435
  }
188
436
  return files;
189
437
  }
190
- function outputBaseline(repoRoot, common, classificationReceiptId, bounds) {
438
+ async function outputBaseline(repoRoot, common, classificationReceiptId, bounds, gate, signal) {
191
439
  if (bounds.outputRoots === undefined)
192
440
  return undefined;
193
441
  const roots = bounds.outputRoots.map((path) => resolve(repoRoot, path)).sort();
@@ -236,7 +484,7 @@ function outputBaseline(repoRoot, common, classificationReceiptId, bounds) {
236
484
  let checkpoint;
237
485
  if (state?.status !== 'complete') {
238
486
  const content = {
239
- files: [...scanOutputRoots(roots)].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
487
+ files: [...(await withOutputGateLock(gate, 'scan', () => scanOutputRoots(roots), signal))].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)),
240
488
  inputId,
241
489
  roots,
242
490
  schemaVersion: 'ai-delivery.output-baseline@1',
@@ -261,11 +509,11 @@ function outputBaseline(repoRoot, common, classificationReceiptId, bounds) {
261
509
  checkpoint.files.some(([file], index) => (index > 0 && file <= checkpoint.files[index - 1][0]) ||
262
510
  !roots.some((root) => file === root || file.startsWith(`${root}${sep}`))))
263
511
  throw new DeliveryError('Filesystem output baseline checkpoint is corrupt or belongs to another input.');
264
- return { baselineId, roots, files: new Map(checkpoint.files) };
512
+ return { baselineId, roots, files: new Map(checkpoint.files), ...(gate === undefined ? {} : { gate }) };
265
513
  }
266
- function positiveNewOutputBytes(baseline) {
514
+ async function positiveNewOutputBytes(baseline, signal) {
267
515
  let bytes = 0;
268
- for (const [path, size] of scanOutputRoots(baseline.roots)) {
516
+ for (const [path, size] of await withOutputGateLock(baseline.gate, 'scan', () => scanOutputRoots(baseline.roots), signal)) {
269
517
  bytes += Math.max(0, size - (baseline.files.get(path) ?? 0));
270
518
  if (!Number.isSafeInteger(bytes))
271
519
  throw new DeliveryError('Filesystem output observation overflowed.');
@@ -382,7 +630,7 @@ function observeOwnedProcesses(state, snapshot) {
382
630
  state.sampled = true;
383
631
  return [...selected.values()];
384
632
  }
385
- function sampleOwnedTree(state, repoRoot, bounds, baseline) {
633
+ function sampleOwnedTree(state, repoRoot, bounds, baseline, newOutputBytes) {
386
634
  const owned = observeOwnedProcesses(state, processSnapshot());
387
635
  const rssBytes = owned.reduce((total, member) => total + member.rssBytes, 0);
388
636
  if (!Number.isSafeInteger(rssBytes))
@@ -393,7 +641,6 @@ function sampleOwnedTree(state, repoRoot, bounds, baseline) {
393
641
  const available = freeDiskBytes(repoRoot, baseline);
394
642
  if (available < bounds.minFreeDiskBytes)
395
643
  throw new DeliveryError(`Free disk fell below limit ${bounds.minFreeDiskBytes}.`);
396
- const newOutputBytes = baseline === undefined ? undefined : positiveNewOutputBytes(baseline);
397
644
  if (newOutputBytes !== undefined && !Number.isSafeInteger(newOutputBytes)) {
398
645
  throw new DeliveryError('Filesystem output observation overflowed.');
399
646
  }
@@ -492,9 +739,30 @@ const WriterStateSchema = z
492
739
  if (stateId !== digestValue(content))
493
740
  ctx.addIssue({ code: 'custom', message: 'Writer state is corrupt.' });
494
741
  });
495
- async function withVerificationWriter(root, operation) {
742
+ /** @internal Read-only closure check shared by inspection and the locked transition lease. */
743
+ export function assertWorktreeTransitionWriterQuiescent(worktreePath, bytes) {
744
+ if (bytes === undefined)
745
+ return;
746
+ const previous = WriterStateSchema.parse(JSON.parse(bytes.toString('utf8')));
747
+ if (previous.worktreeDigest !== worktreeDigest(worktreePath))
748
+ throw new DeliveryError('Verification writer belongs to another worktree.');
749
+ const snapshot = processSnapshot();
750
+ const owner = snapshot.get(previous.owner.pid);
751
+ if (owner?.identity === previous.owner.identity && !owner.status.startsWith('Z'))
752
+ throw new DeliveryError('A live verification writer still owns this worktree.');
753
+ if (previous.command.phase === 'starting')
754
+ throw new DeliveryError('Interrupted writer has unknown command ownership; reconcile before retrying.');
755
+ if (previous.command.phase === 'running') {
756
+ const identities = [previous.command.root, ...previous.command.tracked];
757
+ const groups = new Set([previous.command.root.pid, ...previous.command.tracked.map((member) => member.pgid)]);
758
+ if (identities.some((member) => snapshot.has(member.pid)) ||
759
+ [...snapshot.values()].some((member) => groups.has(member.pgid)))
760
+ throw new DeliveryError('Transition requires identity-bound command and process-group absence.');
761
+ }
762
+ }
763
+ async function withVerificationWriter(root, operation, transition, commonDirectory) {
496
764
  const digest = worktreeDigest(root);
497
- const path = join(gitCommonDir(root), 'ai-delivery', 'writers@1', `${digest.slice(7)}.json`);
765
+ const path = join(commonDirectory ?? gitCommonDir(root), 'ai-delivery', 'writers@1', `${digest.slice(7)}.json`);
498
766
  ensurePrivateDirectoryDurably(dirname(path));
499
767
  return withLock(path, {
500
768
  projectRoot: root,
@@ -504,8 +772,23 @@ async function withVerificationWriter(root, operation) {
504
772
  const owner = snapshot.get(process.pid);
505
773
  if (owner === undefined || owner.status.startsWith('Z'))
506
774
  throw new DeliveryError('Verification writer identity is unknown.');
507
- if (existsSync(path)) {
508
- const previous = WriterStateSchema.parse(JSON.parse(assertPrivateFile(path).toString('utf8')));
775
+ let previousBytes;
776
+ if (transition) {
777
+ try {
778
+ lstatSync(path);
779
+ previousBytes = assertPrivateFile(path);
780
+ }
781
+ catch (error) {
782
+ if (error.code !== 'ENOENT')
783
+ throw error;
784
+ }
785
+ }
786
+ else if (existsSync(path))
787
+ previousBytes = assertPrivateFile(path);
788
+ if (transition)
789
+ assertWorktreeTransitionWriterQuiescent(root, previousBytes);
790
+ if (previousBytes !== undefined && !transition) {
791
+ const previous = WriterStateSchema.parse(JSON.parse(previousBytes.toString('utf8')));
509
792
  if (previous.worktreeDigest !== digest)
510
793
  throw new DeliveryError('Verification writer belongs to another worktree.');
511
794
  const priorOwner = snapshot.get(previous.owner.pid);
@@ -533,9 +816,15 @@ async function withVerificationWriter(root, operation) {
533
816
  });
534
817
  }
535
818
  }
819
+ if (transition) {
820
+ await transition.beforeClaim({
821
+ previousBytes: previousBytes === undefined ? undefined : Buffer.from(previousBytes),
822
+ owner: { pid: owner.pid, identity: owner.identity },
823
+ });
824
+ }
536
825
  let state = {
537
826
  schemaVersion: 'ai-delivery.verification-writer@1',
538
- writerId: randomUUID(),
827
+ writerId: transition?.writerId ?? randomUUID(),
539
828
  worktreeDigest: digest,
540
829
  owner: { pid: owner.pid, identity: owner.identity },
541
830
  command: { phase: 'idle' },
@@ -544,6 +833,22 @@ async function withVerificationWriter(root, operation) {
544
833
  state = { ...state, command };
545
834
  writePrivateJsonFileAtomically(path, { ...state, stateId: digestValue(state) });
546
835
  };
836
+ if (transition) {
837
+ const bytes = Buffer.from(`${JSON.stringify({ ...state, stateId: digestValue(state) }, null, 2)}\n`);
838
+ await transition.recordClaim(Buffer.from(bytes));
839
+ let actual;
840
+ try {
841
+ lstatSync(path);
842
+ actual = assertPrivateFile(path);
843
+ }
844
+ catch (error) {
845
+ if (error.code !== 'ENOENT')
846
+ throw error;
847
+ }
848
+ if ((actual === undefined) !== (previousBytes === undefined) ||
849
+ (actual !== undefined && previousBytes !== undefined && !actual.equals(previousBytes)))
850
+ throw new DeliveryError('Writer changed after transition intent preservation.');
851
+ }
547
852
  persist({ phase: 'idle' });
548
853
  const writer = {
549
854
  starting: () => persist({ phase: 'starting' }),
@@ -563,22 +868,80 @@ async function withVerificationWriter(root, operation) {
563
868
  throw new DeliveryError('Verification writer command quiescence is not confirmed.');
564
869
  },
565
870
  };
566
- const releaseWriter = () => {
567
- const current = WriterStateSchema.parse(JSON.parse(assertPrivateFile(path).toString('utf8')));
871
+ const releaseWriter = async () => {
872
+ const bytes = assertPrivateFile(path);
873
+ const current = WriterStateSchema.parse(JSON.parse(bytes.toString('utf8')));
568
874
  if (current.writerId !== state.writerId)
569
875
  throw new DeliveryError('Verification writer ownership changed.');
570
- if (current.command.phase === 'idle')
876
+ if (current.command.phase === 'idle') {
877
+ if (transition) {
878
+ await transition.recordRelease(Buffer.from(bytes));
879
+ if (!assertPrivateFile(path).equals(bytes))
880
+ throw new DeliveryError('Writer changed before recorded release.');
881
+ }
571
882
  unlinkSync(path);
883
+ if (transition) {
884
+ const descriptor = openSync(dirname(path), 'r');
885
+ try {
886
+ fsyncSync(descriptor);
887
+ }
888
+ finally {
889
+ closeSync(descriptor);
890
+ }
891
+ try {
892
+ lstatSync(path);
893
+ }
894
+ catch (error) {
895
+ if (error.code !== 'ENOENT')
896
+ throw error;
897
+ await transition.afterRelease?.();
898
+ return;
899
+ }
900
+ throw new DeliveryError('Writer absence is not confirmed after recorded release.');
901
+ }
902
+ }
572
903
  };
573
904
  try {
905
+ await transition?.afterClaim?.(Buffer.from(assertPrivateFile(path)));
574
906
  return await operation(writer);
575
907
  }
576
908
  finally {
577
- releaseWriter();
909
+ await releaseWriter();
578
910
  }
579
911
  },
580
912
  });
581
913
  }
914
+ /** @internal Hold the existing writer lock while reading a sealed transition; never claim or recover its slot. */
915
+ export async function withWorktreeTransitionWriterAbsent(repoRoot, worktreePath, operation) {
916
+ const path = join(gitCommonDir(repoRoot), 'ai-delivery', 'writers@1', `${worktreeDigest(worktreePath).slice(7)}.json`);
917
+ return withLock(path, {
918
+ projectRoot: repoRoot,
919
+ timeout: 200,
920
+ operation: async () => {
921
+ try {
922
+ lstatSync(path);
923
+ }
924
+ catch (error) {
925
+ if (error.code !== 'ENOENT')
926
+ throw error;
927
+ return operation();
928
+ }
929
+ throw new DeliveryError('Sealed transition writer absence postcondition drifted.');
930
+ },
931
+ });
932
+ }
933
+ /** @internal Check-only lease for the explicit transition owner; never recovers or signals old commands. */
934
+ export async function withWorktreeTransitionWriterLease(input, operation) {
935
+ z.uuid().parse(input.writerId);
936
+ const repository = gitRoot(input.repoRoot);
937
+ const subject = input.worktreePath === undefined ? repository : resolve(input.worktreePath);
938
+ return withVerificationWriter(subject, async (writer) => {
939
+ writer.assertQuiescent();
940
+ const result = await operation();
941
+ writer.assertQuiescent();
942
+ return result;
943
+ }, input, gitCommonDir(repository));
944
+ }
582
945
  function primaryRoot(worktreeRoot) {
583
946
  const common = gitCommonDir(worktreeRoot);
584
947
  if (basename(common) !== '.git')
@@ -597,8 +960,7 @@ function assertRegisteredIssue(worktreeRoot, issueNumber, allowMerged = false) {
597
960
  function worktreeDigest(root) {
598
961
  return digestValue(resolve(root));
599
962
  }
600
- function producerDigest() {
601
- const root = dirname(fileURLToPath(import.meta.url));
963
+ function producerContent(root) {
602
964
  const code = [];
603
965
  const collect = (directory) => {
604
966
  for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
@@ -646,7 +1008,22 @@ function producerDigest() {
646
1008
  }
647
1009
  };
648
1010
  collectDependencies(join(dirname(root), 'package.json'));
649
- return digestValue({ code, dependencies: [...dependencies].sort(([a], [b]) => a.localeCompare(b)) });
1011
+ return { code, dependencies: [...dependencies].sort(([a], [b]) => a.localeCompare(b)) };
1012
+ }
1013
+ function producerDigest(root = dirname(fileURLToPath(import.meta.url))) {
1014
+ return digestValue(producerContent(root));
1015
+ }
1016
+ /** @internal Retained public code is hashed as data; it is never imported or executed. */
1017
+ export function retainedWorktreeTransitionProducerDigest(distDirectory) {
1018
+ return producerDigest(realpathSync(distDirectory));
1019
+ }
1020
+ /** @internal Preserve the same dependency manifests that participate in the existing producer digest. */
1021
+ export function retainedWorktreeTransitionDependencyManifests(distDirectory) {
1022
+ return producerContent(realpathSync(distDirectory)).dependencies.map(([source, digest]) => ({ source, digest }));
1023
+ }
1024
+ /** @internal Original manifests validate as history and cannot authorize current verification. */
1025
+ export function readWorktreeTransitionRun(bytes) {
1026
+ return parseRun(JSON.parse(bytes.toString('utf8')));
650
1027
  }
651
1028
  function manifestPath(common, headSha, root) {
652
1029
  return root === undefined
@@ -756,6 +1133,9 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
756
1133
  cwd: repoRoot,
757
1134
  detached: process.platform !== 'win32',
758
1135
  stdio: ['pipe', 'pipe', 'pipe'],
1136
+ ...(baseline?.gate === undefined
1137
+ ? {}
1138
+ : { env: { ...process.env, [OUTPUT_GATE_ENV]: JSON.stringify(baseline.gate) } }),
759
1139
  });
760
1140
  const stdout = [];
761
1141
  const stderr = [];
@@ -769,6 +1149,8 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
769
1149
  let closed = false;
770
1150
  let settled = false;
771
1151
  let commandReleased = false;
1152
+ let pendingOutput;
1153
+ const scanCancellation = new AbortController();
772
1154
  let closeTimer;
773
1155
  let exitPipeTimer;
774
1156
  let resourceTimer;
@@ -825,6 +1207,7 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
825
1207
  if (failure !== undefined)
826
1208
  return;
827
1209
  failure = reason;
1210
+ scanCancellation.abort();
828
1211
  killOwned();
829
1212
  if (!closed) {
830
1213
  closeTimer = setTimeout(() => {
@@ -881,10 +1264,26 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
881
1264
  onResourceSample?.(result);
882
1265
  onRunning?.(outputBytes, result);
883
1266
  }
884
- if (!commandReleased) {
885
- commandReleased = true;
886
- child.stdin.end('run\n');
887
- }
1267
+ if (closed || pendingOutput !== undefined)
1268
+ return;
1269
+ pendingOutput = (async () => {
1270
+ if (resourceBounds !== undefined && baseline !== undefined) {
1271
+ const bytes = await positiveNewOutputBytes(baseline, scanCancellation.signal);
1272
+ if (failure !== undefined)
1273
+ return;
1274
+ const result = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline, bytes);
1275
+ onResourceSample?.(result);
1276
+ onRunning?.(outputBytes, result);
1277
+ }
1278
+ if (failure === undefined && !commandReleased) {
1279
+ commandReleased = true;
1280
+ child.stdin.end('run\n');
1281
+ }
1282
+ })()
1283
+ .catch((error) => stop(error instanceof Error ? error.message : String(error)))
1284
+ .finally(() => {
1285
+ pendingOutput = undefined;
1286
+ });
888
1287
  }
889
1288
  catch (error) {
890
1289
  stop(error instanceof Error ? error.message : String(error));
@@ -908,11 +1307,11 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
908
1307
  closed = true;
909
1308
  if (settled)
910
1309
  return;
911
- clearWatchers();
1310
+ await pendingOutput;
912
1311
  if (failure === undefined && owned !== undefined) {
913
1312
  try {
914
1313
  if (resourceBounds !== undefined) {
915
- const sample = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline);
1314
+ const sample = sampleOwnedTree(owned, repoRoot, resourceBounds, baseline, baseline === undefined ? undefined : await positiveNewOutputBytes(baseline, scanCancellation.signal));
916
1315
  onResourceSample?.(sample);
917
1316
  onRunning?.(outputBytes, sample);
918
1317
  }
@@ -955,18 +1354,20 @@ function runStageCommand(repoRoot, argv, abortSignal, onRunning, resourceBounds,
955
1354
  resolve(Buffer.concat([...stdout, ...stderr], outputBytes));
956
1355
  };
957
1356
  child.once('close', (code, signal) => {
958
- void handleClose(code, signal).catch((error) => {
1357
+ void handleClose(code, signal)
1358
+ .catch((error) => {
959
1359
  settled = true;
960
1360
  reject(error);
961
- });
1361
+ })
1362
+ .finally(clearWatchers);
962
1363
  });
963
1364
  });
964
1365
  }
965
1366
  export async function verifyIssue(input) {
966
1367
  const root = gitRoot(input.repoRoot);
967
- return withVerificationWriter(root, (writer) => verifyIssueOwned(input, writer));
1368
+ return withVerificationWriter(root, (writer) => withOutputObservationGate(root, input.resourceBounds?.outputRoots !== undefined, (gate) => verifyIssueOwned(input, writer, gate)));
968
1369
  }
969
- async function verifyIssueOwned(input, writer) {
1370
+ async function verifyIssueOwned(input, writer, gate) {
970
1371
  const assertNotCancelled = () => {
971
1372
  if (input.signal?.aborted)
972
1373
  throw new DeliveryError('Verification cancelled.');
@@ -996,7 +1397,7 @@ async function verifyIssueOwned(input, writer) {
996
1397
  });
997
1398
  const baseline = input.resourceBounds === undefined
998
1399
  ? undefined
999
- : outputBaseline(root, common, classification.receiptId, input.resourceBounds);
1400
+ : await outputBaseline(root, common, classification.receiptId, input.resourceBounds, gate, input.signal);
1000
1401
  if (input.resourceBounds !== undefined)
1001
1402
  assertDiskHeadroom(root, input.resourceBounds, baseline);
1002
1403
  const receipts = [];
@@ -1149,6 +1550,9 @@ async function verifyIssueOwned(input, writer) {
1149
1550
  sampledFreeDiskBytes: sample.freeDiskBytes,
1150
1551
  ...(sample.newOutputBytes === undefined ? {} : { sampledNewOutputBytes: sample.newOutputBytes }),
1151
1552
  ownedProcessCount: sample.ownedProcessCount,
1553
+ ...(baseline !== undefined && sample.newOutputBytes === undefined
1554
+ ? { reason: 'waiting for strict filesystem output scan' }
1555
+ : {}),
1152
1556
  }),
1153
1557
  });
1154
1558
  progress(0);
@@ -1211,6 +1615,19 @@ async function verifyIssueOwned(input, writer) {
1211
1615
  });
1212
1616
  }
1213
1617
  assertNotCancelled();
1618
+ if (baseline !== undefined && input.resourceBounds !== undefined) {
1619
+ const bytes = await positiveNewOutputBytes(baseline, input.signal);
1620
+ if (bytes > input.resourceBounds.maxNewOutputBytes)
1621
+ throw new DeliveryError('Positive new filesystem output ' + bytes + ' exceeded limit ' + input.resourceBounds.maxNewOutputBytes + '.');
1622
+ assertDiskHeadroom(root, input.resourceBounds, baseline);
1623
+ recordResourceSample({
1624
+ aggregateRssBytes: 0,
1625
+ freeDiskBytes: freeDiskBytes(root, baseline),
1626
+ newOutputBytes: bytes,
1627
+ ownedProcessCount: 0,
1628
+ });
1629
+ }
1630
+ assertNotCancelled();
1214
1631
  assertRepositoryClassificationCurrent({
1215
1632
  classification,
1216
1633
  configDigest: loaded.configDigest,
@@ -1299,41 +1716,48 @@ export async function withRuntimeSetupWriter(root, operation) {
1299
1716
  if (roots[i] === roots[i - 1] || roots[i].startsWith(`${roots[i - 1]}${sep}`))
1300
1717
  throw new DeliveryError('Filesystem output roots must not overlap.');
1301
1718
  }
1302
- const files = roots === undefined ? undefined : scanOutputRoots(roots);
1303
- const baseline = roots === undefined || files === undefined
1304
- ? undefined
1305
- : {
1306
- roots,
1307
- files,
1308
- baselineId: digestValue([...files]),
1309
- };
1310
- assertDiskHeadroom(root, bounds, baseline);
1311
- const started = Date.now();
1312
- const output = await runStageCommand(root, argv, signal, (capturedOutputBytes, sample) => reportVerificationProgress({
1313
- state: 'running',
1314
- stageId: 'runtime-setup',
1315
- completedStages: 0,
1316
- reusedStages: 0,
1317
- remainingStages: 1,
1318
- elapsedMs: Date.now() - started,
1319
- capturedOutputBytes,
1320
- ...(sample === undefined
1321
- ? {}
1719
+ return withOutputObservationGate(root, roots !== undefined, async (gate) => {
1720
+ const files = roots === undefined
1721
+ ? undefined
1722
+ : await withOutputGateLock(gate, 'scan', () => scanOutputRoots(roots), signal);
1723
+ const baseline = roots === undefined || files === undefined
1724
+ ? undefined
1322
1725
  : {
1323
- sampledAggregateRssBytes: sample.aggregateRssBytes,
1324
- sampledFreeDiskBytes: sample.freeDiskBytes,
1325
- sampledNewOutputBytes: sample.newOutputBytes,
1326
- }),
1327
- }), bounds, baseline, undefined, writer);
1328
- reportVerificationProgress({
1329
- state: 'completed',
1330
- stageId: 'runtime-setup',
1331
- completedStages: 1,
1332
- reusedStages: 0,
1333
- remainingStages: 0,
1334
- elapsedMs: Date.now() - started,
1726
+ roots,
1727
+ files,
1728
+ baselineId: digestValue([...files]),
1729
+ ...(gate === undefined ? {} : { gate }),
1730
+ };
1731
+ assertDiskHeadroom(root, bounds, baseline);
1732
+ const started = Date.now();
1733
+ const output = await runStageCommand(root, argv, signal, (capturedOutputBytes, sample) => reportVerificationProgress({
1734
+ state: 'running',
1735
+ stageId: 'runtime-setup',
1736
+ completedStages: 0,
1737
+ reusedStages: 0,
1738
+ remainingStages: 1,
1739
+ elapsedMs: Date.now() - started,
1740
+ capturedOutputBytes,
1741
+ ...(sample === undefined
1742
+ ? {}
1743
+ : {
1744
+ sampledAggregateRssBytes: sample.aggregateRssBytes,
1745
+ sampledFreeDiskBytes: sample.freeDiskBytes,
1746
+ sampledNewOutputBytes: sample.newOutputBytes,
1747
+ }),
1748
+ }), bounds, baseline, undefined, writer);
1749
+ if (signal?.aborted)
1750
+ throw new DeliveryError('Runtime setup cancelled.');
1751
+ reportVerificationProgress({
1752
+ state: 'completed',
1753
+ stageId: 'runtime-setup',
1754
+ completedStages: 1,
1755
+ reusedStages: 0,
1756
+ remainingStages: 0,
1757
+ elapsedMs: Date.now() - started,
1758
+ });
1759
+ return output;
1335
1760
  });
1336
- return output;
1337
1761
  },
1338
1762
  }));
1339
1763
  }