|
|
@@ -25,6 +25,11 @@ async function flushContinuation(): Promise<void> {
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
}
|
|
|
|
|
|
+/** Flush delayed child idle-reconcile timers when idleReconcileDelayMs is 0. */
|
|
|
+async function flushChildIdleReconcile(): Promise<void> {
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, 5));
|
|
|
+}
|
|
|
+
|
|
|
function createHook(options?: {
|
|
|
shouldManageSession?: (sessionID: string) => boolean;
|
|
|
registerSessionAsOrchestrator?: (sessionID: string) => void;
|
|
|
@@ -2324,11 +2329,13 @@ describe('task-session-manager hook', () => {
|
|
|
const { hook } = createHook({
|
|
|
backgroundJobBoard: board,
|
|
|
shouldManageSession: (id) => id === 'parent-1',
|
|
|
+ idleReconcileDelayMs: 0,
|
|
|
});
|
|
|
|
|
|
await hook.event({
|
|
|
event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
|
|
|
});
|
|
|
+ await flushChildIdleReconcile();
|
|
|
|
|
|
expect(board.get('child-1')).toMatchObject({
|
|
|
state: 'reconciled',
|
|
|
@@ -2373,11 +2380,13 @@ describe('task-session-manager hook', () => {
|
|
|
backgroundJobBoard: board,
|
|
|
shouldManageSession: (id) => id === 'parent-1',
|
|
|
isFallbackInProgress: (id) => id === 'child-1',
|
|
|
+ idleReconcileDelayMs: 0,
|
|
|
});
|
|
|
|
|
|
await hook.event({
|
|
|
event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
|
|
|
});
|
|
|
+ await flushChildIdleReconcile();
|
|
|
|
|
|
// Job should still be running — not reconciled
|
|
|
expect(board.get('child-1')).toMatchObject({ state: 'running' });
|
|
|
@@ -2448,11 +2457,13 @@ describe('task-session-manager hook', () => {
|
|
|
shouldManageSession: (id) => id === 'parent-1',
|
|
|
// isFallbackInProgress returns false for child-1
|
|
|
isFallbackInProgress: () => false,
|
|
|
+ idleReconcileDelayMs: 0,
|
|
|
});
|
|
|
|
|
|
await hook.event({
|
|
|
event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
|
|
|
});
|
|
|
+ await flushChildIdleReconcile();
|
|
|
|
|
|
expect(board.get('child-1')).toMatchObject({
|
|
|
state: 'reconciled',
|
|
|
@@ -2477,12 +2488,14 @@ describe('task-session-manager hook', () => {
|
|
|
backgroundJobBoard: board,
|
|
|
shouldManageSession: () => false,
|
|
|
isFallbackInProgress: (id) => id === 'child-1',
|
|
|
+ idleReconcileDelayMs: 0,
|
|
|
});
|
|
|
|
|
|
// First idle (abort from fallback) — guarded, no reconciliation
|
|
|
await hook.event({
|
|
|
event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
|
|
|
});
|
|
|
+ await flushChildIdleReconcile();
|
|
|
expect(board.get('child-1')).toMatchObject({ state: 'running' });
|
|
|
|
|
|
// Busy signal (fallback re-prompt) — updates lastLiveBusyAt
|
|
|
@@ -2499,22 +2512,63 @@ describe('task-session-manager hook', () => {
|
|
|
backgroundJobBoard: board,
|
|
|
shouldManageSession: () => false,
|
|
|
isFallbackInProgress: () => false,
|
|
|
+ idleReconcileDelayMs: 0,
|
|
|
});
|
|
|
await hook2.hook.event({
|
|
|
event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
|
|
|
});
|
|
|
+ await flushChildIdleReconcile();
|
|
|
expect(board.get('child-1')).toMatchObject({
|
|
|
state: 'reconciled',
|
|
|
terminalState: 'completed',
|
|
|
});
|
|
|
});
|
|
|
|
|
|
+ test('busy after idle cancels pending child idle-reconcile (FG race)', async () => {
|
|
|
+ // OpenCode can emit idle for a rate-limited child BEFORE FG sets
|
|
|
+ // isFallbackInProgress. Immediate reconcile would mark completed while
|
|
|
+ // FG re-prompts and the child keeps working. Delay + busy cancel keeps
|
|
|
+ // the job running (the observed council-b false-complete race).
|
|
|
+ const board = new BackgroundJobBoard();
|
|
|
+ board.registerLaunch({
|
|
|
+ taskID: 'child-b',
|
|
|
+ parentSessionID: 'parent-1',
|
|
|
+ agent: 'councillor-reviewer-b',
|
|
|
+ description: 'audit distributed',
|
|
|
+ });
|
|
|
+
|
|
|
+ const { hook } = createHook({
|
|
|
+ backgroundJobBoard: board,
|
|
|
+ shouldManageSession: () => false,
|
|
|
+ isFallbackInProgress: () => false,
|
|
|
+ idleReconcileDelayMs: 30,
|
|
|
+ });
|
|
|
+
|
|
|
+ await hook.event({
|
|
|
+ event: { type: 'session.idle', properties: { sessionID: 'child-b' } },
|
|
|
+ });
|
|
|
+ expect(board.get('child-b')).toMatchObject({ state: 'running' });
|
|
|
+
|
|
|
+ await hook.event({
|
|
|
+ event: {
|
|
|
+ type: 'session.status',
|
|
|
+ properties: { sessionID: 'child-b', status: { type: 'busy' } },
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ await new Promise((r) => setTimeout(r, 50));
|
|
|
+ expect(board.get('child-b')).toMatchObject({ state: 'running' });
|
|
|
+ });
|
|
|
+
|
|
|
test('session.created early-registers board job so after-hook cancellation cannot orphan the child', async () => {
|
|
|
// Reproduces #765: parent tool may be cancelled before tool.execute.after,
|
|
|
// so the job never lands on the board. Early registration from
|
|
|
// session.created keeps runningJobForSession true and lets idle reconcile.
|
|
|
const board = new BackgroundJobBoard();
|
|
|
- const { hook } = createHook({ backgroundJobBoard: board });
|
|
|
+ const { hook } = createHook({
|
|
|
+ backgroundJobBoard: board,
|
|
|
+ idleReconcileDelayMs: 0,
|
|
|
+ });
|
|
|
|
|
|
await hook['tool.execute.before'](
|
|
|
{ tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
|
|
|
@@ -2546,6 +2600,7 @@ describe('task-session-manager hook', () => {
|
|
|
await hook.event({
|
|
|
event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
|
|
|
});
|
|
|
+ await flushChildIdleReconcile();
|
|
|
|
|
|
expect(board.get('child-1')).toMatchObject({
|
|
|
state: 'reconciled',
|
|
|
@@ -2618,11 +2673,13 @@ describe('task-session-manager hook', () => {
|
|
|
const { hook } = createHook({
|
|
|
backgroundJobBoard: board,
|
|
|
shouldManageSession: () => false,
|
|
|
+ idleReconcileDelayMs: 0,
|
|
|
});
|
|
|
|
|
|
await hook.event({
|
|
|
event: { type: 'session.idle', properties: { sessionID: 'child-1' } },
|
|
|
});
|
|
|
+ await flushChildIdleReconcile();
|
|
|
|
|
|
// Should remain cancelled — idle does not override terminal state
|
|
|
const job = board.get('child-1');
|
|
|
@@ -2642,6 +2699,7 @@ describe('task-session-manager hook', () => {
|
|
|
const { hook } = createHook({
|
|
|
backgroundJobBoard: board,
|
|
|
shouldManageSession: (id) => id === 'parent-1',
|
|
|
+ idleReconcileDelayMs: 0,
|
|
|
});
|
|
|
|
|
|
await hook.event({
|
|
|
@@ -2650,6 +2708,7 @@ describe('task-session-manager hook', () => {
|
|
|
properties: { sessionID: 'child-1', status: { type: 'idle' } },
|
|
|
},
|
|
|
});
|
|
|
+ await flushChildIdleReconcile();
|
|
|
|
|
|
expect(board.get('child-1')).toMatchObject({
|
|
|
state: 'reconciled',
|