@cydm/happy-elves 0.1.0-beta.488 → 0.1.0-beta.489
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/apps/cli/package.json +1 -1
- package/apps/daemon/dist/paths.d.ts +1 -0
- package/apps/daemon/dist/paths.js +1 -0
- package/apps/daemon/dist/relay/send.js +279 -34
- package/apps/daemon/dist/runtime/headless-event-projector.d.ts +1 -0
- package/apps/daemon/dist/runtime/headless-event-projector.js +19 -1
- package/apps/daemon/dist/runtime/headless-history-reader.js +49 -2
- package/apps/daemon/dist/session/lifecycle.d.ts +1 -1
- package/apps/daemon/dist/session/lifecycle.js +2 -84
- package/apps/daemon/dist/session/native-history-refresh.d.ts +12 -0
- package/apps/daemon/dist/session/native-history-refresh.js +183 -0
- package/apps/daemon/dist/session/runtime-truth.js +8 -2
- package/apps/daemon/package.json +1 -1
- package/package.json +1 -1
- package/packages/headless-runtime/dist/core.js +22 -0
package/apps/cli/package.json
CHANGED
|
@@ -2,6 +2,7 @@ export declare const configDir: string;
|
|
|
2
2
|
export declare const configPath: string;
|
|
3
3
|
export declare const auditLogPath: string;
|
|
4
4
|
export declare const relayOutboxPath: string;
|
|
5
|
+
export declare const relayRetainedOutboxPath: string;
|
|
5
6
|
export declare const inboundPromptAdmissionStorePath: string;
|
|
6
7
|
export declare const sessionProjectionStorePath: string;
|
|
7
8
|
export declare const loopStorePath: string;
|
|
@@ -7,6 +7,7 @@ export const configDir = process.env.HAPPY_ELVES_HOME
|
|
|
7
7
|
export const configPath = path.join(configDir, "daemon.json");
|
|
8
8
|
export const auditLogPath = path.join(configDir, "audit.jsonl");
|
|
9
9
|
export const relayOutboxPath = path.join(configDir, "outbox.jsonl");
|
|
10
|
+
export const relayRetainedOutboxPath = path.join(configDir, "retained-outbox.jsonl");
|
|
10
11
|
export const inboundPromptAdmissionStorePath = path.join(configDir, "inbound-prompt-admissions.json");
|
|
11
12
|
export const sessionProjectionStorePath = path.join(configDir, "sessions.json");
|
|
12
13
|
export const loopStorePath = path.join(configDir, "loops.json");
|
|
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import { encryptJson } from "../../../../packages/shared/dist/index.js";
|
|
4
4
|
import { activeCommandRequests, completedCommandResponses, sessions, sessionTurns } from "../state.js";
|
|
5
5
|
import { appendAudit } from "../audit.js";
|
|
6
|
-
import { configDir, relayOutboxPath } from "../paths.js";
|
|
6
|
+
import { configDir, relayOutboxPath, relayRetainedOutboxPath } from "../paths.js";
|
|
7
7
|
const criticalMessageTypes = new Set([
|
|
8
8
|
"machine:sessionReady",
|
|
9
9
|
"machine:event",
|
|
@@ -37,7 +37,14 @@ let outboxFlushTimer;
|
|
|
37
37
|
const outbox = new Map();
|
|
38
38
|
const directSockets = new WeakSet();
|
|
39
39
|
const retainedVersions = new Map();
|
|
40
|
-
const
|
|
40
|
+
const retainedOutbox = new Map();
|
|
41
|
+
const retainedInFlightKeys = new Set();
|
|
42
|
+
let retainedOutboxLoaded = false;
|
|
43
|
+
let retainedOutboxLoadPromise = null;
|
|
44
|
+
let retainedOutboxWritePromise = Promise.resolve();
|
|
45
|
+
let retainedOutboxDrainPromise = null;
|
|
46
|
+
let retainedOutboxFlushTimer;
|
|
47
|
+
let retainedOutboxFlushAt;
|
|
41
48
|
const transcriptWindows = new Map();
|
|
42
49
|
const controllerProjectionChannel = "product:controllers";
|
|
43
50
|
const retainedTranscriptWindowSize = 200;
|
|
@@ -50,6 +57,13 @@ const outboxAckTimeoutMs = 15_000;
|
|
|
50
57
|
const outboxRetryBaseMs = 1_000;
|
|
51
58
|
const outboxRetryMaxMs = 30_000;
|
|
52
59
|
const relayStoredTtlMs = 24 * 60 * 60 * 1000;
|
|
60
|
+
const configuredRetainedHttpTimeoutMs = Number(process.env.HAPPY_ELVES_RELAY_RETAINED_HTTP_TIMEOUT_MS);
|
|
61
|
+
const retainedHttpTimeoutMs = Number.isFinite(configuredRetainedHttpTimeoutMs) && configuredRetainedHttpTimeoutMs >= 25
|
|
62
|
+
? Math.min(60_000, Math.floor(configuredRetainedHttpTimeoutMs))
|
|
63
|
+
: 10_000;
|
|
64
|
+
const retainedDrainConcurrency = 4;
|
|
65
|
+
const retainedOutboxRetryBaseMs = 1_000;
|
|
66
|
+
const retainedOutboxRetryMaxMs = 30_000;
|
|
53
67
|
export function markDirectSocket(ws) {
|
|
54
68
|
directSockets.add(ws);
|
|
55
69
|
}
|
|
@@ -73,6 +87,9 @@ export function send(ws, message) {
|
|
|
73
87
|
}
|
|
74
88
|
export function configureRelaySender(config) {
|
|
75
89
|
relayConfig = config;
|
|
90
|
+
void ensureRetainedOutboxLoaded().then(() => scheduleRetainedOutboxFlush(0)).catch((error) => {
|
|
91
|
+
console.error(`Relay retained outbox load failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
92
|
+
});
|
|
76
93
|
}
|
|
77
94
|
export function setActiveRelaySocket(ws) {
|
|
78
95
|
activeRelaySocket = ws;
|
|
@@ -157,6 +174,15 @@ export async function clearRelayOutboxForTests() {
|
|
|
157
174
|
outboxLoaded = true;
|
|
158
175
|
outboxLoadPromise = null;
|
|
159
176
|
await persistOutbox();
|
|
177
|
+
if (retainedOutboxFlushTimer) {
|
|
178
|
+
clearTimeout(retainedOutboxFlushTimer);
|
|
179
|
+
retainedOutboxFlushTimer = undefined;
|
|
180
|
+
}
|
|
181
|
+
retainedOutboxFlushAt = undefined;
|
|
182
|
+
retainedOutbox.clear();
|
|
183
|
+
retainedOutboxLoaded = true;
|
|
184
|
+
retainedOutboxLoadPromise = null;
|
|
185
|
+
await persistRetainedOutbox();
|
|
160
186
|
}
|
|
161
187
|
export async function sendReliable(message, ws) {
|
|
162
188
|
if (ws && directSockets.has(ws)) {
|
|
@@ -515,32 +541,195 @@ async function sendPacket(socket, payload, options) {
|
|
|
515
541
|
encryptedPayload: await encryptJson(relayConfig.accountSecret, `packet:${options.packetId}`, payload),
|
|
516
542
|
},
|
|
517
543
|
};
|
|
544
|
+
// Retained projections are durable and independently retried. Persisting
|
|
545
|
+
// the latest projection is local-only work; the WAN/CAS write must never
|
|
546
|
+
// hold up a control packet or its stored ACK.
|
|
547
|
+
await sendRetainedProjection(payload);
|
|
518
548
|
socket.send(JSON.stringify(packet));
|
|
519
|
-
await sendRetainedProjection(socket, payload);
|
|
520
549
|
}
|
|
521
|
-
async function sendRetainedProjection(
|
|
550
|
+
async function sendRetainedProjection(payload) {
|
|
522
551
|
if (!relayConfig)
|
|
523
552
|
return;
|
|
524
|
-
void socket;
|
|
525
553
|
const retainedProjection = retainedProjectionForPayload(relayConfig.machineId, payload);
|
|
526
554
|
if (!retainedProjection)
|
|
527
555
|
return;
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
556
|
+
await enqueueRetainedProjection(retainedProjection.retainedKey, retainedProjection.payload);
|
|
557
|
+
}
|
|
558
|
+
async function enqueueRetainedProjection(retainedKey, payload) {
|
|
559
|
+
await ensureRetainedOutboxLoaded();
|
|
560
|
+
const now = new Date().toISOString();
|
|
561
|
+
const existing = retainedOutbox.get(retainedKey);
|
|
562
|
+
const record = existing ?? {
|
|
563
|
+
retainedKey,
|
|
564
|
+
payload,
|
|
565
|
+
createdAt: now,
|
|
566
|
+
updatedAt: now,
|
|
567
|
+
generation: randomUUID(),
|
|
568
|
+
attemptCount: 0,
|
|
569
|
+
};
|
|
570
|
+
record.payload = payload;
|
|
571
|
+
record.updatedAt = now;
|
|
572
|
+
record.generation = randomUUID();
|
|
573
|
+
// A newer projection supersedes a retry deadline from an older payload.
|
|
574
|
+
// The per-key in-flight fence still allows the older HTTP request to finish
|
|
575
|
+
// without letting it delete or overwrite this newer record.
|
|
576
|
+
record.nextAttemptAt = undefined;
|
|
577
|
+
if (!existing)
|
|
578
|
+
retainedOutbox.set(retainedKey, record);
|
|
579
|
+
await persistRetainedOutbox();
|
|
580
|
+
scheduleRetainedOutboxFlush(0);
|
|
581
|
+
}
|
|
582
|
+
function scheduleRetainedOutboxFlush(delayOverrideMs) {
|
|
583
|
+
void ensureRetainedOutboxLoaded().then(() => {
|
|
584
|
+
if (retainedOutbox.size === 0)
|
|
532
585
|
return;
|
|
533
|
-
const
|
|
534
|
-
|
|
586
|
+
const now = Date.now();
|
|
587
|
+
const dueAt = nextRetainedOutboxFlushAt(now);
|
|
588
|
+
if (dueAt === undefined && delayOverrideMs === undefined)
|
|
589
|
+
return;
|
|
590
|
+
const delay = delayOverrideMs ?? Math.max(0, (dueAt ?? now) - now);
|
|
591
|
+
const targetAt = now + Math.max(0, delay);
|
|
592
|
+
if (retainedOutboxFlushTimer && retainedOutboxFlushAt !== undefined && retainedOutboxFlushAt <= targetAt)
|
|
593
|
+
return;
|
|
594
|
+
if (retainedOutboxFlushTimer)
|
|
595
|
+
clearTimeout(retainedOutboxFlushTimer);
|
|
596
|
+
retainedOutboxFlushAt = targetAt;
|
|
597
|
+
retainedOutboxFlushTimer = setTimeout(() => {
|
|
598
|
+
retainedOutboxFlushTimer = undefined;
|
|
599
|
+
retainedOutboxFlushAt = undefined;
|
|
600
|
+
void flushRetainedOutbox().catch((error) => {
|
|
601
|
+
console.error(`Relay retained outbox flush failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
602
|
+
});
|
|
603
|
+
}, Math.max(0, delay));
|
|
604
|
+
retainedOutboxFlushTimer.unref?.();
|
|
605
|
+
}).catch((error) => {
|
|
606
|
+
console.error(`Relay retained outbox schedule failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
function flushRetainedOutbox() {
|
|
610
|
+
retainedOutboxDrainPromise ??= flushRetainedOutboxOnce().finally(() => {
|
|
611
|
+
retainedOutboxDrainPromise = null;
|
|
612
|
+
scheduleRetainedOutboxFlush();
|
|
535
613
|
});
|
|
536
|
-
|
|
614
|
+
return retainedOutboxDrainPromise;
|
|
615
|
+
}
|
|
616
|
+
async function flushRetainedOutboxOnce() {
|
|
617
|
+
await ensureRetainedOutboxLoaded();
|
|
618
|
+
const now = Date.now();
|
|
619
|
+
const candidates = [...retainedOutbox.values()]
|
|
620
|
+
.filter((record) => !retainedInFlightKeys.has(record.retainedKey))
|
|
621
|
+
.filter((record) => !record.nextAttemptAt || Date.parse(record.nextAttemptAt) <= now)
|
|
622
|
+
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))
|
|
623
|
+
.slice(0, retainedDrainConcurrency);
|
|
624
|
+
await Promise.all(candidates.map((record) => attemptRetainedOutboxRecord(record)));
|
|
625
|
+
}
|
|
626
|
+
function nextRetainedOutboxFlushAt(now) {
|
|
627
|
+
let next;
|
|
628
|
+
for (const record of retainedOutbox.values()) {
|
|
629
|
+
if (retainedInFlightKeys.has(record.retainedKey))
|
|
630
|
+
continue;
|
|
631
|
+
const dueAt = record.nextAttemptAt ? Date.parse(record.nextAttemptAt) : now;
|
|
632
|
+
if (!Number.isFinite(dueAt))
|
|
633
|
+
continue;
|
|
634
|
+
next = next === undefined ? dueAt : Math.min(next, dueAt);
|
|
635
|
+
}
|
|
636
|
+
return next;
|
|
637
|
+
}
|
|
638
|
+
async function attemptRetainedOutboxRecord(record) {
|
|
639
|
+
retainedInFlightKeys.add(record.retainedKey);
|
|
640
|
+
const generation = record.generation;
|
|
641
|
+
record.attemptCount += 1;
|
|
642
|
+
record.nextAttemptAt = new Date(Date.now() + retainedRetryDelayMs(record.attemptCount)).toISOString();
|
|
643
|
+
await persistRetainedOutbox();
|
|
537
644
|
try {
|
|
538
|
-
await
|
|
645
|
+
const version = await putRetainedProjection(record.retainedKey, record.payload);
|
|
646
|
+
retainedVersions.set(record.retainedKey, version);
|
|
647
|
+
const current = retainedOutbox.get(record.retainedKey);
|
|
648
|
+
if (current === record && current.generation === generation) {
|
|
649
|
+
retainedOutbox.delete(record.retainedKey);
|
|
650
|
+
}
|
|
651
|
+
await persistRetainedOutbox();
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
const current = retainedOutbox.get(record.retainedKey);
|
|
655
|
+
if (current === record && current.generation === generation) {
|
|
656
|
+
current.nextAttemptAt = new Date(Date.now() + retainedRetryDelayMs(current.attemptCount)).toISOString();
|
|
657
|
+
await persistRetainedOutbox();
|
|
658
|
+
}
|
|
659
|
+
await appendRetainedOutboxAudit(record, "send-failed", error);
|
|
539
660
|
}
|
|
540
661
|
finally {
|
|
541
|
-
|
|
542
|
-
|
|
662
|
+
retainedInFlightKeys.delete(record.retainedKey);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function retainedRetryDelayMs(attemptCount) {
|
|
666
|
+
const exponent = Math.max(0, Math.min(attemptCount - 1, 5));
|
|
667
|
+
return Math.min(retainedOutboxRetryMaxMs, retainedOutboxRetryBaseMs * (2 ** exponent));
|
|
668
|
+
}
|
|
669
|
+
async function ensureRetainedOutboxLoaded() {
|
|
670
|
+
if (retainedOutboxLoaded)
|
|
671
|
+
return;
|
|
672
|
+
retainedOutboxLoadPromise ??= loadRetainedOutbox();
|
|
673
|
+
await retainedOutboxLoadPromise;
|
|
674
|
+
}
|
|
675
|
+
async function loadRetainedOutbox() {
|
|
676
|
+
retainedOutbox.clear();
|
|
677
|
+
try {
|
|
678
|
+
const text = await fs.readFile(relayRetainedOutboxPath, "utf8");
|
|
679
|
+
for (const line of text.split(/\n/u)) {
|
|
680
|
+
if (!line.trim())
|
|
681
|
+
continue;
|
|
682
|
+
const parsed = JSON.parse(line);
|
|
683
|
+
if (typeof parsed.retainedKey === "string" && parsed.retainedKey
|
|
684
|
+
&& parsed.payload && typeof parsed.payload === "object"
|
|
685
|
+
&& typeof parsed.createdAt === "string"
|
|
686
|
+
&& typeof parsed.updatedAt === "string"
|
|
687
|
+
&& typeof parsed.attemptCount === "number") {
|
|
688
|
+
retainedOutbox.set(parsed.retainedKey, {
|
|
689
|
+
retainedKey: parsed.retainedKey,
|
|
690
|
+
payload: parsed.payload,
|
|
691
|
+
createdAt: parsed.createdAt,
|
|
692
|
+
updatedAt: parsed.updatedAt,
|
|
693
|
+
generation: typeof parsed.generation === "string" && parsed.generation ? parsed.generation : randomUUID(),
|
|
694
|
+
attemptCount: parsed.attemptCount,
|
|
695
|
+
...(typeof parsed.nextAttemptAt === "string" ? { nextAttemptAt: parsed.nextAttemptAt } : {}),
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT")
|
|
702
|
+
throw error;
|
|
543
703
|
}
|
|
704
|
+
retainedOutboxLoaded = true;
|
|
705
|
+
}
|
|
706
|
+
async function persistRetainedOutbox() {
|
|
707
|
+
retainedOutboxWritePromise = retainedOutboxWritePromise.then(writeRetainedOutbox, writeRetainedOutbox);
|
|
708
|
+
await retainedOutboxWritePromise;
|
|
709
|
+
}
|
|
710
|
+
async function writeRetainedOutbox() {
|
|
711
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
712
|
+
const text = [...retainedOutbox.values()].map((record) => JSON.stringify(record)).join("\n");
|
|
713
|
+
const next = text ? `${text}\n` : "";
|
|
714
|
+
const tempPath = `${relayRetainedOutboxPath}.tmp`;
|
|
715
|
+
await fs.writeFile(tempPath, next, { mode: 0o600 });
|
|
716
|
+
await fs.rename(tempPath, relayRetainedOutboxPath);
|
|
717
|
+
await fs.chmod(relayRetainedOutboxPath, 0o600).catch(() => undefined);
|
|
718
|
+
}
|
|
719
|
+
async function appendRetainedOutboxAudit(record, status, error) {
|
|
720
|
+
if (!relayConfig)
|
|
721
|
+
return;
|
|
722
|
+
await appendAudit({
|
|
723
|
+
machineId: relayConfig.machineId,
|
|
724
|
+
actor: "system",
|
|
725
|
+
action: `relay.retained-outbox.${status}`,
|
|
726
|
+
summary: "Retained relay projection retry scheduled",
|
|
727
|
+
evidence: {
|
|
728
|
+
retainedKey: record.retainedKey,
|
|
729
|
+
attemptCount: record.attemptCount,
|
|
730
|
+
error: error instanceof Error ? error.message : error ? String(error) : undefined,
|
|
731
|
+
},
|
|
732
|
+
});
|
|
544
733
|
}
|
|
545
734
|
export async function putEncryptedRetainedPayload(retainedKey, payload) {
|
|
546
735
|
if (!relayConfig)
|
|
@@ -549,26 +738,32 @@ export async function putEncryptedRetainedPayload(retainedKey, payload) {
|
|
|
549
738
|
const baseVersion = retainedVersions.get(retainedKey) ?? await fetchRetainedVersion(retainedKey);
|
|
550
739
|
retainedVersions.set(retainedKey, baseVersion);
|
|
551
740
|
const encryptedPayload = await encryptJson(relayConfig.accountSecret, `retained:${retainedKey}:${baseVersion + 1}`, payload);
|
|
552
|
-
const
|
|
741
|
+
const timed = await fetchWithTimeout(`${relayConfig.relayUrl}/api/retained/${encodeURIComponent(retainedKey)}`, {
|
|
553
742
|
method: "PUT",
|
|
554
743
|
headers: {
|
|
555
744
|
authorization: `Bearer ${relayConfig.machineToken}`,
|
|
556
745
|
"content-type": "application/json",
|
|
557
746
|
},
|
|
558
747
|
body: JSON.stringify({ baseVersion, encryptedPayload }),
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
748
|
+
}, retainedHttpTimeoutMs);
|
|
749
|
+
const response = timed.response;
|
|
750
|
+
try {
|
|
751
|
+
if (response.status === 409) {
|
|
752
|
+
retainedVersions.delete(retainedKey);
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (!response.ok) {
|
|
756
|
+
throw new Error(`Retained packet write failed for ${retainedKey}: HTTP ${response.status}`);
|
|
757
|
+
}
|
|
758
|
+
const body = await readResponseJsonWithTimeout(response, retainedHttpTimeoutMs, timed.abort);
|
|
759
|
+
const version = body.retainedPacket?.version;
|
|
760
|
+
if (typeof version === "number" && Number.isSafeInteger(version) && version > 0)
|
|
761
|
+
return version;
|
|
762
|
+
throw new Error(`Retained packet write did not return a version for ${retainedKey}`);
|
|
563
763
|
}
|
|
564
|
-
|
|
565
|
-
|
|
764
|
+
finally {
|
|
765
|
+
timed.cleanup();
|
|
566
766
|
}
|
|
567
|
-
const body = await response.json();
|
|
568
|
-
const version = body.retainedPacket?.version;
|
|
569
|
-
if (typeof version === "number" && Number.isSafeInteger(version) && version > 0)
|
|
570
|
-
return version;
|
|
571
|
-
throw new Error(`Retained packet write did not return a version for ${retainedKey}`);
|
|
572
767
|
}
|
|
573
768
|
throw new Error(`Retained packet CAS conflict for ${retainedKey}`);
|
|
574
769
|
}
|
|
@@ -579,19 +774,69 @@ async function fetchRetainedVersion(retainedKey) {
|
|
|
579
774
|
if (!relayConfig)
|
|
580
775
|
return 0;
|
|
581
776
|
try {
|
|
582
|
-
const
|
|
777
|
+
const timed = await fetchWithTimeout(`${relayConfig.relayUrl}/api/retained/${encodeURIComponent(retainedKey)}`, {
|
|
583
778
|
headers: { authorization: `Bearer ${relayConfig.machineToken}` },
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
779
|
+
}, retainedHttpTimeoutMs);
|
|
780
|
+
const response = timed.response;
|
|
781
|
+
try {
|
|
782
|
+
if (!response.ok)
|
|
783
|
+
return 0;
|
|
784
|
+
const body = await readResponseJsonWithTimeout(response, retainedHttpTimeoutMs, timed.abort);
|
|
785
|
+
const version = body.retainedPacket?.version;
|
|
786
|
+
return typeof version === "number" && Number.isSafeInteger(version) && version >= 0 ? version : 0;
|
|
787
|
+
}
|
|
788
|
+
finally {
|
|
789
|
+
timed.cleanup();
|
|
790
|
+
}
|
|
590
791
|
}
|
|
591
792
|
catch {
|
|
592
793
|
return 0;
|
|
593
794
|
}
|
|
594
795
|
}
|
|
796
|
+
async function fetchWithTimeout(input, init, timeoutMs) {
|
|
797
|
+
const controller = new AbortController();
|
|
798
|
+
const timeout = setTimeout(() => controller.abort(new Error("retained HTTP request timed out")), timeoutMs);
|
|
799
|
+
timeout.unref?.();
|
|
800
|
+
try {
|
|
801
|
+
const response = await fetch(input, { ...init, signal: controller.signal });
|
|
802
|
+
clearTimeout(timeout);
|
|
803
|
+
return {
|
|
804
|
+
response,
|
|
805
|
+
abort: (reason) => controller.abort(reason),
|
|
806
|
+
cleanup: () => {
|
|
807
|
+
clearTimeout(timeout);
|
|
808
|
+
// Closing the body also releases the socket when the caller exits
|
|
809
|
+
// early on a non-2xx response. A body timeout calls abort() first;
|
|
810
|
+
// abort is idempotent and fences the underlying fetch stream.
|
|
811
|
+
if (!controller.signal.aborted)
|
|
812
|
+
controller.abort(new Error("retained HTTP request finished"));
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
clearTimeout(timeout);
|
|
818
|
+
throw error;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
async function readResponseJsonWithTimeout(response, timeoutMs, abort) {
|
|
822
|
+
let timer;
|
|
823
|
+
try {
|
|
824
|
+
return await Promise.race([
|
|
825
|
+
response.json(),
|
|
826
|
+
new Promise((_, reject) => {
|
|
827
|
+
timer = setTimeout(() => {
|
|
828
|
+
abort?.(new Error("retained HTTP response body timed out"));
|
|
829
|
+
reject(new Error("retained HTTP response body timed out"));
|
|
830
|
+
}, timeoutMs);
|
|
831
|
+
timer.unref?.();
|
|
832
|
+
}),
|
|
833
|
+
]);
|
|
834
|
+
}
|
|
835
|
+
finally {
|
|
836
|
+
if (timer)
|
|
837
|
+
clearTimeout(timer);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
595
840
|
function retainedProjectionForPayload(machineId, payload) {
|
|
596
841
|
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
597
842
|
return undefined;
|
|
@@ -41,3 +41,4 @@ export declare function limitHistoricalEventsByBytes(events: RuntimeHistoricalEv
|
|
|
41
41
|
export declare function terminalHistoricalEventFromTurn(turn: Turn & {
|
|
42
42
|
status: "completed" | "interrupted" | "failed";
|
|
43
43
|
}): RuntimeHistoricalEvent;
|
|
44
|
+
export declare function historicalEventsFromThread(items: ThreadItem[], turns: Turn[], limit: number | undefined, runtimeEvents?: HeadlessRuntimeEvent[]): RuntimeHistoricalEvent[];
|
|
@@ -48,12 +48,20 @@ export function mapHeadlessRuntimeEvent(event, state = createHeadlessRuntimeEven
|
|
|
48
48
|
if (!item)
|
|
49
49
|
return [];
|
|
50
50
|
if (item.type === "agentMessage") {
|
|
51
|
+
// A repaired/replayed provider log can contain more than one terminal
|
|
52
|
+
// snapshot for the same item. The item id is the event identity; do not
|
|
53
|
+
// dedupe by text because two distinct items are allowed to say the same
|
|
54
|
+
// thing.
|
|
55
|
+
if (state.textItems.has(item.id))
|
|
56
|
+
return [];
|
|
51
57
|
if ((!state.authoritativeTextItems.has(item.id) && (state.deltaItems.has(item.id) || state.textItems.has(item.id))) || !item.text)
|
|
52
58
|
return [];
|
|
53
59
|
state.textItems.add(item.id);
|
|
54
60
|
return [{ type: "text_delta", text: item.text, stream: "output", ...itemIdentity }];
|
|
55
61
|
}
|
|
56
62
|
if (item.type === "reasoning") {
|
|
63
|
+
if (state.textItems.has(item.id))
|
|
64
|
+
return [];
|
|
57
65
|
if ((!state.authoritativeTextItems.has(item.id) && (state.deltaItems.has(item.id) || state.textItems.has(item.id))) || !item.text)
|
|
58
66
|
return [];
|
|
59
67
|
state.textItems.add(item.id);
|
|
@@ -786,7 +794,7 @@ function historicalEventFromItem(item) {
|
|
|
786
794
|
}
|
|
787
795
|
return undefined;
|
|
788
796
|
}
|
|
789
|
-
function historicalEventsFromThread(items, turns, limit, runtimeEvents = []) {
|
|
797
|
+
export function historicalEventsFromThread(items, turns, limit, runtimeEvents = []) {
|
|
790
798
|
const events = [];
|
|
791
799
|
const userPromptKeys = new Set();
|
|
792
800
|
const addEvent = (event) => {
|
|
@@ -808,6 +816,16 @@ function historicalEventsFromThread(items, turns, limit, runtimeEvents = []) {
|
|
|
808
816
|
if (event)
|
|
809
817
|
addEvent(event);
|
|
810
818
|
}
|
|
819
|
+
// Server requests are not represented by a canonical ThreadItem until
|
|
820
|
+
// they are resolved (and some providers never materialize them as items at
|
|
821
|
+
// all). Preserve the raw option lifecycle while the canonical item/turn
|
|
822
|
+
// projection supplies text, tools, and terminal state. This keeps an
|
|
823
|
+
// authoritative turn rebuild usable for live needs-user attention without
|
|
824
|
+
// reintroducing duplicate assistant snapshots.
|
|
825
|
+
for (const event of historicalEventsFromRuntimeEvents(runtimeEvents)) {
|
|
826
|
+
if (event.payload.type === "option_request" || event.payload.type === "option_response")
|
|
827
|
+
addEvent(event);
|
|
828
|
+
}
|
|
811
829
|
for (const turn of turns) {
|
|
812
830
|
if (!isTerminalTurn(turn))
|
|
813
831
|
continue;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { limitHistoricalEventsByBytes, mapHeadlessRuntimeEventsToHistoricalEvents, selectThreadEventsAfterItem, selectThreadEventsBeforeCursor, selectThreadEventsBeforeItem, selectThreadEventsForTurn, } from "./headless-event-projector.js";
|
|
1
|
+
import { limitHistoricalEventsByBytes, historicalEventsFromThread, mapHeadlessRuntimeEventsToHistoricalEvents, selectThreadEventsAfterItem, selectThreadEventsBeforeCursor, selectThreadEventsBeforeItem, selectThreadEventsForTurn, } from "./headless-event-projector.js";
|
|
2
2
|
const defaultEventLimit = 200;
|
|
3
3
|
export async function readHeadlessHistoricalEventPage(input) {
|
|
4
4
|
assertValidHeadlessEventCursor(input.before);
|
|
5
5
|
const limitItems = clampLimit(input.limitItems);
|
|
6
6
|
const selection = await selectHeadlessRuntimeEvents(input, limitItems);
|
|
7
7
|
const runtimeEvents = selection.events;
|
|
8
|
-
const allEvents = mapHeadlessRuntimeEventsToHistoricalEvents(runtimeEvents);
|
|
8
|
+
const allEvents = await authoritativeTurnEvents(input, runtimeEvents) ?? mapHeadlessRuntimeEventsToHistoricalEvents(runtimeEvents);
|
|
9
9
|
const limited = limitHistoricalEventsByBytes(allEvents, input.maxBytes);
|
|
10
10
|
const includeSessionHead = !input.turnId && !input.before && !input.beforeItemId;
|
|
11
11
|
const head = includeSessionHead ? latestHeadFromEvents(allEvents) : {};
|
|
@@ -19,6 +19,53 @@ export async function readHeadlessHistoricalEventPage(input) {
|
|
|
19
19
|
...(head.lastTurnId ? { lastTurnId: head.lastTurnId } : {}),
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
+
async function authoritativeTurnEvents(input, runtimeEvents) {
|
|
23
|
+
// A turn result is a rebuild operation, not a best-effort concatenation of
|
|
24
|
+
// every provider notification. Read the provider's canonical item/turn
|
|
25
|
+
// projections when there is no paging anchor; this repairs older logs that
|
|
26
|
+
// contain a delta plus a repeated terminal snapshot for the same item.
|
|
27
|
+
// Identity is itemId/turnId, never text, so legitimate repeated answers
|
|
28
|
+
// from distinct items remain visible.
|
|
29
|
+
if (!input.turnId || input.before || input.beforeItemId || input.afterItemId)
|
|
30
|
+
return undefined;
|
|
31
|
+
// A few internal readers (and older provider adapters during rolling
|
|
32
|
+
// upgrades) only expose the event reader. Canonical rebuild is an
|
|
33
|
+
// enhancement, not a reason to turn an otherwise reachable runtime into an
|
|
34
|
+
// unavailable one; fall back to the event projection when the optional
|
|
35
|
+
// canonical methods are absent.
|
|
36
|
+
if (typeof input.client.listThreadItems !== "function" || typeof input.client.listThreadTurns !== "function")
|
|
37
|
+
return undefined;
|
|
38
|
+
const [items, turns] = await Promise.all([
|
|
39
|
+
listAllThreadItems(input.client, input.threadId, input.turnId),
|
|
40
|
+
listAllThreadTurns(input.client, input.threadId),
|
|
41
|
+
]);
|
|
42
|
+
const turn = turns.find((candidate) => candidate.id === input.turnId);
|
|
43
|
+
return historicalEventsFromThread(items, turn ? [turn] : [], undefined, runtimeEvents);
|
|
44
|
+
}
|
|
45
|
+
async function listAllThreadItems(client, threadId, turnId) {
|
|
46
|
+
const items = [];
|
|
47
|
+
let afterCursor;
|
|
48
|
+
for (let pageIndex = 0; pageIndex < 64; pageIndex += 1) {
|
|
49
|
+
const page = await client.listThreadItems({ threadId, turnId, order: "asc", limit: 200, ...(afterCursor ? { afterCursor } : {}) });
|
|
50
|
+
items.push(...page.items);
|
|
51
|
+
if (!page.nextCursor || page.items.length === 0)
|
|
52
|
+
break;
|
|
53
|
+
afterCursor = page.nextCursor;
|
|
54
|
+
}
|
|
55
|
+
return items;
|
|
56
|
+
}
|
|
57
|
+
async function listAllThreadTurns(client, threadId) {
|
|
58
|
+
const turns = [];
|
|
59
|
+
let afterCursor;
|
|
60
|
+
for (let pageIndex = 0; pageIndex < 64; pageIndex += 1) {
|
|
61
|
+
const page = await client.listThreadTurns({ threadId, order: "asc", limit: 200, ...(afterCursor ? { afterCursor } : {}) });
|
|
62
|
+
turns.push(...page.turns);
|
|
63
|
+
if (!page.nextCursor || page.turns.length === 0)
|
|
64
|
+
break;
|
|
65
|
+
afterCursor = page.nextCursor;
|
|
66
|
+
}
|
|
67
|
+
return turns;
|
|
68
|
+
}
|
|
22
69
|
async function selectHeadlessRuntimeEvents(input, limitItems) {
|
|
23
70
|
if (input.turnId) {
|
|
24
71
|
return await selectThreadEventsForTurn(input.client, input.threadId, input.turnId, limitItems);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { type MachineCommand } from "../../../../packages/shared/dist/index.js";
|
|
2
2
|
import type { DaemonConfig, SessionFallback, SessionState } from "../types.js";
|
|
3
3
|
import { type HistoricalBackfillMode, type HistoricalBackfillOptions, type HistoricalBackfillResult } from "./history-backfill.js";
|
|
4
|
+
export { refreshNativeHistoryForLoadedSession } from "./native-history-refresh.js";
|
|
4
5
|
export declare function handleCreateSession(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
|
|
5
6
|
export declare function handleImportHistoricalSession(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
|
|
6
7
|
export declare function handleListHistoricalSessions(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
|
|
7
8
|
export declare function resumeSession(ws: WebSocket, config: DaemonConfig, sessionId: string, requestId: string, fallback?: SessionFallback, announceSuccess?: boolean, syncDirection?: HistoricalBackfillMode, syncOptions?: Pick<HistoricalBackfillOptions, "afterItemId" | "beforeItemId" | "limitItems" | "maxBytes">): Promise<SessionState | null>;
|
|
8
|
-
export declare function refreshNativeHistoryForLoadedSession(session: SessionState, config: DaemonConfig, requestId: string): Promise<SessionState>;
|
|
9
9
|
export declare function rehydrateRuntimeSession(session: SessionState): Promise<SessionState>;
|
|
10
10
|
export declare function usesAuthoritativeTranscript(session: SessionState): boolean;
|
|
11
11
|
export declare function replaceAuthoritativeTranscript(ws: WebSocket, config: DaemonConfig, session: SessionState, runtimeSessionId?: string, input?: {
|
|
@@ -18,6 +18,8 @@ import { backfillStateWithSessionHead, completedBackfillResult, historicalBackfi
|
|
|
18
18
|
import { commandSessionMetadata, encryptedSessionMetadata, sessionFallbackFromCommand } from "./metadata.js";
|
|
19
19
|
import { closeSessionProjection, persistSessionProjection, sessionReadyProjectionFields } from "./store.js";
|
|
20
20
|
import { nativeHandoffIdentity } from "./native-identity.js";
|
|
21
|
+
import { refreshNativeHistoryForLoadedSession } from "./native-history-refresh.js";
|
|
22
|
+
export { refreshNativeHistoryForLoadedSession } from "./native-history-refresh.js";
|
|
21
23
|
export async function handleCreateSession(ws, config, command) {
|
|
22
24
|
if (command.type !== "machine:createSession")
|
|
23
25
|
return;
|
|
@@ -782,90 +784,6 @@ export async function resumeSession(ws, config, sessionId, requestId, fallback,
|
|
|
782
784
|
return null;
|
|
783
785
|
}
|
|
784
786
|
}
|
|
785
|
-
export async function refreshNativeHistoryForLoadedSession(session, config, requestId) {
|
|
786
|
-
if (session.source !== "native")
|
|
787
|
-
return session;
|
|
788
|
-
const manager = getHeadlessRuntimeManager(session.cwd);
|
|
789
|
-
let lastError;
|
|
790
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
791
|
-
try {
|
|
792
|
-
if (attempt > 0)
|
|
793
|
-
await manager.invalidateClientForBinding(session.runtimeBinding, session.cwd);
|
|
794
|
-
const entry = await manager.clientForBinding(session.runtimeBinding, session.cwd);
|
|
795
|
-
if (typeof entry.client.syncNativeHistory !== "function")
|
|
796
|
-
return session;
|
|
797
|
-
const expectedNativeIdentity = nativeHandoffIdentity({
|
|
798
|
-
machineId: config.machineId,
|
|
799
|
-
provider: entry.provider,
|
|
800
|
-
profile: entry.profile,
|
|
801
|
-
nativeDataRootFingerprint: entry.nativeDataRootFingerprint,
|
|
802
|
-
cwd: session.cwd,
|
|
803
|
-
providerSessionId: session.providerSessionId ?? session.runtimeBinding.threadId,
|
|
804
|
-
});
|
|
805
|
-
const synced = await entry.client.syncNativeHistory({
|
|
806
|
-
threadId: session.runtimeBinding.threadId,
|
|
807
|
-
nativeIdentity: expectedNativeIdentity,
|
|
808
|
-
});
|
|
809
|
-
const providerMetadata = synced.thread.providerMetadata ?? {};
|
|
810
|
-
const externallyChanged = providerMetadata.nativeHistoryChangedExternally === true;
|
|
811
|
-
// Native handoff identities are derived from the current daemon/provider
|
|
812
|
-
// binding. A session persisted by an earlier host build may carry an
|
|
813
|
-
// identity computed before the binding included the machine/root
|
|
814
|
-
// fingerprint. Read-only sync has already verified the provider thread
|
|
815
|
-
// and its history, so refresh the derived identity here and require a
|
|
816
|
-
// fresh explicit handoff confirmation instead of rejecting the session
|
|
817
|
-
// forever with a stale proof.
|
|
818
|
-
const nextProof = externallyChanged
|
|
819
|
-
? undefined
|
|
820
|
-
: session.manualHandoffProof && typeof providerMetadata.nativeHistoryVersion === "string"
|
|
821
|
-
? { ...session.manualHandoffProof, historyVersion: providerMetadata.nativeHistoryVersion }
|
|
822
|
-
: session.manualHandoffProof;
|
|
823
|
-
const next = {
|
|
824
|
-
...session,
|
|
825
|
-
nativeIdentity: expectedNativeIdentity,
|
|
826
|
-
...(typeof providerMetadata.nativeHistoryVersion === "string" ? { nativeHistoryVersion: providerMetadata.nativeHistoryVersion } : {}),
|
|
827
|
-
...(externallyChanged ? { manualHandoffProof: undefined } : nextProof ? { manualHandoffProof: nextProof } : {}),
|
|
828
|
-
...(session.nativeHistorySyncError ? { nativeHistorySyncError: undefined } : {}),
|
|
829
|
-
};
|
|
830
|
-
if (nextProof && typeof providerMetadata.nativeHistoryVersion === "string" && nextProof.historyVersion !== providerMetadata.nativeHistoryVersion) {
|
|
831
|
-
next.manualHandoffProof = undefined;
|
|
832
|
-
}
|
|
833
|
-
sessions.set(session.sessionId, next);
|
|
834
|
-
await persistSessionProjection(next);
|
|
835
|
-
return next;
|
|
836
|
-
}
|
|
837
|
-
catch (error) {
|
|
838
|
-
lastError = error;
|
|
839
|
-
if (attempt === 0 && isNativeRuntimeTransportFailure(error))
|
|
840
|
-
continue;
|
|
841
|
-
break;
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
const code = errorCode(lastError) ?? "NATIVE_HISTORY_SYNC_FAILED";
|
|
845
|
-
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
846
|
-
const failed = {
|
|
847
|
-
...session,
|
|
848
|
-
nativeHistorySyncError: { code, message, checkedAt: new Date().toISOString() },
|
|
849
|
-
};
|
|
850
|
-
sessions.set(session.sessionId, failed);
|
|
851
|
-
await persistSessionProjection(failed);
|
|
852
|
-
await appendAudit({
|
|
853
|
-
sessionId: session.sessionId,
|
|
854
|
-
machineId: config.machineId,
|
|
855
|
-
actor: "system",
|
|
856
|
-
action: "session.native_history.refresh_failed",
|
|
857
|
-
summary: `Read-only native history refresh did not change session ${session.sessionId}`,
|
|
858
|
-
evidence: { requestId, code, error: message },
|
|
859
|
-
});
|
|
860
|
-
return failed;
|
|
861
|
-
}
|
|
862
|
-
function isNativeRuntimeTransportFailure(error) {
|
|
863
|
-
const code = errorCode(error);
|
|
864
|
-
if (code && /^(ECONN|EPIPE|RUNTIME_(?:DISPOSED|UNAVAILABLE)|HOST_(?:START|SHUTDOWN)|REQUEST_TIMEOUT)$/u.test(code))
|
|
865
|
-
return true;
|
|
866
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
867
|
-
return /(headless runtime (?:socket|request|exited)|cannot write to headless runtime|socket (?:closed|read|error)|connection (?:reset|lost|refused)|timed out)/iu.test(message);
|
|
868
|
-
}
|
|
869
787
|
function runtimeBindingsMatch(left, right) {
|
|
870
788
|
return left.runtime === right.runtime &&
|
|
871
789
|
left.provider === right.provider &&
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DaemonConfig, SessionState } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reconcile provider-native history before a loaded session is projected.
|
|
4
|
+
*
|
|
5
|
+
* Native sessions have always used this path. Legacy Crew sessions were
|
|
6
|
+
* persisted as `source:"orchestration"` but still carry a provider session
|
|
7
|
+
* id and native-history metadata. Those sessions must take the same
|
|
8
|
+
* read-only reconciliation path after restart or before runtime truth reads;
|
|
9
|
+
* otherwise an old same-turn duplicate remains visible until an internal
|
|
10
|
+
* caller manually invokes the runtime operation.
|
|
11
|
+
*/
|
|
12
|
+
export declare function refreshNativeHistoryForLoadedSession(session: SessionState, config: DaemonConfig | undefined, requestId: string): Promise<SessionState>;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { appendAudit } from "../audit.js";
|
|
2
|
+
import { getHeadlessRuntimeManager } from "../runtime.js";
|
|
3
|
+
import { sessions } from "../state.js";
|
|
4
|
+
import { persistSessionProjection } from "./store.js";
|
|
5
|
+
import { nativeHandoffIdentity } from "./native-identity.js";
|
|
6
|
+
const orchestrationRefreshes = new Map();
|
|
7
|
+
const orchestrationRefreshCompleted = new Set();
|
|
8
|
+
const orchestrationRefreshRetryAt = new Map();
|
|
9
|
+
const orchestrationRefreshRetryDelayMs = 1_000;
|
|
10
|
+
/**
|
|
11
|
+
* Reconcile provider-native history before a loaded session is projected.
|
|
12
|
+
*
|
|
13
|
+
* Native sessions have always used this path. Legacy Crew sessions were
|
|
14
|
+
* persisted as `source:"orchestration"` but still carry a provider session
|
|
15
|
+
* id and native-history metadata. Those sessions must take the same
|
|
16
|
+
* read-only reconciliation path after restart or before runtime truth reads;
|
|
17
|
+
* otherwise an old same-turn duplicate remains visible until an internal
|
|
18
|
+
* caller manually invokes the runtime operation.
|
|
19
|
+
*/
|
|
20
|
+
export async function refreshNativeHistoryForLoadedSession(session, config, requestId) {
|
|
21
|
+
const isNative = session.source === "native";
|
|
22
|
+
const isLegacyCrew = session.source === "orchestration" && Boolean(session.providerSessionId);
|
|
23
|
+
if (!isNative && !isLegacyCrew)
|
|
24
|
+
return session;
|
|
25
|
+
const refreshKey = `${session.runtimeBinding.profile}\0${session.runtimeBinding.threadId}`;
|
|
26
|
+
if (isLegacyCrew) {
|
|
27
|
+
if (orchestrationRefreshCompleted.has(refreshKey))
|
|
28
|
+
return session;
|
|
29
|
+
const retryAt = orchestrationRefreshRetryAt.get(refreshKey);
|
|
30
|
+
if (retryAt !== undefined) {
|
|
31
|
+
if (retryAt > Date.now())
|
|
32
|
+
return session;
|
|
33
|
+
orchestrationRefreshRetryAt.delete(refreshKey);
|
|
34
|
+
}
|
|
35
|
+
const active = orchestrationRefreshes.get(refreshKey);
|
|
36
|
+
if (active)
|
|
37
|
+
return await active;
|
|
38
|
+
const operation = refreshNativeHistory(session, config, requestId, false, refreshKey);
|
|
39
|
+
orchestrationRefreshes.set(refreshKey, operation);
|
|
40
|
+
try {
|
|
41
|
+
return await operation;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
if (orchestrationRefreshes.get(refreshKey) === operation)
|
|
45
|
+
orchestrationRefreshes.delete(refreshKey);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return await refreshNativeHistory(session, config, requestId, true);
|
|
49
|
+
}
|
|
50
|
+
async function refreshNativeHistory(session, config, requestId, strict, orchestrationRefreshKey) {
|
|
51
|
+
const manager = getHeadlessRuntimeManager(session.cwd);
|
|
52
|
+
let lastError;
|
|
53
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
54
|
+
try {
|
|
55
|
+
if (attempt > 0)
|
|
56
|
+
await manager.invalidateClientForBinding(session.runtimeBinding, session.cwd);
|
|
57
|
+
const entry = await manager.clientForBinding(session.runtimeBinding, session.cwd);
|
|
58
|
+
if (typeof entry.client.syncNativeHistory !== "function") {
|
|
59
|
+
if (orchestrationRefreshKey)
|
|
60
|
+
orchestrationRefreshCompleted.add(orchestrationRefreshKey);
|
|
61
|
+
return session;
|
|
62
|
+
}
|
|
63
|
+
const providerSessionId = session.providerSessionId ?? session.runtimeBinding.threadId;
|
|
64
|
+
const expectedNativeIdentity = nativeHandoffIdentity({
|
|
65
|
+
...(config?.machineId ? { machineId: config.machineId } : {}),
|
|
66
|
+
provider: entry.provider,
|
|
67
|
+
profile: entry.profile,
|
|
68
|
+
nativeDataRootFingerprint: entry.nativeDataRootFingerprint,
|
|
69
|
+
cwd: session.cwd,
|
|
70
|
+
providerSessionId,
|
|
71
|
+
});
|
|
72
|
+
if (orchestrationRefreshKey && typeof entry.client.readThread === "function" && typeof entry.client.adoptThread === "function") {
|
|
73
|
+
const current = await entry.client.readThread({ threadId: session.runtimeBinding.threadId });
|
|
74
|
+
const metadata = current.thread.providerMetadata ?? {};
|
|
75
|
+
// Legacy Crew threads may have provider history metadata but no
|
|
76
|
+
// explicit adoption status because they were created before the
|
|
77
|
+
// native-history state machine existed. Complete that migration on
|
|
78
|
+
// the normal load/read path, preserving the existing HE thread id.
|
|
79
|
+
if (metadata.headlessNativeAdoptionStatus !== "complete" && Array.isArray(metadata.nativeHistoryTurnIds)) {
|
|
80
|
+
await entry.client.adoptThread({
|
|
81
|
+
threadId: session.runtimeBinding.threadId,
|
|
82
|
+
providerSessionId,
|
|
83
|
+
cwd: session.cwd,
|
|
84
|
+
provider: entry.provider,
|
|
85
|
+
nativeIdentity: expectedNativeIdentity,
|
|
86
|
+
manualHandoff: true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const synced = await entry.client.syncNativeHistory({
|
|
91
|
+
threadId: session.runtimeBinding.threadId,
|
|
92
|
+
nativeIdentity: expectedNativeIdentity,
|
|
93
|
+
});
|
|
94
|
+
const providerMetadata = synced.thread.providerMetadata ?? {};
|
|
95
|
+
const externallyChanged = providerMetadata.nativeHistoryChangedExternally === true;
|
|
96
|
+
// A provider thread already verified by read-only sync can refresh a
|
|
97
|
+
// stale proof produced by an earlier host build. Native sessions retain
|
|
98
|
+
// the existing safety behavior; legacy Crew sessions only update the
|
|
99
|
+
// durable observation and never become native-handoff sessions.
|
|
100
|
+
const nextProof = externallyChanged
|
|
101
|
+
? undefined
|
|
102
|
+
: session.manualHandoffProof && typeof providerMetadata.nativeHistoryVersion === "string"
|
|
103
|
+
? { ...session.manualHandoffProof, historyVersion: providerMetadata.nativeHistoryVersion }
|
|
104
|
+
: session.manualHandoffProof;
|
|
105
|
+
const next = {
|
|
106
|
+
...session,
|
|
107
|
+
...(strict && isUsableString(expectedNativeIdentity) ? { nativeIdentity: expectedNativeIdentity } : {}),
|
|
108
|
+
...(typeof providerMetadata.nativeHistoryVersion === "string" ? { nativeHistoryVersion: providerMetadata.nativeHistoryVersion } : {}),
|
|
109
|
+
...(strict && externallyChanged
|
|
110
|
+
? { manualHandoffProof: undefined }
|
|
111
|
+
: strict && nextProof
|
|
112
|
+
? { manualHandoffProof: nextProof }
|
|
113
|
+
: {}),
|
|
114
|
+
...(strict && session.nativeHistorySyncError ? { nativeHistorySyncError: undefined } : {}),
|
|
115
|
+
};
|
|
116
|
+
sessions.set(session.sessionId, next);
|
|
117
|
+
await persistSessionProjection(next);
|
|
118
|
+
if (orchestrationRefreshKey) {
|
|
119
|
+
orchestrationRefreshRetryAt.delete(orchestrationRefreshKey);
|
|
120
|
+
orchestrationRefreshCompleted.add(orchestrationRefreshKey);
|
|
121
|
+
}
|
|
122
|
+
return next;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
lastError = error;
|
|
126
|
+
if (attempt === 0 && isNativeRuntimeTransportFailure(error))
|
|
127
|
+
continue;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!strict) {
|
|
132
|
+
// Legacy Crew reconciliation is best-effort. The authoritative HE event
|
|
133
|
+
// log remains readable when a provider cannot perform native refresh; do
|
|
134
|
+
// not turn a harmless compatibility probe into a session failure.
|
|
135
|
+
if (orchestrationRefreshKey) {
|
|
136
|
+
if (isDefinitelyUnsupportedLegacyRefreshError(lastError))
|
|
137
|
+
orchestrationRefreshCompleted.add(orchestrationRefreshKey);
|
|
138
|
+
else
|
|
139
|
+
orchestrationRefreshRetryAt.set(orchestrationRefreshKey, Date.now() + orchestrationRefreshRetryDelayMs);
|
|
140
|
+
}
|
|
141
|
+
return session;
|
|
142
|
+
}
|
|
143
|
+
const code = errorCode(lastError) ?? "NATIVE_HISTORY_SYNC_FAILED";
|
|
144
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
145
|
+
const failed = {
|
|
146
|
+
...session,
|
|
147
|
+
nativeHistorySyncError: { code, message, checkedAt: new Date().toISOString() },
|
|
148
|
+
};
|
|
149
|
+
sessions.set(session.sessionId, failed);
|
|
150
|
+
await persistSessionProjection(failed);
|
|
151
|
+
if (config) {
|
|
152
|
+
await appendAudit({
|
|
153
|
+
sessionId: session.sessionId,
|
|
154
|
+
machineId: config.machineId,
|
|
155
|
+
actor: "system",
|
|
156
|
+
action: "session.native_history.refresh_failed",
|
|
157
|
+
summary: `Read-only native history refresh did not change session ${session.sessionId}`,
|
|
158
|
+
evidence: { requestId, code, error: message },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return failed;
|
|
162
|
+
}
|
|
163
|
+
function isUsableString(value) {
|
|
164
|
+
return typeof value === "string" && value.length > 0;
|
|
165
|
+
}
|
|
166
|
+
function isDefinitelyUnsupportedLegacyRefreshError(error) {
|
|
167
|
+
const code = errorCode(error);
|
|
168
|
+
return code === "CAPABILITY_UNSUPPORTED";
|
|
169
|
+
}
|
|
170
|
+
function errorCode(error) {
|
|
171
|
+
if (!error || typeof error !== "object")
|
|
172
|
+
return undefined;
|
|
173
|
+
const code = error.code;
|
|
174
|
+
return typeof code === "string" ? code : undefined;
|
|
175
|
+
}
|
|
176
|
+
function isNativeRuntimeTransportFailure(error) {
|
|
177
|
+
const code = errorCode(error);
|
|
178
|
+
if (code && /^(ECONN|EPIPE|RUNTIME_(?:DISPOSED|UNAVAILABLE)|HOST_(?:START|SHUTDOWN)|REQUEST_TIMEOUT)$/u.test(code))
|
|
179
|
+
return true;
|
|
180
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
181
|
+
return /(headless runtime (?:socket|request|exited)|cannot write to headless runtime|socket (?:closed|read|error)|connection (?:reset|lost|refused)|timed out)/iu.test(message);
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=native-history-refresh.js.map
|
|
@@ -5,13 +5,19 @@ import { runtimeTurnIdForCrewTurn } from "../orchestration/store.js";
|
|
|
5
5
|
import { runtimeTurnIdForInboundPrompt } from "../relay/prompt-admission-store.js";
|
|
6
6
|
import { loadSessionProjections } from "./store.js";
|
|
7
7
|
import { runtimeSessionIdForSession } from "./he-official/common.js";
|
|
8
|
+
import { refreshNativeHistoryForLoadedSession } from "./native-history-refresh.js";
|
|
8
9
|
export async function runtimeRead(config, input) {
|
|
9
|
-
void config;
|
|
10
10
|
let runtimeSessionId;
|
|
11
11
|
try {
|
|
12
|
-
|
|
12
|
+
let session = await sessionStateForRuntimeTruth(input.sessionId);
|
|
13
13
|
if (!session)
|
|
14
14
|
return unreachable(input, `Session ${input.sessionId} is not loaded on this daemon`, undefined, "SESSION_NOT_FOUND");
|
|
15
|
+
// Legacy Crew sessions were persisted as source=orchestration even though
|
|
16
|
+
// their Headless thread already carries provider-native history metadata.
|
|
17
|
+
// Reconcile that history before projecting turn.result/status so an
|
|
18
|
+
// upgrade/restart repairs old same-turn duplicates through the official
|
|
19
|
+
// read path, without requiring an internal syncNativeHistory call.
|
|
20
|
+
session = await refreshNativeHistoryForLoadedSession(session, config, `runtime_truth_refresh_${input.requestId}`);
|
|
15
21
|
runtimeSessionId = runtimeSessionIdForSession(session);
|
|
16
22
|
if (!runtimeSessionId)
|
|
17
23
|
return unreachable(input, `Session ${input.sessionId} does not have a runtime session binding`, undefined, "SESSION_BINDING_MISSING");
|
package/apps/daemon/package.json
CHANGED
package/package.json
CHANGED
|
@@ -514,6 +514,28 @@ export class HeadlessRuntimeCore {
|
|
|
514
514
|
changed = true;
|
|
515
515
|
}
|
|
516
516
|
const canonicalMapping = mapNativeItemsToProjection(sourceTurn.items, canonicalItems, sourceItemBindings, sourceTurn.id);
|
|
517
|
+
// A pre-native-history projection can contain multiple HE text items for
|
|
518
|
+
// one provider-native item (the old Codex/Claude resume path emitted a
|
|
519
|
+
// second completion item in the same turn). Once the provider gives us
|
|
520
|
+
// the authoritative ordered item list, remove only unmapped text items
|
|
521
|
+
// whose type and exact text are already represented by a mapped native
|
|
522
|
+
// item. We deliberately do not dedupe by text alone: a native turn may
|
|
523
|
+
// legitimately contain two equal assistant messages, and tool/status
|
|
524
|
+
// items outside the native text list must remain visible.
|
|
525
|
+
if (canonicalMapping.valid && canonicalMapping.itemIds.every((itemId) => typeof itemId === "string")) {
|
|
526
|
+
const mappedSourceItems = sourceTurn.items.filter((_, index) => canonicalMapping.itemIds[index]);
|
|
527
|
+
const mappedItemIds = new Set(canonicalMapping.itemIds);
|
|
528
|
+
const duplicateTextItemIds = canonicalItems
|
|
529
|
+
.filter((item) => !mappedItemIds.has(item.id))
|
|
530
|
+
.filter((item) => item.type === "agentMessage" || item.type === "userMessage")
|
|
531
|
+
.filter((item) => mappedSourceItems.some((sourceItem) => (sourceItem.kind === "assistant" ? item.type === "agentMessage" : item.type === "userMessage")
|
|
532
|
+
&& item.text === sourceItem.text))
|
|
533
|
+
.map((item) => item.id);
|
|
534
|
+
if (duplicateTextItemIds.length > 0) {
|
|
535
|
+
removedItemIds.push(...duplicateTextItemIds);
|
|
536
|
+
changed = true;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
517
539
|
const duplicateMapping = duplicateTurn
|
|
518
540
|
? mapNativeItemsToProjection(sourceTurn.items, duplicateItems, sourceItemBindings, sourceTurn.id)
|
|
519
541
|
: undefined;
|