@indigoai-us/hq-cloud 6.18.35 → 6.18.38-alpha.0
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/bin/push-failure-backoff.d.ts +22 -0
- package/dist/bin/push-failure-backoff.d.ts.map +1 -0
- package/dist/bin/push-failure-backoff.js +27 -0
- package/dist/bin/push-failure-backoff.js.map +1 -0
- package/dist/bin/sync-runner-delete-snapshot-mixed.bench.test.js +9 -2
- package/dist/bin/sync-runner-delete-snapshot-mixed.bench.test.js.map +1 -1
- package/dist/bin/sync-runner-delete-snapshot.bench.test.js +3 -1
- package/dist/bin/sync-runner-delete-snapshot.bench.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +165 -34
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.test.js +46 -0
- package/dist/bin/sync-runner-watch-loop.test.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +11 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +23 -4
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +956 -4
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/lib/describe-error.d.ts +4 -0
- package/dist/lib/describe-error.d.ts.map +1 -1
- package/dist/lib/describe-error.js +37 -0
- package/dist/lib/describe-error.js.map +1 -1
- package/dist/lib/describe-error.test.js +18 -1
- package/dist/lib/describe-error.test.js.map +1 -1
- package/dist/outcome-telemetry.d.ts +16 -6
- package/dist/outcome-telemetry.d.ts.map +1 -1
- package/dist/outcome-telemetry.js +105 -11
- package/dist/outcome-telemetry.js.map +1 -1
- package/dist/outcome-telemetry.test.js +236 -0
- package/dist/outcome-telemetry.test.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +5 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +115 -52
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +349 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -6682,6 +6682,469 @@ describe("runRunnerWithLoop — event-push wiring", () => {
|
|
|
6682
6682
|
process.env.HQ_SYNC_PARALLEL_COORDINATOR = previousCoordinator;
|
|
6683
6683
|
}
|
|
6684
6684
|
});
|
|
6685
|
+
it("handles fake SQS A/B delivery through coordinator preemption", async () => {
|
|
6686
|
+
const previousCoordinator = process.env.HQ_SYNC_PARALLEL_COORDINATOR;
|
|
6687
|
+
process.env.HQ_SYNC_PARALLEL_COORDINATOR = "1";
|
|
6688
|
+
const personalPath = "personal/notes/fake-sqs-preempt.md";
|
|
6689
|
+
const event = (relativePath, sequenceNumber) => ({
|
|
6690
|
+
kind: "upsert",
|
|
6691
|
+
relativePath,
|
|
6692
|
+
contentHash: `sha256:${"0".repeat(64)}`,
|
|
6693
|
+
mtime: "2026-10-02T12:00:00.000Z",
|
|
6694
|
+
originDeviceId: "peer-device",
|
|
6695
|
+
originTenantId: "tenant-indigo",
|
|
6696
|
+
sequenceNumber,
|
|
6697
|
+
eventTimestamp: "2026-10-02T12:00:00.000Z",
|
|
6698
|
+
});
|
|
6699
|
+
const queue = [
|
|
6700
|
+
{ receipt: "rh-a", events: [event(personalPath, 1), event("companies/indigo/a-side.md", 1)] },
|
|
6701
|
+
{ receipt: "rh-b", events: [event(personalPath, 2)] },
|
|
6702
|
+
];
|
|
6703
|
+
const deleted = [];
|
|
6704
|
+
const released = [];
|
|
6705
|
+
let receiveCalls = 0;
|
|
6706
|
+
const receiveMessage = async () => {
|
|
6707
|
+
receiveCalls += 1;
|
|
6708
|
+
return queue.shift();
|
|
6709
|
+
};
|
|
6710
|
+
let receiverSync;
|
|
6711
|
+
let shutdown = () => { };
|
|
6712
|
+
let releaseFirst = () => { };
|
|
6713
|
+
let markFirstStarted = () => { };
|
|
6714
|
+
const firstStarted = new Promise((resolve) => { markFirstStarted = resolve; });
|
|
6715
|
+
const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
|
|
6716
|
+
const appliedPersonalPaths = [];
|
|
6717
|
+
let personalPasses = 0;
|
|
6718
|
+
const runPass = vi.fn(async (argv, checkpoint) => {
|
|
6719
|
+
if (argv.includes(personalPath)) {
|
|
6720
|
+
personalPasses += 1;
|
|
6721
|
+
if (personalPasses === 1) {
|
|
6722
|
+
markFirstStarted();
|
|
6723
|
+
await firstGate;
|
|
6724
|
+
if (checkpoint?.shouldAbort?.())
|
|
6725
|
+
return completedPassOutcome();
|
|
6726
|
+
}
|
|
6727
|
+
appliedPersonalPaths.push(personalPath);
|
|
6728
|
+
}
|
|
6729
|
+
return completedPassOutcome();
|
|
6730
|
+
});
|
|
6731
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--direction", "pull", "--hq-root", "/tmp/hq"], {
|
|
6732
|
+
runPass,
|
|
6733
|
+
runPassSupportsCooperativeCheckpoint: true,
|
|
6734
|
+
createWatcher: () => makeBatchWatcherStub(),
|
|
6735
|
+
createReceiver: ({ syncBatchFn }) => {
|
|
6736
|
+
receiverSync = syncBatchFn;
|
|
6737
|
+
return { connected: true, start: async () => { }, dispose: async () => { } };
|
|
6738
|
+
},
|
|
6739
|
+
sleep: () => new Promise(() => { }),
|
|
6740
|
+
onShutdownSignal: (handler) => { shutdown = handler; return () => { }; },
|
|
6741
|
+
});
|
|
6742
|
+
const settle = async (receipt, delivery) => {
|
|
6743
|
+
try {
|
|
6744
|
+
await delivery;
|
|
6745
|
+
deleted.push(receipt);
|
|
6746
|
+
}
|
|
6747
|
+
catch {
|
|
6748
|
+
released.push(receipt);
|
|
6749
|
+
}
|
|
6750
|
+
};
|
|
6751
|
+
try {
|
|
6752
|
+
await flushLoopMicrotasks(30);
|
|
6753
|
+
const deliveryA = await receiveMessage();
|
|
6754
|
+
const handledA = receiverSync({ events: deliveryA.events, signal: new AbortController().signal });
|
|
6755
|
+
await firstStarted;
|
|
6756
|
+
const deliveryB = await receiveMessage();
|
|
6757
|
+
const handledB = receiverSync({ events: deliveryB.events, signal: new AbortController().signal });
|
|
6758
|
+
await flushLoopMicrotasks(30);
|
|
6759
|
+
releaseFirst();
|
|
6760
|
+
await Promise.all([settle(deliveryA.receipt, handledA), settle(deliveryB.receipt, handledB)]);
|
|
6761
|
+
expect(receiveCalls).toBe(2);
|
|
6762
|
+
expect(appliedPersonalPaths).toEqual([personalPath]);
|
|
6763
|
+
expect(personalPasses).toBe(2);
|
|
6764
|
+
expect(deleted).toContain("rh-b");
|
|
6765
|
+
expect(deleted.includes("rh-a") || released.includes("rh-a")).toBe(true);
|
|
6766
|
+
expect(receiveCalls).toBeLessThanOrEqual(2);
|
|
6767
|
+
}
|
|
6768
|
+
finally {
|
|
6769
|
+
releaseFirst();
|
|
6770
|
+
shutdown();
|
|
6771
|
+
await loop;
|
|
6772
|
+
if (previousCoordinator === undefined)
|
|
6773
|
+
delete process.env.HQ_SYNC_PARALLEL_COORDINATOR;
|
|
6774
|
+
else
|
|
6775
|
+
process.env.HQ_SYNC_PARALLEL_COORDINATOR = previousCoordinator;
|
|
6776
|
+
}
|
|
6777
|
+
});
|
|
6778
|
+
it("keeps fake SQS polling after A and B are admitted and applies B within bounded steps", async () => {
|
|
6779
|
+
const previousCoordinator = process.env.HQ_SYNC_PARALLEL_COORDINATOR;
|
|
6780
|
+
process.env.HQ_SYNC_PARALLEL_COORDINATOR = "1";
|
|
6781
|
+
const personalPath = "personal/notes/fake-sqs-bounded-preempt.md";
|
|
6782
|
+
const event = (relativePath, sequenceNumber) => ({
|
|
6783
|
+
kind: "upsert",
|
|
6784
|
+
relativePath,
|
|
6785
|
+
contentHash: `sha256:${"0".repeat(64)}`,
|
|
6786
|
+
mtime: "2026-10-02T12:00:00.000Z",
|
|
6787
|
+
originDeviceId: "peer-device",
|
|
6788
|
+
originTenantId: "tenant-indigo",
|
|
6789
|
+
sequenceNumber,
|
|
6790
|
+
eventTimestamp: "2026-10-02T12:00:00.000Z",
|
|
6791
|
+
});
|
|
6792
|
+
const queue = [
|
|
6793
|
+
{ receipt: "rh-bounded-a", events: [event(personalPath, 1), event("companies/indigo/bounded-a.md", 1)] },
|
|
6794
|
+
{ receipt: "rh-bounded-b", events: [event(personalPath, 2)] },
|
|
6795
|
+
null,
|
|
6796
|
+
];
|
|
6797
|
+
const deleted = [];
|
|
6798
|
+
const released = [];
|
|
6799
|
+
let receiveCalls = 0;
|
|
6800
|
+
const receiveMessage = async () => {
|
|
6801
|
+
receiveCalls += 1;
|
|
6802
|
+
return queue.shift() ?? null;
|
|
6803
|
+
};
|
|
6804
|
+
let receiverSync;
|
|
6805
|
+
let shutdown = () => { };
|
|
6806
|
+
let releaseFirst = () => { };
|
|
6807
|
+
let markFirstStarted = () => { };
|
|
6808
|
+
const firstStarted = new Promise((resolve) => { markFirstStarted = resolve; });
|
|
6809
|
+
const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
|
|
6810
|
+
const appliedPersonalPaths = [];
|
|
6811
|
+
let personalPasses = 0;
|
|
6812
|
+
const runPass = vi.fn(async (argv, checkpoint) => {
|
|
6813
|
+
if (argv.includes(personalPath)) {
|
|
6814
|
+
personalPasses += 1;
|
|
6815
|
+
if (personalPasses === 1) {
|
|
6816
|
+
markFirstStarted();
|
|
6817
|
+
await firstGate;
|
|
6818
|
+
if (checkpoint?.shouldAbort?.())
|
|
6819
|
+
return completedPassOutcome();
|
|
6820
|
+
}
|
|
6821
|
+
appliedPersonalPaths.push(personalPath);
|
|
6822
|
+
}
|
|
6823
|
+
return completedPassOutcome();
|
|
6824
|
+
});
|
|
6825
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--direction", "pull", "--hq-root", "/tmp/hq"], {
|
|
6826
|
+
runPass,
|
|
6827
|
+
runPassSupportsCooperativeCheckpoint: true,
|
|
6828
|
+
createWatcher: () => makeBatchWatcherStub(),
|
|
6829
|
+
createReceiver: ({ syncBatchFn }) => {
|
|
6830
|
+
receiverSync = syncBatchFn;
|
|
6831
|
+
return { connected: true, start: async () => { }, dispose: async () => { } };
|
|
6832
|
+
},
|
|
6833
|
+
sleep: () => new Promise(() => { }),
|
|
6834
|
+
onShutdownSignal: (handler) => { shutdown = handler; return () => { }; },
|
|
6835
|
+
});
|
|
6836
|
+
const admit = (delivery) => {
|
|
6837
|
+
const handled = receiverSync({ events: delivery.events, signal: new AbortController().signal });
|
|
6838
|
+
return handled.then(() => { deleted.push(delivery.receipt); }, () => { released.push(delivery.receipt); });
|
|
6839
|
+
};
|
|
6840
|
+
let handledA;
|
|
6841
|
+
let handledB;
|
|
6842
|
+
try {
|
|
6843
|
+
await flushLoopMicrotasks(30);
|
|
6844
|
+
const deliveryA = await receiveMessage();
|
|
6845
|
+
expect(deliveryA).not.toBeNull();
|
|
6846
|
+
handledA = admit(deliveryA);
|
|
6847
|
+
await firstStarted;
|
|
6848
|
+
const deliveryB = await receiveMessage();
|
|
6849
|
+
expect(deliveryB).not.toBeNull();
|
|
6850
|
+
handledB = admit(deliveryB);
|
|
6851
|
+
// A third receive is issued after both long pulls have been admitted.
|
|
6852
|
+
expect(await receiveMessage()).toBeNull();
|
|
6853
|
+
expect(receiveCalls).toBe(3);
|
|
6854
|
+
releaseFirst();
|
|
6855
|
+
let schedulerSteps = 0;
|
|
6856
|
+
while (appliedPersonalPaths.length === 0 && schedulerSteps < 12) {
|
|
6857
|
+
schedulerSteps += 1;
|
|
6858
|
+
await flushLoopMicrotasks(1);
|
|
6859
|
+
}
|
|
6860
|
+
expect(personalPasses).toBe(2);
|
|
6861
|
+
expect(appliedPersonalPaths).toEqual([personalPath]);
|
|
6862
|
+
expect(schedulerSteps).toBeLessThanOrEqual(12);
|
|
6863
|
+
await Promise.all([handledA, handledB]);
|
|
6864
|
+
expect(deleted).toContain("rh-bounded-b");
|
|
6865
|
+
expect(deleted.includes("rh-bounded-a") || released.includes("rh-bounded-a")).toBe(true);
|
|
6866
|
+
}
|
|
6867
|
+
finally {
|
|
6868
|
+
releaseFirst();
|
|
6869
|
+
shutdown();
|
|
6870
|
+
await loop;
|
|
6871
|
+
if (previousCoordinator === undefined)
|
|
6872
|
+
delete process.env.HQ_SYNC_PARALLEL_COORDINATOR;
|
|
6873
|
+
else
|
|
6874
|
+
process.env.HQ_SYNC_PARALLEL_COORDINATOR = previousCoordinator;
|
|
6875
|
+
}
|
|
6876
|
+
});
|
|
6877
|
+
it("threads the receiver drain abort signal to the targeted pull checkpoint", async () => {
|
|
6878
|
+
const previousCoordinator = process.env.HQ_SYNC_PARALLEL_COORDINATOR;
|
|
6879
|
+
process.env.HQ_SYNC_PARALLEL_COORDINATOR = "1";
|
|
6880
|
+
const targetPath = "personal/notes/receiver-timeout-abort.md";
|
|
6881
|
+
let receiverSync;
|
|
6882
|
+
let shutdown = () => { };
|
|
6883
|
+
let releasePull = () => { };
|
|
6884
|
+
let markPullStarted = () => { };
|
|
6885
|
+
const pullStarted = new Promise((resolve) => { markPullStarted = resolve; });
|
|
6886
|
+
const pullGate = new Promise((resolve) => { releasePull = resolve; });
|
|
6887
|
+
let abortObservedAtSafePoint = false;
|
|
6888
|
+
const runPass = vi.fn(async (argv, checkpoint) => {
|
|
6889
|
+
if (!argv.includes(targetPath))
|
|
6890
|
+
return completedPassOutcome();
|
|
6891
|
+
markPullStarted();
|
|
6892
|
+
await pullGate;
|
|
6893
|
+
abortObservedAtSafePoint = checkpoint?.shouldAbort?.() === true;
|
|
6894
|
+
return completedPassOutcome();
|
|
6895
|
+
});
|
|
6896
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--direction", "pull", "--hq-root", "/tmp/hq"], {
|
|
6897
|
+
runPass,
|
|
6898
|
+
runPassSupportsCooperativeCheckpoint: true,
|
|
6899
|
+
createWatcher: () => makeBatchWatcherStub(),
|
|
6900
|
+
createReceiver: ({ syncBatchFn }) => {
|
|
6901
|
+
receiverSync = syncBatchFn;
|
|
6902
|
+
return { connected: true, start: async () => { }, dispose: async () => { } };
|
|
6903
|
+
},
|
|
6904
|
+
sleep: () => new Promise(() => { }),
|
|
6905
|
+
onShutdownSignal: (handler) => { shutdown = handler; return () => { }; },
|
|
6906
|
+
});
|
|
6907
|
+
const event = {
|
|
6908
|
+
kind: "upsert",
|
|
6909
|
+
relativePath: targetPath,
|
|
6910
|
+
contentHash: `sha256:${"0".repeat(64)}`,
|
|
6911
|
+
mtime: "2026-10-02T12:00:00.000Z",
|
|
6912
|
+
originDeviceId: "peer-device",
|
|
6913
|
+
originTenantId: "tenant-indigo",
|
|
6914
|
+
sequenceNumber: 1,
|
|
6915
|
+
eventTimestamp: "2026-10-02T12:00:00.000Z",
|
|
6916
|
+
};
|
|
6917
|
+
const controller = new AbortController();
|
|
6918
|
+
try {
|
|
6919
|
+
await flushLoopMicrotasks(30);
|
|
6920
|
+
const delivery = receiverSync({ events: [event], signal: controller.signal });
|
|
6921
|
+
await pullStarted;
|
|
6922
|
+
controller.abort(new Error("test drain timeout"));
|
|
6923
|
+
releasePull();
|
|
6924
|
+
await delivery;
|
|
6925
|
+
expect(abortObservedAtSafePoint).toBe(true);
|
|
6926
|
+
}
|
|
6927
|
+
finally {
|
|
6928
|
+
releasePull();
|
|
6929
|
+
shutdown();
|
|
6930
|
+
await loop;
|
|
6931
|
+
if (previousCoordinator === undefined)
|
|
6932
|
+
delete process.env.HQ_SYNC_PARALLEL_COORDINATOR;
|
|
6933
|
+
else
|
|
6934
|
+
process.env.HQ_SYNC_PARALLEL_COORDINATOR = previousCoordinator;
|
|
6935
|
+
}
|
|
6936
|
+
});
|
|
6937
|
+
it("stops a preempted receiver pull at its next durable file boundary", async () => {
|
|
6938
|
+
const previousCoordinator = process.env.HQ_SYNC_PARALLEL_COORDINATOR;
|
|
6939
|
+
process.env.HQ_SYNC_PARALLEL_COORDINATOR = "1";
|
|
6940
|
+
let receiverSync;
|
|
6941
|
+
let shutdown = () => { };
|
|
6942
|
+
let releaseFirst = () => { };
|
|
6943
|
+
let markFirstStarted = () => { };
|
|
6944
|
+
const firstStarted = new Promise((resolve) => { markFirstStarted = resolve; });
|
|
6945
|
+
const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
|
|
6946
|
+
const appliedPersonalPaths = [];
|
|
6947
|
+
const localFiles = new Map();
|
|
6948
|
+
const journalRows = new Map();
|
|
6949
|
+
const runPass = vi.fn(async (argv, checkpoint) => {
|
|
6950
|
+
const personalPaths = [
|
|
6951
|
+
"personal/notes/preempted.md",
|
|
6952
|
+
"personal/notes/next-file.md",
|
|
6953
|
+
].filter((path) => argv.includes(path));
|
|
6954
|
+
if (personalPaths.includes("personal/notes/preempted.md") && appliedPersonalPaths.length === 0) {
|
|
6955
|
+
markFirstStarted();
|
|
6956
|
+
await firstGate;
|
|
6957
|
+
if (checkpoint?.shouldAbort?.())
|
|
6958
|
+
return completedPassOutcome();
|
|
6959
|
+
}
|
|
6960
|
+
for (const path of personalPaths) {
|
|
6961
|
+
if (checkpoint?.shouldAbort?.())
|
|
6962
|
+
break;
|
|
6963
|
+
// Model the sync engine's durable safe point: the local replacement and
|
|
6964
|
+
// its journal row are committed together before the next abort check.
|
|
6965
|
+
localFiles.set(path, `delivery-${argv.includes("--sequence=2") ? 2 : 1}`);
|
|
6966
|
+
journalRows.set(path, localFiles.get(path));
|
|
6967
|
+
appliedPersonalPaths.push(path);
|
|
6968
|
+
}
|
|
6969
|
+
return completedPassOutcome();
|
|
6970
|
+
});
|
|
6971
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--direction", "pull", "--hq-root", "/tmp/hq"], {
|
|
6972
|
+
runPass,
|
|
6973
|
+
runPassSupportsCooperativeCheckpoint: true,
|
|
6974
|
+
createWatcher: () => makeBatchWatcherStub(),
|
|
6975
|
+
createReceiver: ({ syncBatchFn }) => {
|
|
6976
|
+
receiverSync = syncBatchFn;
|
|
6977
|
+
return { connected: true, start: async () => { }, dispose: async () => { } };
|
|
6978
|
+
},
|
|
6979
|
+
sleep: () => new Promise(() => { }),
|
|
6980
|
+
onShutdownSignal: (handler) => { shutdown = handler; return () => { }; },
|
|
6981
|
+
});
|
|
6982
|
+
const event = (relativePath, sequenceNumber) => ({
|
|
6983
|
+
kind: "upsert",
|
|
6984
|
+
relativePath,
|
|
6985
|
+
contentHash: "sha256:remote",
|
|
6986
|
+
mtime: "2026-10-02T12:00:00.000Z",
|
|
6987
|
+
originDeviceId: "peer-device",
|
|
6988
|
+
originTenantId: "tenant-indigo",
|
|
6989
|
+
sequenceNumber,
|
|
6990
|
+
eventTimestamp: "2026-10-02T12:00:00.000Z",
|
|
6991
|
+
});
|
|
6992
|
+
try {
|
|
6993
|
+
await flushLoopMicrotasks(30);
|
|
6994
|
+
const deliveryA = receiverSync({
|
|
6995
|
+
events: [event("personal/notes/preempted.md", 1), event("personal/notes/next-file.md", 1)],
|
|
6996
|
+
signal: new AbortController().signal,
|
|
6997
|
+
});
|
|
6998
|
+
await firstStarted;
|
|
6999
|
+
const deliveryB = receiverSync({
|
|
7000
|
+
events: [event("personal/notes/preempted.md", 2), event("personal/notes/next-file.md", 2)],
|
|
7001
|
+
signal: new AbortController().signal,
|
|
7002
|
+
});
|
|
7003
|
+
await flushLoopMicrotasks(30);
|
|
7004
|
+
releaseFirst();
|
|
7005
|
+
await deliveryA;
|
|
7006
|
+
await deliveryB;
|
|
7007
|
+
expect(appliedPersonalPaths).toEqual([
|
|
7008
|
+
"personal/notes/preempted.md",
|
|
7009
|
+
"personal/notes/next-file.md",
|
|
7010
|
+
]);
|
|
7011
|
+
expect([...localFiles.keys()]).toEqual([...journalRows.keys()]);
|
|
7012
|
+
expect([...journalRows.entries()]).toEqual([...localFiles.entries()]);
|
|
7013
|
+
expect(localFiles.size).toBe(2);
|
|
7014
|
+
expect(journalRows.has("personal/notes/never-written.md")).toBe(false);
|
|
7015
|
+
}
|
|
7016
|
+
finally {
|
|
7017
|
+
releaseFirst();
|
|
7018
|
+
shutdown();
|
|
7019
|
+
await loop;
|
|
7020
|
+
if (previousCoordinator === undefined)
|
|
7021
|
+
delete process.env.HQ_SYNC_PARALLEL_COORDINATOR;
|
|
7022
|
+
else
|
|
7023
|
+
process.env.HQ_SYNC_PARALLEL_COORDINATOR = previousCoordinator;
|
|
7024
|
+
}
|
|
7025
|
+
});
|
|
7026
|
+
it("reconciles on-disk files and journal rows after a pull is preempted", async () => {
|
|
7027
|
+
const previousCoordinator = process.env.HQ_SYNC_PARALLEL_COORDINATOR;
|
|
7028
|
+
process.env.HQ_SYNC_PARALLEL_COORDINATOR = "1";
|
|
7029
|
+
const journalSlug = "receiver-preempt-durable-boundary";
|
|
7030
|
+
const durablePath = "personal/notes/preempt-durable.md";
|
|
7031
|
+
const nextPath = "personal/notes/preempt-next.md";
|
|
7032
|
+
const localRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-preempt-durable-"));
|
|
7033
|
+
const journal = { version: "2", lastSync: "", pulls: [], files: {} };
|
|
7034
|
+
writeJournal(journalSlug, journal);
|
|
7035
|
+
let receiverSync;
|
|
7036
|
+
let shutdown = () => { };
|
|
7037
|
+
let releaseFirst = () => { };
|
|
7038
|
+
let markFirstStarted = () => { };
|
|
7039
|
+
const firstStarted = new Promise((resolve) => { markFirstStarted = resolve; });
|
|
7040
|
+
const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
|
|
7041
|
+
const writesByPull = [];
|
|
7042
|
+
let targetedPulls = 0;
|
|
7043
|
+
const persist = (relativePath, body, writes) => {
|
|
7044
|
+
const localPath = path.join(localRoot, relativePath);
|
|
7045
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
7046
|
+
fs.writeFileSync(localPath, body);
|
|
7047
|
+
const stat = fs.statSync(localPath);
|
|
7048
|
+
const state = readJournal(journalSlug);
|
|
7049
|
+
state.files[relativePath] = {
|
|
7050
|
+
hash: body,
|
|
7051
|
+
size: stat.size,
|
|
7052
|
+
syncedAt: "2026-10-02T12:00:00.000Z",
|
|
7053
|
+
direction: "down",
|
|
7054
|
+
remoteEtag: body,
|
|
7055
|
+
mtimeMs: stat.mtimeMs,
|
|
7056
|
+
ctimeMs: stat.ctimeMs,
|
|
7057
|
+
};
|
|
7058
|
+
writeJournal(journalSlug, state);
|
|
7059
|
+
writes.push(relativePath);
|
|
7060
|
+
};
|
|
7061
|
+
const isDurable = (relativePath) => {
|
|
7062
|
+
const localPath = path.join(localRoot, relativePath);
|
|
7063
|
+
if (!fs.existsSync(localPath))
|
|
7064
|
+
return false;
|
|
7065
|
+
const state = readJournal(journalSlug);
|
|
7066
|
+
return state.files[relativePath]?.hash === fs.readFileSync(localPath, "utf8");
|
|
7067
|
+
};
|
|
7068
|
+
const runPass = vi.fn(async (argv, checkpoint) => {
|
|
7069
|
+
const paths = [durablePath, nextPath].filter((candidate) => argv.includes(candidate));
|
|
7070
|
+
if (paths.length === 0)
|
|
7071
|
+
return completedPassOutcome();
|
|
7072
|
+
const pullNumber = ++targetedPulls;
|
|
7073
|
+
const writes = [];
|
|
7074
|
+
writesByPull[pullNumber - 1] = writes;
|
|
7075
|
+
if (pullNumber === 1) {
|
|
7076
|
+
persist(durablePath, "durable-before-preemption", writes);
|
|
7077
|
+
markFirstStarted();
|
|
7078
|
+
await firstGate;
|
|
7079
|
+
if (checkpoint?.shouldAbort?.())
|
|
7080
|
+
return completedPassOutcome();
|
|
7081
|
+
}
|
|
7082
|
+
for (const relativePath of paths) {
|
|
7083
|
+
if (checkpoint?.shouldAbort?.())
|
|
7084
|
+
break;
|
|
7085
|
+
if (isDurable(relativePath))
|
|
7086
|
+
continue;
|
|
7087
|
+
persist(relativePath, `delivery-${pullNumber}-${relativePath}`, writes);
|
|
7088
|
+
}
|
|
7089
|
+
return completedPassOutcome();
|
|
7090
|
+
});
|
|
7091
|
+
const loop = runRunnerWithLoop(["--companies", "--watch", "--event-push", "--direction", "pull", "--hq-root", "/tmp/hq"], {
|
|
7092
|
+
runPass,
|
|
7093
|
+
runPassSupportsCooperativeCheckpoint: true,
|
|
7094
|
+
createWatcher: () => makeBatchWatcherStub(),
|
|
7095
|
+
createReceiver: ({ syncBatchFn }) => {
|
|
7096
|
+
receiverSync = syncBatchFn;
|
|
7097
|
+
return { connected: true, start: async () => { }, dispose: async () => { } };
|
|
7098
|
+
},
|
|
7099
|
+
sleep: () => new Promise(() => { }),
|
|
7100
|
+
onShutdownSignal: (handler) => { shutdown = handler; return () => { }; },
|
|
7101
|
+
});
|
|
7102
|
+
const event = (relativePath, sequenceNumber) => ({
|
|
7103
|
+
kind: "upsert",
|
|
7104
|
+
relativePath,
|
|
7105
|
+
contentHash: `sha256:${"0".repeat(64)}`,
|
|
7106
|
+
mtime: "2026-10-02T12:00:00.000Z",
|
|
7107
|
+
originDeviceId: "peer-device",
|
|
7108
|
+
originTenantId: "tenant-indigo",
|
|
7109
|
+
sequenceNumber,
|
|
7110
|
+
eventTimestamp: "2026-10-02T12:00:00.000Z",
|
|
7111
|
+
});
|
|
7112
|
+
try {
|
|
7113
|
+
await flushLoopMicrotasks(30);
|
|
7114
|
+
const deliveryA = receiverSync({
|
|
7115
|
+
events: [event(durablePath, 1), event(nextPath, 1)],
|
|
7116
|
+
signal: new AbortController().signal,
|
|
7117
|
+
});
|
|
7118
|
+
await firstStarted;
|
|
7119
|
+
const deliveryB = receiverSync({
|
|
7120
|
+
events: [event(durablePath, 2), event(nextPath, 2)],
|
|
7121
|
+
signal: new AbortController().signal,
|
|
7122
|
+
});
|
|
7123
|
+
await flushLoopMicrotasks(30);
|
|
7124
|
+
releaseFirst();
|
|
7125
|
+
await Promise.all([deliveryA, deliveryB]);
|
|
7126
|
+
expect(writesByPull[0]).toEqual([durablePath]);
|
|
7127
|
+
expect(writesByPull[1]).toEqual([nextPath]);
|
|
7128
|
+
const expectedPaths = [durablePath, nextPath].sort();
|
|
7129
|
+
const localPaths = fs.readdirSync(path.join(localRoot, "personal", "notes"))
|
|
7130
|
+
.map((name) => `personal/notes/${name}`).sort();
|
|
7131
|
+
const finalJournal = readJournal(journalSlug);
|
|
7132
|
+
expect(localPaths).toEqual(expectedPaths);
|
|
7133
|
+
expect(Object.keys(finalJournal.files).sort()).toEqual(expectedPaths);
|
|
7134
|
+
for (const relativePath of expectedPaths)
|
|
7135
|
+
expect(isDurable(relativePath)).toBe(true);
|
|
7136
|
+
}
|
|
7137
|
+
finally {
|
|
7138
|
+
releaseFirst();
|
|
7139
|
+
shutdown();
|
|
7140
|
+
await loop;
|
|
7141
|
+
fs.rmSync(localRoot, { recursive: true, force: true });
|
|
7142
|
+
if (previousCoordinator === undefined)
|
|
7143
|
+
delete process.env.HQ_SYNC_PARALLEL_COORDINATOR;
|
|
7144
|
+
else
|
|
7145
|
+
process.env.HQ_SYNC_PARALLEL_COORDINATOR = previousCoordinator;
|
|
7146
|
+
}
|
|
7147
|
+
});
|
|
6685
7148
|
it("acknowledges superseded receiver work and records refused telemetry", async () => {
|
|
6686
7149
|
const previousCoordinator = process.env.HQ_SYNC_PARALLEL_COORDINATOR;
|
|
6687
7150
|
process.env.HQ_SYNC_PARALLEL_COORDINATOR = "1";
|
|
@@ -10991,8 +11454,8 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
|
10991
11454
|
"60000",
|
|
10992
11455
|
];
|
|
10993
11456
|
const fullArgv = ["--companies", "--direction", "both", "--hq-root", "/tmp/hq"];
|
|
10994
|
-
function startDrainLoop(runPass) {
|
|
10995
|
-
return startDrainLoopWithArgv(runPass, watchArgv);
|
|
11457
|
+
function startDrainLoop(runPass, overrides = {}) {
|
|
11458
|
+
return startDrainLoopWithArgv(runPass, watchArgv, overrides);
|
|
10996
11459
|
}
|
|
10997
11460
|
function startBatchDrainLoop(runPass) {
|
|
10998
11461
|
const watcher = makeBatchWatcherStub();
|
|
@@ -11016,7 +11479,7 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
|
11016
11479
|
shutdown: () => triggerShutdown(),
|
|
11017
11480
|
};
|
|
11018
11481
|
}
|
|
11019
|
-
function startDrainLoopWithArgv(runPass, argv) {
|
|
11482
|
+
function startDrainLoopWithArgv(runPass, argv, overrides = {}) {
|
|
11020
11483
|
const watcher = makeWatcherStub();
|
|
11021
11484
|
const sleep = makeSteppableSleep();
|
|
11022
11485
|
let triggerShutdown = () => { };
|
|
@@ -11029,6 +11492,7 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
|
11029
11492
|
triggerShutdown = handler;
|
|
11030
11493
|
return () => { };
|
|
11031
11494
|
},
|
|
11495
|
+
...overrides,
|
|
11032
11496
|
});
|
|
11033
11497
|
return {
|
|
11034
11498
|
loop,
|
|
@@ -11498,6 +11962,405 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
|
11498
11962
|
await loop;
|
|
11499
11963
|
capture.restore();
|
|
11500
11964
|
});
|
|
11965
|
+
it("backs off watcher pushes after consecutive route failures", async () => {
|
|
11966
|
+
const clockState = { now: 0 };
|
|
11967
|
+
const timers = new Map();
|
|
11968
|
+
let nextTimerId = 0;
|
|
11969
|
+
const retryScheduler = {
|
|
11970
|
+
setTimeout(callback, delayMs) {
|
|
11971
|
+
const id = ++nextTimerId;
|
|
11972
|
+
timers.set(id, { dueAt: clockState.now + delayMs, callback });
|
|
11973
|
+
return id;
|
|
11974
|
+
},
|
|
11975
|
+
clearTimeout(handle) {
|
|
11976
|
+
timers.delete(handle);
|
|
11977
|
+
},
|
|
11978
|
+
advanceTo(now) {
|
|
11979
|
+
clockState.now = now;
|
|
11980
|
+
while (true) {
|
|
11981
|
+
const due = [...timers.entries()]
|
|
11982
|
+
.filter(([, timer]) => timer.dueAt <= now)
|
|
11983
|
+
.sort((left, right) => left[1].dueAt - right[1].dueAt)[0];
|
|
11984
|
+
if (!due)
|
|
11985
|
+
return;
|
|
11986
|
+
timers.delete(due[0]);
|
|
11987
|
+
due[1].callback();
|
|
11988
|
+
}
|
|
11989
|
+
},
|
|
11990
|
+
};
|
|
11991
|
+
const watcher = makeWatcherStub();
|
|
11992
|
+
const sleep = makeSteppableSleep();
|
|
11993
|
+
const scopedPushAttempts = [];
|
|
11994
|
+
const otherRouteAttempts = [];
|
|
11995
|
+
const diagnostics = [];
|
|
11996
|
+
const telemetry = [];
|
|
11997
|
+
let indigoPushCount = 0;
|
|
11998
|
+
let shutdown = () => { };
|
|
11999
|
+
const runPass = vi.fn(async (argv) => {
|
|
12000
|
+
if (argv.includes("--scope-path") && argv.includes("push")) {
|
|
12001
|
+
if (argv[argv.indexOf("--company") + 1] === "indigo") {
|
|
12002
|
+
scopedPushAttempts.push(clockState.now);
|
|
12003
|
+
indigoPushCount += 1;
|
|
12004
|
+
if (indigoPushCount === 3)
|
|
12005
|
+
return completedPassOutcome();
|
|
12006
|
+
if (indigoPushCount === 2) {
|
|
12007
|
+
return {
|
|
12008
|
+
exitCode: 1,
|
|
12009
|
+
result: {
|
|
12010
|
+
transient: [{ company: "indigo", message: "ECONNRESET host=sync.example.test" }],
|
|
12011
|
+
},
|
|
12012
|
+
};
|
|
12013
|
+
}
|
|
12014
|
+
return {
|
|
12015
|
+
exitCode: 1,
|
|
12016
|
+
result: {
|
|
12017
|
+
companyFailures: [{
|
|
12018
|
+
company: "indigo",
|
|
12019
|
+
message: "AccessDenied token=ghp_0123456789abcdefghijklmnopqrstuv https://bucket.example.test/key?X-Amz-Signature=secret /Users/alice/private.txt /tmp/hq/companies/indigo/knowledge/note.md",
|
|
12020
|
+
}],
|
|
12021
|
+
},
|
|
12022
|
+
};
|
|
12023
|
+
}
|
|
12024
|
+
otherRouteAttempts.push(clockState.now);
|
|
12025
|
+
}
|
|
12026
|
+
return completedPassOutcome();
|
|
12027
|
+
});
|
|
12028
|
+
const loop = runRunnerWithLoop(watchArgv, {
|
|
12029
|
+
runPass: runPass,
|
|
12030
|
+
clock: new FakeClock(),
|
|
12031
|
+
monotonicNow: () => clockState.now,
|
|
12032
|
+
pushRetryScheduler: retryScheduler,
|
|
12033
|
+
stderr: { write: (line) => { diagnostics.push(line); } },
|
|
12034
|
+
emitScopedPushFailureTelemetry: (event) => { telemetry.push(event); },
|
|
12035
|
+
createWatcher: () => watcher,
|
|
12036
|
+
sleep: sleep.sleep,
|
|
12037
|
+
onShutdownSignal: (handler) => {
|
|
12038
|
+
shutdown = handler;
|
|
12039
|
+
return () => { };
|
|
12040
|
+
},
|
|
12041
|
+
});
|
|
12042
|
+
try {
|
|
12043
|
+
await flushLoopMicrotasks();
|
|
12044
|
+
runPass.mockClear();
|
|
12045
|
+
watcher.emit("companies/indigo/knowledge/failing.md");
|
|
12046
|
+
await flushLoopMicrotasks(24);
|
|
12047
|
+
expect(scopedPushAttempts).toEqual([0]);
|
|
12048
|
+
expect(telemetry).toEqual([
|
|
12049
|
+
expect.objectContaining({ route: "company:indigo", consecutiveFailures: 1, nextRetryDelayMs: 5_000 }),
|
|
12050
|
+
]);
|
|
12051
|
+
clockState.now = 1_000;
|
|
12052
|
+
watcher.emit("companies/other/knowledge/available.md");
|
|
12053
|
+
await flushLoopMicrotasks(24);
|
|
12054
|
+
expect(otherRouteAttempts).toEqual([1_000]);
|
|
12055
|
+
sleep.tick();
|
|
12056
|
+
await flushLoopMicrotasks(24);
|
|
12057
|
+
expect(scopedPushAttempts).toEqual([0]);
|
|
12058
|
+
retryScheduler.advanceTo(4_999);
|
|
12059
|
+
await flushLoopMicrotasks(24);
|
|
12060
|
+
expect(scopedPushAttempts).toEqual([0]);
|
|
12061
|
+
retryScheduler.advanceTo(5_000);
|
|
12062
|
+
await flushLoopMicrotasks(24);
|
|
12063
|
+
expect(scopedPushAttempts).toEqual([0, 5_000]);
|
|
12064
|
+
retryScheduler.advanceTo(14_999);
|
|
12065
|
+
await flushLoopMicrotasks(24);
|
|
12066
|
+
expect(scopedPushAttempts).toEqual([0, 5_000]);
|
|
12067
|
+
retryScheduler.advanceTo(15_000);
|
|
12068
|
+
await flushLoopMicrotasks(24);
|
|
12069
|
+
expect(scopedPushAttempts).toEqual([0, 5_000, 15_000]);
|
|
12070
|
+
watcher.emit("companies/indigo/knowledge/failing.md");
|
|
12071
|
+
await flushLoopMicrotasks(24);
|
|
12072
|
+
expect(scopedPushAttempts).toEqual([0, 5_000, 15_000, 15_000]);
|
|
12073
|
+
retryScheduler.advanceTo(19_999);
|
|
12074
|
+
await flushLoopMicrotasks(24);
|
|
12075
|
+
expect(scopedPushAttempts).toEqual([0, 5_000, 15_000, 15_000]);
|
|
12076
|
+
retryScheduler.advanceTo(20_000);
|
|
12077
|
+
await flushLoopMicrotasks(24);
|
|
12078
|
+
expect(scopedPushAttempts).toEqual([0, 5_000, 15_000, 15_000, 20_000]);
|
|
12079
|
+
for (const [dueAt, count] of [
|
|
12080
|
+
[30_000, 6],
|
|
12081
|
+
[50_000, 7],
|
|
12082
|
+
[90_000, 8],
|
|
12083
|
+
[170_000, 9],
|
|
12084
|
+
[330_000, 10],
|
|
12085
|
+
[630_000, 11],
|
|
12086
|
+
[930_000, 12],
|
|
12087
|
+
]) {
|
|
12088
|
+
retryScheduler.advanceTo(dueAt - 1);
|
|
12089
|
+
await flushLoopMicrotasks(24);
|
|
12090
|
+
expect(scopedPushAttempts).toHaveLength(count - 1);
|
|
12091
|
+
retryScheduler.advanceTo(dueAt);
|
|
12092
|
+
await flushLoopMicrotasks(24);
|
|
12093
|
+
expect(scopedPushAttempts).toHaveLength(count);
|
|
12094
|
+
}
|
|
12095
|
+
expect(telemetry.map(({ consecutiveFailures, nextRetryDelayMs }) => [consecutiveFailures, nextRetryDelayMs]))
|
|
12096
|
+
.toEqual([
|
|
12097
|
+
[1, 5_000], [2, 10_000], [1, 5_000], [2, 10_000],
|
|
12098
|
+
[3, 20_000], [4, 40_000], [5, 80_000], [6, 160_000], [7, 300_000],
|
|
12099
|
+
]);
|
|
12100
|
+
expect(diagnostics.join("")).toContain("sync.example.test");
|
|
12101
|
+
expect(telemetry.some(({ errorSummary }) => errorSummary.includes("sync.example.test"))).toBe(true);
|
|
12102
|
+
expect(diagnostics.join("")).toContain('"type":"scoped-push-failure"');
|
|
12103
|
+
const emittedText = diagnostics.join("") + JSON.stringify(telemetry);
|
|
12104
|
+
expect(emittedText).toContain("bucket.example.test");
|
|
12105
|
+
expect(emittedText).toContain("knowledge/note.md");
|
|
12106
|
+
expect(emittedText).not.toContain("ghp_0123456789");
|
|
12107
|
+
expect(emittedText).not.toContain("X-Amz-Signature");
|
|
12108
|
+
expect(emittedText).not.toContain("/Users/alice/private.txt");
|
|
12109
|
+
expect(emittedText).not.toContain("secret");
|
|
12110
|
+
}
|
|
12111
|
+
finally {
|
|
12112
|
+
shutdown();
|
|
12113
|
+
await flushLoopMicrotasks();
|
|
12114
|
+
await loop;
|
|
12115
|
+
}
|
|
12116
|
+
});
|
|
12117
|
+
it("includes transient push failures in the route error summary", async () => {
|
|
12118
|
+
const clockState = { now: 0 };
|
|
12119
|
+
const timers = new Map();
|
|
12120
|
+
let nextTimerId = 0;
|
|
12121
|
+
const retryScheduler = {
|
|
12122
|
+
setTimeout(callback, delayMs) {
|
|
12123
|
+
const id = ++nextTimerId;
|
|
12124
|
+
timers.set(id, { dueAt: clockState.now + delayMs, callback });
|
|
12125
|
+
return id;
|
|
12126
|
+
},
|
|
12127
|
+
clearTimeout(handle) {
|
|
12128
|
+
timers.delete(handle);
|
|
12129
|
+
},
|
|
12130
|
+
};
|
|
12131
|
+
const watcher = makeWatcherStub();
|
|
12132
|
+
const sleep = makeSteppableSleep();
|
|
12133
|
+
const diagnostics = [];
|
|
12134
|
+
const telemetry = [];
|
|
12135
|
+
let shutdown = () => { };
|
|
12136
|
+
const runPass = vi.fn(async (argv) => {
|
|
12137
|
+
if (argv.includes("--scope-path") &&
|
|
12138
|
+
argv.includes("--direction") &&
|
|
12139
|
+
argv[argv.indexOf("--direction") + 1] === "push" &&
|
|
12140
|
+
argv[argv.indexOf("--company") + 1] === "indigo") {
|
|
12141
|
+
return {
|
|
12142
|
+
exitCode: 1,
|
|
12143
|
+
result: {
|
|
12144
|
+
transient: [{ company: "indigo", message: "ECONNRESET host=sync.example.test" }],
|
|
12145
|
+
},
|
|
12146
|
+
};
|
|
12147
|
+
}
|
|
12148
|
+
return completedPassOutcome();
|
|
12149
|
+
});
|
|
12150
|
+
const loop = runRunnerWithLoop(watchArgv, {
|
|
12151
|
+
runPass: runPass,
|
|
12152
|
+
clock: new FakeClock(),
|
|
12153
|
+
monotonicNow: () => clockState.now,
|
|
12154
|
+
pushRetryScheduler: retryScheduler,
|
|
12155
|
+
stderr: { write: (line) => { diagnostics.push(line); } },
|
|
12156
|
+
emitScopedPushFailureTelemetry: (event) => { telemetry.push(event); },
|
|
12157
|
+
createWatcher: () => watcher,
|
|
12158
|
+
sleep: sleep.sleep,
|
|
12159
|
+
onShutdownSignal: (handler) => {
|
|
12160
|
+
shutdown = handler;
|
|
12161
|
+
return () => { };
|
|
12162
|
+
},
|
|
12163
|
+
});
|
|
12164
|
+
try {
|
|
12165
|
+
await flushLoopMicrotasks();
|
|
12166
|
+
runPass.mockClear();
|
|
12167
|
+
watcher.emit("companies/indigo/knowledge/transient.md");
|
|
12168
|
+
await flushLoopMicrotasks(24);
|
|
12169
|
+
expect(diagnostics.join("")).toContain("sync.example.test");
|
|
12170
|
+
expect(telemetry).toEqual([
|
|
12171
|
+
expect.objectContaining({ errorSummary: expect.stringContaining("sync.example.test") }),
|
|
12172
|
+
]);
|
|
12173
|
+
}
|
|
12174
|
+
finally {
|
|
12175
|
+
shutdown();
|
|
12176
|
+
await flushLoopMicrotasks();
|
|
12177
|
+
await loop;
|
|
12178
|
+
}
|
|
12179
|
+
});
|
|
12180
|
+
it("preserves captured delete metadata while a backed-off route is deferred", async () => {
|
|
12181
|
+
const clockState = { now: 0 };
|
|
12182
|
+
const timers = new Map();
|
|
12183
|
+
let nextTimerId = 0;
|
|
12184
|
+
const retryScheduler = {
|
|
12185
|
+
setTimeout(callback, delayMs) {
|
|
12186
|
+
const id = ++nextTimerId;
|
|
12187
|
+
timers.set(id, { dueAt: clockState.now + delayMs, callback });
|
|
12188
|
+
return id;
|
|
12189
|
+
},
|
|
12190
|
+
clearTimeout(handle) {
|
|
12191
|
+
timers.delete(handle);
|
|
12192
|
+
},
|
|
12193
|
+
advanceTo(now) {
|
|
12194
|
+
clockState.now = now;
|
|
12195
|
+
for (const [id, timer] of [...timers]) {
|
|
12196
|
+
if (timer.dueAt > now)
|
|
12197
|
+
continue;
|
|
12198
|
+
timers.delete(id);
|
|
12199
|
+
timer.callback();
|
|
12200
|
+
}
|
|
12201
|
+
},
|
|
12202
|
+
};
|
|
12203
|
+
const watcher = makeBatchWatcherStub();
|
|
12204
|
+
const sleep = makeSteppableSleep();
|
|
12205
|
+
const pushCalls = [];
|
|
12206
|
+
let indigoPushCount = 0;
|
|
12207
|
+
let shutdown = () => { };
|
|
12208
|
+
const runPass = vi.fn(async (argv) => {
|
|
12209
|
+
if (argv.includes("--direction") && argv[argv.indexOf("--direction") + 1] === "push" && argv.includes("--company")) {
|
|
12210
|
+
pushCalls.push(argv);
|
|
12211
|
+
indigoPushCount += 1;
|
|
12212
|
+
if (indigoPushCount === 1) {
|
|
12213
|
+
return { exitCode: 1, result: { companyFailures: [{ company: "indigo", message: "temporary push failure" }] } };
|
|
12214
|
+
}
|
|
12215
|
+
}
|
|
12216
|
+
return completedPassOutcome();
|
|
12217
|
+
});
|
|
12218
|
+
const loop = runRunnerWithLoop(watchArgv, {
|
|
12219
|
+
runPass: runPass,
|
|
12220
|
+
clock: new FakeClock(),
|
|
12221
|
+
monotonicNow: () => clockState.now,
|
|
12222
|
+
pushRetryScheduler: retryScheduler,
|
|
12223
|
+
createWatcher: () => watcher,
|
|
12224
|
+
sleep: sleep.sleep,
|
|
12225
|
+
onShutdownSignal: (handler) => {
|
|
12226
|
+
shutdown = handler;
|
|
12227
|
+
return () => { };
|
|
12228
|
+
},
|
|
12229
|
+
});
|
|
12230
|
+
try {
|
|
12231
|
+
await flushLoopMicrotasks();
|
|
12232
|
+
runPass.mockClear();
|
|
12233
|
+
watcher.emit("companies/indigo/knowledge/first.md", {
|
|
12234
|
+
paths: new Map([["/tmp/hq/companies/indigo/knowledge/first.md", "companies/indigo/knowledge/first.md"]]),
|
|
12235
|
+
});
|
|
12236
|
+
if (sleep.pendingCount() > 0)
|
|
12237
|
+
sleep.tick();
|
|
12238
|
+
await flushLoopMicrotasks(40);
|
|
12239
|
+
expect(pushCalls).toHaveLength(1);
|
|
12240
|
+
expect([...timers.values()].map(({ dueAt }) => dueAt)).toEqual([5_000]);
|
|
12241
|
+
clockState.now = 1_000;
|
|
12242
|
+
const deletedPath = "/tmp/hq/companies/indigo/knowledge/gone.md";
|
|
12243
|
+
watcher.emit("companies/indigo/knowledge/gone.md", {
|
|
12244
|
+
paths: new Map([[deletedPath, "companies/indigo/knowledge/gone.md"]]),
|
|
12245
|
+
changes: new Map([[deletedPath, {
|
|
12246
|
+
kind: "unlink",
|
|
12247
|
+
deleteSnapshots: [{
|
|
12248
|
+
journalSlug: "indigo",
|
|
12249
|
+
journalPath: "knowledge/gone.md",
|
|
12250
|
+
deleteScopeRoot: "knowledge",
|
|
12251
|
+
absolutePath: deletedPath,
|
|
12252
|
+
remoteEtag: "etag",
|
|
12253
|
+
localHash: "sha256:gone",
|
|
12254
|
+
localKind: "file",
|
|
12255
|
+
}],
|
|
12256
|
+
}]]),
|
|
12257
|
+
});
|
|
12258
|
+
if (sleep.pendingCount() > 0)
|
|
12259
|
+
sleep.tick();
|
|
12260
|
+
await flushLoopMicrotasks(40);
|
|
12261
|
+
expect(pushCalls).toHaveLength(1);
|
|
12262
|
+
retryScheduler.advanceTo(5_000);
|
|
12263
|
+
if (sleep.pendingCount() > 0)
|
|
12264
|
+
sleep.tick();
|
|
12265
|
+
await waitForCondition("retry after backed-off unlink", () => pushCalls.length === 2);
|
|
12266
|
+
expect(pushCalls).toHaveLength(2);
|
|
12267
|
+
expect(pushCalls[1]).toContain("--delete-scope-path");
|
|
12268
|
+
expect(pushCalls[1]).toContain("knowledge");
|
|
12269
|
+
}
|
|
12270
|
+
finally {
|
|
12271
|
+
shutdown();
|
|
12272
|
+
await flushLoopMicrotasks();
|
|
12273
|
+
await loop;
|
|
12274
|
+
}
|
|
12275
|
+
});
|
|
12276
|
+
it("resets route backoff after a successful whole-route overflow push", async () => {
|
|
12277
|
+
const clockState = { now: 0 };
|
|
12278
|
+
const timers = new Map();
|
|
12279
|
+
let nextTimerId = 0;
|
|
12280
|
+
const retryScheduler = {
|
|
12281
|
+
setTimeout(callback, delayMs) {
|
|
12282
|
+
const id = ++nextTimerId;
|
|
12283
|
+
timers.set(id, { dueAt: clockState.now + delayMs, callback });
|
|
12284
|
+
return id;
|
|
12285
|
+
},
|
|
12286
|
+
clearTimeout(handle) {
|
|
12287
|
+
timers.delete(handle);
|
|
12288
|
+
},
|
|
12289
|
+
advanceTo(now) {
|
|
12290
|
+
clockState.now = now;
|
|
12291
|
+
for (const [id, timer] of [...timers]) {
|
|
12292
|
+
if (timer.dueAt > now)
|
|
12293
|
+
continue;
|
|
12294
|
+
timers.delete(id);
|
|
12295
|
+
timer.callback();
|
|
12296
|
+
}
|
|
12297
|
+
},
|
|
12298
|
+
};
|
|
12299
|
+
const watcher = makeBatchWatcherStub();
|
|
12300
|
+
const sleep = makeSteppableSleep();
|
|
12301
|
+
const pushAttemptTimes = [];
|
|
12302
|
+
let pushCount = 0;
|
|
12303
|
+
let shutdown = () => { };
|
|
12304
|
+
const runPass = vi.fn(async (argv) => {
|
|
12305
|
+
if (argv.includes("--direction") && argv[argv.indexOf("--direction") + 1] === "push" && argv.includes("--company")) {
|
|
12306
|
+
pushAttemptTimes.push(clockState.now);
|
|
12307
|
+
pushCount += 1;
|
|
12308
|
+
if (pushCount === 1 || pushCount === 3) {
|
|
12309
|
+
return { exitCode: 1, result: { companyFailures: [{ company: "indigo", message: "temporary push failure" }] } };
|
|
12310
|
+
}
|
|
12311
|
+
}
|
|
12312
|
+
return completedPassOutcome();
|
|
12313
|
+
});
|
|
12314
|
+
const loop = runRunnerWithLoop(watchArgv, {
|
|
12315
|
+
runPass: runPass,
|
|
12316
|
+
clock: new FakeClock(),
|
|
12317
|
+
monotonicNow: () => clockState.now,
|
|
12318
|
+
pushRetryScheduler: retryScheduler,
|
|
12319
|
+
createWatcher: () => watcher,
|
|
12320
|
+
sleep: sleep.sleep,
|
|
12321
|
+
onShutdownSignal: (handler) => {
|
|
12322
|
+
shutdown = handler;
|
|
12323
|
+
return () => { };
|
|
12324
|
+
},
|
|
12325
|
+
});
|
|
12326
|
+
try {
|
|
12327
|
+
await flushLoopMicrotasks();
|
|
12328
|
+
runPass.mockClear();
|
|
12329
|
+
watcher.emit("companies/indigo/knowledge/a.md", {
|
|
12330
|
+
paths: new Map([["/tmp/hq/companies/indigo/knowledge/a.md", "companies/indigo/knowledge/a.md"]]),
|
|
12331
|
+
});
|
|
12332
|
+
if (sleep.pendingCount() > 0)
|
|
12333
|
+
sleep.tick();
|
|
12334
|
+
await flushLoopMicrotasks(40);
|
|
12335
|
+
expect(pushAttemptTimes).toEqual([0]);
|
|
12336
|
+
expect([...timers.values()].map(({ dueAt }) => dueAt)).toEqual([5_000]);
|
|
12337
|
+
clockState.now = 1_000;
|
|
12338
|
+
watcher.emit("companies/indigo/knowledge/a.md", {
|
|
12339
|
+
paths: new Map([["/tmp/hq/companies/indigo/knowledge/a.md", "companies/indigo/knowledge/a.md"]]),
|
|
12340
|
+
overflowed: true,
|
|
12341
|
+
droppedRouteHints: ["companies/indigo/knowledge/a.md"],
|
|
12342
|
+
});
|
|
12343
|
+
if (sleep.pendingCount() > 0)
|
|
12344
|
+
sleep.tick();
|
|
12345
|
+
await flushLoopMicrotasks(40);
|
|
12346
|
+
expect(pushAttemptTimes).toEqual([0, 1_000]);
|
|
12347
|
+
expect(timers.size).toBe(0);
|
|
12348
|
+
clockState.now = 2_000;
|
|
12349
|
+
watcher.emit("companies/indigo/knowledge/b.md", {
|
|
12350
|
+
paths: new Map([["/tmp/hq/companies/indigo/knowledge/b.md", "companies/indigo/knowledge/b.md"]]),
|
|
12351
|
+
});
|
|
12352
|
+
if (sleep.pendingCount() > 0)
|
|
12353
|
+
sleep.tick();
|
|
12354
|
+
await flushLoopMicrotasks(40);
|
|
12355
|
+
expect(pushAttemptTimes).toEqual([0, 1_000, 2_000]);
|
|
12356
|
+
expect([...timers.values()].map(({ dueAt }) => dueAt)).toEqual([7_000]);
|
|
12357
|
+
}
|
|
12358
|
+
finally {
|
|
12359
|
+
shutdown();
|
|
12360
|
+
await flushLoopMicrotasks();
|
|
12361
|
+
await loop;
|
|
12362
|
+
}
|
|
12363
|
+
});
|
|
11501
12364
|
it("reports interval diagnostics while the configured interval has not elapsed", async () => {
|
|
11502
12365
|
const saved = process.env.HQ_SYNC_FULL_RECONCILE_MS;
|
|
11503
12366
|
process.env.HQ_SYNC_FULL_RECONCILE_MS = "1000";
|
|
@@ -11817,7 +12680,29 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
|
11817
12680
|
.mockResolvedValueOnce(0)
|
|
11818
12681
|
.mockResolvedValueOnce(0)
|
|
11819
12682
|
.mockResolvedValueOnce(0);
|
|
11820
|
-
const
|
|
12683
|
+
const retryClock = { now: 0 };
|
|
12684
|
+
const retryTimers = new Map();
|
|
12685
|
+
let nextRetryTimerId = 0;
|
|
12686
|
+
const retryScheduler = {
|
|
12687
|
+
setTimeout(callback) {
|
|
12688
|
+
const id = ++nextRetryTimerId;
|
|
12689
|
+
retryTimers.set(id, callback);
|
|
12690
|
+
return id;
|
|
12691
|
+
},
|
|
12692
|
+
clearTimeout(handle) {
|
|
12693
|
+
retryTimers.delete(handle);
|
|
12694
|
+
},
|
|
12695
|
+
fire() {
|
|
12696
|
+
const callbacks = [...retryTimers.values()];
|
|
12697
|
+
retryTimers.clear();
|
|
12698
|
+
for (const callback of callbacks)
|
|
12699
|
+
callback();
|
|
12700
|
+
},
|
|
12701
|
+
};
|
|
12702
|
+
const { loop, watcher, tick, waitForPoll, shutdown } = startDrainLoop(runPass, {
|
|
12703
|
+
monotonicNow: () => retryClock.now,
|
|
12704
|
+
pushRetryScheduler: retryScheduler,
|
|
12705
|
+
});
|
|
11821
12706
|
await waitForPoll();
|
|
11822
12707
|
runPass.mockClear();
|
|
11823
12708
|
const pushCallCount = () => runPass.mock.calls
|
|
@@ -11830,6 +12715,8 @@ describe("runRunnerWithLoop — accumulate-and-drain scoped push", () => {
|
|
|
11830
12715
|
await waitForCondition("first scoped push attempt", () => pushCallCount() >= 1);
|
|
11831
12716
|
await waitForPoll();
|
|
11832
12717
|
tick();
|
|
12718
|
+
retryClock.now = 5_000;
|
|
12719
|
+
retryScheduler.fire();
|
|
11833
12720
|
await waitForCondition("retry scoped push attempt", () => pushCallCount() >= 2);
|
|
11834
12721
|
const pushCalls = runPass.mock.calls
|
|
11835
12722
|
.map((call) => call[0])
|
|
@@ -15019,6 +15906,71 @@ describe("defaultCollectTelemetry person-entity gate", () => {
|
|
|
15019
15906
|
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
15020
15907
|
}
|
|
15021
15908
|
});
|
|
15909
|
+
it("uses the company hq-flags decision for done-state outcomes on forceCollect agent syncs", async () => {
|
|
15910
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-tele-agent-outcome-"));
|
|
15911
|
+
const previous = process.env.HQ_FLAG_CLOUD_OUTCOME_STORY_DONE_STATE;
|
|
15912
|
+
const previousHome = process.env.HOME;
|
|
15913
|
+
process.env.HOME = path.join(hqRoot, "home");
|
|
15914
|
+
process.env.HQ_FLAG_CLOUD_OUTCOME_STORY_DONE_STATE = "true";
|
|
15915
|
+
const posts = [];
|
|
15916
|
+
const diagnostics = [];
|
|
15917
|
+
try {
|
|
15918
|
+
const companyUid = "cmp_01TESTCOMPANY0000000000000";
|
|
15919
|
+
const agentUid = "agt_01KUSAGEAGENTTEST00000000";
|
|
15920
|
+
fs.mkdirSync(path.join(hqRoot, "companies", "acme", "projects", "p"), { recursive: true });
|
|
15921
|
+
fs.writeFileSync(path.join(hqRoot, "companies", "manifest.yaml"), [
|
|
15922
|
+
"companies:",
|
|
15923
|
+
" acme:",
|
|
15924
|
+
" name: Acme",
|
|
15925
|
+
" path: companies/acme",
|
|
15926
|
+
" repos: []",
|
|
15927
|
+
` cloud_uid: ${companyUid}`,
|
|
15928
|
+
"",
|
|
15929
|
+
].join("\n"));
|
|
15930
|
+
fs.writeFileSync(path.join(hqRoot, "companies", "acme", "projects", "p", "prd.json"), JSON.stringify({ name: "p", userStories: [{ id: "US-AGENT", state: "done" }] }));
|
|
15931
|
+
const client = {
|
|
15932
|
+
entity: {
|
|
15933
|
+
get: async () => ({}),
|
|
15934
|
+
listByType: async (type) => type === "agent"
|
|
15935
|
+
? [{
|
|
15936
|
+
uid: agentUid,
|
|
15937
|
+
type: "agent",
|
|
15938
|
+
slug: "agent",
|
|
15939
|
+
status: "active",
|
|
15940
|
+
bucketName: `hq-vault-${agentUid}`,
|
|
15941
|
+
createdAt: "2026-06-01T00:00:00Z",
|
|
15942
|
+
}]
|
|
15943
|
+
: [],
|
|
15944
|
+
},
|
|
15945
|
+
getTelemetryOptIn: async () => ({ enabled: false }),
|
|
15946
|
+
postUsage: async () => ({ ok: true }),
|
|
15947
|
+
postSkillInvocations: async () => ({ written: 0, skipped: [] }),
|
|
15948
|
+
postOutcomeEvents: async (batch) => {
|
|
15949
|
+
posts.push(batch);
|
|
15950
|
+
return { ok: true, written: batch.events.length, deduped: 0 };
|
|
15951
|
+
},
|
|
15952
|
+
};
|
|
15953
|
+
await defaultCollectTelemetry(client, false, hqRoot, (diagnostic) => diagnostics.push(diagnostic), undefined, agentUid, async () => "test-token");
|
|
15954
|
+
expect(fs.existsSync(path.join(hqRoot, ".hq", "machine-id"))).toBe(true);
|
|
15955
|
+
expect(posts, JSON.stringify(diagnostics)).toHaveLength(1);
|
|
15956
|
+
expect(posts[0].events[0]).toMatchObject({
|
|
15957
|
+
type: "story-completed",
|
|
15958
|
+
companyUid,
|
|
15959
|
+
dedupeKey: `${companyUid}#p#US-AGENT`,
|
|
15960
|
+
});
|
|
15961
|
+
}
|
|
15962
|
+
finally {
|
|
15963
|
+
if (previous === undefined)
|
|
15964
|
+
delete process.env.HQ_FLAG_CLOUD_OUTCOME_STORY_DONE_STATE;
|
|
15965
|
+
else
|
|
15966
|
+
process.env.HQ_FLAG_CLOUD_OUTCOME_STORY_DONE_STATE = previous;
|
|
15967
|
+
if (previousHome === undefined)
|
|
15968
|
+
delete process.env.HOME;
|
|
15969
|
+
else
|
|
15970
|
+
process.env.HOME = previousHome;
|
|
15971
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
15972
|
+
}
|
|
15973
|
+
});
|
|
15022
15974
|
it("does not force-collect when a claimed agent uid is absent from the self-listing", async () => {
|
|
15023
15975
|
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-tele-agent-miss-"));
|
|
15024
15976
|
try {
|