Skip to content

Commit f6ea30f

Browse files
Merge e84381f into 5dccba7
2 parents 5dccba7 + e84381f commit f6ea30f

3 files changed

Lines changed: 347 additions & 19 deletions

File tree

src/agents/pi-embedded-runner/run/attempt.session-lock.test.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
installPromptSubmissionLockRelease,
2020
installSessionEventWriteLock,
2121
installSessionExternalHookWriteLock,
22+
resetEmbeddedAttemptSessionFilePromptGuardsForTest,
2223
} from "./attempt.session-lock.js";
2324

2425
const lockOptions = {
@@ -32,6 +33,7 @@ const tempDirs: string[] = [];
3233

3334
afterEach(async () => {
3435
resetSessionWriteLockStateForTest();
36+
resetEmbeddedAttemptSessionFilePromptGuardsForTest();
3537
for (const dir of tempDirs.splice(0)) {
3638
await fs.rm(dir, { recursive: true, force: true });
3739
}
@@ -531,6 +533,7 @@ describe("embedded attempt session lock lifecycle", () => {
531533
},
532534
),
533535
);
536+
resetEmbeddedAttemptSessionFilePromptGuardsForTest();
534537
await secondController.releaseForPrompt();
535538

536539
await expect(
@@ -545,6 +548,229 @@ describe("embedded attempt session lock lifecycle", () => {
545548
expect(releases).toEqual(["release", "release", "release"]);
546549
});
547550

551+
it("waits for an existing prompt holder before releasing another prompt on the same session file", async () => {
552+
const sessionFile = await createTempSessionFile();
553+
const events: string[] = [];
554+
let acquireCount = 0;
555+
const acquireSessionWriteLock = vi.fn(async () => {
556+
acquireCount += 1;
557+
const lockId = acquireCount;
558+
events.push(`acquire-${lockId}`);
559+
return {
560+
release: vi.fn(async () => {
561+
events.push(`release-${lockId}`);
562+
}),
563+
};
564+
});
565+
const firstController = await createEmbeddedAttemptSessionLockController({
566+
acquireSessionWriteLock,
567+
lockOptions: { ...lockOptions, sessionFile },
568+
});
569+
570+
await firstController.releaseForPrompt();
571+
572+
const secondController = await createEmbeddedAttemptSessionLockController({
573+
acquireSessionWriteLock,
574+
lockOptions: { ...lockOptions, sessionFile },
575+
});
576+
let secondReleasedForPrompt = false;
577+
const secondRelease = secondController.releaseForPrompt().then(() => {
578+
secondReleasedForPrompt = true;
579+
});
580+
await Promise.resolve();
581+
582+
expect(secondReleasedForPrompt).toBe(false);
583+
expect(events).toEqual(["acquire-1", "release-1", "acquire-2", "release-2"]);
584+
585+
await firstController.reacquireAfterPrompt();
586+
await secondRelease;
587+
588+
expect(secondReleasedForPrompt).toBe(true);
589+
expect(events).toEqual([
590+
"acquire-1",
591+
"release-1",
592+
"acquire-2",
593+
"release-2",
594+
"acquire-3",
595+
"acquire-4",
596+
"release-4",
597+
]);
598+
expect(firstController.hasSessionTakeover()).toBe(false);
599+
expect(secondController.hasSessionTakeover()).toBe(false);
600+
});
601+
602+
it("keeps later waiters behind a newly registered same-file prompt holder", async () => {
603+
const sessionFile = await createTempSessionFile();
604+
const events: string[] = [];
605+
let acquireCount = 0;
606+
const acquireSessionWriteLock = vi.fn(async () => {
607+
acquireCount += 1;
608+
const lockId = acquireCount;
609+
events.push(`acquire-${lockId}`);
610+
return {
611+
release: vi.fn(async () => {
612+
events.push(`release-${lockId}`);
613+
}),
614+
};
615+
});
616+
const firstController = await createEmbeddedAttemptSessionLockController({
617+
acquireSessionWriteLock,
618+
lockOptions: { ...lockOptions, sessionFile },
619+
});
620+
621+
await firstController.releaseForPrompt();
622+
623+
const secondController = await createEmbeddedAttemptSessionLockController({
624+
acquireSessionWriteLock,
625+
lockOptions: { ...lockOptions, sessionFile },
626+
});
627+
const thirdController = await createEmbeddedAttemptSessionLockController({
628+
acquireSessionWriteLock,
629+
lockOptions: { ...lockOptions, sessionFile },
630+
});
631+
let secondReleasedForPrompt = false;
632+
let thirdReleasedForPrompt = false;
633+
const secondRelease = secondController.releaseForPrompt().then(() => {
634+
secondReleasedForPrompt = true;
635+
events.push("second released for prompt");
636+
});
637+
await Promise.resolve();
638+
const thirdRelease = thirdController.releaseForPrompt().then(() => {
639+
thirdReleasedForPrompt = true;
640+
events.push("third released for prompt");
641+
});
642+
await Promise.resolve();
643+
644+
expect(secondReleasedForPrompt).toBe(false);
645+
expect(thirdReleasedForPrompt).toBe(false);
646+
647+
await firstController.reacquireAfterPrompt();
648+
await secondRelease;
649+
await Promise.resolve();
650+
651+
expect(secondReleasedForPrompt).toBe(true);
652+
expect(thirdReleasedForPrompt).toBe(false);
653+
654+
await secondController.reacquireAfterPrompt();
655+
await thirdRelease;
656+
657+
expect(thirdReleasedForPrompt).toBe(true);
658+
expect(events.indexOf("second released for prompt")).toBeGreaterThanOrEqual(0);
659+
expect(events.indexOf("third released for prompt")).toBeGreaterThan(
660+
events.indexOf("second released for prompt"),
661+
);
662+
expect(firstController.hasSessionTakeover()).toBe(false);
663+
expect(secondController.hasSessionTakeover()).toBe(false);
664+
expect(thirdController.hasSessionTakeover()).toBe(false);
665+
});
666+
667+
it("releases a queued prompt holder when same-file waiter reacquire times out", async () => {
668+
const sessionFile = await createTempSessionFile();
669+
const events: string[] = [];
670+
let acquireCount = 0;
671+
const reacquireError = new SessionWriteLockTimeoutError({
672+
timeoutMs: lockOptions.timeoutMs,
673+
owner: "pid=789",
674+
lockPath: `${sessionFile}.lock`,
675+
});
676+
const acquireSessionWriteLock = vi.fn(async () => {
677+
acquireCount += 1;
678+
const lockId = acquireCount;
679+
events.push(`acquire-${lockId}`);
680+
if (lockId === 5) {
681+
events.push("second reacquire timeout");
682+
throw reacquireError;
683+
}
684+
return {
685+
release: vi.fn(async () => {
686+
events.push(`release-${lockId}`);
687+
}),
688+
};
689+
});
690+
const firstController = await createEmbeddedAttemptSessionLockController({
691+
acquireSessionWriteLock,
692+
lockOptions: { ...lockOptions, sessionFile },
693+
});
694+
695+
await firstController.releaseForPrompt();
696+
697+
const secondController = await createEmbeddedAttemptSessionLockController({
698+
acquireSessionWriteLock,
699+
lockOptions: { ...lockOptions, sessionFile },
700+
});
701+
const thirdController = await createEmbeddedAttemptSessionLockController({
702+
acquireSessionWriteLock,
703+
lockOptions: { ...lockOptions, sessionFile },
704+
});
705+
const secondRelease = secondController.releaseForPrompt();
706+
await Promise.resolve();
707+
const thirdRelease = thirdController.releaseForPrompt().then(() => "released" as const);
708+
await Promise.resolve();
709+
710+
await firstController.reacquireAfterPrompt();
711+
await expect(secondRelease).rejects.toBe(reacquireError);
712+
await expect(
713+
Promise.race([
714+
thirdRelease,
715+
new Promise<"blocked">((resolve) => {
716+
setTimeout(() => resolve("blocked"), 50);
717+
}),
718+
]),
719+
).resolves.toBe("released");
720+
721+
expect(events).toContain("second reacquire timeout");
722+
expect(thirdController.hasSessionTakeover()).toBe(false);
723+
});
724+
725+
it("does not keep a prompt holder after a compaction wait release reacquires through writes", async () => {
726+
const sessionFile = await createTempSessionFile();
727+
const events: string[] = [];
728+
let acquireCount = 0;
729+
const acquireSessionWriteLock = vi.fn(async () => {
730+
acquireCount += 1;
731+
const lockId = acquireCount;
732+
events.push(`acquire-${lockId}`);
733+
return {
734+
release: vi.fn(async () => {
735+
events.push(`release-${lockId}`);
736+
}),
737+
};
738+
});
739+
const firstController = await createEmbeddedAttemptSessionLockController({
740+
acquireSessionWriteLock,
741+
lockOptions: { ...lockOptions, sessionFile },
742+
});
743+
744+
await firstController.releaseForSessionIdleWait();
745+
await firstController.withSessionWriteLock(async () => {
746+
events.push("first post-wait write");
747+
});
748+
749+
const secondController = await createEmbeddedAttemptSessionLockController({
750+
acquireSessionWriteLock,
751+
lockOptions: { ...lockOptions, sessionFile },
752+
});
753+
let secondReleasedForPrompt = false;
754+
await secondController.releaseForPrompt().then(() => {
755+
secondReleasedForPrompt = true;
756+
events.push("second released for prompt");
757+
});
758+
759+
expect(secondReleasedForPrompt).toBe(true);
760+
expect(events).toEqual([
761+
"acquire-1",
762+
"release-1",
763+
"acquire-2",
764+
"first post-wait write",
765+
"release-2",
766+
"acquire-3",
767+
"release-3",
768+
"second released for prompt",
769+
]);
770+
expect(firstController.hasSessionTakeover()).toBe(false);
771+
expect(secondController.hasSessionTakeover()).toBe(false);
772+
});
773+
548774
it("rejects external edits interleaved while another controller holds cleanup lock", async () => {
549775
const sessionFile = await createTempSessionFile();
550776
const releases: string[] = [];
@@ -564,6 +790,7 @@ describe("embedded attempt session lock lifecycle", () => {
564790
acquireSessionWriteLock,
565791
lockOptions: { ...lockOptions, sessionFile },
566792
});
793+
resetEmbeddedAttemptSessionFilePromptGuardsForTest();
567794
await secondController.releaseForPrompt();
568795
const cleanupLock = await secondController.acquireForCleanup();
569796

@@ -629,6 +856,7 @@ describe("embedded attempt session lock lifecycle", () => {
629856
},
630857
),
631858
);
859+
resetEmbeddedAttemptSessionFilePromptGuardsForTest();
632860
await secondController.releaseForPrompt();
633861

634862
await expect(
@@ -665,6 +893,7 @@ describe("embedded attempt session lock lifecycle", () => {
665893
await fs.appendFile(sessionFile, '{"type":"message","id":"same-process"}\n', "utf8");
666894
await fs.appendFile(sessionFile, '{"type":"message","id":"external-interleaved"}\n', "utf8");
667895
});
896+
resetEmbeddedAttemptSessionFilePromptGuardsForTest();
668897
await secondController.releaseForPrompt();
669898

670899
await expect(
@@ -698,6 +927,7 @@ describe("embedded attempt session lock lifecycle", () => {
698927
acquireSessionWriteLock,
699928
lockOptions: { ...lockOptions, sessionFile },
700929
});
930+
resetEmbeddedAttemptSessionFilePromptGuardsForTest();
701931
await secondController.releaseForPrompt();
702932

703933
await expect(
@@ -734,6 +964,7 @@ describe("embedded attempt session lock lifecycle", () => {
734964
await secondController.withSessionWriteLock(async () => {
735965
await fs.appendFile(sessionFile, '{"type":"message","id":"same-process"}\n', "utf8");
736966
});
967+
resetEmbeddedAttemptSessionFilePromptGuardsForTest();
737968
await secondController.releaseForPrompt();
738969

739970
await expect(

0 commit comments

Comments
 (0)