wicked-crew 0.16.12 → 0.16.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 (89) hide show
  1. package/README.md +2 -1
  2. package/dist/api/chat-history.d.ts +6 -0
  3. package/dist/api/chat-history.d.ts.map +1 -1
  4. package/dist/api/chat-history.js +21 -0
  5. package/dist/api/chat-history.js.map +1 -1
  6. package/dist/api/chat-proposals.d.ts +61 -6
  7. package/dist/api/chat-proposals.d.ts.map +1 -1
  8. package/dist/api/chat-proposals.js +252 -24
  9. package/dist/api/chat-proposals.js.map +1 -1
  10. package/dist/api/routes.d.ts +14 -2
  11. package/dist/api/routes.d.ts.map +1 -1
  12. package/dist/api/routes.js +119 -9
  13. package/dist/api/routes.js.map +1 -1
  14. package/dist/api/run-timing-index.d.ts +18 -0
  15. package/dist/api/run-timing-index.d.ts.map +1 -1
  16. package/dist/api/run-timing-index.js +34 -0
  17. package/dist/api/run-timing-index.js.map +1 -1
  18. package/dist/api/server.d.ts.map +1 -1
  19. package/dist/api/server.js +100 -1
  20. package/dist/api/server.js.map +1 -1
  21. package/dist/apps/spec.d.ts +3009 -0
  22. package/dist/apps/spec.d.ts.map +1 -0
  23. package/dist/apps/spec.js +870 -0
  24. package/dist/apps/spec.js.map +1 -0
  25. package/dist/cli/index.js +3 -3
  26. package/dist/cli/index.js.map +1 -1
  27. package/dist/core/run-boot-resume.d.ts +4 -1
  28. package/dist/core/run-boot-resume.d.ts.map +1 -1
  29. package/dist/core/run-boot-resume.js +6 -1
  30. package/dist/core/run-boot-resume.js.map +1 -1
  31. package/dist/editors/csp.d.ts +24 -4
  32. package/dist/editors/csp.d.ts.map +1 -1
  33. package/dist/editors/csp.js +35 -8
  34. package/dist/editors/csp.js.map +1 -1
  35. package/dist/editors/routes.d.ts +4 -1
  36. package/dist/editors/routes.d.ts.map +1 -1
  37. package/dist/editors/routes.js +30 -3
  38. package/dist/editors/routes.js.map +1 -1
  39. package/dist/editors/webrtc-guard.d.ts +94 -0
  40. package/dist/editors/webrtc-guard.d.ts.map +1 -0
  41. package/dist/editors/webrtc-guard.js +506 -0
  42. package/dist/editors/webrtc-guard.js.map +1 -0
  43. package/dist/interactive/bridge-pool.d.ts +11 -2
  44. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  45. package/dist/interactive/bridge-pool.js +10 -1
  46. package/dist/interactive/bridge-pool.js.map +1 -1
  47. package/dist/interactive/doc-binding.d.ts +156 -0
  48. package/dist/interactive/doc-binding.d.ts.map +1 -0
  49. package/dist/interactive/doc-binding.js +277 -0
  50. package/dist/interactive/doc-binding.js.map +1 -0
  51. package/dist/interactive/doc-delete-routes.d.ts +3 -0
  52. package/dist/interactive/doc-delete-routes.d.ts.map +1 -1
  53. package/dist/interactive/doc-delete-routes.js +4 -0
  54. package/dist/interactive/doc-delete-routes.js.map +1 -1
  55. package/dist/interactive/draft-events.d.ts +89 -3
  56. package/dist/interactive/draft-events.d.ts.map +1 -1
  57. package/dist/interactive/draft-events.js +438 -27
  58. package/dist/interactive/draft-events.js.map +1 -1
  59. package/dist/interactive/edit-events.d.ts +2 -0
  60. package/dist/interactive/edit-events.d.ts.map +1 -1
  61. package/dist/interactive/edit-events.js +11 -0
  62. package/dist/interactive/edit-events.js.map +1 -1
  63. package/dist/interactive/proposal-create.d.ts +143 -0
  64. package/dist/interactive/proposal-create.d.ts.map +1 -0
  65. package/dist/interactive/proposal-create.js +318 -0
  66. package/dist/interactive/proposal-create.js.map +1 -0
  67. package/dist/interactive/proxy-routes.d.ts +60 -3
  68. package/dist/interactive/proxy-routes.d.ts.map +1 -1
  69. package/dist/interactive/proxy-routes.js +209 -3
  70. package/dist/interactive/proxy-routes.js.map +1 -1
  71. package/dist/interactive/source-packet.d.ts +126 -0
  72. package/dist/interactive/source-packet.d.ts.map +1 -0
  73. package/dist/interactive/source-packet.js +482 -0
  74. package/dist/interactive/source-packet.js.map +1 -0
  75. package/dist/studio/assets/{index-C_UIgTiy.js → index-u4O_7N-b.js} +1 -1
  76. package/dist/studio/editors/wicked-deck/editor.json +1 -1
  77. package/dist/studio/editors/wicked-deck/index.html +1 -1
  78. package/dist/studio/editors/wicked-doc/editor.json +1 -1
  79. package/dist/studio/editors/wicked-doc/index.html +1 -1
  80. package/dist/studio/editors/wicked-insights/editor.json +1 -1
  81. package/dist/studio/editors/wicked-insights/index.html +1 -1
  82. package/dist/studio/editors/wicked-page/editor.json +1 -1
  83. package/dist/studio/editors/wicked-page/index.html +1 -1
  84. package/dist/studio/editors/wicked-video/editor.json +1 -1
  85. package/dist/studio/editors/wicked-video/index.html +1 -1
  86. package/dist/studio/index.html +1 -1
  87. package/dist/studio/testid-inventory.json +1 -1
  88. package/endpoint-manifest.json +1 -1
  89. package/package.json +4 -3
@@ -35,9 +35,11 @@
35
35
  */
36
36
  import { judgeFallbackLabel } from '../core/deliver-text.js';
37
37
  import { resolveProjectGraphBinding } from '../projects/graph.js';
38
- import { mkdirSync, existsSync, rmSync, statSync } from 'node:fs';
38
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
39
+ import { tmpdir } from 'node:os';
40
+ import { createBeneath, listBeneath, mkdirBeneath, readBeneath, statBeneath } from '../api/beneath.js';
39
41
  import { basename, dirname, join } from 'node:path';
40
- import { randomUUID } from 'node:crypto';
42
+ import { createHash, randomUUID } from 'node:crypto';
41
43
  import { InteractiveHandoffLedger } from './ledger.js';
42
44
  import { reviewPartitionOf as projectPartitionOf } from './review-ledger.js';
43
45
  import { draftQualityClause, logDraftSkillArmLine, pageBudgetFor, PROCESS_NOTES_WHERE, runDraftFloor, } from './draft-skill.js';
@@ -50,11 +52,17 @@ import { awaitingHumanLine, describeStep, resyncRunUnits, ResyncGate, RunUnits }
50
52
  import { acpFallbackLine, ungatedGateNote, unitDistributedLine, workerToolCallDeniedLine, } from './council-outcome.js';
51
53
  import { busSubscriberErrorReporter } from './bus-subscriber-errors.js';
52
54
  import { emitOnBus, requireEngineBus, tapBus } from '../core/bus.js';
55
+ import { UNFILED_MOUNT } from '../api/chat-context.js';
56
+ import { packetClause, packetDirOf } from './source-packet.js';
53
57
  // ── Vocabulary constants (interactive's, verbatim — src/service/events.js is the truth) ──────
54
58
  export const INTERACTIVE_DOMAIN = 'wicked-interactive';
55
59
  export const DOC_CREATED = 'wicked.interactive.doc.created';
56
60
  export const DRAFT_COMPLETED = 'wicked.interactive.draft.completed';
57
61
  export const STATUS_POSTED = 'wicked.interactive.status.posted';
62
+ /** interactive's announce of a landed version (`{document_id, project_id?, version, parent, kind, run_id?}`). */
63
+ export const VERSION_CREATED = 'wicked.interactive.version.created';
64
+ /** interactive ≥ the I1 contract: a `draft.completed` whose `expect_head` no longer held landed nothing. */
65
+ export const DRAFT_CONFLICT = 'wicked.interactive.draft.conflict';
58
66
  /** Exact-type filter with a domain guard — no wildcard, one event type is the whole trigger. */
59
67
  export const INTERACTIVE_BUS_FILTER = `${DOC_CREATED}@${INTERACTIVE_DOMAIN}`;
60
68
  /** The producer identity stamped on every event crew's interactive seams emit (this module and
@@ -320,14 +328,29 @@ export function draftProblem(doc, outPath, snapshotDir, intent, grounding) {
320
328
  `${subjectClause}${groundingClause}${recall}${propose}` +
321
329
  `The finished draft MUST be written to exactly this absolute file path: ${outPath}`);
322
330
  }
323
- /** Deterministic bus idempotency key for the one draft this seam may land per document. */
324
- export function draftIdempotencyKey(documentId, projectId) {
331
+ /**
332
+ * Deterministic bus idempotency key of ONE attempt's draft: a re-announce of the same run dedupes at the bus, while a
333
+ * second attempt at the same document (a proposal's Try again, DES-CHAT-ARTIFACTS-001 §4.3) announces under its own key.
334
+ * Before A3 the key was per document (`…:v1`), so the bus would have dropped a retry's landing as a duplicate.
335
+ */
336
+ export function draftIdempotencyKey(documentId, projectId, runId) {
325
337
  const partition = projectPartitionOf(projectId);
326
338
  // crew#809: the bus resolves a repeated key to the existing row, so a same-slug document in
327
339
  // ANOTHER project must announce under its own key or its draft.completed is silently dropped.
328
340
  return partition === '_unfiled'
329
- ? `crew:interactive.draft:${documentId}:v1`
330
- : `crew:interactive.draft:${documentId}:${partition}:v1`;
341
+ ? `crew:interactive.draft:${documentId}:${runId}`
342
+ : `crew:interactive.draft:${documentId}:${partition}:${runId}`;
343
+ }
344
+ /** The handoff-ledger key of a proposal-bound attempt (one per run: a document may have several attempts), with the
345
+ * project partition like every draft key (crew#809). It starts `<doc>:` like every handoff key, so the doc↔run index
346
+ * and the delete sweep see it, and {@link isAnotherProjectsDraftKey} keeps another project's. */
347
+ export function boundAttemptKey(documentId, projectId, runId) {
348
+ return `${documentId}:attempt:${projectPartitionOf(projectId)}:${runId}`;
349
+ }
350
+ /** The folder of one proposal-bound attempt under the document's run directory: its deliverable, `sources/` and
351
+ * `records/`. A fresh one per attempt, and an earlier attempt's is removed before a new one starts. */
352
+ export function attemptDirName(runId) {
353
+ return `attempt-${runId}`;
331
354
  }
332
355
  // ── Per-project identity of a draft (crew#809) ────────────────────────────────────────────────
333
356
  //
@@ -354,6 +377,9 @@ export function isAnotherProjectsDraftKey(key, documentId, projectId) {
354
377
  const own = draftHandoffKey(documentId, projectId);
355
378
  if (key === own)
356
379
  return false;
380
+ // A3: a proposal-bound attempt's row is this document's only when its partition is this project's.
381
+ if (key.startsWith(`${documentId}:attempt:`))
382
+ return !key.startsWith(`${documentId}:attempt:${projectPartitionOf(projectId)}:`);
357
383
  if (key.startsWith(`${documentId}:draft:`))
358
384
  return true;
359
385
  return key === documentId && own !== documentId; // the unfiled document's row, when this one is filed
@@ -380,6 +406,137 @@ export function isStudioScratchDoc(doc) {
380
406
  // this leg keys it by DOCUMENT ID — one first draft per document lifetime. Re-exported here so
381
407
  // the seam's public surface stays one module.
382
408
  export { InteractiveHandoffLedger } from './ledger.js';
409
+ /**
410
+ * A3 (§5.3): give one attempt's inbox crew's copy of its source packet, in a fresh attempt folder. The document's run
411
+ * folder is the trusted root (a writer can write only inside it, never rename it); everything below it, the attempt
412
+ * folder included, is made and opened with A1's `beneath`, following no link at any component.
413
+ */
414
+ export function writePacketCopy(runDir, attempt, packet) {
415
+ const root = realpathSync(runDir);
416
+ mkdirBeneath(root, attempt);
417
+ for (const d of ['sources', 'records']) {
418
+ const st = (() => {
419
+ try {
420
+ return statBeneath(root, `${attempt}/${d}`);
421
+ }
422
+ catch {
423
+ return null;
424
+ }
425
+ })();
426
+ if (st === null)
427
+ mkdirSync(join(root, attempt, d), { mode: 0o700 });
428
+ if (statBeneath(root, `${attempt}/${d}`)?.isDirectory() !== true)
429
+ throw new Error(`${attempt}/${d} is not a real folder`);
430
+ }
431
+ for (const f of packet.files)
432
+ createBeneath(root, `${attempt}/${packetDirOf(f.name)}/${f.name}`, f.bytes, 0o600);
433
+ }
434
+ /** Remove one entry of the document's run folder (an attempt folder), never through a link: a link a writer put there
435
+ * is removed itself, never followed. Best effort: a leftover is a disk wart, never a correctness one. */
436
+ function removeEntry(runDir, name) {
437
+ let root;
438
+ try {
439
+ root = realpathSync(runDir);
440
+ }
441
+ catch {
442
+ return true; // no run folder: nothing of the attempt is there
443
+ }
444
+ const p = join(root, name);
445
+ try {
446
+ const st = lstatSync(p);
447
+ rmSync(p, st.isSymbolicLink() || !st.isDirectory() ? { force: true } : { recursive: true, force: true });
448
+ }
449
+ catch (err) {
450
+ if (err.code !== 'ENOENT')
451
+ return false;
452
+ }
453
+ try {
454
+ lstatSync(p);
455
+ return false; // still there
456
+ }
457
+ catch {
458
+ return true;
459
+ }
460
+ }
461
+ /** Remove the named attempts' folders under a document's run directory (A3): only attempts of THIS document's binding,
462
+ * so a same-named document elsewhere never loses its own. Returns the folders that could NOT be removed. */
463
+ export function removeAttemptDirs(runDir, runIds) {
464
+ const left = [];
465
+ for (const id of runIds) {
466
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id))
467
+ continue;
468
+ if (!removeEntry(runDir, attemptDirName(id)))
469
+ left.push(attemptDirName(id));
470
+ }
471
+ return left;
472
+ }
473
+ /** Drop an attempt's packet copy (crew keeps the canonical one); its deliverable stays for interactive to read. Best
474
+ * effort, never through a link (a writer could have replaced the attempt folder). */
475
+ export function removePacketCopy(runDir, attempt) {
476
+ let root;
477
+ try {
478
+ root = realpathSync(runDir);
479
+ const st = lstatSync(join(root, attempt));
480
+ if (st.isSymbolicLink() || !st.isDirectory())
481
+ return;
482
+ }
483
+ catch {
484
+ return;
485
+ }
486
+ // Each folder on its own: one already gone never keeps the other.
487
+ for (const d of ['sources', 'records']) {
488
+ try {
489
+ const p = join(root, attempt, d);
490
+ const sd = lstatSync(p);
491
+ rmSync(p, sd.isSymbolicLink() || !sd.isDirectory() ? { force: true } : { recursive: true, force: true });
492
+ }
493
+ catch {
494
+ /* absent, or not removable: best effort */
495
+ }
496
+ }
497
+ }
498
+ /**
499
+ * The first packet file whose bytes no longer match crew's record (missing, a link anywhere below the run folder, not
500
+ * a regular file, not its recorded size), or an entry crew never put there, else `null`. Each read is bounded by the
501
+ * recorded size and never blocks (a FIFO is refused).
502
+ */
503
+ export function packetChanged(runDir, attempt, files) {
504
+ let root;
505
+ try {
506
+ root = realpathSync(runDir);
507
+ }
508
+ catch {
509
+ return files[0]?.name ?? null;
510
+ }
511
+ for (const f of files) {
512
+ try {
513
+ const { data } = readBeneath(root, `${attempt}/${packetDirOf(f.name)}/${f.name}`, f.bytes);
514
+ if (data.length !== f.bytes || createHash('sha256').update(data).digest('hex') !== f.sha256)
515
+ return f.name;
516
+ }
517
+ catch {
518
+ return f.name;
519
+ }
520
+ }
521
+ // Nothing extra: a file the writer added to records/ would otherwise be a citable "record".
522
+ const want = new Set(files.map((f) => `${packetDirOf(f.name)}/${f.name}`));
523
+ for (const d of ['sources', 'records']) {
524
+ const listed = (() => {
525
+ try {
526
+ return listBeneath(root, `${attempt}/${d}`, 4096);
527
+ }
528
+ catch {
529
+ return null;
530
+ }
531
+ })();
532
+ if (listed === null || listed.more)
533
+ return `${d}/`;
534
+ for (const n of listed.names)
535
+ if (!want.has(`${d}/${n}`))
536
+ return `${d}/${n}`;
537
+ }
538
+ return null;
539
+ }
383
540
  /** The seam's durable state (handoff ledger + working dirs) follows the daemon's state home —
384
541
  * the `--db` parent when configured, `~/.wicked-crew` otherwise (crew#353): an isolated
385
542
  * daemon's interactive handoffs must not land in the operator's real home. */
@@ -443,6 +600,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
443
600
  // Keyed by the per-project handoff key (crew#809: two projects finalizing the same slug must not
444
601
  // clear each other's marker); the VALUE is the document id `inFlightDocs()` reports.
445
602
  const finalizing = new Map();
603
+ /** A3: runs whose finalize (floor, announce) is under way: a redelivered terminal frame is not an orphan's. */
604
+ const finalizingRuns = new Set();
446
605
  let closed = false; // set by stop(): a handler mid-snapshot must never launch after shutdown
447
606
  /** Emit onto interactive's vocabulary as the `wi-crew` producer. Never throws into the
448
607
  * caller: narration/announce failures are logged — a lost status line must not kill the
@@ -518,6 +677,20 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
518
677
  }
519
678
  }
520
679
  }
680
+ /** A3: record a proposal-bound attempt's outcome on its document's binding (the first outcome counts), then tell the
681
+ * daemon (release, retry proposal, `landing_failed` on the run). */
682
+ function reportBound(flight, runId, outcome) {
683
+ if (flight.bound === undefined)
684
+ return;
685
+ if (opts.bindings?.setOutcome(flight.bound.ref, runId, outcome) !== true)
686
+ return;
687
+ try {
688
+ opts.onBoundOutcome?.(flight.bound.ref, runId, outcome);
689
+ }
690
+ catch (err) {
691
+ log(`[interactive-draft] recording the outcome of run ${runId} failed: ${err instanceof Error ? err.message : String(err)}`);
692
+ }
693
+ }
521
694
  /** Terminal-event fold: turn the governed run's own events into interactive narration, and
522
695
  * close the loop with `draft.completed` when the run lands. */
523
696
  const foldEvent = (event) => {
@@ -528,8 +701,25 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
528
701
  if (planned?.observe(event) === true)
529
702
  return;
530
703
  const flight = inFlight.get(runId);
531
- if (flight === undefined)
704
+ if (flight === undefined) {
705
+ // A3 (§6.2): a proposal-bound run this process holds no flight for (a restart lost it, and the boot reconcile
706
+ // could not see it end in time) still ends its attempt: released, Try again offered. Never relaunched.
707
+ if ((event.type === 'sessionCompleted' || event.type === 'sessionFailed' || event.type === 'runCancelled') && opts.bindings !== undefined && !finalizingRuns.has(runId)) {
708
+ const hit = opts.bindings.attemptOf(runId);
709
+ if (hit !== null && hit.attempt.outcome === undefined && hit.attempt.emittedAt === undefined) {
710
+ const outcome = { kind: 'failed', reason: 'interrupted by a restart' };
711
+ if (opts.bindings.setOutcome(hit.binding.ref, runId, outcome)) {
712
+ try {
713
+ opts.onBoundOutcome?.(hit.binding.ref, runId, outcome);
714
+ }
715
+ catch (err) {
716
+ log(`[interactive-draft] recording the outcome of run ${runId} failed: ${err instanceof Error ? err.message : String(err)}`);
717
+ }
718
+ }
719
+ }
720
+ }
532
721
  return;
722
+ }
533
723
  // F-4R2-005: every narration line and heartbeat from here carries the run id and the ord of the
534
724
  // latest unit-scoped frame (`narrationStamps`), so a skin keys the thread per run and per unit.
535
725
  flight.runId ??= runId;
@@ -636,10 +826,14 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
636
826
  endFlight(runId);
637
827
  // The doc stays busy until the floor and the announce are done (codex on crew#725).
638
828
  finalizing.set(draftHandoffKey(flight.documentId, flight.projectId), flight.documentId);
829
+ finalizingRuns.add(runId);
639
830
  // The announce awaits the bus writer; a throw is logged, as a synchronous one was.
640
831
  finalize(flight, runId)
641
832
  .catch((err) => log(`[interactive-draft] finalizing run ${runId} failed: ${err instanceof Error ? err.message : String(err)}`))
642
- .finally(() => finalizing.delete(draftHandoffKey(flight.documentId, flight.projectId)));
833
+ .finally(() => {
834
+ finalizing.delete(draftHandoffKey(flight.documentId, flight.projectId));
835
+ finalizingRuns.delete(runId);
836
+ });
643
837
  return;
644
838
  }
645
839
  if (event.type === 'stepFailed') {
@@ -653,7 +847,7 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
653
847
  if (event.type === 'sessionFailed' || event.type === 'runCancelled') {
654
848
  endFlight(runId);
655
849
  removeSnapshots(flight); // the failure path cleans its snapshots too (CREW-UX-8 v4)
656
- ledger.recordFailure(draftHandoffKey(flight.documentId, flight.projectId));
850
+ ledger.recordFailure(flight.ledgerKey ?? draftHandoffKey(flight.documentId, flight.projectId));
657
851
  const why = flight.failureDetail !== undefined ? ` Reason: ${oneLine(flight.failureDetail, 600)}` : '';
658
852
  emitStatus({
659
853
  ...docScope(flight.documentId, flight.projectId),
@@ -661,6 +855,12 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
661
855
  message: `The crew run answering this document ${event.type === 'runCancelled' ? 'was cancelled' : 'failed'} ` +
662
856
  `(run ${runId}).${why} Inspect it via the crew API (GET /api/v1/runs/${runId}); the assist loop can still take over.`,
663
857
  });
858
+ if (flight.bound !== undefined)
859
+ removePacketCopy(flight.bound.runDir, flight.bound.attempt);
860
+ reportBound(flight, runId, {
861
+ kind: 'failed',
862
+ reason: `${event.type === 'runCancelled' ? 'the run was stopped' : 'the run failed'}${flight.failureDetail !== undefined ? `: ${oneLine(flight.failureDetail, 300)}` : ''}`,
863
+ });
664
864
  log(`[interactive-draft] run ${runId} for doc ${flight.documentId} ended: ${event.type}`);
665
865
  }
666
866
  };
@@ -678,26 +878,68 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
678
878
  if (!ok) {
679
879
  // The run is terminal — its grounding snapshots are done serving reads.
680
880
  removeSnapshots(flight);
681
- ledger.recordFailure(draftHandoffKey(documentId, projectId));
881
+ ledger.recordFailure(flight.ledgerKey ?? draftHandoffKey(documentId, projectId));
682
882
  emitStatus({
683
883
  ...docScope(documentId, projectId),
684
884
  state: 'error',
685
885
  message: `The crew run completed but produced no draft file at ${outPath} (run ${runId}).`,
686
886
  });
887
+ if (flight.bound !== undefined)
888
+ removePacketCopy(flight.bound.runDir, flight.bound.attempt);
889
+ reportBound(flight, runId, { kind: 'failed', reason: 'the run finished without writing the draft file' });
687
890
  log(`[interactive-draft] run ${runId} completed but ${outPath} is missing/empty`);
688
891
  return;
689
892
  }
893
+ // A3 (§5.3): the source packet the run was given is checked against the hashes crew holds before anything lands —
894
+ // a changed or missing file fails the landing (the reviewing step was told to check them too).
895
+ if (flight.bound !== undefined) {
896
+ const changed = packetChanged(flight.bound.runDir, flight.bound.attempt, flight.bound.files);
897
+ if (changed !== null) {
898
+ removeSnapshots(flight);
899
+ ledger.recordFailure(flight.ledgerKey ?? draftHandoffKey(documentId, projectId));
900
+ emitStatus({
901
+ ...docScope(documentId, projectId),
902
+ state: 'error',
903
+ message: `The draft was not landed: its source file ${changed} changed during the run (run ${runId}), so crew cannot vouch for what it was made from.`,
904
+ });
905
+ removePacketCopy(flight.bound.runDir, flight.bound.attempt);
906
+ reportBound(flight, runId, { kind: 'failed', reason: `the source packet changed during the run (${changed})` });
907
+ return;
908
+ }
909
+ }
910
+ let floorRecords = null;
911
+ if (flight.bound !== undefined && opts.bindings !== undefined) {
912
+ try {
913
+ floorRecords = mkdtempSync(join(tmpdir(), 'crew-floor-records-'));
914
+ for (const f of flight.bound.files) {
915
+ if (packetDirOf(f.name) !== 'records')
916
+ continue;
917
+ writeFileSync(join(floorRecords, f.name), opts.bindings.packetBytes(flight.bound.ref, f.name), { mode: 0o600, flag: 'wx' });
918
+ }
919
+ }
920
+ catch (err) {
921
+ log(`[interactive-draft] run ${runId}: crew's copy of the records for the floor failed: ${err instanceof Error ? err.message : String(err)}`);
922
+ }
923
+ }
690
924
  // THE DRAFT FLOOR (crew#621 / crew#504): the document's own verdict, re-derived by crew from
691
925
  // the artifact before it is published — the same `wicked-garden-draft` self-check the worker
692
926
  // was told to run, on the same inputs (hence BEFORE the snapshots go: the claims scan traces
693
927
  // every number and URL to them). A breach fails the draft naming the rule; a floor that could
694
928
  // not run is published with that said, never rounded to a pass. Every run is held to it: the
695
929
  // preset always runs the skill, and a snapshot without it fails the run (crew#935).
696
- const floorVerdict = await runDraftFloor(outPath, flight.floor?.budget ?? { pages: null, exact: false, source: 'unknown' }, flight.snapshotDirs, { ...(flight.floor !== undefined ? { style: flight.floor.style } : {}) }, opts.draftFloorIo ?? {});
930
+ const floorVerdict = await runDraftFloor(outPath, flight.floor?.budget ?? { pages: null, exact: false, source: 'unknown' },
931
+ // A3: the packet's records are a claims source too (the run briefs, documents and attachments a figure may cite),
932
+ // read from crew's OWN copy of exactly the verified files, never the writable inbox.
933
+ [...flight.snapshotDirs, ...(floorRecords !== null ? [floorRecords] : [])], { ...(flight.floor !== undefined ? { style: flight.floor.style } : {}) }, opts.draftFloorIo ?? {});
697
934
  removeSnapshots(flight);
935
+ if (floorRecords !== null)
936
+ rmSync(floorRecords, { recursive: true, force: true });
937
+ // The packet served its last read: crew keeps the canonical copy, the inbox keeps only the deliverable.
938
+ if (flight.bound !== undefined)
939
+ removePacketCopy(flight.bound.runDir, flight.bound.attempt);
698
940
  log(`[interactive-draft] run ${runId} draft floor: ${floorVerdict.verdict} — ${floorVerdict.summary}`);
699
941
  if (floorVerdict.verdict === 'fail') {
700
- ledger.recordFailure(draftHandoffKey(documentId, projectId));
942
+ ledger.recordFailure(flight.ledgerKey ?? draftHandoffKey(documentId, projectId));
701
943
  emitStatus({
702
944
  ...docScope(documentId, projectId),
703
945
  state: 'error',
@@ -705,6 +947,7 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
705
947
  `${floorVerdict.breaches.join(' ')} The file crew checked is at ${outPath}; ask again — ` +
706
948
  `the brief's hard constraints are part of the deliverable, not a preference.`,
707
949
  });
950
+ reportBound(flight, runId, { kind: 'failed', reason: `the draft did not meet the document floor: ${oneLine(floorVerdict.breaches.join(' '), 300)}` });
708
951
  return;
709
952
  }
710
953
  if (floorVerdict.verdict === 'unverified' || floorVerdict.verdict === 'unavailable') {
@@ -717,22 +960,35 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
717
960
  }
718
961
  // ADR-0019 D5: announce by path — the service reads the file itself, so a large draft
719
962
  // never rides the bus payload. The deterministic key makes a re-announce a no-op (the key resolves to the existing row).
720
- const emitted = await emitInteractive(DRAFT_COMPLETED, { ...docScope(documentId, projectId), html_path: outPath }, draftIdempotencyKey(documentId, projectId));
963
+ // A3: the bound attempt is announced: from here only a landing (`version.created`) or a refusal ends it. Marked
964
+ // BEFORE the emit, so a restart in between is reconciled against the document's head (`server.ts`).
965
+ // An attempt that already has an outcome (recorded some other way meanwhile) is never announced after it.
966
+ if (flight.bound !== undefined && opts.bindings?.attemptOf(runId)?.attempt.outcome !== undefined) {
967
+ log(`[interactive-draft] run ${runId}: its attempt already has an outcome — not announced`);
968
+ return;
969
+ }
970
+ if (flight.bound !== undefined)
971
+ opts.bindings?.markEmitted(flight.bound.ref, runId);
972
+ const emitted = await emitInteractive(DRAFT_COMPLETED,
973
+ // `run_id` (DES §12): which attempt this draft is; interactive ≥ the I1 contract records it on the version. A bound
974
+ // attempt also says the head it started from (`expect_head`): that contract lands nothing on a moved head.
975
+ { ...docScope(documentId, projectId), html_path: outPath, run_id: runId, ...(flight.bound !== undefined ? { expect_head: flight.bound.base } : {}) }, draftIdempotencyKey(documentId, projectId, runId));
721
976
  if (!emitted) {
722
977
  // The bus refused the announce: the draft exists on disk but never reached
723
978
  // the service. Fail HONEST — leaving the ledger row launched-but-never-closed would
724
979
  // silently eat every replay of this doc (the launch gate is `ledger.has`).
725
- ledger.recordFailure(draftHandoffKey(documentId, projectId));
980
+ ledger.recordFailure(flight.ledgerKey ?? draftHandoffKey(documentId, projectId));
726
981
  emitStatus({
727
982
  ...docScope(documentId, projectId),
728
983
  state: 'error',
729
984
  message: `Crew finished the draft but could not announce it on the bus (run ${runId}); ` +
730
985
  `the draft file is at ${outPath}. Inspect the crew daemon log.`,
731
986
  });
987
+ reportBound(flight, runId, { kind: 'failed', reason: 'crew could not announce the draft on the bus' });
732
988
  log(`[interactive-draft] draft.completed emit FAILED for doc ${documentId} (run ${runId}) — recorded as failure`);
733
989
  return;
734
990
  }
735
- ledger.recordEmitted(draftHandoffKey(documentId, projectId));
991
+ ledger.recordEmitted(flight.ledgerKey ?? draftHandoffKey(documentId, projectId));
736
992
  emitStatus({
737
993
  ...docScope(documentId, projectId),
738
994
  state: 'complete',
@@ -751,6 +1007,16 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
751
1007
  `no draft run is launched for it (crew#811)`);
752
1008
  return;
753
1009
  }
1010
+ // A3: a document a chat proposal made is never answered with a fresh random-id launch. Its Go recorded the binding
1011
+ // BEFORE the create, so the trigger finds it and hands the document to the proposal's one launcher per run id,
1012
+ // which adopts the run Go already started (or starts the one a lost trigger never did).
1013
+ const boundRef = `${doc.projectId ?? UNFILED_MOUNT}/${doc.documentId}`;
1014
+ if (opts.bindings?.get(boundRef) != null) {
1015
+ log(`[interactive-draft] doc ${doc.documentId}${where} was made by a chat proposal — its launcher answers it`);
1016
+ // Not awaited: the launcher waits for Go's own launch (the run id's lock), which must not hold up other documents.
1017
+ void (opts.onBoundDocCreated?.(boundRef, doc.documentId) ?? Promise.resolve()).catch((err) => log(`[interactive-draft] doc ${doc.documentId}${where}: its proposal's launcher failed: ${err instanceof Error ? err.message : String(err)}`));
1018
+ return;
1019
+ }
754
1020
  // Replay-dedup: the ledger is the durable gate (redelivery after crash/restart), the
755
1021
  // in-flight scan the live one (redelivery inside a single process lifetime). Keyed per
756
1022
  // PROJECT (crew#809): the same slug in another project is another document.
@@ -763,6 +1029,14 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
763
1029
  if (f.documentId === doc.documentId && f.projectId === doc.projectId)
764
1030
  return;
765
1031
  }
1032
+ await launchDraft(doc, randomUUID(), key, undefined);
1033
+ }
1034
+ /**
1035
+ * One draft launch: the plain path (a random run id, the doc's create-time grounding) and a proposal-bound one (A3:
1036
+ * the derived run id, the chat's scope repositories and graph, the card's helpers, the source packet). Throws when
1037
+ * nothing was launched.
1038
+ */
1039
+ async function launchDraft(doc, runId, key, bound) {
766
1040
  // Per-run ISOLATION (Copilot, crew#313): every launch gets its OWN subdirectory holding
767
1041
  // both the deliverable and (when grounded) the repo snapshot, and declares ONLY that
768
1042
  // subdirectory as its extra write root below. Declaring the shared draftDir wholesale let
@@ -771,8 +1045,10 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
771
1045
  // NOT created here (Copilot round 2): runDir is only mkdir'd after the snapshot helper's
772
1046
  // containment check has ruled the location safe — see the pre-launch mkdir below.
773
1047
  const runDir = join(draftDir, doc.documentId);
774
- const outPath = join(runDir, `${doc.documentId}-v1.html`);
775
- const runId = randomUUID();
1048
+ // A3: a proposal-bound attempt works in its own folder: its deliverable and packet never share a name with an
1049
+ // earlier attempt's (or a same-named document's in another project, once that attempt ended).
1050
+ const attemptDir = bound !== undefined ? join(runDir, attemptDirName(runId)) : runDir;
1051
+ const outPath = join(attemptDir, `${doc.documentId}-v1.html`);
776
1052
  // PRE-LAUNCH placeholder (Copilot round 2): the awaits below (repo resolution, snapshot)
777
1053
  // open a window in which this doc has no `inFlight` entry — so the chat seam's `isDocBusy`
778
1054
  // saw it idle (double-launch race) and stop()'s sweep could not find a half-made snapshot.
@@ -785,6 +1061,10 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
785
1061
  snapshotDirs: [],
786
1062
  narration: 'Crew run launched — working on your draft…',
787
1063
  floor: { budget: pageBudgetFor(doc.brief, doc.style), style: doc.style },
1064
+ ledgerKey: key,
1065
+ ...(bound !== undefined
1066
+ ? { bound: { ref: bound.artifactRef, runDir, attempt: attemptDirName(runId), attemptDir, files: bound.packet.files.map((f) => ({ name: f.name, sha256: f.sha256, bytes: f.bytes.length })), base: bound.base } }
1067
+ : {}),
788
1068
  };
789
1069
  inFlight.set(runId, flight);
790
1070
  // F-046: WHICH repositories is this document about? The create request's `repo_ref(s)` (the
@@ -797,13 +1077,18 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
797
1077
  // frame goes unanswered — fail closed, like the sibling seams' docs-root reads. An UNFILED doc
798
1078
  // reads its binding too (studio#302): the proxy records the create's seat choice and
799
1079
  // provenance under the default mount, and only the repo grounding is project-only.
800
- const binding = groundingStore !== undefined
1080
+ const binding = groundingStore !== undefined && bound === undefined
801
1081
  ? await groundingStore.waitFor(resolveDocsRoot(doc.projectId), doc.documentId, doc.projectId ?? DEFAULT_PROJECT_ID, GROUNDING_BINDING_WAIT_MS)
802
1082
  : undefined;
803
- const docClisJson = binding?.clis_json;
804
- const docChannel = binding?.channel;
805
- const docActor = binding?.actor;
806
- if (doc.projectId !== undefined) {
1083
+ const docClisJson = bound?.clisJson ?? binding?.clis_json;
1084
+ const docChannel = bound !== undefined ? 'studio' : binding?.channel;
1085
+ const docActor = bound?.actor ?? binding?.actor;
1086
+ if (bound !== undefined) {
1087
+ // DES §5.2: a chat-made document is grounded on the chat's scope repositories, filed OR Unfiled (the accepted
1088
+ // record is what lifted the Unfiled rule at the create).
1089
+ decision = { repos: bound.repos, source: 'named', missing: [], ambiguous: [], memberCount: bound.repos.length };
1090
+ }
1091
+ else if (doc.projectId !== undefined) {
807
1092
  decision = await resolveGroundingRepos(adapter, doc.projectId, doc.brief, binding?.repo_refs, log);
808
1093
  }
809
1094
  emitStatus({
@@ -917,9 +1202,9 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
917
1202
  // crew#512: the same facts as DATA — recorded beside the doc first (a skin that refetches the
918
1203
  // docs list on this frame finds it there), then on the narration frame itself. The record is
919
1204
  // written even when there is no line to say (a repo-less project): the list still answers.
920
- if (decision !== undefined && doc.projectId !== undefined) {
1205
+ if (decision !== undefined && (doc.projectId !== undefined || bound !== undefined)) {
921
1206
  const record = groundingRecord(decision, snapshotted);
922
- if (groundingStore !== undefined) {
1207
+ if (groundingStore !== undefined && doc.projectId !== undefined) {
923
1208
  try {
924
1209
  groundingStore.recordGrounding(resolveDocsRoot(doc.projectId), doc.documentId, doc.projectId, record);
925
1210
  }
@@ -939,10 +1224,35 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
939
1224
  endFlight(runId);
940
1225
  removeSnapshots(flight);
941
1226
  log(`[interactive-draft] doc ${doc.documentId}: subscriber stopped before launch — abandoned (a replay retries)`);
1227
+ // A bound launch reports it: its proposal stays accepting, and Go again resumes the same identity.
1228
+ if (bound !== undefined)
1229
+ throw new Error('the daemon is stopping; nothing was launched');
942
1230
  return;
943
1231
  }
944
1232
  // Containment is settled (any repo overlap refused above) — the run's write root may exist.
945
1233
  mkdirSync(runDir, { recursive: true });
1234
+ let packetLine = '';
1235
+ if (bound !== undefined) {
1236
+ // A3: an earlier attempt's folder goes first (its deliverable can never pass this attempt's floor, and an
1237
+ // announce of it still on the bus can no longer land: its file is gone), then this attempt gets crew's copy of its
1238
+ // packet, through no link (§5.3).
1239
+ try {
1240
+ // (CreateLauncher fenced the earlier attempts before it read the base; this repeats it, idempotently, and clears
1241
+ // what an earlier try of THIS attempt left if the engine refused it: Go again starts from a fresh folder.)
1242
+ const left = removeAttemptDirs(runDir, (opts.bindings?.get(bound.artifactRef)?.attempts ?? []).map((a) => a.runId));
1243
+ if (left.length > 0)
1244
+ throw new Error(`an earlier attempt's folder could not be removed (${left.join(', ')})`);
1245
+ writePacketCopy(runDir, attemptDirName(runId), bound.packet);
1246
+ }
1247
+ catch (err) {
1248
+ endFlight(runId);
1249
+ removeSnapshots(flight);
1250
+ const message = `Crew could not give this draft its sources: ${err instanceof Error ? err.message : String(err)}.`;
1251
+ emitStatus({ ...docScope(doc.documentId, doc.projectId), state: 'error', message });
1252
+ throw new Error(message);
1253
+ }
1254
+ packetLine = ' ' + packetClause(attemptDir, bound.packet, PROCESS_NOTES_WHERE);
1255
+ }
946
1256
  // Resolve the project's graph BEFORE the launch, never indexing (a refresh is
947
1257
  // `wicked-estate index` per member, bounded at 600s EACH — doing that inside a launch turns
948
1258
  // "start a draft" into an unannounced multi-repo job). Missing or stale degrades to no
@@ -955,7 +1265,11 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
955
1265
  // but says so — a silent `catch(() => null)` would make a broken binding indistinguishable
956
1266
  // from a project that simply has no graph yet.
957
1267
  let projectGraphBinding = null;
958
- if (doc.projectId !== undefined) {
1268
+ if (bound !== undefined) {
1269
+ // The stored graph binding (DES §5.2), whatever the mount.
1270
+ projectGraphBinding = bound.graphDb !== null ? { dbPath: bound.graphDb } : null;
1271
+ }
1272
+ else if (doc.projectId !== undefined) {
959
1273
  const decision = await resolveProjectGraphBinding(adapter, doc.projectId, undefined).catch((err) => ({
960
1274
  binding: null,
961
1275
  reason: `the project graph binding could not be resolved ` +
@@ -965,6 +1279,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
965
1279
  projectGraphBinding = decision.binding;
966
1280
  log(`run ${runId}: ${decision.reason}`);
967
1281
  }
1282
+ if (bound !== undefined)
1283
+ opts.bindings?.markLaunching(bound.artifactRef, runId);
968
1284
  try {
969
1285
  unitsByRun.set(runId, new RunUnits()); // before the launch: its first plan is announced during it
970
1286
  await adapter.launchRun({
@@ -980,7 +1296,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
980
1296
  : undefined) +
981
1297
  // The quality floor's inputs (draft-skill.ts): the preset's steps always run the skill.
982
1298
  ' ' +
983
- draftQualityClause(outPath, pageBudgetFor(doc.brief, doc.style), subjects.flatMap((s) => (s.snapshotDir !== undefined ? [s.snapshotDir] : [])), { style: doc.style }),
1299
+ draftQualityClause(outPath, pageBudgetFor(doc.brief, doc.style), [...subjects.flatMap((s) => (s.snapshotDir !== undefined ? [s.snapshotDir] : [])), ...(bound !== undefined ? [join(attemptDir, 'records')] : [])], { style: doc.style }) +
1300
+ packetLine,
984
1301
  sessionId: runId,
985
1302
  clisJson: docClisJson ?? opts.clisJson ?? JSON.stringify(rosterOf(adapter, opts.roster)),
986
1303
  workflow: INTERACTIVE_DRAFT_WORKFLOW,
@@ -1019,6 +1336,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
1019
1336
  }
1020
1337
  catch (err) {
1021
1338
  unitsByRun.delete(runId);
1339
+ if (bound !== undefined)
1340
+ opts.bindings?.clearLaunching(bound.artifactRef, runId);
1022
1341
  // A launch that never happened keeps no flight and no snapshot (a replayed frame
1023
1342
  // re-registers and re-snapshots fresh).
1024
1343
  endFlight(runId);
@@ -1041,6 +1360,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
1041
1360
  // delivery retries. The crash window between launch and this write is the reason the
1042
1361
  // draft emit ALSO carries a deterministic idempotency key.
1043
1362
  ledger.recordLaunch(key, runId);
1363
+ if (bound !== undefined)
1364
+ opts.bindings?.markLaunched(bound.artifactRef, runId);
1044
1365
  if (closed) {
1045
1366
  // stop() ran while the engine was accepting the launch: its sweep already dropped the
1046
1367
  // placeholder and the snapshot, and the engine's workers die with the daemon — the
@@ -1053,6 +1374,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
1053
1374
  deliver: 'none', // crew#762: a document draft publishes a document, it never delivers a PR
1054
1375
  ...(docChannel !== undefined ? { channel: docChannel } : {}),
1055
1376
  ...(docActor !== undefined ? { actor: docActor } : {}),
1377
+ // A3 (DES §5.2 "Filing"): the run is filed under the chat, and says which proposal and artifact it is.
1378
+ ...(bound !== undefined ? { chatId: bound.chatId, proposalId: bound.proposalId, artifactRef: bound.artifactRef } : {}),
1056
1379
  });
1057
1380
  // Upgrade the placeholder to a live flight: the heartbeat starts once the run exists.
1058
1381
  flight.heartbeat = setInterval(() => {
@@ -1085,11 +1408,98 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
1085
1408
  pollIntervalMs: opts.pollIntervalMs ?? 2000,
1086
1409
  }),
1087
1410
  });
1411
+ // A3 (§6.2): a bound attempt's outcome is its LANDING, not the run's end: interactive's `version.created` for the
1412
+ // document after crew's announce (an interactive with the I1 contract also names the run; an older one is matched by
1413
+ // the document and its `generated` kind, as only one attempt per document is ever live), or its `draft.conflict`.
1414
+ async function handleLanding(event) {
1415
+ const bindings = opts.bindings;
1416
+ if (bindings === undefined || (event.event_type !== VERSION_CREATED && event.event_type !== DRAFT_CONFLICT))
1417
+ return;
1418
+ const p = (typeof event.payload === 'object' && event.payload !== null ? event.payload : {});
1419
+ const documentId = typeof p['document_id'] === 'string' ? p['document_id'] : '';
1420
+ if (!DOC_NAME.test(documentId))
1421
+ return;
1422
+ const projectId = typeof p['project_id'] === 'string' && p['project_id'] !== '' ? p['project_id'] : null;
1423
+ const runIdP = typeof p['run_id'] === 'string' && p['run_id'] !== '' ? p['run_id'] : null;
1424
+ const b = bindings.get(`${projectId ?? UNFILED_MOUNT}/${documentId}`);
1425
+ if (b === null)
1426
+ return;
1427
+ const version = typeof p['version'] === 'number' ? p['version'] : null;
1428
+ const ts = typeof p['ts'] === 'string' ? Date.parse(p['ts']) : NaN;
1429
+ const a = b.attempts.find((x) => x.outcome === undefined && x.emittedAt !== undefined &&
1430
+ (runIdP !== null
1431
+ ? x.runId === runIdP
1432
+ // An interactive before the I1 contract names no run: only a generated version past the attempt's base, made
1433
+ // after crew announced it (a delayed event from before cannot settle it), is its landing.
1434
+ : event.event_type === VERSION_CREATED && p['kind'] === 'generated' && version !== null && version > x.base &&
1435
+ !Number.isNaN(ts) && ts >= x.emittedAt));
1436
+ if (a === undefined)
1437
+ return;
1438
+ const outcome = event.event_type === VERSION_CREATED
1439
+ ? { kind: 'landed', version: version ?? 0 }
1440
+ : { kind: 'failed', reason: `the document changed while it was being drafted (it was at v${String(p['expect_head'] ?? '?')}, now v${String(p['actual_head'] ?? '?')}), so nothing landed` };
1441
+ if (!bindings.setOutcome(b.ref, a.runId, outcome))
1442
+ return;
1443
+ log(`[interactive-draft] run ${a.runId} for ${b.ref}: ${outcome.kind === 'landed' ? `landed v${outcome.version}` : outcome.reason}`);
1444
+ try {
1445
+ opts.onBoundOutcome?.(b.ref, a.runId, outcome);
1446
+ }
1447
+ catch (err) {
1448
+ log(`[interactive-draft] recording the outcome of run ${a.runId} failed: ${err instanceof Error ? err.message : String(err)}`);
1449
+ }
1450
+ }
1451
+ const landingTap = opts.bindings === undefined
1452
+ ? null
1453
+ : await tapBus({
1454
+ dbPath: busDbPath,
1455
+ filter: 'wicked.interactive.**@wicked-interactive',
1456
+ pollIntervalMs: opts.pollIntervalMs ?? 2000,
1457
+ handler: (event) => handleLanding(event),
1458
+ onError: busSubscriberErrorReporter({
1459
+ describe: (err, event) => `[interactive-draft] landing handler error on event ${String(event?.event_id ?? '?')}: ${err.message}`,
1460
+ log,
1461
+ logError: opts.logError,
1462
+ pollIntervalMs: opts.pollIntervalMs ?? 2000,
1463
+ }),
1464
+ }).catch((err) => {
1465
+ log(`[interactive-draft] could not watch landings (bound drafts settle at the next restart): ${err instanceof Error ? err.message : String(err)}`);
1466
+ return null;
1467
+ });
1088
1468
  return {
1089
1469
  ledger,
1090
1470
  inFlightDocs: () => [
1091
- ...new Set([...[...inFlight.values()].map((f) => f.documentId), ...finalizing.values()]),
1471
+ ...new Set([
1472
+ ...[...inFlight.values()].map((f) => f.documentId),
1473
+ ...finalizing.values(),
1474
+ // A3 (§6.2): a proposal-bound document stays busy until its attempt's outcome is recorded (announced and not
1475
+ // yet landed, or reconciling at boot), for every seam that serializes on this list.
1476
+ ...(opts.bindings?.all() ?? []).filter((b) => b.attempts.some((a) => a.outcome === undefined && (a.launchingAt !== undefined || a.launchedAt !== undefined || a.emittedAt !== undefined))).map((b) => b.doc),
1477
+ ]),
1092
1478
  ],
1479
+ fenceBound: (documentId, runIds) => {
1480
+ const left = removeAttemptDirs(join(draftDir, documentId), runIds);
1481
+ if (left.length > 0)
1482
+ throw new Error(`an earlier attempt of this document could not be fenced (${left.join(', ')} is still there)`);
1483
+ },
1484
+ launchBound: async (spec) => {
1485
+ if (closed)
1486
+ throw new Error('the draft seam is stopping');
1487
+ for (const f of inFlight.values()) {
1488
+ if (f.documentId === spec.documentId && f.projectId === spec.projectId) {
1489
+ throw new Error(`a draft of ${spec.artifactRef} is already running (run ${f.runId ?? 'starting'})`);
1490
+ }
1491
+ }
1492
+ if (finalizing.has(draftHandoffKey(spec.documentId, spec.projectId)))
1493
+ throw new Error(`a draft of ${spec.artifactRef} is still landing`);
1494
+ const doc = {
1495
+ documentId: spec.documentId,
1496
+ brief: spec.brief,
1497
+ sourcePaths: [],
1498
+ style: spec.style,
1499
+ ...(spec.projectId !== undefined ? { projectId: spec.projectId } : {}),
1500
+ };
1501
+ await launchDraft(doc, spec.runId, boundAttemptKey(spec.documentId, spec.projectId, spec.runId), spec);
1502
+ },
1093
1503
  stop: async () => {
1094
1504
  closed = true; // a handler mid-snapshot sees this and never launches (Copilot round 2)
1095
1505
  offCoreEvents();
@@ -1106,6 +1516,7 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
1106
1516
  removeSnapshots(flight);
1107
1517
  }
1108
1518
  finalizing.clear(); // nothing is finalizing after the subscriber detaches
1519
+ await landingTap?.stop();
1109
1520
  await sub.stop();
1110
1521
  },
1111
1522
  };