src/runner/human-feedback.ts
Rama main · réplica de lectura del repositorio original.
import { mkdir, readFile, readdir, writeFile, link, unlink } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import type { HumanReply } from './human-reply.ts';
import { readState } from './state.ts';
export async function saveHumanReply(path: string, reply: HumanReply) {
await mkdir(`${path}.comments`, { recursive: true });
const temporary = join(`${path}.comments`, `${randomUUID()}.tmp`);
await writeFile(temporary, JSON.stringify(reply) + '\n');
try {
// Publish complete JSON atomically and never overwrite a redelivered comment.
await link(temporary, join(`${path}.comments`, `${reply.comment_id}.json`));
return true;
} catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false; throw error; }
finally { await unlink(temporary); }
}
export async function readHumanReplies(path: string): Promise<HumanReply[]> {
let names: string[];
try { names = await readdir(`${path}.comments`); }
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; }
const replies = await Promise.all(names.filter(n => /^\d+\.json$/.test(n)).map(async n =>
JSON.parse(await readFile(join(`${path}.comments`, n), 'utf8')) as HumanReply));
return replies.sort((a, b) => a.comment_id - b.comment_id);
}
export async function hasHumanReplies(path: string) {
const state = await readState(path);
const handled = new Set(state?.handled_comment_ids ?? []);
// Comments retired by /close stay retired even if GitHub redelivers them after cleanup.
const retired = state?.closed_through_comment_id ?? 0;
return (await readHumanReplies(path)).some(reply => reply.comment_id > retired && !handled.has(reply.comment_id));
}
export async function humanFeedback(path: string, runsRoot: string, oneComment = false) {
const previous = await readState(path);
const retired = previous?.closed_through_comment_id ?? 0;
const comments = await readHumanReplies(path);
const pending = comments.filter(c => c.comment_id > retired && !previous?.handled_comment_ids?.includes(c.comment_id));
const fresh = oneComment ? pending.slice(0, 1) : pending;
const visible = comments.filter(c => c.comment_id > retired && (!pending.includes(c) || fresh.includes(c)));
// A closed local generation has no previous run context: its runs were deleted by /close,
// so the next mention starts fresh instead of resurrecting a deleted run id as history.
const closed = previous?.phase === 'closed';
let previousResult: { status: string; reason?: string; message?: string } | undefined;
if (fresh.length && previous && !closed) {
try { previousResult = JSON.parse(await readFile(join(runsRoot, previous.run_id, 'result.json'), 'utf8')); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
}
return { handledIds: [...new Set([...(previous?.handled_comment_ids ?? []), ...fresh.map(c => c.comment_id)])],
context: fresh.length ? { previous_run_id: closed ? undefined : previous?.run_id, previous_result: previousResult, comments: visible,
new_comment_ids: fresh.map(c => c.comment_id),
instruction: 'Respond to the new_comment_ids in context. A human mention is not automatically a request to repair code. This PR has a persistent conversation thread. Only Harness decides whether a paused Conflict workspace is safe to resume; use the supplied workspace and current Git facts.' } : undefined };
}