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:
Bohdan Triapitsyn
2026-07-14 10:31:23 +03:00
parent 4c27f1753d
commit fb98edda45
3 changed files with 210 additions and 0 deletions
@@ -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();
});
});