fix: settle Pi child capacity reservations
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
- Worktree: D:\Datas\OthersProjects\makelore-pi-child-workers-5c8e2a71
|
- Worktree: D:\Datas\OthersProjects\makelore-pi-child-workers-5c8e2a71
|
||||||
- Base commit: b806c78139aa11e338c680d4c3fa92e076901aa2
|
- Base commit: b806c78139aa11e338c680d4c3fa92e076901aa2
|
||||||
- Owner: codex
|
- Owner: codex
|
||||||
- Status: Planner corrections complete; re-review pending
|
- Status: Second planner corrections complete; re-review pending
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -68,6 +68,15 @@
|
|||||||
prioritizes child reservations so a running parent cannot deadlock while
|
prioritizes child reservations so a running parent cannot deadlock while
|
||||||
waiting on its own child. The child enters the priority queue before idle
|
waiting on its own child. The child enters the priority queue before idle
|
||||||
reclamation releases capacity.
|
reclamation releases capacity.
|
||||||
|
- Planner re-review of `3de85d6` confirmed the original three findings closed,
|
||||||
|
then reproduced two further supported failure paths: a child reservation
|
||||||
|
could wait forever when no parent was idle during the one-time reclaim but a
|
||||||
|
spawning/running parent became reclaimable later; and reclaim/worker-stop
|
||||||
|
failure could leave an unowned process lease. The pool now wakes pending
|
||||||
|
reclaimers on `ready`/`idle` state transitions without polling. Scheduler
|
||||||
|
reservations race direct capacity against this cancellable event-driven
|
||||||
|
reclaim and always settle/release on reclaim rejection, parent abort, or any
|
||||||
|
other early exit. Parent `stop()` now releases its process lease in `finally`.
|
||||||
- Added the managed ephemeral child opener. It resolves only enabled,
|
- Added the managed ephemeral child opener. It resolves only enabled,
|
||||||
unarchived project Agents from `.niancode/project.json`, materializes their
|
unarchived project Agents from `.niancode/project.json`, materializes their
|
||||||
exact model/prompt/skills, keeps credentials in the child environment and
|
exact model/prompt/skills, keeps credentials in the child environment and
|
||||||
@@ -101,7 +110,10 @@
|
|||||||
parent reclamation; parallel sibling preservation; chain skip/abort rules;
|
parent reclamation; parallel sibling preservation; chain skip/abort rules;
|
||||||
parent generation cancellation with no orphan and zero leaked permits;
|
parent generation cancellation with no orphan and zero leaked permits;
|
||||||
recursive child rejection; parent/child same-project write-lease queuing;
|
recursive child rejection; parent/child same-project write-lease queuing;
|
||||||
and unknown schema/version raw-payload suppression.
|
unknown schema/version raw-payload suppression; first reclaim finding no
|
||||||
|
idle followed by delayed parent readiness and automatic child execution;
|
||||||
|
reclaim rejection/abort with zero queued lease; and stop rejection with zero
|
||||||
|
active/waiting lease.
|
||||||
- Locked real Pi 0.84.2 workspace smoke passed for both the parent worker and
|
- Locked real Pi 0.84.2 workspace smoke passed for both the parent worker and
|
||||||
an ephemeral read-only child launched through Electron Node with the child
|
an ephemeral read-only child launched through Electron Node with the child
|
||||||
extension role and `--no-session`. A probe extension reads Pi's actual
|
extension role and `--no-session`. A probe extension reads Pi's actual
|
||||||
@@ -117,18 +129,17 @@
|
|||||||
authenticated Main bridge; the active-tool process probes and bridge execute
|
authenticated Main bridge; the active-tool process probes and bridge execute
|
||||||
test jointly cover visibility and tool invocation without an external
|
test jointly cover visibility and tool invocation without an external
|
||||||
Provider.
|
Provider.
|
||||||
- All cumulative Pi tests passed: 22 files, 106 passed and 1 staged-only
|
- All cumulative Pi tests passed: 22 files, 110 passed and 1 staged-only
|
||||||
skipped; the staged-only command passed separately as described above.
|
skipped; the staged-only command passed separately as described above.
|
||||||
- `pnpm run typecheck`: passed.
|
- `pnpm run typecheck`: passed.
|
||||||
- `pnpm run lint:check`: passed with 0 errors and 6 pre-existing frontend
|
- `pnpm run lint:check`: passed with 0 errors and 6 pre-existing frontend
|
||||||
warnings outside this task.
|
warnings outside this task.
|
||||||
- `pnpm run build:vite`: passed for Renderer, Main, Preload, and utility
|
- `pnpm run build:vite`: passed for Renderer, Main, Preload, and utility
|
||||||
worker bundles; existing chunk-size/dynamic-import warnings remain.
|
worker bundles; existing chunk-size/dynamic-import warnings remain.
|
||||||
- First full-suite run, executed concurrently with the build, passed 2208/2209
|
- The final full-suite first pass hit the known Windows temporary JSON `rename`
|
||||||
and hit the known Windows temporary JSON `rename` `EPERM` in the unchanged
|
`EPERM` in the unchanged conversation store. The failing runtime test passed
|
||||||
conversation store. The failing test passed in isolation, then the serial
|
1/1 in isolation, and the single full-suite rerun passed: 202 files, 2213
|
||||||
full-suite rerun passed. After planner corrections the final serial full
|
passed and 1 staged-only skipped.
|
||||||
suite passed: 202 files, 2209 passed and 1 staged-only skipped.
|
|
||||||
- Real external Provider validation remains **Explicitly Waived / Accepted
|
- Real external Provider validation remains **Explicitly Waived / Accepted
|
||||||
Risk** with `realTurnVerified=false`. Provider concurrency, credential
|
Risk** with `realTurnVerified=false`. Provider concurrency, credential
|
||||||
isolation, protocol compatibility, and image-path risk are accepted rather
|
isolation, protocol compatibility, and image-path risk are accepted rather
|
||||||
@@ -153,7 +164,9 @@
|
|||||||
Future impact: any final Main composition must pass one `PiProcessBudget` to
|
Future impact: any final Main composition must pass one `PiProcessBudget` to
|
||||||
both the parent pool and child scheduler, preserve child-priority budget
|
both the parent pool and child scheduler, preserve child-priority budget
|
||||||
reservations ahead of normal parent-start waiters, and wire the scheduler's
|
reservations ahead of normal parent-start waiters, and wire the scheduler's
|
||||||
capacity reclaimer to `PiWorkerPool.reclaimIdleWorker`. Semantic conflicts:
|
cancellable capacity reclaimer to `PiWorkerPool.reclaimIdleWorker`. The pool
|
||||||
none with the accepted PI runtime specification; this makes its parent/child
|
must notify an already waiting child when a spawning/running parent becomes
|
||||||
cap executable. Human confirmation required: no, unless integration changes
|
`ready`/`idle`, and stop/reclaim failure must never retain a lease. Semantic
|
||||||
the accepted process-cap policy.
|
conflicts: none with the accepted PI runtime specification; this makes its
|
||||||
|
parent/child cap executable. Human confirmation required: no, unless
|
||||||
|
integration changes the accepted process-cap policy.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export interface PiSubagentDispatchOptions {
|
|||||||
export interface PiSubagentSchedulerOptions {
|
export interface PiSubagentSchedulerOptions {
|
||||||
openChild(input: PiSubagentChildOpenInput): Promise<PiSubagentChild>;
|
openChild(input: PiSubagentChildOpenInput): Promise<PiSubagentChild>;
|
||||||
processBudget: PiProcessBudget;
|
processBudget: PiProcessBudget;
|
||||||
reclaimProcessCapacity?(): Promise<boolean>;
|
reclaimProcessCapacity?(signal?: AbortSignal): Promise<boolean>;
|
||||||
createId?: (kind: 'dispatch' | 'task') => string;
|
createId?: (kind: 'dispatch' | 'task') => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ export class PiSubagentScheduler {
|
|||||||
private readonly openChild: PiSubagentSchedulerOptions['openChild'];
|
private readonly openChild: PiSubagentSchedulerOptions['openChild'];
|
||||||
private readonly processBudget: PiProcessBudget;
|
private readonly processBudget: PiProcessBudget;
|
||||||
private readonly childPermits: FifoSemaphore;
|
private readonly childPermits: FifoSemaphore;
|
||||||
private readonly reclaimProcessCapacity: (() => Promise<boolean>) | undefined;
|
private readonly reclaimProcessCapacity: ((signal?: AbortSignal) => Promise<boolean>) | undefined;
|
||||||
private readonly createId: NonNullable<PiSubagentSchedulerOptions['createId']>;
|
private readonly createId: NonNullable<PiSubagentSchedulerOptions['createId']>;
|
||||||
private readonly dispatches = new Map<string, DispatchRecord>();
|
private readonly dispatches = new Map<string, DispatchRecord>();
|
||||||
private readonly parentDispatches = new Map<string, Set<string>>();
|
private readonly parentDispatches = new Map<string, Set<string>>();
|
||||||
@@ -353,12 +353,7 @@ export class PiSubagentScheduler {
|
|||||||
let child: PiSubagentChild | undefined;
|
let child: PiSubagentChild | undefined;
|
||||||
try {
|
try {
|
||||||
releaseChild = await this.childPermits.acquire(record.controller.signal);
|
releaseChild = await this.childPermits.acquire(record.controller.signal);
|
||||||
const budgetWasFull = this.processBudget.activeCount >= this.processBudget.maxProcesses;
|
processLease = await this.acquireProcessLease(record.controller.signal);
|
||||||
const processLeaseFlight = this.processBudget.acquire(record.controller.signal, 'child');
|
|
||||||
if (budgetWasFull && this.reclaimProcessCapacity) {
|
|
||||||
await this.reclaimProcessCapacity();
|
|
||||||
}
|
|
||||||
processLease = await processLeaseFlight;
|
|
||||||
if (record.controller.signal.aborted) throw new PiSubagentChildError('SUBAGENT_ABORTED');
|
if (record.controller.signal.aborted) throw new PiSubagentChildError('SUBAGENT_ABORTED');
|
||||||
projected.status = 'running';
|
projected.status = 'running';
|
||||||
this.emit(details, onUpdate);
|
this.emit(details, onUpdate);
|
||||||
@@ -395,6 +390,33 @@ export class PiSubagentScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async acquireProcessLease(signal: AbortSignal): Promise<PiProcessLease> {
|
||||||
|
const reservation = new AbortController();
|
||||||
|
const abortReservation = () => reservation.abort();
|
||||||
|
signal.addEventListener('abort', abortReservation, { once: true });
|
||||||
|
if (signal.aborted) reservation.abort();
|
||||||
|
const budgetWasFull = this.processBudget.activeCount >= this.processBudget.maxProcesses;
|
||||||
|
const processLeaseFlight = this.processBudget.acquire(reservation.signal, 'child');
|
||||||
|
try {
|
||||||
|
if (budgetWasFull && this.reclaimProcessCapacity) {
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
processLeaseFlight.then((lease) => ({ lease })),
|
||||||
|
this.reclaimProcessCapacity(reservation.signal).then(() => null),
|
||||||
|
]);
|
||||||
|
if (outcome) return outcome.lease;
|
||||||
|
}
|
||||||
|
return await processLeaseFlight;
|
||||||
|
} catch (error) {
|
||||||
|
reservation.abort();
|
||||||
|
const orphanedLease = await processLeaseFlight.catch(() => undefined);
|
||||||
|
orphanedLease?.release();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
signal.removeEventListener('abort', abortReservation);
|
||||||
|
reservation.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private markRemaining(
|
private markRemaining(
|
||||||
details: SubagentDetailsV1,
|
details: SubagentDetailsV1,
|
||||||
from: number,
|
from: number,
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export class PiWorkerPool {
|
|||||||
private readonly activeRuns = new Map<string, { runId: string; generation: number }>();
|
private readonly activeRuns = new Map<string, { runId: string; generation: number }>();
|
||||||
private readonly generations = new Map<string, number>();
|
private readonly generations = new Map<string, number>();
|
||||||
private readonly listeners = new Set<(event: PiWorkerPoolEvent) => void>();
|
private readonly listeners = new Set<(event: PiWorkerPoolEvent) => void>();
|
||||||
|
private readonly reclaimWaiters = new Set<() => void>();
|
||||||
private commandSequence = 0;
|
private commandSequence = 0;
|
||||||
private runningCount = 0;
|
private runningCount = 0;
|
||||||
private useSequence = 0;
|
private useSequence = 0;
|
||||||
@@ -274,6 +275,7 @@ export class PiWorkerPool {
|
|||||||
.then(async ({ opened: { worker, session }, lease }) => {
|
.then(async ({ opened: { worker, session }, lease }) => {
|
||||||
const record = this.createRecord(conversation, worker, session, generation, revision, lease);
|
const record = this.createRecord(conversation, worker, session, generation, revision, lease);
|
||||||
this.workers.set(conversation.conversationId, record);
|
this.workers.set(conversation.conversationId, record);
|
||||||
|
this.notifyReclaimableWorker();
|
||||||
const state = this.publicState(record);
|
const state = this.publicState(record);
|
||||||
await this.trimIdleWorkers();
|
await this.trimIdleWorkers();
|
||||||
return state;
|
return state;
|
||||||
@@ -328,6 +330,7 @@ export class PiWorkerPool {
|
|||||||
lease,
|
lease,
|
||||||
);
|
);
|
||||||
this.workers.set(conversation.conversationId, record);
|
this.workers.set(conversation.conversationId, record);
|
||||||
|
this.notifyReclaimableWorker();
|
||||||
await this.trimIdleWorkers();
|
await this.trimIdleWorkers();
|
||||||
return this.publicState(record);
|
return this.publicState(record);
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
@@ -344,13 +347,19 @@ export class PiWorkerPool {
|
|||||||
return record ? this.publicState(record) : null;
|
return record ? this.publicState(record) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reclaimIdleWorker(): Promise<boolean> {
|
async reclaimIdleWorker(signal?: AbortSignal): Promise<boolean> {
|
||||||
const record = [...this.workers.values()]
|
while (true) {
|
||||||
.filter((candidate) => candidate.state === 'ready' || candidate.state === 'idle')
|
if (signal?.aborted) throw new Error('Pi idle worker reclaim cancelled');
|
||||||
.sort((left, right) => left.lastUsed - right.lastUsed)[0];
|
if (this.shuttingDown) throw new Error('Pi worker pool is shutting down');
|
||||||
if (!record) return false;
|
const record = [...this.workers.values()]
|
||||||
await this.evict(record);
|
.filter((candidate) => candidate.state === 'ready' || candidate.state === 'idle')
|
||||||
return true;
|
.sort((left, right) => left.lastUsed - right.lastUsed)[0];
|
||||||
|
if (record) {
|
||||||
|
if (await this.evict(record)) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.waitForReclaimableWorker(signal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(listener: (event: PiWorkerPoolEvent) => void): () => void {
|
subscribe(listener: (event: PiWorkerPoolEvent) => void): () => void {
|
||||||
@@ -406,6 +415,7 @@ export class PiWorkerPool {
|
|||||||
const record = this.workers.get(conversationId);
|
const record = this.workers.get(conversationId);
|
||||||
if (record && record.state !== 'crashed') {
|
if (record && record.state !== 'crashed') {
|
||||||
record.state = 'idle';
|
record.state = 'idle';
|
||||||
|
this.notifyReclaimableWorker();
|
||||||
try {
|
try {
|
||||||
this.revisions.settleRun(record.revisionWorkerId);
|
this.revisions.settleRun(record.revisionWorkerId);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -605,6 +615,7 @@ export class PiWorkerPool {
|
|||||||
if (current && this.workers.get(run.conversationId) === current
|
if (current && this.workers.get(run.conversationId) === current
|
||||||
&& current.state !== 'crashed') {
|
&& current.state !== 'crashed') {
|
||||||
current.state = 'idle';
|
current.state = 'idle';
|
||||||
|
this.notifyReclaimableWorker();
|
||||||
if (beganRun) this.revisions.settleRun(current.revisionWorkerId);
|
if (beganRun) this.revisions.settleRun(current.revisionWorkerId);
|
||||||
void this.trimIdleWorkers();
|
void this.trimIdleWorkers();
|
||||||
}
|
}
|
||||||
@@ -619,6 +630,7 @@ export class PiWorkerPool {
|
|||||||
this.activeRuns.delete(conversationId);
|
this.activeRuns.delete(conversationId);
|
||||||
this.runningCount -= 1;
|
this.runningCount -= 1;
|
||||||
record.state = 'idle';
|
record.state = 'idle';
|
||||||
|
this.notifyReclaimableWorker();
|
||||||
const revisionAction = this.revisions.settleRun(record.revisionWorkerId);
|
const revisionAction = this.revisions.settleRun(record.revisionWorkerId);
|
||||||
if (!this.shuttingDown
|
if (!this.shuttingDown
|
||||||
&& (record.reconfigureAfterSettled || revisionAction.action === 'rebuild-after-settled')) {
|
&& (record.reconfigureAfterSettled || revisionAction.action === 'rebuild-after-settled')) {
|
||||||
@@ -716,6 +728,7 @@ export class PiWorkerPool {
|
|||||||
? 'running'
|
? 'running'
|
||||||
: 'ready';
|
: 'ready';
|
||||||
this.workers.set(conversationId, replacement);
|
this.workers.set(conversationId, replacement);
|
||||||
|
if (replacement.state === 'ready') this.notifyReclaimableWorker();
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'worker.replaced',
|
type: 'worker.replaced',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -796,15 +809,16 @@ export class PiWorkerPool {
|
|||||||
await Promise.all(idle.slice(0, excess).map((record) => this.evict(record)));
|
await Promise.all(idle.slice(0, excess).map((record) => this.evict(record)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async evict(record: WorkerRecord): Promise<void> {
|
private async evict(record: WorkerRecord): Promise<boolean> {
|
||||||
const conversationId = record.conversation.conversationId;
|
const conversationId = record.conversation.conversationId;
|
||||||
if (this.workers.get(conversationId) !== record) return;
|
if (this.workers.get(conversationId) !== record) return false;
|
||||||
record.unsubscribeEvent();
|
record.unsubscribeEvent();
|
||||||
record.unsubscribeInvalidation();
|
record.unsubscribeInvalidation();
|
||||||
this.cancelGenerationResources(record);
|
this.cancelGenerationResources(record);
|
||||||
this.revisions.removeWorker(record.revisionWorkerId);
|
this.revisions.removeWorker(record.revisionWorkerId);
|
||||||
this.workers.delete(conversationId);
|
this.workers.delete(conversationId);
|
||||||
await this.stopAndRelease(record);
|
await this.stopAndRelease(record);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private launchWaitingRuns(): void {
|
private launchWaitingRuns(): void {
|
||||||
@@ -902,9 +916,39 @@ export class PiWorkerPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async stopAndRelease(record: WorkerRecord): Promise<void> {
|
private async stopAndRelease(record: WorkerRecord): Promise<void> {
|
||||||
await record.worker.stop();
|
try {
|
||||||
record.processLease?.release();
|
await record.worker.stop();
|
||||||
record.processLease = null;
|
} finally {
|
||||||
|
record.processLease?.release();
|
||||||
|
record.processLease = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitForReclaimableWorker(signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cleanup = () => {
|
||||||
|
this.reclaimWaiters.delete(wake);
|
||||||
|
signal?.removeEventListener('abort', cancel);
|
||||||
|
this.shutdownController.signal.removeEventListener('abort', cancel);
|
||||||
|
};
|
||||||
|
const wake = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const cancel = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(
|
||||||
|
this.shuttingDown ? 'Pi worker pool is shutting down' : 'Pi idle worker reclaim cancelled',
|
||||||
|
));
|
||||||
|
};
|
||||||
|
this.reclaimWaiters.add(wake);
|
||||||
|
signal?.addEventListener('abort', cancel, { once: true });
|
||||||
|
this.shutdownController.signal.addEventListener('abort', cancel, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyReclaimableWorker(): void {
|
||||||
|
for (const wake of [...this.reclaimWaiters]) wake();
|
||||||
}
|
}
|
||||||
|
|
||||||
private emit(event: PiWorkerPoolEvent): void {
|
private emit(event: PiWorkerPoolEvent): void {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type PiSubagentChild,
|
type PiSubagentChild,
|
||||||
type PiSubagentChildOpenInput,
|
type PiSubagentChildOpenInput,
|
||||||
} from '../../electron/coding-runtime/pi/subagent';
|
} from '../../electron/coding-runtime/pi/subagent';
|
||||||
import { PiProcessBudget } from '../../electron/coding-runtime/pi/worker-pool';
|
import { PiProcessBudget, PiWorkerPool } from '../../electron/coding-runtime/pi/worker-pool';
|
||||||
|
|
||||||
function parent(runId = 'run-a') {
|
function parent(runId = 'run-a') {
|
||||||
return {
|
return {
|
||||||
@@ -251,4 +251,140 @@ describe('Pi subagent scheduler', () => {
|
|||||||
expect(processBudget.activeCount).toBe(0);
|
expect(processBudget.activeCount).toBe(0);
|
||||||
await scheduler.close();
|
await scheduler.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reclaims a parent that becomes idle after child capacity is already reserved', async () => {
|
||||||
|
const parentOpenGate = deferred();
|
||||||
|
const processBudget = new PiProcessBudget(1);
|
||||||
|
let parentStopped = false;
|
||||||
|
let childRan = false;
|
||||||
|
const pool = new PiWorkerPool({
|
||||||
|
processBudget,
|
||||||
|
maxIdle: 1,
|
||||||
|
openWorker: async () => {
|
||||||
|
await parentOpenGate.promise;
|
||||||
|
return {
|
||||||
|
worker: {
|
||||||
|
id: 'delayed-parent',
|
||||||
|
generation: 1,
|
||||||
|
async request() {
|
||||||
|
return { type: 'response' as const, id: 'parent', success: true as const };
|
||||||
|
},
|
||||||
|
async send() {},
|
||||||
|
subscribe() { return () => undefined; },
|
||||||
|
subscribeInvalidation() { return () => undefined; },
|
||||||
|
async stop() {
|
||||||
|
parentStopped = true;
|
||||||
|
return { mode: 'stdin-close' as const, code: 0, signal: null };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { piSessionId: 'parent-session', sessionKey: 'parent-key' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const preparingParent = pool.prepare({
|
||||||
|
conversationId: 'delayed-parent',
|
||||||
|
projectId: 'project-a',
|
||||||
|
agentId: 'agent-a',
|
||||||
|
title: 'Delayed parent',
|
||||||
|
model: {
|
||||||
|
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||||
|
modelResolution: 'resolved',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect.poll(() => processBudget.activeCount).toBe(1);
|
||||||
|
|
||||||
|
const scheduler = new PiSubagentScheduler({
|
||||||
|
processBudget,
|
||||||
|
reclaimProcessCapacity: (signal) => pool.reclaimIdleWorker(signal),
|
||||||
|
openChild: async (input) => ({
|
||||||
|
id: input.taskId,
|
||||||
|
async run() {
|
||||||
|
childRan = true;
|
||||||
|
return { summary: 'done' };
|
||||||
|
},
|
||||||
|
async stop() {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const child = scheduler.dispatch({
|
||||||
|
...parent('delayed-idle'),
|
||||||
|
request: { mode: 'single', tasks: [task('agent-a')] },
|
||||||
|
});
|
||||||
|
await expect.poll(() => processBudget.waitingCount).toBe(1);
|
||||||
|
|
||||||
|
parentOpenGate.resolve();
|
||||||
|
await preparingParent;
|
||||||
|
await expect(child).resolves.toMatchObject({
|
||||||
|
details: { tasks: [{ status: 'complete' }] },
|
||||||
|
});
|
||||||
|
expect(parentStopped).toBe(true);
|
||||||
|
expect(childRan).toBe(true);
|
||||||
|
expect(processBudget.activeCount).toBe(0);
|
||||||
|
expect(processBudget.waitingCount).toBe(0);
|
||||||
|
await scheduler.close();
|
||||||
|
await pool.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a queued child reservation when the capacity reclaimer rejects', async () => {
|
||||||
|
const processBudget = new PiProcessBudget(1);
|
||||||
|
const parentLease = await processBudget.acquire();
|
||||||
|
const scheduler = new PiSubagentScheduler({
|
||||||
|
processBudget,
|
||||||
|
reclaimProcessCapacity: async () => {
|
||||||
|
throw new Error('reclaim failed');
|
||||||
|
},
|
||||||
|
openChild: async () => {
|
||||||
|
throw new Error('child must not open');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(scheduler.dispatch({
|
||||||
|
...parent('reclaim-rejected'),
|
||||||
|
request: { mode: 'single', tasks: [task('agent-a')] },
|
||||||
|
})).resolves.toMatchObject({
|
||||||
|
details: { tasks: [{ status: 'error', errorCode: 'SUBAGENT_CHILD_FAILED' }] },
|
||||||
|
});
|
||||||
|
expect(processBudget.activeCount).toBe(1);
|
||||||
|
expect(processBudget.waitingCount).toBe(0);
|
||||||
|
parentLease.release();
|
||||||
|
expect(processBudget.activeCount).toBe(0);
|
||||||
|
expect(processBudget.waitingCount).toBe(0);
|
||||||
|
await scheduler.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels both capacity waits when the parent aborts before an idle worker exists', async () => {
|
||||||
|
const processBudget = new PiProcessBudget(1);
|
||||||
|
const parentLease = await processBudget.acquire();
|
||||||
|
let reclaimerCancelled = false;
|
||||||
|
const scheduler = new PiSubagentScheduler({
|
||||||
|
processBudget,
|
||||||
|
reclaimProcessCapacity: async (signal) => await new Promise<boolean>((_resolve, reject) => {
|
||||||
|
const cancel = () => {
|
||||||
|
reclaimerCancelled = true;
|
||||||
|
reject(new Error('reclaim cancelled'));
|
||||||
|
};
|
||||||
|
if (signal?.aborted) cancel();
|
||||||
|
else signal?.addEventListener('abort', cancel, { once: true });
|
||||||
|
}),
|
||||||
|
openChild: async () => {
|
||||||
|
throw new Error('child must not open');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const controller = new AbortController();
|
||||||
|
const child = scheduler.dispatch({
|
||||||
|
...parent('reclaim-aborted'),
|
||||||
|
request: { mode: 'single', tasks: [task('agent-a')] },
|
||||||
|
}, { signal: controller.signal });
|
||||||
|
await expect.poll(() => processBudget.waitingCount).toBe(1);
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
await expect(child).resolves.toMatchObject({
|
||||||
|
details: { tasks: [{ status: 'aborted', errorCode: 'SUBAGENT_ABORTED' }] },
|
||||||
|
});
|
||||||
|
expect(reclaimerCancelled).toBe(true);
|
||||||
|
expect(processBudget.activeCount).toBe(1);
|
||||||
|
expect(processBudget.waitingCount).toBe(0);
|
||||||
|
parentLease.release();
|
||||||
|
expect(processBudget.activeCount).toBe(0);
|
||||||
|
await scheduler.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -227,6 +227,33 @@ describe('Pi worker pool', () => {
|
|||||||
expect(processBudget.activeCount).toBe(0);
|
expect(processBudget.activeCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('releases the process lease even when stopping an idle worker fails', async () => {
|
||||||
|
const processBudget = new PiProcessBudget(1);
|
||||||
|
const pool = new PiWorkerPool({
|
||||||
|
processBudget,
|
||||||
|
maxIdle: 1,
|
||||||
|
openWorker: async ({ conversation: input }) => {
|
||||||
|
const worker = new FakeWorker(`worker-${input.conversationId}`);
|
||||||
|
worker.stop = async () => {
|
||||||
|
worker.stopped = true;
|
||||||
|
throw new Error('stop failed');
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
worker,
|
||||||
|
session: {
|
||||||
|
piSessionId: `session-${input.conversationId}`,
|
||||||
|
sessionKey: `key-${input.conversationId}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await pool.prepare(conversation('conversation-stop-failure'));
|
||||||
|
|
||||||
|
await expect(pool.dispose('conversation-stop-failure')).rejects.toThrow('stop failed');
|
||||||
|
expect(processBudget.activeCount).toBe(0);
|
||||||
|
expect(processBudget.waitingCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('never evicts a running worker when the warm-idle LRU exceeds its cap', async () => {
|
it('never evicts a running worker when the warm-idle LRU exceeds its cap', async () => {
|
||||||
const workers = new Map<string, FakeWorker>();
|
const workers = new Map<string, FakeWorker>();
|
||||||
const pool = new PiWorkerPool({
|
const pool = new PiWorkerPool({
|
||||||
|
|||||||
Reference in New Issue
Block a user