@turns/dev 0.0.24 → 0.0.26

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.
@@ -228,6 +228,8 @@ class ClaudeTransport extends LineTransport {
228
228
  'stream-json',
229
229
  '--verbose',
230
230
  '--include-partial-messages',
231
+ '--thinking-display',
232
+ 'summarized',
231
233
  '--permission-mode',
232
234
  nativePermissionMode,
233
235
  ...(anchor.permissionMode === 'bypassPermissions' ? [] : ['--permission-prompt-tool', 'stdio']),
@@ -294,6 +294,36 @@ function fileDescriptor(harness, file) {
294
294
  createdAt: (stat.birthtimeMs > 0 ? stat.birthtime : stat.mtime).toISOString(),
295
295
  };
296
296
  }
297
+ /** Resolve only ordinary transcript files from their authoritative store.
298
+ * Database projections require discovery; never interpret their source IDs as
299
+ * paths or allow a server-provided path outside the selected harness store. */
300
+ export function nativeDescriptorForSource(harness, sourcePath) {
301
+ const store = Object.hasOwn(EXTRA_FILE_STORES, harness) ? EXTRA_FILE_STORES[harness] : null;
302
+ if (!store || !sourcePath || sourcePath.includes('\0'))
303
+ return null;
304
+ const file = sourcePath.startsWith('~/') ? path.join(HOME, sourcePath.slice(2)) : sourcePath;
305
+ if (!path.isAbsolute(file))
306
+ return null;
307
+ const relative = path.relative(store.root, file);
308
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
309
+ return null;
310
+ if (!store.include(file))
311
+ return null;
312
+ // Discovery does not traverse symlinked entries. Keep this direct lookup
313
+ // from admitting a transcript that an inventory walk would never discover.
314
+ try {
315
+ let candidate = store.root;
316
+ for (const part of relative.split(path.sep)) {
317
+ candidate = path.join(candidate, part);
318
+ if (fs.lstatSync(candidate).isSymbolicLink())
319
+ return null;
320
+ }
321
+ }
322
+ catch {
323
+ return null;
324
+ }
325
+ return harness === 'fx' ? fxDescriptor(file) : fileDescriptor(harness, file);
326
+ }
297
327
  /** Fx's ledger path identifies the session; its adjacent projections provide
298
328
  * the workspace and display title without requiring a conversation scan. */
299
329
  function fxDescriptor(file) {
@@ -0,0 +1,58 @@
1
+ import { isMainThread, parentPort, Worker, workerData } from 'node:worker_threads';
2
+ import { discoverLocalSessions } from "./local-session-sources.js";
3
+ // Keep the synchronous store scans and their caches off the control socket's
4
+ // event loop. One worker serves all scans, including periodic rediscovery.
5
+ if (!isMainThread && workerData === 'turns-native-inventory') {
6
+ parentPort.on('message', ({ id, only }) => {
7
+ try {
8
+ parentPort.postMessage({ id, sessions: discoverLocalSessions(only) });
9
+ }
10
+ catch (error) {
11
+ parentPort.postMessage({ id, error: String(error?.stack || error) });
12
+ }
13
+ });
14
+ }
15
+ export class NativeInventoryWorker {
16
+ worker;
17
+ nextId = 0;
18
+ pending = new Map();
19
+ failure = null;
20
+ constructor(entry = new URL(import.meta.url)) {
21
+ this.worker = new Worker(entry, { workerData: 'turns-native-inventory' });
22
+ this.worker.on('message', ({ id, sessions, error }) => {
23
+ const request = this.pending.get(id);
24
+ if (!request)
25
+ return;
26
+ this.pending.delete(id);
27
+ if (error)
28
+ request.reject(new Error(error));
29
+ else
30
+ request.resolve(sessions);
31
+ if (!this.pending.size)
32
+ this.worker.unref();
33
+ });
34
+ this.worker.on('error', (error) => this.fail(error));
35
+ this.worker.on('exit', (code) => this.fail(new Error(`Native inventory worker exited (${code}).`)));
36
+ this.worker.unref();
37
+ }
38
+ fail(error) {
39
+ this.failure = error;
40
+ for (const request of this.pending.values())
41
+ request.reject(error);
42
+ this.pending.clear();
43
+ }
44
+ scan(only = '') {
45
+ if (this.failure)
46
+ return Promise.reject(this.failure);
47
+ const id = ++this.nextId;
48
+ this.worker.ref();
49
+ return new Promise((resolve, reject) => {
50
+ this.pending.set(id, { resolve, reject });
51
+ this.worker.postMessage({ id, only });
52
+ });
53
+ }
54
+ close() {
55
+ this.fail(new Error('Native inventory stopped.'));
56
+ return this.worker.terminate();
57
+ }
58
+ }
@@ -0,0 +1,66 @@
1
+ export const QUEUE_RESTART_MESSAGE = 'The local daemon restarted before your message was sent. Retrying automatically.';
2
+ /** The synchronous stopping/dispatch gates serialize shutdown against local
3
+ * delivery. Once delivery is uncertain, only anchor recovery may reconcile it. */
4
+ export class QueueDispatchLifecycle {
5
+ stopping = false;
6
+ acquiring = new Set();
7
+ handoffs = new Set();
8
+ claims = new Map();
9
+ stopped = null;
10
+ assertRunning() {
11
+ if (this.stopping)
12
+ throw new Error('The local daemon is restarting.');
13
+ }
14
+ async acquire(request, release) {
15
+ this.assertRunning();
16
+ const pending = request().then((response) => {
17
+ if (response.claim)
18
+ this.claims.set(response.claim, { started: false, release: () => release(response.claim) });
19
+ return response;
20
+ });
21
+ this.acquiring.add(pending);
22
+ try {
23
+ return await pending;
24
+ }
25
+ finally {
26
+ this.acquiring.delete(pending);
27
+ }
28
+ }
29
+ async dispatch(claim, send) {
30
+ this.assertRunning();
31
+ const held = this.claims.get(claim);
32
+ if (!held)
33
+ throw new Error('Queue dispatch has no tracked claim.');
34
+ held.started = true;
35
+ const pending = send();
36
+ this.handoffs.add(pending);
37
+ try {
38
+ return await pending;
39
+ }
40
+ finally {
41
+ this.handoffs.delete(pending);
42
+ }
43
+ }
44
+ forget(claim) {
45
+ this.claims.delete(claim);
46
+ }
47
+ stop() {
48
+ if (this.stopped)
49
+ return this.stopped;
50
+ this.stopping = true;
51
+ this.stopped = (async () => {
52
+ // A claim response can arrive after SIGTERM. Include it before releasing
53
+ // unsent work; new acquire/dispatch calls are already blocked above.
54
+ const releaseUnsent = () => [...this.claims.values()]
55
+ .filter((claim) => !claim.started)
56
+ .map((claim) => {
57
+ claim.releasing ||= Promise.resolve().then(claim.release);
58
+ return claim.releasing;
59
+ });
60
+ // Do not let one stalled claim response delay release of known claims.
61
+ await Promise.allSettled([...releaseUnsent(), ...this.acquiring]);
62
+ await Promise.allSettled([...releaseUnsent(), ...this.handoffs]);
63
+ })();
64
+ return this.stopped;
65
+ }
66
+ }
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { Readable } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
7
  import { logError } from "./logging.js";
8
+ import { QueueDispatchLifecycle, QUEUE_RESTART_MESSAGE } from "./queue-lifecycle.js";
8
9
  import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
9
10
  import { createCodexUpdateManager } from "./codex-update.js";
10
11
  import { isAsyncQuestionId } from "./codex-async-question-recovery.js";
@@ -352,7 +353,7 @@ export async function actualHarnessStatus(entry) {
352
353
  entry.snapshot = snapshot;
353
354
  return snapshot.status === 'running' ? 'running' : 'idle';
354
355
  }
355
- export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors, stableSessionId, redactor = DEFAULT_REDACTOR, }) {
356
+ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors, resolveDescriptor = async (_session) => null, descriptorsReady = Promise.resolve(), lifecycle = new QueueDispatchLifecycle(), stableSessionId, redactor = DEFAULT_REDACTOR, }) {
356
357
  const workerId = `${machine}:turns-queue-worker`;
357
358
  const workerInstanceId = crypto.randomUUID();
358
359
  const baseUrl = httpBaseUrl(options.url);
@@ -628,6 +629,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
628
629
  const runClaim = async (sessionId, claim, recovery = null) => {
629
630
  if (claims.has(sessionId))
630
631
  return;
632
+ if (!recovery)
633
+ lifecycle.assertRunning();
631
634
  // Publish the optimistic user turn before native-session lookup, attachment
632
635
  // download, or a cold anchor launch. The stable event key makes recovery
633
636
  // idempotent, and the outbound protocol frame will replace this placeholder.
@@ -640,9 +643,71 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
640
643
  let creating = false;
641
644
  let cwd = '';
642
645
  let entry = recovery?.entry;
646
+ const queued = {
647
+ ...claim,
648
+ entry,
649
+ renewTimer: null,
650
+ // Recovery only observes turns that an anchor already accepted. Treat
651
+ // an uncertain recovery failure conservatively for the same reason as a
652
+ // failed live turn: the prompt may already have produced side effects.
653
+ promptStarted: Boolean(recovery),
654
+ };
655
+ claims.set(sessionId, queued);
656
+ const renewClaim = () => request({
657
+ type: 'queue.renew',
658
+ sessionId,
659
+ promptId: claim.prompt.id,
660
+ claimToken: claim.claimToken,
661
+ });
662
+ const beginRenewal = () => {
663
+ if (queued.renewTimer)
664
+ return;
665
+ queued.renewTimer = setInterval(() => {
666
+ if (lifecycle.stopping) {
667
+ stopClaimRenewal(queued);
668
+ return;
669
+ }
670
+ if (entry?.connection.closed) {
671
+ stopClaimRenewal(queued);
672
+ void reportHarness(sessionId, 'stopped', entry).catch(() => { });
673
+ return;
674
+ }
675
+ void renewClaim().catch((error) => logError(`Queue lease renewal failed: ${error?.message || error}\n`));
676
+ // Never renew lifecycle state from an old in-memory snapshot. The
677
+ // anchor may have disconnected since the claim began, which would
678
+ // otherwise resurrect a canonical stopped edge as running.
679
+ if (!entry)
680
+ return;
681
+ void reportActualHarness(sessionId, entry).catch(async () => {
682
+ if (lifecycle.stopping) {
683
+ stopClaimRenewal(queued);
684
+ return;
685
+ }
686
+ if (entry.connection.closed) {
687
+ stopClaimRenewal(queued);
688
+ await reportHarness(sessionId, 'stopped', entry).catch(() => { });
689
+ }
690
+ });
691
+ }, CLAIM_RENEW_MS);
692
+ queued.renewTimer.unref?.();
693
+ };
694
+ beginRenewal();
695
+ // Verify and extend the inherited lease before waiting on a recovered
696
+ // anchor. If the server has already replaced it, normal claiming plus the
697
+ // anchor's prompt idempotency will reconcile the surviving turn instead.
698
+ if (recovery)
699
+ await renewClaim();
643
700
  if (!recovery) {
644
- const descriptorMap = descriptors();
645
- let discovered = descriptorMap.get(sessionId);
701
+ let descriptorMap = descriptors();
702
+ let discovered = descriptorMap.get(sessionId) || (await resolveDescriptor(claim.session));
703
+ if (!discovered && claim.session?.sourcePath) {
704
+ await descriptorsReady;
705
+ lifecycle.assertRunning();
706
+ descriptorMap = descriptors();
707
+ discovered =
708
+ descriptorMap.get(sessionId) ||
709
+ [...descriptorMap.values()].find((candidate) => candidate.harness === claim.session.harness && candidate.sourcePath === claim.session.sourcePath);
710
+ }
646
711
  const claimedAgentSessionId = String(claim.session?.agentSessionId || '');
647
712
  if (!discovered && claimedAgentSessionId) {
648
713
  for (const candidate of descriptorMap.values()) {
@@ -689,54 +754,12 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
689
754
  // Codex installation leaves a recoverable unsent prompt in the queue.
690
755
  if (descriptor.harness === 'codex')
691
756
  resolveHarnessCommand('codex');
757
+ lifecycle.assertRunning();
692
758
  entry = await usableAnchor(sessionId, descriptor.harness, agentSessionId, Boolean(claim.session?.modelSettings));
693
759
  }
694
760
  if (!entry)
695
761
  throw new Error(`No surviving agent anchor exists for ${sessionId}.`);
696
- const queued = {
697
- ...claim,
698
- entry,
699
- renewTimer: null,
700
- // Recovery only observes turns that an anchor already accepted. Treat
701
- // an uncertain recovery failure conservatively for the same reason as a
702
- // failed live turn: the prompt may already have produced side effects.
703
- promptStarted: Boolean(recovery),
704
- };
705
- claims.set(sessionId, queued);
706
- const renewClaim = () => request({
707
- type: 'queue.renew',
708
- sessionId,
709
- promptId: claim.prompt.id,
710
- claimToken: claim.claimToken,
711
- });
712
- const beginRenewal = () => {
713
- if (queued.renewTimer)
714
- return;
715
- queued.renewTimer = setInterval(() => {
716
- if (entry.connection.closed) {
717
- stopClaimRenewal(queued);
718
- void reportHarness(sessionId, 'stopped', entry).catch(() => { });
719
- return;
720
- }
721
- void renewClaim().catch((error) => logError(`Queue lease renewal failed: ${error?.message || error}\n`));
722
- // Never renew lifecycle state from an old in-memory snapshot. The
723
- // anchor may have disconnected since the claim began, which would
724
- // otherwise resurrect a canonical stopped edge as running.
725
- void reportActualHarness(sessionId, entry).catch(async () => {
726
- if (entry.connection.closed) {
727
- stopClaimRenewal(queued);
728
- await reportHarness(sessionId, 'stopped', entry).catch(() => { });
729
- }
730
- });
731
- }, CLAIM_RENEW_MS);
732
- queued.renewTimer.unref?.();
733
- };
734
- beginRenewal();
735
- // Verify and extend the inherited lease before waiting on a recovered
736
- // anchor. If the server has already replaced it, normal claiming plus the
737
- // anchor's prompt idempotency will reconcile the surviving turn instead.
738
- if (recovery)
739
- await renewClaim();
762
+ queued.entry = entry;
740
763
  const prompt = recovery?.prompt ||
741
764
  (await preparePrompt({
742
765
  prompt: claim.prompt,
@@ -761,33 +784,35 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
761
784
  const eventCursor = entry.connection.eventCursor();
762
785
  // Persist before local delivery: either acknowledgement can be lost
763
786
  // after acceptance. Uncertain delivery must never trigger automatic replay.
764
- queued.promptStarted = true;
765
- await request({
766
- type: 'queue.renew',
767
- sessionId,
768
- promptId: claim.prompt.id,
769
- claimToken: claim.claimToken,
770
- dispatchStarted: true,
787
+ await lifecycle.dispatch(claim, async () => {
788
+ queued.promptStarted = true;
789
+ await request({
790
+ type: 'queue.renew',
791
+ sessionId,
792
+ promptId: claim.prompt.id,
793
+ claimToken: claim.claimToken,
794
+ dispatchStarted: true,
795
+ });
796
+ await entry.connection.request('send_message', {
797
+ source: descriptor.harness,
798
+ sessionId,
799
+ agentSessionId,
800
+ cwd,
801
+ transcriptPath: descriptor.file,
802
+ workerId,
803
+ messageId: claim.prompt.id,
804
+ claimToken: claim.claimToken,
805
+ prompt,
806
+ expectedText: claim.prompt.text,
807
+ createSession: creating,
808
+ permissionMode: String(claim.session?.permissionMode || 'bypassPermissions'),
809
+ model: String(claim.session?.model || ''),
810
+ thinkingLevel: String(claim.session?.modelEffort || ''),
811
+ codexHistoryMode: String(claim.session?.codexHistoryMode || ''),
812
+ timeoutMs: QUEUE_TURN_TIMEOUT_MS,
813
+ idleTimeoutMs: QUEUE_WARM_MS,
814
+ }, 30_000);
771
815
  });
772
- await entry.connection.request('send_message', {
773
- source: descriptor.harness,
774
- sessionId,
775
- agentSessionId,
776
- cwd,
777
- transcriptPath: descriptor.file,
778
- workerId,
779
- messageId: claim.prompt.id,
780
- claimToken: claim.claimToken,
781
- prompt,
782
- expectedText: claim.prompt.text,
783
- createSession: creating,
784
- permissionMode: String(claim.session?.permissionMode || 'bypassPermissions'),
785
- model: String(claim.session?.model || ''),
786
- thinkingLevel: String(claim.session?.modelEffort || ''),
787
- codexHistoryMode: String(claim.session?.codexHistoryMode || ''),
788
- timeoutMs: QUEUE_TURN_TIMEOUT_MS,
789
- idleTimeoutMs: QUEUE_WARM_MS,
790
- }, 30_000);
791
816
  entry.snapshot = await entry.connection.request('snapshot', {}, 5_000).catch(() => entry.snapshot);
792
817
  // Registration after acceptance is safe: the connection retains recent
793
818
  // events and the snapshot carries a turn that completed immediately.
@@ -804,12 +829,19 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
804
829
  await finishClaim(sessionId, queued, entry, result);
805
830
  };
806
831
  const tryClaim = async (sessionId) => {
807
- if (claims.has(sessionId) || claimStarts.has(sessionId))
832
+ if (lifecycle.stopping || claims.has(sessionId) || claimStarts.has(sessionId))
808
833
  return;
809
834
  claimStarts.add(sessionId);
810
835
  let claim;
811
836
  try {
812
- const response = await request({ type: 'queue.claim', sessionId, dispatchTracking: true });
837
+ const response = await lifecycle.acquire(() => request({ type: 'queue.claim', sessionId, dispatchTracking: true }), (held) => request({
838
+ type: 'queue.fail',
839
+ sessionId,
840
+ promptId: held.prompt.id,
841
+ claimToken: held.claimToken,
842
+ error: QUEUE_RESTART_MESSAGE,
843
+ retryable: true,
844
+ }, 2_000));
813
845
  claim = response.claim;
814
846
  if (!claim)
815
847
  return;
@@ -833,7 +865,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
833
865
  else if (held?.entry) {
834
866
  await reportHarness(sessionId, 'stopped', held.entry).catch(() => { });
835
867
  }
836
- if (claim) {
868
+ if (claim && !lifecycle.stopping) {
837
869
  await request({
838
870
  type: 'queue.fail',
839
871
  sessionId,
@@ -846,6 +878,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
846
878
  claims.delete(sessionId);
847
879
  }
848
880
  finally {
881
+ if (claim && !lifecycle.stopping)
882
+ lifecycle.forget(claim);
849
883
  claimStarts.delete(sessionId);
850
884
  }
851
885
  };
@@ -1359,6 +1393,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1359
1393
  return fileEditChain;
1360
1394
  };
1361
1395
  const heartbeat = setInterval(() => {
1396
+ if (lifecycle.stopping)
1397
+ return;
1362
1398
  for (const [sessionId, entry] of anchors) {
1363
1399
  if (entry.connection.closed)
1364
1400
  continue;
@@ -1367,6 +1403,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1367
1403
  }, HARNESS_HEARTBEAT_MS);
1368
1404
  heartbeat.unref?.();
1369
1405
  rpc.onEvent((message) => {
1406
+ if (lifecycle.stopping)
1407
+ return;
1370
1408
  if (message.type === 'queue.ready')
1371
1409
  void tryClaim(message.sessionId);
1372
1410
  if (message.type === 'session.steer')
@@ -1395,7 +1433,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1395
1433
  const registered = new WeakSet();
1396
1434
  const registering = new WeakSet();
1397
1435
  const registerControl = async (control) => {
1398
- if (registered.has(control) || registering.has(control))
1436
+ if (lifecycle.stopping || registered.has(control) || registering.has(control))
1399
1437
  return;
1400
1438
  registering.add(control);
1401
1439
  try {
@@ -1431,8 +1469,10 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1431
1469
  void registerControl(control);
1432
1470
  });
1433
1471
  await registerControl(await rpc.current());
1434
- for (;;) {
1472
+ while (!lifecycle.stopping) {
1435
1473
  await backgroundWait(MACHINE_HEARTBEAT_MS);
1474
+ if (lifecycle.stopping)
1475
+ break;
1436
1476
  try {
1437
1477
  await registerControl(await rpc.current());
1438
1478
  const status = await request({ type: 'machine.heartbeat' }, MACHINE_HEARTBEAT_TIMEOUT_MS);
@@ -7,10 +7,12 @@ import process from 'node:process';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import WebSocket from 'ws';
9
9
  import { logError, logInfo } from "./logging.js";
10
+ import { NativeInventoryWorker } from "./native-inventory-worker.js";
11
+ import { QueueDispatchLifecycle } from "./queue-lifecycle.js";
10
12
  import { streamDeepseekTrajectoryLines } from "./deepseek-trajectories.js";
11
13
  import { normalizeAllowedDirectories, workingDirectoryIsAllowed } from "./directory-policy.js";
12
14
  import { discoverGitAssociation } from "./git-association.js";
13
- import { discoverLocalSessions, externallyManagedNativeDescriptorKeys, grokSpawnedSubagentIds, nativeDescriptorAgentSessionId, nativeDescriptorLineage, nativeDescriptorWorkingDirectory, nativeSessionShallowWatchRoots, nativeSessionWatchRoots, } from "./local-session-sources.js";
15
+ import { discoverLocalSessions, externallyManagedNativeDescriptorKeys, grokSpawnedSubagentIds, nativeDescriptorAgentSessionId, nativeDescriptorForSource, nativeDescriptorLineage, nativeDescriptorWorkingDirectory, nativeSessionShallowWatchRoots, nativeSessionWatchRoots, } from "./local-session-sources.js";
14
16
  import { compactNativeRaw } from "./native-record-compaction.js";
15
17
  import { createPathResolver, pathCandidates } from "./path-resolution.js";
16
18
  import { httpBaseUrl, watchQueuedPrompts } from "./queue-worker.js";
@@ -1355,8 +1357,8 @@ export async function filterNativeSessionsByDirectories(sessions, allowedDirecto
1355
1357
  export function inventoryIsAuthoritative(options) {
1356
1358
  return !(options.only || options.rewriteSession || options.onlyDirectory || options.allowedDirectories?.length);
1357
1359
  }
1358
- async function nativeInventory(options, machine) {
1359
- let sessions = linkNativeSessionParents(machine, discoverLocalSessions(options.rewriteSession ? '' : options.only));
1360
+ async function nativeInventory(options, machine, discover = async (only) => discoverLocalSessions(only)) {
1361
+ let sessions = linkNativeSessionParents(machine, await discover(options.rewriteSession ? '' : options.only));
1360
1362
  sessions = await filterNativeSessionsByOnlyDirectory(sessions, options.onlyDirectory);
1361
1363
  sessions = await filterNativeSessionsByDirectories(sessions, options.allowedDirectories);
1362
1364
  if (!options.rewriteSession)
@@ -1452,8 +1454,98 @@ export function nativeUploadProgressSnapshot({ machine, descriptors, seen, finge
1452
1454
  };
1453
1455
  }
1454
1456
  async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1455
- let initialSessions = await nativeInventory(options, machine);
1456
- const descriptorRegistry = { sessions: initialSessions };
1457
+ const descriptorRegistry = { sessions: [] };
1458
+ const lifecycle = new QueueDispatchLifecycle();
1459
+ const inventory = options.watch && !options.dryRun ? new NativeInventoryWorker() : null;
1460
+ const scanInventory = () => nativeInventory(options, machine, inventory ? (only) => inventory.scan(only) : undefined);
1461
+ let state;
1462
+ let nativeMonitor = null;
1463
+ // Queue registration and anchor recovery must not wait for historical stores.
1464
+ // The shared transport still owns the sole machine-presence connection.
1465
+ let rpc = options.dryRun
1466
+ ? null
1467
+ : options.watch
1468
+ ? new SharedUploaderConnection(options, machine)
1469
+ : await connectUploader(options, machine);
1470
+ let resolveDescriptors;
1471
+ const descriptorsReady = new Promise((resolve) => {
1472
+ resolveDescriptors = resolve;
1473
+ });
1474
+ if (options.watch && !options.dryRun) {
1475
+ let shuttingDown = false;
1476
+ const shutdown = async () => {
1477
+ if (shuttingDown)
1478
+ return;
1479
+ shuttingDown = true;
1480
+ // stop() closes the dispatch gate synchronously, before any awaited work.
1481
+ const stopped = lifecycle.stop();
1482
+ void inventory?.close();
1483
+ const deadline = setTimeout(() => process.exit(0), 5_000);
1484
+ try {
1485
+ await stopped;
1486
+ }
1487
+ finally {
1488
+ state?.close();
1489
+ nativeMonitor?.close();
1490
+ rpc.close();
1491
+ clearTimeout(deadline);
1492
+ process.exit(0);
1493
+ }
1494
+ };
1495
+ process.once('SIGTERM', () => {
1496
+ void shutdown();
1497
+ });
1498
+ process.once('SIGINT', () => {
1499
+ void shutdown();
1500
+ });
1501
+ void watchQueuedPrompts({
1502
+ rpc,
1503
+ options,
1504
+ machine,
1505
+ getDescriptors: () => descriptorRegistry.sessions,
1506
+ descriptorsReady,
1507
+ resolveDescriptor: async (session) => {
1508
+ const descriptor = nativeDescriptorForSource(String(session?.harness || ''), String(session?.sourcePath || ''));
1509
+ if (!descriptor)
1510
+ return null;
1511
+ const only = String(options.only || '').toLowerCase();
1512
+ if (only &&
1513
+ !descriptor.harness.toLowerCase().includes(only) &&
1514
+ !descriptor.sourcePath.toLowerCase().includes(only))
1515
+ return null;
1516
+ if (options.rewriteSession &&
1517
+ stableNativeSessionId(machine, descriptor.harness, descriptor.sourcePath) !== options.rewriteSession)
1518
+ return null;
1519
+ // Apply the same workspace policy as discovery before using the fast path.
1520
+ const exact = await filterNativeSessionsByOnlyDirectory([descriptor], options.onlyDirectory);
1521
+ return (await filterNativeSessionsByDirectories(exact, options.allowedDirectories))[0] || null;
1522
+ },
1523
+ lifecycle,
1524
+ stableSessionId: stableNativeSessionId,
1525
+ redactor,
1526
+ }).catch((error) => {
1527
+ if (!lifecycle.stopping)
1528
+ logError(`Queue worker stopped unexpectedly: ${error?.stack || error}\n`);
1529
+ });
1530
+ }
1531
+ let initialSessions;
1532
+ try {
1533
+ initialSessions = await scanInventory();
1534
+ }
1535
+ catch (error) {
1536
+ if (lifecycle.stopping)
1537
+ return;
1538
+ // A failed initial inventory cannot leave the control worker alive with an
1539
+ // unresolved descriptor barrier and an uploader that has already failed.
1540
+ void lifecycle.stop();
1541
+ rpc?.close();
1542
+ void inventory?.close();
1543
+ throw error;
1544
+ }
1545
+ if (lifecycle.stopping)
1546
+ return;
1547
+ descriptorRegistry.sessions = initialSessions;
1548
+ resolveDescriptors();
1457
1549
  const byHarness = new Map();
1458
1550
  for (const session of initialSessions)
1459
1551
  byHarness.set(session.harness, (byHarness.get(session.harness) || 0) + 1);
@@ -1462,7 +1554,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1462
1554
  logInfo(` ${harness}: ${count}\n`);
1463
1555
  if (options.dryRun)
1464
1556
  return;
1465
- const state = createNativeUploadState(options.nativeStateFile, machine);
1557
+ state = createNativeUploadState(options.nativeStateFile, machine);
1466
1558
  const seen = new Map();
1467
1559
  const cursors = new Map();
1468
1560
  const fingerprints = new Map();
@@ -1473,7 +1565,6 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1473
1565
  const redactionStarted = new Set();
1474
1566
  const livePriority = new Set();
1475
1567
  const tombstoned = new Set();
1476
- let nativeMonitor = null;
1477
1568
  const authoritativeInventory = inventoryIsAuthoritative(options);
1478
1569
  const rememberInventory = (sessions, retainState = false, forceMetadataDirectories = new Set()) => {
1479
1570
  const previousFingerprints = new Map(fingerprints);
@@ -1541,14 +1632,6 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1541
1632
  nativeMonitor?.addFiles(sessions.map((descriptor) => descriptor.file));
1542
1633
  };
1543
1634
  rememberInventory(initialSessions);
1544
- // A single request-id-multiplexed transport carries ingestion, ownership,
1545
- // queue control, and live rows. These tasks remain concurrent without
1546
- // duplicating authentication or machine-presence sockets.
1547
- let rpc = options.watch ? new SharedUploaderConnection(options, machine) : await connectUploader(options, machine);
1548
- // Only the shared connection has a `current()` handle, and only the watch path builds one.
1549
- // Only the shared connection has a `current()` handle, and only the watch path builds one.
1550
- if (options.watch)
1551
- await rpc.current();
1552
1635
  if (options.watch)
1553
1636
  void watchExternalManagement(rpc, machine, () => descriptorRegistry.sessions);
1554
1637
  // Paths an agent wrote about are resolved from the rows themselves and
@@ -1578,18 +1661,6 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1578
1661
  }
1579
1662
  },
1580
1663
  });
1581
- if (options.watch) {
1582
- void watchQueuedPrompts({
1583
- rpc,
1584
- options,
1585
- machine,
1586
- getDescriptors: () => descriptorRegistry.sessions,
1587
- stableSessionId: stableNativeSessionId,
1588
- redactor,
1589
- }).catch((error) => {
1590
- logError(`Queue worker stopped unexpectedly: ${error?.stack || error}\n`);
1591
- });
1592
- }
1593
1664
  const totals = { current: 0, uploaded: 0, rows: 0, empty: 0, failed: 0 };
1594
1665
  const failures = new Map();
1595
1666
  const rewritePending = new Set(options.rewriteSession ? [options.rewriteSession] : []);
@@ -1797,7 +1868,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1797
1868
  break;
1798
1869
  settlePass += 1;
1799
1870
  if (settlePass >= MAX_NATIVE_SETTLE_PASSES) {
1800
- const sessions = await nativeInventory(options, machine);
1871
+ const sessions = await scanInventory();
1801
1872
  rememberInventory(sessions);
1802
1873
  const unsettled = changedSessions();
1803
1874
  if (unsettled.length) {
@@ -1806,7 +1877,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1806
1877
  break;
1807
1878
  }
1808
1879
  await wait(250);
1809
- const sessions = await nativeInventory(options, machine);
1880
+ const sessions = await scanInventory();
1810
1881
  rememberInventory(sessions);
1811
1882
  }
1812
1883
  state.close();
@@ -1949,20 +2020,6 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1949
2020
  requestSweep();
1950
2021
  }, NATIVE_DISCOVERY_INTERVAL_MS);
1951
2022
  discoveryTimer.unref?.();
1952
- const shutdown = () => {
1953
- state.close();
1954
- nativeMonitor.close();
1955
- clearInterval(pollTimer);
1956
- clearInterval(discoveryTimer);
1957
- };
1958
- process.once('SIGTERM', () => {
1959
- shutdown();
1960
- process.exit(0);
1961
- });
1962
- process.once('SIGINT', () => {
1963
- shutdown();
1964
- process.exit(0);
1965
- });
1966
2023
  logInfo(`Watching ${initialSessions.length} native sessions via ${nativeMonitor.count} directories; ` +
1967
2024
  `polling known files every ${NATIVE_POLL_INTERVAL_MS / 1000}s and auditing every ` +
1968
2025
  `${NATIVE_DISCOVERY_INTERVAL_MS / 1000}s…\n`);
@@ -1973,7 +2030,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1973
2030
  if (discoveryPending) {
1974
2031
  discoveryPending = false;
1975
2032
  try {
1976
- initialSessions = await nativeInventory(options, machine);
2033
+ initialSessions = await scanInventory();
1977
2034
  const forceMetadataDirectories = new Set(pendingMetadataDirectories);
1978
2035
  pendingMetadataDirectories.clear();
1979
2036
  rememberInventory(initialSessions, true, forceMetadataDirectories);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",