From fb98edda451b91e9758cee6a2f1f6e95c438d19c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 14 Jul 2026 10:31:15 +0300 Subject: [PATCH] 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 --- .../server/lib/session-goal/DOCUMENTATION.md | 7 + .../web/server/lib/session-goal/runtime.js | 33 ++++ .../server/lib/session-goal/runtime.test.js | 170 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 packages/web/server/lib/session-goal/runtime.test.js diff --git a/packages/web/server/lib/session-goal/DOCUMENTATION.md b/packages/web/server/lib/session-goal/DOCUMENTATION.md index 4ac79fc3..32ecb786 100644 --- a/packages/web/server/lib/session-goal/DOCUMENTATION.md +++ b/packages/web/server/lib/session-goal/DOCUMENTATION.md @@ -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: diff --git a/packages/web/server/lib/session-goal/runtime.js b/packages/web/server/lib/session-goal/runtime.js index 931599c3..98c36cd6 100644 --- a/packages/web/server/lib/session-goal/runtime.js +++ b/packages/web/server/lib/session-goal/runtime.js @@ -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; diff --git a/packages/web/server/lib/session-goal/runtime.test.js b/packages/web/server/lib/session-goal/runtime.test.js new file mode 100644 index 00000000..40f6a29f --- /dev/null +++ b/packages/web/server/lib/session-goal/runtime.test.js @@ -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(); + }); +});