From 01da292ffcec45a4a14f3a49815d3f020ed7589b Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Wed, 11 Nov 2026 10:33:35 +0700 Subject: [PATCH] fix: bring reviewed Kimi task scoping onto main Apply the task-scoping implementation and regressions reviewed in PR #72, including the maintainer refinement in 201aed204c9182a53e77723718d8dae06186ae25 that reads only the requested turn Bash tasks. The PR merged into its former feature base after that base had already merged to main, leaving these changes absent from main. --- hosts/kimi/transcript.js | 21 +++++++++----------- lib/runtime/session-log.js | 6 +++--- test/hosts/kimi/readers.test.js | 23 ++++++++++++++++------ test/runtime/session-log.test.js | 33 ++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 21 deletions(-) diff --git a/hosts/kimi/transcript.js b/hosts/kimi/transcript.js index 360e694..cf32183 100644 --- a/hosts/kimi/transcript.js +++ b/hosts/kimi/transcript.js @@ -21,19 +21,16 @@ const validResult = (result) => result && typeof result === 'object' && typeof r const pathOf = (input, cwd) => text(input?.path) && (isAbsolute(input.path) ? input.path : text(cwd) && isAbsolute(cwd) ? resolve(cwd, input.path) : null); -// The newest turns' tasks are the ones a turn still waiting for its end can name; older ones are past their end. const MAX_TASKS = 64; +const backgroundTaskOf = (result) => taskId(/^task_id:\s*([A-Za-z0-9_-]+)(?:\r?\n|$)/.exec(text(result?.output))?.[1]); -/** Only discovers fixed task IDs; the reader chooses a bounded file under the known session root. */ -export function taskIdsOf(records = []) { - const ids = []; - for (const record of records.filter(main)) { - const event = record.type === 'context.append_loop_event' ? record.event : null; - if (event?.type === 'tool.call' && event.name === 'TaskOutput') ids.push(taskId(event.args?.task_id)); - if (event?.type === 'tool.result') { - ids.push(taskId(/^task_id:\s*([A-Za-z0-9_-]+)(?:\r?\n|$)/.exec(text(event.result?.output))?.[1])); - } - } +/** + * Only discovers fixed task IDs, those the requested turn's Bash calls started: no other task can settle its + * evidence. The reader chooses a bounded file under the known session root. + */ +export function taskIdsOf(records = [], { turnId } = {}) { + const ids = callsOf(records, nativeTurnIdOf(turnId)).filter((call) => call.name === 'Bash') + .map((call) => backgroundTaskOf(resultOf(records, call))); const newestLast = [...new Set(ids.filter(Boolean).reverse())].reverse(); return newestLast.slice(-MAX_TASKS); } @@ -63,7 +60,7 @@ function notesOf(records, tasks, turnId, cwd) { const result = resultOf(records, call); const input = call.args && typeof call.args === 'object' ? call.args : {}; if (call.name === 'Bash' && text(input.command)) { - const expectedTask = taskId(/^task_id:\s*([A-Za-z0-9_-]+)(?:\r?\n|$)/.exec(text(result?.output))?.[1]); + const expectedTask = backgroundTaskOf(result); const matching = tasks.filter((task) => expectedTask && task?.kind === 'process' && task.parentToolCallId === call.toolCallId && task.command === input.command && task.taskId === expectedTask); const task = matching.length === 1 ? matching[0] : null; diff --git a/lib/runtime/session-log.js b/lib/runtime/session-log.js index a3acd48..e79ad75 100644 --- a/lib/runtime/session-log.js +++ b/lib/runtime/session-log.js @@ -132,8 +132,8 @@ function sessionDirOf(declaration, home, nativeId) { * names the log home (`root(env)`), its workspace index (`workspaceIndex`, listed as `[id, projectDir]` by * `workspacesOf`), a session's folder in a workspace (`sessionDir(workspaceId, nativeId)`) and its log (`logFile`); * `nativeSessionOf(sessionId, scope)` takes the plugin's session id back to the host's. `decode(records, { turnId, - * tasks, projectDir, unread })` answers the row, where `unread` is the byte span of a long log left unread; `taskIds(records)` and `tasksDir(sessionDir)` name the background task files - * it also reads. `scope` is a pseudonym of the log home, for session ids, so no path reaches a Hub task id. Anything + * tasks, projectDir, unread })` answers the row, where `unread` is the byte span of a long log left unread; + * `taskIds(records, { turnId })` and `tasksDir(sessionDir)` name the background task files it also reads. `scope` is a pseudonym of the log home, for session ids, so no path reaches a Hub task id. Anything * unreadable answers null, never an empty valid turn. */ export function sessionLogReaderOf(declaration, env = process.env) { @@ -160,7 +160,7 @@ export function sessionLogReaderOf(declaration, env = process.env) { const key = JSON.stringify([session.dir, stat.dev, stat.ino, stat.size, stat.mtimeMs]); if (parsed?.key !== key) parsed = { key, ...recordsOf(fd, stat.size) }; const { records, unread } = parsed; - const tasks = tasksOf(root, declaration.tasksDir?.(session.dir), declaration.taskIds?.(records) ?? []); + const tasks = tasksOf(root, declaration.tasksDir?.(session.dir), declaration.taskIds?.(records, { turnId }) ?? []); if (!tasks) return null; return declaration.decode(records, { turnId, tasks, projectDir: session.projectDir, unread }) ?? null; } catch { diff --git a/test/hosts/kimi/readers.test.js b/test/hosts/kimi/readers.test.js index b2411e8..d042aab 100644 --- a/test/hosts/kimi/readers.test.js +++ b/test/hosts/kimi/readers.test.js @@ -91,13 +91,24 @@ test('unsupported schemas and foreign agents do not supply evidence', () => { assert.equal(status([meta, { ...prompt(0), origin: { kind: 'cron' } }]), null); }); -test('fixed task discovery rejects paths and does not inspect arbitrary output paths', () => { - const records = [event({ type: 'tool.call', name: 'TaskOutput', args: { task_id: 'bash-task' } }), event({ type: 'tool.result', result: { output: 'task_id: bash-task\nstatus: running' } }), event({ type: 'tool.call', name: 'TaskOutput', args: { task_id: '../outside' } })]; - assert.deepEqual(taskIdsOf(records), ['bash-task']); - const many = Array.from({ length: 70 }, (_, index) => event({ type: 'tool.call', name: 'TaskOutput', args: { task_id: `task-${index}` } })); - const ids = taskIdsOf([...many, many[0]]); +test('fixed task discovery reads only background tasks the requested turn started and rejects paths', () => { + const bash = (id, task, turnId = '0') => [ + event({ type: 'tool.call', uuid: `call-${id}`, toolCallId: `tool-${id}`, turnId, name: 'Bash', args: { command: 'npm test' } }), + event({ type: 'tool.result', parentUuid: `call-${id}`, toolCallId: `tool-${id}`, result: { output: `task_id: ${task}\nstatus: running`, durationMs: 1 } }), + ]; + const records = [ + ...bash(0, 'bash-task'), + ...bash(1, '../outside'), + ...bash(2, 'foreign-task', '1'), + event({ type: 'tool.call', uuid: 'read-uuid', toolCallId: 'read-call', turnId: '0', name: 'TaskOutput', args: { task_id: 'read-task' } }), + event({ type: 'tool.result', result: { output: 'task_id: unrelated-task\nstatus: running' } }), + ]; + assert.deepEqual(taskIdsOf(records, { turnId: '0' }), ['bash-task']); + assert.deepEqual(taskIdsOf(records, { turnId: '1' }), ['foreign-task']); + const many = Array.from({ length: 70 }, (_, index) => bash(index, `task-${index}`)).flat(); + const ids = taskIdsOf([...many, ...bash(70, 'task-0')], { turnId: '0' }); assert.equal(ids.length, 64); - assert.equal(ids.at(-1), 'task-0', 'a task the newest turn names again is kept'); + assert.equal(ids.at(-1), 'task-0', 'a task named again by a later call is kept'); }); test('duplicate native events do not duplicate checks and mismatched results remain unknown', () => { diff --git a/test/runtime/session-log.test.js b/test/runtime/session-log.test.js index ed983a2..f411fc1 100644 --- a/test/runtime/session-log.test.js +++ b/test/runtime/session-log.test.js @@ -4,6 +4,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; +import { taskIdsOf, turnStatusOf } from '../../hosts/kimi/transcript.js'; + import { recordReaderOf } from '../../lib/runtime/records.js'; function fixture(t, overrides = {}) { @@ -137,3 +139,34 @@ test('a task the log names many times counts once toward the task limit', (t) => assert.ok(f.reader(f.sessionId, '0')); assert.deepEqual(f.decoded.at(-1).context.tasks, [{ taskId: 'task', status: 'running' }]); }); + +test('a damaged task of an earlier turn does not hide a later completed turn, even one that reads its output', (t) => { + const f = fixture(t, { + taskIds: taskIdsOf, tasksDir: (dir) => join(dir, 'agents', 'main', 'tasks'), decode: turnStatusOf, + }); + const prompt = (turnId) => ({ type: 'turn.prompt', agentId: 'main', turnId, promptId: `prompt-${turnId}`, origin: { kind: 'user' } }); + const event = (value) => ({ type: 'context.append_loop_event', agentId: 'main', event: value }); + const records = [ + { type: 'metadata', protocol_version: '1.5' }, + prompt(0), + event({ type: 'tool.call', uuid: 'old-call', turnId: '0', toolCallId: 'old-check', name: 'Bash', args: { command: 'npm test', run_in_background: true } }), + event({ type: 'tool.result', parentUuid: 'old-call', toolCallId: 'old-check', result: { output: 'task_id: old-task\nstatus: running', durationMs: 1 } }), + { type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed' }, + prompt(1), + event({ type: 'tool.call', uuid: 'output-call', turnId: '1', toolCallId: 'old-output', name: 'TaskOutput', args: { task_id: 'old-task' } }), + event({ type: 'tool.result', parentUuid: 'output-call', toolCallId: 'old-output', result: { output: 'status: completed', durationMs: 1 } }), + event({ type: 'tool.call', uuid: 'current-call', turnId: '1', toolCallId: 'current-check', name: 'Bash', args: { command: 'npm test' } }), + event({ type: 'tool.result', parentUuid: 'current-call', toolCallId: 'current-check', result: { output: '# pass 1\n# fail 0', durationMs: 1 } }), + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' }, + ]; + writeFileSync(f.wire, records.map(line).join('')); + mkdirSync(join(f.agent, 'tasks')); + const taskFile = join(f.agent, 'tasks', 'old-task.json'); + writeFileSync(taskFile, JSON.stringify({ taskId: 'old-task', kind: 'process', parentToolCallId: 'old-check', command: 'npm test', status: 'completed', exitCode: 0 })); + assert.equal(f.reader(f.sessionId, '1').status, 'completed'); + writeFileSync(taskFile, '{'); + assert.equal(f.reader(f.sessionId, '0'), null, 'the requested turn still requires its own task to be readable'); + const current = f.reader(f.sessionId, '1'); + assert.equal(current?.status, 'completed'); + assert.deepEqual(current.record.checks, [{ command: 'npm test', exit: null, failed: false }]); +});