fix: gate session goal audits on live child activity
Re-checks authoritative session status after the quiet window Skips auditing while a direct child session is still busy or retrying Retries the quiet window when live status data is unavailable
This commit is contained in:
@@ -78,6 +78,13 @@ before touching the filesystem). Rationale: metadata rides every
|
||||
a goal on an idle session emits no status transition.
|
||||
3. On fire (`tick`), gated by the `sessionGoalEnabled` setting:
|
||||
- fetch session (skip sub-agent sessions), require an `active` goal;
|
||||
- authoritative live-activity check after the quiet window: re-read the
|
||||
session status map, bail if the parent resumed, then list direct child
|
||||
sessions and bail while any child is `busy`/`retry`. A background
|
||||
subagent leaves its parent idle, then injects its result into the parent
|
||||
when done; that parent `busy` → `idle` cycle re-arms the loop without
|
||||
polling. Status/children fetch failure is unknown, not empty, so it skips
|
||||
the audit and retries after another quiet window;
|
||||
- quiescence check via the message tail (trailing user message or
|
||||
unfinished assistant reply → bail; the next idle transition re-arms);
|
||||
- token accounting as a SNAPSHOT of the latest completed assistant turn:
|
||||
|
||||
@@ -293,6 +293,19 @@ export const createSessionGoalRuntime = ({
|
||||
return Array.isArray(messages) ? messages : null;
|
||||
};
|
||||
|
||||
const fetchSessionStatuses = async (directory) => {
|
||||
const statuses = await openCodeFetch('/session/status', { directory }).catch(() => null);
|
||||
return statuses && typeof statuses === 'object' && !Array.isArray(statuses) ? statuses : null;
|
||||
};
|
||||
|
||||
const fetchSessionChildren = async (sessionId, directory) => {
|
||||
const children = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/children`, { directory })
|
||||
.catch(() => null);
|
||||
return Array.isArray(children) ? children : null;
|
||||
};
|
||||
|
||||
const isWorkingStatus = (status) => status?.type === 'busy' || status?.type === 'retry';
|
||||
|
||||
// Merge-write the goal payload from a FRESH session read so concurrent
|
||||
// metadata writes (assist payloads, dismissals, UI goal edits) survive.
|
||||
// Returns the written goal, or null when the stored goal no longer matches
|
||||
@@ -437,6 +450,26 @@ export const createSessionGoalRuntime = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Parent idle does not imply the whole task is quiescent: a background
|
||||
// subagent runs in a child session while its parent stays idle. Re-read
|
||||
// authoritative live status after the quiet window. If the parent resumed,
|
||||
// its next idle event will arm a fresh tick. If a child is still working,
|
||||
// OpenCode will inject its result into the parent and produce the same
|
||||
// busy→idle cycle, so do not poll or audit the interim parent reply.
|
||||
const statuses = await fetchSessionStatuses(directory);
|
||||
if (!statuses) {
|
||||
armTimer(sessionId, directory, idleQuietMs);
|
||||
return;
|
||||
}
|
||||
if (isWorkingStatus(statuses[sessionId])) return;
|
||||
|
||||
const children = await fetchSessionChildren(sessionId, directory);
|
||||
if (!children) {
|
||||
armTimer(sessionId, directory, idleQuietMs);
|
||||
return;
|
||||
}
|
||||
if (children.some((child) => typeof child?.id === 'string' && isWorkingStatus(statuses[child.id]))) return;
|
||||
|
||||
const messages = await fetchRecentMessages(sessionId, directory);
|
||||
if (!messages) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createSessionGoalRuntime } from './runtime.js';
|
||||
|
||||
const SESSION_ID = 'ses_parent';
|
||||
const CHILD_ID = 'ses_child';
|
||||
const DIRECTORY = '/workspace';
|
||||
|
||||
const goal = {
|
||||
id: 'goal_1',
|
||||
objective: 'Finish the task',
|
||||
status: 'active',
|
||||
turnsUsed: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
|
||||
const session = {
|
||||
id: SESSION_ID,
|
||||
directory: DIRECTORY,
|
||||
metadata: { openchamber: { goal } },
|
||||
};
|
||||
|
||||
const jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const requestPath = (input) => new URL(typeof input === 'string' ? input : input.url).pathname;
|
||||
|
||||
const startIdleTick = async (fetchImpl) => {
|
||||
const getSmallModelService = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchImpl);
|
||||
const runtime = createSessionGoalRuntime({
|
||||
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
getSmallModelService,
|
||||
idleQuietMs: 10,
|
||||
});
|
||||
runtime.processPayload({
|
||||
type: 'session.status',
|
||||
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
return { runtime, getSmallModelService };
|
||||
};
|
||||
|
||||
describe('session goal live activity gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('waits for the next parent idle when the parent resumed during the quiet window', async () => {
|
||||
const paths = [];
|
||||
const { runtime, getSmallModelService } = await startIdleTick(vi.fn(async (input) => {
|
||||
const pathname = requestPath(input);
|
||||
paths.push(pathname);
|
||||
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
||||
if (pathname === '/session/status') return jsonResponse({ [SESSION_ID]: { type: 'busy' } });
|
||||
throw new Error(`Unexpected request: ${pathname}`);
|
||||
}));
|
||||
|
||||
expect(paths).toEqual([`/session/${SESSION_ID}`, '/session/status']);
|
||||
expect(getSmallModelService).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(paths).toHaveLength(2);
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it('waits for the parent result cycle while a direct child is working', async () => {
|
||||
const paths = [];
|
||||
const { runtime, getSmallModelService } = await startIdleTick(vi.fn(async (input) => {
|
||||
const pathname = requestPath(input);
|
||||
paths.push(pathname);
|
||||
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
||||
if (pathname === '/session/status') return jsonResponse({ [CHILD_ID]: { type: 'busy' } });
|
||||
if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([{ id: CHILD_ID, parentID: SESSION_ID }]);
|
||||
throw new Error(`Unexpected request: ${pathname}`);
|
||||
}));
|
||||
|
||||
expect(paths).toEqual([
|
||||
`/session/${SESSION_ID}`,
|
||||
'/session/status',
|
||||
`/session/${SESSION_ID}/children`,
|
||||
]);
|
||||
expect(getSmallModelService).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(paths).toHaveLength(3);
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it('retries the quiet window when live status cannot be read', async () => {
|
||||
const paths = [];
|
||||
const { runtime, getSmallModelService } = await startIdleTick(vi.fn(async (input) => {
|
||||
const pathname = requestPath(input);
|
||||
paths.push(pathname);
|
||||
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
||||
if (pathname === '/session/status') return jsonResponse({ error: 'unavailable' }, 503);
|
||||
throw new Error(`Unexpected request: ${pathname}`);
|
||||
}));
|
||||
|
||||
expect(paths).toEqual([`/session/${SESSION_ID}`, '/session/status']);
|
||||
expect(getSmallModelService).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(paths).toEqual([
|
||||
`/session/${SESSION_ID}`,
|
||||
'/session/status',
|
||||
`/session/${SESSION_ID}`,
|
||||
'/session/status',
|
||||
]);
|
||||
runtime.stop();
|
||||
});
|
||||
|
||||
it('audits normally when the idle parent has no working children', async () => {
|
||||
const requests = [];
|
||||
const fetchImpl = vi.fn(async (input, init = {}) => {
|
||||
const pathname = requestPath(input);
|
||||
requests.push({ pathname, method: init.method ?? 'GET' });
|
||||
if (pathname === `/session/${SESSION_ID}` && init.method === 'PATCH') return jsonResponse(session);
|
||||
if (pathname === `/session/${SESSION_ID}`) return jsonResponse(session);
|
||||
if (pathname === '/session/status') return jsonResponse({});
|
||||
if (pathname === `/session/${SESSION_ID}/children`) return jsonResponse([]);
|
||||
if (pathname === `/session/${SESSION_ID}/message`) {
|
||||
return jsonResponse([{
|
||||
info: {
|
||||
id: 'msg_assistant',
|
||||
sessionID: SESSION_ID,
|
||||
role: 'assistant',
|
||||
providerID: 'provider',
|
||||
modelID: 'model',
|
||||
time: { completed: 2 },
|
||||
tokens: { input: 1, output: 1, cache: { read: 0 } },
|
||||
},
|
||||
parts: [{ type: 'text', text: 'The task is verified complete.' }],
|
||||
}]);
|
||||
}
|
||||
throw new Error(`Unexpected request: ${pathname}`);
|
||||
});
|
||||
const service = {
|
||||
generateSmallModelText: vi.fn(async () => ({
|
||||
text: '{"verdict":"complete","note":"Task verified complete"}',
|
||||
})),
|
||||
};
|
||||
vi.stubGlobal('fetch', fetchImpl);
|
||||
const runtime = createSessionGoalRuntime({
|
||||
buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
getSmallModelService: async () => service,
|
||||
idleQuietMs: 10,
|
||||
});
|
||||
|
||||
runtime.processPayload({
|
||||
type: 'session.status',
|
||||
properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(service.generateSmallModelText).toHaveBeenCalledOnce();
|
||||
expect(requests).toContainEqual({ pathname: `/session/${SESSION_ID}`, method: 'PATCH' });
|
||||
runtime.stop();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user