Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@

### Fixed

- Fixed `task.maxConcurrency: 0` serializing subagent spawns instead of disabling the task semaphore limit ([#3305](https://github.com/can1357/oh-my-pi/issues/3305)).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking — this bullet ("Fixed MCP tool calls forwarding unused optional placeholder arguments…") has no corresponding code change in the PR diff (git diff origin/main...HEAD only touches parallel.ts plus two test files). Looks like a stray entry from an unrelated branch. Please drop it.


Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix — this entry lands inside ## [16.1.16] - 2026-06-23 (header at line 5), which is already a released section. Per AGENTS.md: "Never modify already-released sections (e.g. ## [0.12.2]) — they are immutable. New entries always go under ## [Unreleased]." The [Unreleased] block on line 3 is currently empty — please move the task.maxConcurrency: 0 bullet there.

- Fixed `local://` URLs decoding images as corrupted text (mojibake) instead of showing the image
- Fixed `omp --resume` hanging instead of exiting when the startup session picker is cancelled (Esc) or there are no sessions to resume. Startup arms long-lived handles (theme/appearance listeners, settings save timer, model registry), so the cancel/empty paths' bare `return` left the event loop alive and the process stuck after the picker cleared the alternate screen. These paths now exit cleanly via `process.exit(0)`, matching the `--version`/`--export` early-exit convention. The in-session `/resume` picker is unaffected — it keeps its own cancel handler that just closes the overlay.
- Fixed the `/resume` session picker scrolling down after a session is deleted. The delete-confirmation dialog was mounted as a sibling below the picker's bottom border, briefly growing the picker past the terminal height; the TUI committed the picker's header rows into native scrollback to fit, and when the dialog closed `windowTop` stayed pinned at the new commit boundary, leaving the header stranded above the viewport. The picker now hosts the `SessionList` in a single content slot and swaps the dialog INTO that slot (replacing the `SessionList`) while it is open, so the dialog only competes with the `SessionList`'s rendered budget — not the `SessionList` AND the picker chrome — and the picker frame stays inside the viewport. ([#3283](https://github.com/can1357/oh-my-pi/issues/3283))
Expand Down
3 changes: 2 additions & 1 deletion packages/coding-agent/src/task/parallel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ export class Semaphore {
#queue: Array<() => void> = [];

constructor(max: number) {
this.#max = Math.max(1, max);
const normalizedMax = Number.isFinite(max) ? Math.floor(max) : Number.POSITIVE_INFINITY;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code fix is correct — 0, negatives, NaN, and +Infinity all collapse to POSITIVE_INFINITY; positive finite values get Math.floor'd. Matches the task.maxConcurrency schema's "0" → "Unlimited" UI label (src/config/settings-schema.ts:3816) and the runEvalConcurrency 0 = unbounded contract (src/eval/concurrency-bridge.ts:31-33).

nitMath.trunc would mirror runEvalConcurrency exactly; Math.floor only differs for negatives, which already fall through to Infinity here, so the runtime behaviour is identical. Not worth changing on its own.

Also note: there's a parallel PR #3307 (by @roboomp) targeting the same constructor with the same Infinity-on-non-positive shape. Functionally equivalent — maintainer's call which lands.

this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
}

async acquire(): Promise<void> {
Expand Down
55 changes: 55 additions & 0 deletions packages/coding-agent/test/task/task-batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* item; with `async.enabled=false`, it blocks and returns merged results.
* Both modes forward the shared `context`; the flat form stays accepted at
* runtime for internal callers.
* 4. task.maxConcurrency=0 preserves full-width synchronous batch fan-out.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
Expand Down Expand Up @@ -76,6 +77,16 @@ function makeResult(id: string, overrides: Partial<SingleResult> = {}): SingleRe
};
}

interface Deferred {
promise: Promise<void>;
resolve: () => void;
}

function deferred(): Deferred {
const { promise, resolve } = Promise.withResolvers<void>();
return { promise, resolve };
}

function mockDiscovery(): void {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({
agents: [taskAgent],
Expand Down Expand Up @@ -364,4 +375,48 @@ describe("task.batch spawning", () => {
"# Goal\nShared synchronous context.",
]);
});

it("treats task.maxConcurrency 0 as unlimited for synchronous batch fan-out", async () => {
mockDiscovery();
const bothStarted = deferred();
const release = deferred();
const started: string[] = [];
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => {
const id = options.id ?? "?";
started.push(id);
if (started.length === 2) bothStarted.resolve();
await release.promise;
return makeResult(id);
});

const manager = createManager();
const tool = await TaskTool.create(
createSession({
manager,
settings: { "async.enabled": false, "task.batch": true, "task.maxConcurrency": 0 },
}),
);

const pending = tool.execute("tc-sync-unlimited", {
agent: "task",
context: "# Goal\nShared synchronous context.",
tasks: [
{ id: "Alpha", assignment: "Do A." },
{ id: "Beta", assignment: "Do B." },
],
} as TaskParams);

const race = await Promise.race([
bothStarted.promise.then(() => "started" as const),
Bun.sleep(250).then(() => "timeout" as const),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — the sibling task-spawn.test.ts test already imports a pollUntil helper and uses it for "both started". A fixed Bun.sleep(250) race here makes the test flake-prone on slow CI without any benefit; consider polling on started.length === 2 for consistency.

Minor: Deferred/deferred() are now duplicated between task-batch.test.ts (lines 80-88, new) and task-spawn.test.ts (lines 68-76, existing). Same shape in test/memories-runtime.test.ts and test/registry/agent-lifecycle.test.ts too — a shared test/helpers/deferred.ts would avoid the 4th copy, but that's out of scope for this PR.

]);
expect(race).toBe("started");
expect([...started].sort()).toEqual(["Alpha", "Beta"]);

release.resolve();
const result = await pending;
expect(getFirstText(result)).toContain("All done.");
expect(result.details?.async).toBeUndefined();
expect(result.details?.results.map(item => item.id).sort()).toEqual(["Alpha", "Beta"]);
});
});
44 changes: 44 additions & 0 deletions packages/coding-agent/test/task/task-spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* 2. The session-scoped spawn semaphore (task.maxConcurrency) serializes job
* bodies: with concurrency 1 the second body does not start until the
* first releases.
* 3. task.maxConcurrency=0 means unlimited rather than one-at-a-time.
*
* Param validation (missing agent / missing assignment) is covered by
* test/task/task-schema.test.ts.
Expand Down Expand Up @@ -187,4 +188,47 @@ describe("task spawn routing", () => {
expect(firstJob.status).toBe("completed");
expect(secondJob.status).toBe("completed");
});

it("treats task.maxConcurrency 0 as unlimited for background spawns", async () => {
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({
agents: [taskAgent],
projectAgentsDir: null,
});
const started: string[] = [];
const gates = new Map<string, Deferred>();
vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => {
const id = options.id ?? "?";
started.push(id);
const gate = deferred();
gates.set(id, gate);
await gate.promise;
return makeResult(id);
});

const manager = createManager();
const tool = await TaskTool.create(createSession({ manager, settings: { "task.maxConcurrency": 0 } }));

const first = await tool.execute("tc-unlimited-1", {
agent: "task",
id: "First",
assignment: "Work A.",
} as TaskParams);
const second = await tool.execute("tc-unlimited-2", {
agent: "task",
id: "Second",
assignment: "Work B.",
} as TaskParams);
const firstJob = manager.getJob(first.details!.async!.jobId)!;
const secondJob = manager.getJob(second.details!.async!.jobId)!;

await pollUntil(() => started.length === 2);
expect(started).toEqual(["First", "Second"]);
expect(secondJob.queued).toBe(false);

gates.get("First")!.resolve();
gates.get("Second")!.resolve();
await Promise.all([firstJob.promise, secondJob.promise]);
expect(firstJob.status).toBe("completed");
expect(secondJob.status).toBe("completed");
});
});
Loading