@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.
- package/dist/agent-anchor.js +2 -0
- package/dist/local-session-sources.js +30 -0
- package/dist/native-inventory-worker.js +58 -0
- package/dist/queue-lifecycle.js +66 -0
- package/dist/queue-worker.js +118 -78
- package/dist/upload-sessions.js +101 -44
- package/package.json +1 -1
package/dist/agent-anchor.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/queue-worker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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);
|
package/dist/upload-sessions.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
1456
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
2033
|
+
initialSessions = await scanInventory();
|
|
1977
2034
|
const forceMetadataDirectories = new Set(pendingMetadataDirectories);
|
|
1978
2035
|
pendingMetadataDirectories.clear();
|
|
1979
2036
|
rememberInventory(initialSessions, true, forceMetadataDirectories);
|