@indigoai-us/hq-cloud 6.7.1 → 6.9.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/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +33 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +73 -4
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts +11 -0
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +1 -1
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +5 -4
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue.d.ts +20 -0
- package/dist/cli/rescue.d.ts.map +1 -1
- package/dist/cli/rescue.js +36 -2
- package/dist/cli/rescue.js.map +1 -1
- package/dist/cli/rescue.test.js +38 -1
- package/dist/cli/rescue.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +104 -8
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +190 -20
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +9 -1
- package/dist/cognito-auth.js.map +1 -1
- package/dist/machine-auth.test.js +4 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +28 -2
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +76 -5
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +93 -2
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +81 -10
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +177 -27
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +122 -11
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/s3.d.ts +3 -2
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +10 -5
- package/dist/s3.js.map +1 -1
- package/dist/vault-client.d.ts +9 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +83 -4
- package/src/bin/sync-runner.ts +39 -1
- package/src/cli/reindex.test.ts +5 -4
- package/src/cli/reindex.ts +12 -1
- package/src/cli/rescue.test.ts +43 -1
- package/src/cli/rescue.ts +48 -2
- package/src/cli/share.test.ts +245 -9
- package/src/cli/share.ts +116 -8
- package/src/cognito-auth.ts +9 -1
- package/src/machine-auth.test.ts +4 -2
- package/src/object-io.test.ts +105 -2
- package/src/object-io.ts +121 -8
- package/src/operation-lock.test.ts +147 -10
- package/src/operation-lock.ts +234 -26
- package/src/s3.ts +11 -4
- package/src/vault-client.ts +9 -0
|
@@ -3604,7 +3604,7 @@ describe("runRunnerWithLoop — operation lock", () => {
|
|
|
3604
3604
|
return p;
|
|
3605
3605
|
}
|
|
3606
3606
|
|
|
3607
|
-
it("one-shot sync refuses
|
|
3607
|
+
it("one-shot sync refuses immediately (exit 17) with --lock-timeout 0 when another op holds the root", async () => {
|
|
3608
3608
|
const lp = writeLiveHolder("rescue");
|
|
3609
3609
|
const errs: string[] = [];
|
|
3610
3610
|
const spy = vi
|
|
@@ -3614,12 +3614,21 @@ describe("runRunnerWithLoop — operation lock", () => {
|
|
|
3614
3614
|
return true;
|
|
3615
3615
|
});
|
|
3616
3616
|
|
|
3617
|
-
// No --watch → one-shot.
|
|
3618
|
-
//
|
|
3619
|
-
|
|
3617
|
+
// No --watch → one-shot. `--lock-timeout 0` keeps the pre-wait
|
|
3618
|
+
// refuse-immediately behavior (the default is now to WAIT for the holder).
|
|
3619
|
+
// Refusal short-circuits BEFORE runRunner (so no network / auth is touched).
|
|
3620
|
+
const start = Date.now();
|
|
3621
|
+
const code = await runRunnerWithLoop([
|
|
3622
|
+
"--companies",
|
|
3623
|
+
"--hq-root",
|
|
3624
|
+
HQ,
|
|
3625
|
+
"--lock-timeout",
|
|
3626
|
+
"0",
|
|
3627
|
+
]);
|
|
3620
3628
|
|
|
3621
3629
|
spy.mockRestore();
|
|
3622
3630
|
expect(code).toBe(OPERATION_LOCKED_EXIT);
|
|
3631
|
+
expect(Date.now() - start).toBeLessThan(1500); // did not wait
|
|
3623
3632
|
expect(errs.join("")).toContain("rescue"); // names the holder
|
|
3624
3633
|
// The holder's lock is left intact — we refused, we didn't take it over.
|
|
3625
3634
|
const held = JSON.parse(fs.readFileSync(lp, "utf8"));
|
|
@@ -3627,6 +3636,76 @@ describe("runRunnerWithLoop — operation lock", () => {
|
|
|
3627
3636
|
expect(held.command).toBe("rescue");
|
|
3628
3637
|
});
|
|
3629
3638
|
|
|
3639
|
+
it("one-shot sync WAITS for a live holder (default), bounded by --lock-timeout, then refuses with exit 17", async () => {
|
|
3640
|
+
const lp = writeLiveHolder("rescue");
|
|
3641
|
+
const errs: string[] = [];
|
|
3642
|
+
const spy = vi
|
|
3643
|
+
.spyOn(process.stderr, "write")
|
|
3644
|
+
.mockImplementation((chunk: string | Uint8Array) => {
|
|
3645
|
+
errs.push(String(chunk));
|
|
3646
|
+
return true;
|
|
3647
|
+
});
|
|
3648
|
+
|
|
3649
|
+
// The holder never releases here; --lock-timeout 1 bounds the wait so the
|
|
3650
|
+
// runner waits ~1s, emits a single "Waiting for …" status line, then
|
|
3651
|
+
// refuses (exit 17) — proving the runner threads the flag through and
|
|
3652
|
+
// takes the wait path (rather than refusing instantly). It still never
|
|
3653
|
+
// enters runRunner, so no network/auth is touched.
|
|
3654
|
+
const start = Date.now();
|
|
3655
|
+
const code = await runRunnerWithLoop([
|
|
3656
|
+
"--companies",
|
|
3657
|
+
"--hq-root",
|
|
3658
|
+
HQ,
|
|
3659
|
+
"--lock-timeout",
|
|
3660
|
+
"1",
|
|
3661
|
+
]);
|
|
3662
|
+
const elapsed = Date.now() - start;
|
|
3663
|
+
|
|
3664
|
+
spy.mockRestore();
|
|
3665
|
+
expect(code).toBe(OPERATION_LOCKED_EXIT);
|
|
3666
|
+
expect(elapsed).toBeGreaterThanOrEqual(800); // actually waited ~1s
|
|
3667
|
+
expect(errs.join("")).toContain("Waiting for"); // status line emitted once
|
|
3668
|
+
expect(errs.join("")).toContain("rescue"); // names the holder
|
|
3669
|
+
const held = JSON.parse(fs.readFileSync(lp, "utf8"));
|
|
3670
|
+
expect(held.pid).toBe(1); // holder untouched
|
|
3671
|
+
}, 20_000);
|
|
3672
|
+
|
|
3673
|
+
it("DEV-1772: one-shot waits out a SHORT-LIVED holder (reindex) then PROCEEDS to sync", async () => {
|
|
3674
|
+
// The exact reported scenario (feedback_28a1833f / DEV-1772): a frequent
|
|
3675
|
+
// ~1-min `reindex` briefly holds the lock; the instant-sync one-shot used
|
|
3676
|
+
// to exit 17 and silently die. Now it WAITS (default) and proceeds the
|
|
3677
|
+
// moment the short holder releases. We model the short holder with a
|
|
3678
|
+
// foreign live pid (1) whose lock file is removed shortly after, and inject
|
|
3679
|
+
// runPass so "proceeds → syncs" is observable without the network.
|
|
3680
|
+
writeLiveHolder("reindex");
|
|
3681
|
+
const errs: string[] = [];
|
|
3682
|
+
const spy = vi
|
|
3683
|
+
.spyOn(process.stderr, "write")
|
|
3684
|
+
.mockImplementation((chunk: string | Uint8Array) => {
|
|
3685
|
+
errs.push(String(chunk));
|
|
3686
|
+
return true;
|
|
3687
|
+
});
|
|
3688
|
+
|
|
3689
|
+
const runPass = vi.fn().mockResolvedValue(0);
|
|
3690
|
+
// The short-lived holder releases ~150ms in.
|
|
3691
|
+
setTimeout(() => fs.rmSync(lockPathFor(HQ), { force: true }), 150);
|
|
3692
|
+
|
|
3693
|
+
const code = await runRunnerWithLoop(
|
|
3694
|
+
["--companies", "--hq-root", HQ],
|
|
3695
|
+
{ runPass },
|
|
3696
|
+
);
|
|
3697
|
+
|
|
3698
|
+
spy.mockRestore();
|
|
3699
|
+
// It did NOT refuse/die — it waited then ran the sync pass exactly once.
|
|
3700
|
+
expect(code).toBe(0);
|
|
3701
|
+
expect(code).not.toBe(OPERATION_LOCKED_EXIT);
|
|
3702
|
+
expect(runPass).toHaveBeenCalledTimes(1);
|
|
3703
|
+
// A single "Waiting for …" status line named the short holder.
|
|
3704
|
+
const out = errs.join("");
|
|
3705
|
+
expect(out).toContain("Waiting for");
|
|
3706
|
+
expect(out).toContain("reindex");
|
|
3707
|
+
}, 20_000);
|
|
3708
|
+
|
|
3630
3709
|
it("the watch runner is EXEMPT — runs despite a held lock and never takes it", async () => {
|
|
3631
3710
|
const lp = writeLiveHolder("sync");
|
|
3632
3711
|
const watcher = makeWatcherStub();
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -607,6 +607,13 @@ interface ParsedArgs {
|
|
|
607
607
|
* mode (single-company runs never visit the personal target).
|
|
608
608
|
*/
|
|
609
609
|
skipPersonal: boolean;
|
|
610
|
+
/**
|
|
611
|
+
* Bounded wait (seconds) for the per-root operation lock when another op is
|
|
612
|
+
* already running. `0` → refuse immediately (pre-wait behavior); omitted →
|
|
613
|
+
* inherit `HQ_OP_LOCK_TIMEOUT` / infinite wait. Only meaningful on the
|
|
614
|
+
* one-shot path (the `--watch` runner is lock-exempt).
|
|
615
|
+
*/
|
|
616
|
+
lockTimeoutSec?: number;
|
|
610
617
|
}
|
|
611
618
|
|
|
612
619
|
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
@@ -620,6 +627,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
620
627
|
let pollRemoteMs: number | undefined;
|
|
621
628
|
let skipPersonal = false;
|
|
622
629
|
let eventPush = false;
|
|
630
|
+
let lockTimeoutSec: number | undefined;
|
|
623
631
|
|
|
624
632
|
for (let i = 0; i < argv.length; i++) {
|
|
625
633
|
const arg = argv[i];
|
|
@@ -693,6 +701,18 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
693
701
|
// @getindigo.ai identities for the first release.
|
|
694
702
|
eventPush = true;
|
|
695
703
|
break;
|
|
704
|
+
case "--lock-timeout": {
|
|
705
|
+
const val = argv[++i];
|
|
706
|
+
if (!val) return { error: "--lock-timeout requires a value (seconds)" };
|
|
707
|
+
const n = Number(val);
|
|
708
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
709
|
+
return {
|
|
710
|
+
error: `--lock-timeout must be a non-negative integer (seconds), got: ${val}`,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
lockTimeoutSec = n;
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
696
716
|
default:
|
|
697
717
|
return { error: `Unknown argument: ${arg}` };
|
|
698
718
|
}
|
|
@@ -732,6 +752,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
732
752
|
pollRemoteMs,
|
|
733
753
|
skipPersonal,
|
|
734
754
|
eventPush,
|
|
755
|
+
lockTimeoutSec,
|
|
735
756
|
};
|
|
736
757
|
}
|
|
737
758
|
|
|
@@ -1855,10 +1876,27 @@ export async function runRunnerWithLoop(
|
|
|
1855
1876
|
// surfaces the parse error rather than us masking it with a lock failure.
|
|
1856
1877
|
const parsed = parseArgs(argv);
|
|
1857
1878
|
if ("error" in parsed) return runRunner(argv);
|
|
1879
|
+
// The actual sync pass — same seam the watch loop uses (deps.runPass),
|
|
1880
|
+
// so a test can assert "waits for a short-lived holder, THEN proceeds to
|
|
1881
|
+
// sync" without touching the network. Production passes `argv` to
|
|
1882
|
+
// runRunner exactly as before. Regression guard for DEV-1772
|
|
1883
|
+
// (feedback_28a1833f): instant-sync one-shots used to exit 17 and die on
|
|
1884
|
+
// a lock conflict with the ~1-min reindex hook; they now WAIT (default)
|
|
1885
|
+
// and proceed once the short holder releases.
|
|
1886
|
+
const runOnce = deps.runPass ?? ((passArgv: string[]) => runRunner(passArgv));
|
|
1858
1887
|
try {
|
|
1859
|
-
return await withOperationLock(parsed.hqRoot, "sync", () =>
|
|
1888
|
+
return await withOperationLock(parsed.hqRoot, "sync", () => runOnce(argv), {
|
|
1889
|
+
timeoutSec: parsed.lockTimeoutSec,
|
|
1890
|
+
});
|
|
1860
1891
|
} catch (err) {
|
|
1861
1892
|
if (err instanceof OperationLockedError) {
|
|
1893
|
+
// The lock wait was BOUNDED and tripped (a holder never released
|
|
1894
|
+
// within --lock-timeout / HQ_OP_LOCK_TIMEOUT). Surface it loudly on
|
|
1895
|
+
// stderr and exit with the stable OPERATION_LOCKED_EXIT (17) so the
|
|
1896
|
+
// spawner (menubar) can recognize a lock conflict and SCHEDULE A
|
|
1897
|
+
// RETRY rather than treating it as a hard failure and silently giving
|
|
1898
|
+
// up (DEV-1772). With the default (no bound) we never reach here — the
|
|
1899
|
+
// one-shot waits indefinitely and proceeds.
|
|
1862
1900
|
process.stderr.write(err.message + "\n");
|
|
1863
1901
|
return OPERATION_LOCKED_EXIT;
|
|
1864
1902
|
}
|
package/src/cli/reindex.test.ts
CHANGED
|
@@ -135,9 +135,10 @@ describe("reindex", () => {
|
|
|
135
135
|
|
|
136
136
|
// ── operation lock (mutual exclusion with sync/rescue) ──────────────────
|
|
137
137
|
|
|
138
|
-
it("refuses (OPERATION_LOCKED_EXIT) when another op holds this root's lock", () => {
|
|
139
|
-
// A live holder in another process (pid 1) →
|
|
140
|
-
//
|
|
138
|
+
it("refuses (OPERATION_LOCKED_EXIT) when another op holds this root's lock (lockTimeoutSec:0)", () => {
|
|
139
|
+
// A live holder in another process (pid 1) → with lockTimeoutSec:0 reindex
|
|
140
|
+
// refuses immediately and does no work. (The new default is to WAIT; the
|
|
141
|
+
// wait-then-acquire path is covered in operation-lock.test.ts.)
|
|
141
142
|
const lp = lockPathFor(root);
|
|
142
143
|
fs.mkdirSync(path.dirname(lp), { recursive: true });
|
|
143
144
|
fs.writeFileSync(
|
|
@@ -145,7 +146,7 @@ describe("reindex", () => {
|
|
|
145
146
|
JSON.stringify({ pid: 1, command: "sync", startedAt: new Date(0).toISOString(), hqRoot: root }),
|
|
146
147
|
);
|
|
147
148
|
const before = fs.existsSync(path.join(root, ".claude/skills"));
|
|
148
|
-
const { status } = reindex({ repoRoot: root });
|
|
149
|
+
const { status } = reindex({ repoRoot: root, lockTimeoutSec: 0 });
|
|
149
150
|
expect(status).toBe(OPERATION_LOCKED_EXIT);
|
|
150
151
|
// It refused before doing any work (didn't create .claude/skills).
|
|
151
152
|
expect(fs.existsSync(path.join(root, ".claude/skills"))).toBe(before);
|
package/src/cli/reindex.ts
CHANGED
|
@@ -35,6 +35,17 @@ export interface ReindexOptions {
|
|
|
35
35
|
* exclusive with a running sync/rescue.
|
|
36
36
|
*/
|
|
37
37
|
skipLock?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Bounded wait (seconds) for the per-root operation lock when a sync/rescue
|
|
40
|
+
* is already running. `0` → refuse immediately; omitted → inherit
|
|
41
|
+
* `HQ_OP_LOCK_TIMEOUT` / infinite wait. Ignored when `skipLock` is set.
|
|
42
|
+
*
|
|
43
|
+
* NB: standalone `hq reindex` waits by default, which is what a human running
|
|
44
|
+
* it wants. The hq-core reindex HOOK (Stop / PostToolUse) should set a small
|
|
45
|
+
* `HQ_OP_LOCK_TIMEOUT` (or `0`) so a hook fired mid-sync never blocks the
|
|
46
|
+
* interactive agent indefinitely.
|
|
47
|
+
*/
|
|
48
|
+
lockTimeoutSec?: number;
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
export interface ReindexResult {
|
|
@@ -151,7 +162,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
151
162
|
let opLock: LockHandle | null = null;
|
|
152
163
|
if (!opts.skipLock) {
|
|
153
164
|
try {
|
|
154
|
-
opLock = acquireOperationLock(root, "reindex");
|
|
165
|
+
opLock = acquireOperationLock(root, "reindex", { timeoutSec: opts.lockTimeoutSec });
|
|
155
166
|
} catch (err) {
|
|
156
167
|
if (err instanceof OperationLockedError) {
|
|
157
168
|
warn(err.message);
|
package/src/cli/rescue.test.ts
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { buildRescueArgs } from "./rescue.js";
|
|
2
|
+
import { buildRescueArgs, extractLockTimeout } from "./rescue.js";
|
|
3
|
+
|
|
4
|
+
describe("extractLockTimeout", () => {
|
|
5
|
+
it("returns the explicit option untouched when extraArgs is empty/absent", () => {
|
|
6
|
+
expect(extractLockTimeout({ lockTimeoutSec: 5 })).toEqual({
|
|
7
|
+
lockTimeoutSec: 5,
|
|
8
|
+
cleanedExtraArgs: undefined,
|
|
9
|
+
});
|
|
10
|
+
expect(extractLockTimeout({ lockTimeoutSec: 5, extraArgs: [] })).toEqual({
|
|
11
|
+
lockTimeoutSec: 5,
|
|
12
|
+
cleanedExtraArgs: [],
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("pulls --lock-timeout <secs> out of extraArgs and strips it from the forwarded args", () => {
|
|
17
|
+
const r = extractLockTimeout({
|
|
18
|
+
extraArgs: ["--lock-timeout", "30", "--dry-run", "--yes"],
|
|
19
|
+
});
|
|
20
|
+
expect(r.lockTimeoutSec).toBe(30);
|
|
21
|
+
// The flag + its value never reach the rescue script.
|
|
22
|
+
expect(r.cleanedExtraArgs).toEqual(["--dry-run", "--yes"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("honors --lock-timeout 0 (refuse immediately)", () => {
|
|
26
|
+
const r = extractLockTimeout({ extraArgs: ["--lock-timeout", "0"] });
|
|
27
|
+
expect(r.lockTimeoutSec).toBe(0);
|
|
28
|
+
expect(r.cleanedExtraArgs).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("explicit option wins over a flag in extraArgs", () => {
|
|
32
|
+
const r = extractLockTimeout({
|
|
33
|
+
lockTimeoutSec: 0,
|
|
34
|
+
extraArgs: ["--lock-timeout", "60"],
|
|
35
|
+
});
|
|
36
|
+
expect(r.lockTimeoutSec).toBe(0);
|
|
37
|
+
expect(r.cleanedExtraArgs).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("ignores a non-numeric / negative --lock-timeout value (treated as unset)", () => {
|
|
41
|
+
expect(extractLockTimeout({ extraArgs: ["--lock-timeout", "abc"] }).lockTimeoutSec).toBeUndefined();
|
|
42
|
+
expect(extractLockTimeout({ extraArgs: ["--lock-timeout", "-3"] }).lockTimeoutSec).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
3
45
|
|
|
4
46
|
describe("buildRescueArgs", () => {
|
|
5
47
|
it("emits no args for an empty option set (script defaults apply)", () => {
|
package/src/cli/rescue.ts
CHANGED
|
@@ -57,10 +57,52 @@ export interface RescueOptions {
|
|
|
57
57
|
/** GitHub token forwarded to the script as `GH_TOKEN` (avoids the
|
|
58
58
|
* anonymous-clone rate limit; required for private sources). */
|
|
59
59
|
ghToken?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Bounded wait (seconds) for the per-root operation lock when a sync/reindex
|
|
62
|
+
* is already running. `0` → refuse immediately; omitted → inherit
|
|
63
|
+
* `HQ_OP_LOCK_TIMEOUT` / infinite wait. Also accepted as `--lock-timeout
|
|
64
|
+
* <secs>` inside {@link extraArgs} (the machine `hq-rescue` entrypoint
|
|
65
|
+
* forwards raw argv) — it is consumed here and never passed to the rescue
|
|
66
|
+
* script, which doesn't understand it.
|
|
67
|
+
*/
|
|
68
|
+
lockTimeoutSec?: number;
|
|
60
69
|
/** Escape hatch — additional raw args appended verbatim. */
|
|
61
70
|
extraArgs?: string[];
|
|
62
71
|
}
|
|
63
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Pull `--lock-timeout <secs>` out of `extraArgs` (the raw argv the machine
|
|
75
|
+
* `hq-rescue` entrypoint forwards) so it controls the operation-lock wait
|
|
76
|
+
* instead of leaking to the rescue script. Returns the resolved timeout
|
|
77
|
+
* (explicit `opts.lockTimeoutSec` wins) and a copy of extraArgs with the flag
|
|
78
|
+
* removed.
|
|
79
|
+
*/
|
|
80
|
+
export function extractLockTimeout(opts: RescueOptions): {
|
|
81
|
+
lockTimeoutSec: number | undefined;
|
|
82
|
+
cleanedExtraArgs: string[] | undefined;
|
|
83
|
+
} {
|
|
84
|
+
const raw = opts.extraArgs;
|
|
85
|
+
if (!raw || raw.length === 0) {
|
|
86
|
+
return { lockTimeoutSec: opts.lockTimeoutSec, cleanedExtraArgs: raw };
|
|
87
|
+
}
|
|
88
|
+
const cleaned: string[] = [];
|
|
89
|
+
let fromArgs: number | undefined;
|
|
90
|
+
for (let i = 0; i < raw.length; i++) {
|
|
91
|
+
if (raw[i] === "--lock-timeout") {
|
|
92
|
+
const val = Number(raw[i + 1]);
|
|
93
|
+
if (Number.isInteger(val) && val >= 0) fromArgs = val;
|
|
94
|
+
i++; // skip the value too
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
cleaned.push(raw[i]);
|
|
98
|
+
}
|
|
99
|
+
// Explicit option beats the parsed flag.
|
|
100
|
+
return {
|
|
101
|
+
lockTimeoutSec: opts.lockTimeoutSec ?? fromArgs,
|
|
102
|
+
cleanedExtraArgs: cleaned,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
64
106
|
export interface RescueResult {
|
|
65
107
|
/** Exit status of the underlying script (0 = success). */
|
|
66
108
|
status: number;
|
|
@@ -100,9 +142,13 @@ export function rescue(opts: RescueOptions = {}): RescueResult {
|
|
|
100
142
|
// lock on the same root the rescue script resolves (cwd when --hq-root is
|
|
101
143
|
// omitted). Rescue is never the exempt push watcher, so it always locks.
|
|
102
144
|
const lockRoot = opts.hqRoot ?? process.cwd();
|
|
145
|
+
// Consume --lock-timeout from extraArgs (machine entrypoint) before it can
|
|
146
|
+
// reach the rescue script, which doesn't understand it.
|
|
147
|
+
const { lockTimeoutSec, cleanedExtraArgs } = extractLockTimeout(opts);
|
|
148
|
+
const rescueOpts: RescueOptions = { ...opts, extraArgs: cleanedExtraArgs };
|
|
103
149
|
let handle;
|
|
104
150
|
try {
|
|
105
|
-
handle = acquireOperationLock(lockRoot, "rescue");
|
|
151
|
+
handle = acquireOperationLock(lockRoot, "rescue", { timeoutSec: lockTimeoutSec });
|
|
106
152
|
} catch (err) {
|
|
107
153
|
if (err instanceof OperationLockedError) {
|
|
108
154
|
process.stderr.write(err.message + "\n");
|
|
@@ -111,7 +157,7 @@ export function rescue(opts: RescueOptions = {}): RescueResult {
|
|
|
111
157
|
throw err;
|
|
112
158
|
}
|
|
113
159
|
try {
|
|
114
|
-
const args = buildRescueArgs(
|
|
160
|
+
const args = buildRescueArgs(rescueOpts);
|
|
115
161
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
116
162
|
if (opts.ghToken) env.GH_TOKEN = opts.ghToken;
|
|
117
163
|
const { status } = runRescue(args, { env });
|