@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.11.7",
3
+ "version": "6.11.8",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
- // ── Codex P1 (5.36.x): interactive conflict prompt serialization ────
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
- // Trade-off (Option A): when onConflict is undefined we force pool
4338
- // concurrency to 1 for the whole run. Interactive sessions are rare
4339
- // (operator is already at the terminal clicking through prompts), so
4340
- // serializing the entire pool is acceptable and has the smallest
4341
- // blast radius vs. a per-prompt mutex. Non-interactive (onConflict set)
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 guard (Codex P1, 5.36.x): when onConflict is unset
1074
- // the per-item conflict path falls into resolveConflict()'s readline
1075
- // prompt on process.stdin. Multiple pool workers prompting at once
1076
- // would race for the same terminal input and interleave answers
1077
- // nondeterministically. Force concurrency=1 for interactive sessions
1078
- // the operator is at the keyboard anyway, the throughput penalty only
1079
- // applies when conflicts actually exist, and Option A (whole-pool
1080
- // serialization) has the smallest blast radius vs. a per-prompt mutex.
1081
- // Non-interactive (onConflict set) keeps the env-tunable default.
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 resolveConflict(
1337
- {
1338
- path: relativePath,
1339
- localHash,
1340
- remoteModified: remoteMeta.lastModified,
1341
- direction: "push",
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 resolveConflict(
1485
- { path: relativePath, localHash, direction: "push" },
1486
- onConflict,
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,