@indigoai-us/hq-cloud 6.11.7 → 6.11.8
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/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +29 -15
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +56 -7
- package/dist/cli/share.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +59 -7
- package/src/cli/share.ts +39 -24
package/package.json
CHANGED
package/src/cli/share.test.ts
CHANGED
|
@@ -4326,7 +4326,7 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
4326
4326
|
expect(errorEvents[0]!.path).toBe("f-2.bin");
|
|
4327
4327
|
});
|
|
4328
4328
|
|
|
4329
|
-
// ──
|
|
4329
|
+
// ── Interactive conflict prompt serialization ───────────────────────
|
|
4330
4330
|
//
|
|
4331
4331
|
// The parallel pool can run multiple processUploadItem workers
|
|
4332
4332
|
// concurrently; each worker calls resolveConflict() on a conflict, and
|
|
@@ -4334,12 +4334,11 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
4334
4334
|
// prompt on process.stdin. Two prompts open at once would race for the
|
|
4335
4335
|
// same terminal input — answers would interleave nondeterministically.
|
|
4336
4336
|
//
|
|
4337
|
-
//
|
|
4338
|
-
//
|
|
4339
|
-
//
|
|
4340
|
-
//
|
|
4341
|
-
//
|
|
4342
|
-
// keeps full parallelism.
|
|
4337
|
+
// Fix: rather than force the WHOLE pool to concurrency=1 (which made an
|
|
4338
|
+
// interactive `hq sync now` crawl even with zero conflicts), the pool
|
|
4339
|
+
// keeps full concurrency and serializes ONLY the prompt via a chained
|
|
4340
|
+
// lock (`resolveConflictSerialized`). At most one prompt awaits input at
|
|
4341
|
+
// a time; this test pins that invariant.
|
|
4343
4342
|
it("serializes interactive conflict prompts (no two readline prompts open at once)", async () => {
|
|
4344
4343
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4345
4344
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -4451,6 +4450,59 @@ describe("currency-gated: journal version 2 fixtures", () => {
|
|
|
4451
4450
|
expect(maxConcurrent).toBeGreaterThanOrEqual(4);
|
|
4452
4451
|
});
|
|
4453
4452
|
|
|
4453
|
+
it("interactive mode (onConflict unset) still runs transfers concurrently", async () => {
|
|
4454
|
+
// REGRESSION: the 5.36.x interactive guard forced the WHOLE pool to
|
|
4455
|
+
// concurrency=1 whenever onConflict was unset — so `hq sync now` (which
|
|
4456
|
+
// omits --on-conflict) crawled through transfers one at a time even when
|
|
4457
|
+
// no conflict ever occurred. The prompt is now serialized on its own
|
|
4458
|
+
// chained lock, leaving the transfer pool at full concurrency.
|
|
4459
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4460
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
4461
|
+
const FILE_COUNT = 16;
|
|
4462
|
+
for (let i = 0; i < FILE_COUNT; i++) {
|
|
4463
|
+
fs.writeFileSync(
|
|
4464
|
+
path.join(companyRoot, `f-${i.toString().padStart(2, "0")}.bin`),
|
|
4465
|
+
`c-${i}`,
|
|
4466
|
+
);
|
|
4467
|
+
}
|
|
4468
|
+
|
|
4469
|
+
const startTimes: number[] = [];
|
|
4470
|
+
const endTimes: number[] = [];
|
|
4471
|
+
vi.mocked(uploadFile).mockImplementation(async () => {
|
|
4472
|
+
startTimes.push(Date.now());
|
|
4473
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
4474
|
+
endTimes.push(Date.now());
|
|
4475
|
+
return { etag: '"upload-etag"' };
|
|
4476
|
+
});
|
|
4477
|
+
|
|
4478
|
+
await share({
|
|
4479
|
+
paths: [companyRoot],
|
|
4480
|
+
company: "acme",
|
|
4481
|
+
vaultConfig: mockConfig,
|
|
4482
|
+
hqRoot: tmpDir,
|
|
4483
|
+
// onConflict intentionally omitted → interactive mode. No conflicts
|
|
4484
|
+
// occur, so the transfer pool must NOT serialize.
|
|
4485
|
+
});
|
|
4486
|
+
|
|
4487
|
+
const sortedEvents: Array<{ t: number; kind: "start" | "end" }> = [];
|
|
4488
|
+
for (const t of startTimes) sortedEvents.push({ t, kind: "start" });
|
|
4489
|
+
for (const t of endTimes) sortedEvents.push({ t, kind: "end" });
|
|
4490
|
+
sortedEvents.sort((a, b) => a.t - b.t || (a.kind === "start" ? -1 : 1));
|
|
4491
|
+
let cur = 0;
|
|
4492
|
+
let maxConcurrent = 0;
|
|
4493
|
+
for (const ev of sortedEvents) {
|
|
4494
|
+
if (ev.kind === "start") {
|
|
4495
|
+
cur++;
|
|
4496
|
+
if (cur > maxConcurrent) maxConcurrent = cur;
|
|
4497
|
+
} else {
|
|
4498
|
+
cur--;
|
|
4499
|
+
}
|
|
4500
|
+
}
|
|
4501
|
+
// Pre-fix this was exactly 1 (forced serial). Now it tracks the full
|
|
4502
|
+
// pool; 4 is a conservative lower bound to avoid CI flakiness.
|
|
4503
|
+
expect(maxConcurrent).toBeGreaterThanOrEqual(4);
|
|
4504
|
+
});
|
|
4505
|
+
|
|
4454
4506
|
// ── Codex P1 (5.36.x): pool drains in-flight on worker rejection ────
|
|
4455
4507
|
//
|
|
4456
4508
|
// Pre-fix: the scheduler waited on `Promise.race(inFlight)`. If any
|
package/src/cli/share.ts
CHANGED
|
@@ -1070,24 +1070,41 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1070
1070
|
// PUTs that already issued will complete (S3 doesn't have client-side
|
|
1071
1071
|
// cancellation in this code path); their results are still recorded on
|
|
1072
1072
|
// the journal so the next sync's planner doesn't re-fire them.
|
|
1073
|
-
// Interactive-mode
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
const isInteractiveConflictMode = onConflict === undefined;
|
|
1073
|
+
// Interactive-mode prompts: when `onConflict` is unset the per-item conflict
|
|
1074
|
+
// path calls resolveConflict()'s readline prompt on process.stdin, and two
|
|
1075
|
+
// pool workers prompting at once would race for the terminal and interleave
|
|
1076
|
+
// answers. The 5.36.x guard solved this by forcing the WHOLE pool to
|
|
1077
|
+
// concurrency=1 — which made an interactive `hq sync now` crawl even when
|
|
1078
|
+
// zero conflicts existed (every transfer serialized just in case one might
|
|
1079
|
+
// prompt). Instead, keep full env-tunable concurrency and serialize ONLY the
|
|
1080
|
+
// prompt (see `resolveConflictSerialized` below): at most one prompt awaits
|
|
1081
|
+
// input at a time while transfers stay parallel.
|
|
1083
1082
|
const TRANSFER_CONCURRENCY = (() => {
|
|
1084
|
-
if (isInteractiveConflictMode) return 1;
|
|
1085
1083
|
const raw = process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
|
|
1086
1084
|
if (raw === undefined || raw === "") return 16;
|
|
1087
1085
|
const parsed = Number.parseInt(raw, 10);
|
|
1088
1086
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 16;
|
|
1089
1087
|
})();
|
|
1090
1088
|
|
|
1089
|
+
// Chained lock around the (possibly interactive) conflict prompt. Each
|
|
1090
|
+
// resolveConflict() runs only after the previous one settles, so concurrent
|
|
1091
|
+
// pool workers never prompt over each other on stdin — without dropping the
|
|
1092
|
+
// transfer pool's parallelism. A rejected prompt must not wedge the chain,
|
|
1093
|
+
// so the link swallows errors (the original promise still rejects to its
|
|
1094
|
+
// awaiter). In non-interactive mode resolveConflict applies the configured
|
|
1095
|
+
// strategy without reading stdin, so the lock adds no real serialization.
|
|
1096
|
+
let conflictPromptChain: Promise<unknown> = Promise.resolve();
|
|
1097
|
+
const resolveConflictSerialized = (
|
|
1098
|
+
info: Parameters<typeof resolveConflict>[0],
|
|
1099
|
+
): ReturnType<typeof resolveConflict> => {
|
|
1100
|
+
const run = conflictPromptChain.then(() => resolveConflict(info, onConflict));
|
|
1101
|
+
conflictPromptChain = run.then(
|
|
1102
|
+
() => undefined,
|
|
1103
|
+
() => undefined,
|
|
1104
|
+
);
|
|
1105
|
+
return run;
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1091
1108
|
// Push-side FILE_TOMBSTONE consult (delete-resync) — symmetric to the pull
|
|
1092
1109
|
// planner's suppression in sync.ts. An authoritative delete
|
|
1093
1110
|
// (`hq files delete <prefix>`) writes a FILE_TOMBSTONE and removes the S3
|
|
@@ -1333,15 +1350,12 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1333
1350
|
if ((localChanged && remoteChanged) || isFreshCollision) {
|
|
1334
1351
|
conflictPaths.push(relativePath);
|
|
1335
1352
|
|
|
1336
|
-
const resolution = await
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
},
|
|
1343
|
-
onConflict,
|
|
1344
|
-
);
|
|
1353
|
+
const resolution = await resolveConflictSerialized({
|
|
1354
|
+
path: relativePath,
|
|
1355
|
+
localHash,
|
|
1356
|
+
remoteModified: remoteMeta.lastModified,
|
|
1357
|
+
direction: "push",
|
|
1358
|
+
});
|
|
1345
1359
|
|
|
1346
1360
|
emit({
|
|
1347
1361
|
type: "conflict",
|
|
@@ -1481,10 +1495,11 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
1481
1495
|
// the race the fence exists to catch. Surface as a push conflict;
|
|
1482
1496
|
// never silently overwrite.
|
|
1483
1497
|
conflictPaths.push(relativePath);
|
|
1484
|
-
const resolution = await
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1498
|
+
const resolution = await resolveConflictSerialized({
|
|
1499
|
+
path: relativePath,
|
|
1500
|
+
localHash,
|
|
1501
|
+
direction: "push",
|
|
1502
|
+
});
|
|
1488
1503
|
emit({
|
|
1489
1504
|
type: "conflict",
|
|
1490
1505
|
path: relativePath,
|