import { repositoryApi } from './factory-lib.mjs'; // Progress is observational only: no raw logs, prompts, screenshots or model calls. // The sender has the existing contents permission; only the isolated publisher // can write comments. A failed notification never changes the build result. import { lstatSync, readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { setTimeout as sleep } from 'node:timers/promises'; import { acceptanceCriteria, identifyCheck } from './acceptance-criteria.mjs'; import { taskOutcome, outcomeLabels } from './task-outcome.mjs'; export const marker = ''; const dataMarker = '')[0]); } catch { return null; } } export function isNewer(next, old) { if (!old) return true; const a = [next.runNumber, next.attempt, Number(next.final), next.sampledAt]; const b = [old.runNumber, old.attempt, Number(old.final), old.sampledAt]; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return a[i] > b[i]; } return false; } export function renderProgress(record, repository) { const s = record.snapshot; const q = s?.qa; const rows = [ ['当前状态', record.label], ['快照时间(UTC)', iso(record.sampledAt)], ['本次运行已耗时', minutes((record.endedAt ?? record.sampledAt) - record.startedAt)], ['最近记录阶段', s ? `${phases[s.phase]}(本阶段 ${minutes(s.sampledAt - s.phaseStartedAt)})` : '尚无阶段快照'], ['累计验证 / 修复轮次', s ? `${s.verificationAttempts} / ${s.repairAttempts}` : '未记录'], ['待复测 ID', s?.pendingCriteria.length ? s.pendingCriteria.join(', ') : '无已记录项'], ['最近 Agent / 校验输出(UTC)', iso(s?.activityAt)], ]; if (q) { rows.push(['当前 QA 已记录', `${q.scope === 'focused' ? '定向' : '全量'}:${q.recorded}/${q.total};通过 ${q.passed},失败 ${q.failed},受阻 ${q.blocked},未执行 ${q.not_run},未记录 ${q.total - q.recorded}`]); } if (!q && ['qa-focused', 'qa-full'].includes(s?.phase)) rows.push(['当前 QA 已记录', '尚无可读取的验收报告']); const last = record.lastCheck; if (last) { rows.push(['最近验收项记录(UTC)', `${last.id} · 第 ${last.round} 轮 · ${iso(last.at)}`]); } return `${marker}\n${dataMarker}${JSON.stringify(record)} -->\n## 搭建实时进度\n\n` + `[Run ${record.runId} / attempt ${record.attempt}](https://github.com/${repository}/actions/runs/${record.runId}/attempts/${record.attempt})\n\n` + `| 指标 | 最近观察 |\n| --- | --- |\n${rows.map(([a, b]) => `| ${a} | ${b} |`).join('\n')}\n\n` + '> 阶段或验收计数变化时最多每两分钟更新一次;无变化时约每二十分钟刷新快照时间。日志有输出不等于验收有进展;QA 行是已记录结果,不是最终验收结论。时间停止更新表示快照已过时,不能仅凭此认定搭建卡死。\n'; } // GitHub reports a job that reached its timeout-minutes as cancelled. The final // progress update is written while the run is still finishing, and it is never // replaced, so a runner timeout must not be labelled a manual cancel. A job // that ran to within five minutes of its own limit timed out. Keep these in // step with code-agent-task.yml (task-review7.test.mjs checks them). export const CORE_JOB_TIMEOUT_MINUTES = { agent: 360, 'verify-final': 90, publish: 15 }; export function jobOutcome(job) { if (job.conclusion !== 'cancelled') return job.conclusion; const limit = CORE_JOB_TIMEOUT_MINUTES[job.name]; const elapsed = Date.parse(job.completed_at) - Date.parse(job.started_at); return limit && Number.isFinite(elapsed) && elapsed >= (limit - 5) * 60_000 ? 'timed_out' : 'cancelled'; } // The run's own conclusion arrives once it completed (the workflow_run and // dispatched fallbacks read it then), and GitHub concludes a run whose job hit // its timeout as cancelled or failed. Refine those through the core jobs so an // agent that ran out of time is not reported as a manual cancel. export function progressOutcome(run, jobs) { const core = jobs.filter((j) => ['agent', 'verify-final', 'publish'].includes(j.name)); const conclusions = core.map(jobOutcome); const dispatched = core.some((j) => j.name === 'agent' && j.status === 'completed' && j.steps?.some((s) => s.name === 'Dispatch continuation run' && s.conclusion === 'success')); const outcome = taskOutcome(run, jobs); if (['cancelled', 'failure'].includes(outcome) && conclusions.includes('timed_out')) return 'timed_out'; return outcome || conclusions.find((conclusion) => ['failure', 'cancelled', 'timed_out'].includes(conclusion)) || (dispatched ? 'handoff' : null); } // The agent job stopped at its first step because the attempt was a GitHub // Re-run (code-agent-task.yml, "Reject a GitHub Re-run of this job"). export function rejectedRerun(jobs) { return jobs.some((j) => j.name === 'agent' && j.steps?.some((s) => s.name === 'Reject a GitHub Re-run of this job' && s.conclusion === 'failure')); } export async function publishProgress(api, repository, { runId, attempt, live }, now = Date.now()) { if (![runId, attempt].every(positive)) throw new Error('Invalid source run'); if (live) live = validateSnapshot(live, now); const repo = await api('GET', ''); const run = await api('GET', `/actions/runs/${runId}`); // An old attempt must never be attributed to a newer rerun. if (run.run_attempt !== attempt) return false; const issue = Number(/^Factory issue #(\d+) build /u.exec(run.display_title ?? run.name)?.[1]); if (run.id !== runId || run.run_attempt !== attempt || !positive(issue) || !positive(run.run_number) || run.path !== '.github/workflows/code-agent-task.yml' || run.head_branch !== repo.default_branch || run.head_repository?.full_name !== repository || !['issues', 'repository_dispatch', 'workflow_dispatch'].includes(run.event) || (live && (live.issue !== issue || live.runId !== runId || live.attempt !== attempt))) throw new Error('Not the requested factory task'); const jobs = await list(api, `/actions/runs/${runId}/attempts/${attempt}/jobs`, 'jobs'); if (!jobs.some((j) => j.name === 'prepare' && j.conclusion === 'success')) return false; // Read-only question runs must not replace the last business build's progress. if (jobs.some((j) => j.name === 'agent' && j.conclusion === 'skipped')) return false; // Nor may a rejected GitHub Re-run, which did no work: it would replace the // real attempt's phase, counters and label with an empty failure. if (rejectedRerun(jobs)) return false; if ((await api('GET', `/issues/${issue}`)).pull_request) throw new Error('Progress target must be an Issue'); const core = jobs.filter((j) => ['agent', 'verify-final', 'publish'].includes(j.name)); const outcome = progressOutcome(run, jobs); // A comment-reply job may still be running after delivery; do not occupy a // reporter runner waiting for it or confuse it with business build progress. const completed = Boolean(outcome); const comments = await list(api, `/issues/${issue}/comments`); const existing = comments.find((c) => savedRecord(c)); const prior = existing && savedRecord(existing); if (prior?.runId === runId && prior.attempt === attempt && prior.snapshot && (!live || (completed && prior.snapshot.sampledAt > live.sampledAt))) live = validateSnapshot(prior.snapshot, now); const activeJob = jobs.find((j) => ['verify-final', 'publish'].includes(j.name) && j.status === 'in_progress'); const label = live?.outcome === 'budget-exhausted' && outcome !== 'delivered' ? '已停止自动修复,待诊断' : completed ? (outcomeLabels[outcome] ?? '运行已结束,结果未确认') : activeJob ? (activeJob.name === 'verify-final' ? '独立终验进行中' : '发布业务 PR 进行中') : live?.outcome === 'handoff' ? '检查点已记录,续跑派发待确认' : live?.outcome === 'blocked' ? '阶段受阻,等待本 Run 收尾' : live?.outcome === 'failed' ? '阶段失败,等待本 Run 收尾' : live ? phases[live.phase] : '运行中,尚无阶段快照'; const record = { runId, attempt, runNumber: run.run_number, final: completed, sampledAt: completed || !live ? now : live.sampledAt, startedAt: Date.parse(run.run_started_at), endedAt: completed ? Math.max(...core.map((j) => Date.parse(j.completed_at)).filter(Number.isFinite), Date.parse(run.updated_at)) : null, lastCheck: live?.qa?.lastCheck ? { ...live.qa.lastCheck, round: live.verificationAttempts } : prior?.runId === runId && prior.attempt === attempt ? prior.lastCheck ?? null : null, label, snapshot: live ?? null }; if (!Number.isFinite(record.startedAt) || (record.endedAt !== null && !Number.isFinite(record.endedAt))) throw new Error('Invalid source start time'); // Completed deliveries are immutable to late live events; replay is idempotent. if (prior?.final && prior.runId === runId && prior.attempt === attempt) return false; if (!isNewer(record, prior)) return false; const body = renderProgress(record, repository); await api(existing ? 'PATCH' : 'POST', existing ? `/issues/comments/${existing.id}` : `/issues/${issue}/comments`, { body }); return true; } async function watch(root, metadataFile, api) { const metadata = readJson(metadataFile); const identity = { issue: metadata.issue.number, runId: Number(process.env.GITHUB_RUN_ID), attempt: Number(process.env.GITHUB_RUN_ATTEMPT) }; const controller = new AbortController(); let stopping = false, previous, lastAttempt = 0; const stop = () => { stopping = true; controller.abort(); }; process.once('SIGTERM', stop); process.once('SIGINT', stop); writeFileSync(path.join(root, 'live-progress.pid'), String(process.pid)); const send = async (force = false) => { try { const current = snapshot(root, metadata, identity); if (!force && !shouldSend(previous, current, lastAttempt, Date.now())) return; lastAttempt = Date.now(); await api('POST', '/dispatches', { event_type: 'factory-progress', client_payload: { snapshot: current } }); previous = current; } catch (error) { console.warn(`Progress notification unavailable: ${error.message}`); } }; try { while (!stopping) { await send(); if (!stopping) await sleep(10_000, undefined, { signal: controller.signal }).catch(() => {}); } await send(true); } finally { writeFileSync(path.join(root, 'live-progress.stopped'), 'stopped'); } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [mode, root, metadata] = process.argv.slice(2); // Even startup/stop failures are non-blocking to the business pipeline. try { if (mode === 'stop') { const pidFile = path.join(root, 'live-progress.pid'); if (existsSync(pidFile)) { const pid = Number(readFileSync(pidFile, 'utf8')); if (!positive(pid)) throw new Error('Invalid progress observer PID'); try { process.kill(pid, 'SIGTERM'); } catch (e) { if (e.code !== 'ESRCH') throw e; } for (let i = 0; i < 12 && !existsSync(path.join(root, 'live-progress.stopped')); i++) await sleep(1_000); } } else { const repository = process.env.GITHUB_REPOSITORY; const api = githubApi(repository, process.env.GITHUB_TOKEN); if (mode === 'watch') await watch(root, metadata, api); else if (mode === 'publish') { const event = readJson(process.env.GITHUB_EVENT_PATH); const live = event.client_payload?.snapshot; await publishProgress(api, repository, { runId: Number(live?.runId ?? event.workflow_run?.id ?? event.inputs?.run_id), attempt: Number(live?.attempt ?? event.workflow_run?.run_attempt ?? event.inputs?.attempt), live, }); } else throw new Error('Usage: task-progress.mjs watch ROOT METADATA | stop ROOT | publish'); } } catch (error) { console.warn(`::warning::Live progress unavailable: ${error.message}`); if (mode === 'publish') process.exitCode = 1; // Report workflow fails visibly, never the source build. } }