Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

### Fixed

- Fixed a session-liveness wait that could pin a CPU core at 100% by resampling settled promises in a tight microtask loop; it now yields a real event-loop turn when a queued work item does not converge, restoring RPC responsiveness under load ([#1084](https://github.com/code-yeongyu/senpi/pull/1084)).
- The interactive TUI now keeps the working dock painted across adjacent and locally buffered turns, and clears it on the new core `agent_idle` event - emitted only after settlement-deferred turns (TTSR, loop-guard, goal recovery) resolve without starting a run - so the editor/footer no longer bounce at queued-turn boundaries. A buffered prompt consumed with `action: "handled"` (for example a `UserPromptSubmit` hook block) also clears the retained dock, prompt admission failure clears it, and clear-on-shrink reserves the dock's measured height, together eliminating the vertical jitter.

- Goal cache-warm notices now render the expected wake time in the user's local system timezone with a short zone label (for example `ready 2026-08-22 16:51 GMT+9 (4m 30s)`), falling back to the legacy UTC shape when local timezone formatting is unavailable ([#1074](https://github.com/code-yeongyu/senpi/pull/1074)).
Expand Down
42 changes: 41 additions & 1 deletion packages/coding-agent/src/core/session-work-barrier.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
/**
* Re-sample rounds allowed before the loop starts yielding to the event loop.
* Normal sessions settle within a couple of rounds, so a small budget keeps the
* fast path free of macrotask hops while still catching a runaway queue.
*/
const SETTLE_ROUNDS_BEFORE_YIELD = 16;

/**
* Yields to a real event-loop turn between re-sample rounds. Awaiting only
* promises keeps work on the microtask queue, which starves timers and IO and
* pins a core at 100% CPU.
*
* A `MessageChannel` message is a genuine macrotask that mocked timers do not
* replace, so the loop keeps yielding under `vi.useFakeTimers()`. `setTimeout`
* and `setImmediate` are both stubbed there and would stall every caller that
* drives retry/compaction recovery on a mocked clock; `process.nextTick` runs
* ahead of the microtask drain and would not relieve the starvation at all.
*/
function yieldToEventLoop(): Promise<void> {
return new Promise<void>((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () => {
channel.port1.close();
channel.port2.close();
resolve();
};
channel.port2.postMessage(undefined);
});
}

export class SessionWorkBarrier {
private activeWork: Promise<void> | undefined = undefined;
private activeWorkResolve: (() => void) | undefined = undefined;
Expand Down Expand Up @@ -39,7 +69,7 @@ export class SessionWorkBarrier {
}

async waitForSettled(getEventQueue: () => Promise<void>): Promise<void> {
while (true) {
for (let round = 0; ; round++) {
const eventQueue = getEventQueue();
const work = this.activeWork;

Expand All @@ -51,6 +81,16 @@ export class SessionWorkBarrier {
if (getEventQueue() === eventQueue && !this.activeWork) {
return;
}

// The queue moved under us. The first rounds re-sample immediately so a
// continuation scheduled during this turn is still observed as pending —
// that tight re-check is what keeps queued work from being reported as
// settled. Once the queue proves it is not converging, hand control back
// to the event loop so a session that re-chains already-resolved promises
// cannot pin a core at 100% CPU.
if (round >= SETTLE_ROUNDS_BEFORE_YIELD) {
await yieldToEventLoop();
}
}
}
}
75 changes: 75 additions & 0 deletions packages/coding-agent/test/session-work-barrier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { SessionWorkBarrier } from "../src/core/session-work-barrier.ts";

/**
* `waitForSettled` re-samples the event queue until two consecutive samples agree.
* A session whose queue identity is replaced by already-resolved promises never
* reaches agreement, and because every iteration only awaits microtasks the loop
* never yields to the event loop's timer/IO phases: the process pins one core at
* 100% CPU for as long as the producer keeps re-chaining. This is the observed
* `omo --mode rpc --multi-session` child spin.
*/
describe("SessionWorkBarrier.waitForSettled", () => {
it("returns once the event queue identity is stable and no work is active", async () => {
const barrier = new SessionWorkBarrier();
const queue = Promise.resolve();
await barrier.waitForSettled(() => queue);
expect(barrier.hasActiveWork).toBe(false);
});

it("waits for active work registered through begin()", async () => {
const barrier = new SessionWorkBarrier();
const queue = Promise.resolve();
const finish = barrier.begin();
expect(barrier.hasActiveWork).toBe(true);

let settled = false;
const waiter = barrier
.waitForSettled(() => queue)
.then(() => {
settled = true;
});

await Promise.resolve();
expect(settled).toBe(false);

finish();
await waiter;
expect(settled).toBe(true);
expect(barrier.hasActiveWork).toBe(false);
});

it("keeps the event loop responsive when the queue never stabilizes", async () => {
const barrier = new SessionWorkBarrier();
// Model the observed spin: a session whose subscriber keeps re-chaining an
// already-resolved queue, so the two samples in one round never agree.
// Awaiting only such promises never leaves the microtask queue, which pins
// a core at 100% CPU and stalls every timer and socket on the process.
let unstableRounds = 0;
const getEventQueue = (): Promise<void> => {
unstableRounds++;
return Promise.resolve();
};

let timerFired = false;
const ticker = setInterval(() => {
timerFired = true;
}, 1);
// Deliberately not awaited: an unstable queue means there is still work to
// drain, so reporting it as settled would be a lie. The contract under test
// is that waiting stays cheap, not that it gives up.
void barrier.waitForSettled(getEventQueue);

try {
await new Promise<void>((resolve) => {
const deadline = setTimeout(resolve, 50);
deadline.unref?.();
});
// A starving loop never lets these timers run at all.
expect(timerFired).toBe(true);
expect(unstableRounds).toBeGreaterThan(0);
} finally {
clearInterval(ticker);
}
});
});